fix: SSH app filtering, auth secret, middleware, i18n typos, remove .env from tracking

This commit is contained in:
chunzhimoe 2026-04-12 17:16:06 +08:00
parent c42392d9df
commit 3f2ecb89c0
10 changed files with 115 additions and 78 deletions

41
.env
View File

@ -1,41 +0,0 @@
# ═══════════════════════════════════════════════════════════════
# SSH Launcher — Environment Variables
# Copy to .env.local for local dev, or paste into Vercel dashboard
# (Settings → Environment Variables → Import .env)
# ═══════════════════════════════════════════════════════════════
# ── GitHub OAuth ─────────────────────────────────────────────
# Create at https://github.com/settings/developers → New OAuth App
# Homepage URL: https://your-domain.vercel.app
# Callback URL: https://your-domain.vercel.app/api/auth/callback/github
GITHUB_CLIENT_ID=Ov23liBlDuiNR8E2ZmPR
GITHUB_CLIENT_SECRET=dfd1251347afb0fb09d0ceb97bd53de301b23768
# ── NextAuth ─────────────────────────────────────────────────
# Generate secret: openssl rand -base64 32
NEXTAUTH_SECRET=N0yaiIpxiVxJrU+xG8cG1CWKRNQ2q3NYjLvd0eEMpic=
# Required only when self-hosting (Vercel sets this automatically)
# NEXTAUTH_URL=https://your-domain.vercel.app
# ── Upstash Redis ─────────────────────────────────────────────
# Stores per-user Cloudflare credentials (multi-user)
# Create a free database at https://console.upstash.com
# Copy the REST API URL and Token from the database dashboard
UPSTASH_REDIS_URL=https://native-tadpole-80150.upstash.io
UPSTASH_REDIS_TOKEN=gQAAAAAAATkWAAIncDIzMWVkNTgxMDMzMzQ0OWVlYjRkY2RiOGM3NTlkYjdlNXAyODAxNTA
# ── Cloudflare API (optional — can set per-user via /settings) ─
# If set here, all users share these credentials (single-user mode)
# Required Token permissions:
# Account → Cloudflare Tunnel: Edit
# Account → Access: Apps and Policies: Edit
# Zone → DNS: Edit
# Zone → Zone: Read
CLOUDFLARE_ACCOUNT_ID=d6c7c05733970cca34c3f3a734195de0
CLOUDFLARE_API_TOKEN=cfut_kNwpPHUr33ZZqvBl1WJpGA6bBiw5wpfTs8LW8kPAbf601ad8
# ── Cloudflare Access Service Token (optional) ───────────────
# Used to fetch server metrics through Access-protected tunnels
# Create in Zero Trust → Access → Service Auth → Service Tokens
CF_SERVICE_CLIENT_ID=
CF_SERVICE_CLIENT_SECRET=

47
.env.example Normal file
View File

@ -0,0 +1,47 @@
# ═══════════════════════════════════════════════════════════════
# SSH Console — 环境变量模板
# 复制此文件为 .env.local 用于本地开发,或在 Vercel 控制台逐项填入
# Vercel: Settings → Environment Variables → 按 Production / Preview 分别配置
# ═══════════════════════════════════════════════════════════════
# ── GitHub OAuth ─────────────────────────────────────────────
# 创建地址: https://github.com/settings/developers → New OAuth App
# Homepage URL: https://your-domain.vercel.app
# Callback URL: https://your-domain.vercel.app/api/auth/callback/github
# 预览环境额外回调: https://<project>-git-<branch>-<team>.vercel.app/api/auth/callback/github
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
# ── NextAuth ─────────────────────────────────────────────────
# 必填!生产环境缺少此项会导致 NO_SECRET 错误和登录失败。
# 生成命令: openssl rand -base64 32
NEXTAUTH_SECRET=
# 本地开发或自托管时需要填写Vercel 部署通常不需要(会自动检测)
# NEXTAUTH_URL=https://your-domain.vercel.app
# ── Upstash Redis ─────────────────────────────────────────────
# 存储每个用户的 Cloudflare 凭证(多用户模式)
# 免费数据库: https://console.upstash.com
UPSTASH_REDIS_URL=
UPSTASH_REDIS_TOKEN=
# ── Cloudflare API可选 — 也可以在 /settings 页面按用户配置)─
# 如果在此处填写,所有用户共享同一套凭证(单用户模式)
# Token 所需权限:
# Account → Cloudflare Tunnel: Edit
# Account → Access: Apps and Policies: Edit
# Zone → DNS: Edit
# Zone → Zone: Read
CLOUDFLARE_ACCOUNT_ID=
CLOUDFLARE_API_TOKEN=
# ── Cloudflare Access 服务令牌(可选)────────────────────────
# 用于通过 Access 保护的隧道拉取服务器指标
# 创建位置: Zero Trust → Access → Service Auth → Service Tokens
CF_SERVICE_CLIENT_ID=
CF_SERVICE_CLIENT_SECRET=
# ── 允许登录的邮箱白名单(可选)──────────────────────────────
# 逗号分隔;留空表示所有通过 GitHub 认证的用户均可登录
# ALLOWED_EMAILS=alice@example.com,bob@example.com

View File

@ -40,12 +40,12 @@ interface Zone {
const STEP_LABELS: Record<string, string> = { const STEP_LABELS: Record<string, string> = {
find_zone: "查找 DNS Zone", find_zone: "查找 DNS Zone",
create_tunnel: "创建道", create_tunnel: "创建道",
configure_tunnel: "配置 Ingress", configure_tunnel: "配置 Ingress",
create_dns: "创建 DNS 记录", create_dns: "创建 DNS 记录",
create_access_app: "创建访问应用", create_access_app: "创建 Access 应用",
setup_ca: "配置短期证书", setup_ca: "配置短期证书",
get_token: "获取道 Token", get_token: "获取道 Token",
browser_rendering: "浏览器渲染提醒", browser_rendering: "浏览器渲染提醒",
}; };
@ -78,11 +78,9 @@ export default function CreatePage() {
try { try {
const res = await fetch("/api/zones"); const res = await fetch("/api/zones");
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to load zones"); if (!res.ok) throw new Error(data.error || "加载 Zone 失败");
setZones(data);
if (data.length === 1) setForm((f) => ({ ...f, zoneId: data[0].id }));
} catch (e) { } catch (e) {
setZonesError(e instanceof Error ? e.message : "Failed to load zones"); setZonesError(e instanceof Error ? e.message : "加载 Zone 失败");
} finally { } finally {
setZonesLoading(false); setZonesLoading(false);
} }
@ -113,7 +111,7 @@ export default function CreatePage() {
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
setResult(data); setResult(data);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Unknown error"); setError(err instanceof Error ? err.message : "未知错误");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -145,7 +143,7 @@ export default function CreatePage() {
<div> <div>
<h1 className="text-2xl font-bold text-[var(--text)]"> SSH </h1> <h1 className="text-2xl font-bold text-[var(--text)]"> SSH </h1>
<p className="text-sm text-[var(--text-muted)]"> <p className="text-sm text-[var(--text-muted)]">
+ DNS + 访 + + DNS + Access +
</p> </p>
</div> </div>
</div> </div>
@ -212,14 +210,14 @@ export default function CreatePage() {
placeholder="22" className="form-input w-28" /> placeholder="22" className="form-input w-28" />
</Field> </Field>
<Field icon={<Mail className="w-4 h-4" />} label="SSO 邮" <Field icon={<Mail className="w-4 h-4" />} label="SSO 邮"
hint="Cloudflare SSO 邮,前缀将作为 Linux 用户名"> hint="Cloudflare SSO 邮,前缀将作为 Linux 用户名">
<input type="email" value={form.ssoEmail} onChange={set("ssoEmail")} <input type="email" value={form.ssoEmail} onChange={set("ssoEmail")}
placeholder="admin@example.com" className="form-input" required /> placeholder="admin@example.com" className="form-input" required />
</Field> </Field>
<Field icon={<Mail className="w-4 h-4" />} label="允许访问的邮(可选)" <Field icon={<Mail className="w-4 h-4" />} label="允许访问的邮(可选)"
hint="多个邮用逗号分隔,留空表示所有已认证用户均可访问"> hint="多个邮用逗号分隔,留空表示所有已认证用户均可访问">
<textarea value={form.allowedEmails} onChange={set("allowedEmails")} <textarea value={form.allowedEmails} onChange={set("allowedEmails")}
placeholder="alice@example.com, bob@example.com" placeholder="alice@example.com, bob@example.com"
rows={2} className="form-input resize-none" /> rows={2} className="form-input resize-none" />
@ -297,7 +295,7 @@ export default function CreatePage() {
{result.installCommand} {result.installCommand}
</pre> </pre>
<div className="px-5 py-3 border-t border-[var(--border)] text-xs text-[var(--text-faint)]"> <div className="px-5 py-3 border-t border-[var(--border)] text-xs text-[var(--text-faint)]">
root cloudflared SSH CA root cloudflared SSH CA
</div> </div>
</div> </div>
)} )}

View File

@ -12,7 +12,7 @@ export default async function Home() {
const allApps = await listAccessApps(); const allApps = await listAccessApps();
apps = filterSshApps(allApps); apps = filterSshApps(allApps);
} catch (e) { } catch (e) {
error = e instanceof Error ? e.message : "Unknown error"; error = e instanceof Error ? e.message : "未知错误";
apps = []; apps = [];
} }
@ -22,7 +22,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"> <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" /> <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 className="text-lg font-semibold text-red-300 mb-2">
Failed to load applications
</h2> </h2>
<p className="text-sm text-red-400/80 break-words">{error}</p> <p className="text-sm text-red-400/80 break-words">{error}</p>
</div> </div>

View File

@ -11,7 +11,7 @@ export default async function TunnelsPage() {
try { try {
tunnels = await listTunnels(); tunnels = await listTunnels();
} catch (e) { } catch (e) {
error = e instanceof Error ? e.message : "Unknown error"; error = e instanceof Error ? e.message : "未知错误";
tunnels = []; tunnels = [];
} }
@ -21,7 +21,7 @@ export default async function TunnelsPage() {
<div className="max-w-md w-full rounded-2xl border border-red-500/20 bg-red-500/5 p-8 text-center"> <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" /> <AlertTriangle className="w-10 h-10 text-red-400 mx-auto mb-4" />
<h2 className="text-lg font-semibold text-red-400 mb-2"> <h2 className="text-lg font-semibold text-red-400 mb-2">
</h2> </h2>
<p className="text-sm text-red-400/80 break-words">{error}</p> <p className="text-sm text-red-400/80 break-words">{error}</p>
</div> </div>

View File

@ -31,17 +31,17 @@ export interface SshAppData {
} }
function timeAgo(dateStr: string | null): string { function timeAgo(dateStr: string | null): string {
if (!dateStr) return "Unknown"; if (!dateStr) return "未知";
const diff = Date.now() - new Date(dateStr).getTime(); const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000); const mins = Math.floor(diff / 60000);
if (mins < 1) return "Just now"; if (mins < 1) return "刚刚";
if (mins < 60) return `${mins}m ago`; if (mins < 60) return `${mins} 分钟前`;
const hrs = Math.floor(mins / 60); const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`; if (hrs < 24) return `${hrs} 小时前`;
const days = Math.floor(hrs / 24); const days = Math.floor(hrs / 24);
if (days < 30) return `${days}d ago`; if (days < 30) return `${days} 天前`;
const months = Math.floor(days / 30); const months = Math.floor(days / 30);
return `${months}mo ago`; return `${months} 个月前`;
} }
export default function Dashboard({ apps }: { apps: SshAppData[] }) { export default function Dashboard({ apps }: { apps: SshAppData[] }) {
@ -261,8 +261,7 @@ function HostCard({ app }: { app: SshAppData }) {
{/* Extra domains */} {/* Extra domains */}
{app.domains.length > 1 && ( {app.domains.length > 1 && (
<div className="text-xs text-[var(--text-faint)] mb-3"> <div className="text-xs text-[var(--text-faint)] mb-3">
+{app.domains.length - 1} more domain {app.domains.length - 1}
{app.domains.length - 1 > 1 ? "s" : ""}
</div> </div>
)} )}

View File

@ -68,7 +68,7 @@ export default function TunnelList({ tunnels }: { tunnels: Tunnel[] }) {
<Server className="w-5 h-5 text-emerald-500" /> <Server className="w-5 h-5 text-emerald-500" />
</div> </div>
<div> <div>
<h1 className="text-2xl font-bold text-[var(--text)]"></h1> <h1 className="text-2xl font-bold text-[var(--text)]"></h1>
<p className="text-sm text-[var(--text-muted)]"> <p className="text-sm text-[var(--text-muted)]">
{tunnels.length} · {activeCount} 线 {tunnels.length} · {activeCount} 线
</p> </p>
@ -92,7 +92,7 @@ export default function TunnelList({ tunnels }: { tunnels: Tunnel[] }) {
<div className="flex flex-col items-center justify-center py-24 text-[var(--text-faint)]"> <div className="flex flex-col items-center justify-center py-24 text-[var(--text-faint)]">
<Server className="w-12 h-12 opacity-30 mb-4" /> <Server className="w-12 h-12 opacity-30 mb-4" />
<p className="text-lg font-medium text-[var(--text-muted)]"> <p className="text-lg font-medium text-[var(--text-muted)]">
</p> </p>
</div> </div>
)} )}
@ -169,7 +169,7 @@ export default function TunnelList({ tunnels }: { tunnels: Tunnel[] }) {
{/* Footer */} {/* Footer */}
<div className="mt-14 pt-6 border-t border-[var(--border-subtle)] flex items-center gap-2 text-xs text-[var(--text-faint)]"> <div className="mt-14 pt-6 border-t border-[var(--border-subtle)] flex items-center gap-2 text-xs text-[var(--text-faint)]">
<Shield className="w-3 h-3" /> <Shield className="w-3 h-3" />
<span>Cloudflare · 30 </span> <span>Cloudflare · 30 </span>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,6 +1,21 @@
import type { NextAuthOptions } from "next-auth"; import type { NextAuthOptions } from "next-auth";
import GithubProvider from "next-auth/providers/github"; import GithubProvider from "next-auth/providers/github";
// NEXTAUTH_SECRET is required in production.
// Generate with: openssl rand -base64 32
// Then add to Vercel: Settings → Environment Variables → NEXTAUTH_SECRET
const secret =
process.env.NEXTAUTH_SECRET ??
process.env.AUTH_SECRET; // fallback for future Auth.js rename
if (!secret && process.env.NODE_ENV === "production") {
throw new Error(
"[auth] 缺少 NEXTAUTH_SECRET 环境变量。" +
"请在 Vercel 控制台 Settings → Environment Variables 中添加 NEXTAUTH_SECRET。" +
"生成命令openssl rand -base64 32"
);
}
const allowedEmails = (process.env.ALLOWED_EMAILS ?? "") const allowedEmails = (process.env.ALLOWED_EMAILS ?? "")
.split(",") .split(",")
.map((e) => e.trim().toLowerCase()) .map((e) => e.trim().toLowerCase())
@ -13,7 +28,7 @@ export const authOptions: NextAuthOptions = {
clientSecret: process.env.GITHUB_CLIENT_SECRET ?? "", clientSecret: process.env.GITHUB_CLIENT_SECRET ?? "",
}), }),
], ],
secret: process.env.NEXTAUTH_SECRET, secret,
pages: { pages: {
signIn: "/login", signIn: "/login",
}, },

View File

@ -170,9 +170,27 @@ export async function listAccessApps(): Promise<AccessApp[]> {
return allApps; return allApps;
} }
/** Tag added to every Access app created by this console */
export const SSH_CONSOLE_TAG = "managed:ssh-console";
/**
* 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;
return false;
}
export function filterSshApps(apps: AccessApp[]): SshApp[] { export function filterSshApps(apps: AccessApp[]): SshApp[] {
return apps return apps
.filter((app) => app.type === "self_hosted") .filter((app) => app.type === "self_hosted" && isSshConsoleApp(app))
.map((app) => { .map((app) => {
const primaryDomain = const primaryDomain =
app.domain || app.domain ||
@ -346,7 +364,7 @@ export async function createAccessApp(
session_duration: "24h", session_duration: "24h",
auto_redirect_to_identity: true, auto_redirect_to_identity: true,
app_launcher_visible: true, app_launcher_visible: true,
tags: [`metrics:https://${metricsHostname}`], tags: [SSH_CONSOLE_TAG, `metrics:https://${metricsHostname}`],
policies, policies,
}); });

View File

@ -4,11 +4,12 @@ export const config = {
matcher: [ matcher: [
/* /*
* *
* - /login * - /login
* - /api/auth/* NextAuth * - /api/auth/* NextAuth
* - /_next/* Next.js * - /_next/* Next.js
* - /favicon.ico * - /favicon.* 网站图标(.ico / .png / .svg
* - favicon.png / logo.svg
*/ */
"/((?!login|api/auth|_next/static|_next/image|favicon\\.ico).*)", "/((?!login|api/auth|_next/static|_next/image|favicon\\.|.*\\.(?:ico|png|svg|jpg|jpeg|webp|gif|woff2?|ttf|otf)$).*)",
], ],
}; };