52e17aefbf
--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---
217 lines
7.0 KiB
Go
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
|