feat: Next.js SSH Launcher with full automation pipeline, metrics agent, dark/light mode

This commit is contained in:
chunzhimoe 2026-04-12 16:16:48 +08:00
parent e231778f54
commit 83fa93f7b7
25 changed files with 4027 additions and 0 deletions

14
.env.example Normal file
View File

@ -0,0 +1,14 @@
# Cloudflare credentials (server-side only, never sent to browser)
CLOUDFLARE_ACCOUNT_ID=your_account_id_here
CLOUDFLARE_API_TOKEN=your_api_token_here
# Required API Token permissions:
# - Access: Apps and Policies Write (list + create Access apps)
# - Cloudflare Tunnel: Edit (create / configure / delete tunnels)
# - DNS: Edit (create CNAME records)
# - Zone: Read (lookup zone ID for a hostname)
# Optional: Cloudflare Access Service Token for fetching server metrics
# (Create one in Zero Trust > Settings > Service Auth)
CF_SERVICE_CLIENT_ID=
CF_SERVICE_CLIENT_SECRET=

37
.gitignore vendored Normal file
View File

@ -0,0 +1,37 @@
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

222
metrics-agent.sh Normal file
View File

@ -0,0 +1,222 @@
#!/usr/bin/env bash
# metrics-agent.sh — Lightweight HTTP metrics server for SSH Launcher
# Serves JSON at http://localhost:9101/metrics.json
# Requires: bash, python3 (standard library only), optional: nvidia-smi
#
# Usage:
# sudo bash metrics-agent.sh install # Install + start as systemd service
# sudo bash metrics-agent.sh start # Run in foreground (testing)
set -euo pipefail
METRICS_PORT="${METRICS_PORT:-9101}"
INSTALL_DIR="/usr/local/lib/ssh-metrics"
# ─── Metrics collection script (Python) ───────────────────────────────────────
write_collector() {
mkdir -p "$INSTALL_DIR"
cat > "$INSTALL_DIR/metrics_server.py" <<'PYEOF'
#!/usr/bin/env python3
import json, os, time, subprocess, socket
from http.server import HTTPServer, BaseHTTPRequestHandler
def read_file(path):
try:
with open(path) as f:
return f.read()
except Exception:
return ""
def cpu_percent():
"""Read CPU usage from /proc/stat (two samples, 200ms apart)."""
def read_stat():
line = read_file("/proc/stat").split("\n")[0]
fields = list(map(int, line.split()[1:]))
idle = fields[3] + (fields[4] if len(fields) > 4 else 0)
total = sum(fields)
return idle, total
i1, t1 = read_stat()
time.sleep(0.2)
i2, t2 = read_stat()
dt = t2 - t1
if dt == 0:
return 0.0
return round((1 - (i2 - i1) / dt) * 100, 1)
def memory():
raw = {}
for line in read_file("/proc/meminfo").splitlines():
if ":" in line:
k, v = line.split(":", 1)
raw[k.strip()] = int(v.strip().split()[0])
total = raw.get("MemTotal", 0)
avail = raw.get("MemAvailable", raw.get("MemFree", 0))
used = total - avail
pct = round(used / total * 100, 1) if total else 0
return {"used": used, "total": total, "percent": pct}
def disk():
try:
st = os.statvfs("/")
total = st.f_blocks * st.f_frsize // 1024
avail = st.f_bavail * st.f_frsize // 1024
used = total - avail
pct = round(used / total * 100, 1) if total else 0
return {"used": used, "total": total, "percent": pct}
except Exception:
return {"used": 0, "total": 0, "percent": 0}
_prev_net = {}
def network():
global _prev_net
iface_stats = {}
for line in read_file("/proc/net/dev").splitlines()[2:]:
parts = line.split()
if len(parts) < 10:
continue
iface = parts[0].rstrip(":")
if iface == "lo":
continue
iface_stats[iface] = {"rx": int(parts[1]), "tx": int(parts[9])}
now = time.time()
result = {"rxBytesPerSec": 0, "txBytesPerSec": 0}
if _prev_net:
dt = now - _prev_net["ts"]
if dt > 0:
for iface, vals in iface_stats.items():
prev = _prev_net.get(iface, {})
if prev:
result["rxBytesPerSec"] += max(0, int((vals["rx"] - prev["rx"]) / dt))
result["txBytesPerSec"] += max(0, int((vals["tx"] - prev["tx"]) / dt))
_prev_net = {"ts": now, **{k: v for k, v in iface_stats.items()}}
return result
def load_avg():
try:
parts = read_file("/proc/loadavg").split()
return [float(parts[0]), float(parts[1]), float(parts[2])]
except Exception:
return [0.0, 0.0, 0.0]
def uptime():
try:
return float(read_file("/proc/uptime").split()[0])
except Exception:
return 0.0
def gpu_info():
try:
out = subprocess.check_output([
"nvidia-smi",
"--query-gpu=name,temperature.gpu,utilization.gpu,memory.used,memory.total",
"--format=csv,noheader,nounits"
], timeout=3, stderr=subprocess.DEVNULL).decode()
gpus = []
for line in out.strip().splitlines():
parts = [p.strip() for p in line.split(",")]
if len(parts) >= 5:
gpus.append({
"name": parts[0],
"tempC": int(parts[1]),
"utilPercent": int(parts[2]),
"memUsedMiB": int(parts[3]),
"memTotalMiB": int(parts[4]),
})
return gpus
except Exception:
return None
def collect():
data = {
"hostname": socket.gethostname(),
"timestamp": int(time.time() * 1000),
"cpu": cpu_percent(),
"memory": memory(),
"disk": disk(),
"network": network(),
"load": load_avg(),
"uptime": uptime(),
}
gpu = gpu_info()
if gpu is not None:
data["gpu"] = gpu
return data
class Handler(BaseHTTPRequestHandler):
def log_message(self, *_):
pass
def do_GET(self):
if self.path not in ("/metrics.json", "/"):
self.send_response(404)
self.end_headers()
return
try:
payload = json.dumps(collect()).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(payload)
except Exception as e:
self.send_response(500)
self.end_headers()
self.wfile.write(str(e).encode())
if __name__ == "__main__":
import sys
port = int(os.environ.get("METRICS_PORT", 9101))
server = HTTPServer(("127.0.0.1", port), Handler)
print(f"metrics-agent listening on 127.0.0.1:{port}", file=sys.stderr)
server.serve_forever()
PYEOF
chmod +x "$INSTALL_DIR/metrics_server.py"
}
# ─── Systemd service ──────────────────────────────────────────────────────────
install_service() {
write_collector
cat > /etc/systemd/system/ssh-metrics.service <<EOF
[Unit]
Description=SSH Launcher Metrics Agent
After=network.target
Wants=network.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 ${INSTALL_DIR}/metrics_server.py
Restart=on-failure
RestartSec=5
Environment=METRICS_PORT=${METRICS_PORT}
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now ssh-metrics.service
echo "✓ ssh-metrics.service installed and started on port ${METRICS_PORT}"
echo " Test: curl http://localhost:${METRICS_PORT}/metrics.json"
}
# ─── Entry point ─────────────────────────────────────────────────────────────
case "${1:-start}" in
install)
install_service
;;
start)
write_collector
exec python3 "$INSTALL_DIR/metrics_server.py"
;;
*)
echo "Usage: $0 {install|start}"
exit 1
;;
esac

5
next.config.ts Normal file
View File

@ -0,0 +1,5 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {};
export default nextConfig;

1682
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "cf-ssh-launcher",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^15.3.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"lucide-react": "^0.487.0",
"next-themes": "^0.4.6",
"jose": "^5.10.0"
},
"devDependencies": {
"@types/node": "^22.14.1",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"typescript": "^5.8.3",
"@tailwindcss/postcss": "^4.1.4",
"tailwindcss": "^4.1.4"
}
}

8
postcss.config.mjs Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
import { fetchServerMetrics } from "@/lib/metrics";
export async function GET(req: NextRequest) {
const url = req.nextUrl.searchParams.get("url");
if (!url) {
return NextResponse.json({ error: "Missing ?url=" }, { status: 400 });
}
try {
const metrics = await fetchServerMetrics(url);
return NextResponse.json(metrics, {
headers: { "Cache-Control": "no-store" },
});
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : "Failed to fetch metrics" },
{ status: 502 }
);
}
}

View File

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { runFullSetup, type SetupInput } from "@/lib/cloudflare";
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const input: SetupInput = {
serverName: body.serverName?.trim(),
hostname: body.hostname?.trim(),
sshPort: Number(body.sshPort) || 22,
ssoEmail: body.ssoEmail?.trim(),
allowedEmails: (body.allowedEmails || "")
.split(/[,\n]/)
.map((e: string) => e.trim())
.filter(Boolean),
};
if (!input.serverName || !input.hostname || !input.ssoEmail) {
return NextResponse.json(
{ error: "serverName, hostname, and ssoEmail are required" },
{ status: 400 }
);
}
const result = await runFullSetup(input);
return NextResponse.json(result);
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : "Internal error" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
import { getSshAppById } from "@/lib/cloudflare";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const app = await getSshAppById(id);
if (!app) {
return NextResponse.json(
{ error: "Application not found or not an SSH app" },
{ status: 404 }
);
}
return NextResponse.redirect(app.launchUrl, 302);
}

350
src/app/create/page.tsx Normal file
View File

@ -0,0 +1,350 @@
"use client";
import { useState } from "react";
import {
Rocket,
CheckCircle2,
XCircle,
AlertTriangle,
Loader2,
Copy,
Check,
ExternalLink,
Server,
Globe,
Mail,
Terminal,
Hash,
} from "lucide-react";
interface StepResult {
step: string;
status: "success" | "error" | "skipped";
message: string;
}
interface SetupResult {
steps: StepResult[];
tunnelToken: string | null;
caPubkey: string | null;
installCommand: string | null;
launchUrl: string | null;
}
const STEP_LABELS: Record<string, string> = {
find_zone: "Lookup DNS Zone",
create_tunnel: "Create Tunnel",
configure_tunnel: "Configure Ingress",
create_dns: "Create DNS Record",
create_access_app: "Create Access App",
setup_ca: "Setup Short-lived Cert",
get_token: "Get Tunnel Token",
browser_rendering: "Browser Rendering",
};
export default function CreatePage() {
const [form, setForm] = useState({
serverName: "",
hostname: "",
sshPort: "22",
ssoEmail: "",
allowedEmails: "",
});
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<SetupResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const canSubmit =
form.serverName.trim() && form.hostname.trim() && form.ssoEmail.trim();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setResult(null);
setError(null);
try {
const res = await fetch("/api/setup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
setResult(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setLoading(false);
}
}
function copyCommand() {
if (!result?.installCommand) return;
navigator.clipboard.writeText(result.installCommand);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
const set = (key: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
setForm((f) => ({ ...f, [key]: e.target.value }));
return (
<div className="min-h-[calc(100vh-3.5rem)]">
<div className="fixed inset-0 -z-10">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_60%_40%_at_50%_-10%,rgba(16,185,129,0.08),transparent)]" />
</div>
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
{/* Header */}
<div className="flex items-center gap-3 mb-1">
<div className="w-10 h-10 rounded-xl bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center">
<Rocket className="w-5 h-5 text-emerald-400" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">Deploy SSH Host</h1>
<p className="text-sm text-zinc-500">
One form Tunnel + DNS + Access App + Install Command
</p>
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="mt-8 space-y-5">
<Field
icon={<Server className="w-4 h-4" />}
label="Server Name"
hint="A friendly label for this server, e.g. prod-web-1"
>
<input
type="text"
value={form.serverName}
onChange={set("serverName")}
placeholder="prod-web-1"
className="form-input"
required
/>
</Field>
<Field
icon={<Globe className="w-4 h-4" />}
label="SSH Hostname"
hint="The domain to access this server, e.g. ssh.example.com"
>
<input
type="text"
value={form.hostname}
onChange={set("hostname")}
placeholder="ssh.example.com"
className="form-input"
required
/>
</Field>
<Field
icon={<Hash className="w-4 h-4" />}
label="SSH Port"
hint="Server-side SSH port (usually 22)"
>
<input
type="number"
value={form.sshPort}
onChange={set("sshPort")}
placeholder="22"
className="form-input w-24"
/>
</Field>
<Field
icon={<Mail className="w-4 h-4" />}
label="SSO Email"
hint="Your Cloudflare SSO email — its prefix becomes the Linux user"
>
<input
type="email"
value={form.ssoEmail}
onChange={set("ssoEmail")}
placeholder="admin@example.com"
className="form-input"
required
/>
</Field>
<Field
icon={<Mail className="w-4 h-4" />}
label="Allowed Emails (optional)"
hint="Comma-separated list of emails for the Access Policy. Leave empty = any authenticated user"
>
<textarea
value={form.allowedEmails}
onChange={set("allowedEmails")}
placeholder="alice@example.com, bob@example.com"
rows={2}
className="form-input resize-none"
/>
</Field>
<button
type="submit"
disabled={!canSubmit || loading}
className="w-full flex items-center justify-center gap-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 disabled:bg-zinc-800 disabled:text-zinc-600 text-white font-semibold py-3 px-4 transition"
>
{loading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Deploying...
</>
) : (
<>
<Rocket className="w-4 h-4" />
Deploy Now
</>
)}
</button>
</form>
{/* Error */}
{error && (
<div className="mt-6 rounded-xl border border-red-500/20 bg-red-500/5 p-4 flex items-start gap-3">
<XCircle className="w-5 h-5 text-red-400 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-red-300">Deploy failed</p>
<p className="text-sm text-red-400/80 mt-1 break-words">{error}</p>
</div>
</div>
)}
{/* Results */}
{result && (
<div className="mt-8 space-y-6">
{/* Steps */}
<div className="rounded-xl border border-zinc-800 bg-zinc-900/60 overflow-hidden">
<div className="px-5 py-3 border-b border-zinc-800 text-sm font-medium text-zinc-400">
Pipeline Steps
</div>
<div className="divide-y divide-zinc-800/50">
{result.steps.map((s, i) => (
<div key={i} className="flex items-center gap-3 px-5 py-3">
{s.status === "success" && (
<CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0" />
)}
{s.status === "error" && (
<XCircle className="w-4 h-4 text-red-400 shrink-0" />
)}
{s.status === "skipped" && (
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0" />
)}
<div className="min-w-0 flex-1">
<span className="text-sm font-medium text-zinc-300">
{STEP_LABELS[s.step] || s.step}
</span>
<p className="text-xs text-zinc-500 truncate">
{s.message}
</p>
</div>
</div>
))}
</div>
</div>
{/* Install command */}
{result.installCommand && (
<div className="rounded-xl border border-zinc-800 bg-zinc-900/60 overflow-hidden">
<div className="px-5 py-3 border-b border-zinc-800 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-medium text-zinc-400">
<Terminal className="w-4 h-4" />
Install Command
</div>
<button
onClick={copyCommand}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium transition bg-zinc-800 hover:bg-zinc-700 text-zinc-400"
>
{copied ? (
<>
<Check className="w-3 h-3 text-emerald-400" />
Copied
</>
) : (
<>
<Copy className="w-3 h-3" />
Copy
</>
)}
</button>
</div>
<pre className="px-5 py-4 text-sm text-emerald-300 font-mono overflow-x-auto whitespace-pre-wrap break-all leading-relaxed">
{result.installCommand}
</pre>
<div className="px-5 py-3 border-t border-zinc-800 text-xs text-zinc-600">
Paste this command on your server (requires root). It will
install cloudflared, register the tunnel, configure SSH CA
trust, and create the login user.
</div>
</div>
)}
{/* Launch URL */}
{result.launchUrl && (
<a
href={result.launchUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 rounded-xl border border-emerald-500/30 bg-emerald-500/5 p-4 text-emerald-300 hover:bg-emerald-500/10 transition"
>
<ExternalLink className="w-4 h-4" />
<span className="font-medium">
Open {result.launchUrl}
</span>
</a>
)}
</div>
)}
</div>
<style jsx>{`
.form-input {
width: 100%;
border-radius: 0.75rem;
border: 1px solid rgb(39 39 42);
background: rgb(24 24 27 / 0.8);
padding: 0.625rem 0.875rem;
font-size: 0.875rem;
color: rgb(228 228 231);
outline: none;
transition: all 0.15s;
}
.form-input:focus {
border-color: rgb(16 185 129 / 0.5);
box-shadow: 0 0 0 2px rgb(16 185 129 / 0.1);
}
.form-input::placeholder {
color: rgb(113 113 122);
}
`}</style>
</div>
);
}
function Field({
icon,
label,
hint,
children,
}: {
icon: React.ReactNode;
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<div>
<label className="flex items-center gap-1.5 text-sm font-medium text-zinc-300 mb-1.5">
<span className="text-zinc-500">{icon}</span>
{label}
</label>
{children}
{hint && <p className="text-xs text-zinc-600 mt-1">{hint}</p>}
</div>
);
}

38
src/app/globals.css Normal file
View File

@ -0,0 +1,38 @@
@import "tailwindcss";
/* Tailwind v4: class-based dark mode (set by next-themes on <html>) */
@custom-variant dark (&:where(.dark, .dark *));
/* Design tokens */
:root {
--bg: #ffffff;
--bg-subtle: #f8fafc;
--bg-card: #f1f5f9;
--border: #e2e8f0;
--border-subtle: #f1f5f9;
--text: #0f172a;
--text-muted: #64748b;
--text-faint: #94a3b8;
--accent: #10b981;
--accent-dim: rgba(16,185,129,0.12);
--accent-border: rgba(16,185,129,0.3);
}
.dark {
--bg: #09090b;
--bg-subtle: #111113;
--bg-card: #18181b;
--border: #27272a;
--border-subtle: #1f1f22;
--text: #f4f4f5;
--text-muted: #71717a;
--text-faint: #3f3f46;
--accent: #10b981;
--accent-dim: rgba(16,185,129,0.1);
--accent-border: rgba(16,185,129,0.25);
}
body {
background: var(--bg);
color: var(--text);
}

26
src/app/layout.tsx Normal file
View File

@ -0,0 +1,26 @@
import type { Metadata } from "next";
import Navigation from "@/components/Navigation";
import Providers from "./providers";
import "./globals.css";
export const metadata: Metadata = {
title: "SSH Launcher",
description: "Cloudflare Zero Trust SSH Web Terminal Launcher",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<body className="min-h-screen antialiased transition-colors duration-200">
<Providers>
<Navigation />
{children}
</Providers>
</body>
</html>
);
}

56
src/app/loading.tsx Normal file
View File

@ -0,0 +1,56 @@
export default function Loading() {
return (
<div className="min-h-screen">
<div className="fixed inset-0 -z-10">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,rgba(16,185,129,0.12),transparent)]" />
<div className="absolute inset-0 bg-zinc-950" style={{ zIndex: -1 }} />
</div>
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8 sm:py-12 animate-pulse">
{/* Header skeleton */}
<div className="flex items-center gap-3 mb-10">
<div className="w-10 h-10 rounded-xl bg-zinc-800" />
<div>
<div className="h-6 w-40 rounded bg-zinc-800 mb-1.5" />
<div className="h-4 w-64 rounded bg-zinc-900" />
</div>
</div>
{/* Stats skeleton */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="rounded-xl border border-zinc-800/80 bg-zinc-900/60 p-4"
>
<div className="h-3 w-16 rounded bg-zinc-800 mb-2" />
<div className="h-7 w-10 rounded bg-zinc-800" />
</div>
))}
</div>
{/* Search skeleton */}
<div className="h-10 rounded-xl bg-zinc-900 border border-zinc-800 mb-6" />
{/* Card skeletons */}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="rounded-xl border border-zinc-800/80 bg-zinc-900/60 p-5"
>
<div className="flex items-center gap-2.5 mb-3">
<div className="w-8 h-8 rounded-lg bg-zinc-800" />
<div className="h-5 w-32 rounded bg-zinc-800" />
</div>
<div className="h-4 w-48 rounded bg-zinc-800/60 mb-3" />
<div className="pt-3 border-t border-zinc-800/50">
<div className="h-3 w-20 rounded bg-zinc-800/40" />
</div>
</div>
))}
</div>
</div>
</div>
);
}

34
src/app/page.tsx Normal file
View File

@ -0,0 +1,34 @@
import { listAccessApps, filterSshApps } from "@/lib/cloudflare";
import Dashboard from "@/components/Dashboard";
import { AlertTriangle } from "lucide-react";
export const revalidate = 60;
export default async function Home() {
let apps;
let error: string | null = null;
try {
const allApps = await listAccessApps();
apps = filterSshApps(allApps);
} catch (e) {
error = e instanceof Error ? e.message : "Unknown error";
apps = [];
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="max-w-md w-full rounded-2xl border border-red-500/20 bg-red-500/5 p-8 text-center">
<AlertTriangle className="w-10 h-10 text-red-400 mx-auto mb-4" />
<h2 className="text-lg font-semibold text-red-300 mb-2">
Failed to load applications
</h2>
<p className="text-sm text-red-400/80 break-words">{error}</p>
</div>
</div>
);
}
return <Dashboard apps={apps} />;
}

11
src/app/providers.tsx Normal file
View File

@ -0,0 +1,11 @@
"use client";
import { ThemeProvider } from "next-themes";
export default function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
{children}
</ThemeProvider>
);
}

33
src/app/tunnels/page.tsx Normal file
View File

@ -0,0 +1,33 @@
import { listTunnels } from "@/lib/cloudflare";
import TunnelList from "@/components/TunnelList";
import { AlertTriangle } from "lucide-react";
export const revalidate = 30;
export default async function TunnelsPage() {
let tunnels;
let error: string | null = null;
try {
tunnels = await listTunnels();
} catch (e) {
error = e instanceof Error ? e.message : "Unknown error";
tunnels = [];
}
if (error) {
return (
<div className="min-h-[calc(100vh-3.5rem)] flex items-center justify-center p-4">
<div className="max-w-md w-full rounded-2xl border border-red-500/20 bg-red-500/5 p-8 text-center">
<AlertTriangle className="w-10 h-10 text-red-400 mx-auto mb-4" />
<h2 className="text-lg font-semibold text-red-300 mb-2">
Failed to load tunnels
</h2>
<p className="text-sm text-red-400/80 break-words">{error}</p>
</div>
</div>
);
}
return <TunnelList tunnels={tunnels} />;
}

View File

@ -0,0 +1,308 @@
"use client";
import { useState, useMemo } from "react";
import {
Terminal,
ExternalLink,
Search,
Server,
Clock,
Tag,
Globe,
Shield,
Activity,
ChevronRight,
Monitor,
} from "lucide-react";
import MetricsWidget from "@/components/MetricsWidget";
export interface SshAppData {
id: string;
name: string;
domain: string;
domains: string[];
launchUrl: string;
createdAt: string | null;
updatedAt: string | null;
tags: string[];
logoUrl: string | null;
sessionDuration: string | null;
metricsUrl: string | null;
}
function timeAgo(dateStr: string | null): string {
if (!dateStr) return "Unknown";
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "Just now";
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
return `${months}mo ago`;
}
export default function Dashboard({ apps }: { apps: SshAppData[] }) {
const [search, setSearch] = useState("");
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const allTags = useMemo(() => {
const tagSet = new Set<string>();
apps.forEach((a) => a.tags.forEach((t) => tagSet.add(t)));
return Array.from(tagSet).sort();
}, [apps]);
const filtered = useMemo(() => {
return apps.filter((app) => {
const q = search.toLowerCase();
const matchSearch =
!q ||
app.name.toLowerCase().includes(q) ||
app.domain.toLowerCase().includes(q) ||
app.tags.some((t) => t.toLowerCase().includes(q));
const matchTag = !selectedTag || app.tags.includes(selectedTag);
return matchSearch && matchTag;
});
}, [apps, search, selectedTag]);
return (
<div className="min-h-screen bg-[var(--bg)]">
{/* Background accent */}
<div className="fixed inset-0 -z-10 pointer-events-none">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,var(--accent-dim),transparent)]" />
</div>
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
{/* Header */}
<header className="mb-10">
<div className="flex items-center gap-3 mb-1">
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-[var(--accent-dim)] border border-[var(--accent-border)]">
<Monitor className="w-5 h-5 text-emerald-500" />
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight text-[var(--text)]">
SSH Launcher
</h1>
<p className="text-sm text-[var(--text-muted)]">
Cloudflare Zero Trust · Browser Terminal
</p>
</div>
</div>
</header>
{/* Stats row */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8">
<StatCard
icon={<Server className="w-4 h-4" />}
label="Total Hosts"
value={apps.length}
/>
<StatCard
icon={<Activity className="w-4 h-4" />}
label="Visible"
value={filtered.length}
/>
<StatCard
icon={<Tag className="w-4 h-4" />}
label="Tags"
value={allTags.length}
/>
<StatCard
icon={<Shield className="w-4 h-4" />}
label="Zero Trust"
value="ON"
accent
/>
</div>
{/* Search + Tags */}
<div className="mb-6 space-y-3">
{/* Search bar */}
<div className="relative">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-faint)]" />
<input
type="text"
placeholder="Search hosts by name, domain, or tag..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full rounded-xl border border-[var(--border)] bg-[var(--bg-subtle)] pl-10 pr-4 py-2.5 text-sm text-[var(--text)] placeholder:text-[var(--text-faint)] outline-none transition focus:border-[var(--accent-border)] focus:ring-1 focus:ring-[var(--accent-dim)]"
/>
</div>
{/* Tag pills */}
{allTags.length > 0 && (
<div className="flex flex-wrap gap-2">
<button
onClick={() => setSelectedTag(null)}
className={`px-3 py-1 rounded-lg text-xs font-medium transition ${
selectedTag === null
? "bg-emerald-500/20 text-emerald-300 border border-emerald-500/30"
: "bg-zinc-800/50 text-zinc-500 border border-zinc-800 hover:border-zinc-700"
}`}
>
All
</button>
{allTags.map((tag) => (
<button
key={tag}
onClick={() =>
setSelectedTag(selectedTag === tag ? null : tag)
}
className={`px-3 py-1 rounded-lg text-xs font-medium transition ${
selectedTag === tag
? "bg-[var(--accent-dim)] text-emerald-600 dark:text-emerald-300 border border-[var(--accent-border)]"
: "bg-[var(--bg-card)] text-[var(--text-muted)] border border-[var(--border)] hover:border-[var(--text-faint)]"
}`}
>
{tag}
</button>
))}
</div>
)}
</div>
{/* Empty state */}
{filtered.length === 0 && (
<div className="flex flex-col items-center justify-center py-24 text-[var(--text-muted)]">
<div className="w-16 h-16 rounded-2xl bg-[var(--bg-card)] border border-[var(--border)] flex items-center justify-center mb-4">
<Terminal className="w-8 h-8 opacity-30" />
</div>
<p className="text-lg font-medium text-[var(--text-muted)]">
No hosts found
</p>
<p className="text-sm mt-1">
{search || selectedTag
? "Try adjusting your search or filter."
: "Ensure your API token has Access: Apps and Policies Read permission."}
</p>
</div>
)}
{/* Host cards */}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((app) => (
<HostCard key={app.id} app={app} />
))}
</div>
{/* Footer */}
<footer className="mt-14 pt-6 border-t border-[var(--border-subtle)] flex flex-col sm:flex-row items-center justify-between gap-2 text-xs text-[var(--text-faint)]">
<div className="flex items-center gap-1.5">
<Shield className="w-3 h-3" />
<span>Protected by Cloudflare Zero Trust</span>
</div>
<div className="flex items-center gap-1.5">
<Clock className="w-3 h-3" />
<span>Auto-refreshes every 60s</span>
</div>
</footer>
</div>
</div>
);
}
/* ---------- Sub-components ---------- */
function StatCard({
icon,
label,
value,
accent,
}: {
icon: React.ReactNode;
label: string;
value: string | number;
accent?: boolean;
}) {
return (
<div className="rounded-xl border border-[var(--border)] bg-[var(--bg-card)] p-4">
<div
className={`flex items-center gap-1.5 text-xs mb-1 ${
accent ? "text-emerald-500" : "text-[var(--text-muted)]"
}`}
>
{icon}
<span>{label}</span>
</div>
<div
className={`text-xl font-bold tracking-tight ${
accent ? "text-emerald-500" : "text-[var(--text)]"
}`}
>
{value}
</div>
</div>
);
}
function HostCard({ app }: { app: SshAppData }) {
return (
<div className="group relative flex flex-col rounded-xl border border-[var(--border)] bg-[var(--bg-card)] transition-all duration-200 hover:border-[var(--accent-border)] hover:shadow-lg hover:shadow-[var(--accent-dim)] overflow-hidden">
<a href={`/connect/${app.id}`} className="flex flex-col p-5 flex-1">
{/* Top row */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2.5 min-w-0">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-[var(--accent-dim)] border border-[var(--accent-border)] shrink-0">
<Terminal className="w-4 h-4 text-emerald-500" />
</div>
<span className="font-semibold text-[var(--text)] truncate">{app.name}</span>
</div>
<ChevronRight className="w-4 h-4 text-[var(--text-faint)] shrink-0 transition-all group-hover:text-emerald-500 group-hover:translate-x-0.5" />
</div>
{/* Domain */}
<div className="flex items-center gap-1.5 text-sm text-[var(--text-muted)] mb-3 truncate">
<Globe className="w-3.5 h-3.5 shrink-0 text-[var(--text-faint)]" />
<span className="truncate">{app.domain}</span>
</div>
{/* Extra domains */}
{app.domains.length > 1 && (
<div className="text-xs text-[var(--text-faint)] mb-3">
+{app.domains.length - 1} more domain
{app.domains.length - 1 > 1 ? "s" : ""}
</div>
)}
{/* Tags */}
{app.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-3">
{app.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="px-2 py-0.5 rounded-md bg-[var(--bg-subtle)] text-[var(--text-muted)] text-xs border border-[var(--border)]"
>
{tag}
</span>
))}
{app.tags.length > 3 && (
<span className="text-xs text-[var(--text-faint)]">
+{app.tags.length - 3}
</span>
)}
</div>
)}
{/* Bottom info */}
<div className="mt-auto pt-3 border-t border-[var(--border)] flex items-center justify-between text-xs text-[var(--text-faint)]">
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>{timeAgo(app.updatedAt)}</span>
</div>
{app.sessionDuration && (
<span>TTL {app.sessionDuration}</span>
)}
</div>
</a>
{/* Metrics widget (outside the connect link) */}
{app.metricsUrl && (
<div className="px-5 pb-5">
<MetricsWidget metricsUrl={app.metricsUrl} />
</div>
)}
</div>
);
}

View File

@ -0,0 +1,172 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Cpu, HardDrive, Wifi, Clock, Thermometer, Activity } from "lucide-react";
import { fmtBytes, fmtUptime, type ServerMetrics } from "@/lib/metrics";
function Bar({ value, warn = 70, danger = 90 }: { value: number; warn?: number; danger?: number }) {
const color =
value >= danger ? "bg-red-500" :
value >= warn ? "bg-amber-400" :
"bg-emerald-500";
return (
<div className="h-1.5 rounded-full bg-[var(--border)] overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-700 ${color}`}
style={{ width: `${Math.min(value, 100)}%` }}
/>
</div>
);
}
function Stat({ label, value, sub }: { label: string; value: string; sub?: string }) {
return (
<div>
<p className="text-[10px] text-[var(--text-faint)] uppercase tracking-wide">{label}</p>
<p className="text-sm font-semibold text-[var(--text)] leading-tight">{value}</p>
{sub && <p className="text-[10px] text-[var(--text-muted)]">{sub}</p>}
</div>
);
}
export default function MetricsWidget({ metricsUrl }: { metricsUrl: string }) {
const [data, setData] = useState<ServerMetrics | null>(null);
const [error, setError] = useState(false);
const [lastUpdate, setLastUpdate] = useState<number | null>(null);
const poll = useCallback(async () => {
try {
const res = await fetch(
`/api/metrics?url=${encodeURIComponent(metricsUrl)}`,
{ cache: "no-store" }
);
if (!res.ok) throw new Error();
const json = await res.json();
setData(json);
setError(false);
setLastUpdate(Date.now());
} catch {
setError(true);
}
}, [metricsUrl]);
useEffect(() => {
poll();
const id = setInterval(poll, 8000);
return () => clearInterval(id);
}, [poll]);
if (error) {
return (
<div className="mt-3 pt-3 border-t border-[var(--border)] flex items-center gap-2 text-xs text-[var(--text-faint)]">
<Activity className="w-3 h-3" />
<span>Metrics agent unreachable</span>
</div>
);
}
if (!data) {
return (
<div className="mt-3 pt-3 border-t border-[var(--border)]">
<div className="flex gap-3 animate-pulse">
{[1, 2, 3].map((i) => (
<div key={i} className="flex-1 h-8 rounded bg-[var(--bg-card)]" />
))}
</div>
</div>
);
}
return (
<div className="mt-3 pt-3 border-t border-[var(--border)] space-y-2.5">
{/* CPU + RAM row */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1 text-[10px] text-[var(--text-faint)] uppercase tracking-wide">
<Cpu className="w-3 h-3" /> CPU
</span>
<span className="text-xs font-semibold text-[var(--text)]">{data.cpu.toFixed(1)}%</span>
</div>
<Bar value={data.cpu} />
<p className="text-[10px] text-[var(--text-faint)]">
load {data.load.map((l) => l.toFixed(2)).join(" ")}
</p>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1 text-[10px] text-[var(--text-faint)] uppercase tracking-wide">
<Activity className="w-3 h-3" /> RAM
</span>
<span className="text-xs font-semibold text-[var(--text)]">{data.memory.percent.toFixed(0)}%</span>
</div>
<Bar value={data.memory.percent} />
<p className="text-[10px] text-[var(--text-faint)]">
{fmtBytes(data.memory.used * 1024)} / {fmtBytes(data.memory.total * 1024)}
</p>
</div>
</div>
{/* Disk + Net row */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1 text-[10px] text-[var(--text-faint)] uppercase tracking-wide">
<HardDrive className="w-3 h-3" /> Disk
</span>
<span className="text-xs font-semibold text-[var(--text)]">{data.disk.percent.toFixed(0)}%</span>
</div>
<Bar value={data.disk.percent} />
<p className="text-[10px] text-[var(--text-faint)]">
{fmtBytes(data.disk.used * 1024)} / {fmtBytes(data.disk.total * 1024)}
</p>
</div>
<div className="space-y-1">
<span className="flex items-center gap-1 text-[10px] text-[var(--text-faint)] uppercase tracking-wide">
<Wifi className="w-3 h-3" /> Network
</span>
<p className="text-xs font-semibold text-[var(--text)]">
{fmtBytes(data.network.txBytesPerSec)}/s
</p>
<p className="text-xs font-semibold text-[var(--text)]">
{fmtBytes(data.network.rxBytesPerSec)}/s
</p>
</div>
</div>
{/* GPU row (if present) */}
{data.gpu && data.gpu.length > 0 && (
<div className="space-y-1.5">
{data.gpu.map((g, i) => (
<div key={i} className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-[10px] text-[var(--text-faint)] truncate max-w-[60%]">
GPU {i > 0 ? i : ""} {g.name}
</span>
<span className="flex items-center gap-1 text-[10px] text-[var(--text-faint)]">
<Thermometer className="w-3 h-3" /> {g.tempC}°C
</span>
</div>
<Bar value={g.utilPercent} />
<p className="text-[10px] text-[var(--text-faint)]">
util {g.utilPercent}% · {g.memUsedMiB}/{g.memTotalMiB} MiB
</p>
</div>
))}
</div>
)}
{/* Uptime */}
<div className="flex items-center justify-between text-[10px] text-[var(--text-faint)]">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" /> up {fmtUptime(data.uptime)}
</span>
{lastUpdate && (
<span>
{Math.round((Date.now() - lastUpdate) / 1000)}s ago
</span>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,77 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { Monitor, PlusCircle, Server, Sun, Moon, User } from "lucide-react";
const links = [
{ href: "/", label: "Hosts", icon: Monitor },
{ href: "/create", label: "Deploy", icon: PlusCircle },
{ href: "/tunnels", label: "Tunnels", icon: Server },
];
function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return <div className="w-8 h-8" />;
return (
<button
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--text-muted)] hover:text-[var(--text)] hover:bg-[var(--bg-card)] transition"
aria-label="Toggle theme"
>
{resolvedTheme === "dark" ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</button>
);
}
export default function Navigation() {
const pathname = usePathname();
return (
<nav className="sticky top-0 z-50 border-b border-[var(--border)] bg-[var(--bg)]/90 backdrop-blur-xl">
<div className="max-w-6xl mx-auto px-4 sm:px-6 flex items-center h-14 gap-2">
<Link
href="/"
className="flex items-center gap-2 font-bold text-[var(--text)] mr-3 shrink-0"
>
<div className="w-7 h-7 rounded-lg bg-[var(--accent-dim)] border border-[var(--accent-border)] flex items-center justify-center">
<Monitor className="w-3.5 h-3.5 text-emerald-500" />
</div>
<span className="hidden sm:inline text-sm">SSH Launcher</span>
</Link>
<div className="flex items-center gap-1 flex-1">
{links.map(({ href, label, icon: Icon }) => {
const active = pathname === href;
return (
<Link
key={href}
href={href}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition ${
active
? "bg-[var(--bg-card)] text-[var(--text)]"
: "text-[var(--text-muted)] hover:text-[var(--text)] hover:bg-[var(--bg-subtle)]"
}`}
>
<Icon className="w-3.5 h-3.5" />
{label}
</Link>
);
})}
</div>
<div className="flex items-center gap-2">
<ThemeToggle />
<div className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-[var(--bg-card)] border border-[var(--border)] text-xs text-[var(--text-muted)]">
<User className="w-3 h-3" />
<span>CF Access</span>
</div>
</div>
</div>
</nav>
);
}

View File

@ -0,0 +1,178 @@
"use client";
import { useState, useMemo } from "react";
import {
Server,
Search,
Wifi,
WifiOff,
Clock,
MapPin,
Shield,
} from "lucide-react";
interface TunnelConnection {
id: string;
is_pending_reconnect: boolean;
origin_ip: string;
opened_at: string;
}
interface Tunnel {
id: string;
name: string;
status: string;
created_at: string;
deleted_at: string | null;
connections: TunnelConnection[];
}
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "Just now";
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days < 30) return `${days}d ago`;
return `${Math.floor(days / 30)}mo ago`;
}
export default function TunnelList({ tunnels }: { tunnels: Tunnel[] }) {
const [search, setSearch] = useState("");
const filtered = useMemo(() => {
const q = search.toLowerCase();
if (!q) return tunnels;
return tunnels.filter(
(t) =>
t.name.toLowerCase().includes(q) || t.id.toLowerCase().includes(q)
);
}, [tunnels, search]);
const activeCount = tunnels.filter(
(t) => t.connections && t.connections.length > 0
).length;
return (
<div className="min-h-[calc(100vh-3.5rem)]">
<div className="fixed inset-0 -z-10">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,rgba(16,185,129,0.08),transparent)]" />
<div className="absolute inset-0 bg-zinc-950" style={{ zIndex: -1 }} />
</div>
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
{/* Header */}
<div className="flex items-center gap-3 mb-8">
<div className="w-10 h-10 rounded-xl bg-blue-500/10 border border-blue-500/20 flex items-center justify-center">
<Server className="w-5 h-5 text-blue-400" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">Tunnels</h1>
<p className="text-sm text-zinc-500">
{tunnels.length} total · {activeCount} active
</p>
</div>
</div>
{/* Search */}
<div className="relative mb-6">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<input
type="text"
placeholder="Search by name or ID..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full rounded-xl border border-zinc-800 bg-zinc-900/80 backdrop-blur-sm pl-10 pr-4 py-2.5 text-sm text-zinc-200 placeholder-zinc-600 outline-none transition focus:border-blue-500/50 focus:ring-1 focus:ring-blue-500/20"
/>
</div>
{/* Empty */}
{filtered.length === 0 && (
<div className="flex flex-col items-center justify-center py-24 text-zinc-500">
<Server className="w-12 h-12 opacity-30 mb-4" />
<p className="text-lg font-medium text-zinc-400">
No tunnels found
</p>
</div>
)}
{/* Tunnel cards */}
<div className="space-y-3">
{filtered.map((tunnel) => {
const isActive =
tunnel.connections && tunnel.connections.length > 0;
return (
<div
key={tunnel.id}
className="rounded-xl border border-zinc-800/80 bg-zinc-900/60 backdrop-blur-sm p-5 transition hover:border-zinc-700"
>
<div className="flex items-start justify-between gap-4">
{/* Left */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1">
{isActive ? (
<Wifi className="w-4 h-4 text-emerald-400 shrink-0" />
) : (
<WifiOff className="w-4 h-4 text-zinc-600 shrink-0" />
)}
<span className="font-semibold text-white truncate">
{tunnel.name}
</span>
<span
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
isActive
? "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
: "bg-zinc-800 text-zinc-500 border border-zinc-700"
}`}
>
{isActive ? "Active" : "Inactive"}
</span>
</div>
<p className="text-xs text-zinc-600 font-mono truncate">
{tunnel.id}
</p>
</div>
{/* Right meta */}
<div className="text-right text-xs text-zinc-600 shrink-0">
<div className="flex items-center gap-1 justify-end">
<Clock className="w-3 h-3" />
Created {timeAgo(tunnel.created_at)}
</div>
</div>
</div>
{/* Connections */}
{isActive && tunnel.connections.length > 0 && (
<div className="mt-3 pt-3 border-t border-zinc-800/50 flex flex-wrap gap-2">
{tunnel.connections.map((conn) => (
<div
key={conn.id}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-zinc-800/50 text-xs text-zinc-400"
>
<MapPin className="w-3 h-3 text-zinc-600" />
{conn.origin_ip}
<span className="text-zinc-600">·</span>
<span className="text-zinc-600">
{timeAgo(conn.opened_at)}
</span>
</div>
))}
</div>
)}
</div>
);
})}
</div>
{/* Footer */}
<div className="mt-14 pt-6 border-t border-zinc-900 flex items-center gap-2 text-xs text-zinc-600">
<Shield className="w-3 h-3" />
<span>Cloudflare Tunnel · auto-refreshes every 30s</span>
</div>
</div>
</div>
);
}

555
src/lib/cloudflare.ts Normal file
View File

@ -0,0 +1,555 @@
const CF_API_BASE = "https://api.cloudflare.com/client/v4";
// ─── Types ───────────────────────────────────────────────────────────────────
export interface AccessApp {
id: string;
name: string;
domain: string;
type: string;
self_hosted_domains?: string[];
created_at?: string;
updated_at?: string;
aud?: string;
app_launcher_visible?: boolean;
session_duration?: string;
tags?: string[];
logo_url?: string;
[key: string]: unknown;
}
export interface SshApp {
id: string;
name: string;
domain: string;
domains: string[];
launchUrl: string;
createdAt: string | null;
updatedAt: string | null;
tags: string[];
logoUrl: string | null;
sessionDuration: string | null;
metricsUrl: string | null;
}
export interface Tunnel {
id: string;
name: string;
status: string;
created_at: string;
deleted_at: string | null;
connections: TunnelConnection[];
[key: string]: unknown;
}
export interface TunnelConnection {
id: string;
is_pending_reconnect: boolean;
origin_ip: string;
opened_at: string;
[key: string]: unknown;
}
export interface SetupInput {
serverName: string;
hostname: string;
sshPort: number;
ssoEmail: string;
allowedEmails: string[];
}
export interface SetupStepResult {
step: string;
status: "success" | "error" | "skipped";
message: string;
data?: Record<string, unknown>;
}
export interface SetupResult {
steps: SetupStepResult[];
tunnelToken: string | null;
caPubkey: string | null;
installCommand: string | null;
launchUrl: string | null;
}
// ─── Credentials ─────────────────────────────────────────────────────────────
function getCredentials() {
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
const apiToken = process.env.CLOUDFLARE_API_TOKEN;
if (!accountId || !apiToken) {
throw new Error(
"Missing CLOUDFLARE_ACCOUNT_ID or CLOUDFLARE_API_TOKEN env vars"
);
}
return { accountId, apiToken };
}
async function cfGet(path: string, revalidateSec = 60) {
const { apiToken } = getCredentials();
const res = await fetch(`${CF_API_BASE}${path}`, {
headers: { Authorization: `Bearer ${apiToken}` },
next: { revalidate: revalidateSec },
});
if (!res.ok) {
const body = await res.text();
throw new Error(`CF API GET ${path}${res.status}: ${body}`);
}
return res.json();
}
async function cfPost(path: string, body: unknown) {
const { apiToken } = getCredentials();
const res = await fetch(`${CF_API_BASE}${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const json = await res.json();
if (!res.ok) {
throw new Error(
`CF API POST ${path}${res.status}: ${JSON.stringify(json.errors ?? json)}`
);
}
return json;
}
async function cfPut(path: string, body: unknown) {
const { apiToken } = getCredentials();
const res = await fetch(`${CF_API_BASE}${path}`, {
method: "PUT",
headers: {
Authorization: `Bearer ${apiToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const json = await res.json();
if (!res.ok) {
throw new Error(
`CF API PUT ${path}${res.status}: ${JSON.stringify(json.errors ?? json)}`
);
}
return json;
}
async function cfDelete(path: string) {
const { apiToken } = getCredentials();
const res = await fetch(`${CF_API_BASE}${path}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${apiToken}` },
});
const json = await res.json();
if (!res.ok) {
throw new Error(
`CF API DELETE ${path}${res.status}: ${JSON.stringify(json.errors ?? json)}`
);
}
return json;
}
// ─── Access Applications ─────────────────────────────────────────────────────
export async function listAccessApps(): Promise<AccessApp[]> {
const { accountId } = getCredentials();
const allApps: AccessApp[] = [];
let page = 1;
const perPage = 50;
while (true) {
const json = await cfGet(
`/accounts/${accountId}/access/apps?page=${page}&per_page=${perPage}`
);
const apps: AccessApp[] = json.result ?? [];
allApps.push(...apps);
const info = json.result_info;
if (!info || page >= info.total_pages) break;
page++;
}
return allApps;
}
export function filterSshApps(apps: AccessApp[]): SshApp[] {
return apps
.filter((app) => app.type === "self_hosted")
.map((app) => {
const primaryDomain =
app.domain ||
(app.self_hosted_domains && app.self_hosted_domains[0]) ||
"";
const allDomains =
app.self_hosted_domains ?? (primaryDomain ? [primaryDomain] : []);
const rawTags = (app.tags as string[]) ?? [];
const metricsTag = rawTags.find((t) => t.startsWith("metrics:"));
const metricsUrl = metricsTag ? metricsTag.slice("metrics:".length) : null;
const visibleTags = rawTags.filter((t) => !t.startsWith("metrics:"));
return {
id: app.id,
name: app.name || primaryDomain,
domain: primaryDomain,
domains: allDomains,
launchUrl: primaryDomain.startsWith("http")
? primaryDomain
: `https://${primaryDomain}`,
createdAt: (app.created_at as string) ?? null,
updatedAt: (app.updated_at as string) ?? null,
tags: visibleTags,
logoUrl: (app.logo_url as string) ?? null,
sessionDuration: (app.session_duration as string) ?? null,
metricsUrl,
};
});
}
export async function getSshAppById(id: string): Promise<SshApp | null> {
const apps = await listAccessApps();
const ssh = filterSshApps(apps);
return ssh.find((a) => a.id === id) ?? null;
}
// ─── Tunnels ─────────────────────────────────────────────────────────────────
export async function listTunnels(): Promise<Tunnel[]> {
const { accountId } = getCredentials();
const json = await cfGet(
`/accounts/${accountId}/cfd_tunnel?is_deleted=false&per_page=50`,
30
);
return (json.result as Tunnel[]) ?? [];
}
export async function createTunnel(name: string): Promise<Tunnel> {
const { accountId } = getCredentials();
const tunnelSecret = Buffer.from(
crypto.getRandomValues(new Uint8Array(32))
).toString("base64");
const json = await cfPost(`/accounts/${accountId}/cfd_tunnel`, {
name,
tunnel_secret: tunnelSecret,
config_src: "cloudflare",
});
return json.result as Tunnel;
}
export async function deleteTunnel(tunnelId: string): Promise<void> {
const { accountId } = getCredentials();
await cfDelete(`/accounts/${accountId}/cfd_tunnel/${tunnelId}`);
}
export async function getTunnelToken(tunnelId: string): Promise<string> {
const { accountId } = getCredentials();
const json = await cfGet(
`/accounts/${accountId}/cfd_tunnel/${tunnelId}/token`,
0
);
return json.result as string;
}
export async function configureTunnel(
tunnelId: string,
hostname: string,
sshPort: number
): Promise<void> {
const { accountId } = getCredentials();
await cfPut(`/accounts/${accountId}/cfd_tunnel/${tunnelId}/configurations`, {
config: {
ingress: [
{
hostname,
service: `ssh://localhost:${sshPort}`,
},
{
service: "http_status:404",
},
],
},
});
}
// ─── DNS ─────────────────────────────────────────────────────────────────────
export async function findZoneForHostname(
hostname: string
): Promise<{ zoneId: string; zoneName: string }> {
// Try progressively shorter domain parts to find the zone
const parts = hostname.split(".");
for (let i = 0; i < parts.length - 1; i++) {
const candidate = parts.slice(i).join(".");
const json = await cfGet(`/zones?name=${encodeURIComponent(candidate)}`, 0);
const zones = json.result ?? [];
if (zones.length > 0) {
return { zoneId: zones[0].id, zoneName: zones[0].name };
}
}
throw new Error(`No Cloudflare zone found for hostname: ${hostname}`);
}
export async function createDnsCname(
zoneId: string,
name: string,
tunnelId: string
): Promise<void> {
await cfPost(`/zones/${zoneId}/dns_records`, {
type: "CNAME",
name,
content: `${tunnelId}.cfargotunnel.com`,
proxied: true,
comment: "Auto-created by SSH Launcher",
});
}
// ─── Access App Creation ─────────────────────────────────────────────────────
export async function createAccessApp(
hostname: string,
appName: string,
allowedEmails: string[]
): Promise<{ appId: string; metricsHostname: string }> {
const { accountId } = getCredentials();
const policies = [];
if (allowedEmails.length > 0) {
policies.push({
name: "Allow specified emails",
decision: "allow",
include: allowedEmails.map((email) => ({
email: { email },
})),
});
} else {
// Fallback: allow any authenticated user
policies.push({
name: "Allow any authenticated user",
decision: "allow",
include: [{ everyone: {} }],
});
}
const metricsHostname = `metrics.${hostname}`;
const json = await cfPost(`/accounts/${accountId}/access/apps`, {
name: appName,
domain: hostname,
type: "self_hosted",
session_duration: "24h",
auto_redirect_to_identity: true,
app_launcher_visible: true,
tags: [`metrics:https://${metricsHostname}`],
policies,
});
return { appId: json.result.id, metricsHostname };
}
// ─── Short-lived Certificate CA ──────────────────────────────────────────────
export async function getOrCreateCaCert(
appId: string
): Promise<string | null> {
const { accountId } = getCredentials();
try {
// Try to get existing CA
const json = await cfGet(
`/accounts/${accountId}/access/apps/${appId}/ca`,
0
);
if (json.result?.public_key) {
return json.result.public_key as string;
}
// If not found, create one
const createJson = await cfPost(
`/accounts/${accountId}/access/apps/${appId}/ca`,
{}
);
return (createJson.result?.public_key as string) ?? null;
} catch {
return null;
}
}
// ─── Full Pipeline ───────────────────────────────────────────────────────────
export async function runFullSetup(input: SetupInput): Promise<SetupResult> {
const steps: SetupStepResult[] = [];
let tunnelToken: string | null = null;
let caPubkey: string | null = null;
let tunnelId: string | null = null;
let appId: string | null = null;
// Step 1: Find zone
let zoneId: string | null = null;
try {
const zone = await findZoneForHostname(input.hostname);
zoneId = zone.zoneId;
steps.push({
step: "find_zone",
status: "success",
message: `Found zone: ${zone.zoneName} (${zone.zoneId})`,
data: zone,
});
} catch (e) {
steps.push({
step: "find_zone",
status: "error",
message: e instanceof Error ? e.message : "Failed to find zone",
});
return { steps, tunnelToken: null, caPubkey: null, installCommand: null, launchUrl: null };
}
// Step 2: Create tunnel
try {
const tunnel = await createTunnel(input.serverName);
tunnelId = tunnel.id;
steps.push({
step: "create_tunnel",
status: "success",
message: `Tunnel created: ${tunnel.name} (${tunnel.id})`,
data: { tunnelId: tunnel.id, tunnelName: tunnel.name },
});
} catch (e) {
steps.push({
step: "create_tunnel",
status: "error",
message: e instanceof Error ? e.message : "Failed to create tunnel",
});
return { steps, tunnelToken: null, caPubkey: null, installCommand: null, launchUrl: null };
}
// Step 3: Configure tunnel ingress (SSH + metrics HTTP)
const metricsDomain = `metrics.${input.hostname}`;
try {
const { accountId } = getCredentials();
await cfPut(`/accounts/${accountId}/cfd_tunnel/${tunnelId}/configurations`, {
config: {
ingress: [
{ hostname: input.hostname, service: `ssh://localhost:${input.sshPort}` },
{ hostname: metricsDomain, service: "http://localhost:9101" },
{ service: "http_status:404" },
],
},
});
steps.push({
step: "configure_tunnel",
status: "success",
message: `Ingress: ${input.hostname} → SSH, ${metricsDomain} → metrics:9101`,
});
} catch (e) {
steps.push({
step: "configure_tunnel",
status: "error",
message: e instanceof Error ? e.message : "Failed to configure tunnel",
});
}
// Step 4: Create DNS CNAME
try {
await createDnsCname(zoneId, input.hostname, tunnelId);
steps.push({
step: "create_dns",
status: "success",
message: `CNAME created: ${input.hostname}${tunnelId}.cfargotunnel.com`,
});
} catch (e) {
const msg = e instanceof Error ? e.message : "";
if (msg.includes("already exists") || msg.includes("81057")) {
steps.push({
step: "create_dns",
status: "skipped",
message: `DNS record already exists for ${input.hostname}`,
});
} else {
steps.push({
step: "create_dns",
status: "error",
message: msg || "Failed to create DNS record",
});
}
}
// Step 5: Create Access application
let metricsHostname: string | null = null;
try {
const appResult = await createAccessApp(
input.hostname,
`SSH · ${input.serverName}`,
input.allowedEmails
);
appId = appResult.appId;
metricsHostname = appResult.metricsHostname;
steps.push({
step: "create_access_app",
status: "success",
message: `Access app created: SSH · ${input.serverName}`,
data: { appId },
});
} catch (e) {
steps.push({
step: "create_access_app",
status: "error",
message: e instanceof Error ? e.message : "Failed to create Access app",
});
}
// Step 6: Get / create short-lived certificate CA
if (appId) {
caPubkey = await getOrCreateCaCert(appId);
steps.push({
step: "setup_ca",
status: caPubkey ? "success" : "error",
message: caPubkey
? "Short-lived certificate CA configured"
: "Could not retrieve CA public key (enable manually in dashboard)",
});
}
// Step 7: Get tunnel token
try {
tunnelToken = await getTunnelToken(tunnelId);
steps.push({
step: "get_token",
status: "success",
message: "Tunnel token retrieved",
});
} catch (e) {
steps.push({
step: "get_token",
status: "error",
message: e instanceof Error ? e.message : "Failed to get tunnel token",
});
}
// Build install command
const loginUser = input.ssoEmail.split("@")[0];
let installCommand: string | null = null;
if (tunnelToken) {
const parts = [
`sudo bash setup-cf-browser-ssh.sh`,
` --tunnel-token "${tunnelToken}"`,
` --sso-email "${input.ssoEmail}"`,
` --login-user "${loginUser}"`,
];
if (caPubkey) {
parts.push(` --ca-pubkey "${caPubkey}"`);
}
installCommand = parts.join(" \\\n");
}
const launchUrl = `https://${input.hostname}`;
// Browser rendering reminder
steps.push({
step: "browser_rendering",
status: "skipped",
message:
"Remember: enable Browser rendering → SSH in the Cloudflare dashboard for this Access app",
});
return { steps, tunnelToken, caPubkey, installCommand, launchUrl };
}

29
src/lib/identity.ts Normal file
View File

@ -0,0 +1,29 @@
import { decodeJwt } from "jose";
export interface CFIdentity {
email: string;
name: string | null;
sub: string;
}
/**
* Decode the Cloudflare Access JWT from the CF-Access-Jwt-Assertion header.
* Returns null in local dev (no CF Access header present).
* Does NOT perform full signature verification the request is already
* validated by Cloudflare's edge before it reaches Next.js.
*/
export function parseCFIdentity(
header: string | null | undefined
): CFIdentity | null {
if (!header) return null;
try {
const payload = decodeJwt(header);
return {
email: (payload.email as string) ?? (payload.sub as string) ?? "unknown",
name: (payload.name as string) ?? null,
sub: (payload.sub as string) ?? "",
};
} catch {
return null;
}
}

69
src/lib/metrics.ts Normal file
View File

@ -0,0 +1,69 @@
export interface ServerMetrics {
cpu: number;
memory: { used: number; total: number; percent: number };
disk: { used: number; total: number; percent: number };
network: { rxBytesPerSec: number; txBytesPerSec: number };
load: [number, number, number];
uptime: number;
gpu?: {
name: string;
tempC: number;
utilPercent: number;
memUsedMiB: number;
memTotalMiB: number;
}[];
hostname: string;
timestamp: number;
}
/**
* Fetch metrics from a server's metrics agent.
* The agent must be running on the server and accessible via CF tunnel.
*
* Cloudflare Access service token headers are passed so the request
* can traverse an Access-protected hostname.
*/
export async function fetchServerMetrics(
metricsUrl: string
): Promise<ServerMetrics> {
const clientId = process.env.CF_SERVICE_CLIENT_ID;
const clientSecret = process.env.CF_SERVICE_CLIENT_SECRET;
const headers: Record<string, string> = {
Accept: "application/json",
};
if (clientId && clientSecret) {
headers["CF-Access-Client-Id"] = clientId;
headers["CF-Access-Client-Secret"] = clientSecret;
}
const url = metricsUrl.endsWith("/") ? metricsUrl + "metrics.json" : metricsUrl + "/metrics.json";
const res = await fetch(url, {
headers,
next: { revalidate: 0 },
signal: AbortSignal.timeout(8000),
});
if (!res.ok) {
throw new Error(`Metrics fetch failed: ${res.status}`);
}
return res.json() as Promise<ServerMetrics>;
}
export function fmtBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
}
export function fmtUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}

21
tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}