syscall.SIGUSR1 is undefined on Windows, so the release matrix failed to
build there (v1.7.2 never reached its release job). Move the signal handling
into a unix build-tagged file with a no-op elsewhere; the profile writer
itself stays portable.
sowerd was OOM-killed on kr with 970 MiB anon-rss on a 1.6 GiB host: a
connection burst in one window ballooned the heap and never came back.
Three unbounded holds made that possible, all on paths the log does not
show:
- the :80 listener had no timeouts, so a connection that never finished a
request kept a goroutine, a socket and its buffers for as long as the
peer stayed connected;
- the :443 accept loop had no concurrency cap, so resident memory grew with
the arrival rate (a failed handshake returns silently);
- relay.Relay only arms read deadlines, so a peer that stopped reading
blocked its writer forever, leaking the connection pair, its goroutines
and its descriptors (and the relay conn log never fired, because the call
never returned).
Arm a write deadline per write below the TLS listener, cap served
connections, set every plain-HTTP timeout, and dump a heap profile on
SIGUSR1 so the next occurrence can be attributed to an allocation site.
Evidence and the open questions (the trigger is still unproven, and r6c's
sower client shows a different, fd-independent growth) are recorded in
docs/plans/2026-10-12-sowerd-connection-bounds.md.
Review of the previous commit found the validation test green for a reason it
did not claim: SowerdConfig with an empty FakeSite fails Validate on
"fake site is required" before validateSiteRoutes runs, so the bogus authority
was never exercised. Set a valid FakeSite and assert the error names the
authority field, which the assertion can no longer satisfy by accident.
Also fold the router mapping case into the end-to-end test and drop the
standalone TestSiteRouterPreserveHost: the positive mapping is already implied
by the wire assertion, and the default case costs one extra route and two
checks there. Both directions were verified by mutation — inverting the mapping
and letting Validate accept the bogus value each turn the covering test red.
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.
`TestHandleConnFallsBackAfterSowerAuthFailure` is the negative arm (a wrong
password falls back to the website path), but nothing exercised the positive
one end to end, so a green run could not distinguish "the frame was relayed"
from "the transport branch never engaged" — only the frame-level Wrap/Unwrap
unit tests covered the authenticated path.
Add TestHandleConnRelaysAfterSowerAuth: a frame authenticated with the
configured password is relayed to the target named inside the frame, asserted
against a fallback body that differs from the target's. Verified by mutation:
a server/client password mismatch flips the response to the fallback body and
the test fails.
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.
A handshake failure on unknown SNI is an active-probing signal: real
sites present their default vhost certificate. Whitelisted names keep
the plain autocert path with errors surfaced as-is; unknown or empty
SNI falls back to the primary domain's certificate, carrying the
client hello over so autocert derives the same ECDSA/RSA cache key.
The h2-on-fallback companion idea was investigated and rejected:
httputil.ReverseProxy has no RFC 8441 support and x/net gates
extended CONNECT behind GODEBUG only, which would break WingGate WSS.
The default ReverseProxy error handler logs a bare
"http: proxy error: <err>": with site routes and path overrides the
upstream address alone does not identify which route failed, so a
refused upstream could not be attributed to a route without guessing.
Report upstream, request method/host/path and the peer address, and
answer 502 with a body instead of an empty response. The handler is also
reachable before any byte is written (round-trip and upgrade failures),
so the response semantics are unchanged.
Unmatched HTTP/1.x browsing is reverse-proxied to fake_site instead of
TCP-spliced, preserving Host. Site routes and this path overwrite
X-Forwarded-For, X-Forwarded-Host, X-Forwarded-Proto, and X-Real-IP from
the TLS peer and drop client-supplied forwarding headers. Non-HTTP
fallback, including sower auth failure, still relays the original bytes.
- Config override validation now accepts bracketed IPv6 remote addresses
([2001:db8::1]:8443): the host part is checked as an IP literal before
falling back to hostname rules, so a valid IPv6 remote no longer fails
validation.
- Directory fake sites serve local relays even when serve_ip is a
concrete non-loopback address: the local check now recognizes any
address bound to a local interface, not just loopback, so fallback
traffic is not wrongly 307-redirected to https.
- The config view's restart handler ignores failures after the component
is destroyed, so a late 401 from the in-flight restart request can no
longer trigger a global logout from a dead page.
- The login throttle bounds its failure map (purges expired entries when
full) so rotating source IPs cannot grow it without bound.
- autocert gets a HostPolicy: certificates are only issued for site_routes
domains plus the new cert.domains whitelist, so an arbitrary SNI can no
longer drive ACME issuance and exhaust the account's rate limits. Direct
connection domains (the sower client's remote addr) must be listed in
cert.domains.
- The TLS handshake now completes explicitly (60s budget) before the 1s
protocol probe, so the first connection to a new domain no longer loses
the race against synchronous ACME issuance inside GetCertificate.
- The transport header read after a probe match keeps a 5s deadline
instead of none: a connection that sends only the 0x80 probe byte can no
longer hold a goroutine and fd forever.
- Directory fake sites relay to the configured serve_ip:80 when it is
concrete, instead of always 127.0.0.1:80, which broke HTTPS fallback
when serve_ip did not cover loopback.
deferlog.Password decodes canonical base64 input on unmarshal, so a password
that happens to be valid base64 (e.g. "fnhkwfnh") was silently corrupted
before reaching sower.Wrap. Auth then failed and the upstream fell back to its
camouflage HTTP site, breaking rule-file loading at startup. Restore plain
string for Remote.Password and sowerd Password, keep deferlog.Password for the
admin console only, and mask the remote password in the startup log again via
deferlog.Secret.
Allow HTTP upgrade requests (WebSocket/CONNECT) through the fallback
reverse proxy instead of rejecting them with 400. Once the connection
is hijacked it is owned by the ReverseProxy upgrade goroutine, so
handleConn skips closing it; the single-conn listener is closed to
unblock Serve while the tunnel stays alive.
Previously the probe read deadline (1s absolute time set at handleConn
entry) remained active while handler.Unwrap ran its io.ReadFull on the
protocol header. A slow client could let Unwrap be interrupted by the
remaining sliver of the probe timeout, causing spurious auth/decode
failures and fallback to the fake site. Clear the deadline once at the
top of the probeMatch case, before Unwrap, so the protocol handshake
gets its own time budget consistent with the subsequent relay path.
Introduce proxyProtocolHandler interface with Probe/Unwrap methods,
extracting protocol detection from handleConn into a clean pipeline:
probe first bytes → match handler → Unwrap, or fallback by SNI.
- probe.go: probeVerdict enum, proxyProtocolHandler interface,
sowerProtocolHandler, readProtocolProbe
- handleConn now iterates handlers list, falls through to TLS SNI
routing on probeNoMatch, and to fake_site on auth failure
- serve443 signature changed: trojan param replaced by []proxyProtocolHandler
- Move configuration structures from cmd/sower/main.go and cmd/sowerd/main.go to config package
- Add config.SowerConfig and config.SowerdConfig types
- Implement Validate() method for configuration validation
- Update Go module dependencies to latest versions
- Improve code organization and maintainability