Compare commits

...
313 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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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
wweir dda120c8ca feat(admin): show client hostnames via reverse DNS in traffic console 2026-08-11 22:02:57 +08:00
wweir 2e839fe9f3 test(router): cover ECS stripping on the TCP exchange path 2026-08-11 21:46:54 +08:00
wweir 5a57267512 feat(router): attribute DNS stats via EDNS client subnet, strip ECS upstream 2026-08-11 21:34:11 +08:00
wweir 83675fe607 fix(router): reject multi-char ip6.arpa nibbles in reverse-name parse
A label like "ff" slipped through ParseUint and silently truncated
to a single nibble. Each ip6.arpa label must be exactly one hex
digit. Also cover IPv6 upstreams in the internal-upstream tests and
add direct isInternalHost cases.
2026-08-11 21:06:18 +08:00
wweir b4d92703ce feat(router): answer internal PTR lookups locally, keep external DNS unexposed
Parse in-addr.arpa/ip6.arpa query names back to IPs and answer
NXDOMAIN locally when the target is an internal range (RFC1918,
CGNAT 100.64/10, link-local, loopback, IPv6 ULA) and no internal
DNS server is among the upstreams. Reverse lookups still forward
when an internal DNS server can answer them, and public reverse
names keep the existing forwarding path.
2026-08-11 21:02:41 +08:00
wweir 683b1da33f fix(admin): decouple rule feedback from list reload, unify rule styling
Success feedback no longer waits for the list reload: on huge rule sets a
slow or failing reload delayed the toast or swallowed it entirely, leaving
the user with a misleading refresh error while the mutation had succeeded.

- add/undo/reset: flash the toast right after the API call succeeds
- remove: open the undo window and release the busy lock immediately, so
  the undo button is not disabled during the reload
- success toasts render the rule part in monospace, matching the table,
  delta panel, and delete toast styling
2026-08-11 19:10:36 +08:00
wweir f3b59f7444 fix(admin): show concrete success feedback after rule mutations
Replace the generic "已保存" flash with operation-specific messages:
- add: 已添加规则 <rule>
- undo: 已恢复规则 <rule>
- reset: 已重置<分类>规则
- delete toast unified to 已删除规则 <rule>,已保存

Messages are truncated to one line and announced via role=status;
the saved timer is cleared on category switch so stale flashes cannot
overwrite newer ones.
2026-08-11 18:57:33 +08:00
wweir 1e4487193c ci(release): build per-arch matrix with aggregated release job
- replace hand-rolled make build loop with a strategy.matrix across 11
  targets: linux (amd64, arm64, armv7, riscv64, loong64, mips, mipsle),
  darwin (amd64, arm64), windows (amd64, arm64)
- add dedicated web job so the admin console is built once and reused
- aggregate all tarballs in a single release job to avoid concurrent
  release edits from matrix jobs
- drop stale sower.yaml references (config is TOML-only) and the
  bun-less make build that would fail on runners
2026-08-11 14:32:45 +08:00
wweir c86937820a fix(admin): bound error alert to recent window, keep UTF-8-safe details
Review fixes for the error visibility feature: the destructive alert now
only shows while a failure occurred within the last 10 minutes, so a past
outage does not keep a red banner up forever (cumulative counts remain as
context). Error details are truncated on a UTF-8 rune boundary and have
line breaks collapsed, so an IDN host cannot produce invalid UTF-8 in the
SSE payload and a multiline error cannot break the alert layout. Adds
tests for both.
2026-08-11 10:29:10 +08:00
wweir cfddf76eb3 feat(admin): surface proxy-side errors and stale data in overview
Proxy dial/DNS/accept failures were logged but invisible to the console:
an unreachable upstream looked identical to no traffic. Stats now count
failures by kind, keep a bounded error event ring, and expose both in the
traffic snapshot (and Prometheus via sower.proxy_errors). The overview
shows a destructive alert with recent events when failures occur, and a
distinct stale-data alert when the SSE stream stays open but traffic
ticks stop parsing (three consecutive failures). Pins the persist-failure
-> 500 contract with a handler test.
2026-08-11 09:24:53 +08:00
wweir 0bd6e433a3 fix(admin): bound SSE writes and reset page filters on leave
The stream handler wrote without a deadline: a client that vanished
without closing the TCP connection (asleep laptop, dropped WiFi) could
block the handler forever once the kernel send buffer filled, leaking
the goroutine and connection on a 24x7 router. Each write now carries a
30s deadline via http.ResponseController and the stream is torn down on
the first failed write; the new failingFlusher test pins the exit path.

Also drop the traffic page's sort/source/client filters from the shared
SSE stream when the page unmounts, so the overview is never served a
hidden page's filtered snapshot, and stop toggling the disconnected
alert during the planned session-renewal reconnect.
2026-08-11 08:35:14 +08:00
wweir 548a45559f chore(deps): deferlog v2.1.5, admin password b64 prefix
deferlog v2.1.5 removes the base64-or-plain heuristic from Password input:
values are verbatim unless explicitly prefixed with "b64:", and marshaling
emits the same tagged form for exact round-trips. The admin password keeps
deferlog.Password and now takes "b64:"-prefixed encoded values; plaintext
passwords (even ones that are valid canonical base64) are never decoded.
Update the tagged-base64 test and document the prefix in README.
2026-08-11 08:20:44 +08:00
wweir e526a73469 fix(sower): redact remote password from config load-failure log
Review follow-up: the load-failure path logged the whole partially-populated
SowerConfig, which re-exposed the plain Remote.Password string after the
verbatim-password fix. Extract loadConfig/logConfigLoadError so the error
log carries only the error and remote_addr, matching sowerd. Add tests
pinning the redaction, JSON-handler redaction for the admin password, and
verbatim loading of sowerd's password. Document the no-base64 semantics of
remote/sowerd passwords and the admin password base64 caveat in config
comments and README.
2026-08-11 08:07:34 +08:00
wweir d983885739 fix(admin): harden chart pin and screen-reader announcements
Review follow-up on the live-value chart work: touch scrolling from the
chart area used to pin a stray crosshair (pointerdown committed the pin; a
scroll gesture ends in pointercancel), so the pin now commits on pointerup
with an 8px tap threshold and pointercancel rolls back the transient hover.
Escape cancels any pending frame before clearing. The sr-only status region
now lives permanently in the DOM and announces only when this chart is the
interaction source (chartLink.owner), so linked crosshairs no longer produce
an announcement burst from every chart. A stale pin whose timestamp slides
out of the history window is released automatically, and the legend omits
values below two samples instead of fabricating a zero.
2026-08-11 08:07:26 +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 961c7e2e72 fix(admin): show live current values in chart legends
AreaChart now always renders a value per series: the hovered sample while a
crosshair is active, otherwise the latest sample, so current levels are
readable without hovering (and on touch devices at all). Touch taps pin the
crosshair until a second tap or Escape. Add a visually-hidden role=status
region announcing the inspected point to screen readers, and bump 11px muted
axis/badge text to text-xs for contrast. Backfill the first rate-series sample
with the second rate so charts don't show a fake drop to zero at the left
edge.
2026-08-11 00:17:42 +08:00
wweir 1dc5f50818 fix(ci): build web assets before running go vet and test
The previous commit stopped tracking web/dist, so the parallel
go vet run no longer finds the embedded dist directory in CI.
Make the test target depend on the web target to guarantee the
frontend is built first.
2026-08-10 16:35:06 +08:00
wweir 53d4271813 chore: stop tracking web dist build artifacts
Add /web/dist to .gitignore, untrack the committed vite build output,
and update the embed.go comment to reflect that dist is generated by
the Makefile web target instead of being committed.
2026-08-10 16:29:31 +08:00
wweir 112bd84bc2 chore: ignore cross-compiled binaries 2026-08-10 16:18:12 +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 6ec5d44d7c fix(sower): handle CONNECT in http proxy handler
Split host:port from the request line before dialing so the target
address stays valid in the sower protocol, reply 200 to CONNECT before
tunneling, and drop the buffered request head so only raw tunnel data
reaches the target. Previously CONNECT targets were dialed as
host:port:80, which the remote sowerd could not connect to.
2026-08-10 12:12:32 +08:00
wweir 265f7e14ba docs: detail admin config console whitelist and in-place restart 2026-08-09 23:26:37 +08:00
wweir 4c3d4e399e ci: checkout before setup-go to enable module cache 2026-08-09 21:34:08 +08:00
wweir b77925d22e chore(deps): update go, web and CI dependencies 2026-08-09 21:31:15 +08:00
wweir ffc631adaa fix(ci): install bun before building web assets 2026-08-09 21:19:07 +08:00
wweir 9ef545b189 docs: complete admin configuration and plan status updates
README gains an admin configuration table (state_file, session_file,
disable_session_persistence, cookie_secure); sower.toml documents
cookie_secure; ARCHITECTURE.md records best-effort session persistence;
both admin plans note the evolved whitelist, restart, stats, and SSE
implementation.
2026-08-09 21:18:40 +08:00
wweir 8e9cd6f940 chore: ignore agent working data 2026-08-09 19:21:02 +08:00
wweir 49710d9151 docs: sync admin console docs with implemented behavior
ARCHITECTURE.md and README.md now describe editable config fields,
in-place restart, rule hit/miss stats, and session persistence; the
admin console plan is marked implemented.
2026-08-09 19:21:02 +08:00
wweir f9c61eddb3 build(web): rebuild frontend dist assets 2026-08-09 19:21:02 +08:00
wweir fbfca95111 feat(web): rule stats, config editing, restart, and navigation polish
Rules page gains hit/last-seen columns, sorting, and a rule-miss view;
Config page adds field filter, list editing, restart button and polling;
URL routing with deep links, favorite pages with sub-menu state, session
probe, and reduced-motion/accessibility refinements.
2026-08-09 19:21:02 +08:00
wweir 0f67081022 feat(admin): rule hit stats, config console, restart, and session persistence
Rule listings gain hit counts, last-seen timestamps, and sort by rule
text, hits, or recency; per-rule and rule-miss stats are tracked in
bounded maps fed by router observers. The config console exposes
whitelisted editable fields (immediate for log level and DNS upstreams,
restart-mode for the rest), persisted as pointer overrides. The restart
endpoint closes listeners and execs the process in place (same PID).
Session persistence is best-effort with configurable options, and admin
state/session config gained disable and cookie-secure switches.
2026-08-09 19:20:26 +08:00
wweir 0aed36f698 feat(router): report matched rule text for hit tracking
MatchRule resolves the matching rule in tree depth (most specific wins)
instead of a linear scan, and the router gains hit/miss observers so
per-rule counters can be fed without coupling the router to admin.
2026-08-09 19:02:18 +08:00
wweir 759794e897 fix(router): drop empty rules parsed from empty TOML arrays
aconfig parses an empty TOML array (rules = []) as a slice holding one
empty string, which the router then treated as a real rule. Empty rules
can never match a real domain; Add and Replace now drop them.
2026-08-09 18:56:45 +08:00
wweir edb665f34c build(web): rebuild frontend dist assets 2026-08-07 15:51:25 +08:00
wweir 0bb3603740 feat(web): show blocked domains in traffic page
Add a third 拦截 view to the Traffic console listing domains blocked
since start, ordered by count, with a search filter and live SSE updates.
The view hides the sort and client-chip controls that only apply to
traffic domains.
2026-08-07 15:51:20 +08:00
wweir 23a51750a7 test(admin): cover per-domain block counting
Adapt RecordRoute call sites to the two-argument signature and assert
the blocked ranking in snapshots. Add a cap test proving batch eviction
of the blocked map.
2026-08-07 15:51:13 +08:00
wweir a8ae275c88 feat(admin): track per-domain block counts
Feed the domain through the router observer into Stats.RecordRoute so
block decisions are attributed per domain. The bounded blocked map
(LRU-evicted at 10000 entries) feeds TrafficSnapshot.Blocked, ordered
by count, and rides the existing SSE traffic stream unchanged.
2026-08-07 15:51:08 +08:00
wweir a14ff9ad6b build(web): rebuild frontend dist assets 2026-08-07 12:02:35 +08:00
wweir 9104370d52 docs: sync architecture and admin console plan
- Document batched byte attribution and the soft memory limit
- Update admin console plan to the current stats implementation
2026-08-07 12:02:31 +08:00
wweir defea58ac2 perf: batch byte accounting and bound gateway memory
- countingConn batches per-domain attribution (32KiB/500ms) so the relay
  hot path pays two atomics per I/O instead of a global mutex; Close drains
- Precompute OTel byte attributes to drop per-I/O allocations
- suffixtree: indexMap only for nodes with >=8 children; Match stays
  read-only for concurrent queries
- Soft Go memory limit (128MiB default, GOMEMLIMIT/SOWER_MEMORY_LIMIT_MB
  override) cuts resident memory from ~240MB to ~130MB on the default
  ad/china/gfw rule sets
- Split stats.go into snapshot/eviction/counting by responsibility
2026-08-07 12:02:26 +08:00
wweir 60ac21f230 feat(router): guard DNS upstream refresh with generation
- Generation counter invalidates in-flight refreshes after SetDNS
- Probe/promote/degrade paths carry the generation to avoid stale mutations
2026-08-07 12:02:21 +08:00
wweir 94c7105187 feat(admin): persist rule changes and add config/traffic console
- StateStore persists rule deltas and whitelisted config overrides atomically
- Config display/edit API with optimistic concurrency (revision)
- Traffic charts with time-axis linking across all views
- Split server.go into auth/rules/traffic/config by responsibility
2026-08-07 12:02:15 +08:00
wweir 6023537319 feat(admin): test a domain's rule matches and route decision
The rules page can now answer 'what happens to this domain?': RuleSet
gains MatchRule, a linear scan that returns the first matching rule under
the same suffix semantics as the suffix tree, and GET /api/rules/test
(auth) reports per-category hits plus the route a connection would take
(block > direct > proxy priority; 'auto' with a note when no rule
matches, without performing live detection).

- Rules page gets a domain test input with route badge, per-category
  match rows, and the fallback note
- tests: MatchRule agrees with Match, fake + endpoint coverage
2026-08-06 22:42:31 +08:00
wweir 9b74f80cfe feat(admin): cumulative traffic totals for domains and clients
The domain map previously doubled as the live window and dropped entries
after staleDomainAge, so from-start totals were lost. It now accumulates
(bounded at maxTotalDomains with least-recent eviction) while Snapshot
only filters staleness, and a new independent client table (bounded at
maxTotalClients) keeps per-client totals exact even when domains are
evicted. record() takes explicit counter deltas so the cumulative layer
sees the same increments as the window.

- GET /api/totals (auth) returns top domains and clients by bytes since
  start; the SSE stream pushes a totals event initially and every 30s
- Traffic page gains a live/totals toggle; totals view keeps byte columns
  regardless of the source breadcrumb, and clicking a totals client chip
  jumps to the live view filtered by it
- per-domain client breakdown capped at maxClientsPerDomain
- tests: totals survive staleness and domain eviction, both caps enforced
2026-08-06 22:34:55 +08:00
wweir 92221c2ac6 feat(admin): show each domain's most recent client IP
Adds lastClientIP to every domain row on the traffic page so operators
can see at a glance who last touched a domain. Under a client filter the
column shows the filter itself; otherwise it reports the client with the
most recent activity on the domain (approximation for source-filtered
views, since the per-client map has no source dimension).
2026-08-06 22:20:56 +08:00
wweir 7211ce9300 fix(admin): keep client chips stable under the client filter
The clients aggregate was built only from domains surviving the client
filter, so selecting a client chip collapsed every other chip's count to
the few shared domains (or made it vanish), and the chips row hid itself
entirely when the filter matched no domains - stranding the user with no
way to clear the filter.

- Snapshot now aggregates chips over the source-filtered view before the
  client filter is applied; chips stay globally counted and stable.
- Traffic page keeps the toolbar and chips visible when the filtered
  domain list is empty; the empty state only replaces the table.
- Regression test pins chips stability, including clients that share no
  domain with the selected one.
2026-08-06 22:10:18 +08:00
wweir 54f6f61e78 fix(admin): review fixes for the SSE migration
- Session cookie MaxAge was 24h, matching sessionTTL, which defeated
  the sliding expiry: the server session could refresh past 24h but the
  browser cookie lapsed, logging the user out anyway. The cookie is only
  a bearer-token carrier, so its MaxAge is now 30 days and the
  server-side session map (sliding 24h) is the real expiry gate.
- Drop the always-empty statusError state from App.svelte; the SSE
  connection state is surfaced by the page-level reconnect banners.
2026-08-06 21:33:48 +08:00
wweir 64a1b55e99 feat(admin): replace polling with Server-Sent Events
The console previously polled /api/traffic, /api/status, and /api/history
every 5-10s. It now subscribes to one SSE stream:

- GET /api/stream (session-cookie auth) pushes status and the traffic
  snapshot every 5s and history every 10s, with an immediate initial
  payload. The connection carries the page's sort/source/client filters
  and revalidates the session on each tick, closing with an auth event
  when it lapses. X-Accel-Buffering is disabled for proxied setups.
- Frontend: lib/live.svelte.ts is a module-level reactive store fed by
  EventSource (auto-reconnects; safe-parse tolerates malformed frames;
  auth event routes to the logout flow). App owns the connection, pages
  consume the store, Traffic re-points the stream on filter changes.
  The pause button and the polling helper are gone.
- tsconfig gains allowImportingTsExtensions for the .svelte.ts store.

Verified: initial + 5s-tick events stream, 401 without session, filter
params apply to the pushed snapshots.
2026-08-06 20:11:10 +08:00
wweir fbc0d54893 feat(admin): track and filter traffic by client IP
The console now records the client source IP for every connection and
DNS query, shows the top clients, and can filter the domain list by one:

- WrapConn captures conn.RemoteAddr into the counting conn; RecordDNS
  takes the client IP from the DNS ResponseWriter. Domain stats keep a
  per-client breakdown alongside the per-source one.
- /api/traffic?client=<ip> restricts domains to that client and reports
  the client's conns/bytes/lastSeen; it composes with ?source=.
- The snapshot now carries clients: top 50 per-IP aggregates (conns,
  bytes up/down, last seen) for display.
- Traffic page shows a client chip row (全部客户端 + IPs with counts);
  clicking one filters, tooltips show byte totals.

Verified live: ?client=192.168.1.1 lists that device's domains with its
counts; ?source=dns&client=... composes correctly.
2026-08-06 19:54:43 +08:00
wweir 6d52cc14c3 feat(admin): monitor DNS queries and filter traffic by source
Domain stats are now tracked per entry point (http/https/socks5/dns)
instead of one mixed counter, and /api/traffic accepts ?source= so each
can be inspected separately:

- Stats.record attributes events to a source; DNS queries land in the
  dns slot with no bytes, proxy connections in their protocol slot.
- Snapshot(sort, source) filters domains to one source, reporting that
  source's conns/bytes/lastSeen; source=all (default) aggregates.
- The traffic page breadcrumb gains a source switcher (all/http/https/
  socks5/dns), the view reloads on switch even while paused, and the
  DNS view hides the byte columns and labels the list as DNS queries.

Verified live: source=dns lists queried domains with query counts;
source=https lists proxied domains with bytes; all still aggregates.
2026-08-06 19:35:44 +08:00
wweir 76ab03df73 fix(admin): keep sessions alive across restarts with sliding expiry
Sessions were stored in memory with a fixed 24h TTL, so every process
restart (deploys, crashes) logged everyone out and an active session
could still lapse mid-browsing.

- Admin sessions now persist to admin.session_file (default
  /etc/sower/sessions.json, 0600, atomic temp+rename writes). Sessions
  are restored on startup, dropping already-expired tokens. IO failures
  degrade to the in-memory store.
- Sliding expiry: a session used past the halfway point of its TTL is
  refreshed instead of left to lapse, so active users stay logged in.
  Persisting only on refresh bounds disk writes to a couple per session
  per TTL.
- Login, logout, expiry purge, and refresh all sync the file.

Verified live: after systemctl restart, the pre-restart cookie still
authenticates.
2026-08-06 19:13:35 +08:00
wweir e524592f61 feat(admin): paginate and search the rules list
The rules page froze because block rules (164k+) were all transferred
and rendered at once. The list API is now paginated and searchable:

- GET /api/rules?category=X&q=...&offset=N&limit=M returns a page plus
  the total match count; q is a case-insensitive substring match
  (alloc-free scan in RuleSet.ListFiltered), limit clamps to 1000,
  default page 200.
- POST/DELETE /api/rules now return 204; the frontend refetches the
  currently loaded range so totals stay accurate.
- Rules view: server-side search box with 300ms debounce, load-more
  button appends pages, count badge shows loaded/total.

Measured on the live box: first page 0.5ms, 164k-rule scan ~22ms.
2026-08-06 19:00:09 +08:00
wweir cd5b857328 feat(admin): switchable domain sorting on the traffic page
Traffic view now sorts domains three ways via ?sort= on /api/traffic:
- bytes (default): total traffic, high to low (the former fixed order)
- recent: most recently seen first
- conns: most connections first
Invalid or missing sort falls back to bytes; the top-100 cap is kept.

Frontend gains a segmented sort switcher next to the filter; the footer
label follows the active mode. api.ts picks up the sort query param and
is committed with the user's prettier reformat (pure formatting) since
the change is entangled with it.
2026-08-06 18:51:28 +08:00
wweir f6423a617e fix(web): correct rangeNote derived value, restore rule/conn totals
Review fixes for the monitoring overhaul:

- rangeNote used $derived(() => ...) which evaluates to the function
  itself, rendering the function source in the footer; switch to
  $derived.by so it evaluates to the time-range string.
- Restore the rule-count and cumulative-connection totals that the
  Overview rewrite dropped: 规则命中 card now shows configured rule
  counts, 活跃连接 card shows cumulative per-protocol connections.
- formatUptime: show bare seconds under one minute instead of '0m 13s'.
- Reformat format.ts to the project's prettier style (tabs).
2026-08-06 17:26:29 +08:00
wweir b4cb204e3f feat(admin): OTEL metrics, real-time rates and in-process history
Monitoring overhaul for the admin console:

- Add internal/metrics: OpenTelemetry meter with Prometheus exporter
  (counters for bytes/dns/connections/rule hits, updown counter for
  active connections, histogram for connection duration, process
  gauges). Served unauthenticated at GET /metrics; an OTLP exporter
  can be added later without touching call sites.
- Fix uptime: TrafficSnapshot.Uptime was a time.Duration serialized
  as nanoseconds while the frontend renders seconds; it is now int64
  seconds. Stats is created before rule loading so uptime tracks the
  process start.
- Extend admin.Stats: per-protocol active connections (idempotent
  decrement on Close), 60s sliding-window rates, rule-hit counters,
  process resources, and a bounded in-process history ring (1h at 5s
  sampling, gap-clamped) served at GET /api/history.
- Router route observer: block/direct decisions in DialSmart, proxy
  decisions in DialProxyOnly (covers HTTP/HTTPS paths), exactly once
  per connection.
- Fix socks5 handler closing the raw conn instead of the wrapped one,
  which would have leaked active-connection counts.
- Frontend: Overview gains rate cards, active connections, rule hits,
  system stats, and four hand-rolled SVG area charts (traffic, DNS/
  conns, active per protocol, rule hits) with hover readouts.
2026-08-06 17:17:24 +08:00
wweir 0c99db7ea9 feat(web): adopt WingGate warm-paper palette for admin console
Port the "glass reading room" light theme from WingGate/web: warm
paper canvas (#eaebe5) with a soft light-falloff texture, warm ink
text, hairline warm borders, and teal (#0f766e) reserved for primary
actions, focus rings, and selection. Corner radius moves to 12px to
match. Token-only change; components consume the same semantic roles.
2026-08-06 16:16:11 +08:00
wweir 6e3c4a0fbd fix(web): switch admin console to light theme
Replace the dark-only shadcn palette with the standard light neutral
tokens and declare color-scheme: light. All components consume tokens,
so no per-component changes are needed.

Note: main.ts still adds the dark class to <html>; it is visually
harmless now that :root carries light tokens, but should be removed
when its pending formatting changes settle.
2026-08-06 15:36:42 +08:00
wweir ef8b3fc72e feat(admin): polish console interactions and visual states 2026-08-06 14:30:07 +08:00
wweir 1981c61f0c feat(admin): share port 80 with http proxy via host-based dispatch 2026-08-06 13:55:55 +08:00
wweir d4825deed2 fix(admin): harden console review findings 2026-08-06 10:30:47 +08:00
wweir 1e87298d15 feat(admin): embedded admin console for rule management and traffic stats
- internal/admin: session-cookie auth server, rule CRUD API, bounded
  per-domain traffic stats with pre-parse byte accounting
- cmd/sower: wrap proxied connections and DNS queries into stats, serve the
  admin console on its own listener
- web/: Svelte 5 + Vite admin console with shadcn-svelte UI and an advanced
  breadcrumb header (section/context dropdowns, favorites), embedded via
  go:embed and committed to web/dist
- Makefile: sower build depends on the frontend build (bun); sowerd does not
- docs: ARCHITECTURE, README, and plan updated for the admin surface
2026-08-06 08:56:33 +08:00
wweir 1daeb36d54 feat(config): add [admin] section with validation and examples
Admin is disabled by default, binds to loopback, and requires a non-empty
password when enabled. Zero-value configs skip admin validation so existing
tests and example files keep loading.
2026-08-06 08:56:27 +08:00
wweir ce5cf284b7 feat(router): add thread-safe RuleSet for runtime rule management
RuleSet retains the raw rule list alongside the suffix tree so rules can be
listed, added and removed at runtime. Removal rebuilds the tree from the
retained list; startup batch loading keeps the old single-compaction flow.
Router.BlockRule/DirectRule/ProxyRule migrate to *RuleSet.
2026-08-06 08:56:22 +08:00
wweir 48883b0783 sec(sower): strengthen auth checksum against brute force
sumChecksum used binary.Uvarint over the digest, which stops at the
first byte with a clear continuation bit: ~50% of checksums collapsed
to 7 bits of entropy (~15 bits effective), making the auth check
brute-forceable. Take a fixed 64-bit window of a SHA-256 digest
instead. Wire layout is unchanged (checksum stays 8 bytes), and the
randomized field also removes the "high bytes all zero" fingerprint.
2026-08-05 14:04:59 +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 62ca487087 docs: update docs and config examples for trojan removal
- ARCHITECTURE.md: remove trojan transport references, update sowerd flow
- README.md: remove trojan usage instructions, simplify remote type docs
- docs/plans/site-routing.md: update to reflect sower-only probe model
- config/*.toml,*.yaml: remove trojan from example config type options
2026-06-25 09:20:37 +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 e0874548b7 refactor: remove trojan protocol support
Trojan transport, config options, and tests are removed entirely.
The sowerd probe abstraction now handles only the sower protocol;
adding new transports requires only a new proxyProtocolHandler impl.

- transport/trojan/: deleted
- cmd/sower/proxy.go: remove trojan import and branch
- config/sower.go,sowerd.go: remove trojan from type options and validate
- transport/transport_test.go: remove trojan test cases
2026-06-25 09:20:31 +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 8aee9da6f0 test(transport): add socks5 integration test and clean up address strings 2026-06-24 17:12:06 +08:00
wweir 8a4fb47003 sec(trojan): use constant-time password comparison and clean up IP handling 2026-06-24 17:12:01 +08:00
wweir c482623005 perf(sower): reduce md5 allocation and improve error handling 2026-06-24 17:11:59 +08:00
wweir 7286822ce9 fix(socks5): use correct reply head and improve error handling 2026-06-24 17:11:57 +08:00
wweir ad43316842 test(transport): extract common connection test helpers to internal/conntest 2026-06-24 17:11:52 +08:00
wweir ffa2dc3391 chore(deps): bump golang.org/x/crypto to v0.53.0 and related x/* deps
Upgrade the golang.org/x/* family to their current latest:
- crypto v0.49.0 -> v0.53.0 (only direct dep with an upgrade)
- net, sys, text, mod, sync, tools pulled forward as transitive deps.

No code changes: the sole x/crypto usage (autocert in cmd/sowerd) is
stable across these versions.
2026-06-24 13:37:30 +08:00
wweir 3dec49dec4 test: add regression tests for routing layer separation and fail-fast
- DNS unknown domain forwards upstream; TCP smart dial falls back proxy.
- Transparent HTTP/HTTPS uses proxy-only even when block rule matches.
- Invalid country CIDR and invalid MMDB path fail at construction.
- Access probe cache is instance-scoped; test injects fake prober.
- net.Pipe tests add timeout protection via waitForHandler helper.
- newTestRouter helper wraps NewRouter error handling.
2026-06-24 13:30:48 +08:00
wweir 8498eb1d32 feat(sower): use DialSmart/DialProxyOnly, fail-fast router construction
- Transparent HTTP/HTTPS handlers call DialProxyOnly (proxy-only path).
- SOCKS5 and explicit HTTP proxy handlers call DialSmart (smart route).
- newRouter returns error; AddCountryCIDRs failure cleans up opened MMDB.
- run() defers r.Close() for graceful MMDB reader shutdown.
2026-06-24 13:30:31 +08:00
wweir 51438c93a0 feat(router): split DialSmart/DialProxyOnly, fail-fast on bad MMDB/CIDR, add Close
- Add DialSmart (rule+detect+fallback) and DialProxyOnly (forced proxy)
  as explicit routing entry points; keep Dial as compatibility alias.
- NewRouter now returns (*Router, error); non-empty invalid MMDB path
  is a startup error instead of a silent warning.
- AddCountryCIDRs now returns error; invalid CIDR entries fail fast.
- Add Router.Close() to release geoip2.Reader on shutdown.
- Add country RWMutex so Close() does not race with concurrent reads.
- localSite checks all resolved IPs instead of only the first.
- Move accessCache from package-level global to Router instance field;
  inject accessProbeFunc for testability.
- Disable redirect following in pingClient to avoid misleading probe
  results on redirect responses.
2026-06-24 13:30:18 +08:00
wweir 0c05d04dec docs: codify DNS/transparent/smart routing layer separation in ARCHITECTURE.md
Document that DNS routing intentionally does not mirror smart TCP
routing, transparent HTTP/HTTPS listeners are proxy-only second-stage
handlers, and SOCKS5/explicit HTTP proxy entries use smart routing.
2026-06-24 13:30:06 +08:00
wweir 0547502748 feat(sowerd): route fallback sites by SNI 2026-06-24 09:24:25 +08:00
wweir 160abcf4cc docs: update agent documentation rules 2026-06-24 09:24:17 +08:00
wweir 875f80e999 refactor: replace mem cache with otter 2026-05-14 21:53:16 +08:00
wweir f23f7f6950 docs: document access probe cache 2026-05-14 21:52:58 +08:00
wweir c5b3917592 docs: add recommended sower network setups 2026-04-30 11:45:35 +08:00
wweir ad338598ae fix: harden dns proxy routing 2026-04-25 04:12:42 +08:00
wweir 09e4fc49fc docs: update client routing notes 2026-04-25 03:21:59 +08:00
wweir 0ef0686530 chore(release): package toml client configs 2026-04-25 03:20:41 +08:00
wweir 9c2aa3af10 chore(deps): remove unused error and hcl packages 2026-04-25 03:17:45 +08:00
wweir 5fb9dfcfee fix(router): skip empty country mmdb 2026-04-25 03:17:45 +08:00
wweir f72d88b204 fix(sower): relay transparent tls client hello 2026-04-25 03:17:35 +08:00
wweir 05f0728aa6 feat(sower): harden client rule loading 2026-04-25 03:09:45 +08:00
root d514a52497 docs: clarify transparent https routing flow 2026-03-31 11:53:10 +08:00
root 6f0b7d39b6 fix: preserve tls client hello in transparent https proxy 2026-03-31 11:52:56 +08:00
wweir 75156760db refactor: centralize proxy service startup handling 2026-03-31 10:14:20 +08:00
wweir 23110fc786 docs: clarify config key mapping comments 2026-03-31 10:14:15 +08:00
wweir 71eede9460 fix: align example config with snake case loader 2026-03-31 07:46:32 +08:00
wweir 549ab2a990 fix: fallback to system cache for sowerd service 2026-03-31 07:46:32 +08:00
wweir 8bbf5ca03d refactor: simplify dns proxy routing checks 2026-03-30 23:12:04 +08:00
wweir c3ef6ed88f refactor: merge upstream tls proxy branches 2026-03-30 23:11:58 +08:00
wweir 0c8bdf6d9c refactor: share upstream tls dial setup 2026-03-30 23:04:21 +08:00
wweir 53c55aef24 refactor: simplify dns proxy replies 2026-03-30 23:03:49 +08:00
wweir e705dd4dd3 docs: refresh agent collaboration guidelines 2026-03-30 23:03:17 +08:00
wweir 6d410b104b docs: add architecture and deployment notes 2026-03-30 23:03:07 +08:00
wweir 6ccdcc131b feat: add sowerd install and startup validation 2026-03-30 23:02:54 +08:00
wweir 2b072584e6 feat: add upstream tls controls for sower 2026-03-30 23:02:38 +08:00
wweir b2228f8643 refactor: harden routing and transport handling 2026-03-30 23:02:13 +08:00
温习江湖 5ab6acd7c7 Merge pull request #33 from sower-proxy/dependabot/go_modules/golang.org/x/crypto-0.45.0
chore(deps): bump golang.org/x/crypto from 0.37.0 to 0.45.0
2026-01-09 14:28:19 +08:00
dependabot[bot] a74b1204c0 chore(deps): bump golang.org/x/crypto from 0.37.0 to 0.45.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.37.0 to 0.45.0.
- [Commits](https://github.com/golang/crypto/compare/v0.37.0...v0.45.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.45.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-20 02:29:16 +00:00
wweir 677b0593b8 refactor: update sowerd main.go configuration handling 2025-10-28 20:04:27 +08:00
wweir af61d38b9e refactor: extract configuration to dedicated config package 2025-10-27 11:28:42 +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 53b1de41d0 Update sower.service to use -c option for config file 2025-08-16 11:03:31 +08:00
温习江湖 6c9b9d9597 Merge pull request #32 from sower-proxy/dependabot/go_modules/golang.org/x/net-0.38.0
chore(deps): bump golang.org/x/net from 0.35.0 to 0.38.0
2025-07-12 06:20:08 +08:00
dependabot[bot] 1d8f0fdabe chore(deps): bump golang.org/x/net from 0.35.0 to 0.38.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.35.0 to 0.38.0.
- [Commits](https://github.com/golang/net/compare/v0.35.0...v0.38.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.38.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-26 11:30:40 +00: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 fc7183d70e feat: support force setting upstream dns server 2025-03-18 18:06:33 +08:00
wweir 4a9197a4ec fix: upstream all failed case 2024-11-03 00:40:23 +08:00
wweir 9eaf27fdcc fix(dns): serve interface parser 2024-10-12 10:26:29 +08:00
wweir ea6c2a29ad feat: reduce dependency on host DNS configuration 2024-10-12 10:10:52 +08:00
wweir 77c4154c7c fix: http_proxy 2024-10-12 07:20:39 +08:00
wweir eadb2f0bdf fix: sync example config with codes 2024-09-23 19:09:22 +08:00
wweir 19b61945ad fix: ipv6 support 2024-09-10 14:11:12 +08:00
wweir 3d6ee5e376 feat: support http(s)_proxy 2024-09-10 07:40:08 +08:00
wweir c0f704a5ad feat: add dns.serve_6 flag 2024-05-25 23:06:17 +08:00
wweir ac09663bc4 feat: multi ips, change default config 2024-05-14 14:06:46 +08:00
wweir 45c9eea6e6 feat: support listen on multi ip 2024-05-14 13:23:25 +08:00
wweir 0c83462b91 chore: update dependencies 2024-05-08 13:36:21 +08:00
wweir 15c02b55d8 feat: optimize for network changed case 2023-04-24 10:44:37 +08:00
wweir aca4636433 Merge pull request #27 from sower-proxy/dependabot/go_modules/golang.org/x/net-0.7.0
Bump golang.org/x/net from 0.0.0-20220531201128-c960675eff93 to 0.7.0
2023-03-08 19:06:59 +08:00
dependabot[bot] d0d635f8b0 Bump golang.org/x/net from 0.0.0-20220531201128-c960675eff93 to 0.7.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.0.0-20220531201128-c960675eff93 to 0.7.0.
- [Release notes](https://github.com/golang/net/releases)
- [Commits](https://github.com/golang/net/commits/v0.7.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-03-08 11:06:47 +00:00
wweir bde3b1bfa4 Merge pull request #26 from sower-proxy/dependabot/go_modules/golang.org/x/text-0.3.8
Bump golang.org/x/text from 0.3.7 to 0.3.8
2023-03-08 19:06:10 +08:00
dependabot[bot] 5dfefdf117 Bump golang.org/x/text from 0.3.7 to 0.3.8
Bumps [golang.org/x/text](https://github.com/golang/text) from 0.3.7 to 0.3.8.
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.3.7...v0.3.8)

---
updated-dependencies:
- dependency-name: golang.org/x/text
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-02-23 08:47:32 +00:00
wweir 3f63038631 fix: file based rules parser 2022-07-15 15:15:02 +08:00
wweir 8d18bd6c5b feat: support do not config block rules 2022-07-14 19:55:20 +08:00
wweir 98a5f5702c feat: update dependency, avoid github warning 2022-06-03 08:23:14 +08:00
wweir 2948ffb7db feat: add yaml example config 2022-06-03 08:18:13 +08:00
wweir b85c3a63d7 Merge pull request #24 from wangtz0607/master
Update README.md
2022-06-03 07:01:29 +08:00
wtz 47e8076c06 Update README.md 2022-05-29 21:00:53 +08:00
wtz 10dc20e9e8 Update README.md 2022-05-29 20:56:26 +08:00
wweir 19dcf8e0d7 fix: dns proxy hang case 2022-05-29 12:43:48 +08:00
wweir 795bf73fb2 fix: DNS upstream connection close 2022-05-19 15:29:35 +08:00
wweir 3412643b6e feat: add restart policy 2022-05-16 07:28:29 +08:00
wweir 6fc97a3c9f feat: optimize code with 1.18 generic 2022-05-01 22:53:51 +08:00
wweir 78d36b0c19 feat: change default config for safety 2022-04-30 16:13:53 +08:00
wweir 5ebd33b0d7 feat: migrate to golang 1.18 2022-04-30 14:33:54 +08:00
wweir b1f152cc0c fix: dns upstream lookback case 2022-04-05 23:42:33 +08:00
wweir c0b77da05a fix(#22): ** preifx match none 2022-02-21 08:38:13 +08:00
wweir bad7ae702a chore: separate packages 2021-12-06 14:44:24 +08:00
wweir 05a1a3249a chore: update dependency 2021-12-03 11:01:45 +08:00
wweir 0f029f8c15 refactor: reduce memory usage 2021-11-15 14:21:23 +08:00
wweir 8c9a0bc1dd refactor: reduce memory usage 2021-11-15 11:33:15 +08:00
wweir 465ff316fd refactor: refactor suffix tree to reduce memory usage 2021-11-15 09:18:02 +08:00
wweir a86de61a45 chore: add default config and service scripts 2021-11-10 20:08:46 +08:00
wweir ffca4220d6 chore: fix cicd 2021-11-10 19:27:35 +08:00
wweir 48e9680c03 chore: update dependency 2021-11-10 18:58:24 +08:00
wweir dabcf998c9 fix windows release binary 2021-09-09 08:09:59 +08:00
wweir 133684e5d2 update golang version 2021-08-18 13:16:52 +08:00
wweir a28a211d2d change ping logix 2021-08-15 13:45:28 +08:00
wweir 28696acafb format readme 2021-08-14 20:45:45 +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 adca6606d2 remove unused code 2021-08-09 22:53:58 +08:00
wweir cfc1afb710 change architecture diagram 2021-08-09 08:30:38 +08:00
wweir 397825556c update the document 2021-08-09 06:53:44 +08:00
wweir 8694ff6edd fix for socks5 IP proxy 2021-08-07 05:59:16 +08:00
wweir a38affd97b fix panic in invalid socks5 case 2021-08-02 14:18:51 +08:00
wweir 74202aad83 optimize sowerd code 2021-07-30 19:13:59 +08:00
wweir 59dfa934a2 optimize code, comment / log / flag 2021-07-29 19:06:34 +08:00
wweir 8bfb26ba3f support use socks5 as upstream proxy 2021-07-29 16:19:55 +08:00
wweir fbc6c93395 extend sower protocol checksum length 2021-07-29 08:51:46 +08:00
wweir 2d269468ea add dns cache for dns proxy 2021-07-29 07:14:49 +08:00
wweir d7c0ba112e fix DNS forword issue, inspired by skydns 2021-07-28 14:59:48 +08:00
wweir d31ba7ef79 add deferlog package 2021-07-28 12:03:31 +08:00
wweir 8d32bca742 change dns solution config 2021-07-21 17:51:48 +08:00
wweir 426799a961 optimize config and log 2021-07-20 15:17:57 +08:00
wweir 5cf8df2a7a change binary name 2021-07-19 16:02:26 +08:00
wweir 7d024f3bfe support rule based config 2021-07-18 07:58:27 +08:00
wweir 585cc01744 update depency 2021-07-18 00:48:39 +08:00
wweir 8307128844 refactor, start v0.8 2021-07-17 08:05:02 +08:00
wweir dab1a78bbf mirate to trojan protocol 2020-07-31 13:51:51 +08:00
wweir 13a6be47a4 fix windows and daewin support 2020-07-13 13:45:33 +08:00
wweir 56b8e35fe7 support config reference 2020-07-13 07:24:40 +08:00
wweir 47b8fc601f Migrate to the MIT lisence 2020-06-14 06:48:34 +08:00
wweir 2dc128d1c8 Listen to single IP 2020-06-11 07:43:24 +08:00
wweir 2a4776dc28 Add dns cache support 2020-05-28 16:37:48 +08:00
wweir d121427bbb Support set system DNS automaticly 2020-04-19 08:34:24 +08:00
wweir 91582d6095 Change domain detect logic 2020-04-18 20:39:40 +08:00
wweir 396b08c0ac Migrate to white list match 2020-04-16 17:44:50 +08:00
wweir 78b6d4df2e Fix disable http_proxy case 2020-03-25 10:03:33 +08:00
wweir 1574623507 Optimize router logic 2020-03-23 12:04:14 +08:00
wweir ee6335fa47 Keep file mod for config file 2020-03-23 07:18:15 +08:00
wweir f7214bfdb4 Optimize code 2020-03-22 16:49:40 +08:00
wweir 291ae3f85a Optimize CI workflow 2020-03-21 11:51:12 +08:00
wweir 07c51d42ff Support windows platform 2020-03-21 11:35:19 +08:00
wweir 90c497b8da Optimize router 2020-03-21 10:50:41 +08:00
wweir 28bd12d220 Merge branch 'refactor/esni' 2020-03-19 16:59:43 +08:00
wweir c0f39fc2e0 Refactor for tls 1.3 esni 2020-03-19 16:38:08 +08:00
wweir bcc6d7c504 Fix the last dot in domain match 2020-02-23 08:08:04 +08:00
wweir 5d1827b2da Optimize dynamic detect logic 2020-02-18 17:40:14 +08:00
wweir c38d519f92 Fix CI 2020-02-17 09:07:01 +08:00
wweir dd4444b71c Merge branch 'feature/router' 2020-02-17 06:56:59 +08:00
wweir 899d64a92c Update readme 2020-02-17 06:56:34 +08:00
wweir 7d77f4a85e Add Socks5 support 2020-02-17 06:19:36 +08:00
wweir 4681dd9845 Debug 2020-02-16 16:04:58 +08:00
wweir 85226890c5 Support run as system service 2020-02-16 05:57:44 +08:00
wweir 1a24286bbc Refactor to tls 2020-02-15 15:52:15 +08:00
wweir fd5c34dca1 Refactor to proxy router 2020-02-05 18:00:09 +08:00
wweir 8cd4fe57ba Fix github action badge 2019-10-10 14:06:07 +08:00
wweir 545b153f50 FIx #18, support ipv6 2019-09-28 17:54:28 +08:00
wweir 697e2aa1d5 Fix #18, support custom
upstream dns port
2019-09-28 13:15:47 +08:00
wweir 2fe1708d3f Fix #19, update shorten url 2019-09-28 13:09:15 +08:00
wweir cbe943a020 Migrate ci from circleci to github actions 2019-09-26 07:52:29 +08:00
wweir d7e25d6565 Update dependency 2019-09-26 06:51:00 +08:00
wweir acb3f58526 Update go.yml 2019-09-11 19:01:24 +08:00
wweir 50684272c9 Update go.yml 2019-09-11 18:13:48 +08:00
wweir b0428177de Support Windows install with relative path 2019-06-24 10:41:46 +08:00
wweir 75e7f1aab7 Update dependency 2019-05-31 14:36:11 +08:00
wweir f256ab686d Fix default config file path 2019-05-05 09:32:23 +08:00
wweir 0c0f0699c1 Change config 2019-04-18 14:03:53 +08:00
wweir 8532a2e16c Merge branch 'feature/more-protocol' 2019-04-16 08:08:26 +08:00
wweir bad338033c Fix code 2019-04-16 08:04:37 +08:00
wweir a57f85ab1b Changes for unit test 2019-04-04 10:57:25 +08:00
wweir aed2afbe6d Fix protocol 2019-04-04 10:57:25 +08:00
wweir 2becd42176 Fix protocol 2019-03-31 08:06:00 +08:00
wweir d3664dc2b9 Add more protocols support 2019-03-30 12:31:02 +08:00
wweir a6fd60b5a2 Fix proxy port 2019-03-30 07:33:10 +08:00
wweir d6dbfd4129 Fix windows refresh hook cmd 2019-03-28 09:51:45 +08:00
wweir d31bbf9226 Fix #12, add print version flag 2019-03-27 21:22:52 +08:00
wweir ac4e4266bc Fix deploy script for #12 2019-03-27 15:36:49 +08:00
wweir f9f0ca4ee7 Merge branch 'bug/suggest' 2019-03-27 15:23:32 +08:00
wweir 98c3934762 Optimize suggest trigger 2019-03-27 15:22:25 +08:00
wweir f001f7dab7 Precompile more platform binary file 2019-03-24 18:44:27 +08:00
wweir 36ef329b68 Update todo list 2019-03-24 18:40:34 +08:00
wweir 64778f68ac Merge branch 'feature/socsk5' 2019-03-24 00:16:02 +08:00
wweir 53748cfa85 Support set socsk5 as upstream proxy 2019-03-24 00:15:08 +08:00
wweir d35115b7b3 Fix for vet 2019-03-20 08:48:46 +08:00
wweir 559c5fde6b Merge branch 'feature/dhcp' 2019-03-20 08:45:50 +08:00
wweir eb8080df18 Avoid unnecessary dhcp message 2019-03-20 08:45:37 +08:00
wweir 45ca063a7e Add timeout for dns exchange 2019-03-19 13:30:36 +08:00
wweir 6add7afd19 Fix for go modules 2019-03-15 11:46:51 +08:00
wweir f13687506e Merge branch 'feature/suggest-level' 2019-03-14 08:41:50 +08:00
wweir 199e4cf295 For #11, expose suggest level option 2019-03-14 08:38:18 +08:00
wweir b3ad9f6657 Fix panic in special network environment 2019-03-12 11:53:58 +08:00
wweir f9e9fe366f Fix log message 2019-03-12 11:53:58 +08:00
wweir 103699e25e Add docker build badge 2019-03-11 10:25:43 +08:00
wweir f80755eae5 Shorten install file url 2019-03-11 07:31:27 +08:00
wweir 5b217b794d Merge branch 'refactor/codes' 2019-03-11 00:22:15 +08:00
wweir 26055c5203 Refactor deploy 2019-03-11 00:11:07 +08:00
wweir 029611c078 Refactor proxy 2019-03-11 00:09:02 +08:00
wweir 89adc6a2c9 Change code style 2019-03-10 21:03:11 +08:00
wweir 8c95c59e5e Merge branch 'bug/dhcp' 2019-03-10 13:28:50 +08:00
wweir 4f8376c041 Fix #7, refactor dhcp implementation for dynamic DNS on Windows 2019-03-10 13:26:33 +08:00
wweir 10172f8be2 Build docker image in docker hub 2019-03-08 21:55:18 +08:00
wweir 2831cdf284 Add Windows flush dns cmd 2019-03-07 07:43:22 +08:00
wweir 06558ac635 Optimize http ping score logic 2019-03-06 11:06:37 +08:00
wweir 3cf4deddb7 Merge branch 'bug/ci-windows' 2019-03-06 08:13:10 +08:00
wweir 679263be9a Fix #8, fix ci script 2019-03-06 08:12:48 +08:00
231 changed files with 31934 additions and 3213 deletions
-57
View File
@@ -1,57 +0,0 @@
version: 2
jobs:
build:
docker:
- image: circleci/golang:1.12
steps:
- checkout # check out the code in the project directory
- run:
name: Prepare Environment
command: |
cp conf/sower.toml .
cp deploy/* .
mkdir artifacts
- run:
name: Run Unit Test
command: |
make test
- run:
name: Build linux Binary
command: |
make build
tar czvf artifacts/sower_linux_amd64.tar.gz sower sower.toml sower-client.service sower-server.service
- run:
name: Build macOS Binary
command: |
GOOS=darwin GOARCH=amd64 make build
tar czvf artifacts/sower_darwin_amd64.tar.gz sower sower.toml cc.wweir.sower.plist
- run:
name: Build Windows Binary
command: |
GOOS=windows GOARCH=amd64 make build
tar czvf artifacts/sower_windows_amd64.tar.gz sower sower.toml
- run:
name: Publish Release on GitHub
command: |
git describe --tags | grep - && exit
GHR_VERSION="$(curl -s "https://api.github.com/repos/tcnksm/ghr/releases/latest" | awk -F '"' '/tag_name/{printf $4}')"
curl -SLf https://github.com/tcnksm/ghr/releases/download/"$GHR_VERSION"/ghr_"$GHR_VERSION"_linux_amd64.tar.gz | tar xzC /go/bin --strip-components=1
VERSION=$(git describe --tags)
ghr -t ${GITHUB_TOKEN} -u ${CIRCLE_PROJECT_USERNAME} -r ${CIRCLE_PROJECT_REPONAME} -c ${CIRCLE_SHA1} -delete ${VERSION%-*-*} artifacts/
- setup_remote_docker # install docker in docker
- run:
name: Build Docker Image
command: |
git describe --tags | grep - && exit
docker login -u $DOCKER_USER -p $DOCKER_PASS
make image
VERSION=$(git describe --tags)
docker tag sower wweir/sower:${VERSION%-*-*}
docker push wweir/sower:${VERSION%-*-*}
docker tag sower wweir/sower:latest
docker push wweir/sower:latest
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh /etc/rc.common
# OpenWrt procd service for the sower client.
# install.sh rewrites PROG/CONF to the paths it installed into.
START=99
STOP=10
USE_PROCD=1
PROG=/usr/sbin/sower
CONF=/etc/sower/sower.toml
start_service() {
procd_open_instance
procd_set_param command "$PROG" -c "$CONF"
# The system resolver may point at this service, so respawn must be on.
procd_set_param respawn
procd_set_param stdout 1
procd_set_param stderr 1
procd_set_param limits nofile="65535 65535"
procd_close_instance
}
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>sower</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/sower</string>
<string>-c</string>
<string>/usr/local/etc/sower/sower.toml</string>
</array>
<key>KeepAlive</key>
<true/>
<key>RunAtLoad</key>
<true/>
<key>WorkingDirectory</key>
<string>/usr/local/etc/sower</string>
</dict>
</plist>
+22
View File
@@ -0,0 +1,22 @@
[Unit]
Description=sower client service
Documentation=https://github.com/sower-proxy/sower
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/etc/sower
# Keep the soft heap limit well above the ~105MB live rule heap: a limit at
# that level pins the GC goal to the live heap and burns CPU on every
# allocation. Matches the binary default and is declared here so a deployment
# can tune it without rebuilding (0 disables the soft limit).
Environment=SOWER_MEMORY_LIMIT_MB=512
ExecStart=/usr/local/bin/sower -c /etc/sower/sower.toml
# The system resolver may point at this service, so it must come back fast.
Restart=always
RestartSec=3
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
+101
View File
@@ -0,0 +1,101 @@
# Sower client configuration packaged with releases.
#
# The binary caps its Go heap with a 512 MiB soft memory limit by default; that
# limit has to stay well above the ~105MB live rule heap, because a limit at
# that level pins the GC goal to the live heap and burns CPU on every
# allocation. Override it with SOWER_MEMORY_LIMIT_MB in the service environment
# (0 disables the soft limit), not with a TOML field.
# Remote upstream connection.
[remote]
type = "sower"
addr = "proxy.example.com"
password = "your_secure_password"
# type = "socks5"
# addr = "127.0.0.1:7890"
[dns]
disable = false
# DNS replies for proxied A/AAAA records point clients to this address.
serve = "127.0.0.1"
# serve_6 = "::1"
# serve_iface = "lo"
fallback = "223.5.5.5"
[socks_5]
disable = false
addr = "127.0.0.1:1080"
# Routing priority:
# block > direct > proxy > geo.
[router.block]
file = "https://raw.githubusercontent.com/pexcn/daily/gh-pages/adlist/adlist.txt"
file_skip_rules = [
"t.co",
]
rules = []
[router.direct]
file = "https://raw.githubusercontent.com/pexcn/daily/gh-pages/chinalist/chinalist.txt"
rules = [
"imap.*.*",
"imap.*.*.*",
"smtp.*.*",
"smtp.*.*.*",
"pop.*.*",
"pop.*.*.*",
"pop3.*.*",
"pop3.*.*.*",
"**.cn",
]
[router.proxy]
file = "https://raw.githubusercontent.com/pexcn/daily/gh-pages/gfwlist/gfwlist.txt"
rules = [
"**.google.*",
"**.goo.gl",
"**.googleusercontent.com",
"**.googleapis.com",
"*.googlesource.com",
"**.youtube.com",
"**.ytimg.com",
"**.ggpht.com",
"**.googlevideo.com",
"**.facebook.com",
"**.fbcdn.net",
"**.twitter.com",
"**.twimg.com",
"**.blogspot.com",
"**.appspot.com",
"**.wikipedia.org",
"**.wikimedia.org",
"*.cloudfront.net",
"**.amazon.com",
"**.amazonaws.com",
"**.github.com",
"**.githubusercontent.com",
"**.githubassets.com",
"**.githubcopilot.com",
"t.co",
"lookup-api.apple.com",
"**.openai.com",
"**.chatgpt.com",
"**.oaistatic.com",
"**.oaiusercontent.com",
"platform.api.openai.org",
]
[router.country]
# mmdb is optional. Leave it empty or unset to disable GeoIP lookup and use CIDR rules only.
# mmdb = "Country.mmdb"
file = "https://raw.githubusercontent.com/pexcn/daily/gh-pages/chnroute/chnroute.txt"
rules = [
"127.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"10.0.0.0/8",
"17.0.0.0/8",
"100.64.0.0/10",
"224.0.0.0/4",
"fe80::/10",
]
+1
View File
@@ -0,0 +1 @@
CreateObject("WScript.Shell").Run "C:\Software\Sower\sower.exe -c sower.toml",0
+17
View File
@@ -0,0 +1,17 @@
[Unit]
Description=sowerd server service
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/sowerd
Environment="FAKE_SITE=127.0.0.1:8080"
Environment="PASSWORD=I_am_Passw0rd"
# Soft heap ceiling: a runaway heap degrades the service instead of
# OOM-killing the host (docs/security.md, Connection Resource Bounds).
Environment="GOMEMLIMIT=512MiB"
Restart=on-failure
RestartSec=1min
[Install]
WantedBy=multi-user.target
+19
View File
@@ -0,0 +1,19 @@
name: Go
on:
push:
branches:
- master
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version: ^1.27
- uses: oven-sh/setup-bun@v2
- name: test and build
run: |
make
+149
View File
@@ -0,0 +1,149 @@
name: Release
on:
push:
tags:
- "v*.*.*"
jobs:
web:
name: Build frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: oven-sh/setup-bun@v2
- name: Build admin console
run: |
cd web
bun install --frozen-lockfile
bun run build
- uses: actions/upload-artifact@v7
with:
name: web-dist
path: web/dist
retention-days: 1
build:
name: Build ${{ matrix.name }}
needs: web
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
# Linux: cloud servers, x86 NAS (Synology/QNAP/UGREEN/ZSpace...)
- name: linux-amd64
goos: linux
goarch: amd64
service: sower.service sowerd.service sower.init
# Linux: ARM servers (Kunpeng/Graviton), ARM NAS, Raspberry Pi 4/5, routers
- name: linux-arm64
goos: linux
goarch: arm64
service: sower.service sowerd.service sower.init
# Linux: legacy NAS (Synology/QNAP ARM models), Raspberry Pi 2/3
- name: linux-arm
goos: linux
goarch: arm
goarm: "7"
service: sower.service sowerd.service sower.init
# Linux: emerging RISC-V boards (Orange Pi RV, VisionFive, SG2042)
- name: linux-riscv64
goos: linux
goarch: riscv64
service: sower.service sowerd.service sower.init
# Linux: LoongArch (Loongson) for the local ecosystem
- name: linux-loong64
goos: linux
goarch: loong64
service: sower.service sowerd.service sower.init
# Linux: legacy routers
- name: linux-mips
goos: linux
goarch: mips
service: sower.service sowerd.service sower.init
- name: linux-mipsle
goos: linux
goarch: mipsle
service: sower.service sowerd.service sower.init
# macOS: Intel Macs
- name: darwin-amd64
goos: darwin
goarch: amd64
service: sower.plist
# macOS: Apple Silicon
- name: darwin-arm64
goos: darwin
goarch: arm64
service: sower.plist
# Windows: mainstream x64
- name: windows-amd64
goos: windows
goarch: amd64
ext: .exe
service: sower_run.vbs
# Windows: ARM (Snapdragon X etc.)
- name: windows-arm64
goos: windows
goarch: arm64
ext: .exe
service: sower_run.vbs
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version: ^1.27
- uses: actions/download-artifact@v8
with:
name: web-dist
path: web/dist
- name: Build binaries
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
GOARM: ${{ matrix.goarm }}
CGO_ENABLED: "0"
run: |
ldflags="-X main.version=$(git describe --tags --always) -X main.date=$(date +%Y-%m-%d)"
go build -ldflags "$ldflags" -o sower${{ matrix.ext }} ./cmd/sower
go build -ldflags "$ldflags" -o sowerd${{ matrix.ext }} ./cmd/sowerd
# The packaging files are copied in only after the build: they land in the
# repository root, and the toolchain stamps the worktree state into each
# binary, so copying them first published every artifact as a build no
# commit can be held to (`vcs.modified=true`, module version `+dirty`).
- name: Package
run: |
cp .github/sower.toml .
# matrix.service lists space-separated file names ("sower.service
# sowerd.service"); prefix each with .github/ individually.
for s in ${{ matrix.service }}; do cp ".github/$s" .; done
tar czf sower-${{ matrix.name }}.tar.gz \
sower${{ matrix.ext }} sowerd${{ matrix.ext }} sower.toml \
${{ matrix.service }}
- uses: actions/upload-artifact@v7
with:
name: sower-${{ matrix.name }}.tar.gz
path: sower-${{ matrix.name }}.tar.gz
retention-days: 1
release:
name: Attach release assets
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v8
with:
path: artifacts
pattern: sower-*.tar.gz
merge-multiple: true
- name: Generate checksums
run: |
cd artifacts
sha256sum sower-*.tar.gz > SHA256SUMS
- uses: ncipollo/release-action@v1
with:
artifacts: "artifacts/*.tar.gz,artifacts/SHA256SUMS"
token: ${{ secrets.GITHUB_TOKEN }}
+21 -3
View File
@@ -17,6 +17,24 @@
*.swo
.vscode
.idea
sower
sower.exe*
/sower.toml
**.env
/bin
**.mmdb
/cmd/client/client
/cmd/server/server
/sower
/sowerd
/sower-*-*
/*.yml
/*.yaml
/*.toml
/*.hcl
/*.tf
# frontend
/web/node_modules
/web/dist
# agent working data (subagent artifacts, not project content)
/.pi-subagents
+137
View File
@@ -0,0 +1,137 @@
## Basic Rules
1. **身份边界**:你是机器,不是人类;没有情绪,不拟人化表达,也不冒充人类。
2. **目标导向**:目标是帮我把问题想清楚,而不是让我感觉良好;回答必须紧扣问题、精确、直接,去掉寒暄、安抚和空话。
3. **判断标准**:保持批判、诚实、直接;对不成立的前提、未说明的假设、逻辑跳跃和自相矛盾要主动指出,并给出可执行修正。
4. **表达方式**:默认使用简体中文;术语、命令、代码标识按原文保留,必要时才补充解释。
5. **协作边界**:改代码时假设用户可能正在并行修改其它内容;缩小 diff 范围,不回滚、不覆盖与任务无关的用户改动。
## Documentation Rules
1. 根目录维护 `ARCHITECTURE.md`,描述整体架构、系统边界、分层职责、关键数据流、设计决策、文档链接。
2. `docs/` 根目录只放长期有效的主设计文档(产品、架构、协议、安全、部署、数据模型、测试策略等)。
3. 设计调整须文档先行;未定稿的方案和维护中的实施进度归入 `docs/plans/`,审查记录、问题清单和阶段复盘归入 `docs/reviews/`;定稿后回写到 `docs/` 根目录下的对应主设计文档。
4. `docs/plans/` 和 `docs/reviews/` 不是权威来源;若沉淀出长期规则,须回写主设计文档并在根目录索引文件中更新引用。
5. 跨文档引用一律指向主设计文档,禁止从 plans/reviews 引用协议、字段、安全或部署规则。
## Coding Agent Rules
1. 禁止修改 `AGENTS.md`,除非用户明确要求。
2. 需求模糊先澄清,禁止猜测;未授权不重构,控制修改面。
3. 发现预料外的文件变更,不影响正确性不动,影响正确性先与用户确认。
4. 遵循 KISS 原则,抽象只服务于复用、测试或隔离;有错误直接指出改掉,不加兜底逻辑,优先返回 error(Let it Crash)。
5. 英文注释,仅注释复杂逻辑、约束和非显然设计。
6. Git、构建与质量门禁:commitizen 英文提交;commit 前执行 lint/format;构建时注入版本和日期信息。
7. 测试与模块健康:模块需有单测/集成测试;文件过大或多业务混杂时主动拆分。
8. 安全与依赖:日志脱敏;谨慎引入第三方依赖,说明引入原因。
9. 部署约定:日常开发、调试和 demo 默认使用 `just deploy`。
10. E2E 测试:各功能和流程需要覆盖端到端测试,并包含合适的初始化和 teardown 过程。
## Design Preferences
1. 优先用接口定义依赖边界,便于测试和替换
2. 优先简单直接的实现;抽象必须服务于复用、测试或隔离复杂度
3. 适当使用泛型减少重复代码,但不为泛型而泛型
4. 实现优雅关闭:通过 `context` 传播取消信号,并用 `defer` 完成清理
## Code Structure
Go 标准项目结构:
├── api # proto、OpenAPI 文档
├── cmd # 组件主文件、入口点
├── config # 配置文件定义、处理、示例
├── deploy # 部署相关文件
├── internal # 核心业务逻辑
├── pkg # 公共组件(业务无关)
├── web # 前端文件(内嵌到二进制)
└── tools # 工具脚本
## Code Style
1. 遵循 Go 官方风格(gofmt + goimports)
2. 使用 `go vet` 和 `go test` 作为基础质量检查;按需补充项目内 lint
3. 使用 `any` 代替 `interface{}`
4. 包名使用小写单词,尽量简短
5. 结构体字段命名遵循 Go 官方约定;仅在序列化或协议需要时添加 `json` 标签
6. 配置、RPC、HTTP 输入结构体需实现 `Validate` 方法
7. 无外部依赖的逻辑优先补单元测试
8. 注释使用英文,只注释复杂逻辑、约束和非显然设计决策
9. 使用 `github.com/sower-proxy/feconf` 处理配置
## Error Handling
1. 业务代码使用 `slog` 记录日志;工具库和基础组件优先返回 `error`
2. 关键函数入口使用 `github.com/sower-proxy/deferlog/v2` 记录退出日志,并基于返回值 `err` 自动判断成功或失败
3. 返回错误时使用 `fmt.Errorf` 包装上下文,保留关键参数和原始错误
4. 避免同一错误在同一调用链上重复记录;由最合适的边界统一记录
5. 重试只用于可恢复的关键操作,并显式限制次数、间隔和 `context` 生命周期
6. 错误上下文必须足以定位问题,但不得泄露敏感信息
## Build and Deployment
1. 使用 Makefile 作为统一构建入口,封装 build、test、package、release 等流程
2. 构建产物通过 `ldflags` 注入版本和构建日期信息
3. 部署参数、权限和运行能力默认遵循 `Security Specifications` 中的最小权限原则
## Security Specifications
1. 禁止修改 `AGENTS.md`,除非用户在当前任务中明确要求
2. 所有网络操作必须设置超时,并优先使用 `context` 控制生命周期
3. 运行时、部署和凭证访问均遵循最小权限原则
## 代码示例
### 配置加载与验证
使用 feconf 加载和验证配置:
```go
cfg, err := feconf.New[config.ConfigStruct]("c",
"warden.toml", "config/warden.toml", "/etc/warden.toml").Parse()
if err != nil {
log.Fatalln("load config failed", err)
}
if err := cfg.Validate(); err != nil {
log.Fatalln("validate config failed", err)
}
```
### 复杂业务函数的日志记录方式
在关键业务函数中使用 deferlog 自动记录错误日志:
```go
func (s *Service) ProcessOrder(orderID string) (err error) {
defer func() { deferlog.DebugError(err, "ProcessOrder", "order_id", orderID) }()
order, err := s.repository.GetOrder(orderID)
if err != nil {
return fmt.Errorf("get order %s: %w", orderID, err)
}
if err := s.validateOrder(order); err != nil {
return fmt.Errorf("validate order %s: %w", orderID, err)
}
if err := s.processPayment(order); err != nil {
return fmt.Errorf("process payment for order %s: %w", orderID, err)
}
if err := s.shipOrder(order); err != nil {
return fmt.Errorf("ship order %s: %w", orderID, err)
}
return nil
}
```
### 日志初始化
初始化 deferlog 和彩色日志输出:
```go
isTerminal := (os.Stdout.Stat().Mode() & os.ModeCharDevice) != 0
deferlog.SetDefault(slog.New(tint.NewHandler(os.Stdout,
&tint.Options{AddSource: true, NoColor: !isTerminal})))
```
+165
View File
@@ -0,0 +1,165 @@
# Architecture
## System Boundary
`sower` is the client-side transparent proxy entrypoint. It exposes local DNS, HTTP, HTTPS, and SOCKS5 listeners, applies rule-based routing, and forwards proxied traffic to an upstream transport.
`sowerd` is the server-side TLS ingress. It exposes `80/tcp` and `443/tcp`, terminates TLS, detects the upstream transport protocol, and relays traffic to the requested target or configured fake site.
For non-transport fallback traffic, it can route by TLS SNI to per-domain upstream site URLs, then falls back to the configured `fake_site`; directory-backed `fake_site` is served by the loopback local fileserver.
## Layer Responsibilities
`cmd/sower`
- Process bootstrap
- Config loading and validation
- Local listener lifecycle and graceful shutdown
- Rule file loading
- Upstream proxy dialing
`cmd/sowerd`
- Process bootstrap
- Interactive self-install mode dispatch
- Config loading and validation
- TLS and HTTP server lifecycle
- Graceful shutdown
`config`
- Runtime configuration schema
- Input validation rules
- Embedded default `sowerd` install config template
`internal/install`
- Interactive systemd installation flow for `sowerd`
- Binary copy/update and service file generation
- Default config and fake site directory bootstrap
`router`
- Domain and CIDR rule matching
- Thread-safe `RuleSet` (list/retain/rebuild) backing runtime rule management
- Direct/proxy/block routing decisions
- DNS query handling and upstream selection
`internal/admin`
- Admin HTTP server: session-cookie auth, rule CRUD API, traffic stats API
- Traffic stats recorder: aggregate counters and bounded per-domain accounting
- Serves the embedded admin console frontend
`web`
- Svelte 5 + Vite (Tailwind v4 + hand-maintained shadcn-svelte-style UI, no bits-ui) admin console frontend
- Build output `web/dist` is embedded into the binary via `go:embed`
`transport/sower`
- Sower transport frame encode/decode
`pkg/dhcp`
- DHCP-based upstream DNS discovery for the client side; the received OFFER is validated (matching transaction id, BOOTREPLY opcode, message type OFFER) so unrelated or spoofed LAN packets cannot inject DNS servers.
## sower Data Flow
1. Load config from flags, env, and files.
2. Validate remote type, listener addresses, and DNS IP fields.
3. Log startup metadata with secrets redacted.
4. Build the upstream proxy dialer with a stable DNS target. If `dns.upstream` is empty, the dialer uses `dns.fallback` to avoid recursive lookup through the local DNS listener.
5. Build the upstream dialer for the configured remote transport, using standard TLS by default and optional uTLS fingerprints for `sower`.
6. Build the router with suffix-tree rules and optional country CIDRs.
Remote rule files are fetched through the configured upstream proxy dialer, never by direct outbound HTTP, so rule bootstrap uses the same stable egress path as proxied traffic.
Remote domain rule files are filtered through per-router `file_skip_rules` before their prefixed entries are appended.
7. Start enabled local listeners for `udp/53`, `tcp/80`, `tcp/443`, and `tcp/1080` only after rule loading completes.
8. For DNS requests, return local proxy IPs only for explicitly proxy-routed domains and query upstream DNS for direct or unknown domains.
DNS routing intentionally does not mirror smart TCP routing: DNS must support arbitrary protocols and ports, so unknown names stay conservative and are not mapped to local HTTP/HTTPS proxy listeners by default.
Empty `dns.upstream` keeps router-side DHCP DNS discovery enabled; `dns.fallback` is appended as a backup upstream and is also used while initial discovery is in flight.
Proxy-routed domains return local A/AAAA records, suppress HTTPS/SVCB and other non-address metadata locally, and never leak proxy-matched names to direct upstream DNS.
Direct upstream DNS failures fall back only for retryable upstream service errors; when no fallback succeeds, the last upstream DNS response code is returned as-is.
Service discovery names are matched against both the full query name and the base domain only for service record types.
Reverse lookups (PTR) for internal ranges (RFC1918, CGNAT 100.64/10, link-local, loopback, IPv6 ULA) are answered with NXDOMAIN locally unless the upstream that would serve the query is an internal DNS server. The gate judges the currently selected upstream rather than the whole pool, so internal layout never leaks to public DNS — including a degraded mixed pool that fell back to a public resolver — and internal reverse resolution still works while an internal DNS server is selected.
Client attribution for DNS statistics prefers the EDNS Client Subnet (ECS) address when a forwarding resolver (e.g. dnsmasq --add-subnet) carries the real client in the query with a full-length source prefix (32/128; a truncated prefix such as --add-subnet=24 only identifies the subnet base and is ignored), falling back to the transport source address; ECS is stripped before any query is forwarded upstream so client subnets never reach public resolvers. ECS is trusted only from the local forwarding resolver — a direct client can spoof it, which affects console attribution only.
When `dns.reverse` is configured, the traffic console resolves client IPs to hostnames via PTR queries to that resolver (typically the local dnsmasq, which answers LAN leases and tailnet names and forwards everything else); results are cached for an hour, failed lookups for five minutes, and failures degrade to the raw IP.
9. For DNS-mode transparent HTTP traffic, parse the target host from the request line and always forward through the upstream proxy. For DNS-mode transparent HTTPS traffic, peek the TLS ClientHello to extract SNI and always forward through the upstream proxy.
These transparent `80/443` listeners are second-stage proxy-only handlers for domains already mapped to local proxy IPs by DNS; they do not run smart routing again.
HTTPS transparent proxying reads only the TLS ClientHello, then replays the untouched bytes to the selected upstream; it must not complete or terminate TLS locally.
10. For SOCKS5 traffic and explicit HTTP proxy traffic, read the client-supplied target host and port, apply smart routing rules, and either dial directly or wrap traffic in the configured upstream transport.
11. Wrap every proxied client connection in the admin stats recorder before protocol parsing, attribute bytes to the discovered domain after parsing, and count DNS queries through a handler decorator. Admin rule mutations take effect immediately and persist as `add` / `remove` deltas relative to the startup baseline; state write failures reject the mutation without changing the runtime rule set.
12. When `[admin]` is enabled, serve the admin console: session-cookie auth for the API, persisted rule deltas, sanitized effective-config display, whitelisted config overrides (immediate for `log_level` and DNS upstreams, restart-mode for the rest), per-rule hit and rule-miss statistics, and an in-place process restart endpoint; secrets never leave the server. The Svelte frontend is served from the embedded `web/dist`. By default the admin server owns a dedicated listener; when `admin.addr` exactly matches `dns.serve:80`, the admin console and the HTTP proxy share one listener and each connection is classified by its request head (origin-form with the listener IP as Host goes to admin; CONNECT, absolute-form, and other Hosts go to the proxy).
13. On shutdown signal, stop listeners and DNS servers through `context` propagation.
## sowerd Data Flow
1. Load config from flags, env, and files.
2. Validate required fields and address/certificate combinations.
3. Initialize logger with configured level and redact secrets from startup logs.
4. Start `:80` HTTP server.
5. Handle ACME HTTP-01 challenge on `:80`.
6. Redirect normal HTTP traffic to HTTPS.
7. If `fakeSite` is a local directory, serve it only for loopback fallback traffic through `127.0.0.1:80`.
8. Start `:443` TLS listener with HTTP/1.1 ALPN only.
9. Complete the TLS handshake explicitly (60s budget; first contact can drive synchronous ACME issuance) before applying the short probe read deadline.
10. Probe the connection's first bytes to identify the `sower` transport.
11. If matched, authenticate (the frame checksum covers command, port, and target via HMAC-SHA256; empty or control-character targets are rejected) and relay traffic to the decoded target — any host the process can dial, since there is no target allowlist (see [docs/security.md](docs/security.md)). The header read is bounded by its own deadline so a connection that sends only the probe byte cannot hold a goroutine and fd forever.
12. If authentication fails or no transport matches, read the TLS SNI from the terminated TLS connection.
13. If the SNI exactly matches a configured `site_routes` domain, apply that route's optional HTTP Basic visitor gate, then reverse-proxy the decrypted HTTP/1.1 request to that route's `http://` or `https://` upstream URL (URL userinfo credentials are sent to the upstream as Basic auth). A matched route always goes to the reverse proxy: `site_routes` serves HTTP/1.1 only.
14. If the SNI has no route and the decrypted probe is an HTTP/1.x request line, reverse-proxy to `fakeSite` while preserving the original Host and setting edge forwarding headers. Otherwise replay the original bytes to `fakeSite` with a TCP relay, including sower auth failures and other non-HTTP traffic.
## sowerd Install Flow
1. Detect install mode from CLI flags before normal config loading.
2. Require root privileges because the installer writes to `/usr/local/bin`, `/etc/systemd/system`, and `/etc/sower`.
3. Optionally copy or update the current binary to `/usr/local/bin/sowerd`.
4. Write `/etc/systemd/system/sowerd.service` with `ExecStart=/usr/local/bin/sowerd -c /etc/sower/sowerd.toml`.
5. Ensure `/etc/sower/sowerd.toml` exists — a fresh one is written with a generated random client password, printed once — and bootstrap `/var/www` for directory-backed fake site mode. An existing config is never rewritten.
6. Reload systemd and optionally enable/start or restart the service.
## Design Decisions
- Client and server both fail fast on invalid startup configuration instead of silently degrading.
- The client and server load TOML configuration files only; YAML and HCL are not supported.
- Sensitive configuration values must never be printed verbatim in logs.
- Local listeners use explicit shutdown hooks instead of blocking forever with unmanaged goroutines.
- Network operations use timeouts and `context` to limit hangs during dialing and remote rule downloads.
- The admin console persists rule changes as bounded deltas in `admin.state_file`, without rewriting TOML or rule sources. It also exposes a sanitized effective-config view and persists whitelisted overrides: `log_level` and the DNS upstreams apply immediately, every other whitelisted field (remote, listeners, rule sources) takes effect on the next restart. Clearing an override reverts the field to the file/flag configuration. `--ignore-admin-state` is the startup escape hatch for a bad state file or override.
- The admin server is disabled by default, binds to loopback by default, and requires a password when enabled; an empty password falls back to a startup-generated random one printed once in the log, and the login page tells the user where to find it. Login is rate-limited per remote IP (five failures lock for fifteen minutes). The frontend never stores the password (HttpOnly session cookie only).
- The admin restart endpoint replaces the process image in place (`exec` on Unix, same PID) so systemd stays unaware; on platforms without in-place restart the endpoint reports an error instead of acknowledging a restart that would fail. Sessions persist to `admin.session_file` (atomic write, 0600) so a restart keeps the browser logged in; `admin.disable_session_persistence` and `admin.cookie_secure` tune that behavior. Session persistence is best-effort: a disk write failure never blocks login or leaves a revoked session valid in memory — the in-memory state stays authoritative and the failure is logged.
- Rule hit statistics (per matched rule, per category) and rule-miss statistics (per domain for connections that matched no rule) are tracked in bounded in-memory maps fed by router observers; rule mutations invalidate the hit domain cache so counts stay attributable to the current rule set.
- The admin console can share the DNS HTTP proxy listener on port 80 when `admin.addr` equals `dns.serve:80`; classification is Host-based (origin-form requests with the listener IP as Host are admin traffic), so proxying a target whose Host equals the listener IP is inherently ambiguous and routes to admin. The HTTPS and DNS listeners speak different protocols and cannot be shared.
- Traffic monitoring reports proxied payload bytes and request/connection counters, not packet-level network accounting. Per-domain byte attribution is batched per connection (32 KiB threshold or 500 ms window) so the relay hot path pays two atomics per I/O instead of a global mutex; Close always drains the remainder.
- The process sets a soft Go memory limit (128 MiB by default; an explicit `GOMEMLIMIT` wins, `SOWER_MEMORY_LIMIT_MB` overrides with a MiB value, `0` disables). The default ad/china/gfw rule lists build ~40 MiB of suffix trees and GOGC's 2x target would otherwise keep resident memory near 250 MiB on an idle gateway.
- Upstream TLS behavior is configured only on the client side; `sowerd` remains a normal TLS server and does not need uTLS-specific logic.
- Site routes are public endpoints (autocert issues for their domains, and issued certificates are published in Certificate Transparency logs), so a route whose upstream cannot authenticate its own visitors can opt into an edge-side HTTP Basic gate (`site_routes.auth`). The gate consumes the visitor's `Authorization` header — the edge credential is not the upstream's — and throttles accumulated failures per source IP. This is a perimeter gate for the route's HTTP surface, not an account system, a replacement for the upstream's own authentication, or a second factor; the mechanism and its rejected alternatives (session login, gating the fake-site decoy) are recorded in `docs/security.md`.
- Rule loading supports local files and remote HTTP sources; remote downloads must use the configured upstream proxy and fail startup if the proxy path cannot fetch them.
- Domain rule files support per-router skip rules for filtering third-party file entries without removing explicit local rules.
- HTTP/HTTPS access probes are cached with an hour-long write TTL through `github.com/maypok86/otter/v2`, keeping repeated smart-routing checks bounded without hiding later reachability changes indefinitely.
- Country routing treats `router.country.mmdb` as optional; an empty value disables GeoIP lookup and keeps CIDR-based matching active without startup warnings. A non-empty invalid MMDB path is a startup error.
- DNS routing, transparent HTTP/HTTPS forwarding, and smart TCP routing are separate policies. DNS uses conservative explicit proxy rules only; transparent HTTP/HTTPS is proxy-only; SOCKS5 and explicit HTTP proxy traffic use smart routing.
- A proxy dial must never re-enter this client's own listeners: both dial entries (`router.DialSmart` for rule/probe-routed traffic and `router.DialProxyOnly` for the tunnel) refuse a target that resolves to one of the addresses the client serves proxy traffic on — the HTTP and HTTPS proxy listeners on every DNS listen address, plus SOCKS5 (`router.ErrSelfProxyTarget`; HTTP answers `403`, SOCKS5 `0x02` not-allowed). Both are guarded because `DialSmart` can reach `directDial` without passing `DialProxyOnly`, and either branch re-enters the proxy. Without the guard one request to a name whose A record is the client's own serve address becomes a self-sustaining chain between the client and its relay (2026-10-12: 601 connections on r6c, and the relay host was OOM-killed), or a local loop that never leaves the host when the target is classified direct. The target is resolved through the client's configured upstream resolvers — never the system resolver, because a deployed client answers its own DNS for every domain it proxies, which would make every proxied dial look like a self target — and the answer is cached for a minute so a proxied dial does not pay a DNS round trip per connection; a resolution failure is not treated as evidence of self-targeting.
- Fake site directory mode is loopback-only on port `80` to avoid exposing local static assets directly to the public internet.
- `sowerd` prefers the user cache directory for ACME state, but falls back to `/var/cache/sower` so systemd services can start without `HOME`/`XDG_CACHE_HOME` or a config file.
- `sowerd` fallback site routing is based only on exact TLS SNI matches; wildcard domains are not supported.
- `sowerd` site routes use HTTP reverse proxying to support full `http://` and `https://` upstream URLs. The reverse proxy rewrites the outbound Host to the upstream host; a route can set `authority = "client"` to forward the client's authority instead, for upstreams that validate the authority themselves (see [docs/security.md](docs/security.md)).
- `sowerd` is the TLS edge for fallback HTTP. Reverse-proxied site routes and HTTP fake-site fallback overwrite `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Proto`, and `X-Real-IP` from the immediate client; client-supplied forwarding headers are discarded. `X-Forwarded-Proto` is `https` because this path is the 443 listener after TLS termination. `X-Forwarded-Host` always carries the original authority. The HTTP fake-site fallback has always kept that authority in the Host header itself, matching the previous TCP relay; a site route keeps it too when the route sets `authority = "client"`.
- `sowerd` reverse-proxied fallback supports HTTP/1.1 protocol upgrades (including websocket) through the standard library ReverseProxy hijack path.
- `sowerd` site routing applies bounded client header, upstream dial, TLS handshake, and upstream response-header timeouts.
- `sowerd` advertises only HTTP/1.1 over TLS because fallback site routing and fake-site serving are HTTP/1.1 paths.
- In autocert mode, `sowerd` obtains certificates only for configured domains: every `site_routes` domain plus the `cert.domains` whitelist (autocert HostPolicy), so an arbitrary SNI cannot drive ACME issuance and exhaust the account's rate limits. Direct-connection domains (e.g. the sower client's remote addr) must be listed in `cert.domains`. In custom certificate mode, the configured certificate must cover every routed domain through SANs.
## Operational Notes
- `sower` usually needs elevated privileges to bind `53/udp`, `80/tcp`, and `443/tcp`.
- `sowerd` must bind privileged ports `80` and `443`.
- `sowerd -i` also requires root because it writes system-level files for self-deployment.
- ACME mode requires port `80` to be reachable from the public internet.
- Remote rule download failures stop startup after bounded retries before local listeners are exposed.
- In ACME mode the handshake is answered for arbitrary SNI with the first whitelist domain's certificate (unknown-SNI fallback); keep that entry concrete. See [docs/security.md](docs/security.md).
## Related Documents
- [README.md](README.md)
- [docs/README.md](docs/README.md) — documentation index
- [docs/security.md](docs/security.md) — sowerd edge threat model and anti-probing rules
+17 -368
View File
@@ -1,373 +1,22 @@
Mozilla Public License Version 2.0
==================================
MIT License
1. Definitions
--------------
Copyright (c) 2020 wweir
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
1.3. "Contribution"
means Covered Software of a particular Contributor.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.
+32 -25
View File
@@ -1,32 +1,39 @@
SERVER:=127.0.0.1:5533
MAKEFLAGS += --jobs all
GO:=CGO_ENABLED=0 go
default: test build
generate:
go generate ./...
test:
go vet ./...
go test ./...
build:
GO111MODULE=on go build -v -ldflags \
"-X main.version=$(shell git describe --tags) \
-X main.date=$(shell date +%Y-%m-%d)"
image:
docker build -t sower -f deploy/Dockerfile .
test: web check-scripts test-install
${GO} vet ./...
${GO} test ./...
kill:
sudo pkill -9 sower || true
.PHONY: check-scripts
check-scripts:
sh -n install.sh
sh -n install_e2e_test.sh
sh -n .github/sower.init
client: build kill
sudo $(PWD)/sower
.PHONY: test-install
test-install:
sh install_e2e_test.sh
server: build kill
$(PWD)/sower -n TCP -v 1
build: sower sowerd
.PHONY: web
web:
cd web && bun install --frozen-lockfile && bun run build
run: build kill
$(PWD)/sower -n TCP -v 1 &
sudo $(PWD)/sower &
@sleep 1
curl 127.0.0.1
@sleep 1
@sudo pkill -9 sower || true
.PHONY: sower
sower: web
${GO} build -ldflags "\
-X main.version=$(shell git describe --tags --always) \
-X main.date=$(shell date +%Y-%m-%d)" \
-o sower ./cmd/sower
.PHONY: sowerd
sowerd:
${GO} build -ldflags "\
-X main.version=$(shell git describe --tags --always) \
-X main.date=$(shell date +%Y-%m-%d)" \
-o sowerd ./cmd/sowerd
clean:
rm -f sower sowerd sower.exe sowerd.exe
+391 -74
View File
@@ -1,95 +1,412 @@
# sower
[![GitHub release](http://img.shields.io/github/release/wweir/sower.svg?style=popout)](https://github.com/wweir/sower/releases)
[![CircleCI](https://img.shields.io/circleci/token/64114421aab286b6e918741071535250a5273f06/project/github/wweir/sower/master.svg?style=popout)](https://circleci.com/gh/wweir/sower)
[![GitHub issue](https://img.shields.io/github/issues/wweir/sower.svg?style=popout)](https://github.com/wweir/sower/issues)
[![GitHub star](https://img.shields.io/github/stars/wweir/sower.svg?style=popout)](https://github.com/wweir/sower/stargazers)
[![GitHub license](https://img.shields.io/github/license/wweir/sower.svg?style=popout)](LICENSE)
中文介绍见 [Wiki](https://github.com/wweir/sower/wiki)
[![GitHub release](http://img.shields.io/github/release/sower-proxy/sower.svg?style=popout)](https://github.com/sower-proxy/sower/releases)
[![Actions Status](https://github.com/sower-proxy/sower/workflows/Go/badge.svg)](https://github.com/sower-proxy/sower/actions)
[![GitHub issue](https://img.shields.io/github/issues/sower-proxy/sower.svg?style=popout)](https://github.com/sower-proxy/sower/issues)
[![GitHub star](https://img.shields.io/github/stars/sower-proxy/sower.svg?style=popout)](https://github.com/sower-proxy/sower/stargazers)
[![GitHub license](https://img.shields.io/github/license/sower-proxy/sower.svg?style=popout)](LICENSE)
The sower is a cross-platform intelligent transparent proxy tool base on DNS solution.
Sower 是一个跨平台的智能分流代理工具。
The first time you visit a new website, sower will detect if the domain in block list and add it in suggect list. So that, you do not need to care about the rules, sower will handle it in a intelligent way.
它可以同时提供:
If you wanna enjoy the sower, you need to deploy sower on both server and client side.
On client side, sower listening UDP `53` and TCP `80`/`443` ports, so that you need run it with privileged.
On server side, it just listening to a port (default `5533`), parse and relay the request to target server.
- DNS 分流:客户端先问 Sower 域名怎么解析,Sower 根据规则决定直连还是代理。
- HTTP/HTTPS 透明代理:DNS 模式下,Sower 可以接住被导向自己的 `80/443` 流量,再按规则转发。
- SOCKS5 代理:把 Sower 当成一个普通的 `socks5h` 代理使用,也能享受同一套分流规则。
Sower also provides an http(s) proxy listening on `:8080` by default. You can turn it off or use another port at any time.
如果你已经有机场、VPS、Clash、V2Ray、SSR 或其它 SOCKS5 上游,推荐先把 Sower 当成“内网智能分流节点”使用,不一定要部署 `sowerd`。
## 安装
## Installation
After Deployed, please check your config file, it is placed in `/usr/local/etc/sower.toml` by default. Here is the example config file [**conf/sower.toml**](https://github.com/wweir/sower/blob/master/conf/sower.toml)
### Auto deploy
Auto deploy script support Linux server side and masOS/Linux client side.
### 一键安装(Linux / macOS / OpenWrt)
```shell
$ bash -c "$(curl -s https://raw.githubusercontent.com/wweir/sower/master/deploy/install)"
curl -fsSL https://raw.githubusercontent.com/sower-proxy/sower/master/install.sh | sudo sh
```
Then modify the configuration file as needed and set `127.0.0.1` as your first domain name server.
In most situation, you just need to modify `/etc/resolv.conf`.
脚本会识别平台与 init 系统,下载对应版本的 release、安装 `sower` 二进制、生成初始配置并注册系统服务:
If you wanna uninstall sower, change `install` into `uninstall` and rerun the command.
| 平台 | 服务方式 | 服务文件 |
| --- | --- | --- |
| Linux(systemd) | systemd | `/etc/systemd/system/sower.service` |
| OpenWrt / iStoreOS | procd | `/etc/init.d/sower` |
| macOS | launchd | `/Library/LaunchDaemons/sower.plist` |
### Manually deploy
1. Download the precompiled file from https://github.com/wweir/sower/releases
2. Decompression the file into a folder
3. Run `./sower -h` for help
5. Config domain name server
4. Config auto start
安装完成后编辑 `/etc/sower/sower.toml`(macOS 是 `/usr/local/etc/sower/sower.toml`),填好 `remote.addr` 与 `remote.password`,再重启服务。重复执行脚本等于原地升级,已有配置不会被覆盖。
### Docker deploy
The auto build docker images are [wweir/sower](https://hub.docker.com/r/wweir/sower).
常用参数:
It is very simple to use it on the server side. Export the port(5533) and run it directly.
But the client is more troublesome and needs some understanding of the working mechanism of the sower.
## Architecture
```shell
sudo sh install.sh --version v1.6.0 # 安装/回退到指定版本
sudo sh install.sh --mirror https://<你的反代>/ # 给 GitHub 下载地址加前缀
sudo sh install.sh --dry-run # 只打印将要执行的操作
sudo sh install.sh --service none \
--prefix "$HOME/.local/bin" --config-dir "$HOME/.config/sower" # 只装二进制
```
request target servers
<-------------+ +------------->
| |
| |
+------------server-------------+
| | relay service| |
| +-----+---------------------+ |
| | | |
| | parse http(s) target url | |
| | | |
| +---------------------------+ |
| shadow service |
+--------^----------------------+
| request domain server
quic / KCP / TCP +---------->
| |
+--------+---client+------+-----+
| | |
| shadow service | |
| relay service | dns |
| | service |
| | |
| 127.0.0.1 or other |
| | |
+-^-----^----------+---^----^---+
| | | |
| | | | +----->
http(s) proxy | +----------+ | |
2 1 1 2
+ + + +
blocked request normal request
远程安装默认会校验 release 里的 `SHA256SUMS`;老版本没有该文件时,需要显式加 `--insecure-skip-verify` 才会继续。`install.sh --help` 可以查看全部参数。
### 手动安装
从 [Releases](https://github.com/sower-proxy/sower/releases) 下载对应平台的 `sower-<平台>.tar.gz`,解压后得到 `sower`、`sowerd`、示例配置和服务文件:
```shell
tar -xzf sower-linux-amd64.tar.gz
sudo install -m 0755 sower /usr/local/bin/sower
sudo install -d -m 0755 /etc/sower
sudo install -m 0600 sower.toml /etc/sower/sower.toml
sudo install -m 0644 sower.service /etc/systemd/system/sower.service
sudo systemctl enable --now sower
```
For more detail, see [透明代理 Sower 技术剖析](https://wweir.cc/post/%E9%80%8F%E6%98%8E%E4%BB%A3%E7%90%86-sower-%E6%8A%80%E6%9C%AF%E5%89%96%E6%9E%90/)
Windows 目前没有一键脚本:解压后运行 `sower.exe -c sower.toml`,或参考 `sower_run.vbs` 自行建立开机启动项。
## Todo
- [x] authenticate
- [ ] broker(waiting for QUIC implementation to be stable)
- [x] CI/CD
- [x] relay optimization
- [x] deploy script for all normal platform
- [x] dns rule intelligent suggestions
## 推荐用法一:Tailscale + Sower 节点
对新手来说,最省事的方式是:选一台常开的设备运行 `sower`,让它加入 Tailscale,其它手机、电脑、平板也加入同一个 Tailscale 网络。
![推荐用法:Tailscale + Sower 节点](./recommended-tailscale-sower.png)
这个方案的核心思路很简单:
1. Tailscale 负责组网、身份认证和加密通道。
2. Sower 节点负责 DNS、SOCKS5 和智能分流。
3. 普通客户端不用暴露在公网里,只需要连上同一个 Tailscale 网络。
4. 国内网站按规则直连,国外网站按规则走上游代理。
### 适合谁
- 你有多台设备,不想在每台设备上单独维护复杂代理规则。
- 你已经有一个可用的上游代理,只想让 Sower 统一分流。
- 你不想把代理入口直接暴露到公网,只想在自己的 Tailscale 网络里使用。
### 工作方式
推荐同时使用两种入口:
- DNS 入口:客户端 DNS 指向 Tailscale 的 MagicDNS 地址 `100.100.100.100`,Tailscale 再把 DNS 请求转给 Sower 节点。
- SOCKS5 入口:需要手动设置代理的软件,直接使用 `socks5h://<Sower 节点的 Tailscale IP>:1080`。
DNS 模式下,Sower 会这样处理域名:
- 命中直连规则:返回真实 IP,客户端直接访问目标网站。
- 命中代理规则:返回 Sower 节点自己的 IP,客户端访问该 IP 的 `80/443`,Sower 再根据域名或 TLS SNI 转发到上游代理。
- 命中阻断规则:直接返回 DNS 错误。
SOCKS5 模式下,客户端把请求交给 Sower,Sower 再根据同一套路由规则决定直连或代理。
### Sower 节点配置
先复制示例配置:
```shell
cp config/sower.toml sower.toml
```
然后重点修改这几处:
```toml
[remote]
type = "socks5"
addr = "127.0.0.1:7890"
password = ""
[dns]
disable = false
serve = "<Sower 节点的 Tailscale IP>"
fallback = "223.5.5.5"
[socks_5]
disable = false
addr = "<Sower 节点的 Tailscale IP>:1080"
```
说明:
- 如果上游代理就在同一台机器上,例如 Clash 本地 SOCKS5 是 `127.0.0.1:7890`,就按上面这样写。
- 如果上游代理在另一台机器上,把 `remote.addr` 改成对应的 `host:port`。
- 如果你使用的是 `sowerd` 上游,把 `remote.type` 改成 `sower`,并按你的服务端信息填写 `remote.addr` 和 `remote.password`。
- `remote.password` 与 sowerd 的 `password` 按字面值传输,不做任何编解码(旧版本会把恰好是合法 base64 的密码当 base64 解码,导致认证失败;该行为已移除)。不要在配置文件里用编码“隐藏”密码,文件权限才是正确的手段。
- `dns.serve` 会同时决定 Sower 的 DNS 入口,以及 DNS 模式下 HTTP/HTTPS 透明代理监听的 IP。
启动:
```shell
sudo sower -c sower.toml
```
`sower` 通常需要 root 权限,因为 DNS 模式会监听 `53/udp`、`80/tcp` 和 `443/tcp`。SOCKS5 入口会监听你在 `[socks_5]` 中配置的地址。
如果你把监听地址写成 `0.0.0.0`,必须用系统防火墙限制只允许 Tailscale 网络访问。更简单的做法是直接绑定 Sower 节点的 Tailscale IP。
### Tailscale DNS 设置
在 Tailscale 的 DNS 设置里,把 Sower 节点的 Tailscale IP 配成自定义 DNS 服务器,并开启“覆盖本机 DNS”。
客户端侧保持使用 Tailscale 的 MagicDNS 地址:
```text
100.100.100.100
```
这样客户端发出的 DNS 请求会先进 Tailscale,再被转发到 Sower 节点。
### 客户端代理设置
如果软件支持 SOCKS5,推荐写成:
```text
socks5h://<Sower 节点的 Tailscale IP>:1080
```
这里的 `socks5h` 很重要:它表示域名解析也交给代理端处理。这样 Sower 才能根据域名做分流,客户端也不会把需要代理的域名提前解析掉。
## 推荐用法二:家庭或办公子网透明分流
如果你的设备都在同一个局域网里,例如家庭网络、小型办公室、实验室网络,也可以把 Sower 放在子网内,然后在路由器上修改 DNS。
这种方式的目标是:手机、电脑、电视等设备不单独配置代理,只要连上这个 Wi-Fi 或有线网络,就自动使用 Sower 做 DNS 分流和 HTTP/HTTPS 透明代理。
### 网络结构
假设你的局域网是:
- 路由器网关:`192.168.1.1`
- Sower 节点:`192.168.1.2`
- 普通客户端:`192.168.1.x`
工作流程是:
1. 路由器通过 DHCP 把 DNS 下发为 `192.168.1.2`。
2. 客户端访问网站前,先向 `192.168.1.2` 查询 DNS。
3. Sower 根据规则判断这个域名应该直连、代理还是阻断。
4. 直连域名返回真实 IP,客户端直接访问。
5. 代理域名返回 Sower 节点 IP,客户端后续访问 `192.168.1.2:80/443`。
6. Sower 接住 HTTP/HTTPS 流量,再转发到上游代理。
### Sower 节点配置
复制示例配置:
```shell
cp config/sower.toml sower.toml
```
重点修改:
```toml
[remote]
type = "socks5"
addr = "127.0.0.1:7890"
password = ""
[dns]
disable = false
serve = "192.168.1.2"
fallback = "223.5.5.5"
[socks_5]
disable = false
addr = "192.168.1.2:1080"
```
把 `192.168.1.2` 换成你的 Sower 节点局域网 IP。
启动:
```shell
sudo sower -c sower.toml
```
Sower 节点需要能监听 `53/udp`、`80/tcp`、`443/tcp` 和 `1080/tcp`。如果系统防火墙开启了,需要允许局域网设备访问这些端口。
### 路由器设置
在路由器的 DHCP 设置里,把下发给客户端的 DNS 服务器改成:
```text
192.168.1.2
```
不同路由器界面名称不一样,常见名称包括:
- DHCP DNS
- LAN DNS
- 自定义 DNS 服务器
- DNS 服务器 1
不要只改路由器自己的“上游 DNS”。要改的是“通过 DHCP 发给局域网客户端的 DNS”。否则客户端可能仍然拿到路由器 IP 作为 DNS,Sower 不会接到查询。
修改后,让客户端重新连接 Wi-Fi,或者更新 DHCP 租约。确认客户端 DNS 已经变成 Sower 节点 IP。
### 建议的路由器限制
如果你想让全子网都稳定走这套透明分流,路由器上还应该限制几类绕过路径:
- 拦截或阻断客户端直接访问外部 `53/udp` 和 `53/tcp`,只允许访问 Sower 节点的 DNS。
- 阻断常见 DoH/DoT,或者在客户端浏览器里关闭“安全 DNS”。
- 阻断 `443/udp`,避免 QUIC 绕过 Sower 的 TCP `443` 透明代理;浏览器通常会回退到 TCP HTTPS。
这些限制不是 Sower 自己完成的,需要路由器、防火墙或网关设备支持。
### 适合和不适合的场景
适合:
- 家庭网络里想让大多数设备自动分流。
- 小型办公网络里想集中维护规则。
- 电视、游戏机、IoT 设备不方便单独配置 SOCKS5。
不适合:
- 客户端强制使用内置 DoH/DoT,且你无法关闭或阻断。
- 应用直接访问固定 IP,不经过 DNS。
- 网络里已经有复杂网关策略,且不能修改 DHCP DNS 或防火墙规则。
这个方案的前提是客户端愿意使用路由器下发的 DNS。只改 DNS 不是万能透明代理;它依赖域名解析把需要代理的 HTTP/HTTPS 流量导向 Sower。
## 传统用法:部署 sowerd
如果你想自己搭一台 Sower 服务端,可以部署 `sowerd`。
`sowerd` 运行在服务端,像一个 Web 代理服务,会监听:
- `80/tcp`
- `443/tcp`
它会在启动时校验配置,日志中会脱敏敏感字段。如果 `80/443` 监听失败,进程会直接退出,不会假装启动成功。
`sowerd` 可以使用你自己的证书,也可以通过 Let's Encrypt 自动申请证书。
如果 `sowerd` 作为 systemd 服务运行,且环境里没有 `HOME` 或 `XDG_CACHE_HOME`,证书缓存会退回到 `/var/cache/sower`。
当 `fake_site` 指向本地目录时,`sowerd` 只会通过 `127.0.0.1:80` 的回退流量服务这个目录;公网 HTTP 流量仍会重定向到 HTTPS。未命中 `site_routes` 的 HTTP/1.x 浏览请求会反代到 `fake_site`(目录模式为 loopback `:80`,或配置的 `host:port`),并覆盖写入 `X-Forwarded-For`、`X-Forwarded-Proto`、`X-Forwarded-Host`、`X-Real-IP`;非 HTTP 流量仍按原字节 TCP relay。
### 站点路由的访问认证
`site_routes` 的域名会随 Let's Encrypt 证书进入 Certificate Transparency 日志,任何人都能查到,因此上游站点如果自身没有登录功能,就等于公开。可以给单条路由加一个边缘认证门(HTTP Basic):
```toml
[[site_routes]]
domains = ["nas.example.com"]
upstream = "http://127.0.0.1:5000"
[site_routes.auth] # 两个字段都填才生效,留空即不启用
username = "admin"
password = "your_long_random_password"
```
- 只作用于这条路由的反代流量(路径分流和 WebSocket 升级都包含在内)。`sower` 客户端的传输流量在 SNI 路由之前就已分流,只用传输密码认证,不受影响。
- 浏览器 WebSocket 是已知的边界情况:门禁会拦到 `Upgrade` 请求,但脚本无法给 WebSocket 握手设置 `Authorization`,浏览器只能靠复用该 origin 已缓存的 Basic 凭证,能否复用因浏览器而异(未验证);门禁按路由生效,无法只放行 `/ws`。每次失败握手还会计入按来源 IP 的锁定计数,所以会自动重连的客户端(socket.io 之类)可能把自己这个 IP 锁在整条路由之外(连普通页面也一起锁)15 分钟。把浏览器的 WS 路由放在门禁后面之前请先自测,或让该客户端走 `sower` 传输。
- 认证通过后客户端的 `Authorization` 头会被丢弃,不会转发给上游,也不会写进上游日志。
- 同一来源 IP 累计 5 次失败(中间没有成功请求)会被拒绝 15 分钟(返回 429),所以弱口令不会被直接暴力枚举。但它是边缘门禁,不是上游自身认证的替代品,也不是账号系统(一条路由一组账号,没有会话和登出)。
- 上游需要认证时把凭证写在 upstream 的 userinfo 里(`upstream = "http://user:pass@127.0.0.1:5000"`),`sowerd` 会把它作为 Basic 凭证发给上游;错误日志中该密码会被脱敏。
启动方式有两种。
直接运行:
```shell
sudo sowerd -password XXX -fake_site 127.0.0.1:8080
```
交互式安装为 systemd 服务:
```shell
sudo sowerd -i
```
安装器可以复制当前二进制到 `/usr/local/bin/sowerd`,写入 `/etc/systemd/system/sowerd.service`,在缺少配置时创建 `/etc/sower/sowerd.toml`,并按提示决定是否立即启动服务。新建配置会写入一个随机生成的客户端口令并在安装过程中打印一次(不会沿用模板里的 `change_me` 占位值);已有配置不会被覆盖,需要自己把 `password` 改成强随机值——它是传输路径上唯一的凭据。
## 管理控制台(Admin)
`sower` 内置一个本地管理控制台,用于运行期管理路由规则和监控流量:
- **规则管理**:实时查看、添加、删除 block / direct / proxy 三类规则,立即生效;变更以增量形式持久化到 `admin.state_file`,重启后自动重放(不会改写配置文件)。
- **流量监控**:DNS 查询数、各入口连接数、上下行字节数、按域名聚合的流量,以及每条规则的命中统计和未命中规则的域名访问统计。
- **配置页**:展示生效配置(并按来源标注为配置文件值或「覆盖」值),可在线调整白名单字段。覆盖以增量持久化到 `admin.state_file`,重启后自动重放;把某字段清空会恢复配置文件里的值。
可编辑字段按生效方式分为三类:
- **立即生效**(无需重启):`log_level`、`dns.upstream`、`dns.fallback`。
- **重启后生效**:远程代理(`remote.type/addr`、`remote.tls.*`)、监听地址(`dns.serve/serve6`、`socks5.addr`)、admin 自身(`admin.session_file`、`admin.disable_session_persistence`、`admin.cookie_secure`、`admin.state_file`)、规则来源(各 `router.*.file/prefix/skip_rules/rules`、`router.country.*`)。
- **只读**:版本、构建时间,以及 `remote.password`、`admin.password` 等密钥字段(只显示「是否已配置」,绝不回显值)。
规则类列表字段(如内联规则、`file_skip_rules`)在控制台内按每行一条编辑,展示时折叠为数量。
- **重启服务**:一键触发就地重启——先关闭所有监听(释放 80/443/53 等端口),再 `exec` 替换当前进程,PID 保持不变,因此 systemd 服务的 `Restart=on-failure` 不会被误触发。
默认关闭。在 `sower.toml` 中启用:
```toml
[admin]
disable = false # 默认 true
addr = "127.0.0.1:19090" # 默认仅监听回环
password = "your_admin_password" # 示例值;留空则启动时自动生成临时密码
```
`admin.password` 接受明文(按字面值)或显式带 `b64:` 前缀的 canonical base64(如 `echo -n "your_password" | base64` 后写成 `password = "b64:c2VjcmV0..."`)。明文密码永远不会被解码,可以放心写。
启动后打开 `http://127.0.0.1:19090`,输入 admin 密码即可。未配置 `password` 时,登录页会提示使用启动日志中打印的临时密码(每次重启更换;已登录的浏览器通过会话持久化免重新登录)。密码只用于换取会话 cookie,不会保存在浏览器里。
### Admin 配置项
| 字段 | 默认值 | 说明 |
| --- | --- | --- |
| `disable` | `true` | 默认关闭管理台 |
| `addr` | `127.0.0.1:19090` | 监听地址;设为 `dns.serve:80` 时与 HTTP 代理共享端口 |
| `password` | 空 | 未配置时启动自动生成一次性随机密码(打印到启动日志);只用于换取会话 cookie |
| `state_file` | `/etc/sower/admin-state.json` | 规则增量与配置覆盖的持久化文件;空字符串禁用持久化 |
| `session_file` | `/etc/sower/sessions.json` | 会话持久化文件,重启后浏览器免重新登录;仅限单进程使用 |
| `disable_session_persistence` | `false` | 设为 `true` 关闭会话持久化(重启后需重新登录) |
| `cookie_secure` | `false` | 仅当管理台位于 TLS 终止代理(如 nginx/caddy)之后时设为 `true`,给会话 cookie 加 `Secure` 标记 |
### 与 HTTP 代理共享端口
如果希望管理台与 HTTP 代理复用同一个 `IP:80`,把 `admin.addr` 设为 `dns.serve:80`:
```toml
[dns]
disable = false
serve = "192.168.1.10"
[admin]
disable = false
addr = "192.168.1.10:80" # 与 dns.serve:80 相同,进入共享模式
password = "your_admin_password"
```
共享模式下,同一个监听器按请求头区分流量:
- `Host` 等于监听 IP 的普通请求(如浏览器访问 `http://192.168.1.10/`)→ 管理台
- `CONNECT`、absolute-form 请求、以及其他 `Host` 的请求 → HTTP 代理
注意:
- 只支持共享 `80` 端口;`443`(HTTPS 透明代理)和 `53`(DNS)无法共享,配置冲突会启动失败。
- 代理一个 `Host` 恰好等于监听 IP 的目标时无法区分,会进入管理台。
- 管理台仍只提供明文 HTTP,共享 `80` 时请勿在公网直接暴露。
- 独立监听模式(默认 `127.0.0.1:19090`)不受影响。
## 规则和配置补充
`sower.toml` 是默认示例配置。完整示例见 [config/sower.toml](./config/sower.toml)。
配置格式仅支持 TOML(`sower.toml` / `sowerd.toml`)。旧版本支持的同名 YAML 配置文件已不再被读取,升级前请将 YAML 配置迁移为 TOML。
几个容易踩坑的点:
- 远程规则文件会通过上游代理下载,不会直接出网。
- 远程规则加载失败时,`sower` 会启动失败,不会带着不完整规则继续运行。
- `router.country.mmdb` 是可选项,留空表示关闭 GeoIP,只使用配置里的 CIDR 规则。
- HTTPS 透明代理只读取 TLS ClientHello 里的 SNI,不会在本机解密或终止 TLS。
- HTTP/HTTPS 可达性探测结果会缓存 1 小时,减少重复探测,同时避免长期固定错误状态。
- `sower` 上游的 `remote.addr` 可以写 `host`,也可以写 `host:port`。
- `remote.tls` 可以设置 SNI、跳过证书校验,或使用 `chrome`、`firefox` 等 uTLS 指纹。
- 规则文件可以用 `file_skip_rules` 跳过第三方列表中的个别条目。例如在 `[router.block]` 中写 `file_skip_rules = ["t.co"]`。
## 架构
![Architecture diagram](./sower.drawio.svg)
更详细的架构、边界和数据流说明见 [ARCHITECTURE.md](./ARCHITECTURE.md)。
+526
View File
@@ -0,0 +1,526 @@
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"math"
"net"
"sort"
"strings"
"sync"
"time"
"github.com/miekg/dns"
"github.com/sower-proxy/sower/config"
"github.com/sower-proxy/sower/internal/admin"
"github.com/sower-proxy/sower/router"
)
const adminShutdownTimeout = 5 * time.Second
// newAdminServer builds the admin server, wiring in the configured reverse
// DNS hostname resolver when present.
func newAdminServer(cfg config.SowerConfig, password string, temporary bool, rules admin.RuleManager, configMgr admin.ConfigManager, stats *admin.Stats, restartCh chan<- struct{}) *admin.Server {
var hostnames admin.HostnameResolver
if cfg.DNS.Reverse != "" {
hostnames = newDNSHostnameResolver(cfg.DNS.Reverse)
}
return admin.NewServer(admin.Options{
Password: password,
TemporaryPassword: temporary,
Version: version,
Date: date,
Rules: rules,
Stats: stats,
SessionFile: cfg.AdminSessionFile(),
CookieSecure: cfg.Admin.CookieSecure,
Config: configMgr,
Restart: restartFn(restartCh),
Hostnames: hostnames,
})
}
// resolveAdminPassword returns the configured admin password, or a random one
// generated at startup when none is configured, plus whether the fallback was
// used. The generated password is printed once in the startup log
// (intentionally: it is the only way to log in) and changes on every restart;
// persisted sessions keep an already logged-in browser working across
// restarts. The temporary flag drives the /api/login-info hint on the login
// page.
func resolveAdminPassword(configured string) (password string, temporary bool) {
if configured != "" {
return configured, false
}
pw := admin.GeneratePassword()
slog.Warn("admin enabled without a password; generated a temporary password",
"password", pw,
"hint", "set [admin].password to use a fixed password")
return pw, true
}
// adminRules adapts the router's rule sets to the admin RuleManager
// interface. Mutations persist through the StateStore first; the runtime
// rule set only changes after a successful write, so a persist failure
// surfaces as an API error without leaving memory and disk out of sync.
type adminRules struct {
// mutationMu covers the persist-then-runtime sequence. StateStore alone
// serializes disk candidates, but it cannot keep a later mutation from
// overtaking an earlier RuleSet update.
mutationMu sync.Mutex
r *router.Router
state *admin.StateStore
baseline map[admin.Category][]string
// hits resolve and count rule hits per category; any rule mutation must
// invalidate the matching tracker's domain cache via invalidateHits.
blockHits *ruleHitTracker
directHits *ruleHitTracker
proxyHits *ruleHitTracker
// missHits counts rule-less connections per domain.
missHits *ruleMissTracker
}
// newAdminRules builds the adapter. baseline holds the boot rule lists and
// must already be registered on the StateStore via SetBaseline.
func newAdminRules(r *router.Router, state *admin.StateStore, baseline map[admin.Category][]string, blockHits, directHits, proxyHits *ruleHitTracker, missHits *ruleMissTracker) *adminRules {
return &adminRules{r: r, state: state, baseline: baseline, blockHits: blockHits, directHits: directHits, proxyHits: proxyHits, missHits: missHits}
}
// snapshotBaseline captures the current rule lists as the boot baseline.
// Call after config and rule files are loaded, before applying state deltas.
func snapshotBaseline(r *router.Router) map[admin.Category][]string {
return map[admin.Category][]string{
admin.CategoryBlock: r.BlockRule.List(),
admin.CategoryDirect: r.DirectRule.List(),
admin.CategoryProxy: r.ProxyRule.List(),
}
}
func (a *adminRules) RuleSearch(category admin.Category, q string, offset, limit int, sortBy admin.RuleSort, dir admin.SortDir) ([]admin.RuleEntry, uint64, error) {
rs, err := a.rules(category)
if err != nil {
return nil, 0, err
}
if sortBy != admin.RuleSortRule && sortBy != admin.RuleSortHits && sortBy != admin.RuleSortLastSeen {
rules, total := rs.ListFiltered(q, offset, limit)
entries := make([]admin.RuleEntry, len(rules))
for i, rule := range rules {
entries[i] = a.entry(category, rule)
}
return entries, total, nil
}
// Non-default ordering needs the full filtered set. Stat lookups are
// O(1) per rule and every sort keeps file order as the stable tiebreak,
// so the scan stays cheap even at 280k rules.
all, total := rs.ListFiltered(q, 0, math.MaxInt)
entries := make([]admin.RuleEntry, len(all))
for i, rule := range all {
entries[i] = a.entry(category, rule)
}
switch sortBy {
case admin.RuleSortRule:
sort.SliceStable(entries, func(i, j int) bool {
if dir == admin.SortDirAsc {
return entries[i].Rule < entries[j].Rule
}
return entries[i].Rule > entries[j].Rule
})
case admin.RuleSortHits:
entries = partitionByStat(entries, func(e admin.RuleEntry) (int64, bool) {
return int64(e.Count), e.Count > 0
}, dir)
case admin.RuleSortLastSeen:
entries = partitionByStat(entries, func(e admin.RuleEntry) (int64, bool) {
if e.LastSeen == nil {
return 0, false
}
return e.LastSeen.UnixNano(), true
}, dir)
}
if offset >= len(entries) {
return []admin.RuleEntry{}, total, nil
}
end := offset + limit
if end > len(entries) {
end = len(entries)
}
return entries[offset:end], total, nil
}
// partitionByStat splits entries into those carrying a stat and those
// without, sorts the stat-bearing ones by key in dir, and keeps the rest in
// file order on the side the direction implies: a missing stat sorts below
// any real value, so desc appends it and asc prepends it.
func partitionByStat(entries []admin.RuleEntry, key func(admin.RuleEntry) (int64, bool), dir admin.SortDir) []admin.RuleEntry {
type keyed struct {
entry admin.RuleEntry
key int64
}
hit := make([]keyed, 0, 64)
miss := make([]admin.RuleEntry, 0, len(entries))
for _, e := range entries {
if k, ok := key(e); ok {
hit = append(hit, keyed{e, k})
} else {
miss = append(miss, e)
}
}
sort.SliceStable(hit, func(i, j int) bool {
if dir == admin.SortDirAsc {
return hit[i].key < hit[j].key
}
return hit[i].key > hit[j].key
})
ordered := make([]admin.RuleEntry, 0, len(entries))
if dir == admin.SortDirAsc {
ordered = append(ordered, miss...)
}
for _, h := range hit {
ordered = append(ordered, h.entry)
}
if dir != admin.SortDirAsc {
ordered = append(ordered, miss...)
}
return ordered
}
// entry builds a listing row for one rule, attaching hit stats from the
// category's tracker when it has them.
func (a *adminRules) entry(category admin.Category, rule string) admin.RuleEntry {
e := admin.RuleEntry{Rule: rule}
t := a.tracker(category)
if t == nil {
return e
}
count, last := t.Lookup(rule)
e.Count = count
if !last.IsZero() {
e.LastSeen = &last
}
return e
}
// tracker returns the hit tracker for one category, or nil when the
// category carries no hit stats.
func (a *adminRules) tracker(category admin.Category) *ruleHitTracker {
switch category {
case admin.CategoryBlock:
return a.blockHits
case admin.CategoryDirect:
return a.directHits
case admin.CategoryProxy:
return a.proxyHits
default:
return nil
}
}
func (a *adminRules) RuleAdd(category admin.Category, rules ...string) error {
a.mutationMu.Lock()
defer a.mutationMu.Unlock()
rs, err := a.rules(category)
if err != nil {
return err
}
runtimeAdd, err := a.state.RuleAdd(category, rules...)
if err != nil {
return fmt.Errorf("persist rule additions: %w", err)
}
if len(runtimeAdd) == 0 {
return nil
}
// Reinstating a baseline rule must restore the boot order. MatchRule is
// intentionally first-match for diagnostics, so appending a restored
// baseline rule would otherwise change its observable result.
for _, rule := range runtimeAdd {
if a.isBaselineRule(category, rule) {
rs.Replace(a.effectiveRules(category)...)
a.invalidateHits(category)
return nil
}
}
rs.Add(runtimeAdd...)
rs.Compact()
a.invalidateHits(category)
return nil
}
// invalidateHits drops the rule hit cache of one category after a rule
// mutation. An empty category (reset-all) invalidates all trackers.
func (a *adminRules) invalidateHits(category admin.Category) {
switch category {
case admin.CategoryBlock:
if a.blockHits != nil {
a.blockHits.Invalidate()
}
case admin.CategoryDirect:
if a.directHits != nil {
a.directHits.Invalidate()
}
case admin.CategoryProxy:
if a.proxyHits != nil {
a.proxyHits.Invalidate()
}
case "":
a.invalidateHits(admin.CategoryBlock)
a.invalidateHits(admin.CategoryDirect)
a.invalidateHits(admin.CategoryProxy)
}
}
func (a *adminRules) isBaselineRule(category admin.Category, rule string) bool {
for _, baselineRule := range a.baseline[category] {
if baselineRule == rule {
return true
}
}
return false
}
func (a *adminRules) RuleRemove(category admin.Category, rule string) (bool, error) {
removed, err := a.RuleRemoveMany(category, rule)
return len(removed) > 0, err
}
// RuleRemoveMany persists the full deletion candidate before touching the
// runtime RuleSet, so a write failure cannot partially apply a batch.
func (a *adminRules) RuleRemoveMany(category admin.Category, rules ...string) ([]string, error) {
a.mutationMu.Lock()
defer a.mutationMu.Unlock()
rs, err := a.rules(category)
if err != nil {
return nil, err
}
removed, err := a.state.RuleRemoveBatch(category, rules...)
if err != nil {
return nil, fmt.Errorf("persist rule removals: %w", err)
}
if len(removed) == 0 {
return nil, nil
}
removedSet := make(map[string]struct{}, len(removed))
for _, rule := range removed {
removedSet[rule] = struct{}{}
}
current := rs.List()
kept := current[:0]
for _, rule := range current {
if _, removed := removedSet[rule]; !removed {
kept = append(kept, rule)
}
}
rs.Replace(kept...)
a.invalidateHits(category)
return removed, nil
}
func (a *adminRules) RuleCount(category admin.Category) uint64 {
rs, err := a.rules(category)
if err != nil {
return 0
}
return rs.Count()
}
func (a *adminRules) RuleChanges() admin.RuleChangeSet {
return a.state.Changes()
}
// RuleReset clears deltas for one category — or all of them when category
// is empty — and rebuilds the runtime rule sets from the boot baseline.
func (a *adminRules) RuleReset(category admin.Category) error {
a.mutationMu.Lock()
defer a.mutationMu.Unlock()
cats := []admin.Category{category}
if category == "" {
cats = []admin.Category{admin.CategoryBlock, admin.CategoryDirect, admin.CategoryProxy}
}
if err := a.state.RuleReset(cats...); err != nil {
return fmt.Errorf("persist rule reset: %w", err)
}
for _, cat := range cats {
rs, err := a.rules(cat)
if err != nil {
return err
}
rs.Replace(a.effectiveRules(cat)...)
}
a.invalidateHits(category)
return nil
}
// RuleMiss implements admin.RuleMissProvider, forwarding to the rule-miss
// tracker. byCount orders by connection count, otherwise by recency.
func (a *adminRules) RuleMiss(byCount bool, limit int) []admin.RuleHit {
if a.missHits == nil {
return nil
}
if byCount {
return a.missHits.Top(limit)
}
return a.missHits.Recent(limit)
}
// effectiveRules computes the runtime rule list for a category: baseline
// minus tombstones, plus admin additions.
func (a *adminRules) effectiveRules(category admin.Category) []string {
d := a.state.Delta(category)
tombstoned := make(map[string]struct{}, len(d.Remove))
for _, rule := range d.Remove {
tombstoned[rule] = struct{}{}
}
out := make([]string, 0, len(a.baseline[category])+len(d.Add))
for _, rule := range a.baseline[category] {
if _, ok := tombstoned[rule]; !ok {
out = append(out, rule)
}
}
return append(out, d.Add...)
}
func (a *adminRules) rules(category admin.Category) (*router.RuleSet, error) {
switch category {
case admin.CategoryBlock:
return a.r.BlockRule, nil
case admin.CategoryDirect:
return a.r.DirectRule, nil
case admin.CategoryProxy:
return a.r.ProxyRule, nil
default:
return nil, fmt.Errorf("unknown rule category %q", category)
}
}
// TestDomain reports which rule sets match the domain and the route a
// connection to it would take. It mirrors DialSmart's rule priority
// (block > direct > proxy); when no rule matches it reports "auto" without
// performing live detection.
func (a *adminRules) TestDomain(domain string) (admin.DomainTest, error) {
domain = strings.ToLower(strings.TrimSpace(strings.TrimSuffix(domain, ".")))
if domain == "" {
return admin.DomainTest{}, fmt.Errorf("domain is required")
}
blockRule, blockOK := a.r.BlockRule.MatchRule(domain)
directRule, directOK := a.r.DirectRule.MatchRule(domain)
proxyRule, proxyOK := a.r.ProxyRule.MatchRule(domain)
res := admin.DomainTest{
Domain: domain,
Matches: []admin.CategoryTest{
{Category: admin.CategoryBlock, Matched: blockOK, Rule: blockRule},
{Category: admin.CategoryDirect, Matched: directOK, Rule: directRule},
{Category: admin.CategoryProxy, Matched: proxyOK, Rule: proxyRule},
},
}
switch {
case blockOK:
res.Route = "block"
case directOK:
res.Route = "direct"
case proxyOK:
res.Route = "proxy"
default:
res.Route = "auto"
res.Note = "未命中任何规则,将按自动检测(本地站点/可直连)或默认代理路由"
}
return res, nil
}
// dnsStatsHandler counts DNS queries before delegating to the router.
type dnsStatsHandler struct {
dns.Handler
stats *admin.Stats
}
func (h dnsStatsHandler) ServeDNS(w dns.ResponseWriter, req *dns.Msg) {
if len(req.Question) == 1 {
h.stats.RecordDNS(req.Question[0].Name, router.ClientIPOf(req, w.RemoteAddr()))
}
h.Handler.ServeDNS(w, req)
}
func startAdminListener(ctx context.Context, wg *sync.WaitGroup, cfg config.SowerConfig, rules admin.RuleManager, configMgr admin.ConfigManager, stats *admin.Stats, errCh chan<- error, restartCh chan<- struct{}) error {
if cfg.Admin.Disable || cfg.Admin.Addr == "" {
return nil
}
password, temporary := resolveAdminPassword(cfg.Admin.Password.Value())
srv := newAdminServer(cfg, password, temporary, rules, configMgr, stats, restartCh)
ln, err := net.Listen("tcp", cfg.Admin.Addr)
if err != nil {
return fmt.Errorf("listen admin on %s: %w", cfg.Admin.Addr, err)
}
slog.Info("service listening", "service", "admin", "network", "tcp", "addr", cfg.Admin.Addr)
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), adminShutdownTimeout)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
slog.Warn("shutdown admin server", "error", err)
}
}()
wg.Add(1)
go closeOnDone(ctx, wg, ln)
go serveAndReport(errCh, "admin", func() error {
return srv.Serve(ln)
})
return nil
}
// startSharedHTTPListener serves the admin console and the HTTP proxy from
// one listener on the DNS HTTP address. It is used when admin.addr exactly
// matches dns.serve:80.
func startSharedHTTPListener(ctx context.Context, wg *sync.WaitGroup, cfg config.SowerConfig, r *router.Router, rules admin.RuleManager, configMgr admin.ConfigManager, stats *admin.Stats, errCh chan<- error, restartCh chan<- struct{}) error {
addr, ok := sharedAdminHTTPAddr(cfg)
if !ok {
return nil
}
ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("listen shared http on %s: %w", addr, err)
}
slog.Info("service listening", "service", "http proxy + admin", "network", "tcp", "addr", addr)
password, temporary := resolveAdminPassword(cfg.Admin.Password.Value())
srv := newAdminServer(cfg, password, temporary, rules, configMgr, stats, restartCh)
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), adminShutdownTimeout)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
slog.Warn("shutdown admin server", "error", err)
}
}()
wg.Add(1)
go closeOnDone(ctx, wg, ln)
go serveAndReport(errCh, "http proxy + admin", func() error {
return ServeSharedHTTP(ctx, ln, r, stats, srv, cfg.DNS.Serve, errCh)
})
return nil
}
// restartFn returns the admin restart callback: it coalesces requests into
// the buffered channel and returns immediately so the HTTP response is
// delivered before the process replaces itself. On platforms without
// in-place restart support it returns an error so the endpoint reports 500
// instead of acknowledging a restart that would fail.
func restartFn(restartCh chan<- struct{}) func() error {
return func() error {
if !restartSupported() {
return errors.New("process restart is unsupported on this platform")
}
select {
case restartCh <- struct{}{}:
default: // restart already pending
}
return nil
}
}
+518
View File
@@ -0,0 +1,518 @@
package main
import (
"log/slog"
"os"
"path/filepath"
"slices"
"sync"
"testing"
"time"
"github.com/sower-proxy/deferlog/v2"
"github.com/sower-proxy/sower/config"
"github.com/sower-proxy/sower/internal/admin"
"github.com/sower-proxy/sower/router"
)
// bootAdapter simulates one process lifetime: a fresh router, baseline
// snapshot, state deltas applied, and the admin adapter on top.
func bootAdapter(t *testing.T, statePath string) (*adminRules, *admin.StateStore) {
t.Helper()
r := newTestRouter()
state := admin.LoadStateStore(statePath)
baseline := snapshotBaseline(r)
state.SetBaseline(baseline)
applyRuleDeltas(r, state)
return newAdminRules(r, state, baseline, newRuleHitTracker(r.BlockRule, maxRuleHits), newRuleHitTracker(r.DirectRule, maxRuleHitsWide), newRuleHitTracker(r.ProxyRule, maxRuleHitsWide), newRuleMissTracker()), state
}
func TestRuleSearchSortsAndStats(t *testing.T) {
t.Parallel()
a, _ := bootAdapter(t, filepath.Join(t.TempDir(), "admin-state.json"))
for _, rule := range []string{"b.com", "a.com", "c.com"} {
if err := a.RuleAdd(admin.CategoryBlock, rule); err != nil {
t.Fatal(err)
}
}
// Hit three of the four block rules with distinct counts and times;
// c.com stays untouched to exercise the no-stat partition.
a.blockHits.OnHit("example.com")
time.Sleep(2 * time.Millisecond)
for range 3 {
a.blockHits.OnHit("b.com")
}
time.Sleep(2 * time.Millisecond)
for range 2 {
a.blockHits.OnHit("a.com")
}
names := func(entries []admin.RuleEntry) []string {
out := make([]string, len(entries))
for i, e := range entries {
out[i] = e.Rule
}
return out
}
search := func(sortBy admin.RuleSort, dir admin.SortDir) []admin.RuleEntry {
t.Helper()
entries, total, err := a.RuleSearch(admin.CategoryBlock, "", 0, 100, sortBy, dir)
if err != nil {
t.Fatal(err)
}
if total != 4 {
t.Fatalf("expected total 4, got %d", total)
}
return entries
}
// Default file order carries stats on every row.
entries := search(admin.RuleSortDefault, admin.SortDirDesc)
if got := names(entries); !slices.Equal(got, []string{"example.com", "b.com", "a.com", "c.com"}) {
t.Fatalf("default order: %v", got)
}
if entries[0].Count != 1 || entries[0].LastSeen == nil {
t.Fatalf("default row missing stats: %+v", entries[0])
}
if entries[3].Count != 0 || entries[3].LastSeen != nil {
t.Fatalf("untouched rule should have no stats: %+v", entries[3])
}
if got := names(search(admin.RuleSortRule, admin.SortDirAsc)); !slices.Equal(got, []string{"a.com", "b.com", "c.com", "example.com"}) {
t.Fatalf("rule asc: %v", got)
}
if got := names(search(admin.RuleSortRule, admin.SortDirDesc)); !slices.Equal(got, []string{"example.com", "c.com", "b.com", "a.com"}) {
t.Fatalf("rule desc: %v", got)
}
if got := names(search(admin.RuleSortHits, admin.SortDirDesc)); !slices.Equal(got, []string{"b.com", "a.com", "example.com", "c.com"}) {
t.Fatalf("hits desc: %v", got)
}
if got := names(search(admin.RuleSortHits, admin.SortDirAsc)); !slices.Equal(got, []string{"c.com", "example.com", "a.com", "b.com"}) {
t.Fatalf("hits asc: %v", got)
}
if got := names(search(admin.RuleSortLastSeen, admin.SortDirDesc)); !slices.Equal(got, []string{"a.com", "b.com", "example.com", "c.com"}) {
t.Fatalf("last_seen desc: %v", got)
}
}
func TestRuleSearchDirectProxyStats(t *testing.T) {
t.Parallel()
a, _ := bootAdapter(t, filepath.Join(t.TempDir(), "admin-state.json"))
for _, rule := range []string{"b.example", "a.example"} {
if err := a.RuleAdd(admin.CategoryDirect, rule); err != nil {
t.Fatal(err)
}
}
if err := a.RuleAdd(admin.CategoryProxy, "p.example"); err != nil {
t.Fatal(err)
}
// Direct hits land in the direct tracker only; proxy stays untouched.
a.directHits.OnHit("b.example")
a.directHits.OnHit("b.example")
a.directHits.OnHit("a.example")
names := func(entries []admin.RuleEntry) []string {
out := make([]string, len(entries))
for i, e := range entries {
out[i] = e.Rule
}
return out
}
entries, total, err := a.RuleSearch(admin.CategoryDirect, "", 0, 100, admin.RuleSortDefault, admin.SortDirDesc)
if err != nil {
t.Fatal(err)
}
if total != 2 {
t.Fatalf("expected total 2, got %d", total)
}
if entries[0].Rule != "b.example" || entries[0].Count != 2 || entries[0].LastSeen == nil {
t.Fatalf("direct row missing stats: %+v", entries[0])
}
if entries[1].Rule != "a.example" || entries[1].Count != 1 || entries[1].LastSeen == nil {
t.Fatalf("direct row missing stats: %+v", entries[1])
}
// Hits sort works for direct; zero-hit rules partition to the bottom.
hits, _, err := a.RuleSearch(admin.CategoryDirect, "", 0, 100, admin.RuleSortHits, admin.SortDirDesc)
if err != nil {
t.Fatal(err)
}
if got := names(hits); !slices.Equal(got, []string{"b.example", "a.example"}) {
t.Fatalf("direct hits desc: %v", got)
}
// Proxy rows carry zero stats and sort by rule text.
proxy, _, err := a.RuleSearch(admin.CategoryProxy, "", 0, 100, admin.RuleSortRule, admin.SortDirAsc)
if err != nil {
t.Fatal(err)
}
if len(proxy) != 1 || proxy[0].Rule != "p.example" || proxy[0].Count != 0 || proxy[0].LastSeen != nil {
t.Fatalf("proxy row should have no stats: %+v", proxy)
}
}
func TestAdminRulesSurviveRestart(t *testing.T) {
t.Parallel()
statePath := filepath.Join(t.TempDir(), "admin-state.json")
// First lifetime: add a proxy rule, tombstone the baseline block rule.
a, _ := bootAdapter(t, statePath)
if err := a.RuleAdd(admin.CategoryProxy, "**.example.com"); err != nil {
t.Fatal(err)
}
found, err := a.RuleRemove(admin.CategoryBlock, "example.com")
if err != nil || !found {
t.Fatalf("tombstone baseline rule: found=%v err=%v", found, err)
}
// Second lifetime: the state file replays both changes.
a2, _ := bootAdapter(t, statePath)
if got := a2.r.ProxyRule.Count(); got != 1 {
t.Fatalf("expected 1 proxy rule after restart, got %d", got)
}
if got := a2.r.BlockRule.Count(); got != 0 {
t.Fatalf("expected block rule to stay removed after restart, got %d", got)
}
changes := a2.RuleChanges()
if d := changes.Rules[admin.CategoryProxy]; len(d.Add) != 1 || d.Add[0] != "**.example.com" {
t.Fatalf("unexpected proxy delta: %+v", d)
}
if d := changes.Rules[admin.CategoryBlock]; len(d.Remove) != 1 || d.Remove[0] != "example.com" {
t.Fatalf("unexpected block delta: %+v", d)
}
}
func TestAdminRulesResetRestoresBaseline(t *testing.T) {
t.Parallel()
statePath := filepath.Join(t.TempDir(), "admin-state.json")
a, _ := bootAdapter(t, statePath)
if err := a.RuleAdd(admin.CategoryProxy, "**.example.com"); err != nil {
t.Fatal(err)
}
if _, err := a.RuleRemove(admin.CategoryBlock, "example.com"); err != nil {
t.Fatal(err)
}
if err := a.RuleReset(""); err != nil {
t.Fatal(err)
}
if got := a.r.BlockRule.Count(); got != 1 {
t.Fatalf("expected baseline block rule restored, got %d", got)
}
if got := a.r.ProxyRule.Count(); got != 0 {
t.Fatalf("expected admin-added proxy rule dropped, got %d", got)
}
changes := a.RuleChanges()
for cat, d := range changes.Rules {
if len(d.Add) != 0 || len(d.Remove) != 0 {
t.Fatalf("expected empty delta for %s after reset, got %+v", cat, d)
}
}
}
func TestApplyConfigOverrides(t *testing.T) {
strPtr := func(s string) *string { return &s }
cfg := config.SowerConfig{LogLevel: slog.LevelInfo}
cfg.DNS.Upstream = "8.8.8.8"
cfg.DNS.Fallback = "223.5.5.5"
applyConfigOverrides(&cfg, admin.ConfigOverrides{
LogLevel: strPtr("debug"),
DNSUpstream: strPtr("1.1.1.1"),
DNSFallback: strPtr("9.9.9.9"),
})
if cfg.LogLevel != slog.LevelDebug {
t.Fatalf("log level not overridden: %v", cfg.LogLevel)
}
if cfg.DNS.Upstream != "1.1.1.1" || cfg.DNS.Fallback != "9.9.9.9" {
t.Fatalf("dns not overridden: %v %v", cfg.DNS.Upstream, cfg.DNS.Fallback)
}
if logLevel.Level() != slog.LevelDebug {
t.Fatalf("runtime log level not applied: %v", logLevel.Level())
}
// Invalid values are dropped, keeping the previous config.
applyConfigOverrides(&cfg, admin.ConfigOverrides{
LogLevel: strPtr("bogus"),
DNSUpstream: strPtr("not-an-ip"),
})
if cfg.LogLevel != slog.LevelDebug {
t.Fatalf("invalid log level must be ignored: %v", cfg.LogLevel)
}
if cfg.DNS.Upstream != "1.1.1.1" {
t.Fatalf("invalid dns upstream must be ignored: %v", cfg.DNS.Upstream)
}
}
func TestAdminConfigViewAndApply(t *testing.T) {
logLevel.Set(slog.LevelInfo)
base := config.SowerConfig{LogLevel: slog.LevelInfo}
base.Remote.Type = "sower"
base.Remote.Addr = "proxy.example.com"
base.Remote.Password = "supersecret"
base.DNS.Serve = "127.0.0.1"
base.DNS.Upstream = "8.8.8.8"
base.DNS.Fallback = "223.5.5.5"
base.Socks5.Addr = "127.0.0.1:1080"
base.Admin.Addr = "127.0.0.1:19090"
base.Admin.Password = deferlog.NewPassword("adminsecret")
base.Admin.StateFile = "/etc/sower/admin-state.json"
state := admin.LoadStateStore("")
ac := newAdminConfig(base, state, newTestRouter())
// The view renders all sections and never exposes secret values.
view := ac.ConfigView()
if len(view.Sections) != 5 {
t.Fatalf("expected 5 sections, got %d", len(view.Sections))
}
for _, sec := range view.Sections {
for _, f := range sec.Fields {
if f.Value == "supersecret" || f.Value == "adminsecret" {
t.Fatalf("secret value exposed for %s", f.Key)
}
if f.Key == "remote.password" && (!f.Secret || !f.Configured) {
t.Fatalf("remote.password must be secret+configured: %+v", f)
}
}
}
// The log level field carries enum metadata for the console's select.
var logField *admin.ConfigField
for _, sec := range view.Sections {
for i := range sec.Fields {
if sec.Fields[i].Key == "log_level" {
logField = &sec.Fields[i]
}
}
}
if logField == nil || logField.Type != "enum" || len(logField.Options) != 4 {
t.Fatalf("log_level must be an enum with 4 options: %+v", logField)
}
// Boolean fields carry type metadata so the console renders 是/否 badges.
for _, sec := range view.Sections {
for _, f := range sec.Fields {
if f.Key == "admin.cookie_secure" && f.Type != "bool" {
t.Fatalf("admin.cookie_secure must be typed bool: %+v", f)
}
}
}
// Apply a log level and DNS change at the correct revision.
debug := "debug"
upstream := "1.1.1.1"
view, err := ac.ApplyConfigChanges(admin.ConfigChanges{LogLevel: &debug, DNSUpstream: &upstream}, 0)
if err != nil {
t.Fatal(err)
}
if view.Revision != 1 {
t.Fatalf("expected revision 1, got %d", view.Revision)
}
if logLevel.Level() != slog.LevelDebug {
t.Fatalf("log level not applied: %v", logLevel.Level())
}
if ac.effective.DNS.Upstream != "1.1.1.1" {
t.Fatalf("dns upstream not applied: %v", ac.effective.DNS.Upstream)
}
// Stale revision is rejected.
if _, err := ac.ApplyConfigChanges(admin.ConfigChanges{LogLevel: &debug}, 0); err == nil {
t.Fatal("expected revision mismatch error")
}
// Clearing the upstream override reverts to the base config.
empty := ""
if _, err := ac.ApplyConfigChanges(admin.ConfigChanges{DNSUpstream: &empty}, 1); err != nil {
t.Fatal(err)
}
if ac.effective.DNS.Upstream != "8.8.8.8" {
t.Fatalf("clearing override must revert to base, got %v", ac.effective.DNS.Upstream)
}
// Clearing a log-level override returns the handler to the base config.
if _, err := ac.ApplyConfigChanges(admin.ConfigChanges{LogLevel: &empty}, 2); err != nil {
t.Fatal(err)
}
if logLevel.Level() != slog.LevelInfo {
t.Fatalf("clearing log level must restore base level, got %v", logLevel.Level())
}
}
func TestAdminRulesRestoresBaselineOrder(t *testing.T) {
statePath := filepath.Join(t.TempDir(), "admin-state.json")
r := newTestRouter()
r.ProxyRule = router.NewRuleSet("**.example.com", "api.example.com")
state := admin.LoadStateStore(statePath)
baseline := snapshotBaseline(r)
state.SetBaseline(baseline)
rules := newAdminRules(r, state, baseline, newRuleHitTracker(r.BlockRule, maxRuleHits), newRuleHitTracker(r.DirectRule, maxRuleHitsWide), newRuleHitTracker(r.ProxyRule, maxRuleHitsWide), newRuleMissTracker())
if _, err := rules.RuleRemove(admin.CategoryProxy, "**.example.com"); err != nil {
t.Fatal(err)
}
if err := rules.RuleAdd(admin.CategoryProxy, "**.example.com"); err != nil {
t.Fatal(err)
}
matched, ok := r.ProxyRule.MatchRule("api.example.com")
if !ok || matched != "**.example.com" {
t.Fatalf("restored baseline order changed MatchRule: matched=%q ok=%v", matched, ok)
}
}
func TestAdminRulesBatchRemovePersistFailureLeavesRuntimeUntouched(t *testing.T) {
blocker := filepath.Join(t.TempDir(), "blocker")
if err := os.WriteFile(blocker, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
r := newTestRouter()
r.ProxyRule = router.NewRuleSet("one.example", "two.example")
state := admin.LoadStateStore(filepath.Join(blocker, "admin-state.json"))
baseline := snapshotBaseline(r)
state.SetBaseline(baseline)
rules := newAdminRules(r, state, baseline, newRuleHitTracker(r.BlockRule, maxRuleHits), newRuleHitTracker(r.DirectRule, maxRuleHitsWide), newRuleHitTracker(r.ProxyRule, maxRuleHitsWide), newRuleMissTracker())
removed, err := rules.RuleRemoveMany(admin.CategoryProxy, "one.example", "two.example")
if err == nil {
t.Fatal("expected persistence failure")
}
if removed != nil {
t.Fatalf("runtime removals must be nil on persist failure: %v", removed)
}
if got := r.ProxyRule.List(); len(got) != 2 || got[0] != "one.example" || got[1] != "two.example" {
t.Fatalf("runtime changed despite persist failure: %v", got)
}
if delta := state.Delta(admin.CategoryProxy); len(delta.Add) != 0 || len(delta.Remove) != 0 {
t.Fatalf("state changed despite persist failure: %+v", delta)
}
}
func TestAdminRulesBatchRemoveRebuildsOnceInOrder(t *testing.T) {
statePath := filepath.Join(t.TempDir(), "admin-state.json")
r := newTestRouter()
r.ProxyRule = router.NewRuleSet("one.example", "two.example", "three.example")
state := admin.LoadStateStore(statePath)
baseline := snapshotBaseline(r)
state.SetBaseline(baseline)
rules := newAdminRules(r, state, baseline, newRuleHitTracker(r.BlockRule, maxRuleHits), newRuleHitTracker(r.DirectRule, maxRuleHitsWide), newRuleHitTracker(r.ProxyRule, maxRuleHitsWide), newRuleMissTracker())
if err := rules.RuleAdd(admin.CategoryProxy, "four.example"); err != nil {
t.Fatal(err)
}
removed, err := rules.RuleRemoveMany(admin.CategoryProxy, "two.example", "four.example")
if err != nil || len(removed) != 2 {
t.Fatalf("batch remove: removed=%v err=%v", removed, err)
}
if got := r.ProxyRule.List(); len(got) != 2 || got[0] != "one.example" || got[1] != "three.example" {
t.Fatalf("batch rebuild did not preserve remaining order: %v", got)
}
}
func TestAdminRulesConcurrentMutationsRemainConsistent(t *testing.T) {
statePath := filepath.Join(t.TempDir(), "admin-state.json")
r := newTestRouter()
r.ProxyRule = router.NewRuleSet("one.example", "two.example")
state := admin.LoadStateStore(statePath)
baseline := snapshotBaseline(r)
state.SetBaseline(baseline)
rules := newAdminRules(r, state, baseline, newRuleHitTracker(r.BlockRule, maxRuleHits), newRuleHitTracker(r.DirectRule, maxRuleHitsWide), newRuleHitTracker(r.ProxyRule, maxRuleHitsWide), newRuleMissTracker())
var wg sync.WaitGroup
errs := make(chan error, 48)
for i := 0; i < 48; i++ {
wg.Add(1)
go runAdminRulesMutation(i, rules, errs, &wg)
}
wg.Wait()
close(errs)
for err := range errs {
t.Error(err)
}
want := rules.effectiveRules(admin.CategoryProxy)
if got := r.ProxyRule.List(); !slices.Equal(got, want) {
t.Fatalf("runtime rules diverged from state: got=%v want=%v", got, want)
}
// A new process must reconstruct the same effective order from disk.
r2 := newTestRouter()
r2.ProxyRule = router.NewRuleSet("one.example", "two.example")
state2 := admin.LoadStateStore(statePath)
baseline2 := snapshotBaseline(r2)
state2.SetBaseline(baseline2)
applyRuleDeltas(r2, state2)
if got := r2.ProxyRule.List(); !slices.Equal(got, want) {
t.Fatalf("reloaded rules diverged from state: got=%v want=%v", got, want)
}
}
func runAdminRulesMutation(i int, rules *adminRules, errs chan<- error, wg *sync.WaitGroup) {
defer wg.Done()
var err error
switch i % 3 {
case 0:
err = rules.RuleAdd(admin.CategoryProxy, "custom.example")
case 1:
_, err = rules.RuleRemoveMany(admin.CategoryProxy, "custom.example", "one.example")
default:
err = rules.RuleReset(admin.CategoryProxy)
}
if err != nil {
errs <- err
}
}
func TestResolveAdminPassword(t *testing.T) {
t.Parallel()
if pw, temp := resolveAdminPassword("secret"); pw != "secret" || temp {
t.Fatalf("configured password: got (%q, %v), want (secret, false)", pw, temp)
}
pw, temp := resolveAdminPassword("")
if pw == "" || !temp {
t.Fatalf("empty password: got (%q, %v), want (non-empty, true)", pw, temp)
}
if pw2, _ := resolveAdminPassword(""); pw2 == pw {
t.Fatal("temporary password must differ across startups")
}
}
func TestRestartFn(t *testing.T) {
ch := make(chan struct{}, 1)
fn := restartFn(ch)
if !restartSupported() {
if err := fn(); err == nil {
t.Fatal("expected error on unsupported platform")
}
select {
case <-ch:
t.Fatal("unsupported platform must not enqueue a restart")
default:
}
return
}
// Supported: multiple calls coalesce into a single enqueue.
for range 3 {
if err := fn(); err != nil {
t.Fatalf("restartFn: %v", err)
}
}
select {
case <-ch:
default:
t.Fatal("expected one restart request")
}
select {
case <-ch:
t.Fatal("expected restart requests to coalesce")
default:
}
}
+264
View File
@@ -0,0 +1,264 @@
package main
import (
"log/slog"
"slices"
"strconv"
"strings"
"sync"
"github.com/sower-proxy/sower/config"
"github.com/sower-proxy/sower/internal/admin"
"github.com/sower-proxy/sower/router"
)
// adminConfig adapts the effective sower configuration to the admin
// ConfigManager interface. It keeps the pre-override file/flag config so a
// cleared override can revert the runtime to it, and applies whitelisted
// changes to the log level and the router's DNS upstreams immediately;
// every other whitelisted field takes effect on the next restart.
type adminConfig struct {
mu sync.Mutex // guards effective
base config.SowerConfig
effective config.SowerConfig
state *admin.StateStore
router *router.Router
}
// newAdminConfig builds the adapter. base must be the configuration before
// admin-state overrides were applied.
func newAdminConfig(base config.SowerConfig, state *admin.StateStore, r *router.Router) *adminConfig {
effective := base
applyConfigOverrides(&effective, state.ConfigOverrides())
return &adminConfig{base: base, effective: effective, state: state, router: r}
}
// ApplyConfigChanges persists the overrides first, then applies them to the
// runtime. A persist failure leaves the runtime untouched.
func (ac *adminConfig) ApplyConfigChanges(changes admin.ConfigChanges, revision uint64) (admin.ConfigView, error) {
ac.mu.Lock()
defer ac.mu.Unlock()
overrides := ac.state.ConfigOverrides()
// Merge semantics: only fields present in changes are touched; absent
// fields keep their previous override. Within a present field, an empty
// value clears the override so the file/flag configuration takes over
// again, and a non-empty value sets the override.
applyStr := func(dst **string, v *string) {
if v == nil {
return
}
if *v == "" {
*dst = nil
return
}
s := *v
*dst = &s
}
applyList := func(dst **[]string, v *[]string) {
if v == nil {
return
}
if len(*v) == 0 {
*dst = nil
return
}
c := slices.Clone(*v)
*dst = &c
}
if changes.LogLevel != nil {
lv := strings.ToLower(*changes.LogLevel)
applyStr(&overrides.LogLevel, &lv)
}
applyStr(&overrides.DNSUpstream, changes.DNSUpstream)
applyStr(&overrides.DNSFallback, changes.DNSFallback)
applyStr(&overrides.DNSReverse, changes.DNSReverse)
applyStr(&overrides.RemoteType, changes.RemoteType)
applyStr(&overrides.RemoteAddr, changes.RemoteAddr)
applyStr(&overrides.RemoteTLSServerName, changes.RemoteTLSServerName)
applyStr(&overrides.RemoteTLSClientHello, changes.RemoteTLSClientHello)
applyStr(&overrides.RemoteTLSInsecureSkipVerify, changes.RemoteTLSInsecureSkipVerify)
applyStr(&overrides.DNSServe, changes.DNSServe)
applyStr(&overrides.DNSServe6, changes.DNSServe6)
applyStr(&overrides.Socks5Addr, changes.Socks5Addr)
applyStr(&overrides.AdminSessionFile, changes.AdminSessionFile)
applyStr(&overrides.AdminDisableSessionPersistence, changes.AdminDisableSessionPersistence)
applyStr(&overrides.AdminCookieSecure, changes.AdminCookieSecure)
applyStr(&overrides.AdminStateFile, changes.AdminStateFile)
applyStr(&overrides.RouterBlockFile, changes.RouterBlockFile)
applyStr(&overrides.RouterBlockFilePrefix, changes.RouterBlockFilePrefix)
applyList(&overrides.RouterBlockFileSkipRules, changes.RouterBlockFileSkipRules)
applyList(&overrides.RouterBlockRules, changes.RouterBlockRules)
applyStr(&overrides.RouterDirectFile, changes.RouterDirectFile)
applyStr(&overrides.RouterDirectFilePrefix, changes.RouterDirectFilePrefix)
applyList(&overrides.RouterDirectFileSkipRules, changes.RouterDirectFileSkipRules)
applyList(&overrides.RouterDirectRules, changes.RouterDirectRules)
applyStr(&overrides.RouterProxyFile, changes.RouterProxyFile)
applyStr(&overrides.RouterProxyFilePrefix, changes.RouterProxyFilePrefix)
applyList(&overrides.RouterProxyFileSkipRules, changes.RouterProxyFileSkipRules)
applyList(&overrides.RouterProxyRules, changes.RouterProxyRules)
applyStr(&overrides.RouterCountryMMDB, changes.RouterCountryMMDB)
applyStr(&overrides.RouterCountryFile, changes.RouterCountryFile)
applyList(&overrides.RouterCountryRules, changes.RouterCountryRules)
newRevision, err := ac.state.ApplyConfig(overrides, revision)
if err != nil {
return admin.ConfigView{}, err
}
ac.effective = ac.base
applyConfigOverrides(&ac.effective, overrides)
// Immediate-effect fields apply to the running process; everything else
// takes effect on the next restart.
if changes.DNSUpstream != nil || changes.DNSFallback != nil {
ac.router.SetDNS(ac.effective.DNS.Upstream, ac.effective.DNS.Fallback)
}
// Never log override values: the whitelisted fields are not secrets
// today, but the revision is all an operator needs to correlate.
slog.Info("applied admin config override", "revision", newRevision)
return ac.configViewLocked(), nil
}
func (ac *adminConfig) ConfigView() admin.ConfigView {
ac.mu.Lock()
defer ac.mu.Unlock()
return ac.configViewLocked()
}
func (ac *adminConfig) configViewLocked() admin.ConfigView {
cfg := ac.effective
overrides := ac.state.ConfigOverrides()
source := func(overridden bool) string {
if overridden {
return admin.SourceOverride
}
return admin.SourceConfig
}
return admin.ConfigView{
Revision: ac.state.Revision(),
Sections: []admin.ConfigSection{
{Name: "运行", Fields: []admin.ConfigField{
{Key: "version", Value: version, ApplyMode: admin.ApplyReadonly, Source: admin.SourceConfig},
{Key: "build_date", Value: date, ApplyMode: admin.ApplyReadonly, Source: admin.SourceConfig},
{Key: "log_level", Value: strings.ToLower(cfg.LogLevel.String()), Editable: true,
ApplyMode: admin.ApplyImmediate, Source: source(overrides.LogLevel != nil),
Constraint: "debug | info | warn | error",
Type: "enum", Options: []string{"debug", "info", "warn", "error"}},
}},
{Name: "远程代理", Fields: []admin.ConfigField{
{Key: "remote.type", Value: cfg.Remote.Type, Editable: true,
ApplyMode: admin.ApplyRestart, Source: source(overrides.RemoteType != nil),
Constraint: "sower | socks5"},
{Key: "remote.addr", Value: cfg.Remote.Addr, Editable: true,
ApplyMode: admin.ApplyRestart, Source: source(overrides.RemoteAddr != nil),
Constraint: "代理地址,如 proxy.com 或 proxy.com:443"},
{Key: "remote.password", ApplyMode: admin.ApplyReadonly, Source: admin.SourceConfig,
Secret: true, Configured: cfg.Remote.Password != ""},
{Key: "remote.tls.server_name", Value: cfg.Remote.TLS.ServerName, Editable: true,
ApplyMode: admin.ApplyRestart, Source: source(overrides.RemoteTLSServerName != nil),
Constraint: "覆盖上游 TLS SNI;留空使用地址"},
{Key: "remote.tls.client_hello", Value: cfg.Remote.TLS.ClientHello, Editable: true,
ApplyMode: admin.ApplyRestart, Source: source(overrides.RemoteTLSClientHello != nil),
Constraint: "chrome | firefox | ios | android | edge | safari | randomized | golang"},
{Key: "remote.tls.insecure_skip_verify", Value: strconv.FormatBool(cfg.Remote.TLS.InsecureSkipVerify),
Editable: true, ApplyMode: admin.ApplyRestart, Source: source(overrides.RemoteTLSInsecureSkipVerify != nil),
Type: "bool"},
}},
{Name: "DNS", Fields: []admin.ConfigField{
{Key: "dns.serve", Value: cfg.DNS.Serve, Editable: true,
ApplyMode: admin.ApplyRestart, Source: source(overrides.DNSServe != nil),
Constraint: "IPv4 监听地址;留空自动探测"},
{Key: "dns.serve6", Value: cfg.DNS.Serve6, Editable: true,
ApplyMode: admin.ApplyRestart, Source: source(overrides.DNSServe6 != nil),
Constraint: "IPv6 监听地址,如 ::1"},
{Key: "dns.upstream", Value: cfg.DNS.Upstream, Editable: true,
ApplyMode: admin.ApplyImmediate, Source: source(overrides.DNSUpstream != nil),
Constraint: "IPv4/IPv6 地址;清空恢复配置文件值"},
{Key: "dns.fallback", Value: cfg.DNS.Fallback, Editable: true,
ApplyMode: admin.ApplyImmediate, Source: source(overrides.DNSFallback != nil),
Constraint: "IPv4/IPv6 地址;清空恢复配置文件值"},
{Key: "dns.reverse", Value: cfg.DNS.Reverse, Editable: true,
ApplyMode: admin.ApplyRestart, Source: source(overrides.DNSReverse != nil),
Constraint: "客户端反查 DNS,如 127.0.0.1(dnsmasq);清空恢复配置文件值"},
}},
{Name: "监听", Fields: []admin.ConfigField{
{Key: "socks5.addr", Value: cfg.Socks5.Addr, Editable: true,
ApplyMode: admin.ApplyRestart, Source: source(overrides.Socks5Addr != nil),
Constraint: "SOCKS5 监听地址,如 127.0.0.1:1080"},
{Key: "admin.addr", Value: cfg.Admin.Addr, ApplyMode: admin.ApplyRestart, Source: admin.SourceConfig},
{Key: "admin.password", ApplyMode: admin.ApplyReadonly, Source: admin.SourceConfig,
Secret: true, Configured: cfg.Admin.Password.Value() != ""},
{Key: "admin.session_file", Value: cfg.Admin.SessionFile, Editable: true,
ApplyMode: admin.ApplyRestart, Source: source(overrides.AdminSessionFile != nil),
Constraint: "会话持久化文件路径;留空不持久化"},
{Key: "admin.disable_session_persistence", Value: strconv.FormatBool(cfg.Admin.DisableSessionPersistence),
Editable: true, ApplyMode: admin.ApplyRestart, Source: source(overrides.AdminDisableSessionPersistence != nil),
Type: "bool"},
{Key: "admin.cookie_secure", Value: strconv.FormatBool(cfg.Admin.CookieSecure),
Editable: true, ApplyMode: admin.ApplyRestart, Source: source(overrides.AdminCookieSecure != nil),
Type: "bool"},
{Key: "admin.state_file", Value: cfg.Admin.StateFile, ApplyMode: admin.ApplyReadonly, Source: admin.SourceConfig,
Constraint: "状态文件在启动时加载,运行期切换不生效"},
}},
{Name: "规则来源", Fields: ruleSourceFields(cfg, overrides)},
},
}
}
// ruleSourceFields renders the per-category rule file settings. List values
// carry the full inline lists (one entry per line) so the console can edit
// them; the display collapses them to counts.
func ruleSourceFields(cfg config.SowerConfig, o admin.ConfigOverrides) []admin.ConfigField {
source := func(overridden bool) string {
if overridden {
return admin.SourceOverride
}
return admin.SourceConfig
}
fields := make([]admin.ConfigField, 0, 3*4+3)
for _, cat := range []string{"block", "direct", "proxy"} {
var file, prefix string
var skip, inline []string
var fileO, prefixO *string
var skipO, inlineO *[]string
switch cat {
case "block":
file, prefix, skip, inline = cfg.Router.Block.File, cfg.Router.Block.FilePrefix, cfg.Router.Block.FileSkipRules, cfg.Router.Block.Rules
fileO, prefixO, skipO, inlineO = o.RouterBlockFile, o.RouterBlockFilePrefix, o.RouterBlockFileSkipRules, o.RouterBlockRules
case "direct":
file, prefix, skip, inline = cfg.Router.Direct.File, cfg.Router.Direct.FilePrefix, cfg.Router.Direct.FileSkipRules, cfg.Router.Direct.Rules
fileO, prefixO, skipO, inlineO = o.RouterDirectFile, o.RouterDirectFilePrefix, o.RouterDirectFileSkipRules, o.RouterDirectRules
case "proxy":
file, prefix, skip, inline = cfg.Router.Proxy.File, cfg.Router.Proxy.FilePrefix, cfg.Router.Proxy.FileSkipRules, cfg.Router.Proxy.Rules
fileO, prefixO, skipO, inlineO = o.RouterProxyFile, o.RouterProxyFilePrefix, o.RouterProxyFileSkipRules, o.RouterProxyRules
}
p := "router." + cat + "."
fields = append(fields,
admin.ConfigField{Key: p + "file", Value: file, Editable: true,
ApplyMode: admin.ApplyRestart, Source: source(fileO != nil),
Constraint: "规则文件路径或 URL;留空不加载"},
admin.ConfigField{Key: p + "file_prefix", Value: prefix, Editable: true,
ApplyMode: admin.ApplyRestart, Source: source(prefixO != nil),
Constraint: "文件规则前缀,如 **."},
admin.ConfigField{Key: p + "file_skip_rules", Value: strings.Join(skip, "\n"), Editable: true,
Type: "list", ApplyMode: admin.ApplyRestart, Source: source(skipO != nil),
Constraint: "跳过文件中的规则,每行一条"},
admin.ConfigField{Key: p + "rules", Value: strings.Join(inline, "\n"), Editable: true,
Type: "list", ApplyMode: admin.ApplyRestart, Source: source(inlineO != nil),
Constraint: "内联规则,每行一条"},
)
}
fields = append(fields,
admin.ConfigField{Key: "router.country.mmdb", Value: cfg.Router.Country.MMDB, Editable: true,
ApplyMode: admin.ApplyRestart, Source: source(o.RouterCountryMMDB != nil),
Constraint: "GeoIP MMDB 文件路径;留空不启用"},
admin.ConfigField{Key: "router.country.file", Value: cfg.Router.Country.File, Editable: true,
ApplyMode: admin.ApplyRestart, Source: source(o.RouterCountryFile != nil),
Constraint: "国家网段规则文件路径或 URL"},
admin.ConfigField{Key: "router.country.rules", Value: strings.Join(cfg.Router.Country.Rules, "\n"), Editable: true,
Type: "list", ApplyMode: admin.ApplyRestart, Source: source(o.RouterCountryRules != nil),
Constraint: "内联 CIDR,每行一条"},
)
return fields
}
+52
View File
@@ -0,0 +1,52 @@
package main
import (
"context"
"net"
"strings"
"time"
"github.com/miekg/dns"
)
// dnsHostnameResolver resolves client IPs to hostnames via PTR queries to a
// configured reverse DNS server. On OpenWrt deployments this is the local
// dnsmasq: it answers LAN DHCP lease names directly, routes tailnet names to
// MagicDNS, and forwards public reverse names upstream. A failure or empty
// answer yields an empty hostname and the console falls back to the raw IP.
type dnsHostnameResolver struct {
addr string
client *dns.Client
}
func newDNSHostnameResolver(reverseDNS string) *dnsHostnameResolver {
return &dnsHostnameResolver{
addr: net.JoinHostPort(reverseDNS, "53"),
client: &dns.Client{Net: "udp", Timeout: 2 * time.Second},
}
}
func (r *dnsHostnameResolver) Hostname(ctx context.Context, ip string) string {
// Transport addresses may carry an IPv6 zone (fe80::1%eth0); strip it
// before constructing the reverse name.
if i := strings.IndexByte(ip, '%'); i >= 0 {
ip = ip[:i]
}
arpa, err := dns.ReverseAddr(ip)
if err != nil {
return ""
}
msg := new(dns.Msg)
msg.SetQuestion(arpa, dns.TypePTR)
resp, _, err := r.client.ExchangeContext(ctx, msg, r.addr)
if err != nil || resp == nil {
return ""
}
for _, rr := range resp.Answer {
if ptr, ok := rr.(*dns.PTR); ok {
return strings.TrimSuffix(ptr.Ptr, ".")
}
}
return ""
}
+85
View File
@@ -0,0 +1,85 @@
package main
import (
"context"
"net"
"testing"
"time"
"github.com/miekg/dns"
)
// startPTRTestDNSServer serves PTR answers on 127.0.0.1:0 with the given
// mapping from reverse name to hostname.
func startPTRTestDNSServer(t *testing.T, ptr map[string]string) string {
t.Helper()
udpConn, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen udp: %v", err)
}
handler := dns.HandlerFunc(func(w dns.ResponseWriter, req *dns.Msg) {
m := new(dns.Msg)
m.SetReply(req)
name := req.Question[0].Name
if host, ok := ptr[name]; ok {
m.Answer = append(m.Answer, &dns.PTR{
Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypePTR, Class: dns.ClassINET, Ttl: 60},
Ptr: host,
})
}
_ = w.WriteMsg(m)
})
server := &dns.Server{PacketConn: udpConn, Handler: handler}
go func() { _ = server.ActivateAndServe() }()
t.Cleanup(func() {
_ = server.Shutdown()
_ = udpConn.Close()
})
return udpConn.LocalAddr().String()
}
func TestDNSHostnameResolver(t *testing.T) {
t.Parallel()
// Point the resolver at the test server by extracting its port; the
// resolver joins "53" itself, so build an address on a fixed base.
addr := startPTRTestDNSServer(t, map[string]string{
"2.1.168.192.in-addr.arpa.": "phone.lan.",
"1.1.168.192.in-addr.arpa.": "router.lan.",
})
// Build the resolver directly on the test server address so the PTR
// query lands on the fake upstream regardless of its port.
r := &dnsHostnameResolver{addr: addr, client: &dns.Client{Net: "udp", Timeout: 2 * time.Second}}
ctx := context.Background()
if got := r.Hostname(ctx, "192.168.1.2"); got != "phone.lan" {
t.Fatalf("reverse 192.168.1.2: got %q", got)
}
if got := r.Hostname(ctx, "192.168.1.1"); got != "router.lan" {
t.Fatalf("reverse 192.168.1.1: got %q", got)
}
// No PTR data: empty hostname.
if got := r.Hostname(ctx, "8.8.8.8"); got != "" {
t.Fatalf("reverse 8.8.8.8: got %q", got)
}
// Invalid IP: empty without querying.
if got := r.Hostname(ctx, "not-an-ip"); got != "" {
t.Fatalf("reverse invalid ip: got %q", got)
}
// IPv6 zone suffix is stripped before the reverse name is built.
if got := r.Hostname(ctx, "192.168.1.2%eth0"); got != "phone.lan" {
t.Fatalf("reverse with zone suffix: got %q", got)
}
// Canceled context: empty.
canceled, cancel := context.WithCancel(ctx)
cancel()
if got := r.Hostname(canceled, "192.168.1.2"); got != "" {
t.Fatalf("reverse with canceled ctx: got %q", got)
}
}
+755
View File
@@ -0,0 +1,755 @@
package main
import (
"bufio"
"compress/gzip"
"context"
"errors"
"fmt"
"io"
"log/slog"
"math"
"net"
"net/http"
"os"
"os/signal"
"runtime"
"runtime/debug"
"slices"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/cristalhq/aconfig"
"github.com/cristalhq/aconfig/aconfigtoml"
"github.com/lmittmann/tint"
"github.com/miekg/dns"
"github.com/sower-proxy/deferlog/v2"
"github.com/sower-proxy/sower/config"
"github.com/sower-proxy/sower/internal/admin"
"github.com/sower-proxy/sower/pkg/suffixtree"
"github.com/sower-proxy/sower/pkg/upstreamtls"
"github.com/sower-proxy/sower/router"
)
var (
version, date string
conf config.SowerConfig
// logLevel backs the tint handler so the admin console can adjust the
// log level at runtime; it defaults to INFO until the config is loaded.
logLevel slog.LevelVar
)
// defaultMemoryLimitMiB bounds the Go heap unless GOMEMLIMIT or
// SOWER_MEMORY_LIMIT_MB overrides it. It must stay well above the live rule
// heap: the full pexcn rule sets keep ~105MB alive, and a limit at that level
// pins the GC goal to the live heap, so every allocation triggers a full-heap
// mark (128MiB burned ~39% CPU on an otherwise idle gateway).
const defaultMemoryLimitMiB = 512
// maxMemoryLimitMiB caps the SOWER_MEMORY_LIMIT_MB override so the MiB
// shift cannot overflow int64.
const maxMemoryLimitMiB int64 = 1 << 20 // 1 TiB
// loadConfig loads the TOML config into a fresh SowerConfig. It is a
// separate function from init so the loader is reachable from tests.
func loadConfig() (config.SowerConfig, error) {
var cfg config.SowerConfig
err := aconfig.LoaderFor(&cfg, aconfig.Config{
AllowUnknownFields: false,
FileFlag: "c",
Files: []string{
"sower.toml",
"/etc/sower/sower.toml",
},
FileDecoders: map[string]aconfig.FileDecoder{
".toml": aconfigtoml.New(),
},
}).Load()
return cfg, err
}
// logConfigLoadError reports a config load failure without dumping the
// partially-populated config: Remote.Password is a plain string and must not
// reach the log, even when surrounding fields were already parsed before the
// loader hit the offending line.
func logConfigLoadError(err error, cfg config.SowerConfig) {
slog.Error("load config", "error", err, "remote_addr", cfg.Remote.Addr)
}
// memoryLimitMiB resolves the soft heap limit for this process.
//
// -1 => the soft limit is explicitly disabled (apply math.MaxInt64)
// 0 => leave the limit alone (an explicit GOMEMLIMIT is in charge)
// >0 => apply that many MiB
func memoryLimitMiB(getenv func(string) string) int64 {
if getenv("GOMEMLIMIT") != "" {
return 0
}
v := getenv("SOWER_MEMORY_LIMIT_MB")
if v == "" {
return defaultMemoryLimitMiB
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return defaultMemoryLimitMiB
}
switch {
case n == 0:
return -1
case n > 0 && n <= maxMemoryLimitMiB:
return n
default:
return defaultMemoryLimitMiB
}
}
func init() {
fi, _ := os.Stdout.Stat()
noColor := (fi.Mode() & os.ModeCharDevice) == 0
deferlog.SetDefault(slog.New(tint.NewHandler(os.Stdout,
&tint.Options{AddSource: true, NoColor: noColor, Level: &logLevel})))
if strings.HasSuffix(os.Args[0], ".test") {
return
}
var err error
conf, err = loadConfig()
if err != nil {
logConfigLoadError(err, conf)
os.Exit(1)
}
if err := conf.Validate(); err != nil {
slog.Error("validate config", "error", err)
os.Exit(1)
}
// Bound the Go heap with a soft memory limit so a mostly idle gateway
// returns memory to the OS instead of holding GOGC's 2x headroom.
//
// The limit must stay well above the live rule heap: the full pexcn rule
// sets keep ~105MB alive, and a limit at that level pins the GC goal to
// the live heap, so every allocation triggers a full-heap mark. 512MiB
// leaves the usual headroom while still capping resident memory.
//
// Precedence: an explicit standard GOMEMLIMIT wins; otherwise the
// default applies unless SOWER_MEMORY_LIMIT_MB overrides it (a positive
// MiB value, or 0 to disable the soft limit). Malformed or negative
// values leave the default in place.
switch limit := memoryLimitMiB(os.Getenv); {
case limit < 0:
debug.SetMemoryLimit(math.MaxInt64) // disable
case limit > 0:
debug.SetMemoryLimit(limit << 20)
}
logLevel.Set(conf.LogLevel)
slog.Info("starting sower",
"version", version,
"date", date,
"log_level", conf.LogLevel,
"remote_type", conf.Remote.Type,
"remote_addr", conf.Remote.Addr,
"remote_password", deferlog.Secret(conf.Remote.Password),
"remote_tls", conf.Remote.TLS,
"dns", conf.DNS,
"socks5", conf.Socks5,
"router", conf.Router)
}
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := run(ctx, stop, conf); err != nil {
slog.Error("run sower", "error", err)
os.Exit(1)
}
}
func run(ctx context.Context, stop context.CancelFunc, cfg config.SowerConfig) error {
stateStore := loadAdminState(cfg)
baseCfg := cfg
applyConfigOverrides(&cfg, stateStore.ConfigOverrides())
upstreamDNS := effectiveUpstreamDNS(cfg)
stats, err := admin.NewStats()
if err != nil {
return fmt.Errorf("init stats: %w", err)
}
proxyDial, err := GenProxyDial(cfg.Remote.Type, cfg.Remote.Addr, cfg.Remote.Password, upstreamDNS, upstreamtls.Options{
ServerName: cfg.Remote.TLS.ServerName,
ClientHello: cfg.Remote.TLS.ClientHello,
InsecureSkipVerify: cfg.Remote.TLS.InsecureSkipVerify,
}, stats)
if err != nil {
return fmt.Errorf("build proxy dialer: %w", err)
}
r, err := newRouter(cfg, proxyDial)
if err != nil {
return fmt.Errorf("build router: %w", err)
}
defer func() {
if err := r.Close(); err != nil {
slog.Warn("close router", "error", err)
}
}()
blockHits := newRuleHitTracker(r.BlockRule, maxRuleHits)
directHits := newRuleHitTracker(r.DirectRule, maxRuleHitsWide)
proxyHits := newRuleHitTracker(r.ProxyRule, maxRuleHitsWide)
missHits := newRuleMissTracker()
r.SetRouteObserver(func(c router.RouteCategory, domain string) {
stats.RecordRoute(string(c), domain)
})
r.SetRuleHitObserver(func(c router.RouteCategory, domain string) {
switch c {
case router.RouteBlock:
blockHits.OnHit(domain)
case router.RouteDirect:
directHits.OnHit(domain)
case router.RouteProxy:
proxyHits.OnHit(domain)
}
})
r.SetRuleMissObserver(func(domain string) {
missHits.OnMiss(domain)
})
start := time.Now()
if err := loadRouterRules(ctx, r, proxyDial, cfg); err != nil {
return err
}
baseline := snapshotBaseline(r)
stateStore.SetBaseline(baseline)
applyRuleDeltas(r, stateStore)
rulesMgr := newAdminRules(r, stateStore, baseline, blockHits, directHits, proxyHits, missHits)
configMgr := newAdminConfig(baseCfg, stateStore, r)
errCh := make(chan error, 8)
// restartCh coalesces restart requests from the admin API; the process
// replaces itself in place (same PID) so systemd stays unaware.
restartCh := make(chan struct{}, 1)
var wg sync.WaitGroup
if err := startDNSListeners(ctx, &wg, cfg, r, stats, errCh); err != nil {
return err
}
if err := startSocks5Listener(ctx, &wg, cfg, r, stats, errCh); err != nil {
return err
}
if _, shared := sharedAdminHTTPAddr(cfg); shared {
if err := startSharedHTTPListener(ctx, &wg, cfg, r, rulesMgr, configMgr, stats, errCh, restartCh); err != nil {
return err
}
} else if err := startAdminListener(ctx, &wg, cfg, rulesMgr, configMgr, stats, errCh, restartCh); err != nil {
return err
}
slog.Info("loaded rules, proxy started", "took", time.Since(start),
"blockRule", r.BlockRule.Count(), "directRule", r.DirectRule.Count(), "proxyRule", r.ProxyRule.Count())
runtime.GC()
select {
case <-ctx.Done():
slog.Info("shutting down sower", "reason", ctx.Err())
case <-restartCh:
slog.Info("restarting sower")
stop()
// Wait for every listener to close so the replacement process can
// bind the same addresses (exec keeps the old file descriptors).
// Bound the wait: a stuck close must not leave a half-alive process.
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(10 * time.Second):
slog.Warn("listener shutdown timed out, restarting anyway")
}
if err := restartCurrentProcess(); err != nil {
return fmt.Errorf("restart current process: %w", err)
}
case err := <-errCh:
slog.Error("serve failed", "error", err)
stop()
}
return nil
}
func effectiveUpstreamDNS(cfg config.SowerConfig) string {
if cfg.DNS.Upstream != "" {
return cfg.DNS.Upstream
}
return cfg.DNS.Fallback
}
// loadAdminState opens the admin state store. An empty state file path or
// the --ignore-admin-state escape hatch yields an in-memory store, so the
// rest of the code never has to nil-check persistence.
func loadAdminState(cfg config.SowerConfig) *admin.StateStore {
switch {
case cfg.Admin.StateFile == "":
return admin.LoadStateStore("")
case cfg.IgnoreAdminState:
slog.Warn("ignoring admin state file", "file", cfg.Admin.StateFile)
return admin.LoadStateStore("")
default:
return admin.LoadStateStore(cfg.Admin.StateFile)
}
}
// applyConfigOverrides applies the whitelisted admin-state config overrides
// to cfg before the router and dialer are built. Invalid values are dropped
// with a warning so a bad override never blocks startup; the
// --ignore-admin-state flag skips the file entirely.
func applyConfigOverrides(cfg *config.SowerConfig, o admin.ConfigOverrides) {
// An empty override (nil or empty value) is skipped so the file/flag
// configuration wins; a non-empty value overrides it. Clearing an
// override in the admin console therefore reverts to the file/flag
// configuration on the next restart.
applyStr := func(dst *string, v *string) {
if v != nil && *v != "" {
*dst = *v
}
}
applyList := func(dst *[]string, v *[]string) {
if v != nil && len(*v) > 0 {
*dst = slices.Clone(*v)
}
}
applyBool := func(dst *bool, v *string, field string) {
if v == nil || *v == "" {
return
}
b, err := strconv.ParseBool(*v)
if err != nil {
slog.Warn("ignore invalid admin state override", "field", field, "error", err)
return
}
*dst = b
}
if o.LogLevel != nil && *o.LogLevel != "" {
var lv slog.Level
if err := lv.UnmarshalText([]byte(strings.ToUpper(*o.LogLevel))); err != nil {
slog.Warn("ignore invalid admin state override", "field", "log_level", "error", err)
} else {
cfg.LogLevel = lv
}
}
if o.DNSUpstream != nil && *o.DNSUpstream != "" {
if net.ParseIP(*o.DNSUpstream) == nil {
slog.Warn("ignore invalid admin state override", "field", "dns_upstream")
} else {
cfg.DNS.Upstream = *o.DNSUpstream
}
}
if o.DNSFallback != nil && *o.DNSFallback != "" {
if net.ParseIP(*o.DNSFallback) == nil {
slog.Warn("ignore invalid admin state override", "field", "dns_fallback")
} else {
cfg.DNS.Fallback = *o.DNSFallback
}
}
if o.DNSReverse != nil && *o.DNSReverse != "" {
if net.ParseIP(*o.DNSReverse) == nil {
slog.Warn("ignore invalid admin state override", "field", "dns_reverse")
} else {
cfg.DNS.Reverse = *o.DNSReverse
}
}
applyStr(&cfg.Remote.Type, o.RemoteType)
applyStr(&cfg.Remote.Addr, o.RemoteAddr)
applyStr(&cfg.Remote.TLS.ServerName, o.RemoteTLSServerName)
applyStr(&cfg.Remote.TLS.ClientHello, o.RemoteTLSClientHello)
applyBool(&cfg.Remote.TLS.InsecureSkipVerify, o.RemoteTLSInsecureSkipVerify, "remote_tls_insecure_skip_verify")
applyStr(&cfg.DNS.Serve, o.DNSServe)
applyStr(&cfg.DNS.Serve6, o.DNSServe6)
applyStr(&cfg.Socks5.Addr, o.Socks5Addr)
applyStr(&cfg.Admin.SessionFile, o.AdminSessionFile)
applyBool(&cfg.Admin.DisableSessionPersistence, o.AdminDisableSessionPersistence, "admin_disable_session_persistence")
applyBool(&cfg.Admin.CookieSecure, o.AdminCookieSecure, "admin_cookie_secure")
applyStr(&cfg.Admin.StateFile, o.AdminStateFile)
applyStr(&cfg.Router.Block.File, o.RouterBlockFile)
applyStr(&cfg.Router.Block.FilePrefix, o.RouterBlockFilePrefix)
applyList(&cfg.Router.Block.FileSkipRules, o.RouterBlockFileSkipRules)
applyList(&cfg.Router.Block.Rules, o.RouterBlockRules)
applyStr(&cfg.Router.Direct.File, o.RouterDirectFile)
applyStr(&cfg.Router.Direct.FilePrefix, o.RouterDirectFilePrefix)
applyList(&cfg.Router.Direct.FileSkipRules, o.RouterDirectFileSkipRules)
applyList(&cfg.Router.Direct.Rules, o.RouterDirectRules)
applyStr(&cfg.Router.Proxy.File, o.RouterProxyFile)
applyStr(&cfg.Router.Proxy.FilePrefix, o.RouterProxyFilePrefix)
applyList(&cfg.Router.Proxy.FileSkipRules, o.RouterProxyFileSkipRules)
applyList(&cfg.Router.Proxy.Rules, o.RouterProxyRules)
applyStr(&cfg.Router.Country.MMDB, o.RouterCountryMMDB)
applyStr(&cfg.Router.Country.File, o.RouterCountryFile)
applyList(&cfg.Router.Country.Rules, o.RouterCountryRules)
logLevel.Set(cfg.LogLevel)
}
// applyRuleDeltas replays persisted admin rule changes onto the freshly
// loaded rule sets: tombstoned baseline rules leave, admin additions enter.
// The rule set is rebuilt via Replace because RuleSet.Remove rebuilds the
// suffix tree per call, which is expensive beyond a handful of tombstones.
func applyRuleDeltas(r *router.Router, state *admin.StateStore) {
sets := map[admin.Category]*router.RuleSet{
admin.CategoryBlock: r.BlockRule,
admin.CategoryDirect: r.DirectRule,
admin.CategoryProxy: r.ProxyRule,
}
for cat, rs := range sets {
d := state.Delta(cat)
if len(d.Add) == 0 && len(d.Remove) == 0 {
continue
}
tombstoned := make(map[string]struct{}, len(d.Remove))
for _, rule := range d.Remove {
tombstoned[rule] = struct{}{}
}
rules := rs.List()
effective := make([]string, 0, len(rules)+len(d.Add))
for _, rule := range rules {
if _, ok := tombstoned[rule]; !ok {
effective = append(effective, rule)
}
}
rs.Replace(append(effective, d.Add...)...)
slog.Info("applied admin rule deltas", "category", cat, "added", len(d.Add), "removed", len(d.Remove))
}
}
func newRouter(cfg config.SowerConfig, proxyDial router.ProxyDialFn) (*router.Router, error) {
r, err := router.NewRouter([]string{cfg.DNS.Serve, cfg.DNS.Serve6}, cfg.DNS.Upstream, cfg.DNS.Fallback, cfg.Router.Country.MMDB, proxyDial)
if err != nil {
return nil, err
}
r.BlockRule.Add(cfg.Router.Block.Rules...)
r.DirectRule.Add(cfg.Router.Direct.Rules...)
r.ProxyRule.Add(cfg.Router.Proxy.Rules...)
r.SetSelfProxyAddrs(selfProxyAddrs(cfg)...)
if err := r.AddCountryCIDRs(cfg.Router.Country.Rules...); err != nil {
_ = r.Close()
return nil, err
}
return r, nil
}
func loadRouterRules(ctx context.Context, r *router.Router, proxyDial router.ProxyDialFn, cfg config.SowerConfig) error {
if err := loadRule(ctx, r.BlockRule, proxyDial, cfg.Router.Block.File, cfg.Router.Block.FilePrefix, cfg.Router.Block.FileSkipRules); err != nil {
return fmt.Errorf("load block rules: %w", err)
}
if err := loadRule(ctx, r.DirectRule, proxyDial, cfg.Router.Direct.File, cfg.Router.Direct.FilePrefix, cfg.Router.Direct.FileSkipRules); err != nil {
return fmt.Errorf("load direct rules: %w", err)
}
if err := loadRule(ctx, r.ProxyRule, proxyDial, cfg.Router.Proxy.File, cfg.Router.Proxy.FilePrefix, cfg.Router.Proxy.FileSkipRules); err != nil {
return fmt.Errorf("load proxy rules: %w", err)
}
countryLines, err := fetchRuleFile(ctx, proxyDial, cfg.Router.Country.File)
if err != nil {
return fmt.Errorf("load country rules: %w", err)
}
if err := r.AddCountryCIDRs(countryLines...); err != nil {
return fmt.Errorf("load country rules: %w", err)
}
return nil
}
func startDNSListeners(ctx context.Context, wg *sync.WaitGroup, cfg config.SowerConfig, r *router.Router, stats *admin.Stats, errCh chan<- error) error {
if cfg.DNS.Disable {
slog.Info("DNS proxy disabled")
return nil
}
_, shared := sharedAdminHTTPAddr(cfg)
for _, ip := range dnsListenIPs(cfg) {
// In shared mode the admin console takes over the primary HTTP
// listener; the HTTPS and DNS listeners still start normally.
if !(shared && ip == cfg.DNS.Serve) {
if err := startHTTPListener(ctx, wg, ip, r, stats, errCh); err != nil {
return err
}
}
if err := startHTTPSListener(ctx, wg, ip, r, stats, errCh); err != nil {
return err
}
if err := startDNSUDPListener(ctx, wg, ip, r, stats, errCh); err != nil {
return err
}
}
return nil
}
func dnsListenIPs(cfg config.SowerConfig) []string {
ips := make([]string, 0, 2)
if strings.TrimSpace(cfg.DNS.Serve) != "" {
ips = append(ips, cfg.DNS.Serve)
}
if strings.TrimSpace(cfg.DNS.Serve6) != "" {
ips = append(ips, cfg.DNS.Serve6)
}
return ips
}
// selfProxyAddrs lists the TCP addresses this process serves proxy traffic on:
// the HTTP and HTTPS proxy listeners on every DNS listen address, plus SOCKS5.
// A proxy dial that resolves to one of them can only re-enter this proxy and be
// forwarded again (router.ErrSelfProxyTarget). Admin-only and DNS listeners are
// deliberately excluded: they do not forward, so dialing them cannot build a
// chain.
func selfProxyAddrs(cfg config.SowerConfig) []string {
if cfg.DNS.Disable {
return nil
}
ips := dnsListenIPs(cfg)
addrs := make([]string, 0, len(ips)*2+1)
for _, ip := range ips {
addrs = append(addrs, net.JoinHostPort(ip, "80"), net.JoinHostPort(ip, "443"))
}
if !cfg.Socks5.Disable && strings.TrimSpace(cfg.Socks5.Addr) != "" {
addrs = append(addrs, cfg.Socks5.Addr)
}
return addrs
}
func startHTTPListener(ctx context.Context, wg *sync.WaitGroup, ip string, r *router.Router, stats *admin.Stats, errCh chan<- error) error {
addr := net.JoinHostPort(ip, "80")
ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("listen http proxy on %s: %w", addr, err)
}
slog.Info("service listening", "service", "http proxy", "network", "tcp", "addr", addr)
wg.Add(1)
go closeOnDone(ctx, wg, ln)
go serveAndReport(errCh, "http proxy", func() error {
return ServeHTTP(ctx, ln, r, stats)
})
return nil
}
func startHTTPSListener(ctx context.Context, wg *sync.WaitGroup, ip string, r *router.Router, stats *admin.Stats, errCh chan<- error) error {
addr := net.JoinHostPort(ip, "443")
ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("listen https proxy on %s: %w", addr, err)
}
slog.Info("service listening", "service", "https proxy", "network", "tcp", "addr", addr)
wg.Add(1)
go closeOnDone(ctx, wg, ln)
go serveAndReport(errCh, "https proxy", func() error {
return ServeHTTPS(ctx, ln, r, stats)
})
return nil
}
func startDNSUDPListener(ctx context.Context, wg *sync.WaitGroup, ip string, r *router.Router, stats *admin.Stats, errCh chan<- error) error {
addr := net.JoinHostPort(ip, "53")
pc, err := net.ListenPacket("udp", addr)
if err != nil {
return fmt.Errorf("listen dns proxy on %s: %w", addr, err)
}
server := &dns.Server{
PacketConn: pc,
Handler: dnsStatsHandler{Handler: r, stats: stats},
}
slog.Info("service listening", "service", "dns proxy", "network", "udp", "addr", addr)
wg.Add(1)
go shutdownDNSServerOnDone(ctx, wg, server)
go func() {
if err := server.ActivateAndServe(); err != nil && !errors.Is(err, net.ErrClosed) {
reportServeError(errCh, "dns proxy", fmt.Errorf("serve on %s: %w", addr, err))
}
}()
return nil
}
func startSocks5Listener(ctx context.Context, wg *sync.WaitGroup, cfg config.SowerConfig, r *router.Router, stats *admin.Stats, errCh chan<- error) error {
if cfg.Socks5.Disable {
slog.Info("SOCKS5 proxy disabled")
return nil
}
ln, err := net.Listen("tcp", cfg.Socks5.Addr)
if err != nil {
return fmt.Errorf("listen socks5 proxy on %s: %w", cfg.Socks5.Addr, err)
}
slog.Info("service listening", "service", "socks5 proxy", "network", "tcp", "addr", cfg.Socks5.Addr)
wg.Add(1)
go closeOnDone(ctx, wg, ln)
go serveAndReport(errCh, "socks5 proxy", func() error {
return ServeSocks5(ctx, ln, r, stats)
})
return nil
}
func loadRule(ctx context.Context, rule *router.RuleSet, proxyDial router.ProxyDialFn, file, linePrefix string, skipRules []string) error {
skipRule := suffixtree.NewNodeFromRules(skipRules...)
lines, err := fetchRuleFile(ctx, proxyDial, file)
if err != nil {
return err
}
for _, line := range lines {
item := linePrefix + line
if skipRule.Match(line) || skipRule.Match(item) {
continue
}
rule.Add(item)
}
rule.Compact()
return nil
}
func fetchRuleFile(ctx context.Context, proxyDial router.ProxyDialFn, file string) ([]string, error) {
if file == "" {
return nil, nil
}
var loadFn func() (io.ReadCloser, error)
if _, err := os.Stat(file); err == nil {
loadFn = func() (io.ReadCloser, error) {
return os.Open(file)
}
} else {
if proxyDial == nil {
return nil, fmt.Errorf("remote rule file %q requires upstream proxy dialer", file)
}
var lastDialErr error
client := &http.Client{
Timeout: 15 * time.Second,
Transport: &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
domain, port, _ := net.SplitHostPort(addr)
p, _ := strconv.Atoi(port)
conn, err := proxyDial("tcp", domain, uint16(p))
if err != nil {
lastDialErr = err
}
return conn, err
},
},
}
loadFn = func() (io.ReadCloser, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, file, nil)
if err != nil {
return nil, err
}
req.Header.Add("Accept-Encoding", "gzip")
resp, err := client.Do(req)
if err != nil {
if ctx.Err() != nil && lastDialErr != nil {
return nil, fmt.Errorf("proxy dial failed before request cancellation: %v: %w", lastDialErr, ctx.Err())
}
return nil, err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("status code: %d", resp.StatusCode)
}
return resp.Body, nil
}
}
// load rule file, retry 10 times
rc, err := loadFn()
for i := time.Duration(1); i < 10; i++ {
if err == nil {
break
}
if ctx.Err() != nil {
return nil, fmt.Errorf("fetch rule file %q canceled after previous error %v: %w", file, err, ctx.Err())
}
// wait: 28.5s
timer := time.NewTimer(i * i * 100 * time.Millisecond)
select {
case <-ctx.Done():
timer.Stop()
return nil, fmt.Errorf("fetch rule file %q canceled after previous error %v: %w", file, err, ctx.Err())
case <-timer.C:
}
rc, err = loadFn()
}
if err != nil {
return nil, fmt.Errorf("fetch rule file %q: %w", file, err)
}
lines, err := readRuleLines(rc)
if err != nil {
return nil, fmt.Errorf("read rule file %q: %w", file, err)
}
return lines, nil
}
func readRuleLines(rc io.ReadCloser) ([]string, error) {
defer rc.Close()
br := bufio.NewReader(rc)
reader := io.Reader(br)
magic, err := br.Peek(2)
if err != nil && err != io.EOF && err != bufio.ErrBufferFull {
return nil, err
}
if len(magic) == 2 && magic[0] == 0x1f && magic[1] == 0x8b {
gr, err := gzip.NewReader(br)
if err != nil {
return nil, err
}
defer gr.Close()
reader = gr
}
lines := make([]string, 0)
lineReader := bufio.NewReader(reader)
for {
line, err := lineReader.ReadString('\n')
if err != nil && err != io.EOF {
return nil, err
}
line = strings.TrimSpace(line)
if line != "" {
lines = append(lines, line)
}
if err == io.EOF {
return lines, nil
}
}
}
func closeOnDone(ctx context.Context, wg *sync.WaitGroup, closer io.Closer) {
defer wg.Done()
<-ctx.Done()
_ = closer.Close()
}
func shutdownDNSServerOnDone(ctx context.Context, wg *sync.WaitGroup, server *dns.Server) {
defer wg.Done()
<-ctx.Done()
_ = server.ShutdownContext(ctx)
}
func reportServeError(errCh chan<- error, service string, err error) {
if err == nil || errors.Is(err, net.ErrClosed) {
return
}
select {
case errCh <- fmt.Errorf("%s: %w", service, err):
default:
}
}
func serveAndReport(errCh chan<- error, service string, serve func() error) {
reportServeError(errCh, service, serve())
}
+333
View File
@@ -0,0 +1,333 @@
package main
import (
"compress/gzip"
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"os"
"reflect"
"slices"
"strconv"
"strings"
"testing"
"time"
"github.com/sower-proxy/sower/config"
"github.com/sower-proxy/sower/router"
)
func TestFetchRuleFileEmptyPathReturnsClosedChannel(t *testing.T) {
t.Parallel()
lines, err := fetchRuleFile(context.Background(), nil, "")
if err != nil {
t.Fatalf("fetch empty rule file: %v", err)
}
if len(lines) != 0 {
t.Fatalf("unexpected lines for empty rule file path: %v", lines)
}
}
func TestNewRouterPreservesEmptyUpstreamForDiscovery(t *testing.T) {
t.Parallel()
cfg := config.SowerConfig{}
cfg.DNS.Serve = "127.0.0.1"
cfg.DNS.Upstream = ""
cfg.DNS.Fallback = "223.5.5.5"
r, err := newRouter(cfg, nil)
if err != nil {
t.Fatalf("new router: %v", err)
}
dnsState := reflect.ValueOf(r).Elem().FieldByName("dns")
upstreamDNS := dnsState.FieldByName("upstreamDNS").String()
fallbackDNS := dnsState.FieldByName("fallbackDNS").String()
if upstreamDNS != "" {
t.Fatalf("expected empty router upstream DNS to enable discovery, got %q", upstreamDNS)
}
if fallbackDNS != "223.5.5.5" {
t.Fatalf("unexpected fallback DNS: %q", fallbackDNS)
}
}
func TestNewRouterRejectsInvalidCountryRule(t *testing.T) {
t.Parallel()
cfg := config.SowerConfig{}
cfg.DNS.Serve = "127.0.0.1"
cfg.DNS.Fallback = "223.5.5.5"
cfg.Router.Country.Rules = []string{"invalid-cidr"}
if _, err := newRouter(cfg, nil); err == nil {
t.Fatal("expected invalid country rule to fail")
}
}
func TestFetchRuleFileRemoteGzip(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Encoding", "gzip")
gz := gzip.NewWriter(w)
defer gz.Close()
_, _ = gz.Write([]byte("alpha\n\nbeta\n"))
}))
defer srv.Close()
dialFn := func(network, host string, port uint16) (net.Conn, error) {
return net.Dial(network, net.JoinHostPort(host, strconv.Itoa(int(port))))
}
got, err := fetchRuleFile(context.Background(), dialFn, srv.URL)
if err != nil {
t.Fatalf("fetch remote gzip rule file: %v", err)
}
want := []string{"alpha", "beta"}
if !slices.Equal(got, want) {
t.Fatalf("unexpected rules: got %v want %v", got, want)
}
}
func TestFetchRuleFileRemoteRequiresProxyDialer(t *testing.T) {
t.Parallel()
_, err := fetchRuleFile(context.Background(), nil, "https://example.com/rules.txt")
if err == nil {
t.Fatal("expected remote rule file without proxy dialer to fail")
}
if !strings.Contains(err.Error(), "requires upstream proxy dialer") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestFetchRuleFileCanceledIncludesPreviousError(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
dialErr := errors.New("proxy dial failed")
dialFn := func(network, host string, port uint16) (net.Conn, error) {
cancel()
return nil, dialErr
}
_, err := fetchRuleFile(ctx, dialFn, "https://example.com/rules.txt")
if err == nil {
t.Fatal("expected canceled fetch to fail")
}
if !strings.Contains(err.Error(), dialErr.Error()) {
t.Fatalf("expected previous error in cancellation message, got: %v", err)
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context.Canceled wrapping, got: %v", err)
}
}
func TestFetchRuleFileUsesProxyDialerForRemoteRules(t *testing.T) {
t.Parallel()
proxyHost := ""
dialFn := func(network, host string, port uint16) (net.Conn, error) {
proxyHost = host
return nil, fmt.Errorf("stop after proxy dial")
}
_, err := fetchRuleFile(context.Background(), dialFn, "https://example.com/rules.txt")
if err == nil {
t.Fatal("expected proxy dial failure")
}
if proxyHost != "example.com" {
t.Fatalf("expected remote host to be dialed through proxy, got %q", proxyHost)
}
}
func TestServeAndReportRunsServeFunction(t *testing.T) {
t.Parallel()
errCh := make(chan error, 1)
serveErr := errors.New("serve failed")
done := make(chan struct{})
go func() {
serveAndReport(errCh, "test service", func() error {
close(done)
return serveErr
})
}()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("serve function was not called")
}
select {
case err := <-errCh:
if !errors.Is(err, serveErr) {
t.Fatalf("unexpected reported error: %v", err)
}
case <-time.After(time.Second):
t.Fatal("serve error was not reported")
}
}
func TestLoadRuleSkipsOnlyFileRules(t *testing.T) {
t.Parallel()
path := writeGzipRuleFile(t, "t.co\nexample.com\n")
rule := router.NewRuleSet("manual.example")
if err := loadRule(context.Background(), rule, nil, path, "**.", []string{"t.co"}); err != nil {
t.Fatalf("load rule: %v", err)
}
if !rule.Match("manual.example") {
t.Fatal("expected existing manual rule to remain")
}
if rule.Match("t.co") {
t.Fatal("expected skipped file rule to be absent")
}
if !rule.Match("example.com") {
t.Fatal("expected non-skipped file rule to be loaded")
}
}
func TestLoadRuleSkipsPrefixedFileRule(t *testing.T) {
t.Parallel()
path := writeGzipRuleFile(t, "t.co\nexample.com\n")
rule := router.NewRuleSet()
if err := loadRule(context.Background(), rule, nil, path, "blocked.", []string{"blocked.t.co"}); err != nil {
t.Fatalf("load rule: %v", err)
}
if rule.Match("blocked.t.co") {
t.Fatal("expected prefixed file rule to be skipped")
}
if !rule.Match("blocked.example.com") {
t.Fatal("expected non-skipped prefixed file rule to be loaded")
}
}
func writeGzipRuleFile(t *testing.T, content string) string {
t.Helper()
path := t.TempDir() + "/rules.txt"
file, err := os.Create(path)
if err != nil {
t.Fatalf("create rule file: %v", err)
}
gz := gzip.NewWriter(file)
if _, err := gz.Write([]byte(content)); err != nil {
t.Fatalf("write gzip rule file: %v", err)
}
if err := gz.Close(); err != nil {
t.Fatalf("close gzip writer: %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("close rule file: %v", err)
}
if err := os.Chmod(path, 0o600); err != nil {
t.Fatalf("write rule file: %v", err)
}
return path
}
func TestLogConfigLoadErrorRedactsRemotePassword(t *testing.T) {
prev := slog.Default()
defer slog.SetDefault(prev)
var buf strings.Builder
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
cfg := config.SowerConfig{}
cfg.Remote.Addr = "proxy.example.com"
cfg.Remote.Password = "topsecret-remote"
logConfigLoadError(errors.New("boom"), cfg)
out := buf.String()
if strings.Contains(out, "topsecret-remote") {
t.Fatalf("remote password leaked in load-failure log: %s", out)
}
if !strings.Contains(out, "load config") || !strings.Contains(out, "boom") {
t.Fatalf("load-failure log missing error context: %s", out)
}
}
func TestMemoryLimitMiB(t *testing.T) {
t.Parallel()
tests := []struct {
name string
env map[string]string
want int64
}{
{name: "default", want: defaultMemoryLimitMiB},
{name: "explicit GOMEMLIMIT wins", env: map[string]string{"GOMEMLIMIT": "1GiB"}, want: 0},
{name: "override", env: map[string]string{"SOWER_MEMORY_LIMIT_MB": "256"}, want: 256},
{name: "zero disables the soft limit", env: map[string]string{"SOWER_MEMORY_LIMIT_MB": "0"}, want: -1},
{name: "garbage keeps the default", env: map[string]string{"SOWER_MEMORY_LIMIT_MB": "lots"}, want: defaultMemoryLimitMiB},
{name: "negative keeps the default", env: map[string]string{"SOWER_MEMORY_LIMIT_MB": "-5"}, want: defaultMemoryLimitMiB},
{name: "above the cap keeps the default", env: map[string]string{"SOWER_MEMORY_LIMIT_MB": strconv.FormatInt(maxMemoryLimitMiB+1, 10)}, want: defaultMemoryLimitMiB},
{name: "at the cap", env: map[string]string{"SOWER_MEMORY_LIMIT_MB": strconv.FormatInt(maxMemoryLimitMiB, 10)}, want: maxMemoryLimitMiB},
{name: "GOMEMLIMIT beats the override", env: map[string]string{"GOMEMLIMIT": "1GiB", "SOWER_MEMORY_LIMIT_MB": "256"}, want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := memoryLimitMiB(func(k string) string { return tt.env[k] })
if got != tt.want {
t.Fatalf("memoryLimitMiB(%v) = %d, want %d", tt.env, got, tt.want)
}
})
}
}
// The self-target guard is only as good as the listener set it is given: it
// must name every address this process forwards proxy traffic on, and only
// those (a proxied dial to one of them re-enters the proxy).
func TestSelfProxyAddrsCoversEveryForwardingListener(t *testing.T) {
t.Parallel()
cfg := config.SowerConfig{}
cfg.DNS.Serve = "100.64.1.1"
cfg.DNS.Serve6 = "fd7a::1"
cfg.Socks5.Addr = "100.64.1.1:1080"
cfg.Admin.Addr = "100.64.1.1:18080"
got := selfProxyAddrs(cfg)
want := []string{
"100.64.1.1:80", "100.64.1.1:443",
"[fd7a::1]:80", "[fd7a::1]:443",
"100.64.1.1:1080",
}
slices.Sort(got)
slices.Sort(want)
if !slices.Equal(got, want) {
t.Fatalf("selfProxyAddrs = %v, want %v", got, want)
}
// A standalone admin listener answers admin traffic instead of forwarding,
// so it can never close a proxy chain; disabling SOCKS5 drops its address.
cfg.Socks5.Disable = true
if got := selfProxyAddrs(cfg); slices.Contains(got, cfg.Admin.Addr) {
t.Fatalf("admin-only listener listed as a proxy listener: %v", got)
}
// With DNS disabled no HTTP/HTTPS proxy listener is started at all.
cfg.DNS.Disable = true
if got := selfProxyAddrs(cfg); len(got) != 0 {
t.Fatalf("selfProxyAddrs = %v, want none while the proxy listeners are disabled", got)
}
}
+30
View File
@@ -0,0 +1,30 @@
//go:build !windows
package main
import (
"fmt"
"os"
"syscall"
)
// restartSupported reports whether the admin restart endpoint can work on
// this platform.
func restartSupported() bool { return true }
// restartCurrentProcess replaces the current process image in place, so the
// systemd unit keeps the same PID and Restart=on-failure is not triggered.
// All listeners must be closed before calling this: listener sockets carry
// CLOEXEC and would be closed by the exec anyway, but closing them first
// guarantees the addresses are released (no in-flight connections, no
// lingering TIME_WAIT on the listener) before the replacement binds them.
func restartCurrentProcess() error {
executable, err := os.Executable()
if err != nil {
return fmt.Errorf("get executable: %w", err)
}
if err := syscall.Exec(executable, os.Args, os.Environ()); err != nil {
return fmt.Errorf("exec %s: %w", executable, err)
}
return nil
}
+15
View File
@@ -0,0 +1,15 @@
//go:build windows
package main
import "errors"
// restartSupported reports whether the admin restart endpoint can work on
// this platform.
func restartSupported() bool { return false }
// restartCurrentProcess is unsupported on Windows: the admin restart
// endpoint reports the error and the process keeps running.
func restartCurrentProcess() error {
return errors.New("process restart is unsupported on windows")
}
+485
View File
@@ -0,0 +1,485 @@
package main
import (
"bufio"
"context"
stderrors "errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"strconv"
"strings"
"time"
utls "github.com/refraction-networking/utls"
"github.com/sower-proxy/conns/relay"
"github.com/sower-proxy/conns/reread"
"github.com/sower-proxy/sower/internal/admin"
"github.com/sower-proxy/sower/pkg/upstreamtls"
"github.com/sower-proxy/sower/router"
"github.com/sower-proxy/sower/transport"
"github.com/sower-proxy/sower/transport/socks5"
"github.com/sower-proxy/sower/transport/sower"
)
const (
proxyDialTimeout = 10 * time.Second
proxyReadTimeout = 15 * time.Second
tlsRecordHeaderLen = 5
tlsRecordTypeHandshake = 22
tlsHandshakeTypeHello = 1
maxTLSPlaintextRecordLen = 64 * 1024
)
func GenProxyDial(proxyType, proxyHost, proxyPassword, dns string, tlsOptions upstreamtls.Options, stats *admin.Stats) (router.ProxyDialFn, error) {
var proxy transport.Transport
var dialFn func() (net.Conn, error)
dialer := &net.Dialer{
Timeout: proxyDialTimeout,
KeepAlive: 30 * time.Second,
Resolver: &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
d := net.Dialer{Timeout: proxyDialTimeout}
c, err := d.DialContext(ctx, "udp", net.JoinHostPort(dns, "53"))
if err != nil {
slog.Warn("dial fallback dns failed, use default dns setting", "error", err)
if stats != nil {
stats.RecordProxyError("dns", fmt.Sprintf("fallback dial %s: %v", dns, err))
}
c, err = d.DialContext(ctx, network, address)
}
return c, err
},
},
}
switch proxyType {
case "sower":
tlsDialFn, err := newTLSDialFn(dialer, proxyHost, tlsOptions)
if err != nil {
return nil, err
}
proxy = sower.New(proxyPassword)
dialFn = tlsDialFn
case "socks5":
proxy = socks5.New()
dialFn = func() (net.Conn, error) {
return dialer.Dial("tcp", proxyHost)
}
default:
return nil, fmt.Errorf("unknown proxy type %q", proxyType)
}
return func(network, host string, port uint16) (net.Conn, error) {
if host == "" || port == 0 {
return nil, fmt.Errorf("invalid addr(%s:%d)", host, port)
}
conn, err := dialFn()
if err != nil {
return nil, err
}
if err := proxy.Wrap(conn, host, port); err != nil {
conn.Close()
return nil, err
}
return conn, nil
}, nil
}
func newTLSDialFn(dialer *net.Dialer, proxyHost string, tlsOptions upstreamtls.Options) (func() (net.Conn, error), error) {
dialAddr, err := upstreamDialAddr(proxyHost, "443")
if err != nil {
return nil, err
}
if tlsOptions.ClientHello != "" {
if err := upstreamtls.ValidateClientHello(tlsOptions.ClientHello); err != nil {
return nil, err
}
}
return func() (net.Conn, error) {
return upstreamtls.Dial(dialer, "tcp", dialAddr, tlsOptions)
}, nil
}
// hostOnly strips the port from a host:port string, returning the input
// unchanged when it has no port.
func hostOnly(addr string) string {
if host, _, err := net.SplitHostPort(addr); err == nil {
return host
}
return addr
}
// splitHostPort splits host:port, falling back to defPort when addr has no
// port or the port is invalid.
func splitHostPort(addr string, defPort uint16) (string, uint16) {
host, portStr, err := net.SplitHostPort(addr)
if err != nil {
return addr, defPort
}
p, err := strconv.Atoi(portStr)
if err != nil || p < 1 || p > 65535 {
return host, defPort
}
return host, uint16(p)
}
func upstreamDialAddr(addr, defaultPort string) (string, error) {
if host, port, err := net.SplitHostPort(addr); err == nil {
if host == "" || port == "" {
return "", fmt.Errorf("invalid remote address %q", addr)
}
return net.JoinHostPort(host, port), nil
}
if addr == "" {
return "", fmt.Errorf("empty remote address")
}
if strings.HasPrefix(addr, "[") || strings.HasSuffix(addr, "]") {
return "", fmt.Errorf("invalid remote address %q", addr)
}
if strings.Count(addr, ":") == 1 {
return "", fmt.Errorf("missing or invalid port in %q", addr)
}
return net.JoinHostPort(addr, defaultPort), nil
}
func ServeHTTP(ctx context.Context, ln net.Listener, r *router.Router, stats *admin.Stats) error {
for {
conn, err := ln.Accept()
if err != nil {
if shouldRetryAccept(ctx, "http", err, stats) {
continue
}
return wrapAcceptErr(ctx, "http", err)
}
go handleHTTPConn(conn, r, stats)
}
}
func ServeHTTPS(ctx context.Context, ln net.Listener, r *router.Router, stats *admin.Stats) error {
for {
conn, err := ln.Accept()
if err != nil {
if shouldRetryAccept(ctx, "https", err, stats) {
continue
}
return wrapAcceptErr(ctx, "https", err)
}
go handleHTTPSConn(conn, r, stats)
}
}
func ServeSocks5(ctx context.Context, ln net.Listener, r *router.Router, stats *admin.Stats) error {
for {
conn, err := ln.Accept()
if err != nil {
if shouldRetryAccept(ctx, "socks5", err, stats) {
continue
}
return wrapAcceptErr(ctx, "socks5", err)
}
go handleSocks5Conn(conn, r, stats)
}
}
func handleHTTPConn(conn net.Conn, r *router.Router, stats *admin.Stats) {
start := time.Now()
conn = stats.WrapConn(conn, "http")
rereadConn := reread.New(conn)
defer rereadConn.Close()
_ = rereadConn.SetDeadline(time.Now().Add(proxyReadTimeout))
br := bufio.NewReader(rereadConn)
req, err := http.ReadRequest(br)
if err != nil {
slog.Error("read http request", "error", err)
return
}
_ = rereadConn.SetDeadline(time.Time{})
stats.BindConn(conn, hostOnly(req.Host))
defPort := uint16(80)
if req.Method == http.MethodConnect {
// CONNECT targets use the port from the request line, defaulting to 443.
defPort = 443
}
host, port := splitHostPort(req.Host, defPort)
rc, err := r.DialProxyOnly("tcp", host, port)
if err != nil {
slog.Error("dial proxy", "error", err, "host", req.Host, "req", req.URL)
if !stderrors.Is(err, router.ErrBlocked) {
stats.RecordProxyError("dial", fmt.Sprintf("%s: %v", hostOnly(req.Host), err))
}
// Answer the refusal: a client that only sees the connection close cannot
// tell a policy refusal (blocked target, this proxy's own listener) from
// a broken proxy.
writeHTTPProxyError(rereadConn.Stop(), err)
return
}
defer rc.Close()
if req.Method == http.MethodConnect {
// Acknowledge CONNECT before tunneling raw bytes; the request head
// must not be replayed to the target, which expects TLS data.
rereadConn.Stop().Reset()
// The bufio reader may already have pulled bytes past the request
// head (a client that pipelines tunnel data without waiting for the
// 200). Replay those before relaying, or the tunnel's first bytes
// would be lost and the target would see a truncated TLS stream.
if buffered := br.Buffered(); buffered > 0 {
if _, err := io.CopyN(rc, br, int64(buffered)); err != nil {
slog.Debug("flush pipelined connect bytes", "error", err, "host", host, "port", port)
return
}
}
if _, err := rereadConn.Write([]byte("HTTP/1.1 200 Connection established\r\n\r\n")); err != nil {
slog.Debug("write connect response", "error", err, "host", host, "port", port)
return
}
} else {
rereadConn.Stop().Reread()
}
err = relay.Relay(rereadConn, rc)
if err != nil {
slog.Debug("serve http", "error", err, "host", req.Host, "spend", time.Since(start))
}
}
func handleHTTPSConn(conn net.Conn, r *router.Router, stats *admin.Stats) {
start := time.Now()
conn = stats.WrapConn(conn, "https")
rereadConn := reread.New(conn)
defer rereadConn.Close()
_ = rereadConn.SetDeadline(time.Now().Add(proxyReadTimeout))
domain, err := peekTLSClientHelloServerName(rereadConn)
if err != nil {
slog.Debug("read tls client hello", "error", err)
return
}
if domain == "" {
slog.Debug("tls client hello missing server name")
return
}
_ = rereadConn.SetDeadline(time.Time{})
stats.BindConn(conn, domain)
rc, err := r.DialProxyOnly("tcp", domain, 443)
if err != nil {
slog.Error("dial proxy", "error", err, "host", domain)
stats.RecordProxyError("dial", fmt.Sprintf("%s: %v", domain, err))
return
}
defer rc.Close()
rereadConn.Stop().Reread()
err = relay.Relay(rereadConn, rc)
if err != nil {
slog.Debug("serve https", "error", err, "host", domain, "spend", time.Since(start))
}
}
func peekTLSClientHelloServerName(conn io.Reader) (string, error) {
clientHello, err := readTLSClientHello(conn)
if err != nil {
return "", err
}
hello := utls.UnmarshalClientHello(clientHello)
if hello == nil {
return "", fmt.Errorf("parse tls client hello")
}
return hello.ServerName, nil
}
func readTLSClientHello(conn io.Reader) ([]byte, error) {
clientHello := make([]byte, 0, 1024)
for {
header := make([]byte, tlsRecordHeaderLen)
if _, err := io.ReadFull(conn, header); err != nil {
return nil, fmt.Errorf("read tls record header: %w", err)
}
if header[0] != tlsRecordTypeHandshake {
return nil, fmt.Errorf("unexpected tls record type %d", header[0])
}
recordLen := int(header[3])<<8 | int(header[4])
if recordLen <= 0 || recordLen > maxTLSPlaintextRecordLen {
return nil, fmt.Errorf("invalid tls record length %d", recordLen)
}
recordBody := make([]byte, recordLen)
if _, err := io.ReadFull(conn, recordBody); err != nil {
return nil, fmt.Errorf("read tls record body: %w", err)
}
clientHello = append(clientHello, recordBody...)
if len(clientHello) < 4 {
continue
}
if clientHello[0] != tlsHandshakeTypeHello {
return nil, fmt.Errorf("unexpected tls handshake type %d", clientHello[0])
}
helloLen := int(clientHello[1])<<16 | int(clientHello[2])<<8 | int(clientHello[3])
if helloLen == 0 {
return nil, fmt.Errorf("empty tls client hello")
}
if len(clientHello) < 4+helloLen {
continue
}
return clientHello[:4+helloLen], nil
}
}
func handleSocks5Conn(conn net.Conn, r *router.Router, stats *admin.Stats) {
conn = stats.WrapConn(conn, "socks5")
defer conn.Close()
rereadConn := reread.New(conn)
_ = rereadConn.SetDeadline(time.Now().Add(proxyReadTimeout))
byte1 := make([]byte, 1)
if n, err := rereadConn.Read(byte1); err != nil || n != 1 {
slog.Error("read first byte", "error", err)
return
}
rereadConn.Reread()
if byte1[0] == 5 {
rereadConn.Stop()
server := socks5.New()
addr, err := server.ReadRequest(rereadConn)
if err != nil {
slog.Error("read socks5 request", "error", err)
return
}
// The handshake is complete: clear the deadline before dialing and
// relaying. Keeping it through the handshake bounds slowloris
// clients that stall mid-negotiation.
_ = rereadConn.SetDeadline(time.Time{})
host, port := addr.(*socks5.AddrHead).Addr()
stats.BindConn(conn, host)
rc, err := r.DialSmart("tcp", host, port)
if err != nil {
if !stderrors.Is(err, router.ErrBlocked) {
stats.RecordProxyError("dial", fmt.Sprintf("%s: %v", host, err))
}
if replyErr := server.WriteReply(rereadConn, routeSocks5ReplyCode(err)); replyErr != nil {
slog.Debug("write socks5 failure reply", "error", replyErr, "host", host, "port", port)
}
return
}
defer rc.Close()
if err := server.WriteReply(rereadConn, socks5.RepSucceeded); err != nil {
slog.Debug("write socks5 success reply", "error", err, "host", host, "port", port)
return
}
_ = relay.Relay(rereadConn, rc)
return
}
br := bufio.NewReader(rereadConn)
req, err := http.ReadRequest(br)
if err != nil {
slog.Error("read http request", "error", err)
return
}
// Handshake complete; clear the deadline before dialing.
_ = rereadConn.SetDeadline(time.Time{})
host, port, err := router.ParseHostPort(req.Host, req.URL)
if err != nil {
rereadConn.Stop().Write([]byte("HTTP/1.1 400 Bad Request\r\n\r\n"))
return
}
stats.BindConn(conn, host)
rc, err := r.DialSmart("tcp", host, port)
if err != nil {
if !stderrors.Is(err, router.ErrBlocked) {
stats.RecordProxyError("dial", fmt.Sprintf("%s: %v", host, err))
}
writeHTTPProxyError(rereadConn.Stop(), err)
return
}
defer rc.Close()
if req.Method == http.MethodConnect {
rereadConn.Stop().Reset()
// Replay bytes the bufio reader already pulled past the request
// head (pipelined tunnel data) before relaying raw bytes.
if buffered := br.Buffered(); buffered > 0 {
if _, err := io.CopyN(rc, br, int64(buffered)); err != nil {
slog.Debug("flush pipelined connect bytes", "error", err, "host", host, "port", port)
return
}
}
if _, err := rereadConn.Write([]byte("HTTP/1.1 200 Connection established\r\n\r\n")); err != nil {
slog.Debug("write connect response", "error", err, "host", host, "port", port)
return
}
} else {
rereadConn.Stop().Reread()
}
if err := relay.Relay(rereadConn, rc); err != nil {
slog.Debug("serve proxy request", "error", err, "host", host, "port", port, "method", req.Method)
}
}
func shouldRetryAccept(ctx context.Context, protocol string, err error, stats *admin.Stats) bool {
if err == nil || ctx.Err() != nil {
return false
}
if ne, ok := err.(net.Error); ok && ne.Temporary() {
slog.Warn("temporary accept failed", "protocol", protocol, "error", err)
if stats != nil {
stats.RecordProxyError("accept", fmt.Sprintf("%s listener: %v", protocol, err))
}
time.Sleep(200 * time.Millisecond)
return true
}
return false
}
func wrapAcceptErr(ctx context.Context, protocol string, err error) error {
if err == nil || ctx.Err() != nil {
return nil
}
return fmt.Errorf("accept %s connection: %w", protocol, err)
}
func writeHTTPProxyError(conn net.Conn, err error) {
status := "502 Bad Gateway"
switch {
case stderrors.Is(err, router.ErrBlocked), stderrors.Is(err, router.ErrSelfProxyTarget):
// A refused request: blocked by a rule, or aimed at a name this proxy
// itself serves, which cannot be forwarded without re-entering it.
status = "403 Forbidden"
}
if _, writeErr := conn.Write([]byte("HTTP/1.1 " + status + "\r\n\r\n")); writeErr != nil {
slog.Debug("write http proxy error", "error", writeErr, "status", status)
}
}
func routeSocks5ReplyCode(err error) byte {
if stderrors.Is(err, router.ErrBlocked) || stderrors.Is(err, router.ErrSelfProxyTarget) {
return socks5.RepConnectionNotAllowed
}
return socks5.RepGeneralFailure
}
+543
View File
@@ -0,0 +1,543 @@
package main
import (
"crypto/tls"
"io"
"net"
"strings"
"sync"
"testing"
"time"
"github.com/sower-proxy/sower/internal/admin"
"github.com/sower-proxy/sower/pkg/upstreamtls"
"github.com/sower-proxy/sower/router"
)
func TestGenProxyDialRejectsUnknownProxyType(t *testing.T) {
t.Parallel()
if _, err := GenProxyDial("unknown", "example.com", "", "8.8.8.8", upstreamtls.Options{}, nil); err == nil {
t.Fatal("expected error for unknown proxy type")
}
}
func TestGenProxyDialRejectsInvalidTLSClientHello(t *testing.T) {
t.Parallel()
_, err := GenProxyDial("sower", "example.com", "", "8.8.8.8", upstreamtls.Options{
ClientHello: "invalid",
}, nil)
if err == nil {
t.Fatal("expected error for invalid TLS client hello")
}
}
func TestUpstreamDialAddrAddsDefaultPort(t *testing.T) {
t.Parallel()
addr, err := upstreamDialAddr("example.com", "443")
if err != nil {
t.Fatalf("upstream dial addr: %v", err)
}
if addr != "example.com:443" {
t.Fatalf("unexpected dial addr: %q", addr)
}
}
func TestUpstreamDialAddrPreservesExplicitPort(t *testing.T) {
t.Parallel()
addr, err := upstreamDialAddr("example.com:8443", "443")
if err != nil {
t.Fatalf("upstream dial addr: %v", err)
}
if addr != "example.com:8443" {
t.Fatalf("unexpected dial addr: %q", addr)
}
}
func TestHostOnly(t *testing.T) {
t.Parallel()
for _, test := range []struct {
addr string
want string
}{
{addr: "example.com:8080", want: "example.com"},
{addr: "example.com", want: "example.com"},
{addr: "[2001:db8::1]:443", want: "2001:db8::1"},
} {
t.Run(test.addr, func(t *testing.T) {
if got := hostOnly(test.addr); got != test.want {
t.Fatalf("hostOnly(%q) = %q, want %q", test.addr, got, test.want)
}
})
}
}
func TestHandleSocks5ConnReturnsForbiddenForBlockedConnect(t *testing.T) {
server, client := net.Pipe()
defer client.Close()
r := newTestRouter()
go handleSocks5Conn(server, r, newTestStats(t))
client.SetDeadline(time.Now().Add(2 * time.Second))
if _, err := io.WriteString(client, "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n"); err != nil {
t.Fatalf("write request: %v", err)
}
resp, err := io.ReadAll(client)
if err != nil {
t.Fatalf("read response: %v", err)
}
if !strings.HasPrefix(string(resp), "HTTP/1.1 403 Forbidden") {
t.Fatalf("unexpected response: %q", string(resp))
}
}
func TestHandleSocks5ConnReturnsSocks5FailureForBlockedRequest(t *testing.T) {
server, client := net.Pipe()
defer client.Close()
r := newTestRouter()
go handleSocks5Conn(server, r, newTestStats(t))
client.SetDeadline(time.Now().Add(2 * time.Second))
if _, err := client.Write([]byte{0x05, 0x01, 0x00}); err != nil {
t.Fatalf("write auth request: %v", err)
}
authResp := make([]byte, 2)
if _, err := io.ReadFull(client, authResp); err != nil {
t.Fatalf("read auth response: %v", err)
}
if authResp[0] != 0x05 || authResp[1] != 0x00 {
t.Fatalf("unexpected auth response: %v", authResp)
}
req := []byte{0x05, 0x01, 0x00, 0x03, 0x0b}
req = append(req, []byte("example.com")...)
req = append(req, 0x01, 0xbb)
if _, err := client.Write(req); err != nil {
t.Fatalf("write connect request: %v", err)
}
reply := make([]byte, 10)
if _, err := io.ReadFull(client, reply); err != nil {
t.Fatalf("read connect reply: %v", err)
}
if reply[1] != 0x02 {
t.Fatalf("expected connection-not-allowed reply, got %d", reply[1])
}
}
func TestPeekTLSClientHelloServerName(t *testing.T) {
t.Parallel()
server, client := net.Pipe()
defer client.Close()
hello := generateTLSClientHelloRecord(t, "github.com")
go func() {
_, _ = client.Write(hello)
_ = client.Close()
}()
got, err := peekTLSClientHelloServerName(server)
_ = server.Close()
if err != nil {
t.Fatalf("peek tls client hello: %v", err)
}
if got != "github.com" {
t.Fatalf("unexpected server name: %q", got)
}
}
func TestHandleHTTPConnUsesProxyOnlyEvenWhenBlocked(t *testing.T) {
t.Parallel()
downstreamServer, downstreamClient := net.Pipe()
defer downstreamClient.Close()
upstreamClient, upstreamServer := net.Pipe()
defer upstreamServer.Close()
request := []byte("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")
var (
gotHost string
gotPort uint16
)
r := &router.Router{
BlockRule: router.NewRuleSet("example.com"),
DirectRule: router.NewRuleSet(),
ProxyRule: router.NewRuleSet(),
ProxyDial: func(network, host string, port uint16) (net.Conn, error) {
gotHost = host
gotPort = port
return upstreamClient, nil
},
}
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
handleHTTPConn(downstreamServer, r, newTestStats(t))
}()
downstreamClient.SetWriteDeadline(time.Now().Add(2 * time.Second))
if _, err := downstreamClient.Write(request); err != nil {
t.Fatalf("write downstream request: %v", err)
}
gotRequest := make([]byte, len(request))
upstreamServer.SetReadDeadline(time.Now().Add(2 * time.Second))
if _, err := io.ReadFull(upstreamServer, gotRequest); err != nil {
t.Fatalf("read upstream request: %v", err)
}
if string(gotRequest) != string(request) {
t.Fatalf("request was not relayed intact: %q", gotRequest)
}
if gotHost != "example.com" || gotPort != 80 {
t.Fatalf("unexpected proxy target: %s:%d", gotHost, gotPort)
}
_ = downstreamClient.Close()
_ = upstreamServer.Close()
waitForHandler(t, &wg)
}
func TestHandleHTTPSConnRelaysClientHelloToUpstream(t *testing.T) {
t.Parallel()
downstreamServer, downstreamClient := net.Pipe()
defer downstreamClient.Close()
upstreamClient, upstreamServer := net.Pipe()
defer upstreamServer.Close()
hello := generateTLSClientHelloRecord(t, "github.com")
response := []byte("upstream-response")
var (
gotHost string
gotPort uint16
)
r := &router.Router{
BlockRule: router.NewRuleSet("github.com"),
DirectRule: router.NewRuleSet(),
ProxyRule: router.NewRuleSet(),
ProxyDial: func(network, host string, port uint16) (net.Conn, error) {
gotHost = host
gotPort = port
return upstreamClient, nil
},
}
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
handleHTTPSConn(downstreamServer, r, newTestStats(t))
}()
writeErrCh := make(chan error, 1)
go func() {
_, err := downstreamClient.Write(hello)
writeErrCh <- err
}()
gotHello := make([]byte, len(hello))
upstreamServer.SetReadDeadline(time.Now().Add(2 * time.Second))
if _, err := io.ReadFull(upstreamServer, gotHello); err != nil {
t.Fatalf("read upstream client hello: %v", err)
}
if string(gotHello) != string(hello) {
t.Fatal("client hello was not relayed intact")
}
select {
case err := <-writeErrCh:
if err != nil {
t.Fatalf("write downstream client hello: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("write downstream client hello timed out")
}
readErrCh := make(chan error, 1)
gotResp := make([]byte, len(response))
go func() {
_, err := io.ReadFull(downstreamClient, gotResp)
readErrCh <- err
}()
upstreamServer.SetWriteDeadline(time.Now().Add(2 * time.Second))
if _, err := upstreamServer.Write(response); err != nil {
t.Fatalf("write upstream response: %v", err)
}
select {
case err := <-readErrCh:
if err != nil {
t.Fatalf("read downstream response: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("read downstream response timed out")
}
if string(gotResp) != string(response) {
t.Fatalf("unexpected downstream response: %q", gotResp)
}
if gotHost != "github.com" || gotPort != 443 {
t.Fatalf("unexpected proxy target: %s:%d", gotHost, gotPort)
}
_ = downstreamClient.Close()
_ = upstreamServer.Close()
waitForHandler(t, &wg)
}
func TestHandleHTTPSConnRelaysClientHelloAfterReadingSNI(t *testing.T) {
server, client := net.Pipe()
upstreamClient, upstreamServer := net.Pipe()
defer server.Close()
defer client.Close()
defer upstreamClient.Close()
defer upstreamServer.Close()
hostCh := make(chan string, 1)
r := &router.Router{
ProxyDial: func(network, host string, port uint16) (net.Conn, error) {
if network != "tcp" {
t.Errorf("unexpected network: %s", network)
}
if port != 443 {
t.Errorf("unexpected port: %d", port)
}
hostCh <- host
return upstreamClient, nil
},
}
go handleHTTPSConn(server, r, newTestStats(t))
tlsClient := tls.Client(client, &tls.Config{
ServerName: "example.com",
InsecureSkipVerify: true,
})
_ = client.SetDeadline(time.Now().Add(2 * time.Second))
_ = upstreamServer.SetDeadline(time.Now().Add(2 * time.Second))
handshakeDone := make(chan error, 1)
go func() {
handshakeDone <- tlsClient.Handshake()
}()
select {
case host := <-hostCh:
if host != "example.com" {
t.Fatalf("unexpected SNI host: %q", host)
}
case <-time.After(2 * time.Second):
t.Fatal("proxy dial was not called")
}
recordHeader := make([]byte, 5)
if _, err := io.ReadFull(upstreamServer, recordHeader); err != nil {
t.Fatalf("read relayed TLS record header: %v", err)
}
if recordHeader[0] != 0x16 {
t.Fatalf("expected relayed TLS handshake record, got %#x", recordHeader[0])
}
_ = client.Close()
_ = upstreamServer.Close()
select {
case <-handshakeDone:
case <-time.After(2 * time.Second):
t.Fatal("tls client handshake did not return after connections closed")
}
}
func waitForHandler(t *testing.T, wg *sync.WaitGroup) {
t.Helper()
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("handler did not exit")
}
}
func generateTLSClientHelloRecord(t *testing.T, serverName string) []byte {
t.Helper()
clientConn, serverConn := net.Pipe()
defer clientConn.Close()
defer serverConn.Close()
errCh := make(chan error, 1)
go func() {
tlsConn := tls.Client(clientConn, &tls.Config{
ServerName: serverName,
InsecureSkipVerify: true,
})
errCh <- tlsConn.Handshake()
_ = tlsConn.Close()
}()
serverConn.SetReadDeadline(time.Now().Add(2 * time.Second))
header := make([]byte, tlsRecordHeaderLen)
if _, err := io.ReadFull(serverConn, header); err != nil {
t.Fatalf("read tls record header: %v", err)
}
recordLen := int(header[3])<<8 | int(header[4])
record := make([]byte, len(header)+recordLen)
copy(record, header)
if _, err := io.ReadFull(serverConn, record[len(header):]); err != nil {
t.Fatalf("read tls record body: %v", err)
}
_ = serverConn.Close()
<-errCh
return record
}
func newTestRouter() *router.Router {
return &router.Router{
BlockRule: router.NewRuleSet("example.com"),
DirectRule: router.NewRuleSet(),
ProxyRule: router.NewRuleSet(),
ProxyDial: func(network, host string, port uint16) (net.Conn, error) {
return nil, router.ErrBlocked
},
}
}
func newTestStats(t *testing.T) *admin.Stats {
t.Helper()
s, err := admin.NewStats()
if err != nil {
t.Fatalf("new stats: %v", err)
}
return s
}
func TestHandleHTTPConnConnectTunnelsToTargetPort(t *testing.T) {
t.Parallel()
downstreamServer, downstreamClient := net.Pipe()
defer downstreamClient.Close()
upstreamClient, upstreamServer := net.Pipe()
defer upstreamServer.Close()
request := []byte("CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n")
tunnelData := []byte("\x16\x03\x01\x00\x05hello")
var (
gotHost string
gotPort uint16
)
r := &router.Router{
BlockRule: router.NewRuleSet("example.com"),
DirectRule: router.NewRuleSet(),
ProxyRule: router.NewRuleSet(),
ProxyDial: func(network, host string, port uint16) (net.Conn, error) {
gotHost = host
gotPort = port
return upstreamClient, nil
},
}
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
handleHTTPConn(downstreamServer, r, newTestStats(t))
}()
downstreamClient.SetWriteDeadline(time.Now().Add(2 * time.Second))
if _, err := downstreamClient.Write(request); err != nil {
t.Fatalf("write connect request: %v", err)
}
// The proxy must acknowledge CONNECT before tunneling.
reply := make([]byte, len("HTTP/1.1 200 Connection established\r\n\r\n"))
downstreamClient.SetReadDeadline(time.Now().Add(2 * time.Second))
if _, err := io.ReadFull(downstreamClient, reply); err != nil {
t.Fatalf("read connect reply: %v", err)
}
if !strings.HasPrefix(string(reply), "HTTP/1.1 200") {
t.Fatalf("unexpected connect reply: %q", string(reply))
}
if _, err := downstreamClient.Write(tunnelData); err != nil {
t.Fatalf("write tunnel data: %v", err)
}
// The request head must be consumed; only raw tunnel data reaches the target.
gotData := make([]byte, len(tunnelData))
upstreamServer.SetReadDeadline(time.Now().Add(2 * time.Second))
if _, err := io.ReadFull(upstreamServer, gotData); err != nil {
t.Fatalf("read upstream data: %v", err)
}
if string(gotData) != string(tunnelData) {
t.Fatalf("unexpected upstream data: %q", gotData)
}
if gotHost != "example.com" || gotPort != 443 {
t.Fatalf("unexpected proxy target: %s:%d", gotHost, gotPort)
}
_ = downstreamClient.Close()
_ = upstreamServer.Close()
waitForHandler(t, &wg)
}
// A refused proxy request must be answered rather than silently closed: only a
// status tells the client that the refusal is policy (this proxy's own
// listener, or a blocked target) and not a broken proxy.
func TestHandleHTTPConnAnswersRefusedSelfTarget(t *testing.T) {
t.Parallel()
downstreamServer, downstreamClient := net.Pipe()
defer downstreamClient.Close()
t.Cleanup(func() { _ = downstreamServer.Close() })
r, err := router.NewRouter(nil, "", "", "", func(_, _ string, _ uint16) (net.Conn, error) {
t.Error("proxy dial reached for a self target")
return nil, nil
})
if err != nil {
t.Fatalf("new router: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
r.SetSelfProxyAddrs("100.64.1.1:80")
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
handleHTTPConn(downstreamServer, r, newTestStats(t))
}()
if _, err := downstreamClient.Write([]byte("GET / HTTP/1.1\r\nHost: 100.64.1.1\r\n\r\n")); err != nil {
t.Fatalf("write downstream request: %v", err)
}
_ = downstreamClient.SetReadDeadline(time.Now().Add(2 * time.Second))
response, err := io.ReadAll(downstreamClient)
if err != nil {
t.Fatalf("read refusal response: %v", err)
}
if !strings.HasPrefix(string(response), "HTTP/1.1 403 Forbidden") {
t.Fatalf("refusal response = %q, want a 403 status", response)
}
waitForHandler(t, &wg)
}
+133
View File
@@ -0,0 +1,133 @@
package main
import (
"strings"
"sync"
"time"
"github.com/sower-proxy/sower/router"
)
const (
// maxRuleHitDomains bounds the domain -> rule resolution cache. The
// cache exists so a hit domain is resolved against the full rule set
// only once; repeats resolve in O(1). When full, the whole cache is
// dropped: resolution cost is amortized and bounded, losing a cache is
// cheaper than an LRU scan on the hot path.
maxRuleHitDomains = 10000
// maxRuleHits bounds the distinct rules retained with hit counts for the
// block set. A pathological stream can hit thousands of wildcard rules;
// the map evicts its least-recently-seen entry once full.
maxRuleHits = 10000
// maxRuleHitsWide bounds retained hit rules for the direct/proxy sets,
// which are far larger and see far more distinct hits than block.
maxRuleHitsWide = 50000
)
// ruleHitTracker counts routing decisions per matched rule of one rule set.
// It resolves the rule for a domain through the rule set's suffix tree —
// once per domain, cached afterwards — so the linear rule scan never runs
// on the connection hot path. Rule mutations invalidate the domain cache
// through Invalidate; hit totals survive rule removal and are reset only
// by restart.
type ruleHitTracker struct {
ruleSet *router.RuleSet
maxHits int
mu sync.Mutex
domains map[string]string // domain -> matched rule
hits map[string]*ruleHit
}
type ruleHit struct {
count uint64
last time.Time
}
// newRuleHitTracker builds a tracker over one rule set. The rule set is
// read under its own lock; the tracker never mutates it.
func newRuleHitTracker(ruleSet *router.RuleSet, maxHits int) *ruleHitTracker {
return &ruleHitTracker{
ruleSet: ruleSet,
maxHits: maxHits,
domains: make(map[string]string),
hits: make(map[string]*ruleHit),
}
}
// OnHit counts one routing decision for the domain, resolving the matched
// rule on first sight of the domain and caching the mapping.
func (t *ruleHitTracker) OnHit(domain string) {
domain = normalizeHitDomain(domain)
if domain == "" {
return
}
t.mu.Lock()
defer t.mu.Unlock()
rule, ok := t.domains[domain]
if !ok {
if len(t.domains) >= maxRuleHitDomains {
t.domains = make(map[string]string)
}
var matched bool
rule, matched = t.ruleSet.MatchRuleFast(domain)
if !matched || rule == "" {
return // rule set changed under us; a later decision will retry
}
t.domains[domain] = rule
}
h := t.hits[rule]
if h == nil {
if len(t.hits) >= t.maxHits {
t.evictOldestHitLocked()
}
h = &ruleHit{}
t.hits[rule] = h
}
h.count++
h.last = time.Now()
}
// evictOldestHitLocked drops the least-recently-seen rule hit when the map
// is full. It scans the map — acceptable here because the eviction happens
// only after maxHits distinct rules have been hit.
func (t *ruleHitTracker) evictOldestHitLocked() {
var oldest string
var oldestAt time.Time
for rule, h := range t.hits {
if oldest == "" || h.last.Before(oldestAt) {
oldest, oldestAt = rule, h.last
}
}
delete(t.hits, oldest)
}
// Invalidate drops the domain -> rule cache after a rule-set mutation, so
// subsequent decisions re-resolve against the updated rule set. Hit counts
// are kept: they describe what has been routed since start.
func (t *ruleHitTracker) Invalidate() {
t.mu.Lock()
defer t.mu.Unlock()
t.domains = make(map[string]string)
}
// Lookup reports the hit count and most recent hit time of one rule; zero
// values when the rule never hit.
func (t *ruleHitTracker) Lookup(rule string) (uint64, time.Time) {
t.mu.Lock()
defer t.mu.Unlock()
if h := t.hits[rule]; h != nil {
return h.count, h.last
}
return 0, time.Time{}
}
// normalizeHitDomain lowercases and strips the trailing dot of a domain.
func normalizeHitDomain(domain string) string {
domain = strings.TrimSpace(domain)
domain = strings.TrimSuffix(domain, ".")
return strings.ToLower(domain)
}
+94
View File
@@ -0,0 +1,94 @@
package main
import (
"fmt"
"testing"
"time"
"github.com/sower-proxy/sower/router"
)
func TestRuleHitTrackerCountsPerRule(t *testing.T) {
rs := router.NewRuleSet("**.ads.example", "tracker.example.net")
tracker := newRuleHitTracker(rs, maxRuleHits)
tracker.OnHit("a.ads.example")
tracker.OnHit("b.ads.example")
tracker.OnHit("b.ads.example")
tracker.OnHit("tracker.example.net")
tracker.OnHit("unmatched.example") // matches no rule, ignored
if count, _ := tracker.Lookup("**.ads.example"); count != 3 {
t.Fatalf("expected 3 hits for **.ads.example, got %d", count)
}
if count, _ := tracker.Lookup("tracker.example.net"); count != 1 {
t.Fatalf("expected 1 hit for tracker.example.net, got %d", count)
}
if count, _ := tracker.Lookup("unmatched.example"); count != 0 {
t.Fatalf("unmatched domain must not be counted, got %d", count)
}
}
func TestRuleHitTrackerLookup(t *testing.T) {
rs := router.NewRuleSet("**.ads.example")
tracker := newRuleHitTracker(rs, maxRuleHits)
if count, last := tracker.Lookup("**.ads.example"); count != 0 || !last.IsZero() {
t.Fatalf("expected zero stat for unknown rule, got %d %v", count, last)
}
tracker.OnHit("a.ads.example")
tracker.OnHit("b.ads.example")
count, last := tracker.Lookup("**.ads.example")
if count != 2 {
t.Fatalf("expected count 2, got %d", count)
}
if last.IsZero() || time.Since(last) > time.Minute {
t.Fatalf("unexpected last seen: %v", last)
}
}
func TestRuleHitTrackerInvalidateReparses(t *testing.T) {
rs := router.NewRuleSet("example.com")
tracker := newRuleHitTracker(rs, maxRuleHits)
tracker.OnHit("evil.example.com") // example.com does not match subdomains
if count, _ := tracker.Lookup("**.example.com"); count != 0 {
t.Fatalf("expected no hits before invalidation, got %d", count)
}
rs.Add("**.example.com")
tracker.Invalidate()
tracker.OnHit("evil.example.com")
if count, _ := tracker.Lookup("**.example.com"); count != 1 {
t.Fatalf("expected 1 hit after invalidation, got %d", count)
}
}
func TestRuleHitTrackerCapEvictsOldest(t *testing.T) {
rs := router.NewRuleSet("fresh.example")
tracker := newRuleHitTracker(rs, maxRuleHits)
now := time.Now()
tracker.mu.Lock()
for i := 0; i < maxRuleHits; i++ {
tracker.hits[fmt.Sprintf("rule-%d", i)] = &ruleHit{count: 1, last: now.Add(time.Duration(i) * time.Nanosecond)}
}
tracker.mu.Unlock()
tracker.OnHit("fresh.example")
tracker.mu.Lock()
n := len(tracker.hits)
_, oldestPresent := tracker.hits["rule-0"]
_, freshPresent := tracker.hits["fresh.example"]
tracker.mu.Unlock()
if n != maxRuleHits {
t.Fatalf("expected %d hits after eviction, got %d", maxRuleHits, n)
}
if oldestPresent || !freshPresent {
t.Fatalf("unexpected eviction: oldest=%v fresh=%v", oldestPresent, freshPresent)
}
}
+158
View File
@@ -0,0 +1,158 @@
package main
import (
"sort"
"sync"
"time"
"github.com/sower-proxy/sower/internal/admin"
)
const (
// maxRuleMissDomains bounds the total distinct domains retained with
// access stats for connections that matched no rule. Detection-based
// and fallback traffic covers most of the long tail, so each shard
// evicts its least-recently-seen entry once full.
maxRuleMissDomains = 50000
// missShards stripes the tracker so the connection hot path (OnMiss)
// never contends on a single global lock. Shards are power-of-two for a
// mask-based index; the read path (Top/Recent) aggregates all shards,
// which is fine because it runs only on admin queries.
missShards = 16
// missShardLimit is the per-shard capacity: total budget spread evenly.
missShardLimit = maxRuleMissDomains / missShards
ruleMissTop = 100
)
type ruleMiss struct {
count uint64
last time.Time
}
// ruleMissTracker counts connections per domain for domains that matched no
// block/direct/proxy rule — detection-based direct or fallback proxy. It is
// fed from the router's rule-miss observer on the connection path. Domains
// are striped across shards by hash so concurrent connections update
// independent maps; each shard holds a short critical section.
type ruleMissTracker struct {
shards [missShards]missShard
}
type missShard struct {
mu sync.Mutex
miss map[string]*ruleMiss
limit int
}
// newRuleMissTracker builds an empty tracker.
func newRuleMissTracker() *ruleMissTracker {
t := &ruleMissTracker{}
for i := range t.shards {
t.shards[i].miss = make(map[string]*ruleMiss)
t.shards[i].limit = missShardLimit
}
return t
}
// missShardIndex maps a domain to its shard with FNV-1a (cheap, no map
// allocation) masked to the power-of-two shard count.
func missShardIndex(domain string) uint32 {
const (
fnvOffset = 2166136261
fnvPrime = 16777619
)
var h uint32 = fnvOffset
for i := 0; i < len(domain); i++ {
h ^= uint32(domain[i])
h *= fnvPrime
}
return h & (missShards - 1)
}
// OnMiss counts one rule-less connection for the domain.
func (t *ruleMissTracker) OnMiss(domain string) {
domain = normalizeHitDomain(domain)
if domain == "" {
return
}
s := &t.shards[missShardIndex(domain)]
s.mu.Lock()
defer s.mu.Unlock()
m := s.miss[domain]
if m == nil {
if len(s.miss) >= s.limit {
s.evictOldestLocked()
}
m = &ruleMiss{}
s.miss[domain] = m
}
m.count++
m.last = time.Now()
}
// evictOldestLocked drops the least-recently-seen domain of one shard when
// it is full. The scan is bounded by the per-shard capacity (~3k), which is
// 16x cheaper than scanning the whole map, and eviction happens only after
// a shard fills up.
func (s *missShard) evictOldestLocked() {
var oldest string
var oldestAt time.Time
for domain, m := range s.miss {
if oldest == "" || m.last.Before(oldestAt) {
oldest, oldestAt = domain, m.last
}
}
delete(s.miss, oldest)
}
// Top returns up to limit domains ordered by connection count, descending,
// with the most recent access as the tiebreak.
func (t *ruleMissTracker) Top(limit int) []admin.RuleHit {
out := t.snapshot()
sort.Slice(out, func(i, j int) bool {
if out[i].Count != out[j].Count {
return out[i].Count > out[j].Count
}
return out[i].LastSeen.After(out[j].LastSeen)
})
return truncateRuleHits(out, limit)
}
// Recent returns up to limit domains ordered by most recent access,
// descending, with the connection count as the tiebreak.
func (t *ruleMissTracker) Recent(limit int) []admin.RuleHit {
out := t.snapshot()
sort.Slice(out, func(i, j int) bool {
if !out[i].LastSeen.Equal(out[j].LastSeen) {
return out[i].LastSeen.After(out[j].LastSeen)
}
return out[i].Count > out[j].Count
})
return truncateRuleHits(out, limit)
}
// snapshot aggregates every shard into one slice under per-shard locks.
func (t *ruleMissTracker) snapshot() []admin.RuleHit {
out := make([]admin.RuleHit, 0, maxRuleMissDomains)
for i := range t.shards {
s := &t.shards[i]
s.mu.Lock()
for domain, m := range s.miss {
out = append(out, admin.RuleHit{Rule: domain, Count: m.count, LastSeen: m.last})
}
s.mu.Unlock()
}
return out
}
func truncateRuleHits(out []admin.RuleHit, limit int) []admin.RuleHit {
if limit <= 0 || limit > len(out) {
limit = len(out)
}
if limit > ruleMissTop {
limit = ruleMissTop
}
return out[:limit]
}
+60
View File
@@ -0,0 +1,60 @@
package main
import (
"fmt"
"testing"
"github.com/sower-proxy/sower/router"
)
// BenchmarkRuleMissOnMiss measures the connection hot-path cost of the
// rule-miss tracker under parallel load — the per-connection lock + map
// update that detection/fallback traffic hits on every connection.
func BenchmarkRuleMissOnMiss(b *testing.B) {
tracker := newRuleMissTracker()
domains := make([]string, 1024)
for i := range domains {
domains[i] = fmt.Sprintf("host%d.sub.example.com", i)
}
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
tracker.OnMiss(domains[i&1023])
i++
}
})
}
// BenchmarkRuleHitOnHit measures the rule-hit hot path for the direct set,
// where a large share of connections match a rule.
func BenchmarkRuleHitOnHit(b *testing.B) {
rs := router.NewRuleSet("**.example.com", "**.cn", "**.intranet")
tracker := newRuleHitTracker(rs, maxRuleHitsWide)
domains := make([]string, 1024)
for i := range domains {
domains[i] = fmt.Sprintf("host%d.example.com", i)
}
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
tracker.OnHit(domains[i&1023])
i++
}
})
}
// BenchmarkRuleMissTop measures the read path of the miss panel (admin
// query), which aggregates and sorts the tracked domains.
func BenchmarkRuleMissTop(b *testing.B) {
tracker := newRuleMissTracker()
for i := 0; i < 30000; i++ {
tracker.OnMiss(fmt.Sprintf("host%d.sub.example.com", i))
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
tracker.Top(20)
}
}
+129
View File
@@ -0,0 +1,129 @@
package main
import (
"fmt"
"sync"
"testing"
"time"
)
func TestRuleMissTrackerCountsPerDomain(t *testing.T) {
tracker := newRuleMissTracker()
tracker.OnMiss("a.example.com")
tracker.OnMiss("b.example.com")
tracker.OnMiss("b.example.com")
tracker.OnMiss("c.example.com")
top := tracker.Top(10)
if len(top) != 3 {
t.Fatalf("expected 3 domains, got %d", len(top))
}
if top[0].Rule != "b.example.com" || top[0].Count != 2 {
t.Fatalf("unexpected top: %+v", top[0])
}
// a and c tie at count 1; the tiebreak is most recent access, so c wins.
if top[1].Rule != "c.example.com" || top[2].Rule != "a.example.com" {
t.Fatalf("unexpected tie order: %+v", top)
}
}
func TestRuleMissTrackerRecent(t *testing.T) {
tracker := newRuleMissTracker()
tracker.OnMiss("first.example")
time.Sleep(2 * time.Millisecond)
tracker.OnMiss("second.example")
tracker.OnMiss("second.example")
recent := tracker.Recent(10)
if len(recent) != 2 {
t.Fatalf("expected 2 domains, got %d", len(recent))
}
if recent[0].Rule != "second.example" {
t.Fatalf("expected most recent first, got %+v", recent[0])
}
if recent[1].Rule != "first.example" {
t.Fatalf("expected older domain second, got %+v", recent[1])
}
}
func TestRuleMissTrackerLimitAndTopCap(t *testing.T) {
tracker := newRuleMissTracker()
for i := 0; i < 5; i++ {
tracker.OnMiss("x.example")
}
tracker.OnMiss("y.example")
if got := len(tracker.Top(1)); got != 1 {
t.Fatalf("expected limit 1, got %d", got)
}
if got := len(tracker.Top(0)); got != 2 {
t.Fatalf("expected unlimited default, got %d", got)
}
}
func TestRuleMissTrackerCapEvictsOldest(t *testing.T) {
tracker := newRuleMissTracker()
// Fill every shard to its limit so the next insert must evict.
now := time.Now()
for i := range tracker.shards {
s := &tracker.shards[i]
s.mu.Lock()
for j := 0; j < s.limit; j++ {
s.miss[fmt.Sprintf("domain-%d-%d.example", i, j)] = &ruleMiss{count: 1, last: now.Add(time.Duration(j) * time.Nanosecond)}
}
s.mu.Unlock()
}
tracker.OnMiss("fresh.example")
idx := missShardIndex("fresh.example")
s := &tracker.shards[idx]
s.mu.Lock()
n := len(s.miss)
_, oldestPresent := s.miss[fmt.Sprintf("domain-%d-0.example", idx)]
_, freshPresent := s.miss["fresh.example"]
s.mu.Unlock()
if n != missShardLimit {
t.Fatalf("expected %d domains after eviction, got %d", missShardLimit, n)
}
if oldestPresent || !freshPresent {
t.Fatalf("unexpected eviction: oldest=%v fresh=%v", oldestPresent, freshPresent)
}
}
func TestRuleMissTrackerConcurrentSnapshot(t *testing.T) {
tracker := newRuleMissTracker()
stop := make(chan struct{})
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
for i := 0; ; i++ {
select {
case <-stop:
return
default:
tracker.OnMiss(fmt.Sprintf("host%d.example.com", i%64))
}
}
}()
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
_ = tracker.Top(10)
_ = tracker.Recent(10)
}
}
}()
time.Sleep(50 * time.Millisecond)
close(stop)
wg.Wait()
}
+246
View File
@@ -0,0 +1,246 @@
package main
import (
"bufio"
"bytes"
"context"
"errors"
"io"
"log/slog"
"net"
"strings"
"sync"
"time"
"github.com/sower-proxy/sower/config"
"github.com/sower-proxy/sower/internal/admin"
"github.com/sower-proxy/sower/router"
)
const (
sharedHeaderMaxBytes = 32 << 10
sharedHeaderTimeout = 5 * time.Second
)
var errHeaderTooLarge = errors.New("request header too large")
// sharedAdminHTTPAddr reports whether the admin console shares the DNS HTTP
// proxy listener on port 80. In shared mode the admin server and the HTTP
// proxy accept connections from the same IP:80 and are distinguished by the
// request head: origin-form requests whose Host equals the listener IP are
// admin traffic; CONNECT, absolute-form requests, and any other Host are
// proxy traffic.
func sharedAdminHTTPAddr(cfg config.SowerConfig) (string, bool) {
if cfg.Admin.Disable || cfg.Admin.Addr == "" {
return "", false
}
if cfg.DNS.Disable || strings.TrimSpace(cfg.DNS.Serve) == "" {
return "", false
}
httpAddr := net.JoinHostPort(cfg.DNS.Serve, "80")
if cfg.Admin.Addr != httpAddr {
return "", false
}
return httpAddr, true
}
// classifyHTTPConn reads the request head from conn and decides whether it is
// an admin console request. The returned connection replays every byte read
// during classification, so the caller can hand it to either handler without
// losing data. A nil connection means the head was malformed, oversized, or
// timed out and the connection was closed.
func classifyHTTPConn(conn net.Conn, adminHost string) (net.Conn, bool) {
_ = conn.SetReadDeadline(time.Now().Add(sharedHeaderTimeout))
br := bufio.NewReaderSize(conn, sharedHeaderMaxBytes)
head, err := readRequestHead(br)
_ = conn.SetReadDeadline(time.Time{})
if err != nil {
slog.Debug("classify http request head", "error", err)
_ = conn.Close()
return nil, false
}
// Replay every byte consumed by the classifier, including bytes already
// buffered inside br beyond the head terminator.
rest, _ := br.Peek(br.Buffered())
prefix := make([]byte, 0, len(head)+len(rest))
prefix = append(prefix, head...)
prefix = append(prefix, rest...)
replayed := &prefixConn{Conn: conn, prefix: bytes.NewReader(prefix)}
return replayed, isAdminRequest(head, adminHost)
}
// readRequestHead reads through the end of the HTTP header block ("\r\n\r\n"),
// bounded by sharedHeaderMaxBytes. Bytes already buffered inside br are not
// consumed from the caller's perspective: the returned head plus br.Buffered()
// covers everything read from the underlying connection.
func readRequestHead(br *bufio.Reader) ([]byte, error) {
var head []byte
for {
line, err := br.ReadSlice('\n')
head = append(head, line...)
if bytes.HasSuffix(head, []byte("\r\n\r\n")) {
return head, nil
}
if err == bufio.ErrBufferFull {
if len(head) >= sharedHeaderMaxBytes {
return nil, errHeaderTooLarge
}
continue
}
if err != nil {
return nil, err
}
if len(head) >= sharedHeaderMaxBytes {
return nil, errHeaderTooLarge
}
}
}
// isAdminRequest reports whether the request head targets the admin console:
// an origin-form request whose Host equals the admin listener IP. CONNECT and
// absolute-form requests are always proxy traffic.
func isAdminRequest(head []byte, adminHost string) bool {
lineEnd := bytes.IndexByte(head, '\n')
if lineEnd < 0 {
return false
}
fields := strings.Fields(string(head[:lineEnd]))
if len(fields) < 2 {
return false
}
method, target := fields[0], fields[1]
if method == "CONNECT" {
return false
}
if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
return false
}
return hostOnly(requestHost(head)) == adminHost
}
// requestHost extracts the Host header value from a request head.
func requestHost(head []byte) string {
idx := bytes.IndexByte(head, '\n')
if idx < 0 {
return ""
}
rest := head[idx+1:]
for len(rest) > 0 {
lineEnd := bytes.IndexByte(rest, '\n')
var line []byte
if lineEnd < 0 {
line = rest
rest = nil
} else {
line = rest[:lineEnd]
rest = rest[lineEnd+1:]
}
line = bytes.TrimRight(line, "\r")
if len(line) == 0 {
break
}
colon := bytes.IndexByte(line, ':')
if colon < 0 {
continue
}
if strings.EqualFold(strings.TrimSpace(string(line[:colon])), "host") {
return strings.TrimSpace(string(line[colon+1:]))
}
}
return ""
}
// prefixConn replays a fixed prefix before reading from the underlying
// connection.
type prefixConn struct {
net.Conn
prefix *bytes.Reader
}
func (c *prefixConn) Read(p []byte) (int, error) {
if c.prefix.Len() > 0 {
return c.prefix.Read(p)
}
return c.Conn.Read(p)
}
// chanListener is a channel-backed net.Listener that hands accepted
// connections to a single consumer (the admin HTTP server).
type chanListener struct {
conns chan net.Conn
closed chan struct{}
once sync.Once
addr net.Addr
}
func newChanListener(addr net.Addr) *chanListener {
return &chanListener{
conns: make(chan net.Conn),
closed: make(chan struct{}),
addr: addr,
}
}
func (l *chanListener) Accept() (net.Conn, error) {
select {
case conn := <-l.conns:
return conn, nil
case <-l.closed:
return nil, net.ErrClosed
}
}
func (l *chanListener) Close() error {
l.once.Do(func() { close(l.closed) })
return nil
}
func (l *chanListener) Addr() net.Addr { return l.addr }
// dispatch hands a classified admin connection to the admin server, closing
// it if the listener is already shut down.
func (l *chanListener) dispatch(conn net.Conn) {
select {
case l.conns <- conn:
case <-l.closed:
_ = conn.Close()
}
}
// ServeSharedHTTP serves the admin console and the HTTP proxy from one
// listener, classifying each connection by its request head. adminHost is the
// normalized listener IP that identifies admin traffic.
func ServeSharedHTTP(ctx context.Context, ln net.Listener, r *router.Router, stats *admin.Stats, srv *admin.Server, adminHost string, errCh chan<- error) error {
adminLn := newChanListener(ln.Addr())
go func() {
if err := srv.Serve(adminLn); err != nil && !errors.Is(err, net.ErrClosed) {
reportServeError(errCh, "admin", err)
}
}()
defer adminLn.Close()
for {
conn, err := ln.Accept()
if err != nil {
if shouldRetryAccept(ctx, "http", err, stats) {
continue
}
return wrapAcceptErr(ctx, "http", err)
}
go func(conn net.Conn) {
replayed, isAdmin := classifyHTTPConn(conn, adminHost)
if replayed == nil {
return
}
if isAdmin {
adminLn.dispatch(replayed)
return
}
handleHTTPConn(replayed, r, stats)
}(conn)
}
}
var _ io.Reader = (*prefixConn)(nil)
+269
View File
@@ -0,0 +1,269 @@
package main
import (
"bufio"
"bytes"
"context"
"errors"
"io"
"net"
"net/http"
"strings"
"testing"
"time"
"github.com/sower-proxy/deferlog/v2"
"github.com/sower-proxy/sower/config"
"github.com/sower-proxy/sower/internal/admin"
)
func TestSharedAdminHTTPAddr(t *testing.T) {
t.Parallel()
base := config.SowerConfig{}
base.DNS.Serve = "127.0.0.1"
base.Admin.Addr = "127.0.0.1:80"
base.Admin.Password = deferlog.NewPassword("secret")
cfg := base
if addr, ok := sharedAdminHTTPAddr(cfg); !ok || addr != "127.0.0.1:80" {
t.Fatalf("expected shared mode, got %q %v", addr, ok)
}
cfg = base
cfg.Admin.Disable = true
if _, ok := sharedAdminHTTPAddr(cfg); ok {
t.Fatal("expected no shared mode when admin disabled")
}
cfg = base
cfg.DNS.Disable = true
if _, ok := sharedAdminHTTPAddr(cfg); ok {
t.Fatal("expected no shared mode when dns disabled")
}
cfg = base
cfg.Admin.Addr = "127.0.0.1:19090"
if _, ok := sharedAdminHTTPAddr(cfg); ok {
t.Fatal("expected no shared mode for a different port")
}
cfg = base
cfg.Admin.Addr = "127.0.0.2:80"
if _, ok := sharedAdminHTTPAddr(cfg); ok {
t.Fatal("expected no shared mode for a different host")
}
}
func TestIsAdminRequest(t *testing.T) {
t.Parallel()
adminHost := "127.0.0.1"
cases := []struct {
name string
head string
want bool
}{
{"origin form local host", "GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", true},
{"origin form local host with port", "GET / HTTP/1.1\r\nHost: 127.0.0.1:80\r\n\r\n", true},
{"post api local host", "POST /api/session HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 0\r\n\r\n", true},
{"origin form other host", "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n", false},
{"absolute form", "GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\n\r\n", false},
{"connect", "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n", false},
{"no host header", "GET / HTTP/1.1\r\n\r\n", false},
{"malformed request line", "GET\r\nHost: 127.0.0.1\r\n\r\n", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isAdminRequest([]byte(tc.head), adminHost); got != tc.want {
t.Fatalf("isAdminRequest = %v, want %v", got, tc.want)
}
})
}
}
func TestReadRequestHead(t *testing.T) {
t.Parallel()
br := bufio.NewReaderSize(bytes.NewReader([]byte("GET / HTTP/1.1\r\nHost: x\r\n\r\nbody")), 1024)
head, err := readRequestHead(br)
if err != nil {
t.Fatalf("read head: %v", err)
}
if string(head) != "GET / HTTP/1.1\r\nHost: x\r\n\r\n" {
t.Fatalf("unexpected head: %q", head)
}
br = bufio.NewReaderSize(bytes.NewReader([]byte(strings.Repeat("a", sharedHeaderMaxBytes+1))), sharedHeaderMaxBytes)
if _, err := readRequestHead(br); !errors.Is(err, errHeaderTooLarge) {
t.Fatalf("expected header too large, got %v", err)
}
br = bufio.NewReaderSize(bytes.NewReader([]byte("GET / HTTP/1.1\r\n")), 1024)
if _, err := readRequestHead(br); err == nil {
t.Fatal("expected error for truncated head")
}
}
func TestPrefixConnReplays(t *testing.T) {
t.Parallel()
server, client := net.Pipe()
defer server.Close()
defer client.Close()
prefix := []byte("GET / HTTP/1.1\r\n\r\n")
pc := &prefixConn{Conn: server, prefix: bytes.NewReader(prefix)}
buf := make([]byte, len(prefix))
if _, err := io.ReadFull(pc, buf); err != nil {
t.Fatalf("read prefix: %v", err)
}
if !bytes.Equal(buf, prefix) {
t.Fatalf("unexpected prefix: %q", buf)
}
go func() {
_, _ = client.Write([]byte("body"))
}()
rest := make([]byte, 4)
if _, err := io.ReadFull(pc, rest); err != nil {
t.Fatalf("read rest: %v", err)
}
if string(rest) != "body" {
t.Fatalf("unexpected rest: %q", rest)
}
}
func TestChanListenerDispatchAndClose(t *testing.T) {
t.Parallel()
addr := &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 80}
l := newChanListener(addr)
if l.Addr() != addr {
t.Fatalf("unexpected addr: %v", l.Addr())
}
server, client := net.Pipe()
defer client.Close()
acceptedCh := make(chan net.Conn, 1)
go func() {
c, _ := l.Accept()
acceptedCh <- c
}()
l.dispatch(server)
accepted := <-acceptedCh
if accepted != server {
t.Fatal("unexpected accepted conn")
}
done := make(chan struct{})
go func() {
_, _ = l.Accept()
close(done)
}()
_ = l.Close()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("accept did not unblock after close")
}
server2, client2 := net.Pipe()
defer client2.Close()
l.dispatch(server2)
_ = server2.SetReadDeadline(time.Now().Add(time.Second))
if _, err := server2.Read(make([]byte, 1)); err == nil {
t.Fatal("expected conn closed after listener close")
}
}
func TestServeSharedHTTPRoutesAdminAndProxy(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
addr := ln.Addr().String()
host, _, _ := net.SplitHostPort(addr)
stats := newTestStats(t)
r := newTestRouter()
state := admin.LoadStateStore("")
baseline := snapshotBaseline(r)
state.SetBaseline(baseline)
srv := admin.NewServer(admin.Options{
Password: "secret",
Version: "v1.2.3",
Date: "2026-01-01",
Rules: newAdminRules(r, state, baseline, newRuleHitTracker(r.BlockRule, maxRuleHits), newRuleHitTracker(r.DirectRule, maxRuleHitsWide), newRuleHitTracker(r.ProxyRule, maxRuleHitsWide), newRuleMissTracker()),
Stats: stats,
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
go func() {
if err := ServeSharedHTTP(ctx, ln, newTestRouter(), stats, srv, host, errCh); err != nil {
t.Errorf("serve shared http: %v", err)
}
}()
client := &http.Client{Timeout: 5 * time.Second}
// admin: GET / with Host = listener IP
resp, err := client.Get("http://" + addr + "/")
if err != nil {
t.Fatalf("admin get: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for admin, got %d", resp.StatusCode)
}
resp.Body.Close()
// admin: POST /api/session with Host = listener IP
loginResp, err := client.Post("http://"+addr+"/api/session", "application/json", strings.NewReader(`{"password":"secret"}`))
if err != nil {
t.Fatalf("admin login: %v", err)
}
if loginResp.StatusCode != http.StatusNoContent {
t.Fatalf("expected 204 for admin login, got %d", loginResp.StatusCode)
}
loginResp.Body.Close()
// proxy: origin-form with another Host -> the proxy path refuses it (the
// router blocks example.com), and the admin console does not answer it.
assertProxyRefusal(t, ln.Addr().String(), "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")
// proxy: CONNECT -> proxy refusal
assertProxyRefusal(t, ln.Addr().String(), "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n")
// proxy: absolute-form -> proxy refusal
assertProxyRefusal(t, ln.Addr().String(), "GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\n\r\n")
}
// assertProxyRefusal drives one proxy-form request through the shared listener
// and requires the proxy path to answer it: the router blocks example.com, so
// the client sees a 403 instead of the admin console's response, and a refusal
// is a status rather than a silent close so a client can tell policy from a
// broken proxy.
func assertProxyRefusal(t *testing.T, addr, request string) {
t.Helper()
conn, err := net.Dial("tcp", addr)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
if _, err := io.WriteString(conn, request); err != nil {
t.Fatalf("write request: %v", err)
}
response, err := io.ReadAll(conn)
if err != nil {
t.Fatalf("read proxy refusal: %v", err)
}
if !strings.HasPrefix(string(response), "HTTP/1.1 403 Forbidden") {
t.Fatalf("proxy refusal = %q, want a 403 status", response)
}
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !unix
package main
import "context"
// dumpHeapOnSignal is a no-op where SIGUSR1 is not defined; the heap profile
// itself (dumpHeapProfile) is portable.
func dumpHeapOnSignal(context.Context, string) {}
+36
View File
@@ -0,0 +1,36 @@
//go:build unix
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"syscall"
)
// dumpHeapOnSignal writes a heap profile per SIGUSR1 into the cache dir, so a
// heap that has grown can be attributed to an allocation site without adding
// a network endpoint ('systemctl kill -s USR1 sowerd'; read it with
// 'go tool pprof -http :0 <file>'). Sending the signal needs root or the
// service's own uid, so the trigger stays local.
func dumpHeapOnSignal(ctx context.Context, dir string) {
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGUSR1)
defer signal.Stop(signals)
for {
select {
case <-ctx.Done():
return
case <-signals:
path, err := dumpHeapProfile(dir)
if err != nil {
slog.Warn("write heap profile", "error", err)
continue
}
slog.Info("heap profile written", "path", path)
}
}
}
+885
View File
@@ -0,0 +1,885 @@
package main
import (
"bufio"
"context"
"crypto/tls"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"os/signal"
"path/filepath"
"runtime/pprof"
"sort"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/cristalhq/aconfig"
"github.com/cristalhq/aconfig/aconfigtoml"
"github.com/lmittmann/tint"
"github.com/sower-proxy/conns/relay"
"github.com/sower-proxy/conns/reread"
"github.com/sower-proxy/deferlog/v2"
"github.com/sower-proxy/sower/config"
"github.com/sower-proxy/sower/internal/install"
transportSower "github.com/sower-proxy/sower/transport/sower"
"golang.org/x/crypto/acme/autocert"
)
const (
httpShutdownTimeout = 5 * time.Second
reverseProxyReadHeaderTimeout = 10 * time.Second
reverseProxyIdleTimeout = 30 * time.Second
upstreamDialTimeout = 10 * time.Second
upstreamResponseTimeout = 30 * time.Second
systemCacheDir = "/var/cache/sower"
// Timeouts for the plain-HTTP listener, which only redirects to HTTPS and
// answers ACME HTTP-01 challenges. Without them a peer that opens a
// connection and never finishes a request keeps a goroutine, its socket
// and its buffers for as long as it stays connected, which is an unbounded
// hold on an internet-facing port.
plainHTTPReadHeaderTimeout = 10 * time.Second
plainHTTPReadTimeout = 10 * time.Second
plainHTTPWriteTimeout = 10 * time.Second
plainHTTPIdleTimeout = 30 * time.Second
// maxInboundConns caps how many connections sowerd serves at once. Each
// one costs a goroutine stack, TLS record buffers and, while it is
// relayed, up to 64 KiB of copy buffers, so an uncapped accept loop turns
// a burst of connections (a scan wave, a reconnect storm, peers that
// stopped reading) into resident memory proportional to the arrival rate.
// At the measured per-connection cost this cap bounds that memory to a few
// hundred MiB, and refusing a connection is cheaper than answering one.
maxInboundConns = 2048
// writeStallTimeout bounds a single write that makes no progress. The
// relay library only arms read deadlines, so a peer that stops reading
// blocks its write forever: both copy directions then wait on each other,
// wg.Wait never returns and the connection pair, its goroutines and its
// file descriptors leak for the process's lifetime. A stalled relayed
// chunk is at most the copy buffer (64 KiB), so five minutes is far beyond
// any live link while still reclaiming a dead one.
writeStallTimeout = 5 * time.Minute
)
var (
version, date string
)
func init() {
setLogger(slog.LevelInfo)
}
func main() {
if hasInstallFlag(os.Args[1:]) {
if err := install.InstallService(stdinConfirm); err != nil {
slog.Error("install service", "error", err)
os.Exit(1)
}
return
}
conf, err := loadConfig()
if err != nil {
slog.Error("load config", "error", err)
os.Exit(1)
}
setLogger(conf.LogLevel)
slog.Info("load config",
"version", version,
"date", date,
"config", sanitizeConfig(conf))
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := run(ctx, conf); err != nil {
slog.Error("sowerd exited", "error", err)
os.Exit(1)
}
}
func loadConfig() (config.SowerdConfig, error) {
var conf config.SowerdConfig
if err := aconfig.LoaderFor(&conf, aconfig.Config{
AllowUnknownFlags: false,
AllowUnknownFields: false,
FileFlag: "c",
Files: []string{
"sowerd.toml",
"config/sowerd.toml",
"/etc/sower/sowerd.toml",
},
FileDecoders: map[string]aconfig.FileDecoder{
".toml": aconfigtoml.New(),
},
}).Load(); err != nil {
return config.SowerdConfig{}, err
}
if err := conf.Validate(); err != nil {
return config.SowerdConfig{}, err
}
return conf, nil
}
func run(ctx context.Context, conf config.SowerdConfig) error {
cacheDir, err := cacheDir()
if err != nil {
return err
}
certManager, tlsConf, err := buildTLSConfig(cacheDir, conf)
if err != nil {
return err
}
go dumpHeapOnSignal(ctx, cacheDir)
fakeSite, dirServer, err := prepareFakeSite(conf.FakeSite, conf.ServeIP)
if err != nil {
return err
}
siteRouter := newSiteRouter(conf.SiteRoutes)
httpAddr := net.JoinHostPort(conf.ServeIP, "80")
httpServer := newRedirectServer(httpAddr, certManager.HTTPHandler(fakeSiteHandler(dirServer)))
httpErrCh := make(chan error, 1)
go func() {
err := httpServer.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
httpErrCh <- fmt.Errorf("listen http %s: %w", httpAddr, err)
}
close(httpErrCh)
}()
httpsAddr := net.JoinHostPort(conf.ServeIP, "443")
rawLn, err := net.Listen("tcp", httpsAddr)
if err != nil {
_ = shutdownHTTP(context.Background(), httpServer)
return fmt.Errorf("listen https %s: %w", httpsAddr, err)
}
// The stall wrapper sits below the TLS listener so that every write on the
// connection is bounded: handshake, proxied response, relayed or hijacked
// tunnel. Idle tunnels issue no writes and stay untouched.
ln := tls.NewListener(newWriteStallListener(rawLn, writeStallTimeout), tlsConf)
defer ln.Close()
protocolHandlers := []proxyProtocolHandler{
newSowerProtocolHandler(transportSower.New(conf.Password)),
}
httpsErrCh := make(chan error, 1)
go func() {
httpsErrCh <- serve443(ctx, ln, fakeSite, siteRouter, protocolHandlers, newConnLimiter(maxInboundConns))
close(httpsErrCh)
}()
select {
case err := <-httpErrCh:
if err != nil {
_ = ln.Close()
return err
}
case err := <-httpsErrCh:
if err != nil {
_ = shutdownHTTP(context.Background(), httpServer)
return err
}
case <-ctx.Done():
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), httpShutdownTimeout)
defer cancel()
_ = ln.Close()
if err := shutdownHTTP(shutdownCtx, httpServer); err != nil {
return err
}
return nil
}
func cacheDir() (string, error) {
dir, fallbackErr := resolveCacheDir(os.UserCacheDir, systemCacheDir)
if fallbackErr != nil {
slog.Warn("user cache dir unavailable, fallback to system cache dir",
"error", fallbackErr,
"dir", dir)
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", fmt.Errorf("create cache dir %s: %w", dir, err)
}
return dir, nil
}
func resolveCacheDir(userCacheDir func() (string, error), fallbackDir string) (string, error) {
base, err := userCacheDir()
if err != nil {
return fallbackDir, err
}
return filepath.Join(base, "sower"), nil
}
func buildTLSConfig(cacheDir string, cfg config.SowerdConfig) (*autocert.Manager, *tls.Config, error) {
certManager := &autocert.Manager{
Prompt: autocert.AcceptTOS,
Email: cfg.Cert.Email,
Cache: autocert.DirCache(cacheDir),
// Only issue for names the operator owns: every site-routed domain
// plus the explicit cert.domains whitelist. A nil HostPolicy lets
// anyone trigger issuance for arbitrary SNI names, exhausting the
// account's order/rate limits and polluting the cache.
HostPolicy: autocert.HostWhitelist(certIssueDomains(cfg)...),
}
tlsConf := &tls.Config{
GetCertificate: certManager.GetCertificate,
MinVersion: tls.VersionTLS12,
NextProtos: []string{"http/1.1"},
}
if cfg.Cert.Cert == "" {
tlsConf.GetCertificate = getCertificateWithFallback(certManager, cfg)
return certManager, tlsConf, nil
}
cert, err := tls.LoadX509KeyPair(cfg.Cert.Cert, cfg.Cert.Key)
if err != nil {
return nil, nil, fmt.Errorf("load cert pair: %w", err)
}
tlsConf.GetCertificate = nil
tlsConf.Certificates = []tls.Certificate{cert}
return certManager, tlsConf, nil
}
// getCertificateWithFallback answers the TLS handshake for names outside the
// issuance whitelist instead of failing it. Real sites present their default
// vhost certificate for arbitrary SNI, so a handshake failure on unknown
// names is itself an active-probing signal. Whitelisted names keep the plain
// autocert path: their failures (ACME issuance, cache) surface as-is and are
// never masked by a wrong-name certificate.
func getCertificateWithFallback(m *autocert.Manager, cfg config.SowerdConfig) func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
domains := certIssueDomains(cfg)
whitelist := make(map[string]struct{}, len(domains))
for _, d := range domains {
whitelist[strings.ToLower(d)] = struct{}{}
}
return func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
if _, ok := whitelist[strings.ToLower(hello.ServerName)]; ok {
return m.GetCertificate(hello)
}
if len(domains) == 0 {
return m.GetCertificate(hello)
}
// Carry the client's capabilities over so autocert derives the same
// cache key (ECDSA vs RSA) it would for a direct visit; a bare hello
// would count as non-ECDSA and force a needless RSA issuance.
fallbackHello := *hello
fallbackHello.ServerName = domains[0]
return m.GetCertificate(&fallbackHello)
}
}
// certIssueDomains collects the deduplicated domain whitelist for autocert
// issuance: every site_routes domain plus cert.domains.
func certIssueDomains(cfg config.SowerdConfig) []string {
seen := make(map[string]struct{}, len(cfg.Cert.Domains))
domains := make([]string, 0, len(cfg.Cert.Domains))
add := func(d string) {
d = strings.ToLower(d)
if _, ok := seen[d]; ok {
return
}
seen[d] = struct{}{}
domains = append(domains, d)
}
for _, d := range cfg.Cert.Domains {
add(d)
}
for _, route := range cfg.SiteRoutes {
for _, d := range route.Domains {
add(d)
}
}
return domains
}
func prepareFakeSite(fakeSite string, serveIP string) (string, http.Handler, error) {
si, err := os.Stat(fakeSite)
if err == nil && si.IsDir() {
slog.Info("serve fake site on http", "dir", fakeSite)
// The directory fake site is served by the loopback HTTP server.
// Prefer a concrete serve_ip as the relay target (its :80 listener
// exists); otherwise fall back to the loopback address.
target := "127.0.0.1:80"
if ip := net.ParseIP(serveIP); ip != nil && !ip.IsUnspecified() {
target = net.JoinHostPort(serveIP, "80")
}
return target, http.FileServer(http.Dir(fakeSite)), nil
}
if err != nil && !os.IsNotExist(err) {
return "", nil, fmt.Errorf("stat fake site: %w", err)
}
return fakeSite, nil, nil
}
func fakeSiteHandler(dirServer http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if dirServer != nil && isLocalRemoteAddr(r.RemoteAddr) {
dirServer.ServeHTTP(w, r)
return
}
target := "https://" + r.Host + r.URL.RequestURI()
http.Redirect(w, r, target, http.StatusPermanentRedirect)
})
}
// newRedirectServer builds the plain-HTTP listener. Every timeout is set:
// this listener answers the whole internet and only ever serves a redirect or
// an ACME challenge, so a connection that is slow, idle or half-sent has
// nothing to wait for and must not be able to pin its goroutine and socket.
func newRedirectServer(addr string, handler http.Handler) *http.Server {
return &http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: plainHTTPReadHeaderTimeout,
ReadTimeout: plainHTTPReadTimeout,
WriteTimeout: plainHTTPWriteTimeout,
IdleTimeout: plainHTTPIdleTimeout,
}
}
// connLimiter is a counting semaphore over served connections. A connection
// that finds no slot is closed instead of queued: queueing would keep the
// memory the cap exists to bound.
type connLimiter struct {
slots chan struct{}
}
func newConnLimiter(n int) *connLimiter {
return &connLimiter{slots: make(chan struct{}, n)}
}
func (l *connLimiter) acquire() bool {
select {
case l.slots <- struct{}{}:
return true
default:
return false
}
}
func (l *connLimiter) release() { <-l.slots }
// writeStallListener arms a write deadline on every accepted connection, so a
// write that a peer never accepts fails instead of blocking the writer
// forever. It wraps the raw listener so the TLS layer - and therefore every
// path above it - is covered.
type writeStallListener struct {
net.Listener
stall time.Duration
}
func newWriteStallListener(ln net.Listener, stall time.Duration) *writeStallListener {
return &writeStallListener{Listener: ln, stall: stall}
}
func (l *writeStallListener) Accept() (net.Conn, error) {
conn, err := l.Listener.Accept()
if err != nil {
return nil, err
}
return &writeStallConn{Conn: conn, stall: l.stall}, nil
}
type writeStallConn struct {
net.Conn
stall time.Duration
}
func (c *writeStallConn) Write(b []byte) (int, error) {
if err := c.Conn.SetWriteDeadline(time.Now().Add(c.stall)); err != nil {
return 0, err
}
return c.Conn.Write(b)
}
func dumpHeapProfile(dir string) (string, error) {
path := filepath.Join(dir, fmt.Sprintf("sowerd-heap-%d.pprof", time.Now().Unix()))
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return "", fmt.Errorf("create heap profile %s: %w", path, err)
}
defer f.Close()
if err := pprof.WriteHeapProfile(f); err != nil {
return "", fmt.Errorf("write heap profile %s: %w", path, err)
}
return path, nil
}
func serve443(ctx context.Context, ln net.Listener, fakeSite string, router siteRouter, handlers []proxyProtocolHandler, limiter *connLimiter) error {
for {
conn, err := ln.Accept()
if err != nil {
if ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
return nil
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Temporary() {
slog.Warn("temporary accept error", "error", err)
time.Sleep(100 * time.Millisecond)
continue
}
return fmt.Errorf("accept tls connection: %w", err)
}
if !limiter.acquire() {
slog.Debug("connection limit reached, dropping", "remote", conn.RemoteAddr(), "limit", maxInboundConns)
_ = conn.Close()
continue
}
go func() {
defer limiter.release()
handleConn(conn, fakeSite, router, handlers)
}()
}
}
func handleConn(conn net.Conn, fakeSite string, router siteRouter, handlers []proxyProtocolHandler) {
rereadConn := reread.New(conn)
var hijacked atomic.Bool
defer func() {
if !hijacked.Load() {
rereadConn.Close()
}
}()
// Complete the TLS handshake explicitly before probing: on first
// contact the handshake can take tens of seconds while GetCertificate
// drives ACME issuance, and the short probe deadline must not race it
// (the first connection to a new domain would otherwise always fail).
if tlsConn, ok := conn.(*tls.Conn); ok {
handshakeCtx, cancel := context.WithTimeout(context.Background(), tlsHandshakeTimeout)
if err := tlsConn.HandshakeContext(handshakeCtx); err != nil {
cancel()
return
}
cancel()
}
_ = rereadConn.SetReadDeadline(time.Now().Add(protocolProbeTimeout))
var (
addr net.Addr
dur time.Duration
err error
)
defer func() {
deferlog.DebugWarn(err, "relay conn", "took", dur, "addr", addr)
}()
rereadConn.Reread()
probeBuf, err := readProtocolProbe(rereadConn)
if err != nil {
return
}
for _, handler := range handlers {
switch handler.Probe(probeBuf) {
case probeNoMatch, probeNeedMore:
continue
case probeMatch:
rereadConn.Reread()
// Bound the header read with its own (longer than the probe)
// deadline: the header follows the TLS handshake immediately,
// but a slow client needs more than the 1s probe window, and an
// attacker that sends only the 0x80 probe byte must not hold
// the connection (and its goroutine) forever.
_ = rereadConn.SetReadDeadline(time.Now().Add(protocolHeaderTimeout))
if addr, err = handler.Unwrap(rereadConn); err == nil {
_ = rereadConn.SetReadDeadline(time.Time{})
rereadConn.Stop()
dur, err = relay.RelayTo(rereadConn, addr.String())
return
}
slog.Debug("protocol auth or decode failed, fallback", "protocol", handler.Name(), "error", err)
_ = rereadConn.SetReadDeadline(time.Time{})
rereadConn.Stop().Reread()
dur, err = fallbackConn(rereadConn, conn, fakeSite, router, &hijacked, probeBuf)
return
}
}
probeBuf = extendHTTPRequestProbe(rereadConn, probeBuf)
rereadConn.Stop().Reread()
_ = rereadConn.SetReadDeadline(time.Time{})
dur, err = fallbackConn(rereadConn, conn, fakeSite, router, &hijacked, probeBuf)
}
func fallbackConn(conn net.Conn, tlsConn net.Conn, fakeSite string, router siteRouter, hijacked *atomic.Bool, probe []byte) (time.Duration, error) {
start := time.Now()
if entry := router.lookup(sniFromConn(tlsConn)); entry != nil {
return time.Since(start), reverseProxyConn(conn, entry, hijacked)
}
if httpRequestLineProbe(probe) {
return time.Since(start), reverseProxyConn(conn, newFakeSiteEntry(fakeSite), hijacked)
}
return relay.RelayTo(conn, fakeSite)
}
func newFakeSiteEntry(fakeSite string) *siteEntry {
return &siteEntry{
upstream: &url.URL{Scheme: "http", Host: fakeSite},
preserveHost: true,
}
}
func sanitizeConfig(cfg config.SowerdConfig) map[string]any {
return map[string]any{
"log_level": cfg.LogLevel.String(),
"serve_ip": cfg.ServeIP,
"fake_site": cfg.FakeSite,
"site_routes": len(cfg.SiteRoutes),
"cert": map[string]any{
"email": cfg.Cert.Email,
"cert_config": cfg.Cert.Cert != "",
"key_config": cfg.Cert.Key != "",
},
}
}
func hasInstallFlag(args []string) bool {
for _, arg := range args {
switch arg {
case "-i", "--install":
return true
}
}
return false
}
func stdinConfirm(label string) bool {
fmt.Printf("%s [y/N]: ", label)
scanner := bufio.NewScanner(os.Stdin)
if !scanner.Scan() {
return false
}
answer := strings.TrimSpace(strings.ToLower(scanner.Text()))
return answer == "y" || answer == "yes"
}
// isLocalRemoteAddr reports whether the connection originated on this host:
// loopback or an address bound to a local interface. Directory fake sites
// relay fallback traffic from this process; the relay's source address
// follows the target (loopback for the default 0.0.0.0 serve_ip, the
// serve_ip itself for a concrete bind), so a loopback-only check would
// wrongly redirect concrete serve_ip deployments.
func isLocalRemoteAddr(remoteAddr string) bool {
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
host = remoteAddr
}
host = strings.Trim(host, "[]")
ip := net.ParseIP(host)
if ip == nil {
return false
}
if ip.IsLoopback() {
return true
}
addrs, err := net.InterfaceAddrs()
if err != nil {
return false
}
for _, a := range addrs {
if ipn, ok := a.(*net.IPNet); ok && ipn.IP.Equal(ip) {
return true
}
}
return false
}
func setLogger(level slog.Level) {
fi, err := os.Stdout.Stat()
noColor := err != nil || (fi.Mode()&os.ModeCharDevice) == 0
deferlog.SetDefault(slog.New(tint.NewHandler(os.Stdout, &tint.Options{
AddSource: true,
NoColor: noColor,
Level: level,
})))
}
func shutdownHTTP(ctx context.Context, server *http.Server) error {
if err := server.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("shutdown http server: %w", err)
}
return nil
}
// pathRoute maps a URL path prefix to an upstream URL, overriding the site
// entry default. Paths are normalized without a trailing slash and matched
// longest-prefix first.
type pathRoute struct {
path string
upstream *url.URL
}
// siteEntry is the routing target for one site route: a default upstream plus
// optional path-prefix overrides and an optional visitor gate.
type siteEntry struct {
upstream *url.URL
paths []pathRoute
basicAuth *siteBasicAuth // nil when the route is not gated
preserveHost bool // forward the client's Host: site routes opt in, fake-site fallback always does
}
// resolve returns the upstream URL for the given request path. A configured
// root path "/" matches every request, acting as a catch-all override.
func (e *siteEntry) resolve(path string) *url.URL {
for _, pr := range e.paths {
if pr.path == "/" || path == pr.path || strings.HasPrefix(path, pr.path+"/") {
return pr.upstream
}
}
return e.upstream
}
// siteRouter maps TLS SNI to the site entry for fallback traffic.
type siteRouter struct {
routes map[string]*siteEntry
}
func newSiteRouter(routes []config.SiteRoute) siteRouter {
m := make(map[string]*siteEntry)
// One throttle for every gated route: a visitor guessing at one route must
// not get a fresh budget by moving to another.
throttle := newAuthThrottle()
for _, r := range routes {
u, _ := url.Parse(r.Upstream) // validated in config.Validate
entry := &siteEntry{upstream: u, basicAuth: newSiteBasicAuth(r.Auth, throttle), preserveHost: r.PreserveHost()}
for path, upstream := range r.Routes {
pu, _ := url.Parse(upstream) // validated in config.Validate
entry.paths = append(entry.paths, pathRoute{
path: normalizeRoutePath(path),
upstream: pu,
})
}
sort.Slice(entry.paths, func(i, j int) bool {
return len(entry.paths[i].path) > len(entry.paths[j].path)
})
for _, d := range r.Domains {
m[strings.ToLower(d)] = entry
}
}
return siteRouter{routes: m}
}
// normalizeRoutePath trims a single trailing slash so that "/a" and "/a/"
// route identically; "/" is kept as-is.
func normalizeRoutePath(path string) string {
if path != "/" {
path = strings.TrimSuffix(path, "/")
}
return path
}
// lookup returns the site entry for the given SNI, or nil if no route matches.
func (r siteRouter) lookup(sni string) *siteEntry {
return r.routes[strings.ToLower(sni)]
}
// sniFromConn extracts the TLS SNI from a *tls.Conn.
func sniFromConn(conn net.Conn) string {
tlsConn, ok := conn.(*tls.Conn)
if !ok {
return ""
}
return tlsConn.ConnectionState().ServerName
}
func stripResidualForwardingHeaders(h http.Header) {
h.Del("X-Real-IP")
for k := range h {
if strings.HasPrefix(k, "X-Forwarded-") {
h.Del(k)
}
}
}
// setEdgeProxyHeaders overwrites forwarding headers from the TLS-edge peer.
// ReverseProxy.Rewrite already deleted Forwarded / X-Forwarded-For /
// X-Forwarded-Host / X-Forwarded-Proto and does not auto-populate them.
// X-Forwarded-Proto is https because this path is the 443 listener after TLS
// termination (req.TLS is nil on the decrypted conn).
func setEdgeProxyHeaders(req *http.Request, forwardedHost, remoteAddr string) {
if forwardedHost != "" {
req.Header.Set("X-Forwarded-Host", forwardedHost)
}
req.Header.Set("X-Forwarded-Proto", "https")
if clientIP, _, err := net.SplitHostPort(remoteAddr); err == nil {
req.Header.Set("X-Forwarded-For", clientIP)
req.Header.Set("X-Real-IP", clientIP)
}
}
// proxyErrorHandler replaces the default ReverseProxy error handler, which
// only logs a bare "http: proxy error: <err>". With site routes and path
// overrides the routing context is what identifies which upstream failed
// (the SNI-matched route host, the request path and the upstream URL).
func proxyErrorHandler(upstream *url.URL) func(http.ResponseWriter, *http.Request, error) {
return func(w http.ResponseWriter, req *http.Request, err error) {
slog.Warn("proxy upstream error",
"error", err,
// Redacted: the upstream URL can carry Basic credentials in its
// userinfo, which must not reach the log.
"upstream", upstream.Redacted(),
"method", req.Method,
"host", req.Host,
"path", req.URL.Path,
"remote", req.RemoteAddr)
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte("bad gateway\n"))
}
}
// reverseProxyConn serves the decrypted HTTP connection through a reverse
// proxy to the upstream selected per request by the site entry's path routes.
// Reverse proxies and their transports are created lazily per upstream and
// closed together when the connection ends.
func reverseProxyConn(conn net.Conn, entry *siteEntry, hijacked *atomic.Bool) error {
proxies := make(map[string]*httputil.ReverseProxy, 1+len(entry.paths))
transports := make([]*http.Transport, 0, 1+len(entry.paths))
proxyFor := func(upstream *url.URL) *httputil.ReverseProxy {
key := upstream.String()
if proxy, ok := proxies[key]; ok {
return proxy
}
proxy := &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(upstream)
if entry.preserveHost {
pr.Out.Host = pr.In.Host
} else {
pr.Out.Host = upstream.Host
}
stripResidualForwardingHeaders(pr.Out.Header)
setEdgeProxyHeaders(pr.Out, pr.In.Host, pr.In.RemoteAddr)
if upstream.User != nil {
// net/http applies URL userinfo as Basic credentials only in
// http.Client; ReverseProxy round-trips the request through a
// Transport, which drops them. Configured upstream credentials
// are explicit, so they override any inbound Authorization.
password, _ := upstream.User.Password()
pr.Out.Header.Set("Authorization", basicAuthHeader(upstream.User.Username(), password))
}
},
ErrorHandler: proxyErrorHandler(upstream),
}
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: upstreamDialTimeout,
KeepAlive: upstreamResponseTimeout,
}).DialContext,
TLSHandshakeTimeout: upstreamDialTimeout,
ResponseHeaderTimeout: upstreamResponseTimeout,
IdleConnTimeout: reverseProxyIdleTimeout,
ExpectContinueTimeout: time.Second,
}
transports = append(transports, transport)
proxy.Transport = transport
proxy.ErrorLog = slog.NewLogLogger(slog.Default().Handler(), slog.LevelWarn)
proxies[key] = proxy
return proxy
}
defer func() {
for _, t := range transports {
t.CloseIdleConnections()
}
}()
handler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
// The gate covers the whole route, including its path overrides and
// WebSocket upgrades: it runs before request dispatch to any upstream.
if entry.basicAuth != nil && !entry.basicAuth.guard(w, req) {
return
}
proxyFor(entry.resolve(req.URL.Path)).ServeHTTP(w, req)
})
ln := newSingleConnListener(conn)
srv := &http.Server{
Handler: handler,
ReadHeaderTimeout: reverseProxyReadHeaderTimeout,
IdleTimeout: reverseProxyIdleTimeout,
ConnState: func(_ net.Conn, state http.ConnState) {
switch state {
case http.StateHijacked:
hijacked.Store(true)
_ = ln.Close()
case http.StateClosed:
_ = ln.Close()
}
},
}
if err := srv.Serve(ln); err != nil && !errors.Is(err, net.ErrClosed) && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("serve reverse proxy connection: %w", err)
}
return nil
}
// singleConnListener wraps a single net.Conn as a net.Listener.
// Accept returns the conn once, then blocks until Close is called.
type singleConnListener struct {
conn net.Conn
once bool
closed chan struct{}
closeOnce sync.Once
}
func newSingleConnListener(conn net.Conn) *singleConnListener {
return &singleConnListener{
conn: conn,
closed: make(chan struct{}),
}
}
func (l *singleConnListener) Accept() (net.Conn, error) {
if !l.once {
l.once = true
return l.conn, nil
}
<-l.closed
return nil, net.ErrClosed
}
func (l *singleConnListener) Close() error {
l.closeOnce.Do(func() {
close(l.closed)
})
return nil
}
func (l *singleConnListener) Addr() net.Addr { return l.conn.LocalAddr() }
File diff suppressed because it is too large Load Diff
+173
View File
@@ -0,0 +1,173 @@
package main
import (
"bytes"
"fmt"
"net"
"time"
transportSower "github.com/sower-proxy/sower/transport/sower"
)
const (
protocolProbeTimeout = time.Second
protocolProbeMaxBytes = 512
// protocolHeaderTimeout bounds reading the transport header after a
// probe match; the probe window itself is too short for a slow client.
protocolHeaderTimeout = 5 * time.Second
// tlsHandshakeTimeout bounds the explicit TLS handshake before probing;
// ACME issuance on first contact can take tens of seconds.
tlsHandshakeTimeout = 60 * time.Second
)
type probeVerdict int
const (
probeNoMatch probeVerdict = iota
probeNeedMore
probeMatch
)
type proxyProtocolHandler interface {
Name() string
Probe(buf []byte) probeVerdict
Unwrap(conn net.Conn) (net.Addr, error)
}
type sowerProtocolHandler struct {
transport *transportSower.Sower
}
func newSowerProtocolHandler(transport *transportSower.Sower) sowerProtocolHandler {
return sowerProtocolHandler{transport: transport}
}
func (h sowerProtocolHandler) Name() string { return "sower" }
func (h sowerProtocolHandler) Probe(buf []byte) probeVerdict {
if len(buf) == 0 {
return probeNeedMore
}
if buf[0] != 0x80 {
return probeNoMatch
}
return probeMatch
}
func (h sowerProtocolHandler) Unwrap(conn net.Conn) (net.Addr, error) {
return h.transport.Unwrap(conn)
}
func httpRequestLine(buf []byte) (line []byte, complete bool) {
i := bytes.IndexByte(buf, '\n')
if i < 0 {
return buf, false
}
line = buf[:i]
if len(line) > 0 && line[len(line)-1] == '\r' {
line = line[:len(line)-1]
}
return line, true
}
func httpRequestLineProbe(buf []byte) bool {
line, _ := httpRequestLine(buf)
method, rest, ok := bytes.Cut(line, []byte{' '})
if !ok || !isExactHTTPMethod(method) {
return false
}
target, version, ok := bytes.Cut(rest, []byte{' '})
if !ok || len(target) == 0 {
return false
}
version = bytes.TrimSuffix(version, []byte{'\r'})
if bytes.IndexByte(version, ' ') >= 0 {
return false
}
return string(version) == "HTTP/1.1" || string(version) == "HTTP/1.0"
}
func isExactHTTPMethod(m []byte) bool {
switch string(m) {
case "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE", "CONNECT":
return true
default:
return false
}
}
func isHTTPMethodPrefix(b []byte) bool {
if len(b) == 0 {
return true
}
for _, method := range []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE", "CONNECT"} {
if bytes.HasPrefix([]byte(method), b) {
return true
}
}
return false
}
func isHTTPVersionPrefix(b []byte) bool {
b = bytes.TrimSuffix(b, []byte{'\r'})
return bytes.HasPrefix([]byte("HTTP/1.1"), b) || bytes.HasPrefix([]byte("HTTP/1.0"), b)
}
func couldBeHTTPRequestLine(buf []byte) bool {
line, complete := httpRequestLine(buf)
if complete {
return false
}
method, rest, hasSpace := bytes.Cut(line, []byte{' '})
if !hasSpace {
return isHTTPMethodPrefix(method)
}
if !isExactHTTPMethod(method) {
return false
}
_, version, hasVersion := bytes.Cut(rest, []byte{' '})
if !hasVersion {
return true
}
if bytes.IndexByte(version, ' ') >= 0 {
return false
}
return isHTTPVersionPrefix(version)
}
func extendHTTPRequestProbe(conn net.Conn, probe []byte) []byte {
if httpRequestLineProbe(probe) || !couldBeHTTPRequestLine(probe) {
return probe
}
buf := make([]byte, protocolProbeMaxBytes)
n := copy(buf, probe)
for n < protocolProbeMaxBytes {
nr, err := conn.Read(buf[n:])
if nr > 0 {
n += nr
if httpRequestLineProbe(buf[:n]) || !couldBeHTTPRequestLine(buf[:n]) {
return buf[:n]
}
continue
}
if err != nil {
return buf[:n]
}
}
return buf[:n]
}
func readProtocolProbe(conn net.Conn) ([]byte, error) {
buf := make([]byte, protocolProbeMaxBytes)
for {
n, err := conn.Read(buf)
if n > 0 {
return buf[:n], nil
}
if err != nil {
return nil, fmt.Errorf("read probe bytes: %w", err)
}
// n == 0 && err == nil is legal (though rare) on TCP; retry instead
// of tearing down the connection.
}
}
+153
View File
@@ -0,0 +1,153 @@
package main
import (
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"log/slog"
"net"
"net/http"
"sync"
"time"
"github.com/sower-proxy/sower/config"
)
// Visitor gate tuning. The throttle mirrors internal/admin's login throttle:
// failed attempts from one source IP accumulate (a passing request clears
// them) and lock that source out for a backoff window, and the failure map is
// bounded so source-IP rotation cannot grow it without bound. Without it a
// dictionary attack finishes in seconds, which would make the gate decorative.
const (
authMaxFails = 5
authLockDuration = 15 * time.Minute
authMaxEntries = 4096
)
// siteBasicAuth gates a site route behind HTTP Basic credentials. It exists
// for upstreams that cannot authenticate their own visitors: the route
// domains are published in Certificate Transparency logs and the edge answers
// any SNI, so the upstream is reachable by anyone who reads those logs.
type siteBasicAuth struct {
username string
password string
throttle *authThrottle
}
// newSiteBasicAuth builds the gate for one route, or nil when the route has no
// credentials configured.
func newSiteBasicAuth(conf config.SiteRouteAuth, throttle *authThrottle) *siteBasicAuth {
if conf.Username == "" && conf.Password == "" {
return nil
}
return &siteBasicAuth{username: conf.Username, password: conf.Password, throttle: throttle}
}
// guard reports whether the request passes the gate, writing the rejection
// response itself when it does not. A passing request has its Authorization
// header removed: the edge credential authenticates the visitor to the edge,
// not to the upstream, so forwarding it would leak the edge password into
// upstream logs and would collide with an upstream realm that carries the
// same name.
func (a *siteBasicAuth) guard(w http.ResponseWriter, r *http.Request) bool {
ip := peerIP(r.RemoteAddr)
if !a.throttle.allow(ip) {
slog.Debug("site route auth locked", "host", r.Host, "remote", ip)
http.Error(w, "too many attempts", http.StatusTooManyRequests)
return false
}
user, pass, ok := r.BasicAuth()
userOK := secureEqual(user, a.username)
passOK := secureEqual(pass, a.password)
if !ok || !userOK || !passOK {
a.throttle.fail(ip)
slog.Debug("site route auth rejected", "host", r.Host, "remote", ip)
w.Header().Set("WWW-Authenticate", `Basic realm="restricted"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return false
}
a.throttle.success(ip)
r.Header.Del("Authorization")
return true
}
// secureEqual compares two secrets in constant time regardless of length.
func secureEqual(a, b string) bool {
ha := sha256.Sum256([]byte(a))
hb := sha256.Sum256([]byte(b))
return subtle.ConstantTimeCompare(ha[:], hb[:]) == 1
}
// peerIP extracts the host part of a remote address for throttling. The TLS
// edge sees the direct peer, so the address is not attacker-controlled.
func peerIP(remoteAddr string) string {
if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
return host
}
return remoteAddr
}
// basicAuthHeader renders the Authorization value for the upstream
// credentials configured as URL userinfo on an upstream.
func basicAuthHeader(username, password string) string {
return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password))
}
type authFail struct {
count int
until time.Time
}
// authThrottle bounds visitor password guessing per source IP. A lockout only
// ever affects the IP that caused it: there is no shared account state, and
// TCP requires a completed handshake, so a third party cannot lock anyone
// else out by spoofing their address.
type authThrottle struct {
mu sync.Mutex
fails map[string]authFail
}
func newAuthThrottle() *authThrottle {
return &authThrottle{fails: make(map[string]authFail)}
}
func (t *authThrottle) allow(ip string) bool {
t.mu.Lock()
defer t.mu.Unlock()
f, ok := t.fails[ip]
return !ok || time.Now().After(f.until)
}
func (t *authThrottle) fail(ip string) {
t.mu.Lock()
defer t.mu.Unlock()
now := time.Now()
// Bound the map: when full, drop entries whose lock window already
// expired. If it is still full, stop tracking the new IP (it cannot be
// locked, but the map stays bounded).
if len(t.fails) >= authMaxEntries {
for k, f := range t.fails {
if now.After(f.until) {
delete(t.fails, k)
}
}
}
if len(t.fails) >= authMaxEntries {
return
}
f := t.fails[ip]
f.count++
if f.count >= authMaxFails {
f.until = now.Add(authLockDuration)
f.count = 0
}
t.fails[ip] = f
}
func (t *authThrottle) success(ip string) {
t.mu.Lock()
defer t.mu.Unlock()
delete(t.fails, ip)
}
+106
View File
@@ -0,0 +1,106 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/sower-proxy/sower/config"
)
func newGatedRequest(ip, user, password string) *http.Request {
req := httptest.NewRequest(http.MethodGet, "https://gated.example.com/", nil)
req.RemoteAddr = ip
if user != "" || password != "" {
req.SetBasicAuth(user, password)
}
return req
}
func TestSiteBasicAuthGuard(t *testing.T) {
t.Parallel()
tests := []struct {
name string
user string
password string
wantPass bool
wantCode int
}{
{name: "valid credentials", user: "alice", password: "s3cret", wantPass: true},
{name: "wrong password", user: "alice", password: "wrong", wantCode: http.StatusUnauthorized},
{name: "wrong user", user: "bob", password: "s3cret", wantCode: http.StatusUnauthorized},
{name: "missing credentials", wantCode: http.StatusUnauthorized},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
gate := newSiteBasicAuth(config.SiteRouteAuth{Username: "alice", Password: "s3cret"}, newAuthThrottle())
if gate == nil {
t.Fatal("configured credentials must build a gate")
}
req := newGatedRequest("203.0.113.9:1234", tt.user, tt.password)
rec := httptest.NewRecorder()
if got := gate.guard(rec, req); got != tt.wantPass {
t.Fatalf("guard = %v, want %v (status %d)", got, tt.wantPass, rec.Code)
}
if tt.wantPass {
if req.Header.Get("Authorization") != "" {
t.Fatalf("Authorization survived a passed gate: %q", req.Header.Get("Authorization"))
}
return
}
if rec.Code != tt.wantCode {
t.Fatalf("status = %d, want %d", rec.Code, tt.wantCode)
}
if got := rec.Header().Get("WWW-Authenticate"); got != `Basic realm="restricted"` {
t.Fatalf("WWW-Authenticate = %q, want a Basic challenge", got)
}
})
}
}
func TestSiteBasicAuthDisabledWithoutCredentials(t *testing.T) {
t.Parallel()
if gate := newSiteBasicAuth(config.SiteRouteAuth{}, newAuthThrottle()); gate != nil {
t.Fatal("empty credentials must leave the route ungated")
}
}
func TestSiteBasicAuthGuardLocksFailingSourceIP(t *testing.T) {
t.Parallel()
gate := newSiteBasicAuth(config.SiteRouteAuth{Username: "alice", Password: "s3cret"}, newAuthThrottle())
const lockedIP = "203.0.113.9:1234"
for i := 0; i < authMaxFails; i++ {
rec := httptest.NewRecorder()
req := newGatedRequest(lockedIP, "alice", "wrong")
if gate.guard(rec, req) {
t.Fatalf("attempt %d passed the gate", i)
}
if rec.Code != http.StatusUnauthorized {
t.Fatalf("attempt %d status = %d, want %d", i, rec.Code, http.StatusUnauthorized)
}
}
// The lockout rejects even the correct password: that is the point.
rec := httptest.NewRecorder()
if gate.guard(rec, newGatedRequest(lockedIP, "alice", "s3cret")) {
t.Fatal("locked source passed the gate")
}
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusTooManyRequests)
}
// A lockout is scoped to the source that earned it.
other := httptest.NewRecorder()
if !gate.guard(other, newGatedRequest("198.51.100.7:5678", "alice", "s3cret")) {
t.Fatalf("unrelated source was rejected with status %d", other.Code)
}
}
-124
View File
@@ -1,124 +0,0 @@
package conf
import (
"context"
"flag"
"os"
"os/exec"
"runtime"
"strconv"
"sync"
"time"
"github.com/golang/glog"
toml "github.com/pelletier/go-toml"
"github.com/wweir/sower/util"
)
// Conf define the config items
var Conf = struct {
ConfigFile string
NetType string `toml:"net_type"`
Cipher string `toml:"cipher"`
Password string `toml:"password"`
ServerPort string `toml:"server_port"`
ServerAddr string `toml:"server_addr"`
HTTPProxy string `toml:"http_proxy"`
DNSServer string `toml:"dns_server"`
ClientIP string `toml:"client_ip"`
ClearDNSCache string `toml:"clear_dns_cache"`
BlockList []string `toml:"blocklist"`
WhiteList []string `toml:"whitelist"`
Suggestions []string `toml:"suggestions"`
Verbose int `toml:"verbose"`
}{}
// OnRefreash will be executed while init and write new config
var OnRefreash = []func() error{
func() (err error) {
f, err := os.OpenFile(Conf.ConfigFile, os.O_RDONLY, 0644)
if err != nil {
return err
}
defer f.Close()
//safe refresh config
file := Conf.ConfigFile
if err = toml.NewDecoder(f).Decode(&Conf); err != nil {
return err
}
Conf.ConfigFile = file
return flag.Set("v", strconv.Itoa(Conf.Verbose))
},
func() error {
if Conf.ClearDNSCache != "" {
ctx, cancel := context.WithTimeout(context.TODO(), 5*time.Second)
defer cancel()
switch runtime.GOOS {
case "windows":
return exec.CommandContext(ctx, "cmd", "/c", Conf.ClearDNSCache).Run()
default:
return exec.CommandContext(ctx, "sh", "-c", Conf.ClearDNSCache).Run()
}
}
return nil
},
}
func init() {
initArgs()
if _, err := os.Stat(Conf.ConfigFile); os.IsNotExist(err) {
glog.Warningln("no config file has been load:", Conf.ConfigFile)
return
}
for i := range OnRefreash {
if err := OnRefreash[i](); err != nil {
glog.Fatalln(err)
}
}
}
// mu keep synchronized add rule(write), do not care read while write
var mu = &sync.Mutex{}
// AddSuggestion add new domain into suggest rules
func AddSuggestion(domain string) {
mu.Lock()
defer mu.Unlock()
Conf.Suggestions = append(Conf.Suggestions, domain)
Conf.Suggestions = util.NewReverseSecSlice(Conf.Suggestions).Sort().Uniq()
{ // safe write
f, err := os.OpenFile(Conf.ConfigFile+"~", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
glog.Errorln(err)
return
}
if err := toml.NewEncoder(f).ArraysWithOneElementPerLine(true).Encode(Conf); err != nil {
glog.Errorln(err)
f.Close()
return
}
f.Close()
if err = os.Rename(Conf.ConfigFile+"~", Conf.ConfigFile); err != nil {
glog.Errorln(err)
return
}
}
// reload config
for i := range OnRefreash {
if err := OnRefreash[i](); err != nil {
glog.Errorln(err)
}
}
}
-26
View File
@@ -1,26 +0,0 @@
// +build !windows
package conf
import (
"flag"
"os"
"path/filepath"
)
func initArgs() {
flag.StringVar(&Conf.ConfigFile, "f", filepath.Dir(os.Args[0])+"/sower.toml", "config file location")
flag.StringVar(&Conf.NetType, "n", "TCP", "proxy net type (QUIC|KCP|TCP)")
flag.StringVar(&Conf.Cipher, "C", "AES_128_GCM", "cipher type (AES_128_GCM|AES_192_GCM|AES_256_GCM|CHACHA20_IETF_POLY1305|XCHACHA20_IETF_POLY1305)")
flag.StringVar(&Conf.Password, "p", "12345678", "password")
flag.StringVar(&Conf.ServerPort, "P", "5533", "server mode listen port")
flag.StringVar(&Conf.ServerAddr, "s", "", "server IP (run in client mode if set)")
flag.StringVar(&Conf.HTTPProxy, "H", "", "http proxy listen addr")
flag.StringVar(&Conf.DNSServer, "d", "114.114.114.114", "client dns server")
flag.StringVar(&Conf.ClientIP, "c", "127.0.0.1", "client dns service redirect IP")
if !flag.Parsed() {
flag.Set("logtostderr", "true")
flag.Parse()
}
}
-145
View File
@@ -1,145 +0,0 @@
// +build windows
package conf
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/golang/glog"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/eventlog"
"golang.org/x/sys/windows/svc/mgr"
)
const name = "sower"
const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown | svc.AcceptPauseAndContinue
func initArgs() {
flag.StringVar(&Conf.ConfigFile, "f", filepath.Dir(os.Args[0])+"/sower.toml", "config file location")
install := flag.Bool("install", false, "install sower as a service")
uninstall := flag.Bool("uninstall", false, "uninstall sower from service list")
if !flag.Parsed() {
flag.Set("log_dir", filepath.Dir(os.Args[0]))
flag.Parse()
}
switch {
case *install:
mgrDo(func(m *mgr.Mgr) error {
s, err := m.OpenService(name)
if err == nil {
s.Close()
return fmt.Errorf("service %s already exists", name)
}
s, err = m.CreateService(name, os.Args[0], mgr.Config{
DisplayName: "Sower Proxy",
StartType: windows.SERVICE_AUTO_START,
})
if err != nil {
return err
}
defer s.Close()
err = eventlog.InstallAsEventCreate(name, eventlog.Error|eventlog.Warning|eventlog.Info)
if err != nil {
s.Delete()
return fmt.Errorf("SetupEventLogSource() failed: %s", err)
}
return s.Start()
})
os.Exit(0)
case *uninstall:
serviceDo(func(s *mgr.Service) error {
err := s.Delete()
if err != nil {
return err
}
return eventlog.Remove(name)
})
os.Exit(0)
default:
os.Chdir(filepath.Dir(os.Args[0]))
if active, err := svc.IsAnInteractiveSession(); err != nil {
glog.Exitf("failed to determine if we are running in an interactive session: %v", err)
} else if !active {
go func() {
elog, err := eventlog.Open(name)
if err != nil {
glog.Exitln(err)
}
defer elog.Close()
if err := svc.Run(name, &myservice{}); err != nil {
elog.Error(1, fmt.Sprintf("%s service failed: %v", name, err))
glog.Exitln(err)
}
elog.Info(1, fmt.Sprintf("winsvc.RunAsService: %s service stopped", name))
os.Exit(0)
}()
}
}
}
func serviceDo(fn func(*mgr.Service) error) {
mgrDo(func(m *mgr.Mgr) error {
s, err := m.OpenService(name)
if err != nil {
return fmt.Errorf("could not access service: %v", err)
}
defer s.Close()
return fn(s)
})
}
func mgrDo(fn func(m *mgr.Mgr) error) {
m, err := mgr.Connect()
if err != nil {
glog.Exitln(err)
}
defer m.Disconnect()
if err := fn(m); err != nil {
glog.Fatalln(err)
}
}
type myservice struct{}
func (m *myservice) Execute(args []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (ssec bool, errno uint32) {
elog, err := eventlog.Open(name)
if err != nil {
glog.Errorln(err)
return
}
defer elog.Close()
elog.Info(1, strings.Join(args, "-"))
changes <- svc.Status{State: svc.StartPending}
changes <- svc.Status{State: svc.Running, Accepts: cmdsAccepted}
for {
c := <-r
switch c.Cmd {
case svc.Interrogate:
changes <- c.CurrentStatus
// Testing deadlock from https://code.google.com/p/winsvc/issues/detail?id=4
time.Sleep(100 * time.Millisecond)
changes <- c.CurrentStatus
case svc.Stop, svc.Shutdown:
changes <- svc.Status{State: svc.StopPending}
return
case svc.Pause:
changes <- svc.Status{State: svc.Paused, Accepts: cmdsAccepted}
case svc.Continue:
changes <- svc.Status{State: svc.Running, Accepts: cmdsAccepted}
default:
elog.Error(1, fmt.Sprintf("unexpected control request #%d", c))
}
}
}
-49
View File
@@ -1,49 +0,0 @@
net_type="TCP"
cipher="AES_128_GCM"
password="12345678"
server_port="5533"
# server_addr="remote-server:5533" # replce it to remote server
http_proxy=":8080" # eg: 192.168.0.2:8080
dns_server="223.5.5.5" # Keep empty for dynamic setting from net env
client_ip="127.0.0.1" # listen the IP, dns target is the IP
# clear_dns_cache="pkill mDNSResponder"
blocklist=[
"**.google.*", # google
"**.goo.gl",
"**.googleusercontent.com",
"**.googleapis.com",
"*.googlesource.com",
"**.youtube.com", # youtube
"**.ytimg.com",
"**.ggpht.com",
"**.googlevideo.com",
"**.facebook.com", # facebook
"**.fbcdn.net",
"**.twitter.com", # twitter
"**.twimg.com",
"**.blogspot.com", # blogspot
"**.appspot.com",
"**.wikipedia.org", # wikipeida
"*.cloudfront.net",
"**.amazon.com",
"**.amazonaws.com",
"*.githubusercontent.com",
"*.github.*",
]
whitelist=[
"iamp.*.*",
"iamp.*.*.*",
"smtp.*.*",
"smtp.*.*.*",
"pop.*.*",
"pop.*.*.*",
"imap-mail.outlook.com",
"**.qq.com",
"**.baidu.com",
"*.aliyun.com",
"**.cn",
"**.icloud.com",
"**.163.com",
"**.weiyun.com",
]
verbose=0
+188
View File
@@ -0,0 +1,188 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/cristalhq/aconfig"
"github.com/cristalhq/aconfig/aconfigtoml"
)
func TestLoadExampleSowerConfigFiles(t *testing.T) {
t.Parallel()
tests := []struct {
name string
file string
}{
{name: "toml", file: "sower.toml"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var cfg SowerConfig
if err := loadConfigFileForTest(tt.file, &cfg); err != nil {
t.Fatalf("load config file: %v", err)
}
if err := cfg.Validate(); err != nil {
t.Fatalf("validate config: %v", err)
}
if cfg.Remote.TLS.ServerName != "" {
t.Fatalf("unexpected remote tls server name: %q", cfg.Remote.TLS.ServerName)
}
if cfg.DNS.Serve != "127.0.0.1" {
t.Fatalf("unexpected dns serve: %q", cfg.DNS.Serve)
}
if cfg.DNS.ServeIface != "" {
t.Fatalf("unexpected dns serve iface: %q", cfg.DNS.ServeIface)
}
if cfg.Socks5.Addr != "127.0.0.1:1080" {
t.Fatalf("unexpected socks5 addr: %q", cfg.Socks5.Addr)
}
if cfg.Router.Block.FilePrefix != "**." {
t.Fatalf("unexpected block file prefix: %q", cfg.Router.Block.FilePrefix)
}
})
}
}
func TestLoadExampleSowerdConfigFiles(t *testing.T) {
t.Parallel()
tests := []struct {
name string
content string
ext string
wantRoutes int
}{
{
name: "embedded toml",
content: strings.ReplaceAll(
ExampleSowerdConfigTOML,
`fake_site = "/var/www"`,
`fake_site = "127.0.0.1:80"`,
),
ext: ".toml",
},
{
name: "file toml",
content: strings.ReplaceAll(
mustReadFileForTest(t, "sowerd.toml"),
`fake_site = "/var/www"`,
`fake_site = "127.0.0.1:80"`,
),
ext: ".toml",
},
{
name: "active site routes toml",
content: `log_level = "info"
serve_ip = "0.0.0.0"
password = "change_me"
fake_site = "127.0.0.1:80"
[[site_routes]]
domains = ["a.example.com", "b.example.com"]
upstream = "http://127.0.0.1:8080"
[[site_routes]]
domains = ["c.example.com"]
upstream = "https://backend.example.com"
`,
ext: ".toml",
wantRoutes: 2,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "sowerd"+tt.ext)
if err := os.WriteFile(path, []byte(tt.content), 0o644); err != nil {
t.Fatalf("write temp config: %v", err)
}
var cfg SowerdConfig
if err := loadConfigFileForTest(path, &cfg); err != nil {
t.Fatalf("load config file: %v", err)
}
if err := cfg.Validate(); err != nil {
t.Fatalf("validate config: %v", err)
}
if cfg.ServeIP != "0.0.0.0" {
t.Fatalf("unexpected serve ip: %q", cfg.ServeIP)
}
if cfg.FakeSite != "127.0.0.1:80" {
t.Fatalf("unexpected fake site: %q", cfg.FakeSite)
}
if len(cfg.SiteRoutes) != tt.wantRoutes {
t.Fatalf("unexpected site route count: %d", len(cfg.SiteRoutes))
}
})
}
}
// TestExampleSowerdConfigTOMLMatchesFile pins the embedded example to the
// checked-in file byte for byte. They are the same artifact in two places:
// the constant is what the installer writes to /etc/sower/sowerd.toml, and
// the file is what `-c config/sowerd.toml` loads during development. Let
// them drift and operators get guidance that contradicts shipped behavior.
func TestExampleSowerdConfigTOMLMatchesFile(t *testing.T) {
t.Parallel()
file := mustReadFileForTest(t, "sowerd.toml")
if ExampleSowerdConfigTOML != file {
t.Fatalf("ExampleSowerdConfigTOML and config/sowerd.toml have drifted:\n%s",
exampleConfigDiff(ExampleSowerdConfigTOML, file))
}
}
func exampleConfigDiff(embedded, file string) string {
embeddedLines := strings.Split(strings.TrimRight(embedded, "\n"), "\n")
fileLines := strings.Split(strings.TrimRight(file, "\n"), "\n")
var b strings.Builder
fmt.Fprintf(&b, " const has %d lines, file has %d lines\n", len(embeddedLines), len(fileLines))
for i := 0; i < len(embeddedLines) || i < len(fileLines); i++ {
var e, f string
if i < len(embeddedLines) {
e = embeddedLines[i]
}
if i < len(fileLines) {
f = fileLines[i]
}
if e != f {
fmt.Fprintf(&b, " line %d\n const: %q\n file: %q\n", i+1, e, f)
}
}
return b.String()
}
func loadConfigFileForTest(path string, dst any) error {
return aconfig.LoaderFor(dst, aconfig.Config{
SkipEnv: true,
SkipFlags: true,
AllowUnknownFields: false,
Files: []string{path},
FileDecoders: map[string]aconfig.FileDecoder{
".toml": aconfigtoml.New(),
},
}).Load()
}
func mustReadFileForTest(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read file %s: %v", path, err)
}
return string(data)
}
+268
View File
@@ -0,0 +1,268 @@
package config
import (
"fmt"
"log/slog"
"net"
"strings"
"github.com/sower-proxy/deferlog/v2"
"github.com/sower-proxy/sower/pkg/upstreamtls"
)
type RemoteTLSConfig struct {
ServerName string `usage:"override upstream TLS server name (SNI)"`
ClientHello string `usage:"uTLS client hello: chrome, firefox, ios, android, edge, safari, 360, qq, randomized, randomized_alpn, randomized_no_alpn, golang"`
InsecureSkipVerify bool `default:"false" usage:"skip upstream TLS certificate verification"`
}
type RemoteConfig struct {
Type string `default:"sower" required:"true" usage:"option: sower/socks5"`
Addr string `required:"true" usage:"proxy address, eg: proxy.com or proxy.com:443"`
// Password is sent verbatim to the upstream proxy. It stays a plain
// string (not deferlog.Password): the base64-or-plain heuristic in
// deferlog.Password would silently corrupt passwords that happen to be
// valid canonical base64, breaking auth against servers that compare
// byte-for-byte.
Password string `usage:"remote proxy password"`
TLS RemoteTLSConfig `flag:"tls"`
}
// SowerConfig represents the configuration for sower client
type SowerConfig struct {
LogLevel slog.Level `default:"info" usage:"log level: debug, info, warn, error"`
// IgnoreAdminState skips the admin state file at startup. It is the
// escape hatch when a persisted override breaks the service.
IgnoreAdminState bool `flag:"ignore-admin-state" usage:"ignore the admin state file at startup"`
Remote RemoteConfig
DNS struct {
Disable bool `default:"false" usage:"disable DNS proxy"`
Serve string `usage:"dns server ip"`
Serve6 string `usage:"dns server ipv6, eg: ::1"`
ServeIface string `usage:"use the IP in the net interface, if serve ip not setted. eg: eth0"`
Upstream string `usage:"upstream dns server"`
Fallback string `default:"223.5.5.5" required:"true" usage:"fallback dns server"`
// Reverse is the DNS server used to resolve client IPs to hostnames
// for the traffic console. Typically the local LAN resolver (e.g.
// dnsmasq) so LAN leases and tailnet names resolve; empty disables
// reverse lookups and the console shows raw IPs.
Reverse string `usage:"reverse dns server for client hostname lookup"`
}
Socks5 struct {
Disable bool `default:"false" usage:"disable sock5 proxy"`
Addr string `default:"127.0.0.1:1080" usage:"socks5 listen address"`
} `flag:"socks5"`
Admin struct {
Disable bool `default:"true" usage:"disable admin web server"`
Addr string `default:"127.0.0.1:19090" usage:"admin web server listen address"`
// Admin console password. Accepts plain text verbatim, or canonical
// base64 with an explicit "b64:" prefix (e.g. "b64:c2VjcmV0"). Plain
// values are never reinterpreted, so a password that happens to be
// valid base64 stays exactly as written.
Password deferlog.Password `usage:"admin web server password; empty generates a random one at startup"`
SessionFile string `default:"/etc/sower/sessions.json" usage:"persist admin sessions to this file; one sower process only"`
DisableSessionPersistence bool `default:"false" usage:"disable admin session persistence"`
CookieSecure bool `default:"false" usage:"set Secure on admin session cookies behind a TLS-terminating proxy"`
StateFile string `default:"/etc/sower/admin-state.json" usage:"persist admin rule and config changes to this file, empty disables persistence"`
} `flag:"admin"`
Router struct {
Block struct {
File string `usage:"block list file, local file or remote"`
FilePrefix string `default:"**." usage:"parsed as '<prefix>line_text'"`
FileSkipRules []string `usage:"rules to skip when loading block list file"`
Rules []string `usage:"block list rules"`
}
Direct struct {
File string `usage:"direct list file, local file or remote"`
FilePrefix string `default:"**." usage:"parsed as '<prefix>line_text'"`
FileSkipRules []string `usage:"rules to skip when loading direct list file"`
Rules []string `usage:"direct list rules"`
}
Proxy struct {
File string `usage:"proxy list file, local file or remote"`
FilePrefix string `default:"**." usage:"parsed as '<prefix>line_text'"`
FileSkipRules []string `usage:"rules to skip when loading proxy list file"`
Rules []string `usage:"proxy list rules"`
}
Country struct {
MMDB string `usage:"mmdb file"`
File string `usage:"CIDR block list file, local file or remote"`
FilePrefix string `default:"" usage:"parsed as '<prefix>line_text'"`
Rules []string `usage:"CIDR list rules"`
}
}
}
// AdminSessionFile returns an empty path only when persistence is explicitly
// disabled. The session file is not safe to share across multiple processes.
func (c SowerConfig) AdminSessionFile() string {
if c.Admin.DisableSessionPersistence {
return ""
}
return c.Admin.SessionFile
}
// Validate implements the validation interface for SowerConfig
func (c *SowerConfig) Validate() error {
switch c.Remote.Type {
case "sower", "socks5":
default:
return fmt.Errorf("unsupported remote type %q", c.Remote.Type)
}
if c.Remote.Type == "socks5" && c.Remote.Password != "" {
// The SOCKS5 client transport only negotiates no-auth (RFC 1928);
// a configured password would be silently ignored and the upstream
// handshake would fail only when it demands authentication.
return fmt.Errorf("remote password is not supported for socks5 upstreams; remove remote.password or use remote.type = \"sower\"")
}
remoteHost, err := validateRemoteAddr(c.Remote.Type, c.Remote.Addr)
if err != nil {
return err
}
if c.Remote.TLS.ClientHello != "" {
if err := upstreamtls.ValidateClientHello(c.Remote.TLS.ClientHello); err != nil {
return err
}
}
if c.DNS.ServeIface != "" {
iface, err := net.InterfaceByName(c.DNS.ServeIface)
if err != nil {
return fmt.Errorf("get interface: %w", err)
}
addrs, err := iface.Addrs()
if err != nil {
return fmt.Errorf("get interface addresses: %w", err)
}
for _, addr := range addrs {
ip, _, err := net.ParseCIDR(addr.String())
if err != nil {
return fmt.Errorf("parse interface IP: %w", err)
}
if ip.To4() != nil { // ipv4
if c.DNS.Serve == "" {
c.DNS.Serve = ip.String()
}
} else if ip.IsGlobalUnicast() { // ipv6 must be global unicast
if c.DNS.Serve6 == "" {
c.DNS.Serve6 = ip.String()
}
}
}
}
if err := validateOptionalIP("dns serve", c.DNS.Serve); err != nil {
return err
}
if err := validateOptionalIP("dns serve6", c.DNS.Serve6); err != nil {
return err
}
if err := validateOptionalIP("dns upstream", c.DNS.Upstream); err != nil {
return err
}
if err := validateRequiredIP("dns fallback", c.DNS.Fallback); err != nil {
return err
}
if err := validateOptionalIP("dns reverse", c.DNS.Reverse); err != nil {
return err
}
if !c.DNS.Disable && c.DNS.Serve == "" {
return fmt.Errorf("dns serve ip and serve interface not set")
}
if !c.Socks5.Disable {
if _, _, err := net.SplitHostPort(c.Socks5.Addr); err != nil {
return fmt.Errorf("invalid socks5 listen address %q: %w", c.Socks5.Addr, err)
}
}
if !c.Admin.Disable && c.Admin.Addr != "" {
host, port, err := net.SplitHostPort(c.Admin.Addr)
if err != nil || host == "" {
return fmt.Errorf("invalid admin listen address %q", c.Admin.Addr)
}
// The admin console can share the DNS HTTP proxy listener on port 80
// (admin.addr == dns.serve:80), but it cannot share the HTTPS or DNS
// listeners, which speak different protocols.
if !c.DNS.Disable && c.DNS.Serve != "" && host == c.DNS.Serve {
switch port {
case "443", "53":
return fmt.Errorf("admin listen address %q conflicts with the %s proxy listener on %s", c.Admin.Addr, port, c.DNS.Serve)
}
}
}
c.Router.Direct.Rules = append(c.Router.Direct.Rules,
remoteHost, "**.in-addr.arpa", "**.ip6.arpa")
return nil
}
func validateRemoteAddr(remoteType, addr string) (string, error) {
switch remoteType {
case "socks5":
host, _, err := net.SplitHostPort(addr)
if err != nil {
return "", fmt.Errorf("invalid remote socks5 address %q: %w", addr, err)
}
if host == "" {
return "", fmt.Errorf("invalid remote socks5 address %q", addr)
}
return host, nil
default:
host, _, err := splitRemoteAddr(addr, "443")
if err != nil {
return "", fmt.Errorf("invalid remote tls address %q: %w", addr, err)
}
return host, nil
}
}
func splitRemoteAddr(addr, defaultPort string) (string, string, error) {
if addr == "" {
return "", "", fmt.Errorf("empty address")
}
if host, port, err := net.SplitHostPort(addr); err == nil {
if host == "" || port == "" {
return "", "", fmt.Errorf("invalid address %q", addr)
}
return host, port, nil
}
if strings.HasPrefix(addr, "[") || strings.HasSuffix(addr, "]") {
return "", "", fmt.Errorf("invalid address %q", addr)
}
// A bare IPv6 literal is a valid host with the default port. Anything
// else that still contains a colon is a malformed host:port (e.g. a
// bare IPv6 with a port, which needs [addr]:port) or a garbage string.
if ip := net.ParseIP(addr); ip != nil {
return addr, defaultPort, nil
}
if strings.Contains(addr, ":") {
return "", "", fmt.Errorf("invalid address %q: IPv6 with a port must use [addr]:port", addr)
}
return addr, defaultPort, nil
}
func validateOptionalIP(name, value string) error {
if value == "" {
return nil
}
return validateRequiredIP(name, value)
}
func validateRequiredIP(name, value string) error {
if net.ParseIP(value) == nil {
return fmt.Errorf("invalid %s %q", name, value)
}
return nil
}
+87
View File
@@ -0,0 +1,87 @@
# Sower configuration example (TOML format)
# This is the client configuration for the sower proxy
#
# The binary caps its Go heap with a 512 MiB soft memory limit by default; that
# limit has to stay well above the ~105MB live rule heap, because a limit at
# that level pins the GC goal to the live heap and burns CPU on every
# allocation. Override it with SOWER_MEMORY_LIMIT_MB in the service environment
# (0 disables the soft limit), not with a TOML field.
# Logging configuration
log_level = "info" # Log level: debug, info, warn, error
# Remote proxy configuration
[remote]
type = "sower" # Proxy type: sower or socks5
addr = "proxy.example.com" # Proxy server address, optional port for TLS remotes
password = "your_secure_password" # Proxy password
[remote.tls]
server_name = "" # Override upstream TLS server name (SNI)
client_hello = "" # Optional uTLS fingerprint: chrome, firefox, ios, android, edge, safari, 360, qq, randomized, randomized_alpn, randomized_no_alpn, golang
insecure_skip_verify = false # Skip upstream TLS certificate verification
# DNS configuration
[dns]
disable = false # Disable DNS proxy
serve = "127.0.0.1" # DNS server IP address
serve_6 = "" # DNS server IPv6 address (optional)
serve_iface = "" # Network interface to get IP from (optional)
upstream = "8.8.8.8" # Upstream DNS server (optional)
fallback = "223.5.5.5" # Fallback DNS server
reverse = "" # Reverse DNS for client hostnames in the console (optional, e.g. local dnsmasq)
# SOCKS5 proxy configuration
# aconfig maps Socks5 -> socks_5 for file keys.
[socks_5]
disable = false # Disable SOCKS5 proxy
addr = "127.0.0.1:1080" # SOCKS5 listen address
# Admin web server configuration
# Serves the embedded admin console for runtime rule management and traffic monitoring.
# Disabled by default; when enabled, set a password or one is generated at startup
# and printed to the log.
#
# Standalone mode (default): admin listens on its own address, e.g. 127.0.0.1:19090.
# Shared mode: set addr to the DNS HTTP proxy address (dns.serve:80) to serve the
# admin console and the HTTP proxy from one listener. Requests whose Host equals
# the listener IP are admin traffic; CONNECT, absolute-form, and other Hosts are
# proxy traffic. The HTTPS (443) and DNS (53) listeners cannot be shared.
[admin]
disable = true # Disable admin web server
addr = "127.0.0.1:19090" # Admin web server listen address
password = "your_admin_password" # Admin web server password; empty = random at startup
session_file = "/etc/sower/sessions.json" # Persist admin sessions for one sower process only
disable_session_persistence = false # Set true to disable session persistence
cookie_secure = false # Set true only when a TLS-terminating proxy protects this origin
state_file = "/etc/sower/admin-state.json" # Persist admin rule/config changes
# Router configuration
[router]
# Block list rules
[router.block]
file = "" # Block list file path (local or remote)
file_prefix = "**." # Prefix for block list rules
file_skip_rules = [] # Rules to skip when loading the block list file
rules = [] # Additional block rules
# Direct list rules
[router.direct]
file = "" # Direct list file path (local or remote)
file_prefix = "**." # Prefix for direct list rules
file_skip_rules = [] # Rules to skip when loading the direct list file
rules = [] # Additional direct rules
# Proxy list rules
[router.proxy]
file = "" # Proxy list file path (local or remote)
file_prefix = "**." # Prefix for proxy list rules
file_skip_rules = [] # Rules to skip when loading the proxy list file
rules = [] # Additional proxy rules
# Country-based routing
[router.country]
mmdb = "" # Optional MMDB file path for GeoIP; empty disables GeoIP lookup
file = "" # CIDR block list file path
file_prefix = "" # Prefix for CIDR rules
rules = [] # Additional CIDR rules
+455
View File
@@ -0,0 +1,455 @@
package config
import (
"bytes"
"log/slog"
"os"
"strings"
"testing"
"github.com/cristalhq/aconfig"
"github.com/cristalhq/aconfig/aconfigtoml"
"github.com/sower-proxy/deferlog/v2"
)
func TestSowerConfigValidateRejectsInvalidRemoteType(t *testing.T) {
t.Parallel()
cfg := SowerConfig{}
cfg.Remote.Type = "invalid"
cfg.Remote.Addr = "example.com"
cfg.DNS.Disable = true
if err := cfg.Validate(); err == nil {
t.Fatal("expected validation error for invalid remote type")
}
}
func TestSowerConfigValidateRejectsInvalidSocks5Addr(t *testing.T) {
t.Parallel()
cfg := SowerConfig{}
cfg.Remote.Type = "sower"
cfg.Remote.Addr = "example.com"
cfg.DNS.Disable = true
cfg.Socks5.Addr = "127.0.0.1"
if err := cfg.Validate(); err == nil {
t.Fatal("expected validation error for invalid socks5 address")
}
}
func TestSowerConfigValidateRejectsInvalidFallbackIP(t *testing.T) {
t.Parallel()
cfg := SowerConfig{}
cfg.Remote.Type = "sower"
cfg.Remote.Addr = "example.com"
cfg.DNS.Disable = true
cfg.DNS.Fallback = "not-an-ip"
if err := cfg.Validate(); err == nil {
t.Fatal("expected validation error for invalid fallback IP")
}
}
func TestSowerConfigValidateAllowsTLSRemoteWithExplicitPort(t *testing.T) {
t.Parallel()
cfg := SowerConfig{}
cfg.Remote.Type = "sower"
cfg.Remote.Addr = "example.com:8443"
cfg.Remote.TLS.ClientHello = "chrome"
cfg.DNS.Disable = true
cfg.DNS.Fallback = "223.5.5.5"
cfg.Socks5.Disable = true
if err := cfg.Validate(); err != nil {
t.Fatalf("validate config: %v", err)
}
if got := cfg.Router.Direct.Rules[len(cfg.Router.Direct.Rules)-3]; got != "example.com" {
t.Fatalf("unexpected direct rule host: %q", got)
}
}
func TestSowerConfigValidateRejectsInvalidTLSClientHello(t *testing.T) {
t.Parallel()
cfg := SowerConfig{}
cfg.Remote.Type = "sower"
cfg.Remote.Addr = "example.com"
cfg.Remote.TLS.ClientHello = "not-supported"
cfg.DNS.Disable = true
cfg.DNS.Fallback = "223.5.5.5"
cfg.Socks5.Disable = true
if err := cfg.Validate(); err == nil {
t.Fatal("expected validation error for invalid TLS client hello")
}
}
// TestSowerConfigValidateAdminAllowsEmptyPassword pins the startup-fallback
// contract: enabling the admin console without a password must not fail
// validation; cmd/sower generates a random password at runtime and prints it
// to the startup log.
func TestSowerConfigValidateAdminAllowsEmptyPassword(t *testing.T) {
t.Parallel()
cfg := SowerConfig{}
cfg.Remote.Type = "sower"
cfg.Remote.Addr = "example.com"
cfg.DNS.Disable = true
cfg.DNS.Fallback = "223.5.5.5"
cfg.Socks5.Disable = true
cfg.Admin.Disable = false
cfg.Admin.Addr = "127.0.0.1:19090"
if err := cfg.Validate(); err != nil {
t.Fatalf("validate admin with empty password: %v", err)
}
}
func TestSowerConfigValidateAdminRejectsInvalidAddr(t *testing.T) {
t.Parallel()
cfg := SowerConfig{}
cfg.Remote.Type = "sower"
cfg.Remote.Addr = "example.com"
cfg.DNS.Disable = true
cfg.DNS.Fallback = "223.5.5.5"
cfg.Socks5.Disable = true
cfg.Admin.Disable = false
cfg.Admin.Addr = "127.0.0.1"
cfg.Admin.Password = deferlog.NewPassword("secret")
if err := cfg.Validate(); err == nil {
t.Fatal("expected validation error for invalid admin address")
}
}
func TestSowerConfigValidateAdminAllowsValidConfig(t *testing.T) {
t.Parallel()
cfg := SowerConfig{}
cfg.Remote.Type = "sower"
cfg.Remote.Addr = "example.com"
cfg.DNS.Disable = true
cfg.DNS.Fallback = "223.5.5.5"
cfg.Socks5.Disable = true
cfg.Admin.Disable = false
cfg.Admin.Addr = "127.0.0.1:19090"
cfg.Admin.Password = deferlog.NewPassword("secret")
if err := cfg.Validate(); err != nil {
t.Fatalf("validate config: %v", err)
}
}
// TestSowerConfigAdminDisabledByDefault pins the upgrade-compatible default:
// a config without an [admin] section must load with admin disabled (the aconfig
// default) and pass validation, so existing deployments keep working after
// upgrading without adding an [admin] block or password.
func TestSowerConfigAdminDisabledByDefault(t *testing.T) {
t.Parallel()
path := t.TempDir() + "/sower.toml"
if err := os.WriteFile(path, []byte(`
[remote]
type = "sower"
addr = "example.com"
[dns]
serve = "127.0.0.1"
fallback = "223.5.5.5"
[socks_5]
disable = true
`), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
var cfg SowerConfig
if err := aconfig.LoaderFor(&cfg, aconfig.Config{
SkipEnv: true,
SkipFlags: true,
Files: []string{path},
FileDecoders: map[string]aconfig.FileDecoder{
".toml": aconfigtoml.New(),
},
}).Load(); err != nil {
t.Fatalf("load config: %v", err)
}
if !cfg.Admin.Disable {
t.Fatal("expected admin disabled by default without an [admin] section")
}
if err := cfg.Validate(); err != nil {
t.Fatalf("validate config without admin section: %v", err)
}
}
func TestSowerConfigLoadsTOMLFileSkipRules(t *testing.T) {
t.Parallel()
path := t.TempDir() + "/sower.toml"
if err := os.WriteFile(path, []byte(`
[remote]
type = "sower"
addr = "example.com"
[dns]
disable = true
fallback = "223.5.5.5"
[socks_5]
disable = true
[admin]
cookie_secure = true
disable_session_persistence = true
[router.block]
file_skip_rules = ["t.co"]
`), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
var cfg SowerConfig
if err := aconfig.LoaderFor(&cfg, aconfig.Config{
SkipEnv: true,
SkipFlags: true,
Files: []string{path},
FileDecoders: map[string]aconfig.FileDecoder{
".toml": aconfigtoml.New(),
},
}).Load(); err != nil {
t.Fatalf("load config: %v", err)
}
if len(cfg.Router.Block.FileSkipRules) != 1 || cfg.Router.Block.FileSkipRules[0] != "t.co" {
t.Fatalf("unexpected file skip rules: %v", cfg.Router.Block.FileSkipRules)
}
if !cfg.Socks5.Disable {
t.Fatal("expected socks_5 section to load")
}
if !cfg.Admin.CookieSecure {
t.Fatal("expected admin.cookie_secure to load")
}
if !cfg.Admin.DisableSessionPersistence {
t.Fatal("expected admin.disable_session_persistence to load")
}
if cfg.AdminSessionFile() != "" {
t.Fatalf("AdminSessionFile() = %q, want persistence disabled", cfg.AdminSessionFile())
}
}
func TestSowerConfigLoadsPackagedExamples(t *testing.T) {
t.Parallel()
for _, path := range []string{
"../config/sower.toml",
"../.github/sower.toml",
} {
path := path
t.Run(path, func(t *testing.T) {
t.Parallel()
var cfg SowerConfig
if err := aconfig.LoaderFor(&cfg, aconfig.Config{
SkipEnv: true,
SkipFlags: true,
Files: []string{path},
FileDecoders: map[string]aconfig.FileDecoder{
".toml": aconfigtoml.New(),
},
}).Load(); err != nil {
t.Fatalf("load config: %v", err)
}
if err := cfg.Validate(); err != nil {
t.Fatalf("validate config: %v", err)
}
})
}
}
// TestSowerConfigAdminPasswordRedactsOnLog pins the masking contract for the
// admin password field: logging a SowerConfig must never leak it. The admin
// password uses deferlog.Password, which renders as *** through fmt.Stringer
// while keeping the plain value reachable via Value(). Remote.Password is a
// plain string (sent verbatim to the upstream proxy) and is masked at the
// startup log site instead, see cmd/sower/main.go.
func TestSowerConfigAdminPasswordRedactsOnLog(t *testing.T) {
t.Parallel()
cfg := SowerConfig{}
cfg.Remote.Password = "topsecret-remote"
cfg.Admin.Password = deferlog.NewPassword("topsecret-admin")
var buf bytes.Buffer
slog.New(slog.NewTextHandler(&buf, nil)).Error("load config", "config", cfg)
out := buf.String()
for _, secret := range []string{"topsecret-admin"} {
if strings.Contains(out, secret) {
t.Fatalf("secret %q leaked in slog output", secret)
}
}
if !strings.Contains(out, "***") {
t.Fatal("expected masked *** in slog output")
}
}
// TestSowerConfigAdminPasswordRedactsOnJSON pins the masking contract under a
// JSON handler too: deferlog.Password serializes as base64 via MarshalJSON
// (obfuscation, not plaintext), so the plaintext value must never appear.
// This guards against a future handler change silently degrading the redaction.
func TestSowerConfigAdminPasswordRedactsOnJSON(t *testing.T) {
t.Parallel()
cfg := SowerConfig{}
cfg.Admin.Password = deferlog.NewPassword("topsecret-admin")
var buf bytes.Buffer
slog.New(slog.NewJSONHandler(&buf, nil)).Error("load config", "config", cfg)
if strings.Contains(buf.String(), "topsecret-admin") {
t.Fatalf("admin password leaked in slog output: %s", buf.String())
}
}
// TestSowerConfigRemotePasswordVerbatim pins that the remote password is
// taken verbatim from TOML: unlike the admin password, it must round-trip
// byte-for-byte to the upstream proxy, so even values that happen to be valid
// canonical base64 (like "fnhkwfnh") must not be decoded.
func TestSowerConfigRemotePasswordVerbatim(t *testing.T) {
t.Parallel()
path := t.TempDir() + "/sower.toml"
if err := os.WriteFile(path, []byte(`
[remote]
type = "sower"
addr = "example.com"
password = "fnhkwfnh"
[dns]
disable = true
fallback = "223.5.5.5"
[socks_5]
disable = true
`), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
var cfg SowerConfig
if err := aconfig.LoaderFor(&cfg, aconfig.Config{
SkipEnv: true,
SkipFlags: true,
Files: []string{path},
FileDecoders: map[string]aconfig.FileDecoder{
".toml": aconfigtoml.New(),
},
}).Load(); err != nil {
t.Fatalf("load config: %v", err)
}
if got := cfg.Remote.Password; got != "fnhkwfnh" {
t.Fatalf("remote password = %q, want verbatim fnhkwfnh", got)
}
}
// TestSowerConfigAdminPasswordAcceptsTaggedBase64 pins that the admin
// password field decodes the explicit "b64:" form from TOML (a
// deferlog.Password feature), so operators can avoid writing plaintext admin
// secrets into config files. Plain values are always verbatim.
func TestSowerConfigAdminPasswordAcceptsTaggedBase64(t *testing.T) {
t.Parallel()
path := t.TempDir() + "/sower.toml"
if err := os.WriteFile(path, []byte(`
[remote]
type = "sower"
addr = "example.com"
[admin]
disable = false
addr = "127.0.0.1:19090"
password = "b64:c2VjcmV0LXBhc3M="
[dns]
disable = true
fallback = "223.5.5.5"
[socks_5]
disable = true
`), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
var cfg SowerConfig
if err := aconfig.LoaderFor(&cfg, aconfig.Config{
SkipEnv: true,
SkipFlags: true,
Files: []string{path},
FileDecoders: map[string]aconfig.FileDecoder{
".toml": aconfigtoml.New(),
},
}).Load(); err != nil {
t.Fatalf("load config: %v", err)
}
if got := cfg.Admin.Password.Value(); got != "secret-pass" {
t.Fatalf("admin tagged base64 password = %q, want secret-pass", got)
}
}
func TestSowerConfigValidateRejectsSocks5Password(t *testing.T) {
t.Parallel()
cfg := SowerConfig{}
cfg.Remote.Type = "socks5"
cfg.Remote.Addr = "proxy.example.com:1080"
cfg.Remote.Password = "secret"
cfg.DNS.Disable = true
if err := cfg.Validate(); err == nil {
t.Fatal("expected validation error for socks5 with password")
}
}
func TestSowerConfigValidateAllowsSocks5WithoutPassword(t *testing.T) {
t.Parallel()
cfg := SowerConfig{}
cfg.Remote.Type = "socks5"
cfg.Remote.Addr = "proxy.example.com:1080"
cfg.DNS.Disable = true
cfg.DNS.Fallback = "223.5.5.5"
cfg.Socks5.Disable = true
if err := cfg.Validate(); err != nil {
t.Fatalf("validate config: %v", err)
}
}
func TestSplitRemoteAddrBareIPv6(t *testing.T) {
t.Parallel()
host, port, err := splitRemoteAddr("2001:db8::1", "443")
if err != nil || host != "2001:db8::1" || port != "443" {
t.Fatalf("bare ipv6: host=%q port=%q err=%v", host, port, err)
}
if _, _, err := splitRemoteAddr(":::", "443"); err == nil {
t.Fatal("garbage multi-colon address must be rejected")
}
if _, _, err := splitRemoteAddr("1:2:3:4:5:6:7:8:9", "443"); err == nil {
t.Fatal("overlong ipv6-like address must be rejected")
}
host, port, err = splitRemoteAddr("[2001:db8::1]:8443", "443")
if err != nil || host != "2001:db8::1" || port != "8443" {
t.Fatalf("bracketed ipv6: host=%q port=%q err=%v", host, port, err)
}
if _, _, err := splitRemoteAddr("::::", "443"); err == nil {
t.Fatal("garbage address must be rejected")
}
}
+339
View File
@@ -0,0 +1,339 @@
package config
import (
"crypto/x509"
"encoding/pem"
"fmt"
"log/slog"
"net"
"net/url"
"os"
"strings"
)
const ExampleSowerdConfigTOML = `# Sowerd configuration example (TOML format)
# This is the daemon configuration for the sower server
# Logging configuration
log_level = "info" # Log level: debug, info, warn, error
# Server configuration
serve_ip = "0.0.0.0" # IP address to listen on for ports 80 and 443
password = "change_me" # Client auth password (required; replace the placeholder)
fake_site = "/var/www" # Fallback fake site directory or address (default: /var/www)
# Site routing: map domains to upstream URLs for fallback traffic.
# When a TLS connection does not match sower transport,
# the SNI is looked up here. Unmatched connections fall through to fake_site.
# Custom certificate mode requires the certificate SANs to cover all routed domains.
# Route domains become public once autocert issues their certificates
# (Certificate Transparency), so an upstream that cannot authenticate its own
# visitors needs the optional auth gate below.
#
# [[site_routes]]
# domains = ["a.example.com", "b.example.com"]
# upstream = "http://127.0.0.1:8080"
#
# authority selects the Host header sent upstream: "upstream" (the default)
# sends the route's upstream host, "client" forwards the client's authority,
# for an upstream that validates that authority itself (see docs/security.md).
# authority = "client"
#
# [site_routes.auth]
# username = "admin" # Both fields are required to enable the gate;
# password = "change_me" # an empty block leaves the route open.
# The gate also covers WebSocket upgrades, but browsers cannot set Authorization
# on a WS handshake and reconnecting clients feed the per-IP lockout: verify
# before gating one (see docs/security.md).
#
# [site_routes.routes]
# "/ws" = "http://127.0.0.1:8082"
#
# [[site_routes]]
# domains = ["c.example.com"]
# upstream = "https://backend.example.com"
#
# URL userinfo credentials are sent to the upstream as HTTP Basic auth:
# upstream = "http://user:pass@127.0.0.1:8080"
# SSL/TLS certificate configuration
[cert]
email = "your-email@example.com" # Email for Let's Encrypt certificate
# domains = ["proxy.example.com"] # Issuance whitelist beyond site_routes (autocert mode).
# The FIRST entry answers TLS handshakes for unknown/empty SNI: keep it a concrete
# domain, not a wildcard. See docs/security.md.
cert = "" # Path to custom certificate file (optional)
key = "" # Path to custom private key file (optional)
`
// SiteRoute maps a set of exact domain names to an upstream URL.
// When a TLS connection's SNI matches one of the domains, fallback traffic
// is reverse-proxied to Upstream instead of going to fake_site. Routes
// override Upstream for requests whose path matches a configured prefix.
type SiteRoute struct {
Domains []string `usage:"exact domain names for this route"`
Upstream string `usage:"upstream URL (http:// or https://)"`
// Authority selects the Host header presented to the upstream: the empty
// value and AuthorityUpstream send the upstream host, AuthorityClient
// forwards the authority the client asked for. Only an upstream that
// validates that authority itself wants the client's name; the reasoning is
// in docs/security.md.
Authority string `usage:"authority presented to the upstream: upstream (default) or client"`
Routes map[string]string `usage:"path-prefix to upstream URL overrides (e.g. /ws = http://127.0.0.1:8082)"`
// Auth is an optional edge-side gate for visitors of this route. The
// route domains are public (Certificate Transparency), so an upstream that
// cannot authenticate its own visitors is exposed; the edge is the only
// place to insert a gate because sowerd owns 80/443 on the host.
Auth SiteRouteAuth `usage:"optional HTTP Basic visitor gate (leave empty to disable)"`
}
// SiteRouteAuth gates a site route behind HTTP Basic credentials. Both
// fields must be set to enable the gate; an empty block leaves the route
// open. The mechanism is deliberately HTTP Basic only: it needs no session
// state and works for non-browser clients, at the cost of a per-origin
// browser credential cache and no logout.
type SiteRouteAuth struct {
Username string `usage:"visitor user name for the route's HTTP Basic gate"`
Password string `usage:"visitor password for the route's HTTP Basic gate"`
}
// SowerdConfig represents the configuration for sowerd daemon
type SowerdConfig struct {
LogLevel slog.Level `default:"info" usage:"log level: debug, info, warn, error"`
ServeIP string `usage:"listen to port 80 443 of the IP"`
// Password is compared byte-for-byte against the client-supplied value. It
// stays a plain string (not deferlog.Password): the base64-or-plain
// heuristic in deferlog.Password would silently corrupt passwords that
// happen to be valid canonical base64, breaking client auth.
Password string `required:"true"`
FakeSite string `default:"/var/www" usage:"fake site address or directoy. serving on 127.0.0.1:80 if directory"`
SiteRoutes []SiteRoute `usage:"domain-to-upstream routing rules for fallback traffic"`
Cert struct {
Email string
Cert string
Key string
// Domains whitelists which names autocert may issue certificates
// for, on top of every site_routes domain. A nil HostPolicy would
// let anyone trigger issuance for arbitrary SNI names, exhausting
// the Let's Encrypt account rate limits. Direct-connection domains
// (e.g. the sower client's remote addr) must be listed here.
Domains []string
}
}
// Validate implements the validation interface for SowerdConfig
func (c *SowerdConfig) Validate() error {
if c.ServeIP != "" && net.ParseIP(c.ServeIP) == nil {
return fmt.Errorf("invalid serve ip: %q", c.ServeIP)
}
if c.Password == "" {
return fmt.Errorf("password is required")
}
if c.FakeSite == "" {
return fmt.Errorf("fake site is required")
}
if (c.Cert.Cert == "") != (c.Cert.Key == "") {
return fmt.Errorf("cert and key must be configured together")
}
if err := validateSiteRoutes(c.SiteRoutes); err != nil {
return err
}
seenCertDomains := make(map[string]struct{}, len(c.Cert.Domains))
for _, d := range c.Cert.Domains {
d = strings.ToLower(d)
if !validExactDomain(d) {
return fmt.Errorf("invalid cert domain %q", d)
}
if _, ok := seenCertDomains[d]; ok {
return fmt.Errorf("duplicate cert domain %q", d)
}
seenCertDomains[d] = struct{}{}
}
if c.Cert.Cert != "" {
cert, err := loadLeafCertificate(c.Cert.Cert)
if err != nil {
return err
}
if _, err := os.Stat(c.Cert.Key); err != nil {
return fmt.Errorf("stat key file: %w", err)
}
if err := validateSiteRouteCertificate(cert, c.SiteRoutes); err != nil {
return err
}
}
if _, err := os.Stat(c.FakeSite); err == nil {
return nil
} else if !os.IsNotExist(err) {
return fmt.Errorf("stat fake site: %w", err)
}
if _, _, err := net.SplitHostPort(c.FakeSite); err != nil {
return fmt.Errorf("fake site must be an existing directory or host:port: %w", err)
}
return nil
}
// Authority values accepted by SiteRoute.Authority.
const (
// AuthorityUpstream sends the route's upstream host as the Host header; it
// is the default.
AuthorityUpstream = "upstream"
// AuthorityClient forwards the authority the client asked for.
AuthorityClient = "client"
)
// PreserveHost reports whether the route asks the proxy to forward the client's
// authority instead of the upstream host.
func (r SiteRoute) PreserveHost() bool { return r.Authority == AuthorityClient }
// validateSiteRouteAuthority rejects a typo'd authority instead of silently
// falling back to the default, which is the value that hides the client's name.
func validateSiteRouteAuthority(i int, authority string) error {
switch authority {
case "", AuthorityUpstream, AuthorityClient:
return nil
}
return fmt.Errorf("site_routes[%d].authority: unknown value %q (want %q or %q)", i, authority, AuthorityUpstream, AuthorityClient)
}
func validateSiteRoutes(routes []SiteRoute) error {
seen := make(map[string]string) // domain -> upstream
for i, r := range routes {
if len(r.Domains) == 0 {
return fmt.Errorf("site_routes[%d]: domains is empty", i)
}
if r.Upstream == "" {
return fmt.Errorf("site_routes[%d]: upstream is empty", i)
}
if err := validateUpstreamURL(r.Upstream); err != nil {
return fmt.Errorf("site_routes[%d]: %w", i, err)
}
if err := validateSiteRouteAuth(i, r.Auth); err != nil {
return err
}
if err := validateSiteRouteAuthority(i, r.Authority); err != nil {
return err
}
for path, upstream := range r.Routes {
if !strings.HasPrefix(path, "/") {
return fmt.Errorf("site_routes[%d].routes: path %q must start with /", i, path)
}
if path != strings.TrimSpace(path) {
return fmt.Errorf("site_routes[%d].routes: path %q must not contain surrounding whitespace", i, path)
}
if strings.ContainsAny(path, "?#") {
return fmt.Errorf("site_routes[%d].routes: path %q must not contain query or fragment", i, path)
}
if err := validateUpstreamURL(upstream); err != nil {
return fmt.Errorf("site_routes[%d].routes[%q]: %w", i, path, err)
}
}
for _, d := range r.Domains {
if d != strings.TrimSpace(d) {
return fmt.Errorf("site_routes[%d]: domain %q must not contain surrounding whitespace", i, d)
}
d = strings.ToLower(d)
if strings.Contains(d, "*") {
return fmt.Errorf("site_routes[%d]: wildcard domain %q is not supported", i, d)
}
if !validExactDomain(d) {
return fmt.Errorf("site_routes[%d]: invalid domain %q", i, d)
}
if prev, ok := seen[d]; ok {
return fmt.Errorf("site_routes: domain %q appears in multiple routes (%q and %q)", d, prev, r.Upstream)
}
seen[d] = r.Upstream
}
}
return nil
}
func validateUpstreamURL(raw string) error {
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("parse upstream: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("upstream scheme must be http or https, got %q", u.Scheme)
}
if u.Host == "" {
return fmt.Errorf("upstream must have a host")
}
return nil
}
// validateSiteRouteAuth rejects a half-configured visitor gate: a route
// whose credentials are only partly set would otherwise run either open or
// with a credential nobody can present. An empty block means "no gate".
func validateSiteRouteAuth(i int, auth SiteRouteAuth) error {
if auth.Username == "" && auth.Password == "" {
return nil
}
if auth.Username == "" || auth.Password == "" {
return fmt.Errorf("site_routes[%d].auth: username and password must be set together", i)
}
if strings.ContainsRune(auth.Username, ':') {
// RFC 7617 splits the decoded credential at the first colon.
return fmt.Errorf("site_routes[%d].auth: username must not contain ':'", i)
}
return nil
}
func validExactDomain(domain string) bool {
if domain == "" || len(domain) > 253 || strings.HasSuffix(domain, ".") {
return false
}
labels := strings.Split(domain, ".")
for _, label := range labels {
if label == "" || len(label) > 63 || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
return false
}
for _, r := range label {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
continue
}
return false
}
}
return true
}
func loadLeafCertificate(path string) (*x509.Certificate, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read cert file: %w", err)
}
block, _ := pem.Decode(data)
if block == nil || block.Type != "CERTIFICATE" {
return nil, fmt.Errorf("parse cert file %s: missing PEM certificate", path)
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse cert file %s: %w", path, err)
}
return cert, nil
}
func validateSiteRouteCertificate(cert *x509.Certificate, routes []SiteRoute) error {
for i, r := range routes {
for _, domain := range r.Domains {
domain = strings.ToLower(domain)
if err := cert.VerifyHostname(domain); err != nil {
return fmt.Errorf("site_routes[%d]: cert does not cover domain %q: %w", i, domain, err)
}
}
}
return nil
}
+53
View File
@@ -0,0 +1,53 @@
# Sowerd configuration example (TOML format)
# This is the daemon configuration for the sower server
# Logging configuration
log_level = "info" # Log level: debug, info, warn, error
# Server configuration
serve_ip = "0.0.0.0" # IP address to listen on for ports 80 and 443
password = "change_me" # Client auth password (required; replace the placeholder)
fake_site = "/var/www" # Fallback fake site directory or address (default: /var/www)
# Site routing: map domains to upstream URLs for fallback traffic.
# When a TLS connection does not match sower transport,
# the SNI is looked up here. Unmatched connections fall through to fake_site.
# Custom certificate mode requires the certificate SANs to cover all routed domains.
# Route domains become public once autocert issues their certificates
# (Certificate Transparency), so an upstream that cannot authenticate its own
# visitors needs the optional auth gate below.
#
# [[site_routes]]
# domains = ["a.example.com", "b.example.com"]
# upstream = "http://127.0.0.1:8080"
#
# authority selects the Host header sent upstream: "upstream" (the default)
# sends the route's upstream host, "client" forwards the client's authority,
# for an upstream that validates that authority itself (see docs/security.md).
# authority = "client"
#
# [site_routes.auth]
# username = "admin" # Both fields are required to enable the gate;
# password = "change_me" # an empty block leaves the route open.
# The gate also covers WebSocket upgrades, but browsers cannot set Authorization
# on a WS handshake and reconnecting clients feed the per-IP lockout: verify
# before gating one (see docs/security.md).
#
# [site_routes.routes]
# "/ws" = "http://127.0.0.1:8082"
#
# [[site_routes]]
# domains = ["c.example.com"]
# upstream = "https://backend.example.com"
#
# URL userinfo credentials are sent to the upstream as HTTP Basic auth:
# upstream = "http://user:pass@127.0.0.1:8080"
# SSL/TLS certificate configuration
[cert]
email = "your-email@example.com" # Email for Let's Encrypt certificate
# domains = ["proxy.example.com"] # Issuance whitelist beyond site_routes (autocert mode).
# The FIRST entry answers TLS handshakes for unknown/empty SNI: keep it a concrete
# domain, not a wildcard. See docs/security.md.
cert = "" # Path to custom certificate file (optional)
key = "" # Path to custom private key file (optional)
+623
View File
@@ -0,0 +1,623 @@
package config
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/cristalhq/aconfig"
"github.com/cristalhq/aconfig/aconfigtoml"
)
func TestSowerdConfigPasswordVerbatim(t *testing.T) {
t.Parallel()
path := t.TempDir() + "/sowerd.toml"
if err := os.WriteFile(path, []byte(`
password = "fnhkwfnh"
fake_site = "127.0.0.1:18080"
`), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
var cfg SowerdConfig
if err := aconfig.LoaderFor(&cfg, aconfig.Config{
SkipEnv: true,
SkipFlags: true,
Files: []string{path},
FileDecoders: map[string]aconfig.FileDecoder{
".toml": aconfigtoml.New(),
},
}).Load(); err != nil {
t.Fatalf("load config: %v", err)
}
// "fnhkwfnh" is valid canonical base64; it must stay verbatim or client
// auth (byte-for-byte checksum over the password) silently breaks.
if got := cfg.Password; got != "fnhkwfnh" {
t.Fatalf("sowerd password = %q, want verbatim fnhkwfnh", got)
}
}
func TestSowerdConfigSiteRouteBasicAuthFromTOML(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "sowerd.toml")
if err := os.WriteFile(path, []byte(`
password = "secret"
fake_site = "127.0.0.1:18080"
[[site_routes]]
domains = ["nas.example.com"]
upstream = "http://127.0.0.1:9000"
[site_routes.auth]
username = "alice"
password = "s3cret"
[site_routes.routes]
"/ws" = "http://127.0.0.1:9002"
[[site_routes]]
domains = ["open.example.com"]
upstream = "http://127.0.0.1:9001"
`), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
var cfg SowerdConfig
if err := loadConfigFileForTest(path, &cfg); err != nil {
t.Fatalf("load config: %v", err)
}
// The gate lives in a nested table of an array-of-tables element; a
// decoder that silently drops it would leave the route open, so the
// parsed values (not just the load result) are the assertion.
if len(cfg.SiteRoutes) != 2 {
t.Fatalf("site routes = %d, want 2", len(cfg.SiteRoutes))
}
if got := cfg.SiteRoutes[0].Auth; got.Username != "alice" || got.Password != "s3cret" {
t.Fatalf("basic auth = %#v, want alice/s3cret", got)
}
if cfg.SiteRoutes[0].Routes["/ws"] != "http://127.0.0.1:9002" {
t.Fatalf("path routes = %#v, want the /ws override alongside auth", cfg.SiteRoutes[0].Routes)
}
if got := cfg.SiteRoutes[1].Auth; got.Username != "" || got.Password != "" {
t.Fatalf("ungated route basic auth = %#v, want empty", got)
}
if err := cfg.Validate(); err != nil {
t.Fatalf("validate config: %v", err)
}
}
func TestSowerdConfigSiteRouteAuthorityFromTOML(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "sowerd.toml")
if err := os.WriteFile(path, []byte(`
password = "secret"
fake_site = "127.0.0.1:18080"
[[site_routes]]
domains = ["web.example.com"]
upstream = "http://192.0.2.10:30141"
authority = "client"
[[site_routes]]
domains = ["plain.example.com"]
upstream = "http://127.0.0.1:9001"
`), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
var cfg SowerdConfig
if err := loadConfigFileForTest(path, &cfg); err != nil {
t.Fatalf("load config: %v", err)
}
if len(cfg.SiteRoutes) != 2 {
t.Fatalf("site routes = %d, want 2", len(cfg.SiteRoutes))
}
// An upstream that validates the visited authority itself only works when the
// client's name survives the proxy, so the value must round-trip from TOML and
// must stay off when the route does not ask for it.
if got := cfg.SiteRoutes[0].Authority; got != AuthorityClient {
t.Errorf("authority = %q, want %q", got, AuthorityClient)
}
if !cfg.SiteRoutes[0].PreserveHost() {
t.Error("PreserveHost() = false for an authority = client route")
}
if cfg.SiteRoutes[1].Authority != "" {
t.Errorf("authority = %q, want the empty default", cfg.SiteRoutes[1].Authority)
}
if cfg.SiteRoutes[1].PreserveHost() {
t.Error("PreserveHost() = true for a route that did not ask for it")
}
if err := cfg.Validate(); err != nil {
t.Fatalf("validate config: %v", err)
}
}
func TestSowerdConfigSiteRouteAuthorityRejectsUnknownValue(t *testing.T) {
t.Parallel()
// FakeSite is set so Validate reaches the route check: an earlier field
// failing first would make this test green without exercising the authority.
cfg := SowerdConfig{Password: "secret", FakeSite: "127.0.0.1:18080"}
cfg.SiteRoutes = []SiteRoute{
{Domains: []string{"a.example.com"}, Upstream: "http://127.0.0.1:9000", Authority: "keep"},
}
err := cfg.Validate()
if err == nil {
t.Fatal("Validate accepted an unknown authority, want an error")
}
if !strings.Contains(err.Error(), "authority") {
t.Fatalf("Validate error = %v, want it to name the authority field", err)
}
}
func TestSowerdConfigValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
cfg SowerdConfig
wantErr bool
}{
{
name: "valid remote fake site",
cfg: SowerdConfig{
ServeIP: "0.0.0.0",
Password: "secret",
FakeSite: "127.0.0.1:8080",
},
},
{
name: "invalid serve ip",
cfg: SowerdConfig{
ServeIP: "bad-ip",
Password: "secret",
FakeSite: "127.0.0.1:8080",
},
wantErr: true,
},
{
name: "missing password",
cfg: SowerdConfig{
FakeSite: "127.0.0.1:8080",
},
wantErr: true,
},
{
name: "invalid fake site address",
cfg: SowerdConfig{
Password: "secret",
FakeSite: "not-an-address",
},
wantErr: true,
},
{
name: "partial cert config",
cfg: SowerdConfig{
Password: "secret",
FakeSite: "127.0.0.1:8080",
Cert: struct {
Email string
Cert string
Key string
Domains []string
}{
Cert: "/tmp/cert.pem",
},
},
wantErr: true,
},
{
name: "valid site routes",
cfg: SowerdConfig{
Password: "secret",
FakeSite: "127.0.0.1:8080",
SiteRoutes: []SiteRoute{
{Domains: []string{"a.example.com", "b.example.com"}, Upstream: "http://127.0.0.1:9000"},
{Domains: []string{"c.example.com"}, Upstream: "https://backend.example.com"},
},
},
},
{
name: "valid path routes",
cfg: SowerdConfig{
Password: "secret",
FakeSite: "127.0.0.1:8080",
SiteRoutes: []SiteRoute{
{
Domains: []string{"a.example.com"},
Upstream: "http://127.0.0.1:9000",
Routes: map[string]string{
"/ws": "http://127.0.0.1:9002",
"/admin/": "http://127.0.0.1:9003",
},
},
},
},
},
{
name: "path route without leading slash",
cfg: SowerdConfig{
Password: "secret",
FakeSite: "127.0.0.1:8080",
SiteRoutes: []SiteRoute{
{
Domains: []string{"a.example.com"},
Upstream: "http://127.0.0.1:9000",
Routes: map[string]string{"ws": "http://127.0.0.1:9002"},
},
},
},
wantErr: true,
},
{
name: "basic auth visitor gate",
cfg: SowerdConfig{
Password: "secret",
FakeSite: "127.0.0.1:8080",
SiteRoutes: []SiteRoute{
{
Domains: []string{"nas.example.com"},
Upstream: "http://127.0.0.1:9000",
Auth: SiteRouteAuth{Username: "alice", Password: "s3cret"},
},
},
},
},
{
name: "basic auth username without password",
cfg: SowerdConfig{
Password: "secret",
FakeSite: "127.0.0.1:8080",
SiteRoutes: []SiteRoute{
{
Domains: []string{"nas.example.com"},
Upstream: "http://127.0.0.1:9000",
Auth: SiteRouteAuth{Username: "alice"},
},
},
},
wantErr: true,
},
{
name: "basic auth password without username",
cfg: SowerdConfig{
Password: "secret",
FakeSite: "127.0.0.1:8080",
SiteRoutes: []SiteRoute{
{
Domains: []string{"nas.example.com"},
Upstream: "http://127.0.0.1:9000",
Auth: SiteRouteAuth{Password: "s3cret"},
},
},
},
wantErr: true,
},
{
name: "basic auth username with colon",
cfg: SowerdConfig{
Password: "secret",
FakeSite: "127.0.0.1:8080",
SiteRoutes: []SiteRoute{
{
Domains: []string{"nas.example.com"},
Upstream: "http://127.0.0.1:9000",
Auth: SiteRouteAuth{Username: "ali:ce", Password: "s3cret"},
},
},
},
wantErr: true,
},
{
name: "path route with query",
cfg: SowerdConfig{
Password: "secret",
FakeSite: "127.0.0.1:8080",
SiteRoutes: []SiteRoute{
{
Domains: []string{"a.example.com"},
Upstream: "http://127.0.0.1:9000",
Routes: map[string]string{"/ws?x=1": "http://127.0.0.1:9002"},
},
},
},
wantErr: true,
},
{
name: "path route invalid upstream scheme",
cfg: SowerdConfig{
Password: "secret",
FakeSite: "127.0.0.1:8080",
SiteRoutes: []SiteRoute{
{
Domains: []string{"a.example.com"},
Upstream: "http://127.0.0.1:9000",
Routes: map[string]string{"/ws": "ftp://127.0.0.1:21"},
},
},
},
wantErr: true,
},
{
name: "empty domains",
cfg: SowerdConfig{
Password: "secret",
FakeSite: "127.0.0.1:8080",
SiteRoutes: []SiteRoute{
{Domains: []string{}, Upstream: "http://127.0.0.1:9000"},
},
},
wantErr: true,
},
{
name: "empty upstream",
cfg: SowerdConfig{
Password: "secret",
FakeSite: "127.0.0.1:8080",
SiteRoutes: []SiteRoute{
{Domains: []string{"a.example.com"}, Upstream: ""},
},
},
wantErr: true,
},
{
name: "invalid upstream scheme",
cfg: SowerdConfig{
Password: "secret",
FakeSite: "127.0.0.1:8080",
SiteRoutes: []SiteRoute{
{Domains: []string{"a.example.com"}, Upstream: "ftp://127.0.0.1:21"},
},
},
wantErr: true,
},
{
name: "upstream missing host",
cfg: SowerdConfig{
Password: "secret",
FakeSite: "127.0.0.1:8080",
SiteRoutes: []SiteRoute{
{Domains: []string{"a.example.com"}, Upstream: "http://"},
},
},
wantErr: true,
},
{
name: "wildcard domain rejected",
cfg: SowerdConfig{
Password: "secret",
FakeSite: "127.0.0.1:8080",
SiteRoutes: []SiteRoute{
{Domains: []string{"*.example.com"}, Upstream: "http://127.0.0.1:9000"},
},
},
wantErr: true,
},
{
name: "duplicate domain across routes",
cfg: SowerdConfig{
Password: "secret",
FakeSite: "127.0.0.1:8080",
SiteRoutes: []SiteRoute{
{Domains: []string{"a.example.com"}, Upstream: "http://127.0.0.1:9000"},
{Domains: []string{"a.example.com"}, Upstream: "http://127.0.0.1:9001"},
},
},
wantErr: true,
},
{
name: "empty domain in list",
cfg: SowerdConfig{
Password: "secret",
FakeSite: "127.0.0.1:8080",
SiteRoutes: []SiteRoute{
{Domains: []string{"a.example.com", ""}, Upstream: "http://127.0.0.1:9000"},
},
},
wantErr: true,
},
{
name: "domain with whitespace rejected",
cfg: SowerdConfig{
Password: "secret",
FakeSite: "127.0.0.1:8080",
SiteRoutes: []SiteRoute{
{Domains: []string{" a.example.com"}, Upstream: "http://127.0.0.1:9000"},
},
},
wantErr: true,
},
{
name: "domain as url rejected",
cfg: SowerdConfig{
Password: "secret",
FakeSite: "127.0.0.1:8080",
SiteRoutes: []SiteRoute{
{Domains: []string{"https://a.example.com"}, Upstream: "http://127.0.0.1:9000"},
},
},
wantErr: true,
},
{
name: "domain with port rejected",
cfg: SowerdConfig{
Password: "secret",
FakeSite: "127.0.0.1:8080",
SiteRoutes: []SiteRoute{
{Domains: []string{"a.example.com:443"}, Upstream: "http://127.0.0.1:9000"},
},
},
wantErr: true,
},
{
name: "domain with trailing dot rejected",
cfg: SowerdConfig{
Password: "secret",
FakeSite: "127.0.0.1:8080",
SiteRoutes: []SiteRoute{
{Domains: []string{"a.example.com."}, Upstream: "http://127.0.0.1:9000"},
},
},
wantErr: true,
},
{
name: "no site routes is valid",
cfg: SowerdConfig{
Password: "secret",
FakeSite: "127.0.0.1:8080",
},
},
{
name: "valid cert domains",
cfg: SowerdConfig{
Password: "secret",
FakeSite: "127.0.0.1:8080",
Cert: struct {
Email string
Cert string
Key string
Domains []string
}{
Domains: []string{"proxy.example.com"},
},
},
},
{
name: "invalid cert domain rejected",
cfg: SowerdConfig{
Password: "secret",
FakeSite: "127.0.0.1:8080",
Cert: struct {
Email string
Cert string
Key string
Domains []string
}{
Domains: []string{"not a domain"},
},
},
wantErr: true,
},
{
name: "duplicate cert domain rejected",
cfg: SowerdConfig{
Password: "secret",
FakeSite: "127.0.0.1:8080",
Cert: struct {
Email string
Cert string
Key string
Domains []string
}{
Domains: []string{"proxy.example.com", "proxy.example.com"},
},
},
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.cfg.Validate()
if tt.wantErr && err == nil {
t.Fatal("expected error")
}
if !tt.wantErr && err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestSowerdConfigValidateSiteRouteCertificate(t *testing.T) {
t.Parallel()
certPath, keyPath := writeTestCertificate(t, []string{"a.example.com", "b.example.com"})
cfg := SowerdConfig{
Password: "secret",
FakeSite: "127.0.0.1:8080",
SiteRoutes: []SiteRoute{
{Domains: []string{"a.example.com", "b.example.com"}, Upstream: "http://127.0.0.1:9000"},
},
}
cfg.Cert.Cert = certPath
cfg.Cert.Key = keyPath
if err := cfg.Validate(); err != nil {
t.Fatalf("unexpected validation error: %v", err)
}
cfg.SiteRoutes = []SiteRoute{
{Domains: []string{"missing.example.com"}, Upstream: "http://127.0.0.1:9000"},
}
if err := cfg.Validate(); err == nil {
t.Fatal("expected validation error for uncovered site route domain")
}
}
func writeTestCertificate(t *testing.T, domains []string) (string, string) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate key: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: domains[0],
},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
DNSNames: domains,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("create cert: %v", err)
}
dir := t.TempDir()
certPath := filepath.Join(dir, "cert.pem")
keyPath := filepath.Join(dir, "key.pem")
certFile, err := os.Create(certPath)
if err != nil {
t.Fatalf("create cert file: %v", err)
}
if err := pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil {
t.Fatalf("write cert file: %v", err)
}
if err := certFile.Close(); err != nil {
t.Fatalf("close cert file: %v", err)
}
keyFile, err := os.Create(keyPath)
if err != nil {
t.Fatalf("create key file: %v", err)
}
if err := pem.Encode(keyFile, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}); err != nil {
t.Fatalf("write key file: %v", err)
}
if err := keyFile.Close(); err != nil {
t.Fatalf("close key file: %v", err)
}
return certPath, keyPath
}
-18
View File
@@ -1,18 +0,0 @@
# Compile
FROM golang:1.11-alpine AS compiler
RUN apk add --no-cache git make
# enable go modules
WORKDIR /go-mod
COPY . .
# do not worry about downloading dependency, sower will fix this.
RUN CGO_ENABLED=0 make build
# Build image
FROM scratch
COPY --from=compiler /go-mod/sower /sower
ENTRYPOINT [ "/sower" ]
-18
View File
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>cc.wweir.sower</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/sower</string>
<string>-f</string>
<string>/usr/local/etc/sower.toml</string>
</array>
<key>KeepAlive</key>
<true/>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
-127
View File
@@ -1,127 +0,0 @@
#!/usr/bin/env bash
set -e
[[ -d /tmp/sower ]] || mkdir /tmp/sower
cd /tmp/sower
echo ======== Install sower to your system ========
succ_message(){
if [[ -z $IP ]]; then
IP=127.0.0.1
fi
echo
echo ======== Installed sower to your system ========
echo The config file is: /usr/local/etc/sower.toml
echo please set $IP as the first domain name server manually.
}
# main logic
VERSION="$(curl -s "https://api.github.com/repos/wweir/sower/releases/latest" | awk -F '"' '/tag_name/{printf $4}')"
case "$(uname -s)" in
"Darwin")
curl -SLf https://github.com/wweir/sower/releases/download/$VERSION/sower_darwin_amd64.tar.gz | tar xzv
sudo mkdir -p /usr/local/bin/
sudo mv sower /usr/local/bin/
if [[ -r /usr/local/etc/sower.toml ]]; then
echo The config file already exists, keep the original file
else
printf "Please enter remote server adddress: "
read ADDRESS
printf "Please enter remote server password (default: 12345678): "
read PASSWORD
if [[ -z $PASSWORD ]]; then
PASSWORD="12345678"
fi
printf "Please enter which IP do you wanna listen (default: 127.0.0.1):"
read IP
if [[ -z $IP ]]; then
IP="127.0.0.1"
fi
echo
sed -i~ "s/# server_addr=\"remote-server/server_addr=\"$ADDRESS/" sower.toml
sed -i~ "s/client_ip=\"127.0.0.1\"/client_ip=\"$IP\"/" sower.toml
sed -i~ "s/# clear_dns_cache/clear_dns_cache/" sower.toml
sed -i~ "s/\"12345678\"/\"$PASSWORD\"/" sower.toml
sudo mkdir -p /usr/local/etc/
sudo mv sower.toml /usr/local/etc/
fi
echo
echo Register auto start service, root privilege is needed!
echo
sudo mv cc.wweir.sower.plist /Library/LaunchDaemons/
sudo chown root:wheel /Library/LaunchDaemons/cc.wweir.sower.plist
sudo launchctl load -w /Library/LaunchDaemons/cc.wweir.sower.plist
succ_message
;;
"Linux")
if [[ "$(cat /proc/1/comm)" != "systemd" ]]; then
echo do not support auto deploy on SysVinit
exit 1
fi
printf "Server side or client side, which do you wanna install [c/s]: "
read SIDE
echo
case "$SIDE" in
"c")
curl -SLf https://github.com/wweir/sower/releases/download/$VERSION/sower_linux_amd64.tar.gz | tar xzv
sudo mv sower /usr/local/bin/
if [[ -r /usr/local/etc/sower.toml ]]; then
echo The config file already exists, keep the original file
else
printf "Please enter remote server adddress: "
read ADDRESS
printf "Please enter remote server password (default: 12345678): "
read PASSWORD
if [[ -z $PASSWORD ]]; then
PASSWORD="12345678"
fi
printf "Please enter which IP do you wanna listen (default: 127.0.0.1):"
read IP
if [[ -z $IP ]]; then
IP="127.0.0.1"
fi
echo
sed -i~ "s/# server_addr=\"remote-server/server_addr=\"$ADDRESS/" sower.toml
sed -i~ "s/client_ip=\"127.0.0.1\"/client_ip=\"$IP\"/" sower.toml
sed -i~ "s/\"12345678\"/\"$PASSWORD\"/" sower.toml
sudo mv sower.toml /usr/local/etc/
fi
sudo mv sower-client.service /etc/systemd/system/
sudo systemctl enable sower-client
sudo systemctl start sower-client
succ_message
;;
"s")
curl -SLf https://github.com/wweir/sower/releases/download/$VERSION/sower_linux_amd64.tar.gz | tar xzv
printf "Please enter remote server password (default: 12345678): "
read PASSWORD
echo
if [[ -z $PASSWORD ]]; then
PASSWORD="12345678"
fi
sudo mv sower /usr/local/bin/
sed -i~ "s%bin/sower%bin/sower -p $PASSWORD%" sower-server.service
sudo mv sower-server.service /etc/systemd/system/
sudo systemctl enable sower-server
sudo systemctl start sower-server
;;
*)
echo "invalid: $SIDE" && exit 1
;;
esac
;;
*)
echo Do not support auto deploy on this platform.
;;
esac
-14
View File
@@ -1,14 +0,0 @@
[Unit]
Description=Sower client service
After=network.target
[Install]
WantedBy=multi-user.target
[Service]
Type=simple
User=root
WorkingDirectory=/tmp
ExecStart=/usr/local/bin/sower -f /usr/local/etc/sower.toml
RestartSec=3
Restart=on-failure
-14
View File
@@ -1,14 +0,0 @@
[Unit]
Description=Sower server service
After=network.target
[Install]
WantedBy=multi-user.target
[Service]
Type=simple
User=nobody
WorkingDirectory=/tmp
ExecStart=/usr/local/bin/sower -n TCP -v 1
RestartSec=3
Restart=on-failure
-20
View File
@@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -u -x -e
# main logic
case "$(uname -s)" in
"Darwin")
sudo launchctl unload /Library/LaunchDaemons/cc.wweir.sower.plist
sudo rm -rf /Library/LaunchDaemons/cc.wweir.sower.plist
rm -rf /usr/local/bin/sower /usr/local/etc/sower.toml
;;
"Linux")
sudo systemctl disable sower-client ||true
sudo systemctl disable sower-server || true
sudo rm -rf /etc/systemd/system/sower-client.service /etc/systemd/system/sower-server.service
sudo rm -rf /usr/local/bin/sower /usr/local/etc/sower.toml
;;
*)
echo Do not support auto deploy on this platform.
;;
esac
-91
View File
@@ -1,91 +0,0 @@
package dns
import (
"math/rand"
"net"
"runtime"
"github.com/golang/glog"
"go.universe.tf/netboot/dhcp4"
)
var xid = make([]byte, 4)
func GetDefaultDNSServer() string {
pack := &dhcp4.Packet{
Type: dhcp4.MsgDiscover,
Broadcast: true,
}
options := map[dhcp4.Option][]byte{
dhcp4.OptRequestedOptions: []byte{byte(dhcp4.OptDNSServers)},
}
NewConn := dhcp4.NewConn
if runtime.GOOS == "linux" {
NewConn = dhcp4.NewSnooperConn
}
ifaces := mustGetInterfaces()
for _, iface := range ifaces {
conn, err := NewConn(iface.IP.String() + ":68")
if err != nil { // maybe in use
glog.Errorln(err)
continue
}
defer conn.Close()
rand.Read(xid)
pack.TransactionID = xid
pack.HardwareAddr = iface.Interface.HardwareAddr
options[dhcp4.OptClientIdentifier] = iface.Interface.HardwareAddr
pack.Options = dhcp4.Options(options)
if err := conn.SendDHCP(pack, iface.Interface); err != nil {
glog.Errorln(err)
continue
}
pack, _, err = conn.RecvDHCP()
if err != nil {
glog.Errorln(err)
continue
}
ips, err := pack.Options.IPs(dhcp4.OptDNSServers)
if err != nil {
glog.Errorln(err)
continue
}
return ips[0].String() // if len(ips) == 0, err should not be wrong size
}
return ""
}
type netIface struct {
*net.Interface
net.Addr
net.IP
}
func mustGetInterfaces() []*netIface {
ifaces, err := net.Interfaces()
if err != nil {
glog.Fatalln(err)
}
v4Iface := make([]*netIface, 0, len(ifaces))
for i := range ifaces {
if len(ifaces[i].HardwareAddr) == 0 {
continue
}
addrs, _ := ifaces[i].Addrs()
for _, addr := range addrs {
if ip := addr.(*net.IPNet).IP.To4(); ip != nil {
v4Iface = append(v4Iface, &netIface{&ifaces[i], addr, ip})
break
}
}
}
return v4Iface
}
-20
View File
@@ -1,20 +0,0 @@
package dns
import "testing"
func TestGetDefaultDNSServer(t *testing.T) {
t.Skip("skip for some enviroment not support dhcp")
tests := []struct {
name string
}{{
"",
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := GetDefaultDNSServer(); got == "" {
t.Errorf("GetDefaultDNSServer() return empty")
}
})
}
}
-158
View File
@@ -1,158 +0,0 @@
package dns
import (
"net"
"strings"
"sync/atomic"
"time"
"github.com/golang/glog"
"github.com/miekg/dns"
mem "github.com/wweir/mem-go"
"github.com/wweir/sower/conf"
)
const colon = byte(':')
func StartDNS(dnsServer, listenIP string) {
ip := net.ParseIP(listenIP)
suggest := &intelliSuggest{listenIP, time.Second}
mem.DefaultCache = mem.New(time.Hour)
dhcpCh := make(chan struct{})
if dnsServer != "" {
dnsServer = net.JoinHostPort(dnsServer, "53")
} else {
go func() {
for {
<-dhcpCh
host := GetDefaultDNSServer()
if host == "" {
continue
}
// atomic action
dnsServer = net.JoinHostPort(host, "53")
glog.Infoln("set dns server to", host)
}
}()
dhcpCh <- struct{}{}
}
dns.HandleFunc(".", func(w dns.ResponseWriter, r *dns.Msg) {
// *Msg r has an TSIG record and it was validated
if r.IsTsig() != nil && w.TsigStatus() == nil {
lastTsig := r.Extra[len(r.Extra)-1].(*dns.TSIG)
r.SetTsig(lastTsig.Hdr.Name, dns.HmacMD5, 300, time.Now().Unix())
}
//https://stackoverflow.com/questions/4082081/requesting-a-and-aaaa-records-in-single-dns-query/4083071#4083071
if len(r.Question) == 0 {
return
}
domain := r.Question[0].Name
if idx := strings.IndexByte(domain, colon); idx > 0 {
domain = domain[:idx]
}
matchAndServe(w, r, domain, listenIP, dnsServer, dhcpCh, ip, suggest)
})
server := &dns.Server{Addr: net.JoinHostPort(listenIP, "53"), Net: "udp"}
glog.Fatalln(server.ListenAndServe())
}
func matchAndServe(w dns.ResponseWriter, r *dns.Msg, domain, listenIP, dnsServer string,
dhcpCh chan struct{}, ipNet net.IP, suggest *intelliSuggest) {
inWriteList := whiteList.Match(domain)
if !inWriteList && (blockList.Match(domain) || suggestList.Match(domain)) {
glog.V(2).Infof("match %s suss", domain)
w.WriteMsg(localA(r, domain, ipNet))
return
}
msg, err := dns.Exchange(r, dnsServer)
if err != nil && dhcpCh != nil {
select {
case dhcpCh <- struct{}{}:
default:
}
}
if msg == nil { // expose any response except nil
glog.V(1).Infof("get dns of %s fail: %s", domain, err)
return
} else if !inWriteList {
// trigger suggest logic while dns server OK
// avoid check while DNS setting not correct
go mem.Remember(suggest, domain)
}
w.WriteMsg(msg)
}
type intelliSuggest struct {
listenIP string
timeout time.Duration
}
func (i *intelliSuggest) GetOne(domain interface{}) (iface interface{}, e error) {
iface, e = struct{}{}, nil
// kill deadloop, for ugly wildcard setting dns setting
addr := strings.TrimSuffix(domain.(string), ".")
if len(strings.Split(addr, ".")) > 10 {
return
}
var (
pings = [...]struct {
viaAddr string
port Port
}{
{addr, HTTP},
{i.listenIP, HTTP},
{addr, HTTPS},
{i.listenIP, HTTPS},
}
protos = [...]*int32{new(int32) /*HTTP*/, new(int32) /*HTTPS*/}
score = new(int32)
)
for idx := range pings {
go func(idx int) {
if err := HTTPPing(pings[idx].viaAddr, addr, pings[idx].port, i.timeout); err != nil {
// local ping fail
if pings[idx].viaAddr == addr {
atomic.AddInt32(score, 1)
glog.V(1).Infof("local ping %s fail", addr)
}
// remote ping faster
} else if atomic.CompareAndSwapInt32(protos[idx/2], 0, 1) {
if pings[idx].viaAddr == i.listenIP {
atomic.AddInt32(score, 1)
glog.V(1).Infof("remote ping %s faster", addr)
}
}
// check all remote pings are faster
if atomic.LoadInt32(score) == int32(len(protos)) {
for i := range protos {
if atomic.LoadInt32(protos[i]) != 1 {
return
}
}
}
// 1. local fail and remote success
// 2. all remote pings are faster
if atomic.LoadInt32(score) >= int32(len(protos)) {
atomic.StoreInt32(score, -1) // avoid readd the suggestion
conf.AddSuggestion(addr)
glog.Infof("suggested domain: %s with score: %d", addr, *score)
}
}(idx)
}
return
}
-131
View File
@@ -1,131 +0,0 @@
package dns
import (
"bytes"
"crypto/tls"
"encoding/binary"
"io"
"net"
"time"
)
// HTTPPing try connect to a http(s) server with domain though the http addr
func HTTPPing(viaHost, domain string, port Port, timeout time.Duration) (err error) {
conn, err := net.DialTimeout("tcp", port.JoinAddr(viaHost), timeout)
if err != nil {
return err
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(timeout))
if _, err = conn.Write(port.pingMsg(domain)); err != nil {
return err
}
// err -> nil: read something succ
// err -> io.EOF: no such domain or connection refused
// err -> timeout: tcp package has been dropped
_, err = conn.Read(make([]byte, 1))
if err == io.EOF && viaHost == domain {
return nil
}
return err
}
// Port ==========================
type Port uint16
const (
HTTP Port = iota
HTTPS
)
func (p Port) JoinAddr(addr string) string {
switch p {
case HTTP:
return addr + ":80"
case HTTPS:
return addr + ":443"
default:
panic("invalid port")
}
}
func (p Port) pingMsg(domain string) []byte {
switch p {
case HTTP:
return []byte("TRACE / HTTP/1.1\r\nHost: " + domain + "\r\n\r\n")
case HTTPS:
return NewClientHelloSNIMsg(domain)
default:
panic("invalid port")
}
}
// SNI ==========================
type clientHelloSNI struct {
ContentType uint8
Version uint16
Length uint16
handshakeProtocol
}
type handshakeProtocol struct {
HandshakeType uint8
LengthExpand uint8
Length uint16
Version uint16
Random [32]byte
SessionIDLength uint8
CipherSuitesLength uint16
CipherSuite uint16
CompressionMethodsLength uint8
CompressionMethod uint8
ExtensionsLength uint16
extensionServerName
}
type extensionServerName struct {
Type uint16
Length uint16
serverNameIndicationExtension
}
type serverNameIndicationExtension struct {
ServerNameListlength uint16
ServerNameType uint8
ServerNamelength uint16
// ServerName []byte // Disable for fix length
}
func NewClientHelloSNIMsg(domain string) []byte {
length := uint16(len(domain))
msg := &clientHelloSNI{
ContentType: 0x16, // Content Type: Handshake (22)
Version: 0x0301, // Version: TLS 1.0 (0x0301)
Length: length + 56,
handshakeProtocol: handshakeProtocol{
HandshakeType: 0x01, // Handshake Type: Client Hello (1)
Length: length + 52,
Version: 0x0303, // Version: TLS 1.2 (0x0303)
Random: [32]byte{}, // [32]byte{},
SessionIDLength: 0x0, // Session ID Length: 0
CipherSuitesLength: 2, // Cipher Suites Length: 84
CipherSuite: tls.TLS_RSA_WITH_AES_128_GCM_SHA256,
CompressionMethodsLength: 1, // Compression Methods Length: 1
CompressionMethod: 0x00, // Compression null
ExtensionsLength: length + 9,
extensionServerName: extensionServerName{
Type: 0x0000, // Type: server_name (0)
Length: length + 5,
serverNameIndicationExtension: serverNameIndicationExtension{
ServerNameListlength: length + 3,
ServerNameType: 0x00, // Server Name Type: host_name (0)
ServerNamelength: length,
},
},
},
}
buf := bytes.NewBuffer(make([]byte, 0, length+61))
binary.Write(buf, binary.BigEndian, msg)
buf.WriteString(domain)
return buf.Bytes()
}
-59
View File
@@ -1,59 +0,0 @@
package dns
import (
"net"
"github.com/golang/glog"
"github.com/miekg/dns"
"github.com/wweir/sower/conf"
"github.com/wweir/sower/util"
)
var (
blockList *util.Node
whiteList *util.Node
suggestList *util.Node
)
func init() {
host, _, _ := net.SplitHostPort(conf.Conf.ServerAddr)
//first init
blockList = loadRules("block", conf.Conf.BlockList)
suggestList = loadRules("suggest", conf.Conf.Suggestions)
whiteList = loadRules("white", conf.Conf.WhiteList)
whiteList.Add(host)
glog.V(1).Infoln("load config")
conf.OnRefreash = append(conf.OnRefreash, func() error {
blockList = loadRules("block", conf.Conf.BlockList)
suggestList = loadRules("suggest", conf.Conf.Suggestions)
whiteList = loadRules("white", conf.Conf.WhiteList)
whiteList.Add(host)
glog.V(1).Infoln("reload config")
return nil
})
}
func loadRules(name string, list []string) *util.Node {
rule := util.NewNodeFromRules(".", list...)
glog.V(3).Infof("load %s rule:\n%s", name, rule)
return rule
}
func localA(r *dns.Msg, domain string, localIP net.IP) *dns.Msg {
m := new(dns.Msg)
m.SetReply(r)
if localIP.To4() != nil {
m.Answer = []dns.RR{&dns.A{
Hdr: dns.RR_Header{Name: domain, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 20},
A: localIP,
}}
} else {
m.Answer = []dns.RR{&dns.AAAA{
Hdr: dns.RR_Header{Name: domain, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: 20},
AAAA: localIP,
}}
}
return m
}
+26
View File
@@ -0,0 +1,26 @@
# Documentation Index
This directory contains long-lived design documents. Per `AGENTS.md` rules:
- `docs/` root holds only finalized, authoritative design documents (product, architecture, protocol, security, deployment, data model, test strategy, etc.).
- `docs/plans/` holds draft proposals and in-progress implementation plans — not authoritative.
- `docs/reviews/` holds review records, issue lists, and retrospective summaries — not authoritative.
When a plan or review produces a durable rule, it must be written back to the relevant root-level design document and referenced from this index.
Cross-document references must point to root-level design documents only. Never reference protocol, field, security, or deployment rules from `plans/` or `reviews/`.
## Root-Level Documents
| Document | Description |
|----------|-------------|
| [ARCHITECTURE.md](../ARCHITECTURE.md) | System boundary, layer responsibilities, data flows, design decisions |
| [security.md](security.md) | sowerd edge threat model, anti-probing layers, unknown-SNI certificate fallback, rejected directions |
## Plans
Historical plans live under `docs/plans/` and are not authoritative. Durable rules from completed plans are reflected only in the root-level documents listed above.
## Reviews
No reviews yet.
@@ -0,0 +1,8 @@
# Plan: sowerd 主动探测加固(未知 SNI 默认证书 + h2)
状态:已定稿并回写(2026-09-18)。本文为非权威历史记录,权威安全规则见 [security.md](../security.md)。
实施结果摘要:
- (b) 未知 SNI 回退默认证书:已实现并部署(commit `b2b344e`)。白名单内错误原样暴露;白名单外/空 SNI 回退主域名证书并透传客户端能力(避免多余的 RSA 签发)。
- (a) fallback 路径 h2:实施前核查否决——stdlib `httputil.ReverseProxy` 无 RFC 8441 支持,x/net(≤v0.59)仅以 `GODEBUG=http2xconnect=1` 提供 extended CONNECT 且默认禁用(golang/go#71128);按 SNI 区分 ALPN 的替代方案因静默失效风险被拒。详细证据见 [security.md](../security.md) 的 "Rejected Directions"。
@@ -0,0 +1,81 @@
# Plan: 拒绝自指代理目标(2026-10-12 r6c↔kr 递归)
状态:已定稿并回写(2026-10-12,v1.7.6)。本文为非权威历史记录;权威规则见 [ARCHITECTURE.md](../../ARCHITECTURE.md) 的 Design Decisions。
## 问题
2026-10-12 kr 上的 sowerd 被 OOM killer 杀掉(`anon-rss 969556kB`,1.6 GiB 主机),
当时它是唯一的大内存进程。触发链由另一会话定位(board 消息 `m-77507977-64f`,
finding `f-4d7d5bd2-cee`):
- r6c 的 sower 监听 `100.64.1.1`(tailscale0)上的 80/443/53/1080;公网名
`sower.wweir.fun` 的 A 记录就是 `100.64.1.1`。
- 任何指名该名字的代理请求到达 r6c 的 HTTP 代理监听器后,`cmd/sower/proxy.go` 的
`handleHTTPConn`/`handleHTTPSConn` 无条件下发给 kr(显式代理路径不查规则);
kr 的 sowerd 解析同名得到 `100.64.1.1`(tailnet 内可达)并回 dial 进 r6c:80,
r6c 再发下一次 —— **每一跳生成下一跳**。
- 实测:r6c 上 `100.64.1.1:80` 来自 kr(100.80.112.55)的 ESTABLISHED 连接
155→254→305→495→601(约 5 分钟),触发它的 curl 退出后仍在增长;kr 上 sowerd
同期 311 个 dial、649 个 fd。只有重启 r6c 的 sower 才停下。
## 不变式与修法
**代理拨号不得回到本机正在转发代理流量的监听器。** 只要目标(解析后)落在这组地址上,
转发它必然再次进入同一个代理,于是链自持。
实现(两个拨号出口都守,因为 `handleHTTPConn` 走 `DialSmart` 而 `handleHTTPSConn` 走
`DialProxyOnly`):
- `Router.SetSelfProxyAddrs(addrs...)` 记录本进程转发代理流量的监听地址:每个 DNS 监听
地址上的 `:80`、`:443`,加上 SOCKS5 地址(`cmd/sower/selfProxyAddrs`)。admin-only 与
DNS 监听器不参与:它们不转发,拨过去不可能成链。
- `DialSmart` 入口与 `DialProxyOnly` 入口各检查一次 `selfTarget(domain, port)`:只有目标
端口命中本机转发端口时才解析(否则直接跳过,零开销);目标是 IP 字面量时直接比较;否则
**向配置的上游解析器求证**(`lookupUpstreamIPs`)——绝不能用系统解析器:部署形态的客户端
对本机 DNS 被配置成对每一个“要代理的域名”都回答自己的 serve IP(这正是把客户机引到它那里
的机制),所以系统解析器的答案对所有被代理域名都是“指向我”,无法区分回环与正常流量;而
上游的答案正是 relay 会据以拨号的答案。结果按 `selfProxyLookupTTL`(1 分钟)缓存(含“无
答案”的负缓存,避免解析失败的名字每条连接都重付一次超时)。
- 两个出口都必须守:`DialSmart` 的 direct 分支(DirectRule 命中,或 `isAccess` 探测把该名字
判为可达)会直接 `directDial` 回到本机监听器,那时递归完全不出主机 —— 同样是自持的,只是
不经过隧道。
- 解析失败不视为自指证据:返回"无地址",拨号照常进行,由 relay 报自己的解析失败。
- 拒绝错误是 `router.ErrSelfProxyTarget`:HTTP 返回 `403`,SOCKS5 回 `0x02`
(connection not allowed by ruleset),HTTPS/SNI 路径关连接(该路径本来没有可写的响应)。
## 被否决的替代方案
- **只在 DNS 路径学习"指向自己的名字"**(收到 answer 是 serve IP 时记录该名字,拨号时查表):
零解析开销,但只有在名字经过本机 DNS 时才知道。用 tailscale MagicDNS / DoH / 自带缓存
解析该名字的设备不会触发学习,链照样能起。不完整。
- **在 sower 协议帧里加跳数标记**:最通用(跨机环也能断),但改了帧格式与校验和,两端必须
同时升级(见仓库 deploy skill 的协议不兼容警告),代价与风险远超本问题;本问题只需
"不要拨回自己"。
- **给监听器加并发上界**:只把 OOM 变成"连接占满槽位"的另一种拒绝服务,不消除链本身。
## 验证
- 单元测试:`go vet ./...`、`go test ./...` 全绿。守卫用例覆盖 IP 字面量自指、上游解析到自指、
上游解析到远端(正常被代理域名必须放行)、未服务端口零解析、未指定地址绑定(`0.0.0.0:443`
匹配任意本地地址)、解析失败/空答案放行、决策缓存;`cmd/sower` 侧钉住 `selfProxyAddrs`
的集合,以及“拒绝回 403 而不是静默关闭”。
- 现场(r6c,v1.7.7 release 资产 `sower-linux-arm64.tar.gz`,sha256 `8cf0a869…`、
`vcs.modified=false`):自指 `curl -x http://100.64.1.1:80 http://sower.wweir.fun/`
→ `403 Forbidden`;`--socks5-hostname` 自指 → SOCKS5 码 `2`;正常路径 `--socks5-hostname`
到 google `204`、`-x http://100.64.1.1:80` 到 baidu `200`、`/api/config` `401`;kr 上 sowerd
到 `100.64.1.1` 的连接数为 `0`(修复前峰值 601)。
### 一次必须记住的回归(v1.7.4 → v1.7.5)
首版守卫(`v1.7.4`,commit `2de08e6`)用系统解析器判定,现场立刻被推翻:`--socks5-hostname`
到 google 返回 SOCKS5 码 `2`,而 `nslookup www.google.com 127.0.0.1` 在 r6c 上返回
`100.64.1.1` —— 本机 DNS 按设计对每个被代理域名都回答自己的 serve IP。于是“解析结果是我的
地址”对**所有正常被代理域名**都成立,守卫把整条正常代理路径拒了:**这是判定依据错,不是
实现细节错**。处理是把 r6c 回滚到上一版可用二进制,改成向上游解析器求证后以 `v1.7.5` 重发
(部署期间的权限位与 LAN DNS 连锁坑见 skill `sower-deploy` 的 Pitfalls)。
## 未决(不在本次范围)
- `relay.Relay` 只设读超时导致卡死连接永久泄漏(finding `f-1ae16668-b92`),需要在
`sower-proxy/conns` 仓库修;sowerd 侧已有写超时缓解。
- r6c 客户端 1.37GB / 36 fd 的独立堆增长(finding `f-e7456e01-67c`),与连接保活无关。
@@ -0,0 +1,95 @@
# Plan: sowerd 连接资源上界(2026-10-12 OOM 复盘)
状态:已定稿并回写(2026-10-12)。本文为非权威历史记录,权威规则见 [security.md](../security.md) 的
"Connection Resource Bounds"。
实施结果摘要:
- 落地 commit `28c4f9e` / `3c869cd` / `06c357c`:`newRedirectServer` 补齐 :80 的四个超时;
`serve443` 加 `maxInboundConns = 2048` 并发上界;`writeStallListener`/`writeStallConn` 位于
TLS 之下,为每次写入武装 `writeStallTimeout`;`dumpHeapOnSignal`(`heapdump_unix.go`,
其他平台 no-op)在 SIGUSR1 时写 heap profile;`GOMEMLIMIT=512MiB` 进入安装器 unit、
打包 unit 与 kr drop-in。
- 已发布 `v1.7.3`(`v1.7.2` 因 Windows 上 `syscall.SIGUSR1` 未定义而未发布);kr 运行的是
该 release 资产的 `sowerd`。
- OOM 的**驱动**随后被定位为自指代理递归并在客户端修复(`v1.7.5`/`v1.7.6`,见
[2026-10-12-self-proxy-target-guard.md](2026-10-12-self-proxy-target-guard.md));本文件的
连接上界是下限保护,不是根因修复。
- 验证:`:80` 308 跳转;`:443` 回退反代 200;SIGUSR1 产出 heap profile;`transport/sower`
经 kr 中继 `www.baidu.com:80` 得到 HTTP/1.0 200(新增写超时不误伤中继)。
## 事件
2026-10-12 17:30:39,kr(1.6 GiB 内存、2 vCPU、zram swap `vm.swappiness=0`)上的
sowerd 被内核 OOM killer 杀掉:`anon-rss:969556kB`、`total-vm:2742656kB`。同一时刻
内核任务表里没有第二个大内存进程(其余全部 < 15 MB);kill 之后 sar 显示空闲内存
从 ~100 MB 回到 1.1 GB、page cache 从 1.03 GB 掉到 168 MB,即被释放的那 1 GB 就是
sowerd。
## 证据
- 被杀的是 v1.7.1(`/usr/local/bin/sowerd`,go1.26.8),16:50:20 启动,17:30:39 被杀。
启动 10 分钟内 journald 已报 `Under memory pressure, flushing caches`(17:00:45)。
- sowerd 自身日志在 16:58:48 之后静默 32 分钟。成功路径只在 debug 级别记录,
`:80` 路径完全不记录,因此"无日志"不等于"无活动",只能说明这些连接走的是
静默路径。
- kr 的 sar 缺了 17:00–17:20 的采样(机器被压住),17:30:46 那条是 16:50→17:30 的
均值:`%system 60.4`、`%iowait 16.3`、`ldavg 87`,网卡 rx 63.8 KB/s、tx 70.7 KB/s
(双向近似对称)。
- 同一二进制在 16:00–16:50 的实例峰值只有 27.4 MB;更早的实例(go1.27.x)跑 8 h /
39 h 峰值 24.7 / 28.8 MB。所以这**不是**随时间稳定累积的泄漏曲线,更像某个窗口
内的突发累积(`total-vm` 2.68 GB ≈ 启动基线 1.33 GB 的两倍)。
- r6c 的 sower 客户端从 17:02:35 起所有到 kr:443 的 utls 握手都 10 s 超时
(`utls handshake: read tcp ...->47.80.11.226:443: i/o timeout`),直到 sowerd
17:31:45 重启后立刻恢复。即 sowerd 在 16:58–17:30 对合法客户端已不可服务。
- 内核侧没有 accept 队列溢出(`TcpExtListenOverflows`、`TCPBacklogDrop`、
`TCPReqQFullDrop` 全为 0),说明连接是被应用层接收后才留下的,不是内核丢包。
## 分析
sowerd 自身没有按连接或按流量增长的全局结构:没有按主机名的 map,autocert 的
`state` 被 3 个白名单域名封顶,basic-auth 失败表上限 4096 且未启用。因此这 1 GB
常驻只能是"被保活的连接(goroutine + 缓冲 + socket)"或"运行时堆的一次突发峰值
未归还"。代码里有三处**上界缺失**,任一处都能让一次连接突发变成常驻内存:
1. `:80` 的 `http.Server` 完全没有超时:连接只要不把请求发完,就永久持有
goroutine、socket 与缓冲;而这是对整个互联网开放的端口。
2. `:443` 的 accept 循环没有并发上界:每个连接在握手阶段最多被持有 60 s
(`tlsHandshakeTimeout`),突发速率直接决定常驻内存;握手失败是静默返回。
3. `relay.Relay`(conns v0.1.1)只设置读超时:一端停止读取时两个方向都会阻塞,
`wg.Wait()` 永不返回,连接对、fd 与 goroutine 永久泄漏;也因为函数不返回,
连 `relay conn` 日志都不会出现。
另有两处放大因素:进程与 unit 都没有内存上限(对比 sower 客户端早就有
`SOWER_MEMORY_LIMIT_MB`,commit d0031e4),所以 sowerd 长到 1 GB 时死的是整台机器;
kr 的 zram swap 因 `vm.swappiness=0` 从未被使用。
## 处置
1. `:80` 监听器补齐 `ReadHeaderTimeout`/`ReadTimeout`/`WriteTimeout`/`IdleTimeout`
(`newRedirectServer`),让半发、空转、keep-alive 的连接不能长期占用。
2. `:443` accept 循环加并发上界 `maxInboundConns = 2048`,超限直接关闭连接(不入队)。
3. 增加本地诊断入口:收到 SIGUSR1 时把 heap profile 写入缓存目录
(`systemctl kill -s USR1 sowerd`,`go tool pprof -http :0 <file>`),不新增网络面。
4. 部署侧:sowerd 单元加 `Environment=GOMEMLIMIT=512MiB`,让失控堆退化服务而不是
杀死宿主机。
5. 发布工具链:CI 的 `go-version: ^1.26` 收敛到 `^1.27`,与本地/现场构建一致。当时曾怀疑
go1.26.8 的 GC 回归(golang/go#77567、#77361)是原因,后证明是自指递归;保留这个改动只是
让制品构建环境不再漂移。
## 未决
- **驱动已定位并修复(见姊妹文件)**:任何指名 `sower.wweir.fun`(公网 A 记录 = r6c 的
tailscale 地址 `100.64.1.1`)的代理请求会形成自持链:实测 r6c 上 `100.64.1.1:80` 连接数
155→601、kr 上 sowerd 311 个 dial / 649 个 fd,且触发请求退出后仍增长;客户端守卫已在
`v1.7.5`/`v1.7.6` 拒绝这类自指拨号。本文件的连接上界因此是下限保护(链在 2048 处停止
增长、内存有界);该链是否就是 17:30 那次膨胀的唯一成因仍无直接证据(进程已死、无 pprof、
sar 缺样),SIGUSR1 dump 的价值是把下一次复现变成可定位事件。
- `relay.Relay` 的写超时缺陷属于 `sower-proxy/conns` 仓库,应在那里修(finding
`f-1ae16668-b92`)。sowerd 侧只做上界,不给隧道加全局空闲超时,避免误杀合法但长时间
空闲的隧道。
- 客户端侧另有一处同类症状需要单独排查:r6c 的 sower 客户端
(v1.6.2 / go1.27.2)`VmRSS 1.36 GB`、`VmData 1.6 GB`,但 fd 只有 36 个。这与
"连接被保活"(fd 驱动)不是同一个根因,更像按请求/流量累积的堆数据
(`cmd/sower` 的 `stats.BindConn`/`WrapConn` 与 admin 统计路径);r6c 的 procd
也没有设置 `SOWER_MEMORY_LIMIT_MB`。sowerd 的改动不会消除它。
+9
View File
@@ -0,0 +1,9 @@
# Admin Console Plan (MVP)
Status: 已实现;本文为历史计划,非权威来源。权威规则见根目录 `ARCHITECTURE.md`。
Superseded by: [admin-persistence-config.md](admin-persistence-config.md)(扩展了本文的 MVP 边界:规则持久化与配置页)。
原 MVP 计划的后端设计(`RuleSet`、流量统计、admin HTTP 服务、`cmd/sower` 接线、构建与测试清单)已全部落地,并回写到 `ARCHITECTURE.md` 的 Layer Responsibilities、sower Data Flow 第 11–12 步与 Design Decisions,此处不再重复。仅保留两条仍在生效、且容易从代码复现不出来的前端约束:
- **不要用 `shadcn-svelte add` 覆盖 `web/src/lib/components/ui/`**:registry 产物会重新引入 bits-ui,而项目坚持手维护原生 Svelte 5 组件。UI 为 Tailwind v4 + lucide-svelte 浅色主题。
- **`web/dist` 必须提交进仓库**:`go:embed all:dist` 要求编译期文件存在,用占位符会产出"能编译但 admin 页面是坏的"的二进制。
+177
View File
@@ -0,0 +1,177 @@
# Admin 规则持久化与配置页设计
Status: 已实现并部署验证(2026-08-07);本文为历史计划,非权威来源。权威规则见根目录 `ARCHITECTURE.md`。
Supersedes: 早期 admin console MVP 计划的边界「规则变更仅运行期生效,重启后重置,不改写 TOML、不持久化」
Scope: `cmd/sower` admin 控制台
## 后续演进(超出本文 v1 范围,已实现)
- **配置页可编辑白名单扩展**:v1 仅 `log_level`、`dns.upstream`、`dns.fallback` 三个 immediate 字段;后续扩展为指针型 overrides,区分「未提交」与「显式清空」,白名单字段覆盖远程代理、监听地址、规则来源、会话选项等,除 `log_level` 与 DNS 上游立即生效外均需重启。
- **进程重启端点**:`POST /api/restart` 在 Unix 上原地 `exec`(PID 不变,systemd 无感知);不支持的平台返回错误而非假成功。
- **会话持久化选项**:新增 `admin.session_file` 说明、`admin.disable_session_persistence`、`admin.cookie_secure`;持久化改为 best-effort(磁盘失败不阻断登录、不残留有效登出会话)。
- **规则命中/未命中统计**:每规则命中计数与未命中规则的域名访问统计,由 router 观察者喂入有界内存表,供规则页排序与 miss 视图展示。
## 背景与问题
Admin 页面的规则增删目前只改内存 `RuleSet`,重启后丢失。用户需要:
1. 页面上的规则调整**重启后仍然生效**;
2. 一个**配置展示页**,并对少数字段支持**页面调整**。
### 已验证的约束(设计前提)
- 规则来源是「远程/本地文件 + 配置内联 `rules`」的合并:远程文件(如 16 万行 adlist)不可写,本地文件回写会被刷新覆盖且格式不可控。**只能存增量(delta),不能回写源文件。**
- `cmd/sower` 用 `aconfig` 加载配置,`Files` 列表首个存在的文件生效,**无多文件合并**;feconf 同样只取第一个可读 URI。**overlay TOML 方案不可行**,配置覆盖必须走类型化状态文件。
- `log_level` 当前未生效:`main.go` 的 tint handler 未传 `Level`,需先引入 `slog.LevelVar`。
- DNS `upstream`/`fallback` 存于 `router.dns`(互斥锁保护),upstream 列表 5 分钟自动重建,热切换可行。
- 已有先例:`admin.SessionFile`(默认 `/etc/sower/sessions.json`)采用 tmp+fsync+rename、0600、`MkdirAll 0700` 的原子写入模式,本设计沿用。
## 总体方案
一个 admin 私有状态文件(JSON)承载两类数据:**规则 delta** 与**白名单配置覆盖**。不回写 TOML、不回写规则文件、不做 TOML 合并。
```toml
[admin]
state_file = "/etc/sower/admin-state.json" # 新增;空字符串禁用持久化(退化为现状)
```
- 与 `session_file` 保持独立(会话是临时凭证,状态是数据,生命周期不同)。
- 文件由 admin 全权拥有:启动时校验,解析失败则重命名为 `admin-state.json.corrupt-<ts>` 并从空状态启动,日志告警。
- 逃逸开关:`--ignore-admin-state` flag(aconfig 原生支持),用于错误覆盖导致无法启动时恢复。
### 状态文件 schema
```json
{
"version": 1,
"revision": 42,
"updatedAt": "2026-08-06T15:00:00Z",
"rules": {
"block": { "add": ["**.example.com"], "remove": ["**.ad.com"] },
"direct": { "add": [], "remove": [] },
"proxy": { "add": [], "remove": [] }
},
"config": {
"log_level": "debug",
"dns_upstream": "1.1.1.1",
"dns_fallback": "223.5.5.5"
}
}
```
- `revision` 单调递增,配置 PATCH 用它做乐观并发控制;规则操作也递增(同一文件串行写)。
- `config` 段只含被覆盖的字段;缺省表示无覆盖。密钥永远不入此文件(v1 密钥全部只读)。
## 规则持久化:即时自动保存
无「保存」按钮。现有 `POST`/`DELETE /api/rules`(JSON body 携带 `category` 与 `rules`)语义不变,增加持久化:
### delta 代数
基线 = 启动时从文件 + 配置内联加载的规则集。
| 操作 | 规则在基线 | 规则在 add | 规则在 remove | 结果 |
| --- | --- | --- | --- | --- |
| PUT add | 是 | - | 是 | 清除 tombstone(恢复基线规则) |
| PUT add | 是 | - | 否 | 无操作 |
| PUT add | 否 | 否 | - | 加入 add |
| PUT add | 否 | 是 | - | 无操作(幂等) |
| DELETE | 是 | - | - | 加入 remove(tombstone) |
| DELETE | 否 | 是 | - | 从 add 移除 |
| DELETE | 否 | 否 | - | 幂等 no-op(204,现状不变) |
### 启动顺序
1. 加载配置,应用 `config` 覆盖段(仅白名单字段,在构建 router 之前);
2. 按现状加载文件/内联规则,得到基线(内存中保留基线副本,供 reset 使用);
3. 应用 `remove` tombstone,再应用 `add`;
4. GC:tombstone 已不在基线、add 已在基线的条目视为过期,清除并回写状态文件(日志记录条数)。
### 写入与失败语义
- 单次操作:在 `StateStore` 内构造候选 delta → 原子写(tmp+fsync+rename、0600)→ 成功后才修改运行期规则集。**写盘失败则丢弃候选状态并向 API 返回 5xx,内存规则与已持久化状态都不变**——不静默退化为仅内存(与 session 的降级策略不同:规则是用户数据,静默丢失比报错更糟糕)。
- 所有变更串行经过 `internal/admin/state.go` 的 `StateStore`(单互斥锁),操作频率低,不做防抖,每次操作直接落盘。
### API 增补
```text
GET /api/rules/changes → 三类 add/remove 清单与计数("自定义变更"汇总)
POST /api/rules/reset → 清空指定分类 delta(body 带 `category`)或全部 delta(body 为 `{}` 或 `{"category":""}`)
DELETE/PUT 现有端点不变
```
### 前端(规则页)
- 每次变更显示瞬时状态:`保存中…` → `已保存` / `保存失败`(失败时规则行恢复原状并报错);
- 删除操作给出 **Undo**(提示条内 6 秒撤销 = 重新 POST);
- 分类卡片头部显示「自定义变更 +3 / −1」badge,点击展开清单,提供「重置本类」。
## 配置页:全量展示 + 白名单调整
### 展示(v1 全部字段只读为主)
`GET /api/config` 返回生效配置,分组:运行(版本/构建日期/log_level)、远程代理、DNS、监听(socks5/admin 地址与状态文件)和规则来源。
每个字段带元数据:
```json
{
"key": "dns.upstream",
"value": "8.8.8.8",
"editable": true,
"applyMode": "immediate", // immediate | restart | readonly
"source": "override", // config | default | override
"constraint": "IPv4/IPv6 address"
}
```
**密钥不返回值**,只返回 `configured: true` 元数据(`remote.password`、`admin.password`)。
### 可编辑白名单(v1)
| 字段 | applyMode | 前置改造 |
| --- | --- | --- |
| `log_level` | immediate | `main.go` 引入 `slog.LevelVar` 接入 tint handler(当前 log_level 未生效,属顺手修复) |
| `dns.upstream` | immediate | `router` 新增 `SetDNS(upstream, fallback string)`:加锁更新字段、失效 refresh 定时、触发 upstream 重建 |
| `dns.fallback` | immediate | 同上 |
其余字段(监听地址、disable 开关、TLS、规则源 URL、所有密钥)v1 全部只读,元数据标注 `restart` 或 `readonly`。密码轮换需要独立流程与会话失效语义,不在本期。
### 修改流程
与规则的自动保存不同,配置修改走**显式应用**:
```text
PATCH /api/config
{ "revision": 42, "changes": { "log_level": "debug", "dns_upstream": "1.1.1.1" } }
→ 409 on revision mismatch; 400 with field errors on invalid
→ 成功:校验 → 落盘 → 应用到运行时 → 返回新 revision 与生效值
```
前端:字段行内编辑 → 底部出现变更摘要(diff:旧值 → 新值)→「应用更改」二次确认 → 提交。校验失败逐字段标红。
## 安全
- 状态文件 0600、目录 0700;密钥永不写入状态文件、永不出现在 API 响应与日志;
- 日志只记录 revision 与字段名,不记录值中的敏感部分;
- 所有写入先校验后落盘;启动时覆盖值非法 → 忽略该字段并告警(不阻断启动),`--ignore-admin-state` 可整体跳过;
- API 全部在既有 session 认证之后;PATCH 有 body 大小限制与 `DisallowUnknownFields`。
## 测试
- delta 代数:上表全部分支(含幂等 PUT、tombstone 恢复);
- 重启恢复:基线 + delta → 运行时规则集等于预期;
- GC:过期 tombstone/add 清除并回写;
- 原子写失败:注入写盘错误 → API 5xx 且内存回滚;
- 并发:-race 下并发 PUT/DELETE/PATCH 串行化正确;
- PATCH:revision 冲突 409、非法值 400、未知字段 400、密钥字段不出现在 GET 响应;
- reset:单类/全部 reset 后规则集回到基线;
- 启动:损坏状态文件 → quarantine + 空状态启动;`--ignore-admin-state` 跳过。
## 实施顺序
1. `internal/admin/state.go`:StateStore + 原子写 + schema(独立可测);
2. 规则持久化接通(RuleManager 适配层 + 启动顺序 + GC)+ 规则页保存状态/Undo/变更汇总;
3. `slog.LevelVar` 接线 + `router.SetDNS`;
4. `GET/PATCH /api/config` + 配置页(展示先行,编辑随后);
5. 文档:定稿后把规则与配置规则沉淀到主设计文档(`ARCHITECTURE.md` 设计决策)。
+28
View File
@@ -0,0 +1,28 @@
# Plan: sowerd Site Routing by SNI
状态:已实现;本文为非权威历史计划。权威规则见根目录 `ARCHITECTURE.md`,安全规则见 `docs/security.md`。
本文只保留已定稿设计里**不容易从代码复现的取舍记录**;配置字段、校验分支与运行流程以代码和 `ARCHITECTURE.md` 为准。
## 目标
将 443 的 fallback 从单一 `fake_site` 扩展为按 TLS SNI 路由:若干 `site_routes` 把一组精确域名映射到 upstream URL,未命中回落 `fake_site`。
## 非目标
- 不动 `sower` 认证流量的目标路由(由 transport 帧解码决定,与域名无关)。
- 不支持通配域名(`*.example.com`),只做精确匹配;拒绝理由见 `docs/security.md` 的 "Rejected Directions"。
- 不在 `sower` 客户端侧实现。
- HTTP(:80)不参与 site routing(拒绝理由见 `docs/security.md`)。
## 关键取舍
- **精确 SNI 匹配,不做通配**:SNI 路由是精确 map 命中,通配要么让所有子域塌到同一 upstream(失去按域分流的意义),要么在边缘最热路径上引入优先级歧义。
- **用 reverse proxy 而非裸 TCP relay**:只有交给 `http.Server` 解析 HTTP 才能拿到路径做前缀分流,并统一重写 outbound `Host` 与转发头。代价是每条连接多一层 HTTP 解析。
- **单连接 listener 模式**:把 `rereadConn` 包装成只 `Accept` 一次的 `net.Listener` 交给 `http.Server.Serve`,让标准库处理请求生命周期,而不是自写 HTTP 解析循环。
- **HTTP/1.1-only 是有意决策**:`NextProtos` 只留 `http/1.1`;advertise h2 后 WebSocket 需要 RFC 8441 extended CONNECT,stdlib 不支持。详细证据与 per-SNI ALPN 替代方案被拒的理由见 `docs/security.md` 的 "Rejected Directions"。
- **未配置 `site_routes` 时 `fake_site` 仍是唯一兜底目标**:字段语义不变(已存在目录或 `host:port`),SNI 分流表为空即永不命中。但兜底机制本身已随这轮改造变化,不再是原先的裸 TCP relay:探测到 HTTP/1.x 请求行时走反向代理(保留原始 `Host`、设置 edge 转发头),其余流量才裸 relay,且 `NextProtos` 只剩 `http/1.1`(见 `ARCHITECTURE.md` 的 sowerd Data Flow 第 13–14 步)。
## 已推翻的实现决策
- ~~reverse proxy 会拒绝 HTTP upgrade 请求~~ — **已推翻**:现行实现不做任何拒绝,`httputil.ReverseProxy` 在 101 时经 hijack 直通,`TestReverseProxyConnWebSocket` 与 `TestReverseProxyConnPathRoutedWebSocket` 均通过,见 `ARCHITECTURE.md`。
+309
View File
@@ -0,0 +1,309 @@
# Security: sowerd Edge Threat Model and Anti-Probing Rules
Authoritative security design for the `sowerd` TLS edge. Rules here are
normative; historical analysis lives in `docs/plans/` (non-authoritative).
## Threat Model
Two adversaries are in scope:
- **Passive DPI observer** — sees wire bytes and flow metadata; does not
complete handshakes.
- **Active prober** — completes TLS handshakes, sends arbitrary application
bytes, observes responses. Does not know the sower password.
Goal: keep the `443` edge behaviorally consistent with an ordinary TLS
website for both adversaries, so that no single observation — wire bytes,
handshake behavior, or application responses — is by itself proof that the
sower transport exists. This is a design goal, not a guarantee: known
residual signals that fall short of full indistinguishability are listed
under "Residual Accepted Signals" and drive the rejected-directions
recorded below.
## Layer 1 — Wire Format Is Ordinary HTTPS
- Real TLS 1.2+ with publicly trusted certificates: autocert/Let's Encrypt,
or a configured static pair. All sower metadata (the transport `Head`)
rides inside TLS; there is no protocol-specific plaintext.
- ALPN is `http/1.1` only. This is a residual but accepted signal (see
"Rejected directions" for why h2 is not served).
## Layer 2 — Protocol Identification Happens After the Handshake
- The sower transport is recognized by the first application byte `0x80`.
`~0x80 == 0x7F` is the highest printable code point, so no valid HTTP/1.1
plaintext can begin with `0x80`; probe dispatch is unambiguous.
- The `Head` checksum (HMAC-SHA256 over cmd/port/target keyed by the
password) authenticates the frame and prevents MITM retargeting of
captured frames. It is not an encryption layer; confidentiality and
integrity come from TLS.
- **ALPN is never trusted for dispatch.** A sower client may offer `h2`
(e.g. uTLS Chrome fingerprint); the post-handshake sniff routes it to the
transport path regardless of the negotiated protocol.
## What the Transport Password Gates
The frame checksum is the only access control on the transport path. Its
consequences are deliberate, and must not be mistaken for per-service
authorization:
- **One shared secret, no identity.** Every client presents the same password;
there is no per-client identity, no per-target authorization, and no
revocation short of rotating the password 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 — there is no allowlist. A
leaked password therefore exposes the host's whole network position, not just
the service the operator meant to share, including cloud metadata endpoints
and same-VPC peers.
- **No attempt throttling.** A wrong checksum falls back to the website path
(`cmd/sowerd/main.go:handleConn`) without counting the failure, and nothing
else rate-limits the transport path: a weak password is guessable online at
roughly one TLS handshake per attempt. Only the `site_routes` visitor gate is
throttled.
- **A fresh install generates its own password.** `sowerd -i` writes a random
128-bit client password into a new `/etc/sower/sowerd.toml` and prints it once
(`internal/install`), instead of shipping the template's placeholder. The
example template keeps `change_me` for manual/development configs, and
validation rejects only an empty password (`SowerdConfig.Validate`), so a
hand-written config is the operator's responsibility. The HMAC key is the raw
password with no KDF (see Residual Accepted Signals).
Use it as a transport, not as a boundary: keep the exposed service's own
authentication, keep the password long and unique to this deployment, and keep
what sowerd can dial as small as the deployment allows.
## Layer 3 — Everything Else Is a Website
Dispatch order for non-transport traffic (`cmd/sowerd/main.go:handleConn`):
1. TLS SNI hit in `site_routes` → reverse proxy to the route's upstream.
2. Otherwise, a valid HTTP/1.1 request line → reverse proxy to `fake_site`.
3. Otherwise → raw TCP relay to `fake_site`.
A matched route is always fed to the reverse proxy, HTTP request line or not:
`site_routes` serves HTTP/1.1 only. Non-HTTP traffic on a routed domain fails
HTTP parsing rather than being relayed, which is also why an HTTP-layer visitor
gate (below) covers the route's entire surface.
Transport auth failures fall back to the same website paths (never RST, never
hang), so a prober without the password observes ordinary site behavior on
every probe.
## Site Route Visitor Gate
`site_routes` upstreams are public: autocert issues certificates for the routed
domains on demand, and issued certificates are published in Certificate
Transparency logs. The optional per-route `site_routes.auth` block gates the
route with HTTP Basic credentials, for upstreams that cannot authenticate their
own visitors — the edge is the only insertion point, because sowerd owns 80/443
on the host.
Rules:
- Opt-in and default off. A route without `auth` (or with both fields empty)
behaves exactly as before, including passing the client's `Authorization`
header through to the upstream.
- The gate runs in the reverse-proxy handler (`reverseProxyConn`), so it covers
the route's whole surface: path-prefix overrides and WebSocket upgrades
included. The sower transport path is dispatched before SNI routing and
carries its own frame-checksum authentication; it is never gated.
- Credentials are compared in constant time (`sha256` + `subtle`, so length
does not leak).
- A passing request has its `Authorization` header removed. The edge
credential authenticates the visitor to the edge, not to the upstream:
forwarding it would copy the edge password into the upstream and its logs,
and would collide with an upstream realm that carries the same name.
- Failures are throttled per direct peer address: `authMaxFails` failed
attempts from one source accumulate (there is no decay window; only a request
that passes the gate clears the count) and lock that source out for
`authLockDuration`. The failure map is bounded (`authMaxEntries`) so
source-IP rotation cannot grow it without bound. A lockout only ever affects
the IP that caused it; the counter is shared by all gated routes so a visitor
cannot get a fresh budget by moving between them.
- Upstream credentials are a separate mechanism: URL userinfo
(`upstream = "http://user:pass@host"`) is sent to the upstream as Basic
credentials. `net/http` turns userinfo into that header only in
`http.Client`; `httputil.ReverseProxy` round-trips through a `Transport` and
drops it, so sowerd sets the header explicitly. Upstream passwords are
redacted in error logs (`url.URL.Redacted`).
- ACME is unaffected: HTTP-01 runs on `:80` and TLS-ALPN-01 completes during
the handshake, both before any request reaches the reverse proxy.
- What it is not: a substitute for the upstream's own authentication, an
account system (one credential per route, no session, no logout), or a
second factor. A weak password stays weak against a distributed or
IPv6-rotating attacker. Cross-origin browser calls that need a CORS
preflight also stay broken: a preflight carries no credentials, so the edge
rejects it before the cross-origin request is even attempted.
- Browser WebSocket clients are the open edge case. The gate does run on the
`Upgrade` request, but script cannot set `Authorization` on a WebSocket
handshake: a browser can only pass it by replaying the origin's cached Basic
credential, and whether it does so is browser-specific and unverified here.
The gate is per-route, so a `/ws` path cannot be exempted while the rest of
the route stays gated. A reconnecting client makes this self-inflicted: every
failed handshake feeds the shared per-IP counter, so an auto-reconnecting
browser client (socket.io and friends) can lock its own source IP out of the
whole route — ordinary page loads included — for `authLockDuration`, and
again after each expiry. Verify in the target browser before putting a
browser WebSocket route behind the gate, or reach that upstream through the
sower transport instead.
## Site Route Upstream Authority
**Rule: a site route states explicitly whether its upstream sees the client's
authority or the upstream's own host.**
- By default the reverse proxy rewrites the outbound `Host` to the route's
upstream host and reports the client's authority in `X-Forwarded-Host`. An
upstream that virtual-hosts on its own address keeps working, and a
misconfigured route cannot make an upstream believe it serves a name it does
not own.
- `authority = "client"` forwards the client's `Host` verbatim instead. This is
what an upstream that validates the visited authority itself needs — an exact
host allowlist, or an `Origin` versus `Host` comparison — because it rejects
the rewritten host: pi-web answers `403` to every browser-originated `/api`
request, so the page loads and its realtime stream never connects. The
forwarded authority is client-controlled, so preserving it protects nothing
by itself: the upstream still has to check the name, and its allowlist has to
include the public domain (pi-web's `PI_WEB_ALLOWED_HOSTS`). The route's
visitor gate still runs, and inbound `X-Forwarded-*` headers from the client
are still discarded, so one request cannot tell the upstream two different
stories.
- Opt in per route, because an upstream that does not check the authority
accepts any name: with `"client"`, a prober chooses what that upstream records
in its logs, emits in redirects, and sets as a cookie domain. That is why the
default stays the upstream host.
## Unknown-SNI Certificate Fallback
**Rule: the TLS handshake must complete for arbitrary SNI.** A handshake
failure on unknown names is an active-probing signal — real sites present
their default vhost certificate.
Implementation rules (`cmd/sowerd/main.go:getCertificateWithFallback`,
autocert mode only):
- The issuance whitelist is `cert.domains` + every `site_routes` domain —
the same source as the autocert `HostPolicy`, so the membership decision
cannot drift from issuance behavior.
- **Whitelisted names** take the plain autocert path. Failures (ACME
issuance, cache) surface as-is and are never masked by a wrong-name
certificate.
- **Unknown or empty SNI** is answered with the primary domain's
certificate. "Primary" is the first entry of the issuance whitelist, so
keep `cert.domains[0]` a concrete domain (a leading wildcard entry would
break the fallback lookup).
- The client's capabilities (signature schemes, curves, cipher suites) are
carried over to the fallback lookup so autocert resolves its usual
ECDSA/RSA cache key; a bare hello would count as non-ECDSA and force a
spurious RSA issuance.
- Static certificate mode (`cert.cert` configured) already answers every
SNI with the configured pair; the wrapper is autocert-mode only.
## Client-Side Fingerprint
The default Go TLS `ClientHello` is an uncommon fingerprint for a "browser".
sower clients should set `[remote.tls] client_hello = "chrome"` (uTLS) in
deployment configs. This is a client configuration knob, not a protocol
property, and has no server-side effect.
## Connection Resource Bounds
Both listeners are internet-facing, so an inbound connection must have a
bounded cost and a bounded lifetime. These bounds are normative:
- The plain-HTTP listener (`:80`) sets `ReadHeaderTimeout`, `ReadTimeout`,
`WriteTimeout` and `IdleTimeout`. It only ever serves a redirect or an ACME
challenge, so a connection that is slow, idle or half-sent has nothing to
wait for; without the timeouts a peer can pin a goroutine, a socket and its
buffers indefinitely, and nothing about it is logged.
- The TLS listener (`:443`) serves at most `maxInboundConns` connections at
once; a connection that finds no slot is closed on accept instead of queued.
Per-connection state (TLS record buffers, relay copy buffers, goroutine
stacks) is what turns an arrival-rate burst into resident memory.
- Every write on a `:443` connection carries a deadline (`writeStallTimeout`,
armed below the TLS listener, so handshake, reverse-proxied response and
hijacked tunnel are all covered). The relay library only arms read
deadlines, so a peer that stops reading would otherwise block its writer
forever, leaking the connection pair, its goroutines and its descriptors.
Idle tunnels issue no writes and are not affected.
- A heap profile is written per `SIGUSR1` (`systemctl kill -s USR1 sowerd`,
read with `go tool pprof`). It is the only way to attribute a grown heap to
an allocation site, and unlike an HTTP endpoint it adds no network surface.
The service unit also carries a soft heap ceiling (`GOMEMLIMIT`): a heap that
grows without bound must degrade this service, not take the host down with it
(the 2026-10-12 OOM on `kr` reached 970 MiB anon-rss on a 1.6 GiB host).
Motivation and the historical analysis live in `docs/plans/` (non-authoritative).
## Rejected Directions
Decisions recorded here so they are not re-litigated without new evidence:
- **QUIC / HTTP3 transport** — requires either a dedicated UDP endpoint or a
custom ALPN. Both expose nonstandard-protocol existence to an active
prober (port scanning / ALPN enumeration), which is strictly weaker than
the current byte sniff that reveals nothing. Would also require a TCP
fallback path everywhere. Experimented with on 2026-09-17/18 and removed.
- **h2 on the fallback path** — once `h2` is advertised, browsers negotiate
it and WebSockets require RFC 8441 extended CONNECT. The Go standard
library `httputil.ReverseProxy` has no extended-CONNECT support (it treats
the request as a plain CONNECT), and `x/net/http2` (through v0.59) gates
extended CONNECT behind `GODEBUG=http2xconnect=1`, default off
(golang/go#71128). The per-SNI ALPN workaround (h1-only for WS-bearing
routes) was declined: it makes "new WS routes must opt out of h2" a silent
failure mode on an anti-censorship edge.
- **Wildcard `site_routes` domains** (`*.example.com`) — rejected in
`config/sowerd.go` (`validateSiteRoutes`) and must stay rejected. SNI
dispatch is an exact-match map (`siteRouter.lookup`); a wildcard either
collapses every subdomain onto one upstream — losing the per-domain
routing it was meant to add — or needs priority/ambiguity rules on the
edge's hottest path. `autocert.HostWhitelist` is exact-match too, so
wildcard issuance would additionally force a hand-written `HostPolicy`.
- **Additional listen addresses and configurable 80/443 ports** — one
`serve_ip` and hardcoded 80/443 are deliberate: an edge listening on a
non-standard port is itself an active-probing signal. Multiple public
addresses are served by one instance per address, not by widening this
listener. `serve_ip` may be a concrete address to bind a single interface.
- **Per-site certificate binding** — the edge's certificate set is
independent of the `site_routes` table (autocert issues for the union of
routed domains plus `cert.domains`; static mode serves one pair for every
SNI). Keeping it independent means a prober cannot enumerate configured
sites from the certificate. Multi-SAN certificates already cover the
legitimate multi-domain case.
- **Domain routing on plain HTTP (:80)** — the :80 listener only completes
ACME challenges and 308-redirects public traffic to HTTPS; the `fake_site`
directory is served there on loopback only (`isLocalRemoteAddr`). Routing
:80 by domain would hand out a cleartext bypass of the TLS edge.
- **Session-cookie login for site routes** — declined in favor of HTTP Basic:
a login page plus server-side sessions adds edge state and login-page
surface, and non-browser clients (curl, media players, mobile apps) speak
Basic rather than HTML forms. Basic costs one browser credential prompt per
origin and keeps the gate stateless.
- **Gating the `fake_site` fallback** — the fake site is the decoy that answers
unknown SNI (probes and failed transport auth included) and must keep
answering like an ordinary public website. An operator's real site that needs
a gate belongs in `site_routes`.
- **An upstream trusting `X-Forwarded-Host` instead of having its `Host`
forwarded** — the adaptation belongs to the edge: forwarding headers are
client-forgeable wherever sowerd is not the immediate proxy, while the edge
already owns the connection and discards what the client supplied. An
upstream that needs its own name gets it through `authority = "client"`.
- **Rejecting known-weak passwords in `SowerdConfig.Validate`** — declined: a
blocklist of placeholder strings is not a strength policy (it catches
`change_me` and nothing else), and it would stop existing deployments from
starting on upgrade. The fixable half of that risk is handled where the
config is created — the installer now generates a random password instead of
writing the template's placeholder — and the remaining operator duty (replace
the placeholder in a hand-written config) is stated under "What the Transport
Password Gates".
## Residual Accepted Signals
- ALPN `http/1.1`-only: plausible for small self-hosted sites; h2 is
blocked by the rejection above.
- Flow statistics (many short-lived TLS connections, no HTTP semantics on
transport connections): common to all TLS-in-TLS proxies; connection reuse
is deferred.
- The `Head` HMAC key is the raw password without a KDF: only relevant to an
observer who can already capture a `Head`, which requires defeating TLS.
+51 -30
View File
@@ -1,34 +1,55 @@
module github.com/wweir/sower
module github.com/sower-proxy/sower
go 1.12
go 1.26.1
require (
github.com/bifurcation/mint v0.0.0-20181105071958-a14404e9a861 // indirect; lock for quic
github.com/cheekybits/genny v1.0.0 // indirect
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b
github.com/golang/mock v1.2.0 // indirect
github.com/guregu/null v3.4.0+incompatible // indirect
github.com/hashicorp/golang-lru v0.5.1 // indirect
github.com/klauspost/cpuid v1.2.0 // indirect
github.com/klauspost/reedsolomon v1.9.0 // indirect
github.com/lib/pq v1.0.0 // indirect
github.com/lucas-clemente/aes12 v0.0.0-20171027163421-cd47fb39b79f // indirect
github.com/lucas-clemente/quic-go v0.10.1
github.com/lucas-clemente/quic-go-certificates v0.0.0-20160823095156-d2f86524cced // indirect
github.com/miekg/dns v1.1.4
github.com/onsi/ginkgo v1.7.0 // indirect
github.com/onsi/gomega v1.4.3 // indirect
github.com/pelletier/go-toml v1.2.0
github.com/pkg/errors v0.8.1
github.com/satori/go.uuid v1.2.0 // indirect
github.com/templexxx/cpufeat v0.0.0-20180724012125-cef66df7f161 // indirect
github.com/templexxx/xor v0.0.0-20181023030647-4e92f724b73b // indirect
github.com/tjfoc/gmsm v1.0.1 // indirect
github.com/ulule/deepcopier v0.0.0-20171107155558-ca99b135e50f // indirect
github.com/wweir/mem-go v0.0.0-20190109100331-8673ab596296
github.com/xtaci/kcp-go v5.0.7+incompatible
go.universe.tf/netboot v0.0.0-20190215013330-01f30467ac8e
golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6 // indirect
golang.org/x/sys v0.0.0-20190305064518-30e92a19ae4a
github.com/cristalhq/aconfig v0.19.0
github.com/cristalhq/aconfig/aconfigtoml v0.17.1
github.com/krolaw/dhcp4 v0.0.0-20190909130307-a50d88189771
github.com/libp2p/go-reuseport v0.4.0
github.com/lmittmann/tint v1.2.0
github.com/maypok86/otter/v2 v2.3.0
github.com/miekg/dns v1.1.72
github.com/oschwald/geoip2-golang v1.13.0
github.com/prometheus/client_golang v1.24.1
github.com/refraction-networking/utls v1.8.2
github.com/sower-proxy/conns v0.1.1
github.com/sower-proxy/deferlog/v2 v2.1.5
go.opentelemetry.io/otel v1.45.0
go.opentelemetry.io/otel/exporters/prometheus v0.67.0
go.opentelemetry.io/otel/metric v1.45.0
go.opentelemetry.io/otel/sdk/metric v1.45.0
golang.org/x/crypto v0.54.0
golang.org/x/sys v0.47.0
)
require (
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/andybalholm/brotli v1.2.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.19.2 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/oschwald/maxminddb-golang v1.13.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/stretchr/testify v1.11.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/sdk v1.45.0 // indirect
go.opentelemetry.io/otel/trace v1.45.0 // indirect
golang.org/x/mod v0.38.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/tools v0.48.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+111 -109
View File
@@ -1,114 +1,116 @@
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/bifurcation/mint v0.0.0-20181105071958-a14404e9a861 h1:x17NvoJaphEzay72TFej4OSSsgu3xRYBLkbIwdofS/4=
github.com/bifurcation/mint v0.0.0-20181105071958-a14404e9a861/go.mod h1:zVt7zX3K/aDCk9Tj+VM7YymsX66ERvzCJzw8rFCX2JU=
github.com/cheekybits/genny v1.0.0 h1:uGGa4nei+j20rOSeDeP5Of12XVm7TGUd4dJA9RDitfE=
github.com/cheekybits/genny v1.0.0/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cristalhq/aconfig v0.17.0/go.mod h1:NXaRp+1e6bkO4dJn+wZ71xyaihMDYPtCSvEhMTm/H3E=
github.com/cristalhq/aconfig v0.19.0 h1:fAo9ZObtzboHnf+5eAoMfb9KTDU5G/ij8OYO2wbpmM0=
github.com/cristalhq/aconfig v0.19.0/go.mod h1:9ogrGEt9yU5V4pif/ThkVUfhj8JkdV+iDeahZGgfnDU=
github.com/cristalhq/aconfig/aconfigtoml v0.17.1 h1:TA3xH8mALD8YeULsr4v87cMbKGBma35lWeRga4Xn11Q=
github.com/cristalhq/aconfig/aconfigtoml v0.17.1/go.mod h1:xt4kCEjhgvHWO/oDGJHQHrW5CnvSEoBjJhRXVBdYbhQ=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/mock v1.2.0 h1:28o5sBqPkBsMGnC6b4MvE2TzSr5/AT4c/1fLqVGIwlk=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.1.0 h1:9tmYDKxX2N1am4Ooz6a2HC7DfK0CWNuhT8T/Fi/bvtA=
github.com/google/go-cmp v0.1.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/guregu/null v3.4.0+incompatible h1:a4mw37gBO7ypcBlTJeZGuMpSxxFTV9qFfFKgWxQSGaM=
github.com/guregu/null v3.4.0+incompatible/go.mod h1:ePGpQaN9cw0tj45IR5E5ehMvsFlLlQZAkkOXZurJ3NM=
github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/hcl v0.0.0-20171017181929-23c074d0eceb/go.mod h1:oZtUIOe8dh44I2q6ScRibXws4Ajl+d+nod3AaR9vL5w=
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/klauspost/cpuid v1.2.0 h1:NMpwD2G9JSFOE1/TJjGSo5zG7Yb2bTe7eq1jH+irmeE=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/reedsolomon v1.9.0 h1:usyTY5K7D2B6WOHn2jmpB7ky8Qom96mShZmmq3OW4JU=
github.com/klauspost/reedsolomon v1.9.0/go.mod h1:CwCi+NUr9pqSVktrkN+Ondf06rkhYZ/pcNv7fu+8Un4=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lucas-clemente/aes12 v0.0.0-20171027163421-cd47fb39b79f h1:sSeNEkJrs+0F9TUau0CgWTTNEwF23HST3Eq0A+QIx+A=
github.com/lucas-clemente/aes12 v0.0.0-20171027163421-cd47fb39b79f/go.mod h1:JpH9J1c9oX6otFSgdUHwUBUizmKlrMjxWnIAjff4m04=
github.com/lucas-clemente/quic-go v0.10.1 h1:ipcMmYP9RT+b1YytOKGUY1qndxPGOczVEQkAVz3CZrs=
github.com/lucas-clemente/quic-go v0.10.1/go.mod h1:wuD+2XqEx8G9jtwx5ou2BEYBsE+whgQmlj0Vz/77PrY=
github.com/lucas-clemente/quic-go-certificates v0.0.0-20160823095156-d2f86524cced h1:zqEC1GJZFbGZA0tRyNZqRjep92K5fujFtFsu5ZW7Aug=
github.com/lucas-clemente/quic-go-certificates v0.0.0-20160823095156-d2f86524cced/go.mod h1:NCcRLrOTZbzhZvixZLlERbJtDtYsmMw8Jc4vS8Z0g58=
github.com/magiconair/properties v1.7.4/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/miekg/dns v1.1.4 h1:rCMZsU2ScVSYcAsOXgmC6+AKOK+6pmQTOcw03nfwYV0=
github.com/miekg/dns v1.1.4/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/mitchellh/mapstructure v0.0.0-20180203102830-a4e142e9c047/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/pelletier/go-toml v1.1.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/krolaw/dhcp4 v0.0.0-20190909130307-a50d88189771 h1:t2c2B9g1ZVhMYduqmANSEGVD3/1WlsrEYNPtVoFlENk=
github.com/krolaw/dhcp4 v0.0.0-20190909130307-a50d88189771/go.mod h1:0AqAH3ZogsCrvrtUpvc6EtVKbc3w6xwZhkvGLuqyi3o=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/libp2p/go-reuseport v0.4.0 h1:nR5KU7hD0WxXCJbmw7r2rhRYruNRl2koHw8fQscQm2s=
github.com/libp2p/go-reuseport v0.4.0/go.mod h1:ZtI03j/wO5hZVDFo2jKywN6bYKWLOy8Se6DrI2E1cLU=
github.com/lmittmann/tint v1.2.0 h1:AogHRHy8HUJUnNJBHJlYa+fR4YY8mko2cnCp67xn9JY=
github.com/lmittmann/tint v1.2.0/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/maypok86/otter/v2 v2.3.0 h1:8H8AVVFUSzJwIegKwv1uF5aGitTY+AIrtktg7OcLs8w=
github.com/maypok86/otter/v2 v2.3.0/go.mod h1:XgIdlpmL6jYz882/CAx1E4C1ukfgDKSaw4mWq59+7l8=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/oschwald/geoip2-golang v1.13.0 h1:Q44/Ldc703pasJeP5V9+aFSZFmBN7DKHbNsSFzQATJI=
github.com/oschwald/geoip2-golang v1.13.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo=
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/spf13/afero v1.0.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.1.0/go.mod h1:r2rcYCSwa1IExKTDiTfzaxqT2FNHs8hODu4LnUfgKEg=
github.com/spf13/cobra v0.0.1/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
github.com/spf13/jwalterweatherman v0.0.0-20180109140146-7c0cea34c8ec/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.0/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/viper v1.0.0/go.mod h1:A8kyI5cUJhb8N+3pkfONlcEcZbueH6nhAm0Fq7SrnBM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/templexxx/cpufeat v0.0.0-20180724012125-cef66df7f161 h1:89CEmDvlq/F7SJEOqkIdNDGJXrQIhuIx9D2DBXjavSU=
github.com/templexxx/cpufeat v0.0.0-20180724012125-cef66df7f161/go.mod h1:wM7WEvslTq+iOEAMDLSzhVuOt5BRZ05WirO+b09GHQU=
github.com/templexxx/xor v0.0.0-20181023030647-4e92f724b73b h1:mnG1fcsIB1d/3vbkBak2MM0u+vhGhlQwpeimUi7QncM=
github.com/templexxx/xor v0.0.0-20181023030647-4e92f724b73b/go.mod h1:5XA7W9S6mni3h5uvOC75dA3m9CCCaS83lltmc0ukdi4=
github.com/tjfoc/gmsm v1.0.1 h1:R11HlqhXkDospckjZEihx9SW/2VW0RgdwrykyWMFOQU=
github.com/tjfoc/gmsm v1.0.1/go.mod h1:XxO4hdhhrzAd+G4CjDqaOkd0hUzmtPR/d3EiBBMn/wc=
github.com/ulule/deepcopier v0.0.0-20171107155558-ca99b135e50f h1:QatZ4lsJBY3x1+Imst9g95+vUl7m52dqM9Pi4aSMW8w=
github.com/ulule/deepcopier v0.0.0-20171107155558-ca99b135e50f/go.mod h1:BNLmYJ8oMJPIPpNx5968jCyUhwEU1XT3YsuOqtbo5qo=
github.com/wweir/mem-go v0.0.0-20190109100331-8673ab596296 h1:/HkUfg+ZMx/tNdnyJdVlhyv+xO3A7ZlpfL9nFLWLYcc=
github.com/wweir/mem-go v0.0.0-20190109100331-8673ab596296/go.mod h1:k7rjBGWoJ+JKwvfe8juAX0zgybjo/Yo3JGkca5f/06s=
github.com/xtaci/kcp-go v5.0.7+incompatible h1:zs9tc8XRID0m+aetu3qPWZFyRt2UIMqbXIBgw+vcnlE=
github.com/xtaci/kcp-go v5.0.7+incompatible/go.mod h1:bN6vIwHQbfHaHtFpEssmWsN45a+AZwO7eyRCmEIbtvE=
go.universe.tf/netboot v0.0.0-20190215013330-01f30467ac8e h1:7H7ZaK8/trvkwdGUmQZIkXIGVUDWG7z8p9stiR9KvBY=
go.universe.tf/netboot v0.0.0-20190215013330-01f30467ac8e/go.mod h1:SayXxCfNj1vDO8eF7GpY1xEZ8px5LfHUSr+uJJMcDTo=
golang.org/x/crypto v0.0.0-20180206190813-d9133f546934/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25 h1:jsG6UpNLt9iAsb0S2AGW28DveNzzgmbXR+ENoPjUeIU=
golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20180202180947-2fb46b16b8dd h1:sFXnfxrhbeCXDiKa6Ra98LxiHoUWSHs9AKOxFURy5pY=
golang.org/x/net v0.0.0-20180202180947-2fb46b16b8dd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd h1:nTDtHvHSdCn1m6ITfMRqtOd/9+7a3s8RBNOZ3eYZzJA=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6 h1:bjcUS9ztw9kFmmIxJInhon/0Is3p+EHBKNgquIzo1OI=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180202135801-37707fdb30a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190305064518-30e92a19ae4a h1:wsSB0WNK6x5F2PxWYOQpGTzp/IH7X8V603VJwSXZUWc=
golang.org/x/sys v0.0.0-20190305064518-30e92a19ae4a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/text v0.0.0-20171227012246-e19ae1496984/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sower-proxy/conns v0.1.1 h1:9VPVW+sFc/y/IvXbUQTbXNSPF3I+1IG5mbrQTt5bdTE=
github.com/sower-proxy/conns v0.1.1/go.mod h1:p3ZM/8MolzW4TN0LaiPJnwnhhif3y9sBi2vQEWHvwhg=
github.com/sower-proxy/deferlog/v2 v2.1.5 h1:3MF21JmCbIOI7ROWqwfBDDX4Y6g3EkDp4p7TZxJFyyg=
github.com/sower-proxy/deferlog/v2 v2.1.5/go.mod h1:mdLrL/bxiTNhxeRLtRgKAecQddugI0iJ0ArC3yDwVSw=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
go.opentelemetry.io/otel/exporters/prometheus v0.67.0 h1:7IefDa35e6V3NoiqIeLDMDxMFyZDk5qcoC0Ax4cC16E=
go.opentelemetry.io/otel/exporters/prometheus v0.67.0/go.mod h1:nsPI1awTg5Vmg1YrommL2mVarVGlqc4yXOoKAkPRD0c=
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
go.opentelemetry.io/otel/metric/x v0.67.0 h1:PcicCNZFkZ4bXfSooXdo3WN7RBOVOtjVdo1wD358Uns=
go.opentelemetry.io/otel/metric/x v0.67.0/go.mod h1:FBjCWZe6wgcqxcMtjdGiClDKXb2YxxXii0CXftE4QtI=
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o=
go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA=
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.0.0 h1:uUkhRGrsEyx/laRdeS6YIQKIys8pg+lRSRdVMTYjivs=
gopkg.in/yaml.v2 v2.0.0/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+428
View File
@@ -0,0 +1,428 @@
#!/bin/sh
# sower one-shot installer.
#
# curl -fsSL https://raw.githubusercontent.com/sower-proxy/sower/master/install.sh | sudo sh
#
# Downloads the release tarball for this machine, installs the sower client
# binary plus a starter config, and wires up the platform service:
#
# * Linux with systemd -> /etc/systemd/system/sower.service
# * OpenWrt (procd) -> /etc/init.d/sower
# * macOS (launchd) -> /Library/LaunchDaemons/sower.plist
#
# Re-running the script upgrades in place and keeps the existing config.
# Only POSIX sh features are used so busybox ash on routers works too.
set -eu
REPO="sower-proxy/sower"
TMP=""
PLATFORM=""
PREFIX=""
CONFIG_DIR=""
VERSION="latest"
MIRROR=""
SERVICE="auto"
WITH_SERVER="no"
DRY_RUN="no"
TARBALL=""
INSECURE="no"
usage() {
cat <<'EOF'
Install or upgrade the sower client.
Usage: install.sh [options]
Options:
-v, --version <tag> release tag to install (default: latest)
-m, --mirror <url> prefix added to the download URL, e.g.
https://ghfast.top/ (for hosts that cannot reach GitHub)
-p, --prefix <dir> binary directory (default: /usr/local/bin)
-c, --config-dir <dir> config directory (default: /etc/sower,
macOS: /usr/local/etc/sower)
-s, --service <name> service backend: auto, systemd, procd, launchd, none
-t, --tarball <path> install from a local release tarball (offline)
--with-server also install the sowerd binary (no service)
--insecure-skip-verify
install even when the release publishes no
SHA256SUMS (remote installs verify by default)
--dry-run print every action without touching the system
-h, --help show this help
Examples:
curl -fsSL .../install.sh | sudo sh
sudo sh install.sh --version v1.6.0
sudo sh install.sh --mirror https://ghfast.top/
sudo sh install.sh --service none --prefix "$HOME/.local/bin"
EOF
}
log() { printf '%s\n' "$*"; }
die() {
printf 'install.sh: %s\n' "$*" >&2
exit 1
}
run() {
if [ "$DRY_RUN" = "yes" ]; then
log "[dry-run] $*"
return 0
fi
"$@"
}
parse_args() {
while [ $# -gt 0 ]; do
case "$1" in
-v | --version)
shift
[ $# -gt 0 ] || die "--version needs a value"
VERSION="$1"
;;
-m | --mirror)
shift
[ $# -gt 0 ] || die "--mirror needs a value"
MIRROR="$1"
;;
-p | --prefix)
shift
[ $# -gt 0 ] || die "--prefix needs a value"
PREFIX="$1"
;;
-c | --config-dir)
shift
[ $# -gt 0 ] || die "--config-dir needs a value"
CONFIG_DIR="$1"
;;
-s | --service)
shift
[ $# -gt 0 ] || die "--service needs a value"
SERVICE="$1"
;;
-t | --tarball)
shift
[ $# -gt 0 ] || die "--tarball needs a value"
TARBALL="$1"
;;
--with-server) WITH_SERVER="yes" ;;
--insecure-skip-verify) INSECURE="yes" ;;
--dry-run) DRY_RUN="yes" ;;
-h | --help)
usage
exit 0
;;
*) die "unknown option: $1 (see --help)" ;;
esac
shift
done
}
detect_platform() {
os=$(uname -s)
arch=$(uname -m)
case "$os" in
Linux) os="linux" ;;
Darwin) os="darwin" ;;
*) die "unsupported OS: $os (see README for manual install)" ;;
esac
case "$arch" in
x86_64 | amd64) arch="amd64" ;;
aarch64 | arm64) arch="arm64" ;;
armv7l | armv7 | armv6l | arm) arch="arm" ;;
riscv64) arch="riscv64" ;;
loongarch64 | loong64) arch="loong64" ;;
mips) arch="mips" ;;
mipsel | mipsle) arch="mipsle" ;;
*) die "unsupported architecture: $arch (see README for manual install)" ;;
esac
PLATFORM="$os-$arch"
}
detect_service() {
[ "$SERVICE" != "auto" ] && return 0
if [ "$(uname -s)" = "Darwin" ]; then
SERVICE="launchd"
elif [ -f /etc/openwrt_release ] || command -v ubus >/dev/null 2>&1; then
SERVICE="procd"
elif [ -d /run/systemd/system ]; then
SERVICE="systemd"
else
SERVICE="none"
fi
}
default_paths() {
# Keep the defaults aligned with the packaged service files.
if [ -z "$PREFIX" ]; then
case "$SERVICE" in
procd) PREFIX="/usr/sbin" ;;
*) PREFIX="/usr/local/bin" ;;
esac
fi
if [ -z "$CONFIG_DIR" ]; then
case "$SERVICE" in
launchd) CONFIG_DIR="/usr/local/etc/sower" ;;
*) CONFIG_DIR="/etc/sower" ;;
esac
fi
}
need_root() {
[ "$DRY_RUN" = "yes" ] && return 0
[ "$(id -u)" = "0" ] && return 0
# A user-local install without a service needs no privileges.
[ "$SERVICE" = "none" ] && return 0
die "must run as root (use sudo), or pass --service none --prefix/--config-dir"
}
download() {
# download <url> <dest>
# Every network call is bounded: the installer may run unattended as root.
if command -v curl >/dev/null 2>&1; then
run curl -fL --retry 3 --connect-timeout 15 --max-time 600 -o "$2" "$1"
elif command -v wget >/dev/null 2>&1; then
run wget -T 30 -t 3 -O "$2" "$1"
else
die "need curl or wget to download the release"
fi
}
release_url() {
# release_url <file>
if [ "$VERSION" = "latest" ]; then
printf '%s' "${MIRROR}https://github.com/${REPO}/releases/latest/download/$1"
else
printf '%s' "${MIRROR}https://github.com/${REPO}/releases/download/${VERSION}/$1"
fi
}
fetch_tarball() {
if [ -n "$TARBALL" ]; then
[ -f "$TARBALL" ] || die "no such tarball: $TARBALL"
log "using local tarball $TARBALL"
cp "$TARBALL" "$TMP/release.tar.gz"
return 0
fi
asset="sower-${PLATFORM}.tar.gz"
if [ "$DRY_RUN" = "yes" ]; then
log "[dry-run] download sower-${PLATFORM}.tar.gz ($VERSION)"
return 0
fi
log "downloading $asset ($VERSION)"
download "$(release_url "$asset")" "$TMP/release.tar.gz"
verify_checksum "$asset"
}
skip_verify() {
# A remote archive is installed as root, so a missing checksum is an error
# unless the operator explicitly opts out.
[ "$INSECURE" = "yes" ] && return 0
die "$1 (re-run with --insecure-skip-verify to override)"
}
verify_checksum() {
sums="$TMP/SHA256SUMS"
if ! download "$(release_url "SHA256SUMS")" "$sums" 2>/dev/null || [ ! -f "$sums" ]; then
log "WARNING: release $VERSION publishes no SHA256SUMS"
skip_verify "cannot verify the downloaded archive"
return 0
fi
expected=$(awk -v name="$1" '$2 == name { print $1 }' "$sums" 2>/dev/null || true)
if [ -z "$expected" ]; then
log "WARNING: SHA256SUMS does not list $1"
skip_verify "cannot verify the downloaded archive"
return 0
fi
if command -v sha256sum >/dev/null 2>&1; then
actual=$(sha256sum "$TMP/release.tar.gz" | awk '{ print $1 }')
elif command -v shasum >/dev/null 2>&1; then
actual=$(shasum -a 256 "$TMP/release.tar.gz" | awk '{ print $1 }')
else
log "WARNING: neither sha256sum nor shasum is available"
skip_verify "cannot verify the downloaded archive"
return 0
fi
[ "$actual" = "$expected" ] || die "checksum mismatch for $1 (want $expected, got $actual)"
log "checksum verified"
}
unpack() {
if [ "$DRY_RUN" = "yes" ]; then
log "[dry-run] tar -xzf release.tar.gz"
return 0
fi
extract="$TMP/extract"
mkdir -p "$extract"
tar -xzf "$TMP/release.tar.gz" -C "$extract"
[ -f "$extract/sower" ] || die "release tarball has no sower binary"
}
install_binaries() {
# Replace atomically: a running process keeps the old inode, so a plain
# copy over it fails with "text file busy".
for name in sower sowerd; do
if [ "$name" = "sowerd" ] && [ "$WITH_SERVER" != "yes" ]; then
continue
fi
if [ "$DRY_RUN" = "yes" ]; then
log "[dry-run] install $PREFIX/$name"
continue
fi
src="$TMP/extract/$name"
[ -f "$src" ] || die "release tarball has no $name binary"
log "installing $PREFIX/$name"
mkdir -p "$PREFIX"
cp "$src" "$PREFIX/$name.new"
chmod 0755 "$PREFIX/$name.new"
mv "$PREFIX/$name.new" "$PREFIX/$name"
done
}
install_config() {
dst="$CONFIG_DIR/sower.toml"
if [ -f "$dst" ]; then
log "keeping existing config $dst"
return 0
fi
if [ "$DRY_RUN" = "yes" ]; then
log "[dry-run] write starter config $dst"
return 0
fi
src="$TMP/extract/sower.toml"
[ -f "$src" ] || die "release tarball has no sower.toml"
log "writing starter config $dst"
mkdir -p "$CONFIG_DIR"
# The config carries the upstream password, keep it private.
cp "$src" "$dst"
chmod 0600 "$dst"
}
render_service() {
# render_service <template> <dest> : rewrite the packaged paths. The
# placeholders keep a rewritten path from matching a later rule (e.g.
# /usr/local/etc/sower already contains /etc/sower).
sed -e "s|/usr/local/bin/sower|__PREFIX__/sower|g" \
-e "s|/usr/sbin/sower|__PREFIX__/sower|g" \
-e "s|/usr/local/etc/sower|__CONFIG__|g" \
-e "s|/etc/sower|__CONFIG__|g" \
-e "s|__PREFIX__|$PREFIX|g" \
-e "s|__CONFIG__|$CONFIG_DIR|g" \
"$1" >"$2"
}
write_root_file() {
# write_root_file <src> <dst> <mode>
log "writing $2"
mkdir -p "$(dirname "$2")"
cp "$1" "$2"
chmod "$3" "$2"
}
install_systemd() {
if [ "$DRY_RUN" = "yes" ]; then
log "[dry-run] write /etc/systemd/system/sower.service"
log "[dry-run] systemctl daemon-reload"
log "[dry-run] systemctl enable sower"
log "[dry-run] systemctl restart sower"
return 0
fi
tpl="$TMP/extract/sower.service"
[ -f "$tpl" ] || die "release tarball has no sower.service"
render_service "$tpl" "$TMP/sower.service.gen"
write_root_file "$TMP/sower.service.gen" "/etc/systemd/system/sower.service" 0644
run systemctl daemon-reload
run systemctl enable sower
run systemctl restart sower
}
install_procd() {
if [ "$DRY_RUN" = "yes" ]; then
log "[dry-run] write /etc/init.d/sower"
log "[dry-run] /etc/init.d/sower enable && /etc/init.d/sower restart"
return 0
fi
tpl="$TMP/extract/sower.init"
[ -f "$tpl" ] || die "release tarball has no sower.init"
render_service "$tpl" "$TMP/sower.init.gen"
write_root_file "$TMP/sower.init.gen" "/etc/init.d/sower" 0755
run /etc/init.d/sower enable
run /etc/init.d/sower restart
}
install_launchd() {
if [ "$DRY_RUN" = "yes" ]; then
log "[dry-run] write /Library/LaunchDaemons/sower.plist"
log "[dry-run] launchctl unload -w /Library/LaunchDaemons/sower.plist (failure ignored)"
log "[dry-run] launchctl load -w /Library/LaunchDaemons/sower.plist"
return 0
fi
tpl="$TMP/extract/sower.plist"
[ -f "$tpl" ] || die "release tarball has no sower.plist"
render_service "$tpl" "$TMP/sower.plist.gen"
plist="/Library/LaunchDaemons/sower.plist"
write_root_file "$TMP/sower.plist.gen" "$plist" 0644
run launchctl unload -w "$plist" 2>/dev/null || true
run launchctl load -w "$plist"
}
install_service() {
case "$SERVICE" in
systemd) install_systemd ;;
procd) install_procd ;;
launchd) install_launchd ;;
none) log "no service requested, skipping service setup" ;;
*) die "unknown service backend: $SERVICE" ;;
esac
}
print_summary() {
log ""
log "sower installed:"
log " binary: $PREFIX/sower"
log " config: $CONFIG_DIR/sower.toml"
log " service: $SERVICE"
log ""
log "Next: set remote.addr and remote.password in the config, then restart:"
case "$SERVICE" in
systemd) log " systemctl restart sower" ;;
procd) log " /etc/init.d/sower restart" ;;
launchd) log " launchctl kickstart -k system/sower" ;;
*) log " $PREFIX/sower -c $CONFIG_DIR/sower.toml" ;;
esac
}
main() {
parse_args "$@"
detect_platform
detect_service
default_paths
need_root
TMP=$(mktemp -d) || die "cannot create a temporary directory"
trap 'rm -rf "$TMP"' EXIT INT TERM
log "platform: $PLATFORM, service: $SERVICE, prefix: $PREFIX, config: $CONFIG_DIR"
fetch_tarball
unpack
install_binaries
install_config
install_service
print_summary
}
main "$@"
+155
View File
@@ -0,0 +1,155 @@
#!/bin/sh
# End-to-end test for install.sh.
#
# Everything runs offline against tarballs built here, with --service none into
# a throwaway prefix and config dir, so the test never needs root, never touches
# the system and never opens a network connection. It covers what `sh -n`
# cannot: the install and upgrade paths, the config-preservation contract, the
# dry-run contract, and the failure modes for a malformed release tarball.
set -eu
HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
INSTALL="$HERE/install.sh"
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT INT TERM
fail() {
printf 'install_e2e_test: %s\n' "$*" >&2
exit 1
}
# stage_tarball <archive> <version> <member>...
# Builds a minimal release tarball. The binaries are shell stubs that echo
# their own name and version, so the test can tell a replaced binary from a
# stale one.
stage_tarball() {
archive=$1
version=$2
shift 2
stage="$WORK/stage-$version"
rm -rf "$stage"
mkdir -p "$stage"
for member in "$@"; do
case "$member" in
sower | sowerd)
printf '#!/bin/sh\necho "%s %s"\n' "$member" "$version" >"$stage/$member"
chmod 0755 "$stage/$member"
;;
sower.toml)
printf 'config %s\n' "$version" >"$stage/sower.toml"
;;
*)
printf '%s\n' "$member" >"$stage/$member"
;;
esac
done
tar -czf "$archive" -C "$stage" "$@"
rm -rf "$stage"
}
# run_install <archive> <prefix> <config-dir> [extra args...]
run_install() {
archive=$1
prefix=$2
config_dir=$3
shift 3
sh "$INSTALL" --tarball "$archive" --service none \
--prefix "$prefix" --config-dir "$config_dir" "$@"
}
# --- fresh install -----------------------------------------------------------
PREFIX="$WORK/prefix"
CONFDIR="$WORK/config"
stage_tarball "$WORK/release-v1.tar.gz" v1 sower sowerd sower.toml
if ! run_install "$WORK/release-v1.tar.gz" "$PREFIX" "$CONFDIR" >"$WORK/fresh.log" 2>&1; then
fail "fresh install failed:
$(cat "$WORK/fresh.log")"
fi
[ -x "$PREFIX/sower" ] || fail "sower was not installed as an executable"
[ "$("$PREFIX/sower")" = "sower v1" ] || fail "installed sower is not the tarball's binary"
[ -f "$CONFDIR/sower.toml" ] || fail "starter config was not written"
[ "$(cat "$CONFDIR/sower.toml")" = "config v1" ] || fail "starter config content mismatch"
config_mode=$(ls -l "$CONFDIR/sower.toml" | cut -c1-10)
[ "$config_mode" = "-rw-------" ] || fail "starter config mode is $config_mode, want -rw-------"
# --- upgrade in place --------------------------------------------------------
printf 'edited by the operator\n' >"$CONFDIR/sower.toml"
stage_tarball "$WORK/release-v2.tar.gz" v2 sower sowerd sower.toml
if ! run_install "$WORK/release-v2.tar.gz" "$PREFIX" "$CONFDIR" >"$WORK/upgrade.log" 2>&1; then
fail "upgrade failed:
$(cat "$WORK/upgrade.log")"
fi
[ "$("$PREFIX/sower")" = "sower v2" ] || fail "upgrade did not replace the binary"
[ "$(cat "$CONFDIR/sower.toml")" = "edited by the operator" ] ||
fail "upgrade overwrote the existing config"
grep -q 'keeping existing config' "$WORK/upgrade.log" || fail "upgrade did not report keeping the config"
# --- --with-server installs sowerd -------------------------------------------
if ! run_install "$WORK/release-v2.tar.gz" "$PREFIX" "$CONFDIR" --with-server >"$WORK/server.log" 2>&1; then
fail "--with-server install failed:
$(cat "$WORK/server.log")"
fi
[ -x "$PREFIX/sowerd" ] || fail "--with-server did not install sowerd"
# --- dry run must not touch the system --------------------------------------
DRY_PREFIX="$WORK/dry/prefix"
DRY_CONFDIR="$WORK/dry/config"
if ! run_install "$WORK/release-v1.tar.gz" "$DRY_PREFIX" "$DRY_CONFDIR" --dry-run >"$WORK/dry.log" 2>&1; then
fail "dry run failed:
$(cat "$WORK/dry.log")"
fi
[ ! -e "$DRY_PREFIX" ] || fail "dry run created the prefix directory"
[ ! -e "$DRY_CONFDIR" ] || fail "dry run created the config directory"
grep -q '^\[dry-run\]' "$WORK/dry.log" || fail "dry run printed no action preview"
# --- malformed tarballs ------------------------------------------------------
stage_tarball "$WORK/no-config.tar.gz" v1 sower sowerd
if run_install "$WORK/no-config.tar.gz" "$WORK/bad-a/prefix" "$WORK/bad-a/config" >"$WORK/no-config.log" 2>&1; then
fail "installer accepted a tarball without sower.toml"
fi
grep -q 'no sower.toml' "$WORK/no-config.log" ||
fail "unexpected failure for a tarball without sower.toml:
$(cat "$WORK/no-config.log")"
[ ! -e "$WORK/bad-a/config/sower.toml" ] || fail "half-written config left behind"
stage_tarball "$WORK/no-binary.tar.gz" v1 sower.toml
if run_install "$WORK/no-binary.tar.gz" "$WORK/bad-b/prefix" "$WORK/bad-b/config" >"$WORK/no-binary.log" 2>&1; then
fail "installer accepted a tarball without the sower binary"
fi
grep -q 'no sower binary' "$WORK/no-binary.log" ||
fail "unexpected failure for a tarball without the sower binary:
$(cat "$WORK/no-binary.log")"
[ ! -e "$WORK/bad-b/prefix/sower" ] || fail "installed a binary from a tarball without one"
stage_tarball "$WORK/no-server.tar.gz" v1 sower sower.toml
if run_install "$WORK/no-server.tar.gz" "$WORK/bad-c/prefix" "$WORK/bad-c/config" --with-server >"$WORK/no-server.log" 2>&1; then
fail "installer accepted --with-server from a tarball without sowerd"
fi
grep -q 'no sowerd binary' "$WORK/no-server.log" ||
fail "unexpected failure for a tarball without sowerd:
$(cat "$WORK/no-server.log")"
# --- CLI surface -------------------------------------------------------------
if ! sh "$INSTALL" --help >"$WORK/help.log" 2>&1; then
fail "--help exited non-zero"
fi
grep -q 'Install or upgrade the sower client' "$WORK/help.log" || fail "--help printed no usage"
if sh "$INSTALL" --not-a-flag >"$WORK/unknown.log" 2>&1; then
fail "installer accepted an unknown option"
fi
grep -q 'unknown option' "$WORK/unknown.log" || fail "unknown option reported nothing useful"
printf 'install_e2e_test: PASS\n'
+354
View File
@@ -0,0 +1,354 @@
package admin
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// loadSessions restores persisted sessions at startup, dropping any that
// already expired while the process was down.
func (s *Server) loadSessions() {
if s.sessionFile == "" {
return
}
data, err := os.ReadFile(s.sessionFile)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
slog.Warn("read session file", "error", err)
}
return
}
var stored map[string]time.Time
if err := json.Unmarshal(data, &stored); err != nil {
slog.Warn("parse session file", "error", err)
return
}
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
for token, exp := range stored {
if now.Before(exp) {
s.sessions[token] = exp
}
}
if len(s.sessions) > 0 {
slog.Info("restored admin sessions", "count", len(s.sessions), "file", s.sessionFile)
}
}
// persistLocked writes the supplied session snapshot to disk atomically with
// 0600 permissions. It must be called with s.mu held. IO errors are returned
// so callers can keep memory and durable state consistent.
func (s *Server) persistLocked(sessions map[string]time.Time) error {
if s.sessionFile == "" {
return nil
}
data, err := json.Marshal(sessions)
if err != nil {
return fmt.Errorf("marshal sessions for %s: %w", s.sessionFile, err)
}
dir := filepath.Dir(s.sessionFile)
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("create session directory %s: %w", dir, err)
}
tmp, err := os.CreateTemp(dir, ".sessions-*.tmp")
if err != nil {
return fmt.Errorf("create session temp file in %s: %w", dir, err)
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("write session temp file %s: %w", tmpName, err)
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return fmt.Errorf("fsync session temp file %s: %w", tmpName, err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close session temp file %s: %w", tmpName, err)
}
if err := os.Chmod(tmpName, 0o600); err != nil {
return fmt.Errorf("chmod session temp file %s: %w", tmpName, err)
}
if err := os.Rename(tmpName, s.sessionFile); err != nil {
return fmt.Errorf("rename session file %s: %w", s.sessionFile, err)
}
if d, err := os.Open(dir); err == nil {
_ = d.Sync()
_ = d.Close()
}
return nil
}
func purgeExpired(sessions map[string]time.Time, now time.Time) {
for token, exp := range sessions {
if now.After(exp) {
delete(sessions, token)
}
}
}
// remoteIP extracts the host part of a remote address for login throttling.
// X-Forwarded-For is not trusted: the admin listener is typically loopback,
// and a reverse-proxied deployment throttles per-proxy instead.
func remoteIP(remoteAddr string) string {
if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
return host
}
return remoteAddr
}
// Login throttling bounds password guessing when the admin listener is not
// loopback-only (the console can be bound to a LAN address or share port 80).
// Consecutive failures from one remote IP are rejected with 429 for a backoff
// window; a success clears the entry.
const (
loginMaxFails = 5
loginLockDuration = 15 * time.Minute
// loginThrottleMaxEntries bounds the failure map so rotating or spoofed
// source IPs cannot grow it without bound; stale entries are purged when
// the map is full.
loginThrottleMaxEntries = 4096
)
type loginFail struct {
count int
until time.Time
}
type loginThrottle struct {
mu sync.Mutex
fails map[string]loginFail
}
func newLoginThrottle() *loginThrottle {
return &loginThrottle{fails: make(map[string]loginFail)}
}
func (t *loginThrottle) allow(ip string) bool {
t.mu.Lock()
defer t.mu.Unlock()
f, ok := t.fails[ip]
return !ok || time.Now().After(f.until)
}
func (t *loginThrottle) fail(ip string) {
t.mu.Lock()
defer t.mu.Unlock()
now := time.Now()
// Bound the map: when full, drop entries whose lock window already
// expired. If it is still full, stop tracking the new IP (it cannot be
// locked, but the map stays bounded).
if len(t.fails) >= loginThrottleMaxEntries {
for k, f := range t.fails {
if now.After(f.until) {
delete(t.fails, k)
}
}
}
if len(t.fails) >= loginThrottleMaxEntries {
return
}
f := t.fails[ip]
f.count++
if f.count >= loginMaxFails {
f.until = now.Add(loginLockDuration)
f.count = 0
}
t.fails[ip] = f
}
func (t *loginThrottle) success(ip string) {
t.mu.Lock()
defer t.mu.Unlock()
delete(t.fails, ip)
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
ip := remoteIP(r.RemoteAddr)
if !s.throttle.allow(ip) {
writeError(w, http.StatusTooManyRequests, "too many login attempts, try again later")
return
}
var body struct {
Password string `json:"password"`
}
if !decodeJSON(w, r, &body) {
return
}
if !secureEqual(body.Password, s.opts.Password) {
s.throttle.fail(ip)
writeError(w, http.StatusUnauthorized, "invalid password")
return
}
s.throttle.success(ip)
if !s.issueSession(w, r) {
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleSession(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(sessionCookieName); err == nil {
s.mu.Lock()
delete(s.sessions, c.Value)
if err := s.persistLocked(s.sessions); err != nil {
slog.Error("persist admin logout session", "error", err)
}
s.mu.Unlock()
}
http.SetCookie(w, s.sessionCookie("", -1, s.cookieSecure(r)))
w.WriteHeader(http.StatusNoContent)
}
// sessionCookie builds the session cookie. The Secure flag is set when the
// listener uses TLS or when an explicitly configured TLS-terminating proxy
// protects the admin origin.
func (s *Server) sessionCookie(value string, maxAge int, secure bool) *http.Cookie {
return &http.Cookie{
Name: sessionCookieName,
Value: value,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: secure,
MaxAge: maxAge,
}
}
func (s *Server) cookieSecure(r *http.Request) bool {
return r.TLS != nil || s.opts.CookieSecure
}
func (s *Server) issueSession(w http.ResponseWriter, r *http.Request) bool {
var token [32]byte
if _, err := rand.Read(token[:]); err != nil {
slog.Error("generate session token", "error", err)
writeError(w, http.StatusInternalServerError, "failed to issue session")
return false
}
value := hex.EncodeToString(token[:])
s.mu.Lock()
if len(s.sessions) >= maxSessionPurge {
purgeExpired(s.sessions, time.Now())
}
if len(s.sessions) >= maxSessionPurge {
// Every retained session is still live; issuing another would grow
// the map unboundedly. Refuse instead of evicting a valid session.
s.mu.Unlock()
slog.Warn("admin session limit reached, login rejected")
writeError(w, http.StatusServiceUnavailable, "session limit reached, try later")
return false
}
s.sessions[value] = time.Now().Add(sessionTTL)
if err := s.persistLocked(s.sessions); err != nil {
slog.Error("persist admin login session", "error", err)
}
s.mu.Unlock()
http.SetCookie(w, s.sessionCookie(value, int(sessionCookieMaxAge.Seconds()), s.cookieSecure(r)))
return true
}
// validSession returns the token, whether it is valid, and whether its
// server-side expiration was renewed. Persistence failures retain the prior
// expiry so a later request can retry the renewal.
func (s *Server) validSession(r *http.Request) (string, bool, bool) {
c, err := r.Cookie(sessionCookieName)
if err != nil {
return "", false, false
}
s.mu.Lock()
defer s.mu.Unlock()
exp, ok := s.sessions[c.Value]
if !ok {
return "", false, false
}
now := time.Now()
if now.After(exp) {
delete(s.sessions, c.Value)
if err := s.persistLocked(s.sessions); err != nil {
slog.Warn("persist expired admin session cleanup", "error", err)
}
return "", false, false
}
if exp.Sub(now) < sessionTTL/2 {
s.sessions[c.Value] = now.Add(sessionTTL)
if err := s.persistLocked(s.sessions); err != nil {
slog.Warn("renew admin session", "error", err)
}
return c.Value, true, true
}
return c.Value, true, false
}
// auth rejects requests without a valid session cookie and renews the
// browser's Max-Age for every authenticated HTTP response.
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token, valid, _ := s.validSession(r)
if !valid {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
http.SetCookie(w, s.sessionCookie(token, int(sessionCookieMaxAge.Seconds()), s.cookieSecure(r)))
next(w, r)
}
}
// mutateGuard rejects cross-origin state-changing requests as CSRF defense.
func (s *Server) mutateGuard(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if origin := r.Header.Get("Origin"); origin != "" {
u, err := url.Parse(origin)
if err != nil || !strings.EqualFold(u.Host, r.Host) {
writeError(w, http.StatusForbidden, "cross-origin request rejected")
return
}
}
next(w, r)
}
}
// secureEqual compares two secrets in constant time regardless of length.
func secureEqual(a, b string) bool {
ha := sha256.Sum256([]byte(a))
hb := sha256.Sum256([]byte(b))
return subtle.ConstantTimeCompare(ha[:], hb[:]) == 1
}
// GeneratePassword returns a cryptographically random 128-bit password
// (hex-encoded). cmd/sower uses it when the admin console is enabled
// without a configured password, so the listener never runs with an empty
// credential. The generated value is printed once in the startup log and
// changes on every restart.
func GeneratePassword() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
panic(fmt.Sprintf("read crypto/rand: %v", err))
}
return hex.EncodeToString(b[:])
}
+302
View File
@@ -0,0 +1,302 @@
package admin
import (
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"strconv"
"strings"
"github.com/sower-proxy/sower/pkg/upstreamtls"
)
// Apply modes for config fields.
const (
// ApplyImmediate takes effect at runtime without a restart.
ApplyImmediate = "immediate"
// ApplyRestart requires a process restart; shown read-only in v1.
ApplyRestart = "restart"
// ApplyReadonly can never be changed through the console.
ApplyReadonly = "readonly"
)
// Config sources for display.
const (
// SourceConfig marks values from the config file/flags (or defaults).
// aconfig merges defaults before the file, so the two cannot be told
// apart after load.
SourceConfig = "config"
// SourceOverride marks values overridden through the admin console and
// persisted in the admin state file.
SourceOverride = "override"
)
// ConfigField is one rendered config entry. Secret fields never carry a
// Value; Configured reports whether one is set. Type/Options let the console
// render a constrained choice (e.g. an enum select) instead of free text.
type ConfigField struct {
Key string `json:"key"`
Value string `json:"value,omitempty"`
Editable bool `json:"editable"`
ApplyMode string `json:"applyMode"`
Source string `json:"source"`
Constraint string `json:"constraint,omitempty"`
Secret bool `json:"secret,omitempty"`
Configured bool `json:"configured,omitempty"`
Type string `json:"type,omitempty"`
Options []string `json:"options,omitempty"`
}
// ConfigSection groups related fields for display.
type ConfigSection struct {
Name string `json:"name"`
Fields []ConfigField `json:"fields"`
}
// ConfigView is the GET /api/config response. Revision drives optimistic
// concurrency for PATCHes.
type ConfigView struct {
Revision uint64 `json:"revision"`
Sections []ConfigSection `json:"sections"`
}
// ConfigChanges is the whitelisted PATCH payload. A nil pointer leaves the
// override unchanged; a non-nil empty string clears it, reverting the field
// to the file/flag configuration. List fields (rule sources) follow the
// same rule with slices.
type ConfigChanges struct {
LogLevel *string `json:"log_level"`
DNSUpstream *string `json:"dns_upstream"`
DNSFallback *string `json:"dns_fallback"`
DNSReverse *string `json:"dns_reverse"`
RemoteType *string `json:"remote_type"`
RemoteAddr *string `json:"remote_addr"`
RemoteTLSServerName *string `json:"remote_tls_server_name"`
RemoteTLSClientHello *string `json:"remote_tls_client_hello"`
RemoteTLSInsecureSkipVerify *string `json:"remote_tls_insecure_skip_verify"`
DNSServe *string `json:"dns_serve"`
DNSServe6 *string `json:"dns_serve6"`
Socks5Addr *string `json:"socks5_addr"`
AdminSessionFile *string `json:"admin_session_file"`
AdminDisableSessionPersistence *string `json:"admin_disable_session_persistence"`
AdminCookieSecure *string `json:"admin_cookie_secure"`
AdminStateFile *string `json:"admin_state_file"`
RouterBlockFile *string `json:"router_block_file"`
RouterBlockFilePrefix *string `json:"router_block_file_prefix"`
RouterBlockFileSkipRules *[]string `json:"router_block_file_skip_rules"`
RouterBlockRules *[]string `json:"router_block_rules"`
RouterDirectFile *string `json:"router_direct_file"`
RouterDirectFilePrefix *string `json:"router_direct_file_prefix"`
RouterDirectFileSkipRules *[]string `json:"router_direct_file_skip_rules"`
RouterDirectRules *[]string `json:"router_direct_rules"`
RouterProxyFile *string `json:"router_proxy_file"`
RouterProxyFilePrefix *string `json:"router_proxy_file_prefix"`
RouterProxyFileSkipRules *[]string `json:"router_proxy_file_skip_rules"`
RouterProxyRules *[]string `json:"router_proxy_rules"`
RouterCountryMMDB *string `json:"router_country_mmdb"`
RouterCountryFile *string `json:"router_country_file"`
RouterCountryRules *[]string `json:"router_country_rules"`
}
// Validate checks all supplied values. Clearing an override (empty string
// or empty list) is always valid.
func (c ConfigChanges) Validate() error {
if c.LogLevel != nil && *c.LogLevel != "" {
var lv slog.Level
if err := lv.UnmarshalText([]byte(strings.ToUpper(*c.LogLevel))); err != nil {
return fmt.Errorf("invalid log_level %q", *c.LogLevel)
}
}
if c.DNSUpstream != nil && *c.DNSUpstream != "" && net.ParseIP(*c.DNSUpstream) == nil {
return fmt.Errorf("invalid dns_upstream %q", *c.DNSUpstream)
}
if c.DNSFallback != nil && *c.DNSFallback != "" && net.ParseIP(*c.DNSFallback) == nil {
return fmt.Errorf("invalid dns_fallback %q", *c.DNSFallback)
}
if c.DNSReverse != nil && *c.DNSReverse != "" && net.ParseIP(*c.DNSReverse) == nil {
return fmt.Errorf("invalid dns_reverse %q", *c.DNSReverse)
}
if c.DNSServe != nil && *c.DNSServe != "" && net.ParseIP(*c.DNSServe) == nil {
return fmt.Errorf("invalid dns_serve %q", *c.DNSServe)
}
if c.DNSServe6 != nil && *c.DNSServe6 != "" && net.ParseIP(*c.DNSServe6) == nil {
return fmt.Errorf("invalid dns_serve6 %q", *c.DNSServe6)
}
if c.RemoteType != nil && *c.RemoteType != "" {
switch *c.RemoteType {
case "sower", "socks5":
default:
return fmt.Errorf("invalid remote_type %q", *c.RemoteType)
}
}
if c.RemoteAddr != nil && *c.RemoteAddr != "" && !validRemoteAddr(*c.RemoteAddr) {
// An invalid remote address would persist and crash-loop the next
// startup (the dialer is built from it before listeners come up).
return fmt.Errorf("invalid remote_addr %q", *c.RemoteAddr)
}
if c.RemoteTLSServerName != nil && *c.RemoteTLSServerName != "" && !validHostname(*c.RemoteTLSServerName) {
return fmt.Errorf("invalid remote_tls_server_name %q", *c.RemoteTLSServerName)
}
if c.RemoteTLSClientHello != nil && *c.RemoteTLSClientHello != "" {
if err := upstreamtls.ValidateClientHello(*c.RemoteTLSClientHello); err != nil {
return fmt.Errorf("invalid remote_tls_client_hello %q", *c.RemoteTLSClientHello)
}
}
if c.RemoteTLSInsecureSkipVerify != nil && *c.RemoteTLSInsecureSkipVerify != "" {
if _, err := strconv.ParseBool(*c.RemoteTLSInsecureSkipVerify); err != nil {
return fmt.Errorf("invalid remote_tls_insecure_skip_verify %q", *c.RemoteTLSInsecureSkipVerify)
}
}
if c.Socks5Addr != nil && *c.Socks5Addr != "" {
if _, _, err := net.SplitHostPort(*c.Socks5Addr); err != nil {
return fmt.Errorf("invalid socks5_addr %q: %w", *c.Socks5Addr, err)
}
}
if c.AdminDisableSessionPersistence != nil && *c.AdminDisableSessionPersistence != "" {
if _, err := strconv.ParseBool(*c.AdminDisableSessionPersistence); err != nil {
return fmt.Errorf("invalid admin_disable_session_persistence %q", *c.AdminDisableSessionPersistence)
}
}
if c.AdminCookieSecure != nil && *c.AdminCookieSecure != "" {
if _, err := strconv.ParseBool(*c.AdminCookieSecure); err != nil {
return fmt.Errorf("invalid admin_cookie_secure %q", *c.AdminCookieSecure)
}
}
// Rule and skip-rule lists: entries must be single-line and bounded so a
// malformed override cannot inject newlines into the rule files.
validateRuleList := func(name string, rules *[]string) error {
if rules == nil {
return nil
}
for _, r := range *rules {
if r == "" || len(r) > maxRuleLength || strings.ContainsAny(r, "\r\n") {
return fmt.Errorf("invalid %s entry %q", name, r)
}
}
return nil
}
for name, rules := range map[string]*[]string{
"router_block_file_skip_rules": c.RouterBlockFileSkipRules,
"router_block_rules": c.RouterBlockRules,
"router_direct_file_skip_rules": c.RouterDirectFileSkipRules,
"router_direct_rules": c.RouterDirectRules,
"router_proxy_file_skip_rules": c.RouterProxyFileSkipRules,
"router_proxy_rules": c.RouterProxyRules,
"router_country_rules": c.RouterCountryRules,
} {
if err := validateRuleList(name, rules); err != nil {
return err
}
}
return nil
}
// validRemoteAddr mirrors the dialer's upstream address acceptance: host:port
// with both parts present, a bare hostname, or a bare IPv6 literal. Anything
// the dialer would reject at startup (and thus crash-loop the process after a
// persisted override) is rejected here.
func validRemoteAddr(addr string) bool {
if host, port, err := net.SplitHostPort(addr); err == nil {
if host == "" || port == "" {
return false
}
h := strings.Trim(host, "[]")
return net.ParseIP(h) != nil || validHostname(h)
}
if addr == "" || strings.HasPrefix(addr, "[") || strings.HasSuffix(addr, "]") {
return false
}
if strings.Count(addr, ":") == 1 {
return false
}
if net.ParseIP(addr) != nil {
return true
}
return validHostname(addr)
}
// validHostname accepts a domain or bare host label: letters, digits, '-',
// '_' and '.', with bounded label and total lengths. Underscores are allowed
// because internal hosts commonly carry them.
func validHostname(s string) bool {
if s == "" || len(s) > 253 {
return false
}
for _, label := range strings.Split(s, ".") {
if label == "" || len(label) > 63 {
return false
}
for i := 0; i < len(label); i++ {
c := label[i]
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_') {
return false
}
}
}
return true
}
// ConfigManager is the config display/edit surface the admin server needs.
// The concrete adapter lives in cmd/sower; tests inject a fake.
type ConfigManager interface {
ConfigView() ConfigView
// ApplyConfigChanges persists the overrides and applies them to the
// runtime, guarded by the caller's revision. It returns the refreshed
// view. ErrRevisionMismatch reports a stale revision.
ApplyConfigChanges(changes ConfigChanges, revision uint64) (ConfigView, error)
}
// handleConfigGet serves the sanitized effective configuration with
// per-field metadata. Secrets are reported as configured flags, never values.
func (s *Server) handleConfigGet(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.opts.Config.ConfigView())
}
// handleConfigPatch applies whitelisted config changes with optimistic
// concurrency: the caller's revision must match the state revision.
func (s *Server) handleConfigPatch(w http.ResponseWriter, r *http.Request) {
var req struct {
Revision uint64 `json:"revision"`
Changes ConfigChanges `json:"changes"`
}
if !decodeJSON(w, r, &req) {
return
}
if err := req.Changes.Validate(); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
view, err := s.opts.Config.ApplyConfigChanges(req.Changes, req.Revision)
if errors.Is(err, ErrRevisionMismatch) {
writeError(w, http.StatusConflict, "config changed since load; refresh and retry")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, view)
}
// handleRestart triggers a process restart. The callback is fire-and-forget
// from the handler's perspective: it must return before the process exits so
// the 202 response is delivered first.
func (s *Server) handleRestart(w http.ResponseWriter, r *http.Request) {
if s.opts.Restart == nil {
writeError(w, http.StatusNotImplemented, "restart unavailable")
return
}
if err := s.opts.Restart(); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"status": "restarting"})
}
+141
View File
@@ -0,0 +1,141 @@
package admin
import (
"net"
"sync/atomic"
"time"
)
// countingConn wraps a client connection and feeds byte counts into Stats.
type countingConn struct {
net.Conn
stats *Stats
kind string
clientIP string
created time.Time
domain atomic.Value // string; empty until BindConn
closed atomic.Bool
// Per-direction pending byte batches. Read and Write run on different
// relay goroutines, so each direction is owned by one goroutine and the
// atomics below are uncontended; Close swaps them safely under I/O.
pendingUp atomic.Uint64
pendingDown atomic.Uint64
nextFlushUp atomic.Int64 // unix nanos, upload flush due
nextFlushDown atomic.Int64 // unix nanos, download flush due
}
// flushBytes and flushInterval bound how long per-domain byte attribution
// may lag: throughput connections flush every flushBytes, slow trickles at
// least every flushInterval, and Close always drains the remainder. The
// global BytesUp/BytesDown totals stay real-time via direct atomics; only
// per-domain attribution and the OTel byte counter are batched.
const (
flushBytes = 32 << 10
flushInterval = 500 * time.Millisecond
)
func (c *countingConn) Read(p []byte) (int, error) {
n, err := c.Conn.Read(p)
if n > 0 {
c.stats.bytesUp.Add(uint64(n))
c.addBytes(true, uint64(n))
}
return n, err
}
func (c *countingConn) Write(p []byte) (int, error) {
n, err := c.Conn.Write(p)
if n > 0 {
c.stats.bytesDown.Add(uint64(n))
c.addBytes(false, uint64(n))
}
return n, err
}
// Close releases the active-connection slot exactly once and records the
// connection duration. The underlying conn is closed on every call, matching
// net.Conn semantics.
func (c *countingConn) Close() error {
if c.closed.CompareAndSwap(false, true) {
switch c.kind {
case "http":
c.stats.activeHTTP.Add(^uint64(0))
case "https":
c.stats.activeHTTPS.Add(^uint64(0))
case "socks5":
c.stats.activeSocks.Add(^uint64(0))
}
c.stats.metrics.ConnClosed(c.kind)
c.stats.metrics.RecordConnDuration(time.Since(c.created))
c.flush(true, c.pendingUp.Swap(0))
c.flush(false, c.pendingDown.Swap(0))
}
return c.Conn.Close()
}
func (c *countingConn) bind(domain string) {
c.domain.Store(domain)
}
// addBytes accumulates payload bytes per direction and flushes the batch
// when it reaches flushBytes or flushInterval has elapsed. The first I/O on
// a fresh connection flushes immediately (nextFlush starts zero), so short
// connections attribute their bytes right away.
func (c *countingConn) addBytes(up bool, n uint64) {
var pending *atomic.Uint64
var due *atomic.Int64
if up {
pending, due = &c.pendingUp, &c.nextFlushUp
} else {
pending, due = &c.pendingDown, &c.nextFlushDown
}
acc := pending.Add(n)
now := time.Now().UnixNano()
if acc < flushBytes && now < due.Load() && !c.closed.Load() {
return
}
due.Store(now + int64(flushInterval))
c.flush(up, pending.Swap(0))
}
// flush attributes one direction's pending bytes to the OTel byte counter
// and, when the domain is known, to the bound domain. The OTel counter is
// recorded unconditionally: protocol bytes read before BindConn (e.g. TLS
// ClientHello) were counted there in the pre-batching implementation too.
// Bytes read before BindConn are dropped from per-domain attribution.
func (c *countingConn) flush(up bool, n uint64) {
if n == 0 {
return
}
c.stats.metrics.RecordBytes(up, int(n))
domain, _ := c.domain.Load().(string)
if domain == "" {
return
}
source := Source(c.kind)
var upBytes, downBytes uint64
if up {
upBytes = n
} else {
downBytes = n
}
c.stats.record(domain, source, c.clientIP, 0, upBytes, downBytes, func(d *domainStat, src *sourceStat, dc *clientStat) {
if up {
d.bytesUp += n
src.bytesUp += n
if dc != nil {
dc.bytesUp += n
}
} else {
d.bytesDown += n
src.bytesDown += n
if dc != nil {
dc.bytesDown += n
}
}
})
}
+106
View File
@@ -0,0 +1,106 @@
package admin
import (
"container/heap"
"time"
)
// evictionCandidate is a potential least-recently-seen entry. The heap keeps
// its newest element at the root, allowing a single scan to retain only the
// oldest candidates required for a batch eviction.
type evictionCandidate struct {
key string
lastSeen time.Time
}
type oldestCandidates []evictionCandidate
func (h oldestCandidates) Len() int { return len(h) }
func (h oldestCandidates) Less(i, j int) bool {
if h[i].lastSeen.Equal(h[j].lastSeen) {
return h[i].key > h[j].key
}
return h[i].lastSeen.After(h[j].lastSeen)
}
func (h oldestCandidates) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *oldestCandidates) Push(x any) { *h = append(*h, x.(evictionCandidate)) }
func (h *oldestCandidates) Pop() any {
old := *h
n := len(old)
item := old[n-1]
*h = old[:n-1]
return item
}
func older(a, b evictionCandidate) bool {
if a.lastSeen.Equal(b.lastSeen) {
return a.key < b.key
}
return a.lastSeen.Before(b.lastSeen)
}
func keepOldest(candidates *oldestCandidates, candidate evictionCandidate, limit int) {
if candidates.Len() < limit {
heap.Push(candidates, candidate)
return
}
if older(candidate, (*candidates)[0]) {
heap.Pop(candidates)
heap.Push(candidates, candidate)
}
}
// evictOldestDomainsLocked frees a batch of least-recently-seen domains so
// a high-cardinality stream amortizes the O(N) scan across many insertions.
func (s *Stats) evictOldestDomainsLocked(count int) {
count = min(count, len(s.domains))
if count == 0 {
return
}
candidates := make(oldestCandidates, 0, count)
for domain, d := range s.domains {
keepOldest(&candidates, evictionCandidate{key: domain, lastSeen: d.lastSeen}, count)
}
for _, candidate := range candidates {
delete(s.domains, candidate.key)
}
}
// evictOldestClientsLocked frees a batch of least-recently-seen client totals.
func (s *Stats) evictOldestClientsLocked(count int) {
count = min(count, len(s.clients))
if count == 0 {
return
}
candidates := make(oldestCandidates, 0, count)
for ip, c := range s.clients {
keepOldest(&candidates, evictionCandidate{key: ip, lastSeen: c.lastSeen}, count)
}
for _, candidate := range candidates {
delete(s.clients, candidate.key)
}
}
// blockedEvictionBatch is the number of least-recently-seen blocked domains
// freed at once when the map is full.
const blockedEvictionBatch = 64
// evictOldestBlockedLocked frees a batch of least-recently-seen blocked
// domains so a pathological wildcard-rule stream amortizes the O(N) scan.
func (s *Stats) evictOldestBlockedLocked(count int) {
count = min(count, len(s.blocked))
if count == 0 {
return
}
candidates := make(oldestCandidates, 0, count)
for domain, b := range s.blocked {
keepOldest(&candidates, evictionCandidate{key: domain, lastSeen: b.lastSeen}, count)
}
for _, candidate := range candidates {
delete(s.blocked, candidate.key)
}
}
+131
View File
@@ -0,0 +1,131 @@
package admin
import (
"context"
"sync"
"time"
)
// HostnameResolver resolves a client IP to a display hostname for the traffic
// console. Implementations are injected via Options; when nil the console
// shows raw IPs and no reverse lookups are issued.
type HostnameResolver interface {
Hostname(ctx context.Context, ip string) string
}
const (
// hostnameTTL bounds how long a resolved hostname is reused before the
// IP is queried again. Client IPs are stable in practice, so an hour
// keeps reverse lookups at a few queries per active client per hour.
hostnameTTL = time.Hour
// hostnameNegativeTTL bounds how long a failed lookup (empty hostname)
// is remembered. A resolver blip must not suppress hostnames for the
// full positive TTL; five minutes keeps retries rare while recovering
// quickly from transient failures.
hostnameNegativeTTL = 5 * time.Minute
// hostnameTimeout bounds each individual reverse lookup. A stalled
// resolver must never block the traffic view.
hostnameTimeout = 2 * time.Second
// hostnameCacheMax bounds the number of cached hostnames. Client counts
// are small (the stats clients map itself is bounded), so the cache is
// cleared wholesale when full rather than paying for an eviction
// structure.
hostnameCacheMax = 1024
)
type hostnameEntry struct {
host string
at time.Time
}
// hostnameCache memoizes reverse lookups keyed by client IP. It is
// goroutine-safe: lookups run outside the lock, and only the map writes are
// serialized. now is injectable for tests.
type hostnameCache struct {
mu sync.Mutex
byIP map[string]hostnameEntry
resolver HostnameResolver
now func() time.Time
}
func newHostnameCache(resolver HostnameResolver) *hostnameCache {
return &hostnameCache{byIP: make(map[string]hostnameEntry), resolver: resolver, now: time.Now}
}
// entryTTL selects the reuse window for a cached entry: failed lookups
// (empty hostname) are retried sooner than successful ones.
func (c *hostnameCache) entryTTL(host string) time.Duration {
if host == "" {
return hostnameNegativeTTL
}
return hostnameTTL
}
// attachHostnames decorates client stats with resolved hostnames. It is a
// no-op when reverse lookups are not configured.
func (s *Server) attachHostnames(clients []ClientStat) {
if s.hostnames == nil {
return
}
s.hostnames.attach(clients)
}
// attach fills Hostname on each client stat, resolving unknown IPs through
// the injected resolver and reusing cached results within their TTL.
func (c *hostnameCache) attach(clients []ClientStat) {
if c == nil || len(clients) == 0 {
return
}
now := c.now()
var unknown []int
c.mu.Lock()
for i := range clients {
if e, ok := c.byIP[clients[i].IP]; ok && now.Sub(e.at) < c.entryTTL(e.host) {
clients[i].Hostname = e.host
continue
}
unknown = append(unknown, i)
}
c.mu.Unlock()
if len(unknown) == 0 {
return
}
// Deduplicate the batch: the same IP can appear under several entries
// (per-domain breakdown plus cumulative totals), and one lookup is
// enough. Resolve unique IPs concurrently so a stalled resolver delays
// the traffic view by at most one lookup, not by every client in turn.
// Each write targets distinct slice elements; the cache itself is
// guarded by its own mutex.
unique := make(map[string][]int, len(unknown))
for _, i := range unknown {
unique[clients[i].IP] = append(unique[clients[i].IP], i)
}
var wg sync.WaitGroup
wg.Add(len(unique))
for ip, idxs := range unique {
go func(ip string, idxs []int) {
defer wg.Done()
ctx, cancel := context.WithTimeout(context.Background(), hostnameTimeout)
host := c.resolver.Hostname(ctx, ip)
cancel()
c.store(ip, host)
for _, i := range idxs {
clients[i].Hostname = host
}
}(ip, idxs)
}
wg.Wait()
}
func (c *hostnameCache) store(ip, host string) {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.byIP) >= hostnameCacheMax {
c.byIP = make(map[string]hostnameEntry)
}
c.byIP[ip] = hostnameEntry{host: host, at: c.now()}
}
+163
View File
@@ -0,0 +1,163 @@
package admin
import (
"context"
"sync/atomic"
"testing"
"time"
)
type fakeHostnameResolver struct {
lookups atomic.Int32
byIP map[string]string
}
func (f *fakeHostnameResolver) Hostname(_ context.Context, ip string) string {
f.lookups.Add(1)
return f.byIP[ip]
}
func TestAttachHostnamesResolvesAndCaches(t *testing.T) {
t.Parallel()
resolver := &fakeHostnameResolver{byIP: map[string]string{
"192.168.1.2": "android-phone.lan",
"100.108.222.67": "workstation.tailnet.ts.net",
}}
cache := newHostnameCache(resolver)
clients := []ClientStat{
{IP: "192.168.1.2"},
{IP: "100.108.222.67"},
{IP: "1.2.3.4"}, // no PTR data
}
cache.attach(clients)
if clients[0].Hostname != "android-phone.lan" {
t.Fatalf("client 0 hostname: %q", clients[0].Hostname)
}
if clients[1].Hostname != "workstation.tailnet.ts.net" {
t.Fatalf("client 1 hostname: %q", clients[1].Hostname)
}
if clients[2].Hostname != "" {
t.Fatalf("client 2 should stay empty, got %q", clients[2].Hostname)
}
if resolver.lookups.Load() != 3 {
t.Fatalf("expected 3 lookups on first pass, got %d", resolver.lookups.Load())
}
// Second pass must hit the cache: no new lookups, same hostnames.
again := []ClientStat{{IP: "192.168.1.2"}, {IP: "100.108.222.67"}}
cache.attach(again)
if resolver.lookups.Load() != 3 {
t.Fatalf("cache miss: lookups=%d", resolver.lookups.Load())
}
if again[0].Hostname != "android-phone.lan" {
t.Fatalf("cached hostname: %q", again[0].Hostname)
}
}
func TestAttachHostnamesNoopWithoutResolver(t *testing.T) {
t.Parallel()
s := &Server{}
clients := []ClientStat{{IP: "192.168.1.2"}}
s.attachHostnames(clients)
if clients[0].Hostname != "" {
t.Fatalf("noop attach wrote hostname %q", clients[0].Hostname)
}
}
func TestAttachHostnamesConcurrent(t *testing.T) {
t.Parallel()
resolver := &fakeHostnameResolver{byIP: map[string]string{"192.168.1.2": "phone.lan"}}
cache := newHostnameCache(resolver)
done := make(chan struct{})
for range 8 {
go func() {
defer func() { done <- struct{}{} }()
for range 20 {
clients := []ClientStat{{IP: "192.168.1.2"}, {IP: "10.0.0.8"}}
cache.attach(clients)
if clients[0].Hostname != "phone.lan" {
t.Errorf("unexpected hostname %q", clients[0].Hostname)
}
}
}()
}
for range 8 {
<-done
}
}
func TestAttachHostnamesDeduplicatesBatch(t *testing.T) {
t.Parallel()
resolver := &fakeHostnameResolver{byIP: map[string]string{"192.168.1.2": "phone.lan"}}
cache := newHostnameCache(resolver)
// The same IP can appear several times in one batch (per-domain
// breakdown plus cumulative totals); one lookup must cover them all.
clients := []ClientStat{{IP: "192.168.1.2"}, {IP: "192.168.1.2"}, {IP: "192.168.1.2"}}
cache.attach(clients)
if resolver.lookups.Load() != 1 {
t.Fatalf("duplicate IPs must resolve once, lookups=%d", resolver.lookups.Load())
}
for i := range clients {
if clients[i].Hostname != "phone.lan" {
t.Fatalf("entry %d hostname: %q", i, clients[i].Hostname)
}
}
}
func TestAttachHostnamesNegativeTTLExpires(t *testing.T) {
t.Parallel()
resolver := &fakeHostnameResolver{} // no PTR data for any IP
cache := newHostnameCache(resolver)
now := time.Now()
cache.now = func() time.Time { return now }
clients := []ClientStat{{IP: "1.2.3.4"}}
cache.attach(clients)
if resolver.lookups.Load() != 1 {
t.Fatalf("expected 1 lookup, got %d", resolver.lookups.Load())
}
// Within the negative TTL the failed lookup is remembered.
now = now.Add(hostnameNegativeTTL - time.Minute)
cache.attach(clients)
if resolver.lookups.Load() != 1 {
t.Fatalf("negative result should be cached, lookups=%d", resolver.lookups.Load())
}
// Past the negative TTL the IP is queried again.
now = now.Add(2 * time.Minute)
cache.attach(clients)
if resolver.lookups.Load() != 2 {
t.Fatalf("negative result should expire, lookups=%d", resolver.lookups.Load())
}
}
func TestAttachHostnamesPositiveTTLKeepsResolved(t *testing.T) {
t.Parallel()
resolver := &fakeHostnameResolver{byIP: map[string]string{"192.168.1.2": "phone.lan"}}
cache := newHostnameCache(resolver)
now := time.Now()
cache.now = func() time.Time { return now }
clients := []ClientStat{{IP: "192.168.1.2"}}
cache.attach(clients)
if resolver.lookups.Load() != 1 {
t.Fatalf("expected 1 lookup, got %d", resolver.lookups.Load())
}
// A resolved hostname stays cached for the positive TTL.
now = now.Add(hostnameTTL - time.Minute)
cache.attach(clients)
if resolver.lookups.Load() != 1 {
t.Fatalf("resolved hostname should stay cached, lookups=%d", resolver.lookups.Load())
}
}
+44
View File
@@ -0,0 +1,44 @@
package admin
import (
"fmt"
"runtime"
"testing"
)
// TestStatsMemoryFootprint measures the heap retained by the domain table at
// realistic and worst-case cardinalities. Run with -count=1.
func TestStatsMemoryFootprint(t *testing.T) {
if testing.Short() {
t.Skip("memory footprint test; skipped in -short mode")
}
for _, domains := range []int{10_000, 50_000, 100_000} {
s := newTestStats(t)
for i := 0; i < domains; i++ {
domain := fmt.Sprintf("host%d.example.com", i)
s.record(domain, SourceHTTPS, "192.168.1.10", 1, 1000, 2000, func(d *domainStat, src *sourceStat, dc *clientStat) {
d.conns++
d.bytesUp += 1000
d.bytesDown += 2000
src.bytesUp += 1000
src.bytesDown += 2000
if dc != nil {
dc.bytesUp += 1000
dc.bytesDown += 2000
}
})
}
runtime.GC()
var m runtime.MemStats
runtime.ReadMemStats(&m)
s.mu.Lock()
retained := len(s.domains)
s.mu.Unlock()
t.Logf("%d records: retained=%d heap=%d KiB (%.0f B/domain)",
domains, retained, m.HeapAlloc/1024, float64(m.HeapAlloc)/float64(max(retained, 1)))
if retained != domains {
t.Errorf("expected %d retained domains, got %d", domains, retained)
}
}
}
+21
View File
@@ -0,0 +1,21 @@
package admin
import (
"encoding/hex"
"testing"
)
func TestGeneratePassword(t *testing.T) {
t.Parallel()
pw := GeneratePassword()
if len(pw) != 32 {
t.Fatalf("GeneratePassword() length = %d, want 32 (16 random bytes hex-encoded)", len(pw))
}
if _, err := hex.DecodeString(pw); err != nil {
t.Fatalf("GeneratePassword() is not hex-encoded: %v", err)
}
if pw == GeneratePassword() {
t.Fatal("two GeneratePassword() calls returned the same value")
}
}
+58
View File
@@ -0,0 +1,58 @@
package admin
import (
"net"
"testing"
)
// discardConn accepts and discards data without copying, isolating the
// accounting overhead from fake-conn buffer growth.
type discardConn struct {
net.Conn
}
func (discardConn) Read(p []byte) (int, error) { return len(p), nil }
func (discardConn) Write(p []byte) (int, error) { return len(p), nil }
func (discardConn) Close() error { return nil }
func (discardConn) RemoteAddr() net.Addr {
return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 12345}
}
func (discardConn) LocalAddr() net.Addr { return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 8080} }
// BenchmarkCountingConnWrite measures the per-write accounting cost of the
// relay path: atomic counters, OTel byte recording, and per-domain
// attribution under the stats mutex.
func BenchmarkCountingConnWrite(b *testing.B) {
s := newBenchmarkStats(b, 100)
conn := s.WrapConn(discardConn{}, "https")
s.BindConn(conn, "example.com")
chunk := make([]byte, 4096)
b.ReportAllocs()
b.ResetTimer()
for range b.N {
if _, err := conn.Write(chunk); err != nil {
b.Fatal(err)
}
}
}
// BenchmarkCountingConnWriteChained measures one Write plus one Read, the
// full duplex relay unit of work.
func BenchmarkCountingConnWriteChained(b *testing.B) {
s := newBenchmarkStats(b, 100)
conn := s.WrapConn(discardConn{}, "http")
s.BindConn(conn, "example.com")
chunk := make([]byte, 4096)
b.ReportAllocs()
b.ResetTimer()
for range b.N {
if _, err := conn.Write(chunk); err != nil {
b.Fatal(err)
}
if _, err := conn.Read(chunk); err != nil {
b.Fatal(err)
}
}
}
+262
View File
@@ -0,0 +1,262 @@
package admin
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
type rulesRequest struct {
Category Category `json:"category"`
Rules []string `json:"rules"`
}
// RuleSort selects the ordering of a rule listing.
type RuleSort string
const (
RuleSortDefault RuleSort = "" // retained rule order (file order)
RuleSortRule RuleSort = "rule" // alphabetical by rule text
RuleSortHits RuleSort = "hits" // by hit count
RuleSortLastSeen RuleSort = "last_seen" // by most recent hit
)
func (s RuleSort) valid() bool {
switch s {
case RuleSortDefault, RuleSortRule, RuleSortHits, RuleSortLastSeen:
return true
default:
return false
}
}
// SortDir selects the direction of a rule listing sort.
type SortDir string
const (
SortDirAsc SortDir = "asc"
SortDirDesc SortDir = "desc"
)
func (d SortDir) valid() bool {
return d == SortDirAsc || d == SortDirDesc
}
// defaultDir resolves the natural direction for a sort field: text sorts
// ascending, recency and magnitude sorts descending.
func defaultDir(s RuleSort) SortDir {
if s == RuleSortRule {
return SortDirAsc
}
return SortDirDesc
}
// RuleEntry is one retained rule in a listing, with its hit count and most
// recent hit time. Count is zero and LastSeen nil for rules that never
// matched a routed connection.
type RuleEntry struct {
Rule string `json:"rule"`
Count uint64 `json:"count"`
LastSeen *time.Time `json:"lastSeen,omitempty"`
}
// CategoryTest reports whether one rule category matched a tested domain and
// which retained rule did so.
type CategoryTest struct {
Category Category `json:"category"`
Matched bool `json:"matched"`
Rule string `json:"rule"`
}
// DomainTest is the result of testing a domain against the rule sets. Route
// is block, direct, or proxy when a rule decides it, or auto when no rule
// matched and the connection would fall through to detection / proxy.
type DomainTest struct {
Domain string `json:"domain"`
Route string `json:"route"`
Matches []CategoryTest `json:"matches"`
Note string `json:"note,omitempty"`
}
// RuleHit aggregates block decisions attributed to one rule.
type RuleHit struct {
Rule string `json:"rule"`
Count uint64 `json:"count"`
LastSeen time.Time `json:"lastSeen"`
}
// RuleMissProvider exposes per-domain access stats for connections that
// matched no block/direct/proxy rule. Rule holds the domain.
type RuleMissProvider interface {
// RuleMiss returns up to limit domains, ordered by connection count
// descending when byCount is true, or by most recent access otherwise.
RuleMiss(byCount bool, limit int) []RuleHit
}
// handleRuleMiss serves per-domain access stats for connections that
// matched no rule, ordered by count (default) or by most recent access.
func (s *Server) handleRuleMiss(w http.ResponseWriter, r *http.Request) {
provider, ok := s.opts.Rules.(RuleMissProvider)
if !ok {
writeError(w, http.StatusNotFound, "rule miss stats unavailable")
return
}
byCount := r.URL.Query().Get("sort") != "recent"
limit := 0
if raw := r.URL.Query().Get("limit"); raw != "" {
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
limit = n
}
}
writeJSON(w, http.StatusOK, provider.RuleMiss(byCount, limit))
}
// handleRulesTest reports which rules match the queried domain and the
// resulting route decision, without performing any live detection.
func (s *Server) handleRulesTest(w http.ResponseWriter, r *http.Request) {
domain := strings.TrimSpace(r.URL.Query().Get("domain"))
if domain == "" {
writeError(w, http.StatusBadRequest, "domain is required")
return
}
res, err := s.opts.Rules.TestDomain(domain)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, res)
}
func (s *Server) handleRulesList(w http.ResponseWriter, r *http.Request) {
category := Category(r.URL.Query().Get("category"))
if !category.valid() {
writeError(w, http.StatusBadRequest, "invalid category")
return
}
q := r.URL.Query().Get("q")
offset, err := strconv.Atoi(r.URL.Query().Get("offset"))
if err != nil || offset < 0 {
offset = 0
}
limit, err := strconv.Atoi(r.URL.Query().Get("limit"))
if err != nil || limit <= 0 {
limit = defaultPageSize
}
if limit > maxPageSize {
limit = maxPageSize
}
sortBy := RuleSort(r.URL.Query().Get("sort"))
if !sortBy.valid() {
sortBy = RuleSortDefault
}
dir := SortDir(r.URL.Query().Get("dir"))
if !dir.valid() {
dir = defaultDir(sortBy)
}
rules, total, err := s.opts.Rules.RuleSearch(category, q, offset, limit, sortBy, dir)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, rulesResponse{
Category: category,
Rules: rules,
Total: total,
Offset: offset,
Limit: limit,
})
}
func (s *Server) handleRulesAdd(w http.ResponseWriter, r *http.Request) {
var req rulesRequest
if !decodeJSON(w, r, &req) {
return
}
validated, err := validateRules(req)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
req.Rules = validated
if err := s.opts.Rules.RuleAdd(req.Category, req.Rules...); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleRulesRemove(w http.ResponseWriter, r *http.Request) {
var req rulesRequest
if !decodeJSON(w, r, &req) {
return
}
validated, err := validateRules(req)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
req.Rules = validated
if _, err := s.opts.Rules.RuleRemoveMany(req.Category, req.Rules...); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleRulesChanges returns the persisted rule deltas relative to the boot
// baseline, so the console can show what was customized and offer a reset.
func (s *Server) handleRulesChanges(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.opts.Rules.RuleChanges())
}
// handleRulesReset clears the deltas of one category — or of all categories
// when the body omits category — and rebuilds the runtime rule sets from the
// boot baseline.
func (s *Server) handleRulesReset(w http.ResponseWriter, r *http.Request) {
var req struct {
Category Category `json:"category"`
}
if !decodeJSON(w, r, &req) {
return
}
if req.Category != "" && !req.Category.valid() {
writeError(w, http.StatusBadRequest, "invalid category")
return
}
if err := s.opts.Rules.RuleReset(req.Category); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
func validateRules(req rulesRequest) ([]string, error) {
if !req.Category.valid() {
return nil, errors.New("invalid category")
}
if len(req.Rules) == 0 {
return nil, errors.New("no rules provided")
}
if len(req.Rules) > maxRulesPerBatch {
return nil, fmt.Errorf("too many rules, max %d", maxRulesPerBatch)
}
out := make([]string, 0, len(req.Rules))
for _, rule := range req.Rules {
rule = strings.TrimSpace(rule)
if rule == "" {
return nil, errors.New("empty rule")
}
if len(rule) > maxRuleLength {
return nil, fmt.Errorf("rule too long, max %d bytes", maxRuleLength)
}
if strings.ContainsAny(rule, "\r\n") {
return nil, errors.New("rule must not contain line breaks")
}
out = append(out, rule)
}
return out, nil
}
+313
View File
@@ -0,0 +1,313 @@
package admin
import (
"context"
"encoding/json"
"errors"
"io"
"io/fs"
"log/slog"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/sower-proxy/sower/web"
)
const (
sessionCookieName = "sower_admin"
sessionTTL = 24 * time.Hour
// sessionCookieMaxAge is how long the browser keeps the session cookie.
// It is deliberately much longer than sessionTTL: the cookie is only a
// bearer-token carrier, and the server-side session map (with sliding
// expiry) is the real gate. A short MaxAge would log the user out after
// 24h even while the server session stays refreshed.
sessionCookieMaxAge = 30 * 24 * time.Hour
maxSessionPurge = 1024
maxBodyBytes = 64 << 10
maxRulesPerBatch = 100
maxRuleLength = 253
defaultPageSize = 200
maxPageSize = 1000
// trafficCacheTTL equals the SSE traffic tick period, so the shared
// default-view cache still lets one client per window advance the history
// ring at the same cadence as a single uncached stream.
trafficCacheTTL = 5 * time.Second
)
// Category identifies a routing rule category managed through the admin API.
type Category string
const (
CategoryBlock Category = "block"
CategoryDirect Category = "direct"
CategoryProxy Category = "proxy"
)
func (c Category) valid() bool {
switch c {
case CategoryBlock, CategoryDirect, CategoryProxy:
return true
default:
return false
}
}
// RuleManager is the rule operations surface the admin server needs. The
// concrete adapter lives in cmd/sower; tests inject a fake.
type RuleManager interface {
// RuleSearch returns a page of retained rules for the category, filtered
// by q and ordered by sortBy in direction dir (see RuleSort constants).
RuleSearch(category Category, q string, offset, limit int, sortBy RuleSort, dir SortDir) ([]RuleEntry, uint64, error)
RuleAdd(category Category, rules ...string) error
RuleRemove(category Category, rule string) (bool, error)
// RuleRemoveMany removes a batch atomically from the persisted state and
// returns only the effective runtime removals.
RuleRemoveMany(category Category, rules ...string) ([]string, error)
RuleCount(category Category) uint64
// RuleChanges returns the persisted rule deltas relative to the boot
// baseline.
RuleChanges() RuleChangeSet
// RuleReset clears the deltas of one category, or of all categories when
// category is empty, rebuilding the runtime rule sets to the baseline.
RuleReset(category Category) error
TestDomain(domain string) (DomainTest, error)
}
// Options configures the admin server.
type Options struct {
Password string
TemporaryPassword bool
Version string
Date string
Rules RuleManager
Stats *Stats
SessionFile string
CookieSecure bool
// Config enables the config display/edit endpoints when non-nil.
Config ConfigManager
// Restart triggers a process restart when non-nil; the endpoint returns
// 501 when nil. The callback must return before the process exits so the
// HTTP response can be delivered.
Restart func() error
// Hostnames resolves client IPs to hostnames for the traffic console;
// reverse lookups are skipped when nil.
Hostnames HostnameResolver
}
// Server serves the admin API and the embedded frontend on one listener.
type Server struct {
opts Options
sessions map[string]time.Time
sessionFile string
mu sync.Mutex
trafficMu sync.Mutex
traffic cachedTraffic
hostnames *hostnameCache
throttle *loginThrottle
http *http.Server
}
type cachedTraffic struct {
at time.Time
snapshot TrafficSnapshot
}
func NewServer(opts Options) *Server {
s := &Server{
opts: opts,
sessions: make(map[string]time.Time),
sessionFile: opts.SessionFile,
throttle: newLoginThrottle(),
}
if opts.Hostnames != nil {
s.hostnames = newHostnameCache(opts.Hostnames)
}
s.loadSessions()
mux := http.NewServeMux()
// The login endpoint carries the same Origin guard as the other
// mutating endpoints so a cross-site page cannot drive password
// guessing through the victim's browser.
mux.HandleFunc("POST /api/session", s.mutateGuard(s.handleLogin))
mux.HandleFunc("GET /api/session", s.mutateGuard(s.auth(s.handleSession)))
mux.HandleFunc("DELETE /api/session", s.handleLogout)
mux.HandleFunc("GET /api/login-info", s.handleLoginInfo)
mux.HandleFunc("GET /api/status", s.mutateGuard(s.auth(s.handleStatus)))
mux.HandleFunc("GET /api/rules", s.mutateGuard(s.auth(s.handleRulesList)))
mux.HandleFunc("POST /api/rules", s.mutateGuard(s.auth(s.handleRulesAdd)))
mux.HandleFunc("DELETE /api/rules", s.mutateGuard(s.auth(s.handleRulesRemove)))
mux.HandleFunc("GET /api/rules/changes", s.mutateGuard(s.auth(s.handleRulesChanges)))
mux.HandleFunc("POST /api/rules/reset", s.mutateGuard(s.auth(s.handleRulesReset)))
mux.HandleFunc("GET /api/rules/test", s.mutateGuard(s.auth(s.handleRulesTest)))
mux.HandleFunc("GET /api/rules/miss", s.mutateGuard(s.auth(s.handleRuleMiss)))
mux.HandleFunc("GET /api/traffic", s.mutateGuard(s.auth(s.handleTraffic)))
mux.HandleFunc("GET /api/totals", s.mutateGuard(s.auth(s.handleTotals)))
mux.HandleFunc("GET /api/history", s.mutateGuard(s.auth(s.handleHistory)))
mux.HandleFunc("GET /api/stream", s.mutateGuard(s.auth(s.handleStream)))
if s.opts.Config != nil {
mux.HandleFunc("GET /api/config", s.mutateGuard(s.auth(s.handleConfigGet)))
mux.HandleFunc("PATCH /api/config", s.mutateGuard(s.auth(s.handleConfigPatch)))
}
if s.opts.Restart != nil {
mux.HandleFunc("POST /api/restart", s.mutateGuard(s.auth(s.handleRestart)))
}
if s.opts.Stats != nil {
mux.HandleFunc("GET /metrics", s.handleMetrics)
}
mux.HandleFunc("/", s.handleStatic)
s.http = &http.Server{
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
// IdleTimeout bounds keep-alive connections between requests; the
// SSE stream is an active response and is unaffected. MaxHeaderBytes
// bounds the request head so a hostile client cannot grow memory
// with oversized headers.
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 64 << 10,
}
return s
}
// Serve accepts connections on ln until Shutdown or a fatal serve error.
func (s *Server) Serve(ln net.Listener) error {
err := s.http.Serve(ln)
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
}
// Shutdown gracefully stops the server.
func (s *Server) Shutdown(ctx context.Context) error {
return s.http.Shutdown(ctx)
}
// handleLoginInfo reports whether the admin console runs with a
// startup-generated temporary password, so the login page can tell the user
// where to find it. It is intentionally public: the login page needs it
// before authenticating, and it leaks no credential material.
func (s *Server) handleLoginInfo(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"temporaryPassword": s.opts.TemporaryPassword,
})
}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.statusPayload())
}
// statusPayload is the /api/status body, reused by the SSE stream.
func (s *Server) statusPayload() map[string]any {
return map[string]any{
"version": s.opts.Version,
"date": s.opts.Date,
"uptime": int64(time.Since(statStart(s.opts.Stats)).Seconds()),
"rules": map[string]uint64{
string(CategoryBlock): s.opts.Rules.RuleCount(CategoryBlock),
string(CategoryDirect): s.opts.Rules.RuleCount(CategoryDirect),
string(CategoryProxy): s.opts.Rules.RuleCount(CategoryProxy),
},
}
}
// handleMetrics serves the Prometheus exposition format. It is intentionally
// unauthenticated: scrapers cannot carry the session cookie, and the exposed
// metrics are aggregate counters without per-domain or credential data.
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
if s.opts.Stats == nil {
writeError(w, http.StatusInternalServerError, "stats unavailable")
return
}
s.opts.Stats.Metrics().ServeHTTP(w, r)
}
// --- static frontend ---
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
// Unknown API paths must not fall through to the SPA shell.
if r.URL.Path == "/api" || strings.HasPrefix(r.URL.Path, "/api/") {
writeError(w, http.StatusNotFound, "unknown API endpoint")
return
}
fsys, err := web.Dist()
if err != nil {
slog.Error("load embedded web dist", "error", err)
writeError(w, http.StatusInternalServerError, "embedded web assets unavailable")
return
}
// The embedded FS is read-only and r.URL.Path is already normalized by
// net/http, so traversal cannot escape the dist directory.
p := strings.TrimPrefix(r.URL.Path, "/")
if p == "" {
p = "index.html"
}
if !fs.ValidPath(p) {
writeError(w, http.StatusNotFound, "not found")
return
}
if strings.HasPrefix(p, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:")
if _, err := fs.Stat(fsys, p); err != nil {
p = "index.html" // SPA fallback for client-side routes
}
http.ServeFileFS(w, r, fsys, p)
}
// --- helpers ---
func statStart(stats *Stats) time.Time {
if stats == nil {
return time.Now()
}
return stats.start
}
type rulesResponse struct {
Category Category `json:"category"`
Rules []RuleEntry `json:"rules"`
Total uint64 `json:"total"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return false
}
if err := dec.Decode(&struct{}{}); err != io.EOF {
writeError(w, http.StatusBadRequest, "unexpected trailing JSON data")
return false
}
return true
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Debug("write JSON response", "error", err)
}
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
File diff suppressed because it is too large Load Diff
+429
View File
@@ -0,0 +1,429 @@
package admin
import (
"container/heap"
"sort"
"time"
)
// Snapshot returns the window view of the current stats, advancing the
// history ring along the way. Domains stale for over staleDomainAge are
// hidden from the window but kept in the map for the cumulative Totals view.
// sort selects the domain ordering, source restricts domains to one entry
// point, and client restricts them to one client IP; invalid values fall back
// to DomainSortBytes and SourceAll, and an empty client disables the filter.
// Clients aggregates per-client counters over the source-filtered view and
// is intentionally unaffected by the client filter, so the console's client
// chips stay stable while one is selected.
func (s *Stats) Snapshot(sort DomainSort, source Source, client string) TrafficSnapshot {
s.sample()
now := time.Now()
snap := TrafficSnapshot{
Uptime: int64(now.Sub(s.start).Seconds()),
DNSQueries: s.dnsQueries.Load(),
BytesUp: s.bytesUp.Load(),
BytesDown: s.bytesDown.Load(),
Domains: []DomainStat{},
}
snap.Conns.HTTP = s.connsHTTP.Load()
snap.Conns.HTTPS = s.connsHTTPS.Load()
snap.Conns.Socks5 = s.connsSocks.Load()
snap.Active.HTTP = s.activeHTTP.Load()
snap.Active.HTTPS = s.activeHTTPS.Load()
snap.Active.Socks5 = s.activeSocks.Load()
snap.RuleHits.Block = s.ruleBlock.Load()
snap.RuleHits.Direct = s.ruleDirect.Load()
snap.RuleHits.Proxy = s.ruleProxy.Load()
snap.Errors.Dial = s.dialFailed.Load()
snap.Errors.DNS = s.dnsFailed.Load()
snap.Errors.Accept = s.acceptFailed.Load()
snap.Events = s.recentErrorEvents()
snap.Rates.BytesUpPerSec, snap.Rates.BytesDownPerSec, snap.Rates.DNSPerSec, snap.Rates.ConnsPerSec = s.rates()
snap.System.Goroutines = uint64(goroutineCount())
snap.System.HeapAlloc = heapAlloc()
s.mu.Lock()
clientAgg := make(map[string]*ClientStat)
for domain, d := range s.domains {
if now.Sub(d.lastSeen) > staleDomainAge {
// Window view only: the entry stays in the map so the cumulative
// Totals view keeps its from-start counters.
continue
}
ds := DomainStat{
Domain: domain,
Conns: d.conns,
BytesUp: d.bytesUp,
BytesDown: d.bytesDown,
LastSeen: d.lastSeen,
}
if source != SourceAll {
src, ok := d.bySource[source] // nil map read is safe
if !ok || (src.conns == 0 && src.bytesUp == 0 && src.bytesDown == 0) {
continue
}
ds.Conns = src.conns
ds.BytesUp = src.bytesUp
ds.BytesDown = src.bytesDown
ds.LastSeen = src.lastSeen
}
// Aggregate the client chips over the source-filtered view but before
// the client filter: the chip row is a navigation device and must stay
// stable (and globally counted) while a client filter is active.
for ip, c := range d.byClient {
agg := clientAgg[ip]
if agg == nil {
agg = &ClientStat{IP: ip}
clientAgg[ip] = agg
}
agg.Conns += c.conns
agg.BytesUp += c.bytesUp
agg.BytesDown += c.bytesDown
if c.lastSeen.After(agg.LastSeen) {
agg.LastSeen = c.lastSeen
}
}
if client != "" {
dc, ok := d.byClient[client] // nil map read is safe
if !ok || (dc.conns == 0 && dc.bytesUp == 0 && dc.bytesDown == 0) {
continue
}
ds.Conns = dc.conns
ds.BytesUp = dc.bytesUp
ds.BytesDown = dc.bytesDown
ds.LastSeen = dc.lastSeen
ds.LastClientIP = client
} else {
// Most recent client across all sources for the domain; the
// per-client map has no source dimension, so this is the best
// approximation for a source-filtered view.
var latest time.Time
for ip, c := range d.byClient {
if c.lastSeen.After(latest) {
latest, ds.LastClientIP = c.lastSeen, ip
}
}
}
snap.Domains = append(snap.Domains, ds)
}
// The blocked counter is global: block decisions carry no source or
// client dimension, so the list is unaffected by the view filters.
blocked := make([]BlockedStat, 0, len(s.blocked))
for domain, b := range s.blocked {
blocked = append(blocked, BlockedStat{Domain: domain, Count: b.count, LastSeen: b.lastSeen})
}
s.mu.Unlock()
snap.Domains = topDomains(snap.Domains, snapshotTopN, sort)
snap.Clients = topClients(clientAgg)
snap.Blocked = topBlocked(blocked)
return snap
}
// topBlocked orders blocked domains by count (ties break alphabetically) and
// caps the list at snapshotTopN.
func topBlocked(blocked []BlockedStat) []BlockedStat {
sort.Slice(blocked, func(i, j int) bool {
if blocked[i].Count != blocked[j].Count {
return blocked[i].Count > blocked[j].Count
}
return blocked[i].Domain < blocked[j].Domain
})
if len(blocked) > snapshotTopN {
blocked = blocked[:snapshotTopN]
}
return blocked
}
// domainLess reports whether a should appear before b in the requested order.
func domainLess(mode DomainSort) func(a, b DomainStat) bool {
switch mode {
case DomainSortRecent:
return func(a, b DomainStat) bool {
if !a.LastSeen.Equal(b.LastSeen) {
return a.LastSeen.After(b.LastSeen)
}
return a.Domain < b.Domain
}
case DomainSortConns:
return func(a, b DomainStat) bool {
if a.Conns != b.Conns {
return a.Conns > b.Conns
}
return a.Domain < b.Domain
}
default:
return func(a, b DomainStat) bool {
ta, tb := a.BytesUp+a.BytesDown, b.BytesUp+b.BytesDown
if ta != tb {
return ta > tb
}
return a.Domain < b.Domain
}
}
}
type domainTopHeap struct {
values []DomainStat
less func(DomainStat, DomainStat) bool
}
func (h domainTopHeap) Len() int { return len(h.values) }
// Less puts the worst retained item at the root.
func (h domainTopHeap) Less(i, j int) bool { return h.less(h.values[j], h.values[i]) }
func (h domainTopHeap) Swap(i, j int) { h.values[i], h.values[j] = h.values[j], h.values[i] }
func (h *domainTopHeap) Push(x any) { h.values = append(h.values, x.(DomainStat)) }
func (h *domainTopHeap) Pop() any {
old := h.values
n := len(old)
item := old[n-1]
h.values = old[:n-1]
return item
}
// topDomains returns the top limit domains in their requested display order.
func topDomains(domains []DomainStat, limit int, mode DomainSort) []DomainStat {
if len(domains) <= limit {
sortDomains(domains, mode)
return domains
}
less := domainLess(mode)
top := domainTopHeap{values: make([]DomainStat, 0, limit), less: less}
for _, domain := range domains {
if top.Len() < limit {
heap.Push(&top, domain)
continue
}
if less(domain, top.values[0]) {
heap.Pop(&top)
heap.Push(&top, domain)
}
}
sort.Slice(top.values, func(i, j int) bool { return less(top.values[i], top.values[j]) })
return top.values
}
type clientTopHeap []ClientStat
func (h clientTopHeap) Len() int { return len(h) }
func (h clientTopHeap) Less(i, j int) bool { return clientLess(h[j], h[i]) }
func (h clientTopHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *clientTopHeap) Push(x any) { *h = append(*h, x.(ClientStat)) }
func (h *clientTopHeap) Pop() any {
old := *h
n := len(old)
item := old[n-1]
*h = old[:n-1]
return item
}
func clientLess(a, b ClientStat) bool {
ta, tb := a.BytesUp+a.BytesDown, b.BytesUp+b.BytesDown
if ta == tb {
return a.IP < b.IP
}
return ta > tb
}
// topClients orders per-IP aggregates by total bytes and caps the list.
func topClients(agg map[string]*ClientStat) []ClientStat {
top := make(clientTopHeap, 0, min(clientsTopN, len(agg)))
for _, c := range agg {
item := *c
if top.Len() < clientsTopN {
heap.Push(&top, item)
continue
}
if clientLess(item, top[0]) {
heap.Pop(&top)
heap.Push(&top, item)
}
}
sort.Slice(top, func(i, j int) bool { return clientLess(top[i], top[j]) })
return top
}
// Totals is the from-start cumulative traffic view, independent of the
// window's staleness filter.
type Totals struct {
Domains []DomainStat `json:"domains"`
Clients []ClientStat `json:"clients"`
}
// Totals returns the cumulative top domains and clients since start, each
// ordered by total bytes. Unlike Snapshot it ignores staleness: counters
// accumulate until the bounded maps evict their least-recently-seen entries.
func (s *Stats) Totals() Totals {
s.mu.Lock()
domains := make([]DomainStat, 0, len(s.domains))
for domain, d := range s.domains {
ds := DomainStat{
Domain: domain,
Conns: d.conns,
BytesUp: d.bytesUp,
BytesDown: d.bytesDown,
LastSeen: d.lastSeen,
}
var latest time.Time
for ip, c := range d.byClient {
if c.lastSeen.After(latest) {
latest, ds.LastClientIP = c.lastSeen, ip
}
}
domains = append(domains, ds)
}
domains = topDomains(domains, snapshotTopN, DomainSortBytes)
clients := make([]ClientStat, 0, len(s.clients))
for ip, c := range s.clients {
clients = append(clients, ClientStat{
IP: ip,
Conns: c.conns,
BytesUp: c.bytesUp,
BytesDown: c.bytesDown,
LastSeen: c.lastSeen,
})
}
s.mu.Unlock()
sort.Slice(clients, func(i, j int) bool {
ti := clients[i].BytesUp + clients[i].BytesDown
tj := clients[j].BytesUp + clients[j].BytesDown
if ti == tj {
return clients[i].IP < clients[j].IP
}
return ti > tj
})
if len(clients) > clientsTopN {
clients = clients[:clientsTopN]
}
return Totals{Domains: domains, Clients: clients}
}
// sortDomains orders domains by the selected mode: total bytes (default),
// most recently seen, or connection count. Ties break alphabetically.
func sortDomains(ds []DomainStat, mode DomainSort) {
less := func(a, b DomainStat) bool { return a.Domain < b.Domain }
switch mode {
case DomainSortRecent:
less = func(a, b DomainStat) bool {
if !a.LastSeen.Equal(b.LastSeen) {
return a.LastSeen.After(b.LastSeen)
}
return a.Domain < b.Domain
}
case DomainSortConns:
less = func(a, b DomainStat) bool {
if a.Conns != b.Conns {
return a.Conns > b.Conns
}
return a.Domain < b.Domain
}
default:
less = func(a, b DomainStat) bool {
ta, tb := a.BytesUp+a.BytesDown, b.BytesUp+b.BytesDown
if ta != tb {
return ta > tb
}
return a.Domain < b.Domain
}
}
sort.Slice(ds, func(i, j int) bool { return less(ds[i], ds[j]) })
}
// History returns a copy of the in-process history ring.
func (s *Stats) History() History {
s.histMu.Lock()
defer s.histMu.Unlock()
out := make([]HistorySample, len(s.history))
copy(out, s.history)
return History{Samples: out}
}
// sample advances the history ring with the deltas since the last sample. It
// is called from Snapshot, so the ring advances at the poll cadence.
func (s *Stats) sample() {
now := time.Now()
cur := sampleCounters{
at: now,
bytesUp: s.bytesUp.Load(),
bytesDown: s.bytesDown.Load(),
dns: s.dnsQueries.Load(),
conns: s.connsHTTP.Load() + s.connsHTTPS.Load() + s.connsSocks.Load(),
block: s.ruleBlock.Load(),
direct: s.ruleDirect.Load(),
proxy: s.ruleProxy.Load(),
}
s.histMu.Lock()
defer s.histMu.Unlock()
prev := s.lastSample
if !prev.at.IsZero() && now.Sub(prev.at) < historyMinGap {
return
}
s.lastSample = cur
if prev.at.IsZero() {
return // first sample establishes the baseline
}
sample := HistorySample{
At: now,
Active: s.activeHTTP.Load() + s.activeHTTPS.Load() + s.activeSocks.Load(),
ActiveHTTP: s.activeHTTP.Load(),
ActiveHTTPS: s.activeHTTPS.Load(),
ActiveSocks: s.activeSocks.Load(),
}
if now.Sub(prev.at) <= historyMaxGap {
sample.BytesUp = cur.bytesUp - prev.bytesUp
sample.BytesDown = cur.bytesDown - prev.bytesDown
sample.DNS = cur.dns - prev.dns
sample.Conns = cur.conns - prev.conns
sample.Block = cur.block - prev.block
sample.Direct = cur.direct - prev.direct
sample.Proxy = cur.proxy - prev.proxy
}
s.history = append(s.history, sample)
if len(s.history) > historyCapacity {
s.history = s.history[len(s.history)-historyCapacity:]
}
}
// rates computes per-second rates over the sliding rateWindow from the
// history ring.
func (s *Stats) rates() (up, down, dns, conns float64) {
s.histMu.Lock()
defer s.histMu.Unlock()
now := time.Now()
cutoff := now.Add(-rateWindow)
var sumUp, sumDown, sumDNS, sumConns uint64
var start time.Time
for _, h := range s.history {
if h.At.Before(cutoff) {
continue
}
if start.IsZero() {
start = h.At
}
sumUp += h.BytesUp
sumDown += h.BytesDown
sumDNS += h.DNS
sumConns += h.Conns
}
if start.IsZero() || now.Sub(start) < time.Second {
return 0, 0, 0, 0
}
elapsed := now.Sub(start).Seconds()
return float64(sumUp) / elapsed, float64(sumDown) / elapsed, float64(sumDNS) / elapsed, float64(sumConns) / elapsed
}
+512
View File
@@ -0,0 +1,512 @@
package admin
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"slices"
"sync"
"time"
)
const stateVersion = 1
// RuleDelta records admin-side rule changes relative to the boot baseline:
// Add holds rules absent from the baseline, Remove holds tombstoned baseline
// rules. Both lists are deduplicated and a rule never appears in both.
type RuleDelta struct {
Add []string `json:"add"`
Remove []string `json:"remove"`
}
// ConfigOverrides holds the whitelisted config fields editable through the
// admin console. Pointer fields distinguish "not submitted" (nil, keep the
// previous override) from an explicit change: a non-empty value sets the
// override, and a non-nil empty value/empty list clears it so the
// file/flag configuration takes over again. Secrets must never be added
// here. List fields (rule sources) hold the full inline lists.
type ConfigOverrides struct {
LogLevel *string `json:"log_level,omitempty"`
DNSUpstream *string `json:"dns_upstream,omitempty"`
DNSFallback *string `json:"dns_fallback,omitempty"`
DNSReverse *string `json:"dns_reverse,omitempty"`
RemoteType *string `json:"remote_type,omitempty"`
RemoteAddr *string `json:"remote_addr,omitempty"`
RemoteTLSServerName *string `json:"remote_tls_server_name,omitempty"`
RemoteTLSClientHello *string `json:"remote_tls_client_hello,omitempty"`
RemoteTLSInsecureSkipVerify *string `json:"remote_tls_insecure_skip_verify,omitempty"`
DNSServe *string `json:"dns_serve,omitempty"`
DNSServe6 *string `json:"dns_serve6,omitempty"`
Socks5Addr *string `json:"socks5_addr,omitempty"`
AdminSessionFile *string `json:"admin_session_file,omitempty"`
AdminDisableSessionPersistence *string `json:"admin_disable_session_persistence,omitempty"`
AdminCookieSecure *string `json:"admin_cookie_secure,omitempty"`
AdminStateFile *string `json:"admin_state_file,omitempty"`
RouterBlockFile *string `json:"router_block_file,omitempty"`
RouterBlockFilePrefix *string `json:"router_block_file_prefix,omitempty"`
RouterBlockFileSkipRules *[]string `json:"router_block_file_skip_rules,omitempty"`
RouterBlockRules *[]string `json:"router_block_rules,omitempty"`
RouterDirectFile *string `json:"router_direct_file,omitempty"`
RouterDirectFilePrefix *string `json:"router_direct_file_prefix,omitempty"`
RouterDirectFileSkipRules *[]string `json:"router_direct_file_skip_rules,omitempty"`
RouterDirectRules *[]string `json:"router_direct_rules,omitempty"`
RouterProxyFile *string `json:"router_proxy_file,omitempty"`
RouterProxyFilePrefix *string `json:"router_proxy_file_prefix,omitempty"`
RouterProxyFileSkipRules *[]string `json:"router_proxy_file_skip_rules,omitempty"`
RouterProxyRules *[]string `json:"router_proxy_rules,omitempty"`
RouterCountryMMDB *string `json:"router_country_mmdb,omitempty"`
RouterCountryFile *string `json:"router_country_file,omitempty"`
RouterCountryRules *[]string `json:"router_country_rules,omitempty"`
}
// State is the on-disk admin state document. Revision bumps on every
// mutation and drives optimistic concurrency for config PATCHes.
type State struct {
Version int `json:"version"`
Revision uint64 `json:"revision"`
UpdatedAt time.Time `json:"updatedAt"`
Rules map[Category]*RuleDelta `json:"rules"`
Config ConfigOverrides `json:"config"`
}
// RuleChangeSet is the API view of the current rule deltas.
type RuleChangeSet struct {
Persistent bool `json:"persistent"`
Revision uint64 `json:"revision"`
Rules map[Category]RuleDelta `json:"rules"`
}
// ErrRevisionMismatch is returned when a config PATCH carries a stale
// revision, i.e. another change landed after the caller loaded the view.
var ErrRevisionMismatch = errors.New("state revision mismatch")
// StateStore serializes all admin state mutations behind one mutex and
// persists them atomically (tmp file + fsync + rename, 0600). Mutations are
// applied to a cloned candidate first and only committed after a successful
// write, so a persist failure leaves the previous state intact and callers
// must not apply the corresponding runtime change.
type StateStore struct {
mu sync.Mutex
path string
state State
baseline map[Category]map[string]struct{}
}
// LoadStateStore opens the state file at path. An empty path yields an
// in-memory-only store (mutations work, nothing is written). A missing file
// starts empty; a corrupt or unsupported file is quarantined aside and
// starts empty as well, so a bad state file never blocks startup.
func LoadStateStore(path string) *StateStore {
st := &StateStore{path: path, state: newState()}
if path == "" {
return st
}
data, err := os.ReadFile(path)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
slog.Warn("read admin state", "file", path, "error", err)
}
return st
}
var s State
if err := json.Unmarshal(data, &s); err != nil || s.Version != stateVersion || !validStateCategories(s.Rules) {
st.quarantine()
return st
}
st.state = normalizeState(s)
return st
}
func newState() State {
return State{Version: stateVersion, Rules: make(map[Category]*RuleDelta)}
}
// normalizeState repairs load-time inconsistencies: nil maps, duplicate
// delta entries, and rules present in both Add and Remove (Add wins: the
// most recent mutation appended it there).
func normalizeState(s State) State {
if s.Rules == nil {
s.Rules = make(map[Category]*RuleDelta)
}
for cat, d := range s.Rules {
if d == nil {
delete(s.Rules, cat)
continue
}
d.Add = dedupeStrings(d.Add)
d.Remove = dedupeStrings(d.Remove)
keep := make(map[string]struct{}, len(d.Add))
for _, r := range d.Add {
keep[r] = struct{}{}
}
d.Remove = slices.DeleteFunc(d.Remove, func(r string) bool {
_, ok := keep[r]
return ok
})
if len(d.Add) == 0 && len(d.Remove) == 0 {
delete(s.Rules, cat)
}
}
return s
}
func validStateCategories(rules map[Category]*RuleDelta) bool {
for cat := range rules {
if !cat.valid() {
return false
}
}
return true
}
func dedupeStrings(in []string) []string {
seen := make(map[string]struct{}, len(in))
out := in[:0]
for _, s := range in {
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
// Persistent reports whether mutations are written to disk.
func (st *StateStore) Persistent() bool { return st.path != "" }
// quarantine renames a corrupt state file aside so the next start does not
// trip over it again.
func (st *StateStore) quarantine() {
dst := fmt.Sprintf("%s.corrupt-%d", st.path, time.Now().Unix())
if err := os.Rename(st.path, dst); err != nil {
slog.Warn("quarantine corrupt admin state", "file", st.path, "error", err)
return
}
slog.Warn("quarantined corrupt admin state, starting empty", "file", st.path, "moved_to", dst)
}
// SetBaseline records the boot rule lists (file + inline config) per
// category and garbage-collects stale delta entries: tombstones whose rule
// left the baseline and additions that entered it. GC does not bump the
// revision; a persist failure keeps the cleaned in-memory state anyway
// because the dropped entries were no-ops.
func (st *StateStore) SetBaseline(baseline map[Category][]string) {
sets := make(map[Category]map[string]struct{}, len(baseline))
for cat, rules := range baseline {
m := make(map[string]struct{}, len(rules))
for _, r := range rules {
m[r] = struct{}{}
}
sets[cat] = m
}
st.mu.Lock()
defer st.mu.Unlock()
st.baseline = sets
cand := st.cloneLocked()
changed := false
for cat, d := range cand.Rules {
base := sets[cat]
adds := slices.DeleteFunc(slices.Clone(d.Add), func(r string) bool {
_, ok := base[r]
return ok
})
rems := slices.DeleteFunc(slices.Clone(d.Remove), func(r string) bool {
_, ok := base[r]
return !ok
})
if !slices.Equal(adds, d.Add) || !slices.Equal(rems, d.Remove) {
changed = true
}
d.Add, d.Remove = adds, rems
if len(d.Add) == 0 && len(d.Remove) == 0 {
delete(cand.Rules, cat)
}
}
if !changed {
return
}
cand.UpdatedAt = time.Now()
if err := st.persistLocked(cand); err != nil {
slog.Warn("persist admin state GC", "error", err)
}
st.state = cand
}
// ConfigOverrides returns the current whitelisted config overrides.
func (st *StateStore) ConfigOverrides() ConfigOverrides {
st.mu.Lock()
defer st.mu.Unlock()
return st.state.Config
}
// ApplyConfig replaces the config overrides and persists, guarded by the
// caller's revision. It returns the new revision.
func (st *StateStore) ApplyConfig(o ConfigOverrides, revision uint64) (uint64, error) {
st.mu.Lock()
defer st.mu.Unlock()
if revision != st.state.Revision {
return 0, ErrRevisionMismatch
}
cand := st.cloneLocked()
cand.Config = o
cand.bump()
if err := st.persistLocked(cand); err != nil {
return 0, err
}
st.state = cand
return cand.Revision, nil
}
// Revision returns the current state revision.
func (st *StateStore) Revision() uint64 {
st.mu.Lock()
defer st.mu.Unlock()
return st.state.Revision
}
// RuleAdd records rules as additions (or restores tombstoned baseline rules)
// and persists. It returns the rules that must enter the runtime rule set;
// rules already effective are dropped from the result. A nil result with nil
// error means the call was a no-op.
func (st *StateStore) RuleAdd(category Category, rules ...string) ([]string, error) {
st.mu.Lock()
defer st.mu.Unlock()
cand := st.cloneLocked()
d := cand.delta(category)
var runtimeAdd []string
for _, rule := range rules {
_, inBase := st.baseline[category][rule]
switch {
case inBase && slices.Contains(d.Remove, rule):
d.Remove = removeString(d.Remove, rule)
runtimeAdd = append(runtimeAdd, rule)
case inBase, slices.Contains(d.Add, rule):
// already effective
default:
d.Add = append(d.Add, rule)
runtimeAdd = append(runtimeAdd, rule)
}
}
if len(runtimeAdd) == 0 {
return nil, nil
}
if len(d.Add) == 0 && len(d.Remove) == 0 {
delete(cand.Rules, category)
}
cand.bump()
if err := st.persistLocked(cand); err != nil {
return nil, err
}
st.state = cand
return runtimeAdd, nil
}
// RuleRemove records one rule removal and persists it as one candidate
// mutation. It is the single-rule convenience wrapper for RuleRemoveBatch.
func (st *StateStore) RuleRemove(category Category, rule string) (bool, error) {
removed, err := st.RuleRemoveBatch(category, rule)
return len(removed) > 0, err
}
// RuleRemoveBatch applies all effective removals to one candidate state and
// persists once. Unknown rules and duplicates are ignored. The returned list
// is the exact runtime work that may be applied after persistence succeeds.
func (st *StateStore) RuleRemoveBatch(category Category, rules ...string) ([]string, error) {
st.mu.Lock()
defer st.mu.Unlock()
cand := st.cloneLocked()
d := cand.delta(category)
removed := make([]string, 0, len(rules))
seen := make(map[string]struct{}, len(rules))
for _, rule := range rules {
if _, ok := seen[rule]; ok {
continue
}
seen[rule] = struct{}{}
_, inBase := st.baseline[category][rule]
switch {
case slices.Contains(d.Add, rule):
d.Add = removeString(d.Add, rule)
removed = append(removed, rule)
case inBase && !slices.Contains(d.Remove, rule):
d.Remove = append(d.Remove, rule)
removed = append(removed, rule)
}
}
if len(removed) == 0 {
return nil, nil
}
if len(d.Add) == 0 && len(d.Remove) == 0 {
delete(cand.Rules, category)
}
cand.bump()
if err := st.persistLocked(cand); err != nil {
return nil, err
}
st.state = cand
return removed, nil
}
// RuleReset clears the deltas of the given categories and persists. Clearing
// a category that has no delta is a no-op.
func (st *StateStore) RuleReset(categories ...Category) error {
st.mu.Lock()
defer st.mu.Unlock()
cand := st.cloneLocked()
changed := false
for _, cat := range categories {
if _, ok := cand.Rules[cat]; ok {
delete(cand.Rules, cat)
changed = true
}
}
if !changed {
return nil
}
cand.bump()
if err := st.persistLocked(cand); err != nil {
return err
}
st.state = cand
return nil
}
// Changes returns a snapshot of the current rule deltas. All three
// categories are always present so clients can render unconditionally.
func (st *StateStore) Changes() RuleChangeSet {
st.mu.Lock()
defer st.mu.Unlock()
out := RuleChangeSet{
Persistent: st.path != "",
Revision: st.state.Revision,
Rules: make(map[Category]RuleDelta, 3),
}
for _, cat := range []Category{CategoryBlock, CategoryDirect, CategoryProxy} {
if d, ok := st.state.Rules[cat]; ok {
out.Rules[cat] = RuleDelta{
Add: nonNilStrings(d.Add),
Remove: nonNilStrings(d.Remove),
}
} else {
out.Rules[cat] = RuleDelta{Add: []string{}, Remove: []string{}}
}
}
return out
}
// nonNilStrings clones a slice, mapping nil to an empty slice so API
// responses never carry JSON null where an array is expected.
func nonNilStrings(in []string) []string {
if out := slices.Clone(in); out != nil {
return out
}
return []string{}
}
// Delta returns a copy of one category's delta for runtime reconstruction.
func (st *StateStore) Delta(category Category) RuleDelta {
st.mu.Lock()
defer st.mu.Unlock()
if d, ok := st.state.Rules[category]; ok {
return RuleDelta{Add: nonNilStrings(d.Add), Remove: nonNilStrings(d.Remove)}
}
return RuleDelta{}
}
func (st *StateStore) cloneLocked() State {
cand := st.state
cand.Rules = make(map[Category]*RuleDelta, len(st.state.Rules))
for cat, d := range st.state.Rules {
cand.Rules[cat] = &RuleDelta{
Add: slices.Clone(d.Add),
Remove: slices.Clone(d.Remove),
}
}
return cand
}
func (s *State) delta(category Category) *RuleDelta {
d, ok := s.Rules[category]
if !ok {
d = &RuleDelta{}
s.Rules[category] = d
}
return d
}
func (s *State) bump() {
s.Revision++
s.UpdatedAt = time.Now()
}
func removeString(list []string, s string) []string {
if i := slices.Index(list, s); i >= 0 {
return slices.Delete(list, i, i+1)
}
return list
}
// persistLocked writes the state atomically: a temp file in the same
// directory, fsync, rename, then a directory fsync so the rename itself is
// durable. It is a no-op for in-memory stores.
func (st *StateStore) persistLocked(s State) error {
if st.path == "" {
return nil
}
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return fmt.Errorf("marshal admin state: %w", err)
}
dir := filepath.Dir(st.path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("create admin state dir: %w", err)
}
tmp, err := os.CreateTemp(dir, ".admin-state-*.tmp")
if err != nil {
return fmt.Errorf("create admin state temp file: %w", err)
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("write admin state temp file: %w", err)
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return fmt.Errorf("fsync admin state temp file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close admin state temp file: %w", err)
}
if err := os.Chmod(tmpName, 0o600); err != nil {
return fmt.Errorf("chmod admin state temp file: %w", err)
}
if err := os.Rename(tmpName, st.path); err != nil {
return fmt.Errorf("rename admin state file: %w", err)
}
if d, err := os.Open(dir); err == nil {
_ = d.Sync()
_ = d.Close()
}
return nil
}
+327
View File
@@ -0,0 +1,327 @@
package admin
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"slices"
"sync"
"testing"
)
func testBaseline() map[Category][]string {
return map[Category][]string{
CategoryBlock: {"ads.example.com", "tracker.example.com"},
CategoryDirect: {"internal.example.com"},
CategoryProxy: {"**.google.com"},
}
}
func stateFilePath(t *testing.T) string {
t.Helper()
return filepath.Join(t.TempDir(), "admin-state.json")
}
func readStateFile(t *testing.T, path string) State {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read state file: %v", err)
}
var s State
if err := json.Unmarshal(data, &s); err != nil {
t.Fatalf("unmarshal state file: %v", err)
}
return s
}
func TestStateStoreRuleAddAndRemoveAlgebra(t *testing.T) {
t.Parallel()
path := stateFilePath(t)
st := LoadStateStore(path)
st.SetBaseline(testBaseline())
// New rule: goes to Add and must enter the runtime set.
add, err := st.RuleAdd(CategoryProxy, "**.example.com")
if err != nil || !slices.Equal(add, []string{"**.example.com"}) {
t.Fatalf("add new rule: runtimeAdd=%v err=%v", add, err)
}
// Duplicate add is a no-op.
add, err = st.RuleAdd(CategoryProxy, "**.example.com")
if err != nil || add != nil {
t.Fatalf("duplicate add: runtimeAdd=%v err=%v", add, err)
}
// Baseline rule without tombstone is a no-op.
add, err = st.RuleAdd(CategoryBlock, "ads.example.com")
if err != nil || add != nil {
t.Fatalf("add existing baseline rule: runtimeAdd=%v err=%v", add, err)
}
// Tombstone a baseline rule.
found, err := st.RuleRemove(CategoryBlock, "ads.example.com")
if err != nil || !found {
t.Fatalf("tombstone baseline rule: found=%v err=%v", found, err)
}
// Re-adding the tombstoned baseline rule clears the tombstone and
// returns it for runtime re-add.
add, err = st.RuleAdd(CategoryBlock, "ads.example.com")
if err != nil || !slices.Equal(add, []string{"ads.example.com"}) {
t.Fatalf("restore tombstoned rule: runtimeAdd=%v err=%v", add, err)
}
// Removing an admin-added rule drops it from Add.
found, err = st.RuleRemove(CategoryProxy, "**.example.com")
if err != nil || !found {
t.Fatalf("remove admin-added rule: found=%v err=%v", found, err)
}
// Unknown rule and double tombstone are not found.
found, err = st.RuleRemove(CategoryProxy, "**.nonexistent.com")
if err != nil || found {
t.Fatalf("remove unknown rule: found=%v err=%v", found, err)
}
changes := st.Changes()
if !changes.Persistent {
t.Fatal("expected persistent store")
}
if d := changes.Rules[CategoryBlock]; len(d.Add) != 0 || len(d.Remove) != 0 {
t.Fatalf("unexpected block delta after restore: %+v", d)
}
if d := changes.Rules[CategoryProxy]; len(d.Add) != 0 || len(d.Remove) != 0 {
t.Fatalf("unexpected proxy delta after remove: %+v", d)
}
// The file on disk reflects the final state.
onDisk := readStateFile(t, path)
if len(onDisk.Rules) != 0 {
t.Fatalf("expected empty deltas on disk, got %+v", onDisk.Rules)
}
if onDisk.Revision == 0 {
t.Fatal("expected revision to bump on mutations")
}
}
func TestStateStoreRestartRestoresDeltas(t *testing.T) {
t.Parallel()
path := stateFilePath(t)
st := LoadStateStore(path)
st.SetBaseline(testBaseline())
if _, err := st.RuleAdd(CategoryProxy, "**.example.com"); err != nil {
t.Fatal(err)
}
if _, err := st.RuleRemove(CategoryBlock, "ads.example.com"); err != nil {
t.Fatal(err)
}
// Simulate a restart: reload from disk, same baseline.
st2 := LoadStateStore(path)
st2.SetBaseline(testBaseline())
if d := st2.Delta(CategoryProxy); !slices.Equal(d.Add, []string{"**.example.com"}) {
t.Fatalf("restored proxy add: %+v", d)
}
if d := st2.Delta(CategoryBlock); !slices.Equal(d.Remove, []string{"ads.example.com"}) {
t.Fatalf("restored block remove: %+v", d)
}
}
func TestStateStoreGCCollectsStaleDeltas(t *testing.T) {
t.Parallel()
path := stateFilePath(t)
st := LoadStateStore(path)
st.SetBaseline(testBaseline())
if _, err := st.RuleAdd(CategoryProxy, "**.example.com"); err != nil {
t.Fatal(err)
}
if _, err := st.RuleRemove(CategoryBlock, "ads.example.com"); err != nil {
t.Fatal(err)
}
// New baseline: the admin-added rule entered the file, the tombstoned
// rule left it. Both deltas are now stale no-ops and must be dropped.
st2 := LoadStateStore(path)
st2.SetBaseline(map[Category][]string{
CategoryBlock: {"tracker.example.com"},
CategoryDirect: {"internal.example.com"},
CategoryProxy: {"**.google.com", "**.example.com"},
})
changes := st2.Changes()
if d := changes.Rules[CategoryProxy]; len(d.Add) != 0 {
t.Fatalf("stale add not collected: %+v", d)
}
if d := changes.Rules[CategoryBlock]; len(d.Remove) != 0 {
t.Fatalf("stale tombstone not collected: %+v", d)
}
onDisk := readStateFile(t, path)
if len(onDisk.Rules) != 0 {
t.Fatalf("GC result not persisted: %+v", onDisk.Rules)
}
}
func TestStateStorePersistFailureLeavesStateUnchanged(t *testing.T) {
t.Parallel()
// A state path under a regular file cannot be created, so every persist
// fails deterministically (even as root).
blocker := filepath.Join(t.TempDir(), "blocker")
if err := os.WriteFile(blocker, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
st := LoadStateStore(filepath.Join(blocker, "admin-state.json"))
st.SetBaseline(testBaseline())
add, err := st.RuleAdd(CategoryProxy, "**.example.com")
if err == nil {
t.Fatal("expected persist error")
}
if add != nil {
t.Fatalf("runtime add must be nil on persist failure, got %v", add)
}
if d := st.Delta(CategoryProxy); len(d.Add) != 0 {
t.Fatalf("state mutated despite persist failure: %+v", d)
}
if err := st.RuleReset(CategoryProxy); err != nil {
t.Fatalf("reset with no delta must be a no-op: %v", err)
}
}
func TestStateStoreCorruptFileIsQuarantined(t *testing.T) {
t.Parallel()
path := stateFilePath(t)
if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil {
t.Fatal(err)
}
st := LoadStateStore(path)
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("corrupt file should be moved aside, stat err=%v", err)
}
matches, err := filepath.Glob(path + ".corrupt-*")
if err != nil || len(matches) != 1 {
t.Fatalf("expected one quarantine file, got %v err=%v", matches, err)
}
// The store works from empty state.
if _, err := st.RuleAdd(CategoryProxy, "**.example.com"); err != nil {
t.Fatal(err)
}
if d := st.Delta(CategoryProxy); !slices.Equal(d.Add, []string{"**.example.com"}) {
t.Fatalf("unexpected delta after quarantine: %+v", d)
}
}
func TestStateStoreApplyConfigRevision(t *testing.T) {
t.Parallel()
path := stateFilePath(t)
st := LoadStateStore(path)
strPtr := func(s string) *string { return &s }
if _, err := st.ApplyConfig(ConfigOverrides{LogLevel: strPtr("debug")}, 1); !errors.Is(err, ErrRevisionMismatch) {
t.Fatalf("stale revision must fail: %v", err)
}
rev, err := st.ApplyConfig(ConfigOverrides{LogLevel: strPtr("debug")}, 0)
if err != nil || rev != 1 {
t.Fatalf("apply config: rev=%d err=%v", rev, err)
}
if got := st.ConfigOverrides(); got.LogLevel == nil || *got.LogLevel != "debug" {
t.Fatalf("unexpected overrides: %+v", got)
}
// Overrides survive a reload.
st2 := LoadStateStore(path)
if got := st2.ConfigOverrides(); got.LogLevel == nil || *got.LogLevel != "debug" {
t.Fatalf("overrides not persisted: %+v", got)
}
if st2.Revision() != 1 {
t.Fatalf("revision not persisted: %d", st2.Revision())
}
}
func TestStateStoreConcurrentMutations(t *testing.T) {
t.Parallel()
path := stateFilePath(t)
st := LoadStateStore(path)
st.SetBaseline(testBaseline())
var wg sync.WaitGroup
for i := range 50 {
wg.Add(2)
go func() {
defer wg.Done()
rule := "**.rule" + string(rune('a'+i%26)) + ".com"
if _, err := st.RuleAdd(CategoryProxy, rule); err != nil {
t.Error(err)
}
}()
go func() {
defer wg.Done()
_ = st.Changes()
_ = st.Revision()
}()
}
wg.Wait()
onDisk := readStateFile(t, path)
if onDisk.Revision != st.Revision() {
t.Fatalf("disk revision %d != memory revision %d", onDisk.Revision, st.Revision())
}
}
func TestStateStoreInMemoryWhenPathEmpty(t *testing.T) {
t.Parallel()
st := LoadStateStore("")
if st.Persistent() {
t.Fatal("empty path must not be persistent")
}
if _, err := st.RuleAdd(CategoryProxy, "**.example.com"); err != nil {
t.Fatal(err)
}
changes := st.Changes()
if changes.Persistent {
t.Fatal("in-memory store must report persistent=false")
}
if d := changes.Rules[CategoryProxy]; !slices.Equal(d.Add, []string{"**.example.com"}) {
t.Fatalf("unexpected delta: %+v", d)
}
}
func TestStateStoreRuleRemoveBatchIsAtomic(t *testing.T) {
t.Parallel()
st := LoadStateStore(stateFilePath(t))
st.SetBaseline(testBaseline())
if _, err := st.RuleAdd(CategoryProxy, "**.custom.example"); err != nil {
t.Fatal(err)
}
before := st.Revision()
removed, err := st.RuleRemoveBatch(CategoryProxy,
"**.google.com", "**.custom.example", "**.google.com", "**.unknown.example")
if err != nil {
t.Fatal(err)
}
if !slices.Equal(removed, []string{"**.google.com", "**.custom.example"}) {
t.Fatalf("unexpected runtime removals: %v", removed)
}
if st.Revision() != before+1 {
t.Fatalf("batch must bump revision once: before=%d after=%d", before, st.Revision())
}
changes := st.Changes().Rules[CategoryProxy]
if len(changes.Add) != 0 || !slices.Equal(changes.Remove, []string{"**.google.com"}) {
t.Fatalf("unexpected persisted batch delta: %+v", changes)
}
// Duplicates and unknown rules are a no-op and do not write again.
before = st.Revision()
removed, err = st.RuleRemoveBatch(CategoryProxy, "**.google.com", "**.unknown.example")
if err != nil || removed != nil {
t.Fatalf("expected no-op batch, removed=%v err=%v", removed, err)
}
if st.Revision() != before {
t.Fatalf("no-op batch changed revision: before=%d after=%d", before, st.Revision())
}
}
+567
View File
@@ -0,0 +1,567 @@
package admin
import (
"fmt"
"net"
rtmetrics "runtime/metrics"
"strings"
"sync"
"sync/atomic"
"time"
"unicode/utf8"
"github.com/sower-proxy/sower/internal/metrics"
)
const (
// maxTotalDomains bounds the cumulative domain map. Domains are never
// dropped for staleness (unlike the old window-only table); the map only
// evicts its least-recently-seen entry once full, so from-start totals
// survive while memory stays bounded.
maxTotalDomains = 100000
// maxTotalClients bounds the cumulative per-client table.
maxTotalClients = 4096
// maxClientsPerDomain guards one domain's per-client breakdown against a
// pathological number of source IPs; extra clients are not attributed.
maxClientsPerDomain = 64
// maxBlockedDomains bounds the per-domain block counter map. Blocked
// domains are typically few (bounded by the rule set), but a wildcard
// rule can match an unbounded domain space, so the map evicts its
// least-recently-seen entry once full.
maxBlockedDomains = 10000
staleDomainAge = time.Hour
snapshotTopN = 100
clientsTopN = 50
domainEvictionBatch = 1024
clientEvictionBatch = 64
// historyCapacity keeps one hour of samples at the 5s poll cadence.
historyCapacity = 720
// historyMinGap is the minimum time between two history samples; rapid
// duplicate polls do not advance the ring.
historyMinGap = time.Second
// historyMaxGap clamps the deltas of a sample taken after a long absence
// (e.g. the console was closed) to zero, so charts do not show a spike
// that aggregates an entire gap into one interval.
historyMaxGap = 30 * time.Second
// rateWindow is the sliding window used for per-second rates.
rateWindow = 60 * time.Second
// maxErrorEvents bounds the in-memory error event ring exposed to the
// console; the ring drops its oldest entry once full.
maxErrorEvents = 64
// maxErrorDetailLen truncates the per-event detail so a malformed host
// cannot bloat the ring or the SSE payload.
maxErrorDetailLen = 120
)
// DomainSort selects the ordering of the domain stats in a snapshot.
type DomainSort string
const (
DomainSortBytes DomainSort = "bytes" // total traffic, high to low
DomainSortRecent DomainSort = "recent" // most recently seen first
DomainSortConns DomainSort = "conns" // most connections first
)
func (s DomainSort) valid() bool {
switch s {
case DomainSortBytes, DomainSortRecent, DomainSortConns:
return true
default:
return false
}
}
// Source identifies the entry point a connection or DNS query came through.
type Source string
const (
SourceAll Source = "all"
SourceHTTP Source = "http"
SourceHTTPS Source = "https"
SourceSocks5 Source = "socks5"
SourceDNS Source = "dns"
)
func (s Source) valid() bool {
switch s {
case SourceAll, SourceHTTP, SourceHTTPS, SourceSocks5, SourceDNS:
return true
default:
return false
}
}
// DomainStat aggregates traffic for a single domain. Conns counts both proxy
// connections and DNS queries involving the domain. LastClientIP is the most
// recent client IP for the displayed view: the filtered client itself when a
// client filter is active, otherwise the latest client across all sources.
type DomainStat struct {
Domain string `json:"domain"`
Conns uint64 `json:"conns"`
BytesUp uint64 `json:"bytesUp"`
BytesDown uint64 `json:"bytesDown"`
LastSeen time.Time `json:"lastSeen"`
LastClientIP string `json:"lastClientIP"`
}
// TrafficSnapshot is an immutable point-in-time view of traffic stats.
type TrafficSnapshot struct {
// Uptime is seconds since the process started. It is an int64 on purpose:
// a time.Duration would serialize as nanoseconds and the frontend renders
// seconds.
Uptime int64 `json:"uptime"`
DNSQueries uint64 `json:"dnsQueries"`
Conns struct {
HTTP uint64 `json:"http"`
HTTPS uint64 `json:"https"`
Socks5 uint64 `json:"socks5"`
} `json:"conns"`
Active struct {
HTTP uint64 `json:"http"`
HTTPS uint64 `json:"https"`
Socks5 uint64 `json:"socks5"`
} `json:"active"`
Rates struct {
BytesUpPerSec float64 `json:"bytesUpPerSec"`
BytesDownPerSec float64 `json:"bytesDownPerSec"`
DNSPerSec float64 `json:"dnsPerSec"`
ConnsPerSec float64 `json:"connsPerSec"`
} `json:"rates"`
RuleHits struct {
Block uint64 `json:"block"`
Direct uint64 `json:"direct"`
Proxy uint64 `json:"proxy"`
} `json:"ruleHits"`
// Errors aggregates from-start proxy-side failures by kind. Blocked
// connections are deliberate routing decisions and are never counted
// here; only dial, DNS, and accept failures are.
Errors struct {
Dial uint64 `json:"dial"`
DNS uint64 `json:"dns"`
Accept uint64 `json:"accept"`
} `json:"errors"`
// Events is the recent error event ring, newest first, capped at
// maxErrorEvents entries. It carries the same kinds as Errors.
Events []ErrorEvent `json:"events"`
// Blocked is the from-start per-domain block counter, ordered by count.
// It is independent of the domain map: blocked connections never carry
// traffic, so they would otherwise be invisible to the traffic view.
Blocked []BlockedStat `json:"blocked"`
System struct {
Goroutines uint64 `json:"goroutines"`
HeapAlloc uint64 `json:"heapAlloc"`
} `json:"system"`
BytesUp uint64 `json:"bytesUp"`
BytesDown uint64 `json:"bytesDown"`
Domains []DomainStat `json:"domains"`
Clients []ClientStat `json:"clients"`
}
// BlockedStat aggregates block decisions for a single domain.
type BlockedStat struct {
Domain string `json:"domain"`
Count uint64 `json:"count"`
LastSeen time.Time `json:"lastSeen"`
}
// ClientStat aggregates traffic for a single client IP. Conns counts both
// proxy connections and DNS queries originating from the IP.
type ClientStat struct {
IP string `json:"ip"`
Hostname string `json:"hostname"`
Conns uint64 `json:"conns"`
BytesUp uint64 `json:"bytesUp"`
BytesDown uint64 `json:"bytesDown"`
LastSeen time.Time `json:"lastSeen"`
}
// HistorySample is one point in the in-process history ring. Byte, query,
// connection and rule-hit fields are deltas since the previous sample; the
// Active fields are instantaneous connection counts. History lives in process
// memory and is lost on restart.
type HistorySample struct {
At time.Time `json:"at"`
BytesUp uint64 `json:"bytesUp"`
BytesDown uint64 `json:"bytesDown"`
DNS uint64 `json:"dns"`
Conns uint64 `json:"conns"`
Active uint64 `json:"active"`
ActiveHTTP uint64 `json:"activeHttp"`
ActiveHTTPS uint64 `json:"activeHttps"`
ActiveSocks uint64 `json:"activeSocks"`
Block uint64 `json:"block"`
Direct uint64 `json:"direct"`
Proxy uint64 `json:"proxy"`
}
// History is the bounded in-process time series served by /api/history.
type History struct {
Samples []HistorySample `json:"samples"`
}
// ErrorEvent is one recorded proxy-side failure, exposed to the console so
// service-level problems (unreachable upstream, DNS breakdown, accept
// pressure) are visible without grepping the system log.
type ErrorEvent struct {
At time.Time `json:"at"`
Kind string `json:"kind"`
Detail string `json:"detail"`
}
// Stats records proxied payload bytes and request/connection counters. It is
// not packet-level network accounting. Byte direction is relative to the
// client: reads from the client are uploads, writes to the client downloads.
// Aggregate counters are mirrored to OpenTelemetry instruments
// (internal/metrics) for the standardized /metrics export; the domain map
// stays here because per-domain attribution is high-cardinality and not
// OTel-appropriate.
type Stats struct {
start time.Time
metrics *metrics.Metrics
dnsQueries atomic.Uint64
connsHTTP atomic.Uint64
connsHTTPS atomic.Uint64
connsSocks atomic.Uint64
activeHTTP atomic.Uint64
activeHTTPS atomic.Uint64
activeSocks atomic.Uint64
bytesUp atomic.Uint64
bytesDown atomic.Uint64
ruleBlock atomic.Uint64
ruleDirect atomic.Uint64
ruleProxy atomic.Uint64
dialFailed atomic.Uint64
dnsFailed atomic.Uint64
acceptFailed atomic.Uint64
errMu sync.Mutex
errEvents []ErrorEvent
mu sync.Mutex
domains map[string]*domainStat
// clients holds the from-start cumulative per-client totals. It is
// independent of the domain map so client totals survive domain eviction.
clients map[string]*clientStat
// blocked holds the from-start per-domain block counts, fed by the router
// observer. It is independent of the domain map: blocked connections
// never carry traffic, so they would otherwise be invisible.
blocked map[string]*blockedStat
histMu sync.Mutex
history []HistorySample
lastSample sampleCounters
}
// sampleCounters is the cumulative counter state at one sampling point.
type sampleCounters struct {
at time.Time
bytesUp uint64
bytesDown uint64
dns uint64
conns uint64
block uint64
direct uint64
proxy uint64
}
type domainStat struct {
conns uint64
bytesUp uint64
bytesDown uint64
lastSeen time.Time
bySource map[Source]*sourceStat
byClient map[string]*clientStat // client IP -> per-domain stats
}
// sourceStat is the per-source breakdown of a domain's traffic. A domain can
// be reached through several entry points (HTTP/HTTPS/SOCKS5 proxies and DNS
// queries), so each is tracked separately for the source-filtered views.
type sourceStat struct {
conns uint64
bytesUp uint64
bytesDown uint64
lastSeen time.Time
}
// clientStat is the per-client-IP breakdown of a domain's traffic.
type clientStat struct {
conns uint64
bytesUp uint64
bytesDown uint64
lastSeen time.Time
}
// blockedStat is the per-domain block counter.
type blockedStat struct {
count uint64
lastSeen time.Time
}
// NewStats creates the stats registry and its OpenTelemetry instruments.
func NewStats() (*Stats, error) {
m, err := metrics.New()
if err != nil {
return nil, fmt.Errorf("init metrics: %w", err)
}
return &Stats{
start: time.Now(),
metrics: m,
domains: make(map[string]*domainStat),
clients: make(map[string]*clientStat),
blocked: make(map[string]*blockedStat),
}, nil
}
// Metrics exposes the OpenTelemetry export surface (Prometheus /metrics).
func (s *Stats) Metrics() *metrics.Metrics { return s.metrics }
// RecordDNS counts a DNS query for the given qname from the given client IP.
func (s *Stats) RecordDNS(qname string, clientIP string) {
s.dnsQueries.Add(1)
s.metrics.RecordDNS()
domain := normalizeDomain(qname)
if domain == "" {
return
}
s.record(domain, SourceDNS, clientIP, 1, 0, 0, func(d *domainStat, src *sourceStat, dc *clientStat) {
d.conns++
src.conns++
if dc != nil {
dc.conns++
}
})
}
// recentErrorEvents returns the error event ring, newest first. The caller
// must not mutate the returned slice.
func (s *Stats) recentErrorEvents() []ErrorEvent {
s.errMu.Lock()
defer s.errMu.Unlock()
out := make([]ErrorEvent, len(s.errEvents))
for i, ev := range s.errEvents {
out[len(s.errEvents)-1-i] = ev
}
return out
}
// RecordProxyError counts one proxy-side failure of the given kind (dial,
// dns, or accept) and appends it to the console event ring. Blocked
// connections must not call this: a block is a routing decision, not a
// failure. detail is a short human-readable context (host, DNS server); it
// is truncated and must never carry query strings or credentials.
func (s *Stats) RecordProxyError(kind string, detail string) {
switch kind {
case "dial":
s.dialFailed.Add(1)
case "dns":
s.dnsFailed.Add(1)
case "accept":
s.acceptFailed.Add(1)
default:
return
}
s.metrics.RecordProxyError(kind)
ev := ErrorEvent{At: time.Now(), Kind: kind, Detail: cleanErrorDetail(detail)}
s.errMu.Lock()
s.errEvents = append(s.errEvents, ev)
if len(s.errEvents) > maxErrorEvents {
s.errEvents = s.errEvents[len(s.errEvents)-maxErrorEvents:]
}
s.errMu.Unlock()
}
// cleanErrorDetail bounds an error detail to the byte budget without
// splitting a UTF-8 rune (a host can be an IDN in Chinese), and collapses
// line breaks so a multiline error cannot break the console alert layout.
func cleanErrorDetail(detail string) string {
detail = strings.ReplaceAll(detail, "\n", " ")
if len(detail) > maxErrorDetailLen {
detail = detail[:maxErrorDetailLen]
for !utf8.ValidString(detail) {
detail = detail[:len(detail)-1]
}
}
return detail
}
// RecordRoute counts one connection routing decision. It is called exactly
// once per connection by the router observer. Block decisions additionally
// attribute the domain to the per-domain block counter; the domain may be
// empty for non-block routes or when it cannot be determined.
func (s *Stats) RecordRoute(route, domain string) {
switch route {
case "block":
s.ruleBlock.Add(1)
if domain != "" {
s.recordBlock(domain)
}
case "direct":
s.ruleDirect.Add(1)
case "proxy":
s.ruleProxy.Add(1)
}
s.metrics.RecordRoute(route)
}
// recordBlock attributes one block decision to a domain, evicting a batch
// of least-recently-seen entries when the map is full.
func (s *Stats) recordBlock(domain string) {
domain = normalizeDomain(domain)
if domain == "" {
return
}
s.mu.Lock()
defer s.mu.Unlock()
b := s.blocked[domain]
if b == nil {
if len(s.blocked) >= maxBlockedDomains {
s.evictOldestBlockedLocked(blockedEvictionBatch)
}
b = &blockedStat{}
s.blocked[domain] = b
}
b.count++
b.lastSeen = time.Now()
}
// WrapConn wraps the client connection before protocol parsing so header and
// handshake bytes are counted. The per-kind connection counter is bumped
// immediately; per-domain attribution happens later via BindConn.
func (s *Stats) WrapConn(conn net.Conn, kind string) net.Conn {
switch kind {
case "http":
s.connsHTTP.Add(1)
s.activeHTTP.Add(1)
case "https":
s.connsHTTPS.Add(1)
s.activeHTTPS.Add(1)
case "socks5":
s.connsSocks.Add(1)
s.activeSocks.Add(1)
}
s.metrics.ConnOpened(kind)
return &countingConn{
Conn: conn,
stats: s,
kind: kind,
clientIP: ClientIPOf(conn.RemoteAddr()),
created: time.Now(),
}
}
// BindConn attributes an already-wrapped connection to a domain and bumps the
// domain's connection counter. It is a no-op when conn was not created by
// WrapConn.
func (s *Stats) BindConn(conn net.Conn, domain string) {
cc, ok := conn.(*countingConn)
if !ok {
return
}
domain = normalizeDomain(domain)
if domain == "" {
return
}
cc.bind(domain)
source := Source(cc.kind)
s.record(domain, source, cc.clientIP, 1, 0, 0, func(d *domainStat, src *sourceStat, dc *clientStat) {
d.conns++
src.conns++
if dc != nil {
dc.conns++
}
})
}
// record attributes one event to a domain, its source, and optionally its
// client IP, with the given counter deltas. The client IP may be empty when it
// cannot be determined; the per-client stat is then nil and callers must
// guard. The deltas also feed the independent cumulative client table, so
// client totals are exact regardless of domain eviction.
func (s *Stats) record(domain string, source Source, clientIP string, conns, up, down uint64, update func(*domainStat, *sourceStat, *clientStat)) {
s.mu.Lock()
defer s.mu.Unlock()
d := s.domains[domain]
if d == nil {
if len(s.domains) >= maxTotalDomains {
s.evictOldestDomainsLocked(domainEvictionBatch)
}
d = &domainStat{bySource: make(map[Source]*sourceStat)}
s.domains[domain] = d
}
src := d.bySource[source]
if src == nil {
src = &sourceStat{}
d.bySource[source] = src
}
var dc *clientStat
if clientIP != "" {
if d.byClient == nil {
d.byClient = make(map[string]*clientStat)
}
dc = d.byClient[clientIP]
if dc == nil && len(d.byClient) < maxClientsPerDomain {
dc = &clientStat{}
d.byClient[clientIP] = dc
}
}
update(d, src, dc)
now := time.Now()
d.lastSeen = now
src.lastSeen = now
if dc != nil {
dc.lastSeen = now
}
if clientIP != "" {
cc := s.clients[clientIP]
if cc == nil {
if len(s.clients) >= maxTotalClients {
s.evictOldestClientsLocked(clientEvictionBatch)
}
cc = &clientStat{}
s.clients[clientIP] = cc
}
cc.conns += conns
cc.bytesUp += up
cc.bytesDown += down
cc.lastSeen = now
}
}
// normalizeDomain lowercases and strips the trailing dot of a domain or qname.
func normalizeDomain(domain string) string {
domain = strings.TrimSpace(domain)
domain = strings.TrimSuffix(domain, ".")
return strings.ToLower(domain)
}
// clientIPOf extracts the host part of a network address, falling back to the
// full address when it has no port.
func ClientIPOf(addr net.Addr) string {
if addr == nil {
return ""
}
if host, _, err := net.SplitHostPort(addr.String()); err == nil {
return host
}
return addr.String()
}
// goroutineCount reports the current number of goroutines.
func goroutineCount() uint64 {
var samples = []rtmetrics.Sample{{Name: "/sched/goroutines:goroutines"}}
rtmetrics.Read(samples)
return samples[0].Value.Uint64()
}
// heapAlloc reads the live heap size without stopping the world.
func heapAlloc() uint64 {
var samples = []rtmetrics.Sample{{Name: "/memory/classes/heap/objects:bytes"}}
rtmetrics.Read(samples)
return samples[0].Value.Uint64()
}
+130
View File
@@ -0,0 +1,130 @@
package admin
import (
"context"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
)
var (
benchmarkSnapshot TrafficSnapshot
benchmarkTotals Totals
)
func newBenchmarkStats(b *testing.B, domains int) *Stats {
b.Helper()
s, err := NewStats()
if err != nil {
b.Fatalf("new stats: %v", err)
}
b.Cleanup(func() {
ctx, cancel := context.WithTimeout(b.Context(), time.Second)
defer cancel()
if err := s.Metrics().Shutdown(ctx); err != nil {
b.Errorf("shutdown metrics: %v", err)
}
})
now := time.Now()
func() {
s.mu.Lock()
defer s.mu.Unlock()
for i := 0; i < domains; i++ {
domain := fmt.Sprintf("host%d.example", i)
s.domains[domain] = &domainStat{
conns: uint64(i + 1),
bytesUp: uint64(i + 1),
bytesDown: uint64(i + 1),
lastSeen: now,
bySource: map[Source]*sourceStat{
SourceHTTP: {conns: 1, bytesUp: 1, bytesDown: 1, lastSeen: now},
},
}
}
}()
return s
}
func BenchmarkStatsSnapshot(b *testing.B) {
for _, domains := range []int{1_000, 10_000, 100_000} {
b.Run(fmt.Sprintf("domains=%d", domains), func(b *testing.B) {
s := newBenchmarkStats(b, domains)
b.ReportAllocs()
b.ResetTimer()
for range b.N {
benchmarkSnapshot = s.Snapshot(DomainSortBytes, SourceAll, "")
}
})
}
}
func BenchmarkStatsTotals(b *testing.B) {
for _, domains := range []int{1_000, 10_000, 100_000} {
b.Run(fmt.Sprintf("domains=%d", domains), func(b *testing.B) {
s := newBenchmarkStats(b, domains)
b.ReportAllocs()
b.ResetTimer()
for range b.N {
benchmarkTotals = s.Totals()
}
})
}
}
func BenchmarkStatsRecordConcurrentWithSnapshots(b *testing.B) {
s := newBenchmarkStats(b, 10_000)
stop := make(chan struct{})
var snapshots sync.WaitGroup
snapshots.Add(1)
go func() {
defer snapshots.Done()
ticker := time.NewTicker(time.Millisecond)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
benchmarkSnapshot = s.Snapshot(DomainSortBytes, SourceAll, "")
}
}
}()
b.Cleanup(func() {
close(stop)
snapshots.Wait()
})
var next atomic.Uint64
b.ReportAllocs()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
i := next.Add(1) % 10_000
s.record(fmt.Sprintf("host%d.example", i), SourceHTTP, "", 0, 1, 1,
func(d *domainStat, src *sourceStat, dc *clientStat) {
d.bytesUp++
d.bytesDown++
src.bytesUp++
src.bytesDown++
})
}
})
}
func BenchmarkStatsInsertAtDomainCapacity(b *testing.B) {
s := newBenchmarkStats(b, maxTotalDomains)
var next atomic.Uint64
b.ReportAllocs()
b.ResetTimer()
for range b.N {
i := next.Add(1)
s.record(fmt.Sprintf("new%d.example", i), SourceHTTP, "", 1, 0, 0,
func(d *domainStat, src *sourceStat, dc *clientStat) {
d.conns++
src.conns++
})
}
}
+976
View File
@@ -0,0 +1,976 @@
package admin
import (
"bytes"
"fmt"
"net"
"slices"
"sort"
"strings"
"sync"
"testing"
"time"
"unicode/utf8"
)
type fakeConn struct {
net.Conn
readBuf []byte
writeBuf []byte
}
func (c *fakeConn) Read(p []byte) (int, error) {
n := copy(p, c.readBuf)
c.readBuf = c.readBuf[n:]
return n, nil
}
func (c *fakeConn) Write(p []byte) (int, error) {
c.writeBuf = append(c.writeBuf, p...)
return len(p), nil
}
func (c *fakeConn) Close() error { return nil }
func (c *fakeConn) RemoteAddr() net.Addr {
return &net.TCPAddr{IP: net.ParseIP("192.0.2.1")}
}
func TestStatsWrapConnCountsBytesBeforeBind(t *testing.T) {
s := newTestStats(t)
conn := &fakeConn{readBuf: []byte("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")}
wrapped := s.WrapConn(conn, "http")
// protocol parsing reads header bytes before the domain is known
header := make([]byte, 5)
if _, err := wrapped.Read(header); err != nil {
t.Fatalf("read: %v", err)
}
if _, err := wrapped.Write([]byte("HTTP/1.1 200 OK\r\n")); err != nil {
t.Fatalf("write: %v", err)
}
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if snap.Conns.HTTP != 1 {
t.Fatalf("expected 1 http conn, got %d", snap.Conns.HTTP)
}
if snap.BytesUp != 5 {
t.Fatalf("expected 5 bytes up, got %d", snap.BytesUp)
}
if snap.BytesDown != 17 {
t.Fatalf("expected 17 bytes down, got %d", snap.BytesDown)
}
if len(snap.Domains) != 0 {
t.Fatalf("expected no domain before bind, got %v", snap.Domains)
}
}
func TestStatsBindConnAttributesBytesToDomain(t *testing.T) {
s := newTestStats(t)
conn := &fakeConn{readBuf: []byte("request body")}
wrapped := s.WrapConn(conn, "https")
pre := make([]byte, 4) // header bytes read before the domain is known
if _, err := wrapped.Read(pre); err != nil {
t.Fatalf("read: %v", err)
}
s.BindConn(wrapped, "example.com.")
payload := make([]byte, 8)
if _, err := wrapped.Read(payload); err != nil {
t.Fatalf("read: %v", err)
}
if _, err := wrapped.Write([]byte("response")); err != nil {
t.Fatalf("write: %v", err)
}
// Byte attribution is batched per connection: the pending batches are
// drained on Close (or when a threshold/interval is crossed), so close
// before asserting the per-domain totals.
if err := wrapped.Close(); err != nil {
t.Fatalf("close: %v", err)
}
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if len(snap.Domains) != 1 {
t.Fatalf("expected 1 domain, got %v", snap.Domains)
}
d := snap.Domains[0]
if d.Domain != "example.com" {
t.Fatalf("unexpected domain %q", d.Domain)
}
if d.Conns != 1 {
t.Fatalf("expected 1 domain conn, got %d", d.Conns)
}
if d.BytesUp != 8 {
t.Fatalf("expected 8 domain bytes up, got %d", d.BytesUp)
}
if d.BytesDown != 8 {
t.Fatalf("expected 8 domain bytes down, got %d", d.BytesDown)
}
if snap.BytesUp != 12 {
t.Fatalf("expected 12 aggregate bytes up, got %d", snap.BytesUp)
}
}
func TestStatsBindConnIgnoresUnwrappedConn(t *testing.T) {
s := newTestStats(t)
s.BindConn(&fakeConn{}, "example.com")
if len(s.Snapshot(DomainSortBytes, SourceAll, "").Domains) != 0 {
t.Fatal("expected unwrapped conn to be ignored")
}
}
// TestCountingConnBatchWindow: bytes within the flush window stay pending;
// Close drains them so attribution is exact in the end.
func TestCountingConnBatchWindow(t *testing.T) {
s := newTestStats(t)
wrapped := s.WrapConn(&fakeConn{readBuf: bytes.Repeat([]byte("x"), 64)}, "http")
s.BindConn(wrapped, "example.com")
buf := make([]byte, 1)
// First I/O flushes immediately (nextFlush starts zero): attributed now.
if _, err := wrapped.Read(buf); err != nil {
t.Fatalf("read: %v", err)
}
// Second I/O falls inside the flush window: kept pending.
if _, err := wrapped.Read(buf); err != nil {
t.Fatalf("read: %v", err)
}
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if len(snap.Domains) != 1 || snap.Domains[0].BytesUp != 1 {
t.Fatalf("expected 1 pending byte unflushed, got %+v", snap.Domains)
}
if err := wrapped.Close(); err != nil {
t.Fatalf("close: %v", err)
}
snap = s.Snapshot(DomainSortBytes, SourceAll, "")
if len(snap.Domains) != 1 || snap.Domains[0].BytesUp != 2 {
t.Fatalf("expected both bytes after close, got %+v", snap.Domains)
}
}
// TestCountingConnBatchThreshold: sustained traffic flushes at the byte
// threshold without waiting for Close.
func TestCountingConnBatchThreshold(t *testing.T) {
s := newTestStats(t)
wrapped := s.WrapConn(&fakeConn{readBuf: bytes.Repeat([]byte("x"), 9*flushBytes)}, "https")
s.BindConn(wrapped, "example.com")
chunk := make([]byte, 4096)
total := 0
for total < 9*4096 {
n, err := wrapped.Read(chunk)
if err != nil {
t.Fatalf("read: %v", err)
}
total += n
}
if total != 9*4096 {
t.Fatalf("read %d bytes, want %d", total, 9*4096)
}
// 9×4KiB crosses the 32KiB threshold (first chunk flushes immediately,
// the next eight accumulate to exactly the threshold), so the domain
// total is visible without Close.
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if len(snap.Domains) != 1 || snap.Domains[0].BytesUp != uint64(total) {
t.Fatalf("expected threshold flush of %d bytes, got %+v", total, snap.Domains)
}
}
// TestCountingConnBatchConcurrent exercises the relay layout: Read and Write
// run on different goroutines, each direction's pending batch is owned by
// its goroutine, and Close drains both directions under concurrent I/O.
func TestCountingConnBatchConcurrent(t *testing.T) {
s := newTestStats(t)
wrapped := s.WrapConn(&fakeConn{readBuf: bytes.Repeat([]byte("y"), 64<<10)}, "socks5")
s.BindConn(wrapped, "example.com")
const iters = 8
readChunk, writeChunk := make([]byte, 4096), make([]byte, 4096)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
for range iters {
if _, err := wrapped.Read(readChunk); err != nil {
t.Errorf("read: %v", err)
return
}
}
}()
go func() {
defer wg.Done()
for range iters {
if _, err := wrapped.Write(writeChunk); err != nil {
t.Errorf("write: %v", err)
return
}
}
}()
wg.Wait()
if err := wrapped.Close(); err != nil {
t.Fatalf("close: %v", err)
}
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if len(snap.Domains) != 1 {
t.Fatalf("expected 1 domain, got %v", snap.Domains)
}
d := snap.Domains[0]
want := uint64(iters * len(readChunk))
if d.BytesUp != want || d.BytesDown != want {
t.Fatalf("expected %d up/%d down after concurrent relay, got %d/%d",
want, want, d.BytesUp, d.BytesDown)
}
}
// TestCountingConnCloseConcurrent races Close against active relay I/O: the
// closed latch must force a final drain of bytes that land after Close's
// own swap, so no byte is left permanently pending.
func TestCountingConnCloseConcurrent(t *testing.T) {
s := newTestStats(t)
wrapped := s.WrapConn(&fakeConn{readBuf: bytes.Repeat([]byte("z"), 1<<20)}, "http")
s.BindConn(wrapped, "example.com")
const iters = 500
readChunk, writeChunk := make([]byte, 4096), make([]byte, 4096)
var wg sync.WaitGroup
wg.Add(3)
go func() {
defer wg.Done()
for i := 0; i < iters; i++ {
if _, err := wrapped.Read(readChunk); err != nil {
t.Errorf("read: %v", err)
return
}
}
}()
go func() {
defer wg.Done()
for i := 0; i < iters; i++ {
if _, err := wrapped.Write(writeChunk); err != nil {
t.Errorf("write: %v", err)
return
}
}
}()
go func() {
defer wg.Done()
if err := wrapped.Close(); err != nil {
t.Errorf("close: %v", err)
}
}()
wg.Wait()
// After Close, any byte that raced in must still be drained by the
// closed-latch flush; totals therefore equal the writes that reported
// success, regardless of interleaving.
want := uint64(iters * len(readChunk))
if got := s.bytesDown.Load(); got != want {
t.Fatalf("expected %d total bytes down, got %d", want, got)
}
}
func TestStatsRecordDNS(t *testing.T) {
s := newTestStats(t)
s.RecordDNS("Example.COM.", "")
s.RecordDNS("example.com.", "")
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if snap.DNSQueries != 2 {
t.Fatalf("expected 2 dns queries, got %d", snap.DNSQueries)
}
if len(snap.Domains) != 1 {
t.Fatalf("expected dns domains to be deduplicated, got %v", snap.Domains)
}
if snap.Domains[0].Conns != 2 {
t.Fatalf("expected 2 dns conns, got %d", snap.Domains[0].Conns)
}
}
func TestStatsSnapshotEvictsStaleDomains(t *testing.T) {
s := newTestStats(t)
s.mu.Lock()
s.domains["stale.example"] = &domainStat{lastSeen: time.Now().Add(-2 * staleDomainAge)}
s.domains["fresh.example"] = &domainStat{lastSeen: time.Now()}
s.mu.Unlock()
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if len(snap.Domains) != 1 || snap.Domains[0].Domain != "fresh.example" {
t.Fatalf("expected stale domain evicted, got %v", snap.Domains)
}
}
func TestStatsDomainCapIsEnforced(t *testing.T) {
s := newTestStats(t)
now := time.Now()
s.mu.Lock()
for i := 0; i < maxTotalDomains; i++ {
s.domains[fmt.Sprintf("host%d.example", i)] = &domainStat{lastSeen: now.Add(time.Duration(i) * time.Nanosecond)}
}
s.mu.Unlock()
s.record("fresh.example", SourceHTTP, "", 1, 0, 0, func(d *domainStat, src *sourceStat, dc *clientStat) {})
s.mu.Lock()
n := len(s.domains)
_, oldestPresent := s.domains["host0.example"]
_, newestPresent := s.domains[fmt.Sprintf("host%d.example", maxTotalDomains-1)]
_, freshPresent := s.domains["fresh.example"]
s.mu.Unlock()
want := maxTotalDomains - domainEvictionBatch + 1
if n != want {
t.Fatalf("expected %d domains after batch eviction, got %d", want, n)
}
if oldestPresent || !newestPresent || !freshPresent {
t.Fatalf("unexpected retained domains: oldest=%v newest=%v fresh=%v", oldestPresent, newestPresent, freshPresent)
}
}
func TestEvictOldestDomainsLocked(t *testing.T) {
s := newTestStats(t)
now := time.Now()
s.mu.Lock()
for i := 0; i < 4; i++ {
s.domains[fmt.Sprintf("host%d.example", i)] = &domainStat{lastSeen: now.Add(time.Duration(i) * time.Second)}
}
s.evictOldestDomainsLocked(2)
_, firstPresent := s.domains["host0.example"]
_, secondPresent := s.domains["host1.example"]
_, thirdPresent := s.domains["host2.example"]
_, fourthPresent := s.domains["host3.example"]
s.mu.Unlock()
if firstPresent || secondPresent || !thirdPresent || !fourthPresent {
t.Fatalf("unexpected eviction: first=%v second=%v third=%v fourth=%v", firstPresent, secondPresent, thirdPresent, fourthPresent)
}
}
func TestStatsSnapshotSortsByTotalBytes(t *testing.T) {
s := newTestStats(t)
s.record("small.example", SourceHTTP, "", 0, 0, 0, func(d *domainStat, src *sourceStat, dc *clientStat) {
d.bytesUp, d.bytesDown = 1, 1
src.bytesUp, src.bytesDown = 1, 1
})
s.record("big.example", SourceHTTP, "", 0, 0, 0, func(d *domainStat, src *sourceStat, dc *clientStat) {
d.bytesUp, d.bytesDown = 100, 100
src.bytesUp, src.bytesDown = 100, 100
})
s.record("mid.example", SourceHTTP, "", 0, 0, 0, func(d *domainStat, src *sourceStat, dc *clientStat) {
d.bytesUp, d.bytesDown = 10, 10
src.bytesUp, src.bytesDown = 10, 10
})
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if len(snap.Domains) != 3 {
t.Fatalf("unexpected domains: %v", snap.Domains)
}
if snap.Domains[0].Domain != "big.example" || snap.Domains[2].Domain != "small.example" {
t.Fatalf("unexpected sort order: %v", snap.Domains)
}
}
func newTestStats(t *testing.T) *Stats {
t.Helper()
s, err := NewStats()
if err != nil {
t.Fatalf("new stats: %v", err)
}
return s
}
func TestStatsUptimeIsSeconds(t *testing.T) {
s := newTestStats(t)
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
// A fresh stats object must report a small number of seconds, not
// nanoseconds (a time.Duration would serialize as ~1e9 per second).
if snap.Uptime < 0 || snap.Uptime > 60 {
t.Fatalf("expected uptime in seconds, got %d", snap.Uptime)
}
}
func TestStatsActiveConnsTrackedAndIdempotentClose(t *testing.T) {
s := newTestStats(t)
wrapped := s.WrapConn(&fakeConn{}, "http")
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if snap.Active.HTTP != 1 || snap.Conns.HTTP != 1 {
t.Fatalf("expected 1 active/1 total http conn, got %+v / %+v", snap.Active, snap.Conns)
}
if err := wrapped.Close(); err != nil {
t.Fatalf("close: %v", err)
}
if err := wrapped.Close(); err != nil { // idempotent
t.Fatalf("second close: %v", err)
}
snap = s.Snapshot(DomainSortBytes, SourceAll, "")
if snap.Active.HTTP != 0 {
t.Fatalf("expected 0 active after close, got %d", snap.Active.HTTP)
}
if snap.Conns.HTTP != 1 {
t.Fatalf("expected cumulative count to stay 1, got %d", snap.Conns.HTTP)
}
}
func TestStatsActiveConnsPerProtocol(t *testing.T) {
s := newTestStats(t)
http := s.WrapConn(&fakeConn{}, "http")
https := s.WrapConn(&fakeConn{}, "https")
socks := s.WrapConn(&fakeConn{}, "socks5")
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if snap.Active.HTTP != 1 || snap.Active.HTTPS != 1 || snap.Active.Socks5 != 1 {
t.Fatalf("unexpected active conns: %+v", snap.Active)
}
_ = http.Close()
_ = https.Close()
_ = socks.Close()
snap = s.Snapshot(DomainSortBytes, SourceAll, "")
if snap.Active.HTTP != 0 || snap.Active.HTTPS != 0 || snap.Active.Socks5 != 0 {
t.Fatalf("expected all active conns released: %+v", snap.Active)
}
}
func TestStatsRecordRoute(t *testing.T) {
s := newTestStats(t)
s.RecordRoute("block", "ads.example.com")
s.RecordRoute("block", "ads.example.com")
s.RecordRoute("block", "tracker.example.net")
s.RecordRoute("direct", "")
s.RecordRoute("proxy", "")
s.RecordRoute("proxy", "")
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if snap.RuleHits.Block != 3 || snap.RuleHits.Direct != 1 || snap.RuleHits.Proxy != 2 {
t.Fatalf("unexpected rule hits: %+v", snap.RuleHits)
}
if len(snap.Blocked) != 2 {
t.Fatalf("expected 2 blocked domains, got %d", len(snap.Blocked))
}
if snap.Blocked[0].Domain != "ads.example.com" || snap.Blocked[0].Count != 2 {
t.Fatalf("unexpected blocked ranking: %+v", snap.Blocked)
}
if snap.Blocked[1].Domain != "tracker.example.net" || snap.Blocked[1].Count != 1 {
t.Fatalf("unexpected blocked ranking: %+v", snap.Blocked)
}
}
func TestStatsRecordProxyErrorCountsAndRings(t *testing.T) {
s := newTestStats(t)
s.RecordProxyError("dial", "example.com: connection refused")
s.RecordProxyError("dial", "example.org: timeout")
s.RecordProxyError("dns", "fallback dial 1.1.1.1: timeout")
s.RecordProxyError("accept", "http listener: accept failed")
// Unknown kinds are ignored, not counted.
s.RecordProxyError("bogus", "ignored")
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if snap.Errors.Dial != 2 || snap.Errors.DNS != 1 || snap.Errors.Accept != 1 {
t.Fatalf("unexpected error counts: %+v", snap.Errors)
}
if len(snap.Events) != 4 {
t.Fatalf("expected 4 events, got %d", len(snap.Events))
}
// Ring is newest first.
if snap.Events[0].Kind != "accept" || snap.Events[3].Kind != "dial" {
t.Fatalf("unexpected event order: %+v", snap.Events)
}
if snap.Events[0].Detail != "http listener: accept failed" {
t.Fatalf("unexpected event detail: %q", snap.Events[0].Detail)
}
}
func TestStatsErrorEventRingIsBounded(t *testing.T) {
s := newTestStats(t)
for i := 0; i < maxErrorEvents+20; i++ {
s.RecordProxyError("dial", fmt.Sprintf("host-%d: refused", i))
}
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if len(snap.Events) != maxErrorEvents {
t.Fatalf("expected ring capped at %d, got %d", maxErrorEvents, len(snap.Events))
}
// The oldest events are dropped: 84 recorded, 64 retained, host-0..host-19 evicted.
if snap.Events[len(snap.Events)-1].Detail != "host-20: refused" {
t.Fatalf("unexpected oldest retained event: %+v", snap.Events[len(snap.Events)-1])
}
if snap.Errors.Dial != maxErrorEvents+20 {
t.Fatalf("counter must stay cumulative, got %d", snap.Errors.Dial)
}
}
func TestStatsErrorEventDetailTruncated(t *testing.T) {
s := newTestStats(t)
s.RecordProxyError("dial", strings.Repeat("x", maxErrorDetailLen*2))
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if len(snap.Events) != 1 || len(snap.Events[0].Detail) != maxErrorDetailLen {
t.Fatalf("detail not truncated to %d: %d", maxErrorDetailLen, len(snap.Events[0].Detail))
}
}
func TestStatsErrorEventDetailTruncationKeepsValidUTF8(t *testing.T) {
s := newTestStats(t)
// A Chinese host name is 3 bytes per rune; a byte-sliced truncation would
// split a rune and emit invalid UTF-8 into the JSON payload.
s.RecordProxyError("dial", strings.Repeat("中", 50))
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if len(snap.Events) != 1 {
t.Fatalf("expected 1 event, got %d", len(snap.Events))
}
d := snap.Events[0].Detail
if !utf8.ValidString(d) {
t.Fatalf("truncated detail must stay valid UTF-8: %q", d)
}
if len(d) > maxErrorDetailLen {
t.Fatalf("detail exceeds byte budget: %d > %d", len(d), maxErrorDetailLen)
}
}
func TestStatsErrorEventDetailCollapsesNewlines(t *testing.T) {
s := newTestStats(t)
s.RecordProxyError("dial", "first line\nsecond line\n")
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if len(snap.Events) != 1 {
t.Fatalf("expected 1 event, got %d", len(snap.Events))
}
if d := snap.Events[0].Detail; strings.ContainsAny(d, "\r\n") {
t.Fatalf("detail must not contain line breaks: %q", d)
}
}
func TestStatsBlockedCapIsEnforced(t *testing.T) {
s := newTestStats(t)
now := time.Now()
s.mu.Lock()
for i := 0; i < maxBlockedDomains; i++ {
s.blocked[fmt.Sprintf("blocked-%d.example", i)] = &blockedStat{lastSeen: now.Add(time.Duration(i) * time.Nanosecond)}
}
s.mu.Unlock()
s.RecordRoute("block", "fresh.example")
s.mu.Lock()
n := len(s.blocked)
_, oldestPresent := s.blocked["blocked-0.example"]
_, newestPresent := s.blocked[fmt.Sprintf("blocked-%d.example", maxBlockedDomains-1)]
_, freshPresent := s.blocked["fresh.example"]
s.mu.Unlock()
want := maxBlockedDomains - blockedEvictionBatch + 1
if n != want {
t.Fatalf("expected %d blocked domains after batch eviction, got %d", want, n)
}
if oldestPresent || !newestPresent || !freshPresent {
t.Fatalf("unexpected retained blocked domains: oldest=%v newest=%v fresh=%v", oldestPresent, newestPresent, freshPresent)
}
}
func TestStatsRatesFromHistory(t *testing.T) {
s := newTestStats(t)
now := time.Now()
s.histMu.Lock()
s.lastSample = sampleCounters{at: now.Add(-10 * time.Second)}
s.history = []HistorySample{
{At: now.Add(-10 * time.Second), BytesUp: 1000, BytesDown: 2000, DNS: 10, Conns: 5},
{At: now.Add(-5 * time.Second), BytesUp: 2000, BytesDown: 4000, DNS: 20, Conns: 10},
}
s.histMu.Unlock()
up, down, dns, conns := s.rates()
if up < 290 || up > 310 {
t.Fatalf("expected ~300 bytes/s up, got %v", up)
}
if down < 590 || down > 610 {
t.Fatalf("expected ~600 bytes/s down, got %v", down)
}
if dns < 2.9 || dns > 3.1 {
t.Fatalf("expected ~3 dns/s, got %v", dns)
}
if conns < 1.4 || conns > 1.6 {
t.Fatalf("expected ~1.5 conns/s, got %v", conns)
}
}
func TestStatsHistoryRingBounded(t *testing.T) {
s := newTestStats(t)
s.histMu.Lock()
s.lastSample = sampleCounters{at: time.Now().Add(-2 * time.Second)}
s.history = make([]HistorySample, historyCapacity)
for i := range s.history {
s.history[i] = HistorySample{At: time.Now().Add(-time.Duration(historyCapacity-i) * time.Second)}
}
s.histMu.Unlock()
s.sample() // gap 2s > minGap: appends and trims to capacity
if got := len(s.History().Samples); got != historyCapacity {
t.Fatalf("expected ring capped at %d, got %d", historyCapacity, got)
}
}
func TestStatsSampleClampsLongGap(t *testing.T) {
s := newTestStats(t)
s.RecordDNS("example.com", "")
s.RecordRoute("proxy", "")
s.histMu.Lock()
s.lastSample = sampleCounters{at: time.Now().Add(-10 * time.Minute)}
s.histMu.Unlock()
s.sample()
h := s.History().Samples
if len(h) != 1 {
t.Fatalf("expected 1 sample, got %d", len(h))
}
if h[0].DNS != 0 || h[0].Proxy != 0 {
t.Fatalf("expected clamped zero deltas, got %+v", h[0])
}
}
func TestStatsSnapshotDomainSortModes(t *testing.T) {
s := newTestStats(t)
now := time.Now()
s.mu.Lock()
s.domains["big.example"] = &domainStat{conns: 10, bytesUp: 1000, bytesDown: 0, lastSeen: now.Add(-time.Minute)}
s.domains["hot.example"] = &domainStat{conns: 2, bytesUp: 100, bytesDown: 100, lastSeen: now}
s.domains["busy.example"] = &domainStat{conns: 20, bytesUp: 10, bytesDown: 10, lastSeen: now.Add(-2 * time.Minute)}
s.mu.Unlock()
byBytes := s.Snapshot(DomainSortBytes, SourceAll, "")
if byBytes.Domains[0].Domain != "big.example" {
t.Fatalf("expected big.example first by bytes, got %q", byBytes.Domains[0].Domain)
}
byRecent := s.Snapshot(DomainSortRecent, SourceAll, "")
if byRecent.Domains[0].Domain != "hot.example" {
t.Fatalf("expected hot.example first by recency, got %q", byRecent.Domains[0].Domain)
}
byConns := s.Snapshot(DomainSortConns, SourceAll, "")
if byConns.Domains[0].Domain != "busy.example" {
t.Fatalf("expected busy.example first by conns, got %q", byConns.Domains[0].Domain)
}
fallback := s.Snapshot(DomainSort("bogus"), SourceAll, "")
if fallback.Domains[0].Domain != "big.example" {
t.Fatalf("expected invalid sort to fall back to bytes, got %q", fallback.Domains[0].Domain)
}
}
func TestStatsSnapshotSourceFilter(t *testing.T) {
s := newTestStats(t)
now := time.Now()
s.mu.Lock()
s.domains["proxied.example"] = &domainStat{
conns: 3, bytesUp: 300, bytesDown: 300, lastSeen: now,
bySource: map[Source]*sourceStat{
SourceHTTP: {conns: 2, bytesUp: 200, bytesDown: 200, lastSeen: now},
SourceHTTPS: {conns: 1, bytesUp: 100, bytesDown: 100, lastSeen: now},
},
}
s.domains["queried.example"] = &domainStat{
conns: 5, lastSeen: now,
bySource: map[Source]*sourceStat{
SourceDNS: {conns: 5, lastSeen: now},
},
}
s.mu.Unlock()
all := s.Snapshot(DomainSortBytes, SourceAll, "")
if len(all.Domains) != 2 {
t.Fatalf("expected 2 domains in all view, got %d", len(all.Domains))
}
dns := s.Snapshot(DomainSortBytes, SourceDNS, "")
if len(dns.Domains) != 1 || dns.Domains[0].Domain != "queried.example" || dns.Domains[0].Conns != 5 {
t.Fatalf("unexpected dns view: %+v", dns.Domains)
}
if dns.Domains[0].BytesUp != 0 || dns.Domains[0].BytesDown != 0 {
t.Fatalf("dns view should carry no bytes, got %+v", dns.Domains[0])
}
httpView := s.Snapshot(DomainSortBytes, SourceHTTP, "")
if len(httpView.Domains) != 1 || httpView.Domains[0].Domain != "proxied.example" || httpView.Domains[0].Conns != 2 {
t.Fatalf("unexpected http view: %+v", httpView.Domains)
}
socks := s.Snapshot(DomainSortBytes, SourceSocks5, "")
if len(socks.Domains) != 0 {
t.Fatalf("expected no socks5 domains, got %+v", socks.Domains)
}
}
func TestRecordDNSAttributedToDNSSource(t *testing.T) {
s := newTestStats(t)
s.RecordDNS("example.com.", "")
dns := s.Snapshot(DomainSortBytes, SourceDNS, "")
if len(dns.Domains) != 1 || dns.Domains[0].Domain != "example.com" || dns.Domains[0].Conns != 1 {
t.Fatalf("unexpected dns view: %+v", dns.Domains)
}
httpView := s.Snapshot(DomainSortBytes, SourceHTTP, "")
if len(httpView.Domains) != 0 {
t.Fatalf("dns query must not appear in http view: %+v", httpView.Domains)
}
}
func TestStatsSnapshotClientFilter(t *testing.T) {
s := newTestStats(t)
now := time.Now()
s.mu.Lock()
s.domains["a.example"] = &domainStat{
conns: 4, bytesUp: 400, bytesDown: 400, lastSeen: now,
byClient: map[string]*clientStat{
"192.168.1.10": {conns: 3, bytesUp: 300, bytesDown: 300, lastSeen: now},
"192.168.1.20": {conns: 1, bytesUp: 100, bytesDown: 100, lastSeen: now},
},
}
s.domains["b.example"] = &domainStat{
conns: 2, bytesUp: 200, bytesDown: 200, lastSeen: now,
byClient: map[string]*clientStat{
"192.168.1.10": {conns: 2, bytesUp: 200, bytesDown: 200, lastSeen: now},
},
}
s.domains["c.example"] = &domainStat{
conns: 7, bytesUp: 700, bytesDown: 700, lastSeen: now,
byClient: map[string]*clientStat{
"192.168.1.20": {conns: 7, bytesUp: 700, bytesDown: 700, lastSeen: now},
},
}
s.mu.Unlock()
// filtered by client: only that client's domains, with that client's values
view := s.Snapshot(DomainSortBytes, SourceAll, "192.168.1.10")
if len(view.Domains) != 2 {
t.Fatalf("expected 2 domains for client, got %d", len(view.Domains))
}
if view.Domains[0].Domain != "a.example" || view.Domains[0].Conns != 3 {
t.Fatalf("a.example should show client 10's conns (3), got %+v", view.Domains[0])
}
if view.Domains[1].Domain != "b.example" || view.Domains[1].Conns != 2 {
t.Fatalf("b.example should show client 10's conns (2), got %+v", view.Domains[1])
}
// unknown client yields an empty list
empty := s.Snapshot(DomainSortBytes, SourceAll, "203.0.113.9")
if len(empty.Domains) != 0 {
t.Fatalf("expected no domains for unknown client, got %+v", empty.Domains)
}
// the client chips are a navigation device: they must stay stable and
// globally counted regardless of the active client filter, including
// clients that share no domain with the selected one
if len(view.Clients) != 2 {
t.Fatalf("expected both clients in chips under filter, got %+v", view.Clients)
}
if view.Clients[0].IP != "192.168.1.20" || view.Clients[0].Conns != 8 {
t.Fatalf("client .20 must keep its global conns (8) under filter, got %+v", view.Clients[0])
}
if len(empty.Clients) != 2 {
t.Fatalf("chips must survive an unknown-client filter, got %+v", empty.Clients)
}
}
func TestStatsSnapshotLastClientIP(t *testing.T) {
s := newTestStats(t)
now := time.Now()
s.mu.Lock()
s.domains["a.example"] = &domainStat{
lastSeen: now,
byClient: map[string]*clientStat{
"192.168.1.10": {conns: 1, bytesUp: 100, bytesDown: 100, lastSeen: now.Add(-2 * time.Minute)},
"192.168.1.20": {conns: 1, bytesUp: 100, bytesDown: 100, lastSeen: now.Add(-1 * time.Minute)},
},
}
s.mu.Unlock()
// unfiltered: the client with the most recent activity wins
view := s.Snapshot(DomainSortBytes, SourceAll, "")
if len(view.Domains) != 1 || view.Domains[0].LastClientIP != "192.168.1.20" {
t.Fatalf("expected .20 as last client, got %+v", view.Domains)
}
// client-filtered: the filter itself is the last client of the view
filtered := s.Snapshot(DomainSortBytes, SourceAll, "192.168.1.10")
if len(filtered.Domains) != 1 || filtered.Domains[0].LastClientIP != "192.168.1.10" {
t.Fatalf("expected filter client as last client, got %+v", filtered.Domains)
}
}
func TestStatsSnapshotClientsAggregate(t *testing.T) {
s := newTestStats(t)
now := time.Now()
s.mu.Lock()
s.domains["a.example"] = &domainStat{
lastSeen: now,
byClient: map[string]*clientStat{
"192.168.1.10": {conns: 3, bytesUp: 300, bytesDown: 300, lastSeen: now},
},
}
s.domains["b.example"] = &domainStat{
lastSeen: now,
byClient: map[string]*clientStat{
"192.168.1.10": {conns: 2, bytesUp: 200, bytesDown: 200, lastSeen: now},
"192.168.1.20": {conns: 5, bytesUp: 50, bytesDown: 50, lastSeen: now},
},
}
s.mu.Unlock()
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if len(snap.Clients) != 2 {
t.Fatalf("expected 2 clients, got %+v", snap.Clients)
}
if snap.Clients[0].IP != "192.168.1.10" || snap.Clients[0].Conns != 5 || snap.Clients[0].BytesUp != 500 {
t.Fatalf("unexpected top client: %+v", snap.Clients[0])
}
if snap.Clients[1].IP != "192.168.1.20" {
t.Fatalf("unexpected second client: %+v", snap.Clients[1])
}
}
func TestStatsTotalsSurviveStaleness(t *testing.T) {
s := newTestStats(t)
old := time.Now().Add(-2 * staleDomainAge)
s.mu.Lock()
s.domains["old.example"] = &domainStat{
conns: 5, bytesUp: 500, bytesDown: 500, lastSeen: old,
bySource: map[Source]*sourceStat{
SourceHTTP: {conns: 5, bytesUp: 500, bytesDown: 500, lastSeen: old},
},
}
s.mu.Unlock()
// the window view hides the stale domain, the totals keep it
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if len(snap.Domains) != 0 {
t.Fatalf("expected stale domain hidden from window, got %+v", snap.Domains)
}
tot := s.Totals()
if len(tot.Domains) != 1 || tot.Domains[0].Domain != "old.example" || tot.Domains[0].Conns != 5 {
t.Fatalf("expected stale domain kept in totals, got %+v", tot.Domains)
}
}
func TestStatsTotalsClientsSurviveDomainEviction(t *testing.T) {
s := newTestStats(t)
s.record("evicted.example", SourceHTTP, "192.168.1.10", 2, 100, 200, func(d *domainStat, src *sourceStat, dc *clientStat) {
d.conns += 2
src.conns += 2
})
s.mu.Lock()
delete(s.domains, "evicted.example") // simulate cap eviction
s.mu.Unlock()
tot := s.Totals()
if len(tot.Domains) != 0 {
t.Fatalf("expected no domains after eviction, got %+v", tot.Domains)
}
if len(tot.Clients) != 1 || tot.Clients[0].IP != "192.168.1.10" ||
tot.Clients[0].Conns != 2 || tot.Clients[0].BytesUp != 100 || tot.Clients[0].BytesDown != 200 {
t.Fatalf("expected client totals to survive domain eviction, got %+v", tot.Clients)
}
}
func TestStatsClientCapIsEnforced(t *testing.T) {
s := newTestStats(t)
now := time.Now()
s.mu.Lock()
for i := 0; i < maxTotalClients; i++ {
s.clients[fmt.Sprintf("192.168.%d.%d", i/256, i%256)] = &clientStat{lastSeen: now.Add(time.Duration(i) * time.Nanosecond)}
}
s.domains["existing.example"] = &domainStat{bySource: make(map[Source]*sourceStat), lastSeen: now}
s.mu.Unlock()
s.record("existing.example", SourceHTTP, "203.0.113.1", 1, 0, 0, func(d *domainStat, src *sourceStat, dc *clientStat) {})
s.mu.Lock()
n := len(s.clients)
_, oldestPresent := s.clients["192.168.0.0"]
_, newestPresent := s.clients[fmt.Sprintf("192.168.%d.%d", (maxTotalClients-1)/256, (maxTotalClients-1)%256)]
_, freshPresent := s.clients["203.0.113.1"]
s.mu.Unlock()
want := maxTotalClients - clientEvictionBatch + 1
if n != want {
t.Fatalf("expected %d clients after batch eviction, got %d", want, n)
}
if oldestPresent || !newestPresent || !freshPresent {
t.Fatalf("unexpected retained clients: oldest=%v newest=%v fresh=%v", oldestPresent, newestPresent, freshPresent)
}
}
func TestStatsClientsPerDomainCap(t *testing.T) {
s := newTestStats(t)
for i := 0; i < maxClientsPerDomain+10; i++ {
s.record("shared.example", SourceHTTP, fmt.Sprintf("192.0.2.%d", i), 1, 0, 0, func(d *domainStat, src *sourceStat, dc *clientStat) {})
}
s.mu.Lock()
n := len(s.domains["shared.example"].byClient)
s.mu.Unlock()
if n > maxClientsPerDomain {
t.Fatalf("expected per-domain client cap, got %d entries", n)
}
}
func TestTopDomainsMatchesFullSort(t *testing.T) {
now := time.Now()
input := make([]DomainStat, 0, snapshotTopN+37)
for i := 0; i < snapshotTopN+37; i++ {
input = append(input, DomainStat{
Domain: fmt.Sprintf("host%03d.example", i),
Conns: uint64((i * 7) % 31),
BytesUp: uint64((i * 13) % 101),
BytesDown: uint64((i * 17) % 97),
LastSeen: now.Add(time.Duration((i*19)%71) * time.Second),
})
}
for _, mode := range []DomainSort{DomainSortBytes, DomainSortRecent, DomainSortConns} {
want := slices.Clone(input)
sortDomains(want, mode)
want = want[:snapshotTopN]
got := topDomains(slices.Clone(input), snapshotTopN, mode)
if !slices.Equal(got, want) {
t.Fatalf("top domains differ for %s:\n got %v\nwant %v", mode, got, want)
}
}
tied := topDomains([]DomainStat{
{Domain: "z.example", BytesUp: 1},
{Domain: "a.example", BytesUp: 1},
}, 2, DomainSortBytes)
if tied[0].Domain != "a.example" || tied[1].Domain != "z.example" {
t.Fatalf("expected domain tie break by name, got %v", tied)
}
}
func TestTopClientsMatchesFullSort(t *testing.T) {
agg := make(map[string]*ClientStat, clientsTopN+17)
for i := 0; i < clientsTopN+17; i++ {
ip := fmt.Sprintf("192.0.2.%03d", i)
agg[ip] = &ClientStat{IP: ip, BytesUp: uint64((i * 11) % 113), BytesDown: uint64((i * 23) % 109)}
}
want := make([]ClientStat, 0, len(agg))
for _, client := range agg {
want = append(want, *client)
}
sort.Slice(want, func(i, j int) bool { return clientLess(want[i], want[j]) })
want = want[:clientsTopN]
got := topClients(agg)
if !slices.Equal(got, want) {
t.Fatalf("top clients differ:\n got %v\nwant %v", got, want)
}
tied := topClients(map[string]*ClientStat{
"192.0.2.2": {IP: "192.0.2.2", BytesUp: 1},
"192.0.2.1": {IP: "192.0.2.1", BytesUp: 1},
})
if tied[0].IP != "192.0.2.1" || tied[1].IP != "192.0.2.2" {
t.Fatalf("expected IP tie break, got %v", tied)
}
}
+57
View File
@@ -0,0 +1,57 @@
package admin
import (
"context"
"testing"
)
func TestTotalsWithHostnames(t *testing.T) {
t.Parallel()
resolver := &fakeHostnameResolver{byIP: map[string]string{
"192.168.1.2": "n100.lan",
"100.64.1.1": "router.tailnet.ts.net",
}}
stats := newTestStats(t)
stats.RecordDNS("example.com.", "192.168.1.2")
stats.RecordDNS("example.com.", "100.64.1.1")
s := NewServer(Options{Password: "secret", Version: "v1", Date: "2026-01-01", Stats: stats, Hostnames: resolver})
totals := s.totalsWithHostnames()
got := map[string]string{}
for _, c := range totals.Clients {
got[c.IP] = c.Hostname
}
if got["192.168.1.2"] != "n100.lan" {
t.Fatalf("hostname for 192.168.1.2: %q", got["192.168.1.2"])
}
if got["100.64.1.1"] != "router.tailnet.ts.net" {
t.Fatalf("hostname for 100.64.1.1: %q", got["100.64.1.1"])
}
// Second call hits the cache: the resolver must not be consulted again.
before := resolver.lookups.Load()
totals = s.totalsWithHostnames()
if resolver.lookups.Load() != before {
t.Fatalf("cache miss on second call: lookups %d -> %d", before, resolver.lookups.Load())
}
got = map[string]string{}
for _, c := range totals.Clients {
got[c.IP] = c.Hostname
}
if got["192.168.1.2"] != "n100.lan" {
t.Fatalf("cached hostname lost for 192.168.1.2: %q", got["192.168.1.2"])
}
}
var _ HostnameResolver = (*fakeHostnameResolver)(nil)
func TestHostnameResolverInterface(t *testing.T) {
t.Parallel()
var r HostnameResolver = &fakeHostnameResolver{byIP: map[string]string{}}
if got := r.Hostname(context.Background(), "192.168.1.9"); got != "" {
t.Fatalf("unexpected hostname %q", got)
}
}
+192
View File
@@ -0,0 +1,192 @@
package admin
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"slices"
"time"
)
func (s *Server) handleTraffic(w http.ResponseWriter, r *http.Request) {
if s.opts.Stats == nil {
writeError(w, http.StatusInternalServerError, "stats unavailable")
return
}
sort, source, client := parseTrafficQuery(r)
snap := s.opts.Stats.Snapshot(sort, source, client)
s.attachHostnames(snap.Clients)
writeJSON(w, http.StatusOK, snap)
}
// handleTotals serves the from-start cumulative traffic view. It ignores
// staleness and query filters: totals are the whole picture, not a window.
func (s *Server) handleTotals(w http.ResponseWriter, r *http.Request) {
if s.opts.Stats == nil {
writeError(w, http.StatusInternalServerError, "stats unavailable")
return
}
writeJSON(w, http.StatusOK, s.totalsWithHostnames())
}
// totalsWithHostnames decorates the cumulative totals with resolved client
// hostnames, shared by the HTTP endpoint and the SSE stream.
func (s *Server) totalsWithHostnames() Totals {
totals := s.opts.Stats.Totals()
s.attachHostnames(totals.Clients)
return totals
}
// parseTrafficQuery extracts the domain sort, source, and client filters
// shared by /api/traffic and the SSE stream.
func parseTrafficQuery(r *http.Request) (sort DomainSort, source Source, client string) {
sort = DomainSort(r.URL.Query().Get("sort"))
if !sort.valid() {
sort = DomainSortBytes
}
source = Source(r.URL.Query().Get("source"))
if !source.valid() {
source = SourceAll
}
client = r.URL.Query().Get("client")
return
}
func (s *Server) handleHistory(w http.ResponseWriter, r *http.Request) {
if s.opts.Stats == nil {
writeError(w, http.StatusInternalServerError, "stats unavailable")
return
}
writeJSON(w, http.StatusOK, s.opts.Stats.History())
}
// streamTrafficSnapshot caches the unfiltered live view for one stream tick.
// Filtered views remain immediate to avoid an unbounded per-client cache.
// Hostname decoration runs on a private copy of the cached Clients slice
// outside the lock: the cached snapshot is shared across streams, and a
// stalled resolver must not block other traffic-view readers, so decoration
// must neither hold the lock nor race on the shared backing array.
func (s *Server) streamTrafficSnapshot(sort DomainSort, source Source, client string) TrafficSnapshot {
var snap TrafficSnapshot
if sort != DomainSortBytes || source != SourceAll || client != "" {
snap = s.opts.Stats.Snapshot(sort, source, client)
} else {
now := time.Now()
s.trafficMu.Lock()
if !s.traffic.at.IsZero() && now.Sub(s.traffic.at) < trafficCacheTTL {
snap = s.traffic.snapshot
} else {
s.traffic.snapshot = s.opts.Stats.Snapshot(sort, source, client)
s.traffic.at = now
snap = s.traffic.snapshot
}
s.trafficMu.Unlock()
// The cached snapshot's Clients slice is shared across streams;
// decorate a private copy so concurrent readers never race on the
// shared backing array.
snap.Clients = slices.Clone(snap.Clients)
}
s.attachHostnames(snap.Clients)
return snap
}
// sseWriteTimeout bounds each individual SSE write. A client that vanished
// without closing the TCP connection (asleep laptop, dropped WiFi) would
// otherwise block the handler forever once the kernel send buffer fills;
// the deadline turns that into a bounded, detectable failure.
const sseWriteTimeout = 30 * time.Second
// handleStream pushes status, traffic snapshots, and history to the console.
// The connection carries the same sort/source/client filters as /api/traffic
// and revalidates the session on each tick. When a sliding renewal is due it
// asks the browser to refresh the cookie through /api/session, then closes so
// EventSource reconnects with the renewed cookie. Expiry sends an auth event.
// The initial payload is sent immediately so the page renders without waiting
// for a tick. Every write carries a deadline (see sseWriteTimeout); a failed
// write means the client is gone and the handler exits instead of leaking.
func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
if s.opts.Stats == nil {
writeError(w, http.StatusInternalServerError, "stats unavailable")
return
}
rc := http.NewResponseController(w)
sort, source, client := parseTrafficQuery(r)
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
fmt.Fprintln(w, "retry: 5000")
fmt.Fprintln(w)
// A writer that cannot flush (buffered proxy, unsupported transport)
// cannot carry the stream; the error-returning form also surfaces dead
// connections here.
if err := rc.Flush(); err != nil {
writeError(w, http.StatusInternalServerError, "streaming unsupported")
return
}
// send writes one event and reports whether the connection is still
// usable; the caller tears the stream down on the first failure. A
// non-writable transport (HTTP/2) degrades to unbounded writes instead
// of failing the stream.
send := func(event string, v any) bool {
if err := rc.SetWriteDeadline(time.Now().Add(sseWriteTimeout)); err != nil {
slog.Debug("set sse write deadline", "error", err)
}
data, err := json.Marshal(v)
if err != nil {
slog.Debug("marshal sse event", "error", err)
return true
}
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, data); err != nil {
return false
}
return rc.Flush() == nil
}
if !send("status", s.statusPayload()) ||
!send("traffic", s.streamTrafficSnapshot(sort, source, client)) ||
!send("history", s.opts.Stats.History()) ||
!send("totals", s.totalsWithHostnames()) {
return
}
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
historyTicker := time.NewTicker(10 * time.Second)
defer historyTicker.Stop()
totalsTicker := time.NewTicker(30 * time.Second)
defer totalsTicker.Stop()
for {
select {
case <-r.Context().Done():
return
case <-ticker.C:
_, valid, renewed := s.validSession(r)
if !valid {
send("auth", map[string]any{"status": http.StatusUnauthorized})
return
}
if renewed {
send("renew", map[string]any{})
return
}
if !send("status", s.statusPayload()) || !send("traffic", s.streamTrafficSnapshot(sort, source, client)) {
return
}
case <-historyTicker.C:
if !send("history", s.opts.Stats.History()) {
return
}
case <-totalsTicker.C:
if !send("totals", s.totalsWithHostnames()) {
return
}
}
}
}
+273
View File
@@ -0,0 +1,273 @@
package install
import (
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/sower-proxy/sower/config"
)
type ConfirmFunc func(label string) bool
type installOptions struct {
confirm ConfirmFunc
geteuid func() int
executable func() (string, error)
evalSymlinks func(string) (string, error)
serviceIsActive func() bool
systemctl func(args ...string) error
targetBinaryPath string
servicePath string
configPath string
defaultFakeSite string
defaultConfigTOML string
}
func InstallService(confirm ConfirmFunc) error {
return installService(installOptions{
confirm: confirm,
geteuid: os.Geteuid,
executable: os.Executable,
evalSymlinks: filepath.EvalSymlinks,
serviceIsActive: serviceIsActive,
systemctl: systemctl,
targetBinaryPath: "/usr/local/bin/sowerd",
servicePath: "/etc/systemd/system/sowerd.service",
configPath: "/etc/sower/sowerd.toml",
defaultFakeSite: "/var/www",
defaultConfigTOML: config.ExampleSowerdConfigTOML,
})
}
func installService(opts installOptions) error {
if opts.geteuid() != 0 {
return fmt.Errorf("installing systemd service requires root privileges, try running with sudo")
}
execPath, err := opts.executable()
if err != nil {
return fmt.Errorf("get executable path: %w", err)
}
execPath, err = opts.evalSymlinks(execPath)
if err != nil {
return fmt.Errorf("resolve executable symlink: %w", err)
}
isUpdate := opts.serviceIsActive()
if execPath != opts.targetBinaryPath {
label := fmt.Sprintf("Copy binary to %s?", opts.targetBinaryPath)
if isUpdate {
label = fmt.Sprintf("Update binary at %s?", opts.targetBinaryPath)
}
if opts.confirm != nil && opts.confirm(label) {
if err := copyFile(execPath, opts.targetBinaryPath, 0o755); err != nil {
return err
}
if isUpdate {
_ = opts.systemctl("stop", "sowerd")
}
fmt.Printf(" OK binary copied to %s\n", opts.targetBinaryPath)
execPath = opts.targetBinaryPath
}
}
if err := writeServiceFile(opts.servicePath, execPath, opts.configPath); err != nil {
return err
}
fmt.Printf(" OK service file installed: %s\n", opts.servicePath)
if err := ensureDefaultFiles(opts.configPath, opts.defaultFakeSite, opts.defaultConfigTOML); err != nil {
return err
}
if err := opts.systemctl("daemon-reload"); err != nil {
return err
}
fmt.Println(" OK systemd daemon reloaded")
if isUpdate {
return finishUpdate(opts.confirm, opts.systemctl)
}
return finishInstall(opts.confirm, opts.systemctl)
}
func finishInstall(confirm ConfirmFunc, systemctlFn func(args ...string) error) error {
if confirm == nil || !confirm("Start and enable sowerd service now?") {
fmt.Println()
fmt.Println("sowerd service installed. Next steps:")
fmt.Println(" 1. Edit /etc/sower/sowerd.toml")
fmt.Println(" 2. sudo systemctl start sowerd")
fmt.Println(" 3. sudo systemctl enable sowerd")
return nil
}
if err := systemctlFn("enable", "sowerd"); err != nil {
return err
}
if err := systemctlFn("start", "sowerd"); err != nil {
return err
}
fmt.Println(" OK service enabled and started")
return nil
}
func finishUpdate(confirm ConfirmFunc, systemctlFn func(args ...string) error) error {
if confirm == nil || !confirm("Restart sowerd service now?") {
fmt.Println()
fmt.Println("sowerd service updated. To apply:")
fmt.Println(" sudo systemctl restart sowerd")
return nil
}
if err := systemctlFn("restart", "sowerd"); err != nil {
return err
}
fmt.Println(" OK service restarted")
return nil
}
func serviceIsActive() bool {
cmd := exec.Command("systemctl", "is-active", "--quiet", "sowerd")
return cmd.Run() == nil
}
func systemctl(args ...string) error {
cmd := exec.Command("systemctl", args...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("systemctl %s: %w - %s", strings.Join(args, " "), err, strings.TrimSpace(string(output)))
}
return nil
}
func copyFile(src, dst string, mode os.FileMode) error {
data, err := os.ReadFile(src)
if err != nil {
return fmt.Errorf("read binary %s: %w", src, err)
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return fmt.Errorf("create binary directory: %w", err)
}
// Write via a temp file and rename so an interrupted update never leaves
// a truncated binary behind (the service is stopped at that point and
// would otherwise fail to start until the file is rewritten).
tmp, err := os.CreateTemp(filepath.Dir(dst), ".sowerd-*.tmp")
if err != nil {
return fmt.Errorf("create temp binary in %s: %w", filepath.Dir(dst), err)
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("write temp binary %s: %w", tmpName, err)
}
if err := tmp.Chmod(mode); err != nil {
_ = tmp.Close()
return fmt.Errorf("chmod temp binary %s: %w", tmpName, err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temp binary %s: %w", tmpName, err)
}
if err := os.Rename(tmpName, dst); err != nil {
return fmt.Errorf("rename temp binary to %s: %w", dst, err)
}
return nil
}
func writeServiceFile(servicePath, execPath, configPath string) error {
if err := os.MkdirAll(filepath.Dir(servicePath), 0o755); err != nil {
return fmt.Errorf("create service directory: %w", err)
}
serviceContent := fmt.Sprintf(`[Unit]
Description=sower server service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart="%s" -c "%s"
# Soft heap ceiling: a runaway heap degrades this service instead of
# OOM-killing the host (see docs/security.md, Connection Resource Bounds).
Environment=GOMEMLIMIT=512MiB
Restart=always
RestartSec=5s
[Install]
WantedBy=multi-user.target
`, execPath, configPath)
if err := os.WriteFile(servicePath, []byte(serviceContent), 0o644); err != nil {
return fmt.Errorf("write service file: %w", err)
}
return nil
}
func ensureDefaultFiles(configPath, fakeSiteDir, defaultConfigTOML string) error {
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
return fmt.Errorf("create config directory: %w", err)
}
if err := os.MkdirAll(fakeSiteDir, 0o755); err != nil {
return fmt.Errorf("create fake site directory: %w", err)
}
if _, err := os.Stat(configPath); err == nil {
return nil
} else if !os.IsNotExist(err) {
return fmt.Errorf("stat default config path: %w", err)
}
password, err := generatePassword()
if err != nil {
return err
}
rendered, err := renderDefaultConfig(defaultConfigTOML, password)
if err != nil {
return err
}
if err := os.WriteFile(configPath, []byte(rendered), 0o600); err != nil {
return fmt.Errorf("write default config: %w", err)
}
fmt.Printf(" OK default config written to %s\n", configPath)
// Printed once, like the admin console's generated password: the operator
// needs it for each client's remote.password, and the file is 0600.
fmt.Printf(" Client password (set remote.password on every client): %s\n", password)
return nil
}
// generatePassword returns a random 128-bit client password, hex encoded. The
// installer writes it into a fresh config instead of shipping the template's
// placeholder, which is a publicly known credential.
func generatePassword() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("generate client password: %w", err)
}
return hex.EncodeToString(b[:]), nil
}
// renderDefaultConfig replaces the template's top-level password line with the
// generated password. The template also carries a commented `password = ...`
// line inside the site_routes.auth sample, so only a line starting with
// `password = ` is replaced. A template without one is an error rather than a
// config that silently keeps a known credential.
func renderDefaultConfig(template, password string) (string, error) {
lines := strings.Split(template, "\n")
replaced := false
for i, line := range lines {
if !strings.HasPrefix(line, "password = ") {
continue
}
lines[i] = fmt.Sprintf("password = %q # Client auth password (generated by the installer)", password)
replaced = true
break
}
if !replaced {
return "", fmt.Errorf("default config template has no top-level password line")
}
return strings.Join(lines, "\n"), nil
}
+261
View File
@@ -0,0 +1,261 @@
package install
import (
"os"
"path/filepath"
"reflect"
"regexp"
"strings"
"testing"
"github.com/sower-proxy/sower/config"
)
func TestInstallServiceRequiresRoot(t *testing.T) {
t.Parallel()
err := installService(installOptions{
geteuid: func() int { return 1000 },
})
if err == nil || !strings.Contains(err.Error(), "root privileges") {
t.Fatalf("installService() error = %v, want root privileges error", err)
}
}
func TestInstallServiceFreshInstall(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
execPath := filepath.Join(tmpDir, "sowerd")
if err := os.WriteFile(execPath, []byte("binary"), 0o755); err != nil {
t.Fatalf("write executable: %v", err)
}
targetBinaryPath := filepath.Join(tmpDir, "usr", "local", "bin", "sowerd")
servicePath := filepath.Join(tmpDir, "etc", "systemd", "system", "sowerd.service")
configPath := filepath.Join(tmpDir, "etc", "sower", "sowerd.toml")
fakeSiteDir := filepath.Join(tmpDir, "var", "www")
var prompts []string
var systemctlCalls []string
confirmAnswers := map[string]bool{
"Copy binary to " + targetBinaryPath + "?": true,
"Start and enable sowerd service now?": true,
}
err := installService(installOptions{
confirm: func(label string) bool {
prompts = append(prompts, label)
return confirmAnswers[label]
},
geteuid: func() int { return 0 },
executable: func() (string, error) { return execPath, nil },
evalSymlinks: func(path string) (string, error) { return path, nil },
serviceIsActive: func() bool { return false },
systemctl: func(args ...string) error {
systemctlCalls = append(systemctlCalls, strings.Join(args, " "))
return nil
},
targetBinaryPath: targetBinaryPath,
servicePath: servicePath,
configPath: configPath,
defaultFakeSite: fakeSiteDir,
defaultConfigTOML: `password = "change_me"` + "\n" + `fakeSite = "/var/www"` + "\n",
})
if err != nil {
t.Fatalf("installService() error = %v", err)
}
gotBinary, err := os.ReadFile(targetBinaryPath)
if err != nil {
t.Fatalf("read target binary: %v", err)
}
if string(gotBinary) != "binary" {
t.Fatalf("target binary = %q, want %q", string(gotBinary), "binary")
}
serviceContent, err := os.ReadFile(servicePath)
if err != nil {
t.Fatalf("read service file: %v", err)
}
serviceText := string(serviceContent)
if !strings.Contains(serviceText, `ExecStart="`+targetBinaryPath+`" -c "`+configPath+`"`) {
t.Fatalf("service file missing quoted ExecStart, content = %s", serviceText)
}
configContent, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("read config file: %v", err)
}
configText := string(configContent)
if configText == "" {
t.Fatal("config file is empty")
}
// A fresh install must not ship the template's publicly known placeholder.
if strings.Contains(configText, "change_me") {
t.Fatalf("fresh config still carries the placeholder password:\n%s", configText)
}
if !regexp.MustCompile(`(?m)^password = "[0-9a-f]{32}"`).MatchString(configText) {
t.Fatalf("fresh config does not carry a generated 32-hex password:\n%s", configText)
}
if info, err := os.Stat(fakeSiteDir); err != nil || !info.IsDir() {
t.Fatalf("fake site dir stat err = %v, isDir = %v", err, err == nil && info.IsDir())
}
wantPrompts := []string{
"Copy binary to " + targetBinaryPath + "?",
"Start and enable sowerd service now?",
}
if !reflect.DeepEqual(prompts, wantPrompts) {
t.Fatalf("prompts = %#v, want %#v", prompts, wantPrompts)
}
wantSystemctlCalls := []string{
"daemon-reload",
"enable sowerd",
"start sowerd",
}
if !reflect.DeepEqual(systemctlCalls, wantSystemctlCalls) {
t.Fatalf("systemctl calls = %#v, want %#v", systemctlCalls, wantSystemctlCalls)
}
}
func TestInstallServiceUpdateWithoutRestart(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
targetBinaryPath := filepath.Join(tmpDir, "usr", "local", "bin", "sowerd")
if err := os.MkdirAll(filepath.Dir(targetBinaryPath), 0o755); err != nil {
t.Fatalf("mkdir target binary dir: %v", err)
}
if err := os.WriteFile(targetBinaryPath, []byte("binary"), 0o755); err != nil {
t.Fatalf("write target binary: %v", err)
}
servicePath := filepath.Join(tmpDir, "etc", "systemd", "system", "sowerd.service")
configPath := filepath.Join(tmpDir, "etc", "sower", "sowerd.toml")
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
t.Fatalf("mkdir config dir: %v", err)
}
if err := os.WriteFile(configPath, []byte("password = \"existing\"\n"), 0o644); err != nil {
t.Fatalf("write existing config: %v", err)
}
var prompts []string
var systemctlCalls []string
err := installService(installOptions{
confirm: func(label string) bool {
prompts = append(prompts, label)
return false
},
geteuid: func() int { return 0 },
executable: func() (string, error) { return targetBinaryPath, nil },
evalSymlinks: func(path string) (string, error) { return path, nil },
serviceIsActive: func() bool { return true },
systemctl: func(args ...string) error {
systemctlCalls = append(systemctlCalls, strings.Join(args, " "))
return nil
},
targetBinaryPath: targetBinaryPath,
servicePath: servicePath,
configPath: configPath,
defaultFakeSite: filepath.Join(tmpDir, "var", "www"),
defaultConfigTOML: "password = \"change_me\"\n",
})
if err != nil {
t.Fatalf("installService() error = %v", err)
}
configContent, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("read config file: %v", err)
}
if string(configContent) != "password = \"existing\"\n" {
t.Fatalf("config content = %q, want existing config to be preserved", string(configContent))
}
wantPrompts := []string{"Restart sowerd service now?"}
if !reflect.DeepEqual(prompts, wantPrompts) {
t.Fatalf("prompts = %#v, want %#v", prompts, wantPrompts)
}
wantSystemctlCalls := []string{"daemon-reload"}
if !reflect.DeepEqual(systemctlCalls, wantSystemctlCalls) {
t.Fatalf("systemctl calls = %#v, want %#v", systemctlCalls, wantSystemctlCalls)
}
}
func TestRenderDefaultConfigReplacesOnlyTheTopLevelPassword(t *testing.T) {
t.Parallel()
// The example template also carries a commented password line inside the
// site_routes.auth sample: replacing that one instead would leave the real
// credential as the placeholder while looking correct.
template := `password = "change_me" # Client auth password (required; replace the placeholder)
fake_site = "/var/www"
# [site_routes.auth]
# password = "change_me" # an empty block leaves the route open.
`
got, err := renderDefaultConfig(template, "abc123")
if err != nil {
t.Fatalf("renderDefaultConfig() error = %v", err)
}
if !strings.Contains(got, `password = "abc123"`) {
t.Fatalf("rendered config does not carry the generated password:\n%s", got)
}
if strings.Count(got, "change_me") != 1 || !strings.Contains(got, `# password = "change_me"`) {
t.Fatalf("rendered config must keep the placeholder only in the commented sample:\n%s", got)
}
}
func TestRenderDefaultConfigWithoutPasswordLine(t *testing.T) {
t.Parallel()
if _, err := renderDefaultConfig("fake_site = \"/var/www\"\n", "abc123"); err == nil {
t.Fatal("a template without a top-level password line must be an error")
}
}
// The installer renders this exact template (installService), so the embedded
// example and renderDefaultConfig form a contract: reformat the password line
// (indent it, drop the space) and renderDefaultConfig errors, which would fail
// every fresh `sowerd -i` at runtime. This is the test that fails instead.
func TestRenderDefaultConfigWithEmbeddedTemplate(t *testing.T) {
t.Parallel()
got, err := renderDefaultConfig(config.ExampleSowerdConfigTOML, "abc123")
if err != nil {
t.Fatalf("renderDefaultConfig(embedded example) error = %v", err)
}
if !strings.Contains(got, `password = "abc123"`) {
t.Fatalf("rendered example does not carry the generated password:\n%s", got)
}
// The example's site_routes.auth sample carries a commented placeholder;
// it must survive untouched, and the real credential must not be it.
if strings.Count(got, "change_me") != 1 || !strings.Contains(got, `# password = "change_me"`) {
t.Fatalf("placeholder must survive only in the commented sample:\n%s", got)
}
}
func TestGeneratePassword(t *testing.T) {
t.Parallel()
first, err := generatePassword()
if err != nil {
t.Fatalf("generatePassword() error = %v", err)
}
if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(first) {
t.Fatalf("password = %q, want 32 hex characters", first)
}
second, err := generatePassword()
if err != nil {
t.Fatalf("generatePassword() error = %v", err)
}
if first == second {
t.Fatal("generatePassword() returned the same value twice")
}
}
+199
View File
@@ -0,0 +1,199 @@
// Package metrics owns the OpenTelemetry meter and instruments for sower. It
// is the standardized export surface: the Prometheus /metrics endpoint today,
// and an OTLP exporter can be added later without touching call sites. The
// admin console reads its own display counters (internal/admin.Stats), not
// these instruments, so the UI never depends on exporter internals.
package metrics
import (
"context"
"fmt"
"net/http"
"runtime"
rtmetrics "runtime/metrics"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.opentelemetry.io/otel/attribute"
otelprom "go.opentelemetry.io/otel/exporters/prometheus"
"go.opentelemetry.io/otel/metric"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
)
// Metrics owns the OTel meter, instruments, and the Prometheus export handler.
type Metrics struct {
provider *sdkmetric.MeterProvider
handler http.Handler
meter metric.Meter
bytes metric.Int64Counter // attr: direction=up|down
dnsQueries metric.Int64Counter
conns metric.Int64Counter // attr: protocol=http|https|socks5
activeConns metric.Int64UpDownCounter
ruleHits metric.Int64Counter // attr: route=block|direct|proxy
proxyErrors metric.Int64Counter // attr: kind=dial|dns|accept
connDur metric.Int64Histogram
start time.Time
}
// New creates the meter provider and all instruments. Instrument definitions
// are programmer errors and panic rather than fail silently; only provider
// creation can return an error.
func New() (*Metrics, error) {
// A fresh registry per instance: the exporter registers itself as a
// prometheus.Collector, and the global default registerer would reject a
// second instance (tests create several).
reg := prometheus.NewRegistry()
exporter, err := otelprom.New(otelprom.WithRegisterer(reg), otelprom.WithoutScopeInfo())
if err != nil {
return nil, fmt.Errorf("create prometheus exporter: %w", err)
}
provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(exporter))
m := &Metrics{
provider: provider,
handler: promhttp.HandlerFor(reg, promhttp.HandlerOpts{}),
meter: provider.Meter("sower"),
start: time.Now(),
}
m.bytes = mustCounter(m.meter, "sower.bytes",
metric.WithDescription("Proxied payload bytes, direction relative to the client"),
metric.WithUnit("By"))
m.dnsQueries = mustCounter(m.meter, "sower.dns_queries",
metric.WithDescription("DNS queries answered by the proxy"))
m.conns = mustCounter(m.meter, "sower.connections",
metric.WithDescription("Proxy connections accepted"))
m.activeConns = mustUpDownCounter(m.meter, "sower.connections_active",
metric.WithDescription("Proxy connections currently open"))
m.ruleHits = mustCounter(m.meter, "sower.rule_hits",
metric.WithDescription("Connection routing decisions by rule category"))
m.proxyErrors = mustCounter(m.meter, "sower.proxy_errors",
metric.WithDescription("Proxy-side failures by kind"))
m.connDur = mustHistogram(m.meter, "sower.connection_duration",
metric.WithDescription("Proxy connection lifetime"),
metric.WithUnit("ms"))
goroutines := mustObservableGauge(m.meter, "sower.process_goroutines",
metric.WithDescription("Goroutines in the sower process"))
heap := mustObservableGauge(m.meter, "sower.process_heap_alloc",
metric.WithDescription("Heap bytes allocated by the sower process"),
metric.WithUnit("By"))
uptime := mustObservableGauge(m.meter, "sower.uptime_seconds",
metric.WithDescription("Seconds since the sower process started"),
metric.WithUnit("s"))
if _, err := m.meter.RegisterCallback(func(_ context.Context, o metric.Observer) error {
o.ObserveInt64(goroutines, int64(runtime.NumGoroutine()))
o.ObserveInt64(heap, int64(heapAlloc()))
o.ObserveInt64(uptime, int64(time.Since(m.start).Seconds()))
return nil
}, goroutines, heap, uptime); err != nil {
return nil, fmt.Errorf("register process gauges: %w", err)
}
return m, nil
}
// Precomputed AddOptions avoid an attribute-slice allocation on every
// RecordBytes call, which runs on the per-I/O relay hot path.
var (
bytesAttrUp = metric.WithAttributes(attribute.String("direction", "up"))
bytesAttrDown = metric.WithAttributes(attribute.String("direction", "down"))
)
// RecordBytes counts proxied payload bytes. up is relative to the client:
// reads from the client are uploads, writes to the client downloads.
func (m *Metrics) RecordBytes(up bool, n int) {
if up {
m.bytes.Add(context.Background(), int64(n), bytesAttrUp)
} else {
m.bytes.Add(context.Background(), int64(n), bytesAttrDown)
}
}
// RecordDNS counts one DNS query.
func (m *Metrics) RecordDNS() {
m.dnsQueries.Add(context.Background(), 1)
}
// ConnOpened counts a new connection and raises the active gauge.
func (m *Metrics) ConnOpened(protocol string) {
attr := attribute.String("protocol", protocol)
m.conns.Add(context.Background(), 1, metric.WithAttributes(attr))
m.activeConns.Add(context.Background(), 1, metric.WithAttributes(attr))
}
// ConnClosed lowers the active gauge for one connection.
func (m *Metrics) ConnClosed(protocol string) {
m.activeConns.Add(context.Background(), -1,
metric.WithAttributes(attribute.String("protocol", protocol)))
}
// RecordRoute counts one connection routing decision.
func (m *Metrics) RecordRoute(route string) {
m.ruleHits.Add(context.Background(), 1,
metric.WithAttributes(attribute.String("route", route)))
}
// RecordConnDuration records a connection lifetime.
func (m *Metrics) RecordConnDuration(d time.Duration) {
m.connDur.Record(context.Background(), d.Milliseconds())
}
// RecordProxyError counts one proxy-side failure. kind is dial, dns, or
// accept. Failures are low-frequency relative to the relay hot path, so the
// attribute is built per call without a precomputed option.
func (m *Metrics) RecordProxyError(kind string) {
m.proxyErrors.Add(context.Background(), 1,
metric.WithAttributes(attribute.String("kind", kind)))
}
// ServeHTTP serves the Prometheus exposition format.
func (m *Metrics) ServeHTTP(w http.ResponseWriter, r *http.Request) {
m.handler.ServeHTTP(w, r)
}
// Shutdown flushes and stops the meter provider.
func (m *Metrics) Shutdown(ctx context.Context) error {
return m.provider.Shutdown(ctx)
}
// heapAlloc reads the live heap size without stopping the world.
func heapAlloc() uint64 {
var samples = []rtmetrics.Sample{{Name: "/memory/classes/heap/objects:bytes"}}
rtmetrics.Read(samples)
return samples[0].Value.Uint64()
}
func mustCounter(meter metric.Meter, name string, opts ...metric.Int64CounterOption) metric.Int64Counter {
c, err := meter.Int64Counter(name, opts...)
if err != nil {
panic(fmt.Sprintf("metrics: invalid counter %q: %v", name, err))
}
return c
}
func mustUpDownCounter(meter metric.Meter, name string, opts ...metric.Int64UpDownCounterOption) metric.Int64UpDownCounter {
c, err := meter.Int64UpDownCounter(name, opts...)
if err != nil {
panic(fmt.Sprintf("metrics: invalid updown counter %q: %v", name, err))
}
return c
}
func mustHistogram(meter metric.Meter, name string, opts ...metric.Int64HistogramOption) metric.Int64Histogram {
h, err := meter.Int64Histogram(name, opts...)
if err != nil {
panic(fmt.Sprintf("metrics: invalid histogram %q: %v", name, err))
}
return h
}
func mustObservableGauge(meter metric.Meter, name string, opts ...metric.Int64ObservableGaugeOption) metric.Int64ObservableGauge {
g, err := meter.Int64ObservableGauge(name, opts...)
if err != nil {
panic(fmt.Sprintf("metrics: invalid gauge %q: %v", name, err))
}
return g
}
+73
View File
@@ -0,0 +1,73 @@
package metrics
import (
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestExporterServesInstruments(t *testing.T) {
m, err := New()
if err != nil {
t.Fatalf("new metrics: %v", err)
}
defer m.Shutdown(t.Context())
m.RecordBytes(true, 5)
m.RecordBytes(false, 7)
m.RecordDNS()
m.ConnOpened("http")
m.ConnClosed("http")
m.RecordRoute("proxy")
m.RecordConnDuration(3 * time.Millisecond)
rec := httptest.NewRecorder()
m.ServeHTTP(rec, httptest.NewRequest("GET", "/metrics", nil))
if rec.Code != 200 {
t.Fatalf("expected 200, got %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
`sower_bytes_total{direction="up"} 5`,
`sower_bytes_total{direction="down"} 7`,
"sower_dns_queries_total 1",
`sower_connections_total{protocol="http"} 1`,
`sower_connections_active{protocol="http"} 0`,
`sower_rule_hits_total{route="proxy"} 1`,
"sower_uptime_seconds ",
"sower_process_goroutines ",
} {
if !strings.Contains(body, want) {
t.Fatalf("expected %q in metrics, got:\n%s", want, body)
}
}
}
func TestMultipleInstancesDoNotConflict(t *testing.T) {
a, err := New()
if err != nil {
t.Fatalf("new metrics a: %v", err)
}
defer a.Shutdown(t.Context())
b, err := New()
if err != nil {
t.Fatalf("new metrics b: %v", err)
}
defer b.Shutdown(t.Context())
a.RecordDNS()
b.RecordDNS()
b.RecordDNS()
ra := httptest.NewRecorder()
a.ServeHTTP(ra, httptest.NewRequest("GET", "/metrics", nil))
if !strings.Contains(ra.Body.String(), "sower_dns_queries_total 1") {
t.Fatalf("expected instance a to report 1 query, got:\n%s", ra.Body.String())
}
rb := httptest.NewRecorder()
b.ServeHTTP(rb, httptest.NewRequest("GET", "/metrics", nil))
if !strings.Contains(rb.Body.String(), "sower_dns_queries_total 2") {
t.Fatalf("expected instance b to report 2 queries, got:\n%s", rb.Body.String())
}
}
-28
View File
@@ -1,28 +0,0 @@
package main
import (
"github.com/golang/glog"
"github.com/wweir/sower/conf"
"github.com/wweir/sower/dns"
"github.com/wweir/sower/proxy"
)
var version, date string
func main() {
conf := conf.Conf
glog.Infof("Starting sower(%s %s): %v", version, date, conf)
if conf.ServerAddr == "" {
proxy.StartServer(conf.NetType, conf.ServerPort, conf.Cipher, conf.Password)
} else {
if conf.HTTPProxy != "" {
go proxy.StartHttpProxy(conf.NetType, conf.ServerAddr,
conf.Cipher, conf.Password, conf.HTTPProxy)
}
go dns.StartDNS(conf.DNSServer, conf.ClientIP)
proxy.StartClient(conf.NetType, conf.ServerAddr, conf.Cipher, conf.Password, conf.ClientIP)
}
}
-48
View File
@@ -1,48 +0,0 @@
package parse
import (
"bufio"
"fmt"
"io"
"net"
"net/http"
"strings"
"github.com/wweir/sower/util"
)
func ParseAddr(conn net.Conn) (teeConn *util.TeeConn, addr string, err error) {
teeConn = &util.TeeConn{Conn: conn}
teeConn.StartOrReset()
defer teeConn.Stop()
buf := make([]byte, 1)
if n, err := teeConn.Read(buf); err != nil {
return teeConn, "", fmt.Errorf("Read conn fail: %v, readed: %v", err, buf[:n])
}
teeConn.StartOrReset()
// https
if buf[0] == 0x16 { // SSL handleshake
host, _, err := extractSNI(io.Reader(teeConn))
if err != nil {
return teeConn, "", err
}
if strings.Contains(host, ":") {
return teeConn, host, nil
}
return teeConn, host + ":443", nil
}
// http
b := bufio.NewReader(teeConn)
resp, err := http.ReadRequest(b)
if err != nil {
return teeConn, "", err
}
if strings.Contains(resp.Host, ":") {
return teeConn, resp.Host, nil
}
return teeConn, resp.Host + ":80", nil
}

Some files were not shown because too many files have changed in this diff Show More