feat(P02): Proxmox SSH join + OrcaOperator role + sudoers
orca node join --type proxmox bootstraps a remote Proxmox VE 8/9 host
via SSH (REQ-050, REQ-051). The password is used only for initial auth;
subsequent access uses the deployed orca SSH key (D-031).
Changes:
- go.mod: add golang.org/x/crypto v0.54.0 (ssh + ssh/knownhosts + ed25519)
bump x/sys to v0.47.0, add x/term (indirect)
- internal/certpaths: SSHKeyPath, SSHPubPath, KnownHostsPath (D-037)
- internal/security/sshkey.go: GenerateOrLoadSSHKey (Ed25519, PKCS8 PEM,
0600/0644 modes, idempotent load per D-036)
- internal/proxmox/bootstrap.go: BootstrapProxmox SSH dance:
1. Generate/load SSH key
2. SSH dial (password + knownhosts.New TOFU per D-035)
3. Deploy pubkey to ~orca/.ssh/authorized_keys (idempotent)
4. useradd -m orca (idempotent)
5. pveum role add OrcaOperator --privs 'VM.Audit Datastore.AllocateSpace SDN.Use'
6. pveum user add orca@pam (AD-019: PAM realm, not @pve)
7. pveum acl modify / -user orca@pam -role OrcaOperator
8. Write /etc/sudoers.d/orca (AD-020: NOEXEC on pct/qm, no NOEXEC on
apt-get/dpkg, pvesh EXCLUDED — API execute bypasses NOEXEC)
9. visudo -cf validation (abort on failure)
All steps idempotent; audit-logged.
- internal/cli/node.go: --type/--host/--ssh-user/--password/--ssh-port/
--proxmox-user/--proxmox-role flags; joinProxmox() wires to
proxmox.BootstrapProxmox + registers node with kind=proxmox, os=pve.
Password zeroed after use (D-031).
- tests: sshkey generate/load round-trip, idempotency, file modes;
proxmox sudoers content (NOEXEC/NOPASSWD/pvesh-excluded),
privilege set, validation; node join flag wiring
---ci---
project: orca
phase: 2
milestone: v0.6
status: execute
---/ci---
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// SSHKeyMode is the file mode for the SSH private key. Matches the
|
||||
// CA key mode (REQ-033 spirit: 0600 for private keys).
|
||||
const SSHKeyMode os.FileMode = 0o600
|
||||
|
||||
// SSHPubMode is the file mode for the SSH public key (authorized_keys
|
||||
// line). Matches the CA cert mode (0644 for public material).
|
||||
const SSHPubMode os.FileMode = 0o644
|
||||
|
||||
const (
|
||||
sshKeyFile = "orca_ssh_key"
|
||||
sshPubFile = "orca_ssh_key.pub"
|
||||
)
|
||||
|
||||
// GenerateOrLoadSSHKey returns the orca SSH keypair, generating it
|
||||
// lazily on first call (D-037). The key is Ed25519 (smaller, faster,
|
||||
// more secure than RSA for SSH auth), persisted as PKCS8 PEM to
|
||||
// dir/orca_ssh_key (0600) and dir/orca_ssh_key.pub (0644).
|
||||
//
|
||||
// Idempotent: if both files exist with valid content, they are loaded
|
||||
// and returned without regeneration. This matches the CAInit fast-path
|
||||
// pattern (D-036 idempotency).
|
||||
//
|
||||
// Returns:
|
||||
// - keyPEM: PKCS8 PEM private key (parses with ssh.ParsePrivateKey)
|
||||
// - pubLine: authorized_keys line (ssh-ed25519 AAAA... comment\n)
|
||||
func GenerateOrLoadSSHKey(dir string) (keyPEM, pubLine []byte, err error) {
|
||||
if dir == "" {
|
||||
return nil, nil, errors.New("GenerateOrLoadSSHKey: dir is required")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, nil, fmt.Errorf("GenerateOrLoadSSHKey: mkdir: %w", err)
|
||||
}
|
||||
keyPath := filepath.Join(dir, sshKeyFile)
|
||||
pubPath := filepath.Join(dir, sshPubFile)
|
||||
|
||||
// Fast path: existing key — load and return.
|
||||
if ok, err := bothExist(keyPath, pubPath); err != nil {
|
||||
return nil, nil, err
|
||||
} else if ok {
|
||||
keyPEM, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("read SSH key: %w", err)
|
||||
}
|
||||
pubLine, err := os.ReadFile(pubPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("read SSH pub: %w", err)
|
||||
}
|
||||
return keyPEM, pubLine, nil
|
||||
}
|
||||
|
||||
// Generate Ed25519 keypair.
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("GenerateOrLoadSSHKey: ed25519 gen: %w", err)
|
||||
}
|
||||
|
||||
// Serialize private key as PKCS8 PEM (consistent with ca.key/server.key).
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("GenerateOrLoadSSHKey: marshal key: %w", err)
|
||||
}
|
||||
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
||||
|
||||
// Serialize public key as authorized_keys line.
|
||||
sshPub, err := ssh.NewPublicKey(pub)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("GenerateOrLoadSSHKey: new pubkey: %w", err)
|
||||
}
|
||||
pubLine = ssh.MarshalAuthorizedKey(sshPub)
|
||||
|
||||
// Persist with correct modes (atomic write + chmod).
|
||||
if err := writeAtomic(keyPath, SSHKeyMode, keyPEM); err != nil {
|
||||
return nil, nil, fmt.Errorf("write SSH key: %w", err)
|
||||
}
|
||||
if err := writeAtomic(pubPath, SSHPubMode, pubLine); err != nil {
|
||||
return nil, nil, fmt.Errorf("write SSH pub: %w", err)
|
||||
}
|
||||
|
||||
return keyPEM, pubLine, nil
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func TestGenerateOrLoadSSHKey_Generates(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
keyPEM, pubLine, err := GenerateOrLoadSSHKey(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("generate: %v", err)
|
||||
}
|
||||
|
||||
// Private key file exists with mode 0600.
|
||||
keyPath := filepath.Join(dir, sshKeyFile)
|
||||
info, err := os.Stat(keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("stat key: %v", err)
|
||||
}
|
||||
if info.Mode().Perm() != SSHKeyMode {
|
||||
t.Errorf("key mode = %04o, want %04o", info.Mode().Perm(), SSHKeyMode)
|
||||
}
|
||||
|
||||
// Public key file exists with mode 0644.
|
||||
pubPath := filepath.Join(dir, sshPubFile)
|
||||
info, err = os.Stat(pubPath)
|
||||
if err != nil {
|
||||
t.Fatalf("stat pub: %v", err)
|
||||
}
|
||||
if info.Mode().Perm() != SSHPubMode {
|
||||
t.Errorf("pub mode = %04o, want %04o", info.Mode().Perm(), SSHPubMode)
|
||||
}
|
||||
|
||||
// Public key line is ssh-ed25519 format.
|
||||
if !strings.HasPrefix(string(pubLine), "ssh-ed25519 ") {
|
||||
t.Errorf("pub line = %q, want ssh-ed25519 prefix", string(pubLine))
|
||||
}
|
||||
|
||||
// Private key PEM parses with ssh.ParsePrivateKey (PKCS8).
|
||||
signer, err := ssh.ParsePrivateKey(keyPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("parse private key: %v", err)
|
||||
}
|
||||
if signer.PublicKey().Type() != "ssh-ed25519" {
|
||||
t.Errorf("signer key type = %q, want ssh-ed25519", signer.PublicKey().Type())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateOrLoadSSHKey_IdempotentLoad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// First call generates.
|
||||
keyPEM1, pubLine1, err := GenerateOrLoadSSHKey(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("first generate: %v", err)
|
||||
}
|
||||
|
||||
// Second call loads existing.
|
||||
keyPEM2, pubLine2, err := GenerateOrLoadSSHKey(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("second load: %v", err)
|
||||
}
|
||||
|
||||
if string(keyPEM1) != string(keyPEM2) {
|
||||
t.Error("key was regenerated on second call (D-036 idempotency violation)")
|
||||
}
|
||||
if string(pubLine1) != string(pubLine2) {
|
||||
t.Error("pub was regenerated on second call (D-036 idempotency violation)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateOrLoadSSHKey_EmptyDir(t *testing.T) {
|
||||
_, _, err := GenerateOrLoadSSHKey("")
|
||||
if err == nil {
|
||||
t.Error("expected error for empty dir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateOrLoadSSHKey_CreatesDir(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "nested", "ssh-dir")
|
||||
if _, _, err := GenerateOrLoadSSHKey(dir); err != nil {
|
||||
t.Fatalf("generate with nested dir: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
t.Errorf("nested dir not created: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user