update
All checks were successful
Build Multi-Platform Binaries / build (push) Successful in 11m54s

This commit is contained in:
Flik
2025-12-26 17:14:54 +08:00
parent 4623a7f031
commit 549f9aaf26
63 changed files with 10266 additions and 740 deletions

View File

@@ -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
View File

@@ -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) 开源许可证。

Binary file not shown.

BIN
build/darwin_arm64/client Executable file

Binary file not shown.

View 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"

Binary file not shown.

View File

@@ -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()
} }

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

@@ -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
} }

View File

@@ -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)

View File

@@ -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

View 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};

File diff suppressed because one or more lines are too long

View File

@@ -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};

View File

@@ -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}

View 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};

View File

@@ -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};

View File

@@ -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}

View 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};

View 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}

View 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};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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}

View File

@@ -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}

File diff suppressed because one or more lines are too long

View File

@@ -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>

View File

@@ -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
} }

View File

@@ -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
}

View File

@@ -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

View File

@@ -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"})
}

View 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})
}

View File

@@ -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)
})
}

View File

@@ -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
View 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)
}

View File

@@ -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 {

View 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
}

View File

@@ -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
View 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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -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"`
} }

View File

@@ -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
View 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
View 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
View 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
View File

@@ -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"
} }
} }
} }

View File

@@ -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"
} }

View File

@@ -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>

View File

@@ -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

View File

@@ -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')

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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
View 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>

View 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>

View File

@@ -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'],
}
}
}
}
}) })