update
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 30s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 58s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 48s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m23s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 56s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 58s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 52s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m42s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m19s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 54s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 2m3s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m1s
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 30s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 58s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 48s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m23s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 56s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 58s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 52s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m42s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m19s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 54s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 2m3s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m1s
This commit is contained in:
560
PLUGINS.md
Normal file
560
PLUGINS.md
Normal file
@@ -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 = "<html><body><h1>Directory Listing</h1><ul>";
|
||||||
|
for (var i = 0; i < result.entries.length; i++) {
|
||||||
|
var e = result.entries[i];
|
||||||
|
html += "<li><a href='" + e.name + "'>" + e.name + "</a></li>";
|
||||||
|
}
|
||||||
|
html += "</ul></body></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.*
|
||||||
67
README.md
67
README.md
@@ -54,14 +54,19 @@ GoTunnel 是一个类似 frp 的内网穿透解决方案,核心特点是**服
|
|||||||
### 安全性
|
### 安全性
|
||||||
|
|
||||||
- **TLS 加密** - 默认启用 TLS 加密,证书自动生成,零配置
|
- **TLS 加密** - 默认启用 TLS 加密,证书自动生成,零配置
|
||||||
|
- **TOFU 证书验证** - 首次连接信任 (Trust On First Use),防止中间人攻击
|
||||||
- **Token 认证** - 基于 Token 的身份验证机制
|
- **Token 认证** - 基于 Token 的身份验证机制
|
||||||
- **客户端白名单** - 仅配置的客户端 ID 可以连接
|
- **强制 Web 认证** - Web 控制台强制启用 JWT 认证
|
||||||
|
- **安全审计日志** - 记录所有认证事件和安全相关操作
|
||||||
|
- **连接数限制** - 防止资源耗尽攻击 (默认 10000 连接上限)
|
||||||
|
- **客户端 ID 验证** - 严格的 ID 格式校验,防止注入攻击
|
||||||
|
|
||||||
### 可靠性
|
### 可靠性
|
||||||
|
|
||||||
- **心跳检测** - 可配置的心跳间隔和超时时间,及时发现断线
|
- **心跳检测** - 可配置的心跳间隔和超时时间,及时发现断线
|
||||||
- **断线重连** - 客户端自动重连机制,网络恢复后自动恢复服务
|
- **断线重连** - 客户端自动重连机制,网络恢复后自动恢复服务
|
||||||
- **优雅关闭** - 客户端断开时自动释放端口资源
|
- **优雅关闭** - 支持 SIGINT/SIGTERM 信号,安全关闭所有连接
|
||||||
|
- **资源自动释放** - 客户端断开时自动释放端口资源
|
||||||
|
|
||||||
### Web 管理
|
### Web 管理
|
||||||
|
|
||||||
@@ -123,6 +128,7 @@ go build -o client ./cmd/client
|
|||||||
| `-t` | 认证 Token | 是 |
|
| `-t` | 认证 Token | 是 |
|
||||||
| `-id` | 客户端 ID | 否(服务端自动分配) |
|
| `-id` | 客户端 ID | 否(服务端自动分配) |
|
||||||
| `-no-tls` | 禁用 TLS 加密 | 否 |
|
| `-no-tls` | 禁用 TLS 加密 | 否 |
|
||||||
|
| `-skip-verify` | 跳过证书验证(不安全,仅测试用) | 否 |
|
||||||
|
|
||||||
## 配置系统
|
## 配置系统
|
||||||
|
|
||||||
@@ -242,30 +248,55 @@ GoTunnel/
|
|||||||
|
|
||||||
## 插件系统
|
## 插件系统
|
||||||
|
|
||||||
GoTunnel 支持基于 WASM 的插件系统,可扩展代理协议支持。
|
GoTunnel 支持灵活的插件系统,可扩展代理协议和应用功能。
|
||||||
|
|
||||||
### 架构设计
|
### 插件类型
|
||||||
|
|
||||||
- **内置类型**: tcp, udp, http, https 直接在 tunnel 代码中处理,无需插件
|
| 类型 | 说明 | 运行位置 |
|
||||||
- **官方插件**: SOCKS5 作为官方插件提供
|
|------|------|----------|
|
||||||
- **WASM 插件**: 自定义插件可通过 wazero 运行时动态加载
|
| `proxy` | 代理协议插件 (如 SOCKS5) | 服务端 |
|
||||||
- **混合分发**: 内置插件离线可用;WASM 插件可从服务端下载
|
| `app` | 应用插件 (如 HTTP 文件服务) | 客户端 |
|
||||||
|
|
||||||
### 开发自定义插件
|
### 插件来源
|
||||||
|
|
||||||
插件需实现 `ProxyHandler` 接口:
|
- **内置插件**: 编译在二进制中,离线可用
|
||||||
|
- **JS 插件**: 基于 goja 运行时,支持动态加载和热更新
|
||||||
|
- **扩展商店**: 从官方商店浏览和安装插件
|
||||||
|
|
||||||
```go
|
### 开发 JS 插件
|
||||||
type ProxyHandler interface {
|
|
||||||
Metadata() PluginMetadata
|
详细的插件开发文档请参考 [PLUGINS.md](PLUGINS.md)。
|
||||||
Init(config map[string]string) error
|
|
||||||
HandleConn(conn net.Conn, dialer Dialer) error
|
**快速示例 - Echo 插件:**
|
||||||
Close() error
|
|
||||||
|
```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 API
|
||||||
|
|
||||||
Web 控制台提供 RESTful API 用于管理客户端和配置。配置了 `username` 和 `password` 后,API 需要 JWT 认证。
|
Web 控制台提供 RESTful API 用于管理客户端和配置。配置了 `username` 和 `password` 后,API 需要 JWT 认证。
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package main
|
|||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/gotunnel/internal/client/tunnel"
|
"github.com/gotunnel/internal/client/tunnel"
|
||||||
"github.com/gotunnel/pkg/crypto"
|
"github.com/gotunnel/pkg/crypto"
|
||||||
@@ -15,19 +17,27 @@ func main() {
|
|||||||
token := flag.String("t", "", "auth token")
|
token := flag.String("t", "", "auth token")
|
||||||
id := flag.String("id", "", "client id (optional, auto-assigned if empty)")
|
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")
|
||||||
|
skipVerify := flag.Bool("skip-verify", false, "skip TLS certificate verification (insecure)")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
if *server == "" || *token == "" {
|
if *server == "" || *token == "" {
|
||||||
log.Fatal("Usage: client -s <server:port> -t <token> [-id <client_id>] [-no-tls]")
|
log.Fatal("Usage: client -s <server:port> -t <token> [-id <client_id>] [-no-tls] [-skip-verify]")
|
||||||
}
|
}
|
||||||
|
|
||||||
client := tunnel.NewClient(*server, *token, *id)
|
client := tunnel.NewClient(*server, *token, *id)
|
||||||
|
|
||||||
// TLS 默认启用
|
// TLS 默认启用,使用 TOFU 验证
|
||||||
if !*noTLS {
|
if !*noTLS {
|
||||||
client.TLSEnabled = true
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化插件系统
|
// 初始化插件系统
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gotunnel/internal/server/app"
|
"github.com/gotunnel/internal/server/app"
|
||||||
"github.com/gotunnel/internal/server/config"
|
"github.com/gotunnel/internal/server/config"
|
||||||
@@ -72,22 +75,41 @@ func main() {
|
|||||||
|
|
||||||
// 启动 Web 控制台
|
// 启动 Web 控制台
|
||||||
if cfg.Web.Enabled {
|
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)
|
ws := app.NewWebServer(clientStore, server, cfg, *configPath, clientStore)
|
||||||
addr := fmt.Sprintf("%s:%d", cfg.Web.BindAddr, cfg.Web.BindPort)
|
addr := fmt.Sprintf("%s:%d", cfg.Web.BindAddr, cfg.Web.BindPort)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
var err error
|
// 始终使用 JWT 认证
|
||||||
if cfg.Web.Username != "" && cfg.Web.Password != "" {
|
err := ws.RunWithJWT(addr, cfg.Web.Username, cfg.Web.Password, cfg.Server.Token)
|
||||||
err = ws.RunWithJWT(addr, cfg.Web.Username, cfg.Web.Password, cfg.Server.Token)
|
|
||||||
} else {
|
|
||||||
err = ws.Run(addr)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[Web] Server error: %v", err)
|
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())
|
log.Fatal(server.Run())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -108,10 +108,26 @@ func setDefaults(cfg *ServerConfig) {
|
|||||||
// generateToken 生成随机 token
|
// generateToken 生成随机 token
|
||||||
func generateToken(length int) string {
|
func generateToken(length int) string {
|
||||||
bytes := make([]byte, length/2)
|
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)
|
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 保存服务端配置
|
// SaveServerConfig 保存服务端配置
|
||||||
func SaveServerConfig(path string, cfg *ServerConfig) error {
|
func SaveServerConfig(path string, cfg *ServerConfig) error {
|
||||||
data, err := yaml.Marshal(cfg)
|
data, err := yaml.Marshal(cfg)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gotunnel/pkg/auth"
|
"github.com/gotunnel/pkg/auth"
|
||||||
|
"github.com/gotunnel/pkg/security"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuthHandler 认证处理器
|
// 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
|
passMatch := subtle.ConstantTimeCompare([]byte(req.Password), []byte(h.password)) == 1
|
||||||
|
|
||||||
if !userMatch || !passMatch {
|
if !userMatch || !passMatch {
|
||||||
|
security.LogWebLogin(r.RemoteAddr, req.Username, false)
|
||||||
http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
|
http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -62,6 +64,7 @@ func (h *AuthHandler) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
security.LogWebLogin(r.RemoteAddr, req.Username, true)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]string{
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
"token": token,
|
"token": token,
|
||||||
@@ -75,6 +78,31 @@ func (h *AuthHandler) handleCheck(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
// 从 Authorization header 获取 token
|
||||||
json.NewEncoder(w).Encode(map[string]bool{"valid": true})
|
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]interface{}{
|
||||||
|
"valid": true,
|
||||||
|
"username": claims.Username,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
|
"regexp"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ import (
|
|||||||
"github.com/gotunnel/pkg/protocol"
|
"github.com/gotunnel/pkg/protocol"
|
||||||
"github.com/gotunnel/pkg/proxy"
|
"github.com/gotunnel/pkg/proxy"
|
||||||
"github.com/gotunnel/pkg/relay"
|
"github.com/gotunnel/pkg/relay"
|
||||||
|
"github.com/gotunnel/pkg/security"
|
||||||
"github.com/gotunnel/pkg/utils"
|
"github.com/gotunnel/pkg/utils"
|
||||||
"github.com/hashicorp/yamux"
|
"github.com/hashicorp/yamux"
|
||||||
)
|
)
|
||||||
@@ -25,8 +27,17 @@ const (
|
|||||||
authTimeout = 10 * time.Second
|
authTimeout = 10 * time.Second
|
||||||
heartbeatTimeout = 10 * time.Second
|
heartbeatTimeout = 10 * time.Second
|
||||||
udpBufferSize = 65535
|
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
|
// generateClientID 生成随机客户端 ID
|
||||||
func generateClientID() string {
|
func generateClientID() string {
|
||||||
bytes := make([]byte, 8)
|
bytes := make([]byte, 8)
|
||||||
@@ -48,6 +59,11 @@ type Server struct {
|
|||||||
tlsConfig *tls.Config
|
tlsConfig *tls.Config
|
||||||
pluginRegistry *plugin.Registry
|
pluginRegistry *plugin.Registry
|
||||||
jsPlugins []JSPluginEntry // 配置的 JS 插件
|
jsPlugins []JSPluginEntry // 配置的 JS 插件
|
||||||
|
connSem chan struct{} // 连接数信号量
|
||||||
|
activeConns int64 // 当前活跃连接数
|
||||||
|
listener net.Listener // 主监听器
|
||||||
|
shutdown chan struct{} // 关闭信号
|
||||||
|
wg sync.WaitGroup // 等待所有连接关闭
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSPluginEntry JS 插件条目
|
// JSPluginEntry JS 插件条目
|
||||||
@@ -83,6 +99,8 @@ func NewServer(cs db.ClientStore, bindAddr string, bindPort int, token string, h
|
|||||||
hbTimeout: hbTimeout,
|
hbTimeout: hbTimeout,
|
||||||
portManager: utils.NewPortManager(),
|
portManager: utils.NewPortManager(),
|
||||||
clients: make(map[string]*ClientSession),
|
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
|
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 设置插件注册表
|
// SetPluginRegistry 设置插件注册表
|
||||||
func (s *Server) SetPluginRegistry(registry *plugin.Registry) {
|
func (s *Server) SetPluginRegistry(registry *plugin.Registry) {
|
||||||
s.pluginRegistry = registry
|
s.pluginRegistry = registry
|
||||||
@@ -122,20 +173,49 @@ func (s *Server) Run() error {
|
|||||||
}
|
}
|
||||||
log.Printf("[Server] Listening on %s (no TLS)", addr)
|
log.Printf("[Server] Listening on %s (no TLS)", addr)
|
||||||
}
|
}
|
||||||
defer ln.Close()
|
s.listener = ln
|
||||||
|
|
||||||
for {
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.shutdown:
|
||||||
|
log.Printf("[Server] Shutdown signal received, stopping accept loop")
|
||||||
|
ln.Close()
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
conn, err := ln.Accept()
|
conn, err := ln.Accept()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
select {
|
||||||
|
case <-s.shutdown:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
log.Printf("[Server] Accept error: %v", err)
|
log.Printf("[Server] Accept error: %v", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
go s.handleConnection(conn)
|
}
|
||||||
|
s.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer s.wg.Done()
|
||||||
|
s.handleConnection(conn)
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleConnection 处理客户端连接
|
// handleConnection 处理客户端连接
|
||||||
func (s *Server) handleConnection(conn net.Conn) {
|
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()
|
defer conn.Close()
|
||||||
|
|
||||||
conn.SetReadDeadline(time.Now().Add(authTimeout))
|
conn.SetReadDeadline(time.Now().Add(authTimeout))
|
||||||
@@ -158,6 +238,7 @@ func (s *Server) handleConnection(conn net.Conn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if authReq.Token != s.token {
|
if authReq.Token != s.token {
|
||||||
|
security.LogInvalidToken(clientIP)
|
||||||
s.sendAuthResponse(conn, false, "invalid token", "")
|
s.sendAuthResponse(conn, false, "invalid token", "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -166,6 +247,10 @@ func (s *Server) handleConnection(conn net.Conn) {
|
|||||||
clientID := authReq.ClientID
|
clientID := authReq.ClientID
|
||||||
if clientID == "" {
|
if clientID == "" {
|
||||||
clientID = generateClientID()
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[Server] Client %s authenticated", clientID)
|
security.LogAuthSuccess(clientIP, clientID)
|
||||||
s.setupClientSession(conn, clientID, rules)
|
s.setupClientSession(conn, clientID, rules)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,17 @@ import (
|
|||||||
"crypto/ecdsa"
|
"crypto/ecdsa"
|
||||||
"crypto/elliptic"
|
"crypto/elliptic"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"crypto/x509/pkix"
|
"crypto/x509/pkix"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -62,3 +68,90 @@ func ClientTLSConfig() *tls.Config {
|
|||||||
MinVersion: tls.VersionTLS12,
|
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
|
||||||
|
}
|
||||||
|
|||||||
123
pkg/security/audit.go
Normal file
123
pkg/security/audit.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
426
plan.md
Normal file
426
plan.md
Normal file
@@ -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*
|
||||||
|
*状态: 待审核*
|
||||||
Reference in New Issue
Block a user