2bf9354c0e
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
180 lines
6.0 KiB
Go
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")
|
|
}
|
|
}
|