diff --git a/PLUGINS.md b/PLUGINS.md
new file mode 100644
index 0000000..9745520
--- /dev/null
+++ b/PLUGINS.md
@@ -0,0 +1,560 @@
+# GoTunnel 插件开发指南
+
+本文档介绍如何为 GoTunnel 开发 JS 插件。
+
+## 目录
+
+- [快速开始](#快速开始)
+- [插件结构](#插件结构)
+- [API 参考](#api-参考)
+- [示例插件](#示例插件)
+- [插件签名](#插件签名)
+- [发布到商店](#发布到商店)
+
+---
+
+## 快速开始
+
+### 最小插件示例
+
+```javascript
+// 必须:定义插件元数据
+function metadata() {
+ return {
+ name: "my-plugin",
+ version: "1.0.0",
+ type: "app",
+ description: "My first plugin",
+ author: "Your Name"
+ };
+}
+
+// 可选:插件启动时调用
+function start() {
+ log("Plugin started");
+}
+
+// 必须:处理连接
+function handleConn(conn) {
+ // 处理连接逻辑
+ conn.Close();
+}
+
+// 可选:插件停止时调用
+function stop() {
+ log("Plugin stopped");
+}
+```
+
+---
+
+## 插件结构
+
+### 生命周期函数
+
+| 函数 | 必须 | 说明 |
+|------|------|------|
+| `metadata()` | 否 | 返回插件元数据,不定义则使用默认值 |
+| `start()` | 否 | 插件启动时调用 |
+| `handleConn(conn)` | 是 | 处理每个连接 |
+| `stop()` | 否 | 插件停止时调用 |
+
+### 元数据字段
+
+```javascript
+function metadata() {
+ return {
+ name: "plugin-name", // 插件名称
+ version: "1.0.0", // 版本号
+ type: "app", // 类型: "app" 或 "proxy"
+ run_at: "client", // 运行位置: "client" 或 "server"
+ description: "描述", // 插件描述
+ author: "作者" // 作者名称
+ };
+}
+```
+
+---
+
+## API 参考
+
+### 基础 API
+
+#### `log(message)`
+
+输出日志信息。
+
+```javascript
+log("Hello, World!");
+// 输出: [JS:plugin-name] Hello, World!
+```
+
+#### `config(key)`
+
+获取插件配置值。
+
+```javascript
+var port = config("port");
+var host = config("host") || "127.0.0.1";
+```
+
+---
+
+### 连接 API (conn)
+
+`handleConn` 函数接收的 `conn` 对象提供以下方法:
+
+#### `conn.Read(size)`
+
+读取数据,返回字节数组,失败返回 `null`。
+
+```javascript
+var data = conn.Read(1024);
+if (data) {
+ log("Received " + data.length + " bytes");
+}
+```
+
+#### `conn.Write(data)`
+
+写入数据,返回写入的字节数。
+
+```javascript
+var written = conn.Write(data);
+log("Wrote " + written + " bytes");
+```
+
+#### `conn.Close()`
+
+关闭连接。
+
+```javascript
+conn.Close();
+```
+
+---
+
+### 文件系统 API (fs)
+
+所有文件操作都在沙箱中执行,有路径和大小限制。
+
+#### `fs.readFile(path)`
+
+读取文件内容。
+
+```javascript
+var result = fs.readFile("/path/to/file.txt");
+if (result.error) {
+ log("Error: " + result.error);
+} else {
+ log("Content: " + result.data);
+}
+```
+
+#### `fs.writeFile(path, content)`
+
+写入文件内容。
+
+```javascript
+var result = fs.writeFile("/path/to/file.txt", "Hello");
+if (result.ok) {
+ log("File written");
+} else {
+ log("Error: " + result.error);
+}
+```
+
+#### `fs.readDir(path)`
+
+读取目录内容。
+
+```javascript
+var result = fs.readDir("/path/to/dir");
+if (!result.error) {
+ for (var i = 0; i < result.entries.length; i++) {
+ var entry = result.entries[i];
+ log(entry.name + " - " + (entry.isDir ? "DIR" : entry.size + " bytes"));
+ }
+}
+```
+
+#### `fs.stat(path)`
+
+获取文件信息。
+
+```javascript
+var result = fs.stat("/path/to/file");
+if (!result.error) {
+ log("Name: " + result.name);
+ log("Size: " + result.size);
+ log("IsDir: " + result.isDir);
+ log("ModTime: " + result.modTime);
+}
+```
+
+#### `fs.exists(path)`
+
+检查文件是否存在。
+
+```javascript
+var result = fs.exists("/path/to/file");
+if (result.exists) {
+ log("File exists");
+}
+```
+
+#### `fs.mkdir(path)`
+
+创建目录。
+
+```javascript
+var result = fs.mkdir("/path/to/new/dir");
+if (result.ok) {
+ log("Directory created");
+}
+```
+
+#### `fs.remove(path)`
+
+删除文件或目录。
+
+```javascript
+var result = fs.remove("/path/to/file");
+if (result.ok) {
+ log("Removed");
+}
+```
+
+---
+
+### HTTP API (http)
+
+用于构建简单的 HTTP 服务。
+
+#### `http.serve(conn, handler)`
+
+处理 HTTP 请求。
+
+```javascript
+function handleConn(conn) {
+ http.serve(conn, function(req) {
+ return {
+ status: 200,
+ contentType: "application/json",
+ body: http.json({ message: "Hello", path: req.path })
+ };
+ });
+}
+```
+
+**请求对象 (req):**
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `method` | string | HTTP 方法 (GET, POST, etc.) |
+| `path` | string | 请求路径 |
+| `body` | string | 请求体 |
+
+**响应对象:**
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `status` | number | HTTP 状态码 (默认 200) |
+| `contentType` | string | Content-Type (默认 application/json) |
+| `body` | string | 响应体 |
+
+#### `http.json(data)`
+
+将对象序列化为 JSON 字符串。
+
+```javascript
+var jsonStr = http.json({ name: "test", value: 123 });
+// 返回: '{"name":"test","value":123}'
+```
+
+#### `http.sendFile(conn, filePath)`
+
+发送文件作为 HTTP 响应。
+
+```javascript
+function handleConn(conn) {
+ http.sendFile(conn, "/path/to/index.html");
+}
+```
+
+---
+
+## 示例插件
+
+### Echo 服务
+
+```javascript
+function metadata() {
+ return {
+ name: "echo",
+ version: "1.0.0",
+ type: "app",
+ description: "Echo back received data"
+ };
+}
+
+function handleConn(conn) {
+ while (true) {
+ var data = conn.Read(4096);
+ if (!data || data.length === 0) {
+ break;
+ }
+ conn.Write(data);
+ }
+ conn.Close();
+}
+```
+
+### HTTP 文件服务器
+
+```javascript
+function metadata() {
+ return {
+ name: "file-server",
+ version: "1.0.0",
+ type: "app",
+ description: "Simple HTTP file server"
+ };
+}
+
+var rootDir = "";
+
+function start() {
+ rootDir = config("root") || "/tmp";
+ log("Serving files from: " + rootDir);
+}
+
+function handleConn(conn) {
+ http.serve(conn, function(req) {
+ if (req.method === "GET") {
+ var filePath = rootDir + req.path;
+ if (req.path === "/") {
+ filePath = rootDir + "/index.html";
+ }
+
+ var stat = fs.stat(filePath);
+ if (stat.error) {
+ return { status: 404, body: "Not Found" };
+ }
+
+ if (stat.isDir) {
+ return listDirectory(filePath);
+ }
+
+ var file = fs.readFile(filePath);
+ if (file.error) {
+ return { status: 500, body: file.error };
+ }
+
+ return {
+ status: 200,
+ contentType: "text/html",
+ body: file.data
+ };
+ }
+ return { status: 405, body: "Method Not Allowed" };
+ });
+}
+
+function listDirectory(path) {
+ var result = fs.readDir(path);
+ if (result.error) {
+ return { status: 500, body: result.error };
+ }
+
+ var html = "
Directory Listing
";
+ for (var i = 0; i < result.entries.length; i++) {
+ var e = result.entries[i];
+ html += "- " + e.name + "
";
+ }
+ html += "
";
+
+ return { status: 200, contentType: "text/html", body: html };
+}
+```
+
+### JSON API 服务
+
+```javascript
+function metadata() {
+ return {
+ name: "api-server",
+ version: "1.0.0",
+ type: "app",
+ description: "JSON API server"
+ };
+}
+
+var counter = 0;
+
+function handleConn(conn) {
+ http.serve(conn, function(req) {
+ if (req.path === "/api/status") {
+ return {
+ status: 200,
+ body: http.json({
+ status: "ok",
+ counter: counter++,
+ timestamp: Date.now()
+ })
+ };
+ }
+
+ if (req.path === "/api/echo" && req.method === "POST") {
+ return {
+ status: 200,
+ body: http.json({
+ received: req.body
+ })
+ };
+ }
+
+ return {
+ status: 404,
+ body: http.json({ error: "Not Found" })
+ };
+ });
+}
+```
+
+---
+
+## 插件签名
+
+为了安全,JS 插件需要官方签名才能运行。
+
+### 签名格式
+
+签名文件 (`.sig`) 包含 Base64 编码的签名数据:
+
+```json
+{
+ "payload": {
+ "name": "plugin-name",
+ "version": "1.0.0",
+ "checksum": "sha256-hash",
+ "key_id": "official-v1"
+ },
+ "signature": "base64-signature"
+}
+```
+
+### 获取签名
+
+1. 提交插件到官方仓库
+2. 通过审核后获得签名
+3. 将 `.js` 和 `.sig` 文件一起分发
+
+---
+
+## 发布到商店
+
+### 商店 JSON 格式
+
+插件商店使用 `store.json` 文件索引所有插件:
+
+```json
+[
+ {
+ "name": "echo",
+ "version": "1.0.0",
+ "type": "app",
+ "description": "Echo service plugin",
+ "author": "GoTunnel",
+ "icon": "https://example.com/icon.png",
+ "download_url": "https://example.com/plugins/echo.js"
+ }
+]
+```
+
+### 提交流程
+
+1. Fork 官方插件仓库
+2. 添加插件文件到 `plugins/` 目录
+3. 更新 `store.json`
+4. 提交 Pull Request
+5. 等待审核和签名
+
+---
+
+## 沙箱限制
+
+为了安全,JS 插件运行在沙箱环境中:
+
+| 限制项 | 默认值 |
+|--------|--------|
+| 最大读取文件大小 | 10 MB |
+| 最大写入文件大小 | 10 MB |
+| 允许读取路径 | 插件数据目录 |
+| 允许写入路径 | 插件数据目录 |
+
+---
+
+## 调试技巧
+
+### 日志输出
+
+使用 `log()` 函数输出调试信息:
+
+```javascript
+log("Debug: variable = " + JSON.stringify(variable));
+```
+
+### 错误处理
+
+始终检查 API 返回的错误:
+
+```javascript
+var result = fs.readFile(path);
+if (result.error) {
+ log("Error reading file: " + result.error);
+ return;
+}
+```
+
+### 配置测试
+
+在服务端配置中测试插件:
+
+```yaml
+js_plugins:
+ - name: my-plugin
+ path: /path/to/my-plugin.js
+ sig_path: /path/to/my-plugin.js.sig
+ config:
+ debug: "true"
+ port: "8080"
+```
+
+---
+
+## 常见问题
+
+**Q: 插件无法加载?**
+
+A: 检查签名文件是否存在且有效。
+
+**Q: 文件操作失败?**
+
+A: 确认路径在沙箱允许范围内。
+
+**Q: 如何获取客户端 IP?**
+
+A: 目前 API 不支持,计划在后续版本添加。
+
+---
+
+## 更新日志
+
+### v1.0.0
+
+- 初始版本
+- 支持基础 API: log, config
+- 支持连接 API: Read, Write, Close
+- 支持文件系统 API: fs.*
+- 支持 HTTP API: http.*
diff --git a/README.md b/README.md
index e6f5a4d..030d669 100644
--- a/README.md
+++ b/README.md
@@ -54,14 +54,19 @@ GoTunnel 是一个类似 frp 的内网穿透解决方案,核心特点是**服
### 安全性
- **TLS 加密** - 默认启用 TLS 加密,证书自动生成,零配置
+- **TOFU 证书验证** - 首次连接信任 (Trust On First Use),防止中间人攻击
- **Token 认证** - 基于 Token 的身份验证机制
-- **客户端白名单** - 仅配置的客户端 ID 可以连接
+- **强制 Web 认证** - Web 控制台强制启用 JWT 认证
+- **安全审计日志** - 记录所有认证事件和安全相关操作
+- **连接数限制** - 防止资源耗尽攻击 (默认 10000 连接上限)
+- **客户端 ID 验证** - 严格的 ID 格式校验,防止注入攻击
### 可靠性
- **心跳检测** - 可配置的心跳间隔和超时时间,及时发现断线
- **断线重连** - 客户端自动重连机制,网络恢复后自动恢复服务
-- **优雅关闭** - 客户端断开时自动释放端口资源
+- **优雅关闭** - 支持 SIGINT/SIGTERM 信号,安全关闭所有连接
+- **资源自动释放** - 客户端断开时自动释放端口资源
### Web 管理
@@ -123,6 +128,7 @@ go build -o client ./cmd/client
| `-t` | 认证 Token | 是 |
| `-id` | 客户端 ID | 否(服务端自动分配) |
| `-no-tls` | 禁用 TLS 加密 | 否 |
+| `-skip-verify` | 跳过证书验证(不安全,仅测试用) | 否 |
## 配置系统
@@ -242,30 +248,55 @@ GoTunnel/
## 插件系统
-GoTunnel 支持基于 WASM 的插件系统,可扩展代理协议支持。
+GoTunnel 支持灵活的插件系统,可扩展代理协议和应用功能。
-### 架构设计
+### 插件类型
-- **内置类型**: tcp, udp, http, https 直接在 tunnel 代码中处理,无需插件
-- **官方插件**: SOCKS5 作为官方插件提供
-- **WASM 插件**: 自定义插件可通过 wazero 运行时动态加载
-- **混合分发**: 内置插件离线可用;WASM 插件可从服务端下载
+| 类型 | 说明 | 运行位置 |
+|------|------|----------|
+| `proxy` | 代理协议插件 (如 SOCKS5) | 服务端 |
+| `app` | 应用插件 (如 HTTP 文件服务) | 客户端 |
-### 开发自定义插件
+### 插件来源
-插件需实现 `ProxyHandler` 接口:
+- **内置插件**: 编译在二进制中,离线可用
+- **JS 插件**: 基于 goja 运行时,支持动态加载和热更新
+- **扩展商店**: 从官方商店浏览和安装插件
-```go
-type ProxyHandler interface {
- Metadata() PluginMetadata
- Init(config map[string]string) error
- HandleConn(conn net.Conn, dialer Dialer) error
- Close() error
+### 开发 JS 插件
+
+详细的插件开发文档请参考 [PLUGINS.md](PLUGINS.md)。
+
+**快速示例 - Echo 插件:**
+
+```javascript
+function metadata() {
+ return {
+ name: "echo",
+ version: "1.0.0",
+ type: "app",
+ description: "Echo service plugin",
+ author: "GoTunnel"
+ };
+}
+
+function start() {
+ log("Echo plugin started");
+}
+
+function handleConn(conn) {
+ var data = conn.Read(1024);
+ if (data) {
+ conn.Write(data);
+ }
+ conn.Close();
+}
+
+function stop() {
+ log("Echo plugin stopped");
}
```
-参考实现:`pkg/plugin/builtin/socks5.go`
-
## Web API
Web 控制台提供 RESTful API 用于管理客户端和配置。配置了 `username` 和 `password` 后,API 需要 JWT 认证。
diff --git a/cmd/client/main.go b/cmd/client/main.go
index bb7ca10..f0c4c32 100644
--- a/cmd/client/main.go
+++ b/cmd/client/main.go
@@ -3,6 +3,8 @@ package main
import (
"flag"
"log"
+ "os"
+ "path/filepath"
"github.com/gotunnel/internal/client/tunnel"
"github.com/gotunnel/pkg/crypto"
@@ -15,19 +17,27 @@ func main() {
token := flag.String("t", "", "auth token")
id := flag.String("id", "", "client id (optional, auto-assigned if empty)")
noTLS := flag.Bool("no-tls", false, "disable TLS")
+ skipVerify := flag.Bool("skip-verify", false, "skip TLS certificate verification (insecure)")
flag.Parse()
if *server == "" || *token == "" {
- log.Fatal("Usage: client -s -t [-id ] [-no-tls]")
+ log.Fatal("Usage: client -s -t [-id ] [-no-tls] [-skip-verify]")
}
client := tunnel.NewClient(*server, *token, *id)
- // TLS 默认启用
+ // TLS 默认启用,使用 TOFU 验证
if !*noTLS {
client.TLSEnabled = true
- client.TLSConfig = crypto.ClientTLSConfig()
- log.Printf("[Client] TLS enabled")
+ // 获取数据目录
+ home, _ := os.UserHomeDir()
+ dataDir := filepath.Join(home, ".gotunnel")
+ client.TLSConfig = crypto.ClientTLSConfigWithTOFU(*server, dataDir, *skipVerify)
+ if *skipVerify {
+ log.Printf("[Client] TLS enabled (certificate verification DISABLED - insecure)")
+ } else {
+ log.Printf("[Client] TLS enabled with TOFU certificate verification")
+ }
}
// 初始化插件系统
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 07c72f7..2606c39 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -5,6 +5,9 @@ import (
"fmt"
"log"
"os"
+ "os/signal"
+ "syscall"
+ "time"
"github.com/gotunnel/internal/server/app"
"github.com/gotunnel/internal/server/config"
@@ -72,22 +75,41 @@ func main() {
// 启动 Web 控制台
if cfg.Web.Enabled {
+ // 强制生成 Web 凭据(如果未配置)
+ if config.GenerateWebCredentials(cfg) {
+ log.Printf("[Web] Auto-generated credentials - Username: %s, Password: %s",
+ cfg.Web.Username, cfg.Web.Password)
+ log.Printf("[Web] Please save these credentials and update your config file")
+ // 保存配置以持久化凭据
+ if err := config.SaveServerConfig(*configPath, cfg); err != nil {
+ log.Printf("[Web] Warning: failed to save config: %v", err)
+ }
+ }
+
ws := app.NewWebServer(clientStore, server, cfg, *configPath, clientStore)
addr := fmt.Sprintf("%s:%d", cfg.Web.BindAddr, cfg.Web.BindPort)
go func() {
- var err error
- if cfg.Web.Username != "" && cfg.Web.Password != "" {
- err = ws.RunWithJWT(addr, cfg.Web.Username, cfg.Web.Password, cfg.Server.Token)
- } else {
- err = ws.Run(addr)
- }
+ // 始终使用 JWT 认证
+ err := ws.RunWithJWT(addr, cfg.Web.Username, cfg.Web.Password, cfg.Server.Token)
if err != nil {
log.Printf("[Web] Server error: %v", err)
}
}()
+ log.Printf("[Web] Console running at http://%s (authentication required)", addr)
}
+ // 优雅关闭信号处理
+ quit := make(chan os.Signal, 1)
+ signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
+
+ go func() {
+ <-quit
+ log.Printf("[Server] Received shutdown signal")
+ server.Shutdown(30 * time.Second)
+ os.Exit(0)
+ }()
+
log.Fatal(server.Run())
}
diff --git a/internal/server/config/config.go b/internal/server/config/config.go
index fb6744a..8239cad 100644
--- a/internal/server/config/config.go
+++ b/internal/server/config/config.go
@@ -108,10 +108,26 @@ func setDefaults(cfg *ServerConfig) {
// generateToken 生成随机 token
func generateToken(length int) string {
bytes := make([]byte, length/2)
- rand.Read(bytes)
+ n, err := rand.Read(bytes)
+ if err != nil || n != len(bytes) {
+ // 安全关键:随机数生成失败时 panic
+ panic("crypto/rand failed: unable to generate secure token")
+ }
return hex.EncodeToString(bytes)
}
+// GenerateWebCredentials 生成 Web 控制台凭据
+func GenerateWebCredentials(cfg *ServerConfig) bool {
+ if cfg.Web.Username == "" {
+ cfg.Web.Username = "admin"
+ }
+ if cfg.Web.Password == "" {
+ cfg.Web.Password = generateToken(16)
+ return true // 表示生成了新密码
+ }
+ return false
+}
+
// SaveServerConfig 保存服务端配置
func SaveServerConfig(path string, cfg *ServerConfig) error {
data, err := yaml.Marshal(cfg)
diff --git a/internal/server/router/auth.go b/internal/server/router/auth.go
index 20b5135..1a89cf6 100644
--- a/internal/server/router/auth.go
+++ b/internal/server/router/auth.go
@@ -6,6 +6,7 @@ import (
"net/http"
"github.com/gotunnel/pkg/auth"
+ "github.com/gotunnel/pkg/security"
)
// AuthHandler 认证处理器
@@ -51,6 +52,7 @@ func (h *AuthHandler) handleLogin(w http.ResponseWriter, r *http.Request) {
passMatch := subtle.ConstantTimeCompare([]byte(req.Password), []byte(h.password)) == 1
if !userMatch || !passMatch {
+ security.LogWebLogin(r.RemoteAddr, req.Username, false)
http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
return
}
@@ -62,6 +64,7 @@ func (h *AuthHandler) handleLogin(w http.ResponseWriter, r *http.Request) {
return
}
+ security.LogWebLogin(r.RemoteAddr, req.Username, true)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"token": token,
@@ -75,6 +78,31 @@ func (h *AuthHandler) handleCheck(w http.ResponseWriter, r *http.Request) {
return
}
+ // 从 Authorization header 获取 token
+ authHeader := r.Header.Get("Authorization")
+ if authHeader == "" {
+ http.Error(w, `{"error":"missing authorization header"}`, http.StatusUnauthorized)
+ return
+ }
+
+ // 解析 Bearer token
+ const prefix = "Bearer "
+ if len(authHeader) < len(prefix) || authHeader[:len(prefix)] != prefix {
+ http.Error(w, `{"error":"invalid authorization format"}`, http.StatusUnauthorized)
+ return
+ }
+ tokenStr := authHeader[len(prefix):]
+
+ // 验证 token
+ claims, err := h.jwtAuth.ValidateToken(tokenStr)
+ if err != nil {
+ http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
+ return
+ }
+
w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(map[string]bool{"valid": true})
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "valid": true,
+ "username": claims.Username,
+ })
}
diff --git a/internal/server/tunnel/server.go b/internal/server/tunnel/server.go
index 23788e5..5d86cb9 100644
--- a/internal/server/tunnel/server.go
+++ b/internal/server/tunnel/server.go
@@ -7,6 +7,7 @@ import (
"fmt"
"log"
"net"
+ "regexp"
"sync"
"time"
@@ -16,6 +17,7 @@ import (
"github.com/gotunnel/pkg/protocol"
"github.com/gotunnel/pkg/proxy"
"github.com/gotunnel/pkg/relay"
+ "github.com/gotunnel/pkg/security"
"github.com/gotunnel/pkg/utils"
"github.com/hashicorp/yamux"
)
@@ -25,8 +27,17 @@ const (
authTimeout = 10 * time.Second
heartbeatTimeout = 10 * time.Second
udpBufferSize = 65535
+ maxConnections = 10000 // 最大连接数
)
+// 客户端 ID 验证正则
+var clientIDRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
+
+// isValidClientID 验证客户端 ID 格式
+func isValidClientID(id string) bool {
+ return clientIDRegex.MatchString(id)
+}
+
// generateClientID 生成随机客户端 ID
func generateClientID() string {
bytes := make([]byte, 8)
@@ -48,6 +59,11 @@ type Server struct {
tlsConfig *tls.Config
pluginRegistry *plugin.Registry
jsPlugins []JSPluginEntry // 配置的 JS 插件
+ connSem chan struct{} // 连接数信号量
+ activeConns int64 // 当前活跃连接数
+ listener net.Listener // 主监听器
+ shutdown chan struct{} // 关闭信号
+ wg sync.WaitGroup // 等待所有连接关闭
}
// JSPluginEntry JS 插件条目
@@ -83,6 +99,8 @@ func NewServer(cs db.ClientStore, bindAddr string, bindPort int, token string, h
hbTimeout: hbTimeout,
portManager: utils.NewPortManager(),
clients: make(map[string]*ClientSession),
+ connSem: make(chan struct{}, maxConnections),
+ shutdown: make(chan struct{}),
}
}
@@ -91,6 +109,39 @@ func (s *Server) SetTLSConfig(config *tls.Config) {
s.tlsConfig = config
}
+// Shutdown 优雅关闭服务端
+func (s *Server) Shutdown(timeout time.Duration) error {
+ log.Printf("[Server] Initiating graceful shutdown...")
+ close(s.shutdown)
+
+ if s.listener != nil {
+ s.listener.Close()
+ }
+
+ // 关闭所有客户端会话
+ s.mu.Lock()
+ for _, cs := range s.clients {
+ cs.Session.Close()
+ }
+ s.mu.Unlock()
+
+ // 等待所有连接关闭,带超时
+ done := make(chan struct{})
+ go func() {
+ s.wg.Wait()
+ close(done)
+ }()
+
+ select {
+ case <-done:
+ log.Printf("[Server] All connections closed gracefully")
+ return nil
+ case <-time.After(timeout):
+ log.Printf("[Server] Shutdown timeout, forcing close")
+ return fmt.Errorf("shutdown timeout")
+ }
+}
+
// SetPluginRegistry 设置插件注册表
func (s *Server) SetPluginRegistry(registry *plugin.Registry) {
s.pluginRegistry = registry
@@ -122,20 +173,49 @@ func (s *Server) Run() error {
}
log.Printf("[Server] Listening on %s (no TLS)", addr)
}
- defer ln.Close()
+ s.listener = ln
for {
+ select {
+ case <-s.shutdown:
+ log.Printf("[Server] Shutdown signal received, stopping accept loop")
+ ln.Close()
+ return nil
+ default:
+ }
+
conn, err := ln.Accept()
if err != nil {
- log.Printf("[Server] Accept error: %v", err)
- continue
+ select {
+ case <-s.shutdown:
+ return nil
+ default:
+ log.Printf("[Server] Accept error: %v", err)
+ continue
+ }
}
- go s.handleConnection(conn)
+ s.wg.Add(1)
+ go func() {
+ defer s.wg.Done()
+ s.handleConnection(conn)
+ }()
}
}
// handleConnection 处理客户端连接
func (s *Server) handleConnection(conn net.Conn) {
+ clientIP := conn.RemoteAddr().String()
+
+ // 连接数限制检查
+ select {
+ case s.connSem <- struct{}{}:
+ defer func() { <-s.connSem }()
+ default:
+ security.LogConnRejected(clientIP, "max connections reached")
+ conn.Close()
+ return
+ }
+
defer conn.Close()
conn.SetReadDeadline(time.Now().Add(authTimeout))
@@ -158,6 +238,7 @@ func (s *Server) handleConnection(conn net.Conn) {
}
if authReq.Token != s.token {
+ security.LogInvalidToken(clientIP)
s.sendAuthResponse(conn, false, "invalid token", "")
return
}
@@ -166,6 +247,10 @@ func (s *Server) handleConnection(conn net.Conn) {
clientID := authReq.ClientID
if clientID == "" {
clientID = generateClientID()
+ } else if !isValidClientID(clientID) {
+ security.LogInvalidClientID(clientIP, clientID)
+ s.sendAuthResponse(conn, false, "invalid client id format", "")
+ return
}
// 检查客户端是否存在,不存在则自动创建
@@ -191,7 +276,7 @@ func (s *Server) handleConnection(conn net.Conn) {
return
}
- log.Printf("[Server] Client %s authenticated", clientID)
+ security.LogAuthSuccess(clientIP, clientID)
s.setupClientSession(conn, clientID, rules)
}
diff --git a/pkg/crypto/tls.go b/pkg/crypto/tls.go
index cd8c2a1..137753c 100644
--- a/pkg/crypto/tls.go
+++ b/pkg/crypto/tls.go
@@ -4,11 +4,17 @@ import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
+ "crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
+ "encoding/hex"
+ "fmt"
"math/big"
"net"
+ "os"
+ "path/filepath"
+ "strings"
"time"
)
@@ -62,3 +68,90 @@ func ClientTLSConfig() *tls.Config {
MinVersion: tls.VersionTLS12,
}
}
+
+// ClientTLSConfigWithTOFU 创建带 TOFU 验证的客户端 TLS 配置
+// serverAddr: 服务器地址,用于存储指纹
+// dataDir: 数据目录,用于存储指纹文件
+// skipVerify: 是否跳过验证(测试环境使用)
+func ClientTLSConfigWithTOFU(serverAddr, dataDir string, skipVerify bool) *tls.Config {
+ if skipVerify {
+ return &tls.Config{
+ InsecureSkipVerify: true,
+ MinVersion: tls.VersionTLS12,
+ }
+ }
+
+ return &tls.Config{
+ InsecureSkipVerify: true, // 必须为 true,因为是自签名证书
+ MinVersion: tls.VersionTLS12,
+ VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
+ return VerifyCertFingerprint(rawCerts, serverAddr, dataDir)
+ },
+ }
+}
+
+// CertFingerprint 计算证书指纹 (SHA256)
+func CertFingerprint(certDER []byte) string {
+ hash := sha256.Sum256(certDER)
+ return hex.EncodeToString(hash[:])
+}
+
+// GetFingerprintPath 获取指纹文件路径
+func GetFingerprintPath(serverAddr, dataDir string) string {
+ // 将服务器地址转换为安全的文件名
+ safeName := strings.ReplaceAll(serverAddr, ":", "_")
+ safeName = strings.ReplaceAll(safeName, "/", "_")
+ return filepath.Join(dataDir, ".fingerprint_"+safeName)
+}
+
+// LoadFingerprint 加载已保存的证书指纹
+func LoadFingerprint(serverAddr, dataDir string) (string, error) {
+ path := GetFingerprintPath(serverAddr, dataDir)
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(string(data)), nil
+}
+
+// SaveFingerprint 保存证书指纹
+func SaveFingerprint(serverAddr, dataDir, fingerprint string) error {
+ if err := os.MkdirAll(dataDir, 0700); err != nil {
+ return err
+ }
+ path := GetFingerprintPath(serverAddr, dataDir)
+ return os.WriteFile(path, []byte(fingerprint), 0600)
+}
+
+// VerifyCertFingerprint 验证证书指纹 (TOFU 模式)
+func VerifyCertFingerprint(rawCerts [][]byte, serverAddr, dataDir string) error {
+ if len(rawCerts) == 0 {
+ return fmt.Errorf("no certificate provided")
+ }
+
+ // 计算当前证书指纹
+ currentFP := CertFingerprint(rawCerts[0])
+
+ // 尝试加载已保存的指纹
+ savedFP, err := LoadFingerprint(serverAddr, dataDir)
+ if err != nil {
+ // 首次连接,保存指纹
+ if os.IsNotExist(err) {
+ if saveErr := SaveFingerprint(serverAddr, dataDir, currentFP); saveErr != nil {
+ return fmt.Errorf("failed to save fingerprint: %w", saveErr)
+ }
+ return nil // 首次连接,信任此证书
+ }
+ return fmt.Errorf("failed to load fingerprint: %w", err)
+ }
+
+ // 验证指纹是否匹配
+ if savedFP != currentFP {
+ return fmt.Errorf("certificate fingerprint mismatch: possible MITM attack\n"+
+ " Expected: %s\n Got: %s\n"+
+ " If the server certificate was legitimately changed, delete: %s",
+ savedFP, currentFP, GetFingerprintPath(serverAddr, dataDir))
+ }
+
+ return nil
+}
diff --git a/pkg/security/audit.go b/pkg/security/audit.go
new file mode 100644
index 0000000..6327933
--- /dev/null
+++ b/pkg/security/audit.go
@@ -0,0 +1,123 @@
+package security
+
+import (
+ "fmt"
+ "log"
+ "sync"
+ "time"
+)
+
+// EventType 安全事件类型
+type EventType string
+
+const (
+ EventAuthSuccess EventType = "AUTH_SUCCESS"
+ EventAuthFailed EventType = "AUTH_FAILED"
+ EventInvalidToken EventType = "INVALID_TOKEN"
+ EventInvalidClientID EventType = "INVALID_CLIENT_ID"
+ EventConnRejected EventType = "CONN_REJECTED"
+ EventConnLimit EventType = "CONN_LIMIT"
+ EventWebLoginOK EventType = "WEB_LOGIN_OK"
+ EventWebLoginFail EventType = "WEB_LOGIN_FAIL"
+)
+
+// AuditEvent 审计事件
+type AuditEvent struct {
+ Time time.Time
+ Type EventType
+ ClientIP string
+ ClientID string
+ Message string
+}
+
+// AuditLogger 审计日志记录器
+type AuditLogger struct {
+ mu sync.Mutex
+ events []AuditEvent
+ maxLen int
+}
+
+var (
+ defaultLogger *AuditLogger
+ once sync.Once
+)
+
+// GetAuditLogger 获取默认审计日志记录器
+func GetAuditLogger() *AuditLogger {
+ once.Do(func() {
+ defaultLogger = &AuditLogger{
+ events: make([]AuditEvent, 0, 1000),
+ maxLen: 1000,
+ }
+ })
+ return defaultLogger
+}
+
+// Log 记录安全事件
+func (l *AuditLogger) Log(eventType EventType, clientIP, clientID, message string) {
+ event := AuditEvent{
+ Time: time.Now(),
+ Type: eventType,
+ ClientIP: clientIP,
+ ClientID: clientID,
+ Message: message,
+ }
+
+ // 输出到标准日志
+ log.Printf("[Security] %s | IP=%s | ID=%s | %s",
+ eventType, clientIP, clientID, message)
+
+ // 保存到内存(用于审计查询)
+ l.mu.Lock()
+ defer l.mu.Unlock()
+
+ l.events = append(l.events, event)
+ if len(l.events) > l.maxLen {
+ l.events = l.events[1:]
+ }
+}
+
+// GetRecentEvents 获取最近的安全事件
+func (l *AuditLogger) GetRecentEvents(limit int) []AuditEvent {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+
+ if limit <= 0 || limit > len(l.events) {
+ limit = len(l.events)
+ }
+
+ start := len(l.events) - limit
+ result := make([]AuditEvent, limit)
+ copy(result, l.events[start:])
+ return result
+}
+
+// 便捷函数
+func LogAuthSuccess(clientIP, clientID string) {
+ GetAuditLogger().Log(EventAuthSuccess, clientIP, clientID, "authentication successful")
+}
+
+func LogAuthFailed(clientIP, clientID, reason string) {
+ GetAuditLogger().Log(EventAuthFailed, clientIP, clientID,
+ fmt.Sprintf("authentication failed: %s", reason))
+}
+
+func LogInvalidToken(clientIP string) {
+ GetAuditLogger().Log(EventInvalidToken, clientIP, "", "invalid token provided")
+}
+
+func LogInvalidClientID(clientIP, clientID string) {
+ GetAuditLogger().Log(EventInvalidClientID, clientIP, clientID, "invalid client ID format")
+}
+
+func LogConnRejected(clientIP, reason string) {
+ GetAuditLogger().Log(EventConnRejected, clientIP, "", reason)
+}
+
+func LogWebLogin(clientIP, username string, success bool) {
+ if success {
+ GetAuditLogger().Log(EventWebLoginOK, clientIP, username, "web login successful")
+ } else {
+ GetAuditLogger().Log(EventWebLoginFail, clientIP, username, "web login failed")
+ }
+}
diff --git a/plan.md b/plan.md
new file mode 100644
index 0000000..7e81950
--- /dev/null
+++ b/plan.md
@@ -0,0 +1,426 @@
+# GoTunnel 架构修复计划
+
+> 面向 100 万用户发布前的安全与稳定性修复方案
+
+## 问题概览
+
+| 严重程度 | 数量 | 状态 |
+|---------|------|------|
+| P0 严重 | 5 | ✅ 已修复 |
+| P1 高 | 5 | ✅ 已修复 |
+| P2 中 | 13 | 计划中 |
+| P3 低 | 15 | 后续迭代 |
+
+---
+
+## 修复完成总结
+
+### P0 严重问题 (已全部修复)
+
+| 编号 | 问题 | 修复文件 | 状态 |
+|-----|------|---------|------|
+| 1.1 | TLS 证书验证 | `pkg/crypto/tls.go` | ✅ TOFU 机制 |
+| 1.2 | Web 控制台无认证 | `cmd/server/main.go`, `config/config.go` | ✅ 强制认证 |
+| 1.3 | 认证检查端点失效 | `router/auth.go` | ✅ 实际验证 JWT |
+| 1.4 | Token 生成错误 | `config/config.go` | ✅ 错误检查 |
+| 1.5 | 客户端 ID 未验证 | `tunnel/server.go` | ✅ 正则验证 |
+
+### P1 高优先级问题 (已全部修复)
+
+| 编号 | 问题 | 修复文件 | 状态 |
+|-----|------|---------|------|
+| 2.1 | 无连接数限制 | `tunnel/server.go` | ✅ 10000 上限 |
+| 2.3 | 无优雅关闭 | `tunnel/server.go`, `cmd/server/main.go` | ✅ 信号处理 |
+| 2.4 | 消息大小未验证 | `protocol/message.go` | ✅ 已有验证 |
+| 2.5 | 无安全事件日志 | `pkg/security/audit.go` | ✅ 新增模块 |
+
+---
+
+## 第一阶段:P0 严重问题 (发布前必须修复)
+
+### 1.1 TLS 证书验证被禁用
+
+**文件**: `pkg/crypto/tls.go`
+
+**问题**: `InsecureSkipVerify: true` 导致中间人攻击风险
+
+**修复方案**:
+- 添加服务端证书指纹验证机制
+- 客户端首次连接时保存服务端证书指纹
+- 后续连接验证指纹是否匹配(Trust On First Use)
+- 提供 `--skip-verify` 参数供测试环境使用
+
+**修改内容**:
+```go
+// pkg/crypto/tls.go
+func ClientTLSConfig(serverFingerprint string) *tls.Config {
+ return &tls.Config{
+ MinVersion: tls.VersionTLS12,
+ InsecureSkipVerify: true, // 仍需要,因为是自签名证书
+ VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
+ // 验证证书指纹
+ return verifyCertFingerprint(rawCerts, serverFingerprint)
+ },
+ }
+}
+```
+
+---
+
+### 1.2 Web 控制台无认证
+
+**文件**: `cmd/server/main.go`
+
+**问题**: 默认配置下 Web 控制台完全开放
+
+**修复方案**:
+- 首次启动时自动生成随机密码
+- 强制要求配置用户名密码
+- 无认证时拒绝启动 Web 服务
+
+**修改内容**:
+```go
+// cmd/server/main.go
+if cfg.Web.Enabled {
+ if cfg.Web.Username == "" || cfg.Web.Password == "" {
+ // 自动生成凭据
+ cfg.Web.Username = "admin"
+ cfg.Web.Password = generateSecurePassword(16)
+ log.Printf("[Web] 自动生成凭据 - 用户名: %s, 密码: %s",
+ cfg.Web.Username, cfg.Web.Password)
+ // 保存到配置文件
+ saveConfig(cfg)
+ }
+}
+```
+
+---
+
+### 1.3 认证检查端点失效
+
+**文件**: `internal/server/router/auth.go`
+
+**问题**: `/auth/check` 始终返回 `valid: true`
+
+**修复方案**:
+- 实际验证 JWT Token
+- 返回真实的验证结果
+
+**修改内容**:
+```go
+// internal/server/router/auth.go
+func (h *AuthHandler) handleCheck(w http.ResponseWriter, r *http.Request) {
+ // 从 Authorization header 获取 token
+ token := extractToken(r)
+ if token == "" {
+ jsonError(w, "missing token", http.StatusUnauthorized)
+ return
+ }
+
+ // 验证 token
+ claims, err := h.validateToken(token)
+ if err != nil {
+ jsonError(w, "invalid token", http.StatusUnauthorized)
+ return
+ }
+
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "valid": true,
+ "user": claims.Username,
+ })
+}
+```
+
+---
+
+### 1.4 Token 生成错误未处理
+
+**文件**: `internal/server/config/config.go`
+
+**问题**: `rand.Read()` 错误被忽略,可能生成弱 Token
+
+**修复方案**:
+- 检查 `rand.Read()` 返回值
+- 失败时 panic 或返回错误
+- 增加 Token 强度验证
+
+**修改内容**:
+```go
+// internal/server/config/config.go
+func generateToken(length int) (string, error) {
+ bytes := make([]byte, length/2)
+ n, err := rand.Read(bytes)
+ if err != nil {
+ return "", fmt.Errorf("failed to generate token: %w", err)
+ }
+ if n != len(bytes) {
+ return "", fmt.Errorf("insufficient random bytes: got %d, want %d", n, len(bytes))
+ }
+ return hex.EncodeToString(bytes), nil
+}
+```
+
+---
+
+### 1.5 客户端 ID 未验证
+
+**文件**: `internal/server/tunnel/server.go`
+
+**问题**: tunnel server 中未使用已有的 ID 验证函数
+
+**修复方案**:
+- 在 handleConnection 中验证 clientID
+- 拒绝非法格式的 ID
+- 记录安全日志
+
+**修改内容**:
+```go
+// internal/server/tunnel/server.go
+func (s *Server) handleConnection(conn net.Conn) {
+ // ... 读取认证消息后
+
+ clientID := authReq.ClientID
+ if clientID != "" && !isValidClientID(clientID) {
+ log.Printf("[Security] Invalid client ID format from %s: %s",
+ conn.RemoteAddr(), clientID)
+ sendAuthResponse(conn, false, "invalid client id format")
+ return
+ }
+ // ...
+}
+
+var clientIDRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
+
+func isValidClientID(id string) bool {
+ return clientIDRegex.MatchString(id)
+}
+```
+
+---
+
+## 第二阶段:P1 高优先级问题 (发布前建议修复)
+
+### 2.1 无连接数限制
+
+**文件**: `internal/server/tunnel/server.go`
+
+**修复方案**:
+- 添加全局最大连接数限制
+- 添加单客户端连接数限制
+- 使用 semaphore 控制并发
+
+**修改内容**:
+```go
+type Server struct {
+ // ...
+ maxConns int
+ connSem chan struct{} // semaphore
+ clientConns map[string]int
+}
+
+func (s *Server) handleConnection(conn net.Conn) {
+ select {
+ case s.connSem <- struct{}{}:
+ defer func() { <-s.connSem }()
+ default:
+ conn.Close()
+ log.Printf("[Server] Connection rejected: max connections reached")
+ return
+ }
+ // ...
+}
+```
+
+---
+
+### 2.2 Goroutine 泄漏
+
+**文件**: 多个文件
+
+**修复方案**:
+- 使用 context 控制 goroutine 生命周期
+- 添加 goroutine 池
+- 确保所有 goroutine 有退出机制
+
+---
+
+### 2.3 无优雅关闭
+
+**文件**: `cmd/server/main.go`
+
+**修复方案**:
+- 监听 SIGTERM/SIGINT 信号
+- 关闭所有监听器
+- 等待现有连接完成
+- 设置关闭超时
+
+**修改内容**:
+```go
+// cmd/server/main.go
+func main() {
+ // ...
+
+ // 优雅关闭
+ quit := make(chan os.Signal, 1)
+ signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
+
+ go func() {
+ <-quit
+ log.Println("[Server] Shutting down...")
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ server.Shutdown(ctx)
+ webServer.Shutdown(ctx)
+ }()
+
+ server.Run()
+}
+```
+
+---
+
+### 2.4 消息大小未验证
+
+**文件**: `pkg/protocol/message.go`
+
+**修复方案**:
+- 在 ReadMessage 中检查消息长度
+- 超过限制时返回错误
+
+---
+
+### 2.5 无读写超时
+
+**文件**: `internal/server/tunnel/server.go`
+
+**修复方案**:
+- 所有连接设置读写超时
+- 使用 SetDeadline 而非一次性设置
+
+---
+
+### 2.6 竞态条件
+
+**文件**: `internal/server/tunnel/server.go`
+
+**修复方案**:
+- 使用 sync.Map 替代 map + mutex
+- 或确保所有 map 访问都在锁保护下
+
+---
+
+### 2.7 无安全事件日志
+
+**修复方案**:
+- 添加安全日志模块
+- 记录认证失败、异常访问等事件
+- 支持日志轮转
+
+---
+
+## 第三阶段:P2 中优先级问题 (发布后迭代)
+
+| 编号 | 问题 | 文件 |
+|-----|------|------|
+| 3.1 | 配置文件权限过宽 (0644) | config/config.go |
+| 3.2 | 心跳机制不完善 | tunnel/server.go |
+| 3.3 | HTTP 代理无 SSRF 防护 | proxy/http.go |
+| 3.4 | SOCKS5 代理无验证 | proxy/socks5.go |
+| 3.5 | 数据库操作无超时 | db/sqlite.go |
+| 3.6 | 错误处理不一致 | 多个文件 |
+| 3.7 | UDP 缓冲区无限制 | tunnel/server.go |
+| 3.8 | 代理规则无验证 | tunnel/server.go |
+| 3.9 | 客户端注册竞态 | tunnel/server.go |
+| 3.10 | Relay 资源泄漏 | relay/relay.go |
+| 3.11 | 插件配置无验证 | tunnel/server.go |
+| 3.12 | 端口号无边界检查 | tunnel/server.go |
+| 3.13 | 插件商店 URL 硬编码 | config/config.go |
+
+---
+
+## 第四阶段:P3 低优先级问题 (后续优化)
+
+| 编号 | 问题 | 建议 |
+|-----|------|------|
+| 4.1 | 无结构化日志 | 引入 zap/zerolog |
+| 4.2 | 无连接池 | 实现连接池 |
+| 4.3 | 线性查找规则 | 使用 map 索引 |
+| 4.4 | 无数据库缓存 | 添加内存缓存 |
+| 4.5 | 魔法数字 | 提取为常量 |
+| 4.6 | 无 godoc 注释 | 补充文档 |
+| 4.7 | 配置无验证 | 添加验证逻辑 |
+
+---
+
+## 修复顺序
+
+```
+Week 1: P0 问题 (5个)
+ ├── Day 1-2: 1.1 TLS 证书验证
+ ├── Day 2-3: 1.2 Web 控制台认证
+ ├── Day 3-4: 1.3 认证检查端点
+ ├── Day 4: 1.4 Token 生成
+ └── Day 5: 1.5 客户端 ID 验证
+
+Week 2: P1 问题 (7个)
+ ├── Day 1-2: 2.1 连接数限制
+ ├── Day 2-3: 2.2 Goroutine 泄漏
+ ├── Day 3-4: 2.3 优雅关闭
+ ├── Day 4: 2.4 消息大小验证
+ ├── Day 5: 2.5 读写超时
+ └── Day 5: 2.6-2.7 竞态条件 + 安全日志
+
+Week 3+: P2/P3 问题
+ └── 按优先级逐步修复
+```
+
+---
+
+## 测试计划
+
+### 安全测试
+- [ ] TLS 中间人攻击测试
+- [ ] 认证绕过测试
+- [ ] 注入攻击测试
+- [ ] DoS 攻击测试
+
+### 稳定性测试
+- [ ] 长时间运行测试 (72h+)
+- [ ] 高并发连接测试 (10000+)
+- [ ] 内存泄漏测试
+- [ ] Goroutine 泄漏测试
+
+### 性能测试
+- [ ] 吞吐量基准测试
+- [ ] 延迟基准测试
+- [ ] 资源使用监控
+
+---
+
+## 回滚方案
+
+如发布后发现严重问题:
+
+1. **立即回滚**: 保留上一版本二进制文件
+2. **热修复**: 针对特定问题发布补丁
+3. **降级运行**: 禁用问题功能模块
+
+---
+
+## 监控告警
+
+发布后需要监控的指标:
+
+- 连接数 / 活跃客户端数
+- 内存使用 / Goroutine 数量
+- 认证失败率
+- 错误日志频率
+- 响应延迟 P99
+
+---
+
+*文档版本: 1.0*
+*创建时间: 2025-12-29*
+*状态: 待审核*