Files
orca/internal/cli/init.go
T
Jon Chery 0424f8ce02 feat(init): interactive remote pre-staging via ssh-copy-id
orca init now interactively prompts for remote host addresses and runs
ssh-copy-id automatically (password prompt passes through to the
operator). This makes orca init the single entry point — no manual
pre-staging of SSH keys required.

- Interactive: enter host addresses (one per line, empty line to finish)
- ssh-copy-id deploys the orca public key to each host
- Skipped in --json mode (non-interactive)
- Idempotent: re-running init can stage additional hosts

Also fixed: install.sh defaults to /usr/local/bin (on PATH for all users).
Non-root without sudo falls back to ~/.local/bin + auto-adds to .bashrc.
2026-08-10 17:37:42 +00:00

418 lines
15 KiB
Go

package cli
import (
"context"
"crypto/x509"
"encoding/pem"
"fmt"
"bufio"
"os"
"strings"
"os/exec"
"path/filepath"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/ssh"
"github.com/spf13/cobra"
"git.cloudinit.dev/coreci/orca/internal/acl"
"git.cloudinit.dev/coreci/orca/internal/certpaths"
"git.cloudinit.dev/coreci/orca/internal/identity"
"git.cloudinit.dev/coreci/orca/internal/model"
"git.cloudinit.dev/coreci/orca/internal/paths"
"git.cloudinit.dev/coreci/orca/internal/secrets"
"git.cloudinit.dev/coreci/orca/internal/security"
"git.cloudinit.dev/coreci/orca/internal/store"
)
const (
initCAN = "orca-internal-ca"
localhostName = "localhost"
localhostAddr = "localhost:8443"
)
var initCmd = &cobra.Command{
Use: "init",
Short: "Initialize local orca state with full bootstrap",
Long: `Initialize the local orca state directory and provision all
dependencies required for ` + "`orca doctor`" + ` to pass:
1. Create the namespace directory (honors $ORCA_HOME; defaults to ~/.orca)
2. Open and migrate the SQLite database (migrations 0001..0006)
3. Bootstrap the internal CA (ca.crt + ca.key) if not already present
4. Generate the server cert (server.crt + server.key) if not already present
5. Auto-detect the local OS via /etc/os-release
6. Register a localhost node (kind=localhost, os=<detected>)
Idempotent: re-running is safe and will refresh last_seen + os on the
localhost node without regenerating certs or changing the node ID.`,
RunE: func(cmd *cobra.Command, args []string) error {
return runInit(cmd.OutOrStdout())
},
}
func runInit(out interface{ Write([]byte) (int, error) }) error {
dir := certpaths.Dir()
type stepResult struct {
Label string `json:"label"`
Status string `json:"status"`
Detail string `json:"detail,omitempty"`
}
type initSummary struct {
Namespace string `json:"namespace"`
Database string `json:"database"`
CAFingerprint string `json:"ca_fingerprint,omitempty"`
CertFingerprint string `json:"cert_fingerprint,omitempty"`
OS string `json:"os"`
NodeID string `json:"node_id"`
NodeName string `json:"node_name"`
Steps []stepResult `json:"steps"`
}
summary := initSummary{Namespace: dir}
// Step 1: namespace dir.
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create orca dir: %w", err)
}
summary.Steps = append(summary.Steps, stepResult{Label: "namespace", Status: "ok", Detail: dir})
if !jsonOutput {
fmt.Fprintf(out, "✓ Namespace dir: %s\n", dir)
}
// Step 2: database + migrations.
dbPath := certpaths.DBPath()
db, err := store.Open(dbPath)
if err != nil {
return fmt.Errorf("open database: %w", err)
}
defer db.Close()
summary.Database = dbPath
summary.Steps = append(summary.Steps, stepResult{Label: "database", Status: "ok", Detail: dbPath})
if !jsonOutput {
fmt.Fprintf(out, "✓ Database initialized: %s\n", dbPath)
}
// Step 3: CA bootstrap (idempotent — CAInit has a fast-path).
ca, err := security.CAInit(dir, initCAN)
if err != nil {
return fmt.Errorf("bootstrap CA: %w", err)
}
caFp := ca.Fingerprint()
summary.CAFingerprint = caFp
summary.Steps = append(summary.Steps, stepResult{Label: "ca", Status: "ok", Detail: caFp[:16] + "..."})
if !jsonOutput {
fmt.Fprintf(out, "✓ CA provisioned: fp=%s\n", caFp[:16]+"...")
}
// Step 4: server cert (only if absent — D-036 idempotency).
certPath := certpaths.ServerCertPath()
certFp := ""
if _, err := os.Stat(certPath); err == nil {
// Already exists — load fingerprint for the summary.
if fp, err := security.Fingerprint(certPath); err == nil {
certFp = fp
}
summary.Steps = append(summary.Steps, stepResult{Label: "server-cert", Status: "skipped", Detail: "already present"})
} else if os.IsNotExist(err) {
keyPEM, csrPEM, err := security.GenerateCSR("localhost", []string{"localhost", "127.0.0.1"})
if err != nil {
return fmt.Errorf("generate server CSR: %w", err)
}
certPEM, err := ca.SignCSR(csrPEM)
if err != nil {
return fmt.Errorf("sign server CSR: %w", err)
}
if err := security.WriteCert(certPath, certPEM); err != nil {
return fmt.Errorf("write server cert: %w", err)
}
if err := security.WriteKey(certpaths.ServerKeyPath(), keyPEM); err != nil {
return fmt.Errorf("write server key: %w", err)
}
certFp = security.FingerprintOf(parseFirstCertDER(certPEM))
summary.Steps = append(summary.Steps, stepResult{Label: "server-cert", Status: "ok", Detail: certFp[:16] + "..."})
} else {
return fmt.Errorf("stat server cert: %w", err)
}
summary.CertFingerprint = certFp
if !jsonOutput {
if certFp != "" {
fmt.Fprintf(out, "✓ Server cert provisioned: fp=%s\n", certFp[:16]+"...")
} else {
fmt.Fprintf(out, "✓ Server cert: already present\n")
}
}
// Step 4a: SSH keypair (idempotent — GenerateOrLoadSSHKey has a fast-path).
// REQ-164: without this, every sshpush.Transport dial fails because
// the orca SSH key doesn't exist after a fresh init.
sshKeyPEM, sshPubLine, err := security.GenerateOrLoadSSHKey(dir)
if err != nil {
return fmt.Errorf("generate SSH keypair: %w", err)
}
_ = sshKeyPEM
sshKeyFp := ""
if pubKey, err := ssh.ParsePublicKey(sshPubLine); err == nil {
sshKeyFp = ssh.FingerprintSHA256(pubKey)
}
summary.Steps = append(summary.Steps, stepResult{Label: "ssh-key", Status: "ok", Detail: sshKeyFp[:min(16, len(sshKeyFp))] + "..."})
if !jsonOutput {
fmt.Fprintf(out, "\xe2\x9c\x93 SSH keypair provisioned: fp=%s\n", sshKeyFp[:min(16, len(sshKeyFp))]+"...")
}
// Step 4a-2: Interactively pre-stage the orca public key on remote
// hosts (ssh-copy-id). Skipped in --json mode (non-interactive).
// The operator enters host addresses (one per line, empty line to
// finish). For each host, ssh-copy-id is run; if SSH key auth is
// not yet established, ssh-copy-id prompts for the password
// interactively. This makes orca init the single entry point —
// no manual pre-staging required.
if !jsonOutput {
pubPath := certpaths.SSHPubPath()
fmt.Fprintf(out, "\n Pre-stage the orca public key on remote nodes.\n")
fmt.Fprintf(out, " Enter host addresses (one per line, empty line to skip):\n")
stagedHosts := []string{}
reader := bufio.NewReader(os.Stdin)
for {
fmt.Fprintf(out, " host> ")
line, err := reader.ReadString('\n')
if err != nil {
break
}
host := strings.TrimSpace(line)
if host == "" {
break
}
// Run ssh-copy-id interactively (password prompt passes through).
fmt.Fprintf(out, " Deploying orca key to %s...\n", host)
cmd := exec.Command("ssh-copy-id", "-i", pubPath, "-o", "StrictHostKeyChecking=accept-new", "root@"+host)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(out, " \xe2\x9a\xa0 Failed to deploy key to %s: %v\n", host, err)
continue
}
fmt.Fprintf(out, " \xe2\x9c\x93 Key deployed to %s\n", host)
stagedHosts = append(stagedHosts, host)
}
if len(stagedHosts) > 0 {
summary.Steps = append(summary.Steps, stepResult{Label: "pre-stage", Status: "ok", Detail: strings.Join(stagedHosts, ", ")})
} else {
summary.Steps = append(summary.Steps, stepResult{Label: "pre-stage", Status: "skipped", Detail: "no hosts entered"})
}
fmt.Fprintf(out, "\n")
}
// Step 4b: known_hosts file (empty, 0600). Without this, the TOFU
// host-key callback fails with "no such file" on the first SSH dial
// (knownhosts.New requires the file to exist).
knownHostsPath := certpaths.KnownHostsPath()
if _, err := os.Stat(knownHostsPath); err != nil {
if os.IsNotExist(err) {
if err := os.WriteFile(knownHostsPath, []byte{}, 0o600); err != nil {
return fmt.Errorf("create known_hosts: %w", err)
}
} else {
return fmt.Errorf("stat known_hosts: %w", err)
}
}
summary.Steps = append(summary.Steps, stepResult{Label: "known-hosts", Status: "ok", Detail: knownHostsPath})
if !jsonOutput {
fmt.Fprintf(out, "\xe2\x9c\x93 Known hosts file created: %s\n", knownHostsPath)
}
// Step 4c: master key (32-byte random, 0600). Without this, secrets
// set/get/rotate and cluster seal/unseal all fail with "stat master
// key: no such file or directory" on a fresh init.
masterKeyPath := paths.MasterKeyPath()
if _, err := os.Stat(masterKeyPath); err != nil {
if os.IsNotExist(err) {
os.MkdirAll(filepath.Dir(masterKeyPath), 0o755)
masterKey, err := secrets.GenerateMasterKey()
if err != nil {
return fmt.Errorf("generate master key: %w", err)
}
if err := secrets.SaveMasterKey(masterKeyPath, masterKey); err != nil {
return fmt.Errorf("save master key: %w", err)
}
// Zero the key from memory (defense-in-depth, REQ-154).
defer secrets.ZeroKey(masterKey)
summary.Steps = append(summary.Steps, stepResult{Label: "master-key", Status: "ok", Detail: "generated"})
if !jsonOutput {
fmt.Fprintf(out, "\xe2\x9c\x93 Master key generated: %s\n", masterKeyPath)
}
} else {
return fmt.Errorf("stat master key: %w", err)
}
} else {
summary.Steps = append(summary.Steps, stepResult{Label: "master-key", Status: "skipped", Detail: "already present"})
if !jsonOutput {
fmt.Fprintf(out, "\xe2\x9c\x93 Master key: already present\n")
}
}
// Step 4d: Install Traefik on the lead node (REQ-165, Phase B).
// Traefik is the data-plane ingress. Idempotent.
if err := installTraefikLocal(); err != nil {
if !jsonOutput {
fmt.Fprintf(out, "Traefik install skipped: %v\n", err)
}
summary.Steps = append(summary.Steps, stepResult{Label: "traefik", Status: "skipped", Detail: err.Error()})
} else {
summary.Steps = append(summary.Steps, stepResult{Label: "traefik", Status: "ok", Detail: traefikVersion})
if !jsonOutput {
fmt.Fprintf(out, "Traefik installed: %s\n", traefikVersion)
}
}
// Step 5: OS detection.
osDetected := detectOS()
summary.OS = osDetected
summary.Steps = append(summary.Steps, stepResult{Label: "os", Status: "ok", Detail: osDetected})
if !jsonOutput {
fmt.Fprintf(out, "✓ OS detected: %s\n", osDetected)
}
// Step 6: localhost node upsert (idempotent per D-036).
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
repo := store.NewNodeRepo(db)
existing, err := repo.GetByName(ctx, localhostName)
if err == nil {
// Refresh last_seen + os; keep id and joined_at.
if err := repo.UpdateLastSeenAndOS(ctx, existing.ID, osDetected); err != nil {
return fmt.Errorf("refresh localhost node: %w", err)
}
summary.NodeID = existing.ID
summary.NodeName = existing.Name
summary.Steps = append(summary.Steps, stepResult{Label: "localhost-node", Status: "refreshed", Detail: existing.ID})
if !jsonOutput {
fmt.Fprintf(out, "✓ Localhost node refreshed: %s (os=%s)\n", existing.ID, osDetected)
}
} else if err == store.ErrNotFound {
node := &model.Node{
ID: uuid.NewString(),
Name: localhostName,
Address: localhostAddr,
State: model.NodeStateReady,
JoinedAt: time.Now().UTC(),
LastSeen: time.Now().UTC(),
Kind: string(model.NodeKindLocalhost),
OS: osDetected,
}
if err := repo.Insert(ctx, node); err != nil {
return fmt.Errorf("insert localhost node: %w", err)
}
summary.NodeID = node.ID
summary.NodeName = node.Name
summary.Steps = append(summary.Steps, stepResult{Label: "localhost-node", Status: "ok", Detail: node.ID})
if !jsonOutput {
fmt.Fprintf(out, "✓ Localhost node registered: %s (os=%s)\n", node.ID, osDetected)
}
} else {
return fmt.Errorf("lookup localhost node: %w", err)
}
// Step 7: bootstrap ACL (P04, T8; C-40). Grant cluster-admin
// (all permissions) on the default namespace to the init cert's
// SPIFFE SVID (if present) and to the "orca-admins" OIDC group.
// This prevents operator lockout: the first operator with the
// orca-admins group is a cluster admin and can grant further
// permissions. Idempotent — re-running init refreshes the grant.
if err := bootstrapACL(certPath); err != nil {
// Non-fatal: log and continue. The operator can run `orca acl
// grant` manually. Failing init here would block bootstrap.
if !jsonOutput {
fmt.Fprintf(out, "⚠ ACL bootstrap skipped: %v\n", err)
}
summary.Steps = append(summary.Steps, stepResult{Label: "acl-bootstrap", Status: "skipped", Detail: err.Error()})
} else {
summary.Steps = append(summary.Steps, stepResult{Label: "acl-bootstrap", Status: "ok", Detail: "cluster-admin on _defaults"})
if !jsonOutput {
fmt.Fprintf(out, "✓ ACL bootstrapped: cluster-admin on _defaults (orca-admins group + init SVID)\n")
}
}
if jsonOutput {
return printJSON(summary)
}
fmt.Fprintf(out, "\n✓ orca init complete — run `orca doctor` to verify.\n")
return nil
}
// bootstrapACL grants cluster-admin (all permissions) on the default
// namespace to the init cert's SPIFFE SVID and to the "orca-admins"
// OIDC group. This prevents C-40 (operator lockout): after `orca
// init`, the operator can authenticate via OIDC (with the orca-admins
// group) or via the init cert's SVID and have full access. Idempotent
// — re-running init refreshes the grants.
//
// The default namespace is paths.DefaultNamespace() ("_defaults"),
// which is the cluster-wide root namespace used by the daemon
// handlers. Future phases can grant on additional namespaces.
func bootstrapACL(certPath string) error {
a, err := loadACL()
if err != nil {
return fmt.Errorf("load acl: %w", err)
}
ns := paths.DefaultNamespace()
// Grant cluster-admin to the orca-admins OIDC group. The first
// operator with this group (set in the IdP) becomes cluster admin.
a.Grant(acl.OidcGroupIdentity("orca-admins"), ns, acl.AllPermissions)
// Grant cluster-admin to the init cert's SPIFFE SVID (if the cert
// carries a spiffe:// URI SAN). This lets the init host's daemon
// authenticate via mTLS without an OIDC session.
if svid, err := svidFromCert(certPath); err == nil && svid != "" {
id := acl.Identity{Kind: acl.KindSpiffe, ID: svid}
if nsFromURI, err := acl.SpiffeNamespace(svid); err == nil {
id.Namespace = nsFromURI
a.Grant(id, nsFromURI, acl.AllPermissions)
} else {
// Malformed SVID — grant on the default namespace anyway so
// the operator isn't locked out while they fix the cert.
a.Grant(id, ns, acl.AllPermissions)
}
}
release, err := lockACL()
if err != nil {
return fmt.Errorf("acquire acl lock: %w", err)
}
defer release()
if err := saveACL(a); err != nil {
return fmt.Errorf("save acl: %w", err)
}
return nil
}
// svidFromCert reads the PEM cert at certPath and returns the first
// spiffe:// URI SAN, or ("", nil) if the cert has no SPIFFE URI.
func svidFromCert(certPath string) (string, error) {
data, err := os.ReadFile(certPath)
if err != nil {
return "", fmt.Errorf("read cert: %w", err)
}
block, _ := pem.Decode(data)
if block == nil {
return "", fmt.Errorf("decode cert pem: no block")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return "", fmt.Errorf("parse cert: %w", err)
}
for _, u := range cert.URIs {
if u != nil && u.Scheme == "spiffe" {
return u.String(), nil
}
}
return "", nil
}
// compile-time guard: identity import is used by the doc comment
// reference; keep the import so future SVID minting hooks land here.
var _ = identity.SpiffeTrustDomain
func init() {
rootCmd.AddCommand(initCmd)
}