Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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`:
Expand Down
1 change: 1 addition & 0 deletions examples/share-upterm.conf
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
# github-user=myghuser
# authorized-keys=~/.ssh/upterm_authorized_keys
# push=user@remotehost
# share-ttl=3600
83 changes: 82 additions & 1 deletion lib/plugins/share-upterm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:-}"
Expand All @@ -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"

Expand All @@ -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() {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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}"
Expand Down
150 changes: 150 additions & 0 deletions tests/ds-test
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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" <<MOCK
#!/usr/bin/env bash
echo "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=""
Expand All @@ -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=""

Expand Down
Loading