From 14f6d39f927f9ae4d30e11f5b71dde802f3ce848 Mon Sep 17 00:00:00 2001 From: samwaf Date: Thu, 6 Aug 2026 10:47:56 +0800 Subject: [PATCH] fix:system info dialog and ai assistant --- api/waf_gpt.go | 121 ++++++++++++-- api/waf_sys_info.go | 42 +++++ api/waf_sys_info_test.go | 54 +++++++ global/global.go | 2 + model/request/waf_gpt_send_req.go | 3 + model/sys_info.go | 35 +++++ router/waf_sysinfo.go | 1 + service/waf_service/waf_stat.go | 52 +++++- test/apicheck/api_routes_test.go | 1 + utils/os_detail.go | 252 ++++++++++++++++++++++++++++++ utils/os_detail_test.go | 53 +++++++ 11 files changed, 596 insertions(+), 20 deletions(-) create mode 100644 utils/os_detail.go create mode 100644 utils/os_detail_test.go diff --git a/api/waf_gpt.go b/api/waf_gpt.go index f042a37..9e3e467 100644 --- a/api/waf_gpt.go +++ b/api/waf_gpt.go @@ -1,6 +1,7 @@ package api import ( + "SamWaf/common/zlog" "SamWaf/global" "SamWaf/model" "SamWaf/model/common/response" @@ -293,6 +294,75 @@ func (w *WafGPTApi) SaveGptConfigApi(c *gin.Context) { response.OkWithMessage("保存成功", c) } +// ============ 对话场景与系统提示词 ============ +// AI 助手是多入口共用的:日志详情点"AI分析"、OWASP 规则解读、右下角自由提问。 +// 以前不管从哪来都硬套"信息安全专家 + 风险等级/风险类型/风险说明"这套格式, +// 结果随便问一句也被套成风险报告。这里按场景分开,前端发 scene,后端选提示词。 +const ( + GptSceneSecurityLog = "security_log" // 日志详情:分析这条请求的安全风险 + GptSceneOwaspRule = "owasp_rule" // OWASP CRS 规则解读 + GptSceneGeneral = "general" // 通用问答(默认) +) + +const gptSystemPromptSecurityLog = `你是一位信息安全专家,正在分析 SamWaf(Web 应用防火墙)记录到的一条 HTTP 请求。请判断这条请求是否存在攻击特征,输出如下格式: + +风险等级: 0-100 +风险类型:某种注入,跨站等 +风险说明:对风险的阐释` + +// gptDocRefs 官方资料索引。 +// 注意:对话走的是标准 chat/completions,没有联网/检索工具,模型打不开任何网页。 +// 所以这里不是"让它去搜",而是给它一份**准确的地址清单**,让它把用户导向官方文档和 issues, +// 同时明确禁止它编造链接或假装读过页面——否则模型很容易凭空造出 /guide/XXX.html。 +const gptDocRefs = ` + +# 官方资料(你没有联网能力,只能引用下面列出的地址;禁止编造其它链接,也不要声称自己读过页面内容) +- 文档站首页:https://doc.samwaf.com/ +- 功能文档地址格式:https://doc.samwaf.com/guide/<页面名>.html(英文界面把 /guide/ 换成 /en/guide/) +- 可用的页面名(只能从这里挑,拿不准就只给文档站首页): + Host 网站配置 | Rule 防护规则 | CC CC防护 | Owasp OWASP规则集 | AIDetection AI智能检测 | + IPBlack IP黑名单 | IPWhite IP白名单 | IPGroup IP组 | ThreatIP 威胁情报IP订阅 | FirewallIPBlock 防火墙IP封禁 | + UrlBlack URL限制访问 | UrlWhite URL白名单 | Sensitive 敏感词 | Ldp URL隐私防护 | Spider 爬虫识别 | + SSL SSL证书管理 | CDNIP CDN回源IP | Tunnel 隧道防护 | CacheRule 缓存规则 | Application 应用管理 | + AttackLog 风险日志 | VisitLog 访问日志 | Analysis 数据分析 | BlockingPage 自定义拦截页 | + NotifyChannel 通知渠道 | NotifySubscription 通知订阅 | NotifyLog 通知日志 | + HttpAuthBase 网站访问认证 | AccessConfig 认证配置 | AccessAccount 访问账号 | AccessSession 在线会话 | AccessAudit 认证审计 | + Account 账号管理 | Otp 双因素认证 | SystemConfig 系统配置 | VpConfig 参数设置 | PrivateInfo 密钥管理 | + OpenPlatform 开放平台 | SqlQuery SQL查询 | Task 任务管理 | BatchTask 批量任务 | DataRetention 数据保留策略 | + IPLocation IP数据库 | FileManage 文件管理 | RuntimeInfo 运行信息 | SysLog 系统日志 | OneKeyMod 一键修改 +- 常见问题:https://doc.samwaf.com/faq/ +- 安装与升级:https://doc.samwaf.com/quickstart/ 、https://doc.samwaf.com/quickstart/Update.html +- 已知问题与反馈:https://github.com/samwafgo/SamWaf/issues + 引导用户自己检索时,给出可直接点击的搜索地址:https://github.com/samwafgo/SamWaf/issues?q=is%3Aissue+关键词(多个关键词用 + 连接) + +引用规则: +1. 回答涉及某个具体功能时,在结尾附上对应文档页链接。 +2. 用户描述的现象像 bug、或你不确定当前版本是否支持,就直说"建议到 issues 搜一下,没有就提一个",并给出上面的 issues 搜索地址。 +3. 只给地址,不要转述你没有的页面内容,也不要编造文档里的章节名或配置项名称。` + +const gptSystemPromptOwaspRule = `你是 OWASP ModSecurity 核心规则集(CRS)专家,正在帮用户读懂 SamWaf 里的一条 CRS 规则。请用简洁中文说明: +1. 这条规则想拦什么攻击; +2. 匹配条件的含义(变量、操作符、转换函数分别起什么作用); +3. 常见误报场景; +4. 如果确认是误报,建议怎么处理(加白名单、调整阈值/等级、或对该规则单独放行)。 +不要输出与规则无关的内容。` + gptDocRefs + +const gptSystemPromptGeneral = `你是 SamWaf(一款开源轻量级 Web 应用防火墙)的智能助手,熟悉 Web 安全、网站防护、反向代理、SSL 证书、WAF 规则与告警等话题。 +请直接回答用户的问题:用与用户提问相同的语言(默认中文),简洁作答,必要时分点说明;不知道就说不知道,不要编造 SamWaf 不存在的功能。 +不要给回答强行套用固定模板或风险评分格式,除非用户明确要求。` + gptDocRefs + +// gptSystemPromptByScene 按场景取系统提示词,未知场景一律按通用问答处理 +func gptSystemPromptByScene(scene string) string { + switch strings.TrimSpace(strings.ToLower(scene)) { + case GptSceneSecurityLog: + return gptSystemPromptSecurityLog + case GptSceneOwaspRule: + return gptSystemPromptOwaspRule + default: + return gptSystemPromptGeneral + } +} + // 新增用于解析流式响应的结构体 type StreamResponse struct { ID string `json:"id"` @@ -322,6 +392,20 @@ type TokenUsage struct { TotalTokens int `json:"total_tokens"` } +// buildDeltaPayload 组装一条加密后的 SSE 消息体 +func buildDeltaPayload(content string, role string) (string, error) { + encryptStr, _ := wafsec.AesEncrypt([]byte(content), global.GWAF_COMMUNICATION_KEY) + msg := Delta{ + Content: encryptStr, + Role: &role, + } + payload, err := json.Marshal(msg) + if err != nil { + return "", err + } + return string(payload), nil +} + // SendDeltaMessage 发送信息 func SendDeltaMessage(messageChan chan<- string, content string, role ...string) { // 设置默认角色为 assistant @@ -329,23 +413,25 @@ func SendDeltaMessage(messageChan chan<- string, content string, role ...string) if len(role) > 0 { r = role[0] } - encryptStr, _ := wafsec.AesEncrypt([]byte(content), global.GWAF_COMMUNICATION_KEY) - // 创建消息结构 - msg := Delta{ - Content: encryptStr, - Role: &r, - } - - // 序列化并发送 - if payload, err := json.Marshal(msg); err == nil { - messageChan <- string(payload) + if payload, err := buildDeltaPayload(content, r); err == nil { + messageChan <- payload } } func (w *WafGPTApi) ChatApi(c *gin.Context) { var req request.WafGptSendReq err := c.ShouldBindJSON(&req) - if err == nil { + if err != nil { + // 以前这里静默返回,前端表现是"点了发送但气泡一直空着",很难排查。 + // 注意 StreamMiddleware 已经把 Content-Type 定成 text/event-stream 了, + // 这时候再 c.JSON 前端也当流读,所以错误也顺着 SSE 回,气泡里能直接看到。 + zlog.Error("GPT对话请求解析失败:" + err.Error()) + if payload, buildErr := buildDeltaPayload("对话请求解析失败:"+err.Error(), "assistant"); buildErr == nil { + c.SSEvent("message", payload) + } + return + } + { // 创建一个取消信号通道,用于触发异常退出 stopChan := make(chan bool) messageChan := make(chan string) @@ -355,11 +441,18 @@ func (w *WafGPTApi) ChatApi(c *gin.Context) { defer close(stopChan) defer close(messageChan) - // 构造基础消息数组 + // 未配置密钥时不去请求空地址,直接回一条可读提示,前端也会在打开助手时先检测并引导配置 + if !IsGPTConfigured() { + SendDeltaMessage(messageChan, "尚未配置AI密钥,请点击【AI 参数设置】填写接口地址/模型/密钥后再使用。", "assistant") + stopChan <- true + return + } + + // 构造基础消息数组:系统提示词按场景取(日志分析/规则解读/通用问答) messages := []model.GptMessage{ { - Content: "你是一位信息安全专家,输出如下格式:\n\n风险等级: 0-100\n风险类型:某种注入,跨站等\n风险说明:对风险的阐释", - Role: "user", + Content: gptSystemPromptByScene(req.Scene), + Role: "system", }, } // 将History内容转换为消息并追加 diff --git a/api/waf_sys_info.go b/api/waf_sys_info.go index 3860a5b..ca1cbb2 100644 --- a/api/waf_sys_info.go +++ b/api/waf_sys_info.go @@ -109,6 +109,48 @@ func (w *WafSysInfoApi) SysVersionApi(c *gin.Context) { }, "获取成功", c) } +// SysRuntimeInfoApi 获取系统运行环境信息 +// @Summary 获取系统运行环境信息 +// @Description 返回操作系统类型/发行版及版本、内核、容器(docker/k8s)、虚拟化、运行时长与软件版本,便于反馈问题时定位环境 +// @Tags 系统信息 +// @Produce json +// @Success 200 {object} response.Response{data=model.RuntimeSystemInfo} "获取成功" +// @Security ApiKeyAuth +// @Router /sysinfo/runtimeinfo [get] +func (w *WafSysInfoApi) SysRuntimeInfoApi(c *gin.Context) { + //探测过程全部容错:任何一项拿不到就留空,保证接口一定有返回 + detail := utils.GetOSDetail() + + info := model.RuntimeSystemInfo{ + Version: global.GWAF_RELEASE_VERSION, + VersionName: global.GWAF_RELEASE_VERSION_NAME, + VersionRelease: global.GWAF_RELEASE, + + OS: detail.GOOS, + Arch: detail.GOARCH, + GoVersion: detail.GoVersion, + + OSName: detail.OSName, + Platform: detail.Platform, + PlatformVersion: detail.PlatformVersion, + KernelVersion: detail.KernelVersion, + KernelArch: detail.KernelArch, + + IsWindows: detail.IsWindows, + IsWin7Build: global.GWAF_RUNTIME_WIN7_VERSION == "true", + IsWin7Kernel: detail.IsWin7Kernel, + Container: detail.Container, + InKubernetes: detail.InKubernetes, + IsWSL: detail.IsWSL, + Virtualization: detail.Virtualization, + RunAsService: global.GWAF_RUNTIME_SERVER_TYPE, + + SystemUptimeSeconds: int64(utils.GetSystemUptimeSeconds()), + ProcessUptimeSeconds: int64(time.Since(global.GWAF_RUNTIME_PROCESS_START_TIME).Seconds()), + } + response.OkWithDetailed(info, "获取成功", c) +} + func (w *WafSysInfoApi) CheckVersionApi(c *gin.Context) { if global.GWAF_RUNTIME_IS_UPDATETING == true { response.FailWithMessage("正在升级中...请在消息等待结果", c) diff --git a/api/waf_sys_info_test.go b/api/waf_sys_info_test.go index d4b1430..160e8fa 100644 --- a/api/waf_sys_info_test.go +++ b/api/waf_sys_info_test.go @@ -2,6 +2,7 @@ package api import ( "SamWaf/global" + "SamWaf/model" "SamWaf/wafsec" "encoding/json" "github.com/gin-gonic/gin" @@ -23,6 +24,59 @@ type ComplexData struct { VersionRelease string `json:"version_release"` } +// 测试用例 SysRuntimeInfoApi:系统信息探测必须永远有返回,不能因为取不到某项而失败 +func TestSysRuntimeInfoApi(t *testing.T) { + r := gin.Default() + global.GWAF_RELEASE_VERSION = "v1.0.0" + global.GWAF_RELEASE_VERSION_NAME = "20241028" + r.GET("/api/v1/sysinfo/runtimeinfo", new(WafSysInfoApi).SysRuntimeInfoApi) + + req, err := http.NewRequest(http.MethodGet, "/api/v1/sysinfo/runtimeinfo", nil) + if err != nil { + t.Fatalf("无法创建请求:%v", err) + } + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + t.Logf("响应体内容: %s", rec.Body.String()) + + if rec.Code != http.StatusOK { + t.Errorf("期望的状态码:%d,实际状态码:%d", http.StatusOK, rec.Code) + } + var response WafCheckVersionResponse + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("响应解析失败:%v", err) + } + if response.Code != 0 { + t.Errorf("期望的响应码:0,实际:%v", response.Code) + } + // 响应体默认经通讯密钥加密,先解密再解析 + payload := response.Data + var encrypted string + if json.Unmarshal(response.Data, &encrypted) == nil { + decrypted, err := wafsec.AesDecrypt(encrypted, global.GWAF_COMMUNICATION_KEY) + if err != nil { + t.Fatalf("响应解密失败:%v", err) + } + payload = decrypted + } + var info model.RuntimeSystemInfo + if err := json.Unmarshal(payload, &info); err != nil { + t.Fatalf("系统信息解析失败:%v", err) + } + if info.OS == "" || info.Arch == "" || info.GoVersion == "" { + t.Errorf("编译相关信息不应为空:%+v", info) + } + if info.OSName == "" { + t.Error("操作系统名称至少应有兜底值") + } + if info.Version != "v1.0.0" || info.VersionName != "20241028" { + t.Errorf("软件版本信息不正确:%+v", info) + } + if info.ProcessUptimeSeconds < 0 { + t.Errorf("程序运行时长不应为负:%v", info.ProcessUptimeSeconds) + } +} + // 测试用例 CheckVersionApi func TestCheckVersionApi(t *testing.T) { // 创建一个基于 Gin 的引擎 diff --git a/global/global.go b/global/global.go index 5ecefd0..68d02a7 100644 --- a/global/global.go +++ b/global/global.go @@ -63,6 +63,8 @@ var ( GWAF_RUNTIME_CURRENT_TUNNELPORT string = "" //当前程序所占用隧道端口 GWAF_RUNTIME_CURRENT_EXPORT_DB_LOG_FILE_PATH string = "" //生成的日志导出文件路径 + GWAF_RUNTIME_PROCESS_START_TIME time.Time = time.Now() //当前进程启动时间(包加载即赋值) + GWAF_RUNTIME_SSL_EXPIRE_CHECK bool = false //SSL过期检测是否正在运行 GWAF_RUNTIME_SSL_SYNC_HOST bool = false //主机同步信息到过期检测是否正在运行 /** diff --git a/model/request/waf_gpt_send_req.go b/model/request/waf_gpt_send_req.go index 1801523..a10fba6 100644 --- a/model/request/waf_gpt_send_req.go +++ b/model/request/waf_gpt_send_req.go @@ -2,6 +2,9 @@ package request type WafGptSendReq struct { History [][]string `json:"history"` // 定义结构体 + // Scene 对话场景,决定用哪套系统提示词: + // security_log 日志安全分析 / owasp_rule OWASP规则解读 / general 通用问答(默认) + Scene string `json:"scene"` } // WafGptRuleReq AI 生成自定义规则请求 diff --git a/model/sys_info.go b/model/sys_info.go index 6625f96..e08c8b6 100644 --- a/model/sys_info.go +++ b/model/sys_info.go @@ -8,3 +8,38 @@ type VersionInfo struct { VersionNew string `json:"version_new"` VersionDesc string `json:"version_desc"` } + +// RuntimeSystemInfo 系统运行环境信息(供前端版本弹窗展示,便于用户反馈问题时定位环境) +// 说明:所有字段都是"取不到就为空/零值",前端按需隐藏,不会因为某项探测失败而整体失败 +type RuntimeSystemInfo struct { + //软件信息 + Version string `json:"version"` //软件版本Code 如 v1.0.0 + VersionName string `json:"version_name"` //软件版本 如 20241028 + VersionRelease string `json:"version_release"` //是否正式版 "true"/"false" + + //编译信息 + OS string `json:"os"` //编译目标系统 windows/linux/darwin + Arch string `json:"arch"` //编译目标架构 amd64/arm64 + GoVersion string `json:"go_version"` //编译器版本 + + //操作系统信息 + OSName string `json:"os_name"` //操作系统名称 如 Ubuntu 24.04.1 LTS / Microsoft Windows Server 2012 R2 Standard + Platform string `json:"platform"` //平台 如 ubuntu + PlatformVersion string `json:"platform_version"` //平台版本 如 24.04 + KernelVersion string `json:"kernel_version"` //内核版本 + KernelArch string `json:"kernel_arch"` //内核架构(真实机器架构) + + //特殊环境标记(前端按条件展示) + IsWindows bool `json:"is_windows"` //是否 Windows 系统 + IsWin7Build bool `json:"is_win7_build"` //当前二进制是否 Win7 专版 + IsWin7Kernel bool `json:"is_win7_kernel"` //当前系统是否 Win7/Win8/Server2008R2 内核 + Container string `json:"container"` //容器类型:空=非容器;docker/podman/containerd/lxc + InKubernetes bool `json:"in_kubernetes"` //是否运行在 Kubernetes 中 + IsWSL bool `json:"is_wsl"` //是否运行在 WSL 中 + Virtualization string `json:"virtualization"` //虚拟化类型 如 kvm/vmware/hyperv,空=未识别 + RunAsService bool `json:"run_as_service"` //是否以系统服务方式启动 + + //运行时长 + SystemUptimeSeconds int64 `json:"system_uptime_seconds"` //操作系统已运行时长(秒),0=未取到 + ProcessUptimeSeconds int64 `json:"process_uptime_seconds"` //SamWaf 进程已运行时长(秒) +} diff --git a/router/waf_sysinfo.go b/router/waf_sysinfo.go index 9dc6de5..b03ff3a 100644 --- a/router/waf_sysinfo.go +++ b/router/waf_sysinfo.go @@ -16,6 +16,7 @@ func (receiver *WebSysInfoRouter) InitSysInfoRouter(group *gin.RouterGroup) { router := group.Group("") router.GET("/api/v1/sysinfo/version", api.SysVersionApi) router.GET("/api/v1/sysinfo/checkversion", api.CheckVersionApi) + router.GET("/api/v1/sysinfo/runtimeinfo", api.SysRuntimeInfoApi) // N7:自升级/回滚属高危系统操作,仅系统管理员(或超管)可执行 router.GET("/api/v1/sysinfo/update", middleware.RequireRole(enums.ROLE_SYSTEM_ADMIN), api.UpdateApi) router.GET("/api/v1/sysinfo/announcement", api.GetAnnouncementApi) diff --git a/service/waf_service/waf_stat.go b/service/waf_service/waf_stat.go index d08d55a..959a086 100644 --- a/service/waf_service/waf_stat.go +++ b/service/waf_service/waf_stat.go @@ -13,6 +13,7 @@ import ( "github.com/shirou/gopsutil/v4/host" "runtime" "strconv" + "strings" "time" ) @@ -606,15 +607,34 @@ func (receiver *WafStatService) StatHomeRumtimeSysinfo() []response2.WafNameValu Value: time.Unix(0, global.GWAF_MEASURE_PROCESS_DEQUEENGINE.ReadData()*int64(time.Millisecond)).Format("2006-01-02 15:04:05")}) data = append(data, response2.WafNameValue{Name: "Goroutine数量", Value: fmt.Sprintf("%v", runtime.NumGoroutine())}) + osDetail := utils.GetOSDetail() data = append(data, response2.WafNameValue{Name: "系统类型", Value: fmt.Sprintf("%v", runtime.GOOS)}) data = append(data, response2.WafNameValue{Name: "系统架构", Value: fmt.Sprintf("%v", runtime.GOARCH)}) - data = append(data, response2.WafNameValue{Name: "编译器版本", Value: fmt.Sprintf("%v", runtime.Version())}) - data = append(data, response2.WafNameValue{Name: "Win7内核", Value: func() string { - if global.GWAF_RUNTIME_WIN7_VERSION == "true" { - return "是" + // 具体操作系统发行版及版本,如 Ubuntu 24.04.1 LTS / Microsoft Windows Server 2012 R2 Standard + if osDetail.OSName != "" { + data = append(data, response2.WafNameValue{Name: "操作系统", Value: osDetail.OSName}) + } + if osDetail.KernelVersion != "" { + kernel := osDetail.KernelVersion + if osDetail.KernelArch != "" { + kernel = kernel + " (" + osDetail.KernelArch + ")" } - return "否" - }()}) + data = append(data, response2.WafNameValue{Name: "内核版本", Value: kernel}) + } + // 运行环境:容器/K8s/WSL/虚拟化,只有识别到才展示 + if env := describeRuntimeEnv(osDetail); env != "" { + data = append(data, response2.WafNameValue{Name: "运行环境", Value: env}) + } + data = append(data, response2.WafNameValue{Name: "编译器版本", Value: fmt.Sprintf("%v", runtime.Version())}) + // Win7内核只在 Windows 下才有意义,其它系统不展示 + if osDetail.IsWindows { + data = append(data, response2.WafNameValue{Name: "Win7内核", Value: func() string { + if global.GWAF_RUNTIME_WIN7_VERSION == "true" { + return "是" + } + return "否" + }()}) + } // 获取开机时间 boottime, _ := host.BootTime() ntime := time.Now().Unix() @@ -631,6 +651,8 @@ func (receiver *WafStatService) StatHomeRumtimeSysinfo() []response2.WafNameValu data = append(data, response2.WafNameValue{ Name: "系统已运行时长", Value: fmt.Sprintf("%v 天 %v 时 %v 分 %v 秒", days, hours, minutes, seconds)}) + data = append(data, response2.WafNameValue{ + Name: "程序已运行时长", Value: utils.FormatDurationCN(int64(time.Since(global.GWAF_RUNTIME_PROCESS_START_TIME).Seconds()))}) data = append(data, response2.WafNameValue{Name: "软件版本", Value: fmt.Sprintf("%v", global.GWAF_RELEASE_VERSION_NAME)}) data = append(data, response2.WafNameValue{Name: "软件版本Code", Value: fmt.Sprintf("%v", global.GWAF_RELEASE_VERSION)}) @@ -643,3 +665,21 @@ func (receiver *WafStatService) StatHomeRumtimeSysinfo() []response2.WafNameValu return data } + +// describeRuntimeEnv 把容器/K8s/WSL/虚拟化信息拼成一句可读描述,都没识别到就返回空字符串 +func describeRuntimeEnv(detail utils.OSDetail) string { + var parts []string + if detail.Container != "" { + parts = append(parts, "容器("+detail.Container+")") + } + if detail.InKubernetes { + parts = append(parts, "Kubernetes") + } + if detail.IsWSL { + parts = append(parts, "WSL") + } + if detail.Virtualization != "" { + parts = append(parts, "虚拟化("+detail.Virtualization+")") + } + return strings.Join(parts, " / ") +} diff --git a/test/apicheck/api_routes_test.go b/test/apicheck/api_routes_test.go index 12e238c..f68fc37 100644 --- a/test/apicheck/api_routes_test.go +++ b/test/apicheck/api_routes_test.go @@ -239,6 +239,7 @@ var registeredRoutes = []route{ // ── 系统信息 ─────────────────────────────────────────────────── {"GET", "/api/v1/sysinfo/version", "获取版本信息"}, + {"GET", "/api/v1/sysinfo/runtimeinfo", "获取系统运行环境信息"}, // ── 系统日志 ─────────────────────────────────────────────────── {"GET", "/api/v1/sys_log/list", "获取系统日志列表"}, diff --git a/utils/os_detail.go b/utils/os_detail.go new file mode 100644 index 0000000..5871eac --- /dev/null +++ b/utils/os_detail.go @@ -0,0 +1,252 @@ +package utils + +import ( + "SamWaf/common/zlog" + "bufio" + "errors" + "fmt" + "os" + "runtime" + "strings" + "sync" + + "github.com/shirou/gopsutil/v4/host" +) + +// OSDetail 运行环境详情:用于版本弹窗/运行参数展示,方便用户反馈问题时定位环境 +type OSDetail struct { + GOOS string `json:"goos"` //编译目标系统 windows/linux/darwin + GOARCH string `json:"goarch"` //编译目标架构 amd64/arm64 + GoVersion string `json:"go_version"` //编译器版本 + + OSName string `json:"os_name"` //友好的操作系统名称 如 Ubuntu 24.04.1 LTS / Microsoft Windows Server 2012 R2 Standard + Platform string `json:"platform"` //平台 如 ubuntu / centos / Microsoft Windows 10 Pro + PlatformFamily string `json:"platform_family"` //平台家族 如 debian / rhel + PlatformVersion string `json:"platform_version"` //平台版本 如 24.04 + KernelVersion string `json:"kernel_version"` //内核版本 + KernelArch string `json:"kernel_arch"` //内核架构(真实机器架构,32位程序跑在64位系统时与 GOARCH 不同) + + IsWindows bool `json:"is_windows"` //是否 Windows 系统 + IsWin7Kernel bool `json:"is_win7_kernel"` //当前系统是否是 Win7/Win8/Server2008R2 内核(6.1/6.2) + + Container string `json:"container"` //容器类型:空=非容器;docker/podman/containerd/lxc/oci + InKubernetes bool `json:"in_kubernetes"` //是否运行在 Kubernetes 中 + IsWSL bool `json:"is_wsl"` //是否运行在 WSL 中 + Virtualization string `json:"virtualization"` //虚拟化类型(仅 guest 角色时给出) 如 kvm/vmware/hyperv +} + +var ( + osDetailOnce sync.Once + osDetailCache OSDetail + + errHostInfoPanic = errors.New("读取主机信息发生异常") +) + +// GetOSDetail 获取运行环境详情(静态信息,进程内只探测一次) +// 探测过程全部容错:任何一项取不到就留空,不会返回错误、也不会 panic +func GetOSDetail() OSDetail { + osDetailOnce.Do(func() { + // 基础信息不依赖任何系统调用,先兜底填上,保证即使后续探测异常也有值可展示 + osDetailCache = OSDetail{ + GOOS: runtime.GOOS, + GOARCH: runtime.GOARCH, + GoVersion: runtime.Version(), + IsWindows: runtime.GOOS == "windows", + OSName: runtime.GOOS, + } + defer func() { + // gopsutil 在部分定制/裁剪系统上可能 panic,这里兜住,保留已填充的基础信息 + if err := recover(); err != nil { + zlog.Warn("获取系统运行环境信息异常", err) + } + }() + osDetailCache = buildOSDetail() + }) + return osDetailCache +} + +func buildOSDetail() OSDetail { + detail := OSDetail{ + GOOS: runtime.GOOS, + GOARCH: runtime.GOARCH, + GoVersion: runtime.Version(), + IsWindows: runtime.GOOS == "windows", + IsWin7Kernel: safeBool(IsSupportedWindows7Version), + } + + if info, err := safeHostInfo(); err == nil && info != nil { + detail.Platform = info.Platform + detail.PlatformFamily = info.PlatformFamily + detail.PlatformVersion = info.PlatformVersion + detail.KernelVersion = info.KernelVersion + detail.KernelArch = info.KernelArch + // 只有作为 guest 运行时虚拟化类型才有意义,宿主机(host 角色)不展示 + if strings.EqualFold(info.VirtualizationRole, "guest") { + detail.Virtualization = info.VirtualizationSystem + } + } + + detail.OSName = buildOSName(detail) + detail.Container = detectContainerRuntime() + detail.InKubernetes = os.Getenv("KUBERNETES_SERVICE_HOST") != "" + detail.IsWSL = detectWSL() + + return detail +} + +// GetSystemUptimeSeconds 获取系统已运行时长(秒),取不到返回 0,不会 panic +func GetSystemUptimeSeconds() (seconds uint64) { + defer func() { + if r := recover(); r != nil { + zlog.Warn("读取系统运行时长异常", r) + seconds = 0 + } + }() + up, err := host.Uptime() + if err != nil { + zlog.Warn("读取系统运行时长失败:" + err.Error()) + return 0 + } + return up +} + +// FormatDurationCN 把秒数格式化为 "x 天 x 时 x 分 x 秒" +func FormatDurationCN(totalSeconds int64) string { + if totalSeconds < 0 { + totalSeconds = 0 + } + seconds := totalSeconds % 60 + minutes := (totalSeconds / 60) % 60 + hours := (totalSeconds / 3600) % 24 + days := totalSeconds / 86400 + return fmt.Sprintf("%v 天 %v 时 %v 分 %v 秒", days, hours, minutes, seconds) +} + +// safeHostInfo 调用 gopsutil 获取主机信息,任何 panic 都转成错误返回 +func safeHostInfo() (info *host.InfoStat, err error) { + defer func() { + if r := recover(); r != nil { + zlog.Warn("读取主机信息异常", r) + info = nil + err = errHostInfoPanic + } + }() + return host.Info() +} + +// safeBool 执行可能触发系统调用的判断函数,异常时返回 false +func safeBool(fn func() bool) (result bool) { + defer func() { + if r := recover(); r != nil { + zlog.Warn("系统信息判断异常", r) + result = false + } + }() + return fn() +} + +// buildOSName 拼一个尽量可读的操作系统名称 +func buildOSName(detail OSDetail) string { + // Linux 优先取 /etc/os-release 的 PRETTY_NAME,如 "Ubuntu 24.04.1 LTS"、"Alpine Linux v3.20" + if runtime.GOOS == "linux" { + if pretty := readOSReleasePrettyName(); pretty != "" { + return pretty + } + } + name := strings.TrimSpace(detail.Platform) + version := strings.TrimSpace(detail.PlatformVersion) + if name == "" { + name = runtime.GOOS + } + if version != "" && !strings.Contains(name, version) { + name = name + " " + version + } + return name +} + +// readOSReleasePrettyName 读取 /etc/os-release(或 /usr/lib/os-release) 中的 PRETTY_NAME +func readOSReleasePrettyName() string { + for _, path := range []string{"/etc/os-release", "/usr/lib/os-release"} { + f, err := os.Open(path) + if err != nil { + continue + } + scanner := bufio.NewScanner(f) + pretty := "" + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if !strings.HasPrefix(line, "PRETTY_NAME=") { + continue + } + pretty = strings.Trim(strings.TrimPrefix(line, "PRETTY_NAME="), `"'`) + break + } + f.Close() + if pretty != "" { + return pretty + } + } + return "" +} + +// detectContainerRuntime 粗略判断当前进程运行在什么容器里,非容器返回空字符串 +func detectContainerRuntime() string { + if runtime.GOOS != "linux" { + return "" + } + // docker 官方镜像会写入该标记文件 + if _, err := os.Stat("/.dockerenv"); err == nil { + return "docker" + } + // podman/buildah 的标记文件 + if _, err := os.Stat("/run/.containerenv"); err == nil { + return "podman" + } + // systemd-nspawn / podman / lxc 会注入 container 环境变量 + if env := strings.ToLower(strings.TrimSpace(os.Getenv("container"))); env != "" { + return env + } + // 退回到 cgroup 特征判断(cgroup v1 常见;v2 下 /proc/1/cgroup 可能只有 "0::/") + if data, err := os.ReadFile("/proc/1/cgroup"); err == nil { + content := strings.ToLower(string(data)) + switch { + case strings.Contains(content, "docker"): + return "docker" + case strings.Contains(content, "kubepods"): + return "containerd" + case strings.Contains(content, "containerd"): + return "containerd" + case strings.Contains(content, "lxc"): + return "lxc" + } + } + // cgroup v2 场景:挂载信息里通常能看到 overlay 根文件系统与容器运行时目录 + if data, err := os.ReadFile("/proc/self/mountinfo"); err == nil { + content := strings.ToLower(string(data)) + switch { + case strings.Contains(content, "/docker/containers/"): + return "docker" + case strings.Contains(content, "/containers/storage/"): + return "podman" + case strings.Contains(content, "/kubelet/pods/"): + return "containerd" + } + } + return "" +} + +// detectWSL 判断是否运行在 WSL(Windows Subsystem for Linux) +func detectWSL() bool { + if runtime.GOOS != "linux" { + return false + } + if os.Getenv("WSL_DISTRO_NAME") != "" || os.Getenv("WSL_INTEROP") != "" { + return true + } + data, err := os.ReadFile("/proc/version") + if err != nil { + return false + } + content := strings.ToLower(string(data)) + return strings.Contains(content, "microsoft") || strings.Contains(content, "wsl") +} diff --git a/utils/os_detail_test.go b/utils/os_detail_test.go new file mode 100644 index 0000000..ab6dd74 --- /dev/null +++ b/utils/os_detail_test.go @@ -0,0 +1,53 @@ +package utils + +import ( + "runtime" + "testing" +) + +// 探测失败也必须给出兜底值,不能 panic、不能整体为空 +func TestGetOSDetail(t *testing.T) { + detail := GetOSDetail() + + if detail.GOOS != runtime.GOOS || detail.GOARCH != runtime.GOARCH { + t.Errorf("编译信息不正确: %+v", detail) + } + if detail.GoVersion == "" { + t.Error("编译器版本不应为空") + } + if detail.OSName == "" { + t.Error("操作系统名称至少要有兜底值") + } + if detail.IsWindows != (runtime.GOOS == "windows") { + t.Errorf("IsWindows 判断不正确: %v", detail.IsWindows) + } + // 非 Windows 不可能是 Win7 内核 + if runtime.GOOS != "windows" && detail.IsWin7Kernel { + t.Error("非 Windows 系统不应判定为 Win7 内核") + } + // 非 Linux 不做容器/WSL 判定 + if runtime.GOOS != "linux" && (detail.Container != "" || detail.IsWSL) { + t.Errorf("非 Linux 不应识别出容器/WSL: container=%v wsl=%v", detail.Container, detail.IsWSL) + } + + t.Logf("运行环境: %+v", detail) + t.Logf("系统已运行: %v", FormatDurationCN(int64(GetSystemUptimeSeconds()))) +} + +func TestFormatDurationCN(t *testing.T) { + cases := []struct { + seconds int64 + want string + }{ + {0, "0 天 0 时 0 分 0 秒"}, + {-10, "0 天 0 时 0 分 0 秒"}, //负数按 0 处理,不能出现负值展示 + {59, "0 天 0 时 0 分 59 秒"}, + {3661, "0 天 1 时 1 分 1 秒"}, + {262145, "3 天 0 时 49 分 5 秒"}, + } + for _, c := range cases { + if got := FormatDurationCN(c.seconds); got != c.want { + t.Errorf("FormatDurationCN(%v)=%v, 期望 %v", c.seconds, got, c.want) + } + } +}