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

204 lines
7.7 KiB
Go

package felhomsshd
import (
"context"
"crypto/sha256"
"encoding/hex"
"log/slog"
"net"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
)
// AuthKeysUserPath is felhom-op's authorized_keys (the default operator identity — key OUTSIDE
// ~/.ssh so the customer's sshd never honours it [SF-3]). FIXED (sudoers install target).
const AuthKeysUserPath = AuthKeysDir + "/" + OperatorUser
const stagedConfName = "sshd_config"
// Manager renders + applies the felhom-sshd config and drives the unit through the narrow-sudoers
// runner. Config changes go write→`sshd -t`→`reload` (NEVER restart-on-change [SF-2]); a deliberate
// restart is `reset-failed`-then-`restart` [SF-5], rate-limited by a cooldown (no flap).
type Manager struct {
runner proxmox.Runner
stateDir string
logger *slog.Logger
// injectable seams (tests)
isActive func(ctx context.Context) bool
isFailed func(ctx context.Context) bool
isFree portProbe
now func() time.Time
port int // the claimed port (0 until Apply claims it)
hash string // sha256 of the last-applied config (steady-state zero-exec gate)
akHash string // sha256 of the last-applied felhom-op authorized_keys
lastRestartAt time.Time // heal cooldown (Part 4)
}
// restartCooldown bounds deliberate felhom-sshd restarts (heal path) — one attempt per window, so a
// persistently-broken instance is reported, not restart-stormed (spike §6 / notification-cooldown shape).
const restartCooldown = 10 * time.Minute
// NewManager builds a Manager. stateDir is the agent state dir (staged config lives under it).
func NewManager(runner proxmox.Runner, stateDir string, logger *slog.Logger) *Manager {
if logger == nil {
logger = slog.Default()
}
return &Manager{
runner: runner,
stateDir: stateDir,
logger: logger,
isActive: func(ctx context.Context) bool {
out, _ := exec.CommandContext(ctx, "systemctl", "is-active", Unit).Output()
return strings.TrimSpace(string(out)) == "active"
},
isFailed: func(ctx context.Context) bool {
out, _ := exec.CommandContext(ctx, "systemctl", "is-failed", Unit).Output()
return strings.TrimSpace(string(out)) == "failed"
},
isFree: probeFree,
now: time.Now,
}
}
// Port returns the claimed port (0 before the first successful Apply).
func (m *Manager) Port() int { return m.port }
func (m *Manager) sshdDir() string { return filepath.Join(m.stateDir, "felhom-sshd") }
func (m *Manager) stagedConfPath() string { return filepath.Join(m.sshdDir(), stagedConfName) }
// Apply claims the port, renders the config, reconciles the running unit, and (when the desired-state
// block carries it) installs the operator's authorized_keys. Idempotent: no change → at most an
// is-active check. block may be nil (no desired-state yet) — the instance still runs; only the
// operator login/belt inputs are skipped. Returns the claimed port (0 on a claim/exhaustion error) —
// the caller (belt sync) needs it.
func (m *Manager) Apply(ctx context.Context, block *hub.WireWireguard) (int, error) {
port, err := claimPort(Candidates, m.isFree, readPortFile, writePortFile)
if err != nil {
m.logger.Error("felhomsshd: port claim failed", "err", err)
return 0, err
}
m.port = port
// Operator authorized_keys (from the hub-driven block) — written to felhom-op's dedicated file,
// outside ~/.ssh [SF-3]. Independent of the config-reload path (a key change never reloads sshd).
if block != nil {
m.applyAuthorizedKeys(ctx, block.OOBOperatorSSHKey)
}
conf, err := renderConfig(port)
if err != nil {
m.logger.Error("felhomsshd: refusing to apply invalid config", "err", err)
return port, err
}
sum := sha256.Sum256([]byte(conf))
hash := hex.EncodeToString(sum[:])
active := m.isActive(ctx)
if m.hash == hash && active {
return port, nil // steady state: zero execs
}
// Stage → validate → install → reload/enable. Never restart on a config change [SF-2].
if err := os.MkdirAll(m.sshdDir(), 0o700); err != nil {
m.logger.Error("felhomsshd: state dir", "err", err)
return port, err
}
if err := os.WriteFile(m.stagedConfPath(), []byte(conf), 0o600); err != nil {
m.logger.Error("felhomsshd: staging config", "err", err)
return port, err
}
// Validate the STAGED config before it is installed — a bad render never reaches the live path.
if _, errOut, err := m.runner.Run(ctx, "sshd", "-t", "-f", m.stagedConfPath()); err != nil {
m.logger.Error("felhomsshd: staged config failed sshd -t — NOT installing", "err", err, "stderr", strings.TrimSpace(string(errOut)))
return port, err
}
if _, errOut, err := m.runner.Run(ctx, "install", "-o", "root", "-g", "root", "-m", "0644", "--", m.stagedConfPath(), ConfPath); err != nil {
m.logger.Error("felhomsshd: config install failed", "err", err, "stderr", strings.TrimSpace(string(errOut)))
return port, err
}
verb := "reload"
if !active {
verb = "enable" // first apply / down → enable --now brings it up
}
if err := m.systemctl(ctx, verb); err != nil {
return port, err
}
m.hash = hash
m.logger.Info("felhomsshd: config applied", "port", port, "action", verb)
return port, nil
}
// applyAuthorizedKeys installs (or clears) felhom-op's authorized_keys from the hub-delivered
// operator SSH key. Hash-gated (no churn); the key is public, never a secret. A write failure is
// logged, not fatal — the instance keeps running. NO sshd reload needed (sshd reads the file per-auth).
func (m *Manager) applyAuthorizedKeys(ctx context.Context, sshKey string) {
content := ""
if k := strings.TrimSpace(sshKey); k != "" {
content = k + "\n"
}
sum := sha256.Sum256([]byte(content))
h := hex.EncodeToString(sum[:])
if h == m.akHash {
return // unchanged
}
staged := filepath.Join(m.sshdDir(), "authorized_keys."+OperatorUser)
if err := os.MkdirAll(m.sshdDir(), 0o700); err != nil {
m.logger.Error("felhomsshd: state dir for authorized_keys", "err", err)
return
}
if err := os.WriteFile(staged, []byte(content), 0o600); err != nil {
m.logger.Error("felhomsshd: staging authorized_keys", "err", err)
return
}
if _, errOut, err := m.runner.Run(ctx, "install", "-o", "root", "-g", "root", "-m", "0644", "--", staged, AuthKeysUserPath); err != nil {
m.logger.Error("felhomsshd: authorized_keys install failed", "err", err, "stderr", strings.TrimSpace(string(errOut)))
return
}
m.akHash = h
m.logger.Info("felhomsshd: operator authorized_keys updated", "user", OperatorUser, "present", content != "")
}
// systemctl runs the reconcile verbs through the narrow runner. `enable` = `enable --now`; `reload`
// HUPs (config change, running instance survives a bad reload via the unit's ExecReload sshd -t gate).
func (m *Manager) systemctl(ctx context.Context, verb string) error {
var args []string
switch verb {
case "enable":
args = []string{"enable", "--now", Unit}
case "reload", "restart":
args = []string{verb, Unit}
default:
return nil
}
if _, errOut, err := m.runner.Run(ctx, "systemctl", args...); err != nil {
m.logger.Error("felhomsshd: systemctl "+verb+" failed", "err", err, "stderr", strings.TrimSpace(string(errOut)))
return err
}
return nil
}
// probeFree is the production port probe: nothing LISTENing (ss) AND a real bind succeeds (a bind
// that succeeds-then-closes proves the port is actually claimable, not just ss-silent).
func probeFree(port int) bool {
p := strconv.Itoa(port)
out, err := exec.Command("ss", "-Htln", "sport = :"+p).Output()
if err == nil && strings.TrimSpace(string(out)) != "" {
return false // something is listening
}
ln, err := net.Listen("tcp", "0.0.0.0:"+p)
if err != nil {
return false
}
_ = ln.Close()
return true
}