From b7f6d1fe1bb325bc5db8fd929021666990297bb9 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Sun, 22 Mar 2026 15:07:22 -0300 Subject: [PATCH] Route .vm DNS through systemd-resolved Banger was already serving VM records on 127.0.0.1:42069, but hosts using systemd-resolved were not routing .vm queries there. That made direct lookups against the local server work while normal host resolution and commands like opencode attach .vm:4096 failed.\n\nSync resolvectl dns/domain/default-route settings onto the banger bridge when the daemon opens and whenever VM DNS records are published, and revert that bridge-scoped configuration on daemon shutdown. This uses sudo resolvectl because unprivileged resolved reconfiguration on this host requires interactive authentication.\n\nValidation: GOCACHE=/tmp/banger-gocache go test ./..., make build, daemon restart, resolvectl dns/domain br-fc, resolvectl query vrum.vm, and curl http://vrum.vm:4096. --- internal/daemon/daemon.go | 3 +- internal/daemon/dns_routing.go | 63 ++++++++++++++++++++++++ internal/daemon/dns_routing_test.go | 75 +++++++++++++++++++++++++++++ internal/daemon/vm.go | 6 ++- 4 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 internal/daemon/dns_routing.go create mode 100644 internal/daemon/dns_routing_test.go diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index e142fd3..31f40ef 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -98,6 +98,7 @@ func Open(ctx context.Context) (d *Daemon, err error) { d.logger.Error("daemon open failed", "stage", "reconcile", "error", err.Error()) return nil, err } + d.ensureVMDNSResolverRouting(ctx) if err = d.initializeTapPool(ctx); err != nil { d.logger.Error("daemon open failed", "stage", "initialize_tap_pool", "error", err.Error()) return nil, err @@ -122,7 +123,7 @@ func (d *Daemon) Close() error { if d.webListener != nil { _ = d.webListener.Close() } - err = errors.Join(d.stopVMDNS(), d.store.Close()) + err = errors.Join(d.clearVMDNSResolverRouting(context.Background()), d.stopVMDNS(), d.store.Close()) }) return err } diff --git a/internal/daemon/dns_routing.go b/internal/daemon/dns_routing.go new file mode 100644 index 0000000..0b9a14e --- /dev/null +++ b/internal/daemon/dns_routing.go @@ -0,0 +1,63 @@ +package daemon + +import ( + "context" + "strings" + + "banger/internal/system" + "banger/internal/vmdns" +) + +const vmResolverRouteDomain = "~vm" + +var ( + lookupExecutableFunc = system.LookupExecutable + vmDNSAddrFunc = func(server *vmdns.Server) string { return server.Addr() } +) + +func (d *Daemon) syncVMDNSResolverRouting(ctx context.Context) error { + if d == nil || d.vmDNS == nil { + return nil + } + if strings.TrimSpace(d.config.BridgeName) == "" { + return nil + } + if _, err := lookupExecutableFunc("resolvectl"); err != nil { + return nil + } + if _, err := d.runner.Run(ctx, "ip", "link", "show", d.config.BridgeName); err != nil { + return nil + } + serverAddr := strings.TrimSpace(vmDNSAddrFunc(d.vmDNS)) + if serverAddr == "" { + return nil + } + if _, err := d.runner.RunSudo(ctx, "resolvectl", "dns", d.config.BridgeName, serverAddr); err != nil { + return err + } + if _, err := d.runner.RunSudo(ctx, "resolvectl", "domain", d.config.BridgeName, vmResolverRouteDomain); err != nil { + return err + } + _, err := d.runner.RunSudo(ctx, "resolvectl", "default-route", d.config.BridgeName, "no") + return err +} + +func (d *Daemon) clearVMDNSResolverRouting(ctx context.Context) error { + if d == nil || strings.TrimSpace(d.config.BridgeName) == "" { + return nil + } + if _, err := lookupExecutableFunc("resolvectl"); err != nil { + return nil + } + if _, err := d.runner.Run(ctx, "ip", "link", "show", d.config.BridgeName); err != nil { + return nil + } + _, err := d.runner.RunSudo(ctx, "resolvectl", "revert", d.config.BridgeName) + return err +} + +func (d *Daemon) ensureVMDNSResolverRouting(ctx context.Context) { + if err := d.syncVMDNSResolverRouting(ctx); err != nil && d.logger != nil { + d.logger.Warn("vm dns resolver route sync failed", "bridge", d.config.BridgeName, "error", err.Error()) + } +} diff --git a/internal/daemon/dns_routing_test.go b/internal/daemon/dns_routing_test.go new file mode 100644 index 0000000..1bd8f6c --- /dev/null +++ b/internal/daemon/dns_routing_test.go @@ -0,0 +1,75 @@ +package daemon + +import ( + "context" + "testing" + + "banger/internal/model" + "banger/internal/vmdns" +) + +func TestSyncVMDNSResolverRoutingConfiguresResolved(t *testing.T) { + origLookup := lookupExecutableFunc + origAddr := vmDNSAddrFunc + t.Cleanup(func() { + lookupExecutableFunc = origLookup + vmDNSAddrFunc = origAddr + }) + lookupExecutableFunc = func(name string) (string, error) { + if name == "resolvectl" { + return "/usr/bin/resolvectl", nil + } + return "", nil + } + vmDNSAddrFunc = func(*vmdns.Server) string { return "127.0.0.1:42069" } + + runner := &scriptedRunner{ + t: t, + steps: []runnerStep{ + {call: runnerCall{name: "ip", args: []string{"link", "show", model.DefaultBridgeName}}, out: []byte("1: br-fc\n")}, + sudoStep("", nil, "resolvectl", "dns", model.DefaultBridgeName, "127.0.0.1:42069"), + sudoStep("", nil, "resolvectl", "domain", model.DefaultBridgeName, vmResolverRouteDomain), + sudoStep("", nil, "resolvectl", "default-route", model.DefaultBridgeName, "no"), + }, + } + d := &Daemon{ + runner: runner, + config: model.DaemonConfig{BridgeName: model.DefaultBridgeName}, + vmDNS: new(vmdns.Server), + } + + if err := d.syncVMDNSResolverRouting(context.Background()); err != nil { + t.Fatalf("syncVMDNSResolverRouting: %v", err) + } + runner.assertExhausted() +} + +func TestClearVMDNSResolverRoutingRevertsBridgeConfig(t *testing.T) { + origLookup := lookupExecutableFunc + t.Cleanup(func() { + lookupExecutableFunc = origLookup + }) + lookupExecutableFunc = func(name string) (string, error) { + if name == "resolvectl" { + return "/usr/bin/resolvectl", nil + } + return "", nil + } + + runner := &scriptedRunner{ + t: t, + steps: []runnerStep{ + {call: runnerCall{name: "ip", args: []string{"link", "show", model.DefaultBridgeName}}, out: []byte("1: br-fc\n")}, + sudoStep("", nil, "resolvectl", "revert", model.DefaultBridgeName), + }, + } + d := &Daemon{ + runner: runner, + config: model.DaemonConfig{BridgeName: model.DefaultBridgeName}, + } + + if err := d.clearVMDNSResolverRouting(context.Background()); err != nil { + t.Fatalf("clearVMDNSResolverRouting: %v", err) + } + runner.assertExhausted() +} diff --git a/internal/daemon/vm.go b/internal/daemon/vm.go index afb34ad..b4c90e3 100644 --- a/internal/daemon/vm.go +++ b/internal/daemon/vm.go @@ -1271,7 +1271,11 @@ func (d *Daemon) setDNS(ctx context.Context, vmName, guestIP string) error { if d.vmDNS == nil { return nil } - return d.vmDNS.Set(vmdns.RecordName(vmName), guestIP) + if err := d.vmDNS.Set(vmdns.RecordName(vmName), guestIP); err != nil { + return err + } + d.ensureVMDNSResolverRouting(ctx) + return nil } func (d *Daemon) removeDNS(ctx context.Context, dnsName string) error {