Compare commits

..

18 Commits

Author SHA1 Message Date
b5fc83065c feat: 更新文档和配置逻辑,增强心跳机制和持续测试功能
- 在 INSTALL.md 和 README.md 中添加配置优先级说明,确保环境变量优先级最高。
- 增强心跳机制,新增字段以传递节点信息。
- 持续测试功能优化,支持批量推送和自动清理。
- 更新版本号至 v1.1.4,完善文档以反映新功能和改进。
2025-12-24 01:21:45 +08:00
ef31a054c0 chore: 更新版本号至 v1.1.3
增加 version host_name 2个新字段传递
2025-12-23 23:09:55 +08:00
ff35510ef0 修复 2025-12-17 21:12:49 +08:00
21592ae8a0 fix: 修复 IPv6 地址解析中的端口处理逻辑
- 将 LastIndex 替换为 Index,以正确找到第一个闭合括号。
- 添加逻辑以在端口部分为空时使用默认端口 80,解决了潜在的连接问题。
2025-12-17 20:09:51 +08:00
f01547df35 refactor: 优化 HTTP 请求处理逻辑
- 改进了对重定向的处理,确保在 CheckRedirect 返回 ErrUseLastResponse 时能够正确处理响应。
- 移除了不必要的空行以提升代码可读性。
- 增强了错误处理逻辑,确保在没有响应的情况下返回适当的错误信息。
2025-12-17 20:08:31 +08:00
4a2532a83b 强壮安装脚本 2025-12-17 19:56:16 +08:00
b962265168 refactor: 优化 TCPing 任务的目标解析逻辑
- 改进了 TCPing 任务中对 host:port 格式的解析,支持 IPv6 地址格式并默认使用端口 80。
- 移除了不必要的空行以提升代码可读性。
- 更新了安装脚本,移除了不再使用的镜像源。
2025-12-17 19:16:39 +08:00
38acca6484 fix: 改进心跳报告中的错误处理和日志记录
- 增强了 RegisterNode 和 sendHeartbeat 函数中的错误消息,包含 URL 和响应体详情以便更好地调试。
- 移除了不必要的空行以使代码结构更清晰。
2025-12-07 18:37:17 +08:00
8d36ef495d 修复 2025-12-07 18:09:49 +08:00
7ac5d54a84 refactor: 重命名和替换构建及上传脚本
- 删除旧的 build-all.sh 和 upload.sh 脚本
- 新增 all-build.sh 和 all-upload-release.sh 脚本,支持从 version.json 自动读取版本号
- 更新 Makefile 和 README.md 以反映脚本名称的更改和新功能
2025-12-07 18:05:27 +08:00
ac3c7e2b4c chore: 更新 .gitignore,忽略编译产物和日志文件 2025-12-07 16:37:24 +08:00
d8ea772c24 feat: 添加日志文件输出功能和心跳故障排查工具
- 新增日志文件输出功能,支持配置日志文件路径和级别
- 添加心跳故障排查脚本 check-heartbeat.sh
- 支持通过环境变量 LOG_FILE 设置日志文件路径
- 日志自动创建目录,支持相对路径和绝对路径
- 优化日志初始化逻辑,支持直接写入文件
- 改进配置加载,支持日志配置项
- 完善文档,添加故障排查章节和日志功能说明
- 更新版本号至 v1.1.0
2025-12-07 16:37:03 +08:00
74c1db2f14 卸载脚本 2025-12-03 22:08:08 +08:00
0bed6eba94 重启也本地依赖编译 2025-12-03 21:57:18 +08:00
238589b82e 1 2025-12-03 21:40:23 +08:00
904bc54248 带v不带v 2025-12-03 21:35:38 +08:00
e5fa9429ae 提交 2025-12-03 21:31:30 +08:00
3996c4fc2f go安装逻辑修复 2025-12-03 20:08:54 +08:00
22 changed files with 3768 additions and 179 deletions

6
.gitignore vendored
View File

@@ -1 +1,7 @@
.DS_Store
bin/
agent
node.log
node.pid
config.yaml
.DS_Store

View File

@@ -169,6 +169,43 @@ EOF
**注意:** 使用 `run.sh` 启动的好处是每次启动会自动拉取最新代码并重新编译。
### 3.1. 配置说明
**配置优先级(从高到低):**
1. 环境变量 `BACKEND_URL`(最高优先级)
2. 配置文件 `config.yaml` 中的 `backend.url`
3. 默认值
**重要说明:**
- 环境变量 `BACKEND_URL` 会**覆盖**配置文件中的设置
- 即使配置文件存在,设置环境变量后也会优先使用环境变量的值
- 这确保了编译后的二进制文件不会硬编码后端地址
- 配置文件不会被编译进二进制文件,是运行时读取的
**使用环境变量(推荐):**
```bash
# 在 systemd 服务文件中设置
Environment="BACKEND_URL=http://your-backend-server:8080"
# 或在命令行中设置
BACKEND_URL=http://your-backend-server:8080 ./run.sh start
```
**使用配置文件:**
创建 `/opt/linkmaster-node/config.yaml`
```yaml
server:
port: 2200
backend:
url: http://your-backend-server:8080
heartbeat:
interval: 60
log:
file: node.log
level: info
debug: false
```
### 4. 启动服务
```bash
@@ -177,7 +214,11 @@ sudo systemctl enable linkmaster-node
sudo systemctl start linkmaster-node
```
**注意** 确保 `BACKEND_URL` 环境变量指向后端服务器的实际地址和端口(默认 8080不是前端地址。
**重要说明**
- 确保 `BACKEND_URL` 环境变量指向后端服务器的实际地址和端口(默认 8080不是前端地址
- `BACKEND_URL` 环境变量会**覆盖**配置文件中的 `backend.url` 设置(优先级最高)
- 即使配置文件存在,设置环境变量后也会优先使用环境变量的值
- 这确保了编译后的二进制文件不会硬编码后端地址
## 防火墙配置
@@ -238,12 +279,19 @@ sudo lsof -i :2200
**解决:**
- 检查后端地址是否正确(应该是 `http://backend-server:8080`,不是前端地址)
- 检查环境变量 `BACKEND_URL` 是否设置正确(优先级最高)
- 检查配置文件 `config.yaml` 中的 `backend.url` 是否正确
- 检查网络连通性:`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`
- 如果使用前端代理,节点端仍需要直接连接后端,不能使用前端地址
**配置优先级说明:**
- 环境变量 `BACKEND_URL` 优先级最高,会覆盖配置文件中的设置
- 如果同时设置了环境变量和配置文件,优先使用环境变量的值
- 这确保了编译后的二进制文件不会硬编码后端地址
## 卸载
```bash

View File

@@ -1,4 +1,4 @@
.PHONY: build build-linux clean
.PHONY: build build-linux build-all clean
build:
go build -o bin/linkmaster-node ./cmd/agent
@@ -6,6 +6,9 @@ build:
build-linux:
GOOS=linux GOARCH=amd64 go build -o bin/linkmaster-node-linux ./cmd/agent
build-all:
@./all-build.sh
clean:
rm -rf bin/

530
README.md
View File

@@ -13,6 +13,8 @@ LinkMaster 节点服务,用于执行网络测试任务。
- FindPing IP段批量ping检测
- 持续 Ping/TCPing 测试
- 心跳上报
- 日志文件输出(支持配置日志文件路径和级别)
- 心跳故障排查工具
## 安装
@@ -83,10 +85,24 @@ BACKEND_URL=http://your-backend-server:8080 ./run.sh start
## 配置
### 配置优先级
配置按以下优先级加载(高优先级会覆盖低优先级):
1. **环境变量**(最高优先级)
2. **配置文件** `config.yaml`
3. **默认值**
### 环境变量
- `BACKEND_URL`: 后端服务地址(必需,默认: http://localhost:8080
- `BACKEND_URL`: 后端服务地址(**优先级最高**,会覆盖配置文件中的设置
- `CONFIG_PATH`: 配置文件路径(可选,默认: config.yaml
- `LOG_FILE`: 日志文件路径(可选,默认: node.log
**重要说明:**
- `BACKEND_URL` 环境变量会**覆盖**配置文件中的 `backend.url` 设置
- 即使配置文件存在,设置环境变量后也会优先使用环境变量的值
- 这确保了编译后的二进制文件不会硬编码后端地址
### 配置文件(可选)
@@ -96,12 +112,29 @@ BACKEND_URL=http://your-backend-server:8080 ./run.sh start
server:
port: 2200
backend:
url: http://your-backend-server:8080
url: http://your-backend-server:8080 # 会被 BACKEND_URL 环境变量覆盖
heartbeat:
interval: 60
log:
file: node.log # 日志文件路径(默认: node.log空则输出到标准错误
level: info # 日志级别: debug, info, warn, error默认: info
debug: false
node:
id: 0 # 节点ID通过心跳自动获取
ip: "" # 节点IP通过心跳自动获取
country: "" # 国家(通过心跳自动获取)
province: "" # 省份(通过心跳自动获取)
city: "" # 城市(通过心跳自动获取)
isp: "" # ISP通过心跳自动获取
```
**配置说明:**
- `backend.url`: 后端服务地址,会被 `BACKEND_URL` 环境变量覆盖
- `log.file`: 日志文件路径。如果为空日志将输出到标准错误stderr
- `log.level`: 日志级别,支持 `debug``info``warn``error`
- `node.*`: 节点信息通过心跳自动获取并保存,无需手动配置
- 配置文件不会被编译进二进制文件,是运行时读取的
## 运行脚本
使用 `run.sh` 脚本管理节点端。**每次启动时会自动拉取最新代码并重新编译**
@@ -136,6 +169,320 @@ BACKEND_URL=http://192.168.1.100:8080 ./run.sh start
- 如果 Git 拉取失败(如网络问题),会使用当前代码继续编译
- 如果编译失败,服务不会启动
## 脚本工具
项目提供了多个脚本工具,方便安装、卸载、运行、编译和发布。
### 1. install.sh - 一键安装脚本
自动安装 LinkMaster 节点端到系统包括依赖安装、源码编译、systemd 服务配置等。
**使用方法:**
```bash
# 通过 curl 下载并安装(推荐)
curl -fsSL https://gitee.nas.cpolar.cn/yoyo/linkmaster-node/raw/branch/main/install.sh | bash -s -- http://your-backend-server:8080
# 本地运行安装脚本
./install.sh http://your-backend-server:8080
# 指定分支安装
GITHUB_BRANCH=develop curl -fsSL https://gitee.nas.cpolar.cn/yoyo/linkmaster-node/raw/branch/main/install.sh | bash -s -- http://your-backend-server:8080
```
**功能特性:**
- 自动检测系统类型和架构Linux/macOS, amd64/arm64
- 自动检测并配置最快的镜像源Ubuntu/Debian/CentOS
- 自动安装系统依赖curl, wget, git, ping, traceroute 等)
- 自动安装 Go 环境(优先使用系统包管理器,失败则从官网下载)
- 优先从 Releases 下载预编译二进制文件,失败则从源码编译
- 自动创建 systemd 服务并配置自启动
- 自动配置防火墙规则(开放 2200 端口)
- 自动登记节点到后端服务器
- 自动启动服务并验证安装
**安装位置:**
- 二进制文件:`/usr/local/bin/linkmaster-node`
- 源码目录:`/opt/linkmaster-node`
- 服务文件:`/etc/systemd/system/linkmaster-node.service`
- 配置文件:`/opt/linkmaster-node/config.yaml`
### 2. uninstall.sh - 一键卸载脚本
完全卸载 LinkMaster 节点端,包括停止服务、删除文件、清理配置等。
**使用方法:**
```bash
# 通过 curl 下载并运行
curl -fsSL https://gitee.nas.cpolar.cn/yoyo/linkmaster-node/raw/branch/main/uninstall.sh | bash
# 本地运行卸载脚本
./uninstall.sh
# 卸载并删除防火墙规则
./uninstall.sh --remove-firewall
```
**功能特性:**
- 停止并禁用 systemd 服务
- 删除 systemd 服务文件和配置目录
- 删除二进制文件(`/usr/local/bin/linkmaster-node`
- 删除源码目录(`/opt/linkmaster-node`
- 清理所有残留进程
- 重新加载 systemd daemon
- 可选:删除防火墙规则(默认保留)
**注意事项:**
- 卸载前会询问确认(交互式环境)
- 默认保留防火墙规则,避免影响其他服务
- 使用 `--remove-firewall` 参数可删除防火墙规则
### 3. run.sh - 运行管理脚本
用于管理节点端的启动、停止、重启、状态查看和日志查看。**每次启动时会自动拉取最新代码并重新编译**。
**使用方法:**
```bash
# 启动服务(会自动拉取最新代码并编译)
./run.sh start
# 停止服务
./run.sh stop
# 重启服务(会拉取最新代码并重新编译)
./run.sh restart
# 查看运行状态
./run.sh status
# 实时查看日志
./run.sh logs
# 查看完整日志
./run.sh logs-all
# 显示帮助信息
./run.sh help
# 指定后端地址启动
BACKEND_URL=http://192.168.1.100:8080 ./run.sh start
```
**功能特性:**
- 启动时自动执行 `git pull` 拉取最新代码
- 自动执行 `go mod download` 更新依赖
- 自动编译生成新的二进制文件
- 自动检测端口占用并提示处理
- 后台运行并保存 PID 文件
- 健康检查验证服务状态
- 支持通过环境变量 `BACKEND_URL` 指定后端地址
**环境变量:**
- `BACKEND_URL`: 后端服务地址(默认: `http://localhost:8080`
### 4. start-systemd.sh - systemd 启动脚本
用于 systemd 服务启动,直接运行二进制文件。如果二进制文件不存在,会自动拉取代码并编译。
**使用场景:**
- 由 systemd 服务自动调用
- 不需要手动运行
**功能特性:**
- 检查二进制文件是否存在且有效
- 如果二进制文件不存在,自动拉取代码并编译
- 使用 vendor 目录编译(无需网络连接)
- 直接运行二进制文件systemd 管理进程)
**注意事项:**
- 此脚本由 systemd 服务调用,通常不需要手动运行
- 需要确保源码目录存在且是 Git 仓库
- 需要 Go 环境已安装并在 PATH 中
### 5. all-build.sh - 跨平台编译脚本
编译多个操作系统和架构的二进制文件,支持并行编译。**版本号自动从 `version.json` 读取**。
**使用方法:**
```bash
# 编译所有平台(自动使用 version.json 中的版本号)
./all-build.sh
# 只编译指定平台
./all-build.sh -p linux/amd64
# 编译前清理输出目录
./all-build.sh -c
# 设置并行编译数量
./all-build.sh -j 2
# 覆盖版本号(覆盖 version.json 中的版本)
./all-build.sh -v 1.0.0
# 只生成不带版本号的文件
./all-build.sh -s
# 列出所有支持的平台
./all-build.sh -l
# 显示帮助信息
./all-build.sh -h
```
**支持的平台:**
- `linux/amd64` - Linux x86_64
- `linux/arm64` - Linux ARM64
- `darwin/amd64` - macOS Intel
- `darwin/arm64` - macOS Apple Silicon
- `windows/amd64` - Windows x86_64
- `windows/arm64` - Windows ARM64
**功能特性:**
-**自动从 `version.json` 读取版本号**(无需手动指定)
- ✅ 支持并行编译(默认 4 个任务)
- ✅ 自动生成带版本号和不带版本号的文件
- ✅ 输出到 `bin/` 目录
- ✅ 显示编译进度和结果
- ✅ 支持清理输出目录
**输出文件:**
- `bin/agent-{os}-{arch}` - 不带版本号的二进制文件
- `bin/agent-{os}-{arch}-{version}` - 带版本号的二进制文件
- Windows 平台会自动添加 `.exe` 扩展名
**版本管理:**
版本号统一从 `version.json` 文件读取:
```json
{
"version": "1.1.3",
"tag": "v1.1.3"
}
```
### 6. all-upload-release.sh - 发布上传脚本
将编译好的二进制文件上传到 Releases 或通过其他方式发布。**版本号和标签自动从 `version.json` 读取Token 已硬编码**。
**使用方法:**
```bash
# 上传到 Gitea Releases自动从 version.json 和 .git/config 读取信息)
./all-upload-release.sh -m gitea
# 上传到 Gitea Releases覆盖版本号和标签
./all-upload-release.sh -m gitea -t v1.2.0 -v 1.2.0
# 上传到 GitHub Releases
./all-upload-release.sh -m github -r owner/repo -t v1.0.0 -v 1.0.0
# 通过 SCP 上传
./all-upload-release.sh -m scp -H example.com -u user -d /path/to/release
# 通过 SCP 上传(指定私钥)
./all-upload-release.sh -m scp -H example.com -u user -d /path/to/release -k ~/.ssh/id_rsa
# 通过 FTP 上传
./all-upload-release.sh -m ftp -H ftp.example.com -u user -d /path/to/release
# 复制到本地目录
./all-upload-release.sh -m local -d /path/to/release
# 只打包不上传
./all-upload-release.sh --pack-only
# 不上传压缩包,直接上传二进制文件
./all-upload-release.sh -m scp --no-pack -H example.com -u user -d /path/to/release
# 显示帮助信息
./all-upload-release.sh -h
```
**支持的上传方式:**
- `gitea` - Gitea Releases自动从 .git/config 读取仓库信息)
- `github` - GitHub Releases需要 GitHub CLI `gh`
- `scp` - 通过 SCP 上传到远程服务器
- `ftp` - 通过 FTP 上传
- `local` - 复制到本地目录
**功能特性:**
-**自动从 `version.json` 读取版本号和标签**(无需手动指定)
-**Token 已硬编码**(无需手动指定)
- ✅ 自动打包二进制文件tar.gz 或 zip
- ✅ 自动创建发布说明
- ✅ 支持指定平台上传
- ✅ 支持自定义版本号和标签(覆盖配置文件)
- ✅ 支持自定义发布说明
- ✅ 自动检测并处理已存在的 Release
**参数说明:**
- `-m, --method`: 上传方式gitea|github|scp|ftp|local默认: gitea
- `-v, --version`: 版本号(默认: 从 version.json 读取)
- `-t, --tag`: Git 标签(默认: 从 version.json 读取)
- `-p, --platform`: 只上传指定平台
- `-T, --token`: 访问令牌(已硬编码,此选项已废弃)
- `-H, --host`: 主机地址SCP/FTP
- `-u, --user`: 用户名SCP/FTP
- `-d, --dest`: 目标路径SCP/FTP/local
- `-k, --key`: 私钥路径SCP
- `--pack-only`: 只打包不上传
- `--no-pack`: 不上传压缩包,直接上传二进制文件
**版本管理:**
版本号和标签统一从 `version.json` 文件读取:
```json
{
"version": "1.1.3",
"tag": "v1.1.3"
}
```
**典型工作流程:**
```bash
# 1. 编译所有平台(自动使用 version.json 中的版本号)
./all-build.sh
# 2. 上传到 Gitea Releases自动使用 version.json 中的版本号和标签)
./all-upload-release.sh -m gitea
```
### 7. vendor.sh - Vendor 依赖打包脚本
将项目依赖下载到 vendor 目录,客户端克隆后可直接编译,无需网络连接。
**使用方法:**
```bash
# 运行脚本(会自动下载依赖并创建 vendor 目录)
./vendor.sh
```
**功能特性:**
- 检查 Go 环境
- 配置 Go 代理(使用官方源)
- 下载所有依赖包
- 创建 vendor 目录
- 更新 .gitignore允许 vendor 目录被提交)
- 自动添加到 Git 暂存区
**使用场景:**
- 项目需要离线编译能力
- 需要确保依赖版本一致性
- 客户端环境网络受限
**注意事项:**
- 需要 Go 环境已安装
- vendor 目录会比较大,需要提交到 Git
- 编译时使用 `-mod=vendor` 标志
**编译命令(使用 vendor**
```bash
go build -mod=vendor -o agent ./cmd/agent
```
## API
### POST /api/test
@@ -180,5 +527,180 @@ BACKEND_URL=http://192.168.1.100:8080 ./run.sh start
### GET /api/health
健康检查
# linkmaster-node
# linkmaster-node
## 故障排查
### 心跳同步问题排查
如果节点无法同步心跳,可以使用排查脚本进行诊断:
```bash
# 运行心跳故障排查脚本
./check-heartbeat.sh
```
排查脚本会自动检查以下项目:
1. **进程状态** - 检查节点进程是否正在运行
2. **配置文件** - 检查配置文件是否存在和正确
3. **网络连接** - 检查能否连接到后端服务器
4. **日志分析** - 分析日志中的心跳相关错误
5. **手动测试** - 手动发送心跳测试连接
6. **系统资源** - 检查磁盘空间和内存使用情况
**常见问题及解决方案:**
1. **进程未运行**
```bash
./run.sh start
```
2. **网络连接失败**
- 检查后端服务是否正常运行
- 检查防火墙规则(确保可以访问后端端口)
- 检查 BACKEND_URL 配置是否正确
3. **心跳发送失败**
- 查看日志: `./run.sh logs`
- 检查后端服务日志
- 确认后端 `/api/node/heartbeat` 接口正常
4. **配置文件问题**
- 检查 `config.yaml` 文件格式是否正确
- 确认 `BACKEND_URL` 环境变量或配置文件中的 URL 正确
5. **查看详细日志**
```bash
# 实时查看日志
./run.sh logs
# 查看完整日志
./run.sh logs-all
```
### 日志功能
节点端支持将日志直接写入文件,便于排查问题和监控运行状态。
**日志配置方式:**
1. **环境变量**(推荐)
```bash
LOG_FILE=/var/log/linkmaster-node.log ./run.sh start
```
2. **配置文件**
在 `config.yaml` 中配置:
```yaml
log:
file: node.log # 日志文件路径
level: info # 日志级别: debug, info, warn, error
```
3. **默认行为**
- 默认日志文件:`node.log`(当前目录)
- 默认日志级别:`info`
- 如果未设置日志文件日志输出到标准错误stderr
**日志特性:**
- ✅ 自动创建日志文件和目录
- ✅ 追加模式,不会覆盖已有日志
- ✅ JSON 格式,便于日志分析
- ✅ 包含调用信息(文件名和行号)
- ✅ Error 级别日志包含堆栈信息
**查看日志:**
```bash
# 实时查看日志
tail -f node.log
# 查看心跳相关日志
grep -i "心跳" node.log
# 查看错误日志
grep -i "error" node.log
# 查看最后100行
tail -n 100 node.log
```
## 心跳机制
节点会定期向后端发送心跳,上报节点状态和获取节点信息。
### 心跳请求字段
心跳请求包含以下字段:
- `type`: 固定值 `pingServer`
- `version`: 协议版本号,固定值 `2`
- `host_name`: 节点主机名(自动读取系统主机名)
### 心跳响应
心跳响应包含以下节点信息:
- `node_id`: 节点ID
- `node_ip`: 节点外网IP
- `country`: 国家
- `province`: 省份
- `city`: 城市
- `isp`: ISP
这些信息会自动保存到配置文件中,用于后续的数据推送。
## 持续测试功能
节点支持持续 Ping 和 TCPing 测试,测试结果会自动推送到后端服务器。
### 功能特性
- ✅ 实时推送测试结果到后端
- ✅ 批量推送优化减少HTTP请求频率
- ✅ 自动清理超时任务
- ✅ 资源自动清理(防止内存泄漏)
- ✅ 详细的调试日志debug模式
### 数据推送
- 测试结果会自动推送到后端 `/api/public/node/continuous/result` 接口
- 推送包含节点ID、IP、位置信息和测试结果
- 如果后端任务不存在,节点端会自动停止对应任务
## 更新日志
### v1.1.4 (最新)
**新增功能:**
- ✨ 心跳请求新增 `version` 字段协议版本号默认值2
- ✨ 心跳请求新增 `host_name` 字段(自动读取系统主机名)
- ✨ 支持环境变量 `BACKEND_URL` 覆盖配置文件中的后端地址
- ✨ 持续测试功能增强,支持批量推送和自动清理
**改进:**
- 🔧 修复持续测试数据推送的锁管理问题
- 🔧 修复任务停止时未清理推送缓冲的内存泄漏问题
- 🔧 优化配置加载逻辑,环境变量优先级最高
- 🔧 增强日志记录,添加详细的调试信息
- 📝 完善文档,添加配置优先级和心跳机制说明
### v1.1.3
**新增功能:**
- ✨ 添加日志文件输出功能,支持配置日志文件路径和级别
- ✨ 添加心跳故障排查工具 `check-heartbeat.sh`
- ✨ 支持通过环境变量 `LOG_FILE` 设置日志文件路径
- ✨ 日志自动创建目录,支持相对路径和绝对路径
**改进:**
- 🔧 优化日志初始化逻辑,支持直接写入文件
- 🔧 改进配置加载,支持日志配置项
- 📝 完善文档,添加故障排查章节
### v1.0.0
- 🎉 初始版本发布
- ✅ 支持 HTTP GET/POST 测试
- ✅ 支持 Ping、DNS、Traceroute 等网络测试
- ✅ 支持持续 Ping/TCPing 测试
- ✅ 支持心跳上报

308
all-build.sh Executable file
View File

@@ -0,0 +1,308 @@
#!/bin/bash
# 跨平台编译脚本
# 支持编译多个操作系统和架构的版本
set -e
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# 项目信息
PROJECT_NAME="agent"
BUILD_DIR="bin"
MAIN_PACKAGE="./cmd/agent"
# 版本配置文件路径
VERSION_FILE="version.json"
# 从版本配置文件读取版本信息
read_version_config() {
local version_file="${VERSION_FILE}"
if [ ! -f "$version_file" ]; then
return 1
fi
# 检查是否有 jq 命令
if command -v jq &> /dev/null; then
local version=$(jq -r '.version' "$version_file" 2>/dev/null)
if [ -n "$version" ] && [ "$version" != "null" ]; then
echo "$version"
return 0
fi
else
# 如果没有 jq使用 grep 和 sed 解析 JSON
local version=$(grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' "$version_file" 2>/dev/null | sed 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')
if [ -n "$version" ]; then
echo "$version"
return 0
fi
fi
return 1
}
# 初始化版本号(从配置文件读取,如果失败则使用时间戳)
VERSION_CONFIG=$(read_version_config)
if [ $? -eq 0 ] && [ -n "$VERSION_CONFIG" ]; then
VERSION="${VERSION:-$VERSION_CONFIG}"
else
VERSION="${VERSION:-$(date +%Y%m%d-%H%M%S)}"
fi
# 支持的平台列表
# 格式: OS/ARCH
PLATFORMS=(
"linux/amd64"
"linux/arm64"
"darwin/amd64"
"darwin/arm64"
"windows/amd64"
"windows/arm64"
)
# 显示使用说明
usage() {
echo -e "${BLUE}使用方法:${NC}"
echo " $0 [选项]"
echo ""
echo -e "${BLUE}选项:${NC}"
echo " -h, --help 显示帮助信息"
echo " -p, --platform PLATFORM 只编译指定平台 (例如: linux/amd64)"
echo " -l, --list 列出所有支持的平台"
echo " -c, --clean 编译前清理输出目录"
echo " -j, --jobs N 并行编译数量 (默认: 4)"
echo " -v, --version VERSION 设置版本号 (默认: 从 version.json 读取)"
echo " -s, --simple-only 只生成不带版本号的文件(默认生成两个)"
echo ""
echo -e "${BLUE}示例:${NC}"
echo " $0 # 编译所有平台"
echo " $0 -p linux/amd64 # 只编译 Linux AMD64"
echo " $0 -j 2 # 使用2个并行任务"
echo " $0 -c # 清理后编译"
}
# 列出所有平台
list_platforms() {
echo -e "${BLUE}支持的平台:${NC}"
for platform in "${PLATFORMS[@]}"; do
echo " - $platform"
done
}
# 清理输出目录
clean_build() {
if [ -d "$BUILD_DIR" ]; then
echo -e "${YELLOW}清理输出目录...${NC}"
rm -rf "$BUILD_DIR"
fi
mkdir -p "$BUILD_DIR"
}
# 编译单个平台
build_platform() {
local os_arch=$1
local simple_only=$2 # 是否只生成不带版本号的文件
local os=$(echo $os_arch | cut -d'/' -f1)
local arch=$(echo $os_arch | cut -d'/' -f2)
local output_path
if [ "$simple_only" = "true" ]; then
# 只生成不带版本号的文件
output_path="${BUILD_DIR}/${PROJECT_NAME}-${os}-${arch}"
if [ "$os" = "windows" ]; then
output_path="${output_path}.exe"
fi
else
# 生成带版本号的文件
output_path="${BUILD_DIR}/${PROJECT_NAME}-${os}-${arch}-${VERSION}"
if [ "$os" = "windows" ]; then
output_path="${output_path}.exe"
fi
fi
echo -e "${BLUE}[编译]${NC} ${os}/${arch} -> ${output_path}"
if GOOS=$os GOARCH=$arch go build -ldflags "-s -w -X main.version=${VERSION}" \
-o "$output_path" "$MAIN_PACKAGE" 2>&1; then
echo -e "${GREEN}[成功]${NC} ${os}/${arch}"
# 如果不是只生成简单版本,则创建不带版本号的副本(方便使用)
if [ "$simple_only" != "true" ]; then
local simple_name="${BUILD_DIR}/${PROJECT_NAME}-${os}-${arch}"
if [ "$os" = "windows" ]; then
simple_name="${simple_name}.exe"
fi
cp "$output_path" "$simple_name" 2>/dev/null || true
fi
return 0
else
echo -e "${RED}[失败]${NC} ${os}/${arch}"
return 1
fi
}
# 主函数
main() {
local selected_platforms=()
local clean=false
local jobs=4
local list_only=false
local simple_only=false
# 解析参数
while [[ $# -gt 0 ]]; do
case $1 in
-h|--help)
usage
exit 0
;;
-p|--platform)
selected_platforms+=("$2")
shift 2
;;
-l|--list)
list_only=true
shift
;;
-c|--clean)
clean=true
shift
;;
-j|--jobs)
jobs="$2"
shift 2
;;
-v|--version)
VERSION="$2"
shift 2
;;
-s|--simple-only)
simple_only=true
shift
;;
*)
echo -e "${RED}未知参数: $1${NC}"
usage
exit 1
;;
esac
done
# 如果只是列出平台,则退出
if [ "$list_only" = true ]; then
list_platforms
exit 0
fi
# 确定要编译的平台
if [ ${#selected_platforms[@]} -eq 0 ]; then
selected_platforms=("${PLATFORMS[@]}")
else
# 验证平台是否支持
for platform in "${selected_platforms[@]}"; do
local found=false
for p in "${PLATFORMS[@]}"; do
if [ "$p" = "$platform" ]; then
found=true
break
fi
done
if [ "$found" = false ]; then
echo -e "${RED}错误: 不支持的平台 '$platform'${NC}"
echo ""
list_platforms
exit 1
fi
done
fi
# 清理(如果需要)
if [ "$clean" = true ]; then
clean_build
else
mkdir -p "$BUILD_DIR"
fi
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}开始跨平台编译${NC}"
echo -e "${GREEN}========================================${NC}"
echo -e "项目名称: ${BLUE}${PROJECT_NAME}${NC}"
echo -e "版本号: ${BLUE}${VERSION}${NC}"
echo -e "输出目录: ${BLUE}${BUILD_DIR}${NC}"
echo -e "并行任务数: ${BLUE}${jobs}${NC}"
echo -e "平台数量: ${BLUE}${#selected_platforms[@]}${NC}"
echo ""
# 导出函数和变量供后台任务使用
export -f build_platform
export PROJECT_NAME BUILD_DIR VERSION MAIN_PACKAGE RED GREEN YELLOW BLUE NC
# 使用后台任务进行并行编译
local success_count=0
local fail_count=0
local temp_file=$(mktemp)
# 导出变量供后台任务使用
export simple_only
# 启动编译任务
for platform in "${selected_platforms[@]}"; do
(
if build_platform "$platform" "$simple_only"; then
echo "SUCCESS $platform" >> "$temp_file"
else
echo "FAIL $platform" >> "$temp_file"
fi
) &
# 控制并行数量
while [ $(jobs -r | wc -l | tr -d ' ') -ge "$jobs" ]; do
sleep 0.1
done
done
# 等待所有后台任务完成
wait
# 统计结果
if [ -f "$temp_file" ]; then
while IFS= read -r line; do
if [[ $line == SUCCESS* ]]; then
((success_count++))
elif [[ $line == FAIL* ]]; then
((fail_count++))
fi
done < "$temp_file"
rm -f "$temp_file"
fi
echo ""
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}编译完成${NC}"
echo -e "${GREEN}========================================${NC}"
echo -e "成功: ${GREEN}${success_count}${NC}"
echo -e "失败: ${RED}${fail_count}${NC}"
echo ""
if [ $fail_count -eq 0 ]; then
echo -e "${GREEN}所有平台编译成功!${NC}"
echo ""
echo -e "${BLUE}编译输出文件:${NC}"
ls -lh "$BUILD_DIR" | grep "$PROJECT_NAME" | awk '{print " " $9 " (" $5 ")"}'
else
echo -e "${RED}部分平台编译失败,请检查错误信息${NC}"
exit 1
fi
}
# 运行主函数
main "$@"

949
all-upload-release.sh Executable file
View File

@@ -0,0 +1,949 @@
#!/bin/bash
# 发布上传脚本
# 支持多种上传方式GitHub Releases、Gitea Releases、SCP、FTP、本地复制
set -e
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# 项目信息
PROJECT_NAME="agent"
BUILD_DIR="bin"
RELEASE_DIR="release"
TEMP_DIR=$(mktemp -d)
# Gitea Token (硬编码)
GITEA_TOKEN="3becb08eee31b422481ce1b8986de1cd645b468e"
# 版本配置文件路径
VERSION_FILE="version.json"
# 从版本配置文件读取版本信息
read_version_config() {
local version_file="${VERSION_FILE}"
if [ ! -f "$version_file" ]; then
return 1
fi
# 检查是否有 jq 命令
if command -v jq &> /dev/null; then
local version=$(jq -r '.version' "$version_file" 2>/dev/null)
local tag=$(jq -r '.tag' "$version_file" 2>/dev/null)
if [ -n "$version" ] && [ "$version" != "null" ]; then
echo "$version|$tag"
return 0
fi
else
# 如果没有 jq使用 grep 和 sed 解析 JSON
local version=$(grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' "$version_file" 2>/dev/null | sed 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')
local tag=$(grep -o '"tag"[[:space:]]*:[[:space:]]*"[^"]*"' "$version_file" 2>/dev/null | sed 's/.*"tag"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')
if [ -n "$version" ]; then
echo "$version|$tag"
return 0
fi
fi
return 1
}
# 初始化版本号(从配置文件读取,如果失败则使用时间戳)
VERSION_CONFIG=$(read_version_config)
if [ $? -eq 0 ] && [ -n "$VERSION_CONFIG" ]; then
IFS='|' read -r config_version config_tag <<< "$VERSION_CONFIG"
VERSION="${VERSION:-$config_version}"
DEFAULT_TAG="${config_tag}"
else
VERSION="${VERSION:-$(date +%Y%m%d-%H%M%S)}"
DEFAULT_TAG=""
fi
# 支持的平台列表
PLATFORMS=(
"linux/amd64"
"linux/arm64"
"darwin/amd64"
"darwin/arm64"
"windows/amd64"
"windows/arm64"
)
# 清理函数
cleanup() {
if [ -d "$TEMP_DIR" ]; then
rm -rf "$TEMP_DIR"
fi
}
trap cleanup EXIT
# 显示使用说明
usage() {
echo -e "${BLUE}使用方法:${NC}"
echo " $0 [选项]"
echo ""
echo -e "${BLUE}选项:${NC}"
echo " -h, --help 显示帮助信息"
echo " -m, --method METHOD 上传方式: github|gitea|scp|ftp|local (默认: gitea)"
echo " -v, --version VERSION 版本号 (默认: 时间戳)"
echo " -p, --platform PLATFORM 只上传指定平台 (例如: linux/amd64)"
echo " -t, --tag TAG Git标签 (GitHub/Gitea Releases需要)"
echo " -r, --repo REPO 仓库 (格式: owner/repo默认从.git/config读取)"
echo " -b, --base-url URL Gitea基础URL (默认从.git/config读取)"
echo " -T, --token TOKEN 访问令牌 (已硬编码,此选项已废弃)"
echo " -d, --dest DEST 目标路径 (SCP/FTP/local需要)"
echo " -H, --host HOST 主机地址 (SCP/FTP需要)"
echo " -u, --user USER 用户名 (SCP/FTP需要)"
echo " -P, --port PORT 端口号 (SCP/FTP需要默认: SCP=22, FTP=21)"
echo " -k, --key KEY 私钥路径 (SCP需要)"
echo " --pack-only 只打包不上传"
echo " --no-pack 不上传压缩包,直接上传二进制文件"
echo " --notes NOTES 发布说明 (GitHub/Gitea Releases)"
echo " --notes-file FILE 从文件读取发布说明"
echo ""
echo -e "${BLUE}上传方式说明:${NC}"
echo ""
echo -e "${CYAN}Gitea Releases (自动从.git/config和version.json读取):${NC}"
echo " $0 -m gitea"
echo " $0 -m gitea -t v1.0.0 -v 1.0.0"
echo ""
echo -e "${CYAN}GitHub Releases:${NC}"
echo " $0 -m github -r owner/repo -t v1.0.0 -v 1.0.0"
echo ""
echo -e "${CYAN}SCP上传:${NC}"
echo " $0 -m scp -H example.com -u user -d /path/to/release"
echo " $0 -m scp -H example.com -u user -d /path/to/release -k ~/.ssh/id_rsa"
echo ""
echo -e "${CYAN}FTP上传:${NC}"
echo " $0 -m ftp -H ftp.example.com -u user -d /path/to/release"
echo ""
echo -e "${CYAN}本地复制:${NC}"
echo " $0 -m local -d /path/to/release"
echo ""
echo -e "${CYAN}只打包:${NC}"
echo " $0 --pack-only -v 1.0.0"
}
# 从 .git/config 读取 Git 信息
read_git_info() {
local git_config=".git/config"
if [ ! -f "$git_config" ]; then
return 1
fi
# 读取 origin URL
local url=$(git config --get remote.origin.url 2>/dev/null || echo "")
if [ -z "$url" ]; then
return 1
fi
# 解析 URL
# 支持格式:
# - https://user:pass@host/owner/repo.git
# - https://host/owner/repo.git
# - git@host:owner/repo.git
# - ssh://user@host/owner/repo.git
local base_url=""
local owner=""
local repo_name=""
local token=""
# 提取 token (如果有)
if [[ "$url" =~ https://([^:]+):([^@]+)@(.+) ]]; then
token="${BASH_REMATCH[2]}"
url="https://${BASH_REMATCH[1]}@${BASH_REMATCH[3]}"
fi
# 提取 base_url, owner, repo
if [[ "$url" =~ https://([^/]+)/([^/]+)/([^/]+)\.git ]]; then
base_url="https://${BASH_REMATCH[1]}"
owner="${BASH_REMATCH[2]}"
repo_name="${BASH_REMATCH[3]}"
elif [[ "$url" =~ git@([^:]+):([^/]+)/([^/]+)\.git ]]; then
base_url="https://${BASH_REMATCH[1]}"
owner="${BASH_REMATCH[2]}"
repo_name="${BASH_REMATCH[3]}"
elif [[ "$url" =~ ssh://[^@]+@([^/]+)/([^/]+)/([^/]+)\.git ]]; then
base_url="https://${BASH_REMATCH[1]}"
owner="${BASH_REMATCH[2]}"
repo_name="${BASH_REMATCH[3]}"
fi
if [ -n "$base_url" ] && [ -n "$owner" ] && [ -n "$repo_name" ]; then
echo "$base_url|$owner|$repo_name|$token"
return 0
fi
return 1
}
# 检查必要的工具
check_dependencies() {
local method=$1
local missing_tools=()
case $method in
github)
if ! command -v gh &> /dev/null; then
missing_tools+=("gh (GitHub CLI)")
fi
;;
gitea)
if ! command -v curl &> /dev/null; then
missing_tools+=("curl")
fi
if ! command -v jq &> /dev/null; then
echo -e "${YELLOW}警告: jq 未安装,某些功能可能受限${NC}"
fi
;;
scp)
if ! command -v scp &> /dev/null; then
missing_tools+=("scp")
fi
;;
ftp)
if ! command -v curl &> /dev/null && ! command -v ftp &> /dev/null; then
missing_tools+=("curl 或 ftp")
fi
;;
esac
if [ ${#missing_tools[@]} -gt 0 ]; then
echo -e "${RED}错误: 缺少必要的工具:${NC}"
for tool in "${missing_tools[@]}"; do
echo " - $tool"
done
exit 1
fi
}
# 检查构建文件是否存在
check_build_files() {
if [ ! -d "$BUILD_DIR" ] || [ -z "$(ls -A $BUILD_DIR 2>/dev/null)" ]; then
echo -e "${RED}错误: 构建目录为空或不存在${NC}"
echo "请先运行 ./all-build.sh 编译项目"
exit 1
fi
local found=0
for platform in "${PLATFORMS[@]}"; do
local os=$(echo $platform | cut -d'/' -f1)
local arch=$(echo $platform | cut -d'/' -f2)
local file="${BUILD_DIR}/${PROJECT_NAME}-${os}-${arch}"
if [ "$os" = "windows" ]; then
file="${file}.exe"
fi
if [ -f "$file" ]; then
found=1
break
fi
done
if [ $found -eq 0 ]; then
echo -e "${RED}错误: 未找到任何构建文件${NC}"
echo "请先运行 ./all-build.sh 编译项目"
exit 1
fi
}
# 打包文件
pack_files() {
local platform=$1
local os=$(echo $platform | cut -d'/' -f1)
local arch=$(echo $platform | cut -d'/' -f2)
local binary="${BUILD_DIR}/${PROJECT_NAME}-${os}-${arch}"
if [ "$os" = "windows" ]; then
binary="${binary}.exe"
fi
if [ ! -f "$binary" ]; then
echo -e "${YELLOW}[跳过]${NC} ${platform} - 文件不存在"
return 1
fi
local pack_name="${PROJECT_NAME}-${os}-${arch}-${VERSION}"
local pack_file
if [ "$os" = "windows" ]; then
pack_file="${TEMP_DIR}/${pack_name}.zip"
echo -e "${BLUE}[打包]${NC} ${platform} -> ${pack_name}.zip"
(cd "$BUILD_DIR" && zip -q "${pack_file}" "$(basename $binary)")
else
pack_file="${TEMP_DIR}/${pack_name}.tar.gz"
echo -e "${BLUE}[打包]${NC} ${platform} -> ${pack_name}.tar.gz"
tar -czf "$pack_file" -C "$BUILD_DIR" "$(basename $binary)"
fi
echo "$pack_file"
return 0
}
# 创建发布说明
create_release_notes() {
local notes_file="${TEMP_DIR}/release_notes.md"
cat > "$notes_file" <<EOF
# ${PROJECT_NAME} ${VERSION}
## 版本信息
- **版本号**: ${VERSION}
- **发布日期**: $(date '+%Y-%m-%d %H:%M:%S')
- **Git提交**: $(git rev-parse --short HEAD 2>/dev/null || echo "N/A")
- **Git分支**: $(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "N/A")
## 支持的平台
EOF
for platform in "${PLATFORMS[@]}"; do
local os=$(echo $platform | cut -d'/' -f1)
local arch=$(echo $platform | cut -d'/' -f2)
local binary="${BUILD_DIR}/${PROJECT_NAME}-${os}-${arch}"
if [ "$os" = "windows" ]; then
binary="${binary}.exe"
fi
if [ -f "$binary" ]; then
local size=$(ls -lh "$binary" | awk '{print $5}')
echo "- ${os}/${arch} (${size})" >> "$notes_file"
fi
done
echo "" >> "$notes_file"
echo "## 安装说明" >> "$notes_file"
echo "" >> "$notes_file"
echo "### Linux/macOS" >> "$notes_file"
echo "\`\`\`bash" >> "$notes_file"
echo "tar -xzf ${PROJECT_NAME}-linux-amd64-${VERSION}.tar.gz" >> "$notes_file"
echo "chmod +x ${PROJECT_NAME}-linux-amd64" >> "$notes_file"
echo "./${PROJECT_NAME}-linux-amd64" >> "$notes_file"
echo "\`\`\`" >> "$notes_file"
echo "" >> "$notes_file"
echo "### Windows" >> "$notes_file"
echo "\`\`\`powershell" >> "$notes_file"
echo "Expand-Archive ${PROJECT_NAME}-windows-amd64-${VERSION}.zip" >> "$notes_file"
echo ".\\${PROJECT_NAME}-windows-amd64.exe" >> "$notes_file"
echo "\`\`\`" >> "$notes_file"
echo "$notes_file"
}
# GitHub Releases 上传
upload_github() {
local repo=$1
local tag=$2
local notes_file=$3
if [ -z "$repo" ]; then
echo -e "${RED}错误: GitHub仓库未指定使用 -r owner/repo${NC}"
exit 1
fi
if [ -z "$tag" ]; then
echo -e "${RED}错误: Git标签未指定使用 -t TAG${NC}"
exit 1
fi
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}上传到 GitHub Releases${NC}"
echo -e "${GREEN}========================================${NC}"
echo -e "仓库: ${BLUE}${repo}${NC}"
echo -e "标签: ${BLUE}${tag}${NC}"
echo -e "版本: ${BLUE}${VERSION}${NC}"
echo ""
# 检查是否已存在release
if gh release view "$tag" --repo "$repo" &>/dev/null; then
echo -e "${YELLOW}警告: Release ${tag} 已存在${NC}"
read -p "是否删除并重新创建? (y/N): " confirm
if [[ "$confirm" =~ ^[Yy]$ ]]; then
gh release delete "$tag" --repo "$repo" --yes
else
echo "取消上传"
exit 0
fi
fi
# 创建release
echo -e "${BLUE}[创建Release]${NC} ${tag}"
if [ -f "$notes_file" ]; then
gh release create "$tag" \
--repo "$repo" \
--title "${PROJECT_NAME} ${VERSION}" \
--notes-file "$notes_file" \
--draft=false \
--prerelease=false
else
gh release create "$tag" \
--repo "$repo" \
--title "${PROJECT_NAME} ${VERSION}" \
--notes "Release ${VERSION}" \
--draft=false \
--prerelease=false
fi
# 上传文件
local upload_count=0
shopt -s nullglob
for file in "${TEMP_DIR}"/*.tar.gz "${TEMP_DIR}"/*.zip; do
if [ -f "$file" ]; then
echo -e "${BLUE}[上传]${NC} $(basename $file)"
gh release upload "$tag" "$file" --repo "$repo" --clobber
((upload_count++))
fi
done
shopt -u nullglob
if [ $upload_count -eq 0 ] && [ "$NO_PACK" != "true" ]; then
echo -e "${YELLOW}警告: 没有找到要上传的文件${NC}"
else
echo -e "${GREEN}[完成]${NC} 已上传 ${upload_count} 个文件"
echo -e "${CYAN}Release链接:${NC} https://github.com/${repo}/releases/tag/${tag}"
fi
}
# Gitea Releases 上传
upload_gitea() {
local base_url=$1
local owner=$2
local repo=$3
local tag=$4
local token=$5
local notes_file=$6
if [ -z "$base_url" ] || [ -z "$owner" ] || [ -z "$repo" ]; then
echo -e "${RED}错误: Gitea仓库信息不完整${NC}"
exit 1
fi
if [ -z "$tag" ]; then
echo -e "${RED}错误: Git标签未指定使用 -t TAG${NC}"
exit 1
fi
# Token 已硬编码,确保使用硬编码的 token
if [ -z "$token" ]; then
token="${GITEA_TOKEN}"
fi
if [ -z "$token" ]; then
echo -e "${RED}错误: 访问令牌未配置${NC}"
exit 1
fi
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}上传到 Gitea Releases${NC}"
echo -e "${GREEN}========================================${NC}"
echo -e "基础URL: ${BLUE}${base_url}${NC}"
echo -e "仓库: ${BLUE}${owner}/${repo}${NC}"
echo -e "标签: ${BLUE}${tag}${NC}"
echo -e "版本: ${BLUE}${VERSION}${NC}"
echo ""
local api_base="${base_url}/api/v1"
local repo_api="${api_base}/repos/${owner}/${repo}"
# 读取发布说明
local notes="Release ${VERSION}"
if [ -f "$notes_file" ]; then
notes=$(cat "$notes_file")
fi
# 转义 JSON 字符串(不使用 jq
local notes_escaped=$(echo "$notes" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g' | sed ':a;N;$!ba;s/\n/\\n/g')
# 检查是否已存在release
local existing_release=$(curl -s -X GET \
"${repo_api}/releases/tags/${tag}" \
-H "Authorization: token ${token}" \
-H "Content-Type: application/json" 2>/dev/null)
if echo "$existing_release" | grep -q '"id"'; then
echo -e "${YELLOW}警告: Release ${tag} 已存在${NC}"
read -p "是否删除并重新创建? (y/N): " confirm
if [[ "$confirm" =~ ^[Yy]$ ]]; then
local release_id=$(echo "$existing_release" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
if [ -n "$release_id" ]; then
curl -s -X DELETE \
"${repo_api}/releases/${release_id}" \
-H "Authorization: token ${token}" > /dev/null
echo -e "${BLUE}[删除]${NC} 已删除现有 Release"
fi
else
echo "取消上传"
exit 0
fi
fi
# 创建release
echo -e "${BLUE}[创建Release]${NC} ${tag}"
local release_data=$(cat <<EOF
{
"tag_name": "${tag}",
"target_commitish": "main",
"name": "${PROJECT_NAME} ${VERSION}",
"body": "${notes_escaped}",
"draft": false,
"prerelease": false
}
EOF
)
local create_response=$(curl -s -X POST \
"${repo_api}/releases" \
-H "Authorization: token ${token}" \
-H "Content-Type: application/json" \
-d "$release_data")
if echo "$create_response" | grep -q '"id"'; then
local release_id=$(echo "$create_response" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
echo -e "${GREEN}[成功]${NC} Release 已创建 (ID: ${release_id})"
else
echo -e "${RED}[失败]${NC} 创建 Release 失败"
echo "$create_response" | head -20
exit 1
fi
# 上传文件
local upload_count=0
shopt -s nullglob
for file in "${TEMP_DIR}"/*.tar.gz "${TEMP_DIR}"/*.zip; do
if [ -f "$file" ]; then
echo -e "${BLUE}[上传]${NC} $(basename $file)"
local upload_response=$(curl -s -X POST \
"${repo_api}/releases/${release_id}/assets?name=$(basename $file)" \
-H "Authorization: token ${token}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${file}")
if echo "$upload_response" | grep -q '"id"'; then
echo -e " ${GREEN}${NC} 上传成功"
((upload_count++))
else
echo -e " ${RED}${NC} 上传失败"
echo "$upload_response" | head -5
fi
fi
done
shopt -u nullglob
if [ $upload_count -eq 0 ] && [ "$NO_PACK" != "true" ]; then
echo -e "${YELLOW}警告: 没有找到要上传的文件${NC}"
else
echo -e "${GREEN}[完成]${NC} 已上传 ${upload_count} 个文件"
echo -e "${CYAN}Release链接:${NC} ${base_url}/${owner}/${repo}/releases/tag/${tag}"
fi
}
# SCP上传
upload_scp() {
local host=$1
local user=$2
local dest=$3
local port=${4:-22}
local key=$5
if [ -z "$host" ] || [ -z "$user" ] || [ -z "$dest" ]; then
echo -e "${RED}错误: SCP需要指定主机(-H)、用户(-u)和目标路径(-d)${NC}"
exit 1
fi
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}上传到 SCP${NC}"
echo -e "${GREEN}========================================${NC}"
echo -e "主机: ${BLUE}${user}@${host}:${port}${NC}"
echo -e "目标: ${BLUE}${dest}${NC}"
echo ""
local scp_opts="-P $port"
if [ -n "$key" ]; then
scp_opts="$scp_opts -i $key"
fi
# 创建远程目录
local ssh_cmd="ssh"
if [ -n "$key" ]; then
ssh_cmd="$ssh_cmd -i $key"
fi
ssh -p "$port" $([ -n "$key" ] && echo "-i $key") "$user@$host" "mkdir -p $dest" || true
# 上传文件
local upload_count=0
if [ "$NO_PACK" = "true" ]; then
# 直接上传二进制文件
for platform in "${SELECTED_PLATFORMS[@]}"; do
local os=$(echo $platform | cut -d'/' -f1)
local arch=$(echo $platform | cut -d'/' -f2)
local binary="${BUILD_DIR}/${PROJECT_NAME}-${os}-${arch}"
if [ "$os" = "windows" ]; then
binary="${binary}.exe"
fi
if [ -f "$binary" ]; then
echo -e "${BLUE}[上传]${NC} $(basename $binary)"
scp $scp_opts "$binary" "$user@$host:$dest/"
((upload_count++))
fi
done
else
# 上传压缩包
shopt -s nullglob
for file in "${TEMP_DIR}"/*.tar.gz "${TEMP_DIR}"/*.zip; do
if [ -f "$file" ]; then
echo -e "${BLUE}[上传]${NC} $(basename $file)"
scp $scp_opts "$file" "$user@$host:$dest/"
((upload_count++))
fi
done
shopt -u nullglob
fi
echo -e "${GREEN}[完成]${NC} 已上传 ${upload_count} 个文件"
}
# FTP上传
upload_ftp() {
local host=$1
local user=$2
local dest=$3
local port=${4:-21}
if [ -z "$host" ] || [ -z "$user" ] || [ -z "$dest" ]; then
echo -e "${RED}错误: FTP需要指定主机(-H)、用户(-u)和目标路径(-d)${NC}"
exit 1
fi
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}上传到 FTP${NC}"
echo -e "${GREEN}========================================${NC}"
echo -e "主机: ${BLUE}${host}:${port}${NC}"
echo -e "用户: ${BLUE}${user}${NC}"
echo -e "目标: ${BLUE}${dest}${NC}"
echo ""
read -sp "请输入FTP密码: " ftp_pass
echo ""
local upload_count=0
if [ "$NO_PACK" = "true" ]; then
# 直接上传二进制文件
for platform in "${SELECTED_PLATFORMS[@]}"; do
local os=$(echo $platform | cut -d'/' -f1)
local arch=$(echo $platform | cut -d'/' -f2)
local binary="${BUILD_DIR}/${PROJECT_NAME}-${os}-${arch}"
if [ "$os" = "windows" ]; then
binary="${binary}.exe"
fi
if [ -f "$binary" ]; then
echo -e "${BLUE}[上传]${NC} $(basename $binary)"
curl -T "$binary" \
"ftp://${host}:${port}${dest}/$(basename $binary)" \
--user "${user}:${ftp_pass}" \
--ftp-create-dirs
((upload_count++))
fi
done
else
# 上传压缩包
shopt -s nullglob
for file in "${TEMP_DIR}"/*.tar.gz "${TEMP_DIR}"/*.zip; do
if [ -f "$file" ]; then
echo -e "${BLUE}[上传]${NC} $(basename $file)"
curl -T "$file" \
"ftp://${host}:${port}${dest}/$(basename $file)" \
--user "${user}:${ftp_pass}" \
--ftp-create-dirs
((upload_count++))
fi
done
shopt -u nullglob
fi
echo -e "${GREEN}[完成]${NC} 已上传 ${upload_count} 个文件"
}
# 本地复制
upload_local() {
local dest=$1
if [ -z "$dest" ]; then
echo -e "${RED}错误: 本地复制需要指定目标路径(-d)${NC}"
exit 1
fi
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}复制到本地目录${NC}"
echo -e "${GREEN}========================================${NC}"
echo -e "目标: ${BLUE}${dest}${NC}"
echo ""
mkdir -p "$dest"
local copy_count=0
if [ "$NO_PACK" = "true" ]; then
# 直接复制二进制文件
for platform in "${SELECTED_PLATFORMS[@]}"; do
local os=$(echo $platform | cut -d'/' -f1)
local arch=$(echo $platform | cut -d'/' -f2)
local binary="${BUILD_DIR}/${PROJECT_NAME}-${os}-${arch}"
if [ "$os" = "windows" ]; then
binary="${binary}.exe"
fi
if [ -f "$binary" ]; then
echo -e "${BLUE}[复制]${NC} $(basename $binary)"
cp "$binary" "$dest/"
((copy_count++))
fi
done
else
# 复制压缩包
shopt -s nullglob
for file in "${TEMP_DIR}"/*.tar.gz "${TEMP_DIR}"/*.zip; do
if [ -f "$file" ]; then
echo -e "${BLUE}[复制]${NC} $(basename $file)"
cp "$file" "$dest/"
((copy_count++))
fi
done
shopt -u nullglob
fi
echo -e "${GREEN}[完成]${NC} 已复制 ${copy_count} 个文件"
}
# 主函数
main() {
local method="gitea"
local selected_platforms=()
local tag="${DEFAULT_TAG}"
local repo=""
local base_url=""
local token="${GITEA_TOKEN}" # 使用硬编码的 token
local dest=""
local host=""
local user=""
local port=""
local key=""
local pack_only=false
local notes=""
local notes_file=""
# 解析参数
while [[ $# -gt 0 ]]; do
case $1 in
-h|--help)
usage
exit 0
;;
-m|--method)
method="$2"
shift 2
;;
-v|--version)
VERSION="$2"
shift 2
;;
-p|--platform)
selected_platforms+=("$2")
shift 2
;;
-t|--tag)
tag="$2"
shift 2
;;
-r|--repo)
repo="$2"
shift 2
;;
-b|--base-url)
base_url="$2"
shift 2
;;
-T|--token)
echo -e "${YELLOW}警告: Token 已硬编码,-T 参数将被忽略${NC}"
shift 2
;;
-d|--dest)
dest="$2"
shift 2
;;
-H|--host)
host="$2"
shift 2
;;
-u|--user)
user="$2"
shift 2
;;
-P|--port)
port="$2"
shift 2
;;
-k|--key)
key="$2"
shift 2
;;
--pack-only)
pack_only=true
shift
;;
--no-pack)
NO_PACK=true
shift
;;
--notes)
notes="$2"
shift 2
;;
--notes-file)
notes_file="$2"
shift 2
;;
*)
echo -e "${RED}未知参数: $1${NC}"
usage
exit 1
;;
esac
done
# 确定要处理的平台
if [ ${#selected_platforms[@]} -eq 0 ]; then
SELECTED_PLATFORMS=("${PLATFORMS[@]}")
else
SELECTED_PLATFORMS=("${selected_platforms[@]}")
fi
# 检查构建文件
check_build_files
# 检查依赖
if [ "$pack_only" != "true" ]; then
check_dependencies "$method"
fi
# 打包文件
if [ "$NO_PACK" != "true" ]; then
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}开始打包${NC}"
echo -e "${GREEN}========================================${NC}"
echo -e "版本: ${BLUE}${VERSION}${NC}"
echo -e "平台数量: ${BLUE}${#SELECTED_PLATFORMS[@]}${NC}"
echo ""
for platform in "${SELECTED_PLATFORMS[@]}"; do
pack_files "$platform" > /dev/null
done
echo ""
echo -e "${GREEN}打包完成${NC}"
echo -e "${BLUE}打包文件:${NC}"
shopt -s nullglob
ls -lh "${TEMP_DIR}"/*.tar.gz "${TEMP_DIR}"/*.zip 2>/dev/null | awk '{print " " $9 " (" $5 ")"}'
shopt -u nullglob
echo ""
fi
# 如果只是打包则复制到release目录
if [ "$pack_only" = "true" ]; then
mkdir -p "$RELEASE_DIR"
shopt -s nullglob
cp "${TEMP_DIR}"/*.tar.gz "${TEMP_DIR}"/*.zip "$RELEASE_DIR/" 2>/dev/null || true
shopt -u nullglob
echo -e "${GREEN}文件已复制到 ${RELEASE_DIR} 目录${NC}"
exit 0
fi
# 创建发布说明
local release_notes=""
if [ -n "$notes_file" ] && [ -f "$notes_file" ]; then
release_notes="$notes_file"
elif [ -n "$notes" ]; then
echo "$notes" > "${TEMP_DIR}/release_notes.md"
release_notes="${TEMP_DIR}/release_notes.md"
elif [ "$method" = "github" ] || [ "$method" = "gitea" ]; then
release_notes=$(create_release_notes)
fi
# 根据方法上传
case $method in
gitea)
# 从 .git/config 读取信息(如果未指定)
local git_info=""
if [ -z "$base_url" ] || [ -z "$repo" ]; then
git_info=$(read_git_info)
if [ $? -eq 0 ] && [ -n "$git_info" ]; then
IFS='|' read -r git_base_url git_owner git_repo_name git_token <<< "$git_info"
if [ -z "$base_url" ]; then
base_url="$git_base_url"
fi
if [ -z "$repo" ]; then
repo="${git_owner}/${git_repo_name}"
fi
# Token 已硬编码,不从 git config 读取
# if [ -z "$token" ] && [ -n "$git_token" ]; then
# token="$git_token"
# fi
echo -e "${CYAN}[信息]${NC} 从 .git/config 读取仓库信息: ${repo}"
fi
fi
if [ -z "$base_url" ] || [ -z "$repo" ]; then
echo -e "${RED}错误: Gitea仓库信息不完整${NC}"
echo "请指定 -b BASE_URL 和 -r owner/repo或确保 .git/config 中有正确的远程仓库配置"
exit 1
fi
IFS='/' read -r owner repo_name <<< "$repo"
if [ -z "$owner" ] || [ -z "$repo_name" ]; then
echo -e "${RED}错误: 仓库格式不正确,应为 owner/repo${NC}"
exit 1
fi
upload_gitea "$base_url" "$owner" "$repo_name" "$tag" "$token" "$release_notes"
;;
github)
upload_github "$repo" "$tag" "$release_notes"
;;
scp)
upload_scp "$host" "$user" "$dest" "$port" "$key"
;;
ftp)
upload_ftp "$host" "$user" "$dest" "$port"
;;
local)
upload_local "$dest"
;;
*)
echo -e "${RED}错误: 不支持的上传方式: ${method}${NC}"
echo "支持的方式: gitea, github, scp, ftp, local"
exit 1
;;
esac
echo ""
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}发布完成${NC}"
echo -e "${GREEN}========================================${NC}"
}
# 运行主函数
main "$@"

512
check-heartbeat.sh Executable file
View File

@@ -0,0 +1,512 @@
#!/bin/bash
# ============================================
# LinkMaster 节点心跳故障排查脚本
# 用途:诊断节点心跳同步问题
# ============================================
set -e
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
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"
CONFIG_FILE="${CONFIG_PATH:-config.yaml}"
# 检查结果
ISSUES=0
WARNINGS=0
# 打印分隔线
print_separator() {
echo -e "${CYAN}========================================${NC}"
}
# 打印检查项标题
print_check_title() {
echo -e "\n${BLUE}$1${NC}"
}
# 打印成功信息
print_success() {
echo -e "${GREEN}$1${NC}"
}
# 打印警告信息
print_warning() {
echo -e "${YELLOW}$1${NC}"
((WARNINGS++))
}
# 打印错误信息
print_error() {
echo -e "${RED}$1${NC}"
((ISSUES++))
}
# 打印信息
print_info() {
echo -e "${CYAN} $1${NC}"
}
# 获取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
}
# 1. 检查进程状态
check_process() {
print_check_title "检查进程状态"
PID=$(get_pid)
if [ -z "$PID" ]; then
print_error "节点进程未运行"
print_info "请使用 ./run.sh start 启动服务"
return 1
else
print_success "节点进程正在运行 (PID: $PID)"
# 检查进程运行时间
if command -v ps > /dev/null 2>&1; then
RUNTIME=$(ps -o etime= -p "$PID" 2>/dev/null | tr -d ' ')
if [ -n "$RUNTIME" ]; then
print_info "进程运行时间: $RUNTIME"
fi
fi
# 检查进程资源使用
if command -v ps > /dev/null 2>&1; then
CPU_MEM=$(ps -o %cpu,%mem= -p "$PID" 2>/dev/null | tr -d ' ')
if [ -n "$CPU_MEM" ]; then
print_info "CPU/内存使用: $CPU_MEM"
fi
fi
return 0
fi
}
# 2. 检查配置文件
check_config() {
print_check_title "检查配置文件"
if [ ! -f "$CONFIG_FILE" ]; then
print_warning "配置文件不存在: $CONFIG_FILE"
print_info "将使用环境变量和默认配置"
# 检查环境变量
if [ -n "$BACKEND_URL" ]; then
print_info "使用环境变量 BACKEND_URL: $BACKEND_URL"
else
print_warning "未设置 BACKEND_URL 环境变量,将使用默认值: http://localhost:8080"
fi
return 0
fi
print_success "配置文件存在: $CONFIG_FILE"
# 检查配置文件内容
if command -v yq > /dev/null 2>&1; then
BACKEND_URL_FROM_CONFIG=$(yq eval '.backend.url' "$CONFIG_FILE" 2>/dev/null || echo "")
HEARTBEAT_INTERVAL=$(yq eval '.heartbeat.interval' "$CONFIG_FILE" 2>/dev/null || echo "")
NODE_ID=$(yq eval '.node.id' "$CONFIG_FILE" 2>/dev/null || echo "")
NODE_IP=$(yq eval '.node.ip' "$CONFIG_FILE" 2>/dev/null || echo "")
else
# 使用 grep 和 sed 简单解析
BACKEND_URL_FROM_CONFIG=$(grep -E "^\s*url:" "$CONFIG_FILE" | head -1 | sed 's/.*url:\s*//' | tr -d '"' | tr -d "'" || echo "")
HEARTBEAT_INTERVAL=$(grep -E "^\s*interval:" "$CONFIG_FILE" | head -1 | sed 's/.*interval:\s*//' | tr -d '"' | tr -d "'" || echo "")
NODE_ID=$(grep -E "^\s*id:" "$CONFIG_FILE" | head -1 | sed 's/.*id:\s*//' | tr -d '"' | tr -d "'" || echo "")
NODE_IP=$(grep -E "^\s*ip:" "$CONFIG_FILE" | head -1 | sed 's/.*ip:\s*//' | tr -d '"' | tr -d "'" || echo "")
fi
# 确定使用的后端URL
if [ -n "$BACKEND_URL" ]; then
FINAL_BACKEND_URL="$BACKEND_URL"
print_info "使用环境变量 BACKEND_URL: $FINAL_BACKEND_URL"
elif [ -n "$BACKEND_URL_FROM_CONFIG" ]; then
FINAL_BACKEND_URL="$BACKEND_URL_FROM_CONFIG"
print_info "使用配置文件中的后端URL: $FINAL_BACKEND_URL"
else
FINAL_BACKEND_URL="http://localhost:8080"
print_warning "未找到后端URL配置使用默认值: $FINAL_BACKEND_URL"
fi
if [ -n "$HEARTBEAT_INTERVAL" ]; then
print_info "心跳间隔: ${HEARTBEAT_INTERVAL}"
else
print_info "心跳间隔: 60秒 (默认值)"
fi
if [ -n "$NODE_ID" ] && [ "$NODE_ID" != "0" ] && [ "$NODE_ID" != "null" ]; then
print_success "节点ID已配置: $NODE_ID"
else
print_warning "节点ID未配置或为0将在首次心跳时获取"
fi
if [ -n "$NODE_IP" ] && [ "$NODE_IP" != "null" ]; then
print_success "节点IP已配置: $NODE_IP"
else
print_warning "节点IP未配置将在首次心跳时获取"
fi
export FINAL_BACKEND_URL
}
# 3. 检查网络连接
check_network() {
print_check_title "检查网络连接"
if [ -z "$FINAL_BACKEND_URL" ]; then
print_error "无法确定后端URL跳过网络检查"
return 1
fi
# 提取主机和端口
BACKEND_HOST=$(echo "$FINAL_BACKEND_URL" | sed -E 's|https?://||' | cut -d'/' -f1 | cut -d':' -f1)
BACKEND_PORT=$(echo "$FINAL_BACKEND_URL" | sed -E 's|https?://||' | cut -d'/' -f1 | cut -d':' -f2)
if [ -z "$BACKEND_PORT" ]; then
if echo "$FINAL_BACKEND_URL" | grep -q "https://"; then
BACKEND_PORT=443
else
BACKEND_PORT=80
fi
fi
print_info "后端地址: $BACKEND_HOST:$BACKEND_PORT"
# 检查DNS解析
if command -v nslookup > /dev/null 2>&1 || command -v host > /dev/null 2>&1; then
if command -v nslookup > /dev/null 2>&1; then
if nslookup "$BACKEND_HOST" > /dev/null 2>&1; then
print_success "DNS解析成功: $BACKEND_HOST"
else
print_error "DNS解析失败: $BACKEND_HOST"
return 1
fi
elif command -v host > /dev/null 2>&1; then
if host "$BACKEND_HOST" > /dev/null 2>&1; then
print_success "DNS解析成功: $BACKEND_HOST"
else
print_error "DNS解析失败: $BACKEND_HOST"
return 1
fi
fi
fi
# 检查端口连通性
if command -v nc > /dev/null 2>&1; then
if nc -z -w 3 "$BACKEND_HOST" "$BACKEND_PORT" 2>/dev/null; then
print_success "端口连通性检查通过: $BACKEND_HOST:$BACKEND_PORT"
else
print_error "端口无法连接: $BACKEND_HOST:$BACKEND_PORT"
print_info "可能原因: 防火墙阻止、后端服务未启动、网络不通"
return 1
fi
elif command -v timeout > /dev/null 2>&1 && command -v bash > /dev/null 2>&1; then
# 使用 bash 内置的 TCP 连接测试
if timeout 3 bash -c "echo > /dev/tcp/$BACKEND_HOST/$BACKEND_PORT" 2>/dev/null; then
print_success "端口连通性检查通过: $BACKEND_HOST:$BACKEND_PORT"
else
print_error "端口无法连接: $BACKEND_HOST:$BACKEND_PORT"
print_info "可能原因: 防火墙阻止、后端服务未启动、网络不通"
return 1
fi
else
print_warning "无法检查端口连通性(需要 nc 或 timeout 命令)"
fi
# 检查HTTP连接
HEARTBEAT_URL="${FINAL_BACKEND_URL%/}/api/node/heartbeat"
print_info "测试心跳接口: $HEARTBEAT_URL"
if command -v curl > /dev/null 2>&1; then
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 --max-time 10 \
-X POST \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "type=pingServer" \
"$HEARTBEAT_URL" 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "200" ]; then
print_success "心跳接口响应正常 (HTTP 200)"
elif [ "$HTTP_CODE" = "000" ]; then
print_error "无法连接到心跳接口"
print_info "可能原因: 网络不通、后端服务未启动、防火墙阻止"
return 1
else
print_warning "心跳接口返回异常状态码: HTTP $HTTP_CODE"
print_info "这可能是正常的,取决于后端实现"
fi
elif command -v wget > /dev/null 2>&1; then
HTTP_CODE=$(wget --spider --server-response --timeout=5 --tries=1 \
--post-data="type=pingServer" \
--header="Content-Type: application/x-www-form-urlencoded" \
"$HEARTBEAT_URL" 2>&1 | grep -E "HTTP/" | tail -1 | awk '{print $2}' || echo "000")
if [ "$HTTP_CODE" = "200" ]; then
print_success "心跳接口响应正常 (HTTP 200)"
elif [ "$HTTP_CODE" = "000" ]; then
print_error "无法连接到心跳接口"
return 1
else
print_warning "心跳接口返回异常状态码: HTTP $HTTP_CODE"
fi
else
print_warning "无法测试HTTP连接需要 curl 或 wget 命令)"
fi
return 0
}
# 4. 检查日志
check_logs() {
print_check_title "检查日志文件"
if [ ! -f "$LOG_FILE" ]; then
print_warning "日志文件不存在: $LOG_FILE"
print_info "如果服务刚启动,日志文件可能还未创建"
return 0
fi
print_success "日志文件存在: $LOG_FILE"
# 检查日志文件大小
LOG_SIZE=$(stat -f%z "$LOG_FILE" 2>/dev/null || stat -c%s "$LOG_FILE" 2>/dev/null || echo "0")
if [ "$LOG_SIZE" -gt 10485760 ]; then
print_warning "日志文件较大: $(($LOG_SIZE / 1024 / 1024))MB"
fi
# 检查最近的心跳记录
print_info "查找最近的心跳记录..."
HEARTBEAT_SUCCESS=$(grep -i "心跳发送成功\|heartbeat.*success\|心跳响应" "$LOG_FILE" 2>/dev/null | tail -5 || true)
HEARTBEAT_FAILED=$(grep -i "心跳发送失败\|heartbeat.*fail\|发送心跳失败" "$LOG_FILE" 2>/dev/null | tail -5 || true)
HEARTBEAT_ERROR=$(grep -i "error.*heartbeat\|心跳.*error" "$LOG_FILE" 2>/dev/null | tail -5 || true)
if [ -n "$HEARTBEAT_SUCCESS" ]; then
echo -e "${GREEN}最近成功的心跳记录:${NC}"
echo "$HEARTBEAT_SUCCESS" | while IFS= read -r line; do
echo " $line"
done
fi
if [ -n "$HEARTBEAT_FAILED" ]; then
echo -e "${YELLOW}最近失败的心跳记录:${NC}"
echo "$HEARTBEAT_FAILED" | while IFS= read -r line; do
echo " $line"
done
((WARNINGS++))
fi
if [ -n "$HEARTBEAT_ERROR" ]; then
echo -e "${RED}最近的心跳错误记录:${NC}"
echo "$HEARTBEAT_ERROR" | while IFS= read -r line; do
echo " $line"
done
((ISSUES++))
fi
# 检查最近的错误
RECENT_ERRORS=$(grep -i "error\|fail\|panic" "$LOG_FILE" 2>/dev/null | tail -10 || true)
if [ -n "$RECENT_ERRORS" ]; then
echo -e "${YELLOW}最近的错误记录最后10条:${NC}"
echo "$RECENT_ERRORS" | while IFS= read -r line; do
echo " $line"
done
fi
# 检查最后的心跳时间
LAST_HEARTBEAT=$(grep -i "心跳" "$LOG_FILE" 2>/dev/null | tail -1 || true)
if [ -n "$LAST_HEARTBEAT" ]; then
print_info "最后的心跳日志: $LAST_HEARTBEAT"
else
print_warning "日志中未找到心跳记录"
fi
}
# 5. 手动测试心跳
test_heartbeat() {
print_check_title "手动测试心跳发送"
if [ -z "$FINAL_BACKEND_URL" ]; then
print_error "无法确定后端URL跳过心跳测试"
return 1
fi
HEARTBEAT_URL="${FINAL_BACKEND_URL%/}/api/node/heartbeat"
print_info "发送测试心跳到: $HEARTBEAT_URL"
if command -v curl > /dev/null 2>&1; then
RESPONSE=$(curl -s -w "\n%{http_code}" --connect-timeout 10 --max-time 15 \
-X POST \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "type=pingServer" \
"$HEARTBEAT_URL" 2>&1)
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [ "$HTTP_CODE" = "200" ]; then
print_success "心跳发送成功 (HTTP 200)"
if [ -n "$BODY" ]; then
print_info "响应内容: $BODY"
# 尝试解析JSON响应
if echo "$BODY" | grep -q "node_id\|node_ip"; then
print_success "响应包含节点信息"
echo "$BODY" | grep -o '"node_id":[0-9]*\|"node_ip":"[^"]*"' 2>/dev/null || true
fi
fi
else
print_error "心跳发送失败 (HTTP $HTTP_CODE)"
if [ -n "$BODY" ]; then
print_info "响应内容: $BODY"
fi
return 1
fi
elif command -v wget > /dev/null 2>&1; then
RESPONSE=$(wget -qO- --post-data="type=pingServer" \
--header="Content-Type: application/x-www-form-urlencoded" \
--timeout=15 \
"$HEARTBEAT_URL" 2>&1)
if [ $? -eq 0 ]; then
print_success "心跳发送成功"
if [ -n "$RESPONSE" ]; then
print_info "响应内容: $RESPONSE"
fi
else
print_error "心跳发送失败"
return 1
fi
else
print_warning "无法测试心跳(需要 curl 或 wget 命令)"
return 1
fi
return 0
}
# 6. 检查系统资源
check_resources() {
print_check_title "检查系统资源"
# 检查磁盘空间
if command -v df > /dev/null 2>&1; then
DISK_USAGE=$(df -h . | tail -1 | awk '{print $5}' | sed 's/%//')
if [ "$DISK_USAGE" -gt 90 ]; then
print_error "磁盘空间不足: ${DISK_USAGE}%"
elif [ "$DISK_USAGE" -gt 80 ]; then
print_warning "磁盘空间紧张: ${DISK_USAGE}%"
else
print_success "磁盘空间充足: ${DISK_USAGE}%"
fi
fi
# 检查内存
if command -v free > /dev/null 2>&1; then
MEM_INFO=$(free -m | grep Mem)
MEM_TOTAL=$(echo "$MEM_INFO" | awk '{print $2}')
MEM_AVAIL=$(echo "$MEM_INFO" | awk '{print $7}')
if [ -z "$MEM_AVAIL" ]; then
MEM_AVAIL=$(echo "$MEM_INFO" | awk '{print $4}')
fi
if [ -n "$MEM_TOTAL" ] && [ -n "$MEM_AVAIL" ]; then
MEM_PERCENT=$((MEM_AVAIL * 100 / MEM_TOTAL))
if [ "$MEM_PERCENT" -lt 10 ]; then
print_error "可用内存不足: ${MEM_AVAIL}MB / ${MEM_TOTAL}MB (${MEM_PERCENT}%)"
elif [ "$MEM_PERCENT" -lt 20 ]; then
print_warning "可用内存紧张: ${MEM_AVAIL}MB / ${MEM_TOTAL}MB (${MEM_PERCENT}%)"
else
print_success "内存充足: ${MEM_AVAIL}MB / ${MEM_TOTAL}MB (${MEM_PERCENT}%)"
fi
fi
fi
}
# 主函数
main() {
echo -e "${CYAN}"
echo "========================================"
echo " LinkMaster 节点心跳故障排查工具"
echo "========================================"
echo -e "${NC}"
# 执行各项检查
check_process
PROCESS_OK=$?
check_config
if [ $PROCESS_OK -eq 0 ]; then
check_network
NETWORK_OK=$?
check_logs
if [ $NETWORK_OK -eq 0 ]; then
echo ""
read -p "是否执行手动心跳测试? (y/N): " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
test_heartbeat
fi
fi
fi
check_resources
# 总结
print_separator
echo -e "\n${BLUE}排查总结:${NC}"
if [ $ISSUES -eq 0 ] && [ $WARNINGS -eq 0 ]; then
echo -e "${GREEN}✓ 未发现明显问题${NC}"
echo -e "${CYAN}如果心跳仍然无法同步,请检查:${NC}"
echo " 1. 后端服务是否正常运行"
echo " 2. 后端数据库是否正常"
echo " 3. 防火墙规则是否正确配置"
echo " 4. 查看完整日志: ./run.sh logs-all"
else
if [ $ISSUES -gt 0 ]; then
echo -e "${RED}发现 $ISSUES 个严重问题${NC}"
fi
if [ $WARNINGS -gt 0 ]; then
echo -e "${YELLOW}发现 $WARNINGS 个警告${NC}"
fi
echo -e "\n${CYAN}建议操作:${NC}"
echo " 1. 根据上述检查结果修复问题"
echo " 2. 重启服务: ./run.sh restart"
echo " 3. 查看实时日志: ./run.sh logs"
echo " 4. 查看完整日志: ./run.sh logs-all"
fi
print_separator
}
# 运行主函数
main

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
@@ -14,8 +15,11 @@ import (
"linkmaster-node/internal/server"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
var version = "1.1.0" // 编译时通过 -ldflags "-X main.version=xxx" 设置
func main() {
// 加载配置
cfg, err := config.Load()
@@ -32,7 +36,7 @@ func main() {
}
defer logger.Sync()
logger.Info("节点服务启动", zap.String("version", "1.0.0"))
logger.Info("节点服务启动", zap.String("version", version))
// 初始化错误恢复
recovery.Init()
@@ -80,9 +84,69 @@ func main() {
}
func initLogger(cfg *config.Config) (*zap.Logger, error) {
// 确定日志级别
var level zapcore.Level
logLevel := cfg.Log.Level
if logLevel == "" {
if cfg.Debug {
return zap.NewDevelopment()
logLevel = "debug"
} else {
logLevel = "info"
}
}
return zap.NewProduction()
}
switch logLevel {
case "debug":
level = zapcore.DebugLevel
case "info":
level = zapcore.InfoLevel
case "warn":
level = zapcore.WarnLevel
case "error":
level = zapcore.ErrorLevel
default:
level = zapcore.InfoLevel
}
// 编码器配置
encoderConfig := zap.NewProductionEncoderConfig()
if cfg.Debug {
encoderConfig = zap.NewDevelopmentEncoderConfig()
}
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
// 确定输出目标
var writeSyncer zapcore.WriteSyncer
if cfg.Log.File != "" {
// 确保日志目录存在
logDir := filepath.Dir(cfg.Log.File)
if logDir != "." && logDir != "" {
if err := os.MkdirAll(logDir, 0755); err != nil {
return nil, fmt.Errorf("创建日志目录失败: %w", err)
}
}
// 打开日志文件(追加模式)
logFile, err := os.OpenFile(cfg.Log.File, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return nil, fmt.Errorf("打开日志文件失败: %w", err)
}
writeSyncer = zapcore.AddSync(logFile)
} else {
// 输出到标准错误(兼容原有行为)
writeSyncer = zapcore.AddSync(os.Stderr)
}
// 创建核心
core := zapcore.NewCore(
zapcore.NewJSONEncoder(encoderConfig),
writeSyncer,
level,
)
// 创建 logger
logger := zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel))
return logger, nil
}

View File

@@ -8,6 +8,30 @@
set -e
# 错误处理函数
error_handler() {
local line_number=$1
local command=$2
echo ""
echo -e "${RED}========================================${NC}"
echo -e "${RED} 脚本执行出错!${NC}"
echo -e "${RED}========================================${NC}"
echo -e "${YELLOW}错误位置: 第 ${line_number}${NC}"
echo -e "${YELLOW}失败命令: ${command}${NC}"
echo ""
echo -e "${YELLOW}故障排查建议:${NC}"
echo " 1. 检查网络连接是否正常"
echo " 2. 检查后端地址是否正确: ${BACKEND_URL:-未设置}"
echo " 3. 检查是否有足够的磁盘空间和权限"
echo " 4. 查看上面的详细错误信息"
echo ""
echo -e "${YELLOW}查看服务日志: sudo journalctl -u ${SERVICE_NAME:-linkmaster-node} -n 50${NC}"
exit 1
}
# 设置错误陷阱
trap 'error_handler ${LINENO} "${BASH_COMMAND}"' ERR
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
@@ -48,14 +72,30 @@ fi
# 检测系统类型和架构
detect_system() {
# 检测操作系统类型linux/darwin/windows
OS_TYPE=$(uname -s | tr '[:upper:]' '[:lower:]')
case $OS_TYPE in
linux)
OS_TYPE="linux"
if [ -f /etc/os-release ]; then
. /etc/os-release
OS=$ID
OS_VERSION=$VERSION_ID
else
echo -e "${RED}无法检测系统类型${NC}"
exit 1
OS="linux"
OS_VERSION=""
fi
;;
darwin)
OS_TYPE="darwin"
OS="darwin"
OS_VERSION=$(sw_vers -productVersion 2>/dev/null || echo "")
;;
*)
echo -e "${RED}不支持的操作系统: $OS_TYPE${NC}"
exit 1
;;
esac
ARCH=$(uname -m)
case $ARCH in
@@ -71,7 +111,7 @@ detect_system() {
;;
esac
echo -e "${BLUE}检测到系统: $OS $OS_VERSION ($ARCH)${NC}"
echo -e "${BLUE}检测到系统: $OS_TYPE $OS_VERSION ($ARCH)${NC}"
}
# 检测并选择最快的镜像源
@@ -80,7 +120,6 @@ detect_fastest_mirror() {
# Ubuntu/Debian 镜像源列表
UBUNTU_MIRRORS=(
"mirrors.tuna.tsinghua.edu.cn"
"mirrors.huaweicloud.com"
"mirrors.163.com"
"archive.ubuntu.com"
@@ -88,7 +127,6 @@ detect_fastest_mirror() {
# CentOS/RHEL 镜像源列表
CENTOS_MIRRORS=(
"mirrors.tuna.tsinghua.edu.cn"
"mirrors.huaweicloud.com"
)
@@ -461,10 +499,16 @@ install_go() {
# 先检查是否已安装且可用
if command -v go > /dev/null 2>&1; then
GO_VERSION=$(go version 2>/dev/null | head -1)
# 确保 PATH 包含 Go如果从官网安装的
if [ -d "/usr/local/go/bin" ] && ! echo "$PATH" | grep -q "/usr/local/go/bin"; then
export PATH=$PATH:/usr/local/go/bin
fi
# 尝试获取 Go 版本
GO_VERSION=$(go version 2>/dev/null | head -1 || echo "")
if [ -n "$GO_VERSION" ]; then
echo -e "${GREEN}✓ Go 已安装: ${GO_VERSION}${NC}"
# 检查 Go 版本是否可用(尝试运行 go version
# 再次验证 Go 是否可用
if go version > /dev/null 2>&1; then
echo -e "${BLUE}Go 环境正常,跳过安装流程${NC}"
return 0
@@ -472,6 +516,17 @@ install_go() {
echo -e "${YELLOW}⚠ Go 已安装但无法正常运行,尝试重新安装...${NC}"
fi
else
# 如果 command -v go 存在但无法获取版本,可能是 PATH 问题
# 尝试添加 /usr/local/go/bin 到 PATH
if [ -d "/usr/local/go/bin" ]; then
export PATH=/usr/local/go/bin:$PATH
GO_VERSION=$(go version 2>/dev/null | head -1 || echo "")
if [ -n "$GO_VERSION" ]; then
echo -e "${GREEN}✓ Go 已安装: ${GO_VERSION}${NC}"
echo -e "${BLUE}Go 环境正常,跳过安装流程${NC}"
return 0
fi
fi
echo -e "${YELLOW}⚠ Go 已安装但无法获取版本信息,尝试重新安装...${NC}"
fi
fi
@@ -611,6 +666,329 @@ uninstall_service() {
echo ""
}
# 尝试从 Releases 下载二进制文件
download_binary_from_releases() {
echo -e "${BLUE}尝试从 Releases 下载预编译二进制文件...${NC}"
# Gitea API 地址
local api_base="https://gitee.nas.cpolar.cn/api/v1"
local repo_api="${api_base}/repos/${GITHUB_REPO}"
# 获取所有 releases按创建时间排序最新的在前
echo -e "${BLUE}获取最新 release 信息...${NC}"
local releases_response=$(curl -s -X GET "${repo_api}/releases?limit=10" 2>/dev/null)
if [ $? -ne 0 ] || [ -z "$releases_response" ]; then
echo -e "${YELLOW}⚠ 无法获取 releases 信息,将使用源码编译${NC}"
return 1
fi
# 解析所有 releases找到最新的按创建时间或版本号
# Gitea API 返回的 releases 通常已经按创建时间倒序排列
# 但我们还是需要解析并验证
# 提取所有 tag 和对应的 release_id、创建时间
local latest_tag=""
local latest_release_id=""
local latest_created_at=""
# 使用更健壮的方式解析 JSON虽然简单但能工作
# 查找第一个有效的 release非 draft非 prerelease
local tag_line=$(echo "$releases_response" | grep -o '"tag_name":"[^"]*"' | head -1)
local id_line=$(echo "$releases_response" | grep -o '"id":[0-9]*' | head -1)
local created_line=$(echo "$releases_response" | grep -o '"created_at":"[^"]*"' | head -1)
local draft_line=$(echo "$releases_response" | grep -o '"draft":[^,}]*' | head -1)
local prerelease_line=$(echo "$releases_response" | grep -o '"prerelease":[^,}]*' | head -1)
# 检查是否是 draft 或 prerelease
if echo "$draft_line" | grep -q "true" || echo "$prerelease_line" | grep -q "true"; then
# 如果是 draft 或 prerelease尝试找下一个
echo -e "${YELLOW}⚠ 第一个 release 是草稿或预发布版本,查找正式版本...${NC}"
# 简化处理:如果第一个是预发布,仍然使用它(因为可能是最新的)
fi
latest_tag=$(echo "$tag_line" | cut -d'"' -f4)
latest_release_id=$(echo "$id_line" | cut -d':' -f2)
latest_created_at=$(echo "$created_line" | cut -d'"' -f4)
if [ -z "$latest_tag" ] || [ -z "$latest_release_id" ]; then
echo -e "${YELLOW}⚠ 无法解析 release 信息,将使用源码编译${NC}"
return 1
fi
# 显示找到的版本信息
echo -e "${GREEN}✓ 找到最新版本: ${latest_tag}${NC}"
if [ -n "$latest_created_at" ]; then
echo -e "${BLUE} 发布日期: ${latest_created_at}${NC}"
fi
# 获取 release 的详细信息(包含 commit hash
local release_detail=$(curl -s -X GET "${repo_api}/releases/${latest_release_id}" 2>/dev/null)
if [ $? -ne 0 ] || [ -z "$release_detail" ]; then
echo -e "${YELLOW}⚠ 无法获取 release 详细信息,将使用源码编译${NC}"
return 1
fi
# 解析 release 对应的 commit hashtarget_commitish
local release_commit=$(echo "$release_detail" | grep -o '"target_commitish":"[^"]*"' | head -1 | cut -d'"' -f4)
# 如果 target_commitish 是分支名(如 "main"),需要通过 API 获取该分支的 commit hash
if [ -n "$release_commit" ] && [ "${#release_commit}" -lt 40 ]; then
# 可能是分支名,尝试获取分支的最新 commit hash
local branch_info=$(curl -s -X GET "${repo_api}/git/commits/${release_commit}" 2>/dev/null)
if [ $? -eq 0 ] && [ -n "$branch_info" ]; then
local branch_commit=$(echo "$branch_info" | grep -o '"sha":"[^"]*"' | head -1 | cut -d'"' -f4)
if [ -n "$branch_commit" ] && [ "${#branch_commit}" -eq 40 ]; then
release_commit="$branch_commit"
fi
fi
fi
# 如果 release_detail 中没有 target_commitish 或获取失败,尝试通过 tag 获取 commit hash
if [ -z "$release_commit" ] || [ "${#release_commit}" -lt 40 ]; then
# 通过 tag API 获取 commit hash
local tag_info=$(curl -s -X GET "${repo_api}/git/tags/${latest_tag}" 2>/dev/null)
if [ $? -eq 0 ] && [ -n "$tag_info" ]; then
local tag_commit=$(echo "$tag_info" | grep -o '"sha":"[^"]*"' | head -1 | cut -d'"' -f4)
if [ -n "$tag_commit" ] && [ "${#tag_commit}" -eq 40 ]; then
release_commit="$tag_commit"
fi
fi
fi
# 构建文件名(根据系统类型)
# 处理 tag 可能带 v 前缀的情况(如 v1.0.0
local version_in_filename="${latest_tag}"
# 如果 tag 以 v 开头,同时尝试带 v 和不带 v 的文件名
local file_ext="tar.gz"
if [ "$OS_TYPE" = "windows" ]; then
file_ext="zip"
fi
# 先尝试使用 tag 的原始格式(可能带 v
local file_name_with_v="agent-${OS_TYPE}-${ARCH}-${latest_tag}"
local full_file_name_with_v="${file_name_with_v}.${file_ext}"
# 如果 tag 以 v 开头,也尝试不带 v 的版本
local file_name_without_v=""
local full_file_name_without_v=""
if [ "${latest_tag#v}" != "${latest_tag}" ]; then
# tag 以 v 开头,去掉 v 前缀
version_in_filename="${latest_tag#v}"
file_name_without_v="agent-${OS_TYPE}-${ARCH}-${version_in_filename}"
full_file_name_without_v="${file_name_without_v}.${file_ext}"
fi
# 查找匹配的二进制文件(优先尝试带 v 的,如果找不到再尝试不带 v 的)
local download_url=""
local full_file_name=""
# 先尝试带 v 的文件名
download_url=$(echo "$release_detail" | grep -o "\"browser_download_url\":\"[^\"]*${full_file_name_with_v}[^\"]*\"" | head -1 | cut -d'"' -f4)
if [ -n "$download_url" ]; then
full_file_name="$full_file_name_with_v"
echo -e "${BLUE}找到文件: ${full_file_name}${NC}"
elif [ -n "$full_file_name_without_v" ]; then
# 如果带 v 的找不到,尝试不带 v 的
download_url=$(echo "$release_detail" | grep -o "\"browser_download_url\":\"[^\"]*${full_file_name_without_v}[^\"]*\"" | head -1 | cut -d'"' -f4)
if [ -n "$download_url" ]; then
full_file_name="$full_file_name_without_v"
echo -e "${BLUE}找到文件: ${full_file_name} (tag: ${latest_tag})${NC}"
fi
fi
if [ -z "$download_url" ] || [ -z "$full_file_name" ]; then
echo -e "${YELLOW}⚠ 未找到匹配的二进制文件${NC}"
echo -e "${YELLOW} 尝试的文件名:${NC}"
echo -e "${YELLOW} - ${full_file_name_with_v}${NC}"
if [ -n "$full_file_name_without_v" ]; then
echo -e "${YELLOW} - ${full_file_name_without_v}${NC}"
fi
echo -e "${YELLOW} 将使用源码编译${NC}"
return 1
fi
echo -e "${BLUE}下载二进制文件: ${full_file_name}...${NC}"
# 创建临时目录
local temp_dir=$(mktemp -d)
local download_file="${temp_dir}/${full_file_name}"
# 下载文件
if ! curl -fsSL -o "$download_file" "$download_url" 2>/dev/null; then
echo -e "${YELLOW}⚠ 下载失败,将使用源码编译${NC}"
rm -rf "$temp_dir"
return 1
fi
# 检查文件大小(至少应该大于 1MB
local file_size=$(stat -f%z "$download_file" 2>/dev/null || stat -c%s "$download_file" 2>/dev/null || echo "0")
if [ "$file_size" -lt 1048576 ]; then
echo -e "${YELLOW}⚠ 下载的文件大小异常,将使用源码编译${NC}"
rm -rf "$temp_dir"
return 1
fi
# 解压文件
echo -e "${BLUE}解压二进制文件...${NC}"
cd "$temp_dir"
if [ "$file_ext" = "tar.gz" ]; then
if ! tar -xzf "$download_file" 2>/dev/null; then
echo -e "${YELLOW}⚠ 解压失败,将使用源码编译${NC}"
rm -rf "$temp_dir"
return 1
fi
# 查找解压后的二进制文件(优先查找 agent然后是 agent-*
local binary_file=""
if [ -f "./agent" ] && [ -x "./agent" ]; then
binary_file="./agent"
else
binary_file=$(find . -maxdepth 1 -type f \( -name "agent" -o -name "agent-*" -o -name "Agent" -o -name "Agent-*" \) ! -name "*.tar.gz" ! -name "*.zip" 2>/dev/null | head -1)
fi
else
# Windows zip 文件(虽然脚本主要在 Linux 上运行,但保留兼容性)
if ! unzip -q "$download_file" 2>/dev/null; then
echo -e "${YELLOW}⚠ 解压失败,将使用源码编译${NC}"
rm -rf "$temp_dir"
return 1
fi
local binary_file=$(find . -maxdepth 1 -type f \( -name "agent*.exe" -o -name "Agent*.exe" \) 2>/dev/null | head -1)
fi
if [ -z "$binary_file" ] || [ ! -f "$binary_file" ]; then
echo -e "${YELLOW}⚠ 未找到解压后的二进制文件,将使用源码编译${NC}"
echo -e "${YELLOW} 解压目录内容:${NC}"
ls -la "$temp_dir" 2>/dev/null || true
rm -rf "$temp_dir"
return 1
fi
# 验证二进制文件是否可执行
if [ ! -x "$binary_file" ]; then
chmod +x "$binary_file" 2>/dev/null || true
fi
# 验证二进制文件类型Linux 应该是 ELF 文件)
if [ "$OS_TYPE" = "linux" ]; then
if command -v file > /dev/null 2>&1; then
local file_type=$(file "$binary_file" 2>/dev/null || echo "")
if [ -n "$file_type" ] && ! echo "$file_type" | grep -qi "ELF"; then
echo -e "${YELLOW}⚠ 二进制文件类型异常: ${file_type}${NC}"
echo -e "${YELLOW} 将使用源码编译${NC}"
rm -rf "$temp_dir"
return 1
fi
fi
fi
# 保存下载的二进制文件到临时位置
local downloaded_binary="${temp_dir}/downloaded_agent"
sudo cp "$binary_file" "$downloaded_binary"
sudo chmod +x "$downloaded_binary"
# 验证复制后的文件
if [ ! -f "$downloaded_binary" ] || [ ! -x "$downloaded_binary" ]; then
echo -e "${YELLOW}⚠ 二进制文件验证失败,将使用源码编译${NC}"
rm -rf "$temp_dir"
return 1
fi
# 清理临时下载文件
rm -f "$download_file"
# 克隆仓库(用于获取 run.sh 和 start-systemd.sh 等脚本)
echo -e "${BLUE}克隆仓库以获取启动脚本...${NC}"
if [ -d "$SOURCE_DIR" ]; then
sudo rm -rf "$SOURCE_DIR"
fi
if ! sudo git clone --branch "${GITHUB_BRANCH}" "https://gitee.nas.cpolar.cn/${GITHUB_REPO}.git" "$SOURCE_DIR" 2>&1; then
echo -e "${YELLOW}⚠ 克隆仓库失败,将使用源码编译${NC}"
rm -rf "$temp_dir"
return 1
fi
# 对比 Git commit hash
if [ -n "$release_commit" ]; then
echo -e "${BLUE}验证 Git commit 版本...${NC}"
# 切换到源码目录(如果存在)
if [ -d "$SOURCE_DIR" ] && [ -d "$SOURCE_DIR/.git" ]; then
cd "$SOURCE_DIR" || {
echo -e "${YELLOW}⚠ 无法切换到源码目录,跳过验证${NC}"
cd /tmp || true
}
# 获取当前分支的最新 commit hash
local current_commit=$(git rev-parse HEAD 2>/dev/null || echo "")
if [ -n "$current_commit" ]; then
# 截取短 commit hash 用于显示前7位
local release_commit_short=""
local current_commit_short=$(echo "$current_commit" | cut -c1-7)
if [ "${#release_commit}" -eq 40 ]; then
release_commit_short=$(echo "$release_commit" | cut -c1-7)
else
release_commit_short="$release_commit"
fi
echo -e "${BLUE} Release commit: ${release_commit_short}${NC}"
echo -e "${BLUE} 当前代码 commit: ${current_commit_short}${NC}"
# 对比 commit hash只有当 release_commit 是完整的 commit hash 时才对比)
if [ "${#release_commit}" -eq 40 ] && [ "${#current_commit}" -eq 40 ]; then
if [ "$release_commit" != "$current_commit" ]; then
echo -e "${YELLOW}⚠ Commit hash 不匹配,二进制文件可能不是最新代码编译的${NC}"
echo -e "${YELLOW} Release 基于较旧的代码,将使用源码编译最新版本${NC}"
# 保留已克隆的仓库目录,供 build_from_source 复用
cd /tmp || true
rm -rf "$temp_dir"
return 1
else
echo -e "${GREEN}✓ Commit hash 匹配,二进制文件是最新代码编译的${NC}"
fi
else
echo -e "${YELLOW}⚠ 无法获取有效的 commit hash 进行对比,跳过验证${NC}"
fi
else
echo -e "${YELLOW}⚠ 无法获取当前代码的 commit hash跳过验证${NC}"
fi
# 返回临时目录
cd /tmp || true
else
echo -e "${YELLOW}⚠ 源码目录不存在,跳过验证${NC}"
fi
else
echo -e "${YELLOW}⚠ 无法获取 release 的 commit hash跳过验证${NC}"
fi
# 用下载的二进制文件覆盖克隆目录中的文件
sudo cp "$downloaded_binary" "$SOURCE_DIR/agent"
sudo chmod +x "$SOURCE_DIR/agent"
# 复制到安装目录
sudo mkdir -p "$INSTALL_DIR"
sudo cp "$SOURCE_DIR/agent" "$INSTALL_DIR/$BINARY_NAME"
sudo chmod +x "$INSTALL_DIR/$BINARY_NAME"
# 清理临时文件
rm -rf "$temp_dir"
# 显示文件信息
local binary_size=$(du -h "$SOURCE_DIR/agent" | cut -f1)
echo -e "${GREEN}✓ 二进制文件下载完成 (文件大小: ${binary_size})${NC}"
echo -e "${BLUE}版本: ${latest_tag}${NC}"
echo -e "${BLUE}二进制文件: ${SOURCE_DIR}/agent${NC}"
return 0
}
# 从源码编译安装
build_from_source() {
echo -e "${BLUE}从源码编译安装节点端...${NC}"
@@ -620,7 +998,12 @@ build_from_source() {
echo -e "${BLUE}未检测到 Go 环境,开始安装...${NC}"
install_go
else
# Go 已安装,验证是否可用
# Go 已安装,先确保 PATH 正确
if [ -d "/usr/local/go/bin" ] && ! echo "$PATH" | grep -q "/usr/local/go/bin"; then
export PATH=/usr/local/go/bin:$PATH
fi
# 验证是否可用
GO_VERSION=$(go version 2>/dev/null | head -1 || echo "")
if [ -n "$GO_VERSION" ] && go version > /dev/null 2>&1; then
echo -e "${GREEN}✓ Go 已安装: ${GO_VERSION}${NC}"
@@ -654,7 +1037,17 @@ build_from_source() {
exit 1
fi
# 如果源码目录已存在,删除(卸载函数应该已经删除,这里作为保险
# 检查源码目录是否已存在(可能由 download_binary_from_releases 已克隆
if [ -d "$SOURCE_DIR" ] && [ -d "$SOURCE_DIR/.git" ]; then
echo -e "${BLUE}检测到已存在的仓库目录,复用现有目录...${NC}"
cd "$SOURCE_DIR"
# 确保是最新代码
echo -e "${BLUE}更新代码到最新版本...${NC}"
sudo git fetch origin 2>/dev/null || true
sudo git checkout "${GITHUB_BRANCH}" 2>/dev/null || true
sudo git pull origin "${GITHUB_BRANCH}" 2>/dev/null || true
else
# 如果源码目录已存在但不是 git 仓库,删除它
if [ -d "$SOURCE_DIR" ]; then
echo -e "${YELLOW}清理旧的源码目录...${NC}"
sudo rm -rf "$SOURCE_DIR"
@@ -668,6 +1061,7 @@ build_from_source() {
show_build_alternatives
exit 1
fi
fi
# 设置目录权限(允许当前用户访问,但服务运行时是 root
sudo chown -R root:root "$SOURCE_DIR" 2>/dev/null || true
@@ -757,6 +1151,22 @@ create_service() {
sudo chmod +x "$SOURCE_DIR/run.sh"
sudo chmod +x "$SOURCE_DIR/start-systemd.sh"
# 检测 Go 的安装路径
GO_PATH=""
if [ -d "/usr/local/go/bin" ]; then
GO_PATH="/usr/local/go/bin"
elif command -v go > /dev/null 2>&1; then
GO_BIN=$(command -v go)
GO_PATH=$(dirname "$GO_BIN")
fi
# 构建 PATH 环境变量
if [ -n "$GO_PATH" ]; then
ENV_PATH="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${GO_PATH}"
else
ENV_PATH="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
fi
sudo tee /etc/systemd/system/${SERVICE_NAME}.service > /dev/null <<EOF
[Unit]
Description=LinkMaster Node Service
@@ -770,6 +1180,7 @@ ExecStart=$SOURCE_DIR/start-systemd.sh
Restart=always
RestartSec=5
Environment="BACKEND_URL=$BACKEND_URL"
Environment="$ENV_PATH"
[Install]
WantedBy=multi-user.target
@@ -875,11 +1286,18 @@ EOF
# 调用心跳API获取节点信息
echo -e "${BLUE}发送心跳请求获取节点信息...${NC}"
RESPONSE=$(curl -s -X POST "${BACKEND_URL}/api/node/heartbeat" \
echo -e "${BLUE}后端地址: ${BACKEND_URL}${NC}"
# 添加超时设置,避免长时间卡住
# 使用 set +e 临时禁用错误退出,因为心跳失败不应该阻止安装
set +e
RESPONSE=$(curl -s --connect-timeout 10 --max-time 30 -X POST "${BACKEND_URL}/api/node/heartbeat" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "type=pingServer" 2>&1)
CURL_EXIT_CODE=$?
set -e # 重新启用错误退出
if [ $? -eq 0 ]; then
if [ $CURL_EXIT_CODE -eq 0 ]; then
# 尝试解析JSON响应
NODE_ID=$(echo "$RESPONSE" | grep -o '"node_id":[0-9]*' | grep -o '[0-9]*' | head -1)
NODE_IP=$(echo "$RESPONSE" | grep -o '"node_ip":"[^"]*"' | cut -d'"' -f4 | head -1)
@@ -917,8 +1335,10 @@ EOF
echo -e "${YELLOW} 响应: ${RESPONSE}${NC}"
fi
else
echo -e "${YELLOW}⚠ 心跳请求失败,将在服务启动时重试${NC}"
echo -e "${YELLOW} 错误: ${RESPONSE}${NC}"
echo -e "${YELLOW}⚠ 心跳请求失败 (退出码: ${CURL_EXIT_CODE}),将在服务启动时重试${NC}"
echo -e "${YELLOW} 错误信息: ${RESPONSE}${NC}"
echo -e "${YELLOW} 提示: 请检查后端地址是否正确: ${BACKEND_URL}${NC}"
echo -e "${YELLOW} 测试连接: curl -v ${BACKEND_URL}/api/public/nodes/online${NC}"
fi
# 设置配置文件权限
@@ -929,18 +1349,52 @@ EOF
start_service() {
echo -e "${BLUE}启动服务...${NC}"
sudo systemctl enable ${SERVICE_NAME} > /dev/null 2>&1
sudo systemctl restart ${SERVICE_NAME}
# 先检查服务文件是否存在
if [ ! -f "/etc/systemd/system/${SERVICE_NAME}.service" ]; then
echo -e "${RED}✗ 错误: 服务文件不存在${NC}"
echo -e "${YELLOW} 路径: /etc/systemd/system/${SERVICE_NAME}.service${NC}"
exit 1
fi
# 检查二进制文件是否存在
if [ ! -f "$SOURCE_DIR/agent" ] && [ ! -f "$INSTALL_DIR/$BINARY_NAME" ]; then
echo -e "${RED}✗ 错误: 二进制文件不存在${NC}"
echo -e "${YELLOW} 检查路径: $SOURCE_DIR/agent 或 $INSTALL_DIR/$BINARY_NAME${NC}"
exit 1
fi
# 启用服务(显示输出以便调试)
echo -e "${BLUE}启用服务...${NC}"
if ! sudo systemctl enable ${SERVICE_NAME} 2>&1; then
echo -e "${RED}✗ 启用服务失败${NC}"
exit 1
fi
# 重新加载 systemd
echo -e "${BLUE}重新加载 systemd...${NC}"
sudo systemctl daemon-reload
# 启动服务(显示输出以便调试)
echo -e "${BLUE}启动服务...${NC}"
if ! sudo systemctl restart ${SERVICE_NAME} 2>&1; then
echo -e "${RED}✗ 启动服务失败${NC}"
echo -e "${YELLOW}查看详细日志: sudo journalctl -u ${SERVICE_NAME} -n 100 --no-pager${NC}"
echo -e "${YELLOW}查看服务状态: sudo systemctl status ${SERVICE_NAME}${NC}"
exit 1
fi
# 等待服务启动
echo -e "${BLUE}等待服务启动...${NC}"
sleep 3
# 检查服务状态
if sudo systemctl is-active --quiet ${SERVICE_NAME}; then
if sudo systemctl is-active --quiet ${SERVICE_NAME} 2>/dev/null; then
echo -e "${GREEN}✓ 服务启动成功${NC}"
else
echo -e "${RED}✗ 服务启动失败${NC}"
echo -e "${YELLOW}查看日志: sudo journalctl -u ${SERVICE_NAME} -n 50${NC}"
echo -e "${YELLOW}服务状态:${NC}"
sudo systemctl status ${SERVICE_NAME} --no-pager -l || true
echo -e "${YELLOW}查看详细日志: sudo journalctl -u ${SERVICE_NAME} -n 100 --no-pager${NC}"
exit 1
fi
}
@@ -998,23 +1452,49 @@ main() {
echo -e "${GREEN} LinkMaster 节点端安装程序${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
echo -e "${BLUE}后端地址: ${BACKEND_URL}${NC}"
echo ""
echo -e "${BLUE}[1/8] 检测系统类型...${NC}"
detect_system
# 检查是否已安装,如果已安装则先卸载
if check_installed; then
echo -e "${BLUE}[2/8] 卸载已存在的服务...${NC}"
uninstall_service
else
echo -e "${BLUE}[2/8] 检查已安装服务...${NC}"
echo -e "${GREEN}✓ 未检测到已安装的服务${NC}"
fi
# 检测并配置最快的镜像源(在安装依赖之前)
echo -e "${BLUE}[3/8] 检测并配置镜像源...${NC}"
detect_fastest_mirror
echo -e "${BLUE}[4/8] 安装系统依赖...${NC}"
install_dependencies
# 优先尝试从 Releases 下载二进制文件
echo -e "${BLUE}[5/8] 下载或编译二进制文件...${NC}"
if ! download_binary_from_releases; then
echo -e "${BLUE}从 Releases 下载失败,开始从源码编译...${NC}"
build_from_source
else
echo -e "${GREEN}✓ 使用预编译二进制文件,跳过编译步骤${NC}"
fi
echo -e "${BLUE}[6/8] 创建 systemd 服务...${NC}"
create_service
echo -e "${BLUE}[7/8] 配置防火墙规则...${NC}"
configure_firewall
echo -e "${BLUE}[8/8] 登记节点到后端服务器...${NC}"
register_node
echo -e "${BLUE}[9/9] 启动服务...${NC}"
start_service
echo -e "${BLUE}[10/10] 验证安装...${NC}"
verify_installation
echo ""

View File

@@ -21,6 +21,11 @@ type Config struct {
Interval int `yaml:"interval"` // 心跳间隔(秒)
} `yaml:"heartbeat"`
Log struct {
File string `yaml:"file"` // 日志文件路径(空则输出到标准错误)
Level string `yaml:"level"` // 日志级别debug, info, warn, error默认: info
} `yaml:"log"`
Debug bool `yaml:"debug"`
// 节点信息(通过心跳获取并持久化)
@@ -42,12 +47,13 @@ func Load() (*Config, error) {
cfg.Heartbeat.Interval = 60
cfg.Debug = false
// 从环境变量读取后端URL
backendURL := os.Getenv("BACKEND_URL")
if backendURL == "" {
backendURL = "http://localhost:8080"
// 默认日志配置
logFile := os.Getenv("LOG_FILE")
if logFile == "" {
logFile = "node.log"
}
cfg.Backend.URL = backendURL
cfg.Log.File = logFile
cfg.Log.Level = "info"
// 尝试从配置文件读取
configPath := os.Getenv("CONFIG_PATH")
@@ -66,6 +72,30 @@ func Load() (*Config, error) {
}
}
// 环境变量优先级最高,覆盖配置文件中的设置
// 支持 BACKEND_URL 环境变量覆盖后端地址
if backendURL := os.Getenv("BACKEND_URL"); backendURL != "" {
cfg.Backend.URL = backendURL
}
// 如果配置文件中没有设置日志文件,使用环境变量或默认值
if cfg.Log.File == "" {
logFile := os.Getenv("LOG_FILE")
if logFile == "" {
logFile = "node.log"
}
cfg.Log.File = logFile
}
// 如果配置文件中没有设置日志级别,使用默认值
if cfg.Log.Level == "" {
if cfg.Debug {
cfg.Log.Level = "debug"
} else {
cfg.Log.Level = "info"
}
}
return cfg, nil
}
@@ -102,4 +132,3 @@ func GetConfigPath() string {
}
return configPath
}

View File

@@ -28,14 +28,47 @@ type TCPingTask struct {
}
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:port如果没有端口则默认80
var host string
var portStr string
var port int
// 检查是否是IPv6格式如 [::1]:8080
if strings.HasPrefix(target, "[") {
// IPv6格式 - 使用 Index 而不是 LastIndex 来找到第一个闭合括号
closeBracket := strings.Index(target, "]")
if closeBracket == -1 {
return nil, fmt.Errorf("无效的target格式IPv6地址格式应为 [host]:port")
}
host = target[1:closeBracket]
if closeBracket+1 < len(target) && target[closeBracket+1] == ':' {
portStr = target[closeBracket+2:]
// 如果端口部分为空使用默认端口80修复 Bug 1
if portStr == "" {
portStr = "80"
}
} else {
portStr = "80" // 默认端口
}
} else {
// 普通格式 host:port 或 host
lastColonIndex := strings.LastIndex(target, ":")
if lastColonIndex == -1 {
// 没有冒号使用默认端口80
host = target
portStr = "80"
} else {
host = target[:lastColonIndex]
portStr = target[lastColonIndex+1:]
// 如果端口部分为空使用默认端口80
if portStr == "" {
portStr = "80"
}
}
}
host := parts[0]
port, err := strconv.Atoi(parts[1])
var err error
port, err = strconv.Atoi(portStr)
if err != nil {
return nil, fmt.Errorf("无效的端口: %v", err)
}
@@ -185,4 +218,3 @@ func (t *TCPingTask) executeTCPing() map[string]interface{} {
"ip": targetIP,
}
}

View File

@@ -8,6 +8,7 @@ import (
"io"
"net"
"net/http"
"os"
"strings"
"sync"
"time"
@@ -18,6 +19,7 @@ import (
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
var continuousTasks = make(map[string]*ContinuousTask)
@@ -46,7 +48,52 @@ const (
func InitContinuousHandler(cfg *config.Config) {
backendURL = cfg.Backend.URL
logger, _ = zap.NewProduction()
// 根据配置创建logger
var level zapcore.Level
logLevel := cfg.Log.Level
if logLevel == "" {
if cfg.Debug {
logLevel = "debug"
} else {
logLevel = "info"
}
}
switch logLevel {
case "debug":
level = zapcore.DebugLevel
case "info":
level = zapcore.InfoLevel
case "warn":
level = zapcore.WarnLevel
case "error":
level = zapcore.ErrorLevel
default:
level = zapcore.InfoLevel
}
// 创建编码器配置
encoderConfig := zap.NewProductionEncoderConfig()
if cfg.Debug {
encoderConfig = zap.NewDevelopmentEncoderConfig()
}
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
// 创建核心 - 输出到标准错误日志文件由main.go统一管理这里输出到stderr便于调试
core := zapcore.NewCore(
zapcore.NewJSONEncoder(encoderConfig),
zapcore.AddSync(os.Stderr),
level,
)
// 创建logger
logger = zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel))
logger.Info("持续测试处理器已初始化",
zap.String("backend_url", backendURL),
zap.String("log_level", logLevel))
}
type ContinuousTask struct {
@@ -160,7 +207,15 @@ func HandleContinuousStop(c *gin.Context) {
if task.tcpingTask != nil {
task.tcpingTask.Stop()
}
// 关闭停止通道
select {
case <-task.StopCh:
// 已经关闭
default:
close(task.StopCh)
}
delete(continuousTasks, req.TaskID)
}
taskMutex.Unlock()
@@ -170,6 +225,17 @@ func HandleContinuousStop(c *gin.Context) {
return
}
// 清理推送缓冲
bufferMutex.Lock()
if buffer, exists := pushBuffers[req.TaskID]; exists {
if buffer.pushTimer != nil {
buffer.pushTimer.Stop()
}
delete(pushBuffers, req.TaskID)
logger.Debug("已清理任务推送缓冲", zap.String("task_id", req.TaskID))
}
bufferMutex.Unlock()
c.JSON(http.StatusOK, gin.H{"message": "任务已停止"})
}
@@ -237,7 +303,8 @@ func pushResultToBackend(taskID string, result map[string]interface{}) {
logger.Warn("节点ID未获取跳过推送结果",
zap.String("task_id", taskID),
zap.String("node_ip", nodeIP),
zap.String("hint", "等待心跳返回node_id后再推送"))
zap.String("hint", "等待心跳返回node_id后再推送"),
zap.Any("result", result))
return
}
@@ -246,10 +313,18 @@ func pushResultToBackend(taskID string, result map[string]interface{}) {
logger.Warn("节点IP未获取跳过推送结果",
zap.String("task_id", taskID),
zap.Uint("node_id", nodeID),
zap.String("hint", "等待心跳返回node_ip后再推送"))
zap.String("hint", "等待心跳返回node_ip后再推送"),
zap.Any("result", result))
return
}
// 记录调试信息
logger.Debug("准备推送结果到后端",
zap.String("task_id", taskID),
zap.Uint("node_id", nodeID),
zap.String("node_ip", nodeIP),
zap.Any("result", result))
// 添加到批量推送缓冲
addToPushBuffer(taskID, nodeID, nodeIP, result)
}
@@ -269,21 +344,34 @@ func addToPushBuffer(taskID string, nodeID uint, nodeIP string, result map[strin
bufferMutex.Unlock()
buffer.mu.Lock()
defer buffer.mu.Unlock()
// 添加结果到缓冲
buffer.results = append(buffer.results, result)
// 如果缓冲已满,立即推送
shouldFlush := len(buffer.results) >= batchPushMaxSize
buffer.mu.Unlock()
if shouldFlush {
flushPushBuffer(taskID, nodeID, nodeIP)
return
// 复制结果列表
results := make([]map[string]interface{}, len(buffer.results))
copy(results, buffer.results)
buffer.results = buffer.results[:0] // 清空缓冲
// 停止定时器
if buffer.pushTimer != nil {
buffer.pushTimer.Stop()
buffer.pushTimer = nil
}
buffer.mu.Lock()
buffer.lastPush = time.Now()
buffer.mu.Unlock()
// 批量推送结果
for _, r := range results {
pushSingleResult(taskID, nodeID, nodeIP, r)
}
return
}
// 如果距离上次推送超过间隔时间,启动定时器推送
if buffer.pushTimer == nil {
@@ -291,6 +379,8 @@ func addToPushBuffer(taskID string, nodeID uint, nodeIP string, result map[strin
flushPushBuffer(taskID, nodeID, nodeIP)
})
}
buffer.mu.Unlock()
}
// flushPushBuffer 刷新并推送缓冲中的结果
@@ -362,13 +452,21 @@ func pushSingleResult(taskID string, nodeID uint, nodeIP string, result map[stri
jsonData, err := json.Marshal(data)
if err != nil {
logger.Error("序列化结果失败", zap.Error(err), zap.String("task_id", taskID))
logger.Error("序列化结果失败",
zap.Error(err),
zap.String("task_id", taskID),
zap.Uint("node_id", nodeID),
zap.String("node_ip", nodeIP),
zap.Any("data", data))
return
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
logger.Error("创建请求失败", zap.Error(err), zap.String("task_id", taskID))
logger.Error("创建请求失败",
zap.Error(err),
zap.String("task_id", taskID),
zap.String("url", url))
return
}
@@ -380,7 +478,9 @@ func pushSingleResult(taskID string, nodeID uint, nodeIP string, result map[stri
logger.Warn("推送结果失败,继续运行",
zap.Error(err),
zap.String("task_id", taskID),
zap.String("url", url))
zap.String("url", url),
zap.Uint("node_id", nodeID),
zap.String("node_ip", nodeIP))
// 推送失败不停止任务,继续运行
return
}
@@ -394,7 +494,9 @@ func pushSingleResult(taskID string, nodeID uint, nodeIP string, result map[stri
if containsTaskNotFoundError(bodyStr) {
logger.Warn("后端任务不存在,停止节点端任务",
zap.String("task_id", taskID),
zap.String("response", bodyStr))
zap.String("response", bodyStr),
zap.Uint("node_id", nodeID),
zap.String("node_ip", nodeIP))
// 停止对应的持续测试任务
stopTaskByTaskID(taskID)
return
@@ -404,12 +506,18 @@ func pushSingleResult(taskID string, nodeID uint, nodeIP string, result map[stri
zap.Int("status", resp.StatusCode),
zap.String("task_id", taskID),
zap.String("url", url),
zap.String("response", bodyStr))
zap.String("response", bodyStr),
zap.Uint("node_id", nodeID),
zap.String("node_ip", nodeIP))
// 其他错误不停止任务,继续运行
return
}
logger.Debug("推送结果成功", zap.String("task_id", taskID))
logger.Debug("推送结果成功",
zap.String("task_id", taskID),
zap.Uint("node_id", nodeID),
zap.String("node_ip", nodeIP),
zap.Any("result", result))
}
// containsTaskNotFoundError 检查响应中是否包含任务不存在的错误
@@ -522,23 +630,20 @@ func StartTaskCleanup() {
for range ticker.C {
now := time.Now()
taskMutex.Lock()
var tasksToDelete []string
for taskID, task := range continuousTasks {
shouldDelete := false
// 检查最大运行时长
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
}
shouldDelete = true
} else if now.Sub(task.LastRequest) > 30*time.Minute {
// 检查无客户端连接30分钟无请求
if now.Sub(task.LastRequest) > 30*time.Minute {
logger.Info("任务无客户端连接,自动停止", zap.String("task_id", taskID))
shouldDelete = true
}
if shouldDelete {
task.IsRunning = false
if task.pingTask != nil {
task.pingTask.Stop()
@@ -546,10 +651,41 @@ func StartTaskCleanup() {
if task.tcpingTask != nil {
task.tcpingTask.Stop()
}
delete(continuousTasks, taskID)
// 关闭停止通道
select {
case <-task.StopCh:
// 已经关闭
default:
close(task.StopCh)
}
tasksToDelete = append(tasksToDelete, taskID)
}
}
taskMutex.Unlock()
// 清理任务和推送缓冲
if len(tasksToDelete) > 0 {
taskMutex.Lock()
for _, taskID := range tasksToDelete {
delete(continuousTasks, taskID)
}
taskMutex.Unlock()
// 清理推送缓冲
bufferMutex.Lock()
for _, taskID := range tasksToDelete {
if buffer, exists := pushBuffers[taskID]; exists {
if buffer.pushTimer != nil {
buffer.pushTimer.Stop()
}
delete(pushBuffers, taskID)
logger.Debug("已清理任务推送缓冲", zap.String("task_id", taskID))
}
}
bufferMutex.Unlock()
}
}
}()
}

View File

@@ -145,11 +145,8 @@ func handleGet(c *gin.Context, urlStr string, params map[string]interface{}) {
Transport: timingTransport,
Timeout: 15 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
// 跟随重定向,最多20次
if len(via) >= 20 {
return fmt.Errorf("重定向次数过多")
}
return nil
// 跟随重定向,返回第一个状态码和 header
return http.ErrUseLastResponse
},
}
@@ -181,8 +178,11 @@ func handleGet(c *gin.Context, urlStr string, params map[string]interface{}) {
// 执行请求
startTime := time.Now()
resp, err := client.Do(req)
if err != nil {
// 错误处理
// 处理重定向错误:当 CheckRedirect 返回 ErrUseLastResponse 时,
// client.Do 会返回响应和错误,但响应仍然有效(包含重定向状态码和 header
if err != nil && resp == nil {
// 真正的错误,没有响应
errMsg := err.Error()
if strings.Contains(errMsg, "no such host") {
result["ip"] = "域名无法解析"
@@ -204,7 +204,24 @@ func handleGet(c *gin.Context, urlStr string, params map[string]interface{}) {
c.JSON(200, result)
return
}
// 如果有响应(包括重定向响应),继续处理
if resp != nil {
defer resp.Body.Close()
} else {
// 没有响应也没有错误,不应该发生
result["error"] = "未知错误"
result["ip"] = "访问失败"
result["totaltime"] = "*"
result["downtime"] = "*"
result["downsize"] = "*"
result["downspeed"] = "*"
result["firstbytetime"] = "*"
result["conntime"] = "*"
result["size"] = "*"
c.JSON(200, result)
return
}
// 获取时间信息
timingTransport.mu.Lock()
@@ -237,7 +254,7 @@ func handleGet(c *gin.Context, urlStr string, params map[string]interface{}) {
bodyReader := io.LimitReader(resp.Body, 1024*1024) // 限制1MB
bodyStartTime := time.Now()
body, err := io.ReadAll(bodyReader)
bodyReadTime := time.Now().Sub(bodyStartTime)
bodyReadTime := time.Since(bodyStartTime)
if err != nil && err != io.EOF {
result["error"] = err.Error()
}
@@ -333,10 +350,8 @@ func handlePost(c *gin.Context, urlStr string, params map[string]interface{}) {
Transport: timingTransport,
Timeout: 15 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 20 {
return fmt.Errorf("重定向次数过多")
}
return nil
// 不跟随重定向,返回第一个状态码和 header
return http.ErrUseLastResponse
},
}
@@ -363,7 +378,11 @@ func handlePost(c *gin.Context, urlStr string, params map[string]interface{}) {
// 执行请求
startTime := time.Now()
resp, err := client.Do(req)
if err != nil {
// 处理重定向错误:当 CheckRedirect 返回 ErrUseLastResponse 时,
// client.Do 会返回响应和错误,但响应仍然有效(包含重定向状态码和 header
if err != nil && resp == nil {
// 真正的错误,没有响应
errMsg := err.Error()
if strings.Contains(errMsg, "no such host") {
result["ip"] = "域名无法解析"
@@ -385,7 +404,24 @@ func handlePost(c *gin.Context, urlStr string, params map[string]interface{}) {
c.JSON(200, result)
return
}
// 如果有响应(包括重定向响应),继续处理
if resp != nil {
defer resp.Body.Close()
} else {
// 没有响应也没有错误,不应该发生
result["error"] = "未知错误"
result["ip"] = "访问失败"
result["totaltime"] = "*"
result["downtime"] = "*"
result["downsize"] = "*"
result["downspeed"] = "*"
result["firstbytetime"] = "*"
result["conntime"] = "*"
result["size"] = "*"
c.JSON(200, result)
return
}
// 获取时间信息
timingTransport.mu.Lock()

View File

@@ -16,21 +16,53 @@ func handleTCPing(c *gin.Context, url string, params map[string]interface{}) {
seq = seqVal
}
// 解析host:port格式
parts := strings.Split(url, ":")
if len(parts) != 2 {
// 解析host:port格式如果没有端口则默认80
var host string
var portStr string
var port int
// 检查是否是IPv6格式如 [::1]:8080
if strings.HasPrefix(url, "[") {
// IPv6格式 - 使用 Index 而不是 LastIndex 来找到第一个闭合括号
closeBracket := strings.Index(url, "]")
if closeBracket == -1 {
c.JSON(200, gin.H{
"seq": seq,
"type": "ceTCPing",
"url": url,
"error": "格式错误,需要 host:port",
"error": "格式错误,IPv6地址格式应为 [host]:port",
})
return
}
host = url[1:closeBracket]
if closeBracket+1 < len(url) && url[closeBracket+1] == ':' {
portStr = url[closeBracket+2:]
// 如果端口部分为空使用默认端口80修复 Bug 1
if portStr == "" {
portStr = "80"
}
} else {
portStr = "80" // 默认端口
}
} else {
// 普通格式 host:port 或 host
lastColonIndex := strings.LastIndex(url, ":")
if lastColonIndex == -1 {
// 没有冒号使用默认端口80
host = url
portStr = "80"
} else {
host = url[:lastColonIndex]
portStr = url[lastColonIndex+1:]
// 如果端口部分为空使用默认端口80
if portStr == "" {
portStr = "80"
}
}
}
host := parts[0]
portStr := parts[1]
port, err := strconv.Atoi(portStr)
var err error
port, err = strconv.Atoi(portStr)
if err != nil {
c.JSON(200, gin.H{
"seq": seq,
@@ -160,4 +192,3 @@ func handleTCPing(c *gin.Context, url string, params map[string]interface{}) {
c.JSON(200, result)
}

View File

@@ -7,6 +7,8 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"sync"
"time"
@@ -110,10 +112,25 @@ func (r *Reporter) Stop() {
close(r.stopCh)
}
// buildHeartbeatBody 构建心跳请求体
func buildHeartbeatBody() string {
hostname, err := os.Hostname()
if err != nil {
hostname = "unknown"
}
values := url.Values{}
values.Set("type", "pingServer")
values.Set("version", "2")
values.Set("host_name", hostname)
return values.Encode()
}
// RegisterNode 注册节点(安装时或首次启动时调用)
func RegisterNode(cfg *config.Config) error {
url := fmt.Sprintf("%s/api/node/heartbeat", cfg.Backend.URL)
req, err := http.NewRequest("POST", url, bytes.NewBufferString("type=pingServer"))
req, err := http.NewRequest("POST", url, bytes.NewBufferString(buildHeartbeatBody()))
if err != nil {
return fmt.Errorf("创建心跳请求失败: %w", err)
}
@@ -123,7 +140,7 @@ func RegisterNode(cfg *config.Config) error {
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("发送心跳失败: %w", err)
return fmt.Errorf("发送心跳失败 (URL: %s): %w", url, err)
}
defer resp.Body.Close()
@@ -173,16 +190,27 @@ func RegisterNode(cfg *config.Config) error {
return nil
}
}
return fmt.Errorf("心跳响应格式无效或节点信息不完整")
return fmt.Errorf("心跳响应格式无效或节点信息不完整 (响应体: %s)", string(body))
}
return fmt.Errorf("心跳请求失败,状态码: %d", resp.StatusCode)
// 读取响应体以便记录错误详情
body, err := io.ReadAll(resp.Body)
bodyStr := ""
if err == nil && len(body) > 0 {
// 限制响应体长度,避免错误信息过长
if len(body) > 500 {
bodyStr = string(body[:500]) + "..."
} else {
bodyStr = string(body)
}
}
return fmt.Errorf("心跳请求失败,状态码: %d, URL: %s, 响应体: %s", resp.StatusCode, url, bodyStr)
}
func (r *Reporter) sendHeartbeat() {
// 发送心跳使用Form格式兼容旧接口
url := fmt.Sprintf("%s/api/node/heartbeat", r.cfg.Backend.URL)
req, err := http.NewRequest("POST", url, bytes.NewBufferString("type=pingServer"))
req, err := http.NewRequest("POST", url, bytes.NewBufferString(buildHeartbeatBody()))
if err != nil {
r.logger.Error("创建心跳请求失败", zap.Error(err))
return
@@ -192,7 +220,9 @@ func (r *Reporter) sendHeartbeat() {
resp, err := r.client.Do(req)
if err != nil {
r.logger.Warn("发送心跳失败", zap.Error(err))
r.logger.Warn("发送心跳失败",
zap.String("url", url),
zap.Error(err))
return
}
defer resp.Body.Close()
@@ -260,7 +290,21 @@ func (r *Reporter) sendHeartbeat() {
}
r.logger.Debug("心跳发送成功")
} else {
r.logger.Warn("心跳发送失败", zap.Int("status", resp.StatusCode))
// 读取响应体以便记录错误详情
body, err := io.ReadAll(resp.Body)
bodyStr := ""
if err == nil && len(body) > 0 {
// 限制响应体长度,避免日志过长
if len(body) > 500 {
bodyStr = string(body[:500]) + "..."
} else {
bodyStr = string(body)
}
}
r.logger.Warn("心跳发送失败",
zap.Int("status", resp.StatusCode),
zap.String("url", url),
zap.String("response_body", bodyStr))
}
}

View File

@@ -1 +0,0 @@
48748

6
run.sh
View File

@@ -191,12 +191,14 @@ start() {
echo -e "${BLUE}启动节点端服务...${NC}"
echo -e "${BLUE}后端地址: $BACKEND_URL${NC}"
echo -e "${BLUE}日志文件: $LOG_FILE${NC}"
# 设置环境变量
export BACKEND_URL="$BACKEND_URL"
export LOG_FILE="$LOG_FILE"
# 后台运行
nohup ./"$BINARY_NAME" > "$LOG_FILE" 2>&1 &
# 后台运行(日志现在由程序直接写入文件,这里保留重定向作为备份)
nohup ./"$BINARY_NAME" >> "$LOG_FILE" 2>&1 &
NEW_PID=$!
# 保存PID

View File

@@ -15,11 +15,38 @@ cd "$SCRIPT_DIR"
BINARY_NAME="agent"
BACKEND_URL="${BACKEND_URL:-http://localhost:8080}"
# 检查二进制文件是否存在且有效
check_binary() {
if [ -f "$BINARY_NAME" ] && [ -x "$BINARY_NAME" ] && [ -s "$BINARY_NAME" ]; then
# 验证二进制文件类型Linux 应该是 ELF 文件)
if command -v file > /dev/null 2>&1; then
local file_type=$(file "$BINARY_NAME" 2>/dev/null || echo "")
if [ -n "$file_type" ] && echo "$file_type" | grep -qi "ELF"; then
return 0
fi
else
# 如果没有 file 命令,至少检查文件大小(应该大于 1MB
local file_size=$(stat -c%s "$BINARY_NAME" 2>/dev/null || stat -f%z "$BINARY_NAME" 2>/dev/null || echo "0")
if [ "$file_size" -gt 1048576 ]; then
return 0
fi
fi
fi
return 1
}
# 拉取最新源码并编译
update_and_build() {
# 如果二进制文件已存在且有效,跳过编译
if check_binary; then
echo "二进制文件已存在,跳过编译步骤"
return 0
fi
# 检查是否在 Git 仓库中
if [ ! -d ".git" ]; then
return 0
echo "错误: 不在 Git 仓库中,无法更新代码" >&2
return 1
fi
# 配置 Git safe.directory解决所有权问题
@@ -31,14 +58,41 @@ update_and_build() {
echo "代码更新完成"
fi
# 确保 Go 在 PATH 中systemd 可能没有设置 PATH
if [ -d "/usr/local/go/bin" ] && ! echo "$PATH" | grep -q "/usr/local/go/bin"; then
export PATH="/usr/local/go/bin:$PATH"
fi
# 检查 Go 环境
if ! command -v go > /dev/null 2>&1; then
echo "错误: 未找到 Go 环境,无法编译" >&2
echo "PATH: $PATH" >&2
echo "请确保 Go 已安装: /usr/local/go/bin 或系统 PATH 中包含 go 命令" >&2
exit 1
fi
# 更新依赖
go mod download 2>&1 > /dev/null || true
# 检查是否有 vendor 目录
local use_vendor=false
if [ -d "./vendor" ] && [ -f "./vendor/modules.txt" ]; then
use_vendor=true
echo "使用 vendor 目录编译(无需网络连接)"
fi
# 更新依赖(仅在非 vendor 模式下,并设置超时避免卡住)
if [ "$use_vendor" != "true" ]; then
echo "更新 Go 依赖..."
# 使用 timeout 命令设置超时30秒如果系统没有 timeout 命令则直接执行
if command -v timeout > /dev/null 2>&1; then
timeout 30 go mod download 2>&1 > /dev/null || {
echo "警告: 依赖更新失败或超时,尝试继续编译" >&2
}
else
# 如果没有 timeout 命令,直接执行(可能卡住,但至少能工作)
go mod download 2>&1 > /dev/null || {
echo "警告: 依赖更新失败,尝试继续编译" >&2
}
fi
fi
# 编译
ARCH=$(uname -m)
@@ -54,21 +108,33 @@ update_and_build() {
;;
esac
if GOOS=linux GOARCH=${ARCH} CGO_ENABLED=0 go build -buildvcs=false -ldflags="-w -s" -o "$BINARY_NAME" ./cmd/agent 2>&1; then
# 构建编译命令
local build_cmd="GOOS=linux GOARCH=${ARCH} CGO_ENABLED=0 go build -buildvcs=false -ldflags=\"-w -s\""
if [ "$use_vendor" = "true" ]; then
build_cmd="$build_cmd -mod=vendor"
fi
build_cmd="$build_cmd -o \"$BINARY_NAME\" ./cmd/agent"
echo "开始编译(架构: ${ARCH}..."
if eval "$build_cmd" 2>&1; then
if [ -f "$BINARY_NAME" ] && [ -s "$BINARY_NAME" ]; then
chmod +x "$BINARY_NAME"
echo "编译成功"
return 0
else
echo "错误: 编译失败,未生成二进制文件" >&2
exit 1
return 1
fi
else
echo "错误: 编译失败" >&2
exit 1
return 1
fi
}
# 拉取最新源码并编译
update_and_build
# 检查并更新/编译
if ! check_binary; then
update_and_build
fi
# 设置环境变量
export BACKEND_URL="$BACKEND_URL"

318
uninstall.sh Executable file
View File

@@ -0,0 +1,318 @@
#!/bin/bash
# ============================================
# LinkMaster 节点端一键卸载脚本
# 使用方法: curl -fsSL https://gitee.nas.cpolar.cn/yoyo/linkmaster-node/raw/branch/main/uninstall.sh | bash
# 或: ./uninstall.sh
# ============================================
set -e
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# 配置
BINARY_NAME="linkmaster-node"
INSTALL_DIR="/usr/local/bin"
SERVICE_NAME="linkmaster-node"
SOURCE_DIR="/opt/linkmaster-node"
CONFIG_FILE="${SOURCE_DIR}/config.yaml"
LOG_FILE="${SOURCE_DIR}/node.log"
PID_FILE="${SOURCE_DIR}/node.pid"
# 检查是否已安装
check_installed() {
local installed=false
# 检查服务文件是否存在
if [ -f "/etc/systemd/system/${SERVICE_NAME}.service" ]; then
installed=true
fi
# 检查二进制文件是否存在
if [ -f "$INSTALL_DIR/$BINARY_NAME" ]; then
installed=true
fi
# 检查源码目录是否存在
if [ -d "$SOURCE_DIR" ]; then
installed=true
fi
if [ "$installed" = false ]; then
return 1
fi
return 0
}
# 停止服务
stop_service() {
echo -e "${BLUE}停止服务...${NC}"
# 停止 systemd 服务
if systemctl is-active --quiet ${SERVICE_NAME} 2>/dev/null; then
echo -e "${BLUE} 正在停止 systemd 服务...${NC}"
sudo systemctl stop ${SERVICE_NAME} 2>/dev/null || true
sleep 2
fi
# 清理残留进程
if pgrep -f "$BINARY_NAME" > /dev/null 2>&1; then
echo -e "${BLUE} 清理残留进程...${NC}"
sudo pkill -f "$BINARY_NAME" 2>/dev/null || true
sleep 1
# 再次检查,如果还有进程则强制杀死
if pgrep -f "$BINARY_NAME" > /dev/null 2>&1; then
echo -e "${YELLOW} 强制清理残留进程...${NC}"
sudo pkill -9 -f "$BINARY_NAME" 2>/dev/null || true
sleep 1
fi
fi
# 如果使用 run.sh 启动的进程(通过 PID 文件)
if [ -f "$PID_FILE" ]; then
local pid=$(cat "$PID_FILE" 2>/dev/null || echo "")
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
echo -e "${BLUE} 停止通过 run.sh 启动的进程 (PID: $pid)...${NC}"
kill "$pid" 2>/dev/null || true
sleep 1
if kill -0 "$pid" 2>/dev/null; then
kill -9 "$pid" 2>/dev/null || true
fi
fi
fi
echo -e "${GREEN}✓ 服务已停止${NC}"
}
# 禁用服务
disable_service() {
echo -e "${BLUE}禁用服务...${NC}"
if systemctl is-enabled --quiet ${SERVICE_NAME} 2>/dev/null; then
sudo systemctl disable ${SERVICE_NAME} 2>/dev/null || true
echo -e "${GREEN}✓ 服务已禁用${NC}"
else
echo -e "${BLUE} 服务未启用,跳过${NC}"
fi
}
# 删除服务文件
remove_service_files() {
echo -e "${BLUE}删除服务文件...${NC}"
local removed=false
# 删除 systemd 服务文件
if [ -f "/etc/systemd/system/${SERVICE_NAME}.service" ]; then
sudo rm -f /etc/systemd/system/${SERVICE_NAME}.service
echo -e "${GREEN}✓ 已删除服务文件: /etc/systemd/system/${SERVICE_NAME}.service${NC}"
removed=true
fi
# 删除可能的 override 配置目录
if [ -d "/etc/systemd/system/${SERVICE_NAME}.service.d" ]; then
sudo rm -rf /etc/systemd/system/${SERVICE_NAME}.service.d
echo -e "${GREEN}✓ 已删除服务配置目录: /etc/systemd/system/${SERVICE_NAME}.service.d${NC}"
removed=true
fi
if [ "$removed" = true ]; then
# 重新加载 systemd daemon
sudo systemctl daemon-reload
echo -e "${GREEN}✓ systemd daemon 已重新加载${NC}"
else
echo -e "${BLUE} 未找到服务文件,跳过${NC}"
fi
}
# 删除二进制文件
remove_binary() {
echo -e "${BLUE}删除二进制文件...${NC}"
if [ -f "$INSTALL_DIR/$BINARY_NAME" ]; then
sudo rm -f "$INSTALL_DIR/$BINARY_NAME"
echo -e "${GREEN}✓ 已删除二进制文件: $INSTALL_DIR/$BINARY_NAME${NC}"
else
echo -e "${BLUE} 未找到二进制文件,跳过${NC}"
fi
}
# 删除源码目录和配置文件
remove_source_directory() {
echo -e "${BLUE}删除源码目录和配置文件...${NC}"
if [ -d "$SOURCE_DIR" ]; then
# 显示将要删除的内容
echo -e "${BLUE} 源码目录: $SOURCE_DIR${NC}"
# 删除整个源码目录
sudo rm -rf "$SOURCE_DIR"
echo -e "${GREEN}✓ 已删除源码目录: $SOURCE_DIR${NC}"
else
echo -e "${BLUE} 未找到源码目录,跳过${NC}"
fi
# 额外检查配置文件(如果单独存在)
if [ -f "$CONFIG_FILE" ]; then
sudo rm -f "$CONFIG_FILE"
echo -e "${GREEN}✓ 已删除配置文件: $CONFIG_FILE${NC}"
fi
}
# 清理防火墙规则(可选,默认不删除)
remove_firewall_rules() {
local remove_firewall="${1:-false}"
if [ "$remove_firewall" != "true" ]; then
echo -e "${BLUE}跳过防火墙规则清理(默认保留)${NC}"
echo -e "${YELLOW} 如需删除防火墙规则,请使用: ./uninstall.sh --remove-firewall${NC}"
return
fi
echo -e "${BLUE}清理防火墙规则开放2200端口...${NC}"
PORT=2200
FIREWALL_REMOVED=false
# 检测 firewalld (CentOS/RHEL 7+, Fedora)
if command -v firewall-cmd > /dev/null 2>&1; then
if sudo systemctl is-active --quiet firewalld 2>/dev/null; then
echo -e "${BLUE} 从 firewalld 删除端口规则...${NC}"
if sudo firewall-cmd --permanent --remove-port=${PORT}/tcp > /dev/null 2>&1; then
sudo firewall-cmd --reload > /dev/null 2>&1
echo -e "${GREEN}✓ firewalld 规则已删除${NC}"
FIREWALL_REMOVED=true
fi
fi
fi
# 检测 ufw (Ubuntu/Debian)
if command -v ufw > /dev/null 2>&1; then
if sudo ufw status > /dev/null 2>&1; then
echo -e "${BLUE} 从 ufw 删除端口规则...${NC}"
if sudo ufw delete allow ${PORT}/tcp > /dev/null 2>&1; then
echo -e "${GREEN}✓ ufw 规则已删除${NC}"
FIREWALL_REMOVED=true
fi
fi
fi
# 检测 iptables较老的系统
if command -v iptables > /dev/null 2>&1 && [ "$FIREWALL_REMOVED" = false ]; then
if sudo iptables -C INPUT -p tcp --dport ${PORT} -j ACCEPT > /dev/null 2>&1; then
echo -e "${BLUE} 从 iptables 删除端口规则...${NC}"
if sudo iptables -D INPUT -p tcp --dport ${PORT} -j ACCEPT > /dev/null 2>&1; then
echo -e "${GREEN}✓ iptables 规则已删除${NC}"
# 尝试保存规则
if command -v iptables-save > /dev/null 2>&1; then
if [ -f /etc/redhat-release ]; then
sudo iptables-save > /etc/sysconfig/iptables 2>/dev/null || true
elif [ -f /etc/debian_version ]; then
sudo iptables-save > /etc/iptables/rules.v4 2>/dev/null || true
fi
fi
FIREWALL_REMOVED=true
fi
fi
fi
if [ "$FIREWALL_REMOVED" = false ]; then
echo -e "${YELLOW}⚠ 未找到防火墙规则或防火墙未启用${NC}"
fi
}
# 显示卸载摘要
show_summary() {
echo ""
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN} 卸载完成!${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
echo -e "${BLUE}已删除的内容:${NC}"
echo " - systemd 服务文件"
echo " - 二进制文件: $INSTALL_DIR/$BINARY_NAME"
echo " - 源码目录: $SOURCE_DIR"
echo ""
echo -e "${YELLOW}注意:${NC}"
echo " - 防火墙规则已保留(如需删除请使用 --remove-firewall 参数)"
echo " - 如果手动修改过系统配置,请手动清理"
echo ""
}
# 主卸载流程
main() {
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN} LinkMaster 节点端卸载程序${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
# 检查是否已安装
if ! check_installed; then
echo -e "${YELLOW}未检测到已安装的 LinkMaster 节点端${NC}"
echo -e "${YELLOW}无需卸载${NC}"
exit 0
fi
# 检查是否需要删除防火墙规则
REMOVE_FIREWALL=false
if [ "$1" = "--remove-firewall" ]; then
REMOVE_FIREWALL=true
fi
# 确认卸载
echo -e "${YELLOW}即将卸载 LinkMaster 节点端,此操作将:${NC}"
echo " - 停止并禁用服务"
echo " - 删除 systemd 服务文件"
echo " - 删除二进制文件"
echo " - 删除源码目录和配置文件"
echo ""
# 如果在非交互式环境中,自动确认
if [ -t 0 ]; then
read -p "是否继续?(y/N): " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo -e "${YELLOW}已取消卸载${NC}"
exit 0
fi
else
echo -e "${BLUE}非交互式环境,自动确认卸载${NC}"
fi
echo ""
# 执行卸载步骤
stop_service
disable_service
remove_service_files
remove_binary
remove_source_directory
remove_firewall_rules "$REMOVE_FIREWALL"
# 最终检查
echo ""
echo -e "${BLUE}最终检查...${NC}"
local still_installed=false
if check_installed; then
still_installed=true
fi
if [ "$still_installed" = false ]; then
show_summary
exit 0
else
echo -e "${YELLOW}⚠ 部分文件可能未完全删除,请手动检查${NC}"
exit 1
fi
}
# 执行卸载
main "$@"

View File

@@ -61,7 +61,7 @@ v1.2.0 (2019-09-26)
and `errors.Is`.
v1.1.0 (2017-06-30)
v1.1.2 (2017-06-30)
===================
- Added an `Errors(error) []error` function to extract the underlying list of

View File

@@ -489,7 +489,7 @@ Enhancements:
[#402]: https://github.com/uber-go/zap/pull/402
## v1.1.0 (31 Mar 2017)
## v1.1.2 (31 Mar 2017)
This release fixes two bugs and adds some enhancements to zap's testing helpers.
It is fully backward-compatible.

4
version.json Normal file
View File

@@ -0,0 +1,4 @@
{
"version": "1.1.4",
"tag": "v1.1.4"
}