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 }