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 /guests// 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 /guests//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 }