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)")
|
||||
|
||||
+24
-15
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user