package felhomsshd import ( "context" "log/slog" "net/netip" "regexp" "sort" "strconv" "strings" "gitea.dooplex.hu/admin/felhom-agent/internal/proxmox" ) // Belt keeps the STATIC nft table `inet felhom_oob` (installed by host-install) converged on the // current OOB state by mutating only its SETS — @operator_ips (the operator /32 allowed to reach // felhom-sshd over wg-felhom) and @ssh_port (felhom-sshd's claimed port). The agent NEVER adds/removes // RULES [trap 4]: set-element mutation can't change rule semantics, so the narrow sudoers grant stays // safe. Idempotent: reads the current elements and mutates only on a difference (no churn). type Belt struct { runner proxmox.Runner logger *slog.Logger table string // "felhom_oob" } // NewBelt builds a Belt over the narrow runner. table defaults to "felhom_oob". func NewBelt(runner proxmox.Runner, logger *slog.Logger) *Belt { if logger == nil { logger = slog.Default() } return &Belt{runner: runner, logger: logger, table: "felhom_oob"} } // SyncPort converges @ssh_port on the claimed port (0 empties it). Always safe to call — the port // comes from the agent's own claim, not the hub desired-state. func (b *Belt) SyncPort(ctx context.Context, port int) { wantPorts := []string{} if port > 0 && port <= 65535 { wantPorts = []string{strconv.Itoa(port)} } b.syncSet(ctx, "ssh_port", wantPorts) } // SyncOperator converges @operator_ips on the operator /32 (operatorIP "" = OOB explicitly off → // empty the set). Call ONLY when the desired-state has actually been FETCHED — a nil/unfetched block // must NOT empty the set (that would lock the operator out until the next fetch, the wgtunnel // fetched=false-is-never-a-teardown rule). Every value is netip-validated before it reaches nft. func (b *Belt) SyncOperator(ctx context.Context, operatorIP string) { wantOps := []string{} if operatorIP != "" { ip, err := netip.ParseAddr(operatorIP) if err != nil || !ip.Is4() { b.logger.Error("felhomsshd belt: operator ip invalid — refusing", "ip", operatorIP) return } wantOps = []string{ip.String()} } b.syncSet(ctx, "operator_ips", wantOps) } var nftElemRe = regexp.MustCompile(`elements\s*=\s*\{([^}]*)\}`) // syncSet converges one named set on `want` (sorted, deduped). Reads current elements; if they match, // ZERO nft mutations (the idempotency the scenario asserts). Otherwise flush + add the desired // elements. A read failure (table/set absent) → one warning, no mutation. func (b *Belt) syncSet(ctx context.Context, setName string, want []string) { want = sortedUnique(want) cur, ok := b.readSet(ctx, setName) if !ok { b.logger.Warn("felhomsshd belt: set unreadable (table not installed?) — skipping", "set", setName) return } if equalStringSlices(cur, want) { return // steady state: no mutation } if _, errOut, err := b.runner.Run(ctx, "nft", "flush", "set", "inet", b.table, setName); err != nil { b.logger.Error("felhomsshd belt: flush failed", "set", setName, "err", err, "stderr", strings.TrimSpace(string(errOut))) return } for _, e := range want { if _, errOut, err := b.runner.Run(ctx, "nft", "add", "element", "inet", b.table, setName, "{ "+e+" }"); err != nil { b.logger.Error("felhomsshd belt: add element failed", "set", setName, "elem", e, "err", err, "stderr", strings.TrimSpace(string(errOut))) return } } b.logger.Info("felhomsshd belt: set synced", "set", setName, "elements", strings.Join(want, ",")) } // readSet returns the current elements of a named set (sorted), or ok=false if the set can't be read. func (b *Belt) readSet(ctx context.Context, setName string) ([]string, bool) { out, _, err := b.runner.Run(ctx, "nft", "list", "set", "inet", b.table, setName) if err != nil { return nil, false } m := nftElemRe.FindSubmatch(out) if m == nil { return []string{}, true // set exists but empty (no "elements = {}" block) } var elems []string for _, f := range strings.Split(string(m[1]), ",") { f = strings.TrimSpace(f) if f != "" { elems = append(elems, f) } } return sortedUnique(elems), true } func sortedUnique(in []string) []string { seen := map[string]bool{} out := make([]string, 0, len(in)) for _, s := range in { if !seen[s] { seen[s] = true out = append(out, s) } } sort.Strings(out) return out } func equalStringSlices(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true }