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