feat: 支持证书托管和节点自动安装
This commit is contained in:
@@ -177,3 +177,29 @@ npm run test:e2e
|
|||||||
升级时保留原 `.env`、`DATABASE_PATH`、`SESSION_SECRET` 和管理员配置。尤其不能更换 `SESSION_SECRET`,现有 HY2 用户凭据依赖它解密。发布前应停服务并通过 SQLite backup API 备份数据库(包含 WAL 中已提交的数据),不要仅复制正在写入的 `.db` 文件。随后更新代码、安装锁定依赖、构建并启动;无需导出导入业务数据,也无需手工执行 `ALTER TABLE`。
|
升级时保留原 `.env`、`DATABASE_PATH`、`SESSION_SECRET` 和管理员配置。尤其不能更换 `SESSION_SECRET`,现有 HY2 用户凭据依赖它解密。发布前应停服务并通过 SQLite backup API 备份数据库(包含 WAL 中已提交的数据),不要仅复制正在写入的 `.db` 文件。随后更新代码、安装锁定依赖、构建并启动;无需导出导入业务数据,也无需手工执行 `ALTER TABLE`。
|
||||||
|
|
||||||
`tests/fixtures/remote-v4-schema.sql` 是从服务器只读获取的实际表结构,不含生产数据。`npm test` 使用该结构和模拟业务数据验证兼容性、重复迁移、失败回滚、原始 YAML 保留、订阅生成,以及自定义节点不参与鉴权和轮询。`npm run test:e2e` 验证新版 UI 的新增、校验、编辑、分组、禁用和删除流程。
|
`tests/fixtures/remote-v4-schema.sql` 是从服务器只读获取的实际表结构,不含生产数据。`npm test` 使用该结构和模拟业务数据验证兼容性、重复迁移、失败回滚、原始 YAML 保留、订阅生成,以及自定义节点不参与鉴权和轮询。`npm run test:e2e` 验证新版 UI 的新增、校验、编辑、分组、禁用和删除流程。
|
||||||
|
|
||||||
|
## 证书托管与服务器自动安装
|
||||||
|
|
||||||
|
管理员进入「证书与安装」,上传 PEM 完整证书链(CRT)和未加密私钥(KEY)。证书私钥使用 `SESSION_SECRET` 派生密钥加密保存,不在页面回显;请保留原有 `SESSION_SECRET` 并备份数据库。本功能使用覆盖 `*.p.xn--876a.net` 的通配符证书;只管理上传的证书,不申请或自动续期,也不会自动更新已安装服务器的文件。
|
||||||
|
|
||||||
|
选择证书、填写节点名称与 Slug,生成并下载安装脚本。每份脚本限一个节点,有效期 24 小时,可在平台撤销。脚本不内嵌证书私钥,运行时通过 HTTPS 获取配置和证书,安装完成后凭据不能再次获取私钥。
|
||||||
|
|
||||||
|
在使用 systemd 的 Linux 服务器上以 root 运行(支持 x86_64、ARM64、ARMv7):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash install-jp1.sh
|
||||||
|
# 或显式指定参数;jp1 可替换为任意合法的单级子域名前缀:
|
||||||
|
sudo bash install-jp1.sh --domain jp1 --hy2-port 23456 --stats-port 23457
|
||||||
|
# NAT 内外端口不同:
|
||||||
|
sudo bash install-jp1.sh --domain jp1.p.xn--876a.net \
|
||||||
|
--hy2-port 23456 --stats-port 23457 \
|
||||||
|
--hy2-listen-port 443 --stats-listen-port 9999
|
||||||
|
```
|
||||||
|
|
||||||
|
无参数时交互输入域名和两个端口。短域名自动补全 `.p.xn--876a.net`;完整域名用作订阅地址和 SNI。请提前将域名解析至服务器公网 IP,在 NAT / 云防火墙设置 UDP 的 HY2 映射和 TCP 的统计 API 映射。脚本不能代替云厂商配置端口转发或 DNS。统计 API 使用随机密钥,建议将其 TCP 端口限制为仅面板 IP 可访问。
|
||||||
|
|
||||||
|
脚本安装 curl/jq/openssl 等依赖,从 Hysteria 官方下载入口下载服务端,保存 `/etc/hysteria/config.yaml`、`server.crt`、`server.key`,创建 `hy2-panel-node.service` 并设置开机启动。先以禁用状态注册,检查本机统计 API 健康后启用节点。公网 NAT 连通性请通过平台的节点同步状态确认。
|
||||||
|
|
||||||
|
重复执行仅允许同一安装记录、同一域名和端口,不覆盖其他已有 HY2 配置。失败后修复问题,在凭据有效期内重试;已启用的节点不因删除安装记录而删除。凭据过期且已有部分安装时,请先检查并清理对应的禁用节点和服务器配置,再生成新的安装记录。
|
||||||
|
|
||||||
|
平台 `APP_URL` 必须是服务器能访问的公网 HTTPS 地址(本地测试允许 localhost HTTP)。新迁移使用数据库版本 5,只新增 `managed_certificates` / `node_installations` 表,兼容现有 v4 节点、用户和订阅数据。部署前仍应备份数据库;不要用旧版本程序打开已升级的数据库。
|
||||||
|
|||||||
+11
-3
@@ -6,6 +6,7 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
Radio,
|
Radio,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
|
ShieldCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
@@ -17,12 +18,19 @@ type NavItem = {
|
|||||||
|
|
||||||
export function AdminNav({ items }: { items: NavItem[] }) {
|
export function AdminNav({ items }: { items: NavItem[] }) {
|
||||||
const pathname = usePathname();
|
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 (
|
return (
|
||||||
<nav className="nav-list">
|
<nav className="nav-list">
|
||||||
{items.map((item, index) => {
|
{items.map((item) => {
|
||||||
const Icon = icons[index];
|
const Icon = icons[item.href] ?? Server;
|
||||||
const active =
|
const active =
|
||||||
pathname === item.href || pathname.startsWith(`${item.href}/`);
|
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/dashboard", label: "仪表盘" },
|
||||||
{ href: "/admin/users", label: "用户管理" },
|
{ href: "/admin/users", label: "用户管理" },
|
||||||
{ href: "/admin/nodes", label: "节点管理" },
|
{ href: "/admin/nodes", label: "节点管理" },
|
||||||
|
{ href: "/admin/certificates", label: "证书与安装" },
|
||||||
{ href: "/admin/subscriptions", label: "订阅配置" },
|
{ href: "/admin/subscriptions", label: "订阅配置" },
|
||||||
{ href: "/admin/audits", label: "审计日志" },
|
{ href: "/admin/audits", label: "审计日志" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export default async function NodesPage() {
|
|||||||
<div className="hero-copy">
|
<div className="hero-copy">
|
||||||
<span className="eyebrow">节点</span>
|
<span className="eyebrow">节点</span>
|
||||||
<h2>节点管理</h2>
|
<h2>节点管理</h2>
|
||||||
|
<a href="/admin/certificates" className={buttonVariants({ variant: "outline", size: "sm" })}>证书托管 / 自动安装节点</a>
|
||||||
<div className="badge-row">
|
<div className="badge-row">
|
||||||
<span className="status-pill status-pill--ok">
|
<span className="status-pill status-pill--ok">
|
||||||
启用 {nodes.filter((node) => node.enabled).length}
|
启用 {nodes.filter((node) => node.enabled).length}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { requireAdminAccess } from "@/lib/session";
|
||||||
|
import { installationForDownload } from "@/lib/provisioning";
|
||||||
|
import { buildInstallScript } from "@/lib/install-script";
|
||||||
|
import { ProvisionError } from "@/lib/certificates";
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
context: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
await requireAdminAccess();
|
||||||
|
try {
|
||||||
|
const job = await installationForDownload((await context.params).id);
|
||||||
|
return new Response(buildInstallScript(job), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/x-shellscript; charset=utf-8",
|
||||||
|
"Content-Disposition": `attachment; filename="install-${job.slug}.sh"`,
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof ProvisionError)) throw error;
|
||||||
|
return new Response(error.message, {
|
||||||
|
status: 410,
|
||||||
|
headers: { "Cache-Control": "no-store" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { ProvisionError } from "@/lib/certificates";
|
||||||
|
import { provisionNode } from "@/lib/provisioning";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const headers = { "Cache-Control": "no-store" };
|
||||||
|
try {
|
||||||
|
// Bound streamed input, including requests without Content-Length.
|
||||||
|
const reader = request.body?.getReader();
|
||||||
|
if (!reader)
|
||||||
|
return Response.json(
|
||||||
|
{ error: "缺少请求内容。" },
|
||||||
|
{ status: 400, headers },
|
||||||
|
);
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let size = 0;
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
size += value.byteLength;
|
||||||
|
if (size > 4096) {
|
||||||
|
await reader.cancel();
|
||||||
|
return Response.json({ error: "请求过大。" }, { status: 413, headers });
|
||||||
|
}
|
||||||
|
chunks.push(value);
|
||||||
|
}
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "无效 JSON。" }, { status: 400, headers });
|
||||||
|
}
|
||||||
|
if (!payload || !["prepare", "complete"].includes(payload.action))
|
||||||
|
return Response.json({ error: "无效操作。" }, { status: 400, headers });
|
||||||
|
const token =
|
||||||
|
request.headers.get("authorization")?.replace(/^Bearer /, "") ?? "";
|
||||||
|
return Response.json(
|
||||||
|
await provisionNode(token, payload.action, payload.ports),
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
error instanceof ProvisionError
|
||||||
|
? error.message
|
||||||
|
: "安装请求失败,请联系平台管理员。",
|
||||||
|
},
|
||||||
|
{ status: error instanceof ProvisionError ? 400 : 500, headers },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1324,3 +1324,15 @@ details[open] > .group-summary::before {
|
|||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
tab-size: 2;
|
tab-size: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Compact certificate and installation lists. */
|
||||||
|
.provision-row { display:flex; align-items:center; gap:12px; padding:12px 0; border-bottom:1px solid var(--border); flex-wrap:wrap; }
|
||||||
|
.provision-row > div:first-child { flex:1; min-width:220px; overflow-wrap:anywhere; }
|
||||||
|
.provision-row p { margin:4px 0; }
|
||||||
|
.provision-actions { display:flex; align-items:center; flex-wrap:wrap; gap:8px; }
|
||||||
|
.provision-row form { max-width:100%; }
|
||||||
|
.provision-row form p { max-width:360px; }
|
||||||
|
|
||||||
|
.provision-form { grid-template-columns:repeat(3,minmax(0,1fr)); }
|
||||||
|
.provision-form button[type="submit"] { justify-self:start; }
|
||||||
|
@media (max-width:760px) { .provision-form { grid-template-columns:1fr; } }
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { X509Certificate, createPrivateKey } from "node:crypto";
|
||||||
|
import { ensureDb } from "@/lib/db";
|
||||||
|
import { encryptText } from "@/lib/crypto";
|
||||||
|
|
||||||
|
export class ProvisionError extends Error {}
|
||||||
|
export type ManagedCertificate = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
certificate_pem: string;
|
||||||
|
private_key_encrypted: string;
|
||||||
|
expires_at: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function validateCertificate(pem: string, key: string, sni?: string) {
|
||||||
|
if (pem.length > 128 * 1024 || key.length > 32 * 1024)
|
||||||
|
throw new ProvisionError("证书或私钥文件过大。");
|
||||||
|
let certificate: X509Certificate;
|
||||||
|
try {
|
||||||
|
certificate = new X509Certificate(pem);
|
||||||
|
const privateKey = createPrivateKey(key);
|
||||||
|
if (!certificate.checkPrivateKey(privateKey)) throw new Error();
|
||||||
|
const chain = pem.match(
|
||||||
|
/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g,
|
||||||
|
);
|
||||||
|
if (!chain?.length) throw new Error();
|
||||||
|
chain.forEach((part) => new X509Certificate(part));
|
||||||
|
} catch {
|
||||||
|
throw new ProvisionError(
|
||||||
|
"无法解析 PEM 证书/私钥,或私钥与证书不匹配。请上传未加密的私钥和完整证书链。",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
Date.parse(certificate.validFrom) > Date.now() ||
|
||||||
|
Date.parse(certificate.validTo) <= Date.now()
|
||||||
|
)
|
||||||
|
throw new ProvisionError("证书尚未生效或已过期。");
|
||||||
|
if (sni && !certificate.checkHost(sni, { subject: "never" }))
|
||||||
|
throw new ProvisionError("SNI 域名与证书的 SAN 不匹配。");
|
||||||
|
return certificate;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveCertificate(name: string, pem: string, key: string) {
|
||||||
|
if (!name.trim() || name.length > 100)
|
||||||
|
throw new ProvisionError("请填写 1–100 字的证书名称。");
|
||||||
|
const certificate = validateCertificate(pem, key);
|
||||||
|
const db = await ensureDb();
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO managed_certificates
|
||||||
|
(name,certificate_pem,private_key_encrypted,expires_at,created_at) VALUES (?,?,?,?,?)`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
name.trim(),
|
||||||
|
pem,
|
||||||
|
encryptText(key),
|
||||||
|
new Date(certificate.validTo).toISOString(),
|
||||||
|
new Date().toISOString(),
|
||||||
|
).lastInsertRowid;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCertificates() {
|
||||||
|
const db = await ensureDb();
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
"SELECT id,name,expires_at,created_at FROM managed_certificates ORDER BY id DESC",
|
||||||
|
)
|
||||||
|
.all() as Pick<
|
||||||
|
ManagedCertificate,
|
||||||
|
"id" | "name" | "expires_at" | "created_at"
|
||||||
|
>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCertificate(id: number) {
|
||||||
|
const db = await ensureDb();
|
||||||
|
if (
|
||||||
|
db
|
||||||
|
.prepare("SELECT 1 FROM node_installations WHERE certificate_id=?")
|
||||||
|
.get(id)
|
||||||
|
)
|
||||||
|
throw new ProvisionError(
|
||||||
|
"此证书仍关联安装记录,请先删除相关安装记录。已安装服务器的证书文件不会随之删除。",
|
||||||
|
);
|
||||||
|
db.prepare("DELETE FROM managed_certificates WHERE id=?").run(id);
|
||||||
|
}
|
||||||
@@ -172,6 +172,33 @@ const MIGRATIONS: (string | ((db: Database.Database) => void))[] = [
|
|||||||
ALTER TABLE hy2_nodes ADD COLUMN hy2_listen TEXT NOT NULL DEFAULT ':443';
|
ALTER TABLE hy2_nodes ADD COLUMN hy2_listen TEXT NOT NULL DEFAULT ':443';
|
||||||
`,
|
`,
|
||||||
ensureCustomNodeColumns,
|
ensureCustomNodeColumns,
|
||||||
|
`
|
||||||
|
CREATE TABLE managed_certificates (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
certificate_pem TEXT NOT NULL,
|
||||||
|
private_key_encrypted TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE node_installations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
token_encrypted TEXT NOT NULL,
|
||||||
|
certificate_id INTEGER NOT NULL REFERENCES managed_certificates(id) ON DELETE RESTRICT,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
server_host TEXT NOT NULL,
|
||||||
|
sni TEXT NOT NULL,
|
||||||
|
origin TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
revoked_at TEXT,
|
||||||
|
completed_at TEXT,
|
||||||
|
ports_json TEXT,
|
||||||
|
node_id INTEGER REFERENCES hy2_nodes(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`,
|
||||||
];
|
];
|
||||||
|
|
||||||
export function migrateDatabase(db: Database.Database) {
|
export function migrateDatabase(db: Database.Database) {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { decryptText } from "@/lib/crypto";
|
||||||
|
import type { Installation } from "@/lib/provisioning";
|
||||||
|
const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
|
||||||
|
export function buildInstallScript(job: Installation) {
|
||||||
|
const token = decryptText(job.token_encrypted);
|
||||||
|
if (!token) throw new Error("installation_token_unavailable");
|
||||||
|
const values: Record<string, string> = {
|
||||||
|
PANEL: job.origin,
|
||||||
|
TOKEN: token,
|
||||||
|
INSTALL_ID: job.id,
|
||||||
|
SLUG: job.slug,
|
||||||
|
};
|
||||||
|
return readFileSync(
|
||||||
|
path.join(process.cwd(), "lib/templates/install-node.sh"),
|
||||||
|
"utf8",
|
||||||
|
).replace(/__(PANEL|TOKEN|INSTALL_ID|SLUG)__/g, (_, key) =>
|
||||||
|
quote(values[key]),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import { randomBytes, randomUUID, createHash } from "node:crypto";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { stringify } from "yaml";
|
||||||
|
import { ensureDb } from "@/lib/db";
|
||||||
|
import { decryptText, encryptText } from "@/lib/crypto";
|
||||||
|
import { getEnv } from "@/lib/env";
|
||||||
|
import {
|
||||||
|
ProvisionError,
|
||||||
|
validateCertificate,
|
||||||
|
type ManagedCertificate,
|
||||||
|
} from "@/lib/certificates";
|
||||||
|
import type { Hy2NodeRow } from "@/lib/store";
|
||||||
|
|
||||||
|
export const portSchema = z
|
||||||
|
.object({
|
||||||
|
domain: z
|
||||||
|
.string()
|
||||||
|
.regex(/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.p\.xn--876a\.net$/),
|
||||||
|
hy2Port: z.number().int().min(1).max(65535),
|
||||||
|
statsPort: z.number().int().min(1).max(65535),
|
||||||
|
hy2ListenPort: z.number().int().min(1).max(65535),
|
||||||
|
statsListenPort: z.number().int().min(1).max(65535),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
export const installationSchema = z.object({
|
||||||
|
slug: z.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/),
|
||||||
|
name: z.string().trim().min(1).max(100),
|
||||||
|
certificateId: z.coerce.number().int().positive(),
|
||||||
|
});
|
||||||
|
export type Installation = {
|
||||||
|
id: string;
|
||||||
|
token_hash: string;
|
||||||
|
token_encrypted: string;
|
||||||
|
certificate_id: number;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
server_host: string;
|
||||||
|
sni: string;
|
||||||
|
origin: string;
|
||||||
|
expires_at: string;
|
||||||
|
created_at: string;
|
||||||
|
revoked_at: string | null;
|
||||||
|
completed_at: string | null;
|
||||||
|
node_id: number | null;
|
||||||
|
ports_json: string | null;
|
||||||
|
};
|
||||||
|
const hash = (token: string) =>
|
||||||
|
createHash("sha256").update(token).digest("hex");
|
||||||
|
|
||||||
|
export async function createInstallation(
|
||||||
|
input: z.infer<typeof installationSchema>,
|
||||||
|
) {
|
||||||
|
const data = installationSchema.safeParse(input);
|
||||||
|
if (!data.success)
|
||||||
|
throw new ProvisionError("请检查节点名称、Slug 和托管证书。");
|
||||||
|
input = data.data;
|
||||||
|
const origin = new URL(getEnv().APP_URL);
|
||||||
|
if (
|
||||||
|
origin.protocol !== "https:" &&
|
||||||
|
!["localhost", "127.0.0.1", "[::1]"].includes(origin.hostname)
|
||||||
|
)
|
||||||
|
throw new ProvisionError("请将 APP_URL 设置为平台的公网 HTTPS 地址。");
|
||||||
|
const db = await ensureDb();
|
||||||
|
const cert = db
|
||||||
|
.prepare("SELECT * FROM managed_certificates WHERE id=?")
|
||||||
|
.get(input.certificateId) as ManagedCertificate | undefined;
|
||||||
|
if (!cert) throw new ProvisionError("请选择托管证书。");
|
||||||
|
const certificate = validateCertificate(
|
||||||
|
cert.certificate_pem,
|
||||||
|
decryptText(cert.private_key_encrypted) || "",
|
||||||
|
"install-check.p.xn--876a.net",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!/(?:^|, )DNS:\*\.p\.xn--876a\.net(?:,|$)/.test(
|
||||||
|
certificate.subjectAltName || "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new ProvisionError("请选择包含 *.p.xn--876a.net 的通配符证书。");
|
||||||
|
const id = randomUUID(),
|
||||||
|
token = randomBytes(32).toString("hex"),
|
||||||
|
now = new Date().toISOString();
|
||||||
|
db.transaction(() => {
|
||||||
|
if (
|
||||||
|
db.prepare("SELECT 1 FROM hy2_nodes WHERE slug=?").get(input.slug) ||
|
||||||
|
db
|
||||||
|
.prepare("SELECT 1 FROM node_installations WHERE slug=?")
|
||||||
|
.get(input.slug)
|
||||||
|
)
|
||||||
|
throw new ProvisionError(
|
||||||
|
"此 Slug 已存在,请使用新 Slug,或删除旧安装记录。",
|
||||||
|
);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO node_installations
|
||||||
|
(id,token_hash,token_encrypted,certificate_id,slug,name,server_host,sni,origin,expires_at,created_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
|
).run(
|
||||||
|
id,
|
||||||
|
hash(token),
|
||||||
|
encryptText(token),
|
||||||
|
input.certificateId,
|
||||||
|
input.slug,
|
||||||
|
input.name,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
origin.origin,
|
||||||
|
new Date(Date.now() + 24 * 3600_000).toISOString(),
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
}).immediate();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listInstallations() {
|
||||||
|
const db = await ensureDb();
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id,slug,name,server_host,sni,expires_at,created_at,revoked_at,completed_at,node_id,
|
||||||
|
(SELECT name FROM managed_certificates WHERE id=certificate_id) AS certificate_name
|
||||||
|
FROM node_installations ORDER BY created_at DESC`,
|
||||||
|
)
|
||||||
|
.all() as (Omit<
|
||||||
|
Installation,
|
||||||
|
| "token_hash"
|
||||||
|
| "token_encrypted"
|
||||||
|
| "certificate_id"
|
||||||
|
| "origin"
|
||||||
|
| "ports_json"
|
||||||
|
> & { certificate_name: string })[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installationForDownload(id: string) {
|
||||||
|
const db = await ensureDb();
|
||||||
|
const row = db
|
||||||
|
.prepare("SELECT * FROM node_installations WHERE id=?")
|
||||||
|
.get(id) as Installation | undefined;
|
||||||
|
if (
|
||||||
|
!row ||
|
||||||
|
row.revoked_at ||
|
||||||
|
row.completed_at ||
|
||||||
|
row.expires_at <= new Date().toISOString()
|
||||||
|
)
|
||||||
|
throw new ProvisionError(
|
||||||
|
"安装凭据已过期、撤销或使用完成。请创建新安装记录。",
|
||||||
|
);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeInstallation(id: string, remove = false) {
|
||||||
|
const db = await ensureDb();
|
||||||
|
db.prepare(
|
||||||
|
remove
|
||||||
|
? "DELETE FROM node_installations WHERE id=?"
|
||||||
|
: "UPDATE node_installations SET revoked_at=datetime('now') WHERE id=?",
|
||||||
|
).run(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function provisionNode(
|
||||||
|
token: string,
|
||||||
|
action: "prepare" | "complete",
|
||||||
|
rawPorts?: unknown,
|
||||||
|
) {
|
||||||
|
if (!/^[a-f0-9]{64}$/.test(token)) throw new ProvisionError("安装凭据无效。");
|
||||||
|
const db = await ensureDb();
|
||||||
|
return db
|
||||||
|
.transaction(() => {
|
||||||
|
const job = db
|
||||||
|
.prepare("SELECT * FROM node_installations WHERE token_hash=?")
|
||||||
|
.get(hash(token)) as Installation | undefined;
|
||||||
|
if (!job || job.revoked_at || job.expires_at <= new Date().toISOString())
|
||||||
|
throw new ProvisionError("安装凭据无效或已过期。");
|
||||||
|
if (action === "complete") {
|
||||||
|
if (!job.node_id) throw new ProvisionError("请先获取安装配置。");
|
||||||
|
if (!job.completed_at) {
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE hy2_nodes SET enabled=1,updated_at=? WHERE id=?",
|
||||||
|
).run(new Date().toISOString(), job.node_id);
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE node_installations SET completed_at=? WHERE id=?",
|
||||||
|
).run(new Date().toISOString(), job.id);
|
||||||
|
}
|
||||||
|
return { ok: true, slug: job.slug };
|
||||||
|
}
|
||||||
|
if (job.completed_at)
|
||||||
|
throw new ProvisionError("该凭据已完成安装,不能再次获取私钥。");
|
||||||
|
const parsed = portSchema.safeParse(rawPorts);
|
||||||
|
if (!parsed.success)
|
||||||
|
throw new ProvisionError(
|
||||||
|
"域名须为 .p.xn--876a.net 下的单级子域名,端口必须为 1–65535 的整数。",
|
||||||
|
);
|
||||||
|
const ports = parsed.data,
|
||||||
|
portsJson = JSON.stringify(ports);
|
||||||
|
if (job.ports_json && job.ports_json !== portsJson)
|
||||||
|
throw new ProvisionError(
|
||||||
|
"该安装记录已绑定域名和端口;重试请使用原配置,或创建新安装记录。",
|
||||||
|
);
|
||||||
|
const cert = db
|
||||||
|
.prepare("SELECT * FROM managed_certificates WHERE id=?")
|
||||||
|
.get(job.certificate_id) as ManagedCertificate;
|
||||||
|
const key = decryptText(cert.private_key_encrypted) || "";
|
||||||
|
validateCertificate(cert.certificate_pem, key, ports.domain);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
let nodeId = job.node_id;
|
||||||
|
if (!nodeId) {
|
||||||
|
if (db.prepare("SELECT 1 FROM hy2_nodes WHERE slug=?").get(job.slug))
|
||||||
|
throw new ProvisionError("节点 Slug 已被占用。");
|
||||||
|
const host = ports.domain;
|
||||||
|
nodeId = Number(
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO hy2_nodes
|
||||||
|
(slug,name,auth_token,traffic_stats_url,traffic_stats_secret,enabled,created_at,updated_at,
|
||||||
|
server_host,server_port,client_name,sni,hy2_listen,traffic_stats_listen)
|
||||||
|
VALUES (?,?,?,?,?,0,?,?,?,?,?,?,?,?)`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
job.slug,
|
||||||
|
job.name,
|
||||||
|
randomBytes(32).toString("hex"),
|
||||||
|
`http://${host}:${ports.statsPort}`,
|
||||||
|
randomBytes(32).toString("hex"),
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
host,
|
||||||
|
ports.hy2Port,
|
||||||
|
job.name,
|
||||||
|
host,
|
||||||
|
`:${ports.hy2ListenPort}`,
|
||||||
|
`:${ports.statsListenPort}`,
|
||||||
|
).lastInsertRowid,
|
||||||
|
);
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE node_installations SET node_id=?,ports_json=?,server_host=?,sni=? WHERE id=?",
|
||||||
|
).run(nodeId, portsJson, host, host, job.id);
|
||||||
|
}
|
||||||
|
const node = db
|
||||||
|
.prepare("SELECT * FROM hy2_nodes WHERE id=?")
|
||||||
|
.get(nodeId) as Hy2NodeRow;
|
||||||
|
const config = stringify({
|
||||||
|
listen: node.hy2_listen,
|
||||||
|
tls: {
|
||||||
|
cert: "/etc/hysteria/server.crt",
|
||||||
|
key: "/etc/hysteria/server.key",
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
type: "http",
|
||||||
|
http: {
|
||||||
|
url: `${job.origin}/api/hy2/nodes/${job.slug}/auth?token=${node.auth_token}`,
|
||||||
|
insecure: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
trafficStats: {
|
||||||
|
listen: node.traffic_stats_listen,
|
||||||
|
secret: node.traffic_stats_secret,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
certificate: cert.certificate_pem,
|
||||||
|
privateKey: key,
|
||||||
|
statsSecret: node.traffic_stats_secret,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.immediate();
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
[ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@"
|
||||||
|
set -Eeuo pipefail
|
||||||
|
umask 077
|
||||||
|
PANEL=__PANEL__
|
||||||
|
TOKEN=__TOKEN__
|
||||||
|
INSTALL_ID=__INSTALL_ID__
|
||||||
|
SLUG=__SLUG__
|
||||||
|
DOMAIN=""
|
||||||
|
HY2_PORT=""
|
||||||
|
STATS_PORT=""
|
||||||
|
HY2_LISTEN=""
|
||||||
|
STATS_LISTEN=""
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
[ "$#" -ge 2 ] || { echo "参数缺少值:$1"; exit 1; }
|
||||||
|
case "$1" in
|
||||||
|
--domain) DOMAIN=$2;;
|
||||||
|
--hy2-port) HY2_PORT=$2;;
|
||||||
|
--stats-port) STATS_PORT=$2;;
|
||||||
|
--hy2-listen-port) HY2_LISTEN=$2;;
|
||||||
|
--stats-listen-port) STATS_LISTEN=$2;;
|
||||||
|
*) echo "未知参数:$1"; exit 1;;
|
||||||
|
esac
|
||||||
|
shift 2
|
||||||
|
done
|
||||||
|
[ "$(id -u)" -eq 0 ] || { echo '请使用 root 执行。'; exit 1; }
|
||||||
|
command -v systemctl >/dev/null || { echo '需要使用 systemd 的 Linux 服务器。'; exit 1; }
|
||||||
|
[ -d /run/systemd/system ] || { echo 'systemd 未运行。'; exit 1; }
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64) ARCH=amd64;; aarch64|arm64) ARCH=arm64;;
|
||||||
|
armv7l) ARCH=arm;; *) echo '暂不支持此 CPU 架构。'; exit 1;;
|
||||||
|
esac
|
||||||
|
if [ -e /etc/hysteria/config.yaml ] || [ -e /etc/systemd/system/hy2-panel-node.service ]; then
|
||||||
|
[ "$(cat /etc/hysteria/.panel-install-id 2>/dev/null || true)" = "$INSTALL_ID" ] || {
|
||||||
|
echo '检测到已有 HY2 配置;为避免覆盖,安装已停止。请在新服务器运行。'; exit 1;
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
for unit in hysteria-server.service hysteria-server@config.service; do
|
||||||
|
if systemctl is-active --quiet "$unit"; then echo '已有 Hysteria 服务正在运行,安装已停止。'; exit 1; fi
|
||||||
|
done
|
||||||
|
read_port() {
|
||||||
|
local value
|
||||||
|
while true; do
|
||||||
|
read -r -p "$1" value </dev/tty
|
||||||
|
if [[ "$value" =~ ^[0-9]{1,5}$ ]] && (( 10#$value >= 1 && 10#$value <= 65535 )); then
|
||||||
|
printf '%s' "$((10#$value))"; return
|
||||||
|
fi
|
||||||
|
echo '端口必须为 1–65535。' >&2
|
||||||
|
done
|
||||||
|
}
|
||||||
|
echo "节点:$SLUG"
|
||||||
|
if [ -z "$DOMAIN" ]; then read -r -p '节点域名(输入 jp1 或 jp1.p.xn--876a.net):' DOMAIN </dev/tty; fi
|
||||||
|
DOMAIN=$(printf '%s' "$DOMAIN" | tr '[:upper:]' '[:lower:]')
|
||||||
|
if [[ "$DOMAIN" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]]; then DOMAIN="$DOMAIN.p.xn--876a.net"; fi
|
||||||
|
[[ "$DOMAIN" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.p\.xn--876a\.net$ ]] || { echo '请填写 .p.xn--876a.net 下的单级子域名,例如 jp1.p.xn--876a.net。'; exit 1; }
|
||||||
|
[ -n "$HY2_PORT" ] || HY2_PORT=$(read_port 'HY2 公网 UDP 端口:')
|
||||||
|
[ -n "$STATS_PORT" ] || STATS_PORT=$(read_port '流量统计公网 TCP 端口:')
|
||||||
|
if [ -z "$HY2_LISTEN" ] && [ -z "$STATS_LISTEN" ] && [ -t 0 ]; then
|
||||||
|
read -r -p 'NAT 内外端口是否不同?[y/N] ' MAPPING </dev/tty
|
||||||
|
if [[ "$MAPPING" =~ ^[yY]$ ]]; then
|
||||||
|
HY2_LISTEN=$(read_port 'HY2 本机 UDP 监听端口:')
|
||||||
|
STATS_LISTEN=$(read_port '流量统计本机 TCP 监听端口:')
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
HY2_LISTEN=${HY2_LISTEN:-$HY2_PORT}
|
||||||
|
STATS_LISTEN=${STATS_LISTEN:-$STATS_PORT}
|
||||||
|
for port in "$HY2_PORT" "$STATS_PORT" "$HY2_LISTEN" "$STATS_LISTEN"; do
|
||||||
|
if ! [[ "$port" =~ ^[1-9][0-9]{0,4}$ ]] || (( port > 65535 )); then echo '端口必须为 1–65535(无前导零)。'; exit 1; fi
|
||||||
|
done
|
||||||
|
if ! command -v curl >/dev/null || ! command -v jq >/dev/null || ! command -v openssl >/dev/null; then
|
||||||
|
if command -v apt-get >/dev/null; then
|
||||||
|
apt-get update
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y curl jq openssl ca-certificates
|
||||||
|
elif command -v dnf >/dev/null; then dnf install -y curl jq openssl ca-certificates
|
||||||
|
elif command -v yum >/dev/null; then yum install -y curl jq openssl ca-certificates
|
||||||
|
else echo '请先安装 curl、jq、openssl 和 ca-certificates。'; exit 1; fi
|
||||||
|
fi
|
||||||
|
WORK=$(mktemp -d)
|
||||||
|
STARTED=0
|
||||||
|
cleanup() {
|
||||||
|
local status=$?
|
||||||
|
rm -rf "$WORK"
|
||||||
|
if [ "$status" -ne 0 ]; then
|
||||||
|
if [ "$STARTED" -eq 1 ]; then systemctl stop hy2-panel-node.service || true; fi
|
||||||
|
echo '安装未完成。修复网络/端口问题后,可在凭据有效期内使用相同脚本和端口重试。' >&2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
printf 'Authorization: Bearer %s\n' "$TOKEN" > "$WORK/headers"
|
||||||
|
# 使用 Hysteria 官方自动化下载入口,仅允许 HTTPS。
|
||||||
|
curl --proto '=https' --proto-redir '=https' -fSL --retry 3 --connect-timeout 20 --max-time 300 \
|
||||||
|
"https://download.hysteria.network/app/latest/hysteria-linux-$ARCH" -o "$WORK/hysteria"
|
||||||
|
chmod 700 "$WORK/hysteria"
|
||||||
|
"$WORK/hysteria" version
|
||||||
|
jq -n --arg domain "$DOMAIN" --argjson h "$HY2_PORT" --argjson s "$STATS_PORT" --argjson hl "$HY2_LISTEN" --argjson sl "$STATS_LISTEN" \
|
||||||
|
'{action:"prepare",ports:{domain:$domain,hy2Port:$h,statsPort:$s,hy2ListenPort:$hl,statsListenPort:$sl}}' > "$WORK/request"
|
||||||
|
api() {
|
||||||
|
local status
|
||||||
|
status=$(curl -sS --connect-timeout 20 --max-time 60 -o "$WORK/response" -w '%{http_code}' \
|
||||||
|
-H @"$WORK/headers" -H 'Content-Type: application/json' --data-binary @"$WORK/request" "$PANEL/api/install")
|
||||||
|
if [ "$status" != 200 ]; then
|
||||||
|
jq -r '.error // "平台请求失败"' "$WORK/response" >&2 || true
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
api
|
||||||
|
jq -er '.config' "$WORK/response" > "$WORK/config.yaml"
|
||||||
|
jq -er '.certificate' "$WORK/response" > "$WORK/server.crt"
|
||||||
|
jq -er '.privateKey' "$WORK/response" > "$WORK/server.key"
|
||||||
|
jq -er '"Authorization: " + .statsSecret' "$WORK/response" > "$WORK/stats-header"
|
||||||
|
openssl x509 -in "$WORK/server.crt" -noout -checkend 0 >/dev/null
|
||||||
|
openssl x509 -in "$WORK/server.crt" -pubkey -noout > "$WORK/cert.pub"
|
||||||
|
openssl pkey -in "$WORK/server.key" -pubout > "$WORK/key.pub"
|
||||||
|
cmp -s "$WORK/cert.pub" "$WORK/key.pub" || { echo '证书与私钥不匹配。'; exit 1; }
|
||||||
|
install -d -m 700 /etc/hysteria /usr/local/lib/hy2-panel
|
||||||
|
# 仅允许重试本安装记录生成的配置。
|
||||||
|
printf '%s' "$INSTALL_ID" > /etc/hysteria/.panel-install-id
|
||||||
|
systemctl stop hy2-panel-node.service 2>/dev/null || true
|
||||||
|
install -m 755 "$WORK/hysteria" /usr/local/lib/hy2-panel/hysteria
|
||||||
|
install -m 600 "$WORK/config.yaml" /etc/hysteria/config.yaml
|
||||||
|
install -m 600 "$WORK/server.crt" /etc/hysteria/server.crt
|
||||||
|
install -m 600 "$WORK/server.key" /etc/hysteria/server.key
|
||||||
|
cat > /etc/systemd/system/hy2-panel-node.service <<'UNIT'
|
||||||
|
[Unit]
|
||||||
|
Description=Hysteria 2 (HY2 Panel)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/local/lib/hy2-panel/hysteria server --config /etc/hysteria/config.yaml
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
LimitNOFILE=1048576
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
PrivateTmp=true
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
UNIT
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl start hy2-panel-node.service
|
||||||
|
STARTED=1
|
||||||
|
READY=0
|
||||||
|
for attempt in {1..15}; do
|
||||||
|
if systemctl is-active --quiet hy2-panel-node.service && \
|
||||||
|
curl -fsS --max-time 2 -H @"$WORK/stats-header" "http://127.0.0.1:$STATS_LISTEN/traffic" -o /dev/null; then
|
||||||
|
READY=1; break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
[ "$READY" -eq 1 ] || { echo '服务健康检查失败,请检查端口占用和 systemctl status hy2-panel-node。'; exit 1; }
|
||||||
|
# 完成通知可能已被平台处理但响应丢失;保留已健康启动的服务,避免误停。
|
||||||
|
systemctl enable hy2-panel-node.service
|
||||||
|
STARTED=0
|
||||||
|
printf '{"action":"complete"}' > "$WORK/request"
|
||||||
|
# 重试完成通知,平台支持幂等确认。
|
||||||
|
for attempt in {1..3}; do
|
||||||
|
if api; then break; fi
|
||||||
|
[ "$attempt" -lt 3 ] || exit 1
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
STARTED=0
|
||||||
|
echo "安装完成,节点 $SLUG 已注册并启用。"
|
||||||
|
echo "请在 NAT / 云防火墙中映射 UDP $HY2_PORT → $HY2_LISTEN、TCP $STATS_PORT → $STATS_LISTEN。"
|
||||||
|
echo '流量统计 TCP 端口建议仅允许面板服务器 IP 访问。公网连通情况请在平台查看同步状态。'
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
export function certificateFixture(domain = "*.p.xn--876a.net") {
|
||||||
|
const dir = mkdtempSync(path.join(tmpdir(), "hy2-cert-fixture-"));
|
||||||
|
try {
|
||||||
|
writeFileSync(
|
||||||
|
path.join(dir, "openssl.cnf"),
|
||||||
|
`[req]\nprompt=no\ndistinguished_name=dn\nx509_extensions=ext\n[dn]\nCN=${domain}\n[ext]\nsubjectAltName=DNS:${domain}\n`,
|
||||||
|
);
|
||||||
|
execFileSync(
|
||||||
|
"openssl",
|
||||||
|
[
|
||||||
|
"req",
|
||||||
|
"-x509",
|
||||||
|
"-newkey",
|
||||||
|
"rsa:2048",
|
||||||
|
"-nodes",
|
||||||
|
"-keyout",
|
||||||
|
path.join(dir, "key"),
|
||||||
|
"-out",
|
||||||
|
path.join(dir, "crt"),
|
||||||
|
"-days",
|
||||||
|
"60",
|
||||||
|
"-config",
|
||||||
|
path.join(dir, "openssl.cnf"),
|
||||||
|
],
|
||||||
|
{ stdio: "ignore" },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
certificate: readFileSync(path.join(dir, "crt"), "utf8"),
|
||||||
|
privateKey: readFileSync(path.join(dir, "key"), "utf8"),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-5
@@ -2,17 +2,25 @@ import { test as base, expect } from "@playwright/test";
|
|||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
|
|
||||||
export const test = base.extend<{ isolatedData: void }>({
|
export const test = base.extend<{ isolatedData: void }>({
|
||||||
isolatedData: [async ({}, use) => {
|
isolatedData: [
|
||||||
|
async ({}, use) => {
|
||||||
const file = process.env.HY2_E2E_DATABASE;
|
const file = process.env.HY2_E2E_DATABASE;
|
||||||
if (!file || !/^\/tmp\/hy2-panel-ui-test-\d+\.db$/.test(file)) throw new Error("Refusing to reset a non-test database");
|
if (!file || !/^\/tmp\/hy2-panel-ui-test-\d+\.db$/.test(file))
|
||||||
|
throw new Error("Refusing to reset a non-test database");
|
||||||
const db = new Database(file, { fileMustExist: true });
|
const db = new Database(file, { fileMustExist: true });
|
||||||
db.pragma("foreign_keys = ON");
|
db.pragma("foreign_keys = ON");
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
db.exec("DELETE FROM hy2_nodes; DELETE FROM hy2_users WHERE is_admin = 0;");
|
db.exec(
|
||||||
db.prepare("UPDATE app_settings SET value = '[]' WHERE key IN ('main_proxy_group_member_slugs_json','proxy_groups_json')").run();
|
"DELETE FROM node_installations; DELETE FROM managed_certificates; DELETE FROM hy2_nodes; DELETE FROM hy2_users WHERE is_admin = 0;",
|
||||||
|
);
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE app_settings SET value = '[]' WHERE key IN ('main_proxy_group_member_slugs_json','proxy_groups_json')",
|
||||||
|
).run();
|
||||||
})();
|
})();
|
||||||
db.close();
|
db.close();
|
||||||
await use();
|
await use();
|
||||||
}, { auto: true }],
|
},
|
||||||
|
{ auto: true },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
export { expect };
|
export { expect };
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { test, expect } from "./e2e-fixtures";
|
||||||
|
import { certificateFixture } from "./certificate-fixture";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { parse } from "yaml";
|
||||||
|
|
||||||
|
test("upload wildcard certificate, download script and complete NAT node registration", async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
page.on("pageerror", (e) => errors.push(e.message));
|
||||||
|
const fixture = certificateFixture();
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByLabel("用户名", { exact: true }).fill("ui-review");
|
||||||
|
await page.getByLabel("密码", { exact: true }).fill("ui-review-local-2026");
|
||||||
|
await page.getByRole("button", { name: "登录", exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(/dashboard/);
|
||||||
|
await page.getByRole("link", { name: "证书与安装", exact: true }).click();
|
||||||
|
const upload = page.locator("details").filter({
|
||||||
|
has: page.getByRole("heading", { name: "上传证书", exact: true }),
|
||||||
|
});
|
||||||
|
await upload.getByLabel("证书名称").fill("测试通配符证书");
|
||||||
|
await upload.getByLabel("CRT / 完整证书链").setInputFiles({
|
||||||
|
name: "test.crt",
|
||||||
|
mimeType: "text/plain",
|
||||||
|
buffer: Buffer.from(fixture.certificate),
|
||||||
|
});
|
||||||
|
await upload.getByLabel("KEY / 私钥").setInputFiles({
|
||||||
|
name: "test.key",
|
||||||
|
mimeType: "text/plain",
|
||||||
|
buffer: Buffer.from("invalid"),
|
||||||
|
});
|
||||||
|
await upload.getByRole("button", { name: "上传证书", exact: true }).click();
|
||||||
|
await expect(upload.getByRole("alert")).toContainText("无法解析");
|
||||||
|
await upload.getByLabel("KEY / 私钥").setInputFiles({
|
||||||
|
name: "test.key",
|
||||||
|
mimeType: "text/plain",
|
||||||
|
buffer: Buffer.from(fixture.privateKey),
|
||||||
|
});
|
||||||
|
await upload.getByRole("button", { name: "上传证书", exact: true }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByText("测试通配符证书", { exact: true }).first(),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.reload();
|
||||||
|
const create = page.locator("details").filter({
|
||||||
|
has: page.getByRole("heading", {
|
||||||
|
name: "生成服务器安装脚本",
|
||||||
|
exact: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if ((await create.getAttribute("open")) === null)
|
||||||
|
await create.locator("summary").click();
|
||||||
|
await create.getByLabel("节点名称", { exact: true }).fill("NAT 浏览器测试");
|
||||||
|
await create.getByLabel("节点 Slug", { exact: true }).fill("nat-browser");
|
||||||
|
await create
|
||||||
|
.getByLabel("托管证书", { exact: true })
|
||||||
|
.selectOption({ label: "测试通配符证书" });
|
||||||
|
await create
|
||||||
|
.getByRole("button", { name: "生成安装脚本", exact: true })
|
||||||
|
.click();
|
||||||
|
const row = page.locator(".provision-row").filter({ hasText: "nat-browser" });
|
||||||
|
await expect(row).toContainText("等待安装");
|
||||||
|
const downloadPath = await row
|
||||||
|
.getByRole("link", { name: "下载 SH" })
|
||||||
|
.getAttribute("href");
|
||||||
|
expect((await request.get(downloadPath!, { maxRedirects: 0 })).status()).toBe(
|
||||||
|
307,
|
||||||
|
);
|
||||||
|
const [download] = await Promise.all([
|
||||||
|
page.waitForEvent("download"),
|
||||||
|
row.getByRole("link", { name: "下载 SH" }).click(),
|
||||||
|
]);
|
||||||
|
const script = readFileSync((await download.path())!, "utf8");
|
||||||
|
expect(script).not.toContain("PRIVATE KEY");
|
||||||
|
expect(script).toContain("--domain");
|
||||||
|
const token = script.match(/^TOKEN='([a-f0-9]+)'$/m)![1];
|
||||||
|
const ports = {
|
||||||
|
domain: "nat.p.xn--876a.net",
|
||||||
|
hy2Port: 23456,
|
||||||
|
statsPort: 23457,
|
||||||
|
hy2ListenPort: 443,
|
||||||
|
statsListenPort: 9999,
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await request.post("/api/install", { data: { action: "prepare", ports } })
|
||||||
|
).status(),
|
||||||
|
).toBe(400);
|
||||||
|
expect(
|
||||||
|
(await request.post("/api/install", { data: "x".repeat(5000) })).status(),
|
||||||
|
).toBe(413);
|
||||||
|
const prepare = await request.post("/api/install", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
data: { action: "prepare", ports },
|
||||||
|
});
|
||||||
|
expect(prepare.status()).toBe(200);
|
||||||
|
expect(prepare.headers()["cache-control"]).toBe("no-store");
|
||||||
|
const bundle = await prepare.json();
|
||||||
|
expect(bundle.privateKey).toBe(fixture.privateKey);
|
||||||
|
const config = parse(bundle.config);
|
||||||
|
expect(config.listen).toBe(":443");
|
||||||
|
expect(config.trafficStats.listen).toBe(":9999");
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await request.post("/api/install", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
data: { action: "complete" },
|
||||||
|
})
|
||||||
|
).status(),
|
||||||
|
).toBe(200);
|
||||||
|
await page.reload();
|
||||||
|
await expect(row).toContainText("已安装");
|
||||||
|
await expect(row.getByRole("link", { name: "下载 SH" })).toHaveCount(0);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await request.post("/api/install", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
data: { action: "prepare", ports },
|
||||||
|
})
|
||||||
|
).status(),
|
||||||
|
).toBe(400);
|
||||||
|
await page.screenshot({
|
||||||
|
path: "test-results/certificates-desktop.png",
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.screenshot({
|
||||||
|
path: "test-results/certificates-mobile.png",
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth <= innerWidth + 1,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
await page.getByRole("link", { name: "节点管理", exact: true }).click();
|
||||||
|
const node = page
|
||||||
|
.locator(".entity-table__item")
|
||||||
|
.filter({ hasText: "nat-browser" });
|
||||||
|
await expect(node).toContainText("nat.p.xn--876a.net");
|
||||||
|
await node.locator(":scope > summary").click();
|
||||||
|
await expect(node.getByLabel("对外端口", { exact: true })).toHaveValue(
|
||||||
|
"23456",
|
||||||
|
);
|
||||||
|
await expect(node.getByLabel("SNI", { exact: true })).toHaveValue(
|
||||||
|
"nat.p.xn--876a.net",
|
||||||
|
);
|
||||||
|
await expect(node.getByLabel("启用节点", { exact: true })).toBeChecked();
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import {
|
||||||
|
mkdtempSync,
|
||||||
|
mkdirSync,
|
||||||
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
existsSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { certificateFixture } from "../certificate-fixture";
|
||||||
|
|
||||||
|
// Execute the generated shell in a disposable filesystem. Only systemctl and
|
||||||
|
// network effects are mocked; bash, jq, openssl, permissions and file writes run.
|
||||||
|
for (const scenario of [
|
||||||
|
"success",
|
||||||
|
"health-failure",
|
||||||
|
"occupied",
|
||||||
|
"bad-domain",
|
||||||
|
]) {
|
||||||
|
test(`installer shell: ${scenario}`, () => {
|
||||||
|
const dir = mkdtempSync(path.join(tmpdir(), "hy2-shell-test-"));
|
||||||
|
const fake = path.join(dir, "bin");
|
||||||
|
mkdirSync(fake);
|
||||||
|
mkdirSync(path.join(dir, "systemd"));
|
||||||
|
const executable = (name: string, body: string) =>
|
||||||
|
writeFileSync(path.join(fake, name), `#!/usr/bin/env bash\n${body}\n`, {
|
||||||
|
mode: 0o700,
|
||||||
|
});
|
||||||
|
const fixture = certificateFixture();
|
||||||
|
writeFileSync(
|
||||||
|
path.join(dir, "bundle.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
...fixture,
|
||||||
|
config: "listen: :443\n",
|
||||||
|
statsSecret: "test-stats",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
executable("id", "echo 0");
|
||||||
|
executable("uname", "echo x86_64");
|
||||||
|
executable("sleep", "exit 0");
|
||||||
|
executable(
|
||||||
|
"systemctl",
|
||||||
|
`echo "$*" >> "$SANDBOX/systemctl.log"
|
||||||
|
if [ "$1" = is-active ]; then
|
||||||
|
[ "$3" = hy2-panel-node.service ] && [ "$SCENARIO" != health-failure ]; exit $?
|
||||||
|
fi
|
||||||
|
exit 0`,
|
||||||
|
);
|
||||||
|
executable(
|
||||||
|
"curl",
|
||||||
|
`url=""; output=""; request=""
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-o) output=$2; shift 2;;
|
||||||
|
--data-binary) request=$2; shift 2;;
|
||||||
|
http*) url=$1; shift;;
|
||||||
|
*) shift;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
case "$url" in
|
||||||
|
https://download.hysteria.network/*) printf '#!/usr/bin/env bash\\nexit 0\\n' > "$output";;
|
||||||
|
*/api/install)
|
||||||
|
cp "\${request#@}" "$SANDBOX/last-request.json"
|
||||||
|
if jq -e '.action == "prepare"' "\${request#@}" >/dev/null; then
|
||||||
|
cp "\${request#@}" "$SANDBOX/prepare.json"
|
||||||
|
cp "$SANDBOX/bundle.json" "$output"
|
||||||
|
else echo '{"ok":true}' > "$output"; touch "$SANDBOX/completed"; fi
|
||||||
|
printf 200;;
|
||||||
|
*/traffic) [ "$SCENARIO" != health-failure ];;
|
||||||
|
*) exit 1;;
|
||||||
|
esac`,
|
||||||
|
);
|
||||||
|
const template = readFileSync("lib/templates/install-node.sh", "utf8")
|
||||||
|
.replaceAll("__PANEL__", "'https://panel.example'")
|
||||||
|
.replaceAll("__TOKEN__", "'test-token'")
|
||||||
|
.replaceAll("__INSTALL_ID__", "'test-job'")
|
||||||
|
.replaceAll("__SLUG__", "'test-node'")
|
||||||
|
.replaceAll("/run/systemd/system", path.join(dir, "systemd"))
|
||||||
|
.replaceAll("/etc/hysteria", path.join(dir, "hysteria"))
|
||||||
|
.replaceAll("/etc/systemd/system", path.join(dir, "systemd"))
|
||||||
|
.replaceAll("/usr/local/lib/hy2-panel", path.join(dir, "lib"));
|
||||||
|
const script = path.join(dir, "install.sh");
|
||||||
|
writeFileSync(script, template);
|
||||||
|
if (scenario === "occupied") {
|
||||||
|
mkdirSync(path.join(dir, "hysteria"));
|
||||||
|
writeFileSync(path.join(dir, "hysteria/config.yaml"), "original-config");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = spawnSync(
|
||||||
|
"bash",
|
||||||
|
[
|
||||||
|
script,
|
||||||
|
"--domain",
|
||||||
|
scenario === "bad-domain" ? "wrong.example" : "test",
|
||||||
|
"--hy2-port",
|
||||||
|
"23456",
|
||||||
|
"--stats-port",
|
||||||
|
"23457",
|
||||||
|
"--hy2-listen-port",
|
||||||
|
"443",
|
||||||
|
"--stats-listen-port",
|
||||||
|
"9999",
|
||||||
|
],
|
||||||
|
{
|
||||||
|
encoding: "utf8",
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PATH: `${fake}:${process.env.PATH}`,
|
||||||
|
SANDBOX: dir,
|
||||||
|
SCENARIO: scenario,
|
||||||
|
},
|
||||||
|
timeout: 20000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
result.status,
|
||||||
|
scenario === "success" ? 0 : 1,
|
||||||
|
result.stderr + result.stdout,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
existsSync(path.join(dir, "completed")),
|
||||||
|
scenario === "success",
|
||||||
|
);
|
||||||
|
if (scenario === "success") {
|
||||||
|
assert.equal(
|
||||||
|
readFileSync(path.join(dir, "hysteria/server.key"), "utf8").trim(),
|
||||||
|
fixture.privateKey.trim(),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
JSON.parse(readFileSync(path.join(dir, "prepare.json"), "utf8")).ports
|
||||||
|
.domain,
|
||||||
|
"test.p.xn--876a.net",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
readFileSync(path.join(dir, "systemctl.log"), "utf8"),
|
||||||
|
/enable hy2-panel-node/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (scenario === "health-failure")
|
||||||
|
assert.match(
|
||||||
|
readFileSync(path.join(dir, "systemctl.log"), "utf8"),
|
||||||
|
/stop hy2-panel-node.service/,
|
||||||
|
);
|
||||||
|
if (scenario === "occupied")
|
||||||
|
assert.equal(
|
||||||
|
readFileSync(path.join(dir, "hysteria/config.yaml"), "utf8"),
|
||||||
|
"original-config",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -22,7 +22,12 @@ function snapshot(db: Database.Database) {
|
|||||||
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name",
|
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name",
|
||||||
)
|
)
|
||||||
.all() as { name: string }[]
|
.all() as { name: string }[]
|
||||||
).map(({ name }) => [name, db.prepare(`SELECT * FROM "${name}"`).all()]),
|
)
|
||||||
|
.filter(
|
||||||
|
({ name }) =>
|
||||||
|
!["managed_certificates", "node_installations"].includes(name),
|
||||||
|
)
|
||||||
|
.map(({ name }) => [name, db.prepare(`SELECT * FROM "${name}"`).all()]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function seed(db: Database.Database) {
|
function seed(db: Database.Database) {
|
||||||
@@ -40,7 +45,7 @@ test("fresh database exactly matches the inspected remote v4 column definitions"
|
|||||||
const fresh = new Database(":memory:");
|
const fresh = new Database(":memory:");
|
||||||
const remote = remoteDb();
|
const remote = remoteDb();
|
||||||
migrateDatabase(fresh);
|
migrateDatabase(fresh);
|
||||||
assert.equal(fresh.pragma("user_version", { simple: true }), 4);
|
assert.equal(fresh.pragma("user_version", { simple: true }), 5);
|
||||||
for (const { name } of remote
|
for (const { name } of remote
|
||||||
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
|
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
.all() as { name: string }[]) {
|
.all() as { name: string }[]) {
|
||||||
@@ -93,7 +98,7 @@ for (const existing of [
|
|||||||
if (!existing.includes(field)) delete row[field];
|
if (!existing.includes(field)) delete row[field];
|
||||||
}
|
}
|
||||||
assert.deepEqual(after, before);
|
assert.deepEqual(after, before);
|
||||||
assert.equal(db.pragma("user_version", { simple: true }), 4);
|
assert.equal(db.pragma("user_version", { simple: true }), 5);
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -123,8 +128,8 @@ test("failed migrations roll back all statements and future schemas are not down
|
|||||||
db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all(),
|
db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all(),
|
||||||
[{ name: "hy2_users" }],
|
[{ name: "hy2_users" }],
|
||||||
);
|
);
|
||||||
db.pragma("user_version = 5");
|
db.pragma("user_version = 6");
|
||||||
assert.throws(() => migrateDatabase(db), /版本/);
|
assert.throws(() => migrateDatabase(db), /版本/);
|
||||||
assert.equal(db.pragma("user_version", { simple: true }), 5);
|
assert.equal(db.pragma("user_version", { simple: true }), 6);
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { parse } from "yaml";
|
||||||
|
import { certificateFixture } from "../certificate-fixture";
|
||||||
|
|
||||||
|
test("certificate provisioning preserves credentials, binds domain/ports, supports retry and consumes capability", async () => {
|
||||||
|
const dir = mkdtempSync(path.join(tmpdir(), "hy2-provision-test-"));
|
||||||
|
process.env.DATABASE_PATH = path.join(dir, "test.db");
|
||||||
|
process.env.APP_URL = "https://panel.example.com";
|
||||||
|
process.env.POLLER_ENABLED = "false";
|
||||||
|
const { ensureDb } = await import("../../lib/db");
|
||||||
|
const { decryptText } = await import("../../lib/crypto");
|
||||||
|
const {
|
||||||
|
saveCertificate,
|
||||||
|
validateCertificate,
|
||||||
|
deleteCertificate,
|
||||||
|
listCertificates,
|
||||||
|
} = await import("../../lib/certificates");
|
||||||
|
const {
|
||||||
|
createInstallation,
|
||||||
|
installationForDownload,
|
||||||
|
provisionNode,
|
||||||
|
revokeInstallation,
|
||||||
|
} = await import("../../lib/provisioning");
|
||||||
|
const { buildInstallScript } = await import("../../lib/install-script");
|
||||||
|
const db = await ensureDb();
|
||||||
|
const fixture = certificateFixture();
|
||||||
|
try {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
validateCertificate(
|
||||||
|
fixture.certificate,
|
||||||
|
certificateFixture().privateKey,
|
||||||
|
),
|
||||||
|
/匹配/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
validateCertificate(
|
||||||
|
fixture.certificate,
|
||||||
|
fixture.privateKey,
|
||||||
|
"a.b.p.xn--876a.net",
|
||||||
|
),
|
||||||
|
/SNI/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => validateCertificate("invalid", fixture.privateKey),
|
||||||
|
/解析/,
|
||||||
|
);
|
||||||
|
const certId = Number(
|
||||||
|
await saveCertificate(
|
||||||
|
"Wildcard",
|
||||||
|
fixture.certificate,
|
||||||
|
fixture.privateKey,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
JSON.stringify(await listCertificates()).includes("PRIVATE KEY"),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
const stored = db
|
||||||
|
.prepare("SELECT private_key_encrypted FROM managed_certificates")
|
||||||
|
.get() as { private_key_encrypted: string };
|
||||||
|
assert.notEqual(stored.private_key_encrypted, fixture.privateKey);
|
||||||
|
assert.equal(decryptText(stored.private_key_encrypted), fixture.privateKey);
|
||||||
|
const id = await createInstallation({
|
||||||
|
name: "New server",
|
||||||
|
slug: "new-server",
|
||||||
|
certificateId: certId,
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
createInstallation({
|
||||||
|
name: "Duplicate",
|
||||||
|
slug: "new-server",
|
||||||
|
certificateId: certId,
|
||||||
|
}),
|
||||||
|
/存在/,
|
||||||
|
);
|
||||||
|
await assert.rejects(() => deleteCertificate(certId), /关联/);
|
||||||
|
const job = await installationForDownload(id);
|
||||||
|
const script = buildInstallScript(job);
|
||||||
|
assert.ok(script.includes("--domain"));
|
||||||
|
assert.ok(!script.includes("PRIVATE KEY"));
|
||||||
|
assert.ok(!script.includes("__TOKEN__"));
|
||||||
|
const token = decryptText(job.token_encrypted)!;
|
||||||
|
assert.equal(job.token_hash.includes(token), false);
|
||||||
|
const ports = {
|
||||||
|
domain: "hk.p.xn--876a.net",
|
||||||
|
hy2Port: 23456,
|
||||||
|
statsPort: 23457,
|
||||||
|
hy2ListenPort: 443,
|
||||||
|
statsListenPort: 9999,
|
||||||
|
};
|
||||||
|
await assert.rejects(
|
||||||
|
() => provisionNode("f".repeat(64), "prepare", ports),
|
||||||
|
/无效/,
|
||||||
|
);
|
||||||
|
await assert.rejects(() => provisionNode(token, "complete"), /先获取/);
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
provisionNode(token, "prepare", { ...ports, domain: "evil.example" }),
|
||||||
|
/域名/,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
() => provisionNode(token, "prepare", { ...ports, hy2Port: 65536 }),
|
||||||
|
/端口/,
|
||||||
|
);
|
||||||
|
const bundle = await provisionNode(token, "prepare", ports);
|
||||||
|
assert.ok("config" in bundle);
|
||||||
|
assert.equal(bundle.privateKey, fixture.privateKey);
|
||||||
|
const config = parse(bundle.config);
|
||||||
|
assert.equal(config.listen, ":443");
|
||||||
|
assert.equal(config.trafficStats.listen, ":9999");
|
||||||
|
assert.match(
|
||||||
|
config.auth.http.url,
|
||||||
|
/^https:\/\/panel.example.com\/api\/hy2\/nodes\/new-server\/auth\?token=/,
|
||||||
|
);
|
||||||
|
assert.deepEqual(await provisionNode(token, "prepare", ports), bundle);
|
||||||
|
await assert.rejects(
|
||||||
|
() => provisionNode(token, "prepare", { ...ports, statsPort: 12345 }),
|
||||||
|
/绑定/,
|
||||||
|
);
|
||||||
|
const node = db
|
||||||
|
.prepare("SELECT * FROM hy2_nodes WHERE slug='new-server'")
|
||||||
|
.get() as Record<string, unknown>;
|
||||||
|
assert.equal(node.server_port, 23456);
|
||||||
|
assert.equal(node.traffic_stats_url, "http://hk.p.xn--876a.net:23457");
|
||||||
|
assert.equal(node.sni, ports.domain);
|
||||||
|
assert.equal(node.enabled, 0);
|
||||||
|
await provisionNode(token, "complete");
|
||||||
|
await provisionNode(token, "complete");
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
db.prepare("SELECT enabled FROM hy2_nodes WHERE id=?").get(node.id) as {
|
||||||
|
enabled: number;
|
||||||
|
}
|
||||||
|
).enabled,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
await assert.rejects(() => provisionNode(token, "prepare", ports), /完成/);
|
||||||
|
await assert.rejects(() => installationForDownload(id), /完成/);
|
||||||
|
assert.equal(
|
||||||
|
(db.prepare("SELECT COUNT(*) AS n FROM hy2_nodes").get() as { n: number })
|
||||||
|
.n,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const retryId = await createInstallation({
|
||||||
|
name: "Retry",
|
||||||
|
slug: "retry",
|
||||||
|
certificateId: certId,
|
||||||
|
});
|
||||||
|
const retryToken = decryptText(
|
||||||
|
(await installationForDownload(retryId)).token_encrypted,
|
||||||
|
)!;
|
||||||
|
await revokeInstallation(retryId);
|
||||||
|
await assert.rejects(
|
||||||
|
() => provisionNode(retryToken, "prepare", ports),
|
||||||
|
/无效/,
|
||||||
|
);
|
||||||
|
const expiredId = await createInstallation({
|
||||||
|
name: "Expired",
|
||||||
|
slug: "expired",
|
||||||
|
certificateId: certId,
|
||||||
|
});
|
||||||
|
const expiredToken = decryptText(
|
||||||
|
(await installationForDownload(expiredId)).token_encrypted,
|
||||||
|
)!;
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE node_installations SET expires_at='2000-01-01' WHERE id=?",
|
||||||
|
).run(expiredId);
|
||||||
|
await assert.rejects(
|
||||||
|
() => provisionNode(expiredToken, "prepare", ports),
|
||||||
|
/过期/,
|
||||||
|
);
|
||||||
|
await revokeInstallation(id, true);
|
||||||
|
assert.equal(
|
||||||
|
(db.prepare("SELECT COUNT(*) AS n FROM hy2_nodes").get() as { n: number })
|
||||||
|
.n,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
await revokeInstallation(retryId, true);
|
||||||
|
await revokeInstallation(expiredId, true);
|
||||||
|
await deleteCertificate(certId);
|
||||||
|
assert.equal((await listCertificates()).length, 0);
|
||||||
|
assert.deepEqual(db.pragma("foreign_key_check"), []);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user