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://<hostname>, remove MetricsWidget - Drop metrics subdomain from tunnel ingress (simplifies model)
This commit is contained in:
parent
d98b6e310a
commit
b8bc7f0e01
34
src/app/api/access-apps/route.ts
Normal file
34
src/app/api/access-apps/route.ts
Normal file
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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://<hostname>. This route is kept as a fallback — if someone
|
||||
* hits /connect/<id>, 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);
|
||||
}
|
||||
|
||||
@ -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<string, string> = {
|
||||
@ -43,8 +41,7 @@ const STEP_LABELS: Record<string, string> = {
|
||||
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<Zone[]>([]);
|
||||
const [zonesLoading, setZonesLoading] = useState(true);
|
||||
const [zonesError, setZonesError] = useState<string | null>(null);
|
||||
const [sshSettings, setSshSettings] = useState<SshSettings | null>(null);
|
||||
const [settingsLoading, setSettingsLoading] = useState(true);
|
||||
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 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() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[var(--text)]">部署 SSH 主机</h1>
|
||||
<p className="text-sm text-[var(--text-muted)]">
|
||||
一键完成 → 隧道 + DNS + Access 应用 + 安装命令
|
||||
一键完成 → 隧道 + DNS + CA 证书 + 安装命令
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings check */}
|
||||
{settingsLoading ? (
|
||||
<div className="rounded-xl border border-[var(--border)] bg-[var(--bg-card)] p-6 flex items-center gap-3 mb-6">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-[var(--text-muted)]" />
|
||||
<span className="text-sm text-[var(--text-muted)]">加载配置中…</span>
|
||||
</div>
|
||||
) : !sshSettings ? (
|
||||
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 p-4 flex items-start gap-3 mb-6">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-amber-500">请先配置 SSH 设置</p>
|
||||
<p className="text-sm text-amber-400/80 mt-1">
|
||||
前往{" "}
|
||||
<a href="/settings" className="underline hover:text-amber-300">
|
||||
设置页面
|
||||
</a>{" "}
|
||||
配置通配域名和共享 Access 应用。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-4 flex items-start gap-3 mb-6">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-500 shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-emerald-600 dark:text-emerald-400">
|
||||
共享应用已配置
|
||||
</p>
|
||||
<p className="text-[var(--text-faint)] mt-0.5 font-mono text-xs">
|
||||
通配域名:*.{sshSettings.wildcardDomain}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<Field icon={<Server className="w-4 h-4" />} label="服务器名称"
|
||||
hint="服务器昵称,如 prod-web-1">
|
||||
hint="服务器昵称,同时也是隧道名称,如 prod-web-1">
|
||||
<input type="text" value={form.serverName} onChange={set("serverName")}
|
||||
placeholder="prod-web-1" className="form-input" required />
|
||||
</Field>
|
||||
|
||||
{/* SSH Hostname — zone dropdown + subdomain */}
|
||||
{/* SSH Hostname — subdomain + wildcard domain */}
|
||||
<Field icon={<Globe className="w-4 h-4" />} label="SSH 访问域名"
|
||||
hint={hostname ? `将创建:${hostname}` : "选择 Zone 再填写子域名前缀"}>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
value={form.subdomain}
|
||||
onChange={set("subdomain")}
|
||||
placeholder="ssh"
|
||||
className="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<span className="flex items-center text-[var(--text-faint)] text-sm font-mono select-none">.</span>
|
||||
<div className="flex-[2] min-w-0 relative">
|
||||
{zonesLoading ? (
|
||||
<div className="form-input flex items-center gap-2 text-[var(--text-faint)]">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin shrink-0" />
|
||||
<span className="text-sm">加载 Zone 中…</span>
|
||||
</div>
|
||||
) : zonesError ? (
|
||||
<div className="form-input flex items-center gap-2">
|
||||
<span className="text-sm text-red-500 truncate flex-1">加载失败: {zonesError}</span>
|
||||
<button type="button" onClick={fetchZones} className="shrink-0 text-[var(--text-muted)] hover:text-[var(--text)]">
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<select
|
||||
value={form.zoneId}
|
||||
onChange={set("zoneId")}
|
||||
className="form-input appearance-none pr-8"
|
||||
required
|
||||
>
|
||||
<option value="">选择 Zone…</option>
|
||||
{zones.map((z) => (
|
||||
<option key={z.id} value={z.id}>{z.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--text-faint)]" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
hint={hostname ? `将创建:${hostname}` : "填写子域名前缀"}>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={form.subdomain}
|
||||
onChange={set("subdomain")}
|
||||
placeholder="node1"
|
||||
className="form-input flex-1"
|
||||
required
|
||||
/>
|
||||
<span className="text-sm text-[var(--text-faint)] font-mono select-none shrink-0">
|
||||
.{sshSettings?.wildcardDomain || "---"}
|
||||
</span>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
@ -216,13 +213,6 @@ export default function CreatePage() {
|
||||
placeholder="admin@example.com" className="form-input" required />
|
||||
</Field>
|
||||
|
||||
<Field icon={<Mail className="w-4 h-4" />} label="允许访问的邮箱(可选)"
|
||||
hint="多个邮箱用逗号分隔,留空表示所有已认证用户均可访问">
|
||||
<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}
|
||||
@ -335,10 +325,6 @@ export default function CreatePage() {
|
||||
.form-input::placeholder {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
select.form-input option {
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,24 +1,30 @@
|
||||
import { listAccessApps, filterSshApps, type SshApp } from "@/lib/cloudflare";
|
||||
import { deriveSshHosts, type SshHost } from "@/lib/cloudflare";
|
||||
import { loadSshSettings } from "@/lib/credentials";
|
||||
import Dashboard from "@/components/Dashboard";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
export default async function Home() {
|
||||
let apps: SshApp[] = [];
|
||||
let hosts: SshHost[] = [];
|
||||
let error: string | null = null;
|
||||
let emptyStateHint = "当前还没有 SSH 主机,先去创建一台。";
|
||||
let emptyStateHint = "当前还没有 SSH 主机,先去部署一台。";
|
||||
|
||||
try {
|
||||
const allApps = await listAccessApps();
|
||||
apps = filterSshApps(allApps);
|
||||
if (apps.length === 0 && allApps.some((app) => app.type === "self_hosted")) {
|
||||
const sshSettings = await loadSshSettings();
|
||||
if (!sshSettings) {
|
||||
emptyStateHint =
|
||||
"当前账号下没有由 SSH Console 管理的主机;仅显示由本控制台创建或带识别标签的 SSH Access 应用。";
|
||||
"请先前往「设置」页面配置通配域名和共享 Access 应用。";
|
||||
} else {
|
||||
hosts = await deriveSshHosts(sshSettings.wildcardDomain);
|
||||
if (hosts.length === 0) {
|
||||
emptyStateHint =
|
||||
`未发现匹配 *.${sshSettings.wildcardDomain} 的 SSH 隧道;请先部署一台主机。`;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "未知错误";
|
||||
apps = [];
|
||||
hosts = [];
|
||||
}
|
||||
|
||||
if (error) {
|
||||
@ -27,7 +33,7 @@ export default async function Home() {
|
||||
<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">
|
||||
加载应用列表失败
|
||||
加载主机列表失败
|
||||
</h2>
|
||||
<p className="text-sm text-red-400/80 break-words">{error}</p>
|
||||
</div>
|
||||
@ -35,5 +41,5 @@ export default async function Home() {
|
||||
);
|
||||
}
|
||||
|
||||
return <Dashboard apps={apps} emptyStateHint={emptyStateHint} />;
|
||||
return <Dashboard hosts={hosts} emptyStateHint={emptyStateHint} />;
|
||||
}
|
||||
|
||||
@ -1,12 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Settings, Key, Save, Check, AlertTriangle, Eye, EyeOff, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
Settings,
|
||||
Key,
|
||||
Save,
|
||||
Check,
|
||||
AlertTriangle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
RefreshCw,
|
||||
Globe,
|
||||
Shield,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
|
||||
interface AccessAppOption {
|
||||
id: string;
|
||||
name: string;
|
||||
domain: string;
|
||||
domains: string[];
|
||||
}
|
||||
|
||||
interface SshSettingsState {
|
||||
wildcardDomain: string;
|
||||
accessAppId: string;
|
||||
}
|
||||
|
||||
interface SettingsState {
|
||||
configured: boolean;
|
||||
accountId?: string;
|
||||
apiTokenMasked?: string;
|
||||
sshSettings?: SshSettingsState | null;
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
@ -18,17 +44,43 @@ export default function SettingsPage() {
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// SSH settings
|
||||
const [wildcardDomain, setWildcardDomain] = useState("");
|
||||
const [accessAppId, setAccessAppId] = useState("");
|
||||
const [accessApps, setAccessApps] = useState<AccessAppOption[]>([]);
|
||||
const [appsLoading, setAppsLoading] = useState(false);
|
||||
const [savingSsh, setSavingSsh] = useState(false);
|
||||
const [savedSsh, setSavedSsh] = useState(false);
|
||||
const [sshError, setSshError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/settings")
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
setState(d);
|
||||
if (d.accountId) setAccountId(d.accountId);
|
||||
if (d.sshSettings) {
|
||||
setWildcardDomain(d.sshSettings.wildcardDomain || "");
|
||||
setAccessAppId(d.sshSettings.accessAppId || "");
|
||||
}
|
||||
})
|
||||
.catch(() => setState({ configured: false }));
|
||||
}, []);
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
// Load Access apps for the dropdown when CF creds are configured
|
||||
useEffect(() => {
|
||||
if (!state?.configured) return;
|
||||
setAppsLoading(true);
|
||||
fetch("/api/access-apps")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (Array.isArray(data)) setAccessApps(data);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setAppsLoading(false));
|
||||
}, [state?.configured]);
|
||||
|
||||
async function handleSaveCreds(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
@ -41,7 +93,12 @@ export default function SettingsPage() {
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
setSaved(true);
|
||||
setState({ configured: true, accountId, apiTokenMasked: `${apiToken.slice(0, 6)}${"*".repeat(20)}` });
|
||||
setState((prev) => ({
|
||||
...prev!,
|
||||
configured: true,
|
||||
accountId,
|
||||
apiTokenMasked: `${apiToken.slice(0, 6)}${"*".repeat(20)}`,
|
||||
}));
|
||||
setApiToken("");
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
} catch (err) {
|
||||
@ -51,24 +108,53 @@ export default function SettingsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveSsh(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSavingSsh(true);
|
||||
setSshError(null);
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ wildcardDomain, accessAppId }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
setSavedSsh(true);
|
||||
setState((prev) => ({
|
||||
...prev!,
|
||||
sshSettings: { wildcardDomain, accessAppId },
|
||||
}));
|
||||
setTimeout(() => setSavedSsh(false), 3000);
|
||||
} catch (err) {
|
||||
setSshError(err instanceof Error ? err.message : "Failed to save");
|
||||
} finally {
|
||||
setSavingSsh(false);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedApp = accessApps.find((a) => a.id === accessAppId);
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-3.5rem)]">
|
||||
<div className="max-w-xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div className="max-w-xl mx-auto px-4 sm:px-6 py-8 sm:py-12 space-y-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-[var(--bg-card)] border border-[var(--border)] flex items-center justify-center">
|
||||
<Settings className="w-5 h-5 text-[var(--text-muted)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-[var(--text)]">设置</h1>
|
||||
<p className="text-sm text-[var(--text-muted)]">
|
||||
Cloudflare API 凭证
|
||||
Cloudflare API 凭证 & SSH 共享应用配置
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Section 1: CF Credentials ─────────────────────────── */}
|
||||
|
||||
{/* Current status */}
|
||||
{state && (
|
||||
<div className={`rounded-xl border p-4 mb-6 flex items-start gap-3 ${
|
||||
<div className={`rounded-xl border p-4 flex items-start gap-3 ${
|
||||
state.configured
|
||||
? "border-emerald-500/20 bg-emerald-500/5"
|
||||
: "border-amber-500/20 bg-amber-500/5"
|
||||
@ -99,8 +185,13 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSave} className="rounded-xl border border-[var(--border)] bg-[var(--bg-card)] p-6 space-y-5">
|
||||
{/* Credentials form */}
|
||||
<form onSubmit={handleSaveCreds} className="rounded-xl border border-[var(--border)] bg-[var(--bg-card)] p-6 space-y-5">
|
||||
<h2 className="text-sm font-semibold text-[var(--text-muted)] flex items-center gap-2">
|
||||
<Key className="w-4 h-4" />
|
||||
Cloudflare API 凭证
|
||||
</h2>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-1.5 text-sm font-medium text-[var(--text)] mb-1.5">
|
||||
<Key className="w-3.5 h-3.5 text-[var(--text-muted)]" />
|
||||
@ -167,6 +258,111 @@ export default function SettingsPage() {
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* ─── Section 2: SSH Shared App Settings ────────────────── */}
|
||||
|
||||
<form onSubmit={handleSaveSsh} className="rounded-xl border border-[var(--border)] bg-[var(--bg-card)] p-6 space-y-5">
|
||||
<h2 className="text-sm font-semibold text-[var(--text-muted)] flex items-center gap-2">
|
||||
<Globe className="w-4 h-4" />
|
||||
SSH 共享应用配置
|
||||
</h2>
|
||||
|
||||
{/* SSH settings status */}
|
||||
{state?.sshSettings && (
|
||||
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-3 flex items-start gap-2">
|
||||
<Shield className="w-3.5 h-3.5 text-emerald-500 mt-0.5 shrink-0" />
|
||||
<div className="text-xs text-emerald-600 dark:text-emerald-400">
|
||||
<p className="font-medium">已配置</p>
|
||||
<p className="text-[var(--text-faint)] mt-0.5 font-mono break-all">
|
||||
通配域名:*.{state.sshSettings.wildcardDomain}<br />
|
||||
Access App ID:{state.sshSettings.accessAppId.slice(0, 12)}…
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-1.5 text-sm font-medium text-[var(--text)] mb-1.5">
|
||||
<Globe className="w-3.5 h-3.5 text-[var(--text-muted)]" />
|
||||
通配域名(Wildcard Domain)
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-[var(--text-faint)] font-mono select-none">*.</span>
|
||||
<input
|
||||
type="text"
|
||||
value={wildcardDomain}
|
||||
onChange={(e) => setWildcardDomain(e.target.value)}
|
||||
placeholder="188889.xyz"
|
||||
className="settings-input flex-1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-faint)] mt-1">
|
||||
所有 SSH 主机都会部署在此域名下,如 node1.188889.xyz
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-1.5 text-sm font-medium text-[var(--text)] mb-1.5">
|
||||
<Shield className="w-3.5 h-3.5 text-[var(--text-muted)]" />
|
||||
共享 Access 应用
|
||||
</label>
|
||||
{!state?.configured ? (
|
||||
<p className="text-xs text-amber-500">请先配置 Cloudflare API 凭证</p>
|
||||
) : appsLoading ? (
|
||||
<div className="settings-input flex items-center gap-2 text-[var(--text-faint)]">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin shrink-0" />
|
||||
<span className="text-sm">加载应用列表中…</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<select
|
||||
value={accessAppId}
|
||||
onChange={(e) => setAccessAppId(e.target.value)}
|
||||
className="settings-input appearance-none pr-8"
|
||||
required
|
||||
>
|
||||
<option value="">选择 Access 应用…</option>
|
||||
{accessApps.map((app) => (
|
||||
<option key={app.id} value={app.id}>
|
||||
{app.name} ({app.domain})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--text-faint)]" />
|
||||
</div>
|
||||
)}
|
||||
{selectedApp && (
|
||||
<p className="text-xs text-[var(--text-faint)] mt-1 font-mono">
|
||||
域名:{selectedApp.domains.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-[var(--text-faint)] mt-1">
|
||||
选择已手动创建的通配 Access 应用(如 *.188889.xyz),所有 SSH 主机将共享此应用的策略和 CA 证书
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{sshError && (
|
||||
<p className="text-sm text-red-500 flex items-center gap-1.5">
|
||||
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
|
||||
{sshError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={savingSsh || !state?.configured}
|
||||
className="w-full flex items-center justify-center gap-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 text-white font-semibold py-2.5 transition"
|
||||
>
|
||||
{savingSsh ? (
|
||||
<><RefreshCw className="w-4 h-4 animate-spin" /> 保存中...</>
|
||||
) : savedSsh ? (
|
||||
<><Check className="w-4 h-4" /> 已保存!</>
|
||||
) : (
|
||||
<><Save className="w-4 h-4" /> 保存 SSH 配置</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@ -188,6 +384,10 @@ export default function SettingsPage() {
|
||||
.settings-input::placeholder {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
select.settings-input option {
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -7,27 +7,23 @@ import {
|
||||
Search,
|
||||
Server,
|
||||
Clock,
|
||||
Tag,
|
||||
Globe,
|
||||
Shield,
|
||||
Activity,
|
||||
ChevronRight,
|
||||
Monitor,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
import MetricsWidget from "@/components/MetricsWidget";
|
||||
|
||||
export interface SshAppData {
|
||||
export interface SshHostData {
|
||||
id: string;
|
||||
name: string;
|
||||
domain: string;
|
||||
domains: string[];
|
||||
hostname: string;
|
||||
tunnelId: string;
|
||||
tunnelName: string;
|
||||
service: string;
|
||||
launchUrl: string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
tags: string[];
|
||||
logoUrl: string | null;
|
||||
sessionDuration: string | null;
|
||||
metricsUrl: string | null;
|
||||
tunnelStatus: string;
|
||||
tunnelCreatedAt: string | null;
|
||||
}
|
||||
|
||||
function timeAgo(dateStr: string | null): string {
|
||||
@ -45,33 +41,28 @@ function timeAgo(dateStr: string | null): string {
|
||||
}
|
||||
|
||||
export default function Dashboard({
|
||||
apps,
|
||||
emptyStateHint = "当前还没有 SSH 主机,先去创建一台。",
|
||||
hosts,
|
||||
emptyStateHint = "当前还没有 SSH 主机,先去部署一台。",
|
||||
}: {
|
||||
apps: SshAppData[];
|
||||
hosts: SshHostData[];
|
||||
emptyStateHint?: string;
|
||||
}) {
|
||||
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) => {
|
||||
return hosts.filter((host) => {
|
||||
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;
|
||||
if (!q) return true;
|
||||
return (
|
||||
host.hostname.toLowerCase().includes(q) ||
|
||||
host.tunnelName.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [apps, search, selectedTag]);
|
||||
}, [hosts, search]);
|
||||
|
||||
const activeCount = hosts.filter(
|
||||
(h) => h.tunnelStatus === "healthy" || h.tunnelStatus === "active"
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--bg)] relative overflow-hidden">
|
||||
@ -104,7 +95,7 @@ export default function Dashboard({
|
||||
<StatCard
|
||||
icon={<Server className="w-4 h-4" />}
|
||||
label="主机总数"
|
||||
value={apps.length}
|
||||
value={hosts.length}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Activity className="w-4 h-4" />}
|
||||
@ -112,9 +103,9 @@ export default function Dashboard({
|
||||
value={filtered.length}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Tag className="w-4 h-4" />}
|
||||
label="标签"
|
||||
value={allTags.length}
|
||||
icon={<Wifi className="w-4 h-4" />}
|
||||
label="在线隧道"
|
||||
value={activeCount}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Shield className="w-4 h-4" />}
|
||||
@ -124,50 +115,18 @@ export default function Dashboard({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Search + Tags */}
|
||||
<div className="mb-6 space-y-3">
|
||||
{/* Search bar */}
|
||||
{/* Search */}
|
||||
<div className="mb-6">
|
||||
<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="按名称、域名或标签搜索主机..."
|
||||
placeholder="按主机名或隧道名称搜索..."
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
全部
|
||||
</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 */}
|
||||
@ -179,18 +138,16 @@ export default function Dashboard({
|
||||
<p className="text-lg font-medium text-[var(--text-muted)]">
|
||||
未找到主机
|
||||
</p>
|
||||
<p className="text-sm mt-1">
|
||||
{search || selectedTag
|
||||
? "请调整搜索条件或过滤器"
|
||||
: emptyStateHint}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm mt-1">
|
||||
{search ? "请调整搜索条件" : emptyStateHint}
|
||||
</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} />
|
||||
{filtered.map((host) => (
|
||||
<HostCard key={host.id} host={host} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -249,75 +206,65 @@ function StatCard({
|
||||
);
|
||||
}
|
||||
|
||||
function HostCard({ app }: { app: SshAppData }) {
|
||||
function HostCard({ host }: { host: SshHostData }) {
|
||||
const isOnline =
|
||||
host.tunnelStatus === "healthy" || host.tunnelStatus === "active";
|
||||
|
||||
return (
|
||||
<div className="group relative flex flex-col rounded-2xl border border-[var(--border)] bg-white/60 dark:bg-zinc-900/60 backdrop-blur-xl transition-all duration-300 hover:-translate-y-1 hover:border-emerald-500/50 hover:shadow-xl hover:shadow-[var(--accent-glow)] overflow-hidden">
|
||||
<a
|
||||
href={host.launchUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group relative flex flex-col rounded-2xl border border-[var(--border)] bg-white/60 dark:bg-zinc-900/60 backdrop-blur-xl transition-all duration-300 hover:-translate-y-1 hover:border-emerald-500/50 hover:shadow-xl hover:shadow-[var(--accent-glow)] overflow-hidden p-6"
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-emerald-500/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
|
||||
<a href={`/connect/${app.id}`} className="flex flex-col p-6 flex-1 relative z-10">
|
||||
{/* Top row */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3.5 min-w-0">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-400/20 to-emerald-500/10 border border-emerald-500/30 shrink-0 shadow-inner group-hover:scale-105 transition-transform duration-300">
|
||||
<Terminal className="w-5 h-5 text-emerald-500" />
|
||||
</div>
|
||||
<span className="font-bold text-[var(--text)] text-lg truncate tracking-tight">{app.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-[var(--bg-subtle)] border border-[var(--border)] group-hover:bg-emerald-500 group-hover:border-emerald-400 transition-colors duration-300 shrink-0">
|
||||
<ChevronRight className="w-4 h-4 text-[var(--text-muted)] group-hover:text-white group-hover:translate-x-0.5 transition-all duration-300" />
|
||||
|
||||
{/* Top row */}
|
||||
<div className="flex items-start justify-between mb-4 relative z-10">
|
||||
<div className="flex items-center gap-3.5 min-w-0">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-400/20 to-emerald-500/10 border border-emerald-500/30 shrink-0 shadow-inner group-hover:scale-105 transition-transform duration-300">
|
||||
<Terminal className="w-5 h-5 text-emerald-500" />
|
||||
</div>
|
||||
<span className="font-bold text-[var(--text)] text-lg truncate tracking-tight">
|
||||
{host.tunnelName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Domain */}
|
||||
<div className="flex items-center gap-2 text-[var(--text-muted)] mb-4 truncate bg-[var(--bg-subtle)] px-3 py-1.5 rounded-lg border border-[var(--border)] group-hover:border-[var(--text-faint)] transition-colors">
|
||||
<Globe className="w-4 h-4 shrink-0 text-[var(--text-faint)] group-hover:text-emerald-500/70 transition-colors" />
|
||||
<span className="truncate text-sm font-medium">{app.domain}</span>
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-[var(--bg-subtle)] border border-[var(--border)] group-hover:bg-emerald-500 group-hover:border-emerald-400 transition-colors duration-300 shrink-0">
|
||||
<ExternalLink className="w-4 h-4 text-[var(--text-muted)] group-hover:text-white transition-all duration-300" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Extra domains */}
|
||||
{app.domains.length > 1 && (
|
||||
<div className="text-xs font-medium text-[var(--text-faint)] mb-4 flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[var(--text-faint)]"></span>
|
||||
另有 {app.domains.length - 1} 个域名
|
||||
</div>
|
||||
)}
|
||||
{/* Hostname */}
|
||||
<div className="flex items-center gap-2 text-[var(--text-muted)] mb-4 truncate bg-[var(--bg-subtle)] px-3 py-1.5 rounded-lg border border-[var(--border)] group-hover:border-[var(--text-faint)] transition-colors relative z-10">
|
||||
<Globe className="w-4 h-4 shrink-0 text-[var(--text-faint)] group-hover:text-emerald-500/70 transition-colors" />
|
||||
<span className="truncate text-sm font-medium">{host.hostname}</span>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{app.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-4 mt-auto">
|
||||
{app.tags.slice(0, 3).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-2.5 py-1 rounded-lg bg-[var(--bg-subtle)] text-[var(--text-muted)] text-[11px] font-semibold tracking-wide uppercase border border-[var(--border)] group-hover:bg-[var(--bg-card)] transition-colors"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{app.tags.length > 3 && (
|
||||
<span className="px-2 py-1 text-[11px] font-bold text-[var(--text-faint)]">
|
||||
+{app.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Service info */}
|
||||
<div className="text-xs font-mono text-[var(--text-faint)] mb-4 relative z-10">
|
||||
{host.service}
|
||||
</div>
|
||||
|
||||
{/* Bottom info */}
|
||||
<div className="mt-auto pt-4 border-t border-[var(--border)] flex items-center justify-between text-xs font-medium text-[var(--text-faint)]">
|
||||
<div className="flex items-center gap-1.5 group-hover:text-[var(--text-muted)] transition-colors">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
<span>{timeAgo(app.updatedAt)}</span>
|
||||
</div>
|
||||
{app.sessionDuration && (
|
||||
<span className="px-2 py-0.5 rounded-md bg-[var(--bg-subtle)] border border-[var(--border)]">TTL {app.sessionDuration}</span>
|
||||
{/* Bottom info */}
|
||||
<div className="mt-auto pt-4 border-t border-[var(--border)] flex items-center justify-between text-xs font-medium text-[var(--text-faint)] relative z-10">
|
||||
<div className="flex items-center gap-1.5 group-hover:text-[var(--text-muted)] transition-colors">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
<span>{timeAgo(host.tunnelCreatedAt)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isOnline ? (
|
||||
<>
|
||||
<Wifi className="w-3.5 h-3.5 text-emerald-500" />
|
||||
<span className="text-emerald-500">在线</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WifiOff className="w-3.5 h-3.5 text-amber-500" />
|
||||
<span className="text-amber-500">{host.tunnelStatus || "离线"}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{/* Metrics widget (outside the connect link) */}
|
||||
{app.metricsUrl && (
|
||||
<div className="px-6 pb-6 relative z-10">
|
||||
<MetricsWidget metricsUrl={app.metricsUrl} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@ -18,18 +18,16 @@ export interface AccessApp {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SshApp {
|
||||
export interface SshHost {
|
||||
/** Unique key: tunnelId + hostname */
|
||||
id: string;
|
||||
name: string;
|
||||
domain: string;
|
||||
domains: string[];
|
||||
hostname: string;
|
||||
tunnelId: string;
|
||||
tunnelName: string;
|
||||
service: string;
|
||||
launchUrl: string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
tags: string[];
|
||||
logoUrl: string | null;
|
||||
sessionDuration: string | null;
|
||||
metricsUrl: string | null;
|
||||
tunnelStatus: string;
|
||||
tunnelCreatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface Tunnel {
|
||||
@ -50,12 +48,27 @@ export interface TunnelConnection {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TunnelIngress {
|
||||
hostname?: string;
|
||||
service: string;
|
||||
originRequest?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TunnelConfig {
|
||||
tunnelId: string;
|
||||
tunnelName: string;
|
||||
tunnelStatus: string;
|
||||
tunnelCreatedAt: string | null;
|
||||
ingress: TunnelIngress[];
|
||||
}
|
||||
|
||||
export interface SetupInput {
|
||||
serverName: string;
|
||||
hostname: string;
|
||||
sshPort: number;
|
||||
ssoEmail: string;
|
||||
allowedEmails: string[];
|
||||
/** The shared Access app ID (wildcard app, already exists) */
|
||||
accessAppId: string;
|
||||
}
|
||||
|
||||
export interface SetupStepResult {
|
||||
@ -74,7 +87,6 @@ export interface SetupResult {
|
||||
}
|
||||
|
||||
// ─── Credentials ─────────────────────────────────────────────────────────────
|
||||
// Reads from env vars first, falls back to encrypted cookie set via /settings
|
||||
import { requireCFCreds } from "@/lib/credentials";
|
||||
|
||||
async function getCredentials() {
|
||||
@ -170,65 +182,6 @@ export async function listAccessApps(): Promise<AccessApp[]> {
|
||||
return allApps;
|
||||
}
|
||||
|
||||
/** Tag added to every Access app created by this console */
|
||||
export const SSH_CONSOLE_TAG = "managed:ssh-console";
|
||||
const LEGACY_SSH_APP_NAME_PREFIX = "SSH · ";
|
||||
|
||||
/**
|
||||
* Identifies apps managed by this SSH console.
|
||||
* An app qualifies when it carries the `managed:ssh-console` tag OR
|
||||
* (legacy) has a `metrics:` tag — both patterns are set by `createAccessApp`.
|
||||
*/
|
||||
function isSshConsoleApp(app: AccessApp): boolean {
|
||||
const tags = (app.tags as string[]) ?? [];
|
||||
// New apps: explicit managed tag
|
||||
if (tags.includes(SSH_CONSOLE_TAG)) return true;
|
||||
// Legacy apps created before the tag was introduced: has a metrics: tag
|
||||
// (only this console sets that pattern)
|
||||
if (tags.some((t) => t.startsWith("metrics:"))) return true;
|
||||
// Older apps created by this console used a stable name prefix.
|
||||
if ((app.name ?? "").startsWith(LEGACY_SSH_APP_NAME_PREFIX)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function filterSshApps(apps: AccessApp[]): SshApp[] {
|
||||
return apps
|
||||
.filter((app) => app.type === "self_hosted" && isSshConsoleApp(app))
|
||||
.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;
|
||||
}
|
||||
|
||||
// ─── Zones ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listZones(): Promise<{ id: string; name: string }[]> {
|
||||
@ -298,6 +251,63 @@ export async function configureTunnel(
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Tunnel Configurations (read ingress) ────────────────────────────────────
|
||||
|
||||
export async function getTunnelConfigurations(
|
||||
tunnelId: string
|
||||
): Promise<TunnelIngress[]> {
|
||||
const { accountId } = await getCredentials();
|
||||
const json = await cfGet(
|
||||
`/accounts/${accountId}/cfd_tunnel/${tunnelId}/configurations`,
|
||||
30
|
||||
);
|
||||
const config = json.result?.config;
|
||||
return (config?.ingress as TunnelIngress[]) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive SSH hosts from all tunnel configurations.
|
||||
* Filters to only hostnames matching the given wildcard domain (e.g. "188889.xyz").
|
||||
* Skips catch-all rules (no hostname) and non-SSH services.
|
||||
*/
|
||||
export async function deriveSshHosts(
|
||||
wildcardDomain: string
|
||||
): Promise<SshHost[]> {
|
||||
const tunnels = await listTunnels();
|
||||
const hosts: SshHost[] = [];
|
||||
|
||||
for (const tunnel of tunnels) {
|
||||
let ingress: TunnelIngress[];
|
||||
try {
|
||||
ingress = await getTunnelConfigurations(tunnel.id);
|
||||
} catch {
|
||||
// Tunnel may not have config yet, skip
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const rule of ingress) {
|
||||
if (!rule.hostname) continue; // skip catch-all
|
||||
if (!rule.service.startsWith("ssh://")) continue; // only SSH services
|
||||
|
||||
// Check if hostname matches wildcard domain (e.g. "node1.188889.xyz" matches "188889.xyz")
|
||||
if (!rule.hostname.endsWith(`.${wildcardDomain}`)) continue;
|
||||
|
||||
hosts.push({
|
||||
id: `${tunnel.id}:${rule.hostname}`,
|
||||
hostname: rule.hostname,
|
||||
tunnelId: tunnel.id,
|
||||
tunnelName: tunnel.name,
|
||||
service: rule.service,
|
||||
launchUrl: `https://${rule.hostname}`,
|
||||
tunnelStatus: tunnel.status,
|
||||
tunnelCreatedAt: tunnel.created_at ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return hosts;
|
||||
}
|
||||
|
||||
// ─── DNS ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function findZoneForHostname(
|
||||
@ -326,54 +336,10 @@ export async function createDnsCname(
|
||||
name,
|
||||
content: `${tunnelId}.cfargotunnel.com`,
|
||||
proxied: true,
|
||||
comment: "Auto-created by SSH Launcher",
|
||||
comment: "Auto-created by SSH Console",
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Access App Creation ─────────────────────────────────────────────────────
|
||||
|
||||
export async function createAccessApp(
|
||||
hostname: string,
|
||||
appName: string,
|
||||
allowedEmails: string[]
|
||||
): Promise<{ appId: string; metricsHostname: string }> {
|
||||
const { accountId } = await 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: [SSH_CONSOLE_TAG, `metrics:https://${metricsHostname}`],
|
||||
policies,
|
||||
});
|
||||
|
||||
return { appId: json.result.id, metricsHostname };
|
||||
}
|
||||
|
||||
// ─── Short-lived Certificate CA ──────────────────────────────────────────────
|
||||
|
||||
export async function getOrCreateCaCert(
|
||||
@ -400,14 +366,22 @@ export async function getOrCreateCaCert(
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Full Pipeline ───────────────────────────────────────────────────────────
|
||||
// ─── Full Pipeline (shared-app model) ────────────────────────────────────────
|
||||
//
|
||||
// New flow:
|
||||
// 1. Find zone for hostname
|
||||
// 2. Create tunnel
|
||||
// 3. Configure tunnel ingress (SSH only, no metrics)
|
||||
// 4. Create DNS CNAME
|
||||
// 5. Fetch CA public key from the shared Access app (no app creation!)
|
||||
// 6. Get tunnel token
|
||||
// 7. Build install command
|
||||
|
||||
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;
|
||||
@ -436,7 +410,7 @@ export async function runFullSetup(input: SetupInput): Promise<SetupResult> {
|
||||
steps.push({
|
||||
step: "create_tunnel",
|
||||
status: "success",
|
||||
message: `Tunnel created: ${tunnel.name} (${tunnel.id})`,
|
||||
message: `隧道已创建:${tunnel.name}(${tunnel.id})`,
|
||||
data: { tunnelId: tunnel.id, tunnelName: tunnel.name },
|
||||
});
|
||||
} catch (e) {
|
||||
@ -448,23 +422,13 @@ export async function runFullSetup(input: SetupInput): Promise<SetupResult> {
|
||||
return { steps, tunnelToken: null, caPubkey: null, installCommand: null, launchUrl: null };
|
||||
}
|
||||
|
||||
// Step 3: Configure tunnel ingress (SSH + metrics HTTP)
|
||||
const metricsDomain = `metrics.${input.hostname}`;
|
||||
// Step 3: Configure tunnel ingress (SSH only — no metrics subdomain)
|
||||
try {
|
||||
const { accountId } = await 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" },
|
||||
],
|
||||
},
|
||||
});
|
||||
await configureTunnel(tunnelId, input.hostname, input.sshPort);
|
||||
steps.push({
|
||||
step: "configure_tunnel",
|
||||
status: "success",
|
||||
message: `Ingress: ${input.hostname} → SSH, ${metricsDomain} → metrics:9101`,
|
||||
message: `Ingress:${input.hostname} → ssh://localhost:${input.sshPort}`,
|
||||
});
|
||||
} catch (e) {
|
||||
steps.push({
|
||||
@ -480,7 +444,7 @@ export async function runFullSetup(input: SetupInput): Promise<SetupResult> {
|
||||
steps.push({
|
||||
step: "create_dns",
|
||||
status: "success",
|
||||
message: `CNAME created: ${input.hostname} → ${tunnelId}.cfargotunnel.com`,
|
||||
message: `CNAME 已创建:${input.hostname} → ${tunnelId}.cfargotunnel.com`,
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "";
|
||||
@ -488,7 +452,7 @@ export async function runFullSetup(input: SetupInput): Promise<SetupResult> {
|
||||
steps.push({
|
||||
step: "create_dns",
|
||||
status: "skipped",
|
||||
message: `DNS record already exists for ${input.hostname}`,
|
||||
message: `DNS 记录已存在:${input.hostname}`,
|
||||
});
|
||||
} else {
|
||||
steps.push({
|
||||
@ -499,49 +463,31 @@ export async function runFullSetup(input: SetupInput): Promise<SetupResult> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
// Step 5: Fetch CA public key from the SHARED Access app (no new app creation)
|
||||
if (input.accessAppId) {
|
||||
caPubkey = await getOrCreateCaCert(input.accessAppId);
|
||||
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)",
|
||||
? "已从共享 Access 应用获取 CA 公钥"
|
||||
: "Could not retrieve CA public key from shared app (enable manually in dashboard)",
|
||||
});
|
||||
} else {
|
||||
steps.push({
|
||||
step: "setup_ca",
|
||||
status: "skipped",
|
||||
message: "未指定共享 Access 应用,跳过 CA 配置",
|
||||
});
|
||||
}
|
||||
|
||||
// Step 7: Get tunnel token
|
||||
// Step 6: Get tunnel token
|
||||
try {
|
||||
tunnelToken = await getTunnelToken(tunnelId);
|
||||
steps.push({
|
||||
step: "get_token",
|
||||
status: "success",
|
||||
message: "Tunnel token retrieved",
|
||||
message: "隧道令牌已获取",
|
||||
});
|
||||
} catch (e) {
|
||||
steps.push({
|
||||
|
||||
@ -54,3 +54,47 @@ export async function requireCFCreds(): Promise<CFCreds> {
|
||||
}
|
||||
return creds;
|
||||
}
|
||||
|
||||
// ─── SSH Settings (wildcard domain + shared Access app) ─────────────────────
|
||||
|
||||
export interface SshSettings {
|
||||
/** e.g. "188889.xyz" — the wildcard domain's base (Access app covers *.188889.xyz) */
|
||||
wildcardDomain: string;
|
||||
/** The Cloudflare Access application ID for the shared wildcard app */
|
||||
accessAppId: string;
|
||||
}
|
||||
|
||||
function sshSettingsKey(userId: string) {
|
||||
return `ssh_settings:${userId}`;
|
||||
}
|
||||
|
||||
export async function saveSshSettings(settings: SshSettings): Promise<void> {
|
||||
const uid = await currentUserId();
|
||||
if (!uid) throw new Error("Not authenticated");
|
||||
await redis.set(sshSettingsKey(uid), settings);
|
||||
}
|
||||
|
||||
export async function loadSshSettings(): Promise<SshSettings | null> {
|
||||
// Env var override
|
||||
if (process.env.SSH_WILDCARD_DOMAIN && process.env.SSH_ACCESS_APP_ID) {
|
||||
return {
|
||||
wildcardDomain: process.env.SSH_WILDCARD_DOMAIN,
|
||||
accessAppId: process.env.SSH_ACCESS_APP_ID,
|
||||
};
|
||||
}
|
||||
|
||||
const uid = await currentUserId();
|
||||
if (!uid) return null;
|
||||
|
||||
return redis.get<SshSettings>(sshSettingsKey(uid));
|
||||
}
|
||||
|
||||
export async function requireSshSettings(): Promise<SshSettings> {
|
||||
const settings = await loadSshSettings();
|
||||
if (!settings) {
|
||||
throw new Error(
|
||||
"SSH settings not configured. Visit /settings to set wildcard domain and Access app."
|
||||
);
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user