d880289b06
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
226 lines
8.4 KiB
Go
226 lines
8.4 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) }
|
|
func (m *Manager) portFilePath() string { return filepath.Join(m.sshdDir(), "port") }
|
|
|
|
// readPort/writePort persist the claimed port in the AGENT-OWNED state dir (the agent is non-root and
|
|
// cannot write the root-owned /etc/felhom-sshd). A port is not a secret.
|
|
func (m *Manager) readPort() (int, bool) {
|
|
raw, err := os.ReadFile(m.portFilePath())
|
|
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
|
|
}
|
|
|
|
func (m *Manager) writePort(port int) error {
|
|
if err := os.MkdirAll(m.sshdDir(), 0o700); err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(m.portFilePath(), []byte(strconv.Itoa(port)+"\n"), 0o600)
|
|
}
|
|
|
|
// 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, m.readPort, m.writePort)
|
|
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
|
|
}
|