Files
felhom-agent/internal/felhomsshd/belt_health_test.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

180 lines
6.0 KiB
Go

package felhomsshd
import (
"context"
"io"
"strings"
"sync"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
)
// scriptRunner records every exec and returns scripted stdout per "name+first-arg" key; scripted
// errors per the same key. Everything else returns empty success.
type scriptRunner struct {
mu sync.Mutex
calls [][]string
stdout map[string]string
errs map[string]error
}
func newScriptRunner() *scriptRunner {
return &scriptRunner{stdout: map[string]string{}, errs: map[string]error{}}
}
func key(name string, args ...string) string {
// `nft list set inet felhom_oob <setName>` → key by the set name (last arg), so the two set reads
// are distinguishable. Everything else keys by first arg.
if name == "nft" && len(args) > 0 && args[0] == "list" {
return "nft list " + args[len(args)-1]
}
if len(args) > 0 {
return name + " " + args[0]
}
return name
}
func (r *scriptRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
r.mu.Lock()
r.calls = append(r.calls, append([]string{name}, args...))
k := key(name, args...)
out := r.stdout[k]
err := r.errs[k]
r.mu.Unlock()
if err != nil {
return nil, []byte("scripted failure"), err
}
return []byte(out), nil, nil
}
func (r *scriptRunner) RunStdin(ctx context.Context, _ io.Reader, name string, args ...string) ([]byte, []byte, error) {
return r.Run(ctx, name, args...)
}
func (r *scriptRunner) count(name, firstArg string) int {
r.mu.Lock()
defer r.mu.Unlock()
n := 0
for _, c := range r.calls {
if c[0] == name && len(c) > 1 && c[1] == firstArg {
n++
}
}
return n
}
func (r *scriptRunner) sawRuleMutation() bool {
r.mu.Lock()
defer r.mu.Unlock()
for _, c := range r.calls {
// trap 4: the agent must NEVER run `nft add rule` / `nft -f` / `nft flush ruleset|table`.
if c[0] == "nft" && len(c) > 1 {
if c[1] == "-f" || (len(c) > 2 && c[1] == "add" && c[2] == "rule") {
return true
}
if c[1] == "flush" && len(c) > 2 && (c[2] == "ruleset" || c[2] == "table") {
return true
}
}
}
return false
}
func TestBelt_SyncMutatesThenIdempotent(t *testing.T) {
r := newScriptRunner()
// first read: both sets empty (no "elements" block)
r.stdout["nft list operator_ips"] = "set operator_ips {\n\ttype ipv4_addr\n}"
r.stdout["nft list ssh_port"] = "set ssh_port {\n\ttype inet_service\n}"
b := NewBelt(r, nil)
b.SyncPort(context.Background(), 8822)
b.SyncOperator(context.Background(), "10.77.0.250")
// mutations happened: flush + add for BOTH sets
if r.count("nft", "flush") < 2 || r.count("nft", "add") < 2 {
t.Fatalf("first sync must flush+add both sets; flushes=%d adds=%d", r.count("nft", "flush"), r.count("nft", "add"))
}
if r.sawRuleMutation() {
t.Fatal("belt must NEVER mutate rules — only set elements (trap 4)")
}
// second sync: the reads now return the desired elements → ZERO mutations (idempotent)
r2 := newScriptRunner()
r2.stdout["nft list operator_ips"] = "elements = { 10.77.0.250 }"
r2.stdout["nft list ssh_port"] = "elements = { 8822 }"
b2 := NewBelt(r2, nil)
b2.SyncPort(context.Background(), 8822)
b2.SyncOperator(context.Background(), "10.77.0.250")
if r2.count("nft", "flush") != 0 || r2.count("nft", "add") != 0 {
t.Fatalf("idempotent sync must do ZERO mutations; flushes=%d adds=%d", r2.count("nft", "flush"), r2.count("nft", "add"))
}
}
func TestBelt_OperatorEmptyEmptiesSet(t *testing.T) {
r := newScriptRunner()
r.stdout["nft list operator_ips"] = "elements = { 10.77.0.250 }" // currently has an operator IP
r.stdout["nft list ssh_port"] = "elements = { 8822 }"
b := NewBelt(r, nil)
b.SyncOperator(context.Background(), "") // OOB explicitly off → operator set must be emptied
if r.count("nft", "flush") < 1 {
t.Fatal("emptying the operator set must flush it")
}
}
// health: inactive + INVALID config → NO restart (the red-proof); inactive + valid → restart.
func TestHeal_NoRestartOnInvalidConfig(t *testing.T) {
r := newScriptRunner()
r.errs["sshd -t"] = context.DeadlineExceeded // sshd -t FAILS (config invalid)
m := newTestManager(r, false) // inactive
m.HealAndCheck(context.Background(), 8822)
if r.count("systemctl", "restart") != 0 {
t.Fatal("a broken config must NOT trigger a restart (never degraded→dead)")
}
}
func TestHeal_RestartsWhenDownWithValidConfigThenCooldown(t *testing.T) {
r := newScriptRunner() // sshd -t passes (no error)
m := newTestManager(r, false)
m.HealAndCheck(context.Background(), 8822)
if r.count("systemctl", "restart") != 1 {
t.Fatalf("down + valid config → exactly one restart, got %d", r.count("systemctl", "restart"))
}
// cooldown: a second call inside the window → still one restart
m.HealAndCheck(context.Background(), 8822)
if r.count("systemctl", "restart") != 1 {
t.Fatalf("restart cooldown violated: %d restarts", r.count("systemctl", "restart"))
}
}
func TestStatus_ReflectsBlockAndPort(t *testing.T) {
r := newScriptRunner()
r.stdout["sshd -T"] = "port 8822\nsomethingelse yes\n"
m := newTestManager(r, true) // active
m.port = 8822
st := m.Status(context.Background(), &hub.WireWireguard{OOBPeerIP: "10.77.0.250", OOBOperatorSSHKey: "ssh-ed25519 AAAA op"})
if !st.FelhomSshdActive || st.FelhomSshdPort != 8822 {
t.Fatalf("status: %+v", st)
}
if !st.OperatorPeerConfigured || !st.OperatorKeyConfigured {
t.Fatalf("status must reflect operator peer + key configured: %+v", st)
}
}
// newTestManager builds a Manager with injected active-state + a now clock; sshd -T/-t go through the
// runner. isFree unused here.
func newTestManager(r *scriptRunner, active bool) *Manager {
m := NewManager(r, "/tmp/felhomsshd-test", nil)
m.isActive = func(context.Context) bool { return active }
m.isFailed = func(context.Context) bool { return false }
m.now = func() time.Time { return time.Unix(1783270000, 0) }
return m
}
func TestConfig_NoRuntimeDirectoryString(t *testing.T) {
c, _ := renderConfig(2222)
if strings.Contains(c, "RuntimeDirectory") {
t.Fatal("SF-1: config must never contain RuntimeDirectory")
}
}