fix: SSH app filtering, auth secret, middleware, i18n typos, remove .env from tracking
This commit is contained in:
parent
c42392d9df
commit
3f2ecb89c0
41
.env
41
.env
@ -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
47
.env.example
Normal 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
|
||||
@ -40,12 +40,12 @@ interface Zone {
|
||||
|
||||
const STEP_LABELS: Record<string, string> = {
|
||||
find_zone: "查找 DNS Zone",
|
||||
create_tunnel: "创建隆道",
|
||||
create_tunnel: "创建隧道",
|
||||
configure_tunnel: "配置 Ingress",
|
||||
create_dns: "创建 DNS 记录",
|
||||
create_access_app: "创建访问应用",
|
||||
create_access_app: "创建 Access 应用",
|
||||
setup_ca: "配置短期证书",
|
||||
get_token: "获取隆道 Token",
|
||||
get_token: "获取隧道 Token",
|
||||
browser_rendering: "浏览器渲染提醒",
|
||||
};
|
||||
|
||||
@ -78,11 +78,9 @@ export default function CreatePage() {
|
||||
try {
|
||||
const res = await fetch("/api/zones");
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to load zones");
|
||||
setZones(data);
|
||||
if (data.length === 1) setForm((f) => ({ ...f, zoneId: data[0].id }));
|
||||
if (!res.ok) throw new Error(data.error || "加载 Zone 失败");
|
||||
} catch (e) {
|
||||
setZonesError(e instanceof Error ? e.message : "Failed to load zones");
|
||||
setZonesError(e instanceof Error ? e.message : "加载 Zone 失败");
|
||||
} finally {
|
||||
setZonesLoading(false);
|
||||
}
|
||||
@ -113,7 +111,7 @@ export default function CreatePage() {
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
setResult(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
setError(err instanceof Error ? err.message : "未知错误");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -145,7 +143,7 @@ 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 + 访问应用 + 安装命令
|
||||
一键完成 → 隧道 + DNS + Access 应用 + 安装命令
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -212,14 +210,14 @@ export default function CreatePage() {
|
||||
placeholder="22" className="form-input w-28" />
|
||||
</Field>
|
||||
|
||||
<Field icon={<Mail className="w-4 h-4" />} label="SSO 邮筱"
|
||||
hint="Cloudflare SSO 邮筱,前缀将作为 Linux 用户名">
|
||||
<Field icon={<Mail className="w-4 h-4" />} label="SSO 邮箱"
|
||||
hint="Cloudflare SSO 邮箱,前缀将作为 Linux 用户名">
|
||||
<input type="email" value={form.ssoEmail} onChange={set("ssoEmail")}
|
||||
placeholder="admin@example.com" className="form-input" required />
|
||||
</Field>
|
||||
|
||||
<Field icon={<Mail className="w-4 h-4" />} label="允许访问的邮筱(可选)"
|
||||
hint="多个邮筱用逗号分隔,留空表示所有已认证用户均可访问">
|
||||
<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" />
|
||||
@ -297,7 +295,7 @@ export default function CreatePage() {
|
||||
{result.installCommand}
|
||||
</pre>
|
||||
<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>
|
||||
)}
|
||||
|
||||
@ -12,7 +12,7 @@ export default async function Home() {
|
||||
const allApps = await listAccessApps();
|
||||
apps = filterSshApps(allApps);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Unknown error";
|
||||
error = e instanceof Error ? e.message : "未知错误";
|
||||
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">
|
||||
<AlertTriangle className="w-10 h-10 text-red-400 mx-auto mb-4" />
|
||||
<h2 className="text-lg font-semibold text-red-300 mb-2">
|
||||
Failed to load applications
|
||||
加载应用列表失败
|
||||
</h2>
|
||||
<p className="text-sm text-red-400/80 break-words">{error}</p>
|
||||
</div>
|
||||
|
||||
@ -11,7 +11,7 @@ export default async function TunnelsPage() {
|
||||
try {
|
||||
tunnels = await listTunnels();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Unknown error";
|
||||
error = e instanceof Error ? e.message : "未知错误";
|
||||
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">
|
||||
<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>
|
||||
<p className="text-sm text-red-400/80 break-words">{error}</p>
|
||||
</div>
|
||||
|
||||
@ -31,17 +31,17 @@ export interface SshAppData {
|
||||
}
|
||||
|
||||
function timeAgo(dateStr: string | null): string {
|
||||
if (!dateStr) return "Unknown";
|
||||
if (!dateStr) return "未知";
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return "Just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
if (mins < 1) return "刚刚";
|
||||
if (mins < 60) return `${mins} 分钟前`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
if (hrs < 24) return `${hrs} 小时前`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
if (days < 30) return `${days}d ago`;
|
||||
if (days < 30) return `${days} 天前`;
|
||||
const months = Math.floor(days / 30);
|
||||
return `${months}mo ago`;
|
||||
return `${months} 个月前`;
|
||||
}
|
||||
|
||||
export default function Dashboard({ apps }: { apps: SshAppData[] }) {
|
||||
@ -261,8 +261,7 @@ function HostCard({ app }: { app: SshAppData }) {
|
||||
{/* Extra domains */}
|
||||
{app.domains.length > 1 && (
|
||||
<div className="text-xs text-[var(--text-faint)] mb-3">
|
||||
+{app.domains.length - 1} more domain
|
||||
{app.domains.length - 1 > 1 ? "s" : ""}
|
||||
另有 {app.domains.length - 1} 个域名
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@ -68,7 +68,7 @@ export default function TunnelList({ tunnels }: { tunnels: Tunnel[] }) {
|
||||
<Server className="w-5 h-5 text-emerald-500" />
|
||||
</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)]">
|
||||
共 {tunnels.length} 条 · {activeCount} 条在线
|
||||
</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)]">
|
||||
<Server className="w-12 h-12 opacity-30 mb-4" />
|
||||
<p className="text-lg font-medium text-[var(--text-muted)]">
|
||||
未找到隆道
|
||||
未找到隧道
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@ -169,7 +169,7 @@ export default function TunnelList({ tunnels }: { tunnels: Tunnel[] }) {
|
||||
{/* Footer */}
|
||||
<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" />
|
||||
<span>Cloudflare 隆道 · 每 30 秒自动刷新</span>
|
||||
<span>Cloudflare 隧道 · 每 30 秒自动刷新</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,21 @@
|
||||
import type { NextAuthOptions } from "next-auth";
|
||||
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 ?? "")
|
||||
.split(",")
|
||||
.map((e) => e.trim().toLowerCase())
|
||||
@ -13,7 +28,7 @@ export const authOptions: NextAuthOptions = {
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET ?? "",
|
||||
}),
|
||||
],
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
secret,
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
},
|
||||
|
||||
@ -170,9 +170,27 @@ 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";
|
||||
|
||||
/**
|
||||
* 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[] {
|
||||
return apps
|
||||
.filter((app) => app.type === "self_hosted")
|
||||
.filter((app) => app.type === "self_hosted" && isSshConsoleApp(app))
|
||||
.map((app) => {
|
||||
const primaryDomain =
|
||||
app.domain ||
|
||||
@ -346,7 +364,7 @@ export async function createAccessApp(
|
||||
session_duration: "24h",
|
||||
auto_redirect_to_identity: true,
|
||||
app_launcher_visible: true,
|
||||
tags: [`metrics:https://${metricsHostname}`],
|
||||
tags: [SSH_CONSOLE_TAG, `metrics:https://${metricsHostname}`],
|
||||
policies,
|
||||
});
|
||||
|
||||
|
||||
@ -4,11 +4,12 @@ export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* 保护所有路由,以下除外:
|
||||
* - /login 登录页
|
||||
* - /api/auth/* NextAuth 回调
|
||||
* - /_next/* Next.js 静态资源
|
||||
* - /favicon.ico
|
||||
* - /login 登录页
|
||||
* - /api/auth/* NextAuth 回调
|
||||
* - /_next/* Next.js 静态资源
|
||||
* - /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)$).*)",
|
||||
],
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user