1
Fork 0
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:
TheGeneralist 2026-06-23 17:11:12 +02:00
parent 10c41ef84f
commit 2d7a4f1766
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
5 changed files with 118 additions and 4 deletions

View file

@ -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?;

View file

@ -14,7 +14,12 @@ pub struct MountedArchive {
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct ServerRegistry {
#[serde(default)]
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> {
@ -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, &registry).unwrap();
@ -102,10 +108,36 @@ mod tests {
archive_path: PathBuf::from("/tmp/b/.archivr"),
},
],
bind: None,
};
let err = validate_registry(&registry).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(&registry).is_ok());
}
}

View file

@ -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)
}