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