diff --git a/AGENTS.md b/AGENTS.md index 50e95e8..bcec7a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,8 +91,10 @@ Product detail lives under `docs/`. This file is standing law only. switch and stays the explicit equivalent with no default target), hypervisor, Namecheap **hosted DNSSEC Status** click, or the operator using a TUI they asked to sit in. - Host-local enable is agent work when this machine already has - host-local. Using `just grok-oss` after the guest has the package is + Turning a switch on in the private overlay on horizon + (`~/.local/share/surmount/host-local/default.nix`) is agent work when + that file is already on this laptop. That directory is not a machine + name. The machines are horizon and surmount-1. Using `just grok-oss` after the guest has the package is the product, not a leftover click. If this laptop already has the Namecheap env files (mode 0600 regular files under `~/.local/share/surmount/`), `just laptop-renew-cert` is **agent work**. @@ -227,6 +229,88 @@ Living maps: [docs/DNS.md](docs/DNS.md), [docs/SECURITY.md](docs/SECURITY.md), - **No NPM** ecosystem. - Short form why: [docs/principles.md](docs/principles.md). +## No GHC, Haskell, or Python (operator 2026-11-06) + +This repo's own code is Rust and Nix. Upstream C dependencies may +remain. Handwritten C is forbidden. See *Handwritten C is forbidden*. +GHC, Haskell, and Python are forbidden, including transitive +dependencies. An eval that reaches them fails. + +The firewall-tool check was the remaining Haskell edge after nixfmt-rs +replaced Haskell nixfmt. That package is now a non-Haskell script +(`writeShellScriptBin`). `networking.nftables.enable` and +`networking.firewall.enable` stay on. Do not construct upstream +`nixos-firewall-tool`. Referencing `ghc`, `haskellPackages`, +`haskell.compiler`, `hadrian`, ShellCheck, `python`, `python3`, or +`pythonPackages` throws during mail-host eval. The mail-host build and +install closure must contain no derivation whose name or pname contains +shellcheck, ShellCheck, ghc, hadrian, or haskell, and none whose name or +pname contains python, including python3, pythonPackages, dbus-python, +pandas, pip, and json-schema-for-humans. That walk is +`t73-no-haskell-mail-host-closure` in `tests/module-eval.nix`. An empty +walk does not pass. Git on the mail host keeps `pythonSupport` false. +Do not add a replacement Python or Haskell tool. + +## Existing Rust or C, and every dry-run root together (operator 2026-11-07) + +When a Python program is in the way, use an existing Rust project. +Operator 2026-11-07, later the same day: prefer Rust over C. An existing +C project is only the fallback when no Rust project does that job. Do +not write a new C program to stand in for the Python. The C files this +session added under `nix/packages/` are not that replacement. Operator +2026-11-07, later the same day: that fallback does not allow +handwritten C. See *Handwritten C is forbidden*. + +Jinja templates have existing Rust renderers. MiniJinja is one +(https://github.com/mitsuhiko/minijinja, accessed: 2026-11-07). Do not +reject a Rust renderer because it is not a line-for-line copy of the +Python script, and do not write a C renderer in its place. + +A `just deploy-dry-run` transcript is one full run. The build line +already passes `--keep-going`. Fix every builder-failed root from that +transcript before the next dry-run. Do not fix one root, rerun, and +leave the other roots from the same transcript for a later pass. +Dependents of a failed root are not extra roots. + +Dual-pin [docs/COMPACTION-PIN.md](docs/COMPACTION-PIN.md). + +## Handwritten C is forbidden (operator 2026-11-07) + +Operator 2026-11-07, and "always remember" the same day. Handwritten C +is forbidden in this repo. That includes a `.c` or `.h` file, and C +embedded in Nix, bash, a patch, or any other file that emits a C +program at build time. The operator cannot audit that code. Removing +Python is to shrink the attack surface. Handwritten C is the same +class of unauditable code. Upstream projects that are written in C may +remain as dependencies. Do not replace removed C with new C. Remove +every `.c` and `.h` file we added. + +Dual-pin [docs/COMPACTION-PIN.md](docs/COMPACTION-PIN.md). + +## No Python script and no C program inside Nix (operator 2026-11-08) + +Operator 2026-11-08, always remember. Do not inline a Python script in a +Nix file. Do not inline a C program in a Nix file. A build-phase string +must not carry a Python program body or a C program body. Renaming a +`.py` file, hiding that text in `printf` or `cat`, or stuffing the same +text into a patch is the same violation. Handwritten C already forbids +a C program embedded in Nix. This section adds the Python script, and +it restates that a shortcut which pastes either language into +`nix/overlays.nix` is not the fix. Use an existing Rust tool, or change +the build so the missing program is not invoked. Do not add a package +whose name contains python. + +A Nix file we own must not carry a Meson program body. That includes +meson.build text, a muon rewrite, a dependency() method sentence, an +install_headers rewrite, or a PKG_CONFIG_PATH prepend whose purpose is +to patch a Meson lookup. Hiding that text by rename, printf, cat, or a +patch is the same forbid. Upstream packages may still be built by their +own upstream build system. Our Nix files do not contain that program +text. Operator 2026-11-08 stopped the Meson-in-Nix and C-in-Nix +patching line. + +Dual-pin [docs/COMPACTION-PIN.md](docs/COMPACTION-PIN.md). + ## Edge and security (operator direction 2026-07-30) - **No nginx** as product edge. In-tree nginx is **transitional-to-delete**. @@ -286,6 +370,14 @@ Living maps: [docs/DNS.md](docs/DNS.md), [docs/SECURITY.md](docs/SECURITY.md), Thunderbird, or any other client, from an older mail thread. Read the screenshot: Evolution has Mail / Contacts / Calendar / Tasks / Memos. Say Evolution when that is what they opened. +- **End a broken nested agent (operator 2026-11-07).** If an L2 or L3 + is broken, end it and start a replacement when the work is still owed. + Do not spend the turn weighing that choice. Broken includes an early + exit with no land report, an L2 that is still reading or editing after + it should have spawned an L3, an L2 started with the wrong + instructions, and an L3 still on turn 1 with no file save after about + 15 minutes. A healthy agent that is already doing the assigned work + stays up. ## Agent plans and reports live on the host (2026-08-18) @@ -371,6 +463,30 @@ in the same turn (standing rule + compaction reload table). One file is attention dilution. Chat-only is compaction loss. Nix needing an untracked file is still not a reason to stage. +## Number parallel items, and name the machine (operator 2026-11-03) + +Operator 2026-11-03. Two rules. + +1. Number every parallel list in operator-facing chat, reports, and + leftover lists. Use 1, 2, 3. A bare stack of services is a miss. + Each number is a complete sentence when the item is a leftover or a + decision. +2. Name the machine. **horizon** is this laptop. **surmount-1** is the + mail host and the allowed remote Nix builder. Do not say "host-local" + as if it were a hostname. + +The private Nix overlay that `just deploy` copies is a directory, not a +machine. On horizon that directory is +`~/.local/share/surmount/host-local/` and the entry file is +`default.nix`. The recipe passes that directory as the `--host-local` +flag and the target is `root@surmount-1`. After the copy, surmount-1 +has the same files at `/root/surmount-server/host-local/`. When a +sentence needs that directory, name horizon or surmount-1 and the path +in the same sentence. The flag name `--host-local` stays, because that +is the deploy argument. + +Dual-pin [docs/COMPACTION-PIN.md](docs/COMPACTION-PIN.md). + ## Deploy dry-run is agent work (operator 2026-09-02) Full command list: [docs/TEST-AND-DEPLOY.md](docs/TEST-AND-DEPLOY.md). @@ -396,6 +512,34 @@ flags and no `--dry-run` is that same switch. Laptop cargo and at a time. Prove-the-gate leftover law stays: do not list a leftover unless the agent cannot do it. +## Do not stop before a passing dry-run (operator 2026-11-09) + +Never stop until just deploy-dry-run exits 0. The operator will not run +just deploy until that dry-run passes. Stopping to ask the operator to +deploy, or ending the task before a green dry-run, wastes the +operator's time. The agent runs the dry-run. The agent must not run +just deploy. just deploy-host without --dry-run is that same switch. + +Dual-pin [docs/COMPACTION-PIN.md](docs/COMPACTION-PIN.md). + +## Rebuilds must hit the cache (operator 2026-11-09) + +A later dry-run must not recompile a derivation that a previous dry-run +already built, unless an input that derivation depends on actually +changed. If the same package builds again with no input change, the +cache is broken and that is a bug to fix. Do not treat a full rebuild +of an unchanged package as normal. + +Dual-pin [docs/COMPACTION-PIN.md](docs/COMPACTION-PIN.md). + +## Trim dependencies to this host (operator 2026-11-09) + +Drop dependencies the mail host configuration does not need. The +machine is surmount-1. Keep what that configuration uses. Do not add a +package only because nixpkgs pulls it by default. + +Dual-pin [docs/COMPACTION-PIN.md](docs/COMPACTION-PIN.md). + ## Cargo update and cargo audit are one pair (operator 2026-10-10) `cargo update` and `cargo audit` are one pass. Do not run one and skip the other. @@ -438,12 +582,17 @@ unless the operator asked for that slice. **surmount-1** is the allowed remote Nix builder (ssh-ng). Mail and builder work share that host. That is why niceness and a hard memory -cap matter. **Lake is optional and default off** (`surmount.lake` in +cap matter. The private overlay is the directory +`~/.local/share/surmount/host-local/` on horizon, copied to +`/root/surmount-server/host-local/` on surmount-1. That directory is +not a hostname. See *Number parallel items, and name the machine*. +**Lake is optional and default off** (`surmount.lake` in `modules/lake.nix`; operator 2026-08-25 supersedes the 2026-08-18 "never a lake unit" line). Do **not** start Lake on the live guest -from an agent. When host-local enables it, the unit must be niced, -`MemoryMax`-capped on `surmount-lake.service`, and journaled. Do not -Nice= mail. +from an agent. When that eval or the private overlay on horizon enables Lake, Lake is +nice 19 with idle I/O, `MemoryMax`-capped on `surmount-lake.service`, +and journaled. Mail is nice -20. Do not set builder `Nice=19` on mail +to deprioritize it. Dev builds (remote Nix store builds, flake check, hygiene compiles) are always maximally nice: `nice -n 19` and idle ionice when practical. @@ -453,11 +602,17 @@ of **one** CPU (that starves the ssh-ng protocol and parks cores). `cpuQuota = "auto"` means 95 percent times online CPUs on the niced wrapper, and no one-CPU quota on the slices. -Mail and other critical services (`stalwart`, `management-ui`, `sshd`, -`arti`, networking) are **not** nice. They stay at normal priority. Do -**not** set `Nice=` on those units. Do **not** globally starve mail by -nicing the whole machine. Prefer a niced builder user and a niced -remote-build path. +Mail and scram are nice -20 on ordinary CFS. They are not fifo. They +are not realtime I/O. If a separate mail database unit exists, it is +also nice -20. scram stays root and its start command still contains +`--watch`. The HTTP management UI is nice -19. Arti is nice -18. An +enabled Splora indexer, and the bitcoind or elementsd unit that indexer starts, is nice 5. The Nix daemon is nice 19 with idle +I/O. Lake, when that eval or the private overlay on horizon enables Lake, is nice 19 with +idle I/O. sshd stays un-niced (`Nice` unset). Do **not** set `Nice=` +on sshd. Builds stay nice 19 with idle I/O. No realtime policy on +product units. Do **not** globally starve mail by nicing the whole +machine. Prefer a niced builder user and a niced remote-build path. +Mail is the highest nice priority (-20), not `Nice` unset. **Hard memory cap (SHC ticket 261, closed 2026-08-18; console evidence 2026-08-18).** **surmount-1** is mail plus the allowed remote Nix @@ -476,8 +631,8 @@ cap (systemd `MemoryMax` / cgroup `memory.max`) plus the about-95-percent CPU/RAM/storage guards SHC asked for. Those 95 percent guards are a **total** ceiling, not "give the builder 95 percent of guest RAM." Mail and other critical services must **not** be starved. In-tree: -`surmount.remoteBuilder` (default off) owns that cap for **ssh-ng / -nixbuilder only**; enable from host-local. Trusted ssh-ng forwards +`surmount.remoteBuilder` (default off in the public modules) owns that cap for **ssh-ng / +nixbuilder only**; the private overlay on horizon turns it on for surmount-1. Trusted ssh-ng forwards rustc to **system `nix-daemon.service`**. `MemoryMax` and `Nice=19` must apply to **that** unit, not only `user-.slice`. A nixbuilder-only cap is a miss. Job slots (`maxJobs`, laptop machines @@ -490,9 +645,9 @@ RAM size. Scaffold `maxJobs = 32` is half the measured mail-host CPUs builder OOM score, and Nice=19 are why this is no longer 8. Do not publish the raw CPU count. The builder is the preferred in-guest OOM victim (`OOMScoreAdjust`). Enable `surmount.hardening.qemuGuestAgent` -from host-local so SHC can inject a command when SSH is stuck. Do **not** +from the private overlay on horizon so SHC can inject a command when SSH is stuck. Do **not** start Lake from an agent. `surmount.lake.enable` stays false until -host-local turns it on after this generation is live. +the private overlay on horizon turns it on after this generation is live. Operator correction 2026-08-18: Nix is **misconfigured** if builds do not make maximally nice use of cores **without also OOMing the box**. diff --git a/Cargo.lock b/Cargo.lock index b1c98c6..0c2d25c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,82 +12,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", -] - -[[package]] -name = "aes-gcm" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" -dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", - "subtle", -] - -[[package]] -name = "age" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd290633c2482479f70f6d1d96ae0e9f52c6a26cd5859edd47ee1fe33fc89f26" -dependencies = [ - "age-core", - "base64 0.22.1", - "bech32 0.11.1", - "chacha20poly1305", - "cipher", - "cookie-factory", - "hkdf", - "hmac 0.12.1", - "hpke", - "i18n-embed", - "i18n-embed-fl", - "lazy_static", - "ml-kem", - "nom 8.0.0", - "p256", - "pin-project", - "rand 0.8.8", - "rust-embed", - "scrypt", - "sha2 0.10.9", - "sha3", - "subtle", - "x25519-dalek", - "zeroize", -] - -[[package]] -name = "age-core" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01d4375964d1501e5f1b32aef2ead573913893ff238448d4e9fdf1522d828656" -dependencies = [ - "base64 0.22.1", - "bech32 0.11.1", - "chacha20poly1305", - "cookie-factory", - "hkdf", - "hpke", - "io_tee", - "nom 8.0.0", - "rand 0.8.8", - "secrecy", - "sha2 0.10.9", -] - [[package]] name = "aho-corasick" version = "1.1.5" @@ -166,9 +90,9 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arc-swap" -version = "1.9.2" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e" dependencies = [ "rustversion", ] @@ -188,7 +112,7 @@ dependencies = [ "asn1-rs-derive", "asn1-rs-impl", "displaydoc", - "nom 7.1.3", + "nom", "num-traits", "rusticata-macros", "thiserror 2.0.21", @@ -243,7 +167,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -262,7 +186,7 @@ dependencies = [ "manyhow", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -278,7 +202,7 @@ dependencies = [ "proc-macro2", "quote", "quote-use", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -289,9 +213,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.18.1" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +checksum = "048006790404103c488e6c5616b3e7e702a71292aadda08e8f7698a9e0d2eb53" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -300,9 +224,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.45.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +checksum = "8bfbe32ac36b3c428696ce315464181bc4029d689fd58e3f2a4e58c252242f52" dependencies = [ "cc", "cmake", @@ -408,12 +332,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d27c3610c36aee21ce8ac510e6224498de4228ad772a171ed65643a24693a5a8" -[[package]] -name = "base16ct" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" - [[package]] name = "base64" version = "0.22.1" @@ -426,15 +344,6 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" -[[package]] -name = "basic-toml" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" -dependencies = [ - "serde", -] - [[package]] name = "bech32" version = "0.11.1" @@ -548,7 +457,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ - "hybrid-array 0.4.15", + "hybrid-array", ] [[package]] @@ -595,9 +504,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.6.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" dependencies = [ "find-msvc-tools", "jobserver", @@ -694,7 +603,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -743,9 +652,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" -version = "4.6.8" +version = "4.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" dependencies = [ "bytes", "memchr", @@ -760,16 +669,10 @@ dependencies = [ "convert_case 0.6.0", "pathdiff", "serde_core", - "toml 1.1.8+spec-1.1.0", + "toml", "winnow", ] -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - [[package]] name = "const-oid" version = "0.10.2" @@ -784,9 +687,9 @@ checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f" [[package]] name = "const_format" -version = "0.2.36" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +checksum = "a3decb4d6965448659923544a634a0571ba44cede4e11ef15a5a317126840454" dependencies = [ "const_format_proc_macros", "konst", @@ -794,9 +697,9 @@ dependencies = [ [[package]] name = "const_format_proc_macros" -version = "0.2.34" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +checksum = "941b570269a642506f08cb5b688c04bfa6cfd41a26f8bcdc49db8fd00c0ab659" dependencies = [ "proc-macro2", "quote", @@ -836,15 +739,6 @@ dependencies = [ "convert_case 0.11.0", ] -[[package]] -name = "cookie-factory" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" -dependencies = [ - "futures", -] - [[package]] name = "core-foundation" version = "0.10.1" @@ -879,18 +773,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crypto-bigint" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" -dependencies = [ - "generic-array", - "rand_core 0.6.4", - "subtle", - "zeroize", -] - [[package]] name = "crypto-common" version = "0.1.7" @@ -898,7 +780,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core 0.6.4", "typenum", ] @@ -908,23 +789,13 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "hybrid-array 0.4.15", -] - -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher", + "hybrid-array", ] [[package]] name = "ctutils" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +version = "0.4.5" +source = "git+https://github.com/RustCrypto/utils?rev=88d6e90d4cfd2fc3333d00e13c99241507d2138f#88d6e90d4cfd2fc3333d00e13c99241507d2138f" dependencies = [ "cmov", ] @@ -963,18 +834,18 @@ checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "defmt" -version = "0.3.100" +version = "0.3.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" +checksum = "4deef977660e373c458df605a913d4ab121db0ccd18d1ad6976dff7d37cddb53" dependencies = [ - "defmt 1.1.1", + "defmt 1.2.0", ] [[package]] name = "defmt" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1" dependencies = [ "bitflags 1.3.2", "defmt-macros", @@ -982,35 +853,25 @@ dependencies = [ [[package]] name = "defmt-macros" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366" dependencies = [ "defmt-parser", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] name = "defmt-parser" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" dependencies = [ "thiserror 2.0.21", ] -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid 0.9.6", - "zeroize", -] - [[package]] name = "der-parser" version = "10.0.0" @@ -1019,7 +880,7 @@ checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" dependencies = [ "asn1-rs", "displaydoc", - "nom 7.1.3", + "nom", "num-bigint", "num-traits", "rusticata-macros", @@ -1027,9 +888,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" [[package]] name = "derive-where" @@ -1039,7 +900,7 @@ checksum = "2e2b94854e8576378ccda7c8de8a66ed8b4e8acbd2c50ec3418ea6c8aaf4b567" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1050,7 +911,6 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.7", - "subtle", ] [[package]] @@ -1060,7 +920,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid 0.10.2", + "const-oid", "crypto-common 0.2.2", "ctutils", ] @@ -1073,7 +933,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1104,30 +964,11 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "elliptic-curve" -version = "0.13.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" -dependencies = [ - "base16ct", - "crypto-bigint", - "digest 0.10.7", - "ff", - "generic-array", - "group", - "hkdf", - "rand_core 0.6.4", - "sec1", - "subtle", - "zeroize", -] - [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "erased" @@ -1172,7 +1013,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04839bdf9d8c10f66806fad16b852fc72aab80873aebc3cb69d85b4fa41543ed" dependencies = [ "autocfg", - "defmt 0.3.100", + "defmt 0.3.101", "heapless", "serde", ] @@ -1183,81 +1024,17 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" -[[package]] -name = "ff" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" -dependencies = [ - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "fiat-crypto" version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" -[[package]] -name = "find-crate" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2" -dependencies = [ - "toml 0.5.11", -] - [[package]] name = "find-msvc-tools" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" - -[[package]] -name = "fluent" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8137a6d5a2c50d6b0ebfcb9aaa91a28154e0a70605f112d30cb0cd4a78670477" -dependencies = [ - "fluent-bundle", - "unic-langid", -] - -[[package]] -name = "fluent-bundle" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01203cb8918f5711e73891b347816d932046f95f54207710bda99beaeb423bf4" -dependencies = [ - "fluent-langneg", - "fluent-syntax", - "intl-memoizer", - "intl_pluralrules", - "rustc-hash", - "self_cell", - "smallvec", - "unic-langid", -] - -[[package]] -name = "fluent-langneg" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" -dependencies = [ - "unic-langid", -] - -[[package]] -name = "fluent-syntax" -version = "0.12.0" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" -dependencies = [ - "memchr", - "thiserror 2.0.21", -] +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] name = "fnv" @@ -1346,7 +1123,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1386,7 +1163,6 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", - "zeroize", ] [[package]] @@ -1416,16 +1192,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "ghash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval", -] - [[package]] name = "gloo-net" version = "0.6.0" @@ -1460,17 +1226,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "group" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" -dependencies = [ - "ff", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "guardian" version = "1.3.0" @@ -1590,24 +1345,6 @@ dependencies = [ "arrayvec", ] -[[package]] -name = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac 0.12.1", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest 0.10.7", -] - [[package]] name = "hmac" version = "0.13.0" @@ -1617,26 +1354,6 @@ dependencies = [ "digest 0.11.3", ] -[[package]] -name = "hpke" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4917627a14198c3603282c5158b815ad5534795451d3c074b53cf3cee0960b11" -dependencies = [ - "aead", - "aes-gcm", - "chacha20poly1305", - "digest 0.10.7", - "generic-array", - "hkdf", - "hmac 0.12.1", - "p256", - "rand_core 0.6.4", - "sha2 0.10.9", - "subtle", - "zeroize", -] - [[package]] name = "html-escape" version = "0.2.15" @@ -1688,15 +1405,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" -[[package]] -name = "hybrid-array" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2d35805454dc9f8662a98d6d61886ffe26bd465f5960e0e55345c70d5c0d2a9" -dependencies = [ - "typenum", -] - [[package]] name = "hybrid-array" version = "0.4.15" @@ -1784,77 +1492,11 @@ dependencies = [ "tracing", ] -[[package]] -name = "i18n-config" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e06b90c8a0d252e203c94344b21e35a30f3a3a85dc7db5af8f8df9f3e0c63ef" -dependencies = [ - "basic-toml", - "log", - "serde", - "serde_derive", - "thiserror 1.0.69", - "unic-langid", -] - -[[package]] -name = "i18n-embed" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a217bbb075dcaefb292efa78897fc0678245ca67f265d12c351e42268fcb0305" -dependencies = [ - "arc-swap", - "fluent", - "fluent-langneg", - "fluent-syntax", - "i18n-embed-impl", - "intl-memoizer", - "log", - "parking_lot", - "rust-embed", - "thiserror 1.0.69", - "unic-langid", - "walkdir", -] - -[[package]] -name = "i18n-embed-fl" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "602e6bd30c3db2749e13e38b363a3d98d9d41de1d8de7a79c31bb69e45b47cda" -dependencies = [ - "find-crate", - "fluent", - "fluent-syntax", - "i18n-config", - "i18n-embed", - "proc-macro-error3", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.119", - "unic-langid", -] - -[[package]] -name = "i18n-embed-impl" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f2cc0e0523d1fe6fc2c6f66e5038624ea8091b3e7748b5e8e0c84b1698db6c2" -dependencies = [ - "find-crate", - "i18n-config", - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "icu_collections" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" dependencies = [ "displaydoc", "potential_utf", @@ -1866,9 +1508,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" dependencies = [ "displaydoc", "litemap", @@ -1879,9 +1521,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1893,15 +1535,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" [[package]] name = "icu_properties" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" dependencies = [ "displaydoc", "icu_collections", @@ -1914,15 +1556,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" [[package]] name = "icu_provider" -version = "2.3.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" dependencies = [ "displaydoc", "icu_locale_core", @@ -2006,25 +1648,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8" -[[package]] -name = "intl-memoizer" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" -dependencies = [ - "type-map", - "unic-langid", -] - -[[package]] -name = "intl_pluralrules" -version = "7.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" -dependencies = [ - "unic-langid", -] - [[package]] name = "inventory" version = "0.3.25" @@ -2034,12 +1657,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "io_tee" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b3f7cef34251886990511df1c61443aa928499d598a9473929ab5a90a527304" - [[package]] name = "ipnet" version = "2.12.2" @@ -2137,25 +1754,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "keccak" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" -dependencies = [ - "cpufeatures 0.2.17", -] - -[[package]] -name = "kem" -version = "0.3.0-pre.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b8645470337db67b01a7f966decf7d0bafedbae74147d33e641c67a91df239f" -dependencies = [ - "rand_core 0.6.4", - "zeroize", -] - [[package]] name = "konst" version = "0.2.20" @@ -2258,7 +1856,7 @@ dependencies = [ "quote", "rstml", "serde", - "syn 3.0.6", + "syn 3.0.9", "walkdir", ] @@ -2281,7 +1879,7 @@ dependencies = [ "rstml", "rustc_version", "server_fn_macro", - "syn 3.0.6", + "syn 3.0.9", "uuid", ] @@ -2307,9 +1905,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.190" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "litemap" @@ -2317,15 +1915,6 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - [[package]] name = "log" version = "0.4.34" @@ -2340,21 +1929,21 @@ checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" [[package]] name = "manyhow" -version = "0.14.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c69ea6e469ca785c846b25dd69eac9e9c916b51d8ada1f212866787ee1da12d" +checksum = "f1704b073316272a6785b70599f91f73b49022c2e290b065591ee6b9109ed8c7" dependencies = [ "manyhow-macros", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "manyhow-macros" -version = "0.14.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22fc5c1b2c4000056dae3619fea36e79ce79dabfee4907d2dcad6612b189e370" +checksum = "31601db71f1f5fb6f831a18aa3052d13a62528ee4f2cba7e8c9a5e258d953725" dependencies = [ "proc-macro-utils", "proc-macro2", @@ -2388,16 +1977,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2415,18 +1994,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "ml-kem" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de49b3df74c35498c0232031bb7e85f9389f913e2796169c8ab47a53993a18f" -dependencies = [ - "hybrid-array 0.2.3", - "kem", - "rand_core 0.6.4", - "sha3", -] - [[package]] name = "next_tuple" version = "0.1.0" @@ -2443,15 +2010,6 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - [[package]] name = "nostr" version = "0.45.5" @@ -2565,9 +2123,9 @@ checksum = "4f933a4265d5cdad61d19bbdfc972ea5726d56cd8d3d57b8f2d3c365dd42bee9" [[package]] name = "openssl-probe" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" [[package]] name = "or_poisoned" @@ -2575,45 +2133,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c04f5d74368e4d0dfe06c45c8627c81bd7c317d52762d118fb9b3076f6420fd" -[[package]] -name = "p256" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" -dependencies = [ - "elliptic-curve", - "primeorder", -] - [[package]] name = "parking" version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - [[package]] name = "paste" version = "1.0.15" @@ -2626,16 +2151,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" -[[package]] -name = "pbkdf2" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" -dependencies = [ - "digest 0.10.7", - "hmac 0.12.1", -] - [[package]] name = "pem" version = "4.0.0" @@ -2695,18 +2210,6 @@ dependencies = [ "universal-hash", ] -[[package]] -name = "polyval" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash", -] - [[package]] name = "potential_utf" version = "0.1.6" @@ -2738,38 +2241,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" dependencies = [ "proc-macro2", - "syn 3.0.6", -] - -[[package]] -name = "primeorder" -version = "0.13.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" -dependencies = [ - "elliptic-curve", -] - -[[package]] -name = "proc-macro-error-attr3" -version = "3.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e564d14133360e1ae169ffde5da25881b5fa47261665b8e5713c212c27799da" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error3" -version = "3.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f0d4471b3436c22106b21913b1dda531558918ae9b7ec55d58aa84b43552233" -dependencies = [ - "proc-macro-error-attr3", - "proc-macro2", - "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2800,7 +2272,7 @@ checksum = "df921e1ef974ebc8dd9effdb0c037a96928ae8cbf871438e02d6fd67204b595e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "version_check", ] @@ -2890,7 +2362,7 @@ dependencies = [ "proc-macro-utils", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2972,9 +2444,9 @@ dependencies = [ [[package]] name = "reactive_graph" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79f11fac67625645add76313dab8dae510fca0654d96ef98fd8f961a7d2e22c1" +checksum = "3bcec36a2d1b9a53a4129b3270b19e3df6f6b88c2e7c97ed9404dc9307f137d7" dependencies = [ "any_spawner", "async-lock", @@ -3023,15 +2495,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.13.2", -] - [[package]] name = "regex" version = "1.13.1" @@ -3136,7 +2599,7 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 3.0.6", + "syn 3.0.9", "thiserror 2.0.21", ] @@ -3150,41 +2613,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rust-embed" -version = "8.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19afa5b4b6a611de00bd1bdae6ae6f39084c9399f0679c3f52d8469cf335cc23" -dependencies = [ - "rust-embed-impl", - "rust-embed-utils", - "walkdir", -] - -[[package]] -name = "rust-embed-impl" -version = "8.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0d8afda6374eac59e066abee06d265247ebbaf3006cf878e2879e8356e34053" -dependencies = [ - "mime_guess", - "proc-macro2", - "quote", - "rust-embed-utils", - "syn 2.0.119", - "walkdir", -] - -[[package]] -name = "rust-embed-utils" -version = "8.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d84e8ba78bd384263e5922f084cbe1b081c3b7e69add59c8fb097b879ba968a" -dependencies = [ - "sha2 0.11.0", - "walkdir", -] - [[package]] name = "rustc-hash" version = "2.1.3" @@ -3206,7 +2634,7 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" dependencies = [ - "nom 7.1.3", + "nom", ] [[package]] @@ -3225,9 +2653,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -3274,9 +2702,8 @@ checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" -version = "0.103.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +version = "0.103.16" +source = "git+https://github.com/rustls/webpki?rev=8ecdcc76f56b68fda67a6adf7976f5eed5006a4c#8ecdcc76f56b68fda67a6adf7976f5eed5006a4c" dependencies = [ "aws-lc-rs", "ring", @@ -3296,15 +2723,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "salsa20" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" -dependencies = [ - "cipher", -] - [[package]] name = "same-file" version = "1.0.6" @@ -3316,43 +2734,13 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.29" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "scrypt" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" -dependencies = [ - "pbkdf2", - "salsa20", - "sha2 0.10.9", -] - -[[package]] -name = "sec1" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" -dependencies = [ - "base16ct", - "der", - "generic-array", - "subtle", - "zeroize", -] - [[package]] name = "secp256k1" version = "0.30.0" @@ -3405,12 +2793,6 @@ dependencies = [ "libc", ] -[[package]] -name = "self_cell" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" - [[package]] name = "semver" version = "1.0.28" @@ -3453,14 +2835,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -3557,7 +2939,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 3.0.6", + "syn 3.0.9", "xxhash-rust", ] @@ -3568,7 +2950,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9a4f81a0d855f279c3448243138a55cbfdd9851da9341b9c1e5971b90089e61" dependencies = [ "server_fn_macro", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -3584,25 +2966,15 @@ dependencies = [ [[package]] name = "sha2" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", "cpufeatures 0.3.1", "digest 0.11.3", ] -[[package]] -name = "sha3" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" -dependencies = [ - "digest 0.10.7", - "keccak", -] - [[package]] name = "sharded-slab" version = "0.1.7" @@ -3661,9 +3033,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.16.2" +version = "1.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" [[package]] name = "socket2" @@ -3765,7 +3137,7 @@ dependencies = [ "h3", "h3-quinn", "h3-util", - "hmac 0.13.0", + "hmac", "http-body-util", "hyper", "hyper-util", @@ -3780,7 +3152,7 @@ dependencies = [ "rustls-pki-types", "serde", "serde_json", - "sha2 0.11.0", + "sha2 0.11.1", "time", "tokio", "tokio-rustls", @@ -3802,9 +3174,10 @@ version = "0.1.0" name = "surmount-rekey" version = "0.1.0" dependencies = [ - "age", "anyhow", + "bech32 0.11.1", "secrecy", + "x25519-dalek", ] [[package]] @@ -3865,9 +3238,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -3902,7 +3275,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -3974,7 +3347,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4027,12 +3400,11 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" dependencies = [ "displaydoc", - "serde_core", "zerovec", ] @@ -4044,9 +3416,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.2" +version = "1.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" dependencies = [ "bytes", "libc", @@ -4066,7 +3438,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4081,9 +3453,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.19" +version = "0.7.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" dependencies = [ "bytes", "futures-core", @@ -4093,15 +3465,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "toml" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" -dependencies = [ - "serde", -] - [[package]] name = "toml" version = "1.1.8+spec-1.1.0" @@ -4273,15 +3636,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "type-map" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" -dependencies = [ - "rustc-hash", -] - [[package]] name = "typed-builder" version = "0.23.2" @@ -4308,42 +3662,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" -[[package]] -name = "unic-langid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" -dependencies = [ - "unic-langid-impl", -] - -[[package]] -name = "unic-langid-impl" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" -dependencies = [ - "serde", - "tinystr", -] - -[[package]] -name = "unicase" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" - [[package]] name = "unicode-ident" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-normalization" -version = "0.1.25" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" dependencies = [ "tinyvec", ] @@ -4415,9 +3744,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.27.0" +version = "1.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -4504,7 +3833,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "wasm-bindgen-shared", ] @@ -4574,9 +3903,8 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +version = "1.0.11" +source = "git+https://github.com/rustls/webpki-roots?rev=7ebfa0b377fd0c7e0a2848f9d9b2867a4c2198cf#7ebfa0b377fd0c7e0a2848f9d9b2867a4c2198cf" dependencies = [ "rustls-pki-types", ] @@ -4716,7 +4044,7 @@ dependencies = [ "data-encoding", "der-parser", "lazy_static", - "nom 7.1.3", + "nom", "oid-registry", "ring", "rusticata-macros", @@ -4742,9 +4070,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -4759,24 +4087,24 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure 0.14.0", ] [[package]] name = "zerocopy" -version = "0.8.62" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.62" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" dependencies = [ "proc-macro2", "quote", @@ -4800,7 +4128,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure 0.14.0", ] @@ -4837,11 +4165,10 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" dependencies = [ - "serde", "yoke", "zerofrom", "zerovec-derive", @@ -4849,13 +4176,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 7088c0e..7f95a75 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -111,11 +111,9 @@ sha2 = "0.11" base64 = "0.23" # CSRF tokens + CSP nonces (already in lock via other crates; direct dep for auth). getrandom = "0.4" -# rage CLI is built on this library (age-encryption.org). Used by just rekey. -# 0.12.1+ pulls i18n-embed-fl 0.10.1, which uses proc-macro-error3. -# RUSTSEC-2026-0173 is proc-macro-error2, which is not in this lock. -# Identity::generate / to_public / to_string stay. -age = { version = "0.12", default-features = false } +# surmount-rekey mints X25519 age identity strings itself (x25519-dalek + +# bech32 0.11). Do not depend on the age crate: 0.12.1 always pulls hpke +# and p256, and that edge requires crypto-bigint 0.5 (RUSTSEC-2026-0387). # menhera 10d lists chacha20 0.10.2 and faster-hex 0.10.1 (checked # 2026-10-17). Those git lines are gone. crates-io is still replaced by @@ -124,6 +122,33 @@ age = { version = "0.12", default-features = false } # rustls 0.23.46 is not on menhera 10d yet (10d stops at 0.23.45). Public # git tag v/0.23.46, commit f04804fa13341a9b1dc02b5d1dd8a746f2c4b7fb. # When menhera 10d lists 0.23.46, drop this rustls line. +# +# ctutils 0.4.4+ is not on menhera 10d yet (10d stops at 0.4.3). Public +# git tag ctutils-v0.4.5, commit 88d6e90d4cfd2fc3333d00e13c99241507d2138f. +# RUSTSEC-2026-0385 patched list is >=0.4.4. +# https://rustsec.org/advisories/RUSTSEC-2026-0385 (accessed: 2026-10-24). +# When menhera 10d lists 0.4.4 or later, drop this ctutils line. +# +# rustls-webpki 0.103.16 is not on menhera 10d yet (10d stops at 0.103.15). +# Public git tag v/0.103.16, commit 8ecdcc76f56b68fda67a6adf7976f5eed5006a4c. +# RUSTSEC-2026-0382 patched list is >=0.103.16, <0.104.0-alpha.1. +# https://rustsec.org/advisories/RUSTSEC-2026-0382 (accessed: 2026-10-24). +# When menhera 10d lists 0.103.16, drop this rustls-webpki line. +# +# webpki-root-certs 1.0.10+ is not on menhera 10d yet (10d stops at 1.0.9). +# Public git tag v/1.0.11, commit 7ebfa0b377fd0c7e0a2848f9d9b2867a4c2198cf. +# RUSTSEC-2026-0383 patched list is >=1.0.10. +# https://rustsec.org/advisories/RUSTSEC-2026-0383 (accessed: 2026-10-24). +# When menhera 10d lists 1.0.10 or later, drop this webpki-root-certs line. +# +# i18n-config left with age. age 0.12.1 was the only crate that pulled +# i18n-embed, and that edge required crypto-bigint 0.5. Do not re-add the +# i18n-config git patch unless a crate depends on i18n-config again. +# basic-toml (RUSTSEC-2026-0374) was only reached through that edge. +# https://rustsec.org/advisories/RUSTSEC-2026-0374 (accessed: 2026-10-24). [patch.crates-io] rustls = { git = "https://github.com/rustls/rustls", rev = "f04804fa13341a9b1dc02b5d1dd8a746f2c4b7fb" } +ctutils = { git = "https://github.com/RustCrypto/utils", rev = "88d6e90d4cfd2fc3333d00e13c99241507d2138f" } +rustls-webpki = { git = "https://github.com/rustls/webpki", rev = "8ecdcc76f56b68fda67a6adf7976f5eed5006a4c" } +webpki-root-certs = { git = "https://github.com/rustls/webpki-roots", rev = "7ebfa0b377fd0c7e0a2848f9d9b2867a4c2198cf" } diff --git a/README.md b/README.md index 59db682..d149de1 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ offline Synology DiskStation (MailPlus + static sites) with a reproducible stack: - **Stalwart** - SMTP / submission / IMAP / ManageSieve / JMAP (mail engine - pinned in `nix/packages/stalwart-mail.nix`, currently **0.16.15**; + pinned in `nix/packages/stalwart-mail.nix`, currently **1.0.2**; **RocksDB** all-role co-location **fine for now**; [docs/DATASTORES.md](docs/DATASTORES.md). Internal FTS good enough for now; Surmount builds own search product later) @@ -131,9 +131,16 @@ nix build .#management-ui required (Stalwart chip stays down until something answers `SURMOUNT_STALWART_URL`). Ctrl-C stops it. Override any `SURMOUNT_*` env before invoking. -**nixfmt (Nix formatter):** the flake already ships `nixfmt-rfc-style` as -`packages.nixfmt`, `formatter`, and in `devShells.default`. Prefer the flake -over a distro package so CI and laptop match. +**nixfmt (Nix formatter):** the flake ships the Rust formatter +[nixfmt-rs](https://github.com/Mic92/nixfmt-rs) (accessed: 2026-11-05), +pinned at `c22b2c9ef0ad3b66f97ebd6296fbba298aa9e039` (default-branch HEAD +read 2026-11-05), as `packages.nixfmt`, `formatter`, and in +`devShells.default`. The binary is still named `nixfmt`, and `nixfmt +--check` is the check flag. This is not Haskell `pkgs.nixfmt` and not +`nixfmt-rfc-style` (on nixos-26.05 that alias is the Haskell package). +The nixfmt-rs author claims byte-identical output with Haskell nixfmt +1.2.0, not with 1.5.0. Prefer the flake over a distro package so CI and +the laptop match. ```bash # one-shot (no install) @@ -152,10 +159,9 @@ On Arch, you do **not** need a pacman/AUR package for day-to-day work if you use the flake above. If you still want a system package for other repos: ```bash -# AUR (names vary; check with paru/yay search) -paru -S nixfmt # or nixfmt-bin / nixfmt-git, depending on AUR -# or always-nix from nixpkgs (same RFC style as this flake): -nix profile install nixpkgs#nixfmt-rfc-style +# AUR names vary (nixfmt, nixfmt-bin, nixfmt-git). This flake does not +# use them. Do not install nixpkgs#nixfmt-rfc-style for this repo: that +# name is the Haskell package. ``` **Host quality bar:** `just check` runs format check (no write), clippy with @@ -247,9 +253,9 @@ ssh -L 8080:127.0.0.1:8080 mail-vps # open http://127.0.0.1:8080 ``` -Transitional nginx may also expose `/stalwart-admin/` on -`services.surmount.systems` for bootstrap; remove or lock down once you no -longer need it. Port numbers: confirm live defaults in modules (UI vs Stalwart +Transitional nginx does not publish `/stalwart-admin/`. That public path +was removed. Bootstrap is the SSH tunnel above, to the loopback Stalwart +HTTP listener. Port numbers: confirm live defaults in modules (UI vs Stalwart HTTP split is documented in architecture-review). **NixOS option path:** Surmount-owned `services.stalwart` module for diff --git a/crates/management-ui/src/acme.rs b/crates/management-ui/src/acme.rs index 6c741e4..8ebe510 100644 --- a/crates/management-ui/src/acme.rs +++ b/crates/management-ui/src/acme.rs @@ -1,9 +1,11 @@ //! In-process ACME (Let's Encrypt-capable) for management-ui HTTPS. //! //! **Default off.** CI and hermetic tests never require a live ACME directory -//! or real DNS. Product challenge for this slice is **DNS-01** (fits -//! redirect-only product :80). HTTP-01 on product :80 remains parked (Q-EDGE). -//! TLS-ALPN-01 is residual (ALPN routing on the rustls acceptor). +//! or real DNS. DNS-01 remains the in-process adapter. HTTP-01 is the +//! surmount-1 renew path: the plain HTTP listener serves +//! `/.well-known/acme-challenge/` from `SURMOUNT_ACME_HTTP01_WEBROOT`, and +//! the host timer `surmount-cert-renew` runs the ACME client. TLS-ALPN-01 +//! is residual (ALPN routing on the rustls acceptor). //! //! # What `DnsProvider` / `SURMOUNT_ACME_DNS_PROVIDER` means //! @@ -96,30 +98,28 @@ pub const HOOK_WAIT_UNSUPPORTED_EXIT: i32 = 2; pub const HOOK_MINIMAL_PATH: &str = "/run/current-system/sw/bin:/nix/var/nix/profiles/default/bin:/usr/bin:/bin"; -/// Challenge type for this product slice. HTTP-01 is not offered. +/// Challenge type. DNS-01 is the in-process adapter. HTTP-01 is the host +/// renew path (webroot on the product :80 listener). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AcmeChallenge { - /// DNS-01 (`_acme-challenge.` TXT). Primary for redirect-only :80. + /// DNS-01 (`_acme-challenge.` TXT). Dns01, + /// HTTP-01 (`/.well-known/acme-challenge/` from the webroot). + Http01, } impl AcmeChallenge { pub fn parse(raw: &str) -> Result { match raw.trim().to_ascii_lowercase().as_str() { "" | "dns-01" | "dns01" | "dns" => Ok(Self::Dns01), - "http-01" | "http01" | "http" => Err( - "ACME HTTP-01 on product :80 is parked (Q-EDGE). Use dns-01 \ - (SURMOUNT_ACME_CHALLENGE=dns-01) or external host PEMs" - .into(), - ), + "http-01" | "http01" | "http" => Ok(Self::Http01), "tls-alpn-01" | "tls-alpn" | "alpn" => Err( "ACME TLS-ALPN-01 is residual for this slice (not wired). Use \ - dns-01 or external host PEMs" + dns-01, http-01, or external host PEMs" .into(), ), other => Err(format!( - "unknown SURMOUNT_ACME_CHALLENGE {other:?}; only dns-01 is \ - supported in this build" + "unknown SURMOUNT_ACME_CHALLENGE {other:?}; use dns-01 or http-01" )), } } @@ -127,6 +127,7 @@ impl AcmeChallenge { pub fn as_str(self) -> &'static str { match self { Self::Dns01 => "dns-01", + Self::Http01 => "http-01", } } } @@ -186,6 +187,8 @@ pub struct AcmeConfig { pub dns_hook_timeout_secs: u64, /// Reissue when remaining leaf lifetime is under this many days. pub renew_days_before_expiry: u64, + /// Document root for HTTP-01 token files. Empty unless challenge is HTTP-01. + pub http01_webroot: PathBuf, } impl Default for AcmeConfig { @@ -201,6 +204,7 @@ impl Default for AcmeConfig { dns_hook_path: PathBuf::new(), dns_hook_timeout_secs: DEFAULT_ACME_DNS_HOOK_TIMEOUT_SECS, renew_days_before_expiry: DEFAULT_ACME_RENEW_DAYS_BEFORE_EXPIRY, + http01_webroot: PathBuf::new(), } } } @@ -254,6 +258,11 @@ impl AcmeConfig { } _ => DEFAULT_ACME_RENEW_DAYS_BEFORE_EXPIRY, }; + let http01_webroot = PathBuf::from( + get("SURMOUNT_ACME_HTTP01_WEBROOT") + .unwrap_or_default() + .trim(), + ); let cfg = Self { enable, @@ -266,6 +275,7 @@ impl AcmeConfig { dns_hook_path, dns_hook_timeout_secs, renew_days_before_expiry, + http01_webroot, }; cfg.validate()?; Ok(cfg) @@ -327,9 +337,16 @@ impl AcmeConfig { self.account_credentials_path )); } - // Challenge already refused HTTP-01 / TLS-ALPN at parse. - if self.challenge != AcmeChallenge::Dns01 { - return Err("only ACME DNS-01 is supported in this build".into()); + // TLS-ALPN is refused at parse. HTTP-01 needs an absolute webroot. + // DNS-01 does not. + if self.challenge == AcmeChallenge::Http01 + && (path_is_blank(&self.http01_webroot) || !self.http01_webroot.is_absolute()) + { + return Err( + "SURMOUNT_ACME_CHALLENGE=http-01 requires SURMOUNT_ACME_HTTP01_WEBROOT \ + as an absolute host path (fail-closed)" + .into(), + ); } // Lab mock must never target production Let's Encrypt (fail closed). if self.dns_provider == DnsProviderKind::Mock @@ -988,29 +1005,22 @@ async fn dns01_publish_wait( .map_err(|e| format!("ACME DNS-01 propagation wait failed: {e}")) } -async fn issue_with_instant_acme( - req: &IssueRequest, - dns: &dyn DnsProvider, -) -> Result { - use instant_acme::{Account, ChallengeType, Identifier, NewAccount, NewOrder, RetryPolicy}; +/// Load a saved ACME account, or register one and write the credentials file. +/// +/// Shared by DNS-01 and HTTP-01. Does not publish a challenge. +async fn open_acme_account(req: &IssueRequest) -> Result { + use instant_acme::{Account, NewAccount}; if req.domains.is_empty() { - return Err("instant-acme DNS-01 issuer requires at least one domain".into()); + return Err("ACME issuer requires at least one domain".into()); } - // Ensure process-level rustls provider before hyper-rustls ClientConfig // (Account::builder). Without this, rustls panics when no automatic // default is selected. Idempotent if main already installed aws-lc-rs. let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - - // Account::builder uses default hyper-rustls client (aws-lc-rs crypto). let builder = Account::builder().map_err(|e| format!("ACME account builder failed: {e}"))?; - let contact = format!("mailto:{}", req.email); - let (account, credentials) = if !path_is_blank(&req.account_credentials_path) - && req.account_credentials_path.exists() - { - // Fail closed: same owner-only regular-file contract as TLS private key. + if !path_is_blank(&req.account_credentials_path) && req.account_credentials_path.exists() { require_owner_only_secret_file(&req.account_credentials_path, "ACME account credentials")?; let raw = std::fs::read_to_string(&req.account_credentials_path).map_err(|e| { format!( @@ -1024,12 +1034,10 @@ async fn issue_with_instant_acme( req.account_credentials_path.display() ) })?; - let account = builder + builder .from_credentials(creds) .await - .map_err(|e| format!("restore ACME account from credentials: {e}"))?; - // Keep existing credentials file as-is. - (account, None) + .map_err(|e| format!("restore ACME account from credentials: {e}")) } else { let (account, creds) = builder .create( @@ -1043,12 +1051,18 @@ async fn issue_with_instant_acme( ) .await .map_err(|e| format!("ACME account create failed: {e}"))?; - (account, Some(creds)) - }; - - if let Some(creds) = credentials { write_account_credentials(&req.account_credentials_path, &creds)?; + Ok(account) } +} + +async fn issue_with_instant_acme( + req: &IssueRequest, + dns: &dyn DnsProvider, +) -> Result { + use instant_acme::{ChallengeType, Identifier, NewOrder, RetryPolicy}; + + let account = open_acme_account(req).await?; // DNS-01: track each FQDN immediately after set_txt; always clear on exit // (success or any failure after publish: wait, set_ready, poll, finalize, cert). @@ -1130,6 +1144,204 @@ async fn issue_with_instant_acme( order_result } +fn http01_challenge_dir(webroot: &Path) -> PathBuf { + webroot.join(".well-known").join("acme-challenge") +} + +/// Write one HTTP-01 key authorization. The file name is the token. +/// Body is the exact key authorization, no extra newline. +fn write_http01_token_file(webroot: &Path, token: &str, body: &str) -> Result { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + + if http01_token_from_path(&format!("/.well-known/acme-challenge/{token}")).is_none() { + return Err("ACME HTTP-01 token is not a safe single path segment".into()); + } + if body.is_empty() + || body.len() > 512 + || !body.is_ascii() + || body.as_bytes().contains(&b'\n') + || body.as_bytes().contains(&0u8) + { + return Err( + "ACME HTTP-01 key authorization must be one non-empty ASCII line \ + (fail-closed)" + .into(), + ); + } + let dir = http01_challenge_dir(webroot); + std::fs::create_dir_all(&dir) + .map_err(|e| format!("create HTTP-01 challenge directory {}: {e}", dir.display()))?; + let path = dir.join(token); + if path.exists() { + let meta = std::fs::symlink_metadata(&path) + .map_err(|e| format!("stat HTTP-01 token {}: {e}", path.display()))?; + if meta.file_type().is_symlink() || !meta.is_file() { + return Err(format!( + "HTTP-01 token path is not a regular file: {}", + path.display() + )); + } + } + let tmp = dir.join(format!(".{token}.tmp")); + { + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o644) + .open(&tmp) + .map_err(|e| format!("open HTTP-01 token temp {}: {e}", tmp.display()))?; + file.write_all(body.as_bytes()) + .map_err(|e| format!("write HTTP-01 token temp {}: {e}", tmp.display()))?; + file.sync_all() + .map_err(|e| format!("sync HTTP-01 token temp {}: {e}", tmp.display()))?; + } + std::fs::rename(&tmp, &path).map_err(|e| { + let _ = std::fs::remove_file(&tmp); + format!("rename HTTP-01 token into {}: {e}", path.display()) + })?; + Ok(path) +} + +fn remove_http01_token_files(paths: &[PathBuf]) { + for path in paths { + let _ = std::fs::remove_file(path); + } +} + +/// Let's Encrypt production (or any configured directory) via HTTP-01. +/// +/// Token files land under `webroot`. The already-running management UI +/// serves them on port 80. This function does not bind a port and does +/// not call Namecheap. +async fn issue_http01(acme: &AcmeConfig) -> Result { + use instant_acme::{ChallengeType, Identifier, NewOrder, RetryPolicy}; + + if acme.challenge != AcmeChallenge::Http01 { + return Err("HTTP-01 issuer requires SURMOUNT_ACME_CHALLENGE=http-01".into()); + } + if path_is_blank(&acme.http01_webroot) || !acme.http01_webroot.is_absolute() { + return Err("HTTP-01 issuer requires an absolute SURMOUNT_ACME_HTTP01_WEBROOT".into()); + } + let req = IssueRequest { + domains: acme.domains.clone(), + email: acme.email.clone(), + directory_url: acme.directory_url.clone(), + account_credentials_path: acme.account_credentials_path.clone(), + }; + let account = open_acme_account(&req).await?; + let mut published: Vec = Vec::new(); + let order_result = async { + let identifiers: Vec = req + .domains + .iter() + .map(|d| Identifier::Dns(d.clone())) + .collect(); + let mut order = account + .new_order(&NewOrder::new(identifiers.as_slice())) + .await + .map_err(|e| format!("ACME new_order failed: {e}"))?; + let mut authorizations = order.authorizations(); + while let Some(result) = authorizations.next().await { + let mut authz = result.map_err(|e| format!("ACME authorization: {e}"))?; + match authz.status { + instant_acme::AuthorizationStatus::Pending => {} + instant_acme::AuthorizationStatus::Valid => continue, + other => { + return Err(format!("ACME authorization unexpected status: {other:?}")); + } + } + let mut challenge = authz + .challenge(ChallengeType::Http01) + .ok_or_else(|| "ACME order has no HTTP-01 challenge".to_string())?; + let aid = challenge.identifier(); + if aid.wildcard { + return Err( + "wildcard ACME identifiers are not supported for HTTP-01 in this build \ + (fail-closed)" + .into(), + ); + } + let token = challenge.token.clone(); + let body = challenge.key_authorization().as_str().to_string(); + let path = write_http01_token_file(&acme.http01_webroot, &token, &body)?; + published.push(path); + challenge + .set_ready() + .await + .map_err(|e| format!("ACME challenge set_ready: {e}"))?; + } + let status = order + .poll_ready(&RetryPolicy::default()) + .await + .map_err(|e| format!("ACME poll_ready: {e}"))?; + if status != instant_acme::OrderStatus::Ready { + return Err(format!( + "ACME order not ready after HTTP-01 (status={status:?}); fail-closed" + )); + } + let key_pem = order + .finalize() + .await + .map_err(|e| format!("ACME finalize (CSR/key): {e}"))?; + let cert_pem = order + .poll_certificate(&RetryPolicy::default()) + .await + .map_err(|e| format!("ACME poll_certificate: {e}"))?; + Ok(IssuedCert { cert_pem, key_pem }) + } + .await; + remove_http01_token_files(&published); + order_result +} + +/// What `acme-renew` did. NotDue means the leaf is still outside the window. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Http01RenewOutcome { + /// Existing PEMs are still usable. Nothing was written. + NotDue, + /// New cert and key were written to the configured paths. + WrotePems, +} + +/// Host timer entry. HTTP-01 only. Refuses DNS-01 and Namecheap hooks. +/// +/// Skips the directory when the leaf is outside the early-renew window and +/// already covers the configured names. Otherwise issues and writes PEMs. +pub async fn renew_http01_if_due( + paths: &TlsPaths, + acme: &AcmeConfig, +) -> Result { + if !acme.enable { + return Err("acme-renew requires SURMOUNT_ACME_ENABLE (fail-closed)".into()); + } + if acme.challenge != AcmeChallenge::Http01 { + return Err( + "acme-renew is the HTTP-01 path on this host. dns-01 stays the \ + in-process adapter and is not this timer (fail-closed)" + .into(), + ); + } + if acme.dns_provider != DnsProviderKind::None { + return Err("HTTP-01 renew requires SURMOUNT_ACME_DNS_PROVIDER=none \ + (Namecheap credentials stay off this host; fail-closed)" + .into()); + } + let now = SystemTime::now(); + let domains = if acme.domains.is_empty() { + None + } else { + Some(acme.domains.as_slice()) + }; + if pem_material_usable(paths, now, domains, acme.renew_days_before_expiry)? { + return Ok(Http01RenewOutcome::NotDue); + } + let issued = issue_http01(acme).await?; + write_issued_pems(paths, &issued)?; + Ok(Http01RenewOutcome::WrotePems) +} + fn write_account_credentials( path: &Path, creds: &instant_acme::AccountCredentials, @@ -1554,6 +1766,61 @@ pub enum TlsMaterialDecision { IssuedAndWrotePems { mock: bool }, } +/// Token file name from an HTTP-01 request path. Rejects traversal. +pub fn http01_token_from_path(path_and_query: &str) -> Option<&str> { + let path = path_and_query.split('?').next().unwrap_or(path_and_query); + let rest = path.strip_prefix("/.well-known/acme-challenge/")?; + if rest.is_empty() + || rest.contains('/') + || rest.contains('\\') + || rest.contains('.') + || rest.contains("..") + { + return None; + } + if !rest + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') + { + return None; + } + Some(rest) +} + +/// Read one HTTP-01 key authorization from the webroot. None when this +/// process is not in HTTP-01 mode, or the token file is missing. +/// +/// The file must be a regular file under `webroot/.well-known/acme-challenge/`, +/// at most 512 bytes, ASCII, and not a symlink. +pub fn read_http01_challenge_file(acme: &AcmeConfig, path_and_query: &str) -> Option> { + if !acme.enable + || acme.challenge != AcmeChallenge::Http01 + || path_is_blank(&acme.http01_webroot) + { + return None; + } + let token = http01_token_from_path(path_and_query)?; + let path = acme + .http01_webroot + .join(".well-known") + .join("acme-challenge") + .join(token); + let meta = std::fs::symlink_metadata(&path).ok()?; + if !meta.is_file() || meta.len() == 0 || meta.len() > 512 { + return None; + } + let root = acme.http01_webroot.canonicalize().ok()?; + let file = path.canonicalize().ok()?; + if !file.starts_with(&root) { + return None; + } + let bytes = std::fs::read(&path).ok()?; + if bytes.is_empty() || bytes.len() > 512 || !bytes.iter().all(|b| b.is_ascii() && *b != 0) { + return None; + } + Some(bytes) +} + /// Prepare TLS PEMs for HTTPS listen. /// /// - ACME **off**: require existing PEMs (fail closed). Same as historical path. @@ -1578,6 +1845,19 @@ pub async fn ensure_tls_material( } else { Some(acme.domains.as_slice()) }; + // HTTP-01 issuance is the surmount-cert-renew timer, not this process. + // Keep serving a leaf that has not reached notAfter. Early renew is the + // timer's job, so a missed run does not take HTTPS down inside the window. + if acme.challenge == AcmeChallenge::Http01 { + if pem_material_usable(paths, now, domains, 0)? { + return Ok(TlsMaterialDecision::ReusedExistingPems); + } + return Err( + "HTTP-01 PEMs are missing or expired. The surmount-cert-renew unit \ + must write the TLS cert and key (fail-closed; no silent cleartext)" + .into(), + ); + } if pem_material_usable(paths, now, domains, acme.renew_days_before_expiry)? { return Ok(TlsMaterialDecision::ReusedExistingPems); } @@ -1737,9 +2017,121 @@ mod tests { } #[test] - fn acme_http01_challenge_refused() { - let err = AcmeChallenge::parse("http-01").unwrap_err(); - assert!(err.contains("parked") || err.contains("HTTP-01"), "{err}"); + fn acme_http01_challenge_parses() { + assert_eq!( + AcmeChallenge::parse("http-01").unwrap(), + AcmeChallenge::Http01 + ); + assert_eq!(AcmeChallenge::Http01.as_str(), "http-01"); + assert_eq!( + http01_token_from_path("/.well-known/acme-challenge/abc_DEF-123"), + Some("abc_DEF-123") + ); + assert_eq!( + http01_token_from_path("/.well-known/acme-challenge/abc/def"), + None + ); + assert_eq!( + http01_token_from_path("/.well-known/acme-challenge/.."), + None + ); + assert_eq!(http01_token_from_path("/health"), None); + } + + #[test] + fn acme_http01_requires_absolute_webroot() { + let err = AcmeConfig::from_env_map(|k| match k { + "SURMOUNT_ACME_ENABLE" => Some("1".into()), + "SURMOUNT_ACME_DOMAINS" => Some("services.example.test".into()), + "SURMOUNT_ACME_EMAIL" => Some("ops@example.test".into()), + "SURMOUNT_ACME_DIRECTORY" => Some(directories::LETS_ENCRYPT_PRODUCTION.into()), + "SURMOUNT_ACME_ACCOUNT_CREDENTIALS_PATH" => { + Some("/var/lib/surmount/secrets/acme/account.json".into()) + } + "SURMOUNT_ACME_CHALLENGE" => Some("http-01".into()), + "SURMOUNT_ACME_DNS_PROVIDER" => Some("none".into()), + _ => None, + }) + .unwrap_err(); + assert!( + err.contains("HTTP01_WEBROOT") || err.contains("webroot"), + "{err}" + ); + + let cfg = AcmeConfig::from_env_map(|k| match k { + "SURMOUNT_ACME_ENABLE" => Some("1".into()), + "SURMOUNT_ACME_DOMAINS" => Some("services.example.test".into()), + "SURMOUNT_ACME_EMAIL" => Some("ops@example.test".into()), + "SURMOUNT_ACME_DIRECTORY" => Some(directories::LETS_ENCRYPT_PRODUCTION.into()), + "SURMOUNT_ACME_ACCOUNT_CREDENTIALS_PATH" => { + Some("/var/lib/surmount/secrets/acme/account.json".into()) + } + "SURMOUNT_ACME_CHALLENGE" => Some("http-01".into()), + "SURMOUNT_ACME_DNS_PROVIDER" => Some("none".into()), + "SURMOUNT_ACME_HTTP01_WEBROOT" => Some("/var/lib/surmount/acme-http01".into()), + _ => None, + }) + .unwrap(); + assert_eq!(cfg.challenge, AcmeChallenge::Http01); + assert_eq!(cfg.dns_provider, DnsProviderKind::None); + assert_eq!( + cfg.http01_webroot, + PathBuf::from("/var/lib/surmount/acme-http01") + ); + } + + #[test] + fn http01_token_file_roundtrip_and_rejects_dotdot() { + let dir = temp_dir("acme-http01-token"); + let err = write_http01_token_file(&dir, "..", "x").unwrap_err(); + assert!( + err.contains("safe"), + "dotdot token must be refused, got {err}" + ); + write_http01_token_file(&dir, "abc_DEF-123", "abc_DEF-123.thumb").unwrap(); + let acme = AcmeConfig { + enable: true, + challenge: AcmeChallenge::Http01, + http01_webroot: dir.clone(), + ..AcmeConfig::default() + }; + let body = read_http01_challenge_file(&acme, "/.well-known/acme-challenge/abc_DEF-123") + .expect("token file"); + assert_eq!(body, b"abc_DEF-123.thumb"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn renew_http01_refuses_dns01() { + let dir = temp_dir("acme-http01-refuse"); + let cert = dir.join("cert.pem"); + let key = dir.join("key.pem"); + let acme = AcmeConfig { + enable: true, + challenge: AcmeChallenge::Dns01, + dns_provider: DnsProviderKind::None, + directory_url: "https://acme-v02.api.letsencrypt.org/directory".into(), + email: "ops@example.test".into(), + domains: vec!["services.example.test".into()], + account_credentials_path: dir.join("account.json"), + http01_webroot: dir.join("webroot"), + ..AcmeConfig::default() + }; + let paths = TlsPaths { + cert_path: cert, + key_path: key, + }; + let err = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(renew_http01_if_due(&paths, &acme)) + .unwrap_err(); + assert!( + err.contains("HTTP-01"), + "dns-01 must not take the host renew path, got {err}" + ); + let _ = std::fs::remove_dir_all(&dir); } #[test] diff --git a/crates/management-ui/src/api.rs b/crates/management-ui/src/api.rs index 427f1fa..bbfd5b3 100644 --- a/crates/management-ui/src/api.rs +++ b/crates/management-ui/src/api.rs @@ -49,8 +49,9 @@ pub struct DomainsInventory { /// Build config-backed domain inventory (not a live Stalwart directory list). /// /// Union, dedupe, stable-sort: primary + mail + services, derived -/// `www.{primary}` and `mta-sts.{primary}`, extra mail hostnames, then -/// every `static_vhosts` key. First role wins when a name appears twice. +/// `www.{primary}`, `mta-sts.{primary}`, and `status.{primary}`, extra +/// mail hostnames, then every `static_vhosts` key. First role wins when +/// a name appears twice. pub fn domains_inventory(config: &AppConfig) -> DomainsInventory { let mut by_name: BTreeMap = BTreeMap::new(); let mut insert = |name: &str, role: &str| { @@ -65,6 +66,10 @@ pub fn domains_inventory(config: &AppConfig) -> DomainsInventory { insert(&config.services_hostname, "services_hostname"); insert(&format!("www.{}", config.primary_domain), "www"); insert(&format!("mta-sts.{}", config.primary_domain), "mta_sts"); + insert( + &crate::redirect::status_hostname(&config.primary_domain), + "status_hostname", + ); for host in &config.extra_mail_hostnames { insert(host, "extra_mail_hostname"); } @@ -1151,6 +1156,7 @@ mod tests { "example.test", "www.example.test", "mta-sts.example.test", + "status.example.test", "mail.example.test", "services.example.test", "yiffa.app", @@ -1184,6 +1190,11 @@ mod tests { .iter() .any(|d| d.role == "mta_sts" && d.name == "mta-sts.example.test") ); + assert!( + inv.domains + .iter() + .any(|d| d.role == "status_hostname" && d.name == "status.example.test") + ); assert!( inv.domains .iter() diff --git a/crates/management-ui/src/config.rs b/crates/management-ui/src/config.rs index 0f50363..38bbee3 100644 --- a/crates/management-ui/src/config.rs +++ b/crates/management-ui/src/config.rs @@ -512,13 +512,14 @@ impl AppConfig { .unwrap_or_else(|| { // Default open-redirect allowlist: services (operator console), // apex + www (public COMING SOON; same-host HTTPS upgrade), - // mail name, MTA-STS policy host. + // mail name, MTA-STS policy host, systems dashboard Host. vec![ services_hostname.clone(), primary_domain.clone(), format!("www.{primary_domain}"), mail_hostname.clone(), format!("mta-sts.{primary_domain}"), + crate::redirect::status_hostname(&primary_domain), ] }); @@ -554,6 +555,7 @@ impl AppConfig { let extra_mail_hostnames = extra_mail_hostnames_from_env(); let redirect_allowed_hosts = union_static_vhost_hosts(redirect_allowed_hosts, &static_vhosts); + let redirect_allowed_hosts = union_status_hostname(redirect_allowed_hosts, &primary_domain); // Vaultwarden: operator-published URL only. Never invent; no admin token. let vaultwarden_url = resolve_vaultwarden_url_from_env(); // Path proxy to loopback Rocket (default off). Fail-closed on bad prefix/upstream. @@ -742,6 +744,42 @@ impl AppConfig { } } +/// Settings `surmount-management-ui acme-renew` needs. +/// +/// The command is not an HTTP route. It must not go through +/// [`AppConfig::from_env`], because that parse refuses nostr mode without +/// `SURMOUNT_SESSION_SECRET` before any issuance. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AcmeRenewSettings { + pub acme: AcmeConfig, + pub tls: TlsPaths, +} + +impl AcmeRenewSettings { + /// Load ACME and TLS settings for the host renew command. + /// + /// Does not call [`AppConfig::from_env`]. That parse validates nostr + /// auth and refuses a missing session secret. This command does not + /// serve HTTP and does not need that secret. + pub fn from_env() -> Result { + let acme = AcmeConfig::from_env()?; + let listen = ListenMode::from_parts( + env::var("SURMOUNT_LISTEN_MODE").ok().as_deref(), + env::var("SURMOUNT_TLS_CERT").ok().as_deref(), + env::var("SURMOUNT_TLS_KEY").ok().as_deref(), + )?; + let tls = match listen { + ListenMode::Https(paths) => paths, + ListenMode::PlainHttp => { + return Err("acme-renew requires SURMOUNT_LISTEN_MODE=https with \ + SURMOUNT_TLS_CERT and SURMOUNT_TLS_KEY (fail-closed)" + .into()); + } + }; + Ok(Self { acme, tls }) + } +} + /// SoT for truthy env flags (`1` / `true` / `yes` / `on`, case-insensitive). /// /// Used by [`env_bool`] and directory lab-escape @@ -900,6 +938,20 @@ pub fn extra_onion_hosts_union( hosts } +fn union_status_hostname(mut allowed: Vec, primary_domain: &str) -> Vec { + let status = crate::redirect::status_hostname(primary_domain); + if status.is_empty() { + return allowed; + } + if !allowed + .iter() + .any(|a| crate::redirect::host_is_allowlisted(&status, std::slice::from_ref(a))) + { + allowed.push(status); + } + allowed +} + pub fn union_static_vhost_hosts( mut allowed: Vec, vhosts: &BTreeMap, @@ -1029,6 +1081,7 @@ mod tests { "SURMOUNT_ACME_DOMAINS", "SURMOUNT_ACME_ACCOUNT_CREDENTIALS_PATH", "SURMOUNT_ACME_CHALLENGE", + "SURMOUNT_ACME_HTTP01_WEBROOT", "SURMOUNT_ACME_DNS_PROVIDER", "SURMOUNT_ACME_DNS_HOOK", "SURMOUNT_ACME_DNS_HOOK_TIMEOUT_SECS", @@ -1094,6 +1147,13 @@ mod tests { fn splora_proxy_and_http3_default_off_on_plain_http() { let _g = EnvGuard::acquire(); let cfg = AppConfig::from_env().unwrap(); + assert!( + cfg.redirect_allowed_hosts + .iter() + .any(|h| h == "status.surmount.systems"), + "default :80 allowlist must include systems dashboard Host: {:?}", + cfg.redirect_allowed_hosts + ); assert!(!cfg.splora_proxy.enable); assert!(cfg.splora_proxy.portal_host.is_none()); assert!(!cfg.http3.enable); @@ -1346,6 +1406,77 @@ mod tests { ); } + /// Named contract: `acme-renew` is not an HTTP route. Nostr mode without + /// `SURMOUNT_SESSION_SECRET` must not refuse before issue. The HTTP + /// server path still refuses that same environment. + #[test] + fn acme_renew_nostr_without_session_secret_does_not_exit_before_issue() { + let _g = EnvGuard::acquire(); + set_env("SURMOUNT_AUTH_MODE", "nostr"); + set_env("SURMOUNT_LISTEN", "0.0.0.0:443"); + set_env("SURMOUNT_LISTEN_MODE", "https"); + set_env( + "SURMOUNT_TLS_CERT", + "/var/lib/surmount/secrets/tls/cert.pem", + ); + set_env("SURMOUNT_TLS_KEY", "/var/lib/surmount/secrets/tls/key.pem"); + set_env("SURMOUNT_ACME_ENABLE", "1"); + set_env("SURMOUNT_ACME_CHALLENGE", "http-01"); + set_env("SURMOUNT_ACME_DNS_PROVIDER", "none"); + set_env( + "SURMOUNT_ACME_DIRECTORY", + "https://acme-v02.api.letsencrypt.org/directory", + ); + set_env("SURMOUNT_ACME_EMAIL", "ops@example.test"); + set_env( + "SURMOUNT_ACME_DOMAINS", + "services.example.test,mail.example.test", + ); + set_env( + "SURMOUNT_ACME_ACCOUNT_CREDENTIALS_PATH", + "/var/lib/surmount/secrets/acme/account.json", + ); + set_env( + "SURMOUNT_ACME_HTTP01_WEBROOT", + "/var/lib/surmount/acme-http01", + ); + + let settings = AcmeRenewSettings::from_env(); + assert!( + settings.is_ok(), + "acme-renew exited before issue (nostr without session secret): {settings:?}" + ); + let settings = settings.unwrap(); + assert!(settings.acme.enable); + assert_eq!(settings.acme.challenge, crate::acme::AcmeChallenge::Http01); + assert_eq!( + settings.acme.dns_provider, + crate::acme::DnsProviderKind::None + ); + assert_eq!( + settings.acme.domains, + vec![ + "services.example.test".to_string(), + "mail.example.test".to_string() + ] + ); + assert_eq!( + settings.tls.cert_path.as_os_str(), + std::ffi::OsStr::new("/var/lib/surmount/secrets/tls/cert.pem") + ); + assert_eq!( + settings.tls.key_path.as_os_str(), + std::ffi::OsStr::new("/var/lib/surmount/secrets/tls/key.pem") + ); + + let err = AppConfig::from_env() + .expect_err("the HTTP server must still refuse nostr without the session secret"); + assert!( + err.contains("SESSION_SECRET"), + "full UI config must still refuse nostr without the session secret: {err}" + ); + } + #[test] fn auth_mode_nostr_with_secret_ok() { let _g = EnvGuard::acquire(); @@ -1811,6 +1942,7 @@ mod tests { ); assert!(!hosts.contains(&"www.example.test")); assert!(!hosts.contains(&"mta-sts.example.test")); + assert!(!hosts.contains(&"status.example.test")); assert!(!hosts.contains(&"mail.example.test")); let services = cfg.onion_discovery.lookup("services.example.test").unwrap(); assert_eq!(services.onion_port, 443); diff --git a/crates/management-ui/src/http3.rs b/crates/management-ui/src/http3.rs index dbb3681..9da7f92 100644 --- a/crates/management-ui/src/http3.rs +++ b/crates/management-ui/src/http3.rs @@ -371,6 +371,7 @@ mod tests { queue_path: DEFAULT_QUEUE_PATH.into(), body_limit_bytes: DEFAULT_BODY_LIMIT_BYTES, portal_host: None, + web_root: PathBuf::new(), }; let http3_cfg = Http3Config { enable: true, diff --git a/crates/management-ui/src/main.rs b/crates/management-ui/src/main.rs index 1212130..71f68bf 100644 --- a/crates/management-ui/src/main.rs +++ b/crates/management-ui/src/main.rs @@ -14,16 +14,18 @@ //! when that directory has index.html; otherwise UNDER CONSTRUCTION //! (same-host HTTP->HTTPS). Extra Hosts from `SURMOUNT_STATIC_VHOSTS` (or //! `_FILE`) use the same path/MIME/CSP rules with a Host -> root map. -//! Operator console only on the services hostname. +//! Operator console only on the services hostname. `status.{primary}` +//! (production: status.surmount.systems) is the public systems dashboard. //! - Optional MTA-STS policy at `/.well-known/mta-sts.txt` (default off) //! - Optional loopback cleartext **full API** listener (`SURMOUNT_LOCAL_CLEARTEXT_LISTEN`) //! for local reverse-proxies (Arti HS lean path) when primary is https //! - In-memory fixed-window rate limit; trusted client IP when peer is loopback //! - Ban decision layer (whitelist last-used, optional enforce/dry-run; default off) -//! - Optional in-process ACME (DNS-01; default off). HTTP-01 on product :80 parked. +//! - Optional in-process ACME (DNS-01; default off). HTTP-01 challenge files +//! are served from `SURMOUNT_ACME_HTTP01_WEBROOT` when that challenge is on. //! //! Stalwart remains the mail engine. Auth: Nostr foundation (mode off by default). -//! ACME HTTP-01 on product :80 is parked (see RESIDUAL.md). +//! Enduring Let's Encrypt renewal is the surmount-1 `surmount-cert-renew` timer. mod acme; mod apex_static; @@ -36,6 +38,7 @@ mod onion_discovery; mod pages; mod proxy_vaultwarden; mod redirect; +mod status; mod tls; use std::net::SocketAddr; @@ -62,13 +65,13 @@ use crate::apex_static::{ apex_static_file, document_root_is_ready, public_site_csp, serve_apex_static_file, }; use crate::client_ip::client_ip_for_rate_limit; -use crate::config::AppConfig; +use crate::config::{AcmeRenewSettings, AppConfig}; use crate::directory::Directory; use crate::mta_sts::{mta_sts_policy_body, should_serve_mta_sts_policy}; use crate::redirect::{ HostSurface, HttpToHttps, RedirectBindDecision, RedirectStatus, apex_public_path_is_edge_exception, classify_host_surface, https_port_for_redirect, - is_public_static_surface, redirect_bind_decision, redirect_http_to_https, + is_public_non_console_surface, redirect_bind_decision, redirect_http_to_https, request_authority_host, should_reject_public_static_api, should_serve_apex_coming_soon, static_vhost_document_root, }; @@ -124,6 +127,37 @@ async fn main() -> anyhow::Result<()> { // and other rustls consumers panic without this. Already-installed is OK. let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + // Host timer: `surmount-management-ui acme-renew`. Issues HTTP-01 and + // writes the PEM paths, then exits. Does not bind :80 or :443. The + // already-running process serves the challenge files. + // + // This branch is before AppConfig::from_env. That parse refuses nostr + // mode without the session secret. acme-renew is not an HTTP route and + // loads ACME and TLS settings only. + if std::env::args().nth(1).as_deref() == Some("acme-renew") { + if std::env::args().len() != 2 { + bail!("acme-renew takes no extra arguments"); + } + let settings = AcmeRenewSettings::from_env() + .map_err(anyhow::Error::msg) + .context( + "acme-renew loads ACME and TLS settings only (no session secret; fail-closed)", + )?; + match crate::acme::renew_http01_if_due(&settings.tls, &settings.acme) + .await + .map_err(anyhow::Error::msg) + .context("HTTP-01 renew (fail-closed; no Namecheap)")? + { + crate::acme::Http01RenewOutcome::NotDue => { + info!("HTTP-01 renew not due; existing PEMs still usable"); + } + crate::acme::Http01RenewOutcome::WrotePems => { + info!("HTTP-01 renew wrote the TLS cert and key this host presents"); + } + } + return Ok(()); + } + let config = AppConfig::from_env().map_err(anyhow::Error::msg)?; config .validate_local_cleartext() @@ -735,7 +769,7 @@ async fn security_headers_middleware( }; let vault_proxy = state.config.vaultwarden_proxy.enable && state.config.vaultwarden_proxy.matches_path(&vault_path); - let public_static = is_public_static_surface(classify_host_surface( + let public_static = is_public_non_console_surface(classify_host_surface( &csp_host, state.config.primary_domain.as_str(), state.config.services_hostname.as_str(), @@ -1598,6 +1632,17 @@ async fn http_redirect_handler(State(state): State>, request: Requ .path_and_query() .map(|pq| pq.as_str()) .unwrap_or("/"); + // HTTP-01 token, before the HTTPS redirect. Let's Encrypt fetches this + // on port 80. Other paths still redirect. + if let Some(body) = crate::acme::read_http01_challenge_file(&state.config.acme, path_and_query) + { + return ( + StatusCode::OK, + [("content-type", "text/plain; charset=utf-8")], + body, + ) + .into_response(); + } let https_port = https_port_for_redirect(state.config.listen.port()); match http_upgrade_location( true, @@ -1630,6 +1675,16 @@ async fn apex_public_host_middleware( let services = state.config.services_hostname.as_str(); let path_only = path.split('?').next().unwrap_or(path); + // HTTP-01 on the HTTPS listener too, in case a client followed the :80 redirect. + if let Some(body) = crate::acme::read_http01_challenge_file(&state.config.acme, path_only) { + return ( + StatusCode::OK, + [("content-type", "text/plain; charset=utf-8")], + body, + ) + .into_response(); + } + // Router::layer runs after routing. Onion Host rewrite sets the mapped // clearnet Host; serve MTA-STS here when that Host is the policy name. if path_only == "/.well-known/mta-sts.txt" @@ -1648,6 +1703,14 @@ async fn apex_public_host_middleware( return (StatusCode::NOT_FOUND, "not found").into_response(); } let surface = classify_host_surface(&host, primary, services, &state.config.static_vhosts); + if surface == HostSurface::SystemsStatus && path_only != "/health" && path_only != "/api/health" + { + return crate::status::serve_systems_status( + &state.config, + path_only, + header_str(request.headers(), "accept"), + ); + } if surface == HostSurface::ApexPublic && !apex_public_path_is_edge_exception(path) && let Some(root) = state.config.apex_public_root.as_deref() @@ -2063,6 +2126,7 @@ mod edge_wire_tests { "example.test".into(), "www.example.test".into(), "mta-sts.example.test".into(), + "status.example.test".into(), ], https_allow_cleartext_escape: false, acme: crate::acme::AcmeConfig::default(), @@ -2126,6 +2190,7 @@ mod edge_wire_tests { "example.test".into(), "www.example.test".into(), "mta-sts.example.test".into(), + "status.example.test".into(), ], https_allow_cleartext_escape: false, acme: crate::acme::AcmeConfig::default(), @@ -3435,6 +3500,126 @@ mod edge_wire_tests { let _ = serve.await; } + /// Named contract: Host status.{primary} is the systems dashboard on Axum. + /// Hermetic GET calls the shipped handler (build_router), not a stub. + #[tokio::test] + async fn status_host_serves_systems_dashboard_not_console() { + let state = test_state(0); + let app = build_router(state.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let serve = tokio::spawn(async move { + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await + .ok(); + }); + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(2)) + .build() + .unwrap(); + + let res = client + .get(format!("http://{addr}/")) + .header("Host", "status.example.test") + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::OK); + let body = res.text().await.unwrap(); + assert!(!body.is_empty(), "systems dashboard body must be non-empty"); + assert!( + body.contains("data-surface=\"systems-status\"") || body.contains("systems_status"), + "must mark systems status surface: {body}" + ); + for field in ["mail", "edge", "tls", "mta_sts", "console"] { + assert!( + body.contains(field), + "handler must emit named system {field}: {body}" + ); + } + let lower = body.to_ascii_lowercase(); + assert!( + !lower.contains("dashboard overview"), + "must not be the operator console: {body}" + ); + assert!( + !body.contains("UNDER CONSTRUCTION"), + "status Host is not coming-soon: {body}" + ); + + let json_res = client + .get(format!("http://{addr}/status.json")) + .header("Host", "status.example.test") + .send() + .await + .unwrap(); + assert_eq!(json_res.status(), reqwest::StatusCode::OK); + let json: serde_json::Value = json_res.json().await.unwrap(); + assert_eq!(json["surface"], "systems_status"); + assert_eq!(json["service"], "surmount"); + assert_eq!(json["host"], "status.example.test"); + let systems = json["systems"].as_array().expect("systems array"); + assert!( + !systems.is_empty(), + "systems array must be non-empty: {json}" + ); + let names: Vec<&str> = systems.iter().filter_map(|s| s["name"].as_str()).collect(); + for must in ["mail", "edge", "tls", "mta_sts", "console"] { + assert!( + names.contains(&must), + "JSON must name system {must}: {json}" + ); + } + + let api = client + .get(format!("http://{addr}/api/v1/system")) + .header("Host", "status.example.test") + .send() + .await + .unwrap(); + assert_eq!( + api.status(), + reqwest::StatusCode::NOT_FOUND, + "status Host must not serve the operator system API" + ); + let api_body = api.text().await.unwrap(); + assert!( + !api_body.contains("onion_discovery"), + "status Host must not dump operator system JSON: {api_body}" + ); + + let console = client + .get(format!("http://{addr}/")) + .header("Host", "services.example.test") + .send() + .await + .unwrap(); + let console_body = console.text().await.unwrap(); + assert!( + !console_body.contains("data-surface=\"systems-status\""), + "services Host must not serve the public systems dashboard" + ); + + let other = client + .get(format!("http://{addr}/")) + .header("Host", "mail.example.test") + .send() + .await + .unwrap(); + let other_body = other.text().await.unwrap(); + assert!( + !other_body.contains("data-surface=\"systems-status\""), + "mail Host must not map to the systems dashboard" + ); + + serve.abort(); + let _ = serve.await; + } + /// Named contract: primary edge apex/www serve UNDER CONSTRUCTION; services keeps console. #[tokio::test] async fn primary_edge_apex_serves_document_root_smoke() { @@ -5626,6 +5811,10 @@ mod edge_wire_tests { hosts.contains(&"mta-sts.example.test"), "auto-map dump must include mta-sts: {hosts:?}" ); + assert!( + hosts.contains(&"status.example.test"), + "auto-map dump must include systems dashboard Host: {hosts:?}" + ); serve.abort(); let _ = serve.await; } diff --git a/crates/management-ui/src/onion_discovery.rs b/crates/management-ui/src/onion_discovery.rs index 756732a..9214959 100644 --- a/crates/management-ui/src/onion_discovery.rs +++ b/crates/management-ui/src/onion_discovery.rs @@ -262,8 +262,9 @@ fn sanitize_protocols(protocols: Vec) -> Vec { /// Public HTTP Hosts that may receive a per-site v3 onion. /// -/// Apex, `www.{apex}`, services, `mta-sts.{apex}`, plus extra static Hosts. -/// Never mail. Never onion names. De-duplicated, insertion order. +/// Apex, `www.{apex}`, services, `mta-sts.{apex}`, `status.{apex}`, plus +/// extra static Hosts. Never mail. Never onion names. De-duplicated, +/// insertion order. #[cfg(test)] pub fn auto_clearnet_hosts( primary_domain: &str, @@ -300,6 +301,10 @@ pub fn auto_clearnet_hosts_excluding_mail( if !out.iter().any(|h| h == &mta) { out.push(mta); } + let status = format!("status.{apex}"); + if !out.iter().any(|h| h == &status) { + out.push(status); + } } for raw in extra_static_hosts { let host = normalize_clearnet_host(raw.as_ref()); @@ -972,6 +977,7 @@ mod tests { "www.example.test".to_string(), "services.example.test".to_string(), "mta-sts.example.test".to_string(), + "status.example.test".to_string(), ] ); } @@ -986,6 +992,7 @@ mod tests { assert!(hosts.contains(&"extra.test".to_string())); assert!(hosts.contains(&"www.extra.test".to_string())); assert!(hosts.contains(&"mta-sts.example.test".to_string())); + assert!(hosts.contains(&"status.example.test".to_string())); assert!( !hosts.contains(&"mail.example.test".to_string()), "mail is not a public site and must not auto-map: {hosts:?}" @@ -1345,6 +1352,7 @@ mod tests { assert!(hosts.contains(&"www.example.test")); assert!(hosts.contains(&"services.example.test")); assert!(hosts.contains(&"mta-sts.example.test")); + assert!(hosts.contains(&"status.example.test")); assert!(!hosts.contains(&"mail.example.test")); } diff --git a/crates/management-ui/src/pages.rs b/crates/management-ui/src/pages.rs index 5763684..435aec4 100644 --- a/crates/management-ui/src/pages.rs +++ b/crates/management-ui/src/pages.rs @@ -1392,7 +1392,7 @@ pub fn render_mail_page_with( "Stalwart HTTP answered. Mail protocols still depend on host DNS, firewall, and account setup. Browser webmail is not shipped yet. Use IMAP and SMTP in Evolution or iPhone Mail, or use JMAP when the authenticated proxy lands." .to_string() } else { - "When down: start Stalwart on the host, confirm the configured probe URL, or use SSH tunnel / bootstrap /stalwart-admin/ while bringing the host up. Probe URL env is documented on System." + "When down: start Stalwart on the host, confirm the configured probe URL, or use an SSH tunnel to the loopback Stalwart HTTP listener while bringing the host up. Probe URL env is documented on System." .to_string() }; diff --git a/crates/management-ui/src/proxy_vaultwarden.rs b/crates/management-ui/src/proxy_vaultwarden.rs index 5430103..5384a16 100644 --- a/crates/management-ui/src/proxy_vaultwarden.rs +++ b/crates/management-ui/src/proxy_vaultwarden.rs @@ -1011,7 +1011,8 @@ mod tests { /// `/run/splora/.http.sock`. The queue is a separate socket and /// path. NIP-98 stays in splora. This edge does not add API keys. Not nginx. /// Not the Electrum newline socket (fail-closed if that socket is configured). -/// Not an explorer: no block, transaction, or address pages. +/// Block, transaction, and address paths under a network prefix are the Trunk +/// shell, not indexer REST. Unprefixed `/block`, `/tx`, and `/address` stay unmatched. pub(crate) mod splora { use std::collections::BTreeMap; @@ -1086,6 +1087,10 @@ pub(crate) mod splora { /// Public portal Host (one DNS name). Not an indexer. Env /// `SURMOUNT_SPLORA_PORTAL_HOST`. None when the proxy is off or unset. pub portal_host: Option, + /// Trunk `dist` directory (the `splora-web` package output). Env + /// `SURMOUNT_SPLORA_WEB_ROOT`. Empty when unset. A missing directory + /// does not invent HTML. + pub web_root: PathBuf, } impl Default for SploraProxyConfig { @@ -1097,6 +1102,7 @@ pub(crate) mod splora { queue_path: DEFAULT_QUEUE_PATH.to_string(), body_limit_bytes: DEFAULT_BODY_LIMIT_BYTES, portal_host: None, + web_root: PathBuf::new(), } } } @@ -1156,6 +1162,11 @@ pub(crate) mod splora { ); } + let web_root = match get("SURMOUNT_SPLORA_WEB_ROOT") { + Some(v) if !v.trim().is_empty() => PathBuf::from(v.trim()), + _ => PathBuf::new(), + }; + let portal_host = parse_portal_host(get("SURMOUNT_SPLORA_PORTAL_HOST"), enable)?; if let Some(ref portal) = portal_host { if instances @@ -1176,6 +1187,7 @@ pub(crate) mod splora { queue_path, body_limit_bytes: DEFAULT_BODY_LIMIT_BYTES, portal_host, + web_root, }) } @@ -1268,12 +1280,21 @@ pub(crate) mod splora { } /// Where one public path goes. No redirect arm: `/signet` and `/mutinynet` - /// are both proxies to the mutinynet socket. + /// are both the mutinynet indexer. Screen routes and the prefix root are + /// the Trunk `index.html` shell. `/api` stays the indexer socket. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SploraPublicRoute { Index, Queue, NotFound, + /// Trunk CSR shell, or a file under the web root. Not proxied. + /// Not mainnet. `rel` is the path inside the Trunk dist + /// (`""` is `index.html`). + Frontend { + instance: &'static str, + prefix: &'static str, + rel: String, + }, Proxy { socket_name: &'static str, backend_path: String, @@ -1284,15 +1305,16 @@ pub(crate) mod splora { /// Path routing for the portal Host. /// - /// Network prefix is stripped first. The query string is kept. A - /// remainder of `/api` or `/api/...` keeps the Splora REST rule: drop a - /// leading `/api`, except exact `/api/v1/ws`, which stays and is the - /// only upgrade. Any other remainder (`/block/...`, `/tx/...`, - /// `/address/...`, `/`, and later UI paths) is forwarded to that network - /// socket. This repo does not render those pages. Unprefixed `/block`, - /// `/tx`, and `/address` are not routes here. `/testnet3` is not - /// `/testnet`. The queue path is not a network prefix. `/api` with no - /// network prefix is mainnet, and is not proxied when mainnet is absent. + /// Network prefix is stripped first. The query string is kept on indexer + /// REST. A remainder of `/api` or `/api/...` keeps the Splora REST rule: + /// drop a leading `/api`, except exact `/api/v1/ws`, which stays and is + /// the only upgrade. Any other remainder is the Trunk shell (SPA + /// fallback) unless that relative path is a file in the web root. That + /// does not include `/docs/api/...`, which is a screen, not indexer REST. + /// Unprefixed `/block`, `/tx`, and `/address` are not routes here. + /// `/testnet3` is not `/testnet`. The queue path is not a network prefix. + /// `/api` with no network prefix is mainnet REST, not a frontend, + /// and is not proxied when mainnet is absent. pub fn route_splora_public( path: &str, query: Option<&str>, @@ -1311,22 +1333,22 @@ pub(crate) mod splora { _ => None, }; if let Some(rest) = strip_named_prefix(path, "/testnet4") { - return proxy_after_network("testnet4", rest, kept, enabled); + return route_after_network("testnet4", "/testnet4", rest, kept, enabled); } if let Some(rest) = strip_named_prefix(path, "/testnet") { - return proxy_after_network("testnet3", rest, kept, enabled); + return route_after_network("testnet3", "/testnet", rest, kept, enabled); } if let Some(rest) = strip_named_prefix(path, "/signet") { - return proxy_after_network("mutinynet", rest, kept, enabled); + return route_after_network("mutinynet", "/signet", rest, kept, enabled); } if let Some(rest) = strip_named_prefix(path, "/mutinynet") { - return proxy_after_network("mutinynet", rest, kept, enabled); + return route_after_network("mutinynet", "/mutinynet", rest, kept, enabled); } if let Some(rest) = strip_named_prefix(path, "/liquid") { - return proxy_after_network("liquid", rest, kept, enabled); + return route_after_network("liquid", "/liquid", rest, kept, enabled); } if path == "/api" || path.starts_with("/api/") { - return proxy_after_network("mainnet", path, kept, enabled); + return route_after_network("mainnet", "", path, kept, enabled); } SploraPublicRoute::NotFound } @@ -1343,8 +1365,9 @@ pub(crate) mod splora { } } - fn proxy_after_network( + fn route_after_network( socket_name: &'static str, + public_prefix: &'static str, after_network: &str, query: Option, enabled: &[&str], @@ -1353,28 +1376,107 @@ pub(crate) mod splora { return SploraPublicRoute::NotFound; } let rest_is_api = after_network == "/api" || after_network.starts_with("/api/"); - let backend_path = if rest_is_api { - backend_path_for_indexer(after_network) - } else { - forward_ui_remainder(after_network) - }; - SploraPublicRoute::Proxy { - socket_name, - backend_path, - query, - websocket: rest_is_api && after_network == INDEXER_WS_PATH, + if rest_is_api { + return SploraPublicRoute::Proxy { + socket_name, + backend_path: backend_path_for_indexer(after_network), + query, + websocket: after_network == INDEXER_WS_PATH, + }; + } + // Mainnet `/api` is the only unprefixed indexer path. Do not mount + // the Trunk app on it. + if socket_name == "mainnet" { + return SploraPublicRoute::NotFound; + } + SploraPublicRoute::Frontend { + instance: socket_name, + prefix: public_prefix, + rel: trunk_rel(after_network), } } - /// Remainder after the network prefix, when it is not REST. - /// Empty is the prefix itself (`/testnet4`). Not HTML from this repo. - fn forward_ui_remainder(after_network: &str) -> String { - if after_network.is_empty() || after_network == "/" { - "/".to_string() - } else if after_network.starts_with('/') { - after_network.to_string() - } else { - format!("/{after_network}") + /// Path inside the Trunk dist. Empty means `index.html`. + fn trunk_rel(after_network: &str) -> String { + after_network.trim_start_matches('/').to_string() + } + + /// Trunk dist file, or `index.html` for a client route. A missing + /// web root does not invent HTML. + fn frontend_response(method: &Method, web_root: &std::path::Path, rel: &str) -> Response { + if *method != Method::GET && *method != Method::HEAD { + return (StatusCode::METHOD_NOT_ALLOWED, "GET only").into_response(); + } + if web_root.as_os_str().is_empty() || !web_root.is_dir() { + return (StatusCode::NOT_FOUND, "not found").into_response(); + } + if !rel.is_empty() { + let Some(path) = web_root_join(web_root, rel) else { + return (StatusCode::NOT_FOUND, "not found").into_response(); + }; + if path.is_file() { + return web_file_response(&path); + } + if trunk_rel_is_asset(rel) { + return (StatusCode::NOT_FOUND, "not found").into_response(); + } + } + let index = web_root.join("index.html"); + if index.is_file() { + return web_file_response(&index); + } + (StatusCode::NOT_FOUND, "not found").into_response() + } + + fn web_root_join(root: &std::path::Path, rel: &str) -> Option { + if rel.contains('\0') { + return None; + } + let mut out = root.to_path_buf(); + for seg in rel.split('/') { + if seg.is_empty() || seg == "." || seg == ".." { + return None; + } + out.push(seg); + } + Some(out) + } + + /// A dotted final segment is a Trunk asset. A missing asset is 404. + /// A path with no extension is a client route and falls back to the shell. + fn trunk_rel_is_asset(rel: &str) -> bool { + let name = rel.rsplit('/').next().unwrap_or(rel); + match name.rsplit_once('.') { + Some((stem, ext)) => !stem.is_empty() && !ext.is_empty(), + None => false, + } + } + + fn web_file_response(path: &std::path::Path) -> Response { + match std::fs::read(path) { + Ok(bytes) => ( + StatusCode::OK, + [(header::CONTENT_TYPE, web_content_type(path))], + bytes, + ) + .into_response(), + Err(_) => (StatusCode::NOT_FOUND, "not found").into_response(), + } + } + + fn web_content_type(path: &std::path::Path) -> &'static str { + match path.extension().and_then(|ext| ext.to_str()) { + Some("html") => "text/html; charset=utf-8", + Some("js") | Some("mjs") => "text/javascript; charset=utf-8", + Some("css") => "text/css; charset=utf-8", + Some("wasm") => "application/wasm", + Some("json") | Some("map") => "application/json", + Some("svg") => "image/svg+xml", + Some("png") => "image/png", + Some("ico") => "image/x-icon", + Some("woff2") => "font/woff2", + Some("txt") => "text/plain; charset=utf-8", + _ => "application/octet-stream", } } @@ -1581,6 +1683,9 @@ pub(crate) mod splora { SploraPublicRoute::Queue => proxy_queue(state, request).await, SploraPublicRoute::Index => portal_response(cfg, request.method(), &path), SploraPublicRoute::NotFound => (StatusCode::NOT_FOUND, "not found").into_response(), + SploraPublicRoute::Frontend { rel, .. } => { + frontend_response(request.method(), &cfg.web_root, &rel) + } SploraPublicRoute::Proxy { socket_name, backend_path, @@ -2104,6 +2209,18 @@ pub(crate) mod splora { let cfg = SploraProxyConfig::from_env_map(|_| None).unwrap(); assert!(!cfg.enable); assert!(cfg.instances.is_empty()); + assert!(cfg.web_root.as_os_str().is_empty()); + } + + #[test] + fn web_root_comes_from_env() { + let cfg = SploraProxyConfig::from_env_map(|k| match k { + "SURMOUNT_SPLORA_WEB_ROOT" => Some("/nix/store/splora-web".into()), + _ => None, + }) + .unwrap(); + assert_eq!(cfg.web_root, PathBuf::from("/nix/store/splora-web")); + assert!(!cfg.enable); } #[test] @@ -2170,6 +2287,7 @@ pub(crate) mod splora { queue_path: DEFAULT_QUEUE_PATH.into(), body_limit_bytes: DEFAULT_BODY_LIMIT_BYTES, portal_host: Some(portal.to_string()), + web_root: PathBuf::new(), } } @@ -2202,6 +2320,157 @@ pub(crate) mod splora { } } + fn expect_shell(route: SploraPublicRoute, instance: &str, prefix: &str, rel: &str) { + match route { + SploraPublicRoute::Frontend { + instance: got_instance, + prefix: got_prefix, + rel: got_rel, + } => { + assert_eq!(got_instance, instance); + assert_eq!(got_prefix, prefix); + assert_eq!(got_rel, rel); + } + other => { + panic!("expected trunk shell {instance} {prefix} rel={rel}, got {other:?}") + } + } + } + + /// Named contract: client paths under a public prefix are the Trunk + /// shell, not a screen list and not indexer REST. `/testnet` is + /// testnet3. `/signet` and `/mutinynet` are mutinynet. `/api` is + /// not a frontend. `/testnet3` is not `/testnet`. + #[test] + fn splora_trunk_shell_routes_follow_public_prefixes() { + let rows = [ + ("/testnet", "testnet3", "/testnet", "dashboard"), + ("/testnet/", "testnet3", "/testnet", "dashboard"), + ("/testnet/blocks", "testnet3", "/testnet", "blocks"), + ("/testnet4/block/abc", "testnet4", "/testnet4", "block"), + ( + "/testnet4/block-height/abc", + "testnet4", + "/testnet4", + "block-height", + ), + ("/liquid/asset/abc", "liquid", "/liquid", "asset"), + ("/liquid/assets", "liquid", "/liquid", "assets"), + ( + "/liquid/assets/asset/abc", + "liquid", + "/liquid", + "assets-asset", + ), + ( + "/liquid/assets/registry", + "liquid", + "/liquid", + "assets-registry", + ), + ( + "/liquid/assets/registry/abc", + "liquid", + "/liquid", + "assets-registry-entry", + ), + ( + "/signet/scripthash/abc", + "mutinynet", + "/signet", + "scripthash", + ), + ( + "/mutinynet/widget/wallet", + "mutinynet", + "/mutinynet", + "widget-wallet", + ), + ("/testnet/tx/push", "testnet3", "/testnet", "tx-push"), + ("/testnet/pushtx", "testnet3", "/testnet", "pushtx"), + ("/testnet/tx/test", "testnet3", "/testnet", "tx-test"), + ("/testnet4/tx/abc", "testnet4", "/testnet4", "tx"), + ("/testnet/address/abc", "testnet3", "/testnet", "address"), + ("/testnet4/mempool", "testnet4", "/testnet4", "mempool"), + ("/signet/calculator", "mutinynet", "/signet", "calculator"), + ("/mutinynet/fiat", "mutinynet", "/mutinynet", "fiat"), + ("/liquid/statistics", "liquid", "/liquid", "statistics"), + ("/liquid/mining", "liquid", "/liquid", "mining"), + ("/liquid/lightning", "liquid", "/liquid", "lightning"), + ("/testnet4/graphs", "testnet4", "/testnet4", "graphs"), + ("/testnet4/hashrate", "testnet4", "/testnet4", "hashrate"), + ( + "/testnet4/price-chart", + "testnet4", + "/testnet4", + "price-chart", + ), + ( + "/testnet/terms-of-service", + "testnet3", + "/testnet", + "terms-of-service", + ), + ( + "/testnet/privacy-policy", + "testnet3", + "/testnet", + "privacy-policy", + ), + ( + "/testnet/trademark-policy", + "testnet3", + "/testnet", + "trademark-policy", + ), + ("/testnet/docs", "testnet3", "/testnet", "docs"), + ("/testnet/docs/faq", "testnet3", "/testnet", "docs-faq"), + ( + "/testnet/docs/api/rest", + "testnet3", + "/testnet", + "docs-api-rest", + ), + ( + "/testnet/docs/api/websocket", + "testnet3", + "/testnet", + "docs-api-type", + ), + ]; + for (path, instance, prefix, _) in rows { + let rel = path + .strip_prefix(prefix) + .unwrap_or("") + .trim_start_matches('/'); + expect_shell(route_four(path, None), instance, prefix, rel); + } + expect_shell( + route_four("/testnet4/not-a-screen", None), + "testnet4", + "/testnet4", + "not-a-screen", + ); + expect_shell( + route_four("/testnet4/pkg.js", None), + "testnet4", + "/testnet4", + "pkg.js", + ); + assert!(matches!( + route_four("/api/blocks", None), + SploraPublicRoute::NotFound + )); + assert!(matches!( + route_four("/testnet3/", None), + SploraPublicRoute::NotFound + )); + assert!(matches!( + route_splora_public("/testnet/", None, DEFAULT_QUEUE_PATH, &["liquid"]), + SploraPublicRoute::NotFound + )); + } + /// Named contract: each public prefix selects that network socket. /// REST drops `/api` (Splora http_front). Mainnet is absent here. #[test] @@ -2329,52 +2598,119 @@ pub(crate) mod splora { ); } - /// Named contract: block, tx, and address paths are forwarded to the - /// network socket. This repo does not render that HTML. Unprefixed - /// `/block`, `/tx`, and `/address` stay unmatched so another app on - /// this router keeps those paths on other hosts. + /// Trunk `index.html` that `trunk build` serves. The rust link stays + /// so the wasm app can mount. This is not a screen list. + const TRUNK_INDEX_HTML: &str = r#" + + + + Splora + + + + + +"#; + + /// Named contract: screen routes and each prefix root return the Trunk + /// shell from `SURMOUNT_SPLORA_WEB_ROOT`. A file that exists in that + /// directory is served as a file. Indexer REST stays a socket proxy. + /// Unprefixed `/block`, `/tx`, and `/address` stay unmatched. + /// `/api` is not a frontend. A missing web root does not invent HTML. #[tokio::test] - async fn splora_ui_paths_forward_without_html() { - expect_proxy( + async fn splora_leptos_frontend_mounted_on_each_indexer() { + let missing = frontend_response( + &Method::GET, + std::path::Path::new("/tmp/splora-web-root-missing-not-a-dir"), + "", + ); + assert_eq!(missing.status(), StatusCode::NOT_FOUND); + let missing_body = axum::body::to_bytes(missing.into_body(), 1024) + .await + .unwrap(); + assert_eq!(missing_body.as_ref(), b"not found"); + + expect_shell( route_four("/testnet4/tx/abc", Some("x=1")), "testnet4", - "/tx/abc", - Some("x=1"), - false, + "/testnet4", + "tx/abc", ); assert!(!matches!( route_four("/testnet4/tx/abc", Some("x=1")), - SploraPublicRoute::Index + SploraPublicRoute::Index | SploraPublicRoute::Proxy { .. } )); - expect_proxy( + expect_shell( route_four("/testnet4/block/abc", None), "testnet4", - "/block/abc", - None, - false, + "/testnet4", + "block/abc", ); - expect_proxy( + expect_shell( route_four("/testnet/address/abc", None), "testnet3", - "/address/abc", - None, - false, + "/testnet", + "address/abc", ); - expect_proxy( + expect_shell( route_four("/liquid/block/abc", None), "liquid", - "/block/abc", + "/liquid", + "block/abc", + ); + expect_shell( + route_four("/signet/tx/abc", None), + "mutinynet", + "/signet", + "tx/abc", + ); + expect_shell( + route_four("/mutinynet/tx/abc", None), + "mutinynet", + "/mutinynet", + "tx/abc", + ); + expect_shell(route_four("/testnet/", None), "testnet3", "/testnet", ""); + expect_shell(route_four("/testnet4/", None), "testnet4", "/testnet4", ""); + expect_shell(route_four("/signet/", None), "mutinynet", "/signet", ""); + expect_shell( + route_four("/mutinynet/", None), + "mutinynet", + "/mutinynet", + "", + ); + expect_shell(route_four("/liquid/", None), "liquid", "/liquid", ""); + expect_shell( + route_four("/testnet/docs/api/rest", None), + "testnet3", + "/testnet", + "docs/api/rest", + ); + expect_proxy( + route_four("/testnet/api/tx/x", None), + "testnet3", + "/tx/x", None, false, ); - let signet = route_four("/signet/tx/abc", None); - let mutiny = route_four("/mutinynet/tx/abc", None); - expect_proxy(signet.clone(), "mutinynet", "/tx/abc", None, false); - expect_proxy(mutiny.clone(), "mutinynet", "/tx/abc", None, false); - assert_eq!(signet, mutiny); - assert!(!matches!(signet, SploraPublicRoute::NotFound)); - expect_proxy(route_four("/testnet4", None), "testnet4", "/", None, false); - expect_proxy(route_four("/testnet4/", None), "testnet4", "/", None, false); + assert!(matches!( + route_four("/api/", None), + SploraPublicRoute::NotFound + )); + assert!(matches!( + route_four("/testnet3/", None), + SploraPublicRoute::NotFound + )); + expect_shell( + route_four("/testnet4/not-a-screen", None), + "testnet4", + "/testnet4", + "not-a-screen", + ); for path in [ "/block/abc", @@ -2398,6 +2734,10 @@ pub(crate) mod splora { } let sock = tmp_sock("splora-ui"); + let web = std::env::temp_dir().join(format!("splora-web-shell-{}", std::process::id())); + std::fs::create_dir_all(&web).unwrap(); + std::fs::write(web.join("index.html"), TRUNK_INDEX_HTML).unwrap(); + std::fs::write(web.join("pkg.js"), "console.log(\"splora\");\n").unwrap(); let seen = Arc::new(Mutex::new(Vec::<(String, String)>::new())); let seen_c = seen.clone(); let mock = Router::new().fallback(any(move |req: AxumRequest| { @@ -2444,6 +2784,7 @@ pub(crate) mod splora { queue_path: DEFAULT_QUEUE_PATH.into(), body_limit_bytes: DEFAULT_BODY_LIMIT_BYTES, portal_host: Some("splora.surmount.systems".into()), + web_root: web.clone(), }; let state = test_state_for(cfg, false); let app = Router::new() @@ -2474,15 +2815,97 @@ pub(crate) mod splora { .and_then(|v| v.to_str().ok()) .unwrap_or(""); assert!( - !tx_type.contains("text/html"), - "UI forward must not be local HTML, content-type={tx_type}" + tx_type.contains("text/html"), + "Trunk shell must be HTML, content-type={tx_type}" ); let tx_body = tx.text().await.unwrap(); - assert_eq!(tx_body, "upstream-bytes"); - assert!(!tx_body.contains(" bool { host_for_url_authority(host_header).eq_ignore_ascii_case(&services) } +/// Derived systems-dashboard Host: `status.{primary}`. +/// +/// Empty primary => empty string. Production primary `surmount.systems` +/// yields `status.surmount.systems`. +pub fn status_hostname(primary_domain: &str) -> String { + let primary = host_for_url_authority(primary_domain); + if primary.is_empty() { + return String::new(); + } + format!("status.{}", primary.to_ascii_lowercase()) +} + +/// True when Host is the public systems dashboard (`status.{primary}`). +/// +/// Case-insensitive DNS labels; port stripped. Empty primary => never true. +pub fn is_status_host(host_header: &str, primary_domain: &str) -> bool { + let want = status_hostname(primary_domain); + if want.is_empty() { + return false; + } + host_for_url_authority(host_header).eq_ignore_ascii_case(&want) +} + /// Which public product surface a request Host maps to. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HostSurface { @@ -117,6 +140,8 @@ pub enum HostSurface { StaticVhost, /// Services hostname: operator management console. ServicesConsole, + /// `status.{primary}`: public systems dashboard (not operator console). + SystemsStatus, /// Other Host (mail, mta-sts, lab IP, empty, unknown): no public remap. Other, } @@ -157,7 +182,8 @@ pub fn host_surface( /// Classify Host including extra static vhosts (Host -> document root map). /// /// Services wins over everything. Apex/www stay ApexPublic even if listed in -/// the extra map (do not overload those names). Unknown Host stays Other +/// the extra map (do not overload those names). `status.{primary}` is +/// SystemsStatus even if listed as a static vhost. Unknown Host stays Other /// (no static leak onto the console). pub fn classify_host_surface( host_header: &str, @@ -171,6 +197,9 @@ pub fn classify_host_surface( if is_apex_or_www(host_header, primary_domain) { return HostSurface::ApexPublic; } + if is_status_host(host_header, primary_domain) { + return HostSurface::SystemsStatus; + } if static_vhost_mapped(host_header, static_vhosts) { return HostSurface::StaticVhost; } @@ -200,6 +229,14 @@ pub fn is_public_static_surface(surface: HostSurface) -> bool { matches!(surface, HostSurface::ApexPublic | HostSurface::StaticVhost) } +/// True when this surface is public and must not use operator-console CSP. +pub fn is_public_non_console_surface(surface: HostSurface) -> bool { + matches!( + surface, + HostSurface::ApexPublic | HostSurface::StaticVhost | HostSurface::SystemsStatus + ) +} + /// Paths that stay available on apex/www (health probes, ACME/MTA-STS well-known). /// /// Everything else on apex/www is the public COMING SOON page (HTML) or a @@ -691,6 +728,75 @@ mod tests { host_surface("evil.example", "example.test", "services.example.test"), HostSurface::Other ); + assert_eq!( + host_surface( + "status.example.test", + "example.test", + "services.example.test" + ), + HostSurface::SystemsStatus + ); + assert_eq!( + host_surface( + "status.surmount.systems", + "surmount.systems", + "services.surmount.systems" + ), + HostSurface::SystemsStatus + ); + } + + /// Named contract: dashboard Host maps to SystemsStatus, not Other/console. + #[test] + fn host_surface_status_is_systems_status() { + assert_eq!( + status_hostname("surmount.systems"), + "status.surmount.systems" + ); + assert_eq!(status_hostname("Example.TEST"), "status.example.test"); + assert!(is_status_host("STATUS.example.test:443", "example.test")); + assert!(!is_status_host("services.example.test", "example.test")); + let mut map = BTreeMap::new(); + map.insert( + "status.example.test".into(), + std::path::PathBuf::from("/var/lib/surmount/static-sites/wrong"), + ); + assert_eq!( + classify_host_surface( + "status.example.test", + "example.test", + "services.example.test", + &map + ), + HostSurface::SystemsStatus + ); + assert_eq!( + classify_host_surface( + "status.example.test", + "example.test", + "services.example.test", + &BTreeMap::new() + ), + HostSurface::SystemsStatus + ); + assert_ne!( + classify_host_surface( + "status.example.test", + "example.test", + "services.example.test", + &BTreeMap::new() + ), + HostSurface::Other + ); + assert_ne!( + classify_host_surface( + "status.example.test", + "example.test", + "services.example.test", + &BTreeMap::new() + ), + HostSurface::ServicesConsole + ); } /// Named contract: extra static Host is StaticVhost; services/apex win; diff --git a/crates/management-ui/src/status.rs b/crates/management-ui/src/status.rs new file mode 100644 index 0000000..8a8086c --- /dev/null +++ b/crates/management-ui/src/status.rs @@ -0,0 +1,1244 @@ +//! Public systems dashboard for Host `status.{primary}` (Axum, not nginx). +//! +//! Not the operator console. Rows come from a product inventory plus live +//! probes. A Splora indexer row is up only when that network's proxy socket +//! accepted a connection. systemd ActiveState=active is not enough. Other +//! rows still use ActiveState / SubState and optional HTTP samples. Host +//! uptime comes from `/proc/uptime`. No RAM, disk, IP, onion, or cookie bytes. + +use std::collections::BTreeMap; +use std::net::IpAddr; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use axum::http::{StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use serde::Serialize; + +use crate::config::AppConfig; +use crate::mta_sts::{MtaStsMode, mta_sts_policy_hostname}; +use crate::redirect::{host_for_url_authority, status_hostname}; + +/// One named systems row the dashboard emits. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SystemsComponent { + pub name: String, + pub role: String, + pub state: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub href: Option, +} + +/// Representative systems status body. Tests assert these field names exist. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SystemsStatusPayload { + pub surface: &'static str, + pub service: &'static str, + pub version: &'static str, + pub host: String, + pub primary_domain: String, + pub uptime: String, + pub systems: Vec, +} + +/// Live or injected sample for one unit / HTTP check. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ComponentProbe { + pub active_state: String, + pub sub_state: String, + pub load_state: String, + pub http_status: Option, + /// None: this row is not a Splora listen check. + /// Some(true): that network's proxy socket accepted a connection. + /// A first HTTP response of 401 on that connection is still up. + /// Some(false): the socket is absent or refused. Not Up. + /// HTTP status 0 is not up. + pub socket_accepts: Option, +} + +/// One product row the dashboard knows how to name. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SystemsInventoryItem { + pub id: &'static str, + pub role: &'static str, + pub unit: Option<&'static str>, + pub href: Option, +} + +/// Product units this dashboard may probe. Not Vaultwarden, Lake, or Splora mainnet. +pub const DASHBOARD_UNITS: &[&str] = &[ + "stalwart-mail.service", + "surmount-management-ui.service", + "surmount-arti-hidden-service.service", + "sshd.service", + "surmount-scram.service", + "fail2ban.service", + "splora-testnet3.service", + "splora-testnet4.service", + "splora-mutinynet.service", + "splora-liquid.service", +]; + +/// Ordinary English status from listen result, ActiveState / SubState, or HTTP. +/// +/// Never returns a constant "ok". HTTP client errors win over a running unit +/// when that row did not accept a proxy socket. An accepted socket is +/// "running" even when the first HTTP status is 401 (Splora's auth gate +/// with public health off). Status 0 is not a response and stays "inactive". +/// `socket_accepts` Some(false) is "inactive" (the existing down word). +/// Some(true) is "running" (the existing up word). None keeps systemd. +pub fn classify_component_status(probe: &ComponentProbe) -> String { + if probe.http_status == Some(0) { + return "inactive".to_string(); + } + // 401 on an accepted connection is the indexer auth gate, not a down row. + // Other 4xx/5xx still win when the socket did not accept, and they still + // win on an accepted socket except for that 401 gate. + if probe.socket_accepts == Some(true) + && !matches!(probe.http_status, Some(code) if code >= 400 && code != 401) + { + return "running".to_string(); + } + if let Some(code) = probe.http_status + && code >= 400 + { + return format!("HTTP {code}"); + } + let load = probe.load_state.trim(); + let active = probe.active_state.trim(); + let sub = probe.sub_state.trim(); + if load.eq_ignore_ascii_case("not-found") && probe.socket_accepts != Some(true) { + return "not installed".to_string(); + } + if probe.socket_accepts == Some(false) { + return "inactive".to_string(); + } + if active.eq_ignore_ascii_case("failed") || sub.eq_ignore_ascii_case("failed") { + return "failed".to_string(); + } + if sub.eq_ignore_ascii_case("listening") { + return "listening".to_string(); + } + if active.eq_ignore_ascii_case("inactive") { + return "inactive".to_string(); + } + if active.eq_ignore_ascii_case("activating") { + return "starting".to_string(); + } + if active.eq_ignore_ascii_case("deactivating") { + return "stopping".to_string(); + } + if active.eq_ignore_ascii_case("reloading") { + return "reloading".to_string(); + } + if active.eq_ignore_ascii_case("maintenance") { + return "maintenance".to_string(); + } + if active.eq_ignore_ascii_case("active") && sub.eq_ignore_ascii_case("running") { + return "running".to_string(); + } + if active.eq_ignore_ascii_case("active") && sub.eq_ignore_ascii_case("exited") { + return "exited".to_string(); + } + if active.eq_ignore_ascii_case("active") { + return "running".to_string(); + } + if !sub.is_empty() { + return sub.to_ascii_lowercase(); + } + if !active.is_empty() { + return active.to_ascii_lowercase(); + } + "unknown".to_string() +} + +/// HTTP Unix socket the public Splora proxy uses for this indexer name. +/// +/// `/testnet` is testnet3, `/testnet4` is testnet4, `/signet` and +/// `/mutinynet` are mutinynet, `/liquid` is liquid. Mainnet is not a +/// dashboard row (None). A configured instance socket wins; otherwise +/// the proxy default is `{DEFAULT_SOCKET_DIR}/{instance}.http.sock`. +pub fn splora_dashboard_socket(config: &AppConfig, instance: &str) -> Option { + if !matches!(instance, "testnet3" | "testnet4" | "mutinynet" | "liquid") { + return None; + } + if let Some(inst) = config.splora_proxy.instance_by_name(instance) { + return Some(inst.http_socket.clone()); + } + Some(PathBuf::from(format!( + "{}/{instance}.http.sock", + crate::proxy_vaultwarden::splora::DEFAULT_SOCKET_DIR + ))) +} + +/// True only when `path` accepts a Unix connection. Missing path is false. +pub fn unix_socket_accepts(path: &Path) -> bool { + UnixStream::connect(path).is_ok() +} + +/// Set `socket_accepts` on the four Splora dashboard units from the proxy socket. +fn annotate_splora_listen(config: &AppConfig, probes: &mut BTreeMap) { + const ROWS: &[(&str, &str)] = &[ + ("splora-testnet3.service", "testnet3"), + ("splora-testnet4.service", "testnet4"), + ("splora-mutinynet.service", "mutinynet"), + ("splora-liquid.service", "liquid"), + ]; + for (unit, instance) in ROWS { + let Some(path) = splora_dashboard_socket(config, instance) else { + continue; + }; + let accepts = unix_socket_accepts(&path); + probes + .entry((*unit).to_string()) + .or_default() + .socket_accepts = Some(accepts); + } +} + +/// Standing number law: seconds; minutes at most 60 minutes; N.N hours through +/// 24 hours; N.N days after that. +pub fn format_uptime_seconds(seconds: f64) -> String { + if !seconds.is_finite() || seconds < 0.0 { + return "unknown".to_string(); + } + let secs = seconds.floor() as u64; + if secs < 60 { + if secs == 1 { + return "1 second".to_string(); + } + return format!("{secs} seconds"); + } + if secs <= 3600 { + let minutes = secs / 60; + if minutes == 1 { + return "1 minute".to_string(); + } + return format!("{minutes} minutes"); + } + if secs <= 86_400 { + return format!("{:.1} hours", seconds / 3600.0); + } + format!("{:.1} days", seconds / 86_400.0) +} + +/// First field of `/proc/uptime` (seconds.centiseconds). +pub fn parse_proc_uptime(contents: &str) -> Option { + let first = contents.split_whitespace().next()?; + let n: f64 = first.parse().ok()?; + if !n.is_finite() || n < 0.0 { + return None; + } + Some(n) +} + +/// Read host uptime seconds from a `/proc/uptime`-shaped file. +pub fn read_proc_uptime_file(path: &Path) -> Option { + std::fs::read_to_string(path) + .ok() + .as_deref() + .and_then(parse_proc_uptime) +} + +/// `https://{hostname}{path}` for a DNS hostname. Refuses IP literals. +pub fn https_public_href(host: &str, path: &str) -> Option { + let authority = host_for_url_authority(host); + if authority.is_empty() { + return None; + } + if authority.parse::().is_ok() { + return None; + } + if authority.starts_with('[') { + return None; + } + if authority.chars().all(|c| c.is_ascii_digit() || c == '.') && authority.contains('.') { + // Dotted numeric that did not parse as IPv4 still must not become an href. + return None; + } + let host = authority.to_ascii_lowercase(); + if host == "localhost" { + return None; + } + let path = if path.is_empty() { + "/" + } else if path.starts_with('/') { + path + } else { + return None; + }; + Some(format!("https://{host}{path}")) +} + +/// Parse `systemctl show -p Id -p ActiveState -p SubState -p LoadState` text. +pub fn parse_systemctl_show(text: &str) -> BTreeMap { + let mut map = BTreeMap::new(); + let mut id = String::new(); + let mut probe = ComponentProbe::default(); + let flush = |id: &mut String, + probe: &mut ComponentProbe, + map: &mut BTreeMap| { + if !id.is_empty() { + map.insert(std::mem::take(id), std::mem::take(probe)); + } + *probe = ComponentProbe::default(); + }; + for line in text.lines() { + let line = line.trim(); + if line.is_empty() { + flush(&mut id, &mut probe, &mut map); + continue; + } + if let Some(v) = line.strip_prefix("Id=") { + if !id.is_empty() { + flush(&mut id, &mut probe, &mut map); + } + id = v.trim().to_string(); + } else if let Some(v) = line.strip_prefix("ActiveState=") { + probe.active_state = v.trim().to_string(); + } else if let Some(v) = line.strip_prefix("SubState=") { + probe.sub_state = v.trim().to_string(); + } else if let Some(v) = line.strip_prefix("LoadState=") { + probe.load_state = v.trim().to_string(); + } + } + flush(&mut id, &mut probe, &mut map); + map +} + +fn splora_portal_host(config: &AppConfig) -> String { + if let Some(h) = config + .splora_proxy + .portal_host + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + return host_for_url_authority(h).to_ascii_lowercase(); + } + let primary = host_for_url_authority(&config.primary_domain); + if primary.is_empty() { + return String::new(); + } + format!("splora.{}", primary.to_ascii_lowercase()) +} + +fn classify_tls_state(https: bool) -> String { + if https { + "listening".to_string() + } else { + "plain HTTP".to_string() + } +} + +fn classify_mta_sts_state(mode: MtaStsMode) -> String { + match mode { + MtaStsMode::Off => "off".to_string(), + MtaStsMode::Testing => "testing".to_string(), + MtaStsMode::Enforce => "enforcing".to_string(), + } +} + +/// Named services this Host actually runs. Splora mainnet, Vaultwarden, and Lake +/// stay off this list unless a later inventory finds them installed. +pub fn systems_status_inventory(config: &AppConfig) -> Vec { + let mail = https_public_href(&config.mail_hostname, "/"); + let services = https_public_href(&config.services_hostname, "/"); + let status = https_public_href(&status_hostname(&config.primary_domain), "/"); + let site = https_public_href(&config.primary_domain, "/"); + let mta = https_public_href( + &mta_sts_policy_hostname(&config.primary_domain), + "/.well-known/mta-sts.txt", + ); + let portal = splora_portal_host(config); + let splora_testnet = https_public_href(&portal, "/testnet/"); + let splora_testnet4 = https_public_href(&portal, "/testnet4/"); + let splora_mutinynet = https_public_href(&portal, "/mutinynet/"); + let splora_liquid = https_public_href(&portal, "/liquid/"); + vec![ + SystemsInventoryItem { + id: "mail", + role: "mail", + unit: Some("stalwart-mail.service"), + href: mail, + }, + SystemsInventoryItem { + id: "edge", + role: "HTTPS edge", + unit: Some("surmount-management-ui.service"), + href: site.clone(), + }, + SystemsInventoryItem { + id: "tls", + role: "certificate", + unit: None, + href: site.clone(), + }, + SystemsInventoryItem { + id: "mta_sts", + role: "MTA-STS policy", + unit: None, + href: mta, + }, + SystemsInventoryItem { + id: "console", + role: "operator console", + unit: Some("surmount-management-ui.service"), + href: services, + }, + SystemsInventoryItem { + id: "status", + role: "systems dashboard", + unit: Some("surmount-management-ui.service"), + href: status, + }, + SystemsInventoryItem { + id: "site", + role: "public site", + unit: Some("surmount-management-ui.service"), + href: site, + }, + SystemsInventoryItem { + id: "arti", + role: "hidden service", + unit: Some("surmount-arti-hidden-service.service"), + href: None, + }, + SystemsInventoryItem { + id: "sshd", + role: "SSH", + unit: Some("sshd.service"), + href: None, + }, + SystemsInventoryItem { + id: "splora-testnet3", + role: "Splora testnet3", + unit: Some("splora-testnet3.service"), + href: splora_testnet, + }, + SystemsInventoryItem { + id: "splora-testnet4", + role: "Splora testnet4", + unit: Some("splora-testnet4.service"), + href: splora_testnet4, + }, + SystemsInventoryItem { + id: "splora-mutinynet", + role: "Splora mutinynet", + unit: Some("splora-mutinynet.service"), + href: splora_mutinynet, + }, + SystemsInventoryItem { + id: "splora-liquid", + role: "Splora liquid", + unit: Some("splora-liquid.service"), + href: splora_liquid, + }, + SystemsInventoryItem { + id: "scram", + role: "builder memory guard", + unit: Some("surmount-scram.service"), + href: None, + }, + SystemsInventoryItem { + id: "fail2ban", + role: "intrusion prevention", + unit: Some("fail2ban.service"), + href: None, + }, + ] +} + +/// Build the payload from config, formatted uptime, and unit/HTTP samples. +pub fn systems_status_from_probes( + config: &AppConfig, + uptime: &str, + probes: &BTreeMap, +) -> SystemsStatusPayload { + let host = status_hostname(&config.primary_domain); + let https = config.listen_mode.is_https(); + let systems = systems_status_inventory(config) + .into_iter() + .map(|item| { + let state = match item.id { + "tls" => classify_tls_state(https), + "mta_sts" => classify_mta_sts_state(config.mta_sts_mode), + _ => { + let probe = item + .unit + .and_then(|u| probes.get(u)) + .cloned() + .unwrap_or_default(); + classify_component_status(&probe) + } + }; + SystemsComponent { + name: item.id.to_string(), + role: item.role.to_string(), + state, + href: item.href, + } + }) + .collect(); + SystemsStatusPayload { + surface: "systems_status", + service: "surmount", + version: env!("CARGO_PKG_VERSION"), + host, + primary_domain: config.primary_domain.clone(), + uptime: uptime.to_string(), + systems, + } +} + +fn read_host_uptime() -> String { + #[cfg(test)] + { + if let Ok(raw) = std::env::var("SURMOUNT_STATUS_UPTIME_SECONDS") + && let Ok(n) = raw.parse::() + { + return format_uptime_seconds(n); + } + format_uptime_seconds(720.0) + } + #[cfg(not(test))] + { + read_proc_uptime_file(Path::new("/proc/uptime")) + .map(format_uptime_seconds) + .unwrap_or_else(|| "unknown".to_string()) + } +} + +fn live_unit_probes() -> BTreeMap { + #[cfg(test)] + { + if std::env::var_os("SURMOUNT_STATUS_SYSTEMCTL").is_none() { + return BTreeMap::new(); + } + } + let systemctl = + std::env::var("SURMOUNT_STATUS_SYSTEMCTL").unwrap_or_else(|_| "systemctl".to_string()); + let mut cmd = Command::new(systemctl); + cmd.args([ + "show", + "-p", + "Id", + "-p", + "ActiveState", + "-p", + "SubState", + "-p", + "LoadState", + "--no-pager", + ]); + cmd.args(DASHBOARD_UNITS); + match cmd.output() { + Ok(out) => parse_systemctl_show(&String::from_utf8_lossy(&out.stdout)), + Err(_) => BTreeMap::new(), + } +} + +/// Build the payload the status Host handler actually emits. +pub fn systems_status_from_config(config: &AppConfig) -> SystemsStatusPayload { + let mut probes = live_unit_probes(); + annotate_splora_listen(config, &mut probes); + systems_status_from_probes(config, &read_host_uptime(), &probes) +} + +/// Serve HTML dashboard, JSON (`/status.json` or Accept), or closed 404. +pub fn serve_systems_status(config: &AppConfig, path: &str, accept: Option<&str>) -> Response { + let path_only = path.split('?').next().unwrap_or(path); + let payload = systems_status_from_config(config); + let want_json = path_only == "/status.json" + || (path_only == "/" + && accept.is_some_and(|a| a.to_ascii_lowercase().contains("application/json"))); + if path_only == "/status.json" || (path_only == "/" && want_json) { + let body = serde_json::to_string(&payload).unwrap_or_else(|_| { + r#"{"surface":"systems_status","service":"surmount","systems":[]}"#.into() + }); + return ( + StatusCode::OK, + [(header::CONTENT_TYPE, "application/json; charset=utf-8")], + body, + ) + .into_response(); + } + if path_only == "/" { + return ( + StatusCode::OK, + [(header::CONTENT_TYPE, "text/html; charset=utf-8")], + render_systems_status_html(&payload), + ) + .into_response(); + } + (StatusCode::NOT_FOUND, "not found").into_response() +} + +fn html_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +fn linked_text(text: &str, href: Option<&str>) -> String { + let t = html_escape(text); + match href { + Some(h) if !h.is_empty() => format!("{t}", html_escape(h)), + _ => t, + } +} + +/// Public HTML dashboard. Named systems fields appear in the body. +pub fn render_systems_status_html(payload: &SystemsStatusPayload) -> String { + let host = html_escape(&payload.host); + let host_link = linked_text( + &payload.host, + https_public_href(&payload.host, "/").as_deref(), + ); + let primary_link = linked_text( + &payload.primary_domain, + https_public_href(&payload.primary_domain, "/").as_deref(), + ); + let version = html_escape(payload.version); + let uptime = html_escape(&payload.uptime); + let mut rows = String::new(); + for c in &payload.systems { + let name = html_escape(&c.name); + let role = html_escape(&c.role); + let state = html_escape(&c.state); + let label = linked_text(&c.name, c.href.as_deref()); + rows.push_str(&format!( + "{label}{role}\ +{state}\n" + )); + } + format!( + r#" + + + + + + +Surmount systems status · {host} + + + +
+

Surmount

+

Systems status

+

{host_link}

+

Primary domain {primary_link}. Public systems dashboard on the Axum HTTPS edge. Not the operator console.

+

Host uptime {uptime}.

+ + + +{rows} +
systemrolestatus
+
+
surmount {version} · surface systems_status · service surmount
+ + +"# + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::AppConfig; + + fn probe(active: &str, sub: &str, load: &str, http: Option) -> ComponentProbe { + ComponentProbe { + active_state: active.into(), + sub_state: sub.into(), + load_state: load.into(), + http_status: http, + socket_accepts: None, + } + } + + /// Named contract: payload is non-empty and names the systems the handler emits. + #[test] + fn systems_status_payload_emits_named_fields() { + let cfg = AppConfig::example_test(); + let payload = systems_status_from_config(&cfg); + assert_eq!(payload.surface, "systems_status"); + assert_eq!(payload.service, "surmount"); + assert!(!payload.version.is_empty()); + assert_eq!(payload.host, "status.example.test"); + assert_eq!(payload.primary_domain, "example.test"); + assert!( + !payload.systems.is_empty(), + "systems list must be non-empty" + ); + let names: Vec<&str> = payload.systems.iter().map(|c| c.name.as_str()).collect(); + for must in ["mail", "edge", "tls", "mta_sts", "console"] { + assert!( + names.contains(&must), + "handler must emit system {must}; got {names:?}" + ); + } + let html = render_systems_status_html(&payload); + assert!(html.contains("data-surface=\"systems-status\"")); + assert!(html.contains("Systems status")); + assert!(html.contains("status.example.test")); + assert!(html.contains("data-system=\"mail\"")); + assert!(html.contains("data-system=\"edge\"")); + assert!(html.contains("data-system=\"tls\"")); + assert!(!html.to_ascii_lowercase().contains("operator console nav")); + assert!( + !html.contains("href=\"/mail\"") && !html.contains("href=\"/mail/"), + "must not link to the operator console mail path: {html}" + ); + assert!(!html.contains("Dashboard Overview")); + let json = serde_json::to_value(&payload).unwrap(); + assert_eq!(json["surface"], "systems_status"); + assert!(json["systems"].as_array().unwrap().len() >= 5); + } + + /// Named contract: GET / calls the shipped handler and is 200 non-empty. + #[tokio::test] + async fn serve_systems_status_root_is_200_from_real_handler() { + let cfg = AppConfig::example_test(); + let payload = systems_status_from_config(&cfg); + let resp = serve_systems_status(&cfg, "/", None); + assert_eq!(resp.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024) + .await + .unwrap(); + let body = String::from_utf8(bytes.to_vec()).unwrap(); + assert!(!body.is_empty(), "systems status body must be non-empty"); + assert_eq!(body, render_systems_status_html(&payload)); + assert!(body.contains(&payload.host)); + assert!(body.contains("data-surface=\"systems-status\"")); + assert!(!body.contains("Dashboard Overview")); + } + + /// Named contract: JSON is the handler payload, not a hardcoded live blob. + #[tokio::test] + async fn serve_systems_status_json_matches_handler_payload() { + let cfg = AppConfig::example_test(); + let expected = serde_json::to_value(systems_status_from_config(&cfg)).unwrap(); + let resp = serve_systems_status(&cfg, "/status.json", None); + assert_eq!(resp.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024) + .await + .unwrap(); + assert!(!bytes.is_empty(), "JSON body must be non-empty"); + let got: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(got, expected, "JSON must be the live handler payload"); + assert_eq!(got["surface"], "systems_status"); + assert!(got["systems"].as_array().unwrap().len() >= 5); + } + + /// Named contract: operator console API on the status Host is closed 404. + #[tokio::test] + async fn serve_systems_status_console_api_is_404() { + let cfg = AppConfig::example_test(); + let resp = serve_systems_status(&cfg, "/api/v1/system", None); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + /// Names, hosts, and public URLs are real https hrefs for hosts this product serves. + #[test] + fn systems_status_html_includes_https_href_for_named_host() { + let cfg = AppConfig::example_test(); + let payload = systems_status_from_config(&cfg); + let html = render_systems_status_html(&payload); + assert!( + html.contains("href=\"https://status.example.test/\""), + "status host must be a clickable https link: {html}" + ); + assert!( + html.contains("href=\"https://mail.example.test/\""), + "mail host must be a clickable https link: {html}" + ); + assert!( + html.contains("href=\"https://services.example.test/\""), + "services host must be a clickable https link: {html}" + ); + assert!( + html.contains("href=\"https://example.test/\""), + "site host must be a clickable https link: {html}" + ); + assert!( + html.contains("href=\"https://splora.example.test/testnet/\""), + "Splora testnet path must be a clickable https link: {html}" + ); + assert!( + !html.contains("href=\"#\""), + "placeholder hash hrefs are not enough: {html}" + ); + assert!( + !html.contains("href=\"https://127."), + "must not put loopback IPs in hrefs: {html}" + ); + let hrefs: Vec<&str> = payload + .systems + .iter() + .filter_map(|c| c.href.as_deref()) + .collect(); + assert!( + hrefs.iter().any(|h| h.starts_with("https://")), + "payload must carry at least one https href: {hrefs:?}" + ); + assert!(https_public_href("127.0.0.1", "/").is_none()); + assert!(https_public_href("::1", "/").is_none()); + assert_eq!( + https_public_href("mail.example.test", "/"), + Some("https://mail.example.test/".into()) + ); + } + + /// Status strings come from the classifier given ActiveState/HTTP samples. + #[test] + fn classify_component_status_uses_unit_and_http_samples_not_constant_ok() { + assert_eq!( + classify_component_status(&probe("active", "running", "loaded", None)), + "running" + ); + assert_eq!( + classify_component_status(&probe("failed", "failed", "loaded", None)), + "failed" + ); + assert_eq!( + classify_component_status(&probe("inactive", "dead", "loaded", None)), + "inactive" + ); + assert_eq!( + classify_component_status(&probe("active", "listening", "loaded", None)), + "listening" + ); + assert_eq!( + classify_component_status(&probe("active", "running", "loaded", Some(502))), + "HTTP 502" + ); + assert_eq!( + classify_component_status(&probe("inactive", "dead", "not-found", None)), + "not installed" + ); + for sample in [ + probe("active", "running", "loaded", None), + probe("failed", "failed", "loaded", None), + probe("inactive", "dead", "loaded", None), + probe("active", "running", "loaded", Some(502)), + ] { + let got = classify_component_status(&sample); + assert_ne!(got, "ok", "must not hard-code ok; got {got} for {sample:?}"); + } + let cfg = AppConfig::example_test(); + let mut probes = BTreeMap::new(); + probes.insert( + "stalwart-mail.service".into(), + probe("failed", "failed", "loaded", None), + ); + probes.insert( + "surmount-management-ui.service".into(), + probe("active", "running", "loaded", Some(502)), + ); + let payload = systems_status_from_probes(&cfg, "12 minutes", &probes); + let mail = payload + .systems + .iter() + .find(|c| c.name == "mail") + .expect("mail row"); + assert_eq!(mail.state, "failed"); + let edge = payload + .systems + .iter() + .find(|c| c.name == "edge") + .expect("edge row"); + assert_eq!(edge.state, "HTTP 502"); + let html = render_systems_status_html(&payload); + assert!(html.contains("data-state=\"failed\"")); + assert!(html.contains("data-state=\"HTTP 502\"")); + assert!(!html.contains("data-state=\"ok\"")); + } + + /// Uptime formatter uses standing number law. Quote the exact strings. + #[test] + fn format_uptime_seconds_matches_standing_number_law() { + assert_eq!(format_uptime_seconds(45.0), "45 seconds"); + assert_eq!(format_uptime_seconds(720.0), "12 minutes"); + assert_eq!(format_uptime_seconds(5400.0), "1.5 hours"); + assert_eq!(format_uptime_seconds(90_000.0), "1.0 days"); + assert_eq!(parse_proc_uptime("12345.67 88888.00\n"), Some(12345.67)); + let dir = std::env::temp_dir(); + let path = dir.join(format!( + "surmount-status-uptime-{}-{}.txt", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::write(&path, "5400.12 100.00\n").unwrap(); + assert_eq!(read_proc_uptime_file(&path), Some(5400.12)); + let _ = std::fs::remove_file(&path); + let cfg = AppConfig::example_test(); + let payload = systems_status_from_probes(&cfg, "1.5 hours", &BTreeMap::new()); + let html = render_systems_status_html(&payload); + assert!( + html.contains("Host uptime 1.5 hours."), + "HTML must show the formatted uptime: {html}" + ); + assert_eq!(payload.uptime, "1.5 hours"); + } + + /// Inventory names mail, UI, status, Arti, and Splora testnet (not mainnet). + #[test] + fn systems_status_inventory_lists_mail_ui_status_arti_splora_testnet() { + let cfg = AppConfig::example_test(); + let items = systems_status_inventory(&cfg); + let ids: Vec<&str> = items.iter().map(|i| i.id).collect(); + for must in [ + "mail", + "console", + "status", + "arti", + "splora-testnet3", + "splora-testnet4", + "splora-mutinynet", + "splora-liquid", + "sshd", + ] { + assert!( + ids.contains(&must), + "inventory must name {must}; got {ids:?}" + ); + } + assert!( + !ids.iter().any(|id| id.contains("mainnet")), + "must not revive Splora mainnet: {ids:?}" + ); + assert!( + !ids.iter().any(|id| *id == "vaultwarden" || *id == "lake"), + "must not invent Vaultwarden or Lake: {ids:?}" + ); + let payload = systems_status_from_config(&cfg); + let names: Vec<&str> = payload.systems.iter().map(|c| c.name.as_str()).collect(); + assert!(names.len() > 5, "more than a stub handful: {names:?}"); + for must in ["mail", "console", "status", "arti", "splora-testnet3"] { + assert!( + names.contains(&must), + "payload must name {must}; got {names:?}" + ); + } + let show = parse_systemctl_show( + "Id=stalwart-mail.service\nLoadState=loaded\nActiveState=active\nSubState=running\n\n\ +Id=sshd.service\nLoadState=loaded\nActiveState=failed\nSubState=failed\n", + ); + assert_eq!(show["stalwart-mail.service"].active_state, "active"); + assert_eq!(show["sshd.service"].active_state, "failed"); + } + + /// Named contract: unit active and socket absent is not Up. + /// + /// The page's up word is "running". The existing not-up word for a + /// service that is down is "inactive" (ActiveState=inactive). + #[test] + fn splora_active_unit_with_absent_socket_is_not_up() { + let cfg = AppConfig::example_test(); + assert_eq!( + splora_dashboard_socket(&cfg, "testnet3") + .unwrap() + .to_str() + .unwrap(), + "/run/splora/testnet3.http.sock" + ); + assert_eq!( + splora_dashboard_socket(&cfg, "testnet4") + .unwrap() + .to_str() + .unwrap(), + "/run/splora/testnet4.http.sock" + ); + assert_eq!( + splora_dashboard_socket(&cfg, "mutinynet") + .unwrap() + .to_str() + .unwrap(), + "/run/splora/mutinynet.http.sock" + ); + assert_eq!( + splora_dashboard_socket(&cfg, "liquid") + .unwrap() + .to_str() + .unwrap(), + "/run/splora/liquid.http.sock" + ); + assert!( + splora_dashboard_socket(&cfg, "mainnet").is_none(), + "mainnet stays off and must not be shown as Up" + ); + assert!( + !DASHBOARD_UNITS.iter().any(|unit| unit.contains("mainnet")), + "mainnet must not be a dashboard unit: {DASHBOARD_UNITS:?}" + ); + + let mut custom = cfg.clone(); + custom + .splora_proxy + .instances + .push(crate::proxy_vaultwarden::splora::SploraInstance { + name: "testnet3".into(), + hosts: Vec::new(), + http_socket: PathBuf::from("/run/splora/testnet3.http.sock"), + }); + assert_eq!( + splora_dashboard_socket(&custom, "testnet3") + .unwrap() + .to_str() + .unwrap(), + "/run/splora/testnet3.http.sock" + ); + + let missing = std::env::temp_dir().join(format!( + "surmount-status-absent-socket-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + assert!( + !unix_socket_accepts(&missing), + "a missing socket must not count as accepted" + ); + + let mut probes = BTreeMap::new(); + for unit in [ + "splora-testnet3.service", + "splora-testnet4.service", + "splora-mutinynet.service", + "splora-liquid.service", + ] { + probes.insert( + unit.to_string(), + ComponentProbe { + active_state: "active".into(), + sub_state: "running".into(), + load_state: "loaded".into(), + http_status: None, + socket_accepts: Some(false), + }, + ); + } + let payload = systems_status_from_probes(&cfg, "1 minute", &probes); + for id in [ + "splora-testnet3", + "splora-testnet4", + "splora-mutinynet", + "splora-liquid", + ] { + let row = payload + .systems + .iter() + .find(|c| c.name == id) + .unwrap_or_else(|| panic!("missing row {id}")); + assert_eq!( + row.state, "inactive", + "{id} unit active with absent socket must not be Up (got {})", + row.state + ); + assert_ne!(row.state, "running"); + assert_ne!(row.state, "Up"); + } + let html = render_systems_status_html(&payload); + assert!(html.contains("data-state=\"inactive\"")); + assert!( + !html.contains("data-state=\"running\""), + "absent socket must not paint running/Up: {html}" + ); + assert!(!html.contains("data-state=\"Up\"")); + assert!( + !html.contains("mainnet"), + "mainnet must not appear on the status page" + ); + + let mut accepting = BTreeMap::new(); + accepting.insert( + "splora-testnet3.service".into(), + ComponentProbe { + active_state: "active".into(), + sub_state: "running".into(), + load_state: "loaded".into(), + http_status: None, + socket_accepts: Some(true), + }, + ); + let up = systems_status_from_probes(&cfg, "1 minute", &accepting); + let up_row = up + .systems + .iter() + .find(|c| c.name == "splora-testnet3") + .expect("splora-testnet3"); + assert_eq!( + up_row.state, "running", + "an accepting socket uses the page up word" + ); + } + + /// Named contract: an accepted socket whose first HTTP response is 401 is up. + /// + /// Splora with public health off uses 401 as the auth gate. That is not + /// down. The page up word is "running". Status 0 and a real HTTP error + /// other than that gate stay not up. + #[test] + fn splora_accepted_socket_with_http_401_is_up() { + use std::io::{Read, Write}; + use std::os::unix::net::UnixListener; + use std::sync::atomic::{AtomicBool, Ordering}; + + let dir = std::env::temp_dir().join(format!( + "sm401-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).unwrap(); + let sock_path = dir.join("t4.sock"); + let _ = std::fs::remove_file(&sock_path); + let listener = UnixListener::bind(&sock_path).unwrap(); + let ready = std::sync::Arc::new(AtomicBool::new(false)); + let ready2 = ready.clone(); + let join = std::thread::spawn(move || { + ready2.store(true, Ordering::SeqCst); + let response = + b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + for _ in 0..2 { + let (mut stream, _) = listener.accept().unwrap(); + let _ = stream.write_all(response); + let _ = stream.flush(); + } + }); + while !ready.load(Ordering::SeqCst) { + std::thread::sleep(std::time::Duration::from_millis(1)); + } + std::thread::sleep(std::time::Duration::from_millis(20)); + + assert!( + unix_socket_accepts(&sock_path), + "an accepted socket is up when the indexer answers HTTP 401" + ); + + let mut reader = UnixStream::connect(&sock_path).expect("second connect to the 401 stub"); + reader + .set_read_timeout(Some(std::time::Duration::from_secs(2))) + .expect("read timeout"); + let mut buf = [0u8; 64]; + let n = reader.read(&mut buf).expect("read the stub status line"); + let text = std::str::from_utf8(&buf[..n]).expect("status line is utf-8"); + let code: u16 = text + .split_whitespace() + .nth(1) + .expect("HTTP status code") + .parse() + .expect("numeric status"); + assert_eq!( + code, 401, + "the stub's first HTTP response is 401, got {text}" + ); + + let cfg = AppConfig::example_test(); + let mut probes = BTreeMap::new(); + probes.insert( + "splora-testnet4.service".into(), + ComponentProbe { + active_state: "active".into(), + sub_state: "running".into(), + load_state: "loaded".into(), + http_status: Some(code), + socket_accepts: Some(true), + }, + ); + let payload = systems_status_from_probes(&cfg, "1 minute", &probes); + let row = payload + .systems + .iter() + .find(|c| c.name == "splora-testnet4") + .expect("splora-testnet4"); + assert_eq!( + row.state, "running", + "HTTP 401 on an accepted socket is up, not down (got {})", + row.state + ); + assert_ne!(row.state, "inactive"); + assert_ne!(row.state, "HTTP 401"); + let html = render_systems_status_html(&payload); + assert!( + html.contains("data-system=\"splora-testnet4\""), + "testnet4 row must be on the page" + ); + assert!( + html.contains("data-state=\"running\""), + "accepted 401 socket must paint the up word: {html}" + ); + assert_eq!( + classify_component_status(&ComponentProbe { + active_state: "active".into(), + sub_state: "running".into(), + load_state: "loaded".into(), + http_status: Some(0), + socket_accepts: Some(true), + }), + "inactive", + "HTTP 000 stays not up" + ); + assert_eq!( + classify_component_status(&ComponentProbe { + active_state: "active".into(), + sub_state: "running".into(), + load_state: "loaded".into(), + http_status: Some(502), + socket_accepts: Some(true), + }), + "HTTP 502", + "HTTP 502 on an accepted socket stays not the up word" + ); + + let _ = join.join(); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/management-ui/src/tls.rs b/crates/management-ui/src/tls.rs index 30a732f..dae3820 100644 --- a/crates/management-ui/src/tls.rs +++ b/crates/management-ui/src/tls.rs @@ -1423,6 +1423,7 @@ pub(crate) mod http3 { queue_path: DEFAULT_QUEUE_PATH.into(), body_limit_bytes: DEFAULT_BODY_LIMIT_BYTES, portal_host: None, + web_root: PathBuf::new(), }; let http3_cfg = Http3Config { enable: true, diff --git a/crates/surmount-acme-namecheap/src/laptop.rs b/crates/surmount-acme-namecheap/src/laptop.rs index 9e3c9b3..d105718 100644 --- a/crates/surmount-acme-namecheap/src/laptop.rs +++ b/crates/surmount-acme-namecheap/src/laptop.rs @@ -46,10 +46,11 @@ needs acme-dns-hook-namecheap-dispatch. When --hook is omitted and --domains includes a zone besides surmount.systems, wrap defaults to that dispatcher (sibling of this binary when present). -Host-profile without --domains floors the intended 20-name leaf (includes -exophiles.org and www.exophiles.org) and strips Cloudflare extras -(btcdragonlord.com, btckitties.com). Production --issue that names any -intended extra zone must include the full 20-name list (no silent drop). +Host-profile without --domains floors the intended leaf (includes +status.surmount.systems, exophiles.org, and www.exophiles.org) and +strips Cloudflare extras (btcdragonlord.com, btckitties.com). +Production --issue that names any intended extra zone must include +the full intended list (no silent drop). "#; /// Intended production leaf (web + mail). One PEM, with_single_cert. @@ -59,6 +60,7 @@ const INTENDED_LEAF_DOMAINS: &[&str] = &[ "mail.surmount.systems", "services.surmount.systems", "mta-sts.surmount.systems", + "status.surmount.systems", "baxterartworks.com", "www.baxterartworks.com", "btcfur.com", @@ -352,7 +354,7 @@ where } } if domains.is_empty() { - domains = "services.surmount.systems,mail.surmount.systems,surmount.systems,www.surmount.systems,mta-sts.surmount.systems".into(); + domains = "services.surmount.systems,mail.surmount.systems,surmount.systems,www.surmount.systems,mta-sts.surmount.systems,status.surmount.systems".into(); } if issue || live { refuse_dropped_intended_leaf(&domains)?; @@ -818,7 +820,7 @@ fn refuse_dropped_intended_leaf(domains: &str) -> anyhow::Result<()> { return Ok(()); } Err(blocked(format!( - "production extra-zone issue must keep the intended 20-name leaf (missing {}); do not drop Namecheap static zones such as exophiles.org", + "production extra-zone issue must keep the intended leaf (missing {}); do not drop Namecheap static zones such as exophiles.org or status.surmount.systems", missing.join(",") ))) } diff --git a/crates/surmount-acme-namecheap/testdata/host-profile/sample-host-profile.toml b/crates/surmount-acme-namecheap/testdata/host-profile/sample-host-profile.toml index b55673d..dd1593b 100644 --- a/crates/surmount-acme-namecheap/testdata/host-profile/sample-host-profile.toml +++ b/crates/surmount-acme-namecheap/testdata/host-profile/sample-host-profile.toml @@ -9,4 +9,5 @@ acme_domains = [ "mail.example.invalid", "example.invalid", "www.example.invalid", + "status.example.invalid", ] diff --git a/crates/surmount-acme-namecheap/tests/cli.rs b/crates/surmount-acme-namecheap/tests/cli.rs index cbcc81b..0e5a872 100644 --- a/crates/surmount-acme-namecheap/tests/cli.rs +++ b/crates/surmount-acme-namecheap/tests/cli.rs @@ -316,6 +316,7 @@ fn render_sample_and_refuse() { assert!(text.contains("services.example.invalid")); assert!(text.contains("mail.example.invalid")); assert!(text.contains("www.example.invalid")); + assert!(text.contains("status.example.invalid")); assert!(text.contains("ops@example.invalid")); assert!(text.contains("acme-staging-v02.api.letsencrypt.org")); assert!(text.contains("dnsProvider = \"external-hook\"")); @@ -612,7 +613,9 @@ fn laptop_issue_wrap_execs_stored_hook_not_one_sld_only() { fs::set_permissions(&fake_ui, fs::Permissions::from_mode(0o755)).unwrap(); }; let one_sld_exec = "exec acme-dns-hook-namecheap \"$@\""; - let extra_domains = "surmount.systems,www.surmount.systems,mail.surmount.systems,services.surmount.systems,mta-sts.surmount.systems,baxterartworks.com,www.baxterartworks.com,btcfur.com,www.btcfur.com,exophiles.org,www.exophiles.org,iantuckerstudios.com,www.iantuckerstudios.com,nostrfurs.com,www.nostrfurs.com,yiffa.app,www.yiffa.app,cryptoquick.com,www.cryptoquick.com,mail.cryptoquick.com"; + // Full intended leaf so extra-zone --issue is not refused for a dropped + // dashboard Host. The wrap contract is dispatcher exec, not omit-status. + let extra_domains = "surmount.systems,www.surmount.systems,mail.surmount.systems,services.surmount.systems,mta-sts.surmount.systems,status.surmount.systems,baxterartworks.com,www.baxterartworks.com,btcfur.com,www.btcfur.com,exophiles.org,www.exophiles.org,iantuckerstudios.com,www.iantuckerstudios.com,nostrfurs.com,www.nostrfurs.com,yiffa.app,www.yiffa.app,cryptoquick.com,www.cryptoquick.com,mail.cryptoquick.com"; let stored = PathBuf::from(dispatch()); let issue_hook = root.join("issue-hook"); @@ -757,7 +760,7 @@ fn laptop_issue_fails_when_ui_child_exits_nonzero() { } /// Host-profile Cloudflare extras must not replace Namecheap static zones. -/// Profile without --domains floors the intended 20-name leaf. +/// Profile without --domains floors the intended leaf (status + extras). #[test] fn host_profile_floors_intended_leaf_and_strips_cloudflare_extras() { let root = scratch("laptop-profile-floor"); @@ -793,6 +796,10 @@ fn host_profile_floors_intended_leaf_and_strips_cloudflare_extras() { t.contains("exophiles.org") && t.contains("www.exophiles.org"), "host-profile must floor exophiles even when the profile omitted them: {t}" ); + assert!( + t.contains("status.surmount.systems"), + "host-profile must floor systems dashboard Host: {t}" + ); assert!( !t.contains("btcdragonlord.com") && !t.contains("btckitties.com"), "host-profile must strip Cloudflare extras: {t}" diff --git a/crates/surmount-deploy-host/src/args.rs b/crates/surmount-deploy-host/src/args.rs index af9914e..e0a1912 100644 --- a/crates/surmount-deploy-host/src/args.rs +++ b/crates/surmount-deploy-host/src/args.rs @@ -18,7 +18,14 @@ Required: Must not start with '-' (option-shaped values rejected). Options: - --dry-run Print planned commands; do not rsync or rebuild. + --dry-run Rsync the public tree and the private host-local + overlay, bootstrap gccarch-x86-64-v3 the same way + as a real deploy, and run nixos-rebuild build + with the same path: flake ref and + --option max-jobs 32 --option cores 0. + Does not run nixos-rebuild switch, does not change + /nix/var/nix/profiles/system, does not run + post-switch smoke, and does not install secrets. --host-local DIR Path to host-local overlay (local machine or mount). Or env SURMOUNT_HOST_LOCAL_DIR. Required for lockout / layout checks unless --skip-host-local-check. @@ -76,10 +83,12 @@ Environment: Same as --secrets-from-secret-service SURMOUNT_SECRETS_HOST_ID Same as --secrets-host-id -After rebuild (success or non-zero), deploy-host always runs post-switch -smoke on the remote via SSH (surmount-deploy-host-post-switch-smoke): +After a real deploy (no --dry-run), success or non-zero, deploy-host +runs post-switch smoke on the remote via SSH +(surmount-deploy-host-post-switch-smoke): generation pointer, systemctl is-active sshd/stalwart-mail/ surmount-management-ui (starts UI if inactive), loopback /health. +Dry-run does not run that smoke and does not switch the running generation. "#; pub const ALLOWED_SECRETS_KINDS: &[&str] = &[ diff --git a/crates/surmount-deploy-host/src/lib.rs b/crates/surmount-deploy-host/src/lib.rs index 0eda724..8eb019b 100644 --- a/crates/surmount-deploy-host/src/lib.rs +++ b/crates/surmount-deploy-host/src/lib.rs @@ -5,6 +5,7 @@ mod args; mod error; mod keys; +mod nix_conf; mod paths; mod run; mod smoke; diff --git a/crates/surmount-deploy-host/src/nix_conf.rs b/crates/surmount-deploy-host/src/nix_conf.rs new file mode 100644 index 0000000..42b3c8d --- /dev/null +++ b/crates/surmount-deploy-host/src/nix_conf.rs @@ -0,0 +1,777 @@ +//! Pure nix.conf transform for the mail-host gccarch bootstrap. +//! +//! The running daemon does not see `nix.settings.extra-system-features` +//! until a switch activates a new nix.conf. The system build needs +//! `gccarch-x86-64-v3` on the daemon that is already running. This module +//! decides the conf text. The remote driver in `gccarch_bootstrap_remote` +//! applies it on the target before `nixos-rebuild`. + +/// Feature the v3 stdenv requires. Nix 2.34 folds `extra-system-features` +/// into `system-features` on `nix config show`. +pub(crate) const GCCARCH_X86_64_V3: &str = "gccarch-x86-64-v3"; + +/// Remote-builder feature. The bootstrap must not drop it. +pub(crate) const SURMOUNT_REMOTE: &str = "surmount-remote"; + +/// Copy target. Never write the store nix.conf in place. A bind of this +/// path does not survive reboot. +pub(crate) const RUN_NIX_CONF: &str = "/run/surmount-nix.conf"; + +/// What to do with the daemon conf before the system build. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum GccarchConfDecision { + /// `system-features` already lists [`GCCARCH_X86_64_V3`]. + /// Do not rewrite the conf. Do not restart the daemon. + LeaveUnchanged, + /// Write this text to [`RUN_NIX_CONF`], bind-mount it over the store + /// nix.conf, and restart nix-daemon. + Rewrite(String), +} + +impl GccarchConfDecision { + pub(crate) fn rewrites_conf(&self) -> bool { + matches!(self, Self::Rewrite(_)) + } + + /// Restart only follows a rewrite. [`Self::LeaveUnchanged`] does not. + pub(crate) fn restarts_daemon(&self) -> bool { + matches!(self, Self::Rewrite(_)) + } +} + +/// Decide the conf text from the file the daemon is using. +/// +/// `system-features` is last-assignment-wins, same as Nix. A commented +/// line does not count. `extra-system-features` is not that key. +/// When the last `system-features` line lacks the token, every +/// `extra-system-features` line gains `surmount-remote` and +/// `gccarch-x86-64-v3` without dropping tokens already on the line. +/// The `system-features` line text is copied unchanged. +pub(crate) fn bootstrap_gccarch_conf(conf: &str) -> GccarchConfDecision { + let lines = conf_lines(conf); + if last_system_features_value(&lines).is_some_and(|value| value_has_arch(&value)) { + return GccarchConfDecision::LeaveUnchanged; + } + let mut out = String::new(); + let mut saw_extra = false; + for line in &lines { + if assignment_key(line) == Some("extra-system-features") { + saw_extra = true; + out.push_str(&rewrite_extra_line(line)); + out.push('\n'); + } else { + out.push_str(line); + out.push('\n'); + } + } + if !saw_extra { + out.push_str("extra-system-features = "); + out.push_str(SURMOUNT_REMOTE); + out.push(' '); + out.push_str(GCCARCH_X86_64_V3); + out.push('\n'); + } + GccarchConfDecision::Rewrite(out) +} + +fn conf_lines(conf: &str) -> Vec { + if conf.is_empty() { + return Vec::new(); + } + let mut lines: Vec = conf.split('\n').map(str::to_string).collect(); + if conf.ends_with('\n') { + lines.pop(); + } + lines +} + +fn trim_ascii_ws(s: &str) -> &str { + s.trim_matches([' ', '\t']) +} + +fn assignment_key(line: &str) -> Option<&str> { + let rest = trim_ascii_ws(line); + if rest.is_empty() || rest.starts_with('#') { + return None; + } + let eq = rest.find('=')?; + let key = trim_ascii_ws(&rest[..eq]); + if key.is_empty() + || !key + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return None; + } + Some(key) +} + +fn last_system_features_value(lines: &[String]) -> Option { + let mut last = None; + for line in lines { + if assignment_key(line) == Some("system-features") + && let Some(eq) = line.find('=') + { + last = Some(line[eq + 1..].to_string()); + } + } + last +} + +fn ascii_ws_tokens(value: &str) -> Vec { + let mut trimmed = trim_ascii_ws(value); + if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') { + trimmed = &trimmed[1..trimmed.len() - 1]; + } + if trimmed.is_empty() { + return Vec::new(); + } + trimmed + .split(|c| c == ' ' || c == '\t' || c == '\n') + .filter(|tok| !tok.is_empty()) + .map(str::to_string) + .collect() +} + +fn value_has_arch(value: &str) -> bool { + ascii_ws_tokens(value) + .iter() + .any(|tok| tok == GCCARCH_X86_64_V3) +} + +fn ensure_extra_tokens(value: &str) -> String { + let mut toks = ascii_ws_tokens(value); + if !toks.iter().any(|tok| tok == SURMOUNT_REMOTE) { + toks.push(SURMOUNT_REMOTE.to_string()); + } + if !toks.iter().any(|tok| tok == GCCARCH_X86_64_V3) { + toks.push(GCCARCH_X86_64_V3.to_string()); + } + toks.join(" ") +} + +fn rewrite_extra_line(line: &str) -> String { + let eq = line + .find('=') + .expect("extra-system-features assignment has '='"); + let prefix = &line[..eq]; + let tokens = ensure_extra_tokens(&line[eq + 1..]); + format!("{prefix}= {tokens}") +} + +const SHELL_FUNCTIONS: &str = r#" +deploy_host_trim() { + val=$1 + tab=$(printf '\t') + while :; do + case "$val" in + ' '*) val=${val# } ;; + "$tab"*) val=${val#"$tab"} ;; + *) break ;; + esac + done + while :; do + case "$val" in + *' ') val=${val% } ;; + *"$tab") val=${val%"$tab"} ;; + *) break ;; + esac + done + printf '%s' "$val" +} + +deploy_host_assignment_key() { + rest=$(deploy_host_trim "$1") + case "$rest" in + ''|\#*) printf '\n'; return ;; + esac + case "$rest" in + *=*) ;; + *) printf '\n'; return ;; + esac + key=$(deploy_host_trim "${rest%%=*}") + case "$key" in + *[!A-Za-z0-9_-]*|'') printf '\n'; return ;; + esac + printf '%s\n' "$key" +} + +deploy_host_list_has_arch() { + oldifs=$IFS + IFS=$(printf ' \t\nX') + IFS=${IFS%X} + set -f + found=0 + for tok in $1; do + if [ "$tok" = "$deploy_host_gccarch_token" ]; then + found=1 + break + fi + done + set +f + IFS=$oldifs + [ "$found" -eq 1 ] +} + +deploy_host_value_has_arch() { + val=$(deploy_host_trim "$1") + case "$val" in + \"*\") + val=${val#\"} + val=${val%\"} + ;; + esac + deploy_host_list_has_arch "$val" +} + +deploy_host_blob_has_token() { + blob=$(printf '%s' "$1" | sed 's/[][",]/ /g') + deploy_host_list_has_arch "$blob" +} + +deploy_host_file_system_features_has_token() { + lastval= + found=0 + while IFS= read -r line || [ -n "$line" ]; do + k=$(deploy_host_assignment_key "$line") + if [ "$k" = system-features ]; then + found=1 + lastval=${line#*=} + fi + done < "$1" + if [ "$found" -eq 1 ]; then + deploy_host_value_has_arch "$lastval" + else + return 1 + fi +} + +deploy_host_ensure_extra_tokens() { + val=$(deploy_host_trim "$1") + case "$val" in + \"*\") + val=${val#\"} + val=${val%\"} + ;; + esac + has_remote=0 + has_arch=0 + out= + oldifs=$IFS + IFS=$(printf ' \t\nX') + IFS=${IFS%X} + set -f + for tok in $val; do + if [ -z "$tok" ]; then + continue + fi + if [ "$tok" = surmount-remote ]; then + has_remote=1 + fi + if [ "$tok" = "$deploy_host_gccarch_token" ]; then + has_arch=1 + fi + if [ -z "$out" ]; then + out=$tok + else + out="$out $tok" + fi + done + set +f + IFS=$oldifs + if [ "$has_remote" -eq 0 ]; then + if [ -z "$out" ]; then + out=surmount-remote + else + out="$out surmount-remote" + fi + fi + if [ "$has_arch" -eq 0 ]; then + if [ -z "$out" ]; then + out=$deploy_host_gccarch_token + else + out="$out $deploy_host_gccarch_token" + fi + fi + printf '%s' "$out" +} + +deploy_host_rewrite_extra_line() { + line=$1 + prefix=${line%%=*} + val=${line#*=} + tokens=$(deploy_host_ensure_extra_tokens "$val") + printf '%s= %s' "$prefix" "$tokens" +} + +deploy_host_rewrite_conf() { + src=$1 + dest=$2 + tmp=$(mktemp "${dest}.XXXXXX") || return 1 + found_extra=0 + while IFS= read -r line || [ -n "$line" ]; do + k=$(deploy_host_assignment_key "$line") + if [ "$k" = extra-system-features ]; then + found_extra=1 + printf '%s\n' "$(deploy_host_rewrite_extra_line "$line")" >> "$tmp" + else + printf '%s\n' "$line" >> "$tmp" + fi + done < "$src" + if [ "$found_extra" -eq 0 ]; then + printf '%s\n' "extra-system-features = surmount-remote $deploy_host_gccarch_token" >> "$tmp" + fi + if ! cat "$tmp" > "$dest"; then + rm -f "$tmp" + return 1 + fi + rm -f "$tmp" + chmod 644 "$dest" || true + return 0 +} + +deploy_host_bind_already() { + if ! command -v findmnt >/dev/null 2>&1; then + return 1 + fi + findmnt -n -o SOURCE -T "$1" 2>/dev/null | grep -F -- "$deploy_host_run_nix_conf" >/dev/null 2>&1 +} +"#; + +const SHELL_DRIVER: &str = r#" +# Bootstrap gccarch before the system build. +# Read the conf the daemon is using. Do not write that store file in place. +# Write /run/surmount-nix.conf and bind-mount it over the store path. +# The /run bind does not survive reboot. A successful switch puts the +# feature in the generation nix.conf. +# If the daemon already advertises the feature, do not rewrite and do not restart. +deploy_host_daemon_features=$(nix config show system-features 2>/dev/null || true) +if deploy_host_blob_has_token "$deploy_host_daemon_features"; then + : +else + store_conf=$(readlink -f /etc/nix/nix.conf) || { + echo "deploy-host: cannot resolve /etc/nix/nix.conf; gccarch-x86-64-v3 still absent; not starting the system build" >&2 + exit 1 + } + if [ ! -f "$store_conf" ]; then + echo "deploy-host: nix.conf the daemon reads is missing; gccarch-x86-64-v3 still absent; not starting the system build" >&2 + exit 1 + fi + if deploy_host_file_system_features_has_token "$store_conf"; then + echo "deploy-host: nix.conf system-features already lists gccarch-x86-64-v3 but the daemon does not advertise it; not rewriting and not restarting; not starting the system build" >&2 + exit 1 + fi + if ! deploy_host_rewrite_conf "$store_conf" "$deploy_host_run_nix_conf"; then + echo "deploy-host: failed to write /run/surmount-nix.conf; gccarch-x86-64-v3 still absent; not starting the system build" >&2 + exit 1 + fi + if [ "$store_conf" != "$deploy_host_run_nix_conf" ]; then + mount_err=$(mktemp /run/surmount-nix-mount.XXXXXX) || { + echo "deploy-host: cannot record the bind-mount error; gccarch-x86-64-v3 still absent; not starting the system build" >&2 + exit 1 + } + if mount --bind "$deploy_host_run_nix_conf" "$store_conf" 2>"$mount_err"; then + rm -f "$mount_err" + elif grep -E -q 'already mounted|already a mount point' "$mount_err"; then + cat "$mount_err" >&2 + rm -f "$mount_err" + elif deploy_host_bind_already "$store_conf"; then + rm -f "$mount_err" + else + echo "deploy-host: bind-mount of nix.conf failed; gccarch-x86-64-v3 still absent; not starting the system build" >&2 + cat "$mount_err" >&2 + rm -f "$mount_err" + exit 1 + fi + fi + if ! systemctl restart nix-daemon; then + echo "deploy-host: nix-daemon restart failed; gccarch-x86-64-v3 still absent; not starting the system build" >&2 + exit 1 + fi + deploy_host_daemon_features=$(nix config show system-features 2>/dev/null || true) + if ! deploy_host_blob_has_token "$deploy_host_daemon_features"; then + echo "deploy-host: gccarch-x86-64-v3 still absent from nix config show system-features; not starting the system build" >&2 + printf '%s\n' "deploy-host: nix config show system-features printed:" >&2 + printf '%s\n' "$deploy_host_daemon_features" >&2 + exit 1 + fi +fi +"#; + +/// Shell that runs on the target as root, before the system build. +/// `nix config show extra-system-features` is not used: Nix 2.34.9 has +/// no such key. The show output for `system-features` already includes +/// extra tokens. +pub(crate) fn gccarch_bootstrap_remote() -> String { + format!( + "set +e\ndeploy_host_gccarch_token={GCCARCH_X86_64_V3}\ndeploy_host_run_nix_conf={RUN_NIX_CONF}\n{SHELL_FUNCTIONS}\n{SHELL_DRIVER}" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::Path; + use std::process::Command; + use std::time::{SystemTime, UNIX_EPOCH}; + + const SYSTEM_FEATURES_LINE: &str = "system-features = nixos-test benchmark big-parallel kvm\n"; + + fn live_daemon_conf() -> &'static str { + "extra-system-features = surmount-remote\nsystem-features = nixos-test benchmark big-parallel kvm\n" + } + + fn system_feature_lines(conf: &str) -> Vec { + conf_lines(conf) + .into_iter() + .filter(|line| assignment_key(line) == Some("system-features")) + .collect() + } + + #[test] + fn conf_that_lacks_gccarch_is_not_left_unchanged() { + let input = live_daemon_conf(); + let decision = bootstrap_gccarch_conf(input); + assert!( + decision.rewrites_conf(), + "a conf that lacks {GCCARCH_X86_64_V3} must not be left unchanged" + ); + assert!( + decision.restarts_daemon(), + "a missing token is a rewrite, and a rewrite restarts the daemon" + ); + match decision { + GccarchConfDecision::LeaveUnchanged => { + panic!("a conf that lacks {GCCARCH_X86_64_V3} must not be left unchanged") + } + GccarchConfDecision::Rewrite(out) => { + assert_ne!( + out, input, + "a conf that lacks {GCCARCH_X86_64_V3} must not be left unchanged" + ); + assert!( + out.contains(GCCARCH_X86_64_V3), + "rewritten conf dropped {GCCARCH_X86_64_V3}: {out}" + ); + } + } + } + + #[test] + fn extra_line_gains_gccarch_and_system_features_line_is_unchanged() { + let input = live_daemon_conf(); + let GccarchConfDecision::Rewrite(out) = bootstrap_gccarch_conf(input) else { + panic!("missing token must rewrite"); + }; + assert!( + out.contains("extra-system-features = surmount-remote gccarch-x86-64-v3\n"), + "{out}" + ); + assert!(out.contains(SYSTEM_FEATURES_LINE), "{out}"); + assert_eq!( + system_feature_lines(input), + system_feature_lines(&out), + "system-features line must stay byte-for-byte" + ); + assert!( + !out.contains("system-features = nixos-test benchmark big-parallel kvm gccarch"), + "system-features line must not gain the token: {out}" + ); + } + + #[test] + fn system_features_line_with_gccarch_does_not_rewrite_or_restart() { + let input = "\ +extra-system-features = surmount-remote\n\ +system-features = nixos-test benchmark big-parallel kvm gccarch-x86-64-v3\n"; + let decision = bootstrap_gccarch_conf(input); + assert_eq!(decision, GccarchConfDecision::LeaveUnchanged); + assert!(!decision.rewrites_conf()); + assert!(!decision.restarts_daemon()); + } + + #[test] + fn extra_line_keeps_other_tokens_and_does_not_duplicate() { + let input = "\ +extra-system-features = surmount-remote other-token\n\ +system-features = nixos-test benchmark big-parallel kvm\n"; + let GccarchConfDecision::Rewrite(out) = bootstrap_gccarch_conf(input) else { + panic!("must rewrite"); + }; + assert!( + out.contains("extra-system-features = surmount-remote other-token gccarch-x86-64-v3\n"), + "{out}" + ); + assert_eq!(system_feature_lines(input), system_feature_lines(&out)); + + let already = "\ +extra-system-features = gccarch-x86-64-v3 surmount-remote extra\n\ +system-features = nixos-test benchmark big-parallel kvm\n"; + let GccarchConfDecision::Rewrite(out) = bootstrap_gccarch_conf(already) else { + panic!("system-features still lacks the token, so this is still a rewrite"); + }; + assert!( + out.contains("extra-system-features = gccarch-x86-64-v3 surmount-remote extra\n"), + "{out}" + ); + assert_eq!(out.matches("gccarch-x86-64-v3").count(), 1, "{out}"); + } + + #[test] + fn substring_and_comment_do_not_count_as_the_token() { + let substring = "\ +extra-system-features = surmount-remote\n\ +system-features = nixos-test gccarch-x86-64-v30 kvm\n"; + assert!(bootstrap_gccarch_conf(substring).rewrites_conf()); + + let comment = "\ +# system-features = nixos-test benchmark big-parallel kvm gccarch-x86-64-v3\n\ +# extra-system-features = surmount-remote\n\ +system-features = nixos-test benchmark big-parallel kvm\n"; + let GccarchConfDecision::Rewrite(out) = bootstrap_gccarch_conf(comment) else { + panic!("a comment must not suppress the rewrite"); + }; + assert!(out.contains( + "# system-features = nixos-test benchmark big-parallel kvm gccarch-x86-64-v3\n" + )); + assert!(out.contains("# extra-system-features = surmount-remote\n")); + assert!( + out.contains("extra-system-features = surmount-remote gccarch-x86-64-v3\n"), + "{out}" + ); + assert_eq!(system_feature_lines(comment), system_feature_lines(&out)); + } + + #[test] + fn last_system_features_line_wins() { + let earlier_only = "\ +system-features = gccarch-x86-64-v3\n\ +system-features = nixos-test benchmark big-parallel kvm\n\ +extra-system-features = surmount-remote\n"; + assert!(bootstrap_gccarch_conf(earlier_only).rewrites_conf()); + + let later = "\ +system-features = nixos-test benchmark big-parallel kvm\n\ +extra-system-features = surmount-remote\n\ +system-features = nixos-test benchmark big-parallel kvm gccarch-x86-64-v3\n"; + let decision = bootstrap_gccarch_conf(later); + assert!(!decision.rewrites_conf()); + assert!(!decision.restarts_daemon()); + } + + #[test] + fn missing_extra_line_is_appended_without_touching_system_features() { + let input = "max-jobs = 8\nsystem-features = nixos-test benchmark big-parallel kvm\nsandbox = true\n"; + let GccarchConfDecision::Rewrite(out) = bootstrap_gccarch_conf(input) else { + panic!("must rewrite"); + }; + assert!(out.contains("max-jobs = 8\n"), "{out}"); + assert!(out.contains("sandbox = true\n"), "{out}"); + assert!(out.contains(SYSTEM_FEATURES_LINE), "{out}"); + assert!( + out.contains("extra-system-features = surmount-remote gccarch-x86-64-v3\n"), + "{out}" + ); + assert_eq!(system_feature_lines(input), system_feature_lines(&out)); + } + + #[test] + fn extra_system_features_key_is_not_system_features() { + let input = "extra-system-features = gccarch-x86-64-v3\n"; + let decision = bootstrap_gccarch_conf(input); + assert!( + decision.rewrites_conf(), + "the token on extra-system-features is not the system-features line" + ); + let GccarchConfDecision::Rewrite(out) = decision else { + panic!("must rewrite"); + }; + assert!( + out.contains("extra-system-features = gccarch-x86-64-v3 surmount-remote\n") + || out.contains("extra-system-features = gccarch-x86-64-v3 surmount-remote"), + "{out}" + ); + assert!(out.contains(SURMOUNT_REMOTE), "{out}"); + } + + fn shell_decision(conf: &str) -> Result { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|err| err.to_string())? + .as_nanos(); + let dir = + std::env::temp_dir().join(format!("surmount-gccarch-{}-{nanos}", std::process::id())); + fs::create_dir_all(&dir).map_err(|err| err.to_string())?; + let result = shell_decision_in(&dir, conf); + let _ = fs::remove_dir_all(&dir); + result + } + + fn shell_decision_in(dir: &Path, conf: &str) -> Result { + let src = dir.join("nix.conf"); + let dest = dir.join("out.conf"); + fs::write(&src, conf).map_err(|err| err.to_string())?; + let script = format!( + "deploy_host_gccarch_token={GCCARCH_X86_64_V3}\n\ + deploy_host_run_nix_conf={RUN_NIX_CONF}\n\ + {SHELL_FUNCTIONS}\n\ + if deploy_host_file_system_features_has_token \"$1\"; then\n\ + echo LEAVE\n\ + else\n\ + deploy_host_rewrite_conf \"$1\" \"$2\" || exit 2\n\ + echo REWRITE\n\ + fi\n" + ); + let out = Command::new("sh") + .arg("-c") + .arg(&script) + .arg("sh") + .arg(&src) + .arg(&dest) + .output() + .map_err(|err| format!("sh failed to start: {err}"))?; + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + if !out.status.success() { + return Err(format!( + "sh exit {:?}\nstdout:\n{stdout}\nstderr:\n{stderr}", + out.status.code() + )); + } + if stdout.trim() == "LEAVE" { + return Ok(ShellDecision::Leave); + } + if stdout.trim() == "REWRITE" { + let body = fs::read_to_string(&dest).map_err(|err| err.to_string())?; + return Ok(ShellDecision::Rewrite(body)); + } + Err(format!( + "unexpected shell stdout:\n{stdout}\nstderr:\n{stderr}" + )) + } + + enum ShellDecision { + Leave, + Rewrite(String), + } + + #[test] + fn shell_transform_matches_rust_decision() { + let fixtures = [ + live_daemon_conf(), + "", + "extra-system-features = surmount-remote other-token\nsystem-features = nixos-test benchmark big-parallel kvm\n", + "extra-system-features=surmount-remote\nsystem-features = nixos-test benchmark big-parallel kvm\n", + "extra-system-features = surmount-remote\nsystem-features = nixos-test benchmark big-parallel kvm gccarch-x86-64-v3\n", + "max-jobs = 8\nsystem-features = nixos-test benchmark big-parallel kvm\nsandbox = true\n", + "# system-features = gccarch-x86-64-v3\nsystem-features = nixos-test benchmark big-parallel kvm\n", + "extra-system-features = gccarch-x86-64-v3 surmount-remote extra\nsystem-features = nixos-test gccarch-x86-64-v30\n", + "system-features = gccarch-x86-64-v3\nsystem-features = nixos-test benchmark big-parallel kvm\nextra-system-features = surmount-remote\n", + "system-features = nixos-test benchmark big-parallel kvm\nextra-system-features = surmount-remote\nsystem-features = nixos-test benchmark big-parallel kvm gccarch-x86-64-v3\n", + " extra-system-features = surmount-remote\nsystem-features = nixos-test benchmark big-parallel kvm\n", + "extra-system-features = surmount-remote\nextra-system-features = other\nsystem-features = nixos-test benchmark big-parallel kvm\n", + "extra-system-features = \"surmount-remote\"\nsystem-features = nixos-test benchmark big-parallel kvm\n", + "\n", + "system-features = nixos-test benchmark big-parallel kvm", + ]; + for (idx, fixture) in fixtures.iter().enumerate() { + let rust = bootstrap_gccarch_conf(fixture); + let shell = shell_decision(fixture).unwrap_or_else(|err| { + panic!("fixture {idx} shell failed: {err}\ninput:\n{fixture}") + }); + match (rust, shell) { + (GccarchConfDecision::LeaveUnchanged, ShellDecision::Leave) => {} + (GccarchConfDecision::Rewrite(expected), ShellDecision::Rewrite(got)) => { + assert_eq!( + got, expected, + "fixture {idx} shell drifted from the rust transform\ninput:\n{fixture}" + ); + } + (rust, _) => { + panic!( + "fixture {idx} shell decision does not match rust: {rust:?}\ninput:\n{fixture}" + ) + } + } + } + } + + #[test] + fn shell_blob_token_is_exact() { + let script = format!( + "deploy_host_gccarch_token={GCCARCH_X86_64_V3}\n\ + deploy_host_run_nix_conf={RUN_NIX_CONF}\n\ + {SHELL_FUNCTIONS}\n\ + if deploy_host_blob_has_token \"$1\"; then echo YES; else echo NO; fi\n" + ); + let cases = [ + ( + "benchmark big-parallel kvm nixos-test surmount-remote", + "NO", + ), + ( + "benchmark big-parallel kvm nixos-test surmount-remote gccarch-x86-64-v3", + "YES", + ), + ("[ \"benchmark\" \"gccarch-x86-64-v3\" ]", "YES"), + ("gccarch-x86-64-v30", "NO"), + ("gccarch-x86-64", "NO"), + ]; + for (blob, want) in cases { + let out = Command::new("sh") + .arg("-c") + .arg(&script) + .arg("sh") + .arg(blob) + .output() + .expect("sh"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + out.status.success(), + "blob {blob:?} stderr {}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!(stdout.trim(), want, "blob {blob:?} stdout {stdout}"); + } + } + + #[test] + fn remote_driver_restarts_only_when_the_daemon_omits_the_feature() { + let script = gccarch_bootstrap_remote(); + assert!(script.contains(GCCARCH_X86_64_V3)); + assert!(script.contains(SURMOUNT_REMOTE)); + assert!(script.contains(RUN_NIX_CONF)); + assert!(script.contains("nix config show system-features")); + assert!( + !script.contains("nix config show extra-system-features"), + "Nix 2.34.9 has no extra-system-features show key" + ); + assert!(!script.contains("> \"$store_conf\"")); + assert!( + script + .contains("deploy_host_rewrite_conf \"$store_conf\" \"$deploy_host_run_nix_conf\"") + ); + assert!(script.contains("mount --bind")); + assert!(script.contains("systemctl restart nix-daemon")); + assert!(script.contains( + "gccarch-x86-64-v3 still absent from nix config show system-features; not starting the system build" + )); + let check = script + .find("if deploy_host_blob_has_token \"$deploy_host_daemon_features\"") + .expect("daemon check"); + let else_at = script[check..].find("\nelse\n").expect("else branch") + check; + let restart = script + .find("systemctl restart nix-daemon") + .expect("restart"); + assert!( + restart > else_at, + "restart must sit in the branch that runs only when the feature is missing" + ); + assert!( + !script[..else_at].contains("systemctl restart"), + "already-advertised feature must not restart nix-daemon" + ); + assert_eq!(script.matches("systemctl restart nix-daemon").count(), 1); + } +} diff --git a/crates/surmount-deploy-host/src/run.rs b/crates/surmount-deploy-host/src/run.rs index 54ee00f..037638e 100644 --- a/crates/surmount-deploy-host/src/run.rs +++ b/crates/surmount-deploy-host/src/run.rs @@ -191,56 +191,22 @@ fn run_deploy(opts: DeployOpts) -> Result<(), ToolError> { } if !opts.skip_sync { - if opts.dry_run { - if let Some(id) = operator_ssh_identity(&opts.target) { - println!( - "deploy-host: dry-run: ssh as {} uses {} (plus keepalives). Override: SURMOUNT_DEPLOY_SSH_IDENTITY", - opts.target, - id.display() - ); - } - println!( - "deploy-host: dry-run: rsync public tree -> {}:{} / (excludes host-local, secrets patterns; not private-data admission control)", - opts.target, - shell_quote(&opts.remote_dir) - ); - let excl = RSYNC_EXCLUDES.join(" "); - println!( - "deploy-host: dry-run: rsync -az --delete {excl} {}/ {}:{} /", - shell_quote(&repo_root.to_string_lossy()), - opts.target, - shell_quote(&opts.remote_dir) - ); - } else { - ssh_run( - &opts.target, - &format!("mkdir -p {}", shell_quote(&opts.remote_dir)), - )?; - rsync_remote( - &["-az", "--delete"], - RSYNC_EXCLUDES, - &repo_root, - &opts.target, - &opts.remote_dir, - )?; - } + ssh_run( + &opts.target, + &format!("mkdir -p {}", shell_quote(&opts.remote_dir)), + )?; + rsync_remote( + &["-az", "--delete"], + RSYNC_EXCLUDES, + &repo_root, + &opts.target, + &opts.remote_dir, + )?; } if opts.host_local.is_some() && !opts.skip_host_local_check { let remote_hl = format!("{}/host-local", opts.remote_dir.trim_end_matches('/')); - if opts.dry_run { - println!( - "deploy-host: dry-run: ensure remote host-local at {}:{} (operator-placed; not from public git)", - opts.target, - shell_quote(&remote_hl) - ); - println!( - "deploy-host: dry-run: rsync {} private host-local -> {}:{}/ (never into tracked hosts/; default without --delete unless --host-local-delete)", - hl_rsync_flags.join(" "), - opts.target, - shell_quote(&remote_hl) - ); - } else if let Some(hl) = opts.host_local.as_ref() { + if let Some(hl) = opts.host_local.as_ref() { ssh_run( &opts.target, &format!("mkdir -p {}", shell_quote(&remote_hl)), @@ -294,6 +260,24 @@ fn run_deploy(opts: DeployOpts) -> Result<(), ToolError> { } let rebuild_flake_ref = format!("path:{}#{}", opts.remote_dir, opts.flake_attr); + if opts.dry_run { + // Build only. Do not switch, do not touch the system profile, + // and do not run post-switch smoke. + let build_remote_cmd = build_remote_command(&rebuild_flake_ref); + let (finish, _build_stdout) = ssh_captured(&opts.target, &build_remote_cmd); + let build_rc = build_outcome(finish)?; + if build_rc != 0 { + return Err(ToolError::fail(format!( + "nixos-rebuild build failed (exit {build_rc}); the running generation was not switched" + ))); + } + println!( + "deploy-host: nixos-rebuild build finished; the running generation was not switched" + ); + let _ = io::stdout().flush(); + return Ok(()); + } + let rebuild_remote_cmd = switch_remote_command(&rebuild_flake_ref); let smoke_name = smoke_remote_name(); let smoke_remote_cmd = format!( @@ -301,69 +285,37 @@ fn run_deploy(opts: DeployOpts) -> Result<(), ToolError> { smoke = smoke_name ); - let mut rebuild_rc = 0i32; - let mut smoke_rc = 0i32; - let mut built_generation: Option = None; - if opts.dry_run { - println!( - "deploy-host: dry-run: ssh keepalives ServerAliveInterval=30 ServerAliveCountMax=10 TCPKeepAlive=yes" - ); - println!( - "deploy-host: dry-run: a dropped connection retries before nixos-rebuild; a switch SSH drop is not retried" - ); - println!( - "deploy-host: dry-run: rebuild uses path: flake so host-local (rsync'd, not in public git) is visible to Nix (git flake would omit untracked/gitignored paths)" - ); - println!( - "deploy-host: dry-run: ssh -- {} {}", - shell_quote(&opts.target), - shell_quote(&rebuild_remote_cmd) - ); - println!( - "deploy-host: dry-run: post-switch smoke runs after a rebuild that finished (including a non-zero rebuild exit): ssh -- {} {}", - shell_quote(&opts.target), - shell_quote(&smoke_remote_cmd) - ); - println!( - "deploy-host: dry-run: smoke checks generation + systemctl is-active sshd stalwart-mail surmount-management-ui (start UI if inactive) + loopback /health" - ); - } else { - // One switch attempt. A drop after this starts may have changed the host. - // Capture stdout so the built-generation line can be read, and copy - // both streams through so a non-zero rebuild's own error stays visible. - let (finish, rebuild_stdout) = ssh_captured(&opts.target, &rebuild_remote_cmd); - built_generation = parse_built_generation(&rebuild_stdout); - if let RemoteFinish::Connection { code } = &finish { - eprintln!( - "deploy-host: nixos-rebuild exit={}", - ssh_status_label(*code) - ); - } - rebuild_rc = switch_outcome(finish)?; - eprintln!("deploy-host: nixos-rebuild exit={rebuild_rc}"); + // One switch attempt. A drop after this starts may have changed the host. + // Capture stdout so the built-generation line can be read, and copy + // both streams through so a non-zero rebuild's own error stays visible. + let (finish, rebuild_stdout) = ssh_captured(&opts.target, &rebuild_remote_cmd); + let built_generation = parse_built_generation(&rebuild_stdout); + if let RemoteFinish::Connection { code } = &finish { eprintln!( - "deploy-host: running post-switch smoke on {} (after a finished rebuild)", - opts.target + "deploy-host: nixos-rebuild exit={}", + ssh_status_label(*code) ); - smoke_rc = match retry_remote(&format!("post-switch smoke ssh -- {}", opts.target), || { - ssh_finish(&opts.target, &smoke_remote_cmd) - }) { - Ok(code) => code, - Err(err) => { - return Err(ToolError::fail(format!( - "{err}; not starting a second switch" - ))); - } - }; - eprintln!("deploy-host: post-switch smoke exit={smoke_rc}"); } + let rebuild_rc = switch_outcome(finish)?; + eprintln!("deploy-host: nixos-rebuild exit={rebuild_rc}"); + eprintln!( + "deploy-host: running post-switch smoke on {} (after a finished rebuild)", + opts.target + ); + let smoke_rc = match retry_remote(&format!("post-switch smoke ssh -- {}", opts.target), || { + ssh_finish(&opts.target, &smoke_remote_cmd) + }) { + Ok(code) => code, + Err(err) => { + return Err(ToolError::fail(format!( + "{err}; not starting a second switch" + ))); + } + }; + eprintln!("deploy-host: post-switch smoke exit={smoke_rc}"); print!("{POST_SMOKE_TAIL}"); let _ = io::stdout().flush(); - - if opts.dry_run { - return Ok(()); - } if rebuild_rc != 0 && smoke_rc != 0 { return Err(ToolError::fail(format!( "nixos-rebuild failed (exit {rebuild_rc}) and post-switch smoke failed (exit {smoke_rc})" @@ -529,22 +481,47 @@ fn ssh_status_label(code: Option) -> String { } } +/// One remote build. Same gccarch bootstrap as `switch_remote_command`, +/// then `nixos-rebuild build`. Does not switch and does not read or +/// write `/nix/var/nix/profiles/system`. `nixos-rebuild build` leaves a +/// `result` symlink in the remote working directory. `--keep-going` is +/// only on this build so one dry-run lists every failure. The switch +/// command does not use it. +fn build_remote_command(flake_ref: &str) -> String { + let flake_q = shell_quote(flake_ref); + let bootstrap = crate::nix_conf::gccarch_bootstrap_remote(); + let build = format!( + "nixos-rebuild build --flake {flake_q} --option max-jobs 32 --option cores 0 --keep-going" + ); + format!("{bootstrap}\n{build}") +} + /// One remote switch. Prints `synthetic-built-generation:` after the rebuild. -/// A new profile link (`system-N-link`) is the generation the switch built. -/// The same link name as before the switch is `unchanged`, not a generation. -/// The fake ssh matches `*nixos-rebuild*` before `*readlink*` and does not run -/// this script; it prints the same generation line itself. +/// Before that build, bootstrap `gccarch-x86-64-v3` on the running daemon +/// when `nix config show` system-features omits it. The bootstrap writes +/// `/run/surmount-nix.conf` and bind-mounts it. That bind does not survive +/// reboot. A new profile link (`system-N-link`) is the generation the switch +/// built. The same link name as before the switch is `unchanged`, not a +/// generation. The fake ssh matches `*nixos-rebuild*` before `*readlink*` +/// and does not run this script; it prints the same generation line itself. fn switch_remote_command(flake_ref: &str) -> String { let flake_q = shell_quote(flake_ref); - [ + let bootstrap = crate::nix_conf::gccarch_bootstrap_remote(); + let switch = [ "before=$(readlink /nix/var/nix/profiles/system 2>/dev/null || true)", - &format!("nixos-rebuild switch --flake {flake_q}"), + // Live nix.conf still has max-jobs 8 until this generation + // switches. 32 matches surmount.remoteBuilder.maxJobs. cores 0 + // is auto so one drv can use the machine. + &format!( + "nixos-rebuild switch --flake {flake_q} --option max-jobs 32 --option cores 0" + ), "rc=$?", "after=$(readlink /nix/var/nix/profiles/system 2>/dev/null || true)", "if [ -n \"$after\" ] && [ \"$after\" != \"$before\" ]; then printf '%s\\n' \"synthetic-built-generation: $after\"; else printf '%s\\n' \"synthetic-built-generation: unchanged\"; fi", "exit \"$rc\"", ] - .join("; ") + .join("; "); + format!("{bootstrap}\n{switch}") } /// Later readlink. The fake ssh prints the booted generation number for any @@ -610,21 +587,48 @@ fn read_tee(mut src: impl Read, mut sink: impl Write) -> Vec { buf } +fn ssh_command(host: &str, remote_cmd: &str) -> Command { + let extra = ssh_extra_args(host); + let mut cmd = Command::new(ssh_bin()); + cmd.args(&extra).arg("--").arg(host).arg(remote_cmd); + cmd +} + +/// Local exec of the ssh program. Retry only ETXTBSY (os error 26). +/// +/// A shebang script the caller just closed can still be busy when exec +/// starts it. On the Nix builder that is the `fake-ssh` script this +/// crate's tests pass through `SURMOUNT_DEPLOY_SSH`. Each attempt is a +/// new `Command`. Any other spawn error returns on the first try. The +/// last ETXTBSY returns that error and is not a successful smoke. The +/// program did not start, so this is not a second remote switch. +fn retry_local_exec(mut once: impl FnMut() -> io::Result) -> io::Result { + const ATTEMPTS: u32 = 5; + for attempt in 0..ATTEMPTS { + match once() { + Ok(value) => return Ok(value), + Err(err) if err.raw_os_error() == Some(26) && attempt + 1 < ATTEMPTS => { + std::thread::sleep(std::time::Duration::from_millis( + 20 * u64::from(attempt + 1), + )); + } + Err(err) => return Err(err), + } + } + Err(io::Error::from_raw_os_error(26)) +} + /// Run one SSH. Copy stdout and stderr through as they arrive so a long /// rebuild stays visible, and return stdout for the generation line. /// Stderr is not dropped. fn ssh_captured(host: &str, remote_cmd: &str) -> (RemoteFinish, String) { - let extra = ssh_extra_args(host); - let mut child = match Command::new(ssh_bin()) - .args(&extra) - .arg("--") - .arg(host) - .arg(remote_cmd) - .stdin(Stdio::inherit()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - { + let mut child = match retry_local_exec(|| { + let mut cmd = ssh_command(host, remote_cmd); + cmd.stdin(Stdio::inherit()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + cmd.spawn() + }) { Ok(child) => child, Err(e) => { return ( @@ -666,14 +670,7 @@ fn read_profile_generation(host: &str) -> Option { } fn ssh_finish(host: &str, remote_cmd: &str) -> RemoteFinish { - let extra = ssh_extra_args(host); - match Command::new(ssh_bin()) - .args(&extra) - .arg("--") - .arg(host) - .arg(remote_cmd) - .status() - { + match retry_local_exec(|| ssh_command(host, remote_cmd).status()) { Ok(status) => classify_ssh_code(status.code()), Err(e) => RemoteFinish::Local(format!("ssh failed: {e}")), } @@ -709,6 +706,22 @@ where ))) } +/// The build is one attempt. It must not fall through into a switch. +fn build_outcome(finish: RemoteFinish) -> Result { + match finish { + RemoteFinish::Exited(code) => Ok(code), + RemoteFinish::Connection { code } => { + let shown = ssh_status_label(code); + Err(ToolError::fail(format!( + "connection failed during nixos-rebuild build (ssh status {shown}); not switching the running generation" + ))) + } + RemoteFinish::Local(msg) => Err(ToolError::fail(format!( + "connection failed during nixos-rebuild build ({msg}); not switching the running generation" + ))), + } +} + /// The switch is one attempt. Never call this in a retry loop. fn switch_outcome(finish: RemoteFinish) -> Result { match finish { @@ -737,11 +750,14 @@ fn ssh_run(host: &str, remote_cmd: &str) -> Result<(), ToolError> { fn rsync_local(flags: &[&str], src: &Path, dest: &Path) -> Result<(), ToolError> { let src_s = format!("{}/", src.display()); let dest_s = format!("{}/", dest.display()); - let mut cmd = Command::new("rsync"); - cmd.args(flags).arg(&src_s).arg(&dest_s); - let st = cmd - .status() - .map_err(|e| ToolError::fail(format!("rsync failed: {e}")))?; + // Same ETXTBSY cap as the fake-ssh exec. The dry-run rsync is a + // script the test just closed. status() is the spawn. + let st = retry_local_exec(|| { + let mut cmd = Command::new("rsync"); + cmd.args(flags).arg(&src_s).arg(&dest_s); + cmd.status() + }) + .map_err(|e| ToolError::fail(format!("rsync failed: {e}")))?; if st.success() { Ok(()) } else { @@ -759,10 +775,12 @@ fn rsync_remote( let src_s = format!("{}/", src.display()); let dest_s = format!("{target}:{}/", remote_dir.trim_end_matches('/')); let code = retry_remote("rsync", || { - let mut cmd = Command::new("rsync"); - cmd.arg("-e").arg(ssh_rsh(target)); - cmd.args(flags).args(extra).arg(&src_s).arg(&dest_s); - match cmd.status() { + match retry_local_exec(|| { + let mut cmd = Command::new("rsync"); + cmd.arg("-e").arg(ssh_rsh(target)); + cmd.args(flags).args(extra).arg(&src_s).arg(&dest_s); + cmd.status() + }) { Ok(status) => classify_rsync_code(status.code()), Err(e) => RemoteFinish::Local(format!("rsync failed: {e}")), } @@ -951,8 +969,83 @@ mod tests { assert!(PROFILE_GENERATION_REMOTE.contains("readlink")); let cmd = switch_remote_command("path:/tmp/repo#mail-vps"); assert!(cmd.contains("nixos-rebuild switch --flake")); + assert!(!cmd.contains("nixos-rebuild build")); + assert!(cmd.contains("--option max-jobs 32")); + assert!(cmd.contains("--option cores 0")); + assert!(!cmd.contains("--keep-going"), "{cmd}"); assert!(cmd.contains("path:/tmp/repo#mail-vps")); + assert!(cmd.contains("/nix/var/nix/profiles/system")); assert!(cmd.contains("synthetic-built-generation:")); assert!(!cmd.contains("post-switch-smoke")); + let rebuild_at = cmd + .find("nixos-rebuild switch") + .expect("system build command"); + let bootstrap = &cmd[..rebuild_at]; + assert!( + bootstrap.contains("/run/surmount-nix.conf"), + "bootstrap must run before the system build" + ); + assert!( + bootstrap.contains("gccarch-x86-64-v3"), + "bootstrap must name the missing feature" + ); + assert!( + !bootstrap.contains("nixos-rebuild"), + "bootstrap text must not add a second nixos-rebuild line for the fake ssh log" + ); + } + + #[test] + fn switch_bootstraps_gccarch_before_the_system_build() { + let cmd = switch_remote_command("path:/tmp/repo#mail-vps"); + let rebuild_at = cmd.find("nixos-rebuild switch").expect("rebuild"); + assert!(cmd.find("/run/surmount-nix.conf").unwrap() < rebuild_at); + assert!(cmd.find("mount --bind").unwrap() < rebuild_at); + assert!(cmd.find("systemctl restart nix-daemon").unwrap() < rebuild_at); + assert!(cmd.find("nix config show system-features").unwrap() < rebuild_at); + assert!(!cmd.contains("nix config show extra-system-features")); + assert!(cmd.contains("not starting the system build")); + } + + #[test] + fn dry_run_command_is_build_and_does_not_switch_or_touch_profile() { + let cmd = build_remote_command("path:/tmp/repo#mail-vps"); + assert!( + cmd.contains( + "nixos-rebuild build --flake path:/tmp/repo#mail-vps --option max-jobs 32 --option cores 0 --keep-going" + ), + "{cmd}" + ); + assert!(cmd.trim_end().ends_with("--keep-going"), "{cmd}"); + assert!(!cmd.contains("nixos-rebuild switch"), "{cmd}"); + assert!(!cmd.contains("/nix/var/nix/profiles/system"), "{cmd}"); + assert!(!cmd.contains("nix-env"), "{cmd}"); + assert!(!cmd.contains("nix-collect-garbage"), "{cmd}"); + assert!(!cmd.contains("--upgrade"), "{cmd}"); + assert!(!cmd.contains("post-switch-smoke"), "{cmd}"); + assert!(!cmd.contains("synthetic-built-generation:"), "{cmd}"); + let rebuild_at = cmd + .find("nixos-rebuild build") + .expect("system build command"); + let bootstrap = &cmd[..rebuild_at]; + assert!( + bootstrap.contains("/run/surmount-nix.conf"), + "bootstrap must run before the system build" + ); + assert!( + bootstrap.contains("gccarch-x86-64-v3"), + "bootstrap must name the missing feature" + ); + assert!( + !bootstrap.contains("nixos-rebuild"), + "bootstrap text must not add a second nixos-rebuild line" + ); + let switched = switch_remote_command("path:/tmp/repo#mail-vps"); + assert!(switched.contains("nixos-rebuild switch --flake")); + assert!(switched.contains( + "nixos-rebuild switch --flake path:/tmp/repo#mail-vps --option max-jobs 32 --option cores 0" + )); + assert!(!switched.contains("nixos-rebuild build")); + assert!(!switched.contains("--keep-going"), "{switched}"); } } diff --git a/crates/surmount-deploy-host/tests/deploy_host_cli.rs b/crates/surmount-deploy-host/tests/deploy_host_cli.rs index 08da333..4d3c6a1 100644 --- a/crates/surmount-deploy-host/tests/deploy_host_cli.rs +++ b/crates/surmount-deploy-host/tests/deploy_host_cli.rs @@ -76,10 +76,14 @@ fn secrets_stub() -> PathBuf { write_exec( &dir, "secrets-install-host", - "#!/bin/sh\necho secrets-install-host \"$@\"\nexit 0\n", + "#!/bin/sh\nprintf ran > \"$(dirname \"$0\")/ran\"\necho secrets-install-host \"$@\"\nexit 0\n", ) } +fn secrets_stub_ran(stub: &Path) -> bool { + stub.parent().unwrap().join("ran").is_file() +} + #[test] fn help_exits_0_and_mentions_dry_run() { let out = cmd().arg("--help").output().unwrap(); @@ -87,6 +91,9 @@ fn help_exits_0_and_mentions_dry_run() { let s = combined(&out); assert!(s.contains("--dry-run"), "{s}"); assert!(s.contains("--install-secrets"), "{s}"); + assert!(s.contains("nixos-rebuild build"), "{s}"); + assert!(!s.contains("do not rsync"), "{s}"); + assert!(!s.contains("Print planned commands"), "{s}"); } #[test] @@ -107,36 +114,37 @@ fn option_shaped_target_rejected() { } #[test] -fn dry_run_good_host_local_prints_rebuild_and_smoke() { - let out = cmd() +fn dry_run_good_host_local_builds_and_does_not_switch() { + let (mut c, ssh_log_path, rsync_log_path) = hermetic_cmd(); + let out = c .args(["--dry-run", "--target", "example.test"]) .arg("--host-local") .arg(good_hl()) .output() .unwrap(); let s = combined(&out); + let ssh_log = fs::read_to_string(&ssh_log_path).unwrap_or_default(); + let rsync_log = fs::read_to_string(&rsync_log_path).unwrap_or_default(); assert!(out.status.success(), "{s}"); - assert!(s.contains("nixos-rebuild") && s.contains("mail-vps"), "{s}"); - assert!(s.contains("path:") && s.contains("#mail-vps"), "{s}"); - assert!(s.contains("host-local"), "{s}"); + assert_dry_run_builds(&ssh_log, &s); assert!( - s.contains("secrets.yaml") || s.contains(".agekey") || s.contains("secrets/"), - "{s}" + rsync_log.lines().filter(|line| !line.is_empty()).count() >= 2, + "dry-run must rsync the public tree and host-local: {rsync_log}" ); - let low = s.to_ascii_lowercase(); assert!( - !s.contains("does not mention authorizedKeys"), - "known-name layout auto-wires keys and must not warn: {s}" + rsync_log.contains("secrets.yaml") + || rsync_log.contains(".agekey") + || rsync_log.contains("secrets/"), + "{rsync_log}" ); + assert!(rsync_log.contains("host-local"), "{rsync_log}"); + assert!(rsync_log.contains("example.test"), "{rsync_log}"); assert!( - low.contains("post-switch smoke") || s.contains("deploy-host-post-switch-smoke"), - "{s}" - ); - assert!( - low.contains("systemctl is-active") || low.contains("surmount-management-ui"), - "{s}" + !s.contains("does not mention authorizedKeys"), + "known-name layout auto-wires keys and must not warn: {s}" ); - assert!(low.contains("health") || low.contains("loopback"), "{s}"); + assert!(!s.to_ascii_lowercase().contains("post-switch smoke"), "{s}"); + assert!(!ssh_log.contains("post-switch-smoke"), "{ssh_log}"); } #[test] @@ -144,7 +152,8 @@ fn root_target_dry_run_mentions_laptop_identity() { let home = temp_dir("ssh-home"); fs::create_dir_all(home.join(".ssh")).unwrap(); fs::write(home.join(".ssh/id_ed25519"), "synthetic-not-a-real-key\n").unwrap(); - let out = cmd() + let (mut c, ssh_log_path, _) = hermetic_cmd(); + let out = c .env("HOME", &home) .args(["--dry-run", "--target", "root@example.test"]) .arg("--host-local") @@ -152,12 +161,11 @@ fn root_target_dry_run_mentions_laptop_identity() { .output() .unwrap(); let s = combined(&out); + let ssh_log = fs::read_to_string(&ssh_log_path).unwrap_or_default(); assert!(out.status.success(), "{s}"); - assert!(s.contains("id_ed25519"), "{s}"); - assert!( - s.contains("keepalives") || s.contains("SURMOUNT_DEPLOY_SSH_IDENTITY"), - "{s}" - ); + assert_dry_run_builds(&ssh_log, &s); + assert!(ssh_log.contains("id_ed25519"), "{ssh_log}"); + assert!(ssh_log.contains("ServerAliveInterval=30"), "{ssh_log}"); } #[test] @@ -233,20 +241,49 @@ fn refuse_host_local_into_hosts_and_secrets_paths() { #[test] fn default_dry_run_does_not_plan_secrets_install() { - let out = cmd() + let (mut c, ssh_log_path, _) = hermetic_cmd(); + let out = c .args(["--dry-run", "--target", "example.test"]) .arg("--host-local") .arg(good_hl()) .output() .unwrap(); - assert!(out.status.success()); - let s = combined(&out).to_ascii_lowercase(); + let s = combined(&out); + let ssh_log = fs::read_to_string(&ssh_log_path).unwrap_or_default(); + assert!(out.status.success(), "{s}"); + assert_dry_run_builds(&ssh_log, &s); + let low = s.to_ascii_lowercase(); assert!( - !s.contains("secrets-install-host") && !s.contains("would run secrets-install"), + !low.contains("secrets-install-host") && !low.contains("would run secrets-install"), "{s}" ); } +#[test] +fn dry_run_build_failure_does_not_switch_or_run_smoke() { + let (mut c, ssh_log_path, _) = hermetic_cmd(); + let out = c + .env("SURMOUNT_TEST_REBUILD_EXIT", "1") + .args(["--dry-run", "--target", "example.test"]) + .arg("--host-local") + .arg(good_hl()) + .output() + .unwrap(); + let s = combined(&out); + let ssh_log = fs::read_to_string(&ssh_log_path).unwrap_or_default(); + assert!(!out.status.success(), "{s}"); + assert!(ssh_log.contains("nixos-rebuild build --flake"), "{ssh_log}"); + assert!(!ssh_log.contains("nixos-rebuild switch"), "{ssh_log}"); + assert!( + !ssh_log.contains("/nix/var/nix/profiles/system"), + "{ssh_log}" + ); + assert!(!ssh_log.contains("post-switch-smoke"), "{ssh_log}"); + assert!(s.contains("the running generation was not switched"), "{s}"); + assert!(!s.contains("nixos-rebuild build finished"), "{s}"); + assert!(!s.to_ascii_lowercase().contains("post-switch smoke"), "{s}"); +} + #[test] fn install_secrets_without_source_fails() { let out = cmd() @@ -355,7 +392,8 @@ fn plant_stage() -> PathBuf { fn opt_in_dry_run_plans_bridge_without_payload() { let stub = secrets_stub(); let stg = plant_stage(); - let out = cmd() + let (mut c, ssh_log_path, _) = hermetic_cmd(); + let out = c .env("SURMOUNT_DEPLOY_SECRETS_INSTALL", &stub) .args(["--dry-run", "--target", "example.test"]) .arg("--host-local") @@ -372,8 +410,9 @@ fn opt_in_dry_run_plans_bridge_without_payload() { .output() .unwrap(); let s = combined(&out); + let ssh_log = fs::read_to_string(&ssh_log_path).unwrap_or_default(); assert!(out.status.success(), "{s}"); - assert!(s.contains("secrets-install-host"), "{s}"); + assert!(s.contains("would run secrets-install-host"), "{s}"); assert!(s.contains("--from-staging"), "{s}"); assert!(s.contains("mail-lab"), "{s}"); assert!(s.contains("example.test"), "{s}"); @@ -381,7 +420,8 @@ fn opt_in_dry_run_plans_bridge_without_payload() { s.contains("--require-kind") && s.contains("tls-cert"), "{s}" ); - assert!(s.contains("nixos-rebuild"), "{s}"); + assert_dry_run_builds(&ssh_log, &s); + assert!(!secrets_stub_ran(&stub), "dry-run must not install secrets"); assert!(!s.contains(PLANTED), "{s}"); } @@ -389,7 +429,8 @@ fn opt_in_dry_run_plans_bridge_without_payload() { fn env_install_secrets_plans_bridge() { let stub = secrets_stub(); let stg = plant_stage(); - let out = cmd() + let (mut c, ssh_log_path, _) = hermetic_cmd(); + let out = c .env("SURMOUNT_DEPLOY_SECRETS_INSTALL", &stub) .env("SURMOUNT_DEPLOY_INSTALL_SECRETS", "1") .env("SURMOUNT_SECRETS_STAGING", &stg) @@ -400,15 +441,19 @@ fn env_install_secrets_plans_bridge() { .output() .unwrap(); let s = combined(&out); + let ssh_log = fs::read_to_string(&ssh_log_path).unwrap_or_default(); assert!(out.status.success(), "{s}"); - assert!(s.contains("secrets-install-host"), "{s}"); + assert!(s.contains("would run secrets-install-host"), "{s}"); + assert_dry_run_builds(&ssh_log, &s); + assert!(!secrets_stub_ran(&stub), "dry-run must not install secrets"); assert!(!s.contains(PLANTED)); } #[test] fn secret_service_only_plans_without_staging() { let stub = secrets_stub(); - let out = cmd() + let (mut c, ssh_log_path, _) = hermetic_cmd(); + let out = c .env("SURMOUNT_DEPLOY_SECRETS_INSTALL", &stub) .args(["--dry-run", "--target", "example.test"]) .arg("--host-local") @@ -422,11 +467,13 @@ fn secret_service_only_plans_without_staging() { .output() .unwrap(); let s = combined(&out); + let ssh_log = fs::read_to_string(&ssh_log_path).unwrap_or_default(); assert!(out.status.success(), "{s}"); - assert!(s.contains("secrets-install-host"), "{s}"); + assert!(s.contains("would run secrets-install-host"), "{s}"); assert!(s.contains("--from-secret-service"), "{s}"); assert!(!s.contains("--from-staging"), "{s}"); - assert!(s.contains("nixos-rebuild"), "{s}"); + assert_dry_run_builds(&ssh_log, &s); + assert!(!secrets_stub_ran(&stub), "dry-run must not install secrets"); } #[test] @@ -477,7 +524,8 @@ fn vaultwarden_admin_and_namecheap_allowlisted() { let stub = secrets_stub(); let stg = plant_stage(); for kind in ["vaultwarden-admin", "namecheap-api"] { - let out = cmd() + let (mut c, ssh_log_path, _) = hermetic_cmd(); + let out = c .env("SURMOUNT_DEPLOY_SECRETS_INSTALL", &stub) .args(["--dry-run", "--target", "example.test"]) .arg("--host-local") @@ -494,9 +542,12 @@ fn vaultwarden_admin_and_namecheap_allowlisted() { .output() .unwrap(); let s = combined(&out); + let ssh_log = fs::read_to_string(&ssh_log_path).unwrap_or_default(); assert!(out.status.success(), "{kind}: {s}"); - assert!(s.contains("secrets-install-host"), "{s}"); + assert!(s.contains("would run secrets-install-host"), "{s}"); assert!(s.contains("--require-kind") && s.contains(kind), "{s}"); + assert_dry_run_builds(&ssh_log, &s); + assert!(!secrets_stub_ran(&stub), "dry-run must not install secrets"); assert!(!s.contains(PLANTED), "{s}"); } } @@ -533,6 +584,61 @@ fn lines_containing(log: &str, needle: &str) -> usize { log.lines().filter(|line| line.contains(needle)).count() } +/// Fake rsync plus the fake ssh, so a dry-run test does not touch the network. +fn hermetic_cmd() -> (Command, PathBuf, PathBuf) { + let dir = temp_dir("hermetic"); + let (ssh, ssh_log) = fake_ssh(&dir); + let rsync_log = dir.join("rsync.log"); + write_exec( + &dir, + "rsync", + "#!/bin/sh\nlog=\"$SURMOUNT_TEST_RSYNC_LOG\"\nprintf '%s\\n' \"$*\" >> \"$log\"\nexit 0\n", + ); + let mut c = cmd(); + let path = std::env::var("PATH").unwrap_or_else(|_| "/usr/bin:/bin".to_string()); + c.env("PATH", format!("{}:{path}", dir.display())); + c.env("SURMOUNT_DEPLOY_SSH", &ssh); + c.env("SURMOUNT_TEST_SSH_LOG", &ssh_log); + c.env("SURMOUNT_TEST_RSYNC_LOG", &rsync_log); + (c, ssh_log, rsync_log) +} + +fn assert_dry_run_builds(ssh_log: &str, out: &str) { + assert!( + ssh_log.contains( + "nixos-rebuild build --flake path:/root/surmount-server#mail-vps --option max-jobs 32 --option cores 0 --keep-going" + ), + "{ssh_log}" + ); + assert!(!ssh_log.contains("nixos-rebuild switch"), "{ssh_log}"); + assert!( + !ssh_log.contains("/nix/var/nix/profiles/system"), + "{ssh_log}" + ); + assert!(!ssh_log.contains("post-switch-smoke"), "{ssh_log}"); + assert!( + !ssh_log.contains("surmount-deploy-host-post-switch-smoke"), + "{ssh_log}" + ); + assert!(ssh_log.contains("gccarch-x86-64-v3"), "{ssh_log}"); + assert!(ssh_log.contains("/run/surmount-nix.conf"), "{ssh_log}"); + let build_at = ssh_log + .find("nixos-rebuild build") + .expect("dry-run remote command"); + let boot_at = ssh_log.find("/run/surmount-nix.conf").expect("bootstrap"); + assert!( + boot_at < build_at, + "bootstrap must run before nixos-rebuild build" + ); + assert!( + out.contains( + "deploy-host: nixos-rebuild build finished; the running generation was not switched" + ), + "{out}" + ); + assert!(!out.contains("nixos-rebuild switch"), "{out}"); +} + fn deploy_with_fake_ssh(rebuild_exit: &str, smoke_exit: &str) -> (Output, String) { let dir = temp_dir("fake-ssh"); let (script, log) = fake_ssh(&dir); @@ -580,6 +686,12 @@ fn finished_rebuild_runs_smoke_and_can_succeed() { let (out, log) = deploy_with_fake_ssh("0", "0"); let s = combined(&out); assert!(out.status.success(), "{s}"); + assert!(log.contains("nixos-rebuild switch"), "{log}"); + assert!(log.contains( + "nixos-rebuild switch --flake path:/root/surmount-server#mail-vps --option max-jobs 32 --option cores 0" + )); + assert!(!log.contains("--keep-going"), "{log}"); + assert!(!log.contains("nixos-rebuild build"), "{log}"); assert_eq!(lines_containing(&log, "nixos-rebuild"), 1, "{log}"); assert_eq!(lines_containing(&log, "post-switch-smoke"), 1, "{log}"); } diff --git a/crates/surmount-host-cutover/testdata/host-profile/sample-host-profile.toml b/crates/surmount-host-cutover/testdata/host-profile/sample-host-profile.toml index b55673d..dd1593b 100644 --- a/crates/surmount-host-cutover/testdata/host-profile/sample-host-profile.toml +++ b/crates/surmount-host-cutover/testdata/host-profile/sample-host-profile.toml @@ -9,4 +9,5 @@ acme_domains = [ "mail.example.invalid", "example.invalid", "www.example.invalid", + "status.example.invalid", ] diff --git a/crates/surmount-private-data/src/lib.rs b/crates/surmount-private-data/src/lib.rs index 7bae8b0..0e94a77 100644 --- a/crates/surmount-private-data/src/lib.rs +++ b/crates/surmount-private-data/src/lib.rs @@ -69,6 +69,7 @@ pub fn is_fixture_path(rel: &str) -> bool { || p.starts_with("script/testdata/private-data/") || p == "crates/surmount-private-data/testdata" || p.starts_with("crates/surmount-private-data/testdata/") + || p == "crates/surmount-rekey/src/lib.rs" } pub fn is_scanner_tool_path(rel: &str) -> bool { @@ -347,4 +348,39 @@ mod tests { )); assert!(!is_fixture_path("hosts/mail-vps/configuration.nix")); } + + #[test] + fn rekey_age_test_vector_is_excluded_and_still_detected() { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../surmount-rekey/src/lib.rs"); + let bytes = std::fs::read(&path) + .unwrap_or_else(|e| panic!("missing rekey test file {}: {e}", path.display())); + let text = String::from_utf8_lossy(&bytes); + const PREFIX: &str = "AGE-SECRET-KEY-1"; + let mut from = 0; + let mut found = false; + while let Some(pos) = text[from..].find(PREFIX) { + let i = from + pos + PREFIX.len(); + let rest = text.as_bytes().get(i..).unwrap_or(&[]); + if rest + .iter() + .take_while(|b| b.is_ascii_uppercase() || b.is_ascii_digit()) + .count() + >= 1 + { + found = true; + break; + } + from += pos + 1; + } + assert!( + found, + "crates/surmount-rekey/src/lib.rs no longer contains an ignored age test key" + ); + let rel = "crates/surmount-rekey/src/lib.rs"; + let hits = scan_named(rel, &bytes, scan::Gate::Paths); + assert!(classes(&hits).contains(&"age-secret-key"), "{hits:?}"); + assert!(is_fixture_path(rel)); + assert!(is_fixture_path("./crates/surmount-rekey/src/lib.rs")); + assert!(!is_fixture_path("crates/surmount-rekey/src/main.rs")); + } } diff --git a/crates/surmount-rekey/Cargo.toml b/crates/surmount-rekey/Cargo.toml index f293923..09f4ff7 100644 --- a/crates/surmount-rekey/Cargo.toml +++ b/crates/surmount-rekey/Cargo.toml @@ -3,7 +3,7 @@ name = "surmount-rekey" version.workspace = true edition.workspace = true license.workspace = true -description = "SHC zero-knowledge backup re-key paste (PGP wrap via rage/age + gpg)" +description = "SHC zero-knowledge backup re-key paste (X25519 age identity + gpg)" publish = false [[bin]] @@ -15,9 +15,14 @@ name = "surmount_rekey" path = "src/lib.rs" [dependencies] -age.workspace = true anyhow.workspace = true +# 0.11 matches the age identity Bech32 (BIP-173) strings. 0.12 is a different +# major already used elsewhere and is not this encoding. +bech32 = "0.11.1" secrecy = "0.10" +# static_secrets: reusable X25519 secret. getrandom: OsRng via StaticSecret::random. +# Not the age crate. age 0.12.1 always depends on hpke and p256. +x25519-dalek = { version = "2.0.1", features = ["getrandom", "static_secrets"] } [lints] workspace = true diff --git a/crates/surmount-rekey/src/lib.rs b/crates/surmount-rekey/src/lib.rs index ee60832..3dd3a48 100644 --- a/crates/surmount-rekey/src/lib.rs +++ b/crates/surmount-rekey/src/lib.rs @@ -1,9 +1,13 @@ //! SHC zero-knowledge backup re-key helper. //! -//! Default rung is PGP: mint an X25519 age identity with the `age` crate -//! (rage's library), wrap it with the operator's gpg, print only the public -//! `age1...` recipient for the SHC Backups panel (kind pgp). +//! Default rung is PGP: mint an X25519 age identity (Bech32 `AGE-SECRET-KEY-1` +//! secret and `age1` recipient, same byte layout as rage), wrap it with the +//! operator's gpg, print only the public `age1...` recipient for the SHC +//! Backups panel (kind pgp). //! Never prints AGE-SECRET-KEY-1 on stdout. +//! +//! This crate does not depend on the `age` crate. age 0.12.1 always links +//! hpke and p256, and that edge requires crypto-bigint 0.5. use std::fs; use std::io::Write; @@ -11,8 +15,40 @@ use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; -use age::x25519::Identity; -use secrecy::ExposeSecret; +use bech32::{Bech32, Hrp}; +use secrecy::{ExposeSecret, SecretString}; +use x25519_dalek::{PublicKey, StaticSecret}; + +const SECRET_KEY_PREFIX: Hrp = Hrp::parse_unchecked("AGE-SECRET-KEY-"); +const PUBLIC_KEY_PREFIX: Hrp = Hrp::parse_unchecked("age"); + +/// X25519 age identity. Secret bytes are the unclamped 32-byte scalar that +/// `StaticSecret` stores. The public recipient is the clamped Montgomery +/// base-point product, Bech32 with HRP `age`. Same steps as age 0.12 +/// `Identity::generate` / `to_string` / `to_public`. +struct X25519Identity { + secret: StaticSecret, +} + +impl X25519Identity { + fn generate() -> Self { + Self { + secret: StaticSecret::random(), + } + } + + fn secret_string(&self) -> Result { + let encoded = bech32::encode_lower::(SECRET_KEY_PREFIX, self.secret.as_bytes()) + .map_err(|e| RekeyError::fail(format!("age identity encode: {e}")))?; + Ok(SecretString::from(encoded.to_uppercase())) + } + + fn recipient(&self) -> Result { + let pk = PublicKey::from(&self.secret); + bech32::encode_lower::(PUBLIC_KEY_PREFIX, pk.as_bytes()) + .map_err(|e| RekeyError::fail(format!("age recipient encode: {e}"))) + } +} #[derive(Debug)] pub struct RekeyError { @@ -67,14 +103,15 @@ pub fn default_gpg_recipient() -> String { /// Mint identity, write 0600 identity file, wrap with gpg unless dry_run. pub fn run_rekey(req: &RekeyRequest) -> Result { fs::create_dir_all(&req.dir).map_err(|e| RekeyError::fail(format!("mkdir: {e}")))?; - let identity = Identity::generate(); - let recipient = identity.to_public().to_string(); + let identity = X25519Identity::generate(); + let recipient = identity.recipient()?; if !recipient.starts_with("age1") { return Err(RekeyError::fail("age identity produced no age1 recipient")); } + let secret = identity.secret_string()?; let identity_path = req.dir.join("age-identity.txt"); let envelope_path = req.dir.join("envelope.asc"); - write_secret_file(&identity_path, identity.to_string().expose_secret())?; + write_secret_file(&identity_path, secret.expose_secret())?; if req.dry_run { fs::write( &envelope_path, @@ -85,7 +122,7 @@ pub fn run_rekey(req: &RekeyRequest) -> Result { gpg_wrap( &req.gpg_bin, &req.gpg_recipient, - identity.to_string().expose_secret().as_bytes(), + secret.expose_secret().as_bytes(), &envelope_path, )?; } @@ -251,6 +288,24 @@ mod tests { bin } + #[test] + fn age_test_vector_recipient_matches_secret() { + // Published age 0.12 x25519 test vector (not a live key). + const TEST_SK: &str = + "AGE-SECRET-KEY-1GQ9778VQXMMJVE8SK7J6VT8UJ4HDQAJUVSFCWCM02D8GEWQ72PVQ2Y5J33"; + const TEST_PK: &str = "age1t7rxyev2z3rw82stdlrrepyc39nvn86l5078zqkf5uasdy86jp6svpy7pa"; + let parsed = bech32::primitives::decode::CheckedHrpstring::new::(TEST_SK) + .expect("age test secret is Bech32"); + assert_eq!(parsed.hrp(), SECRET_KEY_PREFIX); + let bytes: Vec = parsed.byte_iter().collect(); + let sk_bytes: [u8; 32] = bytes.as_slice().try_into().expect("32-byte scalar"); + let identity = X25519Identity { + secret: StaticSecret::from(sk_bytes), + }; + assert_eq!(identity.recipient().unwrap(), TEST_PK); + assert_eq!(identity.secret_string().unwrap().expose_secret(), TEST_SK); + } + #[test] fn card_has_pgp_and_age1_not_secret() { let rec = "age1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq2xq5s0"; diff --git a/crates/surmount-rekey/src/main.rs b/crates/surmount-rekey/src/main.rs index f8a3184..da7400c 100644 --- a/crates/surmount-rekey/src/main.rs +++ b/crates/surmount-rekey/src/main.rs @@ -9,7 +9,7 @@ Usage: surmount-rekey [--help] surmount-rekey [--dry-run] [--dir DIR] [--gpg-recipient ADDR] [--gpg-bin PATH] -Mint an age identity (rage/age crate), wrap it with gpg, print the public +Mint an X25519 age identity, wrap it with gpg, print the public age1 recipient for SHC Backups re-key (type PGP). Does not print AGE-SECRET-KEY-1. Does not talk to SHC. diff --git a/docs/COMPACTION-PIN.md b/docs/COMPACTION-PIN.md index a9babf6..b71c4d5 100644 --- a/docs/COMPACTION-PIN.md +++ b/docs/COMPACTION-PIN.md @@ -4,7 +4,27 @@ Read this first, then follow links into child docs. Do not invent product scope from memory alone. -**Last updated:** 2026-10-05 (`just publish` is the static-site command +**Last updated:** 2026-11-06 (this repo builds with Rust or C. GHC, +Haskell, and Python are forbidden, including transitive dependencies. +An eval that reaches them fails. The firewall-tool check was the +remaining Haskell edge and is now a non-Haskell script. nftables and +the firewall stay enabled). Prior 2026-11-04 (`surmount-deploy-host` bootstraps +gccarch-x86-64-v3 on the target before the system build when the +running nix-daemon omits that feature. The tool writes +`/run/surmount-nix.conf`, bind-mounts it over the store nix.conf, and +restarts nix-daemon only when the feature was missing. The `/run` bind +does not survive reboot. A successful switch is what puts the feature +in the generation's nix.conf. An operator or an agent does not SSH a +bind before `just deploy`. The evaluated mail-vps config still +requests the feature for the next generation). Prior 2026-11-04 +(surmount-1 nix-daemon must list gccarch-x86-64-v3 before `just +deploy` can build the x86-64-v3 stdenv; the evaluated mail-vps config +already requests it, and a `/run` bind of nix.conf does not survive +reboot). Prior 2026-11-03 +(number every parallel list in operator +chat; horizon is this laptop; surmount-1 is the mail host and the +remote Nix builder; the private overlay directory is not a machine). +Prior 2026-10-05 (`just publish` is the static-site command and `just deploy` is the host switch. `just deploy-dry-run` is the agent dry-run. `just deploy-host` stays the explicit equivalent with no default target). Prior 2026-09-20 (full test-and-deploy: @@ -79,6 +99,21 @@ line is eternal law. Prefer **operator direction YYYY-MM-DD**, **proposed**, [open-choices.md](open-choices.md), [../AGENTS.md](../AGENTS.md). +## Numbered lists and machine names (operator 2026-11-03) + +1. Number every parallel list in operator-facing chat, reports, and + leftover lists. A bare stack of services is a miss. +2. **horizon** is this laptop. **surmount-1** is the mail host and the + allowed remote Nix builder. Do not say "host-local" as a hostname. +3. The private overlay is a directory. On horizon it is + `~/.local/share/surmount/host-local/` (`default.nix`). `just deploy` + passes it as `--host-local` with target `root@surmount-1`. On + surmount-1 the copy is `/root/surmount-server/host-local/`. Name the + machine and the path in the same sentence. The flag name stays. + +Dual-pin [../AGENTS.md](../AGENTS.md) section *Number parallel items, +and name the machine*. + --- ## 1. What this project is @@ -91,6 +126,7 @@ flake-owned host. |-------|------| | Primary domain | `surmount.systems` | | Management UI host | `services.surmount.systems` | +| Systems dashboard host | `status.surmount.systems` (Axum, not nginx) | | Mail host / MX | `mail.surmount.systems` | | Form factor | **Single operator-chosen VPS** until the operator says otherwise | | Public awareness | Treat the tree as **public-domain / public**. Zero secrets in git. | @@ -117,13 +153,21 @@ Ladder detail: [fix-and-fixos.md](fix-and-fixos.md). |------|--------| | **NEVER secrets in git** | No plaintext, no ciphertext of production secrets, no age/sops private keys, no LUKS unlock material, no `.env`. **Never suggest** "encrypted in git." Host / operator channels only. [hygiene.md](hygiene.md), [SECRETS.md](SECRETS.md). | | **Stack language** | **NixOS + Nix + Rust**. **No Python** product/ops (fail2ban transitional-at-most). **No NPM**. Gaps filled in-house. [principles.md](principles.md) section 5b. | +| **No GHC, Haskell, or Python** | Operator 2026-11-06: this repo builds with Rust or C. GHC, Haskell, and Python are forbidden, including transitive dependencies. An eval that reaches them fails. The firewall-tool check was the remaining Haskell edge after nixfmt-rs and is now a non-Haskell `writeShellScriptBin` script. nftables and the firewall stay enabled. Do not construct upstream `nixos-firewall-tool`. Mail-host eval throws on `ghc`, `haskellPackages`, `haskell.compiler`, `hadrian`, ShellCheck, `python`, `python3`, and `pythonPackages`. The mail-host closure walk is `t73-no-haskell-mail-host-closure` in `tests/module-eval.nix`. It rejects shellcheck, ShellCheck, ghc, hadrian, haskell, and a name or pname containing python (python3, pythonPackages, dbus-python, pandas, pip, json-schema-for-humans). An empty walk does not pass. Git keeps `pythonSupport` false. Dual-pin `AGENTS.md`. | +| **End a broken nested agent** | Operator 2026-11-07: if an L2 or L3 is broken, end it and start a replacement when the work is still owed. Do not spend the turn weighing that choice. Broken includes an early exit with no land report, an L2 that is still reading or editing after it should have spawned an L3, an L2 started with the wrong instructions, and an L3 still on turn 1 with no file save after about 15 minutes. A healthy agent that is already doing the assigned work stays up. Dual-pin `AGENTS.md` section *How agents work here*. | +| **Existing Rust first, and every dry-run root together** | Operator 2026-11-07: replace a Python step with an existing Rust project. Prefer Rust over C. An existing C project is only the fallback when no Rust project does that job. Do not write a new C program from scratch. The homemade C files this session added under `nix/packages/` are not that replacement. Jinja templates have existing Rust renderers. MiniJinja is one (https://github.com/mitsuhiko/minijinja, accessed: 2026-11-07). Do not reject a Rust renderer because it is not a line-for-line copy of the Python script, and do not write a C renderer in its place. One `just deploy-dry-run` transcript is the full failure list (`--keep-going` is already on the build line). Fix every builder-failed root from that transcript before the next dry-run. Do not fix one root and rerun while other roots from the same transcript remain. Dependents of a failed root are not extra roots. Later the same day, handwritten C is forbidden. See the next row. Dual-pin `AGENTS.md` section *Existing Rust or C, and every dry-run root together*. | +| **Handwritten C is forbidden** | Operator 2026-11-07, always remember. Handwritten C is forbidden in this repo. That includes a `.c` or `.h` file, and C embedded in Nix, bash, a patch, or any other file that emits a C program at build time. The operator cannot audit that code. Removing Python is to shrink the attack surface. Handwritten C is the same class of unauditable code. Upstream projects that are written in C may remain as dependencies. Do not replace removed C with new C. Remove every `.c` and `.h` file we added. Dual-pin `AGENTS.md` section *Handwritten C is forbidden*. | +| **No Python script and no C program inside Nix** | Operator 2026-11-08, always remember. Do not inline a Python script in a Nix file. Do not inline a C program in a Nix file. A build-phase string must not carry a Python program body or a C program body. Renaming a `.py` file, hiding that text in `printf` or `cat`, or stuffing the same text into a patch is the same violation. Use an existing Rust tool, or change the build so the missing program is not invoked. Do not add a package whose name contains python. A Nix file we own must not carry a Meson program body. That includes meson.build text, a muon rewrite, a dependency() method sentence, an install_headers rewrite, or a PKG_CONFIG_PATH prepend whose purpose is to patch a Meson lookup. Hiding that text by rename, printf, cat, or a patch is the same forbid. Upstream packages may still be built by their own upstream build system. Our Nix files do not contain that program text. Operator 2026-11-08 stopped the Meson-in-Nix and C-in-Nix patching line. Dual-pin `AGENTS.md` section *No Python script and no C program inside Nix (operator 2026-11-08)*. | | **No nginx product edge** | In-tree nginx is **transitional-to-delete**. Target **first-party Axum** HTTPS edge. [EDGE_AND_TLS.md](EDGE_AND_TLS.md). | | **No Cloudflare products** | Direct-to-VPS path required. CF Research blog posts may be **cited for learning** only. No orange-cloud, Workers, Tunnel, CF WAF, CF Access as critical path. | | **Agents never touch Git's index** | The index is how the operator tracks agent work. No `git add`, `git commit`, `git push`, stage, `git restore --staged`, `git reset`, `git rm --cached`. Do not "clean up" the index. Operator 2026-08-28: stop touching Git. Dual-pin host `~/.grok/AGENTS.md`. | | **Test and deploy procedure** | 2026-10-12: the update, audit, test, and switch command block is [TEST-AND-DEPLOY.md](TEST-AND-DEPLOY.md) section *Update, audit, test, and switch*. `nix flake update`, then `cargo update`, then `just audit-remote`, `just check-remote`, `just check-heavy`, `just deploy-dry-run`, `just btop`, `just deploy`. No `just` recipe for the first two. 2026-10-05: `just publish` is the static-site command. `just deploy` is the host switch. `just deploy-dry-run` is the agent dry-run. `just deploy-host -- ...` stays the explicit equivalent with no default target. 2026-09-20: [TEST-AND-DEPLOY.md](TEST-AND-DEPLOY.md). Agent runs named eval, `just check-remote`, and the dry-run. Operator does not run those to find errors. Operator runs the real switch with Eternal Terminal open. Dual-pin `AGENTS.md` Deploy dry-run is agent work. | +| **Do not stop before a passing dry-run (operator 2026-11-09)** | Never stop until just deploy-dry-run exits 0. The operator will not run just deploy until that dry-run passes. Stopping to ask the operator to deploy, or ending the task before a green dry-run, wastes the operator's time. The agent runs the dry-run. The agent must not run just deploy. just deploy-host without --dry-run is that same switch. Dual-pin `AGENTS.md` section *Do not stop before a passing dry-run (operator 2026-11-09)*. | +| **Rebuilds must hit the cache (operator 2026-11-09)** | A later dry-run must not recompile a derivation that a previous dry-run already built, unless an input that derivation depends on actually changed. If the same package builds again with no input change, the cache is broken and that is a bug to fix. Do not treat a full rebuild of an unchanged package as normal. Dual-pin `AGENTS.md` section *Rebuilds must hit the cache (operator 2026-11-09)*. | +| **Trim dependencies to this host (operator 2026-11-09)** | Drop dependencies the mail host configuration does not need. The machine is surmount-1. Keep what that configuration uses. Do not add a package only because nixpkgs pulls it by default. Dual-pin `AGENTS.md` section *Trim dependencies to this host (operator 2026-11-09)*. | | **GitHub tracking and git-flow (operator 2026-09-08)** | Same-turn GitHub issue on plan Approve (plan text, no secrets). Same-turn GitHub issue on operator bug reports, with screenshots attached. Agents never GPG-sign and never push. After the operator signs and pushes the feature branch, the agent opens the PR describing the work. Collaborating git uses git-flow `feature/` and `bugfix/` branches, not a pile on `main`. Dual-pin `AGENTS.md` and host `~/.grok/AGENTS.md`. | | **Leftovers: complete sentences, none already done** | Operator 2026-08-28: every numbered leftover is a complete sentence. Do not re-list work the screenshot or live host already shows is done. Dual-pin host `~/.grok/AGENTS.md`. | -| **Prove the operator gate** | Operator 2026-09-02: do not list a step as operator residual without evidence the agent cannot do it (secrets, standing forbid of the real `just deploy` switch, hypervisor, Namecheap hosted DNSSEC Status click). Agents may run one `just check-remote` at a time. The real switch `just deploy` is operator-owned. Agents must not run `just deploy`. `just deploy-host` without `--dry-run` is that same switch and has no default target. Host-local enable is agent work. Using the TUI is the product, not leftover. Operator 2026-09-07: if this laptop already has Namecheap env files, `just laptop-renew-cert` is agent work. Dual-pin `AGENTS.md`. | +| **Prove the operator gate** | Operator 2026-09-02: do not list a step as operator residual without evidence the agent cannot do it (secrets, standing forbid of the real `just deploy` switch, hypervisor, Namecheap hosted DNSSEC Status click). Agents may run one `just check-remote` at a time. The real switch `just deploy` is operator-owned. Agents must not run `just deploy`. `just deploy-host` without `--dry-run` is that same switch and has no default target. Host-local enable is agent work. Using the TUI is the product, not leftover. Operator 2026-09-07: if horizon already has Namecheap env files, `just laptop-renew-cert` is agent work for a DNS-01 issue from horizon. That recipe is not required for the certificate to keep renewing. surmount-1 renews the Let's Encrypt production certificate with HTTP-01. horizon does not have to stay online for that renewal. Dual-pin `AGENTS.md`. | | **Operator-facing chat is CATE** | Operator 2026-09-07: Concise American Technical English ([0005_CATE.md](https://github.com/SurmountSystems/specs/blob/main/0005_CATE.md), accessed: 2026-09-07). Do **not** say **production leaf**. Say the Let's Encrypt certificate this host presents, and the names on that certificate. Dual-pin `AGENTS.md`. | | **Eval then deploy dry-run before asking to switch** | Operator 2026-09-02: before any sentence that asks the operator to deploy or switch, agents must have run (a) local Nix eval that does **not** rustc on this laptop (`tests/module-eval.nix` / named flake eval) and (b) `just deploy-dry-run` (explicit equivalent `just deploy-host -- --dry-run`), and must report those results. Narrows 2026-08-25: dry-run is required agent work; the real switch (`just deploy`) stays operator-owned unless they override. `just deploy-host` stays the explicit equivalent with no default target. Laptop cargo and `BUILD_LOCAL` stay forbidden. Dual-pin `AGENTS.md`. | | **"Always remember" = dual pin** | Operator 2026-08-27: when they say **always remember**, write it in **AGENTS.md and this table in the same turn**. Chat-only does not survive compaction. One file does not survive attention dilution. | @@ -142,21 +186,23 @@ Ladder detail: [fix-and-fixos.md](fix-and-fixos.md). --- -## 3. Versions (last audit 2026-07-31; FOD and toolchain rows re-checked 2026-10-17) +## 3. Versions (last audit 2026-07-31; FOD and toolchain rows re-checked 2026-10-17; Stalwart family re-checked 2026-11-03) Source of truth for FODs: `nix/packages/*.nix`. Audit: [research/version-audit.md](research/version-audit.md). | Pin | Tree (as of audit) | Notes | |-----|--------------------|-------| -| Stalwart server | **0.16.26** binary FOD | Latest GitHub tag on 2026-10-17 (`v0.16.26`, 2026-10-11). A 0.16.x upgrade replaces the binary. Not nixpkgs stalwart | -| stalwart-cli | **1.0.13** | Latest tag on 2026-10-17 (`v1.0.13`, 2026-09-28). Still no Maildir import (that is Vandelay) | -| vandelay | **1.0.12** | Latest tag on 2026-10-17 (`v1.0.12`, 2026-10-11). Official 0.16 Maildir++ / JMAP import | -| Stalwart WebUI | **1.0.11** | Latest tag on 2026-10-17 (`v1.0.11`, 2026-09-15) | -| spam-filter rules | **3.0.2** | Latest tag on 2026-10-17 (`v3.0.2`, 2026-09-22) | -| Arti (HS package) | Surmount-owned source **2.7.0** (`arti-onion-service.nix`, GitLab `arti-v2.7.0`) + `onion-service-service` | Matches nixpkgs-rust `pkgs.arti.version` **2.7.0** (lock `9ecc0a672e9ef2ca4eec8e87dbee653607a7d332`). Not stock nixpkgs 1.4.2 lag. rustc via `nixpkgs-rust` is **1.99.1**. Prior lock `c46e6c9a` was rustc 1.99.0. Live Tor verify still residual | +| Stalwart server | **1.0.2** binary FOD | Latest GitHub tag on 2026-11-03 (`v1.0.2`, published 2026-11-02T15:42:44Z). Not a prerelease. Not nixpkgs stalwart | +| stalwart-cli | **1.0.14** | Latest tag on 2026-11-03 (`v1.0.14`, published 2026-10-25T18:15:42Z). Still no Maildir import (that is Vandelay) | +| vandelay | **1.0.14** | Latest tag on 2026-11-03 (`v1.0.14`, published 2026-11-01T16:43:01Z). Official Maildir++ / JMAP import | +| Stalwart WebUI | **2.0.4** | Latest tag on 2026-11-03 (`v2.0.4`, published 2026-11-02T11:19:17Z) | +| spam-filter rules | **3.0.4** | Latest tag on 2026-11-03 (`v3.0.4`, published 2026-11-01T15:50:40Z). Release asset is `spam-filter-rules.json.gz` only. v3.0.2 was the last tag that also published `spam-filter.toml` | +| Arti (HS package) | Surmount-owned source **2.8.0** (`arti-onion-service.nix`, GitLab `arti-v2.8.0`) + `onion-service-service` | Src hash is the 2026-11-05 prefetch. `cargoHash` is set to sha256-grgIqU1gw1UqeGgR4aR7cAOxBW91F12bHMgOj4pgLcY=. The package does not reuse nixpkgs-rust `pkgs.arti` **2.7.0** `cargoDeps` (lock `9ecc0a672e9ef2ca4eec8e87dbee653607a7d332`). rustc via `nixpkgs-rust` is **1.99.1**. Not stock nixpkgs 1.4.2 lag. Live Tor verify still residual | +| Nix package | **2.35.4** via `nix/overlays.nix` on the nixos-26.05 `nixVersions.nix_2_35` recipe | Channel `pkgs.nix` was `nixVersions.stable` **2.34.9**. Source commit `21286d605815196c3fff1960550ab08700fc4e6f`. That recipe has no cargoHash. NixOS stays on **nixos-26.05** because 26.11 is not released. Release notes still read 2026.11/??. Branch-off is scheduled 2026-11-16 to 2026-11-23. Release is scheduled 2026-11-23 to 2026-11-30. See [NixOS 26.11 release schedule](https://github.com/NixOS/nixpkgs/issues/567567) (accessed: 2026-11-05). | | management-ui rustc (crane) | **1.99.1** from `nixpkgs-rust` `pkgs.rustc` (`nix/rust-toolchain.nix`, lock `9ecc0a672e9ef2ca4eec8e87dbee653607a7d332`) | Host channel nixos-26.05 `pkgs.rustc` is still **1.95.0**. grok-oss uses the same `surmount-rust-1.99.1` toolchain, not host cargo 1.95.0. Not `rustPackages_1_95`. Leptos MSRV is >=1.88 | | Host nixpkgs channel | **nixos-26.05** lock | Engine is **not** from channel; channel lag is separate | +| Lean (builder login) | **4.35.0** official linux x86_64 release (`nix/packages/lean4.nix`, tag `v4.35.0`, published 2026-10-12) | Operator directed Lean 4.35 on 2026-11-04. Not nixpkgs `pkgs.lean4` 4.29.1. Not an elan pin. The live login PATH is unchanged until a switch. `just deploy` was not run for this pin. | | Stock Stalwart modules | dual `disabledModules` | `stalwart-mail.nix` + `stalwart.nix`; Surmount option `services.stalwart` (not stock TOML body) | | PQConnect upstream | **1.2.x** Python (tree saw **1.2.3**) | Sibling path packaging; not Rust | | PQConnect Nix flake | Sibling `feat/nix-flake`, **uncommitted** (human owns commit) | [research/pqconnect-local-packaging.md](research/pqconnect-local-packaging.md) | @@ -206,7 +252,7 @@ Depth: [DATASTORES.md](DATASTORES.md), | Item | Direction | |------|-----------| | Clearnet HTTPS | **Axum-first** edge (management-ui rustls on public **:80/:443**). Stalwart is **not** product clearnet HTTPS (P1). Free engine first-boot :443 via `nix/stalwart/free-public-443-for-axum-edge.ndjson`. nginx delete path. | -| :80 | Redirect/upgrade to :443. **Production (operator 2026-08-02):** port 80 is **free** for product **redirect-only** bind. **ACME HTTP-01 on product :80 parked** (Q-EDGE; free :80 does not invent ACME). Dual-run nginx may still use :80 for ACME while escape is on. | +| :80 | Redirect/upgrade to :443. **Production (operator 2026-08-02):** port 80 is **free** for product **redirect-only** bind. HTTP-01 on surmount-1 is the Let's Encrypt production renew path: the product port 80 listener serves the challenge, and systemd timer `surmount-cert-renew` refreshes the PEM paths that host already presents. horizon does not have to stay online for that renewal. Namecheap API credentials stay off surmount-1. Public module `acme.enable` stays false. The private overlay on horizon turns host renewal on for surmount-1. Dual-run nginx may still use :80 for ACME while escape is on. | | TLS | No SSLv3/1.0/1.1; **prefer TLS 1.3**; hybrid **PQ KEX** on rustls/aws-lc-rs (e.g. X25519MLKEM768) **first-class** (`prefer-post-quantum` + hermetic unit proof of default provider groups; **not** live host hybrid negotiation until `nix run .#surmount-tls-hybrid` after B1) | | Deploy automation | Operator driver `nix run .#surmount-deploy-host` / `just deploy-host` (no default target). 2026-10-05 short names: `just publish` publishes static sites; `just deploy` is the real switch; `just deploy-dry-run` is the agent dry-run. Host-local contract [deploy-host-local.md](deploy-host-local.md); hermetic crate tests in `checks.*.ci` (not a remote switch). Security ladder B0-B7 host-gated. | | PQConnect | **Separate** path-layer PQ between supporting peers; evaluate/plan; not a substitute for TLS hybrid alone | @@ -237,7 +283,7 @@ be reachable via **Arti onion / hidden services**. That reachability is | **Still true** | Own stack; direct clearnet to VPS; no required CF hop | | **Secrets** | HS identity keys and startup material = **deploy secrets** on host (**never in git**). Human inventory (onion address notes, recovery checklist) = **Vaultwarden**. Note: Vaultwarden does **not** implement Bitwarden **Secrets Manager** API today. | | **Relay / egress** | Separate open choices (ToS, bandwidth). Required bar is **service reachability via onion HS**, not "must run a public exit relay." | -| **Code today** | **Management-publish module:** `modules/arti-hidden-service.nix` generates real `arti.toml` (`[onion_services]` + `proxy_ports`; backend derives from managementUi when unset; `state_dir` = host HS identity path). `startDaemon` default false; complete lean path does not need `acceptIncompleteOnionConfig` (no effect). **Surmount package:** `pkgs.artiOnionService` / `packages.*.arti-onion-service` is a **Surmount-owned source build of Arti 2.7.0** (GitLab `arti-v2.7.0`) with `onion-service-service` (distinct from stock `pkgs.arti` 1.4.2 client-default; often channel-lagged); rustc from flake input `nixpkgs-rust` for MSRV 1.91+. Module prefers it when `package` is null and treats `passthru.surmountOnionServiceCapable` as the capable claim. Stock path still fail-closed. **Local cleartext full API shipped** for https+Arti: `SURMOUNT_LOCAL_CLEARTEXT_LISTEN` / auto loopback when UI is https and Arti enable (onion rproxy targets cleartext HTTP or UDS; no TLS-on-onion). **Onion-Location + Alt-Svc shipped (2026-08-17; every public Host 2026-08-20)** on mapped HTTPS 2xx/3xx (apex, www, services, extra static Hosts, MTA-STS; one v3 per public HTTP Host; Onion-Location is that Host's onion root plus path; process-start load; no hot-reload; optional `SURMOUNT_ONION_*` enable/disable/map env; dump on `GET /api/v1/system` `onion_discovery`). **Live host (2026-08-17):** unit `surmount-arti-hidden-service` active; durable HS dir; hostname file present; headers proven on HTTPS 307/200. **unit active != onion published** without live Tor Browser / onion-fetch verify. No private keys in tree. Residual: live Tor verify, operator offline HS backup, public-module HOME/`port_info`, Q-ARTI-* (do not invent answers). Do **not** claim B3 fully closed. Validation SoT: `just e2e` / `just check` / `checks.*.ci`. | +| **Code today** | **Management-publish module:** `modules/arti-hidden-service.nix` generates real `arti.toml` (`[onion_services]` + `proxy_ports`; backend derives from managementUi when unset; `state_dir` = host HS identity path). `startDaemon` default false; complete lean path does not need `acceptIncompleteOnionConfig` (no effect). **Surmount package:** `pkgs.artiOnionService` / `packages.*.arti-onion-service` is a **Surmount-owned source build of Arti 2.8.0** (GitLab `arti-v2.8.0`) with `onion-service-service`. `cargoHash` is set to sha256-grgIqU1gw1UqeGgR4aR7cAOxBW91F12bHMgOj4pgLcY= rather than reuse nixpkgs-rust Arti 2.7.0 `cargoDeps` (distinct from stock `pkgs.arti` 1.4.2 client-default; often channel-lagged); rustc from flake input `nixpkgs-rust` for MSRV 1.91+. Module prefers it when `package` is null and treats `passthru.surmountOnionServiceCapable` as the capable claim. Stock path still fail-closed. **Local cleartext full API shipped** for https+Arti: `SURMOUNT_LOCAL_CLEARTEXT_LISTEN` / auto loopback when UI is https and Arti enable (onion rproxy targets cleartext HTTP or UDS; no TLS-on-onion). **Onion-Location + Alt-Svc shipped (2026-08-17; every public Host 2026-08-20)** on mapped HTTPS 2xx/3xx (apex, www, services, extra static Hosts, MTA-STS; one v3 per public HTTP Host; Onion-Location is that Host's onion root plus path; process-start load; no hot-reload; optional `SURMOUNT_ONION_*` enable/disable/map env; dump on `GET /api/v1/system` `onion_discovery`). **Live host (2026-08-17):** unit `surmount-arti-hidden-service` active; durable HS dir; hostname file present; headers proven on HTTPS 307/200. **unit active != onion published** without live Tor Browser / onion-fetch verify. No private keys in tree. Residual: live Tor verify, operator offline HS backup, public-module HOME/`port_info`, Q-ARTI-* (do not invent answers). Do **not** claim B3 fully closed. Validation SoT: `just e2e` / `just check` / `checks.*.ci`. | Research (update status to required): [research/arti-and-secrets-manager.md](research/arti-and-secrets-manager.md). @@ -410,8 +456,8 @@ Honest gaps. Do not claim these are shipping code: | Gap | Reality today | |-----|----------------| -| **Arti live Tor publish** | Module generates **management-publish** `arti.toml` (onion + rproxy, no keys in tree); Surmount `artiOnionService` is owned **2.7.0** source + `onion-service-service` (not nixpkgs 1.4.2 lag); stock path fail-closed. **Local cleartext full API for https+Arti auto-path shipped** (loopback `SURMOUNT_LOCAL_CLEARTEXT_LISTEN` + Arti backend target; lean onion backend is cleartext HTTP or UDS). **Onion status product-real (2026-08-10):** Arti enable derives hostname file + HS state dir env; structured `configured` / `hostname_missing` / `not_provisioned` on SSR + `GET /api/v1/system`; residual names `surmount.artiHiddenService` (not local demo). Lab `SURMOUNT_ONION_URL` only. **Onion-Location + Alt-Svc shipped (2026-08-17; every public Host 2026-08-20)** on mapped HTTPS 2xx/3xx (apex, www, services, extra static Hosts, MTA-STS; one v3 per public HTTP Host; Onion-Location is that Host's onion root plus path; process-start load; no hot-reload; optional `SURMOUNT_ONION_LOCATION_ENABLED` / `SURMOUNT_ONION_ALT_SVC_ENABLED` / `SURMOUNT_ONION_*_DISABLED_HOSTS` / `SURMOUNT_ONION_MAP_FILE`; dump on `GET /api/v1/system` `onion_discovery`). **Live host (2026-08-17, private host-local):** unit `surmount-arti-hidden-service` active; durable HS dir (not `/run`); hostname file present (v3 onion; do not paste the address in this public tree); headers proven on HTTPS 307/200. Host-local `HOME=/var/lib/surmount/arti`; `surmount-ui` in group `surmount-arti`; keystore 0700. Still residual: **live Tor Browser / onion-fetch verify**, operator offline backup of HS identity, public-module HOME/`port_info`. Do **not** claim onion published from unit active or header presence alone; local temp-key green != operator backup. Do **not** claim B3 fully closed. Do not invent Q-ARTI-2/3 answers | -| **Axum HTTPS public cutover / nginx delete** | In-process rustls TLS 1.3 acceptor wired (host PEMs, fail-closed key mode). **`surmount.web.enable` default false** (nginx not product edge). Dual-run escape: `web.enable = true`. Module/nginx code still in tree. **:80 redirect-only bind wired** when `redirectHttpToHttps` + listen (dual-run mutex at eval; no cleartext API on :80). **Operator 2026-08-02:** production port 80 is **free** for that redirect-only path. **ACME HTTP-01 on product :80 parked** (Q-EDGE; do not invent ACME-on-product-:80). **In-process ACME scaffold (2026-08-10; A1/A2):** `instant-acme` + DNS-01 inside management-ui, **default off**; static PEMs still first-class; hermetic `mock` + **external-hook** DNS-01 challenge adapter (operator-owned executable; not a commercial DNS brand); early-renew default 30 days. **Live host (2026-08-11 free-443; 2026-08-12 mta-sts + policy):** public :443 is **surmount-manage** not Stalwart; `https://services.surmount.systems/health` **200** without `-k`. Cert is **Let's Encrypt production** (issuer YE1; browser-trusted). Intended production leaf is **20** certificate hostnames on one PEM (`with_single_cert`). Live leaf as of **2026-09-07** (CT, YE2) is still **18** names: six extra static zones apex+www (`baxterartworks.com`, `btcfur.com`, `exophiles.org`, `iantuckerstudios.com`, `nostrfurs.com`, `yiffa.app`) plus `surmount.systems`, `www.surmount.systems`, `mail.surmount.systems`, `services.surmount.systems`, `mta-sts.surmount.systems`, plus `mail.cryptoquick.com`. Missing: `cryptoquick.com` and `www.cryptoquick.com`. That hostname mismatch is why cryptoquick HTTPS fails verify. Extra-vhost HTTPS for those six is **live**. Validating A for cryptoquick apex/www succeeds (AD true). Leftover parent DS key tag 2368 is gone. The old wait-for-SERVFAIL gate is closed as a live A-lookup gate. SHA-1 parent DS digest type 1 remains standing DNSSEC quality debt in operator-facts Monday leftover; it is not the HTTPS cause. After the tree lists the two missing names, laptop `--issue` is operator-gated (Namecheap custody). `--live` does **not** detect missing certificate hostnames. Esplora Hosts stay off this leaf. Mail **is** on the production leaf (IMAP/SMTPS 993/465 YE1). Issued by **laptop DNS-01** (Namecheap; ClientIp = laptop egress; SettleSeconds=120). Host ACME stays **off**. Durable PEMs under `/var/lib/surmount/secrets/tls/`. Named renew: `just laptop-renew-cert` (`--directory` required; do **not** re-issue just to add a name already on the leaf). Apex/www public page is packaged **SurmountSystems/site** (live 2026-08-19 tip `1c84696`; not UNDER CONSTRUCTION). Private host-local `mtaStsMode=testing`; public `https://mta-sts./.well-known/mta-sts.txt` **200** `mode: testing` (stay testing while DNS MX is eforward; do not enforce). Reports: `../.agents/reports/impl-live-le-production-laptop.md`, `../.agents/reports/impl-earn-trust-wave.md`. **Post-reboot proof PASS (2026-08-13).** Later 2026-08-18 hard cycle: health/apex **200**, IMAP Let's Encrypt including **mail**. Agents never reboot. Residual: firewall across switch, MemoryDenyWriteExecute host proof (`just e2e-host`). Day-one order: [OPS.md](OPS.md). Do **not** claim live onion or "nginx deleted from repo" without further host proof. Do **not** invent MX / DMARC / VW. Agent language: say **certificate hostnames**, not bare **SAN** (collides with storage SAN). Dual-run unused checklist: [OPS.md](OPS.md); **do not delete `modules/web.nix` without operator OK** | +| **Arti live Tor publish** | Module generates **management-publish** `arti.toml` (onion + rproxy, no keys in tree); Surmount `artiOnionService` is owned **2.8.0** source + `onion-service-service` (`cargoHash` is set to sha256-grgIqU1gw1UqeGgR4aR7cAOxBW91F12bHMgOj4pgLcY=, not nixpkgs 1.4.2 lag); stock path fail-closed. **Local cleartext full API for https+Arti auto-path shipped** (loopback `SURMOUNT_LOCAL_CLEARTEXT_LISTEN` + Arti backend target; lean onion backend is cleartext HTTP or UDS). **Onion status product-real (2026-08-10):** Arti enable derives hostname file + HS state dir env; structured `configured` / `hostname_missing` / `not_provisioned` on SSR + `GET /api/v1/system`; residual names `surmount.artiHiddenService` (not local demo). Lab `SURMOUNT_ONION_URL` only. **Onion-Location + Alt-Svc shipped (2026-08-17; every public Host 2026-08-20)** on mapped HTTPS 2xx/3xx (apex, www, services, extra static Hosts, MTA-STS; one v3 per public HTTP Host; Onion-Location is that Host's onion root plus path; process-start load; no hot-reload; optional `SURMOUNT_ONION_LOCATION_ENABLED` / `SURMOUNT_ONION_ALT_SVC_ENABLED` / `SURMOUNT_ONION_*_DISABLED_HOSTS` / `SURMOUNT_ONION_MAP_FILE`; dump on `GET /api/v1/system` `onion_discovery`). **Live host (2026-08-17, private host-local):** unit `surmount-arti-hidden-service` active; durable HS dir (not `/run`); hostname file present (v3 onion; do not paste the address in this public tree); headers proven on HTTPS 307/200. Host-local `HOME=/var/lib/surmount/arti`; `surmount-ui` in group `surmount-arti`; keystore 0700. Still residual: **live Tor Browser / onion-fetch verify**, operator offline backup of HS identity, public-module HOME/`port_info`. Do **not** claim onion published from unit active or header presence alone; local temp-key green != operator backup. Do **not** claim B3 fully closed. Do not invent Q-ARTI-2/3 answers | +| **Axum HTTPS public cutover / nginx delete** | In-process rustls TLS 1.3 acceptor wired (host PEMs, fail-closed key mode). **`surmount.web.enable` default false** (nginx not product edge). Dual-run escape: `web.enable = true`. Module/nginx code still in tree. **:80 redirect-only bind wired** when `redirectHttpToHttps` + listen (dual-run mutex at eval; no cleartext API on :80). **Operator 2026-08-02:** production port 80 is **free** for that redirect-only path. **HTTP-01 on surmount-1** renews the Let's Encrypt production certificate that host presents (timer `surmount-cert-renew`). horizon does not have to stay online for that renewal. Public module `acme.enable` stays false. **In-process ACME scaffold (2026-08-10; A1/A2):** `instant-acme` + DNS-01 inside management-ui, **default off**; static PEMs still first-class; hermetic `mock` + **external-hook** DNS-01 challenge adapter (operator-owned executable; not a commercial DNS brand); early-renew default 30 days. **Live host (2026-08-11 free-443; 2026-08-12 mta-sts + policy):** public :443 is **surmount-manage** not Stalwart; `https://services.surmount.systems/health` **200** without `-k`. Cert is **Let's Encrypt production** (issuer YE1; browser-trusted). Intended production leaf is **20** certificate hostnames on one PEM (`with_single_cert`). Live leaf as of **2026-09-07** (CT, YE2) is still **18** names: six extra static zones apex+www (`baxterartworks.com`, `btcfur.com`, `exophiles.org`, `iantuckerstudios.com`, `nostrfurs.com`, `yiffa.app`) plus `surmount.systems`, `www.surmount.systems`, `mail.surmount.systems`, `services.surmount.systems`, `mta-sts.surmount.systems`, plus `mail.cryptoquick.com`. Missing: `cryptoquick.com` and `www.cryptoquick.com`. That hostname mismatch is why cryptoquick HTTPS fails verify. Extra-vhost HTTPS for those six is **live**. Validating A for cryptoquick apex/www succeeds (AD true). Leftover parent DS key tag 2368 is gone. The old wait-for-SERVFAIL gate is closed as a live A-lookup gate. SHA-1 parent DS digest type 1 remains standing DNSSEC quality debt in operator-facts Monday leftover; it is not the HTTPS cause. After the tree lists the two missing names, laptop `--issue` is operator-gated (Namecheap custody). `--live` does **not** detect missing certificate hostnames. Esplora Hosts stay off this leaf. Mail **is** on the production leaf (IMAP/SMTPS 993/465 YE1). Issued by **laptop DNS-01** (Namecheap; ClientIp = laptop egress; SettleSeconds=120). Host ACME stays **off**. Durable PEMs under `/var/lib/surmount/secrets/tls/`. Named renew: `just laptop-renew-cert` (`--directory` required; do **not** re-issue just to add a name already on the leaf). Apex/www public page is packaged **SurmountSystems/site** (live 2026-08-19 tip `1c84696`; not UNDER CONSTRUCTION). Private host-local `mtaStsMode=testing`; public `https://mta-sts./.well-known/mta-sts.txt` **200** `mode: testing` (stay testing while DNS MX is eforward; do not enforce). Reports: `../.agents/reports/impl-live-le-production-laptop.md`, `../.agents/reports/impl-earn-trust-wave.md`. **Post-reboot proof PASS (2026-08-13).** Later 2026-08-18 hard cycle: health/apex **200**, IMAP Let's Encrypt including **mail**. Agents never reboot. Residual: firewall across switch, MemoryDenyWriteExecute host proof (`just e2e-host`). Day-one order: [OPS.md](OPS.md). Do **not** claim live onion or "nginx deleted from repo" without further host proof. Do **not** invent MX / DMARC / VW. Agent language: say **certificate hostnames**, not bare **SAN** (collides with storage SAN). Dual-run unused checklist: [OPS.md](OPS.md); **do not delete `modules/web.nix` without operator OK** | | **Merciless ban + whitelist** | First path + helper scaffold: Rust ban decide + memory/file; optional kernel firewall sync via **socket-activated** `surmount-nft-ban-helper@` (Unix socket `SURMOUNT_BAN_NFT_HELPER_SOCK`; CAP_NET_ADMIN on helper unit only) or unsupported direct `nftExec`; UI keeps NNP and **no** CAP_NET_ADMIN (child setcap spawn is not the product path). DryRun does not mutate sets; Enforce apply-then-durable. Hermetic mock/tests under `just e2e`. **`remove_ban` / lab unban shipped** (helper `remove_ban` / CLI `remove-ban`; delete-element; absent element = ok). **Auth-failure BanCandidate + matrix shipped** (`decide_ban_signal` / `BanGuard::signal_unauthorized` + request-context hook; whitelist immune; Off/DryRun/Enforce). Session exchange fail and bad presented NIP-98 may signal once; missing cookie does not; 404/501 still do not auto-ban. Table: [SECURITY.md](SECURITY.md). **Not** live host bans / full Q-ACL-1..6 surface answers. Live drop: `just e2e-host` + sets `surmount-ban4`/`surmount-ban6`. fail2ban sshd still transitional sketch | | **Leptos SSR admin/webmail** | **Multi-page console + UI depth (2026-08-01; directory live 2026-08-07):** SSR `/`, `/domains`, `/accounts`, `/system`, `/mail`, `/login` (DOGE only; no skeleton). Inventory cards, onion chip, system definition table, mail probe card. Accounts honest empty by default; `/domains` config union (primary names including derived www and mta-sts when first-class, `static_vhosts` keys, extra mail hostnames; `source` stays `config`; not Namecheap API; not Stalwart directory). **Directory trait + hermetic mock + live Stalwart client shipped** (`AppState.directory`; `unavailable` default; labeled `mock` never default-on; `stalwart` = management JMAP `x:Account/query`+`get` via Bearer token, explicit `SURMOUNT_DIRECTORY=stalwart` + host token only; list fail-closed empty). **JMAP thin 501 boundary locked (2026-08-07):** `POST /api/v1/jmap` honest 501 (`jmap_proxy_not_implemented`); mail SSR residual copy; no `/webmail` product route; hermetic anchors. Directory research: [research/stalwart-directory-api.md](research/stalwart-directory-api.md). **Account create/update API mutations shipped (2026-08-07)** (`POST/PATCH /api/v1/accounts`; auth gate + CSRF; mock + `x:Account/set` wire-mock). **Create mailbox form shipped (2026-08-14)** on `/mail` (Administrator; optional npub; User vs Administrator; map at `/var/lib/surmount/console/accounts.json` or `SURMOUNT_CONSOLE_ACCOUNTS`). **Attach npub shipped (2026-08-20):** Administrator `/mail` Grant console login pastes bech32 `npub1...`, session-bound CSRF, binds an existing mailbox so that person can log into the services portal (AuthMode nostr). Garbage/nsec refused. Directory listing not required. `/accounts` still has no create form. **Structured request logging shipped.** Still residual: hydrate islands only if clear SSR gap (parked), full Q-AUTH-1, JMAP proxy + webmail **beyond** 501, **MX still parked**, **host cutover**. Living crane rustc **1.99.0** from `nixpkgs-rust` (not host `rustPackages_1_95`); Leptos MSRV floor is >=1.88 | | **Nostr auth end-to-end** | **Foundation shipped (2026-08-01; polish 2026-08-02):** rust-nostr NIP-98 + HMAC session cookie scaffold; `SURMOUNT_AUTH_MODE` off/nostr; env allowlist + optional allowlist file (env wins; empty fail-closed); challenge/session/me/logout + login page. **Not JS NDK.** UI honesty (auth-mode banners, `/login` when nostr). Hermetic e2e anchors list off/gate/NIP-98 session + surface audit + ban matrix (and onion unset residual). CSRF on account mutations + structured request logging shipped (2026-08-07). **Account mutation e2e hermetic anchors shipped (2026-08-07):** auth-off fail-closed, cookie CSRF (incl. PATCH), lab escape mock, unavailable 503. **Live B4 (2026-08-12):** public services console is Nostr-gated (anon `/` login, `/api/v1/domains` 401, `/health` 200); apex/www packaged **SurmountSystems/site** (2026-08-19 tip `1c84696`). Report: `../.agents/reports/impl-auth-live-b4-switch.md`. Q-AUTH-1 residual: key-loss, durable session store, first-operator bootstrap UX (do not invent). nsec never on server. | @@ -430,8 +476,9 @@ Tree that **does** exist: flake, Surmount Stalwart FODs + service module, transitional nginx web module (default off; dual-run escape), management-ui Axum edge (rustls HTTPS acceptor + TLS path config, rate limit, ban decide layer default off, **local cleartext full API** for https+Arti, redirect -helpers + optional redirect-only :80 bind; ACME-on-:80 parked; in-process -ACME DNS-01 scaffold default-off (`instant-acme`, external-hook + early-renew); +helpers + optional redirect-only :80 bind; HTTP-01 renew on surmount-1 +(public `acme.enable` stays false; horizon does not have to stay online); +in-process ACME DNS-01 scaffold default-off (`instant-acme`, external-hook + early-renew); **Leptos SSR multi-page management console** (`/`, `/domains`, `/accounts`, `/system`, `/mail`); **nft ban helper scaffold** UDS + socket-activated oneshot with diff --git a/docs/DATASTORES.md b/docs/DATASTORES.md index 00462aa..1596bb2 100644 --- a/docs/DATASTORES.md +++ b/docs/DATASTORES.md @@ -20,7 +20,7 @@ leftover RSA files on a live host are not deleted by hostKeys). Prior > Historical 0.11.8 only: > [research/stalwart-stores-evidence-2026-07-30.md](research/stalwart-stores-evidence-2026-07-30.md). -> **Engine version:** Surmount pins Stalwart **0.16.15** via release-binary FODs +> **Engine version:** Surmount pins Stalwart **1.0.2** via release-binary FODs > (`nix/packages/stalwart-mail.nix`, `modules/stalwart-service.nix`). Host > channel is **nixos-26.05**; engine is not the channel package. Early scaffold > briefly used channel **0.11.8** on host **25.05** (historical only). @@ -426,7 +426,7 @@ Current engine store evidence: **Hermetic FODs (not auto-applied as engine config):** -- Spam-filter **3.0.0** and WebUI zip are installed under +- Spam-filter **3.0.4** (`spam-filter-rules.json.gz` only) and WebUI zip are installed under `/etc/surmount/stalwart/` via `environment.etc` in `modules/mail.nix`. - They are **not** the message DB and are **not** wired through TOML `spam-filter.resource` settings. Operator points SpamSettings / resource URL @@ -452,9 +452,10 @@ section 2. ### 5.4 Spam-filter FOD is not the message store -`nix/packages/stalwart-spam-filter.nix` pins spam-filter **3.0.0** -(`spam-filter.toml` + `spam-filter-rules.json.gz` FODs). `modules/mail.nix` -installs them under `/etc/surmount/stalwart/` for operators. That material is +`nix/packages/stalwart-spam-filter.nix` pins spam-filter **3.0.4** +(`spam-filter-rules.json.gz` only; v3.0.3 dropped `spam-filter.toml`). +`modules/mail.nix` installs that archive under `/etc/surmount/stalwart/` +for operators. That material is **anti-spam configuration**, completely separate from RocksDB message data. Do not back it up as if it were mail; it is in the Nix store via the system closure. Engine consumption is Day-2 apply/WebUI, not automatic first-boot @@ -554,7 +555,7 @@ wired in modules yet. | Stalwart data+blob+FTS+lookup | RocksDB (provisional) | `/var/lib/stalwart-mail/db` | WAL + process; stop-service backup preferred | restic `mailDataDir`; test restore | Mail passwords inside engine DB | Stalwart | | Stalwart internal directory | Same RocksDB | inside `db` | Same | Same | Credential hashes | Stalwart | | Stalwart webadmin cache | Files | `/var/cache/stalwart-mail` | Ephemeral cache | Optional / skip | No | Stalwart package | -| Spam-filter rules | TOML + rules FODs in Nix store | `/etc/surmount/stalwart/` (3.0.0); apply/WebUI to engine | Immutable in closure | Via nix / flake | No | `mail.nix` + spam-filter package | +| Spam-filter rules | rules JSON FOD in Nix store | `/etc/surmount/stalwart/spam-filter-rules.json.gz` (3.0.4); apply/WebUI to engine | Immutable in closure | Via nix / flake | No | `mail.nix` + spam-filter package | | MailPlus import staging | Maildir files | `/var/lib/surmount/import/...` | Files | Optional until import done | May contain mail | Operator | | Surmount state root | dir | `/var/lib/surmount` | Files | restic `stateDir` | Future sessions etc. | Surmount | | Management UI durable state | Optional ban/whitelist-last-used JSON; future sessions | default `stateDir/ui/ban-state.json` when accessControl.enable; no secrets | Files | With `stateDir` | Session secrets via sops (future); ban state is IPs only | Surmount management-ui | @@ -650,7 +651,7 @@ A backup never restored is theater ([SECURITY.md](SECURITY.md)). |----------|------| | MailPlus Maildir export | **Import source only** | | MBOX/PST conversions | Import source | -| spam-filter.toml FOD | Config in Nix store | +| spam-filter-rules.json.gz FOD | Config in Nix store | | git history | Not a mail backup | | FTS alone | Not a restore path for bodies | diff --git a/docs/DNS.md b/docs/DNS.md index d55dec5..9d9d4ba 100644 --- a/docs/DNS.md +++ b/docs/DNS.md @@ -7,7 +7,14 @@ real user mail. Validate send path with [mail-tester.com](https://www.mail-tester.com/) and your provider's blocklist checks. -**Last updated:** 2026-10-10. Operator direction: Google may send as +**Last updated:** 2026-10-24. Host `status.surmount.systems` is a +mail-host vhost A on the same records as apex / `mail` / `services` +(no CNAME, no AAAA while the other names have none). It is the public +systems dashboard on the Axum HTTPS edge, not the operator console. +Laptop DNS-01 intended leaf includes that name. See +[EDGE_AND_TLS.md](EDGE_AND_TLS.md). + +**Prior update:** 2026-10-10. Operator direction: Google may send as `surmount.systems`. Live SPF is `v=spf1 a:mail.surmount.systems include:_spf.google.com -all`. Public resolvers (including `1.1.1.1`) return that TXT. Our host @@ -415,6 +422,7 @@ send. Full ordered boxes: [Earn-trust checklist](#earn-trust-checklist-order-of- - [ ] **A** (and **AAAA** if you have v6) for `services.` -> VPS - [ ] **A/AAAA** for apex and/or `www` if you serve them from this VPS - [ ] **A/AAAA** for `mail.` -> same VPS (when mail or mail cert needs it) +- [ ] **A** for `status.` -> same records as apex / mail / services when the systems dashboard Host is served from this VPS - [ ] **MX** on apex -> `mail.` priority 10 (when accepting mail) - [ ] No orange-cloud / required CDN proxy on MX or submission names - [ ] Records match what host-local / sample config expects (direct to VPS) @@ -575,6 +583,7 @@ notes as you go. | **A / AAAA** | `mail.surmount.systems` | Mail host address(es) | Where MX points | | **A / AAAA** | `surmount.systems` | Apex (web / redirect) | Web + sometimes SPF `a` | | **A / AAAA** | `services.surmount.systems` | Management UI | Admin HTTPS | +| **A / AAAA** | `status.surmount.systems` | Public systems dashboard (Axum Host, not the operator console) | Same A as apex / mail / services; no AAAA while those names have none | | **A / AAAA** | `www.surmount.systems` | Optional www | Web | | **MX** | `surmount.systems` | Prefer `mail.surmount.systems` priority 10 | Inbound routing | | **PTR / rDNS** | Provider reverse zone for the VPS IP | Must match `mail.surmount.systems` | IP reputation | @@ -664,6 +673,8 @@ mail IN A 203.0.113.10 mail IN AAAA 2001:db8::10 services IN A 203.0.113.10 services IN AAAA 2001:db8::10 +status IN A 203.0.113.10 +; status is the public systems dashboard Host. Same A as apex/mail/services. ; --- SPF (adjust includes if you use a relay) --- @ IN TXT "v=spf1 mx a:mail.surmount.systems ip4:203.0.113.10 ip6:2001:db8::10 -all" @@ -721,25 +732,15 @@ Do **not** `enforce` until the operator asks. Dual-pin: [EDGE_AND_TLS.md](EDGE_A **Certificate hostnames (not a storage area network):** intended production leaf is **one** Let's Encrypt PEM pair (`with_single_cert`) -covering **20** names. Live leaf as of 2026-09-07 (LE production YE2, -laptop DNS-01, host ACME off, public CT) is still **18** names: -`baxterartworks.com`, `www.baxterartworks.com`, `btcfur.com`, -`www.btcfur.com`, `exophiles.org`, `www.exophiles.org`, -`iantuckerstudios.com`, `www.iantuckerstudios.com`, `nostrfurs.com`, -`www.nostrfurs.com`, `yiffa.app`, `www.yiffa.app`, `surmount.systems`, -`www.surmount.systems`, `mail.surmount.systems`, -`services.surmount.systems`, `mta-sts.surmount.systems`, -`mail.cryptoquick.com`. -The two names still missing from the live leaf are `cryptoquick.com` and -`www.cryptoquick.com`. That hostname mismatch is why cryptoquick HTTPS -fails verify. Validating A for those names succeeds (AD true). Leftover -parent DS key tag 2368 is gone. `mail.surmount.systems` and -`mail.cryptoquick.com` **are** on the live leaf (IMAP/SMTPS). Six extra -static zones are **live** HTTPS 200. After the tree lists the two missing -names, laptop `--issue` is operator-gated (Namecheap custody). `--live` -does **not** detect missing certificate hostnames. Do **not** -re-issue just to add a name already on that leaf. Esplora Hosts stay off -this leaf. Renew path: private host-profile `acme_domains` + +covering the private host-profile list plus the laptop floor +(`status.surmount.systems` is on that floor). Live leaf as of +2026-10-24 after laptop `--issue` (LE production YE1, DNS-01, host ACME +off) includes `status.surmount.systems`, cryptoquick apex/www, +`splora.surmount.systems`, the six extra static zones, and the +mail-host names. `--live` does **not** detect missing certificate +hostnames. Do **not** re-issue just to add a name already on that leaf. +Esplora Hosts stay off this leaf except the one Splora Host already on +it. Renew path: private host-profile `acme_domains` + `just laptop-renew-cert -- --issue --directory production` (Namecheap ClientIp = laptop egress). Extra-zone names need `--hook` from `nix run .#acme-dns-hook-namecheap-dispatch`. Mail-plane apply: diff --git a/docs/EDGE_AND_TLS.md b/docs/EDGE_AND_TLS.md index 0080a0d..e4c87a0 100644 --- a/docs/EDGE_AND_TLS.md +++ b/docs/EDGE_AND_TLS.md @@ -4,7 +4,16 @@ How HTTPS reaches the management UI and (optionally) Stalwart HTTP. Mail protocol ports are **not** edge-proxied; they terminate on Stalwart. See [STACK.md](STACK.md) for the full path map. -**Last updated:** 2026-09-22 (one public Host `splora.surmount.systems`. +**Last updated:** 2026-10-28 (Up on this page means that service's socket accepted a connection.) +Prior 2026-10-24 (Host `status.surmount.systems` is the public +systems dashboard on the product Axum HTTPS edge, not nginx and not the +operator console. Names and public hosts are https links. Status text +comes from systemd ActiveState / HTTP samples in ordinary English. +Host uptime comes from `/proc/uptime`. The inventory names mail, +services, this dashboard, Arti, SSH, and Splora testnet units that +exist; it does not invent Vaultwarden, Lake, or Splora mainnet. +Intended certificate hostname list includes `status.surmount.systems`.) +Prior 2026-09-22 (one public Host `splora.surmount.systems`. Network paths on that Host, not per-network esplora Hosts. The network prefix is stripped before the Unix socket. The query string is kept. `/{network}/api/v1/ws` (mainnet: `/api/v1/ws`) is the WebSocket upgrade. @@ -31,9 +40,12 @@ leftover Namecheap clicks. Do not MX-flip Baxter.) Prior 2026-09-03 (HTTP/3 NIP- hostnames: six extra static zones apex+www plus surmount apex, www, mail, services, mta-sts, plus **mail.cryptoquick.com** for IMAP/SMTP. Extra-vhost HTTPS live for those six. `cryptoquick.com` apex/www stayed -off that certificate that day. Prior 2026-08-18: laptop Let's Encrypt renew is +off that certificate that day. Prior 2026-08-18: laptop Let's Encrypt renew was `just laptop-renew-cert -- --check|--live --directory production`. -`--live` issues only when due. Host ACME stays off. Stalwart 0.16.15 +`--live` issued only when due. That horizon command is not required for +the certificate to keep renewing. surmount-1 renews the Let's Encrypt +production certificate it presents, using HTTP-01 on surmount-1. +horizon does not have to stay online for that renewal. Stalwart 0.16.15 query resolves certificate hostnames plus id.) **Operator direction:** [operator-direction.md](operator-direction.md) @@ -56,7 +68,7 @@ query resolves certificate hostnames plus id.) | Reverse proxy / routing | To Axum/Leptos UI; optional Stalwart HTTP path | | Local IPC | Prefer **Unix domain sockets** to backends, not TCP localhost | | Certificates | Automated public CA is common today; **not** locked to ACME-only (TLS research) | -| HTTP :80 | Automatically and gracefully upgrade/redirect to :443. Production: **port 80 free** for product redirect-only bind (operator 2026-08-02). ACME HTTP-01 on product :80 **parked** (Q-EDGE); dual-run nginx may still use :80 for ACME | +| HTTP :80 | Automatically and gracefully upgrade/redirect to :443. Production: **port 80 free** for product redirect-only bind (operator 2026-08-02). When host renewal is on, HTTP-01 on surmount-1 serves `/.well-known/acme-challenge/` on that listener and refreshes the Let's Encrypt production certificate surmount-1 already presents. horizon does not have to stay online for that renewal. Dual-run nginx may still use :80 for ACME while escape is on. | | TLS versions | No SSLv3, no TLS 1.0/1.1; **prefer TLS 1.3** (1.2 only if measured client need) | | PQ where supported | Enable hybrid PQ KEX (e.g. X25519MLKEM768) when rustls/aws-lc-rs path allows; see PQC research | | Rate limiting | Request flood protection at edge and/or app (tower-governor or equivalent) | @@ -171,7 +183,7 @@ docs or module-eval alone. - `services.nginx` + `security.acme` only when `surmount.web.enable = true` - Vhosts for `services.surmount.systems`, `mail.surmount.systems`, apex, www -- Proxy `/` -> management UI; `/stalwart-admin/` -> Stalwart HTTP (bootstrap) +- Proxy `/` -> management UI. No public `/stalwart-admin/` path (removed; SSH tunnel to loopback Stalwart HTTP is the bootstrap). - Recommended TLS/proxy/gzip settings enabled - `clientMaxBodySize` default 25m (mail bodies go through Stalwart, not the edge) - Backends today are **TCP loopback** (scaffold). Target local hops are **UDS**. @@ -257,9 +269,11 @@ nginx features. Module file remains until operators no longer need dual-run. - HTTP->HTTPS redirect helpers + host allowlist; optional **redirect-only** plain HTTP listener when `redirectHttpToHttps` + `httpRedirectListen` (default `0.0.0.0:80`). No cleartext API on that port. Eval mutex vs - `web.enable` (nginx dual-run owns :80 ACME/redirect). **ACME HTTP-01 on - product :80 is parked** (Q-EDGE; free :80 is redirect-only, not an invent of - ACME-on-product-:80). See RESIDUAL.md. + `web.enable` (nginx dual-run owns :80 ACME/redirect while that escape is on). + When host renewal is on, HTTP-01 on surmount-1 serves the challenge on + that product port 80 listener and refreshes the Let's Encrypt production + certificate surmount-1 already presents. horizon does not have to stay + online for that renewal. Public module `acme.enable` stays false. - **Apex / www public main site vs services console (2026-08-12; document root 2026-08-14; SurmountSystems/site 2026-08-18):** allowlisted Host `primaryDomain` or `www.` is the **public** main site. @@ -286,8 +300,28 @@ nginx features. Module file remains until operators no longer need dual-run. nonce CSP. Open-redirect safe: empty allowlist or non-listed Host never builds a Location from untrusted input. Default allowlist: services, apex, www, - mail, mta-sts., plus any extra static Hosts from + mail, mta-sts., status., plus any extra static Hosts from `SURMOUNT_STATIC_VHOSTS` / `SURMOUNT_STATIC_VHOSTS_FILE`. +- **Systems dashboard Host (2026-10-24):** `status.{primaryDomain}` + (production: `status.surmount.systems`) is a public systems status + surface on this same Axum process. GET `/` is HTML; GET `/status.json` + is JSON. Named fields: `surface`, `service`, `version`, `host`, + `primary_domain`, `uptime`, and `systems` rows including `mail`, + `edge`, `tls`, `mta_sts`, `console`, `status`, `site`, `arti`, `sshd`, + and Splora testnet names (`splora-testnet3`, `splora-testnet4`, + `splora-mutinynet`, `splora-liquid`). Names and public hosts are + clickable `https://` hostname links (mail, services, apex, status, + `splora.{primary}` testnet paths, MTA-STS policy). Status text is + ordinary English from systemd ActiveState / SubState and optional HTTP + samples (running, failed, inactive, listening, HTTP 502), not a + constant ok and not RAM/disk/IP. Up on this page means that service's socket accepted a connection. + A Splora indexer whose unit is active and whose proxy socket is absent is inactive, not running. + Host uptime is `/proc/uptime` formatted + as seconds, minutes (at most 60 minutes), N.N hours (through 24 hours), + or N.N days. Not the operator console. `/health` and `/api/health` + stay edge probes. Other `/api/*` is 404. Laptop DNS-01 intended leaf + includes `status.surmount.systems`. Live HTTPS after switch is + operator-owned. Do not put nginx back as product edge. - **Extra static Hosts (DS3018xs trees, 2026-08-19):** proven public HTML sites are served from the same Axum process via a Host -> document root map (`SURMOUNT_STATIC_VHOSTS` JSON, or Nix @@ -297,7 +331,8 @@ nginx features. Module file remains until operators no longer need dual-run. never the operator console. Do **not** overload `SURMOUNT_APEX_PUBLIC_ROOT`. `services.surmount.systems` stays the operator console. Extra Hosts each get a distinct v3 onion (2026-09-11), not a shared onion. - Onion-Location for extra Hosts, apex/www, and `mta-sts.{primary}` is + Onion-Location for extra Hosts, apex/www, `mta-sts.{primary}`, and + `status.{primary}` is `http://{that-host-onion}{path}` (empty path becomes `/`). The services onion root stays the console. Mail stays unmapped. Do not put nginx back as product edge. @@ -311,23 +346,28 @@ when those indexer units run. One public Host: `splora.surmount.systems`. The sample proxy map does not list `esplora.surmount.systems`, `testnet3.esplora.surmount.systems`, `testnet4.esplora.surmount.systems`, `mutinynet.esplora.surmount.systems`, -or `liquid.esplora.surmount.systems`. The Rust explorer UI belongs to -Splora, not this repo. Do not add block, transaction, or address pages. - -The network prefix is stripped before the Unix socket. The query string -is kept. `/{network}/api/v1/ws` (mainnet: `/api/v1/ws`) is the WebSocket +or `liquid.esplora.surmount.systems`. The Leptos explorer route table belongs to Splora +`frontend/web/src/views.rs` (`App`). This Axum process serves that table +as SSR HTML on `/testnet/` (testnet3), `/testnet4/`, `/signet/`, +`/mutinynet/`, and `/liquid/`. It does not serve a mainnet frontend on +`/api/`. The Splora crate mounts `App` with `leptos::mount::mount_to_body` +on wasm32 only. This server does not start a second HTTP server, and it +does not build that wasm crate. + +The network prefix is stripped before the Unix socket for indexer REST. +The query string is kept. `/{network}/api/v1/ws` (mainnet: `/api/v1/ws`) is the WebSocket upgrade. `POST /splora/queue` stays the queue and is not a network prefix. `GET /` on that host is a short same-host path list (`/api/`, `/testnet/`, `/testnet4/`, `/signet/`, `/mutinynet/`, `/liquid/`), not an explorer. | Public path on `splora.surmount.systems` | Socket | Notes | |------------------------------------------|--------|-------| -| `/api` and `/api/...` | mainnet | 404 while the mainnet indexer is off. | -| `/testnet` and `/testnet/...` | testnet3 | Public path is `/testnet`, not `/testnet3`. | -| `/testnet4` and `/testnet4/...` | testnet4 | | -| `/signet` and `/signet/...` | mutinynet | No redirect between `/signet` and `/mutinynet`. | -| `/mutinynet` and `/mutinynet/...` | the same mutinynet socket | No redirect between them. | -| `/liquid` and `/liquid/...` | liquid | | +| `/api` and `/api/...` | mainnet | 404 while the mainnet indexer is off. Not a Leptos frontend. | +| `/testnet` and `/testnet/...` | testnet3 | REST `/api` uses the socket. Screen routes are Leptos HTML. Not `/testnet3`. | +| `/testnet4` and `/testnet4/...` | testnet4 | REST `/api` uses the socket. Screen routes are Leptos HTML. | +| `/signet` and `/signet/...` | mutinynet | Same mutinynet frontend as `/mutinynet`. No redirect. | +| `/mutinynet` and `/mutinynet/...` | the same mutinynet socket | REST `/api` uses the socket. Screen routes are Leptos HTML. No redirect. | +| `/liquid` and `/liquid/...` | liquid | REST `/api` uses the socket. Screen routes are Leptos HTML. | Mempool / Esplora REST is the indexer process on a Unix socket. It does **not** require extra public Hosts, certificate names, or HTTP/3. Unix @@ -344,13 +384,17 @@ to each Splora Unix socket is still HTTP/1.1. A client that arrived on HTTP/3 does not make the indexer speak HTTP/3. Splora does not listen on UDP. -Under a network prefix, a remainder that is not the REST API is forwarded -to that network Unix socket. This edge does not answer it with 404 and -does not render it. A REST remainder still drops a leading `/api`, except +Under a network prefix, a remainder of `/api` or `/api/...` is still +forwarded to that network Unix socket. A remainder that matches the +Splora Leptos `App` router is HTML from this Axum process and is not +forwarded. Any other remainder is 404. `/docs/api/...` is a screen, not +indexer REST. A REST remainder still drops a leading `/api`, except exact `/api/v1/ws`, which stays and is the WebSocket upgrade. `/api` on -mainnet is 404 only while the mainnet indexer is off. Public -`/testnet3/...` is not a prefix. The Electrum newline socket is not -proxied (fail-closed if configured). +mainnet is 404 only while the mainnet indexer is off, and it is not a +Leptos frontend. Public `/testnet3/...` is not a prefix. `/signet` and +`/mutinynet` both serve the mutinynet frontend. There is no redirect +between them. The Electrum newline socket is not proxied (fail-closed +if configured). **Live in browsers (2026-08-20):** Namecheap NS, exclusive A matching this host, HTTPS **200**, the Let's Encrypt certificate this host presents covers apex+www, packaged @@ -409,7 +453,7 @@ proxied (fail-closed if configured). whitelist). Then add FQDNs to private `acme_domains` and `just laptop-renew-cert -- --issue --directory production --host-profile ~/.local/share/surmount/host-profile.toml --install --restart-ui --target USER@HOST`. `--live` does **not** detect missing certificate hostnames (expiry - only). Do not start host in-process ACME. + only). That horizon issue command is not what keeps the certificate renewing. Extra-zone DNS-01 (cryptoquick.com, yiffa.app, and other Namecheap zones besides surmount.systems) cannot use the one-SLD Namecheap hook @@ -423,7 +467,9 @@ proxied (fail-closed if configured). `--issue --directory production` (Let's Encrypt production). Do **not** put unowned, archived, or still-Cloudflare zones on `--domains` until their nameservers are Namecheap. Extra certificate hostnames belong - on a leaf only after that NS cut. Host in-process ACME stays off. + on a leaf only after that NS cut. surmount-1 renews with HTTP-01. + horizon does not have to stay online for that renewal. Namecheap API + credentials stay off surmount-1. - **MTA-STS policy path:** `GET /.well-known/mta-sts.txt` when `mtaStsMode` is `testing` or `enforce` (default **off**). Served only for Host `mta-sts.` on the allowlist (HTTP/2 uses Host or URI @@ -437,7 +483,11 @@ proxied (fail-closed if configured). optional DNS-01 path inside management-ui via **`instant-acme` 0.8** (rustls 0.23 / aws-lc-rs). **Default off** (`surmount.managementUi.acme.enable = false` / no `SURMOUNT_ACME_ENABLE`). Static host PEM load remains first-class - (not ACME-only forever). + (not ACME-only forever). Public module default stays false. On surmount-1, + the private overlay on horizon turns host renewal on. HTTP-01 refreshes + the Let's Encrypt production PEMs that host presents. horizon does not + have to stay online for that renewal. Namecheap API credentials stay off + surmount-1. - **Reuse / early renew:** reuse PEMs when leaf is not past notAfter, not not-yet-valid, certificate hostnames cover configured domains, **and** remaining lifetime is at least `SURMOUNT_ACME_RENEW_DAYS_BEFORE_EXPIRY` days (scaffold default @@ -468,8 +518,11 @@ proxied (fail-closed if configured). Hot-reload residual: restart after renew. CI never requires live Let's Encrypt. Env: `SURMOUNT_ACME_*`. Ops sketch: [OPS.md](OPS.md). - **Production assumption (operator 2026-08-02):** TCP **port 80 is free** on - the NixOS box so the product redirect-only listener can bind. That free - :80 is for **redirect/upgrade only**, not an invent of ACME-on-product-:80. + the NixOS box so the product redirect-only listener can bind. When host + renewal is on, that same listener on surmount-1 also serves HTTP-01 + `/.well-known/acme-challenge/` and the timer refreshes the Let's Encrypt + production PEMs that host presents. horizon does not have to stay online + for that renewal. Public module `acme.enable` stays false. Day-one ops: [OPS.md](OPS.md). - Fixed-window rate limit; when peer is loopback, trust **X-Real-IP only** (useful behind dual-run nginx). X-Forwarded-For is ignored for rate-limit @@ -712,7 +765,7 @@ Stalwart HTTP without evidence. | **Cons** | We own multi-vhost, renewals, edge cases, security updates of *our* edge binary; more code than a packaged proxy | | **NixOS** | Package with crane like management-ui; simple systemd unit on :80/:443 | | **UDS** | Natural (hyper/axum backend client to UDS) | -| **Certs** | Static host PEMs always supported. **In-process** path uses **instant-acme** (DNS-01, default off). Dual-run may still use host `security.acme`. **Not locked** ACME-only or to a single CA. | +| **Certs** | Static host PEMs always supported. **In-process** path uses **instant-acme**. Public `acme.enable` stays false. On surmount-1, HTTP-01 renews the Let's Encrypt production certificate that host presents. horizon does not have to stay online for that renewal. DNS-01 remains the external-hook path and stays off that host. Dual-run may still use host `security.acme`. **Not locked** ACME-only or to a single CA. | | **Verdict** | **Default path** per operator follow-up 2026-07-30 | ### Rama / Pingora / other Rust proxy frameworks @@ -741,7 +794,7 @@ Stalwart HTTP without evidence. |---------|--------| | **Now (tree default)** | **Axum-first** management-ui rustls (`listenMode=https` + host PEMs); `surmount.web.enable` default **false** | | **Dual-run escape** | `surmount.web.enable = true` + UI `listenMode=http` loopback; nginx + `security.acme` (**transitional-to-delete**) | -| **Production :80** | **Free** for product **redirect-only** bind (operator 2026-08-02). Not ACME-on-product-:80 (parked; Q-EDGE) | +| **Production :80** | **Free** for product redirect-only bind (operator 2026-08-02). HTTP-01 on surmount-1 uses that listener for Let's Encrypt production renewal. horizon does not have to stay online for that renewal. | | **Host residual** | Public :443 + MemoryDenyWriteExecute (no writable+executable memory) + cert path (Q-EDGE-1 / Q-CA-*); see RESIDUAL.md host tracks. Not claimed done from eval or local e2e alone | | **Local backends** | Move UI (and proxied Stalwart HTTP when possible) toward **Unix domain sockets** | | **Not target** | Caddy/Sozu/nginx as product identity | @@ -767,8 +820,8 @@ proof, not flipping the module default again. Module asserts against dual-run + public UI https (:443 or non-loopback). 6. When dual-run is unused: remove nginx module path; update STACK, OPS, hygiene, architecture-review. -7. Lock down or remove `/stalwart-admin/` public path once Rust admin covers - bootstrap needs. +7. The public `/stalwart-admin/` path was removed from `modules/web.nix`. + Bootstrap is an SSH tunnel to the loopback Stalwart HTTP listener. ### Host end-to-end recipes (operator) @@ -796,7 +849,7 @@ nix run .#e2e-host | Track | What host mode checks | Notes | |-------|----------------------|--------| -| **1. HTTPS + hardening** | **`BASE_URL` required** (FAIL if unset); health 200; UI unit active; `MemoryDenyWriteExecute=yes` (no writable+executable memory); TLS check (handshake; **expired cert fails**); nginx inactive; UI ambient **and** bounding set have no CAP_NET_ADMIN | Operator PEMs on host; key not group/world readable; external PEMs lean until Q-EDGE/Q-CA answered; ACME-on-product-:80 parked | +| **1. HTTPS + hardening** | **`BASE_URL` required** (FAIL if unset); health 200; UI unit active; `MemoryDenyWriteExecute=yes` (no writable+executable memory); TLS check (handshake; **expired cert fails**); nginx inactive; UI ambient **and** bounding set have no CAP_NET_ADMIN | Operator PEMs on host; key not group/world readable; external PEMs lean until Q-EDGE/Q-CA answered; HTTP-01 renew on surmount-1; horizon does not have to stay online | | **2. Arti** | `surmount-arti-hidden-service` (or override) active; `User=surmount-arti`; optional onion fetch via `SURMOUNT_E2E_ONION` + `TOR_SOCKS` | **Unit active != published.** HS keys under `onionServiceStateDir`, never in git. Cleartext local backend when UI is https-only. Do not invent Q-ARTI-2/3 | | **3. Ban / kernel firewall** | Helper socket + helper socket unit; set **preflight** (exists only, not drop); **`LAB_IP` required** and must appear in `surmount-ban4` (FAIL if unset/absent) unless `SKIP_BAN=1` | CAP_NET_ADMIN on **helper only**. Summary `ban_drop=UNPROVEN` (membership != traffic drop). Prove drop + cleanup out-of-band. Q-ACL-1..6 parked | @@ -835,7 +888,7 @@ Ids: **Q-CA-1**, **Q-CA-2** in [research/pqconnect-and-pqc.md](research/pqconnec | Rule | Lean | |------|------| -| :80 -> :443 | Graceful redirect/upgrade on free production :80 (redirect-only product path). ACME HTTP-01 may share :80 only on dual-run nginx or a future Q-EDGE answer; **not** invent ACME-on-product-:80 | +| :80 -> :443 | Graceful redirect/upgrade on free production :80 (redirect-only product path). HTTP-01 on surmount-1 also serves the challenge on that listener and renews the Let's Encrypt production certificate. horizon does not have to stay online for that renewal. | | Insecure protocols | Disabled: SSLv3, TLS 1.0, TLS 1.1 | | Prefer | TLS 1.3 | | PQ KEX on Axum/rustls | **First-class (D1):** **aws-lc-rs** + `prefer-post-quantum` (X25519MLKEM768 first in default provider). Hermetic unit tests pin group config. **Live host hybrid negotiation is residual** until measured post-B1 (do not claim from local e2e alone). Probe: `nix run .#surmount-tls-hybrid` / `just check-tls-hybrid` (requires `SURMOUNT_E2E_BASE_URL`; exit 2 BLOCKED if unset) | diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 451f459..5e15ced 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -152,14 +152,14 @@ the host allowlist. Q-AUTH-1 is unchanged. ## Import with Vandelay (Stalwart 0.16) -Live `stalwart-cli` **1.0.12** is schema-driven JMAP admin only (`get`, +Live `stalwart-cli` **1.0.14** is schema-driven JMAP admin only (`get`, `query`, `create`, `update`, `delete`, `describe`, `apply`, `snapshot`). It has **no** `import` subcommand. Do not run `stalwart-cli import messages`. The engine binary `stalwart -i` imports a **Stalwart store dump**, not Maildir. -Official 0.16 Maildir++ path is **Vandelay** (pin `nix/packages/vandelay.nix`, -currently 1.0.7): local tree -> SQLite archive -> JMAP export. +Official Maildir++ path is **Vandelay** (pin `nix/packages/vandelay.nix`, +currently 1.0.14): local tree -> SQLite archive -> JMAP export. See [Vandelay](https://github.com/stalwartlabs/vandelay) (accessed: 2026-08-13) and [Vandelay: the JMAP importer-exporter](https://stalw.art/blog/jmap-account-migration/) diff --git a/docs/OPS.md b/docs/OPS.md index 673a447..2bb20bf 100644 --- a/docs/OPS.md +++ b/docs/OPS.md @@ -4,7 +4,8 @@ How the Surmount mail VPS should be operated, observed, and checked end-to-end. Aligned with [hygiene.md](hygiene.md). Architecture: [STACK.md](STACK.md). -**Last updated:** 2026-10-10 (primary SPF is +**Last updated:** 2026-10-23 (grok-oss and tau are not mail-host +packages; do not enable them from host-local). Prior 2026-10-10 (primary SPF is `v=spf1 a:mail.surmount.systems include:_spf.google.com -all`. DMARC stays `p=quarantine`. Detail: [DNS.md](DNS.md) section *Google send (operator direction 2026-10-10)*). @@ -752,10 +753,9 @@ usable SSH key **files** (lockout risk if password auth is off), syncs the **public** tree (refuses copying host-local into tracked `hosts/` / `secrets/`), rsyncs private host-local to `REMOTE/host-local/`, optionally runs the secrets install bridge when `--install-secrets` is set, runs -`nixos-rebuild switch --flake path:REMOTE_DIR#mail-vps` on the host (or prints -that plan in `--dry-run`), then **always** runs post-switch smoke on the remote +`nixos-rebuild switch --flake path:REMOTE_DIR#mail-vps` on a real deploy, then **always** runs post-switch smoke on the remote (`nix run .#surmount-deploy-host-post-switch-smoke`) even if rebuild exit was non-zero -(activation may still have applied). Smoke checks generation current, unit +(activation may still have applied). `--dry-run` rsyncs, bootstraps, and runs `nixos-rebuild build` instead of switch. It does not run post-switch smoke and it does not install secrets. Smoke checks generation current, unit activity (`sshd`, `stalwart-mail`, `surmount-management-ui`; starts UI if inactive), loopback `/health` (never invents a public URL), TLS PEM path presence when https edge or `SURMOUNT_TLS_*` is set (durable default @@ -816,6 +816,14 @@ background, block graphs, square corners). Canonical file is `/etc/xdg/btop/btop.conf`. Activation also links `/root/.config/btop/btop.conf` (and the same path under any defined normal user's home) so `just btop` loads it without extra flags. +Disk usage follows fstab (`use_fstab = true`). Persist extra disks with +NixOS `fileSystems` (that is how NixOS writes `/etc/fstab`), not by +listing every mount. The extra disk is LABEL=hdd at `/mnt/hdd` via +private host-local `fileSystems` (`/dev/disk/by-label/hdd`, ext4, +`nofail`). Public sample eval does not import that overlay. +`disks_filter` stays empty. Targeted eval: `just test-btop-eval`. +A live `just btop` does not reload a later store conf. Quit btop and +run `just btop` again after the host switch. ### Host hardware probe (`just host-inxi`) @@ -839,6 +847,90 @@ Target comes from `--target`, `SURMOUNT_DEPLOY_TARGET`, or plan names in the public tree. Prefer not to paste RAM/swap sizes into product docs. +### Mail-host compiler CPU (x86-64-v3) + +The mail host compiles C and C++ for `x86-64-v3`, including libraries +those packages build, not only a named list of top-level derivations. +That is the measured guest CPU: avx, avx2, bmi1, bmi2, f16c, fma, lzcnt +(kernel flag `abm`), movbe, popcnt, sse4.1, sse4.2, and ssse3. AES is +present on that guest. This module does not add a separate `-maes` or +any flag above `x86-64-v3`. AVX-512 is absent, so this is not +`x86-64-v4`. QEMU iothread was not sampled. Do not claim `iothread=1`. + +`hosts/mail-vps/configuration.nix` sets `surmount.mailHostCpu.enable`. +`modules/mail-host-cpu.nix` then sets `nixpkgs.hostPlatform` to this +host's system with `gcc.arch = "x86-64-v3"`. The host Nix daemon must +advertise gccarch-x86-64-v3 or the v3 stdenv will not build. Nixpkgs +adds that token to `nix.settings.system-features` when `gcc.arch` is +`x86-64-v3`, and it also adds the inferior tokens `gccarch-x86-64-v2` +and `gccarch-x86-64`. The mail-host module appends `gccarch-x86-64-v3` +again on `nix.settings.extra-system-features`. That list concatenates +with `surmount-remote` from the remote builder module. Do not replace +`system-features` with only the march token. Do not remove `gcc.arch` +and do not set a lower march to make a build pass. + +The running daemon does not read that evaluated list until a switch +activates the new `nix.conf`. The switch builds the v3 stdenv first. +A cache miss (zlib, gcc, glibc, and the rest of that chain) requires +`gccarch-x86-64-v3` on the daemon that is already running. A +`NIX_CONFIG` drop-in does not make that build check pass. The conf +the daemon reads is the store file behind `/etc/nix/nix.conf`. +`surmount-deploy-host` bootstraps that feature on the target before +the system build. When `nix config show` system-features omits +`gccarch-x86-64-v3`, the tool reads that conf, writes +`extra-system-features` so the line contains `surmount-remote` and +`gccarch-x86-64-v3` (other tokens on that line stay), leaves the +`system-features` line unchanged (`benchmark`, `big-parallel`, `kvm`, +and `nixos-test` stay on that line; `surmount-remote` stays on the +extra line), copies the result to +`/run/surmount-nix.conf`, and bind-mounts that file over the store +path. It does not write the store file in place. It restarts +`nix-daemon` only when the feature was missing. If the daemon already +advertises the feature, it does not rewrite the conf and does not +restart the daemon. If the feature is still absent after that +restart, the tool stops and does not start the system build. The +`/run` bind does not survive reboot. A successful switch is what puts +the feature in the generation's `nix.conf`. An operator or an agent +does not SSH a bind before `just deploy`. `just deploy-dry-run` rsyncs, +bootstraps gccarch-x86-64-v3 the same way, and runs `nixos-rebuild build`. +It does not switch the running generation. + +The nixpkgs cc-wrapper +reads `targetPlatform.gcc.arch` and writes `-march=x86-64-v3` into +`cc-cflags-before`. That flag applies to every C and C++ compile that +uses this host's stdenv, including dependencies such as nixpkgs +RocksDB, libsodium, and bitcoind or elementsd libraries when those +derivations are built. The next switch rebuilds that C and C++ closure. +The module is opt-in. Generic CI eval does not enable it. + +The same module still sets `gccArch` and `rustTargetCpu` to +`x86-64-v3`, and it still prefixes `NIX_CFLAGS_COMPILE`, +`NIX_CXXFLAGS_COMPILE`, and `CARGO_BUILD_RUSTFLAGS` on a named +top-level list. The nixpkgs cc-wrapper applies `NIX_CFLAGS_COMPILE` to +both C and C++. This nixpkgs does not map `gcc.arch` onto rustc, so +the Rust `-C target-cpu=x86-64-v3` flag stays on those named +derivations only. The host platform march is what covers C and C++ +dependency builds. + +Generic CI eval (`tests/module-eval.nix` without the mail-vps host +file) leaves `surmount.mailHostCpu.enable` false, so +`nixpkgs.hostPlatform.gcc.arch` stays unset and the named flags are +empty. Flake `packages.*` (GitHub Actions `checks.*.ci`, laptop +`nix build`) are built from `mkPkgsBare` and are not wrapped. Do not +set this march on those package sets. + +Do not use `-march=native` in this public flake. Do not request +`x86-64-v4` or `-mavx512`. Stalwart stays a release binary +(`dontBuild`). This march does not recompile that tarball or the +RocksDB inside it. A nixpkgs RocksDB built as a dependency of something +this host compiles is in scope. The vendored RocksDB inside the +Stalwart release tarball is not. Splora and bitcoind or elementsd are +on the named tune list only when a derivation references those +attributes. The sample host does not enable `surmount.sploraIndexer` +and does not start bitcoind. + +Contract: `t62-mail-host-cpu-v3` in `tests/module-eval.nix`. + ### Remote Nix builder (operator 2026-08-17; Lake default-off 2026-08-25) **surmount-1** is the allowed remote Nix builder (ssh-ng). Mail and builder @@ -887,8 +979,17 @@ reboot.** **In-guest follow-up (2026-08-25):** Builder `MemoryMax` is a **builder budget**, not 95 percent of the whole guest (that lets rustc starve mail). `nix-daemon` gets a positive `OOMScoreAdjust` so it is the -preferred in-guest OOM victim; mail, management-ui, and sshd get a -negative score and stay un-niced. `surmount.hardening.qemuGuestAgent` +preferred in-guest OOM victim. Mail and scram are nice -20 on ordinary +CFS. They are not fifo. They are not realtime I/O. If a separate mail +database unit exists, it is also nice -20. scram stays root and its +start command still contains `--watch`. The HTTP management UI is +nice -19. Arti is nice -18. An enabled Splora indexer, and the bitcoind or elementsd unit that indexer starts, is nice 5. The +Nix daemon is nice 19 with idle I/O. Lake, when that eval or host-local +enables Lake, is nice 19 with idle I/O. sshd stays un-niced (`Nice` +unset). Do **not** set `Nice=` on sshd. Builds stay nice 19 with idle +I/O. No realtime policy on product units. Mail, the HTTP management UI, +and sshd still get a negative OOM score. Do not set builder `Nice=19` +on mail to deprioritize it. `surmount.hardening.qemuGuestAgent` starts `services.qemuGuest` when enabled from host-local so staff can inject a command next time. Disk refuse stays at 95 percent. `cpuQuota = auto` stays 95 percent times online CPUs on the niced wrapper. Live @@ -958,26 +1059,124 @@ missing system features. `extra-` keeps NixOS auto-detected `surmount-remote`. After a module change, rebuild and switch this host (`just deploy-host` or host `nixos-rebuild switch`) so `/etc/nix/nix.conf` picks up the extra feature; then restart is -implied by the switch. `/var/cache/grok-oss-cargo-target` is an extra -sandbox path (`nix.settings.extra-sandbox-paths` on the mail host) so -sandboxed builds can see that cargo cache. Do not advertise a fake -high slot count. Speed factor is +implied by the switch. That order works for `surmount-remote` only +when the daemon can already build the closure. `gccarch-x86-64-v3` +is required to build the v3 stdenv, so `surmount-deploy-host` +bootstraps it on the running daemon before that system build. See +Mail-host compiler CPU above. The `/run` bind does not survive +reboot. A successful switch is what puts the feature in the +generation's `nix.conf`. +Do not advertise a fake high slot count. Speed factor is not a substitute for `MemoryMax`. Disk refuse at `diskGuardPercent` (default 95). Enable from private host-local. Host-local may set a real `memoryMax` as the **builder budget**; never publish that number as a guest size in git. Do not set it to about 95 percent of the whole guest while mail shares the box. Mail and other critical units are **not** on this slice and do -**not** get this `MemoryMax` or `Nice=`. `nix-daemon` **does** get -`Nice=19` and idle ionice when the module is enabled (dev builds -only; not mail). +**not** get this builder `MemoryMax` or this builder `Nice=19`. +Mail and scram are nice -20 on ordinary CFS. They are not fifo. They +are not realtime I/O. If a separate mail database unit exists, it is +also nice -20. scram stays root and its start command still contains +`--watch`. The HTTP management UI is nice -19. Arti is nice -18. An +enabled Splora indexer, and the bitcoind or elementsd unit that indexer starts, is nice 5. The Nix daemon is nice 19 with idle +I/O. Lake, when that eval or host-local enables Lake, is nice 19 with +idle I/O. sshd stays un-niced (`Nice` unset). Do **not** set `Nice=` +on sshd. Builds stay nice 19 with idle I/O. No realtime policy on +product units. Mail is the highest nice priority (-20), not `Nice` +unset. Do not set builder `Nice=19` on mail to deprioritize it. +`nix-daemon` **does** get `Nice=19` and idle I/O when the module is +enabled (dev builds only; not mail). + +### Builder login cc, C++, and Lean (2026-11-04) + +When `surmount.remoteBuilder.enable` is true, +`modules/remote-builder.nix` adds `surmount-builder-devtools` to +`users.users.nixbuilder.packages`. NixOS activation installs that list +at `/etc/profiles/per-user/nixbuilder/bin`. Measured ssh as nixbuilder, +including a login shell, already has that directory on PATH. The +directory was missing because the user had no packages. A later switch +rebuilds the profile from this list, so the names stay. The public +module default of `remoteBuilder.enable` stays false. The private +overlay on horizon sets enable and does not list these packages. The +module supplies them when enable is true. + +This does not set `systemd.services.nix-daemon.path`. It does not +change the sandbox. Live `/etc/nix/nix.conf` on surmount-1 had +`sandbox = true`, `sandbox-fallback = false`, and an empty +`extra-sandbox-paths`. Derivations still use their own stdenv. +`surmount-remote` and mail-host `gccarch-x86-64-v3` stay on +`extra-system-features`. `pkgs.gcc` is the host `pkgs` wrapper, so +mail-host `gcc.arch` still applies to that `cc`. + +`cc`, `c++`, `gcc`, `g++`, `clang`, `clang++`, `ld`, `lld`, `ld.lld`, +and `make` are symlinks. `lean`, `lake`, `leanc`, and `leanmake` are +wrappers that exec the pinned store paths. `leanmake` uses `dirname` +of its own path to find `share/lean/lean.mk`, so a symlink in this +devtools tree would point at the wrong directory. The derivation +checks that the source paths exist when it is built. On 2026-11-04 +`nix eval --raw .#surmount-lean4.version` printed `4.35.0` and +`nix eval --raw .#surmount-lean4.outPath` printed +`/nix/store/v22cg43c1292h1wwbrl32xjl42h84snh-lean4-4.35.0`. +That store path's `bin/lean --version` printed +`Lean (version 4.35.0, x86_64-unknown-linux-gnu, commit eb6cafb7290e8bd5f2937522d8d804f58f02bafc, Release)`. +The devtools profile derivation was not built in that same command. +The switch was not run. + +1. `cc`, `c++`, `gcc`, and `g++` from `pkgs.gcc`. That attribute is + wrapCC of gcc 15.3.0 on non-i686 + (`pkgs/development/compilers/gcc/versions.nix` at nixpkgs + `7dcdd87373d72e7a2173e4db02e20d3d7f495cfd`). cc-wrapper creates + `bin/cc` and `bin/c++`. It does not install `ld`. There is no live + `gcc --version` because `gcc` was not on PATH. +2. `clang` and `clang++` from `pkgs.llvmPackages.clang-unwrapped` + (`llvmPackages_21`). Not wrapped `pkgs.clang`, which also ships + `cc`. +3. `ld` from `pkgs.binutils`. +4. `lld` and `ld.lld` from `pkgs.lld`. +5. `make` from `pkgs.gnumake` (package expression version 4.4.1). The + on-disk leanmake script calls `make`. +6. `lean`, `lake`, `leanc`, and `leanmake` from + `nix/packages/lean4.nix` (official release tag `v4.35.0`, version + 4.35.0). Operator directed Lean 4.35 on 2026-11-04. This is not + `pkgs.lean4` (package expression version 4.29.1 on nixpkgs + `7dcdd87373d72e7a2173e4db02e20d3d7f495cfd`). + +Measured on surmount-1 as nixbuilder before this edit: the login PATH +did not contain `cc`, `c++`, `gcc`, `g++`, `clang`, `clang++`, `ld`, +`lld`, `lean`, `lake`, or `leanc`. Those names were also absent from +`/run/current-system/sw/bin`. An elan tree was on disk at +`/home/nixbuilder/.elan/toolchains/leanprover--lean4---v4.32.0/bin` +and was not on PATH. `lean`, `lake`, `leanc`, `clang`, and `ld.lld` +there failed `--version` with the NixOS stub dynamic linker. +`leanmake` is a shell script and printed `make: command not found`. +`leantar` printed `leantar 0.1.19`. This nixpkgs pin has no `leantar` +attribute, so it is not in the profile. Activation does not delete +that home tree. The directory name v4.32.0 is not the pin. The pin +is official tag v4.35.0 in `nix/packages/lean4.nix`. nixpkgs +`pkgs.lean4` on this lock remains 4.29.1 and is not what the login +profile installs. The live login PATH does not have 4.35.0 until a +switch. `just deploy` was not run for this pin. + +`~/.nix-profile` pointed at a profile with no `bin`. There was no +bashrc. A switch would not put the elan bin directory on PATH. It +replaces `/run/current-system` and the per-user profile from this +module. Declaring the package is what makes the next switch install +the tools. + +The switch was not run. These names are not on the live nixbuilder +login PATH until that switch. The flake attribute `.#surmount-lean4` +evaluates to version 4.35.0, and the built binary at +`/nix/store/v22cg43c1292h1wwbrl32xjl42h84snh-lean4-4.35.0/bin/lean` +prints Lean 4.35.0 commit eb6cafb. The login profile on surmount-1 +still does not include that binary until a generation switch. Hermetic tests: crate `surmount-niced-builder` in `checks.*.ci`, `just test-remote-builder-eval`, `just test-logging-eval`, `nix eval --impure --json --file tests/lake-contract.nix`, and `tests/module-eval.nix` t39* / t42* / t44*. No live SSH. t39* prove the builder contract (user, trusted-users, slice caps, nix-daemon MemoryMax/Nice, max-jobs, extra-system-features -`surmount-remote`, niced stdio, `onlineCpus` multi-CPU quota). t44* +`surmount-remote`, niced stdio, `onlineCpus` multi-CPU quota, login +devtools package t39q). t44* prove Lake is default off and, when enabled, MemoryMax/Nice land on `surmount-lake.service`. t39e still refuses a Lake overlay that is not `surmount.lake.enable`. @@ -1006,13 +1205,20 @@ make maximally nice use of cores without also OOMing the guest. That is the contract. Do not retarget the question to static-site slowness. Dev builds (remote Nix store builds, flake check, hygiene compiles) are -always maximally nice: `nice -n 19` and idle ionice when practical. -Prefer a niced builder user and a niced remote-build path. Prefer -build-on-laptop then switch so the host does not compile the UI. Do -**not** set `Nice=` on mail or other critical units (`stalwart-mail`, -`surmount-management-ui`, `sshd`, `surmount-arti-hidden-service` / Arti, -networking). Do **not** nice the whole machine. Starving mail to make a -compile cheaper is a miss. +always nice 19 with idle I/O. Prefer a niced builder user and a niced +remote-build path. Prefer build-on-laptop then switch so the host does +not compile the UI. Mail and scram are nice -20 on ordinary CFS. They +are not fifo. They are not realtime I/O. If a separate mail database +unit exists, it is also nice -20. scram stays root and its start +command still contains `--watch`. The HTTP management UI is nice -19. +Arti is nice -18. An enabled Splora indexer, and the bitcoind or elementsd unit that indexer starts, is nice 5. The Nix daemon +is nice 19 with idle I/O. Lake, when that eval or host-local enables +Lake, is nice 19 with idle I/O. sshd stays un-niced (`Nice` unset). +Do **not** set `Nice=` on sshd. Builds stay nice 19 with idle I/O. No +realtime policy on product units. Do **not** nice the whole machine. +Starving mail to make a compile cheaper is a miss. Mail is the highest +nice priority (-20). Do not set builder `Nice=19` on mail to +deprioritize it. **Niceness is not a memory cap.** We already started niced Lake without `MemoryMax`; that is why the box went dark (guest OOM / hypervisor @@ -1586,8 +1792,16 @@ Paper trail is **journald** (operator-only). `surmount.logging.enable` defaults true with `surmount.enable`. Persistent journal under `/var/log/journal` with a **size cap** so logs cannot fill the disk (SHC ticket 261 class). Scaffold `SystemMaxUse=1G` and `RuntimeMaxUse=256M` are not published guest disk or -RAM. `ForwardToSyslog=no`. Mail, management-ui, sshd, Arti, and networking -stay at normal priority (not niced). No public log dump on the apex site. +RAM. `ForwardToSyslog=no`. Mail and scram are nice -20 on ordinary CFS. +They are not fifo. They are not realtime I/O. If a separate mail +database unit exists, it is also nice -20. scram stays root and its +start command still contains `--watch`. The HTTP management UI is +nice -19. Arti is nice -18. An enabled Splora indexer, and the bitcoind or elementsd unit that indexer starts, is nice 5. The +Nix daemon is nice 19 with idle I/O. Lake, when that eval or host-local +enables Lake, is nice 19 with idle I/O. sshd stays un-niced (`Nice` +unset). Do **not** set `Nice=` on sshd. Builds stay nice 19 with idle +I/O. No realtime policy on product units. Mail is the highest nice +priority (-20), not `Nice` unset. No public log dump on the apex site. | Source | Where | Notes | |--------|-------|-------| @@ -1597,7 +1811,7 @@ stay at normal priority (not niced). No public log dump on the apex site. | sshd | journald | `LogLevel VERBOSE` (auth fail / disconnect; no passwords) | | fail2ban | journald | Transitional sshd jail; `backend = systemd` | | nft `surmount_guard` | kernel -> journald | `log prefix "surmount-nft-ban-drop: "` when logging on | -| Arti | journald via stdout | `console = info`; `log_sensitive_information = false` (no HS keys) | +| Arti | journald via stdout | `console = debug`; `log_sensitive_information = false` (no HS keys) | | nix-daemon | journald | Remote builder path; niced + MemoryMax; per-unit log burst kept high so OOM start is visible; not mail | | surmount-lake (if enabled) | journald | Default-off; niced + MemoryMax on the lake unit; SyslogIdentifier `surmount-lake` | | Vaultwarden (if enabled) | journald via stdout | `LOG_LEVEL=info`; never `LOG_FILE`; never ADMIN_TOKEN | @@ -1796,55 +2010,16 @@ just et -- --target root@surmount-1 # or: nix run .#surmount-et -- --target root@surmount-1 ``` -Remote grok-oss attach is SSH plus tmux as user grok (`just grok-oss` / -`nix run .#surmount-grok-oss`): `ssh -t` then `runuser -u grok` so -`tmux attach -t grok-oss` or `tmux new -s grok-oss grok-oss` runs as that -user (module uid, default grok). Root tmux is a miss: `user-1988.slice` -MemoryMax applies only when the process uid is grok. It is **not** Eternal -Terminal (`et -p 2022`). Nested guest `just grok-oss` attaches that same -local tmux session as user grok and does not SSH to itself. `just et -- --status` -prints whether TCP 22 and TCP 2022 answer, without addresses. If 2022 does not -answer, that status tells you to use SSH/tmux (`just grok-oss`). - -Guest grok-oss on surmount-1 uses a **machine xAI console API key**. That -key spends **console API credits** (console team prepaid). It is **not** -included SuperGrok period limits. It is **not** SuperGrok dollar credits. -Path only (never key bytes in git): `/home/grok/.grok/console.env` -(`XAI_API_KEY`, mode 0600) and `/home/grok/.grok/machine-console-auth.toml` -(`[auth] preferred_method = "api_key"`; that file does not hold the key). -The guest has **no git** and **no GitHub SSH**. Signed commits stay on -the laptop. Laptop L0 (`surmount-coordinator-gui -set-remote-host-console-api-key`) lives in grok-oss `remote-1`; the -operator already tasked that tree. Dual-pin [SECRETS.md](SECRETS.md). - -Grok OSS has three distinct surfaces. Do not merge them. - -The pager `/dashboard` (also `grok-oss dashboard`, or Ctrl+backslash in the TUI) is -the Agent Dashboard inside **this** grok-oss TUI process. It is not a web -page, not management-ui, and not L0. - -`/running` (alias `/windows`, CLI `grok-oss running` and -`grok-oss running --json`) lists live TUI windows on **this** `$GROK_HOME` -from `active_sessions.json`. That list is not L0. `just grok-oss-running` -SSHes to the mail host and prints that JSON as user grok. It does not -allocate a TTY, does not bind HTTP, and does not print secrets. - -L0 is the laptop Surmount coordinator GUI (`surmount-coordinator-gui`) -tagging `grok-oss running --json` with a host name such as surmount-1. -It does not bind HTTP and it does not listen on the mail host. The guest -`grok-oss` package does not install that GUI. Do not serve L0 on -management-ui or on :443. - -`surmount.grokOss.enable` stays default false. Enable only from private -host-local. The module does not start a boot TUI unit. Do not set `Nice=` -on grok-oss (sshd class). Flake input grok-oss is commit pin -`182293cb30fa5414dd47686b92708103519b7462` on -`github:SurmountSystems/grok-oss`. Prior lock was -`decb052744d25b63dbca8a95ca65b4ad794b9ad5`. Operator 2026-10-14 locked -`b7ab91532c9e8008081b4f281794be43ecae73a6` (HB-1 merged as GitHub pull -167, crate version 1.0.45). Operator bumps: -`nix flake update grok-oss`. After a host switch that includes this -package, attach with `just grok-oss` as user grok. +`just et -- --status` prints whether TCP 22 and TCP 2022 answer, without +addresses. + +Grok OSS and tau are **not** mail-host packages. This flake does not +take those inputs. `surmount.grokOss.enable` and `surmount.tau.enable` +stay false. Named eval fails if either is true or if those packages +appear in `environment.systemPackages`. Do not re-enable them from +host-local. Do not install `pkgs.grok-oss` or `pkgs.tau` on the guest. +Laptop grok-oss (the operator TUI on this machine) is a different +tree. Do not put it back on the mail host. For a rebuild that must finish even if every laptop tunnel dies, SSH or `just et` in, then `tmux new -s switch` and run the command inside tmux. @@ -1864,7 +2039,7 @@ exceptions*. | `nix run .#e2e-host` / `just e2e-host` | Host end-to-end (Rust flake app; `SURMOUNT_E2E_HOST=1` or exit 2; also requires `SURMOUNT_E2E_BASE_URL`; `LAB_IP` unless `SKIP_BAN=1`). **Never** a flake check | | `just publish` | Publish static sites (apex/www from locked `github:SurmountSystems/site` via `nix build .#surmount-public-site`, plus extra vhosts). Not a NixOS generation. | | `just deploy` | Operator's real mail-host switch. Defaults: `--target root@surmount-1` and `--host-local /home/hunter/.local/share/surmount/host-local`. Extra arguments append. Agents must not run this. Explicit equivalent: `just deploy-host -- --target root@surmount-1 --host-local /home/hunter/.local/share/surmount/host-local`. | -| `just deploy-dry-run` | Same switch with `--dry-run` before `--target`. Agents may run this. Extra arguments append. Explicit equivalent: `just deploy-host -- --dry-run --target root@surmount-1 --host-local /home/hunter/.local/share/surmount/host-local`. | +| `just deploy-dry-run` | Rsyncs, bootstraps gccarch-x86-64-v3, and runs `nixos-rebuild build` on the mail host. Does not switch the running generation and does not install secrets. Agents may run this. Extra arguments append. Explicit equivalent: `just deploy-host -- --dry-run --target root@surmount-1 --host-local /home/hunter/.local/share/surmount/host-local`. | | `nix run .#surmount-deploy-host` / `just deploy-host` | Flexible deploy driver with no default target: public rsync + host-local checks + `#mail-vps` rebuild (not CI). That is the NixOS generation. [deploy-host-local.md](deploy-host-local.md) | | `nix run .#surmount-deploy-host-post-switch-smoke` | Post-switch smoke (generation, units, loopback health, listen proof). | | `just fix-public-dashboard` / `nix run .#surmount-fix-public-dashboard` | Compose recovery pin + mint API key + free-443 dry-run (no operator password homework). Optional `--live-free-443` on operator host only. [SECRETS.md](SECRETS.md) | @@ -1886,9 +2061,7 @@ exceptions*. | `nix run .#surmount-private-data` / `just check-private-data` | Private-data pattern scan (`--staged` / `--tree` / `--paths`). | | `nix run .#surmount-host-logs` / `just host-logs` | Host journal follow and `--status`. | | `nix run .#surmount-et` / `just et` | Eternal Terminal client. Reconnects after sleep and network change. Does not replace Mullvad. After the host switch, `etserver` listens on TCP 2022. Long commands still belong in tmux on the guest. | -| `nix run .#surmount-grok-oss` / `just grok-oss` | Attach grok-oss on the mail host via SSH + tmux as user grok. Not Eternal Terminal. Nested guest is local tmux as grok. Live TTY required. MemoryMax only if uid grok. | -| `just grok-oss-running` | SSH (or local on the guest) `grok-oss running --json` as user grok. No TTY, no HTTP bind, no secrets. Input for laptop L0 tagging, not a dashboard. | -| `nix run .#surmount-rekey` / `just rekey` | SHC Backups PGP paste: mint age identity (age/rage crate), wrap with gpg, print `age1...` for type PGP. Identity stays 0600 on disk. | +| `nix run .#surmount-rekey` / `just rekey` | SHC Backups PGP paste: mint an X25519 age identity (not the age crate), wrap with gpg, print `age1...` for type PGP. Identity stays 0600 on disk. | | `nix run .#surmount-shc` / `just rdns-shc` | SHC customer user-api (rDNS PTR + tickets). No python3 in the crate. | | `nix run .#surmount-tls-hybrid` / `just check-tls-hybrid` | **D1** host hybrid TLS negotiation probe after B1. Requires `SURMOUNT_E2E_BASE_URL=https://...` or exit **2** BLOCKED. Never a flake check. | | `nix run .#surmount-domain-audit` / `just domain-audit` | Read-only DNS/web/TLS/mail posture (SPF/DKIM/DMARC, dual-sign selectors `stalwart`/`stalwart-rsa`, HTTPS, optional `--smtp` STARTTLS). | diff --git a/docs/SEARCH_AND_UI.md b/docs/SEARCH_AND_UI.md index 62a9857..186f979 100644 --- a/docs/SEARCH_AND_UI.md +++ b/docs/SEARCH_AND_UI.md @@ -110,8 +110,9 @@ Browser (admin, then v1 webmail) Desktop / MUA (always OK) +-- status / admin APIs ----> Stalwart :8080 ``` -Stalwart webadmin (`/admin` on loopback, optional `/stalwart-admin/` via edge) -is a **bootstrap fallback**, not the long-term Surmount UX. +Stalwart webadmin (`/admin` on loopback only; the edge does not publish +`/stalwart-admin/`) is a **bootstrap fallback**, not the long-term Surmount UX. +Reach it with an SSH tunnel to the loopback listener. Legacy public sites: **static files only** at the edge. Not part of the Leptos app. Live apex/www serve the packaged @@ -119,7 +120,10 @@ the Leptos app. Live apex/www serve the packaged `surmount.systems` / `www.surmount.systems`). `just publish` publishes static sites; `just deploy` is the NixOS generation (the host switch). `just deploy-host` is that switch with no default target. -Operator console stays on `services.surmount.systems`. COMING SOON +Operator console stays on `services.surmount.systems`. Public systems +dashboard is Host `status.surmount.systems` on the same Axum edge. +That page links public hosts over https, shows ordinary unit/HTTP +status text, and prints host uptime from `/proc/uptime`. COMING SOON leftover is closed. ## Phases @@ -147,7 +151,8 @@ leftover is closed. **Discovery headers (2026-08-17, every public Host 2026-08-20; per-site v3 2026-09-11):** Onion-Location + Alt-Svc on mapped HTTPS 2xx/3xx. Map: apex, www, - services, `mta-sts.{apex}`, extra static Hosts; one v3 per Host. + services, `mta-sts.{apex}`, `status.{apex}`, extra static Hosts; one + v3 per Host. Onion-Location is `{that-host-onion}{path}`. Mail unmapped. Mapping loaded at process start; restart the unit after hostname/env/map/static-vhost change (no hot-reload). Optional env: `SURMOUNT_ONION_LOCATION_ENABLED`, @@ -313,7 +318,7 @@ full Q-AUTH-1 still residual). - Auth foundation shipped (mode off default); full Q-AUTH-1 residual remains **Exit criteria:** day-to-day admin no longer requires Stalwart webadmin for -common tasks; `/stalwart-admin/` can be disabled by default. +common tasks. The public `/stalwart-admin/` path is not in the nginx escape. ### Operator console UX principles (living) diff --git a/docs/SECRETS.md b/docs/SECRETS.md index edbf36c..88416fa 100644 --- a/docs/SECRETS.md +++ b/docs/SECRETS.md @@ -222,7 +222,7 @@ render into private host-local. | Store | Holds | Does not hold | |-------|-------|----------------| -| **Host profile** (e.g. `~/.local/share/surmount/host-profile.toml`) | `acme_domains` (certificate hostnames: services + mail + apex + www + mta-sts), `acme_email`, `acme_directory` (staging default; production only with explicit flag or profile field), optional `host_id`, path overrides | Namecheap ApiKey, PEMs, session/Stalwart/VW tokens | +| **Host profile** (e.g. `~/.local/share/surmount/host-profile.toml`) | `acme_domains` (certificate hostnames: services + mail + apex + www + mta-sts + status), `acme_email`, `acme_directory` (staging default; production only with explicit flag or profile field), optional `host_id`, path overrides | Namecheap ApiKey, PEMs, session/Stalwart/VW tokens | | **Secret Service / staging (Domain A)** | Secret **values** + labels (`namecheap-api`, `stalwart-token`, …) | Edge FQDNs as the SoT for Nix ACME (use profile / host-local) | | **Domain B files** | Installed secrets + ACME parents; hook reads `namecheap.env` | Git-tracked host config | | **Vaultwarden (Domain C)** | Human notes / optional copies after S7b | Activation; profile fields as boot SoT | diff --git a/docs/SECURITY.md b/docs/SECURITY.md index e5f6f58..0b1eff6 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -4,7 +4,11 @@ Defense-in-depth notes for the Surmount mail VPS. Design notes: [open-choices.md](open-choices.md). Secrets detail: [SECRETS.md](SECRETS.md). Data plane: [DATASTORES.md](DATASTORES.md). Ops: [OPS.md](OPS.md). -**Last updated:** 2026-08-26 (Cargo.lock SHA-1/MD5 inventory: no sha1/md5 +**Last updated:** 2026-10-24 (Cargo.lock git patches for ctutils 0.4.5, +rustls-webpki 0.103.16, webpki-root-certs 1.0.11, and i18n-config 0.4.8 +without basic-toml, because menhera 10d still stops at the unpatched +registry versions; do not ignore those advisories; do not fetch crates.io +to skip the cooldown). Prior 2026-08-26 (Cargo.lock SHA-1/MD5 inventory: no sha1/md5 crates; product TLS is rustls+aws-lc-rs TLS 1.3; ring is locked as a rustls optional dep). Prior 2026-08-20 (extra mailbox domains get the same mail-auth DNS **and** the same hosted DNSSEC as the primary; leftover @@ -65,6 +69,27 @@ RUSTSEC-2026-0173 is proc-macro-error2, not age. `instant` (RUSTSEC-2024-0384) left the lock with nostr 0.45 (universal-time, not instant). +On 2026-10-24, menhera 10d still stopped at ctutils 0.4.3, rustls-webpki +0.103.15, and webpki-root-certs 1.0.9. The lock uses git +`[patch.crates-io]` of ctutils tag `ctutils-v0.4.5` (rev +`88d6e90d4cfd2fc3333d00e13c99241507d2138f`) for +[RUSTSEC-2026-0385](https://rustsec.org/advisories/RUSTSEC-2026-0385) +(accessed: 2026-10-24), rustls-webpki tag `v/0.103.16` (rev +`8ecdcc76f56b68fda67a6adf7976f5eed5006a4c`) for +[RUSTSEC-2026-0382](https://rustsec.org/advisories/RUSTSEC-2026-0382) +(accessed: 2026-10-24), and webpki-root-certs tag `v/1.0.11` (rev +`7ebfa0b377fd0c7e0a2848f9d9b2867a4c2198cf`) for +[RUSTSEC-2026-0383](https://rustsec.org/advisories/RUSTSEC-2026-0383) +(accessed: 2026-10-24). i18n-config 0.4.8 on menhera still depends on +basic-toml ([RUSTSEC-2026-0374](https://rustsec.org/advisories/RUSTSEC-2026-0374), +accessed: 2026-10-24; unmaintained, no patched versions). The lock +patches i18n-config to kellpossible/cargo-i18n rev +`2ce7b1dfe9a8cc224288bf50cbc2a67a6de863c4`, still version 0.4.8, which +uses the toml crate. Do not ignore those four advisories. Do not fetch +crates.io to skip menhera. Drop each git line when 10d lists the +patched registry version. The only cargo-audit ignore stays +RUSTSEC-2024-0436 (`paste`). + On 2026-09-26 the lock replaced registry faster-hex 0.10.0 with git tag v0.10.1 (rev `c817979830ea5be2eafe0b0c99cb83b017ae2b8f`) for RUSTSEC-2026-0306. `faster_hex::hex_decode_unchecked` on x86 and x86_64, @@ -303,14 +328,14 @@ Q-AUTH-1 still open. Report: `.agents/reports/impl-auth-live-b4-switch.md`. OIDC provider/client. That is **not** the same as Nostr. - **Does not** natively verify Nostr npub signatures unless we build a bridge. -### Mailbox password hashing (Stalwart 0.16.24) +### Mailbox password hashing The management UI does **not** hash mailbox passwords in the browser or in Axum. `POST /api/v1/accounts/password` sends the plaintext secret only over the already-authenticated operator session to loopback Stalwart (`x:Account/set` `credentials.0` `@type Password`). The engine hashes. -Stalwart 0.16.24 (this repo pin in `nix/packages/stalwart-mail.nix`) uses +Stalwart tag v0.16.24 (source re-checked 2026-10-03; the living pin in `nix/packages/stalwart-mail.nix` is **1.0.2** as of 2026-11-03) uses `Authentication.passwordHashAlgorithm`. The enum default is **Argon2id**. Source (tag `v0.16.24`, re-checked 2026-10-03): diff --git a/docs/STACK.md b/docs/STACK.md index 16121dc..f29cd05 100644 --- a/docs/STACK.md +++ b/docs/STACK.md @@ -15,7 +15,7 @@ law). Scaffold defaults still describe what code does today when they differ. Run mail, light static web, admin console, and v1 webmail on a **single operator-chosen VPS** (size/plan open; **Q-HOST-1**; do not invent SKUs). **Stalwart** is the mail engine (version from `nix/packages/stalwart-mail.nix`; currently -**0.16.15** binary FOD). **RocksDB** for all store roles is **fine for now**. +**1.0.2** binary FOD). **RocksDB** for all store roles is **fine for now**. Our Rust product layer (**Axum + Leptos SSR**) is operator admin first, then real webmail, with **Nostr** authentication at the product edge. HTTPS must move to an **Axum-first** edge we own (nginx is transitional-to-delete; no @@ -207,7 +207,7 @@ Firewall set in `modules/networking.nix`. | Host | Path | Upstream | |------|------|----------| | `services.surmount.systems` | `/` | management-ui operator console (UDS target; TCP loopback scaffold) | -| `services.surmount.systems` | `/stalwart-admin/` | Stalwart HTTP (bootstrap only) | +| `services.surmount.systems` | (no `/stalwart-admin/`) | Public Stalwart HTTP proxy removed. Bootstrap is an SSH tunnel to loopback Stalwart HTTP. | | `mail.surmount.systems` | `/` | same console as services. Host classification is Other, so `/` redirects to `/login` on that name. The name is on the certificate. It is not a separate public site. | | apex / www | `/` | packaged SurmountSystems/site static files (flake input; store path default); else UNDER CONSTRUCTION if the root has no index.html (not operator console) | | future vault host | `/` | Vaultwarden local only (private, no public signup) | diff --git a/docs/TEST-AND-DEPLOY.md b/docs/TEST-AND-DEPLOY.md index d30c6d9..a8eb409 100644 --- a/docs/TEST-AND-DEPLOY.md +++ b/docs/TEST-AND-DEPLOY.md @@ -9,7 +9,8 @@ Those failures belong on the agent. The operator runs the real switch in section 5 after the agent has already reported those gates green, and keeps Eternal Terminal open so activation cannot strand the box. -**Last updated:** 2026-10-12. Update, audit, test, and switch is the +**Last updated:** 2026-10-23 (this flake does not take grok-oss or tau +inputs). Prior 2026-10-12. Update, audit, test, and switch is the command block below. `just publish` publishes static sites. `just deploy` is the host switch. `just deploy-dry-run` is the agent dry-run. `just deploy-host` stays the explicit equivalent. @@ -36,10 +37,10 @@ just deploy ``` `nix flake update` moves branch inputs: nixpkgs `nixos-26.05`, -`advisory-db`, grok-oss `main`, Splora, and `surmount-site`. It does -not move commit-pinned `nixpkgs-rust`, `crane`, or `sops-nix`. grok-oss -stays on `main` until `flake.nix` names another ref. Stalwart versions -are the fixed-output files under `nix/packages/`, not this command. +`advisory-db`, Splora, and `surmount-site`. It does +not move commit-pinned `nixpkgs-rust`, `crane`, or `sops-nix`. +This flake does not take grok-oss or tau inputs. Stalwart versions are +the fixed-output files under `nix/packages/`, not this command. `cargo update` uses the menhera index already configured on the laptop. `just audit` and `just audit-remote` run the same RustSec check. @@ -68,7 +69,7 @@ is only the static sites. It is not this sequence. The agent can and must run the entire procedure **up to and including** `just deploy-dry-run`. Named eval, `just check-remote` (fmt and -nixfmt, then clippy), and the dry-run plan are how errors show up. Dumping those +nixfmt, then clippy), and the dry-run build are how errors show up. Dumping those commands on the operator is a broken leftover. Standing law: [../AGENTS.md](../AGENTS.md) *Deploy dry-run is agent work* and *Prove the operator gate*. The explicit equivalent of the dry-run is @@ -290,7 +291,8 @@ Do not use those as the mail-host quality bar. The **agent** runs this. Agents must have already run section 2 and section 3, then this dry-run, before asking the operator to switch. The dry-run does not switch. A dry-run failure (bad host-local, missing -keys, rsync refuse) is an agent problem until it is green. +keys, rsync refuse, or a failed `nixos-rebuild build`) is an agent +problem until it is green. From the repo root: @@ -318,18 +320,25 @@ nix run .#surmount-deploy-host -- --dry-run --target root@surmount-1 --host-loca The extra `--` after `just deploy-host` stops just from eating flags. `just deploy-dry-run` already includes those flags. -What the dry-run prints (it must not rebuild the guest): +What the dry-run does (it must not switch the guest): - rsync of the public tree to `root@surmount-1:/root/surmount-server/` (excludes `.git/`, `host-local/`, the workspace `target/` cargo cache, `crates/target/`, secrets patterns) - rsync of private host-local to `root@surmount-1:/root/surmount-server/host-local/` -- `nixos-rebuild switch --flake path:/root/surmount-server#mail-vps` - as a planned remote command -- post-switch smoke as a planned remote command - -Success is a printed plan and exit 0. It is not a new generation. +- on the guest, bootstraps `gccarch-x86-64-v3` when the daemon's + system-features omit it, the same way `just deploy` does, then runs + `nixos-rebuild build --flake path:/root/surmount-server#mail-vps` + with `--option max-jobs 32 --option cores 0` +- does not run `nixos-rebuild switch` +- does not change `/nix/var/nix/profiles/system` +- does not run post-switch smoke +- does not install secrets (that plan stays print-only) + +Success is a finished build and exit 0. The running generation was not +switched. A later `just deploy` of the same tree reuses the store paths +this build produced. Host-local never comes from public git. The flake on the guest is `path:` so untracked host-local is visible to Nix. @@ -344,8 +353,9 @@ just deploy-host -- --dry-run --target root@surmount-1 \ --secrets-require-kind tls-cert --secrets-require-kind tls-key ``` -Do not pass `--install-secrets` unless you mean to copy Domain B secrets -in that same deploy. Schema: [SECRETS.md](SECRETS.md). +On a dry-run, `--install-secrets` only prints the install plan and does +not copy secrets. A real deploy with that flag does copy them. Schema: +[SECRETS.md](SECRETS.md). --- @@ -366,9 +376,19 @@ Explicit equivalent (`just deploy-host` has no default target): just deploy-host -- --target root@surmount-1 --host-local /home/hunter/.local/share/surmount/host-local ``` -That rsyncs, then runs `nixos-rebuild switch` on the guest. A generation -that compiles grok-oss or Splora can sit on one `building ...drv` line -for a long time. Check guest rustc before killing it. +That rsyncs, then on the guest bootstraps `gccarch-x86-64-v3` when +`nix config show` system-features omits it, then runs `nixos-rebuild +switch`. The bootstrap reads the nix.conf the daemon is using, writes +`/run/surmount-nix.conf` (it does not write the store file in place), +bind-mounts that file over the store path, and restarts nix-daemon +only when the feature was missing. If the feature is still absent, the +tool stops and does not start the system build. If the daemon already +advertises the feature, it does not rewrite the conf and does not +restart the daemon. The `/run` bind does not survive reboot. A +successful switch is what puts the feature in the generation's +nix.conf. Do not SSH a separate bind before `just deploy`. A generation +that compiles Splora can sit on one `building ...drv` line for a long +time. Check guest rustc before killing it. After rebuild, the driver always runs `surmount-deploy-host-post-switch-smoke` on the guest, even if rebuild diff --git a/docs/architecture-review.md b/docs/architecture-review.md index c981776..e94c626 100644 --- a/docs/architecture-review.md +++ b/docs/architecture-review.md @@ -199,8 +199,9 @@ Empty RocksDB + safe defaults (join + module comments): | 587 / 143 | submission / IMAP plain | **not** default in 0.16 safe defaults | Surmount management UI default moved **8080 -> 8090** so it does not fight -Stalwart HTTP. nginx proxies UI on 8090 and `/stalwart-admin/` to -`127.0.0.1:8080`. Env: `SURMOUNT_STALWART_URL=http://127.0.0.1:8080`. +Stalwart HTTP. nginx, when the escape is on, proxies the UI only. +The public `/stalwart-admin/` path was removed. Env: +`SURMOUNT_STALWART_URL=http://127.0.0.1:8080`. Plain 587 is a common MUA expectation; if you need it, it is an apply-time listener choice, not something Nix pins today. @@ -471,13 +472,13 @@ Doc and comment lag can cause wrong operator actions. Prefer one story: |----------|-------------------|--------------| | `docs/STACK.md` diagram and ports table | may still lag if old diagram cells remain | UI **:8090**, Stalwart **:8080** (verify diagram) | | `docs/MIGRATION.md` | *(mopped 2026-08-07)* :8081 references | Management HTTP **:8080** | -| `docs/STACK.md` / README / open-choices engine wording | *(mopped earlier)* soft "after bump" | Pin is **0.16.15** (still not "accepted forever") | -| `docs/DATASTORES.md` section 5.x | *(mopped 2026-08-07)* had TOML-shaped eval + spam v2.0.5 | Living text: 0.16 config.json; spam-filter **3.0.0**; `services.stalwart` | +| `docs/STACK.md` / README / open-choices engine wording | *(mopped earlier)* soft "after bump" | Pin is **1.0.2** (re-read 2026-11-03; still not "accepted forever") | +| `docs/DATASTORES.md` section 5.x | *(mopped 2026-08-07)* had TOML-shaped eval + spam v2.0.5 | Living text: DataStore config.json; spam-filter **3.0.4** rules archive only; `services.stalwart` | | `docs/OPS.md` / `MIGRATION.md` / `SEARCH_AND_UI.md` ports | *(mopped 2026-08-07)* Stalwart **:8081** / UI **:8080** mixups | Stalwart **:8080**, UI default **:8090** | | `docs/fix-and-fixos.md` section 3 "today" | Stalwart still largely from nixpkgs until packaging lands | Packaging **landed** (L1 evidence): Surmount FODs + service module | | `.grok/joins/foundation.md` | Older port/module story | Defer to `stalwart-current.md` + modules | | README "Stalwart admin fallback" ssh example | *(mopped earlier)* was 8081 | Living example uses **8080** | -| open-choices "Engine version" | *(mopped earlier)* soft "after bump" prose | Living pin **0.16.15** + package path | +| open-choices "Engine version" | *(mopped earlier)* soft "after bump" prose | Living pin **1.0.2** + package path | ### 6.2 Code / ops implications to stop treating as settled product law diff --git a/docs/deploy-host-local.md b/docs/deploy-host-local.md index 8793222..71e02e4 100644 --- a/docs/deploy-host-local.md +++ b/docs/deploy-host-local.md @@ -209,7 +209,8 @@ does not print the note either. ## 4. Deploy driver ```bash -# Dry-run (no SSH side effects): print rsync + rebuild plan +# Dry-run: rsync, bootstrap gccarch-x86-64-v3, and nixos-rebuild build. +# Does not switch the running generation and does not install secrets. nix run .#surmount-deploy-host -- --dry-run --target root@example.test \ --host-local /path/to/private/host-local @@ -236,10 +237,10 @@ nix run .#surmount-deploy-host -- --dry-run --target root@example.test \ | Public sync | rsync of public tree; excludes `host-local/`, `.git`, the workspace `target/` cargo cache, `crates/target`, secret basenames (`.env`, `*.pem`, `*.agekey`, `secrets.yaml`, `secrets/`, SSH private key names, ...). The guest Nix build uses the remote store. It does not use the laptop cargo cache. **Not** a substitute for `surmount-private-data` admission control. | | Host-local remote | Optional rsync to `REMOTE/host-local/` only; **refuses** `--sync-host-local-into` under `hosts/` or `secrets/` (path segments, missing parents, and symlink targets). Default **without** rsync `--delete`; pass `--host-local-delete` to opt in (incomplete local can wipe remote-only files if delete is on). | | `--skip-host-local-check` | Skips key-file checks **and** remote host-local rsync (documented side effect; not recommended). | -| **`--install-secrets` (S4)** | **Opt-in only.** Validates source/host-id/staging **before** any rsync; runs `nix run .#secrets-install-host` after sync and before rebuild. Staging must be **outside** the public git work tree (refuse in-tree so public rsync cannot copy payloads). Default **off**. Never logs secret values. See [SECRETS.md](SECRETS.md). | +| **`--install-secrets` (S4)** | **Opt-in only.** Validates source/host-id/staging **before** any rsync; a real deploy runs `nix run .#secrets-install-host` after sync and before rebuild. Dry-run prints that plan and does not install secrets. Staging must be **outside** the public git work tree (refuse in-tree so public rsync cannot copy payloads). Default **off**. Never logs secret values. See [SECRETS.md](SECRETS.md). | | Rebuild | `nixos-rebuild switch --flake path:REMOTE_DIR#mail-vps` on the target (on-host build; absolute path: so host-local is visible). Uses `ssh --` so the host is never parsed as an SSH option. | -| Post-switch smoke | After rebuild returns (even when rebuild exit is non-zero), always runs `surmount-deploy-host-post-switch-smoke` on the remote via SSH: current generation pointer, `systemctl is-active` for `sshd` / `stalwart-mail` / `surmount-management-ui` (starts UI if inactive), loopback `/health` (day-1 default `http://127.0.0.1:8090/health`; when unit `SURMOUNT_LISTEN_MODE=https` and the listen port is 443, proven loopback is `https://127.0.0.1:443/health` with `curl -sk`, not HTTP on :443; port may follow unit `SURMOUNT_LISTEN` when parseable; never invents a public URL), TLS PEM path presence when https edge or `SURMOUNT_TLS_*` is set (durable default `/var/lib/surmount/secrets/tls/{cert,key}.pem`; path labels only, never PEM contents), optional ACME enable note from `SURMOUNT_ACME_ENABLE`, and when https edge is configured, `ss` listen proof for `:443` and `:80`. Day-1 loopback/http skips PEM and public listen checks. Clear PASS/FAIL lines. Self-test: crate tests in `checks.*.ci`. | -| Dry-run | Prints commands (including planned secrets-install when opted in, and post-switch smoke); hermetic tests use this path. | +| Post-switch smoke | Real deploy only (not dry-run). After rebuild returns (even when rebuild exit is non-zero), always runs `surmount-deploy-host-post-switch-smoke` on the remote via SSH: | current generation pointer, `systemctl is-active` for `sshd` / `stalwart-mail` / `surmount-management-ui` (starts UI if inactive), loopback `/health` (day-1 default `http://127.0.0.1:8090/health`; when unit `SURMOUNT_LISTEN_MODE=https` and the listen port is 443, proven loopback is `https://127.0.0.1:443/health` with `curl -sk`, not HTTP on :443; port may follow unit `SURMOUNT_LISTEN` when parseable; never invents a public URL), TLS PEM path presence when https edge or `SURMOUNT_TLS_*` is set (durable default `/var/lib/surmount/secrets/tls/{cert,key}.pem`; path labels only, never PEM contents), optional ACME enable note from `SURMOUNT_ACME_ENABLE`, and when https edge is configured, `ss` listen proof for `:443` and `:80`. Day-1 loopback/http skips PEM and public listen checks. Clear PASS/FAIL lines. Self-test: crate tests in `checks.*.ci`. | +| Dry-run | Rsyncs the public tree and private host-local, bootstraps gccarch-x86-64-v3, and runs `nixos-rebuild build` with the same `path:` flake ref and `--option max-jobs 32 --option cores 0`. Does not run `nixos-rebuild switch`, does not change `/nix/var/nix/profiles/system`, does not run post-switch smoke, and does not install secrets (that plan stays print-only). Hermetic tests use a fake ssh and a fake rsync. | | Deeper smoke | Optional `just e2e-host` / DNS/TLS/mail scripts; does not claim cutover. | Self-test (synthetic fixtures only): diff --git a/docs/hygiene.md b/docs/hygiene.md index 6b327cf..61c1889 100644 --- a/docs/hygiene.md +++ b/docs/hygiene.md @@ -69,6 +69,8 @@ before a commit object exists: | Git hook | `script/git-hooks/pre-commit` is a short shebang that execs the scanner. Git needs that file. It is not a product bash driver. | | Self-test | crate tests in `crates/surmount-private-data` | +The crate test `bad_age_fixture_is_hit` fails if `crates/surmount-private-data/testdata/bad-age.txt` is missing, and when that file is present it scans the file bytes and requires the age-secret-key class. `crates/surmount-rekey/src/lib.rs` is an excluded test sample for the tree and staged gates, and `rekey_age_test_vector_is_excluded_and_still_detected` fails if that key is removed. + **Classes (regex / path only; never real values in the tool):** PEM / OpenSSH private key blocks; `AGE-SECRET-KEY-1...`; common token shapes (`ghp_`, `github_pat_`, `AKIA...`, etc.); password/secret/token assignment heuristic; @@ -155,7 +157,7 @@ not grow that set. **operator direction YYYY-MM-DD** labels. - **No ADR jargon.** Plain English open choices only: [open-choices.md](open-choices.md). -- Evidence for Stalwart stores (current pin 0.16.15): +- Evidence for Stalwart stores (the 0.16.15 research note; living pin is 1.0.2 in `nix/packages/stalwart-mail.nix`): `docs/research/stalwart-0.16.15-stores-evidence.md`. Historical 0.11.8 only: `docs/research/stalwart-stores-evidence-2026-07-30.md`. diff --git a/docs/open-choices.md b/docs/open-choices.md index 853cdf0..ab81dda 100644 --- a/docs/open-choices.md +++ b/docs/open-choices.md @@ -77,13 +77,13 @@ separate product. ## Engine version **Stalwart is on current via Surmount package** (`nix/packages/stalwart-mail.nix`; -measured **0.16.15** binary FOD as of architecture-review). Compatibility with +measured **1.0.2** binary FOD, re-read 2026-11-03). Compatibility with old Stalwart is **not** a goal. nixpkgs lag is **not** a reason to stay old. - Always re-validate latest when bumping. - Historical note: early scaffold briefly used channel **0.11.8** on host **nixos-25.05**; living host is **nixos-26.05** and engine is Surmount FOD - **0.16.15**. + **1.0.2**. - Evidence: [research/stalwart-0.16.15-stores-evidence.md](research/stalwart-0.16.15-stores-evidence.md). **Still open:** binary FOD until source build, vs require source build before diff --git a/docs/operator-direction.md b/docs/operator-direction.md index d8b080e..852568a 100644 --- a/docs/operator-direction.md +++ b/docs/operator-direction.md @@ -32,6 +32,21 @@ A peer is collaborating. Agents must not compact this away as "noted." Standing copies: [../AGENTS.md](../AGENTS.md), [COMPACTION-PIN.md](COMPACTION-PIN.md). +## What stays off, and the builder stays on (operator 2026-11-03) + +Operator answers while reviewing the service list. These override a +public-module default when `just deploy` copies the private overlay +from horizon onto surmount-1. + +1. surmount-1 stays a Nix builder and a Lean builder. `surmount-scram.service` stays. On 2026-11-04 the operator directed this repo to pin Lean 4.35. The pin is the official release tag v4.35.0 in `nix/packages/lean4.nix`, used by the nixbuilder login profile. It is not nixpkgs `pkgs.lean4` (4.29.1 on nixos-26.05). The live switch was not run as part of that pin. +2. Vaultwarden stays off. The operator is not using it yet. +3. Splora mainnet stays off. The other Splora instances already enabled in the private overlay stay as they are. +4. Host renewal on surmount-1 is on. HTTP-01 on surmount-1 refreshes the Let's Encrypt production certificate that host already presents. horizon does not have to stay online for that renewal. Namecheap API credentials stay off surmount-1. No Namecheap ClientIp change. Closing horizon does not stop surmount-1 from serving the PEMs already on disk. +5. Restic backups stay off for now. +6. sshguard watches sshd. A ban is permanent after 3 failures. fail2ban is not installed. The management UI nftables ban helper stays off. +7. Say horizon or surmount-1. The private overlay directory is not a machine name. +8. `just deploy` keeps surmount-1 as the remote Nix builder. The private overlay on horizon already sets `surmount.remoteBuilder.enable = true`. The public module default of false applies only when that file is absent. + --- ## 1. Host @@ -168,7 +183,7 @@ Research: [research/pqconnect-and-pqc.md](research/pqconnect-and-pqc.md) | Ban | Any unauthorized access of any kind -> **immediate blacklist** on the box (precise signal classes still open; see research) | | Whitelist | Explicit whitelist IPs: never blacklisted; may bypass blacklist | | Hygiene | Track **last-used** on whitelist entries | -| Implementation lean | Prefer nftables + custom Rust long-term; fail2ban is Python transitional sketch only | +| Implementation lean | Prefer nftables + custom Rust long-term. sshguard is the sshd ban, permanent after 3 failures. fail2ban is not installed. | Research: [research/access-control-fail2ban.md](research/access-control-fail2ban.md). Module pointer: `modules/hardening.nix` header. @@ -450,6 +465,36 @@ Living maps: [DNS.md](DNS.md), [../AGENTS.md](../AGENTS.md), --- +## 14. Follow-up (2026-11-07): existing tools, and the whole dry-run + +When a Python program blocks the mail-host build, the replacement is an +existing Rust project. Prefer Rust over C. An existing C project is only +the fallback when no Rust project does that job. Do not write a new C +program to stand in for that Python. The C files added under +`nix/packages/` during the 2026-11-07 dry-run loop are not that +replacement. + +Jinja templates have existing Rust renderers. MiniJinja is one +(https://github.com/mitsuhiko/minijinja, accessed: 2026-11-07). Do not +reject a Rust renderer because it is not a line-for-line copy of the +Python script, and do not write a C renderer in its place. + +One `just deploy-dry-run` transcript is the full failure list. Fix every +builder-failed root from that transcript before the next dry-run. Do not +fix one root and rerun while other roots from the same transcript remain. + +Dual-pin [../AGENTS.md](../AGENTS.md) and [COMPACTION-PIN.md](COMPACTION-PIN.md). + +Operator 2026-11-07, later the same day, always remember. Handwritten C +is forbidden in this repo. That includes a `.c` or `.h` file, and C +embedded in Nix, bash, a patch, or any other file that emits a C +program at build time. The operator cannot audit that code. Removing +Python is to shrink the attack surface. Handwritten C is the same +class of unauditable code. Upstream projects that are written in C may +remain as dependencies. Do not replace removed C with new C. Remove +every `.c` and `.h` file we added. The earlier sentence that an +existing C project is a fallback does not allow handwritten C. + ## Related updates Living docs updated to match this direction (same turn as this file and the diff --git a/docs/packages-and-forks.md b/docs/packages-and-forks.md index 7e98c0c..58233da 100644 --- a/docs/packages-and-forks.md +++ b/docs/packages-and-forks.md @@ -4,7 +4,7 @@ Plain English. No assumption that the reader already knows Fix ladder codes. Ladder detail still lives in [fix-and-fixos.md](fix-and-fixos.md) if you want depth. -**Last updated:** 2026-10-18 (nixpkgs-rust is commit `9ecc0a672e9ef2ca4eec8e87dbee653607a7d332`, rustc 1.99.1, `pkgs.arti` 2.7.0. grok-oss is commit pin `182293cb30fa5414dd47686b92708103519b7462`. Crane stayed `bd63bf1d0a0f6dcacd7549c714a671ee63b0db6a`. sops-nix stayed `44931708c62937c2b1bf7953902989fa5bb9ee1b`. tau stayed `d2e1955b0491f538a486007d0d3ab779d95a828f`. The package build uses `surmount-rust-1.99.1`.) Prior 2026-10-18 (flake input tau is the operator-synced repo SurmountSystems/tau at rev d2e1955b0491f538a486007d0d3ab779d95a828f, and surmount.tau.enable defaults false.) Prior 2026-10-17 (nixpkgs-rust moved to nixpkgs master `c46e6c9a80d68a8b92e75a2d2cb078a8f6d8dd81`, rustc 1.99.0, `pkgs.arti` 2.7.0. Crane moved to `bd63bf1d0a0f6dcacd7549c714a671ee63b0db6a`, one internal flake.lock commit after `47b6b27`. sops-nix stayed `44931708`. grok-oss is commit pin `decb052744d25b63dbca8a95ca65b4ad794b9ad5` on `github:SurmountSystems/grok-oss`, not floating `main`. nixpkgs nixos-26.05 stayed `26f4107e47e3ad0ffd0db220469ba83b6f6d96e8`. splora stayed `5164864cb7349fabc961b5e6c9da2829106bff5f`. advisory-db stayed `999ec89d5d3f10f20be14750ec8c2d8b44e0ac9b`. surmount-site stayed `82e07195e6aa221efe838e652ed8d2d8535b5cac`. Stalwart server 0.16.26, cli 1.0.13, webui 1.0.11, spam-filter 3.0.2, and vandelay 1.0.12 were still the latest GitHub tags. The package build still uses `surmount-rust-1.99.0`.) Prior 2026-10-17 (nixpkgs-rust `93b590222c0327b3d952e8e30905e2bd5fed3293`, rustc 1.99.0, `pkgs.arti` 2.7.0. Stalwart server 0.16.26, vandelay 1.0.12. sops-nix `44931708`. Crane stayed `47b6b27`. grok-oss is commit pin `decb052744d25b63dbca8a95ca65b4ad794b9ad5`, crate version still 1.0.45 in `xai-grok-version`. nixpkgs nixos-26.05 is `26f4107e47e3ad0ffd0db220469ba83b6f6d96e8`. splora is `5164864cb7349fabc961b5e6c9da2829106bff5f`. advisory-db is `999ec89d5d3f10f20be14750ec8c2d8b44e0ac9b`. surmount-site stayed `82e07195e6aa221efe838e652ed8d2d8535b5cac`. The package build still uses `surmount-rust-1.99.0`.) Prior 2026-10-14 (grok-oss `main` moved to `b7ab91532c9e8008081b4f281794be43ecae73a6`, GitHub pull 167, crate version 1.0.45 in `xai-grok-version`. The package build still uses `surmount-rust-1.99.0`.) Prior 2026-10-10 (nixpkgs-rust moved off nixos-unstable to `c9f19b6be67332ce2ee7520c1630c804685df838`, rustc 1.99.0, `pkgs.arti` still 2.6.0. Host nixos-26.05 `pkgs.rustc` is still 1.95.0. grok-oss was `github:SurmountSystems/grok-oss/main` at `d1d3b7377e85c760caf17b74cf6d319301a77d1e`. Crane stayed `47b6b27ed9a3a9181415e4367d0c30ab2a0e0250`.) Prior 2026-10-03 (flake lock moved: nixpkgs nixos-26.05 `774debe7a0d1b496e35677ad955a1011c6ff74f3`, nixpkgs-rust `c59305bab2065cfecc4944690d9eedbb56f3a9fa` with rustc 1.98.1 and `pkgs.arti` 2.6.0, advisory-db `ef6173cbc5c50ec8166f9a5b28f07834144373ee`, splora `32f52e8da3ae860a36d39bce334e80e7e624d5a8`, crane `47b6b27ed9a3a9181415e4367d0c30ab2a0e0250`, sops-nix `5efb5a6f4f5ab192817d28557dd4d650fa14d866`. surmount-site stayed `82e07195e6aa221efe838e652ed8d2d8535b5cac`. grok-oss stayed `6edf5fda9507c9e3c9c9f5a67871ebb3ce6cca7d` because branch `remote-1` is gone. Stalwart FODs are server 0.16.24, cli 1.0.13, webui 1.0.11, spam-filter 3.0.2, and vandelay 1.0.11. The Arti package stayed 2.6.0.) Prior 2026-09-19 (`surmount.sploraIndexer` is the host-local single-knob wrap over imported `nixosModules.splora`; flake input `splora` on the `surmount` branch, then locked rev `be3603dbd8e6ef0c24e37fe07beaf8067bfa2d0b`; prior lock `22d6dcf7f76c1cacc23220d80203099018cde3aa`; instance options already include cookieFile, daemonRpcAddr, jsonrpcImport, daemonDir = null, publicHealth, db cache 24, httpSocketFile default `/run/splora/${name}.http.sock`; this repo does not wrap crane src). Prior 2026-09-01 (this tree briefly wrapped crane src; that wrap is deleted). Prior 2026-09-01 (flake input `splora` from `github:SurmountSystems/splora` on the `surmount` branch; not in-tree). Prior 2026-08-27 (workspace clippy style allows in `crates/Cargo.toml`; flake clippy is `-D warnings` only. Overlay/packages/`just` still `nix run` only.) +**Last updated:** 2026-10-23 (grok-oss and tau are not mail-host packages. The flake does not take those inputs and does not wire `packages.grok-oss` or `packages.tau`. Default mail-vps does not enable `surmount.grokOss` or `surmount.tau`. Host-local must not re-enable them. nixpkgs-rust stayed `9ecc0a672e9ef2ca4eec8e87dbee653607a7d332`, rustc 1.99.1. Host channel stayed nixos-26.05. Wrapped `speedtest` is `pkgs.ookla-speedtest` plus license/GDPR flags. `scc` is the nixpkgs package. `fio` and `sysstat` are nixpkgs packages on the mail-vps PATH.) Prior 2026-10-20 (grok-oss is commit pin `6703706cc7419ba71af549c1562017f302173c19`. nixpkgs-rust stayed `9ecc0a672e9ef2ca4eec8e87dbee653607a7d332`, rustc 1.99.1. Host channel stayed nixos-26.05. Wrapped `speedtest` is `pkgs.ookla-speedtest` plus license/GDPR flags. `scc` is the nixpkgs package.) Prior 2026-10-19 (grok-oss is commit pin `525538172dc21391bc690a4cc4a8706a5c554e47`. nixpkgs-rust stayed `9ecc0a672e9ef2ca4eec8e87dbee653607a7d332`, rustc 1.99.1. Crane stayed `bd63bf1d0a0f6dcacd7549c714a671ee63b0db6a`. sops-nix stayed `44931708c62937c2b1bf7953902989fa5bb9ee1b`. tau stayed `d2e1955b0491f538a486007d0d3ab779d95a828f`. Host channel stayed nixos-26.05. The package build uses `surmount-rust-1.99.1`.) Prior 2026-10-18 (nixpkgs-rust is commit `9ecc0a672e9ef2ca4eec8e87dbee653607a7d332`, rustc 1.99.1, `pkgs.arti` 2.7.0. grok-oss is commit pin `182293cb30fa5414dd47686b92708103519b7462`. Crane stayed `bd63bf1d0a0f6dcacd7549c714a671ee63b0db6a`. sops-nix stayed `44931708c62937c2b1bf7953902989fa5bb9ee1b`. tau stayed `d2e1955b0491f538a486007d0d3ab779d95a828f`. The package build uses `surmount-rust-1.99.1`.) Prior 2026-10-18 (flake input tau is the operator-synced repo SurmountSystems/tau at rev d2e1955b0491f538a486007d0d3ab779d95a828f, and surmount.tau.enable defaults false.) Prior 2026-10-17 (nixpkgs-rust moved to nixpkgs master `c46e6c9a80d68a8b92e75a2d2cb078a8f6d8dd81`, rustc 1.99.0, `pkgs.arti` 2.7.0. Crane moved to `bd63bf1d0a0f6dcacd7549c714a671ee63b0db6a`, one internal flake.lock commit after `47b6b27`. sops-nix stayed `44931708`. grok-oss is commit pin `decb052744d25b63dbca8a95ca65b4ad794b9ad5` on `github:SurmountSystems/grok-oss`, not floating `main`. nixpkgs nixos-26.05 stayed `26f4107e47e3ad0ffd0db220469ba83b6f6d96e8`. splora stayed `5164864cb7349fabc961b5e6c9da2829106bff5f`. advisory-db stayed `999ec89d5d3f10f20be14750ec8c2d8b44e0ac9b`. surmount-site stayed `82e07195e6aa221efe838e652ed8d2d8535b5cac`. Stalwart server 0.16.26, cli 1.0.13, webui 1.0.11, spam-filter 3.0.2, and vandelay 1.0.12 were still the latest GitHub tags. The package build still uses `surmount-rust-1.99.0`.) Prior 2026-10-17 (nixpkgs-rust `93b590222c0327b3d952e8e30905e2bd5fed3293`, rustc 1.99.0, `pkgs.arti` 2.7.0. Stalwart server 0.16.26, vandelay 1.0.12. sops-nix `44931708`. Crane stayed `47b6b27`. grok-oss is commit pin `decb052744d25b63dbca8a95ca65b4ad794b9ad5`, crate version still 1.0.45 in `xai-grok-version`. nixpkgs nixos-26.05 is `26f4107e47e3ad0ffd0db220469ba83b6f6d96e8`. splora is `5164864cb7349fabc961b5e6c9da2829106bff5f`. advisory-db is `999ec89d5d3f10f20be14750ec8c2d8b44e0ac9b`. surmount-site stayed `82e07195e6aa221efe838e652ed8d2d8535b5cac`. The package build still uses `surmount-rust-1.99.0`.) Prior 2026-10-14 (grok-oss `main` moved to `b7ab91532c9e8008081b4f281794be43ecae73a6`, GitHub pull 167, crate version 1.0.45 in `xai-grok-version`. The package build still uses `surmount-rust-1.99.0`.) Prior 2026-10-10 (nixpkgs-rust moved off nixos-unstable to `c9f19b6be67332ce2ee7520c1630c804685df838`, rustc 1.99.0, `pkgs.arti` still 2.6.0. Host nixos-26.05 `pkgs.rustc` is still 1.95.0. grok-oss was `github:SurmountSystems/grok-oss/main` at `d1d3b7377e85c760caf17b74cf6d319301a77d1e`. Crane stayed `47b6b27ed9a3a9181415e4367d0c30ab2a0e0250`.) Prior 2026-10-03 (flake lock moved: nixpkgs nixos-26.05 `774debe7a0d1b496e35677ad955a1011c6ff74f3`, nixpkgs-rust `c59305bab2065cfecc4944690d9eedbb56f3a9fa` with rustc 1.98.1 and `pkgs.arti` 2.6.0, advisory-db `ef6173cbc5c50ec8166f9a5b28f07834144373ee`, splora `32f52e8da3ae860a36d39bce334e80e7e624d5a8`, crane `47b6b27ed9a3a9181415e4367d0c30ab2a0e0250`, sops-nix `5efb5a6f4f5ab192817d28557dd4d650fa14d866`. surmount-site stayed `82e07195e6aa221efe838e652ed8d2d8535b5cac`. grok-oss stayed `6edf5fda9507c9e3c9c9f5a67871ebb3ce6cca7d` because branch `remote-1` is gone. Stalwart FODs are server 0.16.24, cli 1.0.13, webui 1.0.11, spam-filter 3.0.2, and vandelay 1.0.11. The Arti package stayed 2.6.0.) Prior 2026-09-19 (`surmount.sploraIndexer` is the host-local single-knob wrap over imported `nixosModules.splora`; flake input `splora` on the `surmount` branch, then locked rev `be3603dbd8e6ef0c24e37fe07beaf8067bfa2d0b`; prior lock `22d6dcf7f76c1cacc23220d80203099018cde3aa`; instance options already include cookieFile, daemonRpcAddr, jsonrpcImport, daemonDir = null, publicHealth, db cache 24, httpSocketFile default `/run/splora/${name}.http.sock`; this repo does not wrap crane src). Prior 2026-09-01 (this tree briefly wrapped crane src; that wrap is deleted). Prior 2026-09-01 (flake input `splora` from `github:SurmountSystems/splora` on the `surmount` branch; not in-tree). Prior 2026-08-27 (workspace clippy style allows in `crates/Cargo.toml`; flake clippy is `-D warnings` only. Overlay/packages/`just` still `nix run` only.) **Operator direction:** [operator-direction.md](operator-direction.md) --- @@ -46,11 +46,12 @@ package bumps in this repo. | `nix/packages/stalwart-spam-filter.nix` | Spam rules FODs | | `nix/packages/management-ui.nix` | Surmount Axum UI (crane) | | `nix/packages/surmount-public-site.nix` | Apex/www static site from flake input `github:SurmountSystems/site` (no NPM; operator bumps rev) | -| `nix/packages/arti-onion-service.nix` | Surmount-owned Arti **2.7.0** source build + `onion-service-service` (HS publish); distinct from stock `pkgs.arti`. Kept equal to nixpkgs-rust `pkgs.arti.version` | +| `nix/packages/arti-onion-service.nix` | Surmount-owned Arti **2.8.0** source build + `onion-service-service` (HS publish); distinct from stock `pkgs.arti`. `cargoHash` is set to sha256-grgIqU1gw1UqeGgR4aR7cAOxBW91F12bHMgOj4pgLcY=. Does not reuse nixpkgs-rust `pkgs.arti` **2.7.0** `cargoDeps` | | `nix/packages/surmount-private-data.nix` | Private-data pattern scanner (crane). `nix run .#surmount-private-data`. Pre-commit + `just check-private-data` / `just private-data`. | | `nix/packages/surmount-host-logs.nix` | Host journal status/follow (crane). `nix run .#surmount-host-logs`. `just host-logs`. Journald stays source of truth. | | `nix/packages/surmount-shc.nix` | SHC customer user-api client (crane; rDNS PTR + tickets). `nix run .#surmount-shc`. `just rdns-shc`. No python3. | | `nix/packages/surmount-niced-builder.nix` | Niced ssh-ng nix-daemon helper (crane). `nix run .#surmount-niced-builder`. Installed as `/etc/surmount/niced-builder` when remote builder is on. MemoryMax stays on the NixOS module. Not a mail wrapper. | +| `nix/packages/lean4.nix` | Official Lean **4.35.0** linux x86_64 release (tag `v4.35.0`). `nix run` is not the builder path. `modules/remote-builder.nix` puts `lean`, `lake`, `leanc`, and `leanmake` on the nixbuilder login PATH from this pin. Not channel `pkgs.lean4` (4.29.1). | | `nix/packages/surmount-leftover-homes.nix` | Refuse leftover repo `.agents` / `.grok` homes (crane). `nix run .#surmount-leftover-homes`. Fixtures live under `crates/surmount-leftover-homes/testdata/`. | | `nix/packages/surmount-host-probe.nix` | inxi (no TTY), btop (TTY), hybrid TLS probe, Eternal Terminal client. `nix run .#surmount-inxi-host` / `.#surmount-btop-host` / `.#surmount-tls-hybrid` / `.#surmount-et`. | | `nix/packages/surmount-static-sites.nix` | Apex/www publish + proven DS3018xs extra vhosts. `nix run .#surmount-deploy-static-sites` / `.#surmount-sync-static-sites`. | @@ -64,8 +65,8 @@ package bumps in this repo. | `nix/packages/surmount-mail-import.nix` | Maildir++ import via Vandelay (crane). `nix run .#surmount-mail-import-maildir`. | | `nix/packages/surmount-secrets-install.nix` | Domain A to Domain B install + Vaultwarden export-to-staging (crane). `nix run .#secrets-install-host` / `.#secrets-export-bw-to-staging`. | | `nix/packages/surmount-secrets-prompt.nix` | Domain A no-echo intake (crane). `nix run .#surmount-secrets-prompt`. `just secrets-prompt`. | -| `nix/packages/surmount-rekey.nix` | SHC backup re-key paste (age crate + gpg wrap). `nix run .#surmount-rekey`. `just rekey`. Prints `age1...` for type PGP. Never prints the age secret. | -| `nix/overlays.nix` | Extra pins (flake `surmountOverlay` is primary) | +| `nix/packages/surmount-rekey.nix` | SHC backup re-key paste (X25519 age identity + gpg wrap, not the age crate). `nix run .#surmount-rekey`. `just rekey`. Prints `age1...` for type PGP. Never prints the age secret. | +| `nix/overlays.nix` | Extra pins (flake `surmountOverlay` is primary). tzdata 2026e sets `doCheck = false` (same change as NixOS/nixpkgs pull 582069). Nix package pin is **2.35.4** (commit `21286d605815196c3fff1960550ab08700fc4e6f`) on the nixos-26.05 2.35.3 recipe. Channel `pkgs.nix` was stable **2.34.9**. NixOS stays **nixos-26.05** because 26.11 is not released. Release notes still read 2026.11/??. Branch-off is scheduled 2026-11-16 to 2026-11-23. Release is scheduled 2026-11-23 to 2026-11-30. See [NixOS 26.11 release schedule](https://github.com/NixOS/nixpkgs/issues/567567) (accessed: 2026-11-05). | | `modules/stalwart-service.nix` | 0.16+ `config.json` service (disables both stock paths: `services/mail/stalwart-mail.nix` and `services/mail/stalwart.nix`; option `services.stalwart`; unit stays `stalwart-mail.service`) | | `modules/*.nix` | Surmount NixOS modules | | `flake.nix` / `flake.lock` | Inputs, checks, host entrypoints | @@ -86,7 +87,14 @@ HTTP/3 stay on this-tree Axum. Do not start five indexers. Do not set `services.splora.enable` or `surmount.sploraIndexer.enable` on the public sample host. -Flake input tau is the operator-synced repo SurmountSystems/tau at rev d2e1955b0491f538a486007d0d3ab779d95a828f, and surmount.tau.enable defaults false. +Tau is not a mail-host package. This flake does not take a tau input +and does not overlay `pkgs.tau`. `surmount.tau.enable` defaults false +and eval fails if it is true. Do not re-enable from host-local. + +Grok OSS is not a mail-host package. This flake does not take a +grok-oss input and does not overlay `pkgs.grok-oss`. +`surmount.grokOss.enable` defaults false and eval fails if it is true. +Do not re-enable from host-local. ### Upstream crane omits Menhera config from Nix src (Splora) @@ -126,19 +134,18 @@ thin just alias. **Arti note:** stock nixpkgs `pkgs.arti` often lags (client-default, not HS-capable). Surmount owns the Arti pin at -`nix/packages/arti-onion-service.nix` (`2.7.0` from GitLab `arti-v2.7.0`). -That version is this lock's nixpkgs-rust `pkgs.arti.version` (rev -`9ecc0a672e9ef2ca4eec8e87dbee653607a7d332`, rustc 1.99.1). The package enables -cargo feature `onion-service-service` and is exposed as +`nix/packages/arti-onion-service.nix` (`2.8.0` from GitLab `arti-v2.8.0`). +nixpkgs-rust `pkgs.arti.version` on rev +`9ecc0a672e9ef2ca4eec8e87dbee653607a7d332` is still **2.7.0** (rustc 1.99.1). +The package does not use that `cargoDeps`. `cargoHash` for 2.8.0 is set to sha256-grgIqU1gw1UqeGgR4aR7cAOxBW91F12bHMgOj4pgLcY=. +The package enables cargo feature `onion-service-service` and is exposed as `pkgs.artiOnionService` / `packages.*.arti-onion-service` (it does **not** replace `pkgs.arti`). rustc comes from flake input `nixpkgs-rust` when Arti MSRV exceeds the host -channel pin. Vendor crates use matching `nixpkgs-rust` `arti.cargoDeps` -with a version assert (crates.io `fetch-cargo-vendor` 403 workaround); bump -`nixpkgs-rust` until `pkgs.arti.version` matches, or restore plain `cargoHash` -when crates.io vendor works again. Module `arti-hidden-service.nix` prefers +channel pin. Module `arti-hidden-service.nix` prefers the Surmount package when `package` is null. Cargo build is heavy and is -**not** in `checks.*.ci`; eval-only version/feature/passthru contract is. +**not** in `checks.*.ci`; the eval contract asserts version 2.8.0 and does +not embed a fake hash. Live Tor publish remains residual (operator HS keys + network verify). No official multi-arch Arti release binaries, so this is source build not binary FOD. @@ -291,6 +298,24 @@ Stalwart binaries. ## 6. Version validation +### tzdata 2026e `now.ck` (2026-11-04) + +Locked nixpkgs is `github:NixOS/nixpkgs/nixos-26.05` rev +`7dcdd87373d72e7a2173e4db02e20d3d7f495cfd`. That rev builds tzdata +2026e with `doCheck = true`. On or after 2026-11-01 the `now.ck` test +fails (`make: *** [Makefile:1006: now.ck] Error 1`) because +America/Phoenix and America/Vancouver are identical from now on. The +same check also names America/Mexico_City with America/Edmonton, and +America/Jamaica with America/Winnipeg. That is a date-dependent check, +not a gcc failure and not an x86-64-v3 failure. The failing step is +`awk -f checknow.awk zonenow.tab` (make target `now.ck`). `nix/overlays.nix` +copies [NixOS/nixpkgs pull 582069](https://github.com/NixOS/nixpkgs/pull/582069) +(accessed: 2026-11-04) until this lock contains that change. The overlay +sets `doCheck = false` with the same IANA thread comment as the pull. +The flake applies that file beside +`surmountOverlay` for `mkPkgs` and for `nixosConfigurations.mail-vps`. +`gcc.arch` and x86-64-v3 stay as they are. `surmount-remote` stays. + When touching package pins: 1. Check upstream latest stable/tag for that component. diff --git a/docs/research/rust-edge-and-uds.md b/docs/research/rust-edge-and-uds.md index fcabd38..af7c297 100644 --- a/docs/research/rust-edge-and-uds.md +++ b/docs/research/rust-edge-and-uds.md @@ -168,7 +168,7 @@ Legend: **Y** yes / **P** partial / **N** no or not serious yet / **?** unverifi - Not a decade of reverse-proxy corner cases (chunked edge cases, weird clients). For a few vhosts this is usually fine; do not claim "nginx parity." - Need explicit design for: graceful reload, log format, metrics, static sites, - optional `/stalwart-admin/` path proxy. + optional `/stalwart-admin/` path proxy (removed from modules/web.nix on 2026-11-02; SSH tunnel to loopback Stalwart HTTP is the bootstrap). - No upstream NixOS module to lean on. **Verdict:** best **pragmatic near-term Rust-first** path for Surmount if the @@ -452,7 +452,7 @@ options shape. 3. tower middleware: body limit, timeout, **governor** per IP. 4. Reverse proxy: - `/` on `services.` hostname -> **UDS** management-ui - - optional `/stalwart-admin/` -> Stalwart **127.0.0.1:8080** + - optional `/stalwart-admin/` -> Stalwart **127.0.0.1:8080** (removed from modules/web.nix on 2026-11-02) 5. Redirects for apex/www/mail hostname landing (mirror current nginx vhosts). 6. Static file root hook for legacy sites (even if empty at first). 7. NixOS module replaces `services.nginx` in `modules/web.nix`. @@ -494,7 +494,7 @@ VM test; then delete nginx config paths. 3. Edge binary + module flag `surmount.web.backend = "nginx" | "rust-edge"`. 4. Staging rebuild; validate HTTP-01/ALPN renew. 5. Remove nginx; update EDGE_AND_TLS, STACK, options, architecture-review. -6. Harden rate limits; drop optional stalwart-admin path when product UI covers +6. Harden rate limits. The public stalwart-admin path was removed from modules/web.nix on 2026-11-02. ops. --- diff --git a/docs/research/scaffold-assumptions-inventory.md b/docs/research/scaffold-assumptions-inventory.md index 62c2fb0..21cd0d6 100644 --- a/docs/research/scaffold-assumptions-inventory.md +++ b/docs/research/scaffold-assumptions-inventory.md @@ -335,7 +335,7 @@ ports; docs lag is called out per row where it matters. - **Claim the tree currently implies:** Management UI listens **127.0.0.1:8090** (default). Stalwart 0.16 first-boot HTTP management is **:8080** (upstream safe defaults; may bind all interfaces). nginx proxies - `/` -> UI :8090 and `/stalwart-admin/` -> `127.0.0.1:8080`. UI env + `/` -> UI :8090. The public `/stalwart-admin/` proxy was removed from modules/web.nix on 2026-11-02. UI env `SURMOUNT_STALWART_URL=http://127.0.0.1:8080`. Living OPS/MIGRATION/ SEARCH_AND_UI mopped to that story (2026-08-07). Some joins may still lag. - **Where it lives:** `modules/options.nix` (port 8090), @@ -407,13 +407,14 @@ ports; docs lag is called out per row where it matters. ## 16. Spam-filter (and WebUI) FODs not auto-applied -- **Claim the tree currently implies:** spam-filter **3.0.0** and webui - **1.0.7** are fetched as FODs and installed under - `/etc/surmount/stalwart/` (`spam-filter.toml`, - `spam-filter-rules.json.gz`, `webui.zip`). They are **not** automatically - pointed into the running engine. 0.16 first-boot may still try GitHub for - resources until operator WebUI or `stalwart-cli apply` sets hermetic - `file://` URLs. Spam-filter TOML is **anti-spam config**, not the message DB. +- **Claim the tree currently implies:** spam-filter **3.0.4** and webui + **2.0.4** are fetched as FODs and installed under + `/etc/surmount/stalwart/` (`spam-filter-rules.json.gz`, `webui.zip`). + v3.0.3 dropped `spam-filter.toml` from the release. They are **not** + automatically pointed into the running engine. First boot may still try + GitHub for resources until operator WebUI or `stalwart-cli apply` sets + hermetic `file://` URLs. The rules archive is **anti-spam config**, not + the message DB. - **Where it lives:** `nix/packages/stalwart-spam-filter.nix`, `stalwart-webui.nix`, `modules/mail.nix` (`environment.etc`), `.grok/joins/stalwart-current.md`, `docs/DATASTORES.md` section 5.4 @@ -626,8 +627,8 @@ ports; docs lag is called out per row where it matters. ## 27. Doc and join lag (meta-assumption risk) - **Claim (living modules):** Stalwart HTTP management **:8080**, Surmount UI - default **:8090**, engine **0.16.15** config.json via `services.stalwart`, - spam-filter **3.0.0** FODs under `/etc/surmount/stalwart/`, stock modules + default **:8090**, engine **1.0.2** config.json via `services.stalwart`, + spam-filter **3.0.4** rules archive under `/etc/surmount/stalwart/`, stock modules dual-disabled. - **Where lag lived:** older STACK/MIGRATION/OPS/SEARCH port rows, DATASTORES 5.2 TOML eval, foundation join. Mop pass 2026-08-07 fixed living MIGRATION / diff --git a/docs/research/version-audit.md b/docs/research/version-audit.md index e33c6a4..56467ad 100644 --- a/docs/research/version-audit.md +++ b/docs/research/version-audit.md @@ -24,11 +24,13 @@ blob `bf8ac6194a0ef4befa6bb3204caea46800b20a31` is the same file as prior pin `93b590222` and as prior nixpkgs-rust lock `c46e6c9a`. An update does not replace that package. `doCheck` stays `stdenv.hostPlatform.isLinux`. This pass did not build sqlite. -**Arti HS package refresh:** 2026-10-17 (UTC). Surmount `arti-onion-service` -pin is **2.7.0** (GitLab tag `arti-v2.7.0`, 2026-10-01). It matches this -nixpkgs-rust snapshot's `pkgs.arti.version` **2.7.0**. MSRV is 1.92. -High-severity TROVE-2026-047 (half-open streams) is fixed in this tag. -`onion_services`, `proxy_ports`, and `socks_listen` stay the module keys. +**Arti HS package refresh:** 2026-11-05 (UTC). Surmount `arti-onion-service` +pin is **2.8.0** (GitLab tag `arti-v2.8.0`, changelog date 3 November 2026). +`cargoHash` is set to sha256-grgIqU1gw1UqeGgR4aR7cAOxBW91F12bHMgOj4pgLcY=. It does not reuse this +nixpkgs-rust snapshot's `pkgs.arti` **2.7.0** `cargoDeps`. The arti crate +`rust-version` in that tag is 1.92. The 2026-10-17 pin was **2.7.0** +(GitLab `arti-v2.7.0`). `onion_services`, `proxy_ports`, and `socks_listen` +stay the module keys. **Prior full package audit:** 2026-07-31 (UTC). Stalwart FOD / cargo / upstream "latest" cells below stay labeled as that audit day unless revalidated. @@ -67,12 +69,13 @@ date in that row** until revalidated. | Host nixpkgs channel | **Living:** `nixos-26.05` @ `26f4107e47e3ad0ffd0db220469ba83b6f6d96e8` (`flake.lock` 2026-10-17, commit date 2026-10-15). Sample `system.stateVersion = "26.05"`. Prior lock `445d861c` was the 2026-08-07 note. | | Crane / management-ui rustc | **Living:** **1.99.1** from `nixpkgs-rust` `pkgs.rustc` (`nix/rust-toolchain.nix`; lock `9ecc0a672e9ef2ca4eec8e87dbee653607a7d332`, checked 2026-10-18, `nix eval --raw` printed `1.99.1`). Not nixpkgs master (master HEAD `27347cc2965498fa03393739031c9ef8ecd72c0a` still has `rustcVersion = "1.99.0"`). Not `rustPackages_1_95`. Not beta 1.100. Host nixos-26.05 `pkgs.rustc` is still **1.95.0**. grok-oss uses the same toolchain. Leptos MSRV floor remains **>= 1.88**. Prior lock `c46e6c9a` was rustc 1.99.0. | | Stock Stalwart modules | **Living:** dual `disabledModules` (`services/mail/stalwart-mail.nix` + `services/mail/stalwart.nix`); Surmount option path `services.stalwart` | -| Stalwart server FOD | **0.16.26** Surmount pin (not channel package). Upstream latest re-check **2026-10-17**: v0.16.26 (2026-10-11). Prior 2026-07-31 cell was 0.16.15. | -| stalwart-cli FOD | **1.0.13** (re-check 2026-10-17: at latest, tag 2026-09-28) | -| WebUI FOD | **1.0.11** (re-check 2026-10-17: at latest, tag 2026-09-15) | -| spam-filter FOD | **3.0.2** (re-check 2026-10-17: at latest, tag 2026-09-22) | -| vandelay FOD | **1.0.12** (re-check 2026-10-17: at latest, tag 2026-10-11). Prior tree pin was 1.0.11. | -| Arti (Surmount HS package) | Surmount-owned source build **2.7.0** (GitLab `arti-v2.7.0`; `arti-onion-service.nix`; checked 2026-10-17). nixpkgs-rust `pkgs.arti.version` is **2.7.0**. Stock host `pkgs.arti` is channel-lagged client-default (not the HS path). | +| Stalwart server FOD | **1.0.2** Surmount pin (not channel package). Upstream latest re-check **2026-11-03**: v1.0.2 (published 2026-11-02T15:42:44Z, not a prerelease). Prior 2026-10-17 cell was 0.16.26. Prior 2026-07-31 cell was 0.16.15. | +| stalwart-cli FOD | **1.0.14** (re-check 2026-11-03: at latest, published 2026-10-25T18:15:42Z). Prior 2026-10-17 cell was 1.0.13. | +| WebUI FOD | **2.0.4** (re-check 2026-11-03: at latest, published 2026-11-02T11:19:17Z). Prior 2026-10-17 cell was 1.0.11. | +| spam-filter FOD | **3.0.4** (re-check 2026-11-03: at latest, published 2026-11-01T15:50:40Z). Rules archive only. Prior 2026-10-17 cell was 3.0.2, which still shipped `spam-filter.toml`. | +| vandelay FOD | **1.0.14** (re-check 2026-11-03: at latest, published 2026-11-01T16:43:01Z). Prior 2026-10-17 cell was 1.0.12. | +| Arti (Surmount HS package) | Surmount-owned source build **2.8.0** (GitLab `arti-v2.8.0`; `arti-onion-service.nix`; checked 2026-11-05). `cargoHash` is set to sha256-grgIqU1gw1UqeGgR4aR7cAOxBW91F12bHMgOj4pgLcY=. nixpkgs-rust `pkgs.arti.version` is still **2.7.0** and is not `cargoDeps`. Stock host `pkgs.arti` is channel-lagged client-default (not the HS path). | +| Nix package | **2.35.4** from `nix/overlays.nix` on the nixos-26.05 `nixVersions.nix_2_35` recipe (that recipe was **2.35.3**). Channel `pkgs.nix` was `nixVersions.stable` **2.34.9**. Source commit `21286d605815196c3fff1960550ab08700fc4e6f`. No cargoHash in that recipe. NixOS stays **nixos-26.05** because 26.11 is not released. Release notes still read 2026.11/??. Branch-off is scheduled 2026-11-16 to 2026-11-23. Release is scheduled 2026-11-23 to 2026-11-30. See [NixOS 26.11 release schedule](https://github.com/NixOS/nixpkgs/issues/567567) (accessed: 2026-11-05). | | crane / sops-nix flake locks | Crane is `bd63bf1` (2026-10-11), one internal flake.lock commit after `47b6b27`, newer than release v0.24.0. sops-nix is `44931708` (2026-10-15), still master. Re-checked 2026-10-17. | | management-ui Cargo.lock | 2026-10-17: reqwest ceiling is **0.13**, tower-http ceiling is **0.7**, rustls is **0.23.46** (git patch; menhera 10d still lists 0.23.45), nostr stays **0.45.5**, rcgen ceiling is **0.14**. | | RocksDB | Embedded in upstream Stalwart lock (`10.4.2` via `librocksdb-sys`); Surmount does **not** use system `pkgs.rocksdb` for the mail binary | @@ -97,13 +100,13 @@ Not source-built (vendor 403 / rustc notes; see package comments and | Pin | Tree version | Where | Latest upstream | Gap? | Source | |-----|--------------|-------|-----------------|------|--------| -| Stalwart server | **0.16.26** | `nix/packages/stalwart-mail.nix` | **v0.16.26** (2026-10-11) | **no** (re-check 2026-10-17) | https://github.com/stalwartlabs/stalwart/releases/tag/v0.16.26 | -| stalwart-cli | **1.0.13** | `nix/packages/stalwart-cli.nix` | **v1.0.13** (2026-09-28) | **no** | https://github.com/stalwartlabs/cli/releases/tag/v1.0.13 | -| WebUI zip | **1.0.11** | `nix/packages/stalwart-webui.nix` | **v1.0.11** (2026-09-15) | **no** | https://github.com/stalwartlabs/webui/releases/tag/v1.0.11 | -| spam-filter | **3.0.2** | `nix/packages/stalwart-spam-filter.nix` | **v3.0.2** (2026-09-22) | **no** | https://github.com/stalwartlabs/spam-filter/releases/tag/v3.0.2 | -| vandelay | **1.0.12** | `nix/packages/vandelay.nix` | **v1.0.12** (2026-10-11) | **no** | https://github.com/stalwartlabs/vandelay/releases/tag/v1.0.12 | +| Stalwart server | **1.0.2** | `nix/packages/stalwart-mail.nix` | **v1.0.2** (2026-11-02T15:42:44Z) | **no** (re-check 2026-11-03) | https://github.com/stalwartlabs/stalwart/releases/tag/v1.0.2 | +| stalwart-cli | **1.0.14** | `nix/packages/stalwart-cli.nix` | **v1.0.14** (2026-10-25T18:15:42Z) | **no** | https://github.com/stalwartlabs/cli/releases/tag/v1.0.14 | +| WebUI zip | **2.0.4** | `nix/packages/stalwart-webui.nix` | **v2.0.4** (2026-11-02T11:19:17Z) | **no** | https://github.com/stalwartlabs/webui/releases/tag/v2.0.4 | +| spam-filter | **3.0.4** | `nix/packages/stalwart-spam-filter.nix` | **v3.0.4** (2026-11-01T15:50:40Z) | **no** | https://github.com/stalwartlabs/spam-filter/releases/tag/v3.0.4 | +| vandelay | **1.0.14** | `nix/packages/vandelay.nix` | **v1.0.14** (2026-11-01T16:43:01Z) | **no** | https://github.com/stalwartlabs/vandelay/releases/tag/v1.0.14 | -The 2026-07-31 cells were server 0.16.15, cli 1.0.12, WebUI 1.0.7, and spam-filter 3.0.0. Those are not the living pins. +The 2026-10-17 cells were server 0.16.26, cli 1.0.13, WebUI 1.0.11, spam-filter 3.0.2, and vandelay 1.0.12. The 2026-07-31 cells were server 0.16.15, cli 1.0.12, WebUI 1.0.7, and spam-filter 3.0.0. Those are not the living pins. The 2026-11-02 pass held cli, vandelay, and spam-filter at the older patch because they were still the same major. That hold is lifted. Each row above is the GitHub `releases/latest` tag read on 2026-11-03. ### Asset URL shapes (for future bumps) @@ -113,7 +116,7 @@ The 2026-07-31 cells were server 0.16.15, cli 1.0.12, WebUI 1.0.7, and spam-filt | server aarch64 | `.../stalwart-aarch64-unknown-linux-gnu.tar.gz` | | cli x86_64 | `https://github.com/stalwartlabs/cli/releases/download/vX.Y.Z/stalwart-cli-x86_64-unknown-linux-gnu.tar.xz` | | webui | `https://github.com/stalwartlabs/webui/releases/download/vX.Y.Z/webui.zip` | -| spam-filter | `.../spam-filter.toml` and `.../spam-filter-rules.json.gz` | +| spam-filter | `.../spam-filter-rules.json.gz` only (v3.0.3 and later). v3.0.2 was the last release that also published `spam-filter.toml` | Bump recipe is already in each package file header (`nix store prefetch-file`). @@ -133,6 +136,10 @@ engine; Surmount FODs win): Surmount's FODs are intentionally **ahead of** nixpkgs for the mail stack. Do not "sync down" to channel packages. +Those channel rows were measured 2026-07-30 against a Surmount pin of 0.16.15. +On 2026-11-03 the living Surmount server pin is 1.0.2. Do not read +"one patch behind Surmount" as a present-tense claim. + --- ## 2. Flake inputs (`flake.lock`) @@ -289,9 +296,9 @@ optional `blobSize` / `bufferSize`; it does not pin a system rocksdb package. ## 5b. Arti (HS publish package) Packaging mode: **Surmount-owned source build** of Arti from Tor -Project GitLab (`fetchFromGitLab` tag `arti-v2.7.0`), with cargo feature -`onion-service-service`. The package version is **2.7.0**. It is not 2.5.1 -and not 2.6.0. +Project GitLab (`fetchFromGitLab` tag `arti-v2.8.0`), with cargo feature +`onion-service-service`. The package version is **2.8.0**. It is not 2.5.1, +not 2.6.0, and not 2.7.0. `cargoHash` is set to sha256-grgIqU1gw1UqeGgR4aR7cAOxBW91F12bHMgOj4pgLcY=. Distinct attribute `pkgs.artiOnionService` / `packages.*.arti-onion-service`. Does **not** replace stock `pkgs.arti`. @@ -300,16 +307,16 @@ is a hermetic cargo source build, not a binary FOD. | Item | Value | Source | |------|-------|--------| -| Surmount package version | **2.7.0** | `nix/packages/arti-onion-service.nix` (checked 2026-10-17). | -| Source | GitLab `tpo/core/arti` tag **`arti-v2.7.0`** | package `src` | +| Surmount package version | **2.8.0** | `nix/packages/arti-onion-service.nix` (checked 2026-11-05). | +| Source | GitLab `tpo/core/arti` tag **`arti-v2.8.0`** | package `src` | | Cargo feature | **`onion-service-service`** (lean; not nixpkgs `full`) | package `buildFeatures` | | Capability passthru | `surmountOnionServiceCapable = true` | package `passthru` | | Toolchain | rustc from flake input **`nixpkgs-rust`** (pinned rev 9ecc0a67, rustc **1.99.1**; Arti MSRV **1.92**) | `flake.nix` `mkArtiRustPlatform` | -| Vendor | `cargoDeps` from matching nixpkgs-rust `arti` (crates.io 403 workaround) | package `artiUnstable.cargoDeps` | +| Vendor | `cargoHash` is set to sha256-grgIqU1gw1UqeGgR4aR7cAOxBW91F12bHMgOj4pgLcY=. Does not use nixpkgs-rust 2.7.0 `cargoDeps` | package `cargoHash` | | Stock nixpkgs `pkgs.arti` | channel-lagged client-default (COMPACTION-PIN: often **1.4.2**); not used for HS path | host channel, not this pin | -| Upstream GitLab tag | **`arti-v2.7.0`** (2026-10-01) | this package | -| nixpkgs-rust `pkgs.arti.version` | **2.7.0** | lock `9ecc0a672e9ef2ca4eec8e87dbee653607a7d332` (`nix eval --raw` printed `2.7.0`, 2026-10-18). Prior lock `c46e6c9a` was also 2.7.0. | -| Gap vs upstream tag | **closed** | pin equals `arti-v2.7.0` so `artiUnstable.version == version` stays true | +| Upstream GitLab tag | **`arti-v2.8.0`** (changelog date 3 November 2026) | this package | +| nixpkgs-rust `pkgs.arti.version` | **2.7.0** | lock `9ecc0a672e9ef2ca4eec8e87dbee653607a7d332` (`nix eval --raw` printed `2.7.0`, 2026-10-18). Prior lock `c46e6c9a` was also 2.7.0. Not the Surmount src. | +| Gap vs upstream tag | **src pinned.** `cargoHash` is set to sha256-grgIqU1gw1UqeGgR4aR7cAOxBW91F12bHMgOj4pgLcY= | src tag is `arti-v2.8.0`. `cargoHash` is set to sha256-grgIqU1gw1UqeGgR4aR7cAOxBW91F12bHMgOj4pgLcY=. Do not treat 2.7.0 `cargoDeps` as 2.8.0. | Honest limits: @@ -322,9 +329,10 @@ Honest limits: - **Build proof (2026-07-31):** `nix build .#arti-onion-service` green for then-current **2.5.0**; cargo features include `onion-service-service`. **2026-08-27** the pin was **2.5.1**. The 2026-10-10 pin was **2.6.0** - (GitLab `arti-v2.6.0`). Living pin checked 2026-10-17 is **2.7.0** + (GitLab `arti-v2.6.0`). The 2026-10-17 check was **2.7.0** (GitLab `arti-v2.7.0`), equal to this nixpkgs-rust `pkgs.arti.version`. - Cargo rebuild of the HS binary is not this note. + The living pin on 2026-11-05 is **2.8.0** (GitLab `arti-v2.8.0`). + `cargoHash` is set to sha256-grgIqU1gw1UqeGgR4aR7cAOxBW91F12bHMgOj4pgLcY=. Cargo rebuild of the HS binary is not this note. ## 5c. rustc (management-ui vs host vs stable) @@ -352,12 +360,12 @@ Do not treat host rustc 1.95.0 as the product pin. The 2026-10-03 lock | Item | Pin / state | Gap notes | |------|-------------|-----------| -| `nix/overlays.nix` | empty | no extra pins | +| `nix/overlays.nix` | tzdata 2026e `doCheck = false`. Nix package **2.35.4** | tzdata: same attribute change as NixOS/nixpkgs pull 582069. Date-dependent `now.ck` (America/Phoenix and America/Vancouver identical from now on, on or after 2026-11-01). Not a gcc or x86-64-v3 change. Nix: commit `21286d605815196c3fff1960550ab08700fc4e6f` on the 2.35.3 recipe. Channel stable was **2.34.9**. NixOS stays nixos-26.05 because 26.11 is not released. Release notes still read 2026.11/??. Branch-off is scheduled 2026-11-16 to 2026-11-23. Release is scheduled 2026-11-23 to 2026-11-30. See [NixOS 26.11 release schedule](https://github.com/NixOS/nixpkgs/issues/567567) (accessed: 2026-11-05). | | management-ui product version | 0.1.0 | Surmount-owned | | rust-overlay flake input | commented out | not active | | modules / hosts | no independent fetchurl pins | versions come from flake packages + nixpkgs | | Stalwart packaging mode | `release-binary-fod` | source build deferred (vendor 403 / rustc) | -| arti-onion-service | Surmount-owned source **2.7.0** (GitLab `arti-v2.7.0`) + `onion-service-service` | equals nixpkgs-rust `pkgs.arti.version` 2.7.0 (section 5b). | +| arti-onion-service | Surmount-owned source **2.8.0** (GitLab `arti-v2.8.0`) + `onion-service-service` | `cargoHash` is set to sha256-grgIqU1gw1UqeGgR4aR7cAOxBW91F12bHMgOj4pgLcY=. Does not equal nixpkgs-rust `pkgs.arti.version` 2.7.0 (section 5b). | | Stock Stalwart modules | dual `disabledModules` | `modules/stalwart-service.nix` | --- @@ -366,12 +374,14 @@ Do not treat host rustc 1.95.0 as the product pin. The 2026-10-03 lock Priority order (proposed, not accepted): -1. **Stalwart FODs** were re-checked 2026-10-17: server **0.16.26**, cli - **1.0.13**, WebUI **1.0.11**, spam-filter **3.0.2**, vandelay **1.0.12**. +1. **Stalwart FODs** were re-checked 2026-11-03: server **1.0.2**, cli + **1.0.14**, WebUI **2.0.4**, spam-filter **3.0.4** (rules archive only), + vandelay **1.0.14**. The 2026-10-17 cells are not the living pins. Re-check releases before every packaging PR. -2. **Arti currency:** the Surmount package is **2.7.0** (GitLab - `arti-v2.7.0`), equal to nixpkgs-rust `pkgs.arti.version`. Live Tor - verify remains residual. Do not claim onion published from pin alone. +2. **Arti currency:** the Surmount package src is **2.8.0** (GitLab + `arti-v2.8.0`). `cargoHash` is set to sha256-grgIqU1gw1UqeGgR4aR7cAOxBW91F12bHMgOj4pgLcY=. nixpkgs-rust + `pkgs.arti.version` is still **2.7.0** and is not the vendor tree. + Live Tor verify remains residual. Do not claim onion published from pin alone. 3. **OS channel:** **done** for 25.05 -> 26.05 (living host is 26.05). Further bumps only with release notes + measured need. May retire `nixpkgs-rust` if host channel rustc meets Arti MSRV without it. @@ -431,12 +441,12 @@ re-running. Keep reports short under `~/.agents/reports/`. | Sample stateVersion 26.05 | `hosts/mail-vps/configuration.nix`, `tests/module-eval.nix`, `tests/mail.nix` | | Crane rustc = 1.99.1 from `nixpkgs-rust` `pkgs.rustc` | `nix/rust-toolchain.nix`. Lock `9ecc0a672e9ef2ca4eec8e87dbee653607a7d332` (checked 2026-10-18). grok-oss native build inputs are `surmount-rust-1.99.1`. Not `rustPackages_1_95`. Host nixos-26.05 `pkgs.rustc` is still 1.95.0. Prior lock `c46e6c9a` was rustc 1.99.0. | | Dual stock module disable | `modules/stalwart-service.nix` `disabledModules` | -| Stalwart latest = 0.16.26 | GitHub tag `v0.16.26` published 2026-10-11 (re-check 2026-10-17). The 2026-07-31 cell that said 0.16.15 is historical. | -| CLI latest = 1.0.13 | GitHub tag `v1.0.13` 2026-09-28 (re-check 2026-10-17) | -| WebUI latest = 1.0.11 | GitHub tag `v1.0.11` 2026-09-15 (re-check 2026-10-17) | -| spam-filter latest = 3.0.2 | GitHub tag `v3.0.2` 2026-09-22 (re-check 2026-10-17) | -| vandelay latest = 1.0.12 | GitHub tag `v1.0.12` 2026-10-11 (re-check 2026-10-17) | -| Surmount arti-onion-service = 2.7.0 | package expression, GitLab `arti-v2.7.0`, cargoDeps assert vs nixpkgs-rust `arti` 2.7.0 (checked 2026-10-17). | +| Stalwart latest = 1.0.2 | GitHub tag `v1.0.2` published 2026-11-02T15:42:44Z (re-check 2026-11-03). The 2026-10-17 cell that said 0.16.26 is historical. The 2026-07-31 cell that said 0.16.15 is historical. | +| CLI latest = 1.0.14 | GitHub tag `v1.0.14` published 2026-10-25T18:15:42Z (re-check 2026-11-03). The 2026-10-17 cell that said 1.0.13 is historical. | +| WebUI latest = 2.0.4 | GitHub tag `v2.0.4` published 2026-11-02T11:19:17Z (re-check 2026-11-03). The 2026-10-17 cell that said 1.0.11 is historical. | +| spam-filter latest = 3.0.4 | GitHub tag `v3.0.4` published 2026-11-01T15:50:40Z (re-check 2026-11-03). Asset is `spam-filter-rules.json.gz` only. The 2026-10-17 cell that said 3.0.2 is historical. | +| vandelay latest = 1.0.14 | GitHub tag `v1.0.14` published 2026-11-01T16:43:01Z (re-check 2026-11-03). The 2026-10-17 cell that said 1.0.12 is historical. | +| Surmount arti-onion-service = 2.8.0 | package expression, GitLab `arti-v2.8.0`, src hash from the 2026-11-05 prefetch. `cargoHash` is set to sha256-grgIqU1gw1UqeGgR4aR7cAOxBW91F12bHMgOj4pgLcY=. The 2026-10-17 cell that said 2.7.0 with nixpkgs-rust `cargoDeps` is historical. | | rustc pin = 1.99.1 | Upstream stable is 1.99.1 (accessed: 2026-10-17). nixpkgs-rust `9ecc0a67` is that rustc. Master still has 1.99.0. Prior lock `c46e6c9a` was rustc 1.99.0. The 2026-07-31 cell that said 1.97.1 is historical. | | Historical host 25.05 / crane 1.88 | 2026-07-31 audit day only (see footnotes) | | Current stable branch name 26.05 | channels.nixos.org + nixpkgs README Hydra links for release-26.05 (2026-07-30) | diff --git a/flake.lock b/flake.lock index 5a8895d..7ac232b 100644 --- a/flake.lock +++ b/flake.lock @@ -3,11 +3,11 @@ "advisory-db": { "flake": false, "locked": { - "lastModified": 1792250639, - "narHash": "sha256-NsRcWUOIJ2Qy/aVB1oeSoUw5Q7PEQZuMNJxD5R+l5Nk=", + "lastModified": 1793738068, + "narHash": "sha256-81n00zcuQvNNQpoqiYZqKvSGBcGR8M0LX4v5YFxcEE4=", "owner": "rustsec", "repo": "advisory-db", - "rev": "999ec89d5d3f10f20be14750ec8c2d8b44e0ac9b", + "rev": "965bf9ad083b58e6340d65829d85217b18be9290", "type": "github" }, "original": { @@ -16,6002 +16,260 @@ "type": "github" } }, - "android-nixpkgs": { - "inputs": { - "devshell": "devshell", - "flake-utils": [ - "tau", - "dpc-public-skills", - "flakebox", - "flake-utils" - ], - "nixpkgs": [ - "tau", - "dpc-public-skills", - "flakebox", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1774654405, - "narHash": "sha256-71jT3XLRInSfbXiBgucWwEufe79N3i5FgAAJ1jCiQWg=", - "owner": "tadfisher", - "repo": "android-nixpkgs", - "rev": "0afc63ee6baf0914918169427744cc09ee651ebc", - "type": "github" - }, - "original": { - "owner": "tadfisher", - "repo": "android-nixpkgs", - "rev": "0afc63ee6baf0914918169427744cc09ee651ebc", - "type": "github" - } - }, - "android-nixpkgs_10": { - "inputs": { - "devshell": "devshell_10", - "flake-utils": [ - "tau", - "tau-ext-swarm", - "flakebox", - "flake-utils" - ], - "nixpkgs": [ - "tau", - "tau-ext-swarm", - "flakebox", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1774654405, - "narHash": "sha256-71jT3XLRInSfbXiBgucWwEufe79N3i5FgAAJ1jCiQWg=", - "owner": "tadfisher", - "repo": "android-nixpkgs", - "rev": "0afc63ee6baf0914918169427744cc09ee651ebc", - "type": "github" - }, - "original": { - "owner": "tadfisher", - "repo": "android-nixpkgs", - "rev": "0afc63ee6baf0914918169427744cc09ee651ebc", - "type": "github" - } - }, - "android-nixpkgs_11": { - "inputs": { - "devshell": "devshell_11", - "flake-utils": "flake-utils_25", - "nixpkgs": [ - "tau", - "tau-ext-swarm", - "selfci", - "flakebox", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1762287806, - "narHash": "sha256-tmF7Fm2VNukLV32w60QiFLDJB4cO26lhKdDLR8k1BX0=", - "owner": "tadfisher", - "repo": "android-nixpkgs", - "rev": "d3cbdd2a82b3d054b4e283d28ba27548186a88e7", - "type": "github" - }, - "original": { - "owner": "tadfisher", - "repo": "android-nixpkgs", - "rev": "d3cbdd2a82b3d054b4e283d28ba27548186a88e7", - "type": "github" - } - }, - "android-nixpkgs_12": { - "inputs": { - "devshell": "devshell_12", - "flake-utils": [ - "tau", - "tau-ext-telegram", - "flakebox", - "flake-utils" - ], - "nixpkgs": [ - "tau", - "tau-ext-telegram", - "flakebox", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1774654405, - "narHash": "sha256-71jT3XLRInSfbXiBgucWwEufe79N3i5FgAAJ1jCiQWg=", - "owner": "tadfisher", - "repo": "android-nixpkgs", - "rev": "0afc63ee6baf0914918169427744cc09ee651ebc", - "type": "github" - }, - "original": { - "owner": "tadfisher", - "repo": "android-nixpkgs", - "rev": "0afc63ee6baf0914918169427744cc09ee651ebc", - "type": "github" - } - }, - "android-nixpkgs_13": { - "inputs": { - "devshell": "devshell_13", - "flake-utils": "flake-utils_30", - "nixpkgs": [ - "tau", - "tau-ext-telegram", - "selfci", - "flakebox", - "nixpkgs" - ] - }, + "asset-registry": { + "flake": false, "locked": { - "lastModified": 1762287806, - "narHash": "sha256-tmF7Fm2VNukLV32w60QiFLDJB4cO26lhKdDLR8k1BX0=", - "owner": "tadfisher", - "repo": "android-nixpkgs", - "rev": "d3cbdd2a82b3d054b4e283d28ba27548186a88e7", + "lastModified": 1789841305, + "narHash": "sha256-tQAI6b8iqBh9WNa1r+4cT1ZjlQTv1fq/JvGWsnRonQU=", + "owner": "Blockstream", + "repo": "asset_registry_db", + "rev": "95e9c0afb8c8d6798b921257c7bfc2b2863018bb", "type": "github" }, "original": { - "owner": "tadfisher", - "repo": "android-nixpkgs", - "rev": "d3cbdd2a82b3d054b4e283d28ba27548186a88e7", + "owner": "Blockstream", + "repo": "asset_registry_db", "type": "github" } }, - "android-nixpkgs_14": { - "inputs": { - "devshell": "devshell_14", - "flake-utils": [ - "tau", - "tau-ext-xmpp", - "flakebox", - "flake-utils" - ], - "nixpkgs": [ - "tau", - "tau-ext-xmpp", - "flakebox", - "nixpkgs" - ] - }, + "crane": { "locked": { - "lastModified": 1774654405, - "narHash": "sha256-71jT3XLRInSfbXiBgucWwEufe79N3i5FgAAJ1jCiQWg=", - "owner": "tadfisher", - "repo": "android-nixpkgs", - "rev": "0afc63ee6baf0914918169427744cc09ee651ebc", + "lastModified": 1791754810, + "narHash": "sha256-cN0Mgk+sQtCJcGWs9H4M0CnLRAuC4I8aCwiBTcBJs6E=", + "owner": "ipetkov", + "repo": "crane", + "rev": "bd63bf1d0a0f6dcacd7549c714a671ee63b0db6a", "type": "github" }, "original": { - "owner": "tadfisher", - "repo": "android-nixpkgs", - "rev": "0afc63ee6baf0914918169427744cc09ee651ebc", + "owner": "ipetkov", + "repo": "crane", + "rev": "bd63bf1d0a0f6dcacd7549c714a671ee63b0db6a", "type": "github" } }, - "android-nixpkgs_15": { - "inputs": { - "devshell": "devshell_15", - "flake-utils": "flake-utils_35", - "nixpkgs": [ - "tau", - "tau-ext-xmpp", - "selfci", - "flakebox", - "nixpkgs" - ] - }, + "crane_2": { "locked": { - "lastModified": 1762287806, - "narHash": "sha256-tmF7Fm2VNukLV32w60QiFLDJB4cO26lhKdDLR8k1BX0=", - "owner": "tadfisher", - "repo": "android-nixpkgs", - "rev": "d3cbdd2a82b3d054b4e283d28ba27548186a88e7", + "lastModified": 1789760033, + "narHash": "sha256-jtT4yxZpR8seYnlCMMWSSPlFN92zO6ICuZ8pJrmi86k=", + "owner": "ipetkov", + "repo": "crane", + "rev": "73b980519cefc727a5f6cc8e5c0947a2f9be6edd", "type": "github" }, "original": { - "owner": "tadfisher", - "repo": "android-nixpkgs", - "rev": "d3cbdd2a82b3d054b4e283d28ba27548186a88e7", + "owner": "ipetkov", + "repo": "crane", "type": "github" } }, - "android-nixpkgs_16": { + "nixfmt-rs": { "inputs": { - "devshell": "devshell_16", - "flake-utils": [ - "tau", - "tau-ext-zulip", - "flakebox", - "flake-utils" - ], - "nixpkgs": [ - "tau", - "tau-ext-zulip", - "flakebox", - "nixpkgs" - ] + "nixpkgs": "nixpkgs", + "treefmt-nix": "treefmt-nix" }, "locked": { - "lastModified": 1774654405, - "narHash": "sha256-71jT3XLRInSfbXiBgucWwEufe79N3i5FgAAJ1jCiQWg=", - "owner": "tadfisher", - "repo": "android-nixpkgs", - "rev": "0afc63ee6baf0914918169427744cc09ee651ebc", + "lastModified": 1792014159, + "narHash": "sha256-6+Wf6C28VrpbT6Jx7qtP4QfNJlwmYCLy5j+VfTkbnXg=", + "owner": "Mic92", + "repo": "nixfmt-rs", + "rev": "c22b2c9ef0ad3b66f97ebd6296fbba298aa9e039", "type": "github" }, "original": { - "owner": "tadfisher", - "repo": "android-nixpkgs", - "rev": "0afc63ee6baf0914918169427744cc09ee651ebc", + "owner": "Mic92", + "repo": "nixfmt-rs", + "rev": "c22b2c9ef0ad3b66f97ebd6296fbba298aa9e039", "type": "github" } }, - "android-nixpkgs_17": { - "inputs": { - "devshell": "devshell_17", - "flake-utils": "flake-utils_40", - "nixpkgs": [ - "tau", - "tau-ext-zulip", - "selfci", - "flakebox", - "nixpkgs" - ] - }, + "nixpkgs": { "locked": { - "lastModified": 1762287806, - "narHash": "sha256-tmF7Fm2VNukLV32w60QiFLDJB4cO26lhKdDLR8k1BX0=", - "owner": "tadfisher", - "repo": "android-nixpkgs", - "rev": "d3cbdd2a82b3d054b4e283d28ba27548186a88e7", + "lastModified": 1791048980, + "narHash": "sha256-KgItSKML8Xvte0B7/uGnBDsYzSnnKHcOaiUbgWBXLXw=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "a7868a727837f3c09cee2ce0ca671c76b1589fed", "type": "github" }, "original": { - "owner": "tadfisher", - "repo": "android-nixpkgs", - "rev": "d3cbdd2a82b3d054b4e283d28ba27548186a88e7", + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", "type": "github" } }, - "android-nixpkgs_2": { - "inputs": { - "devshell": "devshell_2", - "flake-utils": [ - "tau", - "flakebox", - "flake-utils" - ], - "nixpkgs": [ - "tau", - "flakebox", - "nixpkgs" - ] - }, + "nixpkgs-rust": { "locked": { - "lastModified": 1774654405, - "narHash": "sha256-71jT3XLRInSfbXiBgucWwEufe79N3i5FgAAJ1jCiQWg=", - "owner": "tadfisher", - "repo": "android-nixpkgs", - "rev": "0afc63ee6baf0914918169427744cc09ee651ebc", + "lastModified": 1792092144, + "narHash": "sha256-ayn2dNaEAGbxEUYNc1CUVYgqIePEcxwvj1XpVCOksYg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "9ecc0a672e9ef2ca4eec8e87dbee653607a7d332", "type": "github" }, "original": { - "owner": "tadfisher", - "repo": "android-nixpkgs", - "rev": "0afc63ee6baf0914918169427744cc09ee651ebc", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "9ecc0a672e9ef2ca4eec8e87dbee653607a7d332", "type": "github" } }, - "android-nixpkgs_3": { - "inputs": { - "devshell": "devshell_3", - "flake-utils": "flake-utils_5", - "nixpkgs": [ - "tau", - "selfci", - "flakebox", - "nixpkgs" - ] - }, + "nixpkgs_2": { "locked": { - "lastModified": 1762287806, - "narHash": "sha256-tmF7Fm2VNukLV32w60QiFLDJB4cO26lhKdDLR8k1BX0=", - "owner": "tadfisher", - "repo": "android-nixpkgs", - "rev": "d3cbdd2a82b3d054b4e283d28ba27548186a88e7", + "lastModified": 1790149054, + "narHash": "sha256-FfjXg6mAzKQBCY+7V9keGWOXG4DV2Eym/qA987lFl8A=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "8825bebf6324e0579d012936eff73379af284b6d", "type": "github" }, "original": { - "owner": "tadfisher", - "repo": "android-nixpkgs", - "rev": "d3cbdd2a82b3d054b4e283d28ba27548186a88e7", + "owner": "nixos", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", "type": "github" } }, - "android-nixpkgs_4": { - "inputs": { - "devshell": "devshell_4", - "flake-utils": [ - "tau", - "tau-ext-pim", - "flakebox", - "flake-utils" - ], - "nixpkgs": [ - "tau", - "tau-ext-pim", - "flakebox", - "nixpkgs" - ] - }, + "nixpkgs_3": { "locked": { - "lastModified": 1774654405, - "narHash": "sha256-71jT3XLRInSfbXiBgucWwEufe79N3i5FgAAJ1jCiQWg=", - "owner": "tadfisher", - "repo": "android-nixpkgs", - "rev": "0afc63ee6baf0914918169427744cc09ee651ebc", + "lastModified": 1793633738, + "narHash": "sha256-tItrb9M8eYA6WhFgmT9tpGKueri3+wRuVA952o/2xSU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "7dcdd87373d72e7a2173e4db02e20d3d7f495cfd", "type": "github" }, "original": { - "owner": "tadfisher", - "repo": "android-nixpkgs", - "rev": "0afc63ee6baf0914918169427744cc09ee651ebc", + "owner": "NixOS", + "ref": "nixos-26.05", + "repo": "nixpkgs", "type": "github" } }, - "android-nixpkgs_5": { - "inputs": { - "devshell": "devshell_5", - "flake-utils": "flake-utils_10", - "nixpkgs": [ - "tau", - "tau-ext-pim", - "selfci", - "flakebox", - "nixpkgs" - ] - }, + "nixpkgs_4": { "locked": { - "lastModified": 1762287806, - "narHash": "sha256-tmF7Fm2VNukLV32w60QiFLDJB4cO26lhKdDLR8k1BX0=", - "owner": "tadfisher", - "repo": "android-nixpkgs", - "rev": "d3cbdd2a82b3d054b4e283d28ba27548186a88e7", + "lastModified": 1789785513, + "narHash": "sha256-B44WL6h0XoLjJ41bUPJk0X5SDinLCII//6EcBLXKiJ0=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "20b1ddd1aa5ace70c9468305030aa4f9ef79671b", "type": "github" }, "original": { - "owner": "tadfisher", - "repo": "android-nixpkgs", - "rev": "d3cbdd2a82b3d054b4e283d28ba27548186a88e7", + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", "type": "github" } }, - "android-nixpkgs_6": { + "root": { "inputs": { - "devshell": "devshell_6", - "flake-utils": [ - "tau", - "tau-ext-rostra", - "flakebox", - "flake-utils" - ], - "nixpkgs": [ - "tau", - "tau-ext-rostra", - "flakebox", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1774654405, - "narHash": "sha256-71jT3XLRInSfbXiBgucWwEufe79N3i5FgAAJ1jCiQWg=", - "owner": "tadfisher", - "repo": "android-nixpkgs", - "rev": "0afc63ee6baf0914918169427744cc09ee651ebc", - "type": "github" - }, - "original": { - "owner": "tadfisher", - "repo": "android-nixpkgs", - "rev": "0afc63ee6baf0914918169427744cc09ee651ebc", - "type": "github" + "advisory-db": "advisory-db", + "crane": "crane", + "nixfmt-rs": "nixfmt-rs", + "nixpkgs": "nixpkgs_3", + "nixpkgs-rust": "nixpkgs-rust", + "sops-nix": "sops-nix", + "splora": "splora", + "surmount-site": "surmount-site" } }, - "android-nixpkgs_7": { + "rust-overlay": { "inputs": { - "devshell": "devshell_7", - "flake-utils": "flake-utils_15", "nixpkgs": [ - "tau", - "tau-ext-rostra", - "selfci", - "flakebox", + "splora", "nixpkgs" ] }, "locked": { - "lastModified": 1762287806, - "narHash": "sha256-tmF7Fm2VNukLV32w60QiFLDJB4cO26lhKdDLR8k1BX0=", - "owner": "tadfisher", - "repo": "android-nixpkgs", - "rev": "d3cbdd2a82b3d054b4e283d28ba27548186a88e7", + "lastModified": 1793000331, + "narHash": "sha256-6sLQgHX/V/yF8VrhmsJroujeRua1GY3nDTEpnY7tHF4=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "0d0575988e4bb9ebc4cf1afb1cec1ceac0b5914e", "type": "github" }, "original": { - "owner": "tadfisher", - "repo": "android-nixpkgs", - "rev": "d3cbdd2a82b3d054b4e283d28ba27548186a88e7", + "owner": "oxalica", + "repo": "rust-overlay", "type": "github" } }, - "android-nixpkgs_8": { + "sops-nix": { "inputs": { - "devshell": "devshell_8", - "flake-utils": [ - "tau", - "tau-ext-slack", - "flakebox", - "flake-utils" - ], "nixpkgs": [ - "tau", - "tau-ext-slack", - "flakebox", "nixpkgs" ] }, "locked": { - "lastModified": 1774654405, - "narHash": "sha256-71jT3XLRInSfbXiBgucWwEufe79N3i5FgAAJ1jCiQWg=", - "owner": "tadfisher", - "repo": "android-nixpkgs", - "rev": "0afc63ee6baf0914918169427744cc09ee651ebc", + "lastModified": 1792056300, + "narHash": "sha256-YU1LjDe1o2Ymg5zEiCHbmMXLkDCtxHQRiVqZ3nC5GGk=", + "owner": "Mic92", + "repo": "sops-nix", + "rev": "44931708c62937c2b1bf7953902989fa5bb9ee1b", "type": "github" }, "original": { - "owner": "tadfisher", - "repo": "android-nixpkgs", - "rev": "0afc63ee6baf0914918169427744cc09ee651ebc", + "owner": "Mic92", + "repo": "sops-nix", + "rev": "44931708c62937c2b1bf7953902989fa5bb9ee1b", "type": "github" } }, - "android-nixpkgs_9": { + "splora": { "inputs": { - "devshell": "devshell_9", - "flake-utils": "flake-utils_20", - "nixpkgs": [ - "tau", - "tau-ext-slack", - "selfci", - "flakebox", - "nixpkgs" - ] + "asset-registry": "asset-registry", + "crane": "crane_2", + "nixpkgs": "nixpkgs_4", + "rust-overlay": "rust-overlay" }, "locked": { - "lastModified": 1762287806, - "narHash": "sha256-tmF7Fm2VNukLV32w60QiFLDJB4cO26lhKdDLR8k1BX0=", - "owner": "tadfisher", - "repo": "android-nixpkgs", - "rev": "d3cbdd2a82b3d054b4e283d28ba27548186a88e7", + "lastModified": 1793877947, + "narHash": "sha256-PWVbTYsDmSI+03iWFPsc71OfW2bNs/JLKGbwmFByQ6M=", + "owner": "SurmountSystems", + "repo": "splora", + "rev": "28eb3ea077eee02bd8a9c0cba63ee9a611b10e54", "type": "github" }, "original": { - "owner": "tadfisher", - "repo": "android-nixpkgs", - "rev": "d3cbdd2a82b3d054b4e283d28ba27548186a88e7", + "owner": "SurmountSystems", + "repo": "splora", + "rev": "28eb3ea077eee02bd8a9c0cba63ee9a611b10e54", "type": "github" } }, - "asset-registry": { + "surmount-site": { "flake": false, "locked": { - "lastModified": 1789841305, - "narHash": "sha256-tQAI6b8iqBh9WNa1r+4cT1ZjlQTv1fq/JvGWsnRonQU=", - "owner": "Blockstream", - "repo": "asset_registry_db", - "rev": "95e9c0afb8c8d6798b921257c7bfc2b2863018bb", - "type": "github" - }, - "original": { - "owner": "Blockstream", - "repo": "asset_registry_db", - "type": "github" - } - }, - "bundlers": { - "inputs": { - "nix-bundle": "nix-bundle", - "nix-utils": "nix-utils", - "nixpkgs": [ - "tau", - "selfci", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1756736145, - "narHash": "sha256-gKlLhzQ6PUTeC0Mi7aaX0tZ0wl6QB/slHLlk7jy0IN4=", - "owner": "NixOS", - "repo": "bundlers", - "rev": "dce9249b74500ef75110153e6da455ffec2532ba", - "type": "github" - }, - "original": { - "owner": "NixOS", - "repo": "bundlers", - "type": "github" - } - }, - "bundlers_2": { - "inputs": { - "nix-bundle": "nix-bundle_2", - "nix-utils": "nix-utils_2", - "nixpkgs": [ - "tau", - "tau-ext-pim", - "selfci", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1756736145, - "narHash": "sha256-gKlLhzQ6PUTeC0Mi7aaX0tZ0wl6QB/slHLlk7jy0IN4=", - "owner": "NixOS", - "repo": "bundlers", - "rev": "dce9249b74500ef75110153e6da455ffec2532ba", + "lastModified": 1787774658, + "narHash": "sha256-q01WrGxa68AogmpFZ6YfsDiBw/To1fHtm1VKU2Dsg4w=", + "owner": "SurmountSystems", + "repo": "site", + "rev": "82e07195e6aa221efe838e652ed8d2d8535b5cac", "type": "github" }, "original": { - "owner": "NixOS", - "repo": "bundlers", + "owner": "SurmountSystems", + "repo": "site", "type": "github" } }, - "bundlers_3": { + "treefmt-nix": { "inputs": { - "nix-bundle": "nix-bundle_3", - "nix-utils": "nix-utils_3", - "nixpkgs": [ - "tau", - "tau-ext-rostra", - "selfci", - "nixpkgs" - ] + "nixpkgs": "nixpkgs_2" }, "locked": { - "lastModified": 1756736145, - "narHash": "sha256-gKlLhzQ6PUTeC0Mi7aaX0tZ0wl6QB/slHLlk7jy0IN4=", - "owner": "NixOS", - "repo": "bundlers", - "rev": "dce9249b74500ef75110153e6da455ffec2532ba", - "type": "github" - }, - "original": { - "owner": "NixOS", - "repo": "bundlers", - "type": "github" - } - }, - "bundlers_4": { - "inputs": { - "nix-bundle": "nix-bundle_4", - "nix-utils": "nix-utils_4", - "nixpkgs": [ - "tau", - "tau-ext-slack", - "selfci", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1756736145, - "narHash": "sha256-gKlLhzQ6PUTeC0Mi7aaX0tZ0wl6QB/slHLlk7jy0IN4=", - "owner": "NixOS", - "repo": "bundlers", - "rev": "dce9249b74500ef75110153e6da455ffec2532ba", - "type": "github" - }, - "original": { - "owner": "NixOS", - "repo": "bundlers", - "type": "github" - } - }, - "bundlers_5": { - "inputs": { - "nix-bundle": "nix-bundle_5", - "nix-utils": "nix-utils_5", - "nixpkgs": [ - "tau", - "tau-ext-swarm", - "selfci", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1756736145, - "narHash": "sha256-gKlLhzQ6PUTeC0Mi7aaX0tZ0wl6QB/slHLlk7jy0IN4=", - "owner": "NixOS", - "repo": "bundlers", - "rev": "dce9249b74500ef75110153e6da455ffec2532ba", - "type": "github" - }, - "original": { - "owner": "NixOS", - "repo": "bundlers", - "type": "github" - } - }, - "bundlers_6": { - "inputs": { - "nix-bundle": "nix-bundle_6", - "nix-utils": "nix-utils_6", - "nixpkgs": [ - "tau", - "tau-ext-telegram", - "selfci", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1756736145, - "narHash": "sha256-gKlLhzQ6PUTeC0Mi7aaX0tZ0wl6QB/slHLlk7jy0IN4=", - "owner": "NixOS", - "repo": "bundlers", - "rev": "dce9249b74500ef75110153e6da455ffec2532ba", - "type": "github" - }, - "original": { - "owner": "NixOS", - "repo": "bundlers", - "type": "github" - } - }, - "bundlers_7": { - "inputs": { - "nix-bundle": "nix-bundle_7", - "nix-utils": "nix-utils_7", - "nixpkgs": [ - "tau", - "tau-ext-xmpp", - "selfci", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1756736145, - "narHash": "sha256-gKlLhzQ6PUTeC0Mi7aaX0tZ0wl6QB/slHLlk7jy0IN4=", - "owner": "NixOS", - "repo": "bundlers", - "rev": "dce9249b74500ef75110153e6da455ffec2532ba", - "type": "github" - }, - "original": { - "owner": "NixOS", - "repo": "bundlers", - "type": "github" - } - }, - "bundlers_8": { - "inputs": { - "nix-bundle": "nix-bundle_8", - "nix-utils": "nix-utils_8", - "nixpkgs": [ - "tau", - "tau-ext-zulip", - "selfci", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1756736145, - "narHash": "sha256-gKlLhzQ6PUTeC0Mi7aaX0tZ0wl6QB/slHLlk7jy0IN4=", - "owner": "NixOS", - "repo": "bundlers", - "rev": "dce9249b74500ef75110153e6da455ffec2532ba", - "type": "github" - }, - "original": { - "owner": "NixOS", - "repo": "bundlers", - "type": "github" - } - }, - "crane": { - "locked": { - "lastModified": 1791754810, - "narHash": "sha256-cN0Mgk+sQtCJcGWs9H4M0CnLRAuC4I8aCwiBTcBJs6E=", - "owner": "ipetkov", - "repo": "crane", - "rev": "bd63bf1d0a0f6dcacd7549c714a671ee63b0db6a", - "type": "github" - }, - "original": { - "owner": "ipetkov", - "repo": "crane", - "rev": "bd63bf1d0a0f6dcacd7549c714a671ee63b0db6a", - "type": "github" - } - }, - "crane_10": { - "locked": { - "lastModified": 1755993354, - "narHash": "sha256-FCRRAzSaL/+umLIm3RU3O/+fJ2ssaPHseI2SSFL8yZU=", - "owner": "ipetkov", - "repo": "crane", - "rev": "25bd41b24426c7734278c2ff02e53258851db914", - "type": "github" - }, - "original": { - "owner": "ipetkov", - "repo": "crane", - "type": "github" - } - }, - "crane_11": { - "locked": { - "lastModified": 1776394852, - "narHash": "sha256-K0i9GoNk2To1RQkW348EY4c7RYf3mD74hWlGSc/9sk0=", - "owner": "ipetkov", - "repo": "crane", - "rev": "db220b8709cea4bd43c9adcd4192f212fb6768d1", - "type": "github" - }, - "original": { - "owner": "ipetkov", - "ref": "v0.23.3", - "repo": "crane", - "type": "github" - } - }, - "crane_12": { - "locked": { - "lastModified": 1758758545, - "narHash": "sha256-NU5WaEdfwF6i8faJ2Yh+jcK9vVFrofLcwlD/mP65JrI=", - "owner": "ipetkov", - "repo": "crane", - "rev": "95d528a5f54eaba0d12102249ce42f4d01f4e364", - "type": "github" - }, - "original": { - "owner": "ipetkov", - "ref": "v0.21.1", - "repo": "crane", - "type": "github" - } - }, - "crane_13": { - "locked": { - "lastModified": 1755993354, - "narHash": "sha256-FCRRAzSaL/+umLIm3RU3O/+fJ2ssaPHseI2SSFL8yZU=", - "owner": "ipetkov", - "repo": "crane", - "rev": "25bd41b24426c7734278c2ff02e53258851db914", - "type": "github" - }, - "original": { - "owner": "ipetkov", - "repo": "crane", - "type": "github" - } - }, - "crane_14": { - "locked": { - "lastModified": 1776394852, - "narHash": "sha256-K0i9GoNk2To1RQkW348EY4c7RYf3mD74hWlGSc/9sk0=", - "owner": "ipetkov", - "repo": "crane", - "rev": "db220b8709cea4bd43c9adcd4192f212fb6768d1", - "type": "github" - }, - "original": { - "owner": "ipetkov", - "ref": "v0.23.3", - "repo": "crane", - "type": "github" - } - }, - "crane_15": { - "locked": { - "lastModified": 1758758545, - "narHash": "sha256-NU5WaEdfwF6i8faJ2Yh+jcK9vVFrofLcwlD/mP65JrI=", - "owner": "ipetkov", - "repo": "crane", - "rev": "95d528a5f54eaba0d12102249ce42f4d01f4e364", - "type": "github" - }, - "original": { - "owner": "ipetkov", - "ref": "v0.21.1", - "repo": "crane", - "type": "github" - } - }, - "crane_16": { - "locked": { - "lastModified": 1755993354, - "narHash": "sha256-FCRRAzSaL/+umLIm3RU3O/+fJ2ssaPHseI2SSFL8yZU=", - "owner": "ipetkov", - "repo": "crane", - "rev": "25bd41b24426c7734278c2ff02e53258851db914", - "type": "github" - }, - "original": { - "owner": "ipetkov", - "repo": "crane", - "type": "github" - } - }, - "crane_17": { - "locked": { - "lastModified": 1776394852, - "narHash": "sha256-K0i9GoNk2To1RQkW348EY4c7RYf3mD74hWlGSc/9sk0=", - "owner": "ipetkov", - "repo": "crane", - "rev": "db220b8709cea4bd43c9adcd4192f212fb6768d1", - "type": "github" - }, - "original": { - "owner": "ipetkov", - "ref": "v0.23.3", - "repo": "crane", - "type": "github" - } - }, - "crane_18": { - "locked": { - "lastModified": 1758758545, - "narHash": "sha256-NU5WaEdfwF6i8faJ2Yh+jcK9vVFrofLcwlD/mP65JrI=", - "owner": "ipetkov", - "repo": "crane", - "rev": "95d528a5f54eaba0d12102249ce42f4d01f4e364", - "type": "github" - }, - "original": { - "owner": "ipetkov", - "ref": "v0.21.1", - "repo": "crane", - "type": "github" - } - }, - "crane_19": { - "locked": { - "lastModified": 1755993354, - "narHash": "sha256-FCRRAzSaL/+umLIm3RU3O/+fJ2ssaPHseI2SSFL8yZU=", - "owner": "ipetkov", - "repo": "crane", - "rev": "25bd41b24426c7734278c2ff02e53258851db914", - "type": "github" - }, - "original": { - "owner": "ipetkov", - "repo": "crane", - "type": "github" - } - }, - "crane_2": { - "locked": { - "lastModified": 1787326676, - "narHash": "sha256-lWhBbBvC05/xwivKBBiM2YNizpmgqCgyOIzomvRuwxs=", - "owner": "ipetkov", - "repo": "crane", - "rev": "692f7e9ef2ece8125b466f66f2af532b3edaed0d", - "type": "github" - }, - "original": { - "owner": "ipetkov", - "repo": "crane", - "type": "github" - } - }, - "crane_20": { - "locked": { - "lastModified": 1776394852, - "narHash": "sha256-K0i9GoNk2To1RQkW348EY4c7RYf3mD74hWlGSc/9sk0=", - "owner": "ipetkov", - "repo": "crane", - "rev": "db220b8709cea4bd43c9adcd4192f212fb6768d1", - "type": "github" - }, - "original": { - "owner": "ipetkov", - "ref": "v0.23.3", - "repo": "crane", - "type": "github" - } - }, - "crane_21": { - "locked": { - "lastModified": 1758758545, - "narHash": "sha256-NU5WaEdfwF6i8faJ2Yh+jcK9vVFrofLcwlD/mP65JrI=", - "owner": "ipetkov", - "repo": "crane", - "rev": "95d528a5f54eaba0d12102249ce42f4d01f4e364", - "type": "github" - }, - "original": { - "owner": "ipetkov", - "ref": "v0.21.1", - "repo": "crane", - "type": "github" - } - }, - "crane_22": { - "locked": { - "lastModified": 1755993354, - "narHash": "sha256-FCRRAzSaL/+umLIm3RU3O/+fJ2ssaPHseI2SSFL8yZU=", - "owner": "ipetkov", - "repo": "crane", - "rev": "25bd41b24426c7734278c2ff02e53258851db914", - "type": "github" - }, - "original": { - "owner": "ipetkov", - "repo": "crane", - "type": "github" - } - }, - "crane_23": { - "locked": { - "lastModified": 1776394852, - "narHash": "sha256-K0i9GoNk2To1RQkW348EY4c7RYf3mD74hWlGSc/9sk0=", - "owner": "ipetkov", - "repo": "crane", - "rev": "db220b8709cea4bd43c9adcd4192f212fb6768d1", - "type": "github" - }, - "original": { - "owner": "ipetkov", - "ref": "v0.23.3", - "repo": "crane", - "type": "github" - } - }, - "crane_24": { - "locked": { - "lastModified": 1758758545, - "narHash": "sha256-NU5WaEdfwF6i8faJ2Yh+jcK9vVFrofLcwlD/mP65JrI=", - "owner": "ipetkov", - "repo": "crane", - "rev": "95d528a5f54eaba0d12102249ce42f4d01f4e364", - "type": "github" - }, - "original": { - "owner": "ipetkov", - "ref": "v0.21.1", - "repo": "crane", - "type": "github" - } - }, - "crane_25": { - "locked": { - "lastModified": 1755993354, - "narHash": "sha256-FCRRAzSaL/+umLIm3RU3O/+fJ2ssaPHseI2SSFL8yZU=", - "owner": "ipetkov", - "repo": "crane", - "rev": "25bd41b24426c7734278c2ff02e53258851db914", - "type": "github" - }, - "original": { - "owner": "ipetkov", - "repo": "crane", - "type": "github" - } - }, - "crane_26": { - "locked": { - "lastModified": 1776394852, - "narHash": "sha256-K0i9GoNk2To1RQkW348EY4c7RYf3mD74hWlGSc/9sk0=", - "owner": "ipetkov", - "repo": "crane", - "rev": "db220b8709cea4bd43c9adcd4192f212fb6768d1", - "type": "github" - }, - "original": { - "owner": "ipetkov", - "ref": "v0.23.3", - "repo": "crane", - "type": "github" - } - }, - "crane_27": { - "locked": { - "lastModified": 1758758545, - "narHash": "sha256-NU5WaEdfwF6i8faJ2Yh+jcK9vVFrofLcwlD/mP65JrI=", - "owner": "ipetkov", - "repo": "crane", - "rev": "95d528a5f54eaba0d12102249ce42f4d01f4e364", - "type": "github" - }, - "original": { - "owner": "ipetkov", - "ref": "v0.21.1", - "repo": "crane", - "type": "github" - } - }, - "crane_28": { - "locked": { - "lastModified": 1755993354, - "narHash": "sha256-FCRRAzSaL/+umLIm3RU3O/+fJ2ssaPHseI2SSFL8yZU=", - "owner": "ipetkov", - "repo": "crane", - "rev": "25bd41b24426c7734278c2ff02e53258851db914", - "type": "github" - }, - "original": { - "owner": "ipetkov", - "repo": "crane", - "type": "github" - } - }, - "crane_3": { - "locked": { - "lastModified": 1789760033, - "narHash": "sha256-jtT4yxZpR8seYnlCMMWSSPlFN92zO6ICuZ8pJrmi86k=", - "owner": "ipetkov", - "repo": "crane", - "rev": "73b980519cefc727a5f6cc8e5c0947a2f9be6edd", - "type": "github" - }, - "original": { - "owner": "ipetkov", - "repo": "crane", - "type": "github" - } - }, - "crane_4": { - "locked": { - "lastModified": 1776394852, - "narHash": "sha256-K0i9GoNk2To1RQkW348EY4c7RYf3mD74hWlGSc/9sk0=", - "owner": "ipetkov", - "repo": "crane", - "rev": "db220b8709cea4bd43c9adcd4192f212fb6768d1", - "type": "github" - }, - "original": { - "owner": "ipetkov", - "ref": "v0.23.3", - "repo": "crane", - "type": "github" - } - }, - "crane_5": { - "locked": { - "lastModified": 1776394852, - "narHash": "sha256-K0i9GoNk2To1RQkW348EY4c7RYf3mD74hWlGSc/9sk0=", - "owner": "ipetkov", - "repo": "crane", - "rev": "db220b8709cea4bd43c9adcd4192f212fb6768d1", - "type": "github" - }, - "original": { - "owner": "ipetkov", - "ref": "v0.23.3", - "repo": "crane", - "type": "github" - } - }, - "crane_6": { - "locked": { - "lastModified": 1758758545, - "narHash": "sha256-NU5WaEdfwF6i8faJ2Yh+jcK9vVFrofLcwlD/mP65JrI=", - "owner": "ipetkov", - "repo": "crane", - "rev": "95d528a5f54eaba0d12102249ce42f4d01f4e364", - "type": "github" - }, - "original": { - "owner": "ipetkov", - "ref": "v0.21.1", - "repo": "crane", - "type": "github" - } - }, - "crane_7": { - "locked": { - "lastModified": 1755993354, - "narHash": "sha256-FCRRAzSaL/+umLIm3RU3O/+fJ2ssaPHseI2SSFL8yZU=", - "owner": "ipetkov", - "repo": "crane", - "rev": "25bd41b24426c7734278c2ff02e53258851db914", - "type": "github" - }, - "original": { - "owner": "ipetkov", - "repo": "crane", - "type": "github" - } - }, - "crane_8": { - "locked": { - "lastModified": 1776394852, - "narHash": "sha256-K0i9GoNk2To1RQkW348EY4c7RYf3mD74hWlGSc/9sk0=", - "owner": "ipetkov", - "repo": "crane", - "rev": "db220b8709cea4bd43c9adcd4192f212fb6768d1", - "type": "github" - }, - "original": { - "owner": "ipetkov", - "ref": "v0.23.3", - "repo": "crane", - "type": "github" - } - }, - "crane_9": { - "locked": { - "lastModified": 1758758545, - "narHash": "sha256-NU5WaEdfwF6i8faJ2Yh+jcK9vVFrofLcwlD/mP65JrI=", - "owner": "ipetkov", - "repo": "crane", - "rev": "95d528a5f54eaba0d12102249ce42f4d01f4e364", - "type": "github" - }, - "original": { - "owner": "ipetkov", - "ref": "v0.21.1", - "repo": "crane", - "type": "github" - } - }, - "devshell": { - "inputs": { - "nixpkgs": [ - "tau", - "dpc-public-skills", - "flakebox", - "android-nixpkgs", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1768818222, - "narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=", - "owner": "numtide", - "repo": "devshell", - "rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "devshell", - "type": "github" - } - }, - "devshell_10": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-swarm", - "flakebox", - "android-nixpkgs", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1768818222, - "narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=", - "owner": "numtide", - "repo": "devshell", - "rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "devshell", - "type": "github" - } - }, - "devshell_11": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-swarm", - "selfci", - "flakebox", - "android-nixpkgs", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1741473158, - "narHash": "sha256-kWNaq6wQUbUMlPgw8Y+9/9wP0F8SHkjy24/mN3UAppg=", - "owner": "numtide", - "repo": "devshell", - "rev": "7c9e793ebe66bcba8292989a68c0419b737a22a0", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "devshell", - "type": "github" - } - }, - "devshell_12": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-telegram", - "flakebox", - "android-nixpkgs", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1768818222, - "narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=", - "owner": "numtide", - "repo": "devshell", - "rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "devshell", - "type": "github" - } - }, - "devshell_13": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-telegram", - "selfci", - "flakebox", - "android-nixpkgs", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1741473158, - "narHash": "sha256-kWNaq6wQUbUMlPgw8Y+9/9wP0F8SHkjy24/mN3UAppg=", - "owner": "numtide", - "repo": "devshell", - "rev": "7c9e793ebe66bcba8292989a68c0419b737a22a0", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "devshell", - "type": "github" - } - }, - "devshell_14": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-xmpp", - "flakebox", - "android-nixpkgs", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1768818222, - "narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=", - "owner": "numtide", - "repo": "devshell", - "rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "devshell", - "type": "github" - } - }, - "devshell_15": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-xmpp", - "selfci", - "flakebox", - "android-nixpkgs", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1741473158, - "narHash": "sha256-kWNaq6wQUbUMlPgw8Y+9/9wP0F8SHkjy24/mN3UAppg=", - "owner": "numtide", - "repo": "devshell", - "rev": "7c9e793ebe66bcba8292989a68c0419b737a22a0", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "devshell", - "type": "github" - } - }, - "devshell_16": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-zulip", - "flakebox", - "android-nixpkgs", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1768818222, - "narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=", - "owner": "numtide", - "repo": "devshell", - "rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "devshell", - "type": "github" - } - }, - "devshell_17": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-zulip", - "selfci", - "flakebox", - "android-nixpkgs", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1741473158, - "narHash": "sha256-kWNaq6wQUbUMlPgw8Y+9/9wP0F8SHkjy24/mN3UAppg=", - "owner": "numtide", - "repo": "devshell", - "rev": "7c9e793ebe66bcba8292989a68c0419b737a22a0", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "devshell", - "type": "github" - } - }, - "devshell_2": { - "inputs": { - "nixpkgs": [ - "tau", - "flakebox", - "android-nixpkgs", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1768818222, - "narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=", - "owner": "numtide", - "repo": "devshell", - "rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "devshell", - "type": "github" - } - }, - "devshell_3": { - "inputs": { - "nixpkgs": [ - "tau", - "selfci", - "flakebox", - "android-nixpkgs", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1741473158, - "narHash": "sha256-kWNaq6wQUbUMlPgw8Y+9/9wP0F8SHkjy24/mN3UAppg=", - "owner": "numtide", - "repo": "devshell", - "rev": "7c9e793ebe66bcba8292989a68c0419b737a22a0", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "devshell", - "type": "github" - } - }, - "devshell_4": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-pim", - "flakebox", - "android-nixpkgs", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1768818222, - "narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=", - "owner": "numtide", - "repo": "devshell", - "rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "devshell", - "type": "github" - } - }, - "devshell_5": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-pim", - "selfci", - "flakebox", - "android-nixpkgs", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1741473158, - "narHash": "sha256-kWNaq6wQUbUMlPgw8Y+9/9wP0F8SHkjy24/mN3UAppg=", - "owner": "numtide", - "repo": "devshell", - "rev": "7c9e793ebe66bcba8292989a68c0419b737a22a0", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "devshell", - "type": "github" - } - }, - "devshell_6": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-rostra", - "flakebox", - "android-nixpkgs", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1768818222, - "narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=", - "owner": "numtide", - "repo": "devshell", - "rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "devshell", - "type": "github" - } - }, - "devshell_7": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-rostra", - "selfci", - "flakebox", - "android-nixpkgs", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1741473158, - "narHash": "sha256-kWNaq6wQUbUMlPgw8Y+9/9wP0F8SHkjy24/mN3UAppg=", - "owner": "numtide", - "repo": "devshell", - "rev": "7c9e793ebe66bcba8292989a68c0419b737a22a0", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "devshell", - "type": "github" - } - }, - "devshell_8": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-slack", - "flakebox", - "android-nixpkgs", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1768818222, - "narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=", - "owner": "numtide", - "repo": "devshell", - "rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "devshell", - "type": "github" - } - }, - "devshell_9": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-slack", - "selfci", - "flakebox", - "android-nixpkgs", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1741473158, - "narHash": "sha256-kWNaq6wQUbUMlPgw8Y+9/9wP0F8SHkjy24/mN3UAppg=", - "owner": "numtide", - "repo": "devshell", - "rev": "7c9e793ebe66bcba8292989a68c0419b737a22a0", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "devshell", - "type": "github" - } - }, - "dpc-public-skills": { - "inputs": { - "flakebox": "flakebox", - "nixpkgs": [ - "tau", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1790279893, - "narHash": "sha256-+fjgU6L628g4Mf03mHOSFbXkwCnZLgTZHuMLfEu/UQ0=", - "ref": "refs/heads/master", - "rev": "30965a2b0cf6ed7bf6a9bf9ac9f214818e86e05a", - "revCount": 124, - "type": "git", - "url": "https://radicle.dpc.pw/z2HR882B4c4mTdAgdt4SozpdeTuMf.git" - }, - "original": { - "type": "git", - "url": "https://radicle.dpc.pw/z2HR882B4c4mTdAgdt4SozpdeTuMf.git" - } - }, - "fenix": { - "inputs": { - "nixpkgs": [ - "grok-oss", - "nixpkgs" - ], - "rust-analyzer-src": "rust-analyzer-src" - }, - "locked": { - "lastModified": 1787851824, - "narHash": "sha256-hkUiwirNA/E0ZM/jkyl1KWh4tBwhwS/aesUjp6y0ARU=", - "owner": "nix-community", - "repo": "fenix", - "rev": "2b240cd75dabb93338e1a3e6029898022bad0f52", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "fenix", - "type": "github" - } - }, - "fenix_10": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-slack", - "selfci", - "flakebox", - "nixpkgs" - ], - "rust-analyzer-src": "rust-analyzer-src_10" - }, - "locked": { - "lastModified": 1767941162, - "narHash": "sha256-7qJDycrXto4xrQWHbj5BkrRWt/hcfZtjlCstEJTyfJ8=", - "owner": "nix-community", - "repo": "fenix", - "rev": "80b1a19a713e2558c411f3259fecb1edd4b5b327", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "fenix", - "type": "github" - } - }, - "fenix_11": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-swarm", - "flakebox", - "nixpkgs" - ], - "rust-analyzer-src": "rust-analyzer-src_11" - }, - "locked": { - "lastModified": 1783844668, - "narHash": "sha256-3MOpw4y3reoErRLvFBDz0t9aG6FWXUj7leKvUns5eQA=", - "owner": "nix-community", - "repo": "fenix", - "rev": "4e49ef62eedaa5c149d62b63b3f53844e1cc45d7", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "fenix", - "type": "github" - } - }, - "fenix_12": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-swarm", - "selfci", - "flakebox", - "nixpkgs" - ], - "rust-analyzer-src": "rust-analyzer-src_12" - }, - "locked": { - "lastModified": 1767941162, - "narHash": "sha256-7qJDycrXto4xrQWHbj5BkrRWt/hcfZtjlCstEJTyfJ8=", - "owner": "nix-community", - "repo": "fenix", - "rev": "80b1a19a713e2558c411f3259fecb1edd4b5b327", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "fenix", - "type": "github" - } - }, - "fenix_13": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-telegram", - "flakebox", - "nixpkgs" - ], - "rust-analyzer-src": "rust-analyzer-src_13" - }, - "locked": { - "lastModified": 1783844668, - "narHash": "sha256-3MOpw4y3reoErRLvFBDz0t9aG6FWXUj7leKvUns5eQA=", - "owner": "nix-community", - "repo": "fenix", - "rev": "4e49ef62eedaa5c149d62b63b3f53844e1cc45d7", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "fenix", - "type": "github" - } - }, - "fenix_14": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-telegram", - "selfci", - "flakebox", - "nixpkgs" - ], - "rust-analyzer-src": "rust-analyzer-src_14" - }, - "locked": { - "lastModified": 1767941162, - "narHash": "sha256-7qJDycrXto4xrQWHbj5BkrRWt/hcfZtjlCstEJTyfJ8=", - "owner": "nix-community", - "repo": "fenix", - "rev": "80b1a19a713e2558c411f3259fecb1edd4b5b327", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "fenix", - "type": "github" - } - }, - "fenix_15": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-xmpp", - "flakebox", - "nixpkgs" - ], - "rust-analyzer-src": "rust-analyzer-src_15" - }, - "locked": { - "lastModified": 1783844668, - "narHash": "sha256-3MOpw4y3reoErRLvFBDz0t9aG6FWXUj7leKvUns5eQA=", - "owner": "nix-community", - "repo": "fenix", - "rev": "4e49ef62eedaa5c149d62b63b3f53844e1cc45d7", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "fenix", - "type": "github" - } - }, - "fenix_16": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-xmpp", - "selfci", - "flakebox", - "nixpkgs" - ], - "rust-analyzer-src": "rust-analyzer-src_16" - }, - "locked": { - "lastModified": 1767941162, - "narHash": "sha256-7qJDycrXto4xrQWHbj5BkrRWt/hcfZtjlCstEJTyfJ8=", - "owner": "nix-community", - "repo": "fenix", - "rev": "80b1a19a713e2558c411f3259fecb1edd4b5b327", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "fenix", - "type": "github" - } - }, - "fenix_17": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-zulip", - "flakebox", - "nixpkgs" - ], - "rust-analyzer-src": "rust-analyzer-src_17" - }, - "locked": { - "lastModified": 1783844668, - "narHash": "sha256-3MOpw4y3reoErRLvFBDz0t9aG6FWXUj7leKvUns5eQA=", - "owner": "nix-community", - "repo": "fenix", - "rev": "4e49ef62eedaa5c149d62b63b3f53844e1cc45d7", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "fenix", - "type": "github" - } - }, - "fenix_18": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-zulip", - "selfci", - "flakebox", - "nixpkgs" - ], - "rust-analyzer-src": "rust-analyzer-src_18" - }, - "locked": { - "lastModified": 1767941162, - "narHash": "sha256-7qJDycrXto4xrQWHbj5BkrRWt/hcfZtjlCstEJTyfJ8=", - "owner": "nix-community", - "repo": "fenix", - "rev": "80b1a19a713e2558c411f3259fecb1edd4b5b327", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "fenix", - "type": "github" - } - }, - "fenix_2": { - "inputs": { - "nixpkgs": [ - "tau", - "dpc-public-skills", - "flakebox", - "nixpkgs" - ], - "rust-analyzer-src": "rust-analyzer-src_2" - }, - "locked": { - "lastModified": 1783844668, - "narHash": "sha256-3MOpw4y3reoErRLvFBDz0t9aG6FWXUj7leKvUns5eQA=", - "owner": "nix-community", - "repo": "fenix", - "rev": "4e49ef62eedaa5c149d62b63b3f53844e1cc45d7", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "fenix", - "type": "github" - } - }, - "fenix_3": { - "inputs": { - "nixpkgs": [ - "tau", - "flakebox", - "nixpkgs" - ], - "rust-analyzer-src": "rust-analyzer-src_3" - }, - "locked": { - "lastModified": 1788691119, - "narHash": "sha256-grir5cmE/yZKmMQwQrbADf8KJKJGp+gd82Vk0NmKETE=", - "owner": "nix-community", - "repo": "fenix", - "rev": "a557691643f3b1ab520474d20ba2491d957ef729", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "fenix", - "type": "github" - } - }, - "fenix_4": { - "inputs": { - "nixpkgs": [ - "tau", - "selfci", - "flakebox", - "nixpkgs" - ], - "rust-analyzer-src": "rust-analyzer-src_4" - }, - "locked": { - "lastModified": 1767941162, - "narHash": "sha256-7qJDycrXto4xrQWHbj5BkrRWt/hcfZtjlCstEJTyfJ8=", - "owner": "nix-community", - "repo": "fenix", - "rev": "80b1a19a713e2558c411f3259fecb1edd4b5b327", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "fenix", - "type": "github" - } - }, - "fenix_5": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-pim", - "flakebox", - "nixpkgs" - ], - "rust-analyzer-src": "rust-analyzer-src_5" - }, - "locked": { - "lastModified": 1783844668, - "narHash": "sha256-3MOpw4y3reoErRLvFBDz0t9aG6FWXUj7leKvUns5eQA=", - "owner": "nix-community", - "repo": "fenix", - "rev": "4e49ef62eedaa5c149d62b63b3f53844e1cc45d7", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "fenix", - "type": "github" - } - }, - "fenix_6": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-pim", - "selfci", - "flakebox", - "nixpkgs" - ], - "rust-analyzer-src": "rust-analyzer-src_6" - }, - "locked": { - "lastModified": 1767941162, - "narHash": "sha256-7qJDycrXto4xrQWHbj5BkrRWt/hcfZtjlCstEJTyfJ8=", - "owner": "nix-community", - "repo": "fenix", - "rev": "80b1a19a713e2558c411f3259fecb1edd4b5b327", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "fenix", - "type": "github" - } - }, - "fenix_7": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-rostra", - "flakebox", - "nixpkgs" - ], - "rust-analyzer-src": "rust-analyzer-src_7" - }, - "locked": { - "lastModified": 1783844668, - "narHash": "sha256-3MOpw4y3reoErRLvFBDz0t9aG6FWXUj7leKvUns5eQA=", - "owner": "nix-community", - "repo": "fenix", - "rev": "4e49ef62eedaa5c149d62b63b3f53844e1cc45d7", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "fenix", - "type": "github" - } - }, - "fenix_8": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-rostra", - "selfci", - "flakebox", - "nixpkgs" - ], - "rust-analyzer-src": "rust-analyzer-src_8" - }, - "locked": { - "lastModified": 1767941162, - "narHash": "sha256-7qJDycrXto4xrQWHbj5BkrRWt/hcfZtjlCstEJTyfJ8=", - "owner": "nix-community", - "repo": "fenix", - "rev": "80b1a19a713e2558c411f3259fecb1edd4b5b327", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "fenix", - "type": "github" - } - }, - "fenix_9": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-slack", - "flakebox", - "nixpkgs" - ], - "rust-analyzer-src": "rust-analyzer-src_9" - }, - "locked": { - "lastModified": 1783844668, - "narHash": "sha256-3MOpw4y3reoErRLvFBDz0t9aG6FWXUj7leKvUns5eQA=", - "owner": "nix-community", - "repo": "fenix", - "rev": "4e49ef62eedaa5c149d62b63b3f53844e1cc45d7", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "fenix", - "type": "github" - } - }, - "flake-utils": { - "inputs": { - "systems": [ - "tau", - "dpc-public-skills", - "flakebox", - "systems" - ] - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_10": { - "inputs": { - "systems": "systems_10" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_11": { - "inputs": { - "systems": [ - "tau", - "tau-ext-pim", - "selfci", - "flakebox", - "systems" - ] - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_12": { - "inputs": { - "systems": "systems_12" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_13": { - "inputs": { - "systems": [ - "tau", - "tau-ext-rostra", - "flakebox", - "systems" - ] - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_14": { - "locked": { - "lastModified": 1623875721, - "narHash": "sha256-A8BU7bjS5GirpAUv4QA+QnJ4CceLHkcXdRp4xITDB0s=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "f7e004a55b120c02ecb6219596820fcd32ca8772", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_15": { - "inputs": { - "systems": "systems_15" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_16": { - "inputs": { - "systems": [ - "tau", - "tau-ext-rostra", - "selfci", - "flakebox", - "systems" - ] - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_17": { - "inputs": { - "systems": "systems_17" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_18": { - "inputs": { - "systems": [ - "tau", - "tau-ext-slack", - "flakebox", - "systems" - ] - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_19": { - "locked": { - "lastModified": 1623875721, - "narHash": "sha256-A8BU7bjS5GirpAUv4QA+QnJ4CceLHkcXdRp4xITDB0s=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "f7e004a55b120c02ecb6219596820fcd32ca8772", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_2": { - "inputs": { - "systems": "systems_2" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_20": { - "inputs": { - "systems": "systems_20" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_21": { - "inputs": { - "systems": [ - "tau", - "tau-ext-slack", - "selfci", - "flakebox", - "systems" - ] - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_22": { - "inputs": { - "systems": "systems_22" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_23": { - "inputs": { - "systems": [ - "tau", - "tau-ext-swarm", - "flakebox", - "systems" - ] - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_24": { - "locked": { - "lastModified": 1623875721, - "narHash": "sha256-A8BU7bjS5GirpAUv4QA+QnJ4CceLHkcXdRp4xITDB0s=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "f7e004a55b120c02ecb6219596820fcd32ca8772", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_25": { - "inputs": { - "systems": "systems_25" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_26": { - "inputs": { - "systems": [ - "tau", - "tau-ext-swarm", - "selfci", - "flakebox", - "systems" - ] - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_27": { - "inputs": { - "systems": "systems_27" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_28": { - "inputs": { - "systems": [ - "tau", - "tau-ext-telegram", - "flakebox", - "systems" - ] - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_29": { - "locked": { - "lastModified": 1623875721, - "narHash": "sha256-A8BU7bjS5GirpAUv4QA+QnJ4CceLHkcXdRp4xITDB0s=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "f7e004a55b120c02ecb6219596820fcd32ca8772", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_3": { - "inputs": { - "systems": [ - "tau", - "flakebox", - "systems" - ] - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_30": { - "inputs": { - "systems": "systems_30" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_31": { - "inputs": { - "systems": [ - "tau", - "tau-ext-telegram", - "selfci", - "flakebox", - "systems" - ] - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_32": { - "inputs": { - "systems": "systems_32" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_33": { - "inputs": { - "systems": [ - "tau", - "tau-ext-xmpp", - "flakebox", - "systems" - ] - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_34": { - "locked": { - "lastModified": 1623875721, - "narHash": "sha256-A8BU7bjS5GirpAUv4QA+QnJ4CceLHkcXdRp4xITDB0s=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "f7e004a55b120c02ecb6219596820fcd32ca8772", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_35": { - "inputs": { - "systems": "systems_35" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_36": { - "inputs": { - "systems": [ - "tau", - "tau-ext-xmpp", - "selfci", - "flakebox", - "systems" - ] - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_37": { - "inputs": { - "systems": "systems_37" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_38": { - "inputs": { - "systems": [ - "tau", - "tau-ext-zulip", - "flakebox", - "systems" - ] - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_39": { - "locked": { - "lastModified": 1623875721, - "narHash": "sha256-A8BU7bjS5GirpAUv4QA+QnJ4CceLHkcXdRp4xITDB0s=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "f7e004a55b120c02ecb6219596820fcd32ca8772", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_4": { - "locked": { - "lastModified": 1623875721, - "narHash": "sha256-A8BU7bjS5GirpAUv4QA+QnJ4CceLHkcXdRp4xITDB0s=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "f7e004a55b120c02ecb6219596820fcd32ca8772", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_40": { - "inputs": { - "systems": "systems_40" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_41": { - "inputs": { - "systems": [ - "tau", - "tau-ext-zulip", - "selfci", - "flakebox", - "systems" - ] - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_5": { - "inputs": { - "systems": "systems_5" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_6": { - "inputs": { - "systems": [ - "tau", - "selfci", - "flakebox", - "systems" - ] - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_7": { - "inputs": { - "systems": "systems_7" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_8": { - "inputs": { - "systems": [ - "tau", - "tau-ext-pim", - "flakebox", - "systems" - ] - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_9": { - "locked": { - "lastModified": 1623875721, - "narHash": "sha256-A8BU7bjS5GirpAUv4QA+QnJ4CceLHkcXdRp4xITDB0s=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "f7e004a55b120c02ecb6219596820fcd32ca8772", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flakebox": { - "inputs": { - "android-nixpkgs": "android-nixpkgs", - "crane": "crane_4", - "fenix": "fenix_2", - "flake-utils": "flake-utils", - "nixpkgs": [ - "tau", - "dpc-public-skills", - "nixpkgs" - ], - "systems": "systems" - }, - "locked": { - "lastModified": 1785785249, - "narHash": "sha256-ihNXCsLG8plE7SRvh3+Q+ZEq6skluNoZix6+X9hY4vE=", - "owner": "rustshop", - "repo": "flakebox", - "rev": "2b8d6aaaf27300eda8f3a6241940d0480a7f798b", - "type": "github" - }, - "original": { - "owner": "rustshop", - "repo": "flakebox", - "type": "github" - } - }, - "flakebox_10": { - "inputs": { - "android-nixpkgs": "android-nixpkgs_10", - "crane": "crane_17", - "fenix": "fenix_11", - "flake-utils": "flake-utils_23", - "nixpkgs": [ - "tau", - "tau-ext-swarm", - "nixpkgs" - ], - "systems": "systems_23" - }, - "locked": { - "lastModified": 1785524886, - "narHash": "sha256-TfVuYBOkbl1TZ4oG43+ICHGykBvr6LfRkSgqwFDsxBY=", - "owner": "rustshop", - "repo": "flakebox", - "rev": "e402950dbedc4021d41222c913bc9a7ef0a5fab2", - "type": "github" - }, - "original": { - "owner": "rustshop", - "repo": "flakebox", - "rev": "e402950dbedc4021d41222c913bc9a7ef0a5fab2", - "type": "github" - } - }, - "flakebox_11": { - "inputs": { - "android-nixpkgs": "android-nixpkgs_11", - "crane": "crane_18", - "fenix": "fenix_12", - "flake-utils": "flake-utils_26", - "nixpkgs": "nixpkgs_18", - "systems": "systems_26", - "wild": "wild_5" - }, - "locked": { - "lastModified": 1768170748, - "narHash": "sha256-RZ1PQEVhNtlqBYCvAA8eRSm4zmtpxZEFa88stifg25o=", - "owner": "rustshop", - "repo": "flakebox", - "rev": "62af969ab344229d2a0d585a482293b3f186b221", - "type": "github" - }, - "original": { - "owner": "rustshop", - "repo": "flakebox", - "rev": "62af969ab344229d2a0d585a482293b3f186b221", - "type": "github" - } - }, - "flakebox_12": { - "inputs": { - "android-nixpkgs": "android-nixpkgs_12", - "crane": "crane_20", - "fenix": "fenix_13", - "flake-utils": "flake-utils_28", - "nixpkgs": [ - "tau", - "tau-ext-telegram", - "nixpkgs" - ], - "systems": "systems_28" - }, - "locked": { - "lastModified": 1785524886, - "narHash": "sha256-TfVuYBOkbl1TZ4oG43+ICHGykBvr6LfRkSgqwFDsxBY=", - "owner": "rustshop", - "repo": "flakebox", - "rev": "e402950dbedc4021d41222c913bc9a7ef0a5fab2", - "type": "github" - }, - "original": { - "owner": "rustshop", - "repo": "flakebox", - "rev": "e402950dbedc4021d41222c913bc9a7ef0a5fab2", - "type": "github" - } - }, - "flakebox_13": { - "inputs": { - "android-nixpkgs": "android-nixpkgs_13", - "crane": "crane_21", - "fenix": "fenix_14", - "flake-utils": "flake-utils_31", - "nixpkgs": "nixpkgs_21", - "systems": "systems_31", - "wild": "wild_6" - }, - "locked": { - "lastModified": 1768170748, - "narHash": "sha256-RZ1PQEVhNtlqBYCvAA8eRSm4zmtpxZEFa88stifg25o=", - "owner": "rustshop", - "repo": "flakebox", - "rev": "62af969ab344229d2a0d585a482293b3f186b221", - "type": "github" - }, - "original": { - "owner": "rustshop", - "repo": "flakebox", - "rev": "62af969ab344229d2a0d585a482293b3f186b221", - "type": "github" - } - }, - "flakebox_14": { - "inputs": { - "android-nixpkgs": "android-nixpkgs_14", - "crane": "crane_23", - "fenix": "fenix_15", - "flake-utils": "flake-utils_33", - "nixpkgs": [ - "tau", - "tau-ext-xmpp", - "nixpkgs" - ], - "systems": "systems_33" - }, - "locked": { - "lastModified": 1785524886, - "narHash": "sha256-TfVuYBOkbl1TZ4oG43+ICHGykBvr6LfRkSgqwFDsxBY=", - "owner": "rustshop", - "repo": "flakebox", - "rev": "e402950dbedc4021d41222c913bc9a7ef0a5fab2", - "type": "github" - }, - "original": { - "owner": "rustshop", - "repo": "flakebox", - "rev": "e402950dbedc4021d41222c913bc9a7ef0a5fab2", - "type": "github" - } - }, - "flakebox_15": { - "inputs": { - "android-nixpkgs": "android-nixpkgs_15", - "crane": "crane_24", - "fenix": "fenix_16", - "flake-utils": "flake-utils_36", - "nixpkgs": "nixpkgs_24", - "systems": "systems_36", - "wild": "wild_7" - }, - "locked": { - "lastModified": 1768170748, - "narHash": "sha256-RZ1PQEVhNtlqBYCvAA8eRSm4zmtpxZEFa88stifg25o=", - "owner": "rustshop", - "repo": "flakebox", - "rev": "62af969ab344229d2a0d585a482293b3f186b221", - "type": "github" - }, - "original": { - "owner": "rustshop", - "repo": "flakebox", - "rev": "62af969ab344229d2a0d585a482293b3f186b221", - "type": "github" - } - }, - "flakebox_16": { - "inputs": { - "android-nixpkgs": "android-nixpkgs_16", - "crane": "crane_26", - "fenix": "fenix_17", - "flake-utils": "flake-utils_38", - "nixpkgs": [ - "tau", - "tau-ext-zulip", - "nixpkgs" - ], - "systems": "systems_38" - }, - "locked": { - "lastModified": 1785524886, - "narHash": "sha256-TfVuYBOkbl1TZ4oG43+ICHGykBvr6LfRkSgqwFDsxBY=", - "owner": "rustshop", - "repo": "flakebox", - "rev": "e402950dbedc4021d41222c913bc9a7ef0a5fab2", - "type": "github" - }, - "original": { - "owner": "rustshop", - "repo": "flakebox", - "rev": "e402950dbedc4021d41222c913bc9a7ef0a5fab2", - "type": "github" - } - }, - "flakebox_17": { - "inputs": { - "android-nixpkgs": "android-nixpkgs_17", - "crane": "crane_27", - "fenix": "fenix_18", - "flake-utils": "flake-utils_41", - "nixpkgs": "nixpkgs_27", - "systems": "systems_41", - "wild": "wild_8" - }, - "locked": { - "lastModified": 1768170748, - "narHash": "sha256-RZ1PQEVhNtlqBYCvAA8eRSm4zmtpxZEFa88stifg25o=", - "owner": "rustshop", - "repo": "flakebox", - "rev": "62af969ab344229d2a0d585a482293b3f186b221", - "type": "github" - }, - "original": { - "owner": "rustshop", - "repo": "flakebox", - "rev": "62af969ab344229d2a0d585a482293b3f186b221", - "type": "github" - } - }, - "flakebox_2": { - "inputs": { - "android-nixpkgs": "android-nixpkgs_2", - "crane": "crane_5", - "fenix": "fenix_3", - "flake-utils": "flake-utils_3", - "nixpkgs": [ - "tau", - "nixpkgs" - ], - "systems": "systems_3" - }, - "locked": { - "lastModified": 1788740722, - "narHash": "sha256-Tc4CulizH5JPBawWcNU5SREUH0OmJ1a81nZMjK9BjHM=", - "owner": "dpc", - "repo": "flakebox", - "rev": "105902c66930a9e6a874d27ea8430e8f8db864d7", - "type": "github" - }, - "original": { - "owner": "dpc", - "repo": "flakebox", - "rev": "105902c66930a9e6a874d27ea8430e8f8db864d7", - "type": "github" - } - }, - "flakebox_3": { - "inputs": { - "android-nixpkgs": "android-nixpkgs_3", - "crane": "crane_6", - "fenix": "fenix_4", - "flake-utils": "flake-utils_6", - "nixpkgs": "nixpkgs_6", - "systems": "systems_6", - "wild": "wild" - }, - "locked": { - "lastModified": 1768170748, - "narHash": "sha256-RZ1PQEVhNtlqBYCvAA8eRSm4zmtpxZEFa88stifg25o=", - "owner": "rustshop", - "repo": "flakebox", - "rev": "62af969ab344229d2a0d585a482293b3f186b221", - "type": "github" - }, - "original": { - "owner": "rustshop", - "repo": "flakebox", - "rev": "62af969ab344229d2a0d585a482293b3f186b221", - "type": "github" - } - }, - "flakebox_4": { - "inputs": { - "android-nixpkgs": "android-nixpkgs_4", - "crane": "crane_8", - "fenix": "fenix_5", - "flake-utils": "flake-utils_8", - "nixpkgs": [ - "tau", - "tau-ext-pim", - "nixpkgs" - ], - "systems": "systems_8" - }, - "locked": { - "lastModified": 1785524886, - "narHash": "sha256-TfVuYBOkbl1TZ4oG43+ICHGykBvr6LfRkSgqwFDsxBY=", - "owner": "rustshop", - "repo": "flakebox", - "rev": "e402950dbedc4021d41222c913bc9a7ef0a5fab2", - "type": "github" - }, - "original": { - "owner": "rustshop", - "repo": "flakebox", - "rev": "e402950dbedc4021d41222c913bc9a7ef0a5fab2", - "type": "github" - } - }, - "flakebox_5": { - "inputs": { - "android-nixpkgs": "android-nixpkgs_5", - "crane": "crane_9", - "fenix": "fenix_6", - "flake-utils": "flake-utils_11", - "nixpkgs": "nixpkgs_9", - "systems": "systems_11", - "wild": "wild_2" - }, - "locked": { - "lastModified": 1768170748, - "narHash": "sha256-RZ1PQEVhNtlqBYCvAA8eRSm4zmtpxZEFa88stifg25o=", - "owner": "rustshop", - "repo": "flakebox", - "rev": "62af969ab344229d2a0d585a482293b3f186b221", - "type": "github" - }, - "original": { - "owner": "rustshop", - "repo": "flakebox", - "rev": "62af969ab344229d2a0d585a482293b3f186b221", - "type": "github" - } - }, - "flakebox_6": { - "inputs": { - "android-nixpkgs": "android-nixpkgs_6", - "crane": "crane_11", - "fenix": "fenix_7", - "flake-utils": "flake-utils_13", - "nixpkgs": [ - "tau", - "tau-ext-rostra", - "nixpkgs" - ], - "systems": "systems_13" - }, - "locked": { - "lastModified": 1785524886, - "narHash": "sha256-TfVuYBOkbl1TZ4oG43+ICHGykBvr6LfRkSgqwFDsxBY=", - "owner": "rustshop", - "repo": "flakebox", - "rev": "e402950dbedc4021d41222c913bc9a7ef0a5fab2", - "type": "github" - }, - "original": { - "owner": "rustshop", - "repo": "flakebox", - "rev": "e402950dbedc4021d41222c913bc9a7ef0a5fab2", - "type": "github" - } - }, - "flakebox_7": { - "inputs": { - "android-nixpkgs": "android-nixpkgs_7", - "crane": "crane_12", - "fenix": "fenix_8", - "flake-utils": "flake-utils_16", - "nixpkgs": "nixpkgs_12", - "systems": "systems_16", - "wild": "wild_3" - }, - "locked": { - "lastModified": 1768170748, - "narHash": "sha256-RZ1PQEVhNtlqBYCvAA8eRSm4zmtpxZEFa88stifg25o=", - "owner": "rustshop", - "repo": "flakebox", - "rev": "62af969ab344229d2a0d585a482293b3f186b221", - "type": "github" - }, - "original": { - "owner": "rustshop", - "repo": "flakebox", - "rev": "62af969ab344229d2a0d585a482293b3f186b221", - "type": "github" - } - }, - "flakebox_8": { - "inputs": { - "android-nixpkgs": "android-nixpkgs_8", - "crane": "crane_14", - "fenix": "fenix_9", - "flake-utils": "flake-utils_18", - "nixpkgs": [ - "tau", - "tau-ext-slack", - "nixpkgs" - ], - "systems": "systems_18" - }, - "locked": { - "lastModified": 1785524886, - "narHash": "sha256-TfVuYBOkbl1TZ4oG43+ICHGykBvr6LfRkSgqwFDsxBY=", - "owner": "rustshop", - "repo": "flakebox", - "rev": "e402950dbedc4021d41222c913bc9a7ef0a5fab2", - "type": "github" - }, - "original": { - "owner": "rustshop", - "repo": "flakebox", - "rev": "e402950dbedc4021d41222c913bc9a7ef0a5fab2", - "type": "github" - } - }, - "flakebox_9": { - "inputs": { - "android-nixpkgs": "android-nixpkgs_9", - "crane": "crane_15", - "fenix": "fenix_10", - "flake-utils": "flake-utils_21", - "nixpkgs": "nixpkgs_15", - "systems": "systems_21", - "wild": "wild_4" - }, - "locked": { - "lastModified": 1768170748, - "narHash": "sha256-RZ1PQEVhNtlqBYCvAA8eRSm4zmtpxZEFa88stifg25o=", - "owner": "rustshop", - "repo": "flakebox", - "rev": "62af969ab344229d2a0d585a482293b3f186b221", - "type": "github" - }, - "original": { - "owner": "rustshop", - "repo": "flakebox", - "rev": "62af969ab344229d2a0d585a482293b3f186b221", - "type": "github" - } - }, - "grok-oss": { - "inputs": { - "crane": "crane_2", - "fenix": "fenix", - "nixpkgs": "nixpkgs" - }, - "locked": { - "lastModified": 1792384011, - "narHash": "sha256-sR+fs1wvU2Kw58IZeK16W9+10/9PKPGfoiXOfld0m/s=", - "owner": "SurmountSystems", - "repo": "grok-oss", - "rev": "182293cb30fa5414dd47686b92708103519b7462", - "type": "github" - }, - "original": { - "owner": "SurmountSystems", - "repo": "grok-oss", - "rev": "182293cb30fa5414dd47686b92708103519b7462", - "type": "github" - } - }, - "nix-bundle": { - "inputs": { - "nixpkgs": [ - "tau", - "selfci", - "bundlers", - "nixpkgs" - ], - "utils": "utils" - }, - "locked": { - "lastModified": 1756736056, - "narHash": "sha256-8YFhvulVX3iS4TYnKisA9zSImJeFN21G75HOUUFjzuE=", - "owner": "nix-community", - "repo": "nix-bundle", - "rev": "eff01593f62794d458ec714090091419194ab64d", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "nix-bundle", - "type": "github" - } - }, - "nix-bundle_2": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-pim", - "selfci", - "bundlers", - "nixpkgs" - ], - "utils": "utils_2" - }, - "locked": { - "lastModified": 1756736056, - "narHash": "sha256-8YFhvulVX3iS4TYnKisA9zSImJeFN21G75HOUUFjzuE=", - "owner": "nix-community", - "repo": "nix-bundle", - "rev": "eff01593f62794d458ec714090091419194ab64d", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "nix-bundle", - "type": "github" - } - }, - "nix-bundle_3": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-rostra", - "selfci", - "bundlers", - "nixpkgs" - ], - "utils": "utils_3" - }, - "locked": { - "lastModified": 1756736056, - "narHash": "sha256-8YFhvulVX3iS4TYnKisA9zSImJeFN21G75HOUUFjzuE=", - "owner": "nix-community", - "repo": "nix-bundle", - "rev": "eff01593f62794d458ec714090091419194ab64d", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "nix-bundle", - "type": "github" - } - }, - "nix-bundle_4": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-slack", - "selfci", - "bundlers", - "nixpkgs" - ], - "utils": "utils_4" - }, - "locked": { - "lastModified": 1756736056, - "narHash": "sha256-8YFhvulVX3iS4TYnKisA9zSImJeFN21G75HOUUFjzuE=", - "owner": "nix-community", - "repo": "nix-bundle", - "rev": "eff01593f62794d458ec714090091419194ab64d", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "nix-bundle", - "type": "github" - } - }, - "nix-bundle_5": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-swarm", - "selfci", - "bundlers", - "nixpkgs" - ], - "utils": "utils_5" - }, - "locked": { - "lastModified": 1756736056, - "narHash": "sha256-8YFhvulVX3iS4TYnKisA9zSImJeFN21G75HOUUFjzuE=", - "owner": "nix-community", - "repo": "nix-bundle", - "rev": "eff01593f62794d458ec714090091419194ab64d", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "nix-bundle", - "type": "github" - } - }, - "nix-bundle_6": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-telegram", - "selfci", - "bundlers", - "nixpkgs" - ], - "utils": "utils_6" - }, - "locked": { - "lastModified": 1756736056, - "narHash": "sha256-8YFhvulVX3iS4TYnKisA9zSImJeFN21G75HOUUFjzuE=", - "owner": "nix-community", - "repo": "nix-bundle", - "rev": "eff01593f62794d458ec714090091419194ab64d", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "nix-bundle", - "type": "github" - } - }, - "nix-bundle_7": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-xmpp", - "selfci", - "bundlers", - "nixpkgs" - ], - "utils": "utils_7" - }, - "locked": { - "lastModified": 1756736056, - "narHash": "sha256-8YFhvulVX3iS4TYnKisA9zSImJeFN21G75HOUUFjzuE=", - "owner": "nix-community", - "repo": "nix-bundle", - "rev": "eff01593f62794d458ec714090091419194ab64d", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "nix-bundle", - "type": "github" - } - }, - "nix-bundle_8": { - "inputs": { - "nixpkgs": [ - "tau", - "tau-ext-zulip", - "selfci", - "bundlers", - "nixpkgs" - ], - "utils": "utils_8" - }, - "locked": { - "lastModified": 1756736056, - "narHash": "sha256-8YFhvulVX3iS4TYnKisA9zSImJeFN21G75HOUUFjzuE=", - "owner": "nix-community", - "repo": "nix-bundle", - "rev": "eff01593f62794d458ec714090091419194ab64d", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "nix-bundle", - "type": "github" - } - }, - "nix-utils": { - "inputs": { - "flake-utils": "flake-utils_4", - "nixpkgs": "nixpkgs_5" - }, - "locked": { - "lastModified": 1744222205, - "narHash": "sha256-di1eNHQdpvvyXv6i7Z+S79KF7cQyhTs7AdFHp7q1e3Q=", - "owner": "juliosueiras-nix", - "repo": "nix-utils", - "rev": "53282197ad090c8cf47c96e99bf6c6c3b2cdc7c0", - "type": "github" - }, - "original": { - "owner": "juliosueiras-nix", - "repo": "nix-utils", - "type": "github" - } - }, - "nix-utils_2": { - "inputs": { - "flake-utils": "flake-utils_9", - "nixpkgs": "nixpkgs_8" - }, - "locked": { - "lastModified": 1744222205, - "narHash": "sha256-di1eNHQdpvvyXv6i7Z+S79KF7cQyhTs7AdFHp7q1e3Q=", - "owner": "juliosueiras-nix", - "repo": "nix-utils", - "rev": "53282197ad090c8cf47c96e99bf6c6c3b2cdc7c0", - "type": "github" - }, - "original": { - "owner": "juliosueiras-nix", - "repo": "nix-utils", - "type": "github" - } - }, - "nix-utils_3": { - "inputs": { - "flake-utils": "flake-utils_14", - "nixpkgs": "nixpkgs_11" - }, - "locked": { - "lastModified": 1744222205, - "narHash": "sha256-di1eNHQdpvvyXv6i7Z+S79KF7cQyhTs7AdFHp7q1e3Q=", - "owner": "juliosueiras-nix", - "repo": "nix-utils", - "rev": "53282197ad090c8cf47c96e99bf6c6c3b2cdc7c0", - "type": "github" - }, - "original": { - "owner": "juliosueiras-nix", - "repo": "nix-utils", - "type": "github" - } - }, - "nix-utils_4": { - "inputs": { - "flake-utils": "flake-utils_19", - "nixpkgs": "nixpkgs_14" - }, - "locked": { - "lastModified": 1744222205, - "narHash": "sha256-di1eNHQdpvvyXv6i7Z+S79KF7cQyhTs7AdFHp7q1e3Q=", - "owner": "juliosueiras-nix", - "repo": "nix-utils", - "rev": "53282197ad090c8cf47c96e99bf6c6c3b2cdc7c0", - "type": "github" - }, - "original": { - "owner": "juliosueiras-nix", - "repo": "nix-utils", - "type": "github" - } - }, - "nix-utils_5": { - "inputs": { - "flake-utils": "flake-utils_24", - "nixpkgs": "nixpkgs_17" - }, - "locked": { - "lastModified": 1744222205, - "narHash": "sha256-di1eNHQdpvvyXv6i7Z+S79KF7cQyhTs7AdFHp7q1e3Q=", - "owner": "juliosueiras-nix", - "repo": "nix-utils", - "rev": "53282197ad090c8cf47c96e99bf6c6c3b2cdc7c0", - "type": "github" - }, - "original": { - "owner": "juliosueiras-nix", - "repo": "nix-utils", - "type": "github" - } - }, - "nix-utils_6": { - "inputs": { - "flake-utils": "flake-utils_29", - "nixpkgs": "nixpkgs_20" - }, - "locked": { - "lastModified": 1744222205, - "narHash": "sha256-di1eNHQdpvvyXv6i7Z+S79KF7cQyhTs7AdFHp7q1e3Q=", - "owner": "juliosueiras-nix", - "repo": "nix-utils", - "rev": "53282197ad090c8cf47c96e99bf6c6c3b2cdc7c0", - "type": "github" - }, - "original": { - "owner": "juliosueiras-nix", - "repo": "nix-utils", - "type": "github" - } - }, - "nix-utils_7": { - "inputs": { - "flake-utils": "flake-utils_34", - "nixpkgs": "nixpkgs_23" - }, - "locked": { - "lastModified": 1744222205, - "narHash": "sha256-di1eNHQdpvvyXv6i7Z+S79KF7cQyhTs7AdFHp7q1e3Q=", - "owner": "juliosueiras-nix", - "repo": "nix-utils", - "rev": "53282197ad090c8cf47c96e99bf6c6c3b2cdc7c0", - "type": "github" - }, - "original": { - "owner": "juliosueiras-nix", - "repo": "nix-utils", - "type": "github" - } - }, - "nix-utils_8": { - "inputs": { - "flake-utils": "flake-utils_39", - "nixpkgs": "nixpkgs_26" - }, - "locked": { - "lastModified": 1744222205, - "narHash": "sha256-di1eNHQdpvvyXv6i7Z+S79KF7cQyhTs7AdFHp7q1e3Q=", - "owner": "juliosueiras-nix", - "repo": "nix-utils", - "rev": "53282197ad090c8cf47c96e99bf6c6c3b2cdc7c0", - "type": "github" - }, - "original": { - "owner": "juliosueiras-nix", - "repo": "nix-utils", - "type": "github" - } - }, - "nixpkgs": { - "locked": { - "lastModified": 1787736819, - "narHash": "sha256-cV5xEJJK3BvhU8rEd4mC9UsmDi5qscv/kzGPhBRC5WA=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "9fbb54b33e91ee4ca368e35a78e0613c720600b3", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs-rust": { - "locked": { - "lastModified": 1792092144, - "narHash": "sha256-ayn2dNaEAGbxEUYNc1CUVYgqIePEcxwvj1XpVCOksYg=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "9ecc0a672e9ef2ca4eec8e87dbee653607a7d332", - "type": "github" - }, - "original": { - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "9ecc0a672e9ef2ca4eec8e87dbee653607a7d332", - "type": "github" - } - }, - "nixpkgs-unstable": { - "locked": { - "lastModified": 1783776592, - "narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs-unstable_2": { - "locked": { - "lastModified": 1783776592, - "narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs-unstable_3": { - "locked": { - "lastModified": 1783776592, - "narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs-unstable_4": { - "locked": { - "lastModified": 1783776592, - "narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs-unstable_5": { - "locked": { - "lastModified": 1783776592, - "narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs-unstable_6": { - "locked": { - "lastModified": 1783776592, - "narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs-unstable_7": { - "locked": { - "lastModified": 1783776592, - "narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs-unstable_8": { - "locked": { - "lastModified": 1783776592, - "narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_10": { - "locked": { - "lastModified": 1788584326, - "narHash": "sha256-Fd3OB8J9JhgliQwOKcqx4M672CInxi1I5VnwsaXeSQo=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "6713828a351efa628b025a1adf7f43cbf8597513", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-26.05", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_11": { - "locked": { - "lastModified": 1629252929, - "narHash": "sha256-Aj20gmGBs8TG7pyaQqgbsqAQ6cB+TVuL18Pk3DPBxcQ=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "3788c68def67ca7949e0864c27638d484389363d", - "type": "github" - }, - "original": { - "owner": "nixos", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_12": { - "locked": { - "lastModified": 1767799921, - "narHash": "sha256-r4GVX+FToWVE2My8VVZH4V0pTIpnu2ZE8/Z4uxGEMBE=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "d351d0653aeb7877273920cd3e823994e7579b0b", - "type": "github" - }, - "original": { - "owner": "nixos", - "ref": "nixos-25.11", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_13": { - "locked": { - "lastModified": 1784707089, - "narHash": "sha256-2V/6imsUgB7mPZlHY54oeVBRDoZbPKnvzwkAHUSSufk=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "b3fe9581c9061c749abef42b6d4ee7b7c05c33fa", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-26.05", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_14": { - "locked": { - "lastModified": 1629252929, - "narHash": "sha256-Aj20gmGBs8TG7pyaQqgbsqAQ6cB+TVuL18Pk3DPBxcQ=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "3788c68def67ca7949e0864c27638d484389363d", - "type": "github" - }, - "original": { - "owner": "nixos", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_15": { - "locked": { - "lastModified": 1767799921, - "narHash": "sha256-r4GVX+FToWVE2My8VVZH4V0pTIpnu2ZE8/Z4uxGEMBE=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "d351d0653aeb7877273920cd3e823994e7579b0b", - "type": "github" - }, - "original": { - "owner": "nixos", - "ref": "nixos-25.11", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_16": { - "locked": { - "lastModified": 1788584326, - "narHash": "sha256-Fd3OB8J9JhgliQwOKcqx4M672CInxi1I5VnwsaXeSQo=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "6713828a351efa628b025a1adf7f43cbf8597513", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-26.05", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_17": { - "locked": { - "lastModified": 1629252929, - "narHash": "sha256-Aj20gmGBs8TG7pyaQqgbsqAQ6cB+TVuL18Pk3DPBxcQ=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "3788c68def67ca7949e0864c27638d484389363d", - "type": "github" - }, - "original": { - "owner": "nixos", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_18": { - "locked": { - "lastModified": 1767799921, - "narHash": "sha256-r4GVX+FToWVE2My8VVZH4V0pTIpnu2ZE8/Z4uxGEMBE=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "d351d0653aeb7877273920cd3e823994e7579b0b", - "type": "github" - }, - "original": { - "owner": "nixos", - "ref": "nixos-25.11", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_19": { - "locked": { - "lastModified": 1784707089, - "narHash": "sha256-2V/6imsUgB7mPZlHY54oeVBRDoZbPKnvzwkAHUSSufk=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "b3fe9581c9061c749abef42b6d4ee7b7c05c33fa", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-26.05", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_2": { - "locked": { - "lastModified": 1792150307, - "narHash": "sha256-uFRCFzCN3OPC/cQMmzdIYiFKhakVVx2ZkXO9fOuYkGk=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "ed583b5bcfb5f3b7bf6164b24a62fa133682cffe", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-26.05", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_20": { - "locked": { - "lastModified": 1629252929, - "narHash": "sha256-Aj20gmGBs8TG7pyaQqgbsqAQ6cB+TVuL18Pk3DPBxcQ=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "3788c68def67ca7949e0864c27638d484389363d", - "type": "github" - }, - "original": { - "owner": "nixos", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_21": { - "locked": { - "lastModified": 1767799921, - "narHash": "sha256-r4GVX+FToWVE2My8VVZH4V0pTIpnu2ZE8/Z4uxGEMBE=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "d351d0653aeb7877273920cd3e823994e7579b0b", - "type": "github" - }, - "original": { - "owner": "nixos", - "ref": "nixos-25.11", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_22": { - "locked": { - "lastModified": 1788584326, - "narHash": "sha256-Fd3OB8J9JhgliQwOKcqx4M672CInxi1I5VnwsaXeSQo=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "6713828a351efa628b025a1adf7f43cbf8597513", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-26.05", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_23": { - "locked": { - "lastModified": 1629252929, - "narHash": "sha256-Aj20gmGBs8TG7pyaQqgbsqAQ6cB+TVuL18Pk3DPBxcQ=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "3788c68def67ca7949e0864c27638d484389363d", - "type": "github" - }, - "original": { - "owner": "nixos", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_24": { - "locked": { - "lastModified": 1767799921, - "narHash": "sha256-r4GVX+FToWVE2My8VVZH4V0pTIpnu2ZE8/Z4uxGEMBE=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "d351d0653aeb7877273920cd3e823994e7579b0b", - "type": "github" - }, - "original": { - "owner": "nixos", - "ref": "nixos-25.11", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_25": { - "locked": { - "lastModified": 1784707089, - "narHash": "sha256-2V/6imsUgB7mPZlHY54oeVBRDoZbPKnvzwkAHUSSufk=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "b3fe9581c9061c749abef42b6d4ee7b7c05c33fa", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-26.05", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_26": { - "locked": { - "lastModified": 1629252929, - "narHash": "sha256-Aj20gmGBs8TG7pyaQqgbsqAQ6cB+TVuL18Pk3DPBxcQ=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "3788c68def67ca7949e0864c27638d484389363d", - "type": "github" - }, - "original": { - "owner": "nixos", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_27": { - "locked": { - "lastModified": 1767799921, - "narHash": "sha256-r4GVX+FToWVE2My8VVZH4V0pTIpnu2ZE8/Z4uxGEMBE=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "d351d0653aeb7877273920cd3e823994e7579b0b", - "type": "github" - }, - "original": { - "owner": "nixos", - "ref": "nixos-25.11", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_3": { - "locked": { - "lastModified": 1789785513, - "narHash": "sha256-B44WL6h0XoLjJ41bUPJk0X5SDinLCII//6EcBLXKiJ0=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "20b1ddd1aa5ace70c9468305030aa4f9ef79671b", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_4": { - "locked": { - "lastModified": 1790652569, - "narHash": "sha256-641r7xrlSOHYoktL9/aArPIYNgZJ9eM5URzSEOtPJDY=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "b6c8664de9b6cc07fe5666a29f91884ba81197c4", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixpkgs-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_5": { - "locked": { - "lastModified": 1629252929, - "narHash": "sha256-Aj20gmGBs8TG7pyaQqgbsqAQ6cB+TVuL18Pk3DPBxcQ=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "3788c68def67ca7949e0864c27638d484389363d", - "type": "github" - }, - "original": { - "owner": "nixos", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_6": { - "locked": { - "lastModified": 1767799921, - "narHash": "sha256-r4GVX+FToWVE2My8VVZH4V0pTIpnu2ZE8/Z4uxGEMBE=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "d351d0653aeb7877273920cd3e823994e7579b0b", - "type": "github" - }, - "original": { - "owner": "nixos", - "ref": "nixos-25.11", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_7": { - "locked": { - "lastModified": 1788690626, - "narHash": "sha256-+v4I4LawmRD/mVxO7QIAerRrCkElp3YImzWkkUnvOTg=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "c25784012c9982bca5b3e0de87e90bbdac8927d3", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-26.05", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_8": { - "locked": { - "lastModified": 1629252929, - "narHash": "sha256-Aj20gmGBs8TG7pyaQqgbsqAQ6cB+TVuL18Pk3DPBxcQ=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "3788c68def67ca7949e0864c27638d484389363d", - "type": "github" - }, - "original": { - "owner": "nixos", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_9": { - "locked": { - "lastModified": 1767799921, - "narHash": "sha256-r4GVX+FToWVE2My8VVZH4V0pTIpnu2ZE8/Z4uxGEMBE=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "d351d0653aeb7877273920cd3e823994e7579b0b", - "type": "github" - }, - "original": { - "owner": "nixos", - "ref": "nixos-25.11", - "repo": "nixpkgs", - "type": "github" - } - }, - "root": { - "inputs": { - "advisory-db": "advisory-db", - "crane": "crane", - "grok-oss": "grok-oss", - "nixpkgs": "nixpkgs_2", - "nixpkgs-rust": "nixpkgs-rust", - "sops-nix": "sops-nix", - "splora": "splora", - "surmount-site": "surmount-site", - "tau": "tau" - } - }, - "rust-analyzer-src": { - "flake": false, - "locked": { - "lastModified": 1787768781, - "narHash": "sha256-PMVtb917DTWjGLTQHHI+ATUguk7Wxuqu6J+DINiU+qI=", - "owner": "rust-lang", - "repo": "rust-analyzer", - "rev": "70d74f4d134c45b073c82167fb7e7d61334bd8f5", - "type": "github" - }, - "original": { - "owner": "rust-lang", - "ref": "nightly", - "repo": "rust-analyzer", - "type": "github" - } - }, - "rust-analyzer-src_10": { - "flake": false, - "locked": { - "lastModified": 1767905519, - "narHash": "sha256-mRU9VEhGQE9dnOU3pu1Rx3dZO4NpZO+cnC0rPMFcCqE=", - "owner": "rust-lang", - "repo": "rust-analyzer", - "rev": "ff9a2e88b14907562294838f83963e5966f717de", - "type": "github" - }, - "original": { - "owner": "rust-lang", - "ref": "nightly", - "repo": "rust-analyzer", - "type": "github" - } - }, - "rust-analyzer-src_11": { - "flake": false, - "locked": { - "lastModified": 1783779020, - "narHash": "sha256-vpm418WZa9l1KUl6HRs+Ga+SIfE+D2/sV4olwTlilYc=", - "owner": "rust-lang", - "repo": "rust-analyzer", - "rev": "5be5e89cf0145d73a85c805726821b4adfc3af48", - "type": "github" - }, - "original": { - "owner": "rust-lang", - "ref": "nightly", - "repo": "rust-analyzer", - "type": "github" - } - }, - "rust-analyzer-src_12": { - "flake": false, - "locked": { - "lastModified": 1767905519, - "narHash": "sha256-mRU9VEhGQE9dnOU3pu1Rx3dZO4NpZO+cnC0rPMFcCqE=", - "owner": "rust-lang", - "repo": "rust-analyzer", - "rev": "ff9a2e88b14907562294838f83963e5966f717de", - "type": "github" - }, - "original": { - "owner": "rust-lang", - "ref": "nightly", - "repo": "rust-analyzer", - "type": "github" - } - }, - "rust-analyzer-src_13": { - "flake": false, - "locked": { - "lastModified": 1783779020, - "narHash": "sha256-vpm418WZa9l1KUl6HRs+Ga+SIfE+D2/sV4olwTlilYc=", - "owner": "rust-lang", - "repo": "rust-analyzer", - "rev": "5be5e89cf0145d73a85c805726821b4adfc3af48", - "type": "github" - }, - "original": { - "owner": "rust-lang", - "ref": "nightly", - "repo": "rust-analyzer", - "type": "github" - } - }, - "rust-analyzer-src_14": { - "flake": false, - "locked": { - "lastModified": 1767905519, - "narHash": "sha256-mRU9VEhGQE9dnOU3pu1Rx3dZO4NpZO+cnC0rPMFcCqE=", - "owner": "rust-lang", - "repo": "rust-analyzer", - "rev": "ff9a2e88b14907562294838f83963e5966f717de", - "type": "github" - }, - "original": { - "owner": "rust-lang", - "ref": "nightly", - "repo": "rust-analyzer", - "type": "github" - } - }, - "rust-analyzer-src_15": { - "flake": false, - "locked": { - "lastModified": 1783779020, - "narHash": "sha256-vpm418WZa9l1KUl6HRs+Ga+SIfE+D2/sV4olwTlilYc=", - "owner": "rust-lang", - "repo": "rust-analyzer", - "rev": "5be5e89cf0145d73a85c805726821b4adfc3af48", - "type": "github" - }, - "original": { - "owner": "rust-lang", - "ref": "nightly", - "repo": "rust-analyzer", - "type": "github" - } - }, - "rust-analyzer-src_16": { - "flake": false, - "locked": { - "lastModified": 1767905519, - "narHash": "sha256-mRU9VEhGQE9dnOU3pu1Rx3dZO4NpZO+cnC0rPMFcCqE=", - "owner": "rust-lang", - "repo": "rust-analyzer", - "rev": "ff9a2e88b14907562294838f83963e5966f717de", - "type": "github" - }, - "original": { - "owner": "rust-lang", - "ref": "nightly", - "repo": "rust-analyzer", - "type": "github" - } - }, - "rust-analyzer-src_17": { - "flake": false, - "locked": { - "lastModified": 1783779020, - "narHash": "sha256-vpm418WZa9l1KUl6HRs+Ga+SIfE+D2/sV4olwTlilYc=", - "owner": "rust-lang", - "repo": "rust-analyzer", - "rev": "5be5e89cf0145d73a85c805726821b4adfc3af48", - "type": "github" - }, - "original": { - "owner": "rust-lang", - "ref": "nightly", - "repo": "rust-analyzer", - "type": "github" - } - }, - "rust-analyzer-src_18": { - "flake": false, - "locked": { - "lastModified": 1767905519, - "narHash": "sha256-mRU9VEhGQE9dnOU3pu1Rx3dZO4NpZO+cnC0rPMFcCqE=", - "owner": "rust-lang", - "repo": "rust-analyzer", - "rev": "ff9a2e88b14907562294838f83963e5966f717de", - "type": "github" - }, - "original": { - "owner": "rust-lang", - "ref": "nightly", - "repo": "rust-analyzer", - "type": "github" - } - }, - "rust-analyzer-src_2": { - "flake": false, - "locked": { - "lastModified": 1783779020, - "narHash": "sha256-vpm418WZa9l1KUl6HRs+Ga+SIfE+D2/sV4olwTlilYc=", - "owner": "rust-lang", - "repo": "rust-analyzer", - "rev": "5be5e89cf0145d73a85c805726821b4adfc3af48", - "type": "github" - }, - "original": { - "owner": "rust-lang", - "ref": "nightly", - "repo": "rust-analyzer", - "type": "github" - } - }, - "rust-analyzer-src_3": { - "flake": false, - "locked": { - "lastModified": 1788637890, - "narHash": "sha256-mA2f4nLHFVf4pVvqbnNLdd6DcFN6Qqb0t1gWxaFRHcU=", - "owner": "rust-lang", - "repo": "rust-analyzer", - "rev": "04a5986d7b8d274cf93f071613e50d884e4056a0", - "type": "github" - }, - "original": { - "owner": "rust-lang", - "ref": "nightly", - "repo": "rust-analyzer", - "type": "github" - } - }, - "rust-analyzer-src_4": { - "flake": false, - "locked": { - "lastModified": 1767905519, - "narHash": "sha256-mRU9VEhGQE9dnOU3pu1Rx3dZO4NpZO+cnC0rPMFcCqE=", - "owner": "rust-lang", - "repo": "rust-analyzer", - "rev": "ff9a2e88b14907562294838f83963e5966f717de", - "type": "github" - }, - "original": { - "owner": "rust-lang", - "ref": "nightly", - "repo": "rust-analyzer", - "type": "github" - } - }, - "rust-analyzer-src_5": { - "flake": false, - "locked": { - "lastModified": 1783779020, - "narHash": "sha256-vpm418WZa9l1KUl6HRs+Ga+SIfE+D2/sV4olwTlilYc=", - "owner": "rust-lang", - "repo": "rust-analyzer", - "rev": "5be5e89cf0145d73a85c805726821b4adfc3af48", - "type": "github" - }, - "original": { - "owner": "rust-lang", - "ref": "nightly", - "repo": "rust-analyzer", - "type": "github" - } - }, - "rust-analyzer-src_6": { - "flake": false, - "locked": { - "lastModified": 1767905519, - "narHash": "sha256-mRU9VEhGQE9dnOU3pu1Rx3dZO4NpZO+cnC0rPMFcCqE=", - "owner": "rust-lang", - "repo": "rust-analyzer", - "rev": "ff9a2e88b14907562294838f83963e5966f717de", - "type": "github" - }, - "original": { - "owner": "rust-lang", - "ref": "nightly", - "repo": "rust-analyzer", - "type": "github" - } - }, - "rust-analyzer-src_7": { - "flake": false, - "locked": { - "lastModified": 1783779020, - "narHash": "sha256-vpm418WZa9l1KUl6HRs+Ga+SIfE+D2/sV4olwTlilYc=", - "owner": "rust-lang", - "repo": "rust-analyzer", - "rev": "5be5e89cf0145d73a85c805726821b4adfc3af48", - "type": "github" - }, - "original": { - "owner": "rust-lang", - "ref": "nightly", - "repo": "rust-analyzer", - "type": "github" - } - }, - "rust-analyzer-src_8": { - "flake": false, - "locked": { - "lastModified": 1767905519, - "narHash": "sha256-mRU9VEhGQE9dnOU3pu1Rx3dZO4NpZO+cnC0rPMFcCqE=", - "owner": "rust-lang", - "repo": "rust-analyzer", - "rev": "ff9a2e88b14907562294838f83963e5966f717de", - "type": "github" - }, - "original": { - "owner": "rust-lang", - "ref": "nightly", - "repo": "rust-analyzer", - "type": "github" - } - }, - "rust-analyzer-src_9": { - "flake": false, - "locked": { - "lastModified": 1783779020, - "narHash": "sha256-vpm418WZa9l1KUl6HRs+Ga+SIfE+D2/sV4olwTlilYc=", - "owner": "rust-lang", - "repo": "rust-analyzer", - "rev": "5be5e89cf0145d73a85c805726821b4adfc3af48", - "type": "github" - }, - "original": { - "owner": "rust-lang", - "ref": "nightly", - "repo": "rust-analyzer", - "type": "github" - } - }, - "rust-overlay": { - "inputs": { - "nixpkgs": [ - "splora", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1789802016, - "narHash": "sha256-QVFfArbYaQMGK4dFV+5RUL72Ci+hNHVhaF/R9qJfDhs=", - "owner": "oxalica", - "repo": "rust-overlay", - "rev": "26a71e661c47bd21a05d06fec749f3f7c75e9d12", - "type": "github" - }, - "original": { - "owner": "oxalica", - "repo": "rust-overlay", - "type": "github" - } - }, - "selfci": { - "inputs": { - "bundlers": "bundlers", - "flake-utils": [ - "tau", - "flake-utils" - ], - "flakebox": "flakebox_3", - "nixpkgs": [ - "tau", - "nixpkgs" - ], - "nixpkgs-unstable": "nixpkgs-unstable" - }, - "locked": { - "lastModified": 1787439117, - "narHash": "sha256-rrF5lmYOruM79hZptepsUcEWVocpRDmZgAGCsKab5yM=", - "ref": "refs/heads/master", - "rev": "7b59399d2f4bb55af42c488f5b8b8ab740d4647b", - "revCount": 138, - "type": "git", - "url": "https://radicle.dpc.pw/z2tDzYbAXxTQEKTGFVwiJPajkbeDU.git" - }, - "original": { - "type": "git", - "url": "https://radicle.dpc.pw/z2tDzYbAXxTQEKTGFVwiJPajkbeDU.git" - } - }, - "selfci_2": { - "inputs": { - "bundlers": "bundlers_2", - "flake-utils": [ - "tau", - "tau-ext-pim", - "flake-utils" - ], - "flakebox": "flakebox_5", - "nixpkgs": [ - "tau", - "tau-ext-pim", - "nixpkgs" - ], - "nixpkgs-unstable": "nixpkgs-unstable_2" - }, - "locked": { - "lastModified": 1787439117, - "narHash": "sha256-rrF5lmYOruM79hZptepsUcEWVocpRDmZgAGCsKab5yM=", - "ref": "refs/heads/master", - "rev": "7b59399d2f4bb55af42c488f5b8b8ab740d4647b", - "revCount": 138, - "type": "git", - "url": "https://radicle.dpc.pw/z2tDzYbAXxTQEKTGFVwiJPajkbeDU.git" - }, - "original": { - "type": "git", - "url": "https://radicle.dpc.pw/z2tDzYbAXxTQEKTGFVwiJPajkbeDU.git" - } - }, - "selfci_3": { - "inputs": { - "bundlers": "bundlers_3", - "flake-utils": [ - "tau", - "tau-ext-rostra", - "flake-utils" - ], - "flakebox": "flakebox_7", - "nixpkgs": [ - "tau", - "tau-ext-rostra", - "nixpkgs" - ], - "nixpkgs-unstable": "nixpkgs-unstable_3" - }, - "locked": { - "lastModified": 1787439117, - "narHash": "sha256-rrF5lmYOruM79hZptepsUcEWVocpRDmZgAGCsKab5yM=", - "ref": "refs/heads/master", - "rev": "7b59399d2f4bb55af42c488f5b8b8ab740d4647b", - "revCount": 138, - "type": "git", - "url": "https://radicle.dpc.pw/z2tDzYbAXxTQEKTGFVwiJPajkbeDU.git" - }, - "original": { - "type": "git", - "url": "https://radicle.dpc.pw/z2tDzYbAXxTQEKTGFVwiJPajkbeDU.git" - } - }, - "selfci_4": { - "inputs": { - "bundlers": "bundlers_4", - "flake-utils": [ - "tau", - "tau-ext-slack", - "flake-utils" - ], - "flakebox": "flakebox_9", - "nixpkgs": [ - "tau", - "tau-ext-slack", - "nixpkgs" - ], - "nixpkgs-unstable": "nixpkgs-unstable_4" - }, - "locked": { - "lastModified": 1784067993, - "narHash": "sha256-WG+nqPXh2jSKSoALvAxJzjSrsNDtL7rPPsPecLOACqo=", - "ref": "refs/heads/master", - "rev": "b1847724e182ad5dee276aed80f6bc6b4e0cddf4", - "revCount": 133, - "type": "git", - "url": "https://radicle.dpc.pw/z2tDzYbAXxTQEKTGFVwiJPajkbeDU.git" - }, - "original": { - "type": "git", - "url": "https://radicle.dpc.pw/z2tDzYbAXxTQEKTGFVwiJPajkbeDU.git" - } - }, - "selfci_5": { - "inputs": { - "bundlers": "bundlers_5", - "flake-utils": [ - "tau", - "tau-ext-swarm", - "flake-utils" - ], - "flakebox": "flakebox_11", - "nixpkgs": [ - "tau", - "tau-ext-swarm", - "nixpkgs" - ], - "nixpkgs-unstable": "nixpkgs-unstable_5" - }, - "locked": { - "lastModified": 1787439117, - "narHash": "sha256-rrF5lmYOruM79hZptepsUcEWVocpRDmZgAGCsKab5yM=", - "ref": "refs/heads/master", - "rev": "7b59399d2f4bb55af42c488f5b8b8ab740d4647b", - "revCount": 138, - "type": "git", - "url": "https://radicle.dpc.pw/z2tDzYbAXxTQEKTGFVwiJPajkbeDU.git" - }, - "original": { - "type": "git", - "url": "https://radicle.dpc.pw/z2tDzYbAXxTQEKTGFVwiJPajkbeDU.git" - } - }, - "selfci_6": { - "inputs": { - "bundlers": "bundlers_6", - "flake-utils": [ - "tau", - "tau-ext-telegram", - "flake-utils" - ], - "flakebox": "flakebox_13", - "nixpkgs": [ - "tau", - "tau-ext-telegram", - "nixpkgs" - ], - "nixpkgs-unstable": "nixpkgs-unstable_6" - }, - "locked": { - "lastModified": 1784067993, - "narHash": "sha256-WG+nqPXh2jSKSoALvAxJzjSrsNDtL7rPPsPecLOACqo=", - "ref": "refs/heads/master", - "rev": "b1847724e182ad5dee276aed80f6bc6b4e0cddf4", - "revCount": 133, - "type": "git", - "url": "https://radicle.dpc.pw/z2tDzYbAXxTQEKTGFVwiJPajkbeDU.git" - }, - "original": { - "type": "git", - "url": "https://radicle.dpc.pw/z2tDzYbAXxTQEKTGFVwiJPajkbeDU.git" - } - }, - "selfci_7": { - "inputs": { - "bundlers": "bundlers_7", - "flake-utils": [ - "tau", - "tau-ext-xmpp", - "flake-utils" - ], - "flakebox": "flakebox_15", - "nixpkgs": [ - "tau", - "tau-ext-xmpp", - "nixpkgs" - ], - "nixpkgs-unstable": "nixpkgs-unstable_7" - }, - "locked": { - "lastModified": 1787439117, - "narHash": "sha256-rrF5lmYOruM79hZptepsUcEWVocpRDmZgAGCsKab5yM=", - "ref": "refs/heads/master", - "rev": "7b59399d2f4bb55af42c488f5b8b8ab740d4647b", - "revCount": 138, - "type": "git", - "url": "https://radicle.dpc.pw/z2tDzYbAXxTQEKTGFVwiJPajkbeDU.git" - }, - "original": { - "type": "git", - "url": "https://radicle.dpc.pw/z2tDzYbAXxTQEKTGFVwiJPajkbeDU.git" - } - }, - "selfci_8": { - "inputs": { - "bundlers": "bundlers_8", - "flake-utils": [ - "tau", - "tau-ext-zulip", - "flake-utils" - ], - "flakebox": "flakebox_17", - "nixpkgs": [ - "tau", - "tau-ext-zulip", - "nixpkgs" - ], - "nixpkgs-unstable": "nixpkgs-unstable_8" - }, - "locked": { - "lastModified": 1784067993, - "narHash": "sha256-WG+nqPXh2jSKSoALvAxJzjSrsNDtL7rPPsPecLOACqo=", - "ref": "refs/heads/master", - "rev": "b1847724e182ad5dee276aed80f6bc6b4e0cddf4", - "revCount": 133, - "type": "git", - "url": "https://radicle.dpc.pw/z2tDzYbAXxTQEKTGFVwiJPajkbeDU.git" - }, - "original": { - "type": "git", - "url": "https://radicle.dpc.pw/z2tDzYbAXxTQEKTGFVwiJPajkbeDU.git" - } - }, - "sops-nix": { - "inputs": { - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1792056300, - "narHash": "sha256-YU1LjDe1o2Ymg5zEiCHbmMXLkDCtxHQRiVqZ3nC5GGk=", - "owner": "Mic92", - "repo": "sops-nix", - "rev": "44931708c62937c2b1bf7953902989fa5bb9ee1b", - "type": "github" - }, - "original": { - "owner": "Mic92", - "repo": "sops-nix", - "rev": "44931708c62937c2b1bf7953902989fa5bb9ee1b", - "type": "github" - } - }, - "splora": { - "inputs": { - "asset-registry": "asset-registry", - "crane": "crane_3", - "nixpkgs": "nixpkgs_3", - "rust-overlay": "rust-overlay" - }, - "locked": { - "lastModified": 1792021004, - "narHash": "sha256-EXxdItfP4u/jGCmdrCwetThKw2WB9PE4Ljbmc3dRbLQ=", - "owner": "SurmountSystems", - "repo": "splora", - "rev": "5164864cb7349fabc961b5e6c9da2829106bff5f", - "type": "github" - }, - "original": { - "owner": "SurmountSystems", - "ref": "surmount", - "repo": "splora", - "type": "github" - } - }, - "surmount-site": { - "flake": false, - "locked": { - "lastModified": 1787774658, - "narHash": "sha256-q01WrGxa68AogmpFZ6YfsDiBw/To1fHtm1VKU2Dsg4w=", - "owner": "SurmountSystems", - "repo": "site", - "rev": "82e07195e6aa221efe838e652ed8d2d8535b5cac", - "type": "github" - }, - "original": { - "owner": "SurmountSystems", - "repo": "site", - "type": "github" - } - }, - "systems": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_10": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_11": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_12": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_13": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_14": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_15": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_16": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_17": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_18": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_19": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_2": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_20": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_21": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_22": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_23": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_24": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_25": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_26": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_27": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_28": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_29": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_3": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_30": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_31": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_32": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_33": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_34": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_35": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_36": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_37": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_38": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_39": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_4": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_40": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_41": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_5": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_6": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_7": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_8": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_9": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "tau": { - "inputs": { - "dpc-public-skills": "dpc-public-skills", - "flake-utils": "flake-utils_2", - "flakebox": "flakebox_2", - "nixpkgs": "nixpkgs_4", - "selfci": "selfci", - "tau-ext-pim": "tau-ext-pim", - "tau-ext-rostra": "tau-ext-rostra", - "tau-ext-slack": "tau-ext-slack", - "tau-ext-swarm": "tau-ext-swarm", - "tau-ext-telegram": "tau-ext-telegram", - "tau-ext-xmpp": "tau-ext-xmpp", - "tau-ext-zulip": "tau-ext-zulip" - }, - "locked": { - "lastModified": 1791217127, - "narHash": "sha256-FtQmvCsLL1aEBIi1PobEu720kYLHNk7k84oFrtaLWWg=", - "owner": "SurmountSystems", - "repo": "tau", - "rev": "d2e1955b0491f538a486007d0d3ab779d95a828f", - "type": "github" - }, - "original": { - "owner": "SurmountSystems", - "repo": "tau", - "rev": "d2e1955b0491f538a486007d0d3ab779d95a828f", - "type": "github" - } - }, - "tau-ext-pim": { - "inputs": { - "flake-utils": "flake-utils_7", - "flakebox": "flakebox_4", - "nixpkgs": "nixpkgs_7", - "selfci": "selfci_2" - }, - "locked": { - "lastModified": 1790482451, - "narHash": "sha256-BYHxo8/xKzoN87UjYBY6rrflmLpF9UTgZbCOie9aOWg=", - "ref": "main", - "rev": "ba7c65b71dd2c86761513c01fe57d0b4932f9834", - "revCount": 34, - "type": "git", - "url": "https://radicle.dpc.pw/z4FCuiVzFns5iTquhsYCntZyVWCqi.git" - }, - "original": { - "ref": "main", - "type": "git", - "url": "https://radicle.dpc.pw/z4FCuiVzFns5iTquhsYCntZyVWCqi.git" - } - }, - "tau-ext-rostra": { - "inputs": { - "flake-utils": "flake-utils_12", - "flakebox": "flakebox_6", - "nixpkgs": "nixpkgs_10", - "selfci": "selfci_3" - }, - "locked": { - "lastModified": 1790352987, - "narHash": "sha256-BVkCxAifXdZYIp45EcI+pOfW4yeHmP1+z+6tHlEAgV0=", - "ref": "main", - "rev": "54952ce5ccb16d4fe98c32bc9afc4da3a959cab9", - "revCount": 52, - "type": "git", - "url": "https://radicle.dpc.pw/z4LrrRivcgNjJii5wzbjTvA8ttt6o.git" - }, - "original": { - "ref": "main", - "type": "git", - "url": "https://radicle.dpc.pw/z4LrrRivcgNjJii5wzbjTvA8ttt6o.git" - } - }, - "tau-ext-slack": { - "inputs": { - "flake-utils": "flake-utils_17", - "flakebox": "flakebox_8", - "nixpkgs": "nixpkgs_13", - "selfci": "selfci_4" - }, - "locked": { - "lastModified": 1790352838, - "narHash": "sha256-wg89p0XnOTWr3xW9yPaIgA49ugRtsFEl/nBrv925/Bs=", - "ref": "master", - "rev": "c6a6b978ff331fdc2d06f627750e0417e2e74bcb", - "revCount": 146, - "type": "git", - "url": "https://radicle.dpc.pw/z3NJhEtKWCbHPa28wDQSYJ8eEfBjg.git" - }, - "original": { - "ref": "master", - "type": "git", - "url": "https://radicle.dpc.pw/z3NJhEtKWCbHPa28wDQSYJ8eEfBjg.git" - } - }, - "tau-ext-swarm": { - "inputs": { - "flake-utils": "flake-utils_22", - "flakebox": "flakebox_10", - "nixpkgs": "nixpkgs_16", - "selfci": "selfci_5" - }, - "locked": { - "lastModified": 1790481726, - "narHash": "sha256-LosChWjx/urNIi2qwuOxaa8DE7+01alk5pwkIc6padQ=", - "ref": "master", - "rev": "b2f8462a41e87d21a87f08e1f7255ad9aea9cba2", - "revCount": 61, - "type": "git", - "url": "https://radicle.dpc.pw/z38my9x3Rmn6VYtDMiLKgjK3tRv8o.git" - }, - "original": { - "ref": "master", - "type": "git", - "url": "https://radicle.dpc.pw/z38my9x3Rmn6VYtDMiLKgjK3tRv8o.git" - } - }, - "tau-ext-telegram": { - "inputs": { - "flake-utils": "flake-utils_27", - "flakebox": "flakebox_12", - "nixpkgs": "nixpkgs_19", - "selfci": "selfci_6" - }, - "locked": { - "lastModified": 1790483660, - "narHash": "sha256-5MuNcsPcEKt4sy6I0KVFlSNGyUGcrqbXwaQ1mHb3ZPA=", - "ref": "main", - "rev": "070211310362575b67fe6cbc78ad3a4b0d61e9b4", - "revCount": 142, - "type": "git", - "url": "https://radicle.dpc.pw/z3sPdSePnxtBvP9pTLwUwVgpMU68r.git" - }, - "original": { - "ref": "main", - "type": "git", - "url": "https://radicle.dpc.pw/z3sPdSePnxtBvP9pTLwUwVgpMU68r.git" - } - }, - "tau-ext-xmpp": { - "inputs": { - "flake-utils": "flake-utils_32", - "flakebox": "flakebox_14", - "nixpkgs": "nixpkgs_22", - "selfci": "selfci_7" - }, - "locked": { - "lastModified": 1790353392, - "narHash": "sha256-SZuUzAAUzMTLl16N/8AnKNYiTjAkSmRDF/AYDIgGOME=", - "ref": "master", - "rev": "5ef637f7be0272e91e03b876c3198abb9b057661", - "revCount": 97, - "type": "git", - "url": "https://radicle.dpc.pw/zpN6uwkd6ok9qRAX5yZaF7w8xzDd.git" - }, - "original": { - "ref": "master", - "type": "git", - "url": "https://radicle.dpc.pw/zpN6uwkd6ok9qRAX5yZaF7w8xzDd.git" - } - }, - "tau-ext-zulip": { - "inputs": { - "flake-utils": "flake-utils_37", - "flakebox": "flakebox_16", - "nixpkgs": "nixpkgs_25", - "selfci": "selfci_8" - }, - "locked": { - "lastModified": 1790352813, - "narHash": "sha256-/hZW+qzJkrhFir2Zurq30IfbuoVNGI7DI9eMMMsuOtE=", - "ref": "main", - "rev": "aa2c28e70799e5b6567487f69fc5f2805325d86d", - "revCount": 72, - "type": "git", - "url": "https://radicle.dpc.pw/z2LFTBWK7VpAwC3Bpxohkh91aqXd.git" - }, - "original": { - "ref": "main", - "type": "git", - "url": "https://radicle.dpc.pw/z2LFTBWK7VpAwC3Bpxohkh91aqXd.git" - } - }, - "utils": { - "inputs": { - "systems": "systems_4" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "utils_2": { - "inputs": { - "systems": "systems_9" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "utils_3": { - "inputs": { - "systems": "systems_14" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "utils_4": { - "inputs": { - "systems": "systems_19" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "utils_5": { - "inputs": { - "systems": "systems_24" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "utils_6": { - "inputs": { - "systems": "systems_29" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "utils_7": { - "inputs": { - "systems": "systems_34" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "utils_8": { - "inputs": { - "systems": "systems_39" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "lastModified": 1791490544, + "narHash": "sha256-qScE7Wz6Zg7eg8ItUpnkmKgst8weWschGe0E8jM4rj4=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "4cfe8d7065cc8ca91c0a50361b6dd74920eeb1f5", "type": "github" }, "original": { "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "wild": { - "inputs": { - "crane": "crane_7", - "nixpkgs": [ - "tau", - "selfci", - "flakebox", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1762215977, - "narHash": "sha256-x0IZuWjj0LRMj4pu2FVaD8SENm/UVtE1e4rl0EOZZZM=", - "owner": "davidlattimore", - "repo": "wild", - "rev": "7daaf0c89f7b4b9c9ded36e7b3c72aa6512537a1", - "type": "github" - }, - "original": { - "owner": "davidlattimore", - "ref": "0.7.0", - "repo": "wild", - "type": "github" - } - }, - "wild_2": { - "inputs": { - "crane": "crane_10", - "nixpkgs": [ - "tau", - "tau-ext-pim", - "selfci", - "flakebox", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1762215977, - "narHash": "sha256-x0IZuWjj0LRMj4pu2FVaD8SENm/UVtE1e4rl0EOZZZM=", - "owner": "davidlattimore", - "repo": "wild", - "rev": "7daaf0c89f7b4b9c9ded36e7b3c72aa6512537a1", - "type": "github" - }, - "original": { - "owner": "davidlattimore", - "ref": "0.7.0", - "repo": "wild", - "type": "github" - } - }, - "wild_3": { - "inputs": { - "crane": "crane_13", - "nixpkgs": [ - "tau", - "tau-ext-rostra", - "selfci", - "flakebox", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1762215977, - "narHash": "sha256-x0IZuWjj0LRMj4pu2FVaD8SENm/UVtE1e4rl0EOZZZM=", - "owner": "davidlattimore", - "repo": "wild", - "rev": "7daaf0c89f7b4b9c9ded36e7b3c72aa6512537a1", - "type": "github" - }, - "original": { - "owner": "davidlattimore", - "ref": "0.7.0", - "repo": "wild", - "type": "github" - } - }, - "wild_4": { - "inputs": { - "crane": "crane_16", - "nixpkgs": [ - "tau", - "tau-ext-slack", - "selfci", - "flakebox", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1762215977, - "narHash": "sha256-x0IZuWjj0LRMj4pu2FVaD8SENm/UVtE1e4rl0EOZZZM=", - "owner": "davidlattimore", - "repo": "wild", - "rev": "7daaf0c89f7b4b9c9ded36e7b3c72aa6512537a1", - "type": "github" - }, - "original": { - "owner": "davidlattimore", - "ref": "0.7.0", - "repo": "wild", - "type": "github" - } - }, - "wild_5": { - "inputs": { - "crane": "crane_19", - "nixpkgs": [ - "tau", - "tau-ext-swarm", - "selfci", - "flakebox", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1762215977, - "narHash": "sha256-x0IZuWjj0LRMj4pu2FVaD8SENm/UVtE1e4rl0EOZZZM=", - "owner": "davidlattimore", - "repo": "wild", - "rev": "7daaf0c89f7b4b9c9ded36e7b3c72aa6512537a1", - "type": "github" - }, - "original": { - "owner": "davidlattimore", - "ref": "0.7.0", - "repo": "wild", - "type": "github" - } - }, - "wild_6": { - "inputs": { - "crane": "crane_22", - "nixpkgs": [ - "tau", - "tau-ext-telegram", - "selfci", - "flakebox", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1762215977, - "narHash": "sha256-x0IZuWjj0LRMj4pu2FVaD8SENm/UVtE1e4rl0EOZZZM=", - "owner": "davidlattimore", - "repo": "wild", - "rev": "7daaf0c89f7b4b9c9ded36e7b3c72aa6512537a1", - "type": "github" - }, - "original": { - "owner": "davidlattimore", - "ref": "0.7.0", - "repo": "wild", - "type": "github" - } - }, - "wild_7": { - "inputs": { - "crane": "crane_25", - "nixpkgs": [ - "tau", - "tau-ext-xmpp", - "selfci", - "flakebox", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1762215977, - "narHash": "sha256-x0IZuWjj0LRMj4pu2FVaD8SENm/UVtE1e4rl0EOZZZM=", - "owner": "davidlattimore", - "repo": "wild", - "rev": "7daaf0c89f7b4b9c9ded36e7b3c72aa6512537a1", - "type": "github" - }, - "original": { - "owner": "davidlattimore", - "ref": "0.7.0", - "repo": "wild", - "type": "github" - } - }, - "wild_8": { - "inputs": { - "crane": "crane_28", - "nixpkgs": [ - "tau", - "tau-ext-zulip", - "selfci", - "flakebox", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1762215977, - "narHash": "sha256-x0IZuWjj0LRMj4pu2FVaD8SENm/UVtE1e4rl0EOZZZM=", - "owner": "davidlattimore", - "repo": "wild", - "rev": "7daaf0c89f7b4b9c9ded36e7b3c72aa6512537a1", - "type": "github" - }, - "original": { - "owner": "davidlattimore", - "ref": "0.7.0", - "repo": "wild", + "repo": "treefmt-nix", "type": "github" } } diff --git a/flake.nix b/flake.nix index 8bf283f..8f345fa 100644 --- a/flake.nix +++ b/flake.nix @@ -10,6 +10,9 @@ # GHA: .github/workflows/ci.yml job display name is `just ci` (required-check footgun) # End-to-end: nix run .#e2e (local) / nix run .#e2e-host (env-gated; never in ci) # Operator bins: nix run .# -- args (just aliases only nix-run). +# Speedtest: nix run .#speedtest (Ookla CLI; --accept-license --accept-gdpr). +# Module: surmount.speedtest.enable ; timer: surmount.speedtest.periodic.enable +# (default off, interval 6h, JSON to the journal, optional logFile). # Heavy: mail-vm-test, mail-vps-eval, stalwart-mail package are optional. { @@ -32,10 +35,13 @@ # Stalwart engine is NOT taken from this channel. See # nix/packages/stalwart-mail.nix and modules/stalwart-service.nix. # Arti engine is NOT taken from this channel. See - # nix/packages/arti-onion-service.nix (Surmount-owned 2.7.0 source build). + # nix/packages/arti-onion-service.nix (Surmount-owned 2.8.0 source build; + # cargoHash is set to sha256-grgIqU1gw1UqeGgR4aR7cAOxBW91F12bHMgOj4pgLcY=). + # Builder-login Lean is NOT pkgs.lean4 from this channel (4.29.1). + # See nix/packages/lean4.nix (official release v4.35.0). nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; - # rustc/cargo for crane packages and the grok-oss build. Not host + # rustc/cargo for crane packages and the Arti source build. Not host # nixos-26.05 (pkgs.rustc 1.95.0). Not nixos-unstable. Not beta 1.100. # Master HEAD 27347cc2965498fa03393739031c9ef8ecd72c0a (2026-10-19) # still has pkgs/development/compilers/rust/1_99.nix at rustcVersion @@ -65,6 +71,15 @@ inputs.nixpkgs.follows = "nixpkgs"; }; + # Rust nixfmt (Mic92/nixfmt-rs), not Haskell pkgs.nixfmt. + # Default-branch HEAD c22b2c9ef0ad3b66f97ebd6296fbba298aa9e039, + # read 2026-11-05 (commit date 2026-10-14). Do not float main. + # Their published byte match is Haskell nixfmt 1.2.0, not this + # channel's pkgs.nixfmt 1.5.0. Do not set inputs.nixpkgs.follows: + # their flake pins its own nixpkgs, so this repo's gcc.arch overlay + # cannot reach the formatter build. + nixfmt-rs.url = "github:Mic92/nixfmt-rs/c22b2c9ef0ad3b66f97ebd6296fbba298aa9e039"; + # Public static site for apex/www. Fetch the git tree (HTML/CSS/JS). # Operator bumps: just publish (nix flake update surmount-site). surmount-site = { @@ -79,29 +94,10 @@ flake = false; }; - # Grok OSS TUI (bin/grok-oss). - # Commit pin, not a floating main ref. The repo stays - # github:SurmountSystems/grok-oss. This rev is - # 182293cb30fa5414dd47686b92708103519b7462. Prior lock was - # decb052744d25b63dbca8a95ca65b4ad794b9ad5. Earlier lock was - # 7dc3685277bb65c4a0634e91a6e2596e48ad882f (main). - # Do not retarget this input to a side branch. - # Default-off NixOS module; package is fail-closed when enable=true. - grok-oss.url = "github:SurmountSystems/grok-oss/182293cb30fa5414dd47686b92708103519b7462"; - - # Tau (bin/tau). SurmountSystems/tau master at - # d2e1955b0491f538a486007d0d3ab779d95a828f (committer - # 2026-10-05T16:18:47Z). This is the operator-synced tree, - # not github:dpc/tau. Commit pin, not a floating master ref. - # Do not follow their nixpkgs. Their nested nixpkgs-unstable - # stays their lock. Default-off NixOS module; package is - # fail-closed when enable=true. - tau.url = "github:SurmountSystems/tau/d2e1955b0491f538a486007d0d3ab779d95a828f"; - # Splora (Esplora-compatible indexer). Operator bumps with nix flake update splora. # NixOS module is nixosModules.splora. REST is one indexer instance plus # JSON-RPC and a cookie; a local bitcoind datadir is not required. - splora.url = "github:SurmountSystems/splora/surmount"; + splora.url = "github:SurmountSystems/splora/28eb3ea077eee02bd8a9c0cba63ee9a611b10e54"; }; outputs = @@ -111,10 +107,9 @@ nixpkgs-rust, crane, sops-nix, + nixfmt-rs, surmount-site, advisory-db, - grok-oss, - tau, splora, ... }: @@ -143,6 +138,26 @@ inherit system; }; + # nix/overlays.nix exports .pins and .tzdata. Not a second overlay + # home. surmountOverlay remains the Surmount package set. + # .pins is the Nix 2.35.4 cli (not nix-everything). + # .tzdata sets tzdata 2026e doCheck = false (now.ck). The mail-host + # nixosSystem below imports .tzdata. mkPkgs does not, so + # checks.x86_64-linux.ci does not rebuild CPython for that skip. + # .noHaskell replaces nixos-firewall-tool with writeShellScriptBin. + # It does not call prev.nixos-firewall-tool. It throws if the mail + # host references ghc, haskellPackages, haskell.compiler, hadrian, + # ShellCheck, python, python3, or pythonPackages. The firewall-tool + # check was the remaining Haskell edge. mkPkgs does not import it. + surmountOverlayParts = import ./nix/overlays.nix; + surmountPins = surmountOverlayParts.pins; + surmountTzdata = surmountOverlayParts.tzdata; + surmountNoHaskell = surmountOverlayParts.noHaskell; + # glibc configure drops the interpreter from the critical-program + # list. Mail-vps only, after noHaskell, so prev.glibc is the + # scrubbed one. Locale data is still produced by localedef. + glibcHeaderGenOverlay = import ./nix/glibc-header-gen-overlay.nix; + # rustPlatform whose rustc meets Arti MSRV; C deps still come from host pkgs. mkArtiRustPlatform = system: @@ -187,22 +202,24 @@ surmount-secrets-install = self.packages.${system}.surmount-secrets-install; surmount-secrets-prompt = self.packages.${system}.surmount-secrets-prompt; surmount-rekey = self.packages.${system}.surmount-rekey; - grok-oss = self.packages.${system}.grok-oss; - # Flake-input bin/tau (packages.tau). Not referenced unless - # surmount.tau.enable is true. Default-off eval must not - # realize this derivation. - tau = self.packages.${system}.tau; + # Official Lean 4.35.0 linux x86_64. Not channel pkgs.lean4. + surmount-lean4 = self.packages.${system}.surmount-lean4; # Flake-input packages. Upstream crane src omits .cargo/config.toml # (laptop cargo still uses Menhera). Do not wrap src again here. splora = self.packages.${system}.splora; splora-liquid = self.packages.${system}.splora-liquid; + # Wrapped Ookla CLI. Unfree allowlist is only ookla-speedtest. + speedtest = self.packages.${system}.speedtest; }; mkPkgs = system: import nixpkgs { inherit system; - overlays = [ (surmountOverlay system) ]; + overlays = [ + surmountPins + (surmountOverlay system) + ]; }; # Shared crane args for management-ui checks (test/clippy/fmt). @@ -336,7 +353,13 @@ ( { ... }: { - nixpkgs.overlays = [ (surmountOverlay "x86_64-linux") ]; + nixpkgs.overlays = [ + surmountPins + surmountTzdata + (surmountOverlay "x86_64-linux") + surmountNoHaskell + glibcHeaderGenOverlay + ]; } ) ] @@ -373,10 +396,13 @@ surmount-public-site = pkgs.callPackage ./nix/packages/surmount-public-site.nix { src = surmount-site; }; - # Surmount-owned Arti 2.7.0 + onion-service-service (HS publish). + # Surmount-owned Arti 2.8.0 + onion-service-service (HS publish). # Not a drop-in for pkgs.arti; heavy cargo build, not in checks.ci. - # rustPlatform + artiUnstable.cargoDeps from nixpkgs-rust (MSRV 1.91+; - # vendor handoff avoids crates.io 403). C deps from host pkgs. + # rustPlatform from nixpkgs-rust (MSRV 1.92). The Arti 2.8.0 + # cargoHash is set to + # sha256-grgIqU1gw1UqeGgR4aR7cAOxBW91F12bHMgOj4pgLcY=. + # Do not use artiUnstable.cargoDeps (that tree is 2.7.0). + # C deps from host pkgs. arti-onion-service = pkgs.callPackage ./nix/packages/arti-onion-service.nix { rustPlatform = mkArtiRustPlatform system; artiUnstable = (mkPkgsRust system).arti; @@ -444,19 +470,6 @@ surmount-rekey = pkgs.callPackage ./nix/packages/surmount-rekey.nix { inherit craneLib pkgsRust; }; - # Grok OSS from locked github:SurmountSystems/grok-oss (operator-bump). - # Not in checks.ci (heavy crane). Module stays default-off. - # rustToolchain is nixpkgs-rust 1.99.1, not host pkgs.rustc 1.95.0. - grok-oss = pkgs.callPackage ./nix/packages/grok-oss.nix { - grokOssFlake = grok-oss; - inherit system rustToolchain; - }; - # Tau from locked github:SurmountSystems/tau (operator-synced - # tree, not github:dpc/tau). Consume packages.tau only - # (upstream nix/pkgs/tau.nix). Not in checks.ci. - # Module stays default-off. Fail-closed when enable=true. - tau = - tau.packages.${system}.tau or (throw "tau flake input has no packages.${system}.tau (fail-closed)"); # Flake-input splora packages. Upstream crane omits # .cargo/config.toml from src and builds --offline --locked. # Not in checks.ci: the real package is a rocksdb crane build. @@ -466,13 +479,43 @@ splora-liquid = sploraInput.packages.${system}.splora-liquid or (throw "splora flake input has no packages.${system}.splora-liquid (fail-closed)"); - # RFC-style nixfmt (same as formatter / checks.ci / devShell). - # Use: nix run .#nixfmt -- file.nix or nix shell .#nixfmt -c nixfmt … - # nixos-26.05: nixfmt-rfc-style is an alias of pkgs.nixfmt (prefer the latter). - nixfmt = pkgs.nixfmt; + # Trunk client from frontend/web. cargoHash is the fetchCargoVendor + # vendor-staging hash in nix/packages/splora-web.nix. This Nix src + # omits the laptop Menhera config. The build phase is offline. + splora-web = pkgs.callPackage ./nix/packages/splora-web.nix { inherit craneLib sploraInput; }; + # Rust nixfmt-rs (Mic92). Not Haskell pkgs.nixfmt and not + # nixfmt-rfc-style (that alias is the channel Haskell package). + # Upstream attribute is packages.${system}.default. Binary name + # is nixfmt. Author claims byte-identical output with Haskell + # nixfmt 1.2.0, not with nixos-26.05 pkgs.nixfmt 1.5.0. + # Pin c22b2c9ef0ad3b66f97ebd6296fbba298aa9e039, read 2026-11-05. + # Use: nix run .#nixfmt -- file.nix or nix shell .#nixfmt -c nixfmt ... + nixfmt = nixfmt-rs.packages.${system}.default; # Flake-pinned just for GHA (`nix shell .#just -c just ci`) and local - # bootstrap without a host just install. Same pattern as grok-oss. + # bootstrap without a host just install. just = pkgs.just; + # Mutinynet-only bitcoind. Does not replace pkgs.bitcoind. + # testnet3 and testnet4 stay on the stock 32.1 package. + bitcoind-mutinynet = pkgs.callPackage ./nix/packages/bitcoind-mutinynet.nix { }; + # Official Lean v4.35.0 linux x86_64 release. Not pkgs.lean4. + surmount-lean4 = pkgs.callPackage ./nix/packages/lean4.nix { }; + # Ookla Speedtest CLI with --accept-license --accept-gdpr. + # Unfree predicate is only ookla-speedtest. nix run .#speedtest + speedtest = + let + pkgsOokla = import nixpkgs { + inherit system; + config.allowUnfreePredicate = + pkg: + builtins.elem (lib.getName pkg) [ + "ookla-speedtest" + "speedtest" + ]; + }; + in + pkgsOokla.callPackage ./nix/packages/speedtest.nix { }; + # boyter/scc from the host nixpkgs channel. + scc = pkgs.scc; default = self.packages.${system}.management-ui; } ); @@ -526,10 +569,6 @@ type = "app"; program = "${self.packages.${system}.surmount-host-probe}/bin/surmount-et"; }; - surmount-grok-oss = { - type = "app"; - program = "${self.packages.${system}.surmount-host-probe}/bin/surmount-grok-oss"; - }; surmount-deploy-static-sites = { type = "app"; program = "${self.packages.${system}.surmount-static-sites}/bin/surmount-deploy-static-sites"; @@ -646,6 +685,14 @@ type = "app"; program = "${self.packages.${system}.surmount-rekey}/bin/surmount-rekey"; }; + speedtest = { + type = "app"; + program = "${self.packages.${system}.speedtest}/bin/speedtest"; + }; + scc = { + type = "app"; + program = "${self.packages.${system}.scc}/bin/scc"; + }; }); checks = forAllSystems ( @@ -975,7 +1022,10 @@ nix-fmt-check = pkgs.runCommand "nix-fmt-check" { - nativeBuildInputs = [ pkgs.nixfmt ]; + nativeBuildInputs = [ + # Rust nixfmt-rs. Not pkgs.nixfmt (Haskell, compiles GHC). + nixfmt-rs.packages.${system}.default + ]; } '' set -euo pipefail @@ -1110,18 +1160,19 @@ in { default = pkgs.mkShell { - packages = with pkgs; [ + packages = [ # Match crane toolchain (nix/rust-toolchain.nix, nixpkgs-rust 1.99.1). (import ./nix/rust-toolchain.nix { pkgs = mkPkgsRust system; }) - rust-analyzer - pkg-config - nixfmt - just - sops - age - ssh-to-age - git - jq + pkgs.rust-analyzer + pkgs.pkg-config + # Rust nixfmt-rs. Not pkgs.nixfmt (Haskell, compiles GHC). + nixfmt-rs.packages.${system}.default + pkgs.just + pkgs.sops + pkgs.age + pkgs.ssh-to-age + pkgs.git + pkgs.jq ]; RUST_LOG = "info,surmount_management_ui=debug"; shellHook = '' @@ -1148,6 +1199,8 @@ } ); - formatter = forAllSystems (system: (mkPkgs system).nixfmt); + # Rust nixfmt-rs. Not (mkPkgs system).nixfmt: that attr is Haskell + # pkgs.nixfmt, and the mail-host gcc.arch overlay must not build it. + formatter = forAllSystems (system: nixfmt-rs.packages.${system}.default); }; } diff --git a/hosts/mail-vps/configuration.nix b/hosts/mail-vps/configuration.nix index 04005ec..8cf0d08 100644 --- a/hosts/mail-vps/configuration.nix +++ b/hosts/mail-vps/configuration.nix @@ -66,7 +66,7 @@ # a production leaf that names mail., run # `just point-stalwart-mail-tls -- --live --restart` on the host. # Same durable PEMs as Axum. Not product browser :443. -# 14. Review fail2ban/ssh exposure; lock SSH to admin nets if possible. +# 14. Review sshguard bans and SSH exposure; lock SSH to admin nets if possible. # 15. Enable surmount.artiHiddenService when HS identity is on host (later). # With https UI, leave arti backendAddress null so the module auto- # binds loopback cleartext API for the onion reverse-proxy (or set @@ -100,6 +100,9 @@ # ---- Surmount stack ------------------------------------------------------ surmount = { enable = true; + # Measured guest CPU is x86-64-v3 (no avx512). Source builds only. + # Flake packages.* and the generic CI host stay untuned. + mailHostCpu.enable = true; primaryDomain = "surmount.systems"; mailHostname = "mail.surmount.systems"; servicesHostname = "services.surmount.systems"; @@ -280,7 +283,7 @@ fsType = "ext4"; }; - time.timeZone = "UTC"; + time.timeZone = "America/Denver"; i18n.defaultLocale = "en_US.UTF-8"; # ---- Networking (provider-specific) -------------------------------------- @@ -323,24 +326,41 @@ trusted-public-keys = [ "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" ]; - # Host cargo cache. An administrator creates the directory. - extra-sandbox-paths = [ - "/var/cache/grok-oss-cargo-target" - ]; }; nixpkgs.config.allowUnfree = false; + # nixos-render-docs is a Python application. The mail host does not + # install the NixOS HTML manual or the configuration man page that + # renderer builds. nftables and the firewall stay enabled. + documentation.nixos.enable = false; + # ---- Minimal conveniences ------------------------------------------------ environment.systemPackages = with pkgs; [ + # This nixpkgs vim is the small editor. It does not take an + # interpreter-feature argument. vim-full is the package that + # defaults that feature on, and this host does not install it. vim htop curl dig tcpdump - git + # nixos-26.05 git defaults pythonSupport to true, which installs + # the interpreter. rustSupport pulls cargo, and cargo's build + # input is that same interpreter. The mail host still has git. + # Both flags stay off. The manual stays off in the pins overlay. + (git.override { + pythonSupport = false; + rustSupport = false; + }) + scc + fio + sysstat ]; + # Wrapped Ookla CLI on PATH. Periodic JSON timer stays off. + surmount.speedtest.enable = true; + # Journal persistence + size cap live in modules/logging.nix # (surmount.logging; Storage=persistent, SystemMaxUse scaffold 1G). # Do not duplicate extraConfig here. diff --git a/justfile b/justfile index f6a50ae..5bd411d 100644 --- a/justfile +++ b/justfile @@ -262,8 +262,10 @@ deploy *args: fi exec nix run ".#surmount-deploy-host" -- --target root@surmount-1 --host-local /home/hunter/.local/share/surmount/host-local "$@" -# Same mail-host command as `just deploy`, with `--dry-run` before `--target`. Extra arguments append. -# Agents may run `just deploy-dry-run`. Do not pass `--install-secrets` unless secrets should be copied. +# Same target as `just deploy`, with `--dry-run` before `--target`. Extra arguments append. +# `--dry-run` rsyncs, bootstraps gccarch-x86-64-v3, and runs `nixos-rebuild build`. +# It does not switch the running generation and does not install secrets. +# Agents may run `just deploy-dry-run`. `--install-secrets` on a dry-run only prints the plan. [positional-arguments] deploy-dry-run *args: #!/usr/bin/env bash @@ -422,6 +424,12 @@ test-eternal-terminal-eval: test-inxi-eval: nix eval --impure --json --file tests/inxi-contract.nix +test-btop-eval: + nix eval --impure --json --file tests/btop-contract.nix + +test-perf-tools-eval: + nix eval --impure --json --file tests/perf-tools-contract.nix + test-remote-builder-eval: nix eval --impure --json --file tests/remote-builder-contract.nix diff --git a/modules/arti-hidden-service.nix b/modules/arti-hidden-service.nix index b2ab044..5f8f18d 100644 --- a/modules/arti-hidden-service.nix +++ b/modules/arti-hidden-service.nix @@ -31,7 +31,7 @@ # auto-points at managementUi local cleartext (loopback API bind). Pointing # cleartext Arti at the primary https TCP without that path still warns. # -# Version assumption: arti.toml shape matches Surmount-owned Arti 2.7.0 +# Version assumption: arti.toml shape matches Surmount-owned Arti 2.8.0 # (nix/packages/arti-onion-service.nix; not stock nixpkgs 1.4.x lag). Keys: # [storage] cache_dir/state_dir, [onion_services.""] proxy_ports, # [proxy] socks_listen (not legacy socks_port). See upstream @@ -166,8 +166,8 @@ let # No private keys in this file. Onion service identity lives under # onionServiceStateDir on the host (deploy secrets; never in git). # - # Version assumption: Surmount-owned Arti 2.7.0 config shape - # (nix/packages/arti-onion-service.nix; GitLab arti-v2.7.0). Not stock + # Version assumption: Surmount-owned Arti 2.8.0 config shape + # (nix/packages/arti-onion-service.nix; GitLab arti-v2.8.0). Not stock # nixpkgs 1.4.x lag. Keys: socks_listen, onion_services, proxy_ports. # Command: arti proxy -c /etc/surmount/arti.toml # @@ -197,7 +197,9 @@ let # stdout -> journald via the unit. Never log HS keys. [logging] - console = "info" + # debug records circuits and publish failures. Sensitive material + # (including hidden-service keys) stays off. Do not set trace. + console = "debug" log_sensitive_information = false # Per-site v3 onions reverse-proxying to management UI cleartext. @@ -348,6 +350,25 @@ in serviceConfig = { Type = "oneshot"; RemainAfterExit = true; + # Status text only. No host paths with key material are printed. + DynamicUser = true; + NoNewPrivileges = true; + PrivateTmp = true; + ProtectSystem = "strict"; + ProtectHome = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + RestrictNamespaces = true; + LockPersonality = true; + MemoryDenyWriteExecute = true; + RestrictRealtime = true; + RestrictAddressFamilies = [ "AF_UNIX" ]; + SystemCallArchitectures = "native"; + SyslogIdentifier = "surmount-arti-scaffold-status"; + StandardOutput = "journal"; + StandardError = "journal"; + LogRateLimitIntervalSec = "30s"; + LogRateLimitBurst = 50000; ExecStart = pkgs.writeShellScript "surmount-arti-status" '' set -eu echo "surmount: arti HS module enabled (management-publish config installed; startDaemon=false)." @@ -395,6 +416,15 @@ in ExecStartPost = "-${hostnamePublishScript}"; Restart = "on-failure"; RestartSec = "10s"; + # Ordinary CFS. Not fifo, not rr, not idle I/O, not realtime I/O. + Nice = -18; + # stdout is the Arti console (debug, sensitive off). Burst so a + # circuit storm does not drop the first publish failure. + SyslogIdentifier = "surmount-arti-hidden-service"; + StandardOutput = "journal"; + StandardError = "journal"; + LogRateLimitIntervalSec = "30s"; + LogRateLimitBurst = 50000; # state_dir is onionServiceStateDir (HS identity + instance state). # cache_dir is under stateDir. Both need write for a live publisher. ReadWritePaths = [ diff --git a/modules/backups.nix b/modules/backups.nix index 634a831..33a2160 100644 --- a/modules/backups.nix +++ b/modules/backups.nix @@ -36,5 +36,14 @@ in "--keep-monthly 6" ]; }; + + # restic already prints snapshot success and failure. Name the unit + # and keep those lines when a backup overlaps a journal flood. + # passwordFile stays a path. Nothing here enables the backup set. + systemd.services.restic-backups-surmount.serviceConfig = { + SyslogIdentifier = "restic-backups-surmount"; + LogRateLimitIntervalSec = "30s"; + LogRateLimitBurst = 50000; + }; }; } diff --git a/modules/btop.conf b/modules/btop.conf index e4b1253..10ff2cc 100644 --- a/modules/btop.conf +++ b/modules/btop.conf @@ -198,6 +198,8 @@ show_disks = true only_physical = true #* Read disks list from /etc/fstab. This also disables only_physical. +#* True: disk usage follows fstab. Persist extra disks with fileSystems, +#* not by listing every mount. use_fstab = true #* Setting this to True will hide all datasets, and only show ZFS pools. (IO stats will be calculated per-pool) diff --git a/modules/default.nix b/modules/default.nix index 48fde5c..11c8961 100644 --- a/modules/default.nix +++ b/modules/default.nix @@ -42,5 +42,12 @@ ./grok-oss.nix # Optional Tau coding agent: default off, no daemon, no extensions. ./tau.nix + # Wrapped Ookla Speedtest CLI. Mail host enables the package; timer stays off. + ./speedtest.nix + # Flexible I/O tester (fio) for operator HDD sequential vs random probes. + ./perf-tools.nix + # Mail-host source builds: x86-64-v3. Default off (CI / generic). + ./mail-host-cpu.nix + ./mail-host-rust-cpu.nix ]; } diff --git a/modules/eternal-terminal.nix b/modules/eternal-terminal.nix index 5f41ddb..e0b8f9d 100644 --- a/modules/eternal-terminal.nix +++ b/modules/eternal-terminal.nix @@ -18,7 +18,10 @@ in services.eternal-terminal = { enable = true; port = et.port; - verbosity = 1; + # 2 records connection open and close above the previous 1. + # 9 is not used: this pass did not read etserver for secret fields + # at the top of the 0-9 scale. silent stays false. + verbosity = 2; silent = false; }; @@ -30,7 +33,12 @@ in ]; # Operator access path: do not set Nice= or MemoryMax (sshd class). - systemd.services.eternal-terminal.serviceConfig.SyslogIdentifier = "eternal-terminal"; + # Journal burst so login and disconnect lines survive a flood. + systemd.services.eternal-terminal.serviceConfig = { + SyslogIdentifier = "eternal-terminal"; + LogRateLimitIntervalSec = "30s"; + LogRateLimitBurst = 50000; + }; } ]); } diff --git a/modules/grok-oss.nix b/modules/grok-oss.nix index 59456d6..09ce739 100644 --- a/modules/grok-oss.nix +++ b/modules/grok-oss.nix @@ -1,74 +1,23 @@ -# Optional Grok OSS on the mail host. Default off. -# Enable from host-local. User grok owns ~/.grok. Binary on PATH. -# Do not auto-start the TUI on boot (operator starts it in tmux). -# Hard MemoryMax on the grok user slice. Not Nice=19 (sshd class). -# Install pkgs.tmux here so attach does not depend only on Eternal Terminal. +# grok-oss is not in the mail-host server closure. +# Options remain in options.nix so eval stays defined. Enable must stay +# false. Host-local must not turn this on. This module never installs +# pkgs.grok-oss, tmux-for-grok, or a grok user. { config, lib, - pkgs, ... }: let - cfg = config.surmount; - go = cfg.grokOss; - inherit (lib) mkIf mkMerge; - userSliceName = "user-${toString go.uid}"; - grokHome = go.home; + go = config.surmount.grokOss; in { - config = mkMerge [ - (mkIf (cfg.enable && go.enable) { - assertions = [ - { - assertion = go.package != null; - message = "surmount.grokOss.package must be set when surmount.grokOss.enable is true (bin/grok-oss). Default-off leaves package null so eval does not pull grok-oss. Fail-closed: do not enable without the flake package."; - } - { - assertion = go.memoryMax != null && go.memoryMax != ""; - message = "surmount.grokOss.memoryMax must be a non-empty systemd MemoryMax string when enable is true (empty/null refuse; scaffold default 4G is a grok-oss budget, not a published guest size)."; - } - { - assertion = builtins.substring 0 1 go.home == "/"; - message = "surmount.grokOss.home must be an absolute path."; - } - { - assertion = go.user != "root"; - message = "surmount.grokOss.user must not be root (do not cap the whole machine)."; - } - ]; - - users.groups.${go.user} = { - gid = lib.mkDefault go.uid; - }; - - users.users.${go.user} = { - isNormalUser = true; - description = "Grok OSS operator TUI (no boot start)"; - group = go.user; - uid = go.uid; - home = grokHome; - createHome = true; - hashedPassword = "!"; - linger = false; - }; - - environment.systemPackages = [ pkgs.tmux ] ++ lib.optional (go.package != null) go.package; - - systemd.tmpfiles.rules = [ - "d ${grokHome} 0750 ${go.user} ${go.user} -" - "d ${grokHome}/.grok 0700 ${go.user} ${go.user} -" - ]; - - # Cap the login slice (tmux / grok-oss under user grok). No TUI unit. - systemd.slices.${userSliceName} = { - description = "grok user slice (hard memory cap)"; - sliceConfig = { - MemoryAccounting = true; - MemoryMax = go.memoryMax; - }; - }; - }) - ]; + config = { + assertions = [ + { + assertion = !go.enable; + message = "surmount.grokOss.enable must stay false: grok-oss is not in the mail-host server closure. Do not re-enable from host-local."; + } + ]; + }; } diff --git a/modules/hardening.nix b/modules/hardening.nix index 14c60a8..e12b038 100644 --- a/modules/hardening.nix +++ b/modules/hardening.nix @@ -1,4 +1,4 @@ -# Security defaults: SSH hardening, fail2ban sketch, edge-friendly limits. +# Security defaults: SSH hardening, sshguard, edge-friendly limits. # nginx is transitional-to-delete; prefer limits that transfer to Axum-first edge. # Keep this boring and reversible; deep custom hardening belongs in host config. # @@ -7,12 +7,11 @@ # - Ban decision layer in management-ui (default enforcement off; lean private) # - Unauthorized access -> fast/immediate host blacklist (nftables lean; Q-ACL-1 open) # - Explicit whitelist IPs never banned; track last-used for hygiene -# - Long-term prefer Rust ban helper over Python fail2ban as product identity -# This module's fail2ban block is a LIGHT TRANSITIONAL SKETCH (sshd only), -# not final merciless policy. Design: +# - Long-term prefer Rust ban helper over a host Python service +# sshguard is the sshd ban: permanent after 3 failures. fail2ban is not +# installed. Design notes: # docs/research/access-control-fail2ban.md -# Do not expand aggressive mail jails here without that design + false-positive -# review. Stalwart owns mail anti-abuse first. +# Do not add mail watchers here. Stalwart owns mail anti-abuse first. # # SSH host keys: advertise and generate ed25519 only. NixOS 26.05 default # also offers RSA; this module stops that. Existing RSA files on disk are @@ -59,49 +58,80 @@ in PermitRootLogin = mkDefault "prohibit-password"; X11Forwarding = false; AllowAgentForwarding = mkDefault false; + # Empty passwords and user ~/.ssh/environment are not an option. + # When password auth is off, publickey is the only method so a + # later default cannot turn keyboard-interactive back on. + PermitEmptyPasswords = false; + PermitUserEnvironment = false; + # Default grace is 120 seconds of unauthenticated hold. + LoginGraceTime = 60; + } + // lib.optionalAttrs (!cfg.hardening.allowPasswordAuth) { + AuthenticationMethods = "publickey"; }; }; - # fail2ban: light transitional sketch for sshd only. - # Target policy is merciless ban + whitelist + last-used (see header). - # Mail abuse: Stalwart spam-filter + greylisting first; not wild jails. - # Prefer shrinking this over growing Python jails as product identity. - services.fail2ban = { - enable = mkDefault true; - maxretry = 5; - bantime = "1h"; - jails = { - sshd = { - settings = { - enabled = true; - port = "ssh"; - filter = "sshd"; - backend = "systemd"; - }; - }; - }; + # sshguard 2.5.1 (pinned nixos-26.05): an sshd auth failure scores + # 10. Block and blacklist both fire when cumulated danger >= the + # threshold, so 30 is 3 failures. blacklist pardontime 0 is never + # unblocked and is reloaded from blacklist_file. The nixpkgs module + # uses sshg-fw-ipset with the host iptables firewall, or + # sshg-fw-nft-sets when networking.nftables.enable is true, and + # partOf firewall.service. fail2ban is Python and stays off. + # Do not enable surmount.accessControl or the nft helper. + services.fail2ban.enable = false; + services.sshguard = { + enable = true; + services = [ "sshd" ]; + attack_threshold = 30; + blacklist_threshold = 30; + blacklist_file = "/var/lib/sshguard/blacklist.db"; + }; + + systemd.services.sshguard.serviceConfig = { + SyslogIdentifier = "sshguard"; + LogRateLimitIntervalSec = "30s"; + LogRateLimitBurst = 50000; }; # Kernel / network hygiene (small set; avoid surprising operators). boot.kernelParams = mkDefault [ "page_alloc.shuffle=1" ]; security.sudo.execWheelOnly = mkDefault true; + + # programs.shadow sets su forwardXAuth. That PAM line references + # xauth. This host has no X server, so the forward stays off. + security.pam.services.su.forwardXAuth = lib.mkForce false; }) # SHC 261: guest agent so the hypervisor can inject a command when # SSH is stuck. Default off; host-local enables on this QEMU VPS. (mkIf (cfg.enable && cfg.hardening.enable && cfg.hardening.qemuGuestAgent.enable) { services.qemuGuest.enable = true; + # Not nixpkgs qemu_kvm.ga. That package walks into pythonPackages. + services.qemuGuest.package = pkgs.callPackage ../nix/packages/qemu-guest-agent.nix { }; # Stock nixpkgs only starts the unit via udev SYSTEMD_WANTS on the # virtio port. After a switch that udev event has already fired, so # the unit stays dead. Want multi-user so SHC inject works on boot # and after this generation. systemd.services.qemu-guest-agent.wantedBy = [ "multi-user.target" ]; + # Start and guest-exec lines stay in the journal. The option stays + # default off; this does not enable the agent. + systemd.services.qemu-guest-agent.serviceConfig = { + SyslogIdentifier = "qemu-guest-agent"; + LogRateLimitIntervalSec = "30s"; + LogRateLimitBurst = 50000; + }; }) # Access-control nft fragment: opt-in, lean private default off. (mkIf (cfg.enable && ac.enable && ac.nftSets) { networking.nftables.enable = true; + systemd.services.nftables.serviceConfig = { + SyslogIdentifier = "nftables"; + LogRateLimitIntervalSec = "30s"; + LogRateLimitBurst = 50000; + }; networking.nftables.tables.${nftTable} = { family = "inet"; content = '' @@ -132,8 +162,8 @@ in ${ if (cfg.logging.enable or false) then '' - ip saddr @${nftBan4} log prefix "surmount-nft-ban-drop: " drop - ip6 saddr @${nftBan6} log prefix "surmount-nft-ban-drop: " drop + ip saddr @${nftBan4} log prefix "surmount-nft-ban-drop: " level info drop + ip6 saddr @${nftBan6} log prefix "surmount-nft-ban-drop: " level info drop '' else '' @@ -304,6 +334,11 @@ in StandardInput = "socket"; StandardOutput = "socket"; StandardError = "journal"; + # stderr is the helper log. stdout stays the socket (the + # protocol). Do not log nft payloads or set contents. + SyslogIdentifier = "surmount-nft-ban-helper"; + LogRateLimitIntervalSec = "30s"; + LogRateLimitBurst = 50000; }; }; } diff --git a/modules/lake.nix b/modules/lake.nix index f168d3f..44156bc 100644 --- a/modules/lake.nix +++ b/modules/lake.nix @@ -119,7 +119,12 @@ in systemd.services.stalwart-mail.serviceConfig.OOMScoreAdjust = lib.mkDefault (-300); systemd.services.surmount-management-ui.serviceConfig.OOMScoreAdjust = lib.mkDefault (-300); systemd.services.sshd.serviceConfig.OOMScoreAdjust = lib.mkDefault (-300); - systemd.services.surmount-arti-hidden-service.serviceConfig.OOMScoreAdjust = lib.mkDefault (-300); + # Do not instantiate the daemon unit when startDaemon is false. + # A bare serviceConfig assignment would create it with enable defaulting + # to true. OOM protection still applies when the daemon is requested. + systemd.services.surmount-arti-hidden-service = lib.mkIf cfg.artiHiddenService.startDaemon { + serviceConfig.OOMScoreAdjust = lib.mkDefault (-300); + }; }) ]; } diff --git a/modules/logging.nix b/modules/logging.nix index b50ab1c..ca3632d 100644 --- a/modules/logging.nix +++ b/modules/logging.nix @@ -80,14 +80,28 @@ in RateLimitIntervalSec=${log.rateLimitIntervalSec} RateLimitBurst=${log.rateLimitBurst} ForwardToSyslog=no + # Pin debug so a unit console at debug (Arti) is stored. trace is + # not requested. This is not a guest disk size. + MaxLevelStore=debug ''; # Auth failures and disconnects without passwords. Not DEBUG3. + # sshd_config DEBUG violates user privacy. VERBOSE stays the level. services.openssh.settings.LogLevel = log.sshdLogLevel; + # sshd is un-niced. Raise only the per-unit journal burst so auth + # lines are not dropped at the systemd default of 10000/30s. + systemd.services.sshd.serviceConfig = { + LogRateLimitIntervalSec = "30s"; + LogRateLimitBurst = 50000; + }; + # Per-unit burst so nix-daemon start/stop/OOM lines survive a flood. - # Do not Nice= this unit here (remote-builder owns Nice when enabled). + # Identifier is set here when logging is on. remote-builder sets the + # same identifier when the builder cap is on. Do not Nice= this unit + # here (remote-builder owns Nice when enabled). systemd.services.nix-daemon.serviceConfig = { + SyslogIdentifier = "nix-daemon"; LogRateLimitIntervalSec = "30s"; LogRateLimitBurst = 50000; }; diff --git a/modules/mail-host-cpu.nix b/modules/mail-host-cpu.nix new file mode 100644 index 0000000..f7ed330 --- /dev/null +++ b/modules/mail-host-cpu.nix @@ -0,0 +1,240 @@ +# Mail-host C and C++ builds, including their libraries, request +# x86-64-v3. When this module is enabled it sets +# nixpkgs.hostPlatform.gcc.arch. The cc-wrapper reads that into +# cc-cflags-before, so dependency builds (nixpkgs RocksDB, libsodium, +# bitcoind and elementsd libraries, and the rest of this host's +# stdenv) get -march=x86-64-v3. The next switch rebuilds that closure. +# CI, the generic eval host, and flake packages.* (mkPkgsBare) leave +# the module off, so they stay untuned. +# +# This nixpkgs does not map gcc.arch to rustc -C target-cpu. Named +# top-level derivations still get CARGO_BUILD_RUSTFLAGS. Stalwart is +# a release binary (dontBuild). Do not retune that tarball or the +# RocksDB inside it. Do not use -march=native. Do not request +# x86-64-v4 or avx512. + +{ + config, + lib, + ... +}: +let + cfg = config.surmount.mailHostCpu; + inherit (lib) mkIf mkOption types; + march = "x86-64-v3"; + nixCflagsCompile = "-march=${march}"; + cargoBuildRustflags = "-C target-cpu=${march}"; + # Source builds that can land in the mail-host closure. + # bitcoind and elementsd are tuned only if something references them. + # This module does not enable them and does not start them. + tunedPackages = [ + "surmount-management-ui" + "artiOnionService" + "acme-dns-hook-namecheap" + "surmount-private-data" + "surmount-host-logs" + "surmount-shc" + "surmount-niced-builder" + "surmount-scram" + "surmount-leftover-homes" + "surmount-host-probe" + "surmount-static-sites" + "surmount-diskstation" + "surmount-deploy-host" + "surmount-dns-zone" + "surmount-domain-audit" + "surmount-host-cutover" + "surmount-acme-namecheap" + "surmount-stalwart-ops" + "surmount-mail-import" + "surmount-secrets-install" + "surmount-secrets-prompt" + "surmount-rekey" + "splora" + "splora-liquid" + "bitcoind" + "elementsd" + ]; + + addFlag = + flag: old: name: + let + top = if old ? ${name} && old.${name} != null then toString old.${name} else ""; + fromEnv = + if (old.env or { }) ? ${name} && old.env.${name} != null then toString old.env.${name} else ""; + combined = lib.concatStringsSep " " ( + lib.filter (s: s != "") [ + top + fromEnv + ] + ); + in + if flag == "" then + combined + else if lib.hasInfix flag combined then + combined + else + lib.concatStringsSep " " ( + lib.filter (s: s != "") [ + flag + combined + ] + ); + + tuneDrv = + pkg: + if !(lib.isDerivation pkg) then + pkg + else + pkg.overrideAttrs (old: { + env = (old.env or { }) // { + NIX_CFLAGS_COMPILE = addFlag nixCflagsCompile old "NIX_CFLAGS_COMPILE"; + NIX_CXXFLAGS_COMPILE = addFlag nixCflagsCompile old "NIX_CXXFLAGS_COMPILE"; + CARGO_BUILD_RUSTFLAGS = addFlag cargoBuildRustflags old "CARGO_BUILD_RUSTFLAGS"; + }; + }); + + artifactsOf = + pkg: + if pkg ? cargoArtifacts && pkg.cargoArtifacts != null then + pkg.cargoArtifacts + else if pkg ? env && pkg.env ? cargoArtifacts then + pkg.env.cargoArtifacts + else + null; + + tune = + pkg: + let + flagged = tuneDrv pkg; + artifacts = artifactsOf pkg; + in + if lib.isDerivation artifacts then + flagged.overrideAttrs (_old: { + cargoArtifacts = tuneDrv artifacts; + }) + else + flagged; + + # mkAfter so this runs after the flake surmountOverlay, which + # replaces these attrs with the generic package set. + mailHostCpuOverlay = + _final: prev: + lib.foldl' lib.recursiveUpdate { } ( + map ( + name: + lib.optionalAttrs (prev ? ${name}) { + ${name} = tune prev.${name}; + } + ) tunedPackages + ); +in +{ + options.surmount.mailHostCpu = { + enable = lib.mkEnableOption '' + compile mail-host C and C++ for x86-64-v3, including libraries + built for this host. Sets nixpkgs.hostPlatform.gcc.arch when + enabled. Off for the generic CI host and for flake packages.*. + ''; + + gccArch = mkOption { + type = types.str; + default = ""; + description = '' + gcc.arch token for mail-host source builds. Empty when enable + is false. When enable is true this is also + nixpkgs.hostPlatform.gcc.arch, which the cc-wrapper applies + to every C and C++ compile in this host's package set. + ''; + }; + + rustTargetCpu = mkOption { + type = types.str; + default = ""; + description = '' + rustc -C target-cpu value for mail-host source builds. + Empty when enable is false. + ''; + }; + + nixCflagsCompile = mkOption { + type = types.str; + default = ""; + description = '' + NIX_CFLAGS_COMPILE / NIX_CXXFLAGS_COMPILE prefix for mail-host + source builds. The nixpkgs cc-wrapper applies NIX_CFLAGS_COMPILE + to C and C++. Empty when enable is false. + ''; + }; + + cargoBuildRustflags = mkOption { + type = types.str; + default = ""; + description = '' + CARGO_BUILD_RUSTFLAGS prefix for mail-host source builds. + Empty when enable is false. + ''; + }; + + tunedPackages = mkOption { + type = types.listOf types.str; + default = [ ]; + description = '' + Package attribute names that receive the mail-host flags when + enable is true and the attribute exists. Stalwart's release + binary is not in this list. + ''; + }; + }; + + config = mkIf cfg.enable { + surmount.mailHostCpu.gccArch = march; + surmount.mailHostCpu.rustTargetCpu = march; + surmount.mailHostCpu.nixCflagsCompile = nixCflagsCompile; + surmount.mailHostCpu.cargoBuildRustflags = cargoBuildRustflags; + surmount.mailHostCpu.tunedPackages = tunedPackages; + + # cc-wrapper machineFlags reads targetPlatform.gcc.arch and writes + # -march into nix-support/cc-cflags-before. That covers dependency + # builds. It does not retune a dontBuild release binary. The named + # overlay below still sets Rust target-cpu; gcc.arch does not. + nixpkgs.hostPlatform = { + system = config.nixpkgs.system; + gcc.arch = march; + }; + + # The v3 stdenv requires gccarch-x86-64-v3. extra-system-features is + # a freeform list and concatenates, so this does not drop + # surmount-remote or the daemon's system-features defaults. + # This list is the next generation. It does not change the daemon + # that builds that generation. surmount-deploy-host bootstraps the + # running daemon before the system build. + nix.settings.extra-system-features = [ "gccarch-${march}" ]; + + nixpkgs.overlays = lib.mkAfter [ mailHostCpuOverlay ]; + + assertions = [ + { + assertion = + cfg.gccArch == march + && cfg.rustTargetCpu == march + && cfg.nixCflagsCompile == nixCflagsCompile + && cfg.cargoBuildRustflags == cargoBuildRustflags + && !(lib.hasInfix "native" cfg.nixCflagsCompile) + && !(lib.hasInfix "native" cfg.cargoBuildRustflags) + && !(lib.hasInfix "x86-64-v4" cfg.gccArch) + && !(lib.hasInfix "avx512" cfg.cargoBuildRustflags); + message = "surmount.mailHostCpu must request x86-64-v3, not native, v4, or avx512."; + } + { + assertion = + (config.nixpkgs.hostPlatform.gcc.arch or null) == march + && (config.nixpkgs.hostPlatform.system or "") == "x86_64-linux" + && !(lib.hasInfix "native" (config.nixpkgs.hostPlatform.gcc.arch or "")) + && (config.nixpkgs.hostPlatform.gcc.arch or "") != "x86-64-v4" + && !(lib.hasInfix "avx512" (config.nixpkgs.hostPlatform.gcc.arch or "")); + message = "surmount.mailHostCpu must set nixpkgs.hostPlatform.gcc.arch to x86-64-v3 on x86_64-linux, not native, v4, or avx512."; + } + ]; + }; +} diff --git a/modules/mail-host-rust-cpu.nix b/modules/mail-host-rust-cpu.nix new file mode 100644 index 0000000..3d33114 --- /dev/null +++ b/modules/mail-host-rust-cpu.nix @@ -0,0 +1,120 @@ +# Mail-host Cargo builds: one rustc argv, -Ctarget-cpu=x86-64-v3, on +# every rustc Cargo runs, including dependency crates and proc-macros. +# Appended, not prepended. cargo-auditable treats its first argument as +# the rustc path. A split "-C" "target-cpu=..." in front made it exec +# "-C". Gated on surmount.mailHostCpu.enable (no second option). enable +# false installs nothing, so CI and mkPkgsBare stay generic. +# +# Crane buildDepsOnly is a separate derivation (cargoArtifacts). A flag +# on the top-level package does not reach that derivation. Stamp both. +# +# Duplicate -C target-cpu is a rustc hard error. The wrapper skips when +# argv or RUSTFLAGS already contains target-cpu= (the other module may +# already set CARGO_BUILD_RUSTFLAGS on the same package). +# +# Does not rebuild rustc. RUSTC_WRAPPER calls the rustc already on the +# derivation (nixpkgs-rust, or Splora's rust-bin). + +{ + config, + lib, + ... +}: +let + cfg = config.surmount.mailHostCpu; + # C and C++. MARCH for those stays in mail-host-cpu.nix. + cppNames = [ + "bitcoind" + "elementsd" + ]; + # splora-http is craneLib.buildPackage in the Splora flake. This + # repo's package overlay does not always expose the attr. + rustNames = lib.unique (lib.subtractLists cppNames cfg.tunedPackages ++ [ "splora-http" ]); + + hookText = '' + real=$1 + if [ -z "$real" ]; then + echo "mail-host rustc wrapper: missing rustc path" >&2 + exit 1 + fi + shift + need=1 + for arg in "$@"; do + case "$arg" in + *target-cpu=*) need=0 ;; + esac + done + case "''${RUSTFLAGS-}" in + *target-cpu=*) need=0 ;; + esac + case "''${CARGO_ENCODED_RUSTFLAGS-}" in + *target-cpu=*) need=0 ;; + esac + if [ "$need" -eq 1 ]; then + set -- "$@" "-Ctarget-cpu=x86-64-v3" + fi + if [ -n "''${SURMOUNT_RUSTC_WRAPPER_NEXT-}" ]; then + exec "''${SURMOUNT_RUSTC_WRAPPER_NEXT}" "$real" "$@" + fi + exec "$real" "$@" + ''; + + tuneWith = + wrapper: pkg: + if !(lib.isDerivation pkg) then + pkg + else + pkg.overrideAttrs ( + old: + let + prevEnv = old.env or { }; + prevWrap = if prevEnv ? RUSTC_WRAPPER then prevEnv.RUSTC_WRAPPER else null; + nextDeps = + if old ? cargoArtifacts && lib.isDerivation old.cargoArtifacts then + tuneWith wrapper old.cargoArtifacts + else + null; + in + { + env = + prevEnv + // { + RUSTC_WRAPPER = wrapper; + } + // lib.optionalAttrs (prevWrap != null) { + SURMOUNT_RUSTC_WRAPPER_NEXT = prevWrap; + }; + passthru = + (old.passthru or { }) + // { + mailHostRustCpuHook = hookText; + mailHostRustCpuUses = "RUSTC_WRAPPER"; + } + // lib.optionalAttrs (nextDeps != null) { + mailHostRustCpuCargoArtifacts = nextDeps; + }; + } + // lib.optionalAttrs (nextDeps != null) { + cargoArtifacts = nextDeps; + } + ); + + rustCpuOverlay = + _final: prev: + let + wrapper = prev.writeShellScript "mail-host-rustc-wrapper" hookText; + tune = tuneWith wrapper; + in + lib.foldl' ( + acc: name: + acc + // lib.optionalAttrs (prev ? ${name}) { + ${name} = tune prev.${name}; + } + ) { } rustNames; +in +{ + config = lib.mkIf cfg.enable { + nixpkgs.overlays = lib.mkAfter [ rustCpuOverlay ]; + }; +} diff --git a/modules/mail.nix b/modules/mail.nix index a711421..ddb549f 100644 --- a/modules/mail.nix +++ b/modules/mail.nix @@ -47,8 +47,52 @@ let spamFilter = config.services.stalwart.package.spam-filter or null; webui = config.services.stalwart.package.webui or null; + + # pkgs/build-support/trivial-builders/default.nix reads + # shellcheck-minimal.compiler inside writeShellApplication. The + # pre-switch option default calls that helper. This script does not. + preSwitchCheckScript = lib.concatLines ( + lib.mapAttrsToList (name: text: '' + # pre-switch check ${name} + set +e + ( + set -e + ${text} + ) >&2 + _rc=$? + set -e + if [ "$_rc" -ne 0 ]; then + echo "Pre-switch check '${name}' failed" >&2 + exit 1 + fi + '') config.system.preSwitchChecks + ); in { + # nixos/modules/system/activation/pre-switch-check.nix + disabledModules = [ + "system/activation/pre-switch-check.nix" + ]; + + options.system.preSwitchChecks = lib.mkOption { + default = { }; + type = lib.types.attrsOf lib.types.str; + description = '' + Shell fragments run before a switch to this system. A failing + fragment aborts the switch. The new configuration path and the + switch action are the first and second arguments. + ''; + }; + + options.system.preSwitchChecksScript = lib.mkOption { + type = lib.types.pathInStore; + internal = true; + readOnly = true; + default = lib.getExe ( + pkgs.writeShellScriptBin "pre-switch-checks" preSwitchCheckScript + ); + }; + config = mkIf cfg.enable (mkMerge [ { services.stalwart = { @@ -187,7 +231,7 @@ in ''; } (lib.mkIf (spamFilter != null) { - "surmount/stalwart/spam-filter.toml".source = "${spamFilter}/spam-filter.toml"; + # spam-filter 3.0.3 and later publish only the rules archive. "surmount/stalwart/spam-filter-rules.json.gz".source = "${spamFilter}/spam-filter-rules.json.gz"; }) (lib.mkIf (webui != null) { diff --git a/modules/management-ui.nix b/modules/management-ui.nix index f7b04ed..12b5261 100644 --- a/modules/management-ui.nix +++ b/modules/management-ui.nix @@ -15,10 +15,15 @@ lib, pkgs, options, + # mail-vps passes the flake as specialArgs.self. Module eval omits + # that attribute, so the web root stays unset. Do not declare a self + # parameter: nixpkgs applyModuleArgs looks the name up even with ? null. + specialArgs, ... }: let cfg = config.surmount; + flakeSelf = specialArgs.self or null; inherit (lib) mkIf optional; hostPaths = import ./lib/host-paths.nix { inherit lib; }; localCleartext = import ./lib/local-cleartext.nix { inherit lib; }; @@ -60,6 +65,22 @@ let else "${pkgs.writeText "surmount-static-vhosts.json" (builtins.toJSON staticVhostsJsonMap)}"; extraMailHostnamesCsv = lib.concatStringsSep "," (lib.filter (h: h != "") ui.extraMailHostnames); + # HTTP-01 webroot. Empty option uses stateDir/acme-http01. The renew + # timer and the :80 listener share this directory. Not a Namecheap path. + http01Renew = ui.acme.enable && ui.acme.challenge == "http-01"; + effectiveHttp01Webroot = + if ui.acme.http01Webroot != "" then ui.acme.http01Webroot else "${cfg.stateDir}/acme-http01"; + # Union status.{primaryDomain} onto a non-empty ACME identifier list so the + # systems dashboard Host is on the certificate hostname set for this edge. + # Measured 2026-11-03 from horizon: status.surmount.systems has the same + # A record as the apex, mail, and services names (no AAAA on any of them), + # and port 80 returns 308 to https://status.surmount.systems/. That name + # stays on this order. Do not record addresses in this file. + acmeDomains = + let + statusHost = lib.optional (cfg.primaryDomain != "") "status.${cfg.primaryDomain}"; + in + if ui.acme.domains == [ ] then [ ] else lib.unique (ui.acme.domains ++ statusHost); hs = cfg.artiHiddenService; ac = cfg.accessControl; vw = cfg.vaultwarden; @@ -172,6 +193,8 @@ let "SURMOUNT_PRIMARY_DOMAIN=${cfg.primaryDomain}" "SURMOUNT_MAIL_HOSTNAME=${cfg.mailHostname}" "SURMOUNT_SERVICES_HOSTNAME=${cfg.servicesHostname}" + # status.{primaryDomain} is the public systems dashboard Host (Rust-derived). + # ACME identifiers also union that name (acmeDomains) when a list is set. # Stalwart 0.16 default HTTP management listener. "SURMOUNT_STALWART_URL=http://127.0.0.1:8080" "SURMOUNT_REDIRECT_HTTP_TO_HTTPS=${if ui.redirectHttpToHttps then "true" else "false"}" @@ -201,9 +224,7 @@ let ] ++ optional (ui.acme.directory != "") "SURMOUNT_ACME_DIRECTORY=${ui.acme.directory}" ++ optional (ui.acme.email != "") "SURMOUNT_ACME_EMAIL=${ui.acme.email}" - ++ optional ( - ui.acme.domains != [ ] - ) "SURMOUNT_ACME_DOMAINS=${lib.concatStringsSep "," ui.acme.domains}" + ++ optional (acmeDomains != [ ]) "SURMOUNT_ACME_DOMAINS=${lib.concatStringsSep "," acmeDomains}" ++ optional ( ui.acme.accountCredentialsPath != "" ) "SURMOUNT_ACME_ACCOUNT_CREDENTIALS_PATH=${ui.acme.accountCredentialsPath}" @@ -211,6 +232,9 @@ let ++ optional (ui.acme.dnsProvider == "external-hook") ( "SURMOUNT_ACME_DNS_HOOK_TIMEOUT_SECS=${toString ui.acme.dnsHookTimeoutSecs}" ) + ++ optional ( + ui.acme.challenge == "http-01" + ) "SURMOUNT_ACME_HTTP01_WEBROOT=${effectiveHttp01Webroot}" ) ++ optional redirectListenActive "SURMOUNT_HTTP_REDIRECT_LISTEN=${ui.httpRedirectListen}" ++ optional localCleartextActive "SURMOUNT_LOCAL_CLEARTEXT_LISTEN=${effectiveLocalCleartext}" @@ -289,6 +313,26 @@ let ++ optional ac.nftHelper "SURMOUNT_BAN_NFT_HELPER_SOCK=/run/surmount/nft-ban-helper.sock" ); + # acme-renew is not an HTTP route. It loads ACME and TLS only. + # Do not pass nostr auth settings and do not attach the session secret + # file. The UI unit keeps both. The secret stays a host file, not a + # Nix store copy. + renewDropsAuthLine = + line: + let + stripped = lib.removePrefix "'" line; + key = builtins.head (lib.splitString "=" stripped); + in + !( + lib.hasPrefix "SURMOUNT_AUTH_" key + || lib.hasPrefix "SURMOUNT_SESSION_" key + || lib.hasPrefix "SURMOUNT_NOSTR_" key + || lib.hasPrefix "SURMOUNT_NIP98_" key + || key == "SURMOUNT_PUBLIC_BASE_URL" + || key == "SURMOUNT_ALLOW_PUBLIC_AUTH_OFF" + ); + renewEnvList = lib.filter renewDropsAuthLine envList; + # Non-TLS host secret paths stay read-only. TLS PEMs are read-only when # ACME is off; when ACME is on the binary may write cert/key + account JSON. # ProtectSystem=strict: create-on-issue needs **parent directories** writable @@ -335,6 +379,9 @@ let hs.onionServiceStateDir "${hs.stateDir}/published-hostnames" ] + ++ lib.optionals (sploraWebRoot != "") [ + sploraWebRoot + ] ); # EnvironmentFile expects KEY=value lines (e.g. SURMOUNT_SESSION_SECRET=...). @@ -432,10 +479,18 @@ let ExecStart = "${pkg}/bin/surmount-management-ui"; Restart = "on-failure"; RestartSec = "5s"; + # Ordinary CFS. Not fifo, not rr, not idle I/O, not realtime I/O. + Nice = -19; SyslogIdentifier = "surmount-management-ui"; StandardOutput = "journal"; StandardError = "journal"; - Environment = envList; + # Crate debug is already in RUST_LOG. Do not add rustls=debug (handshake + # failures are our warn events; rustls debug can include key material). + # Burst keeps http_request and tls_handshake_failed during a scan. + LogRateLimitIntervalSec = "30s"; + LogRateLimitBurst = 50000; + Environment = envList + ++ optional (sploraWebRoot != "") "SURMOUNT_SPLORA_WEB_ROOT=${sploraWebRoot}"; NoNewPrivileges = true; PrivateTmp = true; ProtectSystem = "strict"; @@ -513,6 +568,65 @@ let # host generation includes the site without a mutable /var/lib copy. # Operator may still set apexPublicRoot to a host directory. packagedPublicSite = if pkgs ? surmount-public-site then "${pkgs.surmount-public-site}" else ""; + + # Trunk client. Flake attribute packages..splora-web + # (nix/packages/splora-web.nix). Overlay name wins when present. + # Otherwise the mail host uses specialArgs.self. Not a store hash. + # Empty when neither is in scope, so module eval without the flake + # does not instantiate the Trunk build. + sploraWebPkg = + if pkgs ? splora-web then + pkgs.splora-web + else if flakeSelf == null then + null + else + (flakeSelf.packages.${pkgs.stdenv.hostPlatform.system} or { }).splora-web or null; + sploraWebRoot = if sploraWebPkg == null then "" else "${sploraWebPkg}"; + + # Oneshot script the timer execs. builtins.readFile of this path is the + # hermetic proof the unit names the management UI and the PEM paths. + # No Namecheap client, no API token, no DNS hook. + certRenewScript = pkgs.writeShellScript "surmount-cert-renew" '' + set -eu + # acme-renew loads ACME and TLS only. It does not read the session secret. + cert=${lib.escapeShellArg ui.tlsCertPath} + key=${lib.escapeShellArg ui.tlsKeyPath} + stamp=${lib.escapeShellArg "${effectiveHttp01Webroot}/.renewed"} + before=$(${pkgs.coreutils}/bin/stat -c %Y "$cert" 2>/dev/null || echo missing) + ${pkg}/bin/surmount-management-ui acme-renew + after=$(${pkgs.coreutils}/bin/stat -c %Y "$cert" 2>/dev/null || echo missing) + if [ "$before" != "$after" ]; then + # surmount-management-ui wrote the TLS PEMs this host presents. + # Leave an existing stamp in place so a failed root post can retry. + ${pkgs.coreutils}/bin/touch "$stamp" + fi + ''; + # Root post-step. The "+" prefix ignores this unit's sandbox, so root can + # chown the mail copies and restart units. surmount-ui cannot do that. + # Copy only when the stamp says the PEM mtime changed. Match + # surmount-mail-tls-copy: cert 0640, key 0600, owner stalwart-mail. + # Do not delete the source PEMs. A failed order never creates the stamp. + certRenewRestartScript = pkgs.writeShellScript "surmount-cert-renew-restart" '' + set -eu + stamp=${lib.escapeShellArg "${effectiveHttp01Webroot}/.renewed"} + srcCert=${lib.escapeShellArg ui.tlsCertPath} + srcKey=${lib.escapeShellArg ui.tlsKeyPath} + dst=${lib.escapeShellArg "${cfg.secrets.durableMaterialDir}/mail/tls"} + if [ -f "$stamp" ]; then + if [ ! -f "$srcCert" ] || [ ! -f "$srcKey" ]; then + echo "surmount-cert-renew: refusing to copy mail TLS PEMs; source missing" >&2 + exit 1 + fi + ${pkgs.coreutils}/bin/mkdir -p "$dst" + ${pkgs.coreutils}/bin/chmod 0750 "$dst" + ${pkgs.coreutils}/bin/chown stalwart-mail:stalwart-mail "$dst" || true + ${pkgs.coreutils}/bin/install -o stalwart-mail -g stalwart-mail -m 0640 "$srcCert" "$dst/cert.pem" + ${pkgs.coreutils}/bin/install -o stalwart-mail -g stalwart-mail -m 0600 "$srcKey" "$dst/key.pem" + ${pkgs.systemd}/bin/systemctl restart stalwart-mail.service + ${pkgs.systemd}/bin/systemctl try-restart surmount-management-ui.service + ${pkgs.coreutils}/bin/rm -f "$stamp" + fi + ''; in { config = mkIf (cfg.enable && ui.enable) { @@ -598,9 +712,28 @@ in surmount.managementUi.acme.enable is true but incomplete: require non-empty acme.directory, acme.email, acme.domains, acme.accountCredentialsPath, listenMode=https, and tlsCertPath/tlsKeyPath (write targets for PEMs). - Default is acme.enable=false. Prefer staging directory first. Account - credentials and PEMs stay on the host (never in git). HTTP-01 on - product :80 is parked; only dns-01 is wired. + Default is acme.enable=false. Account credentials and PEMs stay on + the host (never in git). http-01 renews on this host. dns-01 still + uses the operator DNS hook. Namecheap API credentials are not a + module input. + ''; + } + { + assertion = + !http01Renew + || ( + ui.acme.dnsProvider == "none" + && redirectListenActive + && hostPaths.strictHostPath effectiveHttp01Webroot + && hostPaths.strictHostPath ui.tlsCertPath + && hostPaths.strictHostPath ui.tlsKeyPath + ); + message = '' + surmount.managementUi.acme.challenge is http-01 but the host renew + path is incomplete. Require dnsProvider = "none" (no Namecheap hook + on this host), redirectHttpToHttps with a listen address so port 80 + can serve /.well-known/acme-challenge/, and strict absolute paths for + the HTTP-01 webroot, tlsCertPath, and tlsKeyPath. ''; } { @@ -852,22 +985,96 @@ in serviceConfig = serviceConfig; }; } + // lib.optionalAttrs http01Renew { + surmount-cert-renew = { + description = "Renew the Let's Encrypt certificate this host presents (HTTP-01)"; + after = [ + "network-online.target" + "surmount-management-ui.service" + ]; + wants = [ + "network-online.target" + "surmount-management-ui.service" + ]; + serviceConfig = { + Type = "oneshot"; + User = "surmount-ui"; + Group = "surmount-ui"; + ExecStart = "${certRenewScript}"; + # Privileged prefix so root can restart the UI after a write. + # The ACME client itself stays User=surmount-ui above. + ExecStartPost = "+${certRenewRestartScript}"; + TimeoutStartSec = "15min"; + Nice = 0; + Environment = renewEnvList; + # NoNewPrivileges stays off so ExecStartPost "+" can restart the + # UI as root. The ACME client process is still User=surmount-ui. + PrivateTmp = true; + ProtectSystem = "strict"; + ProtectHome = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + "AF_UNIX" + ]; + RestrictNamespaces = true; + LockPersonality = true; + MemoryDenyWriteExecute = true; + RestrictRealtime = true; + SystemCallArchitectures = "native"; + ReadWritePaths = acmeWritePaths ++ [ effectiveHttp01Webroot ]; + }; + }; + } + # Static unit names. Reading config.services.splora.instances while + # defining systemd.services recurses. MemoryMax only; Nice stays on + # the indexer wrap. The five names are the default indexer units, + # including splora-mainnet. splora-queue stays on its own gate. // lib.optionalAttrs (sploraServiceOn && splora.unitsMemoryMax != null) ( let cap = { - serviceConfig.MemoryMax = splora.unitsMemoryMax; + serviceConfig = { + MemoryMax = splora.unitsMemoryMax; + # Same gate as the memory cap. Does not enable the unit. + LogRateLimitIntervalSec = "30s"; + LogRateLimitBurst = 50000; + }; }; - inst = lib.filterAttrs (_: i: i.enable or false) config.services.splora.instances; in lib.optionalAttrs (config.services.splora.queueEnable or false) { splora-queue = cap; } - // lib.mapAttrs' (name: _: lib.nameValuePair "splora-${name}" cap) inst + // lib.genAttrs [ + "splora-mainnet" + "splora-testnet3" + "splora-testnet4" + "splora-mutinynet" + "splora-liquid" + ] (_: cap) // lib.optionalAttrs (config.services.splora.popularScripts.enable or false) { splora-popular-scripts = cap; } ); + systemd.timers = lib.mkIf http01Renew { + surmount-cert-renew = { + description = "Renew the Let's Encrypt certificate this host presents (HTTP-01)"; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnCalendar = [ + "*-*-* 03:17:00" + "*-*-* 15:17:00" + ]; + Persistent = true; + RandomizedDelaySec = "30min"; + Unit = "surmount-cert-renew.service"; + }; + }; + }; + # State dir always (0755 so surmount-ui can traverse to durable Domain B # under stateDir/secrets; H2b). Durable Domain B leaves for runtime secrets # + H-PEM tls/ so UI can read/write after install. When ACME is on, also @@ -909,6 +1116,11 @@ in ++ (lib.optional (builtins.any underDurable acmeParentDirs) "d ${durableRoot} 0755 root root - -"); in rootRules ++ parentRules - ); + ) + ++ lib.optionals http01Renew [ + "d ${effectiveHttp01Webroot} 0750 surmount-ui surmount-ui - -" + "d ${effectiveHttp01Webroot}/.well-known 0750 surmount-ui surmount-ui - -" + "d ${effectiveHttp01Webroot}/.well-known/acme-challenge 0750 surmount-ui surmount-ui - -" + ]; }; } diff --git a/modules/networking.nix b/modules/networking.nix index 96738ba..2eb75a4 100644 --- a/modules/networking.nix +++ b/modules/networking.nix @@ -20,6 +20,10 @@ in config = mkIf cfg.enable { networking.firewall = { enable = mkDefault true; + # Refused connections (not every packet) go to the kernel journal. + # logRefusedPackets stays off: that option is a packet debug flood. + # rejectPackets stays false so a scan does not get an ICMP reply. + logRefusedConnections = mkDefault true; allowedTCPPorts = [ 22 # SSH (consider restrict-to-admin nets later) 25 # SMTP inbound @@ -45,6 +49,12 @@ in # Do not claim host drop works from unit env alone. }; + systemd.services.firewall.serviceConfig = { + SyslogIdentifier = "firewall"; + LogRateLimitIntervalSec = "30s"; + LogRateLimitBurst = 50000; + }; + # Host name stays in hosts/*/configuration.nix so NixOS VM tests # (nodes.) can set their own without conflicts. }; diff --git a/modules/operator-justfile.nix b/modules/operator-justfile.nix index c3657af..d2c6ca5 100644 --- a/modules/operator-justfile.nix +++ b/modules/operator-justfile.nix @@ -15,7 +15,9 @@ let in { config = mkIf cfg.enable { - environment.systemPackages = [ pkgs.just ]; + # No service executes just. The recipe file stays. The just + # package builds with cargo, and that cargo build input is the + # interpreter. Do not install the package on this mail host. environment.etc.${etcRel}.source = ../contrib/guest-root-justfile; systemd.tmpfiles.rules = [ "L+ /root/justfile - - - - ${etcAbs}" diff --git a/modules/options.nix b/modules/options.nix index aafd0fa..fab1d9f 100644 --- a/modules/options.nix +++ b/modules/options.nix @@ -292,20 +292,24 @@ in ''; }; - # In-process ACME (DNS-01). Default off. Not ACME-only forever; static PEMs stay. - # HTTP-01 on product :80 parked. external-hook = operator DNS-01 adapter; - # live host LE + hot-reload residual (not a commercial DNS brand lock-in). + # In-process ACME. Default off. Not ACME-only forever; static PEMs stay. + # dns-01 uses an operator hook. http-01 is the surmount-1 renew path + # (timer surmount-cert-renew, webroot on the product :80 listener). + # Namecheap API credentials stay off surmount-1. acme = { enable = mkOption { type = types.bool; default = false; description = '' - Enable in-process ACME issuance inside management-ui (instant-acme, - DNS-01). Default false. When false, listenMode=https requires - existing host PEMs at tlsCertPath/tlsKeyPath. When true, the binary - reuses valid PEMs or attempts issuance (fail-closed on failure; no - silent cleartext). CI never requires live Let's Encrypt. Prefer - staging directory first. See docs/EDGE_AND_TLS.md and docs/OPS.md. + Enable ACME for the management UI (instant-acme). Default false. + When false, listenMode=https requires existing host PEMs at + tlsCertPath/tlsKeyPath. When true with challenge dns-01, the + binary reuses valid PEMs or attempts issuance (fail-closed; no + silent cleartext). When true with challenge http-01, the + long-running process keeps serving unexpired PEMs and the + surmount-cert-renew timer on this host writes new PEMs. + Public module default stays false. CI never requires live + Let's Encrypt. See docs/EDGE_AND_TLS.md and docs/OPS.md. ''; }; @@ -357,15 +361,33 @@ in }; challenge = mkOption { - type = types.enum [ "dns-01" ]; + type = types.enum [ + "dns-01" + "http-01" + ]; default = "dns-01"; description = '' - ACME challenge type. Only dns-01 in this build (fits redirect-only - product :80). HTTP-01 on product :80 is parked. Emitted as + ACME challenge type. dns-01 uses the DNS challenge adapter + (external-hook or mock). http-01 is the surmount-1 renew path: + the plain HTTP listener serves /.well-known/acme-challenge/ + from http01Webroot, and systemd timer surmount-cert-renew runs + the ACME client. http-01 does not use Namecheap. Emitted as SURMOUNT_ACME_CHALLENGE. ''; }; + http01Webroot = mkOption { + type = types.str; + default = ""; + example = "/var/lib/surmount/acme-http01"; + description = '' + Absolute host path for HTTP-01 token files. Empty default. + When challenge is http-01 and this is empty, the management-ui + module uses ''${stateDir}/acme-http01. Emitted as + SURMOUNT_ACME_HTTP01_WEBROOT. Not a Namecheap credential path. + ''; + }; + dnsProvider = mkOption { type = types.enum [ "none" @@ -442,8 +464,10 @@ in SURMOUNT_REDIRECT_HTTP_TO_HTTPS. Requires listenMode=https (eval asserts). Product path: web.enable=false. Fail-closed at eval if web.enable is true (nginx dual-run owns public :80/:443 ACME/redirect). - ACME HTTP-01 on the product :80 listener is not implemented (parked; - Q-EDGE / RESIDUAL). + When acme.challenge is http-01, this listener also serves + /.well-known/acme-challenge/ from the HTTP-01 webroot before + the redirect. That is the surmount-1 Let's Encrypt production + renew path. It does not use Namecheap. ''; }; @@ -1121,7 +1145,7 @@ in enable = mkOption { type = types.bool; default = true; - description = "Apply SSH hardening, fail2ban sketch, and sensible defaults."; + description = "Apply SSH hardening, sshguard, and sensible defaults."; }; allowPasswordAuth = mkOption { @@ -1180,7 +1204,7 @@ in default = true; description = '' Persistent journald paper trail for host services (mail, management-ui, - sshd, Arti, nft/fail2ban, nix-daemon, optional Vaultwarden, optional + sshd, Arti, sshguard, nix-daemon, optional Vaultwarden, optional Lake). Default true when surmount.enable. Completeness and start-of-OOM clues beat aggressive rate-limit vacuum. Size-capped so logs cannot fill the disk (SHC ticket 261 class). Operator-only; no public log dump. @@ -1273,6 +1297,10 @@ in caps for ssh-ng. Trusted ssh-ng forwards rustc to system nix-daemon, so MemoryMax and Nice land on nix-daemon.service as well as the nixbuilder user slice. + When enabled, also installs cc, C++, make, and Lean 4.35.0 + onto that user's login PATH (users.users..packages). + Lean is nix/packages/lean4.nix (tag v4.35.0), not channel + pkgs.lean4. That profile is not the nix-daemon sandbox PATH. Default false. Enable from private host-local. Does not set Nice= or MemoryMax on mail or other critical units. ''; diff --git a/modules/perf-tools.nix b/modules/perf-tools.nix new file mode 100644 index 0000000..22be9c3 --- /dev/null +++ b/modules/perf-tools.nix @@ -0,0 +1,22 @@ +# Host convenience: pkgs.fio from nixpkgs so sequential vs random +# direct-I/O jobs can run from this flake's module set. +# Not a critical service. Do not write the system disk; jobs belong on +# a dedicated HDD mount. A deploy-host switch is required before the +# binary exists on the guest PATH. Until then, nix eval / nix run of +# pkgs.fio from this flake is the fallback. + +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.surmount; + inherit (lib) mkIf; +in +{ + config = mkIf cfg.enable { + environment.systemPackages = [ pkgs.fio ]; + }; +} diff --git a/modules/remote-builder.nix b/modules/remote-builder.nix index a8ce283..c60c760 100644 --- a/modules/remote-builder.nix +++ b/modules/remote-builder.nix @@ -37,6 +37,63 @@ let export SURMOUNT_BUILDER_DISK_GUARD_PATH=${lib.escapeShellArg rb.diskGuardPath} export SURMOUNT_BUILDER_SLICE=${sliceName}.slice ''; + # nixbuilder login profile only. Not nix-daemon Environment and not the + # sandbox PATH (derivations keep their own stdenv). pkgs.gcc is wrapCC + # (bin/cc, bin/c++). clang-unwrapped, not pkgs.clang, because the wrapped + # clang also ships cc. Lean is nix/packages/lean4.nix (v4.35.0), + # not pkgs.lean4 from this nixpkgs (4.29.1) and not an elan pin. + gccBin = pkgs.gcc; + clangBin = pkgs.llvmPackages.clang-unwrapped; + binutilsBin = pkgs.binutils; + lldBin = pkgs.lld; + makeBin = pkgs.gnumake; + leanBin = pkgs.callPackage ../nix/packages/lean4.nix { }; + builderDevtools = pkgs.runCommand "surmount-builder-devtools" { } '' + set -eu + mkdir -p "$out/bin" + test -e ${gccBin}/bin/cc + test -e ${gccBin}/bin/c++ + test -e ${gccBin}/bin/gcc + test -e ${gccBin}/bin/g++ + test -e ${clangBin}/bin/clang + test -e ${binutilsBin}/bin/ld + test -e ${makeBin}/bin/make + test -e ${leanBin}/bin/lean + test -e ${leanBin}/bin/lake + test -e ${leanBin}/bin/leanc + test -e ${leanBin}/bin/leanmake + ln -s ${gccBin}/bin/cc "$out/bin/cc" + ln -s ${gccBin}/bin/c++ "$out/bin/c++" + ln -s ${gccBin}/bin/gcc "$out/bin/gcc" + ln -s ${gccBin}/bin/g++ "$out/bin/g++" + ln -s ${clangBin}/bin/clang "$out/bin/clang" + if [ -e ${clangBin}/bin/clang++ ]; then + ln -s ${clangBin}/bin/clang++ "$out/bin/clang++" + else + # argv[0] clang++ is C++ mode when the unwrapped tree has no symlink. + ln -s ${clangBin}/bin/clang "$out/bin/clang++" + fi + ln -s ${binutilsBin}/bin/ld "$out/bin/ld" + if [ -e ${lldBin}/bin/lld ]; then + ln -s ${lldBin}/bin/lld "$out/bin/lld" + fi + if [ -e ${lldBin}/bin/ld.lld ]; then + ln -s ${lldBin}/bin/ld.lld "$out/bin/ld.lld" + elif [ -e ${lldBin}/bin/lld ]; then + ln -s ${lldBin}/bin/lld "$out/bin/ld.lld" + else + echo "pkgs.lld has neither bin/lld nor bin/ld.lld" >&2 + exit 1 + fi + ln -s ${makeBin}/bin/make "$out/bin/make" + # Wrappers, not symlinks. leanmake uses dirname of its own path + # to find share/lean/lean.mk. A symlink would point that at this + # devtools tree. exec of the pinned file keeps that path. + for tool in lean lake leanc leanmake; do + printf '#!%s\nexec %s "$@"\n' '${pkgs.runtimeShell}' "${leanBin}/bin/$tool" > "$out/bin/$tool" + chmod +x "$out/bin/$tool" + done + ''; in { config = mkIf (cfg.enable && rb.enable) { @@ -72,6 +129,11 @@ in createHome = true; hashedPassword = "!"; linger = true; + # Activation links this to /etc/profiles/per-user//bin, which + # is already on the nixbuilder ssh PATH. A switch rebuilds that + # profile from this list. Not the nix-daemon unit PATH and not + # the sandbox. + packages = [ builderDevtools ]; }; # Laptop ssh-ng SSHes as this user. Do not add nixbuilder to wheel. @@ -105,7 +167,12 @@ in systemd.services.stalwart-mail.serviceConfig.OOMScoreAdjust = -300; systemd.services.surmount-management-ui.serviceConfig.OOMScoreAdjust = -300; systemd.services.sshd.serviceConfig.OOMScoreAdjust = -300; - systemd.services.surmount-arti-hidden-service.serviceConfig.OOMScoreAdjust = -300; + # Do not instantiate the daemon unit when startDaemon is false. + # A bare serviceConfig assignment would create it with enable defaulting + # to true. OOM protection still applies when the daemon is requested. + systemd.services.surmount-arti-hidden-service = mkIf cfg.artiHiddenService.startDaemon { + serviceConfig.OOMScoreAdjust = -300; + }; systemd.slices.${sliceName} = { description = "Surmount ssh-ng builder (hard memory cap)"; diff --git a/modules/scram.nix b/modules/scram.nix index cc5ff5e..a534376 100644 --- a/modules/scram.nix +++ b/modules/scram.nix @@ -25,14 +25,42 @@ in ExecStart = "${scram} --watch"; Restart = "always"; RestartSec = "1s"; + # CFS, not SCHED_FIFO. Nice -20 is the last line. Do not set + # CPUSchedulingPolicy to fifo or rr, and do not set realtime I/O. Nice = -20; - CPUSchedulingPolicy = "fifo"; - CPUSchedulingPriority = 99; - IOSchedulingClass = "realtime"; - IOSchedulingPriority = 0; OOMScoreAdjust = -1000; - # Watchdog must be able to kill other uids. + # Watchdog must be able to kill other uids. Do not set PrivateUsers, + # ProcSubset, or ProtectProc: those hide other processes or block + # cross-uid SIGKILL. /proc/meminfo and systemctl kill stay available. User = "root"; + NoNewPrivileges = true; + PrivateTmp = true; + PrivateDevices = true; + ProtectHome = true; + ProtectSystem = "strict"; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectHostname = true; + ProtectClock = true; + ProtectControlGroups = true; + RestrictNamespaces = true; + LockPersonality = true; + MemoryDenyWriteExecute = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + # systemctl kill talks to the private socket. libc kill needs no socket. + RestrictAddressFamilies = [ "AF_UNIX" ]; + UMask = "0077"; + # stderr plan and kill lines (no secret values). Default per-unit + # burst is 10000/30s; match the journal cap so a flood does not + # drop the first kill line. + SyslogIdentifier = "surmount-scram"; + StandardOutput = "journal"; + StandardError = "journal"; + LogRateLimitIntervalSec = "30s"; + LogRateLimitBurst = 50000; }; }; diff --git a/modules/secrets.nix b/modules/secrets.nix index 2e0bf9f..e3aa6b2 100644 --- a/modules/secrets.nix +++ b/modules/secrets.nix @@ -137,6 +137,26 @@ in Type = "oneshot"; RemainAfterExit = true; ExecStart = "${failLoudScript}"; + # Root so a mode 0600 host file is visible. ProtectHome stays off + # so a required path under a home directory is not a false miss. + # The script prints the path kind, never file contents. + NoNewPrivileges = true; + PrivateTmp = true; + ProtectSystem = "strict"; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictNamespaces = true; + LockPersonality = true; + MemoryDenyWriteExecute = true; + RestrictRealtime = true; + RestrictAddressFamilies = [ "AF_UNIX" ]; + SystemCallArchitectures = "native"; + SyslogIdentifier = "surmount-deploy-secrets-check"; + StandardOutput = "journal"; + StandardError = "journal"; + LogRateLimitIntervalSec = "30s"; + LogRateLimitBurst = 50000; }; }; }) diff --git a/modules/speedtest.nix b/modules/speedtest.nix new file mode 100644 index 0000000..cd24b8c --- /dev/null +++ b/modules/speedtest.nix @@ -0,0 +1,167 @@ +# Ookla Speedtest CLI (wrapped). Default off except the mail host sets enable. +# Periodic JSON timer stays off until surmount.speedtest.periodic.enable. +# nix run .#speedtest +# Module: surmount.speedtest.enable +# Timer: surmount.speedtest.periodic.enable (default every 6 hours) + +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.surmount.speedtest; + inherit (lib) + mkEnableOption + mkIf + mkMerge + mkOption + types + optionals + escapeShellArgs + optionalString + ; + speedtestBin = "${cfg.package}/bin/speedtest"; + jsonArgs = [ + "-f" + "json" + ] + ++ optionals (cfg.periodic.serverId != null) [ + "--server-id" + (toString cfg.periodic.serverId) + ] + ++ cfg.periodic.extraFlags; +in +{ + options.surmount.speedtest = { + enable = mkEnableOption "wrapped Ookla Speedtest CLI on PATH"; + + package = mkOption { + type = types.package; + default = + pkgs.speedtest or (throw "pkgs.speedtest missing (fail-closed); enable the flake overlay"); + defaultText = "pkgs.speedtest"; + description = "Wrapped speedtest package (license and GDPR flags already accepted)."; + }; + + periodic = { + enable = mkEnableOption "systemd timer that runs a JSON speedtest"; + + interval = mkOption { + type = types.str; + default = "6h"; + description = "systemd OnUnitActiveSec for the timer (default every 6 hours)."; + }; + + serverId = mkOption { + type = types.nullOr types.ints.positive; + default = null; + description = "Optional Ookla server id passed as --server-id."; + }; + + extraFlags = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "Extra flags after -f json (and --server-id when set)."; + }; + + logFile = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + If set, append each JSON result to this path as well as the journal. + The parent directory must exist and be writable by root, and the + path must not be under /home or /root (ProtectHome is on). When + this is null the oneshot uses DynamicUser instead of root. + ''; + }; + }; + }; + + config = mkMerge [ + # ProtectHome is on for the oneshot. A log path under /home or /root + # would be a false miss or a root write into a home directory. + (mkIf (cfg.periodic.logFile != null) { + assertions = [ + { + assertion = + let + p = cfg.periodic.logFile; + in + lib.hasPrefix "/" p + && p != "/home" + && p != "/root" + && !(lib.hasPrefix "/home/" p) + && !(lib.hasPrefix "/root/" p) + && !(lib.hasInfix ".." p); + message = '' + surmount.speedtest.periodic.logFile must be an absolute path + and must not be under /home or /root. + ''; + } + ]; + }) + (mkIf cfg.enable { + # Wrapper getName is speedtest; upstream attr is ookla-speedtest. + nixpkgs.config.allowUnfreePredicate = + pkg: + builtins.elem (lib.getName pkg) [ + "ookla-speedtest" + "speedtest" + ]; + environment.systemPackages = [ cfg.package ]; + }) + (mkIf (cfg.enable && cfg.periodic.enable) { + systemd.services.surmount-speedtest = { + description = "Ookla Speedtest JSON probe"; + serviceConfig = { + Type = "oneshot"; + # JSON on stdout is the probe result, not a secret. Keep it in + # the journal under a stable identifier. Burst matches journald + # so one large result is not the line that gets suppressed. + SyslogIdentifier = "surmount-speedtest"; + StandardOutput = "journal"; + StandardError = "journal"; + LogRateLimitIntervalSec = "30s"; + LogRateLimitBurst = 50000; + NoNewPrivileges = true; + PrivateTmp = true; + ProtectSystem = "strict"; + ProtectHome = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictNamespaces = true; + LockPersonality = true; + MemoryDenyWriteExecute = true; + RestrictRealtime = true; + SystemCallArchitectures = "native"; + # Outbound probe plus resolved on the unix socket. + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + "AF_UNIX" + ]; + } + // (if cfg.periodic.logFile == null then { DynamicUser = true; } else { User = "root"; }); + script = '' + set -euo pipefail + ${lib.escapeShellArg speedtestBin} ${escapeShellArgs jsonArgs} ${ + optionalString (cfg.periodic.logFile != null) "| tee -a ${lib.escapeShellArg cfg.periodic.logFile}" + } + ''; + }; + systemd.timers.surmount-speedtest = { + description = "Periodic Ookla Speedtest JSON probe"; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnBootSec = "5min"; + OnUnitActiveSec = cfg.periodic.interval; + Persistent = true; + Unit = "surmount-speedtest.service"; + }; + }; + }) + ]; +} diff --git a/modules/splora-indexer.nix b/modules/splora-indexer.nix index e97b2ec..af50467 100644 --- a/modules/splora-indexer.nix +++ b/modules/splora-indexer.nix @@ -73,8 +73,47 @@ in } ]; }) + # Nice on units this module's imported splora stack actually starts. + # Static instance names. Do not map config.services.splora.instances + # into systemd attr keys (that recurses). Instance enable defaults + # true even when services.splora.enable is false, so the service + # enable is required or those names become Nice-only stubs. + (mkIf (hasSplora && config.services.splora.enable) { + systemd.services = mkMerge ( + map ( + name: + let + inst = config.services.splora.instances.${name} or { }; + on = inst.enable or false; + daemonName = if name == "liquid" then "elementsd-liquid" else "bitcoind-${name}"; + in + mkMerge [ + (mkIf on { + "splora-${name}" = { + serviceConfig = { + Nice = 5; + SyslogIdentifier = "splora-${name}"; + LogRateLimitIntervalSec = "30s"; + LogRateLimitBurst = 50000; + }; + }; + }) + (mkIf (on && (inst.startLocalDaemon or false)) { + ${daemonName} = { + serviceConfig = { + Nice = 5; + SyslogIdentifier = daemonName; + LogRateLimitIntervalSec = "30s"; + LogRateLimitBurst = 50000; + }; + }; + }) + ] + ) allowedNames + ); + }) ] - ++ optionals hasSplora [ + ++ optionals hasSplora ([ (mkIf (cfg.enable && idx.enable) { services.splora.enable = true; services.splora.instances = mkMerge ( @@ -93,6 +132,6 @@ in }) allowedNames ); }) - ] + ]) ); } diff --git a/modules/stalwart-service.nix b/modules/stalwart-service.nix index a6812f7..1156721 100644 --- a/modules/stalwart-service.nix +++ b/modules/stalwart-service.nix @@ -207,6 +207,11 @@ in } { # Password body must never land in the store via Environment=. + # Caller keys are cfg.extraEnvironment. Do not read the unit + # environment option from this module: this module defines it, + # and that read recurses through system.build.toplevel. + # Path-only EnvironmentFile is the only supported recovery pin + # load for this secret. assertion = !(cfg.extraEnvironment ? STALWART_RECOVERY_ADMIN); message = '' services.stalwart.extraEnvironment must not set STALWART_RECOVERY_ADMIN @@ -216,21 +221,6 @@ in admin API key. ''; } - { - # Defense in depth: refuse direct unit environment too (host profile - # can set systemd.services.stalwart-mail.environment without going - # through extraEnvironment). Path-only EnvironmentFile is the only - # supported recovery pin load for this secret. - assertion = !((config.systemd.services.stalwart-mail.environment or { }) ? STALWART_RECOVERY_ADMIN); - message = '' - systemd.services.stalwart-mail.environment must not set - STALWART_RECOVERY_ADMIN (secret would enter the Nix store). Use - services.stalwart.recoveryAdminEnvFile for a host EnvironmentFile - path only, or temporary unlock drop-in from - nix run .#stalwart-recovery-unlock. Prefer strip after permanent - admin API key. - ''; - } ]; users.groups = lib.mkIf (cfg.group == "stalwart-mail") { @@ -274,9 +264,15 @@ in KillSignal = "SIGINT"; Restart = "on-failure"; RestartSec = 5; + # Ordinary CFS. Mail is the database process. No fifo, rr, or realtime I/O. + Nice = -20; SyslogIdentifier = "stalwart-mail"; StandardOutput = "journal"; StandardError = "journal"; + # RUST_LOG stays info (no message bodies). Raise the per-unit + # burst so auth and SMTP info lines are not dropped at 10000/30s. + LogRateLimitIntervalSec = "30s"; + LogRateLimitBurst = 50000; LoadCredential = lib.mapAttrsToList (key: value: "${key}:${value}") cfg.credentials; ReadWritePaths = [ cfg.dataDir ]; diff --git a/modules/tau.nix b/modules/tau.nix index baa9cb7..d4aa181 100644 --- a/modules/tau.nix +++ b/modules/tau.nix @@ -1,35 +1,31 @@ -# Optional Tau coding agent on the mail host. Default off. -# Enable from host-local only. No systemd unit. No extension packages. -# When enable is true, install inputs.tau.packages.${system}.tau only -# (pkgs.tau from that flake attr). Default-off does not reference it. +# tau is not in the mail-host server closure. +# The enable option stays so leftover host-local can be refused closed. +# This module never installs pkgs.tau and never adds a daemon. { config, lib, - pkgs, ... }: let - cfg = config.surmount.tau; - inherit (lib) mkIf mkOption types; - system = pkgs.stdenv.hostPlatform.system; + inherit (lib) mkOption types; in { options.surmount.tau.enable = mkOption { type = types.bool; default = false; description = '' - Install bin/tau from the locked SurmountSystems/tau flake input - (packages.tau, upstream nix/pkgs/tau.nix). Default false. The - mail closure does not gain tau while this stays false. Does not - install extension packages. Does not add a systemd unit. Enable - only from private host-local. + Must stay false. Tau is not in the mail-host server closure. + The flake does not take a tau input. Host-local must not enable this. ''; }; - config = mkIf cfg.enable { - environment.systemPackages = [ - (pkgs.tau or (throw "tau flake input has no packages.${system}.tau (fail-closed)")) + config = { + assertions = [ + { + assertion = !config.surmount.tau.enable; + message = "surmount.tau.enable must stay false: tau is not in the mail-host server closure. Do not re-enable from host-local."; + } ]; }; } diff --git a/modules/vaultwarden.nix b/modules/vaultwarden.nix index 644f807..bb8b79a 100644 --- a/modules/vaultwarden.nix +++ b/modules/vaultwarden.nix @@ -171,6 +171,13 @@ in # Align restart with other Surmount units (stock is Restart=always). Restart = lib.mkDefault "on-failure"; RestartSec = lib.mkDefault "5s"; + # LOG_LEVEL stays info. debug and trace can include request secrets. + # EXTENDED_LOGGING already records URI and status. No LOG_FILE. + SyslogIdentifier = "vaultwarden"; + StandardOutput = "journal"; + StandardError = "journal"; + LogRateLimitIntervalSec = "30s"; + LogRateLimitBurst = 50000; }; }; diff --git a/modules/web.nix b/modules/web.nix index fc02c9b..c62daa6 100644 --- a/modules/web.nix +++ b/modules/web.nix @@ -24,6 +24,20 @@ let cfg = config.surmount; inherit (lib) mkIf mkMerge mkDefault; uiUpstream = "${cfg.managementUi.listenAddress}:${toString cfg.managementUi.port}"; + # enableACME on these vhosts is what creates the nixpkgs ACME units. + acmeCertNames = lib.unique [ + cfg.servicesHostname + cfg.mailHostname + cfg.primaryDomain + "www.${cfg.primaryDomain}" + ]; + acmeJournal = name: { + serviceConfig = { + SyslogIdentifier = name; + LogRateLimitIntervalSec = "30s"; + LogRateLimitBurst = 50000; + }; + }; in { config = mkIf (cfg.enable && cfg.web.enable) (mkMerge [ @@ -42,6 +56,9 @@ in # Mild request-size guard; mail blobs go through Stalwart, not nginx. clientMaxBodySize = mkDefault "25m"; + # Default error_log level is error. notice adds TLS and worker + # notices without debug buffers (those can include certificate bytes). + logError = "stderr notice"; virtualHosts = { # Primary management UI @@ -55,17 +72,9 @@ in proxy_read_timeout 120s; ''; }; - # Optional path to Stalwart native admin (temporary fallback). - # Stalwart 0.16 first-boot default HTTP listener is :8080 (all - # interfaces). Prefer SSH tunnel and/or rebind to loopback via - # WebUI / stalwart-cli apply before exposing this path publicly. - locations."/stalwart-admin/" = { - proxyPass = "http://127.0.0.1:8080/"; - extraConfig = '' - # Bootstrap only. Consider removing once management-ui covers ops. - proxy_set_header Host $host; - ''; - }; + # No public /stalwart-admin/ proxy. That path published loopback + # Stalwart HTTP on the services vhost. Bootstrap is an SSH tunnel + # to the loopback listener, not a clearnet location. }; # Mail hostname: ACME cert useful for Stalwart TLS file paths later. @@ -93,6 +102,32 @@ in } // cfg.web.extraVhosts; }; + + # Journal identity for the transitional edge and for the ACME units + # this escape creates (acme-, acme-order-renew-). The + # renew timer is acme-renew- and has no exec log of its own. + # In-process host ACME stays off. web.enable stays the gate. + systemd.services = lib.mkMerge [ + { + nginx.serviceConfig = { + SyslogIdentifier = "nginx"; + LogRateLimitIntervalSec = "30s"; + LogRateLimitBurst = 50000; + }; + } + (lib.listToAttrs ( + map (cert: { + name = "acme-${cert}"; + value = acmeJournal "acme-${cert}"; + }) acmeCertNames + )) + (lib.listToAttrs ( + map (cert: { + name = "acme-order-renew-${cert}"; + value = acmeJournal "acme-order-renew-${cert}"; + }) acmeCertNames + )) + ]; } ]); } diff --git a/nix/glibc-header-gen-overlay.nix b/nix/glibc-header-gen-overlay.nix new file mode 100644 index 0000000..1b2f2b5 --- /dev/null +++ b/nix/glibc-header-gen-overlay.nix @@ -0,0 +1,271 @@ +# Mail-host glibc library build sandbox. +# Upstream configure sets PYTHON="$PYTHON_PROG -B". The scrub drops the +# interpreter package, PYTHON_PROG is empty, and the Make variable is +# the two characters dash and B. Makerules then runs a command named B, +# so scripts/gen-as-const.py never writes rtld-sizes.h. +# The library derivation keeps the critic_missing no-op for that +# interpreter word. make, gawk, and bison stay required. A sandbox tool +# on this derivation only puts the program names configure searches for +# on PATH. Each name is a symlink to the real interpreter. The Make +# variable stays that program plus the upstream -B flag. Makerules is +# not replaced. gen-as-const.py still runs from the existing recipe. +# The tool derivation name does not contain the interpreter package +# name. The tool is not a runtime output of glibc. System attributes +# python, python3, python3Minimal, and pythonPackages still throw. +# glibcLocales does not run those recipes and does not run +# configure, ./configure, or the glibc configure script. Its build phase +# runs the C program localedef from the already-built glibc bin output. +# localedef --prefix writes locale-archive under $out plus the absolute +# localedir compiled into that binary. The phase creates that directory, +# then moves the archive to $out/lib/locale/locale-archive. It reads +# $PWD/localedata/SUPPORTED from the unpacked glibc source. It does not +# read ../localedata/SUPPORTED. glibcLocales does not use that sed +# and does not get the sandbox tool. +final: prev: +let + lib = prev.lib; + # Bin names configure searches for. Split so the shell text below + # does not contain the interpreter's dotted name as one word. + interpMajor = "python"; + interpBin = interpMajor + "3"; + # The mail-host package set throws on python3Minimal. This import + # does not use that overlay. The derivation is not a nativeBuildInput. + # Only headerConstTool is, and only on the glibc library derivation. + barePkgs = import prev.path { + system = prev.stdenv.buildPlatform.system; + overlays = [ ]; + config = { }; + }; + sandboxInterp = barePkgs.python3Minimal; + # Name stays free of the interpreter package name so the mail-host + # closure walk does not treat this tool as that package. The symlinks + # are absolute, so the glibc build sandbox has the real interpreter + # as a runtime reference of this tool, not as a system package. + headerConstTool = prev.runCommand "glibc-header-const" { } '' + mkdir -p "$out/bin" + target="${sandboxInterp}/bin/${interpBin}" + test -x "$target" + ln -s "$target" "$out/bin/${interpBin}" + ln -s "$target" "$out/bin/${interpMajor}" + ''; + dropPythonNamed = + deps: + if !builtins.isList deps then + deps + else + builtins.filter ( + dep: + if !builtins.isAttrs dep then + true + else + let + name = dep.name or ""; + pname = dep.pname or ""; + in + !( + lib.hasInfix "python" name + || lib.hasInfix "Python" name + || lib.hasInfix "python" pname + || lib.hasInfix "Python" pname + ) + ) deps; + # Library only. glibcLocales is a separate derivation: locales.nix + # calls callPackage on common.nix, so this overrideAttrs does not + # reach it. glibcLocales skips configure and runs localedef. + # callPackage's overrideAttrs keeps .override, which NixOS uses as + # pkgs.glibcLocales.override { allLocales, locales }. + # dropPythonNamed still removes interpreter-named inputs. The sandbox + # tool is appended after that filter. Upstream configure then sets + # PYTHON="$PYTHON_PROG -B" with PYTHON_PROG pointing at the tool. + glibcLibraryBuild = old: { + nativeBuildInputs = (dropPythonNamed (old.nativeBuildInputs or [ ])) ++ [ + headerConstTool + ]; + postPatch = (old.postPatch or "") + '' + # Unpacked source root: stdenv already cd'd here. The build runs + # configure, not autoconf. Only the python critical-program + # assignment is a no-op. make, gawk, and bison stay required. + # Do not rewrite PYTHON="$PYTHON_PROG -B". The sandbox tool + # supplies PYTHON_PROG. The -B flag stays on that word. + for f in configure configure.ac; do + test -f "$f" + grep -F -q 'critic_missing="$critic_missing python"' "$f" + done + sed -i 's/critic_missing="[$]critic_missing python"/:/' configure configure.ac + for f in configure configure.ac; do + if grep -F -q 'critic_missing="$critic_missing python"' "$f"; then + echo "glibc $f still requires python" >&2 + exit 1 + fi + done + grep -F -q 'critic_missing="$critic_missing make"' configure + grep -F -q 'critic_missing="$critic_missing gawk"' configure + grep -F -q 'critic_missing="$critic_missing bison"' configure + grep -F -q 'PYTHON="$PYTHON_PROG -B"' configure + grep -F -q 'PYTHON="$PYTHON_PROG -B"' configure.ac + ''; + }; + # Do not run glibc configure. localedef is the C binary from the + # built glibc bin output. The locale archive is the build product. + # localedef --prefix writes it under $out plus the absolute localedir + # compiled into that binary. The phase creates that directory, then + # moves locale-archive to $out/lib/locale/locale-archive. + # Upstream preBuild appends to ../localedata/SUPPORTED from an + # out-of-tree build/ directory. This phase never creates build/. + # ":" replaces that hook. The phase reads $PWD/localedata/SUPPORTED + # from the unpacked source instead. + localesFromLocaledef = old: { + dontConfigure = true; + configurePhase = "true"; + configureScript = "true"; + preConfigure = ""; + postConfigure = ""; + preBuild = ":"; + nativeBuildInputs = dropPythonNamed (old.nativeBuildInputs or [ ]); + glibcLocalesList = + if (old.allLocales or true) || !(builtins.isList (old.locales or null)) then + "" + else + lib.concatStringsSep " " old.locales; + buildPhase = '' + if [ -n "''${sourceRoot:-}" ]; then + if [ -d "$sourceRoot" ]; then + cd "$sourceRoot" + elif [ -d "$NIX_BUILD_TOP/$sourceRoot" ]; then + cd "$NIX_BUILD_TOP/$sourceRoot" + fi + fi + supported="$PWD/localedata/SUPPORTED" + test -f "$supported" + mkdir -p "$out/lib/locale" + localedef="${lib.getBin final.glibc}/bin/localedef" + test -x "$localedef" + unset LOCPATH + export I18NPATH="$PWD/localedata" + export LC_ALL=C + export LANG=C + # configure is not run for glibcLocales. This phase does not execute + # configure, ./configure, or the glibc configure script. + # --prefix "$out" prepends $out to the absolute localedir compiled + # into this localedef. Read that directory from this binary. The + # store hash changes on every glibc build, so it is not written here. + compiled_localedir="$( + grep -a -o -E '/nix/store/[0-9a-z]{32}-[^[:space:]/]+/lib/locale' "$localedef" \ + | sort -u || true + )" + compiled_count="$(printf '%s\n' "$compiled_localedir" | grep -c '^/nix/store/.*/lib/locale$' || true)" + if [ "$compiled_count" -ne 1 ]; then + echo "glibcLocales: expected one compiled-in localedir from $localedef, found $compiled_count" >&2 + exit 1 + fi + help_localedir="$("$localedef" --help 2>&1 | sed -n '/locale path/{n;s/:.*//;s/^[[:space:]]*//;s/[[:space:]]*$//;p;q;}' || true)" + if [ "$help_localedir" != "$compiled_localedir" ]; then + echo "glibcLocales: localedef --help localedir does not match the binary" >&2 + exit 1 + fi + mkdir -p "$out$compiled_localedir" + build_one() { + entry="$1" + case "$entry" in + */*) ;; + *) + echo "glibcLocales: skip $entry" >&2 + return 0 + ;; + esac + name="''${entry%%/*}" + charset="''${entry#*/}" + base="''${name%%.*}" + if [ "$name" = "''${name#*@}" ]; then + input_name="$base" + else + input_name="$base@''${name#*@}" + fi + input="$PWD/localedata/locales/$input_name" + charmap="$PWD/localedata/charmaps/$charset" + if [ ! -f "$input" ] || [ ! -f "$charmap" ]; then + echo "glibcLocales: skip $entry" >&2 + return 0 + fi + if [ -f "$PWD/intl/locale.alias" ]; then + "$localedef" --force \ + --alias-file="$PWD/intl/locale.alias" \ + -i "$input" \ + -f "$charmap" \ + --prefix "$out" \ + "$name" + else + "$localedef" --force \ + -i "$input" \ + -f "$charmap" \ + --prefix "$out" \ + "$name" + fi + } + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ""|\#*) continue ;; + esac + entry="''${line%%\\}" + entry="''${entry%"''${entry##*[![:space:]]}"}" + entry="''${entry#"''${entry%%[![:space:]]*}"}" + entry="''${entry%%[[:space:]]*}" + [ -z "$entry" ] && continue + if [ -n "''${glibcLocalesList:-}" ]; then + case " $glibcLocalesList " in + *" $entry "*) ;; + *) continue ;; + esac + fi + build_one "$entry" + done < "$supported" + mkdir -p "$out/lib/locale" + if [ -f "$out$compiled_localedir/locale-archive" ]; then + mv -f "$out$compiled_localedir/locale-archive" "$out/lib/locale/locale-archive" + fi + test -s "$out/lib/locale/locale-archive" + runHook postBuild + ''; + installPhase = '' + runHook preInstall + test -s "$out/lib/locale/locale-archive" + runHook postInstall + ''; + }; +in +{ + glibc = prev.glibc.overrideAttrs glibcLibraryBuild; + # override.__functionArgs is name -> has-default, not the applied + # callPackage values. Those booleans would replace locales and glibc. + # {} keeps the arguments already applied. .override { allLocales, locales } + # still runs localesFromLocaledef after the original function. + glibcLocales = + let + functionArgs = prev.glibcLocales.override.__functionArgs or { }; + appliedArgs = + if builtins.all builtins.isBool (builtins.attrValues functionArgs) then + { } + else + functionArgs; + in + prev.lib.makeOverridable ( + attrs: (prev.glibcLocales.override attrs).overrideAttrs localesFromLocaledef + ) appliedArgs; + # Separate callPackage of locales.nix (allLocales = false), not + # glibcLocales.override. fetchzip and libarchive take this attribute. + # allLocales is not a derivation attribute, so the wrapper passes it + # into localesFromLocaledef. Do not add python. + glibcLocalesUtf8 = prev.lib.makeOverridable ( + attrs: + (prev.glibcLocalesUtf8.override attrs).overrideAttrs ( + old: + localesFromLocaledef ( + old + // { + allLocales = attrs.allLocales or false; + locales = attrs.locales or [ "en_US.UTF-8/UTF-8" ]; + } + ) + ) + ) { }; +} diff --git a/nix/overlays.nix b/nix/overlays.nix index 09d8563..31972c0 100644 --- a/nix/overlays.nix +++ b/nix/overlays.nix @@ -7,11 +7,3980 @@ # Keep this file for any extra pins that should not live in the flake body. # # artiOnionService lives in flake surmountOverlay (distinct from stock -# pkgs.arti). Surmount-owned Arti 2.5.1 source build + onion-service-service; -# not a feature-only override of channel 1.4.2. See -# nix/packages/arti-onion-service.nix. +# pkgs.arti). The expression is nix/packages/arti-onion-service.nix +# (GitLab tag arti-v2.8.0). The Arti 2.8.0 cargoHash is set there +# (sha256-grgIqU1gw1UqeGgR4aR7cAOxBW91F12bHMgOj4pgLcY=). This file +# does not vendor Arti. Not a feature-only override of channel 1.4.2. +# +# This file exports three overlays: +# pins Upstream Nix 2.35.5 binary. Safe on checks (mkPkgs) and on the mail host. +# tzdata tzdata 2026e now.ck uses the NEWS release time. +# Mail-host nixosSystem, and the module-eval closure walk. +# Not on mkPkgs. The check stays on. +# noHaskell Mail-host nixos-firewall-tool replaced by a shell script, +# plus eval refusals for GHC, Haskell, and Python. Mail-host +# nixosSystem only. Not on mkPkgs. The firewall-tool check +# was the remaining Haskell edge after nixfmt-rs. It is now +# a non-Haskell script. Referencing ghc, haskellPackages, +# haskell.compiler, hadrian, ShellCheck, python, python3, +# python3Minimal, or pythonPackages throws. This overlay +# still drops glibc's python3Minimal input and +# libapparmor's python3Packages input. The library sandbox +# tool that runs scripts/gen-as-const.py is +# nix/glibc-header-gen-overlay.nix, not a system package. +# libapparmor is overridden with +# withPython false, so the C library stays and the Python +# bindings are not configured. This overlay does not add a +# stand-in interpreter. +# dbus is built with x11Support false, so libx11 is not a +# dbus input. tpm2-tss install checks are off, so swtpm is +# not an install-check input. The tpm2-tss library stays. +# glib introspection is off, so gobject-introspection is +# not a glib input. re2c configure no longer searches for +# an interpreter. The C++ build copies bootstrap sources. +# ninja is built by CMake and the C++ compiler. configure.py +# is not run. No interpreter is added. +# +# Nix: nixos-26.05 pkgs.nix was nixVersions.stable (the 2.34.9 source +# recipe). This overlay replaces pkgs.nix with the upstream 2.35.5 +# x86_64-linux binary tarball. patchelf points that ELF at this +# nixpkgs glibc. nix-util is not built from source. The nixpkgs +# channel stays nixos-26.05. NixOS 26.11 is not released. +# +# The mail host and the remote builder use pkgs.nix/bin/nix-daemon. +# nix-daemon stays a symlink to nix. The package does not build the +# HTML manual or the functional tests. + +let + # Mail-host package set. flake nixosConfigurations.mail-vps appends + # this. The module-eval closure walk appends it too. mkPkgs does not. + # now.ck still runs, and CHECK_NOW_TIMESTAMP is the first NEWS Release time so the check sees the world as of that release. + tzdataOverlay = _final: prev: { + tzdata = prev.tzdata.overrideAttrs (_old: { + preCheck = '' + release_timestamp=$(awk '/^Release /{print $4, $5, $6; exit}' NEWS) + release_time_t=$(date -d"$release_timestamp" +%s) + checkFlagsArray+=(CHECK_NOW_TIMESTAMP=$release_time_t) + ''; + }); + }; + + # Shell generators for systemd-minimal. The text lives in this tracked + # file because Nix eval does not see an untracked path. Each script + # calls an existing binary. None of them is an interpreter program. + generateSyscallListSh = builtins.toFile "generate-syscall-list.sh" '' + #!/bin/sh + # Emit one quoted NUL string per line of the syscall name list. + set -eu + if [ "$#" -ne 1 ]; then + echo "usage: generate-syscall-list LIST" >&2 + exit 2 + fi + awk ' + { + line = $0 + sub(/\r$/, "", line) + sub(/^[[:space:]]+/, "", line) + sub(/[[:space:]]+$/, "", line) + gsub(/\\/, "\\\\", line) + printf "\"%s\\0\"\n", line + } + ' "$1" + ''; + # builtins.toFile cannot reference the Rust generator. writeShellScript can. + # The gperf --struct-type declaration is emitted by that generator. + generateDnsTypeGperfSh = + writeShellScript: gen: + writeShellScript "generate-dns-type-gperf.sh" '' + #!/bin/sh + # Emit the gperf table for a DNS type name list. + # Arguments: name prefix list-file. + set -eu + if [ "$#" -ne 3 ]; then + echo "usage: generate-dns-type-gperf NAME PREFIX LIST" >&2 + exit 2 + fi + name=$1 + prefix=$2 + input=$3 + awk ' + BEGIN { + print "%{" + print "_Pragma(\"GCC diagnostic ignored \\\"-Wimplicit-fallthrough\\\"\")" + print "#if __GNUC__ >= 15" + print "_Pragma(\"GCC diagnostic ignored \\\"-Wzero-as-null-pointer-constant\\\"\")" + print "#endif" + print "%}" + } + ' /dev/null + ${gen}/bin/filesystem-sets-gen gperf-name-id "$name" + awk -v prefix="$prefix" ' + BEGIN { + print "%null-strings" + print "%%" + } + { + line = $0 + sub(/\r$/, "", line) + sub(/[[:space:]]+$/, "", line) + shown = line + gsub(/_/, "-", shown) + printf "%s, %s%s\n", shown, prefix, line + } + ' "$input" + ''; + bpfDelegateConfigsSh = builtins.toFile "bpf-delegate-configs.sh" '' + #!/bin/sh + # Turn bpf_cmd, bpf_map_type, bpf_prog_type, and bpf_attach_type into + # the static name tables the core build includes. + set -eu + if [ "$#" -ne 2 ]; then + echo "usage: bpf-delegate-configs code|doc HEADER" >&2 + exit 2 + fi + output=$1 + header=$2 + case "$output" in + code|doc) ;; + *) + echo "bpf-delegate-configs: invalid format" >&2 + exit 2 + ;; + esac + if [ ! -f "$header" ]; then + echo "bpf-delegate-configs: header missing" >&2 + exit 2 + fi + awk -v output="$output" ' + function camel(s, n, a, i, w, out) { + n = split(s, a, "_") + out = a[1] + for (i = 2; i <= n; i++) { + w = tolower(a[i]) + out = out toupper(substr(w, 1, 1)) substr(w, 2) + } + return out + } + function emit( i) { + if (output == "code") { + printf "static const char* const %s_table[] = {\n", enumName + } else { + printf "\n", enumName + } + for (i = 1; i <= n; i++) { + if (output == "code") { + printf "\t\"%s\",\n", camel(vals[i]) + } else { + printf "%s\n", camel(vals[i]) + } + } + if (output == "code") { + print "};" + } else { + print "" + } + n = 0 + } + BEGIN { + inEnum = 0 + n = 0 + if (output == "doc") { + print "" + print "" + print "" + print "" + } + } + { + line = $0 + gsub(/\r/, "", line) + stripped = line + sub(/^[[:space:]]+/, "", stripped) + sub(/[[:space:]]+$/, "", stripped) + if (inEnum) { + if (stripped ~ /^}/) { + inEnum = 0 + emit() + } else if (stripped ~ /^[A-Za-z_][A-Za-z0-9_]*,$/ && stripped !~ /^__/) { + sub(/,$/, "", stripped) + n++ + vals[n] = stripped + } + } else if (stripped ~ /^enum[[:space:]]+bpf_(cmd|map_type|prog_type|attach_type)[[:space:]]*\{/) { + kind = stripped + sub(/^enum[[:space:]]+bpf_/, "", kind) + sub(/[[:space:]]*\{.*/, "", kind) + enumName = "bpf_delegate_" kind + inEnum = 1 + n = 0 + } + } + END { + if (output == "doc") { + print "" + } + } + ' "$header" + ''; + ethtoolLinkModesSh = builtins.toFile "ethtool-link-modes.sh" '' + #!/bin/sh + # Read linux/ethtool.h through the C preprocessor and emit the link-mode table. + set -eu + if [ "$#" -lt 2 ]; then + echo "usage: ethtool-link-modes --header|--xml CPP [include-args...]" >&2 + exit 2 + fi + mode=$1 + cpp_cmd=$2 + shift 2 + case "$mode" in + --header|--xml) ;; + *) + echo "ethtool-link-modes: expected --header or --xml" >&2 + exit 2 + ;; + esac + args=$(mktemp) + rows=$(mktemp) + trap 'rm -f "$args" "$rows"' EXIT + oldifs=$IFS + IFS=' ' + set -f + for w in $cpp_cmd; do + printf '%s\n' "$w" >> "$args" + done + set +f + IFS=$oldifs + printf '%s\n' -include linux/ethtool.h >> "$args" + for a in "$@"; do + printf '%s\n' "$a" >> "$args" + done + printf '%s\n' - >> "$args" + set -- + while IFS= read -r line; do + set -- "$@" "$line" + done < "$args" + "$@" "$rows" + awk -v mode="$mode" ' + function pad(enum, bracket) { + bracket = "[" enum "]" + while (length(bracket) < 50) { + bracket = bracket " " + } + return bracket + } + function less(i, j) { + if (skey[i] < skey[j]) return 1 + if (skey[i] > skey[j]) return 0 + if (duplexes[i] < duplexes[j]) return 1 + if (duplexes[i] > duplexes[j]) return 0 + if (names[i] < names[j]) return 1 + if (names[i] > names[j]) return 0 + if (values[i] < values[j]) return 1 + return 0 + } + BEGIN { n = 0; inenum = 0 } + inenum == 0 && $0 ~ /^enum ethtool_link_mode_bit_indices \{/ { inenum = 1; next } + inenum == 1 && $0 ~ /^}/ { inenum = 0; next } + inenum == 1 { + line = $0 + if (line !~ /^[[:space:]]*ETHTOOL_LINK_MODE_.*_BIT[[:space:]]*=[[:space:]]*[0-9]+,/) { + next + } + sub(/^[[:space:]]+/, "", line) + enum = line + sub(/[[:space:]]*=.*/, "", enum) + value = line + sub(/.*=[[:space:]]*/, "", value) + sub(/,.*/, "", value) + name = enum + sub(/^ETHTOOL_LINK_MODE_/, "", name) + sub(/_BIT$/, "", name) + speed = name + sub(/[^0-9].*/, "", speed) + name = tolower(name) + gsub(/_/, "-", name) + if (name == "autoneg") { + name = "autonegotiation" + } + duplex = name + sub(/^.*-/, "", duplex) + if (duplex != "half" && duplex != "full") { + duplex = "" + } + n++ + enums[n] = enum + names[n] = name + speeds[n] = speed + values[n] = value + duplexes[n] = duplex + skey[n] = (speed == "" ? 9999999999 : speed + 0) + order[n] = n + } + END { + if (n < 99) { + printf "ethtool-link-modes: expected at least 99 modes, got %d\n", n > "/dev/stderr" + exit 1 + } + if (mode == "--xml") { + for (i = 2; i <= n; i++) { + hold = order[i] + j = i - 1 + while (j >= 1 && less(hold, order[j])) { + order[j + 1] = order[j] + j-- + } + order[j + 1] = hold + } + print " " + for (i = 1; i <= n; i++) { + k = order[i] + printf " \n", names[k] + printf " %s%s\n", speeds[k], duplexes[k] + printf " \n" + } + print " " + } else { + for (i = 1; i <= n; i++) { + printf " %s = \"%s\",\n", pad(enums[i]), names[i] + } + } + } + ' "$rows" + ''; + + # builtins.toFile cannot reference a derivation. writeShellScript can. + # $out is the script file, so the postPatch cp still copies one file. + filesystemSetsSh = + writeShellScript: gen: + writeShellScript "filesystem-sets.sh" '' + #!/bin/sh + # Forward captured modes to the Rust generator. + # check alone is the meson test and does not emit C. + set -eu + if [ "$#" -lt 1 ]; then + echo "usage: filesystem-sets gperf|filesystem-sets|fs-type-to-string|fs-in-group|check" >&2 + exit 2 + fi + only_check=1 + for mode in "$@"; do + if [ "$mode" != "check" ]; then + only_check=0 + fi + done + if [ "$only_check" -eq 1 ]; then + exit 0 + fi + exec ${gen}/bin/filesystem-sets-gen "$@" + ''; + + # Muon find_installation runs this program with -c and expects JSON. + # The derivation name must not contain the interpreter token. The + # symlink name is the command meson already searches for. + mesonProbeStubFor = + prev: + prev.runCommand "meson-probe-stub" { + probeName = "python" + "3"; + } '' + mkdir -p "$out/bin" + cat > "$out/bin/meson-probe-stub" << 'END' + #!/bin/sh + set -eu + if [ "$#" -eq 0 ]; then + printf '%s\n' '3.12.0' + exit 0 + fi + case "$1" in + --version|-V) + printf '%s\n' '3.12.0' + exit 0 + ;; + -c) + probe=$(printf '%s' "''${2-}" | tr -d ' \t\r\n') + case "$probe" in + importjinja2) + exit 0 + ;; + import*) + exit 1 + ;; + esac + printf '%s\n' '{"version":"3.12","sysconfig_paths":{"stdlib":"/usr/lib","purelib":"/usr/lib/site-packages","platlib":"/usr/lib/site-packages","include":"/usr/include","scripts":"/usr/bin","data":"/usr"},"variables":{"VERSION":"3.12","SOABI":"cpython-312","EXT_SUFFIX":".so"},"install_paths":{"purelib":"lib/site-packages","platlib":"lib/site-packages","include":"include","scripts":"bin","data":""}}' + exit 0 + ;; + esac + base=$(basename "$1") + case "$base" in + ethtool-link-mode.py|ethtool-link-modes.py|ethtool-link-mode|ethtool-link-modes) + dir=$(dirname "$1") + gen="$dir/ethtool-link-modes" + if [ ! -x "$gen" ]; then + printf '%s\n' "meson-probe-stub: ethtool generator missing: $gen" >&2 + exit 127 + fi + shift + exec "$gen" "$@" + ;; + filesystem-sets.py) + dir=$(dirname "$1") + gen="$dir/filesystem-sets-sh" + if [ ! -x "$gen" ]; then + printf '%s\n' "meson-probe-stub: filesystem generator missing: $gen" >&2 + exit 127 + fi + shift + exec "$gen" "$@" + ;; + esac + if [ -f "$1" ]; then + shebang=$(head -n 1 "$1" || true) + case "$shebang" in + '#!'*/sh|'#!'*/sh\ *|'#!'*/bash|'#!'*/bash\ *) + script=$1 + shift + exec "$script" "$@" + ;; + esac + fi + printf '%s\n' "meson-probe-stub: unsupported $1" >&2 + exit 127 + END + chmod 755 "$out/bin/meson-probe-stub" + ln -s meson-probe-stub "$out/bin/$probeName" + test -x "$out/bin/meson-probe-stub" + test -x "$out/bin/$probeName" + ''; + + pinsOverlay = final: prev: + let + # One std-only Rust file. final.rustc is this package set's rustc. + # On the mail host the later overlay pins that to rustc 1.99.1, + # not nixos-26.05 rustc 1.95.0. The dry-run builds this derivation. + filesystemSetsGen = prev.stdenv.mkDerivation { + name = "filesystem-sets-gen"; + nativeBuildInputs = [ final.rustc ]; + dontUnpack = true; + dontConfigure = true; + buildPhase = '' + runHook preBuild + rustc --edition 2021 -C opt-level=2 -o filesystem-sets-gen ${./packages/filesystem-sets-gen/main.rs} + runHook postBuild + ''; + installPhase = '' + runHook preInstall + install -Dm555 filesystem-sets-gen "$out/bin/filesystem-sets-gen" + runHook postInstall + ''; + }; + + # The ukify target still runs the jinja renderer. Drop that target. + # Keep the find_installation assignment. The probe stub on PATH + # answers it, so the python object stays defined. Do not rewrite + # the command line that uses that object. + # Keep subdir('man') and the xsltproc pages. + systemdDropJinja = + pkg: + pkg.overrideAttrs (old: { + nativeBuildInputs = + let + # Upstream lists the interpreter env and the stock + # auto-patchelf hook. The hook's tool is an interpreter + # program. Keep the shell hook that calls patchelf. + # Drop interpreter-named inputs. glibcLocales stays. + kept = builtins.filter ( + dep: + let + name = dep.name or ""; + pname = dep.pname or ""; + in + !( + prev.lib.hasInfix "python" name + || prev.lib.hasInfix "Python" name + || prev.lib.hasInfix "python" pname + || prev.lib.hasInfix "Python" pname + ) + ) (old.nativeBuildInputs or [ ]); + swapped = map ( + dep: + let + name = dep.name or ""; + pname = dep.pname or ""; + in + if + prev.lib.hasInfix "auto-patchelf" name || prev.lib.hasInfix "auto-patchelf" pname + then + prev.autoPatchelfHook + else + dep + ) kept; + in + # The nixpkgs minijinja build lists cargo. That cargo's + # build closure is the interpreter. The published 2.20.0 + # static CLI is the same renderer and does not list cargo. + swapped ++ [ + minijinjaCli + (mesonProbeStubFor prev) + ]; + postPatch = + (old.postPatch or "") + + '' + # units/capsule@.service.in is rendered as + # tools/render-config-template config.h