A site route always rewrote the outbound Host to the upstream host, so an
upstream that validates the authority it is visited under could not sit behind
one: pi-web compares Origin's host against the Host header, answers 403 to
every browser-originated /api request, and the page loads while its WebSocket
never connects.
Add `authority = "upstream" | "client"` to a site route, defaulting to
"upstream" so existing routes keep the current behavior byte for byte.
"client" forwards the authority the client asked for, which is what such an
upstream needs; its own allowlist must still name the public domain (pi-web's
PI_WEB_ALLOWED_HOSTS), because the forwarded name is client-controlled.
The key is one word on purpose: aconfig v0.19.0 title-cases the keys of an
array-of-tables element, so `preserve_host` is rejected at load time — probed
before the spelling was chosen.
- config: SiteRoute.Authority, the AuthorityUpstream/AuthorityClient values,
and SiteRoute.PreserveHost(). Validate rejects an unknown value instead of
silently defaulting, since the two values differ in what the upstream sees.
- cmd/sowerd: newSiteRouter feeds that flag into the siteEntry field the
fake-site fallback already used.
- docs: ARCHITECTURE.md and security.md record the rule, why the adaptation
belongs to the edge rather than to an upstream trusting X-Forwarded-Host,
and what preserving a client-controlled authority does not protect.
- tests: TOML round-trip, unknown-value rejection, router plumbing, and an
end-to-end handleConn case asserting the upstream sees the client's name.
`sowerd -i` wrote config.ExampleSowerdConfigTOML verbatim, so a fresh
install started with `password = "change_me"` — a publicly known credential
— while Validate rejects only an empty password. The admin console already
generates a random password when none is configured; do the same here.
- renderDefaultConfig replaces the template's top-level `password = ` line
with a random 128-bit hex value (the commented `# password = ...` sample
inside site_routes.auth is left alone) and errors when the template has no
such line, instead of silently keeping a known value.
- The password is printed once during install for the client configs, and an
existing /etc/sower/sowerd.toml is still never rewritten.
- docs: security.md states this under "What the Transport Password Gates"
and moves the risk out of Residual Accepted Signals — a fixable
operational defect is not an inherent signal — recording the declined
alternative (a placeholder blocklist in Validate) with its rationale;
ARCHITECTURE/README describe the generated password.
Gate: gofmt, go vet ./..., go test ./... green; install_e2e_test PASS.
The threat model scoped the adversary as "does not know the sower
password" but never said what holding it grants, which is the question an
operator asks before pointing a public domain at an intranet service:
- one shared secret, no per-client identity and no revocation short of
rotating it for everyone (transport/sower's Sower holds only the password);
- a passing frame reaches any target the host can dial — Sower.Unwrap
rejects only empty hosts and control characters, so there is no allowlist
and a leaked password exposes the whole network position;
- no attempt throttling on the transport path (a wrong checksum just falls
back to the website path); only the site_routes gate is throttled;
- password handling is the operator's: validation rejects only an empty
value, the HMAC key is the raw password, and the shipped default config
still carries the change_me placeholder (now flagged in Residual Accepted
Signals and marked in the example config as requiring replacement).
ARCHITECTURE.md step 11 points at the authority for the no-allowlist fact.
Site-route domains become public once autocert issues for them
(Certificate Transparency), and the edge answers any SNI. An upstream that
cannot authenticate its own visitors is therefore exposed, and sowerd owns
80/443 on the host, so nothing can be placed in front of it.
Add an opt-in per-route visitor gate:
- config: site_routes[].auth {username, password}; both fields required,
empty means ungated, ':' rejected in the username (RFC 7617 splits there).
- runtime: the gate runs in the reverse-proxy handler, so it covers path
overrides and WebSocket upgrades too; the sower transport path is
dispatched earlier and stays untouched. Credentials are compared in
constant time, failed attempts accumulate per source IP and lock it out for
15 minutes (bounded map, mirroring the admin login throttle), and a passing
request has its Authorization header removed so the edge credential never
reaches the upstream. The fake-site decoy is deliberately never gated.
- fix: upstream URL userinfo was silently dropped. ReverseProxy sends
through a Transport, and only net/http.Client turns userinfo into Basic
credentials, so sowerd now sets that header itself; the upstream password
is redacted in proxy error logs (url.URL.Redacted).
- docs: security.md records the mechanism and its limits (not a substitute
for upstream auth, no session/logout, CORS preflight blind spot, and the
browser WebSocket handshake: script cannot set Authorization, cached-
credential replay is browser-specific and unverified, and a reconnecting
client's failed handshakes feed the same per-IP counter, so it can lock its
own source IP out of the whole route) plus the rejected alternatives
(session login, gating the fake site).
Gate: gofmt, go vet ./..., go test ./... green; install_e2e_test PASS.
admin-console.md and site-routing.md still carried the full design and
implementation checklists for work that shipped and was written back to
ARCHITECTURE.md. Keep only what cannot be recovered from the code -- the two
frontend constraints and the site-routing trade-offs -- and point at the
authoritative documents. Drop the plan-to-plan "Supersedes" reference from
admin-persistence-config.md so plans stop sourcing rules from other plans.
security.md gains the rejected directions those rewrites now point at:
wildcard site_routes domains (rejected in validateSiteRoutes, and the SNI
dispatch is an exact-match siteRouter.lookup), additional listen addresses or
configurable 80/443 ports, per-site certificate binding, and domain routing on
plain HTTP.
Review of the anti-probing hardening found no code defects; the fixes
are documentation:
- docs/security.md: authoritative threat model, the three camouflage
layers, unknown-SNI certificate fallback rules (whitelist source,
error surfacing, capability propagation, concrete first entry), the
client_hello fingerprint knob, rejected directions (QUIC, h2) with
evidence, and residual accepted signals.
- Promote the plan doc to a non-authoritative historical record per
AGENTS.md docs rules; index security.md from docs/README.md and
ARCHITECTURE.md.
- config/sowerd.toml: document cert.domains; the first entry answers
unknown-SNI handshakes.