ea42a17474
Add ingress.BootstrapRemoteIngress: renders+writes traefik static config, renders+writes+applies nft DNAT/SNAT, pushes step-ca root CA, ensures podman traefik container — all over SSH exec. Uses a heredoc- based remoteWriteFile with a random delimiter (F9 injection guard). Wired into linux/bootstrap.go Step 4d, replacing the standalone EnsureTraefikContainerRemote call with the full ingress stack. C-60: uses certpaths.CACertPath() (not CAPath). C-58: mounts host-side traefik.yml (preserves REQ-100 opt-out). C-55: pre-creates nft table before nft -f. ---ci--- project: orca phase: 4 milestone: v0.14 status: execute ---/ci---
234 lines
7.9 KiB
Go
234 lines
7.9 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/ingress"
|
|
"git.cloudinit.dev/coreci/orca/internal/proxmox"
|
|
"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 {
|
|
// 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 complete ingress stack on the remote host (R-024).
|
|
// Renders+applies nft DNAT/SNAT, pushes step-ca root CA, renders+
|
|
// writes traefik static config, ensures podman container running.
|
|
// All non-fatal (offline host tolerance).
|
|
sshExecFn := func(cmd string) ([]byte, error) {
|
|
session, err := client.NewSession()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer session.Close()
|
|
return session.CombinedOutput(cmd)
|
|
}
|
|
ctx, cancelIngress := context.WithTimeout(ctx, 120*time.Second)
|
|
defer cancelIngress()
|
|
if err := ingress.BootstrapRemoteIngress(ctx, "", ingress.RemoteExecFunc(sshExecFn)); err != nil {
|
|
opts.Logger.Warn("linux bootstrap: ingress bootstrap 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
|