diff --git a/CLAUDE.md b/CLAUDE.md
deleted file mode 120000
index b262ab7..0000000
--- a/CLAUDE.md
+++ /dev/null
@@ -1 +0,0 @@
-/opt/config/CLAUDE.md
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..e69de29
diff --git a/app/api/verify-code/route.ts b/app/api/verify-code/route.ts
new file mode 100644
index 0000000..ccea410
--- /dev/null
+++ b/app/api/verify-code/route.ts
@@ -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
+}
diff --git a/app/gate/page.tsx b/app/gate/page.tsx
new file mode 100644
index 0000000..38f4c67
--- /dev/null
+++ b/app/gate/page.tsx
@@ -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 (
+
+
+
+
+
+
+ Enter the access code to continue
+
+
+
+
+
+ )
+}
diff --git a/middleware.ts b/middleware.ts
new file mode 100644
index 0000000..ccd9ba7
--- /dev/null
+++ b/middleware.ts
@@ -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).*)",
+ ],
+}
diff --git a/vpn-setup/README.md b/vpn-setup/README.md
deleted file mode 100644
index bcbbd9b..0000000
--- a/vpn-setup/README.md
+++ /dev/null
@@ -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 |
diff --git a/vpn-setup/cutover.sh b/vpn-setup/cutover.sh
deleted file mode 100755
index 35c44ca..0000000
--- a/vpn-setup/cutover.sh
+++ /dev/null
@@ -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"
diff --git a/vpn-setup/onboard-user.sh b/vpn-setup/onboard-user.sh
deleted file mode 100755
index bfcf026..0000000
--- a/vpn-setup/onboard-user.sh
+++ /dev/null
@@ -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 "
- 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 </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 < /opt/apps/jefflix-dns/docker-compose.yml </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 ""