// 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