Initialize Clash Web source and deployment documentation

This commit is contained in:
lhc committed 2026-10-05 17:32:00 +08:00
commit 8e0fa00a73
66 files changed
+8532

No files matched your search

+38
View File
@@ -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
+25
View File
@@ -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
+58
View File
@@ -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。
+59
View File
@@ -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)
}
}
+28
View File
@@ -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
+23
View File
@@ -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
+44
View File
@@ -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;不会静默选择其他节点或直连。内核停止时,校验通过的配置可暂存以供下次启动;控制接口认证或通信失败不会当成停止状态忽略。
+130
View File
@@ -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 配置;避免将订阅令牌、配置备份或完整日志写入公开仓库。
+121
View File
@@ -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 或其他文件渠道传递,不作为源码提交。
+7
View File
@@ -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 测试组件不会打包进服务程序。
+92
View File
@@ -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 回退;数据库格式变更应按对应版本说明处理。
+24
View File
@@ -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
)
+59
View File
@@ -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=
+626
View File
@@ -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
}
}
}
+316
View File
@@ -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) }
+355
View File
@@ -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)
}
}
+235
View File
@@ -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, "会话创建失败")
}
}
+211
View File
@@ -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 "连接失败,请检查当前节点、网络和网站访问策略"
}
}
+180
View File
@@ -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, "", "<html>Welcome</html>", "reachable"},
{"header challenge", 403, "challenge", "verify", "challenge"},
{"body challenge", 200, "", "<title>Just a moment</title> 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")
}
}
+358
View File
@@ -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
}
+183
View File
@@ -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")
}
+74
View File
@@ -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))
}
+215
View File
@@ -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
}
+35
View File
@@ -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")
}
}
}
+219
View File
@@ -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, &copy)
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
}
+125
View File
@@ -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,
})
}
+84
View File
@@ -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")
}
}
}
+11
View File
@@ -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
+83
View File
@@ -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())
+48
View File
@@ -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}')
+34
View File
@@ -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')
+61
View File
@@ -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 <<EOF
CLASHWEB_LISTEN=$bind
CLASHWEB_DATA_DIR=/var/lib/clashweb
CLASHWEB_CORE_DIR=/var/lib/clashweb-core
CLASHWEB_MIHOMO_BIN=/usr/local/bin/mihomo
CLASHWEB_CONTROL_HELPER=/usr/local/libexec/clashweb-control
EOF
chmod 0644 /etc/clashweb/env
task_sudoers=$(mktemp)
trap 'rm -f "$task_sudoers"' EXIT
cat > "$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.'
+106
View File
@@ -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.')
+119
View File
@@ -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')
+102
View File
@@ -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()
+22
View File
@@ -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.'
+36
View File
@@ -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)
})
}
+147
View File
@@ -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<string, string>)[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([])
})
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#42b883" />
<link rel="icon" href="/favicon.svg?v=5" type="image/svg+xml" />
<title>Clash Web · 内网代理管理</title>
</head>
<body><div id="app"></div><script type="module" src="/src/main.ts"></script></body>
</html>
+2843
View File
File diff suppressed because it is too large. Load diff
+30
View File
@@ -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"
}
}
+9
View File
@@ -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' },
})
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Clash Web · 网络猫</title>
<circle cx="32" cy="32" r="32" fill="#42b883"/>
<path d="M16 28V17c0-2 1-3 3-2l9 7a24 24 0 0 1 8 0l9-7c2-1 3 0 3 2v11c3 4 4 8 2 13-3 7-10 11-18 11S17 48 14 41c-2-5-1-9 2-13Z" fill="#fff"/>
<g fill="#2d9d78">
<ellipse cx="24" cy="35" rx="4" ry="2.6" transform="rotate(18 24 35)"/>
<ellipse cx="40" cy="35" rx="4" ry="2.6" transform="rotate(-18 40 35)"/>
<path d="M30 41h4c.8 0 1.1.9.5 1.4l-1.8 1.5c-.4.3-1 .3-1.4 0l-1.8-1.5c-.6-.5-.3-1.4.5-1.4Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 580 B

+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NConfigProvider, NDialogProvider, NMessageProvider, darkTheme, zhCN, dateZhCN } from 'naive-ui'
import { useApp } from './stores/app'
import Shell from './components/Shell.vue'
const app = useApp()
const overrides = computed(() => ({
Button: { textColorPrimary: app.dark ? '#142b23' : '#103b2b', textColorHoverPrimary: '#103b2b', textColorPressedPrimary: '#103b2b' },
common: { primaryColor: app.dark ? '#65d6a3' : '#42b883', primaryColorHover: app.dark ? '#83e4ba' : '#54c795', primaryColorPressed: app.dark ? '#42b883' : '#2ea976', borderRadius: '8px', fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif' },
}))
</script>
<template>
<NConfigProvider :theme="app.dark ? darkTheme : null" :theme-overrides="overrides" :locale="zhCN" :date-locale="dateZhCN">
<NDialogProvider><NMessageProvider><Shell /></NMessageProvider></NDialogProvider>
</NConfigProvider>
</template>
+28
View File
@@ -0,0 +1,28 @@
let csrf = ''
export function setCSRF(value: string) { csrf = value }
export async function api<T>(path: string, method = 'GET', body?: unknown, signal?: AbortSignal): Promise<T> {
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<unknown>('/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)
}
+5
View File
@@ -0,0 +1,5 @@
<script setup lang="ts">
import { Globe2 } from '@lucide/vue'
defineProps<{ title: string; description: string }>()
</script>
<template><div class="empty-state"><div class="empty-icon"><slot name="icon"><Globe2 :size="27" /></slot></div><h3>{{ title }}</h3><p>{{ description }}</p><slot /></div></template>
+2
View File
@@ -0,0 +1,2 @@
<script setup lang="ts">defineProps<{ title: string; description: string; eyebrow?: string }>()</script>
<template><div class="page-header"><div><div v-if="eyebrow" class="eyebrow">{{ eyebrow }}</div><h1>{{ title }}</h1><p>{{ description }}</p></div><div class="page-actions"><slot /></div></div></template>
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { NButton, NSpin } from 'naive-ui'
import { LayoutDashboard, Rss, Globe2, Route, Activity, Radar, Settings2, Moon, Sun, Menu, X, LogOut, ArrowUpRight } from '@lucide/vue'
import { useApp } from '../stores/app'
import { useTask } from '../composables/task'
import Auth from '../views/Auth.vue'
const app = useApp()
const route = useRoute()
const mobileOpen = ref(false)
const { run } = useTask()
const nav = [
{ path: '/', label: '网络总览', icon: LayoutDashboard },
{ path: '/subscriptions', label: '订阅管理', icon: Rss },
{ path: '/nodes', label: '代理节点', icon: Globe2 },
{ path: '/connectivity', label: '连通性测试', icon: Radar },
{ path: '/rules', label: '分流规则', icon: Route },
{ path: '/activity', label: '连接与日志', icon: Activity },
{ path: '/settings', label: '系统设置', icon: Settings2 },
]
onMounted(() => void app.checkAuth())
</script>
<template>
<div :class="['application', { dark: app.dark }]">
<div v-if="!app.ready" class="loading-screen"><NSpin size="large" /><span>正在连接管理服务</span></div>
<Auth v-else-if="!app.authenticated" />
<template v-else>
<div v-if="mobileOpen" class="sidebar-overlay" @click="mobileOpen = false" />
<aside :class="['sidebar', { open: mobileOpen }]">
<RouterLink to="/" class="brand" @click="mobileOpen = false"><img class="brand-icon" src="/favicon.svg?v=5" alt="Clash Web 网络猫" /><span>Clash<span class="brand-light"> Web</span><small>YOUR NETWORK, YOUR WAY</small></span></RouterLink>
<button class="mobile-close icon-button" aria-label="关闭导航" @click="mobileOpen = false"><X :size="20" /></button>
<div class="nav-label">工作空间</div>
<nav><RouterLink v-for="item in nav" :key="item.path" :to="item.path" :class="['nav-item', { active: route.path === item.path }]" @click="mobileOpen = false"><component :is="item.icon" :size="19" /><span>{{ item.label }}</span><span v-if="item.path === '/nodes' && app.status?.nodeCount" class="nav-count">{{ app.status.nodeCount }}</span></RouterLink></nav>
<div class="sidebar-bottom">
<div class="engine-card"><div class="engine-top"><span :class="['status-dot', { online: app.status?.coreOnline }]" />Mihomo 内核<span class="engine-tag">CORE</span></div><p>{{ app.status?.coreOnline ? `已连接 · ${app.status.version}` : '等待内核连接' }}</p><RouterLink to="/settings">服务与网络设置<ArrowUpRight :size="14" /></RouterLink></div>
<div class="account"><div class="avatar">A</div><div><strong>管理员</strong><small>个人工作空间</small></div><button class="icon-button" aria-label="退出登录" @click="run(() => app.logout())"><LogOut :size="17" /></button></div>
</div>
</aside>
<div class="workspace">
<header class="topbar"><div class="breadcrumb"><button class="mobile-menu icon-button" aria-label="打开导航" @click="mobileOpen = true"><Menu :size="21" /></button><span>工作空间</span><span class="slash">/</span><strong>{{ route.meta.title }}</strong></div><div class="topbar-actions"><span class="local-badge"><span class="status-dot online" />内网管理</span><button class="theme-button" :aria-label="app.dark ? '切换浅色主题' : '切换深色主题'" @click="app.toggleTheme()"><Sun v-if="app.dark" :size="18" /><Moon v-else :size="18" /></button></div></header>
<main class="main-content"><div v-if="app.error" class="service-error">{{ app.error }} <NButton text size="small" @click="app.reload">重新连接</NButton></div><RouterView /></main>
<footer class="workspace-footer"><span>Clash Web <span class="footer-separator">·</span> 为你的内网而建</span><span><span :class="['status-dot', { online: app.streamConnected }]" />{{ app.streamConnected ? '实时状态已连接' : '正在连接实时状态' }}</span></footer>
</div>
</template>
</div>
</template>
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { init, type ECharts } from 'echarts/core'
import { LineChart } from 'echarts/charts'
import { GridComponent, TooltipComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import { use } from 'echarts/core'
import { useApp } from '../stores/app'
import { bytes } from '../format'
use([LineChart, GridComponent, TooltipComponent, CanvasRenderer])
const element = ref<HTMLDivElement>()
const app = useApp()
let chart: ECharts | undefined
let observer: ResizeObserver | undefined
function render() {
const text = app.dark ? '#9aafbd' : '#74858b'
chart?.setOption({
animation: false,
grid: { left: 60, right: 20, top: 25, bottom: 25 },
tooltip: { trigger: 'axis', valueFormatter: (value: number) => `${bytes(value)}/s`, borderWidth: 0, backgroundColor: app.dark ? '#1d2c39' : '#fff', textStyle: { color: app.dark ? '#fff' : '#35495e' } },
xAxis: { type: 'category', boundaryGap: false, data: app.history.map(m => new Date(m.time).toLocaleTimeString('zh-CN', { hour12: false })), axisLine: { show: false }, axisTick: { show: false }, axisLabel: { color: text, fontSize: 10, hideOverlap: true } },
yAxis: { type: 'value', splitNumber: 3, axisLabel: { color: text, fontSize: 10, formatter: (v: number) => `${bytes(v)}/s` }, splitLine: { lineStyle: { color: app.dark ? '#324553' : '#e1ebe7', type: 'dashed' } } },
series: [
{ name: '下载', type: 'line', data: app.history.map(m => m.down), smooth: .35, symbol: 'none', lineStyle: { width: 2, color: '#42b883' }, areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: 'rgba(66,184,131,.18)' }, { offset: 1, color: 'rgba(66,184,131,.01)' }] } } },
{ name: '上传', type: 'line', data: app.history.map(m => m.up), smooth: .35, symbol: 'none', lineStyle: { width: 2, color: '#a4b7c9' } },
],
})
}
onMounted(() => { if (element.value) { chart = init(element.value); observer = new ResizeObserver(() => chart?.resize()); observer.observe(element.value); render() } })
watch(() => [app.history, app.dark], render)
onBeforeUnmount(() => { observer?.disconnect(); chart?.dispose() })
</script>
<template><div ref="element" class="traffic-chart" role="img" aria-label="最近两分钟上传与下载速率" /></template>
+15
View File
@@ -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<unknown>, 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 }
}
+26
View File
@@ -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')
})
})
+31
View File
@@ -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'
}
+21
View File
@@ -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')
+63
View File
@@ -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<Status | null>(null)
const error = ref('')
const streamConnected = ref(false)
const metrics = ref<Metric>({ up: 0, down: 0, upTotal: 0, downTotal: 0, connections: 0, coreOnline: false, time: '' })
const history = ref<Metric[]>([])
const theme = ref(localStorage.getItem('cw-theme') || 'light')
const dark = computed(() => theme.value === 'dark')
let stream: EventSource | null = null
let timer: ReturnType<typeof setInterval> | 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>('/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 }
})
+193
View File
@@ -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; } }
+112
View File
@@ -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
}
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
import { NButton, NInput, NSelect, NTabs, NTabPane, useDialog } from 'naive-ui'
import { Search, Pause, Play, Trash2, X, Activity, Terminal, RefreshCw } from '@lucide/vue'
import { api } from '../api'
import { useTask } from '../composables/task'
import { useApp } from '../stores/app'
import { bytes } from '../format'
import type { Connection } from '../types'
import PageHeader from '../components/PageHeader.vue'
import EmptyState from '../components/EmptyState.vue'
const app = useApp(), dialog = useDialog(), { busy, run } = useTask()
const tab = ref('connections'), query = ref(''), paused = ref(false), level = ref('all'), coreError = ref(''), streamOnline = ref(false)
const connections = ref<Connection[]>([])
const logs = ref<{ type: string; payload: string; time: string }[]>([])
const filtered = computed(() => connections.value.filter(c => `${c.metadata.host} ${c.metadata.sourceIP} ${c.metadata.destinationIP} ${c.rule} ${c.chains.join(' ')}`.toLowerCase().includes(query.value.toLowerCase())))
const visibleLogs = computed(() => logs.value.filter(l => (level.value === 'all' || l.type === level.value) && l.payload.toLowerCase().includes(query.value.toLowerCase())))
let timer: ReturnType<typeof setInterval> | undefined, stream: EventSource | undefined
async function reload() { try { const data = await api<{ connections: Connection[] }>('/connections'); connections.value = data.connections || []; coreError.value = '' } catch (e) { coreError.value = (e as Error).message; connections.value = [] } }
function close(id?: string) { return run(async () => { await api(id ? `/connections/${encodeURIComponent(id)}` : '/connections', 'DELETE'); await reload() }, id ? '连接已关闭' : '全部连接已关闭') }
function closeAll() { dialog.warning({ title: '关闭全部连接', content: '现有连接将中断,应用通常会自动建立新连接。', positiveText: '关闭全部', negativeText: '取消', onPositiveClick: () => close() }) }
onMounted(() => {
void reload()
timer = setInterval(() => { if (!paused.value && tab.value === 'connections') void reload() }, 3000)
stream = new EventSource('/api/v1/logs')
stream.onopen = () => { streamOnline.value = true }
stream.addEventListener('log', (event) => { streamOnline.value = true; if (paused.value) return; const value = JSON.parse((event as MessageEvent).data); logs.value = [{ ...value, time: new Date().toLocaleTimeString('zh-CN', { hour12: false }) }, ...logs.value].slice(0, 500) })
stream.addEventListener('disconnected', () => { streamOnline.value = false })
stream.addEventListener('expired', () => { stream?.close(); void app.checkAuth() })
stream.onerror = () => { streamOnline.value = false }
})
onBeforeUnmount(() => { if (timer) clearInterval(timer); stream?.close() })
</script>
<template>
<PageHeader title="连接与日志" description="看见流量的去向,了解每一次连接。" eyebrow="LIVE ACTIVITY"><NButton @click="paused = !paused"><template #icon><Play v-if="paused" :size="16" /><Pause v-else :size="16" /></template>{{ paused ? '继续更新' : '暂停更新' }}</NButton></PageHeader>
<section class="panel activity-panel"><NTabs v-model:value="tab" type="line" animated><NTabPane name="connections" tab="实时连接"><div class="activity-toolbar"><NInput v-model:value="query" clearable placeholder="搜索域名、设备 IP、节点或规则"><template #prefix><Search :size="16" /></template></NInput><span class="muted nowrap">{{ filtered.length }} 条连接</span><NButton :disabled="busy || !connections.length" @click="closeAll"><template #icon><X :size="15" /></template>关闭全部</NButton><NButton quaternary aria-label="刷新连接" @click="reload"><template #icon><RefreshCw :size="17" /></template></NButton></div><EmptyState v-if="coreError" title="等待内核连接" :description="coreError"><template #icon><Activity :size="27" /></template></EmptyState><EmptyState v-else-if="!filtered.length" title="暂时没有活跃连接" description="流量经过 Mihomo 时,可以在这里查看目标、来源和命中规则。"><template #icon><Activity :size="27" /></template></EmptyState><div v-else class="table-scroll"><table class="data-table connections-table"><thead><tr><th>目标</th><th>来源设备</th><th>命中规则 / 出口</th><th>传输</th><th></th></tr></thead><tbody><tr v-for="c in filtered" :key="c.id"><td><strong>{{ c.metadata.host || c.metadata.destinationIP }}</strong><small>{{ c.metadata.network.toUpperCase() }} · {{ c.metadata.destinationIP }}:{{ c.metadata.destinationPort }}</small></td><td><code>{{ c.metadata.sourceIP }}</code><small>{{ c.metadata.type }}</small></td><td><span class="protocol-tag">{{ c.rule }}</span><small :title="c.chains.join(' → ')">{{ c.chains.map(n => n.split('/').slice(2).join('/') || n).join(' → ') }}</small></td><td><span>↓ {{ bytes(c.download) }}</span><small>↑ {{ bytes(c.upload) }}</small></td><td><NButton quaternary size="small" :disabled="busy" aria-label="关闭连接" @click="close(c.id)"><template #icon><X :size="15" /></template></NButton></td></tr></tbody></table></div></NTabPane><NTabPane name="logs" tab="内核日志"><div class="activity-toolbar"><NInput v-model:value="query" clearable placeholder="搜索日志内容"><template #prefix><Search :size="16" /></template></NInput><NSelect v-model:value="level" :options="[{ label: '全部级别', value: 'all' }, { label: '信息', value: 'info' }, { label: '警告', value: 'warning' }, { label: '错误', value: 'error' }]" class="log-level-select" /><NButton @click="logs = []"><template #icon><Trash2 :size="15" /></template>清空显示</NButton></div><div class="log-status"><span :class="['status-dot', { online: streamOnline }]" />{{ streamOnline ? '日志流已连接' : '正在等待内核日志' }}<span>保留最近 500 条,不写入数据库</span></div><div class="log-console"><div v-if="!visibleLogs.length" class="log-console-empty"><Terminal :size="25" /><span>等待新的内核日志…</span></div><div v-for="(log, index) in visibleLogs" :key="index" class="log-line"><time>{{ log.time }}</time><span :class="['log-level', log.type]">{{ log.type.toUpperCase() }}</span><span class="log-text">{{ log.payload }}</span></div></div></NTabPane></NTabs></section>
</template>
+13
View File
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { ref } from 'vue'
import { NButton, NInput } from 'naive-ui'
import { ArrowRight, ShieldCheck, Sun, Moon } from '@lucide/vue'
import { useApp } from '../stores/app'
import { useTask } from '../composables/task'
const app = useApp()
const password = ref('')
const token = ref('')
const { busy, run } = useTask()
const submit = () => run(() => app.login(password.value, token.value || undefined))
</script>
<template><div class="auth-screen"><button class="auth-theme theme-button" aria-label="切换主题" @click="app.toggleTheme()"><Sun v-if="app.dark" :size="20" /><Moon v-else :size="20" /></button><div class="auth-brand"><img class="brand-icon" src="/favicon.svg?v=5" alt="Clash Web 网络猫" /><span>Clash Web</span></div><div class="auth-card"><div class="eyebrow">YOUR NETWORK, YOUR WAY</div><h1>{{ app.initialized ? '欢迎回来。' : '从这里,连接世界。' }}</h1><p>{{ app.initialized ? '登录你的内网代理管理工作空间。' : '设置管理员密码,开启你的个人网络工作空间。' }}</p><form @submit.prevent="submit"><label v-if="!app.initialized" class="field-label">首次设置令牌<NInput v-model:value="token" type="password" show-password-on="click" placeholder="从服务启动日志复制令牌" autocomplete="off" /></label><label class="field-label">{{ app.initialized ? '管理员密码' : '设置管理员密码' }}<NInput v-model:value="password" type="password" show-password-on="click" :placeholder="app.initialized ? '输入你的密码' : '至少 12 字节,最多 72 字节'" :input-props="{ autocomplete: app.initialized ? 'current-password' : 'new-password' }" @keydown.enter.prevent="submit" /></label><div v-if="app.error" class="inline-error">{{ app.error }}</div><NButton type="primary" size="large" block :loading="busy" :disabled="!password || (!app.initialized && !token)" attr-type="submit">{{ app.initialized ? '进入工作空间' : '创建工作空间' }}<template #icon><ArrowRight :size="18" /></template></NButton></form><div class="auth-footnote"><ShieldCheck :size="15" /><span>自托管 · 数据保存在你的设备上</span></div><p v-if="!app.initialized" class="setup-hint">在 VM 上运行 <code>journalctl -u clashweb -n 30</code> 查看首次设置令牌。</p></div><div class="auth-bottom">简洁的界面,自由的网络。</div></div></template>
+101
View File
@@ -0,0 +1,101 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { NAlert, NButton, NProgress, useMessage } from 'naive-ui'
import { ArrowUpRight, Check, CircleAlert, Clock3, Globe2, Play, Radar, RotateCw, ShieldCheck, Square, X } from '@lucide/vue'
import { api } from '../api'
import { useApp } from '../stores/app'
import { nodeName } from '../format'
import type { ConnectivityResult, ConnectivitySite } from '../types'
import PageHeader from '../components/PageHeader.vue'
type TestState = 'idle' | 'queued' | 'testing' | 'cancelled' | ConnectivityResult['status']
const app = useApp(), message = useMessage()
const sites = ref<ConnectivitySite[]>([]), loading = ref(true), loadError = ref('')
const results = ref<Record<string, ConnectivityResult>>({}), states = ref<Record<string, TestState>>({})
const running = ref(false), total = ref(0), finished = ref(0), runNode = ref(''), runProxy = ref(true)
// Sort similar sites next to each other without adding visible categories.
const orderedSites = computed(() => {
const order = ['github', 'dockerhub', 'apple', 'google', 'netflix', 'youtube', 'chatgpt', 'claude', 'grok']
return [...sites.value].sort((a, b) => {
const rank = (id: string) => { const index = order.indexOf(id); return index < 0 ? order.length : index }
return rank(a.id) - rank(b.id)
})
})
let controller: AbortController | undefined
const counts = computed(() => ({
reachable: Object.values(states.value).filter(s => s === 'reachable').length,
limited: Object.values(states.value).filter(s => ['challenge', 'restricted'].includes(s)).length,
failed: Object.values(states.value).filter(s => ['failed', 'http_error'].includes(s)).length,
}))
const labels: Record<TestState, string> = { idle: '未测试', queued: '等待测试', testing: '连接中', cancelled: '已取消', reachable: '可访问', challenge: '需要验证', restricted: '访问受限', http_error: '响应异常', failed: '连接失败' }
const monograms: Record<string, string> = { apple: 'A', github: 'GH', google: 'G', netflix: 'N', youtube: '▶', chatgpt: 'GPT', claude: 'C', grok: 'x', dockerhub: 'DH' }
async function load() {
loading.value = true; loadError.value = ''
try { sites.value = await api<ConnectivitySite[]>('/system/connectivity') }
catch (error) { loadError.value = error instanceof Error ? error.message : '无法读取测试网站' }
finally { loading.value = false }
}
async function test(items: ConnectivitySite[]) {
if (running.value || !items.length) return
controller = new AbortController()
const signal = controller.signal
running.value = true; total.value = items.length; finished.value = 0
runNode.value = nodeName(app.status?.actualNode || app.status?.settings.selectedNode || '')
runProxy.value = app.status?.settings.proxyEnabled ?? true
for (const site of items) { states.value[site.id] = 'queued'; delete results.value[site.id] }
let next = 0
async function worker() {
while (!signal.aborted && next < items.length) {
const site = items[next++]!
states.value[site.id] = 'testing'
try {
const result = await api<ConnectivityResult>('/system/connectivity/test', 'POST', { siteId: site.id }, signal)
if (!signal.aborted) { results.value[site.id] = result; states.value[site.id] = result.status }
} catch (error) {
if (!signal.aborted) {
results.value[site.id] = { siteId: site.id, status: 'failed', detail: error instanceof Error ? error.message : '测试请求失败', httpStatus: 0, durationMS: 0, tlsVerified: false, checkedAt: new Date().toISOString() }
states.value[site.id] = 'failed'
}
} finally { if (!signal.aborted) finished.value++ }
}
}
await Promise.all(Array.from({ length: Math.min(3, items.length) }, worker))
for (const site of items) if (['queued', 'testing'].includes(states.value[site.id])) states.value[site.id] = 'cancelled'
running.value = false
if (!signal.aborted) message.success('连通性测试已完成')
}
function cancel() { controller?.abort() }
function hostname(value: string) { return new URL(value).hostname }
function time(value: string) { return new Date(value).toLocaleTimeString('zh-CN', { hour12: false }) }
onMounted(() => void load())
onBeforeUnmount(cancel)
</script>
<template>
<div class="connectivity-page">
<PageHeader title="连通性测试" description="检查常用网站,了解当前出口的连接情况。">
<NButton v-if="running" @click="cancel"><template #icon><Square :size="15" /></template>取消测试</NButton>
<NButton type="primary" :loading="running" :disabled="running || loading || !sites.length || !app.status?.coreOnline" @click="test(orderedSites)"><template #icon><Play :size="16" /></template>一键测试</NButton>
</PageHeader>
<NAlert v-if="!app.status?.coreOnline" type="warning" class="page-alert">Mihomo 内核未连接,请在系统设置中启动内核后测试。</NAlert>
<NAlert v-if="loadError" type="error" class="page-alert">{{ loadError }} <NButton text @click="load">重新加载</NButton></NAlert>
<section class="panel connectivity-overview">
<div class="connectivity-overview-top">
<div class="connectivity-intro"><span class="connectivity-radar"><Radar :size="29" /></span><div><h2>当前连接概况</h2><p>通过此 VM 的 Mihomo,遵循当前分流规则。</p></div><span class="protocol-tag">HTTPS</span></div>
<div class="connectivity-summary"><div><Check :size="18" /><strong>{{ counts.reachable }}</strong><span>可访问</span></div><div><ShieldCheck :size="18" /><strong>{{ counts.limited }}</strong><span>验证或受限</span></div><div><CircleAlert :size="18" /><strong>{{ counts.failed }}</strong><span>连接或响应异常</span></div></div>
</div>
<div class="connectivity-progress"><span>{{ running ? `正在测试 · ${finished} / ${total}` : total ? `本次完成 ${finished} / ${total}` : '按需测试 · 每个网站最长 12 秒 · 同时测试 3 个' }}</span><span>{{ total ? (runProxy ? `开始时所选节点:${runNode}` : '开始时代理关闭 · 直连模式') : '不会切换节点或修改网络配置' }}</span></div>
<NProgress v-if="total" type="line" :percentage="Math.round(finished / total * 100)" :show-indicator="false" :height="4" />
</section>
<div class="section-toolbar"><span>{{ sites.length }} 个常用网站</span><span class="muted small">切换节点后可重新测试</span></div>
<div class="connectivity-grid" :aria-busy="running">
<section v-for="site in orderedSites" :key="site.id" :class="['panel connectivity-card', states[site.id] || 'idle']" :data-site="site.id">
<div class="connectivity-card-heading"><span :class="['site-monogram', site.id]">{{ monograms[site.id] }}</span><div><h3>{{ site.name }}</h3><p>{{ hostname(site.url) }}</p></div><a :href="site.url" target="_blank" rel="noopener noreferrer" :aria-label="`打开 ${site.name} 官网`"><ArrowUpRight :size="17" /></a></div>
<div :class="['connectivity-state', states[site.id] || 'idle']"><RotateCw v-if="states[site.id] === 'testing'" class="spin" :size="17" /><Check v-else-if="states[site.id] === 'reachable'" :size="17" /><ShieldCheck v-else-if="states[site.id] === 'challenge'" :size="17" /><X v-else-if="['failed', 'http_error'].includes(states[site.id])" :size="17" /><CircleAlert v-else-if="states[site.id] === 'restricted'" :size="17" /><Clock3 v-else :size="17" /><strong>{{ labels[states[site.id] || 'idle'] }}</strong><span v-if="results[site.id]?.durationMS">{{ (results[site.id].durationMS / 1000).toFixed(2) }} s</span></div>
<p class="connectivity-detail" :title="results[site.id]?.detail">{{ results[site.id]?.detail || (states[site.id] === 'cancelled' ? '本次测试已取消,可重新测试。' : '检查主页的 HTTPS 响应情况。') }}</p>
<div class="connectivity-card-footer"><div class="connectivity-result-meta"><span v-if="results[site.id]?.httpStatus">HTTP {{ results[site.id].httpStatus }}<template v-if="results[site.id].tlsVerified"> · TLS 已验证</template></span><span>{{ results[site.id] ? time(results[site.id].checkedAt) : '尚无测试结果' }}</span></div><NButton size="small" :disabled="running || !app.status?.coreOnline" :aria-label="`测试 ${site.name}`" @click="test([site])"><template #icon><RotateCw :size="14" /></template>{{ results[site.id] ? '重试' : '测试' }}</NButton></div>
</section>
</div>
<div class="info-note"><Globe2 :size="18" /><p>测试的是 VM 到网站主页的连接,耗时包含建立连接和读取部分响应,不是节点延迟或带宽。网站可能要求浏览器验证、登录或地区授权;主页可访问不代表视频解锁、AI 对话或镜像拉取可用,也不能代替客户端网关与 TUN 接入测试。大陆规则命中的网站会直连。</p></div>
</div>
</template>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
import { NButton, NSwitch, NAlert } from 'naive-ui'
import { ArrowDownLeft, ArrowUpRight, Activity, Globe2, ChevronRight, RefreshCw, ShieldCheck, Radio, Rss, Route } from '@lucide/vue'
import { useApp } from '../stores/app'
import { useTask } from '../composables/task'
import { api } from '../api'
import { bytes, nodeName, uptime, date } from '../format'
import type { Subscription } from '../types'
import PageHeader from '../components/PageHeader.vue'
import TrafficChart from '../components/TrafficChart.vue'
const app = useApp()
const { busy, run } = useTask()
const subscriptions = ref<Subscription[]>([])
const selected = computed(() => nodeName(app.status?.actualNode || app.status?.settings.selectedNode || ''))
const proxy = computed(() => app.status?.settings.proxyEnabled ?? false)
async function reload() { await Promise.all([app.reload(), api<Subscription[]>('/subscriptions').then(v => subscriptions.value = v)]) }
async function toggle(value: boolean) { if (!app.status) return; await run(async () => { await api('/settings', 'PUT', { ...app.status!.settings, proxyEnabled: value }); await app.reload() }, value ? '已启用规则代理' : '已切换直连模式') }
onMounted(() => void run(reload))
</script>
<template>
<PageHeader title="网络总览" description="你的网络,一目了然。" eyebrow="NETWORK OVERVIEW"><NButton :loading="busy" @click="run(reload)"><template #icon><RefreshCw :size="16" /></template>刷新状态</NButton></PageHeader>
<NAlert v-if="app.status?.configError" type="error" class="page-alert">{{ app.status.configError }}</NAlert>
<NAlert v-if="app.status?.settings.tunEnabled && !app.status.tunActive" type="warning" class="page-alert">透明代理已配置,但尚未检测到运行中的 cw-tun 网卡。请检查目标 VM 的内核权限、服务日志与网络部署步骤。</NAlert>
<div class="overview-hero"><div class="hero-copy"><div class="hero-eyebrow"><span :class="['status-dot', { online: app.status?.coreOnline }]" />{{ app.status?.coreOnline ? (proxy ? '规则代理已启用' : '当前为直连模式') : '等待代理内核连接' }}</div><h2>让连接,<br />更自由一些。</h2><p>大陆与内网直连,其他访问交给你选择的节点。</p><div class="hero-pills"><span><ShieldCheck :size="14" />{{ app.status?.settings.tunEnabled ? '透明代理' : '手动代理' }}</span><span>IPv4</span><span>{{ app.status?.settings.selectionMode === 'auto' ? '自动选节点' : '手动选节点' }}</span></div></div><div class="hero-network" aria-hidden="true"><div class="orbit orbit-one" /><div class="orbit orbit-two" /><div class="network-center"><Globe2 :size="49" :stroke-width="1.25" /></div><div class="network-point point-one"><Radio :size="19" /></div><div class="network-point point-two"><ShieldCheck :size="20" /></div><div class="network-point point-three"><Route :size="19" /></div><span class="orbit-label label-one">DIRECT</span><span class="orbit-label label-two">PROXY</span><span class="network-coordinate">CONNECTED TO YOUR WORLD</span></div></div>
<div class="stat-grid"><div class="stat-card"><div class="stat-label">实时下载<span class="stat-icon green"><ArrowDownLeft :size="17" /></span></div><div class="stat-value">{{ bytes(app.metrics.down) }}<small>/s</small></div><p>累计下载 <strong>{{ bytes(app.metrics.downTotal) }}</strong></p></div><div class="stat-card"><div class="stat-label">实时上传<span class="stat-icon blue"><ArrowUpRight :size="17" /></span></div><div class="stat-value">{{ bytes(app.metrics.up) }}<small>/s</small></div><p>累计上传 <strong>{{ bytes(app.metrics.upTotal) }}</strong></p></div><div class="stat-card"><div class="stat-label">活跃连接<span class="stat-icon amber"><Activity :size="17" /></span></div><div class="stat-value">{{ app.metrics.connections }}<small>条</small></div><p>{{ app.metrics.coreOnline ? '正在监测内核连接' : '内核连接后显示实时数据' }}</p></div><div class="stat-card"><div class="stat-label">可选节点<span class="stat-icon purple"><Globe2 :size="17" /></span></div><div class="stat-value">{{ app.status?.nodeCount ?? 0 }}<small>个</small></div><p>来自 <strong>{{ app.status?.subscriptionCount ?? 0 }}</strong> 个订阅</p></div></div>
<div class="dashboard-grid"><section class="panel traffic-panel"><div class="panel-heading"><div><h3>流量趋势</h3><p>最近两分钟的实时传输速率</p></div><div class="chart-legend"><span><i class="legend-dot download" />下载</span><span><i class="legend-dot upload" />上传</span></div></div><TrafficChart /><div class="panel-foot"><span :class="['status-dot', { online: app.metrics.coreOnline }]" />{{ app.metrics.coreOnline ? '每 2 秒更新' : '连接内核后自动更新' }}<span class="foot-right">{{ app.history.length }} 个采样</span></div></section><section class="panel current-node"><div class="panel-heading"><h3>当前出口</h3><span class="small-tag">{{ app.status?.settings.selectionMode === 'auto' ? '自动选择' : '手动选择' }}</span></div><div class="current-node-symbol"><Globe2 :size="31" :stroke-width="1.5" /></div><h3 class="selected-name">{{ selected }}</h3><p class="selected-description">{{ app.status?.coreOnline ? (app.status.selectedAvailable ? '当前内核使用的代理出口' : '选择一个节点以开始代理访问') : '启动内核后确认实际代理出口' }}</p><div class="node-detail-row"><span>代理开关</span><NSwitch :value="proxy" :disabled="busy || !app.status" @update:value="toggle" /></div><div class="node-detail-row"><span>内核版本</span><strong>{{ app.status?.version || '未连接' }}</strong></div><div class="node-detail-row"><span>管理服务运行</span><strong>{{ uptime(app.status?.uptimeSeconds ?? 0) }}</strong></div><RouterLink to="/nodes" class="panel-link">选择代理节点<ChevronRight :size="16" /></RouterLink></section></div>
<div class="dashboard-bottom"><section class="panel"><div class="panel-heading"><h3>订阅概览</h3><RouterLink to="/subscriptions" class="text-link">管理订阅<ArrowUpRight :size="14" /></RouterLink></div><div v-if="!subscriptions.length" class="compact-empty"><Rss :size="24" /><div><strong>还没有订阅</strong><p>添加 Clash 订阅,发现可用的代理节点。</p></div><RouterLink to="/subscriptions">添加订阅<ChevronRight :size="15" /></RouterLink></div><div v-for="s in subscriptions.slice(0, 3)" :key="s.id" class="subscription-summary"><span class="summary-icon"><Rss :size="17" /></span><div><strong>{{ s.name }}</strong><small>{{ s.lastError ? '最近更新失败,保留已有节点' : date(s.updatedAt) }}</small></div><span class="small-tag">{{ s.nodeCount }} 个节点</span><span :class="['status-dot', { online: s.enabled && s.nodeCount > 0 }]" /></div></section><section class="panel quick-start"><div class="panel-heading"><h3>接入你的内网</h3><span class="small-tag">PVE VM</span></div><p>将测试设备的 IPv4 网关和 DNS 指向 VM 的固定地址,即可使用透明代理。</p><div class="network-values"><div><span>VM 地址</span><code>{{ app.status?.settings.lanIP || '待配置' }}</code></div><div><span>上游网关</span><code>{{ app.status?.settings.gateway || '原光猫地址' }}</code></div></div><RouterLink to="/settings" class="text-link">查看网络配置与检查<ArrowUpRight :size="14" /></RouterLink></section></div>
</template>
+38
View File
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { NButton, NInput, NSelect, NRadioGroup, NRadioButton, NAlert } from 'naive-ui'
import { Search, Gauge, Check, Globe2, WandSparkles, RefreshCw, ArrowUpRight, Radio } from '@lucide/vue'
import { api } from '../api'
import { useApp } from '../stores/app'
import { useTask } from '../composables/task'
import { region, nodeName } from '../format'
import type { Node } from '../types'
import PageHeader from '../components/PageHeader.vue'
import EmptyState from '../components/EmptyState.vue'
const app = useApp(), { busy, run } = useTask()
const nodes = ref<Node[]>([]), query = ref(''), subscription = ref('all'), sort = ref('default'), layout = ref('卡片')
const testing = ref('')
const groups = computed(() => [{ label: '全部订阅', value: 'all' }, ...Array.from(new Map(nodes.value.map(n => [n.subscriptionID, { label: n.subscriptionName, value: n.subscriptionID }])).values())])
const filtered = computed(() => {
const items = nodes.value.filter(n => (subscription.value === 'all' || n.subscriptionID === subscription.value) && `${n.name} ${n.type} ${n.subscriptionName}`.toLowerCase().includes(query.value.toLowerCase()))
return sort.value === 'delay' ? items.sort((a, b) => (a.delay && a.delay > 0 ? a.delay : Infinity) - (b.delay && b.delay > 0 ? b.delay : Infinity)) : items
})
const failedSelected = computed(() => app.status?.settings.selectionMode === 'manual' && !!app.status.settings.selectedNode && (nodes.value.find(n => n.id === app.status?.settings.selectedNode)?.alive === false || !nodes.value.some(n => n.id === app.status?.settings.selectedNode)))
const reload = async () => { nodes.value = await api<Node[]>('/nodes'); await app.reload() }
const select = (node?: Node) => run(async () => { await api('/nodes/select', 'POST', { mode: node ? 'manual' : 'auto', ...(node ? { id: node.id } : {}) }); await reload() }, node ? `已选择 ${node.name}` : '已启用自动选择')
async function test(items: Node[]) { testing.value = items.length === 1 ? items[0].id : 'all'; await run(async () => { const results = await api<Record<string, { delay: number; error: string }>>('/nodes/test', 'POST', { ids: items.slice(0, 500).map(n => n.id) }); for (const node of nodes.value) if (results[node.id]) { node.delay = results[node.id].delay; node.alive = !results[node.id].error && results[node.id].delay > 0 } }, '延迟测试已完成'); testing.value = '' }
function delayText(node: Node) { return node.alive === false ? '超时' : node.delay ? `${node.delay} ms` : '未测试' }
function delayClass(node: Node) { return node.alive === false ? 'bad' : node.delay && node.delay < 200 ? 'good' : node.delay ? 'fair' : 'unknown' }
onMounted(() => void run(reload))
</script>
<template>
<PageHeader title="代理节点" description="找到适合你的连接,选择一个出口。" eyebrow="PROXY NODES"><NButton :loading="busy && testing !== ''" :disabled="busy || !filtered.length || !app.status?.coreOnline" @click="test(filtered)"><template #icon><Gauge :size="17" /></template>测试延迟</NButton><NButton type="primary" :disabled="busy || !nodes.length" @click="select()"><template #icon><WandSparkles :size="16" /></template>自动选择</NButton></PageHeader>
<NAlert v-if="failedSelected" type="warning" class="page-alert">所选节点「{{ nodeName(app.status?.settings.selectedNode || '') }}」已失效或已从订阅中移除。手动模式保持原选择,请选择其他节点或主动开启自动选择。</NAlert>
<div class="selection-banner"><span class="selection-symbol"><Radio :size="21" /></span><div><span class="muted">当前选择 · {{ app.status?.settings.selectionMode === 'auto' ? '自动模式' : '手动模式' }}</span><strong>{{ nodeName(app.status?.actualNode || app.status?.settings.selectedNode || '') }}</strong></div><span :class="['status-label', { connected: app.status?.coreOnline }]">{{ app.status?.coreOnline ? '内核已连接' : '等待内核' }}</span></div>
<div class="nodes-toolbar"><NInput v-model:value="query" clearable placeholder="搜索节点、协议或订阅" class="node-search"><template #prefix><Search :size="16" /></template></NInput><NSelect v-model:value="subscription" :options="groups" class="subscription-select" /><NSelect v-model:value="sort" :options="[{ label: '默认排序', value: 'default' }, { label: '延迟从低到高', value: 'delay' }]" class="sort-select" /><NRadioGroup v-model:value="layout"><NRadioButton value="卡片">卡片</NRadioButton><NRadioButton value="列表">列表</NRadioButton></NRadioGroup><NButton quaternary :disabled="busy" aria-label="刷新节点" @click="run(reload)"><template #icon><RefreshCw :size="17" /></template></NButton></div>
<div class="section-toolbar"><span>{{ filtered.length }} 个节点<span class="muted"> · {{ nodes.filter(n => n.alive === true).length }} 个最近测试可用</span></span><span class="muted small">延迟测试按需执行,单次最多 500 个</span></div>
<section v-if="!nodes.length" class="panel"><EmptyState title="还没有可选节点" description="添加并更新 Clash 订阅,节点就会出现在这里。"><NButton type="primary" @click="$router.push('/subscriptions')">前往订阅管理<template #icon><ArrowUpRight :size="16" /></template></NButton></EmptyState></section>
<section v-else-if="!filtered.length" class="panel"><EmptyState title="没有匹配的节点" description="试试其他关键词,或切换订阅筛选条件。" /></section>
<div v-else :class="['node-grid', { 'node-list': layout === '列表' }]"><section v-for="node in filtered" :key="node.id" :class="['panel node-card', { selected: node.selected }]"><div class="node-card-main"><span :class="['region-icon', region(node.name).toLowerCase()]">{{ region(node.name) }}</span><div class="node-title"><h3>{{ node.name }}</h3><p>{{ node.subscriptionName }}</p></div><span v-if="node.selected" class="selected-check"><Check :size="14" /></span></div><div class="node-card-meta"><span class="protocol-tag">{{ node.type.toUpperCase() }}</span><span v-if="node.udp" class="protocol-tag subtle">UDP</span><span :class="['node-delay', delayClass(node)]"><i />{{ delayText(node) }}</span></div><div class="node-card-actions"><NButton size="small" :type="node.selected ? 'primary' : 'default'" :secondary="node.selected" :disabled="busy || node.selected" @click="select(node)">{{ node.selected ? '当前节点' : '选择节点' }}</NButton><NButton quaternary size="small" :loading="busy && testing === node.id" :disabled="busy || !app.status?.coreOnline" :aria-label="`测试 ${node.name}`" @click="test([node])"><template #icon><Gauge :size="16" /></template></NButton></div></section></div>
<div class="info-note"><Globe2 :size="17" /><p>手动模式下,节点故障会提示而不会自动切换。你也可以主动选择自动模式,由内核约每 5 分钟检测,使用 50 ms 切换容差选择低延迟节点;延迟不代表下载带宽。</p></div>
</template>
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { NButton, NInput, NModal, NSelect, NSwitch, useDialog } from 'naive-ui'
import { Plus, Route, ArrowDown, Pencil, Trash2, ShieldCheck, Globe2 } from '@lucide/vue'
import { api } from '../api'
import { useTask } from '../composables/task'
import type { Rule } from '../types'
import PageHeader from '../components/PageHeader.vue'
const { busy, run } = useTask(), dialog = useDialog()
const rules = ref<Rule[]>([]), modal = ref(false), editing = ref('')
const form = reactive<{ type: Rule['type']; value: string; action: Rule['action']; enabled: boolean }>({ type: 'DOMAIN-SUFFIX', value: '', action: 'DIRECT', enabled: true })
const types = [{ label: '域名后缀', value: 'DOMAIN-SUFFIX' }, { label: '完整域名', value: 'DOMAIN' }, { label: 'IPv4 网段', value: 'IP-CIDR' }]
const reload = async () => { const data = await api<{ custom: Rule[] }>('/rules'); rules.value = data.custom }
function open(rule?: Rule) { editing.value = rule?.id || ''; Object.assign(form, { type: rule?.type || 'DOMAIN-SUFFIX', value: rule?.value || '', action: rule?.action || 'DIRECT', enabled: rule?.enabled ?? true }); modal.value = true }
async function save() { await run(async () => { await api(editing.value ? `/rules/${editing.value}` : '/rules', editing.value ? 'PUT' : 'POST', form); modal.value = false; await reload() }, '规则已保存并应用') }
function toggle(rule: Rule, value: boolean) { return run(async () => { const { id, ...data } = rule; await api(`/rules/${id}`, 'PUT', { ...data, enabled: value }); await reload() }) }
function remove(rule: Rule) { dialog.warning({ title: '删除规则', content: `删除 ${rule.value} 的分流规则?`, positiveText: '删除', negativeText: '取消', onPositiveClick: () => run(async () => { await api(`/rules/${rule.id}`, 'DELETE'); await reload() }, '规则已删除') }) }
onMounted(() => void run(reload))
</script>
<template>
<PageHeader title="分流规则" description="让每一次访问,走适合它的路线。" eyebrow="ROUTING RULES"><NButton type="primary" @click="open()"><template #icon><Plus :size="17" /></template>添加规则</NButton></PageHeader>
<div class="routing-overview"><div class="routing-step"><span class="routing-icon"><ShieldCheck :size="21" /></span><div><strong>内网与大陆</strong><span>直接连接,保留本地速度</span></div><span class="route-badge direct">DIRECT</span></div><div class="routing-step"><span class="routing-icon proxy"><Globe2 :size="21" /></span><div><strong>其他目标</strong><span>通过当前选择的代理节点</span></div><span class="route-badge proxy">PROXY</span></div></div>
<section class="panel"><div class="panel-heading"><div><h3>自定义规则</h3><p>优先于大陆规则库,按添加顺序匹配;内网保留直连优先级。</p></div><span class="small-tag">{{ rules.length }} 条</span></div><div v-if="!rules.length" class="rule-empty"><Route :size="25" /><span>默认分流已经就绪。需要特别处理某个网站时,在这里添加规则。</span></div><div v-else class="table-scroll"><table class="data-table"><thead><tr><th>匹配类型</th><th>目标</th><th>动作</th><th>启用</th><th class="align-right">操作</th></tr></thead><tbody><tr v-for="rule in rules" :key="rule.id"><td><span class="protocol-tag">{{ rule.type }}</span></td><td><code>{{ rule.value }}</code></td><td><span :class="['route-badge', rule.action === 'DIRECT' ? 'direct' : 'proxy']">{{ rule.action === 'DIRECT' ? '直连' : '代理' }}</span></td><td><NSwitch :value="rule.enabled" :disabled="busy" @update:value="value => toggle(rule, value)" /></td><td class="align-right"><NButton quaternary size="small" :disabled="busy" aria-label="编辑规则" @click="open(rule)"><template #icon><Pencil :size="15" /></template></NButton><NButton quaternary size="small" :disabled="busy" aria-label="删除规则" @click="remove(rule)"><template #icon><Trash2 :size="15" /></template></NButton></td></tr></tbody></table></div></section>
<section class="panel rule-order"><div class="panel-heading"><h3>默认匹配顺序</h3><span class="muted small">首条匹配生效</span></div><div v-for="(step, index) in ['内网、回环及保留地址 → 直连', '自定义规则 → 按你的设置处理', '大陆域名与 IP 规则库 → 直连', '其余目标 → 当前代理节点']" :key="step" class="rule-order-step"><span class="step-number">0{{ index + 1 }}</span><span>{{ step }}</span><ArrowDown v-if="index < 3" :size="15" /><span v-else class="small-tag">兜底</span></div></section>
<div class="info-note"><Route :size="17" /><p>大陆分类来自域名和 IP 规则库。若某个目标分类不符合你的需要,可用自定义规则修正。规则库更新在 VM 上运行 <code>scripts/update-rules.sh</code>。</p></div>
<NModal v-model:show="modal" preset="card" :title="editing ? '编辑分流规则' : '添加分流规则'" class="form-modal"><form @submit.prevent="save"><label class="field-label">匹配类型<NSelect v-model:value="form.type" :options="types" /></label><label class="field-label">目标<NInput v-model:value="form.value" :placeholder="form.type === 'IP-CIDR' ? '例如 203.0.113.0/24' : '例如 example.com'" /></label><label class="field-label">访问方式<NSelect v-model:value="form.action" :options="[{ label: '直连 DIRECT', value: 'DIRECT' }, { label: '代理 PROXY', value: 'PROXY' }]" /></label><div class="form-switch-row"><span>启用规则</span><NSwitch v-model:value="form.enabled" /></div><div class="modal-actions"><NButton @click="modal = false">取消</NButton><NButton type="primary" :loading="busy" :disabled="!form.value.trim()" attr-type="submit">保存并应用</NButton></div></form></NModal>
</template>
+32
View File
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { NButton, NInput, NInputNumber, NSelect, NSwitch, NAlert, useDialog } from 'naive-ui'
import { Save, Play, Square, RotateCw, Download, Upload, CheckCircle2, Circle, Network as NetworkIcon, ShieldCheck, Server, ArrowUpRight } from '@lucide/vue'
import { api, downloadBackup, setCSRF } from '../api'
import { useApp } from '../stores/app'
import { useTask } from '../composables/task'
import type { CoreUpdate, Network, Settings } from '../types'
import PageHeader from '../components/PageHeader.vue'
const app = useApp(), dialog = useDialog(), { busy, run } = useTask()
const loaded = ref(false), network = ref<Network | null>(null), fileInput = ref<HTMLInputElement>()
const update = ref<CoreUpdate | null>(null), checkingUpdate = ref(false), updateError = ref('')
async function checkUpdate() {
checkingUpdate.value = true
updateError.value = ''
try { update.value = await api<CoreUpdate>('/system/updates') }
catch (error) { updateError.value = (error as Error).message }
finally { checkingUpdate.value = false }
}
const form = reactive<Settings>({ proxyEnabled: true, tunEnabled: false, selectionMode: 'manual', selectedNode: '', lanIP: '', lanCIDR: '', gateway: '', interface: '', mixedPort: 7890, dnsPort: 1053, testURL: 'https://www.gstatic.com/generate_204', testTimeoutMS: 5000 })
const credentials = reactive({ currentPassword: '', password: '' })
async function reload() { const [settings, data] = await Promise.all([api<Settings>('/settings'), api<Network>('/system/network')]); Object.assign(form, settings); network.value = data; loaded.value = true; await app.reload() }
const save = () => run(async () => { await api('/settings', 'PUT', form); await reload() }, '设置已校验并保存')
function service(action: string) { const execute = () => run(async () => { await api('/system/service', 'POST', { action }); await app.reload() }, '服务操作已完成'); if (action === 'stop') dialog.warning({ title: '停止代理内核', content: '使用此 VM 作为网关或 DNS 的设备可能无法上网。需要恢复时,请启动内核或将客户端网关和 DNS 改回光猫。', positiveText: '停止内核', negativeText: '取消', onPositiveClick: execute }); else void execute() }
const changePassword = () => run(async () => { const result = await api<{ csrf: string }>('/auth/password', 'PUT', credentials); setCSRF(result.csrf); credentials.currentPassword = ''; credentials.password = ''; app.checkAuth() }, '密码已更新,其他会话已退出')
async function restore(event: Event) { const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return; if (file.size > 16 * 1024 * 1024) { await run(async () => { throw new Error('备份文件不能超过 16 MiB') }); return } let backup: unknown; try { backup = JSON.parse(await file.text()) } catch { await run(async () => { throw new Error('备份不是有效 JSON 文件') }); return } dialog.warning({ title: '恢复配置备份', content: '将替换订阅、规则、节点选择和网络设置。管理员密码保持当前设置。加载失败会保留原配置。', positiveText: '恢复配置', negativeText: '取消', onPositiveClick: () => run(async () => { await api('/system/restore', 'POST', backup); await reload() }, '配置已恢复') }); (event.target as HTMLInputElement).value = '' }
onMounted(() => void run(reload))
</script>
<template>
<PageHeader title="系统设置" description="让服务稳定运行,让网络配置清晰可控。" eyebrow="SYSTEM SETTINGS"><NButton type="primary" :loading="busy" :disabled="!loaded" @click="save"><template #icon><Save :size="16" /></template>保存并应用</NButton></PageHeader>
<div class="settings-grid"><div class="settings-primary"><section class="panel settings-section"><div class="settings-heading"><span class="settings-icon"><NetworkIcon :size="20" /></span><div><h3>网络与代理</h3><p>在 PVE 的服务 VM 上配置,先验证单台客户端。</p></div></div><div class="setting-switch"><div><strong>规则代理</strong><p>关闭后切换为直连模式,管理界面继续运行。</p></div><NSwitch v-model:value="form.proxyEnabled" /></div><div class="setting-switch"><div><strong>TUN 透明代理</strong><p>先在目标 VM 完成网络部署,再启用。</p></div><NSwitch v-model:value="form.tunEnabled" /></div><div class="settings-fields"><label class="field-label">VM 固定 IPv4<NInput v-model:value="form.lanIP" placeholder="例如 192.168.1.10" /></label><label class="field-label">内网网段<NInput v-model:value="form.lanCIDR" placeholder="例如 192.168.1.0/24" /></label><label class="field-label">上游网关(光猫)<NInput v-model:value="form.gateway" placeholder="例如 192.168.1.1" /></label><label class="field-label">桥接网卡<NSelect v-model:value="form.interface" clearable :options="(network?.interfaces || []).map(i => ({ label: `${i.name} · ${i.addresses.join(', ') || '无 IPv4'}`, value: i.name }))" placeholder="选择目标 VM 网卡" @update:value="value => form.interface = value || ''" /></label><label class="field-label">HTTP / SOCKS 混合端口<NInputNumber v-model:value="form.mixedPort" :min="1024" :max="65535" /></label><label class="field-label">DNS 端口<NInputNumber v-model:value="form.dnsPort" :min="1" :max="65535" /><small>TUN 网关模式使用 53;开发测试使用 1053。</small></label></div><NAlert type="info" :show-icon="false">{{ network?.guidance || '当前光猫 DHCP 无法关闭,VM 不提供 DHCP。客户端需手动指定网关和 DNS。' }}</NAlert></section><section class="panel settings-section"><div class="settings-heading"><span class="settings-icon"><Server :size="20" /></span><div><h3>Mihomo 服务</h3><p>独立运行的代理内核,由 systemd 管理。</p></div><span :class="['status-label', { connected: app.status?.coreOnline }]">{{ app.status?.coreOnline ? '运行中' : '未连接' }}</span></div><div class="service-facts"><div><span>内核版本</span><code>{{ app.status?.version || '—' }}</code></div><div><span>控制接口</span><code>127.0.0.1:9090</code></div></div><div class="service-buttons"><NButton :disabled="busy || !network?.serviceControl" @click="service('start')"><template #icon><Play :size="15" /></template>启动</NButton><NButton :disabled="busy || !network?.serviceControl" @click="service('restart')"><template #icon><RotateCw :size="15" /></template>重启</NButton><NButton :disabled="busy || !network?.serviceControl" @click="service('stop')"><template #icon><Square :size="15" /></template>停止</NButton></div><p v-if="!network?.serviceControl" class="muted small">正式 VM 安装服务控制辅助程序后,可在这里启停内核。</p><div class="core-update-section"><div class="core-update-heading"><div><h4>内核更新</h4><p>按需检查 GitHub 稳定版,升级时保留配置与旧内核。</p></div><NButton size="small" :loading="checkingUpdate" @click="checkUpdate"><template #icon><RotateCw :size="15" /></template>检查更新</NButton></div><NAlert v-if="updateError" type="warning" class="update-alert">{{ updateError }}</NAlert><template v-if="update"><div class="service-facts"><div><span>最新稳定版</span><code>{{ update.latestVersion }}</code></div><div><span>上次检查</span><span>{{ new Date(update.checkedAt).toLocaleString('zh-CN') }}</span></div></div><p class="update-result">{{ update.updateAvailable === true ? '发现新版本,可查看发布说明后手动升级。' : update.updateAvailable === false ? '当前版本不低于最新稳定版。' : '当前内核版本无法比较;启动稳定版内核后再检查。' }}</p><a class="text-link" :href="update.releaseURL" target="_blank" rel="noopener noreferrer">查看官方发布说明<ArrowUpRight :size="14" /></a></template><details class="upgrade-guide"><summary>如何升级软件与内核</summary><p>Web 管理程序和 Mihomo 分别升级。升级前导出备份;内核先下载到新文件、校验当前配置,再替换并重启。失败时恢复旧程序。</p><p>完整操作与恢复步骤见项目 docs/upgrading.md。</p></details></div><div class="settings-fields test-settings"><label class="field-label">延迟测试地址<NInput v-model:value="form.testURL" /></label><label class="field-label">测试超时(毫秒)<NInputNumber v-model:value="form.testTimeoutMS" :min="1000" :max="15000" :step="1000" /></label></div></section><section class="panel settings-section"><div class="settings-heading"><span class="settings-icon"><ShieldCheck :size="20" /></span><div><h3>管理员密码</h3><p>更改后其他登录会话会退出。</p></div></div><form @submit.prevent="changePassword"><div clasLine truncated
</template>
+40
View File
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { NButton, NModal, NInput, NInputNumber, NSwitch, useDialog } from 'naive-ui'
import { Plus, RefreshCw, Rss, Pencil, Trash2, Clock3, ExternalLink } from '@lucide/vue'
import { api } from '../api'
import { useApp } from '../stores/app'
import { useTask } from '../composables/task'
import { subscriptionHost, date, bytes } from '../format'
import type { Subscription } from '../types'
import PageHeader from '../components/PageHeader.vue'
import EmptyState from '../components/EmptyState.vue'
const app = useApp(), dialog = useDialog()
const { busy, run } = useTask()
const items = ref<Subscription[]>([]), modal = ref(false), editing = ref('')
const form = reactive({ name: '', url: '', enabled: true, intervalHours: 6 })
const reload = async () => { items.value = await api<Subscription[]>('/subscriptions'); await app.reload() }
function open(item?: Subscription) { editing.value = item?.id || ''; Object.assign(form, { name: item?.name || '', url: item?.url || '', enabled: item?.enabled ?? true, intervalHours: item?.intervalHours || 6 }); modal.value = true }
async function save() {
const ok = await run(async () => {
const result = await api<{ id: string }>(editing.value ? `/subscriptions/${editing.value}` : '/subscriptions', editing.value ? 'PUT' : 'POST', form)
modal.value = false
await reload()
if (!editing.value) { await api(`/subscriptions/${result.id}/refresh`, 'POST', {}); await reload() }
}, editing.value ? '订阅设置已保存' : '订阅已添加并更新')
if (!ok) await reload().catch(() => {})
}
function remove(item: Subscription) { dialog.warning({ title: '删除订阅', content: `删除「${item.name}」及缓存节点?如果正在使用该订阅的节点,国外访问将停止,直到你选择其他节点。`, positiveText: '删除', negativeText: '取消', onPositiveClick: () => run(async () => { await api(`/subscriptions/${item.id}`, 'DELETE'); await reload() }, '订阅已删除') }) }
function refresh(item: Subscription) { return run(async () => { try { await api(`/subscriptions/${item.id}/refresh`, 'POST', {}) } finally { await reload() } }, '订阅已更新') }
function toggle(item: Subscription, value: boolean) { return run(async () => { await api(`/subscriptions/${item.id}`, 'PUT', { name: item.name, url: item.url, intervalHours: item.intervalHours, enabled: value }); await reload() }, '订阅状态已更新') }
function usage(raw: string) { const fields = Object.fromEntries(raw.split(';').map(x => x.trim().split('='))); const used = Number(fields.upload || 0) + Number(fields.download || 0); const total = Number(fields.total || 0); return total ? `${bytes(used)} / ${bytes(total)}` : '' }
onMounted(() => void run(reload))
</script>
<template>
<PageHeader title="订阅管理" description="集中管理订阅,让节点始终保持更新。" eyebrow="SUBSCRIPTIONS"><NButton type="primary" @click="open()"><template #icon><Plus :size="17" /></template>添加订阅</NButton></PageHeader>
<div class="section-toolbar"><span>{{ items.length }} 个订阅 <span class="muted">· Clash / Mihomo YAML</span></span><NButton text :loading="busy" @click="run(reload)"><template #icon><RefreshCw :size="15" /></template>刷新列表</NButton></div>
<section v-if="!items.length" class="panel"><EmptyState title="你的节点,从一个订阅开始" description="粘贴 Clash 订阅地址,我们会读取节点并保持定时更新。"><template #icon><Rss :size="28" /></template><NButton type="primary" @click="open()">添加第一个订阅</NButton></EmptyState></section>
<div v-else class="subscription-grid"><section v-for="item in items" :key="item.id" :class="['panel subscription-card', { disabled: !item.enabled }]"><div class="subscription-card-top"><span class="subscription-icon"><Rss :size="22" /></span><NSwitch :value="item.enabled" :disabled="busy" :aria-label="`启用 ${item.name}`" @update:value="v => toggle(item, v)" /></div><h3>{{ item.name }}</h3><p class="subscription-host"><ExternalLink :size="13" />{{ subscriptionHost(item.url) }}</p><div class="subscription-node-count">{{ item.nodeCount }}<span>个代理节点</span></div><div class="subscription-facts"><span><Clock3 :size="14" />每 {{ item.intervalHours }} 小时更新</span><span>最近更新 {{ date(item.updatedAt) }}</span><span v-if="usage(item.usage)">已用流量 {{ usage(item.usage) }}</span></div><div v-if="item.lastError" class="inline-error subscription-error">{{ item.lastError }}<small>已有节点和可用配置会保留。</small></div><div class="subscription-card-actions"><NButton size="small" :loading="busy || item.refreshing" @click="refresh(item)"><template #icon><RefreshCw :size="14" /></template>更新订阅</NButton><NButton quaternary size="small" :disabled="busy" :aria-label="`编辑 ${item.name}`" @click="open(item)"><template #icon><Pencil :size="15" /></template></NButton><NButton quaternary size="small" :disabled="busy" :aria-label="`删除 ${item.name}`" @click="remove(item)"><template #icon><Trash2 :size="15" /></template></NButton></div></section></div>
<div class="info-note"><Rss :size="17" /><p>订阅提供节点,分流和 DNS 由 Clash Web 统一管理。支持多个订阅;更新失败时继续使用最后可用版本。</p></div>
<NModal v-model:show="modal" preset="card" :title="editing ? '编辑订阅' : '添加订阅'" class="form-modal" :mask-closable="!busy"><form @submit.prevent="save"><label class="field-label">订阅名称<NInput v-model:value="form.name" placeholder="例如:我的主力订阅" :maxlength="160" /></label><label class="field-label">Clash 订阅地址<NInput v-model:value="form.url" type="textarea" placeholder="https://example.com/subscribe?token=…" :autosize="{ minRows: 2, maxRows: 4 }" /></label><label class="field-label">自动更新间隔(小时)<NInputNumber v-model:value="form.intervalHours" :min="1" :max="720" /></label><div class="form-switch-row"><span>启用此订阅</span><NSwitch v-model:value="form.enabled" /></div><div class="modal-actions"><NButton :disabled="busy" @click="modal = false">取消</NButton><NButton type="primary" :loading="busy" :disabled="!form.name.trim() || !form.url.trim()" attr-type="submit">{{ editing ? '保存设置' : '添加并更新' }}</NButton></div></form></NModal>
</template>
+16
View File
@@ -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"]
}
+8
View File
@@ -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 },
})