orama/pkg/cli/remotessh/wallet.go
anonpenguin23 6898f47e2e 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
2026-02-24 17:24:16 +02:00

159 lines
4.5 KiB
Go

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