286 lines
11 KiB
TypeScript
286 lines
11 KiB
TypeScript
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>
|
||
);
|
||
}
|