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
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user