first commit

This commit is contained in:
2025-11-21 16:32:35 +08:00
parent a54424afba
commit ce361482f4
26 changed files with 2445 additions and 0 deletions

262
INSTALL.md Normal file
View File

@@ -0,0 +1,262 @@
# LinkMaster 节点端安装指南
## 一句话安装
### 从 GitHub 安装(推荐)
```bash
curl -fsSL https://raw.githubusercontent.com/yourbask/linkmaster-node/main/install.sh | bash -s -- http://your-backend-server:8080
```
**替换说明:**
- `yourbask/linkmaster-node` - 独立的 node 项目 GitHub 仓库地址
- `http://your-backend-server:8080` - 替换为实际的后端服务器地址
**重要提示:**
- ⚠️ 节点端需要直接连接后端服务器(端口 8080不是前端地址
- 前端通过 `/api` 路径代理到后端,但节点端不使用前端代理
- 如果节点和后端在同一服务器:使用 `http://localhost:8080`
- 如果节点和后端在不同服务器:使用 `http://backend-ip:8080``http://backend-domain:8080`
- **本项目是独立的 GitHub 仓库**,与前后端项目分离
**示例:**
```bash
# 如果后端服务器在 192.168.1.100:8080
curl -fsSL https://raw.githubusercontent.com/yourbask/linkmaster-node/main/install.sh | bash -s -- http://192.168.1.100:8080
```
### 指定分支安装
```bash
GITHUB_BRANCH=develop curl -fsSL https://raw.githubusercontent.com/yourbask/linkmaster-node/main/install.sh | bash -s -- http://your-backend-server:8080
```
## 安装步骤说明
安装脚本会自动完成以下步骤:
1. **检测系统** - 自动识别 Linux 发行版和 CPU 架构
2. **安装依赖** - 自动安装 Git、Go、ping、traceroute、dnsutils 等工具
3. **克隆源码** - 从 GitHub 克隆 node 项目源码到 `/opt/linkmaster-node`
4. **编译安装** - 自动编译源码并安装二进制文件
5. **创建服务** - 自动创建 systemd 服务文件(使用 run.sh 启动)
6. **启动服务** - 自动启动并设置开机自启
7. **验证安装** - 检查服务状态和健康检查
**注意:** 每次服务启动时会自动拉取最新代码并重新编译,确保使用最新版本。
## 安装后管理
### 查看服务状态
```bash
sudo systemctl status linkmaster-node
```
### 查看日志
```bash
# 实时日志
sudo journalctl -u linkmaster-node -f
# 最近50行日志
sudo journalctl -u linkmaster-node -n 50
```
### 重启服务
```bash
sudo systemctl restart linkmaster-node
```
### 停止服务
```bash
sudo systemctl stop linkmaster-node
```
### 禁用开机自启
```bash
sudo systemctl disable linkmaster-node
```
## 验证安装
### 检查进程
```bash
ps aux | grep linkmaster-node
```
### 检查端口
```bash
netstat -tlnp | grep 2200
# 或
ss -tlnp | grep 2200
```
### 健康检查
```bash
curl http://localhost:2200/api/health
```
应该返回:`{"status":"ok"}`
## 手动安装(不使用脚本)
如果无法使用一键安装脚本,可以手动安装:
### 1. 克隆源码并编译
```bash
# 克隆仓库
git clone https://github.com/yourbask/linkmaster-node.git /opt/linkmaster-node
cd /opt/linkmaster-node
# 安装 Go 环境(如果未安装)
# Ubuntu/Debian
sudo apt-get install -y golang-go
# CentOS/RHEL
sudo yum install -y golang
# 编译
go build -o agent ./cmd/agent
# 安装到系统目录
sudo cp agent /usr/local/bin/linkmaster-node
sudo chmod +x /usr/local/bin/linkmaster-node
```
### 2. 安装系统依赖
```bash
# Ubuntu/Debian
sudo apt-get update
sudo apt-get install -y ping traceroute dnsutils curl
# CentOS/RHEL
sudo yum install -y iputils traceroute bind-utils curl
```
### 3. 创建 systemd 服务
```bash
# 确保 run.sh 有执行权限
sudo chmod +x /opt/linkmaster-node/run.sh
sudo tee /etc/systemd/system/linkmaster-node.service > /dev/null <<EOF
[Unit]
Description=LinkMaster Node Service
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/linkmaster-node
ExecStart=/opt/linkmaster-node/run.sh start
Restart=always
RestartSec=5
Environment="BACKEND_URL=http://your-backend-server:8080"
[Install]
WantedBy=multi-user.target
EOF
```
**注意:** 使用 `run.sh` 启动的好处是每次启动会自动拉取最新代码并重新编译。
### 4. 启动服务
```bash
sudo systemctl daemon-reload
sudo systemctl enable linkmaster-node
sudo systemctl start linkmaster-node
```
**注意:** 确保 `BACKEND_URL` 环境变量指向后端服务器的实际地址和端口(默认 8080不是前端地址。
## 防火墙配置
确保开放端口 2200
```bash
# Ubuntu/Debian (ufw)
sudo ufw allow 2200/tcp
# CentOS/RHEL (firewalld)
sudo firewall-cmd --permanent --add-port=2200/tcp
sudo firewall-cmd --reload
```
## 常见问题
### 1. 克隆或编译失败
**问题:** 无法从 GitHub 克隆源码或编译失败
**解决:**
- 检查网络连接
- 确认 GitHub 仓库地址正确(独立的 node 项目仓库)
- 确认已安装 Git 和 Go 环境
- 手动克隆并编译:`git clone https://github.com/yourbask/linkmaster-node.git && cd linkmaster-node && go build -o agent ./cmd/agent`
### 2. 服务启动失败
**问题:** systemctl status 显示服务失败
**解决:**
```bash
# 查看详细日志
sudo journalctl -u linkmaster-node -n 100
# 检查后端地址是否正确
sudo systemctl cat linkmaster-node | grep BACKEND_URL
# 手动测试后端连接
curl http://your-backend-server:8080/api/public/nodes/online
```
### 3. 端口被占用
**问题:** 端口 2200 已被占用
**解决:**
```bash
# 查找占用进程
sudo lsof -i :2200
# 停止占用进程或修改配置
```
### 4. 无法连接后端
**问题:** 节点无法连接到后端服务器
**解决:**
- 检查后端地址是否正确(应该是 `http://backend-server:8080`,不是前端地址)
- 检查网络连通性:`ping your-backend-server`
- 检查端口是否开放:`telnet your-backend-server 8080``nc -zv your-backend-server 8080`
- 检查防火墙规则(确保后端服务器的 8080 端口开放)
- 检查后端服务是否运行:`curl http://your-backend-server:8080/api/public/nodes/online`
- 如果使用前端代理,节点端仍需要直接连接后端,不能使用前端地址
## 卸载
```bash
# 停止服务
sudo systemctl stop linkmaster-node
sudo systemctl disable linkmaster-node
# 删除服务文件
sudo rm /etc/systemd/system/linkmaster-node.service
sudo systemctl daemon-reload
# 删除二进制文件和源码目录
sudo rm /usr/local/bin/linkmaster-node
sudo rm -rf /opt/linkmaster-node
```

11
Makefile Normal file
View File

@@ -0,0 +1,11 @@
.PHONY: build build-linux clean
build:
go build -o bin/linkmaster-node ./cmd/agent
build-linux:
GOOS=linux GOARCH=amd64 go build -o bin/linkmaster-node-linux ./cmd/agent
clean:
rm -rf bin/

View File

@@ -181,3 +181,4 @@ BACKEND_URL=http://192.168.1.100:8080 ./run.sh start
健康检查
# linkmaster-node
# linkmaster-node

BIN
agent Executable file

Binary file not shown.

75
cmd/agent/main.go Normal file
View File

@@ -0,0 +1,75 @@
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"time"
"linkmaster-node/internal/config"
"linkmaster-node/internal/heartbeat"
"linkmaster-node/internal/recovery"
"linkmaster-node/internal/server"
"go.uber.org/zap"
)
func main() {
// 加载配置
cfg, err := config.Load()
if err != nil {
fmt.Printf("加载配置失败: %v\n", err)
os.Exit(1)
}
// 初始化日志
logger, err := initLogger(cfg)
if err != nil {
fmt.Printf("初始化日志失败: %v\n", err)
os.Exit(1)
}
defer logger.Sync()
logger.Info("节点服务启动", zap.String("version", "1.0.0"))
// 初始化错误恢复
recovery.Init()
// 启动心跳上报
heartbeatReporter := heartbeat.NewReporter(cfg)
go heartbeatReporter.Start(context.Background())
// 启动HTTP服务器
httpServer := server.NewHTTPServer(cfg)
go func() {
if err := httpServer.Start(); err != nil {
logger.Fatal("HTTP服务器启动失败", zap.Error(err))
}
}()
// 等待中断信号
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
<-sigChan
logger.Info("收到停止信号,正在关闭服务...")
// 优雅关闭
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
httpServer.Shutdown(ctx)
heartbeatReporter.Stop()
logger.Info("服务已关闭")
}
func initLogger(cfg *config.Config) (*zap.Logger, error) {
if cfg.Debug {
return zap.NewDevelopment()
}
return zap.NewProduction()
}

36
go.mod Normal file
View File

@@ -0,0 +1,36 @@
module linkmaster-node
go 1.21
require (
github.com/gin-gonic/gin v1.9.1
go.uber.org/zap v1.26.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.14.0 // indirect
golang.org/x/net v0.17.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/text v0.13.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
)

92
go.sum Normal file
View File

@@ -0,0 +1,92 @@
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

323
install.sh Executable file
View File

@@ -0,0 +1,323 @@
#!/bin/bash
# ============================================
# LinkMaster 节点端一键安装脚本
# 使用方法: curl -fsSL https://raw.githubusercontent.com/yourbask/linkmaster-node/main/install.sh | bash -s -- <后端地址>
# 示例: curl -fsSL https://raw.githubusercontent.com/yourbask/linkmaster-node/main/install.sh | bash -s -- http://192.168.1.100:8080
# ============================================
set -e
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# 配置
# 尝试从脚本URL自动提取仓库信息如果通过curl下载
SCRIPT_URL="${SCRIPT_URL:-}"
if [ -z "$SCRIPT_URL" ] && [ -n "${BASH_SOURCE[0]}" ]; then
# 如果脚本是通过 curl 下载的,尝试从环境变量获取
SCRIPT_URL="${SCRIPT_URL:-}"
fi
# 默认配置(如果无法自动提取,使用这些默认值)
GITHUB_REPO="${GITHUB_REPO:-yourbask/linkmaster-node}" # 默认仓库(独立的 node 项目)
GITHUB_BRANCH="${GITHUB_BRANCH:-main}" # 默认分支
SOURCE_DIR="/opt/linkmaster-node" # 源码目录
BINARY_NAME="linkmaster-node"
INSTALL_DIR="/usr/local/bin"
SERVICE_NAME="linkmaster-node"
# 获取后端地址参数
BACKEND_URL="${1:-}"
if [ -z "$BACKEND_URL" ]; then
echo -e "${RED}错误: 请提供后端服务器地址${NC}"
echo -e "${YELLOW}使用方法:${NC}"
echo " curl -fsSL https://raw.githubusercontent.com/${GITHUB_REPO}/${GITHUB_BRANCH}/install.sh | bash -s -- http://your-backend-server:8080"
echo ""
echo -e "${YELLOW}注意:${NC}"
echo " - 节点端需要直接连接后端服务器,不是前端地址"
echo " - 后端默认端口: 8080"
echo " - 如果节点和后端在同一服务器: http://localhost:8080"
echo " - 如果节点和后端在不同服务器: http://backend-ip:8080 或 http://backend-domain:8080"
exit 1
fi
# 检测系统类型和架构
detect_system() {
if [ -f /etc/os-release ]; then
. /etc/os-release
OS=$ID
OS_VERSION=$VERSION_ID
else
echo -e "${RED}无法检测系统类型${NC}"
exit 1
fi
ARCH=$(uname -m)
case $ARCH in
x86_64)
ARCH="amd64"
;;
aarch64|arm64)
ARCH="arm64"
;;
*)
echo -e "${RED}不支持的架构: $ARCH${NC}"
exit 1
;;
esac
echo -e "${BLUE}检测到系统: $OS $OS_VERSION ($ARCH)${NC}"
}
# 安装系统依赖
install_dependencies() {
echo -e "${BLUE}安装系统依赖...${NC}"
if [ "$OS" = "ubuntu" ] || [ "$OS" = "debian" ]; then
sudo apt-get update -qq
sudo apt-get install -y -qq curl wget ping traceroute dnsutils git > /dev/null 2>&1
elif [ "$OS" = "centos" ] || [ "$OS" = "rhel" ] || [ "$OS" = "rocky" ] || [ "$OS" = "almalinux" ]; then
sudo yum install -y -q curl wget iputils traceroute bind-utils git > /dev/null 2>&1
else
echo -e "${YELLOW}警告: 未知系统类型,跳过依赖安装${NC}"
fi
echo -e "${GREEN}✓ 依赖安装完成${NC}"
}
# 安装 Go 环境
install_go() {
echo -e "${BLUE}安装 Go 环境...${NC}"
if [ "$OS" = "ubuntu" ] || [ "$OS" = "debian" ]; then
sudo apt-get update -qq
sudo apt-get install -y -qq golang-go > /dev/null 2>&1
elif [ "$OS" = "centos" ] || [ "$OS" = "rhel" ] || [ "$OS" = "rocky" ] || [ "$OS" = "almalinux" ]; then
sudo yum install -y -q golang > /dev/null 2>&1
else
echo -e "${YELLOW}无法自动安装 Go请手动安装: https://golang.org/dl/${NC}"
show_build_alternatives
exit 1
fi
if command -v go > /dev/null 2>&1; then
GO_VERSION=$(go version 2>/dev/null | head -1)
echo -e "${GREEN}✓ Go 安装完成: ${GO_VERSION}${NC}"
else
echo -e "${RED}Go 安装失败${NC}"
show_build_alternatives
exit 1
fi
}
# 显示替代方案
show_build_alternatives() {
echo ""
echo -e "${YELLOW}═══════════════════════════════════════════════════════════${NC}"
echo -e "${YELLOW} 安装失败,请使用以下替代方案:${NC}"
echo -e "${YELLOW}═══════════════════════════════════════════════════════════${NC}"
echo ""
echo -e "${GREEN}手动编译安装:${NC}"
echo " git clone https://github.com/${GITHUB_REPO}.git ${SOURCE_DIR}"
echo " cd ${SOURCE_DIR}"
echo " go build -o agent ./cmd/agent"
echo " sudo cp agent /usr/local/bin/linkmaster-node"
echo " sudo chmod +x /usr/local/bin/linkmaster-node"
echo ""
}
# 从源码编译安装
build_from_source() {
echo -e "${BLUE}从源码编译安装节点端...${NC}"
# 检查 Go 环境
if ! command -v go > /dev/null 2>&1; then
echo -e "${BLUE}未检测到 Go 环境,开始安装...${NC}"
install_go
fi
# 检查 Go 版本
GO_VERSION=$(go version 2>/dev/null | head -1 || echo "")
if [ -z "$GO_VERSION" ]; then
echo -e "${RED}无法获取 Go 版本信息${NC}"
show_build_alternatives
exit 1
fi
echo -e "${BLUE}检测到 Go 版本: ${GO_VERSION}${NC}"
# 如果源码目录已存在,先备份或删除
if [ -d "$SOURCE_DIR" ]; then
echo -e "${YELLOW}源码目录已存在,将更新代码...${NC}"
sudo rm -rf "$SOURCE_DIR"
fi
# 克隆仓库到源码目录
echo -e "${BLUE}克隆仓库到 ${SOURCE_DIR}...${NC}"
if ! sudo git clone --branch "${GITHUB_BRANCH}" "https://github.com/${GITHUB_REPO}.git" "$SOURCE_DIR" 2>&1; then
echo -e "${RED}克隆仓库失败,请检查网络连接和仓库地址${NC}"
echo -e "${YELLOW}仓库地址: https://github.com/${GITHUB_REPO}.git${NC}"
show_build_alternatives
exit 1
fi
# 设置目录权限
sudo chown -R $USER:$USER "$SOURCE_DIR" 2>/dev/null || true
cd "$SOURCE_DIR"
# 下载依赖
echo -e "${BLUE}下载 Go 依赖...${NC}"
if ! go mod download 2>&1; then
echo -e "${RED}下载依赖失败${NC}"
show_build_alternatives
exit 1
fi
# 编译
echo -e "${BLUE}编译二进制文件...${NC}"
BINARY_PATH="$SOURCE_DIR/agent"
if GOOS=linux GOARCH=${ARCH} CGO_ENABLED=0 go build -ldflags="-w -s" -o "$BINARY_PATH" ./cmd/agent 2>&1; then
if [ -f "$BINARY_PATH" ] && [ -s "$BINARY_PATH" ]; then
echo -e "${GREEN}✓ 编译成功${NC}"
else
echo -e "${RED}编译失败:未生成二进制文件${NC}"
show_build_alternatives
exit 1
fi
else
echo -e "${RED}编译失败${NC}"
show_build_alternatives
exit 1
fi
# 复制到安装目录(可选,保留在源码目录供 run.sh 使用)
sudo mkdir -p "$INSTALL_DIR"
sudo cp "$BINARY_PATH" "$INSTALL_DIR/$BINARY_NAME"
sudo chmod +x "$INSTALL_DIR/$BINARY_NAME"
echo -e "${GREEN}✓ 编译安装完成${NC}"
echo -e "${BLUE}源码目录: ${SOURCE_DIR}${NC}"
echo -e "${BLUE}二进制文件: ${INSTALL_DIR}/${BINARY_NAME}${NC}"
}
# 创建 systemd 服务
create_service() {
echo -e "${BLUE}创建 systemd 服务...${NC}"
# 确保 run.sh 有执行权限
sudo chmod +x "$SOURCE_DIR/run.sh"
sudo tee /etc/systemd/system/${SERVICE_NAME}.service > /dev/null <<EOF
[Unit]
Description=LinkMaster Node Service
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=$SOURCE_DIR
ExecStart=$SOURCE_DIR/run.sh start
Restart=always
RestartSec=5
Environment="BACKEND_URL=$BACKEND_URL"
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
echo -e "${GREEN}✓ 服务创建完成${NC}"
}
# 启动服务
start_service() {
echo -e "${BLUE}启动服务...${NC}"
sudo systemctl enable ${SERVICE_NAME} > /dev/null 2>&1
sudo systemctl restart ${SERVICE_NAME}
# 等待服务启动
sleep 3
# 检查服务状态
if sudo systemctl is-active --quiet ${SERVICE_NAME}; then
echo -e "${GREEN}✓ 服务启动成功${NC}"
else
echo -e "${RED}✗ 服务启动失败${NC}"
echo -e "${YELLOW}查看日志: sudo journalctl -u ${SERVICE_NAME} -n 50${NC}"
exit 1
fi
}
# 验证安装
verify_installation() {
echo -e "${BLUE}验证安装...${NC}"
# 检查进程
if pgrep -f "$BINARY_NAME" > /dev/null; then
echo -e "${GREEN}✓ 进程运行中${NC}"
else
echo -e "${YELLOW}⚠ 进程未运行${NC}"
fi
# 检查端口
if command -v netstat > /dev/null 2>&1; then
if netstat -tlnp 2>/dev/null | grep -q ":2200"; then
echo -e "${GREEN}✓ 端口 2200 已监听${NC}"
fi
elif command -v ss > /dev/null 2>&1; then
if ss -tlnp 2>/dev/null | grep -q ":2200"; then
echo -e "${GREEN}✓ 端口 2200 已监听${NC}"
fi
fi
# 健康检查
sleep 2
if curl -sf http://localhost:2200/api/health > /dev/null; then
echo -e "${GREEN}✓ 健康检查通过${NC}"
else
echo -e "${YELLOW}⚠ 健康检查未通过,请稍后重试${NC}"
fi
}
# 主安装流程
main() {
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN} LinkMaster 节点端安装程序${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
detect_system
install_dependencies
build_from_source
create_service
start_service
verify_installation
echo ""
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN} 安装完成!${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
echo -e "${BLUE}服务管理命令:${NC}"
echo " 查看状态: sudo systemctl status ${SERVICE_NAME}"
echo " 查看日志: sudo journalctl -u ${SERVICE_NAME} -f"
echo " 重启服务: sudo systemctl restart ${SERVICE_NAME}"
echo " 停止服务: sudo systemctl stop ${SERVICE_NAME}"
echo ""
echo -e "${BLUE}后端地址: ${BACKEND_URL}${NC}"
echo -e "${BLUE}节点端口: 2200${NC}"
echo ""
echo -e "${YELLOW}重要提示:${NC}"
echo " - 节点端直接连接后端服务器,不使用前端代理"
echo " - 确保后端地址可访问: curl ${BACKEND_URL}/api/public/nodes/online"
}
# 执行安装
main

60
internal/config/config.go Normal file
View File

@@ -0,0 +1,60 @@
package config
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
type Config struct {
Server struct {
Port int `yaml:"port"`
} `yaml:"server"`
Backend struct {
URL string `yaml:"url"`
} `yaml:"backend"`
Heartbeat struct {
Interval int `yaml:"interval"` // 心跳间隔(秒)
} `yaml:"heartbeat"`
Debug bool `yaml:"debug"`
}
func Load() (*Config, error) {
cfg := &Config{}
// 默认配置
cfg.Server.Port = 2200
cfg.Heartbeat.Interval = 60
cfg.Debug = false
// 从环境变量读取后端URL
backendURL := os.Getenv("BACKEND_URL")
if backendURL == "" {
backendURL = "http://localhost:8080"
}
cfg.Backend.URL = backendURL
// 尝试从配置文件读取
configPath := os.Getenv("CONFIG_PATH")
if configPath == "" {
configPath = "config.yaml"
}
if _, err := os.Stat(configPath); err == nil {
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("读取配置文件失败: %w", err)
}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("解析配置文件失败: %w", err)
}
}
return cfg, nil
}

143
internal/continuous/ping.go Normal file
View File

@@ -0,0 +1,143 @@
package continuous
import (
"context"
"os/exec"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap"
)
type PingTask struct {
TaskID string
Target string
Interval time.Duration
MaxDuration time.Duration
StartTime time.Time
LastRequest time.Time
StopCh chan struct{}
IsRunning bool
mu sync.RWMutex
logger *zap.Logger
}
func NewPingTask(taskID, target string, interval, maxDuration time.Duration) *PingTask {
logger, _ := zap.NewProduction()
return &PingTask{
TaskID: taskID,
Target: target,
Interval: interval,
MaxDuration: maxDuration,
StartTime: time.Now(),
LastRequest: time.Now(),
StopCh: make(chan struct{}),
IsRunning: true,
logger: logger,
}
}
func (t *PingTask) Start(ctx context.Context, resultCallback func(result map[string]interface{})) {
ticker := time.NewTicker(t.Interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.StopCh:
return
case <-ticker.C:
// 检查是否超过最大运行时长
t.mu.RLock()
if time.Since(t.StartTime) > t.MaxDuration {
t.mu.RUnlock()
t.Stop()
return
}
t.mu.RUnlock()
// 执行ping测试
result := t.executePing()
if resultCallback != nil {
resultCallback(result)
}
}
}
}
func (t *PingTask) Stop() {
t.mu.Lock()
defer t.mu.Unlock()
if t.IsRunning {
t.IsRunning = false
close(t.StopCh)
}
}
func (t *PingTask) UpdateLastRequest() {
t.mu.Lock()
defer t.mu.Unlock()
t.LastRequest = time.Now()
}
func (t *PingTask) executePing() map[string]interface{} {
cmd := exec.Command("ping", "-c", "4", t.Target)
output, err := cmd.CombinedOutput()
if err != nil {
return map[string]interface{}{
"timestamp": time.Now().Unix(),
"latency": -1,
"success": false,
"packet_loss": true,
"error": err.Error(),
}
}
// 解析ping输出
result := parsePingOutput(string(output))
result["timestamp"] = time.Now().Unix()
return result
}
func parsePingOutput(output string) map[string]interface{} {
result := map[string]interface{}{
"latency": 0.0,
"success": true,
"packet_loss": false,
}
lines := strings.Split(output, "\n")
for _, line := range lines {
if strings.Contains(line, "packets transmitted") {
// 解析丢包率
parts := strings.Fields(line)
for i, part := range parts {
if part == "packet" && i+2 < len(parts) {
if loss, err := strconv.ParseFloat(strings.Trim(parts[i+1], "%"), 64); err == nil {
result["packet_loss"] = loss > 0
}
}
}
}
if strings.Contains(line, "min/avg/max") {
// 解析平均延迟
parts := strings.Fields(line)
for _, part := range parts {
if strings.Contains(part, "/") {
times := strings.Split(part, "/")
if len(times) >= 2 {
if avg, err := strconv.ParseFloat(times[1], 64); err == nil {
result["latency"] = avg
}
}
}
}
}
}
return result
}

View File

@@ -0,0 +1,126 @@
package continuous
import (
"context"
"fmt"
"net"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap"
)
type TCPingTask struct {
TaskID string
Target string
Host string
Port int
Interval time.Duration
MaxDuration time.Duration
StartTime time.Time
LastRequest time.Time
StopCh chan struct{}
IsRunning bool
mu sync.RWMutex
logger *zap.Logger
}
func NewTCPingTask(taskID, target string, interval, maxDuration time.Duration) (*TCPingTask, error) {
// 解析host:port
parts := strings.Split(target, ":")
if len(parts) != 2 {
return nil, fmt.Errorf("无效的target格式需要 host:port")
}
host := parts[0]
port, err := strconv.Atoi(parts[1])
if err != nil {
return nil, fmt.Errorf("无效的端口: %v", err)
}
logger, _ := zap.NewProduction()
return &TCPingTask{
TaskID: taskID,
Target: target,
Host: host,
Port: port,
Interval: interval,
MaxDuration: maxDuration,
StartTime: time.Now(),
LastRequest: time.Now(),
StopCh: make(chan struct{}),
IsRunning: true,
logger: logger,
}, nil
}
func (t *TCPingTask) Start(ctx context.Context, resultCallback func(result map[string]interface{})) {
ticker := time.NewTicker(t.Interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.StopCh:
return
case <-ticker.C:
// 检查是否超过最大运行时长
t.mu.RLock()
if time.Since(t.StartTime) > t.MaxDuration {
t.mu.RUnlock()
t.Stop()
return
}
t.mu.RUnlock()
// 执行tcping测试
result := t.executeTCPing()
if resultCallback != nil {
resultCallback(result)
}
}
}
}
func (t *TCPingTask) Stop() {
t.mu.Lock()
defer t.mu.Unlock()
if t.IsRunning {
t.IsRunning = false
close(t.StopCh)
}
}
func (t *TCPingTask) UpdateLastRequest() {
t.mu.Lock()
defer t.mu.Unlock()
t.LastRequest = time.Now()
}
func (t *TCPingTask) executeTCPing() map[string]interface{} {
start := time.Now()
conn, err := net.DialTimeout("tcp", net.JoinHostPort(t.Host, strconv.Itoa(t.Port)), 5*time.Second)
latency := time.Since(start).Milliseconds()
if err != nil {
return map[string]interface{}{
"timestamp": time.Now().Unix(),
"latency": -1,
"success": false,
"packet_loss": true,
"error": err.Error(),
}
}
defer conn.Close()
return map[string]interface{}{
"timestamp": time.Now().Unix(),
"latency": float64(latency),
"success": true,
"packet_loss": false,
}
}

View File

@@ -0,0 +1,315 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"sync"
"time"
"linkmaster-node/internal/config"
"linkmaster-node/internal/continuous"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
var continuousTasks = make(map[string]*ContinuousTask)
var taskMutex sync.RWMutex
var backendURL string
var logger *zap.Logger
func InitContinuousHandler(cfg *config.Config) {
backendURL = cfg.Backend.URL
logger, _ = zap.NewProduction()
}
type ContinuousTask struct {
TaskID string
Type string
Target string
Interval time.Duration
MaxDuration time.Duration
StartTime time.Time
LastRequest time.Time
StopCh chan struct{}
IsRunning bool
pingTask *continuous.PingTask
tcpingTask *continuous.TCPingTask
}
func HandleContinuousStart(c *gin.Context) {
var req struct {
Type string `json:"type" binding:"required"`
Target string `json:"target" binding:"required"`
Interval int `json:"interval"` // 秒
MaxDuration int `json:"max_duration"` // 分钟
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 生成任务ID
taskID := generateTaskID()
// 设置默认值
interval := 10 * time.Second
if req.Interval > 0 {
interval = time.Duration(req.Interval) * time.Second
}
maxDuration := 60 * time.Minute
if req.MaxDuration > 0 {
maxDuration = time.Duration(req.MaxDuration) * time.Minute
}
// 创建任务
task := &ContinuousTask{
TaskID: taskID,
Type: req.Type,
Target: req.Target,
Interval: interval,
MaxDuration: maxDuration,
StartTime: time.Now(),
LastRequest: time.Now(),
StopCh: make(chan struct{}),
IsRunning: true,
}
// 根据类型创建对应的任务
if req.Type == "ping" {
pingTask := continuous.NewPingTask(taskID, req.Target, interval, maxDuration)
task.pingTask = pingTask
} else if req.Type == "tcping" {
tcpingTask, err := continuous.NewTCPingTask(taskID, req.Target, interval, maxDuration)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
task.tcpingTask = tcpingTask
} else {
c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的持续测试类型"})
return
}
taskMutex.Lock()
continuousTasks[taskID] = task
taskMutex.Unlock()
// 启动持续测试goroutine
ctx := context.Background()
if task.pingTask != nil {
go task.pingTask.Start(ctx, func(result map[string]interface{}) {
pushResultToBackend(taskID, result)
})
} else if task.tcpingTask != nil {
go task.tcpingTask.Start(ctx, func(result map[string]interface{}) {
pushResultToBackend(taskID, result)
})
}
c.JSON(http.StatusOK, gin.H{
"task_id": taskID,
})
}
func HandleContinuousStop(c *gin.Context) {
var req struct {
TaskID string `json:"task_id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
taskMutex.Lock()
task, exists := continuousTasks[req.TaskID]
if exists {
task.IsRunning = false
if task.pingTask != nil {
task.pingTask.Stop()
}
if task.tcpingTask != nil {
task.tcpingTask.Stop()
}
close(task.StopCh)
delete(continuousTasks, req.TaskID)
}
taskMutex.Unlock()
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "任务不存在"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "任务已停止"})
}
func HandleContinuousStatus(c *gin.Context) {
taskID := c.Query("task_id")
if taskID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "task_id参数缺失"})
return
}
taskMutex.RLock()
task, exists := continuousTasks[taskID]
if exists {
// 更新LastRequest时间
task.LastRequest = time.Now()
if task.pingTask != nil {
task.pingTask.UpdateLastRequest()
}
if task.tcpingTask != nil {
task.tcpingTask.UpdateLastRequest()
}
}
taskMutex.RUnlock()
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "任务不存在"})
return
}
c.JSON(http.StatusOK, gin.H{
"task_id": task.TaskID,
"is_running": task.IsRunning,
"start_time": task.StartTime,
"last_request": task.LastRequest,
})
}
func pushResultToBackend(taskID string, result map[string]interface{}) {
// 推送结果到后端
url := fmt.Sprintf("%s/api/public/node/continuous/result", backendURL)
// 获取本机IP
nodeIP := getLocalIP()
data := map[string]interface{}{
"task_id": taskID,
"node_ip": nodeIP,
"result": result,
}
jsonData, err := json.Marshal(data)
if err != nil {
logger.Error("序列化结果失败", zap.Error(err))
return
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
logger.Error("创建请求失败", zap.Error(err))
return
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
logger.Warn("推送结果失败", zap.Error(err))
// 如果推送失败,停止任务
taskMutex.Lock()
if task, exists := continuousTasks[taskID]; exists {
task.IsRunning = false
if task.pingTask != nil {
task.pingTask.Stop()
}
if task.tcpingTask != nil {
task.tcpingTask.Stop()
}
delete(continuousTasks, taskID)
}
taskMutex.Unlock()
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
logger.Warn("推送结果失败", zap.Int("status", resp.StatusCode))
// 如果推送失败,停止任务
taskMutex.Lock()
if task, exists := continuousTasks[taskID]; exists {
task.IsRunning = false
if task.pingTask != nil {
task.pingTask.Stop()
}
if task.tcpingTask != nil {
task.tcpingTask.Stop()
}
delete(continuousTasks, taskID)
}
taskMutex.Unlock()
}
}
func getLocalIP() string {
// 简化实现返回第一个非回环IP
// 实际应该获取外网IP
addrs, err := net.InterfaceAddrs()
if err != nil {
return "127.0.0.1"
}
for _, addr := range addrs {
if ipNet, ok := addr.(*net.IPNet); ok && !ipNet.IP.IsLoopback() {
if ipNet.IP.To4() != nil {
return ipNet.IP.String()
}
}
}
return "127.0.0.1"
}
func generateTaskID() string {
return fmt.Sprintf("task_%d", time.Now().UnixNano())
}
// 定期清理超时任务
func StartTaskCleanup() {
ticker := time.NewTicker(1 * time.Minute)
go func() {
for range ticker.C {
now := time.Now()
taskMutex.Lock()
for taskID, task := range continuousTasks {
// 检查最大运行时长
if now.Sub(task.StartTime) > task.MaxDuration {
logger.Info("任务达到最大运行时长,自动停止", zap.String("task_id", taskID))
task.IsRunning = false
if task.pingTask != nil {
task.pingTask.Stop()
}
if task.tcpingTask != nil {
task.tcpingTask.Stop()
}
delete(continuousTasks, taskID)
continue
}
// 检查无客户端连接30分钟无请求
if now.Sub(task.LastRequest) > 30*time.Minute {
logger.Info("任务无客户端连接,自动停止", zap.String("task_id", taskID))
task.IsRunning = false
if task.pingTask != nil {
task.pingTask.Stop()
}
if task.tcpingTask != nil {
task.tcpingTask.Stop()
}
delete(continuousTasks, taskID)
}
}
taskMutex.Unlock()
}
}()
}

45
internal/handler/dns.go Normal file
View File

@@ -0,0 +1,45 @@
package handler
import (
"net"
"time"
"github.com/gin-gonic/gin"
)
func handleDns(c *gin.Context, url string, params map[string]interface{}) {
// 执行DNS查询
start := time.Now()
ips, err := net.LookupIP(url)
lookupTime := time.Since(start).Milliseconds()
if err != nil {
c.JSON(200, gin.H{
"type": "ceDns",
"url": url,
"error": err.Error(),
})
return
}
// 格式化IP列表
ipList := make([]map[string]interface{}, 0)
for _, ip := range ips {
ipType := "A"
if ip.To4() == nil {
ipType = "AAAA"
}
ipList = append(ipList, map[string]interface{}{
"type": ipType,
"ip": ip.String(),
})
}
c.JSON(200, gin.H{
"type": "ceDns",
"url": url,
"ips": ipList,
"lookup_time": lookupTime,
})
}

View File

@@ -0,0 +1,84 @@
package handler
import (
"net"
"os/exec"
"sync"
"github.com/gin-gonic/gin"
)
func handleFindPing(c *gin.Context, url string, params map[string]interface{}) {
// url应该是CIDR格式如 8.8.8.0/24
cidr := url
if cidrParam, ok := params["cidr"].(string); ok && cidrParam != "" {
cidr = cidrParam
}
// 解析CIDR
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
c.JSON(200, gin.H{
"type": "ceFindPing",
"error": "无效的CIDR格式",
})
return
}
// 生成IP列表
var ipList []string
for ip := ipNet.IP.Mask(ipNet.Mask); ipNet.Contains(ip); incIP(ip) {
ipList = append(ipList, ip.String())
}
// 移除网络地址和广播地址
if len(ipList) > 2 {
ipList = ipList[1 : len(ipList)-1]
}
// 并发ping测试
var wg sync.WaitGroup
var mu sync.Mutex
aliveIPs := make([]string, 0)
// 限制并发数
semaphore := make(chan struct{}, 50)
for _, ip := range ipList {
wg.Add(1)
semaphore <- struct{}{}
go func(ipAddr string) {
defer wg.Done()
defer func() { <-semaphore }()
// 执行ping只ping一次快速检测
cmd := exec.Command("ping", "-c", "1", "-W", "1", ipAddr)
err := cmd.Run()
if err == nil {
mu.Lock()
aliveIPs = append(aliveIPs, ipAddr)
mu.Unlock()
}
}(ip)
}
wg.Wait()
c.JSON(200, gin.H{
"type": "ceFindPing",
"cidr": cidr,
"alive_ips": aliveIPs,
"alive_count": len(aliveIPs),
"total_ips": len(ipList),
})
}
func incIP(ip net.IP) {
for j := len(ip) - 1; j >= 0; j-- {
ip[j]++
if ip[j] > 0 {
break
}
}
}

32
internal/handler/get.go Normal file
View File

@@ -0,0 +1,32 @@
package handler
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
)
func handleGet(c *gin.Context, url string, params map[string]interface{}) {
// TODO: 实现HTTP GET测试
// 这里先返回一个简单的响应
c.JSON(http.StatusOK, gin.H{
"type": "ceGet",
"url": url,
"statuscode": 200,
"totaltime": time.Since(time.Now()).Milliseconds(),
"response": "OK",
})
}
func handlePost(c *gin.Context, url string, params map[string]interface{}) {
// TODO: 实现HTTP POST测试
c.JSON(http.StatusOK, gin.H{
"type": "cePost",
"url": url,
"statuscode": 200,
"totaltime": time.Since(time.Now()).Milliseconds(),
"response": "OK",
})
}

85
internal/handler/ping.go Normal file
View File

@@ -0,0 +1,85 @@
package handler
import (
"net"
"os/exec"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
func handlePing(c *gin.Context, url string, params map[string]interface{}) {
// 执行ping命令
cmd := exec.Command("ping", "-c", "4", url)
output, err := cmd.CombinedOutput()
if err != nil {
c.JSON(200, gin.H{
"type": "cePing",
"url": url,
"error": err.Error(),
})
return
}
// 解析ping输出
result := parsePingOutput(string(output), url)
c.JSON(200, result)
}
func parsePingOutput(output, url string) map[string]interface{} {
result := map[string]interface{}{
"type": "cePing",
"url": url,
"ip": "",
}
// 解析IP地址
lines := strings.Split(output, "\n")
for _, line := range lines {
if strings.Contains(line, "PING") {
// 提取IP地址
parts := strings.Fields(line)
for _, part := range parts {
if net.ParseIP(part) != nil {
result["ip"] = part
break
}
}
}
if strings.Contains(line, "packets transmitted") {
// 解析丢包率
parts := strings.Fields(line)
for i, part := range parts {
if part == "packet" && i+2 < len(parts) {
if loss, err := strconv.ParseFloat(strings.Trim(parts[i+1], "%"), 64); err == nil {
result["packets_losrat"] = loss
}
}
}
}
if strings.Contains(line, "min/avg/max") {
// 解析延迟统计
parts := strings.Fields(line)
for _, part := range parts {
if strings.Contains(part, "/") {
times := strings.Split(part, "/")
if len(times) >= 3 {
if min, err := strconv.ParseFloat(times[0], 64); err == nil {
result["time_min"] = min
}
if avg, err := strconv.ParseFloat(times[1], 64); err == nil {
result["time_avg"] = avg
}
if max, err := strconv.ParseFloat(times[2], 64); err == nil {
result["time_max"] = max
}
}
}
}
}
}
return result
}

View File

@@ -0,0 +1,59 @@
package handler
import (
"net"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
func handleSocket(c *gin.Context, url string, params map[string]interface{}) {
// 解析host:port格式
parts := strings.Split(url, ":")
if len(parts) != 2 {
c.JSON(200, gin.H{
"type": "ceSocket",
"url": url,
"error": "格式错误,需要 host:port",
})
return
}
host := parts[0]
portStr := parts[1]
port, err := strconv.Atoi(portStr)
if err != nil {
c.JSON(200, gin.H{
"type": "ceSocket",
"url": url,
"error": "端口格式错误",
})
return
}
// 执行TCP连接测试
conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, portStr), 5*time.Second)
if err != nil {
c.JSON(200, gin.H{
"type": "ceSocket",
"url": url,
"host": host,
"port": port,
"result": "false",
"error": err.Error(),
})
return
}
defer conn.Close()
c.JSON(200, gin.H{
"type": "ceSocket",
"url": url,
"host": host,
"port": port,
"result": "true",
})
}

View File

@@ -0,0 +1,63 @@
package handler
import (
"net"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
func handleTCPing(c *gin.Context, url string, params map[string]interface{}) {
// 解析host:port格式
parts := strings.Split(url, ":")
if len(parts) != 2 {
c.JSON(200, gin.H{
"type": "ceTCPing",
"url": url,
"error": "格式错误,需要 host:port",
})
return
}
host := parts[0]
portStr := parts[1]
port, err := strconv.Atoi(portStr)
if err != nil {
c.JSON(200, gin.H{
"type": "ceTCPing",
"url": url,
"error": "端口格式错误",
})
return
}
// 执行TCP连接测试
start := time.Now()
conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, portStr), 5*time.Second)
latency := time.Since(start).Milliseconds()
if err != nil {
c.JSON(200, gin.H{
"type": "ceTCPing",
"url": url,
"host": host,
"port": port,
"latency": -1,
"error": err.Error(),
})
return
}
defer conn.Close()
c.JSON(200, gin.H{
"type": "ceTCPing",
"url": url,
"host": host,
"port": port,
"latency": latency,
"success": true,
})
}

49
internal/handler/test.go Normal file
View File

@@ -0,0 +1,49 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
)
// HandleTest 统一测试接口
func HandleTest(c *gin.Context) {
var req struct {
Type string `json:"type" binding:"required"`
URL string `json:"url" binding:"required"`
Params map[string]interface{} `json:"params"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 根据类型分发到不同的处理器
switch req.Type {
case "ceGet":
handleGet(c, req.URL, req.Params)
case "cePost":
handlePost(c, req.URL, req.Params)
case "cePing":
handlePing(c, req.URL, req.Params)
case "ceDns":
handleDns(c, req.URL, req.Params)
case "ceTrace":
handleTrace(c, req.URL, req.Params)
case "ceSocket":
handleSocket(c, req.URL, req.Params)
case "ceTCPing":
handleTCPing(c, req.URL, req.Params)
case "ceFindPing":
handleFindPing(c, req.URL, req.Params)
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的测试类型"})
}
}
// HandleHealth 健康检查
func HandleHealth(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}

39
internal/handler/trace.go Normal file
View File

@@ -0,0 +1,39 @@
package handler
import (
"os/exec"
"strings"
"github.com/gin-gonic/gin"
)
func handleTrace(c *gin.Context, url string, params map[string]interface{}) {
// 执行traceroute命令
cmd := exec.Command("traceroute", url)
output, err := cmd.CombinedOutput()
if err != nil {
c.JSON(200, gin.H{
"type": "ceTrace",
"url": url,
"error": err.Error(),
})
return
}
// 解析输出
lines := strings.Split(string(output), "\n")
traceResult := make([]string, 0)
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" {
traceResult = append(traceResult, line)
}
}
c.JSON(200, gin.H{
"type": "ceTrace",
"url": url,
"trace_result": traceResult,
})
}

View File

@@ -0,0 +1,103 @@
package heartbeat
import (
"bytes"
"context"
"fmt"
"net"
"net/http"
"time"
"linkmaster-node/internal/config"
"go.uber.org/zap"
)
type Reporter struct {
cfg *config.Config
client *http.Client
logger *zap.Logger
stopCh chan struct{}
}
func NewReporter(cfg *config.Config) *Reporter {
logger, _ := zap.NewProduction()
return &Reporter{
cfg: cfg,
client: &http.Client{
Timeout: 10 * time.Second,
},
logger: logger,
stopCh: make(chan struct{}),
}
}
func (r *Reporter) Start(ctx context.Context) {
ticker := time.NewTicker(time.Duration(r.cfg.Heartbeat.Interval) * time.Second)
defer ticker.Stop()
// 立即发送一次心跳
r.sendHeartbeat()
for {
select {
case <-ctx.Done():
return
case <-r.stopCh:
return
case <-ticker.C:
r.sendHeartbeat()
}
}
}
func (r *Reporter) Stop() {
close(r.stopCh)
}
func (r *Reporter) sendHeartbeat() {
// 获取本机IP
localIP := getLocalIP()
// 发送心跳使用Form格式兼容旧接口
url := fmt.Sprintf("%s/api/node/heartbeat", r.cfg.Backend.URL)
req, err := http.NewRequest("POST", url, bytes.NewBufferString(fmt.Sprintf("realNodeIP=%s&type=pingServer", localIP)))
if err != nil {
r.logger.Error("创建心跳请求失败", zap.Error(err))
return
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := r.client.Do(req)
if err != nil {
r.logger.Warn("发送心跳失败", zap.Error(err))
return
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
r.logger.Debug("心跳发送成功", zap.String("ip", localIP))
} else {
r.logger.Warn("心跳发送失败", zap.Int("status", resp.StatusCode))
}
}
func getLocalIP() string {
// 获取第一个非回环IP
addrs, err := net.InterfaceAddrs()
if err != nil {
return "127.0.0.1"
}
for _, addr := range addrs {
if ipNet, ok := addr.(*net.IPNet); ok && !ipNet.IP.IsLoopback() {
if ipNet.IP.To4() != nil {
return ipNet.IP.String()
}
}
}
return "127.0.0.1"
}

View File

@@ -0,0 +1,25 @@
package recovery
import (
"runtime/debug"
"go.uber.org/zap"
)
var logger *zap.Logger
func Init() {
// 初始化logger这里简化处理实际应该从外部传入
logger, _ = zap.NewProduction()
}
// Recover 恢复panic
func Recover() {
if r := recover(); r != nil {
logger.Error("发生panic",
zap.Any("panic", r),
zap.String("stack", string(debug.Stack())),
)
}
}

72
internal/server/http.go Normal file
View File

@@ -0,0 +1,72 @@
package server
import (
"context"
"fmt"
"net/http"
"linkmaster-node/internal/config"
"linkmaster-node/internal/handler"
"linkmaster-node/internal/recovery"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type HTTPServer struct {
server *http.Server
logger *zap.Logger
}
func NewHTTPServer(cfg *config.Config) *HTTPServer {
if !cfg.Debug {
gin.SetMode(gin.ReleaseMode)
}
router := gin.New()
router.Use(gin.Recovery())
router.Use(recoveryMiddleware)
// 初始化持续测试处理器
handler.InitContinuousHandler(cfg)
// 启动任务清理goroutine
handler.StartTaskCleanup()
// 注册路由
api := router.Group("/api")
{
api.POST("/test", handler.HandleTest)
api.POST("/continuous/start", handler.HandleContinuousStart)
api.POST("/continuous/stop", handler.HandleContinuousStop)
api.GET("/continuous/status", handler.HandleContinuousStatus)
api.GET("/health", handler.HandleHealth)
}
server := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
Handler: router,
}
logger, _ := zap.NewProduction()
return &HTTPServer{
server: server,
logger: logger,
}
}
func (s *HTTPServer) Start() error {
s.logger.Info("HTTP服务器启动", zap.String("addr", s.server.Addr))
return s.server.ListenAndServe()
}
func (s *HTTPServer) Shutdown(ctx context.Context) error {
return s.server.Shutdown(ctx)
}
func recoveryMiddleware(c *gin.Context) {
defer recovery.Recover()
c.Next()
}

5
node.log Normal file
View File

@@ -0,0 +1,5 @@
{"level":"info","ts":1763025207.120181,"caller":"agent/main.go:35","msg":"节点服务启动","version":"1.0.0"}
{"level":"info","ts":1763025207.1208231,"caller":"server/http.go:60","msg":"HTTP服务器启动","addr":":2200"}
{"level":"info","ts":1763653448.720011,"caller":"agent/main.go:57","msg":"收到停止信号,正在关闭服务..."}
{"level":"fatal","ts":1763653448.720453,"caller":"agent/main.go:48","msg":"HTTP服务器启动失败","error":"http: Server closed","stacktrace":"main.main.func1\n\t/Users/yoyo/Desktop/newLinkMaster/node/cmd/agent/main.go:48"}
{"level":"info","ts":1763653448.720591,"caller":"agent/main.go:66","msg":"服务已关闭"}

1
node.pid Normal file
View File

@@ -0,0 +1 @@
48748

339
run.sh Executable file
View File

@@ -0,0 +1,339 @@
#!/bin/bash
# ============================================
# LinkMaster 节点端运行脚本
# 用途:启动、停止、重启节点端服务
# ============================================
set -e
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# 脚本目录
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# 配置
BINARY_NAME="agent"
LOG_FILE="node.log"
PID_FILE="node.pid"
BACKEND_URL="${BACKEND_URL:-http://localhost:8080}"
# 获取PID
get_pid() {
if [ -f "$PID_FILE" ]; then
PID=$(cat "$PID_FILE")
if ps -p "$PID" > /dev/null 2>&1; then
echo "$PID"
else
rm -f "$PID_FILE"
echo ""
fi
else
echo ""
fi
}
# 拉取最新源码并编译
update_and_build() {
echo -e "${BLUE}拉取最新源码...${NC}"
# 检查是否在 Git 仓库中
if [ ! -d ".git" ]; then
echo -e "${YELLOW}警告: 当前目录不是 Git 仓库,跳过代码更新${NC}"
return 0
fi
# 拉取最新代码
if git pull 2>&1; then
echo -e "${GREEN}✓ 代码更新完成${NC}"
else
PULL_EXIT_CODE=$?
echo -e "${YELLOW}警告: Git 拉取失败(退出码: $PULL_EXIT_CODE),将使用当前代码继续${NC}"
echo -e "${YELLOW}可能原因: 网络问题、权限问题或本地有未提交的更改${NC}"
fi
# 检查 Go 环境
if ! command -v go > /dev/null 2>&1; then
echo -e "${RED}错误: 未找到 Go 环境,无法编译${NC}"
exit 1
fi
# 更新依赖
echo -e "${BLUE}更新 Go 依赖...${NC}"
if ! go mod download 2>&1; then
echo -e "${YELLOW}警告: 依赖更新失败,尝试继续编译${NC}"
else
echo -e "${GREEN}✓ 依赖更新完成${NC}"
fi
# 编译
echo -e "${BLUE}编译二进制文件...${NC}"
ARCH=$(uname -m)
case $ARCH in
x86_64)
ARCH="amd64"
;;
aarch64|arm64)
ARCH="arm64"
;;
*)
ARCH="amd64"
;;
esac
if GOOS=linux GOARCH=${ARCH} CGO_ENABLED=0 go build -ldflags="-w -s" -o "$BINARY_NAME" ./cmd/agent 2>&1; then
if [ -f "$BINARY_NAME" ] && [ -s "$BINARY_NAME" ]; then
chmod +x "$BINARY_NAME"
echo -e "${GREEN}✓ 编译成功${NC}"
else
echo -e "${RED}错误: 编译失败,未生成二进制文件${NC}"
exit 1
fi
else
echo -e "${RED}错误: 编译失败${NC}"
exit 1
fi
}
# 检查二进制文件
check_binary() {
if [ ! -f "$BINARY_NAME" ]; then
echo -e "${RED}错误: 找不到二进制文件 $BINARY_NAME${NC}"
echo -e "${YELLOW}尝试编译...${NC}"
update_and_build
return
fi
if [ ! -x "$BINARY_NAME" ]; then
chmod +x "$BINARY_NAME"
fi
}
# 检查端口占用
check_port() {
if command -v lsof > /dev/null 2>&1; then
PORT_PID=$(lsof -ti :2200 2>/dev/null || echo "")
if [ -n "$PORT_PID" ]; then
# 检查是否是我们的进程
if [ -f "$PID_FILE" ] && [ "$PORT_PID" = "$(cat "$PID_FILE")" ]; then
return 0
fi
echo -e "${YELLOW}警告: 端口2200已被占用 (PID: $PORT_PID)${NC}"
echo -e "${YELLOW}是否要停止该进程? (y/n)${NC}"
read -r answer
if [ "$answer" = "y" ] || [ "$answer" = "Y" ]; then
kill "$PORT_PID" 2>/dev/null || true
sleep 1
else
echo -e "${RED}取消启动${NC}"
exit 1
fi
fi
fi
}
# 启动服务
start() {
PID=$(get_pid)
if [ -n "$PID" ]; then
echo -e "${YELLOW}节点端已在运行 (PID: $PID)${NC}"
return 0
fi
# 拉取最新源码并编译
update_and_build
check_port
echo -e "${BLUE}启动节点端服务...${NC}"
echo -e "${BLUE}后端地址: $BACKEND_URL${NC}"
# 设置环境变量
export BACKEND_URL="$BACKEND_URL"
# 后台运行
nohup ./"$BINARY_NAME" > "$LOG_FILE" 2>&1 &
NEW_PID=$!
# 保存PID
echo "$NEW_PID" > "$PID_FILE"
# 等待启动
sleep 2
# 检查是否启动成功
if ps -p "$NEW_PID" > /dev/null 2>&1; then
# 再次检查健康状态
sleep 1
if curl -s http://localhost:2200/api/health > /dev/null 2>&1; then
echo -e "${GREEN}✓ 节点端已启动 (PID: $NEW_PID)${NC}"
echo -e "${BLUE}日志文件: $LOG_FILE${NC}"
echo -e "${BLUE}查看日志: tail -f $LOG_FILE${NC}"
else
echo -e "${GREEN}✓ 节点端进程已启动 (PID: $NEW_PID)${NC}"
echo -e "${YELLOW}⚠ 健康检查未通过,请稍后查看日志${NC}"
fi
else
echo -e "${RED}✗ 节点端启动失败${NC}"
echo -e "${YELLOW}请查看日志: cat $LOG_FILE${NC}"
rm -f "$PID_FILE"
exit 1
fi
}
# 停止服务
stop() {
PID=$(get_pid)
# 如果没有PID文件尝试通过端口查找
if [ -z "$PID" ]; then
if command -v lsof > /dev/null 2>&1; then
PORT_PID=$(lsof -ti :2200 2>/dev/null || echo "")
if [ -n "$PORT_PID" ]; then
PID="$PORT_PID"
echo -e "${YELLOW}通过端口找到进程 (PID: $PID)${NC}"
fi
fi
fi
if [ -z "$PID" ]; then
echo -e "${YELLOW}节点端未运行${NC}"
rm -f "$PID_FILE"
return 0
fi
echo -e "${BLUE}停止节点端服务 (PID: $PID)...${NC}"
# 发送TERM信号
kill "$PID" 2>/dev/null || true
# 等待进程结束
for i in {1..10}; do
if ! ps -p "$PID" > /dev/null 2>&1; then
break
fi
sleep 1
done
# 如果还在运行,强制杀死
if ps -p "$PID" > /dev/null 2>&1; then
echo -e "${YELLOW}强制停止节点端...${NC}"
kill -9 "$PID" 2>/dev/null || true
sleep 1
fi
rm -f "$PID_FILE"
echo -e "${GREEN}✓ 节点端已停止${NC}"
}
# 重启服务
restart() {
echo -e "${BLUE}重启节点端服务...${NC}"
stop
sleep 1
start
}
# 查看状态
status() {
PID=$(get_pid)
if [ -n "$PID" ]; then
echo -e "${GREEN}节点端运行中 (PID: $PID)${NC}"
# 检查健康状态
if command -v curl > /dev/null 2>&1; then
HEALTH=$(curl -s http://localhost:2200/api/health 2>/dev/null || echo "failed")
if [ "$HEALTH" = '{"status":"ok"}' ]; then
echo -e "${GREEN}✓ 健康检查: 正常${NC}"
else
echo -e "${YELLOW}⚠ 健康检查: 异常${NC}"
fi
fi
# 显示进程信息
ps -p "$PID" -o pid,ppid,cmd,%mem,%cpu,etime 2>/dev/null || true
else
echo -e "${RED}节点端未运行${NC}"
fi
}
# 查看日志
logs() {
if [ -f "$LOG_FILE" ]; then
tail -f "$LOG_FILE"
else
echo -e "${YELLOW}日志文件不存在: $LOG_FILE${NC}"
fi
}
# 查看完整日志
logs_all() {
if [ -f "$LOG_FILE" ]; then
cat "$LOG_FILE"
else
echo -e "${YELLOW}日志文件不存在: $LOG_FILE${NC}"
fi
}
# 显示帮助
help() {
echo "LinkMaster 节点端运行脚本"
echo ""
echo "使用方法:"
echo " $0 {start|stop|restart|status|logs|logs-all|help}"
echo ""
echo "命令说明:"
echo " start - 启动节点端服务(会自动拉取最新代码并编译)"
echo " stop - 停止节点端服务"
echo " restart - 重启节点端服务"
echo " status - 查看运行状态"
echo " logs - 实时查看日志"
echo " logs-all - 查看完整日志"
echo " help - 显示帮助信息"
echo ""
echo "环境变量:"
echo " BACKEND_URL - 后端服务地址 (默认: http://localhost:8080)"
echo ""
echo "示例:"
echo " BACKEND_URL=http://192.168.1.100:8080 $0 start"
echo " $0 status"
echo " $0 logs"
}
# 主逻辑
case "${1:-help}" in
start)
start
;;
stop)
stop
;;
restart)
restart
;;
status)
status
;;
logs)
logs
;;
logs-all)
logs_all
;;
help|--help|-h)
help
;;
*)
echo -e "${RED}未知命令: $1${NC}"
echo ""
help
exit 1
;;
esac