From 6898f47e2e0fb6f31141222dfb5b8814f89af39f Mon Sep 17 00:00:00 2001 From: anonpenguin23 Date: Tue, 24 Feb 2026 17:24:16 +0200 Subject: [PATCH] Replace sshpass password auth with RootWallet SSH keys Replaces plaintext password-based SSH authentication (sshpass) across the entire Go CLI with wallet-derived ed25519 keys via RootWallet. - Add `rw vault ssh agent-load` command to RootWallet CLI for SSH agent forwarding in push fanout - Create wallet.go bridge: PrepareNodeKeys resolves keys from `rw vault ssh get --priv`, writes temp PEMs (0600), zero-overwrites on cleanup - Remove Password field from Node struct, update config parser to new 3-field format (env|user@host|role) - Remove all sshpass branches from inspector/ssh.go and remotessh/ssh.go, require SSHKey on all SSH paths - Add WithAgentForward() option to RunSSHStreaming for hub fanout - Add PrepareNodeKeys + defer cleanup to all 7 entry points: inspect, monitor, push, upgrade, clean, recover, install - Update push fanout to use SSH agent forwarding instead of sshpass on hub - Delete install/ssh.go duplicate, replace with remotessh calls - Create nodes.conf from remote-nodes.conf (topology only, no secrets) - Update all config defaults and help text from remote-nodes.conf to nodes.conf - Use StrictHostKeyChecking=accept-new consistently everywhere --- go.mod | 8 +- pkg/cli/cluster/commands.go | 2 +- pkg/cli/cmd/monitorcmd/monitor.go | 2 +- pkg/cli/inspect_command.go | 11 +- pkg/cli/monitor/collector.go | 10 +- pkg/cli/production/clean/clean.go | 6 + pkg/cli/production/install/remote.go | 60 +++++++--- pkg/cli/production/install/ssh.go | 153 ------------------------- pkg/cli/production/push/push.go | 33 ++++-- pkg/cli/production/recover/recover.go | 6 + pkg/cli/production/upgrade/remote.go | 6 + pkg/cli/remotessh/config.go | 28 ++--- pkg/cli/remotessh/ssh.go | 85 +++++++------- pkg/cli/remotessh/wallet.go | 158 ++++++++++++++++++++++++++ pkg/inspector/checks/helpers_test.go | 1 - pkg/inspector/config.go | 25 ++-- pkg/inspector/config_test.go | 24 ++-- pkg/inspector/ssh.go | 39 +++---- scripts/nodes.conf | 42 +++++++ 19 files changed, 399 insertions(+), 300 deletions(-) delete mode 100644 pkg/cli/production/install/ssh.go create mode 100644 pkg/cli/remotessh/wallet.go create mode 100644 scripts/nodes.conf diff --git a/go.mod b/go.mod index bb89867..740f29a 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,10 @@ require ( github.com/miekg/dns v1.1.70 github.com/multiformats/go-multiaddr v0.16.0 github.com/olric-data/olric v0.7.0 + github.com/pion/interceptor v0.1.40 + github.com/pion/rtcp v1.2.15 + github.com/pion/turn/v4 v4.0.2 + github.com/pion/webrtc/v4 v4.1.2 github.com/rqlite/gorqlite v0.0.0-20250609141355-ac86a4a1c9a8 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 @@ -123,11 +127,9 @@ require ( github.com/pion/dtls/v2 v2.2.12 // indirect github.com/pion/dtls/v3 v3.0.6 // indirect github.com/pion/ice/v4 v4.0.10 // indirect - github.com/pion/interceptor v0.1.40 // indirect github.com/pion/logging v0.2.3 // indirect github.com/pion/mdns/v2 v2.0.7 // indirect github.com/pion/randutil v0.1.0 // indirect - github.com/pion/rtcp v1.2.15 // indirect github.com/pion/rtp v1.8.19 // indirect github.com/pion/sctp v1.8.39 // indirect github.com/pion/sdp/v3 v3.0.13 // indirect @@ -136,8 +138,6 @@ require ( github.com/pion/stun/v3 v3.0.0 // indirect github.com/pion/transport/v2 v2.2.10 // indirect github.com/pion/transport/v3 v3.0.7 // indirect - github.com/pion/turn/v4 v4.0.2 // indirect - github.com/pion/webrtc/v4 v4.1.2 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.0 // indirect diff --git a/pkg/cli/cluster/commands.go b/pkg/cli/cluster/commands.go index 68fc725..d4af9d0 100644 --- a/pkg/cli/cluster/commands.go +++ b/pkg/cli/cluster/commands.go @@ -61,7 +61,7 @@ func ShowHelp() { fmt.Printf("Subcommands:\n") fmt.Printf(" status - Show cluster node status (RQLite + Olric)\n") fmt.Printf(" Options:\n") - fmt.Printf(" --all - SSH into all nodes from remote-nodes.conf (TODO)\n") + fmt.Printf(" --all - SSH into all nodes from nodes.conf (TODO)\n") fmt.Printf(" health - Run cluster health checks\n") fmt.Printf(" rqlite - RQLite-specific commands\n") fmt.Printf(" status - Show detailed Raft state for local node\n") diff --git a/pkg/cli/cmd/monitorcmd/monitor.go b/pkg/cli/cmd/monitorcmd/monitor.go index f1a9495..9b77002 100644 --- a/pkg/cli/cmd/monitorcmd/monitor.go +++ b/pkg/cli/cmd/monitorcmd/monitor.go @@ -34,7 +34,7 @@ func init() { Cmd.PersistentFlags().StringVar(&flagEnv, "env", "", "Environment: devnet, testnet, mainnet (required)") Cmd.PersistentFlags().BoolVar(&flagJSON, "json", false, "Machine-readable JSON output") Cmd.PersistentFlags().StringVar(&flagNode, "node", "", "Filter to specific node host/IP") - Cmd.PersistentFlags().StringVar(&flagConfig, "config", "scripts/remote-nodes.conf", "Path to remote-nodes.conf") + Cmd.PersistentFlags().StringVar(&flagConfig, "config", "scripts/nodes.conf", "Path to nodes.conf") Cmd.MarkPersistentFlagRequired("env") Cmd.AddCommand(liveCmd) diff --git a/pkg/cli/inspect_command.go b/pkg/cli/inspect_command.go index 9fedf66..d8251e6 100644 --- a/pkg/cli/inspect_command.go +++ b/pkg/cli/inspect_command.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/DeBrosOfficial/network/pkg/cli/remotessh" "github.com/DeBrosOfficial/network/pkg/inspector" // Import checks package so init() registers the checkers _ "github.com/DeBrosOfficial/network/pkg/inspector/checks" @@ -49,7 +50,7 @@ func HandleInspectCommand(args []string) { fs := flag.NewFlagSet("inspect", flag.ExitOnError) - configPath := fs.String("config", "scripts/remote-nodes.conf", "Path to remote-nodes.conf") + configPath := fs.String("config", "scripts/nodes.conf", "Path to nodes.conf") env := fs.String("env", "", "Environment to inspect (devnet, testnet)") subsystem := fs.String("subsystem", "all", "Subsystem to inspect (rqlite,olric,ipfs,dns,wg,system,network,anyone,all)") format := fs.String("format", "table", "Output format (table, json)") @@ -98,6 +99,14 @@ func HandleInspectCommand(args []string) { os.Exit(1) } + // Prepare wallet-derived SSH keys + cleanup, err := remotessh.PrepareNodeKeys(nodes) + if err != nil { + fmt.Fprintf(os.Stderr, "Error preparing SSH keys: %v\n", err) + os.Exit(1) + } + defer cleanup() + // Parse subsystems var subsystems []string if *subsystem != "all" { diff --git a/pkg/cli/monitor/collector.go b/pkg/cli/monitor/collector.go index 2adc726..1e7ec53 100644 --- a/pkg/cli/monitor/collector.go +++ b/pkg/cli/monitor/collector.go @@ -8,6 +8,7 @@ import ( "time" "github.com/DeBrosOfficial/network/pkg/cli/production/report" + "github.com/DeBrosOfficial/network/pkg/cli/remotessh" "github.com/DeBrosOfficial/network/pkg/inspector" ) @@ -34,6 +35,13 @@ func CollectOnce(ctx context.Context, cfg CollectorConfig) (*ClusterSnapshot, er return nil, fmt.Errorf("no nodes found for env %q", cfg.Env) } + // Prepare wallet-derived SSH keys + cleanup, err := remotessh.PrepareNodeKeys(nodes) + if err != nil { + return nil, fmt.Errorf("prepare SSH keys: %w", err) + } + defer cleanup() + timeout := cfg.Timeout if timeout == 0 { timeout = 30 * time.Second @@ -87,7 +95,7 @@ func collectNodeReport(ctx context.Context, node inspector.Node, timeout time.Du return cs } - // Enrich with node metadata from remote-nodes.conf + // Enrich with node metadata from nodes.conf if rpt.Hostname == "" { rpt.Hostname = node.Host } diff --git a/pkg/cli/production/clean/clean.go b/pkg/cli/production/clean/clean.go index 65d1435..547a9a3 100644 --- a/pkg/cli/production/clean/clean.go +++ b/pkg/cli/production/clean/clean.go @@ -63,6 +63,12 @@ func execute(flags *Flags) error { return err } + cleanup, err := remotessh.PrepareNodeKeys(nodes) + if err != nil { + return err + } + defer cleanup() + if flags.Node != "" { nodes = remotessh.FilterByIP(nodes, flags.Node) if len(nodes) == 0 { diff --git a/pkg/cli/production/install/remote.go b/pkg/cli/production/install/remote.go index de70744..34a0d1f 100644 --- a/pkg/cli/production/install/remote.go +++ b/pkg/cli/production/install/remote.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" + "github.com/DeBrosOfficial/network/pkg/cli/remotessh" "github.com/DeBrosOfficial/network/pkg/inspector" ) @@ -14,39 +15,72 @@ import ( // It uploads the source archive, extracts it on the VPS, and runs // the actual install command remotely. type RemoteOrchestrator struct { - flags *Flags - node inspector.Node + flags *Flags + node inspector.Node + cleanup func() } // NewRemoteOrchestrator creates a new remote orchestrator. -// It resolves SSH credentials and checks prerequisites. +// Resolves SSH credentials via wallet-derived keys and checks prerequisites. func NewRemoteOrchestrator(flags *Flags) (*RemoteOrchestrator, error) { if flags.VpsIP == "" { return nil, fmt.Errorf("--vps-ip is required\nExample: orama install --vps-ip 1.2.3.4 --nameserver --domain orama-testnet.network") } - // Resolve SSH credentials - node, err := resolveSSHCredentials(flags.VpsIP) - if err != nil { - return nil, fmt.Errorf("failed to resolve SSH credentials: %w", err) + // Try to find this IP in nodes.conf for the correct user + user := resolveUser(flags.VpsIP) + + node := inspector.Node{ + User: user, + Host: flags.VpsIP, + Role: "node", } + // Prepare wallet-derived SSH key + nodes := []inspector.Node{node} + cleanup, err := remotessh.PrepareNodeKeys(nodes) + if err != nil { + return nil, fmt.Errorf("failed to prepare SSH key: %w\nEnsure you've run: rw vault ssh add %s/%s", err, flags.VpsIP, user) + } + // PrepareNodeKeys modifies nodes in place + node = nodes[0] + return &RemoteOrchestrator{ - flags: flags, - node: node, + flags: flags, + node: node, + cleanup: cleanup, }, nil } +// resolveUser looks up the SSH user for a VPS IP from nodes.conf. +// Falls back to "root" if not found. +func resolveUser(vpsIP string) string { + confPath := remotessh.FindNodesConf() + if confPath != "" { + nodes, err := inspector.LoadNodes(confPath) + if err == nil { + for _, n := range nodes { + if n.Host == vpsIP { + return n.User + } + } + } + } + return "root" +} + // Execute runs the remote install process. // If a binary archive exists locally, uploads and extracts it on the VPS // so Phase2b auto-detects pre-built mode. Otherwise, source must already // be present on the VPS. func (r *RemoteOrchestrator) Execute() error { + defer r.cleanup() + fmt.Printf("Installing on %s via SSH (%s@%s)...\n\n", r.flags.VpsIP, r.node.User, r.node.Host) // Try to upload a binary archive if one exists locally if err := r.uploadBinaryArchive(); err != nil { - fmt.Printf(" ⚠️ Binary archive upload skipped: %v\n", err) + fmt.Printf(" Binary archive upload skipped: %v\n", err) fmt.Printf(" Proceeding with source mode (source must already be on VPS)\n\n") } @@ -71,7 +105,7 @@ func (r *RemoteOrchestrator) uploadBinaryArchive() error { // Upload to /tmp/ on VPS remoteTmp := "/tmp/" + filepath.Base(archivePath) - if err := uploadFile(r.node, archivePath, remoteTmp); err != nil { + if err := remotessh.UploadFile(r.node, archivePath, remoteTmp); err != nil { return fmt.Errorf("failed to upload archive: %w", err) } @@ -79,7 +113,7 @@ func (r *RemoteOrchestrator) uploadBinaryArchive() error { fmt.Printf("Extracting archive on VPS...\n") extractCmd := fmt.Sprintf("%smkdir -p /opt/orama && tar xzf %s -C /opt/orama && rm -f %s && cp /opt/orama/bin/orama /usr/local/bin/orama && chmod +x /usr/local/bin/orama && echo ' ✓ Archive extracted, CLI installed'", r.sudoPrefix(), remoteTmp, remoteTmp) - if err := runSSHStreaming(r.node, extractCmd); err != nil { + if err := remotessh.RunSSHStreaming(r.node, extractCmd); err != nil { return fmt.Errorf("failed to extract archive on VPS: %w", err) } @@ -118,7 +152,7 @@ func (r *RemoteOrchestrator) findLocalArchive() string { // runRemoteInstall executes `orama install` on the VPS. func (r *RemoteOrchestrator) runRemoteInstall() error { cmd := r.buildRemoteCommand() - return runSSHStreaming(r.node, cmd) + return remotessh.RunSSHStreaming(r.node, cmd) } // buildRemoteCommand constructs the `sudo orama install` command string diff --git a/pkg/cli/production/install/ssh.go b/pkg/cli/production/install/ssh.go deleted file mode 100644 index 5ba1034..0000000 --- a/pkg/cli/production/install/ssh.go +++ /dev/null @@ -1,153 +0,0 @@ -package install - -import ( - "bufio" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - - "github.com/DeBrosOfficial/network/pkg/inspector" - "golang.org/x/term" -) - -const sourceArchivePath = "/tmp/network-source.tar.gz" - -// resolveSSHCredentials finds SSH credentials for the given VPS IP. -// First checks remote-nodes.conf, then prompts interactively. -func resolveSSHCredentials(vpsIP string) (inspector.Node, error) { - confPath := findRemoteNodesConf() - if confPath != "" { - nodes, err := inspector.LoadNodes(confPath) - if err == nil { - for _, n := range nodes { - if n.Host == vpsIP { - // Expand ~ in SSH key path - if n.SSHKey != "" && strings.HasPrefix(n.SSHKey, "~") { - home, _ := os.UserHomeDir() - n.SSHKey = filepath.Join(home, n.SSHKey[1:]) - } - return n, nil - } - } - } - } - - // Not found in config — prompt interactively - return promptSSHCredentials(vpsIP), nil -} - -// findRemoteNodesConf searches for the remote-nodes.conf file. -func findRemoteNodesConf() string { - candidates := []string{ - "scripts/remote-nodes.conf", - "../scripts/remote-nodes.conf", - "network/scripts/remote-nodes.conf", - } - for _, c := range candidates { - if _, err := os.Stat(c); err == nil { - return c - } - } - return "" -} - -// promptSSHCredentials asks the user for SSH credentials interactively. -func promptSSHCredentials(vpsIP string) inspector.Node { - reader := bufio.NewReader(os.Stdin) - - fmt.Printf("\nSSH credentials for %s\n", vpsIP) - fmt.Print(" SSH user (default: ubuntu): ") - user, _ := reader.ReadString('\n') - user = strings.TrimSpace(user) - if user == "" { - user = "ubuntu" - } - - fmt.Print(" SSH password: ") - passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd())) - fmt.Println() // newline after hidden input - if err != nil { - // Fall back to plain read if terminal is not available - password, _ := reader.ReadString('\n') - return inspector.Node{ - User: user, - Host: vpsIP, - Password: strings.TrimSpace(password), - } - } - password := string(passwordBytes) - - return inspector.Node{ - User: user, - Host: vpsIP, - Password: password, - } -} - -// uploadFile copies a local file to a remote host via SCP. -func uploadFile(node inspector.Node, localPath, remotePath string) error { - dest := fmt.Sprintf("%s@%s:%s", node.User, node.Host, remotePath) - - var cmd *exec.Cmd - if node.SSHKey != "" { - cmd = exec.Command("scp", - "-o", "StrictHostKeyChecking=no", - "-o", "ConnectTimeout=10", - "-i", node.SSHKey, - localPath, dest, - ) - } else { - if _, err := exec.LookPath("sshpass"); err != nil { - return fmt.Errorf("sshpass not found — install it: brew install hudochenkov/sshpass/sshpass") - } - cmd = exec.Command("sshpass", "-p", node.Password, - "scp", - "-o", "StrictHostKeyChecking=no", - "-o", "ConnectTimeout=10", - localPath, dest, - ) - } - - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - if err := cmd.Run(); err != nil { - return fmt.Errorf("SCP failed: %w", err) - } - return nil -} - -// runSSHStreaming executes a command on a remote host via SSH, -// streaming stdout/stderr to the local terminal in real-time. -func runSSHStreaming(node inspector.Node, command string) error { - var cmd *exec.Cmd - if node.SSHKey != "" { - cmd = exec.Command("ssh", - "-o", "StrictHostKeyChecking=no", - "-o", "ConnectTimeout=10", - "-i", node.SSHKey, - fmt.Sprintf("%s@%s", node.User, node.Host), - command, - ) - } else { - cmd = exec.Command("sshpass", "-p", node.Password, - "ssh", - "-o", "StrictHostKeyChecking=no", - "-o", "ConnectTimeout=10", - fmt.Sprintf("%s@%s", node.User, node.Host), - command, - ) - } - - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Stdin = os.Stdin // Allow password prompts from remote sudo - - if err := cmd.Run(); err != nil { - return fmt.Errorf("SSH command failed: %w", err) - } - return nil -} - diff --git a/pkg/cli/production/push/push.go b/pkg/cli/production/push/push.go index 9cfebd9..ae54862 100644 --- a/pkg/cli/production/push/push.go +++ b/pkg/cli/production/push/push.go @@ -72,6 +72,13 @@ func execute(flags *Flags) error { return err } + // Prepare wallet-derived SSH keys + cleanup, err := remotessh.PrepareNodeKeys(nodes) + if err != nil { + return err + } + defer cleanup() + // Filter to single node if specified if flags.Node != "" { nodes = remotessh.FilterByIP(nodes, flags.Node) @@ -86,6 +93,11 @@ func execute(flags *Flags) error { return pushDirect(archivePath, nodes) } + // Load keys into ssh-agent for fanout forwarding + if err := remotessh.LoadAgentKeys(nodes); err != nil { + return fmt.Errorf("load agent keys for fanout: %w", err) + } + return pushFanout(archivePath, nodes) } @@ -111,7 +123,7 @@ func pushDirect(archivePath string, nodes []inspector.Node) error { return nil } -// pushFanout uploads to a hub node, then fans out to all others via server-to-server SCP. +// pushFanout uploads to a hub node, then fans out to all others via agent forwarding. func pushFanout(archivePath string, nodes []inspector.Node) error { hub := remotessh.PickHubNode(nodes) remotePath := "/tmp/" + filepath.Base(archivePath) @@ -127,7 +139,7 @@ func pushFanout(archivePath string, nodes []inspector.Node) error { } fmt.Printf(" ✓ hub %s done\n\n", hub.Host) - // Step 2: Fan out from hub to remaining nodes in parallel + // Step 2: Fan out from hub to remaining nodes in parallel (via agent forwarding) remaining := make([]inspector.Node, 0, len(nodes)-1) for _, n := range nodes { if n.Host != hub.Host { @@ -150,11 +162,11 @@ func pushFanout(archivePath string, nodes []inspector.Node) error { go func(idx int, target inspector.Node) { defer wg.Done() - // SCP from hub to target, then extract - scpCmd := fmt.Sprintf("sshpass -p '%s' scp -o StrictHostKeyChecking=no -o ConnectTimeout=10 -o PreferredAuthentications=password -o PubkeyAuthentication=no %s %s@%s:%s", - target.Password, remotePath, target.User, target.Host, remotePath) + // SCP from hub to target (agent forwarding serves the key) + scpCmd := fmt.Sprintf("scp -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 %s %s@%s:%s", + remotePath, target.User, target.Host, remotePath) - if err := remotessh.RunSSHStreaming(hub, scpCmd); err != nil { + if err := remotessh.RunSSHStreaming(hub, scpCmd, remotessh.WithAgentForward()); err != nil { errors[idx] = fmt.Errorf("fanout to %s failed: %w", target.Host, err) return } @@ -196,16 +208,17 @@ func extractOnNode(node inspector.Node, remotePath string) error { } // extractOnNodeVia extracts the archive on a target node by SSHing through the hub. +// Uses agent forwarding so the hub can authenticate to the target. func extractOnNodeVia(hub, target inspector.Node, remotePath string) error { sudo := remotessh.SudoPrefix(target) extractCmd := fmt.Sprintf("%smkdir -p /opt/orama && %star xzf %s -C /opt/orama && %srm -f %s", sudo, sudo, remotePath, sudo, remotePath) - // SSH from hub to target to extract - sshCmd := fmt.Sprintf("sshpass -p '%s' ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 -o PreferredAuthentications=password -o PubkeyAuthentication=no %s@%s '%s'", - target.Password, target.User, target.Host, extractCmd) + // SSH from hub to target to extract (agent forwarding serves the key) + sshCmd := fmt.Sprintf("ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 %s@%s '%s'", + target.User, target.Host, extractCmd) - return remotessh.RunSSHStreaming(hub, sshCmd) + return remotessh.RunSSHStreaming(hub, sshCmd, remotessh.WithAgentForward()) } // findNewestArchive finds the newest binary archive in /tmp/. diff --git a/pkg/cli/production/recover/recover.go b/pkg/cli/production/recover/recover.go index f697325..62a84f4 100644 --- a/pkg/cli/production/recover/recover.go +++ b/pkg/cli/production/recover/recover.go @@ -70,6 +70,12 @@ func execute(flags *Flags) error { return err } + cleanup, err := remotessh.PrepareNodeKeys(nodes) + if err != nil { + return err + } + defer cleanup() + // Find leader node leaderNodes := remotessh.FilterByIP(nodes, flags.Leader) if len(leaderNodes) == 0 { diff --git a/pkg/cli/production/upgrade/remote.go b/pkg/cli/production/upgrade/remote.go index e91096c..9e8ec9a 100644 --- a/pkg/cli/production/upgrade/remote.go +++ b/pkg/cli/production/upgrade/remote.go @@ -25,6 +25,12 @@ func (r *RemoteUpgrader) Execute() error { return err } + cleanup, err := remotessh.PrepareNodeKeys(nodes) + if err != nil { + return err + } + defer cleanup() + // Filter to single node if specified if r.flags.NodeFilter != "" { nodes = remotessh.FilterByIP(nodes, r.flags.NodeFilter) diff --git a/pkg/cli/remotessh/config.go b/pkg/cli/remotessh/config.go index 19ab610..4556be9 100644 --- a/pkg/cli/remotessh/config.go +++ b/pkg/cli/remotessh/config.go @@ -4,24 +4,23 @@ import ( "fmt" "os" "path/filepath" - "strings" "github.com/DeBrosOfficial/network/pkg/inspector" ) -// FindRemoteNodesConf searches for the remote-nodes.conf file +// FindNodesConf searches for the nodes.conf file // in common locations relative to the current directory or project root. -func FindRemoteNodesConf() string { +func FindNodesConf() string { candidates := []string{ - "scripts/remote-nodes.conf", - "../scripts/remote-nodes.conf", - "network/scripts/remote-nodes.conf", + "scripts/nodes.conf", + "../scripts/nodes.conf", + "network/scripts/nodes.conf", } // Also check from home dir home, _ := os.UserHomeDir() if home != "" { - candidates = append(candidates, filepath.Join(home, ".orama", "remote-nodes.conf")) + candidates = append(candidates, filepath.Join(home, ".orama", "nodes.conf")) } for _, c := range candidates { @@ -32,11 +31,12 @@ func FindRemoteNodesConf() string { return "" } -// LoadEnvNodes loads all nodes for a given environment from remote-nodes.conf. +// LoadEnvNodes loads all nodes for a given environment from nodes.conf. +// SSHKey fields are NOT set — caller must call PrepareNodeKeys() after this. func LoadEnvNodes(env string) ([]inspector.Node, error) { - confPath := FindRemoteNodesConf() + confPath := FindNodesConf() if confPath == "" { - return nil, fmt.Errorf("remote-nodes.conf not found (checked scripts/, ../scripts/, network/scripts/)") + return nil, fmt.Errorf("nodes.conf not found (checked scripts/, ../scripts/, network/scripts/)") } nodes, err := inspector.LoadNodes(confPath) @@ -49,14 +49,6 @@ func LoadEnvNodes(env string) ([]inspector.Node, error) { return nil, fmt.Errorf("no nodes found for environment %q in %s", env, confPath) } - // Expand ~ in SSH key paths - home, _ := os.UserHomeDir() - for i := range filtered { - if filtered[i].SSHKey != "" && strings.HasPrefix(filtered[i].SSHKey, "~") { - filtered[i].SSHKey = filepath.Join(home, filtered[i].SSHKey[1:]) - } - } - return filtered, nil } diff --git a/pkg/cli/remotessh/ssh.go b/pkg/cli/remotessh/ssh.go index e77d7e0..803c384 100644 --- a/pkg/cli/remotessh/ssh.go +++ b/pkg/cli/remotessh/ssh.go @@ -8,31 +8,34 @@ import ( "github.com/DeBrosOfficial/network/pkg/inspector" ) +// SSHOption configures SSH command behavior. +type SSHOption func(*sshOptions) + +type sshOptions struct { + agentForward bool +} + +// WithAgentForward enables SSH agent forwarding (-A flag). +// Used by push fanout so the hub can reach targets via the forwarded agent. +func WithAgentForward() SSHOption { + return func(o *sshOptions) { o.agentForward = true } +} + // UploadFile copies a local file to a remote host via SCP. +// Requires node.SSHKey to be set (via PrepareNodeKeys). func UploadFile(node inspector.Node, localPath, remotePath string) error { + if node.SSHKey == "" { + return fmt.Errorf("no SSH key for %s (call PrepareNodeKeys first)", node.Name()) + } + dest := fmt.Sprintf("%s@%s:%s", node.User, node.Host, remotePath) - var cmd *exec.Cmd - if node.SSHKey != "" { - cmd = exec.Command("scp", - "-o", "StrictHostKeyChecking=no", - "-o", "ConnectTimeout=10", - "-i", node.SSHKey, - localPath, dest, - ) - } else { - if _, err := exec.LookPath("sshpass"); err != nil { - return fmt.Errorf("sshpass not found — install it: brew install hudochenkov/sshpass/sshpass") - } - cmd = exec.Command("sshpass", "-p", node.Password, - "scp", - "-o", "StrictHostKeyChecking=no", - "-o", "ConnectTimeout=10", - "-o", "PreferredAuthentications=password", - "-o", "PubkeyAuthentication=no", - localPath, dest, - ) - } + cmd := exec.Command("scp", + "-o", "StrictHostKeyChecking=accept-new", + "-o", "ConnectTimeout=10", + "-i", node.SSHKey, + localPath, dest, + ) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -45,28 +48,28 @@ func UploadFile(node inspector.Node, localPath, remotePath string) error { // RunSSHStreaming executes a command on a remote host via SSH, // streaming stdout/stderr to the local terminal in real-time. -func RunSSHStreaming(node inspector.Node, command string) error { - var cmd *exec.Cmd - if node.SSHKey != "" { - cmd = exec.Command("ssh", - "-o", "StrictHostKeyChecking=no", - "-o", "ConnectTimeout=10", - "-i", node.SSHKey, - fmt.Sprintf("%s@%s", node.User, node.Host), - command, - ) - } else { - cmd = exec.Command("sshpass", "-p", node.Password, - "ssh", - "-o", "StrictHostKeyChecking=no", - "-o", "ConnectTimeout=10", - "-o", "PreferredAuthentications=password", - "-o", "PubkeyAuthentication=no", - fmt.Sprintf("%s@%s", node.User, node.Host), - command, - ) +// Requires node.SSHKey to be set (via PrepareNodeKeys). +func RunSSHStreaming(node inspector.Node, command string, opts ...SSHOption) error { + if node.SSHKey == "" { + return fmt.Errorf("no SSH key for %s (call PrepareNodeKeys first)", node.Name()) } + var cfg sshOptions + for _, o := range opts { + o(&cfg) + } + + args := []string{ + "-o", "StrictHostKeyChecking=accept-new", + "-o", "ConnectTimeout=10", + "-i", node.SSHKey, + } + if cfg.agentForward { + args = append(args, "-A") + } + args = append(args, fmt.Sprintf("%s@%s", node.User, node.Host), command) + + cmd := exec.Command("ssh", args...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Stdin = os.Stdin diff --git a/pkg/cli/remotessh/wallet.go b/pkg/cli/remotessh/wallet.go new file mode 100644 index 0000000..bd8b0b5 --- /dev/null +++ b/pkg/cli/remotessh/wallet.go @@ -0,0 +1,158 @@ +package remotessh + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/DeBrosOfficial/network/pkg/inspector" +) + +// PrepareNodeKeys resolves wallet-derived SSH keys for all nodes. +// Calls `rw vault ssh get / --priv` for each unique host/user, +// writes PEMs to temp files, and sets node.SSHKey for each node. +// +// The nodes slice is modified in place — each node.SSHKey is set to +// the path of the temporary key file. +// +// Returns a cleanup function that zero-overwrites and removes all temp files. +// Caller must defer cleanup(). +func PrepareNodeKeys(nodes []inspector.Node) (cleanup func(), err error) { + rw, err := rwBinary() + if err != nil { + return nil, err + } + + // Create temp dir for all keys + tmpDir, err := os.MkdirTemp("", "orama-ssh-") + if err != nil { + return nil, fmt.Errorf("create temp dir: %w", err) + } + + // Track resolved keys by host/user to avoid duplicate rw calls + keyPaths := make(map[string]string) // "host/user" → temp file path + var allKeyPaths []string + + for i := range nodes { + 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) + if err != nil { + // Cleanup any keys already written before returning error + cleanupKeys(tmpDir, allKeyPaths) + return nil, fmt.Errorf("resolve key for %s: %w", nodes[i].Name(), err) + } + + // Write PEM to temp file with restrictive perms + keyFile := filepath.Join(tmpDir, fmt.Sprintf("id_%d", i)) + if err := os.WriteFile(keyFile, []byte(pem), 0600); err != nil { + cleanupKeys(tmpDir, allKeyPaths) + return nil, fmt.Errorf("write key for %s: %w", nodes[i].Name(), err) + } + + keyPaths[key] = keyFile + allKeyPaths = append(allKeyPaths, keyFile) + nodes[i].SSHKey = keyFile + } + + cleanup = func() { + cleanupKeys(tmpDir, allKeyPaths) + } + return cleanup, nil +} + +// LoadAgentKeys loads SSH keys for the given nodes into the system ssh-agent. +// Used by push fanout to enable agent forwarding. +// Calls `rw vault ssh agent-load ...` +func LoadAgentKeys(nodes []inspector.Node) error { + rw, err := rwBinary() + if err != nil { + return err + } + + // Deduplicate host/user pairs + seen := make(map[string]bool) + var targets []string + for _, n := range nodes { + key := n.Host + "/" + n.User + if seen[key] { + continue + } + seen[key] = true + targets = append(targets, key) + } + + if len(targets) == 0 { + return nil + } + + args := append([]string{"vault", "ssh", "agent-load"}, targets...) + cmd := exec.Command(rw, args...) + cmd.Stderr = os.Stderr + cmd.Stdout = os.Stderr // info messages go to stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("rw vault ssh agent-load failed: %w", err) + } + return nil +} + +// resolveWalletKey calls `rw vault ssh get / --priv` +// and returns the PEM string. Requires an active rw session. +func resolveWalletKey(rw string, host, user string) (string, error) { + target := host + "/" + user + cmd := exec.Command(rw, "vault", "ssh", "get", target, "--priv") + 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", target, target) + } + 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) + } + pem := string(out) + if !strings.Contains(pem, "BEGIN OPENSSH PRIVATE KEY") { + return "", fmt.Errorf("rw returned invalid key for %s", target) + } + return pem, nil +} + +// rwBinary returns the path to the `rw` binary. +// Checks RW_PATH env var first, then PATH. +func rwBinary() (string, error) { + if p := os.Getenv("RW_PATH"); p != "" { + if _, err := os.Stat(p); err == nil { + return p, nil + } + return "", fmt.Errorf("RW_PATH=%q not found", p) + } + + p, err := exec.LookPath("rw") + if err != nil { + return "", fmt.Errorf("rw not found in PATH — install rootwallet CLI: https://github.com/DeBrosOfficial/rootwallet") + } + return p, nil +} + +// cleanupKeys zero-overwrites and removes all key files, then removes the temp dir. +func cleanupKeys(tmpDir string, keyPaths []string) { + zeros := make([]byte, 512) + for _, p := range keyPaths { + _ = os.WriteFile(p, zeros, 0600) // zero-overwrite + _ = os.Remove(p) + } + _ = os.Remove(tmpDir) +} diff --git a/pkg/inspector/checks/helpers_test.go b/pkg/inspector/checks/helpers_test.go index 7732028..8bbc923 100644 --- a/pkg/inspector/checks/helpers_test.go +++ b/pkg/inspector/checks/helpers_test.go @@ -13,7 +13,6 @@ func makeNode(host, role string) inspector.Node { Environment: "devnet", User: "ubuntu", Host: host, - Password: "test", Role: role, } } diff --git a/pkg/inspector/config.go b/pkg/inspector/config.go index 524f19e..cad33c7 100644 --- a/pkg/inspector/config.go +++ b/pkg/inspector/config.go @@ -7,14 +7,13 @@ import ( "strings" ) -// Node represents a remote node parsed from remote-nodes.conf. +// Node represents a remote node parsed from nodes.conf. type Node struct { Environment string // devnet, testnet User string // SSH user Host string // IP or hostname - Password string // SSH password Role string // node, nameserver-ns1, nameserver-ns2, nameserver-ns3 - SSHKey string // optional path to SSH key + SSHKey string // populated at runtime by PrepareNodeKeys() } // Name returns a short display name for the node (user@host). @@ -27,8 +26,8 @@ func (n Node) IsNameserver() bool { return strings.HasPrefix(n.Role, "nameserver") } -// LoadNodes parses a remote-nodes.conf file into a slice of Nodes. -// Format: environment|user@host|password|role|ssh_key (ssh_key optional) +// LoadNodes parses a nodes.conf file into a slice of Nodes. +// Format: environment|user@host|role func LoadNodes(path string) ([]Node, error) { f, err := os.Open(path) if err != nil { @@ -46,20 +45,14 @@ func LoadNodes(path string) ([]Node, error) { continue } - parts := strings.SplitN(line, "|", 5) - if len(parts) < 4 { - return nil, fmt.Errorf("line %d: expected at least 4 pipe-delimited fields, got %d", lineNum, len(parts)) + parts := strings.SplitN(line, "|", 4) + if len(parts) < 3 { + return nil, fmt.Errorf("line %d: expected 3 pipe-delimited fields (env|user@host|role), got %d", lineNum, len(parts)) } env := parts[0] userHost := parts[1] - password := parts[2] - role := parts[3] - - var sshKey string - if len(parts) == 5 { - sshKey = parts[4] - } + role := parts[2] // Parse user@host at := strings.LastIndex(userHost, "@") @@ -73,9 +66,7 @@ func LoadNodes(path string) ([]Node, error) { Environment: env, User: user, Host: host, - Password: password, Role: role, - SSHKey: sshKey, }) } if err := scanner.Err(); err != nil { diff --git a/pkg/inspector/config_test.go b/pkg/inspector/config_test.go index 9d7d368..384b5a1 100644 --- a/pkg/inspector/config_test.go +++ b/pkg/inspector/config_test.go @@ -8,9 +8,9 @@ import ( func TestLoadNodes(t *testing.T) { content := `# Comment line -devnet|ubuntu@1.2.3.4|pass123|node -devnet|ubuntu@1.2.3.5|pass456|node -devnet|ubuntu@5.6.7.8|pass789|nameserver-ns1|/path/to/key +devnet|ubuntu@1.2.3.4|node +devnet|ubuntu@1.2.3.5|node +devnet|ubuntu@5.6.7.8|nameserver-ns1 ` path := writeTempFile(t, content) @@ -33,34 +33,28 @@ devnet|ubuntu@5.6.7.8|pass789|nameserver-ns1|/path/to/key if n.Host != "1.2.3.4" { t.Errorf("node[0].Host = %q, want 1.2.3.4", n.Host) } - if n.Password != "pass123" { - t.Errorf("node[0].Password = %q, want pass123", n.Password) - } if n.Role != "node" { t.Errorf("node[0].Role = %q, want node", n.Role) } if n.SSHKey != "" { - t.Errorf("node[0].SSHKey = %q, want empty", n.SSHKey) + t.Errorf("node[0].SSHKey = %q, want empty (set at runtime)", n.SSHKey) } - // Third node with SSH key + // Third node with nameserver role n3 := nodes[2] if n3.Role != "nameserver-ns1" { t.Errorf("node[2].Role = %q, want nameserver-ns1", n3.Role) } - if n3.SSHKey != "/path/to/key" { - t.Errorf("node[2].SSHKey = %q, want /path/to/key", n3.SSHKey) - } } func TestLoadNodes_EmptyLines(t *testing.T) { content := ` # Full line comment -devnet|ubuntu@1.2.3.4|pass|node +devnet|ubuntu@1.2.3.4|node # Another comment -devnet|ubuntu@1.2.3.5|pass|node +devnet|ubuntu@1.2.3.5|node ` path := writeTempFile(t, content) @@ -78,8 +72,8 @@ func TestLoadNodes_InvalidFormat(t *testing.T) { name string content string }{ - {"too few fields", "devnet|ubuntu@1.2.3.4|pass\n"}, - {"no @ in userhost", "devnet|localhost|pass|node\n"}, + {"too few fields", "devnet|ubuntu@1.2.3.4\n"}, + {"no @ in userhost", "devnet|localhost|node\n"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/inspector/ssh.go b/pkg/inspector/ssh.go index e16ad74..f73d2f0 100644 --- a/pkg/inspector/ssh.go +++ b/pkg/inspector/ssh.go @@ -31,7 +31,7 @@ func (r SSHResult) OK() bool { } // RunSSH executes a command on a remote node via SSH with retry on connection failure. -// Uses sshpass for password auth, falls back to -i for key-based auth. +// Requires node.SSHKey to be set (via PrepareNodeKeys). // The -n flag is used to prevent SSH from reading stdin. func RunSSH(ctx context.Context, node Node, command string) SSHResult { var result SSHResult @@ -76,30 +76,23 @@ func RunSSH(ctx context.Context, node Node, command string) SSHResult { func runSSHOnce(ctx context.Context, node Node, command string) SSHResult { start := time.Now() - var args []string - if node.SSHKey != "" { - // Key-based auth - args = []string{ - "ssh", "-n", - "-o", "StrictHostKeyChecking=no", - "-o", "ConnectTimeout=10", - "-o", "BatchMode=yes", - "-i", node.SSHKey, - fmt.Sprintf("%s@%s", node.User, node.Host), - command, - } - } else { - // Password auth via sshpass - args = []string{ - "sshpass", "-p", node.Password, - "ssh", "-n", - "-o", "StrictHostKeyChecking=no", - "-o", "ConnectTimeout=10", - fmt.Sprintf("%s@%s", node.User, node.Host), - command, + if node.SSHKey == "" { + return SSHResult{ + Duration: 0, + Err: fmt.Errorf("no SSH key for %s (call PrepareNodeKeys first)", node.Name()), } } + args := []string{ + "ssh", "-n", + "-o", "StrictHostKeyChecking=accept-new", + "-o", "ConnectTimeout=10", + "-o", "BatchMode=yes", + "-i", node.SSHKey, + fmt.Sprintf("%s@%s", node.User, node.Host), + command, + } + cmd := exec.CommandContext(ctx, args[0], args[1:]...) var stdout, stderr bytes.Buffer @@ -130,8 +123,6 @@ func runSSHOnce(ctx context.Context, node Node, command string) SSHResult { // isSSHConnectionError returns true if the failure looks like an SSH connection // problem (timeout, refused, network unreachable) rather than a remote command error. func isSSHConnectionError(r SSHResult) bool { - // sshpass exit code 5 = invalid/incorrect password (not retriable) - // sshpass exit code 6 = host key verification failed (not retriable) // SSH exit code 255 = SSH connection error (retriable) if r.ExitCode == 255 { return true diff --git a/scripts/nodes.conf b/scripts/nodes.conf new file mode 100644 index 0000000..72e4c36 --- /dev/null +++ b/scripts/nodes.conf @@ -0,0 +1,42 @@ +# Orama Network node topology +# Format: environment|user@host|role +# Auth: wallet-derived SSH keys (rw vault ssh) +# +# environment: devnet, testnet +# role: node, nameserver-ns1, nameserver-ns2, nameserver-ns3 + +# --- Devnet nameservers --- +devnet|ubuntu@57.129.7.232|nameserver-ns1 +devnet|ubuntu@57.131.41.160|nameserver-ns2 +devnet|ubuntu@51.38.128.56|nameserver-ns3 + +# --- Devnet nodes --- +devnet|ubuntu@144.217.162.62|node +devnet|ubuntu@51.83.128.181|node +devnet|ubuntu@144.217.160.15|node +devnet|root@46.250.241.133|node +devnet|root@109.123.229.231|node +devnet|ubuntu@144.217.162.143|node +devnet|ubuntu@144.217.163.114|node +devnet|root@109.123.239.61|node +devnet|root@217.76.56.2|node +devnet|ubuntu@198.244.150.237|node +devnet|root@154.38.187.158|node + +# --- Testnet nameservers --- +testnet|ubuntu@51.195.109.238|nameserver-ns1 +testnet|ubuntu@57.131.41.159|nameserver-ns1 +testnet|ubuntu@51.38.130.69|nameserver-ns1 + +# --- Testnet nodes --- +testnet|root@178.212.35.184|node +testnet|root@62.72.44.87|node +testnet|ubuntu@51.178.84.172|node +testnet|ubuntu@135.125.175.236|node +testnet|ubuntu@57.128.223.149|node +testnet|root@38.242.221.178|node +testnet|root@194.61.28.7|node +testnet|root@83.171.248.66|node +testnet|ubuntu@141.227.165.168|node +testnet|ubuntu@141.227.165.154|node +testnet|ubuntu@141.227.156.51|node