feat: 支持证书托管和节点自动安装

This commit is contained in:
2026-09-29 13:06:56 +08:00
parent ab7d1c937a
commit 586c2399d1
20 changed files with 1600 additions and 20 deletions
+67
View File
@@ -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"),
);
}
+272
View File
@@ -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>
);
}