NEW lib/ssh-helper.sh implements the full SSH command surface: hosts/list show configured remote hosts add <alias> <user@host[:port]> register a new host remove <alias> remove + clean cred + socket pass <alias> set/update password (hidden interactive) setup <alias> open long-lived ControlMaster close <alias> close ControlMaster status [alias] show open masters + cred presence exec <alias> <command...> run command via master Architecture: • $LARRY_HOME/.ssh-hosts.tsv — alias \t user@host \t port (3-col) • $LARRY_HOME/.ssh-creds/<alias> — raw password, mode 0600 • $LARRY_HOME/.ssh-sockets/<alias>.sock — ControlMaster socket The password is read from disk by sshpass via -f (file argument), so it never lands in argv or environment. It is used ONCE to open the master; all subsequent execs multiplex through the socket with no auth. Daily- rotating passwords: just overwrite the cred file and re-run setup. SLASH COMMANDS wired in larry.sh REPL: /ssh-hosts /ssh-add /ssh-remove /ssh-pass /ssh-setup /ssh-close /ssh-status /ssh <alias> <cmd>. LARRY TOOLS exposed to the LLM: ssh_status — list aliases + open-master state ssh_exec — run command on remote via the master socket Both tool descriptions explicitly tell Larry the password is unreachable and to ask Bryan to run /ssh-setup if a master is closed. Tool inputs and outputs never contain the password. Output capped at max_lines (default 500) with a "[ssh_exec: exit rc=N]" footer. Bundle updated: MANIFEST + install-larry.sh both now include lib/ssh-helper.sh. Auto-update will pull it on next launch. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
254 lines
9.4 KiB
Bash
Executable File
254 lines
9.4 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# ssh-helper.sh — secure SSH command execution via ControlMaster.
|
||
#
|
||
# Architecture:
|
||
# • Hosts configured in $LARRY_HOME/.ssh-hosts.tsv as
|
||
# alias \t user@host \t port
|
||
# • Passwords stored at $LARRY_HOME/.ssh-creds/<alias>, mode 0600.
|
||
# The password file is the single point of truth — to rotate (daily-changing
|
||
# passwords) just overwrite the file with the new one and re-run 'setup'.
|
||
# • sshpass reads the password via -f (file), so it never lands in argv or
|
||
# environment where Larry the LLM (or other processes via /proc) could see it.
|
||
# • The first 'setup' call opens a long-lived SSH ControlMaster connection
|
||
# (default ControlPersist=8h). Subsequent 'exec' calls multiplex through
|
||
# the master socket and need no password.
|
||
# • Larry's tool layer only sees: alias, command, command_output.
|
||
# Never the password. Never the user@host (unless added to the alias list).
|
||
#
|
||
# Subcommands:
|
||
# hosts list configured hosts
|
||
# add <alias> <user@host[:port]> add a host to the alias list
|
||
# remove <alias> remove an alias (also clears cred + socket)
|
||
# pass <alias> set/update the password (hidden interactive)
|
||
# setup <alias> open ControlMaster (uses stored password ONCE)
|
||
# close <alias> close ControlMaster
|
||
# status [alias] show open masters / cred presence
|
||
# exec <alias> <command...> run command via master (returns output)
|
||
# help print this help
|
||
|
||
set -u
|
||
set -o pipefail
|
||
|
||
LARRY_HOME="${LARRY_HOME:-$HOME/.larry}"
|
||
SSH_HOSTS_FILE="$LARRY_HOME/.ssh-hosts.tsv"
|
||
SSH_CREDS_DIR="$LARRY_HOME/.ssh-creds"
|
||
SSH_SOCKETS_DIR="$LARRY_HOME/.ssh-sockets"
|
||
SSH_CONTROL_PERSIST="${LARRY_SSH_CONTROL_PERSIST:-8h}"
|
||
|
||
die() { printf 'ssh-helper: %s\n' "$*" >&2; exit 1; }
|
||
warn() { printf 'ssh-helper: warn: %s\n' "$*" >&2; }
|
||
ok() { printf 'ssh-helper: %s\n' "$*"; }
|
||
|
||
ensure_layout() {
|
||
mkdir -p "$LARRY_HOME" "$SSH_CREDS_DIR" "$SSH_SOCKETS_DIR" 2>/dev/null
|
||
chmod 700 "$LARRY_HOME" "$SSH_CREDS_DIR" "$SSH_SOCKETS_DIR" 2>/dev/null || true
|
||
if [ ! -f "$SSH_HOSTS_FILE" ]; then
|
||
umask 077
|
||
printf 'alias\taddr\tport\n' > "$SSH_HOSTS_FILE"
|
||
chmod 600 "$SSH_HOSTS_FILE"
|
||
fi
|
||
}
|
||
|
||
# read_host_addr ALIAS → echoes "ADDR\tPORT" or empty
|
||
read_host_addr() {
|
||
local alias="$1"
|
||
[ -f "$SSH_HOSTS_FILE" ] || { printf ''; return 1; }
|
||
awk -F'\t' -v a="$alias" 'NR>1 && $1==a { print $2 "\t" $3; exit }' < "$SSH_HOSTS_FILE"
|
||
}
|
||
|
||
require_sshpass() {
|
||
command -v sshpass >/dev/null 2>&1 \
|
||
|| die "sshpass not on PATH — install it (apt install sshpass / brew install sshpass) and retry"
|
||
}
|
||
|
||
cmd_help() {
|
||
sed -n '4,30p' "$0"
|
||
}
|
||
|
||
cmd_hosts() {
|
||
ensure_layout
|
||
if [ "$(wc -l < "$SSH_HOSTS_FILE")" -le 1 ]; then
|
||
echo "no hosts configured. Add with: ssh-helper.sh add <alias> <user@host[:port]>"
|
||
return 0
|
||
fi
|
||
printf 'alias user@host port cred master\n'
|
||
printf '%s\n' '───── ───────── ──── ──── ──────'
|
||
awk -F'\t' 'NR>1' "$SSH_HOSTS_FILE" | while IFS=$'\t' read -r alias addr port; do
|
||
local cred_state="–"
|
||
[ -f "$SSH_CREDS_DIR/$alias" ] && cred_state="✓"
|
||
local master_state="–"
|
||
local sock="$SSH_SOCKETS_DIR/$alias.sock"
|
||
if [ -S "$sock" ] && ssh -S "$sock" -O check -p "$port" "$addr" 2>/dev/null; then
|
||
master_state="open"
|
||
fi
|
||
printf '%-20s%-52s%-6s%-6s%s\n' "$alias" "$addr" "${port:-22}" "$cred_state" "$master_state"
|
||
done
|
||
}
|
||
|
||
cmd_add() {
|
||
local alias="${1:-}" target="${2:-}"
|
||
[ -n "$alias" ] && [ -n "$target" ] || die "usage: add <alias> <user@host[:port]>"
|
||
[[ "$target" =~ ^[^@[:space:]]+@[^:[:space:]]+(:[0-9]+)?$ ]] \
|
||
|| die "target must look like user@host or user@host:port"
|
||
local addr port
|
||
if [[ "$target" == *:* ]]; then
|
||
addr="${target%:*}"
|
||
port="${target##*:}"
|
||
else
|
||
addr="$target"
|
||
port="22"
|
||
fi
|
||
ensure_layout
|
||
# Reject duplicates (use 'remove' first)
|
||
if awk -F'\t' -v a="$alias" 'NR>1 && $1==a { found=1; exit } END { exit !found }' "$SSH_HOSTS_FILE"; then
|
||
die "alias '$alias' already exists. Use 'remove $alias' first."
|
||
fi
|
||
umask 077
|
||
printf '%s\t%s\t%s\n' "$alias" "$addr" "$port" >> "$SSH_HOSTS_FILE"
|
||
chmod 600 "$SSH_HOSTS_FILE"
|
||
ok "added $alias → $addr (port $port). Next: ssh-helper.sh pass $alias"
|
||
}
|
||
|
||
cmd_remove() {
|
||
local alias="${1:-}"
|
||
[ -n "$alias" ] || die "usage: remove <alias>"
|
||
ensure_layout
|
||
local tmp; tmp=$(mktemp)
|
||
awk -F'\t' -v a="$alias" 'NR==1 || $1!=a' "$SSH_HOSTS_FILE" > "$tmp" && mv "$tmp" "$SSH_HOSTS_FILE"
|
||
chmod 600 "$SSH_HOSTS_FILE"
|
||
# Close + clean master socket and cred
|
||
cmd_close "$alias" 2>/dev/null || true
|
||
rm -f "$SSH_CREDS_DIR/$alias" "$SSH_SOCKETS_DIR/$alias.sock" 2>/dev/null
|
||
ok "removed $alias (cred + socket cleared)"
|
||
}
|
||
|
||
cmd_pass() {
|
||
local alias="${1:-}"
|
||
[ -n "$alias" ] || die "usage: pass <alias>"
|
||
local addr_port; addr_port=$(read_host_addr "$alias")
|
||
[ -n "$addr_port" ] || die "no such alias: $alias (run 'add' first)"
|
||
ensure_layout
|
||
printf 'Password for %s (input is hidden; press Enter when done): ' "$alias" >&2
|
||
local pw=""
|
||
stty -echo 2>/dev/null
|
||
IFS= read -r pw </dev/tty || true
|
||
stty echo 2>/dev/null
|
||
echo "" >&2
|
||
[ -n "$pw" ] || die "no password entered"
|
||
umask 077
|
||
# NO trailing newline — sshpass -f expects raw password as full file content
|
||
printf '%s' "$pw" > "$SSH_CREDS_DIR/$alias"
|
||
chmod 600 "$SSH_CREDS_DIR/$alias"
|
||
ok "password saved to $SSH_CREDS_DIR/$alias (mode 0600). Next: ssh-helper.sh setup $alias"
|
||
}
|
||
|
||
cmd_setup() {
|
||
local alias="${1:-}"
|
||
[ -n "$alias" ] || die "usage: setup <alias>"
|
||
local addr_port; addr_port=$(read_host_addr "$alias")
|
||
[ -n "$addr_port" ] || die "no such alias: $alias"
|
||
local addr port
|
||
addr=$(printf '%s' "$addr_port" | cut -f1)
|
||
port=$(printf '%s' "$addr_port" | cut -f2)
|
||
ensure_layout
|
||
local sock="$SSH_SOCKETS_DIR/$alias.sock"
|
||
if [ -S "$sock" ] && ssh -S "$sock" -O check -p "$port" "$addr" 2>/dev/null; then
|
||
ok "master already open for $alias ($addr:$port)"
|
||
return 0
|
||
fi
|
||
local credfile="$SSH_CREDS_DIR/$alias"
|
||
[ -f "$credfile" ] || die "no password set for $alias — run 'pass $alias' first"
|
||
require_sshpass
|
||
ok "opening ssh master for $alias ($addr:$port) — ControlPersist=$SSH_CONTROL_PERSIST..."
|
||
if sshpass -f "$credfile" ssh \
|
||
-o "ControlMaster=yes" \
|
||
-o "ControlPath=$sock" \
|
||
-o "ControlPersist=$SSH_CONTROL_PERSIST" \
|
||
-o "StrictHostKeyChecking=accept-new" \
|
||
-o "ConnectTimeout=10" \
|
||
-p "$port" \
|
||
-N -f \
|
||
"$addr" 2>/tmp/larry-ssh-setup.err; then
|
||
if ssh -S "$sock" -O check -p "$port" "$addr" 2>/dev/null; then
|
||
ok "✓ master open: $alias → $addr:$port (socket: $sock)"
|
||
rm -f /tmp/larry-ssh-setup.err
|
||
return 0
|
||
fi
|
||
fi
|
||
printf 'ssh-helper: setup failed. sshpass/ssh stderr:\n' >&2
|
||
cat /tmp/larry-ssh-setup.err >&2 2>/dev/null
|
||
rm -f /tmp/larry-ssh-setup.err
|
||
return 1
|
||
}
|
||
|
||
cmd_close() {
|
||
local alias="${1:-}"
|
||
[ -n "$alias" ] || die "usage: close <alias>"
|
||
local addr_port; addr_port=$(read_host_addr "$alias") || addr_port=""
|
||
local sock="$SSH_SOCKETS_DIR/$alias.sock"
|
||
if [ -S "$sock" ] && [ -n "$addr_port" ]; then
|
||
local addr port
|
||
addr=$(printf '%s' "$addr_port" | cut -f1)
|
||
port=$(printf '%s' "$addr_port" | cut -f2)
|
||
ssh -S "$sock" -O exit -p "$port" "$addr" 2>/dev/null || true
|
||
fi
|
||
rm -f "$sock"
|
||
ok "closed master for $alias"
|
||
}
|
||
|
||
cmd_status() {
|
||
ensure_layout
|
||
if [ -n "${1:-}" ]; then
|
||
local alias="$1"
|
||
local addr_port; addr_port=$(read_host_addr "$alias")
|
||
[ -n "$addr_port" ] || die "no such alias: $alias"
|
||
local addr port
|
||
addr=$(printf '%s' "$addr_port" | cut -f1)
|
||
port=$(printf '%s' "$addr_port" | cut -f2)
|
||
local sock="$SSH_SOCKETS_DIR/$alias.sock"
|
||
printf 'alias: %s\naddr: %s\nport: %s\ncred: %s\nsocket: %s\nstatus: ' \
|
||
"$alias" "$addr" "$port" \
|
||
"$([ -f "$SSH_CREDS_DIR/$alias" ] && echo present || echo missing)" \
|
||
"$sock"
|
||
if [ -S "$sock" ] && ssh -S "$sock" -O check -p "$port" "$addr" 2>/dev/null; then
|
||
echo "master OPEN"
|
||
else
|
||
echo "no master (run setup)"
|
||
fi
|
||
return 0
|
||
fi
|
||
cmd_hosts
|
||
}
|
||
|
||
cmd_exec() {
|
||
local alias="${1:-}"
|
||
[ -n "$alias" ] || die "usage: exec <alias> <command...>"
|
||
shift
|
||
local cmd="$*"
|
||
[ -n "$cmd" ] || die "no command given"
|
||
local addr_port; addr_port=$(read_host_addr "$alias")
|
||
[ -n "$addr_port" ] || die "no such alias: $alias"
|
||
local addr port
|
||
addr=$(printf '%s' "$addr_port" | cut -f1)
|
||
port=$(printf '%s' "$addr_port" | cut -f2)
|
||
local sock="$SSH_SOCKETS_DIR/$alias.sock"
|
||
if [ ! -S "$sock" ] || ! ssh -S "$sock" -O check -p "$port" "$addr" 2>/dev/null; then
|
||
die "no open master for $alias — run 'setup $alias' first"
|
||
fi
|
||
# Multiplexed; no password needed.
|
||
ssh -S "$sock" -p "$port" -o BatchMode=yes "$addr" "$cmd"
|
||
}
|
||
|
||
case "${1:-help}" in
|
||
hosts|list) shift; cmd_hosts ;;
|
||
add) shift; cmd_add "$@" ;;
|
||
remove|rm) shift; cmd_remove "$@" ;;
|
||
pass|passwd) shift; cmd_pass "$@" ;;
|
||
setup|open) shift; cmd_setup "$@" ;;
|
||
close|exit) shift; cmd_close "$@" ;;
|
||
status) shift; cmd_status "$@" ;;
|
||
exec|run) shift; cmd_exec "$@" ;;
|
||
-h|--help|help) cmd_help ;;
|
||
*) die "unknown subcommand: ${1:-} (run with --help)" ;;
|
||
esac
|