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:
chunzhimoe 2026-04-12 18:07:42 +08:00
parent d98b6e310a
commit b8bc7f0e01
10 changed files with 631 additions and 440 deletions

View 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 }
);
}
}

View File

@ -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 });
}

View File

@ -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) {

View File

@ -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);
}

View File

@ -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">
hint={hostname ? `将创建:${hostname}` : "填写子域名前缀"}>
<div className="flex items-center gap-2">
<input
type="text"
value={form.subdomain}
onChange={set("subdomain")}
placeholder="ssh"
className="form-input"
placeholder="node1"
className="form-input flex-1"
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>
<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>
);

View File

@ -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} />;
}

View File

@ -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>
);

View File

@ -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 */}
@ -180,17 +139,15 @@ export default function Dashboard({
</p>
<p className="text-sm mt-1">
{search || selectedTag
? "请调整搜索条件或过滤器"
: emptyStateHint}
{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-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">{app.name}</span>
<span className="font-bold text-[var(--text)] text-lg truncate tracking-tight">
{host.tunnelName}
</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" />
<ExternalLink className="w-4 h-4 text-[var(--text-muted)] group-hover:text-white transition-all duration-300" />
</div>
</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">
{/* 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">{app.domain}</span>
<span className="truncate text-sm font-medium">{host.hostname}</span>
</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}
{/* Service info */}
<div className="text-xs font-mono text-[var(--text-faint)] mb-4 relative z-10">
{host.service}
</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>
)}
{/* 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="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(app.updatedAt)}</span>
<span>{timeAgo(host.tunnelCreatedAt)}</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>
<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>
</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>
);
}

View File

@ -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({

View File

@ -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;
}