797bc2f412
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---
115 lines
3.6 KiB
Go
115 lines
3.6 KiB
Go
package proxmox
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestSudoersContent(t *testing.T) {
|
|
content := sudoersContent("orca")
|
|
|
|
// Must contain NOPASSWD and NOEXEC for pct and qm.
|
|
if !strings.Contains(content, "NOPASSWD: NOEXEC: /usr/bin/pct") {
|
|
t.Error("missing NOEXEC on pct (AD-020)")
|
|
}
|
|
if !strings.Contains(content, "NOPASSWD: NOEXEC: /usr/bin/qm") {
|
|
t.Error("missing NOEXEC on qm (AD-020)")
|
|
}
|
|
|
|
// apt-get and dpkg must have NOPASSWD but NOT NOEXEC (they need exec).
|
|
if !strings.Contains(content, "NOPASSWD: /usr/bin/apt-get") {
|
|
t.Error("missing NOPASSWD on apt-get")
|
|
}
|
|
if !strings.Contains(content, "NOPASSWD: /usr/bin/dpkg") {
|
|
t.Error("missing NOPASSWD on dpkg")
|
|
}
|
|
if strings.Contains(content, "NOEXEC: /usr/bin/apt-get") {
|
|
t.Error("apt-get must NOT have NOEXEC (breaks maintainer scripts)")
|
|
}
|
|
if strings.Contains(content, "NOEXEC: /usr/bin/dpkg") {
|
|
t.Error("dpkg must NOT have NOEXEC (breaks maintainer scripts)")
|
|
}
|
|
|
|
// pvesh must be EXCLUDED from the sudoers command lines (AD-020).
|
|
// Comments may mention pvesh for documentation, but no command line
|
|
// should grant sudo access to the pvesh binary.
|
|
for _, line := range strings.Split(content, "\n") {
|
|
trimmed := strings.TrimSpace(line)
|
|
if strings.HasPrefix(trimmed, "#") || trimmed == "" {
|
|
continue // skip comments and blank lines
|
|
}
|
|
if strings.Contains(trimmed, "pvesh") {
|
|
t.Errorf("pvesh must be EXCLUDED from sudoers command lines (AD-020): %s", trimmed)
|
|
}
|
|
}
|
|
|
|
// Must use the orca user.
|
|
if !strings.HasPrefix(content, "# /etc/sudoers.d/orca") {
|
|
t.Error("missing managed-by-orca header")
|
|
}
|
|
if !strings.Contains(content, "orca ALL=(root)") {
|
|
t.Error("missing orca user in sudoers")
|
|
}
|
|
}
|
|
|
|
func TestSudoersContent_CustomUser(t *testing.T) {
|
|
content := sudoersContent("custom-orca")
|
|
if !strings.Contains(content, "custom-orca ALL=(root)") {
|
|
t.Error("missing custom-orca user in sudoers")
|
|
}
|
|
}
|
|
|
|
func TestOrcaOperatorPrivileges(t *testing.T) {
|
|
// D-033: VM.Audit, Datastore.AllocateSpace, SDN.Use (space-separated).
|
|
privs := strings.Fields(OrcaOperatorPrivileges)
|
|
expected := map[string]bool{
|
|
"VM.Audit": true,
|
|
"Datastore.AllocateSpace": true,
|
|
"SDN.Use": true,
|
|
}
|
|
if len(privs) != 3 {
|
|
t.Errorf("expected 3 privileges, got %d: %v", len(privs), privs)
|
|
}
|
|
for _, p := range privs {
|
|
if !expected[p] {
|
|
t.Errorf("unexpected privilege %q", p)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBootstrapProxmox_Validation(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// Missing host.
|
|
_, err := BootstrapProxmox(ctx, Options{Password: "pw"})
|
|
if err == nil || !strings.Contains(err.Error(), "host is required") {
|
|
t.Errorf("expected host-required error, got %v", err)
|
|
}
|
|
|
|
// Missing password.
|
|
_, err = BootstrapProxmox(ctx, Options{Host: "10.0.0.1"})
|
|
if err == nil || !strings.Contains(err.Error(), "password is required") {
|
|
t.Errorf("expected password-required error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDefaultOptions(t *testing.T) {
|
|
// Verify the defaults are applied when zero-value options are passed
|
|
// (we can't test the full flow without a real SSH server, but we can
|
|
// test that the defaults are set by checking the validation path).
|
|
opts := Options{Host: "10.0.0.1", Password: "pw"}
|
|
// These would be set inside BootstrapProxmox; we test the constants
|
|
// are the expected defaults.
|
|
if DefaultProxmoxUser != "orca" {
|
|
t.Errorf("DefaultProxmoxUser = %q, want orca", DefaultProxmoxUser)
|
|
}
|
|
if DefaultProxmoxRole != "OrcaOperator" {
|
|
t.Errorf("DefaultProxmoxRole = %q, want OrcaOperator", DefaultProxmoxRole)
|
|
}
|
|
if DefaultSSHPort != 22 {
|
|
t.Errorf("DefaultSSHPort = %d, want 22", DefaultSSHPort)
|
|
}
|
|
_ = opts
|
|
}
|