Tunnel Sandbox #168
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# version | |
name: Tunnel Sandbox (Cloudflare | localhost.run | Tailscale | Tor) | |
on: | |
issues: | |
types: [opened, edited] | |
permissions: | |
contents: read | |
issues: write | |
concurrency: | |
group: ${{ github.workflow }}-${{ github.event.issue.number }} | |
cancel-in-progress: true | |
# Safer bash defaults everywhere | |
defaults: | |
run: | |
shell: bash | |
#shell: bash -euo pipefail {0} | |
env: | |
DEFAULT_PORT: "8080" | |
DEFAULT_MINUTES: "40" | |
STATUS_MARK: "<!-- TUNNEL_SANDBOX_STATUS -->" | |
MAX_CAP_MINUTES: "80" # hard cap for the main job & keepalive logic | |
DEBUG_WINDOW_MINUTES: "10" | |
jobs: | |
# ----------------------------- | |
# Parse user selections from issue | |
# ----------------------------- | |
parse: | |
name: Parse selections | |
if: >- | |
contains(join(github.event.issue.labels.*.name, ','), 'tunnel-sandbox') || | |
startsWith(github.event.issue.title, 'Tunnel Sandbox') || | |
contains(github.event.issue.body, '## Runner OS') | |
runs-on: ubuntu-latest | |
outputs: | |
os: ${{ steps.parse_body.outputs.os }} | |
tunnel: ${{ steps.parse_body.outputs.tunnel }} | |
port: ${{ steps.parse_body.outputs.port }} | |
minutes: ${{ steps.parse_body.outputs.minutes }} | |
on: ${{ steps.parse_body.outputs.on }} | |
steps: | |
- name: Checkout repository | |
uses: actions/checkout@v4 | |
- name: Parse issue body using script | |
id: parse_body | |
run: | | |
chmod +x ./.github/scripts/parse_issue_body.sh | |
printf '%s' "${{ github.event.issue.body }}" | \ | |
./.github/scripts/parse_issue_body.sh | \ | |
tee "$GITHUB_OUTPUT" | |
env: | |
DEFAULT_PORT: ${{ env.DEFAULT_PORT }} | |
DEFAULT_MINUTES: ${{ env.DEFAULT_MINUTES }} | |
MAX_CAP_MINUTES: ${{ env.MAX_CAP_MINUTES }} | |
- name: Upsert status comment (parsed) | |
uses: actions/github-script@v7 | |
with: | |
script: | | |
const mark = process.env.STATUS_MARK; | |
const cap = process.env.MAX_CAP_MINUTES; | |
const body = [ | |
mark, | |
"### Tunnel Sandbox · Status", | |
"", | |
"**Selections parsed** ✅", | |
`- Power: \`${{ steps.parse_body.outputs.on }}\``, | |
`- OS: \`${{ steps.parse_body.outputs.os }}\``, | |
`- Tunnel: \`${{ steps.parse_body.outputs.tunnel }}\``, | |
`- Port: \`${{ steps.parse_body.outputs.port }}\``, | |
`- Minutes: \`${{ steps.parse_body.outputs.minutes }}\` (hard cap: ${cap})`, | |
"", | |
"_Spinning up… this comment will update with the access method._", | |
"" | |
].join("\n"); | |
const { data: comments } = await github.rest.issues.listComments({ | |
owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, per_page: 100 | |
}); | |
const prev = comments.find(c => c.body && c.body.includes(mark)); | |
if (prev) { | |
await github.rest.issues.updateComment({ | |
owner: context.repo.owner, repo: context.repo.repo, comment_id: prev.id, body | |
}); | |
} else { | |
await github.rest.issues.createComment({ | |
owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, body | |
}); | |
} | |
# ----------------------------- | |
# If OFF, update status and exit | |
# ----------------------------- | |
off: | |
name: Power is OFF (no-op) | |
needs: parse | |
if: needs.parse.result == 'success' && needs.parse.outputs.on == 'false' | |
runs-on: ubuntu-latest | |
steps: | |
- name: Upsert status comment (OFF) | |
uses: actions/github-script@v7 | |
with: | |
script: | | |
const mark = process.env.STATUS_MARK; | |
const body = [ | |
mark, | |
"### Tunnel Sandbox · Status", | |
"", | |
"⏹️ **Switch is OFF** — not starting any runner.", | |
"", | |
"_Check the **ON** box and edit this issue to start again._", | |
"" | |
].join("\n"); | |
const { data: comments } = await github.rest.issues.listComments({ | |
owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, per_page: 100 | |
}); | |
const prev = comments.find(c => c.body && c.body.includes(mark)); | |
if (prev) { | |
await github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: prev.id, body }); | |
} else { | |
await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, body }); | |
} | |
# ----------------------------- | |
# Main run: server + chosen tunnel (only when ON) | |
# ----------------------------- | |
run: | |
name: Hello World + Tunnel | |
needs: parse | |
if: needs.parse.result == 'success' && needs.parse.outputs.on == 'true' | |
runs-on: ${{ needs.parse.outputs.os }} | |
timeout-minutes: 40 | |
env: | |
PORT: ${{ needs.parse.outputs.port }} | |
KEEP_MINUTES: ${{ needs.parse.outputs.minutes }} | |
TUNNEL: ${{ needs.parse.outputs.tunnel }} | |
TS_HOSTNAME: "gh-runner-${{ github.run_id }}" | |
steps: | |
- name: Show environment | |
run: | | |
echo "Runner OS: $RUNNER_OS" | |
echo "PORT=$PORT" | |
echo "KEEP_MINUTES=$KEEP_MINUTES (hard cap ${MAX_CAP_MINUTES})" | |
echo "TUNNEL=$TUNNEL" | |
- name: Install basics (Linux) | |
if: runner.os == 'Linux' | |
run: | | |
sudo apt-get update -y | |
sudo apt-get install -y jq curl openssh-client | |
- name: Install basics (macOS) | |
if: runner.os == 'macOS' | |
run: | | |
brew install jq openssh | |
- name: Install basics (Windows) | |
if: runner.os == 'Windows' | |
shell: powershell | |
run: | | |
# Check if jq is installed | |
if (-not (Get-Command jq -ErrorAction SilentlyContinue)) { | |
Write-Output "jq not found, installing via Chocolatey..." | |
choco install jq -y | |
} else { | |
Write-Output "jq is already installed." | |
} | |
- name: Setup Node | |
uses: actions/setup-node@v4 | |
with: | |
node-version: '20' | |
- name: Create Hello World server | |
run: | | |
cat > server.js <<'JS' | |
const http = require('http'); | |
const port = process.env.PORT || 8080; | |
const server = http.createServer((req, res) => { | |
res.writeHead(200, {'content-type':'text/plain'}); | |
res.end(`Hello from ${process.platform} @ ${new Date().toISOString()} on port ${port}\n`); | |
}); | |
server.listen(port, '0.0.0.0', () => console.log(`Listening on ${port}`)); | |
JS | |
- name: Start server & verify port (Windows) | |
if: runner.os == 'Windows' | |
shell: powershell | |
run: | | |
Start-Process -FilePath "node.exe" -ArgumentList "server.js" | |
for ($i = 1; $i -le 60; $i++) { | |
if (curl.exe localhost:$env:PORT) { | |
Write-Output "Port $env:PORT is open." | |
break | |
} | |
if ($i -eq 60) { | |
Write-Output "Server did not start on port $env:PORT in time." | |
exit 1 | |
} | |
Start-Sleep -Seconds 1 | |
} | |
- name: Start server & verify port (macOS/Linux) | |
if: runner.os != 'Windows' | |
shell: bash | |
run: | | |
nohup node server.js > server-both.log 2>&1 & | |
for i in {1..60}; do | |
if (echo > /dev/tcp/127.0.0.1/$PORT) >/dev/null 2>&1; then | |
echo "Port $PORT is open." | |
break | |
fi | |
if [ $i -eq 60 ]; then | |
echo "Server did not start on port $PORT in time." | |
exit 1 | |
fi | |
sleep 1 | |
done | |
# ------------------ Cloudflare Tunnel ------------------ | |
- name: Start Cloudflare Tunnel | |
if: env.TUNNEL == 'cloudflare' | |
# This part of the workflow is very solid. Leaving as is. | |
run: | | |
# Installation | |
if [[ "$RUNNER_OS" == "Linux" ]]; then | |
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb -o cf.deb && sudo dpkg -i cf.deb | |
elif [[ "$RUNNER_OS" == "macOS" ]]; then | |
brew install cloudflared | |
elif [[ "$RUNNER_OS" == "Windows" ]]; then | |
# Using powershell for the windows part of this bash script | |
powershell -Command "choco install cloudflared -y" | |
fi | |
cloudflared --version | |
# Startup and URL scraping | |
(cloudflared tunnel --url "http://127.0.0.1:${PORT}" --no-autoupdate > cloudflared.log 2>&1) & | |
for i in {1..60}; do | |
URL="$(grep -Eo 'https://[-a-z0-9]+\.trycloudflare\.com' cloudflared.log | tail -n1 || true)" | |
[ -n "$URL" ] && break | |
sleep 1 | |
done | |
if [ -z "$URL" ]; then echo "Failed to obtain Cloudflare URL"; cat cloudflared.log; exit 1; fi | |
echo "PUBLIC_URL=${URL}" >> $GITHUB_ENV | |
echo "ACCESS_KIND=browser" >> $GITHUB_ENV | |
echo "ACCESS_VALUE=${URL}" >> $GITHUB_ENV | |
echo "ACCESS_NOTE=Open in a regular browser." >> $GITHUB_ENV | |
# ------------------ localhost.run (lhr) ------------------ | |
- name: Start localhost.run | |
if: env.TUNNEL == 'localhostrun' | |
# This is a solid, self-contained script. Leaving as is. | |
run: | | |
# ... (LHR script remains unchanged) | |
LOG_JSON="${HOME}/localhostrun.jsonl"; : > "$LOG_JSON" | |
if command -v stdbuf >/dev/null 2>&1; then STD="stdbuf -o0 -e0"; else STD=""; fi | |
SSH_OPTS=(-o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o TCPKeepAlive=yes) | |
$STD ssh "${SSH_OPTS[@]}" -v -f -R 80:127.0.0.1:${PORT} [email protected] -- --output json &> "$LOG_JSON" || true | |
exec 3< <(tail -n +1 -F "$LOG_JSON") | |
start_epoch=$(date +%s) | |
found_url="" | |
while true; do | |
if ! IFS= read -r -t 1 line <&3; then | |
now=$(date +%s) | |
(( now - start_epoch >= 90 )) && break | |
continue | |
fi | |
tsv=$(printf '%s\n' "$line" | jq -Rr 'fromjson?|select(.event=="tcpip-forward" and .status=="success")|[(.address // .listen_host // ""), (if .tls_termination then "true" else "false" end), (.message // "")]|@tsv' || true) | |
[[ -z "$tsv" ]] && continue | |
host=$(printf '%s' "$tsv" | cut -f1) | |
tls=$(printf '%s' "$tsv" | cut -f2) | |
if [[ -n "$host" ]]; then scheme="http"; [[ "$tls" == "true" ]] && scheme="https"; found_url="${scheme}://${host}"; break; fi | |
done | |
exec 3<&- | |
if [[ -z "$found_url" ]]; then echo "FAILED to discover SSH public URL"; tail -n 200 "$LOG_JSON"; exit 1; fi | |
echo "PUBLIC_URL=${found_url}" >> "$GITHUB_ENV" | |
echo "ACCESS_KIND=browser" >> "$GITHUB_ENV" | |
echo "ACCESS_VALUE=${found_url}" >> "$GITHUB_ENV" | |
echo "ACCESS_NOTE=Open in a regular browser." >> "$GITHUB_ENV" | |
# ------------------ Tailscale ------------------ | |
- name: Connect to Tailscale | |
if: env.TUNNEL == 'tailscale' | |
uses: tailscale/github-action@v3 | |
with: | |
authkey: ${{ secrets.TAILSCALE_AUTHKEY }} | |
hostname: ${{ env.TS_HOSTNAME }} | |
args: ${{ runner.os != 'Windows' && '--ssh' || '' }} | |
- name: Get Tailscale IP and set access method | |
if: env.TUNNEL == 'tailscale' | |
shell: bash | |
run: | | |
TS_IP=$(tailscale ip -4) | |
echo "TS_IP=${TS_IP}" >> "$GITHUB_ENV" | |
# FIX 1: Set a special 'info' kind for the unsupported Windows SSH case. | |
if [[ "$RUNNER_OS" == "Windows" ]]; then | |
echo "ACCESS_KIND=info" >> "$GITHUB_ENV" | |
echo "ACCESS_VALUE=Tailscale SSH not supported on Windows" >> "$GITHUB_ENV" | |
echo "ACCESS_NOTE=See [tailscale/tailscale#4697](https://github.com/tailscale/tailscale/issues/4697) for progress." >> "$GITHUB_ENV" | |
else | |
echo "ACCESS_KIND=ssh" >> "$GITHUB_ENV" | |
echo "ACCESS_VALUE=${TS_IP}" >> "$GITHUB_ENV" | |
echo "ACCESS_NOTE=Paste the SSH command below in your terminal, then open the local URL." >> "$GITHUB_ENV" | |
fi | |
- name: Allow SSH over Tailscale (macOS) | |
if: env.TUNNEL == 'tailscale' && runner.os == 'macOS' | |
run: | | |
echo "Adding firewall rule to allow traffic on tailscale0 interface..." | |
echo "pass in on tailscale0" | sudo pfctl -f - | |
sudo pfctl -E | |
# ------------------ Tor ------------------ | |
- name: Install Tor (Linux/macOS) | |
if: env.TUNNEL == 'tor' && runner.os != 'Windows' | |
run: | | |
if [[ "$RUNNER_OS" == "Linux" ]]; then | |
sudo apt-get update && sudo apt-get install -y apt-transport-https gpg | |
wget -qO- https://deb.torproject.org/torproject.org/A3C4F0F979CAA22CDBA8F512EE8CBC9E886DDD89.asc | gpg --dearmor | sudo tee /usr/share/keyrings/tor-archive-keyring.gpg >/dev/null | |
echo "deb [signed-by=/usr/share/keyrings/tor-archive-keyring.gpg] https://deb.torproject.org/torproject.org $(lsb_release -sc) main" | sudo tee /etc/apt/sources.list.d/tor.list | |
sudo apt-get update && sudo apt-get install -y tor deb.torproject.org-keyring | |
elif [[ "$RUNNER_OS" == "macOS" ]]; then | |
brew install tor | |
fi | |
- name: Install Tor (Windows) | |
if: env.TUNNEL == 'tor' && runner.os == 'Windows' | |
shell: powershell | |
run: | | |
$url = 'https://archive.torproject.org/tor-package-archive/torbrowser/14.5.6/tor-expert-bundle-windows-x86_64-14.5.6.tar.gz' | |
$tarFile = 'tor-bundle.tar.gz' | |
$extractPath = 'C:\tor-bundle' | |
Write-Host "Downloading Tor Expert Bundle..." | |
Invoke-WebRequest -Uri $url -OutFile $tarFile | |
Write-Host "Creating extraction directory..." | |
New-Item -ItemType Directory -Path $extractPath -Force | Out-Null | |
Write-Host "Extracting archive..." | |
tar -xzf $tarFile -C $extractPath | |
$torExeDir = (Get-ChildItem -Path $extractPath -Recurse -Filter tor.exe).DirectoryName | |
if (-not $torExeDir) { | |
Write-Error 'Could not find tor.exe in the extracted archive.' | |
exit 1 | |
} | |
Write-Host "Found tor.exe in '$torExeDir'. Adding to environment..." | |
"TOR_EXE_PATH=$(Join-Path $torExeDir 'tor.exe')" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 | |
- name: Configure and Start Tor (Linux/macOS) | |
if: env.TUNNEL == 'tor' && runner.os != 'Windows' | |
run: | | |
# Debug flag (set to true for verbose control interactions; off by default) | |
set -x | |
TORBB_DEBUG="${TORBB_DEBUG:-false}" | |
log_dbg() { [[ "$TORBB_DEBUG" == "true" ]] && printf '[tor:debug] %s\n' "$*" >&2; true; } | |
log_inf() { printf '[tor] %s\n' "$*" >&2; } | |
log_err() { printf '[tor:error] %s\n' "$*" >&2; } | |
# Timeouts (seconds) | |
TOR_CTRL_TIMEOUT="${TOR_CTRL_TIMEOUT:-5}" | |
TOR_HS_TIMEOUT="${TOR_HS_TIMEOUT:-120}" | |
# OS-specific paths and users | |
if [[ "$RUNNER_OS" == "Linux" ]]; then | |
TORRC="/etc/tor/torrc" | |
TORDIR="/var/lib/tor" | |
TOR_USER="debian-tor" | |
TOR_GROUP="debian-tor" | |
SUDO="sudo" | |
TOR_SERVICE="tor" | |
elif [[ "$RUNNER_OS" == "macOS" ]]; then | |
pfx="$(brew --prefix)" | |
TORRC="${pfx}/etc/tor/torrc" | |
TORDIR="${pfx}/var/lib/tor" | |
TOR_USER="$USER" | |
TOR_GROUP="staff" | |
SUDO="sudo" | |
TOR_SERVICE="tor" | |
fi | |
COOKIE_AUTH_FILE="${TORDIR}/control_auth_cookie" | |
HS_DIR="${TORDIR}/hidden_service_${PORT}" | |
HOSTNAME_FILE="${HS_DIR}/hostname" | |
# Ensure directories | |
log_inf "Ensuring directories..." | |
$SUDO mkdir -p "${TORDIR}" "${HS_DIR}" | |
if [[ "$RUNNER_OS" == "Linux" ]]; then | |
$SUDO chown -R "${TOR_USER}:${TOR_GROUP}" "${TORDIR}" "${HS_DIR}" | |
$SUDO chmod 700 "${HS_DIR}" | |
elif [[ "$RUNNER_OS" == "macOS" ]]; then | |
$SUDO chown -R "${TOR_USER}:${TOR_GROUP}" "${TORDIR}" "${HS_DIR}" || true | |
$SUDO chmod 700 "${HS_DIR}" | |
fi | |
# Configure torrc (append HS if not present; ensure ControlPort) | |
log_inf "Configuring torrc at ${TORRC}..." | |
touch ./torrc_tmp | |
cat > ./torrc_tmp <<EOF | |
ControlPort 9051 | |
CookieAuthentication 1 | |
CookieAuthFileGroupReadable 1 | |
CookieAuthFile ${COOKIE_AUTH_FILE} | |
HiddenServiceDir ${HS_DIR} | |
HiddenServicePort 80 127.0.0.1:${PORT} | |
EOF | |
dirLine="HiddenServiceDir ${HS_DIR}" | |
if ! $SUDO grep -qF -- "$dirLine" "${TORRC}"; then | |
cat ./torrc_tmp | $SUDO tee -a "${TORRC}" >/dev/null | |
fi | |
rm -f ./torrc_tmp | |
log_dbg "torrc contents:" | |
$SUDO cat "${TORRC}" >&2 || true | |
# Utilities for control port | |
TIMEOUT_CMD="" | |
require_timeout() { | |
if [[ "$RUNNER_OS" == "macOS" ]]; then | |
command -v gtimeout >/dev/null 2>&1 || brew install coreutils | |
TIMEOUT_CMD="gtimeout" | |
else | |
command -v timeout >/dev/null 2>&1 || { log_err "timeout missing"; return 1; } | |
TIMEOUT_CMD="timeout" | |
fi | |
} | |
build_nc_cmd() { | |
local secs="${1:-5}" | |
if [[ "$RUNNER_OS" == "macOS" ]]; then | |
NC_CMD=( /usr/bin/nc -w "$secs" ) | |
else | |
NC_CMD=( nc -q 0 -w "$secs" ) | |
fi | |
} | |
nc_probe() { | |
local host="$1" port="$2" secs="${3:-5}" | |
require_timeout || return 1 | |
build_nc_cmd "$secs" | |
log_dbg "probe: ${TIMEOUT_CMD} ${secs} ${NC_CMD[*]} $host $port" | |
printf 'PROTOCOLINFO\r\nQUIT\r\n' | "$TIMEOUT_CMD" "$secs" "${NC_CMD[@]}" "$host" "$port" >/dev/null 2>&1 && return 0 | |
if [[ "$RUNNER_OS" != "macOS" ]]; then | |
NC_CMD=( nc -w "$secs" ) | |
log_dbg "probe-fallback: ${TIMEOUT_CMD} ${secs} ${NC_CMD[*]} $host $port" | |
printf 'PROTOCOLINFO\r\nQUIT\r\n' | "$TIMEOUT_CMD" "$secs" "${NC_CMD[@]}" "$host" "$port" >/dev/null 2>&1 && return 0 | |
fi | |
return 1 | |
} | |
nc_send() { | |
local host="$1" port="$2" secs="${3:-5}" | |
require_timeout || return 1 | |
build_nc_cmd "$secs" | |
log_dbg "send: ${TIMEOUT_CMD} ${secs} ${NC_CMD[*]} $host $port" | |
"$TIMEOUT_CMD" "$secs" "${NC_CMD[@]}" "$host" "$port" 2>>tor_errors.txt || { | |
local status=$? | |
if [[ "$RUNNER_OS" != "macOS" ]]; then | |
NC_CMD=( nc -w "$secs" ) | |
log_dbg "send-fallback: ${TIMEOUT_CMD} ${secs} ${NC_CMD[*]} $host $port" | |
"$TIMEOUT_CMD" "$secs" "${NC_CMD[@]}" "$host" "$port" 2>>tor_errors.txt | |
status=$? | |
fi | |
return $status | |
} | |
} | |
torctl_auth_send() { | |
local port="$1"; local cookie_file="$2"; shift 2 | |
local cmd="$*" | |
local cookie_hex="" | |
if [[ "$RUNNER_OS" == "Linux" ]]; then | |
cookie_hex="$($SUDO xxd -u -p "$cookie_file" 2>/dev/null | tr -d '\n')" | |
else | |
cookie_hex="$(xxd -u -p "$cookie_file" 2>/dev/null | tr -d '\n')" | |
fi | |
[[ -z "$cookie_hex" ]] && { log_err "empty cookie from $cookie_file"; return 1; } | |
log_dbg "control-cmd(port=$port): $cmd" | |
{ | |
printf 'AUTHENTICATE %s\r\n' "$cookie_hex" | |
printf '%s\r\n' "$cmd" | |
printf 'QUIT\r\n' | |
} | nc_send 127.0.0.1 "$port" "$TOR_CTRL_TIMEOUT" | |
} | |
wait_for_control_port() { | |
local port="${1:-9051}" | |
local deadline=$((SECONDS + TOR_CTRL_TIMEOUT + 10)) | |
while (( SECONDS < deadline )); do | |
if nc_probe 127.0.0.1 "$port" "$TOR_CTRL_TIMEOUT"; then | |
log_dbg "control port $port reachable" | |
return 0 | |
fi | |
sleep 1 | |
done | |
log_err "Timeout waiting for control port ${port}" | |
return 1 | |
} | |
wait_for_hostname() { | |
local deadline=$((SECONDS + TOR_HS_TIMEOUT)) | |
while (( SECONDS < deadline )); do | |
if $SUDO test -f "${HOSTNAME_FILE}"; then | |
log_dbg "hostname file present" | |
return 0 | |
fi | |
sleep 1 | |
done | |
log_err "Timeout waiting for hostname file (waited ${TOR_HS_TIMEOUT}s)" | |
return 1 | |
} | |
# Robust start/reload logic | |
log_inf "Starting or reloading Tor..." | |
CTRL_PORT=9051 | |
if nc_probe 127.0.0.1 "$CTRL_PORT" "$TOR_CTRL_TIMEOUT"; then | |
log_inf "Control port open; attempting SIGNAL RELOAD..." | |
resp="$(torctl_auth_send "$CTRL_PORT" "$COOKIE_AUTH_FILE" "SIGNAL RELOAD" || true)" | |
log_dbg "SIGNAL RELOAD response: $(echo "$resp" | tr '\n' ';' | sed 's/;*$//')" | |
if grep -q '^250' <<<"$resp"; then | |
log_inf "Tor config reloaded without restart." | |
else | |
log_inf "Reload failed; trying SIGNAL HUP..." | |
resp="$(torctl_auth_send "$CTRL_PORT" "$COOKIE_AUTH_FILE" "SIGNAL HUP" || true)" | |
log_dbg "SIGNAL HUP response: $(echo "$resp" | tr '\n' ';' | sed 's/;*$//')" | |
if grep -q '^250' <<<"$resp"; then | |
log_inf "Tor config reloaded via HUP." | |
else | |
log_inf "Signals failed; restarting service..." | |
if [[ "$RUNNER_OS" == "Linux" ]]; then | |
$SUDO systemctl restart "$TOR_SERVICE" >/dev/null 2>&1 || true | |
elif [[ "$RUNNER_OS" == "macOS" ]]; then | |
$SUDO brew services restart "$TOR_SERVICE" >/dev/null 2>&1 || true | |
fi | |
sleep 2 | |
if ! wait_for_control_port "$CTRL_PORT"; then | |
log_inf "Service restart failed; killing and starting ad-hoc..." | |
if [[ "$RUNNER_OS" == "Linux" ]]; then | |
$SUDO pkill -u "$TOR_USER" tor >/dev/null 2>&1 || true | |
$SUDO -u "$TOR_USER" nohup tor -f "$TORRC" >/dev/null 2>&1 & | |
elif [[ "$RUNNER_OS" == "macOS" ]]; then | |
pkill -x tor >/dev/null 2>&1 || true | |
nohup tor -f "$TORRC" >/dev/null 2>&1 & | |
fi | |
if ! wait_for_control_port "$CTRL_PORT"; then | |
log_err "Tor not listening after ad-hoc start." | |
exit 1 | |
fi | |
fi | |
fi | |
fi | |
else | |
log_inf "No control port; (re)starting service..." | |
if [[ "$RUNNER_OS" == "Linux" ]]; then | |
$SUDO systemctl restart "$TOR_SERVICE" >/dev/null 2>&1 || true | |
elif [[ "$RUNNER_OS" == "macOS" ]]; then | |
$SUDO brew services restart "$TOR_SERVICE" >/dev/null 2>&1 || $SUDO brew services start "$TOR_SERVICE" >/dev/null 2>&1 || true | |
fi | |
sleep 2 | |
if ! wait_for_control_port "$CTRL_PORT"; then | |
log_inf "Service start failed; killing and starting ad-hoc..." | |
if [[ "$RUNNER_OS" == "Linux" ]]; then | |
$SUDO pkill -u "$TOR_USER" tor >/dev/null 2>&1 || true | |
$SUDO -u "$TOR_USER" nohup tor -f "$TORRC" >/dev/null 2>&1 & | |
elif [[ "$RUNNER_OS" == "macOS" ]]; then | |
pkill -x tor >/dev/null 2>&1 || true | |
nohup tor -f "$TORRC" >/dev/null 2>&1 & | |
fi | |
if ! wait_for_control_port "$CTRL_PORT"; then | |
log_err "Tor not listening after ad-hoc start." | |
exit 1 | |
fi | |
fi | |
fi | |
# Wait for hostname | |
log_inf "Waiting for hostname file to be created..." | |
wait_for_hostname || exit 1 | |
onion="" | |
if [[ "$RUNNER_OS" == "Linux" ]]; then | |
onion="$($SUDO cat "$HOSTNAME_FILE" | tr -d '[:space:]')" | |
else | |
onion="$(cat "$HOSTNAME_FILE" | tr -d '[:space:]')" | |
fi | |
if [[ -z "$onion" || ! "$onion" == *".onion"* ]]; then | |
echo "::error::Tor did not generate onion URL" | |
exit 1 | |
fi | |
echo "PUBLIC_URL=$onion" >> $GITHUB_ENV | |
echo "ACCESS_KIND=browser" >> $GITHUB_ENV | |
echo "ACCESS_VALUE=$onion" >> $GITHUB_ENV | |
echo "ACCESS_NOTE=Requires Tor Browser." >> $GITHUB_ENV | |
# ------------------ Tor (Windows via Scheduled Task) ------------------ | |
- name: Prepare torrc (Windows) | |
if: env.TUNNEL == 'tor' && runner.os == 'Windows' | |
shell: powershell | |
run: | | |
$torExe = $env:TOR_EXE_PATH | |
if (-not $torExe -or -not (Test-Path $torExe)) { | |
Write-Error "TOR_EXE_PATH invalid or not set: '$torExe'" | |
exit 1 | |
} | |
$work = $PWD.Path | |
$torDir = Join-Path $work 'tor' | |
$hsDir = Join-Path $torDir 'hidden_service' | |
New-Item -ItemType Directory -Path $hsDir -Force | Out-Null | |
$torrc = Join-Path $work 'torrc' | |
# Minimal, quiet torrc (no GeoIP lines; uses defaults) | |
$content = @" | |
DataDirectory $torDir | |
ControlPort 9051 | |
CookieAuthentication 1 | |
HiddenServiceDir $hsDir | |
HiddenServiceVersion 3 | |
HiddenServicePort 80 127.0.0.1:$env:PORT | |
"@ | |
[IO.File]::WriteAllText($torrc, $content, [Text.UTF8Encoding]::new($false)) | |
"TOR_DIR=$torDir" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 | |
"HS_DIR=$hsDir" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 | |
"TORRC_PATH=$torrc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 | |
- name: Start Tor as a scheduled task (Windows) | |
if: env.TUNNEL == 'tor' && runner.os == 'Windows' | |
shell: powershell | |
run: | | |
$taskName = 'TorEphemeral' | |
$exe = $env:TOR_EXE_PATH | |
$cfg = $env:TORRC_PATH | |
# Create & launch as a scheduled task to escape the Actions job object | |
# schtasks /Delete /TN $taskName /F 2>$null | Out-Null | |
schtasks /Create /TN $taskName /SC ONCE /ST 00:00 /TR "`"$exe`" -f `"$cfg`"" /RL HIGHEST /F | Out-Null | |
schtasks /Run /TN $taskName | Out-Null | |
# Wait for control port | |
$ctrl = 9051 | |
$deadline = (Get-Date).AddSeconds(20) | |
$portOpen = $false | |
while ((Get-Date) -lt $deadline) { | |
try { (New-Object Net.Sockets.TcpClient('127.0.0.1', $ctrl)).Dispose(); $portOpen = $true; break } catch { Start-Sleep 1 } | |
} | |
if (-not $portOpen) { | |
Write-Error "Timeout waiting for Tor control port $ctrl" | |
exit 1 | |
} | |
# Wait for onion hostname | |
$hostnameFile = Join-Path $env:HS_DIR 'hostname' | |
$deadline = (Get-Date).AddSeconds(120) | |
$onion = $null | |
while ((Get-Date) -lt $deadline) { | |
if (Test-Path $hostnameFile -PathType Leaf) { | |
$t = (Get-Content $hostnameFile -ErrorAction SilentlyContinue).Trim() | |
if ($t -like '*.onion') { $onion = $t; break } | |
} | |
Start-Sleep 1 | |
} | |
if (-not $onion) { | |
Write-Error "Tor did not generate an onion URL" | |
exit 1 | |
} | |
"PUBLIC_URL=$onion" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 | |
"ACCESS_KIND=browser" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 | |
"ACCESS_VALUE=$onion" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 | |
"ACCESS_NOTE=Requires Tor Browser." | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 | |
# ------------------ Final Status & Keep-Alive ------------------ | |
- name: Upsert status comment (final access) | |
if: always() | |
uses: actions/github-script@v7 | |
env: | |
PUBLIC_URL: ${{ env.PUBLIC_URL }} | |
TS_IP: ${{ env.TS_IP }} | |
ACCESS_KIND: ${{ env.ACCESS_KIND }} | |
ACCESS_VALUE: ${{ env.ACCESS_VALUE }} | |
ACCESS_NOTE: ${{ env.ACCESS_NOTE }} | |
with: | |
script: | | |
const mark = process.env.STATUS_MARK; | |
const tun = process.env.TUNNEL; | |
const url = process.env.PUBLIC_URL; | |
const port = process.env.PORT; | |
const os = process.env.RUNNER_OS; | |
const tsip = process.env.TS_IP; | |
const kind = process.env.ACCESS_KIND; | |
const val = process.env.ACCESS_VALUE; | |
const note = process.env.ACCESS_NOTE; | |
const cap = process.env.MAX_CAP_MINUTES; | |
let accessBlock = "Tunnel failed to initialize."; | |
if (val) { | |
if (kind === "info") { | |
accessBlock = [ | |
"**Access Method**", "", | |
`💡 **${val}**`, | |
`The runner is available on your tailnet at \`${tsip}\`.`, | |
`*${note}*` | |
].join("\n"); | |
} else if (kind === "ssh" && tsip) { | |
const fwd = 8443; | |
const sshCmd = `ssh -o StrictHostKeyChecking=no -N -L ${fwd}:127.0.0.1:${port} ${tsip}`; | |
accessBlock = [ | |
"**Access Method** †", "", | |
"**1. For SSH port-forwarding, paste in your terminal:**", | |
"```bash", | |
sshCmd, | |
"```", | |
`**2. Then open this URL in your browser:** **http://localhost:${fwd}**`, | |
"", | |
"---", | |
"#### Open a shell to the runner:", | |
"<table>", | |
"<tr><th></th><th>Chrome / Firefox</th><th>Safari</th></tr>", | |
`<tr><td><b>Terminal</b></td><td><a href="ssh:${tsip}">Open</a></td><td><a href="ssh://${tsip}">Open</a></td></tr>`, | |
`<tr><td><b>iTerm2</b></td><td><a href="iterm2://ssh/${tsip}">Open</a></td><td><a href="iterm2://ssh/${tsip}">Open</a></td></tr>`, | |
"</table>", | |
"", | |
`† *needs [Tailscale](https://tailscale.com/kb/1347/installation) at your access point*`, | |
].join("\n"); | |
} else if (kind === "browser" && url) { | |
const fullUrl = url.includes('.onion') && !url.startsWith('http') ? `http://${url}` : url; | |
if (fullUrl.includes('.onion')) { | |
accessBlock = [ | |
"**Access Method** ‡", "", | |
"**Clickable Link (for Tor Browser):**", | |
`> ${fullUrl}`, "", | |
"**Copyable Address:**", | |
"```text", | |
fullUrl, | |
"```", | |
"", | |
`‡ *needs [Tor Browser](https://www.torproject.org/download/) at your access point*`, | |
].join("\n"); | |
} else { | |
accessBlock = `**Access Method**\n\n**${note}**\n\n> ${fullUrl}`; | |
} | |
} | |
} | |
const body = [ | |
mark, "### Tunnel Sandbox · Status", "", | |
`**OS:** \`${os}\` | **Tunnel:** \`${tun}\` | **App Port:** \`${port}\``, "", | |
accessBlock, "", | |
`_Max runtime is capped at ${cap} minutes._`, | |
"_Edit this issue to change options (cancels the previous run)._", "" | |
].join("\n"); | |
const { data: comments } = await github.rest.issues.listComments({ | |
owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, per_page: 100 | |
}); | |
const prev = comments.find(c => c.body && c.body.includes(mark)); | |
if (prev) { | |
await github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: prev.id, body }); | |
} else { | |
await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, body }); | |
} | |
- name: tmate rescue shell (for debug) | |
uses: mxschmitt/action-tmate@v3 | |
timeout-minutes: 10 # debug window minutes | |
with: | |
detached: true | |
limit-access-to-actor: true | |
id: tmate-og | |
- name: Keep session alive | |
run: | | |
want_secs=$(( KEEP_MINUTES * 60 )) | |
cap_secs=$(( MAX_CAP_MINUTES * 60 )) | |
secs=$want_secs | |
if (( secs > cap_secs )); then secs=$cap_secs; fi | |
echo "Keeping session alive for $(( secs / 60 )) minute(s)." | |
sleep $secs | |
echo "Time up." | |
# ------------------ Failure Handling ------------------ | |
- name: Collect logs (on failure) | |
if: failure() | |
run: | | |
set +e | |
echo '--- server-out.log ---'; tail -n 80 server-out.log || true | |
echo '--- server-err.log ---'; tail -n 80 server-err.log || true | |
echo '--- server-both.log ---'; tail -n 80 server-both.log || true | |
echo '--- cloudflared.log ---'; tail -n 120 cloudflared.log || true | |
echo '--- localhostrun.jsonl ---'; tail -n 120 ~/localhostrun.jsonl || true | |
echo '--- inlets.log ---'; tail -n 120 inlets.log || true | |
echo '--- tor/tor.log ---'; tail -n 120 tor/tor.log || true | |
- name: tmate rescue shell (on failure) | |
if: failure() | |
uses: mxschmitt/action-tmate@v3 | |
timeout-minutes: 10 # debug window minutes | |
with: | |
detached: true | |
limit-access-to-actor: true | |
id: tmate | |
- name: Cleanup Tor scheduled task (always) | |
if: env.TUNNEL == 'tor' && runner.os == 'Windows' | |
shell: powershell | |
run: | | |
schtasks /End /TN TorEphemeral 2>$null | Out-Null | |
schtasks /Delete /TN TorEphemeral /F 2>$null | Out-Null | |
- name: Upsert status comment (tmate) | |
if: failure() | |
uses: actions/github-script@v7 | |
with: | |
script: | | |
const mark = process.env.STATUS_MARK; | |
const ssh = `${{ steps.tmate.outputs.sshCommand }}`.trim(); | |
const web = `${{ steps.tmate.outputs.webUrl }}`.trim(); | |
const body = [ | |
mark, "### Tunnel Sandbox · Status", "", | |
"❗ **Job failed** — opening a debug shell.", "", | |
"**SSH:**", "```bash\n" + ssh + "\n```", | |
`**Web shell:** ${web}`, "", | |
`_This stays open for ${process.env.DEBUG_WINDOW_MINUTES} minutes._`, "" | |
].join("\n"); | |
const { data: comments } = await github.rest.issues.listComments({ | |
owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number | |
}); | |
const prev = comments.find(c => c.body && c.body.includes(mark)); | |
if (prev) { | |
await github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: prev.id, body }); | |
} else { | |
await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, body }); | |
} |