feat: 支持托管中转主机和节点接入线路
This commit is contained in:
@@ -225,3 +225,13 @@ sudo bash install-jp2-anytls.sh --domain jp2 --port 51046 --listen-port 51046
|
|||||||
上游源码与许可:https://github.com/SagerNet/sing-box/tree/v1.14.2 ,GPL-3.0-or-later。`lib/templates/stats.proto` 来自该版本,许可说明见同目录 `sing-box-LICENSE`。构建脚本提供可复现的源码获取与编译步骤。
|
上游源码与许可:https://github.com/SagerNet/sing-box/tree/v1.14.2 ,GPL-3.0-or-later。`lib/templates/stats.proto` 来自该版本,许可说明见同目录 `sing-box-LICENSE`。构建脚本提供可复现的源码获取与编译步骤。
|
||||||
|
|
||||||
真实协议回归可运行:`SING_BOX_TEST_BINARY=/path/to/sing-box AGENT_TEST_PYTHON=/path/to/python npx tsx tests/anytls-runtime.ts`(Python 环境需 grpcio 和 grpcio-tools)。它验证 TLS 校验、实际转发、按用户统计及停用后的拒绝连接。
|
真实协议回归可运行:`SING_BOX_TEST_BINARY=/path/to/sing-box AGENT_TEST_PYTHON=/path/to/python npx tsx tests/anytls-runtime.ts`(Python 环境需 grpcio 和 grpcio-tools)。它验证 TLS 校验、实际转发、按用户统计及停用后的拒绝连接。
|
||||||
|
|
||||||
|
## 中转主机与线路
|
||||||
|
|
||||||
|
「中转线路」新增入口主机,下载安装 SH,以 root 在该主机运行。脚本安装固定版本 Realm v2.9.6(官方 Linux musl x86_64 / ARM64 产物,内置 SHA-256),独立使用 `/etc/hy2-panel-relay`、`/usr/local/lib/hy2-panel-relay`、`hy2-panel-relay.service` 和 `hy2-panel-relay-agent.service`。不覆盖已有代理服务,不自动修改防火墙;需放行选定的监听端口,并配置云防火墙/NAT 公网端口映射。脚本含长期管理凭据,应按密钥保管;删除主机可撤销凭据。
|
||||||
|
|
||||||
|
新增线路时选择入口主机、原目标节点,填写公网端口与本机端口。第一版支持托管 HY2(UDP)和 AnyTLS(TCP),保留原节点直连。入口只转发加密流量,不持有节点证书、私钥或用户密码;订阅地址切换到入口,但 SNI 保留原目标域名。AnyTLS 仍允许在 TCP 隧道内转发 UDP 业务。
|
||||||
|
|
||||||
|
管理程序每 15 秒主动获取配置,检查本机端口冲突及 Realm 实际监听情况,配置变更会重启这一台主机的 Realm 并断开已有中转连接。启用、停用、删除在下次同步生效;无法联系平台约 90 秒后停止转发,联网后恢复。页面显示配置/进程状态,不能代替真实协议或大陆线路质量测试。UDP 空闲映射保留 300 秒。
|
||||||
|
|
||||||
|
仅最近上报、配置已应用且服务运行的线路进入订阅,可复用订阅拖拽排序和分组。目标节点停用时其中转线路也停止发布并从转发配置移除。数据库 v7 仅新增 `relay_hosts`、`relay_routes`,中转线路不会插入 `hy2_nodes` 或参与节点轮询;用户流量仅由落地节点计量一次,第一版不区分同一用户的直连/中转用量。删除被引用的目标节点前需先删除对应线路。
|
||||||
|
|||||||
@@ -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/relays", label: "中转线路" },
|
||||||
{ href: "/admin/certificates", label: "证书与安装" },
|
{ href: "/admin/certificates", label: "证书与安装" },
|
||||||
{ href: "/admin/subscriptions", label: "订阅配置" },
|
{ href: "/admin/subscriptions", label: "订阅配置" },
|
||||||
{ href: "/admin/audits", label: "审计日志" },
|
{ href: "/admin/audits", label: "审计日志" },
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"use server";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { requireAdminAccess } from "@/lib/session";
|
||||||
|
import {
|
||||||
|
deleteRelay,
|
||||||
|
RelayError,
|
||||||
|
saveRelayHost,
|
||||||
|
saveRelayRoute,
|
||||||
|
} from "@/lib/relays";
|
||||||
|
const value = (form: FormData, key: string) => String(form.get(key) || "");
|
||||||
|
async function run(work: () => Promise<unknown>) {
|
||||||
|
await requireAdminAccess();
|
||||||
|
try {
|
||||||
|
await work();
|
||||||
|
revalidatePath("/admin/relays");
|
||||||
|
revalidatePath("/admin/subscriptions");
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
error: e instanceof RelayError ? e.message : "保存失败,请检查输入。",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function saveHostAction(form: FormData) {
|
||||||
|
return run(() =>
|
||||||
|
saveRelayHost({
|
||||||
|
id: value(form, "id") || undefined,
|
||||||
|
name: value(form, "name"),
|
||||||
|
server_host: value(form, "server_host"),
|
||||||
|
enabled: form.get("enabled") === "on",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export async function saveRouteAction(form: FormData) {
|
||||||
|
return run(() =>
|
||||||
|
saveRelayRoute({
|
||||||
|
id: value(form, "id") || undefined,
|
||||||
|
name: value(form, "name"),
|
||||||
|
host_id: value(form, "host_id"),
|
||||||
|
node_id: value(form, "node_id"),
|
||||||
|
public_port: value(form, "public_port"),
|
||||||
|
listen_port: value(form, "listen_port"),
|
||||||
|
enabled: form.get("enabled") === "on",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export async function deleteHostAction(form: FormData) {
|
||||||
|
return run(() => deleteRelay("host", value(form, "id")));
|
||||||
|
}
|
||||||
|
export async function deleteRouteAction(form: FormData) {
|
||||||
|
return run(() => deleteRelay("route", value(form, "id")));
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { ActionForm } from "@/components/action-form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { SubmitButton } from "@/components/submit-button";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import { relayDesired, relayInventory, type RelayRoute } from "@/lib/relays";
|
||||||
|
import { ensureDb } from "@/lib/db";
|
||||||
|
import { listNodes } from "@/lib/store";
|
||||||
|
import { formatLocalDateTime } from "@/lib/datetime";
|
||||||
|
import {
|
||||||
|
saveHostAction,
|
||||||
|
saveRouteAction,
|
||||||
|
deleteHostAction,
|
||||||
|
deleteRouteAction,
|
||||||
|
} from "./actions";
|
||||||
|
export default async function RelaysPage() {
|
||||||
|
const [{ hosts, routes }, nodes, db] = await Promise.all([
|
||||||
|
relayInventory(),
|
||||||
|
listNodes(),
|
||||||
|
ensureDb(),
|
||||||
|
]);
|
||||||
|
const targets = nodes.filter((n) => ["hy2", "anytls"].includes(n.node_type));
|
||||||
|
const now = new Date().getTime();
|
||||||
|
const states = new Map(
|
||||||
|
hosts.map((h) => [
|
||||||
|
h.id,
|
||||||
|
!h.enabled
|
||||||
|
? "已停用"
|
||||||
|
: !h.last_seen_at
|
||||||
|
? "等待安装"
|
||||||
|
: now - Date.parse(h.last_seen_at) > 90000
|
||||||
|
? "离线"
|
||||||
|
: h.error
|
||||||
|
? h.error === "port_conflict"
|
||||||
|
? "端口冲突"
|
||||||
|
: "应用失败"
|
||||||
|
: h.applied_hash !== relayDesired(db, h).hash
|
||||||
|
? "等待应用"
|
||||||
|
: relayDesired(db, h).config.endpoints.length === 0
|
||||||
|
? "待添加线路"
|
||||||
|
: h.service_active
|
||||||
|
? "配置已应用"
|
||||||
|
: "服务未运行",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const routeForm = (r?: RelayRoute) => (
|
||||||
|
<ActionForm
|
||||||
|
action={saveRouteAction}
|
||||||
|
className="dense-form-grid provision-form"
|
||||||
|
>
|
||||||
|
{r && <input name="id" type="hidden" value={r.id} />}
|
||||||
|
<label className="field">
|
||||||
|
<span>线路名称</span>
|
||||||
|
<Input
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
defaultValue={r?.name}
|
||||||
|
placeholder="JP2 - AnyTLS · 经 HK2"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>中转主机</span>
|
||||||
|
<select
|
||||||
|
aria-label="中转主机"
|
||||||
|
name="host_id"
|
||||||
|
required
|
||||||
|
defaultValue={r?.host_id || ""}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
选择入口主机
|
||||||
|
</option>
|
||||||
|
{hosts.map((h) => (
|
||||||
|
<option key={h.id} value={h.id}>
|
||||||
|
{h.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>目标节点</span>
|
||||||
|
<select
|
||||||
|
aria-label="目标节点"
|
||||||
|
name="node_id"
|
||||||
|
required
|
||||||
|
defaultValue={r?.node_id || ""}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
选择托管节点
|
||||||
|
</option>
|
||||||
|
{targets.map((n) => (
|
||||||
|
<option key={n.id} value={n.id}>
|
||||||
|
{n.name} · {n.node_type === "hy2" ? "UDP" : "TCP"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>公网端口</span>
|
||||||
|
<Input
|
||||||
|
name="public_port"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
required
|
||||||
|
defaultValue={r?.public_port}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>本机监听端口</span>
|
||||||
|
<Input
|
||||||
|
name="listen_port"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
required
|
||||||
|
defaultValue={r?.listen_port}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="enabled"
|
||||||
|
defaultChecked={r ? Boolean(r.enabled) : true}
|
||||||
|
/>
|
||||||
|
<span>启用线路</span>
|
||||||
|
</label>
|
||||||
|
<SubmitButton>{r ? "保存线路" : "创建线路"}</SubmitButton>
|
||||||
|
</ActionForm>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<main className="page-stack management-page">
|
||||||
|
<section className="hero-panel">
|
||||||
|
<div className="hero-copy">
|
||||||
|
<h2>中转线路</h2>
|
||||||
|
<p className="muted">
|
||||||
|
入口主机透传 TCP / UDP,目标节点继续认证与计量,保留直连。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/admin/subscriptions"
|
||||||
|
className={buttonVariants({ variant: "outline", size: "sm" })}
|
||||||
|
>
|
||||||
|
订阅排序与分组
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
<section className="panel-card compact-create-panel">
|
||||||
|
<details open={!hosts.length}>
|
||||||
|
<summary className="compact-create-summary">
|
||||||
|
<h3>新增中转主机</h3>
|
||||||
|
</summary>
|
||||||
|
<ActionForm
|
||||||
|
action={saveHostAction}
|
||||||
|
className="dense-form-grid provision-form"
|
||||||
|
>
|
||||||
|
<label className="field">
|
||||||
|
<span>主机名称</span>
|
||||||
|
<Input name="name" required placeholder="HK2" />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>公网域名 / IP</span>
|
||||||
|
<Input
|
||||||
|
name="server_host"
|
||||||
|
required
|
||||||
|
placeholder="hk2.p.xn--876a.net"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<input type="hidden" name="enabled" value="on" />
|
||||||
|
<SubmitButton>创建主机</SubmitButton>
|
||||||
|
</ActionForm>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
<section className="panel-card dense-panel">
|
||||||
|
<div className="section-title">
|
||||||
|
<h3>中转主机</h3>
|
||||||
|
<span>{hosts.length} 台</span>
|
||||||
|
</div>
|
||||||
|
<div className="provision-list">
|
||||||
|
{hosts.map((h) => (
|
||||||
|
<details key={h.id} className="group-disclosure">
|
||||||
|
<summary className="group-summary">
|
||||||
|
<strong>{h.name}</strong>
|
||||||
|
<span className="muted break-all">{h.server_host}</span>
|
||||||
|
<span>{states.get(h.id)}</span>
|
||||||
|
</summary>
|
||||||
|
<p className="muted">
|
||||||
|
最近上报:
|
||||||
|
{h.last_seen_at
|
||||||
|
? formatLocalDateTime(h.last_seen_at)
|
||||||
|
: "尚未上报"}
|
||||||
|
。状态代表配置和进程,不代表大陆线路质量。
|
||||||
|
</p>
|
||||||
|
<ActionForm
|
||||||
|
action={saveHostAction}
|
||||||
|
className="dense-form-grid provision-form"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={h.id} />
|
||||||
|
<label className="field">
|
||||||
|
<span>主机名称</span>
|
||||||
|
<Input name="name" defaultValue={h.name} required />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>公网域名 / IP</span>
|
||||||
|
<Input
|
||||||
|
name="server_host"
|
||||||
|
defaultValue={h.server_host}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="enabled"
|
||||||
|
defaultChecked={Boolean(h.enabled)}
|
||||||
|
/>
|
||||||
|
<span>启用主机</span>
|
||||||
|
</label>
|
||||||
|
<SubmitButton>保存主机</SubmitButton>
|
||||||
|
</ActionForm>
|
||||||
|
<div className="entity-action-strip">
|
||||||
|
<a
|
||||||
|
className={buttonVariants({ variant: "outline", size: "sm" })}
|
||||||
|
href={`/api/admin/relays/${h.id}/install`}
|
||||||
|
>
|
||||||
|
下载安装 SH
|
||||||
|
</a>
|
||||||
|
<ActionForm action={deleteHostAction}>
|
||||||
|
<input type="hidden" name="id" value={h.id} />
|
||||||
|
<SubmitButton
|
||||||
|
variant="destructive"
|
||||||
|
confirm="删除该主机及其全部中转线路;管理程序收到撤销后停止中转。"
|
||||||
|
>
|
||||||
|
删除主机
|
||||||
|
</SubmitButton>
|
||||||
|
</ActionForm>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="muted">
|
||||||
|
以 root 在入口主机执行脚本,使用独立 Realm
|
||||||
|
服务。脚本含管理凭据,请妥善保管。仅开放线路所需端口;不需要额外的公网管理端口或证书。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section className="panel-card compact-create-panel">
|
||||||
|
<details open={hosts.length > 0 && !routes.length}>
|
||||||
|
<summary className="compact-create-summary">
|
||||||
|
<h3>新增中转线路</h3>
|
||||||
|
</summary>
|
||||||
|
{routeForm()}
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
<section className="panel-card dense-panel">
|
||||||
|
<div className="section-title">
|
||||||
|
<h3>线路列表</h3>
|
||||||
|
<span>{routes.length} 条</span>
|
||||||
|
</div>
|
||||||
|
<p className="muted">
|
||||||
|
端口相同表示无 NAT 端口转换。配置约 15
|
||||||
|
秒同步,应用变更会中断该主机已有中转连接;服务失联超过 90
|
||||||
|
秒停止转发。仅配置已应用的线路进入订阅,SNI 和凭据继承目标节点。UDP
|
||||||
|
业务仍可通过 AnyTLS 的 TCP 隧道转发。
|
||||||
|
</p>
|
||||||
|
{routes.map((r) => {
|
||||||
|
const h = hosts.find((h) => h.id === r.host_id),
|
||||||
|
n = nodes.find((n) => n.id === r.node_id);
|
||||||
|
return (
|
||||||
|
<details key={r.id} className="group-disclosure">
|
||||||
|
<summary className="group-summary">
|
||||||
|
<strong>{r.name}</strong>
|
||||||
|
<span>
|
||||||
|
{n?.node_type === "hy2" ? "UDP" : "TCP"} ·{" "}
|
||||||
|
{r.enabled ? states.get(r.host_id) : "已停用"}
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<p className="muted break-all">
|
||||||
|
{h?.server_host}:{r.public_port} → 本机 :{r.listen_port} →{" "}
|
||||||
|
{n?.server_host}:{n?.server_port}
|
||||||
|
{!n?.enabled ? "(目标已停用)" : ""}
|
||||||
|
</p>
|
||||||
|
{routeForm(r)}
|
||||||
|
<ActionForm action={deleteRouteAction}>
|
||||||
|
<input type="hidden" name="id" value={r.id} />
|
||||||
|
<SubmitButton
|
||||||
|
variant="destructive"
|
||||||
|
confirm="删除这条中转线路,保留原目标节点与直连。"
|
||||||
|
>
|
||||||
|
删除线路
|
||||||
|
</SubmitButton>
|
||||||
|
</ActionForm>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { requireAdminAccess } from "@/lib/session";
|
||||||
|
import { relayInventory } from "@/lib/relays";
|
||||||
|
import { decryptText } from "@/lib/crypto";
|
||||||
|
import { getEnv } from "@/lib/env";
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
context: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
await requireAdminAccess();
|
||||||
|
const id = (await context.params).id;
|
||||||
|
const row = (await relayInventory()).hosts.find((h) => h.id === id);
|
||||||
|
if (!row) return new Response(null, { status: 404 });
|
||||||
|
const origin = new URL(getEnv().APP_URL);
|
||||||
|
if (
|
||||||
|
origin.protocol !== "https:" &&
|
||||||
|
!["localhost", "127.0.0.1"].includes(origin.hostname)
|
||||||
|
)
|
||||||
|
return new Response("请配置 HTTPS APP_URL。", { status: 409 });
|
||||||
|
const source = await readFile(
|
||||||
|
path.join(process.cwd(), "lib/templates/relay-agent.py"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const token = decryptText(row.token_encrypted);
|
||||||
|
const payload = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
credentials: { panel: origin.origin, id, token },
|
||||||
|
source,
|
||||||
|
}),
|
||||||
|
).toString("base64");
|
||||||
|
const script = (
|
||||||
|
await readFile(
|
||||||
|
path.join(process.cwd(), "lib/templates/install-relay.sh"),
|
||||||
|
"utf8",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.replace("__ID__", `'${id}'`)
|
||||||
|
.replace("__PAYLOAD__", `'${payload}'`);
|
||||||
|
return new Response(script, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/x-shellscript; charset=utf-8",
|
||||||
|
"Content-Disposition": `attachment; filename="install-relay-${id}.sh"`,
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { syncRelay, RelayError } from "@/lib/relays";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
context: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const headers = { "Cache-Control": "no-store" };
|
||||||
|
try {
|
||||||
|
const reader = request.body?.getReader();
|
||||||
|
if (!reader) return new Response(null, { status: 400, headers });
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let size = 0;
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
size += value.byteLength;
|
||||||
|
if (size > 16384) {
|
||||||
|
await reader.cancel();
|
||||||
|
return new Response(null, { status: 413, headers });
|
||||||
|
}
|
||||||
|
chunks.push(value);
|
||||||
|
}
|
||||||
|
return Response.json(
|
||||||
|
await syncRelay(
|
||||||
|
(await context.params).id,
|
||||||
|
request.headers.get("authorization")?.replace(/^Bearer /, "") || "",
|
||||||
|
JSON.parse(Buffer.concat(chunks).toString("utf8")),
|
||||||
|
),
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: e instanceof RelayError ? e.message : "无效报告。" },
|
||||||
|
{ status: e instanceof RelayError ? 403 : 400, headers },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -210,6 +210,32 @@ const MIGRATIONS: (string | ((db: Database.Database) => void))[] = [
|
|||||||
PRIMARY KEY(node_id, epoch, auth_id)
|
PRIMARY KEY(node_id, epoch, auth_id)
|
||||||
);
|
);
|
||||||
`,
|
`,
|
||||||
|
`
|
||||||
|
CREATE TABLE relay_hosts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
server_host TEXT NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
token_encrypted TEXT NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
last_seen_at TEXT,
|
||||||
|
applied_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
service_active INTEGER NOT NULL DEFAULT 0,
|
||||||
|
error TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE relay_routes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
host_id TEXT NOT NULL REFERENCES relay_hosts(id) ON DELETE CASCADE,
|
||||||
|
node_id INTEGER NOT NULL REFERENCES hy2_nodes(id) ON DELETE RESTRICT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
public_port INTEGER NOT NULL CHECK(public_port BETWEEN 1 AND 65535),
|
||||||
|
listen_port INTEGER NOT NULL CHECK(listen_port BETWEEN 1 AND 65535),
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX relay_routes_host ON relay_routes(host_id);
|
||||||
|
`,
|
||||||
];
|
];
|
||||||
|
|
||||||
export function migrateDatabase(db: Database.Database) {
|
export function migrateDatabase(db: Database.Database) {
|
||||||
|
|||||||
+307
@@ -0,0 +1,307 @@
|
|||||||
|
import {
|
||||||
|
createHash,
|
||||||
|
randomBytes,
|
||||||
|
randomUUID,
|
||||||
|
timingSafeEqual,
|
||||||
|
} from "node:crypto";
|
||||||
|
import { isIP } from "node:net";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type Database from "better-sqlite3";
|
||||||
|
import { ensureDb } from "@/lib/db";
|
||||||
|
import { encryptText } from "@/lib/crypto";
|
||||||
|
import type { Hy2NodeRow } from "@/lib/store";
|
||||||
|
|
||||||
|
export type RelayHost = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
server_host: string;
|
||||||
|
enabled: number;
|
||||||
|
token_hash: string;
|
||||||
|
token_encrypted: string;
|
||||||
|
last_seen_at: string | null;
|
||||||
|
applied_hash: string;
|
||||||
|
service_active: number;
|
||||||
|
error: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
export type RelayRoute = {
|
||||||
|
id: string;
|
||||||
|
host_id: string;
|
||||||
|
node_id: number;
|
||||||
|
name: string;
|
||||||
|
public_port: number;
|
||||||
|
listen_port: number;
|
||||||
|
enabled: number;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
export class RelayError extends Error {}
|
||||||
|
const hostname = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1)
|
||||||
|
.max(253)
|
||||||
|
.refine(
|
||||||
|
(v) =>
|
||||||
|
Boolean(isIP(v)) ||
|
||||||
|
/^(?=.{1,253}$)[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?$/.test(v),
|
||||||
|
"请输入域名或 IP,不含协议和端口。",
|
||||||
|
);
|
||||||
|
const hostSchema = z.object({
|
||||||
|
id: z.string().uuid().optional(),
|
||||||
|
name: z.string().trim().min(1).max(100),
|
||||||
|
server_host: hostname,
|
||||||
|
enabled: z.boolean(),
|
||||||
|
});
|
||||||
|
const routeSchema = z.object({
|
||||||
|
id: z.string().uuid().optional(),
|
||||||
|
host_id: z.string().uuid(),
|
||||||
|
node_id: z.coerce.number().int().positive(),
|
||||||
|
name: z.string().trim().min(1).max(100),
|
||||||
|
public_port: z.coerce.number().int().min(1).max(65535),
|
||||||
|
listen_port: z.coerce.number().int().min(1).max(65535),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
});
|
||||||
|
const digest = (value: string) =>
|
||||||
|
createHash("sha256").update(value).digest("hex");
|
||||||
|
export const relayReportSchema = z
|
||||||
|
.object({
|
||||||
|
appliedHash: z.string().regex(/^([a-f0-9]{64})?$/),
|
||||||
|
active: z.boolean(),
|
||||||
|
error: z.enum(["", "port_conflict", "apply_failed", "service_stopped"]),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
export async function relayInventory() {
|
||||||
|
const db = await ensureDb();
|
||||||
|
return {
|
||||||
|
hosts: db
|
||||||
|
.prepare("SELECT * FROM relay_hosts ORDER BY created_at DESC")
|
||||||
|
.all() as RelayHost[],
|
||||||
|
routes: db
|
||||||
|
.prepare("SELECT * FROM relay_routes ORDER BY created_at DESC")
|
||||||
|
.all() as RelayRoute[],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export async function saveRelayHost(input: unknown) {
|
||||||
|
const parsed = hostSchema.safeParse(input);
|
||||||
|
if (!parsed.success)
|
||||||
|
throw new RelayError("请填写主机名称和有效的公网域名/IP。");
|
||||||
|
const data = parsed.data,
|
||||||
|
db = await ensureDb();
|
||||||
|
if (data.id) {
|
||||||
|
if (
|
||||||
|
!db
|
||||||
|
.prepare(
|
||||||
|
"UPDATE relay_hosts SET name=?,server_host=?,enabled=? WHERE id=?",
|
||||||
|
)
|
||||||
|
.run(data.name, data.server_host, +data.enabled, data.id).changes
|
||||||
|
)
|
||||||
|
throw new RelayError("中转主机不存在。");
|
||||||
|
return data.id;
|
||||||
|
}
|
||||||
|
const id = randomUUID(),
|
||||||
|
token = randomBytes(32).toString("hex");
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO relay_hosts(id,name,server_host,enabled,token_hash,token_encrypted,created_at) VALUES(?,?,?,?,?,?,?)",
|
||||||
|
).run(
|
||||||
|
id,
|
||||||
|
data.name,
|
||||||
|
data.server_host,
|
||||||
|
+data.enabled,
|
||||||
|
digest(token),
|
||||||
|
encryptText(token),
|
||||||
|
new Date().toISOString(),
|
||||||
|
);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
export async function saveRelayRoute(input: unknown) {
|
||||||
|
const parsed = routeSchema.safeParse(input);
|
||||||
|
if (!parsed.success)
|
||||||
|
throw new RelayError("请填写线路名称、主机、目标节点及 1–65535 的端口。");
|
||||||
|
const data = parsed.data,
|
||||||
|
db = await ensureDb();
|
||||||
|
return db
|
||||||
|
.transaction(() => {
|
||||||
|
const host = db
|
||||||
|
.prepare("SELECT * FROM relay_hosts WHERE id=?")
|
||||||
|
.get(data.host_id) as RelayHost | undefined;
|
||||||
|
const node = db
|
||||||
|
.prepare("SELECT * FROM hy2_nodes WHERE id=?")
|
||||||
|
.get(data.node_id) as Hy2NodeRow | undefined;
|
||||||
|
if (
|
||||||
|
!host ||
|
||||||
|
!node ||
|
||||||
|
!["hy2", "anytls"].includes(node.node_type) ||
|
||||||
|
!node.server_host ||
|
||||||
|
!node.server_port
|
||||||
|
)
|
||||||
|
throw new RelayError("请选择托管 HY2 或 AnyTLS 节点作为目标。");
|
||||||
|
if (
|
||||||
|
data.enabled &&
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`SELECT r.id FROM relay_routes r JOIN hy2_nodes n ON n.id=r.node_id WHERE r.host_id=? AND r.enabled=1 AND n.node_type=? AND (r.listen_port=? OR r.public_port=?) AND r.id<>?`,
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
data.host_id,
|
||||||
|
node.node_type,
|
||||||
|
data.listen_port,
|
||||||
|
data.public_port,
|
||||||
|
data.id || "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new RelayError(
|
||||||
|
"该主机同协议的监听端口或公网端口已被其他线路使用。",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
db
|
||||||
|
.prepare("SELECT id FROM relay_routes WHERE name=? AND id<>?")
|
||||||
|
.get(data.name, data.id || "") ||
|
||||||
|
db
|
||||||
|
.prepare("SELECT id FROM hy2_nodes WHERE name=? OR client_name=?")
|
||||||
|
.get(data.name, data.name)
|
||||||
|
)
|
||||||
|
throw new RelayError("订阅名称与已有节点或线路重复。");
|
||||||
|
if (
|
||||||
|
host.server_host.toLowerCase() === node.server_host.toLowerCase() &&
|
||||||
|
[data.public_port, data.listen_port].includes(node.server_port)
|
||||||
|
)
|
||||||
|
throw new RelayError("中转入口不能指向自身。");
|
||||||
|
const id = data.id || randomUUID();
|
||||||
|
if (data.id) {
|
||||||
|
if (
|
||||||
|
!db
|
||||||
|
.prepare(
|
||||||
|
"UPDATE relay_routes SET host_id=?,node_id=?,name=?,public_port=?,listen_port=?,enabled=? WHERE id=?",
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
data.host_id,
|
||||||
|
data.node_id,
|
||||||
|
data.name,
|
||||||
|
data.public_port,
|
||||||
|
data.listen_port,
|
||||||
|
+data.enabled,
|
||||||
|
id,
|
||||||
|
).changes
|
||||||
|
)
|
||||||
|
throw new RelayError("线路不存在。");
|
||||||
|
} else
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO relay_routes(id,host_id,node_id,name,public_port,listen_port,enabled,created_at) VALUES(?,?,?,?,?,?,?,?)",
|
||||||
|
).run(
|
||||||
|
id,
|
||||||
|
data.host_id,
|
||||||
|
data.node_id,
|
||||||
|
data.name,
|
||||||
|
data.public_port,
|
||||||
|
data.listen_port,
|
||||||
|
+data.enabled,
|
||||||
|
new Date().toISOString(),
|
||||||
|
);
|
||||||
|
return id;
|
||||||
|
})
|
||||||
|
.immediate();
|
||||||
|
}
|
||||||
|
export async function deleteRelay(kind: "host" | "route", id: string) {
|
||||||
|
const db = await ensureDb();
|
||||||
|
db.prepare(
|
||||||
|
kind === "host"
|
||||||
|
? "DELETE FROM relay_hosts WHERE id=?"
|
||||||
|
: "DELETE FROM relay_routes WHERE id=?",
|
||||||
|
).run(id);
|
||||||
|
}
|
||||||
|
const address = (host: string, port: number) =>
|
||||||
|
`${isIP(host) === 6 ? `[${host}]` : host}:${port}`;
|
||||||
|
export function relayDesired(db: Database.Database, host: RelayHost) {
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT r.*, n.node_type,n.server_host,n.server_port FROM relay_routes r JOIN hy2_nodes n ON n.id=r.node_id WHERE r.host_id=? AND r.enabled=1 AND n.enabled=1 AND n.node_type IN ('hy2','anytls') ORDER BY r.id`,
|
||||||
|
)
|
||||||
|
.all(host.id) as (RelayRoute &
|
||||||
|
Pick<Hy2NodeRow, "node_type" | "server_host" | "server_port">)[];
|
||||||
|
const active = host.enabled ? rows : [];
|
||||||
|
const config = {
|
||||||
|
log: { level: "warn" },
|
||||||
|
endpoints: active.map((r) => ({
|
||||||
|
listen: `0.0.0.0:${r.listen_port}`,
|
||||||
|
remote: address(r.server_host, r.server_port),
|
||||||
|
network: {
|
||||||
|
no_tcp: r.node_type === "hy2",
|
||||||
|
use_udp: r.node_type === "hy2",
|
||||||
|
udp_timeout: 300,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
hash: digest(JSON.stringify(config)),
|
||||||
|
leaseSeconds: 90,
|
||||||
|
intervalSeconds: 15,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export async function syncRelay(id: string, token: string, input: unknown) {
|
||||||
|
const db = await ensureDb();
|
||||||
|
const host = db.prepare("SELECT * FROM relay_hosts WHERE id=?").get(id) as
|
||||||
|
RelayHost | undefined;
|
||||||
|
if (
|
||||||
|
!host ||
|
||||||
|
!/^[a-f0-9]{64}$/.test(token) ||
|
||||||
|
!timingSafeEqual(Buffer.from(digest(token)), Buffer.from(host.token_hash))
|
||||||
|
)
|
||||||
|
throw new RelayError("凭据无效。");
|
||||||
|
const data = relayReportSchema.parse(input);
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE relay_hosts SET last_seen_at=?,applied_hash=?,service_active=?,error=? WHERE id=?",
|
||||||
|
).run(
|
||||||
|
new Date().toISOString(),
|
||||||
|
data.appliedHash,
|
||||||
|
+data.active,
|
||||||
|
data.error,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
return relayDesired(db, host);
|
||||||
|
}
|
||||||
|
// These are subscription-only views of the existing destination, never polling nodes.
|
||||||
|
export function appendRelaySubscriptionNodes(
|
||||||
|
db: Database.Database,
|
||||||
|
nodes: Hy2NodeRow[],
|
||||||
|
) {
|
||||||
|
const hosts = db
|
||||||
|
.prepare("SELECT * FROM relay_hosts WHERE enabled=1")
|
||||||
|
.all() as RelayHost[];
|
||||||
|
const routes = db
|
||||||
|
.prepare("SELECT * FROM relay_routes WHERE enabled=1 ORDER BY created_at")
|
||||||
|
.all() as RelayRoute[];
|
||||||
|
const byId = new Map(nodes.map((n) => [n.id, n]));
|
||||||
|
const ready = new Map(
|
||||||
|
hosts
|
||||||
|
.filter(
|
||||||
|
(h) =>
|
||||||
|
h.service_active &&
|
||||||
|
!h.error &&
|
||||||
|
h.last_seen_at &&
|
||||||
|
Date.now() - Date.parse(h.last_seen_at) < 90000 &&
|
||||||
|
h.applied_hash === relayDesired(db, h).hash,
|
||||||
|
)
|
||||||
|
.map((h) => [h.id, h]),
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
...nodes,
|
||||||
|
...routes.flatMap((r) => {
|
||||||
|
const host = ready.get(r.host_id),
|
||||||
|
node = byId.get(r.node_id);
|
||||||
|
if (!host || !node || !["hy2", "anytls"].includes(node.node_type))
|
||||||
|
return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...node,
|
||||||
|
slug: `relay/${r.id}`,
|
||||||
|
name: r.name,
|
||||||
|
client_name: r.name,
|
||||||
|
server_host: host.server_host,
|
||||||
|
server_port: r.public_port,
|
||||||
|
sni: node.sni || node.server_host,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
+3
-1
@@ -1,3 +1,4 @@
|
|||||||
|
import { appendRelaySubscriptionNodes } from "@/lib/relays";
|
||||||
import {
|
import {
|
||||||
parseCustomProxyYaml,
|
parseCustomProxyYaml,
|
||||||
extractProxyNameFromYaml,
|
extractProxyNameFromYaml,
|
||||||
@@ -1249,7 +1250,7 @@ export async function saveMainProxyGroupMemberSlugs(memberSlugs: string[]) {
|
|||||||
|
|
||||||
export async function listSubscriptionNodes() {
|
export async function listSubscriptionNodes() {
|
||||||
const db = await ensureDb();
|
const db = await ensureDb();
|
||||||
return db
|
const nodes = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`
|
`
|
||||||
SELECT *
|
SELECT *
|
||||||
@@ -1259,6 +1260,7 @@ export async function listSubscriptionNodes() {
|
|||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.all() as Hy2NodeRow[];
|
.all() as Hy2NodeRow[];
|
||||||
|
return appendRelaySubscriptionNodes(db, nodes);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeAuthAudit(input: AuditRecordInput) {
|
export async function writeAuthAudit(input: AuditRecordInput) {
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
[ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@"
|
||||||
|
set -Eeuo pipefail
|
||||||
|
umask 077
|
||||||
|
ID=__ID__
|
||||||
|
ROOT=/etc/hy2-panel-relay
|
||||||
|
[ "$(id -u)" -eq 0 ] && [ -d /run/systemd/system ] || { echo '需要 root 和 systemd Linux。'; exit 1; }
|
||||||
|
if [ -d "$ROOT" ] || [ -e /etc/systemd/system/hy2-panel-relay.service ]; then
|
||||||
|
[ "$(cat "$ROOT/host-id" 2>/dev/null || true)" = "$ID" ] || { echo '已有其他中转安装,停止以避免覆盖。'; exit 1; }
|
||||||
|
fi
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64) ARCH=x86_64; SHA=b1cc335547bea8bb2a88178bef12ec7f2363e36200e7ea1d4e1e67627929bf65;;
|
||||||
|
aarch64|arm64) ARCH=aarch64; SHA=f4c0318dd86854da483dcb7645b4f39cae2cc3f91c688fef969d53220b949488;;
|
||||||
|
*) echo '支持 x86_64 / ARM64。'; exit 1;;
|
||||||
|
esac
|
||||||
|
if command -v apt-get >/dev/null; then apt-get update; DEBIAN_FRONTEND=noninteractive apt-get install -y curl ca-certificates python3 tar
|
||||||
|
elif command -v dnf >/dev/null; then dnf install -y curl ca-certificates python3 tar
|
||||||
|
else echo '需要 Debian/Ubuntu 或 dnf 发行版。'; exit 1; fi
|
||||||
|
WORK=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$WORK"' EXIT
|
||||||
|
curl -fsSL --retry 3 --connect-timeout 20 --max-time 300 "https://github.com/zhboner/realm/releases/download/v2.9.6/realm-$ARCH-unknown-linux-musl.tar.gz" -o "$WORK/realm.tar.gz"
|
||||||
|
printf '%s %s\n' "$SHA" "$WORK/realm.tar.gz" | sha256sum -c -
|
||||||
|
tar -xzf "$WORK/realm.tar.gz" -C "$WORK" realm
|
||||||
|
systemctl stop hy2-panel-relay-agent.service hy2-panel-relay.service 2>/dev/null || true
|
||||||
|
install -d -m 700 "$ROOT" /usr/local/lib/hy2-panel-relay
|
||||||
|
printf '%s' "$ID" > "$ROOT/host-id"
|
||||||
|
printf '%s' __PAYLOAD__ | base64 -d > "$WORK/payload.json"
|
||||||
|
python3 - "$WORK/payload.json" <<'PY'
|
||||||
|
import json,sys
|
||||||
|
from pathlib import Path
|
||||||
|
p=json.load(open(sys.argv[1]))
|
||||||
|
Path('/etc/hy2-panel-relay/agent.json').write_text(json.dumps(p['credentials']))
|
||||||
|
Path('/usr/local/lib/hy2-panel-relay/agent.py').write_text(p['source'])
|
||||||
|
PY
|
||||||
|
install -m 755 "$WORK/realm" /usr/local/lib/hy2-panel-relay/realm
|
||||||
|
cat > /etc/systemd/system/hy2-panel-relay.service <<'UNIT'
|
||||||
|
[Unit]
|
||||||
|
Description=HY2 Panel Realm relay
|
||||||
|
After=network-online.target
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/local/lib/hy2-panel-relay/realm -c /etc/hy2-panel-relay/config.json
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
LimitNOFILE=1048576
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
PrivateTmp=true
|
||||||
|
UNIT
|
||||||
|
cat > /etc/systemd/system/hy2-panel-relay-agent.service <<'UNIT'
|
||||||
|
[Unit]
|
||||||
|
Description=HY2 Panel relay management agent
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/bin/python3 /usr/local/lib/hy2-panel-relay/agent.py
|
||||||
|
ExecStopPost=/bin/systemctl stop hy2-panel-relay.service
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ReadWritePaths=/etc/hy2-panel-relay
|
||||||
|
PrivateTmp=true
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
UNIT
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now hy2-panel-relay-agent.service
|
||||||
|
echo '中转管理程序已启动,请在面板配置线路并确认状态。无需开放公网管理端口。'
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Outbound-only management for a dedicated Realm instance."""
|
||||||
|
import fcntl
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
ROOT = Path('/etc/hy2-panel-relay')
|
||||||
|
UNIT = 'hy2-panel-relay.service'
|
||||||
|
|
||||||
|
|
||||||
|
def core(command):
|
||||||
|
subprocess.run(['systemctl', command, UNIT], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
|
||||||
|
|
||||||
|
def pid():
|
||||||
|
return int(subprocess.check_output(['systemctl', 'show', UNIT, '-p', 'MainPID', '--value'], text=True).strip())
|
||||||
|
|
||||||
|
|
||||||
|
def active():
|
||||||
|
return subprocess.run(['systemctl', 'is-active', '--quiet', UNIT]).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def verify_listeners(config):
|
||||||
|
process = pid()
|
||||||
|
if not process:
|
||||||
|
return False
|
||||||
|
inodes = set()
|
||||||
|
for fd in Path('/proc/' + str(process) + '/fd').iterdir():
|
||||||
|
try:
|
||||||
|
link = os.readlink(fd)
|
||||||
|
if link.startswith('socket:['):
|
||||||
|
inodes.add(link[8:-1])
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
ports = set()
|
||||||
|
for network in ('tcp', 'udp'):
|
||||||
|
for line in Path('/proc/net/' + network).read_text().splitlines()[1:]:
|
||||||
|
fields = line.split()
|
||||||
|
if fields[9] in inodes and (network != 'tcp' or fields[3] == '0A'):
|
||||||
|
ports.add((network, int(fields[1].split(':')[1], 16)))
|
||||||
|
return all(('udp' if endpoint['network']['use_udp'] else 'tcp', int(endpoint['listen'].rsplit(':', 1)[1])) in ports for endpoint in config['endpoints'])
|
||||||
|
|
||||||
|
|
||||||
|
def write_config(config):
|
||||||
|
staged = ROOT / 'next.json'
|
||||||
|
with open(staged, 'w') as handle:
|
||||||
|
json.dump(config, handle)
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
os.replace(staged, ROOT / 'config.json')
|
||||||
|
|
||||||
|
|
||||||
|
def apply(config):
|
||||||
|
core('stop')
|
||||||
|
sockets = []
|
||||||
|
try:
|
||||||
|
for endpoint in config['endpoints']:
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM if endpoint['network']['use_udp'] else socket.SOCK_STREAM)
|
||||||
|
sockets.append(sock)
|
||||||
|
# SO_REUSEADDR allows a stopped TCP listener's TIME_WAIT connections.
|
||||||
|
if not endpoint['network']['use_udp']:
|
||||||
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
sock.bind(('0.0.0.0', int(endpoint['listen'].rsplit(':', 1)[1])))
|
||||||
|
except OSError:
|
||||||
|
return 'port_conflict'
|
||||||
|
finally:
|
||||||
|
for sock in sockets:
|
||||||
|
sock.close()
|
||||||
|
write_config(config)
|
||||||
|
if config['endpoints']:
|
||||||
|
core('start')
|
||||||
|
for _ in range(20):
|
||||||
|
time.sleep(0.2)
|
||||||
|
if active() and verify_listeners(config):
|
||||||
|
return ''
|
||||||
|
core('stop')
|
||||||
|
return 'apply_failed'
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
lock = open(ROOT / 'agent.lock', 'w')
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
credentials = json.loads((ROOT / 'agent.json').read_text())
|
||||||
|
applied = ''
|
||||||
|
error = ''
|
||||||
|
deadline = 0
|
||||||
|
core('stop')
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
request = urllib.request.Request(credentials['panel'] + '/api/relay/' + credentials['id'] + '/sync',
|
||||||
|
data=json.dumps({'appliedHash': applied, 'active': active(), 'error': error}).encode(),
|
||||||
|
headers={'Content-Type': 'application/json', 'Authorization': 'Bearer ' + credentials['token']})
|
||||||
|
with urllib.request.urlopen(request, timeout=10) as response:
|
||||||
|
desired = json.load(response)
|
||||||
|
if desired['hash'] != applied or (desired['config']['endpoints'] and not active()):
|
||||||
|
error = apply(desired['config'])
|
||||||
|
applied = desired['hash'] if not error else ''
|
||||||
|
deadline = time.monotonic() + min(90, desired['leaseSeconds'])
|
||||||
|
print('同步成功' if not error else error, flush=True)
|
||||||
|
except Exception as exc:
|
||||||
|
print('同步失败:' + type(exc).__name__, flush=True)
|
||||||
|
if isinstance(exc, urllib.error.HTTPError) and exc.code in (400, 401, 403, 404):
|
||||||
|
deadline = 0
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
core('stop')
|
||||||
|
applied = ''
|
||||||
|
time.sleep(max(1, min(15, deadline - time.monotonic())) if deadline else 5)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
os.umask(0o077)
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
finally:
|
||||||
|
core('stop')
|
||||||
@@ -11,7 +11,7 @@ export const test = base.extend<{ isolatedData: void }>({
|
|||||||
db.pragma("foreign_keys = ON");
|
db.pragma("foreign_keys = ON");
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
db.exec(
|
db.exec(
|
||||||
"DELETE FROM node_installations; DELETE FROM managed_certificates; DELETE FROM hy2_nodes; DELETE FROM hy2_users WHERE is_admin = 0;",
|
"DELETE FROM relay_hosts; DELETE FROM node_installations; DELETE FROM managed_certificates; DELETE FROM hy2_nodes; DELETE FROM hy2_users WHERE is_admin = 0;",
|
||||||
);
|
);
|
||||||
db.prepare(
|
db.prepare(
|
||||||
"UPDATE app_settings SET value = '[]' WHERE key IN ('main_proxy_group_member_slugs_json','proxy_groups_json')",
|
"UPDATE app_settings SET value = '[]' WHERE key IN ('main_proxy_group_member_slugs_json','proxy_groups_json')",
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { test, expect } from "./e2e-fixtures";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
test("managed relay UI, authenticated script, deployment state and subscription membership", async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
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 node = page
|
||||||
|
.locator("details")
|
||||||
|
.filter({
|
||||||
|
has: page.getByRole("heading", { name: "新增 HY2 节点", exact: true }),
|
||||||
|
});
|
||||||
|
await node.locator("summary").click();
|
||||||
|
await node.getByLabel("Slug", { exact: true }).fill("jp2");
|
||||||
|
await node.getByLabel("显示名", { exact: true }).fill("JP2 direct");
|
||||||
|
await node
|
||||||
|
.getByLabel("客户端地址", { exact: true })
|
||||||
|
.fill("jp2.p.xn--876a.net");
|
||||||
|
await node.getByRole("button", { name: "创建节点" }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByText("JP2 direct", { exact: true }).first(),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.getByRole("link", { name: "中转线路", exact: true }).click();
|
||||||
|
const host = page
|
||||||
|
.locator("details")
|
||||||
|
.filter({ has: page.getByRole("heading", { name: "新增中转主机" }) });
|
||||||
|
await host.getByLabel("主机名称").fill("HK2");
|
||||||
|
await host.getByLabel("公网域名 / IP").fill("hk2.p.xn--876a.net");
|
||||||
|
await host.getByRole("button", { name: "创建主机" }).click();
|
||||||
|
await expect(page.getByText("等待安装", { exact: true })).toBeVisible();
|
||||||
|
const row = page
|
||||||
|
.locator("details")
|
||||||
|
.filter({ has: page.locator("summary strong", { hasText: /^HK2$/ }) });
|
||||||
|
await row.locator("summary").click();
|
||||||
|
const href = await row
|
||||||
|
.getByRole("link", { name: "下载安装 SH" })
|
||||||
|
.getAttribute("href");
|
||||||
|
expect((await request.get(href!, { maxRedirects: 0 })).status()).toBe(307);
|
||||||
|
const script = await page.request.get(href!);
|
||||||
|
expect(script.status()).toBe(200);
|
||||||
|
expect(await script.text()).toContain("realm");
|
||||||
|
const create = page
|
||||||
|
.locator("details")
|
||||||
|
.filter({ has: page.getByRole("heading", { name: "新增中转线路" }) });
|
||||||
|
await create.getByLabel("线路名称").fill("JP2 via HK2");
|
||||||
|
await create
|
||||||
|
.getByLabel("中转主机", { exact: true })
|
||||||
|
.selectOption({ label: "HK2" });
|
||||||
|
await create
|
||||||
|
.getByLabel("目标节点")
|
||||||
|
.selectOption({ label: "JP2 direct · UDP" });
|
||||||
|
await create.getByLabel("公网端口").fill("51044");
|
||||||
|
await create.getByLabel("本机监听端口").fill("51044");
|
||||||
|
await create.getByRole("button", { name: "创建线路" }).click();
|
||||||
|
await expect(
|
||||||
|
page.locator("summary strong", { hasText: "JP2 via HK2" }),
|
||||||
|
).toBeVisible();
|
||||||
|
const db = new Database(process.env.HY2_E2E_DATABASE!);
|
||||||
|
const token = "a".repeat(64);
|
||||||
|
const id = (db.prepare("SELECT id FROM relay_hosts").get() as { id: string })
|
||||||
|
.id;
|
||||||
|
db.prepare("UPDATE relay_hosts SET token_hash=? WHERE id=?").run(
|
||||||
|
createHash("sha256").update(token).digest("hex"),
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
db.close();
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await request.post(`/api/relay/${id}/sync`, {
|
||||||
|
data: { appliedHash: "", active: false, error: "" },
|
||||||
|
})
|
||||||
|
).status(),
|
||||||
|
).toBe(403);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await request.post(`/api/relay/${id}/sync`, { data: "x".repeat(17000) })
|
||||||
|
).status(),
|
||||||
|
).toBe(413);
|
||||||
|
const sync = await request.post(`/api/relay/${id}/sync`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
data: { appliedHash: "", active: false, error: "" },
|
||||||
|
});
|
||||||
|
expect(sync.status()).toBe(200);
|
||||||
|
const desired = await sync.json();
|
||||||
|
expect(desired.config.endpoints[0].network.use_udp).toBe(true);
|
||||||
|
await request.post(`/api/relay/${id}/sync`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
data: { appliedHash: desired.hash, active: true, error: "" },
|
||||||
|
});
|
||||||
|
await page.reload();
|
||||||
|
await expect(
|
||||||
|
page.getByText("配置已应用", { exact: true }).first(),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.screenshot({
|
||||||
|
path: "test-results/relays-desktop.png",
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.screenshot({
|
||||||
|
path: "test-results/relays-mobile.png",
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth <= innerWidth + 1,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
await page.getByRole("link", { name: "订阅配置", exact: true }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "拖拽排序 JP2 via HK2" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
@@ -29,6 +29,8 @@ function snapshot(db: Database.Database) {
|
|||||||
"managed_certificates",
|
"managed_certificates",
|
||||||
"node_installations",
|
"node_installations",
|
||||||
"node_agent_counters",
|
"node_agent_counters",
|
||||||
|
"relay_hosts",
|
||||||
|
"relay_routes",
|
||||||
].includes(name),
|
].includes(name),
|
||||||
)
|
)
|
||||||
.map(({ name }) => [name, db.prepare(`SELECT * FROM "${name}"`).all()]),
|
.map(({ name }) => [name, db.prepare(`SELECT * FROM "${name}"`).all()]),
|
||||||
@@ -49,7 +51,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 }), 6);
|
assert.equal(fresh.pragma("user_version", { simple: true }), 7);
|
||||||
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 }[]) {
|
||||||
@@ -102,7 +104,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 }), 6);
|
assert.equal(db.pragma("user_version", { simple: true }), 7);
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -132,8 +134,34 @@ 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 = 7");
|
db.pragma("user_version = 8");
|
||||||
assert.throws(() => migrateDatabase(db), /版本/);
|
assert.throws(() => migrateDatabase(db), /版本/);
|
||||||
|
assert.equal(db.pragma("user_version", { simple: true }), 8);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("v6 upgrade preserves AnyTLS installations, certificates and counters", () => {
|
||||||
|
const db = remoteDb();
|
||||||
|
seed(db);
|
||||||
|
migrateDatabase(db);
|
||||||
|
db.exec(
|
||||||
|
"DROP TABLE relay_routes; DROP TABLE relay_hosts; PRAGMA user_version=6;",
|
||||||
|
);
|
||||||
|
db.exec(`INSERT INTO managed_certificates VALUES(1,'wildcard','certificate','encrypted-key','2030-01-01','2026-01-01');
|
||||||
|
INSERT INTO node_installations(id,token_hash,token_encrypted,certificate_id,slug,name,server_host,sni,origin,expires_at,created_at,node_id,protocol) VALUES('install','hash','encrypted',1,'anytls','AnyTLS','jp2.example','jp2.example','https://panel.example','2030-01-01','2026-01-01',17,'anytls');
|
||||||
|
INSERT INTO node_agent_counters VALUES(17,'boot:1','original-auth',128,256);`);
|
||||||
|
const tables = [
|
||||||
|
"managed_certificates",
|
||||||
|
"node_installations",
|
||||||
|
"node_agent_counters",
|
||||||
|
];
|
||||||
|
const before = tables.map((t) => db.prepare(`SELECT * FROM ${t}`).all());
|
||||||
|
migrateDatabase(db);
|
||||||
|
migrateDatabase(db);
|
||||||
|
assert.deepEqual(
|
||||||
|
tables.map((t) => db.prepare(`SELECT * FROM ${t}`).all()),
|
||||||
|
before,
|
||||||
|
);
|
||||||
assert.equal(db.pragma("user_version", { simple: true }), 7);
|
assert.equal(db.pragma("user_version", { simple: true }), 7);
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { parse } from "yaml";
|
||||||
|
|
||||||
|
test("relay routes inherit credentials and SNI, isolate polling, reject conflicts and withdraw stale/disabled routes", async () => {
|
||||||
|
const dir = mkdtempSync(path.join(tmpdir(), "hy2-relay-"));
|
||||||
|
process.env.DATABASE_PATH = path.join(dir, "db.sqlite");
|
||||||
|
const { ensureDb } = await import("../../lib/db");
|
||||||
|
const {
|
||||||
|
createNode,
|
||||||
|
getNodeBySlug,
|
||||||
|
listSubscriptionNodes,
|
||||||
|
listEnabledNodesForPolling,
|
||||||
|
listUsers,
|
||||||
|
getSubscriptionSettings,
|
||||||
|
} = await import("../../lib/store");
|
||||||
|
const {
|
||||||
|
saveRelayHost,
|
||||||
|
saveRelayRoute,
|
||||||
|
relayInventory,
|
||||||
|
syncRelay,
|
||||||
|
relayDesired,
|
||||||
|
deleteRelay,
|
||||||
|
} = await import("../../lib/relays");
|
||||||
|
const { decryptText } = await import("../../lib/crypto");
|
||||||
|
const { buildSubscriptionConfig } = await import("../../lib/subscription");
|
||||||
|
const db = await ensureDb();
|
||||||
|
try {
|
||||||
|
for (const protocol of ["hy2", "anytls"] as const) {
|
||||||
|
await createNode({
|
||||||
|
nodeType: protocol,
|
||||||
|
slug: protocol,
|
||||||
|
name: protocol,
|
||||||
|
authToken: null,
|
||||||
|
hy2Listen: ":51046",
|
||||||
|
serverHost: "jp2.p.xn--876a.net",
|
||||||
|
serverPort: protocol === "hy2" ? 51044 : 51046,
|
||||||
|
clientName: protocol,
|
||||||
|
bandwidthUpMbps: 30,
|
||||||
|
bandwidthDownMbps: 30,
|
||||||
|
skipCertVerify: false,
|
||||||
|
sni: null,
|
||||||
|
trafficStatsUrl: "http://127.0.0.1:19090",
|
||||||
|
trafficStatsListen: ":19090",
|
||||||
|
trafficStatsSecret: "test",
|
||||||
|
enabled: true,
|
||||||
|
pollIntervalSeconds: 15,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const any = (await getNodeBySlug("anytls"))!,
|
||||||
|
hy2 = (await getNodeBySlug("hy2"))!;
|
||||||
|
const hostId = await saveRelayHost({
|
||||||
|
name: "HK2",
|
||||||
|
server_host: "hk2.p.xn--876a.net",
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
const host = (await relayInventory()).hosts[0],
|
||||||
|
token = decryptText(host.token_encrypted)!;
|
||||||
|
const base = {
|
||||||
|
host_id: hostId,
|
||||||
|
node_id: any.id,
|
||||||
|
name: "JP2 via HK2 TCP",
|
||||||
|
public_port: 52046,
|
||||||
|
listen_port: 51046,
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
const tcpId = await saveRelayRoute(base);
|
||||||
|
await saveRelayRoute({
|
||||||
|
...base,
|
||||||
|
node_id: hy2.id,
|
||||||
|
name: "JP2 via HK2 UDP",
|
||||||
|
public_port: 52046,
|
||||||
|
listen_port: 51046,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
(await listSubscriptionNodes()).length,
|
||||||
|
2,
|
||||||
|
"not advertised before application",
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
() => saveRelayRoute({ ...base, name: "conflict" }),
|
||||||
|
/端口/,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
saveRelayRoute({
|
||||||
|
...base,
|
||||||
|
public_port: 52047,
|
||||||
|
listen_port: 51047,
|
||||||
|
name: "anytls",
|
||||||
|
}),
|
||||||
|
/重复/,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
syncRelay(hostId, "0".repeat(64), {
|
||||||
|
appliedHash: "",
|
||||||
|
active: false,
|
||||||
|
error: "",
|
||||||
|
}),
|
||||||
|
/凭据/,
|
||||||
|
);
|
||||||
|
const desired = await syncRelay(hostId, token, {
|
||||||
|
appliedHash: "",
|
||||||
|
active: false,
|
||||||
|
error: "",
|
||||||
|
});
|
||||||
|
assert.equal(desired.config.endpoints.length, 2);
|
||||||
|
assert.equal(
|
||||||
|
desired.config.endpoints.find((e) => e.remote.endsWith(":51044"))?.network
|
||||||
|
.no_tcp,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
desired.config.endpoints.find((e) => e.remote.endsWith(":51046"))?.network
|
||||||
|
.use_udp,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!JSON.stringify(desired).includes(any.auth_token),
|
||||||
|
"relay receives no backend credentials",
|
||||||
|
);
|
||||||
|
await syncRelay(hostId, token, {
|
||||||
|
appliedHash: desired.hash,
|
||||||
|
active: true,
|
||||||
|
error: "",
|
||||||
|
});
|
||||||
|
const nodes = await listSubscriptionNodes();
|
||||||
|
assert.equal(nodes.length, 4);
|
||||||
|
assert.equal(
|
||||||
|
(await listEnabledNodesForPolling()).length,
|
||||||
|
1,
|
||||||
|
"only original HY2 is polled",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(db.prepare("SELECT count(*) n FROM hy2_nodes").get() as { n: number }).n,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
const result = parse(
|
||||||
|
buildSubscriptionConfig({
|
||||||
|
user: (await listUsers())[0],
|
||||||
|
nodes,
|
||||||
|
settings: await getSubscriptionSettings(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const direct = result.proxies.find(
|
||||||
|
(p: { name: string }) => p.name === "anytls",
|
||||||
|
),
|
||||||
|
relay = result.proxies.find(
|
||||||
|
(p: { name: string }) => p.name === base.name,
|
||||||
|
);
|
||||||
|
assert.equal(relay.password, direct.password);
|
||||||
|
assert.equal(relay.sni, "jp2.p.xn--876a.net");
|
||||||
|
assert.equal(relay.server, "hk2.p.xn--876a.net");
|
||||||
|
assert.equal(relay.port, 52046);
|
||||||
|
assert.equal(relay.udp, true);
|
||||||
|
db.prepare("UPDATE relay_hosts SET last_seen_at=? WHERE id=?").run(
|
||||||
|
"2000-01-01T00:00:00.000Z",
|
||||||
|
hostId,
|
||||||
|
);
|
||||||
|
assert.equal((await listSubscriptionNodes()).length, 2);
|
||||||
|
await syncRelay(hostId, token, {
|
||||||
|
appliedHash: desired.hash,
|
||||||
|
active: true,
|
||||||
|
error: "port_conflict",
|
||||||
|
});
|
||||||
|
assert.equal((await listSubscriptionNodes()).length, 2);
|
||||||
|
await syncRelay(hostId, token, {
|
||||||
|
appliedHash: desired.hash,
|
||||||
|
active: true,
|
||||||
|
error: "",
|
||||||
|
});
|
||||||
|
await saveRelayRoute({ ...base, id: tcpId, enabled: false });
|
||||||
|
assert.equal(
|
||||||
|
(await listSubscriptionNodes()).length,
|
||||||
|
2,
|
||||||
|
"hide old generation until applied",
|
||||||
|
);
|
||||||
|
const next = relayDesired(db, (await relayInventory()).hosts[0]);
|
||||||
|
assert.equal(next.config.endpoints.length, 1);
|
||||||
|
await syncRelay(hostId, token, {
|
||||||
|
appliedHash: next.hash,
|
||||||
|
active: true,
|
||||||
|
error: "",
|
||||||
|
});
|
||||||
|
assert.equal((await listSubscriptionNodes()).length, 3);
|
||||||
|
await saveRelayHost({
|
||||||
|
id: hostId,
|
||||||
|
name: "HK2",
|
||||||
|
server_host: "hk2.p.xn--876a.net",
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await syncRelay(hostId, token, {
|
||||||
|
appliedHash: next.hash,
|
||||||
|
active: true,
|
||||||
|
error: "",
|
||||||
|
})
|
||||||
|
).config.endpoints.length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert.equal((await listSubscriptionNodes()).length, 2);
|
||||||
|
await deleteRelay("host", hostId);
|
||||||
|
assert.equal((await relayInventory()).routes.length, 0);
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
syncRelay(hostId, token, { appliedHash: "", active: false, error: "" }),
|
||||||
|
/凭据/,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user