mirror of
https://github.com/DeBrosOfficial/orama.git
synced 2026-03-17 09:36:56 +00:00
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:
parent
f0d2621199
commit
6898f47e2e
8
go.mod
8
go.mod
@ -20,6 +20,10 @@ require (
|
|||||||
github.com/miekg/dns v1.1.70
|
github.com/miekg/dns v1.1.70
|
||||||
github.com/multiformats/go-multiaddr v0.16.0
|
github.com/multiformats/go-multiaddr v0.16.0
|
||||||
github.com/olric-data/olric v0.7.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/rqlite/gorqlite v0.0.0-20250609141355-ac86a4a1c9a8
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/stretchr/testify v1.11.1
|
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/v2 v2.2.12 // indirect
|
||||||
github.com/pion/dtls/v3 v3.0.6 // indirect
|
github.com/pion/dtls/v3 v3.0.6 // indirect
|
||||||
github.com/pion/ice/v4 v4.0.10 // 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/logging v0.2.3 // indirect
|
||||||
github.com/pion/mdns/v2 v2.0.7 // indirect
|
github.com/pion/mdns/v2 v2.0.7 // indirect
|
||||||
github.com/pion/randutil v0.1.0 // 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/rtp v1.8.19 // indirect
|
||||||
github.com/pion/sctp v1.8.39 // indirect
|
github.com/pion/sctp v1.8.39 // indirect
|
||||||
github.com/pion/sdp/v3 v3.0.13 // 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/stun/v3 v3.0.0 // indirect
|
||||||
github.com/pion/transport/v2 v2.2.10 // indirect
|
github.com/pion/transport/v2 v2.2.10 // indirect
|
||||||
github.com/pion/transport/v3 v3.0.7 // 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/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/prometheus/client_golang v1.23.0 // indirect
|
github.com/prometheus/client_golang v1.23.0 // indirect
|
||||||
|
|||||||
@ -61,7 +61,7 @@ func ShowHelp() {
|
|||||||
fmt.Printf("Subcommands:\n")
|
fmt.Printf("Subcommands:\n")
|
||||||
fmt.Printf(" status - Show cluster node status (RQLite + Olric)\n")
|
fmt.Printf(" status - Show cluster node status (RQLite + Olric)\n")
|
||||||
fmt.Printf(" Options:\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(" health - Run cluster health checks\n")
|
||||||
fmt.Printf(" rqlite <subcommand> - RQLite-specific commands\n")
|
fmt.Printf(" rqlite <subcommand> - RQLite-specific commands\n")
|
||||||
fmt.Printf(" status - Show detailed Raft state for local node\n")
|
fmt.Printf(" status - Show detailed Raft state for local node\n")
|
||||||
|
|||||||
@ -34,7 +34,7 @@ func init() {
|
|||||||
Cmd.PersistentFlags().StringVar(&flagEnv, "env", "", "Environment: devnet, testnet, mainnet (required)")
|
Cmd.PersistentFlags().StringVar(&flagEnv, "env", "", "Environment: devnet, testnet, mainnet (required)")
|
||||||
Cmd.PersistentFlags().BoolVar(&flagJSON, "json", false, "Machine-readable JSON output")
|
Cmd.PersistentFlags().BoolVar(&flagJSON, "json", false, "Machine-readable JSON output")
|
||||||
Cmd.PersistentFlags().StringVar(&flagNode, "node", "", "Filter to specific node host/IP")
|
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.MarkPersistentFlagRequired("env")
|
||||||
|
|
||||||
Cmd.AddCommand(liveCmd)
|
Cmd.AddCommand(liveCmd)
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/DeBrosOfficial/network/pkg/cli/remotessh"
|
||||||
"github.com/DeBrosOfficial/network/pkg/inspector"
|
"github.com/DeBrosOfficial/network/pkg/inspector"
|
||||||
// Import checks package so init() registers the checkers
|
// Import checks package so init() registers the checkers
|
||||||
_ "github.com/DeBrosOfficial/network/pkg/inspector/checks"
|
_ "github.com/DeBrosOfficial/network/pkg/inspector/checks"
|
||||||
@ -49,7 +50,7 @@ func HandleInspectCommand(args []string) {
|
|||||||
|
|
||||||
fs := flag.NewFlagSet("inspect", flag.ExitOnError)
|
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)")
|
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)")
|
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)")
|
format := fs.String("format", "table", "Output format (table, json)")
|
||||||
@ -98,6 +99,14 @@ func HandleInspectCommand(args []string) {
|
|||||||
os.Exit(1)
|
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
|
// Parse subsystems
|
||||||
var subsystems []string
|
var subsystems []string
|
||||||
if *subsystem != "all" {
|
if *subsystem != "all" {
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/production/report"
|
"github.com/DeBrosOfficial/network/pkg/cli/production/report"
|
||||||
|
"github.com/DeBrosOfficial/network/pkg/cli/remotessh"
|
||||||
"github.com/DeBrosOfficial/network/pkg/inspector"
|
"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)
|
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
|
timeout := cfg.Timeout
|
||||||
if timeout == 0 {
|
if timeout == 0 {
|
||||||
timeout = 30 * time.Second
|
timeout = 30 * time.Second
|
||||||
@ -87,7 +95,7 @@ func collectNodeReport(ctx context.Context, node inspector.Node, timeout time.Du
|
|||||||
return cs
|
return cs
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enrich with node metadata from remote-nodes.conf
|
// Enrich with node metadata from nodes.conf
|
||||||
if rpt.Hostname == "" {
|
if rpt.Hostname == "" {
|
||||||
rpt.Hostname = node.Host
|
rpt.Hostname = node.Host
|
||||||
}
|
}
|
||||||
|
|||||||
@ -63,6 +63,12 @@ func execute(flags *Flags) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cleanup, err := remotessh.PrepareNodeKeys(nodes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
if flags.Node != "" {
|
if flags.Node != "" {
|
||||||
nodes = remotessh.FilterByIP(nodes, flags.Node)
|
nodes = remotessh.FilterByIP(nodes, flags.Node)
|
||||||
if len(nodes) == 0 {
|
if len(nodes) == 0 {
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/DeBrosOfficial/network/pkg/cli/remotessh"
|
||||||
"github.com/DeBrosOfficial/network/pkg/inspector"
|
"github.com/DeBrosOfficial/network/pkg/inspector"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -14,39 +15,72 @@ import (
|
|||||||
// It uploads the source archive, extracts it on the VPS, and runs
|
// It uploads the source archive, extracts it on the VPS, and runs
|
||||||
// the actual install command remotely.
|
// the actual install command remotely.
|
||||||
type RemoteOrchestrator struct {
|
type RemoteOrchestrator struct {
|
||||||
flags *Flags
|
flags *Flags
|
||||||
node inspector.Node
|
node inspector.Node
|
||||||
|
cleanup func()
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRemoteOrchestrator creates a new remote orchestrator.
|
// 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) {
|
func NewRemoteOrchestrator(flags *Flags) (*RemoteOrchestrator, error) {
|
||||||
if flags.VpsIP == "" {
|
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")
|
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
|
// Try to find this IP in nodes.conf for the correct user
|
||||||
node, err := resolveSSHCredentials(flags.VpsIP)
|
user := resolveUser(flags.VpsIP)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to resolve SSH credentials: %w", err)
|
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{
|
return &RemoteOrchestrator{
|
||||||
flags: flags,
|
flags: flags,
|
||||||
node: node,
|
node: node,
|
||||||
|
cleanup: cleanup,
|
||||||
}, nil
|
}, 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.
|
// Execute runs the remote install process.
|
||||||
// If a binary archive exists locally, uploads and extracts it on the VPS
|
// If a binary archive exists locally, uploads and extracts it on the VPS
|
||||||
// so Phase2b auto-detects pre-built mode. Otherwise, source must already
|
// so Phase2b auto-detects pre-built mode. Otherwise, source must already
|
||||||
// be present on the VPS.
|
// be present on the VPS.
|
||||||
func (r *RemoteOrchestrator) Execute() error {
|
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)
|
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
|
// Try to upload a binary archive if one exists locally
|
||||||
if err := r.uploadBinaryArchive(); err != nil {
|
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")
|
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
|
// Upload to /tmp/ on VPS
|
||||||
remoteTmp := "/tmp/" + filepath.Base(archivePath)
|
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)
|
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")
|
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'",
|
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)
|
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)
|
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.
|
// runRemoteInstall executes `orama install` on the VPS.
|
||||||
func (r *RemoteOrchestrator) runRemoteInstall() error {
|
func (r *RemoteOrchestrator) runRemoteInstall() error {
|
||||||
cmd := r.buildRemoteCommand()
|
cmd := r.buildRemoteCommand()
|
||||||
return runSSHStreaming(r.node, cmd)
|
return remotessh.RunSSHStreaming(r.node, cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildRemoteCommand constructs the `sudo orama install` command string
|
// buildRemoteCommand constructs the `sudo orama install` command string
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -72,6 +72,13 @@ func execute(flags *Flags) error {
|
|||||||
return err
|
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
|
// Filter to single node if specified
|
||||||
if flags.Node != "" {
|
if flags.Node != "" {
|
||||||
nodes = remotessh.FilterByIP(nodes, flags.Node)
|
nodes = remotessh.FilterByIP(nodes, flags.Node)
|
||||||
@ -86,6 +93,11 @@ func execute(flags *Flags) error {
|
|||||||
return pushDirect(archivePath, nodes)
|
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)
|
return pushFanout(archivePath, nodes)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -111,7 +123,7 @@ func pushDirect(archivePath string, nodes []inspector.Node) error {
|
|||||||
return nil
|
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 {
|
func pushFanout(archivePath string, nodes []inspector.Node) error {
|
||||||
hub := remotessh.PickHubNode(nodes)
|
hub := remotessh.PickHubNode(nodes)
|
||||||
remotePath := "/tmp/" + filepath.Base(archivePath)
|
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)
|
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)
|
remaining := make([]inspector.Node, 0, len(nodes)-1)
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
if n.Host != hub.Host {
|
if n.Host != hub.Host {
|
||||||
@ -150,11 +162,11 @@ func pushFanout(archivePath string, nodes []inspector.Node) error {
|
|||||||
go func(idx int, target inspector.Node) {
|
go func(idx int, target inspector.Node) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
// SCP from hub to target, then extract
|
// SCP from hub to target (agent forwarding serves the key)
|
||||||
scpCmd := fmt.Sprintf("sshpass -p '%s' scp -o StrictHostKeyChecking=no -o ConnectTimeout=10 -o PreferredAuthentications=password -o PubkeyAuthentication=no %s %s@%s:%s",
|
scpCmd := fmt.Sprintf("scp -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 %s %s@%s:%s",
|
||||||
target.Password, remotePath, target.User, target.Host, remotePath)
|
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)
|
errors[idx] = fmt.Errorf("fanout to %s failed: %w", target.Host, err)
|
||||||
return
|
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.
|
// 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 {
|
func extractOnNodeVia(hub, target inspector.Node, remotePath string) error {
|
||||||
sudo := remotessh.SudoPrefix(target)
|
sudo := remotessh.SudoPrefix(target)
|
||||||
extractCmd := fmt.Sprintf("%smkdir -p /opt/orama && %star xzf %s -C /opt/orama && %srm -f %s",
|
extractCmd := fmt.Sprintf("%smkdir -p /opt/orama && %star xzf %s -C /opt/orama && %srm -f %s",
|
||||||
sudo, sudo, remotePath, sudo, remotePath)
|
sudo, sudo, remotePath, sudo, remotePath)
|
||||||
|
|
||||||
// SSH from hub to target to extract
|
// SSH from hub to target to extract (agent forwarding serves the key)
|
||||||
sshCmd := fmt.Sprintf("sshpass -p '%s' ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 -o PreferredAuthentications=password -o PubkeyAuthentication=no %s@%s '%s'",
|
sshCmd := fmt.Sprintf("ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 %s@%s '%s'",
|
||||||
target.Password, target.User, target.Host, extractCmd)
|
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/.
|
// findNewestArchive finds the newest binary archive in /tmp/.
|
||||||
|
|||||||
@ -70,6 +70,12 @@ func execute(flags *Flags) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cleanup, err := remotessh.PrepareNodeKeys(nodes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
// Find leader node
|
// Find leader node
|
||||||
leaderNodes := remotessh.FilterByIP(nodes, flags.Leader)
|
leaderNodes := remotessh.FilterByIP(nodes, flags.Leader)
|
||||||
if len(leaderNodes) == 0 {
|
if len(leaderNodes) == 0 {
|
||||||
|
|||||||
@ -25,6 +25,12 @@ func (r *RemoteUpgrader) Execute() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cleanup, err := remotessh.PrepareNodeKeys(nodes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
// Filter to single node if specified
|
// Filter to single node if specified
|
||||||
if r.flags.NodeFilter != "" {
|
if r.flags.NodeFilter != "" {
|
||||||
nodes = remotessh.FilterByIP(nodes, r.flags.NodeFilter)
|
nodes = remotessh.FilterByIP(nodes, r.flags.NodeFilter)
|
||||||
|
|||||||
@ -4,24 +4,23 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/inspector"
|
"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.
|
// in common locations relative to the current directory or project root.
|
||||||
func FindRemoteNodesConf() string {
|
func FindNodesConf() string {
|
||||||
candidates := []string{
|
candidates := []string{
|
||||||
"scripts/remote-nodes.conf",
|
"scripts/nodes.conf",
|
||||||
"../scripts/remote-nodes.conf",
|
"../scripts/nodes.conf",
|
||||||
"network/scripts/remote-nodes.conf",
|
"network/scripts/nodes.conf",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also check from home dir
|
// Also check from home dir
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
if home != "" {
|
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 {
|
for _, c := range candidates {
|
||||||
@ -32,11 +31,12 @@ func FindRemoteNodesConf() string {
|
|||||||
return ""
|
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) {
|
func LoadEnvNodes(env string) ([]inspector.Node, error) {
|
||||||
confPath := FindRemoteNodesConf()
|
confPath := FindNodesConf()
|
||||||
if confPath == "" {
|
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)
|
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)
|
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
|
return filtered, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,31 +8,34 @@ import (
|
|||||||
"github.com/DeBrosOfficial/network/pkg/inspector"
|
"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.
|
// 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 {
|
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)
|
dest := fmt.Sprintf("%s@%s:%s", node.User, node.Host, remotePath)
|
||||||
|
|
||||||
var cmd *exec.Cmd
|
cmd := exec.Command("scp",
|
||||||
if node.SSHKey != "" {
|
"-o", "StrictHostKeyChecking=accept-new",
|
||||||
cmd = exec.Command("scp",
|
"-o", "ConnectTimeout=10",
|
||||||
"-o", "StrictHostKeyChecking=no",
|
"-i", node.SSHKey,
|
||||||
"-o", "ConnectTimeout=10",
|
localPath, dest,
|
||||||
"-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.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
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,
|
// RunSSHStreaming executes a command on a remote host via SSH,
|
||||||
// streaming stdout/stderr to the local terminal in real-time.
|
// streaming stdout/stderr to the local terminal in real-time.
|
||||||
func RunSSHStreaming(node inspector.Node, command string) error {
|
// Requires node.SSHKey to be set (via PrepareNodeKeys).
|
||||||
var cmd *exec.Cmd
|
func RunSSHStreaming(node inspector.Node, command string, opts ...SSHOption) error {
|
||||||
if node.SSHKey != "" {
|
if node.SSHKey == "" {
|
||||||
cmd = exec.Command("ssh",
|
return fmt.Errorf("no SSH key for %s (call PrepareNodeKeys first)", node.Name())
|
||||||
"-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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
cmd.Stdin = os.Stdin
|
cmd.Stdin = os.Stdin
|
||||||
|
|||||||
158
pkg/cli/remotessh/wallet.go
Normal file
158
pkg/cli/remotessh/wallet.go
Normal 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)
|
||||||
|
}
|
||||||
@ -13,7 +13,6 @@ func makeNode(host, role string) inspector.Node {
|
|||||||
Environment: "devnet",
|
Environment: "devnet",
|
||||||
User: "ubuntu",
|
User: "ubuntu",
|
||||||
Host: host,
|
Host: host,
|
||||||
Password: "test",
|
|
||||||
Role: role,
|
Role: role,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,14 +7,13 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Node represents a remote node parsed from remote-nodes.conf.
|
// Node represents a remote node parsed from nodes.conf.
|
||||||
type Node struct {
|
type Node struct {
|
||||||
Environment string // devnet, testnet
|
Environment string // devnet, testnet
|
||||||
User string // SSH user
|
User string // SSH user
|
||||||
Host string // IP or hostname
|
Host string // IP or hostname
|
||||||
Password string // SSH password
|
|
||||||
Role string // node, nameserver-ns1, nameserver-ns2, nameserver-ns3
|
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).
|
// 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")
|
return strings.HasPrefix(n.Role, "nameserver")
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadNodes parses a remote-nodes.conf file into a slice of Nodes.
|
// LoadNodes parses a nodes.conf file into a slice of Nodes.
|
||||||
// Format: environment|user@host|password|role|ssh_key (ssh_key optional)
|
// Format: environment|user@host|role
|
||||||
func LoadNodes(path string) ([]Node, error) {
|
func LoadNodes(path string) ([]Node, error) {
|
||||||
f, err := os.Open(path)
|
f, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -46,20 +45,14 @@ func LoadNodes(path string) ([]Node, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.SplitN(line, "|", 5)
|
parts := strings.SplitN(line, "|", 4)
|
||||||
if len(parts) < 4 {
|
if len(parts) < 3 {
|
||||||
return nil, fmt.Errorf("line %d: expected at least 4 pipe-delimited fields, got %d", lineNum, len(parts))
|
return nil, fmt.Errorf("line %d: expected 3 pipe-delimited fields (env|user@host|role), got %d", lineNum, len(parts))
|
||||||
}
|
}
|
||||||
|
|
||||||
env := parts[0]
|
env := parts[0]
|
||||||
userHost := parts[1]
|
userHost := parts[1]
|
||||||
password := parts[2]
|
role := parts[2]
|
||||||
role := parts[3]
|
|
||||||
|
|
||||||
var sshKey string
|
|
||||||
if len(parts) == 5 {
|
|
||||||
sshKey = parts[4]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse user@host
|
// Parse user@host
|
||||||
at := strings.LastIndex(userHost, "@")
|
at := strings.LastIndex(userHost, "@")
|
||||||
@ -73,9 +66,7 @@ func LoadNodes(path string) ([]Node, error) {
|
|||||||
Environment: env,
|
Environment: env,
|
||||||
User: user,
|
User: user,
|
||||||
Host: host,
|
Host: host,
|
||||||
Password: password,
|
|
||||||
Role: role,
|
Role: role,
|
||||||
SSHKey: sshKey,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if err := scanner.Err(); err != nil {
|
if err := scanner.Err(); err != nil {
|
||||||
|
|||||||
@ -8,9 +8,9 @@ import (
|
|||||||
|
|
||||||
func TestLoadNodes(t *testing.T) {
|
func TestLoadNodes(t *testing.T) {
|
||||||
content := `# Comment line
|
content := `# Comment line
|
||||||
devnet|ubuntu@1.2.3.4|pass123|node
|
devnet|ubuntu@1.2.3.4|node
|
||||||
devnet|ubuntu@1.2.3.5|pass456|node
|
devnet|ubuntu@1.2.3.5|node
|
||||||
devnet|ubuntu@5.6.7.8|pass789|nameserver-ns1|/path/to/key
|
devnet|ubuntu@5.6.7.8|nameserver-ns1
|
||||||
`
|
`
|
||||||
path := writeTempFile(t, content)
|
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" {
|
if n.Host != "1.2.3.4" {
|
||||||
t.Errorf("node[0].Host = %q, want 1.2.3.4", n.Host)
|
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" {
|
if n.Role != "node" {
|
||||||
t.Errorf("node[0].Role = %q, want node", n.Role)
|
t.Errorf("node[0].Role = %q, want node", n.Role)
|
||||||
}
|
}
|
||||||
if n.SSHKey != "" {
|
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]
|
n3 := nodes[2]
|
||||||
if n3.Role != "nameserver-ns1" {
|
if n3.Role != "nameserver-ns1" {
|
||||||
t.Errorf("node[2].Role = %q, want nameserver-ns1", n3.Role)
|
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) {
|
func TestLoadNodes_EmptyLines(t *testing.T) {
|
||||||
content := `
|
content := `
|
||||||
# Full line comment
|
# Full line comment
|
||||||
|
|
||||||
devnet|ubuntu@1.2.3.4|pass|node
|
devnet|ubuntu@1.2.3.4|node
|
||||||
|
|
||||||
# Another comment
|
# Another comment
|
||||||
devnet|ubuntu@1.2.3.5|pass|node
|
devnet|ubuntu@1.2.3.5|node
|
||||||
`
|
`
|
||||||
path := writeTempFile(t, content)
|
path := writeTempFile(t, content)
|
||||||
|
|
||||||
@ -78,8 +72,8 @@ func TestLoadNodes_InvalidFormat(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
content string
|
content string
|
||||||
}{
|
}{
|
||||||
{"too few fields", "devnet|ubuntu@1.2.3.4|pass\n"},
|
{"too few fields", "devnet|ubuntu@1.2.3.4\n"},
|
||||||
{"no @ in userhost", "devnet|localhost|pass|node\n"},
|
{"no @ in userhost", "devnet|localhost|node\n"},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|||||||
@ -31,7 +31,7 @@ func (r SSHResult) OK() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RunSSH executes a command on a remote node via SSH with retry on connection failure.
|
// 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.
|
// The -n flag is used to prevent SSH from reading stdin.
|
||||||
func RunSSH(ctx context.Context, node Node, command string) SSHResult {
|
func RunSSH(ctx context.Context, node Node, command string) SSHResult {
|
||||||
var result 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 {
|
func runSSHOnce(ctx context.Context, node Node, command string) SSHResult {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
var args []string
|
if node.SSHKey == "" {
|
||||||
if node.SSHKey != "" {
|
return SSHResult{
|
||||||
// Key-based auth
|
Duration: 0,
|
||||||
args = []string{
|
Err: fmt.Errorf("no SSH key for %s (call PrepareNodeKeys first)", node.Name()),
|
||||||
"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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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:]...)
|
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
|
||||||
|
|
||||||
var stdout, stderr bytes.Buffer
|
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
|
// isSSHConnectionError returns true if the failure looks like an SSH connection
|
||||||
// problem (timeout, refused, network unreachable) rather than a remote command error.
|
// problem (timeout, refused, network unreachable) rather than a remote command error.
|
||||||
func isSSHConnectionError(r SSHResult) bool {
|
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)
|
// SSH exit code 255 = SSH connection error (retriable)
|
||||||
if r.ExitCode == 255 {
|
if r.ExitCode == 255 {
|
||||||
return true
|
return true
|
||||||
|
|||||||
42
scripts/nodes.conf
Normal file
42
scripts/nodes.conf
Normal 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
|
||||||
Loading…
x
Reference in New Issue
Block a user