How HTTPS reaches the management UI and (optionally) Stalwart HTTP. Mail protocol ports are not edge-proxied; they terminate on Stalwart. See STACK.md for the full path map.
Last updated: 2026-09-07 (intended production leaf 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
six zones apex+www, surmount apex/www/mail/services/mta-sts, and
mail.cryptoquick.com. Missing: cryptoquick.com and
www.cryptoquick.com. That mismatch is why cryptoquick HTTPS fails
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
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
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.)
Operator direction: operator-direction.md
- No nginx as product edge
- Prefer first-party Axum (or Axum + Leptos / small same-workspace edge crate) as the HTTPS edge
- Prefer Unix domain sockets for local hops
- Arti onion/hidden services REQUIRED (alongside clearnet; not optional)
- Do not lock ACME-only or rustls-acme-only; see research/tls-trust-and-acme.md
- Compaction reload: COMPACTION-PIN.md
| Need | Notes |
|---|---|
| TLS termination | Public HTTPS for services, mail name (certs), apex/www |
| Reverse proxy / routing | To Axum/Leptos UI; optional Stalwart HTTP path |
| Local IPC | Prefer Unix domain sockets to backends, not TCP localhost |
| Certificates | Automated public CA is common today; not locked to ACME-only (TLS research) |
| HTTP :80 | Automatically and gracefully upgrade/redirect to :443. Production: port 80 free for product redirect-only bind (operator 2026-08-02). ACME HTTP-01 on product :80 parked (Q-EDGE); dual-run nginx may still use :80 for ACME |
| TLS versions | No SSLv3, no TLS 1.0/1.1; prefer TLS 1.3 (1.2 only if measured client need) |
| PQ where supported | Enable hybrid PQ KEX (e.g. X25519MLKEM768) when rustls/aws-lc-rs path allows; see PQC research |
| Rate limiting | Request flood protection at edge and/or app (tower-governor or equivalent) |
| Access control | Merciless ban on unauthorized access + whitelist + last-used; see access-control research |
| Direct origin | Works with DNS A/AAAA straight to the VPS |
| Implementation | Prefer Axum-first edge we own (operator follow-up 2026-07-30); no third-party reverse proxy as product identity |
| Ops burden | Single operator, single node; avoid k8s-shaped complexity |
- nginx as the permanent product edge (security concern restated by operator). Current nginx is transitional-to-delete.
- A separate reverse-proxy product (Caddy, Sozu, Traefik, etc.) as the default identity. Optional later only if measured need appears.
- Cloudflare orange-cloud (or any CDN reverse proxy) as a required hop
- CF Access, Workers, WAF, or Tunnel as critical path for mail or core HTTPS
- "It only works if proxied" assumptions in modules
DNS may use Cloudflare or any registrar in DNS-only mode. Mail and services traffic path must succeed when records point at the VPS IP.
Optional future: static marketing site behind a CDN is a separate host story and must not become a hard dependency for this flake's mail stack.
Why not a separate proxy daemon by default? Operator: we can implement this in Axum; it is not that much work relative to running another product edge. Prefer first-party code in the Surmount workspace.
Honest scope (still real work):
| Work item | Notes |
|---|---|
| TLS terminate on :443 | rustls (or equivalent) in-process |
| Cert obtain/renew | ACME or other path; may share PEMs with mail; see TLS research |
| HTTP :80 | challenges and/or redirect |
| Rate limits | tower / governor style |
| Routing | UI vs optional Stalwart HTTP vs static legacy vs optional Vaultwarden /vault/ vs splora Hosts |
| Headers / hardening | baseline security headers, Onion-Location + onion h2 Alt-Svc on mapped HTTPS, clearnet h3 Alt-Svc only when QUIC is bound, body limits |
| Local upstreams | Prefer UDS; loopback TCP until backends support UDS |
| Vaultwarden subpath | Optional Axum reverse-proxy of loopback Rocket under /vault/ (no nginx, no new subdomain) |
| Splora | Optional Host -> /run/splora/<instance>.http.sock (HTTP/1.1); queue POST on its own socket; no Electrum newline proxy |
| HTTP/3 | UDP :443 QUIC next to TCP :443; same PEMs; QUIC ALPN h3 only. axum-h3 does not insert Axum ConnectInfo; NIP-98 session POST must not 500 for that (optional peer; do not ban unspecified). |
Shape options (both Axum-family):
- In-process with management-ui (one binary listens public + serves app)
- Small
surmount-edgecrate in the same workspace (Axum/tower/rustls), reverse-proxying to UI and other local services over UDS
Either is "first-party Axum edge." Separate proxy products are a fallback if we measure a need we do not want to own.
Deep comparison (still useful for UDS and non-default candidates): research/rust-edge-and-uds.md.
Operator approval: Rust HTTP server (Axum management-ui) is the clearnet HTTPS frontend on :80 and :443. Stalwart is the backend mail engine (SMTP/submission/IMAP/ManageSieve and related). Stalwart is not the permanent product clearnet HTTPS owner on :443.
| Plane | Owner | Notes |
|---|---|---|
| Public :80 | management-ui redirect-only (or dual-run nginx ACME escape) | Free production :80 is operator-approved for product redirect |
| Public :443 TCP | management-ui rustls (listenMode=https + host PEMs or in-process ACME) |
Product browser / services HTTPS (h2 + http/1.1) |
| Public :443 UDP | management-ui QUIC HTTP/3 (same PEMs, ALPN h3) |
First-class with TCP; firewall UDP 443 when UI HTTPS is on |
| Mail 25/465/587/993/4190 | Stalwart | Same durable Let's Encrypt PEMs as Axum (/var/lib/surmount/secrets/tls/{cert,key}.pem) after day-2 Certificate apply. First-boot still inserts an engine self-signed leaf until that apply. |
| Stalwart HTTP management | Loopback (prefer 127.0.0.1:8080) |
SSH tunnel / bootstrap; not public product edge |
| Stalwart first-boot HTTPS :443 | Temporary engine default until removed | Free with apply plan before public B1 |
Offline free-:443 path (shipped):
- Template:
nix/stalwart/free-public-443-for-axum-edge.ndjson - Host install:
/etc/surmount/stalwart/(viamodules/mail.nixwhensurmount.enable) - Runbook:
nix/stalwart/README.mdand/etc/surmount/stalwart/README-free-public-443.txt - Operator:
stalwart-cli query NetworkListenerthenapply --dry-run/apply(token host-only). Confirm first-boot listener names match the template (name=https); adjust host-local copy if not. - Sample host comments:
hosts/mail-vps/configuration.nix(do not enable public UI https until :443 is free for Axum)
Honesty: first boot with empty RocksDB may still bind engine HTTPS :443
until apply/WebUI. Tree defaults do not open Stalwart :443 in
services.stalwart.openFirewall. Live public cutover (PEMs/ACME, DNS,
just e2e-host) remains host residual (RESIDUAL.md). Not claimed done from
docs or module-eval alone.
surmount.web.enabledefault false (nginx not the product edge)- Public HTTPS:
managementUi.listenMode = "https"with hosttlsCertPath/tlsKeyPathPEMs; bind public address/port as needed (sample comments inhosts/mail-vps) - Stalwart does not permanently own product clearnet :443 (P1 above)
- Module-eval contracts: web off =>
services.nginx.enablefalse; https UI env + MemoryDenyWriteExecute serviceConfig (no writable+executable memory); dual-run escape still evaluates when web on; free-:443 plan installed underenvironment.etc
services.nginx+security.acmeonly whensurmount.web.enable = true- Vhosts for
services.surmount.systems,mail.surmount.systems, apex, www - Proxy
/-> management UI;/stalwart-admin/-> Stalwart HTTP (bootstrap) - Recommended TLS/proxy/gzip settings enabled
clientMaxBodySizedefault 25m (mail bodies go through Stalwart, not the edge)- Backends today are TCP loopback (scaffold). Target local hops are UDS.
Status: transitional-to-delete. Escape hatch only. Do not expand nginx features. Module file remains until operators no longer need dual-run.
- Auth on the public edge (2026-08-12): public product HTTPS on the
services Host (operator console) requires
authMode = "nostr"with host session secret + allowlist.authMode = "off"is loopback / lab only. When mode is off, middleware does not gate routes (full anonymous console). Product footgun guard (binary + Nix) may refuse public primary edge with auth-off; lab keeps loopback auth-off. Host enable runbook and proof curls: OPS.md Production Nostr auth on the public edge (B4). Posture: SECURITY.md. Secrets: SECRETS.md. Live B4 (2026-08-12): public services is Nostr-gated (anon/login,/api/v1/domains401,/health200). Residual B4 is live-gated; Q-AUTH-1 still open. Report:.agents/reports/impl-auth-live-b4-switch.md. - Interim day-1 (until PEMs + public :443): bind cleartext UI on
loopback only (
surmount.managementUi.listenAddressdefault127.0.0.1, port default 8090). DefaultauthMode = "off"is not public-safe. Do not set a publiclistenAddresswith auth off. Reach via SSH tunnel; pin loopback in private host-local if needed. Product path is still Axum owning public :80/:443 with real TLS (B1), then B4 Nostr before treating services as production-safe. - Recommended with public https: private host-local also sets
publicBaseUrl = "https://services.<domain>"so NIP-98utags match the public origin (optional empty = request Host + scheme). - Listen mode
http(default bind) orhttpswith hosttlsCertPath/tlsKeyPath. H-PEM durable default for new host profiles:/var/lib/surmount/secrets/tls/{cert,key}.pem(cert mode 0640surmount-ui:surmount-tls; key mode 0600 owner-onlysurmount-ui) and account JSON under.../acme/account.json(mode 0600). Stalwart mail-plane TLS uses copies under.../mail/tls/(key 0600stalwart-mail). Ephemeral/run/surmount-secrets/...remains a valid override (wiped on reboot). - Multi-name host profile (H5): private profile
acme_domainslists certificate hostnames for the issued cert (sample: services + mail + apex + www).mailis first-class so IMAP/SMTP can present a browser-trusted name. Render writes a space-separated Nix list. Live issuance is laptop DNS-01 (Namecheap; ClientIp = laptop egress). Host ACME stays off. Optionalmta-sts.<domain>on the private profile when the policy host is live. Named renew:just laptop-renew-cert -- --checkor--live --directory production(directory required; never silent staging/prod).--liveissues only when the leaf is due; otherwise it restages the matching pair. Laptop user timer:--install-timer(not a VPS ACME timer). - Mail-plane TLS (Stalwart-owned copies): Stalwart does not take
IMAP/SMTP certs from Nix
settings(that option is ignored). Point the engine at copies under/var/lib/surmount/secrets/mail/tls/withjust point-stalwart-mail-tls(default dry-run;--live --restarton the host). Axum keeps/var/lib/surmount/secrets/tls/with key mode 0600. Stalwart 0.16.15 query JSON is certificate hostnames plusid(notfilePath); the driver resolves the Let's Encrypt File leaf that way. Nix grantsReadOnlyPathsforsecrets/mail/tls(and still grantssecrets/tls). Template:nix/stalwart/mail-plane-tls-le-pems.example.ndjson. Host ACME stays off. Proof:nix run .#e2e-host(mail TLS rows when BASE_URL is set; issuer must not bercgen self signed cert; hostname list must includemail.<apex>). Evolution "Accept Permanently" is not the product fix. - Production LE directory (H6): scaffold default is Let's Encrypt
staging. Production directory is never silent: re-render with
nix run .#surmount-render-host-profile-acme -- --directory production(preferred; does not rewrite the profile file) or set profileacme_directoryto the production URL explicitly. Cutover steple-prodforces that explicit path. Re-render of a staging profile without a flag stays staging. - Env:
SURMOUNT_LISTEN_MODE,SURMOUNT_TLS_CERT,SURMOUNT_TLS_KEY - In-process rustls HTTPS: TLS 1.3 lean (aws-lc-rs provider; workspace
feature
prefer-post-quantumso default provider offers hybrid X25519MLKEM768 first). Loads PEMs from host paths; ALPN h2 + http/1.1. Hermetic unit tests prove provider includes hybrid and prefers it first; integration test uses temp self-signed PEMs only. Honesty: hybrid group config is a tree/provider proof (D1 offline). Do not claim a live production host negotiated X25519MLKEM768 without host proof after B1 HTTPS cutover. Operator probe (env placeholders only):SURMOUNT_E2E_BASE_URL=https://... nix run .#surmount-tls-hybrid(exit 2 BLOCKED when unset; never silent skip success; not a flake check). Runbook: deploy-host-local.md section 6 (D1-host). PQConnect (D2) is a separate path layer; see research/pqconnect-and-pqc.md. - HTTP->HTTPS redirect helpers + host allowlist; optional redirect-only
plain HTTP listener when
redirectHttpToHttps+httpRedirectListen(default0.0.0.0:80). No cleartext API on that port. Eval mutex vsweb.enable(nginx dual-run owns :80 ACME/redirect). ACME HTTP-01 on product :80 is parked (Q-EDGE; free :80 is redirect-only, not an invent of ACME-on-product-:80). See RESIDUAL.md. - Apex / www public main site vs services console (2026-08-12; document
root 2026-08-14; SurmountSystems/site 2026-08-18): allowlisted Host
primaryDomainorwww.<primaryDomain>is the public main site. WhenSURMOUNT_APEX_PUBLIC_ROOT(NixmanagementUi.apexPublicRoot) points at a directory that containsindex.html, those static files are served (no directory listing; path traversal 404). The management-ui module mkDefaults this topkgs.surmount-public-site(flake inputgithub:SurmountSystems/site; operator bumps the locked rev). A host directory such as/var/lib/surmount/public-siteremains a valid override. Missing root or missingindex.htmlkeeps the yellow UNDER CONSTRUCTION page (#FFFF00). Live 2026-08-19: apex and www serve the current GitHub site copy, not UNDER CONSTRUCTION.just deploypublishes those static files (and extra vhosts);just deploy-hostis the NixOS generation. Services console is unchanged. No operator nav on apex/www. Operator console (Dashboard Overview, Stalwart chips) is only onservicesHostname(e.g.services.surmount.systems). HTTP :80 same-host HTTPS upgrade for every allowlisted Host, including apex/www (public users do not get dumped onto the services console). Apex still answers/healthand/.well-known/*; apex/api/*(except/api/health) is 404. Public-site CSP allows'unsafe-inline'scripts so existing support.html clipboard JS works; the services console keeps the nonce CSP. Open-redirect safe: empty allowlist or non-listed Host never builds a Location from untrusted input. Default allowlist: services, apex, www, mail, mta-sts., plus any extra static Hosts fromSURMOUNT_STATIC_VHOSTS/SURMOUNT_STATIC_VHOSTS_FILE. - Extra static Hosts (DS3018xs trees, 2026-08-19): proven public HTML
sites are served from the same Axum process via a Host -> document root
map (
SURMOUNT_STATIC_VHOSTSJSON, or Nixsurmount.managementUi.staticVhosts). Same path/MIME/CSP/traversal rules as apex. Extra Hosts serve document-root/.well-known/*(closed 404 if missing);/healthand/api/healthstay edge probes; never the operator console. Do not overloadSURMOUNT_APEX_PUBLIC_ROOT.services.surmount.systemsstays 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, andmta-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.
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/<instance>.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
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 |
Live in browsers (2026-08-20): Namecheap NS, exclusive A matching
this host, HTTPS 200, production leaf 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
/var/lib/surmount/static-sites/<slug>. baxterartworks.com is also a
local Stalwart Domain; public MX still parked. Static-site-only extras
are not mail domains.
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
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.
Host tree is populated; insecure -k can still return packaged site
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.
Not extra Hosts (do not Host-serve): btcdragonlord.com (not operator-owned), btckitties.com (archived), denver.space, justsaybits.org.
Skipped (not static or uncertain): divdurv.art Ghost, lunarlupine Grav, hoverbyte.com, bips.dev/bip360, denverbitdevs.com, dues.denver.space, donate.denver.space, www.ro.me, biggaymonster, cryptoquick mail/git/ipfs* service labels.
Populate roots from DS3018xs GVFS (laptop AFP, never print office LAN
IPv4): just diskstation-afp-mount -- --host DS3018xs --share sites
then just sync-static-sites-from-ds3018xs -- --live --target USER@HOST.
Those six extra zones already have DNS + production Let's Encrypt
certificate hostnames. https://<hostname>/ and
https://www.<hostname>/ 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
(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),
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
extras (btcdragonlord.com, btckitties.com) cannot sneak in.
--live does not detect missing certificate hostnames.
Extra-zone DNS-01 uses the dispatcher hook; the laptop --issue wrap
must re-export HOME (ACME env_clear) so dispatch can find
namecheap/<sld.tld>.env.
Adding a name: copy existing apex/www A/AAAA with
just dns-zone-namecheap -- --credentials FILE -- list then
set-host @ and set-host www. Laptop ClientIp (laptop egress
whitelist). Then add FQDNs to private acme_domains and
just laptop-renew-cert -- --issue --directory production --host-profile ~/.local/share/surmount/host-profile.toml --install --restart-ui --target USER@HOST.
--live does not detect missing certificate hostnames (expiry
only). Do not start host in-process ACME.
Extra-zone DNS-01 (cryptoquick.com, yiffa.app, and other Namecheap
zones besides surmount.systems) cannot use the one-SLD Namecheap hook
alone. Pass the dispatcher as --hook:
nix run .#acme-dns-hook-namecheap-dispatch. It maps names under
surmount.systems to ~/.local/share/surmount/issue-le-prod/namecheap.env
(or $XDG_DATA_HOME/...) and other zones to
~/.local/share/surmount/namecheap/<sld.tld>.env (regular file, mode
0600), then execs nix run .#acme-dns-hook-namecheap-bin. Extra-zone TXT
is never written through the surmount.systems env. Production issue is
--issue --directory production (Let's Encrypt production). Do not
put unowned, archived, or still-Cloudflare zones on --domains until
their nameservers are Namecheap. Extra certificate hostnames belong
on a leaf only after that NS cut. Host in-process ACME stays off.
- MTA-STS policy path:
GET /.well-known/mta-sts.txtwhenmtaStsModeistestingorenforce(default off). Served only for Hostmta-sts.<primaryDomain>on the allowlist (HTTP/2 uses Host or URI:authorityviarequest_authority_host). Live (2026-08-12): production leaf includesmta-stsand mail (plus six extra static zones apex+www, cryptoquick apex/www, andmail.cryptoquick.com; live 20 names). Host-localmtaStsMode=testing; public policy 200 over HTTP/1.1 and HTTP/2. Stay testing while DNS MX is eforward. DNS + policy body: DNS.md. - In-process ACME (operator-directed scaffold, 2026-08-10; A1/A2 2026-08-10):
optional DNS-01 path inside management-ui via
instant-acme0.8 (rustls 0.23 / aws-lc-rs). Default off (surmount.managementUi.acme.enable = false/ noSURMOUNT_ACME_ENABLE). Static host PEM load remains first-class (not ACME-only forever).- Reuse / early renew: reuse PEMs when leaf is not past notAfter, not
not-yet-valid, certificate hostnames cover configured domains, and remaining lifetime
is at least
SURMOUNT_ACME_RENEW_DAYS_BEFORE_EXPIRYdays (scaffold default 30; common LE operator practice, not locked CA law). Else issue and write cert+key PEMs totlsCertPath/tlsKeyPathwith fail-closed key mode (0600). - DNS-01 challenge adapter (
DnsProvider/SURMOUNT_ACME_DNS_PROVIDER): this name means the component that creates/deletes_acme-challengeTXT for CA validation. It is not a Surmount commercial DNS product and not a mandatory third-party vendor. Values:none(default; reuse PEMs only),mock(lab self-signed; refused against production Let's Encrypt directory),external-hook/hook(operator-owned absolute regular-file executable viaSURMOUNT_ACME_DNS_HOOK; no symlink; not group/world-writable; argvset|clear|wait; no shell; cleared child env + fixed minimal PATH; timeout 1..600s; no Cloudflare/Route53 crates). Product wait retries are brief (long DNS propagation stays in the operator hook). Surmount runs on operator-chosen host + operator-controlled DNS; external-hook is the first real adapter for that model. Whenacme.enable+dnsProvider = external-hookanddnsHookPathis unset, the management-ui module may defaultdnsHookPathto the packaged Namecheap helper store path (pkgs.acme-dns-hook-namecheap). That package is code only (nix run .#acme-dns-hook-namecheap-bin); Namecheap API credentials stay laptop Domain A custody; Domain B activation copy prefers durable/var/lib/surmount/secrets/acme/namecheap.env(S8);/runremains allowlisted for optional short-lived installs. - Wildcards refused until DNS-01 TXT naming is designed. TLS-ALPN-01
residual. Fail closed on issuance failure (no silent cleartext).
Hot-reload residual: restart after renew. CI never requires live Let's
Encrypt. Env:
SURMOUNT_ACME_*. Ops sketch: OPS.md.
- Reuse / early renew: reuse PEMs when leaf is not past notAfter, not
not-yet-valid, certificate hostnames cover configured domains, and remaining lifetime
is at least
- Production assumption (operator 2026-08-02): TCP port 80 is free on the NixOS box so the product redirect-only listener can bind. That free :80 is for redirect/upgrade only, not an invent of ACME-on-product-:80. Day-one ops: OPS.md.
- Fixed-window rate limit; when peer is loopback, trust X-Real-IP only (useful behind dual-run nginx). X-Forwarded-For is ignored for rate-limit keys (leftmost XFF is spoofable). Never trust forwarding headers from non-loopback. Same IP key feeds the ban/whitelist layer.
- Vaultwarden path proxy (optional, default off): when
managementUi.vaultwardenProxyEnableis true, management-ui reverse- proxies public prefix/vault(envSURMOUNT_VAULTWARDEN_PROXY*=) to loopback Rocket (http://127.0.0.1:8222by default). Path strip/rewrite, method/headers/body forward, WebSocket Upgrade day-one, 502 when upstream down. VW login is SoT on the prefix (no Nostr gate day-one). Alignvaultwarden.domain/ console URL tohttps://{servicesHostname}/vaultwhen publishing. Not nginx; not a second subdomain. Prefer after free-443 and public https listen are real. See SECURITY.md. - Splora Unix proxy (optional, default off):
surmount.sploraProxy.enablemaps public Hosts to HTTP/1.1 Unix sockets on the edge host. Documented Hosts (optional edge, not a REST requirement):esplora.surmount.systemsto/run/splora/mainnet.http.sock,testnet3.esplora.surmount.systemsto/run/splora/testnet3.http.sock,testnet4.esplora.surmount.systemsto/run/splora/testnet4.http.sock,mutinynet.esplora.surmount.systemsto/run/splora/mutinynet.http.sock,liquid.esplora.surmount.systemsto/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/wsWebSocket-upgrades to the same indexer HTTP socket. QueuePOST {npub,email}goes only to/run/splora/queue.sockon/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 enablemodules/web.nixfor this path. Backend from the edge is still HTTP/1.1 over UDS when the client arrived on HTTP/3. HTTP/3 and hypervisor UDP 443 are optional edge, not prerequisites of the mempool REST. Whensurmount.sploraProxy.enableandservices.splora.enableare both true,users.users.surmount-ui.extraGroupsincludes thesploragroup so the edge can connect to 0750 sockets. Flake inputsplora(github:SurmountSystems/sploraon thesurmountbranch, locked rev9481e4cb87273aa99b0357be48503765beadb919) suppliespkgs.splora,pkgs.splora-liquid, andnixosModules.splora. Upstream crane omits.cargo/config.tomlfrom Nix src (laptop cargo still uses Menhera). This overlay does not wrap that src again. Do not setservices.splora.enableorsurmount.sploraIndexer.enablein the public sample host. Splora REST requires a Bitcoin JSON-RPC peer by design. The indexer is not Bitcoin Core. That is not a bug. The remote shape isdaemonDir = null, a cookie file path (cookieFile; never cookie bytes in git), and a JSON-RPC address (daemonRpcAddr). REST needs that peer plus one indexer instance, not a local bitcoind datadir on this guest. Host-local setssurmount.sploraIndexer(one instance, first-class--jsonrpc-import, cookie path,daemonDir = null, 24 MiB db cache; optional--public-health). Bitcoin node inventory is tasked in the splora tree (branchsurmount). Do not start five indexers. - HTTP/3 (required on HTTPS, not later): UDP :443 QUIC next to TCP :443,
same host PEMs, TLS 1.3. That is Axum edge product, not a mempool REST
prerequisite. Hypervisor UDP 443 is only if clearnet HTTP/3 should
answer from the public internet. QUIC rustls ALPN is h3 only; TCP ALPN stays
h2+http/1.1. Same AxumRouter. Stack: quinn + h3, via axum-h3 0.0.6 production quinn backend (h3-utilfeaturequinn; accessed: 2026-08-31). ConnectInfo: TCP HTTPS usesinto_make_service_with_connect_info. The QUIC path (H3Router::from(app)) does not insertConnectInfo<SocketAddr>. A required extractor there is Axum 500 text ("Missing request extension"), which the/loginNIP-07 script shows as Login failed: 500. Session exchange uses an optional peer (unspecified when missing) and does not ban 0.0.0.0/::. Leftover: inject the real QUIC peer intoConnectInfoso H3 bans and rate-limit keys are per-client. Clearnet Alt-Svc addsh3=":443"only if the QUIC listener bound; onionh2Alt-Svc stays a separate token (merged, not replaced).networking.firewall.allowedUDPPortsincludes 443 when the UI HTTPS listener is on (not the cleartext https-escape). Keep the workspace[patch.crates-io]forchacha20if quinn pulls that crate (menhera yank of 0.10.0/0.10.1). - Ban layer (first path):
crates/management-ui/src/ban.rsdecisions (Allow / Whitelisted / RateLimited / Banned / BanCandidate). Whitelist never banned; last-used touched on allowed requests from a whitelist match. Enforcement viaSURMOUNT_BAN_ENFORCEMENT=off|dry-run|enforce(default off). Optional JSON state path; optional nft add-element sync (default off; no CAP_NET_ADMIN on UI). OptionalnftHelpersocket-activated oneshot. Nix:surmount.accessControl.*+ nft sets when enabled. Q-ACL-1..6 still open. - HTTPS happy path: bare
listenMode = "https"with PEM paths (no escape needed). Fail-closed if PEMs missing, unreadable, bad PEM, or key group/world readable (mode & 0o077 == 0, e.g. 0600). allowCleartextHttpsEscape/SURMOUNT_HTTPS_ALLOW_CLEARTEXT_ESCAPE=1is a deliberate cleartext override under the https label (default off; not production). When set, the binary always serves cleartext and never takes the rustls path, even if the acceptor is ready and PEMs are valid.- Default product path is nginx off. Dual-run: set
surmount.web.enable = trueand keep UI onlistenMode = httploopback. Tree still ships nginx module code; do not claim "nginx removed from repo." Host public cutover + MemoryDenyWriteExecute TLS proof remain operator residual (nix run .#e2e-host; RESIDUAL.md). Local self-signed HTTPS isnix run .#e2e.
Arti HS (modules/arti-hidden-service.nix)
- Management-publish config: real
[onion_services."<nickname>"]+proxy_portsto management TCP orunix:UDS;storage.state_dir=onionServiceStateDir(host deploy secrets only); no private keys or.onionaddresses in git - TCP backend:
backendAddressnull derives frommanagementUi.listenAddress:portwhen UI is plain http (tracks UI bind). When UI islistenMode=https(escape off) and no explicitbackendAddress/backendUnixSocket, the module auto-binds a loopback cleartext full API (SURMOUNT_LOCAL_CLEARTEXT_LISTEN) and points the onion reverse-proxy at it. Auto port prefers127.0.0.1:8090, then8091, thenprimary+1, always avoidingmanagementUi.portand the active redirect port (Linux cannot bind0.0.0.0:Pand127.0.0.1:Ptogether). ExplicitmanagementUi.localCleartextListenoverrides (numeric loopback only:127.0.0.1or[::1]; notlocalhosthostnames). - Lean onion backend is cleartext HTTP (or UDS). Pointing Arti at the
primary https TCP (explicit
backendAddressequal to UI listen) still warns. Redirect-only:80is never the cleartext API. No TLS-on-onion without a separate design. - Onion client IP collapse (lean TCP path): Arti reverse-proxies to
loopback cleartext, so the UI
ConnectInfopeer is the local Arti process (127.0.0.1), not the onion client. Rate-limit and ban keys share one loopback bucket unless a future path injects a trusted client IP (Arti does not sendX-Real-IP/ PROXY protocol today). Do not invent headers. Park per-onion-client ACL / PROXY as residual; a ban of127.0.0.1would deny the whole cleartext/onion backend. - Version assumption: Surmount-owned Arti 2.5.1 TOML shape
(
nix/packages/arti-onion-service.nix; GitLabarti-v2.5.1), not stock nixpkgs 1.4.2 lag. Keys:[proxy] socks_listen,[onion_services]+proxy_ports. HS package addsonion-service-servicevia owned build startDaemondefault false: enable installs config + status oneshot only- Complete lean path:
startDaemon = truedoes not requireacceptIncompleteOnionConfig(no effect this version). Package happy path: nullpackagepreferspkgs.artiOnionService(passthru capable claim). Stockpkgs.artistays fail-closed unless operator setspackageIsOnionServiceCapable(unit active != onion published) - Daemon:
ConditionPathIsDirectory+ restart burst caps; HS dir must be writable bysurmount-arti(e.g. 0750 surmount-arti:surmount-arti; never auto-create identity dir; never keys in git) - Default lean: management backend only; Stalwart admin/JMAP onion flags default false and do not add stanzas yet
- Residual: live Tor verify; operator offline backup of HS identity;
hardening in the public module after real
arti proxy; local temp-key e2e != operator backup (see RESIDUAL.md). Do not claim B3 fully closed. Package overlay is in-tree.
Mandatory discovery headers on the custom Rust Axum edge
(security_headers_middleware in surmount-management-ui). Production
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_DIRplusSURMOUNT_PRIMARY_DOMAIN,SURMOUNT_SERVICES_HOSTNAME, and extra static Hosts fromSURMOUNT_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 vhost map, or env change (same as PEMs). - Emit only when
listen_modeis https, Host is a mapped clearnet name (not.onion), status is 2xx/3xx, and the request is not on the:80redirect 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 (AxumRouter::layerruns 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. - 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. Onionh2is not replaced byh3. - Optional env:
SURMOUNT_ONION_LOCATION_ENABLED,SURMOUNT_ONION_ALT_SVC_ENABLED(default on),SURMOUNT_ONION_LOCATION_DISABLED_HOSTS,SURMOUNT_ONION_ALT_SVC_DISABLED_HOSTS(comma lists),SURMOUNT_ONION_MAP_FILE(JSON extra/override mappings; missing or malformed entries skipped). - Diagnostic dump:
GET /api/v1/systemfieldonion_discovery(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, andservices.surmount.systems(same onion). Unitsurmount-arti-hidden-serviceactive. 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./vaulton 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/vaultonce 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.
Header comment in modules/web.nix must keep pointing here.
Internet --:443--> Axum-first edge (TLS + certs + rate limit)
|
+--UDS--> management-ui.sock
+--UDS--> stalwart-http.sock (if proxied; if engine supports)
+--UDS--> vaultwarden.sock (when added)
+--loopback TCP--> Stalwart :8080 (scaffold / if no UDS)
Avoid by default: edge --> 127.0.0.1:8080 style for every hop forever
If TCP local required: nonstandard port + firewall deny from non-local
Public standard ports: 80/443 edge only; mail ports on Stalwart directly
Management UI and Stalwart modules should grow UDS listen options as the edge lands. Until then, loopback TCP remains scaffold reality. Stalwart 0.16 listener model is IP:port (see rust-edge research); do not assume UDS for Stalwart HTTP without evidence.
| Pros | Same language and middleware story as management-ui; tower rate-limit / timeout / concurrency already idiomatic; full control; no nginx/Caddy in the TCB; matches operator "do it in Axum" preference |
| Cons | We own multi-vhost, renewals, edge cases, security updates of our edge binary; more code than a packaged proxy |
| NixOS | Package with crane like management-ui; simple systemd unit on :80/:443 |
| UDS | Natural (hyper/axum backend client to UDS) |
| Certs | Static host PEMs always supported. In-process path uses instant-acme (DNS-01, default off). Dual-run may still use host security.acme. Not locked ACME-only or to a single CA. |
| Verdict | Default path per operator follow-up 2026-07-30 |
| Verdict | Optional later if proxy depth outgrows a thin Axum edge and measurement says so. Not the first cutover default. |
| Verdict | Not default. Emergency bridge only if Axum edge slips and nginx must die sooner. Wrong complexity class for one mail VPS in several cases (Traefik/Envoy). |
| Verdict | No. Bridge only. Operator restated security concern. Mark delete and migrate. |
| Horizon | Choice |
|---|---|
| Now (tree default) | Axum-first management-ui rustls (listenMode=https + host PEMs); surmount.web.enable default false |
| Dual-run escape | surmount.web.enable = true + UI listenMode=http loopback; nginx + security.acme (transitional-to-delete) |
| Production :80 | Free for product redirect-only bind (operator 2026-08-02). Not ACME-on-product-:80 (parked; Q-EDGE) |
| Host residual | Public :443 + MemoryDenyWriteExecute (no writable+executable memory) + cert path (Q-EDGE-1 / Q-CA-*); see RESIDUAL.md host tracks. Not claimed done from eval or local e2e alone |
| Local backends | Move UI (and proxied Stalwart HTTP when possible) toward Unix domain sockets |
| Not target | Caddy/Sozu/nginx as product identity |
| Always | Direct-to-VPS DNS; no CF required path; mail ports stay on Stalwart |
Tree default is already Axum-first (nginx off). Remaining operator work is host proof, not flipping the module default again.
- Place host certificate and key files (PEMs); set
listenMode=https+ public bind; leaveweb.enablefalse. Loud gate:secrets.requireDeployMaterial+ PEMrequiredHostPaths. - Prefer Unix socket upstreams over time; loopback TCP remains scaffold.
- Cert material for
mail.surmount.systemsmust stay usable by Stalwart for IMAPS/SMTPS (shared cert dir or documented export). Avoid trapping mail TLS only inside edge-private storage without a recovery story. - Host end-to-end:
SURMOUNT_E2E_HOST=1 SURMOUNT_E2E_BASE_URL=... just e2e-host(andSURMOUNT_E2E_LAB_IP=...unlessSKIP_BAN=1). Proves unit active, MemoryDenyWriteExecute, curl/healthover TLS, nginx inactive when web off. Full recipes below. - Dual-run only while migrating:
web.enable = true+ UI http loopback. Module asserts against dual-run + public UI https (:443 or non-loopback). - When dual-run is unused: remove nginx module path; update STACK, OPS, hygiene, architecture-review.
- Lock down or remove
/stalwart-admin/public path once Rust admin covers bootstrap needs.
Local first: nix run .#e2e / just e2e proves the product path with
self-signed temp certificate/key files, shared-router health/SSR/rate-limit/ban
contracts, and (when arti + Tor client exist) optional local hidden-service
publish with temp keys. That is not public cutover.
Host mode refuses to run without env (exit 2) so unset env never looks like a host pass:
# On the VPS (or against a staging deploy from a laptop):
export SURMOUNT_E2E_HOST=1
export SURMOUNT_E2E_BASE_URL=https://127.0.0.1 # required (health cannot be silently skipped)
export SURMOUNT_E2E_LAB_IP=203.0.113.50 # required unless SKIP_BAN; lab only
# optional:
# export SURMOUNT_E2E_TLS_HOST=services.example:443
# export SURMOUNT_E2E_TLS_SNI=services.example
# export SURMOUNT_E2E_ONION=....onion # published proof (unit active alone is not enough)
# export SURMOUNT_E2E_SKIP_BAN=1 # HTTPS+Arti only; summary ban_drop=skipped
nix run .#e2e-host
# or: just e2e-host| Track | What host mode checks | Notes |
|---|---|---|
| 1. HTTPS + hardening | BASE_URL required (FAIL if unset); health 200; UI unit active; MemoryDenyWriteExecute=yes (no writable+executable memory); TLS check (handshake; expired cert fails); nginx inactive; UI ambient and bounding set have no CAP_NET_ADMIN |
Operator PEMs on host; key not group/world readable; external PEMs lean until Q-EDGE/Q-CA answered; ACME-on-product-:80 parked |
| 2. Arti | surmount-arti-hidden-service (or override) active; User=surmount-arti; optional onion fetch via SURMOUNT_E2E_ONION + TOR_SOCKS |
Unit active != published. HS keys under onionServiceStateDir, never in git. Cleartext local backend when UI is https-only. Do not invent Q-ARTI-2/3 |
| 3. Ban / kernel firewall | Helper socket + helper socket unit; set preflight (exists only, not drop); LAB_IP required and must appear in surmount-ban4 (FAIL if unset/absent) unless SKIP_BAN=1 |
CAP_NET_ADMIN on helper only. Summary ban_drop=UNPROVEN (membership != traffic drop). Prove drop + cleanup out-of-band. Q-ACL-1..6 parked |
Harness SoT: nix run .#e2e-host (Rust crates/surmount-e2e). Host e2e is never
a default flake check. Expired certs fail those TLS rows.
Public CA + ACME is the common path today and remains a strong compatibility default for browser HTTPS. It is not the only option and is not locked for product law this turn.
Read: research/tls-trust-and-acme.md
Covers: public CA risk, private CA, DANE/TLSA for mail, short-lived certs, pinning limits, performance (handshake, OCSP, renewal), and the split between mail TLS and browser HTTPS. Open ids Q-TLS-1 ...
Open to a better CA than Let's Encrypt if automated and not too pricey:
| CA | Notes |
|---|---|
| Let's Encrypt | Common ACME default; free; rate limits |
| ZeroSSL | ACME; free tier + paid |
| Buypass | ACME-capable public CA |
| Google Trust Services | ACME; public WebPKI |
| Commercial ACME API | DigiCert, Sectigo, etc. when paid path is worth it |
Ids: Q-CA-1, Q-CA-2 in research/pqconnect-and-pqc.md.
| Rule | Lean |
|---|---|
| :80 -> :443 | Graceful redirect/upgrade on free production :80 (redirect-only product path). ACME HTTP-01 may share :80 only on dual-run nginx or a future Q-EDGE answer; not invent ACME-on-product-:80 |
| Insecure protocols | Disabled: SSLv3, TLS 1.0, TLS 1.1 |
| Prefer | TLS 1.3 |
| PQ KEX on Axum/rustls | First-class (D1): aws-lc-rs + prefer-post-quantum (X25519MLKEM768 first in default provider). Hermetic unit tests pin group config. Live host hybrid negotiation is residual until measured post-B1 (do not claim from local e2e alone). Probe: nix run .#surmount-tls-hybrid / just check-tls-hybrid (requires SURMOUNT_E2E_BASE_URL; exit 2 BLOCKED if unset) |
| OpenSSL 3 | Host hybrid probe + other host tools / some mail stacks; not the default in-process edge |
| PQConnect | Separate E2EE PQC path layer (D2); research + human-owned sibling packaging; does not replace D1 TLS hybrid KEX |
| Cloudflare | Research only. We may cite CF Research blog posts on hybrid PQ TLS / migration; we do not run CF products as edge or CDN |
Detail: research/pqconnect-and-pqc.md (includes plain URLs to CF Research PQ posts and the TLS-hybrid vs PQConnect split).
Arti onion / hidden services (REQUIRED; not the clearnet edge)
Arti (Tor Project Rust Tor) onion/hidden service reachability is required product surface for Surmount Server services (operator direction 2026-07-30). First-class alongside clearnet where clearnet applies. Still no Cloudflare products; own stack.
Arti is not a substitute for the Axum clearnet HTTPS edge. HS identity keys
and startup material stay in deploy secrets on the host (never in git);
human inventory via Vaultwarden (password manager API; Vaultwarden does not
implement Bitwarden Secrets Manager API today). Relay/egress remain separate
open choices. Module in tree: modules/arti-hidden-service.nix (management-
publish config + optional daemon). Service-capable package:
pkgs.artiOnionService. Clearnet browsers learn the onion via
Onion-Location and Alt-Svc on the Axum HTTPS edge (see above).
Live (2026-08-17): unit surmount-arti-hidden-service active; hostname
file present; discovery headers proven on mapped HTTPS. Residual: live Tor
Browser / onion-fetch verify, operator offline HS backup, public-module HOME.
Local optional Tor row: just e2e (temp keys; not operator backup). Do
not claim B3 fully closed.
Research: research/arti-and-secrets-manager.md. Pin: COMPACTION-PIN.md section 7.
Layers (defense in depth):
- nftables (opt-in): when
surmount.accessControl.enable+nftSets, tableinet surmount_guardwith setssurmount-ban4/surmount-ban6/surmount-whitelist4/surmount-whitelist6. Whitelist accept early; ban drop. Sets start empty; operator must load/verify on host. - Edge rate limit: fixed-window per client IP key (in-memory).
- Edge ban decide: same IP key; enforce mode returns 403 for banned; default enforcement off.
- App (Axum): Unauthorized -> ban is a thin BanCandidate hook
(
BanGuard::signal_unauthorized+ request-contextsignal_unauthorized); records under DryRun/Enforce; whitelist never banned. Wired for session-exchange parse/verify fail and bad presented NIP-98 on protected paths (not missing cookie; not 404/501). Matrix: SECURITY.md Auth failure to ban matrix. Q-ACL-1 (full surface list beyond auth) still open. - Stalwart: built-in anti-abuse, greylisting, spam-filter (mail plane).
- Ban policy (operator direction): unauthorized access of intentional auth/probe class -> immediate or near-immediate blacklist on the box. Whitelist IPs never banned; track last-used for hygiene.
- fail2ban today: light sshd sketch only (
hardening.nix); transitional. Long-term prefer Rust + nft over Python fail2ban as product identity.
Design + open Q-ACL-*: research/access-control-fail2ban.md.
Hermetic local (no secrets, no root kernel firewall required):
just e2e
# or focused cargo:
just test
cd crates && cargo test ban::Module contracts (firewall set names + lean defaults):
just check # includes module-eval accessControl testsHost (operator; not claimed done by tree or local e2e alone):
- Set
surmount.accessControl.enable = true(and optional whitelist CIDRs). - Confirm kernel firewall loaded:
nft list table inet surmount_guardshows empty ban/whitelist sets with the canonical names above (surmount-ban4/surmount-ban6/ whitelist sets). - Optional app enforce without host drop:
enforcement = "enforce"+ backend memory; curl from a test IP after a manual ban signal should 403. - Prefer host drop with all of:
backend = "nft",enforcement = "enforce",nftHelper = true, and absolutenftBin. UI connects toSURMOUNT_BAN_NFT_HELPER_SOCK=/run/surmount/nft-ban-helper.sockand a socket-activated oneshot (surmount-nft-ban-helper@) runs with CAP_NET_ADMIN/RAW.nftHelperrequiresbackend = "nft"(Nix assertion); Memory + helper is fail-closed, not a silent sock install. The UI unit keeps NoNewPrivileges and never gets CAP_NET_ADMIN; do not rely on child setcap spawn (blocked by NNP). Do not enablenftExecon the UI unit (mutually exclusive; module warning). DryRun never calls the helper. Live host proof that set elements drop traffic:SURMOUNT_E2E_HOST=1 SURMOUNT_E2E_BASE_URL=... SURMOUNT_E2E_LAB_IP=... just e2e-host(BASE_URL required; lab IP only; cleanup required; orSKIP_BAN=1for HTTPS-only). App-levelenforcement = "enforce"+backend = "memory"works without host firewall (step 3). - Rate limit: burst past
rateLimitMaxRequests=> 429 + Retry-After. Enforce bans short-circuit before rate counters. Redirect-only :80 shares the same ban/rate middleware (403/429 only; no cleartext API). - Behind dual-run nginx: only X-Real-IP from loopback is trusted for keys; XFF is ignored.
Do not pretend edge rate limits replace mail authentication and spam policy. Spam detection is first-class product priority (operator direction). Do not ban on every mail greylist event.
| Port | Role | Edge? |
|---|---|---|
| 80 | Product redirect-only (default path); dual-run ACME if nginx escape | Yes (Axum / dual-run) |
| 443 | Product HTTPS (management-ui rustls) | Yes (Axum; not Stalwart product edge) |
| 25/465/587/993/4190 | No (Stalwart) | |
| UI loopback / Stalwart HTTP :8080 | Local only | Loopback; free Stalwart public HTTPS via nix/stalwart plan |
Q-EDGE-1. Shared host ACME PEM files for mail + web vs in-process issuance on the Axum edge for web only? Status (2026-08-10): in-process ACME for management-ui web is operator-directed work in progress / scaffold (default off, DNS-01, instant-acme). Does not close shared mail+web PEM pipeline, multi-service renew, or full Q-EDGE. Do not claim all edge Q-* closed.
Q-EDGE-2. UDS path layout under /run/surmount/ vs /run/ service-specific
dirs?
Q-TLS-1 ... cert trust choices: research/tls-trust-and-acme.md.
Q-CA-1 / Q-CA-2. Primary public CA and multi-CA failover: research/pqconnect-and-pqc.md.
Q-PQC-1 ... PQConnect timing and mail PQ path: same research note.
Q-ACL-1 ... merciless ban definition and whitelist store: research/access-control-fail2ban.md.
(Axum-first vs separate proxy product: answered -- Axum-first preferred.)
modules/web.nix- transitional nginx (delete after cutover)modules/networking.nix- firewallmodules/hardening.nix- fail2ban sketch + optional accessControl nft setscrates/management-ui/src/ban.rs- ban decide + backends- operator-direction.md
- principles.md
- SECURITY.md
- OPS.md - checks and runbooks
- research/rust-edge-and-uds.md
- research/tls-trust-and-acme.md
- research/pqconnect-and-pqc.md
- research/arti-and-secrets-manager.md
- research/access-control-fail2ban.md
- research/luks2-and-deploy-secrets.md
- DNS.md (mail earn-trust / DANE checklist)