mirror of
https://github.com/DeBrosOfficial/orama.git
synced 2026-03-17 03:33:01 +00:00
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:
parent
733b059681
commit
fa826f0d00
@ -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 <host>/<user> --priv`).
|
||||
|
||||
Blank lines and lines starting with `#` are ignored.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 <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`
|
||||
// and returns the PEM string. Requires an active rw session.
|
||||
func resolveWalletKey(rw string, host, user string) (string, error) {
|
||||
|
||||
29
pkg/cli/remotessh/wallet_test.go
Normal file
29
pkg/cli/remotessh/wallet_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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"`
|
||||
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"
|
||||
}
|
||||
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:])
|
||||
}
|
||||
|
||||
53
pkg/cli/sandbox/config_test.go
Normal file
53
pkg/cli/sandbox/config_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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}
|
||||
|
||||
|
||||
@ -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
|
||||
if err := os.Remove(configFile); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
fmt.Fprintf(os.Stderr, " Warning: could not remove %s: %v\n", configFile, err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " Warning: could not remove %s: %v\n", f, err)
|
||||
} else {
|
||||
fmt.Printf(" Removed %s\n", f)
|
||||
}
|
||||
fmt.Printf(" Removed %s\n", configFile)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,94 +383,45 @@ 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)
|
||||
}
|
||||
@ -481,8 +429,7 @@ func setupSSHKey(client *HetznerClient) (SSHKeyConfig, error) {
|
||||
|
||||
return SSHKeyConfig{
|
||||
HetznerID: key.ID,
|
||||
PrivateKeyPath: "~/.orama/sandbox_key",
|
||||
PublicKeyPath: "~/.orama/sandbox_key.pub",
|
||||
VaultTarget: vaultTarget,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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" {
|
||||
// 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
|
||||
|
||||
@ -9,6 +9,8 @@ func TestAggregateHealthStatus_allHealthy(t *testing.T) {
|
||||
"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)
|
||||
@ -43,9 +45,11 @@ func TestAggregateHealthStatus_unavailableIsNotError(t *testing.T) {
|
||||
checks := map[string]checkResult{
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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 <host>/<user> --priv).
|
||||
# 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|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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user