Files
orca/internal/linux/bootstrap.go
T
Jon Chery 16440a89f2 feat(B): Traefik deployment to all nodes during init/join (REQ-165, REQ-167)
- internal/traefik/install.go: shared Traefik installer (download +
  systemd unit + dynamic dir). Default v3.3.0, configurable.
- orca init: installs Traefik on localhost (idempotent, non-fatal
  if offline)
- proxmox bootstrap: installs Traefik on PVE host + downloads LXC
  template (default ubuntu-24.04, --lxc-template flag)
- linux bootstrap: installs Traefik on worker
- emitter/traefik.go: directory provider (was single file);
  register pve-ct/pve-vm in RegisterTraefik
- --lxc-template flag on node join (default ubuntu-24.04)

---ci---
project: orca
milestone: v0.12.18
phase: B
status: complete
requirements:
  covered: [165, 167]
---/ci---
2026-08-10 16:09:48 +00:00

231 lines
7.6 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: Install Traefik on the remote host (REQ-165, Phase B).
// Traefik is the data-plane ingress; SSH is control plane only.
sshExecFn := func(cmd string) ([]byte, error) {
session, err := client.NewSession()
if err != nil {
return nil, err
}
defer session.Close()
return session.CombinedOutput(cmd)
}
if err := traefik.InstallRemote("", sshExecFn); err != nil {
opts.Logger.Warn("linux bootstrap: traefik install 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