Repository navigation
Releases: bcrisp4/bns
Release list
v0.5.1 — fix entries gauge on 304 cache loads
Fix
bns_blocklist_entries_by_source{source=...} no longer goes absent after a restart for HTTP blocklist sources that load from cache.
Previously the gauge was only set on the 200 OK fetch path. Any source already on disk in cache_dir with a valid ETag returns 304 Not Modified on its first conditional GET after restart — and the gauge for that source was never populated, even though the list was fully loaded and actively matching. With conditional GETs plus a multi-hour refresh_interval, the gauge stayed absent until the upstream content actually changed (ETag differs → 200).
The 304 path now re-reports the entry count already persisted in the cache metadata (CacheMeta.Entries). A 304 is only reachable when If-None-Match was sent from the cached ETag, so the cached count is current.
Upgrade
Drop-in. No config changes, no behavioural change beyond the metric now being present after a cache-load restart.
v0.5.0 — direct cache hit/miss metric
Highlights
- New metric:
bns_cache_lookups_total{result="hit"|"miss"}— direct cache effectiveness signal emitted bycachestageon every single-question lookup. Hit-rate is now a clean Prometheus query:Replaces the indirect derivation fromrate(bns_cache_lookups_total{result="hit"}[5m]) / rate(bns_cache_lookups_total[5m])bns_upstream_queries_total/bns_queries_total, which was noisy under coalesced piggybacks and upstream errors. - Help-text fix:
bns_cache_evictions_totalnow correctly documents both eviction paths (LRU pressure on Store and lazy TTL expiry on Get).
Internal
cachestage.Newgained a*metrics.Metricsparameter (nil-safe, matching thecoalescestage precedent). Multi-question requests bypass the cache and the counter — they are not lookups.- CLAUDE.md compressed + improver pass.
Changelog
3bb1663 metrics: add bns_cache_lookups_total{result=hit|miss}
9971540 docs: /caveman:compress pass over CLAUDE.md
f1b349e docs: CLAUDE.md improver pass — v0.4.0 release, make build-stress, compress DoH gotchas
b325bd1 docs: 3 learnings from DoH session (test helpers, TLS h2, Pack-error)
b30dd43 metrics: clarify cache eviction help text covers TTL expiry
Container image
ghcr.io/bcrisp4/bns:0.5.0 (multi-arch linux/amd64 + linux/arm64) — published automatically by CI on tag push.
v0.4.0 — DNS-over-HTTPS upstream support
Highlights
DNS-over-HTTPS (RFC 8484) as upstream transport. New type: doh upstream entries alongside existing UDP. Hand-rolled DoH client over stdlib net/http, uses miekg/dns v2 dnshttp.Response for decode. HTTP/2 preferred with HTTP/1.1 fallback, TLS 1.3 floor, redirects refused, cookies disabled.
Operator-pinned endpoint_ips eliminate the bootstrap deadlock that would otherwise occur on Pi-hole-style deployments where BNS is the system resolver. The DoH URL hostname is used only for SNI / cert SAN validation; never resolved at runtime.
Per-query upstream attribution in the query log. Forwarded queries now carry upstream (URL/addr) and upstream_protocol (udp/doh) attrs. Cache hits, blocked queries, and coalesce piggybacks omit them cleanly.
Container default switched to DoH-primary with UDP fallback. Operators wanting plain UDP only can mount their own config.
Configuration
upstreams:
- type: doh
url: https://cloudflare-dns.com/dns-query
endpoint_ips: [1.1.1.1, 1.0.0.1]
timeout: 5s
- type: udp
addr: 1.1.1.1:53
timeout: 2sSee examples/config.example.yaml (mixed UDP+DoH) and examples/config-doh.yaml (DoH-only).
New metrics
bns_doh_http_status_total{upstream, status}— HTTP-layer outcomesbns_doh_tls_handshakes_total{upstream, result}— connection-churn signal
Breaking changes
--upstreamCLI flag removed. Upstreams are YAML-only (consistent withblocklists.sources, which has been YAML-only since v0.3.0 because viper cannot index slice config via env vars).bns_upstream_*metric vectors gainprotocollabel (udp/doh/pool). Alerts that label-match on{upstream}without protocol continue to work; alerts that group by all labels and assert cardinality need updating.
RFC 8484 compliance
Audited: POST method, ID=0 on the wire (RFC §4.1.1), Content-Type case-insensitive parse with parameters (§4.2 + RFC 7231 §3.1.1.1), 2xx status range, body cap at dns.MaxMsgSize, redirect refusal (§9), cookies disabled (§8), HTTP/2 preferred (§5.2), server push disabled by default (§5.3), TLS 1.3 minimum (BCP 195 / RFC 9325), HTTP Age header subtracted from RR TTLs (§5.1).
Spec + plan
docs/specs/2026-05-24-doh-upstream-design.mddocs/plans/2026-05-24-doh-upstream-implementation.md
Container image
ghcr.io/bcrisp4/bns:0.4.0, :0.4, :latest (linux/amd64 + linux/arm64) — built and pushed automatically by .github/workflows/docker.yml on this tag.
🤖 Generated with Claude Code
v0.3.1 — quieter cold-start logs
Fix
Cold-start with HTTP-only blocklist sources no longer emits a spurious WARN. When every configured source is type: http and the on-disk cache is empty (first run, or no volume mounted), the matcher is empty by design — the fetcher populates it within a few seconds. Previously the startup log line was:
```
WARN blocklist sources configured but yielded zero entries — check paths and file contents
```
which falsely implied operator misconfiguration. The expected case now logs at INFO:
```
INFO blocklist cache cold — fetcher will populate; block stage is a no-op until first fetch lands
```
The original WARN still fires for genuine misconfiguration (file source pointing at an empty/missing path, or mixed sources where the file half yields nothing).
Upgrade
Drop-in. No config changes. No behavioural change beyond the startup log line.
```
docker pull ghcr.io/bcrisp4/bns:0.3.1
```
v0.3.0 — HTTP blocklist source
Highlights
Adds a polite, self-refreshing HTTP blocklist source so operators no longer need to bake lists into the container image or babysit git pull/SIGHUP. The default container now fetches hagezi pro.txt at startup, caches it to disk, and refreshes every 24h with conditional GET.
What's new
type: httpblocklist source — fetches lists from any HTTP(S) URL with conditional GET (ETag /If-Modified-Since). Configurable global refresh interval (blocklists.refresh_interval, default 24h).- On-disk persistent cache at
blocklists.cache_dir(default/var/cache/bns/blocklists). Bodies + sidecar metadata written atomically; readers never observe a partial write. Survives restarts. - Bootstrap dialer — fetcher resolves URL hostnames via configured upstream IPs directly, bypassing the system stub. BNS can fetch its own lists even when it's the sole resolver on the LAN.
- Always fail-open — HTTP fetch failures never block DNS service. A
bns_blocklist_last_success_timestamp_seconds{source}gauge supports staleness alerts. - Per-source observability — three new metric families:
bns_blocklist_fetch_total{source,outcome}(success/not_modified/failure)bns_blocklist_last_success_timestamp_seconds{source}bns_blocklist_entries_by_source{source}
- Required
namefield on every blocklist source — used as the{source="..."}label on everybns_blocklist_*metric. Validation rejects missing or duplicate names at startup. - SIGHUP now does both — reload disk state (file-source edits) and trigger an immediate fetcher cycle (HTTP sources).
Breaking changes
--blocklistCLI flag removed. Use YAML (-c file.yaml) for blocklist source configuration. The flag is incompatible with the new requirednamefield; YAML is the canonical declaration form.- Container no longer bakes the hagezi list. Cold start with no cache volume serves DNS without blocking for a few seconds while the first fetch lands. Mount a named volume at
/var/cache/bnsto preserve cache across restarts:docker volume create bns-cache docker run -v bns-cache:/var/cache/bns ghcr.io/bcrisp4/bns:0.3.0
HAGEZI_TAGDockerfile build arg dropped.
Config schema
blocklists:
refresh_interval: 24h
cache_dir: /var/cache/bns/blocklists
sources:
- type: http
name: hagezi-pro
url: https://raw.githubusercontent.com/hagezi/dns-blocklists/main/domains/pro.txt
- type: file
name: custom-blocklist
path: /etc/bns/custom-blocklist.txtContainer images
Multi-arch (linux/amd64, linux/arm64) at ghcr.io/bcrisp4/bns:0.3.0, :0.3, :latest.
Spec + plan
- Design:
docs/specs/2026-05-21-bns-http-blocklist-source-design.md - Implementation plan:
docs/plans/2026-05-21-bns-http-blocklist-source-implementation.md - 16 TDD tasks plus a simplify pass and a code-review-driven fix-up.
v0.2.0
v0.1.0
First MVP release of BNS — caching DNS forwarder with ad-block, intended for small private networks (Raspberry Pi-class hardware).
Features
- UDP + TCP listeners (single shared resolver chain)
- In-memory LRU cache with TTL-aware expiry and negative caching
- Singleflight upstream coalescing
- Blocklist matcher (exact + subdomain wildcard) with hot-reload on SIGHUP
- Prometheus
/metrics,/healthz,/readyz - Configurable upstream pool with primary + fallback
- Multi-arch container image (
linux/amd64,linux/arm64) — see below
Container
Image published at ghcr.io/bcrisp4/bns:0.1.0 (also 0.1 and latest).
docker run -d \
-p 53:5354/udp -p 53:5354/tcp -p 9090:9090 \
--name bns ghcr.io/bcrisp4/bns:0.1.0
Built on gcr.io/distroless/static-debian12:nonroot. Hagezi pro.txt (~459K domains) baked in at /etc/bns/blocklists/pro.txt. Override config via bind-mount, BNS_* env vars, or trailing CLI flags.
Caveats
- Container listens on
:5354(nonroot can't bind privileged ports). Map host:53at run time. - Blocklist refresh requires image rebuild (URL fetcher source not yet implemented).
- DoH/DoT/DoQ upstream not yet implemented — UDP plaintext only.