Initialize Clash Web source and deployment documentation
This commit is contained in:
commit
8e0fa00a73
66 files changed
+8532
No files matched your search
+38
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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。
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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;不会静默选择其他节点或直连。内核停止时,校验通过的配置可暂存以供下次启动;控制接口认证或通信失败不会当成停止状态忽略。
|
||||||
@@ -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 配置;避免将订阅令牌、配置备份或完整日志写入公开仓库。
|
||||||
@@ -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 或其他文件渠道传递,不作为源码提交。
|
||||||
@@ -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 测试组件不会打包进服务程序。
|
||||||
@@ -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 回退;数据库格式变更应按对应版本说明处理。
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, "会话创建失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 "连接失败,请检查当前节点、网络和网站访问策略"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ProxyGroup = "CW-PROXY"
|
||||||
|
const AutoGroup = "CW-AUTO"
|
||||||
|
|
||||||
|
type Subscription struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
IntervalHours int `json:"intervalHours"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
AttemptedAt string `json:"attemptedAt"`
|
||||||
|
LastError string `json:"lastError"`
|
||||||
|
Usage string `json:"usage,omitempty"`
|
||||||
|
Nodes []map[string]any `json:"nodes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Rule struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Settings struct {
|
||||||
|
ProxyEnabled bool `json:"proxyEnabled"`
|
||||||
|
TunEnabled bool `json:"tunEnabled"`
|
||||||
|
SelectionMode string `json:"selectionMode"`
|
||||||
|
SelectedNode string `json:"selectedNode"`
|
||||||
|
LANIP string `json:"lanIP"`
|
||||||
|
LANCIDR string `json:"lanCIDR"`
|
||||||
|
Gateway string `json:"gateway"`
|
||||||
|
Interface string `json:"interface"`
|
||||||
|
MixedPort int `json:"mixedPort"`
|
||||||
|
DNSPort int `json:"dnsPort"`
|
||||||
|
TestURL string `json:"testURL"`
|
||||||
|
TestTimeoutMS int `json:"testTimeoutMS"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type State struct {
|
||||||
|
Subscriptions []Subscription `json:"subscriptions"`
|
||||||
|
Rules []Rule `json:"rules"`
|
||||||
|
Settings Settings `json:"settings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Backup struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
ExportedAt string `json:"exportedAt"`
|
||||||
|
State State `json:"state"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultState() State {
|
||||||
|
return State{Subscriptions: []Subscription{}, Rules: []Rule{}, Settings: Settings{
|
||||||
|
ProxyEnabled: true, SelectionMode: "manual", MixedPort: 7890, DNSPort: 1053,
|
||||||
|
TestURL: "https://www.gstatic.com/generate_204", TestTimeoutMS: 5000,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneState(s State) State {
|
||||||
|
b, _ := json.Marshal(s)
|
||||||
|
var copy State
|
||||||
|
_ = json.Unmarshal(b, ©)
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
|
||||||
|
func validHTTPURL(raw string) error {
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil || u.Hostname() == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.Fragment != "" {
|
||||||
|
return errors.New("请输入有效的 HTTP 或 HTTPS 地址(不支持 userinfo 或 URL fragment)")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSubscription(s Subscription) error {
|
||||||
|
if strings.TrimSpace(s.Name) == "" || len(s.Name) > 160 {
|
||||||
|
return errors.New("订阅名称不能为空,且不能超过 160 字节")
|
||||||
|
}
|
||||||
|
if s.IntervalHours < 1 || s.IntervalHours > 720 {
|
||||||
|
return errors.New("更新间隔应为 1–720 小时")
|
||||||
|
}
|
||||||
|
return validHTTPURL(s.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
var interfacePattern = regexp.MustCompile(`^[a-zA-Z0-9_.:-]{1,15}$`)
|
||||||
|
var domainPattern = regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?$`)
|
||||||
|
|
||||||
|
func validateSettings(s Settings) error {
|
||||||
|
if s.SelectionMode != "manual" && s.SelectionMode != "auto" {
|
||||||
|
return errors.New("无效的节点选择模式")
|
||||||
|
}
|
||||||
|
if s.MixedPort < 1024 || s.MixedPort > 65535 || s.DNSPort < 1 || s.DNSPort > 65535 || s.MixedPort == s.DNSPort || s.MixedPort == 9090 || s.DNSPort == 9090 || s.MixedPort == 8080 || s.DNSPort == 8080 {
|
||||||
|
return errors.New("代理端口应为 1024–65535,DNS 端口为 1–65535,且不能与管理端口或控制端口冲突")
|
||||||
|
}
|
||||||
|
if s.TestTimeoutMS < 1000 || s.TestTimeoutMS > 15000 {
|
||||||
|
return errors.New("测速超时应为 1000–15000 毫秒")
|
||||||
|
}
|
||||||
|
if err := validHTTPURL(s.TestURL); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.Interface != "" && !interfacePattern.MatchString(s.Interface) {
|
||||||
|
return errors.New("无效的网卡名称")
|
||||||
|
}
|
||||||
|
for label, raw := range map[string]string{"VM 地址": s.LANIP, "上游网关": s.Gateway} {
|
||||||
|
if raw != "" {
|
||||||
|
ip, err := netip.ParseAddr(raw)
|
||||||
|
if err != nil || !ip.Is4() || !ip.IsPrivate() {
|
||||||
|
return fmt.Errorf("%s应为内网 IPv4 地址", label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.LANCIDR != "" {
|
||||||
|
prefix, err := netip.ParsePrefix(s.LANCIDR)
|
||||||
|
if err != nil || !prefix.Addr().Is4() || !prefix.Addr().IsPrivate() || prefix.Bits() < 8 || prefix.Bits() > 30 {
|
||||||
|
return errors.New("请输入有效的内网 IPv4 网段,例如 192.168.1.0/24")
|
||||||
|
}
|
||||||
|
if s.LANIP != "" {
|
||||||
|
ip, _ := netip.ParseAddr(s.LANIP)
|
||||||
|
if !prefix.Contains(ip) {
|
||||||
|
return errors.New("VM 地址不在内网网段内")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.Gateway != "" {
|
||||||
|
ip, _ := netip.ParseAddr(s.Gateway)
|
||||||
|
if !prefix.Contains(ip) {
|
||||||
|
return errors.New("上游网关不在内网网段内")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.LANIP != "" && s.LANIP == s.Gateway {
|
||||||
|
return errors.New("VM 的上游网关必须指向原路由器,不能指向自己")
|
||||||
|
}
|
||||||
|
if s.TunEnabled && (s.LANIP == "" || s.LANCIDR == "" || s.Gateway == "" || s.Interface == "" || s.DNSPort != 53) {
|
||||||
|
return errors.New("透明代理需要填写 VM 地址、网段、上游网关、网卡,并把 DNS 端口设为 53;先完成 Linux 网络部署步骤")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateRule(r Rule) error {
|
||||||
|
if r.Action != "DIRECT" && r.Action != "PROXY" {
|
||||||
|
return errors.New("规则动作应为直连或代理")
|
||||||
|
}
|
||||||
|
if r.Type == "IP-CIDR" {
|
||||||
|
p, err := netip.ParsePrefix(r.Value)
|
||||||
|
if err != nil || !p.Addr().Is4() {
|
||||||
|
return errors.New("请输入有效的 IPv4 网段")
|
||||||
|
}
|
||||||
|
} else if r.Type == "DOMAIN" || r.Type == "DOMAIN-SUFFIX" {
|
||||||
|
if len(r.Value) > 253 || !domainPattern.MatchString(r.Value) || strings.Contains(r.Value, "..") {
|
||||||
|
return errors.New("请输入域名,不要包含协议、端口或路径")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return errors.New("不支持此规则类型")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateState(s State) error {
|
||||||
|
if len(s.Subscriptions) > 50 || len(s.Rules) > 1000 {
|
||||||
|
return errors.New("最多允许 50 个订阅、1000 条规则")
|
||||||
|
}
|
||||||
|
if err := validateSettings(s.Settings); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ids := map[string]bool{}
|
||||||
|
for _, sub := range s.Subscriptions {
|
||||||
|
if !validID(sub.ID) || ids[sub.ID] {
|
||||||
|
return errors.New("订阅 ID 无效或重复")
|
||||||
|
}
|
||||||
|
ids[sub.ID] = true
|
||||||
|
if err := validateSubscription(sub); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(sub.Nodes) > 5000 {
|
||||||
|
return errors.New("单个订阅节点过多")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ids = map[string]bool{}
|
||||||
|
for _, rule := range s.Rules {
|
||||||
|
if !validID(rule.ID) || ids[rule.ID] {
|
||||||
|
return errors.New("规则 ID 无效或重复")
|
||||||
|
}
|
||||||
|
ids[rule.ID] = true
|
||||||
|
if err := validateRule(rule); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validID(s string) bool { return regexp.MustCompile(`^[a-f0-9]{24}$`).MatchString(s) }
|
||||||
|
func now() string { return time.Now().UTC().Format(time.RFC3339) }
|
||||||
|
|
||||||
|
func interfaceExists(name string, ip string) bool {
|
||||||
|
i, err := net.InterfaceByName(name)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
addresses, _ := i.Addrs()
|
||||||
|
for _, a := range addresses {
|
||||||
|
if strings.Split(a.String(), "/")[0] == ip {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+11
@@ -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
|
||||||
@@ -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())
|
||||||
Executable
+48
@@ -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}')
|
||||||
Executable
+34
@@ -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')
|
||||||
Executable
+61
@@ -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.'
|
||||||
Executable
+106
@@ -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.')
|
||||||
@@ -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')
|
||||||
@@ -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()
|
||||||
Executable
+22
@@ -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.'
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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([])
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
Generated
+2843
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
|
})
|
||||||
@@ -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 |
@@ -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>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
@@ -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 }
|
||||||
|
})
|
||||||
@@ -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; } }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
})
|
||||||
Reference in new issue
Block a user