Files
admin a43e9813ad 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>
2026-06-11 18:24:58 +02:00

146 lines
4.1 KiB
Go

package lanresolver
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strconv"
"time"
)
// Loop periodically reconciles the split-horizon resolver for every guest the agent has provisioned.
// It is the IP-freshness mechanism: the guest stays DHCP/ephemeral, so its LAN IP can move (lease
// renewal, MAC reset); the loop re-discovers it each tick and updates the resolver only on change.
// Like the storage watchdog / PBS verify loop, it owns its own cadence and is not journaled/gated.
type Loop struct {
mgr *Manager
stateDir string // agent state dir; provisioned guests live under <stateDir>/guests/<vmid>/
interval time.Duration
logger *slog.Logger
}
// NewLoop builds the reconcile loop. stateDir is the agent state dir (default /var/lib/felhom-agent).
func NewLoop(mgr *Manager, stateDir string, interval time.Duration, logger *slog.Logger) *Loop {
if interval <= 0 {
interval = 5 * time.Minute
}
return &Loop{mgr: mgr, stateDir: stateDir, interval: interval, logger: logger}
}
// Run ensures dnsmasq + the base config once, then reconciles all guests immediately and every
// interval until ctx is cancelled.
func (l *Loop) Run(ctx context.Context) error {
if err := l.mgr.EnsureDnsmasq(ctx); err != nil {
// Non-fatal: log and keep trying on the ticker (a transient apt/systemd hiccup must not kill
// the loop). The host's own resolution is unaffected (we never touch /etc/resolv.conf).
l.logger.Warn("lanresolver: EnsureDnsmasq failed — will retry", "err", err)
}
l.reconcileAll(ctx)
t := time.NewTicker(l.interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
l.reconcileAll(ctx)
}
}
}
func (l *Loop) reconcileAll(ctx context.Context) {
guests, err := l.listGuests()
if err != nil {
l.logger.Warn("lanresolver: cannot list provisioned guests", "err", err)
return
}
for _, g := range guests {
if err := l.mgr.ReconcileGuest(ctx, g.VMID, g.CustomerID); err != nil {
l.logger.Warn("lanresolver: reconcile failed", "vmid", g.VMID, "err", err)
}
}
}
type guestRef struct {
VMID int
CustomerID string
}
// listGuests enumerates the provisioned guests under <stateDir>/guests/<vmid>/bootstrap/bootstrap.json,
// reading customer.id from each bootstrap. Dirs without a readable bootstrap are skipped.
func (l *Loop) listGuests() ([]guestRef, error) {
root := filepath.Join(l.stateDir, "guests")
entries, err := os.ReadDir(root)
if err != nil {
if os.IsNotExist(err) {
return nil, nil // no guests provisioned yet
}
return nil, err
}
var out []guestRef
for _, e := range entries {
if !e.IsDir() {
continue
}
vmid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
cid := readCustomerID(filepath.Join(root, e.Name(), "bootstrap", "bootstrap.json"))
out = append(out, guestRef{VMID: vmid, CustomerID: cid})
}
return out, nil
}
// CustomerID returns a provisioned guest's customer id from its bootstrap (best-effort "").
func CustomerID(stateDir string, vmid int) string {
return readCustomerID(filepath.Join(stateDir, "guests", strconv.Itoa(vmid), "bootstrap", "bootstrap.json"))
}
func readCustomerID(path string) string {
data, err := os.ReadFile(path)
if err != nil {
return ""
}
var b struct {
Customer struct {
ID string `json:"id"`
} `json:"customer"`
}
if err := json.Unmarshal(data, &b); err != nil {
return ""
}
return b.Customer.ID
}
// DeriveHostIP extracts the host LAN IP from the local-API listen address (host:port). Returns "" if
// it can't be parsed (caller then requires an explicit config value).
func DeriveHostIP(listenAddr string) string {
h, _, err := splitHostPort(listenAddr)
if err != nil || h == "" || h == "0.0.0.0" {
return ""
}
return h
}
// splitHostPort is net.SplitHostPort wrapped to avoid importing net in tests that don't need it.
func splitHostPort(s string) (string, string, error) {
i := lastIndexByte(s, ':')
if i < 0 {
return "", "", fmt.Errorf("missing port in %q", s)
}
return s[:i], s[i+1:], nil
}
func lastIndexByte(s string, b byte) int {
for i := len(s) - 1; i >= 0; i-- {
if s[i] == b {
return i
}
}
return -1
}