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.
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.
`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.
- 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.
- 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.
- 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.
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.
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.
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.
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.
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.
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.
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 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
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).
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.
- 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.
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.
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.
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.
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.
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.
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