diff --git a/Cargo.lock b/Cargo.lock index 8d228ce..5459620 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2455,7 +2455,7 @@ checksum = "2195bf6aa996a481483b29d62a7663eed3fe39600c460e323f8ff41e90bdd89b" [[package]] name = "mycel" -version = "0.2.0" +version = "0.3.0" dependencies = [ "axum", "blake3", diff --git a/Cargo.toml b/Cargo.toml index 6148854..e4bd77f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mycel" -version = "0.2.0" +version = "0.3.0" edition = "2024" description = "A fast, decentralized web crawler, indexer, and search engine" license = "AGPL-3.0-only" diff --git a/docs/MANUAL.md b/docs/MANUAL.md index 5d4dd49..3abf86a 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -245,6 +245,12 @@ process start; nothing reloads live. | `bind` | `"127.0.0.1:8080"` | HTTP listen address for `mycel run`. The API has no authentication or TLS; keep it on localhost or put a reverse proxy in front. | | `page_size` | `10` | Results per page, for the API, the web UI, and `mycel search`. There is no per-request size parameter. | +### `[admin]` + +| key | default | meaning | +|---|---|---| +| `allowed_hosts` | `[]` | Extra `Host` header values accepted on `/admin`, beyond `api.bind` and its loopback equivalents; needed when `api.bind` is a wildcard like `0.0.0.0:8080`. See [the admin page](#get-admin-the-admin-page). | + ### `[federation]` | key | default | meaning | @@ -284,7 +290,8 @@ Allowlist changes require a restart. ### Validation At startup mycel rejects: unknown keys anywhere; `crawl.scope` other than -`"host"`; empty `index.languages`; `crawl.concurrency = 0`; a +`"host"`; empty `index.languages`; `crawl.concurrency = 0`; an +`admin.allowed_hosts` entry that is empty or contains `/` or whitespace; a `federation.preset` other than `"n0"`/`"empty"`; a peer `id` that is not 64 hex chars; a peer `addr` that is not `ip:port`. @@ -544,6 +551,18 @@ not authentication: anything that can already send local HTTP can read the token from the page. The API's trust model is unchanged (keep it on localhost or behind a reverse proxy; a proxy must forward a matching `Host`). +If `api.bind` is a wildcard like `0.0.0.0:PORT`, reaching `/admin` by LAN +IP or hostname requires listing those Host values in `admin.allowed_hosts`: + +```toml +[admin] +allowed_hosts = ["mynode.lan:8080", "192.168.1.50:8080"] +``` + +The match stays exact and case-insensitive, so a web page you happen to +visit still cannot drive the node. It is not authentication: anyone who +can reach the port under a listed name can. + ### `GET /healthz` Round-trips the storage pipeline (1 s budget). Healthy: diff --git a/docs/SPEC.md b/docs/SPEC.md index edf06f3..7a26e96 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -116,6 +116,9 @@ shard_mb = 1024 # seal open shard at ~1 GiB bind = "127.0.0.1:8080" page_size = 10 +[admin] +allowed_hosts = [] # extra Host headers accepted on /admin (post-v1 extension) + [federation] enabled = false # peerless default: no socket bound, nothing published fanout = true diff --git a/site/index.html b/site/index.html index 3bf2db3..dbbeaff 100644 --- a/site/index.html +++ b/site/index.html @@ -57,7 +57,7 @@

stract

Open-source web search in Rust. Essentially one developer. Archived by its owner; no successor.

-

what grew back: survivability as architecture. ~6,300 lines, 27 boring dependencies, and data formats that stay valuable even if development pauses for a year.

+

what grew back: survivability as architecture. ~8,500 lines, 27 boring dependencies, and data formats that stay valuable even if development pauses for a year.

@@ -239,7 +239,7 @@

instrument 03 the merge

1binary
27dependencies
-
~6,300lines of rust
+
~8,500lines of rust
0servers required
diff --git a/src/admin.rs b/src/admin.rs index ad6195b..a91b8ad 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -58,6 +58,12 @@ impl AdminState { allowed_hosts.push(v); } } + for h in &cfg.admin.allowed_hosts { + let h = h.to_ascii_lowercase(); + if !allowed_hosts.contains(&h) { + allowed_hosts.push(h); + } + } Self { cfg, cfg_path, diff --git a/src/config.rs b/src/config.rs index fa09cc6..36b23ce 100644 --- a/src/config.rs +++ b/src/config.rs @@ -38,6 +38,9 @@ pub const DEFAULT_CONFIG_TOML: &str = r#"# mycel configuration. Every value belo # bind = "127.0.0.1:8080" # page_size = 10 +[admin] +# allowed_hosts = [] # extra Host headers accepted on /admin, e.g. ["mycel.lan:8080"] + [federation] # enabled = false # peerless default: no socket bound, nothing published # fanout = true @@ -67,6 +70,7 @@ pub struct Config { pub rank: RankCfg, pub warc: WarcCfg, pub api: ApiCfg, + pub admin: AdminCfg, pub federation: FederationCfg, pub sync: SyncCfg, pub bootstrap: BootstrapCfg, @@ -168,6 +172,14 @@ impl Default for ApiCfg { } } +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct AdminCfg { + /// Extra Host headers accepted on /admin, beyond api.bind and its + /// loopback aliases (the DNS-rebinding guard's allowlist). + pub allowed_hosts: Vec, +} + #[derive(Debug, Clone, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct FederationCfg { @@ -280,6 +292,13 @@ impl Config { if self.crawl.concurrency == 0 { return Err("crawl.concurrency must be > 0".into()); } + for h in &self.admin.allowed_hosts { + if h.is_empty() || h.contains('/') || h.contains(char::is_whitespace) { + return Err( + format!("admin.allowed_hosts entries must be host[:port] (got {h:?})").into(), + ); + } + } if !matches!(self.federation.preset.as_str(), "n0" | "empty") { return Err(format!( "federation.preset must be \"n0\" or \"empty\" (got {:?})", @@ -359,6 +378,25 @@ mod tests { assert!(toml::from_str::(bad).unwrap().validate().is_err()); } + #[test] + fn admin_allowed_hosts_defaults_empty() { + let cfg: Config = toml::from_str("").unwrap(); + assert!(cfg.admin.allowed_hosts.is_empty()); + let cfg: Config = + toml::from_str("[admin]\nallowed_hosts = [\"mycel.lan:8080\"]\n").unwrap(); + assert_eq!(cfg.admin.allowed_hosts, vec!["mycel.lan:8080"]); + } + + #[test] + fn admin_allowed_hosts_validated() { + let good = "[admin]\nallowed_hosts = [\"mycel.lan:8080\", \"[::1]:8080\"]\n"; + toml::from_str::(good).unwrap().validate().unwrap(); + for bad in ["", " ", "http://mycel.lan:8080", "mycel.lan:8080/admin"] { + let cfg = format!("[admin]\nallowed_hosts = [{bad:?}]\n"); + assert!(toml::from_str::(&cfg).unwrap().validate().is_err()); + } + } + #[test] fn overrides_apply() { let cfg: Config = diff --git a/tests/admin_ui.rs b/tests/admin_ui.rs index 142f800..70f4c0d 100644 --- a/tests/admin_ui.rs +++ b/tests/admin_ui.rs @@ -65,7 +65,9 @@ fn admin_page_drives_the_node() { let port = free_tcp_port(); let base_cfg = format!( "data_dir = \"{}\"\n[crawl]\ncontact_url = \"http://example.com/test\"\n\ - [index]\ncommit_secs = 1\n[api]\nbind = \"127.0.0.1:{port}\"\n", + [index]\ncommit_secs = 1\n\ + [admin]\nallowed_hosts = [\"extra.example:{port}\"]\n\ + [api]\nbind = \"127.0.0.1:{port}\"\n", dir.join("data").display() ); std::fs::write(dir.join("mycel.toml"), &base_cfg).unwrap(); @@ -107,6 +109,14 @@ fn admin_page_drives_the_node() { let (code, _) = http(&["-H", "Host: evil.example:1", &format!("{api}/admin")]); assert_eq!(code, 403, "foreign Host header must be refused"); + // admin.allowed_hosts admits an extra Host value beyond api.bind + loopback. + let (code, _) = http(&[ + "-H", + &format!("Host: extra.example:{port}"), + &format!("{api}/admin"), + ]); + assert_eq!(code, 200, "admin.allowed_hosts entry must be accepted"); + // Seed through the writer: hosts activate, roots enqueue (.invalid never // resolves, so the crawler generates no real traffic). let (code, _) = http(&[