Files

286 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 id="protocol-label">代理协议</span>
<select
name="protocol"
aria-labelledby="protocol-label"
defaultValue="hy2"
>
<option value="hy2">HY2 · UDP</option>
<option value="anytls">AnyTLS · TCP / sing-box</option>
</select>
</label>
<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,以 root 执行。域名可输入 jp1,自动补全
.p.xn--876a.net。 HY2 输入 UDP 和统计 TCP 端口;AnyTLS(x86_64 /
ARM64)只需代理 TCP 端口,管理接口仅监听本机。 支持 NAT
内外端口不同。请先配置 DNS,证书须覆盖 *.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 端口。AnyTLS 参数为
--domain jp1 --port 端口,可加 --listen-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.protocol === "anytls" ? "AnyTLS" : "HY2"} ·{" "}
{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>
);
}