This commit is contained in:
@@ -21,6 +21,11 @@ jobs:
|
|||||||
go-version: '1.24'
|
go-version: '1.24'
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
|
- name: Install UPX
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y upx-ucl
|
||||||
|
|
||||||
- name: Build all platforms
|
- name: Build all platforms
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
@@ -55,6 +60,13 @@ jobs:
|
|||||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" \
|
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" \
|
||||||
-o dist/client-windows-amd64.exe ./cmd/client
|
-o dist/client-windows-amd64.exe ./cmd/client
|
||||||
|
|
||||||
|
- name: Compress with UPX
|
||||||
|
run: |
|
||||||
|
# UPX 压缩 Linux 和 Windows 二进制 (macOS 不支持)
|
||||||
|
upx -9 dist/server-linux-amd64 dist/client-linux-amd64 || true
|
||||||
|
upx -9 dist/server-linux-arm64 dist/client-linux-arm64 || true
|
||||||
|
upx -9 dist/server-windows-amd64.exe dist/client-windows-amd64.exe || true
|
||||||
|
|
||||||
- name: List artifacts
|
- name: List artifacts
|
||||||
run: ls -lah dist/
|
run: ls -lah dist/
|
||||||
|
|
||||||
|
|||||||
238
README.md
238
README.md
@@ -15,8 +15,9 @@ GoTunnel 是一个类似 frp 的内网穿透解决方案,核心特点是**服
|
|||||||
|------|----------|-----|
|
|------|----------|-----|
|
||||||
| 配置管理 | 服务端集中管理 | 客户端各自配置 |
|
| 配置管理 | 服务端集中管理 | 客户端各自配置 |
|
||||||
| TLS 证书 | 自动生成,零配置 | 需手动配置 |
|
| TLS 证书 | 自动生成,零配置 | 需手动配置 |
|
||||||
| 管理界面 | 内置 Web 控制台 | 需额外部署 Dashboard |
|
| 管理界面 | 内置 Web 控制台 (naive-ui) | 需额外部署 Dashboard |
|
||||||
| 客户端部署 | 仅需 3 个参数 | 需配置文件 |
|
| 客户端部署 | 仅需 2 个参数 | 需配置文件 |
|
||||||
|
| 客户端 ID | 可选,服务端自动分配 | 需手动配置 |
|
||||||
|
|
||||||
### 架构设计
|
### 架构设计
|
||||||
|
|
||||||
@@ -104,7 +105,14 @@ go build -o client ./cmd/client
|
|||||||
### 客户端启动
|
### 客户端启动
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 最简启动(ID 由服务端自动分配)
|
||||||
|
./client -s <服务器IP>:7000 -t <Token>
|
||||||
|
|
||||||
|
# 指定客户端 ID
|
||||||
./client -s <服务器IP>:7000 -t <Token> -id <客户端ID>
|
./client -s <服务器IP>:7000 -t <Token> -id <客户端ID>
|
||||||
|
|
||||||
|
# 禁用 TLS(需服务端也禁用)
|
||||||
|
./client -s <服务器IP>:7000 -t <Token> -no-tls
|
||||||
```
|
```
|
||||||
|
|
||||||
**参数说明:**
|
**参数说明:**
|
||||||
@@ -113,7 +121,7 @@ go build -o client ./cmd/client
|
|||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `-s` | 服务器地址 (ip:port) | 是 |
|
| `-s` | 服务器地址 (ip:port) | 是 |
|
||||||
| `-t` | 认证 Token | 是 |
|
| `-t` | 认证 Token | 是 |
|
||||||
| `-id` | 客户端 ID(需与服务端配置匹配) | 否(自动生成) |
|
| `-id` | 客户端 ID | 否(服务端自动分配) |
|
||||||
| `-no-tls` | 禁用 TLS 加密 | 否 |
|
| `-no-tls` | 禁用 TLS 加密 | 否 |
|
||||||
|
|
||||||
## 配置系统
|
## 配置系统
|
||||||
@@ -189,6 +197,7 @@ web:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "client-a",
|
"id": "client-a",
|
||||||
|
"nickname": "办公室电脑",
|
||||||
"rules": [
|
"rules": [
|
||||||
{"name": "web", "type": "tcp", "local_ip": "127.0.0.1", "local_port": 80, "remote_port": 8080},
|
{"name": "web", "type": "tcp", "local_ip": "127.0.0.1", "local_port": 80, "remote_port": 8080},
|
||||||
{"name": "dns", "type": "udp", "local_ip": "127.0.0.1", "local_port": 53, "remote_port": 5353},
|
{"name": "dns", "type": "udp", "local_ip": "127.0.0.1", "local_port": 53, "remote_port": 5353},
|
||||||
@@ -211,22 +220,23 @@ GoTunnel/
|
|||||||
│ │ ├── config/ # 配置管理
|
│ │ ├── config/ # 配置管理
|
||||||
│ │ ├── db/ # 数据库存储
|
│ │ ├── db/ # 数据库存储
|
||||||
│ │ ├── app/ # Web 服务
|
│ │ ├── app/ # Web 服务
|
||||||
│ │ ├── router/ # API 路由
|
│ │ └── router/ # API 路由
|
||||||
│ │ └── plugin/ # 服务端插件管理
|
|
||||||
│ └── client/
|
│ └── client/
|
||||||
│ ├── tunnel/ # 客户端隧道
|
│ └── tunnel/ # 客户端隧道
|
||||||
│ └── plugin/ # 客户端插件管理和缓存
|
|
||||||
├── pkg/
|
├── pkg/
|
||||||
│ ├── protocol/ # 通信协议
|
│ ├── protocol/ # 通信协议
|
||||||
│ ├── crypto/ # TLS 加密
|
│ ├── crypto/ # TLS 加密
|
||||||
│ ├── proxy/ # 代理服务器
|
│ ├── proxy/ # 代理服务器
|
||||||
│ ├── relay/ # 数据转发
|
│ ├── relay/ # 数据转发
|
||||||
|
│ ├── auth/ # JWT 认证
|
||||||
│ ├── utils/ # 工具函数
|
│ ├── utils/ # 工具函数
|
||||||
│ └── plugin/ # 插件系统核心
|
│ └── plugin/ # 插件系统核心
|
||||||
│ ├── builtin/ # 内置插件 (socks5)
|
│ ├── builtin/ # 内置插件 (socks5)
|
||||||
│ ├── wasm/ # WASM 运行时 (wazero)
|
│ ├── wasm/ # WASM 运行时 (wazero)
|
||||||
│ └── store/ # 插件持久化 (SQLite)
|
│ └── store/ # 插件持久化 (SQLite)
|
||||||
├── web/ # Vue 3 前端
|
├── web/ # Vue 3 + naive-ui 前端
|
||||||
|
├── scripts/ # 构建脚本
|
||||||
|
│ └── build.sh # 跨平台构建脚本
|
||||||
└── go.mod
|
└── go.mod
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -258,7 +268,22 @@ type ProxyHandler interface {
|
|||||||
|
|
||||||
## Web API
|
## Web API
|
||||||
|
|
||||||
Web 控制台提供 RESTful API 用于管理客户端和配置。
|
Web 控制台提供 RESTful API 用于管理客户端和配置。配置了 `username` 和 `password` 后,API 需要 JWT 认证。
|
||||||
|
|
||||||
|
### 认证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 登录获取 Token
|
||||||
|
POST /api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
{"username": "admin", "password": "password"}
|
||||||
|
|
||||||
|
# 响应
|
||||||
|
{"token": "eyJhbGciOiJIUzI1NiIs..."}
|
||||||
|
|
||||||
|
# 后续请求携带 Token
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
### 客户端管理
|
### 客户端管理
|
||||||
|
|
||||||
@@ -266,23 +291,45 @@ Web 控制台提供 RESTful API 用于管理客户端和配置。
|
|||||||
# 获取所有客户端
|
# 获取所有客户端
|
||||||
GET /api/clients
|
GET /api/clients
|
||||||
|
|
||||||
# 添加客户端
|
|
||||||
POST /api/clients
|
|
||||||
Content-Type: application/json
|
|
||||||
{"id": "client-a", "rules": [...]}
|
|
||||||
|
|
||||||
# 获取单个客户端
|
# 获取单个客户端
|
||||||
GET /api/client/{id}
|
GET /api/client/{id}
|
||||||
|
|
||||||
# 更新客户端规则
|
# 更新客户端(昵称和规则)
|
||||||
PUT /api/client/{id}
|
PUT /api/client/{id}
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
{"rules": [...]}
|
{"nickname": "办公室电脑", "rules": [...]}
|
||||||
|
|
||||||
# 删除客户端
|
# 删除客户端
|
||||||
DELETE /api/client/{id}
|
DELETE /api/client/{id}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 客户端控制
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 推送配置到在线客户端(客户端会立即应用新规则)
|
||||||
|
POST /api/client/{id}/push
|
||||||
|
|
||||||
|
# 断开客户端连接
|
||||||
|
POST /api/client/{id}/disconnect
|
||||||
|
```
|
||||||
|
|
||||||
|
### 插件管理
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 获取已注册的插件列表
|
||||||
|
GET /api/plugins
|
||||||
|
|
||||||
|
# 响应示例
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "socks5",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "SOCKS5 proxy plugin",
|
||||||
|
"source": "builtin"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
### 服务状态
|
### 服务状态
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -292,6 +339,11 @@ GET /api/status
|
|||||||
# 获取配置
|
# 获取配置
|
||||||
GET /api/config
|
GET /api/config
|
||||||
|
|
||||||
|
# 更新配置
|
||||||
|
PUT /api/config
|
||||||
|
Content-Type: application/json
|
||||||
|
{"server": {"heartbeat_sec": 30}, "web": {"enabled": true}}
|
||||||
|
|
||||||
# 重载配置
|
# 重载配置
|
||||||
POST /api/config/reload
|
POST /api/config/reload
|
||||||
```
|
```
|
||||||
@@ -324,9 +376,9 @@ curl --socks5 server:1080 http://internal-service/
|
|||||||
|
|
||||||
## 常见问题
|
## 常见问题
|
||||||
|
|
||||||
**Q: 客户端连接失败,提示 "client not configured"**
|
**Q: 客户端连接后如何设置昵称?**
|
||||||
|
|
||||||
A: 需要先在服务端 Web 控制台添加对应的客户端 ID。
|
A: 在 Web 控制台点击客户端详情,进入编辑模式即可设置昵称。
|
||||||
|
|
||||||
**Q: 如何禁用 TLS?**
|
**Q: 如何禁用 TLS?**
|
||||||
|
|
||||||
@@ -336,6 +388,156 @@ A: 服务端配置 `tls_disabled: true`,客户端使用 `-no-tls` 参数。
|
|||||||
|
|
||||||
A: 服务端会自动检测端口冲突,请检查日志并更换端口。
|
A: 服务端会自动检测端口冲突,请检查日志并更换端口。
|
||||||
|
|
||||||
|
**Q: 客户端 ID 是如何分配的?**
|
||||||
|
|
||||||
|
A: 如果客户端未指定 `-id` 参数,服务端会自动生成 16 位随机 ID。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
使用构建脚本可以一键构建前后端:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建当前平台
|
||||||
|
./scripts/build.sh current
|
||||||
|
|
||||||
|
# 构建所有平台
|
||||||
|
./scripts/build.sh all
|
||||||
|
|
||||||
|
# 仅构建 Web UI
|
||||||
|
./scripts/build.sh web
|
||||||
|
|
||||||
|
# 清理构建产物
|
||||||
|
./scripts/build.sh clean
|
||||||
|
|
||||||
|
# 指定版本号
|
||||||
|
VERSION=1.0.0 ./scripts/build.sh all
|
||||||
|
```
|
||||||
|
|
||||||
|
构建产物输出到 `build/<os>_<arch>/` 目录。
|
||||||
|
|
||||||
|
## 架构时序图
|
||||||
|
|
||||||
|
### 1. 连接建立阶段
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────┐ ┌────────┐ ┌──────────┐
|
||||||
|
│ Client │ │ Server │ │ Database │
|
||||||
|
└───┬────┘ └───┬────┘ └────┬─────┘
|
||||||
|
│ │ │
|
||||||
|
│ 1. TCP/TLS Connect│ │
|
||||||
|
│──────────────────>│ │
|
||||||
|
│ │ │
|
||||||
|
│ 2. AuthRequest │ │
|
||||||
|
│ {token, id?} │ │
|
||||||
|
│──────────────────>│ │
|
||||||
|
│ │ 3. 验证 Token │
|
||||||
|
│ │ 4. 查询/创建客户端 │
|
||||||
|
│ │───────────────────>│
|
||||||
|
│ │<───────────────────│
|
||||||
|
│ │ │
|
||||||
|
│ 5. AuthResponse │ │
|
||||||
|
│ {ok, client_id} │ │
|
||||||
|
│<──────────────────│ │
|
||||||
|
│ │ │
|
||||||
|
│ 6. Yamux Session │ │
|
||||||
|
│<═════════════════>│ │
|
||||||
|
│ │ │
|
||||||
|
│ 7. ProxyConfig │ │
|
||||||
|
│ {rules[]} │ │
|
||||||
|
│<──────────────────│ │
|
||||||
|
│ │ │
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. TCP 代理数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────┐ ┌────────┐ ┌────────┐ ┌───────────────┐
|
||||||
|
│ External │ │ Server │ │ Client │ │ Local Service │
|
||||||
|
└────┬─────┘ └───┬────┘ └───┬────┘ └───────┬───────┘
|
||||||
|
│ │ │ │
|
||||||
|
│ 1. Connect │ │ │
|
||||||
|
│ :remote │ │ │
|
||||||
|
│─────────────>│ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 2. Yamux │ │
|
||||||
|
│ │ Stream │ │
|
||||||
|
│ │────────────>│ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 3. NewProxy │ │
|
||||||
|
│ │ {port} │ │
|
||||||
|
│ │────────────>│ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ │ 4. Connect │
|
||||||
|
│ │ │ local:port │
|
||||||
|
│ │ │────────────────>│
|
||||||
|
│ │ │ │
|
||||||
|
│ 5. Relay (双向数据转发) │ │
|
||||||
|
│<════════════>│<═══════════>│<═══════════════>│
|
||||||
|
│ │ │ │
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. SOCKS5/HTTP 代理数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────┐ ┌────────┐ ┌────────┐ ┌─────────────┐
|
||||||
|
│ External │ │ Server │ │ Client │ │ Target Host │
|
||||||
|
└────┬─────┘ └───┬────┘ └───┬────┘ └──────┬──────┘
|
||||||
|
│ │ │ │
|
||||||
|
│ 1. SOCKS5 │ │ │
|
||||||
|
│ Handshake │ │ │
|
||||||
|
│─────────────>│ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 2. Proxy │ │
|
||||||
|
│ │ Connect │ │
|
||||||
|
│ │ {target} │ │
|
||||||
|
│ │────────────>│ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ │ 3. Dial target │
|
||||||
|
│ │ │───────────────>│
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 4. Result │ │
|
||||||
|
│ │<────────────│ │
|
||||||
|
│ │ │ │
|
||||||
|
│ 5. Relay (双向数据转发) │ │
|
||||||
|
│<════════════>│<═══════════>│<══════════════>│
|
||||||
|
│ │ │ │
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 心跳保活机制
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────┐ ┌────────┐
|
||||||
|
│ Client │ │ Server │
|
||||||
|
└───┬────┘ └───┬────┘
|
||||||
|
│ │
|
||||||
|
│ │ 1. Ticker (30s)
|
||||||
|
│ │
|
||||||
|
│ 2. Heartbeat │
|
||||||
|
│<──────────────────│
|
||||||
|
│ │
|
||||||
|
│ 3. HeartbeatAck │
|
||||||
|
│──────────────────>│
|
||||||
|
│ │
|
||||||
|
│ │ 4. 更新 LastPing
|
||||||
|
│ │
|
||||||
|
│ ... 循环 ... │
|
||||||
|
│ │
|
||||||
|
│ │ 5. 超时检测 (90s)
|
||||||
|
│ │ 无响应则断开
|
||||||
|
│ │
|
||||||
|
```
|
||||||
|
|
||||||
|
### 核心组件说明
|
||||||
|
|
||||||
|
| 组件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| Client | 连接服务端、转发本地服务、响应心跳 |
|
||||||
|
| Server | 认证、管理会话、监听端口、路由流量 |
|
||||||
|
| Yamux | 单连接多路复用,承载控制和数据通道 |
|
||||||
|
| Plugin | 处理 SOCKS5/HTTP 等代理协议 |
|
||||||
|
| PortManager | 端口分配与释放管理 |
|
||||||
|
| Database | 持久化客户端配置和规则 |
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
本项目采用 [MIT License](LICENSE) 开源许可证。
|
本项目采用 [MIT License](LICENSE) 开源许可证。
|
||||||
|
|||||||
BIN
bin/client
BIN
bin/client
Binary file not shown.
BIN
build/darwin_arm64/client
Executable file
BIN
build/darwin_arm64/client
Executable file
Binary file not shown.
15
build/darwin_arm64/config.yaml
Normal file
15
build/darwin_arm64/config.yaml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
server:
|
||||||
|
bind_addr: "0.0.0.0" # 监听地址
|
||||||
|
bind_port: 7001 # 监听端口
|
||||||
|
token: "flik1513." # 认证 Token(不配置则自动生成)
|
||||||
|
heartbeat_sec: 30 # 心跳间隔(秒)
|
||||||
|
heartbeat_timeout: 90 # 心跳超时(秒)
|
||||||
|
db_path: "gotunnel.db" # 数据库路径
|
||||||
|
tls_disabled: false # 是否禁用 TLS(默认启用)
|
||||||
|
|
||||||
|
web:
|
||||||
|
enabled: true # 启用 Web 控制台
|
||||||
|
bind_addr: "0.0.0.0"
|
||||||
|
bind_port: 7500
|
||||||
|
username: "admin" # 可选,设置后启用认证
|
||||||
|
password: "password"
|
||||||
BIN
build/darwin_arm64/gotunnel.db
Normal file
BIN
build/darwin_arm64/gotunnel.db
Normal file
Binary file not shown.
Binary file not shown.
@@ -6,12 +6,14 @@ import (
|
|||||||
|
|
||||||
"github.com/gotunnel/internal/client/tunnel"
|
"github.com/gotunnel/internal/client/tunnel"
|
||||||
"github.com/gotunnel/pkg/crypto"
|
"github.com/gotunnel/pkg/crypto"
|
||||||
|
"github.com/gotunnel/pkg/plugin"
|
||||||
|
"github.com/gotunnel/pkg/plugin/builtin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
server := flag.String("s", "", "server address (ip:port)")
|
server := flag.String("s", "", "server address (ip:port)")
|
||||||
token := flag.String("t", "", "auth token")
|
token := flag.String("t", "", "auth token")
|
||||||
id := flag.String("id", "", "client id (optional)")
|
id := flag.String("id", "", "client id (optional, auto-assigned if empty)")
|
||||||
noTLS := flag.Bool("no-tls", false, "disable TLS")
|
noTLS := flag.Bool("no-tls", false, "disable TLS")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
@@ -28,5 +30,13 @@ func main() {
|
|||||||
log.Printf("[Client] TLS enabled")
|
log.Printf("[Client] TLS enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 初始化插件系统
|
||||||
|
registry := plugin.NewRegistry()
|
||||||
|
if err := registry.RegisterAll(builtin.GetAll()); err != nil {
|
||||||
|
log.Fatalf("[Plugin] Register error: %v", err)
|
||||||
|
}
|
||||||
|
client.SetPluginRegistry(registry)
|
||||||
|
log.Printf("[Plugin] Registered %d plugins", len(builtin.GetAll()))
|
||||||
|
|
||||||
client.Run()
|
client.Run()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,14 +53,11 @@ func main() {
|
|||||||
|
|
||||||
// 初始化插件系统
|
// 初始化插件系统
|
||||||
registry := plugin.NewRegistry()
|
registry := plugin.NewRegistry()
|
||||||
if err := registry.RegisterBuiltin(builtin.NewSOCKS5Plugin()); err != nil {
|
if err := registry.RegisterAll(builtin.GetAll()); err != nil {
|
||||||
log.Printf("[Plugin] Register socks5 error: %v", err)
|
log.Fatalf("[Plugin] Register error: %v", err)
|
||||||
}
|
|
||||||
if err := registry.RegisterBuiltin(builtin.NewHTTPPlugin()); err != nil {
|
|
||||||
log.Printf("[Plugin] Register http error: %v", err)
|
|
||||||
}
|
}
|
||||||
server.SetPluginRegistry(registry)
|
server.SetPluginRegistry(registry)
|
||||||
log.Printf("[Plugin] Plugins registered: socks5, http")
|
log.Printf("[Plugin] Registered %d plugins", len(builtin.GetAll()))
|
||||||
|
|
||||||
// 启动 Web 控制台
|
// 启动 Web 控制台
|
||||||
if cfg.Web.Enabled {
|
if cfg.Web.Enabled {
|
||||||
@@ -70,7 +67,7 @@ func main() {
|
|||||||
go func() {
|
go func() {
|
||||||
var err error
|
var err error
|
||||||
if cfg.Web.Username != "" && cfg.Web.Password != "" {
|
if cfg.Web.Username != "" && cfg.Web.Password != "" {
|
||||||
err = ws.RunWithAuth(addr, cfg.Web.Username, cfg.Web.Password)
|
err = ws.RunWithJWT(addr, cfg.Web.Username, cfg.Web.Password, cfg.Server.Token)
|
||||||
} else {
|
} else {
|
||||||
err = ws.Run(addr)
|
err = ws.Run(addr)
|
||||||
}
|
}
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -11,6 +11,7 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -1,5 +1,7 @@
|
|||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
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/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
|||||||
@@ -51,11 +51,11 @@ func NewManager(cacheDir string) (*Manager, error) {
|
|||||||
// registerBuiltins 注册内置 plugins
|
// registerBuiltins 注册内置 plugins
|
||||||
// 注意: tcp, udp, http, https 是内置类型,直接在 tunnel 中处理
|
// 注意: tcp, udp, http, https 是内置类型,直接在 tunnel 中处理
|
||||||
func (m *Manager) registerBuiltins() error {
|
func (m *Manager) registerBuiltins() error {
|
||||||
// 注册 SOCKS5 plugin
|
// 使用统一的插件注册入口
|
||||||
if err := m.registry.RegisterBuiltin(builtin.NewSOCKS5Plugin()); err != nil {
|
if err := m.registry.RegisterAll(builtin.GetAll()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
log.Println("[Plugin] Builtin plugins registered: socks5")
|
log.Printf("[Plugin] Registered %d builtin plugins", len(builtin.GetAll()))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/gotunnel/pkg/plugin"
|
||||||
"github.com/gotunnel/pkg/protocol"
|
"github.com/gotunnel/pkg/protocol"
|
||||||
"github.com/gotunnel/pkg/relay"
|
"github.com/gotunnel/pkg/relay"
|
||||||
"github.com/hashicorp/yamux"
|
"github.com/hashicorp/yamux"
|
||||||
@@ -22,24 +24,27 @@ const (
|
|||||||
reconnectDelay = 5 * time.Second
|
reconnectDelay = 5 * time.Second
|
||||||
disconnectDelay = 3 * time.Second
|
disconnectDelay = 3 * time.Second
|
||||||
udpBufferSize = 65535
|
udpBufferSize = 65535
|
||||||
|
idFileName = ".gotunnel_id"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client 隧道客户端
|
// Client 隧道客户端
|
||||||
type Client struct {
|
type Client struct {
|
||||||
ServerAddr string
|
ServerAddr string
|
||||||
Token string
|
Token string
|
||||||
ID string
|
ID string
|
||||||
TLSEnabled bool
|
TLSEnabled bool
|
||||||
TLSConfig *tls.Config
|
TLSConfig *tls.Config
|
||||||
session *yamux.Session
|
session *yamux.Session
|
||||||
rules []protocol.ProxyRule
|
rules []protocol.ProxyRule
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
pluginRegistry *plugin.Registry
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient 创建客户端
|
// NewClient 创建客户端
|
||||||
func NewClient(serverAddr, token, id string) *Client {
|
func NewClient(serverAddr, token, id string) *Client {
|
||||||
|
// 如果未指定 ID,尝试从本地文件加载
|
||||||
if id == "" {
|
if id == "" {
|
||||||
id = uuid.New().String()[:8]
|
id = loadClientID()
|
||||||
}
|
}
|
||||||
return &Client{
|
return &Client{
|
||||||
ServerAddr: serverAddr,
|
ServerAddr: serverAddr,
|
||||||
@@ -48,6 +53,36 @@ func NewClient(serverAddr, token, id string) *Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getIDFilePath 获取 ID 文件路径
|
||||||
|
func getIDFilePath() string {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return idFileName
|
||||||
|
}
|
||||||
|
return filepath.Join(home, idFileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadClientID 从本地文件加载客户端 ID
|
||||||
|
func loadClientID() string {
|
||||||
|
data, err := os.ReadFile(getIDFilePath())
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveClientID 保存客户端 ID 到本地文件
|
||||||
|
func saveClientID(id string) {
|
||||||
|
if err := os.WriteFile(getIDFilePath(), []byte(id), 0600); err != nil {
|
||||||
|
log.Printf("[Client] Failed to save client ID: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPluginRegistry 设置插件注册表
|
||||||
|
func (c *Client) SetPluginRegistry(registry *plugin.Registry) {
|
||||||
|
c.pluginRegistry = registry
|
||||||
|
}
|
||||||
|
|
||||||
// Run 启动客户端(带断线重连)
|
// Run 启动客户端(带断线重连)
|
||||||
func (c *Client) Run() error {
|
func (c *Client) Run() error {
|
||||||
for {
|
for {
|
||||||
@@ -102,6 +137,13 @@ func (c *Client) connect() error {
|
|||||||
return fmt.Errorf("auth failed: %s", authResp.Message)
|
return fmt.Errorf("auth failed: %s", authResp.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果服务端分配了新 ID,则更新并保存
|
||||||
|
if authResp.ClientID != "" && authResp.ClientID != c.ID {
|
||||||
|
c.ID = authResp.ClientID
|
||||||
|
saveClientID(c.ID)
|
||||||
|
log.Printf("[Client] New ID assigned and saved: %s", c.ID)
|
||||||
|
}
|
||||||
|
|
||||||
log.Printf("[Client] Authenticated as %s", c.ID)
|
log.Printf("[Client] Authenticated as %s", c.ID)
|
||||||
|
|
||||||
session, err := yamux.Client(conn, nil)
|
session, err := yamux.Client(conn, nil)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/gotunnel/internal/server/config"
|
"github.com/gotunnel/internal/server/config"
|
||||||
"github.com/gotunnel/internal/server/db"
|
"github.com/gotunnel/internal/server/db"
|
||||||
"github.com/gotunnel/internal/server/router"
|
"github.com/gotunnel/internal/server/router"
|
||||||
|
"github.com/gotunnel/pkg/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed dist/*
|
//go:embed dist/*
|
||||||
@@ -92,6 +93,35 @@ func (w *WebServer) RunWithAuth(addr, username, password string) error {
|
|||||||
return http.ListenAndServe(addr, handler)
|
return http.ListenAndServe(addr, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RunWithJWT 启动带 JWT 认证的 Web 服务
|
||||||
|
func (w *WebServer) RunWithJWT(addr, username, password, jwtSecret string) error {
|
||||||
|
r := router.New()
|
||||||
|
|
||||||
|
// JWT 认证器
|
||||||
|
jwtAuth := auth.NewJWTAuth(jwtSecret, 24) // 24小时过期
|
||||||
|
|
||||||
|
// 注册认证路由(不需要认证)
|
||||||
|
authHandler := router.NewAuthHandler(username, password, jwtAuth)
|
||||||
|
router.RegisterAuthRoutes(r, authHandler)
|
||||||
|
|
||||||
|
// 注册业务路由
|
||||||
|
router.RegisterRoutes(r, w)
|
||||||
|
|
||||||
|
// 静态文件
|
||||||
|
staticFS, err := fs.Sub(staticFiles, "dist")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.Handle("/", spaHandler{fs: http.FS(staticFS)})
|
||||||
|
|
||||||
|
// JWT 中间件,只对 /api/ 路径进行认证(排除 /api/auth/)
|
||||||
|
skipPaths := []string{"/api/auth/"}
|
||||||
|
handler := router.JWTMiddleware(jwtAuth, skipPaths, r.Handler())
|
||||||
|
|
||||||
|
log.Printf("[Web] Console listening on %s (JWT auth enabled)", addr)
|
||||||
|
return http.ListenAndServe(addr, handler)
|
||||||
|
}
|
||||||
|
|
||||||
// GetClientStore 获取客户端存储
|
// GetClientStore 获取客户端存储
|
||||||
func (w *WebServer) GetClientStore() db.ClientStore {
|
func (w *WebServer) GetClientStore() db.ClientStore {
|
||||||
return w.ClientStore
|
return w.ClientStore
|
||||||
|
|||||||
1
internal/server/app/dist/assets/ArrowBackOutline-QaNKMlLc.js
vendored
Normal file
1
internal/server/app/dist/assets/ArrowBackOutline-QaNKMlLc.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{k as n,S as r,V as o,U as t}from"./vue-vendor-k28cQfDw.js";const l={xmlns:"http://www.w3.org/2000/svg","xmlns:xlink":"http://www.w3.org/1999/xlink",viewBox:"0 0 512 512"},a=n({name:"ArrowBackOutline",render:function(i,e){return t(),r("svg",l,e[0]||(e[0]=[o("path",{fill:"none",stroke:"currentColor","stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"48",d:"M244 400L100 256l144-144"},null,-1),o("path",{fill:"none",stroke:"currentColor","stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"48",d:"M120 256h292"},null,-1)]))}});export{a as A};
|
||||||
1
internal/server/app/dist/assets/ClientView-DMo3F6A5.js
vendored
Normal file
1
internal/server/app/dist/assets/ClientView-DMo3F6A5.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
import{d as D,j as F,r,o as I,c as n,a as e,b as p,k as C,u as S,t as s,n as J,F as V,e as g,l as M,m as O,p as P,f as _,v,i as o,_ as $}from"./index-BvqIwKwu.js";const j={class:"client-view"},z={class:"header"},L={key:0,class:"ping-info"},T={class:"rules-section"},q={class:"section-header"},A={key:0},G={key:0,class:"rules-table"},H={key:1,class:"edit-form"},K=["onUpdate:modelValue"],Q=["onUpdate:modelValue"],W=["onUpdate:modelValue"],X=["onUpdate:modelValue"],Y=["onClick"],Z=D({__name:"ClientView",setup(ee){const w=F(),f=S(),u=w.params.id,m=r(!1),h=r(""),b=r([]),i=r(!1),d=r([]),y=async()=>{try{const{data:l}=await M(u);m.value=l.online,h.value=l.last_ping||"",b.value=l.rules||[]}catch(l){console.error("Failed to load client",l)}};I(y);const U=()=>{d.value=JSON.parse(JSON.stringify(b.value)),i.value=!0},R=()=>{i.value=!1},x=()=>{d.value.push({name:"",local_ip:"127.0.0.1",local_port:80,remote_port:8080})},E=l=>{d.value.splice(l,1)},N=async()=>{try{await O(u,{id:u,rules:d.value}),i.value=!1,y()}catch{alert("保存失败")}},B=async()=>{if(confirm("确定删除此客户端?"))try{await P(u),f.push("/")}catch{alert("删除失败")}};return(l,c)=>(o(),n("div",j,[e("div",z,[e("button",{class:"btn",onClick:c[0]||(c[0]=t=>C(f).push("/"))},"← 返回"),e("h2",null,s(C(u)),1),e("span",{class:J(["status-badge",m.value?"online":"offline"])},s(m.value?"在线":"离线"),3)]),h.value?(o(),n("div",L,"最后心跳: "+s(h.value),1)):p("",!0),e("div",T,[e("div",q,[c[1]||(c[1]=e("h3",null,"代理规则",-1)),i.value?p("",!0):(o(),n("div",A,[e("button",{class:"btn primary",onClick:U},"编辑"),e("button",{class:"btn danger",onClick:B},"删除")]))]),i.value?p("",!0):(o(),n("table",G,[c[2]||(c[2]=e("thead",null,[e("tr",null,[e("th",null,"名称"),e("th",null,"本地地址"),e("th",null,"远程端口")])],-1)),e("tbody",null,[(o(!0),n(V,null,g(b.value,t=>(o(),n("tr",{key:t.name},[e("td",null,s(t.name),1),e("td",null,s(t.local_ip)+":"+s(t.local_port),1),e("td",null,s(t.remote_port),1)]))),128))])])),i.value?(o(),n("div",H,[(o(!0),n(V,null,g(d.value,(t,k)=>(o(),n("div",{key:k,class:"rule-row"},[_(e("input",{"onUpdate:modelValue":a=>t.name=a,placeholder:"名称"},null,8,K),[[v,t.name]]),_(e("input",{"onUpdate:modelValue":a=>t.local_ip=a,placeholder:"本地IP"},null,8,Q),[[v,t.local_ip]]),_(e("input",{"onUpdate:modelValue":a=>t.local_port=a,type:"number",placeholder:"本地端口"},null,8,W),[[v,t.local_port,void 0,{number:!0}]]),_(e("input",{"onUpdate:modelValue":a=>t.remote_port=a,type:"number",placeholder:"远程端口"},null,8,X),[[v,t.remote_port,void 0,{number:!0}]]),e("button",{class:"btn-icon",onClick:a=>E(k)},"×",8,Y)]))),128)),e("button",{class:"btn secondary",onClick:x},"+ 添加规则"),e("div",{class:"edit-actions"},[e("button",{class:"btn",onClick:R},"取消"),e("button",{class:"btn primary",onClick:N},"保存")])])):p("",!0)])]))}}),le=$(Z,[["__scopeId","data-v-01b16887"]]);export{le as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
.header[data-v-01b16887]{display:flex;align-items:center;gap:16px;margin-bottom:20px}.header h2[data-v-01b16887]{margin:0}.status-badge[data-v-01b16887]{padding:4px 12px;border-radius:12px;font-size:12px}.status-badge.online[data-v-01b16887]{background:#d4edda;color:#155724}.status-badge.offline[data-v-01b16887]{background:#f8d7da;color:#721c24}.ping-info[data-v-01b16887]{color:#666;margin-bottom:20px}.rules-section[data-v-01b16887]{background:#fff;border-radius:8px;padding:20px;box-shadow:0 2px 4px #0000001a}.section-header[data-v-01b16887]{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.section-header h3[data-v-01b16887]{margin:0}.section-header .btn[data-v-01b16887]{margin-left:8px}.btn[data-v-01b16887]{padding:8px 16px;border:none;border-radius:4px;cursor:pointer;font-size:14px}.btn.primary[data-v-01b16887]{background:#3498db;color:#fff}.btn.secondary[data-v-01b16887]{background:#95a5a6;color:#fff}.btn.danger[data-v-01b16887]{background:#e74c3c;color:#fff}.rules-table[data-v-01b16887]{width:100%;border-collapse:collapse}.rules-table th[data-v-01b16887],.rules-table td[data-v-01b16887]{padding:12px;text-align:left;border-bottom:1px solid #eee}.rules-table th[data-v-01b16887]{font-weight:600}.rule-row[data-v-01b16887]{display:flex;gap:8px;margin-bottom:8px}.rule-row input[data-v-01b16887]{flex:1;padding:8px;border:1px solid #ddd;border-radius:4px}.btn-icon[data-v-01b16887]{background:#e74c3c;color:#fff;border:none;border-radius:4px;width:32px;cursor:pointer}.edit-actions[data-v-01b16887]{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}
|
|
||||||
1
internal/server/app/dist/assets/HomeView-CC6tujY_.js
vendored
Normal file
1
internal/server/app/dist/assets/HomeView-CC6tujY_.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{k as B,r as V,c as y,o as z,S as p,V as r,_ as l,X as f,Y as a,N as e,F as S,R as $,Z as j,U as u,a4 as F,$ as i,j as m,a3 as R}from"./vue-vendor-k28cQfDw.js";import{e as g,f as E,g as G,h as d,N as c,i as _,j as k,k as N,B as M}from"./index-BJ4y0MF5.js";const T={class:"home"},D={style:{margin:"0 0 4px 0"}},H={key:0,style:{margin:"0 0 8px 0",color:"#999","font-size":"12px"}},Y=B({__name:"HomeView",setup(L){const h=j(),n=V([]),x=async()=>{try{const{data:t}=await G();n.value=t||[]}catch(t){console.error("Failed to load clients",t)}},C=y(()=>n.value.filter(t=>t.online).length),b=y(()=>n.value.reduce((t,o)=>t+o.rule_count,0));z(x);const v=t=>{h.push(`/client/${t}`)};return(t,o)=>(u(),p("div",T,[o[1]||(o[1]=r("div",{style:{"margin-bottom":"24px"}},[r("h2",{style:{margin:"0 0 8px 0"}},"客户端管理"),r("p",{style:{margin:"0",color:"#666"}},"查看已连接的隧道客户端")],-1)),l(e(g),{cols:3,"x-gap":16,"y-gap":16,style:{"margin-bottom":"24px"}},{default:a(()=>[l(e(d),null,{default:a(()=>[l(e(c),null,{default:a(()=>[l(e(_),{label:"总客户端",value:n.value.length},null,8,["value"])]),_:1})]),_:1}),l(e(d),null,{default:a(()=>[l(e(c),null,{default:a(()=>[l(e(_),{label:"在线客户端",value:C.value},null,8,["value"])]),_:1})]),_:1}),l(e(d),null,{default:a(()=>[l(e(c),null,{default:a(()=>[l(e(_),{label:"总规则数",value:b.value},null,8,["value"])]),_:1})]),_:1})]),_:1}),n.value.length===0?(u(),f(e(E),{key:0,description:"暂无客户端连接"})):(u(),f(e(g),{key:1,cols:3,"x-gap":16,"y-gap":16,responsive:"screen","cols-s":"1","cols-m":"2"},{default:a(()=>[(u(!0),p(S,null,$(n.value,s=>(u(),f(e(d),{key:s.id},{default:a(()=>[l(e(c),{hoverable:"",style:{cursor:"pointer"},onClick:w=>v(s.id)},{default:a(()=>[l(e(k),{justify:"space-between",align:"center"},{default:a(()=>[r("div",null,[r("h3",D,i(s.nickname||s.id),1),s.nickname?(u(),p("p",H,i(s.id),1)):F("",!0),l(e(k),null,{default:a(()=>[l(e(N),{type:s.online?"success":"default",size:"small"},{default:a(()=>[m(i(s.online?"在线":"离线"),1)]),_:2},1032,["type"]),l(e(N),{type:"info",size:"small"},{default:a(()=>[m(i(s.rule_count)+" 条规则",1)]),_:2},1024)]),_:2},1024)]),l(e(M),{size:"small",onClick:R(w=>v(s.id),["stop"])},{default:a(()=>[...o[0]||(o[0]=[m("查看详情",-1)])]),_:1},8,["onClick"])]),_:2},1024)]),_:2},1032,["onClick"])]),_:2},1024))),128))]),_:1}))]))}});export{Y as default};
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{d as M,r as p,o as $,c as n,a as e,b as f,F as h,e as b,w as x,f as u,v as c,g as I,h as R,u as B,i as a,t as C,n as D,_ as F}from"./index-BvqIwKwu.js";const H={class:"home"},N={class:"client-grid"},z=["onClick"],A={class:"card-header"},E={class:"client-id"},L={class:"card-info"},P={key:0,class:"empty"},S={class:"modal"},T={class:"form-group"},j={class:"form-group"},q=["onUpdate:modelValue"],G=["onUpdate:modelValue"],J=["onUpdate:modelValue"],K=["onUpdate:modelValue"],O=["onClick"],Q={class:"modal-actions"},W=M({__name:"HomeView",setup(X){const y=B(),v=p([]),d=p(!1),i=p(""),r=p([]),m=async()=>{try{const{data:t}=await I();v.value=t||[]}catch(t){console.error("Failed to load clients",t)}};$(m);const k=()=>{i.value="",r.value=[{name:"",local_ip:"127.0.0.1",local_port:80,remote_port:8080}],d.value=!0},V=()=>{r.value.push({name:"",local_ip:"127.0.0.1",local_port:80,remote_port:8080})},w=t=>{r.value.splice(t,1)},U=async()=>{if(i.value)try{await R({id:i.value,rules:r.value}),d.value=!1,m()}catch{alert("添加失败")}},g=t=>{y.push(`/client/${t}`)};return(t,l)=>(a(),n("div",H,[e("div",{class:"toolbar"},[l[3]||(l[3]=e("h2",null,"客户端列表",-1)),e("button",{class:"btn primary",onClick:k},"添加客户端")]),e("div",N,[(a(!0),n(h,null,b(v.value,o=>(a(),n("div",{key:o.id,class:"client-card",onClick:_=>g(o.id)},[e("div",A,[e("span",E,C(o.id),1),e("span",{class:D(["status",o.online?"online":"offline"])},null,2)]),e("div",L,[e("span",null,C(o.rule_count)+" 条规则",1)])],8,z))),128))]),v.value.length===0?(a(),n("div",P,"暂无客户端配置")):f("",!0),d.value?(a(),n("div",{key:1,class:"modal-overlay",onClick:l[2]||(l[2]=x(o=>d.value=!1,["self"]))},[e("div",S,[l[6]||(l[6]=e("h3",null,"添加客户端",-1)),e("div",T,[l[4]||(l[4]=e("label",null,"客户端 ID",-1)),u(e("input",{"onUpdate:modelValue":l[0]||(l[0]=o=>i.value=o),placeholder:"例如: client-a"},null,512),[[c,i.value]])]),e("div",j,[l[5]||(l[5]=e("label",null,"代理规则",-1)),(a(!0),n(h,null,b(r.value,(o,_)=>(a(),n("div",{key:_,class:"rule-row"},[u(e("input",{"onUpdate:modelValue":s=>o.name=s,placeholder:"名称"},null,8,q),[[c,o.name]]),u(e("input",{"onUpdate:modelValue":s=>o.local_ip=s,placeholder:"本地IP"},null,8,G),[[c,o.local_ip]]),u(e("input",{"onUpdate:modelValue":s=>o.local_port=s,type:"number",placeholder:"本地端口"},null,8,J),[[c,o.local_port,void 0,{number:!0}]]),u(e("input",{"onUpdate:modelValue":s=>o.remote_port=s,type:"number",placeholder:"远程端口"},null,8,K),[[c,o.remote_port,void 0,{number:!0}]]),e("button",{class:"btn-icon",onClick:s=>w(_)},"×",8,O)]))),128)),e("button",{class:"btn secondary",onClick:V},"+ 添加规则")]),e("div",Q,[e("button",{class:"btn",onClick:l[1]||(l[1]=o=>d.value=!1)},"取消"),e("button",{class:"btn primary",onClick:U},"保存")])])])):f("",!0)]))}}),Z=F(W,[["__scopeId","data-v-fd6e4f1d"]]);export{Z as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
.toolbar[data-v-fd6e4f1d]{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}.toolbar h2[data-v-fd6e4f1d]{font-size:18px;color:#2c3e50}.btn[data-v-fd6e4f1d]{padding:8px 16px;border:none;border-radius:4px;cursor:pointer;font-size:14px}.btn.primary[data-v-fd6e4f1d]{background:#3498db;color:#fff}.btn.secondary[data-v-fd6e4f1d]{background:#95a5a6;color:#fff}.client-grid[data-v-fd6e4f1d]{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px}.client-card[data-v-fd6e4f1d]{background:#fff;border-radius:8px;padding:16px;cursor:pointer;box-shadow:0 2px 4px #0000001a;transition:transform .2s}.client-card[data-v-fd6e4f1d]:hover{transform:translateY(-2px)}.card-header[data-v-fd6e4f1d]{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}.client-id[data-v-fd6e4f1d]{font-weight:600}.status[data-v-fd6e4f1d]{width:10px;height:10px;border-radius:50%}.status.online[data-v-fd6e4f1d]{background:#27ae60}.status.offline[data-v-fd6e4f1d]{background:#95a5a6}.card-info[data-v-fd6e4f1d]{font-size:14px;color:#666}.empty[data-v-fd6e4f1d]{text-align:center;color:#999;padding:40px}.modal-overlay[data-v-fd6e4f1d]{position:fixed;inset:0;background:#00000080;display:flex;align-items:center;justify-content:center}.modal[data-v-fd6e4f1d]{background:#fff;border-radius:8px;padding:24px;width:500px;max-width:90%}.modal h3[data-v-fd6e4f1d],.form-group[data-v-fd6e4f1d]{margin-bottom:16px}.form-group label[data-v-fd6e4f1d]{display:block;margin-bottom:8px;font-weight:500}.form-group input[data-v-fd6e4f1d]{width:100%;padding:8px;border:1px solid #ddd;border-radius:4px;box-sizing:border-box}.rule-row[data-v-fd6e4f1d]{display:flex;gap:8px;margin-bottom:8px}.rule-row input[data-v-fd6e4f1d]{flex:1;width:auto}.btn-icon[data-v-fd6e4f1d]{background:#e74c3c;color:#fff;border:none;border-radius:4px;width:32px;cursor:pointer}.modal-actions[data-v-fd6e4f1d]{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}
|
|
||||||
1
internal/server/app/dist/assets/LoginView-DM1JApcE.js
vendored
Normal file
1
internal/server/app/dist/assets/LoginView-DM1JApcE.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{k as N,r as d,S as k,_ as s,Y as a,N as t,Z as w,U as f,a3 as V,X as h,a4 as x,j as m,$ as g,V as i}from"./vue-vendor-k28cQfDw.js";import{N as B,a as C,b,c as _,d as T,B as I,l as L,s as S}from"./index-BJ4y0MF5.js";const U={class:"login-page"},F=N({__name:"LoginView",setup(v){const p=w(),l=d(""),r=d(""),o=d(""),n=d(!1),y=async()=>{if(!l.value||!r.value){o.value="请输入用户名和密码";return}n.value=!0,o.value="";try{const{data:u}=await L(l.value,r.value);S(u.token),p.push("/")}catch(u){o.value=u.response?.data?.error||"登录失败"}finally{n.value=!1}};return(u,e)=>(f(),k("div",U,[s(t(B),{class:"login-card",bordered:!1},{header:a(()=>[...e[2]||(e[2]=[i("div",{class:"login-header"},[i("h1",{class:"logo"},"GoTunnel"),i("p",{class:"subtitle"},"安全的内网穿透工具")],-1)])]),footer:a(()=>[...e[3]||(e[3]=[i("div",{class:"login-footer"},"欢迎使用 GoTunnel",-1)])]),default:a(()=>[s(t(C),{onSubmit:V(y,["prevent"])},{default:a(()=>[s(t(b),{label:"用户名"},{default:a(()=>[s(t(_),{value:l.value,"onUpdate:value":e[0]||(e[0]=c=>l.value=c),placeholder:"请输入用户名",disabled:n.value},null,8,["value","disabled"])]),_:1}),s(t(b),{label:"密码"},{default:a(()=>[s(t(_),{value:r.value,"onUpdate:value":e[1]||(e[1]=c=>r.value=c),type:"password",placeholder:"请输入密码",disabled:n.value,"show-password-on":"click"},null,8,["value","disabled"])]),_:1}),o.value?(f(),h(t(T),{key:0,type:"error","show-icon":!0,style:{"margin-bottom":"16px"}},{default:a(()=>[m(g(o.value),1)]),_:1})):x("",!0),s(t(I),{type:"primary",block:"",loading:n.value,"attr-type":"submit"},{default:a(()=>[m(g(n.value?"登录中...":"登录"),1)]),_:1},8,["loading"])]),_:1})]),_:1})]))}}),G=(v,p)=>{const l=v.__vccOpts||v;for(const[r,o]of p)l[r]=o;return l},D=G(F,[["__scopeId","data-v-0e29b44b"]]);export{D as default};
|
||||||
1
internal/server/app/dist/assets/LoginView-kzRncluE.css
vendored
Normal file
1
internal/server/app/dist/assets/LoginView-kzRncluE.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.login-page[data-v-0e29b44b]{min-height:100vh;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#e8f5e9,#c8e6c9);padding:16px}.login-card[data-v-0e29b44b]{width:100%;max-width:400px;box-shadow:0 8px 24px #0000001a}.login-header[data-v-0e29b44b]{text-align:center}.logo[data-v-0e29b44b]{font-size:28px;font-weight:700;color:#18a058;margin:0 0 8px}.subtitle[data-v-0e29b44b]{color:#666;margin:0;font-size:14px}.login-footer[data-v-0e29b44b]{text-align:center;color:#999;font-size:14px}
|
||||||
1
internal/server/app/dist/assets/PluginsView-CPE2IHXI.js
vendored
Normal file
1
internal/server/app/dist/assets/PluginsView-CPE2IHXI.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{k as $,r as b,c as x,o as E,S as N,_ as a,Y as l,N as e,V as u,Z as F,j as c,X as m,F as T,R as j,U as r,$ as i}from"./vue-vendor-k28cQfDw.js";import{u as A,j as d,D as G,y as M,B as U,p as h,e as w,h as p,N as f,i as _,f as D,k as g,E as L,F as O,G as R,H as q}from"./index-BJ4y0MF5.js";import{A as H}from"./ArrowBackOutline-QaNKMlLc.js";const I={class:"plugins-view"},W={style:{margin:"0",color:"#666"}},Q=$({__name:"PluginsView",setup(X){const k=F(),v=A(),o=b([]),y=b(!0),P=async()=>{try{const{data:t}=await M();o.value=t||[]}catch(t){console.error("Failed to load plugins",t)}finally{y.value=!1}},z=x(()=>o.value.filter(t=>t.type==="proxy")),B=x(()=>o.value.filter(t=>t.type==="app")),S=async t=>{try{t.enabled?(await R(t.name),v.success(`已禁用 ${t.name}`)):(await q(t.name),v.success(`已启用 ${t.name}`)),t.enabled=!t.enabled}catch{v.error("操作失败")}},C=t=>({proxy:"协议",app:"应用",service:"服务",tool:"工具"})[t]||t,V=t=>({proxy:"info",app:"success",service:"warning",tool:"default"})[t]||"default";return E(P),(t,n)=>(r(),N("div",I,[a(e(d),{justify:"space-between",align:"center",style:{"margin-bottom":"24px"}},{default:l(()=>[n[2]||(n[2]=u("div",null,[u("h2",{style:{margin:"0 0 8px 0"}},"插件管理"),u("p",{style:{margin:"0",color:"#666"}},"查看和管理已注册的插件")],-1)),a(e(U),{quaternary:"",onClick:n[0]||(n[0]=s=>e(k).push("/"))},{icon:l(()=>[a(e(h),null,{default:l(()=>[a(e(H))]),_:1})]),default:l(()=>[n[1]||(n[1]=c(" 返回首页 ",-1))]),_:1})]),_:1}),a(e(G),{show:y.value},{default:l(()=>[a(e(w),{cols:3,"x-gap":16,"y-gap":16,style:{"margin-bottom":"24px"}},{default:l(()=>[a(e(p),null,{default:l(()=>[a(e(f),null,{default:l(()=>[a(e(_),{label:"总插件数",value:o.value.length},null,8,["value"])]),_:1})]),_:1}),a(e(p),null,{default:l(()=>[a(e(f),null,{default:l(()=>[a(e(_),{label:"协议插件",value:z.value.length},null,8,["value"])]),_:1})]),_:1}),a(e(p),null,{default:l(()=>[a(e(f),null,{default:l(()=>[a(e(_),{label:"应用插件",value:B.value.length},null,8,["value"])]),_:1})]),_:1})]),_:1}),!y.value&&o.value.length===0?(r(),m(e(D),{key:0,description:"暂无插件"})):(r(),m(e(w),{key:1,cols:3,"x-gap":16,"y-gap":16,responsive:"screen","cols-s":"1","cols-m":"2"},{default:l(()=>[(r(!0),N(T,null,j(o.value,s=>(r(),m(e(p),{key:s.name},{default:l(()=>[a(e(f),{hoverable:""},{header:l(()=>[a(e(d),{align:"center"},{default:l(()=>[a(e(h),{size:"24",color:"#18a058"},{default:l(()=>[a(e(O))]),_:1}),u("span",null,i(s.name),1)]),_:2},1024)]),"header-extra":l(()=>[a(e(L),{value:s.enabled,"onUpdate:value":Y=>S(s)},null,8,["value","onUpdate:value"])]),default:l(()=>[a(e(d),{vertical:"",size:8},{default:l(()=>[a(e(d),null,{default:l(()=>[a(e(g),{size:"small"},{default:l(()=>[c("v"+i(s.version),1)]),_:2},1024),a(e(g),{size:"small",type:V(s.type)},{default:l(()=>[c(i(C(s.type)),1)]),_:2},1032,["type"]),a(e(g),{size:"small",type:s.source==="builtin"?"default":"warning"},{default:l(()=>[c(i(s.source==="builtin"?"内置":"WASM"),1)]),_:2},1032,["type"])]),_:2},1024),u("p",W,i(s.description),1)]),_:2},1024)]),_:2},1024)]),_:2},1024))),128))]),_:1}))]),_:1},8,["show"])]))}});export{Q as default};
|
||||||
7343
internal/server/app/dist/assets/index-BJ4y0MF5.js
vendored
Normal file
7343
internal/server/app/dist/assets/index-BJ4y0MF5.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
internal/server/app/dist/assets/index-cn54chxY.css
vendored
Normal file
1
internal/server/app/dist/assets/index-cn54chxY.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}#app{min-height:100vh}
|
||||||
@@ -1 +0,0 @@
|
|||||||
:root{font-family:system-ui,Avenir,Helvetica,Arial,sans-serif;line-height:1.5;font-weight:400;color-scheme:light dark;color:#ffffffde;background-color:#242424;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}a{font-weight:500;color:#646cff;text-decoration:inherit}a:hover{color:#535bf2}body{margin:0;display:flex;place-items:center;min-width:320px;min-height:100vh}h1{font-size:3.2em;line-height:1.1}button{border-radius:8px;border:1px solid transparent;padding:.6em 1.2em;font-size:1em;font-weight:500;font-family:inherit;background-color:#1a1a1a;cursor:pointer;transition:border-color .25s}button:hover{border-color:#646cff}button:focus,button:focus-visible{outline:4px auto -webkit-focus-ring-color}.card{padding:2em}#app{max-width:1280px;margin:0 auto;padding:2rem;text-align:center}@media(prefers-color-scheme:light){:root{color:#213547;background-color:#fff}a:hover{color:#747bff}button{background-color:#f9f9f9}}.app[data-v-dc56de06]{min-height:100vh;background:#f5f7fa}.header[data-v-dc56de06]{background:#fff;padding:16px 24px;display:flex;justify-content:space-between;align-items:center;box-shadow:0 2px 4px #0000001a}.header h1[data-v-dc56de06]{font-size:20px;color:#2c3e50}.server-info[data-v-dc56de06]{display:flex;align-items:center;gap:12px;color:#666}.badge[data-v-dc56de06]{background:#3498db;color:#fff;padding:4px 12px;border-radius:12px;font-size:12px}.main[data-v-dc56de06]{padding:24px;max-width:1200px;margin:0 auto}
|
|
||||||
1
internal/server/app/dist/assets/vue-vendor-k28cQfDw.js
vendored
Normal file
1
internal/server/app/dist/assets/vue-vendor-k28cQfDw.js
vendored
Normal file
File diff suppressed because one or more lines are too long
5
internal/server/app/dist/index.html
vendored
5
internal/server/app/dist/index.html
vendored
@@ -5,8 +5,9 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>webui</title>
|
<title>webui</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BvqIwKwu.js"></script>
|
<script type="module" crossorigin src="/assets/index-BJ4y0MF5.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-fTDfeMRP.css">
|
<link rel="modulepreload" crossorigin href="/assets/vue-vendor-k28cQfDw.js">
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-cn54chxY.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -4,33 +4,50 @@ import "github.com/gotunnel/pkg/protocol"
|
|||||||
|
|
||||||
// Client 客户端数据
|
// Client 客户端数据
|
||||||
type Client struct {
|
type Client struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Rules []protocol.ProxyRule `json:"rules"`
|
Nickname string `json:"nickname,omitempty"`
|
||||||
|
Rules []protocol.ProxyRule `json:"rules"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PluginData 插件数据
|
||||||
|
type PluginData struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Checksum string `json:"checksum"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
WASMData []byte `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClientStore 客户端存储接口
|
// ClientStore 客户端存储接口
|
||||||
type ClientStore interface {
|
type ClientStore interface {
|
||||||
// GetAllClients 获取所有客户端
|
|
||||||
GetAllClients() ([]Client, error)
|
GetAllClients() ([]Client, error)
|
||||||
|
|
||||||
// GetClient 获取单个客户端
|
|
||||||
GetClient(id string) (*Client, error)
|
GetClient(id string) (*Client, error)
|
||||||
|
|
||||||
// CreateClient 创建客户端
|
|
||||||
CreateClient(c *Client) error
|
CreateClient(c *Client) error
|
||||||
|
|
||||||
// UpdateClient 更新客户端
|
|
||||||
UpdateClient(c *Client) error
|
UpdateClient(c *Client) error
|
||||||
|
|
||||||
// DeleteClient 删除客户端
|
|
||||||
DeleteClient(id string) error
|
DeleteClient(id string) error
|
||||||
|
|
||||||
// ClientExists 检查客户端是否存在
|
|
||||||
ClientExists(id string) (bool, error)
|
ClientExists(id string) (bool, error)
|
||||||
|
|
||||||
// GetClientRules 获取客户端规则
|
|
||||||
GetClientRules(id string) ([]protocol.ProxyRule, error)
|
GetClientRules(id string) ([]protocol.ProxyRule, error)
|
||||||
|
Close() error
|
||||||
// Close 关闭连接
|
}
|
||||||
|
|
||||||
|
// PluginStore 插件存储接口
|
||||||
|
type PluginStore interface {
|
||||||
|
GetAllPlugins() ([]PluginData, error)
|
||||||
|
GetPlugin(name string) (*PluginData, error)
|
||||||
|
SavePlugin(p *PluginData) error
|
||||||
|
DeletePlugin(name string) error
|
||||||
|
SetPluginEnabled(name string, enabled bool) error
|
||||||
|
GetPluginWASM(name string) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store 统一存储接口
|
||||||
|
type Store interface {
|
||||||
|
ClientStore
|
||||||
|
PluginStore
|
||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,11 +34,35 @@ func NewSQLiteStore(dbPath string) (*SQLiteStore, error) {
|
|||||||
|
|
||||||
// init 初始化数据库表
|
// init 初始化数据库表
|
||||||
func (s *SQLiteStore) init() error {
|
func (s *SQLiteStore) init() error {
|
||||||
|
// 创建客户端表
|
||||||
_, err := s.db.Exec(`
|
_, err := s.db.Exec(`
|
||||||
CREATE TABLE IF NOT EXISTS clients (
|
CREATE TABLE IF NOT EXISTS clients (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
nickname TEXT NOT NULL DEFAULT '',
|
||||||
rules TEXT NOT NULL DEFAULT '[]'
|
rules TEXT NOT NULL DEFAULT '[]'
|
||||||
);
|
)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 迁移:添加 nickname 列
|
||||||
|
s.db.Exec(`ALTER TABLE clients ADD COLUMN nickname TEXT NOT NULL DEFAULT ''`)
|
||||||
|
|
||||||
|
// 创建插件表
|
||||||
|
_, err = s.db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS plugins (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
version TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL DEFAULT 'proxy',
|
||||||
|
source TEXT NOT NULL DEFAULT 'wasm',
|
||||||
|
description TEXT,
|
||||||
|
author TEXT,
|
||||||
|
checksum TEXT,
|
||||||
|
size INTEGER DEFAULT 0,
|
||||||
|
enabled INTEGER DEFAULT 1,
|
||||||
|
wasm_data BLOB
|
||||||
|
)
|
||||||
`)
|
`)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -53,7 +77,7 @@ func (s *SQLiteStore) GetAllClients() ([]Client, error) {
|
|||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
rows, err := s.db.Query(`SELECT id, rules FROM clients`)
|
rows, err := s.db.Query(`SELECT id, nickname, rules FROM clients`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -63,7 +87,7 @@ func (s *SQLiteStore) GetAllClients() ([]Client, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var c Client
|
var c Client
|
||||||
var rulesJSON string
|
var rulesJSON string
|
||||||
if err := rows.Scan(&c.ID, &rulesJSON); err != nil {
|
if err := rows.Scan(&c.ID, &c.Nickname, &rulesJSON); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal([]byte(rulesJSON), &c.Rules); err != nil {
|
if err := json.Unmarshal([]byte(rulesJSON), &c.Rules); err != nil {
|
||||||
@@ -81,7 +105,7 @@ func (s *SQLiteStore) GetClient(id string) (*Client, error) {
|
|||||||
|
|
||||||
var c Client
|
var c Client
|
||||||
var rulesJSON string
|
var rulesJSON string
|
||||||
err := s.db.QueryRow(`SELECT id, rules FROM clients WHERE id = ?`, id).Scan(&c.ID, &rulesJSON)
|
err := s.db.QueryRow(`SELECT id, nickname, rules FROM clients WHERE id = ?`, id).Scan(&c.ID, &c.Nickname, &rulesJSON)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -100,7 +124,7 @@ func (s *SQLiteStore) CreateClient(c *Client) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = s.db.Exec(`INSERT INTO clients (id, rules) VALUES (?, ?)`, c.ID, string(rulesJSON))
|
_, err = s.db.Exec(`INSERT INTO clients (id, nickname, rules) VALUES (?, ?, ?)`, c.ID, c.Nickname, string(rulesJSON))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +137,7 @@ func (s *SQLiteStore) UpdateClient(c *Client) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = s.db.Exec(`UPDATE clients SET rules = ? WHERE id = ?`, string(rulesJSON), c.ID)
|
_, err = s.db.Exec(`UPDATE clients SET nickname = ?, rules = ? WHERE id = ?`, c.Nickname, string(rulesJSON), c.ID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,3 +168,100 @@ func (s *SQLiteStore) GetClientRules(id string) ([]protocol.ProxyRule, error) {
|
|||||||
}
|
}
|
||||||
return c.Rules, nil
|
return c.Rules, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 插件存储方法 ==========
|
||||||
|
|
||||||
|
// GetAllPlugins 获取所有插件
|
||||||
|
func (s *SQLiteStore) GetAllPlugins() ([]PluginData, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT name, version, type, source, description, author, checksum, size, enabled
|
||||||
|
FROM plugins
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var plugins []PluginData
|
||||||
|
for rows.Next() {
|
||||||
|
var p PluginData
|
||||||
|
var enabled int
|
||||||
|
err := rows.Scan(&p.Name, &p.Version, &p.Type, &p.Source,
|
||||||
|
&p.Description, &p.Author, &p.Checksum, &p.Size, &enabled)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.Enabled = enabled == 1
|
||||||
|
plugins = append(plugins, p)
|
||||||
|
}
|
||||||
|
return plugins, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlugin 获取单个插件
|
||||||
|
func (s *SQLiteStore) GetPlugin(name string) (*PluginData, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
var p PluginData
|
||||||
|
var enabled int
|
||||||
|
err := s.db.QueryRow(`
|
||||||
|
SELECT name, version, type, source, description, author, checksum, size, enabled
|
||||||
|
FROM plugins WHERE name = ?
|
||||||
|
`, name).Scan(&p.Name, &p.Version, &p.Type, &p.Source,
|
||||||
|
&p.Description, &p.Author, &p.Checksum, &p.Size, &enabled)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.Enabled = enabled == 1
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SavePlugin 保存插件
|
||||||
|
func (s *SQLiteStore) SavePlugin(p *PluginData) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
enabled := 0
|
||||||
|
if p.Enabled {
|
||||||
|
enabled = 1
|
||||||
|
}
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
INSERT OR REPLACE INTO plugins
|
||||||
|
(name, version, type, source, description, author, checksum, size, enabled, wasm_data)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, p.Name, p.Version, p.Type, p.Source, p.Description, p.Author,
|
||||||
|
p.Checksum, p.Size, enabled, p.WASMData)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePlugin 删除插件
|
||||||
|
func (s *SQLiteStore) DeletePlugin(name string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
_, err := s.db.Exec(`DELETE FROM plugins WHERE name = ?`, name)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPluginEnabled 设置插件启用状态
|
||||||
|
func (s *SQLiteStore) SetPluginEnabled(name string, enabled bool) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
val := 0
|
||||||
|
if enabled {
|
||||||
|
val = 1
|
||||||
|
}
|
||||||
|
_, err := s.db.Exec(`UPDATE plugins SET enabled = ? WHERE name = ?`, val, name)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPluginWASM 获取插件 WASM 数据
|
||||||
|
func (s *SQLiteStore) GetPluginWASM(name string) ([]byte, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
var data []byte
|
||||||
|
err := s.db.QueryRow(`SELECT wasm_data FROM plugins WHERE name = ?`, name).Scan(&data)
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,28 +2,26 @@ package plugin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/gotunnel/internal/server/db"
|
||||||
"github.com/gotunnel/pkg/plugin"
|
"github.com/gotunnel/pkg/plugin"
|
||||||
"github.com/gotunnel/pkg/plugin/builtin"
|
"github.com/gotunnel/pkg/plugin/builtin"
|
||||||
"github.com/gotunnel/pkg/plugin/store"
|
|
||||||
"github.com/gotunnel/pkg/plugin/wasm"
|
"github.com/gotunnel/pkg/plugin/wasm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Manager 服务端 plugin 管理器
|
// Manager 服务端 plugin 管理器
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
registry *plugin.Registry
|
registry *plugin.Registry
|
||||||
store store.PluginStore
|
store db.PluginStore
|
||||||
runtime *wasm.Runtime
|
runtime *wasm.Runtime
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewManager 创建 plugin 管理器
|
// NewManager 创建 plugin 管理器
|
||||||
func NewManager(pluginStore store.PluginStore) (*Manager, error) {
|
func NewManager(pluginStore db.PluginStore) (*Manager, error) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
runtime, err := wasm.NewRuntime(ctx)
|
runtime, err := wasm.NewRuntime(ctx)
|
||||||
@@ -51,12 +49,11 @@ func NewManager(pluginStore store.PluginStore) (*Manager, error) {
|
|||||||
// 注意: tcp, udp, http, https 是内置类型,直接在 tunnel 中处理
|
// 注意: tcp, udp, http, https 是内置类型,直接在 tunnel 中处理
|
||||||
// 这里只注册需要通过 plugin 系统提供的协议
|
// 这里只注册需要通过 plugin 系统提供的协议
|
||||||
func (m *Manager) registerBuiltins() error {
|
func (m *Manager) registerBuiltins() error {
|
||||||
// 注册 SOCKS5 plugin
|
// 使用统一的插件注册入口
|
||||||
if err := m.registry.RegisterBuiltin(builtin.NewSOCKS5Plugin()); err != nil {
|
if err := m.registry.RegisterAll(builtin.GetAll()); err != nil {
|
||||||
return fmt.Errorf("register socks5: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
log.Printf("[Plugin] Registered %d builtin plugins", len(builtin.GetAll()))
|
||||||
log.Println("[Plugin] Builtin plugins registered: socks5")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,15 +68,15 @@ func (m *Manager) LoadStoredPlugins(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, meta := range plugins {
|
for _, p := range plugins {
|
||||||
data, err := m.store.GetPluginData(meta.Name)
|
data, err := m.store.GetPluginWASM(p.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[Plugin] Failed to load %s: %v", meta.Name, err)
|
log.Printf("[Plugin] Failed to load %s: %v", p.Name, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.loadWASMPlugin(ctx, meta.Name, data); err != nil {
|
if err := m.loadWASMPlugin(ctx, p.Name, data); err != nil {
|
||||||
log.Printf("[Plugin] Failed to init %s: %v", meta.Name, err)
|
log.Printf("[Plugin] Failed to init %s: %v", p.Name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,28 +94,19 @@ func (m *Manager) loadWASMPlugin(ctx context.Context, name string, data []byte)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// InstallPlugin 安装新的 WASM plugin
|
// InstallPlugin 安装新的 WASM plugin
|
||||||
func (m *Manager) InstallPlugin(ctx context.Context, meta plugin.PluginMetadata, wasmData []byte) error {
|
func (m *Manager) InstallPlugin(ctx context.Context, p *db.PluginData) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
// 验证 checksum
|
|
||||||
hash := sha256.Sum256(wasmData)
|
|
||||||
checksum := hex.EncodeToString(hash[:])
|
|
||||||
if meta.Checksum != "" && meta.Checksum != checksum {
|
|
||||||
return fmt.Errorf("checksum mismatch")
|
|
||||||
}
|
|
||||||
meta.Checksum = checksum
|
|
||||||
meta.Size = int64(len(wasmData))
|
|
||||||
|
|
||||||
// 存储到数据库
|
// 存储到数据库
|
||||||
if m.store != nil {
|
if m.store != nil {
|
||||||
if err := m.store.SavePlugin(meta, wasmData); err != nil {
|
if err := m.store.SavePlugin(p); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载到运行时
|
// 加载到运行时
|
||||||
return m.loadWASMPlugin(ctx, meta.Name, wasmData)
|
return m.loadWASMPlugin(ctx, p.Name, p.WASMData)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetHandler 返回指定代理类型的 handler
|
// GetHandler 返回指定代理类型的 handler
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ func validateClientID(id string) bool {
|
|||||||
// ClientStatus 客户端状态
|
// ClientStatus 客户端状态
|
||||||
type ClientStatus struct {
|
type ClientStatus struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
Nickname string `json:"nickname,omitempty"`
|
||||||
Online bool `json:"online"`
|
Online bool `json:"online"`
|
||||||
LastPing string `json:"last_ping,omitempty"`
|
LastPing string `json:"last_ping,omitempty"`
|
||||||
RuleCount int `json:"rule_count"`
|
RuleCount int `json:"rule_count"`
|
||||||
@@ -36,6 +37,23 @@ type ServerInterface interface {
|
|||||||
ReloadConfig() error
|
ReloadConfig() error
|
||||||
GetBindAddr() string
|
GetBindAddr() string
|
||||||
GetBindPort() int
|
GetBindPort() int
|
||||||
|
// 客户端控制
|
||||||
|
PushConfigToClient(clientID string) error
|
||||||
|
DisconnectClient(clientID string) error
|
||||||
|
GetPluginList() []PluginInfo
|
||||||
|
EnablePlugin(name string) error
|
||||||
|
DisablePlugin(name string) error
|
||||||
|
InstallPluginsToClient(clientID string, plugins []string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// PluginInfo 插件信息
|
||||||
|
type PluginInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AppInterface 应用接口
|
// AppInterface 应用接口
|
||||||
@@ -68,6 +86,8 @@ func RegisterRoutes(r *Router, app AppInterface) {
|
|||||||
api.HandleFunc("/client/", h.handleClient)
|
api.HandleFunc("/client/", h.handleClient)
|
||||||
api.HandleFunc("/config", h.handleConfig)
|
api.HandleFunc("/config", h.handleConfig)
|
||||||
api.HandleFunc("/config/reload", h.handleReload)
|
api.HandleFunc("/config/reload", h.handleReload)
|
||||||
|
api.HandleFunc("/plugins", h.handlePlugins)
|
||||||
|
api.HandleFunc("/plugin/", h.handlePlugin)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *APIHandler) handleStatus(rw http.ResponseWriter, r *http.Request) {
|
func (h *APIHandler) handleStatus(rw http.ResponseWriter, r *http.Request) {
|
||||||
@@ -108,7 +128,7 @@ func (h *APIHandler) getClients(rw http.ResponseWriter) {
|
|||||||
statusMap := h.server.GetAllClientStatus()
|
statusMap := h.server.GetAllClientStatus()
|
||||||
var result []ClientStatus
|
var result []ClientStatus
|
||||||
for _, c := range clients {
|
for _, c := range clients {
|
||||||
cs := ClientStatus{ID: c.ID, RuleCount: len(c.Rules)}
|
cs := ClientStatus{ID: c.ID, Nickname: c.Nickname, RuleCount: len(c.Rules)}
|
||||||
if s, ok := statusMap[c.ID]; ok {
|
if s, ok := statusMap[c.ID]; ok {
|
||||||
cs.Online = s.Online
|
cs.Online = s.Online
|
||||||
cs.LastPing = s.LastPing
|
cs.LastPing = s.LastPing
|
||||||
@@ -156,6 +176,32 @@ func (h *APIHandler) handleClient(rw http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(rw, "client id required", http.StatusBadRequest)
|
http.Error(rw, "client id required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理子路径操作
|
||||||
|
if idx := len(clientID) - 1; idx > 0 {
|
||||||
|
if clientID[idx] == '/' {
|
||||||
|
clientID = clientID[:idx]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否是特殊操作
|
||||||
|
parts := splitPath(clientID)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
clientID = parts[0]
|
||||||
|
action := parts[1]
|
||||||
|
switch action {
|
||||||
|
case "push":
|
||||||
|
h.pushConfigToClient(rw, r, clientID)
|
||||||
|
return
|
||||||
|
case "disconnect":
|
||||||
|
h.disconnectClient(rw, r, clientID)
|
||||||
|
return
|
||||||
|
case "install-plugins":
|
||||||
|
h.installPluginsToClient(rw, r, clientID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
h.getClient(rw, clientID)
|
h.getClient(rw, clientID)
|
||||||
@@ -168,6 +214,16 @@ func (h *APIHandler) handleClient(rw http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// splitPath 分割路径
|
||||||
|
func splitPath(path string) []string {
|
||||||
|
for i, c := range path {
|
||||||
|
if c == '/' {
|
||||||
|
return []string{path[:i], path[i+1:]}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []string{path}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *APIHandler) getClient(rw http.ResponseWriter, clientID string) {
|
func (h *APIHandler) getClient(rw http.ResponseWriter, clientID string) {
|
||||||
client, err := h.clientStore.GetClient(clientID)
|
client, err := h.clientStore.GetClient(clientID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -176,27 +232,29 @@ func (h *APIHandler) getClient(rw http.ResponseWriter, clientID string) {
|
|||||||
}
|
}
|
||||||
online, lastPing := h.server.GetClientStatus(clientID)
|
online, lastPing := h.server.GetClientStatus(clientID)
|
||||||
h.jsonResponse(rw, map[string]interface{}{
|
h.jsonResponse(rw, map[string]interface{}{
|
||||||
"id": client.ID, "rules": client.Rules,
|
"id": client.ID, "nickname": client.Nickname, "rules": client.Rules,
|
||||||
"online": online, "last_ping": lastPing,
|
"online": online, "last_ping": lastPing,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *APIHandler) updateClient(rw http.ResponseWriter, r *http.Request, clientID string) {
|
func (h *APIHandler) updateClient(rw http.ResponseWriter, r *http.Request, clientID string) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Rules []protocol.ProxyRule `json:"rules"`
|
Nickname string `json:"nickname"`
|
||||||
|
Rules []protocol.ProxyRule `json:"rules"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusBadRequest)
|
http.Error(rw, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
exists, _ := h.clientStore.ClientExists(clientID)
|
client, err := h.clientStore.GetClient(clientID)
|
||||||
if !exists {
|
if err != nil {
|
||||||
http.Error(rw, "client not found", http.StatusNotFound)
|
http.Error(rw, "client not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
client := &db.Client{ID: clientID, Rules: req.Rules}
|
client.Nickname = req.Nickname
|
||||||
|
client.Rules = req.Rules
|
||||||
if err := h.clientStore.UpdateClient(client); err != nil {
|
if err := h.clientStore.UpdateClient(client); err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -333,3 +391,130 @@ func (h *APIHandler) jsonResponse(rw http.ResponseWriter, data interface{}) {
|
|||||||
rw.Header().Set("Content-Type", "application/json")
|
rw.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(rw).Encode(data)
|
json.NewEncoder(rw).Encode(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pushConfigToClient 推送配置到客户端
|
||||||
|
func (h *APIHandler) pushConfigToClient(rw http.ResponseWriter, r *http.Request, clientID string) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
online, _ := h.server.GetClientStatus(clientID)
|
||||||
|
if !online {
|
||||||
|
http.Error(rw, "client not online", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.server.PushConfigToClient(clientID); err != nil {
|
||||||
|
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.jsonResponse(rw, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// disconnectClient 断开客户端连接
|
||||||
|
func (h *APIHandler) disconnectClient(rw http.ResponseWriter, r *http.Request, clientID string) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.server.DisconnectClient(clientID); err != nil {
|
||||||
|
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.jsonResponse(rw, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePlugins 处理插件列表
|
||||||
|
func (h *APIHandler) handlePlugins(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
plugins := h.server.GetPluginList()
|
||||||
|
h.jsonResponse(rw, plugins)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePlugin 处理单个插件操作
|
||||||
|
func (h *APIHandler) handlePlugin(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
path := r.URL.Path[len("/api/plugin/"):]
|
||||||
|
if path == "" {
|
||||||
|
http.Error(rw, "plugin name required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := splitPath(path)
|
||||||
|
pluginName := parts[0]
|
||||||
|
|
||||||
|
if len(parts) == 2 {
|
||||||
|
action := parts[1]
|
||||||
|
switch action {
|
||||||
|
case "enable":
|
||||||
|
h.enablePlugin(rw, r, pluginName)
|
||||||
|
return
|
||||||
|
case "disable":
|
||||||
|
h.disablePlugin(rw, r, pluginName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Error(rw, "invalid action", http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *APIHandler) enablePlugin(rw http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.server.EnablePlugin(name); err != nil {
|
||||||
|
http.Error(rw, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.jsonResponse(rw, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *APIHandler) disablePlugin(rw http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.server.DisablePlugin(name); err != nil {
|
||||||
|
http.Error(rw, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.jsonResponse(rw, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// installPluginsToClient 安装插件到客户端
|
||||||
|
func (h *APIHandler) installPluginsToClient(rw http.ResponseWriter, r *http.Request, clientID string) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
online, _ := h.server.GetClientStatus(clientID)
|
||||||
|
if !online {
|
||||||
|
http.Error(rw, "client not online", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Plugins []string `json:"plugins"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(rw, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.Plugins) == 0 {
|
||||||
|
http.Error(rw, "no plugins specified", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.server.InstallPluginsToClient(clientID, req.Plugins); err != nil {
|
||||||
|
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.jsonResponse(rw, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|||||||
80
internal/server/router/auth.go
Normal file
80
internal/server/router/auth.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gotunnel/pkg/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthHandler 认证处理器
|
||||||
|
type AuthHandler struct {
|
||||||
|
username string
|
||||||
|
password string
|
||||||
|
jwtAuth *auth.JWTAuth
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthHandler 创建认证处理器
|
||||||
|
func NewAuthHandler(username, password string, jwtAuth *auth.JWTAuth) *AuthHandler {
|
||||||
|
return &AuthHandler{
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
jwtAuth: jwtAuth,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterAuthRoutes 注册认证路由
|
||||||
|
func RegisterAuthRoutes(r *Router, h *AuthHandler) {
|
||||||
|
r.HandleFunc("/api/auth/login", h.handleLogin)
|
||||||
|
r.HandleFunc("/api/auth/check", h.handleCheck)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLogin 处理登录请求
|
||||||
|
func (h *AuthHandler) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证用户名密码
|
||||||
|
userMatch := subtle.ConstantTimeCompare([]byte(req.Username), []byte(h.username)) == 1
|
||||||
|
passMatch := subtle.ConstantTimeCompare([]byte(req.Password), []byte(h.password)) == 1
|
||||||
|
|
||||||
|
if !userMatch || !passMatch {
|
||||||
|
http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 token
|
||||||
|
token, err := h.jwtAuth.GenerateToken(req.Username)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"token": token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCheck 检查 token 是否有效
|
||||||
|
func (h *AuthHandler) handleCheck(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]bool{"valid": true})
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@ package router
|
|||||||
import (
|
import (
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gotunnel/pkg/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Router 路由管理器
|
// Router 路由管理器
|
||||||
@@ -84,3 +87,37 @@ func BasicAuthMiddleware(auth *AuthConfig, next http.Handler) http.Handler {
|
|||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JWTMiddleware JWT 认证中间件
|
||||||
|
func JWTMiddleware(jwtAuth *auth.JWTAuth, skipPaths []string, next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// 只对 /api/ 路径进行认证
|
||||||
|
if !strings.HasPrefix(r.URL.Path, "/api/") {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否跳过认证
|
||||||
|
for _, path := range skipPaths {
|
||||||
|
if strings.HasPrefix(r.URL.Path, path) {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 Header 获取 token
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
if _, err := jwtAuth.ValidateToken(token); err != nil {
|
||||||
|
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package tunnel
|
package tunnel
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
@@ -9,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gotunnel/internal/server/db"
|
"github.com/gotunnel/internal/server/db"
|
||||||
|
"github.com/gotunnel/internal/server/router"
|
||||||
"github.com/gotunnel/pkg/plugin"
|
"github.com/gotunnel/pkg/plugin"
|
||||||
"github.com/gotunnel/pkg/protocol"
|
"github.com/gotunnel/pkg/protocol"
|
||||||
"github.com/gotunnel/pkg/proxy"
|
"github.com/gotunnel/pkg/proxy"
|
||||||
@@ -19,11 +22,18 @@ import (
|
|||||||
|
|
||||||
// 服务端常量
|
// 服务端常量
|
||||||
const (
|
const (
|
||||||
authTimeout = 10 * time.Second
|
authTimeout = 10 * time.Second
|
||||||
heartbeatTimeout = 10 * time.Second
|
heartbeatTimeout = 10 * time.Second
|
||||||
udpBufferSize = 65535
|
udpBufferSize = 65535
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// generateClientID 生成随机客户端 ID
|
||||||
|
func generateClientID() string {
|
||||||
|
bytes := make([]byte, 8)
|
||||||
|
rand.Read(bytes)
|
||||||
|
return hex.EncodeToString(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
// Server 隧道服务端
|
// Server 隧道服务端
|
||||||
type Server struct {
|
type Server struct {
|
||||||
clientStore db.ClientStore
|
clientStore db.ClientStore
|
||||||
@@ -130,24 +140,44 @@ func (s *Server) handleConnection(conn net.Conn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if authReq.Token != s.token {
|
if authReq.Token != s.token {
|
||||||
s.sendAuthResponse(conn, false, "invalid token")
|
s.sendAuthResponse(conn, false, "invalid token", "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
rules, err := s.clientStore.GetClientRules(authReq.ClientID)
|
// 如果客户端没有提供 ID,则生成一个新的
|
||||||
if err != nil || rules == nil {
|
clientID := authReq.ClientID
|
||||||
s.sendAuthResponse(conn, false, "client not configured")
|
if clientID == "" {
|
||||||
|
clientID = generateClientID()
|
||||||
|
// 创建新客户端记录
|
||||||
|
newClient := &db.Client{ID: clientID, Rules: []protocol.ProxyRule{}}
|
||||||
|
if err := s.clientStore.CreateClient(newClient); err != nil {
|
||||||
|
log.Printf("[Server] Create client error: %v", err)
|
||||||
|
s.sendAuthResponse(conn, false, "failed to create client", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[Server] New client registered: %s", clientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查客户端是否存在
|
||||||
|
exists, err := s.clientStore.ClientExists(clientID)
|
||||||
|
if err != nil || !exists {
|
||||||
|
s.sendAuthResponse(conn, false, "client not found", "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rules, _ := s.clientStore.GetClientRules(clientID)
|
||||||
|
if rules == nil {
|
||||||
|
rules = []protocol.ProxyRule{}
|
||||||
|
}
|
||||||
|
|
||||||
conn.SetReadDeadline(time.Time{})
|
conn.SetReadDeadline(time.Time{})
|
||||||
|
|
||||||
if err := s.sendAuthResponse(conn, true, "ok"); err != nil {
|
if err := s.sendAuthResponse(conn, true, "ok", clientID); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[Server] Client %s authenticated", authReq.ClientID)
|
log.Printf("[Server] Client %s authenticated", clientID)
|
||||||
s.setupClientSession(conn, authReq.ClientID, rules)
|
s.setupClientSession(conn, clientID, rules)
|
||||||
}
|
}
|
||||||
|
|
||||||
// setupClientSession 建立客户端会话
|
// setupClientSession 建立客户端会话
|
||||||
@@ -183,8 +213,8 @@ func (s *Server) setupClientSession(conn net.Conn, clientID string, rules []prot
|
|||||||
}
|
}
|
||||||
|
|
||||||
// sendAuthResponse 发送认证响应
|
// sendAuthResponse 发送认证响应
|
||||||
func (s *Server) sendAuthResponse(conn net.Conn, success bool, message string) error {
|
func (s *Server) sendAuthResponse(conn net.Conn, success bool, message, clientID string) error {
|
||||||
resp := protocol.AuthResponse{Success: success, Message: message}
|
resp := protocol.AuthResponse{Success: success, Message: message, ClientID: clientID}
|
||||||
msg, err := protocol.NewMessage(protocol.MsgTypeAuthResp, resp)
|
msg, err := protocol.NewMessage(protocol.MsgTypeAuthResp, resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -458,6 +488,103 @@ func (s *Server) GetBindPort() int {
|
|||||||
return s.bindPort
|
return s.bindPort
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PushConfigToClient 推送配置到客户端
|
||||||
|
func (s *Server) PushConfigToClient(clientID string) error {
|
||||||
|
s.mu.RLock()
|
||||||
|
cs, ok := s.clients[clientID]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("client %s not found", clientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
rules, err := s.clientStore.GetClientRules(clientID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.sendProxyConfig(cs.Session, rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisconnectClient 断开客户端连接
|
||||||
|
func (s *Server) DisconnectClient(clientID string) error {
|
||||||
|
s.mu.RLock()
|
||||||
|
cs, ok := s.clients[clientID]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("client %s not found", clientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cs.Session.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPluginList 获取插件列表
|
||||||
|
func (s *Server) GetPluginList() []router.PluginInfo {
|
||||||
|
var result []router.PluginInfo
|
||||||
|
|
||||||
|
if s.pluginRegistry == nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, info := range s.pluginRegistry.List() {
|
||||||
|
result = append(result, router.PluginInfo{
|
||||||
|
Name: info.Metadata.Name,
|
||||||
|
Version: info.Metadata.Version,
|
||||||
|
Type: string(info.Metadata.Type),
|
||||||
|
Description: info.Metadata.Description,
|
||||||
|
Source: string(info.Metadata.Source),
|
||||||
|
Enabled: info.Enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnablePlugin 启用插件
|
||||||
|
func (s *Server) EnablePlugin(name string) error {
|
||||||
|
if s.pluginRegistry == nil {
|
||||||
|
return fmt.Errorf("plugin registry not initialized")
|
||||||
|
}
|
||||||
|
return s.pluginRegistry.Enable(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisablePlugin 禁用插件
|
||||||
|
func (s *Server) DisablePlugin(name string) error {
|
||||||
|
if s.pluginRegistry == nil {
|
||||||
|
return fmt.Errorf("plugin registry not initialized")
|
||||||
|
}
|
||||||
|
return s.pluginRegistry.Disable(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstallPluginsToClient 安装插件到客户端
|
||||||
|
func (s *Server) InstallPluginsToClient(clientID string, plugins []string) error {
|
||||||
|
s.mu.RLock()
|
||||||
|
cs, ok := s.clients[clientID]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("client %s not found", clientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.sendInstallPlugins(cs.Session, plugins)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendInstallPlugins 发送安装插件请求
|
||||||
|
func (s *Server) sendInstallPlugins(session *yamux.Session, plugins []string) error {
|
||||||
|
stream, err := session.Open()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stream.Close()
|
||||||
|
|
||||||
|
req := protocol.InstallPluginsRequest{Plugins: plugins}
|
||||||
|
msg, err := protocol.NewMessage(protocol.MsgTypeInstallPlugins, req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return protocol.WriteMessage(stream, msg)
|
||||||
|
}
|
||||||
|
|
||||||
// startUDPListener 启动 UDP 监听
|
// startUDPListener 启动 UDP 监听
|
||||||
func (s *Server) startUDPListener(cs *ClientSession, rule protocol.ProxyRule) {
|
func (s *Server) startUDPListener(cs *ClientSession, rule protocol.ProxyRule) {
|
||||||
if err := s.portManager.Reserve(rule.RemotePort, cs.ID); err != nil {
|
if err := s.portManager.Reserve(rule.RemotePort, cs.ID); err != nil {
|
||||||
|
|||||||
68
pkg/auth/jwt.go
Normal file
68
pkg/auth/jwt.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Claims JWT claims
|
||||||
|
type Claims struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWTAuth JWT 认证管理器
|
||||||
|
type JWTAuth struct {
|
||||||
|
secret []byte
|
||||||
|
expiration time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewJWTAuth 创建 JWT 认证管理器
|
||||||
|
func NewJWTAuth(secret string, expHours int) *JWTAuth {
|
||||||
|
if secret == "" {
|
||||||
|
secret = generateSecret()
|
||||||
|
}
|
||||||
|
return &JWTAuth{
|
||||||
|
secret: []byte(secret),
|
||||||
|
expiration: time.Duration(expHours) * time.Hour,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateToken 生成 JWT token
|
||||||
|
func (j *JWTAuth) GenerateToken(username string) (string, error) {
|
||||||
|
claims := &Claims{
|
||||||
|
Username: username,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(j.expiration)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
Issuer: "gotunnel",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString(j.secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateToken 验证 JWT token
|
||||||
|
func (j *JWTAuth) ValidateToken(tokenString string) (*Claims, error) {
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return j.secret, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
return nil, jwt.ErrSignatureInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateSecret() string {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
@@ -1,11 +1,6 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
// RegisterBuiltins 注册所有内置 plugins
|
// RegisterBuiltins 注册所有内置 plugins
|
||||||
// 注意:此函数需要在调用方导入 builtin 包并手动注册
|
|
||||||
// 示例:
|
|
||||||
// registry := plugin.NewRegistry()
|
|
||||||
// registry.RegisterBuiltin(builtin.NewSOCKS5Plugin())
|
|
||||||
// registry.RegisterBuiltin(builtin.NewHTTPPlugin())
|
|
||||||
func RegisterBuiltins(registry *Registry, handlers ...ProxyHandler) error {
|
func RegisterBuiltins(registry *Registry, handlers ...ProxyHandler) error {
|
||||||
for _, handler := range handlers {
|
for _, handler := range handlers {
|
||||||
if err := registry.RegisterBuiltin(handler); err != nil {
|
if err := registry.RegisterBuiltin(handler); err != nil {
|
||||||
|
|||||||
16
pkg/plugin/builtin/register.go
Normal file
16
pkg/plugin/builtin/register.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package builtin
|
||||||
|
|
||||||
|
import "github.com/gotunnel/pkg/plugin"
|
||||||
|
|
||||||
|
// 全局插件注册表
|
||||||
|
var registry []plugin.ProxyHandler
|
||||||
|
|
||||||
|
// Register 插件自注册函数,由各插件的 init() 调用
|
||||||
|
func Register(handler plugin.ProxyHandler) {
|
||||||
|
registry = append(registry, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAll 返回所有已注册的内置插件
|
||||||
|
func GetAll() []plugin.ProxyHandler {
|
||||||
|
return registry
|
||||||
|
}
|
||||||
@@ -10,6 +10,10 @@ import (
|
|||||||
"github.com/gotunnel/pkg/plugin"
|
"github.com/gotunnel/pkg/plugin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(NewSOCKS5Plugin())
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
socks5Version = 0x05
|
socks5Version = 0x05
|
||||||
noAuth = 0x00
|
noAuth = 0x00
|
||||||
|
|||||||
86
pkg/plugin/builtin/vnc.go
Normal file
86
pkg/plugin/builtin/vnc.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package builtin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"github.com/gotunnel/pkg/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(NewVNCPlugin())
|
||||||
|
}
|
||||||
|
|
||||||
|
// VNCPlugin VNC 远程桌面插件
|
||||||
|
type VNCPlugin struct {
|
||||||
|
config map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewVNCPlugin 创建 VNC plugin
|
||||||
|
func NewVNCPlugin() *VNCPlugin {
|
||||||
|
return &VNCPlugin{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata 返回 plugin 信息
|
||||||
|
func (p *VNCPlugin) Metadata() plugin.PluginMetadata {
|
||||||
|
return plugin.PluginMetadata{
|
||||||
|
Name: "vnc",
|
||||||
|
Version: "1.0.0",
|
||||||
|
Type: plugin.PluginTypeApp,
|
||||||
|
Source: plugin.PluginSourceBuiltin,
|
||||||
|
Description: "VNC remote desktop relay (connects to client's local VNC server)",
|
||||||
|
Author: "GoTunnel",
|
||||||
|
Capabilities: []string{
|
||||||
|
"dial", "read", "write", "close",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init 初始化 plugin
|
||||||
|
func (p *VNCPlugin) Init(config map[string]string) error {
|
||||||
|
p.config = config
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleConn 处理 VNC 连接
|
||||||
|
// 将外部 VNC 客户端连接转发到客户端本地的 VNC 服务
|
||||||
|
func (p *VNCPlugin) HandleConn(conn net.Conn, dialer plugin.Dialer) error {
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// 默认连接客户端本地的 VNC 服务 (5900)
|
||||||
|
vncAddr := "127.0.0.1:5900"
|
||||||
|
if addr, ok := p.config["vnc_addr"]; ok && addr != "" {
|
||||||
|
vncAddr = addr
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[VNC] New connection from %s, forwarding to %s", conn.RemoteAddr(), vncAddr)
|
||||||
|
|
||||||
|
// 通过隧道连接到客户端本地的 VNC 服务
|
||||||
|
remote, err := dialer.Dial("tcp", vncAddr)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[VNC] Failed to connect to %s: %v", vncAddr, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer remote.Close()
|
||||||
|
|
||||||
|
// 双向转发 VNC 流量
|
||||||
|
errCh := make(chan error, 2)
|
||||||
|
go func() {
|
||||||
|
_, err := io.Copy(remote, conn)
|
||||||
|
errCh <- err
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
_, err := io.Copy(conn, remote)
|
||||||
|
errCh <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 等待任一方向完成
|
||||||
|
<-errCh
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 释放资源
|
||||||
|
func (p *VNCPlugin) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -8,14 +8,16 @@ import (
|
|||||||
|
|
||||||
// Registry 管理可用的 plugins
|
// Registry 管理可用的 plugins
|
||||||
type Registry struct {
|
type Registry struct {
|
||||||
builtin map[string]ProxyHandler // 内置 Go 实现
|
builtin map[string]ProxyHandler // 内置 Go 实现
|
||||||
mu sync.RWMutex
|
enabled map[string]bool // 启用状态
|
||||||
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRegistry 创建 plugin 注册表
|
// NewRegistry 创建 plugin 注册表
|
||||||
func NewRegistry() *Registry {
|
func NewRegistry() *Registry {
|
||||||
return &Registry{
|
return &Registry{
|
||||||
builtin: make(map[string]ProxyHandler),
|
builtin: make(map[string]ProxyHandler),
|
||||||
|
enabled: make(map[string]bool),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +36,7 @@ func (r *Registry) RegisterBuiltin(handler ProxyHandler) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
r.builtin[meta.Name] = handler
|
r.builtin[meta.Name] = handler
|
||||||
|
r.enabled[meta.Name] = true // 默认启用
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +47,9 @@ func (r *Registry) Get(proxyType string) (ProxyHandler, error) {
|
|||||||
|
|
||||||
// 先查找内置 plugin
|
// 先查找内置 plugin
|
||||||
if handler, ok := r.builtin[proxyType]; ok {
|
if handler, ok := r.builtin[proxyType]; ok {
|
||||||
|
if !r.enabled[proxyType] {
|
||||||
|
return nil, fmt.Errorf("plugin %s is disabled", proxyType)
|
||||||
|
}
|
||||||
return handler, nil
|
return handler, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,10 +64,11 @@ func (r *Registry) List() []PluginInfo {
|
|||||||
var plugins []PluginInfo
|
var plugins []PluginInfo
|
||||||
|
|
||||||
// 内置 plugins
|
// 内置 plugins
|
||||||
for _, handler := range r.builtin {
|
for name, handler := range r.builtin {
|
||||||
plugins = append(plugins, PluginInfo{
|
plugins = append(plugins, PluginInfo{
|
||||||
Metadata: handler.Metadata(),
|
Metadata: handler.Metadata(),
|
||||||
Loaded: true,
|
Loaded: true,
|
||||||
|
Enabled: r.enabled[name],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,3 +98,44 @@ func (r *Registry) Close(ctx context.Context) error {
|
|||||||
|
|
||||||
return lastErr
|
return lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enable 启用插件
|
||||||
|
func (r *Registry) Enable(name string) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
if _, ok := r.builtin[name]; !ok {
|
||||||
|
return fmt.Errorf("plugin %s not found", name)
|
||||||
|
}
|
||||||
|
r.enabled[name] = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable 禁用插件
|
||||||
|
func (r *Registry) Disable(name string) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
if _, ok := r.builtin[name]; !ok {
|
||||||
|
return fmt.Errorf("plugin %s not found", name)
|
||||||
|
}
|
||||||
|
r.enabled[name] = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEnabled 检查插件是否启用
|
||||||
|
func (r *Registry) IsEnabled(name string) bool {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
return r.enabled[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterAll 批量注册插件
|
||||||
|
func (r *Registry) RegisterAll(handlers []ProxyHandler) error {
|
||||||
|
for _, handler := range handlers {
|
||||||
|
if err := r.RegisterBuiltin(handler); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gotunnel/pkg/plugin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// PluginStore 管理 plugin 持久化
|
|
||||||
type PluginStore interface {
|
|
||||||
// GetAllPlugins 返回所有存储的 plugins
|
|
||||||
GetAllPlugins() ([]plugin.PluginMetadata, error)
|
|
||||||
|
|
||||||
// GetPlugin 返回指定 plugin 的元数据
|
|
||||||
GetPlugin(name string) (*plugin.PluginMetadata, error)
|
|
||||||
|
|
||||||
// GetPluginData 返回 WASM 二进制
|
|
||||||
GetPluginData(name string) ([]byte, error)
|
|
||||||
|
|
||||||
// SavePlugin 存储 plugin
|
|
||||||
SavePlugin(metadata plugin.PluginMetadata, wasmData []byte) error
|
|
||||||
|
|
||||||
// DeletePlugin 删除 plugin
|
|
||||||
DeletePlugin(name string) error
|
|
||||||
|
|
||||||
// PluginExists 检查 plugin 是否存在
|
|
||||||
PluginExists(name string) (bool, error)
|
|
||||||
|
|
||||||
// Close 关闭存储
|
|
||||||
Close() error
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gotunnel/pkg/plugin"
|
|
||||||
_ "modernc.org/sqlite"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SQLiteStore SQLite 实现的 PluginStore
|
|
||||||
type SQLiteStore struct {
|
|
||||||
db *sql.DB
|
|
||||||
mu sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSQLiteStore 创建 SQLite plugin 存储
|
|
||||||
func NewSQLiteStore(dbPath string) (*SQLiteStore, error) {
|
|
||||||
db, err := sql.Open("sqlite", dbPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
store := &SQLiteStore{db: db}
|
|
||||||
if err := store.init(); err != nil {
|
|
||||||
db.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return store, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// init 初始化数据库表
|
|
||||||
func (s *SQLiteStore) init() error {
|
|
||||||
query := `
|
|
||||||
CREATE TABLE IF NOT EXISTS plugins (
|
|
||||||
name TEXT PRIMARY KEY,
|
|
||||||
version TEXT NOT NULL,
|
|
||||||
type TEXT NOT NULL DEFAULT 'proxy',
|
|
||||||
source TEXT NOT NULL DEFAULT 'wasm',
|
|
||||||
description TEXT,
|
|
||||||
author TEXT,
|
|
||||||
checksum TEXT NOT NULL,
|
|
||||||
size INTEGER NOT NULL,
|
|
||||||
capabilities TEXT NOT NULL DEFAULT '[]',
|
|
||||||
config_schema TEXT NOT NULL DEFAULT '{}',
|
|
||||||
wasm_data BLOB NOT NULL,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)`
|
|
||||||
_, err := s.db.Exec(query)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAllPlugins 返回所有存储的 plugins
|
|
||||||
func (s *SQLiteStore) GetAllPlugins() ([]plugin.PluginMetadata, error) {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
|
|
||||||
rows, err := s.db.Query(`
|
|
||||||
SELECT name, version, type, source, description, author,
|
|
||||||
checksum, size, capabilities, config_schema
|
|
||||||
FROM plugins`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var plugins []plugin.PluginMetadata
|
|
||||||
for rows.Next() {
|
|
||||||
var m plugin.PluginMetadata
|
|
||||||
var capJSON, configJSON string
|
|
||||||
err := rows.Scan(&m.Name, &m.Version, &m.Type, &m.Source,
|
|
||||||
&m.Description, &m.Author, &m.Checksum, &m.Size,
|
|
||||||
&capJSON, &configJSON)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
json.Unmarshal([]byte(capJSON), &m.Capabilities)
|
|
||||||
json.Unmarshal([]byte(configJSON), &m.ConfigSchema)
|
|
||||||
plugins = append(plugins, m)
|
|
||||||
}
|
|
||||||
return plugins, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPlugin 返回指定 plugin 的元数据
|
|
||||||
func (s *SQLiteStore) GetPlugin(name string) (*plugin.PluginMetadata, error) {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
|
|
||||||
var m plugin.PluginMetadata
|
|
||||||
var capJSON, configJSON string
|
|
||||||
err := s.db.QueryRow(`
|
|
||||||
SELECT name, version, type, source, description, author,
|
|
||||||
checksum, size, capabilities, config_schema
|
|
||||||
FROM plugins WHERE name = ?`, name).Scan(
|
|
||||||
&m.Name, &m.Version, &m.Type, &m.Source,
|
|
||||||
&m.Description, &m.Author, &m.Checksum, &m.Size,
|
|
||||||
&capJSON, &configJSON)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
json.Unmarshal([]byte(capJSON), &m.Capabilities)
|
|
||||||
json.Unmarshal([]byte(configJSON), &m.ConfigSchema)
|
|
||||||
return &m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPluginData 返回 WASM 二进制
|
|
||||||
func (s *SQLiteStore) GetPluginData(name string) ([]byte, error) {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
|
|
||||||
var data []byte
|
|
||||||
err := s.db.QueryRow(`SELECT wasm_data FROM plugins WHERE name = ?`, name).Scan(&data)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, fmt.Errorf("plugin %s not found", name)
|
|
||||||
}
|
|
||||||
return data, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// SavePlugin 存储 plugin
|
|
||||||
func (s *SQLiteStore) SavePlugin(metadata plugin.PluginMetadata, wasmData []byte) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
capJSON, _ := json.Marshal(metadata.Capabilities)
|
|
||||||
configJSON, _ := json.Marshal(metadata.ConfigSchema)
|
|
||||||
|
|
||||||
_, err := s.db.Exec(`
|
|
||||||
INSERT OR REPLACE INTO plugins
|
|
||||||
(name, version, type, source, description, author, checksum, size,
|
|
||||||
capabilities, config_schema, wasm_data, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
metadata.Name, metadata.Version, metadata.Type, metadata.Source,
|
|
||||||
metadata.Description, metadata.Author, metadata.Checksum, metadata.Size,
|
|
||||||
string(capJSON), string(configJSON), wasmData, time.Now())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeletePlugin 删除 plugin
|
|
||||||
func (s *SQLiteStore) DeletePlugin(name string) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
_, err := s.db.Exec(`DELETE FROM plugins WHERE name = ?`, name)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// PluginExists 检查 plugin 是否存在
|
|
||||||
func (s *SQLiteStore) PluginExists(name string) (bool, error) {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
|
|
||||||
var count int
|
|
||||||
err := s.db.QueryRow(`SELECT COUNT(*) FROM plugins WHERE name = ?`, name).Scan(&count)
|
|
||||||
return count > 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close 关闭存储
|
|
||||||
func (s *SQLiteStore) Close() error {
|
|
||||||
return s.db.Close()
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,10 @@ import (
|
|||||||
type PluginType string
|
type PluginType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
PluginTypeProxy PluginType = "proxy" // 代理处理器 (SOCKS5, HTTP 等)
|
PluginTypeProxy PluginType = "proxy" // 代理协议插件 (SOCKS5 等)
|
||||||
|
PluginTypeApp PluginType = "app" // 应用插件 (VNC, 文件管理等)
|
||||||
|
PluginTypeService PluginType = "service" // 服务插件 (Web服务等)
|
||||||
|
PluginTypeTool PluginType = "tool" // 工具插件 (监控、日志等)
|
||||||
)
|
)
|
||||||
|
|
||||||
// PluginSource 表示 plugin 来源
|
// PluginSource 表示 plugin 来源
|
||||||
@@ -38,6 +41,7 @@ type PluginMetadata struct {
|
|||||||
type PluginInfo struct {
|
type PluginInfo struct {
|
||||||
Metadata PluginMetadata `json:"metadata"`
|
Metadata PluginMetadata `json:"metadata"`
|
||||||
Loaded bool `json:"loaded"`
|
Loaded bool `json:"loaded"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
LoadedAt time.Time `json:"loaded_at,omitempty"`
|
LoadedAt time.Time `json:"loaded_at,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ const (
|
|||||||
|
|
||||||
// UDP 相关消息
|
// UDP 相关消息
|
||||||
MsgTypeUDPData uint8 = 30 // UDP 数据包
|
MsgTypeUDPData uint8 = 30 // UDP 数据包
|
||||||
|
|
||||||
|
// 插件安装消息
|
||||||
|
MsgTypeInstallPlugins uint8 = 24 // 服务端推送安装插件列表
|
||||||
)
|
)
|
||||||
|
|
||||||
// Message 基础消息结构
|
// Message 基础消息结构
|
||||||
@@ -50,8 +53,9 @@ type AuthRequest struct {
|
|||||||
|
|
||||||
// AuthResponse 认证响应
|
// AuthResponse 认证响应
|
||||||
type AuthResponse struct {
|
type AuthResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
|
ClientID string `json:"client_id,omitempty"` // 服务端分配的客户端 ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProxyRule 代理规则
|
// ProxyRule 代理规则
|
||||||
@@ -137,6 +141,11 @@ type PluginReadyNotification struct {
|
|||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InstallPluginsRequest 安装插件请求
|
||||||
|
type InstallPluginsRequest struct {
|
||||||
|
Plugins []string `json:"plugins"` // 要安装的插件名称列表
|
||||||
|
}
|
||||||
|
|
||||||
// UDPPacket UDP 数据包
|
// UDPPacket UDP 数据包
|
||||||
type UDPPacket struct {
|
type UDPPacket struct {
|
||||||
RemotePort int `json:"remote_port"` // 服务端监听端口
|
RemotePort int `json:"remote_port"` // 服务端监听端口
|
||||||
|
|||||||
200
scripts/build.sh
Executable file
200
scripts/build.sh
Executable file
@@ -0,0 +1,200 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# 项目根目录
|
||||||
|
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
BUILD_DIR="$ROOT_DIR/build"
|
||||||
|
|
||||||
|
# 版本信息
|
||||||
|
VERSION="${VERSION:-dev}"
|
||||||
|
BUILD_TIME=$(date -u '+%Y-%m-%d %H:%M:%S')
|
||||||
|
GIT_COMMIT=$(git -C "$ROOT_DIR" rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||||
|
|
||||||
|
# 默认目标平台
|
||||||
|
DEFAULT_PLATFORMS="linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64"
|
||||||
|
|
||||||
|
# 是否启用 UPX 压缩
|
||||||
|
USE_UPX="${USE_UPX:-true}"
|
||||||
|
|
||||||
|
# 颜色输出
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
log_info() {
|
||||||
|
echo -e "${GREEN}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warn() {
|
||||||
|
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 检查 UPX 是否可用
|
||||||
|
check_upx() {
|
||||||
|
if command -v upx &> /dev/null; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# UPX 压缩二进制
|
||||||
|
compress_binary() {
|
||||||
|
local file=$1
|
||||||
|
if [ "$USE_UPX" != "true" ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if ! check_upx; then
|
||||||
|
log_warn "UPX not found, skipping compression"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
# macOS 二进制不支持 UPX
|
||||||
|
if [[ "$file" == *"darwin"* ]]; then
|
||||||
|
log_warn "Skipping UPX for macOS binary: $file"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
log_info "Compressing $file with UPX..."
|
||||||
|
upx -9 -q "$file" 2>/dev/null || log_warn "UPX compression failed for $file"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 构建 Web UI
|
||||||
|
build_web() {
|
||||||
|
log_info "Building web UI..."
|
||||||
|
cd "$ROOT_DIR/web"
|
||||||
|
if [ ! -d "node_modules" ]; then
|
||||||
|
log_info "Installing npm dependencies..."
|
||||||
|
npm install
|
||||||
|
fi
|
||||||
|
npm run build
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
# 复制到 embed 目录
|
||||||
|
log_info "Copying dist to embed directory..."
|
||||||
|
rm -rf "$ROOT_DIR/internal/server/app/dist"
|
||||||
|
cp -r "$ROOT_DIR/web/dist" "$ROOT_DIR/internal/server/app/dist"
|
||||||
|
|
||||||
|
log_info "Web UI built successfully"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 构建单个二进制
|
||||||
|
build_binary() {
|
||||||
|
local os=$1
|
||||||
|
local arch=$2
|
||||||
|
local component=$3 # server 或 client
|
||||||
|
|
||||||
|
local output_name="${component}"
|
||||||
|
if [ "$os" = "windows" ]; then
|
||||||
|
output_name="${component}.exe"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local output_dir="$BUILD_DIR/${os}_${arch}"
|
||||||
|
mkdir -p "$output_dir"
|
||||||
|
|
||||||
|
log_info "Building $component for $os/$arch..."
|
||||||
|
|
||||||
|
GOOS=$os GOARCH=$arch go build \
|
||||||
|
-ldflags "-s -w -X 'main.Version=$VERSION' -X 'main.BuildTime=$BUILD_TIME' -X 'main.GitCommit=$GIT_COMMIT'" \
|
||||||
|
-o "$output_dir/$output_name" \
|
||||||
|
"$ROOT_DIR/cmd/$component"
|
||||||
|
|
||||||
|
# UPX 压缩
|
||||||
|
compress_binary "$output_dir/$output_name"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 构建所有平台
|
||||||
|
build_all() {
|
||||||
|
local platforms="${1:-$DEFAULT_PLATFORMS}"
|
||||||
|
|
||||||
|
for platform in $platforms; do
|
||||||
|
local os="${platform%/*}"
|
||||||
|
local arch="${platform#*/}"
|
||||||
|
build_binary "$os" "$arch" "server"
|
||||||
|
build_binary "$os" "$arch" "client"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# 仅构建当前平台
|
||||||
|
build_current() {
|
||||||
|
local os=$(go env GOOS)
|
||||||
|
local arch=$(go env GOARCH)
|
||||||
|
|
||||||
|
build_binary "$os" "$arch" "server"
|
||||||
|
build_binary "$os" "$arch" "client"
|
||||||
|
|
||||||
|
log_info "Binaries built in $BUILD_DIR/${os}_${arch}/"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 清理构建产物
|
||||||
|
clean() {
|
||||||
|
log_info "Cleaning build directory..."
|
||||||
|
rm -rf "$BUILD_DIR"
|
||||||
|
log_info "Clean completed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 显示帮助
|
||||||
|
show_help() {
|
||||||
|
echo "Usage: $0 [command] [options]"
|
||||||
|
echo ""
|
||||||
|
echo "Commands:"
|
||||||
|
echo " all Build for all platforms (default: $DEFAULT_PLATFORMS)"
|
||||||
|
echo " current Build for current platform only"
|
||||||
|
echo " web Build web UI only"
|
||||||
|
echo " server Build server for current platform"
|
||||||
|
echo " client Build client for current platform"
|
||||||
|
echo " clean Clean build directory"
|
||||||
|
echo " help Show this help message"
|
||||||
|
echo ""
|
||||||
|
echo "Environment variables:"
|
||||||
|
echo " VERSION Set version string (default: dev)"
|
||||||
|
echo " USE_UPX Enable UPX compression (default: true)"
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " $0 current # Build for current platform"
|
||||||
|
echo " $0 all # Build for all platforms"
|
||||||
|
echo " VERSION=1.0.0 $0 all # Build with version"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 主函数
|
||||||
|
main() {
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
case "${1:-current}" in
|
||||||
|
all)
|
||||||
|
build_web
|
||||||
|
build_all "${2:-}"
|
||||||
|
;;
|
||||||
|
current)
|
||||||
|
build_web
|
||||||
|
build_current
|
||||||
|
;;
|
||||||
|
web)
|
||||||
|
build_web
|
||||||
|
;;
|
||||||
|
server)
|
||||||
|
build_binary "$(go env GOOS)" "$(go env GOARCH)" "server"
|
||||||
|
;;
|
||||||
|
client)
|
||||||
|
build_binary "$(go env GOOS)" "$(go env GOARCH)" "client"
|
||||||
|
;;
|
||||||
|
clean)
|
||||||
|
clean
|
||||||
|
;;
|
||||||
|
help|--help|-h)
|
||||||
|
show_help
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log_error "Unknown command: $1"
|
||||||
|
show_help
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
log_info "Done!"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
77
web/auto-imports.d.ts
vendored
Normal file
77
web/auto-imports.d.ts
vendored
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* prettier-ignore */
|
||||||
|
// @ts-nocheck
|
||||||
|
// noinspection JSUnusedGlobalSymbols
|
||||||
|
// Generated by unplugin-auto-import
|
||||||
|
// biome-ignore lint: disable
|
||||||
|
export {}
|
||||||
|
declare global {
|
||||||
|
const EffectScope: typeof import('vue').EffectScope
|
||||||
|
const computed: typeof import('vue').computed
|
||||||
|
const createApp: typeof import('vue').createApp
|
||||||
|
const customRef: typeof import('vue').customRef
|
||||||
|
const defineAsyncComponent: typeof import('vue').defineAsyncComponent
|
||||||
|
const defineComponent: typeof import('vue').defineComponent
|
||||||
|
const effectScope: typeof import('vue').effectScope
|
||||||
|
const getCurrentInstance: typeof import('vue').getCurrentInstance
|
||||||
|
const getCurrentScope: typeof import('vue').getCurrentScope
|
||||||
|
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
|
||||||
|
const h: typeof import('vue').h
|
||||||
|
const inject: typeof import('vue').inject
|
||||||
|
const isProxy: typeof import('vue').isProxy
|
||||||
|
const isReactive: typeof import('vue').isReactive
|
||||||
|
const isReadonly: typeof import('vue').isReadonly
|
||||||
|
const isRef: typeof import('vue').isRef
|
||||||
|
const isShallow: typeof import('vue').isShallow
|
||||||
|
const markRaw: typeof import('vue').markRaw
|
||||||
|
const nextTick: typeof import('vue').nextTick
|
||||||
|
const onActivated: typeof import('vue').onActivated
|
||||||
|
const onBeforeMount: typeof import('vue').onBeforeMount
|
||||||
|
const onBeforeUnmount: typeof import('vue').onBeforeUnmount
|
||||||
|
const onBeforeUpdate: typeof import('vue').onBeforeUpdate
|
||||||
|
const onDeactivated: typeof import('vue').onDeactivated
|
||||||
|
const onErrorCaptured: typeof import('vue').onErrorCaptured
|
||||||
|
const onMounted: typeof import('vue').onMounted
|
||||||
|
const onRenderTracked: typeof import('vue').onRenderTracked
|
||||||
|
const onRenderTriggered: typeof import('vue').onRenderTriggered
|
||||||
|
const onScopeDispose: typeof import('vue').onScopeDispose
|
||||||
|
const onServerPrefetch: typeof import('vue').onServerPrefetch
|
||||||
|
const onUnmounted: typeof import('vue').onUnmounted
|
||||||
|
const onUpdated: typeof import('vue').onUpdated
|
||||||
|
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
|
||||||
|
const provide: typeof import('vue').provide
|
||||||
|
const reactive: typeof import('vue').reactive
|
||||||
|
const readonly: typeof import('vue').readonly
|
||||||
|
const ref: typeof import('vue').ref
|
||||||
|
const resolveComponent: typeof import('vue').resolveComponent
|
||||||
|
const shallowReactive: typeof import('vue').shallowReactive
|
||||||
|
const shallowReadonly: typeof import('vue').shallowReadonly
|
||||||
|
const shallowRef: typeof import('vue').shallowRef
|
||||||
|
const toRaw: typeof import('vue').toRaw
|
||||||
|
const toRef: typeof import('vue').toRef
|
||||||
|
const toRefs: typeof import('vue').toRefs
|
||||||
|
const toValue: typeof import('vue').toValue
|
||||||
|
const triggerRef: typeof import('vue').triggerRef
|
||||||
|
const unref: typeof import('vue').unref
|
||||||
|
const useAttrs: typeof import('vue').useAttrs
|
||||||
|
const useCssModule: typeof import('vue').useCssModule
|
||||||
|
const useCssVars: typeof import('vue').useCssVars
|
||||||
|
const useDialog: typeof import('naive-ui').useDialog
|
||||||
|
const useId: typeof import('vue').useId
|
||||||
|
const useLoadingBar: typeof import('naive-ui').useLoadingBar
|
||||||
|
const useMessage: typeof import('naive-ui').useMessage
|
||||||
|
const useModel: typeof import('vue').useModel
|
||||||
|
const useNotification: typeof import('naive-ui').useNotification
|
||||||
|
const useSlots: typeof import('vue').useSlots
|
||||||
|
const useTemplateRef: typeof import('vue').useTemplateRef
|
||||||
|
const watch: typeof import('vue').watch
|
||||||
|
const watchEffect: typeof import('vue').watchEffect
|
||||||
|
const watchPostEffect: typeof import('vue').watchPostEffect
|
||||||
|
const watchSyncEffect: typeof import('vue').watchSyncEffect
|
||||||
|
}
|
||||||
|
// for type re-export
|
||||||
|
declare global {
|
||||||
|
// @ts-ignore
|
||||||
|
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||||
|
import('vue')
|
||||||
|
}
|
||||||
18
web/components.d.ts
vendored
Normal file
18
web/components.d.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
// @ts-nocheck
|
||||||
|
// biome-ignore lint: disable
|
||||||
|
// oxlint-disable
|
||||||
|
// ------
|
||||||
|
// Generated by unplugin-vue-components
|
||||||
|
// Read more: https://github.com/vuejs/core/pull/3399
|
||||||
|
|
||||||
|
export {}
|
||||||
|
|
||||||
|
/* prettier-ignore */
|
||||||
|
declare module 'vue' {
|
||||||
|
export interface GlobalComponents {
|
||||||
|
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
|
||||||
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
}
|
||||||
|
}
|
||||||
619
web/package-lock.json
generated
619
web/package-lock.json
generated
@@ -8,7 +8,9 @@
|
|||||||
"name": "webui",
|
"name": "webui",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@vicons/ionicons5": "^0.13.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
|
"naive-ui": "^2.43.2",
|
||||||
"vue": "^3.5.24",
|
"vue": "^3.5.24",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
@@ -17,6 +19,8 @@
|
|||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
"@vue/tsconfig": "^0.8.1",
|
"@vue/tsconfig": "^0.8.1",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
|
"unplugin-auto-import": "^20.3.0",
|
||||||
|
"unplugin-vue-components": "^30.0.0",
|
||||||
"vite": "^7.2.4",
|
"vite": "^7.2.4",
|
||||||
"vue-tsc": "^3.1.4"
|
"vue-tsc": "^3.1.4"
|
||||||
}
|
}
|
||||||
@@ -67,6 +71,30 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@css-render/plugin-bem": {
|
||||||
|
"version": "0.15.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz",
|
||||||
|
"integrity": "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"css-render": "~0.15.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@css-render/vue3-ssr": {
|
||||||
|
"version": "0.15.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/@css-render/vue3-ssr/-/vue3-ssr-0.15.14.tgz",
|
||||||
|
"integrity": "sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.0.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@emotion/hash": {
|
||||||
|
"version": "0.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz",
|
||||||
|
"integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.2",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
||||||
@@ -509,12 +537,61 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
|
"version": "0.3.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
|
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.24"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/remapping": {
|
||||||
|
"version": "2.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||||
|
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/gen-mapping": "^0.3.5",
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.24"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/resolve-uri": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/sourcemap-codec": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@jridgewell/trace-mapping": {
|
||||||
|
"version": "0.3.31",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||||
|
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/resolve-uri": "^3.1.0",
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@juggle/resize-observer": {
|
||||||
|
"version": "3.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
|
||||||
|
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.53",
|
"version": "1.0.0-beta.53",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
|
||||||
@@ -837,6 +914,27 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/katex": {
|
||||||
|
"version": "0.16.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz",
|
||||||
|
"integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/lodash": {
|
||||||
|
"version": "4.17.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.21.tgz",
|
||||||
|
"integrity": "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/lodash-es": {
|
||||||
|
"version": "4.17.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
||||||
|
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/lodash": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.10.4",
|
"version": "24.10.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz",
|
||||||
@@ -848,6 +946,12 @@
|
|||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vicons/ionicons5": {
|
||||||
|
"version": "0.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vicons/ionicons5/-/ionicons5-0.13.0.tgz",
|
||||||
|
"integrity": "sha512-zvZKBPjEXKN7AXNo2Na2uy+nvuv6SP4KAMQxpKL2vfHMj0fSvuw7JZcOPCjQC3e7ayssKnaoFVAhbYcW6v41qQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-vue": {
|
"node_modules/@vitejs/plugin-vue": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.3.tgz",
|
||||||
@@ -1035,6 +1139,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/acorn": {
|
||||||
|
"version": "8.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"acorn": "bin/acorn"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/alien-signals": {
|
"node_modules/alien-signals": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz",
|
||||||
@@ -1042,6 +1159,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/async-validator": {
|
||||||
|
"version": "4.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
|
||||||
|
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/asynckit": {
|
"node_modules/asynckit": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
@@ -1072,6 +1195,22 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chokidar": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"readdirp": "^4.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.16.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/combined-stream": {
|
"node_modules/combined-stream": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
@@ -1084,12 +1223,74 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/confbox": {
|
||||||
|
"version": "0.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz",
|
||||||
|
"integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/css-render": {
|
||||||
|
"version": "0.15.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-render/-/css-render-0.15.14.tgz",
|
||||||
|
"integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/hash": "~0.8.0",
|
||||||
|
"csstype": "~3.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/css-render/node_modules/csstype": {
|
||||||
|
"version": "3.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz",
|
||||||
|
"integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/date-fns": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/date-fns-tz": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"date-fns": "^3.0.0 || ^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/delayed-stream": {
|
"node_modules/delayed-stream": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
@@ -1212,12 +1413,38 @@
|
|||||||
"@esbuild/win32-x64": "0.27.2"
|
"@esbuild/win32-x64": "0.27.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/escape-string-regexp": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/estree-walker": {
|
"node_modules/estree-walker": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/evtd": {
|
||||||
|
"version": "0.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/evtd/-/evtd-0.2.4.tgz",
|
||||||
|
"integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/exsolve": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -1384,6 +1611,52 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/highlight.js": {
|
||||||
|
"version": "11.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
|
||||||
|
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/js-tokens": {
|
||||||
|
"version": "9.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
|
||||||
|
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/local-pkg": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mlly": "^1.7.4",
|
||||||
|
"pkg-types": "^2.3.0",
|
||||||
|
"quansync": "^0.2.11"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/antfu"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lodash": {
|
||||||
|
"version": "4.17.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash-es": {
|
||||||
|
"version": "4.17.22",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz",
|
||||||
|
"integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@@ -1423,6 +1696,45 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mlly": {
|
||||||
|
"version": "1.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz",
|
||||||
|
"integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"acorn": "^8.15.0",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"pkg-types": "^1.3.1",
|
||||||
|
"ufo": "^1.6.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mlly/node_modules/confbox": {
|
||||||
|
"version": "0.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
|
||||||
|
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/mlly/node_modules/pkg-types": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"confbox": "^0.1.8",
|
||||||
|
"mlly": "^1.7.4",
|
||||||
|
"pathe": "^2.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/muggle-string": {
|
"node_modules/muggle-string": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
|
||||||
@@ -1430,6 +1742,36 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/naive-ui": {
|
||||||
|
"version": "2.43.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/naive-ui/-/naive-ui-2.43.2.tgz",
|
||||||
|
"integrity": "sha512-YlLMnGrwGTOc+zMj90sG3ubaH5/7czsgLgGcjTLA981IUaz8r6t4WIujNt8r9PNr+dqv6XNEr0vxkARgPPjfBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@css-render/plugin-bem": "^0.15.14",
|
||||||
|
"@css-render/vue3-ssr": "^0.15.14",
|
||||||
|
"@types/katex": "^0.16.2",
|
||||||
|
"@types/lodash": "^4.17.20",
|
||||||
|
"@types/lodash-es": "^4.17.12",
|
||||||
|
"async-validator": "^4.2.5",
|
||||||
|
"css-render": "^0.15.14",
|
||||||
|
"csstype": "^3.1.3",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"date-fns-tz": "^3.2.0",
|
||||||
|
"evtd": "^0.2.4",
|
||||||
|
"highlight.js": "^11.8.0",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
|
"seemly": "^0.3.10",
|
||||||
|
"treemate": "^0.3.11",
|
||||||
|
"vdirs": "^0.1.8",
|
||||||
|
"vooks": "^0.2.12",
|
||||||
|
"vueuc": "^0.4.65"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
@@ -1455,6 +1797,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pathe": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -1467,7 +1816,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -1475,6 +1823,18 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pkg-types": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"confbox": "^0.2.2",
|
||||||
|
"exsolve": "^1.0.7",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
@@ -1509,6 +1869,37 @@
|
|||||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/quansync": {
|
||||||
|
"version": "0.2.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
|
||||||
|
"integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/antfu"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/sxzz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/readdirp": {
|
||||||
|
"version": "4.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||||
|
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.18.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.54.0",
|
"version": "4.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz",
|
||||||
@@ -1551,6 +1942,19 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/scule": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/seemly": {
|
||||||
|
"version": "0.3.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/seemly/-/seemly-0.3.10.tgz",
|
||||||
|
"integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -1560,6 +1964,19 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/strip-literal": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"js-tokens": "^9.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/antfu"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
@@ -1577,6 +1994,12 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/treemate": {
|
||||||
|
"version": "0.3.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/treemate/-/treemate-0.3.11.tgz",
|
||||||
|
"integrity": "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
@@ -1592,6 +2015,13 @@
|
|||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ufo": {
|
||||||
|
"version": "1.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz",
|
||||||
|
"integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.16.0",
|
"version": "7.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||||
@@ -1599,6 +2029,156 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/unimport": {
|
||||||
|
"version": "5.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/unimport/-/unimport-5.6.0.tgz",
|
||||||
|
"integrity": "sha512-8rqAmtJV8o60x46kBAJKtHpJDJWkA2xcBqWKPI14MgUb05o1pnpnCnXSxedUXyeq7p8fR5g3pTo2BaswZ9lD9A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"acorn": "^8.15.0",
|
||||||
|
"escape-string-regexp": "^5.0.0",
|
||||||
|
"estree-walker": "^3.0.3",
|
||||||
|
"local-pkg": "^1.1.2",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"mlly": "^1.8.0",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"picomatch": "^4.0.3",
|
||||||
|
"pkg-types": "^2.3.0",
|
||||||
|
"scule": "^1.3.0",
|
||||||
|
"strip-literal": "^3.1.0",
|
||||||
|
"tinyglobby": "^0.2.15",
|
||||||
|
"unplugin": "^2.3.11",
|
||||||
|
"unplugin-utils": "^0.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unimport/node_modules/estree-walker": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/estree": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unplugin": {
|
||||||
|
"version": "2.3.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz",
|
||||||
|
"integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/remapping": "^2.3.5",
|
||||||
|
"acorn": "^8.15.0",
|
||||||
|
"picomatch": "^4.0.3",
|
||||||
|
"webpack-virtual-modules": "^0.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unplugin-auto-import": {
|
||||||
|
"version": "20.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/unplugin-auto-import/-/unplugin-auto-import-20.3.0.tgz",
|
||||||
|
"integrity": "sha512-RcSEQiVv7g0mLMMXibYVKk8mpteKxvyffGuDKqZZiFr7Oq3PB1HwgHdK5O7H4AzbhzHoVKG0NnMnsk/1HIVYzQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"local-pkg": "^1.1.2",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"picomatch": "^4.0.3",
|
||||||
|
"unimport": "^5.5.0",
|
||||||
|
"unplugin": "^2.3.11",
|
||||||
|
"unplugin-utils": "^0.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/antfu"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@nuxt/kit": "^4.0.0",
|
||||||
|
"@vueuse/core": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@nuxt/kit": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vueuse/core": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unplugin-utils": {
|
||||||
|
"version": "0.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz",
|
||||||
|
"integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"picomatch": "^4.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sxzz"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unplugin-vue-components": {
|
||||||
|
"version": "30.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-30.0.0.tgz",
|
||||||
|
"integrity": "sha512-4qVE/lwCgmdPTp6h0qsRN2u642tt4boBQtcpn4wQcWZAsr8TQwq+SPT3NDu/6kBFxzo/sSEK4ioXhOOBrXc3iw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"chokidar": "^4.0.3",
|
||||||
|
"debug": "^4.4.3",
|
||||||
|
"local-pkg": "^1.1.2",
|
||||||
|
"magic-string": "^0.30.19",
|
||||||
|
"mlly": "^1.8.0",
|
||||||
|
"tinyglobby": "^0.2.15",
|
||||||
|
"unplugin": "^2.3.10",
|
||||||
|
"unplugin-utils": "^0.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/antfu"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@babel/parser": "^7.15.8",
|
||||||
|
"@nuxt/kit": "^3.2.2 || ^4.0.0",
|
||||||
|
"vue": "2 || 3"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@babel/parser": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@nuxt/kit": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vdirs": {
|
||||||
|
"version": "0.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/vdirs/-/vdirs-0.1.8.tgz",
|
||||||
|
"integrity": "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"evtd": "^0.2.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.0.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.0",
|
"version": "7.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
||||||
@@ -1675,6 +2255,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vooks": {
|
||||||
|
"version": "0.2.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/vooks/-/vooks-0.2.12.tgz",
|
||||||
|
"integrity": "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"evtd": "^0.2.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vscode-uri": {
|
"node_modules/vscode-uri": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
|
||||||
@@ -1735,6 +2327,31 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": ">=5.0.0"
|
"typescript": ">=5.0.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vueuc": {
|
||||||
|
"version": "0.4.65",
|
||||||
|
"resolved": "https://registry.npmjs.org/vueuc/-/vueuc-0.4.65.tgz",
|
||||||
|
"integrity": "sha512-lXuMl+8gsBmruudfxnMF9HW4be8rFziylXFu1VHVNbLVhRTXXV4njvpRuJapD/8q+oFEMSfQMH16E/85VoWRyQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@css-render/vue3-ssr": "^0.15.10",
|
||||||
|
"@juggle/resize-observer": "^3.3.1",
|
||||||
|
"css-render": "^0.15.10",
|
||||||
|
"evtd": "^0.2.4",
|
||||||
|
"seemly": "^0.3.6",
|
||||||
|
"vdirs": "^0.1.4",
|
||||||
|
"vooks": "^0.2.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.0.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/webpack-virtual-modules": {
|
||||||
|
"version": "0.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||||
|
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@vicons/ionicons5": "^0.13.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
|
"naive-ui": "^2.43.2",
|
||||||
"vue": "^3.5.24",
|
"vue": "^3.5.24",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
@@ -18,6 +20,8 @@
|
|||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
"@vue/tsconfig": "^0.8.1",
|
"@vue/tsconfig": "^0.8.1",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
|
"unplugin-auto-import": "^20.3.0",
|
||||||
|
"unplugin-vue-components": "^30.0.0",
|
||||||
"vite": "^7.2.4",
|
"vite": "^7.2.4",
|
||||||
"vue-tsc": "^3.1.4"
|
"vue-tsc": "^3.1.4"
|
||||||
}
|
}
|
||||||
|
|||||||
113
web/src/App.vue
113
web/src/App.vue
@@ -1,12 +1,42 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, computed, h } from 'vue'
|
||||||
import { RouterView } from 'vue-router'
|
import { RouterView, useRouter, useRoute } from 'vue-router'
|
||||||
import { getServerStatus } from './api'
|
import { NLayout, NLayoutHeader, NLayoutContent, NMenu, NButton, NSpace, NTag, NIcon, NConfigProvider, NMessageProvider, NDialogProvider } from 'naive-ui'
|
||||||
|
import { HomeOutline, ExtensionPuzzleOutline, LogOutOutline } from '@vicons/ionicons5'
|
||||||
|
import type { MenuOption } from 'naive-ui'
|
||||||
|
import { getServerStatus, removeToken } from './api'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
const serverInfo = ref({ bind_addr: '', bind_port: 0 })
|
const serverInfo = ref({ bind_addr: '', bind_port: 0 })
|
||||||
const clientCount = ref(0)
|
const clientCount = ref(0)
|
||||||
|
|
||||||
|
const isLoginPage = computed(() => route.path === '/login')
|
||||||
|
|
||||||
|
const menuOptions: MenuOption[] = [
|
||||||
|
{
|
||||||
|
label: '客户端',
|
||||||
|
key: '/',
|
||||||
|
icon: () => h(NIcon, null, { default: () => h(HomeOutline) })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '插件',
|
||||||
|
key: '/plugins',
|
||||||
|
icon: () => h(NIcon, null, { default: () => h(ExtensionPuzzleOutline) })
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const activeKey = computed(() => {
|
||||||
|
if (route.path.startsWith('/client/')) return '/'
|
||||||
|
return route.path
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleMenuUpdate = (key: string) => {
|
||||||
|
router.push(key)
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
if (isLoginPage.value) return
|
||||||
try {
|
try {
|
||||||
const { data } = await getServerStatus()
|
const { data } = await getServerStatus()
|
||||||
serverInfo.value = data.server
|
serverInfo.value = data.server
|
||||||
@@ -15,41 +45,50 @@ onMounted(async () => {
|
|||||||
console.error('Failed to get server status', e)
|
console.error('Failed to get server status', e)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
removeToken()
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="app">
|
<n-config-provider>
|
||||||
<header class="header">
|
<n-dialog-provider>
|
||||||
<h1>GoTunnel 控制台</h1>
|
<n-message-provider>
|
||||||
<div class="server-info">
|
<n-layout v-if="!isLoginPage" style="min-height: 100vh;">
|
||||||
<span>{{ serverInfo.bind_addr }}:{{ serverInfo.bind_port }}</span>
|
<n-layout-header bordered style="height: 64px; padding: 0 24px; display: flex; align-items: center; justify-content: space-between;">
|
||||||
<span class="badge">{{ clientCount }} 客户端</span>
|
<div style="display: flex; align-items: center; gap: 32px;">
|
||||||
</div>
|
<div style="font-size: 20px; font-weight: 600; color: #18a058; cursor: pointer;" @click="router.push('/')">
|
||||||
</header>
|
GoTunnel
|
||||||
<main class="main">
|
</div>
|
||||||
<RouterView />
|
<n-menu
|
||||||
</main>
|
mode="horizontal"
|
||||||
</div>
|
:options="menuOptions"
|
||||||
|
:value="activeKey"
|
||||||
|
@update:value="handleMenuUpdate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<n-space align="center" :size="16">
|
||||||
|
<n-tag type="info" round>
|
||||||
|
{{ serverInfo.bind_addr }}:{{ serverInfo.bind_port }}
|
||||||
|
</n-tag>
|
||||||
|
<n-tag type="success" round>
|
||||||
|
{{ clientCount }} 客户端
|
||||||
|
</n-tag>
|
||||||
|
<n-button quaternary circle @click="logout">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon><LogOutOutline /></n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</n-layout-header>
|
||||||
|
<n-layout-content content-style="padding: 24px; max-width: 1200px; margin: 0 auto; width: 100%;">
|
||||||
|
<RouterView />
|
||||||
|
</n-layout-content>
|
||||||
|
</n-layout>
|
||||||
|
<RouterView v-else />
|
||||||
|
</n-message-provider>
|
||||||
|
</n-dialog-provider>
|
||||||
|
</n-config-provider>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.app { min-height: 100vh; background: #f5f7fa; }
|
|
||||||
.header {
|
|
||||||
background: #fff;
|
|
||||||
padding: 16px 24px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
.header h1 { font-size: 20px; color: #2c3e50; }
|
|
||||||
.server-info { display: flex; align-items: center; gap: 12px; color: #666; }
|
|
||||||
.badge {
|
|
||||||
background: #3498db;
|
|
||||||
color: #fff;
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.main { padding: 24px; max-width: 1200px; margin: 0 auto; }
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,11 +1,44 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import type { ClientConfig, ClientStatus, ClientDetail, ServerStatus } from '../types'
|
import type { ClientConfig, ClientStatus, ClientDetail, ServerStatus, PluginInfo } from '../types'
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Token 管理
|
||||||
|
const TOKEN_KEY = 'gotunnel_token'
|
||||||
|
|
||||||
|
export const getToken = () => localStorage.getItem(TOKEN_KEY)
|
||||||
|
export const setToken = (token: string) => localStorage.setItem(TOKEN_KEY, token)
|
||||||
|
export const removeToken = () => localStorage.removeItem(TOKEN_KEY)
|
||||||
|
|
||||||
|
// 请求拦截器:添加 token
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = getToken()
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
// 响应拦截器:处理 401
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
removeToken()
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 认证 API
|
||||||
|
export const login = (username: string, password: string) =>
|
||||||
|
api.post<{ token: string }>('/auth/login', { username, password })
|
||||||
|
export const checkAuth = () => api.get('/auth/check')
|
||||||
|
|
||||||
export const getServerStatus = () => api.get<ServerStatus>('/status')
|
export const getServerStatus = () => api.get<ServerStatus>('/status')
|
||||||
export const getClients = () => api.get<ClientStatus[]>('/clients')
|
export const getClients = () => api.get<ClientStatus[]>('/clients')
|
||||||
export const getClient = (id: string) => api.get<ClientDetail>(`/client/${id}`)
|
export const getClient = (id: string) => api.get<ClientDetail>(`/client/${id}`)
|
||||||
@@ -14,4 +47,15 @@ export const updateClient = (id: string, client: ClientConfig) => api.put(`/clie
|
|||||||
export const deleteClient = (id: string) => api.delete(`/client/${id}`)
|
export const deleteClient = (id: string) => api.delete(`/client/${id}`)
|
||||||
export const reloadConfig = () => api.post('/config/reload')
|
export const reloadConfig = () => api.post('/config/reload')
|
||||||
|
|
||||||
|
// 客户端控制
|
||||||
|
export const pushConfigToClient = (id: string) => api.post(`/client/${id}/push`)
|
||||||
|
export const disconnectClient = (id: string) => api.post(`/client/${id}/disconnect`)
|
||||||
|
export const installPluginsToClient = (id: string, plugins: string[]) =>
|
||||||
|
api.post(`/client/${id}/install-plugins`, { plugins })
|
||||||
|
|
||||||
|
// 插件管理
|
||||||
|
export const getPlugins = () => api.get<PluginInfo[]>('/plugins')
|
||||||
|
export const enablePlugin = (name: string) => api.post(`/plugin/${name}/enable`)
|
||||||
|
export const disablePlugin = (name: string) => api.post(`/plugin/${name}/disable`)
|
||||||
|
|
||||||
export default api
|
export default api
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
|
import naive from 'naive-ui'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
|
||||||
createApp(App).use(router).mount('#app')
|
const app = createApp(App)
|
||||||
|
app.use(router)
|
||||||
|
app.use(naive)
|
||||||
|
app.mount('#app')
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { getToken } from '../api'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'login',
|
||||||
|
component: () => import('../views/LoginView.vue'),
|
||||||
|
meta: { public: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
name: 'home',
|
name: 'home',
|
||||||
@@ -13,7 +20,24 @@ const router = createRouter({
|
|||||||
name: 'client',
|
name: 'client',
|
||||||
component: () => import('../views/ClientView.vue'),
|
component: () => import('../views/ClientView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/plugins',
|
||||||
|
name: 'plugins',
|
||||||
|
component: () => import('../views/PluginsView.vue'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 路由守卫
|
||||||
|
router.beforeEach((to, _from, next) => {
|
||||||
|
const token = getToken()
|
||||||
|
if (!to.meta.public && !token) {
|
||||||
|
next('/login')
|
||||||
|
} else if (to.path === '/login' && token) {
|
||||||
|
next('/')
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -1,79 +1,15 @@
|
|||||||
:root {
|
* {
|
||||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
margin: 0;
|
||||||
line-height: 1.5;
|
padding: 0;
|
||||||
font-weight: 400;
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
color-scheme: light dark;
|
body {
|
||||||
color: rgba(255, 255, 255, 0.87);
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
background-color: #242424;
|
|
||||||
|
|
||||||
font-synthesis: none;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
#app {
|
||||||
font-weight: 500;
|
|
||||||
color: #646cff;
|
|
||||||
text-decoration: inherit;
|
|
||||||
}
|
|
||||||
a:hover {
|
|
||||||
color: #535bf2;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
place-items: center;
|
|
||||||
min-width: 320px;
|
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 3.2em;
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
padding: 0.6em 1.2em;
|
|
||||||
font-size: 1em;
|
|
||||||
font-weight: 500;
|
|
||||||
font-family: inherit;
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.25s;
|
|
||||||
}
|
|
||||||
button:hover {
|
|
||||||
border-color: #646cff;
|
|
||||||
}
|
|
||||||
button:focus,
|
|
||||||
button:focus-visible {
|
|
||||||
outline: 4px auto -webkit-focus-ring-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
padding: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app {
|
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
:root {
|
|
||||||
color: #213547;
|
|
||||||
background-color: #ffffff;
|
|
||||||
}
|
|
||||||
a:hover {
|
|
||||||
color: #747bff;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,17 +4,20 @@ export interface ProxyRule {
|
|||||||
local_ip: string
|
local_ip: string
|
||||||
local_port: number
|
local_port: number
|
||||||
remote_port: number
|
remote_port: number
|
||||||
|
type?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// 客户端配置
|
// 客户端配置
|
||||||
export interface ClientConfig {
|
export interface ClientConfig {
|
||||||
id: string
|
id: string
|
||||||
|
nickname?: string
|
||||||
rules: ProxyRule[]
|
rules: ProxyRule[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 客户端状态
|
// 客户端状态
|
||||||
export interface ClientStatus {
|
export interface ClientStatus {
|
||||||
id: string
|
id: string
|
||||||
|
nickname?: string
|
||||||
online: boolean
|
online: boolean
|
||||||
last_ping?: string
|
last_ping?: string
|
||||||
rule_count: number
|
rule_count: number
|
||||||
@@ -23,6 +26,7 @@ export interface ClientStatus {
|
|||||||
// 客户端详情
|
// 客户端详情
|
||||||
export interface ClientDetail {
|
export interface ClientDetail {
|
||||||
id: string
|
id: string
|
||||||
|
nickname?: string
|
||||||
rules: ProxyRule[]
|
rules: ProxyRule[]
|
||||||
online: boolean
|
online: boolean
|
||||||
last_ping?: string
|
last_ping?: string
|
||||||
@@ -36,3 +40,23 @@ export interface ServerStatus {
|
|||||||
}
|
}
|
||||||
client_count: number
|
client_count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 插件类型
|
||||||
|
export const PluginType = {
|
||||||
|
Proxy: 'proxy',
|
||||||
|
App: 'app',
|
||||||
|
Service: 'service',
|
||||||
|
Tool: 'tool'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type PluginTypeValue = typeof PluginType[keyof typeof PluginType]
|
||||||
|
|
||||||
|
// 插件信息
|
||||||
|
export interface PluginInfo {
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
type: string
|
||||||
|
description: string
|
||||||
|
source: string
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,24 +1,72 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { getClient, updateClient, deleteClient } from '../api'
|
import {
|
||||||
import type { ProxyRule } from '../types'
|
NCard, NButton, NSpace, NTag, NTable, NEmpty,
|
||||||
|
NFormItem, NInput, NInputNumber, NSelect, NModal, NCheckbox,
|
||||||
|
NIcon, useMessage, useDialog
|
||||||
|
} from 'naive-ui'
|
||||||
|
import {
|
||||||
|
ArrowBackOutline, CreateOutline, TrashOutline,
|
||||||
|
PushOutline, PowerOutline, AddOutline, SaveOutline, CloseOutline,
|
||||||
|
DownloadOutline
|
||||||
|
} from '@vicons/ionicons5'
|
||||||
|
import { getClient, updateClient, deleteClient, pushConfigToClient, disconnectClient, getPlugins, installPluginsToClient } from '../api'
|
||||||
|
import type { ProxyRule, PluginInfo } from '../types'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
const dialog = useDialog()
|
||||||
const clientId = route.params.id as string
|
const clientId = route.params.id as string
|
||||||
|
|
||||||
const online = ref(false)
|
const online = ref(false)
|
||||||
const lastPing = ref('')
|
const lastPing = ref('')
|
||||||
|
const nickname = ref('')
|
||||||
const rules = ref<ProxyRule[]>([])
|
const rules = ref<ProxyRule[]>([])
|
||||||
const editing = ref(false)
|
const editing = ref(false)
|
||||||
|
const editNickname = ref('')
|
||||||
const editRules = ref<ProxyRule[]>([])
|
const editRules = ref<ProxyRule[]>([])
|
||||||
|
|
||||||
|
const typeOptions = [
|
||||||
|
{ label: 'TCP', value: 'tcp' },
|
||||||
|
{ label: 'UDP', value: 'udp' },
|
||||||
|
{ label: 'HTTP', value: 'http' },
|
||||||
|
{ label: 'HTTPS', value: 'https' },
|
||||||
|
{ label: 'SOCKS5', value: 'socks5' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 插件安装相关
|
||||||
|
const showInstallModal = ref(false)
|
||||||
|
const availablePlugins = ref<PluginInfo[]>([])
|
||||||
|
const selectedPlugins = ref<string[]>([])
|
||||||
|
|
||||||
|
const loadPlugins = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await getPlugins()
|
||||||
|
availablePlugins.value = (data || []).filter(p => p.enabled)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load plugins', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openInstallModal = async () => {
|
||||||
|
await loadPlugins()
|
||||||
|
selectedPlugins.value = []
|
||||||
|
showInstallModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTypeLabel = (type: string) => {
|
||||||
|
const labels: Record<string, string> = { proxy: '协议', app: '应用', service: '服务', tool: '工具' }
|
||||||
|
return labels[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
const loadClient = async () => {
|
const loadClient = async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await getClient(clientId)
|
const { data } = await getClient(clientId)
|
||||||
online.value = data.online
|
online.value = data.online
|
||||||
lastPing.value = data.last_ping || ''
|
lastPing.value = data.last_ping || ''
|
||||||
|
nickname.value = data.nickname || ''
|
||||||
rules.value = data.rules || []
|
rules.value = data.rules || []
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load client', e)
|
console.error('Failed to load client', e)
|
||||||
@@ -28,7 +76,11 @@ const loadClient = async () => {
|
|||||||
onMounted(loadClient)
|
onMounted(loadClient)
|
||||||
|
|
||||||
const startEdit = () => {
|
const startEdit = () => {
|
||||||
editRules.value = JSON.parse(JSON.stringify(rules.value))
|
editNickname.value = nickname.value
|
||||||
|
editRules.value = rules.value.map(rule => ({
|
||||||
|
...rule,
|
||||||
|
type: rule.type || 'tcp'
|
||||||
|
}))
|
||||||
editing.value = true
|
editing.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +90,7 @@ const cancelEdit = () => {
|
|||||||
|
|
||||||
const addRule = () => {
|
const addRule = () => {
|
||||||
editRules.value.push({
|
editRules.value.push({
|
||||||
name: '', local_ip: '127.0.0.1', local_port: 80, remote_port: 8080
|
name: '', local_ip: '127.0.0.1', local_port: 80, remote_port: 8080, type: 'tcp'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,156 +100,228 @@ const removeRule = (index: number) => {
|
|||||||
|
|
||||||
const saveEdit = async () => {
|
const saveEdit = async () => {
|
||||||
try {
|
try {
|
||||||
await updateClient(clientId, { id: clientId, rules: editRules.value })
|
await updateClient(clientId, { id: clientId, nickname: editNickname.value, rules: editRules.value })
|
||||||
editing.value = false
|
editing.value = false
|
||||||
|
message.success('保存成功')
|
||||||
loadClient()
|
loadClient()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('保存失败')
|
message.error('保存失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
const confirmDelete = () => {
|
||||||
if (!confirm('确定删除此客户端?')) return
|
dialog.warning({
|
||||||
|
title: '确认删除',
|
||||||
|
content: '确定要删除此客户端吗?',
|
||||||
|
positiveText: '删除',
|
||||||
|
negativeText: '取消',
|
||||||
|
onPositiveClick: async () => {
|
||||||
|
try {
|
||||||
|
await deleteClient(clientId)
|
||||||
|
message.success('删除成功')
|
||||||
|
router.push('/')
|
||||||
|
} catch (e) {
|
||||||
|
message.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const pushConfig = async () => {
|
||||||
try {
|
try {
|
||||||
await deleteClient(clientId)
|
await pushConfigToClient(clientId)
|
||||||
router.push('/')
|
message.success('配置已推送')
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
alert('删除失败')
|
message.error(e.response?.data || '推送失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const disconnect = () => {
|
||||||
|
dialog.warning({
|
||||||
|
title: '确认断开',
|
||||||
|
content: '确定要断开此客户端连接吗?',
|
||||||
|
positiveText: '断开',
|
||||||
|
negativeText: '取消',
|
||||||
|
onPositiveClick: async () => {
|
||||||
|
try {
|
||||||
|
await disconnectClient(clientId)
|
||||||
|
online.value = false
|
||||||
|
message.success('已断开连接')
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.response?.data || '断开失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const installPlugins = async () => {
|
||||||
|
if (selectedPlugins.value.length === 0) {
|
||||||
|
message.warning('请选择要安装的插件')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await installPluginsToClient(clientId, selectedPlugins.value)
|
||||||
|
message.success(`已推送 ${selectedPlugins.value.length} 个插件到客户端`)
|
||||||
|
showInstallModal.value = false
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.response?.data || '安装失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="client-view">
|
<div class="client-view">
|
||||||
<div class="header">
|
<!-- 头部信息卡片 -->
|
||||||
<button class="btn" @click="router.push('/')">← 返回</button>
|
<n-card style="margin-bottom: 16px;">
|
||||||
<h2>{{ clientId }}</h2>
|
<n-space justify="space-between" align="center" wrap>
|
||||||
<span :class="['status-badge', online ? 'online' : 'offline']">
|
<n-space align="center">
|
||||||
{{ online ? '在线' : '离线' }}
|
<n-button quaternary @click="router.push('/')">
|
||||||
</span>
|
<template #icon><n-icon><ArrowBackOutline /></n-icon></template>
|
||||||
</div>
|
返回
|
||||||
|
</n-button>
|
||||||
|
<h2 style="margin: 0;">{{ nickname || clientId }}</h2>
|
||||||
|
<span v-if="nickname" style="color: #999; font-size: 12px;">{{ clientId }}</span>
|
||||||
|
<n-tag :type="online ? 'success' : 'default'">
|
||||||
|
{{ online ? '在线' : '离线' }}
|
||||||
|
</n-tag>
|
||||||
|
<span v-if="lastPing" style="color: #666; font-size: 14px;">
|
||||||
|
最后心跳: {{ lastPing }}
|
||||||
|
</span>
|
||||||
|
</n-space>
|
||||||
|
<n-space>
|
||||||
|
<template v-if="online">
|
||||||
|
<n-button type="info" @click="pushConfig">
|
||||||
|
<template #icon><n-icon><PushOutline /></n-icon></template>
|
||||||
|
推送配置
|
||||||
|
</n-button>
|
||||||
|
<n-button type="success" @click="openInstallModal">
|
||||||
|
<template #icon><n-icon><DownloadOutline /></n-icon></template>
|
||||||
|
安装插件
|
||||||
|
</n-button>
|
||||||
|
<n-button type="warning" @click="disconnect">
|
||||||
|
<template #icon><n-icon><PowerOutline /></n-icon></template>
|
||||||
|
断开连接
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
<template v-if="!editing">
|
||||||
|
<n-button type="primary" @click="startEdit">
|
||||||
|
<template #icon><n-icon><CreateOutline /></n-icon></template>
|
||||||
|
编辑规则
|
||||||
|
</n-button>
|
||||||
|
<n-button type="error" @click="confirmDelete">
|
||||||
|
<template #icon><n-icon><TrashOutline /></n-icon></template>
|
||||||
|
删除
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
</n-space>
|
||||||
|
</n-space>
|
||||||
|
</n-card>
|
||||||
|
|
||||||
<div v-if="lastPing" class="ping-info">最后心跳: {{ lastPing }}</div>
|
<!-- 规则卡片 -->
|
||||||
|
<n-card title="代理规则">
|
||||||
<div class="rules-section">
|
<template #header-extra v-if="editing">
|
||||||
<div class="section-header">
|
<n-space>
|
||||||
<h3>代理规则</h3>
|
<n-button @click="cancelEdit">
|
||||||
<div v-if="!editing">
|
<template #icon><n-icon><CloseOutline /></n-icon></template>
|
||||||
<button class="btn primary" @click="startEdit">编辑</button>
|
取消
|
||||||
<button class="btn danger" @click="confirmDelete">删除</button>
|
</n-button>
|
||||||
</div>
|
<n-button type="primary" @click="saveEdit">
|
||||||
</div>
|
<template #icon><n-icon><SaveOutline /></n-icon></template>
|
||||||
|
保存
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- 查看模式 -->
|
<!-- 查看模式 -->
|
||||||
<table v-if="!editing" class="rules-table">
|
<template v-if="!editing">
|
||||||
<thead>
|
<n-empty v-if="rules.length === 0" description="暂无代理规则" />
|
||||||
<tr>
|
<n-table v-else :bordered="false" :single-line="false">
|
||||||
<th>名称</th>
|
<thead>
|
||||||
<th>本地地址</th>
|
<tr>
|
||||||
<th>远程端口</th>
|
<th>名称</th>
|
||||||
</tr>
|
<th>本地地址</th>
|
||||||
</thead>
|
<th>远程端口</th>
|
||||||
<tbody>
|
<th>类型</th>
|
||||||
<tr v-for="rule in rules" :key="rule.name">
|
</tr>
|
||||||
<td>{{ rule.name }}</td>
|
</thead>
|
||||||
<td>{{ rule.local_ip }}:{{ rule.local_port }}</td>
|
<tbody>
|
||||||
<td>{{ rule.remote_port }}</td>
|
<tr v-for="rule in rules" :key="rule.name">
|
||||||
</tr>
|
<td>{{ rule.name || '未命名' }}</td>
|
||||||
</tbody>
|
<td>{{ rule.local_ip }}:{{ rule.local_port }}</td>
|
||||||
</table>
|
<td>{{ rule.remote_port }}</td>
|
||||||
|
<td><n-tag size="small">{{ rule.type || 'tcp' }}</n-tag></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</n-table>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- 编辑模式 -->
|
<!-- 编辑模式 -->
|
||||||
<div v-if="editing" class="edit-form">
|
<template v-else>
|
||||||
<div v-for="(rule, i) in editRules" :key="i" class="rule-row">
|
<n-space vertical :size="12">
|
||||||
<input v-model="rule.name" placeholder="名称" />
|
<n-form-item label="昵称" :show-feedback="false">
|
||||||
<input v-model="rule.local_ip" placeholder="本地IP" />
|
<n-input v-model:value="editNickname" placeholder="给客户端起个名字(可选)" style="max-width: 300px;" />
|
||||||
<input v-model.number="rule.local_port" type="number" placeholder="本地端口" />
|
</n-form-item>
|
||||||
<input v-model.number="rule.remote_port" type="number" placeholder="远程端口" />
|
<n-card v-for="(rule, i) in editRules" :key="i" size="small">
|
||||||
<button class="btn-icon" @click="removeRule(i)">×</button>
|
<n-space align="center">
|
||||||
</div>
|
<n-form-item label="名称" :show-feedback="false">
|
||||||
<button class="btn secondary" @click="addRule">+ 添加规则</button>
|
<n-input v-model:value="rule.name" placeholder="规则名称" />
|
||||||
<div class="edit-actions">
|
</n-form-item>
|
||||||
<button class="btn" @click="cancelEdit">取消</button>
|
<n-form-item label="类型" :show-feedback="false">
|
||||||
<button class="btn primary" @click="saveEdit">保存</button>
|
<n-select v-model:value="rule.type" :options="typeOptions" style="width: 100px;" />
|
||||||
</div>
|
</n-form-item>
|
||||||
</div>
|
<n-form-item label="本地IP" :show-feedback="false">
|
||||||
</div>
|
<n-input v-model:value="rule.local_ip" placeholder="127.0.0.1" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="本地端口" :show-feedback="false">
|
||||||
|
<n-input-number v-model:value="rule.local_port" :show-button="false" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="远程端口" :show-feedback="false">
|
||||||
|
<n-input-number v-model:value="rule.remote_port" :show-button="false" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-button v-if="editRules.length > 1" quaternary type="error" @click="removeRule(i)">
|
||||||
|
<template #icon><n-icon><TrashOutline /></n-icon></template>
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</n-card>
|
||||||
|
<n-button dashed block @click="addRule">
|
||||||
|
<template #icon><n-icon><AddOutline /></n-icon></template>
|
||||||
|
添加规则
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
</n-card>
|
||||||
|
|
||||||
|
<!-- 安装插件模态框 -->
|
||||||
|
<n-modal v-model:show="showInstallModal" preset="card" title="安装插件到客户端" style="width: 500px;">
|
||||||
|
<n-empty v-if="availablePlugins.length === 0" description="暂无可用插件" />
|
||||||
|
<n-space v-else vertical :size="12">
|
||||||
|
<n-card v-for="plugin in availablePlugins" :key="plugin.name" size="small">
|
||||||
|
<n-space justify="space-between" align="center">
|
||||||
|
<n-space vertical :size="4">
|
||||||
|
<n-space align="center">
|
||||||
|
<span style="font-weight: 500;">{{ plugin.name }}</span>
|
||||||
|
<n-tag size="small">{{ getTypeLabel(plugin.type) }}</n-tag>
|
||||||
|
</n-space>
|
||||||
|
<span style="color: #666; font-size: 12px;">{{ plugin.description }}</span>
|
||||||
|
</n-space>
|
||||||
|
<n-checkbox
|
||||||
|
:checked="selectedPlugins.includes(plugin.name)"
|
||||||
|
@update:checked="(v: boolean) => {
|
||||||
|
if (v) selectedPlugins.push(plugin.name)
|
||||||
|
else selectedPlugins = selectedPlugins.filter(n => n !== plugin.name)
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</n-space>
|
||||||
|
</n-card>
|
||||||
|
</n-space>
|
||||||
|
<template #footer>
|
||||||
|
<n-space justify="end">
|
||||||
|
<n-button @click="showInstallModal = false">取消</n-button>
|
||||||
|
<n-button type="primary" @click="installPlugins" :disabled="selectedPlugins.length === 0">
|
||||||
|
安装 ({{ selectedPlugins.length }})
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.header h2 { margin: 0; }
|
|
||||||
.status-badge {
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.status-badge.online { background: #d4edda; color: #155724; }
|
|
||||||
.status-badge.offline { background: #f8d7da; color: #721c24; }
|
|
||||||
.ping-info { color: #666; margin-bottom: 20px; }
|
|
||||||
.rules-section {
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
.section-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
.section-header h3 { margin: 0; }
|
|
||||||
.section-header .btn { margin-left: 8px; }
|
|
||||||
.btn {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
.btn.primary { background: #3498db; color: #fff; }
|
|
||||||
.btn.secondary { background: #95a5a6; color: #fff; }
|
|
||||||
.btn.danger { background: #e74c3c; color: #fff; }
|
|
||||||
.rules-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
.rules-table th, .rules-table td {
|
|
||||||
padding: 12px;
|
|
||||||
text-align: left;
|
|
||||||
border-bottom: 1px solid #eee;
|
|
||||||
}
|
|
||||||
.rules-table th { font-weight: 600; }
|
|
||||||
.rule-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
.rule-row input {
|
|
||||||
flex: 1;
|
|
||||||
padding: 8px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.btn-icon {
|
|
||||||
background: #e74c3c;
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
width: 32px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.edit-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { getClients, addClient } from '../api'
|
import { NCard, NButton, NSpace, NTag, NStatistic, NGrid, NGi, NEmpty } from 'naive-ui'
|
||||||
import type { ClientStatus, ProxyRule } from '../types'
|
import { getClients } from '../api'
|
||||||
|
import type { ClientStatus } from '../types'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const clients = ref<ClientStatus[]>([])
|
const clients = ref<ClientStatus[]>([])
|
||||||
const showModal = ref(false)
|
|
||||||
const newClientId = ref('')
|
|
||||||
const newRules = ref<ProxyRule[]>([])
|
|
||||||
|
|
||||||
const loadClients = async () => {
|
const loadClients = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -19,33 +17,16 @@ const loadClients = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onlineClients = computed(() => {
|
||||||
|
return clients.value.filter(client => client.online).length
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalRules = computed(() => {
|
||||||
|
return clients.value.reduce((sum, client) => sum + client.rule_count, 0)
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(loadClients)
|
onMounted(loadClients)
|
||||||
|
|
||||||
const openAddModal = () => {
|
|
||||||
newClientId.value = ''
|
|
||||||
newRules.value = [{ name: '', local_ip: '127.0.0.1', local_port: 80, remote_port: 8080 }]
|
|
||||||
showModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const addRule = () => {
|
|
||||||
newRules.value.push({ name: '', local_ip: '127.0.0.1', local_port: 80, remote_port: 8080 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeRule = (index: number) => {
|
|
||||||
newRules.value.splice(index, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveClient = async () => {
|
|
||||||
if (!newClientId.value) return
|
|
||||||
try {
|
|
||||||
await addClient({ id: newClientId.value, rules: newRules.value })
|
|
||||||
showModal.value = false
|
|
||||||
loadClients()
|
|
||||||
} catch (e) {
|
|
||||||
alert('添加失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const viewClient = (id: string) => {
|
const viewClient = (id: string) => {
|
||||||
router.push(`/client/${id}`)
|
router.push(`/client/${id}`)
|
||||||
}
|
}
|
||||||
@@ -53,139 +34,49 @@ const viewClient = (id: string) => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="home">
|
<div class="home">
|
||||||
<div class="toolbar">
|
<div style="margin-bottom: 24px;">
|
||||||
<h2>客户端列表</h2>
|
<h2 style="margin: 0 0 8px 0;">客户端管理</h2>
|
||||||
<button class="btn primary" @click="openAddModal">添加客户端</button>
|
<p style="margin: 0; color: #666;">查看已连接的隧道客户端</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="client-grid">
|
<n-grid :cols="3" :x-gap="16" :y-gap="16" style="margin-bottom: 24px;">
|
||||||
<div v-for="client in clients" :key="client.id" class="client-card" @click="viewClient(client.id)">
|
<n-gi>
|
||||||
<div class="card-header">
|
<n-card>
|
||||||
<span class="client-id">{{ client.id }}</span>
|
<n-statistic label="总客户端" :value="clients.length" />
|
||||||
<span :class="['status', client.online ? 'online' : 'offline']"></span>
|
</n-card>
|
||||||
</div>
|
</n-gi>
|
||||||
<div class="card-info">
|
<n-gi>
|
||||||
<span>{{ client.rule_count }} 条规则</span>
|
<n-card>
|
||||||
</div>
|
<n-statistic label="在线客户端" :value="onlineClients" />
|
||||||
</div>
|
</n-card>
|
||||||
</div>
|
</n-gi>
|
||||||
|
<n-gi>
|
||||||
|
<n-card>
|
||||||
|
<n-statistic label="总规则数" :value="totalRules" />
|
||||||
|
</n-card>
|
||||||
|
</n-gi>
|
||||||
|
</n-grid>
|
||||||
|
|
||||||
<div v-if="clients.length === 0" class="empty">暂无客户端配置</div>
|
<n-empty v-if="clients.length === 0" description="暂无客户端连接" />
|
||||||
|
|
||||||
<!-- 添加客户端模态框 -->
|
<n-grid v-else :cols="3" :x-gap="16" :y-gap="16" responsive="screen" cols-s="1" cols-m="2">
|
||||||
<div v-if="showModal" class="modal-overlay" @click.self="showModal = false">
|
<n-gi v-for="client in clients" :key="client.id">
|
||||||
<div class="modal">
|
<n-card hoverable style="cursor: pointer;" @click="viewClient(client.id)">
|
||||||
<h3>添加客户端</h3>
|
<n-space justify="space-between" align="center">
|
||||||
<div class="form-group">
|
<div>
|
||||||
<label>客户端 ID</label>
|
<h3 style="margin: 0 0 4px 0;">{{ client.nickname || client.id }}</h3>
|
||||||
<input v-model="newClientId" placeholder="例如: client-a" />
|
<p v-if="client.nickname" style="margin: 0 0 8px 0; color: #999; font-size: 12px;">{{ client.id }}</p>
|
||||||
</div>
|
<n-space>
|
||||||
<div class="form-group">
|
<n-tag :type="client.online ? 'success' : 'default'" size="small">
|
||||||
<label>代理规则</label>
|
{{ client.online ? '在线' : '离线' }}
|
||||||
<div v-for="(rule, i) in newRules" :key="i" class="rule-row">
|
</n-tag>
|
||||||
<input v-model="rule.name" placeholder="名称" />
|
<n-tag type="info" size="small">{{ client.rule_count }} 条规则</n-tag>
|
||||||
<input v-model="rule.local_ip" placeholder="本地IP" />
|
</n-space>
|
||||||
<input v-model.number="rule.local_port" type="number" placeholder="本地端口" />
|
</div>
|
||||||
<input v-model.number="rule.remote_port" type="number" placeholder="远程端口" />
|
<n-button size="small" @click.stop="viewClient(client.id)">查看详情</n-button>
|
||||||
<button class="btn-icon" @click="removeRule(i)">×</button>
|
</n-space>
|
||||||
</div>
|
</n-card>
|
||||||
<button class="btn secondary" @click="addRule">+ 添加规则</button>
|
</n-gi>
|
||||||
</div>
|
</n-grid>
|
||||||
<div class="modal-actions">
|
|
||||||
<button class="btn" @click="showModal = false">取消</button>
|
|
||||||
<button class="btn primary" @click="saveClient">保存</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.toolbar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.toolbar h2 { font-size: 18px; color: #2c3e50; }
|
|
||||||
.btn {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
.btn.primary { background: #3498db; color: #fff; }
|
|
||||||
.btn.secondary { background: #95a5a6; color: #fff; }
|
|
||||||
.client-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
.client-card {
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
||||||
transition: transform 0.2s;
|
|
||||||
}
|
|
||||||
.client-card:hover { transform: translateY(-2px); }
|
|
||||||
.card-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
.client-id { font-weight: 600; }
|
|
||||||
.status { width: 10px; height: 10px; border-radius: 50%; }
|
|
||||||
.status.online { background: #27ae60; }
|
|
||||||
.status.offline { background: #95a5a6; }
|
|
||||||
.card-info { font-size: 14px; color: #666; }
|
|
||||||
.empty { text-align: center; color: #999; padding: 40px; }
|
|
||||||
.modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0; left: 0; right: 0; bottom: 0;
|
|
||||||
background: rgba(0,0,0,0.5);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.modal {
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 24px;
|
|
||||||
width: 500px;
|
|
||||||
max-width: 90%;
|
|
||||||
}
|
|
||||||
.modal h3 { margin-bottom: 16px; }
|
|
||||||
.form-group { margin-bottom: 16px; }
|
|
||||||
.form-group label { display: block; margin-bottom: 8px; font-weight: 500; }
|
|
||||||
.form-group input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 4px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.rule-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
.rule-row input { flex: 1; width: auto; }
|
|
||||||
.btn-icon {
|
|
||||||
background: #e74c3c;
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
width: 32px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.modal-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
122
web/src/views/LoginView.vue
Normal file
122
web/src/views/LoginView.vue
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { NCard, NForm, NFormItem, NInput, NButton, NAlert } from 'naive-ui'
|
||||||
|
import { login, setToken } from '../api'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
if (!username.value || !password.value) {
|
||||||
|
error.value = '请输入用户名和密码'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await login(username.value, password.value)
|
||||||
|
setToken(data.token)
|
||||||
|
router.push('/')
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.response?.data?.error || '登录失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="login-page">
|
||||||
|
<n-card class="login-card" :bordered="false">
|
||||||
|
<template #header>
|
||||||
|
<div class="login-header">
|
||||||
|
<h1 class="logo">GoTunnel</h1>
|
||||||
|
<p class="subtitle">安全的内网穿透工具</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<n-form @submit.prevent="handleLogin">
|
||||||
|
<n-form-item label="用户名">
|
||||||
|
<n-input
|
||||||
|
v-model:value="username"
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
:disabled="loading"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="密码">
|
||||||
|
<n-input
|
||||||
|
v-model:value="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
:disabled="loading"
|
||||||
|
show-password-on="click"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-alert v-if="error" type="error" :show-icon="true" style="margin-bottom: 16px;">
|
||||||
|
{{ error }}
|
||||||
|
</n-alert>
|
||||||
|
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
:loading="loading"
|
||||||
|
attr-type="submit"
|
||||||
|
>
|
||||||
|
{{ loading ? '登录中...' : '登录' }}
|
||||||
|
</n-button>
|
||||||
|
</n-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="login-footer">欢迎使用 GoTunnel</div>
|
||||||
|
</template>
|
||||||
|
</n-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #18a058;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #666;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer {
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
137
web/src/views/PluginsView.vue
Normal file
137
web/src/views/PluginsView.vue
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import {
|
||||||
|
NCard, NButton, NSpace, NTag, NStatistic, NGrid, NGi,
|
||||||
|
NEmpty, NSpin, NIcon, NSwitch, useMessage
|
||||||
|
} from 'naive-ui'
|
||||||
|
import { ArrowBackOutline, ExtensionPuzzleOutline } from '@vicons/ionicons5'
|
||||||
|
import { getPlugins, enablePlugin, disablePlugin } from '../api'
|
||||||
|
import type { PluginInfo } from '../types'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
const plugins = ref<PluginInfo[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
const loadPlugins = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await getPlugins()
|
||||||
|
plugins.value = data || []
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load plugins', e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyPlugins = computed(() =>
|
||||||
|
plugins.value.filter(p => p.type === 'proxy')
|
||||||
|
)
|
||||||
|
|
||||||
|
const appPlugins = computed(() =>
|
||||||
|
plugins.value.filter(p => p.type === 'app')
|
||||||
|
)
|
||||||
|
|
||||||
|
const togglePlugin = async (plugin: PluginInfo) => {
|
||||||
|
try {
|
||||||
|
if (plugin.enabled) {
|
||||||
|
await disablePlugin(plugin.name)
|
||||||
|
message.success(`已禁用 ${plugin.name}`)
|
||||||
|
} else {
|
||||||
|
await enablePlugin(plugin.name)
|
||||||
|
message.success(`已启用 ${plugin.name}`)
|
||||||
|
}
|
||||||
|
plugin.enabled = !plugin.enabled
|
||||||
|
} catch (e) {
|
||||||
|
message.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTypeLabel = (type: string) => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
proxy: '协议',
|
||||||
|
app: '应用',
|
||||||
|
service: '服务',
|
||||||
|
tool: '工具'
|
||||||
|
}
|
||||||
|
return labels[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTypeColor = (type: string) => {
|
||||||
|
const colors: Record<string, 'info' | 'success' | 'warning' | 'error' | 'default'> = {
|
||||||
|
proxy: 'info',
|
||||||
|
app: 'success',
|
||||||
|
service: 'warning',
|
||||||
|
tool: 'default'
|
||||||
|
}
|
||||||
|
return colors[type] || 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadPlugins)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="plugins-view">
|
||||||
|
<n-space justify="space-between" align="center" style="margin-bottom: 24px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="margin: 0 0 8px 0;">插件管理</h2>
|
||||||
|
<p style="margin: 0; color: #666;">查看和管理已注册的插件</p>
|
||||||
|
</div>
|
||||||
|
<n-button quaternary @click="router.push('/')">
|
||||||
|
<template #icon><n-icon><ArrowBackOutline /></n-icon></template>
|
||||||
|
返回首页
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
|
||||||
|
<n-spin :show="loading">
|
||||||
|
<n-grid :cols="3" :x-gap="16" :y-gap="16" style="margin-bottom: 24px;">
|
||||||
|
<n-gi>
|
||||||
|
<n-card>
|
||||||
|
<n-statistic label="总插件数" :value="plugins.length" />
|
||||||
|
</n-card>
|
||||||
|
</n-gi>
|
||||||
|
<n-gi>
|
||||||
|
<n-card>
|
||||||
|
<n-statistic label="协议插件" :value="proxyPlugins.length" />
|
||||||
|
</n-card>
|
||||||
|
</n-gi>
|
||||||
|
<n-gi>
|
||||||
|
<n-card>
|
||||||
|
<n-statistic label="应用插件" :value="appPlugins.length" />
|
||||||
|
</n-card>
|
||||||
|
</n-gi>
|
||||||
|
</n-grid>
|
||||||
|
|
||||||
|
<n-empty v-if="!loading && plugins.length === 0" description="暂无插件" />
|
||||||
|
|
||||||
|
<n-grid v-else :cols="3" :x-gap="16" :y-gap="16" responsive="screen" cols-s="1" cols-m="2">
|
||||||
|
<n-gi v-for="plugin in plugins" :key="plugin.name">
|
||||||
|
<n-card hoverable>
|
||||||
|
<template #header>
|
||||||
|
<n-space align="center">
|
||||||
|
<n-icon size="24" color="#18a058"><ExtensionPuzzleOutline /></n-icon>
|
||||||
|
<span>{{ plugin.name }}</span>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
<template #header-extra>
|
||||||
|
<n-switch :value="plugin.enabled" @update:value="togglePlugin(plugin)" />
|
||||||
|
</template>
|
||||||
|
<n-space vertical :size="8">
|
||||||
|
<n-space>
|
||||||
|
<n-tag size="small">v{{ plugin.version }}</n-tag>
|
||||||
|
<n-tag size="small" :type="getTypeColor(plugin.type)">
|
||||||
|
{{ getTypeLabel(plugin.type) }}
|
||||||
|
</n-tag>
|
||||||
|
<n-tag size="small" :type="plugin.source === 'builtin' ? 'default' : 'warning'">
|
||||||
|
{{ plugin.source === 'builtin' ? '内置' : 'WASM' }}
|
||||||
|
</n-tag>
|
||||||
|
</n-space>
|
||||||
|
<p style="margin: 0; color: #666;">{{ plugin.description }}</p>
|
||||||
|
</n-space>
|
||||||
|
</n-card>
|
||||||
|
</n-gi>
|
||||||
|
</n-grid>
|
||||||
|
</n-spin>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,7 +1,33 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import AutoImport from 'unplugin-auto-import/vite'
|
||||||
|
import Components from 'unplugin-vue-components/vite'
|
||||||
|
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
AutoImport({
|
||||||
|
imports: [
|
||||||
|
'vue',
|
||||||
|
{
|
||||||
|
'naive-ui': ['useDialog', 'useMessage', 'useNotification', 'useLoadingBar']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
Components({
|
||||||
|
resolvers: [NaiveUiResolver()]
|
||||||
|
})
|
||||||
|
],
|
||||||
|
build: {
|
||||||
|
chunkSizeWarningLimit: 1500,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
'vue-vendor': ['vue', 'vue-router'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user