Compare commits

...

23 Commits

Author SHA1 Message Date
23c88b5f48 feat: 优化URL处理逻辑,清理多余空格和重复协议前缀
- 在 handleGet 和 handlePost 函数中添加URL清理逻辑,去除多余的空格和重复的 http:// 或 https:// 前缀。
- 确保在URL没有协议前缀时自动添加 http://。
- 更新响应结构,新增 statuscode 字段以更好地反映请求状态。
2025-12-27 21:58:35 +08:00
c9c4da01b6 feat: 添加时间同步配置功能至安装脚本
- 在 install.sh 中新增 sync_time 函数,配置系统时间同步,设置时区为 Asia/Shanghai,并安装 chrony。
- 配置 NTP 服务器为阿里云和腾讯云,确保时间同步的准确性。
- 更新主函数以调用时间同步配置,优化安装流程。
2025-12-24 03:31:35 +08:00
7a104bbe42 feat: 更新时间同步逻辑,使用K780时间API替代中国时间API
- 修改时间同步服务,使用K780时间API获取北京时间,增强了API请求的错误处理和日志记录。
- 更新响应结构,解析新的时间戳格式,确保时间同步的准确性和稳定性。
2025-12-24 02:57:37 +08:00
e0d97c4486 feat: 添加时间同步服务和北京时间支持
- 在主程序中集成时间同步服务,每30分钟同步一次时间。
- 在心跳报告中加载并使用北京时间,确保心跳在每分钟的第1秒发送。
- 增强了错误处理,确保在加载时区失败时使用默认时区。
2025-12-24 02:34:05 +08:00
bb73e0f384 feat: 更新打包和安装逻辑,支持新格式发布包
- 在 all-upload-release.sh 中添加临时打包目录,复制二进制文件及必要的脚本和配置文件。
- 修改 install.sh 以支持新格式发布包的提取,简化安装流程,无需从 Git 克隆。
- 更新 INSTALL.md 和 README.md,说明新格式发布包的优点和安装步骤。
- 确保安装脚本能够处理旧格式发布包,保持向后兼容性。
2025-12-24 01:31:30 +08:00
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
24 changed files with 4160 additions and 192 deletions

6
.gitignore vendored
View File

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

View File

@@ -37,13 +37,17 @@ GITHUB_BRANCH=develop curl -fsSL https://raw.githubusercontent.com/yourbask/link
1. **检测系统** - 自动识别 Linux 发行版和 CPU 架构 1. **检测系统** - 自动识别 Linux 发行版和 CPU 架构
2. **安装依赖** - 自动安装 Git、Go、ping、traceroute、dnsutils 等工具 2. **安装依赖** - 自动安装 Git、Go、ping、traceroute、dnsutils 等工具
3. **克隆源码** - 从 GitHub 克隆 node 项目源码到 `/opt/linkmaster-node` 3. **下载发布包** - 从 Releases 下载预编译发布包(包含二进制文件和所有脚本)
4. **编译安装** - 自动编译源码并安装二进制文件 4. **提取文件** - 从发布包提取所有文件到 `/opt/linkmaster-node`(无需克隆 Git
5. **创建服务** - 自动创建 systemd 服务文件(使用 run.sh 启动) 5. **编译安装** - 如果下载失败,自动从源码编译安装
6. **启动服务** - 自动启动并设置开机自启 6. **创建服务** - 自动创建 systemd 服务文件(使用 run.sh 启动)
7. **验证安装** - 检查服务状态和健康检查 7. **启动服务** - 自动启动并设置开机自启
8. **验证安装** - 检查服务状态和健康检查
**注意** 每次服务启动时会自动拉取最新代码并重新编译,确保使用最新版本。 **重要说明**
-**新格式发布包**:包含二进制文件、安装脚本、运行脚本等所有必要文件,安装时直接从压缩包提取,无需克隆 Git 仓库
-**向后兼容**:如果发布包是旧格式(仅包含二进制文件),安装脚本会自动从 Git 克隆获取脚本文件
- ⚠️ **源码编译模式**:如果下载失败,会从 Git 克隆源码并编译(需要网络连接)
## 安装后管理 ## 安装后管理
@@ -109,7 +113,37 @@ curl http://localhost:2200/api/health
如果无法使用一键安装脚本,可以手动安装: 如果无法使用一键安装脚本,可以手动安装:
### 1. 克隆源码并编译 ### 方式一:从发布包安装(推荐)
**优点**:无需 Git 和 Go 环境,直接使用预编译文件
```bash
# 1. 下载发布包(替换为实际版本和平台)
wget https://gitee.nas.cpolar.cn/yoyo/linkmaster-node/releases/download/v1.1.4/agent-linux-amd64-v1.1.4.tar.gz
# 2. 解压
tar -xzf agent-linux-amd64-v1.1.4.tar.gz
cd agent-linux-amd64-v1.1.4
# 3. 复制文件到安装目录
sudo mkdir -p /opt/linkmaster-node
sudo cp -r * /opt/linkmaster-node/
sudo chmod +x /opt/linkmaster-node/agent
sudo chmod +x /opt/linkmaster-node/*.sh
# 4. 复制二进制文件到系统目录
sudo cp /opt/linkmaster-node/agent /usr/local/bin/linkmaster-node
sudo chmod +x /usr/local/bin/linkmaster-node
# 5. 创建配置文件(从示例复制)
sudo cp /opt/linkmaster-node/config.yaml.example /opt/linkmaster-node/config.yaml
# 编辑配置文件,设置后端地址
sudo nano /opt/linkmaster-node/config.yaml
# 6. 创建 systemd 服务(参考下面的服务配置)
```
### 方式二:克隆源码并编译
```bash ```bash
# 克隆仓库 # 克隆仓库
@@ -169,6 +203,43 @@ EOF
**注意:** 使用 `run.sh` 启动的好处是每次启动会自动拉取最新代码并重新编译。 **注意:** 使用 `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. 启动服务 ### 4. 启动服务
```bash ```bash
@@ -177,7 +248,11 @@ sudo systemctl enable linkmaster-node
sudo systemctl start linkmaster-node sudo systemctl start linkmaster-node
``` ```
**注意** 确保 `BACKEND_URL` 环境变量指向后端服务器的实际地址和端口(默认 8080不是前端地址。 **重要说明**
- 确保 `BACKEND_URL` 环境变量指向后端服务器的实际地址和端口(默认 8080不是前端地址
- `BACKEND_URL` 环境变量会**覆盖**配置文件中的 `backend.url` 设置(优先级最高)
- 即使配置文件存在,设置环境变量后也会优先使用环境变量的值
- 这确保了编译后的二进制文件不会硬编码后端地址
## 防火墙配置 ## 防火墙配置
@@ -238,12 +313,19 @@ sudo lsof -i :2200
**解决:** **解决:**
- 检查后端地址是否正确(应该是 `http://backend-server:8080`,不是前端地址) - 检查后端地址是否正确(应该是 `http://backend-server:8080`,不是前端地址)
- 检查环境变量 `BACKEND_URL` 是否设置正确(优先级最高)
- 检查配置文件 `config.yaml` 中的 `backend.url` 是否正确
- 检查网络连通性:`ping your-backend-server` - 检查网络连通性:`ping your-backend-server`
- 检查端口是否开放:`telnet your-backend-server 8080``nc -zv your-backend-server 8080` - 检查端口是否开放:`telnet your-backend-server 8080``nc -zv your-backend-server 8080`
- 检查防火墙规则(确保后端服务器的 8080 端口开放) - 检查防火墙规则(确保后端服务器的 8080 端口开放)
- 检查后端服务是否运行:`curl http://your-backend-server:8080/api/public/nodes/online` - 检查后端服务是否运行:`curl http://your-backend-server:8080/api/public/nodes/online`
- 如果使用前端代理,节点端仍需要直接连接后端,不能使用前端地址 - 如果使用前端代理,节点端仍需要直接连接后端,不能使用前端地址
**配置优先级说明:**
- 环境变量 `BACKEND_URL` 优先级最高,会覆盖配置文件中的设置
- 如果同时设置了环境变量和配置文件,优先使用环境变量的值
- 这确保了编译后的二进制文件不会硬编码后端地址
## 卸载 ## 卸载
```bash ```bash

View File

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

532
README.md
View File

@@ -13,6 +13,8 @@ LinkMaster 节点服务,用于执行网络测试任务。
- FindPing IP段批量ping检测 - FindPing IP段批量ping检测
- 持续 Ping/TCPing 测试 - 持续 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 - `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: server:
port: 2200 port: 2200
backend: backend:
url: http://your-backend-server:8080 url: http://your-backend-server:8080 # 会被 BACKEND_URL 环境变量覆盖
heartbeat: heartbeat:
interval: 60 interval: 60
log:
file: node.log # 日志文件路径(默认: node.log空则输出到标准错误
level: info # 日志级别: debug, info, warn, error默认: info
debug: false 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` 脚本管理节点端。**每次启动时会自动拉取最新代码并重新编译** 使用 `run.sh` 脚本管理节点端。**每次启动时会自动拉取最新代码并重新编译**
@@ -136,6 +169,322 @@ BACKEND_URL=http://192.168.1.100:8080 ./run.sh start
- 如果 Git 拉取失败(如网络问题),会使用当前代码继续编译 - 如果 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 下载预编译二进制文件,失败则从源码编译
- **发布包包含所有必要文件**:二进制文件、安装脚本、运行脚本等,无需从 Git 拉取
- 自动创建 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 ## API
### POST /api/test ### POST /api/test
@@ -180,5 +529,180 @@ BACKEND_URL=http://192.168.1.100:8080 ./run.sh start
### GET /api/health ### 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 "$@"

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

@@ -0,0 +1,985 @@
#!/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
# 创建临时打包目录
local pack_dir="${TEMP_DIR}/${pack_name}"
mkdir -p "$pack_dir"
# 复制二进制文件并重命名为 agent
if [ "$os" = "windows" ]; then
cp "$binary" "$pack_dir/agent.exe"
else
cp "$binary" "$pack_dir/agent"
chmod +x "$pack_dir/agent"
fi
# 复制必要的脚本文件
local scripts=("install.sh" "run.sh" "start-systemd.sh" "uninstall.sh")
for script in "${scripts[@]}"; do
if [ -f "$script" ]; then
cp "$script" "$pack_dir/"
chmod +x "$pack_dir/$script"
echo -e "${BLUE} [包含]${NC} $script"
else
echo -e "${YELLOW} [警告]${NC} $script 不存在,跳过"
fi
done
# 复制示例配置文件
if [ -f "config.yaml.example" ]; then
cp "config.yaml.example" "$pack_dir/config.yaml.example"
echo -e "${BLUE} [包含]${NC} config.yaml.example"
else
echo -e "${YELLOW} [警告]${NC} config.yaml.example 不存在,跳过"
fi
# 打包
if [ "$os" = "windows" ]; then
pack_file="${TEMP_DIR}/${pack_name}.zip"
echo -e "${BLUE}[打包]${NC} ${platform} -> ${pack_name}.zip"
(cd "$TEMP_DIR" && zip -q -r "${pack_file}" "$(basename $pack_dir)")
else
pack_file="${TEMP_DIR}/${pack_name}.tar.gz"
echo -e "${BLUE}[打包]${NC} ${platform} -> ${pack_name}.tar.gz"
tar -czf "$pack_file" -C "$TEMP_DIR" "$(basename $pack_dir)"
fi
# 清理临时目录
rm -rf "$pack_dir"
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" "fmt"
"os" "os"
"os/signal" "os/signal"
"path/filepath"
"syscall" "syscall"
"time" "time"
@@ -14,8 +15,11 @@ import (
"linkmaster-node/internal/server" "linkmaster-node/internal/server"
"go.uber.org/zap" "go.uber.org/zap"
"go.uber.org/zap/zapcore"
) )
var version = "1.1.0" // 编译时通过 -ldflags "-X main.version=xxx" 设置
func main() { func main() {
// 加载配置 // 加载配置
cfg, err := config.Load() cfg, err := config.Load()
@@ -32,7 +36,7 @@ func main() {
} }
defer logger.Sync() defer logger.Sync()
logger.Info("节点服务启动", zap.String("version", "1.0.0")) logger.Info("节点服务启动", zap.String("version", version))
// 初始化错误恢复 // 初始化错误恢复
recovery.Init() recovery.Init()
@@ -80,9 +84,69 @@ func main() {
} }
func initLogger(cfg *config.Config) (*zap.Logger, error) { func initLogger(cfg *config.Config) (*zap.Logger, error) {
// 确定日志级别
var level zapcore.Level
logLevel := cfg.Log.Level
if logLevel == "" {
if cfg.Debug { 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
}

18
config.yaml.example Normal file
View File

@@ -0,0 +1,18 @@
server:
port: 2200
backend:
url: http://your-backend-server:8080
heartbeat:
interval: 60
log:
file: node.log
level: info
debug: false
node:
id: 0
ip: ""
country: ""
province: ""
city: ""
isp: ""

View File

@@ -8,6 +8,30 @@
set -e 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' RED='\033[0;31m'
GREEN='\033[0;32m' GREEN='\033[0;32m'
@@ -48,14 +72,30 @@ fi
# 检测系统类型和架构 # 检测系统类型和架构
detect_system() { 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 if [ -f /etc/os-release ]; then
. /etc/os-release . /etc/os-release
OS=$ID OS=$ID
OS_VERSION=$VERSION_ID OS_VERSION=$VERSION_ID
else else
echo -e "${RED}无法检测系统类型${NC}" OS="linux"
exit 1 OS_VERSION=""
fi 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) ARCH=$(uname -m)
case $ARCH in case $ARCH in
@@ -71,7 +111,7 @@ detect_system() {
;; ;;
esac 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/Debian 镜像源列表
UBUNTU_MIRRORS=( UBUNTU_MIRRORS=(
"mirrors.tuna.tsinghua.edu.cn"
"mirrors.huaweicloud.com" "mirrors.huaweicloud.com"
"mirrors.163.com" "mirrors.163.com"
"archive.ubuntu.com" "archive.ubuntu.com"
@@ -88,7 +127,6 @@ detect_fastest_mirror() {
# CentOS/RHEL 镜像源列表 # CentOS/RHEL 镜像源列表
CENTOS_MIRRORS=( CENTOS_MIRRORS=(
"mirrors.tuna.tsinghua.edu.cn"
"mirrors.huaweicloud.com" "mirrors.huaweicloud.com"
) )
@@ -372,6 +410,119 @@ install_dependencies() {
echo -e "${GREEN}✓ 系统依赖安装完成${NC}" echo -e "${GREEN}✓ 系统依赖安装完成${NC}"
} }
# 配置时间同步
sync_time() {
echo -e "${BLUE}配置时间同步...${NC}"
# 1. 设置时区为 Asia/Shanghai
echo -e "${BLUE}[1/6] 设置时区为 Asia/Shanghai${NC}"
if command -v timedatectl > /dev/null 2>&1; then
sudo timedatectl set-timezone Asia/Shanghai 2>/dev/null || {
echo -e "${YELLOW}⚠ timedatectl 设置时区失败,尝试其他方法${NC}"
# 尝试创建时区链接(适用于较老的系统)
if [ -f /usr/share/zoneinfo/Asia/Shanghai ]; then
sudo ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime 2>/dev/null || true
fi
}
elif [ -f /usr/share/zoneinfo/Asia/Shanghai ]; then
sudo ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime 2>/dev/null || true
fi
# 2. 安装 chrony
echo -e "${BLUE}[2/6] 安装 chrony${NC}"
if [ "$OS" = "ubuntu" ] || [ "$OS" = "debian" ]; then
if ! dpkg -l 2>/dev/null | grep -q "^ii.*chrony"; then
sudo apt-get install -y chrony
else
echo -e "${BLUE}chrony 已安装,跳过${NC}"
fi
elif [ "$OS" = "centos" ] || [ "$OS" = "rhel" ] || [ "$OS" = "rocky" ] || [ "$OS" = "almalinux" ]; then
if ! rpm -q chrony &>/dev/null; then
sudo yum install -y chrony
else
echo -e "${BLUE}chrony 已安装,跳过${NC}"
fi
else
echo -e "${YELLOW}⚠ 未知系统类型,跳过 chrony 安装${NC}"
return 0
fi
# 3. 配置 NTP 服务器
echo -e "${BLUE}[3/6] 配置 NTP 服务器${NC}"
CONF="/etc/chrony.conf"
if [ -f "$CONF" ]; then
# 备份配置文件
if [ ! -f "${CONF}.backup.$(date +%Y%m%d)" ]; then
sudo cp "$CONF" "${CONF}.backup.$(date +%Y%m%d)" 2>/dev/null || true
fi
# 注释掉原有的 server 行
sudo sed -i 's/^server /#server /g' "$CONF" 2>/dev/null || true
# 添加中国 NTP 服务器(如果还没有)
if ! grep -q "ntp.aliyun.com" "$CONF"; then
sudo tee -a "$CONF" > /dev/null <<EOF
# China NTP servers (added by LinkMaster Node installer)
server ntp.aliyun.com iburst
server ntp.tencent.com iburst
server ntp1.aliyun.com iburst
EOF
fi
else
echo -e "${YELLOW}⚠ chrony.conf 不存在,跳过配置${NC}"
fi
# 4. 启动并启用 chronyd
echo -e "${BLUE}[4/6] 启动 chronyd${NC}"
if command -v systemctl > /dev/null 2>&1; then
sudo systemctl enable chronyd --now 2>/dev/null || {
# 如果 systemctl 失败,尝试使用 service 命令
if command -v service > /dev/null 2>&1; then
sudo service chronyd start 2>/dev/null || true
sudo chkconfig chronyd on 2>/dev/null || true
fi
}
elif command -v service > /dev/null 2>&1; then
sudo service chronyd start 2>/dev/null || true
sudo chkconfig chronyd on 2>/dev/null || true
fi
# 等待服务启动
sleep 2
# 5. 立即强制同步
echo -e "${BLUE}[5/6] 强制同步系统时间${NC}"
if command -v chronyc > /dev/null 2>&1; then
sudo chronyc -a makestep 2>/dev/null || {
# 如果 makestep 失败,尝试使用 sources 和 sourcestats
sudo chronyc sources 2>/dev/null || true
sudo chronyc sourcestats 2>/dev/null || true
}
fi
# 6. 写入硬件时间
echo -e "${BLUE}[6/6] 写入硬件时钟${NC}"
if command -v hwclock > /dev/null 2>&1; then
sudo hwclock --systohc 2>/dev/null || true
fi
# 显示时间状态
echo -e "${BLUE}当前时间状态:${NC}"
if command -v timedatectl > /dev/null 2>&1; then
sudo timedatectl status 2>/dev/null || true
else
date
if command -v hwclock > /dev/null 2>&1; then
echo -e "${BLUE}硬件时钟:${NC}"
sudo hwclock 2>/dev/null || true
fi
fi
echo -e "${GREEN}✓ 时间同步配置完成${NC}"
}
# 从官网下载安装 Go # 从官网下载安装 Go
install_go_from_official() { install_go_from_official() {
echo -e "${BLUE}从 Go 官网下载安装...${NC}" echo -e "${BLUE}从 Go 官网下载安装...${NC}"
@@ -461,10 +612,16 @@ install_go() {
# 先检查是否已安装且可用 # 先检查是否已安装且可用
if command -v go > /dev/null 2>&1; then 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 if [ -n "$GO_VERSION" ]; then
echo -e "${GREEN}✓ Go 已安装: ${GO_VERSION}${NC}" echo -e "${GREEN}✓ Go 已安装: ${GO_VERSION}${NC}"
# 检查 Go 版本是否可用(尝试运行 go version # 再次验证 Go 是否可用
if go version > /dev/null 2>&1; then if go version > /dev/null 2>&1; then
echo -e "${BLUE}Go 环境正常,跳过安装流程${NC}" echo -e "${BLUE}Go 环境正常,跳过安装流程${NC}"
return 0 return 0
@@ -472,6 +629,17 @@ install_go() {
echo -e "${YELLOW}⚠ Go 已安装但无法正常运行,尝试重新安装...${NC}" echo -e "${YELLOW}⚠ Go 已安装但无法正常运行,尝试重新安装...${NC}"
fi fi
else 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}" echo -e "${YELLOW}⚠ Go 已安装但无法获取版本信息,尝试重新安装...${NC}"
fi fi
fi fi
@@ -611,6 +779,389 @@ uninstall_service() {
echo "" 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"
local extracted_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
# 查找解压后的目录(可能是直接解压到当前目录,也可能是在子目录中)
extracted_dir=$(find . -maxdepth 1 -type d ! -name "." ! -name ".." | head -1)
if [ -z "$extracted_dir" ]; then
extracted_dir="."
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
extracted_dir=$(find . -maxdepth 1 -type d ! -name "." ! -name ".." | head -1)
if [ -z "$extracted_dir" ]; then
extracted_dir="."
fi
fi
cd "$extracted_dir" || {
echo -e "${YELLOW}⚠ 无法进入解压目录,将使用源码编译${NC}"
rm -rf "$temp_dir"
return 1
}
# 显示解压后的目录内容(用于调试)
echo -e "${BLUE}解压目录内容:${NC}"
ls -la . 2>/dev/null || true
echo ""
# 查找二进制文件(先检查当前目录,再递归查找)
local binary_file=""
if [ "$OS_TYPE" = "windows" ]; then
if [ -f "./agent.exe" ]; then
binary_file="./agent.exe"
elif [ -f "agent.exe" ]; then
binary_file="agent.exe"
else
# 递归查找所有 .exe 文件
binary_file=$(find . -type f -name "*.exe" ! -name "*.tar.gz" ! -name "*.zip" 2>/dev/null | grep -i agent | head -1)
fi
else
# Linux/macOS: 先检查常见位置
if [ -f "./agent" ]; then
binary_file="./agent"
elif [ -f "agent" ]; then
binary_file="agent"
else
# 递归查找所有文件,排除压缩包和目录
# 查找名为 agent 的文件(不是目录)
binary_file=$(find . -type f \( -name "agent" -o -name "agent-*" \) ! -name "*.tar.gz" ! -name "*.zip" 2>/dev/null | head -1)
# 如果还是找不到,尝试查找所有可执行文件
if [ -z "$binary_file" ]; then
binary_file=$(find . -type f -perm +111 ! -name "*.tar.gz" ! -name "*.zip" ! -name "*.sh" 2>/dev/null | head -1)
fi
fi
fi
if [ -z "$binary_file" ] || [ ! -f "$binary_file" ]; then
echo -e "${YELLOW}⚠ 未找到解压后的二进制文件,将使用源码编译${NC}"
echo -e "${YELLOW} 当前目录: $(pwd)${NC}"
echo -e "${YELLOW} 查找的文件: agent 或 agent-*${NC}"
echo -e "${YELLOW} 所有文件列表:${NC}"
find . -type f 2>/dev/null | head -20 || true
rm -rf "$temp_dir"
return 1
fi
# 确保使用绝对路径
local binary_path=""
if [[ "$binary_file" == /* ]]; then
binary_path="$binary_file"
else
# 转换为绝对路径
binary_path="$(cd "$(dirname "$binary_file")" && pwd)/$(basename "$binary_file")"
fi
echo -e "${GREEN}✓ 找到二进制文件: ${binary_path}${NC}"
# 验证二进制文件是否可执行
if [ ! -x "$binary_path" ]; then
chmod +x "$binary_path" 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_path" 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
# 保存当前目录extracted_dir
local extracted_path="$(pwd)"
# 检查是否是新格式的发布包(包含脚本文件)
local has_scripts=false
if [ -f "$extracted_path/install.sh" ] || [ -f "$extracted_path/run.sh" ] || [ -f "$extracted_path/start-systemd.sh" ]; then
has_scripts=true
echo -e "${GREEN}✓ 检测到新格式发布包(包含脚本文件)${NC}"
fi
# 创建源码目录
if [ -d "$SOURCE_DIR" ]; then
sudo rm -rf "$SOURCE_DIR"
fi
sudo mkdir -p "$SOURCE_DIR"
if [ "$has_scripts" = true ]; then
# 新格式:从压缩包提取所有文件
echo -e "${BLUE}从发布包提取所有文件...${NC}"
# 复制二进制文件
sudo cp "$binary_path" "$SOURCE_DIR/agent"
sudo chmod +x "$SOURCE_DIR/agent"
echo -e "${GREEN}✓ 已提取二进制文件${NC}"
# 复制脚本文件
local scripts=("install.sh" "run.sh" "start-systemd.sh" "uninstall.sh")
for script in "${scripts[@]}"; do
if [ -f "$extracted_path/$script" ]; then
sudo cp "$extracted_path/$script" "$SOURCE_DIR/"
sudo chmod +x "$SOURCE_DIR/$script"
echo -e "${GREEN}✓ 已提取 $script${NC}"
fi
done
# 复制示例配置文件
if [ -f "$extracted_path/config.yaml.example" ]; then
sudo cp "$extracted_path/config.yaml.example" "$SOURCE_DIR/config.yaml.example"
echo -e "${GREEN}✓ 已提取 config.yaml.example${NC}"
fi
echo -e "${GREEN}✓ 所有文件已从发布包提取,无需克隆 Git 仓库${NC}"
else
# 旧格式:只有二进制文件,需要克隆 Git 仓库获取脚本
echo -e "${BLUE}检测到旧格式发布包(仅包含二进制文件)${NC}"
echo -e "${BLUE}克隆仓库以获取启动脚本...${NC}"
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/.git" ]; then
cd "$SOURCE_DIR" || {
echo -e "${YELLOW}⚠ 无法切换到源码目录,跳过验证${NC}"
cd /tmp || true
}
local current_commit=$(git rev-parse HEAD 2>/dev/null || echo "")
if [ -n "$current_commit" ] && [ "${#release_commit}" -eq 40 ] && [ "${#current_commit}" -eq 40 ]; then
local release_commit_short=$(echo "$release_commit" | cut -c1-7)
local current_commit_short=$(echo "$current_commit" | cut -c1-7)
echo -e "${BLUE} Release commit: ${release_commit_short}${NC}"
echo -e "${BLUE} 当前代码 commit: ${current_commit_short}${NC}"
if [ "$release_commit" != "$current_commit" ]; then
echo -e "${YELLOW}⚠ Commit hash 不匹配,二进制文件可能不是最新代码编译的${NC}"
echo -e "${YELLOW} Release 基于较旧的代码,将使用源码编译最新版本${NC}"
cd /tmp || true
rm -rf "$temp_dir"
return 1
else
echo -e "${GREEN}✓ Commit hash 匹配${NC}"
fi
fi
cd /tmp || true
fi
fi
# 用下载的二进制文件覆盖克隆目录中的文件
sudo cp "$binary_path" "$SOURCE_DIR/agent"
sudo chmod +x "$SOURCE_DIR/agent"
fi
# 复制到安装目录
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}${NC}"
return 0
}
# 从源码编译安装 # 从源码编译安装
build_from_source() { build_from_source() {
echo -e "${BLUE}从源码编译安装节点端...${NC}" echo -e "${BLUE}从源码编译安装节点端...${NC}"
@@ -620,7 +1171,12 @@ build_from_source() {
echo -e "${BLUE}未检测到 Go 环境,开始安装...${NC}" echo -e "${BLUE}未检测到 Go 环境,开始安装...${NC}"
install_go install_go
else 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 "") GO_VERSION=$(go version 2>/dev/null | head -1 || echo "")
if [ -n "$GO_VERSION" ] && go version > /dev/null 2>&1; then if [ -n "$GO_VERSION" ] && go version > /dev/null 2>&1; then
echo -e "${GREEN}✓ Go 已安装: ${GO_VERSION}${NC}" echo -e "${GREEN}✓ Go 已安装: ${GO_VERSION}${NC}"
@@ -654,7 +1210,17 @@ build_from_source() {
exit 1 exit 1
fi 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 if [ -d "$SOURCE_DIR" ]; then
echo -e "${YELLOW}清理旧的源码目录...${NC}" echo -e "${YELLOW}清理旧的源码目录...${NC}"
sudo rm -rf "$SOURCE_DIR" sudo rm -rf "$SOURCE_DIR"
@@ -668,6 +1234,7 @@ build_from_source() {
show_build_alternatives show_build_alternatives
exit 1 exit 1
fi fi
fi
# 设置目录权限(允许当前用户访问,但服务运行时是 root # 设置目录权限(允许当前用户访问,但服务运行时是 root
sudo chown -R root:root "$SOURCE_DIR" 2>/dev/null || true sudo chown -R root:root "$SOURCE_DIR" 2>/dev/null || true
@@ -757,6 +1324,22 @@ create_service() {
sudo chmod +x "$SOURCE_DIR/run.sh" sudo chmod +x "$SOURCE_DIR/run.sh"
sudo chmod +x "$SOURCE_DIR/start-systemd.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 sudo tee /etc/systemd/system/${SERVICE_NAME}.service > /dev/null <<EOF
[Unit] [Unit]
Description=LinkMaster Node Service Description=LinkMaster Node Service
@@ -770,6 +1353,7 @@ ExecStart=$SOURCE_DIR/start-systemd.sh
Restart=always Restart=always
RestartSec=5 RestartSec=5
Environment="BACKEND_URL=$BACKEND_URL" Environment="BACKEND_URL=$BACKEND_URL"
Environment="$ENV_PATH"
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
@@ -875,11 +1459,18 @@ EOF
# 调用心跳API获取节点信息 # 调用心跳API获取节点信息
echo -e "${BLUE}发送心跳请求获取节点信息...${NC}" 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" \ -H "Content-Type: application/x-www-form-urlencoded" \
-d "type=pingServer" 2>&1) -d "type=pingServer" 2>&1)
CURL_EXIT_CODE=$?
set -e # 重新启用错误退出
if [ $? -eq 0 ]; then if [ $CURL_EXIT_CODE -eq 0 ]; then
# 尝试解析JSON响应 # 尝试解析JSON响应
NODE_ID=$(echo "$RESPONSE" | grep -o '"node_id":[0-9]*' | grep -o '[0-9]*' | head -1) 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) NODE_IP=$(echo "$RESPONSE" | grep -o '"node_ip":"[^"]*"' | cut -d'"' -f4 | head -1)
@@ -917,8 +1508,10 @@ EOF
echo -e "${YELLOW} 响应: ${RESPONSE}${NC}" echo -e "${YELLOW} 响应: ${RESPONSE}${NC}"
fi fi
else else
echo -e "${YELLOW}⚠ 心跳请求失败,将在服务启动时重试${NC}" echo -e "${YELLOW}⚠ 心跳请求失败 (退出码: ${CURL_EXIT_CODE}),将在服务启动时重试${NC}"
echo -e "${YELLOW} 错误: ${RESPONSE}${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 fi
# 设置配置文件权限 # 设置配置文件权限
@@ -929,18 +1522,52 @@ EOF
start_service() { start_service() {
echo -e "${BLUE}启动服务...${NC}" 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 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}" echo -e "${GREEN}✓ 服务启动成功${NC}"
else else
echo -e "${RED}✗ 服务启动失败${NC}" 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 exit 1
fi fi
} }
@@ -998,23 +1625,52 @@ main() {
echo -e "${GREEN} LinkMaster 节点端安装程序${NC}" echo -e "${GREEN} LinkMaster 节点端安装程序${NC}"
echo -e "${GREEN}========================================${NC}" echo -e "${GREEN}========================================${NC}"
echo "" echo ""
echo -e "${BLUE}后端地址: ${BACKEND_URL}${NC}"
echo ""
echo -e "${BLUE}[1/11] 检测系统类型...${NC}"
detect_system detect_system
# 检查是否已安装,如果已安装则先卸载 # 检查是否已安装,如果已安装则先卸载
if check_installed; then if check_installed; then
echo -e "${BLUE}[2/11] 卸载已存在的服务...${NC}"
uninstall_service uninstall_service
else
echo -e "${BLUE}[2/11] 检查已安装服务...${NC}"
echo -e "${GREEN}✓ 未检测到已安装的服务${NC}"
fi fi
# 检测并配置最快的镜像源(在安装依赖之前) echo -e "${BLUE}[3/11] 检测并配置镜像源...${NC}"
detect_fastest_mirror detect_fastest_mirror
echo -e "${BLUE}[4/11] 安装系统依赖...${NC}"
install_dependencies install_dependencies
echo -e "${BLUE}[5/11] 配置时间同步...${NC}"
sync_time
# 优先尝试从 Releases 下载二进制文件
echo -e "${BLUE}[6/11] 下载或编译二进制文件...${NC}"
if ! download_binary_from_releases; then
echo -e "${BLUE}从 Releases 下载失败,开始从源码编译...${NC}"
build_from_source build_from_source
else
echo -e "${GREEN}✓ 使用预编译二进制文件,跳过编译步骤${NC}"
fi
echo -e "${BLUE}[7/11] 创建 systemd 服务...${NC}"
create_service create_service
echo -e "${BLUE}[8/11] 配置防火墙规则...${NC}"
configure_firewall configure_firewall
echo -e "${BLUE}[9/11] 登记节点到后端服务器...${NC}"
register_node register_node
echo -e "${BLUE}[10/11] 启动服务...${NC}"
start_service start_service
echo -e "${BLUE}[11/11] 验证安装...${NC}"
verify_installation verify_installation
echo "" echo ""

View File

@@ -21,6 +21,11 @@ type Config struct {
Interval int `yaml:"interval"` // 心跳间隔(秒) Interval int `yaml:"interval"` // 心跳间隔(秒)
} `yaml:"heartbeat"` } `yaml:"heartbeat"`
Log struct {
File string `yaml:"file"` // 日志文件路径(空则输出到标准错误)
Level string `yaml:"level"` // 日志级别debug, info, warn, error默认: info
} `yaml:"log"`
Debug bool `yaml:"debug"` Debug bool `yaml:"debug"`
// 节点信息(通过心跳获取并持久化) // 节点信息(通过心跳获取并持久化)
@@ -42,12 +47,13 @@ func Load() (*Config, error) {
cfg.Heartbeat.Interval = 60 cfg.Heartbeat.Interval = 60
cfg.Debug = false cfg.Debug = false
// 从环境变量读取后端URL // 默认日志配置
backendURL := os.Getenv("BACKEND_URL") logFile := os.Getenv("LOG_FILE")
if backendURL == "" { if logFile == "" {
backendURL = "http://localhost:8080" logFile = "node.log"
} }
cfg.Backend.URL = backendURL cfg.Log.File = logFile
cfg.Log.Level = "info"
// 尝试从配置文件读取 // 尝试从配置文件读取
configPath := os.Getenv("CONFIG_PATH") 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 return cfg, nil
} }
@@ -102,4 +132,3 @@ func GetConfigPath() string {
} }
return configPath return configPath
} }

View File

@@ -28,14 +28,47 @@ type TCPingTask struct {
} }
func NewTCPingTask(taskID, target string, interval, maxDuration time.Duration) (*TCPingTask, error) { func NewTCPingTask(taskID, target string, interval, maxDuration time.Duration) (*TCPingTask, error) {
// 解析host:port // 解析host:port如果没有端口则默认80
parts := strings.Split(target, ":") var host string
if len(parts) != 2 { var portStr string
return nil, fmt.Errorf("无效的target格式需要 host:port") 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] var err error
port, err := strconv.Atoi(parts[1]) port, err = strconv.Atoi(portStr)
if err != nil { if err != nil {
return nil, fmt.Errorf("无效的端口: %v", err) return nil, fmt.Errorf("无效的端口: %v", err)
} }
@@ -185,4 +218,3 @@ func (t *TCPingTask) executeTCPing() map[string]interface{} {
"ip": targetIP, "ip": targetIP,
} }
} }

View File

@@ -8,6 +8,7 @@ import (
"io" "io"
"net" "net"
"net/http" "net/http"
"os"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -18,6 +19,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap" "go.uber.org/zap"
"go.uber.org/zap/zapcore"
) )
var continuousTasks = make(map[string]*ContinuousTask) var continuousTasks = make(map[string]*ContinuousTask)
@@ -46,7 +48,52 @@ const (
func InitContinuousHandler(cfg *config.Config) { func InitContinuousHandler(cfg *config.Config) {
backendURL = cfg.Backend.URL 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 { type ContinuousTask struct {
@@ -160,7 +207,15 @@ func HandleContinuousStop(c *gin.Context) {
if task.tcpingTask != nil { if task.tcpingTask != nil {
task.tcpingTask.Stop() task.tcpingTask.Stop()
} }
// 关闭停止通道
select {
case <-task.StopCh:
// 已经关闭
default:
close(task.StopCh) close(task.StopCh)
}
delete(continuousTasks, req.TaskID) delete(continuousTasks, req.TaskID)
} }
taskMutex.Unlock() taskMutex.Unlock()
@@ -170,6 +225,17 @@ func HandleContinuousStop(c *gin.Context) {
return 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": "任务已停止"}) c.JSON(http.StatusOK, gin.H{"message": "任务已停止"})
} }
@@ -237,7 +303,8 @@ func pushResultToBackend(taskID string, result map[string]interface{}) {
logger.Warn("节点ID未获取跳过推送结果", logger.Warn("节点ID未获取跳过推送结果",
zap.String("task_id", taskID), zap.String("task_id", taskID),
zap.String("node_ip", nodeIP), zap.String("node_ip", nodeIP),
zap.String("hint", "等待心跳返回node_id后再推送")) zap.String("hint", "等待心跳返回node_id后再推送"),
zap.Any("result", result))
return return
} }
@@ -246,10 +313,18 @@ func pushResultToBackend(taskID string, result map[string]interface{}) {
logger.Warn("节点IP未获取跳过推送结果", logger.Warn("节点IP未获取跳过推送结果",
zap.String("task_id", taskID), zap.String("task_id", taskID),
zap.Uint("node_id", nodeID), zap.Uint("node_id", nodeID),
zap.String("hint", "等待心跳返回node_ip后再推送")) zap.String("hint", "等待心跳返回node_ip后再推送"),
zap.Any("result", result))
return 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) addToPushBuffer(taskID, nodeID, nodeIP, result)
} }
@@ -269,21 +344,34 @@ func addToPushBuffer(taskID string, nodeID uint, nodeIP string, result map[strin
bufferMutex.Unlock() bufferMutex.Unlock()
buffer.mu.Lock() buffer.mu.Lock()
defer buffer.mu.Unlock()
// 添加结果到缓冲 // 添加结果到缓冲
buffer.results = append(buffer.results, result) buffer.results = append(buffer.results, result)
// 如果缓冲已满,立即推送 // 如果缓冲已满,立即推送
shouldFlush := len(buffer.results) >= batchPushMaxSize shouldFlush := len(buffer.results) >= batchPushMaxSize
buffer.mu.Unlock()
if shouldFlush { 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 { if buffer.pushTimer == nil {
@@ -291,6 +379,8 @@ func addToPushBuffer(taskID string, nodeID uint, nodeIP string, result map[strin
flushPushBuffer(taskID, nodeID, nodeIP) flushPushBuffer(taskID, nodeID, nodeIP)
}) })
} }
buffer.mu.Unlock()
} }
// flushPushBuffer 刷新并推送缓冲中的结果 // flushPushBuffer 刷新并推送缓冲中的结果
@@ -362,13 +452,21 @@ func pushSingleResult(taskID string, nodeID uint, nodeIP string, result map[stri
jsonData, err := json.Marshal(data) jsonData, err := json.Marshal(data)
if err != nil { 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 return
} }
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil { 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 return
} }
@@ -380,7 +478,9 @@ func pushSingleResult(taskID string, nodeID uint, nodeIP string, result map[stri
logger.Warn("推送结果失败,继续运行", logger.Warn("推送结果失败,继续运行",
zap.Error(err), zap.Error(err),
zap.String("task_id", taskID), zap.String("task_id", taskID),
zap.String("url", url)) zap.String("url", url),
zap.Uint("node_id", nodeID),
zap.String("node_ip", nodeIP))
// 推送失败不停止任务,继续运行 // 推送失败不停止任务,继续运行
return return
} }
@@ -394,7 +494,9 @@ func pushSingleResult(taskID string, nodeID uint, nodeIP string, result map[stri
if containsTaskNotFoundError(bodyStr) { if containsTaskNotFoundError(bodyStr) {
logger.Warn("后端任务不存在,停止节点端任务", logger.Warn("后端任务不存在,停止节点端任务",
zap.String("task_id", taskID), 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) stopTaskByTaskID(taskID)
return return
@@ -404,12 +506,18 @@ func pushSingleResult(taskID string, nodeID uint, nodeIP string, result map[stri
zap.Int("status", resp.StatusCode), zap.Int("status", resp.StatusCode),
zap.String("task_id", taskID), zap.String("task_id", taskID),
zap.String("url", url), zap.String("url", url),
zap.String("response", bodyStr)) zap.String("response", bodyStr),
zap.Uint("node_id", nodeID),
zap.String("node_ip", nodeIP))
// 其他错误不停止任务,继续运行 // 其他错误不停止任务,继续运行
return 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 检查响应中是否包含任务不存在的错误 // containsTaskNotFoundError 检查响应中是否包含任务不存在的错误
@@ -522,23 +630,20 @@ func StartTaskCleanup() {
for range ticker.C { for range ticker.C {
now := time.Now() now := time.Now()
taskMutex.Lock() taskMutex.Lock()
var tasksToDelete []string
for taskID, task := range continuousTasks { for taskID, task := range continuousTasks {
shouldDelete := false
// 检查最大运行时长 // 检查最大运行时长
if now.Sub(task.StartTime) > task.MaxDuration { if now.Sub(task.StartTime) > task.MaxDuration {
logger.Info("任务达到最大运行时长,自动停止", zap.String("task_id", taskID)) logger.Info("任务达到最大运行时长,自动停止", zap.String("task_id", taskID))
task.IsRunning = false shouldDelete = true
if task.pingTask != nil { } else if now.Sub(task.LastRequest) > 30*time.Minute {
task.pingTask.Stop()
}
if task.tcpingTask != nil {
task.tcpingTask.Stop()
}
delete(continuousTasks, taskID)
continue
}
// 检查无客户端连接30分钟无请求 // 检查无客户端连接30分钟无请求
if now.Sub(task.LastRequest) > 30*time.Minute {
logger.Info("任务无客户端连接,自动停止", zap.String("task_id", taskID)) logger.Info("任务无客户端连接,自动停止", zap.String("task_id", taskID))
shouldDelete = true
}
if shouldDelete {
task.IsRunning = false task.IsRunning = false
if task.pingTask != nil { if task.pingTask != nil {
task.pingTask.Stop() task.pingTask.Stop()
@@ -546,10 +651,41 @@ func StartTaskCleanup() {
if task.tcpingTask != nil { if task.tcpingTask != nil {
task.tcpingTask.Stop() task.tcpingTask.Stop()
} }
delete(continuousTasks, taskID)
// 关闭停止通道
select {
case <-task.StopCh:
// 已经关闭
default:
close(task.StopCh)
}
tasksToDelete = append(tasksToDelete, taskID)
} }
} }
taskMutex.Unlock() 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

@@ -114,7 +114,30 @@ func handleGet(c *gin.Context, urlStr string, params map[string]interface{}) {
seq = seqVal seq = seqVal
} }
// 解析URL // 清理URL:去除多余的空格和重复的协议前缀
urlStr = strings.TrimSpace(urlStr)
// 如果URL中包含多个 http:// 或 https://,只保留第一个
if strings.Contains(urlStr, "http://") {
// 找到第一个 http:// 的位置
firstHttp := strings.Index(urlStr, "http://")
if firstHttp > 0 {
// 如果 http:// 不在开头,说明前面有内容,需要清理
urlStr = urlStr[firstHttp:]
}
// 移除后续重复的 http://
urlStr = strings.Replace(urlStr, "http://http://", "http://", -1)
urlStr = strings.Replace(urlStr, "http://https://", "https://", -1)
}
if strings.Contains(urlStr, "https://") {
firstHttps := strings.Index(urlStr, "https://")
if firstHttps > 0 {
urlStr = urlStr[firstHttps:]
}
urlStr = strings.Replace(urlStr, "https://https://", "https://", -1)
urlStr = strings.Replace(urlStr, "https://http://", "http://", -1)
}
// 如果URL没有协议前缀添加 http://
if !strings.HasPrefix(urlStr, "http://") && !strings.HasPrefix(urlStr, "https://") { if !strings.HasPrefix(urlStr, "http://") && !strings.HasPrefix(urlStr, "https://") {
urlStr = "http://" + urlStr urlStr = "http://" + urlStr
} }
@@ -145,11 +168,8 @@ func handleGet(c *gin.Context, urlStr string, params map[string]interface{}) {
Transport: timingTransport, Transport: timingTransport,
Timeout: 15 * time.Second, Timeout: 15 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error { CheckRedirect: func(req *http.Request, via []*http.Request) error {
// 跟随重定向,最多20次 // 跟随重定向,返回第一个状态码和 header
if len(via) >= 20 { return http.ErrUseLastResponse
return fmt.Errorf("重定向次数过多")
}
return nil
}, },
} }
@@ -181,8 +201,11 @@ func handleGet(c *gin.Context, urlStr string, params map[string]interface{}) {
// 执行请求 // 执行请求
startTime := time.Now() startTime := time.Now()
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil {
// 错误处理 // 处理重定向错误:当 CheckRedirect 返回 ErrUseLastResponse 时,
// client.Do 会返回响应和错误,但响应仍然有效(包含重定向状态码和 header
if err != nil && resp == nil {
// 真正的错误,没有响应
errMsg := err.Error() errMsg := err.Error()
if strings.Contains(errMsg, "no such host") { if strings.Contains(errMsg, "no such host") {
result["ip"] = "域名无法解析" result["ip"] = "域名无法解析"
@@ -194,6 +217,7 @@ func handleGet(c *gin.Context, urlStr string, params map[string]interface{}) {
result["ip"] = "访问失败" result["ip"] = "访问失败"
} }
result["error"] = errMsg result["error"] = errMsg
result["statuscode"] = 0
result["totaltime"] = "*" result["totaltime"] = "*"
result["downtime"] = "*" result["downtime"] = "*"
result["downsize"] = "*" result["downsize"] = "*"
@@ -204,7 +228,25 @@ func handleGet(c *gin.Context, urlStr string, params map[string]interface{}) {
c.JSON(200, result) c.JSON(200, result)
return return
} }
// 如果有响应(包括重定向响应),继续处理
if resp != nil {
defer resp.Body.Close() defer resp.Body.Close()
} else {
// 没有响应也没有错误,不应该发生
result["error"] = "未知错误"
result["ip"] = "访问失败"
result["statuscode"] = 0
result["totaltime"] = "*"
result["downtime"] = "*"
result["downsize"] = "*"
result["downspeed"] = "*"
result["firstbytetime"] = "*"
result["conntime"] = "*"
result["size"] = "*"
c.JSON(200, result)
return
}
// 获取时间信息 // 获取时间信息
timingTransport.mu.Lock() timingTransport.mu.Lock()
@@ -237,7 +279,7 @@ func handleGet(c *gin.Context, urlStr string, params map[string]interface{}) {
bodyReader := io.LimitReader(resp.Body, 1024*1024) // 限制1MB bodyReader := io.LimitReader(resp.Body, 1024*1024) // 限制1MB
bodyStartTime := time.Now() bodyStartTime := time.Now()
body, err := io.ReadAll(bodyReader) body, err := io.ReadAll(bodyReader)
bodyReadTime := time.Now().Sub(bodyStartTime) bodyReadTime := time.Since(bodyStartTime)
if err != nil && err != io.EOF { if err != nil && err != io.EOF {
result["error"] = err.Error() result["error"] = err.Error()
} }
@@ -296,7 +338,30 @@ func handlePost(c *gin.Context, urlStr string, params map[string]interface{}) {
seq = seqVal seq = seqVal
} }
// 解析URL // 清理URL:去除多余的空格和重复的协议前缀
urlStr = strings.TrimSpace(urlStr)
// 如果URL中包含多个 http:// 或 https://,只保留第一个
if strings.Contains(urlStr, "http://") {
// 找到第一个 http:// 的位置
firstHttp := strings.Index(urlStr, "http://")
if firstHttp > 0 {
// 如果 http:// 不在开头,说明前面有内容,需要清理
urlStr = urlStr[firstHttp:]
}
// 移除后续重复的 http://
urlStr = strings.Replace(urlStr, "http://http://", "http://", -1)
urlStr = strings.Replace(urlStr, "http://https://", "https://", -1)
}
if strings.Contains(urlStr, "https://") {
firstHttps := strings.Index(urlStr, "https://")
if firstHttps > 0 {
urlStr = urlStr[firstHttps:]
}
urlStr = strings.Replace(urlStr, "https://https://", "https://", -1)
urlStr = strings.Replace(urlStr, "https://http://", "http://", -1)
}
// 如果URL没有协议前缀添加 http://
if !strings.HasPrefix(urlStr, "http://") && !strings.HasPrefix(urlStr, "https://") { if !strings.HasPrefix(urlStr, "http://") && !strings.HasPrefix(urlStr, "https://") {
urlStr = "http://" + urlStr urlStr = "http://" + urlStr
} }
@@ -333,10 +398,8 @@ func handlePost(c *gin.Context, urlStr string, params map[string]interface{}) {
Transport: timingTransport, Transport: timingTransport,
Timeout: 15 * time.Second, Timeout: 15 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error { CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 20 { // 不跟随重定向,返回第一个状态码和 header
return fmt.Errorf("重定向次数过多") return http.ErrUseLastResponse
}
return nil
}, },
} }
@@ -363,7 +426,11 @@ func handlePost(c *gin.Context, urlStr string, params map[string]interface{}) {
// 执行请求 // 执行请求
startTime := time.Now() startTime := time.Now()
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil {
// 处理重定向错误:当 CheckRedirect 返回 ErrUseLastResponse 时,
// client.Do 会返回响应和错误,但响应仍然有效(包含重定向状态码和 header
if err != nil && resp == nil {
// 真正的错误,没有响应
errMsg := err.Error() errMsg := err.Error()
if strings.Contains(errMsg, "no such host") { if strings.Contains(errMsg, "no such host") {
result["ip"] = "域名无法解析" result["ip"] = "域名无法解析"
@@ -375,6 +442,7 @@ func handlePost(c *gin.Context, urlStr string, params map[string]interface{}) {
result["ip"] = "访问失败" result["ip"] = "访问失败"
} }
result["error"] = errMsg result["error"] = errMsg
result["statuscode"] = 0
result["totaltime"] = "*" result["totaltime"] = "*"
result["downtime"] = "*" result["downtime"] = "*"
result["downsize"] = "*" result["downsize"] = "*"
@@ -385,7 +453,25 @@ func handlePost(c *gin.Context, urlStr string, params map[string]interface{}) {
c.JSON(200, result) c.JSON(200, result)
return return
} }
// 如果有响应(包括重定向响应),继续处理
if resp != nil {
defer resp.Body.Close() defer resp.Body.Close()
} else {
// 没有响应也没有错误,不应该发生
result["error"] = "未知错误"
result["ip"] = "访问失败"
result["statuscode"] = 0
result["totaltime"] = "*"
result["downtime"] = "*"
result["downsize"] = "*"
result["downspeed"] = "*"
result["firstbytetime"] = "*"
result["conntime"] = "*"
result["size"] = "*"
c.JSON(200, result)
return
}
// 获取时间信息 // 获取时间信息
timingTransport.mu.Lock() timingTransport.mu.Lock()

View File

@@ -16,21 +16,53 @@ func handleTCPing(c *gin.Context, url string, params map[string]interface{}) {
seq = seqVal seq = seqVal
} }
// 解析host:port格式 // 解析host:port格式如果没有端口则默认80
parts := strings.Split(url, ":") var host string
if len(parts) != 2 { 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{ c.JSON(200, gin.H{
"seq": seq, "seq": seq,
"type": "ceTCPing", "type": "ceTCPing",
"url": url, "url": url,
"error": "格式错误,需要 host:port", "error": "格式错误,IPv6地址格式应为 [host]:port",
}) })
return 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] var err error
portStr := parts[1] port, err = strconv.Atoi(portStr)
port, err := strconv.Atoi(portStr)
if err != nil { if err != nil {
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"seq": seq, "seq": seq,
@@ -160,4 +192,3 @@ func handleTCPing(c *gin.Context, url string, params map[string]interface{}) {
c.JSON(200, result) c.JSON(200, result)
} }

View File

@@ -7,6 +7,8 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"os"
"sync" "sync"
"time" "time"
@@ -88,21 +90,31 @@ func NewReporter(cfg *config.Config) *Reporter {
} }
func (r *Reporter) Start(ctx context.Context) { func (r *Reporter) Start(ctx context.Context) {
ticker := time.NewTicker(time.Duration(r.cfg.Heartbeat.Interval) * time.Second)
defer ticker.Stop()
// 立即发送一次心跳 // 立即发送一次心跳
r.sendHeartbeat() r.sendHeartbeat()
for { for {
// 计算到下一分钟第1秒的时间
now := time.Now()
nextMinute := now.Truncate(time.Minute).Add(time.Minute)
nextHeartbeatTime := nextMinute.Add(1 * time.Second)
durationUntilNext := nextHeartbeatTime.Sub(now)
// 等待到下一分钟的第1秒
timer := time.NewTimer(durationUntilNext)
select { select {
case <-ctx.Done(): case <-ctx.Done():
timer.Stop()
return return
case <-r.stopCh: case <-r.stopCh:
timer.Stop()
return return
case <-ticker.C: case <-timer.C:
// 在每分钟的第1秒发送心跳
r.sendHeartbeat() r.sendHeartbeat()
} }
timer.Stop()
} }
} }
@@ -110,10 +122,25 @@ func (r *Reporter) Stop() {
close(r.stopCh) 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 注册节点(安装时或首次启动时调用) // RegisterNode 注册节点(安装时或首次启动时调用)
func RegisterNode(cfg *config.Config) error { func RegisterNode(cfg *config.Config) error {
url := fmt.Sprintf("%s/api/node/heartbeat", cfg.Backend.URL) 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 { if err != nil {
return fmt.Errorf("创建心跳请求失败: %w", err) return fmt.Errorf("创建心跳请求失败: %w", err)
} }
@@ -123,7 +150,7 @@ func RegisterNode(cfg *config.Config) error {
client := &http.Client{Timeout: 10 * time.Second} client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return fmt.Errorf("发送心跳失败: %w", err) return fmt.Errorf("发送心跳失败 (URL: %s): %w", url, err)
} }
defer resp.Body.Close() defer resp.Body.Close()
@@ -173,16 +200,27 @@ func RegisterNode(cfg *config.Config) error {
return nil 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() { func (r *Reporter) sendHeartbeat() {
// 发送心跳使用Form格式兼容旧接口 // 发送心跳使用Form格式兼容旧接口
url := fmt.Sprintf("%s/api/node/heartbeat", r.cfg.Backend.URL) 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 { if err != nil {
r.logger.Error("创建心跳请求失败", zap.Error(err)) r.logger.Error("创建心跳请求失败", zap.Error(err))
return return
@@ -192,7 +230,9 @@ func (r *Reporter) sendHeartbeat() {
resp, err := r.client.Do(req) resp, err := r.client.Do(req)
if err != nil { if err != nil {
r.logger.Warn("发送心跳失败", zap.Error(err)) r.logger.Warn("发送心跳失败",
zap.String("url", url),
zap.Error(err))
return return
} }
defer resp.Body.Close() defer resp.Body.Close()
@@ -260,7 +300,21 @@ func (r *Reporter) sendHeartbeat() {
} }
r.logger.Debug("心跳发送成功") r.logger.Debug("心跳发送成功")
} else { } 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}启动节点端服务...${NC}"
echo -e "${BLUE}后端地址: $BACKEND_URL${NC}" echo -e "${BLUE}后端地址: $BACKEND_URL${NC}"
echo -e "${BLUE}日志文件: $LOG_FILE${NC}"
# 设置环境变量 # 设置环境变量
export BACKEND_URL="$BACKEND_URL" 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=$! NEW_PID=$!
# 保存PID # 保存PID

View File

@@ -15,11 +15,38 @@ cd "$SCRIPT_DIR"
BINARY_NAME="agent" BINARY_NAME="agent"
BACKEND_URL="${BACKEND_URL:-http://localhost:8080}" 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() { update_and_build() {
# 如果二进制文件已存在且有效,跳过编译
if check_binary; then
echo "二进制文件已存在,跳过编译步骤"
return 0
fi
# 检查是否在 Git 仓库中 # 检查是否在 Git 仓库中
if [ ! -d ".git" ]; then if [ ! -d ".git" ]; then
return 0 echo "错误: 不在 Git 仓库中,无法更新代码" >&2
return 1
fi fi
# 配置 Git safe.directory解决所有权问题 # 配置 Git safe.directory解决所有权问题
@@ -31,14 +58,41 @@ update_and_build() {
echo "代码更新完成" echo "代码更新完成"
fi 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 环境 # 检查 Go 环境
if ! command -v go > /dev/null 2>&1; then if ! command -v go > /dev/null 2>&1; then
echo "错误: 未找到 Go 环境,无法编译" >&2 echo "错误: 未找到 Go 环境,无法编译" >&2
echo "PATH: $PATH" >&2
echo "请确保 Go 已安装: /usr/local/go/bin 或系统 PATH 中包含 go 命令" >&2
exit 1 exit 1
fi fi
# 更新依赖 # 检查是否有 vendor 目录
go mod download 2>&1 > /dev/null || true 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) ARCH=$(uname -m)
@@ -54,21 +108,33 @@ update_and_build() {
;; ;;
esac 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 if [ -f "$BINARY_NAME" ] && [ -s "$BINARY_NAME" ]; then
chmod +x "$BINARY_NAME" chmod +x "$BINARY_NAME"
echo "编译成功"
return 0
else else
echo "错误: 编译失败,未生成二进制文件" >&2 echo "错误: 编译失败,未生成二进制文件" >&2
exit 1 return 1
fi fi
else else
echo "错误: 编译失败" >&2 echo "错误: 编译失败" >&2
exit 1 return 1
fi fi
} }
# 拉取最新源码并编译 # 检查并更新/编译
update_and_build if ! check_binary; then
update_and_build
fi
# 设置环境变量 # 设置环境变量
export BACKEND_URL="$BACKEND_URL" export BACKEND_URL="$BACKEND_URL"

53
time.sh Normal file
View File

@@ -0,0 +1,53 @@
#!/bin/bash
set -e
echo "=== CentOS 7 时间同步脚本开始 ==="
# 1. 检查是否 root
if [ "$(id -u)" -ne 0 ]; then
echo "请使用 root 用户执行"
exit 1
fi
# 2. 设置时区
echo "[1/6] 设置时区为 Asia/Shanghai"
timedatectl set-timezone Asia/Shanghai
# 3. 安装 chrony
echo "[2/6] 安装 chrony"
if ! rpm -q chrony &>/dev/null; then
yum install -y chrony
else
echo "chrony 已安装,跳过"
fi
# 4. 配置 NTP 服务器
echo "[3/6] 配置 NTP 服务器"
CONF="/etc/chrony.conf"
sed -i 's/^server /#server /g' "$CONF"
grep -q "ntp.aliyun.com" "$CONF" || cat >> "$CONF" <<EOF
# China NTP servers
server ntp.aliyun.com iburst
server ntp.tencent.com iburst
server ntp1.aliyun.com iburst
EOF
# 5. 启动并启用 chronyd
echo "[4/6] 启动 chronyd"
systemctl enable chronyd --now
# 6. 立即强制同步
echo "[5/6] 强制同步系统时间"
chronyc -a makestep
# 7. 写入硬件时间
echo "[6/6] 写入硬件时钟"
hwclock --systohc
echo "=== 时间同步完成 ==="
echo
timedatectl status

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`. 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 - 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 [#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. This release fixes two bugs and adds some enhancements to zap's testing helpers.
It is fully backward-compatible. It is fully backward-compatible.

4
version.json Normal file
View File

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