A handshake failure on unknown SNI is an active-probing signal: real
sites present their default vhost certificate. Whitelisted names keep
the plain autocert path with errors surfaced as-is; unknown or empty
SNI falls back to the primary domain's certificate, carrying the
client hello over so autocert derives the same ECDSA/RSA cache key.
The h2-on-fallback companion idea was investigated and rejected:
httputil.ReverseProxy has no RFC 8441 support and x/net gates
extended CONNECT behind GODEBUG only, which would break WingGate WSS.
The default ReverseProxy error handler logs a bare
"http: proxy error: <err>": with site routes and path overrides the
upstream address alone does not identify which route failed, so a
refused upstream could not be attributed to a route without guessing.
Report upstream, request method/host/path and the peer address, and
answer 502 with a body instead of an empty response. The handler is also
reachable before any byte is written (round-trip and upgrade failures),
so the response semantics are unchanged.
Unmatched HTTP/1.x browsing is reverse-proxied to fake_site instead of
TCP-spliced, preserving Host. Site routes and this path overwrite
X-Forwarded-For, X-Forwarded-Host, X-Forwarded-Proto, and X-Real-IP from
the TLS peer and drop client-supplied forwarding headers. Non-HTTP
fallback, including sower auth failure, still relays the original bytes.
- Config override validation now accepts bracketed IPv6 remote addresses
([2001:db8::1]:8443): the host part is checked as an IP literal before
falling back to hostname rules, so a valid IPv6 remote no longer fails
validation.
- Directory fake sites serve local relays even when serve_ip is a
concrete non-loopback address: the local check now recognizes any
address bound to a local interface, not just loopback, so fallback
traffic is not wrongly 307-redirected to https.
- The config view's restart handler ignores failures after the component
is destroyed, so a late 401 from the in-flight restart request can no
longer trigger a global logout from a dead page.
- The login throttle bounds its failure map (purges expired entries when
full) so rotating source IPs cannot grow it without bound.
- autocert gets a HostPolicy: certificates are only issued for site_routes
domains plus the new cert.domains whitelist, so an arbitrary SNI can no
longer drive ACME issuance and exhaust the account's rate limits. Direct
connection domains (the sower client's remote addr) must be listed in
cert.domains.
- The TLS handshake now completes explicitly (60s budget) before the 1s
protocol probe, so the first connection to a new domain no longer loses
the race against synchronous ACME issuance inside GetCertificate.
- The transport header read after a probe match keeps a 5s deadline
instead of none: a connection that sends only the 0x80 probe byte can no
longer hold a goroutine and fd forever.
- Directory fake sites relay to the configured serve_ip:80 when it is
concrete, instead of always 127.0.0.1:80, which broke HTTPS fallback
when serve_ip did not cover loopback.
- 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.
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.
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.
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.
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.
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.
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.
- 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
- 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
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
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.
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.
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.
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.
- 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
Allow HTTP upgrade requests (WebSocket/CONNECT) through the fallback
reverse proxy instead of rejecting them with 400. Once the connection
is hijacked it is owned by the ReverseProxy upgrade goroutine, so
handleConn skips closing it; the single-conn listener is closed to
unblock Serve while the tunnel stays alive.
Previously the probe read deadline (1s absolute time set at handleConn
entry) remained active while handler.Unwrap ran its io.ReadFull on the
protocol header. A slow client could let Unwrap be interrupted by the
remaining sliver of the probe timeout, causing spurious auth/decode
failures and fallback to the fake site. Clear the deadline once at the
top of the probeMatch case, before Unwrap, so the protocol handshake
gets its own time budget consistent with the subsequent relay path.
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
Introduce proxyProtocolHandler interface with Probe/Unwrap methods,
extracting protocol detection from handleConn into a clean pipeline:
probe first bytes → match handler → Unwrap, or fallback by SNI.
- probe.go: probeVerdict enum, proxyProtocolHandler interface,
sowerProtocolHandler, readProtocolProbe
- handleConn now iterates handlers list, falls through to TLS SNI
routing on probeNoMatch, and to fake_site on auth failure
- serve443 signature changed: trojan param replaced by []proxyProtocolHandler
- Move configuration structures from cmd/sower/main.go and cmd/sowerd/main.go to config package
- Add config.SowerConfig and config.SowerdConfig types
- Implement Validate() method for configuration validation
- Update Go module dependencies to latest versions
- Improve code organization and maintainability