a6ceb13491
Fixes UAT issues 1, 8, 9, 12C, 13: - orca init: generates SSH keypair (GenerateOrLoadSSHKey), creates empty known_hosts (0600), generates master key (GenerateMasterKey + SaveMasterKey). All were missing from runInit — every downstream SSH/secrets/cluster operation failed on a fresh init. - TOFUHostKeyCallbackPath: creates known_hosts file if it doesn't exist (defense-in-depth alongside init) - Linux bootstrap: replaces buggy inline TOFU with proxmox.TOFUHostKeyCallbackPath (first-connect key capture works) - --type flag help: includes "linux" (was "localhost or proxmox") - doctor network: SSH exec probe (was HTTP /healthz to :8443 — no daemon in SSH-push model R-001) ---ci--- project: orca milestone: v0.12.18 phase: A status: complete requirements: covered: [164] ---/ci---
361 lines
13 KiB
Go
361 lines
13 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"os"
|
|
"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"
|
|
"path/filepath"`
|
|
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 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 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)
|
|
}
|