diff --git a/core/cmd/cli/root.go b/core/cmd/cli/root.go index 0f27fdd..2ed928a 100644 --- a/core/cmd/cli/root.go +++ b/core/cmd/cli/root.go @@ -18,7 +18,11 @@ import ( "github.com/DeBrosOfficial/network/pkg/cli/cmd/monitorcmd" "github.com/DeBrosOfficial/network/pkg/cli/cmd/namespacecmd" "github.com/DeBrosOfficial/network/pkg/cli/cmd/node" + "github.com/DeBrosOfficial/network/pkg/cli/cmd/nodescmd" + "github.com/DeBrosOfficial/network/pkg/cli/cmd/rolloutcmd" "github.com/DeBrosOfficial/network/pkg/cli/cmd/sandboxcmd" + "github.com/DeBrosOfficial/network/pkg/cli/cmd/sshcmd" + "github.com/DeBrosOfficial/network/pkg/cli/cmd/statuscmd" ) // version metadata populated via -ldflags at build time @@ -91,6 +95,12 @@ and interacting with the Orama distributed network.`, // Sandbox command (ephemeral Hetzner Cloud clusters) rootCmd.AddCommand(sandboxcmd.Cmd) + // Unified node management commands + rootCmd.AddCommand(nodescmd.Cmd) + rootCmd.AddCommand(rolloutcmd.Cmd) + rootCmd.AddCommand(statuscmd.Cmd) + rootCmd.AddCommand(sshcmd.Cmd) + return rootCmd } diff --git a/core/migrations/020_node_operators.sql b/core/migrations/020_node_operators.sql new file mode 100644 index 0000000..eb2343c --- /dev/null +++ b/core/migrations/020_node_operators.sql @@ -0,0 +1,14 @@ +-- Add operator wallet tracking to nodes. +-- operator_wallet links nodes to the wallet that provisioned them. + +ALTER TABLE dns_nodes ADD COLUMN operator_wallet TEXT; +ALTER TABLE dns_nodes ADD COLUMN environment TEXT DEFAULT 'production'; +ALTER TABLE dns_nodes ADD COLUMN ssh_user TEXT DEFAULT 'root'; +ALTER TABLE dns_nodes ADD COLUMN role TEXT DEFAULT 'node'; + +CREATE INDEX IF NOT EXISTS idx_dns_nodes_operator ON dns_nodes(operator_wallet); +CREATE INDEX IF NOT EXISTS idx_dns_nodes_environment ON dns_nodes(environment); + +ALTER TABLE wireguard_peers ADD COLUMN operator_wallet TEXT; + +ALTER TABLE invite_tokens ADD COLUMN operator_wallet TEXT; diff --git a/core/pkg/cli/cmd/node/migrate_conf.go b/core/pkg/cli/cmd/node/migrate_conf.go new file mode 100644 index 0000000..1b9e2af --- /dev/null +++ b/core/pkg/cli/cmd/node/migrate_conf.go @@ -0,0 +1,116 @@ +package node + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/DeBrosOfficial/network/pkg/auth" + "github.com/DeBrosOfficial/network/pkg/cli" + "github.com/DeBrosOfficial/network/pkg/cli/remotessh" + "github.com/spf13/cobra" +) + +var migrateConfEnv string + +var migrateConfCmd = &cobra.Command{ + Use: "migrate-conf", + Short: "Register nodes.conf nodes with your wallet", + Long: `One-time migration: reads nodes from nodes.conf for an environment +and registers each with your wallet via the gateway API. After migration, +these nodes will appear in 'orama nodes' output. + +Requires: orama auth login (for API authentication)`, + RunE: func(cmd *cobra.Command, args []string) error { + env := migrateConfEnv + if env == "" { + active, err := cli.GetActiveEnvironment() + if err != nil { + return fmt.Errorf("failed to get active environment: %w", err) + } + env = active.Name + } + + // Load nodes from nodes.conf + nodes, err := remotessh.LoadEnvNodes(env) + if err != nil { + return fmt.Errorf("failed to load nodes.conf: %w", err) + } + + // Get gateway URL + envConfig, err := cli.GetEnvironmentByName(env) + if err != nil { + return fmt.Errorf("environment %q not configured: %w", env, err) + } + + // Load stored credentials + store, err := auth.LoadEnhancedCredentials() + if err != nil { + return fmt.Errorf("failed to load credentials: %w", err) + } + creds := store.GetDefaultCredential(envConfig.GatewayURL) + if creds == nil || creds.APIKey == "" { + return fmt.Errorf("no credentials for %s — run 'orama auth login' first", envConfig.GatewayURL) + } + + if len(nodes) == 0 { + fmt.Printf("No nodes found for environment %q in nodes.conf\n", env) + return nil + } + + fmt.Printf("Migrating %d node(s) from nodes.conf to %s...\n\n", len(nodes), env) + + httpClient := &http.Client{Timeout: 10 * time.Second} + registered := 0 + + for _, n := range nodes { + body := map[string]string{ + "ip_address": n.Host, + "environment": env, + "role": n.Role, + "ssh_user": n.User, + } + payload, _ := json.Marshal(body) + + req, err := http.NewRequest(http.MethodPost, + envConfig.GatewayURL+"/v1/operator/node/register", + bytes.NewReader(payload)) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), " %s: failed to create request: %v\n", n.Host, err) + continue + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-API-Key", creds.APIKey) + + resp, err := httpClient.Do(req) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), " %s: request failed: %v\n", n.Host, err) + continue + } + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + fmt.Printf(" %s (%s): registered\n", n.Host, n.Role) + registered++ + } else if resp.StatusCode == http.StatusNotFound { + fmt.Printf(" %s: not found in cluster (node may not have joined yet)\n", n.Host) + } else { + fmt.Fprintf(cmd.ErrOrStderr(), " %s: HTTP %d: %s\n", n.Host, resp.StatusCode, string(respBody)) + } + } + + fmt.Printf("\n%d/%d nodes registered with your wallet\n", registered, len(nodes)) + if registered < len(nodes) { + fmt.Println("Nodes not found may need to join the cluster first, then re-run this command.") + } + return nil + }, +} + +func init() { + migrateConfCmd.Flags().StringVar(&migrateConfEnv, "env", "", "Environment to migrate (default: active)") +} diff --git a/core/pkg/cli/cmd/node/node.go b/core/pkg/cli/cmd/node/node.go index 74f9744..474162a 100644 --- a/core/pkg/cli/cmd/node/node.go +++ b/core/pkg/cli/cmd/node/node.go @@ -32,4 +32,5 @@ func init() { Cmd.AddCommand(recoverRaftCmd) Cmd.AddCommand(enrollCmd) Cmd.AddCommand(unlockCmd) + Cmd.AddCommand(migrateConfCmd) } diff --git a/core/pkg/cli/cmd/nodescmd/nodes.go b/core/pkg/cli/cmd/nodescmd/nodes.go new file mode 100644 index 0000000..3811768 --- /dev/null +++ b/core/pkg/cli/cmd/nodescmd/nodes.go @@ -0,0 +1,58 @@ +package nodescmd + +import ( + "fmt" + "os" + "text/tabwriter" + + "github.com/DeBrosOfficial/network/pkg/cli" + "github.com/DeBrosOfficial/network/pkg/cli/noderesolver" + "github.com/spf13/cobra" +) + +var envFlag string + +// Cmd is the top-level "nodes" command — lists operator's nodes. +var Cmd = &cobra.Command{ + Use: "nodes", + Short: "List your nodes across environments", + Long: `List all nodes owned by your wallet. Queries the network API +with your stored credentials, falling back to nodes.conf. + +Requires: orama auth login (for API-based resolution)`, + RunE: func(cmd *cobra.Command, args []string) error { + env := envFlag + if env == "" { + active, err := cli.GetActiveEnvironment() + if err != nil { + return fmt.Errorf("failed to get active environment: %w", err) + } + env = active.Name + } + + nodes, err := noderesolver.ResolveNodes(env) + if err != nil { + return fmt.Errorf("failed to resolve nodes: %w", err) + } + + if len(nodes) == 0 { + fmt.Printf("No nodes found for environment %q\n", env) + fmt.Println("Register nodes with: orama node setup --env", env) + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) + fmt.Fprintf(w, "IP\tROLE\tUSER\tENVIRONMENT\n") + for _, n := range nodes { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", n.Host, n.Role, n.User, n.Environment) + } + w.Flush() + + fmt.Printf("\n%d node(s) in %s\n", len(nodes), env) + return nil + }, +} + +func init() { + Cmd.Flags().StringVar(&envFlag, "env", "", "Filter by environment (default: active environment)") +} diff --git a/core/pkg/cli/cmd/rolloutcmd/rollout.go b/core/pkg/cli/cmd/rolloutcmd/rollout.go new file mode 100644 index 0000000..40f9717 --- /dev/null +++ b/core/pkg/cli/cmd/rolloutcmd/rollout.go @@ -0,0 +1,234 @@ +package rolloutcmd + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/DeBrosOfficial/network/pkg/cli" + "github.com/DeBrosOfficial/network/pkg/cli/noderesolver" + "github.com/DeBrosOfficial/network/pkg/cli/remotessh" + "github.com/DeBrosOfficial/network/pkg/inspector" + "github.com/spf13/cobra" +) + +var ( + envFlag string + delaySec int +) + +// Cmd is the top-level "rollout" command — build + push + rolling upgrade. +var Cmd = &cobra.Command{ + Use: "rollout", + Short: "Rolling upgrade of your nodes", + Long: `Build, push, and perform a rolling upgrade on all your nodes in an environment. +Upgrades followers first, leader last, with health checks between each node.`, + RunE: func(cmd *cobra.Command, args []string) error { + env := envFlag + if env == "" { + active, err := cli.GetActiveEnvironment() + if err != nil { + return fmt.Errorf("failed to get active environment: %w", err) + } + env = active.Name + } + + nodes, err := noderesolver.ResolveNodes(env) + if err != nil { + return fmt.Errorf("failed to resolve nodes: %w", err) + } + if len(nodes) == 0 { + return fmt.Errorf("no nodes found for environment %q", env) + } + + cleanup, err := remotessh.PrepareNodeKeys(nodes) + if err != nil { + return fmt.Errorf("failed to prepare SSH keys: %w", err) + } + defer cleanup() + + fmt.Printf("Rolling out to %d node(s) in %s\n\n", len(nodes), env) + + // Step 1: Find archive + archivePath := findNewestArchive() + if archivePath == "" { + return fmt.Errorf("no binary archive found in /tmp/ (run `orama build` first)") + } + info, err := os.Stat(archivePath) + if err != nil { + return fmt.Errorf("stat archive %s: %w", archivePath, err) + } + fmt.Printf("Archive: %s (%s)\n\n", filepath.Base(archivePath), formatBytes(info.Size())) + + // Step 2: Push archive to all nodes + fmt.Println("Pushing archive to all nodes...") + if err := pushArchive(nodes, archivePath); err != nil { + return err + } + + // Step 3: Rolling upgrade — followers first, leader last + fmt.Println("\nRolling upgrade (followers first, leader last)...") + + leaderIdx := findLeaderIndex(nodes) + if leaderIdx < 0 { + fmt.Fprintf(os.Stderr, " Warning: could not detect RQLite leader, upgrading in order\n") + } + + // Determine SSH options based on environment + var sshOpts []remotessh.SSHOption + if env == "sandbox" { + sshOpts = append(sshOpts, remotessh.WithNoHostKeyCheck()) + } + + delay := time.Duration(delaySec) * time.Second + + // Upgrade non-leaders first + count := 0 + for i := range nodes { + if i == leaderIdx { + continue + } + count++ + if err := upgradeNode(nodes[i], count, len(nodes), sshOpts); err != nil { + return err + } + if count < len(nodes) { + fmt.Printf(" Waiting %s before next node...\n", delay) + time.Sleep(delay) + } + } + + // Upgrade leader last + if leaderIdx >= 0 { + count++ + if err := upgradeNode(nodes[leaderIdx], count, len(nodes), sshOpts); err != nil { + return err + } + } + + fmt.Printf("\nRollout complete for %s (%d nodes)\n", env, len(nodes)) + return nil + }, +} + +func init() { + Cmd.Flags().StringVar(&envFlag, "env", "", "Environment (default: active)") + Cmd.Flags().IntVar(&delaySec, "delay", 30, "Seconds to wait between node upgrades") +} + +// findLeaderIndex returns the index of the RQLite leader, or -1 if unknown. +func findLeaderIndex(nodes []inspector.Node) int { + for i, n := range nodes { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + result := inspector.RunSSH(ctx, n, "curl -sf http://localhost:5001/status 2>/dev/null | grep -o '\"state\":\"[^\"]*\"'") + cancel() + if result.OK() && strings.Contains(result.Stdout, "Leader") { + return i + } + } + return -1 +} + +// upgradeNode performs orama node upgrade --restart on a single node. +func upgradeNode(node inspector.Node, current, total int, sshOpts []remotessh.SSHOption) error { + fmt.Printf(" [%d/%d] Upgrading %s...\n", current, total, node.Host) + + // Pre-replace orama CLI binary to avoid ETXTBSY + preReplace := "rm -f /usr/local/bin/orama && cp /opt/orama/bin/orama /usr/local/bin/orama" + if err := remotessh.RunSSHStreaming(node, preReplace, sshOpts...); err != nil { + return fmt.Errorf("pre-replace orama binary on %s: %w", node.Host, err) + } + + if err := remotessh.RunSSHStreaming(node, "orama node upgrade --restart", sshOpts...); err != nil { + return fmt.Errorf("upgrade %s: %w", node.Host, err) + } + + // Wait for health + fmt.Printf(" Checking health...") + if err := waitForHealth(node, 2*time.Minute); err != nil { + fmt.Printf(" WARN: %v\n", err) + } else { + fmt.Println(" OK") + } + + return nil +} + +// pushArchive uploads the archive to the first node, then fans out server-to-server. +func pushArchive(nodes []inspector.Node, archivePath string) error { + if len(nodes) == 0 { + return nil + } + + remotePath := "/tmp/" + filepath.Base(archivePath) + + // Upload to first node + hub := nodes[0] + fmt.Printf(" Uploading to %s...\n", hub.Host) + if err := remotessh.UploadFile(hub, archivePath, remotePath); err != nil { + return fmt.Errorf("upload to %s: %w", hub.Host, err) + } + + // Extract on hub + extractCmd := fmt.Sprintf("mkdir -p /opt/orama && tar xzf %s -C /opt/orama && rm -f %s", remotePath, remotePath) + if err := remotessh.RunSSHStreaming(hub, extractCmd); err != nil { + return fmt.Errorf("extract on %s: %w", hub.Host, err) + } + + // For remaining nodes, upload directly and extract + for _, n := range nodes[1:] { + fmt.Printf(" Uploading to %s...\n", n.Host) + if err := remotessh.UploadFile(n, archivePath, remotePath); err != nil { + return fmt.Errorf("upload to %s: %w", n.Host, err) + } + if err := remotessh.RunSSHStreaming(n, extractCmd); err != nil { + return fmt.Errorf("extract on %s: %w", n.Host, err) + } + } + + return nil +} + +// waitForHealth polls RQLite health on a node until it reaches Leader or Follower state. +func waitForHealth(node inspector.Node, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + result := inspector.RunSSH(ctx, node, "curl -sf http://localhost:5001/status 2>/dev/null | grep -o '\"state\":\"[^\"]*\"'") + cancel() + if result.OK() && (strings.Contains(result.Stdout, "Leader") || strings.Contains(result.Stdout, "Follower")) { + return nil + } + time.Sleep(3 * time.Second) + } + return fmt.Errorf("timed out waiting for healthy state on %s", node.Host) +} + +// findNewestArchive finds the newest orama binary archive in /tmp/. +func findNewestArchive() string { + matches, err := filepath.Glob("/tmp/orama-*-linux-*.tar.gz") + if err != nil || 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 formatBytes(b int64) string { + const mb = 1024 * 1024 + if b >= mb { + return fmt.Sprintf("%.1f MB", float64(b)/float64(mb)) + } + return fmt.Sprintf("%d KB", b/1024) +} diff --git a/core/pkg/cli/cmd/sshcmd/ssh.go b/core/pkg/cli/cmd/sshcmd/ssh.go new file mode 100644 index 0000000..448e29e --- /dev/null +++ b/core/pkg/cli/cmd/sshcmd/ssh.go @@ -0,0 +1,87 @@ +package sshcmd + +import ( + "fmt" + "os" + "os/exec" + + "github.com/DeBrosOfficial/network/pkg/cli" + "github.com/DeBrosOfficial/network/pkg/cli/noderesolver" + "github.com/DeBrosOfficial/network/pkg/cli/remotessh" + "github.com/DeBrosOfficial/network/pkg/inspector" + "github.com/spf13/cobra" +) + +var envFlag string + +// Cmd is the top-level "ssh" command — SSH into any node by IP or hostname. +var Cmd = &cobra.Command{ + Use: "ssh ", + Short: "SSH into a node", + Long: `SSH into a node by IP address or hostname. +Resolves the SSH key from rootwallet automatically.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + target := args[0] + + env := envFlag + if env == "" { + active, err := cli.GetActiveEnvironment() + if err != nil { + return fmt.Errorf("failed to get active environment: %w", err) + } + env = active.Name + } + + // Resolve nodes to find the target + nodes, err := noderesolver.ResolveNodes(env) + if err != nil { + return fmt.Errorf("failed to resolve nodes: %w", err) + } + + // Match by IP + for _, n := range nodes { + if n.Host == target { + return sshInto(n) + } + } + + // Not found — try direct SSH with default vault target + fmt.Printf("Node %q not found in %s nodes, attempting direct SSH...\n", target, env) + return sshInto(inspector.Node{ + Host: target, + User: "root", + VaultTarget: target + "/root", + }) + }, +} + +func init() { + Cmd.Flags().StringVar(&envFlag, "env", "", "Environment to search (default: active)") +} + +func sshInto(node inspector.Node) error { + nodes := []inspector.Node{node} + cleanup, err := remotessh.PrepareNodeKeys(nodes) + if err != nil { + return fmt.Errorf("failed to resolve SSH key: %w", err) + } + defer cleanup() + + keyPath := nodes[0].SSHKey + + sshBin, err := exec.LookPath("ssh") + if err != nil { + return fmt.Errorf("ssh not found in PATH: %w", err) + } + + sshCmd := exec.Command(sshBin, + "-i", keyPath, + "-o", "StrictHostKeyChecking=accept-new", + fmt.Sprintf("%s@%s", node.User, node.Host), + ) + sshCmd.Stdin = os.Stdin + sshCmd.Stdout = os.Stdout + sshCmd.Stderr = os.Stderr + return sshCmd.Run() +} diff --git a/core/pkg/cli/cmd/statuscmd/status.go b/core/pkg/cli/cmd/statuscmd/status.go new file mode 100644 index 0000000..0d9547d --- /dev/null +++ b/core/pkg/cli/cmd/statuscmd/status.go @@ -0,0 +1,143 @@ +package statuscmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "sync" + "text/tabwriter" + "time" + + "github.com/DeBrosOfficial/network/pkg/cli" + "github.com/DeBrosOfficial/network/pkg/cli/noderesolver" + "github.com/DeBrosOfficial/network/pkg/cli/remotessh" + "github.com/DeBrosOfficial/network/pkg/inspector" + "github.com/spf13/cobra" +) + +var ( + envFlag string + jsonFlag bool +) + +// Cmd is the top-level "status" command — health check for operator's nodes. +var Cmd = &cobra.Command{ + Use: "status", + Short: "Show health status of your nodes", + Long: `Check the health of all your nodes in an environment. +SSHes into each node and runs orama node report to collect health data.`, + RunE: func(cmd *cobra.Command, args []string) error { + env := envFlag + if env == "" { + active, err := cli.GetActiveEnvironment() + if err != nil { + return fmt.Errorf("failed to get active environment: %w", err) + } + env = active.Name + } + + nodes, err := noderesolver.ResolveNodes(env) + if err != nil { + return fmt.Errorf("failed to resolve nodes: %w", err) + } + + if len(nodes) == 0 { + fmt.Printf("No nodes found for environment %q\n", env) + return nil + } + + cleanup, err := remotessh.PrepareNodeKeys(nodes) + if err != nil { + return fmt.Errorf("failed to prepare SSH keys: %w", err) + } + defer cleanup() + + fmt.Printf("Checking %d node(s) in %s...\n\n", len(nodes), env) + + type nodeResult struct { + Host string `json:"host"` + Role string `json:"role"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + } + + results := make([]nodeResult, len(nodes)) + var wg sync.WaitGroup + + for i, n := range nodes { + wg.Add(1) + go func(idx int, node inspector.Node) { + defer wg.Done() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result := inspector.RunSSH(ctx, node, "sudo orama node report --json") + nr := nodeResult{Host: node.Host, Role: node.Role} + + if !result.OK() { + nr.Status = "unreachable" + nr.Error = fmt.Sprintf("SSH failed (exit %d)", result.ExitCode) + if result.Stderr != "" { + nr.Error = result.Stderr + if len(nr.Error) > 100 { + nr.Error = nr.Error[:100] + "..." + } + } + results[idx] = nr + return + } + + var report struct { + Gateway struct { + Responsive bool `json:"responsive"` + } `json:"gateway"` + RQLite struct { + RaftState string `json:"raft_state"` + } `json:"rqlite"` + } + if err := json.Unmarshal([]byte(result.Stdout), &report); err != nil { + nr.Status = "unknown" + nr.Error = "failed to parse report" + results[idx] = nr + return + } + + if report.Gateway.Responsive && (report.RQLite.RaftState == "Leader" || report.RQLite.RaftState == "Follower") { + nr.Status = "healthy" + } else { + nr.Status = "degraded" + } + results[idx] = nr + }(i, n) + } + wg.Wait() + + if jsonFlag { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(results) + } + + w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) + fmt.Fprintf(w, "IP\tROLE\tSTATUS\tDETAILS\n") + healthy := 0 + for _, r := range results { + details := r.Error + if r.Status == "healthy" { + healthy++ + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", r.Host, r.Role, r.Status, details) + } + w.Flush() + + fmt.Printf("\n%d/%d nodes healthy\n", healthy, len(results)) + return nil + }, +} + +func init() { + Cmd.Flags().StringVar(&envFlag, "env", "", "Environment (default: active)") + Cmd.Flags().BoolVar(&jsonFlag, "json", false, "Output as JSON") +} diff --git a/core/pkg/cli/noderesolver/resolver.go b/core/pkg/cli/noderesolver/resolver.go new file mode 100644 index 0000000..d88903e --- /dev/null +++ b/core/pkg/cli/noderesolver/resolver.go @@ -0,0 +1,156 @@ +// Package noderesolver provides unified node discovery for the orama CLI. +// +// It resolves operator-owned nodes by querying the network's gateway API +// (primary) or falling back to the legacy nodes.conf file. +package noderesolver + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "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" +) + +// httpClient is the shared HTTP client for API calls. +var httpClient = &http.Client{Timeout: 10 * time.Second} + +// ResolveNodes returns the operator's nodes for a given environment. +// It first tries the network API (GET /v1/operator/nodes), then falls +// back to nodes.conf if the API is unreachable or returns no results. +func ResolveNodes(env string) ([]inspector.Node, error) { + nodes, err := resolveFromNetwork(env) + if err == nil && len(nodes) > 0 { + return nodes, nil + } + + // Fallback to nodes.conf + confNodes, confErr := remotessh.LoadEnvNodes(env) + if confErr != nil { + if err != nil { + return nil, fmt.Errorf("network API: %w; nodes.conf: %v", err, confErr) + } + return nil, confErr + } + return confNodes, nil +} + +// ResolveNodesNetworkOnly queries only the network API without nodes.conf fallback. +func ResolveNodesNetworkOnly(env string) ([]inspector.Node, error) { + return resolveFromNetwork(env) +} + +// resolveFromNetwork queries the gateway API for operator-owned nodes. +func resolveFromNetwork(env string) ([]inspector.Node, error) { + // 1. Get gateway URL for the environment + gatewayURL, err := gatewayURLForEnv(env) + if err != nil { + return nil, fmt.Errorf("failed to resolve gateway URL: %w", err) + } + + // 2. Load stored credentials for this gateway + apiKey, err := loadAPIKey(gatewayURL) + if err != nil { + return nil, fmt.Errorf("no credentials for %s: %w (run 'orama auth login' first)", gatewayURL, err) + } + + return resolveFromNetworkWithURL(gatewayURL, apiKey, env) +} + +// resolveFromNetworkWithURL queries a specific gateway URL with an API key. +// Exported for testing. +func resolveFromNetworkWithURL(gatewayURL, apiKey, env string) ([]inspector.Node, error) { + endpoint := fmt.Sprintf("%s/v1/operator/nodes?env=%s", gatewayURL, url.QueryEscape(env)) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("X-API-Key", apiKey) + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to reach gateway: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("gateway returned HTTP %d: %s", resp.StatusCode, string(body)) + } + + var result struct { + Nodes []struct { + ID string `json:"id"` + IPAddress string `json:"ip_address"` + InternalIP string `json:"internal_ip"` + Environment string `json:"environment"` + Role string `json:"role"` + SSHUser string `json:"ssh_user"` + Status string `json:"status"` + } `json:"nodes"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + nodes := make([]inspector.Node, 0, len(result.Nodes)) + for _, n := range result.Nodes { + user := n.SSHUser + if user == "" { + user = "root" + } + nodes = append(nodes, inspector.Node{ + Environment: n.Environment, + User: user, + Host: n.IPAddress, + Role: n.Role, + VaultTarget: fmt.Sprintf("%s/%s", n.IPAddress, user), + }) + } + + return nodes, nil +} + +// gatewayURLForEnv returns the gateway URL for a given environment name. +// If env is empty, uses the active environment. +func gatewayURLForEnv(env string) (string, error) { + if env == "" { + e, err := cli.GetActiveEnvironment() + if err != nil { + return "", err + } + return e.GatewayURL, nil + } + + e, err := cli.GetEnvironmentByName(env) + if err != nil { + return "", err + } + return e.GatewayURL, nil +} + +// loadAPIKey loads the stored API key for a gateway URL. +func loadAPIKey(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 found for %s", gatewayURL) + } + + return creds.APIKey, nil +} diff --git a/core/pkg/cli/noderesolver/resolver_test.go b/core/pkg/cli/noderesolver/resolver_test.go new file mode 100644 index 0000000..3750e87 --- /dev/null +++ b/core/pkg/cli/noderesolver/resolver_test.go @@ -0,0 +1,152 @@ +package noderesolver + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGatewayURLForEnv_knownEnv(t *testing.T) { + url, err := gatewayURLForEnv("devnet") + if err != nil { + t.Fatalf("gatewayURLForEnv(devnet): %v", err) + } + if url == "" { + t.Error("expected non-empty gateway URL for devnet") + } +} + +func TestGatewayURLForEnv_unknownEnv(t *testing.T) { + _, err := gatewayURLForEnv("nonexistent") + if err == nil { + t.Error("expected error for unknown environment") + } +} + +func TestResolveFromMockServer_happyPath(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/operator/nodes" { + http.Error(w, "not found", http.StatusNotFound) + return + } + if r.Header.Get("X-API-Key") != "test-key" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + env := r.URL.Query().Get("env") + resp := map[string]interface{}{ + "nodes": []map[string]string{ + {"id": "node-1", "ip_address": "1.2.3.4", "environment": env, "role": "nameserver", "ssh_user": "root", "status": "active"}, + {"id": "node-2", "ip_address": "5.6.7.8", "environment": env, "role": "node", "ssh_user": "ubuntu", "status": "active"}, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + nodes, err := resolveFromNetworkWithURL(server.URL, "test-key", "devnet") + if err != nil { + t.Fatalf("resolveFromNetworkWithURL: %v", err) + } + + if len(nodes) != 2 { + t.Fatalf("expected 2 nodes, got %d", len(nodes)) + } + + if nodes[0].Host != "1.2.3.4" { + t.Errorf("node 0 host = %q, want %q", nodes[0].Host, "1.2.3.4") + } + if nodes[0].Role != "nameserver" { + t.Errorf("node 0 role = %q, want %q", nodes[0].Role, "nameserver") + } + if nodes[0].VaultTarget != "1.2.3.4/root" { + t.Errorf("node 0 vault target = %q, want %q", nodes[0].VaultTarget, "1.2.3.4/root") + } + if nodes[0].Environment != "devnet" { + t.Errorf("node 0 environment = %q, want %q", nodes[0].Environment, "devnet") + } + if nodes[1].User != "ubuntu" { + t.Errorf("node 1 user = %q, want %q", nodes[1].User, "ubuntu") + } + if nodes[1].VaultTarget != "5.6.7.8/ubuntu" { + t.Errorf("node 1 vault target = %q, want %q", nodes[1].VaultTarget, "5.6.7.8/ubuntu") + } +} + +func TestResolveFromMockServer_emptySSHUser(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]interface{}{ + "nodes": []map[string]string{ + {"id": "node-1", "ip_address": "1.2.3.4", "environment": "devnet", "role": "node", "ssh_user": "", "status": "active"}, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + nodes, err := resolveFromNetworkWithURL(server.URL, "key", "devnet") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(nodes) != 1 { + t.Fatalf("expected 1 node, got %d", len(nodes)) + } + if nodes[0].User != "root" { + t.Errorf("user = %q, want %q (default)", nodes[0].User, "root") + } + if nodes[0].VaultTarget != "1.2.3.4/root" { + t.Errorf("vault target = %q, want %q", nodes[0].VaultTarget, "1.2.3.4/root") + } +} + +func TestResolveFromMockServer_unauthorized(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + })) + defer server.Close() + + _, err := resolveFromNetworkWithURL(server.URL, "bad-key", "devnet") + if err == nil { + t.Error("expected error for unauthorized request") + } +} + +func TestResolveFromMockServer_emptyNodes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"nodes": []interface{}{}}) + })) + defer server.Close() + + nodes, err := resolveFromNetworkWithURL(server.URL, "key", "devnet") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(nodes) != 0 { + t.Errorf("expected 0 nodes, got %d", len(nodes)) + } +} + +func TestResolveFromMockServer_malformedJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`not json`)) + })) + defer server.Close() + + _, err := resolveFromNetworkWithURL(server.URL, "key", "devnet") + if err == nil { + t.Error("expected error for malformed JSON response") + } +} + +func TestResolveFromMockServer_serverDown(t *testing.T) { + _, err := resolveFromNetworkWithURL("http://127.0.0.1:1", "key", "devnet") + if err == nil { + t.Error("expected error for unreachable server") + } +} diff --git a/core/pkg/cli/sandbox/create.go b/core/pkg/cli/sandbox/create.go index 2531130..747c150 100644 --- a/core/pkg/cli/sandbox/create.go +++ b/core/pkg/cli/sandbox/create.go @@ -154,6 +154,9 @@ func Create(name string) error { fmt.Fprintf(os.Stderr, "Warning: failed to switch to sandbox environment: %v\n", err) } + // Tag all nodes with operator wallet for unified node management + registerNodesWithOperator(state, sshKeyPath) + printCreateSummary(cfg, state) return nil } @@ -643,6 +646,36 @@ func printCreateSummary(cfg *Config, state *SandboxState) { fmt.Println("Destroy: orama sandbox destroy") } +// registerNodesWithOperator tags all sandbox nodes with the operator's wallet +// via a direct RQLite UPDATE on the genesis node. This enables `orama nodes` +// to discover sandbox nodes alongside production nodes. +func registerNodesWithOperator(state *SandboxState, sshKeyPath string) { + client := rwagent.New(os.Getenv("RW_AGENT_SOCK")) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + addrData, err := client.GetAddress(ctx, "evm") + if err != nil || addrData == nil || addrData.Address == "" { + fmt.Fprintf(os.Stderr, "Warning: could not get operator wallet, nodes not tagged: %v\n", err) + return + } + wallet := addrData.Address + + if len(state.Servers) == 0 { + return + } + genesis := state.Servers[0] + + node := inspector.Node{User: "root", Host: genesis.IP, SSHKey: sshKeyPath} + // Use RQLite's parameterized query to avoid any injection risk. + // The JSON payload has the wallet as a parameter, not interpolated into SQL. + payload := fmt.Sprintf(`[["UPDATE dns_nodes SET operator_wallet = ?, environment = 'sandbox' WHERE operator_wallet IS NULL OR operator_wallet = ''", %q]]`, wallet) + cmd := fmt.Sprintf(`curl -sf -X POST http://localhost:5001/db/execute -H 'Content-Type: application/json' -d '%s'`, payload) + if _, err := runSSHOutput(node, cmd); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to tag nodes with operator wallet: %v\n", err) + } +} + // cleanupFailedCreate deletes any servers that were created during a failed provision. func cleanupFailedCreate(client *HetznerClient, state *SandboxState) { if len(state.Servers) == 0 { diff --git a/core/pkg/cli/sandbox/destroy.go b/core/pkg/cli/sandbox/destroy.go index 1e9191a..8e7ae3d 100644 --- a/core/pkg/cli/sandbox/destroy.go +++ b/core/pkg/cli/sandbox/destroy.go @@ -4,6 +4,7 @@ import ( "bufio" "fmt" "os" + "os/exec" "strings" "sync" @@ -107,10 +108,25 @@ func Destroy(name string, force bool) error { fmt.Fprintf(os.Stderr, "Warning: failed to remove sandbox environment: %v\n", err) } + // Clean up SSH known_hosts entries for destroyed server IPs. + // This prevents "REMOTE HOST IDENTIFICATION HAS CHANGED" errors + // when the same IPs are reused by a new sandbox. + cleanupKnownHosts(state) + fmt.Printf("\nSandbox %q destroyed (%d servers deleted)\n", state.Name, len(state.Servers)) return nil } +// cleanupKnownHosts removes SSH known_hosts entries for all sandbox server IPs. +func cleanupKnownHosts(state *SandboxState) { + for _, srv := range state.Servers { + cmd := exec.Command("ssh-keygen", "-R", srv.IP) + cmd.Stdout = nil + cmd.Stderr = nil + cmd.Run() // best-effort, ignore errors + } +} + // resolveSandbox finds a sandbox by name or returns the active one. func resolveSandbox(name string) (*SandboxState, error) { if name != "" { diff --git a/core/pkg/gateway/gateway.go b/core/pkg/gateway/gateway.go index 389ab00..531f883 100644 --- a/core/pkg/gateway/gateway.go +++ b/core/pkg/gateway/gateway.go @@ -32,6 +32,7 @@ import ( enrollhandlers "github.com/DeBrosOfficial/network/pkg/gateway/handlers/enroll" joinhandlers "github.com/DeBrosOfficial/network/pkg/gateway/handlers/join" webrtchandlers "github.com/DeBrosOfficial/network/pkg/gateway/handlers/webrtc" + operatorhandlers "github.com/DeBrosOfficial/network/pkg/gateway/handlers/operator" vaulthandlers "github.com/DeBrosOfficial/network/pkg/gateway/handlers/vault" wireguardhandlers "github.com/DeBrosOfficial/network/pkg/gateway/handlers/wireguard" sqlitehandlers "github.com/DeBrosOfficial/network/pkg/gateway/handlers/sqlite" @@ -168,7 +169,8 @@ type Gateway struct { proxyTransport *http.Transport // Vault proxy handlers - vaultHandlers *vaulthandlers.Handlers + vaultHandlers *vaulthandlers.Handlers + operatorHandler *operatorhandlers.Handler // Namespace health state (local service probes + hourly reconciliation) nsHealth *namespaceHealthState @@ -405,6 +407,7 @@ func New(logger *logging.ColoredLogger, cfg *Config) (*Gateway, error) { gw.joinHandler = joinhandlers.NewHandler(logger.Logger, deps.ORMClient, cfg.DataDir) gw.enrollHandler = enrollhandlers.NewHandler(logger.Logger, deps.ORMClient, cfg.DataDir) gw.vaultHandlers = vaulthandlers.NewHandlers(logger, deps.Client) + gw.operatorHandler = operatorhandlers.NewHandler(logger.Logger, deps.ORMClient) } // Initialize deployment system diff --git a/core/pkg/gateway/handlers/join/handler.go b/core/pkg/gateway/handlers/join/handler.go index 678c82f..dd79485 100644 --- a/core/pkg/gateway/handlers/join/handler.go +++ b/core/pkg/gateway/handlers/join/handler.go @@ -129,6 +129,9 @@ func (h *Handler) HandleJoin(w http.ResponseWriter, r *http.Request) { return } + // 1b. Look up the operator wallet from the consumed token (may be empty for legacy tokens) + operatorWallet := h.tokenOperatorWallet(ctx, req.Token) + // 2. Clean up stale WG entries for this public IP (from previous installs). // This prevents ghost peers: old rows with different node_id/wg_key that // the sync loop would keep trying to reach. @@ -150,8 +153,8 @@ func (h *Handler) HandleJoin(w http.ResponseWriter, r *http.Request) { // 4. Register WG peer in database nodeID := fmt.Sprintf("node-%s", wgIP) // temporary ID based on WG IP _, err = h.rqliteClient.Exec(ctx, - "INSERT OR REPLACE INTO wireguard_peers (node_id, wg_ip, public_key, public_ip, wg_port) VALUES (?, ?, ?, ?, ?)", - nodeID, wgIP, req.WGPublicKey, req.PublicIP, 51820) + "INSERT OR REPLACE INTO wireguard_peers (node_id, wg_ip, public_key, public_ip, wg_port, operator_wallet) VALUES (?, ?, ?, ?, ?, ?)", + nodeID, wgIP, req.WGPublicKey, req.PublicIP, 51820, operatorWallet) if err != nil { h.logger.Error("failed to register WG peer", zap.Error(err)) http.Error(w, "failed to register peer", http.StatusInternalServerError) @@ -307,6 +310,22 @@ func (h *Handler) consumeToken(ctx context.Context, token, usedByIP string) erro return nil } +// tokenOperatorWallet looks up the operator_wallet from a consumed invite token. +// Returns empty string if the token has no operator (legacy tokens). +func (h *Handler) tokenOperatorWallet(ctx context.Context, token string) string { + var rows []struct { + Wallet string `db:"operator_wallet"` + } + if err := h.rqliteClient.Query(ctx, &rows, + "SELECT COALESCE(operator_wallet, '') AS operator_wallet FROM invite_tokens WHERE token = ?", token); err != nil { + return "" + } + if len(rows) > 0 { + return rows[0].Wallet + } + return "" +} + // assignWGIP finds the next available 10.0.0.x IP by querying all peers and // finding the numerically highest IP. This avoids lexicographic comparison issues // where MAX("10.0.0.9") > MAX("10.0.0.10") in SQL string comparison. diff --git a/core/pkg/gateway/handlers/operator/handler.go b/core/pkg/gateway/handlers/operator/handler.go new file mode 100644 index 0000000..89f4741 --- /dev/null +++ b/core/pkg/gateway/handlers/operator/handler.go @@ -0,0 +1,50 @@ +// Package operator provides HTTP handlers for node operator management. +// +// Operators authenticate via wallet JWT (same auth flow as namespaces). +// Each operator's nodes are tracked by their wallet address in the +// dns_nodes and wireguard_peers tables. +package operator + +import ( + "encoding/json" + "net/http" + + "github.com/DeBrosOfficial/network/pkg/gateway/auth" + "github.com/DeBrosOfficial/network/pkg/gateway/ctxkeys" + "github.com/DeBrosOfficial/network/pkg/rqlite" + "go.uber.org/zap" +) + +// Handler provides HTTP handlers for operator node management. +type Handler struct { + logger *zap.Logger + rqliteClient rqlite.Client +} + +// NewHandler creates an operator handler. +func NewHandler(logger *zap.Logger, rqliteClient rqlite.Client) *Handler { + return &Handler{ + logger: logger, + rqliteClient: rqliteClient, + } +} + +// walletFromRequest extracts the operator's wallet address from the JWT +// stored in the request context by the auth middleware. +func walletFromRequest(r *http.Request) string { + claims, ok := r.Context().Value(ctxkeys.JWT).(*auth.JWTClaims) + if !ok || claims == nil { + return "" + } + return claims.Sub +} + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} diff --git a/core/pkg/gateway/handlers/operator/handler_test.go b/core/pkg/gateway/handlers/operator/handler_test.go new file mode 100644 index 0000000..120ac68 --- /dev/null +++ b/core/pkg/gateway/handlers/operator/handler_test.go @@ -0,0 +1,206 @@ +package operator + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/DeBrosOfficial/network/pkg/gateway/auth" + "github.com/DeBrosOfficial/network/pkg/gateway/ctxkeys" +) + +func TestWalletFromRequest_withClaims(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + claims := &auth.JWTClaims{Sub: "0xabc123"} + ctx := context.WithValue(r.Context(), ctxkeys.JWT, claims) + r = r.WithContext(ctx) + + wallet := walletFromRequest(r) + if wallet != "0xabc123" { + t.Errorf("wallet = %q, want %q", wallet, "0xabc123") + } +} + +func TestWalletFromRequest_noClaims(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + + wallet := walletFromRequest(r) + if wallet != "" { + t.Errorf("wallet = %q, want empty", wallet) + } +} + +func TestWalletFromRequest_nilClaims(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + ctx := context.WithValue(r.Context(), ctxkeys.JWT, (*auth.JWTClaims)(nil)) + r = r.WithContext(ctx) + + wallet := walletFromRequest(r) + if wallet != "" { + t.Errorf("wallet = %q, want empty", wallet) + } +} + +func TestDecodeJSON_valid(t *testing.T) { + body := strings.NewReader(`{"node_id":"test-node","environment":"devnet"}`) + r := httptest.NewRequest(http.MethodPost, "/", body) + + var req RegisterRequest + if err := decodeJSON(r, &req); err != nil { + t.Fatalf("decodeJSON: %v", err) + } + if req.NodeID != "test-node" { + t.Errorf("NodeID = %q, want %q", req.NodeID, "test-node") + } + if req.Environment != "devnet" { + t.Errorf("Environment = %q, want %q", req.Environment, "devnet") + } +} + +func TestDecodeJSON_invalid(t *testing.T) { + body := strings.NewReader(`not-json`) + r := httptest.NewRequest(http.MethodPost, "/", body) + + var req RegisterRequest + if err := decodeJSON(r, &req); err == nil { + t.Error("expected error for invalid JSON") + } +} + +func TestHandleInvite_noAuth(t *testing.T) { + h := NewHandler(nil, nil) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/v1/operator/invite", nil) + + h.HandleInvite(w, r) + + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want %d", w.Code, http.StatusUnauthorized) + } +} + +func TestHandleInvite_wrongMethod(t *testing.T) { + h := NewHandler(nil, nil) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/v1/operator/invite", nil) + + h.HandleInvite(w, r) + + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want %d", w.Code, http.StatusMethodNotAllowed) + } +} + +func TestHandleListNodes_noAuth(t *testing.T) { + h := NewHandler(nil, nil) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/v1/operator/nodes", nil) + + h.HandleListNodes(w, r) + + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want %d", w.Code, http.StatusUnauthorized) + } +} + +func TestHandleListNodes_wrongMethod(t *testing.T) { + h := NewHandler(nil, nil) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/v1/operator/nodes", nil) + + h.HandleListNodes(w, r) + + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want %d", w.Code, http.StatusMethodNotAllowed) + } +} + +func TestHandleRegister_noAuth(t *testing.T) { + h := NewHandler(nil, nil) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/v1/operator/node/register", strings.NewReader(`{"node_id":"test"}`)) + + h.HandleRegister(w, r) + + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want %d", w.Code, http.StatusUnauthorized) + } +} + +func TestHandleRegister_missingFields(t *testing.T) { + h := NewHandler(nil, nil) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/v1/operator/node/register", strings.NewReader(`{}`)) + claims := &auth.JWTClaims{Sub: "0xabc"} + r = r.WithContext(context.WithValue(r.Context(), ctxkeys.JWT, claims)) + + h.HandleRegister(w, r) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest) + } +} + +func TestHandleRegister_invalidEnvironment(t *testing.T) { + h := NewHandler(nil, nil) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/v1/operator/node/register", + strings.NewReader(`{"node_id":"test","environment":""}`)) + claims := &auth.JWTClaims{Sub: "0xabc"} + r = r.WithContext(context.WithValue(r.Context(), ctxkeys.JWT, claims)) + + h.HandleRegister(w, r) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest) + } +} + +func TestHandleRegister_invalidRole(t *testing.T) { + h := NewHandler(nil, nil) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/v1/operator/node/register", + strings.NewReader(`{"node_id":"test","role":"admin"}`)) + claims := &auth.JWTClaims{Sub: "0xabc"} + r = r.WithContext(context.WithValue(r.Context(), ctxkeys.JWT, claims)) + + h.HandleRegister(w, r) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest) + } +} + +func TestAllowedEnvironments(t *testing.T) { + valid := []string{"devnet", "testnet", "sandbox", "production", "mainnet"} + invalid := []string{"staging", "local", "