450 Commits
Author SHA1 Message Date
wweir 0593934066 docs(plans): record the rollout that was actually verified
r6c now runs the v1.7.7 release artifact (sower-linux-arm64.tar.gz, sower
sha256 8cf0a869..., vcs.modified=false) rather than a local build, so the
verification line names that artifact.
2026-10-13 07:01:54 +08:00
wweir 93ffaec848 docs: fold the review findings into the plans
The sowerd bounds plan still described the recursion driver as unfixed and
carried a deploy log that the board receipt already holds; the client plan
duplicated deploy pitfalls that live in the skill. Both now state what was
verified, point at each other, and keep the regression lesson (the guard must
ask the upstreams, not the system resolver) without restating the mechanism
twice.
v1.7.7
2026-10-13 06:58:40 +08:00
wweir 116d16b98b refactor(sower): guard the dial exits instead of the routing entry
Two findings from reviewing the self-target guard:

- the guard sat at the top of DialSmart, so a blocked target that also points
  at this client reported ErrSelfProxyTarget instead of ErrBlocked, and every
  name on a served port paid an upstream lookup before the rules were even
  consulted. It now sits in directDial, the only other dial exit, so blocked
  targets are decided without resolution and policy keeps precedence.
- selfProxyLookupTimeout was left behind by the switch to upstream
  resolution; drop it.
2026-10-13 06:58:40 +08:00
wweir 7b2a203cf3 fix(sower): answer refused proxy requests instead of closing silently
handleHTTPConn's dial-failure branch only logged and returned, so a client
saw a closed connection and could not tell a policy refusal (blocked target,
this proxy's own listener) from a broken proxy. It now writes the same
refusal response the SOCKS5-side HTTP path already writes (403 for blocked
and self-target, 502 otherwise), and the shared-listener test asserts that
status instead of the close.
v1.7.6
2026-10-12 20:16:43 +08:00
wweir 8db5288365 fix(sower): resolve the self-target check through the upstream resolvers
v1.7.4 asked the system resolver and was wrong on a deployed client: this
client's own DNS answers its serve address for every domain it proxies (that
is how its clients are steered into it), so "the name resolves to one of my
listeners" is true for every normally proxied domain. In the field that
refused legitimate traffic (SOCKS5 google answered code 2 not-allowed).

The recursion discriminator has to be the name's own record, i.e. what the
relay will act on, so the guard now resolves through the configured upstreams
(which already exclude this client's serve addresses) and caches the answer,
including an empty one, for a minute. Verified against a fake upstream in
tests: a name the upstream points at this client is refused, one it resolves
remotely is proxied.

docs/plans/2026-10-12-self-proxy-target-guard.md records the regression, the
rollback and the deploy pitfall (cat > file leaves 0644, so cp -a installed a
non-executable binary and procd could not start the service).
v1.7.5
2026-10-12 20:11:34 +08:00
wweir 2de08e62e4 fix(sower): guard the direct dial path against self-targeting too
DialSmart can reach directDial without passing DialProxyOnly (a direct rule
match, or an access probe that marks the name reachable), and a direct dial
back into this client's own proxy listener is a self-sustaining loop that
never leaves the host. Guard both entries; the second check is a cache hit.
v1.7.4
2026-10-12 19:35:38 +08:00
wweir b688b93fbc fix(sower): refuse a proxy dial that resolves into this client's own listener
One request to a name whose A record is the client's own serve address
became a self-sustaining chain: the proxy forwarded it into the tunnel, the
relay resolved the name back to the client and dialed the client's own
listener, which forwarded it again. Measured on r6c: 601 connections and a
relay host that ran out of memory.

A proxy dial must never re-enter a listener this client forwards proxy
traffic on. Router.DialProxyOnly now refuses such a target: the listener set
is the HTTP/HTTPS proxy addresses on every DNS listen address plus SOCKS5,
resolution is skipped for ports this client does not serve, and the answer is
cached for a minute so a proxied dial does not pay a DNS round trip per
connection. Refusals surface as ErrSelfProxyTarget - HTTP 403, SOCKS5 0x02 -
and a resolution failure is not treated as evidence of self-targeting.

Plan, rejected alternatives and the evidence trail:
docs/plans/2026-10-12-self-proxy-target-guard.md
2026-10-12 19:30:56 +08:00
wweir 6e22cf42a2 docs(plans): record the recursion driver and the v1.7.3 rollout
Another session identified the driver of the connection burst: any proxy
request naming sower.wweir.fun forms a self-sustaining r6c<->kr chain
(measured 155->601 connections on r6c, 311 dials / 649 fds on kr). sowerd's
cap and write-stall bound it; the client-side self-target guard is the real
fix and stays open, as does the conns relay defect. Also records that v1.7.2
never published (its Windows build failed) and kr now runs the v1.7.3
release artifact (sha256 74b34fd9..., vcs.modified=false).
2026-10-12 19:18:53 +08:00
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.
v1.7.3
2026-10-12 19:13:36 +08:00
wweir 3c869cd32d fix(sowerd): give the service a soft heap ceiling on every install path
The 2026-10-12 OOM killed the host, not the service: sowerd had no memory
ceiling anywhere while the sower client has had SOWER_MEMORY_LIMIT_MB since
d0031e4. Put a GOMEMLIMIT on the unit the installer writes and on the
packaged unit, so a runaway heap degrades sowerd instead of OOM-killing the
machine it runs on. kr already carries the same value through a drop-in.
v1.7.2
2026-10-12 17:58:08 +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 bf66914b94 docs(plans): drop a site-routing bullet filed under the wrong header
512515d renamed "## 实现决策" to "## 已推翻的实现决策" and kept the
struck-through upgrade claim, but the other two decisions were merged into one
line that stayed under the renamed header although neither was overturned.

Both halves of that line — the outbound Host rewrite, and the four reverse
proxy timeouts — were already stated in ARCHITECTURE.md at the time of that
trim (lines 143 and 146), and this plan's own scope line defers runtime facts
to the code and ARCHITECTURE.md. So the bullet was a duplicate as well as a
misfiling: drop it rather than move it, which also retires the forward note
dab041b had added to patch the stale claim.

The surviving bullet's evidence still exists: TestReverseProxyConnWebSocket and
TestReverseProxyConnPathRoutedWebSocket, cmd/sowerd/main_test.go:777 and :709.
2026-10-12 16:43:08 +08:00
wweir dab041b9f6 docs(sowerd): keep the site-route authority rule in one place
Second half of the review: the rationale for `authority` had been written into
every comment that touched it, which is three copies to keep in sync. Keep the
argument in docs/security.md and leave the config example, the struct comment
and the constants stating only what the value does.

- docs/security.md: record "an upstream trusting X-Forwarded-Host" under
  Rejected Directions, where the document already collects declined designs,
  instead of inside the new rule section; merge the per-route bullet into the
  client-controlled one they overlapped with.
- ARCHITECTURE.md: the sentence added last commit left the fake-site rationale
  dangling after the site-route clause; split it back into one claim per
  sentence.
- docs/plans/site-routing.md: the plan states the unconditional Host rewrite as
  a decision of its time, so it gets a forward note to the authoritative
  document rather than a rewrite of the record.
- config/sowerd.go: clearer validation error ("unknown value", naming both
  accepted values) and a tighter comment; no behavior change beyond the
  message.
2026-10-12 16:09:19 +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 80dfa072e9 fix(ci): build the release binaries before copying packaging files in
Every published artifact was stamped `vcs.modified=true` with module version
`+dirty` (verified on v1.6.1, v1.6.2 and v1.7.0): the packaging step copies
`.github/sower.toml` and the service files into the repository root, and the
toolchain records the worktree state at build time. sowerd.service is neither
tracked nor ignored, so the tree was dirty before the compiler ran. Kr's edge
deployment rule ("confirm vcs.modified=false") was therefore unsatisfiable
from a release artifact, and the binary could not be held to any commit.

Move the copies and the tarball into a Package step that runs after the build.
Verified locally: building this commit on a clean tree reports `v1.7.0` with
vcs.modified=false, while copying one service file in first reproduces
`v1.7.0+dirty` — the same experiment the next tag re-runs end to end.
v1.7.1
2026-10-12 15:58:09 +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.
v1.7.0
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 a6c76b01db test(install): pin the embedded example against renderDefaultConfig
The installer renders config.ExampleSowerdConfigTOML through
renderDefaultConfig, which now hard-errors unless a line starts with
exactly `password = `. The new tests only used hand-written stubs, so
reformatting the real template (indenting the line, dropping the space)
would fail every fresh `sowerd -i` at runtime with nothing catching it.

Add one test that renders the real embedded template and asserts no error,
the generated password present, and the placeholder surviving only in the
commented site_routes.auth sample. Verified by mutation: `password=` (no
space) and an indented line both turn it red.
2026-10-12 10:56:55 +08:00
wweir 0b27a34f3e fix(install): generate the client password instead of the placeholder
`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.
2026-10-12 10:44:47 +08:00
wweir 2894a15510 docs(security): state what the transport password does and does not gate
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.
2026-10-12 10:35:33 +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 8e5fd3e805 test(install): cover install.sh end to end from make test
install.sh was only syntax-checked (sh -n), so the install and upgrade paths,
the config-preservation contract and the failure modes for a malformed release
tarball had no coverage. Add an offline, unprivileged E2E test that builds its
own tarballs and installs into a temp prefix with --service none: fresh
install, an in-place upgrade that keeps an operator-edited config, --with-server,
a dry run that creates nothing, three malformed tarballs that must fail closed,
and the CLI surface. Wire it into make test as test-install and syntax-check
the new script.
v1.6.2
2026-10-10 14:57:10 +08:00
wweir d6617ef101 fix(install): preview the commands --dry-run actually runs
The systemd preview printed "daemon-reload && enable --now sower" while the
script runs daemon-reload, enable and restart; the launchd preview printed only
the load step and omitted the unload it runs first. A dry run is worth exactly
its fidelity to the real sequence, so print the steps as they will run.
2026-10-10 14:57:07 +08:00
wweir 3d2c940428 docs(plans): correct the fake-site fallback claim in the site routing plan
The rewrite kept the pre-rewrite claim that leaving site_routes unset restores
the old behaviour exactly. It does not: the HTTP-looking fallback is
reverse-proxied with the original Host and edge forwarding headers
(ARCHITECTURE.md, sowerd Data Flow steps 13-14) and NextProtos is http/1.1-only,
which this same file already calls a deliberate decision. Say only what holds:
fake_site stays the single fallback target.
2026-10-10 14:57:03 +08:00
wweir 512515db9b docs: trim the completed plans and record the sowerd rejected directions
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.
2026-10-10 14:57:00 +08:00
wweir ea243feb00 docs(architecture): drop the duplicated custom certificate SAN note
The Operational Notes bullet repeated the Design Decisions rule that a custom
certificate must cover every routed domain through SANs -- and config.Validate
already enforces exactly that through validateSiteRouteCertificate. Keep the
normative sentence in one place.
2026-10-10 14:56:52 +08:00
wweir 2988505770 fix(config): keep the checked-in sowerd example in sync with the embedded one
config/sowerd.toml and ExampleSowerdConfigTOML are the same artifact kept in
two places: the constant is what `sowerd -i` writes to /etc/sower/sowerd.toml
(internal/install/service.go), the file is what `-c config/sowerd.toml` loads
during development. They had drifted in both directions -- the constant
carried the [site_routes.routes] path-prefix example and the older cert.domains
wording, while the file carried the newer wording and left `domains`
uncommented -- so a deployment got guidance contradicting the shipped binary
depending on which copy it read.

Take the constant as the source of truth, comment out the placeholder
cert.domains entry (a placeholder domain is not one the operator owns) and pin
the two copies byte for byte with a test that prints the differing lines.
2026-10-10 14:56:49 +08:00
wweir f37e5fa758 feat(install): add a one-shot installer for systemd, procd and launchd
Installing sower meant grabbing a tarball and wiring the service up by hand,
and the packaged unit was too fragile for something a resolver depends on
(on-failure restart after 1min, no memory limit).

Add install.sh: detects the platform and init system, installs or upgrades
the binary atomically, keeps an existing config, verifies the release
SHA256SUMS unless explicitly opted out, and registers the service on systemd,
OpenWrt procd or macOS launchd. Every network call carries an explicit
timeout, the OpenWrt init script ships in Linux tarballs, the release
workflow generates SHA256SUMS, the README documents the installer, and
make test syntax-checks the scripts.
v1.6.1
2026-09-18 22:53:55 +08:00
wweir d0031e4106 fix(sower): keep the soft heap limit above the live rule heap
The default 128 MiB soft memory limit sits below the ~105 MB live heap the
full pexcn rule sets build, so the GC goal gets pinned to the live heap and
every allocation triggers a full-heap mark: measured 39% CPU on an otherwise
idle gateway with a GC cycle every 300-500 ms.

Raise the default to 512 MiB, resolve the limit in a testable helper that
covers GOMEMLIMIT/override/disable precedence, and declare the same value in
the packaged service plus the example configs so a deployment can tune it
without a rebuild.
2026-09-18 22:37:03 +08:00
wweir 44b7eefe91 ci(release): copy every matrix service file from .github
matrix.service lists space-separated file names ("sower.service
sowerd.service"), but the .github/ prefix only applied to the first,
so every Linux job failed with 'cannot stat sowerd.service' in
Prepare packaging files. Broken since 1e44871; first exercised by
the v1.6.0 tag. Loop over the list and prefix each file individually.
v1.6.0
2026-09-18 18:16:00 +08:00
wweir 5dc57bc234 docs: security model for the sowerd edge; document the unknown-SNI cert fallback
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.
2026-09-18 18:05:02 +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 c4129c06ef docs: describe fake-site HTTP reverse proxy and forwarding headers
Correct the sowerd data flow and the stale claim that fallback rejects
HTTP upgrades. README notes X-Forwarded-* on unmatched browsing.
2026-08-28 13:37:14 +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 bfad951fb0 docs: reflect hand-maintained shadcn-style UI without bits-ui 2026-08-20 18:15:23 +08:00
wweir f0cfd6941d refactor(web): hand-maintain shadcn-style UI components, drop bits-ui
Rewrite dropdown-menu, label, and utils as native Svelte 5 components
with a shared context store; remove now-unused separator and tabs;
drop the bits-ui dependency.
2026-08-20 18:15:19 +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 f3894ef110 docs: reflect cert whitelist, probe handshake, frame checksum, DHCP validation 2026-08-12 17:34:37 +08:00
wweir f89ca0425a fix(web): cancel restart polling on destroy, surface dead SSE, harden rules races
- The post-restart polling loop in the config view is cancelled by an
  AbortController on component destroy, so navigating away no longer
  leaves background probes running or lets a stale 401 from a dead page
  trigger a global logout.
- The traffic and overview views show the disconnect alert even before
  the first SSE payload arrives, instead of a permanent spinner with no
  hint when the stream never connects.
- ConfigChanges declares dns_reverse so the frontend contract matches the
  backend whitelist.
- The rule-miss list guards against out-of-order responses; the rules
  view clears its timers on destroy; the chart a11y warnings are silenced
  with an explanation.
2026-08-12 17:31:35 +08:00
wweir 3ea411e5fd fix(transport,pkg): authenticate full sower frame, validate DHCP offers, harden handshakes
- The sower frame checksum now covers the command and port in addition to
  the target, using HMAC-SHA256 (length-extension resistant) so a
  man-in-the-middle on a plaintext link cannot retarget a captured frame
  to an arbitrary port.
- Unwrap rejects empty or control-character target hosts.
- The SOCKS5 server writes the RFC 1928 METHOD=0xFF failure response
  instead of leaving the peer hanging on an unusable negotiation.
- DHCP discovery validates the reply (matching xid, BOOTREPLY, message
  type OFFER) and skips unrelated packets instead of trusting the first
  datagram, closing a LAN-side DNS-hijack vector; the socket also sets
  SO_BROADCAST explicitly (Go 1.26 removed UDPConn.SetBroadcast).
- The uTLS handshake gets a bounded default deadline when the dialer has
  no timeout, so it cannot hang open forever.
- The suffix tree trims every trailing dot so dirty rule files cannot
  build unreachable empty-label subtrees.
2026-08-12 17:26:26 +08:00
wweir 3286851fdf fix(install,config): lock down installed config, atomic binary copy, reject socks5 password, parse bare ipv6
- The installer writes /etc/sower/sowerd.toml with 0600 instead of 0644:
  the file holds the plaintext client-auth password.
- Binary updates are atomic (temp file + rename) so an interrupted update
  cannot leave a truncated sowerd at /usr/local/bin.
- ExecStart quotes both paths for systemd parsing.
- remote.password for a socks5 upstream is rejected at validation instead
  of being silently ignored (the transport only negotiates no-auth).
- splitRemoteAddr accepts bare IPv6 literals (default port) and rejects
  malformed multi-colon addresses like '::::' instead of passing them
  through as hosts.
2026-08-12 17:23:14 +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 4c19815319 fix(sower,admin): keep CONNECT tunnel head, bound SOCKS5 handshake, harden admin config and login
- CONNECT through the HTTP proxy no longer drops the tunnel's first
  bytes: bytes the bufio reader already pulled past the request head
  (pipelined tunnel data) are replayed to the target before relaying.
  Both the dedicated HTTP proxy and the SOCKS5-port HTTP fallback paths.
- The SOCKS5 entry keeps its read deadline across the whole handshake
  (auth + request) instead of clearing it after the first byte, bounding
  slowloris clients that stall mid-negotiation.
- Config override validation now covers dns_reverse, remote_addr,
  remote_tls_server_name, and every rule list, so a malformed override
  can no longer be persisted and crash-loop the next startup.
- admin.state_file is read-only in the console: the state store loads at
  startup, so the runtime override could never take effect.
- Login is rate-limited per remote IP (5 failures lock 15 minutes) and
  the login endpoint carries the Origin guard; the session map refuses
  new logins instead of growing past its bound.
- The admin HTTP server sets IdleTimeout and MaxHeaderBytes.
2026-08-12 17:15:54 +08:00
wweir 30e80ab510 fix(router): bound degrade retry to snapshot, gate internal PTR on selected upstream
- degradeUpstream now takes the caller's snapshot length: a concurrent
  refresh that grows the live upstream list no longer lets the retry
  index past the stale snapshot and panic the DNS handler.
- Internal reverse lookups take a dedicated forward path that never runs
  the degrade state machine: the query goes only to the selected internal
  upstream and surfaces SERVFAIL on failure instead of retrying onto a
  public fallback, closing the TOCTOU leak in the PTR gate.
- Local DNS replies set the RecursionAvailable bit per RFC 1035.
- A failed TCP retry for a truncated UDP response keeps the truncated
  response (with TC set) instead of dropping it for SERVFAIL, so
  networks that block TCP/53 still get usable answers.
2026-08-12 17:08:32 +08:00
wweir 1e64542adb style: gofmt fixes for legacy build tag and test alignment 2026-08-12 16:19:48 +08:00
wweir a0f627621b fix(router,admin): trust full-length ECS only, gate PTR on selected upstream, harden hostname cache
Review findings on the DNS attribution and hostname series:

- ClientIPOf only trusts EDNS Client Subnet addresses with a full-length
  source prefix (32/128); a truncated prefix like dnsmasq --add-subnet=24
  carries only the subnet base and is ignored, so attribution stays on the
  real client instead of the /24 network address.
- The internal PTR gate now judges the currently selected upstream rather
  than the whole pool, so a degraded mixed pool cannot forward internal
  reverse lookups to a public fallback.
- Failed hostname lookups are cached for five minutes instead of the full
  hour, so a resolver blip stops suppressing names quickly while keeping
  retries rare.
- Duplicate IPs in one decoration batch resolve once instead of once per
  entry.
- Traffic-view hostname decoration runs on a private copy of the cached
  snapshot outside trafficMu, so a stalled resolver neither blocks other
  readers nor races on the shared backing array.
- Local DNS replies (failures and empty answers) echo an OPT record when
  the query carried EDNS0, per RFC 6891.
2026-08-12 14:53:13 +08:00
wweir 203729299e docs(config): document dns.reverse in example config, clarify totals test 2026-08-11 23:35:15 +08:00
wweir 0f7e395ab3 fix(admin): decorate SSE totals hostnames, resolve concurrently, expose dns.reverse override 2026-08-11 22:19:57 +08:00