Files
felhom-agent/internal/felhomsshd/belt.go
T
admin c983a25609 feat(felhomsshd): dedicated OOB sshd instance + port-adaptive belt (H1 Parts 2-4 agent)
internal/felhomsshd: agent-managed felhom-sshd (claim port [8822,2222,8022,62222]
loud-fail-on-exhaustion; render config→sshd -t→reload never-restart-on-change
[SF-2]; operator authorized_keys from the hub block outside ~/.ssh [SF-3]); the
static-table nft belt mutating ONLY @operator_ips + @ssh_port [trap 4]; health/heal
(reset-failed-then-restart with 10min cooldown, NEVER restart onto an invalid
config) + the oob heartbeat stanza. configs/felhom-sshd.service (SAFE, no
RuntimeDirectory [SF-1]). FELHOM_SSHD + FELHOM_OOB sudoers (set-elements only).
oob.enabled config DEFAULT FALSE. Wired into main like wgtunnel.

Non-hollow tests: claim clean/contention/idempotent/exhaustion; config
safe+byte-stable+refuses-:22; belt mutate-then-idempotent + never-touches-rules;
heal no-restart-on-invalid-config + cooldown; status reflects block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-05 22:27:02 +02:00

131 lines
4.2 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"}
}
// Sync converges @operator_ips + @ssh_port on the desired values. operatorIP "" empties @operator_ips
// (belt drops all tunnel SSH — OOB off). port 0 empties @ssh_port. Every value is validated before it
// reaches nft (netip for the IP, int range for the port), so the coarse sudoers wildcard can never be
// abused. A missing table (host-install not run / pre-H1 box) degrades to a single logged warning.
func (b *Belt) Sync(ctx context.Context, port int, operatorIP string) {
// desired sets
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()}
}
wantPorts := []string{}
if port > 0 && port <= 65535 {
wantPorts = []string{strconv.Itoa(port)}
}
b.syncSet(ctx, "operator_ips", wantOps)
b.syncSet(ctx, "ssh_port", wantPorts)
}
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
}