feat: 接入 AnyTLS 节点托管和用户流量同步
This commit is contained in:
@@ -203,3 +203,25 @@ sudo bash install-jp1.sh --domain jp1.p.xn--876a.net \
|
|||||||
重复执行仅允许同一安装记录、同一域名和端口,不覆盖其他已有 HY2 配置。失败后修复问题,在凭据有效期内重试;已启用的节点不因删除安装记录而删除。凭据过期且已有部分安装时,请先检查并清理对应的禁用节点和服务器配置,再生成新的安装记录。
|
重复执行仅允许同一安装记录、同一域名和端口,不覆盖其他已有 HY2 配置。失败后修复问题,在凭据有效期内重试;已启用的节点不因删除安装记录而删除。凭据过期且已有部分安装时,请先检查并清理对应的禁用节点和服务器配置,再生成新的安装记录。
|
||||||
|
|
||||||
平台 `APP_URL` 必须是服务器能访问的公网 HTTPS 地址(本地测试允许 localhost HTTP)。新迁移使用数据库版本 5,只新增 `managed_certificates` / `node_installations` 表,兼容现有 v4 节点、用户和订阅数据。部署前仍应备份数据库;不要用旧版本程序打开已升级的数据库。
|
平台 `APP_URL` 必须是服务器能访问的公网 HTTPS 地址(本地测试允许 localhost HTTP)。新迁移使用数据库版本 5,只新增 `managed_certificates` / `node_installations` 表,兼容现有 v4 节点、用户和订阅数据。部署前仍应备份数据库;不要用旧版本程序打开已升级的数据库。
|
||||||
|
|
||||||
|
## 托管 AnyTLS / sing-box
|
||||||
|
|
||||||
|
「证书与安装」选择 **AnyTLS · TCP / sing-box**,下载脚本后运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash install-jp2-anytls.sh --domain jp2 --port 51046 --listen-port 51046
|
||||||
|
```
|
||||||
|
|
||||||
|
只需映射代理 TCP 端口。sing-box 与管理程序使用独立目录和 systemd 单元,不覆盖已有 HY2。管理 API 自动分配本机端口,节点每 15 秒通过 HTTPS 主动向面板同步,无需开放公网管理端口。支持 Debian/Ubuntu、dnf 系发行版的 x86_64 / ARM64。
|
||||||
|
|
||||||
|
- 每用户派生独立的 AnyTLS 密码,订阅自动生成对应节点。更改用户密码会更新 AnyTLS 凭据。
|
||||||
|
- 用户禁用、到期、额度耗尽及节点禁用在下次同步时生效。当前通过重启 sing-box 应用配置,所有已有连接会断开;配额为周期检查,可能产生一个同步周期的超额。
|
||||||
|
- 连续无法同步超过 90 秒,或本地已到租约到期时间时停止代理;重新连接面板后自动恢复。管理程序异常退出也会停止代理。
|
||||||
|
- 从 V2Ray API 读取累计用户流量,以进程启动标识和累计值去重;待发送报告落盘,网络重试不重复计费。流量为应用层统计,异常掉电/崩溃可能丢失最后一次采样之后的数据,不能当作精确的计费凭证。在线用户数暂不展示,连接数来自 Clash API。
|
||||||
|
- 数据库迁移 v6 只增加安装协议字段和累计采样表,原 HY2 / 自定义节点保持兼容。
|
||||||
|
|
||||||
|
官方 sing-box 默认发行包未启用 `with_v2ray_api`。部署面板前运行 `bash scripts/build-sing-box.sh`,构建**未经修改的** sing-box v1.14.2(固定源码 commit `af6e64c3b69e6132ebaee0e1a3d24e93903f6709`),启用 `with_v2ray_api,with_clash_api,with_utls`。需要 Go 1.25.5 或更新版本。将生成的 `data/artifacts/` 作为部署产物保存到面板同一路径;程序不会把二进制或任何凭据提交到 Git。安装脚本从面板 HTTPS 下载二进制并核对 SHA-256;没有产物时下载端点返回 503。
|
||||||
|
|
||||||
|
上游源码与许可: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 校验、实际转发、按用户统计及停用后的拒绝连接。
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export async function createInstallationAction(data: FormData) {
|
|||||||
return run(async () => {
|
return run(async () => {
|
||||||
const input = installationSchema.safeParse({
|
const input = installationSchema.safeParse({
|
||||||
name: data.get("name"),
|
name: data.get("name"),
|
||||||
|
protocol: data.get("protocol") || "hy2",
|
||||||
slug: data.get("slug"),
|
slug: data.get("slug"),
|
||||||
certificateId: data.get("certificate_id"),
|
certificateId: data.get("certificate_id"),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -124,6 +124,17 @@ export default async function CertificatesPage() {
|
|||||||
action={createInstallationAction}
|
action={createInstallationAction}
|
||||||
className="dense-form-grid provision-form"
|
className="dense-form-grid provision-form"
|
||||||
>
|
>
|
||||||
|
<label className="field">
|
||||||
|
<span id="protocol-label">代理协议</span>
|
||||||
|
<select
|
||||||
|
name="protocol"
|
||||||
|
aria-labelledby="protocol-label"
|
||||||
|
defaultValue="hy2"
|
||||||
|
>
|
||||||
|
<option value="hy2">HY2 · UDP</option>
|
||||||
|
<option value="anytls">AnyTLS · TCP / sing-box</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>节点名称</span>
|
<span>节点名称</span>
|
||||||
<Input
|
<Input
|
||||||
@@ -163,10 +174,10 @@ export default async function CertificatesPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<p className="muted full-span">
|
<p className="muted full-span">
|
||||||
脚本适用于使用 systemd 的 Linux(x86_64 / ARM64 / ARMv7),以 root
|
使用 systemd 的 Linux,以 root 执行。域名可输入 jp1,自动补全
|
||||||
执行。运行时输入 jp1 等域名前缀(自动补全 .p.xn--876a.net)、HY2 UDP 和统计 API TCP
|
.p.xn--876a.net。 HY2 输入 UDP 和统计 TCP 端口;AnyTLS(x86_64 /
|
||||||
端口;支持 NAT 内外端口不同。域名用于节点地址和
|
ARM64)只需代理 TCP 端口,管理接口仅监听本机。 支持 NAT
|
||||||
SNI,请预先解析到服务器公网 IP。证书须覆盖 *.p.xn--876a.net。
|
内外端口不同。请先配置 DNS,证书须覆盖 *.p.xn--876a.net。
|
||||||
</p>
|
</p>
|
||||||
<SubmitButton disabled={certificates.length === 0}>
|
<SubmitButton disabled={certificates.length === 0}>
|
||||||
生成安装脚本
|
生成安装脚本
|
||||||
@@ -187,8 +198,9 @@ export default async function CertificatesPage() {
|
|||||||
<p className="muted">
|
<p className="muted">
|
||||||
下载后执行 sudo bash install-节点Slug.sh。脚本含有效期 24
|
下载后执行 sudo bash install-节点Slug.sh。脚本含有效期 24
|
||||||
小时的安装凭据,请妥善保管;安装成功后自动失效。也支持 --domain
|
小时的安装凭据,请妥善保管;安装成功后自动失效。也支持 --domain
|
||||||
jp1.p.xn--876a.net --hy2-port 端口 --stats-port
|
jp1.p.xn--876a.net --hy2-port 端口 --stats-port 端口。AnyTLS 参数为
|
||||||
端口。重试需使用相同域名和端口。删除记录不删除已注册节点。
|
--domain jp1 --port 端口,可加 --listen-port
|
||||||
|
设置本机端口。重试需使用相同域名和端口。删除记录不删除已注册节点。
|
||||||
</p>
|
</p>
|
||||||
<div className="provision-list">
|
<div className="provision-list">
|
||||||
{installations.length === 0 ? (
|
{installations.length === 0 ? (
|
||||||
@@ -212,6 +224,7 @@ export default async function CertificatesPage() {
|
|||||||
<div>
|
<div>
|
||||||
<strong>{job.name}</strong>
|
<strong>{job.name}</strong>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
|
{job.protocol === "anytls" ? "AnyTLS" : "HY2"} ·{" "}
|
||||||
{job.slug} · {job.server_host || "域名在脚本中设置"} ·{" "}
|
{job.slug} · {job.server_host || "域名在脚本中设置"} ·{" "}
|
||||||
{job.certificate_name}
|
{job.certificate_name}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import { DashboardCharts } from "./dashboard-charts";
|
|||||||
|
|
||||||
function getNodePill(node: {
|
function getNodePill(node: {
|
||||||
enabled: number;
|
enabled: number;
|
||||||
node_type: "hy2" | "custom";
|
node_type: "hy2" | "custom" | "anytls";
|
||||||
last_error_message: string | null;
|
last_error_message: string | null;
|
||||||
|
last_sync_ok_at: string | null;
|
||||||
}) {
|
}) {
|
||||||
if (!node.enabled) {
|
if (!node.enabled) {
|
||||||
return { label: "已禁用", className: "status-pill status-pill--off" };
|
return { label: "已禁用", className: "status-pill status-pill--off" };
|
||||||
@@ -17,6 +18,13 @@ function getNodePill(node: {
|
|||||||
if (node.node_type === "custom")
|
if (node.node_type === "custom")
|
||||||
return { label: "自定义订阅", className: "status-pill status-pill--ink" };
|
return { label: "自定义订阅", className: "status-pill status-pill--ink" };
|
||||||
|
|
||||||
|
if (
|
||||||
|
node.node_type === "anytls" &&
|
||||||
|
(!node.last_sync_ok_at ||
|
||||||
|
Date.parse(node.last_sync_ok_at) < new Date().getTime() - 90_000)
|
||||||
|
)
|
||||||
|
return { label: "心跳超时", className: "status-pill status-pill--danger" };
|
||||||
|
|
||||||
if (node.last_error_message) {
|
if (node.last_error_message) {
|
||||||
return { label: "同步异常", className: "status-pill status-pill--danger" };
|
return { label: "同步异常", className: "status-pill status-pill--danger" };
|
||||||
}
|
}
|
||||||
@@ -70,7 +78,9 @@ export default async function DashboardPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<DashboardCharts
|
<DashboardCharts
|
||||||
nodeHealth={data.nodeHealth.filter((node) => node.node_type === "hy2")}
|
nodeHealth={data.nodeHealth.filter(
|
||||||
|
(node) => node.node_type !== "custom",
|
||||||
|
)}
|
||||||
trafficTrend={data.trafficTrend}
|
trafficTrend={data.trafficTrend}
|
||||||
trafficBuckets={data.trafficBuckets}
|
trafficBuckets={data.trafficBuckets}
|
||||||
trafficUserOptions={data.trafficUserOptions}
|
trafficUserOptions={data.trafficUserOptions}
|
||||||
@@ -109,9 +119,11 @@ export default async function DashboardPage() {
|
|||||||
<p className="muted">
|
<p className="muted">
|
||||||
{node.node_type === "custom"
|
{node.node_type === "custom"
|
||||||
? "仅订阅 · 不参与轮询"
|
? "仅订阅 · 不参与轮询"
|
||||||
|
: node.node_type === "anytls"
|
||||||
|
? `AnyTLS · 流 ${node.last_stream_count}`
|
||||||
: `在线 ${node.last_online_users} / 流 ${node.last_stream_count}`}
|
: `在线 ${node.last_online_users} / 流 ${node.last_stream_count}`}
|
||||||
</p>
|
</p>
|
||||||
{node.node_type === "hy2" && (
|
{node.node_type !== "custom" && (
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
最近成功 {formatLocalDateTime(node.last_sync_ok_at)}
|
最近成功 {formatLocalDateTime(node.last_sync_ok_at)}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from "@/lib/custom-proxy";
|
} from "@/lib/custom-proxy";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
import { ensureDb } from "@/lib/db";
|
||||||
import { requireAdminAccess } from "@/lib/session";
|
import { requireAdminAccess } from "@/lib/session";
|
||||||
import {
|
import {
|
||||||
createNode,
|
createNode,
|
||||||
@@ -115,3 +116,23 @@ export async function deleteNodeAction(formData: FormData) {
|
|||||||
revalidatePath("/admin/subscriptions");
|
revalidatePath("/admin/subscriptions");
|
||||||
revalidatePath("/admin/dashboard");
|
revalidatePath("/admin/dashboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateAnyTLSNodeAction(data: FormData) {
|
||||||
|
await requireAdminAccess();
|
||||||
|
const name = String(data.get("name") || "").trim();
|
||||||
|
if (!name || name.length > 100)
|
||||||
|
return { error: "请填写 1–100 字的节点名称。" };
|
||||||
|
const db = await ensureDb();
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE hy2_nodes SET name=?,client_name=?,enabled=?,updated_at=? WHERE id=? AND node_type='anytls'",
|
||||||
|
).run(
|
||||||
|
name,
|
||||||
|
name,
|
||||||
|
data.get("enabled") === "on" ? 1 : 0,
|
||||||
|
new Date().toISOString(),
|
||||||
|
Number(data.get("id")),
|
||||||
|
);
|
||||||
|
revalidatePath("/admin/nodes");
|
||||||
|
revalidatePath("/admin/subscriptions");
|
||||||
|
revalidatePath("/admin/dashboard");
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,14 +17,16 @@ import {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
createNodeAction,
|
createNodeAction,
|
||||||
|
updateAnyTLSNodeAction,
|
||||||
deleteNodeAction,
|
deleteNodeAction,
|
||||||
updateNodeAction,
|
updateNodeAction,
|
||||||
} from "./actions";
|
} from "./actions";
|
||||||
|
|
||||||
function getNodePills(node: {
|
function getNodePills(node: {
|
||||||
enabled: number;
|
enabled: number;
|
||||||
node_type: "hy2" | "custom";
|
node_type: "hy2" | "custom" | "anytls";
|
||||||
last_error_message: string | null;
|
last_error_message: string | null;
|
||||||
|
last_sync_ok_at: string | null;
|
||||||
}) {
|
}) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -33,6 +35,10 @@ function getNodePills(node: {
|
|||||||
},
|
},
|
||||||
node.node_type === "custom"
|
node.node_type === "custom"
|
||||||
? { label: "自定义 YAML", className: "status-pill status-pill--ink" }
|
? { label: "自定义 YAML", className: "status-pill status-pill--ink" }
|
||||||
|
: node.node_type === "anytls" &&
|
||||||
|
(!node.last_sync_ok_at ||
|
||||||
|
Date.parse(node.last_sync_ok_at) < new Date().getTime() - 90_000)
|
||||||
|
? { label: "心跳超时", className: "status-pill status-pill--danger" }
|
||||||
: node.last_error_message
|
: node.last_error_message
|
||||||
? { label: "同步异常", className: "status-pill status-pill--danger" }
|
? { label: "同步异常", className: "status-pill status-pill--danger" }
|
||||||
: { label: "同步正常", className: "status-pill status-pill--sage" },
|
: { label: "同步正常", className: "status-pill status-pill--sage" },
|
||||||
@@ -48,7 +54,12 @@ 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>
|
<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}
|
||||||
@@ -216,6 +227,7 @@ export default async function NodesPage() {
|
|||||||
}
|
}
|
||||||
items={nodes.map((node) => {
|
items={nodes.map((node) => {
|
||||||
const isCustom = node.node_type === "custom";
|
const isCustom = node.node_type === "custom";
|
||||||
|
const isAnyTLS = node.node_type === "anytls";
|
||||||
const proxyName = getNodeProxyName(node);
|
const proxyName = getNodeProxyName(node);
|
||||||
const authUrl = buildNodeAuthUrl(
|
const authUrl = buildNodeAuthUrl(
|
||||||
node.slug,
|
node.slug,
|
||||||
@@ -229,7 +241,7 @@ export default async function NodesPage() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: node.id,
|
id: node.id,
|
||||||
text: `${node.name} ${node.slug} ${node.server_host} ${proxyName} ${isCustom ? "自定义 YAML custom" : "HY2"}`,
|
text: `${node.name} ${node.slug} ${node.server_host} ${proxyName} ${isCustom ? "自定义 YAML custom" : isAnyTLS ? "AnyTLS sing-box TCP" : "HY2"}`,
|
||||||
status: node.enabled ? "enabled" : "disabled",
|
status: node.enabled ? "enabled" : "disabled",
|
||||||
content: (
|
content: (
|
||||||
<details key={node.id} className="entity-table__item">
|
<details key={node.id} className="entity-table__item">
|
||||||
@@ -250,20 +262,30 @@ export default async function NodesPage() {
|
|||||||
? proxyName
|
? proxyName
|
||||||
: `${node.server_host}:${node.server_port}`}
|
: `${node.server_host}:${node.server_port}`}
|
||||||
<small>
|
<small>
|
||||||
{isCustom ? "自定义订阅节点" : `hy2 ${node.hy2_listen}`}
|
{isCustom
|
||||||
|
? "自定义订阅节点"
|
||||||
|
: `${isAnyTLS ? "AnyTLS TCP" : "HY2 UDP"} ${node.hy2_listen}`}
|
||||||
</small>
|
</small>
|
||||||
</span>
|
</span>
|
||||||
<span className="entity-code-cell break-all">
|
<span className="entity-code-cell break-all">
|
||||||
{isCustom ? "不轮询" : node.traffic_stats_url}
|
{isCustom
|
||||||
|
? "不轮询"
|
||||||
|
: isAnyTLS
|
||||||
|
? "节点主动上报"
|
||||||
|
: node.traffic_stats_url}
|
||||||
<small>
|
<small>
|
||||||
{isCustom ? "仅订阅" : node.traffic_stats_listen}
|
{isCustom
|
||||||
|
? "仅订阅"
|
||||||
|
: isAnyTLS
|
||||||
|
? "管理接口仅本机"
|
||||||
|
: node.traffic_stats_listen}
|
||||||
</small>
|
</small>
|
||||||
</span>
|
</span>
|
||||||
<span className="entity-number-cell">
|
<span className="entity-number-cell">
|
||||||
<strong>
|
<strong>
|
||||||
{isCustom
|
{isCustom
|
||||||
? "—"
|
? "—"
|
||||||
: `${node.last_online_users} / ${node.last_stream_count}`}
|
: `${isAnyTLS ? "—" : node.last_online_users} / ${node.last_stream_count}`}
|
||||||
</strong>
|
</strong>
|
||||||
<span>{isCustom ? "无流量统计" : "用户 / 流"}</span>
|
<span>{isCustom ? "无流量统计" : "用户 / 流"}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -285,6 +307,47 @@ export default async function NodesPage() {
|
|||||||
<div className="entity-table__details">
|
<div className="entity-table__details">
|
||||||
{isCustom ? (
|
{isCustom ? (
|
||||||
<CustomNodeForm node={node} />
|
<CustomNodeForm node={node} />
|
||||||
|
) : isAnyTLS ? (
|
||||||
|
<>
|
||||||
|
<p className="muted">
|
||||||
|
AnyTLS / sing-box · 每用户独立凭据,约 15
|
||||||
|
秒同步。用户增删、密码、到期或额度变更会更新配置并重启
|
||||||
|
sing-box,现有连接会断开。连续失联约 90
|
||||||
|
秒后停止代理。在线用户数暂不提供,流数量来自本机接口。
|
||||||
|
</p>
|
||||||
|
<p className="muted">
|
||||||
|
{!node.last_sync_ok_at ||
|
||||||
|
Date.parse(node.last_sync_ok_at) <
|
||||||
|
new Date().getTime() - 90_000
|
||||||
|
? "节点未连接或心跳已超时"
|
||||||
|
: "节点心跳正常"}{" "}
|
||||||
|
· SNI:{node.sni}
|
||||||
|
</p>
|
||||||
|
<ActionForm
|
||||||
|
action={updateAnyTLSNodeAction}
|
||||||
|
className="dense-form-grid dense-form-grid--nodes"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={node.id} />
|
||||||
|
<label className="field">
|
||||||
|
<span>节点名称 / 订阅名</span>
|
||||||
|
<Input
|
||||||
|
name="name"
|
||||||
|
defaultValue={node.name}
|
||||||
|
required
|
||||||
|
maxLength={100}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="checkbox">
|
||||||
|
<input
|
||||||
|
name="enabled"
|
||||||
|
type="checkbox"
|
||||||
|
defaultChecked={Boolean(node.enabled)}
|
||||||
|
/>
|
||||||
|
<span>启用节点</span>
|
||||||
|
</label>
|
||||||
|
<SubmitButton>保存节点</SubmitButton>
|
||||||
|
</ActionForm>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="dense-code-grid">
|
<div className="dense-code-grid">
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { syncAnyTLSNode } from "@/lib/node-agent";
|
||||||
|
import { ProvisionError } from "@/lib/certificates";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
context: { params: Promise<{ slug: string }> },
|
||||||
|
) {
|
||||||
|
const headers = { "Cache-Control": "no-store" };
|
||||||
|
try {
|
||||||
|
const reader = request.body?.getReader();
|
||||||
|
if (!reader) throw new ProvisionError("缺少报告。");
|
||||||
|
let length = 0;
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
length += value.byteLength;
|
||||||
|
if (length > 2 * 1024 * 1024) {
|
||||||
|
await reader.cancel();
|
||||||
|
return new Response(null, { status: 413, headers });
|
||||||
|
}
|
||||||
|
chunks.push(value);
|
||||||
|
}
|
||||||
|
const payload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||||
|
return Response.json(
|
||||||
|
await syncAnyTLSNode(
|
||||||
|
(await context.params).slug,
|
||||||
|
request.headers.get("authorization")?.replace(/^Bearer /, "") ?? "",
|
||||||
|
payload,
|
||||||
|
),
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
error instanceof ProvisionError ? error.message : "节点同步失败。",
|
||||||
|
},
|
||||||
|
{ status: error instanceof ProvisionError ? 400 : 500, headers },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { SING_BOX_VERSION } from "@/lib/anytls";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
context: { params: Promise<{ arch: string }> },
|
||||||
|
) {
|
||||||
|
const { arch } = await context.params;
|
||||||
|
if (!["amd64", "arm64"].includes(arch))
|
||||||
|
return new Response(null, { status: 404 });
|
||||||
|
try {
|
||||||
|
const bytes = await readFile(
|
||||||
|
path.join(
|
||||||
|
process.cwd(),
|
||||||
|
"data/artifacts",
|
||||||
|
`sing-box-${SING_BOX_VERSION}-linux-${arch}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const checksum = createHash("sha256").update(bytes).digest("hex");
|
||||||
|
if (new URL(request.url).searchParams.has("sha256"))
|
||||||
|
return new Response(checksum, {
|
||||||
|
headers: { "Cache-Control": "no-store" },
|
||||||
|
});
|
||||||
|
return new Response(bytes, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"X-Content-SHA256": checksum,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return new Response(
|
||||||
|
"尚未构建节点程序,请运行 scripts/build-sing-box.sh。",
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { createHmac } from "node:crypto";
|
||||||
|
import { decryptText } from "@/lib/crypto";
|
||||||
|
import { getEnv } from "@/lib/env";
|
||||||
|
import type { Hy2UserRow, Hy2NodeRow } from "@/lib/store";
|
||||||
|
|
||||||
|
export const SING_BOX_VERSION = "1.14.2";
|
||||||
|
export function anytlsPassword(
|
||||||
|
user: Pick<Hy2UserRow, "auth_id" | "auth_secret_encrypted">,
|
||||||
|
) {
|
||||||
|
const secret = decryptText(user.auth_secret_encrypted);
|
||||||
|
if (!secret) throw new Error("user_secret_unavailable");
|
||||||
|
return createHmac("sha256", getEnv().SESSION_SECRET)
|
||||||
|
.update(`anytls\0${user.auth_id}\0${secret}`)
|
||||||
|
.digest("base64url");
|
||||||
|
}
|
||||||
|
export function buildSingBoxConfig(node: Hy2NodeRow, users: Hy2UserRow[]) {
|
||||||
|
return {
|
||||||
|
log: { level: "warn", timestamp: true },
|
||||||
|
inbounds: [
|
||||||
|
{
|
||||||
|
type: "anytls",
|
||||||
|
tag: "anytls-in",
|
||||||
|
listen: "::",
|
||||||
|
listen_port: Number(node.hy2_listen.replace(/^:/, "")),
|
||||||
|
users: users.map((u) => ({
|
||||||
|
name: u.auth_id,
|
||||||
|
password: anytlsPassword(u),
|
||||||
|
})),
|
||||||
|
tls: {
|
||||||
|
enabled: true,
|
||||||
|
certificate_path: "/etc/hy2-panel-anytls/server.crt",
|
||||||
|
key_path: "/etc/hy2-panel-anytls/server.key",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
outbounds: [{ type: "direct", tag: "direct" }],
|
||||||
|
experimental: {
|
||||||
|
v2ray_api: {
|
||||||
|
listen: node.traffic_stats_listen,
|
||||||
|
stats: { enabled: true, users: users.map((u) => u.auth_id) },
|
||||||
|
},
|
||||||
|
clash_api: {
|
||||||
|
external_controller: new URL(node.traffic_stats_url).host,
|
||||||
|
secret: node.traffic_stats_secret,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -199,6 +199,17 @@ const MIGRATIONS: (string | ((db: Database.Database) => void))[] = [
|
|||||||
node_id INTEGER REFERENCES hy2_nodes(id) ON DELETE CASCADE
|
node_id INTEGER REFERENCES hy2_nodes(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
`,
|
`,
|
||||||
|
`
|
||||||
|
ALTER TABLE node_installations ADD COLUMN protocol TEXT NOT NULL DEFAULT 'hy2';
|
||||||
|
CREATE TABLE node_agent_counters (
|
||||||
|
node_id INTEGER NOT NULL REFERENCES hy2_nodes(id) ON DELETE CASCADE,
|
||||||
|
epoch TEXT NOT NULL,
|
||||||
|
auth_id TEXT NOT NULL,
|
||||||
|
tx INTEGER NOT NULL DEFAULT 0,
|
||||||
|
rx INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY(node_id, epoch, auth_id)
|
||||||
|
);
|
||||||
|
`,
|
||||||
];
|
];
|
||||||
|
|
||||||
export function migrateDatabase(db: Database.Database) {
|
export function migrateDatabase(db: Database.Database) {
|
||||||
|
|||||||
+10
-2
@@ -1,3 +1,4 @@
|
|||||||
|
import { SING_BOX_VERSION } from "@/lib/anytls";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { decryptText } from "@/lib/crypto";
|
import { decryptText } from "@/lib/crypto";
|
||||||
@@ -11,11 +12,18 @@ export function buildInstallScript(job: Installation) {
|
|||||||
TOKEN: token,
|
TOKEN: token,
|
||||||
INSTALL_ID: job.id,
|
INSTALL_ID: job.id,
|
||||||
SLUG: job.slug,
|
SLUG: job.slug,
|
||||||
|
SING_BOX_VERSION,
|
||||||
};
|
};
|
||||||
return readFileSync(
|
return readFileSync(
|
||||||
path.join(process.cwd(), "lib/templates/install-node.sh"),
|
path.join(
|
||||||
|
process.cwd(),
|
||||||
|
"lib", "templates",
|
||||||
|
job.protocol === "anytls"
|
||||||
|
? "install-anytls.sh"
|
||||||
|
: "install-node.sh",
|
||||||
|
),
|
||||||
"utf8",
|
"utf8",
|
||||||
).replace(/__(PANEL|TOKEN|INSTALL_ID|SLUG)__/g, (_, key) =>
|
).replace(/__(PANEL|TOKEN|INSTALL_ID|SLUG|SING_BOX_VERSION)__/g, (_, key) =>
|
||||||
quote(values[key]),
|
quote(values[key]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { ensureDb } from "@/lib/db";
|
||||||
|
import { ProvisionError } from "@/lib/certificates";
|
||||||
|
import {
|
||||||
|
applyNodeSync,
|
||||||
|
getUserLifecycleState,
|
||||||
|
type Hy2NodeRow,
|
||||||
|
type Hy2UserRow,
|
||||||
|
} from "@/lib/store";
|
||||||
|
import { buildSingBoxConfig } from "@/lib/anytls";
|
||||||
|
|
||||||
|
const counter = z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.nonnegative()
|
||||||
|
.max(Number.MAX_SAFE_INTEGER / 2);
|
||||||
|
export const reportSchema = z
|
||||||
|
.object({
|
||||||
|
epoch: z.string().regex(/^[a-zA-Z0-9:._-]{1,160}$/),
|
||||||
|
traffic: z
|
||||||
|
.record(
|
||||||
|
z.string().min(1).max(128),
|
||||||
|
z.object({ tx: counter, rx: counter }).strict(),
|
||||||
|
)
|
||||||
|
.refine((v) => Object.keys(v).length <= 10000),
|
||||||
|
streamCount: z.number().int().min(0).max(1000000),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
export function eligibleAnyTLSUsers(users: Hy2UserRow[]) {
|
||||||
|
return users.filter(
|
||||||
|
(u) =>
|
||||||
|
(u.is_admin ? Boolean(u.enabled) : getUserLifecycleState(u).ok) &&
|
||||||
|
u.auth_secret_encrypted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export async function syncAnyTLSNode(
|
||||||
|
slug: string,
|
||||||
|
token: string,
|
||||||
|
input: unknown,
|
||||||
|
) {
|
||||||
|
const data = reportSchema.safeParse(input);
|
||||||
|
if (!data.success) throw new ProvisionError("无效节点报告。");
|
||||||
|
const db = await ensureDb();
|
||||||
|
return db
|
||||||
|
.transaction(() => {
|
||||||
|
const node = db
|
||||||
|
.prepare("SELECT * FROM hy2_nodes WHERE slug=? AND node_type='anytls'")
|
||||||
|
.get(slug) as Hy2NodeRow | undefined;
|
||||||
|
if (
|
||||||
|
!/^[a-f0-9]{64}$/.test(token) ||
|
||||||
|
!node ||
|
||||||
|
token.length !== node.auth_token.length ||
|
||||||
|
!timingSafeEqual(Buffer.from(token), Buffer.from(node.auth_token))
|
||||||
|
)
|
||||||
|
throw new ProvisionError("节点凭据无效。");
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const traffic: Record<string, { tx: number; rx: number }> = {};
|
||||||
|
const lookup = db.prepare(
|
||||||
|
"SELECT tx,rx FROM node_agent_counters WHERE node_id=? AND epoch=? AND auth_id=?",
|
||||||
|
);
|
||||||
|
const save =
|
||||||
|
db.prepare(`INSERT INTO node_agent_counters(node_id,epoch,auth_id,tx,rx) VALUES (?,?,?,?,?)
|
||||||
|
ON CONFLICT(node_id,epoch,auth_id) DO UPDATE SET tx=MAX(tx,excluded.tx),rx=MAX(rx,excluded.rx)`);
|
||||||
|
for (const [authId, counters] of Object.entries(data.data.traffic)) {
|
||||||
|
if (!db.prepare("SELECT 1 FROM hy2_users WHERE auth_id=?").get(authId))
|
||||||
|
continue;
|
||||||
|
const previous = lookup.get(node.id, data.data.epoch, authId) as
|
||||||
|
| { tx: number; rx: number }
|
||||||
|
| undefined;
|
||||||
|
const tx = Math.max(0, counters.tx - (previous?.tx ?? 0));
|
||||||
|
const rx = Math.max(0, counters.rx - (previous?.rx ?? 0));
|
||||||
|
if (tx || rx) traffic[authId] = { tx, rx };
|
||||||
|
save.run(node.id, data.data.epoch, authId, counters.tx, counters.rx);
|
||||||
|
}
|
||||||
|
applyNodeSync(db, {
|
||||||
|
node,
|
||||||
|
startedAt: node.last_sync_ok_at || now,
|
||||||
|
finishedAt: now,
|
||||||
|
traffic,
|
||||||
|
online: {},
|
||||||
|
streamCount: data.data.streamCount,
|
||||||
|
kickedAuthIds: [],
|
||||||
|
});
|
||||||
|
const users = node.enabled
|
||||||
|
? eligibleAnyTLSUsers(
|
||||||
|
db
|
||||||
|
.prepare("SELECT * FROM hy2_users ORDER BY id")
|
||||||
|
.all() as Hy2UserRow[],
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const expires = users
|
||||||
|
.filter((u) => !u.is_admin && u.expires_at)
|
||||||
|
.map((u) => Date.parse(u.expires_at!));
|
||||||
|
const leaseSeconds = Math.max(
|
||||||
|
1,
|
||||||
|
Math.min(
|
||||||
|
90,
|
||||||
|
...expires.map((v) => Math.floor((v - Date.now()) / 1000)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
config: buildSingBoxConfig(node, users),
|
||||||
|
leaseSeconds,
|
||||||
|
intervalSeconds: 15,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.immediate();
|
||||||
|
}
|
||||||
+68
-10
@@ -1,3 +1,7 @@
|
|||||||
|
import { buildSingBoxConfig } from "@/lib/anytls";
|
||||||
|
import { eligibleAnyTLSUsers } from "@/lib/node-agent";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
import { randomBytes, randomUUID, createHash } from "node:crypto";
|
import { randomBytes, randomUUID, createHash } from "node:crypto";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { stringify } from "yaml";
|
import { stringify } from "yaml";
|
||||||
@@ -9,7 +13,7 @@ import {
|
|||||||
validateCertificate,
|
validateCertificate,
|
||||||
type ManagedCertificate,
|
type ManagedCertificate,
|
||||||
} from "@/lib/certificates";
|
} from "@/lib/certificates";
|
||||||
import type { Hy2NodeRow } from "@/lib/store";
|
import type { Hy2NodeRow, Hy2UserRow } from "@/lib/store";
|
||||||
|
|
||||||
export const portSchema = z
|
export const portSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -22,13 +26,27 @@ export const portSchema = z
|
|||||||
statsListenPort: z.number().int().min(1).max(65535),
|
statsListenPort: z.number().int().min(1).max(65535),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
export const anytlsPortSchema = z
|
||||||
|
.object({
|
||||||
|
domain: portSchema.shape.domain,
|
||||||
|
port: portSchema.shape.hy2Port,
|
||||||
|
listenPort: portSchema.shape.hy2Port,
|
||||||
|
statsPort: portSchema.shape.hy2Port,
|
||||||
|
controlPort: portSchema.shape.hy2Port,
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.refine(
|
||||||
|
(v) => new Set([v.listenPort, v.statsPort, v.controlPort]).size === 3,
|
||||||
|
);
|
||||||
export const installationSchema = z.object({
|
export const installationSchema = z.object({
|
||||||
slug: z.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/),
|
slug: z.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/),
|
||||||
name: z.string().trim().min(1).max(100),
|
name: z.string().trim().min(1).max(100),
|
||||||
|
protocol: z.enum(["hy2", "anytls"]).optional(),
|
||||||
certificateId: z.coerce.number().int().positive(),
|
certificateId: z.coerce.number().int().positive(),
|
||||||
});
|
});
|
||||||
export type Installation = {
|
export type Installation = {
|
||||||
id: string;
|
id: string;
|
||||||
|
protocol?: "hy2" | "anytls";
|
||||||
token_hash: string;
|
token_hash: string;
|
||||||
token_encrypted: string;
|
token_encrypted: string;
|
||||||
certificate_id: number;
|
certificate_id: number;
|
||||||
@@ -91,8 +109,8 @@ export async function createInstallation(
|
|||||||
);
|
);
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO node_installations
|
`INSERT INTO node_installations
|
||||||
(id,token_hash,token_encrypted,certificate_id,slug,name,server_host,sni,origin,expires_at,created_at)
|
(id,token_hash,token_encrypted,certificate_id,slug,name,server_host,sni,origin,expires_at,created_at,protocol)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
).run(
|
).run(
|
||||||
id,
|
id,
|
||||||
hash(token),
|
hash(token),
|
||||||
@@ -105,6 +123,7 @@ export async function createInstallation(
|
|||||||
origin.origin,
|
origin.origin,
|
||||||
new Date(Date.now() + 24 * 3600_000).toISOString(),
|
new Date(Date.now() + 24 * 3600_000).toISOString(),
|
||||||
now,
|
now,
|
||||||
|
input.protocol || "hy2",
|
||||||
);
|
);
|
||||||
}).immediate();
|
}).immediate();
|
||||||
return id;
|
return id;
|
||||||
@@ -114,7 +133,7 @@ export async function listInstallations() {
|
|||||||
const db = await ensureDb();
|
const db = await ensureDb();
|
||||||
return db
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT id,slug,name,server_host,sni,expires_at,created_at,revoked_at,completed_at,node_id,
|
`SELECT id,protocol,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
|
(SELECT name FROM managed_certificates WHERE id=certificate_id) AS certificate_name
|
||||||
FROM node_installations ORDER BY created_at DESC`,
|
FROM node_installations ORDER BY created_at DESC`,
|
||||||
)
|
)
|
||||||
@@ -182,12 +201,25 @@ export async function provisionNode(
|
|||||||
}
|
}
|
||||||
if (job.completed_at)
|
if (job.completed_at)
|
||||||
throw new ProvisionError("该凭据已完成安装,不能再次获取私钥。");
|
throw new ProvisionError("该凭据已完成安装,不能再次获取私钥。");
|
||||||
const parsed = portSchema.safeParse(rawPorts);
|
const parsed =
|
||||||
|
job.protocol === "anytls"
|
||||||
|
? anytlsPortSchema.safeParse(rawPorts)
|
||||||
|
: portSchema.safeParse(rawPorts);
|
||||||
if (!parsed.success)
|
if (!parsed.success)
|
||||||
throw new ProvisionError(
|
throw new ProvisionError(
|
||||||
"域名须为 .p.xn--876a.net 下的单级子域名,端口必须为 1–65535 的整数。",
|
"域名须为 .p.xn--876a.net 下的单级子域名,端口必须为 1–65535 的整数。",
|
||||||
);
|
);
|
||||||
const ports = parsed.data,
|
const ports =
|
||||||
|
"listenPort" in parsed.data
|
||||||
|
? {
|
||||||
|
domain: parsed.data.domain,
|
||||||
|
hy2Port: parsed.data.port,
|
||||||
|
hy2ListenPort: parsed.data.listenPort,
|
||||||
|
statsPort: parsed.data.statsPort,
|
||||||
|
statsListenPort: parsed.data.statsPort,
|
||||||
|
controlPort: parsed.data.controlPort,
|
||||||
|
}
|
||||||
|
: { ...parsed.data, controlPort: 0 },
|
||||||
portsJson = JSON.stringify(ports);
|
portsJson = JSON.stringify(ports);
|
||||||
if (job.ports_json && job.ports_json !== portsJson)
|
if (job.ports_json && job.ports_json !== portsJson)
|
||||||
throw new ProvisionError(
|
throw new ProvisionError(
|
||||||
@@ -209,14 +241,16 @@ export async function provisionNode(
|
|||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO hy2_nodes
|
`INSERT INTO hy2_nodes
|
||||||
(slug,name,auth_token,traffic_stats_url,traffic_stats_secret,enabled,created_at,updated_at,
|
(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)
|
server_host,server_port,client_name,sni,hy2_listen,traffic_stats_listen,node_type)
|
||||||
VALUES (?,?,?,?,?,0,?,?,?,?,?,?,?,?)`,
|
VALUES (?,?,?,?,?,0,?,?,?,?,?,?,?,?,?)`,
|
||||||
)
|
)
|
||||||
.run(
|
.run(
|
||||||
job.slug,
|
job.slug,
|
||||||
job.name,
|
job.name,
|
||||||
randomBytes(32).toString("hex"),
|
randomBytes(32).toString("hex"),
|
||||||
`http://${host}:${ports.statsPort}`,
|
job.protocol === "anytls"
|
||||||
|
? `http://127.0.0.1:${ports.controlPort}`
|
||||||
|
: `http://${host}:${ports.statsPort}`,
|
||||||
randomBytes(32).toString("hex"),
|
randomBytes(32).toString("hex"),
|
||||||
now,
|
now,
|
||||||
now,
|
now,
|
||||||
@@ -225,7 +259,10 @@ export async function provisionNode(
|
|||||||
job.name,
|
job.name,
|
||||||
host,
|
host,
|
||||||
`:${ports.hy2ListenPort}`,
|
`:${ports.hy2ListenPort}`,
|
||||||
`:${ports.statsListenPort}`,
|
job.protocol === "anytls"
|
||||||
|
? `127.0.0.1:${ports.statsPort}`
|
||||||
|
: `:${ports.statsListenPort}`,
|
||||||
|
job.protocol || "hy2",
|
||||||
).lastInsertRowid,
|
).lastInsertRowid,
|
||||||
);
|
);
|
||||||
db.prepare(
|
db.prepare(
|
||||||
@@ -235,6 +272,27 @@ export async function provisionNode(
|
|||||||
const node = db
|
const node = db
|
||||||
.prepare("SELECT * FROM hy2_nodes WHERE id=?")
|
.prepare("SELECT * FROM hy2_nodes WHERE id=?")
|
||||||
.get(nodeId) as Hy2NodeRow;
|
.get(nodeId) as Hy2NodeRow;
|
||||||
|
if (job.protocol === "anytls") {
|
||||||
|
const users = eligibleAnyTLSUsers(
|
||||||
|
db
|
||||||
|
.prepare("SELECT * FROM hy2_users ORDER BY id")
|
||||||
|
.all() as Hy2UserRow[],
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
config: JSON.stringify(buildSingBoxConfig(node, users), null, 2),
|
||||||
|
certificate: cert.certificate_pem,
|
||||||
|
privateKey: key,
|
||||||
|
agent: { panel: job.origin, slug: job.slug, token: node.auth_token },
|
||||||
|
agentSource: readFileSync(
|
||||||
|
path.join(process.cwd(), "lib/templates/anytls-agent.py"),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
statsProto: readFileSync(
|
||||||
|
path.join(process.cwd(), "lib/templates/stats.proto"),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
const config = stringify({
|
const config = stringify({
|
||||||
listen: node.hy2_listen,
|
listen: node.hy2_listen,
|
||||||
tls: {
|
tls: {
|
||||||
|
|||||||
+2
-2
@@ -32,7 +32,7 @@ export type Hy2UserRow = {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NodeType = "hy2" | "custom";
|
export type NodeType = "hy2" | "custom" | "anytls";
|
||||||
|
|
||||||
export type Hy2NodeRow = {
|
export type Hy2NodeRow = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -141,7 +141,7 @@ export function buildTrafficStatsUrl(serverHost: string, listen: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeNodeType(value: string | null | undefined): NodeType {
|
export function normalizeNodeType(value: string | null | undefined): NodeType {
|
||||||
return value === "custom" ? "custom" : "hy2";
|
return value === "custom" ? "custom" : value === "anytls" ? "anytls" : "hy2";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNodeProxyName(node: {
|
export function getNodeProxyName(node: {
|
||||||
|
|||||||
+18
-2
@@ -1,3 +1,4 @@
|
|||||||
|
import { anytlsPassword } from "@/lib/anytls";
|
||||||
import { renderCustomProxyYaml } from "@/lib/custom-proxy";
|
import { renderCustomProxyYaml } from "@/lib/custom-proxy";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -52,11 +53,26 @@ function renderHy2Proxy(node: Hy2NodeRow, authString: string) {
|
|||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSubscriptionProxy(node: Hy2NodeRow, authString: string) {
|
function renderSubscriptionProxy(
|
||||||
|
node: Hy2NodeRow,
|
||||||
|
authString: string,
|
||||||
|
user: Hy2UserRow,
|
||||||
|
) {
|
||||||
if (node.node_type === "custom") {
|
if (node.node_type === "custom") {
|
||||||
return renderCustomProxyYaml(node.custom_config_yaml);
|
return renderCustomProxyYaml(node.custom_config_yaml);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (node.node_type === "anytls")
|
||||||
|
return [
|
||||||
|
`- name: ${JSON.stringify(node.client_name || node.name)}`,
|
||||||
|
" type: anytls",
|
||||||
|
` server: ${JSON.stringify(node.server_host)}`,
|
||||||
|
` port: ${node.server_port}`,
|
||||||
|
` password: ${JSON.stringify(anytlsPassword(user))}`,
|
||||||
|
` sni: ${JSON.stringify(node.sni || node.server_host)}`,
|
||||||
|
" skip-cert-verify: false",
|
||||||
|
" udp: true",
|
||||||
|
].join("\n");
|
||||||
return renderHy2Proxy(node, authString);
|
return renderHy2Proxy(node, authString);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,7 +227,7 @@ export function buildSubscriptionConfig(input: {
|
|||||||
);
|
);
|
||||||
const proxyNames = orderedEligibleNodes.map((node) => getNodeProxyName(node));
|
const proxyNames = orderedEligibleNodes.map((node) => getNodeProxyName(node));
|
||||||
const proxiesBlock = orderedEligibleNodes
|
const proxiesBlock = orderedEligibleNodes
|
||||||
.map((node) => renderSubscriptionProxy(node, authString))
|
.map((node) => renderSubscriptionProxy(node, authString, input.user))
|
||||||
.join("\n");
|
.join("\n");
|
||||||
const defaultTemplate =
|
const defaultTemplate =
|
||||||
input.settings.template.trimEnd() || readDefaultTemplate();
|
input.settings.template.trimEnd() || readDefaultTemplate();
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""HY2 Panel AnyTLS agent. Local-only core APIs; HTTPS outbound control plane."""
|
||||||
|
import fcntl
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
import grpc
|
||||||
|
import stats_pb2
|
||||||
|
|
||||||
|
ROOT = Path('/etc/hy2-panel-anytls')
|
||||||
|
STATE = Path('/var/lib/hy2-panel-anytls')
|
||||||
|
BINARY = '/usr/local/lib/hy2-panel-anytls/sing-box'
|
||||||
|
UNIT = 'hy2-panel-anytls.service'
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_json(file, value):
|
||||||
|
temporary = file.with_suffix('.tmp')
|
||||||
|
with open(temporary, 'w') as handle:
|
||||||
|
os.chmod(temporary, 0o600)
|
||||||
|
json.dump(value, handle, sort_keys=True)
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
os.replace(temporary, file)
|
||||||
|
|
||||||
|
|
||||||
|
def core(command):
|
||||||
|
subprocess.run(['systemctl', command, UNIT], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
|
||||||
|
|
||||||
|
def read_json(file):
|
||||||
|
return json.loads(file.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def epoch():
|
||||||
|
pid = subprocess.check_output(['systemctl', 'show', UNIT, '-p', 'MainPID', '--value'], text=True).strip()
|
||||||
|
if pid == '0':
|
||||||
|
return None
|
||||||
|
# PID + boot ID + start ticks survives agent restarts without re-counting.
|
||||||
|
start = Path('/proc/' + pid + '/stat').read_text().rsplit(')', 1)[1].split()[19]
|
||||||
|
return Path('/proc/sys/kernel/random/boot_id').read_text().strip() + ':' + pid + ':' + start
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot():
|
||||||
|
current = epoch()
|
||||||
|
if current is None:
|
||||||
|
return None
|
||||||
|
config = read_json(ROOT / 'config.json')
|
||||||
|
experimental = config['experimental']
|
||||||
|
with grpc.insecure_channel(experimental['v2ray_api']['listen']) as channel:
|
||||||
|
query = channel.unary_unary(
|
||||||
|
'/v2ray.core.app.stats.command.StatsService/QueryStats',
|
||||||
|
request_serializer=stats_pb2.QueryStatsRequest.SerializeToString,
|
||||||
|
response_deserializer=stats_pb2.QueryStatsResponse.FromString)
|
||||||
|
result = query(stats_pb2.QueryStatsRequest(pattern='user>>>', reset=False), timeout=5)
|
||||||
|
traffic = {}
|
||||||
|
for stat in result.stat:
|
||||||
|
parts = stat.name.split('>>>')
|
||||||
|
if len(parts) == 4 and parts[0] == 'user' and parts[2] == 'traffic':
|
||||||
|
value = traffic.setdefault(parts[1], {'tx': 0, 'rx': 0})
|
||||||
|
# HY2 ledgers use tx=server->client, rx=client->server.
|
||||||
|
if parts[3] == 'uplink':
|
||||||
|
value['rx'] = int(stat.value)
|
||||||
|
elif parts[3] == 'downlink':
|
||||||
|
value['tx'] = int(stat.value)
|
||||||
|
clash = experimental['clash_api']
|
||||||
|
request = urllib.request.Request('http://' + clash['external_controller'] + '/connections',
|
||||||
|
headers={'Authorization': 'Bearer ' + clash['secret']})
|
||||||
|
with urllib.request.urlopen(request, timeout=5) as response:
|
||||||
|
streams = len(json.load(response).get('connections', []))
|
||||||
|
if epoch() != current:
|
||||||
|
raise RuntimeError('core_restarted_during_snapshot')
|
||||||
|
return {'epoch': current, 'traffic': traffic, 'streamCount': streams}
|
||||||
|
|
||||||
|
|
||||||
|
def remember(state):
|
||||||
|
sample = snapshot()
|
||||||
|
if sample:
|
||||||
|
state['pending'][sample['epoch']] = sample
|
||||||
|
atomic_json(STATE / 'state.json', state)
|
||||||
|
return sample
|
||||||
|
|
||||||
|
|
||||||
|
def sync(credentials, sample):
|
||||||
|
request = urllib.request.Request(credentials['panel'] + '/api/agent/' + credentials['slug'] + '/sync',
|
||||||
|
data=json.dumps(sample).encode(), method='POST',
|
||||||
|
headers={'Content-Type': 'application/json', 'Authorization': 'Bearer ' + credentials['token']})
|
||||||
|
with urllib.request.urlopen(request, timeout=10) as response:
|
||||||
|
return json.load(response)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_config(config, state):
|
||||||
|
previous = read_json(ROOT / 'config.json')
|
||||||
|
if previous != config:
|
||||||
|
# Capture one last cumulative sample before replacing a core process.
|
||||||
|
remember(state)
|
||||||
|
staged = ROOT / 'next.json'
|
||||||
|
atomic_json(staged, config)
|
||||||
|
checked = subprocess.run([BINARY, 'check', '-c', str(staged)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
if checked.returncode:
|
||||||
|
core('stop')
|
||||||
|
raise RuntimeError('invalid_core_configuration')
|
||||||
|
os.replace(staged, ROOT / 'config.json')
|
||||||
|
core('restart')
|
||||||
|
else:
|
||||||
|
core('start')
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
STATE.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
lock = open(STATE / 'agent.lock', 'w')
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
credentials = read_json(ROOT / 'agent.json')
|
||||||
|
state_file = STATE / 'state.json'
|
||||||
|
state = read_json(state_file) if state_file.exists() else {'pending': {}}
|
||||||
|
# No cached lease is trusted after an agent restart.
|
||||||
|
deadline = 0
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
try:
|
||||||
|
remember(state)
|
||||||
|
finally:
|
||||||
|
core('stop')
|
||||||
|
else:
|
||||||
|
remember(state)
|
||||||
|
if len(state['pending']) > 256:
|
||||||
|
core('stop')
|
||||||
|
raise RuntimeError('report_backlog_full')
|
||||||
|
reports = list(state['pending'].items())
|
||||||
|
if not reports:
|
||||||
|
reports = [('heartbeat', {'epoch': 'heartbeat', 'traffic': {}, 'streamCount': 0})]
|
||||||
|
desired = None
|
||||||
|
for report_epoch, report in reports:
|
||||||
|
desired = sync(credentials, report)
|
||||||
|
state['pending'].pop(report_epoch, None)
|
||||||
|
atomic_json(state_file, state)
|
||||||
|
apply_config(desired['config'], state)
|
||||||
|
deadline = time.monotonic() + min(90, max(1, desired['leaseSeconds']))
|
||||||
|
print('同步成功', flush=True)
|
||||||
|
except Exception as error:
|
||||||
|
# Do not log credentials, URLs, user lists or config contents.
|
||||||
|
print('同步失败:' + type(error).__name__, flush=True)
|
||||||
|
if isinstance(error, urllib.error.HTTPError) and error.code in (400, 401, 403, 404):
|
||||||
|
deadline = 0
|
||||||
|
core('stop')
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
core('stop')
|
||||||
|
time.sleep(max(1, min(15, deadline - time.monotonic())) if deadline else 5)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
os.umask(0o077)
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
except Exception as error:
|
||||||
|
core('stop')
|
||||||
|
print('管理程序停止:' + type(error).__name__, flush=True)
|
||||||
|
sys.exit(1)
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
#!/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__
|
||||||
|
VERSION=__SING_BOX_VERSION__
|
||||||
|
echo "安装 sing-box $VERSION(带用户统计模块)"
|
||||||
|
DOMAIN='' PORT='' LISTEN_PORT=''
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
[ "$#" -ge 2 ] || { echo "参数缺少值:$1"; exit 1; }
|
||||||
|
case "$1" in
|
||||||
|
--domain) DOMAIN=$2;; --port) PORT=$2;; --listen-port) LISTEN_PORT=$2;;
|
||||||
|
*) echo "未知参数:$1"; exit 1;;
|
||||||
|
esac
|
||||||
|
shift 2
|
||||||
|
done
|
||||||
|
[ "$(id -u)" -eq 0 ] && [ -d /run/systemd/system ] || { echo '需要 root 和 systemd Linux。'; exit 1; }
|
||||||
|
ROOT=/etc/hy2-panel-anytls
|
||||||
|
if [ -d "$ROOT" ] || [ -e /etc/systemd/system/hy2-panel-anytls.service ]; then
|
||||||
|
[ "$(cat "$ROOT/install-id" 2>/dev/null || true)" = "$INSTALL_ID" ] || { echo '已有其他 AnyTLS 安装,停止以避免覆盖。'; exit 1; }
|
||||||
|
fi
|
||||||
|
[ -n "$DOMAIN" ] || read -r -p '域名前缀(例如 jp2)或完整域名:' DOMAIN </dev/tty
|
||||||
|
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 下的单级域名。'; exit 1; }
|
||||||
|
[ -n "$PORT" ] || read -r -p 'AnyTLS 公网 TCP 端口:' PORT </dev/tty
|
||||||
|
if [ -z "$LISTEN_PORT" ] && [ -t 0 ]; then read -r -p "本机 TCP 监听端口(回车使用 $PORT):" LISTEN_PORT </dev/tty; fi
|
||||||
|
LISTEN_PORT=${LISTEN_PORT:-$PORT}
|
||||||
|
for port in "$PORT" "$LISTEN_PORT"; do
|
||||||
|
if ! [[ "$port" =~ ^[1-9][0-9]{0,4}$ ]] || (( port > 65535 )); then echo '端口须为 1–65535。'; exit 1; fi
|
||||||
|
done
|
||||||
|
case "$(uname -m)" in x86_64) ARCH=amd64;; aarch64|arm64) ARCH=arm64;; *) 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 jq ca-certificates python3 python3-venv openssl
|
||||||
|
elif command -v dnf >/dev/null; then dnf install -y curl jq ca-certificates python3 python3-pip openssl
|
||||||
|
else echo '当前安装脚本支持 Debian/Ubuntu 或使用 dnf 的发行版。'; exit 1; fi
|
||||||
|
WORK=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$WORK"' EXIT
|
||||||
|
printf 'Authorization: Bearer %s\n' "$TOKEN" > "$WORK/headers"
|
||||||
|
# Panel serves a pinned, unmodified upstream build with the V2Ray stats module.
|
||||||
|
curl -fsSL --retry 3 --connect-timeout 20 --max-time 300 "$PANEL/api/install/sing-box/$ARCH" -o "$WORK/sing-box"
|
||||||
|
curl -fsSL --retry 3 --max-time 60 "$PANEL/api/install/sing-box/$ARCH?sha256" -o "$WORK/checksum"
|
||||||
|
printf '%s %s\n' "$(cat "$WORK/checksum")" "$WORK/sing-box" | sha256sum -c -
|
||||||
|
# Retain local management ports on retries. These are never exposed publicly.
|
||||||
|
if [ -f "$ROOT/ports.json" ]; then
|
||||||
|
jq -e --arg domain "$DOMAIN" --argjson port "$PORT" --argjson listen "$LISTEN_PORT" '.domain == $domain and .port == $port and .listenPort == $listen' "$ROOT/ports.json" >/dev/null || { echo '重试安装请使用原域名和端口;变更配置需生成新的安装记录。'; exit 1; }
|
||||||
|
cp "$ROOT/ports.json" "$WORK/ports.json"
|
||||||
|
else
|
||||||
|
python3 - "$PORT" "$LISTEN_PORT" "$DOMAIN" > "$WORK/ports.json" <<'PY'
|
||||||
|
import json,socket,sys
|
||||||
|
sockets=[]
|
||||||
|
for _ in range(2):
|
||||||
|
s=socket.socket();s.bind(('127.0.0.1',0));sockets.append(s)
|
||||||
|
print(json.dumps(dict(domain=sys.argv[3],port=int(sys.argv[1]),listenPort=int(sys.argv[2]),statsPort=sockets[0].getsockname()[1],controlPort=sockets[1].getsockname()[1])))
|
||||||
|
PY
|
||||||
|
fi
|
||||||
|
jq -n --slurpfile ports "$WORK/ports.json" '{action:"prepare",ports:$ports[0]}' > "$WORK/request"
|
||||||
|
api() {
|
||||||
|
local status
|
||||||
|
status=$(curl -sS --connect-timeout 15 --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; return 1; fi
|
||||||
|
}
|
||||||
|
api
|
||||||
|
systemctl stop hy2-panel-anytls-agent.service hy2-panel-anytls.service 2>/dev/null || true
|
||||||
|
install -d -m 700 "$ROOT" /usr/local/lib/hy2-panel-anytls /var/lib/hy2-panel-anytls
|
||||||
|
printf '%s' "$INSTALL_ID" > "$ROOT/install-id"
|
||||||
|
install -m 600 "$WORK/ports.json" "$ROOT/ports.json"
|
||||||
|
jq -er '.config' "$WORK/response" > "$ROOT/config.json"
|
||||||
|
jq -er '.certificate' "$WORK/response" > "$ROOT/server.crt"
|
||||||
|
jq -er '.privateKey' "$WORK/response" > "$ROOT/server.key"
|
||||||
|
jq -e '.agent' "$WORK/response" > "$ROOT/agent.json"
|
||||||
|
jq -er '.agentSource' "$WORK/response" > /usr/local/lib/hy2-panel-anytls/agent.py
|
||||||
|
jq -er '.statsProto' "$WORK/response" > /usr/local/lib/hy2-panel-anytls/stats.proto
|
||||||
|
install -m 755 "$WORK/sing-box" /usr/local/lib/hy2-panel-anytls/sing-box
|
||||||
|
/usr/local/lib/hy2-panel-anytls/sing-box check -c "$ROOT/config.json"
|
||||||
|
python3 -m venv /usr/local/lib/hy2-panel-anytls/venv
|
||||||
|
/usr/local/lib/hy2-panel-anytls/venv/bin/pip -q install 'grpcio==1.76.0' 'grpcio-tools==1.76.0'
|
||||||
|
/usr/local/lib/hy2-panel-anytls/venv/bin/python -m grpc_tools.protoc -I /usr/local/lib/hy2-panel-anytls --python_out=/usr/local/lib/hy2-panel-anytls /usr/local/lib/hy2-panel-anytls/stats.proto
|
||||||
|
cat > /etc/systemd/system/hy2-panel-anytls.service <<'UNIT'
|
||||||
|
[Unit]
|
||||||
|
Description=AnyTLS sing-box managed by HY2 Panel
|
||||||
|
After=network-online.target
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/local/lib/hy2-panel-anytls/sing-box run -c /etc/hy2-panel-anytls/config.json
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
LimitNOFILE=1048576
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
PrivateTmp=true
|
||||||
|
UNIT
|
||||||
|
cat > /etc/systemd/system/hy2-panel-anytls-agent.service <<'UNIT'
|
||||||
|
[Unit]
|
||||||
|
Description=HY2 Panel AnyTLS sync agent
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/local/lib/hy2-panel-anytls/venv/bin/python /usr/local/lib/hy2-panel-anytls/agent.py
|
||||||
|
ExecStopPost=/bin/systemctl stop hy2-panel-anytls.service
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ReadWritePaths=/etc/hy2-panel-anytls /var/lib/hy2-panel-anytls
|
||||||
|
PrivateTmp=true
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
UNIT
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl restart hy2-panel-anytls.service
|
||||||
|
CONTROL=$(jq -r '.controlPort' "$ROOT/ports.json")
|
||||||
|
jq -er '.experimental.clash_api.secret | "Authorization: Bearer " + .' "$ROOT/config.json" > "$WORK/control-header"
|
||||||
|
READY=0
|
||||||
|
for attempt in {1..15}; do
|
||||||
|
if curl -fsS --max-time 2 -H @"$WORK/control-header" "http://127.0.0.1:$CONTROL/connections" -o /dev/null; then READY=1; break; fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
[ "$READY" -eq 1 ] || { systemctl stop hy2-panel-anytls.service; echo 'sing-box 健康检查失败。'; exit 1; }
|
||||||
|
printf '{"action":"complete"}' > "$WORK/request"
|
||||||
|
for attempt in {1..3}; do
|
||||||
|
if api; then break; fi
|
||||||
|
[ "$attempt" -lt 3 ] || { systemctl stop hy2-panel-anytls.service; exit 1; }
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
# Only the agent starts at boot; it requires a valid panel lease before starting the core.
|
||||||
|
systemctl enable --now hy2-panel-anytls-agent.service
|
||||||
|
echo "AnyTLS 节点 $SLUG 已安装。TCP $PORT → $LISTEN_PORT;管理端口仅监听本机。"
|
||||||
|
echo '用户变更约 15 秒同步;应用变更会重启 sing-box 并断开现有连接。'
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
Copyright (C) 2022 by nekohasekai <contact-sagernet@sekai.icu>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
In addition, no derivative work may use the name or imply association
|
||||||
|
with this application without prior consent.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Source: SagerNet/sing-box v1.14.2, commit af6e64c3b69e6132ebaee0e1a3d24e93903f6709
|
||||||
|
// Copyright (C) 2022 nekohasekai. GPL-3.0-or-later; see sing-box-LICENSE.
|
||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package experimental.v2rayapi;
|
||||||
|
option go_package = "github.com/sagernet/sing-box/experimental/v2rayapi";
|
||||||
|
|
||||||
|
message GetStatsRequest {
|
||||||
|
// Name of the stat counter.
|
||||||
|
string name = 1;
|
||||||
|
// Whether or not to reset the counter to fetching its value.
|
||||||
|
bool reset = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Stat {
|
||||||
|
string name = 1;
|
||||||
|
int64 value = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetStatsResponse {
|
||||||
|
Stat stat = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message QueryStatsRequest {
|
||||||
|
// Deprecated, use Patterns instead
|
||||||
|
string pattern = 1;
|
||||||
|
bool reset = 2;
|
||||||
|
repeated string patterns = 3;
|
||||||
|
bool regexp = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message QueryStatsResponse {
|
||||||
|
repeated Stat stat = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SysStatsRequest {}
|
||||||
|
|
||||||
|
message SysStatsResponse {
|
||||||
|
uint32 NumGoroutine = 1;
|
||||||
|
uint32 NumGC = 2;
|
||||||
|
uint64 Alloc = 3;
|
||||||
|
uint64 TotalAlloc = 4;
|
||||||
|
uint64 Sys = 5;
|
||||||
|
uint64 Mallocs = 6;
|
||||||
|
uint64 Frees = 7;
|
||||||
|
uint64 LiveObjects = 8;
|
||||||
|
uint64 PauseTotalNs = 9;
|
||||||
|
uint32 Uptime = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
service StatsService {
|
||||||
|
rpc GetStats(GetStatsRequest) returns (GetStatsResponse) {}
|
||||||
|
rpc QueryStats(QueryStatsRequest) returns (QueryStatsResponse) {}
|
||||||
|
rpc GetSysStats(SysStatsRequest) returns (SysStatsResponse) {}
|
||||||
|
}
|
||||||
Executable
+17
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
# Unmodified upstream, pinned source; custom tags enable per-user statistics.
|
||||||
|
SOURCE_COMMIT=af6e64c3b69e6132ebaee0e1a3d24e93903f6709
|
||||||
|
DEST=$(pwd)/data/artifacts
|
||||||
|
mkdir -p "$DEST"
|
||||||
|
WORK=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$WORK"' EXIT
|
||||||
|
git clone --depth 1 --branch v1.14.2 https://github.com/SagerNet/sing-box.git "$WORK/source"
|
||||||
|
[ "$(git -C "$WORK/source" rev-parse HEAD)" = "$SOURCE_COMMIT" ]
|
||||||
|
for arch in amd64 arm64; do
|
||||||
|
(cd "$WORK/source" && CGO_ENABLED=0 GOOS=linux GOARCH=$arch go build -trimpath -tags with_v2ray_api,with_clash_api,with_utls \
|
||||||
|
-ldflags '-s -w -X github.com/sagernet/sing-box/constant.Version=1.14.2' \
|
||||||
|
-o "$DEST/sing-box-1.14.2-linux-$arch" ./cmd/sing-box)
|
||||||
|
done
|
||||||
|
cp "$WORK/source/LICENSE" "$DEST/sing-box-LICENSE"
|
||||||
|
(cd "$DEST" && shasum -a 256 sing-box-1.14.2-linux-* > SHA256SUMS)
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// Optional integration check: SING_BOX_TEST_BINARY=/path/to/build npx tsx tests/anytls-runtime.ts
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { spawn, execFileSync, type ChildProcess } from "node:child_process";
|
||||||
|
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import { certificateFixture } from "./certificate-fixture";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const binary = process.env.SING_BOX_TEST_BINARY;
|
||||||
|
const python = process.env.AGENT_TEST_PYTHON;
|
||||||
|
if (!binary || !python)
|
||||||
|
throw new Error("Set SING_BOX_TEST_BINARY and AGENT_TEST_PYTHON");
|
||||||
|
const dir = mkdtempSync(path.join(tmpdir(), "hy2-anytls-runtime-"));
|
||||||
|
process.env.DATABASE_PATH = path.join(dir, "db.sqlite");
|
||||||
|
process.env.APP_URL = "https://panel.example.com";
|
||||||
|
const { saveCertificate } = await import("../lib/certificates");
|
||||||
|
const { createInstallation, installationForDownload, provisionNode } =
|
||||||
|
await import("../lib/provisioning");
|
||||||
|
const { decryptText } = await import("../lib/crypto");
|
||||||
|
const { syncAnyTLSNode } = await import("../lib/node-agent");
|
||||||
|
const { ensureDb } = await import("../lib/db");
|
||||||
|
const fixture = certificateFixture();
|
||||||
|
const certId = Number(
|
||||||
|
await saveCertificate("Runtime", fixture.certificate, fixture.privateKey),
|
||||||
|
);
|
||||||
|
const id = await createInstallation({
|
||||||
|
slug: "runtime",
|
||||||
|
name: "Runtime",
|
||||||
|
certificateId: certId,
|
||||||
|
protocol: "anytls",
|
||||||
|
});
|
||||||
|
const token = decryptText(
|
||||||
|
(await installationForDownload(id)).token_encrypted,
|
||||||
|
)!;
|
||||||
|
const bundle = await provisionNode(token, "prepare", {
|
||||||
|
domain: "runtime.p.xn--876a.net",
|
||||||
|
port: 39194,
|
||||||
|
listenPort: 39194,
|
||||||
|
statsPort: 39196,
|
||||||
|
controlPort: 39197,
|
||||||
|
});
|
||||||
|
assert.ok(bundle.agent && bundle.config && bundle.statsProto);
|
||||||
|
writeFileSync(path.join(dir, "server.crt"), fixture.certificate);
|
||||||
|
writeFileSync(path.join(dir, "server.key"), fixture.privateKey, {
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
const config = JSON.parse(
|
||||||
|
bundle.config.replaceAll("/etc/hy2-panel-anytls", dir),
|
||||||
|
);
|
||||||
|
const authId = config.inbounds[0].users[0].name;
|
||||||
|
writeFileSync(path.join(dir, "server.json"), JSON.stringify(config), {
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
writeFileSync(
|
||||||
|
path.join(dir, "client.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
log: { level: "error" },
|
||||||
|
inbounds: [{ type: "mixed", listen: "127.0.0.1", listen_port: 39193 }],
|
||||||
|
outbounds: [
|
||||||
|
{
|
||||||
|
type: "anytls",
|
||||||
|
server: "127.0.0.1",
|
||||||
|
server_port: 39194,
|
||||||
|
password: config.inbounds[0].users[0].password,
|
||||||
|
tls: {
|
||||||
|
enabled: true,
|
||||||
|
server_name: "runtime.p.xn--876a.net",
|
||||||
|
certificate_path: path.join(dir, "server.crt"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
const target = createServer((_req, res) => res.end("A".repeat(65536)));
|
||||||
|
await new Promise<void>((resolve) =>
|
||||||
|
target.listen(39195, "127.0.0.1", resolve),
|
||||||
|
);
|
||||||
|
const start = (file: string) =>
|
||||||
|
spawn(binary, ["run", "-c", path.join(dir, file)], { stdio: "ignore" });
|
||||||
|
let server: ChildProcess | undefined, client: ChildProcess | undefined;
|
||||||
|
const stop = async (p?: ChildProcess) => {
|
||||||
|
if (!p || p.exitCode !== null) return;
|
||||||
|
p.kill();
|
||||||
|
await new Promise<void>((resolve) => p.once("exit", () => resolve()));
|
||||||
|
};
|
||||||
|
const transfer = () =>
|
||||||
|
new Promise<number>((resolve) => {
|
||||||
|
const p = spawn(
|
||||||
|
"curl",
|
||||||
|
[
|
||||||
|
"--noproxy",
|
||||||
|
"",
|
||||||
|
"-fsS",
|
||||||
|
"--max-time",
|
||||||
|
"4",
|
||||||
|
"--socks5-hostname",
|
||||||
|
"127.0.0.1:39193",
|
||||||
|
"http://127.0.0.1:39195",
|
||||||
|
"-o",
|
||||||
|
"/dev/null",
|
||||||
|
],
|
||||||
|
{ stdio: "ignore" },
|
||||||
|
);
|
||||||
|
p.once("exit", (code) => resolve(code ?? 1));
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
execFileSync(binary, ["check", "-c", path.join(dir, "server.json")], {
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
server = start("server.json");
|
||||||
|
client = start("client.json");
|
||||||
|
await new Promise((r) => setTimeout(r, 800));
|
||||||
|
assert.equal(await transfer(), 0, "real AnyTLS transfer");
|
||||||
|
writeFileSync(path.join(dir, "stats.proto"), bundle.statsProto);
|
||||||
|
execFileSync(python, [
|
||||||
|
"-m",
|
||||||
|
"grpc_tools.protoc",
|
||||||
|
"-I",
|
||||||
|
dir,
|
||||||
|
`--python_out=${dir}`,
|
||||||
|
path.join(dir, "stats.proto"),
|
||||||
|
]);
|
||||||
|
writeFileSync(
|
||||||
|
path.join(dir, "query.py"),
|
||||||
|
`import grpc,json,stats_pb2\nwith grpc.insecure_channel('127.0.0.1:39196') as c:\n q=c.unary_unary('/v2ray.core.app.stats.command.StatsService/QueryStats',request_serializer=stats_pb2.QueryStatsRequest.SerializeToString,response_deserializer=stats_pb2.QueryStatsResponse.FromString)\n print(json.dumps({s.name:s.value for s in q(stats_pb2.QueryStatsRequest(pattern='user>>>'),timeout=5).stat}))\n`,
|
||||||
|
);
|
||||||
|
const counters = JSON.parse(
|
||||||
|
execFileSync(python, [path.join(dir, "query.py")], { encoding: "utf8" }),
|
||||||
|
);
|
||||||
|
const tx = counters[`user>>>${authId}>>>traffic>>>downlink`],
|
||||||
|
rx = counters[`user>>>${authId}>>>traffic>>>uplink`];
|
||||||
|
assert.ok(tx >= 65536 && rx > 0);
|
||||||
|
await provisionNode(token, "complete");
|
||||||
|
await syncAnyTLSNode("runtime", bundle.agent.token, {
|
||||||
|
epoch: "native:1",
|
||||||
|
traffic: { [authId]: { tx, rx } },
|
||||||
|
streamCount: 0,
|
||||||
|
});
|
||||||
|
const db = await ensureDb();
|
||||||
|
db.prepare("UPDATE hy2_users SET enabled=0 WHERE auth_id=?").run(authId);
|
||||||
|
const disabled = await syncAnyTLSNode("runtime", bundle.agent.token, {
|
||||||
|
epoch: "native:1",
|
||||||
|
traffic: { [authId]: { tx, rx } },
|
||||||
|
streamCount: 0,
|
||||||
|
});
|
||||||
|
await stop(server);
|
||||||
|
writeFileSync(
|
||||||
|
path.join(dir, "server.json"),
|
||||||
|
JSON.stringify(disabled.config).replaceAll("/etc/hy2-panel-anytls", dir),
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
server = start("server.json");
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
assert.notEqual(await transfer(), 0, "disabled credentials must fail");
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
anytlsTransfer: true,
|
||||||
|
tlsVerified: true,
|
||||||
|
perUserTx: tx,
|
||||||
|
perUserRx: rx,
|
||||||
|
disabledUserRejected: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await stop(client);
|
||||||
|
await stop(server);
|
||||||
|
await new Promise<void>((resolve) => target.close(() => resolve()));
|
||||||
|
(await ensureDb()).close();
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -3,7 +3,8 @@ import { certificateFixture } from "./certificate-fixture";
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { parse } from "yaml";
|
import { parse } from "yaml";
|
||||||
|
|
||||||
test("upload wildcard certificate, download script and complete NAT node registration", async ({
|
for (const protocol of ["hy2", "anytls"] as const)
|
||||||
|
test(`upload wildcard certificate and register ${protocol} NAT node`, async ({
|
||||||
page,
|
page,
|
||||||
request,
|
request,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -52,20 +53,23 @@ test("upload wildcard certificate, download script and complete NAT node registr
|
|||||||
await create.locator("summary").click();
|
await create.locator("summary").click();
|
||||||
await create.getByLabel("节点名称", { exact: true }).fill("NAT 浏览器测试");
|
await create.getByLabel("节点名称", { exact: true }).fill("NAT 浏览器测试");
|
||||||
await create.getByLabel("节点 Slug", { exact: true }).fill("nat-browser");
|
await create.getByLabel("节点 Slug", { exact: true }).fill("nat-browser");
|
||||||
|
await create.getByLabel("代理协议", { exact: true }).selectOption(protocol);
|
||||||
await create
|
await create
|
||||||
.getByLabel("托管证书", { exact: true })
|
.getByLabel("托管证书", { exact: true })
|
||||||
.selectOption({ label: "测试通配符证书" });
|
.selectOption({ label: "测试通配符证书" });
|
||||||
await create
|
await create
|
||||||
.getByRole("button", { name: "生成安装脚本", exact: true })
|
.getByRole("button", { name: "生成安装脚本", exact: true })
|
||||||
.click();
|
.click();
|
||||||
const row = page.locator(".provision-row").filter({ hasText: "nat-browser" });
|
const row = page
|
||||||
|
.locator(".provision-row")
|
||||||
|
.filter({ hasText: "nat-browser" });
|
||||||
await expect(row).toContainText("等待安装");
|
await expect(row).toContainText("等待安装");
|
||||||
const downloadPath = await row
|
const downloadPath = await row
|
||||||
.getByRole("link", { name: "下载 SH" })
|
.getByRole("link", { name: "下载 SH" })
|
||||||
.getAttribute("href");
|
.getAttribute("href");
|
||||||
expect((await request.get(downloadPath!, { maxRedirects: 0 })).status()).toBe(
|
expect(
|
||||||
307,
|
(await request.get(downloadPath!, { maxRedirects: 0 })).status(),
|
||||||
);
|
).toBe(307);
|
||||||
const [download] = await Promise.all([
|
const [download] = await Promise.all([
|
||||||
page.waitForEvent("download"),
|
page.waitForEvent("download"),
|
||||||
row.getByRole("link", { name: "下载 SH" }).click(),
|
row.getByRole("link", { name: "下载 SH" }).click(),
|
||||||
@@ -74,7 +78,16 @@ test("upload wildcard certificate, download script and complete NAT node registr
|
|||||||
expect(script).not.toContain("PRIVATE KEY");
|
expect(script).not.toContain("PRIVATE KEY");
|
||||||
expect(script).toContain("--domain");
|
expect(script).toContain("--domain");
|
||||||
const token = script.match(/^TOKEN='([a-f0-9]+)'$/m)![1];
|
const token = script.match(/^TOKEN='([a-f0-9]+)'$/m)![1];
|
||||||
const ports = {
|
const ports =
|
||||||
|
protocol === "anytls"
|
||||||
|
? {
|
||||||
|
domain: "nat.p.xn--876a.net",
|
||||||
|
port: 23456,
|
||||||
|
listenPort: 23456,
|
||||||
|
statsPort: 19090,
|
||||||
|
controlPort: 19091,
|
||||||
|
}
|
||||||
|
: {
|
||||||
domain: "nat.p.xn--876a.net",
|
domain: "nat.p.xn--876a.net",
|
||||||
hy2Port: 23456,
|
hy2Port: 23456,
|
||||||
statsPort: 23457,
|
statsPort: 23457,
|
||||||
@@ -83,7 +96,9 @@ test("upload wildcard certificate, download script and complete NAT node registr
|
|||||||
};
|
};
|
||||||
expect(
|
expect(
|
||||||
(
|
(
|
||||||
await request.post("/api/install", { data: { action: "prepare", ports } })
|
await request.post("/api/install", {
|
||||||
|
data: { action: "prepare", ports },
|
||||||
|
})
|
||||||
).status(),
|
).status(),
|
||||||
).toBe(400);
|
).toBe(400);
|
||||||
expect(
|
expect(
|
||||||
@@ -98,8 +113,13 @@ test("upload wildcard certificate, download script and complete NAT node registr
|
|||||||
const bundle = await prepare.json();
|
const bundle = await prepare.json();
|
||||||
expect(bundle.privateKey).toBe(fixture.privateKey);
|
expect(bundle.privateKey).toBe(fixture.privateKey);
|
||||||
const config = parse(bundle.config);
|
const config = parse(bundle.config);
|
||||||
|
if (protocol === "hy2") {
|
||||||
expect(config.listen).toBe(":443");
|
expect(config.listen).toBe(":443");
|
||||||
expect(config.trafficStats.listen).toBe(":9999");
|
expect(config.trafficStats.listen).toBe(":9999");
|
||||||
|
} else {
|
||||||
|
expect(config.inbounds[0].type).toBe("anytls");
|
||||||
|
expect(config.experimental.v2ray_api.listen).toBe("127.0.0.1:19090");
|
||||||
|
}
|
||||||
expect(
|
expect(
|
||||||
(
|
(
|
||||||
await request.post("/api/install", {
|
await request.post("/api/install", {
|
||||||
@@ -139,12 +159,27 @@ test("upload wildcard certificate, download script and complete NAT node registr
|
|||||||
.filter({ hasText: "nat-browser" });
|
.filter({ hasText: "nat-browser" });
|
||||||
await expect(node).toContainText("nat.p.xn--876a.net");
|
await expect(node).toContainText("nat.p.xn--876a.net");
|
||||||
await node.locator(":scope > summary").click();
|
await node.locator(":scope > summary").click();
|
||||||
|
if (protocol === "hy2") {
|
||||||
await expect(node.getByLabel("对外端口", { exact: true })).toHaveValue(
|
await expect(node.getByLabel("对外端口", { exact: true })).toHaveValue(
|
||||||
"23456",
|
"23456",
|
||||||
);
|
);
|
||||||
await expect(node.getByLabel("SNI", { exact: true })).toHaveValue(
|
await expect(node.getByLabel("SNI", { exact: true })).toHaveValue(
|
||||||
"nat.p.xn--876a.net",
|
"nat.p.xn--876a.net",
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
await expect(node).toContainText("节点主动上报");
|
||||||
|
await expect(node.getByLabel("节点名称 / 订阅名")).toHaveValue(
|
||||||
|
"NAT 浏览器测试",
|
||||||
|
);
|
||||||
|
const sync = await request.post("/api/agent/nat-browser/sync", {
|
||||||
|
headers: { Authorization: `Bearer ${bundle.agent.token}` },
|
||||||
|
data: { epoch: "test:1", traffic: {}, streamCount: 1 },
|
||||||
|
});
|
||||||
|
expect(sync.status()).toBe(200);
|
||||||
|
expect(
|
||||||
|
(await sync.json()).config.inbounds[0].users.length,
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
await expect(node.getByLabel("启用节点", { exact: true })).toBeChecked();
|
await expect(node.getByLabel("启用节点", { exact: true })).toBeChecked();
|
||||||
expect(errors).toEqual([]);
|
expect(errors).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
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 { certificateFixture } from "../certificate-fixture";
|
||||||
|
import { parse } from "yaml";
|
||||||
|
|
||||||
|
test("AnyTLS provisioning, per-user credentials, cumulative accounting and revocation", async () => {
|
||||||
|
const dir = mkdtempSync(path.join(tmpdir(), "hy2-anytls-"));
|
||||||
|
process.env.DATABASE_PATH = path.join(dir, "db.sqlite");
|
||||||
|
process.env.APP_URL = "https://panel.example.com";
|
||||||
|
const { ensureDb } = await import("../../lib/db");
|
||||||
|
const { encryptText, decryptText } = await import("../../lib/crypto");
|
||||||
|
const { saveCertificate } = await import("../../lib/certificates");
|
||||||
|
const { createInstallation, installationForDownload, provisionNode } =
|
||||||
|
await import("../../lib/provisioning");
|
||||||
|
const { syncAnyTLSNode } = await import("../../lib/node-agent");
|
||||||
|
const { anytlsPassword } = await import("../../lib/anytls");
|
||||||
|
const { buildInstallScript } = await import("../../lib/install-script");
|
||||||
|
const {
|
||||||
|
getNodeBySlug,
|
||||||
|
listUsers,
|
||||||
|
getSubscriptionSettings,
|
||||||
|
listEnabledNodesForPolling,
|
||||||
|
} = await import("../../lib/store");
|
||||||
|
const { buildSubscriptionConfig } = await import("../../lib/subscription");
|
||||||
|
const db = await ensureDb();
|
||||||
|
try {
|
||||||
|
const fixture = certificateFixture();
|
||||||
|
const certificateId = Number(
|
||||||
|
await saveCertificate(
|
||||||
|
"Wildcard",
|
||||||
|
fixture.certificate,
|
||||||
|
fixture.privateKey,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const id = await createInstallation({
|
||||||
|
slug: "anytls-test",
|
||||||
|
name: "AnyTLS",
|
||||||
|
certificateId,
|
||||||
|
protocol: "anytls",
|
||||||
|
});
|
||||||
|
const job = await installationForDownload(id);
|
||||||
|
assert.equal(job.protocol, "anytls");
|
||||||
|
assert.match(buildInstallScript(job), /--port/);
|
||||||
|
const installToken = decryptText(job.token_encrypted)!;
|
||||||
|
const ports = {
|
||||||
|
domain: "jp2.p.xn--876a.net",
|
||||||
|
port: 51046,
|
||||||
|
listenPort: 51046,
|
||||||
|
statsPort: 19090,
|
||||||
|
controlPort: 19091,
|
||||||
|
};
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
provisionNode(installToken, "prepare", {
|
||||||
|
...ports,
|
||||||
|
controlPort: 19090,
|
||||||
|
}),
|
||||||
|
/端口/,
|
||||||
|
);
|
||||||
|
const bundle = await provisionNode(installToken, "prepare", ports);
|
||||||
|
assert.ok(bundle.agent && bundle.config && bundle.agentSource);
|
||||||
|
assert.equal(bundle.agent.slug, "anytls-test");
|
||||||
|
assert.match(bundle.agentSource, /reset=False/);
|
||||||
|
const initial = JSON.parse(bundle.config);
|
||||||
|
assert.equal(initial.inbounds[0].type, "anytls");
|
||||||
|
assert.equal(initial.inbounds[0].listen_port, 51046);
|
||||||
|
assert.equal(initial.experimental.v2ray_api.listen, "127.0.0.1:19090");
|
||||||
|
const user = (await listUsers())[0];
|
||||||
|
const password = anytlsPassword(user);
|
||||||
|
assert.equal(initial.inbounds[0].users[0].password, password);
|
||||||
|
assert.notEqual(password, decryptText(user.auth_secret_encrypted));
|
||||||
|
await provisionNode(installToken, "complete");
|
||||||
|
const node = (await getNodeBySlug(job.slug))!;
|
||||||
|
assert.equal(node.node_type, "anytls");
|
||||||
|
assert.equal((await listEnabledNodesForPolling()).length, 0);
|
||||||
|
const subscription = parse(
|
||||||
|
buildSubscriptionConfig({
|
||||||
|
user,
|
||||||
|
nodes: [node],
|
||||||
|
settings: await getSubscriptionSettings(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.equal(subscription.proxies[0].type, "anytls");
|
||||||
|
assert.equal(subscription.proxies[0].password, password);
|
||||||
|
assert.equal(subscription.proxies[0]["skip-cert-verify"], false);
|
||||||
|
const report = {
|
||||||
|
epoch: "boot:123:456",
|
||||||
|
traffic: { [user.auth_id]: { tx: 1000, rx: 500 } },
|
||||||
|
streamCount: 2,
|
||||||
|
};
|
||||||
|
await assert.rejects(
|
||||||
|
() => syncAnyTLSNode(job.slug, "0".repeat(64), report),
|
||||||
|
/凭据/,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
syncAnyTLSNode(job.slug, bundle.agent.token, {
|
||||||
|
...report,
|
||||||
|
traffic: { [user.auth_id]: { tx: -1, rx: 2 } },
|
||||||
|
}),
|
||||||
|
/无效/,
|
||||||
|
);
|
||||||
|
await syncAnyTLSNode(job.slug, bundle.agent.token, report);
|
||||||
|
await syncAnyTLSNode(job.slug, bundle.agent.token, report);
|
||||||
|
let refreshed = (await listUsers())[0];
|
||||||
|
assert.equal(refreshed.used_tx_bytes, 1000);
|
||||||
|
assert.equal(refreshed.used_rx_bytes, 500);
|
||||||
|
await syncAnyTLSNode(job.slug, bundle.agent.token, {
|
||||||
|
...report,
|
||||||
|
traffic: { [user.auth_id]: { tx: 900, rx: 400 } },
|
||||||
|
});
|
||||||
|
refreshed = (await listUsers())[0];
|
||||||
|
assert.equal(refreshed.used_tx_bytes, 1000);
|
||||||
|
await syncAnyTLSNode(job.slug, bundle.agent.token, {
|
||||||
|
...report,
|
||||||
|
epoch: "boot:124:500",
|
||||||
|
traffic: { [user.auth_id]: { tx: 100, rx: 50 } },
|
||||||
|
});
|
||||||
|
refreshed = (await listUsers())[0];
|
||||||
|
assert.equal(refreshed.used_tx_bytes, 1100);
|
||||||
|
// Make this account an ordinary user to exercise quota/expiry/disable rules.
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE hy2_users SET is_admin=0,traffic_limit_bytes=1000 WHERE id=?",
|
||||||
|
).run(user.id);
|
||||||
|
const capped = await syncAnyTLSNode(job.slug, bundle.agent.token, report);
|
||||||
|
assert.equal(capped.config.inbounds[0].users.length, 0);
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE hy2_users SET traffic_limit_bytes=NULL,expires_at='2000-01-01' WHERE id=?",
|
||||||
|
).run(user.id);
|
||||||
|
assert.equal(
|
||||||
|
(await syncAnyTLSNode(job.slug, bundle.agent.token, report)).config
|
||||||
|
.inbounds[0].users.length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
db.prepare("UPDATE hy2_users SET expires_at=NULL,enabled=0 WHERE id=?").run(
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await syncAnyTLSNode(job.slug, bundle.agent.token, report)).config
|
||||||
|
.inbounds[0].users.length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE hy2_users SET enabled=1,auth_secret_encrypted=? WHERE id=?",
|
||||||
|
).run(encryptText("new-password"), user.id);
|
||||||
|
const updated = await syncAnyTLSNode(job.slug, bundle.agent.token, report);
|
||||||
|
assert.notEqual(updated.config.inbounds[0].users[0].password, password);
|
||||||
|
db.prepare("UPDATE hy2_nodes SET enabled=0 WHERE id=?").run(node.id);
|
||||||
|
assert.equal(
|
||||||
|
(await syncAnyTLSNode(job.slug, bundle.agent.token, report)).config
|
||||||
|
.inbounds[0].users.length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert.deepEqual(db.pragma("foreign_key_check"), []);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -25,7 +25,11 @@ function snapshot(db: Database.Database) {
|
|||||||
)
|
)
|
||||||
.filter(
|
.filter(
|
||||||
({ name }) =>
|
({ name }) =>
|
||||||
!["managed_certificates", "node_installations"].includes(name),
|
![
|
||||||
|
"managed_certificates",
|
||||||
|
"node_installations",
|
||||||
|
"node_agent_counters",
|
||||||
|
].includes(name),
|
||||||
)
|
)
|
||||||
.map(({ name }) => [name, db.prepare(`SELECT * FROM "${name}"`).all()]),
|
.map(({ name }) => [name, db.prepare(`SELECT * FROM "${name}"`).all()]),
|
||||||
);
|
);
|
||||||
@@ -45,7 +49,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 }), 5);
|
assert.equal(fresh.pragma("user_version", { simple: true }), 6);
|
||||||
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 }[]) {
|
||||||
@@ -98,7 +102,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 }), 5);
|
assert.equal(db.pragma("user_version", { simple: true }), 6);
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -128,8 +132,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 = 6");
|
db.pragma("user_version = 7");
|
||||||
assert.throws(() => migrateDatabase(db), /版本/);
|
assert.throws(() => migrateDatabase(db), /版本/);
|
||||||
assert.equal(db.pragma("user_version", { simple: true }), 6);
|
assert.equal(db.pragma("user_version", { simple: true }), 7);
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user