Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a6ceb13491 |
+71
-1
@@ -6,9 +6,12 @@ import (
|
|||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/acl"
|
"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/identity"
|
||||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||||
"git.cloudinit.dev/coreci/orca/internal/paths"
|
"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/security"
|
||||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||||
)
|
)
|
||||||
@@ -59,7 +63,8 @@ func runInit(out interface{ Write([]byte) (int, error) }) error {
|
|||||||
Database string `json:"database"`
|
Database string `json:"database"`
|
||||||
CAFingerprint string `json:"ca_fingerprint,omitempty"`
|
CAFingerprint string `json:"ca_fingerprint,omitempty"`
|
||||||
CertFingerprint string `json:"cert_fingerprint,omitempty"`
|
CertFingerprint string `json:"cert_fingerprint,omitempty"`
|
||||||
OS string `json:"os"`
|
OS string `json:"os"
|
||||||
|
"path/filepath"`
|
||||||
NodeID string `json:"node_id"`
|
NodeID string `json:"node_id"`
|
||||||
NodeName string `json:"node_name"`
|
NodeName string `json:"node_name"`
|
||||||
Steps []stepResult `json:"steps"`
|
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.
|
// Step 5: OS detection.
|
||||||
osDetected := detectOS()
|
osDetected := detectOS()
|
||||||
summary.OS = osDetected
|
summary.OS = osDetected
|
||||||
|
|||||||
@@ -503,7 +503,7 @@ func init() {
|
|||||||
nodeJoinCmd.Flags().StringVar(&joinName, "name", "", "node name (required for --type localhost)")
|
nodeJoinCmd.Flags().StringVar(&joinName, "name", "", "node name (required for --type localhost)")
|
||||||
nodeJoinCmd.Flags().StringVar(&joinAddr, "addr", "", "node address (default localhost:8443)")
|
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(&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(&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(&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)")
|
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/osdetect"
|
||||||
"git.cloudinit.dev/coreci/orca/internal/proxmox"
|
"git.cloudinit.dev/coreci/orca/internal/proxmox"
|
||||||
"git.cloudinit.dev/coreci/orca/internal/security"
|
"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/store"
|
||||||
"git.cloudinit.dev/coreci/orca/internal/transport"
|
"git.cloudinit.dev/coreci/orca/internal/transport"
|
||||||
)
|
)
|
||||||
@@ -225,22 +226,22 @@ func DB() Check {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Network probes peer reachability via mTLS /healthz (REQ-032 completion).
|
// Network probes peer reachability via SSH exec (REQ-164 Phase A5).
|
||||||
// Peers are sourced from the persisted nodes table (not the in-memory
|
// The SSH-push model (R-001) has no daemon on :8443, so the HTTP /healthz
|
||||||
// PeerRegistry, which is empty at CLI time). Zero peers → WARN (single-node
|
// probe is replaced with an SSH "echo ok" exec. Peers are sourced from
|
||||||
// is legitimate). Any peer unreachable → FAIL (D-038).
|
// the persisted nodes table. Zero peers → WARN (single-node is
|
||||||
|
// legitimate). Any peer unreachable → FAIL (D-038).
|
||||||
func Network() Check {
|
func Network() Check {
|
||||||
return Check{
|
return Check{
|
||||||
Name: "network",
|
Name: "network",
|
||||||
Description: "peer reachability via mTLS /healthz probe",
|
Description: "peer reachability via SSH exec probe",
|
||||||
Run: func(ctx context.Context) (Result, string) {
|
Run: func(ctx context.Context) (Result, string) {
|
||||||
caPath := certpaths.CACertPath()
|
keyPath := certpaths.SSHKeyPath()
|
||||||
certPath := certpaths.ServerCertPath()
|
khPath := certpaths.KnownHostsPath()
|
||||||
keyPath := certpaths.ServerKeyPath()
|
|
||||||
|
|
||||||
// Check that cert files exist before attempting probes.
|
// Check that the SSH key exists.
|
||||||
if _, err := os.Stat(caPath); err != nil {
|
if _, err := os.Stat(keyPath); err != nil {
|
||||||
return ResultFail, fmt.Sprintf("CA cert missing: %v (run `orca cert init`)", err)
|
return ResultFail, fmt.Sprintf("SSH key missing: %v (run `orca init`)", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
path := certpaths.DBPath()
|
path := certpaths.DBPath()
|
||||||
@@ -266,15 +267,23 @@ func Network() Check {
|
|||||||
return ResultWarn, "no peers registered (single-node?)"
|
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
|
var lines []string
|
||||||
anyFail := false
|
anyFail := false
|
||||||
for _, n := range live {
|
for _, n := range live {
|
||||||
probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
err := probeHealthz(probeCtx, caPath, certPath, keyPath, n.Name, n.Address)
|
_, err := transport.Exec(probeCtx, n.Name, "echo ok")
|
||||||
cancel()
|
cancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
anyFail = true
|
if n.Kind == string(model.NodeKindLocalhost) {
|
||||||
lines = append(lines, fmt.Sprintf(" ✗ %s (%s): %v", n.Name, n.Address, err))
|
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 {
|
} else {
|
||||||
lines = append(lines, fmt.Sprintf(" ✓ %s (%s)", n.Name, n.Address))
|
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 {
|
if err := security.WriteKey(dir+"/server.key", keyPEM); err != nil {
|
||||||
t.Fatalf("WriteKey: %v", err)
|
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())
|
rep := Run(context.Background())
|
||||||
byName := make(map[string]CheckResult, len(rep.Checks))
|
byName := make(map[string]CheckResult, len(rep.Checks))
|
||||||
@@ -152,6 +156,9 @@ func TestDBCheck_IntegrityOK(t *testing.T) {
|
|||||||
func TestNetworkCheck_NoPeers(t *testing.T) {
|
func TestNetworkCheck_NoPeers(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("ORCA_HOME", dir)
|
t.Setenv("ORCA_HOME", dir)
|
||||||
|
|
||||||
|
// REQ-164: network check requires SSH key.
|
||||||
|
_, _, _ = security.GenerateOrLoadSSHKey(dir)
|
||||||
t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))
|
t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))
|
||||||
|
|
||||||
// Create a CA + server cert so the network check can build a client.
|
// 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) {
|
func TestNetworkCheck_PeerUnreachable(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("ORCA_HOME", dir)
|
t.Setenv("ORCA_HOME", dir)
|
||||||
|
|
||||||
|
// REQ-164: network check requires SSH key.
|
||||||
|
_, _, _ = security.GenerateOrLoadSSHKey(dir)
|
||||||
t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))
|
t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))
|
||||||
|
|
||||||
// Create a CA + server cert.
|
// Create a CA + server cert.
|
||||||
@@ -225,8 +235,8 @@ func TestNetworkCheck_NoCert(t *testing.T) {
|
|||||||
if r != ResultFail {
|
if r != ResultFail {
|
||||||
t.Errorf("Network check: got %s, want FAIL — %s", r, msg)
|
t.Errorf("Network check: got %s, want FAIL — %s", r, msg)
|
||||||
}
|
}
|
||||||
if !strings.Contains(msg, "CA cert missing") {
|
if !strings.Contains(msg, "SSH key missing") && !strings.Contains(msg, "CA cert missing") {
|
||||||
t.Errorf("Network check message should mention missing CA, got: %s", msg)
|
t.Errorf("Network check message should mention missing key/cert, got: %s", msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
"golang.org/x/crypto/ssh/knownhosts"
|
|
||||||
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/proxmox"
|
||||||
"git.cloudinit.dev/coreci/orca/internal/security"
|
"git.cloudinit.dev/coreci/orca/internal/security"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -112,17 +112,16 @@ func BootstrapLinux(ctx context.Context, opts Options) (*Result, error) {
|
|||||||
}
|
}
|
||||||
hostKeyCallback = hkcb
|
hostKeyCallback = hkcb
|
||||||
} else {
|
} 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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("linux bootstrap: known_hosts: %w", err)
|
return nil, fmt.Errorf("linux bootstrap: known_hosts: %w", err)
|
||||||
}
|
}
|
||||||
hostKeyCallback = ssh.HostKeyCallback(func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
hostKeyCallback = hkcb
|
||||||
err := hkcb(hostname, remote, key)
|
|
||||||
if err == nil {
|
|
||||||
capturedHostKey = key
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sshConfig := &ssh.ClientConfig{
|
sshConfig := &ssh.ClientConfig{
|
||||||
|
|||||||
@@ -318,6 +318,18 @@ func TOFUHostKeyCallbackPath(knownHostsPath, addr string, capturedKey *ssh.Publi
|
|||||||
if knownHostsPath == "" {
|
if knownHostsPath == "" {
|
||||||
knownHostsPath = certpaths.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)
|
cb, err := knownhosts.New(knownHostsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
Reference in New Issue
Block a user