feat: 上游 1.0.3-1.0.5 同步第 1-8 项(v1.2.2)

剪贴板:新增 web/src/utils/clipboard.ts,5 处调用点支持非 HTTPS 环境(对齐 1.0.4)

日志中心:新增「FRP 日志」Tab,解析 frpc 日志为 时间/级别/来源/消息(对齐 1.0.3)

通知:单通道密文解密失败不再拖垮整份配置(对齐 1.0.5);容器补 TZ + tzdata(对齐 1.0.4);Telegram 代理自动补 http:// 并校验 scheme(对齐 1.0.3)

反向代理:复制不再追加「-复制」(对齐 1.0.5);监听占用校验合并表单规则与手动 Nginx 配置两张表(迁移 024 + endpoints 解析器),冲突提示写明占用规则;改端口的并发冲突改为可读提示

FRP:frps 弹窗补「VPS 防火墙放行 TCP 远程端口」提示(对齐 1.0.3);反代配置弹窗补齐内容区内边距与来源说明

文档:新增 docs/upstream-sync-plan.md(逐版本对照 + 第 5 项详细整理);VERSION 与 CHANGELOG 记 v1.2.2
This commit is contained in:
2026-09-23 17:46:21 +08:00
parent 1e915fe7c1
commit fd1661622e
23 changed files with 1008 additions and 53 deletions
+57
View File
@@ -2,6 +2,63 @@
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.2] - 2026-09-23
### 新增
- 日志中心:新增「FRP 日志」Tab——把 frpc 运行日志解析为 时间 / 级别 / 来源 / 消息 四列,支持关键词与级别筛选、分页、错误/警告行着色,与系统日志、访问日志、Nginx 日志同一套交互(对齐上游 1.0.3)
- 新增 `web/src/utils/clipboard.ts`:统一的剪贴板复制工具(异步剪贴板 API 优先,失败或非安全上下文时回退 `execCommand`)
### 改进
- 剪贴板复制在非 HTTPS 环境可用:Fonu 常以 `http://内网IP:6893` 访问,属非安全上下文,此时 `navigator.clipboard` 为 undefined,复制必然失败;公网反代(复制域名 / 复制配置)、公网 Agent(配置查看)、DDNS、本地反向代理(复制访问链接)共 5 处调用点统一改用上述工具(对齐上游 1.0.4)
- 容器时区:`Dockerfile` 新增 `TZ=Asia/Shanghai` 并在运行时阶段安装 `tzdata`(只设 `TZ` 而镜像内无 zoneinfo 时 Go 仍会回落 UTC),三个 compose 文件均支持 `TZ` 覆盖——通知消息时间按系统时区显示(对齐上游 1.0.4)
- 反代配置查看弹窗:补齐内容区内边距(与头部 24px 对齐),新增来源说明(由 VPS 上的 fonu-agent 生成、文件名 `fonu-<域名>.conf`、手工修改会被下次部署覆盖),并改为说明固定 + 代码块内部滚动
- 反向代理:复制规则不再自动追加「-复制」后缀,保留原名称与域名、由用户在表单里确认修改(原先一点复制就得到一个改过名的副本,容易误建同名规则)(对齐上游 1.0.5)
- FRP 穿透页:frps 同源配置弹窗新增「VPS 防火墙 / 安全组放行 TCP 远程端口」提示,端口列表由已启用的 TCP/UDP 规则实时推导(与 frps 的 `allowPorts` 同源;未放行时隧道连不上,但日志不会点明原因)(对齐上游 1.0.3)
- 通知:Telegram 代理地址只填 `host:port` 时自动补全 `http://`,并校验 scheme 仅支持 http / https / socks5,错误信息带上原始地址便于自查(对齐上游 1.0.3)
### 修复
- 通知:单个通道密文解密失败不再让整份配置加载失败——原先 SMTP / Webhook / Telegram 任一密文损坏(例如更换过加密密钥)都会直接返回错误,导致其它已配置通道一并无法告警;现改为仅把该通道标记为「未配置」并继续解析其它通道(对齐上游 1.0.5)
- 反向代理:监听占用冲突提示写明占用方——优先规则备注名,其次该规则首个域名,最后回落 `规则 #id`;原先只说「已被其他规则使用」,用户得自己翻列表找是哪一条
- 反向代理:手动(custom)Nginx 配置的监听占用纳入校验。custom 配置的正本在文件系统(数据库只记 `nginx_mode` 开关),其 `listen` / `server_name` 原先完全不参与校验,与其它规则「同域名 + 同端口」的冲突只会在 Nginx reload 时静默失效(第一个 server 块生效、后面的被忽略);现于保存 / 回滚时解析并写入新表 `proxy_custom_endpoints`,与表单规则共用同一条判定(对齐上游 1.0.5)
- 反向代理:并发下修改监听端口可能绕过保存前校验(`UPDATE proxy_hosts` 的唯一约束冲突此前直接抛出原始 SQL 错误),现改为按域名复检并给出带规则名的提示
### 变更
- 新增 `docs/upstream-sync-plan.md`:上游 1.0.2 → 1.0.5 的逐版本对照方案,含 8 项待核对项的代码级核实结论、不照搬项及原因、以及「可增加项清单」(11 项,按价值排序)
- 新增迁移 `024_proxy_custom_endpoints.sql`(手动 Nginx 配置的占用表,`UNIQUE(rule_id, hostname, listen_port)` + `ON DELETE CASCADE`),解析器 `internal/nginx/endpoints.go`(`ParseServerEndpoints`,保守解析:`include` 片段 / 正则 `server_name` / `listen unix:` 一律忽略,宁可漏报不误报)与 `internal/proxy/custom_endpoints.go`
- 补测试:`internal/nginx/endpoints_test.go`(多 server 块、`[::]` 写法、通配与正则、无 `listen` 默认 80)、`internal/proxy/custom_endpoints_test.go`(走真实迁移建内存库:冲突含规则名、排除自身、同域名不同端口放行、custom 占用对表单规则可见、级联清理)
- `docs/upstream-sync-plan.md` 补 §八「第 5 项详细整理」:Nginx 端口占用语义(一条规则占用哪些端口)、现有校验链逐跳、三处真实缺口与方案 A/B/C 对比、实施记录与未覆盖边界
## [v1.2.1] - 2026-09-23
### 改进
- 公网 Agent 页:按「功能清单」重排为五段分区(运行状态 / 环境与依赖 / frps 控制 / Agent 生命周期 / 输出与日志);4 张状态卡统一为标准 `.stats-row` + `.stat-card` 规格;补齐 5 项「数据已返回但页面未显示」的能力——Agent 凭据状态、8080 端口、已注册反代路由数、最后检查时间、Nginx 可用性
- 公网 Agent 页:「环境与依赖」改为三列并排(Fonu Agent / Nginx / frps 运行时),卡内短字段双列、长路径整行;「frps 控制」与「Agent 生命周期」两列并排;新增「配置查看」按钮(只读弹窗展示即将下发的 frps 配置,含复制),卸载(含数据)增加二次确认
- 公网反代页:规则台账改用 `n-data-table`(与本地反向代理页一致,表头与数据天然对齐),列为 规则/域名(含复制与打开按钮)、协议、上游/回源、健康(DNS·证书·隧道·服务)、连接、当前上传、当前下载、总上传、总下载、更新时间、操作
- 公网反代页:新增统计区(可部署规则 / 已生效 / 健康通过 / 当前连接)与环境与 Nginx 卡(4 列);「使用说明」整卡改为页面级提示与入口行;部署设置弹窗对齐本地反向代理页规格(900px、三段式、页脚分隔线、开关行底色)并新增右侧「配置说明」栏
- 公网反代页:协议列改为按部署设置的「启用 HTTPS」自动识别(规则 type 恒为 http,不能代表入口协议),HTTPS 绿色 / HTTP 灰色并带悬停说明
- FRP 穿透页:4 张状态卡统一为标准统计区;公网服务端列表由自建网格改为 `n-data-table`(8 列,含启动/停止/查看/日志/测速/校验/Agent 管理/编辑/删除)
- FRP 穿透页:6 个弹窗统一到同一外壳(`.frp-card-modal` / `.frp-detail-card`),运行日志、导出配置、frps 同源配置、FRP 应用配置的头部/滚动区/页脚一致
- 统一设计令牌:清理硬编码色值(公网反代页 6 处、FRP 穿透页 13 处)与内联样式,删除死样式;统计区、弹窗、表格单元格全部走 `--fonu-*` 令牌与共享组件(`StatusBadge` / `EmptyState` / `LoadError` / `FonuCard`)
- 表格与列表交互:数字列右对齐、操作按钮右对齐并按行禁用、长文本换行、单元格内容垂直居中;窄屏按 1200 / 900 / 760 断点降列
### 修复
- FRP 穿透页「运行中」状态显示为灰色:`statusKind` 返回 `'success'` 却被当作 `value` 传给 `StatusBadge`(`utils/status.ts` 不识别该值,落到 `unknown`),且其中含非法的 `'default'`;现改为返回合法 `StatusKind` 并改用 `:kind`,服务端详情弹窗同类问题一并修复
- 表格单元格样式整体失效:`n-data-table` 的 render 函数用 `h()` 创建的节点不带 scoped 属性,导致 `table-muted` / `type-chip` / `mono` 与各 `cell-*` 样式未生效(表现为「上游/回源」两行变一行、域名与按钮错位、徽标无间距);统一改用 `:deep()` 选择器
- 公网反代页刷新失败静默:`loadErrorMsg` 此前写入但从未渲染,现改为顶部可见提示,并让单个接口失败不再中断整轮数据加载
- 公网反代页切换服务端残留上一台数据、轮询在页面隐藏时仍请求、loading 粒度粗(任一操作全页按钮转圈)等问题修复
### 变更
- 新增设计文档:`docs/agent-view-design-v2.md`(公网 Agent 页)、`docs/proxy-remote-design.md`(公网反代页),以及被取代留档的 `docs/agent-view-design.md`、`docs/agent-view-refactor-plan.md`
- 新增页面 UI 标准 skill(工作区级 `.breezell/skills/fonu-page-ui`,不在仓库内):页面骨架、共享组件契约、设计令牌、类名约定、弹窗标准结构、脚本约定与验收清单
## [v1.2.0] - 2026-09-23 ## [v1.2.0] - 2026-09-23
### 新增 ### 新增
+4 -2
View File
@@ -45,7 +45,7 @@ FROM ${RUNTIME_IMAGE}
ARG TARGETARCH ARG TARGETARCH
# frpc 不预装:需要内网穿透时在「应用配置」弹窗内按需下载(存入数据目录) # frpc 不预装:需要内网穿透时在「应用配置」弹窗内按需下载(存入数据目录)
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends nginx ca-certificates \ && apt-get install -y --no-install-recommends nginx ca-certificates tzdata \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
@@ -53,7 +53,9 @@ COPY --from=go-builder /fonu /app/fonu
COPY --from=go-builder /src/web/assets/image /app/web/assets/image COPY --from=go-builder /src/web/assets/image /app/web/assets/image
COPY migrations /app/migrations COPY migrations /app/migrations
ENV FONU_DATA_DIR=/data \ # 时区:Go 的 time.Local 取决于 TZ 环境变量,且需要镜像内存在 zoneinfo(故装上 tzdata)
ENV TZ=Asia/Shanghai \
FONU_DATA_DIR=/data \
FONU_LISTEN=:6893 \ FONU_LISTEN=:6893 \
FONU_NGINX_MIME_TYPES=/etc/nginx/mime.types FONU_NGINX_MIME_TYPES=/etc/nginx/mime.types
+1 -1
View File
@@ -1 +1 @@
1.2.0 1.2.2
+2
View File
@@ -18,6 +18,8 @@ services:
FONU_DATA_DIR: /data FONU_DATA_DIR: /data
FONU_LISTEN: :6893 FONU_LISTEN: :6893
FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-change-me-in-production} FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-change-me-in-production}
# 时区:通知消息时间按此显示(默认东八区)
TZ: ${TZ:-Asia/Shanghai}
FONU_NGINX_HTTP_PORT: ${FONU_NGINX_HTTP_PORT:-18080} FONU_NGINX_HTTP_PORT: ${FONU_NGINX_HTTP_PORT:-18080}
FONU_NGINX_HTTPS_PORT: ${FONU_NGINX_HTTPS_PORT:-9443} FONU_NGINX_HTTPS_PORT: ${FONU_NGINX_HTTPS_PORT:-9443}
+2
View File
@@ -22,6 +22,8 @@ services:
FONU_DATA_DIR: /data FONU_DATA_DIR: /data
FONU_LISTEN: :6893 FONU_LISTEN: :6893
FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-change-me-in-production} FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-change-me-in-production}
# 时区:通知消息时间按此显示(默认东八区)
TZ: ${TZ:-Asia/Shanghai}
volumes: volumes:
fonu-data: fonu-data:
+2
View File
@@ -21,6 +21,8 @@ services:
FONU_DATA_DIR: /data FONU_DATA_DIR: /data
FONU_LISTEN: :6893 FONU_LISTEN: :6893
FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-change-me-in-production} FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-change-me-in-production}
# 时区:通知消息时间按此显示(默认东八区)
TZ: ${TZ:-Asia/Shanghai}
volumes: volumes:
fonu-data: fonu-data:
+344
View File
@@ -0,0 +1,344 @@
# 上游同步方案:Fonu 1.0.2 → 1.0.5 对照与可借鉴项
> 本项目基于**上游 v1.0.2**(2026-09-18)开发,上游已迭代到 **v1.0.5**(2026-09-23)。
> 本文逐版本列出上游 1.0.3 / 1.0.4 / 1.0.5 的全部变更,并逐条核对**本项目代码现状**,
> 给出「同步 / 已具备 / 不适用 / 待核对」的判定与落地建议。
>
> 上游来源:`https://github.com/O96u/Fonu`(CHANGELOG.md + 提交记录)
> 核对方式:对本仓库 `f:\Fonu-main\Fonu-1.0.2` 做 grep / 文件树核对(证据写在每条的「本项目现状」里)
> 更新日期:2026-09-23
---
## 一、结论速览
| # | 上游项 | 版本 | 本项目现状 | 建议 |
| --- | --- | --- | --- | --- |
| 1 | 非 HTTPS 环境复制到剪贴板失败 | 1.0.4 | **同样存在**:6 处 `navigator.clipboard`(5 个文件) | **P0 同步**:抽 `utils/clipboard.ts` 加降级 |
| 2 | 非 HTTPS 添加 TCP 隧道失败(`crypto.randomUUID`) | 1.0.4 | **不适用**:全仓 `randomUUID` = 0 处 | 不适用(附一条 `crypto.subtle` 复核项) |
| 3 | 日志中心新增「FRP 日志」Tab(解析 frpc 日志) | 1.0.3 | **缺口**:`LogsView.vue` 只有 系统 / 访问 / Nginx 三个 Tab | **P1 同步**(后端接口已具备,纯前端) |
| 4 | 通知:单通道密钥解密失败不拖垮其它通道 | 1.0.5 | **确凿缺口**:`store.go:105-108` 解密失败即 return,整份配置加载失败 | **P1 同步修** |
| 5 | 通知时间按系统时区显示 | 1.0.4 | **条件缺口**:`format.go` 用 `time.Now()`,但容器未设 `TZ` → Docker 下显示 UTC | P1 补 `TZ` |
| 6 | frps 参考配置自动生成 `allowPorts` + 防火墙放行提示 | 1.0.3 | **已具备**:`internal/frp/frps_template.go` 已生成 `allowPorts` | 已具备;仅可补「VPS 防火墙放行」UI 提示 |
| 7 | TCP 远程端口手动指定支持 1–65535 | 1.0.5 | **已具备**:`FrpView.vue:336` 已是 `:min="1" :max="65535"` | 已具备;后端随机范围(1024–65535)待核 |
| 8 | FRP 页重构为「状态卡 + Web/TCP/高级/运行日志分栏」 | 1.0.3 | **方向已对齐**:本轮刚改为标准统计区 + 卡片分区 | 不照搬(形态差异大,见 §三.1) |
| 9 | 独立 TCP 隧道列表 / 纯 TCP 模式无需域名 | 1.0.3 | **功能已具备**:规则列表支持 tcp/udp/http/https 等类型 | 可选做「按类型筛选」,不照搬分栏 |
| 10 | 反代:新建入口时校验监听端口占用 | 1.0.5 | **已具备且语义更准**:`ensureHostAvailable` 校验「同域名 + 同端口」;仅缺冲突规则名与 custom 模式覆盖 | P3 收尾(详见 §八) |
| 11 | 反代:复制改为「预填表单、不改域名后缀」 | 1.0.5 | **部分对齐**:已是 `openDuplicate` 打开表单,但 `duplicateName()` 仍加「-复制」 | P3 去后缀 |
| 12 | 反代:子表撑满卡片 / 域名后紧跟复制·打开 / 状态列仅保留开关 | 1.0.5 | 本地布局不同(本轮未涉及该页) | P3 视觉可选对齐 |
| 13 | Telegram 代理支持 socks5 + 自动补 `http://` | 1.0.3 | **代理已具备**(`telegram.go:43-51`,Go 原生支持 socks5);缺保存校验与自动补前缀 | P3 补校验 |
| 14 | 通知事件开关保存与前端状态同步更可靠 | 1.0.5 | **未定位到缺陷**:`service.go:210` `send()` 为单一出口,未复现上游描述的问题 | 保留观察 |
| 15 | 连接设置:地址与端口合并一行 / 卡片内重启按钮 | 1.0.3 | 本地 FrpView 已有服务端启停按钮 | P3 视觉可选 |
**优先级**:P0 = 剪贴板降级(真实功能缺陷,且本轮新增的复制按钮也在其中);P1 = FRP 日志 Tab、通知健壮性两条;P2/P3 = 核对项与视觉对齐。
---
## 二、逐版本明细
### v1.0.3(2026-09-21)
**新增**
1. **FRP:支持独立 TCP 隧道列表(如 SSH、数据库),可与 Web 网关同时使用;纯 TCP 模式无需配置穿透域名**
- 本项目现状:`FrpView.vue` 的规则表单已支持 `tcp / udp / http / https / tcpmux / stcp / sudp / xtcp`(`proxyForm.type`),且 `remote_port` 仅对 tcp/udp 显示(`FrpView.vue:336`)→ **能力已具备**。
- 差异:上游是「Web / TCP 分栏」的信息架构;本项目是「统一规则列表 + 类型字段」。本项目的公网反代链路(`ProxyRemoteView` → agent → Nginx)依赖 http 规则 + 自定义域名,改成上游式分栏会牵动两页交互。
- 建议:**不照搬分栏**;可选做「规则列表按类型筛选」的小改进(纯前端,低风险)。
2. **FRP:允许先保存连接设置(地址/端口/Token),待配置域名或 TCP 隧道后再启动 frpc**
- 本项目现状:服务端保存与 frpc 启动是分离的(`createFrpServer` 与 `startFrpServer` 独立),但未核对「无规则时是否允许启动」。
- 建议:列入 §四 待核对;若本地已有拦截,则对齐为「允许保存、启动前给提示」。
3. **FRP:frps 参考配置自动生成 `allowPorts`,并提示在 VPS 防火墙放行 TCP 远程端口**
- 本项目现状:`internal/frp/frps_template.go` **已生成 `allowPorts`**(grep 命中)。
- 缺口:缺少「请在 VPS 防火墙放行 TCP 远程端口」的 UI 提示。
- 建议:**P2**,在 frps 配置弹窗(`showFRPSConfig`)与规则保存后各加一句提示(纯文案 + 1 个列表推导)。
4. **日志中心:新增 FRP 日志 Tab,解析 frpc 运行日志(时间、级别、来源、消息)**
- 本项目现状:`LogsView.vue` 现有 Tab = `系统日志 / 访问日志 / Nginx 日志`(第 11 / 40 / 72 行)→ **无 FRP Tab**。
- 后端:已有 `getFrpLogs` / `getFrpProxyLogs`(`api` 命中),日志内容可得。
- 建议:**P1 同步**。落地方式:`LogsView.vue` 增加第 4 个 Tab + 解析器(时间/级别/来源/消息四列,复用 `formatLogTime` / `formatLogLine` 与现有筛选控件),后端若已有 `/api/frp/logs` 则纯前端。
**改进**
5. **FRP:内网穿透页重构为「状态卡 + 连接设置 + Web/TCP/高级/运行日志分栏」**
- 本项目现状:本轮(见提交 `70796e5`)刚把 4 张自造状态卡改为标准 `.stats-row/.stat-card`,并把 6 个弹窗统一到 `.frp-card-modal` 外壳 → **视觉方向已对齐**。
- 建议:不照搬分栏(理由同 §2.1),但可借鉴其「运行日志就近展示」的思路——本地运行日志已在弹窗与日志中心可查。
6. **FRP:状态区展示已配置 Web 域名与 TCP 隧道,标签单行展示,超出可跳转对应 Tab**
- 本项目现状:`stats-row` 第三张卡是「frpc 版本」,未展示「已配置域名/隧道」。上游把这两类信息提到状态区。
- 建议:**P2 可选**。若要做,建议把第 4 张卡「最近应用」换成「已配置入口(N 个 Web / M 个 TCP)」,点击跳规则列表并筛选。
7. **FRP:连接设置中服务器地址与端口合并为一行;左侧卡片内提供重启 frp 服务按钮**
- 本项目现状:服务端行操作列已含「启动/停止」(`serverColumns` 操作列),但无「重启」。
- 建议:**P3**。加一个「重启」按钮(`stopServer` → `startServer` 串联或后端 action),与上游一致。
8. **通知:Telegram 代理地址支持 socks5,保存时校验格式并自动补全 `http://` 前缀**
- 本项目现状:`internal/notify/telegram.go` 存在;是否支持 socks5 未核对。
- 建议:列入 §四 待核对。
### v1.0.4(2026-09-21)
9. **修复:非 HTTPS 访问时添加 TCP 隧道失败(`crypto.randomUUID` 不可用)**
- 本项目现状:**全仓 `randomUUID` 命中 0 处**(`*.vue` 与全仓两次核对均为 0)→ **不适用**。
- 附注:仍需确认无 `crypto.subtle`(同样要求安全上下文)的使用;`crypto.getRandomValues` 不受限。
10. **修复:非 HTTPS 环境下复制到剪贴板失败(内网穿透、DDNS、反向代理)** ← **本项目同样存在**
- 本项目现状:`navigator.clipboard` 共 **6 处 / 5 个文件**:
`ProxyRemoteView.vue`(2 处,本轮新增的「复制域名」「复制反代配置」)、
`AgentView.vue`(1 处,「配置查看」弹窗的复制)、
`DdnsView.vue`(1 处)、`ProxyView.vue`(1 处)、
归档文件 `FrpView-1.0.2.vue`(1 处,已弃用代码,可不动)。
- 影响:Fonu 常用 `http://内网IP:6893` 访问,属**非安全上下文**,此时 `navigator.clipboard` 为 `undefined` → 复制功能直接失败(本项目现在只弹「复制失败」提示,用户无从下手)。
- 建议:**P0 同步**。做法:新增 `web/src/utils/clipboard.ts`:
```ts
export async function copyText(text: string): Promise<boolean> {
// 非安全上下文(http:// 内网 IP)下 navigator.clipboard 不存在,回退到 execCommand
if (navigator.clipboard?.writeText) {
try { await navigator.clipboard.writeText(text); return true } catch { /* 落到回退 */ }
}
try {
const el = document.createElement('textarea')
el.value = text; el.setAttribute('readonly', ''); el.style.position = 'fixed'; el.style.opacity = '0'
document.body.appendChild(el); el.select(); document.execCommand('copy'); document.body.removeChild(el)
return true
} catch { return false }
}
```
然后 5 个调用点统一改为 `copyText(...)`,失败提示保留。
11. **修复:通知消息时间未按系统设置时区显示(如 Telegram 测试通知)**
- 本项目现状:`internal/notify/format.go` 存在;是否用 `time.Local` / 配置时区未核对。
- 建议:列入 §四 待核对;修法一般是 `time.Now()` → 按系统时区格式化(或统一走一个 `nowInSystemZone()` 帮助函数)。
### v1.0.5(2026-09-23)
**新增**
12. **反代:新建入口时校验监听端口占用,避免重复端口静默冲突**
- 本项目现状(已核实):**已具备**。`internal/proxy/proxy.go:499 ensureHostAvailable` 在保存前查 `proxy_hosts` 的「同域名 + 同端口」,`proxy_hosts` 表另有 UNIQUE 约束兜底。
- 语义差异:上游按「端口」判冲突,本项目按「端口 + 域名」判冲突——后者才是 Nginx 的真实冲突(同端口不同域名是合法的虚拟主机,按端口判会误报)。
- 残留缺口:错误提示不含冲突规则名;`nginx_mode = custom` 的规则不参与校验。详见 §八。
**改进**
13. **反代:入口/规则「复制」改为打开表单预填原内容,由用户确认后再保存(不再自动创建、不改域名后缀)**
- 本项目现状:`ProxyView.vue` 已有复制相关实现(grep 命中 9 处),行为是否「直接创建」未核对。
- 建议:**P2 核对**;若本地是「点击即创建副本」,按上游改为「预填表单」,避免误建规则。
14. **反代:入口分组子表布局撑满卡片宽度;域名后紧跟复制/打开链接;状态列仅保留开关**
- 本项目现状:`ProxyView.vue` 无「入口分组」概念(grep `分组` = 0)→ **子表撑满**项不适用;「域名后紧跟复制/打开链接」与「状态列仅保留开关」是纯视觉项。
- 建议:**P3 可选**。注意:本轮刚在 `ProxyRemoteView` 的域名列做了「域名 + 复制 + 打开」的同类交互(见提交 `ca309db`),若要对齐,可复用同一套 `copyText` + 图标按钮样式。
15. **反代:同端口监听配置冲突提示仅在配置确实不一致时显示**
- 本项目现状:未核对本地是否有该提示(`ProxyView.vue` 的冲突提示逻辑)。
- 建议:**P2 核对**(误报会干扰用户)。
16. **FRP:TCP 隧道远程端口手动指定支持 1–65535(0 仍为自动分配,随机范围 1024–65535)**
- 本项目现状:**前端已具备**(`FrpView.vue:336` `:min="1" :max="65535"`)。
- 待核:后端「0 = 自动分配」的随机范围是否为 1024–65535(`internal/frp` 内)。
- 建议:核对后如有差异,改一行常量。
17. **通知:各通道密钥解密失败不再拖垮其它通道;事件开关保存与前端状态同步更可靠**
- 本项目现状:`internal/notify/store.go` 有 10 处解密相关逻辑 → 需要看「单通道解密失败是否被上层吞掉/中断整轮发送」。
- 建议:**P1 核对后修**。典型修法:每个通道独立 `try/catch`(Go 里是错误收集而非 return),把失败通道记入日志与结果,继续发其它通道。
**修复**
18. **通知:旧 SMTP 等密文无法解密时,Telegram/Webhook 等已配置通道仍无法告警的问题**
- 与第 17 条同源,**同一处修改可一并解决**。
---
## 三、不照搬项及原因
1. **FRP 页「Web / TCP / 高级 / 运行日志」分栏**:本项目的公网反代链路(`ProxyRemoteView` → agent → VPS Nginx)依赖「http 规则 + 自定义域名 + 部署态」这一套语义;照搬分栏会让「规则 → 部署 → 健康」的入口被打散。当前「统计区 + 服务端表 + 规则表」的形态更贴合本项目已有能力。
2. **`crypto.randomUUID` 修复**:本项目未使用该 API(全仓 0 处),无需移植。
3. **入口分组子表撑满**:本项目 `ProxyView` 没有「入口分组」结构,不适用。
---
## 四、逐项核实结果(A–H,全部已读代码确认,无推测)
| # | 上游项 | 核实结论 | 证据 |
| --- | --- | --- | --- |
| A | 允许先保存连接设置、待有域名/TCP 隧道再启动 frpc | **已具备**:本项目没有任何「必须有规则」的拦截 | `internal/frp/service.go:385` `StartServer` 仅做 GetServer → setServerRuntime("running") → enrich 返回;`FrpView.vue:1118` 启动按钮无 disabled 条件 |
| B | Telegram 代理支持 socks5 + 保存时校验格式、自动补 `http://` | **代理已具备(含 socks5)**;**缺保存校验与自动补全** | `internal/notify/telegram.go:43-51` 已有 `cfg.Telegram.ProxyURL` + `http.Transport{Proxy: http.ProxyURL(parsed)}`(Go 原生支持 `socks5://`) |
| C | 通知时间按系统时区显示 | **条件缺口**:代码用 `time.Now()` 格式化(跟随进程 TZ),但**容器未设 TZ** → Docker 部署下显示 UTC | `internal/notify/format.go:9/30`;`Dockerfile` 与 compose 无 `TZ` 环境变量 |
| D | 单通道密钥解密失败不拖垮其它通道 | **确凿缺口,与上游同一 bug** | `internal/notify/store.go:105-108`:SMTP 密文解密失败即 `return Config{}, runtimeSecrets{}, secret.DecryptHint(err)` → **整份配置加载失败**,Telegram/Webhook 也拿不到配置 → 无法告警;`store.go:117` 的 webhook 同理 |
| E | TCP 远程端口 0=自动分配(随机 1024–65535) | **本项目无此功能**:前端强制手填 1–65535,后端只做范围校验 | `FrpView.vue:336` `:min="1"`;`internal/frp/validate.go:331` `port > 0 && port <= 65535`;全仓未找到随机分配逻辑 |
| F | 反代:新建入口时校验监听端口占用 | **已具备(原判为「缺口」是关键词 grep 误判)**:校验存在但文案为「已被其他规则使用」,故搜 `占用`/`in use` 无命中 | `internal/proxy/proxy.go:499-524 ensureHostAvailable/ensureHostsAvailable`(`Create` 调用于 :225、`Update` 调用于 :345/:367);表约束见 `replaceHosts:526` 的 UNIQUE 分支;错误经 `api/proxy_handler.go:87 writeError(400, err.Error())` 直达前端 |
| G | 反代:复制改为「预填表单、不改域名后缀」 | **部分对齐**:已是打开表单预填,但仍自动加「-复制」后缀 | `ProxyView.vue:148` `@click="openDuplicate(selectedRule)"`(打开而非直接创建);`ProxyView.vue:1364-1366` `duplicateName()` 追加 `'-复制'` |
| H | 是否存在 `crypto.subtle`(同样要求安全上下文) | **不存在**(0 处) | 全仓扫描 0 命中 |
### 核实后的关键结论
1. **真正要修的只有两处**:剪贴板降级(P0,6 处隐患)、通知单通道解密失败(P1,`store.go` 直接 return 导致整份配置不可用);
2. **两处是「条件性」的**:通知时区(仅容器未设 TZ 时出现)、反代复制后缀(行为差异,非缺陷);
3. **四处本项目不缺**:无规则启动、Telegram 代理(含 socks5)、远程端口范围校验、`crypto.subtle`;**一处无对应功能**:远程端口 0=自动分配。
### 原待核对清单(索引保留)
## 五、建议的分批实施
| 批次 | 内容 | 影响面 | 预计 |
| --- | --- | --- | --- |
| **P0** | 剪贴板降级:新增 `utils/clipboard.ts` + 改 5 处调用点 | 前端 6 文件(含本轮新增的复制按钮) | 0.5h |
| **P1-a** | 日志中心「FRP 日志」Tab(四列解析 + 复用筛选) | `LogsView.vue`(+ 可能 1 个后端接口确认) | 1.5h |
| **P1-b** | 通知健壮性:单通道解密失败隔离 + 时区(第 17/18/11 条) | `internal/notify/*`(后端,需 `go test`) | 1.5h |
| **P2** | 待核对 A–H 逐条核实并按结论修(含 frps 防火墙提示、端口占用校验、复制行为、socks5) | 前后端零散 | 2h |
| **P3** | 视觉可选对齐(状态卡补「已配置入口」、规则列表类型筛选、反代视觉三项、重启按钮) | 前端 2 页 | 2h |
**验收**:每批以 `npx vue-tsc --noEmit` + `npx vite build`(前端)与 `go build ./...` + `go test ./internal/...`(后端)为准;涉及通知的后端改动需按现有测试补一条「单通道解密失败仍能发送其它通道」的用例。
---
## 六、附:上游 1.0.2 及更早(本项目基线之前)
本项目基线是上游 **v1.0.2(2026-09-18,新增「证书:火山引擎 DNS 的 ACME DNS-01 验证」)**,其上游历史(v1.0.0 通知体系、v1.0.1 DDNS 火山引擎与多层子域)在本项目 `CHANGELOG.md` 中已有对应条目(v1.0.0 / v1.0.1 / v1.0.2 段),本轮无需回溯。
---
## 七、可增加项清单(本项目确实没有的,按价值排序)
| 序 | 可增加项 | 依据(上游版本) | 落地要点 | 工作量 |
| --- | --- | --- | --- | --- |
| 1 | **剪贴板降级**:`utils/clipboard.ts` + 5 处调用点 | 1.0.4 修复 | `navigator.clipboard` 优先、`execCommand` 回退;非 HTTPS 内网访问下才真正生效 | 0.5h |
| 2 | **通知通道解密失败隔离**:单通道失败不拖垮整轮 | 1.0.5 修复 | `internal/notify/store.go` 的 `load()` 改为逐通道收集错误(失败通道标不可用 + 记原因),继续解析其它通道;补一条「SMTP 密文损坏时 Telegram 仍可发送」的测试 | 1.5h |
| 3 | **日志中心「FRP 日志」Tab**(解析时间/级别/来源/消息) | 1.0.3 新增 | `LogsView.vue` 增第 4 个 Tab;后端 `getFrpLogs` 已可用 | 1.5h |
| 4 | **通知时区**:容器设 `TZ`(并让通知时间走系统时区) | 1.0.4 修复 | `docker-compose*.yml` 加 `TZ: Asia/Shanghai`;`Dockerfile` 可加 `ENV TZ` | 0.5h |
| 5 | ~~反代监听端口占用校验~~ → 已具备,降级为「补冲突规则名 + custom 模式覆盖」 | 1.0.5 新增 | 见 §八:错误提示带冲突规则名;custom 规则解析其 `listen`/`server_name` 后纳入校验 | 0.5h + 2h |
| 6 | **frps 配置弹窗补「VPS 防火墙放行 TCP 远程端口」提示** | 1.0.3 改进 | 纯文案 + 由规则列表推导端口集合(`allowPorts` 已生成) | 0.5h |
| 7 | **Telegram 代理保存校验 + 自动补 `http://`** | 1.0.3 改进 | 保存时校验 URL 格式(允许 `http/https/socks5`),缺 scheme 时补 `http://` | 0.5h |
| 8 | **反代复制去掉「-复制」后缀** | 1.0.5 改进 | `duplicateName()` 改为保留原名 + 提示用户改域名 | 0.5h |
| 9 | **FRP 状态卡补「已配置入口」**(N 个 Web / M 个 TCP,点击筛选) | 1.0.3 改进 | 替换第 4 张卡「最近应用」或新增一张 | 1h |
| 10 | **服务端行加「重启」按钮** | 1.0.3 改进 | 复用 stop/start 串联或后端 action | 0.5h |
| 11 | **远程端口 0=自动分配(随机 1024–65535)** | 1.0.5 改进 | 后端随机分配 + 前端允许 0;本项目当前强制手填,属新增能力 | 1h |
> 前 4 项是「本项目确实缺少且有实际影响」的;第 5–8 项是上游同期新增/改进的缺口;第 9–11 项属可选增强。
---
## 八、第 5 项详细整理:反代监听端口占用校验
### 8.1 结论:本项目已有等价实现,且判定语义比上游更准
上游 1.0.5 的「新建入口时校验监听端口占用,避免重复端口静默冲突」,在本项目**已经具备**。
§四.F 当时判为「缺口」,是因为用关键词 `占用` / `in use` 做 grep,而本项目代码里的措辞是
「已被其他规则使用」,所以没搜到——这是**关键词误判**,已在上文更正。
更关键的是**判定语义**:上游按「端口」判冲突,本项目按「端口 + 域名」判冲突。后者才是 Nginx 的真实冲突,
前者会把「同一端口挂多个站点」这种最常见的用法误报成冲突。
### 8.2 Nginx 语义:一条规则到底占用哪些「端口 + 域名」
端口占用的判定必须与 `internal/nginx/generate.go` 的生成逻辑对齐,否则会误报或漏报。
`writeRuleBlocks`(`generate.go:120`)按 `rule.PortGroups()`(`proxy.go:101`)逐组生成 server 块:
| 规则字段 | 生成的 server 块 | 监听端口来源 |
| --- | --- | --- |
| 每个 PortGroup | 至少 1 个 | `host.ListenPort`(host 级覆盖)否则 `rule.ListenPort` |
| `https_enabled = true` 且有可用证书 | +1 个(`listen <port> ssl`) | **同组端口**,不额外占端口 |
| `http_redirect = true` 且 `useHTTPS` | +1 个(同端口、无 ssl、`return 301`) | **同组端口**,不额外占端口 |
**关键推论**:一条规则占用的端口集合就是它的 `PortGroups()` 端口集合;
`https_enabled` / `http_redirect` **不会**额外占用 80 / 443,因此不必按这两个开关推算端口。
Nginx 的冲突规则是「**同端口 + 同 `server_name`**」:此时只在 reload 时打一条
`conflicting server name ... ignored` 警告,**第一个 server 块生效、后面的静默失效**——
这正是上游所说的「重复端口静默冲突」。反过来,「同端口 + 不同域名」是**合法的虚拟主机**,不能报错。
### 8.3 本项目已有的校验链(逐跳)
| 跳 | 位置 | 行为 |
| --- | --- | --- |
| 1 | `web/src/views/ProxyView.vue` 保存表单 | `POST/PUT /api/proxy/rules` |
| 2 | `internal/api/proxy_handler.go:54` / `:93` | `svc.Create` / `svc.Update`;出错走 `writeError(400, err.Error())` |
| 3 | `internal/proxy/proxy.go:225` / `:345` / `:367` | `ensureHostsAvailable(hosts, listenPort, excludeRuleID)` |
| 4 | `internal/proxy/proxy.go:499` | `SELECT rule_id FROM proxy_hosts WHERE hostname = ? COLLATE NOCASE AND listen_port = ?` |
| 5 | `internal/proxy/proxy.go:511` | 返回 `前端域名 %s:%d 已被其他规则使用` |
| 6 | `internal/proxy/proxy.go:526` `replaceHosts` | 撞 `UNIQUE(hostname, listen_port)` 时给出同一文案(兜底) |
**三条路径都已覆盖**:新建(`:225`)、改端口(`:345`,仅校验跟随规则端口的 host,host 级独立端口不受影响)、
改域名(`:367`)。`excludeRuleID` 保证编辑自身时不误判。前端 `message.error(error.message)` 直接展示后端文案,
因此**只改后端文案即可,前端无需改动**。
### 8.4 三处真实缺口(按价值排序)
> **实施状态(2026-09-23):缺口 1、2(方案 C)、3 已完成**,落地位置与测试见 §8.7。
**缺口 1 · 冲突提示不含冲突规则名**(上游明确要求「错误提示写明冲突规则名」)
- 现状:只说「前端域名 a.com:80 已被其他规则使用」,用户还得自己去列表里找是哪一条。
- 改法:`ensureHostAvailable` 的查询 JOIN `proxy_rules` 取 `name`:
```sql
SELECT h.rule_id, COALESCE(r.name, '') FROM proxy_hosts h
LEFT JOIN proxy_rules r ON r.id = h.rule_id
WHERE h.hostname = ? COLLATE NOCASE AND h.listen_port = ? LIMIT 1
```
错误串改为 `前端域名 a.com:80 已被规则「名称」占用`;`name` 为空时回落为 `规则 #id`。
- 影响面:`proxy.go` 单函数 + `proxy_hosts_test.go` 中断言该文案的用例需同步。
- 风险低,0.5h。
**缺口 2 · `nginx_mode = custom` 的规则完全不参与校验**(真正的盲区)
- 现状:`Generate`(`generate.go:52`)对 custom 规则走 `writeCustomRuleBlocks`(`generate.go:108`),
把 `ruleCustomContent` 原样写入;其自定义配置里的 `listen` / `server_name` **不写入 `proxy_hosts`**,
因此与已有规则的「同端口 + 同域名」冲突**保存时无任何提示**,只在 Nginx reload 时打一条 warning。
- 改法(先轻后重,二选一):
1. **保存 custom 配置时解析**其 `listen <port>` 与 `server_name`,与 `ListEnabled` 的其它规则做
「同端口 + 同域名」比对,冲突即报错并写明冲突规则名。可复用 `internal/nginx/custom.go` 中
已有的 `listen ` 前缀处理与 `syntax.go` 的行解析思路。
2. **把解析结果落库到 `proxy_hosts`**(`hostname` 取 `server_name`、`listen_port` 取端口),
这样 8.3 的校验链天然覆盖 custom 规则,不必维护两套逻辑(更推荐,但要处理多域名/通配写法)。
- 需处理的写法:`listen 80 default_server;`、`server_name a.com b.com;`、通配 `*.a.com`、
`include` 片段、注释行。
- 风险中,2h。
**缺口 3 · 并发下端口变更的 TOCTOU**(低危)
- 现状:`Update` 的 `:359` 分支裸执行 `UPDATE proxy_hosts SET listen_port = ? ...`,未捕获 UNIQUE;
校验(`:345`)与写入之间存在窗口。单用户场景几乎不会触发。
- 改法:把该 `UPDATE` 的 UNIQUE 错误映射成与 `replaceHosts` 相同的中文文案,或把校验与写入并入同一事务。
- 风险低,0.5h(可随缺口 1 一起做)。
### 8.5 明确**不做**的两件事
1. **不按「同端口」判冲突**:同端口不同域名是合法虚拟主机,按端口报错会让「一个端口挂多个站点」
这种最常见的用法无法保存。
2. **不探测系统层面的端口占用**(`net.Listen` 试占、`ss -ltn`):Fonu 自己就是占用方(Nginx 监听 80/443),
且容器与宿主机视角不同,误报率极高。上游所指也是 Nginx 配置内的冲突,而非系统端口。
### 8.6 验收标准
- 「同端口 + 同域名」与其它规则冲突 → 400,且提示中**含冲突规则名**;
- 「同端口 + 不同域名」→ 正常保存,不误报;
- host 级独立端口(`a.com:8081` 与 `b.com:8080`)互不干扰;
- 编辑规则自身(不改域名)→ 不误判为与自身冲突(`excludeRuleID` 已保证,需用例固化);
- custom 模式规则与其 `listen`/`server_name` 冲突 → 保存时报错(缺口 2 完成后)。
### 8.7 实施记录(2026-09-23)
缺口 1、2(方案 C)、3 已落地:
| 缺口 | 落地位置 |
| --- | --- |
| 1 · 冲突提示带规则名 | `internal/proxy/proxy.go` 的 `ensureHostAvailable` + 新增 `endpointOwner`(描述优先「备注名 → 该规则首个域名 → `规则 #id`」);文案统一为「域名 a.com:80 已被规则「官网」使用,请编辑现有规则或更换域名/端口」 |
| 2 · 方案 C 独立占用表 | 迁移 `migrations/024_proxy_custom_endpoints.sql`(`UNIQUE(rule_id, hostname, listen_port)` + `ON DELETE CASCADE`);解析器 `internal/nginx/endpoints.go` 的 `ParseServerEndpoints`;读写 `internal/proxy/custom_endpoints.go`(`ReplaceCustomEndpoints` / `ClearCustomEndpoints` / `EnsureEndpointsAvailable`);`ensureHostAvailable` 现在合并查两张表 |
| 2 · 落库时机 | `internal/service/nginx_config.go`:`SaveRuleNginx` 手动分支(校验占用 → 写文件 → 落库)、切回 `auto` 分支(清理占用)、`RollbackRuleNginx`(回滚后同步占用);删除规则由外键级联清理 |
| 3 · TOCTOU | `Update` 的 `UPDATE proxy_hosts SET listen_port` 捕获 UNIQUE 后按域名复检并给出带规则名的原因;`replaceHosts` 同样改为复检;新增 `isUniqueViolation` 供两处复用 |
**测试**:`internal/nginx/endpoints_test.go`(多 server 块、`[::]` 写法、通配与正则、无 `listen` 默认 80、`include`/变量忽略);
`internal/proxy/custom_endpoints_test.go`(走真实迁移建库:冲突含规则名、排除自身、同域名不同端口放行、custom 占用对表单规则可见、级联清理、清理后释放)。
### 8.8 未覆盖的边界(有意保留)
1. **全局手动 Nginx 片段**(`SaveGlobalNginx` → `writeGlobalCustomInclude`)里的 `server` 块不参与校验:
它没有 rule_id,纳入校验需要额外的归属语义,本轮未做。
2. **前端预检**(`ProxyView.vue` 的 `findHostConflict`)只覆盖 `proxy_hosts`(表单规则的 `rule.hosts`),看不到 custom 占用;
这类冲突由后端保存时报错兜底,错误消息直接展示给用户。
3. 解析器对手写配置里的 `include`、变量、`listen unix:` 一律忽略——宁可漏报也不误报(拦不住保存比误报更可接受)。
+113
View File
@@ -0,0 +1,113 @@
package nginx
import (
"strconv"
"strings"
"github.com/fonu/fonu/internal/proxy"
)
// ParseServerEndpoints 从 Nginx 配置文本里抽取各 server 块的 (server_name, listen 端口) 组合,
// 返回 proxy.Endpoint(与表单规则的端点同一类型),用于手动(custom)规则的监听占用校验。
//
// 采取保守策略:解析不出的写法(include 片段、正则 server_name、listen unix:、变量)一律忽略。
// 该校验只用于保存时提示,宁可漏报也不能因为解析失败而拦住保存。
func ParseServerEndpoints(content string) []proxy.Endpoint {
var out []proxy.Endpoint
seen := map[string]bool{}
var (
inServer bool
depth int
ports []int
names []string
)
flush := func() {
// 未写 listen 的 server 块按 Nginx 默认监听端口 80 处理
if len(ports) == 0 && len(names) > 0 {
ports = []int{80}
}
for _, port := range ports {
for _, name := range names {
key := name + ":" + strconv.Itoa(port)
if seen[key] {
continue
}
seen[key] = true
out = append(out, proxy.Endpoint{Hostname: name, Port: port})
}
}
inServer, depth, ports, names = false, 0, nil, nil
}
for _, raw := range strings.Split(content, "\n") {
line := stripInlineComment(raw)
if line == "" {
continue
}
opens := strings.Count(line, "{")
closes := strings.Count(line, "}")
if !inServer {
if opens > 0 && serverBlockOpens(line) {
inServer, depth = true, opens-closes
}
continue
}
switch {
case strings.HasPrefix(line, "listen"):
if port, ok := listenPort(line); ok {
ports = append(ports, port)
}
case strings.HasPrefix(line, "server_name"):
names = append(names, serverNames(line)...)
}
depth += opens - closes
if depth <= 0 {
flush()
}
}
return out
}
func serverBlockOpens(line string) bool {
fields := strings.Fields(line)
return len(fields) > 0 && fields[0] == "server" && strings.Contains(line, "{")
}
// listenPort 从 listen 指令取出端口号;unix socket 等无法判定的写法返回 false
func listenPort(line string) (int, bool) {
fields := strings.Fields(line)
if len(fields) < 2 {
return 0, false
}
addr := strings.TrimSuffix(fields[1], ";")
if strings.Contains(addr, "unix:") {
return 0, false
}
if idx := strings.LastIndex(addr, ":"); idx >= 0 {
addr = addr[idx+1:]
}
port, err := strconv.Atoi(addr)
if err != nil || port <= 0 || port > 65535 {
return 0, false
}
return port, true
}
// serverNames 从 server_name 指令取出字面量主机名;正则(~ 开头)与占位符(含 $)跳过
func serverNames(line string) []string {
fields := strings.Fields(line)
if len(fields) < 2 {
return nil
}
var names []string
for _, field := range fields[1:] {
name := strings.ToLower(strings.TrimSuffix(field, ";"))
if name == "" || name == "_" || strings.HasPrefix(name, "~") || strings.Contains(name, "$") {
continue
}
names = append(names, name)
}
return names
}
+57
View File
@@ -0,0 +1,57 @@
package nginx
import "testing"
func TestParseServerEndpoints(t *testing.T) {
content := `server {
listen 8011;
listen [::]:8011;
server_name a.com b.com;
location / {
proxy_pass http://127.0.0.1:8080;
}
}
# 注释行不参与解析
server {
listen 127.0.0.1:8017 ssl;
server_name *.c.com ~^regex\.com$ _;
}
`
endpoints := ParseServerEndpoints(content)
want := []struct {
host string
port int
}{
{"a.com", 8011},
{"b.com", 8011},
{"*.c.com", 8017},
}
if len(endpoints) != len(want) {
t.Fatalf("expected %d endpoints, got %d (%+v)", len(want), len(endpoints), endpoints)
}
for i, expect := range want {
if endpoints[i].Hostname != expect.host || endpoints[i].Port != expect.port {
t.Fatalf("endpoint %d = %s:%d, want %s:%d", i, endpoints[i].Hostname, endpoints[i].Port, expect.host, expect.port)
}
}
}
func TestParseServerEndpointsDefaultsToPort80(t *testing.T) {
endpoints := ParseServerEndpoints("server {\n server_name d.com;\n return 444;\n}\n")
if len(endpoints) != 1 || endpoints[0].Hostname != "d.com" || endpoints[0].Port != 80 {
t.Fatalf("expected d.com:80, got %+v", endpoints)
}
}
func TestParseServerEndpointsIgnoresUnparseable(t *testing.T) {
content := `include /etc/nginx/snippets/extra.conf;
server {
listen unix:/run/fonu.sock;
server_name $host;
}
`
if endpoints := ParseServerEndpoints(content); len(endpoints) != 0 {
t.Fatalf("expected no endpoints, got %+v", endpoints)
}
}
+13 -6
View File
@@ -105,9 +105,12 @@ func (s *Store) load(ctx context.Context, decrypt bool) (Config, runtimeSecrets,
if decrypt && s.secret != nil { if decrypt && s.secret != nil {
plain, err := s.secret.Decrypt(smtpEnc) plain, err := s.secret.Decrypt(smtpEnc)
if err != nil { if err != nil {
return Config{}, runtimeSecrets{}, secret.DecryptHint(err) // 单个通道的密文损坏(例如更换过加密密钥)不应让整份配置加载失败:
// 仅标记该通道不可用并继续解析其它通道,避免 Telegram/Webhook 一起失效
cfg.Email.HasPassword = false
} else {
secrets.smtpPassword = plain
} }
secrets.smtpPassword = plain
} }
} }
@@ -117,9 +120,11 @@ func (s *Store) load(ctx context.Context, decrypt bool) (Config, runtimeSecrets,
if decrypt && s.secret != nil { if decrypt && s.secret != nil {
plain, err := s.secret.Decrypt(webhookEnc) plain, err := s.secret.Decrypt(webhookEnc)
if err != nil { if err != nil {
return Config{}, runtimeSecrets{}, secret.DecryptHint(err) // 同上:只让该通道不可用,不影响其它通道
cfg.Webhook.HasSecret = false
} else {
secrets.webhookSecret = plain
} }
secrets.webhookSecret = plain
} }
} }
@@ -129,9 +134,11 @@ func (s *Store) load(ctx context.Context, decrypt bool) (Config, runtimeSecrets,
if decrypt && s.secret != nil { if decrypt && s.secret != nil {
plain, err := s.secret.Decrypt(telegramEnc) plain, err := s.secret.Decrypt(telegramEnc)
if err != nil { if err != nil {
return Config{}, runtimeSecrets{}, secret.DecryptHint(err) // 同上:只让该通道不可用,不影响其它通道
cfg.Telegram.HasBotToken = false
} else {
secrets.telegramToken = plain
} }
secrets.telegramToken = plain
} }
} }
+10 -1
View File
@@ -41,9 +41,18 @@ func SendTelegram(ctx context.Context, client *http.Client, cfg RuntimeConfig, c
httpClient = &http.Client{Timeout: 15 * time.Second} httpClient = &http.Client{Timeout: 15 * time.Second}
} }
if proxyURL := strings.TrimSpace(cfg.Telegram.ProxyURL); proxyURL != "" { if proxyURL := strings.TrimSpace(cfg.Telegram.ProxyURL); proxyURL != "" {
// 常见误填:只写 host:port(无 scheme)。补全后再解析,并校验 scheme 是否受支持
if !strings.Contains(proxyURL, "://") {
proxyURL = "http://" + proxyURL
}
parsed, err := url.Parse(proxyURL) parsed, err := url.Parse(proxyURL)
if err != nil { if err != nil {
return fmt.Errorf("Telegram 代理地址无效") return fmt.Errorf("Telegram 代理地址无效:%s", proxyURL)
}
switch parsed.Scheme {
case "http", "https", "socks5", "socks5h":
default:
return fmt.Errorf("Telegram 代理仅支持 http / https / socks5,当前为 %q", parsed.Scheme)
} }
httpClient = &http.Client{ httpClient = &http.Client{
Timeout: 15 * time.Second, Timeout: 15 * time.Second,
+39
View File
@@ -0,0 +1,39 @@
package proxy
import (
"context"
)
// ReplaceCustomEndpoints 用解析出的占用集合替换该规则在 proxy_custom_endpoints 里的记录。
// 手动(custom)Nginx 规则保存或回滚成功后调用,使其占用对其它规则的保存校验可见。
func (s *Store) ReplaceCustomEndpoints(ctx context.Context, ruleID int64, endpoints []Endpoint) error {
if err := s.ClearCustomEndpoints(ctx, ruleID); err != nil {
return err
}
for _, endpoint := range endpoints {
if _, err := s.querier().ExecContext(ctx, `
INSERT INTO proxy_custom_endpoints(rule_id, hostname, listen_port)
VALUES (?, ?, ?)
`, ruleID, endpoint.Hostname, endpoint.Port); err != nil {
return err
}
}
return nil
}
// ClearCustomEndpoints 清除该规则的手动配置占用(切回自动模式或删除规则时调用)
func (s *Store) ClearCustomEndpoints(ctx context.Context, ruleID int64) error {
_, err := s.querier().ExecContext(ctx, `DELETE FROM proxy_custom_endpoints WHERE rule_id = ?`, ruleID)
return err
}
// EnsureEndpointsAvailable 校验一批 (域名, 端口) 是否已被其它规则占用,
// 用于手动 Nginx 配置的保存前检查(与表单规则的校验共用同一条判定)。
func (s *Store) EnsureEndpointsAvailable(ctx context.Context, endpoints []Endpoint, excludeRuleID int64) error {
for _, endpoint := range endpoints {
if err := s.ensureHostAvailable(ctx, endpoint.Hostname, endpoint.Port, excludeRuleID); err != nil {
return err
}
}
return nil
}
+109
View File
@@ -0,0 +1,109 @@
package proxy
import (
"context"
"database/sql"
"fmt"
"path/filepath"
"strings"
"testing"
_ "modernc.org/sqlite"
"github.com/fonu/fonu/internal/db"
)
func newTestStore(t *testing.T) (*Store, *sql.DB) {
t.Helper()
ctx := context.Background()
name := strings.ReplaceAll(t.Name(), "/", "_")
conn, err := sql.Open("sqlite", fmt.Sprintf("file:%s?mode=memory&cache=shared&_pragma=foreign_keys(1)", name))
if err != nil {
t.Fatalf("open test db: %v", err)
}
conn.SetMaxOpenConns(1)
if err := db.Migrate(ctx, conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatalf("migrate test db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
return NewStore(conn), conn
}
func createTestRule(t *testing.T, store *Store, name, host string, port int) Rule {
t.Helper()
rule, err := store.Create(context.Background(), CreateInput{
Upstream: "http://192.168.1.10:8080",
ListenPort: port,
ListenIPv4: true,
Hosts: []string{host},
Enabled: true,
Name: name,
})
if err != nil {
t.Fatalf("create rule %s: %v", name, err)
}
return rule
}
func TestEnsureEndpointsAvailableNamesOwningRule(t *testing.T) {
store, _ := newTestStore(t)
ctx := context.Background()
owner := createTestRule(t, store, "官网", "a.com", 8011)
err := store.EnsureEndpointsAvailable(ctx, []Endpoint{{Hostname: "a.com", Port: 8011}}, 0)
if err == nil {
t.Fatal("expected conflict error")
}
if !strings.Contains(err.Error(), "规则「官网」") {
t.Fatalf("expected owning rule name in error, got %q", err.Error())
}
// 编辑自身不应被判为冲突
if err := store.EnsureEndpointsAvailable(ctx, []Endpoint{{Hostname: "a.com", Port: 8011}}, owner.ID); err != nil {
t.Fatalf("exclude self should pass, got %v", err)
}
// 同域名不同端口是合法虚拟主机
if err := store.EnsureEndpointsAvailable(ctx, []Endpoint{{Hostname: "a.com", Port: 8012}}, 0); err != nil {
t.Fatalf("different port should pass, got %v", err)
}
}
func TestEnsureEndpointsAvailableSeesCustomEndpoints(t *testing.T) {
store, conn := newTestStore(t)
ctx := context.Background()
custom := createTestRule(t, store, "手动规则", "b.com", 8011)
if err := store.ReplaceCustomEndpoints(ctx, custom.ID, []Endpoint{{Hostname: "c.com", Port: 8013}}); err != nil {
t.Fatalf("replace custom endpoints: %v", err)
}
err := store.EnsureEndpointsAvailable(ctx, []Endpoint{{Hostname: "c.com", Port: 8013}}, 0)
if err == nil {
t.Fatal("expected conflict with custom endpoints")
}
if !strings.Contains(err.Error(), "手动 Nginx 配置") {
t.Fatalf("expected custom-rule hint in error, got %q", err.Error())
}
// 删除规则后占用记录随外键级联清除
if _, err := conn.ExecContext(ctx, `DELETE FROM proxy_rules WHERE id = ?`, custom.ID); err != nil {
t.Fatalf("delete rule: %v", err)
}
if err := store.EnsureEndpointsAvailable(ctx, []Endpoint{{Hostname: "c.com", Port: 8013}}, 0); err != nil {
t.Fatalf("cascade should free the binding, got %v", err)
}
}
func TestClearCustomEndpoints(t *testing.T) {
store, _ := newTestStore(t)
ctx := context.Background()
rule := createTestRule(t, store, "手动规则", "b.com", 8011)
if err := store.ReplaceCustomEndpoints(ctx, rule.ID, []Endpoint{{Hostname: "c.com", Port: 8013}}); err != nil {
t.Fatalf("replace custom endpoints: %v", err)
}
if err := store.ClearCustomEndpoints(ctx, rule.ID); err != nil {
t.Fatalf("clear custom endpoints: %v", err)
}
if err := store.EnsureEndpointsAvailable(ctx, []Endpoint{{Hostname: "c.com", Port: 8013}}, 0); err != nil {
t.Fatalf("cleared binding should be free, got %v", err)
}
}
+61 -11
View File
@@ -360,6 +360,17 @@ func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (Rule, err
if _, err := s.querier().ExecContext(ctx, ` if _, err := s.querier().ExecContext(ctx, `
UPDATE proxy_hosts SET listen_port = ? WHERE rule_id = ? AND listen_port = ? UPDATE proxy_hosts SET listen_port = ? WHERE rule_id = ? AND listen_port = ?
`, listenPort, id, oldListenPort); err != nil { `, listenPort, id, oldListenPort); err != nil {
if isUniqueViolation(err) {
// 保存前校验与写入之间存在窗口,被并发规则抢占时按域名复检并给出带规则名的原因
for _, host := range current.Hosts {
if hostEffectivePort(host, oldListenPort) != oldListenPort {
continue
}
if ownerErr := s.ensureHostAvailable(ctx, host.Hostname, listenPort, id); ownerErr != nil {
return Rule{}, ownerErr
}
}
}
return Rule{}, err return Rule{}, err
} }
} }
@@ -463,6 +474,11 @@ func (s *Store) cleanupOrphanRules(ctx context.Context) error {
return err return err
} }
// isUniqueViolation 判断是否为 SQLite 唯一约束冲突(hostname + listen_port 撞车)
func isUniqueViolation(err error) bool {
return err != nil && strings.Contains(strings.ToUpper(err.Error()), "UNIQUE")
}
func hostEffectivePort(host Host, ruleListenPort int) int { func hostEffectivePort(host Host, ruleListenPort int) int {
if host.ListenPort != nil && *host.ListenPort > 0 { if host.ListenPort != nil && *host.ListenPort > 0 {
return *host.ListenPort return *host.ListenPort
@@ -497,22 +513,52 @@ func parseHosts(raw []string, ruleListenPort int) ([]Host, error) {
} }
func (s *Store) ensureHostAvailable(ctx context.Context, hostname string, port int, excludeRuleID int64) error { func (s *Store) ensureHostAvailable(ctx context.Context, hostname string, port int, excludeRuleID int64) error {
var ownerID int64 owner, err := s.endpointOwner(ctx, "proxy_hosts", hostname, port, excludeRuleID)
err := s.querier().QueryRowContext(ctx, `
SELECT rule_id FROM proxy_hosts WHERE hostname = ? COLLATE NOCASE AND listen_port = ? LIMIT 1
`, hostname, port).Scan(&ownerID)
if errors.Is(err, sql.ErrNoRows) {
return nil
}
if err != nil { if err != nil {
return err return err
} }
if ownerID != excludeRuleID { if owner != "" {
return fmt.Errorf("前端域名 %s:%d 已被其他规则使用", hostname, port) return fmt.Errorf("域名 %s:%d 已被%s使用,请编辑现有规则或更换域名/端口", hostname, port, owner)
}
// 手动(custom)Nginx 规则的占用单独存表,其域名/端口不在 proxy_hosts 里,需一并检查
owner, err = s.endpointOwner(ctx, "proxy_custom_endpoints", hostname, port, excludeRuleID)
if err != nil {
return err
}
if owner != "" {
return fmt.Errorf("域名 %s:%d 已被%s使用(该规则使用手动 Nginx 配置),请编辑现有规则或更换域名/端口", hostname, port, owner)
} }
return nil return nil
} }
// endpointOwner 返回占用该 (域名, 端口) 的规则描述(如 规则「名称」);空闲时返回空串。
// table 只传入本包内两张固定表名(proxy_hosts / proxy_custom_endpoints),不接受外部输入。
// 规则描述优先用备注名,其次用该规则的首个域名,最后回落为规则 ID。
func (s *Store) endpointOwner(ctx context.Context, table, hostname string, port int, excludeRuleID int64) (string, error) {
var (
ownerID int64
ownerDesc string
)
query := `SELECT h.rule_id,
COALESCE(NULLIF(TRIM(r.name), ''),
(SELECT hostname FROM proxy_hosts WHERE rule_id = h.rule_id ORDER BY id LIMIT 1), '')
FROM ` + table + ` h
LEFT JOIN proxy_rules r ON r.id = h.rule_id
WHERE h.hostname = ? COLLATE NOCASE AND h.listen_port = ? AND h.rule_id != ?
LIMIT 1`
err := s.querier().QueryRowContext(ctx, query, hostname, port, excludeRuleID).Scan(&ownerID, &ownerDesc)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", err
}
if desc := strings.TrimSpace(ownerDesc); desc != "" {
return fmt.Sprintf("规则「%s」", desc), nil
}
return fmt.Sprintf("规则 #%d", ownerID), nil
}
func (s *Store) ensureHostsAvailable(ctx context.Context, hosts []Host, ruleListenPort int, excludeRuleID int64) error { func (s *Store) ensureHostsAvailable(ctx context.Context, hosts []Host, ruleListenPort int, excludeRuleID int64) error {
for _, host := range hosts { for _, host := range hosts {
port := hostEffectivePort(host, ruleListenPort) port := hostEffectivePort(host, ruleListenPort)
@@ -534,8 +580,12 @@ func (s *Store) replaceHosts(ctx context.Context, ruleID int64, ruleListenPort i
VALUES (?, ?, ?) VALUES (?, ?, ?)
`, ruleID, host.Hostname, port) `, ruleID, host.Hostname, port)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "UNIQUE") { if isUniqueViolation(err) {
return fmt.Errorf("前端域名 %s:%d 已被其他规则使用", host.Hostname, port) // 并发下可能绕过保存前校验:复检一次以给出带规则名的原因
if ownerErr := s.ensureHostAvailable(ctx, host.Hostname, port, ruleID); ownerErr != nil {
return ownerErr
}
return fmt.Errorf("域名 %s:%d 已被其它规则占用", host.Hostname, port)
} }
return err return err
} }
+22
View File
@@ -101,6 +101,10 @@ func (s *ProxyService) SaveRuleNginx(ctx context.Context, id int64, in SaveRuleN
if err := s.store.SetNginxMode(ctx, id, "auto"); err != nil { if err := s.store.SetNginxMode(ctx, id, "auto"); err != nil {
return RuleNginxView{}, err return RuleNginxView{}, err
} }
// 切回自动模式后,手动配置的占用记录不再适用
if err := s.store.ClearCustomEndpoints(ctx, id); err != nil {
return RuleNginxView{}, err
}
if err := s.applyNginx(ctx); err != nil { if err := s.applyNginx(ctx); err != nil {
return RuleNginxView{}, err return RuleNginxView{}, err
} }
@@ -118,9 +122,18 @@ func (s *ProxyService) SaveRuleNginx(ctx context.Context, id int64, in SaveRuleN
if err := s.validateRuleNginxPending(ctx, rule, content); err != nil { if err := s.validateRuleNginxPending(ctx, rule, content); err != nil {
return RuleNginxView{}, err return RuleNginxView{}, err
} }
// 手动配置里的 listen/server_name 不进 proxy_hosts,需单独解析记录并校验占用,
// 否则它与其它规则的同域名同端口冲突只会在 Nginx reload 时静默失效
endpoints := nginx.ParseServerEndpoints(content)
if err := s.store.EnsureEndpointsAvailable(ctx, endpoints, id); err != nil {
return RuleNginxView{}, err
}
if _, err := nginx.SaveRuleCustomWithBackup(s.cfg, id, content); err != nil { if _, err := nginx.SaveRuleCustomWithBackup(s.cfg, id, content); err != nil {
return RuleNginxView{}, err return RuleNginxView{}, err
} }
if err := s.store.ReplaceCustomEndpoints(ctx, id, endpoints); err != nil {
return RuleNginxView{}, err
}
if err := s.store.SetNginxMode(ctx, id, "custom"); err != nil { if err := s.store.SetNginxMode(ctx, id, "custom"); err != nil {
return RuleNginxView{}, err return RuleNginxView{}, err
} }
@@ -158,6 +171,15 @@ func (s *ProxyService) RollbackRuleNginx(ctx context.Context, id int64, backupNa
if err := s.validateRuleNginxPending(ctx, rule, custom); err != nil { if err := s.validateRuleNginxPending(ctx, rule, custom); err != nil {
return RuleNginxView{}, err return RuleNginxView{}, err
} }
endpoints := nginx.ParseServerEndpoints(custom)
if err := s.store.EnsureEndpointsAvailable(ctx, endpoints, id); err != nil {
// 备份文件已写回(与原有行为一致),占用记录需跟磁盘内容保持一致,避免留下过期占用
_ = s.store.ReplaceCustomEndpoints(ctx, id, endpoints)
return RuleNginxView{}, err
}
if err := s.store.ReplaceCustomEndpoints(ctx, id, endpoints); err != nil {
return RuleNginxView{}, err
}
if err := s.store.SetNginxMode(ctx, id, "custom"); err != nil { if err := s.store.SetNginxMode(ctx, id, "custom"); err != nil {
return RuleNginxView{}, err return RuleNginxView{}, err
} }
+14
View File
@@ -0,0 +1,14 @@
-- 手动(custom)Nginx 规则的监听占用:custom 配置的正本在文件系统(rules.nginx_mode 只记开关),
-- 本表只存解析出的 (域名, 端口),专供保存时做「同域名 + 同端口」冲突校验;
-- 不参与配置生成、流量统计与前端表单预填(那些是 proxy_hosts 的语义)。
CREATE TABLE IF NOT EXISTS proxy_custom_endpoints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rule_id INTEGER NOT NULL REFERENCES proxy_rules(id) ON DELETE CASCADE,
hostname TEXT NOT NULL COLLATE NOCASE,
listen_port INTEGER NOT NULL CHECK (listen_port > 0 AND listen_port <= 65535),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(rule_id, hostname, listen_port)
);
CREATE INDEX IF NOT EXISTS idx_proxy_custom_endpoints_rule_id ON proxy_custom_endpoints(rule_id);
CREATE INDEX IF NOT EXISTS idx_proxy_custom_endpoints_binding ON proxy_custom_endpoints(hostname, listen_port);
+31
View File
@@ -0,0 +1,31 @@
// 剪贴板复制。
//
// Fonu 常以 http://内网IP:6893 访问,属于**非安全上下文**,此时 navigator.clipboard 为 undefined,
// 直接调用会导致复制必然失败。这里统一收口:优先用异步剪贴板 API,不可用时回退到 execCommand。
// 返回是否复制成功,由调用方决定提示文案。
export async function copyText(text: string): Promise<boolean> {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// 权限被拒或浏览器限制:继续尝试回退方案
}
}
try {
const el = document.createElement('textarea')
el.value = text
el.setAttribute('readonly', '')
el.style.position = 'fixed'
el.style.top = '-1000px'
el.style.opacity = '0'
document.body.appendChild(el)
el.select()
el.setSelectionRange(0, el.value.length)
const ok = document.execCommand('copy')
document.body.removeChild(el)
return ok
} catch {
return false
}
}
+3 -4
View File
@@ -253,6 +253,7 @@ import StatusBadge from '../components/StatusBadge.vue'
import { api } from '../api/client' import { api } from '../api/client'
import type { FrpAgentStatus, FrpRelease, FrpServer } from '../api/types' import type { FrpAgentStatus, FrpRelease, FrpServer } from '../api/types'
import { formatRelativeTime } from '../utils/format' import { formatRelativeTime } from '../utils/format'
import { copyText } from '../utils/clipboard'
// 操作动作:按动作粒度控制 loading,保证"只有被点的按钮转圈" // 操作动作:按动作粒度控制 loading,保证"只有被点的按钮转圈"
type Action = type Action =
@@ -470,10 +471,8 @@ async function viewConfig(target: FrpServer) {
} }
async function copyConfig() { async function copyConfig() {
try { if (await copyText(configContent.value)) message.success('已复制 frps 配置')
await navigator.clipboard.writeText(configContent.value) else message.error('复制失败,请手动选择文本复制')
message.success('已复制 frps 配置')
} catch { message.error('复制失败,请手动选择文本复制') }
} }
function installFrps(target: FrpServer) { function installFrps(target: FrpServer) {
+5 -6
View File
@@ -878,13 +878,12 @@ async function clearCustomIP() {
await saveCustomIP() await saveCustomIP()
} }
// 复用统一剪贴板工具(本页原有同名函数,故用别名导入避免冲突)
import { copyText as copyToClipboard } from '../utils/clipboard'
async function copyText(text: string) { async function copyText(text: string) {
try { if (await copyToClipboard(text)) message.success('已复制')
await navigator.clipboard.writeText(text) else message.error('复制失败')
message.success('已复制')
} catch {
message.error('复制失败')
}
} }
async function refreshPublicIP() { async function refreshPublicIP() {
+8
View File
@@ -585,6 +585,10 @@
</div> </div>
<div class="proxy-modal__scroll"> <div class="proxy-modal__scroll">
<p class="form-hint">与该服务端的 frpc 配置同源生成(bindPort / 认证 / 管理接口一致);复制到 VPS 保存为 frps.toml 后执行 ./frps -c frps.toml。包含真实 Token,请勿外传。</p> <p class="form-hint">与该服务端的 frpc 配置同源生成(bindPort / 认证 / 管理接口一致);复制到 VPS 保存为 frps.toml 后执行 ./frps -c frps.toml。包含真实 Token,请勿外传。</p>
<p v-if="tcpRemotePorts.length" class="form-hint">
已启用的 TCP/UDP 隧道远程端口:<span class="mono">{{ tcpRemotePorts.join('、') }}</span>
—— 请确认 VPS 防火墙 / 安全组已放行这些端口(frps 侧 allowPorts 已按此生成,未放行时隧道会连不上)。
</p>
<pre class="logs-box">{{ frpsConfigContent || '生成中…' }}</pre> <pre class="logs-box">{{ frpsConfigContent || '生成中…' }}</pre>
</div> </div>
<div class="modal-footer"><n-button @click="showFRPSConfig = false">关闭</n-button></div> <div class="modal-footer"><n-button @click="showFRPSConfig = false">关闭</n-button></div>
@@ -911,6 +915,10 @@ const dialog = useDialog()
const status = ref<FrpStatus>({ status: 'unknown', configured: false, enabled_proxies: 0 }) const status = ref<FrpStatus>({ status: 'unknown', configured: false, enabled_proxies: 0 })
const servers = ref<FrpServer[]>([]) const servers = ref<FrpServer[]>([])
const proxies = ref<FrpProxy[]>([]) const proxies = ref<FrpProxy[]>([])
// 已启用的 TCP/UDP 隧道远程端口:用于提示在 VPS 防火墙放行(与 frps 的 allowPorts 同源)
const tcpRemotePorts = computed(() => proxies.value
.filter((proxy) => proxy.enabled && (proxy.type === 'tcp' || proxy.type === 'udp') && proxy.remote_port)
.map((proxy) => proxy.remote_port as number))
const proxyTraffic = ref<Record<number, FrpProxyTraffic>>({}) const proxyTraffic = ref<Record<number, FrpProxyTraffic>>({})
const proxyTrafficErrors = ref<Record<number, string>>({}) const proxyTrafficErrors = ref<Record<number, string>>({})
const serverDiagnostics = ref<Record<number, FrpServerDiagnostics>>({}) const serverDiagnostics = ref<Record<number, FrpServerDiagnostics>>({})
+87 -1
View File
@@ -37,6 +37,35 @@
/> />
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="frp" tab="FRP 日志">
<LogToolbar
v-model:keyword="frpKeyword"
v-model:level="frpLevel"
:show-level="true"
@refresh="loadFrpLogs"
/>
<p class="tab-hint">frpc 运行日志:解析为 时间 / 级别 / 来源 / 消息,用于排查隧道登录、注册与连接错误。</p>
<n-data-table
v-if="filteredFrpLogs.length > 0"
class="log-table"
:columns="frpColumns"
:data="pagedFrpLogs"
:loading="loadingFrp"
:bordered="false"
:row-class-name="frpRowClassName"
/>
<LogPagination
v-if="filteredFrpLogs.length > 0"
v-model:page="frpPage"
:item-count="filteredFrpLogs.length"
/>
<EmptyState
v-if="!loadingFrp && frpLogs.length === 0"
title="暂无 FRP 日志"
description="启动 frpc 后,登录、隧道注册与连接错误会记录在这里。"
/>
</n-tab-pane>
<n-tab-pane name="access" tab="访问日志"> <n-tab-pane name="access" tab="访问日志">
<LogToolbar <LogToolbar
v-model:keyword="accessKeyword" v-model:keyword="accessKeyword"
@@ -335,6 +364,63 @@ function systemRowClassName(row: SystemLogEntry) {
return 'log-row' return 'log-row'
} }
// FRP 日志(frpc):后端返回原始行,前端解析为 时间 / 级别 / 来源 / 消息 四列
interface FrpLogEntry { time: string; level: string; source: string; message: string }
const frpLogs = ref<FrpLogEntry[]>([])
const frpKeyword = ref('')
const frpLevel = ref('')
const frpPage = ref(1)
const loadingFrp = ref(false)
const frpPageSize = 50
const frpLevelText: Record<string, string> = { I: '信息', W: '警告', E: '错误', D: '调试' }
// frpc 行形如:2026-09-23 15:20:31.123 [I] [service.go:295] login to server success
function parseFrpLogLine(line: string): FrpLogEntry {
const matched = line.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?)\s*\[([A-Z])\]\s*(?:\[([^\]]+)\])?\s*(.*)$/)
if (!matched) return { time: '', level: '', source: '', message: line }
return { time: matched[1], level: matched[2], source: matched[3] ?? '', message: matched[4] ?? '' }
}
const filteredFrpLogs = computed(() => {
const keyword = frpKeyword.value.trim().toLowerCase()
return frpLogs.value.filter((log) => {
if (frpLevel.value && frpLevelText[log.level] !== frpLevel.value) return false
if (keyword && !`${log.source} ${log.message}`.toLowerCase().includes(keyword)) return false
return true
})
})
const pagedFrpLogs = computed(() => {
const start = (frpPage.value - 1) * frpPageSize
return filteredFrpLogs.value.slice(start, start + frpPageSize)
})
const frpColumns: DataTableColumns<FrpLogEntry> = [
{ title: '时间', key: 'time', width: 168, render: (row) => (row.time ? logTimeCell(row.time) : '—') },
{ title: '级别', key: 'level', width: 80, render: (row) => frpLevelText[row.level] ?? (row.level || '—') },
{ title: '来源', key: 'source', width: 170, render: (row) => row.source || '—' },
{ title: '消息', key: 'message', render: (row) => row.message },
]
function frpRowClassName(row: FrpLogEntry) {
if (row.level === 'E') return 'log-row log-row--error'
if (row.level === 'W') return 'log-row log-row--warn'
return 'log-row'
}
async function loadFrpLogs() {
loadingFrp.value = true
try {
const raw = asList(await api.getFrpLogs(500))
frpLogs.value = raw.map((line) => parseFrpLogLine(String(line)))
} catch (error) {
message.error(error instanceof Error ? error.message : '加载 FRP 日志失败')
} finally {
loadingFrp.value = false
}
}
const accessColumns = computed<DataTableColumns<AccessLogEntry>>(() => [ const accessColumns = computed<DataTableColumns<AccessLogEntry>>(() => [
{ title: '时间', key: 'time', width: 168, render: (row) => logTimeCell(row.time) }, { title: '时间', key: 'time', width: 168, render: (row) => logTimeCell(row.time) },
{ {
@@ -491,7 +577,7 @@ async function loadSystemLogs() {
async function loadAll() { async function loadAll() {
pageError.value = '' pageError.value = ''
try { try {
await Promise.all([loadAccess(), loadErrorLogs(), loadSystemLogs()]) await Promise.all([loadAccess(), loadErrorLogs(), loadSystemLogs(), loadFrpLogs()])
} catch (error) { } catch (error) {
pageError.value = error instanceof Error ? error.message : '请检查 Fonu 服务是否正常运行' pageError.value = error instanceof Error ? error.message : '请检查 Fonu 服务是否正常运行'
} }
+17 -10
View File
@@ -300,7 +300,14 @@
<h3 class="modal-title">反代配置 — {{ confDomain }}</h3> <h3 class="modal-title">反代配置 — {{ confDomain }}</h3>
<n-button size="small" quaternary @click="confModal = false">关闭</n-button> <n-button size="small" quaternary @click="confModal = false">关闭</n-button>
</div> </div>
<pre class="code-block conf-view">{{ confContent || '加载中…' }}</pre> <div class="proxy-modal__scroll conf-scroll">
<p class="field-hint">
由 VPS 上的 fonu-agent 生成,位于 Nginx 的 vhost 配置目录(文件名
<span class="mono">fonu-{{ confDomain }}.conf</span>)。请勿在 VPS 上手工修改:
下次部署会被重新生成覆盖,改动请到「部署设置」或穿透规则里调整。
</p>
<pre class="code-block conf-view">{{ confContent || '加载中…' }}</pre>
</div>
<div class="modal-footer"> <div class="modal-footer">
<n-button :disabled="!confContent" @click="copyConf">复制</n-button> <n-button :disabled="!confContent" @click="copyConf">复制</n-button>
<n-button type="primary" @click="confModal = false">关闭</n-button> <n-button type="primary" @click="confModal = false">关闭</n-button>
@@ -327,6 +334,7 @@ import type {
FrpAgentRouteDetail, FrpAgentRouteHealth, FrpAgentStatus, FrpProxy, FrpProxyTraffic, FrpServer, FrpAgentRouteDetail, FrpAgentRouteHealth, FrpAgentStatus, FrpProxy, FrpProxyTraffic, FrpServer,
} from '../api/types' } from '../api/types'
import { formatBytes, formatDate, formatRate, formatRelativeTime } from '../utils/format' import { formatBytes, formatDate, formatRate, formatRelativeTime } from '../utils/format'
import { copyText } from '../utils/clipboard'
type Action = type Action =
| `deploy:${number}` | `remove:${number}` | `conf:${number}` | `save-settings:${number}` | 'install-nginx' | `deploy:${number}` | `remove:${number}` | `conf:${number}` | `save-settings:${number}` | 'install-nginx'
@@ -513,10 +521,8 @@ function isHealthy(candidate: FrpProxy) {
// 域名快捷操作:复制 / 新窗口打开(http 规则走 http,https 规则走 https) // 域名快捷操作:复制 / 新窗口打开(http 规则走 http,https 规则走 https)
async function copyDomain(domain: string) { async function copyDomain(domain: string) {
try { if (await copyText(domain)) message.success(`已复制 ${domain}`)
await navigator.clipboard.writeText(domain) else message.error('复制失败,请手动选择文本复制')
message.success(`已复制 ${domain}`)
} catch { message.error('复制失败,请手动选择文本复制') }
} }
function openDomain(candidate: FrpProxy, domain: string) { function openDomain(candidate: FrpProxy, domain: string) {
@@ -733,10 +739,8 @@ async function viewConf(candidate: FrpProxy) {
} }
async function copyConf() { async function copyConf() {
try { if (await copyText(confContent.value)) message.success('已复制反代配置')
await navigator.clipboard.writeText(confContent.value) else message.error('复制失败,请手动选择文本复制')
message.success('已复制反代配置')
} catch { message.error('复制失败,请手动选择文本复制') }
} }
// 切换服务端必须重置,避免上一台的健康 / 流量 / agent 状态串台 // 切换服务端必须重置,避免上一台的健康 / 流量 / agent 状态串台
@@ -862,7 +866,10 @@ onBeforeUnmount(() => { if (timer) window.clearInterval(timer) })
font-family: var(--fonu-mono); font-size: 12.5px; line-height: 1.65; color: var(--fonu-text); font-family: var(--fonu-mono); font-size: 12.5px; line-height: 1.65; color: var(--fonu-text);
overflow: auto; white-space: pre-wrap; word-break: break-all; overflow: auto; white-space: pre-wrap; word-break: break-all;
} }
.conf-view { max-height: 60vh; } /* 反代配置查看弹窗:内容区与头部同样的 24px 内边距,代码块自身滚动 */
.conf-scroll { padding: var(--fonu-space-4) var(--fonu-space-5) 0; }
.conf-scroll .field-hint { margin-bottom: var(--fonu-space-3); line-height: 1.65; }
.conf-view { max-height: 62vh; }
.form-hint { color: var(--fonu-text-secondary); font-size: 12.5px; } .form-hint { color: var(--fonu-text-secondary); font-size: 12.5px; }
.mono { font-family: var(--fonu-mono); font-size: 13px; } .mono { font-family: var(--fonu-mono); font-size: 13px; }
.mono-wrap { white-space: normal; word-break: break-all; } .mono-wrap { white-space: normal; word-break: break-all; }
+7 -11
View File
@@ -1361,12 +1361,10 @@ function ruleName(rule: ProxyRule): string {
return name || primaryHost(rule) return name || primaryHost(rule)
} }
// 复制:保留原名称与域名,由用户在表单里确认修改(不再自动追加「-复制」后缀,避免误建同名规则)
function duplicateName(rule: ProxyRule): string { function duplicateName(rule: ProxyRule): string {
const base = rule.name?.trim() || primaryHost(rule) const base = rule.name?.trim() || primaryHost(rule)
const suffix = '-复制' return base.length > 100 ? base.slice(0, 100) : base
const maxBase = 100 - suffix.length
const trimmedBase = base.length > maxBase ? base.slice(0, maxBase) : base
return `${trimmedBase}${suffix}`
} }
function hostsToText(rule: ProxyRule): string { function hostsToText(rule: ProxyRule): string {
@@ -1405,13 +1403,11 @@ function ruleAccessUrls(rule: ProxyRule): string[] {
return ruleHosts(rule).map((host) => hostAccessUrl(rule, host)) return ruleHosts(rule).map((host) => hostAccessUrl(rule, host))
} }
import { copyText } from '../utils/clipboard'
async function copyAccessUrl(url: string) { async function copyAccessUrl(url: string) {
try { if (await copyText(url)) message.success('链接已复制')
await navigator.clipboard.writeText(url) else message.error('复制失败')
message.success('链接已复制')
} catch {
message.error('复制失败')
}
} }
function renderLinkAction(icon: Component, title: string, onClick: () => void): VNode { function renderLinkAction(icon: Component, title: string, onClick: () => void): VNode {
@@ -2040,7 +2036,7 @@ function openDuplicate(rule: ProxyRule) {
syncSecurityExpanded() syncSecurityExpanded()
formTab.value = 'basic' formTab.value = 'basic'
showModal.value = true showModal.value = true
message.info('已填入复制内容,请修改域名后保存') message.info('已填入复制内容,请确认名称与域名后保存')
} }
function openEdit(rule: ProxyRule, tab: 'basic' | 'security' | 'nginx' = 'basic') { function openEdit(rule: ProxyRule, tab: 'basic' | 'security' | 'nginx' = 'basic') {