From d5a2a7541ac79cc4031ab30d2ab4fbe88eb5f34f Mon Sep 17 00:00:00 2001 From: Patrick Deuley Date: Mon, 6 Jul 2026 15:24:53 -0500 Subject: [PATCH 1/6] Add admin.allowed_hosts to let /admin accept LAN Host headers /admin's DNS-rebinding guard only ever accepted Host values derived from api.bind plus the loopback aliases. With api.bind = "0.0.0.0:PORT" (needed for any non-loopback deployment), the literal string "0.0.0.0:PORT" is never a real browser Host header, so /admin is unreachable by LAN IP or hostname with no way to opt in. Adds a new [admin] config section with an allowed_hosts list, additive to the existing api.bind/loopback allowlist. Same Host-header exact-match gate as before, just an operator-controlled list of extra values. --- docs/MANUAL.md | 19 +++++++++++++++++++ src/admin.rs | 9 ++++++++- src/config.rs | 31 +++++++++++++++++++++++++++++++ tests/admin_ui.rs | 11 ++++++++++- 4 files changed, 68 insertions(+), 2 deletions(-) diff --git a/docs/MANUAL.md b/docs/MANUAL.md index 5d4dd49..1f511e6 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`, on top of `api.bind` and its `127.0.0.1`/`localhost`/`[::1]` equivalents. Needed to reach `/admin` by LAN IP or hostname when `api.bind` is a wildcard address (e.g. `0.0.0.0:8080`). See "the admin page" above. | + ### `[federation]` | key | default | meaning | @@ -544,6 +550,19 @@ 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 `0.0.0.0:PORT` (or another wildcard/multi-address bind) and +you want to reach `/admin` by LAN IP or hostname rather than loopback, list +those Host values explicitly in `admin.allowed_hosts`: + +```toml +[admin] +allowed_hosts = ["mynode.lan:8080", "192.168.1.50:8080"] +``` + +This is additive to the api.bind/loopback defaults, not a replacement, and +still requires an exact (case-insensitive) `Host` match — it does not open +`/admin` to arbitrary hosts. + ### `GET /healthz` Round-trips the storage pipeline (1 s budget). Healthy: diff --git a/src/admin.rs b/src/admin.rs index ad6195b..8b27813 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -36,7 +36,8 @@ pub struct AdminState { pub started_at: i64, /// Per-boot token embedded in every form; POSTs without it are refused. csrf: String, - /// Host headers accepted on /admin routes (DNS-rebinding guard). + /// Host headers accepted on /admin routes (DNS-rebinding guard): + /// api.bind + loopback aliases, plus any admin.allowed_hosts overrides. allowed_hosts: Vec, job: Mutex>, pub index_tx: std::sync::mpsc::Sender, @@ -58,6 +59,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..932b524 100644 --- a/src/config.rs +++ b/src/config.rs @@ -38,6 +38,10 @@ 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, beyond api.bind + loopback + # (DNS-rebinding guard allowlist); e.g. ["mycel.lan:8080"] for LAN access + [federation] # enabled = false # peerless default: no socket bound, nothing published # fanout = true @@ -67,6 +71,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 +173,23 @@ impl Default for ApiCfg { } } +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct AdminCfg { + /// Extra Host header values accepted on /admin routes, on top of + /// api.bind and the loopback aliases (DNS-rebinding guard allowlist). + /// Needed to reach /admin by LAN IP or hostname rather than loopback. + pub allowed_hosts: Vec, +} + +impl Default for AdminCfg { + fn default() -> Self { + Self { + allowed_hosts: Vec::new(), + } + } +} + #[derive(Debug, Clone, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct FederationCfg { @@ -359,6 +381,15 @@ 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 overrides_apply() { let cfg: Config = diff --git a/tests/admin_ui.rs b/tests/admin_ui.rs index 142f800..d39293a 100644 --- a/tests/admin_ui.rs +++ b/tests/admin_ui.rs @@ -65,7 +65,8 @@ 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[api]\nbind = \"127.0.0.1:{port}\"\n\ + [admin]\nallowed_hosts = [\"extra.example:{port}\"]\n", dir.join("data").display() ); std::fs::write(dir.join("mycel.toml"), &base_cfg).unwrap(); @@ -107,6 +108,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(&[ From d99eea031af5db607662ad30eeb785c8adb2fde7 Mon Sep 17 00:00:00 2001 From: Patrick Deuley Date: Mon, 6 Jul 2026 15:46:15 -0500 Subject: [PATCH 2/6] Fix test config ordering and derive AdminCfg's Default - tests/admin_ui.rs: the valid-config-save assertion appends "page_size = 7\n" directly onto base_cfg assuming [api] is the last open table; my [admin] section landed after [api] and broke that. Moved [admin] before [api] in base_cfg. - config.rs: clippy (derivable_impls) flagged the manual Default impl for AdminCfg since it's just Vec::new(); derive it instead. Verified locally: cargo fmt --check, cargo clippy --all-targets -- -D warnings, and cargo test (63 tests) all pass clean. --- src/config.rs | 14 +++----------- tests/admin_ui.rs | 5 +++-- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/config.rs b/src/config.rs index 932b524..6e6a375 100644 --- a/src/config.rs +++ b/src/config.rs @@ -173,7 +173,7 @@ impl Default for ApiCfg { } } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Default, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct AdminCfg { /// Extra Host header values accepted on /admin routes, on top of @@ -182,14 +182,6 @@ pub struct AdminCfg { pub allowed_hosts: Vec, } -impl Default for AdminCfg { - fn default() -> Self { - Self { - allowed_hosts: Vec::new(), - } - } -} - #[derive(Debug, Clone, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct FederationCfg { @@ -385,8 +377,8 @@ mod tests { 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(); + let cfg: Config = + toml::from_str("[admin]\nallowed_hosts = [\"mycel.lan:8080\"]\n").unwrap(); assert_eq!(cfg.admin.allowed_hosts, vec!["mycel.lan:8080"]); } diff --git a/tests/admin_ui.rs b/tests/admin_ui.rs index d39293a..70f4c0d 100644 --- a/tests/admin_ui.rs +++ b/tests/admin_ui.rs @@ -65,8 +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\ - [admin]\nallowed_hosts = [\"extra.example:{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(); From e2725a2dfcc1972ce1442d7e97447f3fbc95eaba Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:21:36 -0700 Subject: [PATCH 3/6] Validate admin.allowed_hosts entries; true up SPEC and MANUAL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes on top of the allowed_hosts feature: - config.rs: reject allowed_hosts entries that are empty or contain '/' or whitespace. Such a value can never equal a real Host header, so the misconfiguration would otherwise present as the same 403 the operator is trying to escape, with no signal. The check runs on both config paths (startup load and the admin editor validate through the same fn). IPv6 literals like "[::1]:8080" stay legal, pinned by test. - docs/SPEC.md §3 claims "all defaults shown"; add the [admin] section so that stays true (marked post-v1 like the /admin surface in §10). - docs/MANUAL.md: turn the "see the admin page above" cross-reference into the anchor link used elsewhere (the section is below, not above); document the new validation rule; state the exposure plainly (a listed name admits everyone on the network to every operation, CSRF token included; it is not authentication); drop an em dash per the repo prose convention. - config.rs: collapse the [admin] template comment to one line to match every other option in DEFAULT_CONFIG_TOML. Gates: cargo fmt --check, cargo clippy --all-targets -- -D warnings, cargo test (64 tests) all green; the reject path also exercised against the real binary (a scheme-bearing entry fails startup with the new message). --- docs/MANUAL.md | 18 ++++++++++++------ docs/SPEC.md | 3 +++ src/config.rs | 25 +++++++++++++++++++++++-- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/docs/MANUAL.md b/docs/MANUAL.md index 1f511e6..f998d70 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -249,7 +249,7 @@ process start; nothing reloads live. | key | default | meaning | |---|---|---| -| `allowed_hosts` | `[]` | Extra `Host` header values accepted on `/admin`, on top of `api.bind` and its `127.0.0.1`/`localhost`/`[::1]` equivalents. Needed to reach `/admin` by LAN IP or hostname when `api.bind` is a wildcard address (e.g. `0.0.0.0:8080`). See "the admin page" above. | +| `allowed_hosts` | `[]` | Extra `Host` header values accepted on `/admin`, on top of `api.bind` and its `127.0.0.1`/`localhost`/`[::1]` equivalents. Needed to reach `/admin` by LAN IP or hostname when `api.bind` is a wildcard address (e.g. `0.0.0.0:8080`). See [the admin page](#get-admin-the-admin-page). | ### `[federation]` @@ -290,9 +290,11 @@ 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 -`federation.preset` other than `"n0"`/`"empty"`; a peer `id` that is not 64 -hex chars; a peer `addr` that is not `ip:port`. +`"host"`; empty `index.languages`; `crawl.concurrency = 0`; an +`admin.allowed_hosts` entry that is empty or contains `/` or whitespace +(it could never equal a real `Host` header, so it would silently never +match); a `federation.preset` other than `"n0"`/`"empty"`; a peer `id` +that is not 64 hex chars; a peer `addr` that is not `ip:port`. ## 6. Command reference @@ -560,8 +562,12 @@ allowed_hosts = ["mynode.lan:8080", "192.168.1.50:8080"] ``` This is additive to the api.bind/loopback defaults, not a replacement, and -still requires an exact (case-insensitive) `Host` match — it does not open -`/admin` to arbitrary hosts. +still requires an exact (case-insensitive) `Host` match, so a web page you +happen to visit still cannot drive the node through a name you did not +list. It is not authentication: anyone who can reach the port and send a +listed name gets the page, its CSRF token, and every operation on it. If +the network is not trusted, front the node with an authenticating reverse +proxy instead. ### `GET /healthz` 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/src/config.rs b/src/config.rs index 6e6a375..bac79f7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -39,8 +39,7 @@ pub const DEFAULT_CONFIG_TOML: &str = r#"# mycel configuration. Every value belo # page_size = 10 [admin] -# allowed_hosts = [] # extra Host headers accepted on /admin, beyond api.bind + loopback - # (DNS-rebinding guard allowlist); e.g. ["mycel.lan:8080"] for LAN access +# allowed_hosts = [] # extra Host headers accepted on /admin, e.g. ["mycel.lan:8080"] [federation] # enabled = false # peerless default: no socket bound, nothing published @@ -294,6 +293,15 @@ impl Config { if self.crawl.concurrency == 0 { return Err("crawl.concurrency must be > 0".into()); } + // An entry that can never equal a Host header (scheme, path, + // whitespace, empty) would otherwise just silently never match. + 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 {:?})", @@ -380,6 +388,19 @@ mod tests { let cfg: Config = toml::from_str("[admin]\nallowed_hosts = [\"mycel.lan:8080\"]\n").unwrap(); assert_eq!(cfg.admin.allowed_hosts, vec!["mycel.lan:8080"]); + cfg.validate().unwrap(); + } + + #[test] + fn admin_allowed_hosts_validated() { + // IPv6 literals stay legal: the check must not tighten past "Host header". + let good: Config = toml::from_str("[admin]\nallowed_hosts = [\"[::1]:8080\"]\n").unwrap(); + good.validate().unwrap(); + for bad in ["", " ", "http://mycel.lan:8080", "mycel.lan:8080/admin"] { + let cfg: Config = + toml::from_str(&format!("[admin]\nallowed_hosts = [{bad:?}]\n")).unwrap(); + assert!(cfg.validate().is_err(), "{bad:?} must be rejected"); + } } #[test] From ee0e675bfb4d3b58e6cc05f02fc5d482794fb7ce Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:43:58 -0700 Subject: [PATCH 4/6] Tighten allowed_hosts prose and tests to house style No behavior change. Drop the rationale comments (validate()'s checks carry none), reshape the validation test after peer_id_validated, revert the AdminState field doc to its original line, and cut the MANUAL additions down to the delta: the trust-model and loopback details they repeated already live in the adjacent paragraphs and linked section. --- docs/MANUAL.md | 24 +++++++++--------------- src/admin.rs | 3 +-- src/config.rs | 18 ++++++------------ 3 files changed, 16 insertions(+), 29 deletions(-) diff --git a/docs/MANUAL.md b/docs/MANUAL.md index f998d70..3abf86a 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -249,7 +249,7 @@ process start; nothing reloads live. | key | default | meaning | |---|---|---| -| `allowed_hosts` | `[]` | Extra `Host` header values accepted on `/admin`, on top of `api.bind` and its `127.0.0.1`/`localhost`/`[::1]` equivalents. Needed to reach `/admin` by LAN IP or hostname when `api.bind` is a wildcard address (e.g. `0.0.0.0:8080`). See [the admin page](#get-admin-the-admin-page). | +| `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]` @@ -291,10 +291,9 @@ Allowlist changes require a restart. At startup mycel rejects: unknown keys anywhere; `crawl.scope` other than `"host"`; empty `index.languages`; `crawl.concurrency = 0`; an -`admin.allowed_hosts` entry that is empty or contains `/` or whitespace -(it could never equal a real `Host` header, so it would silently never -match); a `federation.preset` other than `"n0"`/`"empty"`; a peer `id` -that is not 64 hex chars; a peer `addr` that is not `ip:port`. +`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`. ## 6. Command reference @@ -552,22 +551,17 @@ 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 `0.0.0.0:PORT` (or another wildcard/multi-address bind) and -you want to reach `/admin` by LAN IP or hostname rather than loopback, list -those Host values explicitly in `admin.allowed_hosts`: +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"] ``` -This is additive to the api.bind/loopback defaults, not a replacement, and -still requires an exact (case-insensitive) `Host` match, so a web page you -happen to visit still cannot drive the node through a name you did not -list. It is not authentication: anyone who can reach the port and send a -listed name gets the page, its CSRF token, and every operation on it. If -the network is not trusted, front the node with an authenticating reverse -proxy instead. +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` diff --git a/src/admin.rs b/src/admin.rs index 8b27813..a91b8ad 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -36,8 +36,7 @@ pub struct AdminState { pub started_at: i64, /// Per-boot token embedded in every form; POSTs without it are refused. csrf: String, - /// Host headers accepted on /admin routes (DNS-rebinding guard): - /// api.bind + loopback aliases, plus any admin.allowed_hosts overrides. + /// Host headers accepted on /admin routes (DNS-rebinding guard). allowed_hosts: Vec, job: Mutex>, pub index_tx: std::sync::mpsc::Sender, diff --git a/src/config.rs b/src/config.rs index bac79f7..36b23ce 100644 --- a/src/config.rs +++ b/src/config.rs @@ -175,9 +175,8 @@ impl Default for ApiCfg { #[derive(Debug, Clone, Default, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct AdminCfg { - /// Extra Host header values accepted on /admin routes, on top of - /// api.bind and the loopback aliases (DNS-rebinding guard allowlist). - /// Needed to reach /admin by LAN IP or hostname rather than loopback. + /// Extra Host headers accepted on /admin, beyond api.bind and its + /// loopback aliases (the DNS-rebinding guard's allowlist). pub allowed_hosts: Vec, } @@ -293,8 +292,6 @@ impl Config { if self.crawl.concurrency == 0 { return Err("crawl.concurrency must be > 0".into()); } - // An entry that can never equal a Host header (scheme, path, - // whitespace, empty) would otherwise just silently never match. for h in &self.admin.allowed_hosts { if h.is_empty() || h.contains('/') || h.contains(char::is_whitespace) { return Err( @@ -388,18 +385,15 @@ mod tests { let cfg: Config = toml::from_str("[admin]\nallowed_hosts = [\"mycel.lan:8080\"]\n").unwrap(); assert_eq!(cfg.admin.allowed_hosts, vec!["mycel.lan:8080"]); - cfg.validate().unwrap(); } #[test] fn admin_allowed_hosts_validated() { - // IPv6 literals stay legal: the check must not tighten past "Host header". - let good: Config = toml::from_str("[admin]\nallowed_hosts = [\"[::1]:8080\"]\n").unwrap(); - good.validate().unwrap(); + 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: Config = - toml::from_str(&format!("[admin]\nallowed_hosts = [{bad:?}]\n")).unwrap(); - assert!(cfg.validate().is_err(), "{bad:?} must be rejected"); + let cfg = format!("[admin]\nallowed_hosts = [{bad:?}]\n"); + assert!(toml::from_str::(&cfg).unwrap().validate().is_err()); } } From 0aec539fc275a9ea622fcf4796d4e0ff1396f4a0 Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:49:37 -0700 Subject: [PATCH 5/6] Bump to 0.3.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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" From 4261041c32fae4d2fc133f0fd3c495eedf16e48e Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:52:16 -0700 Subject: [PATCH 6/6] True up the site's lines-of-rust stat (~8,500) The rebuilt site shipped with SPEC's planning estimate (~6,300) in the stats strip and the graveyard card; src/ is 8,490 lines as of 0.3.0, same src-only count the old site's 8,447 used. --- site/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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