剪贴板:新增 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
29 KiB
上游同步方案: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)
新增
-
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 规则 + 自定义域名,改成上游式分栏会牵动两页交互。 - 建议:不照搬分栏;可选做「规则列表按类型筛选」的小改进(纯前端,低风险)。
- 本项目现状:
-
FRP:允许先保存连接设置(地址/端口/Token),待配置域名或 TCP 隧道后再启动 frpc
- 本项目现状:服务端保存与 frpc 启动是分离的(
createFrpServer与startFrpServer独立),但未核对「无规则时是否允许启动」。 - 建议:列入 §四 待核对;若本地已有拦截,则对齐为「允许保存、启动前给提示」。
- 本项目现状:服务端保存与 frpc 启动是分离的(
-
FRP:frps 参考配置自动生成
allowPorts,并提示在 VPS 防火墙放行 TCP 远程端口- 本项目现状:
internal/frp/frps_template.go已生成allowPorts(grep 命中)。 - 缺口:缺少「请在 VPS 防火墙放行 TCP 远程端口」的 UI 提示。
- 建议:P2,在 frps 配置弹窗(
showFRPSConfig)与规则保存后各加一句提示(纯文案 + 1 个列表推导)。
- 本项目现状:
-
日志中心:新增 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则纯前端。
- 本项目现状:
改进
-
FRP:内网穿透页重构为「状态卡 + 连接设置 + Web/TCP/高级/运行日志分栏」
- 本项目现状:本轮(见提交
70796e5)刚把 4 张自造状态卡改为标准.stats-row/.stat-card,并把 6 个弹窗统一到.frp-card-modal外壳 → 视觉方向已对齐。 - 建议:不照搬分栏(理由同 §2.1),但可借鉴其「运行日志就近展示」的思路——本地运行日志已在弹窗与日志中心可查。
- 本项目现状:本轮(见提交
-
FRP:状态区展示已配置 Web 域名与 TCP 隧道,标签单行展示,超出可跳转对应 Tab
- 本项目现状:
stats-row第三张卡是「frpc 版本」,未展示「已配置域名/隧道」。上游把这两类信息提到状态区。 - 建议:P2 可选。若要做,建议把第 4 张卡「最近应用」换成「已配置入口(N 个 Web / M 个 TCP)」,点击跳规则列表并筛选。
- 本项目现状:
-
FRP:连接设置中服务器地址与端口合并为一行;左侧卡片内提供重启 frp 服务按钮
- 本项目现状:服务端行操作列已含「启动/停止」(
serverColumns操作列),但无「重启」。 - 建议:P3。加一个「重启」按钮(
stopServer→startServer串联或后端 action),与上游一致。
- 本项目现状:服务端行操作列已含「启动/停止」(
-
通知:Telegram 代理地址支持 socks5,保存时校验格式并自动补全
http://前缀- 本项目现状:
internal/notify/telegram.go存在;是否支持 socks5 未核对。 - 建议:列入 §四 待核对。
- 本项目现状:
v1.0.4(2026-09-21)
-
修复:非 HTTPS 访问时添加 TCP 隧道失败(
crypto.randomUUID不可用)- 本项目现状:全仓
randomUUID命中 0 处(*.vue与全仓两次核对均为 0)→ 不适用。 - 附注:仍需确认无
crypto.subtle(同样要求安全上下文)的使用;crypto.getRandomValues不受限。
- 本项目现状:全仓
-
修复:非 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:然后 5 个调用点统一改为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 } }copyText(...),失败提示保留。
- 本项目现状:
-
修复:通知消息时间未按系统设置时区显示(如 Telegram 测试通知)
- 本项目现状:
internal/notify/format.go存在;是否用time.Local/ 配置时区未核对。 - 建议:列入 §四 待核对;修法一般是
time.Now()→ 按系统时区格式化(或统一走一个nowInSystemZone()帮助函数)。
- 本项目现状:
v1.0.5(2026-09-23)
新增
- 反代:新建入口时校验监听端口占用,避免重复端口静默冲突
- 本项目现状(已核实):已具备。
internal/proxy/proxy.go:499 ensureHostAvailable在保存前查proxy_hosts的「同域名 + 同端口」,proxy_hosts表另有 UNIQUE 约束兜底。 - 语义差异:上游按「端口」判冲突,本项目按「端口 + 域名」判冲突——后者才是 Nginx 的真实冲突(同端口不同域名是合法的虚拟主机,按端口判会误报)。
- 残留缺口:错误提示不含冲突规则名;
nginx_mode = custom的规则不参与校验。详见 §八。
- 本项目现状(已核实):已具备。
改进
-
反代:入口/规则「复制」改为打开表单预填原内容,由用户确认后再保存(不再自动创建、不改域名后缀)
- 本项目现状:
ProxyView.vue已有复制相关实现(grep 命中 9 处),行为是否「直接创建」未核对。 - 建议:P2 核对;若本地是「点击即创建副本」,按上游改为「预填表单」,避免误建规则。
- 本项目现状:
-
反代:入口分组子表布局撑满卡片宽度;域名后紧跟复制/打开链接;状态列仅保留开关
- 本项目现状:
ProxyView.vue无「入口分组」概念(grep分组= 0)→ 子表撑满项不适用;「域名后紧跟复制/打开链接」与「状态列仅保留开关」是纯视觉项。 - 建议:P3 可选。注意:本轮刚在
ProxyRemoteView的域名列做了「域名 + 复制 + 打开」的同类交互(见提交ca309db),若要对齐,可复用同一套copyText+ 图标按钮样式。
- 本项目现状:
-
反代:同端口监听配置冲突提示仅在配置确实不一致时显示
- 本项目现状:未核对本地是否有该提示(
ProxyView.vue的冲突提示逻辑)。 - 建议:P2 核对(误报会干扰用户)。
- 本项目现状:未核对本地是否有该提示(
-
FRP:TCP 隧道远程端口手动指定支持 1–65535(0 仍为自动分配,随机范围 1024–65535)
- 本项目现状:前端已具备(
FrpView.vue:336:min="1" :max="65535")。 - 待核:后端「0 = 自动分配」的随机范围是否为 1024–65535(
internal/frp内)。 - 建议:核对后如有差异,改一行常量。
- 本项目现状:前端已具备(
-
通知:各通道密钥解密失败不再拖垮其它通道;事件开关保存与前端状态同步更可靠
- 本项目现状:
internal/notify/store.go有 10 处解密相关逻辑 → 需要看「单通道解密失败是否被上层吞掉/中断整轮发送」。 - 建议:P1 核对后修。典型修法:每个通道独立
try/catch(Go 里是错误收集而非 return),把失败通道记入日志与结果,继续发其它通道。
- 本项目现状:
修复
- 通知:旧 SMTP 等密文无法解密时,Telegram/Webhook 等已配置通道仍无法告警的问题
- 与第 17 条同源,同一处修改可一并解决。
三、不照搬项及原因
- FRP 页「Web / TCP / 高级 / 运行日志」分栏:本项目的公网反代链路(
ProxyRemoteView→ agent → VPS Nginx)依赖「http 规则 + 自定义域名 + 部署态」这一套语义;照搬分栏会让「规则 → 部署 → 健康」的入口被打散。当前「统计区 + 服务端表 + 规则表」的形态更贴合本项目已有能力。 crypto.randomUUID修复:本项目未使用该 API(全仓 0 处),无需移植。- 入口分组子表撑满:本项目
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 命中 |
核实后的关键结论
- 真正要修的只有两处:剪贴板降级(P0,6 处隐患)、通知单通道解密失败(P1,
store.go直接 return 导致整份配置不可用); - 两处是「条件性」的:通知时区(仅容器未设 TZ 时出现)、反代复制后缀(行为差异,非缺陷);
- 四处本项目不缺:无规则启动、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 | 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的查询 JOINproxy_rules取name: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。 - 改法(先轻后重,二选一):
- 保存 custom 配置时解析其
listen <port>与server_name,与ListEnabled的其它规则做 「同端口 + 同域名」比对,冲突即报错并写明冲突规则名。可复用internal/nginx/custom.go中 已有的listen前缀处理与syntax.go的行解析思路。 - 把解析结果落库到
proxy_hosts(hostname取server_name、listen_port取端口), 这样 8.3 的校验链天然覆盖 custom 规则,不必维护两套逻辑(更推荐,但要处理多域名/通配写法)。
- 保存 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 明确不做的两件事
- 不按「同端口」判冲突:同端口不同域名是合法虚拟主机,按端口报错会让「一个端口挂多个站点」 这种最常见的用法无法保存。
- 不探测系统层面的端口占用(
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 未覆盖的边界(有意保留)
- 全局手动 Nginx 片段(
SaveGlobalNginx→writeGlobalCustomInclude)里的server块不参与校验: 它没有 rule_id,纳入校验需要额外的归属语义,本轮未做。 - 前端预检(
ProxyView.vue的findHostConflict)只覆盖proxy_hosts(表单规则的rule.hosts),看不到 custom 占用; 这类冲突由后端保存时报错兜底,错误消息直接展示给用户。 - 解析器对手写配置里的
include、变量、listen unix:一律忽略——宁可漏报也不误报(拦不住保存比误报更可接受)。