38 Commits
Author SHA1 Message Date
wweir 06c357cd04 fix(sowerd): keep the SIGUSR1 heap dump off the Windows build
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.
2026-10-12 19:13:36 +08:00
wweir 28c4f9e8ca fix(sowerd): bound the connection resources a burst can pin
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.
2026-10-12 17:55:45 +08:00
wweir b42b2738e7 test(sowerd): stop the authority checks from passing for the wrong reason
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.
2026-10-12 16:09:19 +08:00
wweir d15ba70100 feat(sowerd): let a site route present the client's authority to its upstream
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.
2026-10-12 14:35:41 +08:00
wweir d14ee7cbfa test(sowerd): add the positive control for transport auth dispatch
`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.
2026-10-12 11:06:28 +08:00
wweir 8a3670d50e feat(sowerd): add an optional HTTP Basic gate for site routes
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.
2026-10-10 19:15:32 +08:00
wweir b2b344e948 feat(sowerd): answer TLS handshake for non-whitelisted SNI
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.
2026-09-18 17:38:17 +08:00
wweir 5c15453dc6 fix(sowerd): log the routing context of proxy upstream failures
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.
2026-09-16 15:36:03 +08:00
wweir 8dad77b778 feat(sowerd): reverse-proxy HTTP fake-site fallback with edge headers
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.
2026-08-28 13:37:05 +08:00
wweir 879c83f0fa feat(sowerd): support path-prefix routing within site routes 2026-08-13 08:44:38 +08:00
wweir b16325b413 fix: correct three regressions from the hardening pass
- 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.
2026-08-12 17:43:24 +08:00
wweir 0ad94ba31c fix(sowerd): whitelist autocert issuance, bound probe handshake, fix fake site target
- 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.
2026-08-12 17:19:31 +08:00
wweir 249c3ae63f fix(config): send remote password verbatim to upstream proxy
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.
2026-08-11 00:17:47 +08:00
wweir 033a070db6 feat(admin): temporary password fallback with login hint and password masking 2026-08-10 16:17:57 +08:00
wweir 52f9205e6a refactor(config): drop YAML support, keep TOML only 2026-08-10 16:17:11 +08:00
wweir bbab57f2a0 fix(sowerd): retry empty reads during protocol probe
A (0, nil) Read is legal on TCP though rare; previously it aborted the
connection. Retry in a loop instead, bounded by the caller's probe
deadline.
2026-08-05 14:04:46 +08:00
wweir c76dadbda1 feat(sowerd): support WebSocket upgrade in site routing
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.
2026-08-05 14:04:38 +08:00
wweir e83c130881 fix(sowerd): clear probe read deadline before protocol unwrap
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.
2026-06-28 21:16:31 +08:00
wweir 474749d865 test(sowerd): add fallback, auth-failure and SNI routing tests
- TestHandleConnRoutesFallbackBySNI: verifies TLS SNI → site route
- TestHandleConnFallsBackToFakeSiteWhenSNIMisses: no-route SNI → fake_site
- TestHandleConnFallsBackAfterSowerAuthFailure: wrong password → fallback
- Add startHTTP1TCPServer and startRawTCPServer test helpers
- Fix config test: trojan → sower for TLS client hello validation
2026-06-25 09:20:34 +08:00
wweir 91dc8b7a87 feat(sowerd): add protocol probe abstraction and refactor handleConn
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
2026-06-25 09:20:27 +08:00
wweir 0547502748 feat(sowerd): route fallback sites by SNI 2026-06-24 09:24:25 +08:00
wweir 549ab2a990 fix: fallback to system cache for sowerd service 2026-03-31 07:46:32 +08:00
wweir 6ccdcc131b feat: add sowerd install and startup validation 2026-03-30 23:02:54 +08:00
wweir 677b0593b8 refactor: update sowerd main.go configuration handling 2025-10-28 20:04:27 +08:00
wweir f7a288af4c refactor: extract configuration to dedicated config package
- 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
2025-09-07 00:10:56 +08:00
wweir 91b7f8e8c1 refactor: rename module from github.com/wweir/sower to github.com/sower-proxy/sower
- Update go.mod module declaration
- Update all import paths in Go files
- Update GitHub links in README.md
- All tests pass and build succeeds
2025-09-06 08:27:11 +08:00
wweir c347e15fe2 fix(stop): avoid unexpected stop 2025-05-14 13:17:18 +08:00
wweir 62176ffb65 feat: support fake site serve directory 2025-04-29 16:18:56 +08:00
wweir 5fe202314d feat: change log package to log/slog 2025-04-29 14:24:35 +08:00
wweir 77c4154c7c fix: http_proxy 2024-10-12 07:20:39 +08:00
wweir 19dcf8e0d7 fix: dns proxy hang case 2022-05-29 12:43:48 +08:00
wweir bad7ae702a chore: separate packages 2021-12-06 14:44:24 +08:00
wweir 48e9680c03 chore: update dependency 2021-11-10 18:58:24 +08:00
wweir 38c7391ceb 1. add comments, 2. optimize text message 2021-08-11 06:48:18 +08:00
wweir 65234b1666 migrate deferlog to the separate package 2021-08-09 23:49:51 +08:00
wweir 74202aad83 optimize sowerd code 2021-07-30 19:13:59 +08:00
wweir 8bfb26ba3f support use socks5 as upstream proxy 2021-07-29 16:19:55 +08:00
wweir 5cf8df2a7a change binary name 2021-07-19 16:02:26 +08:00