diff --git a/.gitignore b/.gitignore index 4d21fcb..45b3be2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ bin/ web/node_modules/ web/dist/ cmd/fonu/web/dist/ +dist/ *.db /data/ .DS_Store @@ -10,6 +11,8 @@ cmd/fonu/web/dist/ # scripts/ 下的本地辅助脚本不入库,但随仓库分发的 agent 安装脚本需要跟踪 scripts/* !scripts/agent-install.sh +!scripts/build-linux.ps1 +!scripts/fonu-install.sh # 本地压缩包与导出产物不入库(如 web/src.rar) *.rar *.zip diff --git a/CHANGELOG.md b/CHANGELOG.md index 180f4d1..22410bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ All notable changes to this project will be documented in this file. +## [v1.2.13] - 2026-09-27 + +### 新增 + +- **Linux 原生安装包**:`scripts/build-linux.ps1` 一条命令产出 `fonu-<版本>-linux-{amd64,arm64}.tar.gz`(内含二进制、migrations、systemd 安装脚本、前端运行时资源与 sha256 校验文件,无需 Docker/WSL,依赖纯 Go 的 modernc.org/sqlite);`scripts/fonu-install.sh` 负责目标机安装与卸载:装到 `/opt/fonu`、数据在 `/var/lib/fonu`、环境变量在 `/etc/fonu/fonu.env`(首次生成随机会话密钥,重装保留)并注册 `fonu.service` +- **`--reset-admin`**:忘了管理员口令时清空 `admins`/`sessions` 后重启,启动流程会重新生成随机口令;装完也会直接打印初始口令(以前只提示访问地址,用户找不到口令) +- **Cloudflare Tunnel 融合方案文档** `docs/cloudflared-integration-plan.md`:同类项目对比、两种托管模型、可复用面盘点、参考实现分析(含用户自有项目 cf-tunnel-manager)与 IP 优选可行性边界 +- 设置页「ACME 证书」新增 **DNS 解析器(高级)**设置项 + +### 修复 + +- **证书申请:DNS-01 传播检查用错解析器**:lego 默认读系统 `/etc/resolv.conf`,容器里那是 Docker 的 `127.0.0.11`,它不返回权威 NS,于是报 `could not determine authoritative nameservers`。现在显式传入 `dns01.AddRecursiveNameservers`(默认 `223.5.5.5:53,1.1.1.1:53,8.8.8.8:53`,可用 `acme_dns_resolvers` 覆盖,填 `system` 回到系统解析器;配置无效时报明确错误而不是静默降级) +- **Buypass 下线**:该 CA 已于 2025-10-15 停止签发、2026-04-15 终止 ACME 服务,已从可选 CA 列表移除;历史记录仍可展示,直接提交会被拦下并给出中文原因(不再让用户面对一个看不懂的 ACME 报错) +- **ZeroSSL 报错信息不全**:返回 422 时把 ZeroSSL 的 `error.type` / `error.code` 带进错误;非 JSON 响应退回压平截断的正文,便于定位 +- **密钥泄露**:ZeroSSL EAB 请求失败时,`url.Error` 里带 `access_key` 的完整 URL 会被写进任务日志与 `<数据目录>/logs/app.log`;现在只保留底层错误原因 + +### 测试 + +- `internal/acme`:递归解析器列表解析(分隔符/自动补 53/上限 8 条/默认值可用);ZeroSSL EAB 四条(成功、422 带错误类型、非 JSON 退回正文、网络错误不泄露密钥) + +### 变更 + +- 版本号提升到 1.2.13;`web/package.json` 同步对齐 +- 本轮不含 agent 行为变更,`AgentVersion` 仍为 0.12.4 + ## [v1.2.12] - 2026-09-25 ### 修复 diff --git a/VERSION b/VERSION index f2ae0b4..0b1f1ed 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.12 +1.2.13 diff --git a/docs/brand-rename-plan.md b/docs/brand-rename-plan.md new file mode 100644 index 0000000..bb4b851 --- /dev/null +++ b/docs/brand-rename-plan.md @@ -0,0 +1,429 @@ +# Fonu 品牌升级 / 改名方案 + +> 日期:2026-09-25 对象:Fonu 1.0.2(VERSION 1.2.12) +> 方法:把 "Fonu / fonu / FONU_" 在仓库里**实际出现的位置逐个查出来**(下文带 `文件:行` 的都是读代码得到的),再按「改动代价 × 破坏性」分层给方案。 +> 结论速览:**能做,而且没有"改不动"的地方;但"全面改名"绝不等价于全局替换——有 3 处一动就会伤到存量用户(数据库文件名、环境变量前缀、远端 agent 凭据路径)。推荐策略 B:分层渐进 + 兼容层,一个版本后清理兼容代码。** + +--- + +## 〇、一句话回答 + +| 问题 | 回答 | +| --- | --- | +| 能不能换名? | 能。所有品牌落点都是可枚举的文本/路径/常量,没有第三方注册死锁(GitHub 仓库名可改、镜像名可切、CLI 名随二进制)。 | +| 能不能"全面"? | 能,但要分 5 层做。**展示层(用户看见的字)改起来是半天的事,存储层与契约层(数据文件名、环境变量、远端 agent 路径)必须带兼容层**,否则老用户升级后会出现「数据像丢了」「容器起来了但库是空的」「VPS 上的 agent 失联」。 | +| 改名量级 | 实测 `Fonu|fonu|FONU` 至少 1912 处、120+ 个文件(分页上限,实际更多);其中约 2/3 是 CSS 变量(`--fonu-*`)、Go import 前缀、文档正文这类**机械替换**,真正的决策点只有 5 个(见 §二)。 | + +--- + +## 一、品牌落点盘点(按层,带实测证据) + +### 第 1 层:展示层(用户能看见的字与图) + +| 落点 | 位置 | 说明 | +| --- | --- | --- | +| 浏览器标题 | `web/index.html` `Fonu` | 建议与侧栏名统一 | +| 侧栏品牌区 | `web/src/layouts/AppLayout.vue:15,17,34` | logo `alt="Fonu"`、`brand__name` 文字、页脚品牌名;`:132` 引 `../assets/brand/logo.png` | +| 品牌标语 | `AppLayout.vue:18` 「让 NAS 访问更简单」 | 换名时建议一并重写(新名 + 新一句话定位) | +| 视觉资产 | `web/src/assets/brand/logo.png`、`web/public/favicon.png`、`web/assets/image/logo.png`、`web/assets/image/banner.png` | 图片需重做;`web/assets/image/*` 由 Dockerfile `COPY web/assets/image /app/web/assets/image` 进镜像 | +| 登录页 | `web/src/views/LoginView.vue` | 品牌露出最集中的页面之一 | +| 公共组件 | `web/src/components/{PageHeader,EmptyState,LoadError,StatusBadge,StatCard,FonuCard}.vue` | 前 5 个是文案/样式里的品牌残留,`FonuCard.vue` **文件名本身含品牌名**(含 import 该组件的多处 view) | +| 前端常量 | `web/src/constants/app.ts:1` `GITHUB_REPO = 'O96u/Fonu'` | 对外展示的仓库地址,改名必须同步(见 §六) | +| 通知消息 | `internal/notify/format.go`、`internal/notify/{service,store,push}.go` | 邮件/推送正文里的品牌前缀,用户直接在手机上看到 | +| 公开状态页 | `internal/api/status_page_handler.go`、`status_handler.go`(各 4~8 处) | `/status` 公开页标题、页脚品牌 | +| CSS 变量 | `web/src/styles/variables.css:2` 起 `--fonu-brand`、`--fonu-brand-soft`…(全仓 51 处) | **纯机械替换**,但波及所有 `.vue` 样式;建议一次性做且用 `replace_all` | +| 本地偏好 key | `web/src/composables/useTheme.ts:4` `themeKey = 'fonu-theme'` | 改名后老用户主题偏好重置(可一行兼容,见 §五.4) | + +### 第 2 层:交付 / 部署层(用户拿到的东西叫什么) + +| 落点 | 位置 | 风险 | +| --- | --- | --- | +| 主程序二进制名 | `Dockerfile`:构建产物 `/fonu` → `COPY --from=go-builder /fonu /app/fonu`、`CMD ["/app/fonu"]`;发布物 `fonu-1.1.0-linux-amd64` | 中:换名即换文件名,用户脚本里的路径要跟着改 | +| agent 交叉编译名 | `Dockerfile`:`go build -o internal/frp/agentbin/fonu-agent-linux-{amd64,arm64}` | **高**:与 `internal/frp/agentbin/embed.go` 的 `//go:embed fonu-agent-linux-amd64` 强耦合,改一处必须同批改另一处,否则编译期直接失败 | +| Docker 编排 | `docker-compose.yml`、`.host.yml`、`.hub.yml`:service `fonu`、`container_name: fonu`、`image: fonu:local`、volume `fonu-data` | **高**:volume 名一变,`docker compose up` 会建**新空 volume**,用户会认为「数据丢了」;必须写清迁移命令或显式 pin `name:` | +| 镜像仓库名 | `.github/workflows/docker-release.yml:63,94` `${DOCKERHUB_USERNAME}/fonu:-` | Docker Hub **不支持仓库改名**(只能新建),见 §六 | +| 前端 embed 路径 | 根 `.gitignore` 的 `cmd/fonu/web/dist/`、`.github/workflows/ci.yml:19,42,43`、`//go:embed all:web/dist` 所在 `cmd/fonu/main.go` | 中:目录改名会牵动 CI、构建脚本、embed 指令、Dockerfile | +| agent 安装布局 | `scripts/agent-install.sh`:`PREFIX=/usr/local/bin/fonu-agent`、`CONF_DIR=/etc/fonu-agent`、`UNIT=/etc/systemd/system/fonu-agent.service`、`/var/lib/fonu-agent/certs` | **高**:涉及已在用户 VPS 上运行的服务,升级路径必须兼容(见 §五.5) | +| 环境变量 | `internal/config/config.go:24,29,31~38`(`FONU_DATA_DIR`/`FONU_LISTEN`/`FONU_SESSION_SECRET`/`FONU_NGINX_*`/`FONU_FRPC_BIN`/`FONU_FRP_PID`)+ `Dockerfile` 的 `ENV` 块 + `docker-compose*.yml` + `docs/DEPLOYMENT.md`(大量) | **高**:前缀一改,老用户的 `.env` / compose / `-e` 全部静默失效 → 容器用默认值起来(例如数据目录跑回 `/data` 之外、会话密钥回到 `change-me-in-production`) | + +### 第 3 层:代码标识层(改了不影响运行语义,但波及面最大) + +| 落点 | 位置 | 说明 | +| --- | --- | --- | +| Go module path | `go.mod:1` `module github.com/fonu/fonu` | **每个 .go 文件的 import 块**(`internal/api` 20 个文件、`internal/nginx`、`internal/frp`…);同时 `Dockerfile` 的 `-ldflags "-X github.com/fonu/fonu/internal/version.Version=..."` 必须同步改,否则版本号注入静默失效(二进制显示 `dev`) | +| 前端包名 | `web/package.json:2` `"name": "fonu-web"` | 机械替换 | +| 内嵌 agent 资产名 | `internal/frp/agentbin/embed.go` | 见上,与 Dockerfile 成对出现 | +| 文档 | `README.md`、`CHANGELOG.md`、`docs/*.md`(20+ 篇,其中 `DEPLOYMENT.md` 56 处、`frps服务端管理方案.md` 52 处) | 量大但不危险;建议随代码同批改,避免文档与实现不一致 | + +### 第 4 层:运行期契约层(跨进程/跨版本,最容易漏) + +| 落点 | 位置 | 说明 | +| --- | --- | --- | +| 会话 Cookie 名 | `internal/api/middleware.go:18` `r.Cookie("fonu_session")` | 改名 = **所有已登录用户掉线**(可接受,但要在发布说明写明) | +| agent 凭据文件 | `internal/frp/agent_installer.go:35,36,168` + `:145`:服务端通过 SSH 在 VPS 上 `sed` 读 `/etc/fonu-agent/agent.env` 的 `FONU_AGENT_TOKEN` | **服务端与 agent 必须同批发布**:服务端换了路径/变量名,而用户 VPS 上还是旧 agent(或反之),「Agent 探测」会直接失败 | +| agent 自述标识 | `internal/agent/server.go:213,221` `{"ok":true,"service":"fonu-agent"}` | 属于对外可见的 API 响应体;若有任何前端/脚本按 `service` 字段判断,改名前要一起查 | +| agent 落盘目录 | `internal/agent/server.go:133,145`:`/var/lib/fonu-agent/certs`、`/var/lib/fonu-agent` | 与安装脚本一致 | +| 远端 systemd unit 描述 | `internal/agent/server.go:344` `Description=frps service (managed by fonu-agent)` | 远端残留可见文本 | +| 远端 nginx 探针/标记 | `internal/agent/server.go:674-675`:写 `fonu-probe.conf`、文件头 `# Managed by fonu-agent. Do not edit.`;`:692` 已有「清理历史残留 `fonu-*.conf`」逻辑 | **改名最容易踩的隐性坑**:老 VPS 上留着旧品牌标记的片段,新版本若只认新标记,会把它们判成「未知文件」→ 漂移检测误报或残留永不清理 | +| 远端 frp 安装与漂移 | `internal/frp/ssh_installer.go`、`internal/frp/route_drift.go`(各 4 / 2 处) | 同上:判定「这是我管理的文件」的依据里含品牌标记 | + +### 第 5 层:数据层(最危险,改错就是"丢数据") + +| 落点 | 位置 | 说明 | +| --- | --- | --- | +| 数据库文件名 | `internal/config/config.go:49-51` `DBPath() = /fonu.db` | **一改,程序找不到库 → 建一个空库 → 用户以为数据没了**。必须带「探测旧文件 + 原子重命名」 | +| 数据目录默认值 | `config.go:126` `defaultDataDir()`:`/data` 或 `/.data` | ✅ **好消息:不含品牌名,无需迁移** | +| 各子目录 | `config.go` 的 `NginxDir/LogsDir/CertsDir/FrpDir`…(`nginx`/`logs`/`certs`/`frp`/`ssh_known_hosts`) | ✅ 也都不含品牌名,只有 DB 文件名带品牌 | +| 迁移脚本 | `migrations/023_frp_server_agent.sql`(1 处品牌) | SQL 迁移一般不需要改(库文件名才是关键路径) | + +> 盘点的意义:**品牌名"长"在 5 个不同性质的地方**——字、路径、常量、协议、文件名。前两类可以随便改,后三类改之前要先想好兼容。 + +--- + +## 二、三个必须先解决的暗礁 + +1. **数据库文件名 `fonu.db`(`internal/config/config.go:50`)** + 裸改 = 静默新建空库。做法:启动时若「新名库不存在」且「`fonu.db` 存在」→ 同目录 `os.Rename` 到新名(原子、可回滚),失败则**继续用旧路径并在日志/界面提示**,绝不新建第二份库。Windows 上文件被占用时 rename 会失败,所以「失败即回退到旧路径」这条兜底不是可选项。 + +2. **环境变量前缀 `FONU_*`(`internal/config/config.go:24,29,31-38`)** + 裸改 = 老部署的 `FONU_SESSION_SECRET` 失效 → 会话密钥回到公开默认值 `change-me-in-production`(**这是安全问题,不只是体验问题**:拿到默认密钥可伪造管理员会话、解密库里凭据,见 `docs/DEPLOYMENT.md` 对该密钥的说明)。 + 做法:`envOr` 升级为「新前缀 → 旧前缀 → 默认值」三档读取,命中旧前缀时启动日志给一条明确的迁移提示(含新旧变量名对照),至少保留一个 stable 版本后再删旧前缀。**尤其 `FONU_SESSION_SECRET` 与 `FONU_DATA_DIR` 必须双读。** + +3. **远端 agent 的凭据路径与品牌标记(`agent_installer.go:35,36,145,168`;`agent/server.go:674,692`)** + 服务端是**通过 SSH 去读用户 VPS 上的文件**来判断 agent 状态的。服务端与 agent 不同批发布时: + - 探测读旧路径、agent 写新路径 → 「Agent 探测」永远显示未配置; + - nginx 探针/片段标记改名 → 旧文件既不被清理也不被识别,漂移检测出现假阳性。 + 做法:探测端**两个路径都试**(新路径优先,旧路径兜底);`agent` 二进制**同时接受新旧 env 变量名**;生成器把新旧品牌标记**都视为"自己生成的文件"**(清理/漂移判定同时匹配),并在 agent 版本号里体现本次变更(本仓既有约定:agent 行为变更需递增 `AgentVersion` 并重编内嵌双架构二进制)。 + +--- + +## 三、三种策略与推荐 + +| | 策略 A:只换皮 | **策略 B:分层渐进 + 兼容层(推荐)** | 策略 C:一次性硬切 | +| --- | --- | --- | --- | +| 范围 | 只做第 1 层(文案/图/CSS 变量/通知) | 第 1→2→4→5→3 层按阶段推进,关键处留兼容 | 5 层全改,不留兼容 | +| 代码净度 | 低(`fonu.db`、`fonu-agent`、`FONU_*` 仍是旧名) | 中:过渡期有双读分支,1~2 个版本后清理 | 高 | +| 存量用户 | 无感 | 无感(或仅掉一次登录态) | **有损**:需手工迁移数据目录/重建 volume/重装 agent | +| 适合 | 只想换个门面、用户量很小 | 已有真实用户、有 Docker 与 VPS 两种部署形态(本仓现状) | 尚未发布 / 可强制清退老版本 | +| 代价 | 半天量级,零风险 | 机械替换 + 5 个兼容点 + 一轮双形态回归 | 改动最少但迁移成本转嫁给用户 | + +**推荐 B**,理由:本仓已有 Docker(`docker-compose*.yml`)、host 直装、VPS agent 三种落地面,且 `docs/DEPLOYMENT.md` 里明确把 `FONU_SESSION_SECRET` 当作安全关键项——这些决定了「裸改」的代价远高于「留兼容层」。 + +--- + +## 四、推荐执行路线(五阶段) + +> 每阶段的验证沿用本仓既有约定:`go build ./... && go vet ./... && go test ./...` + `cd web && npm run typecheck && npm run build`;涉及 agent 的改动递增 `AgentVersion` 并重编内嵌二进制;用户明确要求提交时,才同批更新 `CHANGELOG.md` 与 `VERSION`。 + +### 阶段 0:定名与冻结(不写代码) + +- 定下新品牌名与英文小写标识(CLI/包名/目录用)、显示名(首字母大写形态)。 +- 检查占用:域名、GitHub org/repo、Docker Hub 命名空间、唯一包名(如 npm/PyPI 若以后要用)、以及**中文语境下的商标检索**。 +- 决定 GitHub 与 Docker Hub 的走向(§六),因为 `web/src/constants/app.ts:1` 与版本检查 `web/src/utils/version.ts:37` 直接依赖它。 +- 产出:`旧名 → 新名` 对照表(含:显示名、小写标识、二进制名、镜像名、容器名、env 前缀、cookie 名、DB 文件名、agent 目录名)。 + +### 阶段 1:展示层(零风险,先拿到"看着像新品牌"的反馈) + +- 改:`web/index.html` 标题、`AppLayout.vue:15,17,18,34`、`LoginView.vue`、公共组件(含 `FonuCard.vue` 重命名)、`web/src/constants/app.ts:1`、`internal/notify/format.go`、`internal/api/status_page_handler.go`/`status_handler.go` 的品牌文案。 +- 换图:`web/src/assets/brand/logo.png`、`web/public/favicon.png`、`web/assets/image/{logo,banner}.png`。 +- CSS 变量 `--fonu-*` → `---*`(`variables.css` 定义 + 全部 `.vue` 引用,用 `replace_all` 一次完成)。 +- 验证:`npm run build` 通过 + 前端手点一遍侧栏/登录页/设置页/公开状态页。 +- 回滚:纯文案与样式,`git revert` 即可。 + +### 阶段 2:交付层(二进制、镜像、compose、发布物) + +- 二进制 `fonu` → 新名;`docker-compose*.yml` 的 service/容器名/image/volume;`.github/workflows/docker-release.yml:63,94` 的镜像路径。 +- **volume 必须处理**:在 compose 里显式声明 `volumes: { -data: { name: -data } }` 并在 `docs/DEPLOYMENT.md` 写「老用户迁移三行命令」(旧 volume 挂到新名,或 `docker volume create` + 复制)。 +- 老二进制名建议保留一个版本(同镜像内留符号链接,或发布说明里给 rename 命令),避免用户脚本直接报错。 +- 验证:本地 `docker compose up`,用**旧 volume** 起新容器,确认库被正确打开(这是本阶段的验收核心,不是"能起")。 +- 回滚:compose 文件与镜像名回退即可;volume 命名要事先确定,避免中途再改一次。 + +### 阶段 3:存储层与契约层(暗礁集中区,必须带兼容) + +- `config.go`:`DBPath()` 换新名 + **旧库自动接管**(§二.1);`envOr` 支持旧前缀兜底 + 启动迁移提示(§二.2)。 +- `middleware.go:18` cookie 名:可选做「新名 cookie 优先、旧名兜底」读一次,或直接换名并在发布说明写「需重新登录」。 +- `useTheme.ts:4`:读旧 key 回退 + 写回新 key(三行代码,避免用户主题偏好被重置)。 +- agent 侧:`agent-install.sh`(`/usr/local/bin`、`/etc`、`/var/lib`、systemd unit 名)、`internal/agent/server.go:133,145,213,221,344,674,692`、`internal/frp/agent_installer.go:35,36,145,168`、`ssh_installer.go`、`route_drift.go`。 + - 安装脚本要能**识别并接管已安装的旧 agent**(停旧 unit、装新 unit、保留/迁移 `/etc/fonu-agent` 的 env 文件),而不是留下两套服务互相抢。 + - 探测与漂移判定:新旧路径/标记**都算自己人**,至少一个版本。 +- 验证(关键):造一份**旧版数据目录**,用新二进制启动 → 确认打开的是旧库且数据完整;再用**旧版 agent** 对**新版服务端**跑一次「Agent 探测 / 安装 / 日志」;`go test ./...` 通过(新增的 DB 接管与 env 双读建议各补一个纯函数单测,参照本仓 `maskFRPSSecrets` 那类测试写法)。 +- 回滚:DB rename 是单向的(必要时提供反向重命名说明);env 双读让回滚无感。 + +### 阶段 4:代码标识层(机械但波及全仓) + +- `go.mod:1` module path → 新路径;全仓 import 替换(`gofmt`/`goimports` 后 `go build ./...` 兜底)。 +- **同步点**:`Dockerfile` 的 `-ldflags "-X github.com/fonu/fonu/internal/version.Version=..."`(漏改则版本号变 `dev`,且不报错);`internal/frp/agentbin/embed.go` 与 Dockerfile 的 agent 输出名;`web/package.json:2`;`cmd/fonu/` → `cmd//`(连带 `.gitignore`、`ci.yml:19,42,43`、embed 指令)。 +- 建议与阶段 2/3 分开发布(单独一次提交),便于「编译失败」时快速定位。 +- 验证:`go build ./... && go vet ./... && go test ./...`;构建后确认 `GET /api/version`(或设置页)显示真实版本号而非 `dev`。 + +### 阶段 5:清理(1~2 个版本后) + +- 删除旧 env 前缀兜底、旧 DB 文件名探测、旧 cookie/localStorage key、旧 agent 路径与标记、旧预先/镜像别名。 +- 用 §九 的自查清单跑一次全仓 grep,确认只剩新名。 + +--- + +## 五、兼容层设计要点(可照做) + +1. **环境变量**:`envOr` → `envOrLayered(newKey, oldKey, default)`;命中旧 key 时启动日志输出一次「检测到旧变量 `FONU_DATA_DIR`,已生效;请迁移到 `_DATA_DIR`」。优先级:新 > 旧 > 默认,**不要**用「旧覆盖新」。 +2. **数据库接管**:判断顺序 = 新库存在? → 用新库;否则旧库存在? → rename 后使用(失败则直接用旧路径 + 明确提示);否则建新库。全程不改动用户其它文件。 +3. **Docker volume**:`name:` 显式固定新名;给出一条从旧 volume 复制的命令;**不要**靠 compose 自动改名。 +4. **前端本地状态**:`localStorage.getItem('fonu-theme') ?? localStorage.getItem('-theme')`,读到旧值时写回新 key。 +5. **agent 平滑升级**: + - agent 二进制双读新旧 env 名(`FONU_AGENT_TOKEN` 与 `_AGENT_TOKEN`); + - 安装脚本 `systemctl disable --now fonu-agent` → 装新 unit → 迁移 `/etc/fonu-agent/agent.env` → 启动新 unit; + - 服务端探测命令**两个路径并联**(`test -s A || test -s B`),避免版本错配期间误报; + - 远端文件识别同时匹配新旧标记;`server.go:692` 的「历史残留清理」逻辑扩成「新旧品牌前缀都清」。 +6. **凭据与密钥**:品牌改名**不应触碰**加密数据本身(`secret.Box` 加密的 DDNS/FRP/SMTP 凭据与品牌无关),所以只要 `FONU_SESSION_SECRET` 双读做好,老用户的凭据继续可用——这也是「不要顺手改密钥派生」的理由。 + +--- + +## 六、平台与外部身份 + +| 项 | 现状 | 建议做法 | +| --- | --- | --- | +| GitHub 仓库 | `web/src/constants/app.ts:1` `O96u/Fonu`;`web/src/utils/version.ts:37` 用 `api.github.com/repos/${GITHUB_REPO}/releases/latest` 做版本检查 | GitHub **支持仓库改名并保留旧地址重定向(含 API 与 release 资产下载)**,但重定向会被「别人用旧名新建仓库」永久打断;对自用/小众项目风险低,仍建议:改名后**保留旧仓库名不被占用**(或在旧名新建一个空仓库做说明页——注意这恰好会打断重定向,二者只能选一)。改名后必须回归一次版本检查(`version.ts` 是前端直连 GitHub API)。 | +| Docker Hub | `.github/workflows/docker-release.yml:63,94` `${DOCKERHUB_USERNAME}/fonu` | Docker Hub **不支持仓库改名**,只能新建仓库并重推标签;老用户 `docker pull` 旧名会继续拿到旧标签(不会自动指向新镜像,也不会报错)→ 发布说明里给出新镜像地址 + 迁移命令。 | +| 域名 / 文档站 | 仓库内无硬编码站点域名 | 若已注册 `fonu.*` 域名,改名后建议保留旧域名 301 到新域名(同 GitHub 重定向的思路)。 | +| 文档 | 20+ 篇 md(`DEPLOYMENT.md` 56 处、`frps服务端管理方案.md` 52 处…) | 与代码同批改;`docs/brand-rename-plan.md`(本文)建议保留,作为改名决策记录。 | +| 发布物命名 | 根目录 `fonu-1.1.0-linux-amd64`、`fonu-1.1.0-src.zip`、`fonu-image-build4.log` 等 | 历史产物不必改名;新产物按新名。 | + +--- + +## 七、新名候选与自查 + +**命名约束(硬性)**: +- 小写标识:仅 `[a-z0-9-]`,2~8 字符,无连字符歧义(`-agent`、`.db`、`---brand`、`_DATA_DIR` 都要好看); +- 不与现有工具重名(`frp`、`nginx`、`caddy`、`tunnel` 等已占用词避免单独使用); +- 域名 / GitHub org / Docker Hub / npm 包名四处可注册; +- 中文可读(本项目用户群中文语境,"念出来不别扭"很重要); +- 商标自查(至少本国 + 主要市场)。 + +**候选清单**(`标识` 一列就是命令行、包名、`<标识>-agent`、`<标识>.db`、`_DATA_DIR` 要用的形态;每一项在落地前都要走 §七.3 的四处自查,下表「备注」只标注**已知**的同名存在,不代表未被占用) + +#### A. 通路 / 门 / 桥(英文语义直白,技术用户第一眼就懂) + +| 显示名 | 标识 | 中文名建议 | 寓意与适配 | +| --- | --- | --- | --- | +| Portway | `portway` | 通途 | port(端口)+ way(通路)——产品做的就是"把端口接出去",语义与技术完全对齐;`portway-agent`、`portway.db` 读感都好 | +| Viaduct | `viaduct` | 虹桥 | 高架桥:在两个互不相通的网络之间架一条通路;画面感强,适合做几何型 logo | +| Causeway | `causeway` | 长堤 | 跨水而过的路——跨过 NAT/"内网之海";8 字符是上限,仍可接受 | +| Underpass | `underpass` | 潜通 | 字面"从下面穿过去",暗合 NAT 打洞的本意;比 Underpass 更贴的同类还有 Overpass,但后者已被 OSM 的查询 API 广泛占用 | +| Thruway | `thruway` | 直通 | thru 带"穿透"的俚语味,拼写短促;注意美式拼写,正式英文里少见 | +| Ponto | `ponto` | 蓬托 | 葡萄牙语/世界语"桥",5 字符、短促好念;同名存在较多(银行、设计工具),必须自查 | + +#### B. 渡 / 舟 / 引渡(中文意象最贴产品,拼音做标识) + +| 显示名 | 标识 | 中文名 | 寓意与适配 | +| --- | --- | --- | --- | +| Dukou | `dukou` | 渡口 | **语义完全命中**:把内网的船渡到公网对岸;中文用户一看就懂、一说就记住;`dukou` 5 字符,命令行友好 | +| Zhandao | `zhandao` | 栈道 | 在无路可走的险处架出一条路——正是 NAT 之后的处境;名字冷门,检索不打架 | +| Yongdao | `yongdao` | 甬道 | 连接两端的地下通道;比"隧道"更雅,避开与 tunnel 这类通用词的碰撞 | +| Changdu | `changdu` | 长渡 | "长期摆渡"的意味,适合强调常驻服务;缺点是"长渡"作为词不常见 | +| Yinqiao | `yinqiao` | 引桥 | 从匝道引上主桥的那一段——很像"从内网引到出口";亲切、口语化 | + +> 说明:中文意象类在中文用户群里记忆点最强,代价是国际展示偏窄;如果以后要面向海外,建议「英文标识 + 中文名」成对使用(例如 `dukou` / 渡口),而不是只取其一。 + +#### C. 延续 Fonu 语音(保留 Fo 音,让改名读起来像"升级"而不是"换了个软件") + +| 显示名 | 标识 | 说明 | +| --- | --- | --- | +| Fonara | `fonara` | 保留 `Fo` 音 + 自造后缀,老用户不会觉得陌生;自造词在域名/商标上的可得性通常好于现成词 | +| Fonix | `fonix` | 与 Phoenix(凤凰)谐音,正好呼应"品牌涅槃升级";注意存在 Fönix 等同名公司,需自查 | +| Fonia | `fonia` | 希腊语系词根常与"声音/传输"相关(telefonia),5 字符干净;要确认不与电信类品牌混淆 | +| Fonli | `fonli` | 最短的延续方案,`fonli-agent` / `FONLI_DATA_DIR` 都短;缺点是语义联想弱,需要靠视觉与标语补 | + +#### D. 自造短词(域名与商标可得性最好,语义靠后续塑造) + +| 显示名 | 标识 | 中文名建议 | 备注 | +| --- | --- | --- | --- | +| Nuvio | `nuvio` | 努维 | 发音圆润、无强语义包袱,适合做纯品牌名 | +| Tando | `tando` | 坦途 | 与"坦途"天然谐音——自造词的语义可由中文名反向赋义,这个配对意外地贴 | +| Kaimu | `kaimu` | 开目 | 意为"把门打开",发音柔和 | +| Runa | `runa` | 鲁纳 | 短、好拼、国际化;注意与 Runa 相关的既有品牌 | +| Sairo | `sairo` | 赛罗 | 读音有速度感,适合强调链路性能 | +| Airlock | `airlock` | 气闸 | 不算自造词,但画面感极强:内网是舱内、公网是舱外,穿透就是"过气闸";独特、好画 logo,且不与 frp/nginx/tunnel 语义重叠 | + +#### D 组补充:再给 10 个自造短词 + +**D-1 音节谐音型** —— 读音贴合一个中文词,中文用户不需要解释就记住,中文名与标识互相印证: + +| 显示名 | 标识 | 中文名 | 说明 | +| --- | --- | --- | --- | +| Kaitu | `kaitu` | 开通 | 读音近似「开通」,含义直接落在"把内网服务开通到公网";5 字符,`kaitu-agent`、`KAITU_DATA_DIR` 都短 | +| Kailu | `kailu` | 开路 | 与「开路」完全同音——在 NAT 后面开出一条路,痛点隐喻最强;代价是内蒙古开鲁县同名,检索会混入地名 | +| Toulu | `toulu` | 透路 | 「透」(穿透)+「路」(通路),两个核心动作各取一字;`toulu` 生僻,检索最干净 | +| Chuantu | `chuantu` | 穿途 | 「穿」+「途」,气质偏文学;8 字符正好卡在硬上限,`chuantu-agent` 偏长,适合只在品牌层使用、标识再截短 | + +**D-2 纯自造型** —— 无既有语义包袱,靠 logo 与标语赋义,域名与商标的可得性通常更好: + +| 显示名 | 标识 | 中文名建议 | 说明 | +| --- | --- | --- | --- | +| Kuvo | `kuvo` | 库沃 | 4 字符、K/V 音干脆,读起来就像产品名;注意先锋 DJ 有同名社区平台 KUVO,必须自查 | +| Kelo | `kelo` | 松洛 | 芬兰语 kelo 指北极圈的站桩枯松(在恶劣环境里站住的意思),冷僻、科技圈少见 | +| Veko | `veko` | 维克 | 纯自造,音形短促,适合做"快"的联想 | +| Yomo | `yomo` | 悠陌 | 发音柔和,中文名可往"悠然可达"方向塑造 | +| Tovo | `tovo` | 途沃 | 首音节与「途」谐形,自带一点通路感,便于往中文语义靠 | +| Kivo | `kivo` | 启沃 | 首音节与「启」(开启)谐形,在图标上可以做"开门"的暗示 | + +> 两条路线的取舍:**D-1 在中文本地传播最省力**(不用解释就懂),**D-2 在域名与商标上更干净**(生僻意味着少撞)。用户群以中文 NAS 玩家为主时 D-1 沟通成本最低;若之后要同时面向海外,D-2 + 一个中文名是更稳的组合。 + +#### E. 「本地 / 自家」语义(名字自带"服务跑在本地"的意思) + +**E-1 拉丁语与概念型**("就地、内部、家宅"的直译,气质克制,适合技术向调性) + +| 显示名 | 标识 | 中文名建议 | 说明 | +| --- | --- | --- | --- | +| Insitu | `insitu` | 就地 | 拉丁语 in situ = "在原位、就地"——服务不往外搬,就在它原本那台机器上运行。6 字符,冷僻,检索最干净 | +| Intra | `intra` | 内域 | 拉丁语"内部",直接指向内网/局域网;注意 Google Jigsaw 有同名的反审查 DNS 应用,需自查 | +| Domus | `domus` | 宅邸 | 拉丁语"家宅",数据与服务都收在自己的房子里;Domus 是欧美建筑媒体的常用名 | +| Localis | `localis` | 本地 | local 的拉丁语源,语义最直白;缺点是偏长(7 字符),且与英国一家同名智库撞车 | + +**E-2 跨语言的"家"**(一个字就说清"自己的地方",且大多冷僻) + +| 显示名 | 标识 | 中文名建议 | 说明 | +| --- | --- | --- | --- | +| Kodu | `kodu` | 科杜 | 爱沙尼亚语"家";4 字符、极冷僻,念起来干净利落 | +| Sumika | `sumika` | 住屋 | 日语「住処」(すみか)= 栖息之所;6 字符,画面温和,科技圈少见 | +| Jitaku | `jitaku` | 自宅 | 日语「自宅」,含义精确到"自己的房子",与 self-hosted 一一对应 | +| Uchi | `uchi` | 屋内 | 日语「内 / うち」同时是"内部"和"家";缺点是与美国连锁日料品牌 Uchi 同名 | +| Hearth | `hearth` | 炉边 | 英语"炉边",是"家"最温暖的意象;缺点是撞《炉石传说》的简称 Hearth | + +**E-3 中文拼音直觉型**(中文用户零解释成本,代价是通用词难注册) + +| 显示名 | 标识 | 中文名 | 说明 | +| --- | --- | --- | --- | +| Benji | `benji` | 本机 | 拼音「本机」完全对应,语义最准;缺点是与英文人名 Benji 撞车,检索会被淹没 | +| Bendi | `bendi` | 本地 | 最直白的一个;正因为通用,作为商品名与商标的注册难度最大 | +| Jiudi | `jiudi` | 就地 | 拼音「就地」,是 E-1 里 `insitu` 的中文同义形态,两者可以互为备选 | +| Wojia | `wojia` | 我家 | 亲切、有归属感,一句"服务就在我家跑着"把定位说完;注意与房产类品牌谐音 | +| Yuannei | `yuannei` | 院内 | "自家院子里那台机器"——NAS 用户共情很强;7 字符偏长 | +| Anwo | `anwo` | 安窝 | "给服务安个窝",读音短、意象活泼,适合偏年轻的产品调性 | + +> 两点提醒: +> 1. **`homelab` / `self-hosted` / `on-prem` 这几个词恰好描述你的产品,但它们是行业通用术语,不能当商品名**——建议放进标语(例如「为 homelab 而生」),品牌名另取。 +> 2. "本地"语义与定位最贴,也最容易撞现成词:`bendi`、`benji`、`localis` 这类直白形态被占的概率,明显高于 `insitu`、`kodu`、`sumika` 这类冷僻形态。走这条语义线时,优先在冷僻形态里挑。 + +**这条语义线上的首选三个** + +1. **`insitu` / 就地** —— 语义精确(就地运行、不外迁)、拉丁出处让名字有质感、且检索干净,是"本地服务"这层意思里最不像拼凑的一个。 +2. **`jitaku` / 自宅** —— self-hosted 的日文对应词,含义零歧义,短而冷门,写进 `<标识>-agent`、`<标识>.db` 也好看。 +3. **`kodu` / 家** —— 4 字符、跨语言都能念,留白最多,便于日后把它讲成"你自己的服务空间"。 + +#### F. 「NAS 存储 + 穿透」双关(一个名字同时指向"存着"和"能到") + +**F-1 英文双关型**(同一个词里同时有"盘/存储"和"通路") + +| 显示名 | 标识 | 中文名建议 | 说明 | +| --- | --- | --- | --- | +| Driveway | `driveway` | 盘路 | drive(盘/硬盘)+ way(通路)——字面是"车库前的车道",放在存储语境里刚好是"盘往外走的路",双关最巧;缺点是常见英文词,域名与商标被占概率高,必须先查 | +| Baylink | `baylink` | 盘链 | bay(NAS 的"盘位")+ link(连接);7 字符、自造度高,检索干净 | +| Bayway | `bayway` | 盘途 | bay + way,5 字符,短而奇;英文里有同名小地名,软件领域少见 | +| Portage | `portage` | 搬运 | 指在两条不通的水道之间把船与货搬过去,与"把 NAS 里的东西搬到公网"完全同构;**但 Gentoo 的包管理器就叫 Portage,技术圈认知已被占满,不建议** | + +**F-2 存储器物型**("存"义明确,通路那层交给标语) + +| 显示名 | 标识 | 中文名建议 | 说明 | +| --- | --- | --- | --- | +| Tansu | `tansu` | 箪笥 | 日语「箪笥」=分层抽屉的日式储物柜;冷僻、有质感,画面也很适合做图标 | +| Coffer | `coffer` | 藏柜 | 英语"保险箱",5 字符,画面是"锁着的箱子";冷僻、少撞 | +| Attic | `attic` | 阁楼 | "东西放阁楼,要用时上去取"——正是家庭 NAS 的真实使用方式,亲切易懂 | +| Larder | `larder` | 储藏室 | 家里存食物的小间,冷僻且带生活气 | +| Granary | `granary` | 粮仓 | 囤粮之所,画面感最强;7 字符偏长 | +| Hako | `hako` | 箱 | 日语「箱」,4 字符、极简,适合走冷淡克制的产品调性 | + +**F-3 中文拼音双关型**(仓/库 + 门/路/口) + +| 显示名 | 标识 | 中文名 | 说明 | +| --- | --- | --- | --- | +| Canglu | `canglu` | 仓路 | cāng lù 完全对应:「仓」是 NAS 里存的东西、「路」是穿透出去的通路——一个名字两层意思,5 字符,`canglu-agent`、`canglu.db` 都顺 | +| Cangmen | `cangmen` | 仓门 | 「把自家仓门打开」,直指"在外面访问家里的东西"这个诉求;6 字符 | +| Chuku | `chuku` | 出库 | 「出库」=把仓库里的东西送出去,动作感最强,中文用户零解释成本;4 字符 | +| Kumen | `kumen` | 库门 | 与 cangmen 同义,更短、更有古意 | +| Cangdao | `cangdao` | 仓道 | 「仓中之道」,把存储与通路并列写出来;6 字符 | +| Kufang | `kufang` | 库房 | 最直白好懂;正因为通用,商标注册难度最大 | + +**F-4 直白组合型**(NAS + 词:`nasway` / `nasport` / `nasbay`) + +含义最准确、最不用解释,但都含通用缩写 `NAS`,商标几乎不可能注册,看起来也像内部工具名——**建议只用作副标题或仓库简介(例如「nasway — 为 NAS 而生的穿透面板」),不要当品牌名**。 + +#### 这条线上的首选三个 + +1. **`driveway` / 盘路** —— 双关最巧:drive 是硬盘、way 是通路,英文用户会心一笑,中文名「盘路」也说得通;代价是常见词,必须先核域名与商标。 +2. **`canglu` / 仓路** —— 中文双关,拼音与标识完全对应,`canglu-agent`、`canglu.db`、`CANGLU_DATA_DIR` 都好念,且不与前几组任何候选重叠。 +3. **`tansu` / 箪笥** —— 想要"存储器物"的气质又要冷僻好检索时的选择;分层抽屉的柜子本身就是个不错的图标母题。 + +> ⚠️ 一个诚实的提醒:让**一个名字同时说清"NAS 里存着"和"外面能到"**,很容易变成拼装词(nasway、cangdao 这类就在这个边缘)。如果只能保住一层意思,建议保"通路",把"NAS/存储"交给标语去说——例如「让家里的 NAS 走出内网」,名字本身留出生长空间。 + +#### 建议优先考虑的组合(按这个项目的实际处境排序) + +1. **`portway` / 通途** —— 语义、标识、双语读感三项都最均衡;技术与中文用户都不需要解释。 +2. **`dukou` / 渡口** —— 中文语境里最容易被记住的一个,产品定位讲一遍就懂;国际化时用英文标识兜住。 +3. **`zhandao` / 栈道** —— 冷门、好检索、意象有情绪("在无路处开出一条路"),适合做社区故事。 +4. **`fonara`** —— 想保住 Fo 音、把这次改动讲成"品牌升级"而非"另起炉灶"时的首选。 +5. **`airlock` / 气闸** —— 想要强识别度的视觉符号(圆环阀门 + 状态灯,天然适合做状态图标)时的首选。 + +#### 已知撞名、不建议使用的名字 + +| 名字 | 为什么别用 | +| --- | --- | +| Pangolin / 穿山甲 | 是**同类开源项目本身**(`fosrl/pangolin`,见 `docs/feature-benchmark-2026-09-24.md` 的同类项目盘点),会被直接误认成它 | +| Harbor | CNCF 的镜像仓库项目,技术圈认知已被占满 | +| Warp / Warpgate / Skyway / Overpass / Lumen | 分别撞 Cloudflare WARP 与 Warp.dev、知名开源堡垒机、NTT 的 P2P 服务、OSM 查询 API、电信运营商与多个产品线 | +| Nexo / Zeta / Orbi | 本文初稿曾把它们当作"简洁短词"举例,实际分别撞加密借贷平台、多家同名公司、Netgear 的 Mesh 产品线;写进 `web/src/constants/app.ts` 之前先排除 | +| Portalis / Wayfare | 游戏/出行类同名产品较多,检索会被淹没 | + +> ⚠️ 现名 `Fonu` 与罗曼语系词 `fono`(电话)高度接近,换名正好可以顺手绕开这类"念起来像另一个词"的问题——建议把「读起来/拼起来是否与既有词重合」列入上面的硬性约束。 + +**改名后自查清单(供阶段 5 使用)**: + +``` +# 全仓(排除 node_modules / dist / .data) +rg -in "fonu" --glob '!web/node_modules/**' --glob '!**/dist/**' --glob '!.data/**' +# 分类核查 +rg -n "FONU_" # 环境变量:是否只剩新前缀 +rg -n "fonu_session|fonu-theme" # 契约/本地状态 +rg -n "fonu\\.db|/fonu-agent|fonu-probe" # 存储与远端残留 +rg -n "O96u/Fonu|/fonu:" # 外部身份与镜像 +rg -n "github.com/fonu/fonu" # module path(含 Dockerfile ldflags) +``` + +**正式定名前的四处占用自查(逐个到站内查,别指望搜索引擎)** + +| 查什么 | 怎么查 | 判定标准 | +| --- | --- | --- | +| GitHub 组织 / 仓库 | `https://github.com/search?q=<标识>&type=repositories` 与组织页直连 | 组织名被占 → 无法建同名 org;同名仓库若是活跃的同类工具 → 直接换 | +| Docker Hub | 站内搜 `<标识>`,并确认 `<你的用户名>/<标识>` 可创建 | 镜像名一旦发布,改名只能新建仓库(见 §六) | +| 域名 | `.com` / `.net` / `.io` / `.cn` / `.dev` 的 whois | 核心后缀被竞品或商标方持有 → 减分;仅冷门后缀被占可接受 | +| 商标 | 中国商标网 + 目标市场(EUIPO / USPTO),查第 9 类(软件)、第 42 类(技术服务) | 存在同类同名的在先注册 → 直接排除,不改 | + +> 本次实测:泛化的网页检索对"这个名字有没有被占用"几乎无效(只会返回无关内容),所以这四处必须站内查。另外把派生形态也念一遍——`<标识>-agent`、`<标识>.db`、`--<标识>-brand`、`_DATA_DIR`——它们是每天都要看见的东西。 + +--- + +## 八、风险与回滚 + +| 风险 | 触发条件 | 缓解 | +| --- | --- | --- | +| 老用户"数据丢失" | 只改 `DBPath()` 不做接管 | 阶段 3 的 rename + 失败回退;发布说明写明「首次启动会接管旧库」 | +| 会话密钥静默回落默认值 | 只改 env 前缀 | `FONU_SESSION_SECRET` 双读 + 启动告警;保留一个版本的旧前缀 | +| VPS agent 失联/漂移误报 | 服务端与 agent 版本错配 | 新旧路径/变量/标记双支持;`AgentVersion` 递增;安装脚本接管旧 unit | +| Docker 起来是空库 | volume 名变更 | compose 显式 `name:` + 迁移命令 + 用旧 volume 起新容器做验收 | +| 版本号显示 `dev` | 漏改 Dockerfile 的 `-ldflags` module path | 构建后必查版本接口;CI 加一条断言 | +| 客户端全部掉登录态 | cookie 改名 | 双读或接受一次重登(发布说明写明) | +| 前端版本检查失效 | GitHub 仓库改名后旧名被占/新常量未同步 | 改名后回归一次 `version.ts` 路径 | + +**回滚策略**:阶段 1~3 的回滚以「保留旧名兼容分支」为主(一个 `git revert` 即可,因为兼容层是新增而非替换);数据库重命名是不可逆动作,但方向单一(旧→新),回退版本时提供一条反向重命名说明即可。 + +--- + +## 九、结论 + +- **可以做全面品牌升级,落点是有限且已全部找清的**(见 §一,含 5 层证据)。 +- **不要全局替换**:`internal/config/config.go:50`(`fonu.db`)、`config.go:24,29,31-38`(`FONU_*`)、`internal/frp/agent_installer.go:35,36`(VPS 上 `/etc/fonu-agent/agent.env`)这三处是"改了会伤用户"的位置,必须先设计兼容再动手。 +- **建议按 §四 的五阶段推进**,阶段 1 就能拿到"看着焕然一新"的结果,阶段 3 才是真正需要谨慎的工程部分;阶段 5 用 §七 的清单收尾。 +- 本方案只做分析,未改动任何代码。 + +--- + +> 说明:本文基于 2026-09-25 的仓库状态(VERSION 1.2.12)实测盘点;外部平台(GitHub 重定向、Docker Hub 不支持改名)的结论来自当日检索,实施前建议再核实一次平台当前行为。 diff --git a/docs/cloudflared-integration-plan.md b/docs/cloudflared-integration-plan.md new file mode 100644 index 0000000..f8d295e --- /dev/null +++ b/docs/cloudflared-integration-plan.md @@ -0,0 +1,273 @@ +# Cloudflare Tunnel(cloudflared)与 Fonu 的融合方案 + +> 日期:2026-09-25 对象:Fonu 1.0.2(VERSION 1.2.12) +> 方法:先查 Cloudflare 官方文档与同类开源项目,再逐个核对本仓库现有可复用面(下文带 `文件:行` 的都是读过代码得出的)。 +> 结论速览:**值得做,但建议以「第二条出口」的形态并存,先做 locally-managed + 进程托管,不接 Cloudflare API**。详见 §五、§六。 + +--- + +## 一、为什么值得做(对 Fonu 的战略意义) + +Fonu 现在的穿透链路**必须有公网服务端**:本机 `frpc` → VPS 上的 `frps` → 域名。用户得有一台带公网 IP 的云主机,还得自己放行端口、签证书、配 DNS。 + +Cloudflare Tunnel 把「服务端」换成 Cloudflare 的边缘网络:本机 `cloudflared` **主动向外**建立到边缘的长连接(出站 only),公网侧不需要任何监听端口,也不需要公网 IP——顺带白拿 CF 的 WAF / DDoS 防护与自动 HTTPS。 + +对 Fonu 的用户画像(家庭/个人 NAS)来说,这正好补上一类人:**没有实体公网 IP、或不想为穿透长期租 VPS**的人。 + +但要说清楚它**替代不了什么**(这决定了它是「并存」而不是「替换」): + +| 场景 | frp | Cloudflare Tunnel | +| --- | --- | --- | +| 内网 HTTP(S) 网站/面板(域名访问) | ✅ | ✅ **更省事**(无需 VPS、无需放行端口) | +| 任意 TCP/UDP(游戏服、非 HTTP 协议) | ✅ | ⚠️ 需要访问端也装 `cloudflared`(`cloudflared access tcp`),不是通用 TCP 出口 | +| 客户端不需要额外软件的裸 TCP 端口 | ✅ | ❌ | +| 自持服务端 / 完全自主可控 | ✅ | ❌(流量必经 CF 边缘) | +| 大文件/视频流量 | ✅(自己带宽说话) | ⚠️ CF 条款限制非 Enterprise 的 CDN 视频/大文件用途,见 §七 | + +所以定位是:**HTTP 类公网访问走 Cloudflare 更划算,TCP 类与自主性要求高的仍走 frp**。 + +--- + +## 二、同类项目(检索结果) + +| 项目 | 做什么 | 对 Fonu 的借鉴 | +| --- | --- | --- | +| [WisdomSky/Cloudflared-web](https://github.com/WisdomSky/Cloudflared-web)(≈451★,Vue) | Docker 镜像里打包 `cloudflared` + 简单 Web UI,用来 start/stop **remotely-managed** 隧道 | **最接近的同位产品**:证明「面板管 cloudflared 进程」这个形态成立;但它的能力止步于开关,规则仍在 CF 面板里——这正是 Fonu 可以做得更好的地方(规则同源生成) | +| [adyanth/cloudflare-operator](https://github.com/adyanth/cloudflare-operator)(≈691★) | Kubernetes Operator,用 **CF API** 创建/管理 Tunnel 与 DNS | 演示了「API 驱动」的完整闭环;但需要 API Token + Account/Zone ID,是第二个凭据面(Fonu 第一轮不建议走,见 §六) | +| [qingchencloud/cftunnel](https://github.com/qingchencloud/cftunnel)(≈338★) | 轻量隧道控制 | 佐证需求存在 | +| [brenner-tobias/addon-cloudflared](https://github.com/brenner-tobias/addon-cloudflared) | Home Assistant 插件,locally-managed 与 remotely-managed 两种模式都支持 | HA 社区的做法可参考:**两种模式并存**,文档把差异讲透 | +| [cloudflare/cloudflared](https://github.com/cloudflare/cloudflared) | 官方客户端(Apache-2.0) | 我们的被托管对象;GitHub Release 提供多平台二进制 | +| 对照项:[Pangolin](https://github.com/fosrl/pangolin) / Tailscale Funnel / ngrok | 同类「免公网 IP」方案 | Tailscale Funnel 只开 HTTPS 且端口数量有限、ngrok 免费档收紧;CF Tunnel 免费档最宽松,这也是它值得优先支持的原因 | + +`docs/feature-benchmark-2026-09-24.md` 里已经把 Pangolin 列为架构最近的同类项目;Cloudflare Tunnel 的差别在于:**它把「服务端」外包给了 Cloudflare,Fonu 只需管好本机客户端**。 + +--- + +## 三、cloudflared 的两种托管模型(选型的关键) + +| | locally-managed(本地管理) | remotely-managed(远程管理) | +| --- | --- | --- | +| 凭据 | `cert.pem`(`cloudflared tunnel login` 浏览器授权)+ `.json` credentials | 一个 **token**(`eyJ...`,CF 面板生成) | +| 规则来源 | 本机 `config.yml` 的 `ingress:` | **CF 面板 / API**(本地 `config.yml` 会被**忽略**) | +| 建隧道 | `cloudflared tunnel create ` | CF 面板,或 `POST /accounts/{id}/cfd_tunnel` | +| DNS | `cloudflared tunnel route dns ` | CF 面板自动建 CNAME | +| 与 Fonu 的价值取向 | ✅ **契合**:反代规则由 Fonu 同源生成(和现在生成 `frps.toml` / nginx vhost 一个路子) | ⚠️ 冲突:规则跑到 CF 面板去了,Fonu 沦为「进程开关」 | + +官方文档确认了这个分界([配置参考](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/local-management/configuration-file)),社区也踩过坑:给 remotely-managed 隧道传 `--config` 时配置会被云端覆盖,日志里出现 `No ingress rules were defined ... cloudflared will return 503`([cloudflared#1029](https://github.com/cloudflare/cloudflared/issues/1029))。 + +**结论:第一轮只支持 locally-managed。** 若用户只想粘贴 token,提供「仅托管进程」的降级模式并**在界面上明确写清「规则由 Cloudflare 面板管理」**,不要让用户以为 Fonu 生成的规则生效了。 + +> 补充(参考 §九 后修正):这个二分法不够细。参考实现证明**token 可以解码**(base64 的 JSON,含 `AccountTag` / `TunnelID` / `TunnelSecret`),用它自己生成 `creds.json` 就能按 locally-managed 跑本地 `config.yml`;再配合「云端 ↔ 本地 ingress 互相同步」,两种模式实际上是可切换的。所以 Fonu 的正确做法不是「二选一」,而是**显式区分并同步**:当前生效的是本地 `config.yml` 还是云端 ingress,以及「推送/拉取」两个动作。 + +### 健康探测(这点比 frp 更省事) + +`cloudflared tunnel --metrics 127.0.0.1: run --config ...` 会暴露 HTTP 端点,其中 **`/ready` 在有到边缘的活跃连接时返回 200**(CF 官方在 k8s livenessProbe 里就是这么用的)。Fonu 现在为了判断「隧道通不通」要绕 frps dashboard,还专门加过 `tunnel_known` 来区分「判不出来」和「不通」;CF Tunnel 则有明确的就绪端点,可以直接喂给现有健康链路。 + +--- + +## 四、本仓库可复用的面(已核对代码) + +| 能力 | Fonu 现有实现 | 复用方式 | +| --- | --- | --- | +| 二进制下载 / 启用 | `Service.ListReleases`(`internal/frp/service.go:112`)、`ListBinaries`(`:120`)、`ActivateBinary`(`:258`);`internal/frp/runtime.go` 的 `fetchOfficialReleases` / `listBinaries` / `readActiveBinary` / `writeActiveBinary` / `versionFromBinaryPath` | cloudflared 同样发 GitHub Release,**同一套框架泛化成「可指定仓库」即可**(现在 URL 拼的是 frp 的 release 页) | +| 客户端进程托管 | `internal/frp/manager.go`:`os.OpenFile(logPath, O_CREATE\|O_APPEND\|O_WRONLY, 0600)` + `cmd.Stdout/Stderr` 重定向 + 状态/退出原因 + `tailFile` 读日志尾 | 与 `cloudflared tunnel run` 完全同构,可直接复用(日志追加、不截断的语义也一致) | +| 凭据加密与回显 | `secret.Box` + `maskedToken = "********"`(`internal/frp/service.go:23`)+ 「预览脱敏 / 下发原文」两分支约定(v1.2.12 刚修) | token / credentials JSON 存加密字段;**预览一律掩码** | +| 配置生成 | `internal/frp/frps_template.go`、`internal/proxy` 的 nginx vhost 模板 | `config.yml` 的 `ingress:` 用同样思路生成,注释与安全提示的风格照搬 | +| 健康与可用率 | `internal/monitor`(`Source` 接口 + `HealthRecorder`)、`migrations/026` 的健康事件表、`metrics_samples` | 给 `Source` 加一个 provider(frp / cloudflared),`/ready` 作为判活依据 | +| 日志界面 / 公开状态页 | 日志页各页签、`/status` 公开状态页(默认关闭 + 访问码 + 限流) | 新隧道类型直接进现有页面,不用另造一套 | +| 规则模型 | `frp.Proxy{ServerID, Type, LocalIP, LocalPort, CustomDomains, Options}`(`internal/frp/model.go:186`) | 不再需要 `ServerID` 时用一个 provider 字段代替(见方案 B) | + +--- + +## 五、三个方案 + +### 方案 A:独立模块「Cloudflare 隧道」(推荐第一轮) + +新增一个与「公网服务端(FRP)」并列的模块,**不碰现有 frp 规则模型**: + +- 管理一份 cloudflared 二进制(复用 §四 的下载/启用框架)。 +- 管理 N 个隧道实例,每个实例 = 一份 `config.yml` + credentials + 日志文件 + 子进程。 +- `ingress` 规则**由 Fonu 生成**:从本机已有的反代规则里挑「要暴露的域名」,映射到 `http://127.0.0.1:<本地端口>`,模板与 nginx vhost 同源风格;末尾固定给 catch-all `service: http_status:404`(CF 要求最后一条兜底)。 +- 健康:`--metrics` + `/ready`,写入现有健康与可用率链路;通知复用(隧道断开/恢复)。 +- 前端:与「公网服务端」同构的列表 + 详情(状态 / 日志 / 配置预览 / 下发)。 + +**优点**:改动封闭、不动现有模型、可独立上线、风险低。 +**代价**:规则要在两处维护(frp 规则 vs CF 隧道规则)——这是方案 B 要解决的。 + +### 方案 B:把「隧道出口」抽象成 provider(v2 方向) + +在规则上加 `provider: frp | cloudflare`(`frp.Proxy` 增字段,配套迁移),规则本身只说「哪个域名 → 内网哪个端口」,出口由 provider 决定: + +- 好处:一条规则可在 FRP / CF 之间切换,健康、可用率、状态页、漂移检测天然统一。 +- 成本:动模型 + 迁移 + 前端多处(规则表单、健康四段、公网反代页),且 `ServerID` 的语义要重新定义(CF 侧没有服务端)。 +- 建议:**等方案 A 稳定、用户真的需要在两种出口间迁移规则时再做**。 + +### 方案 C:只做引导(不接管进程) + +只生成 `config.yml` 与 DNS 指引,让用户自己跑 `cloudflared`。 + +**不建议**:不接管进程就没有状态、日志、健康、可用率,等于放弃 Fonu 的全部价值。 + +--- + +## 六、推荐落地路径(分阶段) + +### 阶段 1:只读与引导(最低风险,先验证需求) + +1. 新增「Cloudflare 隧道」入口:粘贴 **token**(remotely-managed)或上传 **credentials JSON + tunnel UUID**(locally-managed),凭据加密存储。 +2. 生成 `config.yml` 预览(**脱敏**,沿用 v1.2.12 的预览/下发两分支约定)并把文件写到 `/cloudflared//config.yml`。 +3. 界面给出 CF 侧操作指引(建隧道、CNAME、`cloudflared tunnel route dns`),**不接管进程**。 +4. 明确提示模式差异:locally-managed 才由 Fonu 生成规则。 + +### 阶段 2:托管进程与健康(核心价值) + +5. 把 §四 的 release/binaries 框架泛化,支持下载 `cloudflared` 并启用指定版本;进程托管复用 `Manager`(日志、状态、退出原因、`tailFile`)。 +6. `--metrics 127.0.0.1:` + `/ready` 接入 `internal/monitor`:新增 provider 判活,接入现有通知与 `metrics_samples` 可用率。 +7. 子命令支持:启动 / 停止 / 重启 / 重载配置 / 查看日志(与「公网服务端」的操作面板一致)。 + +### 阶段 3:规则同源与界面完善 + +8. 从本机反代规则推导 `ingress`(勾选要暴露的域名),生成后一键下发并重启;末尾自动补 catch-all。 +9. 前端补齐:状态卡、日志页签、配置预览(脱敏)、与 VPS 实际文件的漂移检测(沿用「配置漂移」的交互)。 +10. Access(Zero Trust)相关只做**诊断提示**(例如「此域名未启用 Access 保护」),不代配置策略。 + +### 阶段 4(可选,需单独评估) + +11. 接 Cloudflare API 自动建隧道与 DNS:需要 API Token(权限 `Account: Cloudflare Tunnel: Edit` + `Zone: DNS: Edit`)+ Account ID + Zone ID。**这是第二个凭据面**,收益是省掉几步手工操作,代价是多一份高权限凭据要保管——建议等前面稳定后再评估,并且只在用户显式开启时启用。 + +--- + +## 七、风险与边界(务必写进功能说明) + +**条款与合规** +- Tunnel 本身免费、不限条数/带宽,但 Cloudflare 的 CDN 条款限制「用 CDN 提供视频与其他大文件」(非 Enterprise 需购买 Stream/Images 等付费服务)。社区里这条措辞有过调整,但仍应视为风险:**用 CF Tunnel 做影音/大文件分发不在我们的推荐用法内**,界面上要提示。 +- TryCloudflare(快速隧道:`cloudflared tunnel --url http://localhost:PORT`)官方明确「不保证 SLA 与可用性,仅供测试与开发」,域名随机、每次重启变化——**不作为正式功能**,最多做成「临时演示」按钮。 + +**网络可用性** +- 中国大陆访问 CF 边缘的延迟与稳定性不稳定,这对 Fonu 的用户群是**现实问题**:建议在文档与界面上对比说明「CF 适合海外访问优先的场景,国内链路以 frp + 自持 VPS 更可控」。 +- 出站 only 意味着 VPS 上不需要任何放行端口,但也就**读不到**「CF 侧有哪些域名指向了这条隧道」——相关校验只能靠 `cloudflared` 自身命令,不能靠 agent。 + +**实现边界** +- **不做 remotely-managed 的规则生成**(会被云端覆盖,见 §三)。 +- **不代配 Access 策略**(只提示状态)。 +- **不改现有 frp 链路**:CF Tunnel 是并存出口;`internal/frp` 的规则、健康、状态页语义保持不变。 +- **不做通用 TCP 出口**:需要访问端装 `cloudflared` 的场景超出「NAS 面板」的定位,只做说明。 +- 凭据全部走 `secret.Box` 加密 + 掩码回显;日志与 API 响应绝不出现明文 token(沿用 `maskedToken` 与「预览脱敏」约定)。 + +--- + +## 九、参考实现分析:`cf-tunnel-manager`(用户自有项目,Gitee `li-hua-88/cf-tunnel-manager`) + +> 项目:**Tunnel-Cloudflared-Manager** v1.0.1,Rust + Actix-web + Tokio + Serde,前端原生 JS + Tailwind(`static/`),JSON 文件存储,MIT;Linux systemd(`tcm`/`cftm` 运维脚本)+ Docker 两套交付。 +> 本节结论来自逐个读到源码的模块:`src/services/{tunnel,binary,cloudflare_api}.rs`、`src/models/tunnel.rs`、`src/utils/{config_helper,paths}.rs`、`Cargo.toml`。 + +### 9.1 它的定位与能力 + +| 维度 | 它做了什么 | +| --- | --- | +| 隧道类型 | **命名隧道**(token 或 config-file 启动)+ **快速隧道**(`--url`) | +| 进程管理 | `TunnelProcessManager`:`HashMap<名称, Child>` + 独立的 `login_process` + 快速隧道 URL 缓存 | +| 日志 | `tokio::process` 逐行读 stdout/stderr → **WebSocket 广播**(`tunnel-log` 事件),前端实时滚动 | +| 指标 | 每 2 秒抓 `cloudflared` 的 Prometheus 端点 → 同样用 WebSocket 广播(`tunnel-metrics`) | +| 二进制 | `src/services/binary.rs` 自管下载/替换/检测,不要求用户预装 | +| 账号模式 | `--origincert` + `tunnel create` + `tunnel route dns`,把生成的 `creds.json` 搬进自己的 `data/tunnels/<名>/` | +| 云端同步 | `src/services/cloudflare_api.rs` 能按 tunnel **拉取/推送** ingress 配置(`fetch_tunnel_config` / `push_tunnel_config`) | +| 登录与安全 | JWT + bcrypt 管理员账号,默认 `admin`/`admin666`(首次登录强制改密) | +| 网络调优 | `protocol`(auto/http2/quic,Windows 或检测到代理时回落 http2)、`ha-connections`、`edge-ip-version`、`originRequest`(`caPool`/`noTLSVerify`/`httpHostHeader`)、代理环境变量开关 | + +### 9.2 值得直接借鉴的六个做法 + +**① token 可以解码成凭据,于是 token 模式也能本地管规则** +`initialize_token_tunnel` 把 CF 隧道 token 解成 `{AccountTag, TunnelID, TunnelSecret}` 并写出 `creds.json` + `config.yml`——即「用户只给一个 token,但规则仍由面板本地生成」。这直接提升了 Fonu 方案的上限(§三 的补充已按此修正)。 + +**② metrics 端口不去猜,从日志里读出来** +命名隧道启动时**故意不传 `--metrics`**,让 cloudflared 用默认端口段(20241-20245),然后从 stderr 解析 `INF starting metrics server on 127.0.0.1:65432/metrics` 拿到真实端口——天然避开端口冲突(Fonu 现在给 frps dashboard 固定端口,多实例时会撞)。 + +**③ `localhost` 必须归一化成 `127.0.0.1`** +`add_tunnel_endpoint` 里把 `localhost` 换成 `127.0.0.1`,注释写明「避免 Cloudflare 将其转换为本地网络 IP」。这是 CF 侧的真实坑,Fonu 生成 `ingress` 时要照做。 + +**④ 始终保留最后一条 catch-all** +新增端点插在「数组倒数第一位之前」(`insert_pos = len - 1`),末条永远是 `service: http_status:404`。Fonu 改 `ingress` 时必须沿用这个不变式,否则配置校验不过。 + +**⑤ 带宽指标:官方 metrics 没有 HTTP/2 字节数,它做了系统级兜底** +- QUIC 下直接读 `quic_client_receive_bytes` / `quic_client_sent_bytes` / `quic_client_smoothed_rtt`。 +- HTTP/2 下官方无字节数,改用**按进程采 TCP 连接字节**:Linux 走 netlink `SOCK_DIAG_BY_FAMILY` + `/proc//fd` 取 socket inode;Windows 走 `GetExtendedTcpTable` + `GetPerTcpConnectionEStats`(需要管理员,失败时给 `windows_estats_requires_admin` 降级原因)。 +- 并且**只统计公网远端 IP**(过滤私有/回环/链路本地/`100.64/10` CGNAT),延迟兜底用**主动 HEAD 探测** ingress 里的公网 hostname(带 `?__cf_probe=` + no-cache)。 +> 对 Fonu 的意义:这一块可以**先不做**(我们已有规则级流量与可用率),但「降级要给出明确原因而不是空白」这个原则要学。 + +**⑥ Windows 下起子进程要隐藏控制台窗口** +`creation_flags(CREATE_NO_WINDOW)`(`0x08000000`)。Fonu 本机在 Windows 上跑,托管 `cloudflared.exe` 时必须带上,否则会闪黑窗。 + +### 9.3 与 Fonu 的差异(决定「借鉴什么、不借鉴什么」) + +| 维度 | cf-tunnel-manager | Fonu 该怎么做 | +| --- | --- | --- | +| 职责 | **专做** Cloudflare Tunnel 的一个独立面板 | 把 CF 作为**第二条出口**并入现有规则/健康/通知/状态页体系 | +| 存储 | JSON 文件(`data/*.json`)+ `data/tunnels/<名>/` | 继续用 SQLite + 迁移;凭据进 `secret.Box` 加密字段 | +| 规则来源 | 面板自己的 ingress 列表(+ 云端同步) | 从**本机已有反代规则**推导(同源生成),避免第二份手工维护 | +| 健康 | metrics 端点 scraping + 主动探测(仅前端展示) | 接入 `internal/monitor` 的 `Source`/`HealthRecorder`,落库可用率 + 复用告警与通知 | +| 实时日志 | WebSocket 广播(专为「看着它滚」设计) | 沿用现有「读日志文件 + 日志页」模型(与 frpc 一致,无需引入 WS) | +| 多实例 | 多隧道并行(每隧道一个子进程) | 同模;但用 SQLite 建 `tunnels` 表而不是内存 HashMap | +| 认证 | 自带 JWT + 账号体系 | 复用 Fonu 现有会话 + API Token | + +### 9.4 对方案本体的修订建议 + +1. **方案 A 的第一阶段就加「凭据导入」两条路**:`token`(解码出 creds.json)与 `cert.pem + credentials JSON`(账号模式),二者最终都落到「本地 `config.yml` 由 Fonu 生成」。 +2. **阶段 2 把「metrics 端口从日志发现」写成实现约定**,不要固定端口。 +3. **阶段 3 增加「云端 ↔ 本地同步」两个动作**(拉取 / 推送),并在界面上标明当前生效侧——这是参考项目比原方案更完整的地方。 +4. **`ingress` 生成器锁定三条不变式**:末条 catch-all、`localhost` → `127.0.0.1`、插入位置在 catch-all 之前;并补一个「生成后自检」的纯函数测试(参照本仓 `maskFRPSSecrets` 的测试写法)。 + +--- + +## 十、IP 优选:能不能做、能做到哪一步 + +### 结论一句话 + +**隧道侧(本机 `cloudflared` → CF 边缘)可以优选,访问侧(浏览器 → CF 边缘)不行。** 而国内用户最在意的那一段延迟往往在访问侧——所以不要把它宣传成「提速神器」,它的收益是**可控但有限**的。 + +### 隧道侧:三条可用手段(已核对官方文档与变更日志) + +| 手段 | 具体做法 | 适用与代价 | +| --- | --- | --- | +| **选协议** | `--protocol quic` / `http2` | 国内 QUIC(UDP 7844)常被 QoS 掉,`http2`(TCP 7844)反而稳;这是**成本最低、收益最直接**的一项 | +| **选地址族** | `--edge-ip-version 4` / `6` / `auto`,或环境变量 `TUNNEL_EDGE_IP_VERSION` | ⚠️ **2026.4.0 起默认值由 `4` 改成 `auto`**(官方 CHANGES 里的 Breaking Change)——`auto` 会跟着系统解析器走,国内环境若 IPv6 路由差就可能反而变慢。**建议 Fonu 显式下发 `--edge-ip-version 4`**(除非用户自己有 IPv6 好线路) | +| **优选边缘 IP** | 对 `region1.v2.argotunnel.com` / `region2.v2.argotunnel.com` 做延迟测速,选中最快的官方边缘 IP,写 hosts 覆盖解析 | 官方文档给出固定端点与端口:这类域名指向 CF 的 IPv4 段(`198.41.192.7`~`198.41.192.127`、`162.159.234.1`~`162.159.234.10`)与 **端口 7844(TCP/UDP)**。这是社区「Tunnel 优选 IP」的通行做法 | + +**优选时两条不能错的约束**: + +1. **两个 region 都要覆盖**。官方预检文档要求健康部署有**四个活跃 replica、同时连上 `region1` 与 `region2`**(或对应地区域名)——只改其中一个域名,另一半连接会跑到未优选(乃至不通)的路径上,净效果可能是变差。 +2. **IP 列表不能硬编码**。CF 改过端点与 hostname(例:`proxy-dns` 整条功能已在 2026.2.0 被移除),优选表要么给默认值 + 可配置,要么每次从官方 DNS 拉候选集再测。 + +关于实例数:`--ha-connections` 决定每个 region 开几条连接,也是「多路择优」的天然手段(参考实现 §9.1 已把它做成设置项)。 + +### 访问侧:Tunnel 模式下做不到 + +用 CF Tunnel 时,公域域名必须 CNAME 到 `.cfargotunnel.com` 并且**开启代理(橙云)**——解析结果由 CF 决定,**用户无法自选边缘 IP**。社区那套「测速 + 自选 IP」之所以常见,是因为他们用的是「CNAME 到优选 IP + 关闭橙云 + 自己回源」的方案;那**恰好是 Tunnel 不能走的路**(关掉代理就没有 Tunnel 了)。 + +也就是说:换域名解析 / 让访客改 hosts 都不现实,**访问侧这一段在 Tunnel 架构下就是交给 CF 的**。 + +### 对 Fonu 方案的落点 + +| 阶段 | 做什么 | 依据 | +| --- | --- | --- | +| 阶段 2(建议直接做) | 「网络调优」三项做成设置项:`protocol`(默认 http2)、`edge-ip-version`(**默认 4**)、`ha-connections`;以命令行参数下发 | 参考实现已验证可行,且改一项就能治国内 QUIC 被打压的场景 | +| 阶段 3(可选) | 「边缘优选」工具:并发探测官方 IPv4 段的 **TCP 7844 握手延迟** → 生成「两个 region 各 1~2 个优选 IP」的 hosts 建议;**页面展示而不是代写 hosts**(Windows 改 hosts 需管理员,Fonu 不应越权),并提供「复制这段内容」+ 生效检测 | 沿用本仓「不越权、降级要说明原因」的口径 | +| 阶段 3(可选) | 优选前后对比:用**已有的** `metrics_samples` 延迟/可用率序列展示效果,而不是另造一套测速图 | 复用 §四 的健康与指标链路 | + +### 必须写进功能说明的三句实话 + +1. 优选只影响「本机 → CF 边缘」这一段;访客到自己那侧 CF 边缘的那段,Fonu 与用户都控制不了。 +2. 所以它**不是**「翻墙提速」类工具:国内访问 CF 的稳定性受链路与运营商影响,优选只能把「本机出口到边缘」这一段挑好。 +3. 官方端点会变(见上),因此把所有 IP/域名做成**可配置 + 有默认值 + 可一键恢复默认**。 + +--- + +## 十一、给用户的一句话结论(可直接对外) + +> Fonu 现在支持两类公网出口:**frp**(自持 VPS,任意 TCP/UDP,完全自主)与 **Cloudflare 隧道**(无需公网 IP 与 VPS,HTTP 类最省事,自带 CF 防护)。 +> 要裸 TCP 端口、要完全自主,用 frp;要让内网 Web 服务零端口暴露地上公网,用 Cloudflare 隧道。 +> Cloudflare 隧道还提供网络调优(协议 / 地址族 / 连接数)与**边缘 IP 优选**(仅作用于本机到 CF 边缘这一段,访客侧无法自选)。 + +--- + +> 说明:本文只做方案分析,未改动任何代码。实施时沿用本仓库既有约定:每步验证 `go build/vet/test` + `vue-tsc/vite build`、agent 行为变更递增 `AgentVersion`、提交同批更新 `CHANGELOG` 与 `VERSION`、密钥相关内容必须过一遍「预览脱敏 / 下发原文」两头检查。 diff --git a/internal/acme/ca.go b/internal/acme/ca.go index 4388f2b..11ee273 100644 --- a/internal/acme/ca.go +++ b/internal/acme/ca.go @@ -14,6 +14,18 @@ const ( CABuypass = "buypass" ) +// caRetired 记录已停止签发的 CA:仍然认识这些值(历史证书记录里可能存着,用于展示与清理), +// 但不允许再用来申请——否则用户只会看到一个难懂的 ACME 报错而不是「这家 CA 不干了」。 +// 否则用户只会看到一个难懂的 ACME 报错。 +var caRetired = map[string]string{ + CABuypass: "Buypass 已于 2025-10-15 停止签发证书(ACME 服务 2026-04-15 终止),请改用 Let's Encrypt 或 ZeroSSL", +} + +// CAUnavailableReason 返回该 CA 已停用的原因;可用的 CA 返回空字符串。 +func CAUnavailableReason(ca string) string { + return caRetired[NormalizeCA(ca)] +} + const ( zeroSSLDirectoryURL = "https://acme.zerossl.com/v2/DV90" buypassDirectoryURL = "https://api.buypass.com/acme/directory" @@ -86,4 +98,3 @@ func ValidateCADomains(ca string, domains []string) error { } return nil } - diff --git a/internal/acme/resolvers_test.go b/internal/acme/resolvers_test.go new file mode 100644 index 0000000..0f2550c --- /dev/null +++ b/internal/acme/resolvers_test.go @@ -0,0 +1,46 @@ +package acme + +import "testing" + +func TestParseResolverList(t *testing.T) { + cases := []struct { + name string + raw string + want []string + }{ + {"带端口", "223.5.5.5:53, 1.1.1.1:53", []string{"223.5.5.5:53", "1.1.1.1:53"}}, + {"缺端口补 53", "8.8.8.8\n9.9.9.9", []string{"8.8.8.8:53", "9.9.9.9:53"}}, + {"分号与空格", "223.5.5.5;8.8.4.4 1.0.0.1", []string{"223.5.5.5:53", "8.8.4.4:53", "1.0.0.1:53"}}, + {"空值", " ", nil}, + {"只留空白项", " , ; \n", nil}, + } + for _, item := range cases { + got := parseResolverList(item.raw) + if len(got) != len(item.want) { + t.Fatalf("%s: 期望 %v,实际 %v", item.name, item.want, got) + } + for i := range got { + if got[i] != item.want[i] { + t.Fatalf("%s: 期望 %v,实际 %v", item.name, item.want, got) + } + } + } +} + +func TestParseResolverListCapsEntries(t *testing.T) { + raw := "1.1.1.1,1.1.1.2,1.1.1.3,1.1.1.4,1.1.1.5,1.1.1.6,1.1.1.7,1.1.1.8,1.1.1.9,1.1.1.10" + if got := parseResolverList(raw); len(got) != 8 { + t.Fatalf("最多取前 8 条,实际 %d:%v", len(got), got) + } +} + +// 内置的默认值必须是 lego 能直接用的 host:port 形式 +func TestDefaultResolversAreUsable(t *testing.T) { + got := parseResolverList(defaultACMEDNSResolvers) + if len(got) == 0 { + t.Fatalf("默认解析器解析为空:%q", defaultACMEDNSResolvers) + } + if got[0] != "223.5.5.5:53" { + t.Fatalf("默认解析器首项应带端口,实际 %q", got[0]) + } +} diff --git a/internal/acme/service.go b/internal/acme/service.go index 97dbd9f..bf3f3fe 100644 --- a/internal/acme/service.go +++ b/internal/acme/service.go @@ -20,6 +20,7 @@ import ( "github.com/go-acme/lego/v4/certcrypto" legocert "github.com/go-acme/lego/v4/certificate" + "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/lego" "github.com/go-acme/lego/v4/registration" @@ -47,12 +48,12 @@ type Service struct { func NewService(cfg config.Config, store *certstore.Store, ddnsSvc *ddns.Service, settings *settings.Store, proxySvc *service.ProxyService, logger *slog.Logger, notifySvc *notify.Service) *Service { return &Service{ - cfg: cfg, - store: store, - ddnsSvc: ddnsSvc, - settings: settings, - proxySvc: proxySvc, - logger: logger.With("module", "ACME"), + cfg: cfg, + store: store, + ddnsSvc: ddnsSvc, + settings: settings, + proxySvc: proxySvc, + logger: logger.With("module", "ACME"), notify: notifySvc, jobs: NewJobManager(), expiryAlerted: map[string]string{}, @@ -388,6 +389,9 @@ func (s *Service) resolveCA(ctx context.Context, ca string) (string, error) { if err := ValidateCA(ca); err != nil { return "", err } + if reason := CAUnavailableReason(ca); reason != "" { + return "", fmt.Errorf("%s", reason) + } return ca, nil } @@ -446,7 +450,19 @@ func (s *Service) obtain(ctx context.Context, job *Job, ca, email, provider stri return certstore.Record{}, fmt.Errorf("初始化 DNS Provider 失败:%w", err) } dnsProvider = wrapDNSProvider(dnsProvider, job) - if err := client.Challenge.SetDNS01Provider(dnsProvider); err != nil { + // DNS-01 的传播检查由 lego 自己做,它默认读系统 /etc/resolv.conf; + // 容器里那通常是 Docker 的 127.0.0.11,它不返回权威 NS,于是报 + // 「[zone=example.com.] could not determine authoritative nameservers」。 + // 所以显式指定可靠递归解析器(设置项可改;设为 system 则显式回到系统解析器)。 + resolvers, err := s.acmeDNSResolvers(ctx) + if err != nil { + return certstore.Record{}, err + } + var challengeOpts []dns01.ChallengeOption + if len(resolvers) > 0 { + challengeOpts = append(challengeOpts, dns01.AddRecursiveNameservers(resolvers)) + } + if err := client.Challenge.SetDNS01Provider(dnsProvider, challengeOpts...); err != nil { return certstore.Record{}, err } @@ -505,6 +521,49 @@ func (s *Service) resolveACMEEmail(ctx context.Context, email string) (string, e return email, nil } +// defaultACMEDNSResolvers 默认递归解析器(阿里/Cloudflare/Google)。 +// 容器里的系统解析器常只指向 127.0.0.11,无法用于 DNS-01 的权威 NS 查询; +// 设置项写 system 则回到系统解析器,写其它值则按逗号/空格分隔逐条使用。 +const defaultACMEDNSResolvers = "223.5.5.5:53,1.1.1.1:53,8.8.8.8:53" + +// acmeDNSResolvers 返回本次申请要用的解析器;空 slice 表示交给 lego 读系统解析器。 +func (s *Service) acmeDNSResolvers(ctx context.Context) ([]string, error) { + raw, err := s.settings.Get(ctx, settings.KeyACMEDNSResolvers) + if err != nil || strings.TrimSpace(raw) == "" { + raw = defaultACMEDNSResolvers + } + if strings.EqualFold(strings.TrimSpace(raw), "system") { + return nil, nil + } + resolvers := parseResolverList(raw) + if len(resolvers) == 0 { + return nil, fmt.Errorf("DNS 解析器配置无效(%q):请填 host 或 host:port,多个用逗号分隔;留空用默认值,填 system 用系统解析器", raw) + } + return resolvers, nil +} + +// parseResolverList 解析解析器列表:逗号/空格/换行分隔,未写端口时补 53,最多取前 8 个。 +func parseResolverList(raw string) []string { + fields := strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == ';' + }) + out := make([]string, 0, len(fields)) + for _, item := range fields { + item = strings.TrimSpace(item) + if item == "" { + continue + } + if !strings.Contains(item, ":") { + item += ":53" + } + out = append(out, item) + if len(out) >= 8 { + break + } + } + return out +} + func validateACMEEmail(email string) error { addr, err := mail.ParseAddress(email) if err != nil || addr.Address != email { diff --git a/internal/acme/zerossl.go b/internal/acme/zerossl.go index 979f219..4311818 100644 --- a/internal/acme/zerossl.go +++ b/internal/acme/zerossl.go @@ -3,6 +3,7 @@ package acme import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -21,13 +22,16 @@ type zeroSSLEABResponse struct { } `json:"error"` } +// zeroSSLEABEndpoint 单独成变量,测试可以替换成本地 httptest 服务。 +var zeroSSLEABEndpoint = "https://api.zerossl.com/acme/eab-credentials" + func zerosslEABCredentials(ctx context.Context, apiKey string) (kid, hmac string, err error) { apiKey = strings.TrimSpace(apiKey) if apiKey == "" { return "", "", fmt.Errorf("请在设置中配置 ZeroSSL API Key") } - endpoint := "https://api.zerossl.com/acme/eab-credentials?access_key=" + url.QueryEscape(apiKey) + endpoint := zeroSSLEABEndpoint + "?access_key=" + url.QueryEscape(apiKey) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return "", "", err @@ -36,7 +40,9 @@ func zerosslEABCredentials(ctx context.Context, apiKey string) (kid, hmac string client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { - return "", "", fmt.Errorf("请求 ZeroSSL API 失败:%w", err) + // 不能把 url.Error 整个包进错误:它的字符串里是带 access_key 的完整 URL, + // 而这条错误会进任务日志与 <数据目录>/logs/app.log,等于把密钥写进日志。 + return "", "", fmt.Errorf("请求 ZeroSSL API 失败:%s", redactURLError(err)) } defer resp.Body.Close() @@ -44,19 +50,45 @@ func zerosslEABCredentials(ctx context.Context, apiKey string) (kid, hmac string if err != nil { return "", "", err } - if resp.StatusCode != http.StatusOK { - return "", "", fmt.Errorf("ZeroSSL API 返回 %d", resp.StatusCode) - } - var payload zeroSSLEABResponse - if err := json.Unmarshal(body, &payload); err != nil { - return "", "", fmt.Errorf("解析 ZeroSSL 响应失败:%w", err) + parseErr := json.Unmarshal(body, &payload) + if resp.StatusCode != http.StatusOK { + // 422 基本都是 access_key 无效(也可能是账号/权限问题)。ZeroSSL 会在 + // error.type 与 error.code 里写清原因,所以带上它,而不是只回状态码让用户猜。 + if parseErr == nil && payload.Error.Type != "" { + return "", "", fmt.Errorf("ZeroSSL API 返回 %d:%s(错误码 %d)", resp.StatusCode, payload.Error.Type, payload.Error.Code) + } + return "", "", fmt.Errorf("ZeroSSL API 返回 %d:%s", resp.StatusCode, snippet(body)) + } + if parseErr != nil { + return "", "", fmt.Errorf("解析 ZeroSSL 响应失败:%w", parseErr) } if payload.Success != 1 || payload.EABKid == "" || payload.EABHmacKey == "" { if payload.Error.Type != "" { - return "", "", fmt.Errorf("ZeroSSL API 错误:%s", payload.Error.Type) + return "", "", fmt.Errorf("ZeroSSL API 错误:%s(错误码 %d)", payload.Error.Type, payload.Error.Code) } return "", "", fmt.Errorf("ZeroSSL API 未返回有效的 EAB 凭据") } return payload.EABKid, payload.EABHmacKey, nil } + +// redactURLError 只保留底层错误原因,避免把带 access_key 的 URL 写进日志。 +func redactURLError(err error) string { + var urlErr *url.Error + if errors.As(err, &urlErr) && urlErr.Err != nil { + return urlErr.Err.Error() + } + return err.Error() +} + +// snippet 压平并截断响应正文:既不把整页 HTML 灌进日志,也不让换行破坏排版。 +func snippet(body []byte) string { + text := strings.Join(strings.Fields(string(body)), " ") + if text == "" { + return "(响应体为空)" + } + if len(text) > 200 { + text = text[:200] + "…" + } + return text +} diff --git a/internal/acme/zerossl_test.go b/internal/acme/zerossl_test.go new file mode 100644 index 0000000..39ed50c --- /dev/null +++ b/internal/acme/zerossl_test.go @@ -0,0 +1,86 @@ +package acme + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// withEndpoint 把 ZeroSSL 端点指向本地测试服务(用完恢复)。 +func withEndpoint(t *testing.T, handler http.HandlerFunc) { + t.Helper() + server := httptest.NewServer(handler) + previous := zeroSSLEABEndpoint + zeroSSLEABEndpoint = server.URL + t.Cleanup(func() { + zeroSSLEABEndpoint = previous + server.Close() + }) +} + +func TestZeroSSLEABCredentialsSuccess(t *testing.T) { + withEndpoint(t, func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("access_key"); got != "good-key" { + t.Fatalf("access_key 应原样传出,实际 %q", got) + } + _, _ = w.Write([]byte(`{"success":1,"eab_kid":"kid-1","eab_hmac_key":"hmac-1"}`)) + }) + + kid, hmac, err := zerosslEABCredentials(context.Background(), "good-key") + if err != nil { + t.Fatalf("不应报错:%v", err) + } + if kid != "kid-1" || hmac != "hmac-1" { + t.Fatalf("凭据解析错误:kid=%q hmac=%q", kid, hmac) + } +} + +// 422 时要把 ZeroSSL 给的错误类型带出来,而不是只回一个状态码让用户猜 +func TestZeroSSLEABCredentialsSurfacesErrorType(t *testing.T) { + withEndpoint(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"success":false,"error":{"code":2902,"type":"invalid_api_key"}}`)) + }) + + _, _, err := zerosslEABCredentials(context.Background(), "bad-key") + if err == nil { + t.Fatal("期望报错") + } + if !strings.Contains(err.Error(), "422") || !strings.Contains(err.Error(), "invalid_api_key") { + t.Fatalf("错误里应含状态码与错误类型,实际:%v", err) + } +} + +// 非 JSON 的失败响应:退回截断正文,便于定位(例如网关返回的 HTML) +func TestZeroSSLEABCredentialsFallsBackToBodySnippet(t *testing.T) { + withEndpoint(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte("\n 502 Bad Gateway\n")) + }) + + _, _, err := zerosslEABCredentials(context.Background(), "any") + if err == nil || !strings.Contains(err.Error(), "502 Bad Gateway") { + t.Fatalf("应带上响应正文摘要,实际:%v", err) + } +} + +// 网络错误必须脱敏:url.Error 的字符串里带 access_key,泄漏进日志等于泄密 +func TestZeroSSLEABCredentialsDoesNotLeakKey(t *testing.T) { + const key = "SUPERSECRETACCESSKEY" + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + server.Close() // 立刻关掉,制造连接失败 + + previous := zeroSSLEABEndpoint + zeroSSLEABEndpoint = server.URL + t.Cleanup(func() { zeroSSLEABEndpoint = previous }) + + _, _, err := zerosslEABCredentials(context.Background(), key) + if err == nil { + t.Fatal("期望请求失败") + } + if strings.Contains(err.Error(), key) { + t.Fatalf("错误信息里泄露了 API Key:%v", err) + } +} diff --git a/internal/api/cert_handler.go b/internal/api/cert_handler.go index cc9d8f7..6457972 100644 --- a/internal/api/cert_handler.go +++ b/internal/api/cert_handler.go @@ -30,7 +30,8 @@ func (h *CertHandler) Options(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, []map[string]string{ {"value": acme.CALetsEncrypt, "label": acme.CALabel(acme.CALetsEncrypt)}, {"value": acme.CAZeroSSL, "label": acme.CALabel(acme.CAZeroSSL)}, - {"value": acme.CABuypass, "label": acme.CALabel(acme.CABuypass)}, + // Buypass 已于 2025-10-15 停止签发、2026-04-15 终止 ACME 服务, + // 不再列为可选 CA(历史记录仍能展示,直接提交会被 CAUnavailableReason 抦下)。 {"value": acme.CALetsEncryptStaging, "label": acme.CALabel(acme.CALetsEncryptStaging)}, }) } diff --git a/internal/api/settings_handler.go b/internal/api/settings_handler.go index e0f1c0c..1928a4b 100644 --- a/internal/api/settings_handler.go +++ b/internal/api/settings_handler.go @@ -54,6 +54,7 @@ func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) { settings.KeyACMEEmail: true, settings.KeyACMECA: true, settings.KeyZeroSSLAPIKey: true, + settings.KeyACMEDNSResolvers: true, settings.KeyNotifyWebhookURL: true, settings.KeyNotifyType: true, settings.KeyNotifyEmailJSON: true, diff --git a/internal/settings/settings.go b/internal/settings/settings.go index ff0018c..82c174f 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -8,15 +8,17 @@ import ( ) const ( - KeyTheme = "theme" - KeyTimezone = "timezone" - KeyLogRetentionDays = "log_retention_days" - KeyDDNSCheckInterval = "ddns_check_interval_minutes" - KeyCertRenewThreshold = "cert_renew_threshold_days" - KeyRootDomain = "root_domain" - KeyACMEEmail = "acme_email" - KeyACMECA = "acme_ca" - KeyZeroSSLAPIKey = "zerossl_api_key" + KeyTheme = "theme" + KeyTimezone = "timezone" + KeyLogRetentionDays = "log_retention_days" + KeyDDNSCheckInterval = "ddns_check_interval_minutes" + KeyCertRenewThreshold = "cert_renew_threshold_days" + KeyRootDomain = "root_domain" + KeyACMEEmail = "acme_email" + KeyACMECA = "acme_ca" + KeyZeroSSLAPIKey = "zerossl_api_key" + // KeyACMEDNSResolvers DNS-01 传播检查用的递归解析器(逗号分隔;system = 用系统解析器) + KeyACMEDNSResolvers = "acme_dns_resolvers" KeyNotifyWebhookURL = "notify_webhook_url" KeyNotifyType = "notify_type" KeyNotifyEmailJSON = "notify_email_json" diff --git a/scripts/build-linux.ps1 b/scripts/build-linux.ps1 new file mode 100644 index 0000000..d334757 --- /dev/null +++ b/scripts/build-linux.ps1 @@ -0,0 +1,111 @@ +<# +.SYNOPSIS + 构建 Fonu 的 Linux 安装包(tar.gz:二进制 + migrations + systemd 安装脚本)。 + +.DESCRIPTION + 与 Dockerfile 同源的三步: + 1) 前端构建(VITE_APP_VERSION 取根目录 VERSION,dist 会被 //go:embed 进二进制) + 2) 交叉编译 fonu-agent 双架构到 internal/frp/agentbin(主程序构建时内嵌) + 3) CGO_ENABLED=0 交叉编译主程序并打包 + 依赖是纯 Go 的(modernc.org/sqlite),所以 Windows 上就能产出 Linux 包,不需要 Docker/WSL。 + +.EXAMPLE + ./scripts/build-linux.ps1 # 双架构 + ./scripts/build-linux.ps1 -Arch amd64 # 只出 amd64 + ./scripts/build-linux.ps1 -SkipWeb # 前端已构建过,跳过 npm +#> +param( + [ValidateSet('amd64', 'arm64', 'both')] [string]$Arch = 'both', + [string]$OutDir = 'dist', + [switch]$SkipWeb, + [switch]$SkipAgent +) + +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +Set-Location $root + +$version = (Get-Content (Join-Path $root 'VERSION') -Raw).Trim() +if (-not $version) { throw 'VERSION 文件为空,无法确定版本号' } +Write-Host "==> Fonu $version" -ForegroundColor Cyan + +# ---- 1) 前端:dist 缺失会让主程序编译失败(//go:embed all:web/dist) +if (-not $SkipWeb) { + Push-Location (Join-Path $root 'web') + try { + if (-not (Test-Path 'node_modules')) { + Write-Host '==> npm ci(首次构建,需要网络)' + npm ci + if ($LASTEXITCODE -ne 0) { throw "npm ci 失败(exit $LASTEXITCODE)" } + } + $env:VITE_APP_VERSION = $version + npm run build + if ($LASTEXITCODE -ne 0) { throw "前端构建失败(exit $LASTEXITCODE)" } + } finally { + Pop-Location + Remove-Item Env:VITE_APP_VERSION -ErrorAction SilentlyContinue + } +} +if (-not (Test-Path (Join-Path $root 'cmd/fonu/web/dist/index.html'))) { + throw '缺少 cmd/fonu/web/dist/index.html:请先构建前端(或去掉 -SkipWeb)' +} + +# ---- 2) fonu-agent 双架构:主程序会内嵌这两个文件,必须在构建主程序前就位 +if (-not $SkipAgent) { + Write-Host '==> 交叉编译 fonu-agent(amd64/arm64)' + $env:CGO_ENABLED = '0' + $env:GOOS = 'linux' + foreach ($a in @('amd64', 'arm64')) { + $env:GOARCH = $a + go build -trimpath -o "internal/frp/agentbin/fonu-agent-linux-$a" ./cmd/fonu-agent + if ($LASTEXITCODE -ne 0) { throw "fonu-agent($a) 构建失败(exit $LASTEXITCODE)" } + } +} + +# ---- 3) 主程序 + 打包 +$archs = if ($Arch -eq 'both') { @('amd64', 'arm64') } else { @($Arch) } +$outPath = Join-Path $root $OutDir +New-Item -ItemType Directory -Force -Path $outPath | Out-Null + +try { + foreach ($a in $archs) { + $name = "fonu-$version-linux-$a" + $stage = Join-Path $outPath $name + if (Test-Path $stage) { Remove-Item -Recurse -Force $stage } + New-Item -ItemType Directory -Force -Path (Join-Path $stage 'migrations') | Out-Null + + Write-Host "==> 交叉编译 fonu(linux/$a)" + $env:CGO_ENABLED = '0' + $env:GOOS = 'linux' + $env:GOARCH = $a + go build -trimpath ` + -ldflags "-s -w -X github.com/fonu/fonu/internal/version.Version=$version" ` + -o (Join-Path $stage 'fonu') ./cmd/fonu + if ($LASTEXITCODE -ne 0) { throw "fonu($a) 构建失败(exit $LASTEXITCODE)" } + + # 包内容与 Dockerfile 的运行时 stage 对齐:migrations 必须与二进制同级(程序按工作目录找) + Copy-Item (Join-Path $root 'migrations/*.sql') (Join-Path $stage 'migrations') -Force + Copy-Item (Join-Path $root 'scripts/fonu-install.sh') (Join-Path $stage 'install.sh') -Force + Copy-Item (Join-Path $root 'VERSION') $stage -Force + Copy-Item (Join-Path $root 'LICENSE') $stage -Force + if (Test-Path (Join-Path $root 'web/assets/image')) { + New-Item -ItemType Directory -Force -Path (Join-Path $stage 'web/assets') | Out-Null + Copy-Item -Recurse -Force (Join-Path $root 'web/assets/image') (Join-Path $stage 'web/assets') + } + + $tarball = Join-Path $outPath "$name.tar.gz" + if (Test-Path $tarball) { Remove-Item -Force $tarball } + tar -czf $tarball -C $outPath $name + if ($LASTEXITCODE -ne 0) { throw "打包失败(exit $LASTEXITCODE)" } + + $hash = (Get-FileHash $tarball -Algorithm SHA256).Hash.ToLower() + "$hash $name.tar.gz" | Out-File -Encoding ascii "$tarball.sha256" + $sizeMB = [math]::Round((Get-Item $tarball).Length / 1MB, 2) + Write-Host (" {0}.tar.gz {1} MB sha256 {2}" -f $name, $sizeMB, $hash.Substring(0, 16)) -ForegroundColor Green + } +} finally { + # 交叉编译用的环境变量必须复位,否则后续 go vet/test 会继续按 linux 目标跑 + Remove-Item Env:GOOS, Env:GOARCH, Env:CGO_ENABLED -ErrorAction SilentlyContinue +} + +Write-Host "==> 产物在 $outPath" -ForegroundColor Cyan diff --git a/scripts/fonu-install.sh b/scripts/fonu-install.sh new file mode 100644 index 0000000..a05f96d --- /dev/null +++ b/scripts/fonu-install.sh @@ -0,0 +1,229 @@ +#!/usr/bin/env bash +# Fonu Linux 安装脚本(随 tar.gz 一起分发) +# +# sudo bash ./install.sh 安装并启动 +# sudo bash ./install.sh --port 8080 换监听端口 +# sudo bash ./install.sh --uninstall 卸载(保留数据目录) +# sudo bash ./install.sh --uninstall --purge 卸载并删除数据目录 +# +# 用 `bash ./install.sh` 而不是 `./install.sh`:Windows 上打出来的 tar 不带执行位 +# (本机 tar 不支持 --mode,待装本时无法保留 +x),显式调 bash 就不需要先 chmod。 +# +# 关于初始口令:Fonu 首次启动会随机生成一个 16 位管理员口令(用户名 admin), +# 打进攻日志与日志文件(<数据目录>/logs/app.log),本脚本装完会直接把它打印出来。 +# 忘了 / 想重新初始化:sudo bash ./install.sh --reset-admin +# +# 安装布局(与 Docker 版同一套环境变量,见 Dockerfile 的 ENV 块): +# /opt/fonu/fonu 主程序 +# /opt/fonu/migrations/*.sql 迁移脚本(程序按工作目录找它,必须与二进制同级) +# /var/lib/fonu/ 数据目录(库、日志、证书、nginx 与 frp 的运行数据) +# /etc/fonu/fonu.env 环境变量(含随机生成的会话密钥,0600) +# /etc/systemd/system/fonu.service +set -euo pipefail + +PREFIX=/opt/fonu +DATA_DIR=/var/lib/fonu +ENV_DIR=/etc/fonu +ENV_FILE=$ENV_DIR/fonu.env +UNIT=/etc/systemd/system/fonu.service +SERVICE_NAME=fonu +PORT=6893 +UNINSTALL=0 +PURGE=0 +RESET_ADMIN=0 + +usage() { + sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//' + exit "${1:-0}" +} + +while [ $# -gt 0 ]; do + case "$1" in + --prefix) PREFIX=${2:?}; shift 2 ;; + --data) DATA_DIR=${2:?}; shift 2 ;; + --port) PORT=${2:?}; shift 2 ;; + --uninstall) UNINSTALL=1; shift ;; + --purge) PURGE=1; shift ;; + --reset-admin) RESET_ADMIN=1; shift ;; + -h|--help) usage 0 ;; + *) echo "未知参数:$1" >&2; usage 1 ;; + esac +done + +if [ "$(id -u)" -ne 0 ]; then + echo "请用 root 运行(sudo bash ./install.sh)" >&2 + exit 1 +fi + +if [ "$UNINSTALL" -eq 1 ]; then + systemctl stop "$SERVICE_NAME" 2>/dev/null || true + systemctl disable "$SERVICE_NAME" 2>/dev/null || true + rm -f "$UNIT" + systemctl daemon-reload + rm -rf "$PREFIX" + if [ "$PURGE" -eq 1 ]; then + rm -rf "$DATA_DIR" "$ENV_DIR" + echo "已卸载,并删除了数据目录 $DATA_DIR 与 $ENV_DIR" + else + echo "已卸载。数据目录保留在 $DATA_DIR(如需一并删除:--uninstall --purge)" + fi + exit 0 +fi + +# 从日志里拽出最新一次生成的管理员口令(应用启动时写的是 JSON 行,stdout 与 app.log 都有) +print_initial_password() { + local line + line=$(journalctl -u "$SERVICE_NAME" -o cat --no-pager 2>/dev/null \ + | grep -o '"password":"[^"]*"' | tail -n 1) + if [ -z "$line" ]; then + line=$(grep -o '"password":"[^"]*"' "$DATA_DIR/logs/app.log" 2>/dev/null | tail -n 1) + fi + if [ -n "$line" ]; then + pw=$(printf '%s' "$line" | sed 's/^"password":"//; s/"$//') + echo " 初始口令:$pw" + echo " 用户名:admin(首次登录后请在「设置」里改掉)" + echo " 这条口令只在首次启动时生成一次,日志文件:$DATA_DIR/logs/app.log" + else + echo " 未从日志里读到初始口令(可能不是首次启动);手动查看:" + echo " journalctl -u $SERVICE_NAME --no-pager | grep -i password | tail -n 3" + fi +} + +# 重置管理员:删掉 admins 行后重启,启动流程会重新生成一个随机口令并写进日志 +reset_admin() { + if [ ! -f "$DATA_DIR/fonu.db" ]; then + echo "找不到数据库 $DATA_DIR/fonu.db,无需重置" >&2 + exit 1 + fi + systemctl stop "$SERVICE_NAME" 2>/dev/null || true + local backup="$DATA_DIR/fonu.db.bak.$(date +%Y%m%d-%H%M%S)" + cp "$DATA_DIR/fonu.db" "$backup" + if command -v sqlite3 >/dev/null 2>&1; then + sqlite3 "$DATA_DIR/fonu.db" 'DELETE FROM sessions; DELETE FROM admins;' + elif command -v python3 >/dev/null 2>&1; then + python3 - "$DATA_DIR/fonu.db" <<'PY' +import sqlite3, sys +conn = sqlite3.connect(sys.argv[1]) +conn.execute('DELETE FROM sessions') +conn.execute('DELETE FROM admins') +conn.commit() +PY + else + echo "需要 sqlite3 或 python3 才能改库(已备份到 $backup)" >&2 + systemctl start "$SERVICE_NAME" 2>/dev/null || true + exit 1 + fi + echo "==> 已清空管理员与先前的会话(数据库已备份:$backup)" + systemctl start "$SERVICE_NAME" + sleep 2 + systemctl is-active --quiet "$SERVICE_NAME" || { + echo "服务未能启动,请看:journalctl -u $SERVICE_NAME -n 50 --no-pager" >&2 + exit 1 + } + echo "==> 已重置,下面是新生成的管理员口令:" + print_initial_password + exit 0 +} + +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +if [ "$RESET_ADMIN" -eq 1 ]; then + reset_admin +fi + +for f in fonu migrations; do + if [ ! -e "$SCRIPT_DIR/$f" ]; then + echo "安装包不完整:缺少 $f(请在解压后的目录里运行 ./install.sh)" >&2 + exit 1 + fi +done + +# 架构自检:避免把 amd64 包装到 arm64 机器上,起来才报 exec format error +case "$(uname -m)" in + x86_64) PKG_ARCH=amd64 ;; + aarch64|arm64) PKG_ARCH=arm64 ;; + *) PKG_ARCH=unknown ;; +esac +case "$SCRIPT_DIR" in + *"-linux-amd64") BIN_ARCH=amd64 ;; + *"-linux-arm64") BIN_ARCH=arm64 ;; + *) BIN_ARCH=$PKG_ARCH ;; +esac +if [ "$PKG_ARCH" != unknown ] && [ "$BIN_ARCH" != "$PKG_ARCH" ]; then + echo "警告:当前机器是 $PKG_ARCH,但这个包看起来是 $BIN_ARCH(可能无法运行)" >&2 +fi + +# nginx 的 mime.types 路径各发行版不同(Debian/Ubuntu 在 /etc/nginx,RHEL 系在 /usr/share/nginx) +MIME_TYPES=/etc/nginx/mime.types +[ -f "$MIME_TYPES" ] || MIME_TYPES=/usr/share/nginx/mime.types + +echo "==> 安装到 $PREFIX(数据目录 $DATA_DIR,监听 :$PORT)" +systemctl stop "$SERVICE_NAME" 2>/dev/null || true + +install -d -m 0755 "$PREFIX" +install -d -m 0750 "$DATA_DIR" +install -d -m 0750 "$ENV_DIR" +install -d -m 0755 "$PREFIX/migrations" + +install -m 0755 "$SCRIPT_DIR/fonu" "$PREFIX/fonu" +rm -f "$PREFIX"/migrations/*.sql +cp "$SCRIPT_DIR"/migrations/*.sql "$PREFIX/migrations/" +install -m 0644 "$SCRIPT_DIR/VERSION" "$PREFIX/VERSION" 2>/dev/null || true + +# 环境变量只在首次安装时生成:重装不会换掉会话密钥(换了所有人都会掉线) +if [ ! -f "$ENV_FILE" ]; then + SECRET=$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n') + cat > "$ENV_FILE" < 已生成 $ENV_FILE(含随机会话密钥)" +else + echo "==> 保留已有 $ENV_FILE(如需改端口请编辑它后重启服务)" +fi + +cat > "$UNIT" </dev/null | awk '{print $1}') + echo + echo "==> 安装完成,服务已启动" + echo " 访问:http://${IP:-<本机IP>}:$PORT" + print_initial_password + echo " 状态:systemctl status $SERVICE_NAME" + echo " 日志:journalctl -u $SERVICE_NAME -f" + echo " 升级:先停止服务,再重跑本脚本(数据与密钥都会保留)" +else + echo "服务未能启动,请看日志:journalctl -u $SERVICE_NAME -n 50 --no-pager" >&2 + exit 1 +fi diff --git a/web/package.json b/web/package.json index 9283623..d70f4e6 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "fonu-web", "private": true, - "version": "1.2.12", + "version": "1.2.13", "packageManager": "npm@10.9.4", "type": "module", "scripts": { diff --git a/web/src/views/CertificatesView.vue b/web/src/views/CertificatesView.vue index 0d94813..5d090ea 100644 --- a/web/src/views/CertificatesView.vue +++ b/web/src/views/CertificatesView.vue @@ -216,8 +216,8 @@

使用 ZeroSSL 需在「设置」中配置 API Key

-

- Buypass 不支持通配符,单张证书最多 5 个域名 +

+ Buypass 已于 2025-10-15 停止签发证书,请改选 Let's Encrypt 或 ZeroSSL

diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index 41f8196..d91438f 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -69,7 +69,7 @@
ACME 邮箱
-

用于 Let's Encrypt / ZeroSSL / Buypass 账户注册

+

用于 Let's Encrypt / ZeroSSL 账户注册

申请 ZeroSSL 证书时必填,可在 ZeroSSL 控制台获取

+
+
DNS 解析器(高级)
+ +

+ DNS-01 验证时用它查域名的权威 NS 记录。容器里的系统解析器通常指向 Docker 的 + 127.0.0.11,不返回 NS,于是报「could not determine authoritative nameservers」; + 所以默认用公共解析器。留空 = 用默认值;填 system = 回到系统解析器; + 多个用逗号分隔,不写端口默认 53。 +

+
@@ -1015,6 +1025,7 @@ const ddnsInterval = ref(5) const certThreshold = ref(30) const logRetention = ref(30) const acmeEmail = ref('') +const acmeResolvers = ref('') const zerosslApiKey = ref('') const zerosslHasKey = ref(false) const trustedProxyEnabled = ref(false) @@ -1108,6 +1119,7 @@ function applySettingsToForm(settings: Record) { statusPageCode.value = settings.status_page_code ?? '' statusPageConfigured.value = statusPageCode.value === MASKED_SECRET acmeEmail.value = settings.acme_email ?? '' + acmeResolvers.value = settings.acme_dns_resolvers ?? '' zerosslHasKey.value = Boolean(settings.zerossl_api_key?.trim()) zerosslApiKey.value = '' chinaCIDRHours.value = Number(settings.china_cidr_update_interval_hours ?? 24) @@ -1579,6 +1591,7 @@ function buildSavePayload() { cert_renew_threshold_days: String(certThreshold.value), log_retention_days: String(logRetention.value), acme_email: acmeEmail.value.trim(), + acme_dns_resolvers: acmeResolvers.value.trim(), trusted_proxy_json: buildTrustedProxyJSON(), global_ip_blacklist: buildGlobalIPListJSON(globalIPBlacklistText.value), global_ip_whitelist: buildGlobalWhitelistJSON(),