diff --git a/docs/INSPECTOR.md b/docs/INSPECTOR.md index 57224bb..aa05806 100644 --- a/docs/INSPECTOR.md +++ b/docs/INSPECTOR.md @@ -167,18 +167,18 @@ The inspector reads node definitions from a pipe-delimited config file (default: ### Format ``` -# environment|user@host|password|role|ssh_key -devnet|ubuntu@1.2.3.4|mypassword|node| -devnet|ubuntu@5.6.7.8|mypassword|nameserver-ns1|/path/to/key +# environment|user@host|role +devnet|ubuntu@1.2.3.4|node +devnet|ubuntu@5.6.7.8|nameserver-ns1 ``` | Field | Description | |-------|-------------| | `environment` | Cluster name (`devnet`, `testnet`) | | `user@host` | SSH credentials | -| `password` | SSH password | | `role` | `node` or `nameserver-ns1`, `nameserver-ns2`, etc. | -| `ssh_key` | Optional path to SSH private key | + +SSH keys are resolved from rootwallet (`rw vault ssh get / --priv`). Blank lines and lines starting with `#` are ignored. diff --git a/docs/SANDBOX.md b/docs/SANDBOX.md index a2df967..d929e55 100644 --- a/docs/SANDBOX.md +++ b/docs/SANDBOX.md @@ -69,8 +69,8 @@ This will: 2. Ask for your sandbox domain 3. Create or reuse 2 Hetzner Floating IPs (~$0.005/hr each) 4. Create a firewall with sandbox rules -5. Generate an SSH keypair at `~/.orama/sandbox_key` -6. Upload the public key to Hetzner +5. Create a rootwallet SSH entry (`sandbox/root`) if it doesn't exist +6. Upload the wallet-derived public key to Hetzner 7. Display DNS configuration instructions Config is saved to `~/.orama/sandbox.yaml`. @@ -143,7 +143,7 @@ Hetzner Floating IPs are persistent IPv4 addresses that can be reassigned betwee ### SSH Authentication -Sandbox uses a standalone ed25519 keypair at `~/.orama/sandbox_key`, separate from the production wallet-derived keys. The public key is uploaded to Hetzner during setup and injected into every server at creation time. +Sandbox uses a rootwallet-derived SSH key (`sandbox/root` vault entry), the same mechanism as production. The wallet must be unlocked (`rw unlock`) before running sandbox commands that use SSH. The public key is uploaded to Hetzner during setup and injected into every server at creation time. ### Server Naming diff --git a/pkg/cli/environment.go b/pkg/cli/environment.go index 5df2a2c..b92bc5f 100644 --- a/pkg/cli/environment.go +++ b/pkg/cli/environment.go @@ -26,16 +26,16 @@ type EnvironmentConfig struct { // Default environments var DefaultEnvironments = []Environment{ { - Name: "production", + Name: "sandbox", GatewayURL: "https://dbrs.space", - Description: "Production network (dbrs.space)", - IsActive: false, + Description: "Sandbox cluster (dbrs.space)", + IsActive: true, }, { Name: "devnet", GatewayURL: "https://orama-devnet.network", - Description: "Development network (testnet)", - IsActive: true, + Description: "Development network", + IsActive: false, }, { Name: "testnet", @@ -65,7 +65,7 @@ func LoadEnvironmentConfig() (*EnvironmentConfig, error) { if _, err := os.Stat(path); os.IsNotExist(err) { return &EnvironmentConfig{ Environments: DefaultEnvironments, - ActiveEnvironment: "devnet", + ActiveEnvironment: "sandbox", }, nil } @@ -120,9 +120,9 @@ func GetActiveEnvironment() (*Environment, error) { } } - // Fallback to devnet if active environment not found + // Fallback to sandbox if active environment not found for _, env := range envConfig.Environments { - if env.Name == "devnet" { + if env.Name == "sandbox" { return &env, nil } } @@ -184,7 +184,7 @@ func InitializeEnvironments() error { envConfig := &EnvironmentConfig{ Environments: DefaultEnvironments, - ActiveEnvironment: "devnet", + ActiveEnvironment: "sandbox", } return SaveEnvironmentConfig(envConfig) diff --git a/pkg/cli/monitor/collector.go b/pkg/cli/monitor/collector.go index 1742667..8fcec53 100644 --- a/pkg/cli/monitor/collector.go +++ b/pkg/cli/monitor/collector.go @@ -157,7 +157,7 @@ func loadSandboxNodes(cfg CollectorConfig) ([]inspector.Node, func(), error) { return nil, noop, fmt.Errorf("no active sandbox found") } - nodes := state.ToNodes(sbxCfg.ExpandedPrivateKeyPath()) + nodes := state.ToNodes(sbxCfg.SSHKey.VaultTarget) if cfg.NodeFilter != "" { nodes = filterByHost(nodes, cfg.NodeFilter) } @@ -165,5 +165,10 @@ func loadSandboxNodes(cfg CollectorConfig) ([]inspector.Node, func(), error) { return nil, noop, fmt.Errorf("no nodes found for sandbox %q", state.Name) } - return nodes, noop, nil + cleanup, err := remotessh.PrepareNodeKeys(nodes) + if err != nil { + return nil, noop, fmt.Errorf("prepare SSH keys: %w", err) + } + + return nodes, cleanup, nil } diff --git a/pkg/cli/remotessh/wallet.go b/pkg/cli/remotessh/wallet.go index bd8b0b5..5675110 100644 --- a/pkg/cli/remotessh/wallet.go +++ b/pkg/cli/remotessh/wallet.go @@ -36,14 +36,21 @@ func PrepareNodeKeys(nodes []inspector.Node) (cleanup func(), err error) { var allKeyPaths []string for i := range nodes { - key := nodes[i].Host + "/" + nodes[i].User + // Use VaultTarget if set, otherwise default to Host/User + var key string + if nodes[i].VaultTarget != "" { + key = nodes[i].VaultTarget + } else { + key = nodes[i].Host + "/" + nodes[i].User + } if existing, ok := keyPaths[key]; ok { nodes[i].SSHKey = existing continue } // Call rw to get the private key PEM - pem, err := resolveWalletKey(rw, nodes[i].Host, nodes[i].User) + host, user := parseVaultTarget(key) + pem, err := resolveWalletKey(rw, host, user) if err != nil { // Cleanup any keys already written before returning error cleanupKeys(tmpDir, allKeyPaths) @@ -81,7 +88,12 @@ func LoadAgentKeys(nodes []inspector.Node) error { seen := make(map[string]bool) var targets []string for _, n := range nodes { - key := n.Host + "/" + n.User + var key string + if n.VaultTarget != "" { + key = n.VaultTarget + } else { + key = n.Host + "/" + n.User + } if seen[key] { continue } @@ -104,6 +116,78 @@ func LoadAgentKeys(nodes []inspector.Node) error { return nil } +// EnsureVaultEntry creates a wallet SSH entry if it doesn't already exist. +// Checks existence via `rw vault ssh get --pub`, and if missing, +// runs `rw vault ssh add ` to create it. +func EnsureVaultEntry(vaultTarget string) error { + rw, err := rwBinary() + if err != nil { + return err + } + + // Check if entry exists by trying to get the public key + cmd := exec.Command(rw, "vault", "ssh", "get", vaultTarget, "--pub") + if err := cmd.Run(); err == nil { + return nil // entry already exists + } + + // Entry doesn't exist — try to create it + addCmd := exec.Command(rw, "vault", "ssh", "add", vaultTarget) + addCmd.Stdin = os.Stdin + addCmd.Stdout = os.Stderr + addCmd.Stderr = os.Stderr + if err := addCmd.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + stderr := strings.TrimSpace(string(exitErr.Stderr)) + if strings.Contains(stderr, "not unlocked") || strings.Contains(stderr, "session") { + return fmt.Errorf("wallet is locked — run: rw unlock") + } + } + return fmt.Errorf("rw vault ssh add %s failed: %w", vaultTarget, err) + } + return nil +} + +// ResolveVaultPublicKey returns the OpenSSH public key string for a vault entry. +// Calls `rw vault ssh get --pub`. +func ResolveVaultPublicKey(vaultTarget string) (string, error) { + rw, err := rwBinary() + if err != nil { + return "", err + } + + cmd := exec.Command(rw, "vault", "ssh", "get", vaultTarget, "--pub") + out, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + stderr := strings.TrimSpace(string(exitErr.Stderr)) + if strings.Contains(stderr, "No SSH entry") { + return "", fmt.Errorf("no vault SSH entry for %s — run: rw vault ssh add %s", vaultTarget, vaultTarget) + } + if strings.Contains(stderr, "not unlocked") || strings.Contains(stderr, "session") { + return "", fmt.Errorf("wallet is locked — run: rw unlock") + } + return "", fmt.Errorf("%s", stderr) + } + return "", fmt.Errorf("rw command failed: %w", err) + } + + pubKey := strings.TrimSpace(string(out)) + if !strings.HasPrefix(pubKey, "ssh-") { + return "", fmt.Errorf("rw returned invalid public key for %s", vaultTarget) + } + return pubKey, nil +} + +// parseVaultTarget splits a "host/user" vault target string into host and user. +func parseVaultTarget(target string) (host, user string) { + idx := strings.Index(target, "/") + if idx < 0 { + return target, "" + } + return target[:idx], target[idx+1:] +} + // resolveWalletKey calls `rw vault ssh get / --priv` // and returns the PEM string. Requires an active rw session. func resolveWalletKey(rw string, host, user string) (string, error) { diff --git a/pkg/cli/remotessh/wallet_test.go b/pkg/cli/remotessh/wallet_test.go new file mode 100644 index 0000000..b3fece6 --- /dev/null +++ b/pkg/cli/remotessh/wallet_test.go @@ -0,0 +1,29 @@ +package remotessh + +import "testing" + +func TestParseVaultTarget(t *testing.T) { + tests := []struct { + target string + wantHost string + wantUser string + }{ + {"sandbox/root", "sandbox", "root"}, + {"192.168.1.1/ubuntu", "192.168.1.1", "ubuntu"}, + {"my-host/my-user", "my-host", "my-user"}, + {"noslash", "noslash", ""}, + {"a/b/c", "a", "b/c"}, + } + + for _, tt := range tests { + t.Run(tt.target, func(t *testing.T) { + host, user := parseVaultTarget(tt.target) + if host != tt.wantHost { + t.Errorf("parseVaultTarget(%q) host = %q, want %q", tt.target, host, tt.wantHost) + } + if user != tt.wantUser { + t.Errorf("parseVaultTarget(%q) user = %q, want %q", tt.target, user, tt.wantUser) + } + }) + } +} diff --git a/pkg/cli/sandbox/config.go b/pkg/cli/sandbox/config.go index 11eb410..7d89695 100644 --- a/pkg/cli/sandbox/config.go +++ b/pkg/cli/sandbox/config.go @@ -25,11 +25,10 @@ type FloatIP struct { IP string `yaml:"ip"` } -// SSHKeyConfig holds SSH key paths and the Hetzner resource ID. +// SSHKeyConfig holds the wallet vault target and Hetzner resource ID. type SSHKeyConfig struct { - HetznerID int64 `yaml:"hetzner_id"` - PrivateKeyPath string `yaml:"private_key_path"` - PublicKeyPath string `yaml:"public_key_path"` + HetznerID int64 `yaml:"hetzner_id"` + VaultTarget string `yaml:"vault_target"` // e.g. "sandbox/root" } // configDir returns ~/.orama/, creating it if needed. @@ -114,8 +113,8 @@ func (c *Config) validate() error { if len(c.FloatingIPs) < 2 { return fmt.Errorf("2 floating IPs required, got %d", len(c.FloatingIPs)) } - if c.SSHKey.PrivateKeyPath == "" { - return fmt.Errorf("ssh_key.private_key_path is required") + if c.SSHKey.VaultTarget == "" { + return fmt.Errorf("ssh_key.vault_target is required (run: orama sandbox setup)") } return nil } @@ -128,26 +127,7 @@ func (c *Config) Defaults() { if c.ServerType == "" { c.ServerType = "cx23" } -} - -// ExpandedPrivateKeyPath returns the absolute path to the SSH private key. -func (c *Config) ExpandedPrivateKeyPath() string { - return expandHome(c.SSHKey.PrivateKeyPath) -} - -// ExpandedPublicKeyPath returns the absolute path to the SSH public key. -func (c *Config) ExpandedPublicKeyPath() string { - return expandHome(c.SSHKey.PublicKeyPath) -} - -// expandHome replaces a leading ~ with the user's home directory. -func expandHome(path string) string { - if len(path) < 2 || path[:2] != "~/" { - return path + if c.SSHKey.VaultTarget == "" { + c.SSHKey.VaultTarget = "sandbox/root" } - home, err := os.UserHomeDir() - if err != nil { - return path - } - return filepath.Join(home, path[2:]) } diff --git a/pkg/cli/sandbox/config_test.go b/pkg/cli/sandbox/config_test.go new file mode 100644 index 0000000..dc5632b --- /dev/null +++ b/pkg/cli/sandbox/config_test.go @@ -0,0 +1,53 @@ +package sandbox + +import "testing" + +func TestConfig_Validate_EmptyVaultTarget(t *testing.T) { + cfg := &Config{ + HetznerAPIToken: "test-token", + Domain: "test.example.com", + FloatingIPs: []FloatIP{{ID: 1, IP: "1.1.1.1"}, {ID: 2, IP: "2.2.2.2"}}, + SSHKey: SSHKeyConfig{HetznerID: 1, VaultTarget: ""}, + } + if err := cfg.validate(); err == nil { + t.Error("validate() should reject empty VaultTarget") + } +} + +func TestConfig_Validate_WithVaultTarget(t *testing.T) { + cfg := &Config{ + HetznerAPIToken: "test-token", + Domain: "test.example.com", + FloatingIPs: []FloatIP{{ID: 1, IP: "1.1.1.1"}, {ID: 2, IP: "2.2.2.2"}}, + SSHKey: SSHKeyConfig{HetznerID: 1, VaultTarget: "sandbox/root"}, + } + if err := cfg.validate(); err != nil { + t.Errorf("validate() unexpected error: %v", err) + } +} + +func TestConfig_Defaults_SetsVaultTarget(t *testing.T) { + cfg := &Config{} + cfg.Defaults() + + if cfg.SSHKey.VaultTarget != "sandbox/root" { + t.Errorf("Defaults() VaultTarget = %q, want sandbox/root", cfg.SSHKey.VaultTarget) + } + if cfg.Location != "nbg1" { + t.Errorf("Defaults() Location = %q, want nbg1", cfg.Location) + } + if cfg.ServerType != "cx23" { + t.Errorf("Defaults() ServerType = %q, want cx23", cfg.ServerType) + } +} + +func TestConfig_Defaults_PreservesExistingVaultTarget(t *testing.T) { + cfg := &Config{ + SSHKey: SSHKeyConfig{VaultTarget: "custom/user"}, + } + cfg.Defaults() + + if cfg.SSHKey.VaultTarget != "custom/user" { + t.Errorf("Defaults() should preserve existing VaultTarget, got %q", cfg.SSHKey.VaultTarget) + } +} diff --git a/pkg/cli/sandbox/create.go b/pkg/cli/sandbox/create.go index 29434ac..2c26dac 100644 --- a/pkg/cli/sandbox/create.go +++ b/pkg/cli/sandbox/create.go @@ -19,6 +19,13 @@ func Create(name string) error { return err } + // Resolve wallet SSH key once for all phases + sshKeyPath, cleanup, err := resolveVaultKeyOnce(cfg.SSHKey.VaultTarget) + if err != nil { + return fmt.Errorf("prepare SSH key: %w", err) + } + defer cleanup() + // Check for existing active sandbox active, err := FindActiveSandbox() if err != nil { @@ -55,20 +62,20 @@ func Create(name string) error { // Phase 2: Assign floating IPs fmt.Println("\nPhase 2: Assigning floating IPs...") - if err := phase2AssignFloatingIPs(client, cfg, state); err != nil { + if err := phase2AssignFloatingIPs(client, cfg, state, sshKeyPath); err != nil { return fmt.Errorf("assign floating IPs: %w", err) } SaveState(state) // Phase 3: Upload binary archive fmt.Println("\nPhase 3: Uploading binary archive...") - if err := phase3UploadArchive(cfg, state); err != nil { + if err := phase3UploadArchive(state, sshKeyPath); err != nil { return fmt.Errorf("upload archive: %w", err) } // Phase 4: Install genesis node fmt.Println("\nPhase 4: Installing genesis node...") - tokens, err := phase4InstallGenesis(cfg, state) + tokens, err := phase4InstallGenesis(cfg, state, sshKeyPath) if err != nil { state.Status = StatusError SaveState(state) @@ -77,7 +84,7 @@ func Create(name string) error { // Phase 5: Join remaining nodes fmt.Println("\nPhase 5: Joining remaining nodes...") - if err := phase5JoinNodes(cfg, state, tokens); err != nil { + if err := phase5JoinNodes(cfg, state, tokens, sshKeyPath); err != nil { state.Status = StatusError SaveState(state) return fmt.Errorf("join nodes: %w", err) @@ -85,7 +92,7 @@ func Create(name string) error { // Phase 6: Verify cluster fmt.Println("\nPhase 6: Verifying cluster...") - phase6Verify(cfg, state) + phase6Verify(cfg, state, sshKeyPath) state.Status = StatusRunning SaveState(state) @@ -94,6 +101,18 @@ func Create(name string) error { return nil } +// resolveVaultKeyOnce resolves a wallet SSH key to a temp file. +// Returns the key path, cleanup function, and any error. +func resolveVaultKeyOnce(vaultTarget string) (string, func(), error) { + node := inspector.Node{User: "root", Host: "resolve-only", VaultTarget: vaultTarget} + nodes := []inspector.Node{node} + cleanup, err := remotessh.PrepareNodeKeys(nodes) + if err != nil { + return "", func() {}, err + } + return nodes[0].SSHKey, cleanup, nil +} + // phase1ProvisionServers creates 5 Hetzner servers in parallel. func phase1ProvisionServers(client *HetznerClient, cfg *Config, state *SandboxState) error { type serverResult struct { @@ -190,9 +209,7 @@ func phase1ProvisionServers(client *HetznerClient, cfg *Config, state *SandboxSt } // phase2AssignFloatingIPs assigns floating IPs and configures loopback. -func phase2AssignFloatingIPs(client *HetznerClient, cfg *Config, state *SandboxState) error { - sshKeyPath := cfg.ExpandedPrivateKeyPath() - +func phase2AssignFloatingIPs(client *HetznerClient, cfg *Config, state *SandboxState, sshKeyPath string) error { for i := 0; i < 2 && i < len(cfg.FloatingIPs) && i < len(state.Servers); i++ { fip := cfg.FloatingIPs[i] srv := state.Servers[i] @@ -245,7 +262,7 @@ func waitForSSH(node inspector.Node, timeout time.Duration) error { // phase3UploadArchive uploads the binary archive to the genesis node, then fans out // to the remaining nodes server-to-server (much faster than uploading from local machine). -func phase3UploadArchive(cfg *Config, state *SandboxState) error { +func phase3UploadArchive(state *SandboxState, sshKeyPath string) error { archivePath := findNewestArchive() if archivePath == "" { fmt.Println(" No binary archive found, run `orama build` first") @@ -255,7 +272,6 @@ func phase3UploadArchive(cfg *Config, state *SandboxState) error { info, _ := os.Stat(archivePath) fmt.Printf(" Archive: %s (%s)\n", filepath.Base(archivePath), formatBytes(info.Size())) - sshKeyPath := cfg.ExpandedPrivateKeyPath() if err := fanoutArchive(state.Servers, sshKeyPath, archivePath); err != nil { return err } @@ -265,9 +281,8 @@ func phase3UploadArchive(cfg *Config, state *SandboxState) error { } // phase4InstallGenesis installs the genesis node and generates invite tokens. -func phase4InstallGenesis(cfg *Config, state *SandboxState) ([]string, error) { +func phase4InstallGenesis(cfg *Config, state *SandboxState, sshKeyPath string) ([]string, error) { genesis := state.GenesisServer() - sshKeyPath := cfg.ExpandedPrivateKeyPath() node := inspector.Node{User: "root", Host: genesis.IP, SSHKey: sshKeyPath} // Install genesis @@ -304,9 +319,8 @@ func phase4InstallGenesis(cfg *Config, state *SandboxState) ([]string, error) { } // phase5JoinNodes joins the remaining 4 nodes to the cluster (serial). -func phase5JoinNodes(cfg *Config, state *SandboxState, tokens []string) error { +func phase5JoinNodes(cfg *Config, state *SandboxState, tokens []string, sshKeyPath string) error { genesisIP := state.GenesisServer().IP - sshKeyPath := cfg.ExpandedPrivateKeyPath() for i := 1; i < len(state.Servers); i++ { srv := state.Servers[i] @@ -340,8 +354,7 @@ func phase5JoinNodes(cfg *Config, state *SandboxState, tokens []string) error { } // phase6Verify runs a basic cluster health check. -func phase6Verify(cfg *Config, state *SandboxState) { - sshKeyPath := cfg.ExpandedPrivateKeyPath() +func phase6Verify(cfg *Config, state *SandboxState, sshKeyPath string) { genesis := state.GenesisServer() node := inspector.Node{User: "root", Host: genesis.IP, SSHKey: sshKeyPath} diff --git a/pkg/cli/sandbox/reset.go b/pkg/cli/sandbox/reset.go index dbc4dae..9d04cd6 100644 --- a/pkg/cli/sandbox/reset.go +++ b/pkg/cli/sandbox/reset.go @@ -42,8 +42,6 @@ func Reset() error { fmt.Println() fmt.Println("Local files to remove:") fmt.Println(" ~/.orama/sandbox.yaml") - fmt.Println(" ~/.orama/sandbox_key") - fmt.Println(" ~/.orama/sandbox_key.pub") fmt.Println() reader := bufio.NewReader(os.Stdin) @@ -100,29 +98,21 @@ func Reset() error { return nil } -// resetLocalFiles removes the sandbox config and SSH key files. +// resetLocalFiles removes the sandbox config file. func resetLocalFiles() error { dir, err := configDir() if err != nil { return err } - files := []string{ - dir + "/sandbox.yaml", - dir + "/sandbox_key", - dir + "/sandbox_key.pub", - } - + configFile := dir + "/sandbox.yaml" fmt.Println("Removing local files...") - for _, f := range files { - if err := os.Remove(f); err != nil { - if os.IsNotExist(err) { - continue - } - fmt.Fprintf(os.Stderr, " Warning: could not remove %s: %v\n", f, err) - } else { - fmt.Printf(" Removed %s\n", f) + if err := os.Remove(configFile); err != nil { + if !os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, " Warning: could not remove %s: %v\n", configFile, err) } + } else { + fmt.Printf(" Removed %s\n", configFile) } return nil diff --git a/pkg/cli/sandbox/rollout.go b/pkg/cli/sandbox/rollout.go index 8a15385..284b032 100644 --- a/pkg/cli/sandbox/rollout.go +++ b/pkg/cli/sandbox/rollout.go @@ -28,7 +28,12 @@ func Rollout(name string, flags RolloutFlags) error { return err } - sshKeyPath := cfg.ExpandedPrivateKeyPath() + sshKeyPath, cleanup, err := resolveVaultKeyOnce(cfg.SSHKey.VaultTarget) + if err != nil { + return fmt.Errorf("prepare SSH key: %w", err) + } + defer cleanup() + fmt.Printf("Rolling out to sandbox %q (%d nodes)\n\n", state.Name, len(state.Servers)) // Step 1: Find or require binary archive diff --git a/pkg/cli/sandbox/setup.go b/pkg/cli/sandbox/setup.go index f702422..9976dbe 100644 --- a/pkg/cli/sandbox/setup.go +++ b/pkg/cli/sandbox/setup.go @@ -2,9 +2,6 @@ package sandbox import ( "bufio" - "crypto/ed25519" - "crypto/rand" - "encoding/pem" "fmt" "os" "os/exec" @@ -13,7 +10,7 @@ import ( "strings" "time" - "golang.org/x/crypto/ssh" + "github.com/DeBrosOfficial/network/pkg/cli/remotessh" ) // Setup runs the interactive sandbox setup wizard. @@ -386,103 +383,53 @@ func setupFirewall(client *HetznerClient) (int64, error) { return fw.ID, nil } -// setupSSHKey generates an SSH keypair and uploads it to Hetzner. +// setupSSHKey ensures a wallet SSH entry exists and uploads its public key to Hetzner. func setupSSHKey(client *HetznerClient) (SSHKeyConfig, error) { - dir, err := configDir() - if err != nil { - return SSHKeyConfig{}, err - } + const vaultTarget = "sandbox/root" - privPath := dir + "/sandbox_key" - pubPath := privPath + ".pub" - - // Check for existing key - if _, err := os.Stat(privPath); err == nil { - fmt.Printf(" SSH key already exists: %s\n", privPath) - - // Read public key and check if it's on Hetzner - pubData, err := os.ReadFile(pubPath) - if err != nil { - return SSHKeyConfig{}, fmt.Errorf("read public key: %w", err) - } - - // Try to upload (will fail with uniqueness error if already exists) - key, err := client.UploadSSHKey("orama-sandbox", strings.TrimSpace(string(pubData))) - if err != nil { - // Key already exists on Hetzner — find it by fingerprint - sshPubKey, _, _, _, parseErr := ssh.ParseAuthorizedKey(pubData) - if parseErr != nil { - return SSHKeyConfig{}, fmt.Errorf("parse public key to find fingerprint: %w", parseErr) - } - fingerprint := ssh.FingerprintLegacyMD5(sshPubKey) - - existing, listErr := client.ListSSHKeysByFingerprint(fingerprint) - if listErr == nil && len(existing) > 0 { - fmt.Printf(" Found existing SSH key on Hetzner (ID: %d)\n", existing[0].ID) - return SSHKeyConfig{ - HetznerID: existing[0].ID, - PrivateKeyPath: "~/.orama/sandbox_key", - PublicKeyPath: "~/.orama/sandbox_key.pub", - }, nil - } - - return SSHKeyConfig{}, fmt.Errorf("SSH key exists locally but could not find it on Hetzner (fingerprint: %s): %w", fingerprint, err) - } - - fmt.Printf(" Uploaded to Hetzner (ID: %d)\n", key.ID) - return SSHKeyConfig{ - HetznerID: key.ID, - PrivateKeyPath: "~/.orama/sandbox_key", - PublicKeyPath: "~/.orama/sandbox_key.pub", - }, nil - } - - // Generate new ed25519 keypair - fmt.Print(" Generating ed25519 keypair... ") - pubKey, privKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { + // Ensure wallet entry exists (creates if missing) + fmt.Print(" Ensuring wallet SSH entry... ") + if err := remotessh.EnsureVaultEntry(vaultTarget); err != nil { fmt.Println("FAILED") - return SSHKeyConfig{}, fmt.Errorf("generate key: %w", err) - } - - // Marshal private key to OpenSSH format - pemBlock, err := ssh.MarshalPrivateKey(privKey, "") - if err != nil { - fmt.Println("FAILED") - return SSHKeyConfig{}, fmt.Errorf("marshal private key: %w", err) - } - - privPEM := pem.EncodeToMemory(pemBlock) - if err := os.WriteFile(privPath, privPEM, 0600); err != nil { - fmt.Println("FAILED") - return SSHKeyConfig{}, fmt.Errorf("write private key: %w", err) - } - - // Marshal public key to authorized_keys format - sshPubKey, err := ssh.NewPublicKey(pubKey) - if err != nil { - return SSHKeyConfig{}, fmt.Errorf("convert public key: %w", err) - } - pubStr := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPubKey))) - - if err := os.WriteFile(pubPath, []byte(pubStr+"\n"), 0644); err != nil { - return SSHKeyConfig{}, fmt.Errorf("write public key: %w", err) + return SSHKeyConfig{}, fmt.Errorf("ensure vault entry: %w", err) } fmt.Println("OK") - // Upload to Hetzner + // Get public key from wallet + fmt.Print(" Resolving public key from wallet... ") + pubStr, err := remotessh.ResolveVaultPublicKey(vaultTarget) + if err != nil { + fmt.Println("FAILED") + return SSHKeyConfig{}, fmt.Errorf("resolve public key: %w", err) + } + fmt.Println("OK") + + // Upload to Hetzner (will fail with uniqueness error if already exists) fmt.Print(" Uploading to Hetzner... ") key, err := client.UploadSSHKey("orama-sandbox", pubStr) if err != nil { + // Key may already exist on Hetzner — try to find by fingerprint + existing, listErr := client.ListSSHKeysByFingerprint("") // empty = list all + if listErr == nil { + for _, k := range existing { + if strings.TrimSpace(k.PublicKey) == pubStr { + fmt.Printf("already exists (ID: %d)\n", k.ID) + return SSHKeyConfig{ + HetznerID: k.ID, + VaultTarget: vaultTarget, + }, nil + } + } + } + fmt.Println("FAILED") return SSHKeyConfig{}, fmt.Errorf("upload SSH key: %w", err) } fmt.Printf("OK (ID: %d)\n", key.ID) return SSHKeyConfig{ - HetznerID: key.ID, - PrivateKeyPath: "~/.orama/sandbox_key", - PublicKeyPath: "~/.orama/sandbox_key.pub", + HetznerID: key.ID, + VaultTarget: vaultTarget, }, nil } diff --git a/pkg/cli/sandbox/ssh_cmd.go b/pkg/cli/sandbox/ssh_cmd.go index f09ef08..9b30115 100644 --- a/pkg/cli/sandbox/ssh_cmd.go +++ b/pkg/cli/sandbox/ssh_cmd.go @@ -3,7 +3,7 @@ package sandbox import ( "fmt" "os" - "syscall" + "os/exec" ) // SSHInto opens an interactive SSH session to a sandbox node. @@ -23,26 +23,35 @@ func SSHInto(name string, nodeNum int) error { } srv := state.Servers[nodeNum-1] - sshKeyPath := cfg.ExpandedPrivateKeyPath() + + sshKeyPath, cleanup, err := resolveVaultKeyOnce(cfg.SSHKey.VaultTarget) + if err != nil { + return fmt.Errorf("prepare SSH key: %w", err) + } fmt.Printf("Connecting to %s (%s, %s)...\n", srv.Name, srv.IP, srv.Role) // Find ssh binary sshBin, err := findSSHBinary() if err != nil { + cleanup() return err } - // Replace current process with SSH - args := []string{ - "ssh", + // Run SSH as a child process so cleanup runs after the session ends + cmd := exec.Command(sshBin, "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-i", sshKeyPath, fmt.Sprintf("root@%s", srv.IP), - } + ) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr - return syscall.Exec(sshBin, args, os.Environ()) + err = cmd.Run() + cleanup() + return err } // findSSHBinary locates the ssh binary in PATH. diff --git a/pkg/cli/sandbox/state.go b/pkg/cli/sandbox/state.go index 34d4f87..064fe6a 100644 --- a/pkg/cli/sandbox/state.go +++ b/pkg/cli/sandbox/state.go @@ -165,8 +165,8 @@ func FindActiveSandbox() (*SandboxState, error) { } // ToNodes converts sandbox servers to inspector.Node structs for SSH operations. -// Sets SSHKey to the provided key path on each node. -func (s *SandboxState) ToNodes(sshKeyPath string) []inspector.Node { +// Sets VaultTarget on each node so PrepareNodeKeys resolves from the wallet. +func (s *SandboxState) ToNodes(vaultTarget string) []inspector.Node { nodes := make([]inspector.Node, len(s.Servers)) for i, srv := range s.Servers { nodes[i] = inspector.Node{ @@ -174,7 +174,7 @@ func (s *SandboxState) ToNodes(sshKeyPath string) []inspector.Node { User: "root", Host: srv.IP, Role: srv.Role, - SSHKey: sshKeyPath, + VaultTarget: vaultTarget, } } return nodes diff --git a/pkg/cli/sandbox/state_test.go b/pkg/cli/sandbox/state_test.go index e00adb4..84580f0 100644 --- a/pkg/cli/sandbox/state_test.go +++ b/pkg/cli/sandbox/state_test.go @@ -156,7 +156,7 @@ func TestToNodes(t *testing.T) { }, } - nodes := state.ToNodes("/tmp/key") + nodes := state.ToNodes("sandbox/root") if len(nodes) != 2 { t.Fatalf("ToNodes() returned %d nodes, want 2", len(nodes)) } @@ -166,8 +166,11 @@ func TestToNodes(t *testing.T) { if nodes[0].User != "root" { t.Errorf("node[0].User = %s, want root", nodes[0].User) } - if nodes[0].SSHKey != "/tmp/key" { - t.Errorf("node[0].SSHKey = %s, want /tmp/key", nodes[0].SSHKey) + if nodes[0].VaultTarget != "sandbox/root" { + t.Errorf("node[0].VaultTarget = %s, want sandbox/root", nodes[0].VaultTarget) + } + if nodes[0].SSHKey != "" { + t.Errorf("node[0].SSHKey = %s, want empty (set by PrepareNodeKeys)", nodes[0].SSHKey) } if nodes[0].Environment != "sandbox" { t.Errorf("node[0].Environment = %s, want sandbox", nodes[0].Environment) diff --git a/pkg/cli/sandbox/status.go b/pkg/cli/sandbox/status.go index 544ca60..fbc070f 100644 --- a/pkg/cli/sandbox/status.go +++ b/pkg/cli/sandbox/status.go @@ -77,7 +77,12 @@ func Status(name string) error { return err } - sshKeyPath := cfg.ExpandedPrivateKeyPath() + sshKeyPath, cleanup, err := resolveVaultKeyOnce(cfg.SSHKey.VaultTarget) + if err != nil { + return fmt.Errorf("prepare SSH key: %w", err) + } + defer cleanup() + fmt.Printf("Sandbox: %s (status: %s)\n\n", state.Name, state.Status) for _, srv := range state.Servers { diff --git a/pkg/encryption/wallet_keygen.go b/pkg/encryption/wallet_keygen.go deleted file mode 100644 index d65a182..0000000 --- a/pkg/encryption/wallet_keygen.go +++ /dev/null @@ -1,194 +0,0 @@ -package encryption - -import ( - "crypto/ed25519" - "crypto/sha256" - "fmt" - "io" - "os" - "os/exec" - "strings" - - "golang.org/x/crypto/curve25519" - "golang.org/x/crypto/hkdf" -) - -// NodeKeys holds all cryptographic keys derived from a wallet's master key. -type NodeKeys struct { - LibP2PPrivateKey ed25519.PrivateKey // Ed25519 for LibP2P identity - LibP2PPublicKey ed25519.PublicKey - WireGuardKey [32]byte // Curve25519 private key (clamped) - WireGuardPubKey [32]byte // Curve25519 public key - IPFSPrivateKey ed25519.PrivateKey - IPFSPublicKey ed25519.PublicKey - ClusterPrivateKey ed25519.PrivateKey // IPFS Cluster identity - ClusterPublicKey ed25519.PublicKey - JWTPrivateKey ed25519.PrivateKey // EdDSA JWT signing key - JWTPublicKey ed25519.PublicKey -} - -// DeriveNodeKeysFromWallet calls `rw derive` to get a master key from the user's -// Root Wallet, then expands it into all node keys. The wallet's private key never -// leaves the `rw` process. -// -// vpsIP is used as the HKDF info parameter, so each VPS gets unique keys from the -// same wallet. Stdin is passed through so rw can prompt for the wallet password. -func DeriveNodeKeysFromWallet(vpsIP string) (*NodeKeys, error) { - if vpsIP == "" { - return nil, fmt.Errorf("VPS IP is required for key derivation") - } - - // Check rw is installed - if _, err := exec.LookPath("rw"); err != nil { - return nil, fmt.Errorf("Root Wallet (rw) not found in PATH — install it first") - } - - // Call rw derive to get master key bytes - cmd := exec.Command("rw", "derive", "--salt", "orama-node", "--info", vpsIP) - cmd.Stdin = os.Stdin // pass through for password prompts - cmd.Stderr = os.Stderr // rw UI messages go to terminal - out, err := cmd.Output() - if err != nil { - return nil, fmt.Errorf("rw derive failed: %w", err) - } - - masterHex := strings.TrimSpace(string(out)) - if len(masterHex) != 64 { // 32 bytes = 64 hex chars - return nil, fmt.Errorf("rw derive returned unexpected output length: %d (expected 64 hex chars)", len(masterHex)) - } - - masterKey, err := hexToBytes(masterHex) - if err != nil { - return nil, fmt.Errorf("rw derive returned invalid hex: %w", err) - } - defer zeroBytes(masterKey) - - return ExpandNodeKeys(masterKey) -} - -// ExpandNodeKeys expands a 32-byte master key into all node keys using HKDF-SHA256. -// The master key should come from `rw derive --salt "orama-node" --info ""`. -// -// Each key type uses a different HKDF info string under the salt "orama-expand", -// ensuring cryptographic independence between key types. -func ExpandNodeKeys(masterKey []byte) (*NodeKeys, error) { - if len(masterKey) != 32 { - return nil, fmt.Errorf("master key must be 32 bytes, got %d", len(masterKey)) - } - - salt := []byte("orama-expand") - keys := &NodeKeys{} - - // Derive LibP2P Ed25519 key - seed, err := deriveBytes(masterKey, salt, []byte("libp2p-identity"), ed25519.SeedSize) - if err != nil { - return nil, fmt.Errorf("failed to derive libp2p key: %w", err) - } - priv := ed25519.NewKeyFromSeed(seed) - zeroBytes(seed) - keys.LibP2PPrivateKey = priv - keys.LibP2PPublicKey = priv.Public().(ed25519.PublicKey) - - // Derive WireGuard Curve25519 key - wgSeed, err := deriveBytes(masterKey, salt, []byte("wireguard-key"), 32) - if err != nil { - return nil, fmt.Errorf("failed to derive wireguard key: %w", err) - } - copy(keys.WireGuardKey[:], wgSeed) - zeroBytes(wgSeed) - clampCurve25519Key(&keys.WireGuardKey) - pubKey, err := curve25519.X25519(keys.WireGuardKey[:], curve25519.Basepoint) - if err != nil { - return nil, fmt.Errorf("failed to compute wireguard public key: %w", err) - } - copy(keys.WireGuardPubKey[:], pubKey) - - // Derive IPFS Ed25519 key - seed, err = deriveBytes(masterKey, salt, []byte("ipfs-identity"), ed25519.SeedSize) - if err != nil { - return nil, fmt.Errorf("failed to derive ipfs key: %w", err) - } - priv = ed25519.NewKeyFromSeed(seed) - zeroBytes(seed) - keys.IPFSPrivateKey = priv - keys.IPFSPublicKey = priv.Public().(ed25519.PublicKey) - - // Derive IPFS Cluster Ed25519 key - seed, err = deriveBytes(masterKey, salt, []byte("ipfs-cluster"), ed25519.SeedSize) - if err != nil { - return nil, fmt.Errorf("failed to derive cluster key: %w", err) - } - priv = ed25519.NewKeyFromSeed(seed) - zeroBytes(seed) - keys.ClusterPrivateKey = priv - keys.ClusterPublicKey = priv.Public().(ed25519.PublicKey) - - // Derive JWT EdDSA signing key - seed, err = deriveBytes(masterKey, salt, []byte("jwt-signing"), ed25519.SeedSize) - if err != nil { - return nil, fmt.Errorf("failed to derive jwt key: %w", err) - } - priv = ed25519.NewKeyFromSeed(seed) - zeroBytes(seed) - keys.JWTPrivateKey = priv - keys.JWTPublicKey = priv.Public().(ed25519.PublicKey) - - return keys, nil -} - -// deriveBytes uses HKDF-SHA256 to derive n bytes from the given IKM, salt, and info. -func deriveBytes(ikm, salt, info []byte, n int) ([]byte, error) { - hkdfReader := hkdf.New(sha256.New, ikm, salt, info) - out := make([]byte, n) - if _, err := io.ReadFull(hkdfReader, out); err != nil { - return nil, err - } - return out, nil -} - -// clampCurve25519Key applies the standard Curve25519 clamping to a private key. -func clampCurve25519Key(key *[32]byte) { - key[0] &= 248 - key[31] &= 127 - key[31] |= 64 -} - -// hexToBytes decodes a hex string to bytes. -func hexToBytes(hex string) ([]byte, error) { - if len(hex)%2 != 0 { - return nil, fmt.Errorf("odd-length hex string") - } - b := make([]byte, len(hex)/2) - for i := 0; i < len(hex); i += 2 { - var hi, lo byte - var err error - if hi, err = hexCharToByte(hex[i]); err != nil { - return nil, err - } - if lo, err = hexCharToByte(hex[i+1]); err != nil { - return nil, err - } - b[i/2] = hi<<4 | lo - } - return b, nil -} - -func hexCharToByte(c byte) (byte, error) { - switch { - case c >= '0' && c <= '9': - return c - '0', nil - case c >= 'a' && c <= 'f': - return c - 'a' + 10, nil - case c >= 'A' && c <= 'F': - return c - 'A' + 10, nil - default: - return 0, fmt.Errorf("invalid hex character: %c", c) - } -} - -// zeroBytes zeroes a byte slice to clear sensitive data from memory. -func zeroBytes(b []byte) { - for i := range b { - b[i] = 0 - } -} diff --git a/pkg/encryption/wallet_keygen_test.go b/pkg/encryption/wallet_keygen_test.go deleted file mode 100644 index d06cd86..0000000 --- a/pkg/encryption/wallet_keygen_test.go +++ /dev/null @@ -1,202 +0,0 @@ -package encryption - -import ( - "bytes" - "crypto/ed25519" - "testing" -) - -// testMasterKey is a deterministic 32-byte key for testing ExpandNodeKeys. -// In production, this comes from `rw derive --salt "orama-node" --info ""`. -var testMasterKey = bytes.Repeat([]byte{0xab}, 32) -var testMasterKey2 = bytes.Repeat([]byte{0xcd}, 32) - -func TestExpandNodeKeys_Determinism(t *testing.T) { - keys1, err := ExpandNodeKeys(testMasterKey) - if err != nil { - t.Fatalf("ExpandNodeKeys: %v", err) - } - keys2, err := ExpandNodeKeys(testMasterKey) - if err != nil { - t.Fatalf("ExpandNodeKeys (second): %v", err) - } - - if !bytes.Equal(keys1.LibP2PPrivateKey, keys2.LibP2PPrivateKey) { - t.Error("LibP2P private keys differ for same input") - } - if !bytes.Equal(keys1.WireGuardKey[:], keys2.WireGuardKey[:]) { - t.Error("WireGuard keys differ for same input") - } - if !bytes.Equal(keys1.IPFSPrivateKey, keys2.IPFSPrivateKey) { - t.Error("IPFS private keys differ for same input") - } - if !bytes.Equal(keys1.ClusterPrivateKey, keys2.ClusterPrivateKey) { - t.Error("Cluster private keys differ for same input") - } - if !bytes.Equal(keys1.JWTPrivateKey, keys2.JWTPrivateKey) { - t.Error("JWT private keys differ for same input") - } -} - -func TestExpandNodeKeys_Uniqueness(t *testing.T) { - keys1, err := ExpandNodeKeys(testMasterKey) - if err != nil { - t.Fatalf("ExpandNodeKeys(master1): %v", err) - } - keys2, err := ExpandNodeKeys(testMasterKey2) - if err != nil { - t.Fatalf("ExpandNodeKeys(master2): %v", err) - } - - if bytes.Equal(keys1.LibP2PPrivateKey, keys2.LibP2PPrivateKey) { - t.Error("LibP2P keys should differ for different master keys") - } - if bytes.Equal(keys1.WireGuardKey[:], keys2.WireGuardKey[:]) { - t.Error("WireGuard keys should differ for different master keys") - } - if bytes.Equal(keys1.IPFSPrivateKey, keys2.IPFSPrivateKey) { - t.Error("IPFS keys should differ for different master keys") - } - if bytes.Equal(keys1.ClusterPrivateKey, keys2.ClusterPrivateKey) { - t.Error("Cluster keys should differ for different master keys") - } - if bytes.Equal(keys1.JWTPrivateKey, keys2.JWTPrivateKey) { - t.Error("JWT keys should differ for different master keys") - } -} - -func TestExpandNodeKeys_KeysAreMutuallyUnique(t *testing.T) { - keys, err := ExpandNodeKeys(testMasterKey) - if err != nil { - t.Fatalf("ExpandNodeKeys: %v", err) - } - - privKeys := [][]byte{ - keys.LibP2PPrivateKey.Seed(), - keys.IPFSPrivateKey.Seed(), - keys.ClusterPrivateKey.Seed(), - keys.JWTPrivateKey.Seed(), - keys.WireGuardKey[:], - } - labels := []string{"LibP2P", "IPFS", "Cluster", "JWT", "WireGuard"} - - for i := 0; i < len(privKeys); i++ { - for j := i + 1; j < len(privKeys); j++ { - if bytes.Equal(privKeys[i], privKeys[j]) { - t.Errorf("%s and %s keys should differ", labels[i], labels[j]) - } - } - } -} - -func TestExpandNodeKeys_Ed25519Validity(t *testing.T) { - keys, err := ExpandNodeKeys(testMasterKey) - if err != nil { - t.Fatalf("ExpandNodeKeys: %v", err) - } - - msg := []byte("test message for verification") - - pairs := []struct { - name string - priv ed25519.PrivateKey - pub ed25519.PublicKey - }{ - {"LibP2P", keys.LibP2PPrivateKey, keys.LibP2PPublicKey}, - {"IPFS", keys.IPFSPrivateKey, keys.IPFSPublicKey}, - {"Cluster", keys.ClusterPrivateKey, keys.ClusterPublicKey}, - {"JWT", keys.JWTPrivateKey, keys.JWTPublicKey}, - } - - for _, p := range pairs { - signature := ed25519.Sign(p.priv, msg) - if !ed25519.Verify(p.pub, msg, signature) { - t.Errorf("%s key pair: signature verification failed", p.name) - } - } -} - -func TestExpandNodeKeys_WireGuardClamping(t *testing.T) { - keys, err := ExpandNodeKeys(testMasterKey) - if err != nil { - t.Fatalf("ExpandNodeKeys: %v", err) - } - - if keys.WireGuardKey[0]&7 != 0 { - t.Errorf("WireGuard key not properly clamped: low 3 bits of first byte should be 0, got %08b", keys.WireGuardKey[0]) - } - if keys.WireGuardKey[31]&128 != 0 { - t.Errorf("WireGuard key not properly clamped: high bit of last byte should be 0, got %08b", keys.WireGuardKey[31]) - } - if keys.WireGuardKey[31]&64 != 64 { - t.Errorf("WireGuard key not properly clamped: second-high bit of last byte should be 1, got %08b", keys.WireGuardKey[31]) - } - - var zero [32]byte - if keys.WireGuardPubKey == zero { - t.Error("WireGuard public key is all zeros") - } -} - -func TestExpandNodeKeys_InvalidMasterKeyLength(t *testing.T) { - _, err := ExpandNodeKeys(nil) - if err == nil { - t.Error("expected error for nil master key") - } - - _, err = ExpandNodeKeys([]byte{}) - if err == nil { - t.Error("expected error for empty master key") - } - - _, err = ExpandNodeKeys(make([]byte, 16)) - if err == nil { - t.Error("expected error for 16-byte master key") - } - - _, err = ExpandNodeKeys(make([]byte, 64)) - if err == nil { - t.Error("expected error for 64-byte master key") - } -} - -func TestHexToBytes(t *testing.T) { - tests := []struct { - input string - expected []byte - wantErr bool - }{ - {"", []byte{}, false}, - {"00", []byte{0}, false}, - {"ff", []byte{255}, false}, - {"FF", []byte{255}, false}, - {"0a1b2c", []byte{10, 27, 44}, false}, - {"0", nil, true}, // odd length - {"zz", nil, true}, // invalid chars - {"gg", nil, true}, // invalid chars - } - - for _, tt := range tests { - got, err := hexToBytes(tt.input) - if tt.wantErr { - if err == nil { - t.Errorf("hexToBytes(%q): expected error", tt.input) - } - continue - } - if err != nil { - t.Errorf("hexToBytes(%q): unexpected error: %v", tt.input, err) - continue - } - if !bytes.Equal(got, tt.expected) { - t.Errorf("hexToBytes(%q) = %v, want %v", tt.input, got, tt.expected) - } - } -} - -func TestDeriveNodeKeysFromWallet_EmptyIP(t *testing.T) { - _, err := DeriveNodeKeysFromWallet("") - if err == nil { - t.Error("expected error for empty VPS IP") - } -} diff --git a/pkg/gateway/status_handlers.go b/pkg/gateway/status_handlers.go index 7a8259b..19d1862 100644 --- a/pkg/gateway/status_handlers.go +++ b/pkg/gateway/status_handlers.go @@ -2,6 +2,8 @@ package gateway import ( "context" + "fmt" + "net" "net/http" "strings" "time" @@ -52,7 +54,8 @@ func (g *Gateway) healthHandler(w http.ResponseWriter, r *http.Request) { name string result checkResult } - ch := make(chan namedResult, 5) + const numChecks = 7 + ch := make(chan namedResult, numChecks) // RQLite go func() { @@ -138,9 +141,37 @@ func (g *Gateway) healthHandler(w http.ResponseWriter, r *http.Request) { ch <- nr }() + // Vault Guardian (TCP connect to localhost:7500) + go func() { + nr := namedResult{name: "vault"} + start := time.Now() + conn, err := net.DialTimeout("tcp", "localhost:7500", 2*time.Second) + if err != nil { + nr.result = checkResult{Status: "error", Latency: time.Since(start).String(), Error: fmt.Sprintf("vault-guardian unreachable on port 7500: %v", err)} + } else { + conn.Close() + nr.result = checkResult{Status: "ok", Latency: time.Since(start).String()} + } + ch <- nr + }() + + // WireGuard (check wg0 interface exists and has an IP) + go func() { + nr := namedResult{name: "wireguard"} + iface, err := net.InterfaceByName("wg0") + if err != nil { + nr.result = checkResult{Status: "error", Error: "wg0 interface not found"} + } else if addrs, err := iface.Addrs(); err != nil || len(addrs) == 0 { + nr.result = checkResult{Status: "error", Error: "wg0 has no addresses"} + } else { + nr.result = checkResult{Status: "ok"} + } + ch <- nr + }() + // Collect - checks := make(map[string]checkResult, 5) - for i := 0; i < 5; i++ { + checks := make(map[string]checkResult, numChecks) + for i := 0; i < numChecks; i++ { nr := <-ch checks[nr.name] = nr.result } @@ -222,24 +253,26 @@ func (g *Gateway) versionHandler(w http.ResponseWriter, r *http.Request) { } // aggregateHealthStatus determines the overall health status from individual checks. -// Critical: rqlite down → "unhealthy" -// Non-critical (olric, ipfs, libp2p, anyone) error → "degraded" +// Critical: rqlite or vault down → "unhealthy" +// Non-critical (olric, ipfs, libp2p, anyone, wireguard) error → "degraded" // "unavailable" means the client was never configured — not an error. func aggregateHealthStatus(checks map[string]checkResult) string { - status := "healthy" - if c := checks["rqlite"]; c.Status == "error" { - return "unhealthy" + // Critical services — any error means unhealthy + for _, name := range []string{"rqlite", "vault"} { + if c := checks[name]; c.Status == "error" { + return "unhealthy" + } } + // Non-critical services — any error means degraded for name, c := range checks { - if name == "rqlite" { + if name == "rqlite" || name == "vault" { continue } if c.Status == "error" { - status = "degraded" - break + return "degraded" } } - return status + return "healthy" } // tlsCheckHandler validates if a domain should receive a TLS certificate diff --git a/pkg/gateway/status_handlers_test.go b/pkg/gateway/status_handlers_test.go index e20b239..b7fcda1 100644 --- a/pkg/gateway/status_handlers_test.go +++ b/pkg/gateway/status_handlers_test.go @@ -4,11 +4,13 @@ import "testing" func TestAggregateHealthStatus_allHealthy(t *testing.T) { checks := map[string]checkResult{ - "rqlite": {Status: "ok"}, - "olric": {Status: "ok"}, - "ipfs": {Status: "ok"}, - "libp2p": {Status: "ok"}, - "anyone": {Status: "ok"}, + "rqlite": {Status: "ok"}, + "olric": {Status: "ok"}, + "ipfs": {Status: "ok"}, + "libp2p": {Status: "ok"}, + "anyone": {Status: "ok"}, + "vault": {Status: "ok"}, + "wireguard": {Status: "ok"}, } if got := aggregateHealthStatus(checks); got != "healthy" { t.Errorf("expected healthy, got %s", got) @@ -41,11 +43,13 @@ func TestAggregateHealthStatus_unavailableIsNotError(t *testing.T) { // Key test: "unavailable" services (like Anyone in sandbox) should NOT // cause degraded status. checks := map[string]checkResult{ - "rqlite": {Status: "ok"}, - "olric": {Status: "ok"}, - "ipfs": {Status: "unavailable"}, - "libp2p": {Status: "unavailable"}, - "anyone": {Status: "unavailable"}, + "rqlite": {Status: "ok"}, + "olric": {Status: "ok"}, + "vault": {Status: "ok"}, + "ipfs": {Status: "unavailable"}, + "libp2p": {Status: "unavailable"}, + "anyone": {Status: "unavailable"}, + "wireguard": {Status: "unavailable"}, } if got := aggregateHealthStatus(checks); got != "healthy" { t.Errorf("expected healthy when services are unavailable, got %s", got) @@ -70,3 +74,38 @@ func TestAggregateHealthStatus_rqliteErrorOverridesDegraded(t *testing.T) { t.Errorf("expected unhealthy (rqlite takes priority), got %s", got) } } + +func TestAggregateHealthStatus_vaultErrorIsUnhealthy(t *testing.T) { + // vault is critical — error should mean unhealthy, not degraded + checks := map[string]checkResult{ + "rqlite": {Status: "ok"}, + "vault": {Status: "error", Error: "vault-guardian unreachable on port 7500"}, + "olric": {Status: "ok"}, + } + if got := aggregateHealthStatus(checks); got != "unhealthy" { + t.Errorf("expected unhealthy (vault is critical), got %s", got) + } +} + +func TestAggregateHealthStatus_wireguardErrorIsDegraded(t *testing.T) { + // wireguard is non-critical — error should mean degraded, not unhealthy + checks := map[string]checkResult{ + "rqlite": {Status: "ok"}, + "vault": {Status: "ok"}, + "wireguard": {Status: "error", Error: "wg0 interface not found"}, + } + if got := aggregateHealthStatus(checks); got != "degraded" { + t.Errorf("expected degraded (wireguard is non-critical), got %s", got) + } +} + +func TestAggregateHealthStatus_bothCriticalDown(t *testing.T) { + checks := map[string]checkResult{ + "rqlite": {Status: "error", Error: "connection refused"}, + "vault": {Status: "error", Error: "unreachable"}, + "wireguard": {Status: "ok"}, + } + if got := aggregateHealthStatus(checks); got != "unhealthy" { + t.Errorf("expected unhealthy, got %s", got) + } +} diff --git a/pkg/inspector/config.go b/pkg/inspector/config.go index cad33c7..1aaf3cf 100644 --- a/pkg/inspector/config.go +++ b/pkg/inspector/config.go @@ -14,6 +14,7 @@ type Node struct { Host string // IP or hostname Role string // node, nameserver-ns1, nameserver-ns2, nameserver-ns3 SSHKey string // populated at runtime by PrepareNodeKeys() + VaultTarget string // optional: override wallet key lookup (e.g. "sandbox/root") } // Name returns a short display name for the node (user@host). diff --git a/scripts/remote-nodes.conf.example b/scripts/remote-nodes.conf.example index 6065bc2..3a4a91b 100644 --- a/scripts/remote-nodes.conf.example +++ b/scripts/remote-nodes.conf.example @@ -1,26 +1,27 @@ # Remote node configuration -# Format: environment|user@host|password|role|ssh_key (optional) +# Format: environment|user@host|role # environment: devnet, testnet # role: node, nameserver-ns1, nameserver-ns2, nameserver-ns3 -# ssh_key: optional path to SSH key (if node requires key-based auth instead of sshpass) # -# Copy this file to remote-nodes.conf and fill in your credentials. -# The first node with an SSH key will be used as the hub (fan-out relay). +# SSH keys are resolved from rootwallet (rw vault ssh get / --priv). +# Ensure wallet entries exist: rw vault ssh add / +# +# Copy this file to remote-nodes.conf and fill in your node details. # --- Devnet nameservers --- -devnet|root@1.2.3.4|your_password_here|nameserver-ns1 -devnet|ubuntu@1.2.3.5|your_password_here|nameserver-ns2 -devnet|root@1.2.3.6|your_password_here|nameserver-ns3 +devnet|root@1.2.3.4|nameserver-ns1 +devnet|ubuntu@1.2.3.5|nameserver-ns2 +devnet|root@1.2.3.6|nameserver-ns3 # --- Devnet nodes --- -devnet|ubuntu@1.2.3.7|your_password_here|node -devnet|ubuntu@1.2.3.8|your_password_here|node|~/.ssh/my_key/id_ed25519 +devnet|ubuntu@1.2.3.7|node +devnet|ubuntu@1.2.3.8|node # --- Testnet nameservers --- -testnet|ubuntu@2.3.4.5|your_password_here|nameserver-ns1 -testnet|ubuntu@2.3.4.6|your_password_here|nameserver-ns2 -testnet|ubuntu@2.3.4.7|your_password_here|nameserver-ns3 +testnet|ubuntu@2.3.4.5|nameserver-ns1 +testnet|ubuntu@2.3.4.6|nameserver-ns2 +testnet|ubuntu@2.3.4.7|nameserver-ns3 # --- Testnet nodes --- -testnet|root@2.3.4.8|your_password_here|node -testnet|ubuntu@2.3.4.9|your_password_here|node +testnet|root@2.3.4.8|node +testnet|ubuntu@2.3.4.9|node