From 8e0fa00a73c4512435b57379921403851e86d969 Mon Sep 17 00:00:00 2001
From: lhc <2+lhc@noreply.localhost>
Date: Mon, 5 Oct 2026 17:32:00 +0800
Subject: [PATCH] Initialize Clash Web source and deployment documentation
---
.gitignore | 38 +
Makefile | 25 +
README.md | 58 +
cmd/clashweb/main.go | 59 +
deploy/clashweb-mihomo.service | 28 +
deploy/clashweb.service | 23 +
docs/api.md | 44 +
docs/deployment.md | 130 +
docs/development.md | 121 +
docs/third-party.md | 7 +
docs/upgrading.md | 92 +
go.mod | 24 +
go.sum | 59 +
internal/app/api.go | 626 +++++
internal/app/app.go | 316 +++
internal/app/app_test.go | 355 +++
internal/app/auth.go | 235 ++
internal/app/connectivity.go | 211 ++
internal/app/connectivity_test.go | 180 ++
internal/app/core.go | 358 +++
internal/app/integration_test.go | 183 ++
internal/app/store.go | 74 +
internal/app/subscription.go | 215 ++
internal/app/subscription_errors_test.go | 35 +
internal/app/types.go | 219 ++
internal/app/updates.go | 125 +
internal/app/updates_test.go | 84 +
scripts/clashweb-control | 11 +
scripts/diagnose-subscription.py | 83 +
scripts/fetch-mihomo.py | 48 +
scripts/fetch-rules.py | 34 +
scripts/install.sh | 61 +
scripts/network-vm.py | 106 +
scripts/package.py | 119 +
scripts/test-e2e.py | 102 +
scripts/update-rules.sh | 22 +
web/assets.go | 36 +
web/e2e/workspace.spec.ts | 147 ++
web/index.html | 11 +
web/package-lock.json | 2843 ++++++++++++++++++++++
web/package.json | 30 +
web/playwright.config.ts | 9 +
web/public/favicon.svg | 10 +
web/src/App.vue | 17 +
web/src/api.ts | 28 +
web/src/components/EmptyState.vue | 5 +
web/src/components/PageHeader.vue | 2 +
web/src/components/Shell.vue | 48 +
web/src/components/TrafficChart.vue | 33 +
web/src/composables/task.ts | 15 +
web/src/format.test.ts | 26 +
web/src/format.ts | 31 +
web/src/main.ts | 21 +
web/src/stores/app.ts | 63 +
web/src/style.css | 193 ++
web/src/types.ts | 112 +
web/src/views/Activity.vue | 37 +
web/src/views/Auth.vue | 13 +
web/src/views/Connectivity.vue | 101 +
web/src/views/Dashboard.vue | 30 +
web/src/views/Nodes.vue | 38 +
web/src/views/Rules.vue | 27 +
web/src/views/Settings.vue | 32 +
web/src/views/Subscriptions.vue | 40 +
web/tsconfig.json | 16 +
web/vite.config.ts | 8 +
66 files changed, 8532 insertions(+)
create mode 100644 .gitignore
create mode 100644 Makefile
create mode 100644 README.md
create mode 100644 cmd/clashweb/main.go
create mode 100644 deploy/clashweb-mihomo.service
create mode 100644 deploy/clashweb.service
create mode 100644 docs/api.md
create mode 100644 docs/deployment.md
create mode 100644 docs/development.md
create mode 100644 docs/third-party.md
create mode 100644 docs/upgrading.md
create mode 100644 go.mod
create mode 100644 go.sum
create mode 100644 internal/app/api.go
create mode 100644 internal/app/app.go
create mode 100644 internal/app/app_test.go
create mode 100644 internal/app/auth.go
create mode 100644 internal/app/connectivity.go
create mode 100644 internal/app/connectivity_test.go
create mode 100644 internal/app/core.go
create mode 100644 internal/app/integration_test.go
create mode 100644 internal/app/store.go
create mode 100644 internal/app/subscription.go
create mode 100644 internal/app/subscription_errors_test.go
create mode 100644 internal/app/types.go
create mode 100644 internal/app/updates.go
create mode 100644 internal/app/updates_test.go
create mode 100755 scripts/clashweb-control
create mode 100644 scripts/diagnose-subscription.py
create mode 100755 scripts/fetch-mihomo.py
create mode 100755 scripts/fetch-rules.py
create mode 100755 scripts/install.sh
create mode 100755 scripts/network-vm.py
create mode 100644 scripts/package.py
create mode 100644 scripts/test-e2e.py
create mode 100755 scripts/update-rules.sh
create mode 100644 web/assets.go
create mode 100644 web/e2e/workspace.spec.ts
create mode 100644 web/index.html
create mode 100644 web/package-lock.json
create mode 100644 web/package.json
create mode 100644 web/playwright.config.ts
create mode 100644 web/public/favicon.svg
create mode 100644 web/src/App.vue
create mode 100644 web/src/api.ts
create mode 100644 web/src/components/EmptyState.vue
create mode 100644 web/src/components/PageHeader.vue
create mode 100644 web/src/components/Shell.vue
create mode 100644 web/src/components/TrafficChart.vue
create mode 100644 web/src/composables/task.ts
create mode 100644 web/src/format.test.ts
create mode 100644 web/src/format.ts
create mode 100644 web/src/main.ts
create mode 100644 web/src/stores/app.ts
create mode 100644 web/src/style.css
create mode 100644 web/src/types.ts
create mode 100644 web/src/views/Activity.vue
create mode 100644 web/src/views/Auth.vue
create mode 100644 web/src/views/Connectivity.vue
create mode 100644 web/src/views/Dashboard.vue
create mode 100644 web/src/views/Nodes.vue
create mode 100644 web/src/views/Rules.vue
create mode 100644 web/src/views/Settings.vue
create mode 100644 web/src/views/Subscriptions.vue
create mode 100644 web/tsconfig.json
create mode 100644 web/vite.config.ts
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..af134aa
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,38 @@
+# Dependencies and build output
+node_modules/
+/web/dist/
+/bin/
+*.tsbuildinfo
+
+# Local runtime state and secrets
+/data/
+/.ssh/
+.env
+.env.*
+!.env.example
+*.sqlite*
+*.db
+*.pem
+*.key
+
+# Test output and temporary files
+/.cache/
+test-results/
+playwright-report/
+__pycache__/
+*.pyc
+*.log
+*.pid
+*.tmp
+*.bak
+*.backup*
+
+# Distribution packages
+*.tar.gz
+*.tar.gz.sha256
+*.zip
+
+# Editor and OS files
+.idea/
+.DS_Store
+Thumbs.db
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..d610a1a
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,25 @@
+.PHONY: dev frontend build test integration e2e assets
+
+dev: frontend
+ go run ./cmd/clashweb
+
+frontend:
+ cd web && npm ci && npm run build
+
+build: frontend
+ mkdir -p bin
+ CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o bin/clashweb ./cmd/clashweb
+
+test: frontend
+ go test -race ./...
+ cd web && npm run typecheck && npm run test
+
+assets:
+ python3 scripts/fetch-mihomo.py
+ python3 scripts/fetch-rules.py
+
+integration: frontend
+ CLASHWEB_TEST_MIHOMO_BIN="$(CURDIR)/bin/mihomo" CLASHWEB_TEST_RULES_DIR="$(CURDIR)/data/mihomo/rules" go test -race ./...
+
+e2e: build
+ python3 scripts/test-e2e.py
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..91abb9e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,58 @@
+# Clash Web
+
+通过浏览器管理 Clash/Mihomo 订阅、代理节点、分流规则、连接、日志和外网连通性测试。使用 Vue 3、Go 和 SQLite,Mihomo 作为独立代理内核运行,服务部署在 Linux VM 上。
+
+## 开发
+
+需要 Go 1.24+、Node.js 22.12+ 或 24+、npm 和 Python 3。从项目根目录执行:
+
+```bash
+make build
+make assets
+./bin/clashweb --mihomo ./bin/mihomo --init
+./bin/mihomo -d ./data/mihomo -f ./data/mihomo/config.yaml
+```
+
+在另一终端启动管理服务:
+
+```bash
+./bin/clashweb --mihomo ./bin/mihomo
+```
+
+打开 `http://127.0.0.1:8080`,用启动日志中的首次设置令牌创建管理员密码。开发环境保持 TUN 关闭。
+
+依赖和内核已准备时,可直接复用,无需重复执行安装、下载命令。前端热更新、测试和配置说明见 [开发文档](docs/development.md)。
+
+## 部署
+
+在开发机生成 Linux amd64 部署包:
+
+```bash
+python3 scripts/package.py
+```
+
+完整包 `clashweb-linux-amd64.tar.gz` 用于首次部署,`clashweb-update-linux-amd64.tar.gz` 用于升级管理程序;两者均带 SHA-256 校验文件。目标 VM 无需安装 Go、Node.js 或 npm。
+
+安装服务、TUN 网关和客户端接入见 [部署文档](docs/deployment.md),程序、内核与规则升级见 [升级文档](docs/upgrading.md)。网络准备及 systemd 安装脚本在目标 VM 上运行。
+
+## 文档
+
+- [API](docs/api.md)
+- [开发与测试](docs/development.md)
+- [部署与恢复](docs/deployment.md)
+- [升级与回退](docs/upgrading.md)
+- [第三方组件](docs/third-party.md)
+
+## 项目结构
+
+```text
+cmd/clashweb/ Go 服务入口
+internal/app/ 认证、SQLite、订阅及 Mihomo 控制
+web/src/ Vue 页面、组件和状态管理
+web/e2e/ 浏览器测试
+scripts/ 构建资源、部署、诊断与打包工具
+deploy/ systemd 服务定义
+docs/ API、开发和运维文档
+```
+
+仓库仅包含源码、锁文件、测试与部署资料。本机数据库、配置密钥、内核二进制、规则文件、依赖目录、构建输出、测试缓存和部署包不提交到 Git。
diff --git a/cmd/clashweb/main.go b/cmd/clashweb/main.go
new file mode 100644
index 0000000..ee30aba
--- /dev/null
+++ b/cmd/clashweb/main.go
@@ -0,0 +1,59 @@
+package main
+
+import (
+ "context"
+ "flag"
+ "log"
+ "net"
+ "net/http"
+ "os"
+ "os/signal"
+ "path/filepath"
+ "syscall"
+ "time"
+
+ "clashweb/internal/app"
+ "clashweb/web"
+)
+
+func env(key, fallback string) string {
+ if value := os.Getenv(key); value != "" {
+ return value
+ }
+ return fallback
+}
+func main() {
+ listen := flag.String("listen", env("CLASHWEB_LISTEN", "127.0.0.1:8080"), "Web 管理监听地址")
+ data := flag.String("data", env("CLASHWEB_DATA_DIR", "data"), "SQLite 数据目录")
+ coreDir := flag.String("core-dir", os.Getenv("CLASHWEB_CORE_DIR"), "Mihomo 工作目录")
+ coreBin := flag.String("mihomo", os.Getenv("CLASHWEB_MIHOMO_BIN"), "Mihomo 可执行文件")
+ initOnly := flag.Bool("init", false, "校验并生成初始配置,然后退出")
+ flag.Parse()
+ a, err := app.New(app.Options{DataDir: *data, CoreDir: *coreDir, CoreBinary: *coreBin, Helper: os.Getenv("CLASHWEB_CONTROL_HELPER"), SecureCookie: os.Getenv("CLASHWEB_SECURE_COOKIE") == "true", Web: web.Handler()})
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer a.Close()
+ ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer cancel()
+ if *initOnly {
+ if err := a.Initialize(ctx); err != nil {
+ log.Fatal(err)
+ }
+ log.Print("初始配置生成完成")
+ return
+ }
+ go a.Run(ctx)
+ server := &http.Server{Addr: *listen, Handler: a.Handler(), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 90 * time.Second, MaxHeaderBytes: 1 << 20, BaseContext: func(net.Listener) context.Context { return ctx }}
+ go func() {
+ <-ctx.Done()
+ shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ _ = server.Shutdown(shutdown)
+ }()
+ abs, _ := filepath.Abs(*data)
+ log.Printf("Clash Web: http://%s (数据: %s)", *listen, abs)
+ if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ log.Fatal(err)
+ }
+}
diff --git a/deploy/clashweb-mihomo.service b/deploy/clashweb-mihomo.service
new file mode 100644
index 0000000..79d11ce
--- /dev/null
+++ b/deploy/clashweb-mihomo.service
@@ -0,0 +1,28 @@
+[Unit]
+Description=Mihomo core for Clash Web
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=clashweb-mihomo
+Group=clashweb-core
+WorkingDirectory=/var/lib/clashweb-core
+ExecStart=/usr/local/bin/mihomo -d /var/lib/clashweb-core -f /var/lib/clashweb-core/config.yaml
+Restart=on-failure
+RestartSec=3
+UMask=0027
+AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE
+CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE
+NoNewPrivileges=true
+ProtectSystem=strict
+ProtectHome=true
+PrivateTmp=true
+ReadWritePaths=/var/lib/clashweb-core
+DevicePolicy=closed
+DeviceAllow=/dev/net/tun rw
+RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
+LimitNOFILE=65536
+
+[Install]
+WantedBy=multi-user.target
diff --git a/deploy/clashweb.service b/deploy/clashweb.service
new file mode 100644
index 0000000..758c482
--- /dev/null
+++ b/deploy/clashweb.service
@@ -0,0 +1,23 @@
+[Unit]
+Description=Clash Web management
+After=network-online.target clashweb-mihomo.service
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=clashweb
+Group=clashweb
+SupplementaryGroups=clashweb-core
+EnvironmentFile=/etc/clashweb/env
+ExecStart=/usr/local/bin/clashweb
+Restart=on-failure
+RestartSec=3
+UMask=0027
+ProtectSystem=strict
+ProtectHome=true
+PrivateTmp=true
+ReadWritePaths=/var/lib/clashweb /var/lib/clashweb-core
+RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
+
+[Install]
+WantedBy=multi-user.target
diff --git a/docs/api.md b/docs/api.md
new file mode 100644
index 0000000..2a78ec6
--- /dev/null
+++ b/docs/api.md
@@ -0,0 +1,44 @@
+# 管理 API v1
+
+所有接口与 Web UI 同源。除首次设置、登录和登录状态外,均需要 `cw_session` HttpOnly cookie。修改接口还需 `X-CSRF-Token`,令牌由登录/首次设置/密码更新及 `/auth/status` 返回。会话有效期 24 小时,退出或修改密码会撤销对应会话。
+
+统一错误:`{"error":"中文说明"}`;认证失败 401、CSRF/跨站失败 403、输入或应用失败 400、内核未连接的实时查询 503。
+
+| 方法 | `/api/v1` 下的路径 | 用途 |
+|---|---|---|
+| GET | `/auth/status` | initialized、authenticated、csrf |
+| POST | `/auth/setup` | `{token,password}` 首次设置;密码 12–72 字节 |
+| POST | `/auth/login` | `{password}` 登录 |
+| POST | `/auth/logout` | 退出 |
+| PUT | `/auth/password` | `{currentPassword,password}`,撤销旧会话 |
+| GET | `/status` | 内核状态、版本、实际出口、设置和配置错误 |
+| GET/POST | `/subscriptions` | 列表/新增 `{name,url,enabled,intervalHours}` |
+| PUT/DELETE | `/subscriptions/{id}` | 编辑/删除 |
+| POST | `/subscriptions/{id}/refresh` | 下载、解析、校验、应用;失败保留旧快照 |
+| GET | `/nodes` | 脱敏节点信息,不返回密码、服务器地址等凭据 |
+| POST | `/nodes/select` | `{mode:"manual",id}` 或 `{mode:"auto"}` |
+| POST | `/nodes/test` | `{ids:[...]}`,1–500 个、并发上限 8 |
+| GET | `/rules` | 默认顺序及自定义规则 |
+| POST | `/rules` | `{type,value,action,enabled}` |
+| PUT/DELETE | `/rules/{id}` | 编辑/删除 |
+| GET/PUT | `/settings` | 读取/完整替换设置;网卡与地址在当前机器校验 |
+| GET | `/connections` | Mihomo 活跃连接 |
+| DELETE | `/connections/{id}` | 关闭指定连接 |
+| DELETE | `/connections` | 关闭全部连接 |
+| POST | `/system/service` | `{action:"start"\|"stop"\|"restart"}`,仅固定内核 unit |
+| GET | `/system/network` | 网卡、IPv4 转发、TUN、资源和辅助程序检查 |
+| GET | `/system/updates` | 手动检查 Mihomo 最新稳定版;返回当前/最新版本、更新状态(无法比较时为 null)、检查时间及发布说明 URL;成功结果缓存 10 分钟,失败返回 502 |
+| GET | `/system/connectivity` | 9 个固定 HTTPS 测试网站的 id/name/url/category |
+| POST | `/system/connectivity/test` | `{siteId}`;当前 VM Mihomo 分流测试;返回 status/detail/httpStatus/durationMS/tlsVerified/checkedAt;全服务并发上限 3,多余返回 429 |
+| GET | `/system/backup` | `{version:1,exportedAt,state}`,敏感配置导出 |
+| POST | `/system/restore` | 同版本备份,校验与回滚;不覆盖管理员 |
+| GET | `/events` | SSE `metrics`,每 2 秒更新累计量、速率与连接数 |
+| GET | `/logs` | SSE `log`、`heartbeat`、`disconnected` |
+
+SSE 认证失效时发送 `expired` 并关闭,浏览器应重新登录。代理关闭使用 settings 的 `proxyEnabled=false`,进入直连模式;与停止内核进程不同,DNS/网关仍运行。
+
+`Settings` 字段:`proxyEnabled,tunEnabled,selectionMode,selectedNode,lanIP,lanCIDR,gateway,interface,mixedPort,dnsPort,testURL,testTimeoutMS`。HTTP/SOCKS 端口为 1024–65535,DNS 为 1–65535,避免与 8080/9090 冲突。开启 TUN 要求填写内网参数并使用 DNS 端口 53。
+
+订阅 ID/规则 ID 为稳定随机 24 位十六进制标识;节点 ID 使用 `订阅ID/提供者/原节点名`,对路径参数需 URL 编码。规则 `type` 为 DOMAIN、DOMAIN-SUFFIX、IP-CIDR,`action` 为 DIRECT 或 PROXY。内网规则优先,其次自定义列表,再大陆规则,最后代理。
+
+默认手动模式首次未选节点或所选节点移除时,代理策略选择 REJECT;不会静默选择其他节点或直连。内核停止时,校验通过的配置可暂存以供下次启动;控制接口认证或通信失败不会当成停止状态忽略。
diff --git a/docs/deployment.md b/docs/deployment.md
new file mode 100644
index 0000000..df5d9d4
--- /dev/null
+++ b/docs/deployment.md
@@ -0,0 +1,130 @@
+# PVE Linux VM 部署与恢复
+
+## 网络前提
+
+- 开发测试机与正式 VM 分开,正式 VM 位于 PVE。
+- 本方案以现有路由器为上游;VM 不负责替代路由器的 DHCP 服务。
+- VM 不提供 DHCP。首先手动配置一台测试客户端;全网设备逐台配置,或后续增加可控路由器。
+- 第一版只处理 IPv4,客户端 IPv6 可能继续通过光猫绕过 VM。测试设备先关闭 IPv6;全网部署前关闭路由器 IPv6 通告。若无法关闭,需要调整网络设备。
+
+## 1. 创建 VM
+
+推荐 Ubuntu 24.04 LTS / Debian 12+,1–2 vCPU、1 GB 内存、8 GB 磁盘起步。VM 网卡连接 PVE 的 LAN bridge(通常是 `vmbr0`),用 VirtIO 网卡,与客户端在同一网段。不要把 VM 放进仅 NAT 的虚拟网络;无需修改 PVE 宿主机的默认网关。
+
+假设(示例地址,部署时替换):
+
+| 角色 | 地址 |
+|---|---|
+| 光猫/上游路由器 | `192.168.1.1` |
+| LAN 网段 | `192.168.1.0/24` |
+| VM 固定地址 | `192.168.1.10` |
+| VM 网卡 | `ens18` |
+| 测试客户端 | `192.168.1.50` |
+
+将路由器 DHCP 地址池设为如 `192.168.1.100–200`,把 VM 固定地址放在池外,并确认没有地址冲突。VM 自身的上游网关始终为 `192.168.1.1`;初始化时 VM 自身 DNS 可用光猫或公共 DNS,不要指向尚未运行的自己。
+
+## 2. 复制程序并安装服务
+
+开发机生成的 `clashweb-linux-amd64.tar.gz` 包含管理程序、Mihomo 和规则,复制到 amd64 VM 即可,无需再次构建或下载内核。将配套 `.sha256` 文件一起复制。在 VM 上准备系统工具(这些 apt 包仅需安装到目标 VM):
+
+```bash
+sudo apt update
+sudo apt install -y sudo python3 iproute2 nftables ca-certificates
+sha256sum -c clashweb-linux-amd64.tar.gz.sha256
+tar -xzf clashweb-linux-amd64.tar.gz
+cd clashweb-linux-amd64
+sha256sum -c SHA256SUMS
+sudo ./scripts/install.sh --target-vm --bind 192.168.1.10:8080
+```
+
+安装脚本创建普通用户 `clashweb` 与 `clashweb-mihomo`,配置独立数据目录、核心网络能力和受限的服务控制辅助程序。管理后端通过 sudo 仅允许启停固定的 `clashweb-mihomo.service`,不允许执行任意 root 命令。
+
+安装完成时 TUN 仍关闭;光猫、DHCP 和客户端网络不变。已有配置再次安装时会保留,安装脚本不会复制开发机 SQLite 数据库。
+
+```bash
+sudo systemctl status clashweb clashweb-mihomo --no-pager
+sudo journalctl -u clashweb -n 30 --no-pager
+```
+
+打开 `http://192.168.1.10:8080`,使用日志中的首次设置令牌设置密码。添加订阅、更新节点、测试延迟并选择一个节点。先在客户端用显式 HTTP/SOCKS 代理验证:代理地址为 `192.168.1.10:7890`。需在 UI 填写 VM 地址、LAN 网段、网卡并保存后,代理端口才会监听 LAN。
+
+## 3. 准备单网卡旁路转发
+
+在 VM 上检查,再显式应用网络配置:
+
+```bash
+python3 scripts/network-vm.py check
+sudo python3 scripts/network-vm.py apply --target-vm --interface ens18 --lan-cidr 192.168.1.0/24
+```
+
+脚本开启 IPv4 转发、关闭 ICMP 路由重定向、调整所需反向路径检查,并创建独立的 `ip clashweb_nat` nftables 表,为单网卡转发提供回程处理。只管理自己的配置文件和 NAT 表,不清空现有防火墙,不修改网卡 IP、默认网关或 DHCP。原 sysctl 值记录在 `/var/lib/clashweb-network/backup.json`。
+
+如果 VM 使用 UFW/nftables 或启用了 PVE 防火墙,应允许 LAN 到 VM 的管理 TCP 8080、混合代理 TCP 7890、DNS UDP/TCP 53、以及所需的转发流量;规则仅面向实际内网网段。若已有转发链默认 DROP,需要在已有防火墙中放行该 VM 的 LAN 转发。不要开放控制端口 9090;它只监听 VM 本机。
+
+## 4. UI 启用网关模式
+
+在「系统设置」填写:VM 地址 `192.168.1.10`、网段 `192.168.1.0/24`、上游网关 `192.168.1.1`、网卡 `ens18`、DNS 端口 `53`,开启 TUN,保存。
+
+检查日志没有 TUN、DNS 监听或路由创建错误;VM 上 `ip link show cw-tun` 应存在,`ip rule` 应出现内核管理的路由规则。已有程序占用 VM 的 `IP:53` 时,应先处理冲突;systemd-resolved 的 `127.0.0.53:53` 与只绑定 VM LAN IP 的监听通常可以共存。
+
+手动配置测试客户端:
+
+- IPv4 地址仍在 LAN 内,若采用固定地址应选光猫 DHCP 地址池之外的未占用地址。
+- IPv4 网关设为 `192.168.1.10`。
+- DNS 只设为 `192.168.1.10`,不保留备用光猫 DNS;关闭客户端 IPv6。
+- 清除原系统/浏览器代理设置,测试纯透明代理。
+
+浏览器的加密 DNS 不会自动被普通 53 端口 DNS 劫持覆盖;验证阶段关闭浏览器安全 DNS/系统私人 DNS,使用 VM DNS,确保域名分流行为一致。
+
+## 5. 验收
+
+1. 从客户端访问管理页面、光猫、内网设备,确认内网仍可达。
+2. 访问大陆网站,在「连接」中确认命中大陆规则且出口为 DIRECT。
+3. 访问国外网站,确认使用所选代理节点;切换节点后以新连接验证出口。
+4. 测试 UDP:需要订阅节点和目标应用都支持 UDP。HTTP 代理节点等不支持 UDP 的节点不会凭空获得 UDP 能力。
+5. 所选节点失效时,国外访问失败,内核运行期间大陆和内网仍直连;手动模式不自动换节点。
+6. 重启 VM,确认服务、网络准备、订阅快照与选择恢复,再扩大客户端范围。
+
+开发环境的显式代理测试不能替代以上真实 LAN/TUN 验收。
+
+## 恢复与故障定位
+
+节点故障与 VM 停机不同。VM 停机或内核停止时,作为 DNS/网关的客户端可能整体断网,不能由该 VM 自动修复。
+
+优先恢复客户端:把网关和 DNS 改回光猫 `192.168.1.1`,或恢复自动获取,刷新 DHCP 租约/DNS 缓存。光猫 DHCP 一直保留,因此无需恢复光猫 DHCP。
+
+要撤销 VM 网络准备:先在 UI 关闭 TUN(或停止内核),再执行:
+
+```bash
+sudo python3 scripts/network-vm.py rollback --target-vm
+```
+
+它删除本项目 NAT 表和网络 unit/config,恢复记录的 sysctl 值。也可恢复中途失败的 apply。不要对其他软件或系统防火墙执行 flush。
+
+```bash
+sudo journalctl -u clashweb -u clashweb-mihomo -n 100 --no-pager
+sudo systemctl restart clashweb-mihomo
+ip -4 route
+ip rule
+sudo nft list table ip clashweb_nat
+```
+
+## 运维与备份
+
+- UI 导出备份含订阅 URL 和节点凭据,备份不包含管理员密码、登录会话或内核控制密钥;导入不会更改管理员密码。
+- 需要完整迁移管理员时,停止服务后备份 `/var/lib/clashweb` 与 `/var/lib/clashweb-core`;不要在 SQLite 正在写入时只复制单个 `.sqlite` 文件而遗漏 WAL。
+- 规则库更新:在目标 VM 解压目录运行 `sudo scripts/update-rules.sh`。下载失败不替换旧文件,校验或重启失败恢复旧规则。
+- 默认下载脚本使用 Mihomo v1.19.32;升级时显式下载、检查、部署并复测。首次部署与规则更新需要外网下载能力,可先在开发机准备文件。
+- 如通过 HTTPS 反向代理管理界面,关闭 SSE 缓冲,并设置 `CLASHWEB_SECURE_COOKIE=true`。默认方案只用于受信任内网。
+
+## 订阅下载故障排查
+
+下载由管理服务发起,先检查 VM 的系统 DNS、时间、CA 证书和出站连接,确认订阅地址有效;YAML 解析失败与网络下载失败分别排查。
+
+只读诊断脚本根据保存的订阅 hostname 匹配,使用服务用户测试系统 DNS、直接下载与 Mihomo 代理下载,仅输出状态摘要,不显示订阅 URL 和节点凭据:
+
+```bash
+sudo python3 scripts/diagnose-subscription.py --host subscription.example.com
+```
+
+默认代理地址取保存的 VM IPv4 与混合端口,也可显式传入 --proxy http://VM_IP:7890。根据诊断结果修复 VM 使用的网络管理器/DNS 配置;避免将订阅令牌、配置备份或完整日志写入公开仓库。
diff --git a/docs/development.md b/docs/development.md
new file mode 100644
index 0000000..fc20146
--- /dev/null
+++ b/docs/development.md
@@ -0,0 +1,121 @@
+# 开发与测试
+
+## 环境
+
+- Go 1.24+。
+- Node.js 22.12+ 或 24+,npm。
+- Python 3;Linux amd64 打包脚本的安全解压校验需要 Python 3.12+。
+- Linux;正式代理服务与开发环境分开。
+
+依赖版本由 go.mod、go.sum 和 web/package-lock.json 固定。Go 模块目录遵循开发机的 go env 配置。
+
+## 首次准备
+
+在项目根目录执行:
+
+```bash
+make build
+make assets
+```
+
+make build 安装锁定的前端依赖、生成 web/dist,再构建 bin/clashweb;静态 Web 资源嵌入 Go 程序。make assets 下载指定 Mihomo 稳定版和大陆 MRS 规则,分别保存到 bin/mihomo 和 data/mihomo/rules。
+
+资源已有时可复用:
+
+```bash
+cd web
+npm run build
+cd ..
+CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o bin/clashweb ./cmd/clashweb
+```
+
+Go 构建与测试依赖 web/dist 存在,首次克隆应先构建前端。前端构建完成后再执行 Go 构建,避免嵌入尚未生成的资源。
+
+## 本地运行
+
+终端一,生成配置并启动内核:
+
+```bash
+./bin/clashweb --mihomo ./bin/mihomo --init
+./bin/mihomo -d ./data/mihomo -f ./data/mihomo/config.yaml
+```
+
+终端二,启动管理服务:
+
+```bash
+./bin/clashweb --mihomo ./bin/mihomo
+```
+
+访问 http://127.0.0.1:8080。首次设置令牌在启动日志中显示,管理员密码为 12–72 字节。已初始化的数据目录使用原管理员密码登录。
+
+默认混合代理为 127.0.0.1:7890,DNS 为 127.0.0.1:1053,Mihomo 控制接口为 127.0.0.1:9090。开发时保持 TUN 关闭;IPv4 转发、防火墙和网关配置在目标 VM 上准备。
+
+前端热更新时,保持 Go 后端运行,并在另一终端执行:
+
+```bash
+cd web
+npm run dev
+```
+
+Vite 将 /api 转发至 http://127.0.0.1:8080。远程开发可用 SSH 转发访问管理服务:
+
+```bash
+ssh -N -L 8080:127.0.0.1:8080 USER@DEV_HOST
+```
+
+停止时在管理服务和内核终端分别按 Ctrl+C。本地 data/ 目录保留管理员、订阅和设置。
+
+## 测试
+
+前端构建已完成时可直接执行:
+
+```bash
+go test -race ./...
+go vet ./...
+cd web
+npm run test
+cd ..
+```
+
+真实内核集成测试:
+
+```bash
+CLASHWEB_TEST_MIHOMO_BIN="$PWD/bin/mihomo" \
+CLASHWEB_TEST_RULES_DIR="$PWD/data/mihomo/rules" \
+go test -race ./...
+```
+
+浏览器测试首次需要安装 Chromium:
+
+```bash
+cd web
+npx playwright install chromium
+cd ..
+python3 scripts/test-e2e.py
+```
+
+E2E 使用临时数据库、本地订阅夹具、真实后端和 Mihomo;管理服务监听 127.0.0.1:18080,需要 9090/7890/1053 空闲。检测到端口占用时终止,结束后清理测试进程和临时数据库。常用网站响应在浏览器测试中使用夹具。
+
+截图写入 .cache/screenshots,测试报告写入 web/test-results,均不提交到 Git。无需重装依赖时,直接使用以上命令;make test、make integration、make e2e 会先重新准备前端依赖。
+
+## 配置与提交范围
+
+- bin/:本地生成的管理程序和下载的内核。
+- data/:SQLite、订阅、控制密钥、内核配置和规则。
+- web/dist/:嵌入 Go 程序的前端输出。
+- web/node_modules/:前端开发依赖。
+- .cache/、测试报告和压缩包:过程或分发产物。
+
+这些目录和文件由 .gitignore 排除。只提交源码、锁文件、测试、部署脚本和维护文档。
+
+## 打包
+
+在 Linux amd64 构建环境执行:
+
+```bash
+GOOS=linux GOARCH=amd64 GOAMD64=v1 CGO_ENABLED=0 \
+go build -trimpath -ldflags='-s -w' -o bin/clashweb ./cmd/clashweb
+python3 scripts/package.py
+```
+
+脚本只使用现有程序与规则生成完整安装包和管理更新包,附文件清单和 SHA-256,执行解压校验与隔离初始化,不包含开发数据库或订阅。分发包通过 Gitea Release 或其他文件渠道传递,不作为源码提交。
diff --git a/docs/third-party.md b/docs/third-party.md
new file mode 100644
index 0000000..80ade39
--- /dev/null
+++ b/docs/third-party.md
@@ -0,0 +1,7 @@
+# 复用组件
+
+- [Mihomo](https://github.com/MetaCubeX/mihomo):独立进程形式使用的代理内核,遵循其 GPL-3.0 许可。
+- [MetaCubeX meta-rules-dat](https://github.com/MetaCubeX/meta-rules-dat):大陆域名/IP MRS 规则资源,使用项目自身的分发与许可约定。
+- Vue、Vue Router、Pinia、Naive UI、Lucide、ECharts:前端组件与图表。依赖许可证随 npm 包分发。
+- modernc.org/sqlite、yaml.v3、gorilla/websocket、golang.org/x:后端存储、解析与通信,依赖版本与校验值见 go.mod/go.sum。
+- Vitest、Playwright:仅用于开发测试;Chromium 与 FFmpeg 测试组件不会打包进服务程序。
diff --git a/docs/upgrading.md b/docs/upgrading.md
new file mode 100644
index 0000000..0f4e8ad
--- /dev/null
+++ b/docs/upgrading.md
@@ -0,0 +1,92 @@
+# 软件、内核与规则升级
+
+三个部分分别更新:Clash Web 管理程序(包含 Web UI)、Mihomo 代理内核、大陆规则库。管理员、订阅与选择在数据目录中,升级程序时不覆盖它们。
+
+## 检查版本
+
+「系统设置 → Mihomo 服务 → 内核更新」可手动检查 GitHub 官方最新稳定版,显示当前版本、最新版本和发布说明链接。查询结果缓存 10 分钟,避免反复请求 GitHub。内核离线或运行开发版时仍可查最新稳定版,但不能确认版本高低。网络或 GitHub 限流错误会明确提示。
+
+检查仅获取版本信息,不下载程序、不自动升级;未开启无人值守更新。管理程序从仓库获取源码构建,或使用维护者提供的更新包。内核更新检查不等同于管理程序更新。
+
+## 升级 Mihomo
+
+先在 UI 导出配置备份。正式 VM 升级前建议做 PVE 快照,便于恢复完整服务状态。浏览官方发布说明,选择明确的稳定版版本号。
+
+在项目/解压目录下载到新文件;将 `vX.Y.Z` 替换为所选真实版本:
+
+```bash
+python3 scripts/fetch-mihomo.py --version vX.Y.Z --output bin/mihomo.next
+```
+
+下载脚本选择当前机器架构对应的官方资产,根据 GitHub 的 SHA-256 digest 校验,然后解压。缺少校验值或校验失败会终止,不覆盖现有内核。此步会产生一次新的内核下载。
+
+正式 VM 上,先放置候选内核并用现有配置验证,成功后再替换:
+
+```bash
+sudo install -m 0755 -o root -g root bin/mihomo.next /usr/local/bin/mihomo.next
+sudo -u clashweb-mihomo /usr/local/bin/mihomo.next -t \
+ -d /var/lib/clashweb-core -f /var/lib/clashweb-core/config.yaml
+```
+
+只有校验成功才执行:
+
+```bash
+sudo install -m 0755 -o root -g root /usr/local/bin/mihomo /usr/local/bin/mihomo.previous
+sudo mv /usr/local/bin/mihomo.next /usr/local/bin/mihomo
+sudo systemctl restart clashweb-mihomo
+sudo systemctl status clashweb-mihomo --no-pager
+```
+
+在 UI 确认内核版本和节点选择,并用测试客户端验证大陆/国外访问。重启期间代理连接会中断。新版本异常时恢复旧程序:
+
+```bash
+sudo install -m 0755 -o root -g root /usr/local/bin/mihomo.previous /usr/local/bin/mihomo.rollback
+sudo mv /usr/local/bin/mihomo.rollback /usr/local/bin/mihomo
+sudo systemctl restart clashweb-mihomo
+```
+
+本机开发预览也可先下载 `bin/mihomo.next`,使用 `-t -d ./data/mihomo -f ./data/mihomo/config.yaml` 校验;成功后停止预览内核,保留旧二进制再替换 `bin/mihomo` 并重新启动。保持 TUN 关闭。不要在开发机执行上述正式 VM 的 systemd 操作。
+
+## 升级 Clash Web 管理程序
+
+取得新的项目源码,在开发机执行 `make build`。只复制新的 `bin/clashweb` 到目标 VM,保留数据目录:
+
+```bash
+sudo install -m 0755 -o root -g root /usr/local/bin/clashweb /usr/local/bin/clashweb.previous
+sudo install -m 0755 -o root -g root bin/clashweb /usr/local/bin/clashweb.next
+sudo mv /usr/local/bin/clashweb.next /usr/local/bin/clashweb
+sudo systemctl restart clashweb
+```
+
+重新打开页面确认登录、订阅和节点。失败时将 `clashweb.previous` 复制到新临时文件,再移动回 `clashweb` 并重启。未来若更新涉及数据库格式变更,应遵循相应发布说明;不能只替换旧二进制就假定数据必然兼容。
+
+## 更新规则
+
+正式 VM 上在项目/解压目录运行:
+
+```bash
+sudo scripts/update-rules.sh
+```
+
+下载后校验当前配置、重启内核,失败时恢复旧规则。与程序更新互相独立。
+
+## 预构建管理程序更新包
+
+打包脚本生成 `clashweb-update-linux-amd64.tar.gz`(仅管理服务与嵌入 UI)与 `clashweb-linux-amd64.tar.gz`(完整安装)。已有部署使用更新包即可,无需升级 Mihomo 或重配网络。
+
+将更新包传到 VM,在包所在目录执行:
+
+```bash
+tar -xzf clashweb-update-linux-amd64.tar.gz
+cd clashweb-update-linux-amd64
+sha256sum -c SHA256SUMS
+sudo install -m 0755 -o root -g root /usr/local/bin/clashweb /usr/local/bin/clashweb.previous
+sudo install -m 0755 -o root -g root bin/clashweb /usr/local/bin/clashweb.next
+sudo mv /usr/local/bin/clashweb.next /usr/local/bin/clashweb
+sudo systemctl restart clashweb
+sudo systemctl status clashweb --no-pager
+```
+
+成功后刷新管理页面,确认登录、订阅和节点状态。管理服务重启不重启独立 Mihomo;管理员、订阅和网络配置保留。
+
+出现异常可按前面的步骤用 clashweb.previous 回退;数据库格式变更应按对应版本说明处理。
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..eb864c6
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,24 @@
+module clashweb
+
+go 1.24.0
+
+require (
+ github.com/gorilla/websocket v1.5.3
+ golang.org/x/crypto v0.43.0
+ golang.org/x/net v0.45.0
+ gopkg.in/yaml.v3 v3.0.1
+ modernc.org/sqlite v1.39.0
+)
+
+require (
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/ncruces/go-strftime v0.1.9 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
+ golang.org/x/sys v0.37.0 // indirect
+ modernc.org/libc v1.66.3 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.11.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..ef8167b
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,59 @@
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
+github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
+golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
+golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
+golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
+golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
+golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
+golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
+golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
+golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
+golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
+golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
+golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
+modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
+modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
+modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
+modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
+modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
+modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
+modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
+modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/internal/app/api.go b/internal/app/api.go
new file mode 100644
index 0000000..923bd2d
--- /dev/null
+++ b/internal/app/api.go
@@ -0,0 +1,626 @@
+package app
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+)
+
+type NodeView struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ SubscriptionID string `json:"subscriptionID"`
+ SubscriptionName string `json:"subscriptionName"`
+ Type string `json:"type"`
+ UDP bool `json:"udp"`
+ Delay *int `json:"delay"`
+ Alive *bool `json:"alive"`
+ Selected bool `json:"selected"`
+}
+
+func (a *App) api() http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /api/v1/status", a.status)
+ mux.HandleFunc("GET /api/v1/subscriptions", a.subscriptions)
+ mux.HandleFunc("POST /api/v1/subscriptions", a.saveSubscription)
+ mux.HandleFunc("PUT /api/v1/subscriptions/{id}", a.saveSubscription)
+ mux.HandleFunc("DELETE /api/v1/subscriptions/{id}", a.deleteSubscription)
+ mux.HandleFunc("POST /api/v1/subscriptions/{id}/refresh", a.refreshSubscription)
+ mux.HandleFunc("GET /api/v1/nodes", a.nodes)
+ mux.HandleFunc("POST /api/v1/nodes/select", a.selectNode)
+ mux.HandleFunc("POST /api/v1/nodes/test", a.testNodes)
+ mux.HandleFunc("GET /api/v1/rules", a.rules)
+ mux.HandleFunc("POST /api/v1/rules", a.saveRule)
+ mux.HandleFunc("PUT /api/v1/rules/{id}", a.saveRule)
+ mux.HandleFunc("DELETE /api/v1/rules/{id}", a.deleteRule)
+ mux.HandleFunc("GET /api/v1/settings", func(w http.ResponseWriter, r *http.Request) { jsonResponse(w, 200, a.Snapshot().Settings) })
+ mux.HandleFunc("PUT /api/v1/settings", a.settings)
+ mux.HandleFunc("POST /api/v1/system/service", a.service)
+ mux.HandleFunc("GET /api/v1/system/network", a.network)
+ mux.HandleFunc("GET /api/v1/system/updates", a.coreUpdates)
+ mux.HandleFunc("GET /api/v1/system/connectivity", a.connectivitySites)
+ mux.HandleFunc("POST /api/v1/system/connectivity/test", a.testConnectivity)
+ mux.HandleFunc("GET /api/v1/system/backup", a.backup)
+ mux.HandleFunc("POST /api/v1/system/restore", a.restore)
+ mux.HandleFunc("GET /api/v1/connections", a.connections)
+ mux.HandleFunc("DELETE /api/v1/connections/{id}", a.closeConnection)
+ mux.HandleFunc("DELETE /api/v1/connections", a.closeConnection)
+ mux.HandleFunc("GET /api/v1/events", a.events)
+ mux.HandleFunc("GET /api/v1/logs", a.logs)
+ return mux
+}
+
+func (a *App) status(w http.ResponseWriter, r *http.Request) {
+ jsonResponse(w, 200, a.getStatus(r.Context()))
+}
+func (a *App) getStatus(ctx context.Context) map[string]any {
+ state := a.Snapshot()
+ ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
+ defer cancel()
+ var version map[string]any
+ err := a.core.Request(ctx, "GET", "/version", nil, &version)
+ count := 0
+ for _, sub := range state.Subscriptions {
+ if sub.Enabled {
+ count += len(sub.Nodes)
+ }
+ }
+ a.mu.RLock()
+ configError := a.lastError
+ a.mu.RUnlock()
+ result := map[string]any{"coreOnline": err == nil, "coreInstalled": a.core.Binary != "", "version": "", "uptimeSeconds": int(time.Since(a.started).Seconds()), "nodeCount": count, "subscriptionCount": len(state.Subscriptions), "settings": state.Settings, "configError": configError, "actualNode": "", "selectedAvailable": false}
+ _, tunErr := net.InterfaceByName("cw-tun")
+ result["tunActive"] = err == nil && state.Settings.TunEnabled && tunErr == nil
+ if err == nil {
+ result["version"] = version["version"]
+ var group map[string]any
+ if a.core.Request(ctx, "GET", "/proxies/"+ProxyGroup, nil, &group) == nil {
+ actual, _ := group["now"].(string)
+ if actual == AutoGroup {
+ var auto map[string]any
+ if a.core.Request(ctx, "GET", "/proxies/"+AutoGroup, nil, &auto) == nil {
+ actual, _ = auto["now"].(string)
+ }
+ }
+ result["actualNode"] = actual
+ result["selectedAvailable"] = actual != "" && actual != "REJECT"
+ }
+ }
+ if err != nil {
+ result["coreError"] = err.Error()
+ }
+ return result
+}
+
+func (a *App) subscriptions(w http.ResponseWriter, r *http.Request) {
+ items := []map[string]any{}
+ for _, s := range a.Snapshot().Subscriptions {
+ a.refreshMu.Lock()
+ refreshing := a.refreshing[s.ID]
+ a.refreshMu.Unlock()
+ items = append(items, map[string]any{"id": s.ID, "name": s.Name, "url": s.URL, "enabled": s.Enabled, "intervalHours": s.IntervalHours, "updatedAt": s.UpdatedAt, "attemptedAt": s.AttemptedAt, "lastError": s.LastError, "usage": s.Usage, "nodeCount": len(s.Nodes), "refreshing": refreshing})
+ }
+ jsonResponse(w, 200, items)
+}
+
+func (a *App) saveSubscription(w http.ResponseWriter, r *http.Request) {
+ var input struct {
+ Name string `json:"name"`
+ URL string `json:"url"`
+ Enabled bool `json:"enabled"`
+ IntervalHours int `json:"intervalHours"`
+ }
+ if err := decode(w, r, &input); err != nil {
+ writeError(w, 400, err.Error())
+ return
+ }
+ id := r.PathValue("id")
+ newSub := id == ""
+ if newSub {
+ id = randomID()
+ }
+ sub := Subscription{ID: id, Name: strings.TrimSpace(input.Name), URL: strings.TrimSpace(input.URL), Enabled: input.Enabled, IntervalHours: input.IntervalHours, Nodes: []map[string]any{}}
+ if err := validateSubscription(sub); err != nil {
+ writeError(w, 400, err.Error())
+ return
+ }
+ if newSub {
+ // Empty metadata can be saved before installing the core; importing nodes needs validation.
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ candidate := cloneState(a.state)
+ if len(candidate.Subscriptions) >= 50 {
+ writeError(w, 400, "最多允许 50 个订阅")
+ return
+ }
+ candidate.Subscriptions = append(candidate.Subscriptions, sub)
+ if err := a.store.Save(candidate); err != nil {
+ writeError(w, 500, "保存失败")
+ return
+ }
+ a.state = candidate
+ } else {
+ err := a.change(r.Context(), func(s *State) error {
+ for i, old := range s.Subscriptions {
+ if old.ID == id {
+ sub.Nodes = old.Nodes
+ sub.UpdatedAt = old.UpdatedAt
+ sub.AttemptedAt = old.AttemptedAt
+ sub.LastError = old.LastError
+ sub.Usage = old.Usage
+ s.Subscriptions[i] = sub
+ return nil
+ }
+ }
+ return errors.New("订阅不存在")
+ })
+ if err != nil {
+ writeError(w, 400, err.Error())
+ return
+ }
+ }
+ jsonResponse(w, 200, map[string]any{"id": id, "ok": true})
+}
+
+func (a *App) deleteSubscription(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ err := a.change(r.Context(), func(state *State) error {
+ for i, sub := range state.Subscriptions {
+ if sub.ID == id {
+ state.Subscriptions = append(state.Subscriptions[:i], state.Subscriptions[i+1:]...)
+ return nil
+ }
+ }
+ return errors.New("订阅不存在")
+ })
+ if err != nil {
+ writeError(w, 400, err.Error())
+ return
+ }
+ jsonResponse(w, 200, map[string]bool{"ok": true})
+}
+func (a *App) refreshSubscription(w http.ResponseWriter, r *http.Request) {
+ if err := a.Refresh(r.Context(), r.PathValue("id")); err != nil {
+ writeError(w, 400, err.Error())
+ return
+ }
+ jsonResponse(w, 200, map[string]bool{"ok": true})
+}
+
+func nodeViews(state State, live map[string]map[string]any) []NodeView {
+ views := []NodeView{}
+ for _, sub := range state.Subscriptions {
+ if !sub.Enabled {
+ continue
+ }
+ for _, node := range sub.Nodes {
+ id, _ := node["name"].(string)
+ parts := strings.SplitN(id, "/", 3)
+ name := id
+ if len(parts) == 3 {
+ name = parts[2]
+ }
+ kind, _ := node["type"].(string)
+ udp, _ := node["udp"].(bool)
+ view := NodeView{ID: id, Name: name, SubscriptionID: sub.ID, SubscriptionName: sub.Name, Type: kind, UDP: udp, Selected: state.Settings.SelectedNode == id && state.Settings.SelectionMode == "manual"}
+ if info, ok := live[id]; ok {
+ if u, ok := info["udp"].(bool); ok {
+ view.UDP = u
+ }
+ if history, ok := info["history"].([]any); ok && len(history) > 0 {
+ if h, ok := history[len(history)-1].(map[string]any); ok {
+ if d, ok := h["delay"].(float64); ok {
+ delay := int(d)
+ view.Delay = &delay
+ alive := delay > 0
+ view.Alive = &alive
+ }
+ }
+ }
+ }
+ views = append(views, view)
+ }
+ }
+ return views
+}
+
+func (a *App) nodes(w http.ResponseWriter, r *http.Request) {
+ live := struct {
+ Proxies map[string]map[string]any `json:"proxies"`
+ }{Proxies: map[string]map[string]any{}}
+ _ = a.core.Request(r.Context(), "GET", "/proxies", nil, &live)
+ jsonResponse(w, 200, nodeViews(a.Snapshot(), live.Proxies))
+}
+func (a *App) selectNode(w http.ResponseWriter, r *http.Request) {
+ var input struct {
+ ID string `json:"id"`
+ Mode string `json:"mode"`
+ }
+ if err := decode(w, r, &input); err != nil {
+ writeError(w, 400, err.Error())
+ return
+ }
+ err := a.change(r.Context(), func(s *State) error {
+ if input.Mode == "auto" {
+ s.Settings.SelectionMode = "auto"
+ return nil
+ }
+ if input.Mode != "manual" {
+ return errors.New("无效的选择模式")
+ }
+ for _, node := range nodeViews(*s, nil) {
+ if node.ID == input.ID {
+ s.Settings.SelectionMode = "manual"
+ s.Settings.SelectedNode = input.ID
+ return nil
+ }
+ }
+ return errors.New("节点不存在或订阅未启用")
+ })
+ if err != nil {
+ writeError(w, 400, err.Error())
+ return
+ }
+ jsonResponse(w, 200, map[string]bool{"ok": true})
+}
+func (a *App) testNodes(w http.ResponseWriter, r *http.Request) {
+ var input struct {
+ IDs []string `json:"ids"`
+ }
+ if err := decode(w, r, &input); err != nil {
+ writeError(w, 400, err.Error())
+ return
+ }
+ state := a.Snapshot()
+ known := map[string]bool{}
+ for _, n := range nodeViews(state, nil) {
+ known[n.ID] = true
+ }
+ if len(input.IDs) == 0 || len(input.IDs) > 500 {
+ writeError(w, 400, "一次可测试 1–500 个节点")
+ return
+ }
+ for _, id := range input.IDs {
+ if !known[id] {
+ writeError(w, 400, "包含未知节点")
+ return
+ }
+ }
+ results := map[string]any{}
+ for start := 0; start < len(input.IDs); start += 8 {
+ end := start + 8
+ if end > len(input.IDs) {
+ end = len(input.IDs)
+ }
+ type outcome struct {
+ ID string
+ Delay int
+ Err string
+ }
+ ch := make(chan outcome, end-start)
+ for _, id := range input.IDs[start:end] {
+ go func(id string) {
+ ctx, cancel := context.WithTimeout(r.Context(), time.Duration(state.Settings.TestTimeoutMS+2000)*time.Millisecond)
+ defer cancel()
+ var response struct {
+ Delay int `json:"delay"`
+ }
+ path := "/proxies/" + url.PathEscape(id) + "/delay?url=" + url.QueryEscape(state.Settings.TestURL) + fmt.Sprintf("&timeout=%d", state.Settings.TestTimeoutMS)
+ err := a.core.Request(ctx, "GET", path, nil, &response)
+ message := ""
+ if err != nil {
+ message = err.Error()
+ }
+ ch <- outcome{id, response.Delay, message}
+ }(id)
+ }
+ for i := start; i < end; i++ {
+ v := <-ch
+ results[v.ID] = map[string]any{"delay": v.Delay, "error": v.Err}
+ }
+ if r.Context().Err() != nil {
+ return
+ }
+ }
+ jsonResponse(w, 200, results)
+}
+
+func (a *App) rules(w http.ResponseWriter, r *http.Request) {
+ jsonResponse(w, 200, map[string]any{"custom": a.Snapshot().Rules, "defaults": []map[string]string{{"match": "内网、回环、链路本地及组播地址", "action": "DIRECT"}, {"match": "自定义规则(按列表顺序)", "action": "CUSTOM"}, {"match": "大陆域名 / IP 规则库", "action": "DIRECT"}, {"match": "其余目标", "action": "PROXY"}}})
+}
+func (a *App) saveRule(w http.ResponseWriter, r *http.Request) {
+ var input struct {
+ Type string `json:"type"`
+ Value string `json:"value"`
+ Action string `json:"action"`
+ Enabled bool `json:"enabled"`
+ }
+ if err := decode(w, r, &input); err != nil {
+ writeError(w, 400, err.Error())
+ return
+ }
+ id := r.PathValue("id")
+ newRule := id == ""
+ if newRule {
+ id = randomID()
+ }
+ rule := Rule{ID: id, Type: input.Type, Value: strings.ToLower(strings.TrimSpace(input.Value)), Action: input.Action, Enabled: input.Enabled}
+ err := a.change(r.Context(), func(s *State) error {
+ if newRule {
+ s.Rules = append(s.Rules, rule)
+ return nil
+ }
+ for i, r := range s.Rules {
+ if r.ID == id {
+ s.Rules[i] = rule
+ return nil
+ }
+ }
+ return errors.New("规则不存在")
+ })
+ if err != nil {
+ writeError(w, 400, err.Error())
+ return
+ }
+ jsonResponse(w, 200, map[string]string{"id": id})
+}
+func (a *App) deleteRule(w http.ResponseWriter, r *http.Request) {
+ err := a.change(r.Context(), func(s *State) error {
+ for i, rule := range s.Rules {
+ if rule.ID == r.PathValue("id") {
+ s.Rules = append(s.Rules[:i], s.Rules[i+1:]...)
+ return nil
+ }
+ }
+ return errors.New("规则不存在")
+ })
+ if err != nil {
+ writeError(w, 400, err.Error())
+ return
+ }
+ jsonResponse(w, 200, map[string]bool{"ok": true})
+}
+
+func (a *App) settings(w http.ResponseWriter, r *http.Request) {
+ var settings Settings
+ if err := decode(w, r, &settings); err != nil {
+ writeError(w, 400, err.Error())
+ return
+ }
+ if settings.TunEnabled && !interfaceExists(settings.Interface, settings.LANIP) {
+ writeError(w, 400, "VM 地址与网卡不匹配;请在正式 VM 上先配置桥接网卡和固定 IP")
+ return
+ }
+ err := a.change(r.Context(), func(s *State) error { s.Settings = settings; return nil })
+ if err != nil {
+ writeError(w, 400, err.Error())
+ return
+ }
+ jsonResponse(w, 200, settings)
+}
+func (a *App) service(w http.ResponseWriter, r *http.Request) {
+ var input struct {
+ Action string `json:"action"`
+ }
+ if err := decode(w, r, &input); err != nil {
+ writeError(w, 400, err.Error())
+ return
+ }
+ if input.Action == "start" || input.Action == "restart" {
+ a.mu.Lock()
+ config, _ := compileConfig(a.state, a.core.Secret)
+ err := a.core.Apply(r.Context(), config)
+ a.mu.Unlock()
+ if err != nil {
+ writeError(w, 400, err.Error())
+ return
+ }
+ }
+ if err := a.core.Service(r.Context(), input.Action); err != nil {
+ writeError(w, 400, err.Error())
+ return
+ }
+ if input.Action != "stop" {
+ if err := a.core.Select(r.Context(), a.Snapshot()); err != nil {
+ writeError(w, 400, err.Error())
+ return
+ }
+ }
+ jsonResponse(w, 200, map[string]bool{"ok": true})
+}
+func (a *App) connections(w http.ResponseWriter, r *http.Request) {
+ var result map[string]any
+ if err := a.core.Request(r.Context(), "GET", "/connections", nil, &result); err != nil {
+ writeError(w, 503, err.Error())
+ return
+ }
+ jsonResponse(w, 200, result)
+}
+func (a *App) closeConnection(w http.ResponseWriter, r *http.Request) {
+ path := "/connections"
+ if id := r.PathValue("id"); id != "" {
+ path += "/" + url.PathEscape(id)
+ }
+ if err := a.core.Request(r.Context(), "DELETE", path, nil, nil); err != nil {
+ writeError(w, 503, err.Error())
+ return
+ }
+ jsonResponse(w, 200, map[string]bool{"ok": true})
+}
+func (a *App) backup(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Disposition", `attachment; filename="clashweb-backup.json"`)
+ jsonResponse(w, 200, Backup{1, now(), a.Snapshot()})
+}
+func (a *App) restore(w http.ResponseWriter, r *http.Request) {
+ var backup Backup
+ if err := decode(w, r, &backup); err != nil {
+ writeError(w, 400, err.Error())
+ return
+ }
+ if backup.Version != 1 {
+ writeError(w, 400, "不支持此备份版本")
+ return
+ }
+ if backup.State.Settings.TunEnabled && !interfaceExists(backup.State.Settings.Interface, backup.State.Settings.LANIP) {
+ writeError(w, 400, "备份中的网卡和 VM 地址与当前机器不匹配;请先修改备份中的网络设置或关闭 tunEnabled")
+ return
+ }
+ err := a.change(r.Context(), func(s *State) error { *s = backup.State; return nil })
+ if err != nil {
+ writeError(w, 400, err.Error())
+ return
+ }
+ jsonResponse(w, 200, map[string]bool{"ok": true})
+}
+func (a *App) network(w http.ResponseWriter, r *http.Request) {
+ interfaces := []map[string]any{}
+ list, _ := net.Interfaces()
+ for _, i := range list {
+ if i.Flags&net.FlagLoopback != 0 {
+ continue
+ }
+ ips := []string{}
+ addresses, _ := i.Addrs()
+ for _, addr := range addresses {
+ ip, _, err := net.ParseCIDR(addr.String())
+ if err == nil && ip.To4() != nil {
+ ips = append(ips, addr.String())
+ }
+ }
+ interfaces = append(interfaces, map[string]any{"name": i.Name, "addresses": ips, "up": i.Flags&net.FlagUp != 0})
+ }
+ sort.Slice(interfaces, func(i, j int) bool { return interfaces[i]["name"].(string) < interfaces[j]["name"].(string) })
+ forward, _ := os.ReadFile("/proc/sys/net/ipv4/ip_forward")
+ _, tunErr := os.Stat("/dev/net/tun")
+ _, domainErr := os.Stat(filepath.Join(a.core.Dir, "rules/cn-domain.mrs"))
+ _, ipErr := os.Stat(filepath.Join(a.core.Dir, "rules/cn-ip.mrs"))
+ jsonResponse(w, 200, map[string]any{"interfaces": interfaces, "ipForward": strings.TrimSpace(string(forward)) == "1", "tunDevice": tunErr == nil, "rulesInstalled": domainErr == nil && ipErr == nil, "serviceControl": a.core.Helper != "", "dhcpManaged": false, "guidance": "当前光猫 DHCP 无法关闭。请手动将测试设备的 IPv4 网关和 DNS 设为 PVE VM 的固定地址;不要在 VM 启动第二个 DHCP 服务。"})
+}
+
+func streamHeaders(w http.ResponseWriter) error {
+ if _, ok := w.(http.Flusher); !ok {
+ return errors.New("不支持流式响应")
+ }
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("X-Accel-Buffering", "no")
+ return nil
+}
+func sendEvent(w http.ResponseWriter, event string, value any) error {
+ b, err := json.Marshal(value)
+ if err != nil {
+ return err
+ }
+ _ = http.NewResponseController(w).SetWriteDeadline(time.Now().Add(10 * time.Second))
+ if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, b); err != nil {
+ return err
+ }
+ w.(http.Flusher).Flush()
+ return nil
+}
+func (a *App) events(w http.ResponseWriter, r *http.Request) {
+ if err := streamHeaders(w); err != nil {
+ writeError(w, 500, err.Error())
+ return
+ }
+ ticker := time.NewTicker(2 * time.Second)
+ defer ticker.Stop()
+ var prevUp, prevDown float64
+ last := time.Now()
+ initialized := false
+ for {
+ if _, _, err := a.session(r); err != nil {
+ _ = sendEvent(w, "expired", nil)
+ return
+ }
+ var connections struct {
+ Upload float64 `json:"uploadTotal"`
+ Download float64 `json:"downloadTotal"`
+ Connections []json.RawMessage `json:"connections"`
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
+ err := a.core.Request(ctx, "GET", "/connections", nil, &connections)
+ cancel()
+ current := time.Now()
+ seconds := current.Sub(last).Seconds()
+ up, down := float64(0), float64(0)
+ if err == nil && initialized && seconds > 0 {
+ up = (connections.Upload - prevUp) / seconds
+ down = (connections.Download - prevDown) / seconds
+ if up < 0 {
+ up = 0
+ }
+ if down < 0 {
+ down = 0
+ }
+ }
+ if err == nil {
+ prevUp = connections.Upload
+ prevDown = connections.Download
+ }
+ initialized = err == nil
+ last = current
+ if sendEvent(w, "metrics", map[string]any{"up": up, "down": down, "upTotal": connections.Upload, "downTotal": connections.Download, "connections": len(connections.Connections), "coreOnline": err == nil, "time": now()}) != nil {
+ return
+ }
+ select {
+ case <-r.Context().Done():
+ return
+ case <-ticker.C:
+ }
+ }
+}
+func (a *App) logs(w http.ResponseWriter, r *http.Request) {
+ if err := streamHeaders(w); err != nil {
+ writeError(w, 500, err.Error())
+ return
+ }
+ ctx, cancel := context.WithCancel(r.Context())
+ defer cancel()
+ items := make(chan any, 32)
+ finished := make(chan error, 1)
+ go func() {
+ finished <- a.core.Logs(ctx, func(item any) error {
+ select {
+ case items <- item:
+ return nil
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ })
+ }()
+ ticker := time.NewTicker(15 * time.Second)
+ defer ticker.Stop()
+ for {
+ select {
+ case item := <-items:
+ if sendEvent(w, "log", item) != nil {
+ return
+ }
+ case err := <-finished:
+ if err != nil {
+ _ = sendEvent(w, "disconnected", map[string]string{"error": "内核日志连接已断开,正在等待重连"})
+ }
+ return
+ case <-ticker.C:
+ if _, _, err := a.session(r); err != nil {
+ _ = sendEvent(w, "expired", nil)
+ return
+ }
+ if sendEvent(w, "heartbeat", nil) != nil {
+ return
+ }
+ case <-ctx.Done():
+ return
+ }
+ }
+}
diff --git a/internal/app/app.go b/internal/app/app.go
new file mode 100644
index 0000000..c699b71
--- /dev/null
+++ b/internal/app/app.go
@@ -0,0 +1,316 @@
+package app
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "log"
+ "net/http"
+ "net/url"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+)
+
+type Options struct {
+ DataDir string
+ CoreDir string
+ CoreURL string
+ CoreBinary string
+ Helper string
+ SecureCookie bool
+ Web http.Handler
+}
+
+type App struct {
+ store *Store
+ core *Core
+ state State
+ mu sync.RWMutex
+ setupToken string
+ secureCookie bool
+ web http.Handler
+ download *http.Client
+ started time.Time
+ lastError string
+ authMu sync.Mutex
+ failures map[string]loginLimit
+ refreshMu sync.Mutex
+ refreshing map[string]bool
+ updatesMu sync.Mutex
+ updateCache *coreRelease
+ updateTime time.Time
+ connectivitySlots chan struct{}
+}
+
+func randomID() string {
+ b := make([]byte, 12)
+ if _, err := rand.Read(b); err != nil {
+ panic(err)
+ }
+ return hex.EncodeToString(b)
+}
+func randomToken() string {
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ panic(err)
+ }
+ return hex.EncodeToString(b)
+}
+
+func New(options Options) (*App, error) {
+ store, err := OpenStore(options.DataDir)
+ if err != nil {
+ return nil, err
+ }
+ fail := func(err error) (*App, error) { store.Close(); return nil, err }
+ state, err := store.Load()
+ if err != nil {
+ return fail(err)
+ }
+ if options.CoreDir == "" {
+ options.CoreDir = filepath.Join(options.DataDir, "mihomo")
+ }
+ options.CoreDir, err = filepath.Abs(options.CoreDir)
+ if err != nil {
+ return fail(err)
+ }
+ if err = os.MkdirAll(options.CoreDir, 0750); err != nil {
+ return fail(err)
+ }
+ if options.CoreURL == "" {
+ options.CoreURL = "http://127.0.0.1:9090"
+ }
+ u, err := url.Parse(options.CoreURL)
+ if err != nil || u.Scheme != "http" || u.Host != "127.0.0.1:9090" || u.Path != "" || u.User != nil || u.RawQuery != "" {
+ return fail(errors.New("内核控制地址必须是 http://127.0.0.1:9090"))
+ }
+ secretPath := filepath.Join(options.CoreDir, "controller.secret")
+ secret, err := os.ReadFile(secretPath)
+ if os.IsNotExist(err) {
+ secret = []byte(randomToken())
+ err = atomicWrite(secretPath, secret)
+ }
+ if err != nil {
+ return fail(err)
+ }
+ if options.CoreBinary == "" {
+ options.CoreBinary, _ = exec.LookPath("mihomo")
+ }
+ core := &Core{URL: options.CoreURL, Secret: strings.TrimSpace(string(secret)), Binary: options.CoreBinary, Dir: options.CoreDir, Helper: options.Helper, HTTP: &http.Client{Timeout: 15 * time.Second, Transport: &http.Transport{Proxy: nil}}}
+ token, err := store.Get("setup_token")
+ if err != nil {
+ return fail(err)
+ }
+ password, err := store.Get("password")
+ if err != nil {
+ return fail(err)
+ }
+ if password == "" && token == "" {
+ token = randomToken()
+ if err := store.Set("setup_token", token); err != nil {
+ return fail(err)
+ }
+ }
+ if password == "" {
+ log.Printf("首次设置令牌(只在受信任的管理浏览器中使用): %s", token)
+ }
+ app := &App{store: store, core: core, state: state, setupToken: token, secureCookie: options.SecureCookie, web: options.Web, download: &http.Client{Timeout: 30 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ if len(via) >= 5 {
+ return errors.New("重定向次数过多")
+ }
+ return validHTTPURL(req.URL.String())
+ }}, started: time.Now(), failures: map[string]loginLimit{}, refreshing: map[string]bool{}}
+ app.connectivitySlots = make(chan struct{}, 3)
+ return app, nil
+}
+
+func (a *App) Close() error { return a.store.Close() }
+func (a *App) Initialize(ctx context.Context) error {
+ config, err := compileConfig(a.Snapshot(), a.core.Secret)
+ if err != nil {
+ return err
+ }
+ return a.core.Apply(ctx, config)
+}
+func (a *App) Snapshot() State { a.mu.RLock(); defer a.mu.RUnlock(); return cloneState(a.state) }
+
+func (a *App) change(ctx context.Context, update func(*State) error) error {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ before := a.state
+ candidate := cloneState(before)
+ if err := update(&candidate); err != nil {
+ return err
+ }
+ if err := validateState(candidate); err != nil {
+ return err
+ }
+ config, err := compileConfig(candidate, a.core.Secret)
+ if err != nil {
+ return err
+ }
+ if err := a.core.Apply(ctx, config); err != nil {
+ a.lastError = err.Error()
+ return err
+ }
+ if err := a.core.Select(ctx, candidate); err != nil {
+ old, _ := compileConfig(before, a.core.Secret)
+ restoreCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ if restoreErr := a.core.Apply(restoreCtx, old); restoreErr != nil {
+ a.lastError = "节点切换失败且恢复失败,请重启内核"
+ return errors.New(a.lastError)
+ }
+ _ = a.core.Select(restoreCtx, before)
+ return err
+ }
+ if err := a.store.Save(candidate); err != nil {
+ old, _ := compileConfig(before, a.core.Secret)
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ if restoreErr := a.core.Apply(ctx, old); restoreErr != nil {
+ a.lastError = "数据库写入失败,内核配置恢复也失败,请重启服务"
+ return errors.New(a.lastError)
+ }
+ _ = a.core.Select(ctx, before)
+ return errors.New("数据库保存失败,已恢复原配置")
+ }
+ a.state = candidate
+ a.lastError = ""
+ return nil
+}
+
+func (a *App) Refresh(ctx context.Context, id string) error {
+ a.refreshMu.Lock()
+ if a.refreshing[id] {
+ a.refreshMu.Unlock()
+ return errors.New("此订阅正在更新")
+ }
+ a.refreshing[id] = true
+ a.refreshMu.Unlock()
+ defer func() { a.refreshMu.Lock(); delete(a.refreshing, id); a.refreshMu.Unlock() }()
+ var sub Subscription
+ found := false
+ for _, s := range a.Snapshot().Subscriptions {
+ if s.ID == id {
+ sub = s
+ found = true
+ break
+ }
+ }
+ if !found {
+ return errors.New("订阅不存在")
+ }
+ nodes, usage, err := loadSubscription(ctx, sub, a.download)
+ if err == nil {
+ err = a.change(ctx, func(s *State) error {
+ for i := range s.Subscriptions {
+ if s.Subscriptions[i].ID == id {
+ if s.Subscriptions[i].URL != sub.URL {
+ return errors.New("订阅地址已改变,请重新更新")
+ }
+ s.Subscriptions[i].Nodes = nodes
+ s.Subscriptions[i].Usage = usage
+ s.Subscriptions[i].UpdatedAt = now()
+ s.Subscriptions[i].AttemptedAt = now()
+ s.Subscriptions[i].LastError = ""
+ return nil
+ }
+ }
+ return errors.New("订阅已被删除")
+ })
+ }
+ if err != nil {
+ a.mu.Lock()
+ candidate := cloneState(a.state)
+ for i := range candidate.Subscriptions {
+ if candidate.Subscriptions[i].ID == id && candidate.Subscriptions[i].URL == sub.URL {
+ candidate.Subscriptions[i].AttemptedAt = now()
+ candidate.Subscriptions[i].LastError = err.Error()
+ }
+ }
+ if saveErr := a.store.Save(candidate); saveErr == nil {
+ a.state = candidate
+ }
+ a.mu.Unlock()
+ }
+ return err
+}
+
+func (a *App) Run(ctx context.Context) {
+ // Reconcile the persisted configuration on startup, without changing Linux host networking.
+ if a.core.Binary != "" {
+ config, _ := compileConfig(a.Snapshot(), a.core.Secret)
+ if err := a.core.Apply(ctx, config); err != nil {
+ a.mu.Lock()
+ a.lastError = err.Error()
+ a.mu.Unlock()
+ log.Printf("启动配置未应用: %s", err)
+ }
+ _ = a.core.Select(ctx, a.Snapshot())
+ }
+ ticker := time.NewTicker(time.Minute)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ for _, sub := range a.Snapshot().Subscriptions {
+ if !sub.Enabled {
+ continue
+ }
+ last, _ := time.Parse(time.RFC3339, sub.AttemptedAt)
+ if time.Since(last) >= time.Duration(sub.IntervalHours)*time.Hour {
+ if err := a.Refresh(ctx, sub.ID); err != nil {
+ log.Printf("订阅 %s 更新失败: %s", sub.ID, err)
+ }
+ }
+ }
+ _, _ = a.store.db.Exec("DELETE FROM sessions WHERE expires < ?", time.Now().Unix())
+ }
+ }
+}
+
+func (a *App) Handler() http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /api/v1/auth/status", a.authStatus)
+ mux.HandleFunc("POST /api/v1/auth/setup", a.setup)
+ mux.HandleFunc("POST /api/v1/auth/login", a.login)
+ mux.Handle("POST /api/v1/auth/logout", a.protect(http.HandlerFunc(a.logout)))
+ mux.Handle("PUT /api/v1/auth/password", a.protect(http.HandlerFunc(a.password)))
+ mux.Handle("/api/v1/", a.protect(a.api()))
+ if a.web != nil {
+ mux.Handle("/", a.web)
+ }
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ w.Header().Set("X-Frame-Options", "DENY")
+ w.Header().Set("Referrer-Policy", "no-referrer")
+ if strings.HasPrefix(r.URL.Path, "/api/") {
+ w.Header().Set("Cache-Control", "no-store")
+ }
+ if r.Method != "GET" && r.Method != "HEAD" && r.Method != "OPTIONS" {
+ if origin := r.Header.Get("Origin"); origin != "" {
+ u, err := url.Parse(origin)
+ if err != nil || u.Host != r.Host {
+ writeError(w, 403, "不允许跨站请求")
+ return
+ }
+ }
+ if r.Header.Get("Sec-Fetch-Site") == "cross-site" {
+ writeError(w, 403, "不允许跨站请求")
+ return
+ }
+ }
+ mux.ServeHTTP(w, r)
+ })
+}
+
+func (a *App) String() string { return fmt.Sprintf("Clash Web (Mihomo directory: %s)", a.core.Dir) }
diff --git a/internal/app/app_test.go b/internal/app/app_test.go
new file mode 100644
index 0000000..a444854
--- /dev/null
+++ b/internal/app/app_test.go
@@ -0,0 +1,355 @@
+package app
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync/atomic"
+ "testing"
+
+ "gopkg.in/yaml.v3"
+)
+
+func newTestApp(t *testing.T) *App {
+ t.Helper()
+ a, err := New(Options{DataDir: t.TempDir(), CoreBinary: "/bin/true"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/version" {
+ jsonResponse(w, 200, map[string]string{"version": "test-core"})
+ return
+ }
+ w.WriteHeader(204)
+ }))
+ a.core.URL = mock.URL
+ t.Cleanup(func() { mock.Close(); a.Close() })
+ return a
+}
+
+func request(t *testing.T, a *App, method, path string, body any, cookie *http.Cookie, csrf string) *httptest.ResponseRecorder {
+ t.Helper()
+ var b []byte
+ if body != nil {
+ b, _ = json.Marshal(body)
+ }
+ r := httptest.NewRequest(method, path, bytes.NewReader(b))
+ r.Header.Set("Content-Type", "application/json")
+ if cookie != nil {
+ r.AddCookie(cookie)
+ }
+ if csrf != "" {
+ r.Header.Set("X-CSRF-Token", csrf)
+ }
+ w := httptest.NewRecorder()
+ a.Handler().ServeHTTP(w, r)
+ return w
+}
+func loginTest(t *testing.T, a *App) (*http.Cookie, string) {
+ t.Helper()
+ w := request(t, a, "POST", "/api/v1/auth/setup", credentials{Token: a.setupToken, Password: "test-password-long"}, nil, "")
+ if w.Code != 200 {
+ t.Fatalf("setup %d: %s", w.Code, w.Body)
+ }
+ var response struct {
+ CSRF string `json:"csrf"`
+ }
+ json.Unmarshal(w.Body.Bytes(), &response)
+ return w.Result().Cookies()[0], response.CSRF
+}
+
+func TestAuthCSRFAndSessionRevocation(t *testing.T) {
+ a := newTestApp(t)
+ if w := request(t, a, "GET", "/api/v1/settings", nil, nil, ""); w.Code != 401 {
+ t.Fatal(w.Code)
+ }
+ if w := request(t, a, "POST", "/api/v1/auth/setup", credentials{Token: "wrong", Password: "test-password-long"}, nil, ""); w.Code != 403 {
+ t.Fatal(w.Code)
+ }
+ cookie, csrf := loginTest(t, a)
+ if !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode {
+ t.Fatal("session cookie flags missing")
+ }
+ if w := request(t, a, "POST", "/api/v1/subscriptions", map[string]any{}, cookie, ""); w.Code != 403 {
+ t.Fatal("missing CSRF allowed")
+ }
+ r := httptest.NewRequest("POST", "/api/v1/auth/login", strings.NewReader(`{"password":"test-password-long"}`))
+ r.Header.Set("Origin", "https://attacker.example")
+ w := httptest.NewRecorder()
+ a.Handler().ServeHTTP(w, r)
+ if w.Code != 403 {
+ t.Fatal("cross-site login allowed")
+ }
+ w = request(t, a, "PUT", "/api/v1/auth/password", credentials{CurrentPassword: "test-password-long", Password: "new-password-long"}, cookie, csrf)
+ if w.Code != 200 {
+ t.Fatal(w.Code, w.Body)
+ }
+ if w = request(t, a, "GET", "/api/v1/settings", nil, cookie, ""); w.Code != 401 {
+ t.Fatal("old session still valid")
+ }
+ if w = request(t, a, "POST", "/api/v1/auth/setup", credentials{Token: a.setupToken, Password: "test-password-long"}, nil, ""); w.Code != 409 {
+ t.Fatal("setup can run twice")
+ }
+}
+
+func TestLoginRateLimit(t *testing.T) {
+ a := newTestApp(t)
+ loginTest(t, a)
+ for i := 0; i < 5; i++ {
+ if w := request(t, a, "POST", "/api/v1/auth/login", credentials{Password: "wrong"}, nil, ""); w.Code != 401 {
+ t.Fatal(w.Code)
+ }
+ }
+ if w := request(t, a, "POST", "/api/v1/auth/login", credentials{Password: "test-password-long"}, nil, ""); w.Code != 429 {
+ t.Fatal("not rate limited", w.Code)
+ }
+}
+
+func TestDecodeRejectsTrailingJSON(t *testing.T) {
+ for _, payload := range []string{`{} {}`, `{"unknown":1}`, `{broken`, strings.Repeat("x", 17<<20)} {
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("POST", "/", strings.NewReader(payload))
+ var target credentials
+ if decode(w, r, &target) == nil {
+ t.Fatal("invalid payload accepted")
+ }
+ }
+}
+
+func TestSubscriptionImportNamespaceAndProvider(t *testing.T) {
+ provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Authorization") != "Bearer provider-token" {
+ t.Error("missing provider header")
+ }
+ io.WriteString(w, "proxies:\n- name: shared\n type: http\n server: 127.0.0.1\n port: 8088\n")
+ }))
+ defer provider.Close()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ io.WriteString(w, "proxies:\n- name: shared\n type: http\n server: localhost\n port: 8081\nproxy-providers:\n remote:\n type: http\n url: "+provider.URL+"\n header:\n Authorization: [Bearer provider-token]\nexternal-controller: 0.0.0.0:9999\nrules: [MATCH,DIRECT]\n")
+ }))
+ defer server.Close()
+ sub := Subscription{ID: randomID(), URL: server.URL}
+ nodes, _, err := loadSubscription(context.Background(), sub, server.Client())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(nodes) != 2 || nodes[0]["name"] == nodes[1]["name"] {
+ t.Fatal("nodes not namespaced", nodes)
+ }
+ for _, node := range nodes {
+ if !strings.HasPrefix(node["name"].(string), sub.ID+"/") {
+ t.Fatal("unstable namespace")
+ }
+ }
+ sub.ID = randomID()
+ other, _, err := loadSubscription(context.Background(), sub, server.Client())
+ if err != nil || other[0]["name"] == nodes[0]["name"] {
+ t.Fatal("cross-subscription conflict", err)
+ }
+}
+
+func TestSubscriptionRejectsInvalidSources(t *testing.T) {
+ for _, body := range []string{"YmFzZTY0", "proxy-providers:\n local:\n type: file\n path: /etc/shadow\n", "proxies:\n- name: a\n type: http\n dialer-proxy: unknown-group\n", "proxies:\n- name: same\n type: http\n- name: same\n type: http\n"} {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, body) }))
+ _, _, err := loadSubscription(context.Background(), Subscription{ID: randomID(), URL: server.URL}, server.Client())
+ server.Close()
+ if err == nil {
+ t.Fatalf("accepted %s", body)
+ }
+ }
+}
+
+func TestRefreshFailureKeepsNodesAndConfig(t *testing.T) {
+ a := newTestApp(t)
+ var fail atomic.Bool
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if fail.Load() {
+ http.Error(w, "unavailable", 503)
+ return
+ }
+ io.WriteString(w, "proxies:\n- name: node-A\n type: http\n server: localhost\n port: 8088\n")
+ }))
+ defer server.Close()
+ id := randomID()
+ a.state.Subscriptions = []Subscription{{ID: id, Name: "subscription", URL: server.URL, Enabled: true, IntervalHours: 6}}
+ if err := a.Refresh(context.Background(), id); err != nil {
+ t.Fatal(err)
+ }
+ before, _ := os.ReadFile(filepath.Join(a.core.Dir, "config.yaml"))
+ fail.Store(true)
+ if err := a.Refresh(context.Background(), id); err == nil {
+ t.Fatal("expected failure")
+ }
+ after, _ := os.ReadFile(filepath.Join(a.core.Dir, "config.yaml"))
+ state := a.Snapshot()
+ if !bytes.Equal(before, after) || len(state.Subscriptions[0].Nodes) != 1 || state.Subscriptions[0].LastError == "" {
+ t.Fatal("last good snapshot lost")
+ }
+ loaded, err := a.store.Load()
+ if err != nil || len(loaded.Subscriptions[0].Nodes) != 1 {
+ t.Fatal("snapshot not persisted", err)
+ }
+}
+
+func TestCoreReloadRollbackAndValidation(t *testing.T) {
+ var reloads atomic.Int32
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/version" {
+ jsonResponse(w, 200, map[string]string{"version": "test"})
+ return
+ }
+ if reloads.Add(1) == 1 {
+ http.Error(w, "failed", 500)
+ return
+ }
+ w.WriteHeader(204)
+ }))
+ defer server.Close()
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.yaml")
+ os.WriteFile(path, []byte("old"), 0600)
+ core := Core{Dir: dir, Binary: "/bin/true", URL: server.URL, HTTP: server.Client()}
+ if err := core.Apply(context.Background(), []byte("new")); err == nil {
+ t.Fatal("reload failure not surfaced")
+ }
+ actual, _ := os.ReadFile(path)
+ if string(actual) != "old" || reloads.Load() != 2 {
+ t.Fatal("rollback failed", string(actual), reloads.Load())
+ }
+ core.Binary = "/bin/false"
+ if err := core.Apply(context.Background(), []byte("invalid")); err == nil {
+ t.Fatal("validation failure not surfaced")
+ }
+ actual, _ = os.ReadFile(path)
+ if string(actual) != "old" {
+ t.Fatal("bad configuration overwrote old")
+ }
+}
+
+func TestStateNotCommittedWhenCoreFails(t *testing.T) {
+ a := newTestApp(t)
+ a.core.Binary = "/bin/false"
+ before := a.Snapshot()
+ err := a.change(context.Background(), func(s *State) error { s.Settings.ProxyEnabled = false; return nil })
+ if err == nil || a.Snapshot().Settings.ProxyEnabled != before.Settings.ProxyEnabled {
+ t.Fatal("failed change committed")
+ }
+}
+
+func TestControllerAuthFailureDoesNotStageConfig(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.yaml")
+ os.WriteFile(path, []byte("previous"), 0600)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "unauthorized", 401) }))
+ defer server.Close()
+ core := Core{Dir: dir, Binary: "/bin/true", URL: server.URL, HTTP: server.Client()}
+ if core.Apply(context.Background(), []byte("candidate")) == nil {
+ t.Fatal("controller auth failure was treated as stopped")
+ }
+ data, _ := os.ReadFile(path)
+ if string(data) != "previous" {
+ t.Fatal("controller auth error replaced config")
+ }
+}
+
+func TestCompileManualRemovalDoesNotChooseOtherNode(t *testing.T) {
+ s := defaultState()
+ s.Settings.SelectedNode = "removed"
+ s.Subscriptions = []Subscription{{Enabled: true, Nodes: []map[string]any{{"name": "other", "type": "http", "server": "localhost", "port": 8088}}}}
+ data, err := compileConfig(s, "secret")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var parsed map[string]any
+ if err := yaml.Unmarshal(data, &parsed); err != nil {
+ t.Fatal(err)
+ }
+ group := parsed["proxy-groups"].([]any)[0].(map[string]any)
+ first := group["proxies"].([]any)[0]
+ if first != "REJECT" {
+ t.Fatal("manual node silently changed", first)
+ }
+ if parsed["external-controller"] != "127.0.0.1:9090" || parsed["profile"].(map[string]any)["store-selected"] != false {
+ t.Fatal("controller or selection persistence invalid")
+ }
+ s.Settings.SelectionMode = "auto"
+ data, _ = compileConfig(s, "secret")
+ yaml.Unmarshal(data, &parsed)
+ if parsed["proxy-groups"].([]any)[0].(map[string]any)["proxies"].([]any)[0] != AutoGroup {
+ t.Fatal("auto mode not selected")
+ }
+}
+
+func TestSettingsValidation(t *testing.T) {
+ valid := defaultState().Settings
+ if err := validateSettings(valid); err != nil {
+ t.Fatal(err)
+ }
+ invalid := []Settings{}
+ s := valid
+ s.Gateway = "192.168.1.1"
+ s.LANIP = s.Gateway
+ invalid = append(invalid, s)
+ s = valid
+ s.TunEnabled = true
+ invalid = append(invalid, s)
+ s = valid
+ s.MixedPort = 9090
+ invalid = append(invalid, s)
+ s = valid
+ s.Interface = "eth0; touch /tmp/x"
+ invalid = append(invalid, s)
+ s = valid
+ s.LANCIDR = "0.0.0.0/0"
+ invalid = append(invalid, s)
+ for _, s := range invalid {
+ if validateSettings(s) == nil {
+ t.Fatal("invalid settings accepted", s)
+ }
+ }
+}
+
+func TestStoreRestartRecovery(t *testing.T) {
+ dir := t.TempDir()
+ store, err := OpenStore(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ s := defaultState()
+ s.Settings.SelectedNode = "persisted-node"
+ s.Subscriptions = []Subscription{{ID: randomID(), Name: "saved", URL: "https://example.com/sub", IntervalHours: 6, Enabled: true, Nodes: []map[string]any{{"name": "persisted-node", "type": "http"}}}}
+ if err := store.Save(s); err != nil {
+ t.Fatal(err)
+ }
+ store.Close()
+ store, err = OpenStore(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ loaded, err := store.Load()
+ if err != nil || loaded.Settings.SelectedNode != "persisted-node" || len(loaded.Subscriptions) != 1 {
+ t.Fatal("restart lost state", err)
+ }
+ info, _ := os.Stat(filepath.Join(dir, "clashweb.sqlite"))
+ if info.Mode().Perm() != 0600 {
+ t.Fatal("database is not private")
+ }
+}
+
+func TestAPINodeListDoesNotExposeCredentials(t *testing.T) {
+ a := newTestApp(t)
+ cookie, _ := loginTest(t, a)
+ a.state.Subscriptions = []Subscription{{ID: randomID(), Name: "private", Enabled: true, Nodes: []map[string]any{{"name": "id/inline/node", "type": "ss", "password": "do-not-expose", "server": "private.example"}}}}
+ w := request(t, a, "GET", "/api/v1/nodes", nil, cookie, "")
+ if w.Code != 200 || strings.Contains(w.Body.String(), "do-not-expose") || strings.Contains(w.Body.String(), "private.example") {
+ t.Fatal("credentials leaked", w.Body)
+ }
+}
diff --git a/internal/app/auth.go b/internal/app/auth.go
new file mode 100644
index 0000000..459f594
--- /dev/null
+++ b/internal/app/auth.go
@@ -0,0 +1,235 @@
+package app
+
+import (
+ "crypto/sha256"
+ "crypto/subtle"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "io"
+ "net"
+ "net/http"
+ "time"
+
+ "golang.org/x/crypto/bcrypt"
+)
+
+type loginLimit struct {
+ Count int
+ Until time.Time
+}
+type credentials struct {
+ Password string `json:"password"`
+ Token string `json:"token"`
+ CurrentPassword string `json:"currentPassword"`
+}
+
+func decode(w http.ResponseWriter, r *http.Request, target any) error {
+ r.Body = http.MaxBytesReader(w, r.Body, 16<<20)
+ decoder := json.NewDecoder(r.Body)
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(target); err != nil {
+ return errors.New("请求内容无效或过大")
+ }
+ var extra any
+ if err := decoder.Decode(&extra); err != io.EOF {
+ return errors.New("请求必须是单个 JSON 对象")
+ }
+ return nil
+}
+func jsonResponse(w http.ResponseWriter, status int, value any) {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(value)
+}
+func writeError(w http.ResponseWriter, status int, message string) {
+ jsonResponse(w, status, map[string]string{"error": message})
+}
+func hashToken(token string) string {
+ b := sha256.Sum256([]byte(token))
+ return hex.EncodeToString(b[:])
+}
+func validPassword(password string) bool { return len(password) >= 12 && len(password) <= 72 }
+
+func (a *App) session(r *http.Request) (string, string, error) {
+ cookie, err := r.Cookie("cw_session")
+ if err != nil {
+ return "", "", err
+ }
+ hash := hashToken(cookie.Value)
+ var csrf string
+ var expires int64
+ err = a.store.db.QueryRow("SELECT csrf,expires FROM sessions WHERE token_hash=?", hash).Scan(&csrf, &expires)
+ if err != nil || expires <= time.Now().Unix() {
+ return "", "", errors.New("会话已过期")
+ }
+ return hash, csrf, nil
+}
+
+func (a *App) newSession(w http.ResponseWriter) error {
+ token, csrf := randomToken(), randomToken()
+ expires := time.Now().Add(24 * time.Hour)
+ if _, err := a.store.db.Exec("INSERT INTO sessions(token_hash,csrf,expires) VALUES(?,?,?)", hashToken(token), csrf, expires.Unix()); err != nil {
+ return err
+ }
+ http.SetCookie(w, &http.Cookie{Name: "cw_session", Value: token, Path: "/", HttpOnly: true, Secure: a.secureCookie, SameSite: http.SameSiteStrictMode, MaxAge: 86400, Expires: expires})
+ jsonResponse(w, 200, map[string]any{"authenticated": true, "csrf": csrf})
+ return nil
+}
+
+func (a *App) protect(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, csrf, err := a.session(r)
+ if err != nil {
+ writeError(w, 401, "请先登录")
+ return
+ }
+ if r.Method != "GET" && r.Method != "HEAD" {
+ if subtle.ConstantTimeCompare([]byte(r.Header.Get("X-CSRF-Token")), []byte(csrf)) != 1 {
+ writeError(w, 403, "会话校验失败,请重新登录")
+ return
+ }
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+func (a *App) authStatus(w http.ResponseWriter, r *http.Request) {
+ password, err := a.store.Get("password")
+ if err != nil {
+ writeError(w, 500, "数据库读取失败")
+ return
+ }
+ _, csrf, err := a.session(r)
+ jsonResponse(w, 200, map[string]any{"initialized": password != "", "authenticated": err == nil, "csrf": csrf})
+}
+
+func (a *App) setup(w http.ResponseWriter, r *http.Request) {
+ a.authMu.Lock()
+ defer a.authMu.Unlock()
+ password, err := a.store.Get("password")
+ if err != nil {
+ writeError(w, 500, "数据库读取失败")
+ return
+ }
+ if password != "" {
+ writeError(w, 409, "管理员已经设置")
+ return
+ }
+ var input credentials
+ if err := decode(w, r, &input); err != nil {
+ writeError(w, 400, err.Error())
+ return
+ }
+ if subtle.ConstantTimeCompare([]byte(input.Token), []byte(a.setupToken)) != 1 {
+ writeError(w, 403, "首次设置令牌不正确,请查看服务启动日志")
+ return
+ }
+ if !validPassword(input.Password) {
+ writeError(w, 400, "密码需为 12–72 字节")
+ return
+ }
+ hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
+ if err != nil {
+ writeError(w, 500, "密码处理失败")
+ return
+ }
+ if err := a.store.Set("password", string(hash)); err != nil {
+ writeError(w, 500, "保存失败")
+ return
+ }
+ _ = a.store.Set("setup_token", "")
+ a.setupToken = ""
+ if err := a.newSession(w); err != nil {
+ writeError(w, 500, "会话创建失败")
+ }
+}
+
+func (a *App) login(w http.ResponseWriter, r *http.Request) {
+ a.authMu.Lock()
+ defer a.authMu.Unlock()
+ ip, _, _ := net.SplitHostPort(r.RemoteAddr)
+ limit := a.failures[ip]
+ if limit.Count >= 5 && time.Now().Before(limit.Until) {
+ writeError(w, 429, "登录尝试过多,请 5 分钟后重试")
+ return
+ }
+ if time.Now().After(limit.Until) {
+ limit = loginLimit{}
+ }
+ var input credentials
+ if err := decode(w, r, &input); err != nil {
+ writeError(w, 400, err.Error())
+ return
+ }
+ password, err := a.store.Get("password")
+ if err != nil {
+ writeError(w, 500, "读取失败")
+ return
+ }
+ if password == "" || bcrypt.CompareHashAndPassword([]byte(password), []byte(input.Password)) != nil {
+ if len(a.failures) > 1024 {
+ a.failures = map[string]loginLimit{}
+ }
+ limit.Count++
+ limit.Until = time.Now().Add(5 * time.Minute)
+ a.failures[ip] = limit
+ writeError(w, 401, "密码不正确")
+ return
+ }
+ delete(a.failures, ip)
+ if err := a.newSession(w); err != nil {
+ writeError(w, 500, "会话创建失败")
+ }
+}
+
+func (a *App) logout(w http.ResponseWriter, r *http.Request) {
+ hash, _, _ := a.session(r)
+ _, err := a.store.db.Exec("DELETE FROM sessions WHERE token_hash=?", hash)
+ if err != nil {
+ writeError(w, 500, "退出失败")
+ return
+ }
+ http.SetCookie(w, &http.Cookie{Name: "cw_session", Path: "/", HttpOnly: true, Secure: a.secureCookie, SameSite: http.SameSiteStrictMode, MaxAge: -1})
+ jsonResponse(w, 200, map[string]bool{"ok": true})
+}
+
+func (a *App) password(w http.ResponseWriter, r *http.Request) {
+ a.authMu.Lock()
+ defer a.authMu.Unlock()
+ var input credentials
+ if err := decode(w, r, &input); err != nil {
+ writeError(w, 400, err.Error())
+ return
+ }
+ old, err := a.store.Get("password")
+ if err != nil || bcrypt.CompareHashAndPassword([]byte(old), []byte(input.CurrentPassword)) != nil {
+ writeError(w, 400, "当前密码不正确")
+ return
+ }
+ if !validPassword(input.Password) {
+ writeError(w, 400, "新密码需为 12–72 字节")
+ return
+ }
+ hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
+ if err != nil {
+ writeError(w, 500, "密码处理失败")
+ return
+ }
+ tx, err := a.store.db.Begin()
+ if err != nil {
+ writeError(w, 500, "保存失败")
+ return
+ }
+ defer tx.Rollback()
+ if _, err = tx.Exec("UPDATE kv SET value=? WHERE key='password'", string(hash)); err == nil {
+ _, err = tx.Exec("DELETE FROM sessions")
+ }
+ if err != nil || tx.Commit() != nil {
+ writeError(w, 500, "保存失败")
+ return
+ }
+ if err := a.newSession(w); err != nil {
+ writeError(w, 500, "会话创建失败")
+ }
+}
diff --git a/internal/app/connectivity.go b/internal/app/connectivity.go
new file mode 100644
index 0000000..cfc508b
--- /dev/null
+++ b/internal/app/connectivity.go
@@ -0,0 +1,211 @@
+package app
+
+import (
+ "context"
+ "crypto/x509"
+ "errors"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "syscall"
+ "time"
+)
+
+type connectivitySite struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ URL string `json:"url"`
+ Category string `json:"category"`
+}
+
+var connectivityCatalog = []connectivitySite{
+ {"apple", "Apple", "https://www.apple.com/", "官方网站"},
+ {"github", "GitHub", "https://github.com/", "开发工具"},
+ {"google", "Google", "https://www.google.com/", "搜索"},
+ {"netflix", "Netflix", "https://www.netflix.com/", "流媒体"},
+ {"youtube", "YouTube", "https://www.youtube.com/", "流媒体"},
+ {"chatgpt", "ChatGPT", "https://chatgpt.com/", "AI 服务"},
+ {"claude", "Claude", "https://claude.ai/", "AI 服务"},
+ {"grok", "Grok", "https://grok.com/", "AI 服务"},
+ {"dockerhub", "Docker Hub", "https://hub.docker.com/", "开发工具"},
+}
+
+type connectivityResult struct {
+ SiteID string `json:"siteId"`
+ Status string `json:"status"`
+ Detail string `json:"detail"`
+ HTTPStatus int `json:"httpStatus"`
+ DurationMS int64 `json:"durationMS"`
+ TLSVerified bool `json:"tlsVerified"`
+ CheckedAt time.Time `json:"checkedAt"`
+}
+
+func (a *App) connectivitySites(w http.ResponseWriter, r *http.Request) {
+ jsonResponse(w, 200, connectivityCatalog)
+}
+
+func (a *App) testConnectivity(w http.ResponseWriter, r *http.Request) {
+ var input struct {
+ SiteID string `json:"siteId"`
+ }
+ if err := decode(w, r, &input); err != nil {
+ writeError(w, 400, err.Error())
+ return
+ }
+ var site *connectivitySite
+ for i := range connectivityCatalog {
+ if connectivityCatalog[i].ID == input.SiteID {
+ site = &connectivityCatalog[i]
+ break
+ }
+ }
+ if site == nil {
+ writeError(w, 400, "请选择列表中的测试网站")
+ return
+ }
+ select {
+ case a.connectivitySlots <- struct{}{}:
+ defer func() { <-a.connectivitySlots }()
+ default:
+ writeError(w, 429, "最多同时测试 3 个网站,请稍后重试")
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
+ defer cancel()
+ var version map[string]any
+ coreCtx, coreCancel := context.WithTimeout(ctx, 2*time.Second)
+ err := a.core.Request(coreCtx, "GET", "/version", nil, &version)
+ coreCancel()
+ if err != nil {
+ jsonResponse(w, 200, connectivityResult{SiteID: site.ID, Status: "failed", Detail: "Mihomo 内核未连接,请先启动内核", CheckedAt: time.Now().UTC()})
+ return
+ }
+ a.mu.RLock()
+ settings := a.state.Settings
+ a.mu.RUnlock()
+ client := connectivityClient(settings)
+ defer client.CloseIdleConnections()
+ jsonResponse(w, 200, probeConnectivity(ctx, client, *site))
+}
+
+func connectivityClient(settings Settings) *http.Client {
+ host := settings.LANIP
+ if host == "" {
+ host = "127.0.0.1"
+ }
+ proxy := &url.URL{Scheme: "http", Host: net.JoinHostPort(host, strconv.Itoa(settings.MixedPort))}
+ transport := http.DefaultTransport.(*http.Transport).Clone()
+ // Always use this VM's Mihomo, even when the service has HTTP_PROXY/NO_PROXY.
+ transport.Proxy = http.ProxyURL(proxy)
+ transport.TLSHandshakeTimeout = 8 * time.Second
+ return &http.Client{Transport: transport, Timeout: 12 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ if len(via) >= 5 {
+ return errors.New("网站重定向次数过多")
+ }
+ if req.URL.Scheme != "https" || req.URL.User != nil {
+ return errors.New("网站重定向到非 HTTPS 地址")
+ }
+ if !connectivityRedirectAllowed(req.URL.Hostname(), via[0].URL.Hostname()) {
+ return errors.New("网站重定向到测试范围外的域名")
+ }
+ return nil
+ }}
+}
+
+func connectivityRedirectAllowed(host, original string) bool {
+ // Only follow the selected site's domains, never a redirect into the LAN.
+ host, original = strings.ToLower(host), strings.ToLower(original)
+ for _, domain := range []string{"apple.com", "github.com", "google.com", "netflix.com", "youtube.com", "chatgpt.com", "claude.ai", "grok.com", "docker.com"} {
+ if original == domain || strings.HasSuffix(original, "."+domain) {
+ allowed := []string{domain}
+ if domain == "claude.ai" {
+ allowed = append(allowed, "claude.com")
+ }
+ if domain == "chatgpt.com" {
+ allowed = append(allowed, "openai.com")
+ }
+ for _, candidate := range allowed {
+ if host == candidate || strings.HasSuffix(host, "."+candidate) {
+ return true
+ }
+ }
+ return false
+ }
+ }
+ return false
+}
+
+func probeConnectivity(ctx context.Context, client *http.Client, site connectivitySite) (result connectivityResult) {
+ start := time.Now()
+ result = connectivityResult{SiteID: site.ID, Status: "failed"}
+ defer func() { result.DurationMS = time.Since(start).Milliseconds(); result.CheckedAt = time.Now().UTC() }()
+ req, err := http.NewRequestWithContext(ctx, "GET", site.URL, nil)
+ if err != nil {
+ result.Detail = "测试地址无效"
+ return
+ }
+ req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; ClashWeb/1.0; connectivity-check)")
+ req.Header.Set("Accept", "text/html,application/xhtml+xml")
+ resp, err := client.Do(req)
+ if err != nil {
+ if resp != nil {
+ // net/http retains the last response when a redirect is refused.
+ result.HTTPStatus = resp.StatusCode
+ result.TLSVerified = resp.TLS != nil && len(resp.TLS.VerifiedChains) > 0
+ result.Status, result.Detail = "http_error", "网站已响应,但重定向超出测试范围或次数限制"
+ return
+ }
+ result.Detail = connectivityError(err)
+ return
+ }
+ defer resp.Body.Close()
+ result.HTTPStatus = resp.StatusCode
+ result.TLSVerified = resp.TLS != nil && len(resp.TLS.VerifiedChains) > 0
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
+ if err != nil {
+ result.Detail = "网站已响应,但读取响应失败或超时"
+ return
+ }
+ sample := strings.ToLower(string(body))
+ challenge := resp.Header.Get("Cf-Mitigated") == "challenge" || strings.Contains(sample, "cf-chl-") || (strings.Contains(sample, "just a moment") && strings.Contains(sample, "cloudflare"))
+ switch {
+ case challenge:
+ result.Status, result.Detail = "challenge", "已连接网站,网站要求浏览器安全验证"
+ case resp.StatusCode >= 200 && resp.StatusCode < 300:
+ result.Status, result.Detail = "reachable", "HTTPS 主页响应正常"
+ case resp.StatusCode == 403 || resp.StatusCode == 451:
+ result.Status, result.Detail = "restricted", "网站拒绝访问,可能有出口地区或访问策略限制"
+ case resp.StatusCode == 429:
+ result.Status, result.Detail = "restricted", "网站请求频率受限,请稍后重试"
+ default:
+ result.Status, result.Detail = "http_error", "已连接网站,但返回异常 HTTP 状态"
+ }
+ return
+}
+
+func connectivityError(err error) string {
+ var authority x509.UnknownAuthorityError
+ var certificate x509.CertificateInvalidError
+ var hostname x509.HostnameError
+ var dns *net.DNSError
+ var network net.Error
+ switch {
+ case errors.Is(err, context.Canceled):
+ return "测试已取消"
+ case errors.Is(err, context.DeadlineExceeded), errors.As(err, &network) && network.Timeout():
+ return "连接超时,请检查当前节点和网络"
+ case errors.As(err, &authority):
+ return "TLS 证书不受信任,请检查 VM 的 CA 证书包"
+ case errors.As(err, &certificate), errors.As(err, &hostname):
+ return "TLS 证书校验失败,请检查 VM 时间及网站证书"
+ case errors.As(err, &dns):
+ return "域名解析失败,请检查 VM 或 Mihomo 的 DNS"
+ case errors.Is(err, syscall.ECONNREFUSED):
+ return "连接被拒绝,请检查 Mihomo 代理端口与网站状态"
+ default:
+ return "连接失败,请检查当前节点、网络和网站访问策略"
+ }
+}
diff --git a/internal/app/connectivity_test.go b/internal/app/connectivity_test.go
new file mode 100644
index 0000000..a479eef
--- /dev/null
+++ b/internal/app/connectivity_test.go
@@ -0,0 +1,180 @@
+package app
+
+import (
+ "context"
+ "crypto/x509"
+ "encoding/json"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strconv"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+func TestConnectivityResponses(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ code int
+ header, body, status string
+ }{
+ {"success", 200, "", "Welcome", "reachable"},
+ {"header challenge", 403, "challenge", "verify", "challenge"},
+ {"body challenge", 200, "", "
Just a moment Cloudflare cf-chl-test", "challenge"},
+ {"denied", 403, "", "denied", "restricted"},
+ {"region", 451, "", "region", "restricted"},
+ {"limited", 429, "", "rate limited", "restricted"},
+ {"server failure", 503, "", "unavailable", "http_error"},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "GET" {
+ t.Error("not a GET probe")
+ }
+ w.Header().Set("Cf-Mitigated", test.header)
+ w.WriteHeader(test.code)
+ io.WriteString(w, test.body)
+ }))
+ defer server.Close()
+ result := probeConnectivity(context.Background(), server.Client(), connectivitySite{ID: "fixture", URL: server.URL})
+ if result.Status != test.status || result.HTTPStatus != test.code || !result.TLSVerified || result.CheckedAt.IsZero() {
+ t.Fatalf("unexpected result: %+v", result)
+ }
+ })
+ }
+}
+
+func TestConnectivityUsesExplicitProxyAndVerifiesTLS(t *testing.T) {
+ origin := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") }))
+ defer origin.Close()
+ var connects atomic.Int32
+ proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "CONNECT" {
+ t.Error("expected CONNECT")
+ w.WriteHeader(400)
+ return
+ }
+ connects.Add(1)
+ upstream, err := net.Dial("tcp", r.Host)
+ if err != nil {
+ w.WriteHeader(502)
+ return
+ }
+ connection, buffered, err := w.(http.Hijacker).Hijack()
+ if err != nil {
+ upstream.Close()
+ return
+ }
+ defer connection.Close()
+ defer upstream.Close()
+ buffered.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n")
+ buffered.Flush()
+ go io.Copy(upstream, buffered)
+ io.Copy(connection, upstream)
+ }))
+ defer proxy.Close()
+ u, _ := url.Parse(proxy.URL)
+ port, _ := strconv.Atoi(u.Port())
+ t.Setenv("HTTP_PROXY", "http://127.0.0.1:1")
+ t.Setenv("HTTPS_PROXY", "http://127.0.0.1:1")
+ t.Setenv("NO_PROXY", "*")
+ client := connectivityClient(Settings{MixedPort: port})
+ defer client.CloseIdleConnections()
+ transport := client.Transport.(*http.Transport)
+ transport.TLSClientConfig = origin.Client().Transport.(*http.Transport).TLSClientConfig.Clone()
+ result := probeConnectivity(context.Background(), client, connectivitySite{ID: "fixture", URL: origin.URL})
+ if connects.Load() != 1 || result.Status != "reachable" || !result.TLSVerified {
+ t.Fatalf("proxy/TLS regression: %+v, connects=%d", result, connects.Load())
+ }
+ client.CloseIdleConnections()
+ transport.TLSClientConfig.RootCAs = x509.NewCertPool()
+ result = probeConnectivity(context.Background(), client, connectivitySite{ID: "fixture", URL: origin.URL})
+ if result.Status != "failed" || result.TLSVerified || !strings.Contains(result.Detail, "证书") {
+ t.Fatalf("untrusted TLS accepted: %+v", result)
+ }
+}
+
+func TestConnectivityTimeoutCancellationAndRedirects(t *testing.T) {
+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() }))
+ defer server.Close()
+ client := server.Client()
+ client.Timeout = 30 * time.Millisecond
+ result := probeConnectivity(context.Background(), client, connectivitySite{URL: server.URL})
+ if result.Status != "failed" || !strings.Contains(result.Detail, "超时") {
+ t.Fatalf("timeout: %+v", result)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ result = probeConnectivity(ctx, client, connectivitySite{URL: server.URL})
+ if !strings.Contains(result.Detail, "取消") {
+ t.Fatalf("cancel: %+v", result)
+ }
+ redirectClient := connectivityClient(Settings{MixedPort: 7890})
+ defer redirectClient.CloseIdleConnections()
+ original, _ := http.NewRequest("GET", "https://chatgpt.com/", nil)
+ for _, target := range []string{"http://chatgpt.com/", "https://127.0.0.1/", "https://evilchatgpt.com/", "https://chatgpt.com.evil.example/", "https://user:pass@chatgpt.com/"} {
+ req, _ := http.NewRequest("GET", target, nil)
+ if redirectClient.CheckRedirect(req, []*http.Request{original}) == nil {
+ t.Fatalf("unsafe redirect accepted: %s", target)
+ }
+ }
+ req, _ := http.NewRequest("GET", "https://auth.openai.com/", nil)
+ if err := redirectClient.CheckRedirect(req, []*http.Request{original}); err != nil {
+ t.Fatal(err)
+ }
+ if err := redirectClient.CheckRedirect(req, []*http.Request{original, original, original, original, original}); err == nil {
+ t.Fatal("unbounded redirects")
+ }
+ redirectServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "http://127.0.0.1/private", 302) }))
+ defer redirectServer.Close()
+ client = redirectServer.Client()
+ client.CheckRedirect = redirectClient.CheckRedirect
+ result = probeConnectivity(context.Background(), client, connectivitySite{URL: redirectServer.URL})
+ if result.Status != "http_error" || result.HTTPStatus != 302 || !result.TLSVerified {
+ t.Fatalf("redirect response discarded: %+v", result)
+ }
+}
+
+func TestConnectivityAPIAuthCatalogAndLimits(t *testing.T) {
+ a := newTestApp(t)
+ path := "/api/v1/system/connectivity"
+ if w := request(t, a, "GET", path, nil, nil, ""); w.Code != 401 {
+ t.Fatal("unauthenticated test access")
+ }
+ cookie, csrf := loginTest(t, a)
+ w := request(t, a, "GET", path, nil, cookie, "")
+ var sites []connectivitySite
+ if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &sites) != nil || len(sites) != 9 {
+ t.Fatal("catalog missing")
+ }
+ if w := request(t, a, "POST", path+"/test", map[string]string{"siteId": "google"}, cookie, ""); w.Code != 403 {
+ t.Fatal("CSRF missing")
+ }
+ if w := request(t, a, "POST", path+"/test", map[string]string{"siteId": "https://127.0.0.1/"}, cookie, csrf); w.Code != 400 {
+ t.Fatal("arbitrary target allowed")
+ }
+ for i := 0; i < 3; i++ {
+ a.connectivitySlots <- struct{}{}
+ }
+ if w := request(t, a, "POST", path+"/test", map[string]string{"siteId": "google"}, cookie, csrf); w.Code != 429 {
+ t.Fatal("concurrency limit missing")
+ }
+ for i := 0; i < 3; i++ {
+ <-a.connectivitySlots
+ }
+ core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(503) }))
+ defer core.Close()
+ a.core.URL = core.URL
+ w = request(t, a, "POST", path+"/test", map[string]string{"siteId": "google"}, cookie, csrf)
+ var result connectivityResult
+ if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &result) != nil || result.Status != "failed" || !strings.Contains(result.Detail, "内核") {
+ t.Fatal("offline core not explained", w.Body)
+ }
+ if len(a.connectivitySlots) != 0 {
+ t.Fatal("slot leaked")
+ }
+}
diff --git a/internal/app/core.go b/internal/app/core.go
new file mode 100644
index 0000000..824c9fc
--- /dev/null
+++ b/internal/app/core.go
@@ -0,0 +1,358 @@
+package app
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/gorilla/websocket"
+ "gopkg.in/yaml.v3"
+)
+
+type Core struct {
+ URL string
+ Secret string
+ Binary string
+ Dir string
+ Helper string
+ HTTP *http.Client
+}
+
+var errCoreUnavailable = errors.New("Mihomo 内核未连接,请检查服务状态和控制端口")
+
+func (c *Core) Request(ctx context.Context, method, path string, body any, target any) error {
+ var payload io.Reader
+ if body != nil {
+ b, err := json.Marshal(body)
+ if err != nil {
+ return err
+ }
+ payload = bytes.NewReader(b)
+ }
+ req, err := http.NewRequestWithContext(ctx, method, c.URL+path, payload)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Authorization", "Bearer "+c.Secret)
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := c.HTTP.Do(req)
+ if err != nil {
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+ if errors.Is(err, syscall.ECONNREFUSED) {
+ return errCoreUnavailable
+ }
+ return errors.New("Mihomo 请求超时或通信失败,请检查内核状态")
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return fmt.Errorf("内核请求失败(HTTP %d)", resp.StatusCode)
+ }
+ if target != nil {
+ return json.NewDecoder(io.LimitReader(resp.Body, 16<<20)).Decode(target)
+ }
+ return nil
+}
+
+func (c *Core) Validate(ctx context.Context, config []byte) error {
+ if c.Binary == "" {
+ return errors.New("未找到 Mihomo,设置 CLASHWEB_MIHOMO_BIN 后才能应用配置")
+ }
+ file, err := os.CreateTemp(c.Dir, "validate-*.yaml")
+ if err != nil {
+ return err
+ }
+ defer os.Remove(file.Name())
+ if err := file.Chmod(0640); err != nil {
+ file.Close()
+ return err
+ }
+ if _, err = file.Write(config); err != nil {
+ file.Close()
+ return err
+ }
+ file.Close()
+ ctx, cancel := context.WithTimeout(ctx, 25*time.Second)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, c.Binary, "-t", "-d", c.Dir, "-f", file.Name())
+ if _, err := cmd.CombinedOutput(); err != nil {
+ return errors.New("Mihomo 配置校验失败,请检查节点格式及规则资源是否已安装;原配置已保留")
+ }
+ return nil
+}
+
+func atomicWrite(path string, b []byte) error {
+ f, err := os.CreateTemp(filepath.Dir(path), ".config-*")
+ if err != nil {
+ return err
+ }
+ defer os.Remove(f.Name())
+ if err := f.Chmod(0640); err != nil {
+ f.Close()
+ return err
+ }
+ if _, err := f.Write(b); err != nil {
+ f.Close()
+ return err
+ }
+ if err := f.Sync(); err != nil {
+ f.Close()
+ return err
+ }
+ if err := f.Close(); err != nil {
+ return err
+ }
+ return os.Rename(f.Name(), path)
+}
+
+func (c *Core) Apply(ctx context.Context, config []byte) error {
+ if err := c.Validate(ctx, config); err != nil {
+ return err
+ }
+ var version map[string]any
+ probeErr := c.Request(ctx, "GET", "/version", nil, &version)
+ if probeErr != nil && !errors.Is(probeErr, errCoreUnavailable) {
+ return probeErr
+ }
+ path := filepath.Join(c.Dir, "config.yaml")
+ old, readErr := os.ReadFile(path)
+ if err := atomicWrite(path, config); err != nil {
+ return err
+ }
+ if probeErr != nil {
+ // A validated configuration can be staged while the service is stopped.
+ return nil
+ }
+ if err := c.Request(ctx, "PUT", "/configs?force=true", map[string]any{"path": path}, nil); err != nil {
+ if readErr == nil {
+ if writeErr := atomicWrite(path, old); writeErr != nil {
+ return fmt.Errorf("加载失败且恢复配置文件失败: %w", writeErr)
+ }
+ rollbackCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+ if rollbackErr := c.Request(rollbackCtx, "PUT", "/configs?force=true", map[string]any{"path": path}, nil); rollbackErr != nil {
+ return errors.New("配置加载失败,旧文件已恢复,但内核恢复失败;请重启 Mihomo 服务")
+ }
+ } else {
+ _ = os.Remove(path)
+ }
+ return errors.New("配置加载失败,已恢复上一个配置")
+ }
+ return nil
+}
+
+func (c *Core) Service(ctx context.Context, action string) error {
+ if action != "start" && action != "stop" && action != "restart" {
+ return errors.New("不支持的服务操作")
+ }
+ if c.Helper == "" {
+ return errors.New("尚未安装服务控制辅助程序,请使用 Linux 安装脚本")
+ }
+ ctx, cancel := context.WithTimeout(ctx, 25*time.Second)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, "/usr/bin/sudo", "-n", c.Helper, action)
+ if _, err := cmd.CombinedOutput(); err != nil {
+ return errors.New("服务操作失败,请检查 systemd 与辅助程序权限")
+ }
+ return nil
+}
+
+func (c *Core) Logs(ctx context.Context, send func(any) error) error {
+ u, err := url.Parse(c.URL)
+ if err != nil {
+ return err
+ }
+ u.Scheme = "ws"
+ u.Path = "/logs"
+ u.RawQuery = "level=info"
+ headers := http.Header{}
+ headers.Set("Authorization", "Bearer "+c.Secret)
+ dialer := websocket.Dialer{HandshakeTimeout: 5 * time.Second, Proxy: nil}
+ conn, resp, err := dialer.DialContext(ctx, u.String(), headers)
+ if resp != nil && resp.Body != nil {
+ defer resp.Body.Close()
+ }
+ if err != nil {
+ return errors.New("日志流未连接")
+ }
+ defer conn.Close()
+ conn.SetReadLimit(1 << 20)
+ done := make(chan struct{})
+ defer close(done)
+ go func() {
+ select {
+ case <-ctx.Done():
+ conn.Close()
+ case <-done:
+ }
+ }()
+ for {
+ var item map[string]any
+ if err := conn.ReadJSON(&item); err != nil {
+ return err
+ }
+ if text, ok := item["payload"].(string); ok {
+ item["payload"] = redactLog(text)
+ }
+ if err := send(item); err != nil {
+ return err
+ }
+ }
+}
+
+func compileConfig(state State, secret string) ([]byte, error) {
+ settings := state.Settings
+ proxies := []map[string]any{}
+ names := []string{}
+ for _, sub := range state.Subscriptions {
+ if !sub.Enabled {
+ continue
+ }
+ for _, node := range sub.Nodes {
+ proxies = append(proxies, node)
+ name, _ := node["name"].(string)
+ names = append(names, name)
+ }
+ }
+ autoNames := names
+ if len(autoNames) == 0 {
+ autoNames = []string{"REJECT"}
+ }
+ manualNames := append([]string{}, names...)
+ manualNames = append(manualNames, AutoGroup, "REJECT")
+ first := "REJECT"
+ if settings.SelectionMode == "auto" {
+ first = AutoGroup
+ } else {
+ for _, n := range names {
+ if n == settings.SelectedNode {
+ first = n
+ break
+ }
+ }
+ }
+ ordered := []string{first}
+ for _, n := range manualNames {
+ if n != first {
+ ordered = append(ordered, n)
+ }
+ }
+ manualNames = ordered
+ rules := []string{"IP-CIDR,127.0.0.0/8,DIRECT,no-resolve", "IP-CIDR,10.0.0.0/8,DIRECT,no-resolve", "IP-CIDR,172.16.0.0/12,DIRECT,no-resolve", "IP-CIDR,192.168.0.0/16,DIRECT,no-resolve", "IP-CIDR,169.254.0.0/16,DIRECT,no-resolve", "IP-CIDR,224.0.0.0/4,DIRECT,no-resolve"}
+ for _, rule := range state.Rules {
+ if !rule.Enabled {
+ continue
+ }
+ action := rule.Action
+ if action == "PROXY" {
+ action = ProxyGroup
+ }
+ s := rule.Type + "," + rule.Value + "," + action
+ if rule.Type == "IP-CIDR" {
+ s += ",no-resolve"
+ }
+ rules = append(rules, s)
+ }
+ // Rule sets are installed and cached locally; the core does not download them at boot.
+ providers := map[string]any{}
+ for _, r := range []struct{ Name, Path, Behavior string }{{"cn-domain", "rules/cn-domain.mrs", "domain"}, {"cn-ip", "rules/cn-ip.mrs", "ipcidr"}} {
+ providers[r.Name] = map[string]any{"type": "file", "path": r.Path, "behavior": r.Behavior, "format": "mrs"}
+ }
+ rules = append(rules, "RULE-SET,cn-domain,DIRECT", "RULE-SET,cn-ip,DIRECT", "MATCH,"+ProxyGroup)
+ mode := "rule"
+ if !settings.ProxyEnabled {
+ mode = "direct"
+ }
+ listen := "127.0.0.1"
+ if settings.LANIP != "" {
+ listen = settings.LANIP
+ }
+ config := map[string]any{
+ "mixed-port": settings.MixedPort, "allow-lan": settings.LANIP != "", "bind-address": listen,
+ "lan-allowed-ips": []string{settings.LANCIDR}, "mode": mode, "log-level": "info", "ipv6": false,
+ "external-controller": "127.0.0.1:9090", "secret": secret, "external-controller-cors": map[string]any{"allow-origins": []string{}, "allow-private-network": false},
+ "profile": map[string]any{"store-selected": false, "store-fake-ip": true}, "unified-delay": true,
+ "proxies": proxies, "proxy-groups": []any{
+ map[string]any{"name": ProxyGroup, "type": "select", "proxies": manualNames},
+ map[string]any{"name": AutoGroup, "type": "url-test", "proxies": autoNames, "url": settings.TestURL, "interval": 300, "tolerance": 50, "lazy": true},
+ }, "rule-providers": providers, "rules": rules,
+ "dns": map[string]any{
+ "enable": true, "listen": fmt.Sprintf("%s:%d", listen, settings.DNSPort), "ipv6": false, "enhanced-mode": "fake-ip", "fake-ip-range": "198.18.0.1/16",
+ "fake-ip-filter": []string{"*.lan", "*.local", "localhost", "+.home.arpa"},
+ "default-nameserver": []string{"223.5.5.5", "119.29.29.29"},
+ "proxy-server-nameserver": []string{"https://dns.alidns.com/dns-query"},
+ "direct-nameserver": []string{"https://dns.alidns.com/dns-query"},
+ "nameserver-policy": map[string]any{"rule-set:cn-domain": []string{"https://dns.alidns.com/dns-query"}},
+ "nameserver": []string{"https://cloudflare-dns.com/dns-query#" + ProxyGroup},
+ },
+ "tun": map[string]any{"enable": settings.TunEnabled, "device": "cw-tun", "stack": "mixed", "auto-route": true, "auto-redirect": true, "auto-detect-interface": settings.Interface == "", "dns-hijack": []string{"any:53", "tcp://any:53"}, "mtu": 1500, "route-exclude-address": []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"}},
+ }
+ if settings.LANCIDR == "" {
+ delete(config, "lan-allowed-ips")
+ }
+ if settings.Interface != "" {
+ config["interface-name"] = settings.Interface
+ }
+ if !settings.ProxyEnabled {
+ config["dns"].(map[string]any)["nameserver"] = []string{"https://dns.alidns.com/dns-query"}
+ }
+ return yaml.Marshal(config)
+}
+
+func (c *Core) Select(ctx context.Context, state State) error {
+ var version map[string]any
+ if err := c.Request(ctx, "GET", "/version", nil, &version); err != nil {
+ if errors.Is(err, errCoreUnavailable) {
+ return nil
+ }
+ return err
+ }
+ selected := "REJECT"
+ if state.Settings.SelectionMode == "auto" {
+ selected = AutoGroup
+ } else {
+ for _, sub := range state.Subscriptions {
+ if !sub.Enabled {
+ continue
+ }
+ for _, node := range sub.Nodes {
+ if node["name"] == state.Settings.SelectedNode {
+ selected = state.Settings.SelectedNode
+ }
+ }
+ }
+ }
+ return c.Request(ctx, "PUT", "/proxies/"+ProxyGroup, map[string]string{"name": selected}, nil)
+}
+
+func redactLog(text string) string {
+ words := strings.Fields(text)
+ for _, word := range words {
+ start := strings.Index(word, "http")
+ if start < 0 {
+ continue
+ }
+ raw := strings.TrimRight(word[start:], "\"'(),;")
+ u, err := url.Parse(raw)
+ if err == nil && u.Host != "" {
+ u.User = nil
+ if u.RawQuery != "" {
+ u.RawQuery = "REDACTED"
+ }
+ text = strings.ReplaceAll(text, raw, u.String())
+ }
+ }
+ return text
+}
diff --git a/internal/app/integration_test.go b/internal/app/integration_test.go
new file mode 100644
index 0000000..7f7cf74
--- /dev/null
+++ b/internal/app/integration_test.go
@@ -0,0 +1,183 @@
+package app
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "golang.org/x/net/proxy"
+ "gopkg.in/yaml.v3"
+)
+
+func freePort(t *testing.T) int {
+ t.Helper()
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ port := listener.Addr().(*net.TCPAddr).Port
+ listener.Close()
+ return port
+}
+
+// This test starts a real core with TUN disabled and routes test traffic through local HTTP proxy nodes.
+// It never modifies the development machine's routes, forwarding, DNS, or DHCP.
+func TestRealMihomoProxyAndNodeSelection(t *testing.T) {
+ binary := os.Getenv("CLASHWEB_TEST_MIHOMO_BIN")
+ rulesDir := os.Getenv("CLASHWEB_TEST_RULES_DIR")
+ if binary == "" || rulesDir == "" {
+ t.Skip("set CLASHWEB_TEST_MIHOMO_BIN and CLASHWEB_TEST_RULES_DIR to run real-core integration")
+ }
+ binary, _ = filepath.Abs(binary)
+ dir := t.TempDir()
+ os.MkdirAll(filepath.Join(dir, "rules"), 0750)
+ for _, file := range []string{"cn-domain.mrs", "cn-ip.mrs"} {
+ data, err := os.ReadFile(filepath.Join(rulesDir, file))
+ if err != nil {
+ t.Fatal(err)
+ }
+ os.WriteFile(filepath.Join(dir, "rules", file), data, 0640)
+ }
+ makeProxy := func(label string) *httptest.Server {
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodConnect {
+ fmt.Fprint(w, label)
+ return
+ }
+ connection, buffer, err := w.(http.Hijacker).Hijack()
+ if err != nil {
+ return
+ }
+ defer connection.Close()
+ io.WriteString(buffer, "HTTP/1.1 200 Connection Established\r\n\r\n")
+ buffer.Flush()
+ request, err := http.ReadRequest(buffer.Reader)
+ if err != nil {
+ return
+ }
+ request.Body.Close()
+ time.Sleep(30 * time.Millisecond)
+ fmt.Fprintf(buffer, "HTTP/1.1 200 OK\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s", len(label), label)
+ buffer.Flush()
+ }))
+ }
+ nodeA, nodeB := makeProxy("node-a"), makeProxy("node-b")
+ defer nodeA.Close()
+ defer nodeB.Close()
+ makeNode := func(name string, server *httptest.Server) map[string]any {
+ u, _ := url.Parse(server.URL)
+ _, p, _ := net.SplitHostPort(u.Host)
+ port, _ := strconv.Atoi(p)
+ return map[string]any{"name": name, "type": "http", "server": "127.0.0.1", "port": port}
+ }
+ s := defaultState()
+ s.Settings.SelectedNode = "node-A"
+ s.Settings.MixedPort = freePort(t)
+ s.Settings.DNSPort = freePort(t)
+ s.Subscriptions = []Subscription{{Enabled: true, Nodes: []map[string]any{makeNode("node-A", nodeA), makeNode("node-B", nodeB)}}}
+ data, _ := compileConfig(s, "integration-secret")
+ var config map[string]any
+ yaml.Unmarshal(data, &config)
+ controller := freePort(t)
+ config["external-controller"] = fmt.Sprintf("127.0.0.1:%d", controller)
+ data, _ = yaml.Marshal(config)
+ core := Core{Binary: binary, Dir: dir, URL: fmt.Sprintf("http://127.0.0.1:%d", controller), Secret: "integration-secret", HTTP: &http.Client{Timeout: 3 * time.Second}}
+ if err := core.Validate(context.Background(), data); err != nil {
+ t.Fatal(err)
+ }
+ if err := atomicWrite(filepath.Join(dir, "config.yaml"), data); err != nil {
+ t.Fatal(err)
+ }
+ command := exec.Command(binary, "-d", dir, "-f", filepath.Join(dir, "config.yaml"))
+ var output strings.Builder
+ command.Stdout = &output
+ command.Stderr = &output
+ if err := command.Start(); err != nil {
+ t.Fatal(err)
+ }
+ defer func() {
+ command.Process.Signal(os.Interrupt)
+ command.Wait()
+ if t.Failed() {
+ t.Log(output.String())
+ }
+ }()
+ ready := false
+ for i := 0; i < 50; i++ {
+ var version map[string]any
+ if core.Request(context.Background(), "GET", "/version", nil, &version) == nil {
+ ready = true
+ break
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+ if !ready {
+ t.Fatal("core did not start")
+ }
+ var latency struct {
+ Delay int `json:"delay"`
+ }
+ if err := core.Request(context.Background(), "GET", "/proxies/node-A/delay?url="+url.QueryEscape("http://198.51.100.42/health")+"&timeout=2000", nil, &latency); err != nil || latency.Delay <= 0 {
+ t.Fatalf("node latency check failed: delay=%d err=%v", latency.Delay, err)
+ }
+ u, _ := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", s.Settings.MixedPort))
+ client := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(u)}, Timeout: 5 * time.Second}
+ get := func(target, want string) {
+ t.Helper()
+ resp, err := client.Get(target)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ b, _ := io.ReadAll(resp.Body)
+ if string(b) != want {
+ t.Fatalf("got %q, want %q", b, want)
+ }
+ }
+ get("http://198.51.100.42/test", "node-a")
+ if err := core.Request(context.Background(), "PUT", "/proxies/"+ProxyGroup, map[string]string{"name": "node-B"}, nil); err != nil {
+ t.Fatal(err)
+ }
+ get("http://198.51.100.42/test", "node-b")
+ direct := makeProxy("direct-lan")
+ defer direct.Close()
+ get(direct.URL, "direct-lan")
+ dialer, err := proxy.SOCKS5("tcp", fmt.Sprintf("127.0.0.1:%d", s.Settings.MixedPort), nil, proxy.Direct)
+ if err != nil {
+ t.Fatal(err)
+ }
+ socksClient := &http.Client{Transport: &http.Transport{DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
+ return dialer.(proxy.ContextDialer).DialContext(ctx, network, address)
+ }}, Timeout: 5 * time.Second}
+ resp, err := socksClient.Get("http://198.51.100.42/socks")
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if string(b) != "node-b" {
+ t.Fatal("SOCKS selection not applied", string(b))
+ }
+ if err := core.Request(context.Background(), "PUT", "/proxies/"+ProxyGroup, map[string]string{"name": "REJECT"}, nil); err != nil {
+ t.Fatal(err)
+ }
+ resp, err = client.Get("http://198.51.100.42/reject")
+ if err == nil {
+ resp.Body.Close()
+ if resp.StatusCode < 400 {
+ t.Fatal("REJECT fell back to a different node")
+ }
+ }
+ get(direct.URL, "direct-lan")
+}
diff --git a/internal/app/store.go b/internal/app/store.go
new file mode 100644
index 0000000..47065aa
--- /dev/null
+++ b/internal/app/store.go
@@ -0,0 +1,74 @@
+package app
+
+import (
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ _ "modernc.org/sqlite"
+)
+
+type Store struct{ db *sql.DB }
+
+func OpenStore(dir string) (*Store, error) {
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ return nil, err
+ }
+ p := filepath.Join(dir, "clashweb.sqlite")
+ f, err := os.OpenFile(p, os.O_CREATE|os.O_RDWR, 0600)
+ if err != nil {
+ return nil, err
+ }
+ f.Close()
+ db, err := sql.Open("sqlite", p)
+ if err != nil {
+ return nil, err
+ }
+ db.SetMaxOpenConns(1)
+ _, err = db.Exec(`PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;
+ CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT NOT NULL);
+ CREATE TABLE IF NOT EXISTS sessions (token_hash TEXT PRIMARY KEY, csrf TEXT NOT NULL, expires INTEGER NOT NULL);
+ PRAGMA user_version=1;`)
+ if err != nil {
+ db.Close()
+ return nil, err
+ }
+ return &Store{db: db}, nil
+}
+
+func (s *Store) Close() error { return s.db.Close() }
+func (s *Store) Get(key string) (string, error) {
+ var value string
+ err := s.db.QueryRow("SELECT value FROM kv WHERE key=?", key).Scan(&value)
+ if err == sql.ErrNoRows {
+ return "", nil
+ }
+ return value, err
+}
+func (s *Store) Set(key, value string) error {
+ _, err := s.db.Exec("INSERT INTO kv(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", key, value)
+ return err
+}
+func (s *Store) Load() (State, error) {
+ text, err := s.Get("state")
+ if err != nil {
+ return State{}, err
+ }
+ if text == "" {
+ return defaultState(), nil
+ }
+ var state State
+ if err := json.Unmarshal([]byte(text), &state); err != nil {
+ return State{}, fmt.Errorf("数据库中的配置损坏: %w", err)
+ }
+ return state, validateState(state)
+}
+func (s *Store) Save(state State) error {
+ b, err := json.Marshal(state)
+ if err != nil {
+ return err
+ }
+ return s.Set("state", string(b))
+}
diff --git a/internal/app/subscription.go b/internal/app/subscription.go
new file mode 100644
index 0000000..8261d11
--- /dev/null
+++ b/internal/app/subscription.go
@@ -0,0 +1,215 @@
+package app
+
+import (
+ "context"
+ "crypto/x509"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "regexp"
+ "sort"
+ "strings"
+ "syscall"
+ "time"
+
+ "gopkg.in/yaml.v3"
+)
+
+const maxDownload = 8 << 20
+
+type subscriptionDocument struct {
+ Proxies []map[string]any `yaml:"proxies"`
+ Providers map[string]struct {
+ Type string `yaml:"type"`
+ URL string `yaml:"url"`
+ Payload []map[string]any `yaml:"payload"`
+ Filter string `yaml:"filter"`
+ Exclude string `yaml:"exclude-filter"`
+ Override map[string]any `yaml:"override"`
+ Header map[string][]string `yaml:"header"`
+ } `yaml:"proxy-providers"`
+}
+
+func fetchSubscription(ctx context.Context, client *http.Client, raw string, headers map[string][]string) ([]byte, string, error) {
+ if err := validHTTPURL(raw); err != nil {
+ return nil, "", err
+ }
+ req, err := http.NewRequestWithContext(ctx, "GET", raw, nil)
+ if err != nil {
+ return nil, "", errors.New("无效的订阅地址")
+ }
+ req.Header.Set("User-Agent", "clash.meta/ClashWeb")
+ for k, values := range headers {
+ for _, v := range values {
+ req.Header.Add(k, v)
+ }
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, "", subscriptionDownloadError(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 200 {
+ return nil, "", fmt.Errorf("订阅下载失败(HTTP %d)", resp.StatusCode)
+ }
+ b, err := io.ReadAll(io.LimitReader(resp.Body, maxDownload+1))
+ if err != nil {
+ return nil, "", errors.New("订阅读取失败")
+ }
+ if len(b) > maxDownload {
+ return nil, "", errors.New("订阅文件超过 8 MiB 限制")
+ }
+ return b, resp.Header.Get("Subscription-Userinfo"), nil
+}
+
+func subscriptionDownloadError(err error) error {
+ // Do not return url.Error.String(): it includes the private subscription URL.
+ var authority x509.UnknownAuthorityError
+ var hostname x509.HostnameError
+ var certificate x509.CertificateInvalidError
+ var dns *net.DNSError
+ var network net.Error
+ switch {
+ case errors.As(err, &authority):
+ return errors.New("订阅 TLS 证书不受信任,请检查 VM 的 CA 证书包;若站点使用自签名证书,需修复其信任配置")
+ case errors.As(err, &hostname):
+ return errors.New("订阅 TLS 证书与域名不匹配,请核对订阅地址")
+ case errors.As(err, &certificate):
+ return errors.New("订阅 TLS 证书校验失败,请检查 VM 时间和站点证书有效期")
+ case errors.As(err, &dns):
+ return errors.New("订阅域名解析失败,请检查 VM 的系统 DNS 配置")
+ case errors.Is(err, context.Canceled):
+ return errors.New("订阅下载已取消")
+ case errors.Is(err, context.DeadlineExceeded), errors.As(err, &network) && network.Timeout():
+ return errors.New("订阅下载超时,请检查 VM 到订阅服务器的网络或下载代理")
+ case errors.Is(err, syscall.ECONNREFUSED):
+ return errors.New("订阅连接被拒绝,请检查服务器或下载代理端口")
+ default:
+ return errors.New("订阅连接失败,请检查 VM 的网络、TLS 和下载代理配置")
+ }
+}
+
+func parseDocument(data []byte) (subscriptionDocument, error) {
+ var doc subscriptionDocument
+ if err := yaml.Unmarshal(data, &doc); err != nil {
+ return doc, errors.New("订阅不是有效的 Clash/Mihomo YAML;不支持 Base64 订阅")
+ }
+ if len(doc.Proxies) == 0 && len(doc.Providers) == 0 {
+ return doc, errors.New("订阅中未发现 proxies 或 proxy-providers")
+ }
+ return doc, nil
+}
+
+func loadSubscription(ctx context.Context, sub Subscription, client *http.Client) ([]map[string]any, string, error) {
+ ctx, cancel := context.WithTimeout(ctx, 90*time.Second)
+ defer cancel()
+ b, usage, err := fetchSubscription(ctx, client, sub.URL, nil)
+ if err != nil {
+ return nil, "", err
+ }
+ doc, err := parseDocument(b)
+ if err != nil {
+ return nil, "", err
+ }
+ type batch struct {
+ source string
+ nodes []map[string]any
+ }
+ batches := []batch{{"inline", doc.Proxies}}
+ keys := []string{}
+ for key := range doc.Providers {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ if len(keys) > 20 {
+ return nil, "", errors.New("单个订阅最多允许 20 个节点提供者")
+ }
+ for _, key := range keys {
+ provider := doc.Providers[key]
+ nodes := provider.Payload
+ if provider.Type == "http" {
+ data, _, err := fetchSubscription(ctx, client, provider.URL, provider.Header)
+ if err != nil {
+ return nil, "", fmt.Errorf("节点提供者下载失败: %w", err)
+ }
+ var p subscriptionDocument
+ if err := yaml.Unmarshal(data, &p); err != nil {
+ return nil, "", errors.New("节点提供者返回了无效 YAML")
+ }
+ nodes = p.Proxies
+ } else if provider.Type != "inline" {
+ return nil, "", errors.New("仅支持 HTTP 和 inline 节点提供者,不读取订阅指定的本地文件")
+ }
+ var filter, exclude *regexp.Regexp
+ if provider.Filter != "" {
+ filter, err = regexp.Compile(provider.Filter)
+ if err != nil {
+ return nil, "", errors.New("节点提供者筛选表达式无效")
+ }
+ }
+ if provider.Exclude != "" {
+ exclude, err = regexp.Compile(provider.Exclude)
+ if err != nil {
+ return nil, "", errors.New("节点提供者排除表达式无效")
+ }
+ }
+ filtered := []map[string]any{}
+ for _, node := range nodes {
+ name, _ := node["name"].(string)
+ if filter != nil && !filter.MatchString(name) {
+ continue
+ }
+ if exclude != nil && exclude.MatchString(name) {
+ continue
+ }
+ for k, v := range provider.Override {
+ switch k {
+ case "additional-prefix", "additional-suffix":
+ default:
+ node[k] = v
+ }
+ }
+ prefix, _ := provider.Override["additional-prefix"].(string)
+ suffix, _ := provider.Override["additional-suffix"].(string)
+ node["name"] = prefix + name + suffix
+ filtered = append(filtered, node)
+ }
+ batches = append(batches, batch{key, filtered})
+ }
+ result := []map[string]any{}
+ for _, batch := range batches {
+ renames := map[string]string{}
+ for _, node := range batch.nodes {
+ name, ok := node["name"].(string)
+ kind, _ := node["type"].(string)
+ if !ok || strings.TrimSpace(name) == "" || kind == "" || len(name) > 256 {
+ return nil, "", errors.New("节点缺少有效名称或协议类型")
+ }
+ if _, exists := renames[name]; exists {
+ return nil, "", errors.New("同一个节点提供者中存在同名节点")
+ }
+ renames[name] = sub.ID + "/" + batch.source + "/" + name
+ }
+ for _, node := range batch.nodes {
+ if dialer, ok := node["dialer-proxy"].(string); ok && dialer != "" {
+ if renamed, ok := renames[dialer]; ok {
+ node["dialer-proxy"] = renamed
+ } else {
+ return nil, "", errors.New("节点链式代理引用了无法导入的策略组或其他节点提供者")
+ }
+ }
+ name := node["name"].(string)
+ node["name"] = renames[name]
+ result = append(result, node)
+ }
+ }
+ if len(result) == 0 {
+ return nil, "", errors.New("订阅中没有可用的节点")
+ }
+ if len(result) > 5000 {
+ return nil, "", errors.New("单个订阅最多允许 5000 个节点")
+ }
+ return result, usage, nil
+}
diff --git a/internal/app/subscription_errors_test.go b/internal/app/subscription_errors_test.go
new file mode 100644
index 0000000..34c4fe4
--- /dev/null
+++ b/internal/app/subscription_errors_test.go
@@ -0,0 +1,35 @@
+package app
+
+import (
+ "context"
+ "crypto/x509"
+ "net"
+ "net/url"
+ "strings"
+ "syscall"
+ "testing"
+)
+
+func TestSubscriptionDownloadErrorsExplainCauseWithoutURL(t *testing.T) {
+ for _, test := range []struct {
+ err error
+ phrase string
+ }{
+ {x509.UnknownAuthorityError{}, "CA 证书包"},
+ {x509.HostnameError{}, "域名不匹配"},
+ {x509.CertificateInvalidError{}, "证书校验失败"},
+ {&net.DNSError{Err: "no such host", Name: "private.example"}, "域名解析失败"},
+ {context.DeadlineExceeded, "下载超时"},
+ {context.Canceled, "已取消"},
+ {syscall.ECONNREFUSED, "连接被拒绝"},
+ } {
+ wrapped := &url.Error{Op: "Get", URL: "https://private.example/sub?token=secret-token", Err: test.err}
+ message := subscriptionDownloadError(wrapped).Error()
+ if !strings.Contains(message, test.phrase) {
+ t.Fatalf("cause not explained: %s", message)
+ }
+ if strings.Contains(message, "secret-token") || strings.Contains(message, "private.example") {
+ t.Fatal("download error exposed subscription URL")
+ }
+ }
+}
diff --git a/internal/app/types.go b/internal/app/types.go
new file mode 100644
index 0000000..bb25e77
--- /dev/null
+++ b/internal/app/types.go
@@ -0,0 +1,219 @@
+package app
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net"
+ "net/netip"
+ "net/url"
+ "regexp"
+ "strings"
+ "time"
+)
+
+const ProxyGroup = "CW-PROXY"
+const AutoGroup = "CW-AUTO"
+
+type Subscription struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ URL string `json:"url"`
+ Enabled bool `json:"enabled"`
+ IntervalHours int `json:"intervalHours"`
+ UpdatedAt string `json:"updatedAt"`
+ AttemptedAt string `json:"attemptedAt"`
+ LastError string `json:"lastError"`
+ Usage string `json:"usage,omitempty"`
+ Nodes []map[string]any `json:"nodes"`
+}
+
+type Rule struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Value string `json:"value"`
+ Action string `json:"action"`
+ Enabled bool `json:"enabled"`
+}
+
+type Settings struct {
+ ProxyEnabled bool `json:"proxyEnabled"`
+ TunEnabled bool `json:"tunEnabled"`
+ SelectionMode string `json:"selectionMode"`
+ SelectedNode string `json:"selectedNode"`
+ LANIP string `json:"lanIP"`
+ LANCIDR string `json:"lanCIDR"`
+ Gateway string `json:"gateway"`
+ Interface string `json:"interface"`
+ MixedPort int `json:"mixedPort"`
+ DNSPort int `json:"dnsPort"`
+ TestURL string `json:"testURL"`
+ TestTimeoutMS int `json:"testTimeoutMS"`
+}
+
+type State struct {
+ Subscriptions []Subscription `json:"subscriptions"`
+ Rules []Rule `json:"rules"`
+ Settings Settings `json:"settings"`
+}
+
+type Backup struct {
+ Version int `json:"version"`
+ ExportedAt string `json:"exportedAt"`
+ State State `json:"state"`
+}
+
+func defaultState() State {
+ return State{Subscriptions: []Subscription{}, Rules: []Rule{}, Settings: Settings{
+ ProxyEnabled: true, SelectionMode: "manual", MixedPort: 7890, DNSPort: 1053,
+ TestURL: "https://www.gstatic.com/generate_204", TestTimeoutMS: 5000,
+ }}
+}
+
+func cloneState(s State) State {
+ b, _ := json.Marshal(s)
+ var copy State
+ _ = json.Unmarshal(b, ©)
+ return copy
+}
+
+func validHTTPURL(raw string) error {
+ u, err := url.Parse(raw)
+ if err != nil || u.Hostname() == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.Fragment != "" {
+ return errors.New("请输入有效的 HTTP 或 HTTPS 地址(不支持 userinfo 或 URL fragment)")
+ }
+ return nil
+}
+
+func validateSubscription(s Subscription) error {
+ if strings.TrimSpace(s.Name) == "" || len(s.Name) > 160 {
+ return errors.New("订阅名称不能为空,且不能超过 160 字节")
+ }
+ if s.IntervalHours < 1 || s.IntervalHours > 720 {
+ return errors.New("更新间隔应为 1–720 小时")
+ }
+ return validHTTPURL(s.URL)
+}
+
+var interfacePattern = regexp.MustCompile(`^[a-zA-Z0-9_.:-]{1,15}$`)
+var domainPattern = regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?$`)
+
+func validateSettings(s Settings) error {
+ if s.SelectionMode != "manual" && s.SelectionMode != "auto" {
+ return errors.New("无效的节点选择模式")
+ }
+ if s.MixedPort < 1024 || s.MixedPort > 65535 || s.DNSPort < 1 || s.DNSPort > 65535 || s.MixedPort == s.DNSPort || s.MixedPort == 9090 || s.DNSPort == 9090 || s.MixedPort == 8080 || s.DNSPort == 8080 {
+ return errors.New("代理端口应为 1024–65535,DNS 端口为 1–65535,且不能与管理端口或控制端口冲突")
+ }
+ if s.TestTimeoutMS < 1000 || s.TestTimeoutMS > 15000 {
+ return errors.New("测速超时应为 1000–15000 毫秒")
+ }
+ if err := validHTTPURL(s.TestURL); err != nil {
+ return err
+ }
+ if s.Interface != "" && !interfacePattern.MatchString(s.Interface) {
+ return errors.New("无效的网卡名称")
+ }
+ for label, raw := range map[string]string{"VM 地址": s.LANIP, "上游网关": s.Gateway} {
+ if raw != "" {
+ ip, err := netip.ParseAddr(raw)
+ if err != nil || !ip.Is4() || !ip.IsPrivate() {
+ return fmt.Errorf("%s应为内网 IPv4 地址", label)
+ }
+ }
+ }
+ if s.LANCIDR != "" {
+ prefix, err := netip.ParsePrefix(s.LANCIDR)
+ if err != nil || !prefix.Addr().Is4() || !prefix.Addr().IsPrivate() || prefix.Bits() < 8 || prefix.Bits() > 30 {
+ return errors.New("请输入有效的内网 IPv4 网段,例如 192.168.1.0/24")
+ }
+ if s.LANIP != "" {
+ ip, _ := netip.ParseAddr(s.LANIP)
+ if !prefix.Contains(ip) {
+ return errors.New("VM 地址不在内网网段内")
+ }
+ }
+ if s.Gateway != "" {
+ ip, _ := netip.ParseAddr(s.Gateway)
+ if !prefix.Contains(ip) {
+ return errors.New("上游网关不在内网网段内")
+ }
+ }
+ }
+ if s.LANIP != "" && s.LANIP == s.Gateway {
+ return errors.New("VM 的上游网关必须指向原路由器,不能指向自己")
+ }
+ if s.TunEnabled && (s.LANIP == "" || s.LANCIDR == "" || s.Gateway == "" || s.Interface == "" || s.DNSPort != 53) {
+ return errors.New("透明代理需要填写 VM 地址、网段、上游网关、网卡,并把 DNS 端口设为 53;先完成 Linux 网络部署步骤")
+ }
+ return nil
+}
+
+func validateRule(r Rule) error {
+ if r.Action != "DIRECT" && r.Action != "PROXY" {
+ return errors.New("规则动作应为直连或代理")
+ }
+ if r.Type == "IP-CIDR" {
+ p, err := netip.ParsePrefix(r.Value)
+ if err != nil || !p.Addr().Is4() {
+ return errors.New("请输入有效的 IPv4 网段")
+ }
+ } else if r.Type == "DOMAIN" || r.Type == "DOMAIN-SUFFIX" {
+ if len(r.Value) > 253 || !domainPattern.MatchString(r.Value) || strings.Contains(r.Value, "..") {
+ return errors.New("请输入域名,不要包含协议、端口或路径")
+ }
+ } else {
+ return errors.New("不支持此规则类型")
+ }
+ return nil
+}
+
+func validateState(s State) error {
+ if len(s.Subscriptions) > 50 || len(s.Rules) > 1000 {
+ return errors.New("最多允许 50 个订阅、1000 条规则")
+ }
+ if err := validateSettings(s.Settings); err != nil {
+ return err
+ }
+ ids := map[string]bool{}
+ for _, sub := range s.Subscriptions {
+ if !validID(sub.ID) || ids[sub.ID] {
+ return errors.New("订阅 ID 无效或重复")
+ }
+ ids[sub.ID] = true
+ if err := validateSubscription(sub); err != nil {
+ return err
+ }
+ if len(sub.Nodes) > 5000 {
+ return errors.New("单个订阅节点过多")
+ }
+ }
+ ids = map[string]bool{}
+ for _, rule := range s.Rules {
+ if !validID(rule.ID) || ids[rule.ID] {
+ return errors.New("规则 ID 无效或重复")
+ }
+ ids[rule.ID] = true
+ if err := validateRule(rule); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func validID(s string) bool { return regexp.MustCompile(`^[a-f0-9]{24}$`).MatchString(s) }
+func now() string { return time.Now().UTC().Format(time.RFC3339) }
+
+func interfaceExists(name string, ip string) bool {
+ i, err := net.InterfaceByName(name)
+ if err != nil {
+ return false
+ }
+ addresses, _ := i.Addrs()
+ for _, a := range addresses {
+ if strings.Split(a.String(), "/")[0] == ip {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/app/updates.go b/internal/app/updates.go
new file mode 100644
index 0000000..d6b433b
--- /dev/null
+++ b/internal/app/updates.go
@@ -0,0 +1,125 @@
+package app
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const coreReleaseEndpoint = "https://api.github.com/repos/MetaCubeX/mihomo/releases/latest"
+
+var stableVersionPattern = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)$`)
+
+type coreRelease struct {
+ Tag string `json:"tag_name"`
+ Published string `json:"published_at"`
+ Draft bool `json:"draft"`
+ Prerelease bool `json:"prerelease"`
+}
+
+func stableVersion(version string) ([3]int, bool) {
+ var parts [3]int
+ match := stableVersionPattern.FindStringSubmatch(strings.TrimSpace(version))
+ if len(match) != 4 {
+ return parts, false
+ }
+ for i := range parts {
+ n, err := strconv.Atoi(match[i+1])
+ if err != nil {
+ return parts, false
+ }
+ parts[i] = n
+ }
+ return parts, true
+}
+
+func newerStableVersion(current, latest string) *bool {
+ a, currentOK := stableVersion(current)
+ b, latestOK := stableVersion(latest)
+ if !currentOK || !latestOK {
+ return nil
+ }
+ newer := false
+ for i := range a {
+ if b[i] != a[i] {
+ newer = b[i] > a[i]
+ break
+ }
+ }
+ return &newer
+}
+
+func fetchCoreRelease(ctx context.Context, client *http.Client) (*coreRelease, error) {
+ ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
+ defer cancel()
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, coreReleaseEndpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Accept", "application/vnd.github+json")
+ req.Header.Set("User-Agent", "ClashWeb-update-check")
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, errors.New("无法连接 GitHub,请检查网络后重试")
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
+ return nil, errors.New("GitHub 更新检查暂时受限,请稍后重试")
+ }
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("更新检查失败(HTTP %d)", resp.StatusCode)
+ }
+ data, err := io.ReadAll(io.LimitReader(resp.Body, (2<<20)+1))
+ if err != nil || len(data) > 2<<20 {
+ return nil, errors.New("版本信息读取失败")
+ }
+ var release coreRelease
+ if err := json.Unmarshal(data, &release); err != nil {
+ return nil, errors.New("版本信息格式错误")
+ }
+ if _, ok := stableVersion(release.Tag); !ok || release.Draft || release.Prerelease {
+ return nil, errors.New("未获取到有效的 Mihomo 稳定版本")
+ }
+ return &release, nil
+}
+
+func (a *App) latestCoreRelease(ctx context.Context) (*coreRelease, time.Time, error) {
+ a.updatesMu.Lock()
+ defer a.updatesMu.Unlock()
+ if a.updateCache != nil && time.Since(a.updateTime) < 10*time.Minute {
+ return a.updateCache, a.updateTime, nil
+ }
+ release, err := fetchCoreRelease(ctx, a.download)
+ if err != nil {
+ return nil, time.Time{}, err
+ }
+ a.updateCache, a.updateTime = release, time.Now()
+ return release, a.updateTime, nil
+}
+
+func (a *App) coreUpdates(w http.ResponseWriter, r *http.Request) {
+ release, checked, err := a.latestCoreRelease(r.Context())
+ if err != nil {
+ writeError(w, http.StatusBadGateway, err.Error())
+ return
+ }
+ var version struct {
+ Version string `json:"version"`
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
+ defer cancel()
+ _ = a.core.Request(ctx, http.MethodGet, "/version", nil, &version)
+ jsonResponse(w, http.StatusOK, map[string]any{
+ "currentVersion": version.Version, "latestVersion": release.Tag,
+ "updateAvailable": newerStableVersion(version.Version, release.Tag),
+ "checkedAt": checked.Format(time.RFC3339), "publishedAt": release.Published,
+ "releaseURL": "https://github.com/MetaCubeX/mihomo/releases/tag/" + release.Tag,
+ })
+}
diff --git a/internal/app/updates_test.go b/internal/app/updates_test.go
new file mode 100644
index 0000000..7cbdeab
--- /dev/null
+++ b/internal/app/updates_test.go
@@ -0,0 +1,84 @@
+package app
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "strings"
+ "sync/atomic"
+ "testing"
+)
+
+type updateTransport func(*http.Request) (*http.Response, error)
+
+func (transport updateTransport) RoundTrip(request *http.Request) (*http.Response, error) {
+ return transport(request)
+}
+
+func TestStableVersionComparison(t *testing.T) {
+ for _, test := range []struct {
+ current, latest string
+ known, newer bool
+ }{
+ {"v1.19.9", "v1.19.32", true, true},
+ {"1.19.32", "v1.19.32", true, false},
+ {"v2.0.0", "v1.99.99", true, false},
+ {"v1.19.32", "v1.20.0", true, true},
+ {"", "v1.19.32", false, false},
+ {"Meta-alpha", "v1.19.32", false, false},
+ {"v1.19.32", "v1.20.0-beta", false, false},
+ } {
+ result := newerStableVersion(test.current, test.latest)
+ if (result != nil) != test.known || result != nil && *result != test.newer {
+ t.Fatalf("%s -> %s: got %v", test.current, test.latest, result)
+ }
+ }
+}
+
+func TestUpdateCheckAuthCachingAndFailure(t *testing.T) {
+ a := newTestApp(t)
+ var calls atomic.Int32
+ a.download.Transport = updateTransport(func(r *http.Request) (*http.Response, error) {
+ calls.Add(1)
+ if r.URL.String() != coreReleaseEndpoint {
+ t.Fatal("unexpected update source", r.URL)
+ }
+ return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"tag_name":"v1.19.33","published_at":"2026-10-03T00:00:00Z"}`)), Header: http.Header{}}, nil
+ })
+ if w := request(t, a, "GET", "/api/v1/system/updates", nil, nil, ""); w.Code != 401 || calls.Load() != 0 {
+ t.Fatal("unauthenticated update check must not fetch metadata")
+ }
+ cookie, _ := loginTest(t, a)
+ for range 2 {
+ w := request(t, a, "GET", "/api/v1/system/updates", nil, cookie, "")
+ if w.Code != 200 || !strings.Contains(w.Body.String(), `"updateAvailable":null`) || !strings.Contains(w.Body.String(), `"latestVersion":"v1.19.33"`) {
+ t.Fatal(w.Code, w.Body)
+ }
+ }
+ if calls.Load() != 1 {
+ t.Fatal("repeated checks must use cached release metadata")
+ }
+ a.updateCache = nil
+ a.download.Transport = updateTransport(func(*http.Request) (*http.Response, error) {
+ return &http.Response{StatusCode: 429, Body: io.NopCloser(strings.NewReader("rate limited")), Header: http.Header{}}, nil
+ })
+ if w := request(t, a, "GET", "/api/v1/system/updates", nil, cookie, ""); w.Code != 502 {
+ t.Fatal(w.Code, w.Body)
+ }
+}
+
+func TestUpdateCheckRejectsInvalidRelease(t *testing.T) {
+ for _, body := range []string{
+ `{"tag_name":"v1.20.0-beta"}`,
+ `{"tag_name":"v1.20.0","draft":true}`,
+ `{"tag_name":"v1.20.0","prerelease":true}`,
+ `{broken`, strings.Repeat("x", (2<<20)+1),
+ } {
+ client := &http.Client{Transport: updateTransport(func(*http.Request) (*http.Response, error) {
+ return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: http.Header{}}, nil
+ })}
+ if _, err := fetchCoreRelease(context.Background(), client); err == nil {
+ t.Fatal("invalid stable release metadata accepted")
+ }
+ }
+}
diff --git a/scripts/clashweb-control b/scripts/clashweb-control
new file mode 100755
index 0000000..ba45a8a
--- /dev/null
+++ b/scripts/clashweb-control
@@ -0,0 +1,11 @@
+#!/bin/sh
+set -eu
+export PATH=/usr/sbin:/usr/bin:/sbin:/bin
+if [ "$#" -ne 1 ]; then
+ echo 'Usage: clashweb-control start|stop|restart' >&2
+ exit 2
+fi
+case "$1" in
+ start|stop|restart) exec /usr/bin/systemctl --no-pager "$1" clashweb-mihomo.service ;;
+ *) echo 'Unsupported action' >&2; exit 2 ;;
+esac
diff --git a/scripts/diagnose-subscription.py b/scripts/diagnose-subscription.py
new file mode 100644
index 0000000..011f34b
--- /dev/null
+++ b/scripts/diagnose-subscription.py
@@ -0,0 +1,83 @@
+#!/usr/bin/env python3
+"""Read-only VM diagnostics. Never prints subscription URLs or node credentials."""
+import argparse
+import json
+import os
+from pathlib import Path
+import sqlite3
+import subprocess
+import sys
+from urllib.parse import urlsplit
+
+parser = argparse.ArgumentParser(description=__doc__)
+parser.add_argument('--host', required=True, help='Hostname of a saved subscription')
+parser.add_argument('--data-dir', default='/var/lib/clashweb')
+parser.add_argument('--proxy', help='Override the saved VM Mihomo HTTP proxy address')
+args = parser.parse_args()
+if os.geteuid() != 0:
+ raise SystemExit('Run with sudo to read the protected VM database; all network tests run as user clashweb.')
+database = (Path(args.data_dir) / 'clashweb.sqlite').resolve()
+with sqlite3.connect(database.as_uri() + '?mode=ro', uri=True) as db:
+ row = db.execute("SELECT value FROM kv WHERE key='state'").fetchone()
+if not row:
+ raise SystemExit('No saved state found.')
+state = json.loads(row[0])
+settings = state.get('settings', {})
+proxy = args.proxy or f"http://{settings.get('lanIP') or '127.0.0.1'}:{settings.get('mixedPort', 7890)}"
+subscriptions = [s for s in state.get('subscriptions', []) if urlsplit(s.get('url', '')).hostname == args.host]
+if not subscriptions:
+ raise SystemExit('No saved subscription matches this hostname.')
+code = r'''
+import hashlib,json,os,pathlib,socket,ssl,time,urllib.error,urllib.request
+from urllib.parse import urlsplit
+payload=json.loads(__import__('sys').stdin.read())
+url=payload['url'];host=urlsplit(url).hostname
+class SafeRedirect(urllib.request.HTTPRedirectHandler):
+ max_redirections=5
+ def redirect_request(self,request,fp,code,msg,headers,newurl):
+ target=urlsplit(newurl)
+ if target.scheme not in ('http','https') or target.username is not None:
+ raise urllib.error.URLError('Unsupported redirect')
+ return super().redirect_request(request,fp,code,msg,headers,newurl)
+verify=ssl.get_default_verify_paths()
+result={'host':host,'runAsUID':os.getuid(),'caBundleExists':bool(verify.cafile and pathlib.Path(verify.cafile).exists()),'utcTime':time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime()),'tests':[]}
+try:
+ addresses=socket.getaddrinfo(host,443,type=socket.SOCK_STREAM)
+ result['systemDNSAddresses']=sorted({a[4][0] for a in addresses})
+except Exception as error:result['systemDNSError']=type(error).__name__
+for mode,proxy in [('direct',{}),('vm-proxy',{'http':payload['proxy'],'https':payload['proxy']})]:
+ started=time.monotonic();entry={'mode':mode}
+ try:
+ opener=urllib.request.build_opener(urllib.request.ProxyHandler(proxy),SafeRedirect())
+ request=urllib.request.Request(url,headers={'User-Agent':'clash.meta/ClashWeb'})
+ try:response=opener.open(request,timeout=20)
+ except urllib.error.HTTPError as error:response=error
+ with response:
+ body=response.read(8*1024*1024+1)
+ entry.update({'httpStatus':response.status,'contentType':response.headers.get('Content-Type'),'bytes':len(body),'sha256':hashlib.sha256(body).hexdigest(),'hasClashProxies':b'proxies:' in body,'cfChallenge':response.headers.get('cf-mitigated')=='challenge'})
+ except Exception as error:
+ reason=getattr(error,'reason',error)
+ entry['errorType']=type(reason).__name__
+ if isinstance(reason,ssl.SSLCertVerificationError):
+ entry['certificateErrorCode']=reason.verify_code;entry['detail']=reason.verify_message
+ elif isinstance(reason,socket.gaierror):entry['detail']='System DNS lookup failed'
+ elif isinstance(reason,(socket.timeout,TimeoutError)):entry['detail']='Connection or read timed out'
+ else:
+ entry['detail']='Network connection failed'
+ if getattr(reason,'errno',None) is not None:entry['errno']=reason.errno
+ entry['seconds']=round(time.monotonic()-started,2);result['tests'].append(entry)
+print(json.dumps(result,ensure_ascii=False,indent=2))
+'''
+for subscription in subscriptions:
+ try:
+ result = subprocess.run(
+ ['/usr/bin/sudo', '-n', '-u', 'clashweb', 'python3', '-c', code],
+ input=json.dumps({'url': subscription['url'], 'proxy': proxy}),
+ text=True, capture_output=True, timeout=60,
+ )
+ except subprocess.TimeoutExpired:
+ raise SystemExit('Diagnostics timed out; inspect VM system DNS and connectivity.')
+ if result.returncode:
+ print('Diagnostics subprocess failed; no stored URL or subscription body has been printed.', file=sys.stderr)
+ raise SystemExit(result.returncode)
+ print(result.stdout.strip())
diff --git a/scripts/fetch-mihomo.py b/scripts/fetch-mihomo.py
new file mode 100755
index 0000000..453e57c
--- /dev/null
+++ b/scripts/fetch-mihomo.py
@@ -0,0 +1,48 @@
+#!/usr/bin/env python3
+"""Download a pinned official release, verify GitHub's asset SHA256, and unpack it."""
+import argparse
+import gzip
+import hashlib
+import json
+import os
+import platform
+import tempfile
+import urllib.request
+from pathlib import Path
+
+parser = argparse.ArgumentParser()
+parser.add_argument('--version', default='v1.19.32')
+parser.add_argument('--output', default='bin/mihomo')
+args = parser.parse_args()
+machine = platform.machine()
+arch = {'x86_64': 'amd64-compatible', 'aarch64': 'arm64'}.get(machine)
+if arch is None:
+ raise SystemExit(f'Unsupported architecture: {machine}')
+if not args.version.startswith('v') or any(c not in 'v0123456789.' for c in args.version):
+ raise SystemExit('Use an explicit stable version such as v1.19.32')
+request = urllib.request.Request(f'https://api.github.com/repos/MetaCubeX/mihomo/releases/tags/{args.version}', headers={'User-Agent': 'ClashWeb-build'})
+with urllib.request.urlopen(request, timeout=30) as response:
+ release = json.load(response)
+name = f'mihomo-linux-{arch}-{args.version}.gz'
+asset = next((a for a in release['assets'] if a['name'] == name), None)
+if not asset or not asset.get('digest', '').startswith('sha256:'):
+ raise SystemExit('Release asset or authoritative SHA256 digest is missing; download aborted.')
+print(f'Downloading {name}', flush=True)
+with urllib.request.urlopen(asset['browser_download_url'], timeout=120) as response:
+ compressed = response.read(100 * 1024 * 1024 + 1)
+if len(compressed) > 100 * 1024 * 1024:
+ raise SystemExit('Asset exceeds download limit')
+if hashlib.sha256(compressed).hexdigest() != asset['digest'].split(':', 1)[1]:
+ raise SystemExit('SHA256 mismatch; download aborted')
+output = Path(args.output)
+output.parent.mkdir(parents=True, exist_ok=True)
+fd, temporary = tempfile.mkstemp(dir=output.parent, prefix='.mihomo-')
+try:
+ with os.fdopen(fd, 'wb') as stream:
+ stream.write(gzip.decompress(compressed))
+ os.chmod(temporary, 0o755)
+ os.replace(temporary, output)
+finally:
+ if os.path.exists(temporary):
+ os.unlink(temporary)
+print(f'Verified {asset["digest"]}; installed to {output}')
diff --git a/scripts/fetch-rules.py b/scripts/fetch-rules.py
new file mode 100755
index 0000000..0df2a79
--- /dev/null
+++ b/scripts/fetch-rules.py
@@ -0,0 +1,34 @@
+#!/usr/bin/env python3
+"""Cache mainland domain/IP rule sets locally. Does not restart any service."""
+import argparse
+import os
+import tempfile
+import urllib.request
+from pathlib import Path
+
+parser = argparse.ArgumentParser()
+parser.add_argument('--output', default='data/mihomo/rules')
+args = parser.parse_args()
+directory = Path(args.output)
+directory.mkdir(parents=True, exist_ok=True)
+files = {'cn-domain.mrs': 'geosite/cn.mrs', 'cn-ip.mrs': 'geoip/cn.mrs'}
+downloads = {}
+for name, remote in files.items():
+ request = urllib.request.Request('https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/' + remote, headers={'User-Agent': 'ClashWeb-build'})
+ with urllib.request.urlopen(request, timeout=60) as response:
+ data = response.read(32 * 1024 * 1024 + 1)
+ # MRS resources use a Zstandard frame; core validation checks the decoded payload.
+ if len(data) > 32 * 1024 * 1024 or data[:4] != b'\x28\xb5\x2f\xfd':
+ raise SystemExit(f'{name}: invalid MRS resource or size exceeds limit')
+ downloads[name] = data
+for name, data in downloads.items():
+ fd, temporary = tempfile.mkstemp(dir=directory, prefix='.rules-')
+ try:
+ with os.fdopen(fd, 'wb') as stream:
+ stream.write(data)
+ os.chmod(temporary, 0o640)
+ os.replace(temporary, directory / name)
+ finally:
+ if os.path.exists(temporary):
+ os.unlink(temporary)
+ print(f'Cached {name}: {len(data)} bytes')
diff --git a/scripts/install.sh b/scripts/install.sh
new file mode 100755
index 0000000..94e4535
--- /dev/null
+++ b/scripts/install.sh
@@ -0,0 +1,61 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Run on the PVE service VM only. Does not enable TUN, DHCP, or change routes.
+task_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
+bind='127.0.0.1:8080'
+confirmed=false
+while (($#)); do
+ case "$1" in
+ --target-vm) confirmed=true; shift ;;
+ --bind) bind=${2:?missing bind address}; shift 2 ;;
+ *) echo "Usage: sudo scripts/install.sh --target-vm --bind VM_IP:8080" >&2; exit 2 ;;
+ esac
+done
+[[ $EUID -eq 0 && $confirmed == true ]] || { echo 'Run as root on the PVE service VM with --target-vm.' >&2; exit 1; }
+python3 - "$bind" <<'PY'
+import ipaddress, sys
+address, port = sys.argv[1].rsplit(':', 1)
+ip = ipaddress.ip_address(address)
+assert ip.version == 4 and (ip.is_private or ip.is_loopback) and port == '8080', 'Use VM private IPv4:8080'
+PY
+for required in "$task_root/bin/clashweb" "$task_root/bin/mihomo" "$task_root/data/mihomo/rules/cn-domain.mrs" "$task_root/data/mihomo/rules/cn-ip.mrs"; do
+ [[ -f "$required" ]] || { echo "Missing $required; see README build instructions." >&2; exit 1; }
+done
+for command in systemctl useradd groupadd sudo visudo; do command -v "$command" >/dev/null || { echo "Missing $command" >&2; exit 1; }; done
+getent group clashweb-core >/dev/null || groupadd --system clashweb-core
+id clashweb >/dev/null 2>&1 || useradd --system --user-group --no-create-home --home-dir /var/lib/clashweb --shell /usr/sbin/nologin clashweb
+id clashweb-mihomo >/dev/null 2>&1 || useradd --system --gid clashweb-core --no-create-home --home-dir /var/lib/clashweb-core --shell /usr/sbin/nologin clashweb-mihomo
+usermod -aG clashweb-core clashweb
+install -d -m 0700 -o clashweb -g clashweb /var/lib/clashweb
+install -d -m 2770 -o clashweb -g clashweb-core /var/lib/clashweb-core /var/lib/clashweb-core/rules
+install -d -m 0755 /usr/local/libexec /etc/clashweb
+install -m 0755 -o root -g root "$task_root/bin/clashweb" /usr/local/bin/clashweb
+install -m 0755 -o root -g root "$task_root/bin/mihomo" /usr/local/bin/mihomo
+install -m 0755 -o root -g root "$task_root/scripts/clashweb-control" /usr/local/libexec/clashweb-control
+install -m 0640 -o clashweb -g clashweb-core "$task_root/data/mihomo/rules/"*.mrs /var/lib/clashweb-core/rules/
+if [[ -f /etc/clashweb/env ]]; then cp -a /etc/clashweb/env "/etc/clashweb/env.backup-$(date +%Y%m%d-%H%M%S)"; fi
+cat > /etc/clashweb/env < "$task_sudoers" <<'EOF'
+clashweb ALL=(root) NOPASSWD: /usr/local/libexec/clashweb-control start, /usr/local/libexec/clashweb-control stop, /usr/local/libexec/clashweb-control restart
+EOF
+visudo -cf "$task_sudoers"
+install -m 0440 -o root -g root "$task_sudoers" /etc/sudoers.d/clashweb
+sudo -u clashweb env CLASHWEB_DATA_DIR=/var/lib/clashweb CLASHWEB_CORE_DIR=/var/lib/clashweb-core CLASHWEB_MIHOMO_BIN=/usr/local/bin/mihomo /usr/local/bin/clashweb --init
+install -m 0644 "$task_root/deploy/clashweb.service" "$task_root/deploy/clashweb-mihomo.service" /etc/systemd/system/
+systemctl daemon-reload
+systemctl enable --now clashweb-mihomo.service clashweb.service
+systemctl restart clashweb-mihomo.service clashweb.service
+systemctl is-active --quiet clashweb-mihomo.service clashweb.service
+echo "Installed on service VM. Open http://$bind"
+echo 'First-run token: journalctl -u clashweb -n 30'
+echo 'TUN remains disabled. Follow docs/deployment.md before enabling gateway mode.'
diff --git a/scripts/network-vm.py b/scripts/network-vm.py
new file mode 100755
index 0000000..5457482
--- /dev/null
+++ b/scripts/network-vm.py
@@ -0,0 +1,106 @@
+#!/usr/bin/env python3
+"""Explicit, reversible network preparation on the service VM; never manages DHCP."""
+import argparse
+import ipaddress
+import json
+import os
+import re
+import subprocess
+from pathlib import Path
+
+parser = argparse.ArgumentParser()
+parser.add_argument('action', choices=['check', 'apply', 'rollback'])
+parser.add_argument('--target-vm', action='store_true')
+parser.add_argument('--interface')
+parser.add_argument('--lan-cidr')
+args = parser.parse_args()
+backup = Path('/var/lib/clashweb-network/backup.json')
+sysctl_file = Path('/etc/sysctl.d/90-clashweb.conf')
+nft_file = Path('/etc/clashweb/network.nft')
+service_file = Path('/etc/systemd/system/clashweb-network.service')
+
+def run(command, **kwargs):
+ return subprocess.run(command, check=True, text=True, capture_output=True, **kwargs).stdout
+
+def sysctl_get(key):
+ return run(['sysctl', '-n', key]).strip()
+
+if args.action == 'check':
+ print(run(['ip', '-br', '-4', 'address']))
+ print(run(['ip', '-4', 'route']))
+ print('IPv4 forwarding:', sysctl_get('net.ipv4.ip_forward'))
+ print('/dev/net/tun:', Path('/dev/net/tun').exists())
+ print('No DHCP changes are performed. Use manual client gateway/DNS configuration.')
+ raise SystemExit(0)
+
+if os.geteuid() != 0 or not args.target_vm:
+ raise SystemExit('Mutations require root and --target-vm on the PVE service VM.')
+
+if args.action == 'rollback':
+ if not backup.exists():
+ raise SystemExit('No Clash Web network backup found.')
+ data = json.loads(backup.read_text())
+ # Also recover an interrupted apply that never finished installing the unit.
+ subprocess.run(['systemctl', 'disable', '--now', 'clashweb-network.service'], capture_output=True)
+ subprocess.run(['nft', 'delete', 'table', 'ip', 'clashweb_nat'], capture_output=True)
+ for path in (sysctl_file, nft_file, service_file):
+ path.unlink(missing_ok=True)
+ # ip_forward changes reset some IPv4 settings, so restore it first.
+ for key, value in data['sysctls'].items():
+ run(['sysctl', '-w', key + '=' + value])
+ run(['systemctl', 'daemon-reload'])
+ backup.unlink()
+ print('Own network rules removed; saved sysctls restored. Restore client gateway/DNS to the modem.')
+ raise SystemExit(0)
+
+if not args.interface or not re.fullmatch(r'[a-zA-Z0-9_.:-]{1,15}', args.interface):
+ raise SystemExit('Provide a valid --interface.')
+if not args.lan_cidr:
+ raise SystemExit('Provide --lan-cidr, for example 192.168.1.0/24.')
+network = ipaddress.ip_network(args.lan_cidr, strict=True)
+if network.version != 4 or not network.is_private or not 8 <= network.prefixlen <= 30:
+ raise SystemExit('Use a private IPv4 subnet with prefix 8–30.')
+addresses = json.loads(run(['ip', '-j', '-4', 'address', 'show', 'dev', args.interface]))
+if not any(ipaddress.ip_address(a['local']) in network for i in addresses for a in i['addr_info']):
+ raise SystemExit('The interface does not have an IPv4 address inside this subnet.')
+if backup.exists():
+ raise SystemExit('Network preparation already applied. Roll back before changing interface/subnet.')
+for path in (sysctl_file, nft_file, service_file):
+ if path.exists():
+ raise SystemExit(f'Refusing to overwrite existing {path}.')
+if subprocess.run(['nft', 'list', 'table', 'ip', 'clashweb_nat'], check=False, capture_output=True).returncode == 0:
+ parser.error('Table clashweb_nat already exists.')
+keys = ['net.ipv4.ip_forward', 'net.ipv4.conf.all.send_redirects', 'net.ipv4.conf.default.send_redirects', f'net.ipv4.conf.{args.interface}.send_redirects', 'net.ipv4.conf.all.rp_filter', f'net.ipv4.conf.{args.interface}.rp_filter']
+backup.parent.mkdir(parents=True, exist_ok=True)
+backup.write_text(json.dumps({'sysctls': {key: sysctl_get(key) for key in keys}}, indent=2))
+os.chmod(backup, 0o600)
+values = {key: ('1' if key.endswith('ip_forward') else '2' if key.endswith('rp_filter') else '0') for key in keys}
+sysctl_file.write_text('\n'.join(f'{key}={value}' for key, value in values.items()) + '\n')
+nft_file.parent.mkdir(parents=True, exist_ok=True)
+nft_file.write_text(f'''table ip clashweb_nat {{
+ chain postrouting {{
+ type nat hook postrouting priority srcnat; policy accept;
+ ip saddr {network} ip daddr != {network} oifname "{args.interface}" masquerade
+ }}
+}}
+''')
+run(['nft', '-c', '-f', str(nft_file)])
+service_file.write_text('''[Unit]
+Description=Clash Web single-arm gateway NAT
+After=network-online.target
+Before=clashweb-mihomo.service
+Wants=network-online.target
+[Service]
+Type=oneshot
+RemainAfterExit=yes
+ExecStart=/usr/sbin/nft -f /etc/clashweb/network.nft
+ExecStop=/usr/sbin/nft delete table ip clashweb_nat
+[Install]
+WantedBy=multi-user.target
+''')
+for key, value in values.items():
+ run(['sysctl', '-w', key + '=' + value])
+run(['systemctl', 'daemon-reload'])
+run(['systemctl', 'enable', '--now', 'clashweb-network.service'])
+print('VM forwarding and own NAT table configured. TUN is still controlled by the UI.')
+print('Do not enable DHCP on this VM. Configure a test client manually.')
diff --git a/scripts/package.py b/scripts/package.py
new file mode 100644
index 0000000..d2a7690
--- /dev/null
+++ b/scripts/package.py
@@ -0,0 +1,119 @@
+#!/usr/bin/env python3
+"""Package already-built Linux amd64 resources; never include runtime state."""
+import argparse
+import datetime
+import hashlib
+import json
+import subprocess
+import tarfile
+import tempfile
+from pathlib import Path
+import shutil
+
+root = Path(__file__).resolve().parent.parent
+parser = argparse.ArgumentParser(description=__doc__)
+parser.add_argument('--output-dir', type=Path, default=root)
+args = parser.parse_args()
+args.output_dir.mkdir(parents=True, exist_ok=True)
+(root / '.cache').mkdir(exist_ok=True)
+
+def digest(path):
+ return hashlib.sha256(path.read_bytes()).hexdigest()
+
+for name in ('clashweb', 'mihomo'):
+ header = (root / 'bin' / name).read_bytes()[:64]
+ if header[:5] != b'\x7fELF\x02' or int.from_bytes(header[18:20], 'little') != 62:
+ raise SystemExit(f'bin/{name} must be a Linux amd64 ELF binary')
+
+update_readme = '''# Clash Web 管理程序更新(Linux amd64)
+
+本包更新 Clash Web 管理服务及其嵌入的 Web UI。
+无需更新 Mihomo 或规则,也不修改数据库、管理员密码、订阅和 VM 网络设置。
+
+在已部署的目标 VM 上解压并进入本目录,然后执行:
+
+```bash
+sha256sum -c SHA256SUMS
+sudo install -m 0755 -o root -g root /usr/local/bin/clashweb /usr/local/bin/clashweb.previous
+sudo install -m 0755 -o root -g root bin/clashweb /usr/local/bin/clashweb.next
+sudo mv /usr/local/bin/clashweb.next /usr/local/bin/clashweb
+sudo systemctl restart clashweb
+sudo systemctl status clashweb --no-pager
+```
+
+重启后刷新浏览器,确认管理服务正常。独立 Mihomo 内核继续运行;若页面仍显示旧内容,可用 Ctrl+Shift+R 刷新。
+
+需要回退时执行:
+
+```bash
+sudo install -m 0755 -o root -g root /usr/local/bin/clashweb.previous /usr/local/bin/clashweb.rollback
+sudo mv /usr/local/bin/clashweb.rollback /usr/local/bin/clashweb
+sudo systemctl restart clashweb
+```
+
+测试只检查 HTTPS 主页,不承诺流媒体解锁、AI 账号或镜像拉取。
+完整说明见 docs/upgrading.md。
+'''
+
+deploy_readme = '''# Clash Web Linux amd64 部署包
+
+包含静态 Go 管理程序与 Web UI、Mihomo v1.19.32、已有大陆规则、安装脚本和文档。
+在正式 PVE VM 上部署;不包含任何订阅、管理员密码或开发机运行数据。
+
+解压后进入目录,先运行 `sha256sum -c SHA256SUMS`。
+首次安装见 docs/deployment.md;Ubuntu/Debian 需要 sudo、python3、iproute2、nftables、ca-certificates。
+运行 `sudo scripts/install.sh --target-vm --bind VM_IP:8080`。
+VM 固定 IP、转发、防火墙与客户端网关/DNS 按部署文档配置,VM 不运行 DHCP。
+
+已经部署的 VM 推荐使用 clashweb-update-linux-amd64.tar.gz,只替换管理程序。
+不要用包内空数据覆盖 /var/lib/clashweb 或 /var/lib/clashweb-core。
+'''
+
+scripts = ['install.sh', 'clashweb-control', 'network-vm.py', 'fetch-mihomo.py',
+ 'fetch-rules.py', 'update-rules.sh', 'diagnose-subscription.py']
+with tempfile.TemporaryDirectory(prefix='package-', dir=root / '.cache') as temp:
+ for name, full in [('clashweb-linux-amd64', True), ('clashweb-update-linux-amd64', False)]:
+ stage = Path(temp) / name
+ files = ['bin/clashweb']
+ if full:
+ files += ['bin/mihomo', 'data/mihomo/rules/cn-domain.mrs', 'data/mihomo/rules/cn-ip.mrs']
+ files += ['scripts/' + script for script in scripts]
+ files += [p.relative_to(root).as_posix() for p in (root / 'deploy').glob('*.service')]
+ files += ['README.md']
+ files += ['docs/' + document for document in ['api.md', 'development.md', 'deployment.md', 'upgrading.md', 'third-party.md']]
+ else:
+ files += ['docs/upgrading.md']
+ for relative in files:
+ destination = stage / relative
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(root / relative, destination)
+ (stage / 'README-DEPLOY.md' if full else stage / 'README-UPDATE.md').write_text(deploy_readme if full else update_readme)
+ (stage / 'BUILD-INFO.json').write_text(json.dumps({
+ 'platform': 'linux/amd64', 'goamd64': 'v1', 'cgoEnabled': False,
+ 'createdAt': datetime.datetime.now(datetime.timezone.utc).isoformat(),
+ 'clashwebSha256': digest(root / 'bin/clashweb'),
+ 'mihomoVersion': 'v1.19.32' if full else None,
+ }, indent=2) + '\n')
+ payload = sorted(p for p in stage.rglob('*') if p.is_file())
+ (stage / 'SHA256SUMS').write_text(''.join(f'{digest(p)} {p.relative_to(stage).as_posix()}\n' for p in payload))
+ archive = args.output_dir / f'{name}.tar.gz'
+ with tarfile.open(archive, 'w:gz') as tar:
+ for p in sorted(stage.rglob('*')):
+ if p.is_file():
+ tar.add(p, arcname=name + '/' + p.relative_to(stage).as_posix(), recursive=False)
+ archive.with_suffix(archive.suffix + '.sha256').write_text(f'{digest(archive)} {archive.name}\n')
+ # Verify the actual archive, not just its staging directory.
+ with tarfile.open(archive) as tar:
+ extracted = Path(temp) / (name + '-verify')
+ tar.extractall(extracted, filter='data')
+ checked = subprocess.run(['sha256sum', '-c', 'SHA256SUMS'], cwd=extracted / name, capture_output=True)
+ if checked.returncode:
+ raise SystemExit('Package payload checksum failed')
+ if full:
+ clean = Path(temp) / 'clean-init'
+ initialized = subprocess.run([str(extracted / name / 'bin/clashweb'), '--init',
+ '--data', str(clean / 'app'), '--core-dir', str(extracted / name / 'data/mihomo'),
+ '--mihomo', str(extracted / name / 'bin/mihomo')], capture_output=True)
+ if initialized.returncode:
+ raise SystemExit('Clean package initialization/core validation failed')
+ print(f'{archive}: {archive.stat().st_size:,} bytes, payload checksums verified')
diff --git a/scripts/test-e2e.py b/scripts/test-e2e.py
new file mode 100644
index 0000000..155c63b
--- /dev/null
+++ b/scripts/test-e2e.py
@@ -0,0 +1,102 @@
+#!/usr/bin/env python3
+"""Run the browser against an isolated real backend/core and a local subscription fixture."""
+import contextlib
+import http.server
+import json
+import os
+import shutil
+import socket
+import sqlite3
+import subprocess
+import tempfile
+import threading
+import time
+import urllib.request
+from pathlib import Path
+
+root = Path(__file__).resolve().parent.parent
+for port in (18080, 9090, 7890, 1053):
+ with socket.socket() as probe:
+ if probe.connect_ex(('127.0.0.1', port)) == 0:
+ raise SystemExit(f'Port {port} is occupied. E2E aborted to avoid changing another service.')
+
+class Fixture(http.server.BaseHTTPRequestHandler):
+ protocol_version = 'HTTP/1.1'
+
+ def do_CONNECT(self):
+ self.send_response(200, 'Connection Established')
+ self.end_headers()
+ self.close_connection = False
+ self.handle_one_request()
+
+ def do_HEAD(self):
+ # Mihomo's unified-delay check repeats HEAD on the established tunnel.
+ # Supply a framed keep-alive response, rather than the default HTTP 501.
+ time.sleep(0.03)
+ self.send_response(200)
+ self.send_header('Content-Length', '0')
+ self.end_headers()
+
+ def do_GET(self):
+ if self.path == '/subscription':
+ data = 'proxies:\n'
+ for name in ['香港 · 测试节点', '日本 · 测试节点', '新加坡 · 测试节点']:
+ data += f' - name: {name}\n type: http\n server: 127.0.0.1\n port: {self.server.server_port}\n'
+ body = data.encode()
+ else:
+ # Keep successful local health checks above Mihomo's integer-ms zero.
+ time.sleep(0.03)
+ body = b'local-e2e-proxy'
+ self.send_response(200)
+ self.send_header('Content-Length', str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+ def log_message(self, *args):
+ pass
+
+fixture = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Fixture)
+threading.Thread(target=fixture.serve_forever, daemon=True).start()
+
+def wait_url(url, timeout=20):
+ until = time.monotonic() + timeout
+ while time.monotonic() < until:
+ try:
+ with urllib.request.urlopen(url, timeout=1) as response:
+ return response.status
+ except Exception:
+ time.sleep(0.1)
+ raise RuntimeError(f'Service failed to start: {url}')
+
+root.joinpath('.cache').mkdir(exist_ok=True)
+with tempfile.TemporaryDirectory(prefix='e2e-', dir=root / '.cache') as temporary:
+ work = Path(temporary)
+ core = work / 'mihomo'
+ shutil.copytree(root / 'data/mihomo/rules', core / 'rules')
+ command = [str(root / 'bin/clashweb'), '--data', str(work / 'app'), '--core-dir', str(core), '--mihomo', str(root / 'bin/mihomo')]
+ initialized = subprocess.run(command + ['--init'], text=True, capture_output=True)
+ if initialized.returncode:
+ raise SystemExit('Initialization failed: ' + initialized.stderr)
+ with contextlib.closing(sqlite3.connect(work / 'app/clashweb.sqlite')) as db:
+ token = db.execute("SELECT value FROM kv WHERE key='setup_token'").fetchone()[0]
+ processes = []
+ try:
+ with open(work / 'core.log', 'w') as log:
+ processes.append(subprocess.Popen([str(root / 'bin/mihomo'), '-d', str(core), '-f', str(core / 'config.yaml')], stdout=log, stderr=log))
+ with open(work / 'app.log', 'w') as log:
+ processes.append(subprocess.Popen(command + ['--listen', '127.0.0.1:18080'], stdout=log, stderr=log))
+ wait_url('http://127.0.0.1:18080/api/v1/auth/status')
+ environment = dict(os.environ, CW_E2E_URL='http://127.0.0.1:18080', CW_E2E_SETUP_TOKEN=token, CW_E2E_SUBSCRIPTION_URL=f'http://127.0.0.1:{fixture.server_port}/subscription')
+ result = subprocess.run(['npm', 'run', 'test:e2e'], cwd=root / 'web', env=environment)
+ if result.returncode:
+ # This harness contains only credential-free local proxy fixtures.
+ print('Local fixture core log:', (work / 'core.log').read_text()[-8000:])
+ raise SystemExit(result.returncode)
+ finally:
+ for process in reversed(processes):
+ process.terminate()
+ try:
+ process.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ process.wait()
+ fixture.shutdown()
diff --git a/scripts/update-rules.sh b/scripts/update-rules.sh
new file mode 100755
index 0000000..118cae5
--- /dev/null
+++ b/scripts/update-rules.sh
@@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+set -euo pipefail
+[[ $EUID -eq 0 ]] || { echo 'Run on the service VM as root.' >&2; exit 1; }
+task_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
+task_staging=$(mktemp -d)
+trap 'rm -rf "$task_staging"' EXIT
+python3 "$task_root/scripts/fetch-rules.py" --output "$task_staging/new"
+mkdir -p "$task_staging/old"
+cp -a /var/lib/clashweb-core/rules/. "$task_staging/old/"
+install -m 0640 -o clashweb -g clashweb-core "$task_staging/new/"*.mrs /var/lib/clashweb-core/rules/
+if ! sudo -u clashweb-mihomo /usr/local/bin/mihomo -t -d /var/lib/clashweb-core -f /var/lib/clashweb-core/config.yaml; then
+ cp -a "$task_staging/old/." /var/lib/clashweb-core/rules/
+ echo 'Validation failed; previous rules restored.' >&2
+ exit 1
+fi
+if ! systemctl restart clashweb-mihomo.service || ! systemctl is-active --quiet clashweb-mihomo.service; then
+ cp -a "$task_staging/old/." /var/lib/clashweb-core/rules/
+ systemctl restart clashweb-mihomo.service
+ echo 'Reload failed; previous rules restored.' >&2
+ exit 1
+fi
+echo 'Rule resources updated. Select a node in the UI if needed.'
diff --git a/web/assets.go b/web/assets.go
new file mode 100644
index 0000000..94a78a5
--- /dev/null
+++ b/web/assets.go
@@ -0,0 +1,36 @@
+package web
+
+import (
+ "embed"
+ "io/fs"
+ "net/http"
+ "strings"
+)
+
+//go:embed all:dist
+var assets embed.FS
+
+func Handler() http.Handler {
+ root, _ := fs.Sub(assets, "dist")
+ files := http.FileServer(http.FS(root))
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ path := strings.TrimPrefix(r.URL.Path, "/")
+ if path == "" {
+ path = "index.html"
+ }
+ if _, err := fs.Stat(root, path); err != nil {
+ if strings.Contains(path, ".") {
+ http.NotFound(w, r)
+ return
+ }
+ r = r.Clone(r.Context())
+ r.URL.Path = "/"
+ }
+ if strings.HasPrefix(path, "assets/") {
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+ } else {
+ w.Header().Set("Cache-Control", "no-cache")
+ }
+ files.ServeHTTP(w, r)
+ })
+}
diff --git a/web/e2e/workspace.spec.ts b/web/e2e/workspace.spec.ts
new file mode 100644
index 0000000..ebd5d24
--- /dev/null
+++ b/web/e2e/workspace.spec.ts
@@ -0,0 +1,147 @@
+import { test, expect } from '@playwright/test'
+
+test('real workspace: auth, subscription, selection, rules, mobile and backup', async ({ page }) => {
+ const errors: string[] = []
+ page.on('pageerror', error => errors.push(error.message))
+ await page.goto('/')
+ await expect(page.getByRole('heading', { name: '从这里,连接世界。' })).toBeVisible()
+ await page.getByPlaceholder('从服务启动日志复制令牌').fill(process.env.CW_E2E_SETUP_TOKEN!)
+ await page.getByPlaceholder('至少 12 字节,最多 72 字节').fill('e2e-test-password-long')
+ await page.getByRole('button', { name: '创建工作空间' }).click()
+ await expect(page.getByRole('heading', { name: '网络总览' })).toBeVisible()
+ await expect(page.getByText('规则代理已启用', { exact: true })).toBeVisible()
+ const auth = await (await page.request.get('/api/v1/auth/status')).json()
+ const settings = await (await page.request.get('/api/v1/settings')).json()
+ // The local proxy answers this documentation address; no Internet request.
+ // A health target equal to the proxy endpoint is rejected as a routing loop.
+ const settingsResponse = await page.request.put('/api/v1/settings', { headers: { 'X-CSRF-Token': auth.csrf }, data: { ...settings, testURL: 'http://198.51.100.42/health' } })
+ expect(settingsResponse.ok()).toBe(true)
+ await page.getByRole('link', { name: '订阅管理', exact: true }).click()
+ await page.getByRole('button', { name: '添加订阅', exact: true }).click()
+ await page.getByPlaceholder('例如:我的主力订阅').fill('本地测试订阅')
+ await page.getByPlaceholder('https://example.com/subscribe?token=…').fill(process.env.CW_E2E_SUBSCRIPTION_URL!)
+ await page.getByRole('button', { name: '添加并更新' }).click()
+ await expect(page.getByRole('heading', { name: '本地测试订阅', exact: true })).toBeVisible()
+ await expect(page.locator('.subscription-node-count')).toContainText('3')
+ await page.getByRole('link', { name: '代理节点', exact: false }).first().click()
+ await expect(page.getByRole('heading', { name: '香港 · 测试节点' })).toBeVisible()
+ await page.getByPlaceholder('搜索节点、协议或订阅').fill('香港')
+ await expect(page.locator('.node-card')).toHaveCount(1)
+ await page.getByRole('button', { name: '选择节点', exact: true }).click()
+ await expect(page.getByRole('button', { name: '当前节点', exact: true })).toBeVisible()
+ await page.getByPlaceholder('搜索节点、协议或订阅').clear()
+ await page.getByRole('button', { name: '测试延迟', exact: true }).click()
+ await expect(page.getByText('3 个最近测试可用', { exact: false })).toBeVisible()
+ // Test progressive results without contacting third-party services in browser CI.
+ let connectivityRequests: string[] = []
+ await page.route('**/api/v1/system/connectivity/test', async route => {
+ const id = route.request().postDataJSON().siteId
+ connectivityRequests.push(id)
+ await new Promise(resolve => setTimeout(resolve, 80))
+ const status = ({ claude: 'challenge', netflix: 'restricted', grok: 'failed', github: 'http_error' } as Record)[id] || 'reachable'
+ await route.fulfill({ json: { siteId: id, status, detail: status === 'reachable' ? 'HTTPS 主页响应正常' : '测试夹具响应', httpStatus: status === 'failed' ? 0 : status === 'reachable' ? 200 : status === 'http_error' ? 503 : 403, durationMS: 640, tlsVerified: status !== 'failed', checkedAt: new Date().toISOString() } })
+ })
+ await page.getByRole('link', { name: '连通性测试', exact: true }).click()
+ await expect(page.locator('.connectivity-card')).toHaveCount(9)
+ await expect(page.locator('.connectivity-grid')).toHaveCount(1)
+ await expect(page.locator('.connectivity-card-heading h3')).toHaveText(['GitHub', 'Docker Hub', 'Apple', 'Google', 'Netflix', 'YouTube', 'ChatGPT', 'Claude', 'Grok'])
+ await expect(page.locator('.connectivity-state.reachable')).toHaveCount(0)
+ await page.getByRole('button', { name: '测试 Apple', exact: true }).click()
+ await expect(page.locator('[data-site="apple"] .connectivity-state')).toContainText('可访问')
+ expect(connectivityRequests).toEqual(['apple'])
+ await page.getByRole('button', { name: '一键测试', exact: true }).click()
+ await expect(page.getByText('本次完成 9 / 9', { exact: true })).toBeVisible()
+ expect(connectivityRequests.length).toBe(10)
+ expect(connectivityRequests.slice(1)).toEqual(['github', 'dockerhub', 'apple', 'google', 'netflix', 'youtube', 'chatgpt', 'claude', 'grok'])
+ await expect(page.locator('.connectivity-state.reachable')).toHaveCount(5)
+ await expect(page.locator('[data-site="claude"] .connectivity-state')).toContainText('需要验证')
+ await expect(page.locator('[data-site="netflix"] .connectivity-state')).toContainText('访问受限')
+ await expect(page.locator('[data-site="grok"] .connectivity-state')).toContainText('连接失败')
+ await expect(page.locator('[data-site="github"]')).toContainText('HTTP 503')
+ await page.setViewportSize({ width: 1366, height: 900 })
+ const lastResult = await page.locator('.connectivity-card').last().boundingBox()
+ expect(lastResult).not.toBeNull()
+ expect(lastResult!.y + lastResult!.height).toBeLessThanOrEqual(900)
+ for (const pair of [['github', 'dockerhub'], ['netflix', 'youtube'], ['chatgpt', 'claude'], ['claude', 'grok']]) {
+ const left = await page.locator(`[data-site="${pair[0]}"]`).boundingBox()
+ const right = await page.locator(`[data-site="${pair[1]}"]`).boundingBox()
+ expect(Math.abs(left!.y - right!.y)).toBeLessThan(1)
+ }
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
+ await page.setViewportSize({ width: 1440, height: 1080 })
+ await page.screenshot({ path: '../.cache/screenshots/connectivity-desktop.png', fullPage: true, animations: 'disabled', style: '.n-message-container { visibility: hidden; }' })
+ await page.getByRole('button', { name: '切换深色主题' }).click()
+ await page.screenshot({ path: '../.cache/screenshots/connectivity-dark.png', fullPage: true, animations: 'disabled', style: '.n-message-container { visibility: hidden; }' })
+ await page.getByRole('button', { name: '切换浅色主题' }).click()
+ await page.setViewportSize({ width: 390, height: 844 })
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
+ await page.screenshot({ path: '../.cache/screenshots/connectivity-mobile.png', fullPage: true, animations: 'disabled', style: '.n-message-container { visibility: hidden; }' })
+ await page.setViewportSize({ width: 1440, height: 1080 })
+ await page.unroute('**/api/v1/system/connectivity/test')
+ connectivityRequests = []
+ await page.route('**/api/v1/system/connectivity/test', async route => {
+ connectivityRequests.push(route.request().postDataJSON().siteId)
+ await new Promise(resolve => setTimeout(resolve, 1000))
+ await route.fulfill({ json: { siteId: route.request().postDataJSON().siteId, status: 'reachable', detail: 'ok', httpStatus: 200, durationMS: 1000, tlsVerified: true, checkedAt: new Date().toISOString() } }).catch(() => {})
+ })
+ await page.getByRole('button', { name: '一键测试', exact: true }).click()
+ await expect(page.locator('.connectivity-state.testing')).toHaveCount(3)
+ await page.getByRole('button', { name: '取消测试', exact: true }).click()
+ await expect(page.locator('.connectivity-state.cancelled')).toHaveCount(9)
+ await expect(page.getByRole('button', { name: '一键测试', exact: true })).toBeEnabled()
+ expect(connectivityRequests.length).toBe(3)
+ await page.unroute('**/api/v1/system/connectivity/test')
+ await page.getByRole('link', { name: '分流规则', exact: true }).click()
+ await page.getByRole('button', { name: '添加规则' }).click()
+ await page.getByPlaceholder('例如 example.com').fill('custom.example.com')
+ await page.getByRole('button', { name: '保存并应用' }).click()
+ await expect(page.locator('.data-table')).toContainText('custom.example.com')
+ await page.getByRole('link', { name: '连接与日志', exact: true }).click()
+ await page.getByText('内核日志', { exact: true }).click()
+ await expect(page.locator('.log-console')).toBeVisible()
+ await page.getByRole('link', { name: '系统设置', exact: true }).click()
+ await expect(page.getByText('当前光猫 DHCP 无法关闭。', { exact: false })).toBeVisible()
+ await page.route('**/api/v1/system/updates', route => route.fulfill({ json: {
+ currentVersion: 'v1.19.32', latestVersion: 'v1.19.33', updateAvailable: true,
+ checkedAt: new Date().toISOString(), publishedAt: new Date().toISOString(),
+ releaseURL: 'https://github.com/MetaCubeX/mihomo/releases/tag/v1.19.33',
+ } }))
+ await page.getByRole('button', { name: '检查更新', exact: true }).click()
+ await expect(page.getByText('发现新版本,可查看发布说明后手动升级。', { exact: true })).toBeVisible()
+ await expect(page.getByRole('link', { name: '查看官方发布说明' })).toHaveAttribute('href', 'https://github.com/MetaCubeX/mihomo/releases/tag/v1.19.33')
+ await page.unroute('**/api/v1/system/updates')
+ await page.route('**/api/v1/system/updates', route => route.fulfill({ status: 502, json: { error: 'GitHub 更新检查暂时受限,请稍后重试' } }))
+ await page.getByRole('button', { name: '检查更新', exact: true }).click()
+ await expect(page.getByText('GitHub 更新检查暂时受限,请稍后重试', { exact: true })).toBeVisible()
+ await page.getByText('如何升级软件与内核', { exact: true }).click()
+ await expect(page.getByText('完整操作与恢复步骤见项目 docs/upgrading.md。', { exact: true })).toBeVisible()
+ const downloadPromise = page.waitForEvent('download')
+ await page.getByRole('button', { name: '导出备份' }).click()
+ const download = await downloadPromise
+ expect(download.suggestedFilename()).toMatch(/^clashweb-backup/)
+ await page.getByRole('button', { name: '切换深色主题' }).click()
+ await expect(page.locator('.application')).toHaveClass(/dark/)
+ await page.getByRole('button', { name: '切换浅色主题' }).click()
+ await page.getByRole('link', { name: '网络总览', exact: true }).click()
+ await page.setViewportSize({ width: 1440, height: 1080 })
+ await expect(page.getByRole('img', { name: 'Clash Web 网络猫' })).toBeVisible()
+ await page.screenshot({ path: '../.cache/screenshots/dashboard-desktop.png', fullPage: true, animations: 'disabled', style: '.n-message-container { visibility: hidden; }' })
+ await page.getByRole('button', { name: '切换深色主题' }).click()
+ await page.screenshot({ path: '../.cache/screenshots/dashboard-dark.png', fullPage: true, animations: 'disabled', style: '.n-message-container { visibility: hidden; }' })
+ await page.getByRole('button', { name: '切换浅色主题' }).click()
+ await page.setViewportSize({ width: 390, height: 844 })
+ await page.getByRole('button', { name: '打开导航' }).click()
+ await page.getByRole('link', { name: '代理节点', exact: false }).first().click()
+ await expect(page.getByRole('heading', { name: '代理节点', exact: true })).toBeVisible()
+ await expect(page.locator('.node-card')).toHaveCount(3)
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
+ await page.screenshot({ path: '../.cache/screenshots/nodes-mobile.png', fullPage: true, animations: 'disabled', style: '.n-message-container { visibility: hidden; }' })
+ await page.getByRole('button', { name: '打开导航' }).click()
+ await page.getByRole('button', { name: '退出登录' }).click()
+ await expect(page.getByRole('heading', { name: '欢迎回来。' })).toBeVisible()
+ await page.getByPlaceholder('输入你的密码').fill('e2e-test-password-long')
+ await page.getByRole('button', { name: '进入工作空间' }).click()
+ await expect(page.getByRole('heading', { name: '代理节点', exact: true })).toBeVisible()
+ await expect(page.getByRole('button', { name: '当前节点', exact: true })).toBeVisible()
+ expect(errors).toEqual([])
+})
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..9d3dcab
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+ Clash Web · 内网代理管理
+
+
+
diff --git a/web/package-lock.json b/web/package-lock.json
new file mode 100644
index 0000000..a793fbc
--- /dev/null
+++ b/web/package-lock.json
@@ -0,0 +1,2843 @@
+{
+ "name": "clash-web-ui",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "clash-web-ui",
+ "version": "0.1.0",
+ "dependencies": {
+ "@lucide/vue": "^1.51.0",
+ "echarts": "^6.0.0",
+ "naive-ui": "^2.42.0",
+ "pinia": "^3.0.3",
+ "vue": "^3.5.22",
+ "vue-router": "^4.6.3"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.56.0",
+ "@types/node": "^24.7.0",
+ "@vitejs/plugin-vue": "^6.0.1",
+ "typescript": "^5.9.3",
+ "vite": "^7.1.9",
+ "vitest": "^4.1.11",
+ "vue-tsc": "^3.1.1"
+ }
+ },
+ "node_modules/@acemir/cssom": {
+ "version": "0.9.31",
+ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz",
+ "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz",
+ "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@csstools/css-calc": "^3.0.0",
+ "@csstools/css-color-parser": "^4.0.1",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0",
+ "lru-cache": "^11.2.5"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "6.8.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz",
+ "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@asamuzakjp/nwsapi": "^2.3.9",
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.1.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.2.6"
+ }
+ },
+ "node_modules/@asamuzakjp/nwsapi": {
+ "version": "2.3.9",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
+ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.9",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz",
+ "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.8"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@css-render/plugin-bem": {
+ "version": "0.15.14",
+ "resolved": "https://registry.npmjs.org/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz",
+ "integrity": "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "css-render": "~0.15.14"
+ }
+ },
+ "node_modules/@css-render/vue3-ssr": {
+ "version": "0.15.14",
+ "resolved": "https://registry.npmjs.org/@css-render/vue3-ssr/-/vue3-ssr-0.15.14.tgz",
+ "integrity": "sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==",
+ "license": "MIT",
+ "peerDependencies": {
+ "vue": "^3.0.11"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz",
+ "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "optional": true,
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz",
+ "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.2",
+ "@csstools/css-tokenizer": "^4.0.2"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.2.6",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz",
+ "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@csstools/color-helpers": "^6.1.2",
+ "@csstools/css-calc": "^3.4.3"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.2",
+ "@csstools/css-tokenizer": "^4.0.2"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz",
+ "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "optional": true,
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@emotion/hash": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz",
+ "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==",
+ "license": "MIT"
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
+ "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
+ "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
+ "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
+ "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
+ "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
+ "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
+ "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
+ "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
+ "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
+ "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
+ "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
+ "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
+ "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
+ "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
+ "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
+ "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
+ "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
+ "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
+ "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
+ "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
+ "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@exodus/bytes": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz",
+ "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "license": "MIT"
+ },
+ "node_modules/@juggle/resize-observer": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
+ "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@lucide/vue": {
+ "version": "1.51.0",
+ "resolved": "https://registry.npmjs.org/@lucide/vue/-/vue-1.51.0.tgz",
+ "integrity": "sha512-zMkTAMP8OsPESz/GDedsZfr3VUF2IiHlj25mBZG6Pc8A8J8BdygkE8k1HhRFXEUNzytp5DqxiBQNm3RG/U2bVg==",
+ "license": "ISC",
+ "peerDependencies": {
+ "vue": ">=3.0.1"
+ }
+ },
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
+ "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.63.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.0.tgz",
+ "integrity": "sha512-FLLpX941CD/7aBvX3GqLGBM9OvY5PGXTryPJkG5r5/OuBpZpV5k45Uo3RxAvqd35tsyc3hH1bhtz921IKzoIzg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.0.tgz",
+ "integrity": "sha512-iI32XM/jnDYJsvx5ai/za/7gWO9XoSeFFtU06/ZCaRgyilfJOjaopD3nOpk2WrTCGOyeDt4hleUiGOAnd5tHNg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.0.tgz",
+ "integrity": "sha512-dRBZlbt2sEtF8Mn4MODThze9fIPdDoOhVSlXMAKy5dQbOYK9pOOoPY4Fat3OTtr0+8zbJo5TTZwlsfxY/kDNJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.0.tgz",
+ "integrity": "sha512-Y3whrJOMucfkhgNkvcM93M6j8C53iEAMmPMYD99C6G1kUy4ELigLWk9ar2/4aCBSRgsql0zdlOemLgW2m5GZgQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.0.tgz",
+ "integrity": "sha512-S6S1GEzPjFF6IONYJX3AKt/799+8RgBgCcwRnUo+Ep1IvBxUbw5M51iU0TDdK2NWWVnSvCQ8yi6Em0fGASMOng==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.0.tgz",
+ "integrity": "sha512-Ed1DPoFENkXj4IGzSB12SesV40iP6LxCCrbTIkSGIQBpOOxpBM+0kj62lmaeIHfMWTDoddqTOtqc7s8LYfn+Pw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.0.tgz",
+ "integrity": "sha512-J/6xhutJPtFyIkmHdf4olOdHHKH5kOJNumojdWn+Elv5ICEYTVOa362Vw27TxsK0BLzb1le3EDlO3UbvFK1lnQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.0.tgz",
+ "integrity": "sha512-G4L/Nnx59d2LvDoV/P4S5lZ7oVLT6hodOHMacqjiq2LL2gts86KbkEBTYL53+fBt0odhf7XPIbIATGoYiJctxg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.0.tgz",
+ "integrity": "sha512-zF16pvJDEUksGYRbfnXne7pdklAyj2XDmJeYKgP93oq0cUyX9Y8O7lUo1R+IY1GagP/YBvCEDCe7eEcAKpw//g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.0.tgz",
+ "integrity": "sha512-nIggWn8n+w12+R5lsV+PYYoUY8vuObxPAQqJI7WES+9v0qspYuAHoZMfYM0yLxlipasT5BY9ht5W8t5c/rSCkg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.0.tgz",
+ "integrity": "sha512-WLOTiHPzszP0dxIMsHpFeDSOSB5I49Mj8K4+yKE3H1X/D0JRG5XZ5jrctJLMjnUc/LUzaKiwq6EHWNJ8pup8PA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.0.tgz",
+ "integrity": "sha512-35RK9aRhh7g7Pmeu5gQ01aCvrhXzj9LoBE6xtUcEH1ns+nyTaSzxSpADmBQINu4QiTDGvmQYwo1j5hlg1rRXFw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.0.tgz",
+ "integrity": "sha512-RqTfnpoTjuWfPAmqK5p2edt2AMCDJWm6oTn7sKfmyx+6tRga0ip05SJ977o6udTcIiDeF/CJMUfkaN4gtWt6Aw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.0.tgz",
+ "integrity": "sha512-RQjZPigjxFvFZDijG6rjlSXxhRaTf9zLYVpGK+lVcga64oMqP1GbgVqCb46eHTTceSIgp89Ja0y5aPe3GFBYHg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.0.tgz",
+ "integrity": "sha512-tjL58mgQcTRnDRpeky+Sth1MKQeYi2Ct6r3Luq9E5aTSjnM/i/JbozqZkKqp9thM5+oDSZxo+05f9kdQedfTGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.0.tgz",
+ "integrity": "sha512-ictoRyVaoZ8C67myHef+8hhCa6IplXJJ/xUCkVCxziV4zxMZh+jvhkhGsPBLqj1S72fnqD0QH5zL4lcaa4X7DQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.0.tgz",
+ "integrity": "sha512-8aVvZ2hfr9RLFzBQkQNmYeHLDja8fLeQAmLt757zdKN/B+8L809KlLJKctQjS22wZotptV8G6o7fVPuAs8S1tQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.0.tgz",
+ "integrity": "sha512-2dEF8GAcDKwshUfydD+GhosppNyBzhVUHkdFF3CXd3FpOkzj/RZzF5aoEtzklujklT5qFVRU2GK/cFeLaTNKVg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.0.tgz",
+ "integrity": "sha512-3XHTvoo2hKkh0MiOfS7SkhHjMaaWF7P4F3drocZZaho/Z8w7h/jpnuBHm91BOQLfvrKHs1yR+zir4E8RI3XzTQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.0.tgz",
+ "integrity": "sha512-HKtkzodL4a3KFWDXDoO61cRynAWTR3LnAE0oGtfG9lcNFpNuVYUFOByxDgNqEvic9rvaQbAj+SFwTEWDptRaIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.0.tgz",
+ "integrity": "sha512-JBsxqiY16uyyCujtoC3s7zL+SrYyNElptD7K4V+KUoJ2V8/XG3hPWpheK+SuPwNh1cikJvIZmIdsrYXvD6PLJw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.0.tgz",
+ "integrity": "sha512-S5Qrijh37qhnUYQ0ay8WN3LuWeKjow7wTUyCOPMZtnXiBXYXdsZETI6ZLfYnQAKovltckPLBFdNeD61P2rJc7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.0.tgz",
+ "integrity": "sha512-zbZ6iMmLFEsCtTdc2/y1F/cDFVNNCPF7Ae8PCT/wdPE//hGwYp6r+MyCLMFX+kOeIe09Pz6ASfnnfpSTN1FJQQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.0.tgz",
+ "integrity": "sha512-L6Hw69oNpuYaOr1zCG30v0/DqKYCSRwodPGwIcVZ/6f8SOzq9rxtL/xNVI5MO4wjli+Ko0ErK9uHJL3mNPt8wQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.0.tgz",
+ "integrity": "sha512-nl+CTWIgOGszQmUz/kanfgrfFZ4qiwxKIfT/ryl3N3Ewk5tqizmYtNXiqb7Bu/fDtNgu1kyxdFiAUJNur3iysg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/lodash": {
+ "version": "4.17.25",
+ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz",
+ "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/lodash-es": {
+ "version": "4.17.12",
+ "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
+ "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/lodash": "*"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "24.19.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.1.tgz",
+ "integrity": "sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "undici-types": ">=7.24.0 <7.24.7"
+ }
+ },
+ "node_modules/@vitejs/plugin-vue": {
+ "version": "6.0.9",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.9.tgz",
+ "integrity": "sha512-rD/MORlhaZMlXWW0rEn4FB4wMinWC0z/D7Ye160S5+Rs1mC8ZDAdcDp4SjKfUhG5t8q1ktcPVw4xuTkPRlzrSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
+ "vue": "^3.2.25"
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
+ "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.11",
+ "@vitest/utils": "4.1.11",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
+ "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.11",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/mocker/node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
+ "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
+ "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.11",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
+ "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.11",
+ "@vitest/utils": "4.1.11",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
+ "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
+ "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.11",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@volar/language-core": {
+ "version": "2.4.28",
+ "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz",
+ "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/source-map": "2.4.28"
+ }
+ },
+ "node_modules/@volar/source-map": {
+ "version": "2.4.28",
+ "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz",
+ "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@volar/typescript": {
+ "version": "2.4.28",
+ "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz",
+ "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-core": "2.4.28",
+ "path-browserify": "^1.0.1",
+ "vscode-uri": "^3.0.8"
+ }
+ },
+ "node_modules/@vue/compiler-core": {
+ "version": "3.5.43",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.43.tgz",
+ "integrity": "sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.8",
+ "@vue/shared": "3.5.43",
+ "entities": "^7.0.1",
+ "estree-walker": "^2.0.2",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-dom": {
+ "version": "3.5.43",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.43.tgz",
+ "integrity": "sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@vue/compiler-core": "3.5.43",
+ "@vue/shared": "3.5.43"
+ }
+ },
+ "node_modules/@vue/compiler-sfc": {
+ "version": "3.5.43",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.43.tgz",
+ "integrity": "sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.8",
+ "@vue/compiler-core": "3.5.43",
+ "@vue/compiler-dom": "3.5.43",
+ "@vue/compiler-ssr": "3.5.43",
+ "@vue/shared": "3.5.43",
+ "estree-walker": "^2.0.2",
+ "magic-string": "^0.30.21",
+ "postcss": "^8.5.28",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-ssr": {
+ "version": "3.5.43",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.43.tgz",
+ "integrity": "sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.43",
+ "@vue/shared": "3.5.43"
+ }
+ },
+ "node_modules/@vue/devtools-api": {
+ "version": "7.7.10",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.10.tgz",
+ "integrity": "sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/devtools-kit": "^7.7.10"
+ }
+ },
+ "node_modules/@vue/devtools-kit": {
+ "version": "7.7.10",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.10.tgz",
+ "integrity": "sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/devtools-shared": "^7.7.10",
+ "birpc": "^2.3.0",
+ "hookable": "^5.5.3",
+ "mitt": "^3.0.1",
+ "perfect-debounce": "^1.0.0",
+ "speakingurl": "^14.0.1",
+ "superjson": "^2.2.2"
+ }
+ },
+ "node_modules/@vue/devtools-shared": {
+ "version": "7.7.10",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.10.tgz",
+ "integrity": "sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "rfdc": "^1.4.1"
+ }
+ },
+ "node_modules/@vue/language-core": {
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.12.tgz",
+ "integrity": "sha512-k7uIKR0n4CBidk233y7cac9osnDQ5WRidTl6nsrNvpZ3nf8cx9iJxRVsRzVsoPRHbowT/iKgGMTS9RGov3jeIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-core": "2.4.28",
+ "@vue/compiler-dom": "^3.5.0",
+ "@vue/shared": "^3.5.0",
+ "alien-signals": "^3.2.1",
+ "muggle-string": "^0.4.1",
+ "path-browserify": "^1.0.1",
+ "picomatch": "^4.0.4"
+ }
+ },
+ "node_modules/@vue/reactivity": {
+ "version": "3.5.43",
+ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.43.tgz",
+ "integrity": "sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/shared": "3.5.43"
+ }
+ },
+ "node_modules/@vue/runtime-core": {
+ "version": "3.5.43",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.43.tgz",
+ "integrity": "sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.43",
+ "@vue/shared": "3.5.43"
+ }
+ },
+ "node_modules/@vue/runtime-dom": {
+ "version": "3.5.43",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.43.tgz",
+ "integrity": "sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.43",
+ "@vue/runtime-core": "3.5.43",
+ "@vue/shared": "3.5.43",
+ "csstype": "^3.2.3"
+ }
+ },
+ "node_modules/@vue/server-renderer": {
+ "version": "3.5.43",
+ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.43.tgz",
+ "integrity": "sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-ssr": "3.5.43",
+ "@vue/runtime-dom": "3.5.43",
+ "@vue/shared": "3.5.43"
+ }
+ },
+ "node_modules/@vue/shared": {
+ "version": "3.5.43",
+ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.43.tgz",
+ "integrity": "sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==",
+ "license": "MIT"
+ },
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/alien-signals": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz",
+ "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/async-validator": {
+ "version": "4.2.5",
+ "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
+ "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
+ "license": "MIT"
+ },
+ "node_modules/bidi-js": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
+ "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
+ "node_modules/birpc": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
+ "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/chai": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
+ "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/copy-anything": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.1.5.tgz",
+ "integrity": "sha512-vePst2VLTSqPglW7Q98VwFpGiGpOHTyXIo81MO5VyLecA5zMbZw/P/V1vmwD1jksa2iJpn/YkDrJ3XhTfzUyhQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/mesqueeb"
+ }
+ },
+ "node_modules/css-render": {
+ "version": "0.15.14",
+ "resolved": "https://registry.npmjs.org/css-render/-/css-render-0.15.14.tgz",
+ "integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@emotion/hash": "~0.8.0",
+ "csstype": "~3.0.5"
+ }
+ },
+ "node_modules/css-render/node_modules/csstype": {
+ "version": "3.0.11",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz",
+ "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==",
+ "license": "MIT"
+ },
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/cssstyle": {
+ "version": "5.3.7",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz",
+ "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@asamuzakjp/css-color": "^4.1.1",
+ "@csstools/css-syntax-patches-for-csstree": "^1.0.21",
+ "css-tree": "^3.1.0",
+ "lru-cache": "^11.2.4"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
+ "node_modules/data-urls": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz",
+ "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^15.1.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/data-urls/node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/date-fns": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
+ "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
+ "license": "MIT",
+ "peer": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
+ "node_modules/date-fns-tz": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz",
+ "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "date-fns": "^3.0.0 || ^4.0.0"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/echarts": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
+ "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "2.3.0",
+ "zrender": "6.1.0"
+ }
+ },
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
+ "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
+ "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.2",
+ "@esbuild/android-arm": "0.28.2",
+ "@esbuild/android-arm64": "0.28.2",
+ "@esbuild/android-x64": "0.28.2",
+ "@esbuild/darwin-arm64": "0.28.2",
+ "@esbuild/darwin-x64": "0.28.2",
+ "@esbuild/freebsd-arm64": "0.28.2",
+ "@esbuild/freebsd-x64": "0.28.2",
+ "@esbuild/linux-arm": "0.28.2",
+ "@esbuild/linux-arm64": "0.28.2",
+ "@esbuild/linux-ia32": "0.28.2",
+ "@esbuild/linux-loong64": "0.28.2",
+ "@esbuild/linux-mips64el": "0.28.2",
+ "@esbuild/linux-ppc64": "0.28.2",
+ "@esbuild/linux-riscv64": "0.28.2",
+ "@esbuild/linux-s390x": "0.28.2",
+ "@esbuild/linux-x64": "0.28.2",
+ "@esbuild/netbsd-arm64": "0.28.2",
+ "@esbuild/netbsd-x64": "0.28.2",
+ "@esbuild/openbsd-arm64": "0.28.2",
+ "@esbuild/openbsd-x64": "0.28.2",
+ "@esbuild/openharmony-arm64": "0.28.2",
+ "@esbuild/sunos-x64": "0.28.2",
+ "@esbuild/win32-arm64": "0.28.2",
+ "@esbuild/win32-ia32": "0.28.2",
+ "@esbuild/win32-x64": "0.28.2"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "license": "MIT"
+ },
+ "node_modules/evtd": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/evtd/-/evtd-0.2.4.tgz",
+ "integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==",
+ "license": "MIT"
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/highlight.js": {
+ "version": "11.12.0",
+ "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.12.0.tgz",
+ "integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/hookable": {
+ "version": "5.5.3",
+ "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
+ "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
+ "license": "MIT"
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@exodus/bytes": "^1.6.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/lodash": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+ "license": "MIT"
+ },
+ "node_modules/lodash-es": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
+ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
+ "license": "MIT"
+ },
+ "node_modules/lru-cache": {
+ "version": "11.5.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
+ "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "optional": true,
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "dev": true,
+ "license": "CC0-1.0",
+ "optional": true
+ },
+ "node_modules/mitt": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
+ "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
+ "license": "MIT"
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/muggle-string": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
+ "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/naive-ui": {
+ "version": "2.45.3",
+ "resolved": "https://registry.npmjs.org/naive-ui/-/naive-ui-2.45.3.tgz",
+ "integrity": "sha512-D83gJ+6zUdkQ5DuUbmlfyaM1t8pV5WMPa6KmL9mByqQuiL12qBdA0n/iEIth4dQw1x/duNGpOS2anpt5FKO1kw==",
+ "license": "MIT",
+ "dependencies": {
+ "@css-render/plugin-bem": "^0.15.14",
+ "@css-render/vue3-ssr": "^0.15.14",
+ "@types/lodash": "^4.17.20",
+ "@types/lodash-es": "^4.17.12",
+ "async-validator": "^4.2.5",
+ "css-render": "^0.15.14",
+ "csstype": "^3.1.3",
+ "date-fns": "^4.1.0",
+ "date-fns-tz": "^3.2.0",
+ "evtd": "^0.2.4",
+ "highlight.js": "^11.8.0",
+ "lodash": "^4.17.21",
+ "lodash-es": "^4.17.21",
+ "seemly": "^0.3.10",
+ "treemate": "^0.3.11",
+ "vdirs": "^0.1.8",
+ "vooks": "^0.2.12",
+ "vueuc": "^0.4.66"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "vue": "^3.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.19",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
+ "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
+ "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/parse5": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+ "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "entities": "^8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/parse5/node_modules/entities": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
+ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "optional": true,
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/path-browserify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
+ "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/perfect-debounce": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
+ "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pinia": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
+ "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/devtools-api": "^7.7.7"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/posva"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.5.0",
+ "vue": "^3.5.11"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
+ "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.63.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
+ "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.28",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
+ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.18",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rfdc": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
+ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
+ "license": "MIT"
+ },
+ "node_modules/rollup": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.0.tgz",
+ "integrity": "sha512-gjA/kFeDfffII8COoPsvLPvE16hWDTEc1+0QKQNKUkuV8iJjqsC4xXs8/FzuL0v9EV39kuxTbAgcFoTRyy2TGQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+ "@rollup/rollup-android-arm-eabi": "4.64.0",
+ "@rollup/rollup-android-arm64": "4.64.0",
+ "@rollup/rollup-darwin-arm64": "4.64.0",
+ "@rollup/rollup-darwin-x64": "4.64.0",
+ "@rollup/rollup-freebsd-arm64": "4.64.0",
+ "@rollup/rollup-freebsd-x64": "4.64.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.64.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.64.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.64.0",
+ "@rollup/rollup-linux-arm64-musl": "4.64.0",
+ "@rollup/rollup-linux-loong64-gnu": "4.64.0",
+ "@rollup/rollup-linux-loong64-musl": "4.64.0",
+ "@rollup/rollup-linux-ppc64-gnu": "4.64.0",
+ "@rollup/rollup-linux-ppc64-musl": "4.64.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.64.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.64.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.64.0",
+ "@rollup/rollup-linux-x64-gnu": "4.64.0",
+ "@rollup/rollup-linux-x64-musl": "4.64.0",
+ "@rollup/rollup-openbsd-x64": "4.64.0",
+ "@rollup/rollup-openharmony-arm64": "4.64.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.64.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.64.0",
+ "@rollup/rollup-win32-x64-gnu": "4.64.0",
+ "@rollup/rollup-win32-x64-msvc": "4.64.0",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
+ "node_modules/seemly": {
+ "version": "0.3.10",
+ "resolved": "https://registry.npmjs.org/seemly/-/seemly-0.3.10.tgz",
+ "integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==",
+ "license": "MIT"
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/speakingurl": {
+ "version": "14.0.1",
+ "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
+ "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz",
+ "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/superjson": {
+ "version": "2.2.6",
+ "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
+ "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==",
+ "license": "MIT",
+ "dependencies": {
+ "copy-anything": "^4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
+ "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz",
+ "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "7.4.16",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.16.tgz",
+ "integrity": "sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tldts-core": "^7.4.16"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.4.16",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.16.tgz",
+ "integrity": "sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/tough-cookie": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
+ "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/treemate": {
+ "version": "0.3.11",
+ "resolved": "https://registry.npmjs.org/treemate/-/treemate-0.3.11.tgz",
+ "integrity": "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==",
+ "license": "MIT"
+ },
+ "node_modules/tslib": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
+ "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
+ "license": "0BSD"
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.24.6",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
+ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vdirs": {
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/vdirs/-/vdirs-0.1.8.tgz",
+ "integrity": "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==",
+ "license": "MIT",
+ "dependencies": {
+ "evtd": "^0.2.2"
+ },
+ "peerDependencies": {
+ "vue": "^3.0.11"
+ }
+ },
+ "node_modules/vite": {
+ "version": "7.3.6",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
+ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
+ "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.11",
+ "@vitest/mocker": "4.1.11",
+ "@vitest/pretty-format": "4.1.11",
+ "@vitest/runner": "4.1.11",
+ "@vitest/snapshot": "4.1.11",
+ "@vitest/spy": "4.1.11",
+ "@vitest/utils": "4.1.11",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.11",
+ "@vitest/browser-preview": "4.1.11",
+ "@vitest/browser-webdriverio": "4.1.11",
+ "@vitest/coverage-istanbul": "4.1.11",
+ "@vitest/coverage-v8": "4.1.11",
+ "@vitest/ui": "4.1.11",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/vooks": {
+ "version": "0.2.12",
+ "resolved": "https://registry.npmjs.org/vooks/-/vooks-0.2.12.tgz",
+ "integrity": "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==",
+ "license": "MIT",
+ "dependencies": {
+ "evtd": "^0.2.2"
+ },
+ "peerDependencies": {
+ "vue": "^3.0.0"
+ }
+ },
+ "node_modules/vscode-uri": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz",
+ "integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vue": {
+ "version": "3.5.43",
+ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.43.tgz",
+ "integrity": "sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.43",
+ "@vue/compiler-sfc": "3.5.43",
+ "@vue/runtime-dom": "3.5.43",
+ "@vue/server-renderer": "3.5.43",
+ "@vue/shared": "3.5.43"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue-router": {
+ "version": "4.6.4",
+ "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
+ "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/devtools-api": "^6.6.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/posva"
+ },
+ "peerDependencies": {
+ "vue": "^3.5.0"
+ }
+ },
+ "node_modules/vue-router/node_modules/@vue/devtools-api": {
+ "version": "6.6.4",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
+ "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
+ "license": "MIT"
+ },
+ "node_modules/vue-tsc": {
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.12.tgz",
+ "integrity": "sha512-kbpaz18O9jPVkXVHD1BEjpUXM6WhXSu+62L1sGGr6Wbzd8GbvkBQWjoj3KZ/C0aq2MZllce7UdtWzwCIYsBMGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/typescript": "2.4.28",
+ "@vue/language-core": "3.3.12"
+ },
+ "bin": {
+ "vue-tsc": "bin/vue-tsc.js"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.0.0"
+ }
+ },
+ "node_modules/vueuc": {
+ "version": "0.4.66",
+ "resolved": "https://registry.npmjs.org/vueuc/-/vueuc-0.4.66.tgz",
+ "integrity": "sha512-hHrZl+0AsrUvqYPBXSXQ2QaSrO3razjeUseLAhJWlOS/Hdfw/Y2MU05fOTP1qn1A3121gMxsHLaEz2V6CkWdTg==",
+ "license": "MIT",
+ "dependencies": {
+ "@css-render/vue3-ssr": "^0.15.10",
+ "@juggle/resize-observer": "^3.3.1",
+ "css-render": "^0.15.10",
+ "evtd": "^0.2.4",
+ "seemly": "^0.3.6",
+ "vdirs": "^0.1.4",
+ "vooks": "^0.2.4"
+ },
+ "peerDependencies": {
+ "vue": "^3.0.11"
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "optional": true,
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "15.1.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz",
+ "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.22.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
+ "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/zrender": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
+ "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tslib": "2.3.0"
+ }
+ }
+ }
+}
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..fa71df4
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "clash-web-ui",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite --host 127.0.0.1",
+ "build": "vue-tsc --noEmit && vite build",
+ "typecheck": "vue-tsc --noEmit",
+ "test": "vitest run src",
+ "test:e2e": "playwright test"
+ },
+ "dependencies": {
+ "@lucide/vue": "^1.51.0",
+ "echarts": "^6.0.0",
+ "naive-ui": "^2.42.0",
+ "pinia": "^3.0.3",
+ "vue": "^3.5.22",
+ "vue-router": "^4.6.3"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.56.0",
+ "@types/node": "^24.7.0",
+ "@vitejs/plugin-vue": "^6.0.1",
+ "typescript": "^5.9.3",
+ "vite": "^7.1.9",
+ "vitest": "^4.1.11",
+ "vue-tsc": "^3.1.1"
+ }
+}
diff --git a/web/playwright.config.ts b/web/playwright.config.ts
new file mode 100644
index 0000000..697d76a
--- /dev/null
+++ b/web/playwright.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from '@playwright/test'
+
+export default defineConfig({
+ testDir: './e2e',
+ timeout: 60000,
+ workers: 1,
+ reporter: 'list',
+ use: { baseURL: process.env.CW_E2E_URL || 'http://127.0.0.1:18080', browserName: 'chromium', trace: 'retain-on-failure', screenshot: 'only-on-failure' },
+})
diff --git a/web/public/favicon.svg b/web/public/favicon.svg
new file mode 100644
index 0000000..8fe3fff
--- /dev/null
+++ b/web/public/favicon.svg
@@ -0,0 +1,10 @@
+
diff --git a/web/src/App.vue b/web/src/App.vue
new file mode 100644
index 0000000..e534cd0
--- /dev/null
+++ b/web/src/App.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/web/src/api.ts b/web/src/api.ts
new file mode 100644
index 0000000..8ebe566
--- /dev/null
+++ b/web/src/api.ts
@@ -0,0 +1,28 @@
+let csrf = ''
+export function setCSRF(value: string) { csrf = value }
+
+export async function api(path: string, method = 'GET', body?: unknown, signal?: AbortSignal): Promise {
+ const response = await fetch(`/api/v1${path}`, {
+ method,
+ signal,
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
+ })
+ const result = await response.json().catch(() => ({ error: '服务返回了无效响应' }))
+ if (!response.ok) {
+ if (response.status === 401 && !path.startsWith('/auth/')) window.dispatchEvent(new Event('session-expired'))
+ throw new Error(result.error || `请求失败(${response.status})`)
+ }
+ return result as T
+}
+
+export async function downloadBackup() {
+ const backup = await api('/system/backup')
+ const url = URL.createObjectURL(new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' }))
+ const anchor = document.createElement('a')
+ anchor.href = url
+ anchor.download = `clashweb-backup-${new Date().toISOString().slice(0, 10)}.json`
+ anchor.click()
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
+}
diff --git a/web/src/components/EmptyState.vue b/web/src/components/EmptyState.vue
new file mode 100644
index 0000000..e4dd5ab
--- /dev/null
+++ b/web/src/components/EmptyState.vue
@@ -0,0 +1,5 @@
+
+
{{ title }}
{{ description }}
diff --git a/web/src/components/PageHeader.vue b/web/src/components/PageHeader.vue
new file mode 100644
index 0000000..8d860fb
--- /dev/null
+++ b/web/src/components/PageHeader.vue
@@ -0,0 +1,2 @@
+
+
diff --git a/web/src/components/Shell.vue b/web/src/components/Shell.vue
new file mode 100644
index 0000000..10dc135
--- /dev/null
+++ b/web/src/components/Shell.vue
@@ -0,0 +1,48 @@
+
+
+
+
+
正在连接管理服务
+
+
+
+
+
+
+
{{ app.error }} 重新连接
+
+
+
+
+
diff --git a/web/src/components/TrafficChart.vue b/web/src/components/TrafficChart.vue
new file mode 100644
index 0000000..da49602
--- /dev/null
+++ b/web/src/components/TrafficChart.vue
@@ -0,0 +1,33 @@
+
+
diff --git a/web/src/composables/task.ts b/web/src/composables/task.ts
new file mode 100644
index 0000000..fe42384
--- /dev/null
+++ b/web/src/composables/task.ts
@@ -0,0 +1,15 @@
+import { ref } from 'vue'
+import { useMessage } from 'naive-ui'
+
+export function useTask() {
+ const busy = ref(false)
+ const message = useMessage()
+ async function run(task: () => Promise, success?: string) {
+ if (busy.value) return false
+ busy.value = true
+ try { await task(); if (success) message.success(success); return true }
+ catch (e) { message.error((e as Error).message, { duration: 6000 }); return false }
+ finally { busy.value = false }
+ }
+ return { busy, run, message }
+}
diff --git a/web/src/format.test.ts b/web/src/format.test.ts
new file mode 100644
index 0000000..be6153a
--- /dev/null
+++ b/web/src/format.test.ts
@@ -0,0 +1,26 @@
+import { describe, it, expect } from 'vitest'
+import { bytes, nodeName, subscriptionHost, region } from './format'
+
+describe('network display values', () => {
+ it('formats zero, nonfinite values and transferred bytes', () => {
+ expect(bytes(0)).toBe('0 B')
+ expect(bytes(NaN)).toBe('0 B')
+ expect(bytes(0.5)).toBe('0.5 B')
+ expect(bytes(1024)).toBe('1.0 KB')
+ expect(bytes(1024 ** 3)).toBe('1.0 GB')
+ })
+ it('keeps slashes inside a node display name and identifies inactive selections', () => {
+ expect(nodeName('subscription/provider/HK / 01')).toBe('HK / 01')
+ expect(nodeName('REJECT')).toBe('尚未选择节点')
+ expect(nodeName('')).toBe('尚未选择节点')
+ })
+ it('shows a subscription host without its private token', () => {
+ expect(subscriptionHost('https://example.com/sub?token=secret')).toBe('example.com')
+ expect(subscriptionHost('invalid')).toBe('无效地址')
+ })
+ it('identifies common node region labels', () => {
+ expect(region('香港 01')).toBe('HK')
+ expect(region('🇯🇵 Tokyo')).toBe('JP')
+ expect(region('unknown')).toBe('GL')
+ })
+})
diff --git a/web/src/format.ts b/web/src/format.ts
new file mode 100644
index 0000000..1ce79b8
--- /dev/null
+++ b/web/src/format.ts
@@ -0,0 +1,31 @@
+export function bytes(value: number, digits = 1): string {
+ if (!Number.isFinite(value) || value <= 0) return '0 B'
+ const units = ['B', 'KB', 'MB', 'GB', 'TB']
+ const index = Math.max(0, Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1))
+ return `${(value / 1024 ** index).toFixed(index === 0 && value >= 1 ? 0 : digits)} ${units[index]}`
+}
+export function nodeName(id: string): string {
+ if (!id || id === 'REJECT') return '尚未选择节点'
+ if (id === 'CW-AUTO') return '自动选择'
+ return id.split('/').slice(2).join('/') || id
+}
+export function date(value: string): string {
+ if (!value) return '尚未更新'
+ const parsed = new Date(value)
+ return Number.isNaN(parsed.getTime()) ? '—' : parsed.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })
+}
+export function uptime(seconds: number): string {
+ if (seconds < 60) return `${seconds} 秒`
+ if (seconds < 3600) return `${Math.floor(seconds / 60)} 分钟`
+ return `${Math.floor(seconds / 3600)} 小时 ${Math.floor(seconds % 3600 / 60)} 分钟`
+}
+export function subscriptionHost(raw: string): string { try { return new URL(raw).host } catch { return '无效地址' } }
+export function region(name: string): string {
+ if (/香港|hong\s*kong|\bhk\b|🇭🇰/i.test(name)) return 'HK'
+ if (/日本|japan|\bjp\b|东京|🇯🇵/i.test(name)) return 'JP'
+ if (/新加坡|singapore|\bsg\b|🇸🇬/i.test(name)) return 'SG'
+ if (/美国|united\s*states|\bus\b|🇺🇸/i.test(name)) return 'US'
+ if (/台湾|taiwan|\btw\b|🇹🇼/i.test(name)) return 'TW'
+ if (/韩国|korea|\bkr\b|🇰🇷/i.test(name)) return 'KR'
+ return 'GL'
+}
diff --git a/web/src/main.ts b/web/src/main.ts
new file mode 100644
index 0000000..5a983c4
--- /dev/null
+++ b/web/src/main.ts
@@ -0,0 +1,21 @@
+import { createApp } from 'vue'
+import { createPinia } from 'pinia'
+import { createRouter, createWebHistory } from 'vue-router'
+import App from './App.vue'
+import './style.css'
+
+const router = createRouter({
+ history: createWebHistory(),
+ routes: [
+ { path: '/', component: () => import('./views/Dashboard.vue'), meta: { title: '网络总览' } },
+ { path: '/subscriptions', component: () => import('./views/Subscriptions.vue'), meta: { title: '订阅管理' } },
+ { path: '/nodes', component: () => import('./views/Nodes.vue'), meta: { title: '代理节点' } },
+ { path: '/connectivity', component: () => import('./views/Connectivity.vue'), meta: { title: '连通性测试' } },
+ { path: '/rules', component: () => import('./views/Rules.vue'), meta: { title: '分流规则' } },
+ { path: '/activity', component: () => import('./views/Activity.vue'), meta: { title: '连接与日志' } },
+ { path: '/settings', component: () => import('./views/Settings.vue'), meta: { title: '系统设置' } },
+ { path: '/:pathMatch(.*)*', redirect: '/' },
+ ],
+})
+
+createApp(App).use(createPinia()).use(router).mount('#app')
diff --git a/web/src/stores/app.ts b/web/src/stores/app.ts
new file mode 100644
index 0000000..7335d02
--- /dev/null
+++ b/web/src/stores/app.ts
@@ -0,0 +1,63 @@
+import { defineStore } from 'pinia'
+import { computed, ref } from 'vue'
+import { api, setCSRF } from '../api'
+import type { Metric, Status } from '../types'
+
+export const useApp = defineStore('app', () => {
+ const authenticated = ref(false)
+ const initialized = ref(true)
+ const ready = ref(false)
+ const status = ref(null)
+ const error = ref('')
+ const streamConnected = ref(false)
+ const metrics = ref({ up: 0, down: 0, upTotal: 0, downTotal: 0, connections: 0, coreOnline: false, time: '' })
+ const history = ref([])
+ const theme = ref(localStorage.getItem('cw-theme') || 'light')
+ const dark = computed(() => theme.value === 'dark')
+ let stream: EventSource | null = null
+ let timer: ReturnType | undefined
+
+ async function checkAuth() {
+ try {
+ const auth = await api<{ initialized: boolean; authenticated: boolean; csrf: string }>('/auth/status')
+ initialized.value = auth.initialized
+ authenticated.value = auth.authenticated
+ setCSRF(auth.csrf)
+ if (auth.authenticated) start()
+ error.value = ''
+ } catch (e) { error.value = (e as Error).message }
+ finally { ready.value = true }
+ }
+ async function login(password: string, token?: string) {
+ const result = await api<{ csrf: string }>(initialized.value ? '/auth/login' : '/auth/setup', 'POST', { password, ...(token ? { token } : {}) })
+ setCSRF(result.csrf)
+ initialized.value = true
+ authenticated.value = true
+ start()
+ }
+ async function reload() {
+ try { status.value = await api('/status'); error.value = '' }
+ catch (e) { error.value = (e as Error).message }
+ }
+ function start() {
+ stop()
+ void reload()
+ timer = setInterval(() => void reload(), 15000)
+ stream = new EventSource('/api/v1/events')
+ stream.addEventListener('metrics', (event) => {
+ const value = JSON.parse((event as MessageEvent).data) as Metric
+ metrics.value = value
+ streamConnected.value = true
+ history.value = [...history.value, value].slice(-60)
+ if (status.value) status.value.coreOnline = value.coreOnline
+ })
+ stream.addEventListener('expired', expire)
+ stream.onerror = () => { streamConnected.value = false }
+ }
+ function stop() { stream?.close(); stream = null; if (timer) clearInterval(timer); timer = undefined; streamConnected.value = false }
+ function expire() { authenticated.value = false; setCSRF(''); stop(); status.value = null; history.value = [] }
+ async function logout() { await api('/auth/logout', 'POST', {}); expire() }
+ function toggleTheme() { theme.value = dark.value ? 'light' : 'dark'; localStorage.setItem('cw-theme', theme.value) }
+ window.addEventListener('session-expired', expire)
+ return { authenticated, initialized, ready, status, error, metrics, history, dark, theme, streamConnected, checkAuth, login, reload, logout, toggleTheme }
+})
diff --git a/web/src/style.css b/web/src/style.css
new file mode 100644
index 0000000..b7bf006
--- /dev/null
+++ b/web/src/style.css
@@ -0,0 +1,193 @@
+:root { font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif; color: #35495e; background: #f5f9f8; font-synthesis: none; -webkit-font-smoothing: antialiased; }
+* { box-sizing: border-box; }
+body { margin: 0; }
+a { color: inherit; text-decoration: none; }
+button, input { font: inherit; }
+button { cursor: pointer; }
+button:focus-visible, a:focus-visible { outline: 2px solid #42b883; outline-offset: 4px; }
+code { font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace; font-size: .91em; }
+h1, h2, h3, p { margin: 0; }
+.application { --canvas: #f5f9f8; --surface: #fff; --text: #35495e; --muted: #74858b; --border: #e1ebe7; --soft: #f1f7f4; --accent: #27865f; --accent-bright: #42b883; --accent-soft: #e5f7ef; --hero: #e0f7ed; --shadow: 0 2px 8px #35495e04; min-height: 100vh; color: var(--text); background: var(--canvas); }
+.application.dark { --canvas: #15202b; --surface: #1d2c39; --text: #e2edf3; --muted: #9aafbd; --border: #324553; --soft: #263a47; --accent: #65d6a3; --accent-bright: #65d6a3; --accent-soft: #203f3b; --hero: #23483f; --shadow: none; }
+.loading-screen { min-height: 100vh; display: flex; align-items: center; justify-content: center; gap: 20px; color: var(--muted); }
+.sidebar { position: fixed; inset: 0 auto 0 0; width: 238px; background: var(--surface); color: var(--muted); border-right: 1px solid var(--border); display: flex; flex-direction: column; padding: 36px 20px 0; z-index: 20; }
+.brand { display: flex; align-items: center; gap: 11px; color: var(--text); font-size: 23px; font-weight: 700; letter-spacing: -.8px; padding-left: 8px; white-space: nowrap; }
+.brand-icon { width: 40px; height: 42px; display: inline-flex; align-items: center; justify-content: center; object-fit: contain; background: transparent; border: 0; border-radius: 12px; flex-shrink: 0; }
+.brand-light { font-weight: 450; }
+.brand small { display: block; font-size: 8px; letter-spacing: 1.75px; color: var(--muted); margin-top: 2px; font-weight: 500; }
+.nav-label { font-size: 10px; font-weight: 500; letter-spacing: 1.5px; padding: 44px 15px 13px; color: var(--muted); }
+.nav-item { display: flex; align-items: center; gap: 13px; margin-bottom: 6px; padding: 13px 15px; font-size: 13px; border-radius: 8px; transition: background .15s, color .15s; }
+.nav-item:hover { color: var(--accent); background: var(--soft); }
+.nav-item.active { color: var(--accent); background: var(--accent-soft); box-shadow: inset 3px 0 var(--accent-bright); }
+.nav-item.active svg { color: var(--accent); }
+.nav-count { margin-left: auto; font-size: 10px; background: var(--surface); border-radius: 5px; padding: 2px 6px; color: var(--accent); }
+.sidebar-bottom { margin-top: auto; padding-top: 45px; }
+.engine-card { background: var(--soft); border: 1px solid var(--border); border-radius: 10px; padding: 15px 13px; }
+.engine-top { display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--text); }
+.engine-tag { margin-left: auto; font-size: 8px; letter-spacing: .7px; color: var(--muted); }
+.engine-card p { font-size: 10px; color: var(--muted); margin: 8px 0 14px 15px; overflow-wrap: anywhere; }
+.engine-card a { display: flex; justify-content: space-between; font-size: 11px; color: var(--accent); padding-top: 12px; border-top: 1px solid var(--border); }
+.account { display: flex; align-items: center; gap: 10px; padding: 21px 2px; }
+.avatar { width: 32px; height: 32px; border: 1px solid var(--border); border-radius: 50%; display: flex; align-items: center; justify-content: center; background: var(--accent-soft); color: var(--accent); font-size: 12px; }
+.account strong { display: block; font-size: 11px; color: var(--text); font-weight: 500; }
+.account small { display: block; font-size: 9px; margin-top: 3px; }
+.account button { margin-left: auto; color: var(--muted); }
+.icon-button, .theme-button { border: 0; background: transparent; color: inherit; display: inline-flex; align-items: center; justify-content: center; padding: 7px; border-radius: 7px; }
+.icon-button:hover { background: #7eac8f15; }
+.workspace { margin-left: 238px; min-height: 100vh; display: flex; flex-direction: column; }
+.topbar { height: 70px; padding: 0 38px; border-bottom: 1px solid var(--border); background: var(--surface); display: flex; align-items: center; justify-content: space-between; }
+.breadcrumb { display: flex; align-items: center; gap: 16px; font-size: 11px; color: var(--muted); }
+.breadcrumb strong { font-weight: 500; color: var(--text); }
+.slash { color: #c2cbc5; }
+.topbar-actions { display: flex; align-items: center; gap: 21px; }
+.local-badge { display: inline-flex; align-items: center; gap: 7px; background: var(--accent-soft); color: var(--accent); font-size: 10px; padding: 5px 9px; border-radius: 5px; }
+.theme-button { border: 1px solid var(--border); background: var(--surface); color: var(--muted); width: 32px; height: 32px; }
+.main-content { width: 100%; max-width: 1530px; padding: 34px 38px 25px; margin: 0 auto; flex: 1; }
+.page-header { display: flex; justify-content: space-between; gap: 20px; align-items: center; margin-bottom: 27px; }
+.eyebrow { font-size: 9px; letter-spacing: 1.9px; color: var(--muted); font-weight: 600; margin-bottom: 8px; }
+.page-header h1 { font-size: 26px; font-weight: 650; letter-spacing: -.8px; line-height: 1.3; }
+.page-header p { margin-top: 8px; color: var(--muted); font-size: 12px; }
+.page-actions { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
+.workspace-footer { display: flex; justify-content: space-between; padding: 18px 38px 23px; color: var(--muted); font-size: 10px; }
+.workspace-footer > span:last-child { display: flex; align-items: center; gap: 7px; }
+.footer-separator { margin: 0 7px; color: #c4cfc7; }
+.status-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: #8c9891; flex-shrink: 0; }
+.status-dot.online { background: #60c996; box-shadow: 0 0 0 3px #60c99614; }
+.status-label { color: var(--muted); background: var(--soft); font-size: 10px; border-radius: 5px; padding: 5px 9px; white-space: nowrap; }
+.status-label.connected { color: var(--accent); background: var(--accent-soft); }
+.overview-hero { border-radius: 13px; display: flex; background: var(--hero); color: var(--text); min-height: 246px; position: relative; overflow: hidden; margin-bottom: 21px; background-image: radial-gradient(ellipse at 84% 30%, #42b88338 0%, transparent 58%); border: 1px solid #42b88326; }
+.hero-copy { padding: 28px 33px; position: relative; z-index: 2; }
+.hero-eyebrow { display: flex; align-items: center; gap: 8px; color: var(--accent); font-size: 10px; }
+.hero-copy h2 { font-size: 31px; letter-spacing: -1px; font-weight: 550; line-height: 1.38; margin: 15px 0 10px; }
+.hero-copy p { color: var(--muted); font-size: 11px; }
+.hero-pills { display: flex; gap: 8px; margin-top: 21px; }
+.hero-pills span { display: inline-flex; align-items: center; gap: 5px; font-size: 9px; border: 1px solid #42b88338; background: var(--surface); padding: 4px 8px; border-radius: 5px; color: var(--accent); }
+.hero-network { position: absolute; width: 400px; height: 290px; right: 35px; top: -12px; }
+.orbit { position: absolute; border-radius: 50%; border: 1px solid #42b88340; left: 50%; top: 49%; transform: translate(-50%, -50%); }
+.orbit-one { width: 170px; height: 170px; }
+.orbit-two { width: 274px; height: 274px; border-style: dashed; border-color: #42b88328; }
+.network-center { position: absolute; width: 97px; height: 97px; display: flex; align-items: center; justify-content: center; top: 93px; left: 152px; border-radius: 50%; border: 1px solid #42b88340; color: var(--accent); background: linear-gradient(135deg, #42b88335, #42b88310); box-shadow: 0 0 70px #42b88315; }
+.network-point { display: flex; align-items: center; justify-content: center; position: absolute; width: 41px; height: 41px; border: 1px solid #42b88340; color: var(--accent); background: var(--surface); border-radius: 12px; box-shadow: 0 5px 25px #16291a30; }
+.point-one { top: 52px; left: 116px; }
+.point-two { top: 180px; left: 225px; }
+.point-three { top: 118px; left: 317px; color: var(--accent); background: var(--accent-soft); }
+.orbit-label { position: absolute; color: var(--accent); font-size: 8px; letter-spacing: 1.6px; }
+.label-one { top: 43px; left: 238px; }.label-two { top: 225px; left: 122px; }
+.network-coordinate { position: absolute; bottom: 18px; left: 107px; font-size: 7px; letter-spacing: 2px; color: var(--muted); }
+.stat-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; margin-bottom: 21px; }
+.stat-card, .panel { background: var(--surface); border: 1px solid var(--border); border-radius: 11px; box-shadow: var(--shadow); }
+.stat-card { padding: 18px 21px; }
+.stat-label { display: flex; align-items: center; justify-content: space-between; color: var(--muted); font-size: 11px; }
+.stat-icon { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 8px; }
+.stat-icon.green { background: #edf8f0; color: #3b9366; }.stat-icon.blue { background: #edf3f8; color: #6b91af; }.stat-icon.amber { background: #fcf5e9; color: #ba975c; }.stat-icon.purple { background: #f3eef8; color: #9c85b5; }
+.dark .stat-icon { background: var(--soft); }
+.stat-value { font-size: 28px; letter-spacing: -1px; font-weight: 600; margin: 11px 0 8px; font-variant-numeric: tabular-nums; }
+.stat-value small { color: var(--muted); font-size: 11px; font-weight: 400; letter-spacing: 0; margin-left: 5px; }
+.stat-card p { font-size: 10px; color: var(--muted); }.stat-card p strong { color: #788a7c; font-weight: 500; margin-left: 3px; }
+.dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.9fr) minmax(260px, 1fr); gap: 20px; margin-bottom: 20px; }
+.panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding: 21px 23px 0; }
+.panel-heading h3 { font-size: 13px; font-weight: 600; }.panel-heading p { font-size: 10px; color: var(--muted); margin-top: 6px; }
+.chart-legend { display: flex; gap: 15px; font-size: 9px; color: var(--muted); white-space: nowrap; }.chart-legend span { display: flex; align-items: center; gap: 5px; }
+.legend-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; }.legend-dot.download { background: var(--accent-bright); }.legend-dot.upload { background: #a4b7c9; }
+.traffic-chart { height: 230px; width: 100%; margin-top: 12px; }
+.panel-foot { display: flex; align-items: center; gap: 8px; border-top: 1px solid var(--border); padding: 13px 23px; color: var(--muted); font-size: 9px; }.foot-right { margin-left: auto; }
+.current-node { padding-bottom: 0; overflow: hidden; }.current-node-symbol { width: 54px; height: 54px; border-radius: 14px; background: var(--accent-soft); color: var(--accent); display: flex; align-items: center; justify-content: center; margin: 18px auto 12px; }
+.selected-name { font-size: 15px; text-align: center; font-weight: 550; padding: 0 18px; overflow-wrap: anywhere; }.selected-description { font-size: 9px; text-align: center; color: var(--muted); margin: 7px 0 15px; }
+.node-detail-row { display: flex; align-items: center; justify-content: space-between; padding: 7px 23px; font-size: 10px; color: var(--muted); }.node-detail-row strong { font-weight: 500; color: var(--text); }
+.panel-link { display: flex; align-items: center; justify-content: space-between; color: var(--accent); background: var(--accent-soft); padding: 12px 23px; font-size: 10px; margin-top: 13px; border-top: 1px solid var(--border); }
+.small-tag { display: inline-flex; align-items: center; background: var(--soft); border: 1px solid var(--border); color: var(--muted); padding: 3px 7px; font-size: 9px; border-radius: 5px; white-space: nowrap; }
+.text-link { display: inline-flex; align-items: center; gap: 5px; font-size: 10px; color: var(--accent); }
+.dashboard-bottom { display: grid; grid-template-columns: 1.45fr 1fr; gap: 20px; }
+.compact-empty { display: flex; align-items: center; gap: 14px; padding: 29px 23px 35px; color: var(--muted); }.compact-empty strong { color: var(--text); font-size: 11px; font-weight: 500; }.compact-empty p { font-size: 10px; margin-top: 5px; }.compact-empty a { display: flex; align-items: center; margin-left: auto; color: var(--accent); font-size: 10px; white-space: nowrap; }
+.quick-start { padding-bottom: 20px; }.quick-start > p { font-size: 10px; color: var(--muted); line-height: 1.8; padding: 14px 23px; }.network-values { display: flex; gap: 25px; padding: 0 23px 16px; }.network-values span { display: block; font-size: 9px; color: var(--muted); margin-bottom: 5px; }.network-values code { font-size: 11px; }.quick-start > a { margin-left: 23px; }
+.subscription-summary { display: flex; align-items: center; gap: 11px; padding: 15px 23px; border-bottom: 1px solid var(--border); }.subscription-summary:last-child { border-bottom: 0; }.summary-icon { width: 30px; height: 30px; background: var(--accent-soft); color: var(--accent); display: flex; align-items: center; justify-content: center; border-radius: 8px; }.subscription-summary strong { font-size: 11px; font-weight: 500; }.subscription-summary small { display: block; color: var(--muted); font-size: 9px; margin-top: 3px; }.subscription-summary .small-tag { margin-left: auto; }
+.section-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; font-size: 12px; margin: 0 0 17px; }.muted { color: var(--muted); }.small { font-size: 10px; }.nowrap { white-space: nowrap; }
+.empty-state { padding: 66px 20px 71px; text-align: center; }.empty-icon { display: flex; align-items: center; justify-content: center; width: 62px; height: 62px; background: var(--accent-soft); color: var(--accent); border: 1px solid var(--border); border-radius: 18px; margin: 0 auto 20px; }.empty-state h3 { font-size: 17px; font-weight: 550; }.empty-state p { font-size: 12px; color: var(--muted); margin: 12px 0 23px; line-height: 1.8; }
+.info-note { display: flex; align-items: flex-start; gap: 12px; color: var(--muted); padding: 20px 5px; font-size: 11px; line-height: 1.9; }.info-note svg { flex-shrink: 0; margin-top: 3px; }
+.subscription-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; }.subscription-card { padding: 22px; }.subscription-card.disabled { opacity: .65; }.subscription-card-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 19px; }.subscription-icon { display: flex; align-items: center; justify-content: center; width: 43px; height: 43px; background: var(--accent-soft); color: var(--accent); border-radius: 12px; }.subscription-card h3 { font-size: 17px; font-weight: 550; overflow-wrap: anywhere; }.subscription-host { display: flex; align-items: center; gap: 5px; color: var(--muted); font-size: 10px; margin-top: 8px; overflow-wrap: anywhere; }.subscription-node-count { margin: 24px 0 20px; font-size: 33px; font-weight: 600; letter-spacing: -1px; }.subscription-node-count span { color: var(--muted); font-size: 10px; letter-spacing: 0; font-weight: 400; margin-left: 10px; }.subscription-facts { display: flex; flex-direction: column; gap: 9px; font-size: 10px; color: var(--muted); }.subscription-facts span { display: flex; align-items: center; gap: 6px; }.subscription-card-actions { display: flex; gap: 5px; padding-top: 19px; margin-top: 19px; border-top: 1px solid var(--border); }.subscription-card-actions > :nth-child(2) { margin-left: auto; }.subscription-error { margin-top: 15px; }.subscription-error small { display: block; font-size: 9px; margin-top: 5px; }
+.selection-banner { display: flex; align-items: center; gap: 13px; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 17px 21px; margin-bottom: 24px; }.selection-symbol { width: 41px; height: 41px; background: var(--accent-soft); color: var(--accent); border-radius: 10px; display: flex; align-items: center; justify-content: center; }.selection-banner div > span { font-size: 10px; }.selection-banner strong { display: block; font-size: 14px; font-weight: 550; margin-top: 4px; overflow-wrap: anywhere; }.selection-banner > .status-label { margin-left: auto; }
+.nodes-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 21px; }.node-search { flex: 1; min-width: 160px; }.subscription-select { width: 160px; }.sort-select { width: 150px; }
+.node-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }.node-card { padding: 20px; transition: border-color .15s, box-shadow .15s; }.node-card:hover { border-color: var(--accent-bright); }.node-card.selected { border-color: var(--accent-bright); box-shadow: 0 0 0 1px #42b88330; background: linear-gradient(120deg, var(--accent-soft), var(--surface) 70%); }.node-card-main { display: flex; align-items: center; gap: 12px; }.region-icon { display: flex; align-items: center; justify-content: center; width: 39px; height: 39px; border-radius: 10px; font-size: 11px; letter-spacing: .4px; font-weight: 600; background: #edf2f7; color: #658397; flex-shrink: 0; }.region-icon.hk { background: #f9eeed; color: #be726c; }.region-icon.jp { background: #f8eef2; color: #bf7693; }.region-icon.sg { background: #f8f0ea; color: #bd926f; }.region-icon.us { background: #edf2fa; color: #7395c2; }.region-icon.tw { background: #eceef9; color: #878dba; }.dark .region-icon { background: var(--soft); }.node-title { min-width: 0; }.node-title h3 { font-size: 12px; font-weight: 550; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }.node-title p { font-size: 9px; color: var(--muted); margin-top: 5px; }.selected-check { display: inline-flex; color: white; background: var(--accent); border-radius: 50%; padding: 3px; margin-left: auto; }.node-card-meta { display: flex; gap: 6px; align-items: center; margin: 20px 0 18px; }.protocol-tag { background: var(--soft); border: 1px solid var(--border); color: var(--muted); font-size: 8px; padding: 3px 6px; border-radius: 4px; white-space: nowrap; }.protocol-tag.subtle { border-color: transparent; background: transparent; }.node-delay { display: flex; align-items: center; gap: 5px; margin-left: auto; font-size: 10px; font-variant-numeric: tabular-nums; white-space: nowrap; }.node-delay i { width: 5px; height: 5px; border-radius: 50%; background: currentColor; }.node-delay.good { color: #47a371; }.node-delay.fair { color: #b39354; }.node-delay.bad { color: #c87873; }.node-delay.unknown { color: var(--muted); }.node-card-actions { display: flex; justify-content: space-between; align-items: center; border-top: 1px solid var(--border); padding-top: 14px; }
+.node-list { grid-template-columns: 1fr; }.node-list .node-card { display: grid; grid-template-columns: 1.4fr 1fr 145px; align-items: center; gap: 20px; padding: 14px 20px; }.node-list .node-card-meta { margin: 0; }.node-list .node-card-actions { padding: 0; border: 0; }
+.routing-overview { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 22px; }.routing-step { display: flex; align-items: center; gap: 15px; padding: 24px; background: var(--surface); border: 1px solid var(--border); border-radius: 11px; }.routing-icon { display: flex; align-items: center; justify-content: center; width: 44px; height: 44px; border-radius: 12px; background: var(--accent-soft); color: var(--accent); }.routing-icon.proxy { background: #eff1f8; color: #8291ba; }.routing-step strong { display: block; font-size: 13px; font-weight: 550; }.routing-step div span { display: block; font-size: 10px; color: var(--muted); margin-top: 7px; }.routing-step > .route-badge { margin-left: auto; }.route-badge { display: inline-block; font-size: 9px; border-radius: 4px; padding: 4px 8px; white-space: nowrap; }.route-badge.direct { color: #398c5e; background: #edf7ef; }.route-badge.proxy { color: #7c87b1; background: #eff1f9; }.dark .route-badge { background: var(--soft); }
+.rule-empty { display: flex; justify-content: center; align-items: center; gap: 15px; padding: 48px 23px; color: var(--muted); font-size: 12px; line-height: 1.8; }.table-scroll { width: 100%; overflow-x: auto; margin-top: 20px; }.data-table { width: 100%; border-collapse: collapse; font-size: 11px; text-align: left; }.data-table th { font-size: 10px; color: var(--muted); font-weight: 500; background: var(--soft); padding: 12px 23px; border-block: 1px solid var(--border); white-space: nowrap; }.data-table td { padding: 15px 23px; border-bottom: 1px solid var(--border); }.data-table tbody tr:last-child td { border-bottom: 0; }.data-table small { display: block; font-size: 9px; color: var(--muted); margin-top: 6px; max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.align-right { text-align: right; }
+.rule-order { margin-top: 23px; padding-bottom: 13px; }.rule-order-step { display: flex; align-items: center; gap: 17px; padding: 14px 23px; font-size: 11px; border-bottom: 1px solid var(--border); }.rule-order-step:last-child { border: 0; }.rule-order-step > svg, .rule-order-step > .small-tag { margin-left: auto; color: var(--muted); }.step-number { font-size: 10px; color: var(--muted); background: var(--soft); padding: 5px 7px; border-radius: 5px; }
+.activity-panel { padding: 9px 23px 23px; }.activity-toolbar { display: flex; align-items: center; gap: 12px; margin: 12px 0 16px; }.activity-toolbar .n-input { flex: 1; }.log-level-select { width: 130px; }.log-status { font-size: 10px; color: var(--muted); display: flex; align-items: center; gap: 7px; padding: 8px 0 17px; }.log-status > span:last-child { margin-left: auto; }.log-console { min-height: 390px; max-height: 65vh; overflow-y: auto; background: #17221c; border: 1px solid #293b2e; border-radius: 8px; padding: 14px; color: #b3cbb7; font-family: Consolas, monospace; font-size: 10px; }.log-line { display: flex; align-items: flex-start; gap: 11px; line-height: 1.7; padding: 5px 0; border-bottom: 1px solid #ffffff05; }.log-line time { color: #60876b; flex-shrink: 0; }.log-level { color: #7fc19a; font-size: 9px; width: 50px; flex-shrink: 0; }.log-level.warning { color: #c8ac72; }.log-level.error { color: #ce8b81; }.log-text { overflow-wrap: anywhere; min-width: 0; }.log-console-empty { display: flex; gap: 13px; align-items: center; justify-content: center; height: 270px; color: #6d8d75; }.connections-table strong { font-weight: 500; }
+.settings-grid { display: grid; grid-template-columns: minmax(0, 1.8fr) minmax(270px, 1fr); gap: 22px; }.settings-primary, .settings-secondary { display: flex; flex-direction: column; gap: 22px; }.settings-section { padding: 23px; }.settings-heading { display: flex; align-items: center; gap: 13px; margin-bottom: 24px; }.settings-heading h3 { font-size: 14px; font-weight: 550; }.settings-heading p { font-size: 10px; color: var(--muted); margin-top: 6px; }.settings-icon { display: flex; align-items: center; justify-content: center; width: 37px; height: 37px; border-radius: 10px; background: var(--soft); color: var(--accent); flex-shrink: 0; }.settings-heading > .status-label { margin-left: auto; }.setting-switch { display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border); padding: 13px 0; gap: 16px; }.setting-switch strong { font-size: 12px; font-weight: 500; }.setting-switch p { font-size: 10px; color: var(--muted); margin-top: 5px; }.settings-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 0 19px; margin-top: 23px; }.field-label { display: block; color: inherit; font-size: 11px; font-weight: 500; margin-bottom: 19px; }.field-label > .n-input, .field-label > .n-input-number, .field-label > .n-select { margin-top: 8px; }.field-label small { display: block; color: var(--muted); font-size: 9px; font-weight: 400; line-height: 1.7; margin-top: 6px; }.service-facts { display: flex; gap: 27px; margin-bottom: 20px; }.service-facts span { display: block; font-size: 10px; color: var(--muted); margin-bottom: 7px; }.service-facts code { font-size: 11px; }.service-buttons { display: flex; gap: 8px; margin-bottom: 12px; }.test-settings { grid-template-columns: 1.6fr 1fr; border-top: 1px solid var(--border); padding-top: 20px; }.network-checks { padding-bottom: 20px; }.check-row { display: flex; align-items: center; gap: 10px; padding: 10px 23px; font-size: 11px; }.network-checks .panel-heading { margin-bottom: 13px; }.check-ok { color: #58a87c; }.network-checks > .n-button { margin: 12px 23px 0; }.deployment-note { padding: 23px; background: var(--accent-soft); border-color: #b8d2bf45; }.deployment-note h3 { font-size: 14px; font-weight: 550; }.deployment-note p { color: var(--muted); line-height: 1.9; font-size: 10px; margin-top: 13px; }.deployment-note ol { font-size: 10px; color: var(--text); padding-left: 17px; line-height: 2.15; margin: 17px 0; }.deployment-foot { padding-top: 14px; border-top: 1px solid #b8d2bf45; }.settings-backup { padding-bottom: 21px; }.settings-backup p { padding: 14px 23px; font-size: 10px; color: var(--muted); line-height: 1.9; }.settings-backup > .n-button { margin: 7px 23px; width: calc(100% - 46px); }.settings-doc-link { display: flex; gap: 6px; font-size: 9px; color: var(--muted); line-height: 1.8; padding: 0 3px; }.settings-doc-link svg { flex-shrink: 0; }
+.form-modal { width: min(490px, calc(100vw - 32px)); }.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 24px; }.form-switch-row { display: flex; justify-content: space-between; align-items: center; font-size: 12px; }.page-alert { margin-bottom: 22px; }.inline-error { color: #b46360; font-size: 11px; line-height: 1.8; background: #c8685c0c; border-radius: 7px; padding: 9px 11px; }.service-error { display: flex; justify-content: space-between; align-items: center; color: #bd7863; background: #b9894d0d; padding: 10px 15px; font-size: 12px; border-radius: 8px; margin-bottom: 20px; }
+.auth-screen { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; padding: 40px 20px; background: radial-gradient(ellipse at 50% 8%, #42b88316, transparent 60%); }.auth-theme { position: absolute; top: 24px; right: 28px; }.auth-brand { display: flex; align-items: center; gap: 12px; font-size: 24px; font-weight: 600; letter-spacing: -.6px; margin-bottom: 33px; }.auth-card { background: var(--surface); border: 1px solid var(--border); padding: 37px 37px 28px; width: min(420px, 100%); border-radius: 16px; box-shadow: 0 20px 70px #17372a06; }.auth-card h1 { font-size: 26px; font-weight: 550; letter-spacing: -.7px; margin: 14px 0 11px; }.auth-card > p { font-size: 12px; color: var(--muted); line-height: 1.8; margin-bottom: 27px; }.auth-card form > .n-button { margin-top: 8px; }.auth-footnote { display: flex; justify-content: center; align-items: center; gap: 6px; color: var(--muted); font-size: 10px; border-top: 1px solid var(--border); margin-top: 28px; padding-top: 21px; }.auth-card .setup-hint { font-size: 10px; margin-top: 18px; margin-bottom: 0; overflow-wrap: anywhere; }.auth-bottom { font-size: 10px; color: var(--muted); margin-top: 28px; letter-spacing: .5px; }
+.mobile-menu, .mobile-close { display: none; }
+
+@media (min-width: 1600px) { .main-content { padding-top: 40px; }.overview-hero { min-height: 270px; }.hero-copy { padding: 33px 38px; }.hero-copy h2 { font-size: 36px; }.hero-network { top: 0; right: 70px; }.traffic-chart { height: 270px; }.stat-card { padding-block: 23px; } }
+@media (max-width: 1150px) { .sidebar { width: 211px; padding-inline: 15px; }.brand { font-size: 21px; }.workspace { margin-left: 211px; }.topbar { padding-inline: 25px; }.main-content { padding-inline: 25px; }.workspace-footer { padding-inline: 25px; }.hero-network { right: -50px; }.hero-copy { padding-inline: 26px; }.stat-grid { gap: 10px; }.stat-card { padding-inline: 15px; }.stat-value { font-size: 24px; }.dashboard-grid { grid-template-columns: minmax(0, 1.6fr) minmax(230px, 1fr); gap: 15px; }.dashboard-bottom { gap: 15px; }.subscription-grid, .node-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.node-list { grid-template-columns: 1fr; }.nodes-toolbar { flex-wrap: wrap; }.node-search { min-width: 250px; }.settings-grid { grid-template-columns: minmax(0, 1.6fr) minmax(240px, 1fr); gap: 15px; }.routing-step { padding: 20px 16px; gap: 10px; }.routing-step > .route-badge { display: none; }.compact-empty { flex-wrap: wrap; }.compact-empty a { margin-left: 38px; }.network-values { gap: 15px; }.settings-fields { grid-template-columns: 1fr; }.settings-section { padding: 20px; } }
+@media (max-width: 860px) { .sidebar { width: 200px; padding-inline: 13px; }.brand { font-size: 19px; }.brand small { font-size: 7px; letter-spacing: 1.4px; }.brand-icon { width: 35px; height: 38px; }.workspace { margin-left: 200px; }.main-content { padding: 25px 20px; }.page-header { align-items: flex-start; }.page-header h1 { font-size: 23px; }.page-actions { flex-wrap: wrap; justify-content: flex-end; max-width: 210px; }.overview-hero { min-height: 234px; }.hero-network { right: -137px; opacity: .65; }.hero-copy h2 { font-size: 29px; }.hero-copy p { max-width: 260px; font-size: 10px; }.stat-grid { grid-template-columns: repeat(2, 1fr); }.dashboard-grid, .dashboard-bottom { grid-template-columns: 1fr; }.current-node { display: none; }.settings-grid { grid-template-columns: 1fr; }.settings-fields { grid-template-columns: 1fr 1fr; }.settings-secondary { display: grid; grid-template-columns: 1fr 1fr; }.settings-doc-link { grid-column: 1 / -1; }.routing-overview { gap: 12px; }.routing-step { padding: 17px 14px; }.routing-icon { width: 34px; height: 34px; }.routing-step div span { font-size: 9px; }.node-list .node-card { grid-template-columns: 1fr 1fr; }.node-list .node-card-actions { grid-column: 1 / -1; }.activity-toolbar { flex-wrap: wrap; }.activity-toolbar .n-input { min-width: 180px; } }
+@media (max-width: 680px) { .sidebar { transform: translateX(-100%); transition: transform .2s; width: 238px; padding: 30px 20px 0; }.sidebar.open { transform: translateX(0); }.sidebar-overlay { position: fixed; inset: 0; background: #15202b70; z-index: 19; }.mobile-close { display: flex; position: absolute; right: 13px; top: 12px; }.mobile-menu { display: inline-flex; }.brand { font-size: 23px; }.brand small { font-size: 8px; }.brand-icon { width: 40px; height: 42px; }.workspace { margin-left: 0; }.topbar { height: 60px; padding-inline: 14px; }.breadcrumb { gap: 9px; font-size: 10px; }.topbar-actions { gap: 10px; }.local-badge { font-size: 9px; }.main-content { padding: 25px 17px 16px; }.page-header { flex-wrap: wrap; gap: 17px; margin-bottom: 22px; }.page-header h1 { font-size: 25px; }.page-actions { max-width: none; }.page-header p { font-size: 11px; }.overview-hero { min-height: 236px; }.hero-copy { padding: 25px; }.hero-network { right: -152px; opacity: .42; }.hero-copy p { max-width: 250px; }.hero-copy h2 { font-size: 31px; }.stat-card { padding: 16px; }.stat-value { font-size: 25px; }.stat-grid { gap: 12px; }.stat-card p { font-size: 9px; }.subscription-grid, .node-grid { grid-template-columns: 1fr; }.node-list .node-card { grid-template-columns: 1fr; }.node-list .node-card-actions { grid-column: auto; padding-top: 12px; border-top: 1px solid var(--border); }.node-list .node-card-meta { margin: 13px 0 0; }.nodes-toolbar { gap: 8px; }.node-search { flex-basis: 100%; }.subscription-select, .sort-select { flex: 1; min-width: 130px; }.section-toolbar { font-size: 11px; align-items: flex-start; }.section-toolbar > .small { display: none; }.selection-banner { padding-inline: 15px; }.selection-banner strong { font-size: 12px; }.selection-banner .status-label { font-size: 8px; }.routing-overview { grid-template-columns: 1fr; }.routing-step > .route-badge { display: block; }.settings-fields { grid-template-columns: 1fr; }.settings-secondary { display: flex; }.activity-panel { padding-inline: 15px; }.activity-toolbar { gap: 9px; }.activity-toolbar .n-input { flex-basis: 100%; }.data-table th, .data-table td { padding-inline: 15px; }.connections-table { min-width: 670px; }.log-status > span:last-child { display: none; }.log-line { gap: 7px; flex-wrap: wrap; }.log-text { flex-basis: 100%; }.panel-heading { padding-inline: 18px; }.panel-heading p { font-size: 9px; }.chart-legend { gap: 9px; }.traffic-chart { height: 205px; }.workspace-footer { padding: 15px 17px 22px; font-size: 9px; }.info-note { font-size: 10px; }.auth-card { padding-inline: 27px; }.empty-state { padding-block: 47px; } }
+@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; } }
+
+.core-update-section { border-top: 1px solid var(--border); padding-top: 20px; margin-top: 22px; }
+.core-update-heading { display: flex; align-items: center; justify-content: space-between; gap: 14px; }
+.core-update-heading h4 { font-size: 13px; margin: 0; font-weight: 600; }
+.core-update-heading p, .update-result { color: var(--muted); font-size: 11px; line-height: 1.8; margin: 7px 0; }
+.update-alert { margin-top: 14px; }
+.upgrade-guide { margin-top: 17px; border-radius: 8px; padding: 12px 14px; background: var(--soft); font-size: 11px; line-height: 1.9; }
+.upgrade-guide summary { color: var(--accent); cursor: pointer; }
+.upgrade-guide p { margin: 9px 0 0; color: var(--muted); }
+@media (max-width: 680px) { .core-update-heading { align-items: flex-start; flex-wrap: wrap; } }
+
+/* Compact result grid: all nine sites together, with similar sites adjacent. */
+.connectivity-page .page-header { margin-bottom: 20px; }
+.connectivity-page .section-toolbar { margin-bottom: 12px; }
+.connectivity-overview { padding: 14px 18px; margin-bottom: 16px; }
+.connectivity-overview-top { display: flex; align-items: center; gap: 24px; }
+.connectivity-intro { display: flex; align-items: center; gap: 11px; }
+.connectivity-radar { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 10px; color: var(--accent); background: var(--accent-soft); flex-shrink: 0; }
+.connectivity-intro h2 { font-size: 14px; }
+.connectivity-intro p { color: var(--muted); font-size: 10px; margin-top: 4px; line-height: 1.5; }
+.connectivity-intro > .protocol-tag { display: none; }
+.connectivity-summary { display: flex; justify-content: flex-end; gap: 24px; flex: 1; }
+.connectivity-summary > div { display: flex; align-items: center; gap: 7px; font-size: 11px; color: var(--muted); }
+.connectivity-summary strong { font-size: 22px; color: var(--text); font-weight: 650; }
+.connectivity-summary > div:first-child svg { color: var(--accent); }
+.connectivity-summary > div:nth-child(2) svg { color: #c18d43; }
+.connectivity-summary > div:last-child svg { color: #d87878; }
+.connectivity-progress { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 6px 16px; color: var(--muted); font-size: 10px; margin-top: 12px; }
+.connectivity-overview .n-progress { margin-top: 8px; }
+.connectivity-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
+.connectivity-card { padding: 12px 16px; transition: border-color .2s; min-width: 0; }
+.connectivity-card.reachable { border-color: color-mix(in srgb, var(--accent-bright) 40%, var(--border)); }
+.connectivity-card-heading { display: flex; align-items: center; gap: 10px; }
+.connectivity-card-heading h3 { font-size: 14px; line-height: 1.25; }
+.connectivity-card-heading p { color: var(--muted); font-size: 10px; line-height: 1.35; margin-top: 3px; overflow-wrap: anywhere; }
+.connectivity-card-heading a { margin-left: auto; color: var(--muted); }
+.site-monogram { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 10px; flex-shrink: 0; font-size: 16px; font-weight: 650; background: var(--soft); color: var(--text); }
+.site-monogram.google { color: #4285f4; background: #4285f411; }
+.site-monogram.netflix, .site-monogram.youtube { color: #e95050; background: #e9505011; }
+.site-monogram.chatgpt { color: var(--accent); background: var(--accent-soft); font-size: 13px; }
+.site-monogram.claude { color: #c78160; background: #c7816014; }
+.site-monogram.dockerhub { color: #2496ed; background: #2496ed11; font-size: 15px; }
+.connectivity-state { display: flex; align-items: center; gap: 8px; margin: 8px 0 4px; font-size: 13px; color: var(--muted); }
+.connectivity-state strong { font-weight: 550; }
+.connectivity-state span { margin-left: auto; font-size: 12px; font-variant-numeric: tabular-nums; }
+.connectivity-state.reachable { color: var(--accent); }
+.connectivity-state.challenge, .connectivity-state.restricted { color: #bb8c45; }
+.connectivity-state.failed, .connectivity-state.http_error { color: #d87878; }
+.connectivity-state.testing { color: var(--accent); }
+.connectivity-detail { font-size: 11px; color: var(--muted); line-height: 1.5; min-height: 33px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
+.connectivity-card-footer { border-top: 1px solid var(--border); margin-top: 8px; padding-top: 8px; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
+.connectivity-result-meta { display: flex; flex-direction: column; gap: 2px; color: var(--muted); font-size: 10px; line-height: 1.3; }
+.connectivity-page .info-note { padding-block: 14px; }
+.spin { animation: connectivity-spin 1.2s linear infinite; }
+@keyframes connectivity-spin { to { transform: rotate(360deg); } }
+@media (max-width: 1050px) { .connectivity-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
+@media (min-width: 651px) and (max-width: 1150px) { .connectivity-overview-top { flex-wrap: wrap; gap: 12px; }.connectivity-summary { justify-content: flex-start; } }
+@media (max-width: 650px) {
+ .connectivity-grid { grid-template-columns: 1fr; }
+ .connectivity-overview { padding: 14px; }
+ .connectivity-overview-top { flex-wrap: wrap; gap: 14px; }
+ .connectivity-summary { justify-content: flex-start; flex-basis: 100%; }
+ .connectivity-summary { gap: 16px; }
+ .connectivity-summary > div { flex-wrap: wrap; gap: 5px; }
+ .connectivity-summary > div span { width: 100%; font-size: 10px; }
+ .connectivity-summary strong { font-size: 23px; }
+}
+@media (prefers-reduced-motion: reduce) { .spin { animation: none; } }
diff --git a/web/src/types.ts b/web/src/types.ts
new file mode 100644
index 0000000..5e44677
--- /dev/null
+++ b/web/src/types.ts
@@ -0,0 +1,112 @@
+export interface Settings {
+ proxyEnabled: boolean
+ tunEnabled: boolean
+ selectionMode: 'manual' | 'auto'
+ selectedNode: string
+ lanIP: string
+ lanCIDR: string
+ gateway: string
+ interface: string
+ mixedPort: number
+ dnsPort: number
+ testURL: string
+ testTimeoutMS: number
+}
+export interface Status {
+ coreOnline: boolean
+ coreInstalled: boolean
+ tunActive: boolean
+ version: string
+ uptimeSeconds: number
+ nodeCount: number
+ subscriptionCount: number
+ settings: Settings
+ configError: string
+ coreError?: string
+ actualNode: string
+ selectedAvailable: boolean
+}
+export interface Subscription {
+ id: string
+ name: string
+ url: string
+ enabled: boolean
+ intervalHours: number
+ updatedAt: string
+ attemptedAt: string
+ lastError: string
+ usage: string
+ nodeCount: number
+ refreshing: boolean
+}
+export interface Node {
+ id: string
+ name: string
+ subscriptionID: string
+ subscriptionName: string
+ type: string
+ udp: boolean
+ delay: number | null
+ alive: boolean | null
+ selected: boolean
+}
+export interface Rule {
+ id: string
+ type: 'DOMAIN' | 'DOMAIN-SUFFIX' | 'IP-CIDR'
+ value: string
+ action: 'DIRECT' | 'PROXY'
+ enabled: boolean
+}
+export interface Metric {
+ up: number
+ down: number
+ upTotal: number
+ downTotal: number
+ connections: number
+ coreOnline: boolean
+ time: string
+}
+export interface Connection {
+ id: string
+ metadata: { host: string; destinationIP: string; destinationPort: string; sourceIP: string; sourcePort: string; network: string; type: string }
+ upload: number
+ download: number
+ chains: string[]
+ rule: string
+ rulePayload: string
+ start: string
+}
+export interface Network {
+ interfaces: { name: string; addresses: string[]; up: boolean }[]
+ ipForward: boolean
+ tunDevice: boolean
+ rulesInstalled: boolean
+ serviceControl: boolean
+ dhcpManaged: boolean
+ guidance: string
+}
+
+export interface CoreUpdate {
+ currentVersion: string
+ latestVersion: string
+ updateAvailable: boolean | null
+ checkedAt: string
+ publishedAt: string
+ releaseURL: string
+}
+
+export interface ConnectivitySite {
+ id: string
+ name: string
+ url: string
+ category: string
+}
+export interface ConnectivityResult {
+ siteId: string
+ status: 'reachable' | 'challenge' | 'restricted' | 'http_error' | 'failed'
+ detail: string
+ httpStatus: number
+ durationMS: number
+ tlsVerified: boolean
+ checkedAt: string
+}
diff --git a/web/src/views/Activity.vue b/web/src/views/Activity.vue
new file mode 100644
index 0000000..2a57d94
--- /dev/null
+++ b/web/src/views/Activity.vue
@@ -0,0 +1,37 @@
+
+
+ {{ paused ? '继续更新' : '暂停更新' }}
+ {{ filtered.length }} 条连接关闭全部
清空显示
{{ streamOnline ? '日志流已连接' : '正在等待内核日志' }}保留最近 500 条,不写入数据库
等待新的内核日志…
{{ log.type.toUpperCase() }}{{ log.payload }}
+
diff --git a/web/src/views/Auth.vue b/web/src/views/Auth.vue
new file mode 100644
index 0000000..c22c910
--- /dev/null
+++ b/web/src/views/Auth.vue
@@ -0,0 +1,13 @@
+
+
Clash WebYOUR NETWORK, YOUR WAY
{{ app.initialized ? '欢迎回来。' : '从这里,连接世界。' }}
{{ app.initialized ? '登录你的内网代理管理工作空间。' : '设置管理员密码,开启你的个人网络工作空间。' }}
在 VM 上运行 journalctl -u clashweb -n 30 查看首次设置令牌。
简洁的界面,自由的网络。
diff --git a/web/src/views/Connectivity.vue b/web/src/views/Connectivity.vue
new file mode 100644
index 0000000..52c05fd
--- /dev/null
+++ b/web/src/views/Connectivity.vue
@@ -0,0 +1,101 @@
+
+
+
+
+
+ 取消测试
+ 一键测试
+
+
Mihomo 内核未连接,请在系统设置中启动内核后测试。
+
{{ loadError }} 重新加载
+
+
+
当前连接概况
通过此 VM 的 Mihomo,遵循当前分流规则。
HTTPS
+
{{ counts.reachable }}可访问
{{ counts.limited }}验证或受限
{{ counts.failed }}连接或响应异常
+
+ {{ running ? `正在测试 · ${finished} / ${total}` : total ? `本次完成 ${finished} / ${total}` : '按需测试 · 每个网站最长 12 秒 · 同时测试 3 个' }}{{ total ? (runProxy ? `开始时所选节点:${runNode}` : '开始时代理关闭 · 直连模式') : '不会切换节点或修改网络配置' }}
+
+
+
{{ sites.length }} 个常用网站切换节点后可重新测试
+
+
+ {{ monograms[site.id] }}{{ site.name }}
{{ hostname(site.url) }}
+ {{ labels[states[site.id] || 'idle'] }}{{ (results[site.id].durationMS / 1000).toFixed(2) }} s
+ {{ results[site.id]?.detail || (states[site.id] === 'cancelled' ? '本次测试已取消,可重新测试。' : '检查主页的 HTTPS 响应情况。') }}
+
+
+
+
测试的是 VM 到网站主页的连接,耗时包含建立连接和读取部分响应,不是节点延迟或带宽。网站可能要求浏览器验证、登录或地区授权;主页可访问不代表视频解锁、AI 对话或镜像拉取可用,也不能代替客户端网关与 TUN 接入测试。大陆规则命中的网站会直连。
+
+
diff --git a/web/src/views/Dashboard.vue b/web/src/views/Dashboard.vue
new file mode 100644
index 0000000..01fe715
--- /dev/null
+++ b/web/src/views/Dashboard.vue
@@ -0,0 +1,30 @@
+
+
+
+ 刷新状态
+ {{ app.status.configError }}
+ 透明代理已配置,但尚未检测到运行中的 cw-tun 网卡。请检查目标 VM 的内核权限、服务日志与网络部署步骤。
+ {{ app.status?.coreOnline ? (proxy ? '规则代理已启用' : '当前为直连模式') : '等待代理内核连接' }}
让连接,
更自由一些。
大陆与内网直连,其他访问交给你选择的节点。
{{ app.status?.settings.tunEnabled ? '透明代理' : '手动代理' }}IPv4{{ app.status?.settings.selectionMode === 'auto' ? '自动选节点' : '手动选节点' }}
DIRECTPROXYCONNECTED TO YOUR WORLD
+ {{ bytes(app.metrics.down) }}/s
累计下载 {{ bytes(app.metrics.downTotal) }}
{{ bytes(app.metrics.up) }}/s
累计上传 {{ bytes(app.metrics.upTotal) }}
{{ app.metrics.connections }}条
{{ app.metrics.coreOnline ? '正在监测内核连接' : '内核连接后显示实时数据' }}
可选节点
{{ app.status?.nodeCount ?? 0 }}个
来自 {{ app.status?.subscriptionCount ?? 0 }} 个订阅
+ 当前出口
{{ app.status?.settings.selectionMode === 'auto' ? '自动选择' : '手动选择' }}
{{ selected }}
{{ app.status?.coreOnline ? (app.status.selectedAvailable ? '当前内核使用的代理出口' : '选择一个节点以开始代理访问') : '启动内核后确认实际代理出口' }}
代理开关
内核版本{{ app.status?.version || '未连接' }}
管理服务运行{{ uptime(app.status?.uptimeSeconds ?? 0) }}
选择代理节点
+ 还没有订阅添加 Clash 订阅,发现可用的代理节点。
添加订阅{{ s.name }}{{ s.lastError ? '最近更新失败,保留已有节点' : date(s.updatedAt) }}
{{ s.nodeCount }} 个节点接入你的内网
PVE VM将测试设备的 IPv4 网关和 DNS 指向 VM 的固定地址,即可使用透明代理。
VM 地址{{ app.status?.settings.lanIP || '待配置' }}
上游网关{{ app.status?.settings.gateway || '原光猫地址' }}
查看网络配置与检查
+
diff --git a/web/src/views/Nodes.vue b/web/src/views/Nodes.vue
new file mode 100644
index 0000000..a0db03e
--- /dev/null
+++ b/web/src/views/Nodes.vue
@@ -0,0 +1,38 @@
+
+
+ 测试延迟自动选择
+ 所选节点「{{ nodeName(app.status?.settings.selectedNode || '') }}」已失效或已从订阅中移除。手动模式保持原选择,请选择其他节点或主动开启自动选择。
+ 当前选择 · {{ app.status?.settings.selectionMode === 'auto' ? '自动模式' : '手动模式' }}{{ nodeName(app.status?.actualNode || app.status?.settings.selectedNode || '') }}
{{ app.status?.coreOnline ? '内核已连接' : '等待内核' }}
+ 卡片列表
+ {{ filtered.length }} 个节点 · {{ nodes.filter(n => n.alive === true).length }} 个最近测试可用延迟测试按需执行,单次最多 500 个
+
+
+ {{ region(node.name) }}{{ node.name }}
{{ node.subscriptionName }}
{{ node.type.toUpperCase() }}UDP{{ delayText(node) }}
{{ node.selected ? '当前节点' : '选择节点' }}
+ 手动模式下,节点故障会提示而不会自动切换。你也可以主动选择自动模式,由内核约每 5 分钟检测,使用 50 ms 切换容差选择低延迟节点;延迟不代表下载带宽。
+
diff --git a/web/src/views/Rules.vue b/web/src/views/Rules.vue
new file mode 100644
index 0000000..04678ec
--- /dev/null
+++ b/web/src/views/Rules.vue
@@ -0,0 +1,27 @@
+
+
+ 添加规则
+
+ 自定义规则
优先于大陆规则库,按添加顺序匹配;内网保留直连优先级。
{{ rules.length }} 条默认分流已经就绪。需要特别处理某个网站时,在这里添加规则。
+ 默认匹配顺序
首条匹配生效0{{ index + 1 }}{{ step }}兜底
+ 大陆分类来自域名和 IP 规则库。若某个目标分类不符合你的需要,可用自定义规则修正。规则库更新在 VM 上运行 scripts/update-rules.sh。
+
+
diff --git a/web/src/views/Settings.vue b/web/src/views/Settings.vue
new file mode 100644
index 0000000..46a706a
--- /dev/null
+++ b/web/src/views/Settings.vue
@@ -0,0 +1,32 @@
+
+
+ 保存并应用
+ 网络与代理
在 PVE 的服务 VM 上配置,先验证单台客户端。
TUN 透明代理先在目标 VM 完成网络部署,再启用。
{{ network?.guidance || '当前光猫 DHCP 无法关闭,VM 不提供 DHCP。客户端需手动指定网关和 DNS。' }}Mihomo 服务
独立运行的代理内核,由 systemd 管理。
{{ app.status?.coreOnline ? '运行中' : '未连接' }}内核版本{{ app.status?.version || '—' }}
控制接口127.0.0.1:9090
正式 VM 安装服务控制辅助程序后,可在这里启停内核。
内核更新
按需检查 GitHub 稳定版,升级时保留配置与旧内核。
检查更新 {{ updateError }}最新稳定版{{ update.latestVersion }}
上次检查{{ new Date(update.checkedAt).toLocaleString('zh-CN') }}
{{ update.updateAvailable === true ? '发现新版本,可查看发布说明后手动升级。' : update.updateAvailable === false ? '当前版本不低于最新稳定版。' : '当前内核版本无法比较;启动稳定版内核后再检查。' }}
查看官方发布说明如何升级软件与内核
Web 管理程序和 Mihomo 分别升级。升级前导出备份;内核先下载到新文件、校验当前配置,再替换并重启。失败时恢复旧程序。
完整操作与恢复步骤见项目 docs/upgrading.md。
部署检查
此机器{{ check.label }}
重新检查DEPLOYMENT
开发机与服务 VM 分开
本机用于开发测试;正式代理服务部署到 PVE 上桥接至内网的 Linux VM。
- VM 固定地址放在光猫 DHCP 地址池之外。
- VM 上游网关指向光猫。
- 运行部署脚本准备转发和防火墙。
- 填写网络参数,启用 TUN。
- 手动配置一台设备的网关和 DNS。
部署细节见项目 README 与 docs/deployment.md
+
diff --git a/web/src/views/Subscriptions.vue b/web/src/views/Subscriptions.vue
new file mode 100644
index 0000000..b9b20f8
--- /dev/null
+++ b/web/src/views/Subscriptions.vue
@@ -0,0 +1,40 @@
+
+
+ 添加订阅
+ {{ items.length }} 个订阅 · Clash / Mihomo YAML刷新列表
+
+ toggle(item, v)" />
{{ item.name }}
{{ subscriptionHost(item.url) }}
{{ item.nodeCount }}个代理节点
每 {{ item.intervalHours }} 小时更新最近更新 {{ date(item.updatedAt) }}已用流量 {{ usage(item.usage) }}
{{ item.lastError }}已有节点和可用配置会保留。
+ 订阅提供节点,分流和 DNS 由 Clash Web 统一管理。支持多个订阅;更新失败时继续使用最后可用版本。
+
+
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..aa09e35
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "strict": true,
+ "jsx": "preserve",
+ "resolveJsonModule": true,
+ "esModuleInterop": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "types": ["vite/client"],
+ "skipLibCheck": true,
+ "noEmit": true
+ },
+ "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
new file mode 100644
index 0000000..f86c6a6
--- /dev/null
+++ b/web/vite.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+
+export default defineConfig({
+ plugins: [vue()],
+ server: { proxy: { '/api': 'http://127.0.0.1:8080' } },
+ build: { chunkSizeWarningLimit: 1100 },
+})