2bf9354c0e
Mirrors wgtunnel fetched=false-is-never-a-teardown: until the desired-state is delivered, @operator_ips + felhom-op authorized_keys are left untouched (a nil block on agent restart would otherwise empty @operator_ips and lock the operator out until the next fetch). Belt.Sync split into SyncPort (always) + SyncOperator (fetched only). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
134 lines
4.4 KiB
Go
134 lines
4.4 KiB
Go
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
|
|
}
|