From 2d7a4f1766a1aa4f31bf1ab28a5adc1be1400fe8 Mon Sep 17 00:00:00 2001 From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:11:12 +0200 Subject: [PATCH] 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 --- ARCHIVR-MENTAL-MODEL.md | 2 +- crates/archivr-server/src/main.rs | 27 +++++++++++++++++++--- crates/archivr-server/src/registry.rs | 32 +++++++++++++++++++++++++++ crates/archivr-server/src/routes.rs | 31 ++++++++++++++++++++++++++ docs/README.md | 30 +++++++++++++++++++++++++ 5 files changed, 118 insertions(+), 4 deletions(-) diff --git a/ARCHIVR-MENTAL-MODEL.md b/ARCHIVR-MENTAL-MODEL.md index 3ce03f6..37a1100 100644 --- a/ARCHIVR-MENTAL-MODEL.md +++ b/ARCHIVR-MENTAL-MODEL.md @@ -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. -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. diff --git a/crates/archivr-server/src/main.rs b/crates/archivr-server/src/main.rs index 13eb826..47f9532 100644 --- a/crates/archivr-server/src/main.rs +++ b/crates/archivr-server/src/main.rs @@ -1,9 +1,11 @@ mod registry; mod routes; -use anyhow::Result; +use anyhow::{Context, Result}; use std::{net::SocketAddr, path::PathBuf}; +const DEFAULT_BIND: &str = "127.0.0.1:8080"; + #[tokio::main] async fn main() -> Result<()> { let config_path = std::env::args() @@ -11,8 +13,27 @@ async fn main() -> Result<()> { .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from("archivr-server.toml")); let registry = registry::load_registry(&config_path)?; - let app = routes::app(registry); - let addr = SocketAddr::from(([127, 0, 0, 1], 8080)); + let app = routes::app(registry.clone()); + + // 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?; println!("archivr-server listening on http://{addr}"); axum::serve(listener, app).await?; diff --git a/crates/archivr-server/src/registry.rs b/crates/archivr-server/src/registry.rs index c186d01..a9fef6c 100644 --- a/crates/archivr-server/src/registry.rs +++ b/crates/archivr-server/src/registry.rs @@ -14,7 +14,12 @@ pub struct MountedArchive { #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] pub struct ServerRegistry { + #[serde(default)] pub archives: Vec, + /// 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, } pub fn load_registry(path: &Path) -> Result { @@ -78,6 +83,7 @@ mod tests { label: "Personal".to_string(), archive_path: archive_path.clone(), }], + bind: None, }; let path = temp.path().join("server.toml"); save_registry(&path, ®istry).unwrap(); @@ -102,10 +108,36 @@ mod tests { archive_path: PathBuf::from("/tmp/b/.archivr"), }, ], + bind: None, }; let err = validate_registry(®istry).unwrap_err().to_string(); 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()); + } } diff --git a/crates/archivr-server/src/routes.rs b/crates/archivr-server/src/routes.rs index a8ed252..014544a 100644 --- a/crates/archivr-server/src/routes.rs +++ b/crates/archivr-server/src/routes.rs @@ -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 archivr_core::{archive, capture, database}; @@ -301,6 +325,7 @@ mod tests { label: "Personal".to_string(), archive_path: std::path::PathBuf::from("/tmp/personal/.archivr"), }], + bind: None, }; let response = app(registry) .oneshot( @@ -361,6 +386,7 @@ mod tests { label: "Test".to_string(), archive_path, }], + bind: None, }; let response = app(registry) .oneshot( @@ -391,6 +417,7 @@ mod tests { label: "Test".to_string(), archive_path, }], + bind: None, }; let response = app(registry) .oneshot( @@ -483,6 +510,7 @@ mod tests { label: "Test".to_string(), archive_path: paths.archive_path.clone(), }], + bind: None, }; let uri = format!( "/api/archives/test/entries/{}/artifacts/0", @@ -526,6 +554,7 @@ mod tests { label: "Test".to_string(), archive_path, }], + bind: None, }; let response = app(registry) .oneshot( @@ -556,6 +585,7 @@ mod tests { label: "Test".to_string(), archive_path, }], + bind: None, }; let response = app(registry) .oneshot( @@ -585,6 +615,7 @@ mod tests { label: "Test".to_string(), archive_path: paths.archive_path.clone(), }], + bind: None, }; (registry, paths.archive_path) } diff --git a/docs/README.md b/docs/README.md index 7c35eea..7917645 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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`. +### 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 - Local files: `file:///absolute/path/to/file.ext`