feat: replace Tailscale VPN gate with simple access code

Remove the vpn-setup/ directory (Headscale/Tailscale scripts, CoreDNS,
onboarding) and add a lightweight access code gate instead. Visitors
must enter a code to view the site — validated via API route with an
httpOnly cookie persisted for 1 year.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-16 14:07:22 -07:00
parent 9ea2a0ff9f
commit 228f3c6658
9 changed files with 114 additions and 394 deletions

View File

@ -1 +0,0 @@
/opt/config/CLAUDE.md

0
CLAUDE.md Normal file
View File

View File

@ -0,0 +1,22 @@
import { NextResponse } from "next/server"
const ACCESS_CODE = process.env.ACCESS_CODE || "42069"
export async function POST(request: Request) {
const { code } = await request.json()
if (code !== ACCESS_CODE) {
return NextResponse.json({ error: "Invalid code" }, { status: 401 })
}
const response = NextResponse.json({ success: true })
response.cookies.set("jefflix-access", "granted", {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 60 * 60 * 24 * 365, // 1 year
path: "/",
})
return response
}

67
app/gate/page.tsx Normal file
View File

@ -0,0 +1,67 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { JefflixLogo } from "@/components/jefflix-logo"
export default function GatePage() {
const [code, setCode] = useState("")
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
const router = useRouter()
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError("")
setLoading(true)
const res = await fetch("/api/verify-code", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code }),
})
if (res.ok) {
router.push("/")
router.refresh()
} else {
setError("Wrong code. Try again.")
setLoading(false)
}
}
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="absolute inset-0 bg-gradient-to-br from-yellow-50/80 via-background/90 to-green-50/80" />
<div className="relative w-full max-w-md mx-auto px-4">
<div className="text-center space-y-8">
<JefflixLogo />
<p className="text-lg text-muted-foreground">
Enter the access code to continue
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<input
type="text"
inputMode="numeric"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="Access code"
autoFocus
className="w-full text-center text-2xl tracking-widest px-4 py-3 rounded-lg border-2 border-border bg-card focus:outline-none focus:ring-2 focus:ring-ring"
/>
{error && (
<p className="text-destructive font-medium">{error}</p>
)}
<button
type="submit"
disabled={loading || !code}
className="w-full py-3 px-6 rounded-lg bg-primary text-primary-foreground font-bold text-lg hover:opacity-90 transition-opacity disabled:opacity-50"
>
{loading ? "Checking..." : "Enter"}
</button>
</form>
</div>
</div>
</div>
)
}

25
middleware.ts Normal file
View File

@ -0,0 +1,25 @@
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
export function middleware(request: NextRequest) {
const hasAccess = request.cookies.get("jefflix-access")?.value === "granted"
if (!hasAccess) {
return NextResponse.redirect(new URL("/gate", request.url))
}
return NextResponse.next()
}
export const config = {
matcher: [
/*
* Match all paths except:
* - /gate (the code entry page)
* - /api/verify-code (the verification endpoint)
* - /_next (Next.js internals)
* - /favicon.ico, /icon*, /apple-icon*, /og-image* (static assets)
*/
"/((?!gate|api/verify-code|_next|favicon\\.ico|icon|apple-icon|og-image).*)",
],
}

View File

@ -1,37 +0,0 @@
# Jefflix VPN Setup — Headscale + Tailscale
Protects all `*.jefflix.lol` services behind the existing Headscale VPN at `vpn.jeffemmett.com`.
## How It Works
```
Before (public):
Browser → Cloudflare → Tunnel → Traefik → Jellyfin/etc
After (VPN-only):
Browser → Tailscale (WireGuard) → Traefik → Jellyfin/etc
(Only works if connected to the tailnet)
```
Traefik still routes by Host header — the only change is how traffic reaches it.
## Quick Start
SSH into the server and follow the phases in order:
```bash
ssh netcup
```
Then run `setup.sh` (or follow the manual steps below).
## Files
| File | Purpose |
|------|---------|
| `setup.sh` | Full setup script (run on Netcup) |
| `coredns/Corefile` | CoreDNS config — resolves *.jefflix.lol to Tailscale IP |
| `coredns/docker-compose.yml` | CoreDNS container definition |
| `headscale-config-patch.yaml` | Split DNS addition for Headscale config |
| `cloudflared-config-clean.yml` | Cloudflare tunnel config with jefflix entries removed |
| `rollback.sh` | Emergency rollback script |

View File

@ -1,99 +0,0 @@
#!/bin/bash
set -euo pipefail
# ============================================================
# Jefflix VPN Cutover Script
# Removes public access to *.jefflix.lol
# Run ONLY after setup.sh and testing VPN access works
# ============================================================
echo "========================================"
echo " Jefflix Public Access Cutover"
echo "========================================"
echo ""
# Pre-flight check: verify VPN access works
TAILSCALE_IP=$(tailscale ip -4 2>/dev/null || echo "")
if [ -z "$TAILSCALE_IP" ]; then
echo "ERROR: Tailscale not running on this server. Run setup.sh first."
exit 1
fi
echo "Server Tailscale IP: $TAILSCALE_IP"
echo ""
# Check DNS works
DIG_RESULT=$(dig +short @${TAILSCALE_IP} movies.jefflix.lol 2>/dev/null || echo "FAILED")
if [ "$DIG_RESULT" != "$TAILSCALE_IP" ]; then
echo "ERROR: CoreDNS not resolving correctly. Got: $DIG_RESULT (expected $TAILSCALE_IP)"
echo "Fix CoreDNS before proceeding."
exit 1
fi
echo "✓ CoreDNS resolving correctly"
echo ""
echo "This will REMOVE public access to all *.jefflix.lol services."
echo "Users will need Tailscale connected to vpn.jeffemmett.com to access Jefflix."
echo ""
read -p "Continue? [y/N] " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Aborted."
exit 0
fi
# --- Remove jefflix entries from Cloudflare tunnel ---
echo ""
echo "[Cutover] Removing *.jefflix.lol from Cloudflare tunnel..."
TUNNEL_CONFIG="/root/cloudflared/config.yml"
# Backup current config (timestamped)
cp "$TUNNEL_CONFIG" "${TUNNEL_CONFIG}.pre-cutover-$(date +%Y%m%d-%H%M%S)"
# Remove all jefflix.lol hostname entries (hostname + service lines)
# This removes the "- hostname: *.jefflix.lol" and its " service:" line
python3 -c "
import yaml, sys
with open('$TUNNEL_CONFIG', 'r') as f:
config = yaml.safe_load(f)
original_count = len(config.get('ingress', []))
# Filter out jefflix.lol entries
config['ingress'] = [
entry for entry in config.get('ingress', [])
if not (isinstance(entry.get('hostname', ''), str) and 'jefflix.lol' in entry.get('hostname', ''))
]
removed = original_count - len(config['ingress'])
with open('$TUNNEL_CONFIG', 'w') as f:
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
print(f' Removed {removed} jefflix.lol entries from tunnel config')
"
# Restart cloudflared
echo " Restarting cloudflared..."
docker restart cloudflared
echo " ✓ Cloudflared restarted"
# Wait and verify
sleep 5
echo ""
echo "========================================"
echo " Cutover Complete"
echo "========================================"
echo ""
echo "Public access to *.jefflix.lol is now REMOVED."
echo ""
echo "Verify from a non-VPN device:"
echo " curl -I https://movies.jefflix.lol (should fail/404)"
echo ""
echo "Verify from a VPN device:"
echo " curl http://movies.jefflix.lol (should work)"
echo ""
echo "To ROLLBACK: run rollback.sh"

View File

@ -1,66 +0,0 @@
#!/bin/bash
set -euo pipefail
# ============================================================
# Generate a Jefflix VPN invite for a new user
# Usage: ./onboard-user.sh [username]
# ============================================================
USERNAME="${1:-}"
if [ -z "$USERNAME" ]; then
echo "Usage: ./onboard-user.sh <username>"
echo " Creates a pre-auth key for the user and prints setup instructions."
exit 1
fi
# Generate a single-use pre-auth key (7 day expiry)
echo "Generating pre-auth key for user: $USERNAME"
PREAUTH_KEY=$(docker exec headscale headscale preauthkeys create \
--user jefflix \
--reusable=false \
--expiration 168h 2>&1 | tail -1)
echo ""
echo "============================================================"
echo " Send the following to $USERNAME:"
echo "============================================================"
echo ""
cat <<MSG
Hey! Here's how to connect to Jefflix:
1. Install Tailscale on your device:
- Windows/Mac/Linux: https://tailscale.com/download
- iOS: Search "Tailscale" in the App Store
- Android: Search "Tailscale" in Google Play
2. Connect to the Jefflix VPN:
Desktop (open a terminal and run):
tailscale up --login-server=https://vpn.jeffemmett.com --authkey=$PREAUTH_KEY
iOS: Settings → tap account → "..." → Use custom coordination server
Server: https://vpn.jeffemmett.com
Then log in with this key: $PREAUTH_KEY
Android: Settings → tap account → "..." → Use alternate server
Server: https://vpn.jeffemmett.com
Then log in with this key: $PREAUTH_KEY
3. Once connected, open your browser and go to:
- http://movies.jefflix.lol (Watch movies & shows)
- http://requests.jefflix.lol (Request new content)
- http://upload.jefflix.lol (Upload your own content)
- http://music.jefflix.lol (Listen to music)
Tailscale runs in the background — you only need to set it up once!
NOTE: This invite key expires in 7 days. Let me know if you need a new one.
MSG
echo ""
echo "============================================================"
echo " Key: $PREAUTH_KEY"
echo " Expires: 7 days"
echo " User: jefflix"
echo "============================================================"

View File

@ -1,40 +0,0 @@
#!/bin/bash
set -euo pipefail
# ============================================================
# Jefflix VPN Rollback Script
# Restores public access to *.jefflix.lol
# ============================================================
echo "========================================"
echo " Jefflix VPN Rollback"
echo "========================================"
echo ""
# Restore Cloudflare tunnel config
if [ -f /root/cloudflared/config.yml.backup-jefflix-vpn ]; then
cp /root/cloudflared/config.yml.backup-jefflix-vpn /root/cloudflared/config.yml
docker restart cloudflared
echo "✓ Cloudflare tunnel config restored and restarted"
else
echo "⚠ No backup found at /root/cloudflared/config.yml.backup-jefflix-vpn"
fi
# Restore Headscale config
if [ -f /opt/apps/headscale-deploy/config/config.yaml.backup-jefflix-vpn ]; then
cp /opt/apps/headscale-deploy/config/config.yaml.backup-jefflix-vpn /opt/apps/headscale-deploy/config/config.yaml
cd /opt/apps/headscale-deploy && docker compose restart headscale
echo "✓ Headscale config restored and restarted"
else
echo "⚠ No Headscale backup found"
fi
# Stop CoreDNS (optional — it doesn't hurt to leave it running)
if docker ps --format '{{.Names}}' | grep -q jefflix-dns; then
cd /opt/apps/jefflix-dns && docker compose down
echo "✓ CoreDNS stopped"
fi
echo ""
echo "Rollback complete. Public access to *.jefflix.lol should be restored."
echo "Verify: curl -I https://movies.jefflix.lol"

View File

@ -1,151 +0,0 @@
#!/bin/bash
set -euo pipefail
# ============================================================
# Jefflix VPN Setup Script
# Run this on the Netcup RS 8000 server (ssh netcup)
# ============================================================
echo "========================================"
echo " Jefflix VPN Setup (Headscale/Tailscale)"
echo "========================================"
echo ""
# --- Phase 1: Backups ---
echo "[Phase 1] Creating backups..."
cp /root/cloudflared/config.yml /root/cloudflared/config.yml.backup-jefflix-vpn
cp /opt/apps/headscale-deploy/config/config.yaml /opt/apps/headscale-deploy/config/config.yaml.backup-jefflix-vpn
cp -r /root/traefik/config/ /root/traefik/config-backup-jefflix-vpn/ 2>/dev/null || true
echo " ✓ Backups created"
# --- Phase 1b: Create Headscale users ---
echo ""
echo "[Phase 1b] Creating Headscale users..."
docker exec headscale headscale users create server 2>/dev/null || echo " (user 'server' already exists)"
docker exec headscale headscale users create jefflix 2>/dev/null || echo " (user 'jefflix' already exists)"
echo " ✓ Users ready"
# --- Phase 2: Install Tailscale ---
echo ""
echo "[Phase 2] Installing Tailscale..."
if command -v tailscale &>/dev/null; then
echo " Tailscale already installed: $(tailscale version)"
else
curl -fsSL https://tailscale.com/install.sh | sh
echo " ✓ Tailscale installed"
fi
# Generate pre-auth key and join tailnet
echo ""
echo " Generating pre-auth key..."
PREAUTH_KEY=$(docker exec headscale headscale preauthkeys create --user server --reusable=false --expiration 1h 2>&1 | tail -1)
echo " Pre-auth key: $PREAUTH_KEY"
echo ""
echo " Joining tailnet..."
tailscale up \
--login-server=https://vpn.jeffemmett.com \
--authkey="$PREAUTH_KEY" \
--hostname=netcup-rs8000 \
--accept-dns=false
# Get the Tailscale IP
TAILSCALE_IP=$(tailscale ip -4)
echo " ✓ Joined tailnet with IP: $TAILSCALE_IP"
# --- Phase 3: Deploy CoreDNS ---
echo ""
echo "[Phase 3] Deploying CoreDNS for *.jefflix.lol..."
mkdir -p /opt/apps/jefflix-dns
# Write Corefile with the actual Tailscale IP
cat > /opt/apps/jefflix-dns/Corefile <<COREFILE_EOF
jefflix.lol {
template IN A {
answer "{{ .Name }} 60 IN A ${TAILSCALE_IP}"
}
log
}
COREFILE_EOF
cat > /opt/apps/jefflix-dns/docker-compose.yml <<COMPOSE_EOF
services:
coredns:
image: coredns/coredns:latest
container_name: jefflix-dns
restart: unless-stopped
command: -conf /etc/coredns/Corefile
volumes:
- ./Corefile:/etc/coredns/Corefile:ro
ports:
- "${TAILSCALE_IP}:53:53/udp"
- "${TAILSCALE_IP}:53:53/tcp"
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
security_opt:
- no-new-privileges:true
COMPOSE_EOF
cd /opt/apps/jefflix-dns && docker compose up -d
echo " ✓ CoreDNS deployed"
# Test DNS
echo ""
echo " Testing DNS resolution..."
sleep 2
DIG_RESULT=$(dig +short @${TAILSCALE_IP} movies.jefflix.lol 2>/dev/null || echo "FAILED")
if [ "$DIG_RESULT" = "$TAILSCALE_IP" ]; then
echo " ✓ DNS test passed: movies.jefflix.lol -> $TAILSCALE_IP"
else
echo " ⚠ DNS test returned: $DIG_RESULT (expected $TAILSCALE_IP)"
echo " Check CoreDNS logs: docker logs jefflix-dns"
fi
# --- Phase 4: Configure Headscale split DNS ---
echo ""
echo "[Phase 4] Configuring Headscale split DNS..."
HEADSCALE_CONFIG="/opt/apps/headscale-deploy/config/config.yaml"
# Check if split DNS is already configured
if grep -q "jefflix.lol" "$HEADSCALE_CONFIG"; then
echo " Split DNS for jefflix.lol already in config, skipping"
else
# Add split DNS section to the nameservers block
# This uses sed to add the split DNS config after the global nameservers
sed -i '/nameservers:/,/^[^ ]/ {
/global:/,/^ [^ ]/ {
/- 1.0.0.1/a\ split:\n jefflix.lol:\n - '"${TAILSCALE_IP}"'
}
}' "$HEADSCALE_CONFIG"
echo " ✓ Split DNS added to Headscale config"
fi
# Restart Headscale
cd /opt/apps/headscale-deploy && docker compose restart headscale
echo " ✓ Headscale restarted"
sleep 3
# Verify Headscale is healthy
docker exec headscale headscale nodes list >/dev/null 2>&1 && echo " ✓ Headscale healthy" || echo " ⚠ Headscale may need attention"
echo ""
echo "========================================"
echo " Setup Complete (Phases 1-4)"
echo "========================================"
echo ""
echo "Server Tailscale IP: $TAILSCALE_IP"
echo "CoreDNS: running on $TAILSCALE_IP:53"
echo "Split DNS: jefflix.lol -> $TAILSCALE_IP"
echo ""
echo "NEXT STEPS:"
echo " 1. Connect YOUR device to the tailnet and test:"
echo " tailscale up --login-server=https://vpn.jeffemmett.com"
echo " dig movies.jefflix.lol (should return $TAILSCALE_IP)"
echo " curl http://movies.jefflix.lol (should return Jellyfin)"
echo ""
echo " 2. Once confirmed working, run cutover.sh to remove public access"
echo " 3. Then onboard users with pre-auth keys"
echo ""