diff --git a/core/pkg/cli/cmd/node/node.go b/core/pkg/cli/cmd/node/node.go index 474162a..19ff3a1 100644 --- a/core/pkg/cli/cmd/node/node.go +++ b/core/pkg/cli/cmd/node/node.go @@ -33,4 +33,5 @@ func init() { Cmd.AddCommand(enrollCmd) Cmd.AddCommand(unlockCmd) Cmd.AddCommand(migrateConfCmd) + Cmd.AddCommand(setupCmd) } diff --git a/core/pkg/cli/cmd/node/setup.go b/core/pkg/cli/cmd/node/setup.go new file mode 100644 index 0000000..b5131e9 --- /dev/null +++ b/core/pkg/cli/cmd/node/setup.go @@ -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") +} diff --git a/core/pkg/cli/production/setup/command.go b/core/pkg/cli/production/setup/command.go new file mode 100644 index 0000000..f2408ed --- /dev/null +++ b/core/pkg/cli/production/setup/command.go @@ -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 +} diff --git a/vault/src/main.zig b/vault/src/main.zig index 4efe5d3..eaa14b6 100644 --- a/vault/src/main.zig +++ b/vault/src/main.zig @@ -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); };