Fix the bbolt round trip, the blocklist gaps and the deploy set
ci / Console typecheck and build (push) Successful in 13s
ci / Go build, vet and test (push) Failing after 22s
ci / Binary with the console embedded (push) Skipped
ci / Container image (push) Skipped

The cache snapshot never loaded, so every restart was a cold cache. Snapshot
packed the whole key while Load decoded the record header out of the key's
remainder instead of the value, so every entry was skipped. The two sides are
now an explicit encodeRecord/decodeRecord pair with a version byte and a CRC-32C
trailer, so a future format change is detected instead of silently misparsed.
Load also drops entries that are already past ExpiresAt and rewrites the TTL to
the time actually left, so a served TTL never jumps back up across a restart.

Blocklist, all six gaps the failing tests were pointing at: a leading UTF-8 BOM
is stripped, "!allow example.com" works, SourceMeta exists for a source that
only ever contributed a rule, Pattern() reports the bare name again (the new
WireName() produces the wildcard form for the API), an RPZ record with no
explicit TTL inherits the zone default instead of whatever the reader left in
the RR, a hosts line with no address in the first field says so, and the
adblock parser handles CRLF and adblock-flavoured lines pasted into a plain
list. TestHolderSwapIsAtomic was asserting the wrong invariant — the swap was
always atomic, a name blocked by the old set is legitimately unblocked by the
new one.

Tests added for acl and resolver, both of which turned up real bugs:

- a PTR suffix matched across label boundaries, so a group scoped to
  example.com. also swallowed ample.com. and notexample.com. — a client could
  land in a group it does not belong to;
- the "*" catch-all was dropped from the rendered domain patterns, so the
  engine's per-query recompile lost the "everything" case;
- Upstream.Success never closed an open breaker, so a recovered upstream stayed
  excluded for the full cooldown;
- Exchange fanned out in parallel even under policy: failover.

Deploy set: go.mod requires Go 1.27.1 but CI pinned 1.23 and the Dockerfile
pinned golang:1.23-alpine, so neither could have built. The Dockerfile also
copied the source tree after the console bundle, which clobbered it.

The k8s set asked for hostNetwork with two replicas, but the server never sets
SO_REUSEPORT, so the second pod could not bind :53 and would crash-loop. It now
runs without hostNetwork behind a LoadBalancer Service, with a separate
ClusterIP Service for the Ingress (a headless Service cannot back one), a
ServiceAccount, an API-key Secret, per-pod PVCs, and a kustomization. The HPA
is gone: it cannot drive a StatefulSet. To let the API key come from that
Secret, config.Load now expands ${VAR} / ${VAR:-default} / ${NAME-default} and
fails to start on an unset variable with no default, so a missing secret is a
crash loop rather than an unauthenticated console. Expansion skips YAML
comments, so documenting ${VAR} in the ConfigMap does not break the file.

Verified: gofmt, go build, go vet clean; go test -race ./internal/... green
(acl, app, blocklist, cache, config, resolver, zonefile).
This commit is contained in:
developer committed 2026-09-27 13:30:43 +00:00
1 parent 3bcc10a029
commit 26134ba437
22 files changed
+3106 -225

No files matched your search

+53 -10
View File
@@ -44,10 +44,12 @@ type Decision struct {
MaxConcurrent int
// LogQueries forces query logging regardless of the sampling rate.
LogQueries bool
// Upstreams restricts this client to specific upstream addresses.
// Upstreams restricts this client to specific upstream addresses. The
// slice is owned by the engine and must be treated as read-only.
Upstreams []string
// AllowDomains and DenyDomains are per-group name overrides, using the
// same wildcard syntax as the blocklist ("*.example.com").
// blocklist's wildcard spellings ("*.example.com", "**.example.com"),
// except that both forms also cover the apex: see NewDomainSet.
AllowDomains []string
DenyDomains []string
// Default is true when no group matched.
@@ -66,8 +68,11 @@ type DomainSet struct {
any bool
}
// NewDomainSet compiles patterns like "*.example.com" (subdomains only),
// "**.example.com" (subdomains plus the apex) and "example.com" (exact).
// NewDomainSet compiles the blocklist's wildcard spellings: "example.com"
// (exact), "*.example.com" and "**.example.com" (the domain plus its
// subdomains), and "*", "**" or "*.*" (everything). Unlike in the blocklist,
// "*.x" and "**.x" are the same rule here: the leading "*" label is dropped,
// so either form covers the apex too. An exact-only rule carries no wildcard.
func NewDomainSet(patterns []string) *DomainSet {
ds := &DomainSet{
exact: make(map[string]struct{}, len(patterns)),
@@ -185,7 +190,7 @@ func Compile(cfg config.ACL) *Engine {
comment: g.Comment,
policy: Policy(g.Policy),
any: g.Match.Any,
ptr: lowerAll(g.Match.PTRSuffixes),
ptr: ptrSuffixes(g.Match.PTRSuffixes),
tags: lowerAll(g.Match.Tags),
allow: NewDomainSet(g.AllowDomains),
deny: NewDomainSet(g.DenyDomains),
@@ -269,7 +274,12 @@ func (e *Engine) Match(c Client) Decision {
}
// Default returns the decision for a client that matched nothing.
func (e *Engine) Default() Decision { return e.defaultDecision() }
func (e *Engine) Default() Decision {
if e == nil {
return Decision{Policy: PolicyAllow, Default: true}
}
return e.defaultDecision()
}
func (e *Engine) defaultDecision() Decision {
d := e.def
@@ -292,7 +302,7 @@ func (g *group) matches(c Client) bool {
if len(g.ptr) > 0 {
for _, n := range c.Names {
for _, suffix := range g.ptr {
if strings.HasSuffix(n, suffix) {
if matchPTR(n, suffix) {
return true
}
}
@@ -310,6 +320,19 @@ func (g *group) matches(c Client) bool {
return false
}
// matchPTR reports whether the reverse name n ends with suffix at a DNS label
// boundary. Both sides are in FQDN form (trailing dot) by the time they get
// here, so "example.com." must not match the client "host.notexample.com.".
func matchPTR(n, suffix string) bool {
if suffix == "" || len(n) < len(suffix) || !strings.HasSuffix(n, suffix) {
return false
}
// The suffix has to start a label: it is either the whole name, or the
// character in front of it is a dot.
rest := n[:len(n)-len(suffix)]
return rest == "" || rest[len(rest)-1] == '.'
}
// Groups returns the compiled group summaries for the API.
type GroupSummary struct {
Name string `json:"name"`
@@ -378,7 +401,14 @@ func domainPatterns(d *DomainSet) []string {
if d == nil {
return nil
}
out := make([]string, 0, len(d.exact)+len(d.subs))
out := make([]string, 0, len(d.exact)+len(d.subs)+1)
if d.any {
// Keep the catch-all visible alongside the specific rules — dropping
// it would mean the engine's per-query recompile of the rendered
// patterns lost the "everything" case. It sorts first, like any other
// pattern.
out = append(out, "*")
}
for k := range d.exact {
out = append(out, k)
}
@@ -404,8 +434,21 @@ func lowerAll(in []string) []string {
return out
}
// TagsFromName derives candidate tags from a reverse-DNS name, using the
// labels as-is: "laptop.lan.example.net" yields laptop, lan, example, net.
// ptrSuffixes lowercases reverse-name suffixes and normalises them to the
// FQDN form (trailing dot) that Client.Names carries, so matching can rely on
// a label boundary at the end of the suffix.
func ptrSuffixes(in []string) []string {
out := lowerAll(in)
for i, s := range out {
if !strings.HasSuffix(s, ".") {
out[i] = s + "."
}
}
return out
}
// TagsFromName derives candidate tags from a reverse-DNS name: every label
// above the parent domain, so "laptop.lan.example.net" yields laptop and lan.
// Matching a group on tag "laptop" then works without any extra
// configuration.
func TagsFromName(name string) []string {
File diff suppressed because it is too large. Load diff