refactor(sandbox): integrate rootwallet SSH keys

- replace standalone sandbox keys with "sandbox/root" vault entry
- update inspector config to use vault targets (no passwords/keys)
- make sandbox default active environment
- add vault helpers and tests for remotessh
This commit is contained in:
anonpenguin23 2026-03-10 05:25:41 +02:00
parent 733b059681
commit fa826f0d00
22 changed files with 416 additions and 615 deletions

View File

@ -167,18 +167,18 @@ The inspector reads node definitions from a pipe-delimited config file (default:
### Format ### Format
``` ```
# environment|user@host|password|role|ssh_key # environment|user@host|role
devnet|ubuntu@1.2.3.4|mypassword|node| devnet|ubuntu@1.2.3.4|node
devnet|ubuntu@5.6.7.8|mypassword|nameserver-ns1|/path/to/key devnet|ubuntu@5.6.7.8|nameserver-ns1
``` ```
| Field | Description | | Field | Description |
|-------|-------------| |-------|-------------|
| `environment` | Cluster name (`devnet`, `testnet`) | | `environment` | Cluster name (`devnet`, `testnet`) |
| `user@host` | SSH credentials | | `user@host` | SSH credentials |
| `password` | SSH password |
| `role` | `node` or `nameserver-ns1`, `nameserver-ns2`, etc. | | `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 <host>/<user> --priv`).
Blank lines and lines starting with `#` are ignored. Blank lines and lines starting with `#` are ignored.

View File

@ -69,8 +69,8 @@ This will:
2. Ask for your sandbox domain 2. Ask for your sandbox domain
3. Create or reuse 2 Hetzner Floating IPs (~$0.005/hr each) 3. Create or reuse 2 Hetzner Floating IPs (~$0.005/hr each)
4. Create a firewall with sandbox rules 4. Create a firewall with sandbox rules
5. Generate an SSH keypair at `~/.orama/sandbox_key` 5. Create a rootwallet SSH entry (`sandbox/root`) if it doesn't exist
6. Upload the public key to Hetzner 6. Upload the wallet-derived public key to Hetzner
7. Display DNS configuration instructions 7. Display DNS configuration instructions
Config is saved to `~/.orama/sandbox.yaml`. 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 ### 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 ### Server Naming

View File

@ -26,16 +26,16 @@ type EnvironmentConfig struct {
// Default environments // Default environments
var DefaultEnvironments = []Environment{ var DefaultEnvironments = []Environment{
{ {
Name: "production", Name: "sandbox",
GatewayURL: "https://dbrs.space", GatewayURL: "https://dbrs.space",
Description: "Production network (dbrs.space)", Description: "Sandbox cluster (dbrs.space)",
IsActive: false, IsActive: true,
}, },
{ {
Name: "devnet", Name: "devnet",
GatewayURL: "https://orama-devnet.network", GatewayURL: "https://orama-devnet.network",
Description: "Development network (testnet)", Description: "Development network",
IsActive: true, IsActive: false,
}, },
{ {
Name: "testnet", Name: "testnet",
@ -65,7 +65,7 @@ func LoadEnvironmentConfig() (*EnvironmentConfig, error) {
if _, err := os.Stat(path); os.IsNotExist(err) { if _, err := os.Stat(path); os.IsNotExist(err) {
return &EnvironmentConfig{ return &EnvironmentConfig{
Environments: DefaultEnvironments, Environments: DefaultEnvironments,
ActiveEnvironment: "devnet", ActiveEnvironment: "sandbox",
}, nil }, 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 { for _, env := range envConfig.Environments {
if env.Name == "devnet" { if env.Name == "sandbox" {
return &env, nil return &env, nil
} }
} }
@ -184,7 +184,7 @@ func InitializeEnvironments() error {
envConfig := &EnvironmentConfig{ envConfig := &EnvironmentConfig{
Environments: DefaultEnvironments, Environments: DefaultEnvironments,
ActiveEnvironment: "devnet", ActiveEnvironment: "sandbox",
} }
return SaveEnvironmentConfig(envConfig) return SaveEnvironmentConfig(envConfig)

View File

@ -157,7 +157,7 @@ func loadSandboxNodes(cfg CollectorConfig) ([]inspector.Node, func(), error) {
return nil, noop, fmt.Errorf("no active sandbox found") return nil, noop, fmt.Errorf("no active sandbox found")
} }
nodes := state.ToNodes(sbxCfg.ExpandedPrivateKeyPath()) nodes := state.ToNodes(sbxCfg.SSHKey.VaultTarget)
if cfg.NodeFilter != "" { if cfg.NodeFilter != "" {
nodes = filterByHost(nodes, 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 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
} }

View File

@ -36,14 +36,21 @@ func PrepareNodeKeys(nodes []inspector.Node) (cleanup func(), err error) {
var allKeyPaths []string var allKeyPaths []string
for i := range nodes { 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 { if existing, ok := keyPaths[key]; ok {
nodes[i].SSHKey = existing nodes[i].SSHKey = existing
continue continue
} }
// Call rw to get the private key PEM // 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 { if err != nil {
// Cleanup any keys already written before returning error // Cleanup any keys already written before returning error
cleanupKeys(tmpDir, allKeyPaths) cleanupKeys(tmpDir, allKeyPaths)
@ -81,7 +88,12 @@ func LoadAgentKeys(nodes []inspector.Node) error {
seen := make(map[string]bool) seen := make(map[string]bool)
var targets []string var targets []string
for _, n := range nodes { 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] { if seen[key] {
continue continue
} }
@ -104,6 +116,78 @@ func LoadAgentKeys(nodes []inspector.Node) error {
return nil return nil
} }
// EnsureVaultEntry creates a wallet SSH entry if it doesn't already exist.
// Checks existence via `rw vault ssh get <target> --pub`, and if missing,
// runs `rw vault ssh add <target>` 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 <target> --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 <host>/<user> --priv` // resolveWalletKey calls `rw vault ssh get <host>/<user> --priv`
// and returns the PEM string. Requires an active rw session. // and returns the PEM string. Requires an active rw session.
func resolveWalletKey(rw string, host, user string) (string, error) { func resolveWalletKey(rw string, host, user string) (string, error) {

View File

@ -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)
}
})
}
}

View File

@ -25,11 +25,10 @@ type FloatIP struct {
IP string `yaml:"ip"` 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 { type SSHKeyConfig struct {
HetznerID int64 `yaml:"hetzner_id"` HetznerID int64 `yaml:"hetzner_id"`
PrivateKeyPath string `yaml:"private_key_path"` VaultTarget string `yaml:"vault_target"` // e.g. "sandbox/root"
PublicKeyPath string `yaml:"public_key_path"`
} }
// configDir returns ~/.orama/, creating it if needed. // configDir returns ~/.orama/, creating it if needed.
@ -114,8 +113,8 @@ func (c *Config) validate() error {
if len(c.FloatingIPs) < 2 { if len(c.FloatingIPs) < 2 {
return fmt.Errorf("2 floating IPs required, got %d", len(c.FloatingIPs)) return fmt.Errorf("2 floating IPs required, got %d", len(c.FloatingIPs))
} }
if c.SSHKey.PrivateKeyPath == "" { if c.SSHKey.VaultTarget == "" {
return fmt.Errorf("ssh_key.private_key_path is required") return fmt.Errorf("ssh_key.vault_target is required (run: orama sandbox setup)")
} }
return nil return nil
} }
@ -128,26 +127,7 @@ func (c *Config) Defaults() {
if c.ServerType == "" { if c.ServerType == "" {
c.ServerType = "cx23" c.ServerType = "cx23"
} }
} if c.SSHKey.VaultTarget == "" {
c.SSHKey.VaultTarget = "sandbox/root"
// 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
} }
home, err := os.UserHomeDir()
if err != nil {
return path
}
return filepath.Join(home, path[2:])
} }

View File

@ -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)
}
}

View File

@ -19,6 +19,13 @@ func Create(name string) error {
return err 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 // Check for existing active sandbox
active, err := FindActiveSandbox() active, err := FindActiveSandbox()
if err != nil { if err != nil {
@ -55,20 +62,20 @@ func Create(name string) error {
// Phase 2: Assign floating IPs // Phase 2: Assign floating IPs
fmt.Println("\nPhase 2: Assigning 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) return fmt.Errorf("assign floating IPs: %w", err)
} }
SaveState(state) SaveState(state)
// Phase 3: Upload binary archive // Phase 3: Upload binary archive
fmt.Println("\nPhase 3: Uploading 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) return fmt.Errorf("upload archive: %w", err)
} }
// Phase 4: Install genesis node // Phase 4: Install genesis node
fmt.Println("\nPhase 4: Installing genesis node...") fmt.Println("\nPhase 4: Installing genesis node...")
tokens, err := phase4InstallGenesis(cfg, state) tokens, err := phase4InstallGenesis(cfg, state, sshKeyPath)
if err != nil { if err != nil {
state.Status = StatusError state.Status = StatusError
SaveState(state) SaveState(state)
@ -77,7 +84,7 @@ func Create(name string) error {
// Phase 5: Join remaining nodes // Phase 5: Join remaining nodes
fmt.Println("\nPhase 5: Joining 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 state.Status = StatusError
SaveState(state) SaveState(state)
return fmt.Errorf("join nodes: %w", err) return fmt.Errorf("join nodes: %w", err)
@ -85,7 +92,7 @@ func Create(name string) error {
// Phase 6: Verify cluster // Phase 6: Verify cluster
fmt.Println("\nPhase 6: Verifying cluster...") fmt.Println("\nPhase 6: Verifying cluster...")
phase6Verify(cfg, state) phase6Verify(cfg, state, sshKeyPath)
state.Status = StatusRunning state.Status = StatusRunning
SaveState(state) SaveState(state)
@ -94,6 +101,18 @@ func Create(name string) error {
return nil 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. // phase1ProvisionServers creates 5 Hetzner servers in parallel.
func phase1ProvisionServers(client *HetznerClient, cfg *Config, state *SandboxState) error { func phase1ProvisionServers(client *HetznerClient, cfg *Config, state *SandboxState) error {
type serverResult struct { type serverResult struct {
@ -190,9 +209,7 @@ func phase1ProvisionServers(client *HetznerClient, cfg *Config, state *SandboxSt
} }
// phase2AssignFloatingIPs assigns floating IPs and configures loopback. // phase2AssignFloatingIPs assigns floating IPs and configures loopback.
func phase2AssignFloatingIPs(client *HetznerClient, cfg *Config, state *SandboxState) error { func phase2AssignFloatingIPs(client *HetznerClient, cfg *Config, state *SandboxState, sshKeyPath string) error {
sshKeyPath := cfg.ExpandedPrivateKeyPath()
for i := 0; i < 2 && i < len(cfg.FloatingIPs) && i < len(state.Servers); i++ { for i := 0; i < 2 && i < len(cfg.FloatingIPs) && i < len(state.Servers); i++ {
fip := cfg.FloatingIPs[i] fip := cfg.FloatingIPs[i]
srv := state.Servers[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 // 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). // 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() archivePath := findNewestArchive()
if archivePath == "" { if archivePath == "" {
fmt.Println(" No binary archive found, run `orama build` first") 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) info, _ := os.Stat(archivePath)
fmt.Printf(" Archive: %s (%s)\n", filepath.Base(archivePath), formatBytes(info.Size())) fmt.Printf(" Archive: %s (%s)\n", filepath.Base(archivePath), formatBytes(info.Size()))
sshKeyPath := cfg.ExpandedPrivateKeyPath()
if err := fanoutArchive(state.Servers, sshKeyPath, archivePath); err != nil { if err := fanoutArchive(state.Servers, sshKeyPath, archivePath); err != nil {
return err return err
} }
@ -265,9 +281,8 @@ func phase3UploadArchive(cfg *Config, state *SandboxState) error {
} }
// phase4InstallGenesis installs the genesis node and generates invite tokens. // 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() genesis := state.GenesisServer()
sshKeyPath := cfg.ExpandedPrivateKeyPath()
node := inspector.Node{User: "root", Host: genesis.IP, SSHKey: sshKeyPath} node := inspector.Node{User: "root", Host: genesis.IP, SSHKey: sshKeyPath}
// Install genesis // Install genesis
@ -304,9 +319,8 @@ func phase4InstallGenesis(cfg *Config, state *SandboxState) ([]string, error) {
} }
// phase5JoinNodes joins the remaining 4 nodes to the cluster (serial). // 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 genesisIP := state.GenesisServer().IP
sshKeyPath := cfg.ExpandedPrivateKeyPath()
for i := 1; i < len(state.Servers); i++ { for i := 1; i < len(state.Servers); i++ {
srv := 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. // phase6Verify runs a basic cluster health check.
func phase6Verify(cfg *Config, state *SandboxState) { func phase6Verify(cfg *Config, state *SandboxState, sshKeyPath string) {
sshKeyPath := cfg.ExpandedPrivateKeyPath()
genesis := state.GenesisServer() genesis := state.GenesisServer()
node := inspector.Node{User: "root", Host: genesis.IP, SSHKey: sshKeyPath} node := inspector.Node{User: "root", Host: genesis.IP, SSHKey: sshKeyPath}

View File

@ -42,8 +42,6 @@ func Reset() error {
fmt.Println() fmt.Println()
fmt.Println("Local files to remove:") fmt.Println("Local files to remove:")
fmt.Println(" ~/.orama/sandbox.yaml") fmt.Println(" ~/.orama/sandbox.yaml")
fmt.Println(" ~/.orama/sandbox_key")
fmt.Println(" ~/.orama/sandbox_key.pub")
fmt.Println() fmt.Println()
reader := bufio.NewReader(os.Stdin) reader := bufio.NewReader(os.Stdin)
@ -100,29 +98,21 @@ func Reset() error {
return nil return nil
} }
// resetLocalFiles removes the sandbox config and SSH key files. // resetLocalFiles removes the sandbox config file.
func resetLocalFiles() error { func resetLocalFiles() error {
dir, err := configDir() dir, err := configDir()
if err != nil { if err != nil {
return err return err
} }
files := []string{ configFile := dir + "/sandbox.yaml"
dir + "/sandbox.yaml",
dir + "/sandbox_key",
dir + "/sandbox_key.pub",
}
fmt.Println("Removing local files...") fmt.Println("Removing local files...")
for _, f := range files { if err := os.Remove(configFile); err != nil {
if err := os.Remove(f); err != nil { if !os.IsNotExist(err) {
if os.IsNotExist(err) { fmt.Fprintf(os.Stderr, " Warning: could not remove %s: %v\n", configFile, err)
continue
} }
fmt.Fprintf(os.Stderr, " Warning: could not remove %s: %v\n", f, err)
} else { } else {
fmt.Printf(" Removed %s\n", f) fmt.Printf(" Removed %s\n", configFile)
}
} }
return nil return nil

View File

@ -28,7 +28,12 @@ func Rollout(name string, flags RolloutFlags) error {
return err 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)) fmt.Printf("Rolling out to sandbox %q (%d nodes)\n\n", state.Name, len(state.Servers))
// Step 1: Find or require binary archive // Step 1: Find or require binary archive

View File

@ -2,9 +2,6 @@ package sandbox
import ( import (
"bufio" "bufio"
"crypto/ed25519"
"crypto/rand"
"encoding/pem"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
@ -13,7 +10,7 @@ import (
"strings" "strings"
"time" "time"
"golang.org/x/crypto/ssh" "github.com/DeBrosOfficial/network/pkg/cli/remotessh"
) )
// Setup runs the interactive sandbox setup wizard. // Setup runs the interactive sandbox setup wizard.
@ -386,94 +383,45 @@ func setupFirewall(client *HetznerClient) (int64, error) {
return fw.ID, nil 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) { func setupSSHKey(client *HetznerClient) (SSHKeyConfig, error) {
dir, err := configDir() const vaultTarget = "sandbox/root"
if err != nil {
return SSHKeyConfig{}, err
}
privPath := dir + "/sandbox_key" // Ensure wallet entry exists (creates if missing)
pubPath := privPath + ".pub" fmt.Print(" Ensuring wallet SSH entry... ")
if err := remotessh.EnsureVaultEntry(vaultTarget); err != nil {
// 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 {
fmt.Println("FAILED") fmt.Println("FAILED")
return SSHKeyConfig{}, fmt.Errorf("generate key: %w", err) return SSHKeyConfig{}, fmt.Errorf("ensure vault entry: %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)
} }
fmt.Println("OK") 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... ") fmt.Print(" Uploading to Hetzner... ")
key, err := client.UploadSSHKey("orama-sandbox", pubStr) key, err := client.UploadSSHKey("orama-sandbox", pubStr)
if err != nil { 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") fmt.Println("FAILED")
return SSHKeyConfig{}, fmt.Errorf("upload SSH key: %w", err) return SSHKeyConfig{}, fmt.Errorf("upload SSH key: %w", err)
} }
@ -481,8 +429,7 @@ func setupSSHKey(client *HetznerClient) (SSHKeyConfig, error) {
return SSHKeyConfig{ return SSHKeyConfig{
HetznerID: key.ID, HetznerID: key.ID,
PrivateKeyPath: "~/.orama/sandbox_key", VaultTarget: vaultTarget,
PublicKeyPath: "~/.orama/sandbox_key.pub",
}, nil }, nil
} }

View File

@ -3,7 +3,7 @@ package sandbox
import ( import (
"fmt" "fmt"
"os" "os"
"syscall" "os/exec"
) )
// SSHInto opens an interactive SSH session to a sandbox node. // 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] 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) fmt.Printf("Connecting to %s (%s, %s)...\n", srv.Name, srv.IP, srv.Role)
// Find ssh binary // Find ssh binary
sshBin, err := findSSHBinary() sshBin, err := findSSHBinary()
if err != nil { if err != nil {
cleanup()
return err return err
} }
// Replace current process with SSH // Run SSH as a child process so cleanup runs after the session ends
args := []string{ cmd := exec.Command(sshBin,
"ssh",
"-o", "StrictHostKeyChecking=no", "-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null", "-o", "UserKnownHostsFile=/dev/null",
"-i", sshKeyPath, "-i", sshKeyPath,
fmt.Sprintf("root@%s", srv.IP), 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. // findSSHBinary locates the ssh binary in PATH.

View File

@ -165,8 +165,8 @@ func FindActiveSandbox() (*SandboxState, error) {
} }
// ToNodes converts sandbox servers to inspector.Node structs for SSH operations. // ToNodes converts sandbox servers to inspector.Node structs for SSH operations.
// Sets SSHKey to the provided key path on each node. // Sets VaultTarget on each node so PrepareNodeKeys resolves from the wallet.
func (s *SandboxState) ToNodes(sshKeyPath string) []inspector.Node { func (s *SandboxState) ToNodes(vaultTarget string) []inspector.Node {
nodes := make([]inspector.Node, len(s.Servers)) nodes := make([]inspector.Node, len(s.Servers))
for i, srv := range s.Servers { for i, srv := range s.Servers {
nodes[i] = inspector.Node{ nodes[i] = inspector.Node{
@ -174,7 +174,7 @@ func (s *SandboxState) ToNodes(sshKeyPath string) []inspector.Node {
User: "root", User: "root",
Host: srv.IP, Host: srv.IP,
Role: srv.Role, Role: srv.Role,
SSHKey: sshKeyPath, VaultTarget: vaultTarget,
} }
} }
return nodes return nodes

View File

@ -156,7 +156,7 @@ func TestToNodes(t *testing.T) {
}, },
} }
nodes := state.ToNodes("/tmp/key") nodes := state.ToNodes("sandbox/root")
if len(nodes) != 2 { if len(nodes) != 2 {
t.Fatalf("ToNodes() returned %d nodes, want 2", len(nodes)) t.Fatalf("ToNodes() returned %d nodes, want 2", len(nodes))
} }
@ -166,8 +166,11 @@ func TestToNodes(t *testing.T) {
if nodes[0].User != "root" { if nodes[0].User != "root" {
t.Errorf("node[0].User = %s, want root", nodes[0].User) t.Errorf("node[0].User = %s, want root", nodes[0].User)
} }
if nodes[0].SSHKey != "/tmp/key" { if nodes[0].VaultTarget != "sandbox/root" {
t.Errorf("node[0].SSHKey = %s, want /tmp/key", nodes[0].SSHKey) 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" { if nodes[0].Environment != "sandbox" {
t.Errorf("node[0].Environment = %s, want sandbox", nodes[0].Environment) t.Errorf("node[0].Environment = %s, want sandbox", nodes[0].Environment)

View File

@ -77,7 +77,12 @@ func Status(name string) error {
return err 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) fmt.Printf("Sandbox: %s (status: %s)\n\n", state.Name, state.Status)
for _, srv := range state.Servers { for _, srv := range state.Servers {

View File

@ -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 "<IP>"`.
//
// 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
}
}

View File

@ -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 "<IP>"`.
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")
}
}

View File

@ -2,6 +2,8 @@ package gateway
import ( import (
"context" "context"
"fmt"
"net"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@ -52,7 +54,8 @@ func (g *Gateway) healthHandler(w http.ResponseWriter, r *http.Request) {
name string name string
result checkResult result checkResult
} }
ch := make(chan namedResult, 5) const numChecks = 7
ch := make(chan namedResult, numChecks)
// RQLite // RQLite
go func() { go func() {
@ -138,9 +141,37 @@ func (g *Gateway) healthHandler(w http.ResponseWriter, r *http.Request) {
ch <- nr 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 // Collect
checks := make(map[string]checkResult, 5) checks := make(map[string]checkResult, numChecks)
for i := 0; i < 5; i++ { for i := 0; i < numChecks; i++ {
nr := <-ch nr := <-ch
checks[nr.name] = nr.result 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. // aggregateHealthStatus determines the overall health status from individual checks.
// Critical: rqlite down → "unhealthy" // Critical: rqlite or vault down → "unhealthy"
// Non-critical (olric, ipfs, libp2p, anyone) error → "degraded" // Non-critical (olric, ipfs, libp2p, anyone, wireguard) error → "degraded"
// "unavailable" means the client was never configured — not an error. // "unavailable" means the client was never configured — not an error.
func aggregateHealthStatus(checks map[string]checkResult) string { func aggregateHealthStatus(checks map[string]checkResult) string {
status := "healthy" // Critical services — any error means unhealthy
if c := checks["rqlite"]; c.Status == "error" { for _, name := range []string{"rqlite", "vault"} {
if c := checks[name]; c.Status == "error" {
return "unhealthy" return "unhealthy"
} }
}
// Non-critical services — any error means degraded
for name, c := range checks { for name, c := range checks {
if name == "rqlite" { if name == "rqlite" || name == "vault" {
continue continue
} }
if c.Status == "error" { if c.Status == "error" {
status = "degraded" return "degraded"
break
} }
} }
return status return "healthy"
} }
// tlsCheckHandler validates if a domain should receive a TLS certificate // tlsCheckHandler validates if a domain should receive a TLS certificate

View File

@ -9,6 +9,8 @@ func TestAggregateHealthStatus_allHealthy(t *testing.T) {
"ipfs": {Status: "ok"}, "ipfs": {Status: "ok"},
"libp2p": {Status: "ok"}, "libp2p": {Status: "ok"},
"anyone": {Status: "ok"}, "anyone": {Status: "ok"},
"vault": {Status: "ok"},
"wireguard": {Status: "ok"},
} }
if got := aggregateHealthStatus(checks); got != "healthy" { if got := aggregateHealthStatus(checks); got != "healthy" {
t.Errorf("expected healthy, got %s", got) t.Errorf("expected healthy, got %s", got)
@ -43,9 +45,11 @@ func TestAggregateHealthStatus_unavailableIsNotError(t *testing.T) {
checks := map[string]checkResult{ checks := map[string]checkResult{
"rqlite": {Status: "ok"}, "rqlite": {Status: "ok"},
"olric": {Status: "ok"}, "olric": {Status: "ok"},
"vault": {Status: "ok"},
"ipfs": {Status: "unavailable"}, "ipfs": {Status: "unavailable"},
"libp2p": {Status: "unavailable"}, "libp2p": {Status: "unavailable"},
"anyone": {Status: "unavailable"}, "anyone": {Status: "unavailable"},
"wireguard": {Status: "unavailable"},
} }
if got := aggregateHealthStatus(checks); got != "healthy" { if got := aggregateHealthStatus(checks); got != "healthy" {
t.Errorf("expected healthy when services are unavailable, got %s", got) 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) 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)
}
}

View File

@ -14,6 +14,7 @@ type Node struct {
Host string // IP or hostname Host string // IP or hostname
Role string // node, nameserver-ns1, nameserver-ns2, nameserver-ns3 Role string // node, nameserver-ns1, nameserver-ns2, nameserver-ns3
SSHKey string // populated at runtime by PrepareNodeKeys() 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). // Name returns a short display name for the node (user@host).

View File

@ -1,26 +1,27 @@
# Remote node configuration # Remote node configuration
# Format: environment|user@host|password|role|ssh_key (optional) # Format: environment|user@host|role
# environment: devnet, testnet # environment: devnet, testnet
# role: node, nameserver-ns1, nameserver-ns2, nameserver-ns3 # 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. # SSH keys are resolved from rootwallet (rw vault ssh get <host>/<user> --priv).
# The first node with an SSH key will be used as the hub (fan-out relay). # Ensure wallet entries exist: rw vault ssh add <host>/<user>
#
# Copy this file to remote-nodes.conf and fill in your node details.
# --- Devnet nameservers --- # --- Devnet nameservers ---
devnet|root@1.2.3.4|your_password_here|nameserver-ns1 devnet|root@1.2.3.4|nameserver-ns1
devnet|ubuntu@1.2.3.5|your_password_here|nameserver-ns2 devnet|ubuntu@1.2.3.5|nameserver-ns2
devnet|root@1.2.3.6|your_password_here|nameserver-ns3 devnet|root@1.2.3.6|nameserver-ns3
# --- Devnet nodes --- # --- Devnet nodes ---
devnet|ubuntu@1.2.3.7|your_password_here|node devnet|ubuntu@1.2.3.7|node
devnet|ubuntu@1.2.3.8|your_password_here|node|~/.ssh/my_key/id_ed25519 devnet|ubuntu@1.2.3.8|node
# --- Testnet nameservers --- # --- Testnet nameservers ---
testnet|ubuntu@2.3.4.5|your_password_here|nameserver-ns1 testnet|ubuntu@2.3.4.5|nameserver-ns1
testnet|ubuntu@2.3.4.6|your_password_here|nameserver-ns2 testnet|ubuntu@2.3.4.6|nameserver-ns2
testnet|ubuntu@2.3.4.7|your_password_here|nameserver-ns3 testnet|ubuntu@2.3.4.7|nameserver-ns3
# --- Testnet nodes --- # --- Testnet nodes ---
testnet|root@2.3.4.8|your_password_here|node testnet|root@2.3.4.8|node
testnet|ubuntu@2.3.4.9|your_password_here|node testnet|ubuntu@2.3.4.9|node