diff --git a/CHANGELOG.md b/CHANGELOG.md index 600e6ad..180f4d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ All notable changes to this project will be documented in this file. +## [v1.2.12] - 2026-09-25 + +### 修复 + +- **frps.toml 同源配置预览泄露明文密钥**:`FRPSServerConfig` 是对外**预览**入口,之前直接把 builder 输出返回,而模板里含解密后的 `auth.token`——「即将下发的内容」弹窗拿到的是明文,与同文件「密钥不入其他 API 响应」的约定矛盾。现在预览统一走 `maskFRPSSecrets`(`auth.token` / `auth.oidc.clientSecret` 的值换成 `********`,键名与行结构保留、注释行不动);**真正下发路径不受影响**(`PushFRPSConfigToAgent` 与安装流程仍用未脱敏内容)。预览弹窗补了说明,「复制」改为「复制(已脱敏)」 +- 规则详情「日志」页签改为**倒序**:加载后反转,最新一条在最上面(与日志页「FRP 日志」页签一致) + +### 改进 + +- 日志页「FRP 日志」的**来源**列不再显示 frpc 的 Go 源码文件行号(如 `client/control.go:174`),改为中文模块名(登录 / 连接 / 代理 / 服务 / 配置 / 健康检查 / 服务端);认不出来的值(包括回落到的组件名)原样保留 +- 「公网服务端」日志弹窗改为显示 **VPS 上的 frps 服务日志**(该页职责是管理 frps 端),标题与说明同步;本机 frpc 日志仍在日志页「FRP 日志」页签 +- frps 日志从写死 6 行改为**最近 20 行、最新在最上面**,并把 `2026-09-22T18:43:34+08:00 RainYun-XXX systemd[1]: …` 重写成 `2026-09-22 18:43:34 systemd[1]: …`(去掉 T 分隔、时区偏移与每行重复的主机名);`frps/action` 失败时带回的日志一并统一格式 + +### 测试 + +- `internal/frp`:`maskFRPSSecrets` 脱敏(明文不出现、非敏感行不被误伤)与注释行不被改写 + +### 变更 + +- **agent 行为变更**:`AgentVersion` 0.12.2 → **0.12.4**(0.12.3:20 行 + 倒序 + ISO;0.12.4:重写日期格式),双架构内嵌二进制已重编;**需在 VPS 上点一次「升级 Agent」**才生效 +- 版本号提升到 1.2.12;`web/package.json` 同步对齐 + ## [v1.2.11] - 2026-09-25 ### 新增 diff --git a/VERSION b/VERSION index c114700..f2ae0b4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.11 +1.2.12 diff --git a/internal/agent/server.go b/internal/agent/server.go index 8cefcef..eb2f547 100644 --- a/internal/agent/server.go +++ b/internal/agent/server.go @@ -41,7 +41,9 @@ import ( // nginx 配置注释(改为只在 PUT 响应里回一次)。 // 0.12.1:frps 启停失败如实回报(不再无条件写「已启动」);HTTP 服务补 ReadTimeout/IdleTimeout。 // 0.12.2:新增域名 vhost 的版本列表 / 对比 / 回滚端点;写配置前留版本,校验失败时恢复上一个可用版本。 -const AgentVersion = "0.12.2" +// 0.12.3:frps 日志尾巴改为最近 20 行、最新在前,并用 short-iso 时间戳(默认格式不带年份、分不清哪一天)。 +// 0.12.4:日志行重写成「2026-09-22 18:43:34 进程: 消息」,去掉 T 分隔、时区偏移与每行重复的主机名。 +const AgentVersion = "0.12.4" // Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。 type Config struct { @@ -234,7 +236,7 @@ func (s *Server) status(w http.ResponseWriter, _ *http.Request) { "frps_unit_exec": strings.TrimSpace(execCommandOutput("systemctl", "show", "frps", "-p", "ExecStart", "--value")), "frps_enabled": exec.Command("systemctl", "is-enabled", "--quiet", "frps").Run() == nil, "frps_version": strings.TrimSpace(frpsVersion(s.cfg.FrpsBinary)), - "frps_log_tail": strings.TrimSpace(journalTail("frps", 6)), + "frps_log_tail": strings.TrimSpace(journalTailDesc("frps", frpsLogTailLines)), "dashboard_addr": strings.TrimSpace(dashboardAddr(s.cfg.FrpsConfigPath)), "routes": s.routeNames(), "checked_at": time.Now().UTC().Format(time.RFC3339), @@ -535,12 +537,65 @@ func frpsVersion(binary string) string { return string(output) } -func journalTail(unit string, lines int) string { - output, err := exec.Command("journalctl", "-u", unit, "-n", strconv.Itoa(lines), "--no-pager").CombinedOutput() +// frpsLogTailLines 服务端详情里展示的 frps 日志行数 +const frpsLogTailLines = 20 + +// journalLinePattern 匹配 short-iso 的日志首段:时间戳 + 主机名 + 其余(进程与消息) +var journalLinePattern = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})(?:[+-]\d{2}:?\d{2}|Z)\s+\S+\s+(.*)$`) + +// formatJournalLine 把 journalctl 的行重写成「2026-09-22 18:43:34 进程: 消息」。 +// short-iso 的 T 分隔与 +08:00 偏移看着费力,主机名在单机视图里每行都一样(纯噪声), +// 认不出来格式的行原样返回,不丢内容。 +func formatJournalLine(line string) string { + matched := journalLinePattern.FindStringSubmatch(line) + if matched == nil { + return line + } + return matched[1] + " " + matched[2] + " " + matched[3] +} + +// formatJournalLog 逐行重写整段日志 +func formatJournalLog(text string) string { + if strings.TrimSpace(text) == "" { + return "" + } + rows := strings.Split(strings.TrimRight(text, "\n"), "\n") + for i, row := range rows { + rows[i] = formatJournalLine(row) + } + return strings.Join(rows, "\n") +} + +// journalTailDesc 取 unit 的最近 lines 行系统日志,按「最新在前」返回。 +// 用 short-iso 时间戳:journalctl 默认格式只有 "Sep 25 13:20:04",跨天排查时分不清是哪一天。 +func journalTailDesc(unit string, lines int) string { + if lines <= 0 { + return "" + } + output, err := exec.Command("journalctl", "-u", unit, "-n", strconv.Itoa(lines), "-o", "short-iso", "--no-pager").CombinedOutput() if err != nil { return "" } - return string(output) + if strings.TrimSpace(string(output)) == "" { + return "" + } + rows := strings.Split(strings.TrimRight(string(output), "\n"), "\n") + // journalctl 输出是「旧 → 新」,倒过来让最先看到的是最新一条 + for i, j := 0, len(rows)-1; i < j; i, j = i+1, j-1 { + rows[i], rows[j] = rows[j], rows[i] + } + for i, row := range rows { + rows[i] = formatJournalLine(row) + } + return strings.Join(rows, "\n") +} + +func journalTail(unit string, lines int) string { + output, err := exec.Command("journalctl", "-u", unit, "-n", strconv.Itoa(lines), "-o", "short-iso", "--no-pager").CombinedOutput() + if err != nil { + return "" + } + return formatJournalLog(string(output)) } // nginxBinaryPath 解析实际可用的 nginx 可执行文件:优先传入名称(PATH/绝对路径), diff --git a/internal/frp/agentbin/fonu-agent-linux-amd64 b/internal/frp/agentbin/fonu-agent-linux-amd64 index d5823a1..8574919 100644 Binary files a/internal/frp/agentbin/fonu-agent-linux-amd64 and b/internal/frp/agentbin/fonu-agent-linux-amd64 differ diff --git a/internal/frp/agentbin/fonu-agent-linux-arm64 b/internal/frp/agentbin/fonu-agent-linux-arm64 index c93131f..39290c3 100644 Binary files a/internal/frp/agentbin/fonu-agent-linux-arm64 and b/internal/frp/agentbin/fonu-agent-linux-arm64 differ diff --git a/internal/frp/frps_template.go b/internal/frp/frps_template.go index c0df5da..56aba0b 100644 --- a/internal/frp/frps_template.go +++ b/internal/frp/frps_template.go @@ -3,6 +3,7 @@ package frp import ( "context" "fmt" + "regexp" "strconv" "strings" ) @@ -206,11 +207,34 @@ func renderAllowPorts(ports string) string { return strings.Join(lines, "\n") } +// frpsSecretLinePattern 匹配带着敏感值的 frps.toml 行 +var frpsSecretLinePattern = regexp.MustCompile(`(?i)^\s*(auth\.token|auth\.oidc\.clientsecret)\s*=`) + +// maskFRPSSecrets 把 frps.toml 里的敏感值换成掩码:保留键名与行结构, +// 让用户在预览里仍能看清「配了哪些认证项」,但不下发明文密钥。 +// 只用于预览;下发路径(PushFRPSConfigToAgent)必须用未脱敏的内容。 +func maskFRPSSecrets(content string) string { + lines := strings.Split(content, "\n") + for i, line := range lines { + if !frpsSecretLinePattern.MatchString(line) { + continue + } + key := strings.TrimSpace(strings.SplitN(line, "=", 2)[0]) + lines[i] = fmt.Sprintf("%s = %q", key, maskedToken) + } + return strings.Join(lines, "\n") +} + // FRPSServerConfig 返回指定服务端的 frps.toml 同源配置(对外 API 入口)。 +// 这是**预览**入口:密钥一律脱敏(明文只在 PushFRPSConfigToAgent 的下发路径里出现)。 func (s *Service) FRPSServerConfig(ctx context.Context, id int64) (string, error) { server, err := s.store.GetServer(ctx, id) if err != nil { return "", err } - return s.buildFRPSServerConfig(ctx, server) + content, err := s.buildFRPSServerConfig(ctx, server) + if err != nil { + return "", err + } + return maskFRPSSecrets(content), nil } diff --git a/internal/frp/frps_template_mask_test.go b/internal/frp/frps_template_mask_test.go new file mode 100644 index 0000000..16a4512 --- /dev/null +++ b/internal/frp/frps_template_mask_test.go @@ -0,0 +1,43 @@ +package frp + +import ( + "strings" + "testing" +) + +// frps.toml 的预览(FRPSServerConfig 返回的内容)绝不能带明文密钥: +// 拉到的内容会直接渲染在浏览器的「即将下发的内容」里。 +func TestMaskFRPSSecrets(t *testing.T) { + content := strings.Join([]string{ + `bindPort = 7000`, + `auth.method = "token"`, + `auth.token = "s3cr3t-token"`, + `auth.oidc.clientSecret = "s3cr3t-oidc"`, + `# auth.token = "注释里的示例不应受影响"`, + `webServer.addr = "0.0.0.0"`, + }, "\n") + + masked := maskFRPSSecrets(content) + if strings.Contains(masked, "s3cr3t-token") || strings.Contains(masked, "s3cr3t-oidc") { + t.Fatalf("预览里仍能看到明文密钥:\n%s", masked) + } + for _, expected := range []string{ + `auth.token = "********"`, + `auth.oidc.clientSecret = "********"`, + `bindPort = 7000`, + `auth.method = "token"`, + `webServer.addr = "0.0.0.0"`, + } { + if !strings.Contains(masked, expected) { + t.Fatalf("脱敏后缺少 %q:\n%s", expected, masked) + } + } +} + +// 注释行不是生效配置,不参与脱敏(保持模板原样) +func TestMaskFRPSSecretsKeepsComments(t *testing.T) { + content := `# auth.token = "说明文字"` + if got := maskFRPSSecrets(content); got != content { + t.Fatalf("注释行被改写:%q", got) + } +} diff --git a/web/package.json b/web/package.json index aaa701e..9283623 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "fonu-web", "private": true, - "version": "1.2.11", + "version": "1.2.12", "packageManager": "npm@10.9.4", "type": "module", "scripts": { diff --git a/web/src/views/AgentView.vue b/web/src/views/AgentView.vue index c5bdc4e..1fc662c 100644 --- a/web/src/views/AgentView.vue +++ b/web/src/views/AgentView.vue @@ -336,11 +336,13 @@

由 Fonu 本地生成;点「下发 frps 配置」后写入 VPS 的 {{ agentStatus?.frps_config_path || '/etc/frp/frps.toml' }},重启 frps 后生效。 +
+ 密钥类字段(如 auth.token)在这里显示为掩码,下发时仍使用真实值。

{{ configContent || '# 暂无内容' }}
@@ -561,15 +563,14 @@
- + 关闭
- {{ serverLogsTarget?.server_addr }}:{{ serverLogsTarget?.server_port }} · frpc-server-{{ serverLogsTarget?.id }}.log - 刷新 + VPS 上的 frps 服务日志(journalctl,最近 20 行、新的在上)· {{ serverLogsTarget?.server_addr }}:{{ serverLogsTarget?.server_port }}
-
{{ serverLogLines.length ? serverLogLines.join('\n') : '暂无该服务端的运行日志' }}
+
{{ agentStatus?.frps_log_tail || '暂无 frps 日志:可能是 VPS 上 frps 未启动,或 agent 版本低于 0.12.3' }}
diff --git a/web/src/views/FrpView.vue b/web/src/views/FrpView.vue index 24aa98b..9db1d5d 100644 --- a/web/src/views/FrpView.vue +++ b/web/src/views/FrpView.vue @@ -921,7 +921,10 @@ function closeProxyDetail() { async function loadProxyLogs(proxyID: number) { proxyLogsLoading.value = true - try { proxyLogLines.value = asList(await api.getFrpProxyLogs(proxyID)) } + try { + // 日志文件按时间升序返回,倒过来让最新一条在最上面,与「FRP 日志」页签的展示一致 + proxyLogLines.value = asList(await api.getFrpProxyLogs(proxyID)).reverse() + } catch (error) { message.error(error instanceof Error ? error.message : '读取规则日志失败') } finally { proxyLogsLoading.value = false } } diff --git a/web/src/views/LogsView.vue b/web/src/views/LogsView.vue index e6d6a14..32b60df 100644 --- a/web/src/views/LogsView.vue +++ b/web/src/views/LogsView.vue @@ -413,6 +413,31 @@ const frpLevelText: Record = { I: '信息', W: '警告', E: '错 // 工具条的级别选项值是 INFO / WARN / ERROR,而 frpc 的级别是单字母 I/W/E/D,需转换后再比较 const frpLevelAlias: Record = { INFO: 'I', WARN: 'W', ERROR: 'E' } +// frpc 的「来源」是 Go 源码文件行号(如 client/control.go:174),对用户没有意义; +// 这里按文件前缀映射成中文模块名,认不出来的(例如回落到的组件名)原样保留。 +const FRP_SOURCE_LABELS: { prefix: string; label: string }[] = [ + { prefix: 'client/control.go', label: '连接' }, + { prefix: 'client/service.go', label: '登录' }, + { prefix: 'client/proxy.go', label: '连接' }, + { prefix: 'client/', label: '客户端' }, + { prefix: 'proxy/proxy_manager.go', label: '代理' }, + { prefix: 'proxy/', label: '代理' }, + { prefix: 'sub/root.go', label: '服务' }, + { prefix: 'sub/', label: '服务' }, + { prefix: 'config/', label: '配置' }, + { prefix: 'health/', label: '健康检查' }, + { prefix: 'server/', label: '服务端' }, + { prefix: 'pkg/', label: '组件' }, +] + +function frpSourceLabel(source: string): string { + const file = source.split(':')[0] ?? '' + for (const item of FRP_SOURCE_LABELS) { + if (file === item.prefix || file.startsWith(item.prefix)) return item.label + } + return file || source +} + // frpc 行有两种形态: // 2026-09-23 15:20:31.123 [I] [service.go:295] login to server success // [web] 2026-09-20 19:38:51.771 [I] [client/control.go:174] [trace-id] [web-http] start proxy success @@ -426,7 +451,7 @@ function parseFrpLogLine(line: string): FrpLogEntry { return { time: matched[2], level: matched[3].toUpperCase(), - source: matched[4] || component, + source: frpSourceLabel(matched[4] || component), message: matched[5] ?? '', } }