From 6378ab4994c11a4075b9857e44f095a3a595b8c1 Mon Sep 17 00:00:00 2001 From: feie9454 Date: Tue, 14 Apr 2026 18:05:32 +0800 Subject: [PATCH] init --- .codex | 0 README.md | 120 +- app/admin/admin-nav.tsx | 33 + app/admin/audits/page.tsx | 51 + app/admin/dashboard/page.tsx | 193 + app/admin/layout.tsx | 42 + app/admin/nodes/actions.ts | 75 + app/admin/nodes/page.tsx | 347 ++ app/admin/page.tsx | 5 + app/admin/subscriptions/actions.ts | 59 + app/admin/subscriptions/page.tsx | 160 + app/admin/users/actions.ts | 71 + app/admin/users/page.tsx | 220 + app/api/admin/nodes/[slug]/config/route.ts | 28 + app/api/hy2/nodes/[slug]/auth/route.ts | 134 + app/api/subscription/[token]/clash/route.ts | 38 + app/config/config.yaml | 571 ++ app/globals.css | 881 +++- app/layout.tsx | 22 +- app/login/actions.ts | 34 + app/login/page.tsx | 70 + app/logout/route.ts | 8 + app/me/page.tsx | 75 + app/page.tsx | 81 +- bun.lock | 1 + deploy/hy2-panel.service | 17 + instrumentation.ts | 11 + lib/crypto.ts | 38 + lib/db.ts | 340 ++ lib/env.ts | 37 + lib/hy2.ts | 40 + lib/password.ts | 27 + lib/poller.ts | 127 + lib/request-origin.ts | 24 + lib/session.ts | 188 + lib/store.ts | 1062 ++++ lib/subscription.ts | 196 + next.config.ts | 2 +- package-lock.json | 5172 +++++++++++++++++++ package.json | 1 + 40 files changed, 10488 insertions(+), 113 deletions(-) create mode 100644 .codex create mode 100644 app/admin/admin-nav.tsx create mode 100644 app/admin/audits/page.tsx create mode 100644 app/admin/dashboard/page.tsx create mode 100644 app/admin/layout.tsx create mode 100644 app/admin/nodes/actions.ts create mode 100644 app/admin/nodes/page.tsx create mode 100644 app/admin/page.tsx create mode 100644 app/admin/subscriptions/actions.ts create mode 100644 app/admin/subscriptions/page.tsx create mode 100644 app/admin/users/actions.ts create mode 100644 app/admin/users/page.tsx create mode 100644 app/api/admin/nodes/[slug]/config/route.ts create mode 100644 app/api/hy2/nodes/[slug]/auth/route.ts create mode 100644 app/api/subscription/[token]/clash/route.ts create mode 100644 app/config/config.yaml create mode 100644 app/login/actions.ts create mode 100644 app/login/page.tsx create mode 100644 app/logout/route.ts create mode 100644 app/me/page.tsx create mode 100644 deploy/hy2-panel.service create mode 100644 instrumentation.ts create mode 100644 lib/crypto.ts create mode 100644 lib/db.ts create mode 100644 lib/env.ts create mode 100644 lib/hy2.ts create mode 100644 lib/password.ts create mode 100644 lib/poller.ts create mode 100644 lib/request-origin.ts create mode 100644 lib/session.ts create mode 100644 lib/store.ts create mode 100644 lib/subscription.ts create mode 100644 package-lock.json diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index e215bc4..803db29 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,114 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# HY2 Panel -## Getting Started +单机自托管的 Next 16 管理面板,面向 Hysteria 2 HTTP 鉴权与多节点流量聚合场景。 -First, run the development server: +功能包括: + +- 单管理员本地登录,基于 SQLite 持久化会话 +- hy2 用户管理:启用/禁用、重置密码、到期时间、总流量上限、稳定 `auth_id` +- 多节点管理:每节点独立鉴权 URL + token、`trafficStats` URL/secret、轮询间隔 +- 公开 HTTP 鉴权接口:返回标准 `200 + { ok, id }` +- 后台轮询 `trafficStats`、聚合用户流量、记录同步错误与鉴权审计 +- 对禁用、过期、超额用户调用节点 `kick` 做准实时断开 + +## Environment + +复制 `.env.example` 为 `.env`,至少设置: ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +APP_URL=http://127.0.0.1:3000 +SESSION_SECRET=your-long-random-secret +SESSION_COOKIE_SECURE=auto +ADMIN_PASSWORD=your-strong-admin-password ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +说明: -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +- `ADMIN_USERNAME` / `ADMIN_PASSWORD` 是单管理员账号,启动时会同步到数据库中的唯一管理员记录 +- `SESSION_COOKIE_SECURE=auto` 时会按 `APP_URL` 协议决定是否开启 `Secure` cookie;如果你是直接用 `http://IP:3000` 访问,不要强制设成 `true` +- `DATABASE_PATH` 默认是 `./data/hy2-panel.db` +- `POLLER_ENABLED=true` 时,Next `instrumentation.ts` 会在 Node 进程启动后启动轮询器 -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +## Development -## Learn More +```bash +npm install +npm run dev +``` -To learn more about Next.js, take a look at the following resources: +打开 `http://127.0.0.1:3000`。 -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +## Build -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +```bash +npm run lint +npm run build +``` -## Deploy on Vercel +这里将生产构建固定为 `next build --webpack`。原因是 Next 16 默认 Turbopack,但在部分受限环境下会触发构建期端口绑定问题;Webpack 构建已验证通过。 -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +## HY2 Node Access -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +面板里的每个节点都会生成独立鉴权 URL,例如: + +```yaml +auth: + type: http + http: + url: http://127.0.0.1:3000/api/hy2/nodes/tokyo-01/auth?token=YOUR_TOKEN + insecure: false +``` + +根据 Hysteria 官方文档,HTTP 鉴权回调请求体是: + +```json +{ + "addr": "123.123.123.123:44556", + "auth": "username:password", + "tx": 123456 +} +``` + +面板响应: + +```json +{ + "ok": true, + "id": "immutable-auth-id" +} +``` + +多节点识别依赖“不同的 URL 路径”,不是依赖 hy2 自动附带节点 ID。 + +## trafficStats + +节点需启用: + +```yaml +trafficStats: + listen: :9999 + secret: some_secret +``` + +面板会轮询: + +- `GET /traffic?clear=1` +- `GET /online` +- `GET /dump/streams` +- `POST /kick` + +## systemd + +已提供示例单元文件 [deploy/hy2-panel.service](/root/hy2-panel/deploy/hy2-panel.service)。 + +典型流程: + +```bash +npm install +npm run build +sudo cp deploy/hy2-panel.service /etc/systemd/system/hy2-panel.service +sudo systemctl daemon-reload +sudo systemctl enable --now hy2-panel +``` + +如果你需要对外开放,再在 Nginx/Caddy 前面反代 `127.0.0.1:3000` 即可。 diff --git a/app/admin/admin-nav.tsx b/app/admin/admin-nav.tsx new file mode 100644 index 0000000..473936b --- /dev/null +++ b/app/admin/admin-nav.tsx @@ -0,0 +1,33 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +type NavItem = { + href: string; + label: string; + meta: string; +}; + +export function AdminNav({ items }: { items: NavItem[] }) { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/app/admin/audits/page.tsx b/app/admin/audits/page.tsx new file mode 100644 index 0000000..c403aae --- /dev/null +++ b/app/admin/audits/page.tsx @@ -0,0 +1,51 @@ +import { formatBytes, listAudits } from "@/lib/store"; + +export default async function AuditsPage() { + const audits = await listAudits(); + + return ( +
+
+
+ AUDIT +

鉴权审计

+

记录最近的 HTTP 鉴权回调结果、失败原因和请求来源。

+
+
+
+ 最近记录 + {audits.length} +
+
+
+ +
+
+ {audits.map((audit) => ( +
+
+
+ + {audit.ok ? "允许" : "拒绝"} + +
+

+ {audit.node_name ?? "未知节点"} / {audit.username ?? audit.auth_id ?? "-"} +

+

{audit.addr ?? "-"}

+
+
+

{audit.reason}

+

+ 请求速率 {audit.requested_tx != null ? formatBytes(audit.requested_tx) : "-"} + /s +

+

{audit.created_at}

+
+
+ ))} +
+
+
+ ); +} diff --git a/app/admin/dashboard/page.tsx b/app/admin/dashboard/page.tsx new file mode 100644 index 0000000..2aea561 --- /dev/null +++ b/app/admin/dashboard/page.tsx @@ -0,0 +1,193 @@ +import { formatBytes, getDashboardMetrics } from "@/lib/store"; + +function getNodePill(node: { + enabled: number; + last_error_message: string | null; +}) { + if (!node.enabled) { + return { label: "已禁用", className: "status-pill status-pill--off" }; + } + + if (node.last_error_message) { + return { label: "同步异常", className: "status-pill status-pill--danger" }; + } + + return { label: "运行健康", className: "status-pill status-pill--ok" }; +} + +export default async function DashboardPage() { + const data = await getDashboardMetrics(); + + return ( +
+
+
+ OVERVIEW +

仪表盘

+

+ 汇总多节点在线、鉴权失败、同步错误和用户用量,作为当前运行态的统一入口。 +

+
+ HTTP 鉴权在线 + 轮询聚合已启用 + 节点 {data.totals.enabled_nodes} +
+
+
+
+ 活动连接 + {data.totals.total_online_connections} +
+
+ 累计流量 + {formatBytes(data.totals.total_traffic)} +
+
+
+ +
+
+ 总用户 + {data.totals.total_users} + 启用 {data.totals.enabled_users} / 管理员 {data.totals.admin_users} +
+
+ 活跃用户 + {data.totals.active_presence_rows} + 在线连接 {data.totals.total_online_connections} +
+
+ 启用节点 + {data.totals.enabled_nodes} + 轮询与 kick 生效范围 +
+
+ 累计流量 + {formatBytes(data.totals.total_traffic)} + 已聚合到 SQLite +
+
+ +
+
+
+

节点健康

+
+
+ {data.nodeHealth.map((node) => ( +
+
+ {node.name} +

/{node.slug}

+
+
+

+ + {getNodePill(node).label} + +

+

+ 在线 {node.last_online_users} / 流 {node.last_stream_count} +

+

+ 最近成功 {node.last_sync_ok_at ?? "无"} +

+ {node.last_error_message ? ( +

{node.last_error_message}

+ ) : null} +
+
+ ))} +
+
+ +
+
+

用量排行

+
+
+ {data.topUsers.map((user) => ( +
+
+ {user.username} +

{user.auth_id}

+
+
+

{formatBytes(user.total_bytes)}

+

+ TX {formatBytes(user.used_tx_bytes)} / RX {formatBytes(user.used_rx_bytes)} +

+
+
+ ))} +
+
+ +
+
+

最近鉴权失败

+
+
+ {data.recentFailures.map((item, index) => ( +
+
+ {item.reason} +

+ {item.node_name ?? "未知节点"} / {item.username ?? "未知用户"} +

+
+
+

{item.addr ?? "-"}

+

{item.created_at}

+
+
+ ))} +
+
+ +
+
+

最近流量入账

+
+
+ {data.recentUsage.map((item, index) => ( +
+
+ {item.username ?? item.auth_id} +

{item.node_name ?? "未知节点"}

+
+
+

+ +{formatBytes(item.tx_bytes + item.rx_bytes)} +

+

{item.created_at}

+
+
+ ))} +
+
+ +
+
+

节点同步错误

+
+
+ {data.syncErrors.length === 0 ? ( +

暂无同步错误。

+ ) : ( + data.syncErrors.map((item, index) => ( +
+
+ {item.node_name} +

{item.error_message ?? "unknown error"}

+
+

{item.created_at}

+
+ )) + )} +
+
+
+
+ ); +} diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx new file mode 100644 index 0000000..12856b5 --- /dev/null +++ b/app/admin/layout.tsx @@ -0,0 +1,42 @@ +import { requireAdminAccess } from "@/lib/session"; + +import { AdminNav } from "./admin-nav"; + +const navItems = [ + { href: "/admin/dashboard", label: "仪表盘", meta: "运行态与健康度" }, + { href: "/admin/users", label: "用户管理", meta: "凭证、额度与订阅" }, + { href: "/admin/nodes", label: "节点管理", meta: "接入、鉴权与导出" }, + { href: "/admin/subscriptions", label: "订阅配置", meta: "模板与代理分组" }, + { href: "/admin/audits", label: "审计日志", meta: "鉴权失败与追踪" }, +]; + +export default async function AdminLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const session = await requireAdminAccess(); + + return ( +
+ +
+
{children}
+
+
+ ); +} diff --git a/app/admin/nodes/actions.ts b/app/admin/nodes/actions.ts new file mode 100644 index 0000000..11dc9fa --- /dev/null +++ b/app/admin/nodes/actions.ts @@ -0,0 +1,75 @@ +"use server"; + +import { revalidatePath } from "next/cache"; + +import { requireAdminAccess } from "@/lib/session"; +import { createNode, deleteNode, updateNode } from "@/lib/store"; + +function boolFromForm(value: FormDataEntryValue | null) { + return value === "on"; +} + +export async function createNodeAction(formData: FormData) { + await requireAdminAccess(); + await createNode({ + slug: String(formData.get("slug") ?? "").trim(), + name: String(formData.get("name") ?? "").trim(), + authToken: String(formData.get("auth_token") ?? "").trim() || null, + hy2Listen: String(formData.get("hy2_listen") ?? "").trim() || "", + serverHost: String(formData.get("server_host") ?? "").trim(), + serverPort: Number(formData.get("server_port") ?? 443), + clientName: + String(formData.get("client_name") ?? "").trim() || + String(formData.get("name") ?? "").trim(), + bandwidthUpMbps: Number(formData.get("bandwidth_up_mbps") ?? 30), + bandwidthDownMbps: Number(formData.get("bandwidth_down_mbps") ?? 30), + skipCertVerify: boolFromForm(formData.get("skip_cert_verify")), + sni: String(formData.get("sni") ?? "").trim() || null, + trafficStatsUrl: String(formData.get("traffic_stats_url") ?? "").trim(), + trafficStatsListen: + String(formData.get("traffic_stats_listen") ?? "").trim() || ":9999", + trafficStatsSecret: + String(formData.get("traffic_stats_secret") ?? "").trim(), + enabled: boolFromForm(formData.get("enabled")), + pollIntervalSeconds: Number(formData.get("poll_interval_seconds") ?? 15), + }); + + revalidatePath("/admin/nodes"); + revalidatePath("/admin/dashboard"); +} + +export async function updateNodeAction(formData: FormData) { + await requireAdminAccess(); + await updateNode({ + id: Number(formData.get("id")), + slug: String(formData.get("slug") ?? "").trim(), + name: String(formData.get("name") ?? "").trim(), + authToken: String(formData.get("auth_token") ?? "").trim(), + hy2Listen: String(formData.get("hy2_listen") ?? "").trim() || "", + serverHost: String(formData.get("server_host") ?? "").trim(), + serverPort: Number(formData.get("server_port") ?? 443), + clientName: + String(formData.get("client_name") ?? "").trim() || + String(formData.get("name") ?? "").trim(), + bandwidthUpMbps: Number(formData.get("bandwidth_up_mbps") ?? 30), + bandwidthDownMbps: Number(formData.get("bandwidth_down_mbps") ?? 30), + skipCertVerify: boolFromForm(formData.get("skip_cert_verify")), + sni: String(formData.get("sni") ?? "").trim() || null, + trafficStatsUrl: String(formData.get("traffic_stats_url") ?? "").trim(), + trafficStatsListen: + String(formData.get("traffic_stats_listen") ?? "").trim() || ":9999", + trafficStatsSecret: String(formData.get("traffic_stats_secret") ?? "").trim(), + enabled: boolFromForm(formData.get("enabled")), + pollIntervalSeconds: Number(formData.get("poll_interval_seconds") ?? 15), + }); + + revalidatePath("/admin/nodes"); + revalidatePath("/admin/dashboard"); +} + +export async function deleteNodeAction(formData: FormData) { + await requireAdminAccess(); + await deleteNode(Number(formData.get("id"))); + revalidatePath("/admin/nodes"); + revalidatePath("/admin/dashboard"); +} diff --git a/app/admin/nodes/page.tsx b/app/admin/nodes/page.tsx new file mode 100644 index 0000000..1d43e4d --- /dev/null +++ b/app/admin/nodes/page.tsx @@ -0,0 +1,347 @@ +import { getRequestOrigin } from "@/lib/request-origin"; +import { buildNodeHy2Config } from "@/lib/subscription"; +import { buildNodeAuthUrl, buildTrafficStatsUrl, listNodes } from "@/lib/store"; + +import { createNodeAction, deleteNodeAction, updateNodeAction } from "./actions"; + +function getNodePills(node: { + enabled: number; + last_error_message: string | null; +}) { + return [ + { + label: node.enabled ? "已启用" : "已禁用", + className: `status-pill ${node.enabled ? "status-pill--ok" : "status-pill--off"}`, + }, + node.last_error_message + ? { label: "同步异常", className: "status-pill status-pill--danger" } + : { label: "同步正常", className: "status-pill status-pill--sage" }, + ]; +} + +export default async function NodesPage() { + const [nodes, origin] = await Promise.all([listNodes(), getRequestOrigin()]); + + return ( +
+
+
+ NODES +

节点管理

+

+ 统一维护 hy2 节点监听参数、鉴权 URL、trafficStats 接入和客户端导出配置。 +

+
+ URL 自动推导 + Secret 自动生成 +
+
+
+
+ 节点总数 + {nodes.length} +
+
+ 启用中 + {nodes.filter((node) => node.enabled).length} +
+
+
+ +
+
+

新增节点

+
+
+ + + + + + + + + + + + + + + + + +
+
+ +
+ {nodes.map((node) => { + const authUrl = buildNodeAuthUrl(node.slug, node.auth_token, origin); + const suggestedTrafficStatsUrl = buildTrafficStatsUrl( + node.server_host, + node.traffic_stats_listen, + ); + + return ( +
+
+
+

{node.name}

+

/{node.slug}

+
+
+
+ {getNodePills(node).map((pill) => ( + + {pill.label} + + ))} +
+

+ 最近同步 {node.last_sync_ok_at ?? "无"} / 错误{" "} + {node.last_error_at ?? "无"} +

+
+
+
+
+ hy2 监听 + {node.hy2_listen} +
+
+ 对外地址 + {node.server_host}:{node.server_port} +
+
+ trafficStats + {node.traffic_stats_url} +
+
+ +
+ + + + + + + + + + + + + + + + + + +
+ +
+ 节点接入说明 +

+ hy2 HTTP 鉴权 URL 必须独立配置到本节点。面板通过路径识别节点来源,不依赖请求体。 +

+
+{buildNodeHy2Config(node, origin)}
+                
+

+ 面板轮询地址:{node.traffic_stats_url} +

+

+ 自动推导地址:{suggestedTrafficStatsUrl} +

+

+ 鉴权 URL:{authUrl} +

+ + 下载 hy2 config.yaml + + {node.last_error_message ? ( +

{node.last_error_message}

+ ) : null} +
+ +
+ + +
+
+ ); + })} +
+
+ ); +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..5587a1b --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function AdminIndexPage() { + redirect("/admin/dashboard"); +} diff --git a/app/admin/subscriptions/actions.ts b/app/admin/subscriptions/actions.ts new file mode 100644 index 0000000..3dc840b --- /dev/null +++ b/app/admin/subscriptions/actions.ts @@ -0,0 +1,59 @@ +"use server"; + +import { revalidatePath } from "next/cache"; + +import { createStableId } from "@/lib/password"; +import { requireAdminAccess } from "@/lib/session"; +import { + getSubscriptionSettings, + saveProxyGroups, + saveSubscriptionSettings, +} from "@/lib/store"; + +export async function saveSubscriptionSettingsAction(formData: FormData) { + await requireAdminAccess(); + + await saveSubscriptionSettings({ + template: String(formData.get("template") ?? ""), + extraProxyGroups: String(formData.get("extra_proxy_groups") ?? ""), + profileName: String(formData.get("profile_name") ?? "").trim() || "FeieProxy", + }); + + revalidatePath("/admin/subscriptions"); +} + +export async function saveProxyGroupAction(formData: FormData) { + await requireAdminAccess(); + + const settings = await getSubscriptionSettings(); + const id = String(formData.get("id") ?? "").trim() || createStableId(); + const name = String(formData.get("name") ?? "").trim(); + const memberSlugs = formData + .getAll("member_slugs") + .map((value) => String(value)) + .filter(Boolean); + + if (!name) { + return; + } + + const proxyGroups = settings.proxyGroups.filter((group) => group.id !== id); + proxyGroups.push({ + id, + name, + type: "select", + memberSlugs, + }); + + await saveProxyGroups(proxyGroups); + revalidatePath("/admin/subscriptions"); +} + +export async function deleteProxyGroupAction(formData: FormData) { + await requireAdminAccess(); + + const settings = await getSubscriptionSettings(); + const id = String(formData.get("id") ?? "").trim(); + await saveProxyGroups(settings.proxyGroups.filter((group) => group.id !== id)); + revalidatePath("/admin/subscriptions"); +} diff --git a/app/admin/subscriptions/page.tsx b/app/admin/subscriptions/page.tsx new file mode 100644 index 0000000..204b539 --- /dev/null +++ b/app/admin/subscriptions/page.tsx @@ -0,0 +1,160 @@ +import { getRequestOrigin } from "@/lib/request-origin"; +import { buildSubscriptionUrl, getDefaultClashTemplate } from "@/lib/subscription"; +import { + getAdminMirrorUser, + getSubscriptionSettings, + listSubscriptionNodes, +} from "@/lib/store"; + +import { + deleteProxyGroupAction, + saveProxyGroupAction, + saveSubscriptionSettingsAction, +} from "./actions"; + +export default async function AdminSubscriptionsPage() { + const [settings, adminUser, nodes, origin] = await Promise.all([ + getSubscriptionSettings(), + getAdminMirrorUser(), + listSubscriptionNodes(), + getRequestOrigin(), + ]); + + return ( +
+
+
+ SUBSCRIPTIONS +

订阅与模板

+

+ 管理 Clash 基础模板、订阅文件名、代理分组以及管理员默认不限量订阅链接。 +

+
+ 动态 proxies + 分组 {settings.proxyGroups.length} +
+
+
+
+ 可用节点 + {nodes.length} +
+
+ 默认主组 + {settings.profileName} +
+
+
+ +
+
+

管理员订阅

+
+ {adminUser?.subscription_token ? ( +
+

+ {buildSubscriptionUrl(adminUser.subscription_token, origin)} +

+
+ ) : ( +

管理员订阅链接暂不可用。

+ )} +
+ +
+
+

Proxy Groups

+
+
+ {settings.proxyGroups.map((group) => ( +
+
+ + +
+ {nodes.map((node) => ( + + ))} +
+
+ +
+
+
+ + +
+
+ ))} + +
+
+ +
+ {nodes.map((node) => ( + + ))} +
+ +
+
+
+
+ +
+
+

Clash 模板

+
+
+ +