From b8bc7f0e0130e32d0602cc16524152843ff6362a Mon Sep 17 00:00:00 2001 From: chunzhimoe <60135925+chunzhimoe@users.noreply.github.com> Date: Sun, 12 Apr 2026 18:07:42 +0800 Subject: [PATCH] feat: migrate to shared wildcard Access app model - Fix zone dropdown bug (setZones was never called) - Add SSH settings storage (wildcardDomain + accessAppId) in Redis - Add /api/access-apps endpoint for app selection dropdown - Settings page: add wildcard domain and shared Access app selector - Deploy page: use subdomain + wildcard domain, remove allowedEmails - Rewrite runFullSetup to skip app creation, fetch CA from shared app - Homepage: derive SSH hosts from tunnel ingress configs - Dashboard: cards link directly to https://, remove MetricsWidget - Drop metrics subdomain from tunnel ingress (simplifies model) --- src/app/api/access-apps/route.ts | 34 ++++ src/app/api/settings/route.ts | 39 ++++- src/app/api/setup/route.ts | 23 ++- src/app/connect/[id]/route.ts | 20 +-- src/app/create/page.tsx | 164 +++++++++--------- src/app/page.tsx | 26 +-- src/app/settings/page.tsx | 218 +++++++++++++++++++++++- src/components/Dashboard.tsx | 227 ++++++++++--------------- src/lib/cloudflare.ts | 276 +++++++++++++------------------ src/lib/credentials.ts | 44 +++++ 10 files changed, 631 insertions(+), 440 deletions(-) create mode 100644 src/app/api/access-apps/route.ts diff --git a/src/app/api/access-apps/route.ts b/src/app/api/access-apps/route.ts new file mode 100644 index 0000000..8da979a --- /dev/null +++ b/src/app/api/access-apps/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/authOptions"; +import { listAccessApps } from "@/lib/cloudflare"; + +/** + * GET /api/access-apps + * Returns all self_hosted Access applications in the account. + * Used by the settings page to pick the shared wildcard Access app. + */ +export async function GET() { + const session = await getServerSession(authOptions); + if (!session) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + try { + const apps = await listAccessApps(); + // Only return self_hosted apps (the kind used for SSH) + const selfHosted = apps + .filter((a) => a.type === "self_hosted") + .map((a) => ({ + id: a.id, + name: a.name, + domain: a.domain, + domains: a.self_hosted_domains ?? (a.domain ? [a.domain] : []), + })); + return NextResponse.json(selfHosted); + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : "Failed to list Access apps" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index 7e7a061..fc39cf6 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -1,18 +1,27 @@ import { NextRequest, NextResponse } from "next/server"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/authOptions"; -import { saveCFCreds, loadCFCreds } from "@/lib/credentials"; +import { + saveCFCreds, + loadCFCreds, + saveSshSettings, + loadSshSettings, +} from "@/lib/credentials"; export async function GET() { const session = await getServerSession(authOptions); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); const creds = await loadCFCreds(); - if (!creds) return NextResponse.json({ configured: false }); + const sshSettings = await loadSshSettings(); + return NextResponse.json({ - configured: true, - accountId: creds.accountId, - apiTokenMasked: `${creds.apiToken.slice(0, 6)}${"*".repeat(20)}`, + configured: !!creds, + accountId: creds?.accountId ?? null, + apiTokenMasked: creds + ? `${creds.apiToken.slice(0, 6)}${"*".repeat(20)}` + : null, + sshSettings: sshSettings ?? null, }); } @@ -20,11 +29,23 @@ export async function PUT(req: NextRequest) { const session = await getServerSession(authOptions); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - const { accountId, apiToken } = await req.json(); - if (!accountId?.trim() || !apiToken?.trim()) { - return NextResponse.json({ error: "accountId and apiToken are required" }, { status: 400 }); + const body = await req.json(); + + // Save CF credentials if provided + if (body.accountId?.trim() && body.apiToken?.trim()) { + await saveCFCreds({ + accountId: body.accountId.trim(), + apiToken: body.apiToken.trim(), + }); + } + + // Save SSH settings if provided + if (body.wildcardDomain?.trim() && body.accessAppId?.trim()) { + await saveSshSettings({ + wildcardDomain: body.wildcardDomain.trim(), + accessAppId: body.accessAppId.trim(), + }); } - await saveCFCreds({ accountId: accountId.trim(), apiToken: apiToken.trim() }); return NextResponse.json({ ok: true }); } diff --git a/src/app/api/setup/route.ts b/src/app/api/setup/route.ts index 3e1985b..7e7aa49 100644 --- a/src/app/api/setup/route.ts +++ b/src/app/api/setup/route.ts @@ -1,19 +1,32 @@ import { NextRequest, NextResponse } from "next/server"; import { runFullSetup, type SetupInput } from "@/lib/cloudflare"; +import { requireSshSettings } from "@/lib/credentials"; export async function POST(req: NextRequest) { try { const body = await req.json(); + // Load SSH settings (wildcard domain + shared app ID) + const sshSettings = await requireSshSettings(); + + const hostname = body.hostname?.trim(); + + // Validate hostname matches wildcard domain + if (hostname && !hostname.endsWith(`.${sshSettings.wildcardDomain}`)) { + return NextResponse.json( + { + error: `主机名必须在 *.${sshSettings.wildcardDomain} 下,当前:${hostname}`, + }, + { status: 400 } + ); + } + const input: SetupInput = { serverName: body.serverName?.trim(), - hostname: body.hostname?.trim(), + hostname, sshPort: Number(body.sshPort) || 22, ssoEmail: body.ssoEmail?.trim(), - allowedEmails: (body.allowedEmails || "") - .split(/[,\n]/) - .map((e: string) => e.trim()) - .filter(Boolean), + accessAppId: sshSettings.accessAppId, }; if (!input.serverName || !input.hostname || !input.ssoEmail) { diff --git a/src/app/connect/[id]/route.ts b/src/app/connect/[id]/route.ts index 33d6058..fe0bb2a 100644 --- a/src/app/connect/[id]/route.ts +++ b/src/app/connect/[id]/route.ts @@ -1,20 +1,14 @@ import { NextRequest, NextResponse } from "next/server"; -import { getSshAppById } from "@/lib/cloudflare"; +/** + * Legacy connect route. In the shared-app model, host cards link directly + * to https://. This route is kept as a fallback — if someone + * hits /connect/, redirect them to the homepage. + */ 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); + await params; // consume params to avoid Next.js warnings + return NextResponse.redirect(new URL("/", _req.url), 302); } diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx index bbf1f33..f99090a 100644 --- a/src/app/create/page.tsx +++ b/src/app/create/page.tsx @@ -15,8 +15,6 @@ import { Mail, Terminal, Hash, - ChevronDown, - RefreshCw, } from "lucide-react"; interface StepResult { @@ -33,9 +31,9 @@ interface SetupResult { launchUrl: string | null; } -interface Zone { - id: string; - name: string; +interface SshSettings { + wildcardDomain: string; + accessAppId: string; } const STEP_LABELS: Record = { @@ -43,8 +41,7 @@ const STEP_LABELS: Record = { create_tunnel: "创建隧道", configure_tunnel: "配置 Ingress", create_dns: "创建 DNS 记录", - create_access_app: "创建 Access 应用", - setup_ca: "配置短期证书", + setup_ca: "获取 CA 公钥", get_token: "获取隧道 Token", browser_rendering: "浏览器渲染提醒", }; @@ -53,46 +50,37 @@ export default function CreatePage() { const [form, setForm] = useState({ serverName: "", subdomain: "", - zoneId: "", sshPort: "22", ssoEmail: "", - allowedEmails: "", }); - const [zones, setZones] = useState([]); - const [zonesLoading, setZonesLoading] = useState(true); - const [zonesError, setZonesError] = useState(null); + const [sshSettings, setSshSettings] = useState(null); + const [settingsLoading, setSettingsLoading] = useState(true); const [loading, setLoading] = useState(false); const [result, setResult] = useState(null); const [error, setError] = useState(null); const [copied, setCopied] = useState(false); - const selectedZone = zones.find((z) => z.id === form.zoneId); const hostname = - form.subdomain && selectedZone - ? `${form.subdomain}.${selectedZone.name}` + form.subdomain && sshSettings?.wildcardDomain + ? `${form.subdomain}.${sshSettings.wildcardDomain}` : ""; - async function fetchZones() { - setZonesLoading(true); - setZonesError(null); - try { - const res = await fetch("/api/zones"); - const data = await res.json(); - if (!res.ok) throw new Error(data.error || "加载 Zone 失败"); - } catch (e) { - setZonesError(e instanceof Error ? e.message : "加载 Zone 失败"); - } finally { - setZonesLoading(false); - } - } - - useEffect(() => { fetchZones(); }, []); + useEffect(() => { + fetch("/api/settings") + .then((r) => r.json()) + .then((d) => { + if (d.sshSettings) setSshSettings(d.sshSettings); + }) + .catch(() => {}) + .finally(() => setSettingsLoading(false)); + }, []); const canSubmit = form.serverName.trim() && hostname.trim() && form.ssoEmail.trim() && - !loading; + !loading && + !!sshSettings; async function handleSubmit(e: React.FormEvent) { e.preventDefault(); @@ -105,7 +93,12 @@ export default function CreatePage() { const res = await fetch("/api/setup", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ...form, hostname }), + body: JSON.stringify({ + serverName: form.serverName, + hostname, + sshPort: form.sshPort, + ssoEmail: form.ssoEmail, + }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); @@ -143,64 +136,68 @@ export default function CreatePage() {

部署 SSH 主机

- 一键完成 → 隧道 + DNS + Access 应用 + 安装命令 + 一键完成 → 隧道 + DNS + CA 证书 + 安装命令

+ {/* Settings check */} + {settingsLoading ? ( +
+ + 加载配置中… +
+ ) : !sshSettings ? ( +
+ +
+

请先配置 SSH 设置

+

+ 前往{" "} + + 设置页面 + {" "} + 配置通配域名和共享 Access 应用。 +

+
+
+ ) : ( +
+ +
+

+ 共享应用已配置 +

+

+ 通配域名:*.{sshSettings.wildcardDomain} +

+
+
+ )} + {/* Form */}
} label="服务器名称" - hint="服务器昵称,如 prod-web-1"> + hint="服务器昵称,同时也是隧道名称,如 prod-web-1"> - {/* SSH Hostname — zone dropdown + subdomain */} + {/* SSH Hostname — subdomain + wildcard domain */} } label="SSH 访问域名" - hint={hostname ? `将创建:${hostname}` : "选择 Zone 再填写子域名前缀"}> -
-
- -
- . -
- {zonesLoading ? ( -
- - 加载 Zone 中… -
- ) : zonesError ? ( -
- 加载失败: {zonesError} - -
- ) : ( - <> - - - - )} -
+ hint={hostname ? `将创建:${hostname}` : "填写子域名前缀"}> +
+ + + .{sshSettings?.wildcardDomain || "---"} +
@@ -216,13 +213,6 @@ export default function CreatePage() { placeholder="admin@example.com" className="form-input" required /> - } label="允许访问的邮箱(可选)" - hint="多个邮箱用逗号分隔,留空表示所有已认证用户均可访问"> -