Skip to content

Tunnel Sandbox

Tunnel Sandbox #175

# version
name: Tunnel Sandbox (Cloudflare | localhost.run | ngrok | 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'
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
powershell -Command "choco install cloudflared -y"
fi
cloudflared --version
(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'
run: |
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"
# ------------------ ngrok ------------------
- name: Start ngrok Tunnel
if: env.TUNNEL == 'ngrok'
shell: bash
run: |
# 1. Download and install ngrok. This is the tokenless, short-session version for testing.
echo "Downloading ngrok..."
if [[ "$RUNNER_OS" == "Linux" ]]; then
curl -s https://bin.equinox.io/c/bNyj1mQVY4c/ngrok-v3-stable-linux-amd64.tgz | tar xz
elif [[ "$RUNNER_OS" == "macOS" ]]; then
curl -s https://bin.equinox.io/c/bNyj1mQVY4c/ngrok-v3-stable-darwin-amd64.zip -o ngrok.zip && unzip -o ngrok.zip
elif [[ "$RUNNER_OS" == "Windows" ]]; then
curl -s https://bin.equinox.io/c/bNyj1mQVY4c/ngrok-v3-stable-windows-amd64.zip -o ngrok.zip && unzip -o ngrok.zip
fi
sudo mv ./ngrok /usr/local/bin/
# 2. Start ngrok in the background.
# Without an auth token, ngrok sessions are very short and show a warning page.
echo "Starting ngrok..."
nohup ngrok http "${PORT}" --log=stdout > ngrok.log 2>&1 &
# 3. Scrape the public URL from the ngrok API (more reliable than logs)
for i in {1..30}; do
URL=$(curl -s http://127.0.0.1:4040/api/tunnels | jq -r '.tunnels[] | select(.proto=="https") | .public_url // ""' | head -n1)
if [[ -n "$URL" && "$URL" != "null" ]]; then
echo "ngrok URL found: $URL"
break
fi
echo "Waiting for ngrok API..."
sleep 2
done
if [[ -z "$URL" ]]; then
echo "::error::Failed to obtain ngrok URL from API."
cat ngrok.log
exit 1
fi
# 4. Set environment variables for the status comment
NOTE="Open in a regular browser. NOTE: This is a temporary session. For a longer, stable session, add an NGROK_AUTHTOKEN to this repository's secrets."
echo "PUBLIC_URL=${URL}" >> $GITHUB_ENV
echo "ACCESS_KIND=browser" >> $GITHUB_ENV
echo "ACCESS_VALUE=${URL}" >> $GITHUB_ENV
echo "ACCESS_NOTE=${NOTE}" >> $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;
const user = os == 'Windows' ? 'runneradmin' : 'runner';
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} ${user}@${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}**`,
"",
`† *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 });
}