feat(cli): add node setup command

- implement automated VPS bootstrapping for Orama nodes
- add SSH key management via rootwallet
- support genesis node creation and cluster joining via invite tokens
This commit is contained in:
anonpenguin23 2026-03-28 10:24:48 +02:00
parent c27faa02fa
commit ab1be4105c
4 changed files with 384 additions and 5 deletions

View File

@ -33,4 +33,5 @@ func init() {
Cmd.AddCommand(enrollCmd)
Cmd.AddCommand(unlockCmd)
Cmd.AddCommand(migrateConfCmd)
Cmd.AddCommand(setupCmd)
}

View File

@ -0,0 +1,46 @@
package node
import (
"github.com/DeBrosOfficial/network/pkg/cli/production/setup"
"github.com/spf13/cobra"
)
var setupOpts setup.Options
var setupCmd = &cobra.Command{
Use: "setup",
Short: "Set up a fresh VPS as an Orama node",
Long: `Bootstrap a fresh VPS into a running Orama node in one command.
Creates an SSH key in rootwallet, installs it on the VPS, uploads the binary
archive, and runs the node install. For the first node, use --genesis to
create a new cluster.
Examples:
# Genesis node (first node, creates new cluster)
orama node setup --ip 1.2.3.4 --password 'vps-pass' --env devnet \
--base-domain orama-devnet.network --role nameserver --genesis
# Join existing cluster
orama node setup --ip 5.6.7.8 --password 'vps-pass' --env devnet \
--base-domain orama-devnet.network
# Join as nameserver
orama node setup --ip 9.10.11.12 --password 'vps-pass' --env devnet \
--base-domain orama-devnet.network --role nameserver`,
RunE: func(cmd *cobra.Command, args []string) error {
return setup.Run(setupOpts)
},
}
func init() {
setupCmd.Flags().StringVar(&setupOpts.IP, "ip", "", "Public IP address of the VPS (required)")
setupCmd.Flags().StringVar(&setupOpts.Env, "env", "", "Target environment (default: active)")
setupCmd.Flags().StringVar(&setupOpts.Role, "role", "node", "Node role: node or nameserver")
setupCmd.Flags().StringVar(&setupOpts.User, "user", "root", "SSH user on the VPS")
setupCmd.Flags().StringVar(&setupOpts.Password, "password", "", "One-time password for initial SSH access")
setupCmd.Flags().StringVar(&setupOpts.BaseDomain, "base-domain", "", "Base domain for the network")
setupCmd.Flags().BoolVar(&setupOpts.Genesis, "genesis", false, "Create a new cluster (first node)")
setupCmd.Flags().BoolVar(&setupOpts.AnyoneRelay, "anyone-relay", false, "Run as Anyone relay operator")
setupCmd.MarkFlagRequired("ip")
}

View File

@ -0,0 +1,331 @@
// Package setup implements the "orama node setup" command — a single command
// to bootstrap a fresh VPS into a running Orama node.
//
// Flow:
// 1. Create SSH key in rootwallet vault for this node
// 2. Install the public key on the VPS (one-time password-based SSH)
// 3. Upload the binary archive
// 4. For genesis: run install without --join
// 5. For joining: request invite token via operator API, run install with --join
package setup
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"github.com/DeBrosOfficial/network/pkg/auth"
"github.com/DeBrosOfficial/network/pkg/cli"
"github.com/DeBrosOfficial/network/pkg/cli/remotessh"
"github.com/DeBrosOfficial/network/pkg/inspector"
"github.com/DeBrosOfficial/network/pkg/rwagent"
)
// Options holds the flags for the setup command.
type Options struct {
IP string
Env string
Role string // "node" or "nameserver"
User string // SSH user (default: "root")
Password string // One-time password for initial SSH access
BaseDomain string
Genesis bool // If true, create a new cluster instead of joining
AnyoneRelay bool
}
// Run executes the node setup.
func Run(opts Options) error {
if opts.IP == "" {
return fmt.Errorf("--ip is required")
}
if opts.User == "" {
opts.User = "root"
}
if opts.Role == "" {
opts.Role = "node"
}
// 1. Ensure rootwallet agent is running
fmt.Println("Checking rootwallet agent...")
agentClient := rwagent.New(os.Getenv("RW_AGENT_SOCK"))
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
status, err := agentClient.Status(ctx)
if err != nil {
return fmt.Errorf("rootwallet agent not reachable: %w (is the desktop app running?)", err)
}
if status.Locked {
return fmt.Errorf("rootwallet agent is locked — unlock it in the desktop app first")
}
// 2. Get operator wallet address
addrData, err := agentClient.GetAddress(ctx, "evm")
if err != nil {
return fmt.Errorf("failed to get wallet address: %w", err)
}
fmt.Printf(" Wallet: %s\n", addrData.Address)
// 3. Create SSH key in rootwallet vault for this node
vaultTarget := fmt.Sprintf("%s/%s", opts.IP, opts.User)
fmt.Printf(" Setting up SSH key for %s...\n", vaultTarget)
if err := remotessh.EnsureVaultEntry(vaultTarget); err != nil {
return fmt.Errorf("failed to create SSH key in vault: %w", err)
}
pubKey, err := remotessh.ResolveVaultPublicKey(vaultTarget)
if err != nil {
return fmt.Errorf("failed to get public key: %w", err)
}
// 4. Install the public key on the VPS via password SSH
if opts.Password != "" {
fmt.Printf(" Installing SSH key on %s...\n", opts.IP)
if err := installPublicKey(opts.IP, opts.User, opts.Password, pubKey); err != nil {
return fmt.Errorf("failed to install SSH key: %w", err)
}
fmt.Println(" SSH key installed")
} else {
fmt.Println(" No --password provided, assuming SSH key is already installed")
}
// 5. Test SSH with rootwallet key
fmt.Println(" Testing SSH connection...")
node := inspector.Node{
Host: opts.IP,
User: opts.User,
VaultTarget: vaultTarget,
Environment: opts.Env,
Role: opts.Role,
}
nodes := []inspector.Node{node}
cleanup, err := remotessh.PrepareNodeKeys(nodes)
if err != nil {
return fmt.Errorf("failed to prepare SSH key: %w", err)
}
defer cleanup()
node = nodes[0] // SSHKey is now set
testResult := inspector.RunSSH(context.Background(), node, "echo ok")
if !testResult.OK() {
return fmt.Errorf("SSH test failed: %s", testResult.Stderr)
}
fmt.Println(" SSH connection OK")
// 6. Check if binary archive needs uploading
if needsArchiveUpload(node) {
archivePath := findNewestArchive()
if archivePath == "" {
return fmt.Errorf("no binary archive found in /tmp/ (run `orama build` first)")
}
fmt.Printf(" Uploading archive (%s)...\n", filepath.Base(archivePath))
if err := remotessh.UploadFile(node, archivePath, "/tmp/archive.tar.gz"); err != nil {
return fmt.Errorf("failed to upload archive: %w", err)
}
extractCmd := "sudo bash -c 'mkdir -p /opt/orama && tar xzf /tmp/archive.tar.gz -C /opt/orama && rm -f /tmp/archive.tar.gz'"
if err := remotessh.RunSSHStreaming(node, extractCmd); err != nil {
return fmt.Errorf("failed to extract archive: %w", err)
}
fmt.Println(" Archive extracted")
} else {
fmt.Println(" Binary already present on node")
}
// 7. Build the install command
installCmd, err := buildInstallCommand(opts, node, agentClient)
if err != nil {
return fmt.Errorf("failed to build install command: %w", err)
}
fmt.Printf("\n Running: %s\n\n", installCmd)
// 8. Run the install
if err := remotessh.RunSSHStreaming(node, installCmd); err != nil {
return fmt.Errorf("install failed: %w", err)
}
fmt.Printf("\n Node %s setup complete!\n", opts.IP)
return nil
}
// installPublicKey installs an SSH public key on a VPS using password authentication.
func installPublicKey(ip, user, password, pubKey string) error {
sshpassBin, err := findBinary("sshpass")
if err != nil {
return fmt.Errorf("sshpass is required for password-based SSH key installation: %w", err)
}
// Ensure .ssh directory exists and install the key
cmd := fmt.Sprintf(
`mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo '%s' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys && echo 'key installed'`,
strings.TrimSpace(pubKey),
)
args := []string{
"-p", password,
"ssh",
"-o", "StrictHostKeyChecking=no",
"-o", "ConnectTimeout=10",
fmt.Sprintf("%s@%s", user, ip),
cmd,
}
out, err := runCommand(sshpassBin, args...)
if err != nil {
return fmt.Errorf("sshpass failed: %w (%s)", err, out)
}
if !strings.Contains(out, "key installed") {
return fmt.Errorf("unexpected output: %s", out)
}
return nil
}
// buildInstallCommand constructs the `sudo orama node install` command.
func buildInstallCommand(opts Options, node inspector.Node, agentClient *rwagent.Client) (string, error) {
parts := []string{"sudo /opt/orama/bin/orama node install"}
parts = append(parts, "--vps-ip", opts.IP)
if opts.BaseDomain != "" {
parts = append(parts, "--base-domain", opts.BaseDomain)
}
if strings.HasPrefix(opts.Role, "nameserver") {
parts = append(parts, "--nameserver")
if opts.BaseDomain != "" {
parts = append(parts, "--domain", opts.BaseDomain)
}
}
if opts.AnyoneRelay {
parts = append(parts, "--anyone-relay")
} else {
parts = append(parts, "--anyone-client")
}
if !opts.Genesis {
// Get gateway URL and invite token
env := opts.Env
if env == "" {
active, err := cli.GetActiveEnvironment()
if err != nil {
return "", fmt.Errorf("failed to get active environment: %w", err)
}
env = active.Name
}
envConfig, err := cli.GetEnvironmentByName(env)
if err != nil {
return "", fmt.Errorf("environment %q not found: %w", env, err)
}
gatewayURL := envConfig.GatewayURL
// Request invite token via operator API
token, err := requestInviteToken(gatewayURL)
if err != nil {
return "", fmt.Errorf("failed to get invite token: %w", err)
}
parts = append(parts, "--join", gatewayURL, "--token", token)
}
return strings.Join(parts, " "), nil
}
// requestInviteToken calls POST /v1/operator/invite to get an invite token.
func requestInviteToken(gatewayURL string) (string, error) {
store, err := auth.LoadEnhancedCredentials()
if err != nil {
return "", fmt.Errorf("failed to load credentials: %w", err)
}
creds := store.GetDefaultCredential(gatewayURL)
if creds == nil || creds.APIKey == "" {
return "", fmt.Errorf("no credentials for %s — run 'orama auth login' first", gatewayURL)
}
body, _ := json.Marshal(map[string]int{"expiry_minutes": 60})
req, err := http.NewRequest(http.MethodPost, gatewayURL+"/v1/operator/invite", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-API-Key", creds.APIKey)
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
var result struct {
Token string `json:"token"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return "", fmt.Errorf("failed to parse response: %w", err)
}
if result.Token == "" {
return "", fmt.Errorf("empty token in response")
}
return result.Token, nil
}
// needsArchiveUpload checks if the node already has the orama binary.
func needsArchiveUpload(node inspector.Node) bool {
result := inspector.RunSSH(context.Background(), node, "/opt/orama/bin/orama version 2>/dev/null")
return !result.OK()
}
// findNewestArchive finds the newest orama binary archive in /tmp/.
func findNewestArchive() string {
matches, _ := filepath.Glob("/tmp/orama-*-linux-*.tar.gz")
if len(matches) == 0 {
return ""
}
sort.Slice(matches, func(i, j int) bool {
fi, _ := os.Stat(matches[i])
fj, _ := os.Stat(matches[j])
if fi == nil || fj == nil {
return false
}
return fi.ModTime().After(fj.ModTime())
})
return matches[0]
}
func findBinary(name string) (string, error) {
paths := []string{
"/opt/homebrew/bin/" + name,
"/usr/local/bin/" + name,
"/usr/bin/" + name,
}
for _, p := range paths {
if _, err := os.Stat(p); err == nil {
return p, nil
}
}
return "", fmt.Errorf("%s not found", name)
}
func runCommand(bin string, args ...string) (string, error) {
cmd := &exec.Cmd{
Path: bin,
Args: append([]string{bin}, args...),
}
out, err := cmd.CombinedOutput()
return string(out), err
}

View File

@ -7,12 +7,13 @@ const guardian_mod = @import("guardian.zig");
const heartbeat = @import("peer/heartbeat.zig");
const posix = std.posix;
/// Global shutdown flag set by signal handlers.
var shutdown_flag = std.atomic.Value(bool).init(false);
/// Global running flag true while the server should keep running.
/// Signal handlers set this to false to trigger graceful shutdown.
var running_flag = std.atomic.Value(bool).init(true);
fn signalHandler(sig: i32) callconv(.c) void {
_ = sig;
shutdown_flag.store(true, .release);
running_flag.store(false, .release);
}
pub fn main() !void {
@ -97,7 +98,7 @@ pub fn main() !void {
// Start heartbeat thread
var hb_thread: ?std.Thread = blk: {
break :blk std.Thread.spawn(.{}, heartbeatLoop, .{ &guardian, &shutdown_flag }) catch |err| {
break :blk std.Thread.spawn(.{}, heartbeatLoop, .{ &guardian, &running_flag }) catch |err| {
log.warn("failed to start heartbeat thread: {}, running without heartbeat", .{err});
break :blk null;
};
@ -113,7 +114,7 @@ pub fn main() !void {
.allocator = allocator,
.guardian = &guardian,
};
listener.serve(ctx, &shutdown_flag) catch |err| {
listener.serve(ctx, &running_flag) catch |err| {
log.err("server failed: {}", .{err});
std.process.exit(1);
};