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:
@@ -2,6 +2,63 @@
|
||||
|
||||
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
|
||||
|
||||
### 新增
|
||||
|
||||
+4
-2
@@ -45,7 +45,7 @@ FROM ${RUNTIME_IMAGE}
|
||||
ARG TARGETARCH
|
||||
# frpc 不预装:需要内网穿透时在「应用配置」弹窗内按需下载(存入数据目录)
|
||||
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/*
|
||||
|
||||
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 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_NGINX_MIME_TYPES=/etc/nginx/mime.types
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ services:
|
||||
FONU_DATA_DIR: /data
|
||||
FONU_LISTEN: :6893
|
||||
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_HTTPS_PORT: ${FONU_NGINX_HTTPS_PORT:-9443}
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ services:
|
||||
FONU_DATA_DIR: /data
|
||||
FONU_LISTEN: :6893
|
||||
FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-change-me-in-production}
|
||||
# 时区:通知消息时间按此显示(默认东八区)
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
|
||||
volumes:
|
||||
fonu-data:
|
||||
|
||||
@@ -21,6 +21,8 @@ services:
|
||||
FONU_DATA_DIR: /data
|
||||
FONU_LISTEN: :6893
|
||||
FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-change-me-in-production}
|
||||
# 时区:通知消息时间按此显示(默认东八区)
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
|
||||
volumes:
|
||||
fonu-data:
|
||||
|
||||
@@ -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:` 一律忽略——宁可漏报也不误报(拦不住保存比误报更可接受)。
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -105,11 +105,14 @@ func (s *Store) load(ctx context.Context, decrypt bool) (Config, runtimeSecrets,
|
||||
if decrypt && s.secret != nil {
|
||||
plain, err := s.secret.Decrypt(smtpEnc)
|
||||
if err != nil {
|
||||
return Config{}, runtimeSecrets{}, secret.DecryptHint(err)
|
||||
}
|
||||
// 单个通道的密文损坏(例如更换过加密密钥)不应让整份配置加载失败:
|
||||
// 仅标记该通道不可用并继续解析其它通道,避免 Telegram/Webhook 一起失效
|
||||
cfg.Email.HasPassword = false
|
||||
} else {
|
||||
secrets.smtpPassword = plain
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
webhookEnc, _ := s.settings.Get(ctx, settings.KeyNotifyWebhookSecret)
|
||||
if webhookEnc != "" {
|
||||
@@ -117,11 +120,13 @@ func (s *Store) load(ctx context.Context, decrypt bool) (Config, runtimeSecrets,
|
||||
if decrypt && s.secret != nil {
|
||||
plain, err := s.secret.Decrypt(webhookEnc)
|
||||
if err != nil {
|
||||
return Config{}, runtimeSecrets{}, secret.DecryptHint(err)
|
||||
}
|
||||
// 同上:只让该通道不可用,不影响其它通道
|
||||
cfg.Webhook.HasSecret = false
|
||||
} else {
|
||||
secrets.webhookSecret = plain
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
telegramEnc, _ := s.settings.Get(ctx, settings.KeyNotifyTelegramToken)
|
||||
if telegramEnc != "" {
|
||||
@@ -129,11 +134,13 @@ func (s *Store) load(ctx context.Context, decrypt bool) (Config, runtimeSecrets,
|
||||
if decrypt && s.secret != nil {
|
||||
plain, err := s.secret.Decrypt(telegramEnc)
|
||||
if err != nil {
|
||||
return Config{}, runtimeSecrets{}, secret.DecryptHint(err)
|
||||
}
|
||||
// 同上:只让该通道不可用,不影响其它通道
|
||||
cfg.Telegram.HasBotToken = false
|
||||
} else {
|
||||
secrets.telegramToken = plain
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Type == "" {
|
||||
legacyURL, _ := s.settings.Get(ctx, settings.KeyNotifyWebhookURL)
|
||||
|
||||
@@ -41,9 +41,18 @@ func SendTelegram(ctx context.Context, client *http.Client, cfg RuntimeConfig, c
|
||||
httpClient = &http.Client{Timeout: 15 * time.Second}
|
||||
}
|
||||
if proxyURL := strings.TrimSpace(cfg.Telegram.ProxyURL); proxyURL != "" {
|
||||
// 常见误填:只写 host:port(无 scheme)。补全后再解析,并校验 scheme 是否受支持
|
||||
if !strings.Contains(proxyURL, "://") {
|
||||
proxyURL = "http://" + proxyURL
|
||||
}
|
||||
parsed, err := url.Parse(proxyURL)
|
||||
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{
|
||||
Timeout: 15 * time.Second,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -360,6 +360,17 @@ func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (Rule, err
|
||||
if _, err := s.querier().ExecContext(ctx, `
|
||||
UPDATE proxy_hosts SET listen_port = ? WHERE rule_id = ? AND listen_port = ?
|
||||
`, 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
|
||||
}
|
||||
}
|
||||
@@ -463,6 +474,11 @@ func (s *Store) cleanupOrphanRules(ctx context.Context) error {
|
||||
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 {
|
||||
if host.ListenPort != nil && *host.ListenPort > 0 {
|
||||
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 {
|
||||
var ownerID int64
|
||||
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
|
||||
}
|
||||
owner, err := s.endpointOwner(ctx, "proxy_hosts", hostname, port, excludeRuleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ownerID != excludeRuleID {
|
||||
return fmt.Errorf("前端域名 %s:%d 已被其他规则使用", hostname, port)
|
||||
if owner != "" {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
for _, host := range hosts {
|
||||
port := hostEffectivePort(host, ruleListenPort)
|
||||
@@ -534,8 +580,12 @@ func (s *Store) replaceHosts(ctx context.Context, ruleID int64, ruleListenPort i
|
||||
VALUES (?, ?, ?)
|
||||
`, ruleID, host.Hostname, port)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
return fmt.Errorf("前端域名 %s:%d 已被其他规则使用", host.Hostname, port)
|
||||
if isUniqueViolation(err) {
|
||||
// 并发下可能绕过保存前校验:复检一次以给出带规则名的原因
|
||||
if ownerErr := s.ensureHostAvailable(ctx, host.Hostname, port, ruleID); ownerErr != nil {
|
||||
return ownerErr
|
||||
}
|
||||
return fmt.Errorf("域名 %s:%d 已被其它规则占用", host.Hostname, port)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
return RuleNginxView{}, err
|
||||
}
|
||||
// 切回自动模式后,手动配置的占用记录不再适用
|
||||
if err := s.store.ClearCustomEndpoints(ctx, id); err != nil {
|
||||
return RuleNginxView{}, err
|
||||
}
|
||||
if err := s.applyNginx(ctx); err != nil {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
return RuleNginxView{}, err
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -253,6 +253,7 @@ import StatusBadge from '../components/StatusBadge.vue'
|
||||
import { api } from '../api/client'
|
||||
import type { FrpAgentStatus, FrpRelease, FrpServer } from '../api/types'
|
||||
import { formatRelativeTime } from '../utils/format'
|
||||
import { copyText } from '../utils/clipboard'
|
||||
|
||||
// 操作动作:按动作粒度控制 loading,保证"只有被点的按钮转圈"
|
||||
type Action =
|
||||
@@ -470,10 +471,8 @@ async function viewConfig(target: FrpServer) {
|
||||
}
|
||||
|
||||
async function copyConfig() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(configContent.value)
|
||||
message.success('已复制 frps 配置')
|
||||
} catch { message.error('复制失败,请手动选择文本复制') }
|
||||
if (await copyText(configContent.value)) message.success('已复制 frps 配置')
|
||||
else message.error('复制失败,请手动选择文本复制')
|
||||
}
|
||||
|
||||
function installFrps(target: FrpServer) {
|
||||
|
||||
@@ -878,13 +878,12 @@ async function clearCustomIP() {
|
||||
await saveCustomIP()
|
||||
}
|
||||
|
||||
// 复用统一剪贴板工具(本页原有同名函数,故用别名导入避免冲突)
|
||||
import { copyText as copyToClipboard } from '../utils/clipboard'
|
||||
|
||||
async function copyText(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
message.success('已复制')
|
||||
} catch {
|
||||
message.error('复制失败')
|
||||
}
|
||||
if (await copyToClipboard(text)) message.success('已复制')
|
||||
else message.error('复制失败')
|
||||
}
|
||||
|
||||
async function refreshPublicIP() {
|
||||
|
||||
@@ -585,6 +585,10 @@
|
||||
</div>
|
||||
<div class="proxy-modal__scroll">
|
||||
<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>
|
||||
</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 servers = ref<FrpServer[]>([])
|
||||
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 proxyTrafficErrors = ref<Record<number, string>>({})
|
||||
const serverDiagnostics = ref<Record<number, FrpServerDiagnostics>>({})
|
||||
|
||||
@@ -37,6 +37,35 @@
|
||||
/>
|
||||
</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="访问日志">
|
||||
<LogToolbar
|
||||
v-model:keyword="accessKeyword"
|
||||
@@ -335,6 +364,63 @@ function systemRowClassName(row: SystemLogEntry) {
|
||||
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>>(() => [
|
||||
{ title: '时间', key: 'time', width: 168, render: (row) => logTimeCell(row.time) },
|
||||
{
|
||||
@@ -491,7 +577,7 @@ async function loadSystemLogs() {
|
||||
async function loadAll() {
|
||||
pageError.value = ''
|
||||
try {
|
||||
await Promise.all([loadAccess(), loadErrorLogs(), loadSystemLogs()])
|
||||
await Promise.all([loadAccess(), loadErrorLogs(), loadSystemLogs(), loadFrpLogs()])
|
||||
} catch (error) {
|
||||
pageError.value = error instanceof Error ? error.message : '请检查 Fonu 服务是否正常运行'
|
||||
}
|
||||
|
||||
@@ -300,7 +300,14 @@
|
||||
<h3 class="modal-title">反代配置 — {{ confDomain }}</h3>
|
||||
<n-button size="small" quaternary @click="confModal = false">关闭</n-button>
|
||||
</div>
|
||||
<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">
|
||||
<n-button :disabled="!confContent" @click="copyConf">复制</n-button>
|
||||
<n-button type="primary" @click="confModal = false">关闭</n-button>
|
||||
@@ -327,6 +334,7 @@ import type {
|
||||
FrpAgentRouteDetail, FrpAgentRouteHealth, FrpAgentStatus, FrpProxy, FrpProxyTraffic, FrpServer,
|
||||
} from '../api/types'
|
||||
import { formatBytes, formatDate, formatRate, formatRelativeTime } from '../utils/format'
|
||||
import { copyText } from '../utils/clipboard'
|
||||
|
||||
type Action =
|
||||
| `deploy:${number}` | `remove:${number}` | `conf:${number}` | `save-settings:${number}` | 'install-nginx'
|
||||
@@ -513,10 +521,8 @@ function isHealthy(candidate: FrpProxy) {
|
||||
|
||||
// 域名快捷操作:复制 / 新窗口打开(http 规则走 http,https 规则走 https)
|
||||
async function copyDomain(domain: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(domain)
|
||||
message.success(`已复制 ${domain}`)
|
||||
} catch { message.error('复制失败,请手动选择文本复制') }
|
||||
if (await copyText(domain)) message.success(`已复制 ${domain}`)
|
||||
else message.error('复制失败,请手动选择文本复制')
|
||||
}
|
||||
|
||||
function openDomain(candidate: FrpProxy, domain: string) {
|
||||
@@ -733,10 +739,8 @@ async function viewConf(candidate: FrpProxy) {
|
||||
}
|
||||
|
||||
async function copyConf() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(confContent.value)
|
||||
message.success('已复制反代配置')
|
||||
} catch { message.error('复制失败,请手动选择文本复制') }
|
||||
if (await copyText(confContent.value)) message.success('已复制反代配置')
|
||||
else message.error('复制失败,请手动选择文本复制')
|
||||
}
|
||||
|
||||
// 切换服务端必须重置,避免上一台的健康 / 流量 / 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);
|
||||
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; }
|
||||
.mono { font-family: var(--fonu-mono); font-size: 13px; }
|
||||
.mono-wrap { white-space: normal; word-break: break-all; }
|
||||
|
||||
@@ -1361,12 +1361,10 @@ function ruleName(rule: ProxyRule): string {
|
||||
return name || primaryHost(rule)
|
||||
}
|
||||
|
||||
// 复制:保留原名称与域名,由用户在表单里确认修改(不再自动追加「-复制」后缀,避免误建同名规则)
|
||||
function duplicateName(rule: ProxyRule): string {
|
||||
const base = rule.name?.trim() || primaryHost(rule)
|
||||
const suffix = '-复制'
|
||||
const maxBase = 100 - suffix.length
|
||||
const trimmedBase = base.length > maxBase ? base.slice(0, maxBase) : base
|
||||
return `${trimmedBase}${suffix}`
|
||||
return base.length > 100 ? base.slice(0, 100) : base
|
||||
}
|
||||
|
||||
function hostsToText(rule: ProxyRule): string {
|
||||
@@ -1405,13 +1403,11 @@ function ruleAccessUrls(rule: ProxyRule): string[] {
|
||||
return ruleHosts(rule).map((host) => hostAccessUrl(rule, host))
|
||||
}
|
||||
|
||||
import { copyText } from '../utils/clipboard'
|
||||
|
||||
async function copyAccessUrl(url: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
message.success('链接已复制')
|
||||
} catch {
|
||||
message.error('复制失败')
|
||||
}
|
||||
if (await copyText(url)) message.success('链接已复制')
|
||||
else message.error('复制失败')
|
||||
}
|
||||
|
||||
function renderLinkAction(icon: Component, title: string, onClick: () => void): VNode {
|
||||
@@ -2040,7 +2036,7 @@ function openDuplicate(rule: ProxyRule) {
|
||||
syncSecurityExpanded()
|
||||
formTab.value = 'basic'
|
||||
showModal.value = true
|
||||
message.info('已填入复制内容,请修改域名后保存')
|
||||
message.info('已填入复制内容,请确认名称与域名后保存')
|
||||
}
|
||||
|
||||
function openEdit(rule: ProxyRule, tab: 'basic' | 'security' | 'nginx' = 'basic') {
|
||||
|
||||
Reference in New Issue
Block a user