diff --git a/.cargo/audit.toml b/.cargo/audit.toml new file mode 100644 index 0000000..e66095a --- /dev/null +++ b/.cargo/audit.toml @@ -0,0 +1,40 @@ +# Decision 2026-09-26. +# +# Ignore only RUSTSEC-2024-0436 (paste) and RUSTSEC-2026-0173 +# (proc-macro-error2). Do not ignore any other advisory. Do not turn +# off the whole unmaintained class. +# +# Both records are RustSec type INFO, informational "unmaintained". +# They are not CVEs. The OSV records have cvss null and no CVE alias. +# +# paste (RUSTSEC-2024-0436) pastes identifiers inside a compile-time +# macro. The dtolnay/paste repo was archived on 2024-10-06. The advisory +# text is only that the project is unmaintained. It does not describe a +# vulnerability. +# https://rustsec.org/advisories/RUSTSEC-2024-0436 +# +# proc-macro-error2 (RUSTSEC-2026-0173) prints compiler errors when a +# procedural macro fails. The author confirmed it is unmaintained. The +# advisory does not describe an exploitable bug. This id is +# proc-macro-error2, not age. +# https://rustsec.org/advisories/RUSTSEC-2026-0173 +# +# Both entered this lock only through Leptos. The management UI is the +# only product crate that depends on Leptos. Its source does not call +# paste or proc-macro-error. The crates run inside rustc while the UI is +# compiled. They are not in the mail server process and not in the +# running management UI process. A lockfile pin means a later crates.io +# upload does not enter the build until someone updates the lock. +# +# Do not vendor Leptos to clear these two ids. +# +# The hermetic check is flake.nix checks.*.cargo-audit. That derivation +# does not see this file. It passes --ignore for these two ids and still +# passes --deny warnings. This file is the same list for a local +# cargo-audit that reads .cargo/audit.toml. + +[advisories] +ignore = [ + "RUSTSEC-2024-0436", + "RUSTSEC-2026-0173", +] diff --git a/.gitignore b/.gitignore index dc52fd3..3df0bf5 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ target .DS_Store *~ *.swp +# These are patch backups and must not be committed. +*.orig # Local agent session reports / plans (not product). May name private # host work; never part of the public commit surface. diff --git a/AGENTS.md b/AGENTS.md index 58312c2..dac051b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,9 +83,13 @@ Product detail lives under `docs/`. This file is standing law only. loop for that. **Prove the operator gate (operator 2026-09-02; laptop issue 2026-09-07).** Do not list a step as operator residual unless you have evidence the agent cannot do it: - secrets custody, standing forbid of the real `just deploy-host` - switch and of `just check-remote` (2026-08-25; narrowed 2026-09-02: - `just deploy-host -- --dry-run` is required agent work), hypervisor, + secrets custody, standing forbid of the real `just deploy` + switch (2026-08-25; narrowed 2026-09-02, + short names 2026-10-05: `just deploy-dry-run` is required agent work; + agents may run one `just check-remote` at a time; agents must not run + `just deploy`; `just deploy-host` without `--dry-run` is that same + 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 @@ -369,18 +373,46 @@ file is still not a reason to stage. ## Deploy dry-run is agent work (operator 2026-09-02) +Full command list: [docs/TEST-AND-DEPLOY.md](docs/TEST-AND-DEPLOY.md). +The operator does **not** run named eval, `just check-remote`, or the +dry-run in order to discover errors. The agent runs that entire prefix, +including the dry-run, then asks for the real switch. + Before any sentence that asks the operator to deploy or switch, agents must have already run both of these and must report the results: 1. Local Nix eval contracts that do **not** rustc on this laptop (`tests/module-eval.nix` / named flake eval). -2. `just deploy-host -- --dry-run`. +2. `just deploy-dry-run`. + +The explicit equivalent is `just deploy-host -- --dry-run` with the +target and host-local flags. `just deploy-host` has no default target. This **narrows** 2026-08-25: dry-run is required agent work. The **real -switch** stays operator-owned unless they override. Laptop cargo and -`BUILD_LOCAL` stay forbidden. `just check-remote` stays operator-owned. -Prove-the-gate leftover law stays: do not list a leftover unless the -agent cannot do it. +switch** (`just deploy`) stays operator-owned unless they override. +Agents must not run `just deploy`. `just deploy-host` with those target +flags and no `--dry-run` is that same switch. Laptop cargo and +`BUILD_LOCAL` stay forbidden. Agents may run one `just check-remote` +at a time. Prove-the-gate leftover law stays: do not list a leftover +unless the agent cannot do it. + +## 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. + +- An update that is not followed by an audit of that new lock is not done. +- An audit of a lock that was not just updated is not done, unless a significant amount of time has passed since the last paired update and audit. Do not invent a day count. Auditing a lock that was updated a few days earlier, without a new update, is not that gap. +- In this repo the audit command is `just audit-remote` (hermetic `cargo-audit`). It runs in the same pass, after `cargo update`. The full command block, including `nix flake update`, is [docs/TEST-AND-DEPLOY.md](docs/TEST-AND-DEPLOY.md) section *Update, audit, test, and switch*. Do not restate that block in chat. Point at that section. +- Use the menhera cooldown index already configured for cargo. Do not point cargo at crates.io to skip the wait. +- When the operator asks for either command, the agent runs the pair. Do not hand `cargo update` back because menhera is only on the laptop. The laptop-cargo forbid still covers tests, clippy, and rustc. It does not split this pair. + +Dual-pin [docs/COMPACTION-PIN.md](docs/COMPACTION-PIN.md). + +## Fmt runs before clippy (operator 2026-10-12) + +Cargo fmt and nixfmt finish before clippy. `just check` is `fmt`, then `clippy`, then `test`. In `checks.*.ci`, `management-ui-clippy`, `e2e-clippy`, and each wave1 `*-clippy` depend on `management-ui-fmt` and `nix-fmt-check`. Do not list clippy before fmt. Do not let the CI aggregate start clippy beside fmt. + +Dual-pin [docs/COMPACTION-PIN.md](docs/COMPACTION-PIN.md). ## Mention is in scope; remote builder and niceness (operator 2026-08-17) @@ -391,11 +423,13 @@ already exists. **After a guest incident, finish the named process (operator 2026-08-27; dry-run agent work 2026-09-02).** Do not tell the operator they need do nothing, or to wait, when leftover is `just check-remote`, then -`just deploy-host -- --dry-run`, then the real switch. A reboot does +`just deploy-dry-run`, then the real `just deploy` switch. A reboot does not cancel that slice. Agents never reboot. Keep the Eternal Terminal -window. Agents run named eval and `just deploy-host -- --dry-run` and -report those results. The real switch stays operator-owned unless they -override. `just check-remote` stays operator-owned. +window. Agents may run one `just check-remote` at a time, then named +eval and `just deploy-dry-run`, and report those results. The real +switch `just deploy` stays operator-owned unless they override. Agents +must not run `just deploy`. `just deploy-host` without `--dry-run` is +that same switch and stays the explicit form with no default target. Comprehensive residual means finish the **named leftover slices**. Do **not** invent unlocked tracks. Do not start an MX flip, a DMARC `p=reject` diff --git a/Cargo.lock b/Cargo.lock index 0a1c17e..09368f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,8 +44,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd290633c2482479f70f6d1d96ae0e9f52c6a26cd5859edd47ee1fe33fc89f26" dependencies = [ "age-core", - "base64", - "bech32", + "base64 0.22.1", + "bech32 0.11.1", "chacha20poly1305", "cipher", "cookie-factory", @@ -59,7 +59,7 @@ dependencies = [ "nom 8.0.0", "p256", "pin-project", - "rand 0.8.7", + "rand 0.8.8", "rust-embed", "scrypt", "sha2 0.10.9", @@ -75,15 +75,15 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01d4375964d1501e5f1b32aef2ead573913893ff238448d4e9fdf1522d828656" dependencies = [ - "base64", - "bech32", + "base64 0.22.1", + "bech32 0.11.1", "chacha20poly1305", "cookie-factory", "hkdf", "hpke", "io_tee", "nom 8.0.0", - "rand 0.8.7", + "rand 0.8.8", "secrecy", "sha2 0.10.9", ] @@ -154,7 +154,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1384d3fe1eecb464229fcf6eebb72306591c56bf27b373561489458a7c73027d" dependencies = [ "futures", - "thiserror 2.0.20", + "thiserror 2.0.21", "wasm-bindgen-futures", ] @@ -191,7 +191,7 @@ dependencies = [ "nom 7.1.3", "num-traits", "rusticata-macros", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", ] @@ -204,7 +204,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "synstructure", + "synstructure 0.13.2", ] [[package]] @@ -243,7 +243,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -290,9 +290,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.18.0" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -301,9 +301,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.44.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", @@ -422,10 +422,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] -name = "base64ct" -version = "1.8.3" +name = "base64" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "basic-toml" @@ -442,13 +442,19 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" +[[package]] +name = "bech32" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbd3e1070bbdf4cd88a75264e18e8a26f7cb5c6949eadf0ceb85fb159cf08f8" + [[package]] name = "bip39" version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.14.101", "serde", "unicode-normalization", ] @@ -464,12 +470,12 @@ dependencies = [ [[package]] name = "bitcoin-consensus-encoding" -version = "1.2.0" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6712f9c6fd6785b3b270884e57c441c403dc5d7e19ca45368c97c7a1de3000ec" +checksum = "9daa31138eb443d5751b207f3f64154e2bb09cd59960562ccc7a7112be38147f" dependencies = [ - "bitcoin-internals", - "hex-conservative 1.2.0", + "bitcoin-internals 0.7.0", + "hex-conservative 1.3.0", "serde", ] @@ -479,6 +485,12 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" +[[package]] +name = "bitcoin-internals" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8bea3a9f0cfece4564e37cb49a38cc245ca5184719e50d7d0dda3268722c4e2" + [[package]] name = "bitcoin-io" version = "0.1.101" @@ -495,15 +507,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" dependencies = [ "bitcoin-io", - "hex-conservative 0.2.2", + "hex-conservative 0.2.3", +] + +[[package]] +name = "bitcoin_hashes" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5304e53726dbe5f93141535e102ed97b5bf4714fbecefdda8f9fb98d7fdaff0e" +dependencies = [ + "bitcoin-consensus-encoding", + "bitcoin-internals 0.6.0", + "hex-conservative 1.3.0", "serde", ] [[package]] name = "bitflags" -version = "2.13.1" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "block-buffer" @@ -520,7 +549,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ - "hybrid-array 0.4.14", + "hybrid-array 0.4.15", ] [[package]] @@ -538,6 +567,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.1" @@ -546,9 +581,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "camino" -version = "1.2.5" +version = "1.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" [[package]] name = "cbc" @@ -561,9 +596,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.3" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" dependencies = [ "find-msvc-tools", "jobserver", @@ -573,9 +608,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" @@ -600,7 +635,7 @@ version = "0.10.2" source = "git+https://github.com/RustCrypto/stream-ciphers?rev=6b236b758a0279f64d777797514813b2cb572c8b#6b236b758a0279f64d777797514813b2cb572c8b" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -630,9 +665,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.6" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", "clap_derive", @@ -640,9 +675,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.6" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ "anstream", "anstyle", @@ -652,21 +687,21 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.4" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] name = "clap_lex" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" [[package]] name = "cmake" @@ -685,7 +720,7 @@ checksum = "a9dbbdc4b4d349732bc6690de10a9de952bd39ba6a065c586e26600b6b0b91f5" dependencies = [ "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -702,9 +737,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", @@ -712,14 +747,14 @@ dependencies = [ [[package]] name = "config" -version = "0.15.25" +version = "0.15.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b85f248a4de22d204ceabc6299d89d2c70fbd7f09fea53c06c852369652d8139" +checksum = "38e9fbc53afcec54441422f1c596cd054655b32e70e1cbac956651b7b175a404" dependencies = [ "convert_case 0.6.0", "pathdiff", "serde_core", - "toml 1.1.4+spec-1.1.0", + "toml 1.1.6+spec-1.1.0", "winnow", ] @@ -831,9 +866,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] @@ -867,7 +902,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "hybrid-array 0.4.14", + "hybrid-array 0.4.15", ] [[package]] @@ -911,6 +946,46 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +[[package]] +name = "defmt" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" +dependencies = [ + "defmt 1.1.1", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "der" version = "0.7.10" @@ -943,13 +1018,13 @@ checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" [[package]] name = "derive-where" -version = "1.6.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" +checksum = "2e2b94854e8576378ccda7c8de8a66ed8b4e8acbd2c50ec3418ea6c8aaf4b567" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -982,7 +1057,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -999,9 +1074,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "either" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "either_of" @@ -1074,6 +1149,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "faster-hex" +version = "0.10.1" +source = "git+https://github.com/nervosnetwork/faster-hex?rev=c817979830ea5be2eafe0b0c99cb83b017ae2b8f#c817979830ea5be2eafe0b0c99cb83b017ae2b8f" +dependencies = [ + "autocfg", + "defmt 0.3.100", + "heapless", + "serde", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -1107,9 +1193,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.11" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "fluent" @@ -1153,7 +1239,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" dependencies = [ "memchr", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -1173,9 +1259,9 @@ dependencies = [ [[package]] name = "fs-err" -version = "3.3.1" +version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" +checksum = "b5c95b673b8f6f7235229ae11c5642d81b04c2e64c1e2fb417bc0cf73ca45f29" dependencies = [ "autocfg", "tokio", @@ -1243,7 +1329,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -1376,9 +1462,9 @@ checksum = "17e2ac29387b1aa07a1e448f7bb4f35b500787971e965b02842b900afa5c8f6f" [[package]] name = "h2" -version = "0.4.16" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", @@ -1438,6 +1524,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -1445,31 +1540,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] -name = "heck" -version = "0.5.0" +name = "heapless" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] [[package]] -name = "hex" -version = "0.4.3" +name = "heck" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hex-conservative" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +checksum = "db3fef046dca3ca91ee1408a8c1b80ab777e80a4d308d1bf4e7adb3fcb047e08" dependencies = [ "arrayvec", ] [[package]] name = "hex-conservative" -version = "1.2.0" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35431185f361ccf3ffc58254628af5f1f5d5f28531da2e02e5d6c82bbc282a10" +checksum = "271e0d19bcb473b6675739a2b536076b24a082316cb5199ad918edce10c599e8" dependencies = [ "arrayvec", ] @@ -1574,9 +1673,9 @@ dependencies = [ [[package]] name = "hybrid-array" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ "typenum", ] @@ -1598,9 +1697,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", @@ -1620,9 +1719,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http", "hyper", @@ -1638,16 +1737,17 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64", + "base64 0.23.1", "bytes", "futures-channel", "futures-util", "http", "http-body", + "httparse", "hyper", "ipnet", "libc", @@ -1795,9 +1895,9 @@ checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.3.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -1831,9 +1931,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown", @@ -1849,18 +1949,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", - "js-sys", - "wasm-bindgen", - "web-sys", -] - [[package]] name = "instant-acme" version = "0.8.5" @@ -1869,7 +1957,7 @@ checksum = "9f05ad37c421b962354c358d347d4a6130151df9407978372d3ad7f0c8f71a64" dependencies = [ "async-trait", "aws-lc-rs", - "base64", + "base64 0.22.1", "bytes", "http", "http-body", @@ -1878,12 +1966,12 @@ dependencies = [ "hyper", "hyper-rustls", "hyper-util", - "rcgen 0.14.9", + "rcgen 0.14.10", "rustls", "rustls-pki-types", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", ] @@ -1929,9 +2017,9 @@ checksum = "4b3f7cef34251886990511df1c61443aa928499d598a9473929ab5a90a527304" [[package]] name = "ipnet" -version = "2.12.1" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "is_terminal_polyfill" @@ -1966,7 +2054,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.20", + "thiserror 2.0.21", "walkdir", "windows-link", ] @@ -2015,9 +2103,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.104" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -2060,15 +2148,15 @@ checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" [[package]] name = "lazy_static" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" [[package]] name = "leptos" -version = "0.8.20" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "705e2951f3688e0c4f66bbb7a2702282782dcee716971dbd6209c2619d272479" +checksum = "ae0b16d491eff5674a34a99bdba7cc9190c596c08dd6d99c89d01cd582f040b1" dependencies = [ "any_spawner", "cfg-if", @@ -2093,7 +2181,7 @@ dependencies = [ "server_fn", "slotmap", "tachys", - "thiserror 2.0.20", + "thiserror 2.0.21", "throw_error", "typed-builder", "typed-builder-macro", @@ -2112,7 +2200,7 @@ dependencies = [ "config", "regex", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", "typed-builder", ] @@ -2151,9 +2239,9 @@ dependencies = [ [[package]] name = "leptos_macro" -version = "0.8.17" +version = "0.8.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de6e8da9d4f1a7b74b447b317d590ebabb38709f588f7ee20564b773ccbcce" +checksum = "4cfefedc5b4bd150b2669b9d3169b5795008b1cca8147ef1a6591eda0d57c94c" dependencies = [ "attribute-derive", "cfg-if", @@ -2163,7 +2251,6 @@ dependencies = [ "itertools", "leptos_hot_reload", "prettyplease", - "proc-macro-error2", "proc-macro2", "quote", "rstml", @@ -2175,12 +2262,12 @@ dependencies = [ [[package]] name = "leptos_server" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da974775c5ccbb6bd64be7f53f75e8321542e28f21563a416574dbe4d5447eae" +checksum = "202b2d1261c22d886f307934760a6bb566444f68f39b24f6f10defdc23633049" dependencies = [ "any_spawner", - "base64", + "base64 0.22.1", "codee", "futures", "hydration_context", @@ -2195,9 +2282,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.189" +version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" [[package]] name = "litemap" @@ -2216,15 +2303,15 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lru-slab" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" [[package]] name = "manyhow" @@ -2294,9 +2381,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "mio" -version = "1.2.2" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "wasi", @@ -2342,26 +2429,27 @@ dependencies = [ [[package]] name = "nostr" -version = "0.44.8" +version = "0.45.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ff7b77ef428b40aa2834a6acbae38a0e104c98b306208ca4b87a420d579a4b" +checksum = "38ca5c25a6df8d4d78fdf39b42792438f6ce22b4809ccbfdb27582ed9ca45407" dependencies = [ - "base64", - "bech32", + "base64 0.22.1", + "bech32 0.12.0", "bip39", - "bitcoin_hashes", + "bitcoin_hashes 1.2.0", "cbc", "chacha20 0.9.1", "chacha20poly1305", - "getrandom 0.2.17", - "hex", - "instant", - "scrypt", + "faster-hex", + "opaquerr", + "rand 0.10.3", "secp256k1", "serde", "serde_json", "unicode-normalization", + "universal-time", "url", + "zeroize", ] [[package]] @@ -2414,7 +2502,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed0423ff9973dea4d6bd075934fdda86ebb8c05bdf9d6b0507067d4a1226371d" dependencies = [ "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -2444,6 +2532,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "opaquerr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f933a4265d5cdad61d19bbdfc972ea5726d56cd8d3d57b8f2d3c365dd42bee9" + [[package]] name = "openssl-probe" version = "0.2.1" @@ -2495,17 +2589,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "password-hash" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" -dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "paste" version = "1.0.15" @@ -2534,7 +2617,17 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" +dependencies = [ + "base64 0.23.1", "serde_core", ] @@ -2654,9 +2747,9 @@ dependencies = [ [[package]] name = "proc-macro-error-attr3" -version = "3.1.0" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0084e6206a967a2dad822180626b2f6b07a3b379325e8f1ec0438e33a469ba7" +checksum = "9e564d14133360e1ae169ffde5da25881b5fa47261665b8e5713c212c27799da" dependencies = [ "proc-macro2", "quote", @@ -2671,19 +2764,18 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.119", ] [[package]] name = "proc-macro-error3" -version = "3.1.0" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cf066225f2373bc711684792b69bdeac0356019b007e721090c24d92d5d5a50" +checksum = "8f0d4471b3436c22106b21913b1dda531558918ae9b7ec55d58aa84b43552233" dependencies = [ "proc-macro-error-attr3", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -2721,9 +2813,9 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.11" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" dependencies = [ "bytes", "cfg_aliases", @@ -2734,7 +2826,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", "web-time", @@ -2742,22 +2834,22 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.17" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ "aws-lc-rs", "bytes", "getrandom 0.4.3", "lru-slab", - "rand 0.10.2", + "rand 0.10.3", "rand_pcg", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.20", + "thiserror 2.0.21", "tinyvec", "tracing", "web-time", @@ -2765,9 +2857,9 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" dependencies = [ "cfg_aliases", "libc", @@ -2816,9 +2908,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha", @@ -2827,9 +2919,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20 0.10.2", "getrandom 0.4.3", @@ -2876,7 +2968,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" dependencies = [ - "pem", + "pem 3.0.6", "ring", "rustls-pki-types", "time", @@ -2885,12 +2977,12 @@ dependencies = [ [[package]] name = "rcgen" -version = "0.14.9" +version = "0.14.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" +checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" dependencies = [ "aws-lc-rs", - "pem", + "pem 4.0.0", "rustls-pki-types", "time", "x509-parser", @@ -2899,9 +2991,9 @@ dependencies = [ [[package]] name = "reactive_graph" -version = "0.2.14" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00c5a025366836190c7030e883cc2bcd9e384ff555336e3c7954741ca411b177" +checksum = "79f11fac67625645add76313dab8dae510fca0654d96ef98fd8f961a7d2e22c1" dependencies = [ "any_spawner", "async-lock", @@ -2917,15 +3009,15 @@ dependencies = [ "send_wrapper", "serde", "slotmap", - "thiserror 2.0.20", + "thiserror 2.0.21", "web-sys", ] [[package]] name = "reactive_stores" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c30fd35b7d299c591293bb69fed47a703eb2703b1cff0493e78b16ed007e5382" +checksum = "a29e469b556f25486678e9f5f65fa21a0351d98affde9a7eea9fee286bf8ebd6" dependencies = [ "guardian", "indexmap", @@ -2940,12 +3032,11 @@ dependencies = [ [[package]] name = "reactive_stores_macro" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68072edd607edd30b9ebf57d984ba45d8ab8809e598d0f6046278373fb76a5a0" +checksum = "8d11f1b82859d94577892b8037c35f8a4f066cbb3864583e22e68def0af49a88" dependencies = [ "convert_case 0.11.0", - "proc-macro-error2", "proc-macro2", "quote", "syn 2.0.119", @@ -2957,7 +3048,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.2", ] [[package]] @@ -2995,7 +3086,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -3066,17 +3157,17 @@ dependencies = [ "quote", "syn 2.0.119", "syn_derive", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] name = "rtoolbox" -version = "0.0.5" +version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" +checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3140,9 +3231,8 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +version = "0.23.45" +source = "git+https://github.com/rustls/rustls?rev=2976d90fd1c2db6b518700dd101b714069cfcb17#2976d90fd1c2db6b518700dd101b714069cfcb17" dependencies = [ "aws-lc-rs", "log", @@ -3178,9 +3268,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" dependencies = [ "core-foundation", "core-foundation-sys", @@ -3199,15 +3289,15 @@ dependencies = [ [[package]] name = "rustls-platform-verifier-android" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" -version = "0.103.14" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -3266,7 +3356,6 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" dependencies = [ - "password-hash", "pbkdf2", "salsa20", "sha2 0.10.9", @@ -3287,13 +3376,13 @@ dependencies = [ [[package]] name = "secp256k1" -version = "0.29.1" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" dependencies = [ - "rand 0.8.7", + "bitcoin_hashes 0.14.101", + "rand 0.8.8", "secp256k1-sys", - "serde", ] [[package]] @@ -3320,7 +3409,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.13.2", "core-foundation", "core-foundation-sys", "libc", @@ -3385,7 +3474,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -3420,7 +3509,7 @@ checksum = "f3faaf9e727533a19351a43cc5a8de957372163c7d35cc48c90b75cdda13c352" dependencies = [ "percent-encoding", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -3450,7 +3539,7 @@ version = "0.8.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be8559dd05af1b5b7e363a150616589d5a88af5187273f7f331ba0dae8922812" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "const-str", "const_format", @@ -3468,7 +3557,7 @@ dependencies = [ "serde_json", "serde_qs", "server_fn_macro_default", - "thiserror 2.0.20", + "thiserror 2.0.21", "throw_error", "url", "wasm-bindgen", @@ -3480,9 +3569,9 @@ dependencies = [ [[package]] name = "server_fn_macro" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1295b54815397d30d986b63f93cfd515fa86d5e528e0bb589ce9d530502f9e0f" +checksum = "b4ef7e82840fddcd17e15a62640b6d7d3b3a905e65b07854a903393db2aced76" dependencies = [ "const_format", "convert_case 0.11.0", @@ -3521,7 +3610,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -3593,9 +3682,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" [[package]] name = "socket2" @@ -3646,6 +3735,7 @@ name = "surmount-dns-zone" version = "0.1.0" dependencies = [ "anyhow", + "surmount-acme-namecheap", ] [[package]] @@ -3690,7 +3780,7 @@ dependencies = [ "axum", "axum-h3", "axum-server", - "base64", + "base64 0.22.1", "bytes", "getrandom 0.2.17", "h3", @@ -3796,9 +3886,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.3" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -3837,11 +3927,22 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "tachys" -version = "0.2.18" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92ba81187437cc5df4281f2326a2e13cc81e8f96448292d1112388e2025ca66" +checksum = "e47e2220bb9d2a3be976a22a0fb7685a0b84499bb11ea5cb3bbc82907675ac68" dependencies = [ "any_spawner", "async-trait", @@ -3880,11 +3981,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.20", + "thiserror-impl 2.0.21", ] [[package]] @@ -3900,13 +4001,13 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -3970,24 +4071,15 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.1" +version = "1.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" dependencies = [ "bytes", "libc", @@ -4007,14 +4099,14 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls", "tokio", @@ -4045,14 +4137,16 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.4+spec-1.1.0" +version = "1.1.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" dependencies = [ + "indexmap", "serde_core", "serde_spanned", "toml_datetime", "toml_parser", + "toml_writer", "winnow", ] @@ -4074,6 +4168,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "tower" version = "0.5.3" @@ -4097,7 +4197,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.13.2", "bytes", "futures-util", "http", @@ -4252,9 +4352,9 @@ checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "unicode-normalization" @@ -4287,6 +4387,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "universal-time" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a939edecc3c5a7b83c02e5f6b3c31d2bc69eabcc9a87ab12c6d37ee6dbc856" + [[package]] name = "untrusted" version = "0.7.1" @@ -4326,9 +4432,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.24.1" +version = "1.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -4374,9 +4480,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasm-bindgen" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -4387,19 +4493,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.77" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4407,22 +4514,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] @@ -4464,9 +4571,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.104" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -4524,15 +4631,6 @@ dependencies = [ "windows-targets", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -4647,15 +4745,15 @@ dependencies = [ "nom 7.1.3", "oid-registry", "rusticata-macros", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", ] [[package]] name = "xxhash-rust" -version = "0.8.18" +version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" +checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7" [[package]] name = "yansi" @@ -4695,30 +4793,30 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", - "synstructure", + "syn 3.0.6", + "synstructure 0.14.0", ] [[package]] name = "zerocopy" -version = "0.8.56" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.56" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" dependencies = [ "proc-macro2", "quote", @@ -4736,14 +4834,14 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", - "synstructure", + "syn 3.0.6", + "synstructure 0.14.0", ] [[package]] @@ -4779,9 +4877,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.7" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "serde", "yoke", @@ -4791,13 +4889,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.4" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 0c5d584..043e669 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,17 +61,23 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus # not change the edge ServerConfig protocol list. # prefer-post-quantum: aws-lc-rs default provider offers hybrid X25519MLKEM768 # first (D1 first-class aim). Still not live-host hybrid negotiation proof. -rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs", "prefer-post-quantum", "std", "logging"] } +# 0.23.45 is the RUSTSEC-2026-0285 floor (TLS 1.3 handshake messages across +# encryption-level boundaries). menhera 10d/7d still stop at 0.23.44; 3d +# already lists 0.23.45 (published 2026-09-14). Git [patch.crates-io] below, +# same class as chacha20, until 10d lists 0.23.45. +# https://rustsec.org/advisories/RUSTSEC-2026-0285 (accessed: 2026-09-21). +rustls = { version = "0.23.45", default-features = false, features = ["aws-lc-rs", "prefer-post-quantum", "std", "logging"] } rustls-pki-types = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } anyhow = "1" # Leptos SSR admin shell (ssr only; no csr/hydrate dual build). Current stable -# 0.8 is 0.8.20. That graph still pulls unmaintained paste (RUSTSEC-2024-0436) -# and proc-macro-error2 (RUSTSEC-2026-0173 via leptos_macro / rstml). 0.9 is -# beta; not a product bump this turn. -# https://rustsec.org/advisories/RUSTSEC-2024-0436 (accessed: 2026-08-27). -# https://rustsec.org/advisories/RUSTSEC-2026-0173 (accessed: 2026-08-27). +# 0.8 is 0.8.20. Upstream still names unmaintained paste (RUSTSEC-2024-0436) +# and proc-macro-error2 (RUSTSEC-2026-0173). Both are RustSec INFO records, +# not CVEs. Ignored on 2026-09-26. Do not vendor Leptos to clear them. +# 0.9 is beta, not a product bump this turn. +# https://rustsec.org/advisories/RUSTSEC-2024-0436 (accessed: 2026-09-26). +# https://rustsec.org/advisories/RUSTSEC-2026-0173 (accessed: 2026-09-26). leptos = { version = "0.8", default-features = false, features = ["ssr"] } # Self-signed PEMs for hermetic ACME mock issuer + unit tests. Pin kept for lock stability. rcgen = "=0.13.2" @@ -86,31 +92,41 @@ x509-parser = "0.18" # O_NOFOLLOW for ACME secret/public PEM temp opens (Linux). libc = "0.2" # Nostr NIP-98 verify (rust-nostr). 0.44.7+ for RUSTSEC-2026-0216..0230. -# Locked 0.44.8. nostr 0.45.3 dropped instant (RUSTSEC-2024-0384) but removed -# TagKind / TagStandard / JsonUtil / EventBuilder::sign_with_keys and gates -# Keys::generate behind os-rng (rand 0.10 -> chacha20 0.10). That is a NIP-98 -# rewrite, not this unmaintained lockfile turn. +# Lock is nostr 0.45.5 (pin 0.45.3). 0.45 drops instant (RUSTSEC-2024-0384). # Keep getrandom 0.2 workspace pin so leptos wasm-bindgen does not pull 0.1. # menhera-cooldown has yanked chacha20 0.10.x; cargo update then cannot keep # quinn-proto 0.11.16+ (needs rand 0.10 -> chacha20 0.10). Do not bypass the # private index. See docs/research/menhera-chacha20-yank-2026-08-27.md. # https://rustsec.org/advisories/RUSTSEC-2024-0384 (accessed: 2026-08-27). -nostr = { version = "0.44.7", default-features = false, features = ["std", "nip98"] } +# 0.45 drops unmaintained instant (RUSTSEC-2024-0384). os-rng keeps +# Keys::generate without pulling a yanked chacha20. +# https://rustsec.org/advisories/RUSTSEC-2024-0384 (accessed: 2026-09-26). +nostr = { version = "0.45.3", default-features = false, features = ["std", "nip98", "os-rng"] } hmac = "0.12" sha2 = "0.10" base64 = "0.22" # CSRF tokens + CSP nonces (already in lock via other crates; direct dep for auth). getrandom = "0.2" # rage CLI is built on this library (age-encryption.org). Used by just rekey. -# 0.12.1+ pulls i18n-embed-fl 0.10 (proc-macro-error3), dropping the age path -# of RUSTSEC-2026-0173. Identity::generate / to_public / to_string stay. -# https://rustsec.org/advisories/RUSTSEC-2026-0173 (accessed: 2026-08-27). +# 0.12.1+ pulls i18n-embed-fl 0.10.1, which uses proc-macro-error3. +# RUSTSEC-2026-0173 is proc-macro-error2, not age. Identity::generate / +# to_public / to_string stay. age = { version = "0.12", default-features = false } # menhera has yanked chacha20 0.10.0/0.10.1 (UB in SSE2) and has not listed # 0.10.2 yet. Patch from the public RustCrypto git tag, not crates-io. # crates-io is still replaced by menhera for everything else. # Source name is still crates-io even with replace-with; cargo said so. +# +# rustls 0.23.45 is not on menhera 10d/7d yet (3d has it). Do not fetch +# crates.io to skip the cooldown. Public git tag v/0.23.45, rev below. +# When menhera 10d lists 0.23.45, drop this rustls line and +# `cargo update -p rustls --precise 0.23.45`. [patch.crates-io] chacha20 = { git = "https://github.com/RustCrypto/stream-ciphers", rev = "6b236b758a0279f64d777797514813b2cb572c8b" } +rustls = { git = "https://github.com/rustls/rustls", rev = "2976d90fd1c2db6b518700dd101b714069cfcb17" } +# 0.10.1 is inside the menhera 10-day window (published 2026-09-23). +# When menhera 10d lists 0.10.1, drop this line and use the registry crate. +# Do not ignore RUSTSEC-2026-0306. +faster-hex = { git = "https://github.com/nervosnetwork/faster-hex", rev = "c817979830ea5be2eafe0b0c99cb83b017ae2b8f" } diff --git a/RESIDUAL.md b/RESIDUAL.md index 97e3ee0..2aabf59 100644 --- a/RESIDUAL.md +++ b/RESIDUAL.md @@ -20,8 +20,8 @@ peer into `ConnectInfo` so HTTP/3 bans and rate-limit keys are per-client absent). NWC is not that 500; it is the `/mail` wallet store after login. Live switch still operator-owned. Flake input `splora` is `github:SurmountSystems/splora` on the `surmount` branch, locked rev -`9481e4cb87273aa99b0357be48503765beadb919` (previous lock -`343727487988ed0a764674ff21c0750465b9a3e8`). Imported +`be3603dbd8e6ef0c24e37fe07beaf8067bfa2d0b` (previous lock +`22d6dcf7f76c1cacc23220d80203099018cde3aa`). Imported `nixosModules.splora` instance options now include `cookieFile`, `daemonRpcAddr`, `jsonrpcImport`, `daemonDir` (null is remote JSON-RPC), `publicHealth`, db cache default 24, and `httpSocketFile` default @@ -179,8 +179,8 @@ note in place of that path.) Nix **ssh-ng** builder **live on the host** (MemoryMax generation switched). Lake, if run at all, is **outside this flake**. Onion-Location + Alt-Svc on the Axum edge; live Arti unit `surmount-arti-hidden-service` **active**; -same v3 hostname for apex, www, services, extra static Hosts, and -MTA-STS (`/_o/{host}` for non-console); Tor Browser verify +per-site v3 onions for apex, www, services, extra static Hosts, and +MTA-STS (mail unmapped); Tor Browser verify **BLOCKED**; operator HS identity backup still open; **B3 not fully closed**; live VW unit still leftover). Prior 2026-08-14 (create-mailbox portal **shipped** on `/mail`; @@ -278,7 +278,7 @@ prior `/home/hunter/.agents/reports/impl-lake-memory-cap-2026-08-18.md`; ticket thread `/home/hunter/.agents/reports/ticket-261-thread-lessons-2026-08-18.md`. **Host package (btop, 2026-08-17; ET 2026-08-28):** `modules/btop.nix` adds `pkgs.btop` when `surmount.enable`; laptop `just btop` uses Eternal -Terminal (`et -c btop`, live tty required, nested guest runs local +Terminal (`et -e -c btop`, session stays as a shell, live tty required, nested guest runs local btop). **Not** on the live box until a `deploy-host` switch. Hermetic `just test-btop-host`. Report: `.agents/reports/impl-btop-ci-deploy-2026-08-17.md` (CI/deploy leftover). @@ -432,8 +432,9 @@ self-signed PEMs are not host ownership or public cutover. inventory (empty + `source: unavailable`; no fake `admin@`). Domains from config labeled as inventory, not Stalwart directory. Live Stalwart probe on overview/mail. Hermetic tests: SSR markers, DOGE palette, nav, no skeleton, - accounts honesty, `/health`. No NPM. Crane: nixpkgs `rustPackages_1_95` - (host channel nixos-26.05). + accounts honesty, `/health`. No NPM. Crane rustc is 1.99.0 from + `nixpkgs-rust`, not host-channel `rustPackages_1_95` (nixos-26.05 + `pkgs.rustc` is still 1.95.0). **DOGE theme (2026-08-01):** pure 3-bit RGB eight-color palette only (`data-theme="doge"`, `color-scheme: only dark`); no grays / no light media queries. Spec SurmountSystems/specs `0001_DOGE.md` v1.0.0. @@ -499,8 +500,8 @@ self-signed PEMs are not host ownership or public cutover. - **Onion-Location + Alt-Svc (2026-08-17; every public Host 2026-08-20):** `security_headers_middleware` emits both on mapped HTTPS 2xx/3xx (apex, `www.{apex}`, services, extra static Hosts, `mta-sts.{apex}`; same v3). - Non-console Onion-Location uses `/_o/{clearnet-host}{path}`; onion-side - rewrite serves that Host. `http://{onion}/` stays the services console. + Onion-Location is `http://{that-host-onion}{path}`; onion-side Host + rewrite serves that site. Mail stays unmapped. Mail unmapped. Mapping loaded once at process start from the existing onion surface plus extra static Hosts. No hot-reload; restart the unit after hostname file, map file, static vhost map, or env change. Optional @@ -510,7 +511,8 @@ self-signed PEMs are not host ownership or public cutover. `GET /api/v1/system` dumps `onion_discovery` (admin-gated when Nostr on). Local Arti cleartext bind does not emit discovery headers. Tor Browser purple pill still residual. Live extra-vhost and MTA-STS dual headers - proven 2026-08-20 after `deploy-host` (same v3, `/_o/{host}`). Vaultwarden + proven 2026-08-20 after `deploy-host` (then a shared v3). Tree 2026-09-11 + is per-site onions. Vaultwarden unit and `/vault` proxy still off (no Domain A vaultwarden-admin, no host-local enable fragment). - **Live host (2026-08-17, private host-local only):** unit @@ -602,7 +604,7 @@ self-signed PEMs are not host ownership or public cutover. | **nginx module delete from tree** | Dual-run escape still ships (`web.enable = true`). Unused-detection checklist: [docs/OPS.md](docs/OPS.md). Delete module file only after operators no longer need it **and** explicit operator OK | | **:80 redirect listener** | **Wired** in tree (flag + listen + allowlist; dual-run mutex; redirect-only; **same-host** HTTPS upgrade including apex/www so public users are not sent to the operator console). **Operator assumption (2026-08-02):** production port 80 is **free** for that bind. Host public proof still residual. **ACME HTTP-01 on product :80 parked** (Q-EDGE; free :80 does not invent ACME) | | **requireDeployMaterial (B2)** | Module shipped; enable on host only after PEMs (and later Arti paths) exist so activation fails loud. Sample host leaves it commented. | -| **Arti live HS (B3)** | **Do not claim B3 fully closed.** Live Tor Browser / onion-fetch verify still residual (unit active != published). Operator offline backup of HS identity still open. Q-ARTI-2/3 still open. **Package currency shipped:** Surmount-owned Arti **2.5.1** source + `onion-service-service` (`artiOnionService`; not nixpkgs 1.4.2 lag). **Tree cleartext local backend for https+Arti auto-path shipped** (loopback API + onion target). Local temp-key publish (optional `just e2e` row) != operator backup. Do not invent Q-ARTI-2/3 answers. **Onion status product-real (2026-08-10):** when `artiHiddenService.enable`, management-ui derives hostname file + `SURMOUNT_ONION_HS_STATE_DIR` from `onionServiceStateDir` (walk nested hostname); structured status `configured` / `hostname_missing` / `not_provisioned` on SSR + `GET /api/v1/system`; residual names `surmount.artiHiddenService` (not demo env copy). Lab `SURMOUNT_ONION_URL` override only. **Onion-Location + Alt-Svc shipped (2026-08-17; every public Host 2026-08-20):** `security_headers_middleware` on mapped HTTPS 2xx/3xx; mapping loaded once at process start (restart after hostname/env/map change; no hot-reload). Same v3 for apex, www, services, extra static Hosts, and `mta-sts.{apex}`. Non-console Onion-Location uses `/_o/{clearnet-host}{path}`. Mail unmapped. Optional env: `SURMOUNT_ONION_LOCATION_ENABLED`, `SURMOUNT_ONION_ALT_SVC_ENABLED`, `SURMOUNT_ONION_LOCATION_DISABLED_HOSTS`, `SURMOUNT_ONION_ALT_SVC_DISABLED_HOSTS`, `SURMOUNT_ONION_MAP_FILE`. Dump on `GET /api/v1/system` `onion_discovery` (admin-gated when Nostr on). **Live host (2026-08-17, private host-local; extra/MTA-STS dual headers 2026-08-20):** 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 for apex, www, services (2026-08-17) and extra/MTA-STS `/_o/{host}` (2026-08-20). Extra live wiring (host-local only): `HOME=/var/lib/surmount/arti`; `surmount-ui` in group `surmount-arti` to read hostname; keystore 0700. Public module still does not set HOME / `port_info`. Arti does not write `hostname`; host wrote it from `arti hss onion-address`. Tor Browser purple pill **BLOCKED**. Do **not** log full onion addresses in failure tails | +| **Arti live HS (B3)** | **Do not claim B3 fully closed.** Live Tor Browser / onion-fetch verify still residual (unit active != published). Operator offline backup of HS identity still open. Q-ARTI-2/3 still open. **Package currency shipped:** Surmount-owned Arti **2.5.1** source + `onion-service-service` (`artiOnionService`; not nixpkgs 1.4.2 lag). **Tree cleartext local backend for https+Arti auto-path shipped** (loopback API + onion target). Local temp-key publish (optional `just e2e` row) != operator backup. Do not invent Q-ARTI-2/3 answers. **Onion status product-real (2026-08-10):** when `artiHiddenService.enable`, management-ui derives hostname file + `SURMOUNT_ONION_HS_STATE_DIR` from `onionServiceStateDir` (walk nested hostname); structured status `configured` / `hostname_missing` / `not_provisioned` on SSR + `GET /api/v1/system`; residual names `surmount.artiHiddenService` (not demo env copy). Lab `SURMOUNT_ONION_URL` override only. **Onion-Location + Alt-Svc shipped (2026-08-17; every public Host 2026-08-20):** `security_headers_middleware` on mapped HTTPS 2xx/3xx; mapping loaded once at process start (restart after hostname/env/map change; no hot-reload). One v3 per public HTTP Host (apex, www, services, extra static Hosts, and `mta-sts.{apex}`). Onion-Location is `http://{that-host-onion}{path}` (one v3 per public HTTP Host). Mail unmapped. Optional env: `SURMOUNT_ONION_LOCATION_ENABLED`, `SURMOUNT_ONION_ALT_SVC_ENABLED`, `SURMOUNT_ONION_LOCATION_DISABLED_HOSTS`, `SURMOUNT_ONION_ALT_SVC_DISABLED_HOSTS`, `SURMOUNT_ONION_MAP_FILE`. Dump on `GET /api/v1/system` `onion_discovery` (admin-gated when Nostr on). **Live host (2026-08-17, private host-local; extra/MTA-STS dual headers 2026-08-20):** 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 for apex, www, services (2026-08-17) and extra/MTA-STS headers (2026-08-20; per-site tree 2026-09-11). Extra live wiring (host-local only): `HOME=/var/lib/surmount/arti`; `surmount-ui` in group `surmount-arti` to read hostname; keystore 0700. Public module still does not set HOME / `port_info`. Arti does not write `hostname`; host wrote it from `arti hss onion-address`. Tor Browser purple pill **BLOCKED**. Do **not** log full onion addresses in failure tails | | **Nostr production auth (B4)** | **Live gated (2026-08-12):** public services `authMode=nostr`; anonymous `/` **307** `/login` (login HTML, not dashboard); anonymous `/api/v1/domains` **401**; `/health` **200**. Apex/www public page is packaged **SurmountSystems/site** (live 2026-08-19 GitHub tip `1c84696`; COMING SOON leftover closed). HTTP/2 `:authority` fallback already shipped. Domain B `session-secret` (EnvironmentFile) + `nostr-allowlist` installed (0600, `surmount-ui`; values never in git). Private host-local: `authMode=nostr` + paths + `publicBaseUrl` + ACME-off + durable TLS. Footgun guard live (public+off refuse). Report: `.agents/reports/impl-auth-live-b4-switch.md`. **Q-AUTH-1 still open** (durable session store, key-loss, first-operator bootstrap UX). Allowlisted operator browser login (NIP-07 / NIP-98) is operator-side proof, not a remaining host install. nsec never on server. | | **Merciless ban product (B5)** | **First path + helper scaffold shipped:** Rust decide + memory/file; optional kernel firewall sync via Unix-socket helper oneshot or unsupported direct exec; Nix `accessControl` + sets; `nftHelper` requires `backend=nft` (fail-closed); UI no CAP_NET_ADMIN; EEXIST/already-present treated as apply ok for crash-window re-signal; **`remove_ban` / lab unban shipped** (hermetic delete-element + absent=ok). **Auth-failure BanCandidate stub shipped** + surface audit (404/501 do not auto-ban); bad NIP-98 / session exchange may call `signal_unauthorized` once (missing cookie does not). **Still residual:** Q-ACL-1..6 (which surfaces call the hook); live host helper + sets drop (`just e2e-host`); fail2ban SSH transitional (**B7** after B5 solid). **Not** live host drop / full unauthorized auto-ban product | | **Mail earn-trust DNS/TLS (B6)** | **Primary `surmount.systems` live (2026-08-11/12, re-checked 2026-08-20):** Namecheap NS; operator clicked hosted **DNSSEC Status ON**; **live 2026-08-20** still no parent DS and no apex DNSKEY (Insecure, not SERVFAIL; waiting for Namecheap to publish); dual DKIM TXT `stalwart` + `stalwart-rsa`; DMARC **`p=quarantine`**; TLS-RPT; MTA-STS DNS + HTTPS testing; CAA Let's Encrypt. Domain B dual-sign PEMs on host. **Live DkimSignature (Track A, primary Domain):** both selectors stage active (sign-ready; **not** outbound signed mail). **Primary public MX flipped 2026-08-20** (dual-sign + PTR already green; operator Custom MX click; laptop `--live set-mx`). EmailType **MX**. Public and auth NS MX `10 mail.surmount.systems`. SPF `v=spf1 a:mail.surmount.systems -all`. DMARC **`p=quarantine`**. Laptop `issue-le-prod/namecheap.env` **`list` worked** (stored ClientIp accepted; file not rewritten). In-tree `list` prints **EmailType**. Leftover eforward MX gone. **`--live set-mx` still fails closed while EmailType is FWD** (not a public MX flip while Email Forwarding is on; extra domains; no EmailType-set API). `domain-audit` **FAIL**s registrar eforward on claimed mailbox domains. Local Stalwart RCPT for `hunter@surmount.systems` is **250**. **Public MX still eforward/FWD** on `baxterartworks.com` until asked. **MTA-STS HTTPS live (testing, primary only):** leaf covers `mta-sts.surmount.systems`; stay testing; do not enforce. **Extra mailbox domains (operator 2026-08-20; not primary-only):** this host already sends/receives locally for `cryptoquick.com` and `baxterartworks.com`. They do **not** entirely lack dual DKIM / TLS-RPT / CAA. **`cryptoquick.com`:** child (`dig @1.1.1.1 +cd`, SOA serial 1787271066) already has dual DKIM, TLS-RPT, CAA, SPF `v=spf1 a:mail.cryptoquick.com -all`, MX `10 mail.cryptoquick.com`, DMARC **`p=quarantine`** (leave as-is). Validating resolvers **SERVFAIL** on leftover parent **DS** key tag 2368, algorithm 13, digest type 1, **no child DNSKEY**. Operator UI this measure: Advanced DNS **DNSSEC Status off**. Do **not** re-add 2368. Same hosted **ON** after that DS is gone. Extra MTA-STS wait. Do **not** add apex/www to the production leaf. **`baxterartworks.com`:** EmailType still **FWD**; public MX eforward1-5 (do **not** claim MX flipped). getHosts (11 records) has dual DKIM (same `p=` as primary), TLS-RPT, CAA Let's Encrypt, SPF `v=spf1 a:mail.surmount.systems include:spf.efwd.registrar-servers.com -all`. `_dmarc` **intended** **`p=quarantine`**. Do **not** restore getHosts `_dmarc` to `p=none`. Public recursive `1.1.1.1` 2026-08-20: DKIM / `_dmarc` / TLS-RPT **NXDOMAIN** (may stay NXDOMAIN while EmailType is FWD even when getHosts has the TXT), CAA empty NOERROR, SPF still eforward-only `~all`, SOA serial **1787245654** not bumped. Leftover `_acme-challenge` TXT still on getHosts. Extra MTA-STS not published. `--live set-mx` **fails closed** while FWD. Extra-zone API **worked** this slice (not still Invalid request IP). Published Namecheap API has **no** DNSSEC/DS commands. Do **not** whitelist the VPS. **Still residual:** leftover cryptoquick parent DS (required for validating mailers to see those records); Baxter Custom MX only if the operator asks; public NS republish/lag for Baxter getHosts vs served zone; extra MTA-STS wait; `register-dkim --live --domain` as a host user who can read the token; rDNS/PTR; mail-tester. No reboot. Checklist: [docs/DNS.md](docs/DNS.md). | @@ -994,7 +996,7 @@ a host **stop** at docs, tree wiring, and local e2e. Ladder detail: - [x] Version currency audit vs network latest (Stalwart family at tip; crane rustc 1.88 vs stable 1.97.1 noted in pre-26.05 audit; living - crane is 1.95 via `rustPackages_1_95`, see + crane is 1.99.0 from `nixpkgs-rust`, not host `rustPackages_1_95`, see [docs/COMPACTION-PIN.md](docs/COMPACTION-PIN.md)). Snapshot: [docs/research/version-audit.md](docs/research/version-audit.md) - [x] **Arti packaging currency:** Surmount-owned **2.5.1** source build diff --git a/TECH.md b/TECH.md new file mode 100644 index 0000000..37e22f8 --- /dev/null +++ b/TECH.md @@ -0,0 +1,187 @@ +# Nested L2 token tracking + +Measured nested L2 tokens are session usage counts, not included SuperGrok period limits, not SuperGrok dollar credits, and not console team prepaid / console API credits. SuperGrok is a paid product. Estimates are estimates, not billing truth. + +## Dependency tree + +- L1 main session + - L2 Complete wrap and issue + - L3 specialists (when spawned) + - L2 DNS zone live setHosts + - L3 specialists (when spawned) + - L2 Diagnose Nostr login 500 + - L3 specialists (when spawned) + - L2 Document Splora UI forward + - L3 specialists (when spawned) + - L2 Finish Splora path proxy + - L3 specialists (when spawned) + - L2 Finish issue and report + - L3 specialists (when spawned) + - L2 Finish per-site onion code + - L3 specialists (when spawned) + - L2 Finish per-site onions + - L3 specialists (when spawned) + - L2 Fix Splora doc contradiction + - L3 specialists (when spawned) + - L2 Fix nft helper ETXTBSY + - L3 specialists (when spawned) + - L2 Fix publicHttpHosts eval fail + - L3 specialists (when spawned) + - L2 Forward Splora UI paths + - L3 specialists (when spawned) + - L2 Green just check-remote + - L3 specialists (when spawned) + - L2 Honor hook and issue cert + - L3 specialists (when spawned) + - L2 Implement Splora portal Host + - L3 specialists (when spawned) + - L2 Implement domains inventory union + - L3 specialists (when spawned) + - L2 Implement per-site v3 onions + - L3 specialists (when spawned) + - L2 Inventory onion Hosts + - L3 specialists (when spawned) + - L2 Issue cert from laptop + - L3 specialists (when spawned) + - L2 List Namecheap-updated zones + - L3 specialists (when spawned) + - L2 Map TLS cert names + - L3 specialists (when spawned) + - L2 Map UI domains page + - L3 specialists (when spawned) + - L2 Map mempool URL model + - L3 specialists (when spawned) + - L2 Map remote CI check scope + - L3 specialists (when spawned) + - L2 Map splora proxy paths + - L3 specialists (when spawned) + - L2 Named flake eval onion tests + - L3 specialists (when spawned) + - L2 Per-site onion implementer + - L3 specialists (when spawned) + - L2 Per-site onion services + - L3 specialists (when spawned) + - L2 Per-site onion services + - L3 specialists (when spawned) + - L2 Pin 20-name leaf docs + - L3 specialists (when spawned) + - L2 Restore exophiles cert names + - L3 specialists (when spawned) + - L2 Restore exophiles on cert + - L3 specialists (when spawned) + - L2 Review Splora portal Host + - L3 specialists (when spawned) + - L2 Run laptop-renew-cert now + - L3 specialists (when spawned) + - L2 Splora hosts and docs + - L3 specialists (when spawned) + - L2 Splora path proxy + - L3 specialists (when spawned) + - L2 Splora path proxy forward + - L3 specialists (when spawned) + - L2 Splora router and tests + - L3 specialists (when spawned) + - L2 Sync stale splora module file + - L3 specialists (when spawned) + - L2 Union domains inventory + - L3 specialists (when spawned) + - L2 Update living leaf docs + - L3 specialists (when spawned) + - L2 Wait for cert issue + - L3 specialists (when spawned) + - L2 Write L3 onion report file + - L3 specialists (when spawned) + - L2 Write per-site onion report + - L3 specialists (when spawned) + - L2 check-remote path proxy + - L3 specialists (when spawned) + - L2 dns-zone live Namecheap + - L3 specialists (when spawned) + - L2 goal plan writer + - L3 specialists (when spawned) + - L2 goal plan writer + - L3 specialists (when spawned) + - L2 goal plan writer + - L3 specialists (when spawned) + - L2 goal plan writer + - L3 specialists (when spawned) + - L2 goal plan writer + - L3 specialists (when spawned) + - L2 goal plan writer + - L3 specialists (when spawned) + - L2 goal plan writer + - L3 specialists (when spawned) + - L2 goal plan writer + - L3 specialists (when spawned) + - L2 goal plan writer + - L3 specialists (when spawned) + - L2 goal plan writer + - L3 specialists (when spawned) + - L2 rustls audit then check-remote + - L3 specialists (when spawned) + - L2 splora.surmount.systems Host + - L3 specialists (when spawned) + +## Nested L2 session usage + +| id | contract/aspect | owner | measured tokens | estimate | status | +| --- | --- | --- | --- | --- | --- | +| Complete wrap and issue | nested L2 session usage | L2 | 200076 | estimate | exited | +| DNS zone live setHosts | nested L2 session usage | L2 | 132747 | estimate | exited | +| Diagnose Nostr login 500 | nested L2 session usage | L2 | 163200 | estimate | exited | +| Document Splora UI forward | nested L2 session usage | L2 | 69357 | estimate | exited | +| Finish Splora path proxy | nested L2 session usage | L2 | 199368 | estimate | exited | +| Finish issue and report | nested L2 session usage | L2 | 198052 | estimate | exited | +| Finish per-site onion code | nested L2 session usage | L2 | 199929 | estimate | exited | +| Finish per-site onions | nested L2 session usage | L2 | 78308 | estimate | exited | +| Fix Splora doc contradiction | nested L2 session usage | L2 | 0 | estimate | exited | +| Fix nft helper ETXTBSY | nested L2 session usage | L2 | 111677 | estimate | exited | +| Fix publicHttpHosts eval fail | nested L2 session usage | L2 | 73876 | estimate | exited | +| Forward Splora UI paths | nested L2 session usage | L2 | 139874 | estimate | exited | +| Green just check-remote | nested L2 session usage | L2 | 69322 | estimate | exited | +| Honor hook and issue cert | nested L2 session usage | L2 | 196300 | estimate | exited | +| Implement Splora portal Host | nested L2 session usage | L2 | 182109 | estimate | exited | +| Implement domains inventory union | nested L2 session usage | L2 | 156192 | estimate | exited | +| Implement per-site v3 onions | nested L2 session usage | L2 | 198190 | estimate | exited | +| Inventory onion Hosts | nested L2 session usage | L2 | 127836 | estimate | exited | +| Issue cert from laptop | nested L2 session usage | L2 | 69523 | estimate | exited | +| List Namecheap-updated zones | nested L2 session usage | L2 | 95675 | estimate | exited | +| Map TLS cert names | nested L2 session usage | L2 | 120448 | estimate | exited | +| Map UI domains page | nested L2 session usage | L2 | 95426 | estimate | exited | +| Map mempool URL model | nested L2 session usage | L2 | 108899 | estimate | exited | +| Map remote CI check scope | nested L2 session usage | L2 | 109497 | estimate | exited | +| Map splora proxy paths | nested L2 session usage | L2 | 117311 | estimate | exited | +| Named flake eval onion tests | nested L2 session usage | L2 | 69731 | estimate | exited | +| Per-site onion implementer | nested L2 session usage | L2 | 198871 | estimate | exited | +| Per-site onion services | nested L2 session usage | L2 | 0 | estimate | exited | +| Per-site onion services | nested L2 session usage | L2 | 56812 | estimate | exited | +| Pin 20-name leaf docs | nested L2 session usage | L2 | 56601 | estimate | exited | +| Restore exophiles cert names | nested L2 session usage | L2 | 141403 | estimate | exited | +| Restore exophiles on cert | nested L2 session usage | L2 | 54981 | estimate | exited | +| Review Splora portal Host | nested L2 session usage | L2 | 96589 | estimate | exited | +| Run laptop-renew-cert now | nested L2 session usage | L2 | 157441 | estimate | exited | +| Splora hosts and docs | nested L2 session usage | L2 | 153029 | estimate | exited | +| Splora path proxy | nested L2 session usage | L2 | 120653 | estimate | exited | +| Splora path proxy forward | nested L2 session usage | L2 | 199175 | estimate | exited | +| Splora router and tests | nested L2 session usage | L2 | 0 | estimate | exited | +| Sync stale splora module file | nested L2 session usage | L2 | 87731 | estimate | exited | +| Union domains inventory | nested L2 session usage | L2 | 54616 | estimate | exited | +| Update living leaf docs | nested L2 session usage | L2 | 126253 | estimate | exited | +| Wait for cert issue | nested L2 session usage | L2 | 201989 | estimate | exited | +| Write L3 onion report file | nested L2 session usage | L2 | 203671 | estimate | exited | +| Write per-site onion report | nested L2 session usage | L2 | 201035 | estimate | exited | +| check-remote path proxy | nested L2 session usage | L2 | 111826 | estimate | exited | +| dns-zone live Namecheap | nested L2 session usage | L2 | 66876 | estimate | exited | +| goal plan writer | nested L2 session usage | L2 | 224310 | estimate | exited | +| goal plan writer | nested L2 session usage | L2 | 224310 | estimate | exited | +| goal plan writer | nested L2 session usage | L2 | 224310 | estimate | exited | +| goal plan writer | nested L2 session usage | L2 | 224310 | estimate | exited | +| goal plan writer | nested L2 session usage | L2 | 224310 | estimate | exited | +| goal plan writer | nested L2 session usage | L2 | 87889 | estimate | exited | +| goal plan writer | nested L2 session usage | L2 | 88582 | estimate | exited | +| goal plan writer | nested L2 session usage | L2 | 89472 | estimate | exited | +| goal plan writer | nested L2 session usage | L2 | 89644 | estimate | exited | +| goal plan writer | nested L2 session usage | L2 | 88643 | estimate | exited | +| rustls audit then check-remote | nested L2 session usage | L2 | 144854 | estimate | exited | +| splora.surmount.systems Host | nested L2 session usage | L2 | 71509 | estimate | exited | + diff --git a/crates/management-ui/src/api.rs b/crates/management-ui/src/api.rs index e53e07b..bcff2d4 100644 --- a/crates/management-ui/src/api.rs +++ b/crates/management-ui/src/api.rs @@ -410,16 +410,24 @@ pub async fn create_account_via_directory( let mut password_set = false; let mut password_note = None; if let Some(secret) = password { - let lookup_addr = result - .account - .as_ref() + // Stalwart 0.16 created objects have no emailAddress. The address + // field is then the id or the local-part, and password set refuses + // that string. Keep a real mailbox address for the echo, and set the + // credential on the created principal id so lookup is not required. + let created = result.account.as_ref(); + let lookup_addr = created .map(|a| a.address.clone()) + .filter(|addr| addr.contains('@')) .unwrap_or_else(|| address.clone()); + let account_id = created + .map(|a| a.id.trim().to_string()) + .filter(|id| !id.is_empty()); let pw = state .directory .set_mailbox_password(SetMailboxPasswordInput { mailbox: lookup_addr, password: secret.to_string(), + account_id, }) .await; password_set = pw.ok; @@ -467,7 +475,10 @@ pub async fn create_account_via_directory( "source": result.source, "note": note, }); - if let Some(acc) = result.account { + if let Some(mut acc) = result.account { + if !acc.address.contains('@') { + acc.address = address.clone(); + } body["account"] = json!(acc); } if !console_saved || (password.is_some() && !password_set) { @@ -744,6 +755,7 @@ pub async fn set_mailbox_password_via_directory( .set_mailbox_password(SetMailboxPasswordInput { mailbox, password: body.password, + account_id: None, }) .await; if result.ok @@ -1202,13 +1214,18 @@ mod tests { let url = "http://abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion"; cfg.onion_url = Some(url.into()); cfg.onion_surface = crate::config::OnionSurface::Configured { url: url.into() }; + let extra = crate::onion_discovery::per_site_mappings( + &cfg.primary_domain, + &cfg.services_hostname, + &[] as &[&str], + ); cfg.onion_discovery = crate::onion_discovery::build_onion_discovery( &cfg.primary_domain, &cfg.services_hostname, Some(url), true, true, - Vec::new(), + extra, &[], &[], &[] as &[&str], @@ -1228,6 +1245,19 @@ mod tests { assert!(hosts.contains(&"www.example.test")); assert!(hosts.contains(&"services.example.test")); assert!(!hosts.contains(&"mail.example.test")); + let apex = s + .onion_discovery + .mappings + .iter() + .find(|m| m.clearnet_host == "example.test") + .unwrap(); + let www = s + .onion_discovery + .mappings + .iter() + .find(|m| m.clearnet_host == "www.example.test") + .unwrap(); + assert_ne!(apex.onion_host, www.onion_host); let blob = serde_json::to_string(&s).unwrap(); assert!( blob.contains("\"onion_discovery\""), diff --git a/crates/management-ui/src/auth.rs b/crates/management-ui/src/auth.rs index e2d5754..a040e66 100644 --- a/crates/management-ui/src/auth.rs +++ b/crates/management-ui/src/auth.rs @@ -23,8 +23,9 @@ use base64::engine::general_purpose::{STANDARD as B64_STANDARD, URL_SAFE_NO_PAD} use hmac::{Hmac, Mac}; use nostr::nips::nip19::ToBech32; pub use nostr::nips::nip98::{HttpData, HttpMethod}; -use nostr::prelude::{Event, EventBuilder, JsonUtil, Keys, Kind, PublicKey, TagKind, TagStandard}; -use nostr::{Timestamp, Url as NostrUrl}; +use nostr::prelude::{ + Event, FinalizeEvent, IntoEventBuilder, Keys, Kind, PublicKey, Tags, Timestamp, Url as NostrUrl, +}; use serde::{Deserialize, Serialize}; use sha2::Sha256; @@ -393,27 +394,14 @@ pub fn verify_nip98_event_unlisted( return Err(AuthError::Skew); } - let authorized_url = event - .tags - .find_standardized(TagKind::u()) - .and_then(|tag| match tag { - TagStandard::AbsoluteURL(u) => Some(u.to_string()), - _ => None, - }) - .ok_or(AuthError::MissingTag("u"))?; + let authorized_url = nip98_tag_value(&event.tags, "u").ok_or(AuthError::MissingTag("u"))?; if !urls_match_for_nip98(&authorized_url, expected_url) { return Err(AuthError::UrlMismatch); } - let authorized_method = event - .tags - .find_standardized(TagKind::Method) - .and_then(|tag| match tag { - TagStandard::Method(m) => Some(m.to_string()), - _ => None, - }) - .ok_or(AuthError::MissingTag("method"))?; + let authorized_method = + nip98_tag_value(&event.tags, "method").ok_or(AuthError::MissingTag("method"))?; if !authorized_method.eq_ignore_ascii_case(expected_method) { return Err(AuthError::MethodMismatch); @@ -448,6 +436,18 @@ pub fn verify_nip98_event( Ok(identity) } +/// First value of a NIP-98 tag (`u` or `method`). nostr 0.45 dropped `TagKind`. +fn nip98_tag_value(tags: &Tags, kind: &str) -> Option { + tags.iter().find_map(|tag| { + let parts = tag.as_slice(); + if parts.first().map(String::as_str) == Some(kind) { + parts.get(1).cloned() + } else { + None + } + }) +} + /// Loose URL compare: normalize trailing slash on path-only roots. fn urls_match_for_nip98(authorized: &str, expected: &str) -> bool { fn norm(s: &str) -> String { @@ -797,11 +797,11 @@ pub fn sign_nip98_event( ) -> Result { let url = NostrUrl::parse(url).map_err(|e| e.to_string())?; let data = HttpData::new(url, method); - let mut builder = EventBuilder::http_auth(data); + let mut builder = data.into_event_builder(); if let Some(ts) = created_at { builder = builder.custom_created_at(Timestamp::from_secs(ts)); } - builder.sign_with_keys(keys).map_err(|e| e.to_string()) + builder.finalize(keys).map_err(|e| e.to_string()) } /// Encode event as `Authorization: Nostr ` value (includes prefix). diff --git a/crates/management-ui/src/config.rs b/crates/management-ui/src/config.rs index 57029df..0f50363 100644 --- a/crates/management-ui/src/config.rs +++ b/crates/management-ui/src/config.rs @@ -554,7 +554,15 @@ 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 extra_onion_hosts: Vec = static_vhosts.keys().cloned().collect(); + // 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. + let vaultwarden_proxy = VaultwardenProxyConfig::from_env()?; + // Load splora before onion discovery so extra onion Hosts include + // indexer Hosts and the portal Host (not only static vhost keys). + let splora_proxy = SploraProxyConfig::from_env()?; + let redirect_allowed_hosts = union_splora_hosts(redirect_allowed_hosts, &splora_proxy); + let extra_onion_hosts = extra_onion_hosts_union(&static_vhosts, &splora_proxy); // Onion: structured surface from env + host HS paths. Never invent. let onion_surface = resolve_onion_surface_from_env(); @@ -566,13 +574,7 @@ impl AppConfig { onion_url.as_deref(), &extra_onion_hosts, ); - // 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. - let vaultwarden_proxy = VaultwardenProxyConfig::from_env()?; - let splora_proxy = SploraProxyConfig::from_env()?; let http3 = Http3Config::from_env_map(|k| env::var(k).ok(), listen_mode.is_https())?; - let redirect_allowed_hosts = union_splora_hosts(redirect_allowed_hosts, &splora_proxy); // Public apex/www document root. Empty/unset = coming-soon fallback. let apex_public_root = match env::var("SURMOUNT_APEX_PUBLIC_ROOT") { @@ -870,19 +872,34 @@ pub fn union_splora_hosts(mut allowed: Vec, splora: &SploraProxyConfig) if !splora.enable { return allowed; } - for inst in &splora.instances { - for host in &inst.hosts { - if !allowed - .iter() - .any(|a| crate::redirect::host_is_allowlisted(host, std::slice::from_ref(a))) - { - allowed.push(host.to_string()); - } + for host in splora.extra_onion_and_redirect_hosts() { + if !allowed + .iter() + .any(|a| crate::redirect::host_is_allowlisted(&host, std::slice::from_ref(a))) + { + allowed.push(host); } } allowed } +/// Extra onion Hosts: static vhost keys plus splora indexer Hosts and portal. +pub fn extra_onion_hosts_union( + vhosts: &BTreeMap, + splora: &SploraProxyConfig, +) -> Vec { + let mut hosts: Vec = vhosts.keys().cloned().collect(); + if !splora.enable { + return hosts; + } + for host in splora.extra_onion_and_redirect_hosts() { + if !hosts.iter().any(|h| h == &host) { + hosts.push(host); + } + } + hosts +} + pub fn union_static_vhost_hosts( mut allowed: Vec, vhosts: &BTreeMap, @@ -1036,6 +1053,10 @@ mod tests { "SURMOUNT_SPLORA_QUEUE_SOCKET", "SURMOUNT_SPLORA_QUEUE_PATH", "SURMOUNT_SPLORA_ELECTRUM_SOCKET", + "SURMOUNT_SPLORA_PORTAL_HOST", + "SURMOUNT_ONION_SITES_DIR", + "SURMOUNT_ONION_SITE_NICKNAMES_FILE", + "SURMOUNT_ONION_PUBLISHED_HOSTNAMES_DIR", "SURMOUNT_HTTP3", "SURMOUNT_RATE_LIMIT_MAX", "SURMOUNT_RATE_LIMIT_WINDOW_SECS", @@ -1074,8 +1095,122 @@ mod tests { let _g = EnvGuard::acquire(); let cfg = AppConfig::from_env().unwrap(); assert!(!cfg.splora_proxy.enable); + assert!(cfg.splora_proxy.portal_host.is_none()); assert!(!cfg.http3.enable); assert!(!cfg.http3.listener_bound()); + assert!( + !cfg.redirect_allowed_hosts + .iter() + .any(|h| h == "splora.surmount.systems") + ); + } + + /// Named contract: the one public Host is on the :80 allowlist and is the + /// only Splora onion extra. Esplora Hosts are not in the proxy map. + #[test] + fn splora_portal_unions_redirect_allowlist_and_onion_extra_hosts() { + let _g = EnvGuard::acquire(); + set_env("SURMOUNT_SPLORA_PROXY", "1"); + set_env("SURMOUNT_SPLORA_PORTAL_HOST", "splora.surmount.systems"); + set_env( + "SURMOUNT_SPLORA_INSTANCES", + r#"{"testnet3":{"hosts":[]},"testnet4":{"hosts":[]},"mutinynet":{"hosts":[]},"liquid":{"hosts":[]}}"#, + ); + let cfg = AppConfig::from_env().unwrap(); + assert!(cfg.splora_proxy.enable); + assert_eq!( + cfg.splora_proxy.portal_host.as_deref(), + Some("splora.surmount.systems") + ); + assert!( + cfg.splora_proxy + .instances + .iter() + .all(|inst| inst.hosts.is_empty()) + ); + assert!( + cfg.splora_proxy + .instances + .iter() + .all(|inst| inst.name != "mainnet"), + "mainnet stays off when it is absent from the instance map" + ); + assert!( + cfg.redirect_allowed_hosts + .iter() + .any(|h| h == "splora.surmount.systems"), + ":80 allowlist must include the portal: {:?}", + cfg.redirect_allowed_hosts + ); + for host in [ + "esplora.surmount.systems", + "testnet3.esplora.surmount.systems", + "testnet4.esplora.surmount.systems", + "mutinynet.esplora.surmount.systems", + "liquid.esplora.surmount.systems", + ] { + assert!( + !cfg.redirect_allowed_hosts.iter().any(|h| h == host), + ":80 allowlist must not include {host}: {:?}", + cfg.redirect_allowed_hosts + ); + } + let extra = extra_onion_hosts_union(&cfg.static_vhosts, &cfg.splora_proxy); + assert!( + extra.iter().any(|h| h == "splora.surmount.systems"), + "onion extra hosts must include the portal: {extra:?}" + ); + for host in [ + "esplora.surmount.systems", + "testnet3.esplora.surmount.systems", + "testnet4.esplora.surmount.systems", + "mutinynet.esplora.surmount.systems", + "liquid.esplora.surmount.systems", + ] { + assert!( + !extra.iter().any(|h| h == host), + "onion extra hosts must not include {host}: {extra:?}" + ); + } + assert!( + crate::redirect::redirect_http_to_https( + true, + "splora.surmount.systems", + "/", + None, + &cfg.redirect_allowed_hosts, + &cfg.primary_domain, + &cfg.services_hostname, + ) == crate::redirect::HttpToHttps::Redirect { + location: "https://splora.surmount.systems/".into() + } + ); + let dir = std::env::temp_dir().join(format!( + "surmount-splora-portal-onion-{}-{}", + 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 portal_onion = crate::onion_discovery::unique_v3_onion_host(21); + std::fs::write( + dir.join("splora.surmount.systems"), + format!("{portal_onion}\n"), + ) + .unwrap(); + set_env( + "SURMOUNT_ONION_SITES_DIR", + dir.to_str().expect("utf8 temp path"), + ); + let cfg2 = AppConfig::from_env().unwrap(); + let mapped = cfg2 + .onion_discovery + .lookup("splora.surmount.systems") + .expect("portal Host must be in onion extra hosts / sites dir"); + assert_eq!(mapped.onion_host, portal_onion); + let _ = std::fs::remove_dir_all(&dir); } #[test] @@ -1267,9 +1402,9 @@ mod tests { use surmount_management_ui::auth::allowlist_contains; let _g = EnvGuard::acquire(); - let keys = nostr::Keys::generate(); + let keys = nostr::prelude::Keys::generate(); let file_hex = keys.public_key().to_hex(); - let env_keys = nostr::Keys::generate(); + let env_keys = nostr::prelude::Keys::generate(); let env_hex = env_keys.public_key().to_hex(); let dir = std::env::temp_dir().join(format!("surmount-cfg-allowlist-{}", std::process::id())); @@ -1655,7 +1790,8 @@ mod tests { assert_eq!(cfg.onion_discovery.mappings().count(), 0); } - /// Named contract: configured onion auto-derives apex, www, services, and mta-sts mappings. + /// Named contract: SURMOUNT_ONION_URL maps the services Host only. + /// Sharing that v3 onto apex/www would correlate sites. #[test] fn onion_discovery_auto_derive_from_onion_url() { let _g = EnvGuard::acquire(); @@ -1668,25 +1804,24 @@ mod tests { .mappings() .map(|m| m.clearnet_host.as_str()) .collect(); - assert!(hosts.contains(&"example.test")); - assert!(hosts.contains(&"www.example.test")); assert!(hosts.contains(&"services.example.test")); - assert!(hosts.contains(&"mta-sts.example.test")); - assert!(!hosts.contains(&"mail.example.test")); - let apex = cfg.onion_discovery.lookup("example.test").unwrap(); - assert_eq!(apex.onion_port, 443); - assert_eq!(apex.protocols, vec!["h2".to_string()]); - assert_eq!(apex.onion_scheme, "http"); - assert_eq!(apex.ma_seconds, 86_400); - assert_eq!(apex.onion_path_prefix, "/_o/example.test"); - let services = cfg.onion_discovery.lookup("services.example.test").unwrap(); assert!( - services.onion_path_prefix.is_empty(), - "services console stays onion root" + !hosts.contains(&"example.test"), + "one onion URL must not map every public Host: {hosts:?}" ); + assert!(!hosts.contains(&"www.example.test")); + assert!(!hosts.contains(&"mta-sts.example.test")); + assert!(!hosts.contains(&"mail.example.test")); + let services = cfg.onion_discovery.lookup("services.example.test").unwrap(); + assert_eq!(services.onion_port, 443); + assert_eq!(services.protocols, vec!["h2".to_string()]); + assert_eq!(services.onion_scheme, "http"); + assert_eq!(services.ma_seconds, 86_400); + assert!(services.onion_path_prefix.is_empty()); + assert_eq!(services.onion_host, format!("{SAMPLE_V3}.onion")); } - /// Named contract: extra static Hosts auto-map; mail does not. + /// Named contract: extra static Hosts map only with their own onion; mail does not. #[test] fn onion_discovery_auto_maps_extra_static_vhosts() { let _g = EnvGuard::acquire(); @@ -1697,13 +1832,101 @@ mod tests { "SURMOUNT_STATIC_VHOSTS", r#"{"extra.test":"/tmp/extra","www.extra.test":"/tmp/extra"}"#, ); + let dir = std::env::temp_dir().join(format!( + "surmount-onion-sites-{}-{}", + 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 extra_onion = crate::onion_discovery::unique_v3_onion_host(3); + let www_extra_onion = crate::onion_discovery::unique_v3_onion_host(4); + std::fs::write(dir.join("extra.test"), format!("{extra_onion}\n")).unwrap(); + std::fs::write(dir.join("www.extra.test"), format!("{www_extra_onion}\n")).unwrap(); + set_env( + "SURMOUNT_ONION_SITES_DIR", + dir.to_str().expect("utf8 temp path"), + ); let cfg = AppConfig::from_env().unwrap(); assert!(cfg.onion_discovery.lookup("extra.test").is_some()); assert!(cfg.onion_discovery.lookup("www.extra.test").is_some()); - assert!(cfg.onion_discovery.lookup("mta-sts.example.test").is_some()); assert!(cfg.onion_discovery.lookup("mail.example.test").is_none()); let extra = cfg.onion_discovery.lookup("extra.test").unwrap(); - assert_eq!(extra.onion_path_prefix, "/_o/extra.test"); + let www_extra = cfg.onion_discovery.lookup("www.extra.test").unwrap(); + assert_eq!(extra.onion_host, extra_onion); + assert_eq!(www_extra.onion_host, www_extra_onion); + assert_ne!(extra.onion_host, www_extra.onion_host); + assert!(extra.onion_path_prefix.is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Named contract: nickname JSON plus published-hostnames files map two + /// Hosts to two different v3 onions at onion root (no `/_o/` prefix). + #[test] + fn onion_discovery_from_published_nickname_files_is_per_site() { + let _g = EnvGuard::acquire(); + set_env("SURMOUNT_PRIMARY_DOMAIN", "example.test"); + set_env("SURMOUNT_SERVICES_HOSTNAME", "services.example.test"); + set_env("SURMOUNT_MAIL_HOSTNAME", "mail.example.test"); + set_env("SURMOUNT_EXTRA_MAIL_HOSTNAMES", "mail.cryptoquick.com"); + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let dir = std::env::temp_dir().join(format!( + "surmount-onion-published-{}-{}", + std::process::id(), + stamp + )); + std::fs::create_dir_all(&dir).unwrap(); + let nick_path = dir.join("nicknames.json"); + let pub_dir = dir.join("published-hostnames"); + std::fs::create_dir_all(&pub_dir).unwrap(); + std::fs::write( + &nick_path, + r#"{"example.test":"site-example-test","www.example.test":"site-www-example-test","mail.example.test":"site-mail-example-test","mail.cryptoquick.com":"site-mail-cryptoquick-com"}"#, + ) + .unwrap(); + let onion_a = crate::onion_discovery::unique_v3_onion_host(11); + let onion_b = crate::onion_discovery::unique_v3_onion_host(12); + let onion_mail = crate::onion_discovery::unique_v3_onion_host(13); + std::fs::write(pub_dir.join("site-example-test"), format!("{onion_a}\n")).unwrap(); + std::fs::write( + pub_dir.join("site-www-example-test"), + format!("{onion_b}\n"), + ) + .unwrap(); + std::fs::write( + pub_dir.join("site-mail-example-test"), + format!("{onion_mail}\n"), + ) + .unwrap(); + std::fs::write( + pub_dir.join("site-mail-cryptoquick-com"), + format!("{}\n", crate::onion_discovery::unique_v3_onion_host(14)), + ) + .unwrap(); + set_env( + "SURMOUNT_ONION_SITE_NICKNAMES_FILE", + nick_path.to_str().expect("utf8 nick path"), + ); + set_env( + "SURMOUNT_ONION_PUBLISHED_HOSTNAMES_DIR", + pub_dir.to_str().expect("utf8 published dir"), + ); + let cfg = AppConfig::from_env().unwrap(); + let apex = cfg.onion_discovery.lookup("example.test").unwrap(); + let www = cfg.onion_discovery.lookup("www.example.test").unwrap(); + assert_eq!(apex.onion_host, onion_a); + assert_eq!(www.onion_host, onion_b); + assert_ne!(apex.onion_host, www.onion_host); + assert!(apex.onion_path_prefix.is_empty()); + assert!(www.onion_path_prefix.is_empty()); + assert!(cfg.onion_discovery.lookup("mail.example.test").is_none()); + assert!(cfg.onion_discovery.lookup("mail.cryptoquick.com").is_none()); + let _ = std::fs::remove_dir_all(&dir); } /// Named contract: invalid v3 onion is rejected for discovery (no panic, no map). @@ -1730,8 +1953,8 @@ mod tests { let cfg = AppConfig::from_env().unwrap(); assert!(!cfg.onion_discovery.onion_location_enabled); assert!(!cfg.onion_discovery.alt_svc_enabled); - let apex = cfg.onion_discovery.lookup("example.test").unwrap(); - assert_eq!(apex.onion_scheme, "https"); + let services = cfg.onion_discovery.lookup("services.example.test").unwrap(); + assert_eq!(services.onion_scheme, "https"); } /// Named contract: path set but missing material => hostname_missing (not invent). diff --git a/crates/management-ui/src/directory.rs b/crates/management-ui/src/directory.rs index 0ba0760..a88a169 100644 --- a/crates/management-ui/src/directory.rs +++ b/crates/management-ui/src/directory.rs @@ -6,9 +6,12 @@ //! plus a host token (env or file); never default-on; never invents accounts. //! //! Mutations (`create_account` / `update_account`) use management JMAP -//! `x:Account/set` on the live backend. Mailbox password set looks up the -//! principal by email and PATCHes a Password credential on that Account -//! (never `x:AccountPassword/set`, which is the API-token principal). +//! `x:Account/set` on the live backend. The password card looks up the +//! principal by mailbox address and PATCHes a Password credential on that +//! Account. Create sets that credential on the principal id just returned: +//! Stalwart 0.16 User has no emailAddress, so the created object is an id +//! or a local-part, and using that string as the mailbox address refuses. +//! Never `x:AccountPassword/set` (that singleton is the API-token principal). //! HTTP API routes gate mutations behind `AUTH_MODE=nostr` (or lab //! `SURMOUNT_DIRECTORY_ALLOW_UNAUTHENTICATED=1`) and double-submit CSRF on //! cookie-authenticated POSTs. Password is never logged, never returned in @@ -61,25 +64,39 @@ pub struct AccountMutationResult { pub note: String, } -/// Set a mailbox Password credential (lookup by email; never the API-token principal). +/// Set a mailbox Password credential (never the API-token principal). /// +/// Password card: `account_id` is absent and `mailbox` is the lookup key. +/// Create passes the principal id from `x:Account/set` and skips lookup. /// `password` is omitted from [`Debug`] so logs cannot echo the secret. pub struct SetMailboxPasswordInput { - /// Mailbox address (`hunter@surmount.systems`). Lookup key; not Account id. + /// Mailbox address (`local@domain`). Lookup key when `account_id` is absent. pub mailbox: String, /// New password. Never logged; never serialized on the response path. pub password: String, + /// Principal id from a create that just succeeded. Skips address lookup. + pub account_id: Option, } impl std::fmt::Debug for SetMailboxPasswordInput { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("SetMailboxPasswordInput") .field("mailbox", &self.mailbox) + .field("account_id", &self.account_id) .field("password", &"[redacted]") .finish() } } +fn nonempty_account_id(input: &SetMailboxPasswordInput) -> Option { + input + .account_id + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + /// Result of a mailbox password set. Never includes the password. #[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct MailboxPasswordResult { @@ -451,6 +468,44 @@ For tests and explicit SURMOUNT_DIRECTORY=mock only. Mutations available via API ) -> Pin + Send + '_>> { Box::pin(async move { let mailbox = input.mailbox.trim().to_string(); + if let Some(id) = nonempty_account_id(&input) { + if input.password.is_empty() { + return MailboxPasswordResult { + ok: false, + source: "mock", + mailbox: if mailbox.contains('@') { + Some(mailbox) + } else { + None + }, + note: "password set refused: password required".into(), + }; + } + let found = { + let lock = self.accounts.lock().unwrap_or_else(|e| e.into_inner()); + lock.iter().find(|a| a.id == id).cloned() + }; + return match found { + Some(entry) => MailboxPasswordResult { + ok: true, + source: "mock", + mailbox: Some(entry.address), + note: "Hermetic mock password set (not live Stalwart). \ +Use this password in your mail app." + .into(), + }, + None => MailboxPasswordResult { + ok: false, + source: "mock", + mailbox: if mailbox.contains('@') { + Some(mailbox) + } else { + None + }, + note: "No mailbox with that address.".into(), + }, + }; + } if mailbox.is_empty() || !mailbox.contains('@') { return MailboxPasswordResult { ok: false, @@ -639,10 +694,37 @@ impl StalwartDirectory { if want.is_empty() || !want.contains('@') { return Err("mailbox address required".into()); } - let accounts = self.fetch_accounts().await?; - accounts - .into_iter() - .find(|a| a.address.eq_ignore_ascii_case(want)) + let value = self.post_jmap(&jmap_account_list_request()).await?; + let rows = account_list_rows(&value)?; + if let Some(row) = rows + .iter() + .find(|row| row.address.eq_ignore_ascii_case(want)) + { + return Ok(row.to_entry()); + } + let Some((local, domain)) = mailbox_local_and_domain(want) else { + return Err("No mailbox with that address.".into()); + }; + let needs_domain = rows.iter().any(|row| { + row.domain_id.is_some() + && !row.address.contains('@') + && row.address.eq_ignore_ascii_case(local) + }); + if !needs_domain { + return Err("No mailbox with that address.".into()); + } + // domainId is an id. Match the id of the domain whose name is `domain`. + let domain_body = self.post_jmap(&jmap_domain_list_request()).await?; + let Some(domain_id) = domain_id_from_jmap(&domain_body, domain)? else { + return Err("No mailbox with that address.".into()); + }; + rows.into_iter() + .find(|row| { + !row.address.contains('@') + && row.address.eq_ignore_ascii_case(local) + && row.domain_id.as_deref() == Some(domain_id.as_str()) + }) + .map(|row| row.to_entry()) .ok_or_else(|| "No mailbox with that address.".into()) } @@ -775,6 +857,35 @@ Set the mailbox password from Mail on this console." ) -> Pin + Send + '_>> { Box::pin(async move { let mailbox = input.mailbox.trim().to_string(); + if let Some(id) = nonempty_account_id(&input) { + let echo = if mailbox.contains('@') { + Some(mailbox) + } else { + None + }; + if input.password.is_empty() { + return MailboxPasswordResult { + ok: false, + source: "stalwart", + mailbox: echo.clone(), + note: "password set refused: password required".into(), + }; + } + return match self.set_password_credential(&id, &input.password).await { + Ok(()) => MailboxPasswordResult { + ok: true, + source: "stalwart", + mailbox: echo.clone(), + note: "Password set. Use this password in your mail app.".into(), + }, + Err(err) => MailboxPasswordResult { + ok: false, + source: "stalwart", + mailbox: echo, + note: format!("Stalwart password set failed (fail-closed). {err}"), + }, + }; + } if mailbox.is_empty() || !mailbox.contains('@') { return MailboxPasswordResult { ok: false, @@ -952,7 +1063,7 @@ impl Directory for StalwartPasswordOnly { } } -/// JMAP request body: query all accounts then get id/name/emailAddress/@type. +/// JMAP request body: query all accounts then get id/name/emailAddress/domainId/@type. pub fn jmap_account_list_request() -> Value { json!({ "using": [ @@ -976,7 +1087,7 @@ pub fn jmap_account_list_request() -> Value { "name": "x:Account/query", "path": "/ids" }, - "properties": ["id", "name", "emailAddress", "@type"] + "properties": ["id", "name", "emailAddress", "domainId", "@type"] }, "g1" ] @@ -1319,11 +1430,52 @@ Open auth_mode=off must not mutate principals." Ok(()) } +/// One `x:Account/get` row. `domain_id` is Stalwart's id, not the domain name. +struct AccountListRow { + id: String, + address: String, + status: String, + domain_id: Option, +} + +impl AccountListRow { + fn to_entry(&self) -> AccountEntry { + AccountEntry { + id: self.id.clone(), + address: self.address.clone(), + status: self.status.clone(), + } + } +} + +/// `local@domain` with both sides non-empty. No `@` is not an address. +fn mailbox_local_and_domain(mailbox: &str) -> Option<(&str, &str)> { + let (local, domain) = mailbox.split_once('@')?; + let local = local.trim(); + let domain = domain.trim(); + if local.is_empty() || domain.is_empty() { + return None; + } + Some((local, domain)) +} + /// Map a successful JMAP response into account rows (no invent on missing list). /// /// Looks for the first `x:Account/get` method response with a `list` array. /// Each object needs at least `id`; address prefers `emailAddress`, else `name`. pub fn accounts_from_jmap_response(body: &Value) -> Result, String> { + let rows = account_list_rows(body)?; + Ok(rows + .into_iter() + .map(|row| AccountEntry { + id: row.id, + address: row.address, + status: row.status, + }) + .collect()) +} + +fn account_list_rows(body: &Value) -> Result, String> { let responses = body .get("methodResponses") .and_then(|v| v.as_array()) @@ -1380,6 +1532,12 @@ pub fn accounts_from_jmap_response(body: &Value) -> Result, St (None, Some(n)) => n, (None, None) => id.clone(), }; + let domain_id = item + .get("domainId") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); // Wire field is still `status` for JSON stability; value is principal // kind (@type) when present, else "active" (not a lifecycle SoT). let status = item @@ -1388,10 +1546,11 @@ pub fn accounts_from_jmap_response(body: &Value) -> Result, St .map(|t| t.trim().to_ascii_lowercase()) .filter(|s| !s.is_empty()) .unwrap_or_else(|| "active".into()); - out.push(AccountEntry { + out.push(AccountListRow { id, address, status, + domain_id, }); } return Ok(out); @@ -2216,6 +2375,7 @@ mod tests { .set_mailbox_password(SetMailboxPasswordInput { mailbox: "alice@example.test".into(), password: concat!("unit-test-only-", "secret").into(), + account_id: None, }) .await; assert!(!pw.ok); @@ -2333,6 +2493,237 @@ mod tests { assert!(s.contains("urn:stalwart:jmap")); } + /// Named contract: a UI-created mailbox is an inbound User bound to a + /// Domain id. Stalwart 0.16 delivers to `name` at `domainId`. Omitting + /// either, sending `@` inside `name`, or using Group is not that account. + /// The password is a later credential update. + #[test] + fn jmap_account_create_request_is_inbound_user_bound_to_domain() { + let body = jmap_account_create_request(&CreateAccountInput { + name: "tony".into(), + domain_id: "dom-9".into(), + description: Some("Tony V (Lean)".into()), + }); + let calls = body["methodCalls"].as_array().expect("methodCalls"); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0][0], "x:Account/set"); + let created = &calls[0][1]["create"]["new1"]; + assert_eq!(created["@type"], "User"); + assert_ne!(created["@type"], "Group"); + assert_eq!(created["name"], "tony"); + assert!( + !created["name"].as_str().unwrap_or("").contains('@'), + "local-part must not contain @" + ); + assert_eq!(created["domainId"], "dom-9"); + assert_ne!(created["domainId"], "example.test"); + assert!( + created["credentials"] + .as_object() + .expect("credentials") + .is_empty(), + "create must not put the password on a different principal type" + ); + assert_eq!(created["roles"]["@type"], "User"); + assert_eq!(created["description"], "Tony V (Lean)"); + let s = body.to_string(); + assert!(!s.contains("password") && !s.contains("nsec") && !s.contains("secret")); + assert!(!s.contains("emailAddress")); + } + + /// Named contract: password set without a created id still refuses a + /// mailbox string that is not an address. That is the password card. + #[tokio::test] + async fn stalwart_password_refuses_non_address_without_created_id() { + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_millis(200)) + .build() + .unwrap(); + let dir = StalwartDirectory::new(http, "http://127.0.0.1:9", "test-token"); + let refused = dir + .set_mailbox_password(SetMailboxPasswordInput { + mailbox: "live-1".into(), + password: concat!("unit-test-only-", "secret").into(), + account_id: None, + }) + .await; + assert!(!refused.ok); + assert_eq!( + refused.note, + "password set refused: mailbox address required" + ); + } + + /// Named contract: the password card looks up `local@domain` with no + /// created id. Stalwart 0.16 often lists the User as the local-part + /// plus a domain id and omits emailAddress. Lookup of + /// tony@example.test must find the User whose name is tony when that + /// user's domain is example.test, not the tony on other.test and not + /// alice on example.test. The card then sets the password on that id. + #[tokio::test] + async fn stalwart_password_card_matches_local_part_when_domain_matches() { + let seen = Arc::new(Mutex::new(Vec::::new())); + let seen_h = seen.clone(); + let app = Router::new().route( + "/jmap", + post(move |req: Request| { + let seen = seen_h.clone(); + async move { + let bytes = axum::body::to_bytes(req.into_body(), 64 * 1024) + .await + .unwrap_or_default(); + let incoming: Value = serde_json::from_slice(&bytes).unwrap_or(json!({})); + let s = incoming.to_string(); + seen.lock().await.push(s.clone()); + let is_domain = s.contains("x:Domain/"); + let is_set = s.contains("x:Account/set") || s.contains("Account/set"); + let targets_example_tony = s.contains("\"user-tony-example\"") + && !s.contains("user-tony-other") + && !s.contains("user-alice-example"); + let body = if is_domain { + json!({ + "methodResponses": [ + ["x:Domain/query", {"ids": ["dom-example", "dom-other"]}, "dq1"], + ["x:Domain/get", { + "list": [ + {"id": "dom-example", "name": "example.test"}, + {"id": "dom-other", "name": "other.test"} + ] + }, "dg1"] + ] + }) + } else if is_set && targets_example_tony { + json!({ + "methodResponses": [ + ["x:Account/set", { + "updated": { "user-tony-example": null }, + "notUpdated": {} + }, "u1"] + ] + }) + } else if is_set { + json!({ + "methodResponses": [ + ["x:Account/set", { + "updated": {}, + "notUpdated": { + "wrong": { "type": "notFound" } + } + }, "u1"] + ] + }) + } else { + json!({ + "methodResponses": [ + ["x:Account/query", { + "ids": [ + "user-tony-other", + "user-alice-example", + "user-tony-example" + ] + }, "q1"], + ["x:Account/get", { + "list": [ + { + "id": "user-tony-other", + "name": "tony", + "@type": "User", + "domainId": "dom-other" + }, + { + "id": "user-alice-example", + "name": "alice", + "@type": "User", + "domainId": "dom-example" + }, + { + "id": "user-tony-example", + "name": "tony", + "@type": "User", + "domainId": "dom-example" + } + ] + }, "g1"] + ] + }) + }; + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body.to_string())) + .unwrap() + } + }), + ); + 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).await.ok(); + }); + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + .unwrap(); + let dir = StalwartDirectory::new(http, format!("http://{addr}"), "test-token"); + + let found = dir.lookup_mailbox("tony@example.test").await; + assert!( + found.ok, + "tony@example.test must find the User listed as tony on example.test: {}", + found.note + ); + let account = found.account.expect("tony on example.test"); + assert_eq!(account.id, "user-tony-example"); + assert_eq!(account.status, "user"); + assert_ne!(account.id, "user-tony-other"); + assert_ne!(account.id, "user-alice-example"); + + let other = dir.lookup_mailbox("tony@other.test").await; + assert!( + other.ok, + "tony@other.test must find the other domain, not example.test: {}", + other.note + ); + assert_eq!( + other.account.as_ref().map(|a| a.id.as_str()), + Some("user-tony-other") + ); + + let set = dir + .set_mailbox_password(SetMailboxPasswordInput { + mailbox: "tony@example.test".into(), + password: concat!("unit-test-only-", "secret").into(), + account_id: None, + }) + .await; + assert!( + set.ok, + "password card must set the password on user-tony-example: {}", + set.note + ); + assert_eq!(set.source, "stalwart"); + assert!(!set.note.contains("unit-test-only-secret")); + assert!(!set.note.contains("mailbox address required")); + assert!(!set.note.contains("No mailbox")); + assert!(!set.note.contains("AccountPassword")); + + let calls = seen.lock().await; + assert!( + calls.iter().any(|c| { + (c.contains("x:Account/set") || c.contains("Account/set")) + && c.contains("\"user-tony-example\"") + && c.contains("Password") + && c.contains("unit-test-only-secret") + && !c.contains("user-tony-other") + && !c.contains("user-alice-example") + && !c.contains("AccountPassword") + }), + "password set must target user-tony-example; calls={calls:?}" + ); + serve.abort(); + let _ = serve.await; + } + /// Named contract: live create maps wire-mock set response (no body leak on error). #[tokio::test] async fn stalwart_directory_create_from_jmap_wire_mock() { @@ -2508,6 +2899,7 @@ mod tests { let input = SetMailboxPasswordInput { mailbox: "hunter@surmount.systems".into(), password: concat!("unit-test-only-", "secret").into(), + account_id: None, }; let dbg = format!("{input:?}"); assert!(dbg.contains("hunter@surmount.systems")); @@ -2523,6 +2915,7 @@ mod tests { .set_mailbox_password(SetMailboxPasswordInput { mailbox: "fixture-operator@mock.surmount.test".into(), password: concat!("unit-test-only-", "secret").into(), + account_id: None, }) .await; assert!(ok.ok, "{}", ok.note); @@ -2539,6 +2932,7 @@ mod tests { .set_mailbox_password(SetMailboxPasswordInput { mailbox: "nobody@mock.surmount.test".into(), password: concat!("unit-test-only-", "secret").into(), + account_id: None, }) .await; assert!(!missing.ok); @@ -2546,6 +2940,34 @@ mod tests { assert!(!missing.note.contains("unit-test-only-secret")); } + /// Named contract: after create, password set may target the new principal + /// id even when the mailbox string has no `@`. + #[tokio::test] + async fn mock_password_set_by_created_id_when_mailbox_has_no_at() { + let dir = MockDirectory::fixture(); + let created = dir + .create_account(CreateAccountInput { + name: "newop".into(), + domain_id: "mock-domain".into(), + description: None, + }) + .await; + assert!(created.ok, "{}", created.note); + let id = created.account.expect("created").id; + assert_eq!(id, "mock-newop"); + let ok = dir + .set_mailbox_password(SetMailboxPasswordInput { + mailbox: "newop".into(), + password: concat!("unit-test-only-", "secret").into(), + account_id: Some(id), + }) + .await; + assert!(ok.ok, "{}", ok.note); + assert_eq!(ok.mailbox.as_deref(), Some("newop@mock.surmount.test")); + assert!(!ok.note.contains("unit-test-only-secret")); + assert!(!ok.note.contains("mailbox address required")); + } + /// Named contract: listing unavailable + token still sets password via Stalwart. #[tokio::test] async fn stalwart_password_only_lists_empty_but_sets_password() { @@ -2613,6 +3035,7 @@ mod tests { .set_mailbox_password(SetMailboxPasswordInput { mailbox: "hunter@surmount.systems".into(), password: concat!("unit-test-only-", "secret").into(), + account_id: None, }) .await; assert!(result.ok, "{}", result.note); @@ -2718,6 +3141,7 @@ mod tests { .set_mailbox_password(SetMailboxPasswordInput { mailbox: "hunter@surmount.systems".into(), password: concat!("unit-test-only-", "secret").into(), + account_id: None, }) .await; assert!( diff --git a/crates/management-ui/src/http3.rs b/crates/management-ui/src/http3.rs index 8277810..dbb3681 100644 --- a/crates/management-ui/src/http3.rs +++ b/crates/management-ui/src/http3.rs @@ -182,7 +182,7 @@ pub fn require_http3_bind(enable: bool, listen_is_https: bool) -> Result<(), Str #[cfg(test)] mod tests { use super::*; - use crate::proxy_splora::{ + use crate::proxy_vaultwarden::splora::{ DEFAULT_BODY_LIMIT_BYTES, DEFAULT_QUEUE_PATH, SploraInstance, SploraProxyConfig, splora_proxy_middleware, }; @@ -370,6 +370,7 @@ mod tests { queue_socket: PathBuf::from("/tmp/splora-h3-queue-unused.sock"), queue_path: DEFAULT_QUEUE_PATH.into(), body_limit_bytes: DEFAULT_BODY_LIMIT_BYTES, + portal_host: None, }; let http3_cfg = Http3Config { enable: true, diff --git a/crates/management-ui/src/main.rs b/crates/management-ui/src/main.rs index 31aa41d..1212130 100644 --- a/crates/management-ui/src/main.rs +++ b/crates/management-ui/src/main.rs @@ -725,11 +725,9 @@ async fn security_headers_middleware( // Discovery eligibility stays on the original onion Host / URI so // Onion-Location is not emitted on `.onion` or CleartextSocket. let (csp_host, vault_path) = if crate::onion_discovery::host_is_onion(&host) { - match crate::onion_discovery::match_onion_vhost_rewrite( - &path, - &state.config.onion_discovery, - ) { - Some((clearnet, new_path)) => (clearnet, new_path), + match crate::onion_discovery::match_onion_host_rewrite(&host, &state.config.onion_discovery) + { + Some(clearnet) => (clearnet, path.clone()), None => (host.clone(), path.clone()), } } else { @@ -1016,7 +1014,10 @@ async fn auth_session_create( response } -fn resolve_session_event(headers: &HeaderMap, body: &Bytes) -> Result { +fn resolve_session_event( + headers: &HeaderMap, + body: &Bytes, +) -> Result { if let Some(auth) = header_str(headers, "authorization") && (auth.to_ascii_lowercase().starts_with("nostr ") || auth.starts_with("Nostr ")) { @@ -1629,8 +1630,8 @@ async fn apex_public_host_middleware( let services = state.config.services_hostname.as_str(); let path_only = path.split('?').next().unwrap_or(path); - // Router::layer runs after routing. Onion `/_o/{host}/.well-known/mta-sts.txt` - // does not match the policy route; serve here after Host rewrite. + // 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" && should_serve_mta_sts_policy( state.config.mta_sts_mode, @@ -1683,11 +1684,10 @@ async fn apex_public_host_middleware( next.run(request).await } -/// Onion Host + `/_o/{mapped-clearnet-host}` selects that Host's surface. +/// Onion request Host selects that site's clearnet surface (per-site v3). /// -/// Strips the discriminator and rewrites Host so extra vhosts and MTA-STS -/// policy serve on the shared v3. Unmatched `/_o/{host}` stays unrewritten -/// (404 unless that path is already a console route). `{onion}/` stays console. +/// Rewrites Host to the mapped clearnet name and keeps the path. There is no +/// shared `/_o/{host}` discriminator (that correlated sites on one onion). async fn onion_vhost_rewrite_middleware( State(state): State>, mut request: Request, @@ -1697,19 +1697,14 @@ async fn onion_vhost_rewrite_middleware( if !crate::onion_discovery::host_is_onion(&host) { return next.run(request).await; } - let Some((clearnet, new_path)) = crate::onion_discovery::match_onion_vhost_rewrite( - request.uri().path(), - &state.config.onion_discovery, - ) else { + let Some(clearnet) = + crate::onion_discovery::match_onion_host_rewrite(&host, &state.config.onion_discovery) + else { return next.run(request).await; }; if let Ok(hv) = HeaderValue::from_str(&clearnet) { request.headers_mut().insert(header::HOST, hv); } - if let Some(uri) = crate::onion_discovery::rewrite_uri_path_keep_query(request.uri(), &new_path) - { - *request.uri_mut() = uri; - } next.run(request).await } @@ -2196,31 +2191,60 @@ mod edge_wire_tests { format!("http://{FIXTURE_ONION_HOST}") } + fn onion_location_for( + cfg: &crate::onion_discovery::OnionDiscoveryConfig, + host: &str, + path: &str, + ) -> String { + let mapping = cfg + .lookup(host) + .unwrap_or_else(|| panic!("expected onion mapping for {host}")); + let uri: axum::http::Uri = path.parse().unwrap_or_else(|_| panic!("uri {path}")); + crate::onion_discovery::onion_location_value(mapping, &uri) + .unwrap_or_else(|| panic!("Onion-Location for {host}")) + } + fn fixture_onion_discovery() -> crate::onion_discovery::OnionDiscoveryConfig { + let extra: Vec<_> = crate::onion_discovery::per_site_mappings( + "example.test", + "services.example.test", + &[] as &[&str], + ) + .into_iter() + .filter(|m| m.clearnet_host != "services.example.test") + .collect(); crate::onion_discovery::build_onion_discovery( "example.test", "services.example.test", Some(&fixture_onion_url()), true, true, - Vec::new(), + extra, &[], &[], &[] as &[&str], ) } - /// Auto-derive plus extra static Hosts (same path as process-start map). + /// Per-site onions plus extra static Hosts. Services keep the fixture v3. fn onion_discovery_with_extra_hosts( extra_hosts: &[&str], ) -> crate::onion_discovery::OnionDiscoveryConfig { + let extra: Vec<_> = crate::onion_discovery::per_site_mappings( + "example.test", + "services.example.test", + extra_hosts, + ) + .into_iter() + .filter(|m| m.clearnet_host != "services.example.test") + .collect(); crate::onion_discovery::build_onion_discovery( "example.test", "services.example.test", Some(&fixture_onion_url()), true, true, - Vec::new(), + extra, &[], &[], extra_hosts, @@ -2272,13 +2296,23 @@ mod edge_wire_tests { .and_then(|v| v.to_str().ok()) .unwrap_or(""); assert_eq!(ol, onion_location, "Onion-Location"); + assert!( + !ol.contains("/_o/"), + "Onion-Location must not use a shared /_o/{{host}} prefix: {ol}" + ); + let onion_host = onion_location + .trim_start_matches("http://") + .trim_start_matches("https://") + .split('/') + .next() + .unwrap_or(""); let alt = headers .get("alt-svc") .and_then(|v| v.to_str().ok()) .unwrap_or(""); assert_eq!( alt, - format!("h2=\"{FIXTURE_ONION_HOST}:443\"; ma=86400; persist=1"), + format!("h2=\"{onion_host}:443\"; ma=86400; persist=1"), "Alt-Svc" ); } @@ -2478,7 +2512,7 @@ mod edge_wire_tests { use surmount_management_ui::ban::MemoryBanBackend; let secret = b"test-session-secret-ban-matrix!!!!"; - let allow_keys = nostr::Keys::generate(); + let allow_keys = nostr::prelude::Keys::generate(); let allow_hex = allow_keys.public_key().to_hex(); let ban = BanGuard::with_backend( BanEnforcement::Enforce, @@ -2572,7 +2606,7 @@ mod edge_wire_tests { ); // 5) Presented NIP-98 not allowlisted on protected path -> ban. - let evil = nostr::Keys::generate(); + let evil = nostr::prelude::Keys::generate(); let domains_url = format!("{base}/api/v1/domains"); let event = sign_nip98_event( &evil, @@ -2634,7 +2668,7 @@ mod edge_wire_tests { #[tokio::test] async fn auth_mode_nostr_gates_without_cookie() { let secret = b"test-session-secret-for-http-gate!!"; - let k = nostr::Keys::generate(); + let k = nostr::prelude::Keys::generate(); let hex = k.public_key().to_hex(); let state = test_state_nostr(&hex, secret); let app = build_router(state); @@ -2691,7 +2725,7 @@ mod edge_wire_tests { }; let secret = b"test-session-secret-for-http-sess!!"; - let keys = nostr::Keys::generate(); + let keys = nostr::prelude::Keys::generate(); let hex = keys.public_key().to_hex(); let state = test_state_nostr(&hex, secret); let app = build_router(state); @@ -2768,7 +2802,7 @@ mod edge_wire_tests { #[tokio::test] async fn auth_session_without_connect_info_is_json_401_not_500() { let secret = b"test-session-secret-for-h3-sess!!"; - let keys = nostr::Keys::generate(); + let keys = nostr::prelude::Keys::generate(); let hex = keys.public_key().to_hex(); let state = test_state_nostr(&hex, secret); let app = build_router(state); @@ -2812,7 +2846,7 @@ mod edge_wire_tests { }; let secret = b"test-session-secret-for-h3-ok!!!!"; - let keys = nostr::Keys::generate(); + let keys = nostr::prelude::Keys::generate(); let hex = keys.public_key().to_hex(); let state = test_state_nostr(&hex, secret); let app = build_router(state); @@ -2863,7 +2897,7 @@ mod edge_wire_tests { let backend = MemoryBanBackend::new(vec![]); let ban = BanGuard::with_backend(BanEnforcement::Enforce, Box::new(backend)); let secret = b"test-session-secret-for-h3-ban!!"; - let keys = nostr::Keys::generate(); + let keys = nostr::prelude::Keys::generate(); let hex = keys.public_key().to_hex(); let state = test_state_nostr_with_ban(&hex, secret, ban); let check = state.clone(); @@ -3565,7 +3599,7 @@ mod edge_wire_tests { /// Named contract: extra Host serves its own document root; www alias same /// root; apex stays Surmount public site; services stays console; unknown /// Host does not leak extra files; path traversal 404; extra static Hosts - /// auto-map dual onion discovery with a Host discriminator prefix. + /// emit Onion-Location at that Host's own onion root (not a shared prefix). #[tokio::test] async fn primary_edge_extra_static_vhost_serves_own_root() { let testdata = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata"); @@ -3600,6 +3634,23 @@ mod edge_wire_tests { ); config.onion_discovery = onion_discovery_with_extra_hosts(&["extra.test", "www.extra.test", "other.test"]); + let extra_loc = onion_location_for(&config.onion_discovery, "extra.test", "/"); + let extra_onion = config + .onion_discovery + .lookup("extra.test") + .expect("extra mapped") + .onion_host + .clone(); + let other_onion = config + .onion_discovery + .lookup("other.test") + .expect("other mapped") + .onion_host + .clone(); + assert_ne!( + extra_onion, other_onion, + "two extra Hosts must not share a v3 onion" + ); let state = rebuild_state_with_config(config); let app = build_router(state); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -3626,10 +3677,7 @@ mod edge_wire_tests { .await .unwrap(); assert_eq!(extra.status(), reqwest::StatusCode::OK); - assert_both_onion_discovery( - extra.headers(), - &format!("http://{FIXTURE_ONION_HOST}/_o/extra.test/"), - ); + assert_both_onion_discovery(extra.headers(), &extra_loc); let extra_body = extra.text().await.unwrap(); assert!( extra_body.contains("EXTRA-VHOST-MARKER"), @@ -3768,7 +3816,7 @@ mod edge_wire_tests { ); let secret = b"test-session-secret-for-http-gate!!"; - let k = nostr::Keys::generate(); + let k = nostr::prelude::Keys::generate(); let hex = k.public_key().to_hex(); let mut allowlist = std::collections::HashSet::new(); allowlist.insert(hex.to_ascii_lowercase()); @@ -4903,7 +4951,7 @@ mod edge_wire_tests { #[tokio::test] async fn auth_mode_nostr_gates_accounts_api_with_directory_injected() { let secret = b"test-session-secret-for-dir-gate!!"; - let k = nostr::Keys::generate(); + let k = nostr::prelude::Keys::generate(); let hex = k.public_key().to_hex(); let base = test_state_nostr(&hex, secret); let state = Arc::new(AppState { @@ -5294,10 +5342,30 @@ mod edge_wire_tests { let _ = serve.await; } - /// Named contract: apex and www convenience mapping both emit. + /// Named contract: two public Hosts emit two different Onion-Location + /// onion hostnames at each Host's onion root (not `/_o/{host}`). #[tokio::test] - async fn onion_discovery_apex_and_www_convenience() { + async fn two_hosts_emit_distinct_onion_location_headers() { let state = test_state_onion_https(); + let cfg = state.config.onion_discovery.clone(); + let apex_onion = cfg + .lookup("example.test") + .expect("apex mapped") + .onion_host + .clone(); + let www_onion = cfg + .lookup("www.example.test") + .expect("www mapped") + .onion_host + .clone(); + assert_ne!( + apex_onion, www_onion, + "each public Host must have its own v3 onion" + ); + let apex_loc = onion_location_for(&cfg, "example.test", "/"); + let www_loc = onion_location_for(&cfg, "www.example.test", "/"); + assert_eq!(apex_loc, format!("http://{apex_onion}/")); + assert_eq!(www_loc, format!("http://{www_onion}/")); let app = build_router(state); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -5310,19 +5378,22 @@ mod edge_wire_tests { .ok(); }); let client = reqwest::Client::new(); - for host in ["example.test", "www.example.test"] { - let res = client - .get(format!("http://{addr}/")) - .header("Host", host) - .send() - .await - .unwrap(); - assert_eq!(res.status(), reqwest::StatusCode::OK, "host {host}"); - assert_both_onion_discovery( - res.headers(), - &format!("http://{FIXTURE_ONION_HOST}/_o/{host}/"), - ); - } + let apex = client + .get(format!("http://{addr}/")) + .header("Host", "example.test") + .send() + .await + .unwrap(); + assert_eq!(apex.status(), reqwest::StatusCode::OK); + assert_both_onion_discovery(apex.headers(), &apex_loc); + let www = client + .get(format!("http://{addr}/")) + .header("Host", "www.example.test") + .send() + .await + .unwrap(); + assert_eq!(www.status(), reqwest::StatusCode::OK); + assert_both_onion_discovery(www.headers(), &www_loc); serve.abort(); let _ = serve.await; } @@ -5382,11 +5453,19 @@ mod edge_wire_tests { Some(&fixture_onion_url()), true, true, - Vec::new(), + crate::onion_discovery::per_site_mappings( + "example.test", + "services.example.test", + &[] as &[&str], + ) + .into_iter() + .filter(|m| m.clearnet_host != "services.example.test") + .collect::>(), &["services.example.test".into()], &["services.example.test".into()], &[] as &[&str], ); + let expected_apex = onion_location_for(&config.onion_discovery, "example.test", "/"); let state = rebuild_state_with_config(config); let app = build_router(state); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -5414,10 +5493,7 @@ mod edge_wire_tests { .await .unwrap(); assert_eq!(apex.status(), reqwest::StatusCode::OK); - assert_both_onion_discovery( - apex.headers(), - &format!("http://{FIXTURE_ONION_HOST}/_o/example.test/"), - ); + assert_both_onion_discovery(apex.headers(), &expected_apex); serve.abort(); let _ = serve.await; } @@ -5465,7 +5541,7 @@ mod edge_wire_tests { #[tokio::test] async fn onion_discovery_https_3xx_emits_both() { let secret = b"test-session-secret-for-onion-3xx!!"; - let k = nostr::Keys::generate(); + let k = nostr::prelude::Keys::generate(); let hex = k.public_key().to_hex(); let base = test_state_nostr(&hex, secret); let mut config = base.config.clone(); @@ -5590,6 +5666,11 @@ mod edge_wire_tests { let base = test_state_onion_https(); let mut config = base.config.clone(); config.mta_sts_mode = crate::mta_sts::MtaStsMode::Testing; + let expected = onion_location_for( + &config.onion_discovery, + "mta-sts.example.test", + "/.well-known/mta-sts.txt", + ); let state = rebuild_state_with_config(config); let app = build_router(state); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -5610,10 +5691,7 @@ mod edge_wire_tests { .await .unwrap(); assert_eq!(res.status(), reqwest::StatusCode::OK); - assert_both_onion_discovery( - res.headers(), - &format!("http://{FIXTURE_ONION_HOST}/_o/mta-sts.example.test/.well-known/mta-sts.txt"), - ); + assert_both_onion_discovery(res.headers(), &expected); serve.abort(); let _ = serve.await; } @@ -5676,10 +5754,10 @@ mod edge_wire_tests { let _ = serve.await; } - /// Named contract: onion Host plus extra-vhost discriminator serves that - /// vhost root, not the services console. + /// Named contract: that extra Host's own onion serves that vhost root, + /// not the services console. #[tokio::test] - async fn onion_host_discriminator_serves_extra_vhost_not_console() { + async fn onion_host_per_site_serves_extra_vhost_not_console() { let testdata = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata"); let extra_root = testdata.join("static-vhosts").join("extra.test"); let apex_root = testdata.join("public-site"); @@ -5688,6 +5766,13 @@ mod edge_wire_tests { config.apex_public_root = Some(apex_root); config.static_vhosts.insert("extra.test".into(), extra_root); config.onion_discovery = onion_discovery_with_extra_hosts(&["extra.test"]); + let extra_onion = config + .onion_discovery + .lookup("extra.test") + .expect("extra mapped") + .onion_host + .clone(); + let unknown_onion = crate::onion_discovery::unique_v3_onion_host(99); let state = rebuild_state_with_config(config); let app = build_router(state); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -5702,8 +5787,8 @@ mod edge_wire_tests { }); let client = reqwest::Client::new(); let extra = client - .get(format!("http://{addr}/_o/extra.test/")) - .header("Host", FIXTURE_ONION_HOST) + .get(format!("http://{addr}/")) + .header("Host", extra_onion.as_str()) .send() .await .unwrap(); @@ -5712,23 +5797,23 @@ mod edge_wire_tests { let extra_body = extra.text().await.unwrap(); assert!( extra_body.contains("EXTRA-VHOST-MARKER"), - "onion discriminator must serve extra.test root: {extra_body}" + "that Host's onion must serve extra.test root: {extra_body}" ); assert!( !extra_body.contains("Operator console") && !extra_body.contains("Overview"), - "onion discriminator must not serve console: {extra_body}" + "that Host's onion must not serve console: {extra_body}" ); let unknown = client - .get(format!("http://{addr}/_o/unknown.example/")) - .header("Host", FIXTURE_ONION_HOST) + .get(format!("http://{addr}/")) + .header("Host", unknown_onion.as_str()) .send() .await .unwrap(); let unknown_body = unknown.text().await.unwrap(); assert!( !unknown_body.contains("EXTRA-VHOST-MARKER"), - "unknown discriminator must not leak extra files: {unknown_body}" + "unknown onion must not leak extra files: {unknown_body}" ); let root = client @@ -5742,21 +5827,21 @@ mod edge_wire_tests { let root_body = root.text().await.unwrap(); assert!( root_body.contains("Operator console") || root_body.contains("Overview"), - "onion root must stay the services console: {root_body}" + "services onion root must stay the services console: {root_body}" ); assert!( !root_body.contains("EXTRA-VHOST-MARKER"), - "onion root must not serve extra vhost: {root_body}" + "services onion root must not serve extra vhost: {root_body}" ); serve.abort(); let _ = serve.await; } - /// Named contract: onion Host plus extra/apex discriminator uses public-site - /// CSP (`script-src 'self' 'unsafe-inline'`), not the console nonce CSP. + /// Named contract: extra/apex per-site onions use public-site CSP + /// (`script-src 'self' 'unsafe-inline'`), not the console nonce CSP. /// Discovery headers stay off on `.onion` Host. #[tokio::test] - async fn onion_host_discriminator_extra_vhost_uses_public_site_csp() { + async fn onion_host_per_site_extra_vhost_uses_public_site_csp() { let testdata = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata"); let extra_root = testdata.join("static-vhosts").join("extra.test"); let apex_root = testdata.join("public-site"); @@ -5765,6 +5850,18 @@ mod edge_wire_tests { config.apex_public_root = Some(apex_root); config.static_vhosts.insert("extra.test".into(), extra_root); config.onion_discovery = onion_discovery_with_extra_hosts(&["extra.test"]); + let extra_onion = config + .onion_discovery + .lookup("extra.test") + .expect("extra mapped") + .onion_host + .clone(); + let apex_onion = config + .onion_discovery + .lookup("example.test") + .expect("apex mapped") + .onion_host + .clone(); let state = rebuild_state_with_config(config); let app = build_router(state); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -5779,8 +5876,8 @@ mod edge_wire_tests { }); let client = reqwest::Client::new(); let extra = client - .get(format!("http://{addr}/_o/extra.test/")) - .header("Host", FIXTURE_ONION_HOST) + .get(format!("http://{addr}/")) + .header("Host", extra_onion.as_str()) .send() .await .unwrap(); @@ -5794,25 +5891,25 @@ mod edge_wire_tests { .to_string(); assert!( extra_csp.contains("script-src 'self' 'unsafe-inline'"), - "onion extra discriminator must use public-site CSP: {extra_csp}" + "extra Host onion must use public-site CSP: {extra_csp}" ); assert!( !extra_csp.contains("nonce-"), - "onion extra discriminator must not use console nonce CSP: {extra_csp}" + "extra Host onion must not use console nonce CSP: {extra_csp}" ); let extra_body = extra.text().await.unwrap(); assert!( extra_body.contains("EXTRA-VHOST-MARKER"), - "onion discriminator must still serve extra.test root: {extra_body}" + "extra Host onion must still serve extra.test root: {extra_body}" ); assert!( !extra_body.contains("Operator console") && !extra_body.contains("Overview"), - "onion discriminator must not serve console: {extra_body}" + "extra Host onion must not serve console: {extra_body}" ); let apex = client - .get(format!("http://{addr}/_o/example.test/")) - .header("Host", FIXTURE_ONION_HOST) + .get(format!("http://{addr}/")) + .header("Host", apex_onion.as_str()) .send() .await .unwrap(); @@ -5826,11 +5923,11 @@ mod edge_wire_tests { .to_string(); assert!( apex_csp.contains("script-src 'self' 'unsafe-inline'"), - "onion apex discriminator must use public-site CSP: {apex_csp}" + "apex onion must use public-site CSP: {apex_csp}" ); assert!( !apex_csp.contains("nonce-"), - "onion apex discriminator must not use console nonce CSP: {apex_csp}" + "apex onion must not use console nonce CSP: {apex_csp}" ); let root = client @@ -5849,40 +5946,40 @@ mod edge_wire_tests { .to_string(); assert!( root_csp.contains("nonce-"), - "onion root console must keep nonce CSP: {root_csp}" + "services onion console must keep nonce CSP: {root_csp}" ); assert!( !root_csp.contains("script-src 'self' 'unsafe-inline'"), - "onion root console must not use public-site script-src: {root_csp}" + "services onion console must not use public-site script-src: {root_csp}" ); serve.abort(); let _ = serve.await; } - /// Named contract: onion Host plus MTA-STS discriminator serves the policy - /// body, not a console 404. + /// Named contract: the MTA-STS Host's own onion serves the policy body, + /// not a console 404. #[tokio::test] - async fn onion_host_discriminator_serves_mta_sts_policy() { + async fn onion_host_per_site_serves_mta_sts_policy() { let base = test_state_onion_https(); let mut config = base.config.clone(); config.mta_sts_mode = crate::mta_sts::MtaStsMode::Testing; config.onion_discovery = onion_discovery_with_extra_hosts(&[]); - assert!( - config - .onion_discovery - .lookup("mta-sts.example.test") - .is_some(), - "mta-sts Host must auto-map for onion rewrite" + let mta_map = config + .onion_discovery + .lookup("mta-sts.example.test") + .expect("mta-sts Host must map for onion rewrite"); + let mta_onion = mta_map.onion_host.clone(); + assert_eq!( + crate::onion_discovery::match_onion_host_rewrite(&mta_onion, &config.onion_discovery), + Some("mta-sts.example.test".into()) ); assert_eq!( crate::onion_discovery::match_onion_vhost_rewrite( "/_o/mta-sts.example.test/.well-known/mta-sts.txt", &config.onion_discovery, ), - Some(( - "mta-sts.example.test".into(), - "/.well-known/mta-sts.txt".into() - )) + None, + "shared /_o/{{host}} is not a discovery path" ); let state = rebuild_state_with_config(config); let app = build_router(state); @@ -5902,10 +5999,8 @@ mod edge_wire_tests { .build() .unwrap(); let res = client - .get(format!( - "http://{addr}/_o/mta-sts.example.test/.well-known/mta-sts.txt" - )) - .header("Host", FIXTURE_ONION_HOST) + .get(format!("http://{addr}/.well-known/mta-sts.txt")) + .header("Host", mta_onion.as_str()) .send() .await .unwrap(); @@ -5924,7 +6019,7 @@ mod edge_wire_tests { #[tokio::test] async fn login_page_script_nonce_matches_csp() { let secret = b"test-session-secret-for-csp-nonce!!"; - let k = nostr::Keys::generate(); + let k = nostr::prelude::Keys::generate(); let hex = k.public_key().to_hex(); let state = test_state_nostr(&hex, secret); let app = build_router(state); @@ -5981,7 +6076,7 @@ mod edge_wire_tests { async fn session_login_cookies( client: &reqwest::Client, base: &str, - keys: &nostr::Keys, + keys: &nostr::prelude::Keys, ) -> (String, String, String) { use surmount_management_ui::auth::{ HttpMethod, event_to_nostr_authorization, sign_nip98_event, @@ -6034,7 +6129,7 @@ mod edge_wire_tests { #[tokio::test] async fn csrf_logout_without_token_forbidden() { let secret = b"test-session-secret-for-csrf-out!!"; - let keys = nostr::Keys::generate(); + let keys = nostr::prelude::Keys::generate(); let hex = keys.public_key().to_hex(); let state = test_state_nostr(&hex, secret); let app = build_router(state); @@ -6079,7 +6174,7 @@ mod edge_wire_tests { #[tokio::test] async fn csrf_logout_with_double_submit_succeeds() { let secret = b"test-session-secret-for-csrf-ok!!"; - let keys = nostr::Keys::generate(); + let keys = nostr::prelude::Keys::generate(); let hex = keys.public_key().to_hex(); let state = test_state_nostr(&hex, secret); let app = build_router(state); @@ -6240,7 +6335,7 @@ mod edge_wire_tests { #[tokio::test] async fn account_create_cookie_auth_requires_csrf() { let secret = b"test-session-secret-for-acct-csrf!"; - let keys = nostr::Keys::generate(); + let keys = nostr::prelude::Keys::generate(); let hex = keys.public_key().to_hex(); let base_state = test_state_nostr(&hex, secret); // Keep nostr; mock directory for hermetic mutation. @@ -6401,7 +6496,7 @@ mod edge_wire_tests { #[tokio::test] async fn mail_page_unauth_redirects_create_unauth_is_401() { let secret = b"test-session-secret-for-mail-unauth"; - let keys = nostr::Keys::generate(); + let keys = nostr::prelude::Keys::generate(); let hex = keys.public_key().to_hex(); let state = test_state_nostr_mock_dir(&hex, secret); let app = build_router(state); @@ -6447,7 +6542,7 @@ mod edge_wire_tests { #[tokio::test] async fn account_create_admin_contracts_refuse_and_success() { let secret = b"test-session-secret-for-create-ok!!"; - let keys = nostr::Keys::generate(); + let keys = nostr::prelude::Keys::generate(); let hex = keys.public_key().to_hex(); let map_path = crate::config::unused_console_accounts_path(); let state = test_state_nostr_mock_map(&hex, secret, map_path.clone()); @@ -6640,16 +6735,448 @@ mod edge_wire_tests { let _ = serve.await; } + /// Named contract: create still sets the password when Stalwart returns no + /// emailAddress. An id-only created object and a local-part echo both lack + /// `@`. The password PATCH must target that created id. A list row whose + /// address is only the local-part must not be enough, because lookup of + /// `local@primary` does not match it. The form posts `name`, not the preview. + #[tokio::test] + async fn account_create_sets_password_on_created_id_when_jmap_has_no_email() { + use axum::body::Body; + use axum::extract::Request; + use axum::http::{StatusCode as HyperStatus, header}; + use axum::response::Response; + use surmount_management_ui::console_accounts::{ConsoleRole, load_console_accounts}; + use tokio::sync::Mutex; + + let secret = concat!("unit-test-only-", "secret"); + let seen = Arc::new(Mutex::new(Vec::::new())); + let seen_h = seen.clone(); + let wire = axum::Router::new().route( + "/jmap", + post(move |req: Request| { + let seen = seen_h.clone(); + async move { + let bytes = axum::body::to_bytes(req.into_body(), 64 * 1024) + .await + .unwrap_or_default(); + let incoming: serde_json::Value = + serde_json::from_slice(&bytes).unwrap_or(serde_json::json!({})); + seen.lock().await.push(incoming.to_string()); + let method = incoming["methodCalls"][0][0].as_str().unwrap_or(""); + let args = &incoming["methodCalls"][0][1]; + let body = if method.contains("Domain") { + serde_json::json!({ + "methodResponses": [ + ["x:Domain/query", {"ids": ["dom-9"]}, "dq1"], + ["x:Domain/get", { + "list": [{"id": "dom-9", "name": "example.test"}] + }, "dg1"] + ] + }) + } else if args.get("update").is_some() { + let update_id = args["update"] + .as_object() + .and_then(|m| m.keys().next().cloned()) + .unwrap_or_default(); + let mut updated = serde_json::Map::new(); + updated.insert(update_id, serde_json::Value::Null); + serde_json::json!({ + "methodResponses": [[ + "x:Account/set", + { + "updated": serde_json::Value::Object(updated), + "notUpdated": {} + }, + "u1" + ]] + }) + } else if args.get("create").is_some() { + let name = args["create"]["new1"]["name"].as_str().unwrap_or(""); + let created = if name == "lean" { + serde_json::json!({"id": "live-lean", "name": "lean"}) + } else { + serde_json::json!({"id": "live-tony"}) + }; + serde_json::json!({ + "methodResponses": [[ + "x:Account/set", + {"created": {"new1": created}}, + "c1" + ]] + }) + } else { + // No emailAddress. Lookup of local@primary cannot match `name`. + serde_json::json!({ + "methodResponses": [ + ["x:Account/query", {"ids": ["live-tony", "live-lean"]}, "q1"], + ["x:Account/get", { + "list": [ + {"id": "live-tony", "name": "tony", "@type": "User"}, + {"id": "live-lean", "name": "lean", "@type": "User"} + ] + }, "g1"] + ] + }) + }; + Response::builder() + .status(HyperStatus::OK) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body.to_string())) + .unwrap() + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mock_addr = listener.local_addr().unwrap(); + let mock_serve = tokio::spawn(async move { + axum::serve(listener, wire).await.ok(); + }); + + let session_secret = b"test-session-secret-for-create-id"; + let keys = nostr::prelude::Keys::generate(); + let hex = keys.public_key().to_hex(); + let map_path = crate::config::unused_console_accounts_path(); + let base_state = test_state_nostr(&hex, session_secret); + let mut config = base_state.config.clone(); + config.console_accounts_path = map_path.clone(); + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + .unwrap(); + let state = Arc::new(AppState { + http: http.clone(), + rate_limiter: None, + ban: BanGuard::with_backend( + BanEnforcement::Off, + Box::new(surmount_management_ui::ban::MemoryBanBackend::new(vec![])), + ), + directory: crate::directory::directory_stalwart( + http, + format!("http://{mock_addr}"), + "unit-test-token", + ), + config, + }); + let app = build_router(state); + 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::new(); + let base = format!("http://{addr}"); + let (session_pair, _login, _blob) = session_login_cookies(&client, &base, &keys).await; + let csrf = session_bound_from_pair(&session_pair, session_secret); + + async fn post_create( + client: &reqwest::Client, + base: &str, + session_pair: &str, + csrf: &str, + body: serde_json::Value, + ) -> (reqwest::StatusCode, String, serde_json::Value) { + let resp = client + .post(format!("{base}/api/v1/accounts")) + .header(reqwest::header::COOKIE, session_pair) + .header(CSRF_HEADER_NAME, csrf) + .json(&body) + .send() + .await + .unwrap(); + let status = resp.status(); + let text = resp.text().await.unwrap(); + let v = serde_json::from_str(&text).unwrap_or(serde_json::json!({})); + (status, text, v) + } + + let (st, text, body) = post_create( + &client, + &base, + &session_pair, + &csrf, + serde_json::json!({ + "name": "tony", + "description": "Tony V (Lean)", + "password": secret, + "confirm": secret, + "role": "administrator", + "csrf": csrf + }), + ) + .await; + assert_eq!(st, reqwest::StatusCode::OK, "{body}"); + assert_eq!(body["ok"], true, "{body}"); + assert_eq!(body["password_set"], true, "{body}"); + assert_eq!(body["address"], "tony@example.test", "{body}"); + assert_eq!(body["account"]["id"], "live-tony", "{body}"); + assert_eq!(body["account"]["address"], "tony@example.test", "{body}"); + assert!( + !text.contains("mailbox address required"), + "id-only create must not refuse the password" + ); + assert!(!text.contains(secret), "response must not echo the secret"); + assert!( + body.get("error").is_none() || body["error"].is_null(), + "success must not carry error: {body}" + ); + + let (st, text, body) = post_create( + &client, + &base, + &session_pair, + &csrf, + serde_json::json!({ + "name": "lean", + "password": secret, + "confirm": secret, + "role": "administrator", + "csrf": csrf + }), + ) + .await; + assert_eq!(st, reqwest::StatusCode::OK, "{body}"); + assert_eq!(body["ok"], true, "{body}"); + assert_eq!(body["password_set"], true, "{body}"); + assert_eq!(body["address"], "lean@example.test", "{body}"); + assert_eq!(body["account"]["id"], "live-lean", "{body}"); + assert_eq!(body["account"]["address"], "lean@example.test", "{body}"); + assert!( + !text.contains("mailbox address required"), + "local-part echo must not refuse the password" + ); + assert!(!text.contains(secret)); + + let calls = seen.lock().await; + let mut domains = 0usize; + let mut creates = Vec::new(); + let mut updates = Vec::new(); + let mut other = 0usize; + for raw in calls.iter() { + let v: serde_json::Value = serde_json::from_str(raw).unwrap(); + let method = v["methodCalls"][0][0].as_str().unwrap_or(""); + let args = &v["methodCalls"][0][1]; + if method.contains("Domain") { + domains += 1; + } else if args.get("create").is_some() { + creates.push(v); + } else if args.get("update").is_some() { + updates.push(v); + } else { + other += 1; + } + } + assert_eq!(domains, 2, "each create looks up the primary domain id"); + assert_eq!(creates.len(), 2, "two mailbox creates"); + assert_eq!( + updates.len(), + 2, + "password must PATCH the created principal id" + ); + assert_eq!( + other, 0, + "email lookup must not run when create returns no emailAddress" + ); + let mut saw_tony = false; + let mut saw_lean = false; + for v in &creates { + let obj = &v["methodCalls"][0][1]["create"]["new1"]; + assert_eq!(obj["@type"], "User"); + assert_ne!(obj["@type"], "Group"); + let name = obj["name"].as_str().unwrap_or(""); + assert!(!name.contains('@'), "name must be the local-part"); + assert_eq!(obj["domainId"], "dom-9"); + assert_ne!(obj["domainId"], "example.test"); + assert!( + obj["credentials"] + .as_object() + .expect("credentials") + .is_empty() + ); + assert_eq!(obj["roles"]["@type"], "User"); + let raw = v.to_string(); + assert!(!raw.contains("AccountPassword")); + assert!(!raw.contains(secret), "create must not carry the password"); + match name { + "tony" => { + assert_eq!(obj["description"], "Tony V (Lean)"); + saw_tony = true; + } + "lean" => { + assert!(obj.get("description").is_none()); + saw_lean = true; + } + other_name => panic!("unexpected create name {other_name}"), + } + } + assert!(saw_tony && saw_lean, "both local-parts must be created"); + for id in ["live-tony", "live-lean"] { + let found = updates.iter().find(|v| { + v["methodCalls"][0][1]["update"] + .as_object() + .is_some_and(|m| m.contains_key(id)) + }); + let Some(v) = found else { + panic!("missing password update for {id}"); + }; + let patch = &v["methodCalls"][0][1]["update"][id]; + assert_eq!(patch["credentials"]["0"]["@type"], "Password"); + assert_eq!(patch["credentials"]["0"]["secret"].as_str(), Some(secret)); + let update_obj = v["methodCalls"][0][1]["update"].as_object().unwrap(); + assert!(!update_obj.contains_key("tony")); + assert!(!update_obj.contains_key("lean")); + assert!(!v.to_string().contains("AccountPassword")); + } + + let loaded = load_console_accounts(&map_path).unwrap(); + let tony = loaded + .by_mailbox("tony@example.test") + .expect("console row is the composed address"); + assert_eq!(tony.role, ConsoleRole::Administrator); + assert!(tony.npub_hex.is_none(), "empty npub stays IMAP/SMTP only"); + assert!(loaded.by_mailbox("tony").is_none()); + assert!(loaded.by_mailbox("live-tony").is_none()); + let lean = loaded.by_mailbox("lean@example.test").expect("lean row"); + assert_eq!(lean.role, ConsoleRole::Administrator); + assert!(loaded.by_mailbox("lean").is_none()); + assert!(loaded.by_mailbox("live-lean").is_none()); + + drop(calls); + let _ = std::fs::remove_file(&map_path); + serve.abort(); + mock_serve.abort(); + let _ = serve.await; + let _ = mock_serve.await; + } + + /// Named contract: a submitted npub on create is stored on that mailbox + /// and can be read back. This is someone else's key, not the admin key. + /// The separate attach form is `grant_console_attaches_npub1_to_existing_mailbox`. + #[tokio::test] + async fn account_create_persists_submitted_npub_readable_on_mailbox() { + use nostr::prelude::ToBech32; + use surmount_management_ui::auth::{ + HttpMethod, event_to_nostr_authorization, sign_nip98_event, + }; + use surmount_management_ui::console_accounts::{ConsoleRole, load_console_accounts}; + + let secret = concat!("unit-test-only-", "secret"); + let session_secret = b"test-session-secret-for-npub-create"; + let admin_keys = nostr::prelude::Keys::generate(); + let admin_hex = admin_keys.public_key().to_hex(); + let guest_keys = nostr::prelude::Keys::generate(); + let guest_hex = guest_keys.public_key().to_hex().to_ascii_lowercase(); + let guest_npub = guest_keys.public_key().to_bech32().unwrap(); + assert!( + guest_npub.starts_with("npub1"), + "synthetic fixture must be bech32 npub1" + ); + assert_ne!(guest_hex, admin_hex.to_ascii_lowercase()); + let map_path = crate::config::unused_console_accounts_path(); + let state = test_state_nostr_mock_map(&admin_hex, session_secret, map_path.clone()); + let app = build_router(state); + 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::new(); + let base = format!("http://{addr}"); + let (session_pair, _login, _blob) = + session_login_cookies(&client, &base, &admin_keys).await; + let csrf = session_bound_from_pair(&session_pair, session_secret); + let resp = client + .post(format!("{base}/api/v1/accounts")) + .header(reqwest::header::COOKIE, &session_pair) + .header(CSRF_HEADER_NAME, &csrf) + .json(&serde_json::json!({ + "name": "guestbox", + "password": secret, + "confirm": secret, + "npub": guest_npub, + "role": "user", + "csrf": csrf + })) + .send() + .await + .unwrap(); + let status = resp.status(); + let text = resp.text().await.unwrap(); + let body: serde_json::Value = serde_json::from_str(&text).unwrap_or(serde_json::json!({})); + assert_eq!(status, reqwest::StatusCode::OK, "{body}"); + assert_eq!(body["ok"], true, "{body}"); + assert_eq!(body["password_set"], true, "{body}"); + assert_eq!(body["address"], "guestbox@example.test", "{body}"); + assert!(!text.contains(secret)); + assert!(!text.contains(&guest_npub), "response must not echo npub"); + + let loaded = load_console_accounts(&map_path).unwrap(); + let row = loaded + .by_mailbox("guestbox@example.test") + .expect("create stores the composed address"); + assert_eq!(row.npub_normalized(), Some(guest_hex.clone())); + assert_eq!(row.role, ConsoleRole::User); + assert!(loaded.by_mailbox("guestbox").is_none()); + assert!(loaded.by_mailbox("guestbox@mock.surmount.test").is_none()); + + let (guest_session, _, _) = session_login_cookies(&client, &base, &guest_keys).await; + assert!( + guest_session.starts_with("surmount_session="), + "stored npub must log into the services portal" + ); + let me_url = format!("{base}/api/v1/auth/me"); + let me_ev = sign_nip98_event( + &guest_keys, + &me_url, + HttpMethod::GET, + Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + ), + ) + .unwrap(); + let me = client + .get(&me_url) + .header( + reqwest::header::AUTHORIZATION, + event_to_nostr_authorization(&me_ev), + ) + .send() + .await + .unwrap(); + assert_eq!(me.status(), reqwest::StatusCode::OK); + let me_body: serde_json::Value = me.json().await.unwrap(); + assert_eq!(me_body["role"], "user", "{me_body}"); + assert_eq!(me_body["mailbox"], "guestbox@example.test", "{me_body}"); + + let _ = std::fs::remove_file(&map_path); + let _ = std::fs::remove_file(format!("{}.lock", map_path.display())); + serve.abort(); + let _ = serve.await; + } + /// Named contract: User cannot create or touch other passwords; own password /// works; operator pages 403. Allowlist with no map row stays Administrator. #[tokio::test] async fn console_user_role_lockout_and_allowlist_admin() { let secret = b"test-session-secret-for-role-gate!!"; - let admin_keys = nostr::Keys::generate(); + let admin_keys = nostr::prelude::Keys::generate(); let admin_hex = admin_keys.public_key().to_hex(); - let user_keys = nostr::Keys::generate(); + let user_keys = nostr::prelude::Keys::generate(); let user_hex = user_keys.public_key().to_hex(); - let stranger = nostr::Keys::generate(); + let stranger = nostr::prelude::Keys::generate(); let map_path = crate::config::unused_console_accounts_path(); let mut file = surmount_management_ui::console_accounts::ConsoleAccountFile::default(); file.upsert_mailbox( @@ -6794,9 +7321,9 @@ mod edge_wire_tests { #[tokio::test] async fn leftover_cookie_password_is_403_and_mail_does_not_prefill_hunter() { let secret = b"test-session-secret-for-leftover-pw"; - let admin_keys = nostr::Keys::generate(); + let admin_keys = nostr::prelude::Keys::generate(); let admin_hex = admin_keys.public_key().to_hex(); - let user_keys = nostr::Keys::generate(); + let user_keys = nostr::prelude::Keys::generate(); let user_hex = user_keys.public_key().to_hex(); let map_path = crate::config::unused_console_accounts_path(); let mut file = surmount_management_ui::console_accounts::ConsoleAccountFile::default(); @@ -6893,9 +7420,9 @@ mod edge_wire_tests { }; let secret = b"test-session-secret-for-nip98-q-pw"; - let admin_keys = nostr::Keys::generate(); + let admin_keys = nostr::prelude::Keys::generate(); let admin_hex = admin_keys.public_key().to_hex(); - let user_keys = nostr::Keys::generate(); + let user_keys = nostr::prelude::Keys::generate(); let user_hex = user_keys.public_key().to_hex(); let map_path = crate::config::unused_console_accounts_path(); let mut file = surmount_management_ui::console_accounts::ConsoleAccountFile::default(); @@ -7016,9 +7543,9 @@ mod edge_wire_tests { #[tokio::test] async fn user_session_can_patch_own_mailbox_password_not_others() { let secret = b"test-session-secret-for-user-patch!"; - let admin_keys = nostr::Keys::generate(); + let admin_keys = nostr::prelude::Keys::generate(); let admin_hex = admin_keys.public_key().to_hex(); - let user_keys = nostr::Keys::generate(); + let user_keys = nostr::prelude::Keys::generate(); let user_hex = user_keys.public_key().to_hex(); let map_path = crate::config::unused_console_accounts_path(); let mut file = surmount_management_ui::console_accounts::ConsoleAccountFile::default(); @@ -7190,9 +7717,9 @@ mod edge_wire_tests { #[tokio::test] async fn nwc_uri_valid_accepted_nsec_garbage_refused_without_echo() { let secret = b"test-session-secret-for-nwc-store!!"; - let admin_keys = nostr::Keys::generate(); + let admin_keys = nostr::prelude::Keys::generate(); let admin_hex = admin_keys.public_key().to_hex(); - let user_keys = nostr::Keys::generate(); + let user_keys = nostr::prelude::Keys::generate(); let user_hex = user_keys.public_key().to_hex(); let map_path = crate::config::unused_console_accounts_path(); let mut file = surmount_management_ui::console_accounts::ConsoleAccountFile::default(); @@ -7342,11 +7869,11 @@ mod edge_wire_tests { #[tokio::test] async fn grant_console_csrf_user_and_map_refuse() { let secret = b"test-session-secret-for-grant-csrf"; - let admin_keys = nostr::Keys::generate(); + let admin_keys = nostr::prelude::Keys::generate(); let admin_hex = admin_keys.public_key().to_hex(); - let user_keys = nostr::Keys::generate(); + let user_keys = nostr::prelude::Keys::generate(); let user_hex = user_keys.public_key().to_hex(); - let guest_keys = nostr::Keys::generate(); + let guest_keys = nostr::prelude::Keys::generate(); let guest_hex = guest_keys.public_key().to_hex(); let map_dir = std::env::temp_dir().join(format!( "surmount-console-mapdir-{}-{}", @@ -7545,7 +8072,7 @@ mod edge_wire_tests { /// required (live default source unavailable). Synthetic npubs only. #[tokio::test] async fn grant_console_attaches_npub1_to_existing_mailbox() { - use nostr::ToBech32; + use nostr::prelude::ToBech32; use surmount_management_ui::auth::{ HttpMethod, event_to_nostr_authorization, sign_nip98_event, }; @@ -7554,9 +8081,9 @@ mod edge_wire_tests { }; let secret = b"test-session-secret-for-npub-attach"; - let admin_keys = nostr::Keys::generate(); + let admin_keys = nostr::prelude::Keys::generate(); let admin_hex = admin_keys.public_key().to_hex(); - let guest_keys = nostr::Keys::generate(); + let guest_keys = nostr::prelude::Keys::generate(); let guest_hex = guest_keys.public_key().to_hex().to_ascii_lowercase(); let guest_npub = guest_keys.public_key().to_bech32().unwrap(); assert!( @@ -7604,7 +8131,7 @@ mod edge_wire_tests { async fn guest_session( client: &reqwest::Client, session_url: &str, - keys: &nostr::Keys, + keys: &nostr::prelude::Keys, ) -> reqwest::StatusCode { let ev = sign_nip98_event( keys, @@ -7808,7 +8335,7 @@ mod edge_wire_tests { #[tokio::test] async fn console_map_load_fail_does_not_promote_allowlist_admin() { let secret = b"test-session-secret-for-map-fail!!"; - let admin_keys = nostr::Keys::generate(); + let admin_keys = nostr::prelude::Keys::generate(); let admin_hex = admin_keys.public_key().to_hex(); let map_path = crate::config::unused_console_accounts_path(); let mut file = surmount_management_ui::console_accounts::ConsoleAccountFile::default(); @@ -7876,7 +8403,7 @@ mod edge_wire_tests { #[tokio::test] async fn mailbox_password_unauth_is_401() { let secret = b"test-session-secret-for-pw-unauth!"; - let keys = nostr::Keys::generate(); + let keys = nostr::prelude::Keys::generate(); let hex = keys.public_key().to_hex(); let state = test_state_nostr(&hex, secret); let app = build_router(state); @@ -8118,7 +8645,7 @@ mod edge_wire_tests { #[tokio::test] async fn mail_page_authenticated_get_embeds_and_mints_csrf() { let secret = b"test-session-secret-for-mail-csrf!"; - let keys = nostr::Keys::generate(); + let keys = nostr::prelude::Keys::generate(); let hex = keys.public_key().to_hex(); let state = test_state_nostr_mock_dir(&hex, secret); let app = build_router(state); @@ -8192,7 +8719,7 @@ mod edge_wire_tests { #[tokio::test] async fn mailbox_password_csrf_from_mail_page_succeeds_session_only_still_403() { let secret = b"test-session-secret-for-mail-pw!!"; - let keys = nostr::Keys::generate(); + let keys = nostr::prelude::Keys::generate(); let hex = keys.public_key().to_hex(); let state = test_state_nostr_mock_dir(&hex, secret); let app = build_router(state); @@ -8297,7 +8824,7 @@ mod edge_wire_tests { #[tokio::test] async fn mail_page_authenticated_get_csrf_cookie_survives_same_jar_as_session() { let secret = b"test-session-secret-for-mail-jar!!"; - let keys = nostr::Keys::generate(); + let keys = nostr::prelude::Keys::generate(); let hex = keys.public_key().to_hex(); let state = test_state_nostr_mock_dir(&hex, secret); let app = build_router(state); @@ -8433,7 +8960,7 @@ mod edge_wire_tests { #[tokio::test] async fn mailbox_password_session_plus_header_succeeds_without_csrf_cookie() { let secret = b"test-session-secret-for-sync-csrf!"; - let keys = nostr::Keys::generate(); + let keys = nostr::prelude::Keys::generate(); let hex = keys.public_key().to_hex(); let state = test_state_nostr_mock_dir(&hex, secret); let app = build_router(state); @@ -8499,7 +9026,7 @@ mod edge_wire_tests { #[tokio::test] async fn mailbox_password_session_wrong_token_is_403() { let secret = b"test-session-secret-for-wrong-csrf"; - let keys = nostr::Keys::generate(); + let keys = nostr::prelude::Keys::generate(); let hex = keys.public_key().to_hex(); let state = test_state_nostr_mock_dir(&hex, secret); let app = build_router(state); @@ -8549,7 +9076,7 @@ mod edge_wire_tests { #[tokio::test] async fn mailbox_password_session_empty_token_is_403() { let secret = b"test-session-secret-for-empty-csrf"; - let keys = nostr::Keys::generate(); + let keys = nostr::prelude::Keys::generate(); let hex = keys.public_key().to_hex(); let state = test_state_nostr_mock_dir(&hex, secret); let app = build_router(state); @@ -8598,7 +9125,7 @@ mod edge_wire_tests { #[tokio::test] async fn mailbox_password_no_session_with_token_is_401() { let secret = b"test-session-secret-for-nosess-tok"; - let keys = nostr::Keys::generate(); + let keys = nostr::prelude::Keys::generate(); let hex = keys.public_key().to_hex(); let state = test_state_nostr_mock_dir(&hex, secret); let app = build_router(state); @@ -8636,7 +9163,7 @@ mod edge_wire_tests { #[tokio::test] async fn mailbox_password_header_only_no_session_is_401() { let secret = b"test-session-secret-for-hdr-only!!"; - let keys = nostr::Keys::generate(); + let keys = nostr::prelude::Keys::generate(); let hex = keys.public_key().to_hex(); let state = test_state_nostr_mock_dir(&hex, secret); let app = build_router(state); diff --git a/crates/management-ui/src/nft_helper.rs b/crates/management-ui/src/nft_helper.rs index eccf72f..52084f8 100644 --- a/crates/management-ui/src/nft_helper.rs +++ b/crates/management-ui/src/nft_helper.rs @@ -23,7 +23,7 @@ use std::io::{BufRead, BufReader, ErrorKind, Read, Write}; use std::net::IpAddr; use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; +use std::process::{Child, Command, Stdio}; use std::time::Duration; use serde::{Deserialize, Serialize}; @@ -274,6 +274,43 @@ enum HelperTransport { Process(PathBuf), } +/// Spawn the helper. Retry only `ETXTBSY` (os error 26). +/// +/// Tests drop the script `File`, `sync_all`, and rename before spawn. That +/// close-first sequence still returned text-file-busy for `helper-ok` on the +/// Nix builder (shebang exec race). Each attempt is a new [`Command`]. Any +/// other spawn error fail-closes on the first try. The last `ETXTBSY` +/// fail-closes too. +fn spawn_nft_helper(bin: &Path, nft_bin: Option<&Path>) -> Result { + const ATTEMPTS: u32 = 5; + for attempt in 0..ATTEMPTS { + let mut cmd = Command::new(bin); + cmd.arg("apply-json") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some(nft) = nft_bin { + cmd.env("SURMOUNT_BAN_NFT_BIN", nft); + } + match cmd.spawn() { + Ok(child) => return Ok(child), + Err(err) if err.raw_os_error() == Some(26) && attempt + 1 < ATTEMPTS => { + std::thread::sleep(Duration::from_millis(20 * u64::from(attempt + 1))); + } + Err(err) => { + return Err(format!( + "spawn nft helper {}: {err} (fail-closed)", + bin.display() + )); + } + } + } + Err(format!( + "spawn nft helper {}: ETXTBSY retries exhausted (fail-closed)", + bin.display() + )) +} + /// UI-side client. Prefer [`HelperNftClient::unix_socket`] on product hosts. /// /// Does **not** hold CAP_NET_ADMIN. Fail-closed on connect/IO/timeout/protocol. @@ -424,18 +461,7 @@ impl HelperNftClient { } let payload = req.to_json_line()?; - let mut cmd = Command::new(bin); - cmd.arg("apply-json") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - if let Some(nft) = &self.nft_bin { - cmd.env("SURMOUNT_BAN_NFT_BIN", nft); - } - - let mut child = cmd - .spawn() - .map_err(|e| format!("spawn nft helper {}: {e} (fail-closed)", bin.display()))?; + let mut child = spawn_nft_helper(bin, self.nft_bin.as_deref())?; { let mut stdin = child @@ -840,7 +866,8 @@ mod tests { .as_secs() )); std::fs::create_dir_all(&dir).unwrap(); - // Write+fsync+rename so exec is not ETXTBSY (os error 26) on overlay/tmp. + // Drop the writer before rename. Close-first still returned ETXTBSY + // on the builder; spawn_nft_helper retries only that errno. fn write_exec(path: &std::path::Path, body: &str) { use std::io::Write; use std::os::unix::fs::PermissionsExt; diff --git a/crates/management-ui/src/onion_discovery.rs b/crates/management-ui/src/onion_discovery.rs index 78986ff..756732a 100644 --- a/crates/management-ui/src/onion_discovery.rs +++ b/crates/management-ui/src/onion_discovery.rs @@ -7,6 +7,7 @@ use std::collections::BTreeMap; use std::path::Path; +#[cfg(test)] use axum::http::uri::PathAndQuery; use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode, Uri}; use serde::{Deserialize, Serialize}; @@ -23,11 +24,6 @@ pub static ALT_SVC: HeaderName = HeaderName::from_static("alt-svc"); pub const DEFAULT_MA_SECONDS: u64 = 86_400; /// Default onion port for auto-derived mappings. pub const DEFAULT_ONION_PORT: u16 = 443; -/// Path prefix that selects a clearnet Host on the shared v3 onion. -/// -/// Services console stays at `{onion}/` (empty prefix). Apex, www, extra -/// static Hosts, and `mta-sts.{primary}` use `/_o/{clearnet-host}{path}`. -pub const ONION_VHOST_PATH_PREFIX: &str = "/_o"; /// Marker inserted on the local Arti / loopback cleartext socket so discovery /// headers are not emitted as if that bind were TLS. @@ -47,7 +43,9 @@ pub struct OnionMapping { pub onion_authority: String, pub onion_location_enabled: bool, pub alt_svc_enabled: bool, - /// Empty for the services console. Otherwise `/_o/{clearnet_host}`. + /// Always empty. Each public HTTP Host has its own v3 onion, so + /// Onion-Location is `{scheme}://{that-host-onion}{path}` (no shared + /// `/_o/{host}` discriminator; that prefix correlated sites). pub onion_path_prefix: String, } @@ -101,6 +99,12 @@ impl OnionDiscoveryConfig { self.mappings.get(&key) } + /// Incoming onion Host -> mapping for that v3 (per-site onion root). + pub fn lookup_by_onion_host(&self, onion_host: &str) -> Option<&OnionMapping> { + let key = parse_v3_onion_host(onion_host)?; + self.mappings.values().find(|m| m.onion_host == key) + } + pub fn mappings(&self) -> impl Iterator { self.mappings.values() } @@ -158,12 +162,12 @@ impl OnionMapping { /// Auto-derive defaults: port 443, `["h2"]`, ma 86400, both headers on. /// - /// Sets `/_o/{host}` so purple-pill lands on this Host. Callers that - /// map the services hostname must clear `onion_path_prefix`. + /// Onion-Location is onion root plus the clearnet path. Prefix stays empty + /// (per-site v3; never `/_o/{host}`). pub fn auto_derived(clearnet_host: &str, onion_url: &str) -> Option { let onion_host = parse_v3_onion_host(onion_url)?; let scheme = scheme_from_onion_url(onion_url); - let mut mapping = Self::try_new( + Self::try_new( clearnet_host, &onion_host, DEFAULT_ONION_PORT, @@ -172,25 +176,7 @@ impl OnionMapping { DEFAULT_MA_SECONDS, true, true, - )?; - mapping.onion_path_prefix = onion_vhost_path_prefix(&mapping.clearnet_host); - Some(mapping) - } -} - -/// `/_o/{normalized-clearnet-host}` (no trailing slash). -pub fn onion_vhost_path_prefix(clearnet_host: &str) -> String { - let host = normalize_clearnet_host(clearnet_host); - format!("{ONION_VHOST_PATH_PREFIX}/{host}") -} - -/// Apply console-vs-vhost prefix: services hostname stays onion root. -pub fn apply_onion_surface_prefix(mapping: &mut OnionMapping, services_hostname: &str) { - let services = normalize_clearnet_host(services_hostname); - if !services.is_empty() && mapping.clearnet_host == services { - mapping.onion_path_prefix.clear(); - } else if mapping.onion_path_prefix.is_empty() { - mapping.onion_path_prefix = onion_vhost_path_prefix(&mapping.clearnet_host); + ) } } @@ -274,17 +260,30 @@ fn sanitize_protocols(protocols: Vec) -> Vec { out } -/// Public Hosts that auto-map to the process-wide v3 onion. +/// 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. +#[cfg(test)] pub fn auto_clearnet_hosts( primary_domain: &str, services_hostname: &str, extra_static_hosts: impl IntoIterator>, +) -> Vec { + auto_clearnet_hosts_excluding_mail(primary_domain, services_hostname, extra_static_hosts, &[]) +} + +/// Same as [`auto_clearnet_hosts`], also skipping extra mail Hosts. +#[cfg(test)] +pub fn auto_clearnet_hosts_excluding_mail( + primary_domain: &str, + services_hostname: &str, + extra_static_hosts: impl IntoIterator>, + extra_mail_hosts: &[String], ) -> Vec { let mut out = Vec::new(); let apex = normalize_clearnet_host(primary_domain); + let mail_block = mail_hosts_blocked(&apex, extra_mail_hosts); if !apex.is_empty() && !host_is_onion(&apex) { out.push(apex.clone()); let www = format!("www.{apex}"); @@ -302,27 +301,72 @@ pub fn auto_clearnet_hosts( out.push(mta); } } - let mail = if apex.is_empty() { - String::new() - } else { - format!("mail.{apex}") - }; for raw in extra_static_hosts { let host = normalize_clearnet_host(raw.as_ref()); if host.is_empty() || host_is_onion(&host) { continue; } - if !mail.is_empty() && host == mail { + if mail_block.iter().any(|m| m == &host) { continue; } if !out.iter().any(|h| h == &host) { out.push(host); } } + out.into_iter() + .filter(|h| !mail_block.iter().any(|m| m == h)) + .collect() +} + +fn mail_hosts_blocked(apex: &str, extra_mail_hosts: &[String]) -> Vec { + let mut out = Vec::new(); + if !apex.is_empty() { + out.push(format!("mail.{apex}")); + } + for raw in extra_mail_hosts { + let host = normalize_clearnet_host(raw); + if !host.is_empty() && !out.iter().any(|h| h == &host) { + out.push(host); + } + } out } -/// Build the table from an already-resolved onion URL (tests + `from_env`). +fn host_is_blocked_mail(host: &str, mail_block: &[String]) -> bool { + mail_block.iter().any(|m| m == host) +} + +/// Distinct v3 onion URL for tests (56-char label; `seed` changes the host). +#[cfg(test)] +pub fn unique_v3_onion_host(seed: u8) -> String { + let alphabet = b"abcdefghijklmnopqrstuvwxyz234567"; + let mut label = [b'2'; 56]; + label[0] = alphabet[(seed as usize) % alphabet.len()]; + label[1] = alphabet[((seed as usize) / alphabet.len()) % alphabet.len()]; + format!("{}.onion", String::from_utf8_lossy(&label)) +} + +/// One mapping per Host, each with its own v3 onion. Mail Hosts omitted. +#[cfg(test)] +pub fn per_site_mappings( + primary_domain: &str, + services_hostname: &str, + extra_static_hosts: impl IntoIterator>, +) -> Vec { + auto_clearnet_hosts(primary_domain, services_hostname, extra_static_hosts) + .into_iter() + .enumerate() + .filter_map(|(i, host)| { + let onion = unique_v3_onion_host(i as u8); + OnionMapping::auto_derived(&host, &format!("http://{onion}")) + }) + .collect() +} + +/// Build the table. `onion_url` maps **only** the services hostname (console). +/// Other public Hosts need their own onion in `extra` (map file / sites dir). +/// Sharing one v3 across Hosts is a correlation leak; do not auto-copy it. +#[cfg(test)] #[allow(clippy::too_many_arguments)] pub fn build_onion_discovery( primary_domain: &str, @@ -334,25 +378,58 @@ pub fn build_onion_discovery( location_disabled_hosts: &[String], alt_svc_disabled_hosts: &[String], extra_static_hosts: impl IntoIterator>, +) -> OnionDiscoveryConfig { + build_onion_discovery_with_mail( + primary_domain, + services_hostname, + onion_url, + onion_location_enabled, + alt_svc_enabled, + extra, + location_disabled_hosts, + alt_svc_disabled_hosts, + extra_static_hosts, + &[], + ) +} + +#[allow(clippy::too_many_arguments)] +pub fn build_onion_discovery_with_mail( + primary_domain: &str, + services_hostname: &str, + onion_url: Option<&str>, + onion_location_enabled: bool, + alt_svc_enabled: bool, + extra: impl IntoIterator, + location_disabled_hosts: &[String], + alt_svc_disabled_hosts: &[String], + extra_static_hosts: impl IntoIterator>, + extra_mail_hosts: &[String], ) -> OnionDiscoveryConfig { let extra_static: Vec = extra_static_hosts .into_iter() .map(|h| normalize_clearnet_host(h.as_ref())) .filter(|h| !h.is_empty()) .collect(); + let apex = normalize_clearnet_host(primary_domain); + let mail_block = mail_hosts_blocked(&apex, extra_mail_hosts); + let services = normalize_clearnet_host(services_hostname); let mut mappings = BTreeMap::new(); - if let Some(url) = onion_url { - for host in auto_clearnet_hosts(primary_domain, services_hostname, &extra_static) { - if let Some(mut m) = OnionMapping::auto_derived(&host, url) { - apply_onion_surface_prefix(&mut m, services_hostname); - mappings.insert(m.clearnet_host.clone(), m); - } + for m in extra { + if host_is_blocked_mail(&m.clearnet_host, &mail_block) { + continue; } + mappings.insert(m.clearnet_host.clone(), m); } - for mut m in extra { - apply_onion_surface_prefix(&mut m, services_hostname); + if let Some(url) = onion_url + && !services.is_empty() + && !host_is_blocked_mail(&services, &mail_block) + && !mappings.contains_key(&services) + && let Some(m) = OnionMapping::auto_derived(&services, url) + { mappings.insert(m.clearnet_host.clone(), m); } + let _ = extra_static; for host in location_disabled_hosts { let key = normalize_clearnet_host(host); if let Some(m) = mappings.get_mut(&key) { @@ -384,10 +461,19 @@ pub fn onion_discovery_from_env( ) -> OnionDiscoveryConfig { let onion_location_enabled = env_flag("SURMOUNT_ONION_LOCATION_ENABLED", true); let alt_svc_enabled = env_flag("SURMOUNT_ONION_ALT_SVC_ENABLED", true); - let extra = load_map_file_extras(); + let mut extra = load_map_file_extras(); + extra.extend(load_sites_dir_extras()); + extra.extend(load_nickname_published_extras()); let location_disabled = env_host_list("SURMOUNT_ONION_LOCATION_DISABLED_HOSTS"); let alt_disabled = env_host_list("SURMOUNT_ONION_ALT_SVC_DISABLED_HOSTS"); - build_onion_discovery( + let mut mail_hosts = env_host_list("SURMOUNT_EXTRA_MAIL_HOSTNAMES"); + if let Ok(v) = std::env::var("SURMOUNT_MAIL_HOSTNAME") { + let h = normalize_clearnet_host(&v); + if !h.is_empty() { + mail_hosts.push(h); + } + } + build_onion_discovery_with_mail( primary_domain, services_hostname, onion_url, @@ -397,6 +483,7 @@ pub fn onion_discovery_from_env( &location_disabled, &alt_disabled, extra_static_hosts, + &mail_hosts, ) } @@ -434,6 +521,122 @@ fn load_map_file_extras() -> Vec { load_map_file(Path::new(&path)) } +/// Directory of `{clearnet-host}` files whose first line is a v3 onion. +/// `SURMOUNT_ONION_SITES_DIR`. Missing dir is empty (never panics). +fn load_sites_dir_extras() -> Vec { + let path = match std::env::var("SURMOUNT_ONION_SITES_DIR") { + Ok(s) => { + let t = s.trim(); + if t.is_empty() { + return Vec::new(); + } + t.to_string() + } + Err(_) => return Vec::new(), + }; + load_sites_dir(Path::new(&path)) +} + +fn nickname_is_safe(nick: &str) -> bool { + !nick.is_empty() + && nick + .bytes() + .all(|b| matches!(b, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-')) +} + +/// `SURMOUNT_ONION_SITE_NICKNAMES_FILE` JSON object host -> Arti nickname, +/// plus `SURMOUNT_ONION_PUBLISHED_HOSTNAMES_DIR`/{nickname} address files. +fn load_nickname_published_extras() -> Vec { + let nick_path = match std::env::var("SURMOUNT_ONION_SITE_NICKNAMES_FILE") { + Ok(s) => { + let t = s.trim(); + if t.is_empty() { + return Vec::new(); + } + t.to_string() + } + Err(_) => return Vec::new(), + }; + let dir = match std::env::var("SURMOUNT_ONION_PUBLISHED_HOSTNAMES_DIR") { + Ok(s) => { + let t = s.trim(); + if t.is_empty() { + return Vec::new(); + } + t.to_string() + } + Err(_) => return Vec::new(), + }; + let contents = match std::fs::read_to_string(&nick_path) { + Ok(s) => s, + Err(_) => return Vec::new(), + }; + let parsed: BTreeMap = match serde_json::from_str(&contents) { + Ok(v) => v, + Err(err) => { + tracing::warn!( + path = %nick_path, + error = %err, + "SURMOUNT_ONION_SITE_NICKNAMES_FILE is not valid JSON; skipping" + ); + return Vec::new(); + } + }; + let dir = Path::new(&dir); + let mut out = Vec::new(); + for (host, nick) in parsed { + if !nickname_is_safe(&nick) { + continue; + } + let p = dir.join(&nick); + let Some(url) = crate::config::onion_url_from_file(&p) else { + continue; + }; + if let Some(m) = OnionMapping::auto_derived(&host, &url) { + out.push(m); + } + } + out +} + +fn load_sites_dir(dir: &Path) -> Vec { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return Vec::new(), + }; + let mut out = Vec::new(); + let mut seen = 0usize; + for ent in entries.flatten() { + if seen >= 128 { + break; + } + let p = ent.path(); + let ft = match ent.file_type() { + Ok(t) => t, + Err(_) => continue, + }; + if !ft.is_file() { + continue; + } + seen += 1; + let name = p.file_name().and_then(|s| s.to_str()).unwrap_or(""); + if name.starts_with('.') { + continue; + } + let host = normalize_clearnet_host(name); + if host.is_empty() || host_is_onion(&host) { + continue; + } + let Some(url) = crate::config::onion_url_from_file(&p) else { + continue; + }; + if let Some(m) = OnionMapping::auto_derived(&host, &url) { + out.push(m); + } + } + out +} + #[derive(Debug, Deserialize)] struct OnionMapFile { #[serde(default = "default_true")] @@ -452,6 +655,7 @@ fn default_true() -> Option { struct OnionMapFileEntry { clearnet_host: Option, onion_host: Option, + hostname_file: Option, onion_port: Option, #[serde(default)] protocols: Option>, @@ -491,14 +695,22 @@ fn load_map_file(path: &Path) -> Vec { let Some(host) = entry.clearnet_host.as_deref() else { continue; }; - let Some(onion) = entry.onion_host.as_deref() else { - continue; + let onion_from_file = entry + .hostname_file + .as_deref() + .map(str::trim) + .filter(|p| !p.is_empty()) + .and_then(|p| crate::config::onion_url_from_file(Path::new(p))); + let onion = match (entry.onion_host.as_deref(), onion_from_file.as_deref()) { + (Some(h), _) if !h.trim().is_empty() => h.to_string(), + (_, Some(url)) => url.to_string(), + _ => continue, }; let protocols = entry.protocols.unwrap_or_else(|| vec!["h2".into()]); let scheme = entry.onion_scheme.as_deref().unwrap_or("http"); let Some(mapping) = OnionMapping::try_new( host, - onion, + &onion, entry.onion_port.unwrap_or(DEFAULT_ONION_PORT), protocols, scheme, @@ -535,40 +747,24 @@ pub fn onion_location_value(mapping: &OnionMapping, uri: &Uri) -> Option Some(loc) } -/// Match `/_o/{mapped-host}` or `/_o/{mapped-host}/{rest}` on an onion request. -/// Longest prefix wins. Unknown hosts return None (path stays unrewritten; -/// Axum 404 unless that path is already a console route). +/// Shared `/_o/{host}` path rewrite is gone (it correlated sites on one onion). +/// Kept as a named None so leftover bookmarks are not a discovery path. +#[cfg(test)] pub fn match_onion_vhost_rewrite( - path: &str, - cfg: &OnionDiscoveryConfig, + _path: &str, + _cfg: &OnionDiscoveryConfig, ) -> Option<(String, String)> { - let path = path.split('?').next().unwrap_or(path); - let mut best: Option<(usize, String, String)> = None; - for mapping in cfg.mappings() { - let prefix = mapping.onion_path_prefix.trim_end_matches('/'); - if prefix.is_empty() { - continue; - } - let (host, new_path) = if path == prefix { - (mapping.clearnet_host.clone(), "/".to_string()) - } else if let Some(rest) = path.strip_prefix(prefix).filter(|r| r.starts_with('/')) { - let new_path = if rest.is_empty() { - "/".to_string() - } else { - rest.to_string() - }; - (mapping.clearnet_host.clone(), new_path) - } else { - continue; - }; - if best.as_ref().is_none_or(|(len, _, _)| prefix.len() > *len) { - best = Some((prefix.len(), host, new_path)); - } - } - best.map(|(_, host, new_path)| (host, new_path)) + None +} + +/// Onion request Host -> that site's clearnet Host (per-site v3). +pub fn match_onion_host_rewrite(request_host: &str, cfg: &OnionDiscoveryConfig) -> Option { + cfg.lookup_by_onion_host(request_host) + .map(|m| m.clearnet_host.clone()) } /// Replace URI path, keep query. `new_path` must start with `/`. +#[cfg(test)] pub fn rewrite_uri_path_keep_query(uri: &Uri, new_path: &str) -> Option { if !new_path.starts_with('/') || new_path.contains('\r') || new_path.contains('\n') { return None; @@ -796,63 +992,163 @@ mod tests { ); } + /// Named contract: two public Hosts emit two different Onion-Location + /// onion hostnames, each at that Host's onion root (not `/_o/{host}`). #[test] - fn match_onion_vhost_rewrite_longest_prefix() { + fn two_hosts_emit_distinct_onion_location_roots() { + const ONION_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.onion"; + const ONION_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.onion"; + let extra = vec![ + OnionMapping::try_new( + "example.test", + ONION_A, + DEFAULT_ONION_PORT, + vec!["h2".into()], + "http", + DEFAULT_MA_SECONDS, + true, + true, + ) + .unwrap(), + OnionMapping::try_new( + "www.example.test", + ONION_B, + DEFAULT_ONION_PORT, + vec!["h2".into()], + "http", + DEFAULT_MA_SECONDS, + true, + true, + ) + .unwrap(), + ]; let cfg = build_onion_discovery( "example.test", "services.example.test", - Some(&fixture_url()), + None, true, true, - Vec::new(), + extra, + &[], + &[], + &[] as &[&str], + ); + let apex = cfg.lookup("example.test").expect("apex mapped"); + let www = cfg.lookup("www.example.test").expect("www mapped"); + assert_ne!( + apex.onion_host, www.onion_host, + "each public Host must have its own v3 onion, not a shared onion" + ); + assert!( + apex.onion_path_prefix.is_empty(), + "Onion-Location must be this Host's onion root, not /_o/{{host}}: {:?}", + apex.onion_path_prefix + ); + assert!( + www.onion_path_prefix.is_empty(), + "Onion-Location must be this Host's onion root, not /_o/{{host}}: {:?}", + www.onion_path_prefix + ); + let uri: Uri = "/about".parse().unwrap(); + let loc_a = onion_location_value(apex, &uri).unwrap(); + let loc_b = onion_location_value(www, &uri).unwrap(); + assert_eq!(loc_a, format!("http://{ONION_A}/about")); + assert_eq!(loc_b, format!("http://{ONION_B}/about")); + assert!( + !loc_a.contains("/_o/"), + "Onion-Location must not use a shared path prefix: {loc_a}" + ); + assert!( + !loc_b.contains("/_o/"), + "Onion-Location must not use a shared path prefix: {loc_b}" + ); + assert!( + cfg.lookup("mail.example.test").is_none(), + "mail Hosts stay unmapped" + ); + } + + /// Named contract: extra mail Hosts stay unmapped even if a mapping is supplied. + #[test] + fn extra_mail_hosts_stay_unmapped_even_if_supplied() { + let mail_onion = unique_v3_onion_host(7); + let extra_onion = unique_v3_onion_host(8); + let extra = vec![ + OnionMapping::auto_derived("mail.cryptoquick.com", &format!("http://{mail_onion}")) + .unwrap(), + OnionMapping::auto_derived("extra.test", &format!("http://{extra_onion}")).unwrap(), + ]; + let cfg = build_onion_discovery_with_mail( + "example.test", + "services.example.test", + None, + true, + true, + extra, + &[], + &[], + ["extra.test", "mail.cryptoquick.com"], + &["mail.cryptoquick.com".into()], + ); + assert!( + cfg.lookup("mail.cryptoquick.com").is_none(), + "extra mail Hosts stay unmapped" + ); + assert!( + cfg.lookup("mail.example.test").is_none(), + "primary mail Host stays unmapped" + ); + let extra_map = cfg.lookup("extra.test").expect("static extra mapped"); + assert_eq!(extra_map.onion_host, extra_onion); + assert!(extra_map.onion_path_prefix.is_empty()); + } + + #[test] + fn match_onion_host_rewrite_selects_that_sites_clearnet_host() { + let extra = per_site_mappings("example.test", "services.example.test", ["extra.test"]); + let cfg = build_onion_discovery( + "example.test", + "services.example.test", + None, + true, + true, + extra, &[], &[], ["extra.test"], ); + let extra_map = cfg.lookup("extra.test").expect("extra mapped"); + let apex = cfg.lookup("example.test").expect("apex mapped"); + let mta = cfg.lookup("mta-sts.example.test").expect("mta-sts mapped"); + assert_ne!(extra_map.onion_host, apex.onion_host); assert_eq!( - match_onion_vhost_rewrite("/_o/extra.test/", &cfg), - Some(("extra.test".into(), "/".into())) + match_onion_host_rewrite(&extra_map.onion_host, &cfg), + Some("extra.test".into()) ); assert_eq!( - match_onion_vhost_rewrite("/_o/extra.test/about?x=1", &cfg), - Some(("extra.test".into(), "/about".into())) + match_onion_host_rewrite(&apex.onion_host, &cfg), + Some("example.test".into()) ); assert_eq!( - match_onion_vhost_rewrite("/_o/example.test/", &cfg), - Some(("example.test".into(), "/".into())) + match_onion_host_rewrite(&mta.onion_host, &cfg), + Some("mta-sts.example.test".into()) ); assert_eq!( - match_onion_vhost_rewrite("/_o/mta-sts.example.test/.well-known/mta-sts.txt", &cfg), - Some(( - "mta-sts.example.test".into(), - "/.well-known/mta-sts.txt".into() - )) + match_onion_vhost_rewrite("/_o/extra.test/", &cfg), + None, + "shared /_o/{{host}} is not a discovery path" ); let well_known = rewrite_uri_path_keep_query( - &"/_o/mta-sts.example.test/.well-known/mta-sts.txt" - .parse() - .unwrap(), + &"/.well-known/mta-sts.txt".parse().unwrap(), "/.well-known/mta-sts.txt", ) .expect("well-known path rewrite must parse"); assert_eq!(well_known.path(), "/.well-known/mta-sts.txt"); - let full: Uri = "http://127.0.0.1:8090/_o/mta-sts.example.test/.well-known/mta-sts.txt" - .parse() - .unwrap(); - assert_eq!( - full.path(), - "/_o/mta-sts.example.test/.well-known/mta-sts.txt", - "http Uri must keep .well-known as a path segment: {full}" - ); - let full_rw = rewrite_uri_path_keep_query(&full, "/.well-known/mta-sts.txt") - .expect("full URI well-known rewrite must parse"); - assert_eq!(full_rw.path(), "/.well-known/mta-sts.txt"); - assert_eq!(match_onion_vhost_rewrite("/", &cfg), None); - assert_eq!(match_onion_vhost_rewrite("/_o/unknown.test/", &cfg), None); + assert_eq!(match_onion_host_rewrite("unknown.onion", &cfg), None); let services = cfg.lookup("services.example.test").unwrap(); assert!( services.onion_path_prefix.is_empty(), - "services console stays onion root" + "each site including services uses onion root" ); } @@ -878,12 +1174,12 @@ mod tests { let uri: Uri = "/mail/inbox?tab=unread".parse().unwrap(); assert_eq!( onion_location_value(&m, &uri).unwrap(), - format!("http://{FIXTURE_ONION}/_o/example.test/mail/inbox?tab=unread") + format!("http://{FIXTURE_ONION}/mail/inbox?tab=unread") ); let root: Uri = "/".parse().unwrap(); assert_eq!( onion_location_value(&m, &root).unwrap(), - format!("http://{FIXTURE_ONION}/_o/example.test/") + format!("http://{FIXTURE_ONION}/") ); } @@ -919,7 +1215,7 @@ mod tests { )); assert_eq!( headers.get(&ONION_LOCATION).and_then(|v| v.to_str().ok()), - Some(format!("http://{FIXTURE_ONION}/_o/example.test/health?x=1").as_str()) + Some(format!("http://{FIXTURE_ONION}/health?x=1").as_str()) ); assert_eq!( headers.get(&ALT_SVC).and_then(|v| v.to_str().ok()), @@ -1025,13 +1321,14 @@ mod tests { #[test] fn dump_reflects_loaded_map() { + let extra = per_site_mappings("example.test", "services.example.test", &[] as &[&str]); let cfg = build_onion_discovery( "example.test", "services.example.test", - Some(&fixture_url()), + None, true, true, - Vec::new(), + extra, &[], &[], &[] as &[&str], diff --git a/crates/management-ui/src/proxy_splora.rs b/crates/management-ui/src/proxy_splora.rs index d500838..9853a6d 100644 --- a/crates/management-ui/src/proxy_splora.rs +++ b/crates/management-ui/src/proxy_splora.rs @@ -1,1219 +1,11 @@ -//! Splora (Esplora-compatible indexer) reverse proxy over Unix sockets. +//! Pointer only. Not a product module. //! -//! Public Hosts map to `/run/splora/.http.sock` on the edge host. -//! The queue unit is a separate socket and path. NIP-98 stays in splora; -//! this edge does not add API keys. Not nginx. Not the Electrum newline -//! protocol socket (fail-closed if that socket is configured). - -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::Duration; - -use axum::body::Body; -use axum::extract::{Request, State}; -use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri, header}; -use axum::middleware::Next; -use axum::response::{IntoResponse, Response}; -use bytes::Bytes; -use http_body_util::{BodyExt, Full}; -use hyper::body::Incoming; -use hyper_util::rt::TokioIo; -use serde::Deserialize; -use tokio::net::UnixStream; -use tracing::{debug, warn}; - -use crate::AppState; -use crate::proxy_vaultwarden::{ - filter_request_headers, filter_response_headers, is_websocket_upgrade, redact_secrets_in_text, -}; - -/// Instance names this edge will proxy. Host-local map chooses which are live. -pub const ALLOWED_INSTANCE_NAMES: &[&str] = - &["mainnet", "testnet3", "testnet4", "mutinynet", "liquid"]; - -/// Directory for splora sockets on the edge host. -pub const DEFAULT_SOCKET_DIR: &str = "/run/splora"; - -/// Queue unit socket (not an indexer instance). -pub const DEFAULT_QUEUE_SOCKET: &str = "/run/splora/queue.sock"; - -/// Public path for queue POST `{npub,email}` only. -pub const DEFAULT_QUEUE_PATH: &str = "/splora/queue"; - -/// WebSocket path on each indexer HTTP socket. -pub const INDEXER_WS_PATH: &str = "/api/v1/ws"; - -pub const DEFAULT_BODY_LIMIT_BYTES: usize = 8 * 1024 * 1024; -pub const QUEUE_BODY_LIMIT_BYTES: usize = 64 * 1024; - -/// One indexer instance behind a Host list and one HTTP Unix socket. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SploraInstance { - pub name: String, - pub hosts: Vec, - pub http_socket: PathBuf, -} - -/// Runtime knobs for the splora Unix-socket proxy (env / Nix). Default off. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SploraProxyConfig { - pub enable: bool, - pub instances: Vec, - pub queue_socket: PathBuf, - pub queue_path: String, - pub body_limit_bytes: usize, -} - -impl Default for SploraProxyConfig { - fn default() -> Self { - Self { - enable: false, - instances: Vec::new(), - queue_socket: PathBuf::from(DEFAULT_QUEUE_SOCKET), - queue_path: DEFAULT_QUEUE_PATH.to_string(), - body_limit_bytes: DEFAULT_BODY_LIMIT_BYTES, - } - } -} - -impl SploraProxyConfig { - pub fn from_env() -> Result { - Self::from_env_map(|k| std::env::var(k).ok()) - } - - pub fn from_env_map(get: impl Fn(&str) -> Option) -> Result { - if let Some(raw) = get("SURMOUNT_SPLORA_ELECTRUM_SOCKET") { - let t = raw.trim(); - if !t.is_empty() { - return Err( - "SURMOUNT_SPLORA_ELECTRUM_SOCKET is set: this edge does not proxy \ - the Electrum newline Unix socket (fail-closed)" - .into(), - ); - } - } - - let enable = match get("SURMOUNT_SPLORA_PROXY") { - Some(v) => crate::config::parse_env_flag_truthy(&v), - None => false, - }; - - let socket_dir = match get("SURMOUNT_SPLORA_SOCKET_DIR") { - Some(v) if !v.trim().is_empty() => v.trim().to_string(), - _ => DEFAULT_SOCKET_DIR.to_string(), - }; - refuse_electrum_path(&socket_dir, "SURMOUNT_SPLORA_SOCKET_DIR")?; - - let queue_socket = match get("SURMOUNT_SPLORA_QUEUE_SOCKET") { - Some(v) if !v.trim().is_empty() => PathBuf::from(v.trim()), - _ => PathBuf::from(DEFAULT_QUEUE_SOCKET), - }; - refuse_electrum_path( - queue_socket.to_str().unwrap_or(""), - "SURMOUNT_SPLORA_QUEUE_SOCKET", - )?; - - let queue_path = match get("SURMOUNT_SPLORA_QUEUE_PATH") { - Some(v) if !v.trim().is_empty() => normalize_queue_path(&v)?, - _ => DEFAULT_QUEUE_PATH.to_string(), - }; - - let instances = match get("SURMOUNT_SPLORA_INSTANCES") { - Some(v) if !v.trim().is_empty() => parse_instances_json(&v, &socket_dir)?, - _ => Vec::new(), - }; - - if enable && instances.is_empty() { - return Err( - "SURMOUNT_SPLORA_PROXY is on but SURMOUNT_SPLORA_INSTANCES is empty \ - (fail-closed; configure a Host -> Unix socket map)" - .into(), - ); - } - - Ok(Self { - enable, - instances, - queue_socket, - queue_path, - body_limit_bytes: DEFAULT_BODY_LIMIT_BYTES, - }) - } - - /// True when this request is a splora Host or the queue path (skip Nostr). - pub fn should_bypass_auth(&self, host: &str, path: &str) -> bool { - if !self.enable { - return false; - } - self.is_queue_path(path) || self.instance_for_host(host).is_some() - } - - pub fn is_queue_path(&self, path: &str) -> bool { - let p = self.queue_path.trim_end_matches('/'); - path == p || path == format!("{p}/") - } - - pub fn instance_for_host(&self, host: &str) -> Option<&SploraInstance> { - let key = normalize_host(host); - if key.is_empty() { - return None; - } - self.instances - .iter() - .find(|inst| inst.hosts.iter().any(|h| h == &key)) - } -} - -fn refuse_electrum_path(path: &str, label: &str) -> Result<(), String> { - let lower = path.to_ascii_lowercase(); - if lower.contains("electrum") { - return Err(format!( - "{label}={path:?} looks like an Electrum newline socket; this edge \ - does not proxy that protocol (fail-closed)" - )); - } - Ok(()) -} - -/// Leading slash, no trailing slash, not `/`. -pub fn normalize_queue_path(raw: &str) -> Result { - let t = raw.trim(); - if t.is_empty() { - return Err("splora queue path must not be empty".into()); - } - if t.contains("://") || t.contains('?') || t.contains('#') || t.contains(' ') { - return Err(format!("splora queue path must be a path (got {t:?})")); - } - let with_slash = if t.starts_with('/') { - t.to_string() - } else { - format!("/{t}") - }; - let trimmed = with_slash.trim_end_matches('/').to_string(); - if trimmed.is_empty() || trimmed == "/" { - return Err("splora queue path must not be '/'".into()); - } - Ok(trimmed) -} - -pub fn normalize_host(raw: &str) -> String { - crate::redirect::host_for_url_authority(raw) - .trim() - .to_ascii_lowercase() -} - -fn allowed_instance_name(name: &str) -> bool { - ALLOWED_INSTANCE_NAMES.iter().any(|n| *n == name) -} - -#[derive(Debug, Deserialize)] -struct InstanceJson { - #[serde(default)] - hosts: Vec, - #[serde(default)] - socket: String, -} - -/// JSON object: `{ "mainnet": { "hosts": ["esplora.example.test"], "socket": "..." } }`. -pub fn parse_instances_json(raw: &str, socket_dir: &str) -> Result, String> { - let value: serde_json::Value = serde_json::from_str(raw.trim()).map_err(|e| { - format!("SURMOUNT_SPLORA_INSTANCES must be a JSON object instance -> hosts/socket: {e}") - })?; - let obj = value.as_object().ok_or_else(|| { - "SURMOUNT_SPLORA_INSTANCES must be a JSON object (instance name -> config)".to_string() - })?; - let mut out = Vec::new(); - let mut seen_hosts: BTreeMap = BTreeMap::new(); - for (name, cfg) in obj { - if !allowed_instance_name(name) { - return Err(format!( - "SURMOUNT_SPLORA_INSTANCES unknown instance {name:?}; allowed: {}", - ALLOWED_INSTANCE_NAMES.join(", ") - )); - } - let parsed: InstanceJson = serde_json::from_value(cfg.clone()).map_err(|e| { - format!("SURMOUNT_SPLORA_INSTANCES[{name}] must be an object with hosts/socket: {e}") - })?; - let socket = if parsed.socket.trim().is_empty() { - PathBuf::from(format!("{socket_dir}/{name}.http.sock")) - } else { - PathBuf::from(parsed.socket.trim()) - }; - refuse_electrum_path(socket.to_str().unwrap_or(""), "splora instance socket")?; - if !socket.is_absolute() { - return Err(format!( - "SURMOUNT_SPLORA_INSTANCES[{name}].socket must be an absolute path" - )); - } - let mut hosts = Vec::new(); - for h in parsed.hosts { - let n = normalize_host(&h); - if n.is_empty() { - return Err(format!( - "SURMOUNT_SPLORA_INSTANCES[{name}] host must not be empty" - )); - } - if let Some(other) = seen_hosts.get(&n) { - return Err(format!( - "SURMOUNT_SPLORA_INSTANCES host {n} is mapped to both {other} and {name}" - )); - } - seen_hosts.insert(n.clone(), name.clone()); - hosts.push(n); - } - if hosts.is_empty() { - return Err(format!( - "SURMOUNT_SPLORA_INSTANCES[{name}] must list at least one Host" - )); - } - out.push(SploraInstance { - name: name.clone(), - hosts, - http_socket: socket, - }); - } - Ok(out) -} - -/// `X-Forwarded-Proto` for the hop to splora (always set; tests fail if omitted). -pub fn forwarded_proto(listen_is_https: bool) -> &'static str { - if listen_is_https { "https" } else { "http" } -} - -/// Axum middleware: Host/path match -> Unix HTTP/1.1 (or WebSocket) proxy. -pub async fn splora_proxy_middleware( - State(state): State>, - request: Request, - next: Next, -) -> Response { - let cfg = &state.config.splora_proxy; - if !cfg.enable { - return next.run(request).await; - } - - let path = request.uri().path().to_string(); - let host = crate::redirect::request_authority_host( - header_str(request.headers(), "host"), - request.uri().host(), - ); - - if cfg.is_queue_path(&path) { - return proxy_queue(state, request).await; - } - - if let Some(instance) = cfg.instance_for_host(&host).cloned() { - return proxy_indexer(state, request, instance, host).await; - } - - next.run(request).await -} - -fn header_str<'a>(headers: &'a HeaderMap, name: &'static str) -> Option<&'a str> { - headers.get(name).and_then(|v| v.to_str().ok()) -} - -async fn proxy_queue(state: Arc, req: Request) -> Response { - if req.method() != Method::POST { - return (StatusCode::METHOD_NOT_ALLOWED, "POST only").into_response(); - } - let headers = req.headers().clone(); - let limit = QUEUE_BODY_LIMIT_BYTES; - let body = match axum::body::to_bytes(req.into_body(), limit).await { - Ok(b) => b, - Err(_) => { - return (StatusCode::PAYLOAD_TOO_LARGE, "queue body too large").into_response(); - } - }; - if !queue_body_has_npub_and_email(&body) { - return ( - StatusCode::BAD_REQUEST, - "queue body must be JSON object with npub and email", - ) - .into_response(); - } - let host = header_str(&headers, "host") - .map(normalize_host) - .filter(|h| !h.is_empty()) - .unwrap_or_else(|| state.config.services_hostname.clone()); - let proto = forwarded_proto(state.config.listen_mode.is_https()); - let path = state.config.splora_proxy.queue_path.clone(); - proxy_unix_http( - state.config.splora_proxy.queue_socket.as_path(), - Method::POST, - &path, - None, - &host, - proto, - &headers, - body, - false, - ) - .await -} - -fn queue_body_has_npub_and_email(bytes: &[u8]) -> bool { - let Ok(v) = serde_json::from_slice::(bytes) else { - return false; - }; - let Some(obj) = v.as_object() else { - return false; - }; - nonempty_str_field(obj.get("npub")) && nonempty_str_field(obj.get("email")) -} - -fn nonempty_str_field(v: Option<&serde_json::Value>) -> bool { - v.and_then(|x| x.as_str()) - .map(|s| !s.trim().is_empty()) - .unwrap_or(false) -} - -async fn proxy_indexer( - state: Arc, - req: Request, - instance: SploraInstance, - host: String, -) -> Response { - let path = req.uri().path().to_string(); - let query = req.uri().query().map(str::to_string); - let method = req.method().clone(); - let headers = req.headers().clone(); - let proto = forwarded_proto(state.config.listen_mode.is_https()); - let wants_ws = path == INDEXER_WS_PATH && is_websocket_upgrade(&headers); - - if wants_ws { - return proxy_unix_websocket( - instance.http_socket, - req, - &path, - query.as_deref(), - &host, - proto, - ) - .await; - } - - let limit = state.config.splora_proxy.body_limit_bytes; - let body = match axum::body::to_bytes(req.into_body(), limit).await { - Ok(b) => b, - Err(_) => { - return (StatusCode::PAYLOAD_TOO_LARGE, "request body too large").into_response(); - } - }; - - proxy_unix_http( - instance.http_socket.as_path(), - method, - &path, - query.as_deref(), - &host, - proto, - &headers, - body, - false, - ) - .await -} - -/// HTTP/1.1 over a Unix socket. Always sets Host and X-Forwarded-Proto. -#[allow(clippy::too_many_arguments)] -pub async fn proxy_unix_http( - socket: &Path, - method: Method, - path: &str, - query: Option<&str>, - host: &str, - forwarded_proto: &str, - incoming_headers: &HeaderMap, - body: Bytes, - preserve_upgrade: bool, -) -> Response { - let uri = match origin_form_uri(path, query) { - Ok(u) => u, - Err(e) => { - warn!(error = %e, "splora_proxy_bad_uri"); - return StatusCode::BAD_GATEWAY.into_response(); - } - }; - - let stream = - match tokio::time::timeout(Duration::from_secs(10), UnixStream::connect(socket)).await { - Ok(Ok(s)) => s, - Ok(Err(e)) => { - let safe = redact_secrets_in_text(&format!("uds_connect: {e}")); - warn!(error = %safe, socket = %socket.display(), "splora_proxy_unix_connect"); - return (StatusCode::BAD_GATEWAY, "splora upstream unreachable").into_response(); - } - Err(_) => { - warn!(socket = %socket.display(), "splora_proxy_unix_connect_timeout"); - return (StatusCode::BAD_GATEWAY, "splora upstream unreachable").into_response(); - } - }; - - let io = TokioIo::new(stream); - let (mut sender, conn) = match hyper::client::conn::http1::handshake(io).await { - Ok(pair) => pair, - Err(e) => { - let safe = redact_secrets_in_text(&format!("uds_handshake: {e}")); - warn!(error = %safe, "splora_proxy_unix_handshake"); - return (StatusCode::BAD_GATEWAY, "splora upstream unreachable").into_response(); - } - }; - tokio::spawn(async move { - if let Err(e) = conn.await { - debug!( - error = %redact_secrets_in_text(&e.to_string()), - "splora_proxy_unix_conn" - ); - } - }); - - let filtered = filter_request_headers(incoming_headers, preserve_upgrade); - let mut builder = hyper::Request::builder().method(method).uri(uri); - for (name, value) in filtered.iter() { - builder = builder.header(name, value); - } - builder = builder.header(header::HOST, host); - builder = builder.header("x-forwarded-proto", forwarded_proto); - - let outbound = match builder.body(Full::new(body)) { - Ok(r) => r, - Err(e) => { - warn!(error = %e, "splora_proxy_build_request"); - return StatusCode::BAD_GATEWAY.into_response(); - } - }; - - let resp = match sender.send_request(outbound).await { - Ok(r) => r, - Err(e) => { - let safe = redact_secrets_in_text(&format!("uds_send: {e}")); - warn!(error = %safe, "splora_proxy_unix_send"); - return (StatusCode::BAD_GATEWAY, "splora upstream unreachable").into_response(); - } - }; - - hyper_response_to_axum(resp, preserve_upgrade).await -} - -async fn hyper_response_to_axum( - resp: hyper::Response, - preserve_upgrade: bool, -) -> Response { - let status = resp.status(); - let headers = resp.headers().clone(); - let bytes = match resp.into_body().collect().await { - Ok(c) => c.to_bytes(), - Err(e) => { - let safe = redact_secrets_in_text(&format!("uds_body: {e}")); - warn!(error = %safe, "splora_proxy_unix_body"); - return StatusCode::BAD_GATEWAY.into_response(); - } - }; - let mut out = Response::builder().status(status); - let filtered_out = filter_response_headers(&headers, preserve_upgrade); - if let Some(hmap) = out.headers_mut() { - for (name, value) in filtered_out.iter() { - hmap.append(name, value.clone()); - } - } - out.body(Body::from(bytes)) - .unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response()) -} - -fn origin_form_uri(path: &str, query: Option<&str>) -> Result { - let p = if path.is_empty() { "/" } else { path }; - if !p.starts_with('/') { - return Err(format!("path must start with / (got {p:?})")); - } - let s = match query { - Some(q) if !q.is_empty() => format!("{p}?{q}"), - _ => p.to_string(), - }; - s.parse::() - .map_err(|e| format!("invalid origin-form URI {s:?}: {e}")) -} - -/// WebSocket upgrade over the same indexer HTTP Unix socket (101 tunnel). -async fn proxy_unix_websocket( - socket: PathBuf, - req: Request, - path: &str, - query: Option<&str>, - host: &str, - forwarded_proto: &str, -) -> Response { - let path_and_query = match query { - Some(q) if !q.is_empty() => format!("{path}?{q}"), - _ => path.to_string(), - }; - - let unix = - match tokio::time::timeout(Duration::from_secs(10), UnixStream::connect(&socket)).await { - Ok(Ok(s)) => s, - Ok(Err(e)) => { - let safe = redact_secrets_in_text(&format!("ws_uds_connect: {e}")); - warn!(error = %safe, "splora_proxy_ws_connect"); - return (StatusCode::BAD_GATEWAY, "splora upstream unreachable").into_response(); - } - Err(_) => { - warn!("splora_proxy_ws_connect_timeout"); - return (StatusCode::BAD_GATEWAY, "splora upstream unreachable").into_response(); - } - }; - - let method = req.method().clone(); - let headers = req.headers().clone(); - let filtered = filter_request_headers(&headers, true); - - let mut req_buf = format!("{method} {path_and_query} HTTP/1.1\r\nHost: {host}\r\n"); - req_buf.push_str("X-Forwarded-Proto: "); - req_buf.push_str(forwarded_proto); - req_buf.push_str("\r\n"); - for (name, value) in filtered.iter() { - if name.as_str().eq_ignore_ascii_case("x-forwarded-proto") { - continue; - } - if let Ok(v) = value.to_str() { - req_buf.push_str(name.as_str()); - req_buf.push_str(": "); - req_buf.push_str(v); - req_buf.push_str("\r\n"); - } - } - if !filtered.contains_key(header::CONNECTION) { - req_buf.push_str("Connection: Upgrade\r\n"); - } - if !filtered.contains_key(header::UPGRADE) { - req_buf.push_str("Upgrade: websocket\r\n"); - } - req_buf.push_str("\r\n"); - - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - let mut upstream_stream = unix; - if let Err(e) = upstream_stream.write_all(req_buf.as_bytes()).await { - let safe = redact_secrets_in_text(&format!("ws_write: {e}")); - warn!(error = %safe, "splora_proxy_ws_write"); - return StatusCode::BAD_GATEWAY.into_response(); - } - - let mut header_bytes = Vec::with_capacity(1024); - let mut tmp = [0u8; 256]; - let header_end = loop { - if header_bytes.len() > 64 * 1024 { - warn!("splora_proxy_ws_header_too_large"); - return StatusCode::BAD_GATEWAY.into_response(); - } - let n = match upstream_stream.read(&mut tmp).await { - Ok(0) => { - warn!("splora_proxy_ws_upstream_eof"); - return StatusCode::BAD_GATEWAY.into_response(); - } - Ok(n) => n, - Err(e) => { - let safe = redact_secrets_in_text(&format!("ws_read: {e}")); - warn!(error = %safe, "splora_proxy_ws_read"); - return StatusCode::BAD_GATEWAY.into_response(); - } - }; - header_bytes.extend_from_slice(&tmp[..n]); - if let Some(pos) = find_header_end(&header_bytes) { - break pos; - } - }; - - let header_blob = &header_bytes[..header_end]; - let rest = header_bytes[header_end..].to_vec(); - let header_str = String::from_utf8_lossy(header_blob); - let mut lines = header_str.split("\r\n"); - let status_line = lines.next().unwrap_or(""); - let status_code = parse_status_code(status_line).unwrap_or(502); - let mut resp_headers = HeaderMap::new(); - for line in lines { - if line.is_empty() { - continue; - } - if let Some((n, v)) = line.split_once(':') - && let (Ok(name), Ok(val)) = ( - HeaderName::from_bytes(n.trim().as_bytes()), - HeaderValue::from_str(v.trim()), - ) - { - resp_headers.append(name, val); - } - } - - if status_code != 101 { - let mut body = rest; - let mut buf = [0u8; 8192]; - if let Ok(Ok(n)) = - tokio::time::timeout(Duration::from_millis(200), upstream_stream.read(&mut buf)).await - && n > 0 - { - body.extend_from_slice(&buf[..n]); - } - let status = StatusCode::from_u16(status_code).unwrap_or(StatusCode::BAD_GATEWAY); - let filtered_out = filter_response_headers(&resp_headers, false); - let mut out = Response::builder().status(status); - if let Some(hmap) = out.headers_mut() { - for (name, value) in filtered_out.iter() { - hmap.append(name, value.clone()); - } - } - return out - .body(Body::from(body)) - .unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response()); - } - - let filtered_out = filter_response_headers(&resp_headers, true); - let upgrade = match hyper::upgrade::on(req).await { - Ok(upgraded) => upgraded, - Err(e) => { - let safe = redact_secrets_in_text(&format!("client_upgrade: {e}")); - warn!(error = %safe, "splora_proxy_client_upgrade"); - return StatusCode::BAD_GATEWAY.into_response(); - } - }; - - tokio::spawn(async move { - if let Err(e) = websocket_unix_tunnel(upgrade, upstream_stream, rest).await { - debug!( - error = %redact_secrets_in_text(&e.to_string()), - "splora_proxy_ws_tunnel_end" - ); - } - }); - - let mut out = Response::builder().status(StatusCode::SWITCHING_PROTOCOLS); - if let Some(hmap) = out.headers_mut() { - for (name, value) in filtered_out.iter() { - hmap.append(name, value.clone()); - } - if !hmap.contains_key(header::UPGRADE) { - hmap.insert(header::UPGRADE, HeaderValue::from_static("websocket")); - } - if !hmap.contains_key(header::CONNECTION) { - hmap.insert(header::CONNECTION, HeaderValue::from_static("Upgrade")); - } - } - out.body(Body::empty()) - .unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response()) -} - -fn find_header_end(buf: &[u8]) -> Option { - buf.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4) -} - -fn parse_status_code(status_line: &str) -> Option { - let mut parts = status_line.split_whitespace(); - let _http = parts.next()?; - parts.next()?.parse().ok() -} - -async fn websocket_unix_tunnel( - client: hyper::upgrade::Upgraded, - mut upstream: UnixStream, - pending_from_upstream: Vec, -) -> Result<(), std::io::Error> { - use tokio::io::AsyncWriteExt; - - let mut client = hyper_util::rt::TokioIo::new(client); - - if !pending_from_upstream.is_empty() { - client.write_all(&pending_from_upstream).await?; - } - - let (mut client_r, mut client_w) = tokio::io::split(client); - let (mut up_r, mut up_w) = upstream.split(); - - let c2u = async { - tokio::io::copy(&mut client_r, &mut up_w).await?; - up_w.shutdown().await - }; - let u2c = async { - tokio::io::copy(&mut up_r, &mut client_w).await?; - client_w.shutdown().await - }; - - tokio::select! { - r = c2u => { r?; } - r = u2c => { r?; } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use axum::Router; - use axum::extract::Request as AxumRequest; - use axum::routing::{any, get, post}; - use std::sync::Mutex; - - fn tmp_sock(prefix: &str) -> PathBuf { - std::env::temp_dir().join(format!( - "{prefix}-{}-{}.sock", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )) - } - - #[test] - fn electrum_socket_env_fails_closed() { - let err = SploraProxyConfig::from_env_map(|k| match k { - "SURMOUNT_SPLORA_ELECTRUM_SOCKET" => Some("/run/splora/mainnet.electrum.sock".into()), - _ => None, - }) - .unwrap_err(); - assert!(err.contains("Electrum"), "{err}"); - assert!( - err.contains("fail-closed") || err.contains("does not proxy"), - "{err}" - ); - } - - #[test] - fn electrum_in_instance_socket_fails_closed() { - let err = parse_instances_json( - r#"{"mainnet":{"hosts":["esplora.example.test"],"socket":"/run/splora/mainnet.electrum.sock"}}"#, - "/run/splora", - ) - .unwrap_err(); - assert!(err.to_ascii_lowercase().contains("electrum"), "{err}"); - } - - #[test] - fn unknown_instance_name_fails_closed() { - let err = parse_instances_json( - r#"{"regtest":{"hosts":["x.example.test"],"socket":"/run/splora/regtest.http.sock"}}"#, - "/run/splora", - ) - .unwrap_err(); - assert!(err.contains("unknown instance"), "{err}"); - } - - #[test] - fn proxy_disabled_by_default() { - let cfg = SploraProxyConfig::from_env_map(|_| None).unwrap(); - assert!(!cfg.enable); - assert!(cfg.instances.is_empty()); - } - - #[test] - fn enable_without_instances_fails_closed() { - let err = SploraProxyConfig::from_env_map(|k| match k { - "SURMOUNT_SPLORA_PROXY" => Some("1".into()), - _ => None, - }) - .unwrap_err(); - assert!(err.contains("INSTANCES"), "{err}"); - } - - #[test] - fn instances_json_maps_hosts_and_default_socket() { - let cfg = SploraProxyConfig::from_env_map(|k| match k { - "SURMOUNT_SPLORA_PROXY" => Some("true".into()), - "SURMOUNT_SPLORA_INSTANCES" => Some( - r#"{"mainnet":{"hosts":["Esplora.Example.TEST:443"]},"liquid":{"hosts":["liquid.example.test"],"socket":"/run/splora/liquid.http.sock"}}"# - .into(), - ), - _ => None, - }) - .unwrap(); - assert!(cfg.enable); - assert_eq!(cfg.instances.len(), 2); - let main = cfg.instance_for_host("esplora.example.test").unwrap(); - assert_eq!(main.name, "mainnet"); - assert_eq!( - main.http_socket, - PathBuf::from("/run/splora/mainnet.http.sock") - ); - assert!(cfg.should_bypass_auth("esplora.example.test", "/api/tx/abc")); - assert!(cfg.should_bypass_auth("services.example.test", "/splora/queue")); - assert!(!cfg.should_bypass_auth("services.example.test", "/health")); - } - - fn test_state_for(cfg: SploraProxyConfig, https: bool) -> Arc { - use crate::config::{AppConfig, OnionSurface}; - use crate::tls::ListenMode; - use std::net::SocketAddr; - use surmount_management_ui::auth::AuthConfig; - use surmount_management_ui::ban::{BanConfig, BanEnforcement, BanGuard, MemoryBanBackend}; - - let listen_mode = if https { - ListenMode::Https(crate::tls::TlsPaths::new("/tmp/c.pem", "/tmp/k.pem")) - } else { - ListenMode::PlainHttp - }; - let config = AppConfig { - listen: SocketAddr::from(([127, 0, 0, 1], 8090)), - http_redirect_listen: None, - local_cleartext_listen: None, - listen_mode, - redirect_http_to_https: false, - redirect_allowed_hosts: vec!["services.example.test".into()], - https_allow_cleartext_escape: false, - acme: crate::acme::AcmeConfig::default(), - mta_sts_mode: crate::mta_sts::MtaStsMode::Off, - mta_sts_max_age: 86_400, - primary_domain: "example.test".into(), - mail_hostname: "mail.example.test".into(), - services_hostname: "services.example.test".into(), - stalwart_url: "http://127.0.0.1:8080".into(), - onion_surface: OnionSurface::NotProvisioned, - onion_url: None, - onion_discovery: crate::onion_discovery::OnionDiscoveryConfig::empty(), - vaultwarden_url: None, - vaultwarden_proxy: crate::proxy_vaultwarden::VaultwardenProxyConfig::default(), - splora_proxy: cfg, - http3: crate::http3::Http3Config::default(), - apex_public_root: None, - static_vhosts: Default::default(), - extra_mail_hostnames: Vec::new(), - rate_limit_max_requests: 0, - rate_limit_window: Duration::from_secs(60), - rate_limit_max_keys: 1000, - ban: BanConfig { - enforcement: BanEnforcement::Off, - backend_kind: surmount_management_ui::ban::BanBackendKind::Memory, - whitelist: vec![], - state_path: None, - nft_exec_enabled: false, - nft_bin: None, - nft_helper_sock: None, - nft_helper_bin: None, - }, - auth: AuthConfig::off(), - allow_directory_unauthenticated: false, - allow_public_auth_off: false, - console_accounts_path: crate::config::unused_console_accounts_path(), - nwc_store_path: crate::config::unused_nwc_store_path(), - }; - let http = reqwest::Client::builder() - .timeout(Duration::from_secs(5)) - .build() - .unwrap(); - Arc::new(AppState { - http, - rate_limiter: None, - ban: BanGuard::with_backend( - BanEnforcement::Off, - Box::new(MemoryBanBackend::new(vec![])), - ), - directory: crate::directory::directory_unavailable(), - config, - }) - } - - async fn serve_uds(path: PathBuf, app: Router) -> tokio::task::JoinHandle<()> { - let _ = std::fs::remove_file(&path); - let lis = tokio::net::UnixListener::bind(&path).unwrap(); - tokio::spawn(async move { - axum::serve(lis, app).await.ok(); - }) - } - - /// Named contract: GET/POST to an indexer Host forward Host and - /// X-Forwarded-Proto over HTTP/1.1 Unix. Must fail if proto is omitted. - #[tokio::test] - async fn splora_proxy_get_post_forward_host_and_x_forwarded_proto() { - let sock = tmp_sock("splora-http"); - let seen = Arc::new(Mutex::new(Vec::<(String, HeaderMap, String)>::new())); - let seen_c = seen.clone(); - let mock = Router::new() - .route( - "/api/blocks/tip/hash", - get({ - let seen_c = seen_c.clone(); - move |req: AxumRequest| { - let seen_c = seen_c.clone(); - async move { - seen_c.lock().unwrap().push(( - "GET".into(), - req.headers().clone(), - String::new(), - )); - "tip-hash" - } - } - }), - ) - .route( - "/api/tx", - post({ - let seen_c = seen_c.clone(); - move |req: AxumRequest| { - let seen_c = seen_c.clone(); - async move { - let headers = req.headers().clone(); - let body = axum::body::to_bytes(req.into_body(), 1024).await.unwrap(); - seen_c.lock().unwrap().push(( - "POST".into(), - headers, - String::from_utf8_lossy(&body).into_owned(), - )); - "txid" - } - } - }), - ); - let mock_serve = serve_uds(sock.clone(), mock).await; - tokio::time::sleep(Duration::from_millis(20)).await; - - let cfg = SploraProxyConfig { - enable: true, - instances: vec![SploraInstance { - name: "mainnet".into(), - hosts: vec!["esplora.example.test".into()], - http_socket: sock.clone(), - }], - queue_socket: PathBuf::from("/tmp/splora-queue-unused.sock"), - queue_path: DEFAULT_QUEUE_PATH.into(), - body_limit_bytes: DEFAULT_BODY_LIMIT_BYTES, - }; - let state = test_state_for(cfg, true); - let app = Router::new() - .fallback(axum::routing::any(|| async { StatusCode::NOT_FOUND })) - .layer(axum::middleware::from_fn_with_state( - state.clone(), - splora_proxy_middleware, - )) - .with_state(state); - - let lis = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = lis.local_addr().unwrap(); - let serve = tokio::spawn(async move { - axum::serve(lis, app).await.ok(); - }); - - let client = reqwest::Client::new(); - let get_resp = client - .get(format!("http://{addr}/api/blocks/tip/hash")) - .header("Host", "esplora.example.test") - .send() - .await - .unwrap(); - assert_eq!(get_resp.status(), reqwest::StatusCode::OK); - assert_eq!(get_resp.text().await.unwrap(), "tip-hash"); - - let post_resp = client - .post(format!("http://{addr}/api/tx")) - .header("Host", "esplora.example.test") - .body("rawtx") - .send() - .await - .unwrap(); - assert_eq!(post_resp.status(), reqwest::StatusCode::OK); - - tokio::time::sleep(Duration::from_millis(30)).await; - let hits = seen.lock().unwrap().clone(); - assert_eq!(hits.len(), 2, "expected GET and POST hits, got {hits:?}"); - for (method, headers, _) in &hits { - let host = headers - .get(header::HOST) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - let proto = headers - .get("x-forwarded-proto") - .and_then(|v| v.to_str().ok()); - assert_eq!(host, "esplora.example.test", "Host missing on {method}"); - assert_eq!( - proto, - Some("https"), - "X-Forwarded-Proto must be forwarded (test fails if omitted); {method} headers={headers:?}" - ); - } - - serve.abort(); - mock_serve.abort(); - let _ = std::fs::remove_file(&sock); - } - - /// Named contract: queue POST goes only to queue.sock, never indexer. - #[tokio::test] - async fn splora_queue_post_only_hits_queue_socket() { - let idx_sock = tmp_sock("splora-idx"); - let q_sock = tmp_sock("splora-queue"); - let idx_hits = Arc::new(Mutex::new(0u32)); - let q_hits = Arc::new(Mutex::new(Vec::::new())); - let idx_c = idx_hits.clone(); - let q_c = q_hits.clone(); - - let idx_app = Router::new().fallback(any({ - let idx_c = idx_c.clone(); - move || { - let idx_c = idx_c.clone(); - async move { - *idx_c.lock().unwrap() += 1; - "indexer" - } - } - })); - let q_app = Router::new().route( - "/splora/queue", - post({ - let q_c = q_c.clone(); - move |req: AxumRequest| { - let q_c = q_c.clone(); - async move { - q_c.lock().unwrap().push(req.headers().clone()); - "queued" - } - } - }), - ); - let idx_serve = serve_uds(idx_sock.clone(), idx_app).await; - let q_serve = serve_uds(q_sock.clone(), q_app).await; - tokio::time::sleep(Duration::from_millis(20)).await; - - let cfg = SploraProxyConfig { - enable: true, - instances: vec![SploraInstance { - name: "mainnet".into(), - hosts: vec!["esplora.example.test".into()], - http_socket: idx_sock.clone(), - }], - queue_socket: q_sock.clone(), - queue_path: DEFAULT_QUEUE_PATH.into(), - body_limit_bytes: DEFAULT_BODY_LIMIT_BYTES, - }; - let state = test_state_for(cfg, false); - let app = Router::new() - .fallback(axum::routing::any(|| async { StatusCode::NOT_FOUND })) - .layer(axum::middleware::from_fn_with_state( - state.clone(), - splora_proxy_middleware, - )) - .with_state(state); - let lis = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = lis.local_addr().unwrap(); - let serve = tokio::spawn(async move { - axum::serve(lis, app).await.ok(); - }); - - let client = reqwest::Client::new(); - let resp = client - .post(format!("http://{addr}/splora/queue")) - .header("Host", "esplora.example.test") - .header(header::CONTENT_TYPE, "application/json") - .body(r#"{"npub":"npub1abc","email":"a@example.test"}"#) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), reqwest::StatusCode::OK); - assert_eq!(resp.text().await.unwrap(), "queued"); - assert_eq!( - *idx_hits.lock().unwrap(), - 0, - "queue POST must not hit indexer units" - ); - let qh = q_hits.lock().unwrap().clone(); - assert_eq!(qh.len(), 1); - assert_eq!( - qh[0].get("x-forwarded-proto").and_then(|v| v.to_str().ok()), - Some("http"), - "queue hop must set X-Forwarded-Proto; headers={:?}", - qh[0] - ); - - let bad = client - .get(format!("http://{addr}/splora/queue")) - .header("Host", "esplora.example.test") - .send() - .await - .unwrap(); - assert_eq!(bad.status(), reqwest::StatusCode::METHOD_NOT_ALLOWED); - - serve.abort(); - idx_serve.abort(); - q_serve.abort(); - let _ = std::fs::remove_file(&idx_sock); - let _ = std::fs::remove_file(&q_sock); - } - - /// Named contract: GET /api/v1/ws Upgrade headers reach the indexer HTTP socket. - #[tokio::test] - async fn splora_ws_upgrade_headers_reach_indexer_http_socket() { - let sock = tmp_sock("splora-ws"); - let seen = Arc::new(Mutex::new(HeaderMap::new())); - let seen_c = seen.clone(); - let mock = Router::new().route( - "/api/v1/ws", - any(move |req: AxumRequest| { - let seen_c = seen_c.clone(); - async move { - *seen_c.lock().unwrap() = req.headers().clone(); - (StatusCode::BAD_REQUEST, "not a real ws; headers captured") - } - }), - ); - let mock_serve = serve_uds(sock.clone(), mock).await; - tokio::time::sleep(Duration::from_millis(20)).await; - - let cfg = SploraProxyConfig { - enable: true, - instances: vec![SploraInstance { - name: "testnet4".into(), - hosts: vec!["testnet4.esplora.example.test".into()], - http_socket: sock.clone(), - }], - queue_socket: PathBuf::from("/tmp/splora-queue-unused.sock"), - queue_path: DEFAULT_QUEUE_PATH.into(), - body_limit_bytes: DEFAULT_BODY_LIMIT_BYTES, - }; - let state = test_state_for(cfg, true); - let app = Router::new() - .fallback(axum::routing::any(|| async { StatusCode::NOT_FOUND })) - .layer(axum::middleware::from_fn_with_state( - state.clone(), - splora_proxy_middleware, - )) - .with_state(state); - let lis = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = lis.local_addr().unwrap(); - let serve = tokio::spawn(async move { - axum::serve(lis, app).await.ok(); - }); - - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap(); - let req = format!( - "GET /api/v1/ws HTTP/1.1\r\n\ - Host: testnet4.esplora.example.test\r\n\ - Connection: Upgrade\r\n\ - Upgrade: websocket\r\n\ - Sec-WebSocket-Version: 13\r\n\ - Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\ - \r\n" - ); - stream.write_all(req.as_bytes()).await.unwrap(); - let mut buf = vec![0u8; 4096]; - let _ = tokio::time::timeout(Duration::from_secs(2), stream.read(&mut buf)).await; - tokio::time::sleep(Duration::from_millis(50)).await; - - let h = seen.lock().unwrap().clone(); - assert!( - h.get(header::UPGRADE) - .and_then(|v| v.to_str().ok()) - .map(|s| s.eq_ignore_ascii_case("websocket")) - .unwrap_or(false), - "indexer HTTP socket must see Upgrade: websocket; headers={h:?}" - ); - assert!( - h.get("sec-websocket-key").is_some(), - "indexer HTTP socket must see Sec-WebSocket-Key; headers={h:?}" - ); - assert_eq!( - h.get("x-forwarded-proto").and_then(|v| v.to_str().ok()), - Some("https"), - "WS handshake must set X-Forwarded-Proto; headers={h:?}" - ); - - serve.abort(); - mock_serve.abort(); - let _ = std::fs::remove_file(&sock); - } -} +//! The compiled Splora Unix-socket proxy is nested `pub(crate) mod splora` +//! in `proxy_vaultwarden.rs` (`crate::proxy_vaultwarden::splora`). That +//! module owns the portal Host, GET `/` HTML, extra onion/redirect Hosts, +//! and the named tests. `main.rs` does not `mod proxy_splora`. Do not add +//! that line: a later edit of this path would miss the portal Host. + +compile_error!( + "proxy_splora.rs is a pointer only; use crate::proxy_vaultwarden::splora" +); diff --git a/crates/management-ui/src/proxy_vaultwarden.rs b/crates/management-ui/src/proxy_vaultwarden.rs index 55f518b..5430103 100644 --- a/crates/management-ui/src/proxy_vaultwarden.rs +++ b/crates/management-ui/src/proxy_vaultwarden.rs @@ -1007,11 +1007,11 @@ mod tests { /// Splora Unix-socket proxy (nested so the flake git tree sees this module). /// -/// Splora (Esplora-compatible indexer) reverse proxy over Unix sockets. -/// Public Hosts map to `/run/splora/.http.sock` on the edge host. -/// The queue unit is a separate socket and path. NIP-98 stays in splora; -/// this edge does not add API keys. Not nginx. Not the Electrum newline -/// protocol socket (fail-closed if that socket is configured). +/// One public Host. Network paths on that Host map to +/// `/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. pub(crate) mod splora { use std::collections::BTreeMap; @@ -1051,9 +1051,19 @@ pub(crate) mod splora { /// Public path for queue POST `{npub,email}` only. pub const DEFAULT_QUEUE_PATH: &str = "/splora/queue"; - /// WebSocket path on each indexer HTTP socket. + /// WebSocket path on each indexer HTTP socket. Exact path only. pub const INDEXER_WS_PATH: &str = "/api/v1/ws"; + /// `GET /` hrefs on the one public Host. Not per-network Hosts. + pub const PUBLIC_PATH_HREFS: &[&str] = &[ + "/api/", + "/testnet/", + "/testnet4/", + "/signet/", + "/mutinynet/", + "/liquid/", + ]; + pub const DEFAULT_BODY_LIMIT_BYTES: usize = 8 * 1024 * 1024; pub const QUEUE_BODY_LIMIT_BYTES: usize = 64 * 1024; @@ -1073,6 +1083,9 @@ pub(crate) mod splora { pub queue_socket: PathBuf, pub queue_path: String, pub body_limit_bytes: usize, + /// 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, } impl Default for SploraProxyConfig { @@ -1083,6 +1096,7 @@ pub(crate) mod splora { queue_socket: PathBuf::from(DEFAULT_QUEUE_SOCKET), queue_path: DEFAULT_QUEUE_PATH.to_string(), body_limit_bytes: DEFAULT_BODY_LIMIT_BYTES, + portal_host: None, } } } @@ -1142,26 +1156,85 @@ pub(crate) mod splora { ); } + let portal_host = parse_portal_host(get("SURMOUNT_SPLORA_PORTAL_HOST"), enable)?; + if let Some(ref portal) = portal_host { + if instances + .iter() + .any(|inst| inst.hosts.iter().any(|h| h == portal)) + { + return Err(format!( + "SURMOUNT_SPLORA_PORTAL_HOST={portal} must not also be an indexer Host \ + (the portal is not a fifth indexer)" + )); + } + } + Ok(Self { enable, instances, queue_socket, queue_path, body_limit_bytes: DEFAULT_BODY_LIMIT_BYTES, + portal_host, }) } - /// True when this request is a splora Host or the queue path (skip Nostr). + /// True when this request is a splora Host, the portal, or the queue path (skip Nostr). pub fn should_bypass_auth(&self, host: &str, path: &str) -> bool { if !self.enable { return false; } - self.is_queue_path(path) || self.instance_for_host(host).is_some() + self.is_queue_path(path) + || self.is_portal_host(host) + || self.instance_for_host(host).is_some() + } + + pub fn is_portal_host(&self, host: &str) -> bool { + if !self.enable { + return false; + } + let Some(portal) = self.portal_host.as_deref() else { + return false; + }; + let key = normalize_host(host); + !key.is_empty() && key == portal + } + + /// Indexer Hosts plus the portal Host (when set). Empty when the proxy is off. + pub fn extra_onion_and_redirect_hosts(&self) -> Vec { + if !self.enable { + return Vec::new(); + } + let mut out = Vec::new(); + for inst in &self.instances { + for host in &inst.hosts { + if !out.iter().any(|h| h == host) { + out.push(host.clone()); + } + } + } + if let Some(portal) = self.portal_host.as_ref() { + if !portal.is_empty() && !out.iter().any(|h| h == portal) { + out.push(portal.clone()); + } + } + out } pub fn is_queue_path(&self, path: &str) -> bool { - let p = self.queue_path.trim_end_matches('/'); - path == p || path == format!("{p}/") + path_is_queue(&self.queue_path, path) + } + + pub fn instance_by_name(&self, name: &str) -> Option<&SploraInstance> { + self.instances.iter().find(|inst| inst.name == name) + } + + /// Instance names present in this map. Absent `mainnet` means `/api` is off. + pub fn enabled_instance_names(&self) -> Vec<&str> { + self.instances + .iter() + .map(|inst| inst.name.as_str()) + .collect() } pub fn instance_for_host(&self, host: &str) -> Option<&SploraInstance> { @@ -1175,6 +1248,191 @@ pub(crate) mod splora { } } + fn parse_portal_host(raw: Option, enable: bool) -> Result, String> { + if !enable { + return Ok(None); + } + let Some(v) = raw else { + return Ok(None); + }; + let n = normalize_host(&v); + if n.is_empty() { + return Ok(None); + } + Ok(Some(n)) + } + + fn path_is_queue(queue_path: &str, path: &str) -> bool { + let p = queue_path.trim_end_matches('/'); + !p.is_empty() && (path == p || path == format!("{p}/")) + } + + /// Where one public path goes. No redirect arm: `/signet` and `/mutinynet` + /// are both proxies to the mutinynet socket. + #[derive(Debug, Clone, PartialEq, Eq)] + pub enum SploraPublicRoute { + Index, + Queue, + NotFound, + Proxy { + socket_name: &'static str, + backend_path: String, + query: Option, + websocket: bool, + }, + } + + /// 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. + pub fn route_splora_public( + path: &str, + query: Option<&str>, + queue_path: &str, + enabled: &[&str], + ) -> SploraPublicRoute { + let path = path.split('?').next().unwrap_or(path); + if path_is_queue(queue_path, path) { + return SploraPublicRoute::Queue; + } + if path == "/" { + return SploraPublicRoute::Index; + } + let kept = match query { + Some(q) if !q.is_empty() => Some(q.to_string()), + _ => None, + }; + if let Some(rest) = strip_named_prefix(path, "/testnet4") { + return proxy_after_network("testnet4", rest, kept, enabled); + } + if let Some(rest) = strip_named_prefix(path, "/testnet") { + return proxy_after_network("testnet3", rest, kept, enabled); + } + if let Some(rest) = strip_named_prefix(path, "/signet") { + return proxy_after_network("mutinynet", rest, kept, enabled); + } + if let Some(rest) = strip_named_prefix(path, "/mutinynet") { + return proxy_after_network("mutinynet", rest, kept, enabled); + } + if let Some(rest) = strip_named_prefix(path, "/liquid") { + return proxy_after_network("liquid", rest, kept, enabled); + } + if path == "/api" || path.starts_with("/api/") { + return proxy_after_network("mainnet", path, kept, enabled); + } + SploraPublicRoute::NotFound + } + + fn strip_named_prefix<'a>(path: &'a str, prefix: &str) -> Option<&'a str> { + if path == prefix { + return Some(""); + } + let rest = path.strip_prefix(prefix)?; + if rest.starts_with('/') { + Some(rest) + } else { + None + } + } + + fn proxy_after_network( + socket_name: &'static str, + after_network: &str, + query: Option, + enabled: &[&str], + ) -> SploraPublicRoute { + if !enabled.iter().any(|name| *name == socket_name) { + 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, + } + } + + /// 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}") + } + } + + /// `/api/v1/ws` stays. Other `/api/...` loses the `/api` prefix. + fn backend_path_for_indexer(path_with_api: &str) -> String { + if path_with_api == INDEXER_WS_PATH || path_with_api.starts_with("/api/v1/ws/") { + return path_with_api.to_string(); + } + match path_with_api.strip_prefix("/api") { + Some("") => "/".to_string(), + Some(rest) => rest.to_string(), + None => path_with_api.to_string(), + } + } + + fn html_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + } + + /// GET / HTML for the one public Host. Same-host path list. Not an explorer. + pub fn portal_index_html(_cfg: &SploraProxyConfig) -> String { + let mut items = String::new(); + for href in PUBLIC_PATH_HREFS { + let h = html_escape(href); + items.push_str(&format!("
  • {h}
  • \n")); + } + format!( + "\n\ +\n\ +Splora\n\ +\n\ +

    Splora

    \n\ +

    Paths on this host:

    \n\ +
      \n{items}
    \n\ +\n\ +\n" + ) + } + + fn portal_response(cfg: &SploraProxyConfig, method: &Method, path: &str) -> Response { + let p = path.split('?').next().unwrap_or(path); + if p != "/" { + return (StatusCode::NOT_FOUND, "not found").into_response(); + } + if *method != Method::GET { + return (StatusCode::METHOD_NOT_ALLOWED, "GET only").into_response(); + } + ( + StatusCode::OK, + [(header::CONTENT_TYPE, "text/html; charset=utf-8")], + portal_index_html(cfg), + ) + .into_response() + } + fn refuse_electrum_path(path: &str, label: &str) -> Result<(), String> { let lower = path.to_ascii_lowercase(); if lower.contains("electrum") { @@ -1277,11 +1535,8 @@ pub(crate) mod splora { seen_hosts.insert(n.clone(), name.clone()); hosts.push(n); } - if hosts.is_empty() { - return Err(format!( - "SURMOUNT_SPLORA_INSTANCES[{name}] must list at least one Host" - )); - } + // Empty hosts is the path-routing map: one portal Host, no + // per-network public Host. The instance name still selects the socket. out.push(SploraInstance { name: name.clone(), hosts, @@ -1317,8 +1572,33 @@ pub(crate) mod splora { return proxy_queue(state, request).await; } + if cfg.is_portal_host(&host) { + let decision = { + let enabled = cfg.enabled_instance_names(); + route_splora_public(&path, request.uri().query(), &cfg.queue_path, &enabled) + }; + return match decision { + 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::Proxy { + socket_name, + backend_path, + websocket, + .. + } => { + let Some(instance) = cfg.instance_by_name(socket_name).cloned() else { + return (StatusCode::NOT_FOUND, "not found").into_response(); + }; + proxy_indexer(state, request, instance, host, backend_path, websocket).await + } + }; + } + if let Some(instance) = cfg.instance_for_host(&host).cloned() { - return proxy_indexer(state, request, instance, host).await; + let backend_path = path.clone(); + let websocket = backend_path == INDEXER_WS_PATH; + return proxy_indexer(state, request, instance, host, backend_path, websocket).await; } next.run(request).await @@ -1388,19 +1668,20 @@ pub(crate) mod splora { req: Request, instance: SploraInstance, host: String, + backend_path: String, + websocket: bool, ) -> Response { - let path = req.uri().path().to_string(); let query = req.uri().query().map(str::to_string); let method = req.method().clone(); let headers = req.headers().clone(); let proto = forwarded_proto(state.config.listen_mode.is_https()); - let wants_ws = path == INDEXER_WS_PATH && is_websocket_upgrade(&headers); + let wants_ws = websocket && is_websocket_upgrade(&headers); if wants_ws { return proxy_unix_websocket( instance.http_socket, req, - &path, + &backend_path, query.as_deref(), &host, proto, @@ -1419,7 +1700,7 @@ pub(crate) mod splora { proxy_unix_http( instance.http_socket.as_path(), method, - &path, + &backend_path, query.as_deref(), &host, proto, @@ -1857,6 +2138,629 @@ pub(crate) mod splora { assert!(cfg.should_bypass_auth("esplora.example.test", "/api/tx/abc")); assert!(cfg.should_bypass_auth("services.example.test", "/splora/queue")); assert!(!cfg.should_bypass_auth("services.example.test", "/health")); + assert!(cfg.portal_host.is_none()); + } + + fn four_live_mainnet_off_cfg(portal: &str) -> SploraProxyConfig { + SploraProxyConfig { + enable: true, + instances: vec![ + SploraInstance { + name: "testnet3".into(), + hosts: vec![], + http_socket: PathBuf::from("/run/splora/testnet3.http.sock"), + }, + SploraInstance { + name: "testnet4".into(), + hosts: vec![], + http_socket: PathBuf::from("/run/splora/testnet4.http.sock"), + }, + SploraInstance { + name: "mutinynet".into(), + hosts: vec![], + http_socket: PathBuf::from("/run/splora/mutinynet.http.sock"), + }, + SploraInstance { + name: "liquid".into(), + hosts: vec![], + http_socket: PathBuf::from("/run/splora/liquid.http.sock"), + }, + ], + queue_socket: PathBuf::from(DEFAULT_QUEUE_SOCKET), + queue_path: DEFAULT_QUEUE_PATH.into(), + body_limit_bytes: DEFAULT_BODY_LIMIT_BYTES, + portal_host: Some(portal.to_string()), + } + } + + const FOUR_LIVE: &[&str] = &["testnet3", "testnet4", "mutinynet", "liquid"]; + + fn route_four(path: &str, query: Option<&str>) -> SploraPublicRoute { + route_splora_public(path, query, DEFAULT_QUEUE_PATH, FOUR_LIVE) + } + + fn expect_proxy( + route: SploraPublicRoute, + socket: &str, + backend: &str, + query: Option<&str>, + websocket: bool, + ) { + match route { + SploraPublicRoute::Proxy { + socket_name, + backend_path, + query: got_q, + websocket: got_ws, + } => { + assert_eq!(socket_name, socket); + assert_eq!(backend_path, backend); + assert_eq!(got_q.as_deref(), query); + assert_eq!(got_ws, websocket); + } + other => panic!("expected proxy to {socket} {backend}, got {other:?}"), + } + } + + /// Named contract: each public prefix selects that network socket. + /// REST drops `/api` (Splora http_front). Mainnet is absent here. + #[test] + fn splora_public_prefix_forwards_to_named_socket() { + expect_proxy( + route_four("/testnet/api/tx/x", None), + "testnet3", + "/tx/x", + None, + false, + ); + expect_proxy( + route_four("/testnet4/api/v1/blocks/tip/height", None), + "testnet4", + "/v1/blocks/tip/height", + None, + false, + ); + expect_proxy( + route_four("/signet/api/tx/x", None), + "mutinynet", + "/tx/x", + None, + false, + ); + expect_proxy( + route_four("/mutinynet/api/tx/x", None), + "mutinynet", + "/tx/x", + None, + false, + ); + expect_proxy( + route_four("/liquid/api/tx/x", None), + "liquid", + "/tx/x", + None, + false, + ); + assert!(matches!( + route_four("/api/tx/x", None), + SploraPublicRoute::NotFound + )); + } + + /// Named contract: public `/testnet3/...` is not the testnet prefix. + #[test] + fn splora_testnet_is_not_testnet3_prefix() { + assert!(matches!( + route_four("/testnet3/api/tx/x", None), + SploraPublicRoute::NotFound + )); + assert!(matches!( + route_four("/testnet3", None), + SploraPublicRoute::NotFound + )); + assert!(matches!( + route_four("/testnet30/api/x", None), + SploraPublicRoute::NotFound + )); + expect_proxy( + route_four("/testnet/api/tx/x", None), + "testnet3", + "/tx/x", + None, + false, + ); + expect_proxy( + route_four("/testnet4/api/tx/x", None), + "testnet4", + "/tx/x", + None, + false, + ); + } + + /// Named contract: `/signet` and `/mutinynet` share mutinynet. No redirect. + #[test] + fn splora_signet_and_mutinynet_share_socket_without_redirect() { + let signet = route_four("/signet/api/v1/blocks/tip/height", Some("n=1")); + let mutiny = route_four("/mutinynet/api/v1/blocks/tip/height", Some("n=1")); + expect_proxy( + signet.clone(), + "mutinynet", + "/v1/blocks/tip/height", + Some("n=1"), + false, + ); + expect_proxy( + mutiny.clone(), + "mutinynet", + "/v1/blocks/tip/height", + Some("n=1"), + false, + ); + assert_eq!(signet, mutiny); + assert!(!matches!(signet, SploraPublicRoute::NotFound)); + } + + /// Named contract: network prefix strip keeps the query string. + /// `/testnet/api/v1/tx/abc?x=1` is testnet3, path `/v1/tx/abc`, query `x=1` + /// (same `/api` strip as Splora `http_front::backend_path`). + #[test] + fn splora_query_string_kept_after_prefix_strip() { + expect_proxy( + route_four("/testnet/api/v1/tx/abc", Some("x=1")), + "testnet3", + "/v1/tx/abc", + Some("x=1"), + false, + ); + expect_proxy( + route_four("/liquid/api/tx/x", Some("verbose=1&limit=2")), + "liquid", + "/tx/x", + Some("verbose=1&limit=2"), + false, + ); + expect_proxy( + route_four("/testnet4/api/v1/blocks/tip/height", Some("x=1")), + "testnet4", + "/v1/blocks/tip/height", + Some("x=1"), + false, + ); + } + + /// 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. + #[tokio::test] + async fn splora_ui_paths_forward_without_html() { + expect_proxy( + route_four("/testnet4/tx/abc", Some("x=1")), + "testnet4", + "/tx/abc", + Some("x=1"), + false, + ); + assert!(!matches!( + route_four("/testnet4/tx/abc", Some("x=1")), + SploraPublicRoute::Index + )); + expect_proxy( + route_four("/testnet4/block/abc", None), + "testnet4", + "/block/abc", + None, + false, + ); + expect_proxy( + route_four("/testnet/address/abc", None), + "testnet3", + "/address/abc", + None, + false, + ); + expect_proxy( + route_four("/liquid/block/abc", None), + "liquid", + "/block/abc", + 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); + + for path in [ + "/block/abc", + "/tx/abc", + "/address/abc", + "/block", + "/tx", + "/address", + ] { + assert!( + matches!(route_four(path, None), SploraPublicRoute::NotFound), + "{path} must stay unmatched" + ); + assert!( + matches!( + route_splora_public(path, None, DEFAULT_QUEUE_PATH, &["mainnet"]), + SploraPublicRoute::NotFound + ), + "{path} must stay unmatched even when mainnet is on" + ); + } + + let sock = tmp_sock("splora-ui"); + let seen = Arc::new(Mutex::new(Vec::<(String, String)>::new())); + let seen_c = seen.clone(); + let mock = Router::new().fallback(any(move |req: AxumRequest| { + let seen_c = seen_c.clone(); + async move { + let path = req.uri().path().to_string(); + let query = req.uri().query().unwrap_or("").to_string(); + seen_c.lock().unwrap().push((path, query)); + ( + StatusCode::OK, + [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], + "upstream-bytes", + ) + } + })); + let mock_serve = serve_uds(sock.clone(), mock).await; + tokio::time::sleep(Duration::from_millis(20)).await; + + let cfg = SploraProxyConfig { + enable: true, + instances: vec![ + SploraInstance { + name: "testnet3".into(), + hosts: vec![], + http_socket: PathBuf::from("/run/splora/testnet3.http.sock"), + }, + SploraInstance { + name: "testnet4".into(), + hosts: vec![], + http_socket: sock.clone(), + }, + SploraInstance { + name: "mutinynet".into(), + hosts: vec![], + http_socket: PathBuf::from("/run/splora/mutinynet.http.sock"), + }, + SploraInstance { + name: "liquid".into(), + hosts: vec![], + http_socket: PathBuf::from("/run/splora/liquid.http.sock"), + }, + ], + queue_socket: PathBuf::from(DEFAULT_QUEUE_SOCKET), + queue_path: DEFAULT_QUEUE_PATH.into(), + body_limit_bytes: DEFAULT_BODY_LIMIT_BYTES, + portal_host: Some("splora.surmount.systems".into()), + }; + let state = test_state_for(cfg, false); + let app = Router::new() + .route("/tx/abc", get(|| async { "console-tx" })) + .fallback(axum::routing::any(|| async { StatusCode::NOT_FOUND })) + .layer(axum::middleware::from_fn_with_state( + state.clone(), + splora_proxy_middleware, + )) + .with_state(state); + let lis = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = lis.local_addr().unwrap(); + let serve = tokio::spawn(async move { + axum::serve(lis, app).await.ok(); + }); + let client = reqwest::Client::new(); + + let tx = client + .get(format!("http://{addr}/testnet4/tx/abc?x=1")) + .header("Host", "splora.surmount.systems") + .send() + .await + .unwrap(); + assert_eq!(tx.status(), reqwest::StatusCode::OK); + let tx_type = tx + .headers() + .get(reqwest::header::CONTENT_TYPE) + .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}" + ); + let tx_body = tx.text().await.unwrap(); + assert_eq!(tx_body, "upstream-bytes"); + assert!(!tx_body.contains(" `/api` is 404. + #[test] + fn splora_mainnet_api_is_404_when_indexer_disabled() { + for path in [ + "/api", + "/api/", + "/api/tx/x", + "/api/v1/blocks/tip/height", + "/api/v1/ws", + ] { + assert!( + matches!(route_four(path, Some("q=1")), SploraPublicRoute::NotFound), + "{path} must be 404 while mainnet is off" + ); + } + expect_proxy( + route_splora_public( + "/api/tx/x", + Some("q=1"), + DEFAULT_QUEUE_PATH, + &["mainnet", "testnet4"], + ), + "mainnet", + "/tx/x", + Some("q=1"), + false, + ); + let parsed = parse_instances_json( + r#"{"testnet4":{"hosts":[]},"liquid":{"hosts":[]}}"#, + "/run/splora", + ) + .expect("empty hosts still enable path routing"); + assert!(parsed.iter().all(|inst| inst.hosts.is_empty())); + assert!(parsed.iter().all(|inst| inst.name != "mainnet")); + } + + /// Named contract: GET / lists same-host paths and not esplora Hosts. + #[test] + fn splora_root_lists_same_host_paths_without_esplora_host() { + let cfg = four_live_mainnet_off_cfg("splora.surmount.systems"); + let html = portal_index_html(&cfg); + for href in PUBLIC_PATH_HREFS { + assert!( + html.contains(&format!("href=\"{href}\"")), + "missing {href} in {html}" + ); + } + assert!(html.contains("href=\"/testnet4/\"")); + assert!( + !html.contains("esplora.surmount.systems"), + "path list must not name an esplora Host: {html}" + ); + assert!(!html.contains("/block")); + assert!(!html.contains("/tx/")); + assert!(!html.contains("/address")); + assert!(cfg.should_bypass_auth("splora.surmount.systems", "/testnet4/api/tx/x")); + assert!(cfg.instance_for_host("splora.surmount.systems").is_none()); + let extra = cfg.extra_onion_and_redirect_hosts(); + assert_eq!(extra, vec!["splora.surmount.systems".to_string()]); + } + + /// Named contract: portal GET / is 200 HTML, a same-host path list. + #[test] + fn portal_html_lists_same_host_paths_not_esplora_hosts() { + let cfg = four_live_mainnet_off_cfg("splora.surmount.systems"); + let html = portal_index_html(&cfg); + assert!(html.contains("href=\"/testnet4/\"")); + assert!(!html.contains("esplora.surmount.systems"), "{html}"); + assert!(cfg.should_bypass_auth("splora.surmount.systems", "/")); + assert!(cfg.is_portal_host("Splora.surmount.systems")); + assert!(cfg.instance_for_host("splora.surmount.systems").is_none()); + let extra = cfg.extra_onion_and_redirect_hosts(); + assert!(extra.iter().any(|h| h == "splora.surmount.systems")); + assert!( + extra + .iter() + .all(|h| !h.contains("esplora.surmount.systems")), + "{extra:?}" + ); + } + + #[test] + fn portal_host_colliding_with_indexer_fails_closed() { + let err = SploraProxyConfig::from_env_map(|k| match k { + "SURMOUNT_SPLORA_PROXY" => Some("1".into()), + "SURMOUNT_SPLORA_PORTAL_HOST" => Some("testnet3.esplora.surmount.systems".into()), + "SURMOUNT_SPLORA_INSTANCES" => { + Some(r#"{"testnet3":{"hosts":["testnet3.esplora.surmount.systems"]}}"#.into()) + } + _ => None, + }) + .unwrap_err(); + assert!(err.contains("PORTAL_HOST"), "{err}"); + assert!( + err.contains("fifth indexer") || err.contains("not also"), + "{err}" + ); + } + + #[test] + fn portal_env_ignored_when_proxy_off() { + let cfg = SploraProxyConfig::from_env_map(|k| match k { + "SURMOUNT_SPLORA_PORTAL_HOST" => Some("splora.surmount.systems".into()), + _ => None, + }) + .unwrap(); + assert!(!cfg.enable); + assert!(cfg.portal_host.is_none()); + assert!(!cfg.is_portal_host("splora.surmount.systems")); + } + + /// Named contract: GET / on Host splora.surmount.systems is 200 HTML. + #[tokio::test] + async fn splora_portal_get_root_is_200_html_listing_live_hosts() { + let cfg = four_live_mainnet_off_cfg("splora.surmount.systems"); + let state = test_state_for(cfg, false); + let app = Router::new() + .fallback(axum::routing::any(|| async { StatusCode::NOT_FOUND })) + .layer(axum::middleware::from_fn_with_state( + state.clone(), + splora_proxy_middleware, + )) + .with_state(state); + let lis = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = lis.local_addr().unwrap(); + let serve = tokio::spawn(async move { + axum::serve(lis, app).await.ok(); + }); + let client = reqwest::Client::new(); + let resp = client + .get(format!("http://{addr}/")) + .header("Host", "splora.surmount.systems") + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::OK); + let ctype = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!( + ctype.contains("text/html"), + "portal GET / must be HTML, content-type={ctype}" + ); + let body = resp.text().await.unwrap(); + for href in PUBLIC_PATH_HREFS { + assert!( + body.contains(&format!("href=\"{href}\"")), + "missing {href}: {body}" + ); + } + assert!( + !body.contains("esplora.surmount.systems"), + "portal GET / must not name an esplora Host: {body}" + ); + assert!(!body.contains("/block")); + assert!(!body.contains("/tx/")); + assert!(!body.contains("/address")); + serve.abort(); } fn test_state_for(cfg: SploraProxyConfig, https: bool) -> Arc { @@ -1999,6 +2903,7 @@ pub(crate) mod splora { queue_socket: PathBuf::from("/tmp/splora-queue-unused.sock"), queue_path: DEFAULT_QUEUE_PATH.into(), body_limit_bytes: DEFAULT_BODY_LIMIT_BYTES, + portal_host: None, }; let state = test_state_for(cfg, true); let app = Router::new() @@ -2105,6 +3010,7 @@ pub(crate) mod splora { queue_socket: q_sock.clone(), queue_path: DEFAULT_QUEUE_PATH.into(), body_limit_bytes: DEFAULT_BODY_LIMIT_BYTES, + portal_host: None, }; let state = test_state_for(cfg, false); let app = Router::new() @@ -2189,6 +3095,7 @@ pub(crate) mod splora { queue_socket: PathBuf::from("/tmp/splora-queue-unused.sock"), queue_path: DEFAULT_QUEUE_PATH.into(), body_limit_bytes: DEFAULT_BODY_LIMIT_BYTES, + portal_host: None, }; let state = test_state_for(cfg, true); let app = Router::new() diff --git a/crates/management-ui/src/tls.rs b/crates/management-ui/src/tls.rs index 44b2083..30a732f 100644 --- a/crates/management-ui/src/tls.rs +++ b/crates/management-ui/src/tls.rs @@ -1422,6 +1422,7 @@ pub(crate) mod http3 { queue_socket: PathBuf::from("/tmp/splora-h3-queue-unused.sock"), queue_path: DEFAULT_QUEUE_PATH.into(), body_limit_bytes: DEFAULT_BODY_LIMIT_BYTES, + portal_host: None, }; let http3_cfg = Http3Config { enable: true, diff --git a/crates/surmount-acme-namecheap/src/hook.rs b/crates/surmount-acme-namecheap/src/hook.rs index 76b9849..eb2b6cb 100644 --- a/crates/surmount-acme-namecheap/src/hook.rs +++ b/crates/surmount-acme-namecheap/src/hook.rs @@ -216,8 +216,9 @@ fn cmd_wait( /// and [setHosts](https://www.namecheap.com/support/api/methods/domains-dns/set-hosts/) /// (accessed: 2026-09-07). Tests override the API base to a local HTTP /// listener. Never live Namecheap as CI green. Lives in this tracked file -/// so crane git-filtered source includes it. -mod live { +/// so crane git-filtered source includes it. `surmount-dns-zone --live` +/// reuses this module (same XML API, ClientIp required). +pub mod live { use anyhow::Result; use crate::cred::{Credentials, die}; @@ -233,7 +234,12 @@ mod live { } pub fn get_hosts(cred: &Credentials) -> Result { - require_client_ip(cred)?; + get_hosts_with_prefix(cred, PREFIX) + } + + /// Same getHosts path with a caller prefix (dns-zone errors). + pub fn get_hosts_with_prefix(cred: &Credentials, prefix: &str) -> Result { + require_client_ip(cred, prefix)?; let xml = api_get( cred, &[ @@ -241,21 +247,27 @@ mod live { ("SLD", cred.sld.as_str()), ("TLD", cred.tld.as_str()), ], + prefix, )?; - parse_get_hosts(&xml) + parse_get_hosts(&xml, prefix) } pub fn set_hosts(cred: &Credentials, hosts: &Hosts) -> Result<()> { - require_client_ip(cred)?; + set_hosts_with_prefix(cred, hosts, PREFIX) + } + + /// Same setHosts path with a caller prefix (dns-zone errors). + pub fn set_hosts_with_prefix(cred: &Credentials, hosts: &Hosts, prefix: &str) -> Result<()> { + require_client_ip(cred, prefix)?; if hosts.records.is_empty() { return Err(die( - PREFIX, + prefix, "getHosts returned no records (refuse setHosts wipe)", )); } if hosts.email_type.is_empty() { return Err(die( - PREFIX, + prefix, "getHosts missing EmailType (refuse setHosts that would un-publish)", )); } @@ -277,14 +289,14 @@ mod live { .iter() .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); - let xml = api_get(cred, &q)?; - parse_set_hosts_ok(&xml) + let xml = api_get(cred, &q, prefix)?; + parse_set_hosts_ok(&xml, prefix) } - fn require_client_ip(cred: &Credentials) -> Result<()> { + fn require_client_ip(cred: &Credentials, prefix: &str) -> Result<()> { if cred.client_ip.trim().is_empty() { return Err(die( - PREFIX, + prefix, "BLOCKED: live Namecheap API requires ClientIp (laptop egress whitelist)", )); } @@ -292,20 +304,25 @@ mod live { } fn api_base() -> String { - std::env::var("SURMOUNT_ACME_DNS_NAMECHEAP_API_BASE") + std::env::var("SURMOUNT_DNS_ZONE_NAMECHEAP_API_BASE") .ok() .filter(|s| !s.is_empty()) + .or_else(|| { + std::env::var("SURMOUNT_ACME_DNS_NAMECHEAP_API_BASE") + .ok() + .filter(|s| !s.is_empty()) + }) .unwrap_or_else(|| DEFAULT_API.to_string()) } - fn api_get(cred: &Credentials, extra: &[(&str, &str)]) -> Result { + fn api_get(cred: &Credentials, extra: &[(&str, &str)], prefix: &str) -> Result { let base = api_base(); let https_only = base.starts_with("https://"); let client = reqwest::blocking::Client::builder() .timeout(std::time::Duration::from_secs(60)) .https_only(https_only) .build() - .map_err(|_| die(PREFIX, "Namecheap API HTTP client failed (URL not logged)"))?; + .map_err(|_| die(prefix, "Namecheap API HTTP client failed (URL not logged)"))?; let mut q: Vec<(&str, &str)> = vec![ ("ApiUser", cred.api_user.as_str()), ("ApiKey", cred.api_key.as_str()), @@ -317,15 +334,15 @@ mod live { .get(&base) .query(&q) .send() - .map_err(|_| die(PREFIX, "Namecheap API HTTP failed (URL not logged)"))?; + .map_err(|_| die(prefix, "Namecheap API HTTP failed (URL not logged)"))?; let status = resp.status(); let body = resp .text() - .map_err(|_| die(PREFIX, "Namecheap API body unreadable (URL not logged)"))?; + .map_err(|_| die(prefix, "Namecheap API body unreadable (URL not logged)"))?; if !status.is_success() { - return Err(api_xml_error(&body).unwrap_or_else(|| { + return Err(api_xml_error(&body, prefix).unwrap_or_else(|| { die( - PREFIX, + prefix, "Namecheap API HTTP status failed (URL and body not logged)", ) })); @@ -333,7 +350,7 @@ mod live { Ok(body) } - fn api_xml_error(xml: &str) -> Option { + fn api_xml_error(xml: &str, prefix: &str) -> Option { let low = xml.to_ascii_lowercase(); let num = xml_attr(xml, "Number").unwrap_or_default(); let num = if num.chars().all(|c| c.is_ascii_digit()) && !num.is_empty() { @@ -348,7 +365,7 @@ mod live { }; if low.contains("invalid request ip") { return Some(die( - PREFIX, + prefix, format!( "BLOCKED: Invalid request IP (Namecheap ClientIp whitelist of laptop egress){num_note}" ), @@ -362,7 +379,7 @@ mod live { let msg_l = msg.to_ascii_lowercase(); if msg_l.contains("invalid request ip") { return Some(die( - PREFIX, + prefix, format!( "BLOCKED: Invalid request IP (Namecheap ClientIp whitelist of laptop egress){num_note}" ), @@ -372,39 +389,39 @@ mod live { && (msg_l.contains("name server") || msg_l.contains("dns")) { return Some(die( - PREFIX, + prefix, format!("Namecheap API: zone is not on Namecheap hosted DNS{num_note}"), )); } if msg_l.contains("domain not found") || msg_l.contains("no longer exists") { return Some(die( - PREFIX, + prefix, format!("Namecheap API: domain not found{num_note}"), )); } if msg_l.contains("api key") { return Some(die( - PREFIX, + prefix, format!("Namecheap API: credentials rejected{num_note}"), )); } return Some(die( - PREFIX, + prefix, format!("Namecheap API error{num_note} (body not logged; check ClientIp and zone)"), )); } None } - fn parse_get_hosts(xml: &str) -> Result { - if let Some(e) = api_xml_error(xml) { + fn parse_get_hosts(xml: &str, prefix: &str) -> Result { + if let Some(e) = api_xml_error(xml, prefix) { return Err(e); } if xml_attr(xml, "Status").as_deref() != Some("OK") && !xml.to_ascii_lowercase().contains("status=\"ok\"") && !xml.to_ascii_lowercase().contains("status='ok'") { - return Err(die(PREFIX, "Namecheap getHosts XML missing Status=OK")); + return Err(die(prefix, "Namecheap getHosts XML missing Status=OK")); } let email_type = xml_attr(xml, "EmailType").unwrap_or_default(); let mut records = Vec::new(); @@ -424,7 +441,7 @@ mod live { } if records.is_empty() { return Err(die( - PREFIX, + prefix, "getHosts returned no records (refuse setHosts wipe)", )); } @@ -434,8 +451,8 @@ mod live { }) } - fn parse_set_hosts_ok(xml: &str) -> Result<()> { - if let Some(e) = api_xml_error(xml) { + fn parse_set_hosts_ok(xml: &str, prefix: &str) -> Result<()> { + if let Some(e) = api_xml_error(xml, prefix) { return Err(e); } let low = xml.to_ascii_lowercase(); @@ -446,7 +463,7 @@ mod live { { return Ok(()); } - Err(die(PREFIX, "Namecheap setHosts did not report success")) + Err(die(prefix, "Namecheap setHosts did not report success")) } fn nonempty(s: String, fallback: &str) -> String { @@ -537,7 +554,7 @@ mod live { #[test] fn parse_get_hosts_records_and_email_type() { let xml = r#""#; - let h = parse_get_hosts(xml).unwrap(); + let h = parse_get_hosts(xml, PREFIX).unwrap(); assert_eq!(h.email_type, "MX"); assert_eq!(h.records.len(), 2); assert_eq!(h.records[0].name, "@"); @@ -547,7 +564,7 @@ mod live { #[test] fn parse_get_hosts_invalid_request_ip() { let xml = r#"Invalid request IP"#; - let err = parse_get_hosts(xml).unwrap_err().to_string(); + let err = parse_get_hosts(xml, PREFIX).unwrap_err().to_string(); assert!(err.to_ascii_lowercase().contains("invalid request ip")); assert!(err.contains("BLOCKED")); } @@ -555,7 +572,7 @@ mod live { #[test] fn parse_get_hosts_empty_refuses_wipe() { let xml = r#""#; - let err = parse_get_hosts(xml).unwrap_err().to_string(); + let err = parse_get_hosts(xml, PREFIX).unwrap_err().to_string(); assert!(err.to_ascii_lowercase().contains("no records")); } } diff --git a/crates/surmount-acme-namecheap/src/laptop.rs b/crates/surmount-acme-namecheap/src/laptop.rs index f4f62e1..9e3c9b3 100644 --- a/crates/surmount-acme-namecheap/src/laptop.rs +++ b/crates/surmount-acme-namecheap/src/laptop.rs @@ -3,8 +3,9 @@ use std::ffi::OsString; use std::fs; use std::os::unix::fs::PermissionsExt; +use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Child, Command}; use std::thread; use std::time::{Duration, Instant}; @@ -501,22 +502,29 @@ fn do_issue(cfg: &Cfg, label: &str, url: &str) -> anyhow::Result { child.env("SURMOUNT_ACME_EMAIL", &cfg.email); } child.env_remove("SURMOUNT_ACME_DNS_NAMECHEAP_ENV"); + // Own group so a parked post-issue sleep dies with the shell. + child.process_group(0); let mut child = child .spawn() .map_err(|e| die("laptop-renew-cert", format!("ui-bin: {e}")))?; let deadline = Instant::now() + Duration::from_secs(cfg.timeout_secs); + // File creation is not issuance. A shell redirect creates the PEM path + // before later startup lines (env record, then the long sleep). Exit 0 + // only after that child has exited with PEM bytes, or has been parked + // on two polls with those bytes present. A timeout is not success. + let mut parked_hits: u8 = 0; loop { if Instant::now() >= deadline { - let _ = child.kill(); - let _ = child.wait(); - return Err(issue_fail(cfg, "issue timed out waiting for PEMs")); + stop_issuer(&mut child); + return Err(issue_fail( + cfg, + "issue timed out before the issuer finished", + )); } match child.try_wait() { Ok(Some(st)) => { - if cert.is_file() && key.is_file() { - eprintln!("laptop-renew-cert: issue: directory={label} (host ACME stays off)"); - assert_issued_sans_cover_requested(cfg)?; - return Ok(0); + if issued_pems_ready(&cert, &key) { + return finish_issue(cfg, label); } if !st.success() { return Err(issue_fail( @@ -530,23 +538,75 @@ fn do_issue(cfg: &Cfg, label: &str, url: &str) -> anyhow::Result { return Err(issue_fail(cfg, "ui-bin exited before PEMs")); } Ok(None) => { - if cert.is_file() && key.is_file() { - let _ = child.kill(); - let _ = child.wait(); - eprintln!("laptop-renew-cert: issue: directory={label} (host ACME stays off)"); - assert_issued_sans_cover_requested(cfg)?; - return Ok(0); + if issued_pems_ready(&cert, &key) && issuer_parked(child.id()) { + parked_hits = parked_hits.saturating_add(1); + if parked_hits >= 2 { + stop_issuer(&mut child); + return finish_issue(cfg, label); + } + } else { + parked_hits = 0; } thread::sleep(Duration::from_millis(50)); } Err(e) => { - let _ = child.kill(); + stop_issuer(&mut child); return Err(die("laptop-renew-cert", format!("ui-bin wait: {e}"))); } } } } +fn finish_issue(cfg: &Cfg, label: &str) -> anyhow::Result { + eprintln!("laptop-renew-cert: issue: directory={label} (host ACME stays off)"); + assert_issued_sans_cover_requested(cfg)?; + Ok(0) +} + +fn issued_pems_ready(cert: &Path, key: &Path) -> bool { + issued_pem_ready(cert) && issued_pem_ready(key) +} + +fn issued_pem_ready(path: &Path) -> bool { + let Ok(meta) = fs::metadata(path) else { + return false; + }; + if !meta.is_file() { + return false; + } + fs::read(path).is_ok_and(|bytes| bytes.starts_with(b"-----BEGIN ")) +} + +/// Interruptible sleep (`S`) or idle (`I`) in `/proc//stat`. +/// Disk sleep and running do not count: those are still startup. +fn issuer_parked(pid: u32) -> bool { + let Ok(text) = fs::read_to_string(format!("/proc/{pid}/stat")) else { + return false; + }; + let Some((_, after)) = text.rsplit_once(')') else { + return false; + }; + matches!(after.trim_start().chars().next(), Some('S' | 'I')) +} + +fn stop_issuer(child: &mut Child) { + if let Ok(pid) = i32::try_from(child.id()) { + unsafe extern "C" { + fn kill(pid: i32, sig: i32) -> i32; + } + const SIGKILL: i32 = 9; + // SAFETY: pid is this child. process_group(0) set pgid to that pid. + // Negative pid signals only that group. + let signaled = unsafe { kill(-pid, SIGKILL) }; + if signaled != 0 { + let _ = child.kill(); + } + } else { + let _ = child.kill(); + } + let _ = child.wait(); +} + fn issue_fail(cfg: &Cfg, msg: impl std::fmt::Display) -> anyhow::Error { let log = cfg.work_dir.join("hook-stderr.log"); if let Ok(t) = fs::read_to_string(&log) { diff --git a/crates/surmount-deploy-host/src/keys.rs b/crates/surmount-deploy-host/src/keys.rs index 84a3e94..ceb389d 100644 --- a/crates/surmount-deploy-host/src/keys.rs +++ b/crates/surmount-deploy-host/src/keys.rs @@ -93,13 +93,34 @@ pub fn check_host_local(dir: &Path, notes: &mut dyn std::io::Write) -> Result<() "deploy-host: note: no hardware-configuration.nix under host-local (ok if already on host)" ); } - let _ = writeln!( - notes, - "deploy-host: note: host-local key files present; flake path: rebuild auto-wires authorized_keys into evaluated config when using known-name layout (or host-local/default.nix that imports them). File presence alone is not enough / not sufficient if default.nix omits keys. See docs/deploy-host-local.md" - ); + // Known-name layout (no default.nix) auto-wires authorized_keys. + // A custom default.nix that already sets authorizedKeys is enough. + // Warn only when that entry module exists and does not mention keys. + if entry_module_omits_keys(dir) { + let _ = writeln!( + notes, + "deploy-host: note: host-local default.nix does not mention authorizedKeys. File presence alone is not enough. Wire users.users.root.openssh.authorizedKeys in that file or drop default.nix so the flake auto-wires host-local/authorized_keys. See docs/deploy-host-local.md" + ); + } Ok(()) } +/// True when default.nix or host-local.nix exists and never mentions SSH keys. +fn entry_module_omits_keys(dir: &Path) -> bool { + for name in ["default.nix", "host-local.nix"] { + let path = dir.join(name); + if !path.is_file() { + continue; + } + let Ok(text) = fs::read_to_string(&path) else { + return true; + }; + let mentions = text.contains("authorizedKeys") || text.contains("authorized_keys"); + return !mentions; + } + false +} + #[cfg(test)] mod tests { use super::*; @@ -118,6 +139,50 @@ mod tests { assert_eq!(count_authorized_key_lines(&dir).unwrap(), 0); } + #[test] + fn wired_default_nix_does_not_warn() { + let dir = std::env::temp_dir().join(format!("surmount-keys-wired-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join("authorized_keys"), + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILEpaPf6ltFz4UT0qajfxQojuKuVF8FVE4Kj5U54k+gM test\n", + ) + .unwrap(); + fs::write( + dir.join("default.nix"), + "{ users.users.root.openssh.authorizedKeys.keys = [ \"ssh-ed25519 AAAA\" ]; }\n", + ) + .unwrap(); + let mut notes = Vec::new(); + check_host_local(&dir, &mut notes).unwrap(); + let text = String::from_utf8(notes).unwrap(); + assert!(!text.contains("does not mention authorizedKeys"), "{text}"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn default_nix_without_keys_warns() { + let dir = std::env::temp_dir().join(format!("surmount-keys-omit-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join("authorized_keys"), + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILEpaPf6ltFz4UT0qajfxQojuKuVF8FVE4Kj5U54k+gM test\n", + ) + .unwrap(); + fs::write( + dir.join("default.nix"), + "{ networking.hostName = \"x\"; }\n", + ) + .unwrap(); + let mut notes = Vec::new(); + check_host_local(&dir, &mut notes).unwrap(); + let text = String::from_utf8(notes).unwrap(); + assert!(text.contains("does not mention authorizedKeys"), "{text}"); + let _ = fs::remove_dir_all(&dir); + } + #[test] fn short_placeholder_counts_zero() { let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/surmount-deploy-host/src/run.rs b/crates/surmount-deploy-host/src/run.rs index 8ce171c..7b5805d 100644 --- a/crates/surmount-deploy-host/src/run.rs +++ b/crates/surmount-deploy-host/src/run.rs @@ -286,6 +286,7 @@ fn run_deploy(opts: DeployOpts) -> Result<(), ToolError> { "deploy-host: running secrets-install-host (host-id={}; values not logged)", opts.secrets_host_id.as_deref().unwrap_or("") ); + // The bridge may already have written host secrets. Do not retry it. run_cmd(&secrets_cmd)?; } } @@ -304,6 +305,12 @@ fn run_deploy(opts: DeployOpts) -> Result<(), ToolError> { let mut rebuild_rc = 0i32; let mut smoke_rc = 0i32; 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)" ); @@ -313,7 +320,7 @@ fn run_deploy(opts: DeployOpts) -> Result<(), ToolError> { shell_quote(&rebuild_remote_cmd) ); println!( - "deploy-host: dry-run: post-switch smoke always runs after rebuild (even if rebuild exit non-zero): ssh -- {} {}", + "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) ); @@ -321,13 +328,30 @@ fn run_deploy(opts: DeployOpts) -> Result<(), ToolError> { "deploy-host: dry-run: smoke checks generation + systemctl is-active sshd stalwart-mail surmount-management-ui (start UI if inactive) + loopback /health" ); } else { - rebuild_rc = ssh_run_rc(&opts.target, &rebuild_remote_cmd); + // One switch attempt. A drop after this starts may have changed the host. + let finish = ssh_finish(&opts.target, &rebuild_remote_cmd); + 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}"); eprintln!( - "deploy-host: running post-switch smoke on {} (always after rebuild)", + "deploy-host: running post-switch smoke on {} (after a finished rebuild)", opts.target ); - smoke_rc = ssh_run_rc(&opts.target, &smoke_remote_cmd); + 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}"); } @@ -456,26 +480,40 @@ fn ssh_rsh(target: &str) -> String { .join(" ") } -fn ssh_run(host: &str, remote_cmd: &str) -> Result<(), ToolError> { - let extra = ssh_extra_args(host); - let st = Command::new(ssh_bin()) - .args(&extra) - .arg("--") - .arg(host) - .arg(remote_cmd) - .status() - .map_err(|e| ToolError::fail(format!("ssh failed: {e}")))?; - if st.success() { - Ok(()) - } else { - Err(ToolError::fail(format!( - "ssh -- {host} exited {}", - st.code().unwrap_or(1) - ))) +/// OpenSSH client failure is 255. A remote command that finished uses its +/// own status, including 1. No code means the client died on a signal. +const SSH_ATTEMPTS: u32 = 3; + +#[derive(Debug, PartialEq, Eq)] +enum RemoteFinish { + Exited(i32), + Connection { code: Option }, + Local(String), +} + +fn classify_ssh_code(code: Option) -> RemoteFinish { + match code { + Some(255) | None => RemoteFinish::Connection { code }, + Some(other) => RemoteFinish::Exited(other), + } +} + +/// rsync passes ssh's 255 through. 10, 30, and 35 are socket and timeout exits. +fn classify_rsync_code(code: Option) -> RemoteFinish { + match code { + Some(10 | 30 | 35 | 255) | None => RemoteFinish::Connection { code }, + Some(other) => RemoteFinish::Exited(other), } } -fn ssh_run_rc(host: &str, remote_cmd: &str) -> i32 { +fn ssh_status_label(code: Option) -> String { + match code { + Some(code) => code.to_string(), + None => "signal".to_string(), + } +} + +fn ssh_finish(host: &str, remote_cmd: &str) -> RemoteFinish { let extra = ssh_extra_args(host); match Command::new(ssh_bin()) .args(&extra) @@ -484,8 +522,63 @@ fn ssh_run_rc(host: &str, remote_cmd: &str) -> i32 { .arg(remote_cmd) .status() { - Ok(s) => s.code().unwrap_or(1), - Err(_) => 1, + Ok(status) => classify_ssh_code(status.code()), + Err(e) => RemoteFinish::Local(format!("ssh failed: {e}")), + } +} + +/// Retry a connection drop. A finished remote status, including exit 1, returns immediately. +fn retry_remote(what: &str, mut once: F) -> Result +where + F: FnMut() -> RemoteFinish, +{ + let mut last = RemoteFinish::Connection { code: None }; + for attempt in 1..=SSH_ATTEMPTS { + match once() { + RemoteFinish::Exited(code) => return Ok(code), + RemoteFinish::Local(msg) => return Err(ToolError::fail(msg)), + finish @ RemoteFinish::Connection { .. } => { + last = finish; + if attempt == SSH_ATTEMPTS { + break; + } + eprintln!( + "deploy-host: connection failed (attempt {attempt} of {SSH_ATTEMPTS}); retrying {what}" + ); + } + } + } + let shown = match &last { + RemoteFinish::Connection { code } => ssh_status_label(*code), + _ => "signal".to_string(), + }; + Err(ToolError::fail(format!( + "connection failed after {SSH_ATTEMPTS} attempts: {what} exited {shown}" + ))) +} + +/// The switch is one attempt. Never call this in a retry loop. +fn switch_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 switch (ssh status {shown}); not starting a second switch" + ))) + } + RemoteFinish::Local(msg) => Err(ToolError::fail(format!( + "connection failed during nixos-rebuild switch ({msg}); not starting a second switch" + ))), + } +} + +fn ssh_run(host: &str, remote_cmd: &str) -> Result<(), ToolError> { + let code = retry_remote(&format!("ssh -- {host}"), || ssh_finish(host, remote_cmd))?; + if code == 0 { + Ok(()) + } else { + Err(ToolError::fail(format!("ssh -- {host} exited {code}"))) } } @@ -513,16 +606,19 @@ fn rsync_remote( ) -> Result<(), ToolError> { let src_s = format!("{}/", src.display()); let dest_s = format!("{target}:{}/", remote_dir.trim_end_matches('/')); - let mut cmd = Command::new("rsync"); - cmd.arg("-e").arg(ssh_rsh(target)); - cmd.args(flags).args(extra).arg(&src_s).arg(&dest_s); - let st = cmd - .status() - .map_err(|e| ToolError::fail(format!("rsync failed: {e}")))?; - if st.success() { + 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() { + Ok(status) => classify_rsync_code(status.code()), + Err(e) => RemoteFinish::Local(format!("rsync failed: {e}")), + } + })?; + if code == 0 { Ok(()) } else { - Err(ToolError::fail("rsync exited non-zero")) + Err(ToolError::fail(format!("rsync exited {code}"))) } } @@ -566,6 +662,91 @@ mod tests { let a = ssh_extra_args("example.test"); let joined = a.join(" "); assert!(joined.contains("ServerAliveInterval=30"), "{joined}"); + assert!(joined.contains("ServerAliveCountMax=10"), "{joined}"); + assert!(joined.contains("TCPKeepAlive=yes"), "{joined}"); assert!(joined.contains("BatchMode=yes"), "{joined}"); } + + #[test] + fn ssh_255_is_connection_and_remote_exit_is_not() { + assert_eq!( + classify_ssh_code(Some(255)), + RemoteFinish::Connection { code: Some(255) } + ); + assert_eq!( + classify_ssh_code(None), + RemoteFinish::Connection { code: None } + ); + assert_eq!(classify_ssh_code(Some(0)), RemoteFinish::Exited(0)); + assert_eq!(classify_ssh_code(Some(1)), RemoteFinish::Exited(1)); + } + + #[test] + fn rsync_socket_codes_are_connection() { + for code in [10, 30, 35, 255] { + assert!( + matches!( + classify_rsync_code(Some(code)), + RemoteFinish::Connection { .. } + ), + "{code}" + ); + } + assert_eq!(classify_rsync_code(Some(1)), RemoteFinish::Exited(1)); + assert_eq!(classify_rsync_code(Some(23)), RemoteFinish::Exited(23)); + assert_eq!( + classify_rsync_code(None), + RemoteFinish::Connection { code: None } + ); + } + + #[test] + fn retry_repeats_only_connection_failures() { + let mut calls = 0; + let err = retry_remote("ssh -- example.test", || { + calls += 1; + RemoteFinish::Connection { code: Some(255) } + }) + .unwrap_err(); + assert_eq!(calls, 3); + assert!(err.message.contains("connection failed"), "{}", err.message); + assert!(err.exit_code != 0); + + calls = 0; + let code = retry_remote("ssh -- example.test", || { + calls += 1; + RemoteFinish::Exited(1) + }) + .unwrap(); + assert_eq!(code, 1); + assert_eq!(calls, 1); + + calls = 0; + let err = retry_remote("ssh -- example.test", || { + calls += 1; + RemoteFinish::Local("ssh failed: missing".into()) + }) + .unwrap_err(); + assert_eq!(calls, 1); + assert!( + !err.message.contains("connection failed"), + "{}", + err.message + ); + assert!(err.message.contains("ssh failed"), "{}", err.message); + } + + #[test] + fn switch_drop_is_reported_once() { + let err = switch_outcome(RemoteFinish::Connection { code: Some(255) }).unwrap_err(); + assert!(err.message.contains("connection failed"), "{}", err.message); + assert!( + err.message.contains("not starting a second switch"), + "{}", + err.message + ); + assert!(err.message.contains("255"), "{}", err.message); + assert_eq!(switch_outcome(RemoteFinish::Exited(1)).unwrap(), 1); + assert_eq!(switch_outcome(RemoteFinish::Exited(0)).unwrap(), 0); + } } diff --git a/crates/surmount-deploy-host/tests/deploy_host_cli.rs b/crates/surmount-deploy-host/tests/deploy_host_cli.rs index ab27b3e..96ccb8f 100644 --- a/crates/surmount-deploy-host/tests/deploy_host_cli.rs +++ b/crates/surmount-deploy-host/tests/deploy_host_cli.rs @@ -125,11 +125,8 @@ fn dry_run_good_host_local_prints_rebuild_and_smoke() { ); let low = s.to_ascii_lowercase(); assert!( - low.contains("evaluated") - || low.contains("authorizedkeys") - || low.contains("not enough") - || low.contains("not sufficient"), - "{s}" + !s.contains("does not mention authorizedKeys"), + "known-name layout auto-wires keys and must not warn: {s}" ); assert!( low.contains("post-switch smoke") || s.contains("deploy-host-post-switch-smoke"), @@ -503,3 +500,97 @@ fn vaultwarden_admin_and_namecheap_allowlisted() { assert!(!s.contains(PLANTED), "{s}"); } } + +fn fake_ssh(dir: &Path) -> (PathBuf, PathBuf) { + let log = dir.join("ssh.log"); + let script = write_exec( + dir, + "fake-ssh", + r#"#!/bin/sh +log="$SURMOUNT_TEST_SSH_LOG" +printf '%s\n' "$*" >> "$log" +last="" +for a in "$@"; do + last=$a +done +case "$last" in + *nixos-rebuild*) + exit "${SURMOUNT_TEST_REBUILD_EXIT:-0}" + ;; + *post-switch-smoke*|*surmount-deploy-host-post-switch-smoke*) + exit "${SURMOUNT_TEST_SMOKE_EXIT:-0}" + ;; + *) + exit "${SURMOUNT_TEST_OTHER_EXIT:-0}" + ;; +esac +"#, + ); + (script, log) +} + +fn lines_containing(log: &str, needle: &str) -> usize { + log.lines().filter(|line| line.contains(needle)).count() +} + +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); + let out = cmd() + .env("SURMOUNT_DEPLOY_SSH", &script) + .env("SURMOUNT_TEST_SSH_LOG", &log) + .env("SURMOUNT_TEST_REBUILD_EXIT", rebuild_exit) + .env("SURMOUNT_TEST_SMOKE_EXIT", smoke_exit) + .args([ + "--target", + "example.test", + "--skip-sync", + "--skip-host-local-check", + ]) + .output() + .unwrap(); + let logged = fs::read_to_string(&log).unwrap_or_default(); + (out, logged) +} + +#[test] +fn switch_ssh_drop_does_not_start_a_second_switch() { + let (out, log) = deploy_with_fake_ssh("255", "0"); + let s = combined(&out); + assert!(!out.status.success(), "{s}"); + assert!(s.contains("connection failed"), "{s}"); + assert!(s.contains("not starting a second switch"), "{s}"); + assert_eq!(lines_containing(&log, "nixos-rebuild"), 1, "{log}"); + assert_eq!(lines_containing(&log, "post-switch-smoke"), 0, "{log}"); +} + +#[test] +fn rebuild_exit_runs_smoke_once_and_is_not_a_connection_failure() { + let (out, log) = deploy_with_fake_ssh("1", "0"); + let s = combined(&out); + assert!(!out.status.success(), "{s}"); + assert!(s.contains("nixos-rebuild failed"), "{s}"); + assert!(!s.contains("connection failed"), "{s}"); + assert_eq!(lines_containing(&log, "nixos-rebuild"), 1, "{log}"); + assert_eq!(lines_containing(&log, "post-switch-smoke"), 1, "{log}"); +} + +#[test] +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_eq!(lines_containing(&log, "nixos-rebuild"), 1, "{log}"); + assert_eq!(lines_containing(&log, "post-switch-smoke"), 1, "{log}"); +} + +#[test] +fn smoke_ssh_drop_does_not_start_a_second_switch() { + let (out, log) = deploy_with_fake_ssh("0", "255"); + let s = combined(&out); + assert!(!out.status.success(), "{s}"); + assert!(s.contains("connection failed"), "{s}"); + assert!(s.contains("not starting a second switch"), "{s}"); + assert_eq!(lines_containing(&log, "nixos-rebuild"), 1, "{log}"); + assert_eq!(lines_containing(&log, "post-switch-smoke"), 3, "{log}"); +} diff --git a/crates/surmount-dns-zone/Cargo.toml b/crates/surmount-dns-zone/Cargo.toml index 260c339..9a2236b 100644 --- a/crates/surmount-dns-zone/Cargo.toml +++ b/crates/surmount-dns-zone/Cargo.toml @@ -3,7 +3,7 @@ name = "surmount-dns-zone" version.workspace = true edition.workspace = true license.workspace = true -description = "Namecheap forward-zone helper (A/AAAA/TXT/CAA/MX). Dry-run default. Hermetic mock." +description = "Namecheap forward-zone helper (A/AAAA/TXT/CAA/MX). Dry-run default. --live getHosts/setHosts." publish = false [[bin]] @@ -16,6 +16,7 @@ path = "src/lib.rs" [dependencies] anyhow.workspace = true +surmount-acme-namecheap = { path = "../surmount-acme-namecheap" } [lints] workspace = true diff --git a/crates/surmount-dns-zone/src/lib.rs b/crates/surmount-dns-zone/src/lib.rs index 00d817e..808a567 100644 --- a/crates/surmount-dns-zone/src/lib.rs +++ b/crates/surmount-dns-zone/src/lib.rs @@ -1,4 +1,5 @@ //! Namecheap forward-zone helper. Default dry-run. Hermetic mock zone. +//! `--live` without MOCK_DIR uses Namecheap getHosts then setHosts. //! //! SHA-1 parent DNSSEC digest type 1 fails closed on setHosts. diff --git a/crates/surmount-dns-zone/src/run.rs b/crates/surmount-dns-zone/src/run.rs index 69f7646..4098cce 100644 --- a/crates/surmount-dns-zone/src/run.rs +++ b/crates/surmount-dns-zone/src/run.rs @@ -1,4 +1,5 @@ //! CLI: list / set-* / delete-host. Default dry-run. +//! `--live` without MOCK_DIR: Namecheap getHosts, merge, setHosts. use std::ffi::OsString; use std::net::Ipv4Addr; @@ -332,23 +333,79 @@ fn parse_ttl_tail( Ok((host.to_string(), val.unwrap_or("").to_string(), ttl)) } -fn load_zone(mock: Option<&std::path::Path>) -> Result { - let Some(dir) = mock else { - return Err(die( - "live Namecheap API is not used in tests; set SURMOUNT_DNS_ZONE_NAMECHEAP_MOCK_DIR (ClientIp required for live API)", - )); - }; - load_mock(dir) +const LIVE_PREFIX: &str = "dns-zone-namecheap"; + +fn acme_cred(cred: &crate::cred::Credentials) -> surmount_acme_namecheap::cred::Credentials { + surmount_acme_namecheap::cred::Credentials { + api_user: cred.api_user.clone(), + api_key: cred.api_key.clone(), + user_name: cred.user_name.clone(), + client_ip: cred.client_ip.clone(), + sld: cred.sld.clone(), + tld: cred.tld.clone(), + settle_secs: 0, + txt_ttl: DEFAULT_TTL, + path: cred.path.clone(), + } +} + +fn record_from_acme(r: surmount_acme_namecheap::zone::Record) -> Record { + Record { + name: r.name, + r#type: r.r#type, + address: r.address, + mx_pref: r.mx_pref, + ttl: r.ttl, + } +} + +fn record_to_acme(r: &Record) -> surmount_acme_namecheap::zone::Record { + surmount_acme_namecheap::zone::Record { + name: r.name.clone(), + r#type: r.r#type.clone(), + address: r.address.clone(), + mx_pref: r.mx_pref.clone(), + ttl: r.ttl.clone(), + } +} + +fn zone_from_hosts(h: surmount_acme_namecheap::hook::live::Hosts) -> Zone { + Zone { + records: h.records.into_iter().map(record_from_acme).collect(), + email_type: h.email_type, + } } -fn save_zone(mock: Option<&std::path::Path>, zone: &Zone) -> Result<()> { +fn hosts_from_zone(zone: &Zone) -> surmount_acme_namecheap::hook::live::Hosts { + surmount_acme_namecheap::hook::live::Hosts { + records: zone.records.iter().map(record_to_acme).collect(), + email_type: zone.email_type.clone(), + } +} + +fn load_zone(cred: &crate::cred::Credentials, mock: Option<&std::path::Path>) -> Result { + if let Some(dir) = mock { + return load_mock(dir); + } + let hosts = + surmount_acme_namecheap::hook::live::get_hosts_with_prefix(&acme_cred(cred), LIVE_PREFIX)?; + Ok(zone_from_hosts(hosts)) +} + +fn save_zone( + cred: &crate::cred::Credentials, + mock: Option<&std::path::Path>, + zone: &Zone, +) -> Result<()> { refuse_sha1_ds(&zone.records)?; - let Some(dir) = mock else { - return Err(die( - "live Namecheap API write refused without MOCK_DIR in this binary path", - )); - }; - save_mock(dir, zone) + if let Some(dir) = mock { + return save_mock(dir, zone); + } + surmount_acme_namecheap::hook::live::set_hosts_with_prefix( + &acme_cred(cred), + &hosts_from_zone(zone), + LIVE_PREFIX, + ) } fn assert_ttl(t: &str) -> Result { @@ -379,7 +436,7 @@ fn assert_ipv6(ip: &str) -> Result<()> { } fn cmd_list(cred: &crate::cred::Credentials, mock: Option<&std::path::Path>) -> Result { - let zone = load_zone(mock)?; + let zone = load_zone(cred, mock)?; let mode = if mock.is_some() { "mock" } else { "live-api" }; let et = if zone.email_type.is_empty() { "unset" @@ -397,6 +454,7 @@ fn cmd_list(cred: &crate::cred::Credentials, mock: Option<&std::path::Path>) -> } fn apply_or_dry( + cred: &crate::cred::Credentials, live: bool, mock: Option<&std::path::Path>, zone: &Zone, @@ -408,7 +466,7 @@ fn apply_or_dry( println!("dns-zone-namecheap: dry-run only (no setHosts). Pass --live to apply."); return Ok(0); } - save_zone(mock, zone)?; + save_zone(cred, mock, zone)?; println!("dns-zone-namecheap: applied setHosts for host={host}{extra_applied}"); Ok(0) } @@ -450,7 +508,7 @@ fn cmd_set_records( if let Some(v) = aaaa { assert_ipv6(v)?; } - let mut zone = load_zone(mock)?; + let mut zone = load_zone(cred, mock)?; let before = zone.records.len(); if let Some(v) = a { drop_type_at_host(&mut zone, &host, "A"); @@ -487,7 +545,7 @@ fn cmd_set_records( } println!(" planned zone:"); print!("{}", print_hosts(&zone)); - apply_or_dry(live, mock, &zone, &host, "") + apply_or_dry(cred, live, mock, &zone, &host, "") } fn cmd_set_txt( @@ -517,7 +575,7 @@ fn cmd_set_txt( } else { "all-txt" }; - let mut zone = load_zone(mock)?; + let mut zone = load_zone(cred, mock)?; let before = zone.records.len(); match merge { "spf" => drop_txt_prefix_at_host(&mut zone, &host, "v=spf1"), @@ -543,7 +601,7 @@ fn cmd_set_txt( println!(" set TXT {host} -> {preview} (ttl={ttl}; merge={merge})"); println!(" planned zone:"); print!("{}", print_hosts(&zone)); - apply_or_dry(live, mock, &zone, &host, " type=TXT") + apply_or_dry(cred, live, mock, &zone, &host, " type=TXT") } #[allow(clippy::too_many_arguments)] @@ -586,7 +644,7 @@ fn cmd_set_caa( let ttl = assert_ttl(ttl)?; let host = to_hostname(host_raw, &cred.sld, &cred.tld)?; let caa_addr = format!("{flags_v} {tag} \"{value}\""); - let mut zone = load_zone(mock)?; + let mut zone = load_zone(cred, mock)?; let before = zone.records.len(); drop_caa_tag_at_host(&mut zone, &host, tag); append( @@ -604,7 +662,14 @@ fn cmd_set_caa( println!(" note: CAA is RecordType=CAA (not TXT)"); println!(" planned zone:"); print!("{}", print_hosts(&zone)); - apply_or_dry(live, mock, &zone, &host, &format!(" type=CAA tag={tag}")) + apply_or_dry( + cred, + live, + mock, + &zone, + &host, + &format!(" type=CAA tag={tag}"), + ) } fn cmd_set_mx( @@ -643,7 +708,7 @@ fn cmd_set_mx( } let ttl = assert_ttl(ttl)?; let host = to_hostname(host_raw, &cred.sld, &cred.tld)?; - let mut zone = load_zone(mock)?; + let mut zone = load_zone(cred, mock)?; let before = zone.records.len(); let et = if zone.email_type.is_empty() { "unset" @@ -678,7 +743,7 @@ fn cmd_set_mx( } println!(" planned zone:"); print!("{}", print_hosts(&zone)); - apply_or_dry(live, mock, &zone, &host, " type=MX") + apply_or_dry(cred, live, mock, &zone, &host, " type=MX") } fn cmd_delete_host( @@ -710,7 +775,7 @@ fn cmd_delete_host( ))); } let host = to_hostname(host_raw, &cred.sld, &cred.tld)?; - let mut zone = load_zone(mock)?; + let mut zone = load_zone(cred, mock)?; let before = zone.records.len(); if before == 0 { return Err(die("getHosts returned no records (refuse setHosts wipe)")); @@ -733,6 +798,7 @@ fn cmd_delete_host( println!(" planned zone:"); print!("{}", print_hosts(&zone)); apply_or_dry( + cred, live, mock, &zone, diff --git a/crates/surmount-dns-zone/tests/cli.rs b/crates/surmount-dns-zone/tests/cli.rs index 42e7fbf..9b2c3cf 100644 --- a/crates/surmount-dns-zone/tests/cli.rs +++ b/crates/surmount-dns-zone/tests/cli.rs @@ -1,5 +1,6 @@ //! Hermetic CLI tests ported from script/test-dns-zone-namecheap.sh. -//! Mock zone via SURMOUNT_DNS_ZONE_NAMECHEAP_MOCK_DIR. No network. +//! Mock zone via SURMOUNT_DNS_ZONE_NAMECHEAP_MOCK_DIR. Live path uses a +//! local HTTP listener (API base override). Never live Namecheap. use std::fs; use std::os::unix::fs::PermissionsExt; @@ -542,3 +543,276 @@ fn digest_type_2_ds_may_merge() { assert!(hosts.contains("www|A|203.0.113.20|")); assert!(hosts.contains("@|DS|370 13 2 ")); } + +struct FakeNc { + records: Vec<(String, String, String, String, String)>, + email_type: String, + commands: Vec, +} + +fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +fn url_decode(s: &str) -> String { + let mut out = Vec::new(); + let b = s.as_bytes(); + let mut i = 0; + while i < b.len() { + match b[i] { + b'+' => { + out.push(b' '); + i += 1; + } + b'%' if i + 2 < b.len() => { + let hex = &s[i + 1..i + 3]; + if let Ok(v) = u8::from_str_radix(hex, 16) { + out.push(v); + i += 3; + } else { + out.push(b'%'); + i += 1; + } + } + c => { + out.push(c); + i += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + +fn parse_query(q: &str) -> std::collections::HashMap { + let mut m = std::collections::HashMap::new(); + for part in q.split('&') { + if part.is_empty() { + continue; + } + let mut kv = part.splitn(2, '='); + let k = url_decode(kv.next().unwrap_or("")); + let v = url_decode(kv.next().unwrap_or("")); + m.insert(k, v); + } + m +} + +fn spawn_namecheap_http(state: std::sync::Arc>) -> String { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { continue }; + let mut buf = [0u8; 8192]; + let n = match std::io::Read::read(&mut stream, &mut buf) { + Ok(n) => n, + Err(_) => continue, + }; + let req = String::from_utf8_lossy(&buf[..n]); + let first = req.lines().next().unwrap_or(""); + let path = first.split_whitespace().nth(1).unwrap_or("/"); + let q = path.split_once('?').map(|x| x.1).unwrap_or(""); + let params = parse_query(q); + let body = { + let mut g = state.lock().unwrap(); + let cmd = params.get("Command").cloned().unwrap_or_default(); + if cmd.contains("setHosts") { + g.commands.push("setHosts".into()); + let mut recs = Vec::new(); + for i in 1..64 { + let name = params.get(&format!("HostName{i}")).cloned(); + let Some(name) = name else { break }; + recs.push(( + name, + params + .get(&format!("RecordType{i}")) + .cloned() + .unwrap_or_default(), + params + .get(&format!("Address{i}")) + .cloned() + .unwrap_or_default(), + params + .get(&format!("MXPref{i}")) + .cloned() + .unwrap_or_else(|| "10".into()), + params + .get(&format!("TTL{i}")) + .cloned() + .unwrap_or_else(|| "1800".into()), + )); + } + if let Some(et) = params.get("EmailType") { + g.email_type = et.clone(); + } + g.records = recs; + r#""#.to_string() + } else { + g.commands.push("getHosts".into()); + let mut xml = String::from( + r#""#); + for (n, t, a, mx, ttl) in &g.records { + xml.push_str(&format!( + r#""#, + xml_escape(n), + xml_escape(t), + xml_escape(a), + xml_escape(mx), + xml_escape(ttl) + )); + } + xml.push_str(""); + xml + } + }; + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/xml\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = std::io::Write::write_all(&mut stream, resp.as_bytes()); + } + }); + format!("http://{addr}/xml.response") +} + +fn live_http_cmd(cred: &std::path::Path, base: &str) -> Command { + let mut c = Command::new(bin()); + c.env("SURMOUNT_DNS_ZONE_NAMECHEAP_ENV", cred); + c.env("SURMOUNT_DNS_ZONE_NAMECHEAP_API_BASE", base); + c.env_remove("SURMOUNT_DNS_ZONE_NAMECHEAP_MOCK_DIR"); + c.env_remove("SURMOUNT_ACME_DNS_NAMECHEAP_MOCK_DIR"); + c +} + +/// `--live set-a` without MOCK_DIR must getHosts then setHosts on the +/// local listener, not return the mock-dir error. +#[test] +fn live_set_a_without_mock_dir_gethosts_then_sethosts() { + let h = Harness::new(); + let state = std::sync::Arc::new(std::sync::Mutex::new(FakeNc { + records: vec![ + ( + "@".into(), + "A".into(), + "203.0.113.10".into(), + "10".into(), + "1800".into(), + ), + ( + "mail".into(), + "A".into(), + "203.0.113.11".into(), + "10".into(), + "1800".into(), + ), + ], + email_type: "MX".into(), + commands: Vec::new(), + })); + let base = spawn_namecheap_http(state.clone()); + let out = live_http_cmd(&h.cred, &base) + .args(["--live", "set-a", "splora", "192.0.2.10"]) + .output() + .unwrap(); + let text = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!(out.status.code(), Some(0), "{text}"); + assert!(!text.contains("SURMOUNT_DNS_ZONE_NAMECHEAP_MOCK_DIR")); + assert!(!text.contains("live Namecheap API is not used in tests")); + assert!(!text.contains("surmount-test-apikey-not-real-zone-001")); + let g = state.lock().unwrap(); + assert_eq!(g.commands, ["getHosts", "setHosts"]); + assert_eq!(g.email_type, "MX"); + assert!( + g.records + .iter() + .any(|(n, t, a, _, _)| n == "splora" && t == "A" && a == "192.0.2.10"), + "setHosts must publish merged A: {:?}", + g.records + ); + assert!( + g.records.iter().any(|(n, t, _, _, _)| n == "@" && t == "A"), + "setHosts must re-apply other getHosts records" + ); + assert!( + g.records + .iter() + .any(|(n, t, _, _, _)| n == "mail" && t == "A") + ); +} + +/// Operator shape: `--live set-host HOST --a V4` without MOCK_DIR. +#[test] +fn live_set_host_without_mock_dir_merges_a() { + let h = Harness::new(); + let state = std::sync::Arc::new(std::sync::Mutex::new(FakeNc { + records: vec![( + "@".into(), + "A".into(), + "203.0.113.10".into(), + "10".into(), + "1800".into(), + )], + email_type: "MX".into(), + commands: Vec::new(), + })); + let base = spawn_namecheap_http(state.clone()); + let out = live_http_cmd(&h.cred, &base) + .args(["--live", "set-host", "splora", "--a", "192.0.2.10"]) + .output() + .unwrap(); + let text = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!(out.status.code(), Some(0), "{text}"); + assert!(!text.contains("SURMOUNT_DNS_ZONE_NAMECHEAP_MOCK_DIR")); + let g = state.lock().unwrap(); + assert_eq!(g.commands, ["getHosts", "setHosts"]); + assert!( + g.records + .iter() + .any(|(n, t, a, _, _)| n == "splora" && t == "A" && a == "192.0.2.10") + ); +} + +#[test] +fn dry_run_without_mock_dir_gethosts_not_sethosts() { + let h = Harness::new(); + let state = std::sync::Arc::new(std::sync::Mutex::new(FakeNc { + records: vec![( + "@".into(), + "A".into(), + "203.0.113.10".into(), + "10".into(), + "1800".into(), + )], + email_type: "MX".into(), + commands: Vec::new(), + })); + let base = spawn_namecheap_http(state.clone()); + let out = live_http_cmd(&h.cred, &base) + .args(["set-a", "splora", "192.0.2.10"]) + .output() + .unwrap(); + let text = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!(out.status.code(), Some(0), "{text}"); + assert!(text.to_ascii_lowercase().contains("dry-run")); + let g = state.lock().unwrap(); + assert_eq!(g.commands, ["getHosts"]); + assert!(!g.records.iter().any(|(n, _, _, _, _)| n == "splora")); +} diff --git a/crates/surmount-host-probe/src/bin/surmount-btop-host.rs b/crates/surmount-host-probe/src/bin/surmount-btop-host.rs index 59c9fc5..7118986 100644 --- a/crates/surmount-host-probe/src/bin/surmount-btop-host.rs +++ b/crates/surmount-host-probe/src/bin/surmount-btop-host.rs @@ -19,8 +19,11 @@ as just et). Needs etserver on the guest (after just deploy-host). Mullvad stays operator-owned. Does not use a raw SSH that can freeze-paint a last frame. After btop -exits, the Eternal Terminal session exits (not et --noexit / nested -guest shell). stdin and stdout must be a live tty. +exits (q or Ctrl-C), the Eternal Terminal session stays up and drops +to a shell on the mail host (et -e / --noexit with -c btop). That +shell is the emergency terminal. Do not start nixos-rebuild or just +deploy from that shell. The laptop still starts the switch. stdin and +stdout must be a live tty. If already on the mail guest (Eternal Terminal, guest hostname, or /etc/surmount/root-justfile), runs local btop and refuses nested et. @@ -160,7 +163,7 @@ fn main() -> ExitCode { target_label(&target) ); eprintln!( - "btop-host: If the clock in this pane stops, the pane is dead. Press q to end btop (this session exits). Then run just btop from the laptop if you need a new live tty." + "btop-host: If the clock in this pane stops, the pane is dead. Press q or Ctrl-C to leave btop. This Eternal Terminal session stays up and drops to a shell on the mail host. Do not start nixos-rebuild or just deploy from that shell. The laptop still starts the switch." ); let mut cmd = Command::new(&argv[0]); if argv.len() > 1 { diff --git a/crates/surmount-host-probe/src/et.rs b/crates/surmount-host-probe/src/et.rs index 2b327da..1ea7ba0 100644 --- a/crates/surmount-host-probe/src/et.rs +++ b/crates/surmount-host-probe/src/et.rs @@ -53,9 +53,10 @@ pub fn build_et_argv( v } -/// Extra et flags for `just btop`: run btop, then exit (not `--noexit` / `-e`). +/// Extra et flags for `just btop`. +/// Packaged et 6.2.11: `-e` / `--noexit` with `-c` leaves a shell. pub fn btop_et_extra() -> Vec { - vec!["-c".into(), "btop".into()] + vec!["-e".into(), "-c".into(), "btop".into()] } #[derive(Debug, Clone, Default)] @@ -305,10 +306,12 @@ mod tests { } #[test] - fn btop_extra_is_command_without_noexit() { + fn btop_extra_runs_btop_then_leaves_shell() { let extra = btop_et_extra(); - assert_eq!(extra, vec!["-c".to_string(), "btop".to_string()]); - assert!(!extra.iter().any(|w| w == "-e")); + assert_eq!( + extra, + vec!["-e".to_string(), "-c".to_string(), "btop".to_string()] + ); } #[test] diff --git a/crates/surmount-host-probe/tests/cli.rs b/crates/surmount-host-probe/tests/cli.rs index 8095518..0ae1a32 100644 --- a/crates/surmount-host-probe/tests/cli.rs +++ b/crates/surmount-host-probe/tests/cli.rs @@ -231,10 +231,10 @@ fn btop_dry_run_tty() { assert!(t.contains("-c") && t.contains("btop"), "{t}"); assert!(t.contains("example.test"), "{t}"); assert!(!t.contains(" ssh "), "{t}"); - // -e is et --noexit: after btop the session would drop to a guest shell. + // -e is et --noexit: after btop the session stays and drops to a shell. assert!( - !t.split_whitespace().any(|w| w == "-e"), - "just btop must not pass et --noexit (-e): {t}" + t.split_whitespace().any(|w| w == "-e"), + "just btop must pass et --noexit (-e) with -c btop: {t}" ); } @@ -253,8 +253,8 @@ fn btop_fake_et() { assert!(args.contains("-c") && args.contains("btop"), "{args}"); assert!(args.contains("example.test"), "{args}"); assert!( - !args.split_whitespace().any(|w| w == "-e"), - "et --noexit (-e) leaves a guest shell after btop: {args}" + args.split_whitespace().any(|w| w == "-e"), + "et --noexit (-e) must stay with -c btop so the session drops to a shell: {args}" ); } diff --git a/crates/surmount-secrets-install/src/install.rs b/crates/surmount-secrets-install/src/install.rs index cd4fbf4..1975a36 100644 --- a/crates/surmount-secrets-install/src/install.rs +++ b/crates/surmount-secrets-install/src/install.rs @@ -846,6 +846,29 @@ fn maybe_chown_ui_secret(_kind: &str, _path: &Path) { // chown only when surmount-ui exists; hermetic dest-root usually lacks it. } +/// First remote stderr line that already states a directory or other refusal. +/// Strips one leading `secrets-install-host:` so the process prefix is added once. +fn remote_refusal_message(stderr: &[u8]) -> Option { + let text = String::from_utf8_lossy(stderr); + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let lower = trimmed.to_lowercase(); + if !lower.contains("directory") && !lower.contains("refuse") { + continue; + } + let msg = trimmed + .strip_prefix("secrets-install-host:") + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(trimmed); + return Some(msg.to_string()); + } + None +} + fn install_remote( src: &[u8], hpath: &str, @@ -953,16 +976,22 @@ if [ -L \"$final\" ] || [ ! -f \"$final\" ]; then echo \"secrets-install-host: r .stderr(Stdio::piped()) .spawn() .map_err(|e| ToolError::fail(format!("ssh failed to start: {e}")))?; - { + // A directory refusal exits before `cat`, so the mock ssh never reads + // stdin and this write returns EPIPE. That io error must not replace + // the remote refusal text. + let write_err = { let mut stdin = child .stdin .take() .ok_or_else(|| ToolError::fail("ssh stdin was not piped".to_string()))?; - stdin.write_all(src)?; - } + stdin.write_all(src).err() + }; let out = child .wait_with_output() .map_err(|e| ToolError::fail(format!("ssh wait: {e}")))?; + if let Some(msg) = remote_refusal_message(&out.stderr) { + return Err(ToolError::fail(msg)); + } if !out.stderr.is_empty() { let _ = io::stderr().write_all(&out.stderr); } @@ -972,6 +1001,9 @@ if [ -L \"$final\" ] || [ ! -f \"$final\" ]; then echo \"secrets-install-host: r out.status.code().unwrap_or(1) ))); } + if let Some(err) = write_err { + return Err(ToolError::from(err)); + } log_line(&format!( "installed kind={kind} -> {host}:{hpath} (mode {mode:04o})" )); diff --git a/crates/surmount-stalwart-ops/Cargo.toml b/crates/surmount-stalwart-ops/Cargo.toml index f65dcac..a3d7d52 100644 --- a/crates/surmount-stalwart-ops/Cargo.toml +++ b/crates/surmount-stalwart-ops/Cargo.toml @@ -30,6 +30,14 @@ path = "src/bin/bootstrap-stalwart-api-token.rs" name = "stalwart-recovery-unlock" path = "src/bin/stalwart-recovery-unlock.rs" +[[bin]] +name = "vm-lab-directory-setup" +path = "src/bin/vm-lab-directory-setup.rs" + +[[bin]] +name = "vm-lab-smtp-accept" +path = "src/bin/vm-lab-smtp-accept.rs" + [lib] name = "surmount_stalwart_ops" path = "src/lib.rs" diff --git a/crates/surmount-stalwart-ops/src/bin/vm-lab-directory-setup.rs b/crates/surmount-stalwart-ops/src/bin/vm-lab-directory-setup.rs new file mode 100644 index 0000000..fbd5887 --- /dev/null +++ b/crates/surmount-stalwart-ops/src/bin/vm-lab-directory-setup.rs @@ -0,0 +1,17 @@ +use std::process::ExitCode; +use surmount_stalwart_ops::run_vm_lab_directory_guest; + +fn main() -> ExitCode { + match run_vm_lab_directory_guest(std::env::args().skip(1)) { + Ok(lines) => { + for line in lines { + println!("{line}"); + } + ExitCode::SUCCESS + } + Err(err) => { + eprintln!("vm-lab-directory-setup: {err}"); + ExitCode::from(err.exit_code.min(255) as u8) + } + } +} diff --git a/crates/surmount-stalwart-ops/src/bin/vm-lab-smtp-accept.rs b/crates/surmount-stalwart-ops/src/bin/vm-lab-smtp-accept.rs new file mode 100644 index 0000000..0e5e0f3 --- /dev/null +++ b/crates/surmount-stalwart-ops/src/bin/vm-lab-smtp-accept.rs @@ -0,0 +1,26 @@ +use std::process::ExitCode; +use surmount_stalwart_ops::run_vm_lab_smtp_accept; + +fn main() -> ExitCode { + let mut args = std::env::args().skip(1); + let Some(rcpt) = args.next() else { + eprintln!("vm-lab-smtp-accept: usage: vm-lab-smtp-accept local@example.test"); + return ExitCode::from(1); + }; + if args.next().is_some() { + eprintln!("vm-lab-smtp-accept: usage: vm-lab-smtp-accept local@example.test"); + return ExitCode::from(1); + } + let report = run_vm_lab_smtp_accept(&rcpt); + for line in &report.lines { + println!("{line}"); + } + if let Some(err) = &report.error { + eprintln!("vm-lab-smtp-accept: {err}"); + } + if report.success { + ExitCode::SUCCESS + } else { + ExitCode::from(1) + } +} diff --git a/crates/surmount-stalwart-ops/src/bootstrap.rs b/crates/surmount-stalwart-ops/src/bootstrap.rs index 50f406d..4399fa8 100644 --- a/crates/surmount-stalwart-ops/src/bootstrap.rs +++ b/crates/surmount-stalwart-ops/src/bootstrap.rs @@ -325,7 +325,13 @@ where Ok(()) } -fn ensure_local(cli: &str, url: &str, user: &str, domain: &str, password: &str) -> Result { +pub(crate) fn ensure_local( + cli: &str, + url: &str, + user: &str, + domain: &str, + password: &str, +) -> Result { log_line(&format!("ensure directory: query domain name={domain}")); let q = Command::new(cli) .args([ @@ -455,7 +461,7 @@ fn ensure_local(cli: &str, url: &str, user: &str, domain: &str, password: &str) Ok(apikey_user) } -fn local_create( +pub(crate) fn local_create( cli: &str, url: &str, user: &str, @@ -553,7 +559,7 @@ fn read_password_file(path: &str) -> Result { .to_string()) } -fn scrub(out: &str, password: &str) -> String { +pub(crate) fn scrub(out: &str, password: &str) -> String { out.lines() .filter(|l| !l.contains(concat!("Secret", ":"))) .map(|l| { diff --git a/crates/surmount-stalwart-ops/src/lib.rs b/crates/surmount-stalwart-ops/src/lib.rs index 83f7e16..670df6c 100644 --- a/crates/surmount-stalwart-ops/src/lib.rs +++ b/crates/surmount-stalwart-ops/src/lib.rs @@ -1,6 +1,8 @@ //! Stalwart operator drivers (DKIM, free :443, mail-plane TLS, tokens, recovery). //! -//! Never logs STALWART_TOKEN or password values. Hermetic tests use fake CLI only. +//! Never logs STALWART_TOKEN, API tokens, or password values. Hermetic tests +//! use fake CLI only. The lab VM directory setup and SMTP acceptance bins do +//! not print the API token or the message body. mod add_token; mod bootstrap; @@ -11,7 +13,9 @@ mod free443; mod json; mod mail_tls; mod recovery; +mod smtp_accept; mod token; +mod vm_directory; pub use add_token::run as run_add_token; pub use bootstrap::run as run_bootstrap; @@ -21,3 +25,6 @@ pub use free443::run as run_free_443; pub use json::{account_query_has_admin, file_cert_id_in_query, find_domain_id}; pub use mail_tls::run as run_point_mail_tls; pub use recovery::run as run_recovery_unlock; +pub use smtp_accept::{SmtpAcceptReport, run_vm_lab_smtp_accept, smtp_accept_io}; +pub use token::write_api_token_file; +pub use vm_directory::{VmLabDirectory, run_vm_lab_directory, run_vm_lab_directory_guest}; diff --git a/crates/surmount-stalwart-ops/src/smtp_accept.rs b/crates/surmount-stalwart-ops/src/smtp_accept.rs new file mode 100644 index 0000000..68c7fe0 --- /dev/null +++ b/crates/surmount-stalwart-ops/src/smtp_accept.rs @@ -0,0 +1,193 @@ +//! One SMTP dialogue to 127.0.0.1:25, else ::1. +//! +//! Prints `RCPT ` and, only after RCPT 250, `DATA `. +//! Does not print the message body. Recipients must end in `@example.test`. + +use std::io::{BufRead, Write}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, TcpStream}; +use std::time::Duration; + +use crate::error::ToolError; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SmtpAcceptReport { + pub lines: Vec, + pub success: bool, + pub error: Option, +} + +pub fn smtp_accept_io( + reader: &mut R, + writer: &mut W, + rcpt: &str, +) -> SmtpAcceptReport { + if let Some(report) = reject_rcpt(rcpt) { + return report; + } + dialogue(reader, writer, rcpt) +} + +pub fn run_vm_lab_smtp_accept(rcpt: &str) -> SmtpAcceptReport { + if let Some(report) = reject_rcpt(rcpt) { + return report; + } + let stream = match connect_smtp() { + Ok(stream) => stream, + Err(err) => return fail_report(Vec::new(), err.to_string()), + }; + if let Err(err) = stream.set_read_timeout(Some(Duration::from_secs(20))) { + return fail_report(Vec::new(), err.to_string()); + } + if let Err(err) = stream.set_write_timeout(Some(Duration::from_secs(20))) { + return fail_report(Vec::new(), err.to_string()); + } + let mut writer = match stream.try_clone() { + Ok(cloned) => cloned, + Err(err) => return fail_report(Vec::new(), err.to_string()), + }; + let mut reader = std::io::BufReader::new(stream); + smtp_accept_io(&mut reader, &mut writer, rcpt) +} + +fn reject_rcpt(rcpt: &str) -> Option { + if !rcpt.contains('@') + || rcpt + .bytes() + .any(|b| matches!(b, b'\r' | b'\n' | b' ' | b'<' | b'>')) + { + return Some(fail_report(Vec::new(), "refusing recipient".to_string())); + } + if !rcpt.ends_with("@example.test") { + return Some(fail_report( + Vec::new(), + "refusing non-example.test recipient".to_string(), + )); + } + None +} + +fn dialogue(reader: &mut R, writer: &mut W, rcpt: &str) -> SmtpAcceptReport { + let banner = match read_smtp_reply(reader) { + Ok(code) => code, + Err(err) => return fail_report(Vec::new(), err.to_string()), + }; + if banner != 220 { + return fail_report(Vec::new(), format!("banner {banner}")); + } + let ehlo = match send_cmd(writer, reader, "EHLO mail.example.test") { + Ok(code) => code, + Err(err) => return fail_report(Vec::new(), err.to_string()), + }; + if ehlo != 250 { + return fail_report(Vec::new(), "ehlo refused".to_string()); + } + let mail = match send_cmd(writer, reader, "MAIL FROM:<>") { + Ok(code) => code, + Err(err) => return fail_report(Vec::new(), err.to_string()), + }; + if mail != 250 { + return fail_report(Vec::new(), format!("mail from {mail}")); + } + let rcpt_code = match send_cmd(writer, reader, &format!("RCPT TO:<{rcpt}>")) { + Ok(code) => code, + Err(err) => return fail_report(Vec::new(), err.to_string()), + }; + let mut lines = vec![format!("RCPT {rcpt_code}")]; + if rcpt_code != 250 { + let _ = send_cmd(writer, reader, "QUIT"); + return SmtpAcceptReport { + lines, + success: true, + error: None, + }; + } + let greeting = match send_cmd(writer, reader, "DATA") { + Ok(code) => code, + Err(err) => return fail_report(lines, err.to_string()), + }; + if greeting != 354 { + return fail_report(lines, "data refused".to_string()); + } + let blob = format!( + "From: lab-sender@example.test\r\n\ +To: {rcpt}\r\n\ +Subject: vm-receive-proof\r\n\ +Message-ID: \r\n\ +\r\n\ +vm-receive-proof\r\n\ +.\r\n" + ); + if let Err(err) = writer + .write_all(blob.as_bytes()) + .and_then(|()| writer.flush()) + { + return fail_report(lines, err.to_string()); + } + let data_code = match read_smtp_reply(reader) { + Ok(code) => code, + Err(err) => return fail_report(lines, err.to_string()), + }; + lines.push(format!("DATA {data_code}")); + let _ = send_cmd(writer, reader, "QUIT"); + SmtpAcceptReport { + lines, + success: data_code == 250, + error: None, + } +} + +fn fail_report(lines: Vec, message: String) -> SmtpAcceptReport { + SmtpAcceptReport { + lines, + success: false, + error: Some(message), + } +} + +fn send_cmd( + writer: &mut W, + reader: &mut R, + command: &str, +) -> Result { + writer.write_all(command.as_bytes())?; + writer.write_all(b"\r\n")?; + writer.flush()?; + read_smtp_reply(reader) +} + +fn read_smtp_reply(reader: &mut R) -> Result { + let mut count = 0u32; + loop { + let mut line = Vec::new(); + let n = reader.read_until(b'\n', &mut line)?; + if n == 0 { + return Err(ToolError::fail("smtp closed".to_string())); + } + count += 1; + if count > 40 { + return Err(ToolError::fail("smtp reply too long".to_string())); + } + if line.len() >= 4 && line[3] == b' ' { + let text = std::str::from_utf8(&line[..3]) + .map_err(|_| ToolError::fail("smtp reply had no code".to_string()))?; + let code = text + .parse::() + .map_err(|_| ToolError::fail("smtp reply had no code".to_string()))?; + return Ok(code); + } + } +} + +fn connect_smtp() -> Result { + let mut last = "smtp connect failed".to_string(); + for addr in [ + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 25), + SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 25), + ] { + match TcpStream::connect_timeout(&addr, Duration::from_secs(10)) { + Ok(stream) => return Ok(stream), + Err(err) => last = err.to_string(), + } + } + Err(ToolError::fail(format!("smtp connect failed: {last}"))) +} diff --git a/crates/surmount-stalwart-ops/src/token.rs b/crates/surmount-stalwart-ops/src/token.rs index 07de7c6..98471c9 100644 --- a/crates/surmount-stalwart-ops/src/token.rs +++ b/crates/surmount-stalwart-ops/src/token.rs @@ -130,3 +130,121 @@ pub fn which_or(spec: &str) -> Result { } Ok(spec.to_string()) } + +/// Write `token` plus a newline at `path` with mode 0600. +/// +/// When `owner` is set, the parent directory is mode 0750 and both the parent +/// and the file are given to that user (the lab VM uses `surmount-ui`). +/// Success does not print the token. +pub fn write_api_token_file(path: &Path, token: &str, owner: Option<&str>) -> Result<()> { + assert_safe("api token", token)?; + if token.len() > 4096 || token.contains('\n') || token.contains('\r') { + return Err(ToolError::fail( + "api token must be a single reasonable line".to_string(), + )); + } + absolute_no_dotdot(path)?; + refuse_symlink(path, "token file must be a regular file (symlink refused)")?; + let Some(parent) = path.parent() else { + return Err(ToolError::fail("token path has no parent".to_string())); + }; + if parent.as_os_str().is_empty() { + return Err(ToolError::fail("token path has no parent".to_string())); + } + fs::create_dir_all(parent)?; + if let Some(user) = owner { + chown_user(parent, user)?; + set_mode(parent, 0o750)?; + } + let tmp = parent.join(format!(".stalwart-api-token.{}.tmp", std::process::id())); + refuse_symlink( + &tmp, + "token temp path must be a regular file (symlink refused)", + )?; + if fs::symlink_metadata(&tmp).is_ok() { + fs::remove_file(&tmp)?; + } + let write_result = (|| -> Result<()> { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&tmp)?; + writeln!(file, "{token}")?; + file.sync_all()?; + Ok(()) + })(); + if let Err(err) = write_result { + let _ = fs::remove_file(&tmp); + return Err(err); + } + if let Err(err) = fs::rename(&tmp, path) { + let _ = fs::remove_file(&tmp); + return Err(err.into()); + } + set_mode(path, 0o600)?; + if let Some(user) = owner { + chown_user(path, user)?; + set_mode(path, 0o600)?; + } + Ok(()) +} + +fn absolute_no_dotdot(path: &Path) -> Result<()> { + if !path.is_absolute() + || path + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + return Err(ToolError::fail( + "path must be absolute and must not contain ..".to_string(), + )); + } + Ok(()) +} + +fn refuse_symlink(path: &Path, message: &str) -> Result<()> { + match fs::symlink_metadata(path) { + Ok(meta) if meta.file_type().is_symlink() => Err(ToolError::fail(message.to_string())), + Ok(meta) if !meta.is_file() && meta.file_type().is_dir() => { + Err(ToolError::fail(format!("{message} (path is a directory)"))) + } + Ok(_) | Err(_) => Ok(()), + } +} + +fn set_mode(path: &Path, mode: u32) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + let mut perm = fs::metadata(path)?.permissions(); + perm.set_mode(mode); + fs::set_permissions(path, perm)?; + Ok(()) +} + +fn chown_user(path: &Path, user: &str) -> Result<()> { + if !owner_name_ok(user) { + return Err(ToolError::fail("token owner name refused".to_string())); + } + let status = std::process::Command::new("chown") + .arg("--") + .arg(format!("{user}:{user}")) + .arg(path) + .status()?; + if !status.success() { + return Err(ToolError::fail( + "chown of the token path failed (secret values not logged)".to_string(), + )); + } + Ok(()) +} + +fn owner_name_ok(user: &str) -> bool { + !user.is_empty() + && user.len() <= 32 + && !user.starts_with('-') + && user + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') +} diff --git a/crates/surmount-stalwart-ops/src/vm_directory.rs b/crates/surmount-stalwart-ops/src/vm_directory.rs new file mode 100644 index 0000000..2b9ee45 --- /dev/null +++ b/crates/surmount-stalwart-ops/src/vm_directory.rs @@ -0,0 +1,310 @@ +//! Lab VM guest: wait until Stalwart management HTTP is on 127.0.0.1:8080, +//! ensure the domain and permanent admin, mint an API key, and write that +//! token mode 0600. Does not create the mailbox and does not print the token. +//! +//! Domain, permanent admin, and API key minting go through the existing +//! bootstrap helpers. This module adds the port wait and the token file. + +use std::fs; +use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr, TcpStream}; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::Duration; + +use crate::bootstrap::{ensure_local, local_create, scrub}; +use crate::error::{Result, ToolError}; +use crate::json::extract_secret_line; +use crate::token::{assert_loopback_8080, which_or, write_api_token_file}; + +const FORWARD_UNIT: &str = "surmount-vm-lab-http-forward.service"; +const MANAGEMENT_PORT: u16 = 8080; + +pub struct VmLabDirectory { + recovery_path: PathBuf, + token_path: PathBuf, + domain: String, + url: String, + cli: String, + owner: Option, + forward_unit: String, + http_attempts: u32, + cli_attempts: u32, + http_pause: Duration, + forward_pause: Duration, + cli_pause: Duration, +} + +impl VmLabDirectory { + pub fn guest_defaults() -> Self { + Self { + recovery_path: PathBuf::from("/var/lib/surmount/secrets/stalwart/recovery.env"), + token_path: PathBuf::from("/var/lib/surmount/secrets/ui/stalwart-api-token"), + domain: "example.test".to_string(), + url: "http://127.0.0.1:8080".to_string(), + cli: "stalwart-cli".to_string(), + owner: Some("surmount-ui".to_string()), + forward_unit: FORWARD_UNIT.to_string(), + http_attempts: 90, + cli_attempts: 15, + http_pause: Duration::from_secs(1), + forward_pause: Duration::from_millis(500), + cli_pause: Duration::from_secs(2), + } + } + + /// Hermetic stand-in: no sleeps, no chown, fixed example.test paths the caller supplies. + pub fn hermetic( + recovery_path: impl Into, + token_path: impl Into, + cli: impl Into, + ) -> Self { + let cli = cli.into(); + Self { + recovery_path: recovery_path.into(), + token_path: token_path.into(), + domain: "example.test".to_string(), + url: "http://127.0.0.1:8080".to_string(), + cli: cli.to_string_lossy().into_owned(), + owner: None, + forward_unit: FORWARD_UNIT.to_string(), + http_attempts: 3, + cli_attempts: 3, + http_pause: Duration::ZERO, + forward_pause: Duration::ZERO, + cli_pause: Duration::ZERO, + } + } +} + +pub fn run_vm_lab_directory_guest(args: I) -> Result> +where + I: IntoIterator, + S: AsRef, +{ + let extra: Vec = args.into_iter().map(|s| s.as_ref().to_string()).collect(); + if !extra.is_empty() { + return Err(ToolError::fail( + "vm-lab-directory-setup takes no arguments".to_string(), + )); + } + let opts = VmLabDirectory::guest_defaults(); + run_vm_lab_directory(&opts, vm_lab_tcp_open, vm_lab_systemctl_start) +} + +pub fn run_vm_lab_directory( + opts: &VmLabDirectory, + http_open: impl Fn(&str, u16) -> bool, + start_forward: impl Fn(&str) -> Result<()>, +) -> Result> { + if opts.token_path == opts.recovery_path { + return Err(ToolError::fail( + "token path and recovery path must differ".to_string(), + )); + } + absolute_no_dotdot(&opts.recovery_path)?; + absolute_no_dotdot(&opts.token_path)?; + let (user, password) = read_recovery_pin(&opts.recovery_path)?; + bare_domain(&opts.domain)?; + assert_loopback_8080(&opts.url)?; + let cli_bin = which_or(&opts.cli)?; + let mut lines = Vec::new(); + lines.push(wait_management(opts, &http_open, &start_forward)?); + let primary = ensure_with_retry(opts, &cli_bin, &user, &password)?; + lines.push("domain-ready".to_string()); + let token = mint_with_fallback(opts, &cli_bin, &primary, &user, &password)?; + write_api_token_file(&opts.token_path, &token, opts.owner.as_deref())?; + lines.push("token-ready".to_string()); + Ok(lines) +} + +fn wait_management( + opts: &VmLabDirectory, + http_open: &impl Fn(&str, u16) -> bool, + start_forward: &impl Fn(&str) -> Result<()>, +) -> Result { + let attempts = opts.http_attempts.max(1); + for attempt in 0..attempts { + if http_open("127.0.0.1", MANAGEMENT_PORT) { + return Ok("management-http 127.0.0.1:8080".to_string()); + } + if http_open("::1", MANAGEMENT_PORT) { + start_forward(&opts.forward_unit)?; + thread::sleep(opts.forward_pause); + if http_open("127.0.0.1", MANAGEMENT_PORT) { + return Ok("management-http forwarded from ::1".to_string()); + } + } + if attempt + 1 < attempts { + thread::sleep(opts.http_pause); + } + } + Err(ToolError::fail( + "stalwart management HTTP did not accept 127.0.0.1:8080".to_string(), + )) +} + +fn ensure_with_retry( + opts: &VmLabDirectory, + cli: &str, + user: &str, + password: &str, +) -> Result { + let attempts = opts.cli_attempts.max(1); + let mut last = None; + for attempt in 0..attempts { + match ensure_local(cli, &opts.url, user, &opts.domain, password) { + Ok(login) => return Ok(login), + Err(err) => { + last = Some(err); + if attempt + 1 < attempts { + thread::sleep(opts.cli_pause); + } + } + } + } + Err(last.unwrap_or_else(|| ToolError::fail("ensure directory failed".to_string()))) +} + +fn mint_with_fallback( + opts: &VmLabDirectory, + cli: &str, + primary: &str, + user: &str, + password: &str, +) -> Result { + let mut logins = vec![primary.to_string()]; + if primary != user { + logins.push(user.to_string()); + } + let mut last_text = String::new(); + for login in logins { + if let Some(token) = mint_login(opts, cli, &login, password, &mut last_text)? { + return Ok(token); + } + } + if !last_text.is_empty() { + let safe = scrub(&last_text, password); + if !safe.is_empty() { + eprintln!("{safe}"); + } + } + Err(ToolError::fail( + "create apikey did not return a Secret line".to_string(), + )) +} + +fn mint_login( + opts: &VmLabDirectory, + cli: &str, + login: &str, + password: &str, + last_text: &mut String, +) -> Result> { + let attempts = opts.cli_attempts.max(1); + for attempt in 0..attempts { + let (text, code) = local_create(cli, &opts.url, login, password, "vm-receive-proof")?; + *last_text = text; + if code == 0 { + if let Some(token) = extract_secret_line(last_text) { + return Ok(Some(token)); + } + } + if attempt + 1 < attempts { + thread::sleep(opts.cli_pause); + } + } + Ok(None) +} + +fn read_recovery_pin(path: &Path) -> Result<(String, String)> { + let meta = fs::symlink_metadata(path)?; + if meta.file_type().is_symlink() { + return Err(ToolError::fail( + "recovery pin path must not be a symlink".to_string(), + )); + } + if !meta.is_file() { + return Err(ToolError::fail( + "recovery pin path is not a regular file".to_string(), + )); + } + let mode = { + use std::os::unix::fs::PermissionsExt; + meta.permissions().mode() + }; + if mode & 0o077 != 0 { + return Err(ToolError::fail(format!( + "recovery pin is group or world accessible (mode {:03o})", + mode & 0o777 + ))); + } + let text = fs::read_to_string(path)?; + parse_recovery_pin(&text) +} + +fn parse_recovery_pin(text: &str) -> Result<(String, String)> { + for line in text.lines() { + let line = line.trim_end_matches('\r'); + let Some(rest) = line.strip_prefix("STALWART_RECOVERY_ADMIN=") else { + continue; + }; + let Some((user, password)) = rest.split_once(':') else { + return Err(ToolError::fail("recovery pin shape refused".to_string())); + }; + if user.is_empty() || password.is_empty() || password.chars().any(|c| c.is_control()) { + return Err(ToolError::fail("recovery pin shape refused".to_string())); + } + return Ok((user.to_string(), password.to_string())); + } + Err(ToolError::fail("recovery pin missing".to_string())) +} + +fn bare_domain(domain: &str) -> Result<()> { + if domain.is_empty() + || domain.contains('/') + || domain.contains(' ') + || domain.starts_with('-') + || domain.contains('@') + || domain.contains('\\') + { + return Err(ToolError::fail( + "domain must be a bare hostname (got invalid shape; values not logged)".to_string(), + )); + } + Ok(()) +} + +fn absolute_no_dotdot(path: &Path) -> Result<()> { + if !path.is_absolute() + || path + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + return Err(ToolError::fail( + "path must be absolute and must not contain ..".to_string(), + )); + } + Ok(()) +} + +fn vm_lab_tcp_open(host: &str, port: u16) -> bool { + if port != MANAGEMENT_PORT { + return false; + } + let addr = match host { + "127.0.0.1" => SocketAddr::from((Ipv4Addr::LOCALHOST, port)), + "::1" => SocketAddr::from((Ipv6Addr::LOCALHOST, port)), + _ => return false, + }; + TcpStream::connect_timeout(&addr, Duration::from_secs(2)).is_ok() +} + +fn vm_lab_systemctl_start(unit: &str) -> Result<()> { + if unit != FORWARD_UNIT { + return Err(ToolError::fail("forward unit name refused".to_string())); + } + let _ = std::process::Command::new("systemctl") + .args(["start", unit]) + .output()?; + Ok(()) +} diff --git a/crates/surmount-stalwart-ops/tests/ops.rs b/crates/surmount-stalwart-ops/tests/ops.rs index a742af9..1761496 100644 --- a/crates/surmount-stalwart-ops/tests/ops.rs +++ b/crates/surmount-stalwart-ops/tests/ops.rs @@ -492,3 +492,280 @@ fn mail_tls_help() { let s = String::from_utf8_lossy(&out.stdout); assert!(s.contains("--dry-run") && s.contains("Certificate")); } + +const VM_LAB_TOKEN: &str = "API_SYNTHETIC_VM_LAB_TOKEN_NOT_FOR_STDOUT_9f3c"; +const VM_LAB_PASSWORD: &str = "SYNTHETIC-ADMIN-PASSWORD-NOT-REAL-7e4b"; + +fn vm_lab_smtp_bin() -> &'static str { + env!("CARGO_BIN_EXE_vm-lab-smtp-accept") +} + +fn smtp_script(replies: &str, rcpt: &str) -> (surmount_stalwart_ops::SmtpAcceptReport, Vec) { + let mut reader = std::io::BufReader::new(std::io::Cursor::new(replies.as_bytes())); + let mut outbound = Vec::new(); + let report = surmount_stalwart_ops::smtp_accept_io(&mut reader, &mut outbound, rcpt); + (report, outbound) +} + +fn fake_directory_cli(dir: &Path) -> PathBuf { + let body = r#"#!/bin/sh +set -eu +if [ -z "${STALWART_PASSWORD:-}" ]; then + echo "missing password" >&2 + exit 2 +fi +case "$*" in + *PASSWORD_SENTINEL*) + echo "leaked" >&2 + exit 99 + ;; +esac +case "$*" in + *query*domain*) + printf '%s\n' '{"items":[{"id":"d1","name":"example.test"}]}' + exit 0 + ;; + *query*account*) + printf '%s\n' '{"items":[{"id":"a","name":"admin","emailAddress":"admin@example.test","roles":{"@type":"Admin"}}]}' + exit 0 + ;; + *create*apikey*) + printf '%s\n' 'Secret: TOKEN_SENTINEL' + exit 0 + ;; + *) + echo "unexpected" >&2 + exit 1 + ;; +esac +"# + .replace("PASSWORD_SENTINEL", VM_LAB_PASSWORD) + .replace("TOKEN_SENTINEL", VM_LAB_TOKEN); + write_exec(dir, "stalwart-cli", &body) +} + +fn lab_recovery(dir: &Path) -> PathBuf { + let recovery = dir.join("recovery.env"); + fs::write( + &recovery, + format!("STALWART_RECOVERY_ADMIN=admin:{VM_LAB_PASSWORD}\n"), + ) + .unwrap(); + chmod_600(&recovery); + recovery +} + +#[test] +fn vm_lab_smtp_rcpt_250_prints_data_line() { + // EHLO is multiline on purpose so a one-line reader falls out of step. + let replies = "\ +220 lab ESMTP\r\n\ +250-PIPELINING\r\n\ +250-SIZE 100\r\n\ +250 HELP\r\n\ +250 sender ok\r\n\ +250 recipient ok\r\n\ +354 end data with .\r\n\ +250 queued as vm\r\n\ +221 bye\r\n"; + let (report, outbound) = smtp_script(replies, "recvbox@example.test"); + assert!(report.success, "{report:?}"); + assert_eq!( + report.lines, + vec!["RCPT 250".to_string(), "DATA 250".to_string()] + ); + let wire = String::from_utf8(outbound).unwrap(); + assert!(wire.contains("EHLO mail.example.test\r\n")); + assert!(wire.contains("MAIL FROM:<>\r\n")); + assert!(wire.contains("RCPT TO:\r\n")); + assert!(wire.contains("DATA\r\n")); + assert!(wire.contains("vm-receive-proof\r\n.\r\n")); + assert!( + report + .lines + .iter() + .all(|line| !line.contains("vm-receive-proof")) + ); +} + +#[test] +fn vm_lab_smtp_rcpt_refusal_prints_rcpt_and_no_data_line() { + let replies = "\ +220 lab ESMTP\r\n\ +250 OK\r\n\ +250 sender ok\r\n\ +550 5.1.1 user unknown\r\n\ +221 bye\r\n"; + let (report, outbound) = smtp_script(replies, "notcreated@example.test"); + assert!(report.success, "{report:?}"); + assert!(report.error.is_none()); + assert_eq!(report.lines, vec!["RCPT 550".to_string()]); + assert!(report.lines.iter().all(|line| !line.starts_with("DATA "))); + let wire = String::from_utf8(outbound).unwrap(); + assert!(wire.contains("RCPT TO:\r\n")); + assert!(wire.contains("QUIT\r\n")); + assert!(!wire.contains("DATA\r\n")); +} + +#[test] +fn vm_lab_smtp_data_not_250_fails_after_the_data_line() { + let replies = "\ +220 lab ESMTP\r\n\ +250 OK\r\n\ +250 sender ok\r\n\ +250 recipient ok\r\n\ +354 end\r\n\ +451 queue refused\r\n\ +221 bye\r\n"; + let (report, _) = smtp_script(replies, "recvbox@example.test"); + assert!(!report.success); + assert_eq!( + report.lines, + vec!["RCPT 250".to_string(), "DATA 451".to_string()] + ); +} + +#[test] +fn vm_lab_smtp_foreign_domain_does_not_talk() { + let (report, outbound) = smtp_script("220 lab ESMTP\r\n", "user@other.test"); + assert!(!report.success); + assert!(report.lines.is_empty()); + assert!(outbound.is_empty()); + assert_eq!( + report.error.as_deref(), + Some("refusing non-example.test recipient") + ); +} + +#[test] +fn vm_lab_smtp_accept_bin_rejects_foreign_domain() { + let out = Command::new(vm_lab_smtp_bin()) + .arg("user@other.test") + .output() + .unwrap(); + assert!(!out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout + .lines() + .all(|line| !line.starts_with("RCPT ") && !line.starts_with("DATA ")) + ); +} + +#[test] +fn vm_lab_token_file_is_mode_0600_without_printing_token() { + let work = temp_dir("vm-token"); + let path = work.join("dir").join("stalwart-api-token"); + surmount_stalwart_ops::write_api_token_file(&path, VM_LAB_TOKEN, None).unwrap(); + let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + assert_eq!( + fs::read_to_string(&path).unwrap(), + format!("{VM_LAB_TOKEN}\n") + ); +} + +#[test] +fn vm_lab_token_file_refuses_symlink() { + let work = temp_dir("vm-symlink"); + let real = work.join("real"); + fs::write(&real, "x\n").unwrap(); + let link = work.join("link"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + let err = surmount_stalwart_ops::write_api_token_file(&link, VM_LAB_TOKEN, None).unwrap_err(); + assert!(!err.to_string().contains(VM_LAB_TOKEN)); + assert_eq!(fs::read_to_string(&real).unwrap(), "x\n"); +} + +#[test] +fn vm_lab_directory_setup_rejects_arguments() { + let err = surmount_stalwart_ops::run_vm_lab_directory_guest(["--help"]).unwrap_err(); + assert!(err.to_string().contains("no arguments")); +} + +#[test] +fn vm_lab_directory_missing_pin_fails_before_http() { + let work = temp_dir("vm-nopin"); + let recovery = work.join("recovery.env"); + fs::write(&recovery, "OTHER=1\n").unwrap(); + chmod_600(&recovery); + let token_path = work.join("stalwart-api-token"); + let cli = write_exec(&work, "stalwart-cli", "#!/bin/sh\nexit 99\n"); + let opts = surmount_stalwart_ops::VmLabDirectory::hermetic(&recovery, &token_path, &cli); + let err = surmount_stalwart_ops::run_vm_lab_directory( + &opts, + |_host, _port| panic!("http should not be probed"), + |_unit| panic!("forward should not start"), + ) + .unwrap_err(); + assert!(err.to_string().contains("recovery pin")); + assert!(!token_path.exists()); +} + +#[test] +fn vm_lab_directory_status_lines_omit_token_and_file_is_0600() { + let work = temp_dir("vm-dir"); + let cli = fake_directory_cli(&work); + let recovery = lab_recovery(&work); + let token_path = work.join("ui").join("stalwart-api-token"); + let opts = surmount_stalwart_ops::VmLabDirectory::hermetic(&recovery, &token_path, &cli); + let lines = surmount_stalwart_ops::run_vm_lab_directory( + &opts, + |host, _port| host == "127.0.0.1", + |_unit| { + Err(surmount_stalwart_ops::ToolError::fail( + "forward unit must not start when 127.0.0.1:8080 is open".to_string(), + )) + }, + ) + .unwrap_or_else(|err| panic!("{err}")); + let rendered = lines.join("\n"); + let domain_at = lines + .iter() + .position(|line| line == "domain-ready") + .unwrap(); + let token_at = lines.iter().position(|line| line == "token-ready").unwrap(); + assert!(domain_at < token_at, "{rendered}"); + assert!(!rendered.contains(VM_LAB_TOKEN), "{rendered}"); + assert!(!rendered.contains(VM_LAB_PASSWORD), "{rendered}"); + assert!(!rendered.contains("Secret"), "{rendered}"); + let mode = fs::metadata(&token_path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + assert_eq!( + fs::read_to_string(&token_path).unwrap(), + format!("{VM_LAB_TOKEN}\n") + ); +} + +#[test] +fn vm_lab_directory_forwards_ipv6_management_then_writes_token() { + let work = temp_dir("vm-fwd"); + let cli = fake_directory_cli(&work); + let recovery = lab_recovery(&work); + let token_path = work.join("stalwart-api-token"); + let opts = surmount_stalwart_ops::VmLabDirectory::hermetic(&recovery, &token_path, &cli); + let phase = std::cell::Cell::new(0u8); + let lines = surmount_stalwart_ops::run_vm_lab_directory( + &opts, + |host, _port| match (host, phase.get()) { + ("127.0.0.1", 0) => false, + ("::1", 0) => true, + ("127.0.0.1", 1) => true, + _ => false, + }, + |_unit| -> surmount_stalwart_ops::Result<()> { + phase.set(1); + Ok(()) + }, + ) + .unwrap_or_else(|err| panic!("{err}")); + assert!( + lines + .iter() + .any(|line| line == "management-http forwarded from ::1") + ); + assert!(lines.iter().any(|line| line == "token-ready")); + assert!(!lines.join("\n").contains(VM_LAB_TOKEN)); + let mode = fs::metadata(&token_path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); +} diff --git a/docs/COMPACTION-PIN.md b/docs/COMPACTION-PIN.md index 44e38d2..0a16e9a 100644 --- a/docs/COMPACTION-PIN.md +++ b/docs/COMPACTION-PIN.md @@ -4,7 +4,13 @@ Read this first, then follow links into child docs. Do not invent product scope from memory alone. -**Last updated:** 2026-09-08 (GitHub tracking: plan Approve opens a +**Last updated:** 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: +[TEST-AND-DEPLOY.md](TEST-AND-DEPLOY.md); agent through dry-run). Prior 2026-09-11 (each public HTTP Host gets its own v3 onion; +Onion-Location is `http://{that-host-onion}{path}`; mail Hosts stay +unmapped). Prior 2026-09-08 (GitHub tracking: plan Approve opens a repo issue with the plan text; operator bug reports open a repo issue with screenshots; agents never GPG-sign or push; after the operator signs and pushes the feature branch the agent opens the PR; git-flow @@ -46,7 +52,7 @@ day: operator asked primary MX to `mail.surmount.systems`; laptop `list` worked; FWD still published eforward. Prior same day: dual onion discovery on every public HTTP Host this edge serves, including extra static vhosts and MTA-STS; -`/_o/{host}` onion routing; mail unmapped. Prior same day: mail records +per-site v3 onions (2026-09-11); mail unmapped. Prior same day: mail records on every mailbox domain, not primary-only; leftover parent DS is a SERVFAIL class to clear then sign. Prior same day: Administrator attach npub on `/mail` Grant console login; session CSRF; map write without @@ -54,7 +60,7 @@ directory listing. Prior 2026-08-19 (apex/www serve packaged SurmountSystems/site tip `1c84696`; COMING SOON leftover closed; services console unchanged). Prior 2026-08-18 (first packaged GitHub site on apex/www). Prior 2026-08-17 -(Onion-Location + Alt-Svc on Axum edge; live Arti unit active; same v3 +(Onion-Location + Alt-Svc on Axum edge; live Arti unit active; per-site v3 for apex/www/services; Tor Browser verify and operator HS backup still residual; B3 not fully closed). Prior 2026-08-12 (earn-trust live Tracks 0/A/B/C: UNDER CONSTRUCTION, dual DKIM register, @@ -114,14 +120,17 @@ Ladder detail: [fix-and-fixos.md](fix-and-fixos.md). | **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. | | **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-host` switch and of `just check-remote`, hypervisor, Namecheap hosted DNSSEC Status click). 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 this laptop already has Namecheap env files, `just laptop-renew-cert` is agent work. 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-host -- --dry-run`, and must report those results. Narrows 2026-08-25: dry-run is required agent work; the real switch stays operator-owned unless they override. Laptop cargo and `BUILD_LOCAL` stay forbidden. 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. | -| **Finish check-remote then dry-run then switch** | Operator 2026-08-27: after a guest reboot or RAM scare, do **not** tell the operator to wait. Leftover stays `just check-remote`, then `just deploy-host -- --dry-run`, then the real switch unless they said stop. Agents never reboot. Agents run eval plus dry-run (2026-09-02). The real switch and `just check-remote` stay operator-owned unless they override. | +| **Finish check-remote then dry-run then switch** | Operator 2026-08-27: after a guest reboot or RAM scare, do **not** tell the operator to wait. Leftover stays `just check-remote`, then `just deploy-dry-run`, then the real `just deploy` switch unless they said stop. Agents never reboot. Agents may run one `just check-remote` at a time, plus eval and the dry-run (2026-09-02). The real switch `just deploy` is operator-owned. Agents must not run `just deploy`. `just deploy-host` without `--dry-run` is that same switch. Long form `just deploy-host` stays valid and has no default target. | | **Forks** | Agents never touch `SurmountSystems/*` fork git unless the operator **explicitly** orders that work. Stalwart fork consume: flake input. PQConnect Nix work on sibling repo stays **unstaged for human** commit. | +| **Cargo update and cargo audit are one pair** | Operator 2026-10-10: do not run `cargo update` without an audit of that new lock. Do not run `cargo audit` / `just audit-remote` on a lock that was not just updated, unless a significant amount of time has passed since the last pair. Do not invent a day count. A few days is not that gap. Menhera stays the index. The laptop-cargo forbid still covers tests, clippy, and rustc. It does not split this pair or hand `cargo update` back. Dual-pin `AGENTS.md`. | +| **Fmt runs before clippy** | Operator 2026-10-12: cargo fmt and nixfmt finish before clippy. `just check` is `fmt`, then `clippy`, then `test`. In `checks.*.ci`, each clippy derivation depends on `management-ui-fmt` and `nix-fmt-check`. Do not list clippy before fmt. Dual-pin `AGENTS.md`. | | **Always validate versions** | Re-check upstream latest when touching pins. Docs lag. [research/version-audit.md](research/version-audit.md). | | **Prove assumptions** | No "unsafe" / scare copy without evidence (failed build, broken import, measured issue). | | **Question ids** | Unique global **Q-*** (or per-file Q1, Q2...). Never collide with section numbers. | @@ -133,20 +142,20 @@ Ladder detail: [fix-and-fixos.md](fix-and-fixos.md). --- -## 3. Versions (last audit 2026-07-31; re-verify before bump) +## 3. Versions (last audit 2026-07-31; FOD and toolchain rows re-checked 2026-10-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.15** binary FOD | At latest upstream tag (re-check 2026-07-31) | -| stalwart-cli | **1.0.12** | At latest; no `import` subcommand | -| vandelay | **1.0.7** | Official 0.16 Maildir++ / JMAP import | -| Stalwart WebUI | **1.0.7** | At latest | -| spam-filter rules | **3.0.0** | At latest | -| Arti (HS package) | Surmount-owned source **2.5.1** (`arti-onion-service.nix`, GitLab `arti-v2.5.1`) + `onion-service-service` | Matches nixpkgs-rust `arti` 2.5.1 cargoDeps. crates.io max_stable **2.5.1** (re-check 2026-08-27; no 2.6). Not stock nixpkgs 1.4.2 lag; rustc via `nixpkgs-rust` (MSRV 1.91+). Live Tor verify still residual | -| management-ui rustc (crane) | **1.95.0** via `rustPackages_1_95` | Host channel nixos-26.05; Leptos MSRV is >=1.88 | +| Stalwart server | **0.16.24** binary FOD | Latest GitHub tag on 2026-10-03. A 0.16.x upgrade replaces the binary. Not nixpkgs stalwart | +| stalwart-cli | **1.0.13** | Latest tag on 2026-10-03. Still no Maildir import (that is Vandelay) | +| vandelay | **1.0.11** | Latest tag on 2026-10-03. Official 0.16 Maildir++ / JMAP import | +| Stalwart WebUI | **1.0.11** | Latest tag on 2026-10-03 | +| spam-filter rules | **3.0.2** | Latest tag on 2026-10-03 | +| Arti (HS package) | Surmount-owned source **2.6.0** (`arti-onion-service.nix`, GitLab `arti-v2.6.0`) + `onion-service-service` | Matches nixpkgs-rust `pkgs.arti.version` **2.6.0** (lock `c9f19b6`, `nix eval` 2026-10-10). Upstream tag `arti-v2.7.0` exists (2026-10-01). staging HEAD that day had `pkgs.arti` 2.7.0 and was not taken. Not stock nixpkgs 1.4.2 lag; rustc via `nixpkgs-rust` is **1.99.0** (MSRV 1.91+). Live Tor verify still residual | +| management-ui rustc (crane) | **1.99.0** from `nixpkgs-rust` `pkgs.rustc` (`nix/rust-toolchain.nix`, lock `c9f19b6`) | Host channel nixos-26.05 `pkgs.rustc` is still **1.95.0**. grok-oss uses the same `surmount-rust-1.99.0` 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 | | 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 | @@ -199,7 +208,7 @@ Depth: [DATASTORES.md](DATASTORES.md), | 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. | | 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`; 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. | +| 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 | | Local IPC | Prefer **Unix domain sockets**. Stalwart HTTP today is IP:port (UDS gap; loopback scaffold). | | Access control | **Merciless ban** + **whitelist** (never banned) + **last-used**; edge rate limits; first path in UI + optional nft sets + **helper scaffold** (`nftHelper`, UI no CAP_NET_ADMIN); long-term **Rust + nftables**, not Python fail2ban as identity; Q-ACL-* open | @@ -228,7 +237,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.5.1** (GitLab `arti-v2.5.1`) 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; same v3; `/_o/{host}` for non-console; 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.6.0** (GitLab `arti-v2.6.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`. | Research (update status to required): [research/arti-and-secrets-manager.md](research/arti-and-secrets-manager.md). @@ -401,10 +410,10 @@ 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.5.1** 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; same v3; `/_o/{host}` for non-console; 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 | +| **Arti live Tor publish** | Module generates **management-publish** `arti.toml` (onion + rproxy, no keys in tree); Surmount `artiOnionService` is owned **2.6.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** | | **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.95** (`rustPackages_1_95`); Leptos MSRV floor is >=1.88 | +| **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. | | **Vaultwarden module** | **S7a shipped offline:** `modules/vaultwarden.nix`, management console URL/chip/Open vault, pure eval tests. Sample host enable=false. **Do not rebuild S7a.** **S7b** A0 scripts install kind + private enable fragment + smoke; live unit after operator token + target. **V4 offline:** human-store runbook + optional `secrets-export-bw-to-staging` (not activation). **Phase B offline (2026-08-11):** Axum `/vault/` reverse-proxy in management-ui (`vaultwardenProxyEnable` default off; hermetic green); not live host VW; not public HTTPS. Not SM API | | **Operator OS secret store + deploy bridge** | Direction 2026-08-09; **S0-S4 + A0 cutover pack shipped offline:** living contract + runbook + `nix run .#secrets-install-host` + opt-in `nix run .#surmount-deploy-host -- --install-secrets` + `nix run .#surmount-host-cutover` compose (inventory, lab DNS hook, fragments) + hermetic crate tests. **Not** shipped: libsecret Rust crate, live keyring CI, S5 Rust helper, S6 live requireDeployMaterial with real values, live S7b unit / Q-SEC-SM-*. Host still needs B0+ files on box | @@ -431,8 +440,11 @@ multi-page management console** (`/`, `/domains`, `/accounts`, `/system`, deploy-secrets fail-loud options, hardening/networking + optional accessControl nft sets, docs, scripts, local/host end-to-end harnesses (`nix run .#e2e` / `nix run .#e2e-host`, thin `just e2e` / `just e2e-host`), -VM and pure contract checks. Crane management-ui uses host-channel -`rustPackages_1_95` (nixos-26.05). E2E SoT is flake apps + Rust `crates/surmount-e2e` +VM and pure contract checks. Crane management-ui and grok-oss use +`nixpkgs-rust` `pkgs.rustc` 1.99.0 (`nix/rust-toolchain.nix`, lock +`c9f19b6`), not host-channel `rustPackages_1_95`. Host nixos-26.05 +`pkgs.rustc` is still 1.95.0. +E2E SoT is flake apps + Rust `crates/surmount-e2e` (not free-floating bash). Host e2e is never in `checks.*.ci`. Tor deep row is optional on local `just e2e` only (not CI). Full hermetic anchors live outside the aggregate today; folding **hermetic-only** into a flake check is a product diff --git a/docs/DATASTORES.md b/docs/DATASTORES.md index 6a97392..00462aa 100644 --- a/docs/DATASTORES.md +++ b/docs/DATASTORES.md @@ -4,7 +4,8 @@ Intentional design of every durable and semi-durable store in the Surmount mail + web stack. Written for a senior database engineer: precise, no "defaults are fine," explicit unknowns, load-test honesty. -**Last updated:** 2026-08-19 (sshd host keys: module generates ed25519 only; +**Last updated:** 2026-09-11 (per-site onion nicknames JSON + published-hostnames +dir; HS keys still off git). Prior 2026-08-19 (sshd host keys: module generates ed25519 only; leftover RSA files on a live host are not deleted by hostKeys). Prior 2026-08-17 (Arti HS identity dir + optional onion map file) @@ -562,7 +563,9 @@ wired in modules yet. | Extra static site document roots | Static HTML/CSS/JS/media | `/var/lib/surmount/static-sites/` (Nix `managementUi.staticVhosts`; env `SURMOUNT_STATIC_VHOSTS_FILE`) | Files | Operator copy from DS3018xs GVFS; not git | Public site files (no deploy secrets) | surmount-management-ui | | Arti HS identity | Arti onion-service keystore | `surmount.artiHiddenService.onionServiceStateDir` (module default `/run/surmount-secrets/arti/onion-service` is **ephemeral**; live host uses durable Domain B under `/var/lib/surmount/secrets/arti/onion-service`) | Files; **reboot-safe only if not `/run`** | **Operator offline backup** (still residual). Never public git | **HS private keys** (0700 keystore). Onion **hostname** is not a secret | `surmount-arti`; module does not create the dir | | Arti process cache / HOME | Arti cache + `port_info.json` | `/var/lib/surmount/arti` (tmpfiles 0750 `surmount-arti`). Live host-local sets unit `HOME=` here | Files | Optional / skip | No keys | `surmount-arti` | -| Optional onion map file | JSON host-to-onion overrides | `SURMOUNT_ONION_MAP_FILE` only if set; not required when auto-derive apex/www/services | Files | With operator path | Onion hostnames only; never HS keys | Operator / management-ui (loaded at process start) | +| Optional onion map file | JSON host-to-onion overrides | `SURMOUNT_ONION_MAP_FILE` only if set | Files | With operator path | Onion hostnames only; never HS keys | Operator / management-ui (loaded at process start) | +| Per-site onion nicknames | JSON clearnet Host -> Arti nickname | `/etc/surmount/onion-site-nicknames.json` (`SURMOUNT_ONION_SITE_NICKNAMES_FILE`) | Nix etc | With generation | Nicknames only; never HS keys | arti-hidden-service.nix | +| Published per-site onion addresses | One file per Arti nickname | `surmount.artiHiddenService.stateDir` + `/published-hostnames` (`SURMOUNT_ONION_PUBLISHED_HOSTNAMES_DIR`) | Files; filled after `arti hss onion-address` | Optional | Public v3 addresses only; never HS keys | surmount-arti ExecStartPost | | nft surmount_guard sets | nftables sets (empty at install) | kernel nft when accessControl.nftSets | Host firewall | N/A (rebuilt from app/helper) | No | hardening.nix | | Product auth (npub allowlist) | Host file / env | `nostrAllowlistFile` Domain B path | As designed | sops + state | Bootstrap Administrator npubs; not User map rows | Surmount | | nginx (transitional) / future Axum-first edge state | certs + conf | conf in Nix; see EDGE_AND_TLS / tls research | Cert material | `/var/lib/acme` (scaffold) | Private keys | Edge module | diff --git a/docs/DNS.md b/docs/DNS.md index 87cf142..d55dec5 100644 --- a/docs/DNS.md +++ b/docs/DNS.md @@ -7,7 +7,15 @@ real user mail. Validate send path with [mail-tester.com](https://www.mail-tester.com/) and your provider's blocklist checks. -**Last updated:** 2026-09-07 (intended production leaf is **20** +**Last updated:** 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 +mechanism stays. The record still ends in `-all`. DMARC stays +`p=quarantine`. Selector `google._domainkey` is not published. +See [Google send](#google-send-operator-direction-2026-10-10). + +**Prior update:** 2026-09-07 (intended production leaf is **20** certificate hostnames on one Let's Encrypt PEM (`with_single_cert`): the live 18 plus `cryptoquick.com` and `www.cryptoquick.com`. Live leaf still has **18** names (CT). Validating A for cryptoquick apex/www @@ -66,6 +74,30 @@ Living companions: [EDGE_AND_TLS.md](EDGE_AND_TLS.md), [SECURITY.md](SECURITY.md), [COMPACTION-PIN.md](COMPACTION-PIN.md), [../AGENTS.md](../AGENTS.md) (mail records standing law). +## Google send (operator direction 2026-10-10) + +Operator direction **2026-10-10**: Google may send mail as +`surmount.systems`. This decision is that domain only. Do not change +`cryptoquick.com` for Google send. + +Live apex SPF: + +```text +v=spf1 a:mail.surmount.systems include:_spf.google.com -all +``` + +`include:_spf.google.com` is the Google-documented include. Keep +`a:mail.surmount.systems`. Do not remove our host. The record still +ends in `-all`. Do not use `~all` or `+all`. DMARC stays +`p=quarantine`. Do not set `p=reject`. + +DKIM selector `google._domainkey.surmount.systems` stays unpublished +until Google Admin produces the TXT. Path: Apps, Google Workspace, +Gmail, Authenticate email. Selector name `google`. Do not invent a +`p=` value. Do not generate a keypair and publish it as Google's. +Google's private key stays at Google. The TXT `p=` value is public +once published. Stalwart selectors `stalwart` and `stalwart-rsa` stay. + --- ## Mail records on every domain we send or receive through @@ -924,6 +956,7 @@ just dns-zone-namecheap -- set-host mail --a 203.0.113.10 --aaaa 2001:db8::10 just dns-zone-namecheap -- set-txt 'default._bimi' 'v=BIMI1; l=https://surmount.systems/bimi.svg;' # Apply only after dry-run looks right (TEST-NET examples above; use real VPS addresses) +# --live without MOCK_DIR is live getHosts then setHosts (laptop ClientIp). Default stays dry-run. just dns-zone-namecheap -- --live set-a services 203.0.113.10 # Other registered zones (one SLD/TLD per credentials file; laptop ClientIp): diff --git a/docs/EDGE_AND_TLS.md b/docs/EDGE_AND_TLS.md index eb29540..0080a0d 100644 --- a/docs/EDGE_AND_TLS.md +++ b/docs/EDGE_AND_TLS.md @@ -4,7 +4,18 @@ 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-07 (intended production leaf is **one** Let's +**Last updated:** 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. +`POST /splora/queue` stays the queue and is not a network prefix. GET / +is a short same-host path list, not an explorer. The Rust explorer UI +belongs to Splora, not this repo. No block, transaction, or address +pages.) Prior +2026-09-20 (that Host listed indexer Hosts as https links and was not an +indexer). Prior 2026-09-11 (each public HTTP Host gets its own v3 onion; +Onion-Location is `http://{that-host-onion}{path}`; mail Hosts stay +unmapped). Prior 2026-09-07 (the intended Let's Encrypt certificate this host presents is **one** Let's Encrypt PEM pair (`with_single_cert`) covering **20** certificate hostnames: the live 18 plus `cryptoquick.com` and `www.cryptoquick.com`. Live leaf as of this measure still has **18** names (CT): extra static @@ -15,12 +26,12 @@ verify. 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. Esplora Hosts stay off this leaf. Do not invent -leftover Namecheap clicks. Do not MX-flip Baxter.) Prior 2026-09-03 (HTTP/3 NIP-07 login 500: axum-h3 omits Axum `ConnectInfo` on `POST /api/v1/auth/session`; optional peer, do not ban unspecified). Prior 2026-09-02 (Splora REST requires a Bitcoin JSON-RPC peer by design; indexer is not Core; remote shape is `daemonDir = null` plus cookie path plus `daemonRpcAddr`; node inventory is tasked in the splora tree). Prior same day (flake input `splora` locked to `9481e4cb87273aa99b0357be48503765beadb919`; `surmount.sploraIndexer` stays the host-local single knob over first-class instance JSON-RPC options; five esplora Hosts and UDP 443 / HTTP/3 stay optional Axum edge, not mempool REST prerequisites; Unix socket or one existing Host is enough; do not map REST onto the mail console Host). Prior 2026-09-01 (`surmount.sploraIndexer` host-local wrap for one remote JSON-RPC indexer). Prior 2026-09-01 (flake input `splora` locked to `343727487988ed0a764674ff21c0750465b9a3e8`; overlay consumes input packages; no this-tree crane wrap). Prior 2026-09-01 (Splora Host map: TCP HTTP/2 vs UDP HTTP/3; leftover esplora certificate hostnames as complete sentences). Prior 2026-09-01 (flake input `splora` on the `surmount` branch). Prior 2026-09-01 (documented esplora Hosts for the Splora Unix proxy; flake input `splora`). Prior 2026-08-31 (splora Unix sockets behind Axum; HTTP/3 QUIC on UDP :443). Prior 2026-08-25 (operator bins are `nix run .#...`.) Prior 2026-08-24 (`just deploy` publishes static sites; `just deploy-host` is the NixOS generation.) Prior 2026-08-21 (live production leaf was 18 certificate +not the HTTPS cause. Esplora Hosts stay off that certificate. Do not invent +leftover Namecheap clicks. Do not MX-flip Baxter.) Prior 2026-09-03 (HTTP/3 NIP-07 login 500: axum-h3 omits Axum `ConnectInfo` on `POST /api/v1/auth/session`; optional peer, do not ban unspecified). Prior 2026-09-02 (Splora REST requires a Bitcoin JSON-RPC peer by design; indexer is not Core; remote shape is `daemonDir = null` plus cookie path plus `daemonRpcAddr`; node inventory is tasked in the splora tree). Prior same day (flake input `splora` locked to `9481e4cb87273aa99b0357be48503765beadb919`; `surmount.sploraIndexer` stays the host-local single knob over first-class instance JSON-RPC options; five esplora Hosts and UDP 443 / HTTP/3 stay optional Axum edge, not mempool REST prerequisites; Unix socket or one existing Host is enough; do not map REST onto the mail console Host). Prior 2026-09-01 (`surmount.sploraIndexer` host-local wrap for one remote JSON-RPC indexer). Prior 2026-09-01 (flake input `splora` locked to `343727487988ed0a764674ff21c0750465b9a3e8`; overlay consumes input packages; no this-tree crane wrap). Prior 2026-09-01 (Splora Host map: TCP HTTP/2 vs UDP HTTP/3; leftover esplora certificate hostnames as complete sentences). Prior 2026-09-01 (flake input `splora` on the `surmount` branch). Prior 2026-09-01 (documented esplora Hosts for the Splora Unix proxy; flake input `splora`). Prior 2026-08-31 (splora Unix sockets behind Axum; HTTP/3 QUIC on UDP :443). Prior 2026-08-25 (operator bins are `nix run .#...`.) Prior 2026-08-24 (`just deploy` publishes static sites; `just deploy-host` is the NixOS generation.) Prior 2026-08-21 (the live Let's Encrypt certificate this host presents had 18 certificate 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 the leaf 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 is `just laptop-renew-cert -- --check|--live --directory production`. `--live` issues only when due. Host ACME stays off. Stalwart 0.16.15 query resolves certificate hostnames plus id.) @@ -261,8 +272,9 @@ nginx features. Module file remains until operators no longer need dual-run. override. Missing root or missing `index.html` keeps the yellow **UNDER CONSTRUCTION** page (`#FFFF00`). **Live 2026-08-19:** apex and www serve the current GitHub site copy, not UNDER CONSTRUCTION. - `just deploy` publishes those static files (and extra vhosts); `just deploy-host` - is the NixOS generation. Services + `just publish` publishes those static files (and extra vhosts); `just deploy` + is the NixOS generation (the host switch). `just deploy-host` is that + switch with no default target. Services console is unchanged. No operator nav on apex/www. Operator console (Dashboard Overview, Stalwart chips) is only on **`servicesHostname`** (e.g. `services.surmount.systems`). @@ -284,54 +296,64 @@ nginx features. Module file remains until operators no longer need dual-run. (closed 404 if missing); `/health` and `/api/health` stay edge probes; never the operator console. Do **not** overload `SURMOUNT_APEX_PUBLIC_ROOT`. `services.surmount.systems` stays the operator console. Extra Hosts - **auto-map** Onion-Location / Alt-Svc to the same v3 onion (2026-08-20). - Onion-Location for extra Hosts, apex/www, and `mta-sts.{primary}` uses - `/_o/{clearnet-host}{path}` so Tor Browser lands on that surface, not - the services console. `http://{onion}/` stays the console. Mail stays - unmapped. Do not put nginx back as product edge. + 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 + `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. -### Splora Host -> Unix socket map +### Splora paths on one Host The public sample host keeps `surmount.sploraProxy.enable` at the default **off**. Private host-local enables the proxy. Indexer sockets exist only when those indexer units run. -Mempool / Esplora REST is the indexer process on a Unix socket (or TCP -`--http-addr`). It does **not** require public Hosts, Let's Encrypt names, -or HTTP/3. A local client can call -`curl --unix-socket /run/splora/.http.sock http://localhost/blocks/tip/height` -(plus NIP-98 unless `--public-health` and that exact tip path). Unix -socket or **one existing** Host on the already-running Axum listener is +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 +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 | | + +Mempool / Esplora REST is the indexer process on a Unix socket. It does +**not** require extra public Hosts, certificate names, or HTTP/3. Unix +socket or **this one** Host on the already-running Axum listener is enough. Do **not** map REST onto the mail console Host (`services.surmount.systems`); that Host would steal every path. Do not invent a `/esplora` prefix on the console. -The five `esplora.*` names below are an **optional** documented Host map, -not a REST gate. They are not on the live leaf today. Adding them to the -production leaf is optional edge work. Do not invent extra domains this -turn. Do not treat a Host map in this file as proof the certificate -already covers them. - -Clearnet clients that use this optional map reach the edge on TCP :443 -(TLS 1.3, ALPN `h2` and `http/1.1`) and, when HTTP/3 is on (the -`http3Enable` default), on UDP :443 (QUIC, ALPN `h3` only). HTTP/3 and -hypervisor UDP 443 are optional edge, not prerequisites of the mempool -REST. The hop from this process 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. - -| Network | Public Host | Unix socket | Notes | -|---------|-------------|-------------|-------| -| mainnet | `esplora.surmount.systems` | `/run/splora/mainnet.http.sock` | Indexer HTTP + `GET /api/v1/ws` | -| testnet3 | `testnet3.esplora.surmount.systems` | `/run/splora/testnet3.http.sock` | Same | -| testnet4 | `testnet4.esplora.surmount.systems` | `/run/splora/testnet4.http.sock` | Same | -| mutinynet | `mutinynet.esplora.surmount.systems` | `/run/splora/mutinynet.http.sock` | Same | -| liquid | `liquid.esplora.surmount.systems` | `/run/splora/liquid.http.sock` | Same | -| queue | `POST /splora/queue` (any Host) | `/run/splora/queue.sock` | `{npub,email}` only; not indexer | -| Electrum newline socket | **not proxied** | (no socket) | Fail-closed if configured | +Clearnet clients reach that Host on TCP :443 (TLS 1.3, ALPN `h2` and +`http/1.1`) and, when HTTP/3 is on (the `http3Enable` default), on UDP +:443 (QUIC, ALPN `h3` only). HTTP/3 and hypervisor UDP 443 are optional +edge, not prerequisites of the mempool REST. The hop from this process +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 +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). **Live in browsers (2026-08-20):** Namecheap NS, exclusive A matching - this host, HTTPS **200**, production leaf covers apex+www, packaged + this host, HTTPS **200**, the Let's Encrypt certificate this host presents covers apex+www, packaged site content, not console, not COMING SOON: yiffa.app, baxterartworks.com, btcfur.com, iantuckerstudios.com, nostrfurs.com, exophiles.org (each apex + www). Host-local roots under @@ -342,7 +364,7 @@ speak HTTP/3. Splora does not listen on UDP. **Not live in browsers as trusted HTTPS (2026-09-07):** `cryptoquick.com` and `www.cryptoquick.com`. Validating A lookups succeed (AD true). Leftover parent DS key tag 2368 is gone. The live - production leaf still has **18** names and does **not** include those + Let's Encrypt certificate this host presents still has **18** names and does **not** include those two. HTTPS fails at certificate hostname mismatch on the same Axum listener and the same Let's Encrypt YE2 leaf. Intended leaf is **20** names on one PEM (`with_single_cert`): the live 18 plus those two. @@ -350,7 +372,7 @@ speak HTTP/3. Splora does not listen on UDP. titled Hunter Beast. Do **not** invent leftover Namecheap DNSSEC clicks. SHA-1 parent DS digest type 1 remains standing DNSSEC quality debt in operator-facts Monday leftover; it is not the HTTPS cause. - Esplora Hosts stay off this leaf. Do **not** MX-flip Baxter. + Esplora Hosts stay off that certificate. Do **not** MX-flip Baxter. Not extra Hosts (do not Host-serve): btcdragonlord.com (not operator-owned), btckitties.com (archived), denver.space, @@ -368,10 +390,10 @@ speak HTTP/3. Splora does not listen on UDP. certificate hostnames. `https:///` and `https://www./` are live **200**. Do not re-issue just to add a name that is already on that leaf. **Live 2026-08-21 through - 2026-09-07:** `mail.cryptoquick.com` is on this shared production leaf + 2026-09-07:** `mail.cryptoquick.com` is on this shared Let's Encrypt certificate (IMAP :993 / SMTPS :465 identity). Cryptoquick apex/www are first-class web Hosts like the six extra static zones. **Live 2026-09-07/08:** - production leaf is **20** names on the same PEM (`with_single_cert`), + the Let's Encrypt certificate this host presents has **20** names on the same PEM (`with_single_cert`), including `exophiles.org` and `www.exophiles.org`. Do **not** omit Namecheap static zones when adding cryptoquick apex/www. Pass an explicit `--domains` list of those 20 names so host-profile Cloudflare @@ -463,22 +485,24 @@ speak HTTP/3. Splora does not listen on UDP. when publishing. Not nginx; not a second subdomain. Prefer after free-443 and public https listen are real. See [SECURITY.md](SECURITY.md). - **Splora Unix proxy (optional, default off):** `surmount.sploraProxy.enable` - maps public Hosts to HTTP/1.1 Unix sockets on the **edge host**. Documented - Hosts (optional edge, not a REST requirement): - `esplora.surmount.systems` to `/run/splora/mainnet.http.sock`, - `testnet3.esplora.surmount.systems` to `/run/splora/testnet3.http.sock`, - `testnet4.esplora.surmount.systems` to `/run/splora/testnet4.http.sock`, - `mutinynet.esplora.surmount.systems` to `/run/splora/mutinynet.http.sock`, - `liquid.esplora.surmount.systems` to `/run/splora/liquid.http.sock`. The - public sample stays proxy off. Private host-local enables the proxy. - Indexer sockets exist only when those units run. Unix socket or one - existing Host is enough for mempool REST. Do not map REST onto the - mail console Host. Five esplora Let's Encrypt names are optional - edge; they are not on the live leaf today and are not a REST gate. - The Axum edge forwards **Host** and **X-Forwarded-Proto** (tests fail - if proto is omitted). `GET /api/v1/ws` WebSocket-upgrades to the same - indexer HTTP socket. Queue `POST {npub,email}` goes only to - `/run/splora/queue.sock` on `/splora/queue`, never to indexer units. + maps paths on one public Host, `splora.surmount.systems`, to HTTP/1.1 + Unix sockets on the **edge host**. See the path table above. 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. + `POST /splora/queue` stays the queue and is not a network prefix. GET / + is a short same-host path list, not an explorer. The Rust explorer UI + belongs to Splora, not this repo. Do not add block, transaction, or + address pages. Do not add esplora Hosts. The portal Host joins the :80 + allowlist and is the Splora Arti extra onion Host when the proxy is on. + The public sample stays proxy off. Private host-local enables the proxy. + Indexer sockets exist only when those units run. Do not map REST onto + the mail console Host. `/api` and `/api/...` are 404 while the mainnet + indexer is off. The Axum edge forwards + **Host** and **X-Forwarded-Proto** (tests fail if proto is omitted). + `/{network}/api/v1/ws` (mainnet: `/api/v1/ws`) WebSocket-upgrades to that + network's indexer HTTP socket. Other paths are not upgrades. Queue + `POST {npub,email}` goes only to `/run/splora/queue.sock` on + `/splora/queue`, never to indexer units. Queue is not REST. NIP-98 stays in splora (no edge API keys). The Electrum newline Unix socket is **not** proxied (fail-closed if configured). Not nginx; do not enable `modules/web.nix` for this path. @@ -488,7 +512,7 @@ speak HTTP/3. Splora does not listen on UDP. and `services.splora.enable` are both true, `users.users.surmount-ui.extraGroups` includes the `splora` group so the edge can connect to 0750 sockets. Flake input `splora` (`github:SurmountSystems/splora` on the `surmount` - branch, locked rev `9481e4cb87273aa99b0357be48503765beadb919`) supplies + branch, locked rev `be3603dbd8e6ef0c24e37fe07beaf8067bfa2d0b`) supplies `pkgs.splora`, `pkgs.splora-liquid`, and `nixosModules.splora`. Upstream crane omits `.cargo/config.toml` from Nix src (laptop cargo still uses Menhera). This overlay does not wrap that src again. Do @@ -522,7 +546,8 @@ speak HTTP/3. Splora does not listen on UDP. `networking.firewall.allowedUDPPorts` includes 443 when the UI HTTPS listener is on (not the cleartext https-escape). Keep the workspace `[patch.crates-io]` for `chacha20` if quinn pulls that crate (menhera - yank of 0.10.0/0.10.1). + yank of 0.10.0/0.10.1) and for `rustls` 0.23.45 until menhera 10d lists + that version (RUSTSEC-2026-0285; do not ignore; do not fetch crates.io). - **Ban layer (first path):** `crates/management-ui/src/ban.rs` decisions (Allow / Whitelisted / RateLimited / Banned / BanCandidate). Whitelist never banned; last-used touched on allowed requests from a whitelist match. @@ -598,26 +623,27 @@ Mandatory discovery headers on the custom Rust Axum edge public HTTPS is still **Let's Encrypt production**. This slice does not change issuance. -- Mapping loads **once at process start** from the existing onion surface - (`SURMOUNT_ONION_URL` / `SURMOUNT_ONION_HOSTNAME_FILE` / - `SURMOUNT_ONION_HS_STATE_DIR` plus `SURMOUNT_PRIMARY_DOMAIN`, - `SURMOUNT_SERVICES_HOSTNAME`, and extra static Hosts from - `SURMOUNT_STATIC_VHOSTS` / `_FILE`). Auto-derive apex, `www.{apex}`, - services, `mta-sts.{apex}`, and extra static Hosts to the **same** v3 - onion (port 443, `h2`, ma 86400). Mail and unmapped hosts emit nothing. - No hot-reload; restart the unit after hostname file, map file, static +- Mapping loads **once at process start** from the onion surface + (`SURMOUNT_ONION_URL` maps the **services** Host only), plus per-site + published addresses (`SURMOUNT_ONION_SITE_NICKNAMES_FILE` + + `SURMOUNT_ONION_PUBLISHED_HOSTNAMES_DIR`, optional + `SURMOUNT_ONION_SITES_DIR` / `SURMOUNT_ONION_MAP_FILE`), plus + `SURMOUNT_PRIMARY_DOMAIN`, `SURMOUNT_SERVICES_HOSTNAME`, extra static + Hosts, and extra mail Hosts to skip. Each public HTTP Host gets its + own v3 (port 443, `h2`, ma 86400). Mail and unmapped hosts emit + nothing. Sharing one v3 across Hosts is a correlation leak. No + hot-reload; restart the unit after hostname file, map file, static vhost map, or env change (same as PEMs). - Emit only when `listen_mode` is https, Host is a mapped clearnet name (not `.onion`), status is 2xx/3xx, and the request is not on the `:80` redirect router. Local Arti cleartext (`SURMOUNT_LOCAL_CLEARTEXT_LISTEN`) does **not** emit these headers. -- Onion-Location: services console `{scheme}://{onion_host}{path}{?query}` - (empty path -> `/`). Other mapped Hosts: - `{scheme}://{onion_host}/_o/{clearnet-host}{path}{?query}`. Onion-side - middleware treats `/_o/{mapped-host}` as that clearnet Host and strips - the prefix (Axum `Router::layer` runs after routing; extra vhosts and - MTA-STS policy are served from that rewritten Host, not a second onion - key). `http://{onion}/` stays the services console. +- Onion-Location: `{scheme}://{that-host-onion}{path}{?query}` (empty + path -> `/`). Onion-side middleware maps the request `.onion` Host to + that site's clearnet Host and keeps the path. There is no `/_o/{host}` + discriminator. Arti publishes one onion service nickname per public + HTTP Host (`/etc/surmount/arti.toml`, `/etc/surmount/onion-site-nicknames.json`). + Console keeps `artiHiddenService.nickname`. - Alt-Svc: `h2="{onion_host}:{port}"; ma={ma}; persist=1`. Clearnet HTTP/3 is a **separate** token (`h3=":443"`) added only when the QUIC listener is bound. Onion `h2` is not replaced by `h3`. @@ -631,21 +657,24 @@ change issuance. (admin-gated when Nostr is on). Not on `/health`. - **Live (2026-08-17):** headers proven with HTTPS curl on 307/200 for `surmount.systems`, `www.surmount.systems`, and - `services.surmount.systems` (same onion). Unit - `surmount-arti-hidden-service` active. Tor Browser purple pill - **BLOCKED** (header presence is not an Alt-Svc upgrade proof). -- **Every public HTTP Host (2026-08-20):** extra static vhosts and - `mta-sts.{primary}` auto-map the same dual headers. `/vault` on the - services Host emits path-preserving Onion-Location when the Vaultwarden - proxy is on (VW login is SoT on that prefix; the edge does not - Nostr-gate `/vault` once proxy enable is true). Six extra static zones - (yiffa.app, baxterartworks.com, btcfur.com, iantuckerstudios.com, - nostrfurs.com, exophiles.org) are on the live Let's Encrypt production - leaf (apex+www). cryptoquick Hosts on this :443 map may exist; they are - first-class static vhosts like those six. Live leaf still omits - cryptoquick apex/www (**18** names). Intended leaf is **20** names on - one PEM. The SERVFAIL A-lookup gate is closed. Mail stays - unmapped. Tor Browser purple pill still not claimed. + `services.surmount.systems`. Unit `surmount-arti-hidden-service` + active. That live generation still used one shared v3. This tree + (2026-09-11) is per-site onions after the next host switch. Tor + Browser purple pill **BLOCKED** (header presence is not an Alt-Svc + upgrade proof). +- **Every public HTTP Host (2026-08-20; per-site 2026-09-11):** extra + static vhosts and `mta-sts.{primary}` emit dual headers at each Host's + own onion root. `/vault` on the services Host emits path-preserving + Onion-Location when the Vaultwarden proxy is on (VW login is SoT on + that prefix; the edge does not Nostr-gate `/vault` once proxy enable is + true). Six extra static zones (yiffa.app, baxterartworks.com, + btcfur.com, iantuckerstudios.com, nostrfurs.com, exophiles.org) are on + the live Let's Encrypt certificate this host presents (apex+www). + cryptoquick Hosts on this :443 map may exist; they are first-class + static vhosts like those six. Live certificate still omits cryptoquick + apex/www (**18** names). Intended certificate is **20** names on one + PEM. The SERVFAIL A-lookup gate is closed. Mail stays unmapped. Tor + Browser purple pill still not claimed. Header comment in `modules/web.nix` must keep pointing here. diff --git a/docs/OPS.md b/docs/OPS.md index 2dba346..857bc19 100644 --- a/docs/OPS.md +++ b/docs/OPS.md @@ -4,7 +4,18 @@ 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-09-03 (public NIP-07 login over HTTP/3 must not 500 +**Last updated:** 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)*). +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. See [TEST-AND-DEPLOY.md](TEST-AND-DEPLOY.md)). Prior +2026-09-20 (full test-and-deploy procedure: +[TEST-AND-DEPLOY.md](TEST-AND-DEPLOY.md). Agent runs named eval, +`just check-remote`, and `just deploy-host -- --dry-run`. Operator +runs the real switch with Eternal Terminal open). Prior 2026-09-03 (public NIP-07 login over HTTP/3 must not 500 missing `ConnectInfo`; NWC is the `/mail` wallet store). Prior 2026-09-02 (guest grok-oss uses a machine xAI console API key on surmount-1, path only under `/home/grok/.grok`; guest has no git and no GitHub SSH; signed commits stay on the laptop). Prior same @@ -781,8 +792,12 @@ reliability in [deploy-host-local.md](deploy-host-local.md) section 5 `just btop` (`nix run .#surmount-btop-host`) uses Eternal Terminal to the same target as `just deploy-host` and `just et`, then runs interactive `btop` -(`et -c btop`). It does not use a raw SSH that can freeze-paint a last frame. -After btop exits, the Eternal Terminal session exits (not `et --noexit`). +(`et -e -c btop`). It does not use a raw SSH that can freeze-paint a last frame. +After btop exits, including q or Ctrl-C, the Eternal Terminal session stays +up and drops to a shell on the mail host (`-e` / `--noexit` with `-c`). +That shell is the emergency terminal. You do not need a second `just et` +tunnel. Do not start `nixos-rebuild` or `just deploy` from that shell. +The laptop still starts the switch. `just et` remains a shell-only client. Needs `etserver` on the guest (after `just deploy-host`). Target comes from `--target`, `SURMOUNT_DEPLOY_TARGET`, or `~/.local/share/surmount/agent-target.env`. Fails loud if those are missing, @@ -942,7 +957,10 @@ 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. Do not advertise a fake high slot count. Speed factor 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 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 @@ -1669,8 +1687,8 @@ surmount-management-ui -g tls_handshake_failed`. | HS identity dir | `surmount.artiHiddenService.onionServiceStateDir` default `/run/surmount-secrets/arti/onion-service` (**ephemeral**; prefer durable Domain B). Live host uses `/var/lib/surmount/secrets/arti/onion-service`. | | Ownership | **Must** be owned/writable by `surmount-arti:surmount-arti` (e.g. mode **0750**). Module does **not** auto-create this dir (`ConditionPathIsDirectory` gates the daemon; missing dir => inactive, not a restart loop). Root-owned 0700 can pass the path check then fail at keystore open. Keystore stays **0700**. Live: `surmount-ui` is in group `surmount-arti` so the UI can read the hostname file (0750 dir). | | Process cache | `/var/lib/surmount/arti` (+ `cache/`) via tmpfiles 0750 surmount-arti. Live host-local sets unit `HOME=/var/lib/surmount/arti` so Arti can write `port_info.json` (public module does not set HOME). | -| Hostname file | Arti 2.5.1 does **not** write `hostname`. Live host wrote it from `arti hss --nickname surmount-management onion-address`. Address is not a secret; HS keys are. Same v3 for apex, www, services. | -| Discovery headers | Onion-Location + Alt-Svc on mapped HTTPS 2xx/3xx. Optional env: `SURMOUNT_ONION_LOCATION_ENABLED`, `SURMOUNT_ONION_ALT_SVC_ENABLED`, `SURMOUNT_ONION_LOCATION_DISABLED_HOSTS`, `SURMOUNT_ONION_ALT_SVC_DISABLED_HOSTS`, `SURMOUNT_ONION_MAP_FILE`. Restart `surmount-management-ui` after hostname/env/map change. Dump: `GET /api/v1/system` `onion_discovery` (admin-gated when Nostr on). | +| Hostname files | Arti 2.5.1 does **not** write `hostname`. Console: `arti hss --nickname` `artiHiddenService.nickname` `onion-address`. Per-site: ExecStartPost writes public addresses under `stateDir/published-hostnames/{nickname}`. Addresses are not secrets; HS keys are. One v3 per public HTTP Host. | +| Discovery headers | Onion-Location + Alt-Svc on mapped HTTPS 2xx/3xx. Each mapped Host uses `http://{that-host-onion}{path}`. Optional env: `SURMOUNT_ONION_LOCATION_ENABLED`, `SURMOUNT_ONION_ALT_SVC_ENABLED`, `SURMOUNT_ONION_LOCATION_DISABLED_HOSTS`, `SURMOUNT_ONION_ALT_SVC_DISABLED_HOSTS`, `SURMOUNT_ONION_MAP_FILE`, `SURMOUNT_ONION_SITE_NICKNAMES_FILE`, `SURMOUNT_ONION_PUBLISHED_HOSTNAMES_DIR`. Restart `surmount-management-ui` after hostname/env/map change. Dump: `GET /api/v1/system` `onion_discovery` (admin-gated when Nostr on). | | Secrets | HS private keys **never in git**. Identity may be generated on first start in an empty writable dir; operator **offline backup** is still residual. | | Honesty | `systemctl is-active surmount-arti-hidden-service` does **not** prove an onion is published on the Tor network. Live (2026-08-17): unit **active**; hostname file present (v3 onion; do not paste the address in this public tree); headers proven on HTTPS 307/200. Tor Browser purple pill **BLOCKED**. Do **not** claim B3 fully closed. | @@ -1819,8 +1837,9 @@ 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 tracks -`github:SurmountSystems/grok-oss/remote-1` (open PR 51 is the product -tip; default branch main is not). Operator bumps: +`github:SurmountSystems/grok-oss/main`. 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. @@ -1840,8 +1859,10 @@ exceptions*. |---------|---------| | `nix run .#e2e` / `just e2e` | Local comprehensive end-to-end (Rust flake app; hermetic cargo matrix; optional Tor). SoT | | `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 deploy` | Publish static sites (apex/www from locked `github:SurmountSystems/site` via `nix build .#surmount-public-site`, plus extra vhosts). Not a NixOS generation. | -| `nix run .#surmount-deploy-host` / `just deploy-host` | Operator deploy driver: public rsync + host-local checks + `#mail-vps` rebuild (not CI). That is the NixOS generation. [deploy-host-local.md](deploy-host-local.md) | +| `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`. | +| `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) | | `just stalwart-recovery-unlock` / `nix run .#stalwart-recovery-unlock` | Generate/install `STALWART_RECOVERY_ADMIN` EnvironmentFile + private systemd drop-in + Basic auth probe. Kind `stalwart-recovery-admin`. [SECRETS.md](SECRETS.md) | diff --git a/docs/SEARCH_AND_UI.md b/docs/SEARCH_AND_UI.md index a4fd1d3..62a9857 100644 --- a/docs/SEARCH_AND_UI.md +++ b/docs/SEARCH_AND_UI.md @@ -16,8 +16,8 @@ missing Axum `ConnectInfo`; NWC is not that login). Prior 2026-08-25 (living mai `~/.agents/surmount-server/operator-facts.md`, not this public file.) Prior 2026-08-24 (`just deploy` publishes static sites; `just deploy-host` is the NixOS generation.) Prior 2026-08-21 (living mailbox map: `~/.agents/surmount-server/operator-facts.md`. Do not assume Thunderbird. -Prior 2026-08-20: Onion-Location + Alt-Svc on every -public Host; `/_o/{host}` onion routing) +Prior 2026-09-11: each public HTTP Host has its own v3 onion. +Prior 2026-08-20: Onion-Location + Alt-Svc on every public Host) ## Principles @@ -116,8 +116,9 @@ is a **bootstrap fallback**, not the long-term Surmount UX. Legacy public sites: **static files only** at the edge. Not part of the Leptos app. Live apex/www serve the packaged `SurmountSystems/site` tree (`pkgs.surmount-public-site`; Host -`surmount.systems` / `www.surmount.systems`). `just deploy` publishes -static sites; `just deploy-host` is the NixOS generation. +`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 leftover is closed. @@ -143,12 +144,11 @@ leftover is closed. `onionServiceStateDir` / derived `SURMOUNT_ONION_HOSTNAME_FILE`); structured `configured` / `hostname_missing` / `not_provisioned` on system + `GET /api/v1/system`. Lab override `SURMOUNT_ONION_URL` only; never invent. - **Discovery headers (2026-08-17, every public Host 2026-08-20):** - Onion-Location + Alt-Svc on mapped HTTPS 2xx/3xx. Auto-map: apex, www, - services, `mta-sts.{apex}`, extra static Hosts; same v3. Services - Onion-Location is `{onion}{path}`. Other mapped Hosts use - `{onion}/_o/{clearnet-host}{path}` so purple-pill lands on that surface. - `http://{onion}/` is the services console. Mail unmapped. Mapping loaded + **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. + 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`, `SURMOUNT_ONION_ALT_SVC_ENABLED`, `SURMOUNT_ONION_LOCATION_DISABLED_HOSTS`, diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 0ba7a75..1b607d1 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -49,19 +49,81 @@ not prove the UI binary links ring SHA-1. Git object IDs and Nixpkgs C libraries are out of this lockfile. Full method and leftovers: [research/hash-primitives-lockfile-2026-08-26.md](research/hash-primitives-lockfile-2026-08-26.md). -**Automated:** `just audit` is hermetic `cargo-audit` against flake -input `advisory-db` (RustSec; `--no-fetch --stale`). `just deny` is +**Automated:** `just audit` is hermetic `cargo-audit audit --no-fetch --stale --deny warnings` against flake +input `advisory-db` (RustSec). A warning fails that check. `just deny` is `cargo-deny` bans (no sha1/md5 crates) and **is** in `checks.*.ci`. `just audit` is in `checks.*.ci`. `h2` is 0.4.16 (RUSTSEC-2026-0258 -cleared). Workspace `age` is 0.12 (drops the rekey path of -RUSTSEC-2026-0173). Three unmaintained warnings remain and are **not** -cargo-audit-ignored so `just audit` still prints them: instant via -nostr 0.44.8 (RUSTSEC-2024-0384), paste via leptos 0.8.20 -(RUSTSEC-2024-0436), proc-macro-error2 via leptos_macro / rstml -(RUSTSEC-2026-0173). See +cleared). Workspace rustls is **0.23.45** (RUSTSEC-2026-0285 TLS 1.3 +handshake messages across encryption-level boundaries). menhera 10d/7d +still stop at 0.23.44; 3d already lists 0.23.45. The lock uses git +`[patch.crates-io]` of rustls tag `v/0.23.45` (rev +`2976d90fd1c2db6b518700dd101b714069cfcb17`), same class as the chacha20 +git patch, until 10d lists 0.23.45. Do not ignore that advisory. Do not +fetch crates.io to skip menhera. Workspace `age` is 0.12. That bump +pulls i18n-embed-fl 0.10.1, which uses proc-macro-error3. +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-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, +with AVX2, loads 32 bytes from `src` and from `src[32..]` without +requiring `src` to hold 64 bytes. menhera-cooldown 10d does not list +0.10.1 yet (published 2026-09-23), so the lock uses that public git tag +instead of the registry crate. Do not ignore RUSTSEC-2026-0306. +See [RUSTSEC-2026-0306](https://rustsec.org/advisories/RUSTSEC-2026-0306) +(accessed: 2026-09-26). + +On 2026-09-26 the operator ignored two RustSec records, and only those +two. Both are type INFO, informational "unmaintained". They are not +CVEs. The OSV records have cvss null and no CVE alias. `paste` +(RUSTSEC-2024-0436) pastes identifiers inside a compile-time macro. The +dtolnay/paste repo was archived on 2024-10-06. The advisory text is only +that the project is unmaintained. It does not describe a vulnerability. +https://rustsec.org/advisories/RUSTSEC-2024-0436 +`proc-macro-error2` (RUSTSEC-2026-0173) prints compiler errors when a +procedural macro fails. The author confirmed it is unmaintained. The +advisory does not describe an exploitable bug. +https://rustsec.org/advisories/RUSTSEC-2026-0173 +Both entered this lock only through Leptos. The management UI is the +only product crate that depends on Leptos. Its source does not call +paste or proc-macro-error. The crates run inside rustc while the UI is +compiled. They are not in the mail server process and not in the running +management UI process. A lockfile pin means a later crates.io upload +does not enter the build until someone updates the lock. Do not vendor +Leptos to clear these two ids. Do not ignore any other advisory. +`checks.*.cargo-audit` passes `--ignore RUSTSEC-2024-0436` and +`--ignore RUSTSEC-2026-0173`, and it still passes `--deny warnings`. +proc-macro-error3 stays because i18n-embed-fl 0.10.1 uses it. See +`.cargo/audit.toml` and [research/cargo-audit-2026-08-26.md](research/cargo-audit-2026-08-26.md). Bump the DB with `nix flake update advisory-db`. +On 2026-10-03 `just audit-remote` ran `checks.x86_64-linux.cargo-audit` against advisory-db rev `ef6173cbc5c50ec8166f9a5b28f07834144373ee` (locked the same day). cargo-audit 0.22.1 loaded 1290 advisories and scanned 481 crate dependencies in the existing repo-root `Cargo.lock`. The command exited 0. It printed no new RustSec id and no CVE name. The advisory files for the two ignored records still have no `aliases` field and no CVE name: RUSTSEC-2024-0436 (`paste`, informational unmaintained) and RUSTSEC-2026-0173 (`proc-macro-error2`, informational unmaintained). `--deny warnings` stayed on. No third ignore was added. This scan did not refresh `Cargo.lock`. The worktree lock later had 479 package name lines. The cargo-audit constituent inside `just check-remote` scanned those 479 dependencies and that derivation completed with no new RustSec id. The CI aggregate did not finish, because the builder SSH connection failed. A later `cargo update` needs the same check again. +See [RUSTSEC-2024-0436](https://rustsec.org/advisories/RUSTSEC-2024-0436) +and [RUSTSEC-2026-0173](https://rustsec.org/advisories/RUSTSEC-2026-0173) +(accessed: 2026-10-03). + +On 2026-10-09, for the lock-and-suite wave named 2026-10-05, `just audit-remote` ran `checks.x86_64-linux.cargo-audit` against the same advisory-db rev `ef6173cbc5c50ec8166f9a5b28f07834144373ee`. The derivation was already in the local store, so the Nix build was a cache hit and exited 0. `nix log` for that output was empty. The same cargo-audit 0.22.1 command line (including `--deny warnings` and the two ignores) was run from those store inputs so the text could be recorded. It loaded 1290 advisories and scanned 479 crate dependencies in the current repo-root `Cargo.lock`. Exit 0. It printed no RustSec id and no CVE name. The same command without those two ignores also exited 0 and printed no RustSec id and no CVE name. The locked advisory files still have no `aliases` field and no CVE name: RUSTSEC-2024-0436 (`paste`, informational unmaintained) and RUSTSEC-2026-0173 (`proc-macro-error2`, informational unmaintained). Both crate names are in this lock. No third ignore was added. This scan did not refresh `Cargo.lock`. The builder account has no cargo config and no menhera index. Menhera is only in the laptop cargo config, so this wave did not run `cargo update`. +See [RUSTSEC-2024-0436](https://rustsec.org/advisories/RUSTSEC-2024-0436) +and [RUSTSEC-2026-0173](https://rustsec.org/advisories/RUSTSEC-2026-0173). +The text checked on 2026-10-09 is the locked advisory-db copy at that rev, not a new fetch of those pages. + +### Suite, 2026-10-09 + +Lock-and-suite wave named 2026-10-05. These commands ran on 2026-10-09. `cargo update` was not run here, because menhera is only in the laptop cargo config. The builder account has no cargo config. + +- `just audit-remote` exited 0. Result is the paragraph above. No new RustSec id and no CVE name. +- `just check-remote` exited 0. No ssh-ng drop. The log had 209 `test result: ok` lines and no `test result: FAILED`. +- `just check-heavy` exited 0. The mail VM test script finished in 98.69 seconds. Forcing `max-jobs = 0` is not part of that recipe. One earlier attempt that did force it exited 1, because the VM run needs `kvm` and `nixos-test` and the remote builder does not advertise those features. That was not an ssh drop, and it was not a second loop of a failed recipe run. +- `just deploy-dry-run` exited 0. It printed the planned copy and switch and did not connect. `just deploy` was not run. `--install-secrets` was not passed. + +On 2026-10-10, after `cargo update` (cargo 1.99.0, menhera index, 0 packages moved), `just audit-remote` ran `checks.x86_64-linux.cargo-audit`. It exited 0. The Nix build log loaded 1290 advisories and scanned 481 crate dependencies in this repo-root `Cargo.lock`. It printed no RustSec id and no CVE name. The builder log also printed `warning: couldn't open crates.io index` (os error 2) under `--no-fetch`. That is not an advisory. Replaying that same `cargo-audit` 0.22.1 command from the derivation's store inputs also exited 0, printed the same 1290 and 481 counts, and printed no RustSec id. `--deny warnings` stayed on. Ignores stayed RUSTSEC-2024-0436 (`paste`) and RUSTSEC-2026-0173 (`proc-macro-error2`). No third ignore was added. The lock hash scanned is `sha256-YvPc+++PxY6cDk8ocpI6pVe5CJpTm91I969M5Tu5Fg8=`, the same as the repo `Cargo.lock`. Ceiling crates stayed reqwest 0.12.28, tower-http 0.6.11, rustls 0.23.45, and nostr 0.45.5. +See [RUSTSEC-2024-0436](https://rustsec.org/advisories/RUSTSEC-2024-0436) +and [RUSTSEC-2026-0173](https://rustsec.org/advisories/RUSTSEC-2026-0173) +(accessed: 2026-10-10). + ## Non-goals (this doc) - Live Vaultwarden unit / first admin user (S7b host residual after token; @@ -165,8 +227,9 @@ See [EDGE_AND_TLS.md](EDGE_AND_TLS.md) and open-choices. Arti HS alongside clearnet; HS keys never in git; not a clearnet edge replacement. Clearnet HTTPS advertises the onion with **Onion-Location** and **Alt-Svc** (apex, www, services, extra static Hosts, MTA-STS; - same v3; `/_o/{host}` on non-console Onion-Location; process-start load; - restart after hostname/env/map/static-vhost change). Live unit + one v3 per public HTTP Host; Onion-Location is that Host's onion root + plus path; process-start load; restart after hostname/env/map/static-vhost + change). Live unit `surmount-arti-hidden-service` is **active** (2026-08-17); Tor Browser verify and operator HS backup remain residual. Do **not** claim B3 fully closed. [research/arti-and-secrets-manager.md](research/arti-and-secrets-manager.md), @@ -234,25 +297,28 @@ 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.15) +### Mailbox password hashing (Stalwart 0.16.24) 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.15 (this repo pin in `nix/packages/stalwart-mail.nix`) uses -`Authentication.passwordHashAlgorithm`. The enum and singleton default are -**Argon2id**. Source (tag `v0.16.15`): +Stalwart 0.16.24 (this repo pin in `nix/packages/stalwart-mail.nix`) uses +`Authentication.passwordHashAlgorithm`. The enum default is **Argon2id**. +Source (tag `v0.16.24`, re-checked 2026-10-03): - `crates/registry/src/schema/enums.rs`: `PasswordHashAlgorithm` `#[default] Argon2id` -- `crates/registry/src/schema/structs_impl.rs`: `impl Default for - Authentication` sets `password_hash_algorithm: - PasswordHashAlgorithm::Argon2id` +- `crates/registry/src/schema/structs.rs`: `Authentication` field + `password_hash_algorithm: PasswordHashAlgorithm` - `crates/directory/src/core/secret.rs`: `hash_secret` for `Argon2id` calls `Argon2::default().hash_password` +Tag `v0.16.24` `structs_impl.rs` does not mention `password_hash_algorithm`. +The 0.16.15 citation of an explicit `impl Default for Authentication` in +that file is not in this tag. + Public docs (same default; accessed: 2026-08-14): [Passwords](https://stalw.art/docs/auth/authentication/password/) and [Authentication.passwordHashAlgorithm](https://stalw.art/docs/ref/object/authentication/#passwordhashalgorithm) @@ -458,9 +524,11 @@ surfaces must raise the hook) remains open. NIP-07 inline script; `style-src 'self' 'unsafe-inline'` for DOGE CSS; `frame-ancestors 'none'`), `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `X-Frame-Options: DENY`. - **Onion-Location + Alt-Svc (2026-08-17, every public Host 2026-08-20):** + **Onion-Location + Alt-Svc (2026-08-17, every public Host 2026-08-20; + per-site v3 2026-09-11):** emitted on mapped HTTPS 2xx/3xx (apex, www, services, extra static - Hosts, MTA-STS; same v3; `/_o/{host}` discriminator for non-console). + Hosts, MTA-STS; one v3 per Host; Onion-Location is + `http://{that-host-onion}{path}`). Not on `.onion` Host, not on the `:80` redirect router, not on the local Arti cleartext bind. Mail unmapped. 4xx/5xx emit nothing. Mapping is loaded at process start (no hot-reload). Dump on diff --git a/docs/STACK.md b/docs/STACK.md index b2b54a0..72112ec 100644 --- a/docs/STACK.md +++ b/docs/STACK.md @@ -5,7 +5,7 @@ survive compaction live here, in [operator-direction.md](operator-direction.md), and in [open-choices.md](open-choices.md). Implementation detail stays in Nix modules and Rust crates. -**Last updated:** 2026-08-17 (Onion-Location + Alt-Svc on Axum edge; live Arti unit) +**Last updated:** 2026-09-11 (per-site v3 onions; Onion-Location is each Host's onion root plus path). Prior 2026-08-17 (Onion-Location + Alt-Svc on Axum edge; live Arti unit) Operator direction 2026-07-30 is working product direction (dated, not eternal law). Scaffold defaults still describe what code does today when they differ. @@ -257,8 +257,9 @@ See [EDGE_AND_TLS.md](EDGE_AND_TLS.md), [hygiene.md](hygiene.md), - **Arti hidden services (REQUIRED):** onion HS reachability for Surmount services; first-class next to clearnet; not a clearnet edge replacement. Clearnet HTTPS advertises the onion with **Onion-Location** and **Alt-Svc** - (apex, www, services, extra static Hosts, MTA-STS policy Host; same v3; - `/_o/{host}` for non-console surfaces). Live unit + (apex, www, services, extra static Hosts, MTA-STS policy Host; one v3 + per public HTTP Host; Onion-Location is that Host's onion root plus + path). Live unit `surmount-arti-hidden-service` is active; Tor Browser verify remains residual. [research/arti-and-secrets-manager.md](research/arti-and-secrets-manager.md), [COMPACTION-PIN.md](COMPACTION-PIN.md) section 7, diff --git a/docs/TEST-AND-DEPLOY.md b/docs/TEST-AND-DEPLOY.md new file mode 100644 index 0000000..6c7695b --- /dev/null +++ b/docs/TEST-AND-DEPLOY.md @@ -0,0 +1,593 @@ +# Test and deploy procedure (mail host) + +This is the full procedure for this repository. Do not treat a chat +summary as a substitute. Commands below are complete. The **agent** +runs every command in sections 2, 3, and 4 from the laptop clone, +including the deploy dry-run. The **operator** does not run eval, +`just check-remote`, or the dry-run in order to discover failures. +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 +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. + +Companions: [OPS.md](OPS.md), [deploy-host-local.md](deploy-host-local.md), +[SECRETS.md](SECRETS.md), [hygiene.md](hygiene.md), [../AGENTS.md](../AGENTS.md). + +--- + +## Update, audit, test, and switch + +From the repo root, in this order. One Nix client at a time. There is +no `just` recipe for the first two lines. + +```bash +nix flake update +cargo update +just audit-remote +just check-remote +just check-heavy +just deploy-dry-run +just btop +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. + +`cargo update` uses the menhera index already configured on the laptop. +`just audit` and `just audit-remote` run the same RustSec check. +`just audit-remote` forces the mail-host builder. Run one of them as +the next command after `cargo update`. Do not update and skip it. Do +not audit a lock that was not just updated, unless a significant +amount of time has passed. A few days is not that gap. The check sets `[yanked] enabled = false` because the Nix sandbox +has no crates.io index. Yanked crates are refused by the menhera index +during `cargo update`. +Nix does not warn that the git tree is dirty on these builds. The lock +files are supposed to be uncommitted at this step. + +`just check-remote` is the test gate. Fmt runs before clippy. Cargo +fmt and nixfmt both finish before any clippy derivation starts. Then +crate tests, cargo-deny, cargo-audit, and the module, deploy-secrets, +Arti, and Splora contracts. `just check-heavy` is the mail VM test. It +is not inside `just check-remote`. + +Open `just btop` and leave it up. Run `just deploy` from another +terminal. Do not start the switch from the btop shell. `just publish` +is only the static sites. It is not this sequence. + +--- + +## Why the operator still types the real switch + +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 +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 +`just deploy-host -- --dry-run` with the target and host-local flags. +`just deploy-host` has no default target. + +The operator still runs the **real** `just deploy` (no `--dry-run`) +because: + +1. Agents are forbidden from the real switch unless the operator + overrides that standing rule in writing. Dry-run is required agent + work. The switch is not. +2. `nixos-rebuild switch` is the live generation: it stops and starts + `surmount-management-ui`, `surmount-scram`, and related units. A + failed or hung switch is a live mail-host event, not a CI log. +3. Activation can drop the SSH session that started the switch + (`reloading user units for root`). The operator already sits in + Eternal Terminal for that. The agent does not own that TTY. +4. The switch applies private host-local (hardware, keys, Splora + instance enable, MTA-STS mode). The agent must not treat that as a + laptop cargo proof. + +Secrets custody (cookie files, Namecheap if the agent cannot see env +mode 0600, GPG sign) stays operator-owned for the same prove-the-gate +reason. Eval, remote CI, and dry-run are not secrets custody. + +--- + +## 0. Who does what + +| Who | Does | Does not | +|-----|------|----------| +| Agent | **All of sections 2, 3, and 4**, including every named `nix eval` and `just check-remote` and `just deploy-dry-run`. Report pass or fail with the store path or the named error. GitHub issues on plan Approve and on bug reports (screenshots attached). GitHub pull request after the operator has signed and pushed a git-flow feature branch. Laptop Let's Encrypt `--issue` when this laptop already has the Namecheap env files (do not print the env). | Laptop `cargo`, `rustc`, `BUILD_LOCAL=true`. Real `just deploy` switch unless the operator overrides (`just deploy-host` without `--dry-run` is the same switch). `git commit`, GPG sign, `git push`. Reboot. Invent secrets, cookie bytes, or JSON-RPC addresses. Asking the operator to run eval or `check-remote` so the operator can see the error. | +| Operator | Eternal Terminal window before a switch. Real `just deploy` (no `--dry-run`) after the agent reports dry-run green. GPG-signed commit and `git push` on a git-flow `feature/` or `bugfix/` branch. Place cookie files and other Domain B secrets the agent cannot create. | Run section 2, 3, or 4 in order to discover CI or eval failures. Leave the only SSH session as the one that started `nixos-rebuild switch`. | + +Laptop = this session host. It is not the remote builder. + +surmount-1 = mail guest and the allowed remote Nix builder (ssh-ng as +`nixbuilder`). Mail and builder share that guest. Do not nice mail. +Do not publish guest RAM, disk, SKU, or IP in git or chat. + +Private overlay on the laptop (never git): + +```text +/home/hunter/.local/share/surmount/host-local +``` + +After a successful switch, the guest copy is `/root/surmount-server/host-local/`. + +--- + +## 1. Open Eternal Terminal first (operator, before the real switch only) + +The agent does not need Eternal Terminal to run eval, `just check-remote`, +or the dry-run. Those use the laptop Nix client and ssh-ng as +`nixbuilder`, or a non-interactive deploy dry-run. + +Activation can drop the SSH that started the **real** switch. Before +section 5, not before section 2, open one laptop window from the home +justfile (`~/justfile` wrapping this clone) or from this clone: + +```bash +just btop +``` + +That window is enough. It opens remote btop on Eternal Terminal. When +btop exits, including Ctrl-C in btop or quit with q, that same +connection stays up and drops to a shell on the mail host. That shell +is the emergency terminal. You do not need a second idle `just et` +tunnel. + +Do not start `nixos-rebuild` or `just deploy` from that shell. The +laptop still starts the switch. `just et` can stay as a shell-only +client when you want a shell and no btop. It is not a second tunnel +you must keep open. + +On the guest, for a switch that must survive a dead laptop tunnel: + +```bash +tmux new -s switch +``` + +Run the later switch command inside that tmux session only if you are +already on the guest. + +Confirm the builder answers before quality work: + +```bash +ssh -o BatchMode=yes -o ConnectTimeout=8 nixbuilder@surmount-1 'echo nixbuilder_ok; hostname' +``` + +Expected: `nixbuilder_ok` and hostname `surmount-1`. + +Do not start a second `just check-remote` while one is live. Do not start +Lake from an agent. + +--- + +## 2. Named flake eval (agent; no rustc on this laptop) + +These are Nix eval contracts. They do not compile the mail UI with +laptop rustc. The **agent** runs all four from the repo root and +reports the store path or the error. The operator does not run them to +find out they failed. + +Use this laptop's flake system (`x86_64-linux` on this machine). If +`nix eval --impure --raw --expr 'builtins.currentSystem'` prints +something else, the agent substitutes that string for `x86_64-linux` in +every command in this section. + +### 2.1 Module and host-contract eval + +```bash +nix eval --raw '.#checks.x86_64-linux.module-eval-contract' +``` + +Success prints a store path whose name ends in `-module-eval-contract`. +Failure is a Nix assert or a `writeText` error. Fix the named contract +in `tests/module-eval.nix`. Do not skip it. + +### 2.2 Deploy-secrets path charset + +```bash +nix eval --raw '.#checks.x86_64-linux.deploy-secrets-contract' +``` + +Success prints a store path ending in `-deploy-secrets-contract`. + +### 2.3 Arti module path shape + +```bash +nix eval --raw '.#checks.x86_64-linux.arti-module-contract' +``` + +Success prints a store path ending in `-arti-module-contract`. + +### 2.4 Splora package pass-through (no RocksDB build) + +```bash +nix eval --raw '.#checks.x86_64-linux.splora-package-contract' +``` + +Success prints a store path ending in `-splora-package-contract`. +This does not build `pkgs.splora` or `splora-deps`. It only checks the +flake-input package attrs and Menhera cargo config on the input tree. + +Run 2.4 every time `flake.lock` input `splora` moved. Run 2.1 through +2.3 every time modules, management-ui Nix env, or tests/module-eval.nix +changed. When in doubt, run all four. + +Do not use `BUILD_LOCAL=true` on these commands. + +--- + +## 3. Remote quality bar (`just check-remote`) (agent) + +This is the CI aggregate this repo actually gates on. It is not grok-oss +`just test-remote`. This repo has no `just test-remote` recipe. The +**agent** runs it. A clippy or nixfmt failure is an agent fix, not an +operator homework list. + +From the repo root, one live run at a time: + +```bash +just check-remote +``` + +What that does: + +- Refuses `BUILD_LOCAL` (exit 2). That would rustc on the laptop. +- Prints that it is building `.#checks..ci` with `--option max-jobs 0`. +- Flake eval can sit for several minutes with no rustc. That is expected. +- Then Nix builds on nixbuilder (ssh-ng). `-L` streams remote logs. +- On success it prints `just check-remote: ok` and a store path for + `surmount-ci`. + +The aggregate `checks..ci` constituents are: + +- `management-ui-fmt` and `nix-fmt-check` first +- `management-ui-clippy`, `e2e-clippy`, and each landed wave1 `*-clippy` (`-D warnings`) only after those two pass +- `packages.management-ui` (the UI binary) +- `management-ui-test` +- `e2e-pure-test` +- `module-eval-contract` +- `deploy-secrets-contract` +- `arti-module-contract` +- `arti-onion-package-contract` +- `public-site-package-contract` +- `splora-package-contract` +- `cargo-deny-bans` +- `cargo-audit` +- each landed wave1 package and that crate's test + +Not in `ci` (do not treat these as the quality bar): + +- `just e2e-host` (needs `SURMOUNT_E2E_HOST=1` and `SURMOUNT_E2E_BASE_URL`) +- mail-vm / Stalwart FOD / `#mail-vps` toplevel +- starting Splora indexers +- the real `nixos-rebuild switch` + +If `just check-remote` fails, the log names a derivation (`nixfmt +failed: PATH`, clippy file:line, a failed test). Fix that contract. Do +not `#[allow]` to shrink the list. Do not fit tests to code. Re-run +`just check-remote` until it prints `ok` and a `surmount-ci` store path. + +Optional same remote path for audit only: + +```bash +just audit-remote +``` + +Laptop `just check` / `just ci` without `check-remote` would rustc here. +Do not use those as the mail-host quality bar. + +--- + +## 4. Deploy dry-run (agent; required before any real switch) + +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. + +From the repo root: + +```bash +just deploy-dry-run +``` + +That places `--dry-run` before `--target root@surmount-1` and +`--host-local /home/hunter/.local/share/surmount/host-local`. Extra +arguments append after those flags. Do not pass `--install-secrets` +unless that deploy should copy secrets. + +Explicit equivalent (`just deploy-host` has no default target): + +```bash +just deploy-host -- --dry-run --target root@surmount-1 --host-local /home/hunter/.local/share/surmount/host-local +``` + +Equivalent without just: + +```bash +nix run .#surmount-deploy-host -- --dry-run --target root@surmount-1 --host-local /home/hunter/.local/share/surmount/host-local +``` + +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): + +- rsync of the public tree to `root@surmount-1:/root/surmount-server/` + (excludes `.git/`, `host-local/`, 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. + +Host-local never comes from public git. The flake on the guest is +`path:` so untracked host-local is visible to Nix. + +Opt-in secrets install before rebuild (default off): + +```bash +just deploy-host -- --dry-run --target root@surmount-1 \ + --host-local /home/hunter/.local/share/surmount/host-local \ + --install-secrets --secrets-staging /path/to/private-staging \ + --secrets-host-id YOUR_LOGICAL_HOST \ + --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). + +--- + +## 5. Real switch (operator only) + +This is the first command in this procedure that the operator must type +for the generation to go live. Eternal Terminal from section 1 must +already be open. The agent has already reported section 2, 3, and 4 +green, including the exact dry-run command and exit 0. + +```bash +just deploy +``` + +Explicit equivalent (`just deploy-host` has no default target): + +```bash +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. + +After rebuild, the driver always runs +`surmount-deploy-host-post-switch-smoke` on the guest, even if rebuild +exit is non-zero. + +Smoke must pass all of: + +- current NixOS generation store path +- `sshd` active +- `stalwart-mail` active +- `surmount-management-ui` active (smoke may start it if inactive) +- loopback `https://127.0.0.1:443/health` +- TLS cert and key paths exist; key mode 600 +- listen proof for :443 and :80 (`ss`) + +Re-run smoke only: + +```bash +ssh -- root@surmount-1 surmount-deploy-host-post-switch-smoke +``` + +If the switch SSH dies during `reloading user units for root`, use the +Eternal Terminal window. Then: + +```bash +just status +just logs +just host-logs -- --status +``` + +Do not reboot. Agents never reboot. + +--- + +## 6. After the generation is live + +### 6.1 Units and logs + +```bash +just status +just logs +just host-logs +just host-logs -- --status +``` + +Guest `/root/justfile` is a symlink to `/etc/surmount/root-justfile` +after a switch (`just status`, `just logs`, `just btop`, `just scram`). + +### 6.2 Optional public HTTPS end-to-end (not CI) + +```bash +SURMOUNT_E2E_HOST=1 SURMOUNT_E2E_BASE_URL=https://services.surmount.systems just e2e-host +``` + +This is never a flake check. It needs a live host and +`SURMOUNT_E2E_BASE_URL`. Ban tests need `LAB_IP` unless `SKIP_BAN=1`. + +Local hermetic e2e (not cutover): + +```bash +just e2e +``` + +### 6.3 Static sites only (not a NixOS generation) + +```bash +just publish +``` + +That publishes apex/www from locked `github:SurmountSystems/site` plus +extra vhosts. It does not run `nixos-rebuild`. Do not treat `just publish` +as a NixOS generation. `just deploy` is the host switch. + +### 6.4 Splora indexers (host-local) + +Host-local `host-local-splora.nix` owns instance `enable`. Mainnet +indexer is off. testnet3, testnet4, mutinynet, and liquid are on in that +file. They do not index until cookie files exist: + +```text +/run/surmount-secrets/splora/testnet3.cookie +/run/surmount-secrets/splora/testnet4.cookie +/run/surmount-secrets/splora/mutinynet.cookie +/run/surmount-secrets/splora/liquid.cookie +``` + +Path only. Never cookie bytes in git. JSON-RPC is loopback on Splora +default ports unless `daemonRpcAddr` in that same host-local file points +at a real node. Do not invent that peer in public docs. + +### 6.5 MTA-STS + +Host-local `mtaStsMode` may already be `enforce`. Live +`https://mta-sts.surmount.systems/.well-known/mta-sts.txt` stays +`testing` until a real switch. After switch, confirm that file shows +`mode: enforce`. The DNS TXT `_mta-sts.surmount.systems` id should +change when the policy changes so senders refetch. `surmount-dns-zone --live` without +`SURMOUNT_DNS_ZONE_NAMECHEAP_MOCK_DIR` calls live Namecheap getHosts +then setHosts from the laptop (ClientIp required). Default without +`--live` is dry-run (getHosts plan, no setHosts). Tests override the +API base to a local HTTP listener. Never live Namecheap as CI green. + +### 6.6 Certificate hostnames + +When names on the Let's Encrypt certificate this host presents must +change: + +```bash +just laptop-renew-cert -- --issue --directory production \ + --host-profile /home/hunter/.local/share/surmount/host-profile.toml \ + --namecheap-env /home/hunter/.local/share/surmount/issue-le-prod/namecheap.env \ + --install --restart-ui --target root@surmount-1 \ + --timeout-secs 3600 +``` + +Pass `--hook` so extra-zone TXT uses `acme-dns-hook-namecheap-dispatch`. +`--live` does not add missing names. Namecheap ClientIp is laptop +egress. Do not mint a second certificate. + +--- + +## 7. Git with a collaborating peer + +- Work on git-flow `feature/` or `bugfix/`, not a pile on + `main`. +- Agents never `git add`, never `git commit`, never GPG-sign, never + `git push`. +- Operator signs on a real TTY and pushes. +- After that push, the agent opens the GitHub pull request for that + branch. Base is `develop` if it exists, otherwise `main`. For + `SurmountSystems/splora` the default branch is `surmount`; a push + there is already on the default branch, so there is no pull request + from that branch onto itself. +- Plan Approve: GitHub issue with the plan text (no secrets). +- Operator bug report: GitHub issue the same turn, screenshots attached. + +--- + +## 8. Forbidden + +- `BUILD_LOCAL=true` on `just check-remote` or as a way to rustc this + laptop for product proof. +- Laptop `cargo test`, `cargo clippy`, `cargo build` for this repo's + quality bar. +- Two live `just check-remote` processes. +- Real `just deploy` by an agent unless the operator overrides. + `just deploy-host` without `--dry-run` is the same switch. +- Secrets, PEMs, cookie bytes, tokens, provisioned IPs in git, issues, + or chat. +- Reboot. +- Starting Lake from an agent. +- Nice on `stalwart`, `management-ui`, `sshd`, `arti`, or networking. +- Treating `just publish` (static sites) as a NixOS generation. +- Treating grok-oss `just test-remote` as this repo's gate. This repo's + gate is `just check-remote`. + +--- + +## 9. One-page command list + +### Update, audit, test, and switch + +The block in [Update, audit, test, and switch](#update-audit-test-and-switch) +is the sequence. One-page copy: + +```bash +nix flake update +cargo update +just audit-remote +just check-remote +just check-heavy +just deploy-dry-run +just btop +just deploy +``` + +### Agent (through dry-run; operator does not run these to find errors) + +```bash +ssh -o BatchMode=yes -o ConnectTimeout=8 nixbuilder@surmount-1 'echo nixbuilder_ok; hostname' + +nix eval --raw '.#checks.x86_64-linux.module-eval-contract' +nix eval --raw '.#checks.x86_64-linux.deploy-secrets-contract' +nix eval --raw '.#checks.x86_64-linux.arti-module-contract' +nix eval --raw '.#checks.x86_64-linux.splora-package-contract' + +just check-remote + +just deploy-dry-run +``` + +Explicit equivalent of that dry-run: + +```bash +just deploy-host -- --dry-run --target root@surmount-1 --host-local /home/hunter/.local/share/surmount/host-local +``` + +The agent reports each store path or the named failure, then the dry-run +exit 0, then asks for the switch. + +### Operator (after the agent reports dry-run green) + +The `just btop` window from section 1 is already the emergency +terminal. Do not open a second idle `just et`. From the laptop, not +from the shell inside that btop session: + +```bash +just deploy +just status +``` + +Explicit equivalent of that switch: + +```bash +just deploy-host -- --target root@surmount-1 --host-local /home/hunter/.local/share/surmount/host-local +``` + +Smoke already runs inside the deploy driver. Re-run only if the switch +SSH died: + +```bash +ssh -- root@surmount-1 surmount-deploy-host-post-switch-smoke +``` diff --git a/docs/architecture-review.md b/docs/architecture-review.md index 42209c6..53af7cb 100644 --- a/docs/architecture-review.md +++ b/docs/architecture-review.md @@ -89,7 +89,7 @@ Measured 2026-07-30 from package files, `nix build`, `--version`, and | nixpkgs lock rev | `445d861c6d31b4af0c79d8d4be2331f762a361d7` | locked node `nixpkgs` (nixos-26.05) | `flake.lock` | | Channel `pkgs.stalwart-mail` | historical on **25.05** (was 0.11.8); Surmount never uses channel engine | stock module dual-disabled | nixpkgs (not product pin) | | Surmount service module | custom | disables both stock paths (`services/mail/stalwart-mail.nix` and `services/mail/stalwart.nix`); option `services.stalwart` | `modules/stalwart-service.nix` | -| Management UI | Axum + Leptos SSR admin shell scaffold (ssr-only; no hydrate) | crane under `nix/packages/` with `rustPackages_1_95` (nixos-26.05) | `crates/management-ui/`, `nix/packages/management-ui.nix`, `nix/rust-toolchain.nix` | +| Management UI | Axum + Leptos SSR admin shell scaffold (ssr-only; no hydrate) | crane under `nix/packages/` with nixpkgs-rust rustc 1.99.0 (not host `rustPackages_1_95`) | `crates/management-ui/`, `nix/packages/management-ui.nix`, `nix/rust-toolchain.nix` | | Upstream server tag | `v0.16.15` (published 2026-07-27) | https://github.com/stalwartlabs/stalwart | evidence note | | RocksDB (upstream lock) | rust `rocksdb` **0.24.0**; `librocksdb-sys` **0.17.3+10.4.2** | tag `Cargo.lock` | evidence note | | Blob hash crate | `blake3` **1.8.5** | tag `Cargo.lock` | evidence note | @@ -103,7 +103,7 @@ compatibility ([AGENTS.md](../AGENTS.md), [open-choices.md](open-choices.md)). **Why binary FOD, not rustPlatform today:** cargo vendor hit crates.io HTTP 403 in this environment (2026-07-30). Binary FODs are hermetic hashes and match the published tag. Source build is a future path, not current (host rustc age is -no longer the blocker; living crane is 1.95 on 26.05). +no longer the blocker; crane rustc is 1.99.0 from nixpkgs-rust, not host 1.95.0). **Approx closures (this machine after build):** server ~126.5 MiB; cli ~39 MiB (see evidence note for store path examples). diff --git a/docs/deploy-host-local.md b/docs/deploy-host-local.md index 6bb0b7c..7e6a79c 100644 --- a/docs/deploy-host-local.md +++ b/docs/deploy-host-local.md @@ -3,7 +3,9 @@ How operators keep **box-specific** material off the public Surmount Server tree while still rebuilding `#mail-vps` repeatably. -**Last updated:** 2026-08-25 (driver is `nix run .#surmount-deploy-host` / +**Last updated:** 2026-09-20 (operator procedure through dry-run is +agent work; real switch is operator. Full list: +[TEST-AND-DEPLOY.md](TEST-AND-DEPLOY.md)). Prior 2026-08-25 (driver is `nix run .#surmount-deploy-host` / `just deploy-host`; crate tests in `checks.*.ci`. No product `nix run .#surmount-deploy-host --`.) **Companions:** [OPS.md](OPS.md) (cutover gates), [hygiene.md](hygiene.md), [SECRETS.md](SECRETS.md), [SECURITY.md](SECURITY.md), sample @@ -194,9 +196,13 @@ file. That check is **necessary but not sufficient** for lockout safety: With the known-name layout (no custom `default.nix`), flake auto-import sets `authorizedKeys.keyFiles` from `host-local/authorized_keys` on path: rebuild. -If you ship a custom `default.nix`, you must import keys yourself; otherwise -password auth off still means **lockout**. The driver prints this note after a -passing key-file check. +If you ship a custom `default.nix`, you must set `authorizedKeys` in that +file; otherwise password auth off still means **lockout**. The driver prints +a note only when that entry module exists and does not mention +`authorizedKeys` or `authorized_keys`. A `default.nix` that already sets +`users.users.root.openssh.authorizedKeys` does not print the note. The +known-name layout with no `default.nix` auto-wires `authorized_keys` and +does not print the note either. --- diff --git a/docs/hygiene.md b/docs/hygiene.md index f377b2e..6b327cf 100644 --- a/docs/hygiene.md +++ b/docs/hygiene.md @@ -7,6 +7,10 @@ Agent process pins also live in [../AGENTS.md](../AGENTS.md). Principles: [principles.md](principles.md). Glossary: [glossary.md](glossary.md). Operator direction: [operator-direction.md](operator-direction.md). +Test and deploy: [TEST-AND-DEPLOY.md](TEST-AND-DEPLOY.md). Agent runs +eval, `just check-remote`, and deploy dry-run. Operator runs the real +switch. + Collaborating GitHub (operator 2026-09-08): plan Approve opens a GitHub issue with the plan text. An operator bug report opens a GitHub issue with screenshots. Agents never GPG-sign and never push. After the diff --git a/docs/operator-direction.md b/docs/operator-direction.md index 7ffeac2..d8b080e 100644 --- a/docs/operator-direction.md +++ b/docs/operator-direction.md @@ -259,6 +259,17 @@ Full write-up: [SECRETS.md](SECRETS.md). Hygiene top rule: [hygiene.md](hygiene. | Future PQ mail auth | Residual: when IETF and receivers support PQ or hybrid mail auth, Surmount tracks it. Separate from D1 TLS hybrid KEX and D2 PQConnect. | | Living docs | [DNS.md](DNS.md) *Hardness and quantum honesty*, [SECURITY.md](SECURITY.md), [OPS.md](OPS.md), [SECRETS.md](SECRETS.md), RESIDUAL B6 | +### Google may send as surmount.systems (operator direction 2026-10-10) + +| Item | Direction | +|------|-----------| +| Domain | `surmount.systems` only. Do not apply this to `cryptoquick.com`. | +| SPF | `v=spf1 a:mail.surmount.systems include:_spf.google.com -all`. Keep `a:mail.surmount.systems`. Still ends in `-all`. Do not use `~all` or `+all`. | +| DMARC | Stays `p=quarantine`. Do not set `p=reject`. | +| Google DKIM | Selector `google._domainkey` stays unpublished until Google Admin (Apps, Google Workspace, Gmail, Authenticate email) generates the TXT for selector name `google`. Do not invent a `p=` value. Google's private key stays at Google. | +| Our DKIM | Selectors `stalwart` and `stalwart-rsa` stay. | +| Living doc | [DNS.md](DNS.md) section *Google send (operator direction 2026-10-10)*. | + --- ## 7. Process and language diff --git a/docs/packages-and-forks.md b/docs/packages-and-forks.md index 6a3d512..357ee5c 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-09-02 (`surmount.sploraIndexer` is the host-local single-knob wrap over imported `nixosModules.splora`; flake input `splora` on the `surmount` branch, locked rev `9481e4cb87273aa99b0357be48503765beadb919`; 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-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,7 +46,7 @@ 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.5.1** source build + `onion-service-service` (HS publish); distinct from stock `pkgs.arti` | +| `nix/packages/arti-onion-service.nix` | Surmount-owned Arti **2.6.0** source build + `onion-service-service` (HS publish); distinct from stock `pkgs.arti`. Kept equal to nixpkgs-rust `pkgs.arti.version` | | `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. | @@ -71,7 +71,7 @@ package bumps in this repo. | `flake.nix` / `flake.lock` | Inputs, checks, host entrypoints | **Flake input, not in-tree:** `splora` is `github:SurmountSystems/splora` on -the `surmount` branch (locked rev `9481e4cb87273aa99b0357be48503765beadb919`). +the `surmount` branch (locked rev `32f52e8da3ae860a36d39bce334e80e7e624d5a8`; prior `be3603dbd8e6ef0c24e37fe07beaf8067bfa2d0b`). This repo imports `nixosModules.splora` and overlays `pkgs.splora` / `pkgs.splora-liquid` from the input packages. Operator bumps with `nix flake update splora`. Do not copy the splora tree into this repo. @@ -123,10 +123,14 @@ Do **not** wrap leftover bash in `writeShellApplication`. Do **not** grow thin just alias. **Arti note:** stock nixpkgs `pkgs.arti` often lags (client-default, not -HS-capable). Surmount owns a **current** Arti pin (`2.5.1` from GitLab -`arti-v2.5.1`) at `nix/packages/arti-onion-service.nix` with cargo feature -`onion-service-service`, exposed as `pkgs.artiOnionService` / -`packages.*.arti-onion-service` (does **not** replace `pkgs.arti`). rustc +HS-capable). Surmount owns the Arti pin at +`nix/packages/arti-onion-service.nix` (`2.6.0` from GitLab `arti-v2.6.0`). +That version is this lock's nixpkgs-rust `pkgs.arti.version`. Upstream tag +`arti-v2.7.0` (2026-10-01) is newer and is not in this nixpkgs-rust +snapshot, so the cargoDeps assert stays on 2.6.0. 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 diff --git a/docs/research/cargo-audit-2026-08-26.md b/docs/research/cargo-audit-2026-08-26.md index 0fe2b01..6b5a917 100644 --- a/docs/research/cargo-audit-2026-08-26.md +++ b/docs/research/cargo-audit-2026-08-26.md @@ -1,14 +1,40 @@ # cargo-audit (RustSec) 2026-08-26 -Hermetic run: `cargo-audit audit --no-fetch --stale` against flake input -`advisory-db` (github:rustsec/advisory-db) and root `Cargo.lock`. -Loaded 1226 advisories. Operator command: `just audit` (or -`just audit-remote`). +Hermetic run: `cargo-audit audit --no-fetch --stale --deny warnings` +against flake input `advisory-db` (github:rustsec/advisory-db) and root +`Cargo.lock`. A warning fails that check. Loaded 1226 advisories on the +2026-08-27 run. Operator command: `just audit` (or `just audit-remote`). `just audit` builds `checks.*.cargo-audit` and **is** in `checks.*.ci` after nostr 0.44.8, time 0.3.55, and `h2` 0.4.16 (RUSTSEC-2026-0258 cleared on cargo update 2026-08-27). `just deny` stays in `checks.ci`. +## 2026-09-21 rustls RUSTSEC-2026-0285 + +[RUSTSEC-2026-0285](https://rustsec.org/advisories/RUSTSEC-2026-0285) +(accessed: 2026-09-21): rustls 0.23.13 through 0.23.44 accepted TLS 1.3 +handshake messages across encryption-level boundaries. Patched +`>=0.23.45`. crates.io and menhera **3d** list 0.23.45 (published +2026-09-14T15:11:17Z, checksum +`0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634`). +Laptop `replace-with` is menhera **10d**; named registry is **7d**. Both +still stop at 0.23.44. + +`cargo update -p rustls --precise 0.23.45` against that replacement: + +``` +error: no matching package named `rustls` found +location searched: `menhera-cooldown` index (which is replacing registry `crates-io`) +``` + +Do **not** ignore the advisory. Do **not** fetch crates.io to skip +menhera. Git `[patch.crates-io]` of +https://github.com/rustls/rustls rev +`2976d90fd1c2db6b518700dd101b714069cfcb17` (tag `v/0.23.45`) is the +same escape as chacha20. Workspace pin is `0.23.45`. When menhera 10d +lists 0.23.45, drop the rustls patch line and +`cargo update -p rustls --precise 0.23.45`. + ## 2026-08-27 unmaintained warnings Operator `cargo audit` paste and hermetic `just audit` (same three IDs, @@ -24,6 +50,9 @@ exit 0, "3 allowed warnings"): silence `just audit`. Leave the IDs printing until the parent crate drops them. +That paragraph describes the 2026-08-27 lock. The current lock is the +2026-09-26 section. + `age` 0.12.1 also pulls `kem 0.3.0-pre.0` via `ml-kem` 0.2.3 (upstream published graph; rand stays 0.8). chacha20 git patch unchanged. Accessed: 2026-08-27. @@ -43,29 +72,85 @@ Accessed: 2026-08-27. | RUSTSEC-2026-0219 | nostr 0.43.1 | | NIP-04 IV DoS; >=0.44.6 | | RUSTSEC-2026-0009 | time 0.3.45 | rcgen/x509-parser/UI | stack DoS; >=0.3.47 | -Workspace today **pins** `nostr = "0.44.7"` (lock 0.44.8) and `time` +Workspace today pins nostr `0.45.3` (lock 0.45.5) and `time` `0.3.47+`. Audit **is** a CI gate. The 0.43 / exact-time-0.3.45 notes below are historical from 2026-08-26. -## Warnings (did not fail `--deny` vulns; unmaintained/unsound) +## Warnings + +`checks.*.cargo-audit` passes `--deny warnings`. A warning fails the +derivation. As of 2026-09-26, instant is not a current lock hit. paste +and proc-macro-error2 are in the lock and ignored. See the 2026-09-26 +section. Do not ignore any other advisory. | ID | Crate | Kind | |----|-------|------| -| RUSTSEC-2024-0384 | instant 0.1.13 | unmaintained (via nostr 0.44.8; still in lock) | -| RUSTSEC-2024-0436 | paste 1.0.15 | unmaintained (via leptos 0.8.20; still in lock) | -| RUSTSEC-2026-0173 | proc-macro-error2 2.0.1 | unmaintained (via leptos_macro / rstml; age path dropped) | +| RUSTSEC-2024-0384 | instant 0.1.13 | unmaintained. Not in the lock (nostr 0.45.5, universal-time). | +| RUSTSEC-2024-0436 | paste 1.0.15 | unmaintained INFO. In the lock via Leptos. Ignored 2026-09-26. Not a CVE. | +| RUSTSEC-2026-0173 | proc-macro-error2 2.0.1 | unmaintained INFO. In the lock via Leptos. Ignored 2026-09-26. Not age. Not a CVE. | | RUSTSEC-2026-0221 | event-listener 5.4.1 | unsound `!Send` (via leptos reactive_graph). **Not printed** by `just audit` 2026-08-27 (1226 advisories). | +## 2026-09-26 vendor reverted, two ids ignored + +On 2026-09-26 the third_party Leptos fork was reverted. either_of, +leptos, leptos_macro, reactive_graph, reactive_stores, +reactive_stores_macro, syn_derive, and tachys come from crates.io +again, at the same versions as before the fork (leptos 0.8.20 and the +matching published crates). paste 1.0.15 and proc-macro-error2 2.0.1 +are back in the lock the way upstream publishes them. pastey is not in +the lock. proc-macro-error3 3.1.1 stays because i18n-embed-fl 0.10.1 +uses it. The rustls git patch and the chacha20 git patch stay. + +The fork existed only so cargo-audit would stop naming +RUSTSEC-2024-0436 and RUSTSEC-2026-0173. That was the wrong size of +change. Do not vendor Leptos to clear these two ids. + +Both records are RustSec type INFO, informational "unmaintained". They +are not CVEs. The OSV records have cvss null and no CVE alias. + +paste (RUSTSEC-2024-0436) pastes identifiers inside a compile-time +macro. The dtolnay/paste repo was archived on 2024-10-06. The advisory +text is only that the project is unmaintained. It does not describe a +vulnerability. +https://rustsec.org/advisories/RUSTSEC-2024-0436 + +proc-macro-error2 (RUSTSEC-2026-0173) prints compiler errors when a +procedural macro fails. The author confirmed it is unmaintained. The +advisory does not describe an exploitable bug. This id is +proc-macro-error2, not age. The age 0.12 bump is a separate change: it +pulls i18n-embed-fl 0.10.1, which uses proc-macro-error3. +https://rustsec.org/advisories/RUSTSEC-2026-0173 + +Both entered this lock only through Leptos. The management UI is the +only product crate that depends on Leptos. Its source does not call +paste or proc-macro-error. The crates run inside rustc while the UI is +compiled. They are not in the mail server process and not in the running +management UI process. A lockfile pin means a later crates.io upload +does not enter the build until someone updates the lock. + +`checks.*.cargo-audit` runs +`cargo-audit audit --no-fetch --stale --deny warnings` with +`--ignore RUSTSEC-2024-0436` and `--ignore RUSTSEC-2026-0173`. +`--deny warnings` stays, so any other warning still fails. The ignore +list is only those two ids. Do not ignore any other advisory. Do not +turn off the whole unmaintained class. `.cargo/audit.toml` records the +same two ids. The hermetic check passes the flags on the command +because that derivation does not see the repo. `deny.toml` stays +bans-only. + +Accessed: 2026-09-26. + ## Operator leftovers -1. Rewrite management-ui NIP-98 helpers onto nostr 0.45.3 (or later 0.45) - so `instant` leaves the lock. Do not enable `os-rng` without checking - rand 0.10 / chacha20 0.10 against menhera-cooldown. -2. When leptos publishes a **stable** 0.9 that drops `paste` and - `proc-macro-error2`, bump workspace `leptos` off 0.8. Do not ship - 0.9.0-beta as that bump. -3. `h2` >=0.4.16, nostr 0.44.7+, `time` 0.3.47+, and `checks.*.ci` +1. `instant` is gone from the lock (nostr 0.45.5, universal-time). Do not + re-add it. +2. The Leptos `third_party/` fork was reverted on 2026-09-26. Do not + vendor Leptos again to clear RUSTSEC-2024-0436 or RUSTSEC-2026-0173. + Those two ids are ignored. Do not ignore any other advisory. Do not + ship 0.9.0-beta just to chase paste. +3. `h2` >=0.4.16, nostr 0.45, `time` 0.3.47+, and `checks.*.ci` already landed. Do not re-do those. URLs: https://rustsec.org/advisories/ -Accessed: 2026-08-26; unmaintained re-check 2026-08-27. +Accessed: 2026-08-26; unmaintained re-check 2026-08-27; vendor reverted +and two INFO ids ignored 2026-09-26. diff --git a/docs/research/scaffold-assumptions-inventory.md b/docs/research/scaffold-assumptions-inventory.md index 849d75e..e4d7f15 100644 --- a/docs/research/scaffold-assumptions-inventory.md +++ b/docs/research/scaffold-assumptions-inventory.md @@ -5,7 +5,7 @@ **Scope:** every material architectural assumption the Surmount Server tree currently implies via modules, packages, docs, or joins. -**Living host (2026-08-07):** **nixos-26.05**, crane **`rustPackages_1_95`**, +**Living host (2026-10-10):** **nixos-26.05** `pkgs.rustc` **1.95.0**. Crane rustc is **1.99.0** from `nixpkgs-rust`, not **`rustPackages_1_95`**. dual stock Stalwart module disable. See section 3 and section 22. **How to read this file** diff --git a/docs/research/stalwart-directory-api.md b/docs/research/stalwart-directory-api.md index 739afc3..0ec1219 100644 --- a/docs/research/stalwart-directory-api.md +++ b/docs/research/stalwart-directory-api.md @@ -106,17 +106,22 @@ now delegates create to the inner client (list stays empty). Gate: is 403. Hermetic wire-mock covers happy path + fail-closed HTTP. `/accounts` still has no create form. -**Mailbox password shipped (2026-08-14):** `Directory::set_mailbox_password`; -HTTP `POST /api/v1/accounts/password`; `/mail` HTML form. Lookup by -`emailAddress`, then `x:Account/set` update with -`credentials.0 @type Password`. Not `x:AccountPassword/set` (that singleton -is the API-token principal). Listing may stay `unavailable`; password set -still uses the host token + `SURMOUNT_STALWART_URL` when present -(`StalwartPasswordOnly`). Password never in JSON responses or logs. -Surmount sends plaintext; Stalwart 0.16.15 hashes with -`Authentication.passwordHashAlgorithm` (default **Argon2id**; see -[SECURITY.md](../SECURITY.md) *Mailbox password hashing*). `/mail` card -keeps `#mailbox-password`; the password INPUT id is +**Mailbox password shipped (2026-08-14; create id path 2026-09-30):** +`Directory::set_mailbox_password`; HTTP `POST /api/v1/accounts/password`; +`/mail` HTML form. The password card looks up the principal by the mailbox +address the form sends (`local@domain`), then `x:Account/set` update with +`credentials.0 @type Password`. Create does not post the address preview. +The server composes `local@primary`. Stalwart 0.16 User has no +`emailAddress`, so the create response address is the new id or the +local-part. Create therefore sets the Password credential on that principal +id and does not look the row up by `emailAddress`. Not +`x:AccountPassword/set` (that singleton is the API-token principal). +Listing may stay `unavailable`; password set still uses the host token + +`SURMOUNT_STALWART_URL` when present (`StalwartPasswordOnly`). Password +never in JSON responses or logs. Surmount sends plaintext; Stalwart +0.16.15 hashes with `Authentication.passwordHashAlgorithm` (default +**Argon2id**; see [SECURITY.md](../SECURITY.md) *Mailbox password hashing*). +`/mail` card keeps `#mailbox-password`; the password INPUT id is `mailbox-password-input`. **Still residual:** full Q-AUTH-1 product answers; UDS to Stalwart. @@ -139,6 +144,10 @@ replace that bootstrap step. `POST /api/v1/accounts/password` shipped (2026-08-14). Host live IMAP login after the operator sets a password is operator-side proof (do not invent a password in-tree). +- **Not claimed:** live SMTP or IMAP delivery to a mailbox created in this + UI. In-tree proof is the create payload only: `@type` User (not Group), + `name` is the local-part with no `@`, `domainId` is the looked-up Domain + id (not the hostname). A live send is out of scope for these tests. - **Do not invent Q-AUTH-1** (key-loss, durable session store, first-operator bootstrap). Live directory and mutations require `authMode=nostr` unless lab escape (`allowDirectoryUnauthenticated` / `SURMOUNT_DIRECTORY_ALLOW_UNAUTHENTICATED`). diff --git a/docs/research/version-audit.md b/docs/research/version-audit.md index 0d7ef85..31ebe48 100644 --- a/docs/research/version-audit.md +++ b/docs/research/version-audit.md @@ -1,10 +1,22 @@ # Version pin audit (Surmount Server) **Status:** research finding / inventory. Not operator acceptance of any bump. -**Living host + crane refresh:** 2026-08-07 (UTC). Host channel and crane -rows describe the **tree today**. -**Arti HS package refresh:** 2026-08-27 (UTC). Surmount `arti-onion-service` -pin is **2.5.1** (matches nixpkgs-rust `arti`; crates.io max_stable 2.5.1). +**Living host + crane refresh:** host-channel rows below stay the +2026-08-07 note unless a later row says otherwise. Crane rustc was +re-checked 2026-10-10: `nixpkgs-rust` `pkgs.rustc` is **1.99.0** on lock +`c9f19b6be67332ce2ee7520c1630c804685df838` (committed 2026-10-01). That is +not `rustPackages_1_95`. nixos-unstable on 2026-10-10 still only had +`1_98.nix`. staging HEAD that day had rustc 1.99.0 and `pkgs.arti` 2.7.0, +so the pin is the rust bump commit, not that tip. Host nixos-26.05 +`pkgs.rustc` is still **1.95.0** (`nix eval` 2026-10-10). The grok-oss +package native build inputs are `surmount-rust-1.99.0`, not host cargo +1.95.0. Upstream stable is 1.99.0 +([Announcing Rust 1.99.0](https://blog.rust-lang.org/2026/10/01/Rust-1.99.0), +accessed: 2026-10-10). No 1.99 patch and no 1.100 stable that day. +**Arti HS package refresh:** 2026-10-10 (UTC). Surmount `arti-onion-service` +pin is **2.6.0** (GitLab tag `arti-v2.6.0`). It is not 2.5.1. Upstream tag +`arti-v2.7.0` exists (2026-10-01). This nixpkgs-rust snapshot still reports +`pkgs.arti.version` **2.6.0**, so the package stayed at 2.6.0. **Prior full package audit:** 2026-07-31 (UTC). Stalwart FOD / cargo / upstream "latest" cells below stay labeled as that audit day unless revalidated. @@ -41,13 +53,13 @@ date in that row** until revalidated. | Area | Verdict | |------|---------| | Host nixpkgs channel | **Living:** `nixos-26.05` @ `445d861c6d31b4af0c79d8d4be2331f762a361d7` (`flake.lock` 2026-08-07). Sample `system.stateVersion = "26.05"`. | -| Crane / management-ui rustc | **Living:** **1.95** via `rustPackages_1_95` (`nix/rust-toolchain.nix`). Leptos MSRV floor remains **>= 1.88**. Not "at latest rustc." | +| Crane / management-ui rustc | **Living:** **1.99.0** from `nixpkgs-rust` `pkgs.rustc` (`nix/rust-toolchain.nix`; lock `c9f19b6be67332ce2ee7520c1630c804685df838`, committed 2026-10-01, checked 2026-10-10). Not `rustPackages_1_95`. Not nixos-unstable (still 1.98 on that date). Host nixos-26.05 `pkgs.rustc` is still **1.95.0**. grok-oss uses the same toolchain. Leptos MSRV floor remains **>= 1.88**. | | 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.15** Surmount pin (not channel package). Upstream latest re-check **2026-07-31**: still v0.16.15 | | stalwart-cli FOD | **1.0.12** (re-check 2026-07-31: at latest) | | WebUI FOD | **1.0.7** (re-check 2026-07-31: at latest) | | spam-filter FOD | **3.0.0** (re-check 2026-07-31: at latest) | -| Arti (Surmount HS package) | Surmount-owned source build **2.5.1** (`arti-onion-service.nix`; re-check 2026-08-27). Stock `pkgs.arti` is channel-lagged client-default (not HS path). rustc via `nixpkgs-rust` (MSRV 1.91+) | +| Arti (Surmount HS package) | Surmount-owned source build **2.6.0** (GitLab `arti-v2.6.0`; `arti-onion-service.nix`; checked 2026-10-03). Not 2.5.1. Upstream tag `arti-v2.7.0` exists (2026-10-01). This nixpkgs-rust snapshot still reports `pkgs.arti.version` **2.6.0**, so the package stayed at 2.6.0. Stock host `pkgs.arti` is channel-lagged client-default (not the HS path). | | crane / sops-nix flake locks | Prior audit (2026-07-30) at master HEAD; **not re-checked** 2026-08-07 | | management-ui Cargo.lock | Prior audit (2026-07-30): mostly current; small **tokio** patch lag; intentional older **reqwest** / **tower-http** majors. **Not re-fetched** 2026-08-07 | | RocksDB | Embedded in upstream Stalwart lock (`10.4.2` via `librocksdb-sys`); Surmount does **not** use system `pkgs.rocksdb` for the mail binary | @@ -112,7 +124,7 @@ Do not "sync down" to channel packages. | Input | Original ref | Locked rev | Notes | |-------|--------------|------------|-------| | **nixpkgs** | `github:NixOS/nixpkgs/nixos-26.05` | `445d861c6d31b4af0c79d8d4be2331f762a361d7` | **Living** host channel (2026-08-07). Engine not from this package set. | -| **nixpkgs-rust** | `github:NixOS/nixpkgs/nixos-unstable` | `1559d3daa3ecc813a650b79375ea61b6741b8746` | Arti MSRV / vendor path (`flake.lock`) | +| **nixpkgs-rust** | `github:NixOS/nixpkgs/c9f19b6be67332ce2ee7520c1630c804685df838` | `c9f19b6be67332ce2ee7520c1630c804685df838` | Pinned rev, not floating nixos-unstable. Committed 2026-10-01, checked 2026-10-10. `pkgs.rustc` **1.99.0**. `pkgs.arti.version` **2.6.0**. | | **crane** | `github:ipetkov/crane` (default branch) | `756d6d07c3818ea95d1e2cdac63fa7d02fe3e61b` | Prior HEAD check 2026-07-30; **not re-polled** 2026-08-07. Latest release tag `v0.23.4` (2026-05-17) is older than floating master tip | | **sops-nix** | `github:Mic92/sops-nix` | `f1406619a3884cd5c47992a70b8b35c9c0fcb4c9` | Prior HEAD check 2026-07-30; **not re-polled** 2026-08-07 | @@ -136,7 +148,7 @@ That lock is **not** the living tree. |------|---------------------------| | Surmount pin | `nixos-26.05` @ `445d861c6d31b4af0c79d8d4be2331f762a361d7` | | Sample host `stateVersion` | **26.05** (`hosts/mail-vps/configuration.nix`, module eval tests) | -| Crane wants | host channel with `rustPackages_1_95` (26.05 ships it; 1.88 set removed) | +| Crane toolchain | `nixpkgs-rust` `pkgs.rustc` **1.99.0** (lock `c9f19b6be67332ce2ee7520c1630c804685df838`, checked 2026-10-10). Not `rustPackages_1_95`. Host channel still ships `rust_1_95` / **1.95.0**. | | Engine from channel? | **no.** Engine is Surmount FOD overlay (`nix/packages/stalwart-mail.nix`) | ### Default `rustc` on channels (nixpkgs `all-packages.nix` series map) @@ -145,8 +157,8 @@ That lock is **not** the living tree. |---------|---------------------|---------------|-------------------| | nixos-25.05 | `rust_1_86` | **1.86** | historical host only | | nixos-25.11 | `rust_1_91` | 1.91 | not host | -| nixos-26.05 (Surmount host) | `rust_1_95` | **1.95** | living host + crane pin | -| nixos-unstable | `rust_1_97` | 1.97 | `nixpkgs-rust` for Arti MSRV | +| nixos-26.05 (Surmount host) | `rust_1_95` | **1.95.0** | living host default `pkgs.rustc` (checked 2026-10-10). Not the crane pin. | +| nixpkgs-rust (pinned rev c9f19b6) | `rust_1_99` | **1.99.0** | crane and grok-oss pin. Not nixos-unstable (that branch still had only `1_98.nix` on 2026-10-10). Prior lock `c59305ba` was 1.98.1. | ### Gap assessment @@ -177,7 +189,7 @@ Package version string: **0.1.0** (Surmount product, not upstream). | Pin | Value | Notes | |-----|-------|--------| | workspace edition | **2021** | `crates/Cargo.toml` `[workspace.package]` | -| Edition 2024 | stable since rustc 1.85 | Available on living 1.95 rustc; optional bump, not required | +| Edition 2024 | stable since rustc 1.85 | Available on host `pkgs.rustc` **1.95.0** and on locked nixpkgs-rust rustc **1.99.0**; optional bump, not required | ### Direct workspace dependencies @@ -250,9 +262,10 @@ optional `blobSize` / `bufferSize`; it does not pin a system rocksdb package. ## 5b. Arti (HS publish package) -Packaging mode: **Surmount-owned source build** of upstream Arti from Tor -Project GitLab (`fetchFromGitLab` tag `arti-v2.5.1`), with cargo feature -`onion-service-service`. Distinct attribute `pkgs.artiOnionService` / +Packaging mode: **Surmount-owned source build** of Arti from Tor +Project GitLab (`fetchFromGitLab` tag `arti-v2.6.0`), with cargo feature +`onion-service-service`. The package version is **2.6.0**. It is not 2.5.1. +Distinct attribute `pkgs.artiOnionService` / `packages.*.arti-onion-service`. Does **not** replace stock `pkgs.arti`. No official multi-arch Arti release binaries (unlike Stalwart FODs), so this @@ -260,15 +273,17 @@ is a hermetic cargo source build, not a binary FOD. | Item | Value | Source | |------|-------|--------| -| Surmount package version | **2.5.1** | `nix/packages/arti-onion-service.nix` | -| Source | GitLab `tpo/core/arti` tag **`arti-v2.5.1`** | package `src` | +| Surmount package version | **2.6.0** | `nix/packages/arti-onion-service.nix` (checked 2026-10-03). Not 2.5.1. | +| Source | GitLab `tpo/core/arti` tag **`arti-v2.6.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`** (nixos-unstable; MSRV **1.91+**) | `flake.nix` `mkArtiRustPlatform` | +| Toolchain | rustc from flake input **`nixpkgs-rust`** (pinned rev c9f19b6, rustc **1.99.0**; MSRV **1.91+**) | `flake.nix` `mkArtiRustPlatform` | | Vendor | `cargoDeps` from matching nixpkgs-rust `arti` (crates.io 403 workaround) | package `artiUnstable.cargoDeps` | | Stock nixpkgs `pkgs.arti` | channel-lagged client-default (COMPACTION-PIN: often **1.4.2**); not used for HS path | host + `nixpkgs-rust` | -| Upstream crates.io max_stable | **2.5.1** (re-check 2026-08-27; crate published 2026-08-03; no 2.6) | [crates.io arti](https://crates.io/crates/arti) (accessed: 2026-08-27) | -| Gap vs upstream engine | **closed** for Surmount HS package | own pin matches 2.5.1 | +| Upstream GitLab tag | **`arti-v2.7.0`** (2026-10-01) | newer than this package. The package did **not** move to 2.7.0. | +| nixpkgs-rust `pkgs.arti.version` | **2.6.0** | lock `c9f19b6be67332ce2ee7520c1630c804685df838` (`nix eval` 2026-10-10). staging HEAD that day was 2.7.0 and was not taken. | +| Upstream crates.io max_stable | **2.5.1** on the 2026-08-27 check | [crates.io arti](https://crates.io/crates/arti) (accessed: 2026-08-27). Not re-polled 2026-10-03. | +| Gap vs upstream tag | **open** (pin 2.6.0, tag `arti-v2.7.0`) | stayed at 2.6.0 so `artiUnstable.version == version` stays true | Honest limits: @@ -280,22 +295,24 @@ Honest limits: MSRV). - **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 pin bump** to **2.5.1** matches nixpkgs-rust `arti` (eval - assert) and crates.io max_stable. Cargo rebuild of the HS binary is not - this eval slice. + **2026-08-27** the pin was **2.5.1**. Living pin checked 2026-10-03 is + **2.6.0** (GitLab `arti-v2.6.0`), equal to this nixpkgs-rust + `pkgs.arti.version`. Upstream tag `arti-v2.7.0` (2026-10-01) is newer. + The package stayed at 2.6.0. Cargo rebuild of the HS binary is not + this note. ## 5c. rustc (management-ui vs host vs stable) | Pin | Version | Notes | |-----|---------|-------| -| Living host default `pkgs.rustc` | **1.95** series on nixos-26.05 | channel default `rust_1_95` | -| Crane / management-ui toolchain | **1.95** | `rustPackages_1_95` in `nix/rust-toolchain.nix` | -| Leptos MSRV floor | **>= 1.88** | product floor; channel ships 1.95 | -| Upstream stable (static.rust-lang.org) | **1.97.1** as of 2026-07-31 | not re-polled 2026-08-07 | -| Host `rustc` on a developer machine | may differ | product builds use crane pin, not host rustup | +| Living host default `pkgs.rustc` | **1.95.0** on nixos-26.05 | channel default. Checked 2026-10-10. Not the crane pin. | +| Crane / management-ui / grok-oss toolchain | **1.99.0** | `nixpkgs-rust` `pkgs.rustc` in `nix/rust-toolchain.nix`. Lock `c9f19b6be67332ce2ee7520c1630c804685df838` (committed 2026-10-01, `nix eval` 2026-10-10). grok-oss native build inputs are `surmount-rust-1.99.0`. Not `rustPackages_1_95`. Not host cargo 1.95.0. | +| Leptos MSRV floor | **>= 1.88** | product floor. Host channel ships 1.95.0. Crane rustc is 1.99.0. | +| Upstream stable | **1.99.0** | [Announcing Rust 1.99.0](https://blog.rust-lang.org/2026/10/01/Rust-1.99.0) (accessed: 2026-10-10). Official `rustc-1.99.0-src.tar.gz` hash matches the nixpkgs pin (`sha256-IDXkB3uDSkL/iv0H8neuPwY0AJi4ax0oQ6ojS0z8rmc=`). No 1.99 patch that day. | +| Host `rustc` on a developer machine | may differ | product builds use the crane pin (`nixpkgs-rust` `pkgs.rustc`), not host rustup | -Gap vs latest stable is **expected** until OS channel or rust-overlay moves. -Do not treat host rustc as the product pin. +Do not treat host rustc 1.95.0 as the product pin. The 2026-10-03 lock +(`c59305ba`, rustc 1.98.1) is not the living pin. ### Historical footnote: crane on 2026-07-31 audit day @@ -315,7 +332,7 @@ Do not treat host rustc as the product pin. | 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.5.1** + `onion-service-service` | gap vs upstream closed as of 2026-08-27; rustc via `nixpkgs-rust` (section 5b) | +| arti-onion-service | Surmount-owned source **2.6.0** (GitLab `arti-v2.6.0`) + `onion-service-service` | not 2.5.1. Upstream tag `arti-v2.7.0` (2026-10-01) is newer. Package stayed at 2.6.0 because nixpkgs-rust `pkgs.arti.version` is 2.6.0 (section 5b). | | Stock Stalwart modules | dual `disabledModules` | `modules/stalwart-service.nix` | --- @@ -326,10 +343,12 @@ Priority order (proposed, not accepted): 1. **Keep Stalwart FODs as-is** until a tag newer than 0.16.15 / 1.0.12 / 1.0.7 / 3.0.0 appears. Re-check releases before every packaging PR. -2. **Arti currency:** **shipped** as Surmount-owned **2.5.1** source package - (2026-08-27). Re-check crates.io / GitLab tags on next packaging pass; - bump version + hashes in `arti-onion-service.nix`. Live Tor verify remains - residual. Do not claim onion published from pin alone. +2. **Arti currency:** the Surmount package is **2.6.0** (GitLab + `arti-v2.6.0`), not 2.5.1. Upstream tag `arti-v2.7.0` exists + (2026-10-01). This nixpkgs-rust snapshot still reports + `pkgs.arti.version` **2.6.0**, so the package stayed at 2.6.0. That is + not a move to 2.7.0. 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. @@ -348,14 +367,14 @@ Priority order (proposed, not accepted): # Stalwart family latest tags for r in stalwartlabs/stalwart stalwartlabs/cli stalwartlabs/webui stalwartlabs/spam-filter; do curl -sL "https://api.github.com/repos/$r/releases/latest" \ - | python3 -c 'import sys,json;d=json.load(sys.stdin);print(d["tag_name"], d["published_at"])' + | jq -r '"\(.tag_name) \(.published_at)"' done # Arti upstream curl -sL -A 'surmount-audit' "https://crates.io/api/v1/crates/arti" \ - | python3 -c 'import sys,json;c=json.load(sys.stdin)["crate"];print("arti", c["max_stable_version"])' + | jq -r '"arti \(.crate.max_stable_version)"' curl -sL "https://gitlab.torproject.org/api/v4/projects/tpo%2Fcore%2Farti/repository/tags?per_page=3" \ - | python3 -c 'import sys,json; [print(t["name"]) for t in json.load(sys.stdin)]' + | jq -r '.[].name' # rustc stable curl -sL "https://static.rust-lang.org/dist/channel-rust-stable.toml" | rg -A1 '\[pkg\.rust\]' @@ -368,7 +387,7 @@ done # crates.io direct deps for c in axum tokio tower-http serde serde_json reqwest tracing tracing-subscriber anyhow; do curl -sL -A 'surmount-audit' "https://crates.io/api/v1/crates/$c" \ - | python3 -c 'import sys,json;c=json.load(sys.stdin)["crate"];print(c["id"], c["max_stable_version"])' + | jq -r '"\(.crate.id) \(.crate.max_stable_version)"' done # Locked nixpkgs facts (host channel 26.05) @@ -386,15 +405,15 @@ re-running. Keep reports short under `~/.agents/reports/`. |-------|----------| | Living host = nixos-26.05 @ `445d861c...` | `flake.nix` input + `flake.lock` node `nixpkgs` (2026-08-07) | | Sample stateVersion 26.05 | `hosts/mail-vps/configuration.nix`, `tests/module-eval.nix`, `tests/mail.nix` | -| Crane rustc = 1.95 / rustPackages_1_95 | `nix/rust-toolchain.nix`, `flake.nix` devShell, `COMPACTION-PIN.md` | +| Crane rustc = 1.99.0 from `nixpkgs-rust` `pkgs.rustc` | `nix/rust-toolchain.nix`. Lock `c9f19b6be67332ce2ee7520c1630c804685df838` (`nix eval` 2026-10-10). grok-oss native build inputs are `surmount-rust-1.99.0`. Not `rustPackages_1_95`. Host nixos-26.05 `pkgs.rustc` is still 1.95.0. | | Dual stock module disable | `modules/stalwart-service.nix` `disabledModules` | | Stalwart latest = 0.16.15 | GitHub releases/latest tag `v0.16.15` published 2026-07-27 (re-check 2026-07-31) | | CLI latest = 1.0.12 | GitHub releases/latest 2026-07-28 (re-check 2026-07-31) | | WebUI latest = 1.0.7 | GitHub releases/latest 2026-07-30 (re-check 2026-07-31) | | spam-filter latest = 3.0.0 | GitHub releases/latest 2026-04-13 (re-check 2026-07-31) | -| Surmount arti-onion-service = 2.5.1 | package expression 2026-08-27; cargoDeps assert vs nixpkgs-rust `arti` | -| Arti upstream = 2.5.1 | crates.io `max_stable_version` 2026-08-27; GitLab tag `arti-v2.5.1` (published 2026-08-03). No 2.6. Historical 2.5.0 packaging: 2026-07-31 | -| rustc stable = 1.97.1 | static.rust-lang.org channel-rust-stable.toml `[pkg.rust]` 2026-07-31 | +| Surmount arti-onion-service = 2.6.0 | package expression, GitLab `arti-v2.6.0`, cargoDeps assert vs nixpkgs-rust `arti` 2.6.0 (checked 2026-10-03). Not 2.5.1. | +| Arti upstream tag = `arti-v2.7.0` (2026-10-01) | Package stayed at 2.6.0 because this nixpkgs-rust snapshot's `pkgs.arti.version` is 2.6.0. The 2026-08-27 crates.io max_stable 2.5.1 cell was not re-polled. | +| rustc stable = 1.99.0 | [Announcing Rust 1.99.0](https://blog.rust-lang.org/2026/10/01/Rust-1.99.0) (accessed: 2026-10-10). Locked nixpkgs-rust rustc `nix eval` 2026-10-10 is 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) | | unstable stalwart_0_16 = 0.16.14 | raw.githubusercontent.com nixos-unstable package.nix (2026-07-30) | diff --git a/flake.lock b/flake.lock index 5a3b32e..5fa3e2d 100644 --- a/flake.lock +++ b/flake.lock @@ -3,11 +3,11 @@ "advisory-db": { "flake": false, "locked": { - "lastModified": 1787625737, - "narHash": "sha256-YBoms9lLCFwvTGkiuw8abRHHup26xEcxQcJU7VFLHik=", + "lastModified": 1791828408, + "narHash": "sha256-HD8080Vwg22sm1XKRtgt68OjePJN/HbmSjWauXpVW5w=", "owner": "rustsec", "repo": "advisory-db", - "rev": "a7bfe16948bf6f3ee25bdee4822209f87da21b80", + "rev": "57dcc0f2192e025138907a495c1ac9a7e1e3e123", "type": "github" }, "original": { @@ -19,11 +19,11 @@ "asset-registry": { "flake": false, "locked": { - "lastModified": 1787954902, - "narHash": "sha256-kmlpnoRPzrGtCor6ZqWp7+GREa2aea27cmoRZdgre0A=", + "lastModified": 1789841305, + "narHash": "sha256-tQAI6b8iqBh9WNa1r+4cT1ZjlQTv1fq/JvGWsnRonQU=", "owner": "Blockstream", "repo": "asset_registry_db", - "rev": "2a6e4fcb440fff8926d61b2f9c9dc17aaf4d92f6", + "rev": "95e9c0afb8c8d6798b921257c7bfc2b2863018bb", "type": "github" }, "original": { @@ -34,17 +34,17 @@ }, "crane": { "locked": { - "lastModified": 1785284101, - "narHash": "sha256-ghcXEpYEM4a7pbEkoqbn8c0ptJJqgGzuFiG3T6W5g4I=", + "lastModified": 1790899423, + "narHash": "sha256-THGtz65UGJU+sonZidRboyCMk+9DclpMF7pwL4uDV4M=", "owner": "ipetkov", "repo": "crane", - "rev": "756d6d07c3818ea95d1e2cdac63fa7d02fe3e61b", + "rev": "47b6b27ed9a3a9181415e4367d0c30ab2a0e0250", "type": "github" }, "original": { "owner": "ipetkov", "repo": "crane", - "rev": "756d6d07c3818ea95d1e2cdac63fa7d02fe3e61b", + "rev": "47b6b27ed9a3a9181415e4367d0c30ab2a0e0250", "type": "github" } }, @@ -65,11 +65,11 @@ }, "crane_3": { "locked": { - "lastModified": 1787326676, - "narHash": "sha256-lWhBbBvC05/xwivKBBiM2YNizpmgqCgyOIzomvRuwxs=", + "lastModified": 1789760033, + "narHash": "sha256-jtT4yxZpR8seYnlCMMWSSPlFN92zO6ICuZ8pJrmi86k=", "owner": "ipetkov", "repo": "crane", - "rev": "692f7e9ef2ece8125b466f66f2af532b3edaed0d", + "rev": "73b980519cefc727a5f6cc8e5c0947a2f9be6edd", "type": "github" }, "original": { @@ -107,16 +107,16 @@ "nixpkgs": "nixpkgs" }, "locked": { - "lastModified": 1788392035, - "narHash": "sha256-cUnJ/i9xIhVkR7oDDlYvmXK0RfZZZ/9fhjn+jGo4h2k=", + "lastModified": 1792020063, + "narHash": "sha256-d5tqU+NiDbrvDPI1mWfJ/vnGDON1+5oI201fBa/iXH4=", "owner": "SurmountSystems", "repo": "grok-oss", - "rev": "6edf5fda9507c9e3c9c9f5a67871ebb3ce6cca7d", + "rev": "b7ab91532c9e8008081b4f281794be43ecae73a6", "type": "github" }, "original": { "owner": "SurmountSystems", - "ref": "remote-1", + "ref": "main", "repo": "grok-oss", "type": "github" } @@ -139,27 +139,27 @@ }, "nixpkgs-rust": { "locked": { - "lastModified": 1787736819, - "narHash": "sha256-cV5xEJJK3BvhU8rEd4mC9UsmDi5qscv/kzGPhBRC5WA=", + "lastModified": 1790857391, + "narHash": "sha256-Q4g9QJS0SxcSpczY4mjpvErWlVH6qkzHxO8PaFuTFyE=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "9fbb54b33e91ee4ca368e35a78e0613c720600b3", + "rev": "c9f19b6be67332ce2ee7520c1630c804685df838", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-unstable", "repo": "nixpkgs", + "rev": "c9f19b6be67332ce2ee7520c1630c804685df838", "type": "github" } }, "nixpkgs_2": { "locked": { - "lastModified": 1785989512, - "narHash": "sha256-HFQhkQcl5D1hUNoen3SGHCSFCt2Bg6uP+HgbrnA3InQ=", + "lastModified": 1791662279, + "narHash": "sha256-xI8L+UwJLIg75yVJwLPsY+AUEP1teCT3FyuvodyJu2Y=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "445d861c6d31b4af0c79d8d4be2331f762a361d7", + "rev": "6fcc160353f63555b560f25588925133c8f61f2a", "type": "github" }, "original": { @@ -171,11 +171,11 @@ }, "nixpkgs_3": { "locked": { - "lastModified": 1787736819, - "narHash": "sha256-cV5xEJJK3BvhU8rEd4mC9UsmDi5qscv/kzGPhBRC5WA=", + "lastModified": 1789785513, + "narHash": "sha256-B44WL6h0XoLjJ41bUPJk0X5SDinLCII//6EcBLXKiJ0=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "9fbb54b33e91ee4ca368e35a78e0613c720600b3", + "rev": "20b1ddd1aa5ace70c9468305030aa4f9ef79671b", "type": "github" }, "original": { @@ -222,11 +222,11 @@ ] }, "locked": { - "lastModified": 1787926460, - "narHash": "sha256-Rf2+5+ACRsI0xBicCraxsBtfSaQ93Br0Nr22nWimkS4=", + "lastModified": 1789802016, + "narHash": "sha256-QVFfArbYaQMGK4dFV+5RUL72Ci+hNHVhaF/R9qJfDhs=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "04ceec130b3a935d01584d4b1a68a366919674a9", + "rev": "26a71e661c47bd21a05d06fec749f3f7c75e9d12", "type": "github" }, "original": { @@ -242,17 +242,17 @@ ] }, "locked": { - "lastModified": 1783174389, - "narHash": "sha256-aCWC8ngycU7OdJrU2+Je3qf+1a2ykuBvpPhZT/9tXMc=", + "lastModified": 1790498116, + "narHash": "sha256-rs9meAYxW3zzrh43yaW7htrqCD+X9+pupDPHN86fumI=", "owner": "Mic92", "repo": "sops-nix", - "rev": "f1406619a3884cd5c47992a70b8b35c9c0fcb4c9", + "rev": "5efb5a6f4f5ab192817d28557dd4d650fa14d866", "type": "github" }, "original": { "owner": "Mic92", "repo": "sops-nix", - "rev": "f1406619a3884cd5c47992a70b8b35c9c0fcb4c9", + "rev": "5efb5a6f4f5ab192817d28557dd4d650fa14d866", "type": "github" } }, @@ -264,11 +264,11 @@ "rust-overlay": "rust-overlay" }, "locked": { - "lastModified": 1788392120, - "narHash": "sha256-ZOohD3Qlu/Tf5gIuYUBXysODmd6td4Yc/81TX3Nj4L0=", + "lastModified": 1790400471, + "narHash": "sha256-bZixbKYpVE0KpVlXOpwzBhuXa75aazi4lxymsafk00Q=", "owner": "SurmountSystems", "repo": "splora", - "rev": "9481e4cb87273aa99b0357be48503765beadb919", + "rev": "32f52e8da3ae860a36d39bce334e80e7e624d5a8", "type": "github" }, "original": { @@ -281,11 +281,11 @@ "surmount-site": { "flake": false, "locked": { - "lastModified": 1787766211, - "narHash": "sha256-YdJ2r/8MTwJzOAjtV9W7tDsrcSFxj8bKSPDg9Ubemeo=", + "lastModified": 1787774658, + "narHash": "sha256-q01WrGxa68AogmpFZ6YfsDiBw/To1fHtm1VKU2Dsg4w=", "owner": "SurmountSystems", "repo": "site", - "rev": "4463b07de98d1f617bd6eba4a002edf26a4a5ce7", + "rev": "82e07195e6aa221efe838e652ed8d2d8535b5cac", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 85a8127..5e2557d 100644 --- a/flake.nix +++ b/flake.nix @@ -21,13 +21,10 @@ # ./host-local is auto-imported when present (path flake / remote rsync # without .git); never committed. - # Mild discoverability only; host nix.settings already prefer cache.nixos.org. - nixConfig = { - extra-substituters = [ "https://cache.nixos.org" ]; - extra-trusted-public-keys = [ - "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" - ]; - }; + # Do not set nixConfig extra-substituters here. cache.nixos.org is already + # the default cache. Guest nixos-rebuild is not a trusted flake-config + # context, so extra-substituters / extra-trusted-public-keys only printed + # "ignoring untrusted flake configuration" on the switch (2026-09-20). inputs = { # Prefer stable for a mail host. Bump deliberately after reading release notes. @@ -35,26 +32,32 @@ # 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.5.1 source build). + # nix/packages/arti-onion-service.nix (Surmount-owned 2.6.0 source build). nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; - # Rustc/cargo only for engines whose MSRV exceeds the host channel default. - # Arti 2.5.1 MSRV is 1.91. Keep a separate input so we can pin newer rustc - # without waiting on every host-channel package set. Bump when Arti needs it. - nixpkgs-rust.url = "github:NixOS/nixpkgs/nixos-unstable"; + # rustc/cargo for crane packages and the grok-oss build. Not host + # nixos-26.05 (pkgs.rustc 1.95.0). Not nixos-unstable: on 2026-10-10 that + # branch still only has pkgs/development/compilers/rust/1_98.nix. + # Not staging HEAD: that tip has rustc 1.99.0 and pkgs.arti 2.7.0. + # This rev is the nixpkgs commit "rust: 1.98.1 -> 1.99.0" (c9f19b6, + # committed 2026-10-01). pkgs.rustc is 1.99.0 and pkgs.arti stays 2.6.0. + # The rustc 1.99.0 source hash matches the official tarball. Crane + # 47b6b27 has no nixpkgs input: mkLib keeps host pkgs and + # overrideToolchain supplies this rustc. Do not float this input. + nixpkgs-rust.url = "github:NixOS/nixpkgs/c9f19b6be67332ce2ee7520c1630c804685df838"; # Pin input URLs to flake.lock revs for reproducible originals; bump via # intentional `nix flake update` (or lock edit), not floating HEAD. - crane.url = "github:ipetkov/crane/756d6d07c3818ea95d1e2cdac63fa7d02fe3e61b"; + crane.url = "github:ipetkov/crane/47b6b27ed9a3a9181415e4367d0c30ab2a0e0250"; # crane follows its own nixpkgs; we pass pkgs from our nixpkgs in outputs. sops-nix = { - url = "github:Mic92/sops-nix/f1406619a3884cd5c47992a70b8b35c9c0fcb4c9"; + url = "github:Mic92/sops-nix/5efb5a6f4f5ab192817d28557dd4d650fa14d866"; inputs.nixpkgs.follows = "nixpkgs"; }; # Public static site for apex/www. Fetch the git tree (HTML/CSS/JS). - # Operator bumps: just deploy (nix flake update surmount-site). + # Operator bumps: just publish (nix flake update surmount-site). surmount-site = { url = "github:SurmountSystems/site"; flake = false; @@ -67,11 +70,11 @@ flake = false; }; - # Grok OSS TUI (bin/grok-oss). Product tip is still branch remote-1 - # (open PR 51; default branch main is #50 and is not this tip). - # Operator bumps: nix flake update grok-oss. + # Grok OSS TUI (bin/grok-oss). + # URL is main. Operator 2026-10-14: HB-1 merged as GitHub pull 167. + # The lock follows that main. Bump with: nix flake update grok-oss. # Default-off NixOS module; package is fail-closed when enable=true. - grok-oss.url = "github:SurmountSystems/grok-oss/remote-1"; + grok-oss.url = "github:SurmountSystems/grok-oss/main"; # Splora (Esplora-compatible indexer). Operator bumps with nix flake update splora. # NixOS module is nixosModules.splora. REST is one indexer instance plus @@ -176,7 +179,7 @@ }; # Shared crane args for management-ui checks (test/clippy/fmt). - # Toolchain from nix/rust-toolchain.nix (nixpkgs-rust / nixos-unstable). + # Toolchain from nix/rust-toolchain.nix (nixpkgs-rust rustc 1.99.0). mkManagementUiCrane = system: let @@ -330,7 +333,7 @@ surmount-public-site = pkgs.callPackage ./nix/packages/surmount-public-site.nix { src = surmount-site; }; - # Surmount-owned Arti 2.5.1 + onion-service-service (HS publish). + # Surmount-owned Arti 2.6.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. @@ -403,9 +406,10 @@ }; # 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.0, not host pkgs.rustc 1.95.0. grok-oss = pkgs.callPackage ./nix/packages/grok-oss.nix { grokOssFlake = grok-oss; - inherit system; + inherit system rustToolchain; }; # Flake-input splora packages. Upstream crane omits # .cargo/config.toml from src and builds --offline --locked. @@ -859,6 +863,16 @@ pkgs.writeText "arti-onion-package-contract" (builtins.toJSON r.ok); # Offline RustSec audit of Cargo.lock (no crates.io). + # 2026-09-26: ignore RUSTSEC-2024-0436 (paste) and + # RUSTSEC-2026-0173 (proc-macro-error2) only. + # See docs/SECURITY.md and .cargo/audit.toml. + # --deny warnings stays. Do not ignore any other advisory. + # yanked.enabled is false because the sandbox has no crates.io + # index. cargo-audit otherwise prints "couldn't open crates.io + # index" and still exits 0. The nixos-26.05 cargo-audit binary + # has no --no-yanked flag. Yanked crates are refused by the + # menhera index when cargo update runs. This check does not + # pretend to scan yanks. cargo-audit = pkgs.runCommand "surmount-cargo-audit" { @@ -866,7 +880,12 @@ } '' set -euo pipefail - cargo-audit audit --no-fetch --stale \ + export HOME="$PWD/home" + mkdir -p .cargo "$HOME/.cargo" + printf '%s\n' '[yanked]' 'enabled = false' | tee .cargo/audit.toml > "$HOME/.cargo/audit.toml" + cargo-audit audit --no-fetch --stale --deny warnings \ + --ignore RUSTSEC-2024-0436 \ + --ignore RUSTSEC-2026-0173 \ -d ${advisory-db} \ -f ${./Cargo.lock} mkdir -p "$out" @@ -909,8 +928,24 @@ if [ "$fail" -ne 0 ]; then exit 1 fi - echo ok > "$out" + mkdir -p "$out" + echo ok > "$out/ok" + ''; + + # just check is fmt, then clippy, then test. releaseTools.aggregate + # builds constituents in parallel, so each clippy derivation depends + # on cargo fmt and nixfmt. The store paths are string context, not + # buildInputs: nix-fmt-check is a file, and stdenv would source a + # file in buildInputs. + afterFmt = + drv: + drv.overrideAttrs (old: { + preBuild = '' + test -e ${management-ui-fmt} + test -e ${nix-fmt-check} + ${old.preBuild or ""} ''; + }); # Fast CI aggregate: rust quality + module contracts + nixfmt. # Excludes mail-vm-test, mail-vps toplevel, stalwart FODs, host e2e. @@ -919,21 +954,28 @@ constituents = [ self.packages.${system}.management-ui management-ui-test - management-ui-clippy management-ui-fmt + nix-fmt-check + (afterFmt management-ui-clippy) e2e-pure-test - e2e-clippy + (afterFmt e2e-clippy) module-eval-contract deploy-secrets-contract arti-module-contract arti-onion-package-contract public-site-package-contract splora-package-contract - nix-fmt-check cargo-deny-bans cargo-audit ] - ++ wave1CiExtras; + ++ lib.concatMap ( + spec: + lib.optionals (wave1CrateLanded spec.crate) [ + self.packages.${system}.${spec.crate} + wave1CheckAttrs."${spec.crate}-test" + (afterFmt wave1CheckAttrs."${spec.crate}-clippy") + ] + ) waveOps; }; in { @@ -977,15 +1019,11 @@ inherit pkgs; inherit (pkgs) lib; nixosTest = pkgs.nixosTest or pkgs.testers.runNixOSTest; + # runNixOSTest pins node.pkgs to this overlaid set and marks + # nixpkgs.overlays read-only. Do not assign that option here. surmountModules = [ sops-nix.nixosModules.sops ./modules - ( - { ... }: - { - nixpkgs.overlays = [ (surmountOverlay system) ]; - } - ) ]; managementUi = self.packages.${system}.management-ui; } @@ -1005,7 +1043,7 @@ { default = pkgs.mkShell { packages = with pkgs; [ - # Match crane toolchain (nix/rust-toolchain.nix via nixpkgs-rust). + # Match crane toolchain (nix/rust-toolchain.nix, nixpkgs-rust 1.99.0). (import ./nix/rust-toolchain.nix { pkgs = mkPkgsRust system; }) rust-analyzer pkg-config diff --git a/hosts/mail-vps/configuration.nix b/hosts/mail-vps/configuration.nix index 2207ad5..3ad347f 100644 --- a/hosts/mail-vps/configuration.nix +++ b/hosts/mail-vps/configuration.nix @@ -319,6 +319,10 @@ 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; diff --git a/justfile b/justfile index 47ec3d9..b2c50d3 100644 --- a/justfile +++ b/justfile @@ -12,6 +12,12 @@ set shell := ["bash", "-euo", "pipefail", "-c"] # Honor BUILD_LOCAL for every recipe that calls nix (including nix run). export NIX_CONFIG := if env_var_or_default("BUILD_LOCAL", "") == "" { env_var_or_default("NIX_CONFIG", "") } else { "builders =\nmax-jobs = auto\n" } +# ssh-ng builder client. Nix 2.34 prepends NIX_SSHOPTS, and OpenSSH keeps +# the first value of each option. Nix does not set these itself. This does +# not retry a nixos-rebuild switch. remote-nix-build retries a dropped +# nix build only. An operator-set NIX_SSHOPTS is left as-is. +export NIX_SSHOPTS := env_var_or_default("NIX_SSHOPTS", "-o ServerAliveInterval=30 -o ServerAliveCountMax=10 -o TCPKeepAlive=yes -o ConnectionAttempts=3") + # Prefer CI_SYSTEM when set (GHA); else host flake system. system := env_var_or_default("CI_SYSTEM", `nix eval --impure --raw --expr 'builtins.currentSystem'`) @@ -41,6 +47,9 @@ ci: # success so a cache hit is not a blank prompt. No --log-format raw: # that hid "these N derivations" / waiting-for-machine and looked dead. # No 20s heartbeat. No --store ssh-ng. Machines file plus max-jobs 0. +# NIX_SSHOPTS keepalives are separate from that heartbeat. A dropped +# ssh-ng connection retries the nix build. A derivation failure does not. +# This is not a nixos-rebuild switch. check-remote: #!/usr/bin/env bash set -euo pipefail @@ -48,13 +57,11 @@ check-remote: echo "just check-remote: refuse BUILD_LOCAL (that is laptop rustc)" >&2 exit 2 fi - echo "==> just check-remote: nix build .#checks.{{system}}.ci (max-jobs 0)" >&2 - echo "==> just check-remote: eval can take a few minutes; then remote logs or a store path" >&2 - out="$(nix build --option max-jobs 0 --print-build-logs --print-out-paths --no-link ".#checks.{{system}}.ci")" - echo "==> just check-remote: ok" >&2 - echo "${out}" + exec just remote-nix-build ".#checks.{{system}}.ci" "check-remote" # Same force-remote path for cargo-audit (also in checks.ci). +# Same ssh-ng keepalive and connection retry as check-remote. +# A derivation failure is not retried. audit-remote: #!/usr/bin/env bash set -euo pipefail @@ -62,14 +69,87 @@ audit-remote: echo "just audit-remote: refuse BUILD_LOCAL" >&2 exit 2 fi - echo "==> just audit-remote: nix build .#checks.{{system}}.cargo-audit (max-jobs 0)" >&2 - out="$(nix build --option max-jobs 0 --print-build-logs --print-out-paths --no-link ".#checks.{{system}}.cargo-audit")" - echo "==> just audit-remote: ok" >&2 - echo "${out}" + exec just remote-nix-build ".#checks.{{system}}.cargo-audit" "audit-remote" + +# One ssh-ng nix build. Retries a dropped connection. Does not retry a +# derivation that actually failed, and does not run nixos-rebuild. +remote-nix-build installable label: + #!/usr/bin/env bash + set -euo pipefail + if [[ -n "${BUILD_LOCAL:-}" ]]; then + echo "just {{label}}: refuse BUILD_LOCAL (that is laptop rustc)" >&2 + exit 2 + fi + installable="{{installable}}" + label="{{label}}" + echo "==> just ${label}: nix build ${installable} (max-jobs 0)" >&2 + echo "==> just ${label}: eval can take a few minutes; then remote logs or a store path" >&2 + echo "==> just ${label}: ssh-ng keepalives via NIX_SSHOPTS; connection drops retry; derivation failures do not" >&2 + + is_connection_drop() { + local log="$1" + # A finished remote builder (rustc, cargo fmt, and the like) is not a drop. + if grep -E -q 'builder failed with exit code|Reason: builder failed' "$log"; then + return 1 + fi + grep -E -q 'failed to start SSH connection|failed to start SSH master connection|unable to open connection to remote store|unexpected end-of-file|unexpected EOF|kex_exchange_identification|Connection reset by peer|Connection timed out|Connection refused|Broken pipe|closed by remote host|Timeout, server .+ not responding' "$log" + } + + max=3 + attempt=1 + log="" + outf="" + fifo="" + trap 'rm -f "${log:-}" "${outf:-}" "${fifo:-}"' EXIT + while [[ "$attempt" -le "$max" ]]; do + log="$(mktemp)" + outf="$(mktemp)" + fifo="$(mktemp -u)" + mkfifo "$fifo" + tee "$log" <"$fifo" >&2 & + tee_pid=$! + set +e + nix build --option max-jobs 0 --option warn-dirty false --print-build-logs --print-out-paths --no-link "$installable" >"$outf" 2>"$fifo" + rc=$? + set -e + wait "$tee_pid" || true + rm -f "$fifo" + fifo="" + if [[ "$rc" -eq 0 ]]; then + echo "==> just ${label}: ok" >&2 + cat "$outf" + rm -f "$log" "$outf" + log="" + outf="" + exit 0 + fi + if is_connection_drop "$log"; then + rm -f "$log" "$outf" + log="" + outf="" + if [[ "$attempt" -lt "$max" ]]; then + echo "==> just ${label}: connection failed (attempt ${attempt} of ${max}); retrying the nix build" >&2 + attempt=$((attempt + 1)) + sleep 2 + continue + fi + echo "==> just ${label}: connection failed after ${max} attempts" >&2 + exit 1 + fi + rm -f "$log" "$outf" + log="" + outf="" + echo "==> just ${label}: build failed (not a connection drop); not retrying" >&2 + exit "$rc" + done + echo "==> just ${label}: connection failed after ${max} attempts" >&2 + exit 1 # Offline RustSec audit of crates/Cargo.lock (advisory-db flake input). +# warn-dirty is off because this command runs after nix flake update +# and cargo update, while those lock files are still uncommitted. audit: - nix build --print-build-logs ".#checks.{{system}}.cargo-audit" + nix build --option warn-dirty false --print-build-logs ".#checks.{{system}}.cargo-audit" # Ban sha1/md5 crates in the lockfile (cargo-deny bans). deny: @@ -157,10 +237,10 @@ e2e: e2e-host: nix run ".#e2e-host" -# Publish static sites (apex/www from github:SurmountSystems/site, plus extra vhosts). -# Docs: docs/OPS.md, docs/EDGE_AND_TLS.md +# Apex/www from github:SurmountSystems/site, plus extra vhosts. Docs: docs/OPS.md, docs/EDGE_AND_TLS.md +# `just publish` publishes static sites (not a NixOS generation). [positional-arguments] -deploy *args: +publish *args: #!/usr/bin/env bash set -euo pipefail if [[ "${1:-}" == "--" ]]; then @@ -171,6 +251,28 @@ deploy *args: export SURMOUNT_PUBLIC_SITE_ROOT="${root}" exec nix run ".#surmount-deploy-static-sites" -- "$@" +# Default target is root@surmount-1. Extra arguments append after the host-local flag. +# This is the operator's real switch. Agents must not run `just deploy`. Agents run `just deploy-dry-run`. +[positional-arguments] +deploy *args: + #!/usr/bin/env bash + set -euo pipefail + if [[ "${1:-}" == "--" ]]; then + shift + 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. +[positional-arguments] +deploy-dry-run *args: + #!/usr/bin/env bash + set -euo pipefail + if [[ "${1:-}" == "--" ]]; then + shift + fi + exec nix run ".#surmount-deploy-host" -- --dry-run --target root@surmount-1 --host-local /home/hunter/.local/share/surmount/host-local "$@" + # Operator deploy driver (public tree sync + host-local checks + #mail-vps). # Docs: docs/deploy-host-local.md [positional-arguments] diff --git a/modules/arti-hidden-service.nix b/modules/arti-hidden-service.nix index e6e63f0..c75fd9e 100644 --- a/modules/arti-hidden-service.nix +++ b/modules/arti-hidden-service.nix @@ -7,10 +7,16 @@ # Absolute: HS private keys and identity state are deploy secrets on the # host only. Never put keys (plain or ciphertext) in git or module examples. # -# Default lean surfaces (Q-ARTI-3): management HTTP only. -# Do NOT onion-publish Stalwart admin/JMAP unless explicitly enabled. -# publishStalwartAdmin/Jmap flags are reserved; they do not yet add stanzas -# (no backend address options). Defaults stay false. +# Default lean surfaces (Q-ARTI-3): one reverse-proxy backend (management +# HTTP / local cleartext). Do NOT onion-publish Stalwart admin/JMAP unless +# explicitly enabled. publishStalwartAdmin/Jmap flags are reserved; they do +# not yet add stanzas (no backend address options). Defaults stay false. +# +# Per-site v3 onions (operator 2026-09-11): every public HTTP Host gets its +# own Arti onion service nickname and identity. Onion-Location for that Host +# is that Host's onion root, not /_o/{host} on a shared onion. Mail Hosts +# stay unmapped. Console keeps artiHiddenService.nickname so the already +# published management onion stays the services Host. # # Daemon start is OFF by default (startDaemon=false). enable=true installs # config/docs only. When startDaemon=true with the complete management-publish @@ -25,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.5.1 +# Version assumption: arti.toml shape matches Surmount-owned Arti 2.6.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 @@ -57,6 +63,31 @@ let hostPaths = import ./lib/host-paths.nix { inherit lib; }; localCleartext = import ./lib/local-cleartext.nix { inherit lib; }; + publicOnion = import ./lib/public-onion-sites.nix { inherit lib; }; + + sploraProxy = cfg.sploraProxy; + sploraPortalHost = + if sploraProxy.portalHost != "" then sploraProxy.portalHost else "splora.${cfg.primaryDomain}"; + sploraExtraOnionHosts = + if sploraProxy.enable then + lib.unique ( + (lib.concatMap (inst: inst.hosts or [ ]) (lib.attrValues sploraProxy.instances)) + ++ [ sploraPortalHost ] + ) + else + [ ]; + publicOnionSites = publicOnion.publicOnionSites { + primaryDomain = cfg.primaryDomain; + servicesHostname = cfg.servicesHostname; + mailHostname = cfg.mailHostname; + extraStaticHosts = lib.unique ((lib.attrNames ui.staticVhosts) ++ sploraExtraOnionHosts); + extraMailHostnames = ui.extraMailHostnames; + consoleNickname = hs.nickname; + }; + publicOnionNicknames = publicOnion.nicknamesJsonMap publicOnionSites; + publicOnionNicknamesJson = builtins.toJSON publicOnionNicknames; + publicOnionNicknameList = map (s: s.nickname) publicOnionSites; + publishedHostnamesDir = "${hs.stateDir}/published-hostnames"; # Prefer Surmount service-capable package when package is null so the happy # path is enable + host HS secrets (not silent stock client arti). @@ -118,6 +149,15 @@ let || builtins.match "[A-Za-z0-9._-]+:[0-9]+" hs.backendAddress != null || builtins.match "[[][0-9a-fA-F:]+[]]:[0-9]+" hs.backendAddress != null; + onionServiceStanzas = lib.concatMapStrings (s: '' + [onion_services."${s.nickname}"] + proxy_ports = [ + ["80", "${proxyTarget}"], + ["*", "destroy"] + ] + + '') publicOnionSites; + # Complete management-publish config. No private keys or .onion addresses. # state_dir points at onionServiceStateDir so HS identity + HS instance state # live under the deploy-secrets host path. cache_dir stays under stateDir. @@ -126,11 +166,16 @@ 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.5.1 config shape - # (nix/packages/arti-onion-service.nix; GitLab arti-v2.5.1). Not stock + # Version assumption: Surmount-owned Arti 2.6.0 config shape + # (nix/packages/arti-onion-service.nix; GitLab arti-v2.6.0). Not stock # nixpkgs 1.4.x lag. Keys: socks_listen, onion_services, proxy_ports. # Command: arti proxy -c /etc/surmount/arti.toml # + # One [onion_services] table per public site. Not per www Host, + # not per esplora subdomain, and not for mta-sts.. Same + # cleartext backend. Mail Hosts are not listed. Console nickname + # stays artiHiddenService.nickname. + # # NOTE: systemd unit active does not prove an onion is published. # Prefer pkgs.artiOnionService (Surmount overlay). Stock pkgs.arti is # client-default. Live Tor verify + operator HS keys remain residual. @@ -155,14 +200,22 @@ let console = "info" log_sensitive_information = false - # Lean default: one onion service reverse-proxying to management UI. + # Per-site v3 onions reverse-proxying to management UI cleartext. # Backend is cleartext TCP or unix: (not TLS). Stalwart admin/JMAP publish # options default false and do not add stanzas yet. - [onion_services."${hs.nickname}"] - proxy_ports = [ - ["80", "${proxyTarget}"], - ["*", "destroy"] - ] + ${onionServiceStanzas} + ''; + + hostnamePublishScript = pkgs.writeShellScript "surmount-arti-publish-hostnames" '' + set -eu + out=${lib.escapeShellArg publishedHostnamesDir} + mkdir -p "$out" + ${lib.concatMapStrings (s: '' + nick=${lib.escapeShellArg s.nickname} + if addr="$(${artiBin} hss -c /etc/surmount/arti.toml --nickname "$nick" onion-address)"; then + printf '%s\n' "$addr" > "$out/$nick" + fi + '') publicOnionSites} ''; in { @@ -237,6 +290,22 @@ in (start with alphanumeric; then alphanumeric, underscore, or hyphen). ''; } + { + assertion = lib.length publicOnionNicknameList == lib.length (lib.unique publicOnionNicknameList); + message = '' + surmount.artiHiddenService public HTTP onion nicknames must be unique + (two Hosts must not share an Arti nickname). + ''; + } + { + assertion = lib.all ( + n: builtins.match "[A-Za-z0-9][A-Za-z0-9_-]*" n != null + ) publicOnionNicknameList; + message = '' + Each per-site Arti onion nickname must be a simple Arti HS nickname + (start with alphanumeric; then alphanumeric, underscore, or hyphen). + ''; + } ]; warnings = @@ -260,12 +329,15 @@ in }; environment.etc."surmount/arti.toml".source = artiConfig; + environment.etc."surmount/onion-site-nicknames.json".text = publicOnionNicknamesJson; # Process cache under stateDir. HS state dir is operator deploy secrets: # do not auto-create onionServiceStateDir (ConditionPathIsDirectory gates daemon). + # published-hostnames holds public v3 addresses (not keys) for Onion-Location. systemd.tmpfiles.rules = [ "d ${hs.stateDir} 0750 surmount-arti surmount-arti - -" "d ${hs.stateDir}/cache 0750 surmount-arti surmount-arti - -" + "d ${publishedHostnamesDir} 0750 surmount-arti surmount-arti - -" ]; # Oneshot marker: enable=true without startDaemon does not touch multi-user @@ -318,6 +390,9 @@ in User = "surmount-arti"; Group = "surmount-arti"; ExecStart = "${artiBin} proxy -c /etc/surmount/arti.toml"; + # Addresses are public; never write keys here. Dash prefix: a missing + # nickname must not take down the publisher. + ExecStartPost = "-${hostnamePublishScript}"; Restart = "on-failure"; RestartSec = "10s"; # state_dir is onionServiceStateDir (HS identity + instance state). @@ -360,6 +435,8 @@ in publishStalwartJmap=${if hs.publishStalwartJmap then "true" else "false"} package=${if pkg != null then pkg.pname or "arti" else "missing"} configKind=management-publish + publicOnionSiteCount=${toString (lib.length publicOnionSites)} + publishedHostnamesDir=${publishedHostnamesDir} ''; }; } diff --git a/modules/lib/onion-sites.nix b/modules/lib/onion-sites.nix new file mode 100644 index 0000000..687c2fb --- /dev/null +++ b/modules/lib/onion-sites.nix @@ -0,0 +1,94 @@ +# Public HTTP Host -> Arti HS nickname helpers (per-site v3 onions). +# HS private keys stay on the host under onionServiceStateDir. This file +# never embeds .onion addresses or key material. + +{ lib }: +let + inherit (lib) + filter + hasPrefix + hasSuffix + stringLength + toLower + unique + ; + + normalizeHost = h: toLower (lib.removeSuffix "." (lib.trim h)); + + nicknameCharsetOk = n: builtins.match "[A-Za-z0-9][A-Za-z0-9_-]*" n != null; + + # Arti nickname: start alphanumeric, then alnum / _ / -. Host dots become '-'. + hostToNickname = + host: + let + n = normalizeHost host; + slug = lib.replaceStrings [ "." ] [ "-" ] n; + raw = "site-${slug}"; + in + if nicknameCharsetOk raw && stringLength raw <= 96 then raw else null; + + isMailHost = + { + host, + primaryDomain, + mailHostname, + extraMailHostnames, + }: + let + h = normalizeHost host; + primary = normalizeHost primaryDomain; + mail = normalizeHost mailHostname; + extras = map normalizeHost extraMailHostnames; + in + (h != "" && h == mail) || (primary != "" && h == "mail.${primary}") || builtins.elem h extras; + + publicHttpHosts = + { + primaryDomain, + servicesHostname, + mailHostname, + extraMailHostnames ? [ ], + staticVhostHosts ? [ ], + }: + let + primary = normalizeHost primaryDomain; + services = normalizeHost servicesHostname; + skip = + h: + isMailHost { + host = h; + inherit primaryDomain mailHostname extraMailHostnames; + }; + apex = if primary == "" then [ ] else [ primary ]; + www = if primary == "" then [ ] else [ "www.${primary}" ]; + svc = if services == "" then [ ] else [ services ]; + mta = if primary == "" then [ ] else [ "mta-sts.${primary}" ]; + extras = map normalizeHost staticVhostHosts; + ordered = apex ++ www ++ svc ++ mta ++ extras; + cleaned = filter (h: h != "" && !(hasPrefix "." h) && !(skip h)) ordered; + in + unique cleaned; + + # Services console keeps artiHiddenService.nickname (existing HS identity). + nicknameForHost = + { + host, + servicesHostname, + consoleNickname, + }: + let + h = normalizeHost host; + services = normalizeHost servicesHostname; + in + if h != "" && h == services then consoleNickname else hostToNickname h; +in +{ + inherit + normalizeHost + nicknameCharsetOk + hostToNickname + isMailHost + publicHttpHosts + nicknameForHost + ; +} diff --git a/modules/lib/public-onion-sites.nix b/modules/lib/public-onion-sites.nix new file mode 100644 index 0000000..dd96be1 --- /dev/null +++ b/modules/lib/public-onion-sites.nix @@ -0,0 +1,103 @@ +# Public sites that each get one Arti v3 onion. +# Mail Hosts stay unmapped. www Hosts do not get a second onion. +# Esplora subdomain Hosts do not get an onion. Networks are paths +# on splora.. HS private keys never appear here. +# +# Console (servicesHostname) keeps artiHiddenService.nickname so the +# already-published management onion identity stays that site. +# Other Hosts use nickname site-. + +{ lib }: +let + inherit (lib) toLower; + + nicknameSlug = + host: + let + lower = toLower host; + chars = lib.stringToCharacters lower; + mapped = map (c: if c == "." then "-" else c) chars; + in + lib.concatStrings mapped; + + siteNickname = + { + host, + servicesHostname, + consoleNickname, + }: + if toLower host == toLower servicesHostname then consoleNickname else "site-${nicknameSlug host}"; + + publicHttpHosts = + { + primaryDomain, + servicesHostname, + mailHostname, + extraStaticHosts, + extraMailHostnames, + }: + let + apex = toLower primaryDomain; + www = "www.${apex}"; + services = toLower servicesHostname; + mta = "mta-sts.${apex}"; + mail = toLower mailHostname; + extraMail = map toLower extraMailHostnames; + isMail = h: h == mail || builtins.elem h extraMail; + # www is the same site as the apex. Esplora names are not sites. + # mta-sts. is mail policy. It does not belong on Tor. + skipOnion = h: isMail h || h == mta || lib.hasPrefix "www." h || lib.hasInfix ".esplora." h; + extras = lib.filter (h: h != "" && !skipOnion h) (map toLower extraStaticHosts); + base = lib.filter (h: h != "" && !skipOnion h) [ + apex + www + services + mta + ]; + in + lib.unique (base ++ extras); + + publicOnionSites = + { + consoleNickname, + servicesHostname, + primaryDomain, + mailHostname, + extraStaticHosts, + extraMailHostnames, + }: + map + (host: { + inherit host; + nickname = siteNickname { + inherit host servicesHostname consoleNickname; + }; + }) + (publicHttpHosts { + inherit + primaryDomain + servicesHostname + mailHostname + extraStaticHosts + extraMailHostnames + ; + }); + + nicknamesJsonMap = + sites: + lib.listToAttrs ( + map (s: { + name = s.host; + value = s.nickname; + }) sites + ); +in +{ + inherit + nicknameSlug + siteNickname + publicHttpHosts + publicOnionSites + nicknamesJsonMap + ; +} diff --git a/modules/management-ui.nix b/modules/management-ui.nix index 34744e4..f7b04ed 100644 --- a/modules/management-ui.nix +++ b/modules/management-ui.nix @@ -87,6 +87,9 @@ let }; in builtins.toJSON (lib.mapAttrs toEntry splora.instances); + # One DNS name for the product. Not a fifth indexer. Empty option = splora.. + sploraPortalHost = + if splora.portalHost != "" then splora.portalHost else "splora.${cfg.primaryDomain}"; # Console link to domain C vault: # 1) explicit managementUi.vaultwardenUrl wins @@ -218,6 +221,8 @@ let # When Arti HS module is on, expose state dir so the binary can walk for # hostname material (Arti layout may nest under keystore). Never invent. ++ optional hs.enable "SURMOUNT_ONION_HS_STATE_DIR=${hs.onionServiceStateDir}" + ++ optional hs.enable "SURMOUNT_ONION_SITE_NICKNAMES_FILE=/etc/surmount/onion-site-nicknames.json" + ++ optional hs.enable "SURMOUNT_ONION_PUBLISHED_HOSTNAMES_DIR=${hs.stateDir}/published-hostnames" # Domain C Vaultwarden console link (operator-published, proxy public URL, or loopback). # Never ADMIN_TOKEN. Empty = residual not configured in UI. ++ optional (effectiveVaultwardenUrl != "") "SURMOUNT_VAULTWARDEN_URL=${effectiveVaultwardenUrl}" @@ -232,6 +237,7 @@ let "SURMOUNT_SPLORA_SOCKET_DIR=${splora.socketDir}" "SURMOUNT_SPLORA_QUEUE_SOCKET=${splora.queueSocket}" "SURMOUNT_SPLORA_QUEUE_PATH=${splora.queuePath}" + "SURMOUNT_SPLORA_PORTAL_HOST=${sploraPortalHost}" # systemd Environment= without wrapping quotes treats inner " as syntax # and strips them. Single-quoted KEY=VALUE keeps JSON in process env. "'SURMOUNT_SPLORA_INSTANCES=${sploraInstancesJson}'" @@ -325,7 +331,10 @@ let ++ staticVhostReadPaths # Allow reading hostname material under HS state dir when Arti is enabled # (nested hostname files; missing path is ignored by systemd ReadOnlyPaths). - ++ lib.optionals hs.enable [ hs.onionServiceStateDir ] + ++ lib.optionals hs.enable [ + hs.onionServiceStateDir + "${hs.stateDir}/published-hostnames" + ] ); # EnvironmentFile expects KEY=value lines (e.g. SURMOUNT_SESSION_SECRET=...). @@ -753,8 +762,9 @@ in assertion = !splora.enable || splora.instances != { }; message = '' surmount.sploraProxy.enable is true but instances is empty. - Configure Host -> /run/splora/.http.sock (mainnet, - testnet3, testnet4, mutinynet, liquid). Fail-closed. + Configure mainnet, testnet3, testnet4, mutinynet, and/or liquid + sockets under /run/splora/.http.sock. Paths are on the + portal Host. Fail-closed. ''; } { @@ -827,6 +837,7 @@ in extraGroups = [ "surmount-tls" ] + ++ lib.optionals hs.enable [ "surmount-arti" ] ++ lib.optionals (splora.enable && sploraServiceOn) [ sploraGroupName ]; description = "Surmount management UI"; }; diff --git a/modules/options.nix b/modules/options.nix index fba52ff..cff5575 100644 --- a/modules/options.nix +++ b/modules/options.nix @@ -915,6 +915,7 @@ in # Arti onion / hidden service (REQUIRED product surface). # HS private keys are deploy secrets on the host only (never in git). # Generated arti.toml is a management-publish config (onion + rproxy). + # One [onion_services] nickname per public HTTP Host. Mail Hosts unmapped. artiHiddenService = { enable = mkEnableOption "Arti onion/hidden service for Surmount backends"; @@ -1004,7 +1005,8 @@ in default = "/run/surmount-secrets/arti/onion-service"; description = '' Host path for onion service identity and HS instance state - (arti storage.state_dir). Deploy secrets on host only; never example + (arti storage.state_dir). One Arti keystore root for every public + HTTP Host nickname. Deploy secrets on host only; never example private key material in repo. Daemon unit requires this directory to exist (ConditionPathIsDirectory) and to be writable by the surmount-arti service user (e.g. chown surmount-arti:surmount-arti, @@ -1793,10 +1795,11 @@ in type = types.bool; default = false; description = '' - When true, management-ui reverse-proxies configured public Hosts to - Unix sockets /run/splora/.http.sock (HTTP/1.1). NIP-98 - stays in splora. No edge API keys. SURMOUNT_SPLORA_PROXY=1. + When true, management-ui reverse-proxies paths on the portal Host + to Unix sockets /run/splora/.http.sock (HTTP/1.1). + NIP-98 stays in splora. No edge API keys. SURMOUNT_SPLORA_PROXY=1. Sample host stays off. Do not enable modules/web.nix for this. + One public Host. Not per-network esplora Hosts. ''; }; @@ -1835,8 +1838,12 @@ in hosts = mkOption { type = types.listOf types.str; default = [ ]; - example = [ "esplora.surmount.systems" ]; - description = "Public Host names for this instance (lowercased at runtime)."; + example = [ ]; + description = '' + Optional extra public Host names (lowercased at runtime). + Leave empty. Network paths are on portalHost, not a + per-network Host. + ''; }; socket = mkOption { type = types.str; @@ -1851,19 +1858,30 @@ in ); default = { }; example = { - mainnet = { - hosts = [ "esplora.surmount.systems" ]; - socket = "/run/splora/mainnet.http.sock"; - }; - testnet3.hosts = [ "testnet3.esplora.surmount.systems" ]; - testnet4.hosts = [ "testnet4.esplora.surmount.systems" ]; - mutinynet.hosts = [ "mutinynet.esplora.surmount.systems" ]; - liquid.hosts = [ "liquid.esplora.surmount.systems" ]; + testnet3.socket = "/run/splora/testnet3.http.sock"; + testnet4.socket = "/run/splora/testnet4.http.sock"; + mutinynet.socket = "/run/splora/mutinynet.http.sock"; + liquid.socket = "/run/splora/liquid.http.sock"; }; description = '' - Instance name -> Host list and HTTP Unix socket. Allowed names: - mainnet, testnet3, testnet4, mutinynet, liquid. Host-local. - Emitted as SURMOUNT_SPLORA_INSTANCES JSON when enable. + Instance name -> HTTP Unix socket. Allowed names: mainnet, + testnet3, testnet4, mutinynet, liquid. Empty hosts is normal: + paths on portalHost select the socket. Omit mainnet while that + indexer is off. Host-local. Emitted as SURMOUNT_SPLORA_INSTANCES + JSON when enable. + ''; + }; + + portalHost = mkOption { + type = types.str; + default = ""; + example = "splora.surmount.systems"; + description = '' + One public Host (one DNS name). Not an indexer. Empty = + splora.. Emitted as SURMOUNT_SPLORA_PORTAL_HOST + when sploraProxy.enable. GET / lists same-host paths + (/api/, /testnet/, /testnet4/, /signet/, /mutinynet/, /liquid/). + Not an explorer. The Rust explorer UI is Splora's, not this repo. ''; }; diff --git a/modules/splora-indexer.nix b/modules/splora-indexer.nix index aac3f3a..e97b2ec 100644 --- a/modules/splora-indexer.nix +++ b/modules/splora-indexer.nix @@ -1,6 +1,6 @@ # Wrap imported services.splora for one remote JSON-RPC indexer. # Host-local single knob. Sample host stays off. Cookie bytes never belong here. -# Imported instance options (9481e4c) already have cookieFile, daemonRpcAddr, +# Imported instance options (be3603d) already have cookieFile, daemonRpcAddr, # jsonrpcImport, publicHealth, daemonDir = null, db cache 24, and # httpSocketFile /run/splora/${name}.http.sock. This wrap still maps one # instance, asserts cookie path charset, and does not start five indexers. diff --git a/nix/packages/arti-onion-service.nix b/nix/packages/arti-onion-service.nix index 26fb9e3..7f74e86 100644 --- a/nix/packages/arti-onion-service.nix +++ b/nix/packages/arti-onion-service.nix @@ -2,14 +2,14 @@ # # Why not stock pkgs.arti from the host nixpkgs channel? # That channel packages Arti 1.4.2 (client-default features only). Upstream -# stable is far ahead (2.5.1 as of 2026-08-03; re-check 2026-08-27, no 2.6 -# on crates.io). Greenfield Surmount pins the current engine here, same +# stable is far ahead (2.6.0, GitLab tag arti-v2.6.0). Greenfield Surmount +# pins the current engine here, same # spirit as Stalwart: channel lag is not a reason to ship old Tor for the # required HS surface. # # Why not only overrideAttrs on host pkgs.arti? # Feature-only overrides inherit the channel version (1.4.2). This expression -# owns the upstream tag (arti-v2.5.1), features, and Surmount capability +# owns the upstream tag (arti-v2.6.0), features, and Surmount capability # passthru. # # Why source build (not binary FOD like Stalwart)? @@ -26,7 +26,7 @@ # works again, cargoHash can replace the artiUnstable.cargoDeps handoff. # # Toolchain: -# Arti 2.5.1 MSRV is 1.91. Older host channels had rustc < 1.91 and +# Arti 2.6.0 MSRV is 1.91. Older host channels had rustc < 1.91 and # rustPackages_* tops out at 1.89. The flake passes a rustPlatform whose # rustc/cargo meet MSRV (from the nixpkgs-rust input), without rebasing # the whole host OS channel. @@ -36,6 +36,8 @@ # expand. arti-client's matching feature pulls keymgr as needed for HS # identity. Do not enable the arti binary experimental top-level `keymgr` # unless a future residual proves it is required beyond onion-service-service. +# Counter Galois Onion is always on in 2.6.0, so this package does not pass +# that flag and does not use full. # # Never put HS private keys in this expression or the repo. # Live Tor publish still requires operator host keys + network verify (residual). @@ -50,7 +52,7 @@ # 5. Re-read upstream CHANGELOG / example config for TOML shape drift. # 6. Keep passthru.surmountOnionServiceCapable = true. # -# Modelled on nixpkgs-unstable pkgs/by-name/ar/arti/package.nix (2.5.1 shape: +# Modelled on nixpkgs-unstable pkgs/by-name/ar/arti/package.nix (2.6.0 shape: # buildAndTestSubdir, tokio-util postPatch, ARTI_FS_DISABLE_PERMISSION_CHECKS) # with Surmount pname, lean HS feature, and capability passthru. # @@ -73,7 +75,7 @@ let # Single source of truth for pin, cargoDeps assert, and GitLab tag. - version = "2.5.1"; + version = "2.6.0"; in assert lib.assertMsg ( artiUnstable != null @@ -91,7 +93,7 @@ rustPlatform.buildRustPackage (finalAttrs: { owner = "core"; repo = "arti"; tag = "arti-v${finalAttrs.version}"; - hash = "sha256-fPobYu2ADTeIwpeXyxQKh5yr1zw+yMQfqTkiZMMd8YY="; + hash = "sha256-ukGplnZz1O1Djh12COKk8FL/3rLmmWGyl0b816wRWBE="; }; # Working around a bug in cargo that appears with cargo-auditable, see @@ -117,7 +119,7 @@ rustPlatform.buildRustPackage (finalAttrs: { checkFeatures = [ "onion-service-service" ]; checkFlags = [ - # problematic test that hangs the build (same skip as nixpkgs 2.5.1) + # problematic test that hangs the build (same skip as nixpkgs 2.6.0) "--skip=reload_cfg::test::watch_single_file" ]; diff --git a/nix/packages/grok-oss.nix b/nix/packages/grok-oss.nix index 8aeccf2..a5065d0 100644 --- a/nix/packages/grok-oss.nix +++ b/nix/packages/grok-oss.nix @@ -1,14 +1,36 @@ # Consume github:SurmountSystems/grok-oss flake package (bin/grok-oss). -# Call as: pkgs.callPackage ./grok-oss.nix { inherit grokOssFlake system; } +# Call as: pkgs.callPackage ./grok-oss.nix { inherit grokOssFlake system rustToolchain; } # Fail-closed: missing packages.grok-oss throws. Do not enable the NixOS # module without this package. +# pkgs stays the host channel for C libraries. rustToolchain is +# nixpkgs-rust (rustc 1.99.0). Do not use host pkgs.rustc / pkgs.cargo. { grokOssFlake, system, + pkgs, + lib, + runCommand, + rustToolchain, }: let - pkgsFor = grokOssFlake.packages.${system} or { }; + # Rewrite leftover stdenv.isLinux / stdenv.isDarwin to hostPlatform + # before import. A tip that already uses hostPlatform is unchanged. + # Do not evaluate grokOssFlake.packages (the old form warns). + patchedSrc = runCommand "grok-oss-flake-hostplatform" { } '' + cp -a ${grokOssFlake.outPath} "$out" + chmod -R u+w "$out" + find "$out" -type f -name '*.nix' -print0 | xargs -0 sed -i \ + -e 's/pkgs\.stdenv\.isLinux/pkgs.stdenv.hostPlatform.isLinux/g' \ + -e 's/pkgs\.stdenv\.isDarwin/pkgs.stdenv.hostPlatform.isDarwin/g' \ + -e 's/stdenv\.isLinux/stdenv.hostPlatform.isLinux/g' \ + -e 's/stdenv\.isDarwin/stdenv.hostPlatform.isDarwin/g' + ''; + craneLib = (grokOssFlake.inputs.crane.mkLib pkgs).overrideToolchain rustToolchain; + built = import "${patchedSrc}/flake/grok-oss.nix" { + inherit pkgs lib craneLib; + self = grokOssFlake; + }; in -pkgsFor.grok-oss or pkgsFor.default +built.grok-oss or built.default or (throw "grok-oss flake input has no packages.${system}.grok-oss (fail-closed; do not set surmount.grokOss.enable without the package)") diff --git a/nix/packages/management-ui.nix b/nix/packages/management-ui.nix index 451cdf0..bdd0792 100644 --- a/nix/packages/management-ui.nix +++ b/nix/packages/management-ui.nix @@ -3,7 +3,7 @@ # or via the flake overlay which injects craneLib. # # Leptos 0.8 SSR MSRV is rustc 1.88+. Toolchain comes from -# nix/rust-toolchain.nix (nixpkgs-rust; 1.97.1 until nixpkgs has 1.98). +# nix/rust-toolchain.nix (nixpkgs-rust rustc 1.99.0). { lib, @@ -42,12 +42,7 @@ let cargoExtraArgs = "-p surmount-management-ui"; }; - cargoArtifacts = craneLib'.buildDepsOnly ( - commonArgs - // { - # deps-only still needs the lockfile present in src - } - ); + cargoArtifacts = craneLib'.buildDepsOnly commonArgs; in craneLib'.buildPackage ( commonArgs diff --git a/nix/packages/stalwart-cli.nix b/nix/packages/stalwart-cli.nix index 6ea0a84..66b248a 100644 --- a/nix/packages/stalwart-cli.nix +++ b/nix/packages/stalwart-cli.nix @@ -17,17 +17,17 @@ }: let - version = "1.0.12"; + version = "1.0.13"; sources = { x86_64-linux = { url = "https://github.com/stalwartlabs/cli/releases/download/v${version}/stalwart-cli-x86_64-unknown-linux-gnu.tar.xz"; - hash = "sha256-4rsFRQmqrDEfE/9PngnDjGBxld4ulzXPhM/G7kd2paI="; + hash = "sha256-G4UJt2ft0aF2k+CStRjEFhDsS3JPTmLEYb0ozUDGafc="; dir = "stalwart-cli-x86_64-unknown-linux-gnu"; }; aarch64-linux = { url = "https://github.com/stalwartlabs/cli/releases/download/v${version}/stalwart-cli-aarch64-unknown-linux-gnu.tar.xz"; - hash = "sha256-IRM0dLiAyWg2mZRkGXcoiW8X5yOpmWkOe/XQQjIyQNQ="; + hash = "sha256-ZfS25SjYQ4iVP8mNlGM53YJG+wO/TKqmTh2bN49S2JI="; dir = "stalwart-cli-aarch64-unknown-linux-gnu"; }; }; diff --git a/nix/packages/stalwart-mail.nix b/nix/packages/stalwart-mail.nix index 60b3316..07892ad 100644 --- a/nix/packages/stalwart-mail.nix +++ b/nix/packages/stalwart-mail.nix @@ -41,17 +41,20 @@ }: let - version = "0.16.15"; + # Checked 2026-10-03: GitHub tag v0.16.24. Each 0.16.x note says an + # upgrade from 0.16.x replaces the binary. UPGRADING/v0_16.md is the + # 0.15-and-below migration, which this pin already passed. + version = "0.16.24"; # Release asset hashes (gnu libc). musl variants exist upstream if needed. sources = { x86_64-linux = fetchurl { url = "https://github.com/stalwartlabs/stalwart/releases/download/v${version}/stalwart-x86_64-unknown-linux-gnu.tar.gz"; - hash = "sha256-byPGIX8PlxC6/eiJt++8NJx8+mE8SxS4s2UkV5k3NnY="; + hash = "sha256-UTkmkdSrZ4ZOhK9SFSd9vUoGn/r5oM65SgdGLiJIeEM="; }; aarch64-linux = fetchurl { url = "https://github.com/stalwartlabs/stalwart/releases/download/v${version}/stalwart-aarch64-unknown-linux-gnu.tar.gz"; - hash = "sha256-SArFIesOCMxntsI3LN9QT50Jd6uUba0ILdrMMDdDfiw="; + hash = "sha256-TgJZcaZZHXo/uBiDxVbQOPokbugWTr2govpoLl0xdlA="; }; }; diff --git a/nix/packages/stalwart-spam-filter.nix b/nix/packages/stalwart-spam-filter.nix index a814f00..9924d93 100644 --- a/nix/packages/stalwart-spam-filter.nix +++ b/nix/packages/stalwart-spam-filter.nix @@ -14,16 +14,16 @@ stdenvNoCC.mkDerivation (finalAttrs: { pname = "stalwart-spam-filter"; - version = "3.0.0"; + version = "3.0.2"; srcToml = fetchurl { url = "https://github.com/stalwartlabs/spam-filter/releases/download/v${finalAttrs.version}/spam-filter.toml"; - hash = "sha256-SsA1KDJA5Xcq0eQiumipy8uYW43obcTIjbtgBhtRvQc="; + hash = "sha256-MridVW9VRZkg+tLL+pS+Tmq4ObB22ptwObGvRF0RDnw="; }; srcRules = fetchurl { url = "https://github.com/stalwartlabs/spam-filter/releases/download/v${finalAttrs.version}/spam-filter-rules.json.gz"; - hash = "sha256-xObN2h5oG2HtWWFuOJSp8rv/jJT8ugFCfDC1sLMP7vo="; + hash = "sha256-HTpm9vqaMvcRxbOnOFNmPv8nyZSkbo9qDbZKmypYD1I="; }; dontUnpack = true; diff --git a/nix/packages/stalwart-webui.nix b/nix/packages/stalwart-webui.nix index 484f521..84d41a2 100644 --- a/nix/packages/stalwart-webui.nix +++ b/nix/packages/stalwart-webui.nix @@ -14,11 +14,11 @@ stdenvNoCC.mkDerivation (finalAttrs: { pname = "stalwart-webui"; - version = "1.0.7"; + version = "1.0.11"; src = fetchurl { url = "https://github.com/stalwartlabs/webui/releases/download/v${finalAttrs.version}/webui.zip"; - hash = "sha256-FCAod80zjFsXkgYKzYdUR7NFBvbyhv0yglfovQ5HV0o="; + hash = "sha256-1n5oyroCAkMBx52p8V/kZd6b9Tgs7lS9+cMJqXtBeaU="; }; dontUnpack = true; diff --git a/nix/packages/surmount-dns-zone.nix b/nix/packages/surmount-dns-zone.nix index 78653d1..efa71b8 100644 --- a/nix/packages/surmount-dns-zone.nix +++ b/nix/packages/surmount-dns-zone.nix @@ -36,7 +36,7 @@ craneLib'.buildPackage ( commonArgs // { inherit cargoArtifacts; - # Hermetic mock tests only. Never live Namecheap as CI green. + # Hermetic mock tests plus local HTTP listener. Never live Namecheap as CI green. cargoTestExtraArgs = "-p surmount-dns-zone"; meta = { description = "Namecheap forward-zone helper (A/AAAA/TXT/CAA/MX). Dry-run default."; diff --git a/nix/packages/vandelay.nix b/nix/packages/vandelay.nix index fb3c4e4..c8cabfa 100644 --- a/nix/packages/vandelay.nix +++ b/nix/packages/vandelay.nix @@ -18,17 +18,17 @@ }: let - version = "1.0.7"; + version = "1.0.11"; sources = { x86_64-linux = { url = "https://github.com/stalwartlabs/vandelay/releases/download/v${version}/vandelay-x86_64-unknown-linux-gnu.tar.gz"; - hash = "sha256-6maiYWqAfYrqb1DBbg0xk/4P4kOAzkn6eEsNACnz1v4="; + hash = "sha256-vXaqse8suZAFjXMe8b8UpKOVfh8HsaEibbFHiic0BWY="; dir = "vandelay-x86_64-unknown-linux-gnu"; }; aarch64-linux = { url = "https://github.com/stalwartlabs/vandelay/releases/download/v${version}/vandelay-aarch64-unknown-linux-gnu.tar.gz"; - hash = "sha256-SvpCtDh94+dCDh6GyhmMW62UOwVHmxRNU6FomaFzY6A="; + hash = "sha256-wBNbenTAzN6xHSl9LlvhMd/lNCgxY88wY1IblB8wxY4="; dir = "vandelay-aarch64-unknown-linux-gnu"; }; }; diff --git a/nix/rust-toolchain.nix b/nix/rust-toolchain.nix index 7aac81c..2f785db 100644 --- a/nix/rust-toolchain.nix +++ b/nix/rust-toolchain.nix @@ -1,6 +1,6 @@ -# Crane / `nix develop` rustc. Pass nixpkgs-rust (nixos-unstable), not the -# mail-host channel: nixos-26.05 is still 1.95. Locked unstable is 1.97.1 -# (2026-08-26). Rust 1.98.0 is out (2026-08-20) but not in this nixpkgs yet. +# Crane / `nix develop` rustc. Pass nixpkgs-rust, not the mail-host channel. +# nixos-26.05 rustc is still 1.95.0. nixpkgs-rust is pinned to c9f19b6 +# (rustc 1.99.0, pkgs.arti 2.6.0), not floating nixos-unstable. { pkgs }: pkgs.symlinkJoin { name = "surmount-rust-${pkgs.rustc.version}"; diff --git a/tests/arti-onion-package.nix b/tests/arti-onion-package.nix index 14c626c..89c9942 100644 --- a/tests/arti-onion-package.nix +++ b/tests/arti-onion-package.nix @@ -41,9 +41,9 @@ let # Named contract: Surmount owns current upstream Arti (not nixpkgs 1.4.2 lag). t0 = - assert pkg.version == "2.5.1"; + assert pkg.version == "2.6.0"; assert !(lib.hasPrefix "1.4" pkg.version); - "t0-version-2.5.1-ok"; + "t0-version-2.6.0-ok"; t1 = assert builtins.elem "onion-service-service" buildFeatures; diff --git a/tests/mail.nix b/tests/mail.nix index a456557..d3eb773 100644 --- a/tests/mail.nix +++ b/tests/mail.nix @@ -1,12 +1,24 @@ -# NixOS VM test sketch: Stalwart + management UI come up; health endpoint works. -# This is intentionally lighter than upstream's full SMTP/IMAP test so flake -# check stays practical. Expand once TLS/ACME secrets are test-friendly. +# NixOS VM smoke: Stalwart and the management UI come up, health and the +# config domain list still answer, then one mailbox is created the way the +# services console does (POST /api/v1/accounts). SMTP on port 25 must accept +# that recipient (RCPT 250 and DATA 250) and refuse a local-part that was +# not created (RCPT 5xx, no DATA). # -# Invoke via: nix build .#checks.x86_64-linux.mail-vm-test -# Or flake check (may take a while on first run). +# This is SMTP acceptance for that recipient. It is not an IMAP read, and +# it is not proof a later queue runner kept the message. The guest generates +# the recovery pin and the API token at boot. Those values are not in this +# file. Domain and permanent admin CLI setup is not the mailbox create. +# +# Guest directory setup and the one SMTP dialogue are Rust programs in +# surmount-stalwart-ops (vm-lab-directory-setup and vm-lab-smtp-accept). +# The testScript below stays Python because that runner is upstream NixOS. +# +# Invoke later via: nix build .#checks.x86_64-linux.mail-vm-test +# This file does not run that build. { pkgs, + # Passed by flake.nix. Unused in this expression. lib, nixosTest, surmountModules, @@ -15,16 +27,20 @@ let # Prefer testers.runNixOSTest on newer nixpkgs; fall back to nixosTest. runTest = if pkgs.testers ? runNixOSTest then pkgs.testers.runNixOSTest else nixosTest; + createBody = pkgs.writeText "mail-vm-create-account.json" '' + {"name":"recvbox","description":"vm receive proof","role":"user"} + ''; + # Curl -w token. Defined outside the test script on purpose. + curlStatus = "%{http_code}"; + stalwartOps = pkgs.surmount-stalwart-ops; + tokenPath = "/var/lib/surmount/secrets/ui/stalwart-api-token"; + recoveryPath = "/var/lib/surmount/secrets/stalwart/recovery.env"; in runTest { - name = "surmount-mail-smoke"; + name = "surmount-mail-ui-smtp-accept"; nodes.server = - { - config, - pkgs, - ... - }: + { pkgs, ... }: { imports = surmountModules; @@ -41,6 +57,11 @@ runTest { listenAddress = "127.0.0.1"; # Distinct from Stalwart 0.16 default HTTP :8080. port = 8090; + # Lab VM only. The module already has this switch. authMode stays + # off on loopback. No session secret and no token string in Nix. + directory = "stalwart"; + allowDirectoryUnauthenticated = true; + stalwartTokenPath = tokenPath; }; web.enable = false; # skip ACME in VM hardening.enable = false; @@ -52,25 +73,119 @@ runTest { environment.systemPackages = [ pkgs.curl + pkgs.stalwart-cli + stalwartOps ]; + # Pin recovery admin before the first Stalwart start. The password is + # generated in the guest. It is not written into the Nix store. + # Do not set STALWART_RECOVERY_MODE: that disables the MTA. + systemd.services.surmount-vm-lab-recovery-pin = { + description = "Generate lab VM Stalwart recovery pin"; + requiredBy = [ "stalwart-mail.service" ]; + before = [ "stalwart-mail.service" ]; + after = [ "local-fs.target" ]; + path = [ pkgs.coreutils ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + script = '' + set -eu + d=/var/lib/surmount/secrets/stalwart + mkdir -p "$d" + f=${recoveryPath} + if [ -s "$f" ]; then + exit 0 + fi + hex=$(od -An -N24 -tx1 /dev/urandom | tr -d ' \n') + len=$(printf %s "$hex" | wc -c) + if [ "$len" -lt 32 ]; then + echo "recovery pin generation failed" >&2 + exit 1 + fi + umask 077 + printf 'STALWART_RECOVERY_ADMIN=admin:%s\n' "$hex" > "$f" + chmod 0600 "$f" + ''; + }; + + # Started only when first-boot HTTP is on ::1 and not on 127.0.0.1. + # The UI module always calls http://127.0.0.1:8080. + systemd.services.surmount-vm-lab-http-forward = { + description = "Forward Stalwart management HTTP onto 127.0.0.1:8080"; + after = [ "stalwart-mail.service" ]; + serviceConfig = { + Type = "simple"; + ExecStart = "${pkgs.socat}/bin/socat TCP-LISTEN:8080,bind=127.0.0.1,reuseaddr,fork TCP6:[::1]:8080"; + }; + }; + # Required by some modules even if unused system.stateVersion = "26.05"; }; testScript = '' + def smtp_rcpt(text): + for line in text.splitlines(): + if line.startswith("RCPT "): + return int(line.split()[1]) + raise AssertionError(text) + server.start() server.wait_for_unit("stalwart-mail.service") - server.wait_for_unit("surmount-management-ui.service") + server.wait_for_unit("surmount-vm-lab-recovery-pin.service") server.wait_for_open_port(25) + # Stalwart 0.16 first-boot default HTTP management. + server.wait_for_open_port(8080) + server.succeed("test -s ${recoveryPath}") + server.succeed("test $(stat -c %a ${recoveryPath}) = 600") + + prepared = server.succeed( + "PATH=/run/current-system/sw/bin:$PATH ${stalwartOps}/bin/vm-lab-directory-setup" + ) + assert "domain-ready" in prepared, prepared + assert "token-ready" in prepared, prepared + server.succeed("test -s ${tokenPath}") + server.succeed("test $(stat -c %a ${tokenPath}) = 600") + + server.succeed("systemctl reset-failed surmount-management-ui.service || true") + server.succeed("systemctl start surmount-management-ui.service") + server.wait_for_unit("surmount-management-ui.service") # Surmount management UI (default 8090) server.wait_for_open_port(8090) - # Stalwart 0.16 first-boot default HTTP management - server.wait_for_open_port(8080) out = server.succeed("curl -fsS http://127.0.0.1:8090/health") assert "ok" in out, out out = server.succeed("curl -fsS http://127.0.0.1:8090/api/v1/domains") assert "example.test" in out, out + + # Same route as the services console. No password field: a literal + # password would be a secret in git, and inbound SMTP does not use it. + request = server.succeed("cat ${createBody}") + assert '"name":"recvbox"' in request, request + assert "password" not in request.lower(), request + created = server.succeed( + "curl -sS -H content-type:application/json --data-binary @${createBody} " + + "-w '\\nHTTP_CODE:${curlStatus}\\n' http://127.0.0.1:8090/api/v1/accounts" + ) + assert "HTTP_CODE:200" in created, created + assert "recvbox@example.test" in created, created + assert '"ok":true' in created or '"ok": true' in created, created + assert '"created":true' in created or '"created": true' in created, created + assert '"source":"stalwart"' in created or '"source": "stalwart"' in created, created + + accepted = server.succeed( + "${stalwartOps}/bin/vm-lab-smtp-accept recvbox@example.test" + ) + assert smtp_rcpt(accepted) == 250, accepted + assert any(line == "DATA 250" for line in accepted.splitlines()), accepted + + refused = server.succeed( + "${stalwartOps}/bin/vm-lab-smtp-accept notcreated@example.test" + ) + refused_code = smtp_rcpt(refused) + assert refused_code >= 500, refused + assert not any(line.startswith("DATA ") for line in refused.splitlines()), refused ''; } diff --git a/tests/module-eval.nix b/tests/module-eval.nix index ad4f7bb..52f7976 100644 --- a/tests/module-eval.nix +++ b/tests/module-eval.nix @@ -394,6 +394,79 @@ let !(lib.hasInfix "SURMOUNT_ONION_HOSTNAME_FILE=/run/surmount-secrets/arti/onion-service/hostname" envBlob); "t5c-onion-hostname-file-option-wins-ok"; + # Per-site v3 onions: two public HTTP Hosts get two different Arti nicknames. + # Onion-Location must be each Host's own onion root (not /_o/{host} on a + # shared onion). Mail Hosts stay unmapped. HS keys never appear in toml. + t5d-per-site-onions-distinct-nicknames = + let + extraRoot = "/var/lib/surmount/static-sites/extra"; + e = evalSurmount { + artiHiddenService.enable = true; + artiHiddenService.startDaemon = false; + managementUi.enable = true; + managementUi.staticVhosts = { + "extra.example.test" = { + root = extraRoot; + }; + }; + managementUi.extraMailHostnames = [ "mail.cryptoquick.com" ]; + }; + toml = builtins.readFile e.config.environment.etc."surmount/arti.toml".source; + nick = e.config.surmount.artiHiddenService.nickname; + apex = e.config.surmount.primaryDomain; + www = "www.${apex}"; + mail = e.config.surmount.mailHostname; + env = e.config.systemd.services.surmount-management-ui.serviceConfig.Environment; + envList = if builtins.isList env then env else [ env ]; + envBlob = builtins.unsafeDiscardStringContext (lib.concatStringsSep "\n" envList); + nickLine = lib.findFirst (s: lib.hasPrefix "SURMOUNT_ONION_SITE_NICKNAMES_FILE=" s) null envList; + nickPath = + if nickLine == null then "" else lib.removePrefix "SURMOUNT_ONION_SITE_NICKNAMES_FILE=" nickLine; + nickEtc = e.config.environment.etc."surmount/onion-site-nicknames.json" or { }; + nickJson = + if nickEtc ? text && nickEtc.text != null && nickEtc.text != "" then + builtins.unsafeDiscardStringContext nickEtc.text + else if nickEtc ? source && nickEtc.source != null then + builtins.unsafeDiscardStringContext (builtins.readFile nickEtc.source) + else + ""; + nickMap = if nickJson == "" then { } else builtins.fromJSON nickJson; + apexNick = nickMap.${apex} or ""; + wwwNick = nickMap.${www} or ""; + extraNick = nickMap."extra.example.test" or ""; + servicesNick = nickMap.${e.config.surmount.servicesHostname} or ""; + in + assert failedAssertions e == [ ]; + assert e.config.environment.etc ? "surmount/onion-site-nicknames.json"; + assert lib.hasInfix "SURMOUNT_ONION_SITE_NICKNAMES_FILE=" envBlob; + assert nickPath != ""; + assert nickJson != ""; + assert !(lib.hasInfix "/_o/" nickJson); + assert !(lib.hasInfix "BEGIN PRIVATE" toml); + assert !(lib.hasInfix "PRIVATE KEY" toml); + assert !(lib.hasInfix "hs_ed25519_secret_key" toml); + # Console keeps the existing HS nickname so the live identity stays that site. + assert servicesNick == nick; + assert lib.hasInfix "[onion_services.\"${nick}\"]" toml; + # Apex and an extra site each get an onion. www does not. + assert apexNick != ""; + assert wwwNick == ""; + assert extraNick != ""; + assert extraNick != apexNick; + assert extraNick != nick; + assert !(nickMap ? ${www}); + assert !(nickMap ? "mta-sts.${apex}"); + assert lib.hasInfix "[onion_services.\"${apexNick}\"]" toml; + assert !(lib.hasInfix "[onion_services.\"site-www-" toml); + assert !(lib.hasInfix "site-mta-sts-" toml); + assert lib.hasInfix "[onion_services.\"${extraNick}\"]" toml; + # Mail Hosts stay unmapped (primary MX and extra mail hostname). + assert !(nickMap ? ${mail}); + assert !(nickMap ? "mail.cryptoquick.com"); + assert !(lib.hasInfix "mail.cryptoquick.com" nickJson); + assert !(lib.hasInfix "[onion_services.\"site-mail-" toml); + "t5d-per-site-onions-distinct-nicknames-ok"; + # Complete management-publish config: startDaemon does NOT need acceptIncomplete. t6-arti-start-daemon-complete-no-accept = let @@ -2782,6 +2855,7 @@ let assert failedAssertions e == [ ]; assert e.config.surmount.sploraProxy.enable == false; assert !(builtins.any (x: lib.hasPrefix "SURMOUNT_SPLORA_PROXY=1" x) envList); + assert !(builtins.any (x: lib.hasPrefix "SURMOUNT_SPLORA_PORTAL_HOST=" x) envList); "t50-splora-proxy-default-off-ok"; t51-splora-proxy-enable-env-and-udp-https = @@ -2794,7 +2868,6 @@ let sploraProxy.enable = true; sploraProxy.instances = { mainnet = { - hosts = [ "esplora.example.test" ]; socket = "/run/splora/mainnet.http.sock"; }; }; @@ -2805,6 +2878,13 @@ let in assert failedAssertions e == [ ]; assert builtins.any (x: x == "SURMOUNT_SPLORA_PROXY=1") envList; + assert builtins.any (x: x == "SURMOUNT_SPLORA_PORTAL_HOST=splora.surmount.systems") envList; + assert !(lib.hasInfix "esplora.surmount.systems" envBlob); + assert !(lib.hasInfix "testnet3.esplora.surmount.systems" envBlob); + assert !(lib.hasInfix "testnet4.esplora.surmount.systems" envBlob); + assert !(lib.hasInfix "mutinynet.esplora.surmount.systems" envBlob); + assert !(lib.hasInfix "liquid.esplora.surmount.systems" envBlob); + assert lib.all (inst: inst.hosts == [ ]) (lib.attrValues e.config.surmount.sploraProxy.instances); assert lib.hasInfix "SURMOUNT_SPLORA_INSTANCES=" envBlob; assert lib.hasInfix ''"mainnet"'' envBlob; assert lib.hasInfix ''"hosts"'' envBlob; @@ -2822,7 +2902,6 @@ let sploraProxy.enable = true; sploraProxy.instances = { mainnet = { - hosts = [ "esplora.example.test" ]; socket = "/run/splora/mainnet.electrum.sock"; }; }; @@ -2845,7 +2924,6 @@ let sploraProxy.enable = true; sploraProxy.instances = { mainnet = { - hosts = [ "esplora.example.test" ]; socket = "/run/splora/mainnet.http.sock"; }; }; @@ -2866,7 +2944,6 @@ let sploraProxy.enable = true; sploraProxy.instances = { mainnet = { - hosts = [ "esplora.example.test" ]; socket = "/run/splora/mainnet.http.sock"; }; }; @@ -2885,7 +2962,6 @@ let sploraProxy.unitsMemoryMax = "2G"; sploraProxy.instances = { mainnet = { - hosts = [ "esplora.example.test" ]; socket = "/run/splora/mainnet.http.sock"; }; }; @@ -2905,7 +2981,6 @@ let sploraProxy.enable = true; sploraProxy.instances = { mainnet = { - hosts = [ "esplora.example.test" ]; socket = "/run/splora/mainnet.http.sock"; }; }; @@ -2991,7 +3066,6 @@ let sploraProxy.enable = true; sploraProxy.instances = { mainnet = { - hosts = [ "esplora.example.test" ]; socket = "/run/splora/mainnet.http.sock"; }; }; @@ -3021,6 +3095,57 @@ let ) failed; "t60-splora-remote-enable-needs-cookie-path-ok"; + # One public Host. Extra onion Hosts are that Host only when instance + # hosts are empty. Esplora Hosts are not in the sample map. Mainnet is absent. + t61-splora-portal-host-and-onion-extras = + let + e = evalSurmount { + managementUi.enable = true; + artiHiddenService.enable = true; + artiHiddenService.startDaemon = false; + sploraProxy.enable = true; + sploraProxy.instances = { + testnet3 = { }; + testnet4 = { }; + mutinynet = { }; + liquid = { }; + }; + }; + env = e.config.systemd.services.surmount-management-ui.serviceConfig.Environment; + envList = if builtins.isList env then env else [ env ]; + nickEtc = e.config.environment.etc."surmount/onion-site-nicknames.json" or { }; + nickJson = + if nickEtc ? text && nickEtc.text != null && nickEtc.text != "" then + builtins.unsafeDiscardStringContext nickEtc.text + else if nickEtc ? source && nickEtc.source != null then + builtins.unsafeDiscardStringContext (builtins.readFile nickEtc.source) + else + ""; + nickMap = if nickJson == "" then { } else builtins.fromJSON nickJson; + toml = builtins.readFile e.config.environment.etc."surmount/arti.toml".source; + portalNick = nickMap."splora.surmount.systems" or ""; + in + assert failedAssertions e == [ ]; + assert builtins.any (x: x == "SURMOUNT_SPLORA_PORTAL_HOST=splora.surmount.systems") envList; + assert nickMap ? "splora.surmount.systems"; + assert !(nickMap ? "testnet3.esplora.surmount.systems"); + assert !(nickMap ? "testnet4.esplora.surmount.systems"); + assert !(nickMap ? "mutinynet.esplora.surmount.systems"); + assert !(nickMap ? "liquid.esplora.surmount.systems"); + assert !(nickMap ? "esplora.surmount.systems"); + assert lib.all (inst: inst.hosts == [ ]) (lib.attrValues e.config.surmount.sploraProxy.instances); + assert !(builtins.any (x: lib.hasInfix "esplora.surmount.systems" x) envList); + assert !(builtins.any (x: lib.hasInfix "testnet3.esplora.surmount.systems" x) envList); + assert !(builtins.any (x: lib.hasInfix "testnet4.esplora.surmount.systems" x) envList); + assert !(builtins.any (x: lib.hasInfix "mutinynet.esplora.surmount.systems" x) envList); + assert !(builtins.any (x: lib.hasInfix "liquid.esplora.surmount.systems" x) envList); + assert portalNick != ""; + assert !(nickMap ? "www.surmount.systems"); + assert lib.hasInfix "[onion_services.\"${portalNick}\"]" toml; + assert !(lib.hasInfix "site-www-" toml); + assert !(lib.hasInfix "esplora-surmount-systems" toml); + "t61-splora-portal-host-and-onion-extras-ok"; + results = [ t1-defaults t1b-p1-free-443-plan-and-firewall @@ -3031,6 +3156,7 @@ let t5-arti-enable-no-daemon t5b-arti-enable-derives-ui-onion-hostname-env t5c-onion-hostname-file-option-wins + t5d-per-site-onions-distinct-nicknames t6-arti-start-daemon-complete-no-accept t6b-arti-start-daemon-ok t6c-arti-start-daemon-needs-package @@ -3136,6 +3262,7 @@ let t58-splora-remote-public-health-extra-arg t59-splora-remote-ui-extra-groups-when-proxy-on t60-splora-remote-enable-needs-cookie-path + t61-splora-portal-host-and-onion-extras ]; in {