From 77fa5af592302cfeab8ca0deee193afd88da2e59 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sat, 13 Jun 2026 16:56:39 +0200 Subject: [PATCH] v0.29.1: lanresolver restarts dnsmasq on change (SIGHUP doesn't re-read config) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 14 ++++++++++++++ cmd/felhom-agent/main.go | 2 +- internal/lanresolver/lanresolver.go | 27 +++++++++++++++++---------- 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdedc4d..aa0c7b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,20 @@ All notable changes to **felhom-agent** are recorded here. Update on every code 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=//`) 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) Phase 1 of the storage-split slice (Phase 2 = felhom-controller v0.58.0 prevention layer). The diff --git a/cmd/felhom-agent/main.go b/cmd/felhom-agent/main.go index e5ae491..17c69f7 100644 --- a/cmd/felhom-agent/main.go +++ b/cmd/felhom-agent/main.go @@ -43,7 +43,7 @@ import ( // version is the agent version. Overridable at build time with // -ldflags "-X main.version="; defaults to the in-repo CHANGELOG version. -var version = "0.29.0" +var version = "0.29.1" func main() { var ( diff --git a/internal/lanresolver/lanresolver.go b/internal/lanresolver/lanresolver.go index b9949a8..e6e87de 100644 --- a/internal/lanresolver/lanresolver.go +++ b/internal/lanresolver/lanresolver.go @@ -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 -- 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=//` + `address=//`), 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 }