From a6ceb1349198507b2cdc918fc544695c07583226 Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Mon, 10 Aug 2026 16:02:19 +0000 Subject: [PATCH] =?UTF-8?q?fix(A):=20bootstrap=20plumbing=20=E2=80=94=20in?= =?UTF-8?q?it=20creates=20SSH=20key=20+=20known=5Fhosts=20+=20master=20key?= =?UTF-8?q?=20(REQ-164)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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--- --- internal/cli/init.go | 72 +++++++++++++++++++++++++++++++++- internal/cli/node.go | 2 +- internal/doctor/doctor.go | 39 +++++++++++------- internal/doctor/doctor_test.go | 14 ++++++- internal/linux/bootstrap.go | 17 ++++---- internal/proxmox/bootstrap.go | 12 ++++++ 6 files changed, 128 insertions(+), 28 deletions(-) diff --git a/internal/cli/init.go b/internal/cli/init.go index c1d37cc..e8cc06e 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -6,9 +6,12 @@ import ( "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" @@ -16,6 +19,7 @@ import ( "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" ) @@ -59,7 +63,8 @@ func runInit(out interface{ Write([]byte) (int, error) }) error { Database string `json:"database"` CAFingerprint string `json:"ca_fingerprint,omitempty"` CertFingerprint string `json:"cert_fingerprint,omitempty"` - OS string `json:"os"` + OS string `json:"os" + "path/filepath"` NodeID string `json:"node_id"` NodeName string `json:"node_name"` Steps []stepResult `json:"steps"` @@ -138,6 +143,71 @@ func runInit(out interface{ Write([]byte) (int, error) }) error { } } + // 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 diff --git a/internal/cli/node.go b/internal/cli/node.go index 11c2405..64a27d7 100644 --- a/internal/cli/node.go +++ b/internal/cli/node.go @@ -503,7 +503,7 @@ func init() { nodeJoinCmd.Flags().StringVar(&joinName, "name", "", "node name (required for --type localhost)") nodeJoinCmd.Flags().StringVar(&joinAddr, "addr", "", "node address (default localhost:8443)") nodeJoinCmd.Flags().StringVar(&joinCAFinger, "ca-fingerprint", "", "pin CA cert SHA-256 (REQ-026); fails if on-disk CA doesn't match") - nodeJoinCmd.Flags().StringVar(&joinType, "type", "localhost", "node type: localhost (default) or proxmox (SSH bootstrap)") + nodeJoinCmd.Flags().StringVar(&joinType, "type", "localhost", "node type: localhost (default), proxmox, or linux (SSH bootstrap)") nodeJoinCmd.Flags().StringVar(&joinHost, "host", "", "proxmox host address (IP/hostname, no port; required for --type proxmox)") nodeJoinCmd.Flags().StringVar(&joinSSHUser, "ssh-user", "root", "SSH username for proxmox bootstrap (default root)") nodeJoinCmd.Flags().StringVar(&joinSSHKey, "ssh-key", "", "SSH private key path for proxmox bootstrap (R-021: no passwords; default: orca key)") diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index 2556e21..5caebde 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -32,6 +32,7 @@ import ( "git.cloudinit.dev/coreci/orca/internal/osdetect" "git.cloudinit.dev/coreci/orca/internal/proxmox" "git.cloudinit.dev/coreci/orca/internal/security" + "git.cloudinit.dev/coreci/orca/internal/sshpush" "git.cloudinit.dev/coreci/orca/internal/store" "git.cloudinit.dev/coreci/orca/internal/transport" ) @@ -225,22 +226,22 @@ func DB() Check { } } -// Network probes peer reachability via mTLS /healthz (REQ-032 completion). -// Peers are sourced from the persisted nodes table (not the in-memory -// PeerRegistry, which is empty at CLI time). Zero peers → WARN (single-node -// is legitimate). Any peer unreachable → FAIL (D-038). +// Network probes peer reachability via SSH exec (REQ-164 Phase A5). +// The SSH-push model (R-001) has no daemon on :8443, so the HTTP /healthz +// probe is replaced with an SSH "echo ok" exec. Peers are sourced from +// the persisted nodes table. Zero peers → WARN (single-node is +// legitimate). Any peer unreachable → FAIL (D-038). func Network() Check { return Check{ Name: "network", - Description: "peer reachability via mTLS /healthz probe", + Description: "peer reachability via SSH exec probe", Run: func(ctx context.Context) (Result, string) { - caPath := certpaths.CACertPath() - certPath := certpaths.ServerCertPath() - keyPath := certpaths.ServerKeyPath() + keyPath := certpaths.SSHKeyPath() + khPath := certpaths.KnownHostsPath() - // Check that cert files exist before attempting probes. - if _, err := os.Stat(caPath); err != nil { - return ResultFail, fmt.Sprintf("CA cert missing: %v (run `orca cert init`)", err) + // Check that the SSH key exists. + if _, err := os.Stat(keyPath); err != nil { + return ResultFail, fmt.Sprintf("SSH key missing: %v (run `orca init`)", err) } path := certpaths.DBPath() @@ -266,15 +267,23 @@ func Network() Check { return ResultWarn, "no peers registered (single-node?)" } + // For localhost nodes, check SSH to 127.0.0.1:22 (may fail if + // SSH isn't running — that's OK, report WARN not FAIL). + transport := sshpush.NewTransport(keyPath, khPath) + var lines []string anyFail := false for _, n := range live { - probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) - err := probeHealthz(probeCtx, caPath, certPath, keyPath, n.Name, n.Address) + probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + _, err := transport.Exec(probeCtx, n.Name, "echo ok") cancel() if err != nil { - anyFail = true - lines = append(lines, fmt.Sprintf(" ✗ %s (%s): %v", n.Name, n.Address, err)) + if n.Kind == string(model.NodeKindLocalhost) { + lines = append(lines, fmt.Sprintf(" ⚠ %s (%s): %v (SSH to self may not be running)", n.Name, n.Address, err)) + } else { + anyFail = true + lines = append(lines, fmt.Sprintf(" ✗ %s (%s): %v", n.Name, n.Address, err)) + } } else { lines = append(lines, fmt.Sprintf(" ✓ %s (%s)", n.Name, n.Address)) } diff --git a/internal/doctor/doctor_test.go b/internal/doctor/doctor_test.go index 6324ee0..45b2ab9 100644 --- a/internal/doctor/doctor_test.go +++ b/internal/doctor/doctor_test.go @@ -99,6 +99,10 @@ func TestRunWithCAAndServerCert(t *testing.T) { if err := security.WriteKey(dir+"/server.key", keyPEM); err != nil { t.Fatalf("WriteKey: %v", err) } + // REQ-164: network check requires the SSH key to exist. + if _, _, err := security.GenerateOrLoadSSHKey(dir); err != nil { + t.Fatalf("GenerateOrLoadSSHKey: %v", err) + } rep := Run(context.Background()) byName := make(map[string]CheckResult, len(rep.Checks)) @@ -152,6 +156,9 @@ func TestDBCheck_IntegrityOK(t *testing.T) { func TestNetworkCheck_NoPeers(t *testing.T) { dir := t.TempDir() t.Setenv("ORCA_HOME", dir) + + // REQ-164: network check requires SSH key. + _, _, _ = security.GenerateOrLoadSSHKey(dir) t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db")) // Create a CA + server cert so the network check can build a client. @@ -179,6 +186,9 @@ func TestNetworkCheck_NoPeers(t *testing.T) { func TestNetworkCheck_PeerUnreachable(t *testing.T) { dir := t.TempDir() t.Setenv("ORCA_HOME", dir) + + // REQ-164: network check requires SSH key. + _, _, _ = security.GenerateOrLoadSSHKey(dir) t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db")) // Create a CA + server cert. @@ -225,8 +235,8 @@ func TestNetworkCheck_NoCert(t *testing.T) { if r != ResultFail { t.Errorf("Network check: got %s, want FAIL — %s", r, msg) } - if !strings.Contains(msg, "CA cert missing") { - t.Errorf("Network check message should mention missing CA, got: %s", msg) + if !strings.Contains(msg, "SSH key missing") && !strings.Contains(msg, "CA cert missing") { + t.Errorf("Network check message should mention missing key/cert, got: %s", msg) } } diff --git a/internal/linux/bootstrap.go b/internal/linux/bootstrap.go index 9787701..d7e344e 100644 --- a/internal/linux/bootstrap.go +++ b/internal/linux/bootstrap.go @@ -30,9 +30,9 @@ import ( "time" "golang.org/x/crypto/ssh" - "golang.org/x/crypto/ssh/knownhosts" "git.cloudinit.dev/coreci/orca/internal/certpaths" + "git.cloudinit.dev/coreci/orca/internal/proxmox" "git.cloudinit.dev/coreci/orca/internal/security" ) @@ -112,17 +112,16 @@ func BootstrapLinux(ctx context.Context, opts Options) (*Result, error) { } hostKeyCallback = hkcb } else { - hkcb, err := knownhosts.New(certpaths.KnownHostsPath()) + // 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 = ssh.HostKeyCallback(func(hostname string, remote net.Addr, key ssh.PublicKey) error { - err := hkcb(hostname, remote, key) - if err == nil { - capturedHostKey = key - } - return err - }) + hostKeyCallback = hkcb } sshConfig := &ssh.ClientConfig{ diff --git a/internal/proxmox/bootstrap.go b/internal/proxmox/bootstrap.go index cd6063e..9f9c175 100644 --- a/internal/proxmox/bootstrap.go +++ b/internal/proxmox/bootstrap.go @@ -318,6 +318,18 @@ func TOFUHostKeyCallbackPath(knownHostsPath, addr string, capturedKey *ssh.Publi if knownHostsPath == "" { knownHostsPath = certpaths.KnownHostsPath() } + // REQ-164 / Phase A2: create the known_hosts file if it doesn't + // exist (knownhosts.New requires the file to be present). This is + // defense-in-depth alongside init.go which also creates it. + if _, err := os.Stat(knownHostsPath); err != nil { + if os.IsNotExist(err) { + if writeErr := security.WriteAtomic(knownHostsPath, 0o600, []byte{}); writeErr != nil { + return nil, fmt.Errorf("tofu create known_hosts: %w", writeErr) + } + } else { + return nil, fmt.Errorf("tofu stat known_hosts: %w", err) + } + } cb, err := knownhosts.New(knownHostsPath) if err != nil { return nil, err