diff --git a/README.md b/README.md index eb013b7..0b31464 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,7 @@ Config file: `~/.config/ds/share-upterm.conf` (env vars `DS_UPTERM_*` override): | `authorized-keys` | `DS_UPTERM_AUTHORIZED_KEYS` | Restrict access via authorized_keys | | `push` | `DS_UPTERM_PUSH` | `user@host` — push share info via SCP | | `proxy-session` | `DS_UPTERM_PROXY_SESSION` | tmux session name for the share proxy (default: `_share`) | +| `share-ttl` | `DS_UPTERM_SHARE_TTL` | seconds before share auto-expires (default: `3600`, set to `0` to disable) | See `examples/share-upterm.conf` for a template. @@ -144,6 +145,10 @@ When sharing, connecting clients are placed into a dedicated proxy tmux session - Clients can interact with your sessions non-destructively via `tmux capture-pane` (read) and `tmux send-keys` (write) - The proxy session is created automatically on `--share` and destroyed on `--unshare` +#### Share TTL + +Shares expire automatically after `share-ttl` seconds (default: 1 hour). When the timer fires, `ds --unshare` is called automatically. Running `ds --share` on an already-shared session resets the timer. Set `share-ttl = 0` to disable auto-expiry. + ## Shell Integration Add to `~/.bashrc`: diff --git a/examples/share-upterm.conf b/examples/share-upterm.conf index c325dda..f901ee5 100644 --- a/examples/share-upterm.conf +++ b/examples/share-upterm.conf @@ -7,3 +7,4 @@ # github-user=myghuser # authorized-keys=~/.ssh/upterm_authorized_keys # push=user@remotehost +# share-ttl=3600 diff --git a/lib/plugins/share-upterm.sh b/lib/plugins/share-upterm.sh index 95c5e4e..6643ee4 100644 --- a/lib/plugins/share-upterm.sh +++ b/lib/plugins/share-upterm.sh @@ -20,6 +20,9 @@ # (default: _share). A dedicated background session is # created and shared so connecting clients get a shell # without mirroring into the user's active session. +# share-ttl seconds before the share automatically expires (default: +# 3600). Set to 0 to disable auto-expiry. Calling +# `ds --share` resets the timer. # # Env vars (all optional, override config): # DS_UPTERM_HOST maps to server @@ -30,6 +33,7 @@ # DS_UPTERM_PUSH maps to push # DS_UPTERM_PID_FILE override PID file path # DS_UPTERM_PROXY_SESSION maps to proxy-session +# DS_UPTERM_SHARE_TTL maps to share-ttl DS_UPTERM_HOST="${DS_UPTERM_HOST:-}" DS_UPTERM_PRIVATE_KEY="${DS_UPTERM_PRIVATE_KEY:-}" @@ -39,6 +43,7 @@ DS_UPTERM_AUTHORIZED_KEYS="${DS_UPTERM_AUTHORIZED_KEYS:-}" DS_UPTERM_PID_FILE="${DS_UPTERM_PID_FILE:-}" DS_UPTERM_PUSH="${DS_UPTERM_PUSH:-}" DS_UPTERM_PROXY_SESSION="${DS_UPTERM_PROXY_SESSION:-}" +DS_UPTERM_SHARE_TTL="${DS_UPTERM_SHARE_TTL:-}" _UPTERM_REMOTE_STATE_DIR=".local/state/ds" @@ -64,6 +69,10 @@ _upterm_log_file() { echo "$(_state_file_prefix).upterm.log" } +_upterm_ttl_pid_file() { + echo "$(_state_file_prefix).upterm.ttl.pid" +} + # --- Internal helpers --- _upterm_resolve_key() { @@ -172,10 +181,71 @@ _share_load_config() { authorized-keys) [[ -z "$DS_UPTERM_AUTHORIZED_KEYS" ]] && DS_UPTERM_AUTHORIZED_KEYS="${val/#\~/$HOME}" ;; push) [[ -z "$DS_UPTERM_PUSH" ]] && DS_UPTERM_PUSH="$val" ;; proxy-session) [[ -z "$DS_UPTERM_PROXY_SESSION" ]] && DS_UPTERM_PROXY_SESSION="$val" ;; + share-ttl) [[ -z "$DS_UPTERM_SHARE_TTL" ]] && DS_UPTERM_SHARE_TTL="$val" ;; esac done < "$conf" || true } +# Cancel any running TTL expiry watcher. +_upterm_cancel_ttl_watcher() { + local ttl_pid_file + ttl_pid_file=$(_upterm_ttl_pid_file) + if [[ -f "$ttl_pid_file" ]]; then + local pid + pid=$(cat "$ttl_pid_file" 2>/dev/null || true) + if [[ "$pid" =~ ^[0-9]+$ ]]; then + # SIGTERM the process group (kills sleep child too); fall back to + # killing just the PID if the group signal fails. + kill -- -"$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true + fi + rm -f "$ttl_pid_file" + fi +} + +# Spawn a background watcher that calls `ds --unshare` after $ttl seconds. +# Stores the watcher's PID in the TTL pid file for later cancellation. +_upterm_start_ttl_watcher() { + local ttl="$1" + local session="$2" + local ttl_pid_file + ttl_pid_file=$(_upterm_ttl_pid_file) + + _upterm_cancel_ttl_watcher + + # Resolve the ds binary path before forking. + local ds_bin + ds_bin=$(command -v ds 2>/dev/null || true) + [[ -z "$ds_bin" ]] && ds_bin="ds" + + local esc_bin esc_session + esc_bin=$(printf '%q' "$ds_bin") + esc_session=$(printf '%q' "$session") + + # setsid gives the subshell its own process group so killing the group + # also kills the sleep child. Fall back to a plain subshell if unavailable. + if command -v setsid >/dev/null 2>&1; then + setsid bash -c "sleep ${ttl} && ${esc_bin} --unshare ${esc_session}" >/dev/null 2>&1 & + else + ( sleep "$ttl" && "$ds_bin" --unshare "$session" ) >/dev/null 2>&1 & + fi + local watcher_pid=$! + local old_umask + old_umask=$(umask) + umask 077 + echo "$watcher_pid" > "$ttl_pid_file" + umask "$old_umask" +} + +# Start TTL watcher if share-ttl > 0; print expiry message. No-op if TTL=0. +_upterm_maybe_start_ttl_watcher() { + local session="$1" + local ttl="${DS_UPTERM_SHARE_TTL:-3600}" + if [[ "$ttl" =~ ^[0-9]+$ && "$ttl" -gt 0 ]]; then + _upterm_start_ttl_watcher "$ttl" "$session" + echo "ds: share will auto-expire in ${ttl}s (run 'ds --share' to reset)" + fi +} + _share_running() { local pid_file pid_file=$(_upterm_pid_file) @@ -203,7 +273,14 @@ _share_start() { local current_session current_session=$(_share_current_session) if [[ "$current_session" == "$session" ]]; then - echo "ds: already sharing session '$session'" + # Reset the TTL timer if configured. + local ttl="${DS_UPTERM_SHARE_TTL:-3600}" + if [[ "$ttl" =~ ^[0-9]+$ && "$ttl" -gt 0 ]]; then + _upterm_start_ttl_watcher "$ttl" "$session" + echo "ds: already sharing session '$session' (TTL reset to ${ttl}s)" + else + echo "ds: already sharing session '$session'" + fi _share_info return 0 else @@ -391,6 +468,7 @@ _share_start() { _write_share_info "$content" printf '%s\n' "$content" _upterm_push_share_info "$session" + _upterm_maybe_start_ttl_watcher "$session" return 0 fi echo "ds: timed out waiting for upterm share info (admin socket unavailable; log: $log_file)" >&2 @@ -404,6 +482,7 @@ _share_start() { _write_share_info "$content" printf '%s\n' "$content" _upterm_push_share_info "$session" + _upterm_maybe_start_ttl_watcher "$session" return 0 fi sleep 0.5 @@ -413,6 +492,7 @@ _share_start() { _write_share_info "$content" printf '%s\n' "$content" _upterm_push_share_info "$session" + _upterm_maybe_start_ttl_watcher "$session" return 0 fi @@ -439,6 +519,7 @@ _share_stop() { fi rm -f "$pid_file" "$DS_SHARE_INFO_FILE" \ "$(_upterm_admin_file)" "$(_upterm_session_file)" "$(_upterm_log_file)" + _upterm_cancel_ttl_watcher _upterm_unpush_share_info "$session" # Kill the proxy session if it exists. local proxy_session="${DS_UPTERM_PROXY_SESSION:-_share}" diff --git a/tests/ds-test b/tests/ds-test index 582e2cc..b5c2347 100755 --- a/tests/ds-test +++ b/tests/ds-test @@ -357,6 +357,7 @@ _reset_tmux_log export MOCK_SESSIONS="ds" DS_SHARE_VIA="upterm" DS_UPTERM_GITHUB_USER="testuser" +DS_UPTERM_SHARE_TTL="0" # disable TTL for this test to avoid background watchers SESSION="ds" HOST="" # _do_share calls _share_start, which runs the full share flow with mock upterm @@ -469,13 +470,161 @@ _assert_eq "env override: github-user" "envuser" "$DS_UPTERM_GITHUB_USER" _assert_eq "env override: authorized-keys" "/env/authorized_keys" "$DS_UPTERM_AUTHORIZED_KEYS" _assert_eq "env override: push" "envuser@envhost" "$DS_UPTERM_PUSH" +# share-ttl config key loaded +cat > "$CONF_DIR/share-upterm.conf" <<'EOF' +server=myupterm.internal:2222 +share-ttl=1800 +EOF +DS_UPTERM_HOST="" +DS_UPTERM_SHARE_TTL="" +_share_load_config +_assert_eq "config: share-ttl" "1800" "$DS_UPTERM_SHARE_TTL" + +# Env var overrides share-ttl from config +DS_UPTERM_SHARE_TTL="600" +_share_load_config +_assert_eq "env override: share-ttl" "600" "$DS_UPTERM_SHARE_TTL" + # Missing config file is silently ignored rm -f "$CONF_DIR/share-upterm.conf" DS_UPTERM_HOST="" DS_UPTERM_PUSH="" +DS_UPTERM_SHARE_TTL="" _share_load_config _assert_eq "missing config: server empty" "" "$DS_UPTERM_HOST" _assert_eq "missing config: push empty" "" "$DS_UPTERM_PUSH" +_assert_eq "missing config: share-ttl empty" "" "$DS_UPTERM_SHARE_TTL" + +# --------------------------------------------------------------------------- +# Tests: Share TTL watcher +# --------------------------------------------------------------------------- + +echo "" +echo "=== Share TTL watcher ===" + +# Add a mock setsid that just runs its args directly (no new process group +# needed in tests — we just need to verify the watcher pid file is created +# and the watcher can be cancelled). +cat > "$MOCK_BIN/setsid" <<'MOCK' +#!/usr/bin/env bash +# Run in background so sleep doesn't block the test process. +"$@" & +MOCK +chmod +x "$MOCK_BIN/setsid" + +# Add a mock ds that records --unshare calls +DS_UNSHARE_LOG="$MOCK_BIN/ds-unshare.log" +cat > "$MOCK_BIN/ds" <> "${DS_UNSHARE_LOG}" +MOCK +chmod +x "$MOCK_BIN/ds" + +# --- _upterm_start_ttl_watcher creates a pid file --- +_reset_tmux_log +DS_UPTERM_PID_FILE="$(_upterm_pid_file)" +DS_UPTERM_SHARE_TTL="" +: > "$DS_UNSHARE_LOG" +_upterm_start_ttl_watcher 60 "ds" +ttl_pid_file=$(_upterm_ttl_pid_file) +_assert_file_exists "watcher: ttl pid file created" "$ttl_pid_file" +watcher_pid=$(cat "$ttl_pid_file" 2>/dev/null || true) +if [[ "$watcher_pid" =~ ^[0-9]+$ ]]; then + _pass "watcher: pid file contains numeric PID" +else + _fail "watcher: pid file contains non-numeric value: '$watcher_pid'" +fi +# Clean up — kill the watcher (it's sleeping for 60s) +_upterm_cancel_ttl_watcher +_assert_file_missing "watcher: cancel removes pid file" "$ttl_pid_file" +# Watcher process should be gone +if [[ "$watcher_pid" =~ ^[0-9]+$ ]] && kill -0 "$watcher_pid" 2>/dev/null; then + _fail "watcher: cancel killed process" +else + _pass "watcher: cancel killed process" +fi + +# --- _upterm_cancel_ttl_watcher is idempotent (no pid file) --- +_upterm_cancel_ttl_watcher +_assert_file_missing "cancel: idempotent when no pid file" "$(_upterm_ttl_pid_file)" +_pass "cancel: no error when pid file absent" + +# --- re-starting watcher cancels previous one --- +_upterm_start_ttl_watcher 60 "ds" +first_pid=$(cat "$(_upterm_ttl_pid_file)" 2>/dev/null || true) +_upterm_start_ttl_watcher 60 "ds" +second_pid=$(cat "$(_upterm_ttl_pid_file)" 2>/dev/null || true) +if [[ "$first_pid" != "$second_pid" ]]; then + _pass "watcher: re-start spawns new watcher" +else + _fail "watcher: re-start should spawn a new watcher PID" +fi +if [[ "$first_pid" =~ ^[0-9]+$ ]] && kill -0 "$first_pid" 2>/dev/null; then + _fail "watcher: re-start killed previous watcher" +else + _pass "watcher: re-start killed previous watcher" +fi +_upterm_cancel_ttl_watcher + +# --- TTL=0 disables the watcher --- +DS_UPTERM_SHARE_TTL="0" +export MOCK_SESSIONS="ds" +DS_UPTERM_GITHUB_USER="testuser" +DS_SHARE_INFO_FILE="$(_default_share_info_file)" +DS_UPTERM_PID_FILE="$(_upterm_pid_file)" +SESSION="ds" HOST="" +result=$(_do_share 2>&1) || true +_assert_file_missing "ttl=0: no ttl pid file created" "$(_upterm_ttl_pid_file)" +_assert_not_contains "ttl=0: no auto-expire message" "auto-expire" "$result" +# Clean up share state +_pid_f=$(_upterm_pid_file) +if [[ -f "$_pid_f" ]]; then + _pid=$(cat "$_pid_f" 2>/dev/null || true) + if [[ "$_pid" =~ ^[0-9]+$ ]]; then kill "$_pid" 2>/dev/null || true; fi + rm -f "$_pid_f" +fi +rm -f "$DS_SHARE_INFO_FILE" "$(_upterm_admin_file)" "$(_upterm_session_file)" "$(_upterm_log_file)" + +# --- TTL>0 creates watcher on successful share --- +# Note: _do_share must NOT be captured in $() because the mock upterm starts +# background jobs (the hosted_cmd loop) that would make the subshell hang. +# Use a temp file to capture output instead. +_share_out=$(_tmpdir)/share.out +DS_UPTERM_SHARE_TTL="3600" +DS_UPTERM_PRIVATE_KEY="$TEST_HOME/.ssh/test_upterm_key" +DS_UPTERM_KNOWN_HOSTS="" +DS_UPTERM_HOST="" +DS_UPTERM_AUTHORIZED_KEYS="" +DS_UPTERM_PUSH="" +export MOCK_SESSIONS="ds" +DS_UPTERM_GITHUB_USER="testuser" +DS_SHARE_INFO_FILE="$(_default_share_info_file)" +DS_UPTERM_PID_FILE="$(_upterm_pid_file)" +SESSION="ds" HOST="" +_do_share > "$_share_out" 2>&1 || true +result=$(cat "$_share_out") +_assert_file_exists "ttl>0: ttl pid file created on share" "$(_upterm_ttl_pid_file)" +_assert_contains "ttl>0: auto-expire message shown" "auto-expire" "$result" +share_watcher_pid=$(cat "$(_upterm_ttl_pid_file)" 2>/dev/null || true) + +# --- _share_stop cancels the watcher --- +SESSION="ds" HOST="" +_do_unshare 2>&1 || true +_assert_file_missing "unshare: ttl pid file removed" "$(_upterm_ttl_pid_file)" +if [[ "$share_watcher_pid" =~ ^[0-9]+$ ]] && kill -0 "$share_watcher_pid" 2>/dev/null; then + _fail "unshare: watcher process killed" +else + _pass "unshare: watcher process killed" +fi + +# --- TTL fires and calls ds --unshare --- +DS_UPTERM_SHARE_TTL="" +: > "$DS_UNSHARE_LOG" +_upterm_start_ttl_watcher 1 "myses" +sleep 2 +unshare_calls=$(cat "$DS_UNSHARE_LOG" 2>/dev/null || true) +_assert_contains "ttl fire: ds --unshare called after TTL" "--unshare myses" "$unshare_calls" +rm -f "$(_upterm_ttl_pid_file)" # Reset sharing vars for remaining tmux tests DS_SHARE_VIA="" @@ -485,6 +634,7 @@ DS_UPTERM_PID_FILE="" DS_UPTERM_GITHUB_USER="" DS_UPTERM_AUTHORIZED_KEYS="" DS_UPTERM_PUSH="" +DS_UPTERM_SHARE_TTL="" SESSION="" HOST=""