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
+14
View File
@@ -3,6 +3,20 @@
All notable changes to **felhom-agent** are recorded here. Update on every code All notable changes to **felhom-agent** are recorded here. Update on every code
change that gets pushed. change that gets pushed.
## v0.29.1 — lanresolver: RESTART dnsmasq on change (not reload) — fixes stale split-horizon IP (2026-06-13)
**Bug:** after a guest's DHCP IP moved (e.g. the v0.29.0 9201 re-provision: .151 → .141), the LAN
split-horizon resolver kept answering the OLD IP, so LAN clients (via Pi-hole's conditional forward to
the host dnsmasq) resolved `*.demo-felhom.eu` to the dead IP. Root cause: `lanresolver.Manager` updated
the per-customer drop-in (`address=/<domain>/<ip>`) correctly but then ran `systemctl reload dnsmasq`
(SIGHUP) — and **dnsmasq's SIGHUP does NOT re-read its config files** (`/etc/dnsmasq.d/*.conf`); it only
clears the cache + re-reads `/etc/hosts`/addn-hosts. So the changed `address=` directive never took
effect until a restart. **Fix:** `reload()``restartDnsmasq()` (`systemctl restart dnsmasq`) for every
config-drop-in change (ReconcileGuest IP change, EnsureDnsmasq base change, Remove/decommission). Restart
is sub-second and the records carry local-ttl 0, so downstream forwarders don't cache a stale answer.
(Live: after the fix + a one-time host dnsmasq restart + a Pi-hole cache flush, `*.demo-felhom.eu`
resolves to the live guest IP again; future IP moves now self-heal on the loop's next tick.)
## v0.29.0 — OS / Docker-data storage split: golden + provision (2026-06-13) ## v0.29.0 — OS / Docker-data storage split: golden + provision (2026-06-13)
Phase 1 of the storage-split slice (Phase 2 = felhom-controller v0.58.0 prevention layer). The Phase 1 of the storage-split slice (Phase 2 = felhom-controller v0.58.0 prevention layer). The
+1 -1
View File
@@ -43,7 +43,7 @@ import (
// version is the agent version. Overridable at build time with // version is the agent version. Overridable at build time with
// -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version. // -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version.
var version = "0.29.0" var version = "0.29.1"
func main() { func main() {
var ( var (
+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) 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). // the rest of the agent (direct as root on the demo; sudo + a narrow allowlist in the hardened model).
type Manager struct { type Manager struct {
runner proxmox.Runner runner proxmox.Runner
@@ -113,18 +113,18 @@ func (m *Manager) EnsureDnsmasq(ctx context.Context) error {
if err != nil { if err != nil {
return fmt.Errorf("write base config: %w", err) 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 { if _, _, err := m.runner.Run(ctx, "systemctl", "enable", "--now", "dnsmasq"); err != nil {
return fmt.Errorf("enable dnsmasq: %w", err) return fmt.Errorf("enable dnsmasq: %w", err)
} }
if changed { if changed {
return m.reload(ctx) return m.restartDnsmasq(ctx)
} }
return nil return nil
} }
// ReconcileGuest discovers the guest's live IP + domain and applies the per-customer drop-in (writing // 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. // (logged), never a blank/zero `address=` record. Logs IP transitions.
func (m *Manager) ReconcileGuest(ctx context.Context, vmid int, customerID string) error { func (m *Manager) ReconcileGuest(ctx context.Context, vmid int, customerID string) error {
ip, err := m.discoverGuestIP(ctx, vmid) ip, err := m.discoverGuestIP(ctx, vmid)
@@ -162,7 +162,7 @@ func (m *Manager) ReconcileGuest(ctx context.Context, vmid int, customerID strin
} else { } else {
m.logger.Info("lanresolver: applied split-horizon record", "vmid", vmid, "customer", customerID, "domain", domain, "ip", ip) 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 return err
} }
} }
@@ -172,13 +172,13 @@ func (m *Manager) ReconcileGuest(ctx context.Context, vmid int, customerID strin
return nil 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 { func (m *Manager) Remove(ctx context.Context, customerID string) error {
path := filepath.Join(DropinDir, DropinName(customerID)) path := filepath.Join(DropinDir, DropinName(customerID))
if _, _, err := m.runner.Run(ctx, "rm", "-f", path); err != nil { if _, _, err := m.runner.Run(ctx, "rm", "-f", path); err != nil {
return fmt.Errorf("remove drop-in: %w", err) 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. // 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 "" return ""
} }
func (m *Manager) reload(ctx context.Context) error { // restartDnsmasq applies a changed drop-in by RESTARTING dnsmasq — NOT `systemctl reload` (SIGHUP).
if _, errOut, err := m.runner.Run(ctx, "systemctl", "reload", "dnsmasq"); err != nil { // dnsmasq's SIGHUP only clears the cache + re-reads /etc/hosts & addn-hosts; it does NOT re-read the
return fmt.Errorf("reload dnsmasq: %s: %w", strings.TrimSpace(string(errOut)), err) // 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 return nil
} }