Compare commits

..

1 Commits

Author SHA1 Message Date
Jon Chery a6ceb13491 fix(A): bootstrap plumbing — init creates SSH key + known_hosts + master key (REQ-164)
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---
2026-08-10 16:02:19 +00:00
6 changed files with 128 additions and 28 deletions
+71 -1
View File
@@ -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
+1 -1
View File
@@ -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)")
+24 -15
View File
@@ -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))
}
+12 -2
View File
@@ -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)
}
}
+8 -9
View File
@@ -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{
+12
View File
@@ -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