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
This commit is contained in:
2026-09-24 10:15:11 +08:00
parent f75e672a45
commit 2f59f5adc4
19 changed files with 953 additions and 7 deletions
+233
View File
@@ -0,0 +1,233 @@
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)
}