Files
Havline/internal/agent/insights.go
T
mrlee 2f59f5adc4 feat(agent): agent 0.10.0 新增资源/日志/配置回读端点,前端接入 VPS 资源与公网 Nginx 日志(v1.2.4)
agent 0.10.0:新增 GET /api/v1/metrics(资源采集,复用 internal/sysinfo;CPU 两次采样差值 + 1 秒内复用上次结果)、GET /api/v1/logs/nginx(日志尾部,路径白名单来自 nginx -T / 环境变量 / 默认目录,32KB 块倒读、单行 4KB 截断、上限 2000 行)、GET /api/v1/frps/config(frps.toml 回读,256KB 上限)

internal/sysinfo:补 LoadAvg 与 UptimeSeconds(Linux /proc/loadavg 与 /proc/uptime、Windows GetTickCount64、其它平台空值)

应用侧:AgentClient 三个只读方法;request 错误改为带状态码的 statusError,新增 ErrAgentEndpointMissing,把 404/405 统一转成「agent 版本过低需 ≥ 0.10.0」提示;补 service/handler/router

前端:公网 Agent 页「VPS 资源」横排带(占满环境与依赖整行);日志中心「公网 Nginx」页签;frps 同源配置弹窗「比对 VPS 实际文件」

二进制:内嵌 fonu-agent-linux-amd64 / arm64 用新代码重编;VERSION → 1.2.4
2026-09-24 10:15:11 +08:00

234 lines
6.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package agent
import (
"bytes"
"fmt"
"io"
"math"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/fonu/fonu/internal/sysinfo"
)
const (
// metricsInterval 两次 CPU 采样的最小间隔:间隔内重复调用直接返回上次结果,避免数值抖动
metricsInterval = time.Second
// maxLogLineBytes 单行日志截断长度,避免一行超长把响应撑大
maxLogLineBytes = 4 << 10
// maxLogLines 日志尾部行数上限
maxLogLines = 2000
// maxFRPSConfigBytes frps.toml 回读上限
maxFRPSConfigBytes = 256 << 10
)
// metrics 采集 VPS 主机资源(CPU / 内存 / 磁盘 / 负载 / 运行时长)。
// 复用 internal/sysinfo:agent 跑在 VPS 上,采到的天然是 VPS 自身数值。
func (s *Server) metrics(w http.ResponseWriter, _ *http.Request) {
s.metricsMu.Lock()
defer s.metricsMu.Unlock()
now := time.Now()
if s.metricsCached != nil && now.Sub(s.metricsAt) < metricsInterval {
writeJSON(w, http.StatusOK, s.metricsCached)
return
}
stats, sample := sysinfo.Read(s.metricsSample)
s.metricsSample = sample
payload := map[string]any{
"cpu_percent": math.Round(stats.CPUPercent*10) / 10,
"mem_used_bytes": stats.MemUsedBytes,
"mem_total_bytes": stats.MemTotalBytes,
"disk_used_bytes": stats.DiskUsedBytes,
"disk_total_bytes": stats.DiskTotalBytes,
// 当前只统计根文件系统(与 internal/sysinfo 的 Linux 实现一致)
"disk_path": "/",
"checked_at": now.UTC().Format(time.RFC3339),
}
if load := sysinfo.LoadAvg(); len(load) == 3 {
payload["load_avg"] = load
}
if uptime := sysinfo.UptimeSeconds(); uptime > 0 {
payload["uptime_seconds"] = uptime
}
s.metricsAt = now
s.metricsCached = payload
writeJSON(w, http.StatusOK, payload)
}
// nginxLogs 读取 VPS 上 Nginx 的 access / error 日志尾部。
// 路径不接受请求参数:只从 `nginx -T` 的实际生效配置、默认目录或环境变量目录里取,避免变成任意文件读取。
func (s *Server) nginxLogs(w http.ResponseWriter, r *http.Request) {
kind := strings.TrimSpace(r.URL.Query().Get("type"))
if kind != "access" && kind != "error" {
kind = "access"
}
lines := maxLogLines
if raw := strings.TrimSpace(r.URL.Query().Get("lines")); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil {
lines = parsed
}
}
if lines <= 0 || lines > maxLogLines {
lines = maxLogLines
}
path := s.nginxLogPath(kind)
if path == "" {
writeJSON(w, http.StatusOK, map[string]any{
"type": kind, "path": "", "lines": []string{}, "truncated": false,
"checked_at": time.Now().UTC().Format(time.RFC3339),
})
return
}
content, truncated, err := tailFile(path, lines, maxLogLineBytes)
if err != nil {
if os.IsNotExist(err) {
// 文件不存在不是错误:返回空列表 + 路径,由前端提示「未找到日志文件」
writeJSON(w, http.StatusOK, map[string]any{
"type": kind, "path": path, "lines": []string{}, "truncated": false,
"checked_at": time.Now().UTC().Format(time.RFC3339),
})
return
}
status := http.StatusInternalServerError
if os.IsPermission(err) {
status = http.StatusForbidden
}
writeJSON(w, status, map[string]any{"error": fmt.Sprintf("读取日志失败:%v", err), "path": path})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"type": kind, "path": path, "lines": content, "truncated": truncated,
"checked_at": time.Now().UTC().Format(time.RFC3339),
})
}
// nginxLogPath 解析日志文件路径:优先 `nginx -T` 实际生效配置里的指令,其次环境变量目录,最后默认目录
func (s *Server) nginxLogPath(kind string) string {
directive := "access_log"
if kind == "error" {
directive = "error_log"
}
if nginx := s.nginxPath(); nginx != "" {
if out, err := exec.Command(nginx, "-T").CombinedOutput(); err == nil {
if path := logPathFromDump(string(out), directive); path != "" {
return path
}
}
}
dir := strings.TrimSpace(os.Getenv("FONU_AGENT_NGINX_LOG_DIR"))
if dir == "" {
dir = "/var/log/nginx"
}
return filepath.Join(dir, kind+".log")
}
// logPathFromDump 从 `nginx -T` 输出里取第一条绝对路径的日志指令(跳过 off / stderr / 含变量的写法)
func logPathFromDump(dump, directive string) string {
pattern := regexp.MustCompile(`(?m)^\s*` + directive + `\s+([^;\s]+)`)
for _, match := range pattern.FindAllStringSubmatch(dump, -1) {
candidate := strings.TrimSpace(match[1])
if candidate == "" || candidate == "off" || candidate == "stderr" || strings.Contains(candidate, "$") {
continue
}
// 只接受绝对路径:agent 只跑在 Linux 上,用前导斜杠判定(filepath.IsAbs 在 Windows 上对 /var/... 返回 false)
if strings.HasPrefix(candidate, "/") {
return candidate
}
}
return ""
}
// tailFile 从文件末尾倒读最多 lines 行:按块回溯,避免把整个日志读进内存;单行超长时截断
func tailFile(path string, lines int, maxLine int) ([]string, bool, error) {
file, err := os.Open(path)
if err != nil {
return nil, false, err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return nil, false, err
}
const chunkSize = 32 * 1024
var (
offset int64 = info.Size()
buffer []byte
)
for offset > 0 && bytes.Count(buffer, []byte("\n")) <= lines {
readSize := int64(chunkSize)
if offset < readSize {
readSize = offset
}
offset -= readSize
block := make([]byte, readSize)
if _, err := file.ReadAt(block, offset); err != nil && err != io.EOF {
return nil, false, err
}
buffer = append(block, buffer...)
}
rows := strings.Split(strings.TrimRight(string(buffer), "\n"), "\n")
truncated := false
if len(rows) > lines {
rows = rows[len(rows)-lines:]
truncated = true
}
out := make([]string, 0, len(rows))
for _, row := range rows {
if len(row) > maxLine {
row = row[:maxLine] + "…"
}
out = append(out, row)
}
return out, truncated, nil
}
// frpsConfig 回读 VPS 上的 frps.toml,供应用侧与「即将下发的内容」比对,检出被手工修改的情况
func (s *Server) frpsConfig(w http.ResponseWriter, _ *http.Request) {
path := strings.TrimSpace(s.cfg.FrpsConfigPath)
payload := map[string]any{
"path": path, "exists": false, "content": "", "truncated": false,
"checked_at": time.Now().UTC().Format(time.RFC3339),
}
if path == "" {
writeJSON(w, http.StatusOK, payload)
return
}
info, err := os.Stat(path)
if err != nil {
writeJSON(w, http.StatusOK, payload)
return
}
data, err := os.ReadFile(path)
if err != nil {
status := http.StatusInternalServerError
if os.IsPermission(err) {
status = http.StatusForbidden
}
writeJSON(w, status, map[string]any{"error": fmt.Sprintf("读取 frps 配置失败:%v", err), "path": path})
return
}
if len(data) > maxFRPSConfigBytes {
data = data[:maxFRPSConfigBytes]
payload["truncated"] = true
}
payload["exists"] = true
payload["content"] = string(data)
payload["mod_time"] = info.ModTime().UTC().Format(time.RFC3339)
writeJSON(w, http.StatusOK, payload)
}