Files
orca/internal/linux/bootstrap.go
T
Jon Chery dea472f443 feat(P2): podman traefik reconciler + TLS model fix (REQ-172)
Replace internal/traefik/install.go binary+systemd installer with a
podman-container reconciler (R-024). The reconciler is idempotent:
inspect → start-if-stopped → pull+run-if-absent.

Container run flags (research-validated):
  --restart=unless-stopped (not always; research Topic 6)
  --network host (binds 127.0.0.1:8080/8443 on host/LXC loopback)
  -v /etc/traefik/traefik.yml:ro (overrides baked default; C-58)
  -v /etc/traefik/dynamic:ro (orca writes atomically via SSH-push)
  -v /etc/orca/step-ca-root.crt:ro (future mTLS; v0.14 uses tls:{})
  No :Z SELinux flag (research Topic 7)

C-50: ensurePodmanLocal/Remote installs podman if absent.
C-57: removeLegacySystemdUnitLocal/Remote stops+disables+removes
  the v0.13 orca-traefik.service + /usr/local/bin/traefik before
  starting the podman container (upgrade path).
  upgrade.go cutover rewritten to use the reconciler.

TLS model fix (research Topic 4): drop certResolver: orca from
dynamic config (traefik v3.3 only supports acme/tailscale resolvers,
not CA-file-based). Emit tls: {} instead. Real mTLS via dynamic
tls.certificates + clientAuth.caFiles deferred to v0.15 (grill
G-003, confidence 0.55 < 0.60).

Callsites updated:
  init.go: installTraefikLocal → ensureTraefikContainerLocal
  linux/bootstrap.go: traefik.InstallRemote → EnsureTraefikContainerRemote
  proxmox/bootstrap.go: same
  traefik_install.go: wrapper updated

Tests: internal/traefik/install_test.go (new) — ImageRef, podmanRunArgs,
  container-running/stopped/absent paths, legacy systemd removal (C-57).

---ci---
project: orca
phase: 2
milestone: v0.14
status: execute
---/ci---
2026-08-10 20:04:20 +00:00

232 lines
7.7 KiB
Go

// Package linux implements the SSH-based bootstrap of a generic Linux
// host (Ubuntu/Debian/Alpine) as an orca worker node (REQ-161, P12).
//
// The bootstrap sequence (run via `orca node join --type linux`):
// 1. Generate or load the orca SSH keypair (Ed25519, D-037)
// 2. SSH dial with key auth + TOFU host-key capture (D-035)
// 3. Deploy the orca pubkey to ~orca/.ssh/authorized_keys
// 4. Create the `orca` Linux system user (nologin shell)
// 5. Create the drift-events directory (~orca/drift-events)
// 6. Return the node metadata for the caller to persist
//
// Unlike Proxmox bootstrap, there is NO PVE role, NO sudoers file, and
// NO PVE user — this is a plain Linux worker. Authentication is
// key-based (R-021): the orca SSH key is used for the initial SSH auth
// and pubkey deployment; subsequent orca→worker access uses the same
// key.
//
// All steps are idempotent: re-running the bootstrap on an
// already-configured host is a no-op.
package linux
import (
"bytes"
"context"
"fmt"
"log/slog"
"net"
"os"
"strings"
"time"
"golang.org/x/crypto/ssh"
"git.cloudinit.dev/coreci/orca/internal/certpaths"
"git.cloudinit.dev/coreci/orca/internal/proxmox"
"git.cloudinit.dev/coreci/orca/internal/security"
"git.cloudinit.dev/coreci/orca/internal/traefik"
)
// DefaultSSHUser is the default SSH username for the initial connection.
const DefaultSSHUser = "root"
// DefaultOrcaUser is the default Linux system user created on the worker.
const DefaultOrcaUser = "orca"
// DefaultSSHPort is the default SSH port.
const DefaultSSHPort = 22
// Options configures a Linux worker bootstrap run.
type Options struct {
Host string
SSHUser string
SSHKeyPath string
OrcaUser string
SSHPort int
HostKeyFingerprint string
Logger *slog.Logger
}
// Result is the outcome of a successful bootstrap.
type Result struct {
NodeName string
NodeAddress string
HostKeyFingerprint string
}
// BootstrapLinux runs the full SSH bootstrap sequence on a remote
// generic Linux host. Returns the node metadata for the caller to
// persist to the registry.
func BootstrapLinux(ctx context.Context, opts Options) (*Result, error) {
if opts.Host == "" {
return nil, fmt.Errorf("linux bootstrap: --host is required")
}
if opts.SSHKeyPath == "" {
return nil, fmt.Errorf("linux bootstrap: --ssh-key is required (R-021: no passwords; use --ssh-key or pre-stage the orca key)")
}
if opts.SSHUser == "" {
opts.SSHUser = DefaultSSHUser
}
if opts.OrcaUser == "" {
opts.OrcaUser = DefaultOrcaUser
}
if opts.SSHPort == 0 {
opts.SSHPort = DefaultSSHPort
}
if opts.Logger == nil {
opts.Logger = slog.Default()
}
// Step 1: Load the orca SSH keypair.
privKey, err := os.ReadFile(opts.SSHKeyPath)
if err != nil {
return nil, fmt.Errorf("linux bootstrap: read SSH key: %w", err)
}
signer, err := ssh.ParsePrivateKey(privKey)
if err != nil {
return nil, fmt.Errorf("linux bootstrap: parse SSH key: %w", err)
}
pubKey, err := os.ReadFile(certpaths.SSHPubPath())
if err != nil {
return nil, fmt.Errorf("linux bootstrap: read orca pubkey: %w", err)
}
pubKeyLine := strings.TrimSpace(string(pubKey))
// Step 2: SSH dial with key auth + TOFU host-key capture.
sshAddr := net.JoinHostPort(opts.Host, fmt.Sprintf("%d", opts.SSHPort))
var capturedHostKey ssh.PublicKey
var hostKeyCallback ssh.HostKeyCallback
if opts.HostKeyFingerprint != "" {
hkcb, err := pinnedHostKeyCallback(opts.HostKeyFingerprint, &capturedHostKey)
if err != nil {
return nil, fmt.Errorf("linux bootstrap: parse host key fingerprint: %w", err)
}
hostKeyCallback = hkcb
} else {
// REQ-164 / Phase A3: reuse the tested Proxmox TOFU callback
// which handles first-connect key capture + known_hosts file
// creation (create-on-open). The previous inline implementation
// failed on first connect with a raw KeyError because it never
// wrote the captured key.
hkcb, err := proxmox.TOFUHostKeyCallbackPath(certpaths.KnownHostsPath(), sshAddr, &capturedHostKey)
if err != nil {
return nil, fmt.Errorf("linux bootstrap: known_hosts: %w", err)
}
hostKeyCallback = hkcb
}
sshConfig := &ssh.ClientConfig{
User: opts.SSHUser,
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
HostKeyCallback: hostKeyCallback,
Timeout: 30 * time.Second,
}
opts.Logger.Info("linux bootstrap: dialing", "addr", sshAddr, "user", opts.SSHUser)
client, err := ssh.Dial("tcp", sshAddr, sshConfig)
if err != nil {
return nil, fmt.Errorf("linux bootstrap: SSH dial %s: %w", sshAddr, err)
}
defer client.Close()
// Step 3: Deploy the orca pubkey to authorized_keys.
if err := sshExec(client, fmt.Sprintf(
"mkdir -p ~%s/.ssh && grep -qF '%s' ~%s/.ssh/authorized_keys 2>/dev/null || echo '%s' >> ~%s/.ssh/authorized_keys && chmod 700 ~%s/.ssh && chmod 600 ~%s/.ssh/authorized_keys",
opts.OrcaUser, pubKeyLine, opts.OrcaUser, pubKeyLine, opts.OrcaUser, opts.OrcaUser, opts.OrcaUser,
)); err != nil {
return nil, fmt.Errorf("linux bootstrap: deploy pubkey: %w", err)
}
opts.Logger.Info("linux bootstrap: pubkey deployed", "user", opts.OrcaUser)
// Step 4: Create the orca system user (nologin shell).
if err := sshExec(client, fmt.Sprintf(
"id -u %s 2>/dev/null || useradd -r -s /usr/sbin/nologin -d /home/%s -m %s",
opts.OrcaUser, opts.OrcaUser, opts.OrcaUser,
)); err != nil {
return nil, fmt.Errorf("linux bootstrap: create user: %w", err)
}
opts.Logger.Info("linux bootstrap: user created", "user", opts.OrcaUser)
// Step 4d: Ensure orca-traefik podman container on the remote host
// (REQ-172, R-024). Replaces v0.13 binary+systemd install.
sshExecFn := func(cmd string) ([]byte, error) {
session, err := client.NewSession()
if err != nil {
return nil, err
}
defer session.Close()
return session.CombinedOutput(cmd)
}
ctx, cancelContainer := context.WithTimeout(ctx, 120*time.Second)
defer cancelContainer()
if err := traefik.EnsureTraefikContainerRemote(ctx, "", sshExecFn); err != nil {
opts.Logger.Warn("linux bootstrap: traefik container ensure failed", "err", err)
}
// Step 5: Create the drift-events directory.
if err := sshExec(client, fmt.Sprintf(
"mkdir -p ~%s/drift-events && chown %s:%s ~%s/drift-events",
opts.OrcaUser, opts.OrcaUser, opts.OrcaUser, opts.OrcaUser,
)); err != nil {
return nil, fmt.Errorf("linux bootstrap: create drift-events dir: %w", err)
}
opts.Logger.Info("linux bootstrap: drift-events dir created", "user", opts.OrcaUser)
// Step 6: Return node metadata.
hostKeyFP := ""
if capturedHostKey != nil {
hostKeyFP = ssh.FingerprintSHA256(capturedHostKey)
}
return &Result{
NodeName: opts.Host,
NodeAddress: fmt.Sprintf("%s:8443", opts.Host),
HostKeyFingerprint: hostKeyFP,
}, nil
}
// sshExec runs a command on the remote host and returns an error if
// the exit code is non-zero.
func sshExec(client *ssh.Client, cmd string) error {
session, err := client.NewSession()
if err != nil {
return err
}
defer session.Close()
var stderr bytes.Buffer
session.Stderr = &stderr
if err := session.Run(cmd); err != nil {
return fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
}
return nil
}
// pinnedHostKeyCallback returns a host key callback that pins to the
// expected fingerprint.
func pinnedHostKeyCallback(expectedSHA256Base64 string, capturedKey *ssh.PublicKey) (ssh.HostKeyCallback, error) {
if expectedSHA256Base64 == "" {
return nil, fmt.Errorf("empty fingerprint")
}
cb := ssh.HostKeyCallback(func(hostname string, remote net.Addr, key ssh.PublicKey) error {
got := ssh.FingerprintSHA256(key)
if got != expectedSHA256Base64 {
return fmt.Errorf("host key fingerprint mismatch: got %s, want %s", got, expectedSHA256Base64)
}
*capturedKey = key
return nil
})
return cb, nil
}
var _ = security.WriteAtomic