Skip to content

Instantly share code, notes, and snippets.

@cemoody
Last active March 11, 2026 21:13
Show Gist options
  • Select an option

  • Save cemoody/4a047b74d61e361d5f98ee1b1507eff2 to your computer and use it in GitHub Desktop.

Select an option

Save cemoody/4a047b74d61e361d5f98ee1b1507eff2 to your computer and use it in GitHub Desktop.
Vibe Kanban + CopyParty behind Tailscale Serve with origin-stripping proxy
#!/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