feat: 支持证书托管和节点自动安装
This commit is contained in:
+11
-3
@@ -6,6 +6,7 @@ import {
|
||||
Server,
|
||||
Radio,
|
||||
ScrollText,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
@@ -17,12 +18,19 @@ type NavItem = {
|
||||
|
||||
export function AdminNav({ items }: { items: NavItem[] }) {
|
||||
const pathname = usePathname();
|
||||
const icons = [LayoutDashboard, Users, Server, Radio, ScrollText];
|
||||
const icons: Record<string, typeof Server> = {
|
||||
"/admin/dashboard": LayoutDashboard,
|
||||
"/admin/users": Users,
|
||||
"/admin/nodes": Server,
|
||||
"/admin/certificates": ShieldCheck,
|
||||
"/admin/subscriptions": Radio,
|
||||
"/admin/audits": ScrollText,
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="nav-list">
|
||||
{items.map((item, index) => {
|
||||
const Icon = icons[index];
|
||||
{items.map((item) => {
|
||||
const Icon = icons[item.href] ?? Server;
|
||||
const active =
|
||||
pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireAdminAccess } from "@/lib/session";
|
||||
import {
|
||||
saveCertificate,
|
||||
deleteCertificate,
|
||||
ProvisionError,
|
||||
} from "@/lib/certificates";
|
||||
import {
|
||||
createInstallation,
|
||||
installationSchema,
|
||||
revokeInstallation,
|
||||
} from "@/lib/provisioning";
|
||||
|
||||
async function run(action: () => Promise<unknown>) {
|
||||
await requireAdminAccess();
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
if (error instanceof ProvisionError) return { error: error.message };
|
||||
throw error;
|
||||
}
|
||||
revalidatePath("/admin/certificates");
|
||||
revalidatePath("/admin/nodes");
|
||||
}
|
||||
export async function uploadCertificateAction(data: FormData) {
|
||||
return run(async () => {
|
||||
const crt = data.get("certificate"),
|
||||
key = data.get("private_key");
|
||||
if (
|
||||
!(crt instanceof File) ||
|
||||
!(key instanceof File) ||
|
||||
crt.size > 128 * 1024 ||
|
||||
key.size > 32 * 1024
|
||||
)
|
||||
throw new ProvisionError(
|
||||
"请选择 PEM 格式的 CRT(最多 128 KB)和 KEY(最多 32 KB)文件。",
|
||||
);
|
||||
await saveCertificate(
|
||||
String(data.get("name") ?? ""),
|
||||
await crt.text(),
|
||||
await key.text(),
|
||||
);
|
||||
});
|
||||
}
|
||||
export async function deleteCertificateAction(data: FormData) {
|
||||
return run(() => deleteCertificate(Number(data.get("id"))));
|
||||
}
|
||||
export async function createInstallationAction(data: FormData) {
|
||||
return run(async () => {
|
||||
const input = installationSchema.safeParse({
|
||||
name: data.get("name"),
|
||||
slug: data.get("slug"),
|
||||
certificateId: data.get("certificate_id"),
|
||||
});
|
||||
if (!input.success)
|
||||
throw new ProvisionError(
|
||||
"请检查输入:Slug 使用小写字母、数字或连字符,并选择覆盖 *.p.xn--876a.net 的证书。",
|
||||
);
|
||||
await createInstallation(input.data);
|
||||
});
|
||||
}
|
||||
export async function revokeInstallationAction(data: FormData) {
|
||||
return run(() =>
|
||||
revokeInstallation(String(data.get("id")), data.get("remove") === "yes"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import Link from "next/link";
|
||||
import { ActionForm } from "@/components/action-form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { SubmitButton } from "@/components/submit-button";
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
import { listCertificates } from "@/lib/certificates";
|
||||
import { listInstallations } from "@/lib/provisioning";
|
||||
import { formatLocalDateTime } from "@/lib/datetime";
|
||||
import {
|
||||
uploadCertificateAction,
|
||||
deleteCertificateAction,
|
||||
createInstallationAction,
|
||||
revokeInstallationAction,
|
||||
} from "./actions";
|
||||
|
||||
export default async function CertificatesPage() {
|
||||
const [certificates, installations] = await Promise.all([
|
||||
listCertificates(),
|
||||
listInstallations(),
|
||||
]);
|
||||
const now = new Date().getTime();
|
||||
return (
|
||||
<main className="page-stack management-page">
|
||||
<section className="hero-panel">
|
||||
<div className="hero-copy">
|
||||
<span className="eyebrow">部署</span>
|
||||
<h2>证书与自动安装</h2>
|
||||
<p className="muted">
|
||||
上传证书 → 生成脚本 → 在服务器设置端口,自动安装并注册节点。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<section className="panel-card compact-create-panel">
|
||||
<details open={certificates.length === 0}>
|
||||
<summary className="compact-create-summary">
|
||||
<h3>上传证书</h3>
|
||||
</summary>
|
||||
<ActionForm
|
||||
action={uploadCertificateAction}
|
||||
className="dense-form-grid provision-form"
|
||||
>
|
||||
<label className="field">
|
||||
<span>证书名称</span>
|
||||
<Input
|
||||
name="name"
|
||||
required
|
||||
maxLength={100}
|
||||
placeholder="example.com 通配符证书"
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>CRT / 完整证书链</span>
|
||||
<Input
|
||||
type="file"
|
||||
name="certificate"
|
||||
accept=".crt,.pem,.cer"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>KEY / 私钥</span>
|
||||
<Input
|
||||
type="file"
|
||||
name="private_key"
|
||||
accept=".key,.pem"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<p className="muted full-span">
|
||||
使用 PEM
|
||||
格式、未加密私钥。平台加密存储私钥,页面不回显。上传替换证书需创建新记录;已安装节点不会自动更新或续期。
|
||||
</p>
|
||||
<SubmitButton>上传证书</SubmitButton>
|
||||
</ActionForm>
|
||||
</details>
|
||||
</section>
|
||||
<section className="panel-card dense-panel">
|
||||
<div className="section-title">
|
||||
<h3>托管证书</h3>
|
||||
<span>{certificates.length} 张</span>
|
||||
</div>
|
||||
<div className="provision-list">
|
||||
{certificates.length === 0 ? (
|
||||
<p className="muted">还没有证书,请先上传。</p>
|
||||
) : (
|
||||
certificates.map((cert) => {
|
||||
const expired = Date.parse(cert.expires_at) <= now;
|
||||
const soon = Date.parse(cert.expires_at) < now + 30 * 86400_000;
|
||||
return (
|
||||
<div className="provision-row" key={cert.id}>
|
||||
<div>
|
||||
<strong>{cert.name}</strong>
|
||||
<p className="muted">
|
||||
到期:{formatLocalDateTime(cert.expires_at)}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`status-pill ${soon ? "status-pill--danger" : "status-pill--ok"}`}
|
||||
>
|
||||
{expired ? "已过期" : soon ? "30 天内到期" : "有效"}
|
||||
</span>
|
||||
<ActionForm action={deleteCertificateAction}>
|
||||
<input type="hidden" name="id" value={cert.id} />
|
||||
<SubmitButton
|
||||
variant="destructive"
|
||||
confirm={`删除证书「${cert.name}」?`}
|
||||
>
|
||||
删除
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<section className="panel-card compact-create-panel">
|
||||
<details open>
|
||||
<summary className="compact-create-summary">
|
||||
<h3>生成服务器安装脚本</h3>
|
||||
</summary>
|
||||
<ActionForm
|
||||
action={createInstallationAction}
|
||||
className="dense-form-grid provision-form"
|
||||
>
|
||||
<label className="field">
|
||||
<span>节点名称</span>
|
||||
<Input
|
||||
name="name"
|
||||
required
|
||||
maxLength={100}
|
||||
placeholder="香港 NAT 01"
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>节点 Slug</span>
|
||||
<Input
|
||||
name="slug"
|
||||
required
|
||||
pattern="[a-z0-9][a-z0-9-]{0,62}"
|
||||
placeholder="hk-nat-01"
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span id="certificate-selector-label">托管证书</span>
|
||||
<select
|
||||
name="certificate_id"
|
||||
aria-labelledby="certificate-selector-label"
|
||||
required
|
||||
defaultValue=""
|
||||
>
|
||||
<option value="" disabled>
|
||||
选择证书
|
||||
</option>
|
||||
{certificates
|
||||
.filter((c) => Date.parse(c.expires_at) > now)
|
||||
.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className="muted full-span">
|
||||
脚本适用于使用 systemd 的 Linux(x86_64 / ARM64 / ARMv7),以 root
|
||||
执行。运行时输入 jp1 等域名前缀(自动补全 .p.xn--876a.net)、HY2 UDP 和统计 API TCP
|
||||
端口;支持 NAT 内外端口不同。域名用于节点地址和
|
||||
SNI,请预先解析到服务器公网 IP。证书须覆盖 *.p.xn--876a.net。
|
||||
</p>
|
||||
<SubmitButton disabled={certificates.length === 0}>
|
||||
生成安装脚本
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
</details>
|
||||
</section>
|
||||
<section className="panel-card dense-panel">
|
||||
<div className="section-title">
|
||||
<h3>安装记录</h3>
|
||||
<Link
|
||||
href="/admin/nodes"
|
||||
className={buttonVariants({ variant: "outline", size: "sm" })}
|
||||
>
|
||||
查看节点同步状态
|
||||
</Link>
|
||||
</div>
|
||||
<p className="muted">
|
||||
下载后执行 sudo bash install-节点Slug.sh。脚本含有效期 24
|
||||
小时的安装凭据,请妥善保管;安装成功后自动失效。也支持 --domain
|
||||
jp1.p.xn--876a.net --hy2-port 端口 --stats-port
|
||||
端口。重试需使用相同域名和端口。删除记录不删除已注册节点。
|
||||
</p>
|
||||
<div className="provision-list">
|
||||
{installations.length === 0 ? (
|
||||
<p className="muted">生成的脚本会出现在这里。</p>
|
||||
) : (
|
||||
installations.map((job) => {
|
||||
const expired = Date.parse(job.expires_at) <= now;
|
||||
const available =
|
||||
!job.completed_at && !job.revoked_at && !expired;
|
||||
const status = job.completed_at
|
||||
? "已安装"
|
||||
: job.revoked_at
|
||||
? "已撤销"
|
||||
: expired
|
||||
? "已过期"
|
||||
: job.node_id
|
||||
? "待完成,可重试"
|
||||
: "等待安装";
|
||||
return (
|
||||
<div className="provision-row" key={job.id}>
|
||||
<div>
|
||||
<strong>{job.name}</strong>
|
||||
<p className="muted">
|
||||
{job.slug} · {job.server_host || "域名在脚本中设置"} ·{" "}
|
||||
{job.certificate_name}
|
||||
</p>
|
||||
<small className="muted">
|
||||
凭据到期:{formatLocalDateTime(job.expires_at)}
|
||||
</small>
|
||||
</div>
|
||||
<span
|
||||
className={`status-pill ${job.completed_at ? "status-pill--ok" : "status-pill--neutral"}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
<div className="provision-actions">
|
||||
{available && (
|
||||
<>
|
||||
<a
|
||||
className={buttonVariants({
|
||||
variant: "outline",
|
||||
size: "sm",
|
||||
})}
|
||||
href={`/api/admin/installations/${job.id}`}
|
||||
>
|
||||
下载 SH
|
||||
</a>
|
||||
<CopyButton
|
||||
value={`sudo bash install-${job.slug}.sh`}
|
||||
/>
|
||||
<ActionForm action={revokeInstallationAction}>
|
||||
<input type="hidden" name="id" value={job.id} />
|
||||
<SubmitButton
|
||||
variant="outline"
|
||||
confirm="撤销后,此脚本将无法继续安装。"
|
||||
>
|
||||
撤销
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
</>
|
||||
)}
|
||||
<ActionForm action={revokeInstallationAction}>
|
||||
<input type="hidden" name="id" value={job.id} />
|
||||
<input type="hidden" name="remove" value="yes" />
|
||||
<SubmitButton
|
||||
variant="destructive"
|
||||
confirm="删除安装记录并使脚本失效?已注册节点会保留。"
|
||||
>
|
||||
删除记录
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ const navItems = [
|
||||
{ href: "/admin/dashboard", label: "仪表盘" },
|
||||
{ href: "/admin/users", label: "用户管理" },
|
||||
{ href: "/admin/nodes", label: "节点管理" },
|
||||
{ href: "/admin/certificates", label: "证书与安装" },
|
||||
{ href: "/admin/subscriptions", label: "订阅配置" },
|
||||
{ href: "/admin/audits", label: "审计日志" },
|
||||
];
|
||||
|
||||
@@ -48,6 +48,7 @@ export default async function NodesPage() {
|
||||
<div className="hero-copy">
|
||||
<span className="eyebrow">节点</span>
|
||||
<h2>节点管理</h2>
|
||||
<a href="/admin/certificates" className={buttonVariants({ variant: "outline", size: "sm" })}>证书托管 / 自动安装节点</a>
|
||||
<div className="badge-row">
|
||||
<span className="status-pill status-pill--ok">
|
||||
启用 {nodes.filter((node) => node.enabled).length}
|
||||
|
||||
Reference in New Issue
Block a user