Portable AI agent for Cloverleaf integration work. Pure bash + curl + jq. Zero dependency on v1 wrapper scripts or v2 cloverleaf-tools.pyz. 27 native Anthropic tools: NetConfig parsing (read) nc_list_protocols, nc_list_processes, nc_protocol_block, nc_protocol_field, nc_protocol_nested, nc_protocol_summary, nc_destinations, nc_sources, nc_xlate_refs, nc_tclproc_refs NetConfig modification (journal-backed writes with rollback) nc_insert_protocol, nc_add_route, larry_rollback_list Workflows nc_find_inbound, nc_make_jump (3-thread jump pattern), nc_find (tbn/tbp/tbh/tbpr/where replacements), nc_document, nc_diff_interface, nc_regression Messages hl7_field, nc_msgs (smat is SQLite!), hl7_diff (with --ignore MSH.7) File system read_file, list_dir, grep_files, glob_files, write_file, bash_exec Validated against a 22-site real Cloverleaf test install. Five worked examples end-to-end: jump-thread generation, smat MRN search, system documentation, interface+connected diff, HL7-aware regression diff. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
184 lines
6.1 KiB
Bash
Executable File
184 lines
6.1 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# larry-tunnel — open a reverse SSH tunnel from a remote shell back to
|
|
# a public hop, so Bryan's home Larry can SSH "in" to this machine.
|
|
#
|
|
# Two modes:
|
|
# 1. Bryan-controlled hop (recommended once set up)
|
|
# Requires: an SSH-reachable host you control (bjnoela.com or a VPS)
|
|
# with `GatewayPorts clientspecified` (or yes) in sshd_config, plus
|
|
# either your SSH pubkey installed there or password auth allowed.
|
|
#
|
|
# 2. serveo.net fallback (zero-config, no account, no install)
|
|
# Just works. Less private. Useful when the primary hop is offline
|
|
# or not yet configured.
|
|
#
|
|
# Usage:
|
|
# larry-tunnel.sh # uses env vars or prompts
|
|
# larry-tunnel.sh --serveo # force serveo.net mode
|
|
# larry-tunnel.sh --hop user@host:port # force custom-hop mode
|
|
#
|
|
# Env vars (set in $LARRY_HOME/.env or shell):
|
|
# LARRY_HOP_HOST e.g. bjnoela.com
|
|
# LARRY_HOP_USER e.g. larry-tunnel (a low-priv user on the hop)
|
|
# LARRY_HOP_PORT SSH port on the hop (default 22)
|
|
# LARRY_HOP_BIND remote-side bind port (default 0 = auto)
|
|
# LARRY_LOCAL_PORT local SSH port to expose (default 22)
|
|
# LARRY_HOP_KEY path to private key for the hop (default ~/.ssh/id_rsa or id_ed25519)
|
|
#
|
|
# Files:
|
|
# $LARRY_HOME/tunnel.pid PID of the active tunnel ssh process
|
|
# $LARRY_HOME/tunnel.url the public host:port to dial from home
|
|
# $LARRY_HOME/tunnel.log ssh stderr/log
|
|
set -u
|
|
set -o pipefail
|
|
|
|
LARRY_HOME="${LARRY_HOME:-$HOME/.larry}"
|
|
mkdir -p "$LARRY_HOME"
|
|
[ -f "$LARRY_HOME/.env" ] && { set -a; . "$LARRY_HOME/.env"; set +a; }
|
|
|
|
LARRY_HOP_HOST="${LARRY_HOP_HOST:-}"
|
|
LARRY_HOP_USER="${LARRY_HOP_USER:-}"
|
|
LARRY_HOP_PORT="${LARRY_HOP_PORT:-22}"
|
|
LARRY_HOP_BIND="${LARRY_HOP_BIND:-0}"
|
|
LARRY_LOCAL_PORT="${LARRY_LOCAL_PORT:-22}"
|
|
LARRY_HOP_KEY="${LARRY_HOP_KEY:-}"
|
|
|
|
C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'; C_DIM=$'\033[2m'
|
|
C_RED=$'\033[31m'; C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'; C_CYAN=$'\033[36m'
|
|
|
|
say() { printf '%s%slarry-tunnel>%s %s\n' "$C_CYAN" "$C_BOLD" "$C_RESET" "$*"; }
|
|
err() { printf '%serror:%s %s\n' "$C_RED" "$C_RESET" "$*" >&2; }
|
|
warn() { printf '%swarn:%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2; }
|
|
|
|
MODE=""
|
|
for arg in "$@"; do
|
|
case "$arg" in
|
|
--serveo) MODE="serveo" ;;
|
|
--hop) MODE="hop" ;;
|
|
--hop=*) MODE="hop"; spec="${arg#--hop=}"
|
|
LARRY_HOP_USER="${spec%@*}"
|
|
rest="${spec#*@}"
|
|
LARRY_HOP_HOST="${rest%:*}"
|
|
[ "$rest" != "${rest%:*}" ] && LARRY_HOP_PORT="${rest##*:}"
|
|
;;
|
|
--status)
|
|
if [ -f "$LARRY_HOME/tunnel.pid" ] && kill -0 "$(cat "$LARRY_HOME/tunnel.pid")" 2>/dev/null; then
|
|
say "running (PID $(cat "$LARRY_HOME/tunnel.pid"))"
|
|
[ -f "$LARRY_HOME/tunnel.url" ] && say "public: $(cat "$LARRY_HOME/tunnel.url")"
|
|
exit 0
|
|
else
|
|
say "not running"
|
|
exit 1
|
|
fi
|
|
;;
|
|
--stop)
|
|
if [ -f "$LARRY_HOME/tunnel.pid" ]; then
|
|
kill "$(cat "$LARRY_HOME/tunnel.pid")" 2>/dev/null && say "stopped"
|
|
rm -f "$LARRY_HOME/tunnel.pid" "$LARRY_HOME/tunnel.url"
|
|
fi
|
|
exit 0
|
|
;;
|
|
-h|--help)
|
|
sed -n '2,32p' "$0"; exit 0
|
|
;;
|
|
*) err "unknown arg: $arg"; exit 2 ;;
|
|
esac
|
|
done
|
|
|
|
# Pick mode if not forced
|
|
if [ -z "$MODE" ]; then
|
|
if [ -n "$LARRY_HOP_HOST" ] && [ -n "$LARRY_HOP_USER" ]; then
|
|
MODE="hop"
|
|
else
|
|
MODE="serveo"
|
|
fi
|
|
fi
|
|
|
|
# Identify ssh key flag (only if set and exists)
|
|
KEY_FLAG=""
|
|
if [ -n "$LARRY_HOP_KEY" ] && [ -f "$LARRY_HOP_KEY" ]; then
|
|
KEY_FLAG="-i $LARRY_HOP_KEY"
|
|
fi
|
|
|
|
# Common ssh options for reverse tunnel
|
|
SSH_OPTS=(
|
|
-N
|
|
-o ServerAliveInterval=30
|
|
-o ServerAliveCountMax=3
|
|
-o ExitOnForwardFailure=yes
|
|
-o StrictHostKeyChecking=accept-new
|
|
-o UserKnownHostsFile="$LARRY_HOME/known_hosts"
|
|
)
|
|
|
|
run_hop() {
|
|
if [ -z "$LARRY_HOP_HOST" ] || [ -z "$LARRY_HOP_USER" ]; then
|
|
err "hop mode needs LARRY_HOP_HOST and LARRY_HOP_USER (or --hop=user@host)"
|
|
exit 2
|
|
fi
|
|
say "opening reverse tunnel: ${LARRY_HOP_USER}@${LARRY_HOP_HOST}:${LARRY_HOP_PORT}"
|
|
say " binds remote port ${LARRY_HOP_BIND} -> localhost:${LARRY_LOCAL_PORT}"
|
|
while true; do
|
|
: > "$LARRY_HOME/tunnel.log"
|
|
# shellcheck disable=SC2086
|
|
ssh $KEY_FLAG \
|
|
"${SSH_OPTS[@]}" \
|
|
-p "$LARRY_HOP_PORT" \
|
|
-R "${LARRY_HOP_BIND}:localhost:${LARRY_LOCAL_PORT}" \
|
|
"${LARRY_HOP_USER}@${LARRY_HOP_HOST}" \
|
|
2> "$LARRY_HOME/tunnel.log" &
|
|
local pid=$!
|
|
echo "$pid" > "$LARRY_HOME/tunnel.pid"
|
|
printf '%s:%s\n' "$LARRY_HOP_HOST" "$LARRY_HOP_BIND" > "$LARRY_HOME/tunnel.url"
|
|
say "ssh PID $pid — dial from home: ssh -p ${LARRY_HOP_BIND} <local-user>@${LARRY_HOP_HOST}"
|
|
wait "$pid"
|
|
rc=$?
|
|
warn "tunnel exited (rc=$rc) — reconnecting in 5s"
|
|
sleep 5
|
|
done
|
|
}
|
|
|
|
run_serveo() {
|
|
say "opening serveo.net reverse tunnel (no account needed)"
|
|
say " binds an auto-assigned port -> localhost:${LARRY_LOCAL_PORT}"
|
|
say " serveo.net is third-party; do NOT use for sensitive sessions"
|
|
while true; do
|
|
: > "$LARRY_HOME/tunnel.log"
|
|
# serveo prints assignment on stderr/stdout — we capture both
|
|
ssh \
|
|
"${SSH_OPTS[@]}" \
|
|
-R "0:localhost:${LARRY_LOCAL_PORT}" \
|
|
serveo.net \
|
|
> "$LARRY_HOME/tunnel.log" 2>&1 &
|
|
local pid=$!
|
|
echo "$pid" > "$LARRY_HOME/tunnel.pid"
|
|
|
|
# Try to extract the forwarded URL
|
|
local attempts=0 url=""
|
|
while [ "$attempts" -lt 30 ] && [ -z "$url" ]; do
|
|
sleep 1
|
|
attempts=$((attempts + 1))
|
|
url=$(grep -Eo 'Forwarding tcp from [^ ]+|tcp://[^ ]+|serveo\.net:[0-9]+' \
|
|
"$LARRY_HOME/tunnel.log" 2>/dev/null | head -1)
|
|
done
|
|
if [ -n "$url" ]; then
|
|
echo "$url" > "$LARRY_HOME/tunnel.url"
|
|
say "public: $url"
|
|
say "dial from home: ssh -p <PORT> <local-user>@serveo.net (PORT from URL above)"
|
|
else
|
|
warn "could not read serveo.net assignment — check $LARRY_HOME/tunnel.log"
|
|
fi
|
|
wait "$pid"
|
|
rc=$?
|
|
warn "tunnel exited (rc=$rc) — reconnecting in 5s"
|
|
sleep 5
|
|
done
|
|
}
|
|
|
|
trap 'kill $(cat "$LARRY_HOME/tunnel.pid" 2>/dev/null) 2>/dev/null; rm -f "$LARRY_HOME/tunnel.pid" "$LARRY_HOME/tunnel.url"; exit 0' INT TERM
|
|
|
|
case "$MODE" in
|
|
hop) run_hop ;;
|
|
serveo) run_serveo ;;
|
|
*) err "unknown mode: $MODE"; exit 2 ;;
|
|
esac
|