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(&[