Files
felhom-agent/internal/felhomsshd/belt.go
T
admin 2bf9354c0e fix(felhomsshd): don't empty the belt/authkeys on an unfetched block (operator lockout)
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
2026-07-05 22:51:30 +02:00

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
}