From f58bcf711fd632e6e3c8507e9140865064618e3e Mon Sep 17 00:00:00 2001 From: Bryan Johnson Date: Wed, 27 May 2026 10:28:37 -0700 Subject: [PATCH] =?UTF-8?q?v0.6.0:=20secure=20SSH=20ControlMaster=20?= =?UTF-8?q?=E2=80=94=20password=20hidden=20from=20Larry-the-LLM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NEW lib/ssh-helper.sh implements the full SSH command surface: hosts/list show configured remote hosts add register a new host remove remove + clean cred + socket pass set/update password (hidden interactive) setup open long-lived ControlMaster close close ControlMaster status [alias] show open masters + cred presence exec run command via master Architecture: • $LARRY_HOME/.ssh-hosts.tsv — alias \t user@host \t port (3-col) • $LARRY_HOME/.ssh-creds/ — raw password, mode 0600 • $LARRY_HOME/.ssh-sockets/.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 . 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 --- MANIFEST | 3 + VERSION | 2 +- install-larry.sh | 1 + larry.sh | 70 ++++++++++++- lib/ssh-helper.sh | 253 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 327 insertions(+), 2 deletions(-) create mode 100755 lib/ssh-helper.sh diff --git a/MANIFEST b/MANIFEST index 85681f7..0f3c133 100644 --- a/MANIFEST +++ b/MANIFEST @@ -26,6 +26,9 @@ agents/regress.md # Auth implementation lib/oauth.sh +# Secure SSH with ControlMaster (password hidden from Larry-the-LLM) +lib/ssh-helper.sh + # Logging / capture lib/lessons.sh lib/journal.sh diff --git a/VERSION b/VERSION index d1d899f..a918a2a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.5 +0.6.0 diff --git a/install-larry.sh b/install-larry.sh index ba62a75..0e50279 100755 --- a/install-larry.sh +++ b/install-larry.sh @@ -91,6 +91,7 @@ fetch agents/regress.md "$LARRY_HOME/agents/regress.md" fetch larry-rollback.sh "$LARRY_HOME/larry-rollback.sh" fetch larry-auth.sh "$LARRY_HOME/larry-auth.sh" fetch lib/oauth.sh "$LARRY_HOME/lib/oauth.sh" +fetch lib/ssh-helper.sh "$LARRY_HOME/lib/ssh-helper.sh" fetch lib/lessons.sh "$LARRY_HOME/lib/lessons.sh" fetch lib/hl7-sanitize.sh "$LARRY_HOME/lib/hl7-sanitize.sh" fetch lib/hl7-desanitize.sh "$LARRY_HOME/lib/hl7-desanitize.sh" diff --git a/larry.sh b/larry.sh index 8a1cac6..bc17402 100755 --- a/larry.sh +++ b/larry.sh @@ -36,7 +36,7 @@ set -o pipefail # ───────────────────────────────────────────────────────────────────────────── # Config # ───────────────────────────────────────────────────────────────────────────── -LARRY_VERSION="0.5.5" +LARRY_VERSION="0.6.0" LARRY_HOME="${LARRY_HOME:-$HOME/.larry}" LARRY_BASE_URL="${LARRY_BASE_URL:-https://raw.githubusercontent.com/bojj27/cloverleaf-larry/main}" LARRY_UPDATE_URL="${LARRY_UPDATE_URL:-${LARRY_BASE_URL}/larry.sh}" @@ -807,6 +807,33 @@ tool_hl7_sanitize() { "$LARRY_LIB_DIR/hl7-sanitize.sh" "${args[@]}" 2>&1 } +# Secure SSH tools — password is read from $LARRY_HOME/.ssh-creds/ by +# ssh-helper.sh and never exposed in argv, env, or tool output. The Larry-LLM +# only sees: alias name, command, command output. +tool_ssh_exec() { + local alias="$1" command="$2" max_lines="${3:-500}" + local helper="$LARRY_LIB_DIR/ssh-helper.sh" + [ -x "$helper" ] || { echo "ERROR: ssh-helper.sh not installed"; return 1; } + [ -n "$alias" ] && [ -n "$command" ] || { echo "ERROR: ssh_exec needs alias and command"; return 1; } + local out + out=$("$helper" exec "$alias" "$command" 2>&1) + local rc=$? + local total_lines + total_lines=$(printf '%s' "$out" | wc -l | tr -d ' ') + if [ "$total_lines" -gt "$max_lines" ]; then + printf '%s\n[ssh_exec: output truncated — showed %s of %s lines. Exit rc=%d]\n' \ + "$(printf '%s' "$out" | head -n "$max_lines")" "$max_lines" "$total_lines" "$rc" + else + printf '%s\n[ssh_exec: exit rc=%d]\n' "$out" "$rc" + fi +} + +tool_ssh_status() { + local helper="$LARRY_LIB_DIR/ssh-helper.sh" + [ -x "$helper" ] || { echo "ERROR: ssh-helper.sh not installed"; return 1; } + "$helper" status 2>&1 +} + tool_lesson_record() { local text="$1" topic="${2:-}" site="${3:-${HCISITE:-}}" severity="${4:-info}" _lib_err_if_missing || return @@ -906,6 +933,8 @@ execute_tool() { "$(J '.phase // "all"')" "$(J '.dry_run // 0' | sed "s/false/0/;s/true/1/")" ;; lesson_record) tool_lesson_record "$(J '.text')" "$(J '.topic // ""')" "$(J '.site // ""')" "$(J '.severity // "info"')" ;; hl7_sanitize) tool_hl7_sanitize "$(J '.input_path')" "$(J '.strict // 0' | sed "s/false/0/;s/true/1/")" ;; + ssh_exec) tool_ssh_exec "$(J '.alias')" "$(J '.command')" "$(J '.max_lines // 500')" ;; + ssh_status) tool_ssh_status ;; larry_rollback_list) tool_larry_rollback_list "$(J '.session // ""')" ;; *) echo "ERROR: unknown tool: $name" ;; esac @@ -948,6 +977,10 @@ TOOLS_JSON='[ {"name":"hl7_sanitize","description":"Tokenize PHI fields in an HL7 message file. Replaces values in patient identifiers, names, DOB, addresses, phones, SSN, account numbers, providers, visit numbers, NK1/GT1/IN1 fields, etc. with deterministic local tokens like [[MRN_0001]]. Same value gets same token across the entire local lookup table, so correlation analysis still works. The token-to-original mapping NEVER leaves the client (stored at $LARRY_HOME/sanitize/lookup.tsv, mode 0600). Use this when Bryan needs you to analyze a file that has real PHI. Returns the sanitized HL7 content with tokens substituted. Bryan can desanitize the final output locally with hl7-desanitize.sh.","input_schema":{"type":"object","properties":{"input_path":{"type":"string","description":"Path to the HL7 message file to sanitize."},"strict":{"type":"integer","description":"1=also tokenize any unknown Z* segments wholesale. Default 0 (safer for legibility but might miss custom PHI in Z segments)."}},"required":["input_path"]}}, + {"name":"ssh_exec","description":"Run a shell command on a remote test/dev host via an authenticated SSH ControlMaster session. Bryan must have already configured the alias (via /ssh-add) and opened the master (via /ssh-setup). The password is stored locally and you CANNOT see it — do not ask Bryan for it; if the master is closed, tell him to run the /ssh-setup ALIAS slash command. Use ssh_status first to confirm which aliases are open. Output capped at max_lines (default 500). Tool result includes the remote exit code as a [ssh_exec: exit rc=N] footer.","input_schema":{"type":"object","properties":{"alias":{"type":"string","description":"Host alias Bryan configured. Run ssh_status to see the list."},"command":{"type":"string","description":"Shell command to execute on the remote. Quote as needed; will be passed through ssh as a single string."},"max_lines":{"type":"integer","description":"Cap output lines (default 500). Increase for known-large output, but prefer targeted commands."}},"required":["alias","command"]}}, + + {"name":"ssh_status","description":"List the SSH hosts Bryan has configured and which ones have an open ControlMaster session. Call this BEFORE ssh_exec to confirm an alias exists and the master is open. Each line shows: alias, user@host, port, cred (present/absent), master (open or dash). If the master is not open for an alias you need, ask Bryan to run the /ssh-setup ALIAS slash command. Do NOT attempt to authenticate yourself — you have no access to the password.","input_schema":{"type":"object","properties":{},"required":[]}}, + {"name":"hl7_diff","description":"HL7-aware diff between two message files (or multi-message dumps). Compares segment-by-segment, field-by-field, with component and subcomponent precision. Ignores configured fields (default MSH.7 timestamp) so timestamp-only diffs do not show up as noise. Use for regression testing between environments (e.g. test vs prod route-test outputs).","input_schema":{"type":"object","properties":{"left":{"type":"string","description":"Path to left HL7 file."},"right":{"type":"string","description":"Path to right HL7 file."},"ignore":{"type":"string","description":"Comma-separated list of fields to ignore (e.g. MSH.7,MSH.10,EVN.6). Default MSH.7."},"include":{"type":"string","description":"If set, ONLY these fields are compared (overrides ignore for that set)."},"format":{"type":"string","enum":["text","tsv","count"],"description":"text=human-readable diff, tsv=machine-parseable, count=just the difference count."}},"required":["left","right"]}}, {"name":"nc_regression","description":"End-to-end regression testing between two Cloverleaf environments. 6 phases: discover inbounds in scope, sample N messages per inbound from env-A smatdbs, run route_test on env-A, run route_test on env-B with same inputs, hl7_diff every paired output file, compile summary report. Phases 3/4 require the Cloverleaf route_test command; pass it via route_test_cmd with placeholders {THREAD} {INPUT} {OUTPUT_DIR} {HCIROOT} {HCISITE}. If route_test_cmd is empty, phases 3/4 are skipped and you can run them manually using the generated input files.","input_schema":{"type":"object","properties":{"scope":{"type":"string","description":"thread:NAME | threads:N1,N2 | site (needs site_a) | server (all sites)"},"count":{"type":"integer","description":"Messages to sample per inbound. Default 10."},"env_a":{"type":"string","description":"HCIROOT of env-A (the test/source env)."},"site_a":{"type":"string","description":"Site name on env-A. Required if scope=site."},"env_b":{"type":"string","description":"HCIROOT of env-B (the prod/target env)."},"site_b":{"type":"string","description":"Site name on env-B."},"out":{"type":"string","description":"Output root directory for inputs, outputs, diffs, and summary."},"route_test_cmd":{"type":"string","description":"Command template for invoking route_test. Use {THREAD} {INPUT} {OUTPUT_DIR} {HCIROOT} {HCISITE} as placeholders."},"ignore":{"type":"string","description":"hl7_diff ignore list. Default MSH.7."},"phase":{"type":"string","enum":["1","2","3","4","5","6","all"],"description":"Run a specific phase or all. Default all."},"dry_run":{"type":"integer","description":"1 = print what would happen, do not execute. Default 0."}},"required":["scope","env_a","env_b","out"]}} @@ -1096,6 +1129,16 @@ Slash commands: /unmask show the original PHI for a token (local only; never sent) /tokens show the full local PHI ↔ token lookup table + Secure SSH (password stays local; never visible to Larry-the-LLM): + /ssh-hosts list configured remote hosts + /ssh-add register a new host + /ssh-pass set/update password (hidden input; daily rotation OK) + /ssh-setup open a long-lived ControlMaster connection + /ssh-close close the ControlMaster + /ssh-status [alias] show open masters + cred presence + /ssh run command on the remote (you-driven, ad-hoc) + Larry can also run things there via the ssh_exec tool. + PHI inline syntax in any prompt: @@VALUE EASY: wrap PHI in @@. Spaceless = no end delim. e.g. @@12345 @@SMITH^JOHN @@V789 @@ -1197,6 +1240,31 @@ main_loop() { /tokens) [ -x "$LARRY_LIB_DIR/hl7-sanitize.sh" ] && "$LARRY_LIB_DIR/hl7-sanitize.sh" show-table \ || err "hl7-sanitize.sh not installed" continue ;; + # ── SSH ControlMaster commands (password never visible to Larry-the-LLM) ── + /ssh-hosts|/ssh-list) [ -x "$LARRY_LIB_DIR/ssh-helper.sh" ] && "$LARRY_LIB_DIR/ssh-helper.sh" hosts \ + || err "ssh-helper.sh not installed" + continue ;; + /ssh-add\ *) local rest="${input#/ssh-add }"; local args=($rest) + if [ -x "$LARRY_LIB_DIR/ssh-helper.sh" ]; then "$LARRY_LIB_DIR/ssh-helper.sh" add "${args[@]}"; else err "ssh-helper.sh not installed"; fi + continue ;; + /ssh-remove\ *|/ssh-rm\ *) local a="${input#/ssh-* }" + if [ -x "$LARRY_LIB_DIR/ssh-helper.sh" ]; then "$LARRY_LIB_DIR/ssh-helper.sh" remove "$a"; else err "ssh-helper.sh not installed"; fi + continue ;; + /ssh-pass\ *) local a="${input#/ssh-pass }" + if [ -x "$LARRY_LIB_DIR/ssh-helper.sh" ]; then "$LARRY_LIB_DIR/ssh-helper.sh" pass "$a"; else err "ssh-helper.sh not installed"; fi + continue ;; + /ssh-setup\ *) local a="${input#/ssh-setup }" + if [ -x "$LARRY_LIB_DIR/ssh-helper.sh" ]; then "$LARRY_LIB_DIR/ssh-helper.sh" setup "$a"; else err "ssh-helper.sh not installed"; fi + continue ;; + /ssh-close\ *) local a="${input#/ssh-close }" + if [ -x "$LARRY_LIB_DIR/ssh-helper.sh" ]; then "$LARRY_LIB_DIR/ssh-helper.sh" close "$a"; else err "ssh-helper.sh not installed"; fi + continue ;; + /ssh-status) [ -x "$LARRY_LIB_DIR/ssh-helper.sh" ] && "$LARRY_LIB_DIR/ssh-helper.sh" status \ + || err "ssh-helper.sh not installed" + continue ;; + /ssh\ *) local rest="${input#/ssh }"; local alias="${rest%% *}"; local rcmd="${rest#"$alias" }" + if [ -x "$LARRY_LIB_DIR/ssh-helper.sh" ]; then "$LARRY_LIB_DIR/ssh-helper.sh" exec "$alias" "$rcmd"; else err "ssh-helper.sh not installed"; fi + continue ;; /redetect) detect_cloverleaf_env system_prompt=$(build_system_prompt) larry_say "re-detected. /env to view." diff --git a/lib/ssh-helper.sh b/lib/ssh-helper.sh new file mode 100755 index 0000000..d4e7ea2 --- /dev/null +++ b/lib/ssh-helper.sh @@ -0,0 +1,253 @@ +#!/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/, 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 add a host to the alias list +# remove remove an alias (also clears cred + socket) +# pass set/update the password (hidden interactive) +# setup open ControlMaster (uses stored password ONCE) +# close close ControlMaster +# status [alias] show open masters / cred presence +# exec 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 " + 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 " + [[ "$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 " + 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 " + 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/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 " + 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 " + 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 " + 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