c983a25609
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
131 lines
4.2 KiB
Go
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
|
|
}
|