feat: 接入 AnyTLS 节点托管和用户流量同步
This commit is contained in:
@@ -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"),
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user