Last active
June 27, 2026 19:32
-
-
Save slashtechno/985af75d77aa5a92dd86c22cabd94bb2 to your computer and use it in GitHub Desktop.
tsdev — expose local dev servers on your tailnet via tailscale serve
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
| #!/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 |
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
| #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