commit 8e0fa00a73c4512435b57379921403851e86d969 Author: lhc <2+lhc@noreply.localhost> Date: Mon Oct 5 17:32:00 2026 +0800 Initialize Clash Web source and deployment documentation 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 @@ + + Clash Web · 网络猫 + + + + + + + + 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 @@ + + 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 @@ + + + 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 @@ + + 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 @@ + + 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 @@ + + + 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 @@ + + + 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 @@ + + 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 @@ + + 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 @@ + + 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 @@ + + 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 }, +})