feat: 接入 AnyTLS 节点托管和用户流量同步

This commit is contained in:
2026-09-29 13:35:44 +08:00
parent 586c2399d1
commit ac5fed695c
24 changed files with 1413 additions and 185 deletions
+1
View File
@@ -50,6 +50,7 @@ export async function createInstallationAction(data: FormData) {
return run(async () => {
const input = installationSchema.safeParse({
name: data.get("name"),
protocol: data.get("protocol") || "hy2",
slug: data.get("slug"),
certificateId: data.get("certificate_id"),
});
+19 -6
View File
@@ -124,6 +124,17 @@ export default async function CertificatesPage() {
action={createInstallationAction}
className="dense-form-grid provision-form"
>
<label className="field">
<span id="protocol-label">代理协议</span>
<select
name="protocol"
aria-labelledby="protocol-label"
defaultValue="hy2"
>
<option value="hy2">HY2 · UDP</option>
<option value="anytls">AnyTLS · TCP / sing-box</option>
</select>
</label>
<label className="field">
<span>节点名称</span>
<Input
@@ -163,10 +174,10 @@ export default async function CertificatesPage() {
</select>
</label>
<p className="muted full-span">
脚本适用于使用 systemd 的 Linux(x86_64 / ARM64 / ARMv7),以 root
执行。运行时输入 jp1 等域名前缀(自动补全 .p.xn--876a.net)、HY2 UDP 和统计 API TCP
端口;支持 NAT 内外端口不同。域名用于节点地址和
SNI,请预先解析到服务器公网 IP。证书须覆盖 *.p.xn--876a.net。
使用 systemd 的 Linux,以 root 执行。域名可输入 jp1,自动补全
.p.xn--876a.net。 HY2 输入 UDP 和统计 TCP 端口;AnyTLS(x86_64 /
ARM64)只需代理 TCP 端口,管理接口仅监听本机。 支持 NAT
内外端口不同。请先配置 DNS,证书须覆盖 *.p.xn--876a.net。
</p>
<SubmitButton disabled={certificates.length === 0}>
生成安装脚本
@@ -187,8 +198,9 @@ export default async function CertificatesPage() {
<p className="muted">
下载后执行 sudo bash install-节点Slug.sh。脚本含有效期 24
小时的安装凭据,请妥善保管;安装成功后自动失效。也支持 --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>
<div className="provision-list">
{installations.length === 0 ? (
@@ -212,6 +224,7 @@ export default async function CertificatesPage() {
<div>
<strong>{job.name}</strong>
<p className="muted">
{job.protocol === "anytls" ? "AnyTLS" : "HY2"} ·{" "}
{job.slug} · {job.server_host || "域名在脚本中设置"} ·{" "}
{job.certificate_name}
</p>
+16 -4
View File
@@ -7,8 +7,9 @@ import { DashboardCharts } from "./dashboard-charts";
function getNodePill(node: {
enabled: number;
node_type: "hy2" | "custom";
node_type: "hy2" | "custom" | "anytls";
last_error_message: string | null;
last_sync_ok_at: string | null;
}) {
if (!node.enabled) {
return { label: "已禁用", className: "status-pill status-pill--off" };
@@ -17,6 +18,13 @@ function getNodePill(node: {
if (node.node_type === "custom")
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) {
return { label: "同步异常", className: "status-pill status-pill--danger" };
}
@@ -70,7 +78,9 @@ export default async function DashboardPage() {
</section>
<DashboardCharts
nodeHealth={data.nodeHealth.filter((node) => node.node_type === "hy2")}
nodeHealth={data.nodeHealth.filter(
(node) => node.node_type !== "custom",
)}
trafficTrend={data.trafficTrend}
trafficBuckets={data.trafficBuckets}
trafficUserOptions={data.trafficUserOptions}
@@ -109,9 +119,11 @@ export default async function DashboardPage() {
<p className="muted">
{node.node_type === "custom"
? "仅订阅 · 不参与轮询"
: `在线 ${node.last_online_users} / 流 ${node.last_stream_count}`}
: node.node_type === "anytls"
? `AnyTLS · 流 ${node.last_stream_count}`
: `在线 ${node.last_online_users} / 流 ${node.last_stream_count}`}
</p>
{node.node_type === "hy2" && (
{node.node_type !== "custom" && (
<p className="muted">
最近成功 {formatLocalDateTime(node.last_sync_ok_at)}
</p>
+21
View File
@@ -6,6 +6,7 @@ import {
} from "@/lib/custom-proxy";
import { revalidatePath } from "next/cache";
import { ensureDb } from "@/lib/db";
import { requireAdminAccess } from "@/lib/session";
import {
createNode,
@@ -115,3 +116,23 @@ export async function deleteNodeAction(formData: FormData) {
revalidatePath("/admin/subscriptions");
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");
}
+73 -10
View File
@@ -17,14 +17,16 @@ import {
import {
createNodeAction,
updateAnyTLSNodeAction,
deleteNodeAction,
updateNodeAction,
} from "./actions";
function getNodePills(node: {
enabled: number;
node_type: "hy2" | "custom";
node_type: "hy2" | "custom" | "anytls";
last_error_message: string | null;
last_sync_ok_at: string | null;
}) {
return [
{
@@ -33,9 +35,13 @@ function getNodePills(node: {
},
node.node_type === "custom"
? { label: "自定义 YAML", className: "status-pill status-pill--ink" }
: node.last_error_message
? { label: "同步异常", className: "status-pill status-pill--danger" }
: { label: "同步正常", className: "status-pill status-pill--sage" },
: 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
? { label: "同步异常", className: "status-pill status-pill--danger" }
: { label: "同步正常", className: "status-pill status-pill--sage" },
];
}
@@ -48,7 +54,12 @@ export default async function NodesPage() {
<div className="hero-copy">
<span className="eyebrow">节点</span>
<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">
<span className="status-pill status-pill--ok">
启用 {nodes.filter((node) => node.enabled).length}
@@ -216,6 +227,7 @@ export default async function NodesPage() {
}
items={nodes.map((node) => {
const isCustom = node.node_type === "custom";
const isAnyTLS = node.node_type === "anytls";
const proxyName = getNodeProxyName(node);
const authUrl = buildNodeAuthUrl(
node.slug,
@@ -229,7 +241,7 @@ export default async function NodesPage() {
return {
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",
content: (
<details key={node.id} className="entity-table__item">
@@ -250,20 +262,30 @@ export default async function NodesPage() {
? proxyName
: `${node.server_host}:${node.server_port}`}
<small>
{isCustom ? "自定义订阅节点" : `hy2 ${node.hy2_listen}`}
{isCustom
? "自定义订阅节点"
: `${isAnyTLS ? "AnyTLS TCP" : "HY2 UDP"} ${node.hy2_listen}`}
</small>
</span>
<span className="entity-code-cell break-all">
{isCustom ? "不轮询" : node.traffic_stats_url}
{isCustom
? "不轮询"
: isAnyTLS
? "节点主动上报"
: node.traffic_stats_url}
<small>
{isCustom ? "仅订阅" : node.traffic_stats_listen}
{isCustom
? "仅订阅"
: isAnyTLS
? "管理接口仅本机"
: node.traffic_stats_listen}
</small>
</span>
<span className="entity-number-cell">
<strong>
{isCustom
? "—"
: `${node.last_online_users} / ${node.last_stream_count}`}
: `${isAnyTLS ? "—" : node.last_online_users} / ${node.last_stream_count}`}
</strong>
<span>{isCustom ? "无流量统计" : "用户 / 流"}</span>
</span>
@@ -285,6 +307,47 @@ export default async function NodesPage() {
<div className="entity-table__details">
{isCustom ? (
<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">