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:
@@ -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
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user