v0.29.1: lanresolver restarts dnsmasq on change (SIGHUP doesn't re-read config)

After a guest DHCP IP move, the split-horizon resolver kept serving the old IP:
the drop-in (address=/domain/ip) updated but 'systemctl reload dnsmasq' (SIGHUP)
does NOT re-read /etc/dnsmasq.d config — only /etc/hosts + cache. Changed
reload() -> restartDnsmasq() so address= changes actually take effect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-13 16:56:39 +02:00
parent 4d30444914
commit 77fa5af592
3 changed files with 32 additions and 11 deletions
+17 -10
View File
@@ -75,7 +75,7 @@ func sanitize(s string) string {
return strings.Trim(s, "-.") // also strip leading/trailing dots (no "..", no path-ish names)
}
// Manager renders + applies the resolver config and reloads dnsmasq. It uses the same fenced Runner as
// Manager renders + applies the resolver config and restarts dnsmasq (config drop-ins need a restart, not SIGHUP). It uses the same fenced Runner as
// the rest of the agent (direct as root on the demo; sudo + a narrow allowlist in the hardened model).
type Manager struct {
runner proxmox.Runner
@@ -113,18 +113,18 @@ func (m *Manager) EnsureDnsmasq(ctx context.Context) error {
if err != nil {
return fmt.Errorf("write base config: %w", err)
}
// enable + start (idempotent); reload if the base changed.
// enable + start (idempotent); restart if the base changed (SIGHUP wont re-read the config).
if _, _, err := m.runner.Run(ctx, "systemctl", "enable", "--now", "dnsmasq"); err != nil {
return fmt.Errorf("enable dnsmasq: %w", err)
}
if changed {
return m.reload(ctx)
return m.restartDnsmasq(ctx)
}
return nil
}
// ReconcileGuest discovers the guest's live IP + domain and applies the per-customer drop-in (writing
// + reloading only on change). It tolerates the early-boot pre-lease window: an empty IP is a no-op
// + restarting only on change). It tolerates the early-boot pre-lease window: an empty IP is a no-op
// (logged), never a blank/zero `address=` record. Logs IP transitions.
func (m *Manager) ReconcileGuest(ctx context.Context, vmid int, customerID string) error {
ip, err := m.discoverGuestIP(ctx, vmid)
@@ -162,7 +162,7 @@ func (m *Manager) ReconcileGuest(ctx context.Context, vmid int, customerID strin
} else {
m.logger.Info("lanresolver: applied split-horizon record", "vmid", vmid, "customer", customerID, "domain", domain, "ip", ip)
}
if err := m.reload(ctx); err != nil {
if err := m.restartDnsmasq(ctx); err != nil {
return err
}
}
@@ -172,13 +172,13 @@ func (m *Manager) ReconcileGuest(ctx context.Context, vmid int, customerID strin
return nil
}
// Remove deletes a customer's drop-in (decommission) and reloads.
// Remove deletes a customer's drop-in (decommission) and restarts dnsmasq.
func (m *Manager) Remove(ctx context.Context, customerID string) error {
path := filepath.Join(DropinDir, DropinName(customerID))
if _, _, err := m.runner.Run(ctx, "rm", "-f", path); err != nil {
return fmt.Errorf("remove drop-in: %w", err)
}
return m.reload(ctx)
return m.restartDnsmasq(ctx)
}
// discoverGuestIP runs `pct exec <vmid> -- ip -4 -o addr show dev eth0` and parses the inet address.
@@ -231,9 +231,16 @@ func parseDomain(s string) string {
return ""
}
func (m *Manager) reload(ctx context.Context) error {
if _, errOut, err := m.runner.Run(ctx, "systemctl", "reload", "dnsmasq"); err != nil {
return fmt.Errorf("reload dnsmasq: %s: %w", strings.TrimSpace(string(errOut)), err)
// restartDnsmasq applies a changed drop-in by RESTARTING dnsmasq — NOT `systemctl reload` (SIGHUP).
// dnsmasq's SIGHUP only clears the cache + re-reads /etc/hosts & addn-hosts; it does NOT re-read the
// configuration files in /etc/dnsmasq.d. Our split-horizon records are CONFIG directives
// (`local=/<domain>/` + `address=/<domain>/<ip>`), so a reloaded dnsmasq keeps serving the IP it read
// at startup — exactly the stale-IP bug seen after a guest's DHCP IP moved (the file updated, but
// `reload` left the old `address=` live). A restart re-reads the drop-ins. dnsmasq restart is sub-second
// and the records carry local-ttl 0, so downstream forwarders (Pi-hole) don't cache a stale answer.
func (m *Manager) restartDnsmasq(ctx context.Context) error {
if _, errOut, err := m.runner.Run(ctx, "systemctl", "restart", "dnsmasq"); err != nil {
return fmt.Errorf("restart dnsmasq: %s: %w", strings.TrimSpace(string(errOut)), err)
}
return nil
}