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---
This commit is contained in:
+71
-1
@@ -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
|
||||
|
||||
@@ -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)")
|
||||
|
||||
Reference in New Issue
Block a user