77fa5af592
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>
271 lines
11 KiB
Go
271 lines
11 KiB
Go
// Package lanresolver manages a host-level split-horizon DNS resolver (dnsmasq) so LAN clients reach
|
|
// the customer's guest DIRECTLY (not hairpinning through Cloudflare) at the SAME public hostname with
|
|
// the SAME real wildcard cert. The agent is the natural owner: it runs on the host (the stable LAN
|
|
// anchor — static IP, unlike the DHCP/ephemeral guest), and it can read the guest's live IP + domain.
|
|
//
|
|
// Mechanism (proven in the slice spike): dnsmasq answers `*.<customer-domain>` with the guest's live
|
|
// LAN IP and returns NODATA for AAAA (the guest has only link-local v6 — no Cloudflare-AAAA leak),
|
|
// forwarding everything else upstream. Two pieces of config:
|
|
// - a BASE drop-in (felhom-resolver-base.conf): bind to the host LAN IP, no-resolv, upstreams.
|
|
// - a per-customer drop-in (felhom-<customer-id>.conf): `local=/<domain>/` + `address=/<domain>/<ip>`.
|
|
//
|
|
// `local=/<domain>/` (trailing slash, no server) makes dnsmasq AUTHORITATIVE for the zone → AAAA
|
|
// returns NODATA instead of forwarding; `address=/<domain>/<ip>` is the wildcard A. Non-customer names
|
|
// (and their AAAA) still forward normally, so the resolver can serve as the LAN's general resolver.
|
|
package lanresolver
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
|
)
|
|
|
|
const (
|
|
// DropinDir is where dnsmasq reads conf-dir drop-ins (Debian default).
|
|
DropinDir = "/etc/dnsmasq.d"
|
|
// BaseDropin holds the host-wide listen/upstream config (one per host).
|
|
BaseDropin = "felhom-resolver-base.conf"
|
|
)
|
|
|
|
// RenderBase returns the host-wide dnsmasq drop-in: bind to the host LAN IP (+ loopback), no-resolv,
|
|
// and the explicit upstreams everything-else forwards to. Kept separate from the per-customer
|
|
// drop-ins so the global directives aren't duplicated across guests on a multi-guest host.
|
|
func RenderBase(hostIP string, upstreams []string) string {
|
|
var b strings.Builder
|
|
b.WriteString("# felhom split-horizon resolver — host base config (agent-managed; DO NOT EDIT)\n")
|
|
b.WriteString("# Bound to the host LAN IP so LAN clients (or the LAN's forwarder) reach it here.\n")
|
|
b.WriteString("bind-interfaces\n")
|
|
fmt.Fprintf(&b, "listen-address=%s\n", hostIP)
|
|
b.WriteString("listen-address=127.0.0.1\n")
|
|
b.WriteString("no-resolv\n")
|
|
for _, up := range upstreams {
|
|
fmt.Fprintf(&b, "server=%s\n", up)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// RenderGuestDropin returns the per-customer split-horizon drop-in. `local=` makes dnsmasq
|
|
// authoritative for the zone (→ AAAA NODATA, no upstream leak); `address=` is the wildcard A → guest IP.
|
|
func RenderGuestDropin(customerID, domain, guestIP string) string {
|
|
return fmt.Sprintf(`# felhom split-horizon DNS — customer %s (agent-managed; DO NOT EDIT)
|
|
# Answer *.%s with the guest's live LAN IP; AAAA → NODATA; everything else forwards upstream.
|
|
local=/%s/
|
|
address=/%s/%s
|
|
`, customerID, domain, domain, domain, guestIP)
|
|
}
|
|
|
|
// DropinName is the per-customer drop-in filename (sanitized customer id).
|
|
func DropinName(customerID string) string {
|
|
return "felhom-" + sanitize(customerID) + ".conf"
|
|
}
|
|
|
|
var unsafeName = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
|
|
|
|
func sanitize(s string) string {
|
|
s = strings.ToLower(strings.TrimSpace(s))
|
|
s = unsafeName.ReplaceAllString(s, "-")
|
|
return strings.Trim(s, "-.") // also strip leading/trailing dots (no "..", no path-ish names)
|
|
}
|
|
|
|
// 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
|
|
hostIP string
|
|
upstreams []string
|
|
logger *slog.Logger
|
|
|
|
mu sync.Mutex
|
|
lastIP map[int]string // vmid → last applied guest IP (for transition logging + change detection)
|
|
}
|
|
|
|
// NewManager builds a Manager. hostIP is the host LAN IP dnsmasq binds to (the stable anchor);
|
|
// upstreams are the forward targets for non-customer names (defaults applied by the caller).
|
|
func NewManager(runner proxmox.Runner, hostIP string, upstreams []string, logger *slog.Logger) *Manager {
|
|
return &Manager{
|
|
runner: runner,
|
|
hostIP: hostIP,
|
|
upstreams: upstreams,
|
|
logger: logger,
|
|
lastIP: map[int]string{},
|
|
}
|
|
}
|
|
|
|
// EnsureDnsmasq makes dnsmasq present + enabled and writes the host base config. Idempotent: it
|
|
// installs the package only when absent, and writes the base drop-in only when its content changes.
|
|
func (m *Manager) EnsureDnsmasq(ctx context.Context) error {
|
|
if _, err := os.Stat("/usr/sbin/dnsmasq"); err != nil { // metadata read, no privilege needed
|
|
m.logger.Info("lanresolver: dnsmasq absent — installing")
|
|
if out, errOut, ierr := m.runner.Run(ctx, "apt-get", "install", "-y", "-q", "dnsmasq"); ierr != nil {
|
|
return fmt.Errorf("install dnsmasq: %s: %w", strings.TrimSpace(string(errOut))+string(out), ierr)
|
|
}
|
|
}
|
|
base := RenderBase(m.hostIP, m.upstreams)
|
|
changed, err := m.writeFileIfChanged(ctx, filepath.Join(DropinDir, BaseDropin), base, "0644")
|
|
if err != nil {
|
|
return fmt.Errorf("write base config: %w", err)
|
|
}
|
|
// 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.restartDnsmasq(ctx)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReconcileGuest discovers the guest's live IP + domain and applies the per-customer drop-in (writing
|
|
// + 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)
|
|
if err != nil {
|
|
return fmt.Errorf("discover guest %d IP: %w", vmid, err)
|
|
}
|
|
if ip == "" {
|
|
m.logger.Info("lanresolver: guest IP not yet leased — skipping (will retry)", "vmid", vmid)
|
|
return nil
|
|
}
|
|
domain, err := m.discoverDomain(ctx, vmid)
|
|
if err != nil {
|
|
return fmt.Errorf("discover guest %d domain: %w", vmid, err)
|
|
}
|
|
if domain == "" {
|
|
m.logger.Info("lanresolver: guest domain not available yet — skipping (will retry)", "vmid", vmid)
|
|
return nil
|
|
}
|
|
if customerID == "" {
|
|
customerID = "guest-" + strconv.Itoa(vmid)
|
|
}
|
|
|
|
m.mu.Lock()
|
|
prev := m.lastIP[vmid]
|
|
m.mu.Unlock()
|
|
|
|
dropin := RenderGuestDropin(customerID, domain, ip)
|
|
changed, err := m.writeFileIfChanged(ctx, filepath.Join(DropinDir, DropinName(customerID)), dropin, "0644")
|
|
if err != nil {
|
|
return fmt.Errorf("write guest %d drop-in: %w", vmid, err)
|
|
}
|
|
if changed {
|
|
if prev != "" && prev != ip {
|
|
m.logger.Info("lanresolver: guest IP changed — updating resolver", "vmid", vmid, "customer", customerID, "domain", domain, "from", prev, "to", ip)
|
|
} else {
|
|
m.logger.Info("lanresolver: applied split-horizon record", "vmid", vmid, "customer", customerID, "domain", domain, "ip", ip)
|
|
}
|
|
if err := m.restartDnsmasq(ctx); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
m.mu.Lock()
|
|
m.lastIP[vmid] = ip
|
|
m.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// 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.restartDnsmasq(ctx)
|
|
}
|
|
|
|
// discoverGuestIP runs `pct exec <vmid> -- ip -4 -o addr show dev eth0` and parses the inet address.
|
|
// Returns "" (no error) when no IPv4 is leased yet — the agent's IP tracking must tolerate this.
|
|
func (m *Manager) discoverGuestIP(ctx context.Context, vmid int) (string, error) {
|
|
out, errOut, err := m.runner.Run(ctx, "pct", "exec", strconv.Itoa(vmid), "--", "ip", "-4", "-o", "addr", "show", "dev", "eth0")
|
|
if err != nil {
|
|
// A stopped/early-boot guest can fail here; treat as "not ready" rather than a hard error
|
|
// only if stderr looks like a transient state. Otherwise surface it.
|
|
es := strings.TrimSpace(string(errOut))
|
|
if strings.Contains(es, "not running") || strings.Contains(es, "Configuration file") {
|
|
return "", nil
|
|
}
|
|
return "", fmt.Errorf("pct exec ip: %s: %w", es, err)
|
|
}
|
|
return parseInet(string(out)), nil
|
|
}
|
|
|
|
var inetRe = regexp.MustCompile(`inet\s+(\d{1,3}(?:\.\d{1,3}){3})/\d+`)
|
|
|
|
func parseInet(s string) string {
|
|
if mtch := inetRe.FindStringSubmatch(s); mtch != nil {
|
|
return mtch[1]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// discoverDomain reads customer.domain from the guest controller's pulled controller.yaml.
|
|
// (The v2 bootstrap.json deliberately omits the domain — it comes from the hub pull — so the agent
|
|
// reads it back from the running controller; no new credential.)
|
|
func (m *Manager) discoverDomain(ctx context.Context, vmid int) (string, error) {
|
|
out, errOut, err := m.runner.Run(ctx, "pct", "exec", strconv.Itoa(vmid), "--",
|
|
"docker", "exec", "felhom-controller", "cat", "/opt/docker/felhom-controller/controller.yaml")
|
|
if err != nil {
|
|
es := strings.TrimSpace(string(errOut))
|
|
if strings.Contains(es, "not running") || strings.Contains(es, "No such container") || strings.Contains(es, "Configuration file") {
|
|
return "", nil // controller not up yet — retry later
|
|
}
|
|
return "", fmt.Errorf("pct exec cat controller.yaml: %s: %w", es, err)
|
|
}
|
|
return parseDomain(string(out)), nil
|
|
}
|
|
|
|
var domainRe = regexp.MustCompile(`(?m)^[ \t]*domain:[ \t]*"?([A-Za-z0-9.-]+)"?`)
|
|
|
|
func parseDomain(s string) string {
|
|
if mtch := domainRe.FindStringSubmatch(s); mtch != nil {
|
|
return mtch[1]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// writeFileIfChanged writes content to path (mode) only when the current content differs, returning
|
|
// whether it changed. It writes via a temp file + `install` so it works both as root (direct) and via
|
|
// the sudo allowlist (install runs privileged); the file is world-readable so the compare reads directly.
|
|
func (m *Manager) writeFileIfChanged(ctx context.Context, path, content, mode string) (bool, error) {
|
|
if cur, err := os.ReadFile(path); err == nil && string(cur) == content {
|
|
return false, nil
|
|
}
|
|
tmp, err := os.CreateTemp("", "felhom-resolver-*.conf")
|
|
if err != nil {
|
|
return false, fmt.Errorf("temp file: %w", err)
|
|
}
|
|
tmpName := tmp.Name()
|
|
defer os.Remove(tmpName)
|
|
if _, err := tmp.WriteString(content); err != nil {
|
|
tmp.Close()
|
|
return false, fmt.Errorf("write temp: %w", err)
|
|
}
|
|
tmp.Close()
|
|
if _, errOut, err := m.runner.Run(ctx, "install", "-m", mode, tmpName, path); err != nil {
|
|
return false, fmt.Errorf("install %s: %s: %w", path, strings.TrimSpace(string(errOut)), err)
|
|
}
|
|
return true, nil
|
|
}
|