fix(web,agent,frp): frps 配置预览脱敏、日志界面倒序与来源中文化,agent 0.12.4 重写日志日期格式(v1.2.12)

- frps.toml 同源配置预览泄露明文密钥:FRPSServerConfig(对外预览入口)改为走 maskFRPSSecrets,auth.token / auth.oidc.clientSecret 的值换成 ********(键名与结构保留、注释行不动);下发路径 PushFRPSConfigToAgent 与安装流程仍用未脱敏内容。弹窗补说明,「复制」改「复制(已脱敏)」

- agent 0.12.4:frps 日志 6 行 → 最近 20 行、最新在最上面;日期由 short-iso 的 2026-09-22T18:43:34+08:00 RainYun-XXX 重写为 2026-09-22 18:43:34 进程: 消息(去掉 T 分隔、时区偏移与每行重复的主机名),frps/action 失败日志一并统一

- 日志界面:FRP 日志「来源」列由 Go 源码文件名(client/control.go:174)改为中文模块名;规则详情「日志」页签倒序;「公网服务端」弹窗改为显示 VPS 上的 frps 服务日志

- 测试:maskFRPSSecrets 脱敏(明文不出现、非敏感行不误伤)与注释行不改写;gofmt / go build / go vet / go test / vue-tsc / vite build 全绿

- AgentVersion 0.12.2 → 0.12.4,双架构内嵌二进制已重编:需在 VPS 上点一次「升级 Agent」才生效
This commit is contained in:
2026-09-25 13:56:07 +08:00
parent 74d731d0ac
commit 2236259a52
11 changed files with 188 additions and 15 deletions
+22
View File
@@ -2,6 +2,28 @@
All notable changes to this project will be documented in this file. 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 ## [v1.2.11] - 2026-09-25
### 新增 ### 新增
+1 -1
View File
@@ -1 +1 @@
1.2.11 1.2.12
+60 -5
View File
@@ -41,7 +41,9 @@ import (
// nginx 配置注释(改为只在 PUT 响应里回一次)。 // nginx 配置注释(改为只在 PUT 响应里回一次)。
// 0.12.1:frps 启停失败如实回报(不再无条件写「已启动」);HTTP 服务补 ReadTimeout/IdleTimeout。 // 0.12.1:frps 启停失败如实回报(不再无条件写「已启动」);HTTP 服务补 ReadTimeout/IdleTimeout。
// 0.12.2:新增域名 vhost 的版本列表 / 对比 / 回滚端点;写配置前留版本,校验失败时恢复上一个可用版本。 // 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 配置。 // Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
type Config struct { 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_unit_exec": strings.TrimSpace(execCommandOutput("systemctl", "show", "frps", "-p", "ExecStart", "--value")),
"frps_enabled": exec.Command("systemctl", "is-enabled", "--quiet", "frps").Run() == nil, "frps_enabled": exec.Command("systemctl", "is-enabled", "--quiet", "frps").Run() == nil,
"frps_version": strings.TrimSpace(frpsVersion(s.cfg.FrpsBinary)), "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)), "dashboard_addr": strings.TrimSpace(dashboardAddr(s.cfg.FrpsConfigPath)),
"routes": s.routeNames(), "routes": s.routeNames(),
"checked_at": time.Now().UTC().Format(time.RFC3339), "checked_at": time.Now().UTC().Format(time.RFC3339),
@@ -535,12 +537,65 @@ func frpsVersion(binary string) string {
return string(output) return string(output)
} }
func journalTail(unit string, lines int) string { // frpsLogTailLines 服务端详情里展示的 frps 日志行数
output, err := exec.Command("journalctl", "-u", unit, "-n", strconv.Itoa(lines), "--no-pager").CombinedOutput() 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 { if err != nil {
return "" 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/绝对路径), // nginxBinaryPath 解析实际可用的 nginx 可执行文件:优先传入名称(PATH/绝对路径),
Binary file not shown.
Binary file not shown.
+25 -1
View File
@@ -3,6 +3,7 @@ package frp
import ( import (
"context" "context"
"fmt" "fmt"
"regexp"
"strconv" "strconv"
"strings" "strings"
) )
@@ -206,11 +207,34 @@ func renderAllowPorts(ports string) string {
return strings.Join(lines, "\n") 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 入口)。 // FRPSServerConfig 返回指定服务端的 frps.toml 同源配置(对外 API 入口)。
// 这是**预览**入口:密钥一律脱敏(明文只在 PushFRPSConfigToAgent 的下发路径里出现)。
func (s *Service) FRPSServerConfig(ctx context.Context, id int64) (string, error) { func (s *Service) FRPSServerConfig(ctx context.Context, id int64) (string, error) {
server, err := s.store.GetServer(ctx, id) server, err := s.store.GetServer(ctx, id)
if err != nil { if err != nil {
return "", err return "", err
} }
return s.buildFRPSServerConfig(ctx, server) content, err := s.buildFRPSServerConfig(ctx, server)
if err != nil {
return "", err
}
return maskFRPSSecrets(content), nil
} }
+43
View File
@@ -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)
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "fonu-web", "name": "fonu-web",
"private": true, "private": true,
"version": "1.2.11", "version": "1.2.12",
"packageManager": "npm@10.9.4", "packageManager": "npm@10.9.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
+6 -5
View File
@@ -336,11 +336,13 @@
<p class="form-hint"> <p class="form-hint">
由 Fonu 本地生成;点「下发 frps 配置」后写入 VPS 的 由 Fonu 本地生成;点「下发 frps 配置」后写入 VPS 的
<span class="mono">{{ agentStatus?.frps_config_path || '/etc/frp/frps.toml' }}</span>,重启 frps 后生效。 <span class="mono">{{ agentStatus?.frps_config_path || '/etc/frp/frps.toml' }}</span>,重启 frps 后生效。
<br />
密钥类字段(如 <span class="mono">auth.token</span>)在这里显示为掩码,下发时仍使用真实值。
</p> </p>
<pre class="code-block config-modal__code">{{ configContent || '# 暂无内容' }}</pre> <pre class="code-block config-modal__code">{{ configContent || '# 暂无内容' }}</pre>
</div> </div>
<div class="config-modal__footer"> <div class="config-modal__footer">
<n-button size="small" :disabled="!configContent" @click="copyConfig">复制</n-button> <n-button size="small" :disabled="!configContent" @click="copyConfig">复制(已脱敏)</n-button>
<n-button size="small" type="primary" @click="configModal = false">关闭</n-button> <n-button size="small" type="primary" @click="configModal = false">关闭</n-button>
</div> </div>
</div> </div>
@@ -561,15 +563,14 @@
<n-modal v-model:show="showServerLogs"> <n-modal v-model:show="showServerLogs">
<div class="frp-card-modal"> <div class="frp-card-modal">
<div class="proxy-modal__header"> <div class="proxy-modal__header">
<h3 class="modal-title">公网服务端运行日志 - {{ serverLogsTarget?.name ?? '' }}</h3> <h3 class="modal-title">frps 服务日志 - {{ serverLogsTarget?.name ?? '' }}</h3>
<n-button size="small" quaternary @click="showServerLogs = false">关闭</n-button> <n-button size="small" quaternary @click="showServerLogs = false">关闭</n-button>
</div> </div>
<div class="proxy-modal__scroll"> <div class="proxy-modal__scroll">
<div class="logs-toolbar"> <div class="logs-toolbar">
<span class="table-muted">{{ serverLogsTarget?.server_addr }}:{{ serverLogsTarget?.server_port }} · frpc-server-{{ serverLogsTarget?.id }}.log</span> <span class="table-muted">VPS 上的 frps 服务日志(journalctl,最近 20 行、新的在上)· {{ serverLogsTarget?.server_addr }}:{{ serverLogsTarget?.server_port }}</span>
<n-button size="small" :loading="serverLogsLoading" @click="loadServerLogs">刷新</n-button>
</div> </div>
<pre class="logs-box">{{ serverLogLines.length ? serverLogLines.join('\n') : '暂无该服务端的运行日志' }}</pre> <pre class="logs-box">{{ agentStatus?.frps_log_tail || '暂无 frps 日志:可能是 VPS 上 frps 未启动,或 agent 版本低于 0.12.3' }}</pre>
</div> </div>
<div class="modal-footer"><n-button @click="showServerLogs = false">关闭</n-button></div> <div class="modal-footer"><n-button @click="showServerLogs = false">关闭</n-button></div>
</div> </div>
+4 -1
View File
@@ -921,7 +921,10 @@ function closeProxyDetail() {
async function loadProxyLogs(proxyID: number) { async function loadProxyLogs(proxyID: number) {
proxyLogsLoading.value = true 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 : '读取规则日志失败') } catch (error) { message.error(error instanceof Error ? error.message : '读取规则日志失败') }
finally { proxyLogsLoading.value = false } finally { proxyLogsLoading.value = false }
} }
+26 -1
View File
@@ -413,6 +413,31 @@ const frpLevelText: Record<string, string> = { I: '信息', W: '警告', E: '错
// 工具条的级别选项值是 INFO / WARN / ERROR,而 frpc 的级别是单字母 I/W/E/D,需转换后再比较 // 工具条的级别选项值是 INFO / WARN / ERROR,而 frpc 的级别是单字母 I/W/E/D,需转换后再比较
const frpLevelAlias: Record<string, string> = { INFO: 'I', WARN: 'W', ERROR: 'E' } const frpLevelAlias: Record<string, string> = { 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 行有两种形态: // frpc 行有两种形态:
// 2026-09-23 15:20:31.123 [I] [service.go:295] login to server success // 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 // [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 { return {
time: matched[2], time: matched[2],
level: matched[3].toUpperCase(), level: matched[3].toUpperCase(),
source: matched[4] || component, source: frpSourceLabel(matched[4] || component),
message: matched[5] ?? '', message: matched[5] ?? '',
} }
} }