feat: Next.js SSH Launcher with full automation pipeline, metrics agent, dark/light mode
This commit is contained in:
parent
e231778f54
commit
83fa93f7b7
14
.env.example
Normal file
14
.env.example
Normal 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
37
.gitignore
vendored
Normal 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
222
metrics-agent.sh
Normal 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
5
next.config.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
1682
package-lock.json
generated
Normal file
1682
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal 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
8
postcss.config.mjs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
21
src/app/api/metrics/route.ts
Normal file
21
src/app/api/metrics/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/app/api/setup/route.ts
Normal file
34
src/app/api/setup/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/app/connect/[id]/route.ts
Normal file
20
src/app/connect/[id]/route.ts
Normal 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
350
src/app/create/page.tsx
Normal 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
38
src/app/globals.css
Normal 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
26
src/app/layout.tsx
Normal 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
56
src/app/loading.tsx
Normal 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
34
src/app/page.tsx
Normal 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
11
src/app/providers.tsx
Normal 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
33
src/app/tunnels/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
308
src/components/Dashboard.tsx
Normal file
308
src/components/Dashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
src/components/MetricsWidget.tsx
Normal file
172
src/components/MetricsWidget.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
src/components/Navigation.tsx
Normal file
77
src/components/Navigation.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
178
src/components/TunnelList.tsx
Normal file
178
src/components/TunnelList.tsx
Normal 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
555
src/lib/cloudflare.ts
Normal 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
29
src/lib/identity.ts
Normal 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
69
src/lib/metrics.ts
Normal 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
21
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user