#!/bin/sh # OpenTunnel host script, version dev. https://opentunnel.sh # # POSIX shim: this file may be piped into any /bin/sh. It writes the bash # body below into a private temp directory and re-executes it with bash. set -eu if ! command -v bash >/dev/null 2>&1; then echo "[opentunnel] error: bash is required" >&2 exit 1 fi OPENTUNNEL_WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/opentunnel-host.XXXXXX") || exit 1 chmod 700 "$OPENTUNNEL_WORK_DIR" export OPENTUNNEL_WORK_DIR cat >"$OPENTUNNEL_WORK_DIR/self.sh" <<'OPENTUNNEL_BODY_EOF' #!/usr/bin/env bash # OpenTunnel host script. Served at https://beta.opentunnel.sh # # curl -fsSL https://beta.opentunnel.sh | sh # # Opens a temporary command tunnel from this machine to one coding agent on # another machine, prints the prompt to paste into that agent, and ends on # Ctrl-C, on inactivity, or when the optional hard limit is reached. # # build/embed.sh replaces the "# @@...@@" marker lines below at release time. set -euo pipefail VERSION="${VERSION:-dev}" VERSION=dev BASE_URL="${OPENTUNNEL_BASE_URL:-https://beta.opentunnel.sh}" BASE_URL="${OPENTUNNEL_BASE_URL:-https://beta.opentunnel.sh}" # Pinned per-target sha256 of the tailcat binary. Empty in a development # checkout, filled in by build/embed.sh for released scripts. ot_expected_sha256() { case "$1" in *) printf '' ;; esac } ot_expected_sha256() { case "$1" in darwin_amd64) printf 35712ac3a6bf3c4e47611005303f5d5c5cacae3bcf52f168faf6933ceaa26d75 ;; darwin_arm64) printf a117ee0c052860354fa4d1afc7e39a265e0886a78289d546987d557ce0045e79 ;; linux_amd64) printf c08c81529a9ac0986394a6dd3e2ac755841507c05c4a3b3aa001d7dab81e754d ;; linux_arm64) printf 3ae6b9226022eb5f4c198f895098a0236ea368bb03fa6f4afa42641ce7ae87ef ;; *) printf '' ;; esac } # Writes the ForceCommand wrapper to $WORK/ot-exec.sh. In a development # checkout the sibling script is used; build/embed.sh replaces this with an # inline copy so the served script is self-contained. ot_write_exec_wrapper() { local src src="$(dirname -- "$0")/ot-exec.sh" if [ ! -f "$src" ]; then ot_die "ot-exec.sh not found next to $0 (development checkout only)" fi cat "$src" >"$WORK/ot-exec.sh" } ot_write_exec_wrapper() { cat >"$WORK/ot-exec.sh" <<'OPENTUNNEL_OT_EXEC_EOF' #!/usr/bin/env bash # OpenTunnel ForceCommand wrapper. # # tailcat runs this once per SSH session on the host, as the host user, with # the client's requested command in $SSH_ORIGINAL_COMMAND. It refuses # interactive shells, records session activity for the supervisor, writes the # audit line, and runs the command in the session working directory. # # Inputs: $SSH_ORIGINAL_COMMAND, $TAILCAT_PEER_KEY, $TAILCAT_REMOTE_ADDR. # State: $OPENTUNNEL_WORK from the env file written next to this script. set -uo pipefail ot_dir=$(unset CDPATH && cd -- "$(dirname -- "$0")" && pwd) # shellcheck disable=SC1091 . "$ot_dir/env" : "${OPENTUNNEL_WORK:=$ot_dir}" : "${OPENTUNNEL_CWD:=$HOME}" command_line=${SSH_ORIGINAL_COMMAND:-} if [ -z "$command_line" ]; then echo "opentunnel: interactive shells are disabled; pass a command" >&2 exit 2 fi ot_now() { date -u +%Y-%m-%dT%H:%M:%SZ } # The supervisor reads activity as epoch seconds; write it atomically so a # concurrent read never sees a half-written file. ot_touch_activity() { date +%s >"$OPENTUNNEL_WORK/activity.$$" 2>/dev/null || return 0 mv -f "$OPENTUNNEL_WORK/activity.$$" "$OPENTUNNEL_WORK/activity" 2>/dev/null || return 0 } mkdir -p "$OPENTUNNEL_WORK/sessions" 2>/dev/null || true : >"$OPENTUNNEL_WORK/sessions/$$" 2>/dev/null || true ot_touch_activity trap 'rm -f "$OPENTUNNEL_WORK/sessions/$$" 2>/dev/null; ot_touch_activity' EXIT # Audit the command line only, never the payload piped through stdin. audit_command=$command_line audit_command=${audit_command//\\/\\\\} audit_command=${audit_command//$'\n'/\\n} audit_command=${audit_command//$'\r'/\\r} audit_command=${audit_command//$'\t'/\\t} printf '%s\t%s\t%s\t%s\n' \ "$(ot_now)" \ "${TAILCAT_PEER_KEY:-unknown}" \ "${TAILCAT_REMOTE_ADDR:-unknown}" \ "$audit_command" >>"$OPENTUNNEL_WORK/audit.log" 2>/dev/null || true cd "$OPENTUNNEL_CWD" 2>/dev/null || cd "$HOME" || exit 1 # Not exec: the EXIT trap has to run so the supervisor sees the session end. bash -lc "$command_line" rc=$? printf '%s\texit=%s\n' "$(ot_now)" "$rc" >>"$OPENTUNNEL_WORK/audit.log" 2>/dev/null || true exit "$rc" OPENTUNNEL_OT_EXEC_EOF } WORK="" SERVER_PID="" CLAIM_PID="" ENDED_REASON="" ot_log() { printf '[opentunnel] %s\n' "$*" >&2 } ot_die() { printf '[opentunnel] error: %s\n' "$*" >&2 exit 1 } ot_is_uint() { case "$1" in '' | *[!0-9]*) return 1 ;; *) return 0 ;; esac } # ot_uint_env NAME DEFAULT ot_uint_env() { local name=$1 default=$2 value eval "value=\${$name:-}" if [ -z "$value" ]; then printf '%s' "$default" return 0 fi if ! ot_is_uint "$value"; then ot_die "$name must be a non-negative whole number of seconds, got: $value" fi printf '%s' "$value" } ot_minutes() { printf '%s' "$((($1 + 59) / 60))" } ot_require_tools() { local missing=0 tool for tool in curl mktemp uname date; do if ! command -v "$tool" >/dev/null 2>&1; then printf '[opentunnel] error: missing required tool: %s\n' "$tool" >&2 missing=1 fi done if ! command -v sha256sum >/dev/null 2>&1 && ! command -v shasum >/dev/null 2>&1; then printf '[opentunnel] error: missing required tool: sha256sum or shasum\n' >&2 missing=1 fi [ "$missing" -eq 0 ] || exit 1 } ot_detect_platform() { case "$(uname -s)" in Linux) OS=linux ;; Darwin) OS=darwin ;; *) ot_die "unsupported OS: $(uname -s). OpenTunnel supports Linux and macOS." ;; esac case "$(uname -m)" in x86_64 | amd64) ARCH=amd64 ;; aarch64 | arm64) ARCH=arm64 ;; *) ot_die "unsupported architecture: $(uname -m). OpenTunnel supports amd64 and arm64." ;; esac } ot_sha256_file() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' else shasum -a 256 "$1" | awk '{print $1}' fi } ot_download_tailcat() { local url="$BASE_URL/bin/$VERSION/tailcat_${OS}_${ARCH}" local expected actual ot_log "downloading tailcat ($VERSION, ${OS}/${ARCH})" if [ -n "${OPENTUNNEL_ALLOW_HTTP:-}" ]; then curl -fsSL --retry 3 -o "$WORK/tailcat" "$url" || ot_die "download failed: $url" else curl -fsSL --proto '=https' --tlsv1.2 --retry 3 -o "$WORK/tailcat" "$url" || ot_die "download failed: $url" fi expected=$(ot_expected_sha256 "${OS}_${ARCH}") if [ -z "$expected" ]; then ot_log "warning: no pinned checksum in this script ($VERSION); skipping verification" else actual=$(ot_sha256_file "$WORK/tailcat") if [ "$actual" != "$expected" ]; then rm -f "$WORK/tailcat" ot_die "checksum mismatch for tailcat_${OS}_${ARCH} (expected $expected, got $actual)" fi fi chmod 700 "$WORK/tailcat" TC="$WORK/tailcat" } ot_is_addr() { case "$1" in tc*) ;; *) return 1 ;; esac # shellcheck disable=SC2254 case "$1" in *[!A-Za-z0-9_-]*) return 1 ;; esac [ "${#1}" -ge 42 ] } ot_is_nodekey() { local hex=${1#nodekey:} [ "$hex" != "$1" ] || return 1 [ "${#hex}" -eq 64 ] || return 1 case "$hex" in *[!0-9a-f]*) return 1 ;; esac return 0 } ot_sanitize() { printf '%s' "$1" | tr -c '[:print:]' '?' | cut -c1-40 } ot_active_sessions() { local count=0 marker pid [ -d "$WORK/sessions" ] || { printf '0' return 0 } for marker in "$WORK/sessions"/*; do [ -e "$marker" ] || continue pid=${marker##*/} if kill -0 "$pid" 2>/dev/null; then count=$((count + 1)) else rm -f "$marker" 2>/dev/null || true fi done printf '%s' "$count" } ot_command_count() { [ -f "$WORK/audit.log" ] || { printf '0' return 0 } awk -F'\t' 'NF >= 4 {n++} END {printf "%d", n+0}' "$WORK/audit.log" } ot_last_activity() { local value="" [ -f "$WORK/activity" ] && value=$(cat "$WORK/activity" 2>/dev/null || true) if ot_is_uint "${value:-}"; then printf '%s' "$value" else printf '%s' "$START" fi } ot_stop_process() { local pid=$1 [ -n "$pid" ] || return 0 kill -0 "$pid" 2>/dev/null || return 0 pkill -TERM -P "$pid" 2>/dev/null || true kill -TERM "$pid" 2>/dev/null || true for _ in 1 2 3 4 5; do kill -0 "$pid" 2>/dev/null || return 0 sleep 1 done pkill -KILL -P "$pid" 2>/dev/null || true kill -KILL "$pid" 2>/dev/null || true return 0 } ot_keep_audit() { local stamp target [ "${OPENTUNNEL_KEEP_AUDIT:-}" = "1" ] || return 0 [ -n "$WORK" ] && [ -f "$WORK/audit.log" ] || return 0 stamp=$(date -u +%Y%m%dT%H%M%SZ) target="$LAUNCH_PWD/opentunnel-audit-$stamp.log" if cp "$WORK/audit.log" "$target" 2>/dev/null; then ot_log "audit log kept at $target" else ot_log "warning: could not write the audit log to $target" fi } ot_cleanup() { local rc=$? trap - EXIT INT TERM ot_stop_process "${CLAIM_PID:-}" ot_stop_process "${SERVER_PID:-}" ot_keep_audit if [ -n "$WORK" ] && [ -d "$WORK" ]; then rm -rf "$WORK" fi exit "$rc" } ot_print_prompt() { local idle_min ttl_min ttl_clause transfer_hint idle_min=$(ot_minutes "$IDLE") ttl_clause="" if [ "$TTL" -gt 0 ]; then ttl_min=$(ot_minutes "$TTL") ttl_clause=" and after $ttl_min minutes in total" fi transfer_hint=" /remote --put /remote --get rsync -av -e /ssh ./dir opentunnel: scp -O -S /ssh opentunnel:" printf '%s\n' "────────────────────────────────────────────────────────────────────────" cat </remote 'uname -a && pwd' $transfer_hint Remote host: $USER_NAME@$HOST_NAME ($OS_DESC), working directory $CWD. Rules: - Commands run non-interactively through SSH as that user with their login environment. No TTY, no interactive programs, no editors, no sudo prompts. Several commands may run at the same time. - Always ask me to confirm before running anything destructive or irreversible. - The session ends after $idle_min minutes without a command$ttl_clause. A connection error means it has ended: report that to me and stop; do not retry in a loop. - Do not copy the address into shared logs, tickets, summaries, or long-lived notes. Do not persist the helper or its keys anywhere else. When finished, run \`/remote --close\`. Task: PROMPT printf '%s\n' "────────────────────────────────────────────────────────────────────────" } ot_phase_one() { local waited=0 claim ot_log "waiting for the agent to claim (timeout $(ot_minutes "$CLAIM_TIMEOUT")m). Ctrl-C to abort." "$TC" --key="$WORK/session.private.json" serve >"$WORK/claim.txt" 2>"$WORK/phase1.log" & CLAIM_PID=$! while kill -0 "$CLAIM_PID" 2>/dev/null; do if [ "$waited" -ge "$CLAIM_TIMEOUT" ]; then ot_stop_process "$CLAIM_PID" CLAIM_PID="" ot_die "no agent claimed the tunnel within $(ot_minutes "$CLAIM_TIMEOUT") minutes" fi sleep 1 waited=$((waited + 1)) done wait "$CLAIM_PID" 2>/dev/null || true CLAIM_PID="" claim=$(tr -d ' \t\r\n' <"$WORK/claim.txt" 2>/dev/null || true) if ! ot_is_nodekey "$claim"; then ot_log "claim rejected: $(ot_sanitize "$claim")" ot_die "the tunnel was claimed with an invalid key. Run the command again for a fresh address." fi PEER=$claim ot_log "claimed by nodekey:$(printf '%s' "${claim#nodekey:}" | cut -c1-12)…" } ot_phase_two() { local waited=0 "$TC" --key="$WORK/session.private.json" serve --allow="$PEER" no-auth-ssh -- "$WORK/ot-exec.sh" \ >"$WORK/server.out" 2>"$WORK/server.log" & SERVER_PID=$! while :; do if grep -q 'Server listening' "$WORK/server.log" 2>/dev/null; then break fi if ! kill -0 "$SERVER_PID" 2>/dev/null; then ot_log "$(tail -n 3 "$WORK/server.log" 2>/dev/null || true)" ot_die "the tunnel server exited during startup" fi if [ "$waited" -ge 30 ]; then ot_die "the tunnel server did not start within 30 seconds" fi sleep 1 waited=$((waited + 1)) done if [ "$TTL" -gt 0 ]; then ot_log "active. idle timeout $(ot_minutes "$IDLE")m, hard limit $(ot_minutes "$TTL")m, cwd $CWD" else ot_log "active. idle timeout $(ot_minutes "$IDLE")m, cwd $CWD" fi } ot_end_session() { ENDED_REASON=$1 ot_stop_process "$SERVER_PID" SERVER_PID="" ot_log "session ended ($ENDED_REASON). audit log was $WORK/audit.log (removed with the temp dir)" } ot_supervise() { local now active last heartbeat_at expires_in heartbeat_at=$(date +%s) while :; do if ! kill -0 "$SERVER_PID" 2>/dev/null; then ot_log "$(tail -n 3 "$WORK/server.log" 2>/dev/null || true)" SERVER_PID="" ot_die "the tunnel server exited unexpectedly" fi now=$(date +%s) active=$(ot_active_sessions) last=$(ot_last_activity) if [ "$TTL" -gt 0 ] && [ "$((now - START))" -ge "$TTL" ]; then ot_end_session ttl return 0 fi if [ "$active" -eq 0 ] && [ "$((now - last))" -ge "$IDLE" ]; then ot_end_session idle return 0 fi if [ "$((now - heartbeat_at))" -ge 60 ]; then heartbeat_at=$now if [ "$TTL" -gt 0 ]; then expires_in=$(ot_minutes "$((TTL - (now - START)))") ot_log "alive, sessions=$active, commands=$(ot_command_count), expires in ${expires_in}m" else ot_log "alive, sessions=$active, commands=$(ot_command_count)" fi fi sleep 2 done } main() { # The temp directory and the trap come first: the POSIX shim may already # have created the directory, and every later failure has to remove it. if [ -n "${OPENTUNNEL_WORK_DIR:-}" ] && [ -d "${OPENTUNNEL_WORK_DIR:-}" ]; then WORK="$OPENTUNNEL_WORK_DIR" else WORK=$(mktemp -d "${TMPDIR:-/tmp}/opentunnel-host.XXXXXX") fi chmod 700 "$WORK" LAUNCH_PWD=$PWD trap ot_cleanup EXIT INT TERM ot_require_tools ot_detect_platform CLAIM_TIMEOUT=$(ot_uint_env OPENTUNNEL_CLAIM_TIMEOUT 300) IDLE=$(ot_uint_env OPENTUNNEL_IDLE 1800) TTL=$(ot_uint_env OPENTUNNEL_TTL 0) CWD="${OPENTUNNEL_CWD:-$PWD}" [ -d "$CWD" ] || ot_die "OPENTUNNEL_CWD is not a directory: $CWD" ot_download_tailcat ot_write_exec_wrapper chmod 700 "$WORK/ot-exec.sh" { printf 'OPENTUNNEL_WORK=%q\n' "$WORK" printf 'OPENTUNNEL_CWD=%q\n' "$CWD" } >"$WORK/env" mkdir -p "$WORK/sessions" ADDR=$("$TC" genkey --key="$WORK/session.private.json" --embed-derp-map 2>"$WORK/genkey.log") || ot_die "could not generate the session key: $(tail -n 2 "$WORK/genkey.log" 2>/dev/null || true)" ot_is_addr "$ADDR" || ot_die "unexpected tunnel address from tailcat" START=$(date +%s) date +%s >"$WORK/activity" USER_NAME="${USER:-$(id -un 2>/dev/null || echo unknown)}" HOST_NAME="$(hostname 2>/dev/null || uname -n)" OS_DESC="$(uname -srm)" ot_print_prompt ot_phase_one ot_phase_two ot_supervise } # test/unit sources this script to exercise the functions above. if [ -z "${OPENTUNNEL_SOURCE_ONLY:-}" ]; then main "$@" fi OPENTUNNEL_BODY_EOF exec bash "$OPENTUNNEL_WORK_DIR/self.sh" "$@"