unbound_conf_path Output — Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add an optional [server] unbound_conf_path output that writes per-name local-zone: "<fqdn>." static + grouped local-data directives, a sibling of the shipped dnsmasq_conf_path, to close the HTTPS/type-65 ECH + AAAA DNS leak class on unbound.
Architecture: A new UnboundConfGenerator (mirroring DnsmasqConfGenerator's shape but with an internal name-keyed grouping pass) is wired into the existing three-generator output pipeline (regenerateOutputs, run on startup + every successful mutation + hooks). All new logic lives in new files; the shipped dnsmasqconf.go is untouched. Config gains unbound_conf_path + unbound_ttl; validation becomes a three-way "at least one output" gate.
Tech Stack: Go 1.25, zombiezen.com/go/sqlite store, samber/oops errors, testify (assert/require), stdlib net/sort/strings. Build/test via task. VCS is jj (colocated) — see references/vcs-preamble.md; commit with jj commit -m / jj describe -m, never git commit.
Spec: docs/superpowers/specs/2026-07-07-unbound-conf-path-design.md
Bead: router-hosts-fn5
File Structure¶
| File | Responsibility | Action |
|---|---|---|
internal/config/server.go |
UnboundConfPath + UnboundTTL fields; three-way + non-negative-TTL validation |
Modify (:54-62, :277-298) |
internal/config/server_test.go |
config parse + validation tests; fix stale message assertion | Modify (:85-100, append) |
internal/server/unboundconf.go |
UnboundConfGenerator: grouping, format, atomic write |
Create |
internal/server/unboundconf_test.go |
generator unit tests + service-wiring test | Create |
internal/server/service.go |
unboundGen field, WithUnboundGenerator, regenerateOutputs branch, doc comments |
Modify (:36, :56, :95-121) |
internal/client/commands/serve.go |
construct generator when unbound_conf_path set |
Modify (:88-92) |
docs/reference/configuration.md |
config-table rows, three-way "at least one" line, worked example | Modify (:12-35) |
docs/guides/operations.md |
DNS-output-files section: unbound reload (systemd path unit) + FQDN footgun | Modify (insert before :53) |
examples/server.toml.example |
commented unbound_conf_path / unbound_ttl example block |
Modify (after :20) |
Reused (not modified): atomicWriteFile + formatSuffix (internal/server/hostsfile.go:92,113), storage.Storage.ListAll, domain.HostEntry (internal/domain/host.go:10, single-valued IP).
Task 1: Config — unbound_conf_path + unbound_ttl fields and three-way validation¶
Files:
- Modify:
internal/config/server.go:54-62(fields),internal/config/server.go:281-283(validation) -
Modify/Test:
internal/config/server_test.go:99(fix stale assertion), append new tests -
[ ] Step 1: Write the failing tests
Append to internal/config/server_test.go:
func TestLoadServerConfig_UnboundConfPathOnly(t *testing.T) {
content := `
[server]
bind_address = "0.0.0.0:50051"
hosts_file_path = ""
unbound_conf_path = "/etc/unbound/unbound.conf.d/router-hosts.conf"
[tls]
cert_path = "/cert.pem"
key_path = "/key.pem"
ca_cert_path = "/ca.pem"
`
path := writeConfigFile(t, content)
cfg, err := LoadServerConfig(path)
require.NoError(t, err)
assert.Empty(t, cfg.Server.HostsFilePath)
assert.Equal(t, "/etc/unbound/unbound.conf.d/router-hosts.conf", cfg.Server.UnboundConfPath)
// Unset TTL stays 0 at load; NewUnboundConfGenerator normalizes 0 -> 300.
assert.Equal(t, 0, cfg.Server.UnboundTTL)
}
func TestLoadServerConfig_UnboundTTL(t *testing.T) {
content := `
[server]
bind_address = "0.0.0.0:50051"
unbound_conf_path = "/etc/unbound/router-hosts.conf"
unbound_ttl = 600
[tls]
cert_path = "/cert.pem"
key_path = "/key.pem"
ca_cert_path = "/ca.pem"
`
path := writeConfigFile(t, content)
cfg, err := LoadServerConfig(path)
require.NoError(t, err)
assert.Equal(t, 600, cfg.Server.UnboundTTL)
}
func TestLoadServerConfig_NegativeUnboundTTL(t *testing.T) {
content := `
[server]
bind_address = "0.0.0.0:50051"
unbound_conf_path = "/etc/unbound/router-hosts.conf"
unbound_ttl = -5
[tls]
cert_path = "/cert.pem"
key_path = "/key.pem"
ca_cert_path = "/ca.pem"
`
path := writeConfigFile(t, content)
_, err := LoadServerConfig(path)
require.Error(t, err)
assert.Contains(t, err.Error(), "unbound_ttl must not be negative")
}
And fix the existing stale assertion at internal/config/server_test.go:99 (the message changes in Step 3):
assert.Contains(t, err.Error(), "at least one of hosts_file_path, dnsmasq_conf_path, or unbound_conf_path is required")
- [ ] Step 2: Run tests to verify they fail
Run: go test ./internal/config/ -run 'TestLoadServerConfig_(Unbound|MissingOutputPaths)' -v
Expected: FAIL — cfg.Server.UnboundConfPath/UnboundTTL undefined (compile error), and MissingOutputPaths fails on the new message.
- [ ] Step 3: Add the fields and validation
In internal/config/server.go, extend ServerConfig (after DnsmasqConfPath, :61):
// UnboundConfPath, when set, emits authoritative unbound local-zone/local-data
// directives to a conf-dir file: one `local-zone: "<fqdn>." static` per name plus
// its A/AAAA `local-data`. `static` answers listed types and returns NODATA for
// the rest, so a name's missing record types never leak upstream (closes the
// HTTPS/type-65 ECH + AAAA leak class dnsmasq v2.82 cannot). Additive to
// HostsFilePath/DnsmasqConfPath; at least one of the three must be configured.
// Names are emitted verbatim (trailing-dot normalized) — a bare, non-FQDN alias
// makes unbound authoritative for that pseudo-TLD, so inventories MUST carry
// FQDNs. See GH #349.
UnboundConfPath string `toml:"unbound_conf_path"`
// UnboundTTL is the TTL (seconds) emitted in every local-data line. 0/unset
// defaults to 300; only consulted when UnboundConfPath is set.
UnboundTTL int `toml:"unbound_ttl"`
In validate() replace the two-way check (:281-283) with:
if c.Server.HostsFilePath == "" && c.Server.DnsmasqConfPath == "" && c.Server.UnboundConfPath == "" {
return oops.Code(domain.CodeValidation).Errorf("config: at least one of hosts_file_path, dnsmasq_conf_path, or unbound_conf_path is required")
}
if c.Server.UnboundTTL < 0 {
return oops.Code(domain.CodeValidation).Errorf("config: unbound_ttl must not be negative")
}
- [ ] Step 4: Run tests to verify they pass
Run: go test ./internal/config/ -v
Expected: PASS (all config tests, including the updated MissingOutputPaths).
- [ ] Step 5: Commit
jj commit -m "feat(config): add unbound_conf_path and unbound_ttl options
Three-way 'at least one output' validation; reject negative unbound_ttl.
Refs #349"
Task 2: Generator — UnboundConfGenerator + FormatConf (grouping, format, sort, comments)¶
Files:
- Create:
internal/server/unboundconf.go -
Create/Test:
internal/server/unboundconf_test.go -
[ ] Step 1: Write the failing tests
Create internal/server/unboundconf_test.go:
package server
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"github.com/oklog/ulid/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/fzymgc-house/router-hosts/internal/domain"
"github.com/fzymgc-house/router-hosts/internal/storage/sqlite"
)
func TestUnboundFormatConf_Empty(t *testing.T) {
gen := NewUnboundConfGenerator("/tmp/unbound.conf", 0)
content := gen.FormatConf(nil)
assert.Contains(t, content, "# Generated by router-hosts")
assert.Contains(t, content, "# Entry count: 0")
assert.NotContains(t, content, "local-zone:")
assert.NotContains(t, content, "local-data:")
}
func TestUnboundFormatConf_SingleA(t *testing.T) {
gen := NewUnboundConfGenerator("/tmp/unbound.conf", 0)
entries := []domain.HostEntry{
{ID: ulid.Make(), IP: "10.0.0.5", Hostname: "api.fzymgc.house"},
}
content := gen.FormatConf(entries)
assert.Contains(t, content, "local-zone: \"api.fzymgc.house.\" static\n")
assert.Contains(t, content, "local-data: \"api.fzymgc.house. 300 IN A 10.0.0.5\"\n")
}
func TestUnboundFormatConf_IPv6AAAA(t *testing.T) {
gen := NewUnboundConfGenerator("/tmp/unbound.conf", 0)
entries := []domain.HostEntry{
{ID: ulid.Make(), IP: "fd00::5", Hostname: "api.fzymgc.house"},
}
content := gen.FormatConf(entries)
assert.Contains(t, content, "local-data: \"api.fzymgc.house. 300 IN AAAA fd00::5\"\n")
}
// Dual-stack: two entries, same hostname, v4 + v6 => ONE local-zone, both
// local-data (A before AAAA), deduped-union comments (sorted).
func TestUnboundFormatConf_DualStackGrouping(t *testing.T) {
gen := NewUnboundConfGenerator("/tmp/unbound.conf", 0)
cA := "role=api"
cV6 := "ipv6-managed"
entries := []domain.HostEntry{
{ID: ulid.Make(), IP: "10.0.0.5", Hostname: "api.fzymgc.house", Comment: &cA},
{ID: ulid.Make(), IP: "fd00::5", Hostname: "api.fzymgc.house", Comment: &cV6},
}
content := gen.FormatConf(entries)
assert.Equal(t, 1, strings.Count(content, "local-zone: \"api.fzymgc.house.\" static"))
assert.Contains(t, content, "# ipv6-managed\n# role=api\nlocal-zone: \"api.fzymgc.house.\" static\n"+
"local-data: \"api.fzymgc.house. 300 IN A 10.0.0.5\"\n"+
"local-data: \"api.fzymgc.house. 300 IN AAAA fd00::5\"\n")
}
func TestUnboundFormatConf_Golden(t *testing.T) {
gen := NewUnboundConfGenerator("/tmp/unbound.conf", 0)
cA := "role=api"
cV6 := "ipv6-managed"
entries := []domain.HostEntry{
{ID: ulid.Make(), IP: "10.0.0.5", Hostname: "api.fzymgc.house", Comment: &cA},
{ID: ulid.Make(), IP: "fd00::5", Hostname: "api.fzymgc.house", Comment: &cV6},
{ID: ulid.Make(), IP: "10.0.0.9", Hostname: "db.fzymgc.house", Aliases: []string{"sql.fzymgc.house"}},
}
content := gen.FormatConf(entries)
var kept []string
for _, line := range strings.Split(content, "\n") {
if strings.HasPrefix(line, "# Last updated:") {
continue
}
kept = append(kept, line)
}
got := strings.Join(kept, "\n")
want := "# Generated by router-hosts\n" +
"# Entry count: 3\n" +
"\n" +
"# ipv6-managed\n" +
"# role=api\n" +
"local-zone: \"api.fzymgc.house.\" static\n" +
"local-data: \"api.fzymgc.house. 300 IN A 10.0.0.5\"\n" +
"local-data: \"api.fzymgc.house. 300 IN AAAA fd00::5\"\n" +
"local-zone: \"db.fzymgc.house.\" static\n" +
"local-data: \"db.fzymgc.house. 300 IN A 10.0.0.9\"\n" +
"local-zone: \"sql.fzymgc.house.\" static\n" +
"local-data: \"sql.fzymgc.house. 300 IN A 10.0.0.9\"\n"
assert.Equal(t, want, got)
}
func TestUnboundFormatConf_TrailingDotIdempotent(t *testing.T) {
gen := NewUnboundConfGenerator("/tmp/unbound.conf", 0)
entries := []domain.HostEntry{
{ID: ulid.Make(), IP: "10.0.0.5", Hostname: "foo.fzymgc.house."},
}
content := gen.FormatConf(entries)
assert.Contains(t, content, "local-zone: \"foo.fzymgc.house.\" static\n")
assert.NotContains(t, content, "fzymgc.house..")
}
func TestUnboundFormatConf_BareAliasPseudoTLD(t *testing.T) {
gen := NewUnboundConfGenerator("/tmp/unbound.conf", 0)
entries := []domain.HostEntry{
{ID: ulid.Make(), IP: "10.0.0.5", Hostname: "host.fzymgc.house", Aliases: []string{"host"}},
}
content := gen.FormatConf(entries)
assert.Contains(t, content, "local-zone: \"host.\" static\n")
assert.Contains(t, content, "local-data: \"host. 300 IN A 10.0.0.5\"\n")
}
func TestUnboundFormatConf_RoundRobin(t *testing.T) {
gen := NewUnboundConfGenerator("/tmp/unbound.conf", 0)
entries := []domain.HostEntry{
{ID: ulid.Make(), IP: "10.0.0.2", Hostname: "web.fzymgc.house"},
{ID: ulid.Make(), IP: "10.0.0.1", Hostname: "web.fzymgc.house"},
}
content := gen.FormatConf(entries)
assert.Equal(t, 1, strings.Count(content, "local-zone: \"web.fzymgc.house.\" static"))
assert.Contains(t, content, "local-data: \"web.fzymgc.house. 300 IN A 10.0.0.1\"\n"+
"local-data: \"web.fzymgc.house. 300 IN A 10.0.0.2\"\n")
}
func TestUnboundFormatConf_TTLApplied(t *testing.T) {
gen := NewUnboundConfGenerator("/tmp/unbound.conf", 600)
entries := []domain.HostEntry{
{ID: ulid.Make(), IP: "10.0.0.5", Hostname: "api.fzymgc.house"},
}
assert.Contains(t, gen.FormatConf(entries), "IN A 10.0.0.5\"")
assert.Contains(t, gen.FormatConf(entries), "api.fzymgc.house. 600 IN A 10.0.0.5")
}
func TestUnboundFormatConf_SortedByFQDN(t *testing.T) {
gen := NewUnboundConfGenerator("/tmp/unbound.conf", 0)
entries := []domain.HostEntry{
{ID: ulid.Make(), IP: "10.0.0.3", Hostname: "zeta.fzymgc.house"},
{ID: ulid.Make(), IP: "10.0.0.1", Hostname: "alpha.fzymgc.house"},
}
content := gen.FormatConf(entries)
assert.Less(t,
strings.Index(content, "alpha.fzymgc.house."),
strings.Index(content, "zeta.fzymgc.house."))
}
- [ ] Step 2: Run tests to verify they fail
Run: go test ./internal/server/ -run TestUnboundFormatConf -v
Expected: FAIL — NewUnboundConfGenerator / FormatConf undefined (compile error).
- [ ] Step 3: Write the generator
Create internal/server/unboundconf.go:
package server
import (
"context"
"fmt"
"net"
"sort"
"strings"
"time"
"github.com/samber/oops"
"github.com/fzymgc-house/router-hosts/internal/domain"
"github.com/fzymgc-house/router-hosts/internal/storage"
)
// defaultUnboundTTL is used when the configured TTL is unset or non-positive.
const defaultUnboundTTL = 300
// UnboundConfGenerator writes an unbound conf-dir file of authoritative
// local-zone/local-data directives atomically. For each managed name it emits
// one:
//
// local-zone: "<fqdn>." static
//
// followed by one `local-data` line per address:
//
// local-data: "<fqdn>. <ttl> IN A|AAAA <ip>"
//
// A `static` zone answers the listed record types and returns NODATA for any
// other type at that name (NXDOMAIN for names with no local-data), so a name's
// missing types are never forwarded upstream — closing the HTTPS/type-65 ECH +
// AAAA leak class. Records for one name are grouped across host entries: because
// domain.HostEntry carries a single IP, a dual-stack host is two entries sharing
// a hostname, and both fold into one local-zone. See GH #349.
type UnboundConfGenerator struct {
path string
ttl int
}
// NewUnboundConfGenerator creates a generator writing to path. A ttl <= 0 is
// normalized to defaultUnboundTTL.
func NewUnboundConfGenerator(path string, ttl int) *UnboundConfGenerator {
if ttl <= 0 {
ttl = defaultUnboundTTL
}
return &UnboundConfGenerator{path: path, ttl: ttl}
}
// Regenerate loads all entries, formats them, and atomically writes the result.
// Returns the entry count.
func (g *UnboundConfGenerator) Regenerate(ctx context.Context, store storage.Storage) (int, error) {
entries, err := store.ListAll(ctx)
if err != nil {
return 0, oops.Wrapf(err, "list hosts for unbound regeneration")
}
content := g.FormatConf(entries)
if err := atomicWriteFile(g.path, content); err != nil {
return 0, err
}
return len(entries), nil
}
// unboundName accumulates the addresses and comment lines for one zone name.
type unboundName struct {
ips []string
seenIP map[string]bool
comments []string
seenCmt map[string]bool
}
// FormatConf renders entries as grouped unbound local-zone/local-data blocks,
// one block per unique name (hostname + each alias), sorted by FQDN. Output is a
// pure function of the entry set (records and comments sorted), independent of
// input order, for stable diffs.
func (g *UnboundConfGenerator) FormatConf(entries []domain.HostEntry) string {
var b strings.Builder
now := time.Now().UTC()
fmt.Fprintf(&b, "# Generated by router-hosts\n")
fmt.Fprintf(&b, "# Last updated: %s\n", now.Format("2006-01-02 15:04:05 UTC"))
fmt.Fprintf(&b, "# Entry count: %d\n\n", len(entries))
names := make(map[string]*unboundName)
var order []string
add := func(rawName, ip, suffix string) {
key := normalizeFQDN(rawName)
agg, ok := names[key]
if !ok {
agg = &unboundName{seenIP: map[string]bool{}, seenCmt: map[string]bool{}}
names[key] = agg
order = append(order, key)
}
if !agg.seenIP[ip] {
agg.seenIP[ip] = true
agg.ips = append(agg.ips, ip)
}
if suffix != "" && !agg.seenCmt[suffix] {
agg.seenCmt[suffix] = true
agg.comments = append(agg.comments, suffix)
}
}
for _, e := range entries {
suffix := formatSuffix(e.Comment, e.Tags)
add(e.Hostname, e.IP, suffix)
for _, a := range e.Aliases {
add(a, e.IP, suffix)
}
}
sort.Strings(order)
for _, key := range order {
agg := names[key]
sort.Strings(agg.comments)
for _, c := range agg.comments {
b.WriteString(c)
b.WriteByte('\n')
}
fmt.Fprintf(&b, "local-zone: %q static\n", key)
sort.Slice(agg.ips, func(i, j int) bool {
ti, tj := rrType(agg.ips[i]), rrType(agg.ips[j])
if ti != tj {
return ti == "A" // A before AAAA
}
return agg.ips[i] < agg.ips[j]
})
for _, ip := range agg.ips {
data := fmt.Sprintf("%s %d IN %s %s", key, g.ttl, rrType(ip), ip)
fmt.Fprintf(&b, "local-data: %q\n", data)
}
}
return b.String()
}
// normalizeFQDN strips any trailing dots and appends exactly one, so a name
// stored with or without a trailing dot yields the same zone name.
func normalizeFQDN(name string) string {
return strings.TrimRight(name, ".") + "."
}
// rrType returns "A" for an IPv4 address, "AAAA" otherwise. dnsmasq's address=
// directive is type-agnostic, so the sibling generator needs no such helper;
// unbound's local-data must name the type explicitly.
func rrType(ip string) string {
if net.ParseIP(ip).To4() != nil {
return "A"
}
return "AAAA"
}
- [ ] Step 4: Run tests to verify they pass
Run: go test ./internal/server/ -run TestUnboundFormatConf -v
Expected: PASS (all 10 FormatConf tests).
- [ ] Step 5: Commit
jj commit -m "feat(server): add UnboundConfGenerator FormatConf
Per-name local-zone static + grouped local-data (A/AAAA), grouped across
entries by FQDN, deterministic (sorted names/records/comments).
Refs #349"
Task 3: Generator — Regenerate write path (atomic write, real store)¶
Files:
- Modify/Test:
internal/server/unboundconf_test.go(append) -
Production code already exists (Task 2 wrote
Regenerate); these tests exercise the write path end-to-end. -
[ ] Step 1: Write the failing tests
Append to internal/server/unboundconf_test.go:
func TestUnboundRegenerate(t *testing.T) {
ctx := context.Background()
store, err := sqlite.New(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()), slog.Default())
require.NoError(t, err)
require.NoError(t, store.Initialize(ctx))
t.Cleanup(func() { _ = store.Close() })
handler := NewCommandHandler(store)
_, err = handler.AddHost(ctx, "10.0.0.5", "api.fzymgc.house", nil, []string{"web"}, []string{"sso.fzymgc.house"})
require.NoError(t, err)
dir := t.TempDir()
path := filepath.Join(dir, "router-hosts.conf")
gen := NewUnboundConfGenerator(path, 0)
count, err := gen.Regenerate(ctx, store)
require.NoError(t, err)
assert.Equal(t, 1, count)
data, err := os.ReadFile(path)
require.NoError(t, err)
content := string(data)
assert.Contains(t, content, "local-zone: \"api.fzymgc.house.\" static\n")
assert.Contains(t, content, "local-data: \"api.fzymgc.house. 300 IN A 10.0.0.5\"\n")
assert.Contains(t, content, "local-zone: \"sso.fzymgc.house.\" static\n")
}
func TestUnboundRegenerate_InvalidPath(t *testing.T) {
ctx := context.Background()
store, err := sqlite.New(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()), slog.Default())
require.NoError(t, err)
require.NoError(t, store.Initialize(ctx))
t.Cleanup(func() { _ = store.Close() })
gen := NewUnboundConfGenerator("/nonexistent/dir/router-hosts.conf", 0)
_, err = gen.Regenerate(ctx, store)
require.Error(t, err)
assert.Contains(t, err.Error(), "create temp file")
}
- [ ] Step 2: Run tests to verify they pass
Run: go test ./internal/server/ -run TestUnboundRegenerate -v
Expected: PASS — Regenerate (written in Task 2) writes via the shared atomicWriteFile, and the invalid-path case surfaces the "create temp file" error.
Note: this task has no separate red phase —
Regenerateshipped with Task 2 so the generator file compiles as a unit. If you are executing strictly TDD and want a red phase, temporarily stubRegeneratetopanic("not implemented")after Task 2, confirm these tests fail, then restore. Otherwise proceed.
- [ ] Step 3: Commit
jj commit -m "test(server): cover UnboundConfGenerator.Regenerate write path
Real store round-trip + atomic-write error on unwritable path.
Refs #349"
Task 4: Service wiring — field, option, regenerateOutputs branch, doc comments¶
Files:
- Modify:
internal/server/service.go:36(field),:56(option),:95-121(doc comments + branch) -
Modify/Test:
internal/server/unboundconf_test.go(append wiring test) -
[ ] Step 1: Write the failing test
Append to internal/server/unboundconf_test.go:
func TestRegenerateOutputs_WritesUnbound(t *testing.T) {
ctx := context.Background()
store, err := sqlite.New(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()), slog.Default())
require.NoError(t, err)
require.NoError(t, store.Initialize(ctx))
t.Cleanup(func() { _ = store.Close() })
handler := NewCommandHandler(store)
_, err = handler.AddHost(ctx, "10.0.0.5", "api.fzymgc.house", nil, nil, nil)
require.NoError(t, err)
dir := t.TempDir()
path := filepath.Join(dir, "router-hosts.conf")
svc := NewHostsServiceImpl(handler, store, WithUnboundGenerator(NewUnboundConfGenerator(path, 0)))
svc.RegenerateOutputs(ctx)
data, err := os.ReadFile(path)
require.NoError(t, err)
assert.Contains(t, string(data), "local-zone: \"api.fzymgc.house.\" static\n")
}
- [ ] Step 2: Run test to verify it fails
Run: go test ./internal/server/ -run TestRegenerateOutputs_WritesUnbound -v
Expected: FAIL — WithUnboundGenerator undefined (compile error).
- [ ] Step 3: Wire the generator into the service
In internal/server/service.go, add the field to HostsServiceImpl (after dnsmasqGen, :36):
Add the option (after WithDnsmasqGenerator, :56):
// WithUnboundGenerator sets the unbound conf-dir generator.
func WithUnboundGenerator(gen *UnboundConfGenerator) ServiceOption {
return func(s *HostsServiceImpl) { s.unboundGen = gen }
}
Update the two doc comments to name the third output — RegenerateOutputs (:95-96) and regenerateOutputs (:105-106): change each "hosts file and/or\n// dnsmasq conf" to "hosts file, dnsmasq conf, and/or\n// unbound conf".
Add the branch in regenerateOutputs, after the dnsmasqGen branch (:116-120):
if s.unboundGen != nil {
if _, err := s.unboundGen.Regenerate(ctx, s.store); err != nil {
slog.Error("unbound conf regeneration failed", "op", op, "error", err)
}
}
- [ ] Step 4: Run test to verify it passes
Run: go test ./internal/server/ -run TestRegenerateOutputs_WritesUnbound -v
Expected: PASS.
- [ ] Step 5: Run the full server package + lint
Run: task test then task lint
Expected: PASS (race-clean, no lint findings).
- [ ] Step 6: Commit
jj commit -m "feat(server): wire unbound generator into regenerateOutputs
WithUnboundGenerator option + fail-soft branch (log, never fail the
mutation), matching hosts/dnsmasq. Refresh output doc comments.
Refs #349"
Task 5: CLI wiring — construct the generator in serve¶
Files:
-
Modify:
internal/client/commands/serve.go:88-92 -
[ ] Step 1: Add the wiring block
In internal/client/commands/serve.go, after the dnsmasq block (:88-92):
// unbound conf-dir generator (optional, additive)
if cfg.Server.UnboundConfPath != "" {
unboundGen := server.NewUnboundConfGenerator(cfg.Server.UnboundConfPath, cfg.Server.UnboundTTL)
svcOpts = append(svcOpts, server.WithUnboundGenerator(unboundGen))
}
Startup regeneration is already covered by the existing svc.RegenerateOutputs(ctx) (serve.go:168); per-mutation regeneration and hooks run through the same regenerateOutputs path wired in Task 4. No further changes.
- [ ] Step 2: Build
Run: task build
Expected: PASS (compiles). This wiring mirrors the dnsmasq block exactly and, like it, has no serve-command unit test; it is exercised by the Task 4 service test plus the smoke check below.
- [ ] Step 3: Smoke-test the write path
Run (from repo root, adjust binary path from task build output):
tmp=$(mktemp -d)
cat > "$tmp/server.toml" <<EOF
[server]
bind_address = "127.0.0.1:0"
unbound_conf_path = "$tmp/router-hosts.conf"
[database]
path = "$tmp/hosts.db"
[tls]
cert_path = "$tmp/c"
key_path = "$tmp/k"
ca_cert_path = "$tmp/ca"
EOF
Then confirm config loads and validates (the leak-class value here is that an unbound-only config is now accepted):
Run: go test ./internal/config/ -run TestLoadServerConfig_UnboundConfPathOnly -v
Expected: PASS — verifies the end-to-end config path that serve consumes. (A full serve boot needs real mTLS material; the config + service tests already cover the generator's runtime behavior, so a live boot is optional.)
- [ ] Step 4: Commit
jj commit -m "feat(server): construct unbound generator from serve config
Wire unbound_conf_path/unbound_ttl into the serve command, mirroring the
dnsmasq_conf_path block. Refs #349"
Task 6: Documentation — configuration.md (rows, three-way line, example)¶
Files:
-
Modify:
docs/reference/configuration.md:12-35 -
[ ] Step 1: Update the config table and validation line
In docs/reference/configuration.md, add a row after the dnsmasq_conf_path row (:13):
| `unbound_conf_path` | path | - | Output unbound conf-dir file of `local-zone`/`local-data` directives (additive) |
| `unbound_ttl` | int | `300` | TTL (seconds) for `unbound_conf_path` `local-data` records |
Change the "at least one" line (:15) to:
- [ ] Step 2: Add a worked example + footgun note
After the existing dnsmasq example block (:17-35), add:
When `unbound_conf_path` is configured, router-hosts writes, per managed name,
one `local-zone: "<fqdn>." static` plus its `local-data` records:
```
local-zone: "api.fzymgc.house." static
local-data: "api.fzymgc.house. 300 IN A 10.0.0.5"
local-data: "api.fzymgc.house. 300 IN AAAA fd00::5"
```
`static` answers the listed record types and returns NODATA for any other type
at that name, so a name's missing types (e.g. AAAA, HTTPS/type-65) are never
forwarded upstream. Names are emitted **verbatim** (trailing-dot normalized): a
bare, non-FQDN alias (e.g. `api`) becomes `local-zone: "api." static`, making
unbound authoritative for that entire pseudo-TLD — inventories MUST carry FQDNs.
Reload is out of scope: point a host-side systemd path unit at the conf
directory to reload unbound on write.
- [ ] Step 3: Add the operations-guide DNS-output section (spec-committed footgun)
In docs/guides/operations.md, insert a new section immediately before ## Certificate Reload via SIGHUP (:53):
## DNS Output Files
Beyond the hosts file, the server can emit authoritative DNS config — one
generator per configured `[server]` path, all rewritten atomically on startup
and after every successful mutation, and observed by `on_success`/`on_failure`
hooks:
- `hosts_file_path` — hosts(5) file.
- `dnsmasq_conf_path` — dnsmasq `local=`/`address=` pairs.
- `unbound_conf_path` — unbound `local-zone: "<fqdn>." static` + `local-data`.
### unbound reload
router-hosts writes `unbound_conf_path` atomically but does **not** reload
unbound. Point a host-side systemd path unit at the conf directory so unbound
reloads on write:
```ini
# /etc/systemd/system/unbound-reload.path
[Path]
PathModified=/etc/unbound/unbound.conf.d
[Install]
WantedBy=multi-user.target
```
with a companion `unbound-reload.service` that runs `unbound-control reload`.
### FQDN footgun
`unbound_conf_path` emits every hostname and alias **verbatim** (trailing-dot
normalized). A bare, non-FQDN alias such as `api` becomes
`local-zone: "api." static`, making unbound authoritative for that entire
pseudo-TLD and returning NXDOMAIN for everything under it. Inventories MUST carry
FQDNs.
- [ ] Step 4: Add the
server.toml.exampleunbound block (parity with dnsmasq)
In examples/server.toml.example, after the commented dnsmasq_conf_path line (:20), insert:
# Optional: also emit an unbound conf-dir file of authoritative local-zone/
# local-data directives (one `local-zone: "<fqdn>." static` plus A/AAAA
# local-data per name). `static` answers listed types and returns NODATA for the
# rest, so a name's missing types (AAAA, HTTPS/type-65 ECH) never leak upstream —
# even where dnsmasq cannot suppress unknown RR types. Names are emitted
# verbatim, so inventories must carry FQDNs. Additive; at least one output is
# required. unbound_ttl defaults to 300. See GH #349.
# unbound_conf_path = "/etc/unbound/unbound.conf.d/router-hosts.conf"
# unbound_ttl = 300
- [ ] Step 5: Lint the docs
Run: rumdl check docs/reference/configuration.md docs/guides/operations.md
Expected: PASS (no markdown lint findings). Note: rumdl runs via the lefthook pre-commit hook (lefthook.yaml), not task lint, so invoke it directly here. examples/server.toml.example is TOML — validated by build/config parsing, not rumdl.
- [ ] Step 6: Commit
jj commit -m "docs(config): document unbound_conf_path and unbound_ttl
configuration.md table rows + three-way validation line + worked example;
operations.md DNS-output section (unbound reload + FQDN footgun);
server.toml.example commented block.
Closes #349"
Final verification¶
- [ ] Run the full local CI pipeline
Run: task ci
Expected: PASS — lint (golangci-lint + buf), race tests, build, coverage ≥ 80%.
- [ ] Confirm spec coverage
Every spec section maps to a task: config (T1), generator format + grouping (T2), write path (T3), service wiring + fail-soft (T4), CLI wiring (T5), docs surfaces — configuration.md + operations.md (FQDN footgun, spec-committed) + server.toml.example (T6). Reload, PTR, FQDN-enforcement remain out of scope per the spec.