a43e9813ad
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>
146 lines
4.1 KiB
Go
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
|
|
}
|