16440a89f2
- 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---
231 lines
7.6 KiB
Go
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
|