Compare commits
No commits in common. "main" and "feature/process-monitor" have entirely different histories.
main
...
feature/pr
|
|
@ -33,14 +33,4 @@ config.local.yaml
|
||||||
.env.local
|
.env.local
|
||||||
|
|
||||||
# 系统文件
|
# 系统文件
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# 需求文件
|
|
||||||
todo.txt
|
|
||||||
|
|
||||||
# 测试文件
|
|
||||||
test/
|
|
||||||
|
|
||||||
# 编译文件
|
|
||||||
sysmonitord
|
|
||||||
fixroad.md
|
|
||||||
132
README.md
132
README.md
|
|
@ -1,131 +1,19 @@
|
||||||
# SysMonitord - 轻量级 Linux 安全终端探针
|
# sysmonitord
|
||||||
|
|
||||||
`SysMonitord` 是一个基于 Go 语言开发的轻量级主机安全监控代理(HIDS/EDR Agent)。它采用**白名单机制**来监控文件系统的完整性,结合实时 SSH 审计与系统状态采集,通过 WebSocket 与中心服务器保持实时通讯。
|
重写VigilGoGuard,更符合工程规范
|
||||||
|
|
||||||
设计目标:**低资源占用、高灵敏度、配置动态下发。**
|
多线程支持、更加模块化,以及...
|
||||||
|
|
||||||
## ✨ 核心特性
|
## ToDo
|
||||||
|
|
||||||
- **🛡️ 文件完整性防护 (FIM)**
|
1. 用户登录监控 (ssh, su, sudo)
|
||||||
- **白名单引擎**:基于文件 Hash (SHA256) 的白名单校验,拒绝未授权的二进制文件。
|
|
||||||
- **双模感知**:
|
|
||||||
- **实时监听 (Watcher)**:基于 `inotify`,毫秒级感知文件创建、修改、删除。
|
|
||||||
- **主动巡逻 (Scanner)**:周期性全盘扫描,具备 **CPU 负载避让** 机制(CPU > 50% 自动休眠),防止影响业务。
|
|
||||||
|
|
||||||
- **🔌 动态配置与通讯**
|
2. 进程监控 (异常进程、资源占用)
|
||||||
- **远程配置**:启动时通过 HTTP 拉取最新的官方策略(白名单)和用户配置。
|
|
||||||
- **双通道长连接**:
|
|
||||||
- **控制通道**:与中心服务器保持 WebSocket 连接,上报状态和接收指令。
|
|
||||||
- **审计通道**:独立的 WebSocket 连接用于传输敏感日志(如 SSH 登录记录)。
|
|
||||||
- **断线重连**:内置指数退避算法,网络波动自动恢复。
|
|
||||||
|
|
||||||
- **👁️ 行为审计**
|
3. 文件监控 (关键文件变更)
|
||||||
- **SSH 监控**:实时分析 `/var/log/secure` 或 `journald`,捕获异常登录尝试。
|
|
||||||
- **资源监控**:实时采集 CPU、内存、磁盘 IO 等系统指标。
|
|
||||||
|
|
||||||
## 🛠️ 架构概览
|
4. 网络连接监控
|
||||||
|
|
||||||
```mermaid
|
5. 系统资源监控 (CPU、内存、磁盘)
|
||||||
graph TD
|
|
||||||
A[启动 Start] --> B[下载配置 HTTP]
|
|
||||||
B --> C[初始化白名单引擎 Whitelist Manager]
|
|
||||||
C --> D{启动子系统}
|
|
||||||
D --> E[网络客户端 Network Client]
|
|
||||||
D --> F[感知系统 Sensor]
|
|
||||||
D --> G[监控系统 Monitor]
|
|
||||||
|
|
||||||
E --> |WebSocket| H[中心服务器]
|
6. 安全事件报警
|
||||||
E --> |WebSocket| I[审计服务器]
|
|
||||||
|
|
||||||
F --> |inotify| J[实时监听 Watcher]
|
|
||||||
F --> |Walk| K[周期扫描 Scanner]
|
|
||||||
|
|
||||||
G --> L[SSH 分析器]
|
|
||||||
G --> M[性能采集器]
|
|
||||||
|
|
||||||
J & K & L & M --> |Alert/Metrics| E
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🚀 快速开始
|
|
||||||
|
|
||||||
### 环境要求
|
|
||||||
- **操作系统**: Linux (依赖 `inotify` 和 `/proc` 文件系统)
|
|
||||||
- **权限**: `root` (用于读取系统日志及遍历关键目录)
|
|
||||||
- **构建环境**: Go 1.18+
|
|
||||||
|
|
||||||
### 1. 编译项目
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 下载依赖
|
|
||||||
go mod tidy
|
|
||||||
|
|
||||||
# 编译二进制文件
|
|
||||||
go build -o sysmonitord cmd/sysmonitord/main.go
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 运行
|
|
||||||
|
|
||||||
由于涉及系统底层文件监控,建议使用 `sudo` 运行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo ./sysmonitord
|
|
||||||
```
|
|
||||||
|
|
||||||
## ⚙️ 配置说明
|
|
||||||
|
|
||||||
程序启动时会请求两个 JSON 配置文件。你需要开发一个简单的 HTTP Server 来提供这些 JSON。
|
|
||||||
|
|
||||||
### 1. 官方配置 (Official Config)
|
|
||||||
用于定义“什么是合法的”。
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"hash_whitelist": {
|
|
||||||
"/usr/bin/ls": "sha256_hash_value_here",
|
|
||||||
"/usr/sbin/nginx": "sha256_hash_value_here"
|
|
||||||
},
|
|
||||||
"ignored_paths": [
|
|
||||||
"/tmp/logs",
|
|
||||||
"/var/cache"
|
|
||||||
],
|
|
||||||
"update_interval": 3600
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 用户配置 (User Config)
|
|
||||||
用于定义运行时行为。
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"audit_server_url": "ws://audit.example.com/ws",
|
|
||||||
"reporting_interval": 60,
|
|
||||||
"alert_threshold": 5
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📂 项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
sysmonitord/
|
|
||||||
├── cmd/
|
|
||||||
│ └── sysmonitord/ # 程序入口
|
|
||||||
│ └── main.go
|
|
||||||
├── internal/
|
|
||||||
│ ├── config/ # 配置结构体定义
|
|
||||||
│ ├── monitor/ # SSH 日志分析与系统资源采集
|
|
||||||
│ ├── network/ # HTTP 加载器与 WebSocket 客户端
|
|
||||||
│ ├── scanner/ # 文件扫描 (Scanner) 与实时监听 (Watcher)
|
|
||||||
│ └── whitelist/ # 核心白名单判定逻辑
|
|
||||||
├── go.mod
|
|
||||||
└── README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🗓️ 开发计划 (Roadmap)
|
|
||||||
|
|
||||||
- [x] 基础框架搭建
|
|
||||||
- [x] 核心白名单逻辑实现
|
|
||||||
- [x] 网络通信层 (HTTP/WebSocket)
|
|
||||||
- [x] 文件扫描与实时监控集成
|
|
||||||
- [ ] **后端控制台开发** (接收上报数据并可视化)
|
|
||||||
- [ ] **主动防御能力** (发现恶意文件自动隔离/删除)
|
|
||||||
- [ ] **一键安装脚本** (Systemd 集成)
|
|
||||||
|
|
|
||||||
|
|
@ -1,158 +1,70 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/wuko233/sysmonitord/internal/config"
|
"github.com/wuko233/sysmonitord/internal/config"
|
||||||
"github.com/wuko233/sysmonitord/internal/monitor"
|
"github.com/wuko233/sysmonitord/internal/monitor"
|
||||||
"github.com/wuko233/sysmonitord/internal/network"
|
|
||||||
"github.com/wuko233/sysmonitord/internal/scanner"
|
|
||||||
"github.com/wuko233/sysmonitord/internal/whitelist"
|
|
||||||
"gopkg.in/natefinch/lumberjack.v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
OfficialConfigURL = "http://localhost:8090/api/v1/configs/official.json"
|
|
||||||
UserConfigURL = "http://localhost:8090/api/v1/configs/user.json"
|
|
||||||
CenterServerURL = "ws://localhost:8090/api/v1/ws"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
log.Println("启动sysmonitord...")
|
||||||
|
|
||||||
initLogger()
|
cfg := &config.SSHMonitor{
|
||||||
log.Println("==========================================")
|
|
||||||
log.Print(`
|
|
||||||
_ _ _
|
|
||||||
(_) | | |
|
|
||||||
___ _ _ ___ _ __ ___ ___ _ __ _| |_ ___ _ __ __| |
|
|
||||||
/ __| | | / __| '_ ` + "`" + ` _ \ / _ \| '_ \| | __/ _ \| '__/ _` + "`" + ` |
|
|
||||||
\__ \ |_| \__ \ | | | | | (_) | | | | | || (_) | | | (_| |
|
|
||||||
|___/\__, |___/_| |_| |_|\___/|_| |_|_|\__\___/|_| \__,_|
|
|
||||||
__/ |
|
|
||||||
|___/
|
|
||||||
`)
|
|
||||||
log.Println("==========================================")
|
|
||||||
|
|
||||||
// 加载配置
|
|
||||||
log.Println("[启动流程] 1/6: 下载远程安全策略配置...")
|
|
||||||
cfgLoader := network.NewConfigLoader()
|
|
||||||
|
|
||||||
officialCfg, userCfg, err := cfgLoader.LoadConfigs(network.ConfigUrls{
|
|
||||||
OfficialConfigUrl: OfficialConfigURL,
|
|
||||||
UserConfigUrl: UserConfigURL,
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("[启动错误]下载配置失败: %v", err)
|
|
||||||
log.Println("[启动降级] 继续使用默认空配置...")
|
|
||||||
// os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("[启动流程] 2/6: 初始化白名单判定引擎...")
|
|
||||||
wlManager := whitelist.NewManager()
|
|
||||||
wlManager.UpdateConfig(officialCfg, userCfg)
|
|
||||||
|
|
||||||
log.Println("[启动流程] 3/6: 启动中心服务器连接...")
|
|
||||||
centerClient := network.NewWSClient(network.ClientConfig{
|
|
||||||
ServerURL: CenterServerURL,
|
|
||||||
SendInterval: 1 * time.Second,
|
|
||||||
BufferSize: 1000,
|
|
||||||
})
|
|
||||||
centerClient.Start()
|
|
||||||
|
|
||||||
auditUrl := wlManager.GetAuditServerUrl()
|
|
||||||
if auditUrl == "" {
|
|
||||||
auditUrl = CenterServerURL
|
|
||||||
}
|
|
||||||
auditClient := network.NewWSClient(network.ClientConfig{
|
|
||||||
ServerURL: auditUrl,
|
|
||||||
SendInterval: 1 * time.Second,
|
|
||||||
BufferSize: 1000,
|
|
||||||
})
|
|
||||||
auditClient.Start()
|
|
||||||
|
|
||||||
log.Println("[启动流程] 4/6: 启动文件完整性防护...")
|
|
||||||
|
|
||||||
// 扫盘器
|
|
||||||
sysScanner := scanner.NewScanner(wlManager, centerClient)
|
|
||||||
sysScanner.Start()
|
|
||||||
|
|
||||||
// 监控器
|
|
||||||
sysWatcher, err := scanner.NewWatcher(wlManager, centerClient)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("[启动错误] 初始化监控器失败: %v", err)
|
|
||||||
} else {
|
|
||||||
sysWatcher.Start()
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("[启动流程] 5/6: 启动系统行为监控...")
|
|
||||||
|
|
||||||
// SSH监控
|
|
||||||
sshAlertChan := make(chan monitor.Alert, 100)
|
|
||||||
sshMon := monitor.NewSSHMonitor(&config.SSHMonitor{
|
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
AlertOnRootLogin: true,
|
|
||||||
DisplayOnShell: true,
|
DisplayOnShell: true,
|
||||||
}, sshAlertChan)
|
AlertOnRootLogin: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("加载SSH监控配置: %+v\n", cfg)
|
||||||
|
|
||||||
|
alertChan := make(chan monitor.Alert, 100)
|
||||||
|
|
||||||
|
log.Println("初始化SSH监控器...")
|
||||||
|
sshMonitor := monitor.NewSSHMonitor(cfg, alertChan)
|
||||||
|
|
||||||
|
log.Println("启用告警处理...")
|
||||||
|
go handleAlerts(alertChan)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
for alert := range sshAlertChan {
|
if err := sshMonitor.Start(); err != nil {
|
||||||
packet := network.NewPacket("SSH_ALERT", alert)
|
log.Fatalf("启动SSH监控器失败: %v", err)
|
||||||
auditClient.SendQueue(packet)
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
go func() {
|
time.Sleep(3 * time.Second)
|
||||||
if err := sshMon.Start(); err != nil {
|
|
||||||
log.Printf("[监控错误] SSH监控遇到错误: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// 状态监控
|
log.Println("启动sysmonitord完成.")
|
||||||
metricsChan := make(chan monitor.ServerMetrics, 100)
|
log.Println("sysmonitord正在运行...")
|
||||||
infoMon := monitor.NewInfoMonitor(nil, metricsChan)
|
|
||||||
|
|
||||||
go func() {
|
log.Println("按Ctrl+C退出...")
|
||||||
for metrics := range metricsChan {
|
|
||||||
packet := network.NewPacket("STATUS_UPDATE", metrics)
|
|
||||||
centerClient.SendQueue(packet)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
go infoMon.Start()
|
|
||||||
|
|
||||||
log.Println("[启动流程] 6/6: 系统监控守护进程启动完成!")
|
|
||||||
|
|
||||||
stopChan := make(chan os.Signal, 1)
|
stopChan := make(chan os.Signal, 1)
|
||||||
signal.Notify(stopChan, os.Interrupt, syscall.SIGTERM)
|
|
||||||
<-stopChan
|
<-stopChan
|
||||||
log.Println("[守护进程] 接收到停止信号,正在关闭...")
|
|
||||||
|
|
||||||
if sysWatcher != nil {
|
log.Println("停止SSH监控器...")
|
||||||
sysWatcher.Stop()
|
if err := sshMonitor.Stop(); err != nil {
|
||||||
}
|
log.Fatalf("停止SSH监控器失败: %v", err)
|
||||||
sysScanner.Stop()
|
|
||||||
sshMon.Stop()
|
|
||||||
infoMon.Stop()
|
|
||||||
centerClient.Stop()
|
|
||||||
auditClient.Stop()
|
|
||||||
log.Println("[守护进程] 已成功停止,安全退出程序。")
|
|
||||||
}
|
|
||||||
|
|
||||||
func initLogger() {
|
|
||||||
log.SetOutput(os.Stdout)
|
|
||||||
fileLogger := &lumberjack.Logger{
|
|
||||||
Filename: "/var/log/sysmonitord/sysmonitord.log",
|
|
||||||
MaxSize: 100, // MB
|
|
||||||
MaxBackups: 7,
|
|
||||||
MaxAge: 30, // 天
|
|
||||||
Compress: true,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.SetOutput(io.MultiWriter(os.Stdout, fileLogger))
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
|
log.Println("sysmonitord已退出.")
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleAlerts(alertChan <-chan monitor.Alert) {
|
||||||
|
for alert := range alertChan {
|
||||||
|
log.Printf("[告警] 类型: %s | 级别: %s | 时间: %s | 消息: %s | 数据: %+v\n",
|
||||||
|
alert.Type, alert.Level, alert.Timestamp.Format(time.RFC3339), alert.Message, alert.Data)
|
||||||
|
|
||||||
|
switch alert.Type {
|
||||||
|
case "SSH_ROOT_LOGIN":
|
||||||
|
log.Println("ROOT用户登入")
|
||||||
|
// Todo: 接入发信接口
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
20
go.mod
20
go.mod
|
|
@ -2,22 +2,6 @@ module github.com/wuko233/sysmonitord
|
||||||
|
|
||||||
go 1.24.3
|
go 1.24.3
|
||||||
|
|
||||||
require github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
)
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/ebitengine/purego v0.9.1 // indirect
|
|
||||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
|
||||||
github.com/shirou/gopsutil/v4 v4.25.12
|
|
||||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
|
||||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
|
||||||
)
|
|
||||||
28
go.sum
28
go.sum
|
|
@ -2,31 +2,3 @@ github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pq
|
||||||
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||||
github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo=
|
github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo=
|
||||||
github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU=
|
github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU=
|
||||||
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
|
|
||||||
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
|
||||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
|
||||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
|
||||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
|
||||||
github.com/shirou/gopsutil/v4 v4.25.12 h1:e7PvW/0RmJ8p8vPGJH4jvNkOyLmbkXgXW4m6ZPic6CY=
|
|
||||||
github.com/shirou/gopsutil/v4 v4.25.12/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU=
|
|
||||||
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
|
||||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
|
||||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
|
||||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,5 @@
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
type Configuration struct {
|
|
||||||
Local Localconfig // 本地配置
|
|
||||||
Offical OfficialConfig // 官方配置
|
|
||||||
User UserConfig // 用户自定义配置
|
|
||||||
}
|
|
||||||
|
|
||||||
type Localconfig struct {
|
|
||||||
LogPath string `yaml:"log_path"`
|
|
||||||
CheckInterval time.Duration `yaml:"check_interval"`
|
|
||||||
ServerUrl string `yaml:"server_url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type OfficialConfig struct {
|
|
||||||
WhitelistFiles map[string][]string `yaml:"whitelist_files"`
|
|
||||||
WhitelistProcesses []string `yaml:"whitelist_processes"`
|
|
||||||
IgnoredPaths []string `yaml:"ignored_paths"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserConfig struct {
|
|
||||||
AuditServerUrl string `json:"audit_server_url"` // 审计服务器地址
|
|
||||||
// 用户补充的白名单文件
|
|
||||||
SupplementFiles map[string][]string `json:"supplement_files"`
|
|
||||||
// 用户补充的进程列表
|
|
||||||
// Key: 进程名, Value: 启动指令(如果为空则仅作为白名单,如果不为空则需保活)
|
|
||||||
SupplementProcesses map[string]string `json:"supplement_processes"`
|
|
||||||
IgnoredPaths []string `json:"ignored_paths"`
|
|
||||||
CheckPermPaths []string `json:"check_perm_paths"` // 检查权限的目录
|
|
||||||
|
|
||||||
// 邮件配置
|
|
||||||
EmailConfig EmailConfig `json:"email_config"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type EmailConfig struct {
|
|
||||||
ImapServer string `json:"imap_server"`
|
|
||||||
EmergencyMail []string `json:"emergency_mail"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SSHMonitor struct {
|
type SSHMonitor struct {
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
DisplayOnShell bool `yaml:"display_on_shell"`
|
DisplayOnShell bool `yaml:"display_on_shell"`
|
||||||
|
|
|
||||||
|
|
@ -1,873 +0,0 @@
|
||||||
package monitor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/cpu"
|
|
||||||
"github.com/shirou/gopsutil/v4/disk"
|
|
||||||
"github.com/shirou/gopsutil/v4/host"
|
|
||||||
"github.com/shirou/gopsutil/v4/load"
|
|
||||||
"github.com/shirou/gopsutil/v4/mem"
|
|
||||||
"github.com/shirou/gopsutil/v4/net"
|
|
||||||
"github.com/shirou/gopsutil/v4/process"
|
|
||||||
)
|
|
||||||
|
|
||||||
// InfoMonitor 服务器信息监控器
|
|
||||||
type InfoMonitor struct {
|
|
||||||
config *InfoMonitorConfig
|
|
||||||
logFile *os.File
|
|
||||||
stopChan chan struct{}
|
|
||||||
metricsChan chan ServerMetrics
|
|
||||||
}
|
|
||||||
|
|
||||||
// InfoMonitorConfig 信息监控配置
|
|
||||||
type InfoMonitorConfig struct {
|
|
||||||
Enabled bool `yaml:"enabled"`
|
|
||||||
Interval time.Duration `yaml:"interval"` // 采集间隔
|
|
||||||
LogFilePath string `yaml:"log_file_path"` // 日志文件路径
|
|
||||||
MaxLogSize int64 `yaml:"max_log_size"` // 最大日志大小(字节)
|
|
||||||
LogRetention int `yaml:"log_retention"` // 日志保留天数
|
|
||||||
ProcessLimit int `yaml:"process_limit"` // 显示进程数限制
|
|
||||||
CollectNetwork bool `yaml:"collect_network"` // 是否收集网络信息
|
|
||||||
CollectProcess bool `yaml:"collect_process"` // 是否收集进程信息
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServerMetrics 服务器指标
|
|
||||||
type ServerMetrics struct {
|
|
||||||
Timestamp time.Time `json:"timestamp"`
|
|
||||||
CPU CPUInfo `json:"cpu"`
|
|
||||||
Memory MemoryInfo `json:"memory"`
|
|
||||||
Disk []DiskInfo `json:"disk"`
|
|
||||||
Network NetworkInfo `json:"network"`
|
|
||||||
Load LoadInfo `json:"load"`
|
|
||||||
Processes []ProcessInfo `json:"processes"`
|
|
||||||
Host HostInfo `json:"host"`
|
|
||||||
Runtime RuntimeInfo `json:"runtime"`
|
|
||||||
QuickMetrics QuickMetrics `json:"quick_metrics"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CPUInfo CPU信息
|
|
||||||
type CPUInfo struct {
|
|
||||||
Model string `json:"model"`
|
|
||||||
Cores int `json:"cores"`
|
|
||||||
LogicalCores int `json:"logical_cores"`
|
|
||||||
UsagePercent float64 `json:"usage_percent"`
|
|
||||||
PerCorePercent []float64 `json:"per_core_percent"`
|
|
||||||
Mhz float64 `json:"mhz"`
|
|
||||||
CacheSize int `json:"cache_size"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// MemoryInfo 内存信息
|
|
||||||
type MemoryInfo struct {
|
|
||||||
TotalGB float64 `json:"total_gb"`
|
|
||||||
UsedGB float64 `json:"used_gb"`
|
|
||||||
AvailableGB float64 `json:"available_gb"`
|
|
||||||
UsedPercent float64 `json:"used_percent"`
|
|
||||||
SwapTotalGB float64 `json:"swap_total_gb"`
|
|
||||||
SwapUsedGB float64 `json:"swap_used_gb"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DiskInfo 磁盘信息
|
|
||||||
type DiskInfo struct {
|
|
||||||
Mountpoint string `json:"mountpoint"`
|
|
||||||
Device string `json:"device"`
|
|
||||||
Fstype string `json:"fstype"`
|
|
||||||
TotalGB float64 `json:"total_gb"`
|
|
||||||
UsedGB float64 `json:"used_gb"`
|
|
||||||
FreeGB float64 `json:"free_gb"`
|
|
||||||
UsedPercent float64 `json:"used_percent"`
|
|
||||||
InodesPercent float64 `json:"inodes_percent"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// NetworkInfo 网络信息
|
|
||||||
type NetworkInfo struct {
|
|
||||||
Interfaces []NetworkInterface `json:"interfaces"`
|
|
||||||
TotalRecvMB float64 `json:"total_recv_mb"`
|
|
||||||
TotalSentMB float64 `json:"total_sent_mb"`
|
|
||||||
TCPConnections int `json:"tcp_connections"`
|
|
||||||
EstablishedConn int `json:"established_conn"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// NetworkInterface 网络接口
|
|
||||||
type NetworkInterface struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
HardwareAddr string `json:"hardware_addr"`
|
|
||||||
IPAddresses []string `json:"ip_addresses"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadInfo 负载信息
|
|
||||||
type LoadInfo struct {
|
|
||||||
Load1 float64 `json:"load_1"`
|
|
||||||
Load5 float64 `json:"load_5"`
|
|
||||||
Load15 float64 `json:"load_15"`
|
|
||||||
RelativeLoad1 float64 `json:"relative_load_1"`
|
|
||||||
RelativeLoad5 float64 `json:"relative_load_5"`
|
|
||||||
RelativeLoad15 float64 `json:"relative_load_15"`
|
|
||||||
ProcsRunning int `json:"procs_running"` // 改为 int 类型
|
|
||||||
ProcsTotal int `json:"procs_total"` // 改为 int 类型
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProcessInfo 进程信息
|
|
||||||
type ProcessInfo struct {
|
|
||||||
PID int32 `json:"pid"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Cmdline string `json:"cmdline"`
|
|
||||||
MemoryMB float64 `json:"memory_mb"`
|
|
||||||
CPUPercent float64 `json:"cpu_percent"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// HostInfo 主机信息
|
|
||||||
type HostInfo struct {
|
|
||||||
Hostname string `json:"hostname"`
|
|
||||||
OS string `json:"os"`
|
|
||||||
Platform string `json:"platform"`
|
|
||||||
PlatformVersion string `json:"platform_version"`
|
|
||||||
KernelVersion string `json:"kernel_version"`
|
|
||||||
BootTime time.Time `json:"boot_time"`
|
|
||||||
Uptime string `json:"uptime"`
|
|
||||||
CPUCount uint64 `json:"cpu_count"`
|
|
||||||
Architecture string `json:"architecture"`
|
|
||||||
HostID string `json:"host_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RuntimeInfo 运行时信息
|
|
||||||
type RuntimeInfo struct {
|
|
||||||
GoVersion string `json:"go_version"`
|
|
||||||
GOOS string `json:"goos"`
|
|
||||||
GOARCH string `json:"goarch"`
|
|
||||||
GOROOT string `json:"goroot"`
|
|
||||||
GOMAXPROCS int `json:"gomaxprocs"`
|
|
||||||
NumCPU int `json:"num_cpu"`
|
|
||||||
NumGoroutine int `json:"num_goroutine"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// QuickMetrics 快速指标
|
|
||||||
type QuickMetrics struct {
|
|
||||||
CPUPercent float64 `json:"cpu_percent"`
|
|
||||||
MemoryPercent float64 `json:"memory_percent"`
|
|
||||||
RootDiskPercent float64 `json:"root_disk_percent"`
|
|
||||||
AvailableMemoryGB float64 `json:"available_memory_gb"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewInfoMonitor 创建信息监控器
|
|
||||||
func NewInfoMonitor(cfg *InfoMonitorConfig, metricsChan chan ServerMetrics) *InfoMonitor {
|
|
||||||
if cfg == nil {
|
|
||||||
cfg = &InfoMonitorConfig{
|
|
||||||
Enabled: true,
|
|
||||||
Interval: 30 * time.Second,
|
|
||||||
ProcessLimit: 10,
|
|
||||||
CollectNetwork: true,
|
|
||||||
CollectProcess: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.Interval == 0 {
|
|
||||||
cfg.Interval = 30 * time.Second
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.ProcessLimit == 0 {
|
|
||||||
cfg.ProcessLimit = 10
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.MaxLogSize == 0 {
|
|
||||||
cfg.MaxLogSize = 100 * 1024 * 1024 // 100MB
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.LogFilePath == "" {
|
|
||||||
cfg.LogFilePath = "/var/log/sysmonitord/info_monitor.log"
|
|
||||||
}
|
|
||||||
|
|
||||||
return &InfoMonitor{
|
|
||||||
config: cfg,
|
|
||||||
stopChan: make(chan struct{}),
|
|
||||||
metricsChan: metricsChan,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start 启动信息监控
|
|
||||||
func (m *InfoMonitor) Start() error {
|
|
||||||
log.Println("启动服务器信息监控...")
|
|
||||||
|
|
||||||
// 初始化日志文件
|
|
||||||
if err := m.initLogFile(); err != nil {
|
|
||||||
return fmt.Errorf("初始化日志文件失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 启动监控循环
|
|
||||||
go m.monitorLoop()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop 停止信息监控
|
|
||||||
func (m *InfoMonitor) Stop() error {
|
|
||||||
log.Println("停止服务器信息监控...")
|
|
||||||
close(m.stopChan)
|
|
||||||
|
|
||||||
if m.logFile != nil {
|
|
||||||
m.logFile.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// initLogFile 初始化日志文件
|
|
||||||
func (m *InfoMonitor) initLogFile() error {
|
|
||||||
if m.config.LogFilePath == "" {
|
|
||||||
m.config.LogFilePath = "/var/log/sysmonitord/info_monitor.log"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建日志目录
|
|
||||||
logDir := filepath.Dir(m.config.LogFilePath)
|
|
||||||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 打开日志文件
|
|
||||||
file, err := os.OpenFile(m.config.LogFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
m.logFile = file
|
|
||||||
|
|
||||||
// 启动日志轮转检查
|
|
||||||
go m.logRotateCheck()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// logRotateCheck 日志轮转检查
|
|
||||||
func (m *InfoMonitor) logRotateCheck() {
|
|
||||||
ticker := time.NewTicker(1 * time.Hour)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-m.stopChan:
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
if m.logFile != nil {
|
|
||||||
// 检查文件大小
|
|
||||||
if info, err := m.logFile.Stat(); err == nil {
|
|
||||||
if info.Size() > m.config.MaxLogSize {
|
|
||||||
m.rotateLogFile()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// rotateLogFile 轮转日志文件
|
|
||||||
func (m *InfoMonitor) rotateLogFile() {
|
|
||||||
if m.logFile != nil {
|
|
||||||
m.logFile.Close()
|
|
||||||
|
|
||||||
// 重命名旧文件
|
|
||||||
timestamp := time.Now().Format("20060102_150405")
|
|
||||||
backupFile := fmt.Sprintf("%s.%s", m.config.LogFilePath, timestamp)
|
|
||||||
oldPath := m.config.LogFilePath
|
|
||||||
|
|
||||||
// 重新打开日志文件
|
|
||||||
file, err := os.OpenFile(m.config.LogFilePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("重新打开日志文件失败: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
m.logFile = file
|
|
||||||
|
|
||||||
// 异步重命名旧文件
|
|
||||||
go func() {
|
|
||||||
if err := os.Rename(oldPath, backupFile); err != nil {
|
|
||||||
log.Printf("重命名日志文件失败: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// monitorLoop 监控循环
|
|
||||||
func (m *InfoMonitor) monitorLoop() {
|
|
||||||
// 首次立即执行
|
|
||||||
m.collectAndLogMetrics()
|
|
||||||
|
|
||||||
ticker := time.NewTicker(m.config.Interval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-m.stopChan:
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
m.collectAndLogMetrics()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectAndLogMetrics 收集并记录指标
|
|
||||||
func (m *InfoMonitor) collectAndLogMetrics() {
|
|
||||||
startTime := time.Now()
|
|
||||||
metrics := m.collectAllMetrics()
|
|
||||||
collectionTime := time.Since(startTime)
|
|
||||||
|
|
||||||
log.Printf("收集指标完成,耗时: %v", collectionTime)
|
|
||||||
|
|
||||||
// 记录到日志文件
|
|
||||||
m.logMetrics(metrics)
|
|
||||||
|
|
||||||
// 发送到metrics通道(如果有)
|
|
||||||
if m.metricsChan != nil {
|
|
||||||
select {
|
|
||||||
case m.metricsChan <- metrics:
|
|
||||||
default:
|
|
||||||
// 通道满,丢弃数据
|
|
||||||
log.Printf("警告: metrics通道已满,丢弃数据")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 输出到控制台
|
|
||||||
m.displayMetrics(metrics)
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectAllMetrics 收集所有指标
|
|
||||||
func (m *InfoMonitor) collectAllMetrics() ServerMetrics {
|
|
||||||
return ServerMetrics{
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
CPU: m.getCPUInfo(),
|
|
||||||
Memory: m.getMemoryInfo(),
|
|
||||||
Disk: m.getDiskInfo(),
|
|
||||||
Network: m.getNetworkInfo(),
|
|
||||||
Load: m.getLoadInfo(),
|
|
||||||
Processes: m.getProcessInfo(),
|
|
||||||
Host: m.getHostInfo(),
|
|
||||||
Runtime: m.getRuntimeInfo(),
|
|
||||||
QuickMetrics: m.getQuickMetrics(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// getHostInfo 获取主机信息
|
|
||||||
func (m *InfoMonitor) getHostInfo() HostInfo {
|
|
||||||
hostInfo, err := host.Info()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("获取主机信息失败: %v", err)
|
|
||||||
return HostInfo{}
|
|
||||||
}
|
|
||||||
|
|
||||||
bootTime := time.Unix(int64(hostInfo.BootTime), 0)
|
|
||||||
uptime := time.Since(bootTime)
|
|
||||||
hours := int(uptime.Hours())
|
|
||||||
minutes := int(uptime.Minutes()) % 60
|
|
||||||
seconds := int(uptime.Seconds()) % 60
|
|
||||||
|
|
||||||
return HostInfo{
|
|
||||||
Hostname: hostInfo.Hostname,
|
|
||||||
OS: hostInfo.OS,
|
|
||||||
Platform: hostInfo.Platform,
|
|
||||||
PlatformVersion: hostInfo.PlatformVersion,
|
|
||||||
KernelVersion: hostInfo.KernelVersion,
|
|
||||||
BootTime: bootTime,
|
|
||||||
Uptime: fmt.Sprintf("%d小时%d分钟%d秒", hours, minutes, seconds),
|
|
||||||
CPUCount: hostInfo.Procs,
|
|
||||||
Architecture: hostInfo.KernelArch,
|
|
||||||
HostID: hostInfo.HostID,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// getCPUInfo 获取CPU信息
|
|
||||||
func (m *InfoMonitor) getCPUInfo() CPUInfo {
|
|
||||||
physicalCount, _ := cpu.Counts(false)
|
|
||||||
logicalCount, _ := cpu.Counts(true)
|
|
||||||
|
|
||||||
percent, _ := cpu.Percent(200*time.Millisecond, false)
|
|
||||||
perCorePercent, _ := cpu.Percent(200*time.Millisecond, true)
|
|
||||||
|
|
||||||
cpuInfoList, _ := cpu.Info()
|
|
||||||
var model string
|
|
||||||
var mhz float64
|
|
||||||
var cacheSize int
|
|
||||||
|
|
||||||
if len(cpuInfoList) > 0 {
|
|
||||||
model = cpuInfoList[0].ModelName
|
|
||||||
mhz = cpuInfoList[0].Mhz
|
|
||||||
// 将 int32 转换为 int
|
|
||||||
cacheSize = int(cpuInfoList[0].CacheSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
usagePercent := 0.0
|
|
||||||
if len(percent) > 0 {
|
|
||||||
usagePercent = percent[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
return CPUInfo{
|
|
||||||
Model: model,
|
|
||||||
Cores: physicalCount,
|
|
||||||
LogicalCores: logicalCount,
|
|
||||||
UsagePercent: usagePercent,
|
|
||||||
PerCorePercent: perCorePercent,
|
|
||||||
Mhz: mhz,
|
|
||||||
CacheSize: cacheSize,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// getMemoryInfo 获取内存信息
|
|
||||||
func (m *InfoMonitor) getMemoryInfo() MemoryInfo {
|
|
||||||
vMem, err := mem.VirtualMemory()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("获取内存信息失败: %v", err)
|
|
||||||
return MemoryInfo{}
|
|
||||||
}
|
|
||||||
|
|
||||||
swap, _ := mem.SwapMemory()
|
|
||||||
|
|
||||||
return MemoryInfo{
|
|
||||||
TotalGB: float64(vMem.Total) / (1024 * 1024 * 1024),
|
|
||||||
UsedGB: float64(vMem.Used) / (1024 * 1024 * 1024),
|
|
||||||
AvailableGB: float64(vMem.Available) / (1024 * 1024 * 1024),
|
|
||||||
UsedPercent: vMem.UsedPercent,
|
|
||||||
SwapTotalGB: float64(swap.Total) / (1024 * 1024 * 1024),
|
|
||||||
SwapUsedGB: float64(swap.Used) / (1024 * 1024 * 1024),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// getDiskInfo 获取磁盘信息
|
|
||||||
func (m *InfoMonitor) getDiskInfo() []DiskInfo {
|
|
||||||
partitions, err := disk.Partitions(false)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("获取磁盘分区失败: %v", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var disks []DiskInfo
|
|
||||||
for _, partition := range partitions {
|
|
||||||
// 过滤掉特殊文件系统
|
|
||||||
if partition.Fstype == "" ||
|
|
||||||
partition.Fstype == "tmpfs" ||
|
|
||||||
partition.Fstype == "devtmpfs" ||
|
|
||||||
partition.Fstype == "squashfs" ||
|
|
||||||
partition.Fstype == "efivarfs" ||
|
|
||||||
partition.Fstype == "debugfs" ||
|
|
||||||
partition.Fstype == "securityfs" ||
|
|
||||||
partition.Fstype == "cgroup" ||
|
|
||||||
partition.Fstype == "cgroup2" ||
|
|
||||||
partition.Fstype == "pstore" ||
|
|
||||||
partition.Fstype == "autofs" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
usage, err := disk.Usage(partition.Mountpoint)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
inodesPercent := 0.0
|
|
||||||
if usage.InodesUsedPercent > 0 {
|
|
||||||
inodesPercent = usage.InodesUsedPercent
|
|
||||||
}
|
|
||||||
|
|
||||||
disks = append(disks, DiskInfo{
|
|
||||||
Mountpoint: partition.Mountpoint,
|
|
||||||
Device: partition.Device,
|
|
||||||
Fstype: partition.Fstype,
|
|
||||||
TotalGB: float64(usage.Total) / (1024 * 1024 * 1024),
|
|
||||||
UsedGB: float64(usage.Used) / (1024 * 1024 * 1024),
|
|
||||||
FreeGB: float64(usage.Free) / (1024 * 1024 * 1024),
|
|
||||||
UsedPercent: usage.UsedPercent,
|
|
||||||
InodesPercent: inodesPercent,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return disks
|
|
||||||
}
|
|
||||||
|
|
||||||
// getNetworkInfo 获取网络信息
|
|
||||||
func (m *InfoMonitor) getNetworkInfo() NetworkInfo {
|
|
||||||
if !m.config.CollectNetwork {
|
|
||||||
return NetworkInfo{}
|
|
||||||
}
|
|
||||||
|
|
||||||
interfaces, err := net.Interfaces()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("获取网络接口失败: %v", err)
|
|
||||||
return NetworkInfo{}
|
|
||||||
}
|
|
||||||
|
|
||||||
var netInterfaces []NetworkInterface
|
|
||||||
for _, iface := range interfaces {
|
|
||||||
if len(iface.Addrs) > 0 && iface.Name != "lo" {
|
|
||||||
var ips []string
|
|
||||||
for _, addr := range iface.Addrs {
|
|
||||||
ips = append(ips, addr.Addr)
|
|
||||||
}
|
|
||||||
|
|
||||||
netInterfaces = append(netInterfaces, NetworkInterface{
|
|
||||||
Name: iface.Name,
|
|
||||||
HardwareAddr: iface.HardwareAddr,
|
|
||||||
IPAddresses: ips,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取网络IO统计
|
|
||||||
ioCounters, _ := net.IOCounters(true)
|
|
||||||
var totalRecv, totalSent uint64
|
|
||||||
for _, io := range ioCounters {
|
|
||||||
totalRecv += io.BytesRecv
|
|
||||||
totalSent += io.BytesSent
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取TCP连接数
|
|
||||||
tcpConns, _ := net.Connections("tcp")
|
|
||||||
established := 0
|
|
||||||
for _, conn := range tcpConns {
|
|
||||||
if conn.Status == "ESTABLISHED" {
|
|
||||||
established++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NetworkInfo{
|
|
||||||
Interfaces: netInterfaces,
|
|
||||||
TotalRecvMB: float64(totalRecv) / (1024 * 1024),
|
|
||||||
TotalSentMB: float64(totalSent) / (1024 * 1024),
|
|
||||||
TCPConnections: len(tcpConns),
|
|
||||||
EstablishedConn: established,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// getLoadInfo 获取负载信息
|
|
||||||
func (m *InfoMonitor) getLoadInfo() LoadInfo {
|
|
||||||
avg, err := load.Avg()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("获取系统负载失败: %v", err)
|
|
||||||
return LoadInfo{}
|
|
||||||
}
|
|
||||||
|
|
||||||
misc, _ := load.Misc()
|
|
||||||
logicalCount, _ := cpu.Counts(true)
|
|
||||||
|
|
||||||
relativeLoad1 := 0.0
|
|
||||||
relativeLoad5 := 0.0
|
|
||||||
relativeLoad15 := 0.0
|
|
||||||
if logicalCount > 0 {
|
|
||||||
relativeLoad1 = avg.Load1 / float64(logicalCount)
|
|
||||||
relativeLoad5 = avg.Load5 / float64(logicalCount)
|
|
||||||
relativeLoad15 = avg.Load15 / float64(logicalCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
return LoadInfo{
|
|
||||||
Load1: avg.Load1,
|
|
||||||
Load5: avg.Load5,
|
|
||||||
Load15: avg.Load15,
|
|
||||||
RelativeLoad1: relativeLoad1,
|
|
||||||
RelativeLoad5: relativeLoad5,
|
|
||||||
RelativeLoad15: relativeLoad15,
|
|
||||||
// load.Misc() 返回的是 int 类型
|
|
||||||
ProcsRunning: misc.ProcsRunning,
|
|
||||||
ProcsTotal: misc.ProcsTotal,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// getProcessInfo 获取进程信息
|
|
||||||
func (m *InfoMonitor) getProcessInfo() []ProcessInfo {
|
|
||||||
if !m.config.CollectProcess {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
processes, err := process.Processes()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("获取进程列表失败: %v", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var procList []ProcessInfo
|
|
||||||
limit := m.config.ProcessLimit
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 10 // 默认显示10个进程
|
|
||||||
}
|
|
||||||
|
|
||||||
count := 0
|
|
||||||
for _, p := range processes {
|
|
||||||
if count >= limit {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
name, err := p.Name()
|
|
||||||
if err != nil || name == "" || name == " " {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
cmdline, _ := p.Cmdline()
|
|
||||||
memInfo, _ := p.MemoryInfo()
|
|
||||||
cpuPercent, _ := p.CPUPercent()
|
|
||||||
|
|
||||||
var memMB float64
|
|
||||||
if memInfo != nil {
|
|
||||||
memMB = float64(memInfo.RSS) / (1024 * 1024)
|
|
||||||
}
|
|
||||||
|
|
||||||
procList = append(procList, ProcessInfo{
|
|
||||||
PID: p.Pid,
|
|
||||||
Name: name,
|
|
||||||
Cmdline: cmdline,
|
|
||||||
MemoryMB: memMB,
|
|
||||||
CPUPercent: cpuPercent,
|
|
||||||
})
|
|
||||||
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
|
|
||||||
return procList
|
|
||||||
}
|
|
||||||
|
|
||||||
// getRuntimeInfo 获取运行时信息
|
|
||||||
func (m *InfoMonitor) getRuntimeInfo() RuntimeInfo {
|
|
||||||
return RuntimeInfo{
|
|
||||||
GoVersion: runtime.Version(),
|
|
||||||
GOOS: runtime.GOOS,
|
|
||||||
GOARCH: runtime.GOARCH,
|
|
||||||
GOROOT: runtime.GOROOT(),
|
|
||||||
GOMAXPROCS: runtime.GOMAXPROCS(0),
|
|
||||||
NumCPU: runtime.NumCPU(),
|
|
||||||
NumGoroutine: runtime.NumGoroutine(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// getQuickMetrics 获取快速指标
|
|
||||||
func (m *InfoMonitor) getQuickMetrics() QuickMetrics {
|
|
||||||
cpuPercent, _ := cpu.Percent(100*time.Millisecond, false)
|
|
||||||
memInfo, _ := mem.VirtualMemory()
|
|
||||||
rootUsage, _ := disk.Usage("/")
|
|
||||||
|
|
||||||
quickCPU := 0.0
|
|
||||||
if len(cpuPercent) > 0 {
|
|
||||||
quickCPU = cpuPercent[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
memPercent := 0.0
|
|
||||||
availableGB := 0.0
|
|
||||||
if memInfo != nil {
|
|
||||||
memPercent = memInfo.UsedPercent
|
|
||||||
availableGB = float64(memInfo.Available) / (1024 * 1024 * 1024)
|
|
||||||
}
|
|
||||||
|
|
||||||
rootDiskPercent := 0.0
|
|
||||||
if rootUsage != nil {
|
|
||||||
rootDiskPercent = rootUsage.UsedPercent
|
|
||||||
}
|
|
||||||
|
|
||||||
return QuickMetrics{
|
|
||||||
CPUPercent: quickCPU,
|
|
||||||
MemoryPercent: memPercent,
|
|
||||||
RootDiskPercent: rootDiskPercent,
|
|
||||||
AvailableMemoryGB: availableGB,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// logMetrics 记录指标到日志文件
|
|
||||||
func (m *InfoMonitor) logMetrics(metrics ServerMetrics) {
|
|
||||||
if m.logFile == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 基本指标日志
|
|
||||||
basicLog := fmt.Sprintf("[INFO-METRIC] %s | CPU:%.2f%% | MEM:%.2f%% | Load1:%.2f | DiskRoot:%.2f%%",
|
|
||||||
metrics.Timestamp.Format("2006-01-02 15:04:05"),
|
|
||||||
metrics.QuickMetrics.CPUPercent,
|
|
||||||
metrics.QuickMetrics.MemoryPercent,
|
|
||||||
metrics.Load.Load1,
|
|
||||||
metrics.QuickMetrics.RootDiskPercent,
|
|
||||||
)
|
|
||||||
|
|
||||||
// 详细指标日志
|
|
||||||
detailedLog := fmt.Sprintf("\n[INFO-DETAIL] Host: %s, Uptime: %s, Cores: %d/%d, Mem: %.2f/%.2f GB",
|
|
||||||
metrics.Host.Hostname,
|
|
||||||
metrics.Host.Uptime,
|
|
||||||
metrics.CPU.Cores,
|
|
||||||
metrics.CPU.LogicalCores,
|
|
||||||
metrics.Memory.UsedGB,
|
|
||||||
metrics.Memory.TotalGB,
|
|
||||||
)
|
|
||||||
|
|
||||||
logLine := basicLog + detailedLog + "\n"
|
|
||||||
|
|
||||||
if _, err := m.logFile.WriteString(logLine); err != nil {
|
|
||||||
log.Printf("写入日志文件失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确保数据写入磁盘
|
|
||||||
m.logFile.Sync()
|
|
||||||
}
|
|
||||||
|
|
||||||
// displayMetrics 显示指标到控制台
|
|
||||||
func (m *InfoMonitor) displayMetrics(metrics ServerMetrics) {
|
|
||||||
// 使用不同颜色显示不同类型的指标
|
|
||||||
fmt.Printf("\n\x1b[36m════════════════ 服务器监控指标 [%s] ════════════════\x1b[0m\n",
|
|
||||||
metrics.Timestamp.Format("15:04:05"))
|
|
||||||
|
|
||||||
// 主机信息
|
|
||||||
fmt.Printf("\n\x1b[33m主机信息:\x1b[0m\n")
|
|
||||||
fmt.Printf(" \x1b[32m主机名:\x1b[0m %s\n", metrics.Host.Hostname)
|
|
||||||
fmt.Printf(" \x1b[32m运行时间:\x1b[0m %s\n", metrics.Host.Uptime)
|
|
||||||
fmt.Printf(" \x1b[32m系统:\x1b[0m %s %s\n", metrics.Host.Platform, metrics.Host.PlatformVersion)
|
|
||||||
|
|
||||||
// CPU信息
|
|
||||||
fmt.Printf("\n\x1b[33mCPU信息:\x1b[0m\n")
|
|
||||||
fmt.Printf(" \x1b[32m型号:\x1b[0m %s\n", metrics.CPU.Model)
|
|
||||||
fmt.Printf(" \x1b[32m核心:\x1b[0m %d物理/%d逻辑\n", metrics.CPU.Cores, metrics.CPU.LogicalCores)
|
|
||||||
|
|
||||||
// 根据CPU使用率显示不同颜色
|
|
||||||
cpuColor := "\x1b[32m" // 绿色
|
|
||||||
if metrics.CPU.UsagePercent > 70 {
|
|
||||||
cpuColor = "\x1b[33m" // 黄色
|
|
||||||
}
|
|
||||||
if metrics.CPU.UsagePercent > 90 {
|
|
||||||
cpuColor = "\x1b[31m" // 红色
|
|
||||||
}
|
|
||||||
fmt.Printf(" \x1b[32m使用率:\x1b[0m %s%.2f%%\x1b[0m", cpuColor, metrics.CPU.UsagePercent)
|
|
||||||
|
|
||||||
if len(metrics.CPU.PerCorePercent) > 0 {
|
|
||||||
fmt.Printf(" (")
|
|
||||||
for i, p := range metrics.CPU.PerCorePercent {
|
|
||||||
if i > 0 {
|
|
||||||
fmt.Printf(" ")
|
|
||||||
}
|
|
||||||
coreColor := "\x1b[32m"
|
|
||||||
if p > 70 {
|
|
||||||
coreColor = "\x1b[33m"
|
|
||||||
}
|
|
||||||
if p > 90 {
|
|
||||||
coreColor = "\x1b[31m"
|
|
||||||
}
|
|
||||||
fmt.Printf("%s%d:%.0f%%\x1b[0m", coreColor, i, p)
|
|
||||||
}
|
|
||||||
fmt.Printf(")")
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
// 内存信息
|
|
||||||
fmt.Printf("\n\x1b[33m内存信息:\x1b[0m\n")
|
|
||||||
|
|
||||||
// 根据内存使用率显示不同颜色
|
|
||||||
memColor := "\x1b[32m"
|
|
||||||
if metrics.Memory.UsedPercent > 70 {
|
|
||||||
memColor = "\x1b[33m"
|
|
||||||
}
|
|
||||||
if metrics.Memory.UsedPercent > 90 {
|
|
||||||
memColor = "\x1b[31m"
|
|
||||||
}
|
|
||||||
|
|
||||||
memBar := getProgressBar(metrics.Memory.UsedPercent, 20)
|
|
||||||
fmt.Printf(" \x1b[32m使用率:\x1b[0m %s%.1f%%\x1b[0m %s\n",
|
|
||||||
memColor, metrics.Memory.UsedPercent, memBar)
|
|
||||||
fmt.Printf(" \x1b[32m总量/已用/可用:\x1b[0m %.2f/%.2f/%.2f GB\n",
|
|
||||||
metrics.Memory.TotalGB, metrics.Memory.UsedGB, metrics.Memory.AvailableGB)
|
|
||||||
|
|
||||||
if metrics.Memory.SwapTotalGB > 0 {
|
|
||||||
fmt.Printf(" \x1b[32m交换空间:\x1b[0m %.2f GB\n", metrics.Memory.SwapTotalGB)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 磁盘信息
|
|
||||||
if len(metrics.Disk) > 0 {
|
|
||||||
fmt.Printf("\n\x1b[33m磁盘使用情况:\x1b[0m\n")
|
|
||||||
for _, disk := range metrics.Disk {
|
|
||||||
diskColor := "\x1b[32m"
|
|
||||||
if disk.UsedPercent > 70 {
|
|
||||||
diskColor = "\x1b[33m"
|
|
||||||
}
|
|
||||||
if disk.UsedPercent > 90 {
|
|
||||||
diskColor = "\x1b[31m"
|
|
||||||
}
|
|
||||||
|
|
||||||
diskBar := getProgressBar(disk.UsedPercent, 15)
|
|
||||||
fmt.Printf(" \x1b[32m%s:\x1b[0m %s%.1f%%\x1b[0m %s %.2f/%.2f GB\n",
|
|
||||||
disk.Mountpoint, diskColor, disk.UsedPercent, diskBar, disk.UsedGB, disk.TotalGB)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 负载信息
|
|
||||||
fmt.Printf("\n\x1b[33m系统负载:\x1b[0m\n")
|
|
||||||
load1Color := "\x1b[32m"
|
|
||||||
if metrics.Load.RelativeLoad1 > 1.0 {
|
|
||||||
load1Color = "\x1b[33m"
|
|
||||||
}
|
|
||||||
if metrics.Load.RelativeLoad1 > 2.0 {
|
|
||||||
load1Color = "\x1b[31m"
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf(" \x1b[32m1/5/15分钟:\x1b[0m %s%.2f\x1b[0m/%.2f/%.2f\n",
|
|
||||||
load1Color, metrics.Load.Load1, metrics.Load.Load5, metrics.Load.Load15)
|
|
||||||
fmt.Printf(" \x1b[32m相对负载:\x1b[0m %.2f/%.2f/%.2f\n",
|
|
||||||
metrics.Load.RelativeLoad1, metrics.Load.RelativeLoad5, metrics.Load.RelativeLoad15)
|
|
||||||
fmt.Printf(" \x1b[32m进程:\x1b[0m %d运行中 / %d总计\n",
|
|
||||||
metrics.Load.ProcsRunning, metrics.Load.ProcsTotal)
|
|
||||||
|
|
||||||
// 网络信息(如果启用了)
|
|
||||||
if m.config.CollectNetwork && len(metrics.Network.Interfaces) > 0 {
|
|
||||||
fmt.Printf("\n\x1b[33m网络信息:\x1b[0m\n")
|
|
||||||
fmt.Printf(" \x1b[32mTCP连接:\x1b[0m %d (已建立: %d)\n",
|
|
||||||
metrics.Network.TCPConnections, metrics.Network.EstablishedConn)
|
|
||||||
fmt.Printf(" \x1b[32m流量:\x1b[0m 接收:%.2f MB 发送:%.2f MB\n",
|
|
||||||
metrics.Network.TotalRecvMB, metrics.Network.TotalSentMB)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 进程信息
|
|
||||||
if len(metrics.Processes) > 0 {
|
|
||||||
fmt.Printf("\n\x1b[33mTOP进程 (按内存排序):\x1b[0m\n")
|
|
||||||
for i, proc := range metrics.Processes {
|
|
||||||
if i >= 5 { // 只显示前5个
|
|
||||||
break
|
|
||||||
}
|
|
||||||
procColor := "\x1b[36m"
|
|
||||||
if proc.CPUPercent > 10 {
|
|
||||||
procColor = "\x1b[33m"
|
|
||||||
}
|
|
||||||
if proc.CPUPercent > 30 {
|
|
||||||
procColor = "\x1b[31m"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 截断过长的命令行
|
|
||||||
cmdDisplay := proc.Cmdline
|
|
||||||
if len(cmdDisplay) > 50 {
|
|
||||||
cmdDisplay = cmdDisplay[:47] + "..."
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf(" %s%5d\x1b[0m %-20s %s%.1f%%\x1b[0m %.1fMB %s\n",
|
|
||||||
procColor, proc.PID, proc.Name, procColor, proc.CPUPercent,
|
|
||||||
proc.MemoryMB, cmdDisplay)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("\n\x1b[36m══════════════════════════════════════════════════════\x1b[0m\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// getProgressBar 获取进度条
|
|
||||||
func getProgressBar(percent float64, width int) string {
|
|
||||||
filled := int((percent / 100.0) * float64(width))
|
|
||||||
if filled > width {
|
|
||||||
filled = width
|
|
||||||
}
|
|
||||||
|
|
||||||
bar := "["
|
|
||||||
for i := 0; i < width; i++ {
|
|
||||||
if i < filled {
|
|
||||||
// 根据填充量使用不同颜色
|
|
||||||
if i < width/3 {
|
|
||||||
bar += "\x1b[32m=\x1b[0m" // 绿色
|
|
||||||
} else if i < width*2/3 {
|
|
||||||
bar += "\x1b[33m=\x1b[0m" // 黄色
|
|
||||||
} else {
|
|
||||||
bar += "\x1b[31m=\x1b[0m" // 红色
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
bar += " "
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bar += "]"
|
|
||||||
|
|
||||||
return bar
|
|
||||||
}
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
package network
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"log"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ClientConfig struct {
|
|
||||||
ServerURL string
|
|
||||||
SendInterval time.Duration
|
|
||||||
BufferSize int
|
|
||||||
}
|
|
||||||
|
|
||||||
type WSClient struct {
|
|
||||||
config ClientConfig
|
|
||||||
conn *websocket.Conn
|
|
||||||
sendChan chan Packet
|
|
||||||
mu sync.Mutex
|
|
||||||
isConnected bool
|
|
||||||
stopChan chan struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewWSClient(cfg ClientConfig) *WSClient {
|
|
||||||
if cfg.BufferSize == 0 {
|
|
||||||
cfg.BufferSize = 100
|
|
||||||
}
|
|
||||||
return &WSClient{
|
|
||||||
config: cfg,
|
|
||||||
sendChan: make(chan Packet, cfg.BufferSize),
|
|
||||||
stopChan: make(chan struct{}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WSClient) Start() {
|
|
||||||
go c.connectionLoop()
|
|
||||||
go c.sendLoop()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WSClient) SendQueue(packet Packet) {
|
|
||||||
select {
|
|
||||||
case c.sendChan <- packet:
|
|
||||||
default:
|
|
||||||
log.Printf("[网络] 发送队列已满,丢弃消息: %s", packet.Type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WSClient) Stop() {
|
|
||||||
close(c.stopChan)
|
|
||||||
if c.conn != nil {
|
|
||||||
c.conn.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WSClient) sendLoop() {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-c.stopChan:
|
|
||||||
return
|
|
||||||
case packet := <-c.sendChan:
|
|
||||||
c.sendRaw(packet)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WSClient) sendRaw(packet Packet) {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
if !c.isConnected || c.conn == nil {
|
|
||||||
log.Printf("[网络] 无连接,无法发送消息: %s", packet.Type)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
|
||||||
|
|
||||||
payload, _ := json.Marshal(packet)
|
|
||||||
if err := c.conn.WriteMessage(websocket.TextMessage, payload); err != nil {
|
|
||||||
log.Printf("[网络] 发送消息失败: %v", err)
|
|
||||||
c.isConnected = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WSClient) connectionLoop() {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-c.stopChan:
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
if !c.isConnected {
|
|
||||||
if err := c.connect(); err != nil {
|
|
||||||
log.Printf("[网络] 连接 %s 失败: %v. 5秒后重试...", c.config.ServerURL, err)
|
|
||||||
time.Sleep(5 * time.Second)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _, err := c.conn.ReadMessage()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[网络] 连接断开: %v", err)
|
|
||||||
c.closeConn()
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: 处理服务器消息
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WSClient) closeConn() {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
if c.conn != nil {
|
|
||||||
c.conn.Close()
|
|
||||||
c.conn = nil
|
|
||||||
}
|
|
||||||
c.isConnected = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WSClient) connect() error {
|
|
||||||
conn, _, err := websocket.DefaultDialer.Dial(c.config.ServerURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-c.stopChan:
|
|
||||||
conn.Close()
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
c.conn = conn
|
|
||||||
c.isConnected = true
|
|
||||||
log.Printf("[网络] 成功连接到服务器: %s", c.config.ServerURL)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
package network
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/wuko233/sysmonitord/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ConfigLoader struct {
|
|
||||||
client *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewConfigLoader() *ConfigLoader {
|
|
||||||
return &ConfigLoader{
|
|
||||||
client: &http.Client{
|
|
||||||
Timeout: 10 * time.Second,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *ConfigLoader) LoadConfigs(urls ConfigUrls) (config.OfficialConfig, config.UserConfig, error) {
|
|
||||||
var official config.OfficialConfig
|
|
||||||
var user config.UserConfig
|
|
||||||
// 1. 下载官方配置
|
|
||||||
if err := l.fetchJSON(urls.OfficialConfigUrl, &official); err != nil {
|
|
||||||
return official, user, fmt.Errorf("下载官方配置失败: %v", err)
|
|
||||||
}
|
|
||||||
// 2. 下载用户配置
|
|
||||||
if err := l.fetchJSON(urls.UserConfigUrl, &user); err != nil {
|
|
||||||
return official, user, fmt.Errorf("下载用户配置失败: %v", err)
|
|
||||||
}
|
|
||||||
return official, user, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *ConfigLoader) fetchJSON(url string, target interface{}) error {
|
|
||||||
resp, err := l.client.Get(url)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("请求失败: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return fmt.Errorf("非200响应: %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
return json.NewDecoder(resp.Body).Decode(target)
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
package network
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
type Packet struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Timestamp int64 `json:"timestamp"`
|
|
||||||
Payload interface{} `json:"payload"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPacket(msgType string, payload interface{}) Packet {
|
|
||||||
return Packet{
|
|
||||||
Type: msgType,
|
|
||||||
Timestamp: time.Now().Unix(),
|
|
||||||
Payload: payload,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ConfigUrls struct {
|
|
||||||
OfficialConfigUrl string
|
|
||||||
UserConfigUrl string
|
|
||||||
}
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
package scanner
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/cpu"
|
|
||||||
"github.com/wuko233/sysmonitord/internal/network"
|
|
||||||
"github.com/wuko233/sysmonitord/internal/whitelist"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Scanner struct {
|
|
||||||
wlManager *whitelist.Manager
|
|
||||||
client *network.WSClient
|
|
||||||
cpuLimit float64
|
|
||||||
scanPaths []string
|
|
||||||
stopChan chan struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewScanner(wl *whitelist.Manager, client *network.WSClient) *Scanner {
|
|
||||||
return &Scanner{
|
|
||||||
wlManager: wl,
|
|
||||||
client: client,
|
|
||||||
cpuLimit: 50.0,
|
|
||||||
scanPaths: []string{"/bin", "/sbin", "/usr/bin", "/usr/sbin", "/etc", "/tmp", "/home"},
|
|
||||||
stopChan: make(chan struct{}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Scanner) Start() {
|
|
||||||
log.Println("[扫描器] 启动文件完整性扫描...")
|
|
||||||
go s.scanLoop()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Scanner) scanLoop() {
|
|
||||||
ticker := time.NewTicker(10 * time.Minute)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-s.stopChan:
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
s.performScan()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Scanner) performScan() {
|
|
||||||
log.Println("[扫描器] 开始新一轮全盘扫描")
|
|
||||||
fileCount := 0
|
|
||||||
|
|
||||||
for _, root := range s.scanPaths {
|
|
||||||
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-s.stopChan:
|
|
||||||
return filepath.SkipDir
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[扫描器] 访问错误: %v", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fileCount++
|
|
||||||
|
|
||||||
if fileCount%100 == 0 {
|
|
||||||
s.checkCPUAndSleep()
|
|
||||||
}
|
|
||||||
|
|
||||||
if info.IsDir() {
|
|
||||||
if s.wlManager.IsPathIgnored(path) {
|
|
||||||
return filepath.SkipDir
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
isWhitelisted, isHashMatch, err := s.wlManager.CheckFileStatus(path)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[扫描器] 检查文件状态失败: %v", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isWhitelisted {
|
|
||||||
log.Printf("[扫描器] 发现未在白名单文件: %s", path)
|
|
||||||
s.reportFile(path, "NON_WHITELISTED_FILE")
|
|
||||||
} else if !isHashMatch {
|
|
||||||
log.Printf("[扫描器] 警告!文件Hash不匹配(可能被篡改): %s", path)
|
|
||||||
s.reportFile(path, "FILE_HASH_MISMATCH")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[扫描器] 扫描目录 %s 出错: %v", root, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Scanner) checkCPUAndSleep() {
|
|
||||||
percent, err := cpu.Percent(200*time.Millisecond, false)
|
|
||||||
if err != nil || len(percent) == 0 {
|
|
||||||
log.Printf("[扫描器] 获取CPU使用率失败: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if percent[0] > s.cpuLimit {
|
|
||||||
log.Printf("[扫描器] CPU使用率过高 (%.2f%%),暂停扫描5秒", percent[0])
|
|
||||||
time.Sleep(5 * time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Scanner) reportFile(path string, alertType string) {
|
|
||||||
payload := map[string]interface{}{
|
|
||||||
"filepath": path,
|
|
||||||
"status": "detected",
|
|
||||||
}
|
|
||||||
|
|
||||||
packet := network.NewPacket(alertType, payload)
|
|
||||||
|
|
||||||
s.client.SendQueue(packet)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Scanner) Stop() {
|
|
||||||
log.Println("[扫描器] 停止文件完整性扫描...")
|
|
||||||
close(s.stopChan)
|
|
||||||
}
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
package scanner
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/fsnotify/fsnotify"
|
|
||||||
"github.com/wuko233/sysmonitord/internal/network"
|
|
||||||
"github.com/wuko233/sysmonitord/internal/whitelist"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Watcher struct {
|
|
||||||
wlManager *whitelist.Manager
|
|
||||||
client *network.WSClient
|
|
||||||
watcher *fsnotify.Watcher
|
|
||||||
stopChan chan struct{}
|
|
||||||
watchPaths []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewWatcher(wl *whitelist.Manager, client *network.WSClient) (*Watcher, error) {
|
|
||||||
fsWatch, err := fsnotify.NewWatcher()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Watcher{
|
|
||||||
wlManager: wl,
|
|
||||||
client: client,
|
|
||||||
watcher: fsWatch,
|
|
||||||
stopChan: make(chan struct{}),
|
|
||||||
|
|
||||||
// TODO: 当前仅实现对主目录的监控,后续实现递归监控子目录
|
|
||||||
watchPaths: []string{
|
|
||||||
"/bin", "/sbin", "/usr/bin", "/etc/init.d", "/tmp",
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *Watcher) Start() {
|
|
||||||
log.Println("[监听器] 启动实时文件监控...")
|
|
||||||
|
|
||||||
for _, path := range w.watchPaths {
|
|
||||||
if _, err := os.Stat(path); err == nil {
|
|
||||||
if err := w.watcher.Add(path); err != nil {
|
|
||||||
log.Printf("[监听器] 无法监控路径 %s: %v", path, err)
|
|
||||||
} else {
|
|
||||||
log.Printf("[监听器] 开始监控路径: %s", path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
go w.eventLoop()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *Watcher) eventLoop() {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-w.stopChan:
|
|
||||||
return
|
|
||||||
case event, ok := <-w.watcher.Events:
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if event.Has(fsnotify.Create) || event.Has(fsnotify.Write) {
|
|
||||||
if w.wlManager.IsPathIgnored(event.Name) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
go w.handleFileChange(event.Name, event.Op.String())
|
|
||||||
}
|
|
||||||
case err, ok := <-w.watcher.Errors:
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Printf("[监听器] 错误: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *Watcher) handleFileChange(path string, op string) {
|
|
||||||
|
|
||||||
time.Sleep(200 * time.Millisecond) // 等待文件写入完成
|
|
||||||
|
|
||||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isWhitelisted, isHashMatch, err := w.wlManager.CheckFileStatus(path)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isWhitelisted {
|
|
||||||
log.Printf("[监听器] 实时拦截:检测到非白名单文件变动 (%s): %s", op, path)
|
|
||||||
w.reportEvent(path, "REALTIME_FILE_ALERT", op)
|
|
||||||
} else if !isHashMatch {
|
|
||||||
log.Printf("[监听器] 实时拦截:检测到白名单文件被篡改 (%s): %s", op, path)
|
|
||||||
w.reportEvent(path, "REALTIME_HASH_MISMATCH", op)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *Watcher) reportEvent(path, alertType, op string) {
|
|
||||||
payload := map[string]interface{}{
|
|
||||||
"filepath": path,
|
|
||||||
"operation": op,
|
|
||||||
"time": time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
packet := network.NewPacket(alertType, payload)
|
|
||||||
w.client.SendQueue(packet)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *Watcher) Stop() {
|
|
||||||
log.Println("[监听器] 停止实时文件监控...")
|
|
||||||
close(w.stopChan)
|
|
||||||
w.watcher.Close()
|
|
||||||
}
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
package whitelist
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/wuko233/sysmonitord/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Manager struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
official config.OfficialConfig
|
|
||||||
user config.UserConfig
|
|
||||||
mergedIgnore []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewManager() *Manager {
|
|
||||||
return &Manager{
|
|
||||||
official: config.OfficialConfig{
|
|
||||||
WhitelistFiles: make(map[string][]string),
|
|
||||||
},
|
|
||||||
user: config.UserConfig{
|
|
||||||
SupplementFiles: make(map[string][]string),
|
|
||||||
SupplementProcesses: make(map[string]string),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) UpdateConfig(official config.OfficialConfig, user config.UserConfig) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
m.official = official
|
|
||||||
m.user = user
|
|
||||||
|
|
||||||
m.mergedIgnore = append([]string{}, m.official.IgnoredPaths...)
|
|
||||||
m.mergedIgnore = append(m.mergedIgnore, m.user.IgnoredPaths...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) IsPathIgnored(path string) bool {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
|
|
||||||
return m.IsPathIgnoredUnsafe(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) IsPathIgnoredUnsafe(path string) bool {
|
|
||||||
path = filepath.Clean(path)
|
|
||||||
for _, ignore := range m.mergedIgnore {
|
|
||||||
if strings.HasPrefix(path, filepath.Clean(ignore)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckFileStatus 检查文件状态
|
|
||||||
// 返回: isWhitelisted(是否在白名单), isValid(Hash是否匹配), err
|
|
||||||
func (m *Manager) CheckFileStatus(path string) (bool, bool, error) {
|
|
||||||
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
|
|
||||||
if m.IsPathIgnoredUnsafe((path)) {
|
|
||||||
return true, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
hashes, exists := m.official.WhitelistFiles[path]
|
|
||||||
if !exists {
|
|
||||||
hashes, exists = m.user.SupplementFiles[path]
|
|
||||||
}
|
|
||||||
if !exists {
|
|
||||||
return false, false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fileHash, err := CalculateFileHash(path)
|
|
||||||
if err != nil {
|
|
||||||
return true, false, fmt.Errorf("计算文件哈希失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, h := range hashes {
|
|
||||||
if strings.EqualFold(h, fmt.Sprintf("%v", fileHash)) {
|
|
||||||
return true, true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func CalculateFileHash(filePath string) (string, error) {
|
|
||||||
file, err := os.Open(filePath)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
hash := sha256.New()
|
|
||||||
if _, err := io.Copy(hash, file); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return hex.EncodeToString(hash.Sum(nil)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) IsProcessAllowed(procName string, cmdLine string) bool {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
|
|
||||||
for _, p := range m.official.WhitelistProcesses {
|
|
||||||
if p == procName {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := m.user.SupplementProcesses[procName]; ok {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) GetAuditServerUrl() string {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
|
|
||||||
return m.user.AuditServerUrl
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user