v0.21.0: agent-managed split-horizon LAN resolver (internal/lanresolver)

Host-side dnsmasq the agent manages so LAN clients reach their guest directly
(same hostname + real wildcard cert, no Cloudflare hairpin). Renders local=/
+address=/ per customer (AAAA->NODATA via authoritative zone, wildcard A ->
live guest IP), forwards everything else. Manager ensures dnsmasq+base config,
discovers guest IP (pct exec ip) + domain (controller.yaml), write-if-changed +
reload. Loop (7th daemon goroutine) tracks DHCP IP changes per provisioned
guest. --selftest=lanresolver. FELHOM_DNSMASQ sudoers. Spiked live on felhom-pve.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 18:24:58 +02:00
parent 621a09a1c5
commit a43e9813ad
7 changed files with 642 additions and 10 deletions
+73 -6
View File
@@ -30,6 +30,7 @@ import (
"gitea.dooplex.hu/admin/felhom-agent/internal/desired"
"gitea.dooplex.hu/admin/felhom-agent/internal/escrow"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
"gitea.dooplex.hu/admin/felhom-agent/internal/lanresolver"
"gitea.dooplex.hu/admin/felhom-agent/internal/localapi"
applog "gitea.dooplex.hu/admin/felhom-agent/internal/log"
"gitea.dooplex.hu/admin/felhom-agent/internal/pbs"
@@ -42,7 +43,7 @@ import (
// version is the agent version. Overridable at build time with
// -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version.
var version = "0.20.0"
var version = "0.21.0"
func main() {
var (
@@ -129,6 +130,8 @@ func main() {
os.Exit(runSelftestRestoreTest(context.Background(), cfg, logger, archive))
case "pbs-verify":
os.Exit(runSelftestPBSVerify(context.Background(), cfg, logger))
case "lanresolver":
os.Exit(runSelftestLANResolver(context.Background(), cfg, logger, vmid))
case "bring-up":
os.Exit(runSelftestBringUp(context.Background(), cfg, logger, mode, archive, vmid, hostname, keep))
case "provision":
@@ -383,10 +386,32 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int {
defer localTokens.Close()
}
// LAN split-horizon resolver: the agent manages a host-side dnsmasq answering *.<customer-domain>
// with each guest's live LAN IP (so LAN clients reach the box directly — same hostname, same real
// wildcard cert — instead of hairpinning through Cloudflare). Optional; runs only when
// lan_resolver.enable is set and a host LAN IP is known (explicit or derived from local_api).
lanServers := 0
var lanLoop *lanresolver.Loop
{
lr := cfg.LANResolver.WithDefaults(cfg.LocalAPI.ListenAddr)
if lr.Enabled() && lr.HostIP != "" {
lrMode := proxmox.RunnerMode(cfg.Privileged.Mode)
if lrMode == "" {
lrMode = proxmox.RunnerSudo
}
lrRunner := &proxmox.ExecRunner{Mode: lrMode, SudoPath: cfg.Privileged.SudoPath}
mgr := lanresolver.NewManager(lrRunner, lr.HostIP, lr.Upstreams, logger)
lanLoop = lanresolver.NewLoop(mgr, lr.StateDir, time.Duration(lr.IntervalSeconds)*time.Second, logger)
logger.Info("lanresolver: enabled", "host_ip", lr.HostIP, "upstreams", lr.Upstreams, "interval_s", lr.IntervalSeconds)
} else if lr.Enabled() {
logger.Warn("lanresolver: enabled but no host IP (set lan_resolver.host_ip or local_api.listen_addr) — disabled")
}
}
// Run reconcile, the hub loop, the storage watchdog, the restore-test scheduler, the PBS
// verify loop, and (optionally) the local-API server concurrently; any one returning ends
// the daemon (ctx cancel tears down the rest).
errc := make(chan error, 6)
// verify loop, (optionally) the local-API server, and (optionally) the LAN resolver loop
// concurrently; any one returning ends the daemon (ctx cancel tears down the rest).
errc := make(chan error, 7)
go func() { errc <- engine.Run(ctx, interval) }()
go func() { errc <- loop.Run(ctx) }()
go func() { errc <- watchdog.Run(ctx) }()
@@ -396,10 +421,14 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int {
localServers = 1
go func() { errc <- localSrv.Run(ctx) }()
}
if lanLoop != nil {
lanServers = 1
go func() { errc <- lanLoop.Run(ctx) }()
}
err = <-errc
stop() // tear down the siblings on the first exit
for i := 0; i < 4+localServers; i++ { // wait for the other goroutines
stop() // tear down the siblings on the first exit
for i := 0; i < 4+localServers+lanServers; i++ { // wait for the other goroutines
<-errc
}
if err != nil && err != context.Canceled {
@@ -1651,6 +1680,42 @@ func dur(seconds int64) string { return (time.Duration(seconds) * time.Second).S
func gib(bytes int64) string { return fmt.Sprintf("%.1fGiB", float64(bytes)/(1<<30)) }
// runSelftestLANResolver ensures dnsmasq + the host base config, then applies the split-horizon record
// for ONE guest (its live LAN IP + domain discovered from the running guest). Prints what it wrote;
// verify the actual resolution out-of-band (dig @<host-ip> <app>.<domain>). Requires a host LAN IP
// (lan_resolver.host_ip or derivable from local_api.listen_addr).
func runSelftestLANResolver(ctx context.Context, cfg config.Config, logger *slog.Logger, vmid int) int {
if vmid <= 0 {
fmt.Fprintln(os.Stderr, "selftest=lanresolver: -vmid is required")
return 1
}
lr := cfg.LANResolver.WithDefaults(cfg.LocalAPI.ListenAddr)
if lr.HostIP == "" {
fmt.Fprintln(os.Stderr, "selftest=lanresolver: no host IP (set lan_resolver.host_ip or local_api.listen_addr)")
return 1
}
mode := proxmox.RunnerMode(cfg.Privileged.Mode)
if mode == "" {
mode = proxmox.RunnerSudo
}
runner := &proxmox.ExecRunner{Mode: mode, SudoPath: cfg.Privileged.SudoPath}
mgr := lanresolver.NewManager(runner, lr.HostIP, lr.Upstreams, logger)
fmt.Printf("=== felhom-agent %s selftest=lanresolver (vmid=%d host_ip=%s) ===\n", version, vmid, lr.HostIP)
if err := mgr.EnsureDnsmasq(ctx); err != nil {
fmt.Fprintf(os.Stderr, " [FAIL] EnsureDnsmasq: %v\n", err)
return 1
}
fmt.Printf(" [OK] dnsmasq present + base config (listen %s, upstreams %v)\n", lr.HostIP, lr.Upstreams)
cid := lanresolver.CustomerID(lr.StateDir, vmid)
if err := mgr.ReconcileGuest(ctx, vmid, cid); err != nil {
fmt.Fprintf(os.Stderr, " [FAIL] ReconcileGuest: %v\n", err)
return 1
}
fmt.Printf(" [OK] split-horizon applied for guest %d (customer=%q). Verify: dig @%s felhom.<domain>\n", vmid, cid, lr.HostIP)
return 0
}
// selftestFlag is a flag.Value that also satisfies IsBoolFlag, so `--selftest`
// works bare (read-only) and `--selftest=task` / `--selftest=read` set the mode.
type selftestFlag struct{ mode string }
@@ -1673,6 +1738,8 @@ func (f *selftestFlag) Set(v string) error {
f.mode = "restore-test"
case "pbs-verify":
f.mode = "pbs-verify"
case "lanresolver":
f.mode = "lanresolver"
case "bring-up":
f.mode = "bring-up"
case "provision":