Last active
March 11, 2026 21:13
-
-
Save cemoody/4a047b74d61e361d5f98ee1b1507eff2 to your computer and use it in GitHub Desktop.
Vibe Kanban + CopyParty behind Tailscale Serve with origin-stripping proxy
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 | |
| # | |
| # vk-tunnel.sh — Launch Vibe Kanban + CopyParty via Tailscale Serve | |
| # | |
| # A Node.js proxy handles all routing and origin-stripping: | |
| # 1. /files/* and /.cpr/* → CopyParty | |
| # 2. Everything else → Vibe Kanban (with Origin header stripped) | |
| # | |
| # Tailscale Serve (:443) | |
| # └── /* → Node proxy (:42818) | |
| # ├── /files/* & /.cpr/* → CopyParty (:42819) | |
| # └── /* → Vibe Kanban (:42817) | |
| set -euo pipefail | |
| # --------------------------------------------------------------------------- | |
| # Config | |
| # --------------------------------------------------------------------------- | |
| VK_PORT=42817 | |
| PROXY_PORT=42818 | |
| CP_PORT=42819 | |
| CP_DIR="${CP_DIR:-$HOME}" # directory CopyParty serves | |
| # --------------------------------------------------------------------------- | |
| # Colors / helpers | |
| # --------------------------------------------------------------------------- | |
| GRN=$'\033[32m' # Vibe Kanban | |
| YLW=$'\033[33m' # Proxy | |
| BLU=$'\033[34m' # CopyParty | |
| MAG=$'\033[35m' # Script | |
| RED=$'\033[31m' | |
| BLD=$'\033[1m' | |
| RST=$'\033[0m' | |
| PIDS=() | |
| log() { echo "${MAG}${BLD}[script]${RST} $*"; } | |
| logr() { echo "${RED}${BLD}[script]${RST} $*"; } | |
| prefix() { | |
| local color="$1" label="$2" | |
| while IFS= read -r line; do | |
| printf '%s\n' "${color}${BLD}[${label}]${RST} ${line}" | |
| done | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Cleanup — kill processes and tear down tailscale serve | |
| # --------------------------------------------------------------------------- | |
| cleanup() { | |
| echo "" | |
| logr "Shutting down…" | |
| tailscale serve reset 2>/dev/null || true | |
| for pid in "${PIDS[@]}"; do | |
| kill "$pid" 2>/dev/null && wait "$pid" 2>/dev/null || true | |
| done | |
| rm -f "$HOME/.vk-proxy.js" | |
| logr "Done." | |
| } | |
| trap cleanup EXIT INT TERM | |
| # --------------------------------------------------------------------------- | |
| # Dependency installation | |
| # --------------------------------------------------------------------------- | |
| install_tailscale() { | |
| log "Installing Tailscale…" | |
| curl -fsSL https://tailscale.com/install.sh | sh | |
| } | |
| install_node() { | |
| log "Installing Node.js…" | |
| if command -v apt-get &>/dev/null; then | |
| curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - | |
| sudo apt-get install -y nodejs | |
| elif command -v brew &>/dev/null; then | |
| brew install node | |
| elif command -v dnf &>/dev/null; then | |
| sudo dnf install -y nodejs npm | |
| elif command -v yum &>/dev/null; then | |
| curl -fsSL https://rpm.nodesource.com/setup_lts.x | sudo bash - | |
| sudo yum install -y nodejs | |
| else | |
| logr "Cannot install Node.js: no supported package manager found." | |
| logr "Please install Node.js manually: https://nodejs.org/" | |
| exit 1 | |
| fi | |
| } | |
| install_copyparty() { | |
| log "Installing CopyParty…" | |
| # Try pipx first (best for CLI tools on modern Debian/Ubuntu with PEP 668) | |
| if command -v pipx &>/dev/null; then | |
| pipx install copyparty | |
| elif command -v apt-get &>/dev/null && apt-get install -y pipx 2>/dev/null; then | |
| pipx install copyparty | |
| elif command -v pip3 &>/dev/null; then | |
| pip3 install --user --break-system-packages copyparty 2>/dev/null \ | |
| || pip3 install --user copyparty | |
| elif command -v pip &>/dev/null; then | |
| pip install --user --break-system-packages copyparty 2>/dev/null \ | |
| || pip install --user copyparty | |
| else | |
| logr "Cannot install CopyParty: no pip or pipx found." | |
| logr "Please install pipx then run: pipx install copyparty" | |
| exit 1 | |
| fi | |
| # Ensure ~/.local/bin is on PATH (covers both pip --user and pipx) | |
| export PATH="$HOME/.local/bin:$PATH" | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Pre-flight checks — auto-install missing dependencies | |
| # --------------------------------------------------------------------------- | |
| if ! command -v tailscale &>/dev/null; then | |
| install_tailscale | |
| fi | |
| if ! command -v node &>/dev/null || ! command -v npx &>/dev/null; then | |
| install_node | |
| fi | |
| if ! command -v copyparty &>/dev/null; then | |
| install_copyparty | |
| fi | |
| # Ensure ~/.local/bin is on PATH in case copyparty was just installed | |
| export PATH="$HOME/.local/bin:$PATH" | |
| for cmd in node tailscale npx curl copyparty; do | |
| command -v "$cmd" &>/dev/null || { logr "Required command '$cmd' not found after install attempt."; exit 1; } | |
| done | |
| for port in $VK_PORT $PROXY_PORT $CP_PORT; do | |
| if ss -tlnp 2>/dev/null | grep -q ":${port} "; then | |
| logr "Port ${port} is already in use." | |
| exit 1 | |
| fi | |
| done | |
| # Clear any stale serve config | |
| tailscale serve reset 2>/dev/null || true | |
| log "Ports: Vibe Kanban=${VK_PORT}, Proxy=${PROXY_PORT}, CopyParty=${CP_PORT}" | |
| log "CopyParty serving: ${CP_DIR}" | |
| # --------------------------------------------------------------------------- | |
| # 1. Write the Node.js routing + origin-stripping proxy | |
| # --------------------------------------------------------------------------- | |
| cat > "$HOME/.vk-proxy.js" << 'NODEEOF' | |
| const http = require("http"); | |
| const net = require("net"); | |
| const LISTEN = parseInt(process.env.PROXY_PORT, 10); | |
| const VK_PORT = parseInt(process.env.VK_PORT, 10); | |
| const CP_PORT = parseInt(process.env.CP_PORT, 10); | |
| const HOST = "127.0.0.1"; | |
| function stripOrigin(headers) { | |
| const h = Object.assign({}, headers); | |
| delete h["origin"]; | |
| return h; | |
| } | |
| function isCopyParty(url) { | |
| return url === "/files" || | |
| url.startsWith("/files/") || | |
| url.startsWith("/files?") || | |
| url.startsWith("/.cpr/"); | |
| } | |
| function pickBackend(url) { | |
| return isCopyParty(url) ? CP_PORT : VK_PORT; | |
| } | |
| // --- Normal HTTP requests ------------------------------------------------ | |
| const server = http.createServer((cReq, cRes) => { | |
| const port = pickBackend(cReq.url); | |
| const pReq = http.request( | |
| { | |
| hostname: HOST, | |
| port: port, | |
| path: cReq.url, | |
| method: cReq.method, | |
| headers: stripOrigin(cReq.headers), | |
| }, | |
| (pRes) => { | |
| cRes.writeHead(pRes.statusCode, pRes.headers); | |
| pRes.pipe(cRes); | |
| } | |
| ); | |
| pReq.on("error", (err) => { | |
| process.stderr.write("http error (" + port + "): " + err.message + "\n"); | |
| if (!cRes.headersSent) { | |
| cRes.writeHead(502); | |
| cRes.end("Bad Gateway"); | |
| } | |
| }); | |
| cReq.pipe(pReq); | |
| }); | |
| // --- WebSocket upgrades -------------------------------------------------- | |
| server.on("upgrade", (req, clientSocket, head) => { | |
| const port = pickBackend(req.url); | |
| const headers = stripOrigin(req.headers); | |
| const upstream = net.connect(port, HOST, () => { | |
| let raw = req.method + " " + req.url + " HTTP/" + req.httpVersion + "\r\n"; | |
| for (const [k, v] of Object.entries(headers)) { | |
| const vals = Array.isArray(v) ? v : [v]; | |
| for (const val of vals) raw += k + ": " + val + "\r\n"; | |
| } | |
| raw += "\r\n"; | |
| upstream.write(raw); | |
| if (head.length > 0) upstream.write(head); | |
| upstream.pipe(clientSocket); | |
| clientSocket.pipe(upstream); | |
| }); | |
| upstream.on("error", (err) => { | |
| process.stderr.write("ws error (" + port + "): " + err.message + "\n"); | |
| clientSocket.destroy(); | |
| }); | |
| clientSocket.on("error", () => upstream.destroy()); | |
| }); | |
| server.listen(LISTEN, HOST, () => { | |
| console.log( | |
| "routing proxy on " + HOST + ":" + LISTEN + | |
| " /files/*|/.cpr/* → :" + CP_PORT + | |
| " /* → :" + VK_PORT | |
| ); | |
| }); | |
| NODEEOF | |
| # --------------------------------------------------------------------------- | |
| # 2. Start Vibe Kanban | |
| # --------------------------------------------------------------------------- | |
| log "Starting Vibe Kanban on port ${VK_PORT}…" | |
| PORT="$VK_PORT" npx vibe-kanban 2>&1 | prefix "$GRN" "vk" & | |
| PIDS+=($!) | |
| log "Waiting for Vibe Kanban…" | |
| attempts=0 | |
| until curl -sf "http://127.0.0.1:${VK_PORT}/api/health" >/dev/null 2>&1; do | |
| sleep 1 | |
| attempts=$((attempts + 1)) | |
| if (( attempts > 60 )); then | |
| logr "Vibe Kanban did not start within 60 s — aborting." | |
| exit 1 | |
| fi | |
| done | |
| log "Vibe Kanban is ready." | |
| # --------------------------------------------------------------------------- | |
| # 3. Start CopyParty | |
| # --------------------------------------------------------------------------- | |
| log "Starting CopyParty on port ${CP_PORT}…" | |
| copyparty \ | |
| -i 127.0.0.1 \ | |
| -p "$CP_PORT" \ | |
| --rp-loc /files \ | |
| -v "${CP_DIR}:/:rwA" \ | |
| 2>&1 | prefix "$BLU" "cp" & | |
| PIDS+=($!) | |
| attempts=0 | |
| until curl -sf "http://127.0.0.1:${CP_PORT}/files/" >/dev/null 2>&1; do | |
| sleep 1 | |
| attempts=$((attempts + 1)) | |
| if (( attempts > 30 )); then | |
| logr "CopyParty did not start within 30 s — aborting." | |
| exit 1 | |
| fi | |
| done | |
| log "CopyParty is ready." | |
| # --------------------------------------------------------------------------- | |
| # 4. Start the routing proxy | |
| # --------------------------------------------------------------------------- | |
| log "Starting routing proxy on port ${PROXY_PORT}…" | |
| VK_PORT="$VK_PORT" CP_PORT="$CP_PORT" PROXY_PORT="$PROXY_PORT" \ | |
| node "$HOME/.vk-proxy.js" 2>&1 | prefix "$YLW" "proxy" & | |
| PIDS+=($!) | |
| sleep 1 | |
| if ! curl -sf "http://127.0.0.1:${PROXY_PORT}/api/health" >/dev/null 2>&1; then | |
| logr "Proxy health check failed — aborting." | |
| exit 1 | |
| fi | |
| log "Proxy is ready." | |
| # --------------------------------------------------------------------------- | |
| # 5. Configure Tailscale Serve — single entry point through the proxy | |
| # --------------------------------------------------------------------------- | |
| log "Configuring Tailscale Serve…" | |
| # Grant operator permissions once so future runs don't need sudo | |
| if sudo tailscale set --operator="$USER" 2>/dev/null; then | |
| log "Tailscale operator permissions granted for $USER." | |
| fi | |
| if ! tailscale serve --bg --set-path / "http://127.0.0.1:${PROXY_PORT}" 2>&1; then | |
| log "Retrying with sudo…" | |
| sudo tailscale serve --bg --set-path / "http://127.0.0.1:${PROXY_PORT}" 2>&1 | |
| fi | |
| echo "" | |
| log "${BLD}All services running:${RST}" | |
| tailscale serve status | |
| echo "" | |
| TS_URL=$(tailscale status --json 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print('https://' + d['Self']['DNSName'].rstrip('.'))" 2>/dev/null || echo "https://<your-tailscale-host>") | |
| log " Vibe Kanban → ${TS_URL}/" | |
| log " CopyParty → ${TS_URL}/files/" | |
| log "Press Ctrl-C to stop." | |
| wait |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment