Files
orca/internal/linux/bootstrap.go
T
Jon Chery 52e17aefbf feat(P12): --type linux SSH-join + UAT plan + signoff script (REQ-161..163)
--type linux (REQ-161):
- internal/linux/bootstrap.go: SSH bootstrap for generic Linux workers
  (orcas pubkey, system user, drift-events dir; no PVE role/sudoers)
- internal/cli/node.go: joinLinux function + --type linux dispatch
- peer-setup kept as documented fallback

UAT plan (REQ-162):
- docs/uat.md: 3-host topology (lead Ubuntu + pve01 Proxmox + worker01
  Ubuntu), 22 step-by-step commands, 35-claim matrix, Proxmox
  prerequisite + alternative 3xUbuntu path (C-48), signoff procedure

UAT signoff script (REQ-163, C-47):
- scripts/uat-signoff.sh: 35 idempotent read-only assertions, exit 0
  iff all pass. Includes 4 critical-path assertions: job deploys to
  remote, ACL deny-by-default, seal/unseal round-trip, OIDC health
- scripts/uat-smoke.sh: 13 CI-tested pure-CLI assertions for .coreci.yml

Tests: node join --type linux test, fingerprint test updated, smoke
test all 13 pass.

---ci---
project: orca
phase: 12
milestone: v0.13
status: complete
requirements:
  covered: [161, 162, 163]
---/ci---
2026-08-10 14:33:29 +00:00

217 lines
7.0 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"
"golang.org/x/crypto/ssh/knownhosts"
"git.cloudinit.dev/coreci/orca/internal/certpaths"
"git.cloudinit.dev/coreci/orca/internal/security"
)
// 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 {
hkcb, err := knownhosts.New(certpaths.KnownHostsPath())
if err != nil {
return nil, fmt.Errorf("linux bootstrap: known_hosts: %w", err)
}
hostKeyCallback = ssh.HostKeyCallback(func(hostname string, remote net.Addr, key ssh.PublicKey) error {
err := hkcb(hostname, remote, key)
if err == nil {
capturedHostKey = key
}
return err
})
}
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 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