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
This commit is contained in:
anonpenguin23 2026-02-24 17:24:16 +02:00
parent f0d2621199
commit 6898f47e2e
19 changed files with 399 additions and 300 deletions

8
go.mod
View File

@ -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

View File

@ -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 <subcommand> - RQLite-specific commands\n")
fmt.Printf(" status - Show detailed Raft state for local node\n")

View File

@ -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)

View File

@ -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" {

View File

@ -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
}

View File

@ -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 {

View File

@ -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

View File

@ -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
}

View File

@ -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/.

View File

@ -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 {

View File

@ -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)

View File

@ -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
}

View File

@ -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

158
pkg/cli/remotessh/wallet.go Normal file
View File

@ -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 <host>/<user> --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 <host1/user1> <host2/user2> ...`
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 <host>/<user> --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)
}

View File

@ -13,7 +13,6 @@ func makeNode(host, role string) inspector.Node {
Environment: "devnet",
User: "ubuntu",
Host: host,
Password: "test",
Role: role,
}
}

View File

@ -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 {

View File

@ -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) {

View File

@ -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

42
scripts/nodes.conf Normal file
View File

@ -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