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
106 lines
3.6 KiB
Go
106 lines
3.6 KiB
Go
package felhomsshd
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestRenderConfig_SafeTemplateAndByteStable(t *testing.T) {
|
|
c, err := renderConfig(8822)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, must := range []string{
|
|
"Port 8822\n",
|
|
"AuthorizedKeysFile /etc/felhom-sshd/authorized_keys/%u\n",
|
|
"HostKey /etc/felhom-sshd/ssh_host_ed25519_key\n",
|
|
"PasswordAuthentication no\n",
|
|
"PermitRootLogin prohibit-password\n",
|
|
"AllowUsers root felhom-op\n",
|
|
"PidFile /run/felhom-sshd.pid\n",
|
|
} {
|
|
if !strings.Contains(c, must) {
|
|
t.Errorf("config missing %q:\n%s", must, c)
|
|
}
|
|
}
|
|
// [SF-1] the incident cause must NEVER appear.
|
|
if strings.Contains(c, "RuntimeDirectory") {
|
|
t.Fatal("config/unit must never mention RuntimeDirectory")
|
|
}
|
|
// byte-stable for a given port (conf-hash stability → no reload churn).
|
|
c2, _ := renderConfig(8822)
|
|
if c != c2 {
|
|
t.Fatal("renderConfig not deterministic")
|
|
}
|
|
}
|
|
|
|
func TestRenderConfig_RefusesPort22AndOutOfRange(t *testing.T) {
|
|
if _, err := renderConfig(22); err == nil {
|
|
t.Fatal("renderConfig(22) must be refused — never claim the stock/customer port")
|
|
}
|
|
if _, err := renderConfig(0); err == nil {
|
|
t.Fatal("port 0 accepted")
|
|
}
|
|
if _, err := renderConfig(70000); err == nil {
|
|
t.Fatal("out-of-range port accepted")
|
|
}
|
|
}
|
|
|
|
// claim harness: a fake free-set + an in-memory persisted port.
|
|
func claimHarness(free map[int]bool, persisted int) (portProbe, func() (int, bool), func(int) error, *int) {
|
|
stored := persisted
|
|
isFree := func(p int) bool { return free[p] }
|
|
read := func() (int, bool) {
|
|
if stored == 0 {
|
|
return 0, false
|
|
}
|
|
return stored, true
|
|
}
|
|
write := func(p int) error { stored = p; return nil }
|
|
return isFree, read, write, &stored
|
|
}
|
|
|
|
func TestClaimPort_CleanContentionIdempotentExhaustion(t *testing.T) {
|
|
cands := []int{8822, 2222, 8022, 62222}
|
|
|
|
// clean → first candidate
|
|
isFree, read, write, stored := claimHarness(map[int]bool{8822: true, 2222: true, 8022: true, 62222: true}, 0)
|
|
if p, err := claimPort(cands, isFree, read, write); err != nil || p != 8822 {
|
|
t.Fatalf("clean claim = %d / %v, want 8822", p, err)
|
|
}
|
|
if *stored != 8822 {
|
|
t.Fatalf("clean claim not persisted, stored=%d", *stored)
|
|
}
|
|
|
|
// contention: 8822 busy → 2222
|
|
isFree, read, write, _ = claimHarness(map[int]bool{8822: false, 2222: true, 8022: true, 62222: true}, 0)
|
|
if p, err := claimPort(cands, isFree, read, write); err != nil || p != 2222 {
|
|
t.Fatalf("contention claim = %d / %v, want 2222", p, err)
|
|
}
|
|
|
|
// idempotent: persisted 2222 still free (even though 8822 is now free) → keep 2222 (no thrash)
|
|
isFree, read, write, _ = claimHarness(map[int]bool{8822: true, 2222: true, 8022: true, 62222: true}, 2222)
|
|
if p, err := claimPort(cands, isFree, read, write); err != nil || p != 2222 {
|
|
t.Fatalf("idempotent claim = %d / %v, want 2222 (kept)", p, err)
|
|
}
|
|
|
|
// exhaustion: all busy → LOUD error, no fallback
|
|
isFree, read, write, stored = claimHarness(map[int]bool{8822: false, 2222: false, 8022: false, 62222: false}, 0)
|
|
p, err := claimPort(cands, isFree, read, write)
|
|
if err != ErrPortsExhausted {
|
|
t.Fatalf("exhaustion must return ErrPortsExhausted, got %d / %v", p, err)
|
|
}
|
|
if p == 22 || p != 0 {
|
|
t.Fatalf("exhaustion must NOT yield a port (esp. :22), got %d", p)
|
|
}
|
|
if *stored != 0 {
|
|
t.Fatalf("exhaustion must persist nothing, stored=%d", *stored)
|
|
}
|
|
|
|
// persisted port that is now BUSY → re-claim a fresh free one (not the stale persisted)
|
|
isFree, read, write, _ = claimHarness(map[int]bool{8822: false, 2222: true, 8022: true, 62222: true}, 8822)
|
|
if p, err := claimPort(cands, isFree, read, write); err != nil || p != 2222 {
|
|
t.Fatalf("stale-persisted re-claim = %d / %v, want 2222", p, err)
|
|
}
|
|
}
|