Skip to content

Instantly share code, notes, and snippets.

@slashtechno
Last active June 27, 2026 19:32
Show Gist options
  • Select an option

  • Save slashtechno/985af75d77aa5a92dd86c22cabd94bb2 to your computer and use it in GitHub Desktop.

Select an option

Save slashtechno/985af75d77aa5a92dd86c22cabd94bb2 to your computer and use it in GitHub Desktop.
tsdev — expose local dev servers on your tailnet via tailscale serve
#!/usr/bin/env bash
# tsdev — expose local dev servers on your tailnet via tailscale serve
#
# Install:
# chmod +x tsdev && mv tsdev ~/.local/bin/tsdev # Fedora / personal
# chmod +x tsdev && mv tsdev /usr/local/bin/tsdev # macOS / system-wide
#
# Requires HTTPS certs enabled in your tailnet:
# https://login.tailscale.com/admin/dns → enable "HTTPS Certificates"
#
# ── Vite users ────────────────────────────────────────────────────────────────
# By default, tsdev runs a local host-rewriting proxy so Vite's host header
# check doesn't block requests from your tailnet. This requires bun or node.
#
# If you'd rather skip the proxy entirely and configure Vite instead, add this
# to your vite.config.ts (covers all *.ts.net addresses, safe to commit):
#
# export default defineConfig({
# server: {
# allowedHosts: ['.ts.net'],
# },
# })
#
# Then run tsdev with --no-proxy to skip the proxy for that project.
# ─────────────────────────────────────────────────────────────────────────────
set -euo pipefail
# ── colours ────────────────────────────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; CYAN='\033[0;36m'
YELLOW='\033[0;33m'; BOLD='\033[1m'; NC='\033[0m'
die() { echo -e "${RED}error: $*${NC}" >&2; exit 1; }
info() { echo -e "${CYAN} → $*${NC}"; }
ok() { echo -e "${GREEN} ✓ $*${NC}"; }
warn() { echo -e "${YELLOW} ! $*${NC}"; }
PROXY_DIR="${TMPDIR:-/tmp}/tsdev-proxies"
# ── helpers ────────────────────────────────────────────────────────────────────
check_deps() {
command -v tailscale &>/dev/null || die "tailscale not found in PATH"
tailscale status &>/dev/null || die "tailscale daemon not running (try: tailscale up)"
}
get_hostname() {
local raw
raw=$(tailscale status --json 2>/dev/null) || die "could not get tailscale status"
if command -v python3 &>/dev/null; then
echo "$raw" | python3 -c \
"import sys,json; print(json.load(sys.stdin)['Self']['DNSName'].rstrip('.'))" \
2>/dev/null && return
fi
if command -v jq &>/dev/null; then
echo "$raw" | jq -r '.Self.DNSName | rtrimstr(".")' 2>/dev/null && return
fi
echo "$raw" | grep -o '"DNSName":"[^"]*"' | head -1 | sed 's/"DNSName":"//; s/\."$//'
}
validate_port() {
[[ "$1" =~ ^[0-9]+$ ]] && (( $1 >= 1 && $1 <= 65535 )) || die "invalid port: '$1'"
}
find_runtime() {
command -v bun 2>/dev/null && return
command -v node 2>/dev/null && return
die "proxy requires bun or node in PATH (or use --no-proxy)"
}
# Find the first free port at or above $1 by attempting a TCP connection.
# /dev/tcp succeeds if something is listening → port in use → try next.
find_free_port() {
local p="$1"
while (( p < 65535 )); do
if ! (echo "" >/dev/tcp/127.0.0.1/$p) 2>/dev/null; then
echo "$p"
return 0
fi
(( p++ ))
done
die "no free port found starting from $1"
}
# ── embedded proxy (node/bun) ──────────────────────────────────────────────────
# Rewrites the Host header to localhost before forwarding to the dev server.
# Also proxies WebSocket upgrades so Vite HMR works over the tailnet.
write_proxy_script() {
cat > "$1" << 'PROXY_EOF'
const http = require('http');
const net = require('net');
const TARGET = parseInt(process.env.TARGET_PORT);
const LISTEN = parseInt(process.env.LISTEN_PORT);
if (!TARGET || !LISTEN) {
process.stderr.write('TARGET_PORT and LISTEN_PORT required\n');
process.exit(1);
}
function rewrite(headers) {
return { ...headers, host: `localhost:${TARGET}` };
}
// HTTP
const server = http.createServer((req, res) => {
const proxy = http.request(
{ hostname: 'localhost', port: TARGET, path: req.url,
method: req.method, headers: rewrite(req.headers) },
(pRes) => { res.writeHead(pRes.statusCode, pRes.headers); pRes.pipe(res, { end: true }); }
);
proxy.on('error', (e) => { res.writeHead(502); res.end('Proxy error: ' + e.message); });
req.pipe(proxy, { end: true });
});
// WebSocket (Vite HMR)
server.on('upgrade', (req, socket, head) => {
const conn = net.connect(TARGET, 'localhost', () => {
const h = Object.entries(rewrite(req.headers)).map(([k,v]) => `${k}: ${v}`).join('\r\n');
conn.write(`${req.method} ${req.url} HTTP/${req.httpVersion}\r\n${h}\r\n\r\n`);
if (head?.length) conn.write(head);
socket.pipe(conn);
conn.pipe(socket);
});
conn.on('error', () => socket.destroy());
socket.on('error', () => conn.destroy());
});
server.listen(LISTEN, '127.0.0.1', () => process.stdout.write('proxy:ready\n'));
PROXY_EOF
}
# ── proxy lifecycle ────────────────────────────────────────────────────────────
start_proxy() {
local src_port="$1" proxy_port="$2"
mkdir -p "$PROXY_DIR"
local script="$PROXY_DIR/proxy-${src_port}.js"
local pid_file="$PROXY_DIR/proxy-${src_port}.pid"
local log_file="$PROXY_DIR/proxy-${src_port}.log"
write_proxy_script "$script"
local runtime
runtime=$(find_runtime)
TARGET_PORT="$src_port" LISTEN_PORT="$proxy_port" \
"$runtime" "$script" > "$log_file" 2>&1 &
echo $! > "$pid_file"
# Wait for ready signal (up to 3s)
local i=0
while (( i++ < 30 )); do
sleep 0.1
grep -q "proxy:ready" "$log_file" 2>/dev/null && return
kill -0 "$(cat "$pid_file")" 2>/dev/null || \
die "proxy failed to start. Log:\n$(cat "$log_file" 2>/dev/null)"
done
die "proxy timed out. Log:\n$(cat "$log_file" 2>/dev/null)"
}
stop_proxy() {
local src_port="$1"
local pid_file="$PROXY_DIR/proxy-${src_port}.pid"
[[ -f "$pid_file" ]] || return 0
kill "$(cat "$pid_file")" 2>/dev/null && ok "stopped proxy for :${src_port}" || true
rm -f "$pid_file" "$PROXY_DIR/proxy-${src_port}.js" "$PROXY_DIR/proxy-${src_port}.log"
}
# ── commands ───────────────────────────────────────────────────────────────────
cmd_up() {
local ports=()
local use_proxy=true proxy_port="" foreground=false use_funnel=false
while [[ $# -gt 0 ]]; do
case "$1" in
--no-proxy) use_proxy=false ;;
--proxy-port) shift; proxy_port="$1" ;;
--proxy-port=*) proxy_port="${1#--proxy-port=}" ;;
--fg) foreground=true ;;
--funnel) use_funnel=true ;;
-*) die "unknown flag: $1" ;;
*) ports+=("$1") ;;
esac
shift
done
[[ ${#ports[@]} -gt 0 ]] || die "specify at least one port (tsdev up 5173)"
[[ -z "$proxy_port" || ${#ports[@]} -eq 1 ]] || die "--proxy-port can only be used with a single port"
local hostname
hostname=$(get_hostname)
echo -e "\n${BOLD}Exposing ports on tailnet...${NC}"
for port in "${ports[@]}"; do
validate_port "$port"
local serve_target="$port"
if [[ "$use_proxy" == "true" ]]; then
local pport
if [[ -n "$proxy_port" ]]; then
pport="$proxy_port"
else
pport=$(find_free_port $(( port + 1 )))
fi
validate_port "$pport"
info "starting host-rewrite proxy :${pport} → :${port} ..."
start_proxy "$port" "$pport"
ok "proxy :${pport} → :${port} (Host: localhost)"
serve_target="$pport"
fi
local ts_cmd="serve"
[[ "$use_funnel" == "true" ]] && ts_cmd="funnel"
info "configuring tailscale ${ts_cmd} :${port} → local :${serve_target} ..."
if ! tailscale "$ts_cmd" --bg --https="$port" "$serve_target" 2>/tmp/tsdev_err; then
warn "$(cat /tmp/tsdev_err)"
[[ "$use_proxy" == "true" ]] && stop_proxy "$port"
die "failed to ${ts_cmd} :${port}"
fi
if [[ "$use_funnel" == "true" ]]; then
ok "https://${hostname}:${port} ${YELLOW}(public — internet-accessible via funnel)${NC}"
else
ok "https://${hostname}:${port}"
fi
done
if [[ "$foreground" == "true" ]]; then
echo
warn "foreground mode — Ctrl+C to stop and remove serve configs"
local _ports=("${ports[@]}")
local _proxy="$use_proxy"
local _funnel="$use_funnel"
cleanup() {
echo
info "cleaning up..."
for p in "${_ports[@]}"; do
tailscale serve --https="$p" off 2>/dev/null || true
tailscale funnel --https="$p" off 2>/dev/null || true
[[ "$_proxy" == "true" ]] && stop_proxy "$p"
done
ok "done"
exit 0
}
trap cleanup INT TERM
while true; do sleep 1; done
fi
echo
}
cmd_down() {
[[ $# -gt 0 ]] || die "specify at least one port (tsdev down 5173)"
echo -e "\n${BOLD}Removing ports from tailnet...${NC}"
for port in "$@"; do
validate_port "$port"
info "removing :${port} ..."
tailscale serve --https="$port" off 2>/dev/null || true
tailscale funnel --https="$port" off 2>/tmp/tsdev_err || \
{ [[ -s /tmp/tsdev_err ]] && warn "$(cat /tmp/tsdev_err)"; }
stop_proxy "$port"
ok "removed :${port}"
done
echo
}
cmd_reset() {
warn "this will clear ALL tailscale serve/funnel configs and proxies on this machine."
read -r -p " Continue? [y/N] " confirm
[[ "$confirm" == "y" || "$confirm" == "Y" ]] || { echo "Aborted."; exit 0; }
if [[ -d "$PROXY_DIR" ]]; then
for pid_file in "$PROXY_DIR"/*.pid; do
[[ -f "$pid_file" ]] && kill "$(cat "$pid_file")" 2>/dev/null || true
done
rm -rf "$PROXY_DIR"
fi
tailscale serve reset
tailscale funnel reset 2>/dev/null || true
ok "all serve/funnel configs and proxies cleared"
echo
}
cmd_status() {
tailscale serve status
tailscale funnel status 2>/dev/null || true
if [[ -d "$PROXY_DIR" ]] && ls "$PROXY_DIR"/*.pid &>/dev/null; then
echo -e "\n${BOLD}Running proxies:${NC}"
for pid_file in "$PROXY_DIR"/*.pid; do
local port pid
port=$(basename "$pid_file" | sed 's/proxy-\(.*\)\.pid/\1/')
pid=$(cat "$pid_file")
if kill -0 "$pid" 2>/dev/null; then
echo -e " ${GREEN}✓${NC} :${port} (pid ${pid})"
else
echo -e " ${RED}✗${NC} :${port} (stale pid ${pid} — run: tsdev down ${port})"
fi
done
fi
}
usage() {
echo -e "
${BOLD}tsdev${NC} — expose local dev servers on your tailnet
${BOLD}usage:${NC}
tsdev up <port> [port2 ...] [flags] expose ports via tailscale serve
tsdev down <port> [port2 ...] stop ports and any running proxies
tsdev reset clear ALL serve/funnel configs + proxies
tsdev status show serve/funnel config + running proxies
${BOLD}flags (up):${NC}
--funnel expose publicly via tailscale funnel (internet-accessible)
note: funnel only supports ports 443, 8443, 10000
--no-proxy skip the host-rewriting proxy (use if you've set
allowedHosts: ['.ts.net'] in vite.config.ts, or for
servers like FastAPI that don't check the Host header)
--proxy-port <port> explicit proxy listen port (default: auto)
--fg foreground — block and auto-cleanup on Ctrl+C
${BOLD}examples:${NC}
tsdev up 5173 # proxy on (auto port), served on :5173
tsdev up 5173 8000 # vite + fastapi, both proxied
tsdev up 8000 --no-proxy # fastapi, no proxy needed
tsdev up 8000 --funnel # public internet via funnel
tsdev up 8000 --funnel --no-proxy # funnel, no proxy (e.g. FastAPI)
tsdev up 5173 --proxy-port 9000
tsdev up 5173 --fg # foreground, cleans up on exit
tsdev down 5173
tsdev status
${BOLD}notes:${NC}
• Requires HTTPS certs enabled: https://login.tailscale.com/admin/dns
• Funnel also requires the funnel node attribute in your tailnet policy file
• Proxy requires bun or node in PATH
• Proxy scripts/pids stored in: ${PROXY_DIR}
• On Fedora, tailscale may need sudo depending on install method
"
}
# ── main ───────────────────────────────────────────────────────────────────────
check_deps
case "${1:-help}" in
up) shift; cmd_up "$@" ;;
down|off|stop) shift; cmd_down "$@" ;;
reset|clear) cmd_reset ;;
status|ls) cmd_status ;;
help|-h|--help) usage ;;
*) usage; exit 1 ;;
esac
#Requires -Version 5.1
<#
.SYNOPSIS
tsdev — expose local dev servers on your tailnet via tailscale serve
.DESCRIPTION
Requires HTTPS certs enabled in your tailnet:
https://login.tailscale.com/admin/dns → enable "HTTPS Certificates"
.NOTES
Install (pick one):
Option 1 — put tsdev.ps1 somewhere in $env:PATH and call it directly:
tsdev.ps1 up 5173
Option 2 — add a function to your $PROFILE for a clean 'tsdev' alias:
function tsdev { & "C:\path\to\tsdev.ps1" @args }
Option 3 — drop a tsdev.cmd alongside this script for cmd.exe / terminals
that don't run .ps1 directly:
@echo off
powershell -ExecutionPolicy Bypass -File "%~dp0tsdev.ps1" %*
If you see "running scripts is disabled", allow user-level scripts once:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
Vite users:
By default tsdev runs a host-rewriting proxy so Vite's host header
check doesn't block tailnet requests. Requires bun or node in PATH.
To skip the proxy, add this to vite.config.ts instead:
server: { allowedHosts: ['.ts.net'] }
Then pass -NoProxy to tsdev.
#>
[CmdletBinding()]
param(
[Parameter(Position = 0)]
[string]$Subcommand = 'help',
[Parameter(Position = 1, ValueFromRemainingArguments = $true)]
[string[]]$CmdArgs = @()
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$ProxyDir = Join-Path $env:TEMP 'tsdev-proxies'
# ── helpers ────────────────────────────────────────────────────────────────────
function Die([string]$msg) {
Write-Host " error: $msg" -ForegroundColor Red
exit 1
}
function Info([string]$msg) { Write-Host " -> $msg" -ForegroundColor Cyan }
function Ok([string]$msg) { Write-Host " v $msg" -ForegroundColor Green }
function Warn([string]$msg) { Write-Host " ! $msg" -ForegroundColor Yellow }
function Assert-Deps {
if (-not (Get-Command tailscale -ErrorAction SilentlyContinue)) {
Die "tailscale not found in PATH"
}
$result = tailscale status 2>&1
if ($LASTEXITCODE -ne 0) {
Die "tailscale daemon not running (try: tailscale up)"
}
}
function Get-TailscaleHostname {
$json = tailscale status --json 2>&1 | ConvertFrom-Json
return $json.Self.DNSName.TrimEnd('.')
}
function Assert-Port([string]$port) {
if ($port -notmatch '^\d+$' -or [int]$port -lt 1 -or [int]$port -gt 65535) {
Die "invalid port: '$port'"
}
}
function Find-Runtime {
if (Get-Command bun -ErrorAction SilentlyContinue) { return 'bun' }
if (Get-Command node -ErrorAction SilentlyContinue) { return 'node' }
Die "proxy requires bun or node in PATH (or use -NoProxy)"
}
function Find-FreePort([int]$start) {
$port = $start
while ($port -lt 65535) {
try {
$listener = [System.Net.Sockets.TcpListener]::new(
[System.Net.IPAddress]::Loopback, $port)
$listener.Start()
$listener.Stop()
return $port
} catch {
$port++
}
}
Die "no free port found starting from $start"
}
# ── embedded proxy (node/bun) ──────────────────────────────────────────────────
# Rewrites the Host header to localhost before forwarding to the dev server.
# Also proxies WebSocket upgrades so Vite HMR works over the tailnet.
# Ports are passed as argv[2] and argv[3] to avoid env var inheritance issues.
function Write-ProxyScript([string]$path) {
$script = @'
const http = require('http');
const net = require('net');
const TARGET = parseInt(process.argv[2]);
const LISTEN = parseInt(process.argv[3]);
if (!TARGET || !LISTEN) {
process.stderr.write('Usage: node proxy.js <target_port> <listen_port>\n');
process.exit(1);
}
function rewrite(headers) {
return { ...headers, host: `localhost:${TARGET}` };
}
const server = http.createServer((req, res) => {
const proxy = http.request(
{ hostname: 'localhost', port: TARGET, path: req.url,
method: req.method, headers: rewrite(req.headers) },
(pRes) => { res.writeHead(pRes.statusCode, pRes.headers); pRes.pipe(res, { end: true }); }
);
proxy.on('error', (e) => { res.writeHead(502); res.end('Proxy error: ' + e.message); });
req.pipe(proxy, { end: true });
});
server.on('upgrade', (req, socket, head) => {
const conn = net.connect(TARGET, 'localhost', () => {
const h = Object.entries(rewrite(req.headers)).map(([k,v]) => `${k}: ${v}`).join('\r\n');
conn.write(`${req.method} ${req.url} HTTP/${req.httpVersion}\r\n${h}\r\n\r\n`);
if (head?.length) conn.write(head);
socket.pipe(conn);
conn.pipe(socket);
});
conn.on('error', () => socket.destroy());
socket.on('error', () => conn.destroy());
});
server.listen(LISTEN, '127.0.0.1', () => process.stdout.write('proxy:ready\n'));
'@
Set-Content -Path $path -Value $script -Encoding UTF8
}
# ── proxy lifecycle ────────────────────────────────────────────────────────────
function Start-TsProxy([int]$srcPort, [int]$proxyPort) {
New-Item -ItemType Directory -Path $ProxyDir -Force | Out-Null
$script = Join-Path $ProxyDir "proxy-${srcPort}.js"
$pidFile = Join-Path $ProxyDir "proxy-${srcPort}.pid"
$outFile = Join-Path $ProxyDir "proxy-${srcPort}.out.log"
$errFile = Join-Path $ProxyDir "proxy-${srcPort}.err.log"
Write-ProxyScript $script
$runtime = Find-Runtime
$proc = Start-Process `
-FilePath $runtime `
-ArgumentList @($script, "$srcPort", "$proxyPort") `
-RedirectStandardOutput $outFile `
-RedirectStandardError $errFile `
-PassThru -WindowStyle Hidden
Set-Content -Path $pidFile -Value $proc.Id
# Wait for ready signal (up to 3s)
$i = 0
while ($i++ -lt 30) {
Start-Sleep -Milliseconds 100
if ((Get-Content $outFile -ErrorAction SilentlyContinue) -match 'proxy:ready') { return }
if ($proc.HasExited) {
$out = Get-Content $outFile -ErrorAction SilentlyContinue
$err = Get-Content $errFile -ErrorAction SilentlyContinue
Die "proxy failed to start. Log:`n$out`n$err"
}
}
$out = Get-Content $outFile -ErrorAction SilentlyContinue
$err = Get-Content $errFile -ErrorAction SilentlyContinue
Die "proxy timed out. Log:`n$out`n$err"
}
function Stop-TsProxy([int]$port) {
$pidFile = Join-Path $ProxyDir "proxy-${port}.pid"
if (-not (Test-Path $pidFile)) { return }
$procId = Get-Content $pidFile
try {
Stop-Process -Id $procId -Force -ErrorAction Stop
Ok "stopped proxy for :${port}"
} catch {}
Remove-Item -Force -ErrorAction SilentlyContinue `
$pidFile,
(Join-Path $ProxyDir "proxy-${port}.js"),
(Join-Path $ProxyDir "proxy-${port}.out.log"),
(Join-Path $ProxyDir "proxy-${port}.err.log")
}
# ── commands ───────────────────────────────────────────────────────────────────
function Invoke-Up([string[]]$Arguments) {
$ports = @()
$useProxy = $true
$proxyPort = $null
$foreground = $false
$useFunnel = $false
$i = 0
while ($i -lt $Arguments.Count) {
switch -Regex ($Arguments[$i]) {
'^--no-proxy$' { $useProxy = $false; break }
'^--funnel$' { $useFunnel = $true; break }
'^--fg$' { $foreground = $true; break }
'^--proxy-port$' { $i++; $proxyPort = $Arguments[$i]; break }
'^--proxy-port=(.+)$' { $proxyPort = $Matches[1]; break }
'^-' { Die "unknown flag: $($Arguments[$i])" }
default { $ports += $Arguments[$i] }
}
$i++
}
if ($ports.Count -eq 0) { Die "specify at least one port (tsdev up 5173)" }
if ($proxyPort -and $ports.Count -gt 1) { Die "--proxy-port can only be used with a single port" }
$hostname = Get-TailscaleHostname
Write-Host "`nExposing ports on tailnet..." -ForegroundColor White
foreach ($port in $ports) {
Assert-Port $port
$serveTarget = $port
if ($useProxy) {
$pport = if ($proxyPort) { [int]$proxyPort } else { Find-FreePort ([int]$port + 1) }
Info "starting host-rewrite proxy :${pport} -> :${port} ..."
Start-TsProxy ([int]$port) ([int]$pport)
Ok "proxy :${pport} -> :${port} (Host: localhost)"
$serveTarget = "$pport"
}
$tsCmd = if ($useFunnel) { 'funnel' } else { 'serve' }
Info "configuring tailscale ${tsCmd} :${port} -> local :${serveTarget} ..."
$result = tailscale $tsCmd --bg "--https=$port" $serveTarget 2>&1
if ($LASTEXITCODE -ne 0) {
Warn "$result"
if ($useProxy) { Stop-TsProxy ([int]$port) }
Die "failed to ${tsCmd} :${port}"
}
if ($useFunnel) {
Write-Host " v https://${hostname}:${port} " -ForegroundColor Green -NoNewline
Write-Host "(public -- internet-accessible via funnel)" -ForegroundColor Yellow
} else {
Ok "https://${hostname}:${port}"
}
}
if ($foreground) {
Write-Host ""
Warn "foreground mode -- Ctrl+C to stop and remove serve configs"
try {
while ($true) { Start-Sleep -Seconds 1 }
} finally {
Write-Host ""
Info "cleaning up..."
foreach ($p in $ports) {
tailscale serve "--https=$p" off 2>&1 | Out-Null
tailscale funnel "--https=$p" off 2>&1 | Out-Null
if ($useProxy) { Stop-TsProxy ([int]$p) }
}
Ok "done"
}
}
Write-Host ""
}
function Invoke-Down([string[]]$Arguments) {
if ($Arguments.Count -eq 0) { Die "specify at least one port (tsdev down 5173)" }
Write-Host "`nRemoving ports from tailnet..." -ForegroundColor White
foreach ($port in $Arguments) {
Assert-Port $port
Info "removing :${port} ..."
tailscale serve "--https=$port" off 2>&1 | Out-Null
$result = tailscale funnel "--https=$port" off 2>&1
if ($LASTEXITCODE -ne 0 -and $result) { Warn "$result" }
Stop-TsProxy ([int]$port)
Ok "removed :${port}"
}
Write-Host ""
}
function Invoke-Reset {
Warn "this will clear ALL tailscale serve/funnel configs and proxies on this machine."
$confirm = Read-Host " Continue? [y/N]"
if ($confirm -notmatch '^[yY]$') { Write-Host "Aborted."; exit 0 }
if (Test-Path $ProxyDir) {
Get-ChildItem $ProxyDir -Filter '*.pid' -ErrorAction SilentlyContinue | ForEach-Object {
$procId = Get-Content $_.FullName
Stop-Process -Id $procId -Force -ErrorAction SilentlyContinue
}
Remove-Item $ProxyDir -Recurse -Force
}
tailscale serve reset 2>&1 | Out-Null
tailscale funnel reset 2>&1 | Out-Null
Ok "all serve/funnel configs and proxies cleared"
Write-Host ""
}
function Invoke-Status {
tailscale serve status
tailscale funnel status 2>&1
if (Test-Path $ProxyDir) {
$pidFiles = Get-ChildItem $ProxyDir -Filter '*.pid' -ErrorAction SilentlyContinue
if ($pidFiles) {
Write-Host "`nRunning proxies:" -ForegroundColor White
foreach ($f in $pidFiles) {
$port = $f.Name -replace 'proxy-(.*)\.pid', '$1'
$procId = Get-Content $f.FullName
$proc = Get-Process -Id $procId -ErrorAction SilentlyContinue
if ($proc) {
Write-Host " " -NoNewline
Write-Host "v" -ForegroundColor Green -NoNewline
Write-Host " :${port} (pid ${procId})"
} else {
Write-Host " " -NoNewline
Write-Host "x" -ForegroundColor Red -NoNewline
Write-Host " :${port} (stale pid ${procId} -- run: tsdev down ${port})"
}
}
}
}
}
function Show-Usage {
Write-Host @"
tsdev -- expose local dev servers on your tailnet
usage:
tsdev up <port> [port2 ...] [flags] expose ports via tailscale serve
tsdev down <port> [port2 ...] stop ports and any running proxies
tsdev reset clear ALL serve/funnel configs + proxies
tsdev status show serve/funnel config + running proxies
flags (up):
--funnel expose publicly via tailscale funnel (internet-accessible)
note: funnel only supports ports 443, 8443, 10000
--no-proxy skip the host-rewriting proxy (use if you've set
allowedHosts: ['.ts.net'] in vite.config.ts, or for
servers like FastAPI that don't check the Host header)
--proxy-port <port> explicit proxy listen port (default: auto)
--fg foreground -- block and auto-cleanup on Ctrl+C
examples:
tsdev up 5173 # proxy on (auto port), served on :5173
tsdev up 5173 8000 # vite + fastapi, both proxied
tsdev up 8000 --no-proxy # fastapi, no proxy needed
tsdev up 8000 --funnel # public internet via funnel
tsdev up 8000 --funnel --no-proxy # funnel, no proxy (e.g. FastAPI)
tsdev up 5173 --proxy-port 9000
tsdev up 5173 --fg # foreground, cleans up on exit
tsdev down 5173
tsdev status
notes:
* Requires HTTPS certs enabled: https://login.tailscale.com/admin/dns
* Funnel also requires the funnel node attribute in your tailnet policy file
* Proxy requires bun or node in PATH
* Proxy scripts/pids stored in: $ProxyDir
"@
}
# ── main ───────────────────────────────────────────────────────────────────────
Assert-Deps
switch -Regex ($Subcommand) {
'^up$' { Invoke-Up $CmdArgs }
'^(down|off|stop)$' { Invoke-Down $CmdArgs }
'^(reset|clear)$' { Invoke-Reset }
'^(status|ls)$' { Invoke-Status }
'^(help|-h|--help)$' { Show-Usage }
default { Show-Usage; exit 1 }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment