mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
feat(server): configurable bind address with loopback default and non-loopback warning
- Add optional `bind` field to ServerRegistry (TOML + ARCHIVR_BIND env var) - Default bind address remains 127.0.0.1:8080; non-loopback prints a warning - Add route security classification comment block (READ/ADMIN/WRITE/STATIC) - Add Security and Deployment section to docs/README.md - Replace vague auth note in ARCHIVR-MENTAL-MODEL.md with concrete model description - Add three registry tests covering bind field round-trip and defaults
This commit is contained in:
parent
10c41ef84f
commit
2d7a4f1766
5 changed files with 118 additions and 4 deletions
|
|
@ -164,6 +164,6 @@ The web server reads archive data and serves the UI. It does not yet implement c
|
||||||
|
|
||||||
Search is currently simple client-side filtering.
|
Search is currently simple client-side filtering.
|
||||||
|
|
||||||
Auth is not a production model yet.
|
**Auth and session model:** The server binds to `127.0.0.1` by default and has no authentication middleware. This is intentional — Archivr is a local tool. The bind address is configurable via the TOML `bind` field or `ARCHIVR_BIND` env var; a non-loopback address triggers a startup warning. Route families are classified (READ / ADMIN / WRITE / STATIC) in `crates/archivr-server/src/routes.rs` as the decision record for when middleware is eventually added. See the "Security and Deployment" section in `docs/README.md`.
|
||||||
|
|
||||||
Admin is a mounted-archives view, not a management system.
|
Admin is a mounted-archives view, not a management system.
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
mod registry;
|
mod registry;
|
||||||
mod routes;
|
mod routes;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::{Context, Result};
|
||||||
use std::{net::SocketAddr, path::PathBuf};
|
use std::{net::SocketAddr, path::PathBuf};
|
||||||
|
|
||||||
|
const DEFAULT_BIND: &str = "127.0.0.1:8080";
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
let config_path = std::env::args()
|
let config_path = std::env::args()
|
||||||
|
|
@ -11,8 +13,27 @@ async fn main() -> Result<()> {
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.unwrap_or_else(|| PathBuf::from("archivr-server.toml"));
|
.unwrap_or_else(|| PathBuf::from("archivr-server.toml"));
|
||||||
let registry = registry::load_registry(&config_path)?;
|
let registry = registry::load_registry(&config_path)?;
|
||||||
let app = routes::app(registry);
|
let app = routes::app(registry.clone());
|
||||||
let addr = SocketAddr::from(([127, 0, 0, 1], 8080));
|
|
||||||
|
// Bind address priority: ARCHIVR_BIND env var > TOML bind field > default loopback.
|
||||||
|
let bind_str = std::env::var("ARCHIVR_BIND")
|
||||||
|
.ok()
|
||||||
|
.or_else(|| registry.bind.clone())
|
||||||
|
.unwrap_or_else(|| DEFAULT_BIND.to_string());
|
||||||
|
|
||||||
|
let addr: SocketAddr = bind_str
|
||||||
|
.parse()
|
||||||
|
.with_context(|| format!("invalid bind address: {bind_str}"))?;
|
||||||
|
|
||||||
|
// Warn when the server is reachable beyond localhost — it has no authentication.
|
||||||
|
if !addr.ip().is_loopback() {
|
||||||
|
eprintln!(
|
||||||
|
"warn: archivr-server is bound to {addr} — \
|
||||||
|
this server has no authentication. \
|
||||||
|
Only expose it on a trusted network."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
println!("archivr-server listening on http://{addr}");
|
println!("archivr-server listening on http://{addr}");
|
||||||
axum::serve(listener, app).await?;
|
axum::serve(listener, app).await?;
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,12 @@ pub struct MountedArchive {
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||||
pub struct ServerRegistry {
|
pub struct ServerRegistry {
|
||||||
|
#[serde(default)]
|
||||||
pub archives: Vec<MountedArchive>,
|
pub archives: Vec<MountedArchive>,
|
||||||
|
/// Optional bind address for the server. Defaults to `127.0.0.1:8080`.
|
||||||
|
/// Set this to `0.0.0.0:8080` only on trusted networks — the server has no authentication.
|
||||||
|
#[serde(default)]
|
||||||
|
pub bind: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_registry(path: &Path) -> Result<ServerRegistry> {
|
pub fn load_registry(path: &Path) -> Result<ServerRegistry> {
|
||||||
|
|
@ -78,6 +83,7 @@ mod tests {
|
||||||
label: "Personal".to_string(),
|
label: "Personal".to_string(),
|
||||||
archive_path: archive_path.clone(),
|
archive_path: archive_path.clone(),
|
||||||
}],
|
}],
|
||||||
|
bind: None,
|
||||||
};
|
};
|
||||||
let path = temp.path().join("server.toml");
|
let path = temp.path().join("server.toml");
|
||||||
save_registry(&path, ®istry).unwrap();
|
save_registry(&path, ®istry).unwrap();
|
||||||
|
|
@ -102,10 +108,36 @@ mod tests {
|
||||||
archive_path: PathBuf::from("/tmp/b/.archivr"),
|
archive_path: PathBuf::from("/tmp/b/.archivr"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
bind: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let err = validate_registry(®istry).unwrap_err().to_string();
|
let err = validate_registry(®istry).unwrap_err().to_string();
|
||||||
|
|
||||||
assert!(err.contains("duplicate archive id"));
|
assert!(err.contains("duplicate archive id"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_bind_field_round_trips() {
|
||||||
|
let toml = r#"bind = "127.0.0.1:9090""#;
|
||||||
|
let registry: ServerRegistry = toml::from_str(toml).unwrap();
|
||||||
|
assert_eq!(registry.bind.as_deref(), Some("127.0.0.1:9090"));
|
||||||
|
assert!(registry.archives.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_bind_field_defaults_to_none_when_absent() {
|
||||||
|
let toml = r#""#;
|
||||||
|
let registry: ServerRegistry = toml::from_str(toml).unwrap();
|
||||||
|
assert!(registry.bind.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_bind_field_does_not_affect_archive_validation() {
|
||||||
|
let registry = ServerRegistry {
|
||||||
|
archives: vec![],
|
||||||
|
bind: Some("0.0.0.0:8080".to_string()),
|
||||||
|
};
|
||||||
|
// validate_registry does not reject non-loopback bind — that's main's concern.
|
||||||
|
assert!(validate_registry(®istry).is_ok());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,27 @@
|
||||||
|
// ── Security Boundary ────────────────────────────────────────────────────────
|
||||||
|
// All routes are currently trusted-local: no authentication or authorization
|
||||||
|
// middleware is applied. The server is designed to bind on 127.0.0.1 only.
|
||||||
|
//
|
||||||
|
// Route classification (for when middleware is added later):
|
||||||
|
//
|
||||||
|
// STATIC — safe to expose publicly: GET / and static /assets/*
|
||||||
|
// READ — safe to expose read-only: GET /health
|
||||||
|
// GET /api/archives
|
||||||
|
// GET /api/archives/:id/entries
|
||||||
|
// GET /api/archives/:id/entries/search
|
||||||
|
// GET /api/archives/:id/entries/:uid
|
||||||
|
// GET /api/archives/:id/entries/:uid/artifacts/:idx
|
||||||
|
// GET /api/archives/:id/runs
|
||||||
|
// GET /api/archives/:id/tags
|
||||||
|
// ADMIN — requires auth if ever public: GET /api/admin/archives
|
||||||
|
// WRITE — requires auth if ever public: POST /api/archives/:id/captures
|
||||||
|
// POST /api/archives/:id/tags
|
||||||
|
// PUT /api/archives/:id/tags/:tag_id
|
||||||
|
// DELETE /api/archives/:id/tags/:tag_id
|
||||||
|
//
|
||||||
|
// Do not add middleware here until the auth model is chosen. See docs/README.md.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
use std::{path::PathBuf, sync::Arc};
|
use std::{path::PathBuf, sync::Arc};
|
||||||
|
|
||||||
use archivr_core::{archive, capture, database};
|
use archivr_core::{archive, capture, database};
|
||||||
|
|
@ -301,6 +325,7 @@ mod tests {
|
||||||
label: "Personal".to_string(),
|
label: "Personal".to_string(),
|
||||||
archive_path: std::path::PathBuf::from("/tmp/personal/.archivr"),
|
archive_path: std::path::PathBuf::from("/tmp/personal/.archivr"),
|
||||||
}],
|
}],
|
||||||
|
bind: None,
|
||||||
};
|
};
|
||||||
let response = app(registry)
|
let response = app(registry)
|
||||||
.oneshot(
|
.oneshot(
|
||||||
|
|
@ -361,6 +386,7 @@ mod tests {
|
||||||
label: "Test".to_string(),
|
label: "Test".to_string(),
|
||||||
archive_path,
|
archive_path,
|
||||||
}],
|
}],
|
||||||
|
bind: None,
|
||||||
};
|
};
|
||||||
let response = app(registry)
|
let response = app(registry)
|
||||||
.oneshot(
|
.oneshot(
|
||||||
|
|
@ -391,6 +417,7 @@ mod tests {
|
||||||
label: "Test".to_string(),
|
label: "Test".to_string(),
|
||||||
archive_path,
|
archive_path,
|
||||||
}],
|
}],
|
||||||
|
bind: None,
|
||||||
};
|
};
|
||||||
let response = app(registry)
|
let response = app(registry)
|
||||||
.oneshot(
|
.oneshot(
|
||||||
|
|
@ -483,6 +510,7 @@ mod tests {
|
||||||
label: "Test".to_string(),
|
label: "Test".to_string(),
|
||||||
archive_path: paths.archive_path.clone(),
|
archive_path: paths.archive_path.clone(),
|
||||||
}],
|
}],
|
||||||
|
bind: None,
|
||||||
};
|
};
|
||||||
let uri = format!(
|
let uri = format!(
|
||||||
"/api/archives/test/entries/{}/artifacts/0",
|
"/api/archives/test/entries/{}/artifacts/0",
|
||||||
|
|
@ -526,6 +554,7 @@ mod tests {
|
||||||
label: "Test".to_string(),
|
label: "Test".to_string(),
|
||||||
archive_path,
|
archive_path,
|
||||||
}],
|
}],
|
||||||
|
bind: None,
|
||||||
};
|
};
|
||||||
let response = app(registry)
|
let response = app(registry)
|
||||||
.oneshot(
|
.oneshot(
|
||||||
|
|
@ -556,6 +585,7 @@ mod tests {
|
||||||
label: "Test".to_string(),
|
label: "Test".to_string(),
|
||||||
archive_path,
|
archive_path,
|
||||||
}],
|
}],
|
||||||
|
bind: None,
|
||||||
};
|
};
|
||||||
let response = app(registry)
|
let response = app(registry)
|
||||||
.oneshot(
|
.oneshot(
|
||||||
|
|
@ -585,6 +615,7 @@ mod tests {
|
||||||
label: "Test".to_string(),
|
label: "Test".to_string(),
|
||||||
archive_path: paths.archive_path.clone(),
|
archive_path: paths.archive_path.clone(),
|
||||||
}],
|
}],
|
||||||
|
bind: None,
|
||||||
};
|
};
|
||||||
(registry, paths.archive_path)
|
(registry, paths.archive_path)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,36 @@ http://127.0.0.1:8080
|
||||||
|
|
||||||
When installed through Nix, `archivr-server` is wrapped so it can find the static web UI assets automatically. The wrapper sets `ARCHIVR_STATIC_DIR` to the installed static asset directory. Running from source with `cargo run -p archivr-server` falls back to `crates/archivr-server/static`.
|
When installed through Nix, `archivr-server` is wrapped so it can find the static web UI assets automatically. The wrapper sets `ARCHIVR_STATIC_DIR` to the installed static asset directory. Running from source with `cargo run -p archivr-server` falls back to `crates/archivr-server/static`.
|
||||||
|
|
||||||
|
### Security and Deployment
|
||||||
|
|
||||||
|
`archivr-server` is a **local-only tool by default**. It binds to `127.0.0.1:8080` and has no authentication or access control. Do not expose it to a public network or a shared LAN without understanding the risks.
|
||||||
|
|
||||||
|
**Changing the bind address**
|
||||||
|
|
||||||
|
You can set the bind address in your TOML config:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# Optional. Default: 127.0.0.1:8080
|
||||||
|
# Only change this if you know what you are doing — the server has no authentication.
|
||||||
|
bind = "127.0.0.1:9090"
|
||||||
|
```
|
||||||
|
|
||||||
|
Or override it with the `ARCHIVR_BIND` environment variable:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ARCHIVR_BIND=127.0.0.1:9090 nix run .#archivr-server -- ./archivr-server.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
If the server is started with a non-loopback address (e.g. `0.0.0.0`), it prints a warning to stderr:
|
||||||
|
|
||||||
|
```text
|
||||||
|
warn: archivr-server is bound to 0.0.0.0:8080 — this server has no authentication. Only expose it on a trusted network.
|
||||||
|
```
|
||||||
|
|
||||||
|
**When will auth be added?**
|
||||||
|
|
||||||
|
Auth and session handling will be designed when remote or public hosting becomes a real requirement. Until then, keep the server on loopback. See `crates/archivr-server/src/routes.rs` for the route classification that will guide where middleware is applied.
|
||||||
|
|
||||||
### Supported Platforms
|
### Supported Platforms
|
||||||
|
|
||||||
- Local files: `file:///absolute/path/to/file.ext`
|
- Local files: `file:///absolute/path/to/file.ext`
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue