Files
felhom-agent/internal/felhomsshd/claim.go
T
admin c983a25609 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
2026-07-05 22:27:02 +02:00

66 lines
2.1 KiB
Go

package felhomsshd
import (
"fmt"
"os"
"strconv"
"strings"
)
// Candidates is the ordered OOB-port candidate list (spike §2). First free wins; NEVER :22 or a
// random port. Package-var (not const) so tests can shrink it.
var Candidates = []int{8822, 2222, 8022, 62222}
// ErrPortsExhausted is returned when every candidate is busy — a LOUD failure [SF-4/trap 6], never a
// silent fallback to :22 or a random high port.
var ErrPortsExhausted = fmt.Errorf("felhomsshd: all candidate OOB ports are busy — refusing to fall back to :22 or a random port")
// portProbe reports whether a TCP port is free (nothing listening AND a real bind succeeds). Injected
// for tests; production impl = probeFree (ss + net.Listen).
type portProbe func(port int) bool
// claimPort returns the OOB port, mirroring the spike's shell algorithm:
// - if a persisted port exists AND is still free → keep it (idempotent, no thrash),
// - else the FIRST free candidate → persist + return,
// - else ErrPortsExhausted (LOUD).
//
// persist writes PortFile; readPersisted reads it. isFree is the probe. All injected for tests.
func claimPort(candidates []int, isFree portProbe, readPersisted func() (int, bool), persist func(int) error) (int, error) {
if cur, ok := readPersisted(); ok && cur != 22 && isFree(cur) {
return cur, nil
}
for _, p := range candidates {
if p == 22 {
continue // defensive: never :22
}
if isFree(p) {
if err := persist(p); err != nil {
return 0, fmt.Errorf("felhomsshd: persist claimed port %d: %w", p, err)
}
return p, nil
}
}
return 0, ErrPortsExhausted
}
// readPortFile parses PortFile → (port, ok). A missing/garbage file → (0,false).
func readPortFile() (int, bool) {
raw, err := os.ReadFile(PortFile)
if err != nil {
return 0, false
}
p, err := strconv.Atoi(strings.TrimSpace(string(raw)))
if err != nil || p < 1 || p > 65535 {
return 0, false
}
return p, true
}
// writePortFile persists the claimed port (0644 — a port is not a secret).
func writePortFile(port int) error {
if err := os.MkdirAll(ConfDir, 0o755); err != nil {
return err
}
return os.WriteFile(PortFile, []byte(strconv.Itoa(port)+"\n"), 0o644)
}