commit 278f8267b723d58b5a37e6267c19153bf471bbcf Author: mrlee <470349233@qq.com> Date: Wed Sep 23 09:40:59 2026 +0800 chore: 初始提交 Fonu 1.0.2 - 公网反代:Nginx 状态卡片(版本 / 运行状态 / 配置校验 / 端口监听) - 部署设置第一、二梯队(HTTPS 开关、安全头、限流、连接数、仅中国大陆 IP) - 隧道检测兼容 frps v0.68 / v0.71(V2 与旧版端点) - 恢复被清空的 internal/frp/store.go、internal/api/ddns_handler.go、web/src/views/DdnsView.vue diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5832e8f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +web/node_modules +web/dist +bin +*.db +/data +# 本地运行数据与归档,不进镜像(防止把真实数据库/证书/日志带入构建上下文) +.data +# 已弃用归档代码,不参与编译 +internal/frp-1.0.2 +web/src/views/FrpView-1.0.2.vue +# 文档与本地杂项 + docs diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml new file mode 100644 index 0000000..553ee8e --- /dev/null +++ b/.github/workflows/docker-release.yml @@ -0,0 +1,105 @@ +name: Docker Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Release tag (e.g. v0.1.0)' + required: true + default: v0.1.0 + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + concurrency: + group: docker-release-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}-${{ matrix.arch }} + cancel-in-progress: false + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + arch: amd64 + - platform: linux/arm64 + arch: arm64 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }} + + - name: Resolve version + id: version + env: + REF_NAME: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }} + run: echo "version=${REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up QEMU + if: matrix.arch == 'arm64' + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push arch image + uses: docker/build-push-action@v6 + with: + context: . + platforms: ${{ matrix.platform }} + push: true + build-args: | + VERSION=${{ steps.version.outputs.version }} + tags: ${{ secrets.DOCKERHUB_USERNAME }}/fonu:${{ steps.version.outputs.version }}-${{ matrix.arch }} + labels: | + org.opencontainers.image.source=${{ github.repositoryUrl }} + org.opencontainers.image.version=${{ steps.version.outputs.version }} + org.opencontainers.image.revision=${{ github.sha }} + cache-from: type=gha,scope=${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=${{ matrix.arch }} + + create-manifest: + needs: build-and-push + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Resolve version + id: version + env: + REF_NAME: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }} + run: echo "version=${REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Create multi-arch manifest + env: + IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/fonu + VERSION: ${{ steps.version.outputs.version }} + run: | + if [ -z "${IMAGE}" ]; then + echo "DOCKERHUB_USERNAME secret is not set" >&2 + exit 1 + fi + docker buildx imagetools create \ + -t "${IMAGE}:${VERSION}" \ + -t "${IMAGE}:latest" \ + "${IMAGE}:${VERSION}-amd64" \ + "${IMAGE}:${VERSION}-arm64" diff --git a/.github/workflows/github-release.yml b/.github/workflows/github-release.yml new file mode 100644 index 0000000..541d06f --- /dev/null +++ b/.github/workflows/github-release.yml @@ -0,0 +1,51 @@ +name: GitHub Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Release tag (e.g. v0.1.0)' + required: true + +concurrency: + group: github-release-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }} + cancel-in-progress: true + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + env: + VERSION: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ env.VERSION }} + + - name: Extract release notes from CHANGELOG + id: notes + run: | + if awk -v ver="$VERSION" ' + $0 ~ "^## \\[" ver "\\]" { found = 1; next } + found && /^## \[v/ { exit } + found { print } + ' CHANGELOG.md > release-body.md && [ -s release-body.md ]; then + echo "use_changelog=true" >> "$GITHUB_OUTPUT" + else + echo "use_changelog=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ env.VERSION }} + name: ${{ env.VERSION }} + body_path: ${{ steps.notes.outputs.use_changelog == 'true' && 'release-body.md' || '' }} + generate_release_notes: ${{ steps.notes.outputs.use_changelog != 'true' }} + draft: false + prerelease: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..edd41bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +bin/ +.data/ +web/node_modules/ +web/dist/ +cmd/fonu/web/dist/ +*.db +/data/ +.DS_Store +.cursor/ +scripts/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..704e6cf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,416 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [v1.1.0] - 2026-09-19 + +### 新增 + +- FRP:新增公网 `fonu-agent` 服务端管理基础能力——VPS 侧提供 Bearer Token API,Fonu 本地可测试 Agent、读取状态、下发 frps.toml,并在 HTTP/HTTPS 规则保存后同步公网 Nginx 反代路由 +- FRP:服务端新增「Agent 管理」弹窗——API 状态面板(连接/frps/Nginx/80-443 端口监听/已注册路由)与 SSH 生命周期操作(探测环境、一键安装/升级/卸载、SSH 诊断),安装后自动回填 Agent 地址与 Token;agent 二进制内嵌随主程序分发,无需单独下载 +- FRP:模块整体升级为多服务端管理——支持添加多台 frps(独立启动 / 停止 / 日志 / 测速 / 校验),穿透规则支持全部 8 种类型(tcp / udp / http / https / tcpmux / stcp / sudp / xtcp)与插件(static_file / socks5 / https2http 等,按插件类型校验必填字段) +- FRP:服务端详情五项独立诊断(TCP 可达 / frpc 登录 / 代理可达 / DNS 解析 / 归属地),含最近 30 分钟可用率与延迟曲线 +- FRP:frps.toml 同源生成——服务端「详情」一键生成与 frpc 侧配置同源的服务端配置(bindPort / 认证 / webServer / 传输参数 / vhost 端口 / 防火墙放行清单);支持 token / 无认证 / OIDC 三种方式;格式对齐官方 `frps_full_example.toml`(v0.52.0+ TOML,v0.63.0+ 均适用) +- FRP:穿透规则实时流量与连接数展示(需在 frps 配置 webServer 管理接口) +- FRP:「应用配置」旁新增「使用说明」——配置流程、DDNS / 反向代理 / 证书组合说明与十个组合场景详解(双标签页 + 手风琴折叠展示) +- FRP:frpc 客户端多版本管理(「应用配置」弹窗内下载 / 启用 / 删除 / 配置下载代理) +- DDNS:新增「自定义 IP」——按任务设置固定 IPv4 / IPv6(如公网服务器 IP),更新时直接使用该 IP,不再检测本机出口 IP;须为公网地址,否则拒绝更新 + +### 改进 + +- FRP:脱敏令牌完整闭环——Token / OIDC 客户端密钥 / frps 管理密码掩码往返保护,回传掩码不会覆盖真实凭据;表单统一「已配置」标识 +- FRP:服务端详情「配置」面板重新分组排版(描述 / 连接与认证 / 诊断) +- 前端:使用说明弹窗对齐「添加穿透规则」弹窗的自绘外壳样式 + +### 变更 + +- FRP:旧「Nginx 网关模式」退役——原 5 条 `/api/frp` 接口移除,由 `/api/frpmulti` 接管;旧实现归档为 `internal/frp-1.0.2`(不参与运行、可整目录删除);旧网关配置(settings 键值)保留但不再读取,升级后需在「FRP 穿透」页重新配置服务端 +- 数据库:新增迁移 015–021(FRP 多服务端核心表)与 022(DDNS 自定义 IP 字段),升级后首次启动自动应用 + +## [v1.0.2] - 2026-09-18 + +### 新增 + +- 证书:支持火山引擎 DNS 的 ACME DNS-01 验证 + +## [v1.0.1] - 2026-09-18 + +### 新增 + +- DDNS:支持火山引擎 DNS(AccessKey / Secret Access Key) +- DDNS:委托子域与多层子域名 Zone 自动匹配(最长后缀优先,如 `a.zzz.com`) + +### 改进 + +- DDNS:解析记录统一展示完整域名;任务列表仅显示记录条数 +- DDNS:保存时校验域名格式(非法字符、标签长度、连字符位置) +- 证书:火山引擎 DNS 任务申请证书时给出明确不支持 DNS-01 的提示 + +### 修复 + +- DDNS:多层子域名(如 `aaaa.vvv.bbb.com`、`www.a.zzz.com`)保存后被截断的问题 +- DDNS:同一任务混用多根域/委托子域时 Zone 解析错误 + +## [v1.0.0] - 2026-09-18 + +### 新增 + +- 通知:支持 SMTP 邮件、Webhook 预设(Bark / ntfy / Gotify / 自定义)与 Telegram 机器人(可选 HTTP 代理),全局仅可启用一种方式 +- 设置页新增「测试通知」按钮,无需保存即可验证当前配置 +- 告警事件:DDNS / 证书 / 安全 / 系统分组,含 IP 变更、续签成功/失败、登录异常、Nginx 重载失败等 +- 设置页改为「常规 / 通知 / 安全 / 高级」分栏,减轻单页信息密度 +- 日志中心:系统日志、访问日志、Nginx 错误日志三 Tab;移除实时日志 SSE +- 凭据类字段统一「已配置」标识与「留空表示不修改」占位(SMTP / Webhook / Telegram / DDNS / FRP / Basic Auth 等) + +### 改进 + +- 旧版 `notify_webhook_url` 自动迁移为「自定义 Webhook」 +- 通知密钥加密存储,接口不再返回明文;保存后表单状态与服务端同步 +- 系统日志中文展示(历史英文日志兼容翻译) +- 仪表盘与反代详情「最近访问」展示反代服务名而非域名 +- README 界面截图更新为原图,两列排版展示 + +### 修复 + +- 通知 / 系统设置保存后刷新丢失的问题 + +## [v0.1.31] - 2026-09-18 + +### 新增 + +- 反向代理 Nginx:支持单条规则自动/手动编辑、备份与回滚;详情页只读预览,编辑弹窗内配置 +- 设置页 Nginx 全局片段:http 块自定义编辑,默认上传大小 50M 片段与框架说明 +- Nginx 编辑器:CodeMirror 语法高亮;自动生成配置附带中文注释 +- 保存 Nginx 时基础语法校验(无 nginx 可执行文件时亦可拦截明显错误) + +### 改进 + +- 反代日志面板:移除「隐藏轮询」开关,简化日志工具栏 + +## [v0.1.30] - 2026-09-17 + +### 修复 + +- 反向代理 Basic Auth:htpasswd 文件补充 nginx 可读权限与 bcrypt 前缀兼容,修复认证后 500 错误 +- 反代连接数:按「域名 + 监听端口」统计,修复同域名多规则互相串数的问题 +- 仪表盘「今日请求」:排除错误页与 favicon 噪音,趋势改为较昨日对比 + +### 新增 + +- 反代详情日志:支持全屏查看实时访问日志 + +## [v0.1.29] - 2026-09-17 + +### 修复 + +- 反向代理 Basic Auth:修复密码哈希在 Nginx 重载前被剥离导致认证不生效的问题 +- 反代上传:全局默认 `client_max_body_size 50m`,修复大文件上传 413 错误 + +### 改进 + +- README 更新界面截图(v0.1.28)并补充开源地址与协议说明 + +## [v0.1.28] - 2026-09-16 + +### 修复 + +- 仪表盘「今日请求」:改为今日 0–24 时固定坐标轴(00:00–22:00),未来时段为空 + +### 改进 + +- FRP Token:保存后显示「已保存」状态,说明不显示明文的原因 +- FRP Web 穿透:移除重复域名输入框,统一在「已同步域名」弹窗中编辑保存 + +## [v0.1.27] - 2026-09-16 + +### 修复 + +- FRP:移除无效的 `pidFile` 配置项,修复 frpc 启动即退出(exit status 1) +- FRP:关闭穿透时保存按钮不可用,导致无法真正关闭 +- FRP:frps 配置随连接设置实时生成,不再展示静态示例 +- 仪表盘「今日请求」:改为滚动最近 24 小时统计,修复当前时段无数据 + +### 改进 + +- Docker 镜像内置 frpc 升级至 v0.71.0 + +## [v0.1.26] - 2026-09-16 + +### 新增 + +- FRP 内网穿透(Nginx 网关模式):独立「内网穿透」页面配置 frps 连接,自动穿透本地 Nginx HTTP/HTTPS 端口 +- Docker 镜像内置 frpc v0.66.x(amd64 / arm64) +- DDNS 页:FRP 启用时提示将域名解析到 VPS 公网 IP + +## [v0.1.25] - 2026-09-15 + +### 新增 + +- DDNS 与证书 DNS-01:支持腾讯云 DNS(SecretId + SecretKey,区别于 DNSPod ID+Token) + +## [v0.1.24] - 2026-09-14 + +### 修复 + +- 中国 IP 段更新后 nginx 重载失败:校验用临时文件,应用配置时改用正式的 `china_cidr.conf` +- 仪表盘「今日请求」图表:从完整 access.log 按小时统计,不再仅用最近 10 条访问记录 + +## [v0.1.23] - 2026-09-14 + +### 修复 + +- 自定义错误页插图无法显示:图片路径改为公开 location,浏览器可正常加载 `error.png` / `429.png` + +## [v0.1.22] - 2026-09-14 + +### 改进 + +- 仅中国大陆:内网 IP(10/8、172.16/12、192.168/16 等)默认放行,无需手动加白名单 +- 自定义错误页图片内嵌进二进制,Docker 镜像补充 `web/assets/image` 资源目录 + +### 修复 + +- 错误页目录权限:生成后自动 chmod/chown,避免 nginx(www-data)无法读取导致回退默认 403 页 +- 中国 IP 段未就绪时,仅中国大陆规则改为只拦截非公网内网地址 + +## [v0.1.21] - 2026-09-14 + +### 新增 + +- 反代安全设置:IP 黑/白名单、仅中国大陆、Basic Auth、速率/连接数限制 +- 设置页:信任代理(CDN Real IP)、全局 IP 黑名单、中国 IP 段定时更新 +- 自定义 Nginx 错误页(403 / 404 / 429 / 500 / 503) +- 高级选项:上游 TLS 校验跳过、目标 Host 头、TLS 1.3 仅、HTTPS 安全响应头 + +### 改进 + +- 中国 IP 段:同步更新 API、ghfast 镜像回退、多源下载与状态展示 +- 反代编辑/详情弹窗 UI 重构;列表域名访问链接与复制 +- 仅中国大陆 + 白名单:白名单 IP 正确豁免大陆限制(geo + 变量组合) + +### 修复 + +- 白名单模式开启但未填 IP 时的校验 +- API 测试依赖 settings 包导入 + +## [v0.1.20] - 2026-09-13 + +### 改进 + +- 反代详情改为弹窗展示,概览布局重排(信息条 + 图表/指标 + 最近访问) +- 标题栏显示当前连接数;最近访问列表支持纵向滚动 +- 反代详情日志结构化展示(时间、方法、路径、状态码等分列) + +### 文档 + +- README 默认反代端口更新为 `18080` / `9443` + +## [v0.1.19] - 2026-09-13 + +### 修复 + +- 反代详情日志:修复无法滚动的问题,悬停显示滚动条,默认跟随最新日志 + +## [v0.1.18] - 2026-09-13 + +### 改进 + +- 反代详情:概览 / 日志 Tab 切换,默认概览;日志区域撑满侧栏 +- 反代列表:状态列改为开关,可直接启用/停用 +- 反代「复制」改为填入新建表单(名称加 `-复制` 后缀) +- README Docker 徽章按 semver 排序,避免显示 `-amd64` 标签 + +### 移除 + +- 反代详情「设置」Tab 与拖动排序提示文案 + +## [v0.1.17] - 2026-09-13 + +### 新增 + +- 反代规则「备注」改为「名称」,列表以名称优先展示 +- 反代规则复制当前信息到剪贴板 +- 反代列表拖动排序(无筛选时) + +## [v0.1.16] - 2026-09-13 + +### 改进 + +- 证书申请:首次未配置 ACME 邮箱时提示填写,前后端校验邮箱格式 +- 证书列表空状态与反代/DDNS 页风格统一 + +## [v0.1.15] - 2026-09-13 + +### 修复 + +- Docker 发版:amd64/arm64 均构建完成后再推送 `latest` 与版本多架构 manifest +- 补全 `web/assets/image` 品牌图片资源,同步仪表盘 banner + +### 改进 + +- arm64 镜像构建:前端与 Go 在 BUILDPLATFORM 编译,缩短 QEMU 构建时间 + +## [v0.1.14] - 2026-09-13 + +### 新增 + +- ZeroSSL 证书申请支持 +- 反代规则备注字段;域名+端口唯一约束 +- 可配置默认反代监听端口(`FONU_NGINX_HTTP_PORT` / `FONU_NGINX_HTTPS_PORT`),适配飞牛 host 部署 +- `docker-compose.host.yml` 飞牛 host 模式部署示例 +- 全新 Web UI:品牌视觉、仪表盘、证书/DDNS/反代/设置页重构 + +### 改进 + +- README 重写:飞牛 fnOS 部署说明、界面截图、Docker Hub 徽章 +- `/api/version` 返回默认反代端口配置 +- 移除仓库内 `scripts/`、`cmd/seed` 本地开发辅助 + +## [v0.1.13] - 2026-09-13 + +### 修复 + +- 修复证书页 TypeScript 类型错误导致 Docker 构建失败 + +## [v0.1.12] - 2026-09-12 + +### 新增 + +- DDNS:单任务支持多根域名,列表按域名展示解析 IP 与同步状态 +- 证书:申请进度弹窗与实时日志(SSE 异步任务);列表支持下载证书/私钥/ZIP +- 反代:按规则实时访问日志、流量统计(累计/速率)与当前连接 IP 查看 +- 日志:访问/错误/运行日志分页;实时日志预载最近 100 条 Nginx 记录 + +### 修复 + +- 实时日志 SSE 因中间件未转发 Flush 导致始终为空 +- Vite 开发代理对 SSE 流的缓冲问题 +- 证书申请:同一 DDNS 凭证管理多个根域(如 roven.cc + chiak.cc)时校验失败 +- 首次启动自动创建管理员并在运行日志输出初始密码(移除独立初始化页) +- 证书申请失败写入 `last_error` 并在列表展示 +- 系统日志过滤无意义 API 轮询噪音 + +### 改进 + +- 仪表盘/证书/DDNS 列表加载优化(轻量接口与缓存刷新) +- Nginx 访问日志增加 `request_length` / `bytes_sent` 供流量统计 +- 反代与证书页操作按钮横排布局 + +## [v0.1.11] - 2026-09-12 + +### 修复 + +- Vite 开发服务器允许经 Nginx 反代用自定义域名访问(`server.allowedHosts`) + +## [v0.1.10] - 2026-09-12 + +### 修复 + +- Windows 本地 Nginx:证书/日志使用绝对路径,HTTPS 块先于 HTTP 重定向,修复 `ssl_certificate` 校验失败 +- Windows 进程检测与启动:修复 `isPIDAlive` 误判、启动阻塞及重复拉起 Nginx 导致端口冲突 +- 系统日志写入 `app.log`,API 请求记录状态码;实时日志连接时预载最近条目 +- 未设置 `FONU_DATA_DIR` 时默认使用当前目录 `.data` + +### 改进 + +- 新增 `scripts/dev.ps1`、`scripts/clean-data.ps1` 便于 Windows 本地开发 +- 测试与文档示例域名统一为 `example.com`,移除个人域名硬编码 + +## [v0.1.9] - 2026-09-12 + +### 修复 + +- 反代保存/删除不再因 Nginx 重载卡住 30 秒:优先 SIGHUP 信号重载,失败时快速强制重启 +- 保存/更新规则先落库再重载 Nginx,避免超时后规则未写入 +- 部分成功(规则已保存但 Nginx 失败)时前端仍关闭弹窗并刷新列表 + +## [v0.1.8] - 2026-09-12 + +### 修复 + +- 修复反代规则删除无反应:删除失败时显示错误提示;Nginx 重载失败时不再回滚已删除的规则 + +## [v0.1.7] - 2026-09-12 + +### 改进 + +- 界面左下角版本号随发版标签注入,与 Docker 镜像版本一致(不再固定显示 v0.1.0) +- 新增 `GET /api/version` 接口;发版时更新根目录 `VERSION` 文件 + +## [v0.1.6] - 2026-09-12 + +### 修复 + +- 为 Nginx 校验/重载/启动命令增加 15 秒超时,避免反代保存时无限转圈 +- 前端 API 请求增加 30 秒超时,超时后显示明确错误提示 +- Nginx 重载失败时先尝试优雅停止再重启,降低端口占用导致的卡死 + +## [v0.1.5] - 2026-09-12 + +### 修复 + +- 反代证书匹配改为读取证书库记录与磁盘文件,导入证书可正确用于 Nginx HTTPS +- 启用 HTTPS 但域名未匹配证书时,返回明确错误(含当前证书覆盖范围)而非 Nginx 校验失败 +- 修复 HTTPS 配置生成时可能遗漏 HTTP 回退的问题 + +## [v0.1.4] - 2026-09-12 + +### 修复 + +- 修复启用 HTTPS 但证书文件不可用时,Nginx 生成 `listen ... ssl` 却缺少 `ssl_certificate` 导致反代保存失败 +- 仅在证书文件完整存在时生成 HTTPS 配置,否则自动降级为 HTTP + +## [v0.1.3] - 2026-09-12 + +### 新增 + +- **反代多域名**:一条规则支持多个前端域名、自定义监听端口,以及 IPv4/IPv6 监听开关 +- **单域名独立端口**:前端地址可写 `example.com:6893` 覆盖规则默认端口 + +### 改进 + +- 本地开发未安装 Nginx 时,保存反代规则仍写入配置并跳过校验,便于调试 UI + +### 数据库迁移 + +- `006_proxy_multi_host.sql` — 反代规则多域名与监听配置 + +## [v0.1.2] - 2026-09-12 + +### 修复 + +- 修复 Nginx PID 文件为空或进程已退出时仍尝试重载,导致反代规则保存失败 + +## [v0.1.1] - 2026-09-12 + +### 修复 + +- 修复全新部署时 API 返回 `null` 导致仪表盘/日志等页面空白、`map` 报错 +- 修复 Nginx 重载使用错误 PID 路径(`/run/nginx.pid`)导致反代保存失败 +- 公网 IP 检测增加多个备用服务,降低 Docker 环境下单点失败概率 +- 修复 SPA 路由刷新时未正确回退到 `index.html` + +## [v0.1.0] - 2026-09-12 + +### 新增 + +- **DDNS 多域名**:支持多条 DDNS 配置,独立管理根域名与记录 +- **证书多域名**:申请/续签支持多域名与通配符(`*.example.com`) +- **ACME 颁发机构**:申请与续签时可选择 Let's Encrypt 正式/测试环境 +- **证书导入**:支持粘贴 PEM 或指定文件路径导入自签/已有证书 +- **Docker 发版**:推送 `v*` 标签自动分架构推送镜像(`latest` / `0.1.0` / `0.1.0-amd64` / `0.1.0-arm64`) +- **表格操作规范**:所有列表操作按钮平铺展示(`renderTableRowActions`) + +### 改进 + +- **仪表盘**:全新布局,ECharts 图表,状态卡片与日志预览 +- **日志页**:分页(20 条/页),支持 `?tab=` 跳转,统一时间格式 +- **证书页**:概览统计、申请弹窗优化、表格自适应宽度 +- **布局**:顶栏时间靠左、操作靠右,内容区铺满主区域 +- **主题**:深色模式与组件样式统一 + +### 数据库迁移 + +- `003_ddns_multi.sql` — DDNS 多配置 +- `004_cert_acme_ca.sql` — 证书 ACME CA 字段 +- `005_cert_domains.sql` — 证书多域名 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..adf992c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,64 @@ +ARG TARGETARCH +# 版本单一来源:根目录 VERSION 文件,本地改版本号只需改此文件 +ARG VERSION_FILE=VERSION + +# 本地项目:基础镜像默认走国内镜像源(fnOS 等环境直连 Docker Hub 会被加速器拦截 401)。 +# 海外/可直连环境可用 --build-arg 或 .env 覆盖回官方名:NODE_IMAGE=node:22-alpine 等 +ARG NODE_IMAGE=docker.m.daocloud.io/library/node:22-alpine +ARG GO_IMAGE=docker.m.daocloud.io/library/golang:1.23-bookworm +ARG RUNTIME_IMAGE=docker.m.daocloud.io/library/debian:bookworm-slim +# Go 依赖代理:国内默认 goproxy.cn(官方 proxy.golang.org 国内不可达),海外可覆盖回 direct +ARG GOPROXY=https://goproxy.cn,direct + +# 版本注入 stage:把根目录 VERSION 文件载入构建图,供前后端构建 stage 读取 +FROM ${NODE_IMAGE} AS version +ARG VERSION_FILE +COPY ${VERSION_FILE} /VERSION + +FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS web-builder +WORKDIR /src/web +COPY web/package.json web/package-lock.json* ./ +RUN npm ci +COPY web/ ./ +COPY --from=version /VERSION /src/web/.version +RUN VITE_APP_VERSION=$(tr -d ' \t\n\r' < /src/web/.version) npm run build + +FROM --platform=$BUILDPLATFORM ${GO_IMAGE} AS go-builder +ARG TARGETARCH +ARG GOPROXY +ENV GOPROXY=${GOPROXY} +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +COPY --from=version /VERSION ./VERSION +# 先交叉编译 fonu-agent 到 embed 目录(替换仓库中的占位文件),主程序构建时自动内嵌 +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -o internal/frp/agentbin/fonu-agent-linux-amd64 ./cmd/fonu-agent \ + && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -o internal/frp/agentbin/fonu-agent-linux-arm64 ./cmd/fonu-agent +COPY --from=web-builder /src/web/dist ./cmd/fonu/web/dist +RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \ + -ldflags "-X github.com/fonu/fonu/internal/version.Version=$(cat /src/VERSION | tr -d ' \t\n\r')" \ + -o /fonu ./cmd/fonu + +# 运行时 stage +FROM ${RUNTIME_IMAGE} +ARG TARGETARCH +# frpc 不预装:需要内网穿透时在「应用配置」弹窗内按需下载(存入数据目录) +RUN apt-get update \ + && apt-get install -y --no-install-recommends nginx ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +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 \ + FONU_LISTEN=:6893 \ + FONU_NGINX_MIME_TYPES=/etc/nginx/mime.types + +EXPOSE 6893 80 443 + +VOLUME ["/data"] + +CMD ["/app/fonu"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ee65003 --- /dev/null +++ b/README.md @@ -0,0 +1,258 @@ +

+ Fonu Logo +

+ +

Fonu

+ +

让 NAS 访问更简单

+ +

+ 面向 飞牛 fnOS 的轻量公网访问管理工具
+ 反向代理 · DDNS · HTTPS 证书 · 内网穿透 · 一站式 Web 管理 +

+ +

+ 飞牛 fnOS + 其他环境未测试 + Go + Vue 3 + Nginx + GPL-3.0 +

+ +--- + +## 简介 + +**Fonu** 是一款主要为 **飞牛 fnOS** 编写的反代与管理工具,帮助你在 NAS 上快速搭建稳定的公网访问能力:通过 Web 界面管理 Nginx 反向代理、自动同步 DDNS、申请与续期 Let's Encrypt 证书,并在一个仪表盘里查看服务状态、访问日志与流量统计。 + +无需手写 Nginx 配置,也无需在多个工具之间切换。当前开发与测试环境以飞牛 fnOS 为主,**群晖、威联通、自建 Linux 等其他环境尚未充分测试**,部署前请自行验证。 + +## 功能特性 + +| 模块 | 说明 | +| -------------- | ---------------------------------------------------------------------------- | +| **反向代理** | 多域名、多端口、HTTP/HTTPS、自动重定向;实时访问日志、流量统计、当前连接 IP | +| **DDNS** | 支持 Cloudflare、DNSPod、阿里云、腾讯云、火山引擎 DNS;单任务多根域名,自动同步公网 IP | +| **HTTPS 证书** | ACME 自动申请与续期;证书/私钥/ZIP 下载;申请进度实时日志 | +| **仪表盘** | 公网 IP、域名、证书、服务状态一览;请求趋势与运行健康度 | +| **日志中心** | 系统日志、Nginx 访问日志、Nginx 错误日志;分页筛选与自动刷新 | +| **内网穿透** | FRP 客户端(Nginx 网关穿透):无公网 IP 时经 VPS 中转,反代与安全策略不变 | +| **系统设置** | 常规 / 通知 / 安全 / 高级分栏;邮件/Webhook/Telegram 告警、备份与恢复 | +| **其他** | 深色模式、配置导出/导入、GitHub Release 更新提示 | + +## 界面展示 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 仪表盘
+ 仪表盘 +
+ DDNS
+ DDNS +
+ HTTPS 证书
+ HTTPS 证书 +
+ HTTPS 证书 · 申请向导
+ 证书申请 +
+ 反向代理
+ 反向代理 +
+ 反向代理 · 规则详情
+ 反向代理规则详情 +
+ 反向代理 · 规则配置
+ 反向代理规则配置 +
+ 反向代理 · 安全设置
+ 反向代理安全设置 +
+ 反向代理 · Nginx 配置
+ 反向代理 Nginx 配置 +
+ 内网穿透
+ 内网穿透 +
+ 系统设置
+ 系统设置 +
+ 系统设置 · 高级
+ 系统设置高级 +
+ 日志中心
+ 日志中心 +
+ +## 技术栈 + +| 层级 | 技术 | +| ------------ | ----------------------------------------------------- | +| **后端** | Go 1.23、标准库 HTTP、SQLite(modernc.org/sqlite) | +| **反向代理** | 内置 Nginx(动态生成配置、热重载) | +| **证书** | go-acme/lego(Let's Encrypt) | +| **DDNS** | Cloudflare / DNSPod / 阿里云 / 腾讯云 / 火山引擎 DNS API | +| **内网穿透** | FRP 客户端(多服务端):无公网 IP 时经 VPS 中转,8 种穿透类型与插件,frps.toml 同源生成 | +| **前端** | Vue 3、TypeScript、Vite、Naive UI、ECharts | +| **部署** | Docker 镜像(amd64 / arm64),本地源码构建 | + +## 部署 + +本地项目:三个 compose 文件均从源码构建镜像(`build: .`),不拉取任何仓库镜像,构建产物标签为 `fonu:local`。 + +> **详细部署安装文档**:[`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) —— 环境要求、镜像构建、三种部署方式、首次配置、FRP/DDNS/证书联动、升级回滚与故障排查。 +> **FRPS 服务端管理方案**:[`docs/frps服务端管理方案.md`](frps服务端管理方案.md) —— 公网 VPS 部署 `fonu-agent`,SSH 从本地 Fonu 安装/升级/卸载,日常通过本地管理面板调用 API 自动编排 Nginx、frps 与证书。 + +### 快速部署(推荐 · 飞牛 fnOS · Host 模式) + +飞牛系统已占用 **80/443**,使用 **host 网络** + **高位反代端口**,避免与系统 Nginx 冲突。 + +```bash +docker run -d \ + --name fonu \ + --net=host \ + -v ./data:/data \ + --restart unless-stopped \ + fonu:local +``` + +| 入口 | 地址 | +| ------------------ | ----------------------- | +| 管理后台 | `http://:6893` | +| HTTP 反代(默认) | `http://:18080` | +| HTTPS 反代(默认) | `https://:9443` | + +- `./data` 挂载后自动作为数据目录,存放数据库、Nginx 配置、证书与日志 +- 飞牛系统已占用 80/443,容器默认反代端口为 `18080`、`9443`,可通过环境变量修改 + +### 其他部署方式 + +
+Docker Compose + +```bash +FONU_SESSION_SECRET=your-secret docker compose -f docker-compose.host.yml up -d +``` + +
+ +
+Bridge 网络(端口映射,非飞牛环境可自行尝试) + +```bash +FONU_SESSION_SECRET=your-secret docker compose -f docker-compose.hub.yml up -d --build +``` + +
+ +
+本地构建 + +```bash +docker compose up -d --build +``` + +
+ +### 首次登录 + +1. 启动后查看容器/运行日志,获取自动生成的 **admin 初始密码** +2. 访问 `http://:6893/login` 登录 +3. 在 **设置** 中立即修改管理员密码 + +### FRP 内网穿透(无公网 IP) + +适用于无法端口映射、无公网 IPv4 的场景。Fonu 内置 **frpc**,支持**多台公网服务端**与全部 8 种穿透类型(tcp / udp / http / https / tcpmux / stcp / sudp / xtcp)及插件(static_file / socks5 / https2http 等)。 + +**典型流量路径**(tcp 规则 + 本地反代):用户 → DNS(解析到 VPS)→ frps → frpc → Fonu Nginx → 内网服务 + +#### 1. VPS 部署 frps + +在服务端「详情」弹窗点 **「frps.toml」** 一键生成与 frpc 侧同源的服务端配置(bindPort / 认证 / webServer / 传输参数 / vhost 端口与防火墙清单全部对齐),复制到 VPS 保存后 `frps -c frps.toml` 启动,按文件末尾清单放行防火墙端口。 + +#### 2. Fonu 配置 frpc + +在侧栏 **FRP 穿透** 页面: + +- **添加服务端**:主机地址 / 端口(frps bindPort,默认 7000)/ 认证方式与 Token,与 frps 一致;可选填 frps 管理接口(webServer)用于拉取穿透流量; +- **添加穿透规则**:按需选择类型——tcp / udp 填远程端口,http / https / tcpmux 填自定义域名,stcp / sudp / xtcp 凭 secretKey 访问; +- 保存后「应用配置」弹窗内校验并应用;服务端「详情」含五项诊断与独立日志。 + +#### 3. DNS 与证书 + +- **DDNS**:IP 来源**留空**=检测本机(域名指家庭宽带);填**自定义 IP**=VPS 的 IP(域名直接指 VPS,Web 服务经 VPS 对外时使用);VPS 动态 IP 场景把服务端地址填 DDNS 域名即可自动跟随; +- **HTTPS 证书**:ACME DNS-01 验证(复用 DDNS 服务商凭证,含火山引擎);「统一入口」方案证书在本地 Nginx 终结,「直通」方案在 VPS 侧终止。 + +> 组合场景详解已内置页面:FRP 页右上「使用说明」→「组合场景详解」标签,覆盖十个典型部署场景与故障排查。 + +## 环境变量 + +| 变量 | 默认值 | 说明 | +| ----------------------- | ------------------------- | ------------------------------------------ | +| `FONU_LISTEN` | `:6893` | 管理后台监听地址 | +| `FONU_DATA_DIR` | `/data` | 数据目录(数据库、Nginx 配置、证书、日志) | +| `FONU_SESSION_SECRET` | `change-me-in-production` | 会话加密密钥,生产环境请自行修改 | +| `FONU_NGINX_HTTP_PORT` | `80` | 新建 HTTP 反代规则的默认监听端口 | +| `FONU_NGINX_HTTPS_PORT` | `443` | 新建 HTTPS 反代规则的默认监听端口 | +| `FONU_NGINX_BIN` | `nginx` | Nginx 可执行文件路径(本地开发可指定) | +| `FONU_FRPC_BIN` | `frpc` | frpc 可执行文件路径(Docker 镜像内置 0.71.0) | +| `FONU_FRP_PID` | `{DATA_DIR}/frp/frpc.pid` | frpc 进程 PID 文件路径 | + +## 注意事项 + +1. **生产环境务必设置** `FONU_SESSION_SECRET`,不要使用默认值。 +2. **端口冲突**:宿主机已有 Nginx(如飞牛系统)占用 80/443 时,不要让 Fonu 反代规则监听 80/443;使用 `18080/9443` 或 host 模式 + 环境变量。 +3. **已有反代规则**:修改 `FONU_NGINX_HTTP_PORT` / `FONU_NGINX_HTTPS_PORT` 后,**不会自动更新**数据库中已有规则的端口,需在反代页手动修改并保存。 +4. **访问日志**:仅统计经 Fonu Nginx 反代的域名流量,不包含管理后台 `:6893` 的请求。 +5. **证书与 DNS**:HTTPS 申请前请确保域名 DNS 已解析到本机,且 DDNS 凭证能管理对应根域。 +6. **数据持久化**:Docker 部署请挂载 volume 到 `FONU_DATA_DIR`,避免容器重建后配置丢失。 +7. **备份**:可通过设置页导出配置;重要证书与数据库建议定期备份 `FONU_DATA_DIR` 目录。 + +## 本地开发 + +```bash +# 前端热更新 +cd web && npm ci && npm run dev + +# 后端(需先把 web/dist 复制到 cmd/fonu/web/dist) +cd web && npm run build +go run ./cmd/fonu + +# 测试 +go test ./... +cd web && npm run typecheck +``` + +发版版本号以根目录 `VERSION` 文件为单一来源,`docker compose build` 自动读取并注入前后端;本地 `go run` / 未注入时默认显示 `dev`。 + +## 开源协议 + +本项目采用 [GPL-3.0](https://www.gnu.org/licenses/gpl-3.0.html) 开源。 diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..9084fa2 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.1.0 diff --git a/cmd/fonu-agent/main.go b/cmd/fonu-agent/main.go new file mode 100644 index 0000000..943e2c2 --- /dev/null +++ b/cmd/fonu-agent/main.go @@ -0,0 +1,51 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "os/signal" + "syscall" + + "github.com/fonu/fonu/internal/agent" +) + +func main() { + listen := flag.String("listen", envOr("FONU_AGENT_LISTEN", ":7700"), "agent listen address") + token := flag.String("token", os.Getenv("FONU_AGENT_TOKEN"), "agent bearer token") + nginxDir := flag.String("nginx-conf-dir", envOr("FONU_AGENT_NGINX_CONF_DIR", ""), "managed nginx config directory(留空自动探测 Nginx 实际加载的 vhost 目录)") + certsDir := flag.String("certs-dir", envOr("FONU_AGENT_CERTS_DIR", "/var/lib/fonu-agent/certs"), "certificate directory") + frpsConfig := flag.String("frps-config", envOr("FONU_AGENT_FRPS_CONFIG", "/etc/frp/frps.toml"), "frps config path") + frpsBin := flag.String("frps-bin", envOr("FONU_AGENT_FRPS_BIN", "frps"), "frps binary") + dataDir := flag.String("data-dir", envOr("FONU_AGENT_DATA_DIR", "/var/lib/fonu-agent"), "agent data directory") + showVersion := flag.Bool("version", false, "print agent version and exit") + flag.Parse() + if *showVersion { + fmt.Println("fonu-agent", agent.AgentVersion) + return + } + if *token == "" { + log.Fatal("FONU_AGENT_TOKEN or -token is required") + } + server, err := agent.NewServer(agent.Config{ + ListenAddr: *listen, Token: *token, NginxConfDir: *nginxDir, + CertsDir: *certsDir, FrpsConfigPath: *frpsConfig, FrpsBinary: *frpsBin, DataDir: *dataDir, + }) + if err != nil { + log.Fatal(err) + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + if err := server.ListenAndServe(ctx); err != nil && ctx.Err() == nil { + log.Fatal(err) + } +} + +func envOr(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} diff --git a/cmd/fonu/main.go b/cmd/fonu/main.go new file mode 100644 index 0000000..ccd5c38 --- /dev/null +++ b/cmd/fonu/main.go @@ -0,0 +1,54 @@ +package main + +import ( + "context" + "embed" + "errors" + "io/fs" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/fonu/fonu/internal/app" + "github.com/fonu/fonu/internal/config" +) + +//go:embed all:web/dist +var webDist embed.FS + +func main() { + cfg := config.Load() + migrationsDir, err := app.ResolveMigrationsDir() + if err != nil { + log.Fatalf("resolve migrations: %v", err) + } + + staticFS, err := fs.Sub(webDist, "web/dist") + if err != nil { + log.Fatalf("load static files: %v", err) + } + + application, err := app.New(cfg, staticFS, migrationsDir) + if err != nil { + log.Fatalf("start application: %v", err) + } + + go func() { + if err := application.Run(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("server error: %v", err) + } + }() + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + <-stop + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := application.Shutdown(ctx); err != nil { + log.Printf("shutdown error: %v", err) + } +} diff --git a/deploy/nginx/mime.types b/deploy/nginx/mime.types new file mode 100644 index 0000000..e63e970 --- /dev/null +++ b/deploy/nginx/mime.types @@ -0,0 +1,15 @@ +types { + text/html html htm shtml; + text/css css; + text/javascript js; + application/json json; + image/gif gif; + image/jpeg jpeg jpg; + application/javascript js; + application/octet-stream bin exe dll; + image/png png; + image/svg+xml svg svgz; + image/webp webp; + font/woff woff; + font/woff2 woff2; +} diff --git a/docker-compose.host.yml b/docker-compose.host.yml new file mode 100644 index 0000000..b379082 --- /dev/null +++ b/docker-compose.host.yml @@ -0,0 +1,25 @@ +# 飞牛 NAS 等已占用 80/443 的环境:使用 host 网络 + 高位默认反代端口 +# 本地项目:直接用源码构建镜像,不从 Docker Hub 拉取 +services: + fonu: + build: + context: . + args: + NODE_IMAGE: ${NODE_IMAGE:-docker.m.daocloud.io/library/node:22-alpine} + GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm} + RUNTIME_IMAGE: ${RUNTIME_IMAGE:-docker.m.daocloud.io/library/debian:bookworm-slim} + GOPROXY: ${GOPROXY:-https://goproxy.cn,direct} + container_name: fonu + restart: unless-stopped + network_mode: host + volumes: + - fonu-data:/data + environment: + FONU_DATA_DIR: /data + FONU_LISTEN: :6893 + FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-change-me-in-production} + FONU_NGINX_HTTP_PORT: ${FONU_NGINX_HTTP_PORT:-18080} + FONU_NGINX_HTTPS_PORT: ${FONU_NGINX_HTTPS_PORT:-9443} + +volumes: + fonu-data: diff --git a/docker-compose.hub.yml b/docker-compose.hub.yml new file mode 100644 index 0000000..6763d5f --- /dev/null +++ b/docker-compose.hub.yml @@ -0,0 +1,27 @@ +# 本地构建并打标签(原为从 Docker Hub 拉取 muxui/fonu,本地项目无需拉取) +# 构建后镜像名为 fonu:local,可直接 docker save 导出或自行推送 +services: + fonu: + build: + context: . + args: + NODE_IMAGE: ${NODE_IMAGE:-docker.m.daocloud.io/library/node:22-alpine} + GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm} + RUNTIME_IMAGE: ${RUNTIME_IMAGE:-docker.m.daocloud.io/library/debian:bookworm-slim} + GOPROXY: ${GOPROXY:-https://goproxy.cn,direct} + image: fonu:local + container_name: fonu + restart: unless-stopped + ports: + - "6893:6893" + - "18080:80" + - "9443:443" + volumes: + - fonu-data:/data + environment: + FONU_DATA_DIR: /data + FONU_LISTEN: :6893 + FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-change-me-in-production} + +volumes: + fonu-data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e8546eb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +# 本地项目:用源码构建镜像(镜像名 fonu:local,便于导出 / 备份) +services: + fonu: + build: + context: . + args: + NODE_IMAGE: ${NODE_IMAGE:-docker.m.daocloud.io/library/node:22-alpine} + GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm} + RUNTIME_IMAGE: ${RUNTIME_IMAGE:-docker.m.daocloud.io/library/debian:bookworm-slim} + GOPROXY: ${GOPROXY:-https://goproxy.cn,direct} + image: fonu:local + container_name: fonu + restart: unless-stopped + ports: + - "6893:6893" + - "18080:80" + - "9443:443" + volumes: + - fonu-data:/data + environment: + FONU_DATA_DIR: /data + FONU_LISTEN: :6893 + FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-change-me-in-production} + +volumes: + fonu-data: diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..670313f --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,305 @@ +# Fonu 部署安装文档 + +> 适用版本:v1.1.0(源码包 `fonu-1.1.0-src.zip`)。本文覆盖从拿到安装包到服务上线的完整流程:环境要求 → 打包 → 三种部署方式 → 首次配置 → FRP/DDNS/证书联动 → 升级与回滚 → 故障排查。 +> 功能细节与组合场景请配合项目内两份文档:`README.md`(功能总览)与 FRP 页面右上「使用说明」(内置十个组合场景详解)。 + +## 0. 交付物清单与安装路径速查 + +| 交付物 | 内容 | 适用 | 安装方式 | +| --- | --- | --- | --- | +| `fonu-1.1.0-linux-amd64.tar.gz`(14MB) | 编译好的 Linux 二进制(前端已内嵌)+ migrations + systemd + 一键脚本 | **裸机 Linux 服务器**,目标机只需系统 nginx | 解压 → `sudo bash install.sh`(见 3.4) | +| `fonu-1.1.0-image.tar.gz`(约 100-150MB) | 构建好的 Docker 镜像 `fonu:local`(含 nginx + frpc) | 目标机有 Docker、要离线安装 | `docker load` → `docker run`(见 3.3 方式 C) | +| `fonu-1.1.0-src.zip`(11MB) | 完整源码 + Dockerfile/compose + 部署文档 | 要自行构建 / 二次开发 / 交叉编译 | `docker compose build` 后按方式 A/B 部署(见第 2 节) | + +**怎么选**:飞牛 fnOS 首选镜像包导入(图形界面,见第 3 节末「飞牛 fnOS 部署详解」),要在 NAS 上构建则用源码包 + host 模式 compose;普通 Linux 服务器要省事用 Linux 运行包(免 Docker);目标机不方便联网构建用镜像包离线导入。三者的功能完全一致——容器里跑的也是同一份二进制 + 系统 nginx。 + +## 1. 包内容与环境要求 + +### 1.1 源码包内容 + +`fonu-1.1.0-src.zip`(约 11MB,304 项)为完整可构建源码: + +| 目录/文件 | 说明 | +| --- | --- | +| `cmd/` `internal/` | Go 后端源码(含已迁移的多服务端 FRP 模块) | +| `web/` | Vue 3 + TypeScript 前端源码 | +| `migrations/` | SQLite 数据库迁移 001–022(启动时自动按序应用) | +| `Dockerfile` / `docker-compose*.yml` | 本地构建镜像与三种部署编排 | +| `VERSION` / `CHANGELOG.md` | 版本号单一来源(当前 `1.1.0`)与版本记录 | +| `deploy/nginx/mime.types` | 容器内 Nginx 依赖的 MIME 类型文件 | +| `docs/screenshots/` | README 引用的界面截图 | + +**已排除**(打包时即剔除,无需自行处理):`.data/`(本地运行数据)、`web/node_modules`(构建时自动安装)、`internal/frp-1.0.2/` 与 `FrpView-1.0.2.vue`(弃用归档)、`*.db`。 + +### 1.2 部署机要求 + +| 项 | 要求 | +| --- | --- | +| 系统 | 飞牛 fnOS(主要目标平台);其他 Linux x86_64 / arm64 理论可用,未充分测试 | +| Docker | 20.10+(含 compose v2) | +| 内存 | ≥ 512MB 空闲(构建期建议 ≥ 2GB,前端编译吃内存) | +| 磁盘 | ≥ 2GB(镜像约 300MB + 数据目录按日志量增长) | +| 网络 | 构建期需访问 npm registry、Go module proxy、GitHub(下载 frpc 0.71.0);运行期需访问你的 DNS 服务商 API(DDNS/证书) | +| 端口 | 管理端口 6893;反代端口默认 80/443(Bridge)或 18080/9443(飞牛 host 模式) | + +## 2. 构建镜像(所有部署方式的前提) + +解压源码包后进入项目根目录执行: + +```bash +docker compose build +``` + +一次构建同时产出:前端 `dist`、Go 后端二进制、内置 frpc 0.71.0 的运行镜像,标签为 `fonu:local`。 + +**版本号**:读取根目录 `VERSION` 文件(当前 `1.1.0`)自动注入前后端显示;要改版本号只需编辑该文件后重新 build。 + +**网络受限环境**可覆盖基础镜像与 frpc 下载源(默认值见 `Dockerfile` 顶部注释): + +```bash +docker build \ + --build-arg NODE_IMAGE=docker.m.daocloud.io/library/node:22-alpine \ + --build-arg GO_IMAGE=docker.m.daocloud.io/library/golang:1.23-bookworm \ + --build-arg RUNTIME_IMAGE=docker.m.daocloud.io/library/debian:bookworm-slim \ + -t fonu:local . +``` + +## 3. 三种部署方式(按环境二选一) + +### 方式 A:飞牛 fnOS / 已占用 80/443 的环境 —— host 模式(推荐) + +飞牛系统自带 Nginx 占用 80/443,Fonu 使用 host 网络 + 高位反代端口避免冲突。 + +```bash +FONU_SESSION_SECRET=请改成一串随机字符 docker compose -f docker-compose.host.yml up -d --build +``` + +- 无端口映射,容器直接使用宿主机网络栈; +- 反代端口由环境变量 `FONU_NGINX_HTTP_PORT=18080`、`FONU_NGINX_HTTPS_PORT=9443` 决定(文件内已内置); +- 入口:管理后台 `http://:6893`,HTTP 反代 `http://:18080`,HTTPS 反代 `https://:9443`; +- 数据持久化在命名卷 `fonu-data`(挂载到容器 `/data`)。 + +### 方式 B:其他环境 / 空闲 80/443 —— Bridge 端口映射 + +```bash +FONU_SESSION_SECRET=请改成一串随机字符 docker compose -f docker-compose.hub.yml up -d --build +``` + +- 端口映射 `6893:6893`、`18080:80`、`9443:443`(宿主机 18080/9443 → 容器 80/443); +- 数据同样在命名卷 `fonu-data`。 + +### 方式 C:docker run(不想用 compose) + +```bash +docker run -d --name fonu \ + --net=host \ + -v ./data:/data \ + -e FONU_SESSION_SECRET=请改成一串随机字符 \ + --restart unless-stopped \ + fonu:local +``` + +host 模式下反代端口用环境变量控制:`-e FONU_NGINX_HTTP_PORT=18080 -e FONU_NGINX_HTTPS_PORT=9443`。Bridge 环境改为 `-p 6893:6893 -p 18080:80 -p 9443:443`(去掉 `--net=host`)。 + +**生成随机 SESSION_SECRET**:`openssl rand -hex 32`(Linux)或 PowerShell:`-join ((48..57)+(97..122) | Get-Random -Count 32 | % {[char]$_})`。 + +> **`FONU_SESSION_SECRET` 是什么**:Fonu 的主密钥,两个用途——① 签名/加密登录会话 Cookie(决定登录态是否可被伪造);② 作为 `secret.Box` 加密存储中敏感凭据(DDNS API Token、FRP Token、SMTP 密码等)。默认值 `change-me-in-production` 是公开的:拿到它即可伪造管理员会话、解密数据库里的全部凭据,**生产环境必须替换**。⚠️ 换密钥的时机:首次启动前设置;若之后更换,旧密钥加密的凭据将无法解密(应用会提示重新保存),需到对应页面重录 Token。 + +### Linux 服务器完整部署步骤(Ubuntu / Debian / CentOS) + +```bash +# 0. 安装 Docker(已装跳过) +curl -fsSL https://get.docker.com | sh +sudo systemctl enable --now docker + +# 1. 解压源码包并进入目录 +unzip fonu-1.1.0-src.zip -d fonu && cd fonu + +# 2. 构建镜像(约 3-10 分钟,取决于网络与机器性能) +docker compose build + +# 3. 生成密钥并启动(Bridge 模式,映射 6893/18080/9443) +echo "FONU_SESSION_SECRET=$(openssl rand -hex 32)" > .env +docker compose -f docker-compose.hub.yml up -d + +# 4. 防火墙放行(按需) +sudo ufw allow 6893/tcp # 管理后台 +sudo ufw allow 80/tcp 443/tcp # 反代(Bridge 模式容器内监听 80/443) +# firewalld 用: firewall-cmd --permanent --add-port=6893/tcp && firewall-cmd --reload + +# 5. 验证 +docker logs fonu | grep -i password # 取 admin 初始密码 +curl -I http://127.0.0.1:6893/login # 应返回 200 +``` + +- **开机自启**:compose 内已含 `restart: unless-stopped`,且第 0 步已 `enable docker`,重启服务器自动拉起; +- **80/443 被系统 Nginx 占用**:改用 `docker-compose.host.yml`(host 网络 + 18080/9443 高位端口),或改 `docker-compose.hub.yml` 里的宿主端口映射; +- **离线部署**(目标机不方便联网构建):在有网的机器上 `docker compose build` 后 `docker save fonu:local | gzip > fonu.tar.gz`,拷贝到目标机 `docker load < fonu.tar.gz`,再用方式 C 的 `docker run` 启动(无需再构建); +- **数据位置**:命名卷 `fonu-data`(`docker volume inspect fonu-data` 查看宿主机实际路径),备份该卷即备份全部配置与证书。 + +### Linux 运行包安装(免 Docker,裸机部署) + +适用:不想装 Docker 的 Linux 服务器。前提:系统已装 nginx(Fonu 复用它做反代),root 权限。 + +```bash +# 1. 解压 +tar xzf fonu-1.1.0-linux-amd64.tar.gz -C fonu && cd fonu + +# 2. 一键安装(自动建 fonu 系统用户、装到 /opt/fonu、生成随机密钥、注册 systemd 开机自启) +sudo bash install.sh + +# 3. 验证 +systemctl status fonu +curl -I http://127.0.0.1:6893/login +journalctl -u fonu | grep -i password # 取 admin 初始密码 +``` + +包内容:`fonu` 二进制(前端已 `go:embed` 内嵌,无需单独部署 dist)、`migrations/`(从二进制同级目录自动查找)、`deploy/nginx/mime.types`、`fonu.service`(systemd 模板)、`install.sh`、`DEPLOYMENT.md`。 + +关键路径(install.sh 自动配置): + +| 项 | 值 | +| --- | --- | +| 二进制 | `/opt/fonu/fonu` | +| 数据目录 | `/var/lib/fonu`(数据库、证书、日志全在这) | +| 会话密钥 | `/etc/default/fonu`(重复安装保留原值) | +| 服务 | `systemctl restart fonu` 生效配置改动 | + +**备份/迁移**:停服务后打包 `/var/lib/fonu` 即可(`tar czf fonu-data.tar.gz /var/lib/fonu`),新机器解压到同路径 + 装同版本二进制即完成迁移。 + +**手动安装**(不用脚本):按包内 `README.txt` 要点——建 `fonu` 系统用户、复制二进制与 migrations 到 `/opt/fonu`、`EnvironmentFile` 写入随机密钥、`systemctl enable --now fonu`。 + +> 镜像包(`fonu-1.1.0-image.tar.gz`)来源:在任意装了 Docker 的机器上对源码包执行 `docker compose build` 后 `docker save fonu:local | gzip > fonu-1.1.0-image.tar.gz`;目标机 `docker load < fonu-1.1.0-image.tar.gz` 后按方式 C 启动。 + +### 飞牛 fnOS 部署详解(图形界面,不敲命令) + +前置:fnOS 应用中心安装 Docker 应用(较新版本自带)。以下菜单名以 fnOS 当前版本实际界面为准,大致路径一致。 + +**方式一:导入现成镜像(推荐,5 分钟)** + +1. **上传文件**:把 `fonu-1.1.0-image.tar.gz` 通过文件管理 / SMB 上传到 NAS,如共享目录 `docker/fonu/`; +2. **导入镜像**:打开 Docker 应用 → 镜像 → 导入(从文件加载)→ 选择该 tar.gz → 等待镜像列表出现 `fonu:local`(若界面只接受 .tar,先在电脑上解压出内层的 .tar 再导入); +3. **创建容器**:容器 → 创建: + +| 配置项 | 填写 | +| --- | --- | +| 名称 | `fonu` | +| 镜像 | `fonu:local` | +| 网络 | **host**(飞牛 80/443 被系统占用,host + 高位端口最省事;UI 不支持 host 时选 Bridge 并映射 6893/18080/9443) | +| 存储挂载 | NAS 建目录(如 `/vol1/…/docker/fonu/data`)→ 映射到容器 `/data`(读写) | +| 环境变量 | `FONU_SESSION_SECRET=随机串`、`FONU_DATA_DIR=/data`、`FONU_LISTEN=:6893`、`FONU_NGINX_HTTP_PORT=18080`、`FONU_NGINX_HTTPS_PORT=9443` | +| 重启策略 | 自动重启 / unless-stopped(开机自启) | + +4. **启动并取初始密码**:容器详情 → 日志,搜 `password`;浏览器打开 `http://:6893` 登录。 + +**方式二:Compose 项目(用源码包,在 NAS 上构建)** + +1. 把源码包 zip 上传到 NAS 并解压(文件管理支持解压,或电脑解压后整目录上传); +2. Docker 应用 → 项目(Compose)→ 新建:指定项目目录为解压后的源码目录、使用 `docker-compose.host.yml`(或直接把该文件内容粘贴到 Compose 编辑器); +3. 在源码目录同层放一个 `.env` 文件,内容一行:`FONU_SESSION_SECRET=随机串`; +4. 构建并启动。⚠️ NAS 上构建耗时较长(前端编译),低配机器建议改用方式一; +5. 后续升级:上传新源码覆盖 → 项目重新构建即可,数据在卷里不受影响。 + +**Compose 构建报 401(docker.fnnas.com)**:fnOS 的 Docker 设置里默认配了自家加速地址,但它对 `library/` 官方镜像拒绝匿名拉取。三种解法任选: + +1. **改用镜像包(最省事)**:跳过构建,按方式一导入 `fonu-1.1.0-image.tar.gz`; +2. **覆盖基础镜像源**:在源码目录的 `.env` 里加三行(已有 FONU_SESSION_SECRET 就追加在下面): + ``` + NODE_IMAGE=docker.m.daocloud.io/library/node:22-alpine + GO_IMAGE=docker.m.daocloud.io/library/golang:1.23-bookworm + RUNTIME_IMAGE=docker.m.daocloud.io/library/debian:bookworm-slim + GOPROXY=https://goproxy.cn,direct + ``` + + (Dockerfile/compose 现已内置这些默认值,无需 .env;此处列出供手动 docker build 参考或覆盖。`GOPROXY` 解决 go mod download 访问 proxy.golang.org 超时——国内默认 goproxy.cn,海外可覆盖回 direct。) + 然后 Compose 重新构建——compose 文件已支持这三个变量透传; +3. **换/关掉 fnOS 加速地址**:Docker 应用 → 设置 → 镜像源,把 `docker.fnnas.com` 换成可用加速源(如 `docker.m.daocloud.io`)或清空,再重新构建。 + +**fnOS 注意事项**: + +- 80/443 已被 fnOS 系统 Nginx 占用——**不要**把反代规则监听 80/443,用默认 18080/9443; +- host 模式下容器端口即宿主机端口,fnOS 防火墙(如有启用)放行 6893 / 18080 / 9443; +- 数据目录建议放在存储池挂载路径下(`/vol1/…`,以文件管理器显示的实际路径为准),系统重装时数据不随系统分区丢失; +- 升级:方式一重新导入新镜像 → 停止并删除旧容器 → 用相同挂载/环境变量重建(数据在映射目录,不丢);方式二直接重新构建。 + +## 4. 首次登录与安全配置 + +1. 启动后查看初始密码:`docker logs fonu | grep -i password`(自动生成的 admin 初始密码会打印在日志里); +2. 浏览器打开 `http://:6893/login`,用 `admin` + 初始密码登录; +3. **立即**在「设置 → 安全」修改管理员密码; +4. 确认「设置 → 常规」中 `FONU_SESSION_SECRET` 已改为非默认值(compose 文件中已传入则跳过); +5. 建议在「设置 → 通知」配置一种告警方式(邮件 / Webhook / Telegram),DDNS 失败、证书续签失败、登录异常等事件会推送。 + +## 5. 反向代理与 HTTPS 证书 + +1. **反向代理页 → 添加规则**:填前端域名(如 `app.example.com`)、后端目标(内网应用地址)、HTTPS 开关与证书; +2. **证书页 → 申请证书**:ACME DNS-01 验证,需先在 DDNS 页配置对应服务商凭证(Cloudflare / DNSPod / 阿里云 / 腾讯云 / 火山引擎),通配符证书 `*.example.com` 可覆盖整组域名; +3. DNS 记录指向本机 IP:家庭宽带场景由 DDNS 自动跟随(见第 6 节); +4. 验证:浏览器访问域名,证书页看剩余有效期,日志中心看访问/错误日志。 + +## 6. DDNS(域名解析自动跟随) + +DDNS 页 → 添加任务:选服务商 → 填 API 凭证(可「测试连接」)→ 添加解析记录(支持 `@`、子域、完整域名)→ 开 IPv4/IPv6 → 保存。 + +**IP 来源两种模式**(任务详情 → 「自定义 IP」按钮设置): + +| 模式 | 填法 | 适用 | +| --- | --- | --- | +| 自动检测(默认) | 自定义 IP 留空 | 域名指向家庭宽带出口 IP | +| 自定义 IP | 填固定公网 IP(如 VPS 的 IP) | 域名直接指向公网服务器 | + +自定义 IP 必须为公网地址,填内网 IP 会被拒绝更新。VPS 动态 IP 场景:自定义 IP 填 VPS 当前 IP,FRP 服务端地址填 DDNS 域名,VPS 换 IP 后自动跟随。 + +## 7. FRP 内网穿透(无公网 IP 时) + +前置:一台有公网 IP 的 VPS。 + +1. **Fonu 添加服务端**:FRP 穿透页 → 添加服务端 → 填 VPS 地址、bindPort(默认 7000)、认证方式与 Token(与 frps 一致); +2. **生成 frps 配置**:服务端「详情」→「frps.toml」→ 复制生成内容到 VPS 保存为 `frps.toml`,执行 `frps -c frps.toml`;按文件末尾的防火墙清单在 VPS 安全组放行端口; +3. **添加穿透规则**:8 种类型按需选——tcp/udp 填远程端口;http/https/tcpmux 填自定义域名(走 frps vhost 端口);stcp/sudp/xtcp 凭 secretKey(无公网监听); +4. **应用并验证**:「应用配置」→「校验并应用配置」;服务端「详情」看五项诊断(TCP 可达 / frpc 登录 / 代理可达 / DNS 解析 / 归属地)与独立日志。 + +## 8. 升级与回滚 + +### 升级 + +```bash +# 1. 备份数据(最重要) +docker run --rm -v fonu-data:/data -v ${PWD}:/backup alpine tar czf /backup/fonu-data-backup.tar.gz /data +# 2. 解压新版源码包覆盖项目目录(VERSION 与 migrations 会更新) +# 3. 重建并启动(迁移 015–022 会在启动时自动应用) +docker compose -f docker-compose.host.yml up -d --build +# 4. 看启动日志确认迁移与版本 +docker logs -f fonu +``` + +### 回滚 + +```bash +docker compose down +# 还原上一版源码目录后: +docker compose -f docker-compose.host.yml up -d --build +# 数据目录如需还原:解压第 1 步的备份 tar 到挂载卷 +``` + +> 注意:数据库迁移只向前应用(`schema_migrations` 记录版本号)。跨大版本回滚前必须先做第 1 步的数据备份,否则旧版代码遇到新表结构可能启动异常。 + +## 9. 故障排查速查 + +| 现象 | 处理 | +| --- | --- | +| 容器起来但 6893 打不开 | `docker logs fonu` 看启动报错;`docker port fonu`(Bridge 模式)确认映射 | +| 提示「会话加密密钥」警告 | 未设置 `FONU_SESSION_SECRET`,compose 传入即可 | +| 反代 502 | 目标内网应用未启动或地址填错;日志中心看 Nginx error 日志 | +| 证书申请失败 | DDNS 凭证是否有效、域名 DNS 是否已生效、ACME 邮箱是否已设置 | +| frpc 连不上 frps | 两端 Token/端口不一致;用服务端「详情 → frps.toml」重新生成对齐 | +| 穿透流量显示 0 | frps 未配置 webServer 或 Fonu 服务端表单未填管理地址(非必须功能) | +| 页面改动未生效 | 确认访问的是容器端口而非开发服务器;`docker compose up -d --build` 重建 | + +## 10. 卸载 + +```bash +docker compose down # 停止并删除容器(数据卷保留) +docker volume rm fonu-data # 彻底删除数据(不可逆,先备份) +docker rmi fonu:local # 删除镜像 +``` diff --git a/docs/FRP公网暴露操作手册.md b/docs/FRP公网暴露操作手册.md new file mode 100644 index 0000000..56a93c1 --- /dev/null +++ b/docs/FRP公网暴露操作手册.md @@ -0,0 +1,170 @@ +# 内网应用经 FRP 暴露公网:NAS 侧完整操作手册 + +> 场景:把 NAS 内网应用(本例 `192.168.31.152:7023`)通过一台公网 VPS(`38.95.74.87`)以域名(`tv.36800.cc`)暴露出去,URL 不带端口号,且后续域名 / HTTPS / 访问控制全部在 NAS 的 Fonu 网页里管理,VPS 只做纯流量转发。 +> 适用版本:Fonu v1.1.0+(FRP 多服务端模块)。VPS 上 **不需要也不应该** 装 Nginx 反代。 + +## 0. 最终架构与访问地址 + +```text +用户浏览器 + ↓ http://tv.36800.cc(80 端口,URL 不带端口) +VPS frps(监听 80 —— 由 frpc 动态注册的 tcp 隧道,VPS 上无需 Nginx) + ↓ FRP 隧道(bindPort 7000,TLS) +NAS frpc + ↓ 127.0.0.1:18080 +Fonu Nginx(按域名分流,后续可加 HTTPS/黑白名单) + ↓ http://192.168.31.152:7023 +内网应用 +``` + +**访问地址:`http://tv.36800.cc`** + +| 角色 | 地址 | 说明 | +| --- | --- | --- | +| 公网 VPS | `38.95.74.87`(tv.36800.cc) | 只跑 frps,Nginx 停用 | +| NAS 内网应用 | `192.168.31.152:7023` | 真正的 Web 服务 | +| Fonu 管理后台 | `http://:6893` | 全部配置入口 | + +## 1. 前置条件 + +- VPS:已部署 frps(bindPort 7000、token 认证;参考 Fonu 服务端详情「frps.toml」一键生成); +- VPS:frps 的 Nginx **未**占用 80(占用见第 5 节排障); +- 云厂商安全组已放行:7000/tcp(frps 控制)、80/tcp(对外)、7500/tcp(frps 管理接口,可选,用于流量统计); +- NAS:Fonu 已部署并能打开管理后台; +- 域名:`tv.36800.cc` 的 DNS A 记录指向 VPS IP(可用 Fonu DDNS 页「自定义 IP」固定,或由 DNS 服务商直接管理)。 + +## 2. Fonu 网页配置(三处) + +### 2.1 FRP 服务端 + +路径:**FRP 穿透 → 添加服务端** + +| 字段 | 值 | +| --- | --- | +| 主机地址 | VPS 的 IP 或域名 | +| 端口 | 7000(= frps bindPort) | +| 认证方式 | Token,与 VPS frps.toml 的 `auth.token` 一致 | +| TLS | 开启(frps 默认接受) | +| frps 管理接口(可选) | 填 frps `webServer` 的地址/端口/用户/密码,用于显示穿透流量 | + +保存后状态应显示「运行中 · 已连接」。 + +### 2.2 穿透规则(核心) + +路径:**FRP 穿透 → 添加规则** + +| 字段 | 值 | 说明 | +| --- | --- | --- | +| 所属服务端 | 上一步的服务端 | | +| 名称 | `tv`(frps 全局唯一,出现在日志里) | | +| 类型 | **tcp** | ⚠️ 不选 http——80 直接由 frps 转发,域名分流交给 Fonu 反代 | +| 远程端口 | **80** | 选 tcp 类型后出现此字段;frpc 登录时向 frps 动态注册 | +| 内网目标 | **127.0.0.1:18080** | 指向 Fonu 自己的 Nginx HTTP 监听(fnOS host 模式默认 18080) | + +保存 → 右上 **「应用配置」→「校验并应用配置」**,或对该服务端「停止→启动」。 + +### 2.3 反向代理(域名分流) + +路径:**反向代理 → 添加规则** + +| 字段 | 值 | +| --- | --- | +| 前端域名 | `tv.36800.cc` | +| 监听端口 | **18080**(与 2.2 的内网目标端口一致;fnOS 下不要用 80,系统 Nginx 已占用) | +| 目标地址 | `http://192.168.31.152:7023`(⚠️ 指向真正的内网应用,完整 URL 带协议与端口) | +| HTTPS | 按需;fnOS 下 Fonu Nginx 的 HTTPS 监听默认 9443 | + +> ⚠️ **回环警告**:目标地址千万不要填 `http://tv.36800.cc:xxxx`——那会让请求从 NAS 出公网再回 NAS(NAS→VPS→NAS 绕圈)。反代目标是**内网应用本身**。 +> ⚠️ **「目标地址缺少主机名」报错**:目标地址格式必须完整 `http://IP:端口`(协议+主机+端口缺一不可,单斜杠 `http:/` 或中文冒号都会报 400)。 + +## 3. VPS 侧配置(一次性的两步) + +### 3.1 frps.toml(Fonu 一键生成) + +Fonu 服务端「详情」→「frps.toml」→ 复制内容到 VPS 保存为 `frps.toml`: + +```toml +bindAddr = "0.0.0.0" +bindPort = 7000 + +auth.method = "token" +auth.token = "<与 Fonu 一致>" + +webServer.addr = "0.0.0.0" # 可选:Fonu 拉取穿透流量 +webServer.port = 7500 +webServer.user = "admin" +webServer.password = "<自定义>" + +log.to = "console" +log.level = "info" +log.maxDays = 3 +log.disablePrintColor = false +``` + +> 注意:**这个文件里没有任何远程端口**。tcp 类型规则的远程端口(80)由 frpc 登录时动态注册,frps 只管 bindPort 和认证——规则改了也不用改这个文件。 + +启动:`./frps -c frps.toml`(建议配 systemd)。 + +### 3.2 释放 80 端口(关键,`port unavailable` 的唯一解法) + +80 只能给一个进程。frps 要绑 80,VPS 上原有的 Nginx 必须停掉: + +```bash +# 确认 80 被谁占用 +ss -tlnp | grep ':80 ' + +# 停止并禁用 Nginx(宝塔面板安装的在宝塔「软件商店」里停) +systemctl stop nginx +systemctl disable nginx + +# 确认已释放(应无输出) +ss -tlnp | grep ':80 ' +``` + +frpc 约每 33 秒自动重试注册,释放后无需任何手动操作,注册成功后 frps 日志出现 proxy 启动记录、frpc 日志的 `start error: port unavailable` 消失。 + +> 如果 VPS 的 80/443 还要跑其他站点(Nginx 不能停),本方案不适用——改回「VPS Nginx 监听 80 → proxy_pass 127.0.0.1:8080(frps vhost)」的方案,FRP 规则用 http 类型,见第 6 节方案对比。 + +## 4. 验证清单 + +| 步骤 | 命令 / 操作 | 预期 | +| --- | --- | --- | +| VPS 80 已监听 | `ss -tlnp \| grep ':80 '` | 进程为 frps | +| frpc 注册成功 | Fonu 服务端「日志」 | 无 `port unavailable`,有 proxy 启动记录 | +| 隧道通 | Fonu 服务端「测速」/「详情」诊断 | TCP 可达、frpc 登录 ok、延迟正常 | +| 分流通 | 服务端「详情」→ 诊断 `proxy_reachable` | ok | +| 端到端 | 外网浏览器 `http://tv.36800.cc` | 直达内网应用页面 | + +## 5. 故障排查(按链路顺序) + +| 现象 | 断点 | 处理 | +| --- | --- | --- | +| `Connection refused`(外部 curl 80) | **VPS 侧**:frps 没绑上 80 | ① `ss -tlnp \| grep ':80 '` 看 80 被谁占(多为 Nginx 没停)→ 停用;② `systemctl status frps` 确认在跑且用的新配置(nohup 手动跑的必须 `pkill frps` 重启);③ 重启后等 frpc 自动重试 | +| frpc 日志 `start error: port unavailable` | 同上 | VPS 80 被占;见 3.2 | +| 诊断「frpc 登录」失败 | Token/端口不一致 | 两端 token 对齐;端口=frps bindPort | +| 登录成功但 `proxy_reachable` 失败 | Fonu 反代 | 反代规则监听端口与 FRP 内网目标端口一致(18080);反代已启用 | +| 打开域名 404 | 域名分流失败 | 反代规则的前端域名与访问域名完全一致;frps 无需 vhost(tcp 方案) | +| 域名解析不对 | DNS | `nslookup tv.36800.cc` 应返回 VPS IP;用 DDNS 自定义 IP 固定 | +| 流量统计全 0 | frps webServer 未配 | 非必须;要在 Fonu 看流量就配 webServer 并在服务端表单填管理接口 | + +## 6. 两种方案对比(VPS 80 的归属) + +| | 方案 A:NAS 侧全管(本文) | 方案 B:VPS Nginx 反代 | +| --- | --- | --- | +| FRP 规则类型 | tcp,远程端口 80 → 127.0.0.1:18080 | http 类型 + frps `vhostHTTPPort=8080` | +| http 类型去端口 | —(tcp 方案直接占 80) | `vhostHTTPPort` 从 8080 改 **80**:URL 变 `http://域名`(无端口);前提同样是 VPS 停 Nginx 释放 80,改后 `pkill frps` 重启生效;NAS 侧规则无需动 | +| VPS Nginx | 不需要(停用) | 必须:listen 80 → proxy_pass 127.0.0.1:8080(**必须带** `proxy_set_header Host $host`) | +| 加新域名 | Fonu 反代加一条 + FRP 加一条 tcp 规则(远程端口仍 80,内网目标同 18080) | VPS 上加 server 块 | +| 上 HTTPS | Fonu 证书页(DNS-01)+ 反代开 HTTPS + FRP 加 tcp 443→127.0.0.1:9443 | VPS 上装证书 | +| 访问控制(IP/BasicAuth) | Fonu 反代页直接配 | VPS 上手写 | +| 限制 | VPS 80/443 不能再跑别的站点 | VPS 可共存多站点 | +| URL | `http://tv.36800.cc`(无端口) | `http://tv.36800.cc`(80)或带 vhost 端口 | + +选型:VPS 专用做穿透入口 → 方案 A;VPS 80/443 另有他用 → 方案 B。两方案不混用(80 归属唯一)。 + +## 7. HTTPS 升级路径(方案 A 延伸) + +1. **Fonu 证书页**:申请 `tv.36800.cc` 或 `*.36800.cc` 证书(ACME DNS-01,复用 DDNS 页服务商凭证); +2. **反代规则**:开 HTTPS、绑定证书、(可选)HTTP 强制跳转; +3. **FRP 加一条 tcp 规则**:远程端口 443 → 内网目标 `127.0.0.1:9443`(Fonu Nginx HTTPS 监听); +4. 安全组放行 443;之后 `https://tv.36800.cc` 可用,TLS 全程在 NAS 侧终止。 diff --git a/docs/agent-proxy-roadmap.md b/docs/agent-proxy-roadmap.md new file mode 100644 index 0000000..6d749e3 --- /dev/null +++ b/docs/agent-proxy-roadmap.md @@ -0,0 +1,163 @@ +# 公网反代(Agent 页「公网 Nginx 反代」区)功能增强规划 + +> 基于实际部署踩坑史(目录探测、证书缺失、端口漂移、类型错配均发生过)梳理, +> 每项标注:价值 / 实现成本 / 依赖。供后续逐项开发选用。 +> 更新日期:2026-09-21 + +--- + + +第一梯队(最推荐先做,1-2 天) +功能 解决什么 +① 部署后在线验证 部署成功后 agent 在 VPS 上 curl -H "Host: 域名" 127.0.0.1:9080,页面直接显示「回源自检 200 ✓」或「回源 502 ✗(隧道未通)」——你昨天遇到的“部署成功但打开 502”当时就能看见断在哪层 +③ 状态徽章实时化 「已部署」升级为「已生效 / 已写入未加载 / 未部署」三态(nginx -T 真实验证),tcp/ 目录那种“写了但不加载”的残留一眼可见 +⑥ WebSocket 开关 家庭影院等实时服务的标配,一个开关 + 模板片段,成本极低 +第二梯队(反代能力追平本地反代) +② 配置查看/自定义:每条规则可看生成的 nginx 配置、可改(WebSocket 头、特殊 location 不再需要 SSH)——本地反代已有 auto/custom 模式可直接复用; + +④ HTTP 强制跳转 HTTPS、⑤ IP 白名单 / Basic Auth——公网暴露面的安全标配。 + +第三/四梯队(多规则后有价值) +批量同步、部署历史回滚、域名健康检查面板(含证书剩余天数)、自定义 upstream、流量统计、错误日志快捷查看——文档里都有具体设计。 + +文档末尾附了配套 API 草案(4 个端点 + agent 侧新增项),实现时按这个走即可。说一声做哪几个,我按第一批开始。 + + + + + + + + +## 现状回顾(已具备) + +- 手动部署 / 移除单条规则的反代(部署时自动推证书、自动读最新 vhost 端口); +- VPS 当前已加载路由展示(`agentStatus.routes`); +- 反代文件按 `fonu-<域名>.conf` 写入 Nginx 实际加载目录(`-T` 试探-验证)。 + +--- + +## 第一梯队:立刻有用(解决「部署了≠生效」的可见性) + +### 1. 部署后在线验证(强烈推荐,第一个做) + +部署成功后 agent 自动做一次真实探测并把结果返回给页面: + +```text +在 VPS 上执行: +curl -s -o /dev/null -w "%{http_code}" -H "Host: <域名>" http://127.0.0.1: +``` + +- 页面显示「部署成功 · 回源自检 200 ✓」或「部署成功 · 但回源 502 ✗(隧道未通)」; +- 直接区分「反代层 OK / 隧道层断」,消灭"部署成功但打开 502"这类排障; +- 成本:低(agent 加一个验证步骤 + 前端展示字段)。 + +### 2. Nginx 配置内容查看 / 自定义 + +每条规则提供「查看生成的 nginx 配置」弹窗(对齐本地反代已有的 auto/custom 模式): + +- auto:显示 agent 生成的配置(只读); +- custom:用户编辑后下发,agent 写入文件并 reload; +- 排障时可确认「文件里到底写了什么」;特殊需求(WebSocket 头、自定义 location、缓存策略)不再需要 SSH; +- 成本:中(agent 加 GET/PUT conf 接口;本地反代的 nginx_config.go 模式可直接复用)。 + +### 3. 反代状态徽章的实时化 + +「已部署 / 未部署」目前基于 `routes` 文件名推断,升级为真实状态: + +- 检查文件存在 ✓ + `nginx -t` 通过 ✓ + 域名出现在 `nginx -T` ✓ → 「已生效」; +- 文件在但 `nginx -T` 没有它 → 「已写入未加载」(目录不对的残留场景); +- 成本:低。 + +## 第二梯队:安全与可控性(公网暴露面的标配) + +### 4. 一键 HTTPS 强制跳转 + +生成的配置加一段 80 → 443 的 301 redirect(现在 http 和 443 是两个平行的 server)。 + +- 表单按规则勾选「HTTP 跳转 HTTPS」; +- 成本:低。 + +### 5. 访问控制(IP 白名单 / Basic Auth) + +公网服务(尤其路由器/摄像头等敏感入口)的标配: + +- IP 白名单:`allow x.x.x.x; deny all;` 列表来自表单; +- Basic Auth:agent 生成 htpasswd 并在 location 中引用; +- 成本:中。 + +### 6. WebSocket 支持开关 + +生成配置追加 `proxy_set_header Upgrade $http_upgrade; Connection "upgrade";`(群晖/家庭影院等实时服务需要)。 + +- 成本:低(一个开关 + 模板片段)。 + +## 第三梯队:运维效率(多规则后才有价值) + +### 7. 批量部署 / 全部同步 + +「一键同步全部规则」:对当前服务端所有候选规则循环部署,展示每条的成败。 +配合规则增删后的批量重建场景。 + +- 成本:低-中。 + +### 8. 部署历史与回滚 + +每次部署在 agent 侧留 `fonu-<域名>.conf.bak-<时间戳>`(保留最近 N 份), +页面提供「查看历史 / 回滚到此版本」。 + +- 误操作(比如改坏了 upstream)30 秒恢复; +- 成本:中。 + +### 9. 域名健康检查面板 + +页面顶部一行:每个已部署域名的公网可达性(HTTPS 状态码 + 证书剩余天数), +由 Fonu 侧定时探测(复用服务端诊断的 TTL 缓存模式)。 + +- 证书临期/隧道断链主动可见; +- 成本:中。 + +## 第四梯队:锦上添花 + +### 10. 自定义 upstream 覆盖 + +允许某条规则的反代目标不跟随穿透规则(例如指向本机 Nginx:80 做二次分流), +默认仍自动取穿透规则的 vhost 回源。 + +- 成本:低。 + +### 11. 流量统计接入 + +frps dashboard 已有连接数/流量数据(Fonu 正在拉取),按域名聚合展示到反代列表, +可见每条反代的实际使用量。 + +- 成本:中。 + +### 12. Nginx 错误日志快捷查看 + +agent 提供 `tail /www/wwwlogs/<域名>.error.log`,规则行内展开最近 20 行。 + +- 成本:低。 + +--- + +## 建议路线 + +```text +第一批(1-2 天):① 部署后在线验证 + ③ 状态徽章实时化 + ⑥ WebSocket 开关 + → 直接消灭「部署成功但不通」的灰区 +第二批(2-3 天):② 配置查看/自定义 + ④ 强制跳转 + ⑤ 访问控制 + → 反代层能力追平本地反代 +第三批(按需):⑦⑧⑨⑩⑪⑫ +``` + +## 附:接口草案(第一/二批共用) + +```text +GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/conf # 查看生成配置 +PUT /api/frpmulti/servers/{id}/agent/routes/{proxyID}/conf # 自定义配置下发 +POST /api/frpmulti/servers/{id}/agent/routes/{proxyID}/verify # 部署后在线验证 +POST /api/frpmulti/servers/{id}/agent/routes/batch # 批量同步 +``` + +agent 侧新增:`GET/PUT /api/v1/routes/{domain}/conf`、验证逻辑复用部署链路的探测段。 diff --git a/docs/agent-proxy-settings-plan.md b/docs/agent-proxy-settings-plan.md new file mode 100644 index 0000000..0badb18 --- /dev/null +++ b/docs/agent-proxy-settings-plan.md @@ -0,0 +1,209 @@ +# 公网反代「部署设置」对齐本地反代 —— 方案设计 + +> 目标:把本地反向代理(`ProxyView.vue` 规则弹窗)中真正有价值的安全与运维设置,按公网反代的语义迁移到 +> 「公网反代」页(`/public-proxy`,`ProxyRemoteView.vue`)的「部署设置」中,由 fonu-agent 生成对应的 Nginx vhost 配置。 +> +> 原则:**公网反代只管「公网入口」这一层**。凡是会破坏 frps vhost 路由、或本质属于「本机内网分流」的选项, +> 一律不迁移(见「明确不做」一节)。 +> +> 更新日期:2026-09-22 + +--- + +## 建议 +「部署设置」弹窗从现在的单页改为**三个 Tab(基础 / 安全 / 高级)**,与本地反代弹窗结构对齐: + +- +**基础**:启用 HTTPS、强制跳转、WebSocket、上传大小、代理超时 + +- +**安全**:IP 白名单 / 黑名单、Basic Auth(用户名 + 可选密码) + +- +**高级**:仅 TLS 1.3、安全响应头、限流、连接数限制 + +第一梯队 7 项都是 agent 模板内改动 + Fonu 参数透传,风险低、见效快;第二梯队 3 项需要 agent 多写一个 http 上下文文件。说一声我从第一梯队开始做,或者指定要哪几项。 + + + +## 一、现状对照 + +### 本地反代(ProxyView.vue)规则弹窗现有设置项 + +| 分组 | 设置项 | +| --- | --- | +| 基础 | 名称、前端域名、目标地址、**启用 HTTPS**、**HTTP 跳转 HTTPS**、启用规则 | +| 安全 · IP 访问控制 | 仅中国大陆 IP、黑名单模式 + 列表、白名单模式 + 列表 | +| 安全 · 认证 | Basic Auth(用户名 + **自定义密码**) | +| 安全 · 流量控制 | 请求限流(速率 / 突发)、连接数限制(每 IP 并发) | +| 安全 · 高级 | 忽略后端 TLS 证书、使用目标 Host 头、仅 TLS 1.3、安全响应头 | +| Nginx | auto / custom 自定义配置 | + +### 公网反代(agent `nginxRoute` 模板)现有能力 + +`internal/agent/server.go` 的 `routeRequest` 与 `nginxRoute` 已支持: + +- `domain` / `upstream`(`127.0.0.1:`)/ `tls` + `cert_dir`; +- `websocket`(Upgrade / Connection 头); +- `redirect_https`(80 → 443 的 301 + 443 TLS server 两块); +- `allow_ips`(location 内 allow + deny all); +- `basic_auth_user`(密码由 agent 自动生成并落盘 htpasswd); +- 默认头:Host / X-Real-IP / X-Forwarded-For。 + +Fonu 侧对应链路:`ProxyOptions`(`internal/frp/model.go`)→ `DeployAgentRoute` → `AgentClient.SyncRouteEx` +→ `PUT /api/v1/routes`。 + +--- + +## 二、迁移清单 + +### 第一梯队:纯模板级改动,无 Nginx 上下文限制(建议先做) + +| # | 功能 | 对应本地项 | 生成的 Nginx 指令 | +| --- | --- | --- | --- | +| 1 | **启用 HTTPS 开关** | 启用 HTTPS | 关闭时仅 `listen 80`;开启且证书已推送时 `listen 443 ssl` | +| 2 | **安全响应头** | 安全响应头 | `add_header Strict-Transport-Security / X-Frame-Options / X-Content-Type-Options / Referrer-Policy` | +| 3 | **仅 TLS 1.3** | 仅 TLS 1.3 | `ssl_protocols TLSv1.3;` | +| 4 | **客户端最大上传大小** | (本地为全局默认) | `client_max_body_size 50m;`(server 级,按规则可覆盖) | +| 5 | **代理超时** | (本地固定 3600s) | `proxy_read_timeout 3600s;`(长轮询 / 流媒体 / 大文件必需) | +| 6 | **IP 黑名单** | 黑名单模式 | location 内 `deny x.x.x.x;`(与现有白名单对称) | +| 7 | **Basic Auth 自定义密码** | Basic Auth 密码 | 用表单密码写 htpasswd;留空则保持「首次自动生成」 | + +### 第二梯队:需要 Nginx `http` 上下文或额外数据下发(成本中等) + +| # | 功能 | 关键约束 | +| --- | --- | --- | +| 8 | **请求限流** | `limit_req_zone` 只能出现在 `http` 块。agent 需额外生成 `fonu-limits.conf`(宝塔将 vhost 目录 include 在 `http{}` 内,因此该文件顶层指令即 http 上下文),各域名 server 内 `limit_req zone=fonu_<域名>_req burst=N nodelay;` | +| 9 | **连接数限制** | 同上,`limit_conn_zone` + `limit_conn fonu_<域名>_conn N;` | +| 10 | **仅中国大陆 IP** | 需 `geo` 模块 + 中国 IP 段下发到 VPS(Fonu 已有 china cidr 模块,7456 条 v4),下发体积最大、成本最高,建议最后做 | + +### 明确不做(字段 / 语义不匹配) + +| 功能 | 原因 | +| --- | --- | +| 使用目标 Host 头 | 公网回源目标是 frps 的 vhost 端口,**frps 靠 Host 头匹配隧道**。改成上游 Host 会让 vhost 无法路由,直接 404。必须保持 `$host` | +| 忽略后端 TLS 证书 | 公网 upstream 是 `http://127.0.0.1:`,不涉及上游 TLS 校验,无作用 | +| 自定义 upstream 覆盖 | 技术上可行,但会让流量绕过 frps 隧道(健康检查「隧道」必然 ✗),语义属于「本机内网分流」,只在本地反代页做 | + +--- + +## 三、字段契约 + +两侧 JSON key 必须严格一致(历史教训:字段未逐项对照导致多轮返工)。 + +| 显示名 | `ProxyOptions`(Go,`internal/frp/model.go`) | JSON key | TS(`web/src/api/types.ts`) | `routeRequest`(agent) | Nginx 指令 | +| --- | --- | --- | --- | --- | --- | +| 启用 HTTPS | `HTTPSDisabled bool` | `https_disabled` | `https_disabled?: boolean` | `HTTPSDisabled bool` | 关闭时不出 `listen 443` | +| 上传大小 | `ClientMaxBodySize string` | `client_max_body_size` | `client_max_body_size?: string` | `ClientMaxBodySize string` | `client_max_body_size 50m;` | +| 代理超时 | `ProxyReadTimeout string` | `proxy_read_timeout` | `proxy_read_timeout?: string` | `ProxyReadTimeout string` | `proxy_read_timeout 3600s;` | +| 安全响应头 | `SecurityHeaders bool` | `security_headers` | `security_headers?: boolean` | `SecurityHeaders bool` | `add_header ...` | +| 仅 TLS 1.3 | `TLS13Only bool` | `tls13_only` | `tls13_only?: boolean` | `TLS13Only bool` | `ssl_protocols TLSv1.3;` | +| IP 黑名单 | `DenyIPs []string` | `deny_ips` | `deny_ips?: string[]` | `DenyIPs []string` | `deny x;` | +| Basic Auth 密码 | `BasicAuthPassword string` | `basic_auth_password` | `basic_auth_password?: string` | `BasicAuthPassword string` | 写入 htpasswd | +| 请求限流(速率) | `RateLimitRate int` | `rate_limit_rate` | `rate_limit_rate?: number` | `RateLimitRate int` | `limit_req_zone ... rate=Nr/s;` | +| 请求限流(突发) | `RateLimitBurst int` | `rate_limit_burst` | `rate_limit_burst?: number` | `RateLimitBurst int` | `limit_req ... burst=N` | +| 连接数限制 | `ConnLimitMax int` | `conn_limit_max` | `conn_limit_max?: number` | `ConnLimitMax int` | `limit_conn_zone` / `limit_conn` | +| 仅中国大陆 | `ChinaOnly bool` | `china_only` | `china_only?: boolean` | `ChinaOnly bool` | `geo` + `allow`/`deny` | + +约束: + +- 这些字段**只作用于公网 Nginx 模板,绝不写入 frpc.toml**(与现有 `websocket` / `redirect_https` 等一致,在 `ProxyOptions` 上以注释标明); +- 「启用 HTTPS」采用**反向字段 `https_disabled`**:字段缺省时保持现有行为(证书推送成功即启用 TLS), + 保证新 Fonu + 旧 agent、旧数据不出现行为突变。 + +--- + +## 四、实施步骤 + +### 阶段 1:第一梯队后端(agent + Fonu) + +1. `internal/agent/server.go` + - `routeRequest` 增加第一梯队字段; + - `nginxRoute` 模板按字段输出指令:`listen 443` 受 `HTTPSDisabled` 控制;`redirect_https` 仅在 TLS 实际启用时生成; + - `client_max_body_size` / `proxy_read_timeout` / 安全响应头 / `ssl_protocols` / `deny` 列表; + - Basic Auth:传入密码时 `htpasswd -nbB` 写入,未传则沿用现有「首次自动生成 + 复用」。 +2. `internal/frp/model.go`:`ProxyOptions` 增加同名 Go 字段(含注释)。 +3. `internal/frp/agent_client.go`:`SyncRouteEx` 参数改为**传结构体**(当前 10 个位置参数已到上限,继续追加易错): + ```go + type RouteSpec struct { + Domain, Upstream, CertDir string + TLS, WebSocket, RedirectHTTPS, HTTPSDisabled bool + VhostPort int + AllowIPs, DenyIPs []string + BasicAuthUser, BasicAuthPassword string + ClientMaxBodySize, ProxyReadTimeout string + SecurityHeaders, TLS13Only bool + } + func (c *AgentClient) SyncRouteSpec(ctx, spec RouteSpec) (map[string]any, error) + ``` + 保留 `SyncRoute` / `SyncRouteEx` 作为兼容包装。 +4. `internal/frp/service.go`:`DeployAgentRoute` 从 `proxy.Options` 组装 `RouteSpec` 透传。 + +### 阶段 2:第一梯队前端 + +5. `web/src/api/types.ts` 补 `FrpProxyOptions` 字段; +6. `ProxyRemoteView.vue`「部署设置」弹窗改为三个 Tab(与本地反代结构对齐): + - **基础**:启用 HTTPS、HTTP 强制跳转、WebSocket、上传大小、代理超时; + - **安全**:IP 白名单、IP 黑名单、Basic Auth(用户名 + 可选密码); + - **高级**:仅 TLS 1.3、安全响应头(第二梯队上线后再加限流/连接数/中国 IP)。 + +### 阶段 3:第二梯队(按需) + +7. agent 增加 `fonu-limits.conf` 生成器(http 上下文 zone 定义)+ 每域名 server 引用; +8. 中国 IP:Fonu 将 china cidr 列表推送到 agent(`/var/lib/fonu-agent/geo/china.conf`),agent 生成 `geo` 块。 + +--- + +## 五、验证方案 + +每次改动后全量跑(项目既有闭环): + +```powershell +cd f:\Fonu-main\Fonu-1.0.2 +gofmt -w internal/agent/server.go internal/frp/model.go internal/frp/agent_client.go internal/frp/service.go +go build ./... ; go vet ./internal/frp ./internal/agent ./internal/api +go test ./internal/frp ./internal/api +cd web ; npx vue-tsc --noEmit ; npx vite build +``` + +agent 二进制需按既有流程交叉编译(amd64 + arm64)并升级: + +```powershell +$env:GOOS='linux'; $env:GOARCH='amd64'; $env:CGO_ENABLED='0' +go build -trimpath -o internal/frp/agentbin/fonu-agent-linux-amd64.new ./cmd/fonu-agent +# arm64 同理;Move-Item 覆盖后,在「公网 Agent」页执行「升级 Agent」 +``` + +VPS 侧实测(部署 7023 规则后): + +```bash +nginx -t # 语法 +cat /www/server/panel/vhost/nginx/fonu-7023.36800.cc.conf # 配置内容(或页面「查看配置」) +curl -I http://7023.36800.cc # 强制跳转 → 301 +curl -sI -H 'Host: 7023.36800.cc' http://127.0.0.1:9080 # 回源自检 +curl -k -sI https://7023.36800.cc # 响应头(HSTS / X-Frame-Options) +openssl s_client -connect 7023.36800.cc:443 -tls1_2 基于 frp v0.71.0 官方 `frps_full_example.toml` 全量对照。 +> 状态图例:✅ 已实现 🔶 部分实现(有缺口或需手动步骤) ⬜ 未实现 🚫 有意不实现(有说明理由) +> +> 「Fonu 入口」= 用户在 Fonu 界面操作的位置;「生成器」= `internal/frp/frps_template.go` 的 frps.toml 同源生成器。 +> 更新日期:2026-09-21 + +--- + +## 一、网络与监听([common] 顶层键) + +| 配置项 | 官方默认 | 状态 | 说明 | +| --- | --- | --- | --- | +| `bindAddr` | `0.0.0.0` | ✅ | 固定生成 `0.0.0.0` | +| `bindPort` | `7000` | ✅ | 取服务端表单「端口」,与 frpc 同源 | +| `kcpBindPort` | 0(禁用) | ✅ | 表单「协议」选 kcp 时生成 | +| `quicBindPort` | 0(禁用) | ✅ | 表单「协议」选 quic 时生成 | +| `proxyBindAddr` | 同 bindAddr | ⬜ | 未提供(UDP/HTTP 代理单独绑定地址的场景才需要) | +| `vhostHTTPPort` | 0(禁用) | ✅ | 有 http/https 规则时生成;**表单「远程管理 → frps vhost 反代端口」可自定义**,默认 8080 | +| `vhostHTTPSPort` | 0(禁用) | ✅ | 同上,默认 8443 | +| `vhostHTTPTimeout` | 60 | ⬜ | 未提供(vhost 响应超时,一般无需改) | +| `tcpmuxHTTPConnectPort` | 0(禁用) | ⬜ | tcpmux 需要它(存在 tcpmux 规则时生成器只提示 vhost,未生成此键) | +| `tcpmuxPassThrough` | false | ⬜ | 未提供 | + +## 二、认证(auth.*) + +| 配置项 | 官方默认 | 状态 | 说明 | +| --- | --- | --- | --- | +| `auth.method`(token) | `token` | ✅ | 与 frpc 同源;Token 加密存储,生成时解密写入 | +| `auth.token` | — | ✅ | 同上 | +| `auth.method`(none) | — | ✅ | 生成安全提示注释 | +| `auth.oidc.*`(issuer/clientID/clientSecret/audience/scope/tokenEndpointURL 等) | — | 🔶 | frpc 侧 OIDC 表单已支持;frps 侧校验参数依赖 IdP,生成器只输出注释指引手动配置 | + +## 三、TLS(transport.tls.*) + +| 配置项 | 官方默认 | 状态 | 说明 | +| --- | --- | --- | --- | +| `transport.tls.force` | false | 🔶 | frpc 侧未启用 TLS 时生成**注释**建议开启;无表单开关直接生成 | +| `transport.tls.certFile / keyFile` | — | ⬜ | frps 侧 TLS 证书未提供(frpc 侧证书表单已有,frps 反向 TLS 需另加) | +| `transport.tls.trustedCaFile` | — | ⬜ | 未提供 | + +## 四、管理面板(webServer.*) + +| 配置项 | 官方默认 | 状态 | 说明 | +| --- | --- | --- | --- | +| `webServer.addr / port` | `127.0.0.1:0` | ✅ | 表单「frps 管理接口」地址/端口;Fonu 借此拉流量;「frps 管理」按钮直达面板 | +| `webServer.user / password` | — | ✅ | 表单同源;密码加密存储(ConfiguredSecretField 掩码闭环) | +| `webServer.tls.certFile / keyFile` | — | ⬜ | dashboard 自身 HTTPS 未提供 | + +## 五、HTTP / 子域名(vhost 语义) + +| 配置项 | 官方默认 | 状态 | 说明 | +| --- | --- | --- | --- | +| `subDomainHost` | "" | 🔶 | **缺口检测已实现**:规则填了 subdomain 时生成器输出注释提醒必须配置;但表单无字段、不能直接生成 | +| `custom404Page` | — | ⬜ | 未提供 | +| `httpPlugins`(server 端) | — | ⬜ | frps 端 HTTP 插件未提供(frpc 端 8 种插件表单已支持,两者是不同层的功能) | + +## 六、限流与配额 + +| 配置项 | 官方默认 | 状态 | 说明 | +| --- | --- | --- | --- | +| `allowPorts`(端口白名单/范围) | 不限 | ⬜ | 未提供 | +| `maxPortsPerClient` | 0(不限) | ⬜ | 未提供 | +| `userConnTimeout` | 10 | ⬜ | 未提供 | + +## 七、传输调优(transport.* 其余) + +| 配置项 | 官方默认 | 状态 | 说明 | +| --- | --- | --- | --- | +| `transport.tcpMux` | true | ✅ | 与 frpc 同源;仅关闭时输出 | +| `transport.tcpMuxKeepaliveInterval` | 30 | ✅ | 表单「高级配置」同源(frpc 侧);frps 生成器未输出该键,两端不一致时 TCPMux keepalive 各用默认值——**低风险缺口** | +| `transport.maxPoolCount` | 0(按 frpc) | ✅ | 有 pool_count 时输出 | +| `transport.heartbeatInterval / timeout` | 30/90 | ✅ | frpc 侧表单同源;frps 生成器不输出(服务端跟随客户端心跳,官方无需配置) | +| `transport.tls.force` | — | 🔶 | 见第三节 | + +## 八、运行与日志 + +| 配置项 | 官方默认 | 状态 | 说明 | +| --- | --- | --- | --- | +| `log.to / level / maxDays` | console | ⬜ | frps 日志走 journalctl(agent 采集尾部显示),未提供文件日志配置生成 | +| `log.disablePrintColor` | false | ⬜ | 未提供 | +| `detailedErrorsToClient` | true | ⬜ | 未提供 | +| `printStats`(每 30s) | — | ⬜ | 未提供 | + +## 九、用户隔离与资产 + +| 配置项 | 官方默认 | 状态 | 说明 | +| --- | --- | --- | --- | +| 多用户 `user` 前缀隔离 | — | ⬜ | 未提供(frps 端 `user` 是 frpc 登录名前缀隔离,与 Fonu 的服务端 user 概念不同) | +| `udpPacketSize` | 1500 | ⬜ | 未提供(需与 frpc 同源,frpc 侧也无此表单项) | + +## 十、Fonu Agent 侧的 frps 运维能力(与配置生成配套) + +| 功能 | 状态 | 说明 | +| --- | --- | --- | +| 一键安装 / 升级(SSH) | ✅ | 原子替换 + `-version` 校验,失败不伪报成功 | +| systemd 托管(frps.service) | ✅ | 坏 unit 自动接管重写 | +| 状态可视(进程/端口/版本/日志尾/dashboard 地址) | ✅ | 「公网 Agent」页 | +| 启动 / 停止 / 重启 | ✅ | 含 `ok:false` 失败契约与 journal 透出 | +| 配置下发(同源 frps.toml) | ✅ | 「下发 frps 配置」按钮 | +| 官方版本安装 / 升级(下拉选择) | ✅ | 含下载代理 | +| 证书下发(HTTPS 规则) | ✅ | `/api/v1/certs` | +| **公网 Nginx 反代手动部署 / 移除** | ✅ | 试探-验证式目录自动发现(`-T` 加载验证),规则保存与反代解耦 | +| **vhost 端口自定义** | ✅ | 表单 → frps.toml → Nginx upstream 三方同源 | + +--- + +## 优先级建议(下一步最值得补的) + +1. **`subDomainHost` 表单字段**(🔶):subdomain 功能目前只能靠手动改 frps.toml,是 vhost 体系里最后一块手动拼图;配合 `custom404Page` 一起做收益最高; +2. **`transport.tls.force` 开关**(🔶):一条注释提示不如一个开关——公网 frps 的标准安全实践; +3. **`allowPorts` / `maxPortsPerClient`**(⬜):多用户/防滥用场景需要;单用户自用优先级低; +4. **`tcpmuxHTTPConnectPort`**(⬜):存在 tcpmux 规则时的硬依赖,用到 tcpmux 的话必须补; +5. **frps 日志配置**(⬜):journalctl 尾部只能看 6 行,排障时不够;生成 `log.to = "/var/log/frps.log"` 即可接入现有日志查看。 + +## 附:架构口径 + +- **frps.toml 是生成器产物,不是用户手写文件**:所有已实现项均以 Fonu 服务端表单为唯一事实源,VPS 侧禁止手改(改了会被下次「下发 frps 配置」覆盖); +- **端口同源链**:表单 `vhost_http_port/vhost_https_port` → frps.toml `vhostHTTPPort/vhostHTTPSPort` → Nginx `proxy_pass 127.0.0.1:`,三方由 `agentUpstreamFor`/生成器共用同一值; +- **agent 与 frps 解耦**:agent 只管 Nginx 片段/证书/frps 生命周期,frps 进程由 systemd 托管;二者通过 Fonu 下发配置保持一致。 diff --git a/docs/frps服务端管理方案.md b/docs/frps服务端管理方案.md new file mode 100644 index 0000000..2946832 --- /dev/null +++ b/docs/frps服务端管理方案.md @@ -0,0 +1,199 @@ +# FRPS 服务端管理方案(fonu-agent,部署于公网服务器) + +> 需求:frpc 端(Fonu/NAS)提交穿透规则后,**公网服务器端自动完成反向代理设置**,实现域名无端口访问(`http://tv.36800.cc`),并自动管理服务器侧证书与 frps。 +> 实现形态:在公网 VPS 上部署一个常驻管理组件 **fonu-agent**(单个 Go 二进制 + systemd),对外提供管理 API;Fonu 在规则变化时调用该 API,agent 在 VPS 本地完成 Nginx 反代配置、frps 管理与证书部署。 +> **安装与管理通道**:agent 的**首次安装、升级、卸载通过 SSH 由 Fonu 远程完成**(用户在 Fonu 界面填 SSH 信息点按钮即可,无需手工登 VPS);装好后 agent 以 **API** 接受日常管理。管理面板全部在本地 Fonu,VPS 上没有任何面板。 +> 修订记录:v2 混合形态(SSH 装/卸 + API 管理);v1 为纯 SSH 推送方案,v2 初稿为「agent 纯 API」方案,两版历史见文末对照。 + +## 1. 目标与效果 + +| 诉求 | 达成方式 | +| --- | --- | +| frpc 提交规则后 frps 端自动反代 | Fonu 保存规则 → 调 agent API 注册域名路由 → agent 写 Nginx vhost 并 reload | +| 无端口访问 | agent 的 Nginx 监听 80(443),`proxy_pass` 到 frps vhost 端口,URL 不带端口号 | +| 管理证书 | Fonu 证书页(DNS-01)签发后一键推送到 agent,自动挂 443 并续期重推 | +| frps 管理 | 配置下发(vhost 端口等)、进程重启/状态/版本、端口占用诊断,全部 API 化 | +| VPS 上已有 Nginx/站点 | 共存:agent 只写自己的 vhost 文件(`conf.d/fonu-*.conf`),不动其他配置 | + +最终链路(http 规则为例): + +```text +用户 → http://tv.36800.cc(80,无端口) + → VPS Nginx(agent 生成的 vhost)→ 127.0.0.1:8080(frps vhostHTTPPort) + → frps 按域名路由 → FRP 隧道 → NAS frpc → 192.168.31.152:7023 +``` + +## 2. 总体架构 + +```text +Fonu (NAS,管理面板所在地) 公网服务器 (VPS) +┌──────────────────────────────┐ ┌────────────────────────────────┐ +│ FRP 模块(现有) │ │ frps :7000 控制 / 8080 vhost │ +│ 规则 CRUD / frpc / frps.toml │ │ nginx :80/443 │ +│ 证书页(lego DNS-01,已有) │ │ └─ fonu-*.conf(agent 产物) │ +│ ┌────────────────────┐ │ │ fonu-agent :7700(常驻) │ +│ │ SSH 安装器(新增) │──SSH──┼→ 首次安装/升级/卸载 agent │ +│ │ agent 客户端(新增) │──API──┼→ 日常管理:路由/配置/证书/状态 │ +│ └────────────────────┘ │ │ ├─ 路由管理(nginx vhost 编排) │ +│ 管理面板 = Fonu 网页 │ │ ├─ frps 管理(配置/进程/诊断) │ +│ (规则页/证书页/管理弹窗) │ │ └─ 证书接收与挂载 │ +└──────────────────────────────┘ └────────────────────────────────┘ +``` + +两条通道各司其职: + +- **SSH(低频)**:仅用于 agent 生命周期——安装、升级、卸载、agent 失联自救。用户在 Fonu 填一次 SSH 信息(可选保存备用); +- **API(高频)**:日常全部管理——规则同步、frps 配置下发、证书推送、状态查询。面板 100% 在 Fonu 本地,VPS 上没有任何管理界面。 + +职责划分:Fonu 是**控制面**(用户在哪配置就存哪),agent 是**执行面**(只接收指令并落实),frps/Nginx 是**数据面**。agent 无状态(配置即文件),可随时重装。 + +## 3. fonu-agent 设计(VPS 侧) + +单个 Go 二进制(复用主项目代码编译,`cmd/fonu-agent`),systemd 常驻,管理 API 监听 `:7700`(可配置)。 + +### 3.1 管理 API(Bearer Token 鉴权) + +| 方法/路径 | 作用 | +| --- | --- | +| `GET /api/v1/ping` | 连通性/鉴权校验(Fonu「测试连接」按钮) | +| `GET /api/v1/status` | frps 版本/运行方式/配置路径、80/443/7000/8080 端口占用者、已注册路由、证书有效期 | +| `PUT /api/v1/routes` | 注册/更新域名路由:`{domain, upstream(=frps vhost 端口), tls, cert_id}` → 写 Nginx vhost → `nginx -t` → reload(失败自动回滚文件) | +| `DELETE /api/v1/routes/{domain}` | 注销路由(规则删除/停用时调用) | +| `GET /api/v1/routes` | 当前路由清单(对账用) | +| `PUT /api/v1/frps/config` | 下发完整 frps.toml(Fonu 同源生成)→ 备份旧文件 → 按运行方式重启/重载 → 回读验证 | +| `POST /api/v1/certs` | 部署证书:`{domain, fullchain_pem, key_pem}` → 写 `/var/lib/fonu-agent/certs//`(0600)→ 更新 443 vhost → reload | + +### 3.2 Nginx 编排(反代核心) + +- 每个域名一个文件:`/etc/nginx/conf.d/fonu-tv.36800.cc.conf`,模板: + +```nginx +server { + listen 80; + server_name tv.36800.cc; + location / { + proxy_pass http://127.0.0.1:8080; # frps vhostHTTPPort + proxy_set_header Host $host; # frps 按域名分流,必须保留 + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_http_version 1.1; + } +} +# 启用证书后追加 443 server 块(ssl_certificate 指向 agent 证书目录)+ 80 跳转 +``` + +- 写入流程:写 `.tmp` → `nginx -t` 校验 → 通过则替换并 `nginx -s reload`,失败保留原文件并把 `nginx -t` 输出回传给 Fonu 显示; +- **只增删自己的 `fonu-*.conf`**,绝不触碰其他配置 → 与宝塔/手工站点共存; +- 首次运行自检:nginx 是否安装、`conf.d`/`sites-enabled` 目录探测、80/443 监听者识别(nginx? frps? 其他?)。 + +### 3.3 frps 管理 + +- **配置下发**:Fonu「frps.toml 同源生成」已有(含 vhost 端口、auth、webServer),改为推送给 agent 落盘:备份旧文件 → 原子替换 → 按运行方式生效(systemd 单元 `systemctl restart frps`;nohup 场景 `pkill frps` 后按原命令拉起,agent 从状态接口学习原启动命令); +- **诊断**:`status` 聚合 frps 版本/存活/配置路径 + `ss -tlnp` 端口占用判定——把之前手工排查(`port unavailable`、80 被占)变成 API 一次调用; +- **边界**:不安装/升级 frps 二进制(检测报告提示版本即可)。 + +### 3.4 证书 + +- 签发仍在 **Fonu 证书页**(lego DNS-01,复用 DDNS 凭证,NAS 侧完成,VPS 无需参与); +- 部署:证书页「部署到服务器」→ `POST /certs` 推送 PEM → agent 落盘并生成 443 vhost → reload; +- 续期:Fonu 定时任务续期成功后自动重推,VPS 侧无感; +- (可选演进)agent 本地 ACME HTTP-01:agent 已在 80 上,可自助签发,作为无 DNS 凭证场景的备选,本期不做。 + +## 4. 通信协议与安全 + +| 项 | 设计 | +| --- | --- | +| 鉴权 | Bearer Token(Fonu 服务端表单生成/粘贴,agent 配置文件持有;两端一致即可用),`7700` 建议用防火墙限制仅 NAS IP 访问 | +| 传输 | 明文 HTTP 起步可接受(Token + 内网限制);增强路径:agent 用 frps 证书或自签证书提供 HTTPS 管理口 | +| 命令安全 | agent 只执行固定动作(写文件/nginx -t/reload/重启 frps),**不执行 Fonu 下发的任意命令**;域名/路径参数做白名单校验(域名格式、路径锁定 `conf.d/fonu-*` 与 `/var/lib/fonu-agent/`) | +| 幂等 | 路由/配置/证书接口全部幂等,重复调用无副作用;`routes` 对账接口支持 Fonu 定时校准 | +| 审计 | agent 每个写操作记本地日志(who/when/what),Fonu 侧同步状态入系统日志 | + +## 5. Fonu 侧改动(frpc 端) + +| 模块 | 内容 | +| --- | --- | +| 数据模型 | `frp_servers` 加 `agent_url`、`agent_token_enc`(secret.Box 加密不回显)、`mgmt_enabled`(默认 0,行为与 v1.1.0 完全一致);迁移 `023_frp_server_agent.sql` | +| 规则联动 | 规则保存/删除/启停且 `mgmt_enabled` 时,自动调 agent 路由接口(http/https 类型域名规则);失败不阻塞保存,规则行显示同步状态(已同步/待同步/失败+原因) | +| frps.toml | 「下发到服务器」按钮替代「复制到 VPS」;一键含重启与验证 | +| 证书页 | 「部署到服务器」:选目标服务端 → 推送 PEM → 回显 agent 挂载结果;续期任务成功后自动重推 | +| 管理弹窗 | 服务端「管理」按钮:status 报告(frps 状态/端口占用者/路由清单/证书有效期)+ 一键操作(下发配置/全量对账) | +| API 客户端 | `internal/frp/agent_client.go`:Go HTTP 客户端封装上述 7 个接口(超时/重试/错误透传) | + +## 6. agent 部署与生命周期管理(SSH,全程在 Fonu 界面完成) + +### 6.1 首次安装(用户不登 VPS) + +1. Fonu 服务端表单「远程管理」分组填 SSH 信息(地址/端口/用户/密码或私钥,可选保存)+ 点「安装 agent」; +2. Fonu 经单次 SSH 会话自动完成: + - 探测环境:`uname -m`(选二进制)、nginx 是否安装、`systemd` 可用性、端口占用快照; + - SFTP 上传 `fonu-agent` 二进制与 `install.sh`(从 Fonu 自带资源解出对应架构); + - 远程执行 `install.sh`:落位 `/usr/local/bin/fonu-agent`、写 `/etc/fonu-agent/agent.toml`(**生成随机 Token 并回传给 Fonu**,无需用户抄写)、注册 `systemd` 单元并启动; + - 安装后立刻走 API `ping` 验证 + 防火墙提示(如 7700 未放行给出命令); +3. UI 展示每步输出,失败可重试(幂等,重复安装=覆盖升级)。 + +### 6.2 升级 / 卸载 / 自救 + +| 动作 | 方式 | 说明 | +| --- | --- | --- | +| 升级 agent | SSH:上传新二进制 + `systemctl restart fonu-agent` | 路由/证书/Token 均在磁盘,不丢 | +| 卸载 agent | SSH:`install.sh --uninstall`(停服务、可选保留数据) | nginx vhost 文件一并清理(可选项) | +| agent 失联 | API 不可达时,Fonu 管理弹窗提示「走 SSH 通道诊断」:进程状态/端口/日志尾部 | SSH 是 API 故障时的备用通道 | + +### 6.3 日常管理(API,面板在 Fonu) + +安装完成后的一切操作不再用 SSH:规则联动同步、frps.toml 下发、证书部署、status 查询、全量对账——全部是 Fonu 界面按钮/自动触发,走 agent API。VPS 上没有任何管理面板,用户日常甚至不需要记住 VPS 密码。 + +## 6.5 Fonu 侧新增「SSH 安装器」(与 agent 客户端并列) + +| 模块 | 内容 | +| --- | --- | +| `internal/frp/ssh_installer.go`(新) | SSH 连接(x/crypto/ssh 已在 go.mod)、SFTP 上传二进制/install.sh、远程幂等安装流程、Token 回传 | +| agent 二进制内嵌 | 构建期把 `fonu-agent` 各架构二进制 embed 进 Fonu(构建矩阵:linux-amd64/arm64),安装时按 `uname -m` 选择,无需用户单独下载 | +| 数据模型 | 复用 023 迁移:`ssh_host/port/user/auth/secret_enc` 可选保存(仅启用「安装/升级/卸载」功能时需要),agent API Token 由安装器自动生成并写入 Fonu | +| UI | 服务端表单「远程管理」:SSH 信息(可选)+「安装 agent / 升级 / 卸载 / 走 SSH 诊断」按钮,安装过程实时输出展示 | + +> SSH 信息**可选保存**:仅安装/升级时需要;日常管理走 API Token。安全上 SSH 凭据同 token 机制(secret.Box 加密、不回显),远程命令固定白名单模板(上传/安装/卸载三种),无注入面。 + +## 7. 实现拆解与工作量 + +| 模块 | 位置 | 内容 | 量级 | +| --- | --- | --- | --- | +| agent 主体 | `cmd/fonu-agent/` + `internal/agent/`(新) | HTTP 服务、鉴权、路由/frps/证书三个执行器、nginx 模板与 reload、状态聚合 | 大(约 1200 行) | +| Fonu SSH 安装器 | `internal/frp/ssh_installer.go`(新) | SSH/SFTP 上传、远程幂等安装/升级/卸载、agent 二进制 embed、Token 自动回传 | 中 | +| Fonu agent 客户端 | `internal/frp/agent_client.go`(新) | 7 个接口封装 | 中 | +| 数据模型/迁移 | `023_frp_server_agent.sql` | SSH + agent 字段 + 规则同步状态 | 小 | +| 规则联动 | `internal/frp/service.go` | 保存/删除/启停后同步调用 + 状态落库 | 中 | +| API/UI | `frpmulti_handler.go`、`router.go`、`FrpView.vue`、`client.ts` | 5 个端点 + 表单分组 + 管理弹窗 + 同步状态列 | 中 | +| 测试 | `agent_test.go` 等 | nginx 模板渲染/回滚、接口幂等、鉴权 | 中 | + +总计约 2500–3000 行(含测试)。`mgmt_enabled=0` 时 Fonu 行为与 v1.1.0 完全一致;agent 未部署的服务端不受任何影响。 + +## 8. 里程碑 + +| 阶段 | 内容 | 验收 | +| --- | --- | --- | +| M1 | agent 主体(ping/status/routes)+ Fonu SSH 安装器(一键安装) | Fonu 填 SSH 信息点「安装」后自动装好 agent 并回连成功,status 能报端口占用者 | +| M2 | Fonu 规则联动 + 路由自动注册 + frps 配置下发 | 保存 http 规则后 `http://域名` 无端口直达;frps.toml 一键下发生效 | +| M3 | 证书推送 + 续期联动 + 443 路由 | `https://域名` 可用,续期自动重推 | +| M4 | 管理弹窗 UI + 全量对账 + 升级/卸载/SSH 诊断 + 手册更新 | 手册第 6 节新增方案 C(agent 模式:SSH 装、API 管) | + +## 9. 明确不做(本期) + +- 不做 agent 侧自主 ACME(DNS-01 在 NAS 侧签发已覆盖;HTTP-01 作为后续可选); +- 不做 frps 二进制远程安装/升级(status 报告版本,安装沿用现有手册); +- 不做 agent 集群/多机编排(一个服务端一个 agent,模型对齐现有 frp_servers); +- 不动 frp 二进制与协议(agent 只操作 frps 的配置文件、进程与 vhost 端口)。 + +## 10. 方案形态对照 + +| | v2 最终(agent + SSH 安装器) | v1(纯 SSH 推送) | +| --- | --- | --- | +| VPS 侧 | 常驻 agent,但**安装/升级由 Fonu SSH 一键完成** | 零安装 | +| 日常管理 | API(面板全在 Fonu,日常无需 SSH) | 每次操作都走 SSH 命令 | +| 能力上限 | 高:本地编排 nginx/frps/证书,可自愈可扩展 | 受限于 SSH 命令白名单 | +| 与已有站点共存 | 只写 `fonu-*.conf`,共存 | 可实现但每步都要远程拼配置 | +| 故障定位 | status API 一次拿全;agent 失联时 SSH 急救 | 每次探测拼 SSH 命令 | +| 适用 | VPS 长期做穿透入口、多域名/HTTPS 全自动 | 不想在 VPS 装常驻组件 | + +结论:**SSH 与 API 不是二选一**——SSH 只承担 agent 生命周期(装/升/卸/急救),API 承担日常管理;管理面板 100% 在 Fonu 本地,VPS 上没有任何管理界面。 diff --git a/docs/frps管理方案.md b/docs/frps管理方案.md new file mode 100644 index 0000000..7edc335 --- /dev/null +++ b/docs/frps管理方案.md @@ -0,0 +1,156 @@ +# FRPS 服务端自动管理方案(frps-manager) + +> 目标:frpc 端在 Fonu 提交穿透规则后,frps 端**自动完成反向代理配置与生效**,实现域名无端口访问(`http://tv.36800.cc`),并自动管理 VPS 侧证书。本文是基于现有项目(Fonu v1.1.0 FRP 多服务端模块)的实施方案,不引入新服务、不改 frp 二进制。 + +## 1. 现状与问题 + +当前(v1.1.0)frpc 侧规则提交后: + +| 环节 | 现状 | 问题 | +| --- | --- | --- | +| tcp 规则注册(远程端口) | ✅ 自动(frpc 登录时向 frps 动态注册) | 无 | +| http 规则域名路由 | ⚠️ 需 frps.toml 预先配置 `vhostHTTPPort` | 首次使用 http 类型时 VPS 要手工改配置+重启 | +| frps.toml 生成 | ✅ Fonu 一键生成,用户手动复制到 VPS | 每次变更都要 SSH 手工同步 | +| VPS 端口释放(80/443 被占) | ❌ 手动 SSH 停 Nginx | `port unavailable` 最常见根因 | +| 改配置后生效 | ❌ 手动 `pkill frps` 重启(frp 无热加载) | 容易忘、容易误 | +| VPS 侧证书 | ❌ 无 | HTTPS 要在 VPS 手工装证书 | +| 配置漂移检测 | ❌ 无 | VPS 上的文件与 Fonu 数据库可能不一致 | + +## 2. 总体设计 + +在 Fonu(frpc 所在机器)新增 **frps-manager** 子系统:Fonu 通过 **SSH** 连接 VPS,执行「检测 → 生成 → 推送 → 生效 → 验证」闭环。VPS 侧**零安装**(只要求 frps + SSH)。 + +```text +Fonu (NAS) VPS (38.95.74.87) +┌─────────────────────────────┐ ┌──────────────────────────┐ +│ FRP 模块(现有) │ │ frps (7000/80/443/7500) │ +│ 规则/服务端 CRUD │ │ ↑ frpc 动态注册端口 │ +│ frps-manager(新增) │ SSH │ frps.toml (推送产物) │ +│ 检测→生成→推送→reload→验证 │ ────────→ │ /etc/fonu/frps/ │ +│ 证书申请→推送→挂载 │ │ /etc/fonu/frps/certs/ │ +└─────────────────────────────┘ └──────────────────────────┘ +``` + +核心思路:**frps 不认识"反代",它只认 frps.toml**。因此"自动反代"= ① 规则变化时重算出应然的 frps.toml(含 vhost 端口、subDomainHost、TLS 配置)→ ② 与 VPS 现状 diff → ③ 无变化则跳过,有变化则原子推送 → ④ 通过 frps **admin API 热重载**(无需重启进程)→ ⑤ 回读验证。证书同理:Fonu 证书页现有 ACME 能力签好后把证书文件推到 VPS 并写入 frps.toml 的 `transport.tls`/`webServer.tls` 段。 + +## 3. 服务端模型扩展(frp_servers 表) + +新增「服务端连接信息」(加密存储,复用 secret.Box): + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `ssh_host` | TEXT | VPS SSH 地址(可与 server_addr 不同) | +| `ssh_port` | INTEGER | 默认 22 | +| `ssh_user` | TEXT | 建议 root 或具备 sudo 的用户 | +| `ssh_auth` | TEXT | `password` / `key` | +| `ssh_secret_enc` | TEXT | 密码或私钥(加密) | +| `frps_dir` | TEXT | VPS 上 frps 目录,默认 `/etc/fonu/frps` | +| `frps_bin` | TEXT | frps 二进制路径,默认 `/usr/local/bin/frps` | +| `mgmt_enabled` | INTEGER | 是否启用自动管理(0/1,默认 0=纯手动,向后兼容) | + +迁移 `023_frp_server_ssh.sql`。SSH 凭据用 secret.Box 加密,接口不回显(同 Token 处理)。 + +## 4. 核心流程 + +### 4.1 检测(Probe)——服务端详情新增「检测服务端」按钮 + +一次 SSH 会话内批量执行(只读,不改任何东西): + +```bash +frps --version # 版本 +ss -tlnp | grep -E ':(80|443|7000) ' # 端口占用者(frps? nginx? 其他?) +systemctl is-active nginx frps # 服务状态 +cat /proc/$(pidof frps)/cmdline # 实际使用的配置路径 +cat <配置路径> # 现有 frps.toml 内容(做 diff) +``` + +返回结构化报告:frps 版本/运行状态/配置路径、80/443 占用者判定(frps/nginx/其他/空闲)、现有 frps.toml 与 Fonu 期望配置的 **diff 摘要**、待办建议(如「80 被 nginx 占用,需释放」)。这就是用户之前反复手工排查的自动化。 + +### 4.2 下发(Apply)——「自动反代」的主流程 + +触发时机:用户点「下发配置」,或规则保存后若 `mgmt_enabled` 且检测到配置将变化(规则含 http 类型 / 证书变更)自动触发。 + +1. **生成期望配置**:扩展现有 `buildFRPSServerConfig`: + - 存在 http/https/tcpmux 规则 → 输出 `vhostHTTPPort = 80`、`vhostHTTPSPort = 443`(去端口访问的关键); + - 存在 subdomain 规则 → 输出 `subDomainHost = <用户在服务端表单新填的主域>`; + - 证书启用时输出 `transport.tls.force` / `webServer.tls` 段; +2. **SSH 推送(原子)**:新内容写 `/etc/fonu/frps/frps.toml.new` → `mv` 原子替换 → 旧文件留 `frps.toml.bak`(回滚点); +3. **热重载**:调 frps admin API `PUT /api/reload`(frps webServer 7500 已有 Basic Auth 凭据)——**不杀进程**,连接不断;API 不可用时降级 `systemctl restart frps`(nohup 场景提示用户); +4. **验证**:重读 VPS 上的 frps.toml 确认与期望一致 + `ss` 确认 80/443 已由 frps 监听 + 通过公网侧发起一次探测请求; +5. 全程动作与输出写入系统日志(复用 logstore),UI 弹窗展示每步结果。 + +### 4.3 释放端口(安全加固提示,不偷偷杀人) + +检测发现 80/443 被 nginx 等占用时,**不自动杀进程**(危险动作),而是: +- 检测报告中标红 + 给出一键命令; +- UI 提供「释放端口」按钮:二次确认后执行 `systemctl stop nginx && systemctl disable nginx`(仅当占用者确认为 nginx 时可点,其他占用者只提示不动手); +- 释放后自动重跑 Apply 验证闭环。 + +### 4.4 证书管理(VPS 侧 HTTPS) + +复用 Fonu 证书页现有 ACME(lego)能力,证书签发在 NAS 侧完成(DNS-01,无需 VPS 参与),然后: + +1. 证书页选「部署到 VPS」→ 选目标服务端; +2. SSH 推送 `fullchain.pem` / `privkey.pem` 到 `/etc/fonu/frps/certs/<域名>/`(0600); +3. frps.toml 写入 `webServer.tls.certFile/keyFile`(管理面板 HTTPS);对外流量 TLS 若需要也在 frps 侧终止时,走 `transport.tls` 相关段(v0.71 支持 `transport.tls.force` + 证书); +4. 续期:Fonu 证书定时任务续期成功后自动重推 + reload(证书 60 天滚动,VPS 侧无感)。 + +> 设计取舍:对外业务的 HTTPS 终止仍推荐在 **Fonu Nginx(NAS 侧)**(本手册方案 A 架构),frps 侧证书用于 webServer 管理面板与 `tls.force` 场景。若用户想让 VPS 终止 TLS,同一套推送机制也覆盖。 + +## 5. API 与 UI + +### 5.1 新增 API(挂在 `/api/frpmulti` 下,鉴权与现有中间件一致) + +| 方法/路径 | 作用 | +| --- | --- | +| `PUT /servers/{id}/ssh` | 保存 SSH 连接信息(加密存储) | +| `GET /servers/{id}/probe` | 检测:frps 状态/端口占用/配置 diff | +| `POST /servers/{id}/apply` | 下发期望配置 + reload + 验证 | +| `POST /servers/{id}/release-port` | 释放被占端口(body: `{port, confirm: true}`) | +| `POST /servers/{id}/push-cert` | 推送证书到 VPS 并挂载 | + +### 5.2 前端(FrpView.vue) + +- 服务端表单「高级配置」折叠区新增「远程管理」分组:SSH 地址/端口/用户/认证方式/密钥(ConfiguredSecretField,v-model)/frps 目录; +- 服务端列表操作列新增「管理」按钮 → 弹窗(复用 `proxy-modal` 自绘骨架)三块:检测报告(分色状态行)、diff 视图(`
` 左右对照期望/现状)、操作按钮(下发配置 / 释放端口 / 推送证书);
+- 规则保存时的自动下发提示:非阻塞 toast「检测到 vhost 配置变化,点击下发」。
+
+## 6. 实现拆解与工作量
+
+| 模块 | 文件 | 内容 | 量级 |
+| --- | --- | --- | --- |
+| 迁移 | `migrations/023_frp_server_ssh.sql` | SSH 字段 | 小 |
+| SSH 客户端 | `internal/frp/ssh.go`(新) | 连接池/单会话命令执行/SFTP 推送/超时 | 中(x/crypto/ssh 已在 go.mod) |
+| 检测 | `internal/frp/probe.go`(新) | 探测命令编排 + 结果结构体 + 80 占用者判定 | 中 |
+| 配置 diff/应用 | `internal/frp/frps_apply.go`(新) | 期望配置生成(扩展现有 frps_template)、diff、原子推送、admin API reload、验证 | 中 |
+| 证书推送 | `internal/frp/cert_push.go`(新) | 证书文件推送 + frps.toml 证书段写入 | 小 |
+| Service/API | `frpmulti_handler.go` + `router.go` | 5 个端点 | 小 |
+| 前端 | `FrpView.vue` + `client.ts` | 管理弹窗 + 表单分组 + 按钮 | 中 |
+| 测试 | `probe_test.go` `frps_apply_test.go` | 命令输出解析、diff、原子写逻辑 | 中 |
+
+总计约 1500–2000 行(含测试),纯增量、不动现有链路(`mgmt_enabled=0` 时行为与 v1.1.0 完全一致)。
+
+## 7. 安全设计
+
+1. SSH 凭据 secret.Box 加密落库,API 永不回显(同 token 掩码机制);
+2. 推荐文档引导用户用**密钥登录**(Fonu 可一键生成 ed25519 密钥对、展示公钥让用户贴到 VPS `authorized_keys`);
+3. 所有远程命令为**固定白名单模板**(版本/端口/状态/替换文件/reload),不拼接用户输入,杜绝命令注入;SFTP 只写 `/etc/fonu/frps/` 前缀内路径;
+4. 危险动作(释放端口、重启 frps)二次确认 + 动作日志;
+5. Apply 幂等:重复执行无副作用(diff 无变化即跳过);
+6. frps admin API 调用走本机 127.0.0.1:7500(SSH 远端执行 curl),不暴露公网。
+
+## 8. 里程碑
+
+| 阶段 | 内容 | 验收 |
+| --- | --- | --- |
+| M1 | 迁移 + SSH 客户端 + 检测(Probe) | 服务端「检测」按钮能报告 frps 状态与 80 占用者 |
+| M2 | 期望配置生成 + diff + 原子推送 + reload + 验证 | 改规则后一键下发,`http://域名` 直达,`port unavailable` 类问题由系统自愈 |
+| M3 | 证书推送 + 续期联动 | 证书页一键部署到 VPS,续期自动重推 |
+| M4 | UI 完善 + 文档(手册第 6 节追加方案 A') | 手册更新,`port unavailable` 类问题由系统自愈 |
+
+## 9. 明确不做(本期)
+
+- 不实现 VPS 伴生 agent(能力更强但需安装,作为后续可选演进);
+- 不做 frps 二进制的远程安装/升级(用户自装,检测报告给出版本即可);
+- 不做多 VPS 并行下发编排(现有逐服务端模型已覆盖);
+- 不改 frp 二进制、不依赖 frp 私有接口(只用 admin API 与文件系统)。
diff --git a/docs/screenshots/certificates-apply.png b/docs/screenshots/certificates-apply.png
new file mode 100644
index 0000000..8b410fd
Binary files /dev/null and b/docs/screenshots/certificates-apply.png differ
diff --git a/docs/screenshots/certificates.png b/docs/screenshots/certificates.png
new file mode 100644
index 0000000..5d1197a
Binary files /dev/null and b/docs/screenshots/certificates.png differ
diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png
new file mode 100644
index 0000000..12dafcc
Binary files /dev/null and b/docs/screenshots/dashboard.png differ
diff --git a/docs/screenshots/ddns.png b/docs/screenshots/ddns.png
new file mode 100644
index 0000000..c319f46
Binary files /dev/null and b/docs/screenshots/ddns.png differ
diff --git a/docs/screenshots/frp.png b/docs/screenshots/frp.png
new file mode 100644
index 0000000..25c5034
Binary files /dev/null and b/docs/screenshots/frp.png differ
diff --git a/docs/screenshots/logs.png b/docs/screenshots/logs.png
new file mode 100644
index 0000000..9be7488
Binary files /dev/null and b/docs/screenshots/logs.png differ
diff --git a/docs/screenshots/proxy-detail.png b/docs/screenshots/proxy-detail.png
new file mode 100644
index 0000000..df9e2ce
Binary files /dev/null and b/docs/screenshots/proxy-detail.png differ
diff --git a/docs/screenshots/proxy-rule-basic.png b/docs/screenshots/proxy-rule-basic.png
new file mode 100644
index 0000000..bc19242
Binary files /dev/null and b/docs/screenshots/proxy-rule-basic.png differ
diff --git a/docs/screenshots/proxy-rule-nginx.png b/docs/screenshots/proxy-rule-nginx.png
new file mode 100644
index 0000000..f48b874
Binary files /dev/null and b/docs/screenshots/proxy-rule-nginx.png differ
diff --git a/docs/screenshots/proxy-rule-security.png b/docs/screenshots/proxy-rule-security.png
new file mode 100644
index 0000000..9a757ff
Binary files /dev/null and b/docs/screenshots/proxy-rule-security.png differ
diff --git a/docs/screenshots/proxy.png b/docs/screenshots/proxy.png
new file mode 100644
index 0000000..f8c3363
Binary files /dev/null and b/docs/screenshots/proxy.png differ
diff --git a/docs/screenshots/settings-advanced.png b/docs/screenshots/settings-advanced.png
new file mode 100644
index 0000000..b787388
Binary files /dev/null and b/docs/screenshots/settings-advanced.png differ
diff --git a/docs/screenshots/settings.png b/docs/screenshots/settings.png
new file mode 100644
index 0000000..0481b26
Binary files /dev/null and b/docs/screenshots/settings.png differ
diff --git a/fonu-agent.exe b/fonu-agent.exe
new file mode 100644
index 0000000..e1dd5aa
Binary files /dev/null and b/fonu-agent.exe differ
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..b5bad1f
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,51 @@
+module github.com/fonu/fonu
+
+go 1.23.0
+
+require (
+	github.com/go-acme/lego/v4 v4.23.1
+	github.com/nrdcg/dnspod-go v0.4.0
+	github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1128
+	github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.1128
+	github.com/volcengine/volcengine-go-sdk v1.2.53
+	golang.org/x/crypto v0.36.0
+	golang.org/x/sys v0.31.0
+	gopkg.in/natefinch/lumberjack.v2 v2.2.1
+	modernc.org/sqlite v1.34.4
+)
+
+require (
+	github.com/aliyun/alibaba-cloud-sdk-go v1.63.100 // indirect
+	github.com/cenkalti/backoff/v4 v4.3.0 // indirect
+	github.com/cloudflare/cloudflare-go v0.115.0 // indirect
+	github.com/dustin/go-humanize v1.0.1 // indirect
+	github.com/go-jose/go-jose/v4 v4.0.5 // indirect
+	github.com/goccy/go-json v0.10.5 // indirect
+	github.com/google/go-querystring v1.1.0 // indirect
+	github.com/google/uuid v1.6.0 // indirect
+	github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
+	github.com/jmespath/go-jmespath v0.4.0 // indirect
+	github.com/json-iterator/go v1.1.12 // indirect
+	github.com/mattn/go-isatty v0.0.20 // indirect
+	github.com/miekg/dns v1.1.64 // indirect
+	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+	github.com/modern-go/reflect2 v1.0.2 // indirect
+	github.com/ncruces/go-strftime v0.1.9 // indirect
+	github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
+	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+	github.com/volcengine/volc-sdk-golang v1.0.199 // indirect
+	golang.org/x/mod v0.23.0 // indirect
+	golang.org/x/net v0.37.0 // indirect
+	golang.org/x/sync v0.12.0 // indirect
+	golang.org/x/text v0.23.0 // indirect
+	golang.org/x/time v0.11.0 // indirect
+	golang.org/x/tools v0.30.0 // indirect
+	gopkg.in/ini.v1 v1.67.0 // indirect
+	gopkg.in/yaml.v2 v2.4.0 // indirect
+	modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
+	modernc.org/libc v1.55.3 // indirect
+	modernc.org/mathutil v1.6.0 // indirect
+	modernc.org/memory v1.8.0 // indirect
+	modernc.org/strutil v1.2.0 // indirect
+	modernc.org/token v1.1.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..7261931
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,908 @@
+cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
+cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
+cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
+cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
+cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
+cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
+cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
+cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
+cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
+cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
+cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
+cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
+cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
+cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
+cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
+cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
+cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
+cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
+cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
+cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
+cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
+cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
+cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
+cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
+cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
+cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
+cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
+cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
+cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
+cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
+dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
+github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
+github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
+github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
+github.com/HdrHistogram/hdrhistogram-go v1.1.0/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo=
+github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo=
+github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
+github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
+github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
+github.com/Shopify/sarama v1.30.1/go.mod h1:hGgx05L/DiW8XYBXeJdKIN6V2QUy2H6JqME5VT1NLRw=
+github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
+github.com/Shopify/toxiproxy/v2 v2.1.6-0.20210914104332-15ea381dcdae/go.mod h1:/cvHQkZ1fst0EmZnA5dFtiQdWCNCFYzb+uE2vqVgvx0=
+github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g=
+github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
+github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
+github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
+github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
+github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
+github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
+github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
+github.com/aliyun/alibaba-cloud-sdk-go v1.63.100 h1:yUkCbrSM1cWtgBfRVKMQtdt22KhDvKY7g4V+92eG9wA=
+github.com/aliyun/alibaba-cloud-sdk-go v1.63.100/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
+github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
+github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
+github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
+github.com/armon/go-metrics v0.3.9/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc=
+github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
+github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
+github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
+github.com/aws/aws-sdk-go v1.40.45/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
+github.com/aws/aws-sdk-go-v2 v1.9.1/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4=
+github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.8.1/go.mod h1:CM+19rL1+4dFWnOQKwDc7H1KwXTz+h61oUSHyhV0b3o=
+github.com/aws/smithy-go v1.8.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E=
+github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
+github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
+github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
+github.com/casbin/casbin/v2 v2.37.0/go.mod h1:vByNa/Fchek0KZUgG5wEsl7iFsiviAYKRtgrQfcJqHg=
+github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
+github.com/cenkalti/backoff/v4 v4.1.2/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
+github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
+github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
+github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
+github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
+github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
+github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
+github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
+github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
+github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
+github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
+github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
+github.com/cloudflare/cloudflare-go v0.115.0 h1:84/dxeeXweCc0PN5Cto44iTA8AkG1fyT11yPO5ZB7sM=
+github.com/cloudflare/cloudflare-go v0.115.0/go.mod h1:Ds6urDwn/TF2uIU24mu7H91xkKP8gSAHxQ44DSZgVmU=
+github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
+github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
+github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
+github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
+github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
+github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
+github.com/eapache/go-resiliency v1.2.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
+github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
+github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
+github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
+github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
+github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
+github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
+github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
+github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
+github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
+github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
+github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
+github.com/fatih/color v1.12.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
+github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
+github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
+github.com/franela/goblin v0.0.0-20210519012713-85d372ac71e2/go.mod h1:VzmDKDJVZI3aJmnRI9VjAn9nJ8qPPsN1fqzr9dqInIo=
+github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=
+github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k=
+github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
+github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
+github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
+github.com/go-acme/lego/v4 v4.23.1 h1:lZ5fGtGESA2L9FB8dNTvrQUq3/X4QOb8ExkKyY7LSV4=
+github.com/go-acme/lego/v4 v4.23.1/go.mod h1:7UMVR7oQbIYw6V7mTgGwi4Er7B6Ww0c+c8feiBM0EgI=
+github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
+github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
+github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
+github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
+github.com/go-kit/kit v0.12.0/go.mod h1:lHd+EkCZPIwYItmGDDRdhinkzX2A1sj+M9biaEaizzs=
+github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
+github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
+github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
+github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
+github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
+github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
+github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
+github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
+github.com/go-zookeeper/zk v1.0.2/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL+UX1Qcw=
+github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
+github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
+github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
+github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A=
+github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
+github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
+github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
+github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
+github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
+github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
+github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
+github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
+github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
+github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
+github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
+github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
+github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
+github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
+github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
+github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
+github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
+github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
+github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
+github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
+github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
+github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
+github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
+github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
+github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
+github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
+github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
+github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
+github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
+github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
+github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
+github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
+github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
+github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
+github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
+github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
+github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
+github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
+github.com/hashicorp/consul/api v1.10.1/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M=
+github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms=
+github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
+github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
+github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
+github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
+github.com/hashicorp/go-hclog v0.16.2/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
+github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
+github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
+github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
+github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
+github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
+github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
+github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
+github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
+github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
+github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
+github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY=
+github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE=
+github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk=
+github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
+github.com/hudl/fargo v1.4.0/go.mod h1:9Ai6uvFy5fQNq6VPKtg+Ceq1+eTY4nKUlR2JElEOcDo=
+github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
+github.com/influxdata/influxdb1-client v0.0.0-20200827194710-b269163b24ab/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo=
+github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
+github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
+github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o=
+github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
+github.com/jcmturner/gokrb5/v8 v8.4.2/go.mod h1:sb+Xq/fTY5yktf/VxLsE3wlfPqQjp0aWNYyvBVK62bc=
+github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
+github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
+github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
+github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
+github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
+github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
+github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
+github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
+github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
+github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
+github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
+github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
+github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
+github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
+github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
+github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
+github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
+github.com/klauspost/compress v1.13.5/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
+github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
+github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
+github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
+github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
+github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
+github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
+github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
+github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
+github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
+github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
+github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
+github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
+github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
+github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
+github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
+github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4=
+github.com/miekg/dns v1.1.64 h1:wuZgD9wwCE6XMT05UU/mlSko71eRSXEAm2EbjQXLKnQ=
+github.com/miekg/dns v1.1.64/go.mod h1:Dzw9769uoKVaLuODMDZz9M6ynFU6Em65csPuoi8G0ck=
+github.com/minio/highwayhash v1.0.1/go.mod h1:BQskDq+xkJ12lmlUUi7U0M5Swg3EWR+dLTk+kldvVxY=
+github.com/minio/highwayhash v1.0.2/go.mod h1:BQskDq+xkJ12lmlUUi7U0M5Swg3EWR+dLTk+kldvVxY=
+github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI=
+github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
+github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
+github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
+github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
+github.com/mitchellh/mapstructure v1.4.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
+github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
+github.com/nats-io/jwt v1.2.2/go.mod h1:/xX356yQA6LuXI9xWW7mZNpxgF2mBmGecH+Fj34sP5Q=
+github.com/nats-io/jwt/v2 v2.0.3/go.mod h1:VRP+deawSXyhNjXmxPCHskrR6Mq50BqpEI5SEcNiGlY=
+github.com/nats-io/nats-server/v2 v2.5.0/go.mod h1:Kj86UtrXAL6LwYRA6H4RqzkHhK0Vcv2ZnKD5WbQ1t3g=
+github.com/nats-io/nats.go v1.12.1/go.mod h1:BPko4oXsySz4aSWeFgOHLZs3G4Jq4ZAyE6/zMCxRT6w=
+github.com/nats-io/nkeys v0.2.0/go.mod h1:XdZpAbhgyyODYqjTawOnIOI7VlbKSarI9Gfy1tqEu/s=
+github.com/nats-io/nkeys v0.3.0/go.mod h1:gvUNGjVcM2IPr5rCsRsC6Wb3Hr2CQAm08dsxtV6A5y4=
+github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
+github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
+github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
+github.com/nrdcg/dnspod-go v0.4.0 h1:c/jn1mLZNKF3/osJ6mz3QPxTudvPArXTjpkmYj0uK6U=
+github.com/nrdcg/dnspod-go v0.4.0/go.mod h1:vZSoFSFeQVm2gWLMkyX61LZ8HI3BaqtHZWgPTGKr6KQ=
+github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
+github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
+github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
+github.com/onsi/ginkgo v1.16.2/go.mod h1:CObGmKUOKaSC0RjmoAK7tKyn4Azo5P2IWuoMnvwxz1E=
+github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
+github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
+github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
+github.com/onsi/gomega v1.13.0/go.mod h1:lRk9szgn8TxENtWd0Tp4c3wjlRfMTMH27I+3Je41yGY=
+github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
+github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
+github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A=
+github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU=
+github.com/openzipkin/zipkin-go v0.2.5/go.mod h1:KpXfKdgRDnnhsxw4pNIH9Md5lyFqKUa4YDFlwRYAMyE=
+github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
+github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
+github.com/performancecopilot/speed/v4 v4.0.0/go.mod h1:qxrSyuDGrTOWfV+uKRFhfxw6h/4HXRGUiZiufxo49BM=
+github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc=
+github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
+github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
+github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
+github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
+github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
+github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
+github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
+github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
+github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
+github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
+github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
+github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
+github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
+github.com/prometheus/common v0.30.0/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
+github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
+github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
+github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
+github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
+github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
+github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
+github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
+github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
+github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
+github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg=
+github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
+github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
+github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
+github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
+github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
+github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
+github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
+github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
+github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
+github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
+github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
+github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
+github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
+github.com/streadway/amqp v1.0.0/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
+github.com/streadway/handy v0.0.0-20200128134331-0f66f006fb2e/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1128 h1:NGnqDc8FQL0YdiCHgTO4Wkso6ToD8rE3JW9VOzoPBNA=
+github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1128/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
+github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.1128 h1:mrJ5Fbkd7sZIJ5F6oRfh5zebPQaudPH9Y0+GUmFytYU=
+github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.1128/go.mod h1:zbsYIBT+VTX4z4ocjTAdLBIWyNYj3z0BRqd0iPdnjsk=
+github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
+github.com/uber/jaeger-client-go v2.30.0+incompatible h1:D6wyKGCecFaSRUpo8lCVbaOOb6ThwMmTEbhRwtKR97o=
+github.com/uber/jaeger-client-go v2.30.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk=
+github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVKhn2Um6rjCsSsg=
+github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U=
+github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
+github.com/volcengine/volc-sdk-golang v1.0.23/go.mod h1:AfG/PZRUkHJ9inETvbjNifTDgut25Wbkm2QoYBTbvyU=
+github.com/volcengine/volc-sdk-golang v1.0.199 h1:zv9QOqTl/IsLwtfC37GlJtcz6vMAHi+pjq8ILWjLYUc=
+github.com/volcengine/volc-sdk-golang v1.0.199/go.mod h1:stZX+EPgv1vF4nZwOlEe8iGcriUPRBKX8zA19gXycOQ=
+github.com/volcengine/volcengine-go-sdk v1.2.53 h1:aWooc5D8+Tx+R+Z7VsIp8aJCbWgfGdb687tq8G5b2JI=
+github.com/volcengine/volcengine-go-sdk v1.2.53/go.mod h1:DURKPVQ8Rsi4OQu4/NPjUyjla3MeHPgXT/qV+400fAw=
+github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
+github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs=
+github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM=
+github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
+go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
+go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ=
+go.etcd.io/etcd/client/v3 v3.5.0/go.mod h1:AIKXXVX/DQXtfTEqBryiLTUXwON+GuvO6Z7lLS/oTh0=
+go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
+go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
+go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
+go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
+go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
+go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
+go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
+go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
+go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
+go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
+go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
+go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
+go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
+golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20210314154223-e6e6c4f2bb5b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
+golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.0.0-20210915214749-c084706c2272/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.0.0-20210920023735-84f357641f63/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
+golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
+golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
+golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
+golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
+golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
+golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
+golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
+golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
+golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
+golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
+golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=
+golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
+golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
+golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
+golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
+golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
+golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
+golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
+golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
+golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
+golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
+golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
+golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
+golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
+golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
+golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
+golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
+golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
+golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
+golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
+golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20210917221730-978cfadd31cf/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
+golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
+golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
+golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
+golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
+golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190130150945-aca44879d564/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
+golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
+golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
+golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
+golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
+golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
+golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
+golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
+golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
+golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
+golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
+golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
+golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
+golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
+golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
+golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY=
+golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo=
+gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0=
+gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=
+gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc=
+google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
+google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
+google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
+google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
+google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
+google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
+google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
+google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
+google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
+google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
+google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
+google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
+google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
+google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
+google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
+google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
+google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
+google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
+google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
+google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
+google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
+google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
+google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
+google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
+google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
+google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
+google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
+google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
+google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
+google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
+google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
+google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
+google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
+google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
+google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
+google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
+google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
+google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
+google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
+google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
+google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
+google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
+google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
+google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
+gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
+gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o=
+gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
+gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
+gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
+gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
+gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
+gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
+honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
+honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
+modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
+modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
+modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
+modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
+modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
+modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
+modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
+modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
+modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
+modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
+modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
+modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
+modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
+modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
+modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
+modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
+modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
+modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
+modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
+modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
+modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8=
+modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk=
+modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
+modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
+rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
+rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
+sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=
diff --git a/internal/acme/ca.go b/internal/acme/ca.go
new file mode 100644
index 0000000..4388f2b
--- /dev/null
+++ b/internal/acme/ca.go
@@ -0,0 +1,89 @@
+package acme
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/go-acme/lego/v4/lego"
+)
+
+const (
+	CALetsEncrypt        = "letsencrypt"
+	CALetsEncryptStaging = "letsencrypt-staging"
+	CAZeroSSL            = "zerossl"
+	CABuypass            = "buypass"
+)
+
+const (
+	zeroSSLDirectoryURL = "https://acme.zerossl.com/v2/DV90"
+	buypassDirectoryURL = "https://api.buypass.com/acme/directory"
+)
+
+func NormalizeCA(ca string) string {
+	switch ca {
+	case "", CALetsEncrypt, "production":
+		return CALetsEncrypt
+	case CALetsEncryptStaging, "staging", "letsencrypt-test":
+		return CALetsEncryptStaging
+	case CAZeroSSL:
+		return CAZeroSSL
+	case CABuypass, "buypass-go":
+		return CABuypass
+	default:
+		return ca
+	}
+}
+
+func DirectoryURL(ca string) (string, error) {
+	switch NormalizeCA(ca) {
+	case CALetsEncrypt:
+		return lego.LEDirectoryProduction, nil
+	case CALetsEncryptStaging:
+		return lego.LEDirectoryStaging, nil
+	case CAZeroSSL:
+		return zeroSSLDirectoryURL, nil
+	case CABuypass:
+		return buypassDirectoryURL, nil
+	default:
+		return "", fmt.Errorf("不支持的颁发机构:%s", ca)
+	}
+}
+
+func CALabel(ca string) string {
+	switch NormalizeCA(ca) {
+	case CALetsEncrypt:
+		return "Let's Encrypt"
+	case CALetsEncryptStaging:
+		return "Let's Encrypt 测试"
+	case CAZeroSSL:
+		return "ZeroSSL"
+	case CABuypass:
+		return "Buypass"
+	default:
+		if ca == "" {
+			return "手动导入"
+		}
+		return ca
+	}
+}
+
+func ValidateCA(ca string) error {
+	_, err := DirectoryURL(ca)
+	return err
+}
+
+func ValidateCADomains(ca string, domains []string) error {
+	switch NormalizeCA(ca) {
+	case CABuypass:
+		if len(domains) > 5 {
+			return fmt.Errorf("Buypass 单张证书最多支持 5 个域名")
+		}
+		for _, domain := range domains {
+			if strings.HasPrefix(domain, "*.") {
+				return fmt.Errorf("Buypass 不支持通配符证书")
+			}
+		}
+	}
+	return nil
+}
+
diff --git a/internal/acme/ca_test.go b/internal/acme/ca_test.go
new file mode 100644
index 0000000..cdf67e6
--- /dev/null
+++ b/internal/acme/ca_test.go
@@ -0,0 +1,37 @@
+package acme
+
+import "testing"
+
+func TestDirectoryURL(t *testing.T) {
+	url, err := DirectoryURL(CALetsEncrypt)
+	if err != nil || url == "" {
+		t.Fatalf("production: %v", err)
+	}
+	url, err = DirectoryURL(CALetsEncryptStaging)
+	if err != nil || url == "" {
+		t.Fatalf("staging: %v", err)
+	}
+	url, err = DirectoryURL(CAZeroSSL)
+	if err != nil || url == "" {
+		t.Fatalf("zerossl: %v", err)
+	}
+	url, err = DirectoryURL(CABuypass)
+	if err != nil || url == "" {
+		t.Fatalf("buypass: %v", err)
+	}
+	if err := ValidateCA("unknown"); err == nil {
+		t.Fatal("expected error for unknown ca")
+	}
+}
+
+func TestValidateCADomainsBuypass(t *testing.T) {
+	if err := ValidateCADomains(CABuypass, []string{"*.example.com"}); err == nil {
+		t.Fatal("expected wildcard error")
+	}
+	if err := ValidateCADomains(CABuypass, []string{"a.com", "b.com", "c.com", "d.com", "e.com", "f.com"}); err == nil {
+		t.Fatal("expected domain limit error")
+	}
+	if err := ValidateCADomains(CALetsEncrypt, []string{"*.example.com"}); err != nil {
+		t.Fatalf("letsencrypt wildcard should be allowed: %v", err)
+	}
+}
diff --git a/internal/acme/dns_provider.go b/internal/acme/dns_provider.go
new file mode 100644
index 0000000..a95a01d
--- /dev/null
+++ b/internal/acme/dns_provider.go
@@ -0,0 +1,98 @@
+package acme
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/go-acme/lego/v4/challenge"
+	"github.com/go-acme/lego/v4/providers/dns/alidns"
+	"github.com/go-acme/lego/v4/providers/dns/cloudflare"
+	"github.com/go-acme/lego/v4/providers/dns/dnspod"
+	"github.com/go-acme/lego/v4/providers/dns/tencentcloud"
+
+	"github.com/fonu/fonu/internal/ddns"
+)
+
+func newDNS01Provider(provider string, cred ddns.Credentials) (challenge.Provider, error) {
+	switch provider {
+	case "dnspod":
+		cfg := dnspod.NewDefaultConfig()
+		cfg.LoginToken = cred.LoginToken()
+		return dnspod.NewDNSProviderConfig(cfg)
+	case "alidns":
+		cfg := alidns.NewDefaultConfig()
+		cfg.APIKey = cred.Token
+		cfg.SecretKey = cred.Secret
+		return alidns.NewDNSProviderConfig(cfg)
+	case "tencentcloud":
+		cfg := tencentcloud.NewDefaultConfig()
+		cfg.SecretID = cred.Token
+		cfg.SecretKey = cred.Secret
+		return tencentcloud.NewDNSProviderConfig(cfg)
+	case "volcengine":
+		return newVolcengineDNSProvider(cred)
+	default:
+		cfg := cloudflare.NewDefaultConfig()
+		cfg.AuthToken = cred.Token
+		return cloudflare.NewDNSProviderConfig(cfg)
+	}
+}
+
+func dnsProviderName(provider string) string {
+	switch provider {
+	case "dnspod", "alidns", "tencentcloud", "volcengine":
+		return provider
+	default:
+		return "cloudflare"
+	}
+}
+
+type loggingDNSProvider struct {
+	inner challenge.Provider
+	job   *Job
+}
+
+func wrapDNSProvider(inner challenge.Provider, job *Job) challenge.Provider {
+	if job == nil {
+		return inner
+	}
+	return &loggingDNSProvider{inner: inner, job: job}
+}
+
+func (p *loggingDNSProvider) Present(domain, token, keyAuth string) error {
+	record := strings.TrimSuffix(domain, ".")
+	p.job.Info(fmt.Sprintf("添加 DNS TXT 记录: _acme-challenge.%s", record))
+	if err := p.inner.Present(domain, token, keyAuth); err != nil {
+		p.job.Error(fmt.Sprintf("添加 DNS TXT 记录失败: %v", err))
+		return err
+	}
+	p.job.Info("DNS TXT 记录已添加,等待 DNS 传播…")
+	return nil
+}
+
+func (p *loggingDNSProvider) CleanUp(domain, token, keyAuth string) error {
+	p.job.Info("清理 DNS TXT 验证记录…")
+	return p.inner.CleanUp(domain, token, keyAuth)
+}
+
+func validateDNSCredentials(provider string, cred ddns.Credentials) error {
+	if err := cred.Validate(dnsProviderName(provider)); err != nil {
+		return err
+	}
+	if provider == "dnspod" && cred.TokenID == "" {
+		return fmt.Errorf("请先在 DDNS 页面配置 DNSPod ID 和 Token")
+	}
+	if provider == "alidns" && cred.Secret == "" {
+		return fmt.Errorf("请先在 DDNS 页面配置阿里云 AccessKey")
+	}
+	if provider == "tencentcloud" && cred.Secret == "" {
+		return fmt.Errorf("请先在 DDNS 页面配置腾讯云 SecretKey")
+	}
+	if provider == "volcengine" && cred.Secret == "" {
+		return fmt.Errorf("请先在 DDNS 页面配置火山引擎 Secret Access Key")
+	}
+	if provider != "dnspod" && provider != "alidns" && provider != "tencentcloud" && provider != "volcengine" && cred.Token == "" {
+		return fmt.Errorf("请先在 DDNS 页面配置 Cloudflare API Token")
+	}
+	return nil
+}
diff --git a/internal/acme/dns_provider_test.go b/internal/acme/dns_provider_test.go
new file mode 100644
index 0000000..de2422a
--- /dev/null
+++ b/internal/acme/dns_provider_test.go
@@ -0,0 +1,23 @@
+package acme
+
+import (
+	"testing"
+
+	"github.com/fonu/fonu/internal/ddns"
+)
+
+func TestValidateDNSCredentialsVolcengine(t *testing.T) {
+	if err := validateDNSCredentials("volcengine", ddns.Credentials{Token: "ak", Secret: "sk"}); err != nil {
+		t.Fatalf("expected volcengine credentials to pass: %v", err)
+	}
+}
+
+func TestNewVolcengineDNSProvider(t *testing.T) {
+	p, err := newVolcengineDNSProvider(ddns.Credentials{Token: "ak", Secret: "sk"})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if p == nil {
+		t.Fatal("expected provider")
+	}
+}
diff --git a/internal/acme/domains.go b/internal/acme/domains.go
new file mode 100644
index 0000000..83593bf
--- /dev/null
+++ b/internal/acme/domains.go
@@ -0,0 +1,166 @@
+package acme
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	"github.com/fonu/fonu/internal/validate"
+)
+
+func NormalizeCertDomains(raw []string) ([]string, error) {
+	if len(raw) == 0 {
+		return nil, fmt.Errorf("请填写至少一个域名")
+	}
+	if len(raw) > 100 {
+		return nil, fmt.Errorf("域名数量不能超过 100 个")
+	}
+
+	seen := map[string]bool{}
+	out := make([]string, 0, len(raw))
+	for _, item := range raw {
+		for _, part := range splitDomainInput(item) {
+			domain, err := normalizeCertDomain(part)
+			if err != nil {
+				return nil, err
+			}
+			if seen[domain] {
+				continue
+			}
+			seen[domain] = true
+			out = append(out, domain)
+		}
+	}
+	if len(out) == 0 {
+		return nil, fmt.Errorf("请填写至少一个域名")
+	}
+	return out, nil
+}
+
+func splitDomainInput(input string) []string {
+	input = strings.ReplaceAll(input, "\r\n", "\n")
+	input = strings.ReplaceAll(input, ",", "\n")
+	parts := strings.Split(input, "\n")
+	out := make([]string, 0, len(parts))
+	for _, part := range parts {
+		part = strings.TrimSpace(part)
+		if part != "" {
+			out = append(out, part)
+		}
+	}
+	return out
+}
+
+func normalizeCertDomain(domain string) (string, error) {
+	domain = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(domain)), ".")
+	if domain == "" {
+		return "", fmt.Errorf("域名不能为空")
+	}
+	wildcard := strings.HasPrefix(domain, "*.")
+	base := strings.TrimPrefix(domain, "*.")
+	if err := validate.Domain(base); err != nil {
+		return "", err
+	}
+	if wildcard {
+		return "*." + base, nil
+	}
+	return base, nil
+}
+
+func DomainsUnderZone(domains []string, zone string) error {
+	return DomainsUnderZones(domains, []string{zone})
+}
+
+func DomainsUnderZones(domains []string, zones []string) error {
+	normalized := normalizeManagedZones(zones)
+	if len(normalized) == 0 {
+		return fmt.Errorf("请选择 DNS 凭证")
+	}
+	for _, domain := range domains {
+		base := strings.TrimPrefix(domain, "*.")
+		if !domainUnderAnyZone(base, normalized) {
+			return fmt.Errorf("域名 %s 不在所选 DNS 凭证的管理范围内(%s)", domain, strings.Join(normalized, "、"))
+		}
+	}
+	return nil
+}
+
+func normalizeManagedZones(zones []string) []string {
+	seen := map[string]bool{}
+	out := make([]string, 0, len(zones))
+	for _, zone := range zones {
+		zone = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(zone, "*.")))
+		if zone == "" || seen[zone] {
+			continue
+		}
+		seen[zone] = true
+		out = append(out, zone)
+	}
+	return out
+}
+
+func domainUnderAnyZone(domain string, zones []string) bool {
+	for _, zone := range zones {
+		if domain == zone || strings.HasSuffix(domain, "."+zone) {
+			return true
+		}
+	}
+	return false
+}
+
+func PrimaryCertDomain(domains []string) string {
+	for _, domain := range domains {
+		if !strings.HasPrefix(domain, "*.") {
+			return domain
+		}
+	}
+	if len(domains) > 0 {
+		return strings.TrimPrefix(domains[0], "*.")
+	}
+	return ""
+}
+
+func HasWildcardDomain(domains []string) bool {
+	for _, domain := range domains {
+		if strings.HasPrefix(domain, "*.") {
+			return true
+		}
+	}
+	return false
+}
+
+func LegacyCertDomains(domain string, wildcard bool) []string {
+	domain = strings.ToLower(strings.TrimSpace(domain))
+	if domain == "" {
+		return nil
+	}
+	if wildcard {
+		return []string{domain, "*." + domain}
+	}
+	return []string{domain}
+}
+
+func EncodeCertDomains(domains []string) string {
+	if len(domains) == 0 {
+		return ""
+	}
+	b, err := json.Marshal(domains)
+	if err != nil {
+		return ""
+	}
+	return string(b)
+}
+
+func DecodeCertDomains(raw string, domain string, wildcard bool) []string {
+	raw = strings.TrimSpace(raw)
+	if raw != "" {
+		var domains []string
+		if err := json.Unmarshal([]byte(raw), &domains); err == nil && len(domains) > 0 {
+			normalized, err := NormalizeCertDomains(domains)
+			if err == nil {
+				return normalized
+			}
+		}
+	}
+	return LegacyCertDomains(domain, wildcard)
+}
diff --git a/internal/acme/domains_test.go b/internal/acme/domains_test.go
new file mode 100644
index 0000000..1c7f4da
--- /dev/null
+++ b/internal/acme/domains_test.go
@@ -0,0 +1,43 @@
+package acme
+
+import "testing"
+
+func TestNormalizeCertDomains(t *testing.T) {
+	domains, err := NormalizeCertDomains([]string{"example.com", "*.example.com", "nas.example.com"})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(domains) != 3 {
+		t.Fatalf("expected 3 domains, got %d", len(domains))
+	}
+	if domains[1] != "*.example.com" {
+		t.Fatalf("unexpected wildcard: %s", domains[1])
+	}
+}
+
+func TestDomainsUnderZone(t *testing.T) {
+	domains := []string{"example.com", "*.example.com", "nas.example.com"}
+	if err := DomainsUnderZone(domains, "example.com"); err != nil {
+		t.Fatal(err)
+	}
+	if err := DomainsUnderZone([]string{"other.com"}, "example.com"); err == nil {
+		t.Fatal("expected zone mismatch error")
+	}
+}
+
+func TestDomainsUnderZonesMultiRoot(t *testing.T) {
+	zones := []string{"roven.cc", "chiak.cc"}
+	if err := DomainsUnderZones([]string{"chiak.cc", "*.chiak.cc"}, zones); err != nil {
+		t.Fatal(err)
+	}
+	if err := DomainsUnderZones([]string{"other.com"}, zones); err == nil {
+		t.Fatal("expected zone mismatch error")
+	}
+}
+
+func TestDecodeCertDomainsLegacy(t *testing.T) {
+	got := DecodeCertDomains("", "example.com", true)
+	if len(got) != 2 || got[1] != "*.example.com" {
+		t.Fatalf("unexpected legacy domains: %v", got)
+	}
+}
diff --git a/internal/acme/download.go b/internal/acme/download.go
new file mode 100644
index 0000000..46ef9ce
--- /dev/null
+++ b/internal/acme/download.go
@@ -0,0 +1,117 @@
+package acme
+
+import (
+	"archive/zip"
+	"bytes"
+	"context"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+
+	certstore "github.com/fonu/fonu/internal/certificate"
+)
+
+type DownloadPart string
+
+const (
+	DownloadZIP  DownloadPart = "zip"
+	DownloadCert DownloadPart = "cert"
+	DownloadKey  DownloadPart = "key"
+)
+
+func (s *Service) Download(ctx context.Context, domain string, part string) (filename string, contentType string, data []byte, err error) {
+	domain = strings.ToLower(strings.TrimSpace(domain))
+	if domain == "" {
+		return "", "", nil, fmt.Errorf("域名不能为空")
+	}
+
+	rec, err := s.store.GetByDomain(ctx, domain)
+	if err != nil {
+		return "", "", nil, err
+	}
+	if rec.CertPath == "" || rec.KeyPath == "" {
+		return "", "", nil, fmt.Errorf("证书文件不存在,可能尚未申请成功")
+	}
+	if err := validateCertFilePath(s.cfg.CertsDir(), rec.CertPath); err != nil {
+		return "", "", nil, err
+	}
+	if err := validateCertFilePath(s.cfg.CertsDir(), rec.KeyPath); err != nil {
+		return "", "", nil, err
+	}
+
+	certPEM, err := os.ReadFile(rec.CertPath)
+	if err != nil {
+		return "", "", nil, fmt.Errorf("读取证书失败")
+	}
+	keyPEM, err := os.ReadFile(rec.KeyPath)
+	if err != nil {
+		return "", "", nil, fmt.Errorf("读取私钥失败")
+	}
+
+	base := downloadBaseName(rec)
+	switch DownloadPart(strings.ToLower(strings.TrimSpace(part))) {
+	case "", DownloadZIP:
+		data, err = zipCertBundle(base, certPEM, keyPEM)
+		if err != nil {
+			return "", "", nil, err
+		}
+		return base + ".zip", "application/zip", data, nil
+	case DownloadCert:
+		return base + "-fullchain.pem", "application/x-pem-file", certPEM, nil
+	case DownloadKey:
+		return base + "-privatekey.pem", "application/x-pem-file", keyPEM, nil
+	default:
+		return "", "", nil, fmt.Errorf("不支持的下载类型")
+	}
+}
+
+func downloadBaseName(rec certstore.Record) string {
+	name := strings.TrimPrefix(rec.Domain, "*.")
+	name = strings.ReplaceAll(name, "*", "wildcard")
+	name = strings.ReplaceAll(name, "/", "_")
+	if name == "" {
+		name = "certificate"
+	}
+	return name
+}
+
+func validateCertFilePath(certsDir, path string) error {
+	absCerts, err := filepath.Abs(certsDir)
+	if err != nil {
+		return fmt.Errorf("证书目录无效")
+	}
+	absPath, err := filepath.Abs(path)
+	if err != nil {
+		return fmt.Errorf("证书路径无效")
+	}
+	rel, err := filepath.Rel(absCerts, absPath)
+	if err != nil || strings.HasPrefix(rel, "..") {
+		return fmt.Errorf("证书路径无效")
+	}
+	return nil
+}
+
+func zipCertBundle(base string, certPEM, keyPEM []byte) ([]byte, error) {
+	var buf bytes.Buffer
+	zw := zip.NewWriter(&buf)
+	if err := writeZipEntry(zw, "fullchain.pem", certPEM); err != nil {
+		return nil, err
+	}
+	if err := writeZipEntry(zw, "privatekey.pem", keyPEM); err != nil {
+		return nil, err
+	}
+	if err := zw.Close(); err != nil {
+		return nil, err
+	}
+	return buf.Bytes(), nil
+}
+
+func writeZipEntry(zw *zip.Writer, name string, data []byte) error {
+	w, err := zw.Create(name)
+	if err != nil {
+		return err
+	}
+	_, err = w.Write(data)
+	return err
+}
diff --git a/internal/acme/download_test.go b/internal/acme/download_test.go
new file mode 100644
index 0000000..b1be252
--- /dev/null
+++ b/internal/acme/download_test.go
@@ -0,0 +1,26 @@
+package acme
+
+import (
+	"path/filepath"
+	"testing"
+)
+
+func TestValidateCertFilePath(t *testing.T) {
+	certsDir := filepath.Clean("/data/certs")
+	if err := validateCertFilePath(certsDir, "/data/certs/example.com/fullchain.pem"); err != nil {
+		t.Fatalf("expected valid path: %v", err)
+	}
+	if err := validateCertFilePath(certsDir, "/etc/passwd"); err == nil {
+		t.Fatal("expected invalid path")
+	}
+}
+
+func TestZipCertBundle(t *testing.T) {
+	data, err := zipCertBundle("example.com", []byte("cert"), []byte("key"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(data) == 0 {
+		t.Fatal("empty zip")
+	}
+}
diff --git a/internal/acme/job.go b/internal/acme/job.go
new file mode 100644
index 0000000..c6cb48c
--- /dev/null
+++ b/internal/acme/job.go
@@ -0,0 +1,183 @@
+package acme
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"path/filepath"
+	"sync"
+	"time"
+
+	certstore "github.com/fonu/fonu/internal/certificate"
+)
+
+type JobEvent struct {
+	Type    string          `json:"type"`
+	Level   string          `json:"level,omitempty"`
+	Message string          `json:"message,omitempty"`
+	Result  *JobDonePayload `json:"result,omitempty"`
+}
+
+type JobDonePayload struct {
+	OK        bool   `json:"ok"`
+	Error     string `json:"error,omitempty"`
+	Domain    string `json:"domain,omitempty"`
+	CertPath  string `json:"cert_path,omitempty"`
+	KeyPath   string `json:"key_path,omitempty"`
+	CertDir   string `json:"cert_dir,omitempty"`
+	ExpiresAt string `json:"expires_at,omitempty"`
+}
+
+type Job struct {
+	id     string
+	mu     sync.Mutex
+	events []JobEvent
+	done   bool
+	waiters map[chan JobEvent]struct{}
+}
+
+type JobManager struct {
+	mu   sync.Mutex
+	jobs map[string]*Job
+}
+
+func NewJobManager() *JobManager {
+	return &JobManager{jobs: map[string]*Job{}}
+}
+
+func (m *JobManager) Create() *Job {
+	job := &Job{
+		id:      fmt.Sprintf("%d", time.Now().UnixNano()),
+		waiters: map[chan JobEvent]struct{}{},
+	}
+	m.mu.Lock()
+	m.jobs[job.id] = job
+	m.mu.Unlock()
+	return job
+}
+
+func (m *JobManager) Get(id string) *Job {
+	m.mu.Lock()
+	job := m.jobs[id]
+	m.mu.Unlock()
+	return job
+}
+
+func (j *Job) ID() string {
+	return j.id
+}
+
+func (j *Job) Append(level, message string) {
+	j.emit(JobEvent{Type: "log", Level: level, Message: message})
+}
+
+func (j *Job) Info(message string)  { j.Append("info", message) }
+func (j *Job) Warn(message string)  { j.Append("warn", message) }
+func (j *Job) Error(message string) { j.Append("error", message) }
+
+func (j *Job) Finish(result JobDonePayload) {
+	j.emit(JobEvent{Type: "done", Result: &result})
+}
+
+func (j *Job) emit(ev JobEvent) {
+	j.mu.Lock()
+	j.events = append(j.events, ev)
+	if ev.Type == "done" {
+		j.done = true
+	}
+	for ch := range j.waiters {
+		ch <- ev
+	}
+	j.mu.Unlock()
+}
+
+func (j *Job) Snapshot() []JobEvent {
+	j.mu.Lock()
+	out := append([]JobEvent(nil), j.events...)
+	j.mu.Unlock()
+	return out
+}
+
+func (j *Job) Subscribe() chan JobEvent {
+	ch := make(chan JobEvent, 32)
+	j.mu.Lock()
+	j.waiters[ch] = struct{}{}
+	j.mu.Unlock()
+	return ch
+}
+
+func (j *Job) Unsubscribe(ch chan JobEvent) {
+	j.mu.Lock()
+	delete(j.waiters, ch)
+	j.mu.Unlock()
+	close(ch)
+}
+
+func (m *JobManager) Stream(ctx context.Context, w http.ResponseWriter, id string, flush func() error) error {
+	job := m.Get(id)
+	if job == nil {
+		return fmt.Errorf("任务不存在或已过期")
+	}
+
+	ch := job.Subscribe()
+	defer job.Unsubscribe(ch)
+
+	for _, ev := range job.Snapshot() {
+		if err := writeJobSSE(w, ev); err != nil {
+			return err
+		}
+		if err := flush(); err != nil {
+			return err
+		}
+		if ev.Type == "done" {
+			return nil
+		}
+	}
+
+	for {
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		case ev := <-ch:
+			if err := writeJobSSE(w, ev); err != nil {
+				return err
+			}
+			if err := flush(); err != nil {
+				return err
+			}
+			if ev.Type == "done" {
+				return nil
+			}
+		}
+	}
+}
+
+func writeJobSSE(w io.Writer, ev JobEvent) error {
+	data, err := json.Marshal(ev)
+	if err != nil {
+		return err
+	}
+	_, err = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Type, data)
+	return err
+}
+
+func jobDoneFromRecord(rec certstore.Record, err error) JobDonePayload {
+	if err != nil {
+		return JobDonePayload{OK: false, Error: err.Error()}
+	}
+	payload := JobDonePayload{
+		OK:       true,
+		Domain:   rec.Domain,
+		CertPath: rec.CertPath,
+		KeyPath:  rec.KeyPath,
+	}
+	if rec.CertPath != "" {
+		payload.CertDir = filepath.Dir(rec.CertPath)
+	}
+	if rec.ExpiresAt != nil {
+		payload.ExpiresAt = rec.ExpiresAt.UTC().Format(time.RFC3339)
+	}
+	return payload
+}
diff --git a/internal/acme/service.go b/internal/acme/service.go
new file mode 100644
index 0000000..97dbd9f
--- /dev/null
+++ b/internal/acme/service.go
@@ -0,0 +1,621 @@
+package acme
+
+import (
+	"context"
+	"crypto"
+	"crypto/ecdsa"
+	"crypto/elliptic"
+	"crypto/rand"
+	"crypto/x509"
+	"encoding/pem"
+	"fmt"
+	"log/slog"
+	"net/http"
+	"net/mail"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/go-acme/lego/v4/certcrypto"
+	legocert "github.com/go-acme/lego/v4/certificate"
+	"github.com/go-acme/lego/v4/lego"
+	"github.com/go-acme/lego/v4/registration"
+
+	certstore "github.com/fonu/fonu/internal/certificate"
+	"github.com/fonu/fonu/internal/config"
+	"github.com/fonu/fonu/internal/ddns"
+	"github.com/fonu/fonu/internal/notify"
+	"github.com/fonu/fonu/internal/service"
+	"github.com/fonu/fonu/internal/settings"
+)
+
+type Service struct {
+	cfg      config.Config
+	store    *certstore.Store
+	ddnsSvc  *ddns.Service
+	settings *settings.Store
+	proxySvc *service.ProxyService
+	logger   *slog.Logger
+	notify   *notify.Service
+	jobs     *JobManager
+
+	expiryAlertMu sync.Mutex
+	expiryAlerted map[string]string
+}
+
+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"),
+		notify:        notifySvc,
+		jobs:          NewJobManager(),
+		expiryAlerted: map[string]string{},
+	}
+}
+
+func (s *Service) List(ctx context.Context) ([]certstore.Record, error) {
+	return s.store.List(ctx)
+}
+
+func (s *Service) StartApply(ctx context.Context, domains []string, ca, email string, ddnsConfigID int64) (string, error) {
+	if ddnsConfigID <= 0 {
+		return "", fmt.Errorf("请选择 DNS 任务")
+	}
+	domains, ca, email, primary, dnsZone, provider, cred, err := s.prepareApplyWithConfig(ctx, domains, ca, email, ddnsConfigID)
+	if err != nil {
+		return "", err
+	}
+	job := s.jobs.Create()
+	go s.runApplyJob(job, dnsZone, domains, ca, email, primary, provider, cred)
+	return job.ID(), nil
+}
+
+func (s *Service) StreamJob(ctx context.Context, w http.ResponseWriter, jobID string, flush func() error) error {
+	return s.jobs.Stream(ctx, w, jobID, flush)
+}
+
+func (s *Service) prepareApplyWithConfig(ctx context.Context, domains []string, ca, email string, ddnsConfigID int64) ([]string, string, string, string, string, string, ddns.Credentials, error) {
+	domains, err := NormalizeCertDomains(domains)
+	if err != nil {
+		return nil, "", "", "", "", "", ddns.Credentials{}, err
+	}
+	primary := PrimaryCertDomain(domains)
+	if primary == "" {
+		return nil, "", "", "", "", "", ddns.Credentials{}, fmt.Errorf("请填写至少一个域名")
+	}
+	email, err = s.resolveACMEEmail(ctx, email)
+	if err != nil {
+		return nil, "", "", "", "", "", ddns.Credentials{}, err
+	}
+	ca, err = s.resolveCA(ctx, ca)
+	if err != nil {
+		return nil, "", "", "", "", "", ddns.Credentials{}, err
+	}
+	if err := ValidateCADomains(ca, domains); err != nil {
+		return nil, "", "", "", "", "", ddns.Credentials{}, err
+	}
+
+	cfg, provider, cred, err := s.ddnsSvc.CredentialsForConfigID(ctx, ddnsConfigID)
+	if err != nil {
+		return nil, "", "", "", "", "", ddns.Credentials{}, err
+	}
+	if err := validateDNSCredentials(provider, cred); err != nil {
+		return nil, "", "", "", "", "", ddns.Credentials{}, err
+	}
+	dnsZone := cfg.RootDomain
+	zones := cfg.ManagedDNSZones()
+	if len(zones) > 0 {
+		dnsZone = zones[0]
+	}
+	return domains, ca, email, primary, dnsZone, provider, cred, nil
+}
+
+func (s *Service) prepareApplyByZone(ctx context.Context, dnsZone string, domains []string, ca, email string) ([]string, string, string, string, string, string, ddns.Credentials, error) {
+	domains, err := NormalizeCertDomains(domains)
+	if err != nil {
+		return nil, "", "", "", "", "", ddns.Credentials{}, err
+	}
+	dnsZone = strings.ToLower(strings.TrimSpace(dnsZone))
+	ddnsCfg, err := s.ddnsSvc.ConfigForDNSZone(ctx, dnsZone)
+	if err != nil {
+		return nil, "", "", "", "", "", ddns.Credentials{}, err
+	}
+	if err := DomainsUnderZones(domains, ddnsCfg.ManagedDNSZones()); err != nil {
+		return nil, "", "", "", "", "", ddns.Credentials{}, err
+	}
+	primary := PrimaryCertDomain(domains)
+	if primary == "" {
+		return nil, "", "", "", "", "", ddns.Credentials{}, fmt.Errorf("请填写至少一个域名")
+	}
+	email, err = s.resolveACMEEmail(ctx, email)
+	if err != nil {
+		return nil, "", "", "", "", "", ddns.Credentials{}, err
+	}
+	ca, err = s.resolveCA(ctx, ca)
+	if err != nil {
+		return nil, "", "", "", "", "", ddns.Credentials{}, err
+	}
+	if err := ValidateCADomains(ca, domains); err != nil {
+		return nil, "", "", "", "", "", ddns.Credentials{}, err
+	}
+	provider, cred, err := s.dnsCredentialsForDomain(ctx, dnsZone)
+	if err != nil {
+		return nil, "", "", "", "", "", ddns.Credentials{}, err
+	}
+	return domains, ca, email, primary, dnsZone, provider, cred, nil
+}
+
+func (s *Service) runApplyJob(job *Job, dnsZone string, domains []string, ca, email, primary, provider string, cred ddns.Credentials) {
+	ctx := context.Background()
+	job.Info("开始申请证书…")
+	job.Info(fmt.Sprintf("主域名: %s", primary))
+	job.Info(fmt.Sprintf("覆盖域名: %s", strings.Join(domains, ", ")))
+	job.Info(fmt.Sprintf("DNS 凭证: %s (%s)", dnsZone, provider))
+
+	rec, err := s.obtain(ctx, job, ca, email, provider, cred, primary, domains)
+	if err != nil {
+		s.markCertError(ctx, primary, domains, ca, err)
+		job.Error(err.Error())
+		job.Finish(JobDonePayload{OK: false, Error: err.Error(), Domain: primary})
+		return
+	}
+	if err := s.settings.Set(ctx, settings.KeyACMECA, ca); err != nil {
+		job.Warn("保存 ACME 颁发机构设置失败: " + err.Error())
+	}
+	if err := s.proxySvc.ReloadAll(ctx); err != nil {
+		job.Warn("Nginx 重载失败: " + err.Error())
+	} else {
+		job.Info("Nginx 已重载")
+	}
+	job.Info("证书申请完成")
+	job.Finish(jobDoneFromRecord(rec, nil))
+}
+
+func (s *Service) Apply(ctx context.Context, dnsZone string, domains []string, ca, email string) ([]certstore.Record, error) {
+	domains, ca, email, primary, _, provider, cred, err := s.prepareApplyByZone(ctx, dnsZone, domains, ca, email)
+	if err != nil {
+		return nil, err
+	}
+	rec, err := s.obtain(ctx, nil, ca, email, provider, cred, primary, domains)
+	if err != nil {
+		s.markCertError(ctx, primary, domains, ca, err)
+		return nil, err
+	}
+	if err := s.settings.Set(ctx, settings.KeyACMECA, ca); err != nil {
+		s.logger.Warn("save acme ca setting failed", "error", err.Error())
+	}
+	if err := s.proxySvc.ReloadAll(ctx); err != nil {
+		s.logger.Error("nginx reload after cert apply failed", "error", err.Error())
+	}
+	_ = rec
+	return s.store.List(ctx)
+}
+
+func (s *Service) Import(ctx context.Context, certPEM, keyPEM, certFile, keyFile string) (certstore.Record, error) {
+	certBytes, err := certstore.ResolvePEMSource(certPEM, certFile)
+	if err != nil {
+		return certstore.Record{}, fmt.Errorf("证书:%w", err)
+	}
+	keyBytes, err := certstore.ResolvePEMSource(keyPEM, keyFile)
+	if err != nil {
+		return certstore.Record{}, fmt.Errorf("私钥:%w", err)
+	}
+
+	info, err := certstore.InspectImport(certBytes, keyBytes)
+	if err != nil {
+		return certstore.Record{}, err
+	}
+
+	certPath, keyPath, err := writeCertFiles(s.cfg.CertsDir(), info.Domains, certBytes, keyBytes)
+	if err != nil {
+		return certstore.Record{}, err
+	}
+
+	status := "ok"
+	if time.Now().After(info.ExpiresAt) {
+		status = "expired"
+	}
+
+	domainsJSON := EncodeCertDomains(info.Domains)
+	record, err := s.store.Upsert(ctx, info.Primary, info.Wildcard, domainsJSON, "imported", certPath, keyPath, info.ExpiresAt, status, "")
+	if err != nil {
+		return certstore.Record{}, err
+	}
+
+	if err := s.proxySvc.ReloadAll(ctx); err != nil {
+		s.logger.Warn("nginx reload after cert import failed", "error", err.Error())
+	}
+	s.logger.Info("certificate imported", "domain", info.Primary, "domains", info.Domains, "expires_at", info.ExpiresAt.Format(time.RFC3339))
+	return record, nil
+}
+
+func (s *Service) Delete(ctx context.Context, domain string) error {
+	domain = strings.ToLower(strings.TrimSpace(domain))
+	if domain == "" {
+		return fmt.Errorf("域名不能为空")
+	}
+	existing, err := s.store.GetByDomain(ctx, domain)
+	if err != nil {
+		return err
+	}
+	if err := removeCertFiles(s.cfg.CertsDir(), existing); err != nil {
+		s.logger.Warn("remove cert files failed", "domain", domain, "error", err.Error())
+	}
+	if err := s.store.Delete(ctx, domain); err != nil {
+		return err
+	}
+	if err := s.proxySvc.ReloadAll(ctx); err != nil {
+		s.logger.Warn("nginx reload after cert delete failed", "error", err.Error())
+	}
+	s.logger.Info("certificate deleted", "domain", domain)
+	return nil
+}
+
+func (s *Service) Renew(ctx context.Context, domain string, ca string) (certstore.Record, error) {
+	rootDomain := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(domain, "*.")))
+	if rootDomain == "" {
+		return certstore.Record{}, fmt.Errorf("请选择要续签的证书域名")
+	}
+	existing, err := s.store.GetByDomain(ctx, rootDomain)
+	if err != nil {
+		return certstore.Record{}, err
+	}
+	if existing.ACMECA == "imported" {
+		return certstore.Record{}, fmt.Errorf("手动导入的证书请重新导入,无法自动续签")
+	}
+	if ca == "" {
+		ca = existing.ACMECA
+	}
+	ca, err = s.resolveCA(ctx, ca)
+	if err != nil {
+		return certstore.Record{}, err
+	}
+	email, err := s.resolveACMEEmail(ctx, "")
+	if err != nil {
+		return certstore.Record{}, err
+	}
+	domains := existing.Domains
+	if len(domains) == 0 {
+		domains = LegacyCertDomains(existing.Domain, existing.Wildcard)
+	}
+	dnsZone, err := s.resolveDNSZone(ctx, domains)
+	if err != nil {
+		return certstore.Record{}, err
+	}
+	provider, cred, err := s.dnsCredentialsForDomain(ctx, dnsZone)
+	if err != nil {
+		return certstore.Record{}, err
+	}
+	if _, err := s.obtain(ctx, nil, ca, email, provider, cred, existing.Domain, domains); err != nil {
+		s.markCertError(ctx, rootDomain, domains, ca, err)
+		return certstore.Record{}, err
+	}
+	if err := s.proxySvc.ReloadAll(ctx); err != nil {
+		s.logger.Error("nginx reload after cert renew failed", "error", err.Error())
+	}
+	rec, err := s.store.GetByDomain(ctx, rootDomain)
+	if err != nil {
+		return certstore.Record{}, err
+	}
+	if s.notify != nil {
+		expires := "未知"
+		if rec.ExpiresAt != nil {
+			expires = rec.ExpiresAt.Local().Format("2006-01-02")
+		}
+		s.notify.Alert(ctx, notify.EventCertRenewSuccess, "证书续签成功",
+			fmt.Sprintf("%s 已续签,到期 %s", rootDomain, expires))
+	}
+	return rec, nil
+}
+
+func (s *Service) Tick(ctx context.Context) {
+	records, err := s.store.List(ctx)
+	if err != nil {
+		return
+	}
+	threshold, err := s.settings.GetInt(ctx, settings.KeyCertRenewThreshold)
+	if err != nil || threshold <= 0 {
+		threshold = 30
+	}
+	for _, rec := range records {
+		if rec.ExpiresAt == nil {
+			continue
+		}
+		if rec.ACMECA != "imported" && rec.DaysLeft <= threshold {
+			s.maybeAlertCertExpiry(ctx, rec.Domain, rec.DaysLeft)
+		}
+		if rec.DaysLeft <= threshold {
+			if rec.ACMECA == "imported" {
+				continue
+			}
+			if _, err := s.Renew(ctx, rec.Domain, rec.ACMECA); err != nil {
+				s.logger.Error("certificate renew failed", "domain", rec.Domain, "error", err.Error())
+				if s.notify != nil {
+					s.notify.Alert(ctx, notify.EventCertRenewFailure, "证书续签失败",
+						fmt.Sprintf("%s: %s", rec.Domain, err.Error()))
+				}
+			}
+		}
+	}
+}
+
+func (s *Service) maybeAlertCertExpiry(ctx context.Context, domain string, daysLeft int) {
+	if s.notify == nil {
+		return
+	}
+	today := time.Now().Format("2006-01-02")
+	s.expiryAlertMu.Lock()
+	if s.expiryAlerted[domain] == today {
+		s.expiryAlertMu.Unlock()
+		return
+	}
+	s.expiryAlerted[domain] = today
+	s.expiryAlertMu.Unlock()
+
+	msg := fmt.Sprintf("%s 剩余 %d 天到期", domain, daysLeft)
+	if daysLeft <= 0 {
+		msg = fmt.Sprintf("%s 已到期或即将失效", domain)
+	}
+	s.notify.Alert(ctx, notify.EventCertExpiry, "证书即将到期", msg)
+}
+
+func (s *Service) markCertError(ctx context.Context, primary string, domains []string, ca string, err error) {
+	if err == nil {
+		return
+	}
+	errMsg := err.Error()
+	s.logger.Error("certificate operation failed", "domain", primary, "error", errMsg)
+	_, upsertErr := s.store.Upsert(ctx, primary, HasWildcardDomain(domains), EncodeCertDomains(domains), ca, "", "", time.Time{}, "error", errMsg)
+	if upsertErr != nil {
+		s.logger.Error("save cert error status failed", "domain", primary, "error", upsertErr.Error())
+	}
+}
+
+func (s *Service) resolveCA(ctx context.Context, ca string) (string, error) {
+	if strings.TrimSpace(ca) == "" {
+		ca, err := s.settings.Get(ctx, settings.KeyACMECA)
+		if err != nil || strings.TrimSpace(ca) == "" {
+			ca = CALetsEncrypt
+		}
+	}
+	ca = NormalizeCA(ca)
+	if err := ValidateCA(ca); err != nil {
+		return "", err
+	}
+	return ca, nil
+}
+
+func (s *Service) registerACMEAccount(ctx context.Context, client *lego.Client, ca string, logStep func(string)) (*registration.Resource, error) {
+	reg, err := client.Registration.ResolveAccountByKey()
+	if err == nil {
+		return reg, nil
+	}
+	logStep("注册 ACME 账户…")
+	switch NormalizeCA(ca) {
+	case CAZeroSSL:
+		apiKey, err := s.settings.Get(ctx, settings.KeyZeroSSLAPIKey)
+		if err != nil {
+			return nil, err
+		}
+		kid, hmac, err := zerosslEABCredentials(ctx, apiKey)
+		if err != nil {
+			return nil, err
+		}
+		return client.Registration.RegisterWithExternalAccountBinding(registration.RegisterEABOptions{
+			TermsOfServiceAgreed: true,
+			Kid:                  kid,
+			HmacEncoded:          hmac,
+		})
+	default:
+		return client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
+	}
+}
+
+func (s *Service) obtain(ctx context.Context, job *Job, ca, email, provider string, cred ddns.Credentials, rootDomain string, domains []string) (certstore.Record, error) {
+	logStep := func(msg string) {
+		if job != nil {
+			job.Info(msg)
+		}
+	}
+	caDir, err := DirectoryURL(ca)
+	if err != nil {
+		return certstore.Record{}, err
+	}
+	logStep(fmt.Sprintf("连接 ACME 服务器 (%s)", CALabel(ca)))
+	user, err := newUser(email)
+	if err != nil {
+		return certstore.Record{}, err
+	}
+	config := lego.NewConfig(user)
+	config.CADirURL = caDir
+	config.Certificate.KeyType = certcrypto.EC256
+
+	client, err := lego.NewClient(config)
+	if err != nil {
+		return certstore.Record{}, err
+	}
+
+	dnsProvider, err := newDNS01Provider(provider, cred)
+	if err != nil {
+		return certstore.Record{}, fmt.Errorf("初始化 DNS Provider 失败:%w", err)
+	}
+	dnsProvider = wrapDNSProvider(dnsProvider, job)
+	if err := client.Challenge.SetDNS01Provider(dnsProvider); err != nil {
+		return certstore.Record{}, err
+	}
+
+	logStep("检查 ACME 账户…")
+	reg, err := s.registerACMEAccount(ctx, client, ca, logStep)
+	if err != nil {
+		return certstore.Record{}, fmt.Errorf("ACME 注册失败:%w", err)
+	}
+	user.registration = reg
+
+	logStep("开始 DNS-01 验证并申请证书(可能需要 1~3 分钟)…")
+	request := legocert.ObtainRequest{
+		Domains: domains,
+		Bundle:  true,
+	}
+	cert, err := client.Certificate.Obtain(request)
+	if err != nil {
+		return certstore.Record{}, fmt.Errorf("证书申请失败:%w", err)
+	}
+
+	certPath, keyPath, err := writeCertFiles(s.cfg.CertsDir(), domains, cert.Certificate, cert.PrivateKey)
+	if err != nil {
+		return certstore.Record{}, err
+	}
+	logStep(fmt.Sprintf("证书已保存: %s", certPath))
+	logStep(fmt.Sprintf("私钥已保存: %s", keyPath))
+
+	expiresAt, err := parseCertExpiry(cert.Certificate)
+	if err != nil {
+		expiresAt = time.Now().Add(90 * 24 * time.Hour)
+	}
+	logStep(fmt.Sprintf("到期时间: %s", expiresAt.Local().Format("2006-01-02 15:04:ss")))
+	rec, err := s.store.Upsert(ctx, rootDomain, HasWildcardDomain(domains), EncodeCertDomains(domains), ca, certPath, keyPath, expiresAt, "ok", "")
+	if err != nil {
+		return certstore.Record{}, err
+	}
+	s.logger.Info("certificate obtained", "domain", rootDomain, "ca", ca, "expires_at", expiresAt.Format(time.RFC3339))
+	return rec, nil
+}
+
+func (s *Service) resolveACMEEmail(ctx context.Context, email string) (string, error) {
+	email = strings.TrimSpace(email)
+	if email == "" {
+		stored, err := s.settings.Get(ctx, settings.KeyACMEEmail)
+		if err != nil || strings.TrimSpace(stored) == "" {
+			return "", fmt.Errorf("请先填写 ACME 邮箱,用于注册 ACME 账户(首次申请必填)")
+		}
+		email = strings.TrimSpace(stored)
+	}
+	if err := validateACMEEmail(email); err != nil {
+		return "", err
+	}
+	if err := s.settings.Set(ctx, settings.KeyACMEEmail, email); err != nil {
+		s.logger.Warn("save acme email failed", "error", err.Error())
+	}
+	return email, nil
+}
+
+func validateACMEEmail(email string) error {
+	addr, err := mail.ParseAddress(email)
+	if err != nil || addr.Address != email {
+		return fmt.Errorf("ACME 邮箱格式不正确,请填写类似 admin@example.com 的地址")
+	}
+	return nil
+}
+
+func (s *Service) resolveDNSZone(ctx context.Context, domains []string) (string, error) {
+	for _, domain := range domains {
+		cfg, err := s.ddnsSvc.ConfigForAnyDomain(ctx, domain)
+		if err == nil {
+			return cfg.RootDomain, nil
+		}
+	}
+	return "", fmt.Errorf("请先在 DDNS 页面配置对应域名的 DNS 凭证")
+}
+
+func (s *Service) dnsCredentialsForDomain(ctx context.Context, rootDomain string) (string, ddns.Credentials, error) {
+	provider, cred, err := s.ddnsSvc.CredentialsForDomain(ctx, rootDomain)
+	if err != nil {
+		return "", ddns.Credentials{}, fmt.Errorf("请先在 DDNS 页面为 %s 配置 DNS 凭证", rootDomain)
+	}
+	if err := validateDNSCredentials(provider, cred); err != nil {
+		return "", ddns.Credentials{}, err
+	}
+	return provider, cred, nil
+}
+
+func writeCertFiles(certsDir string, domains []string, certPEM, keyPEM []byte) (string, string, error) {
+	names := certStorageNames(domains)
+	if len(names) == 0 {
+		return "", "", fmt.Errorf("证书域名不能为空")
+	}
+	var certPath, keyPath string
+	for _, name := range names {
+		dir := filepath.Join(certsDir, name)
+		if err := os.MkdirAll(dir, 0o755); err != nil {
+			return "", "", err
+		}
+		cp := filepath.Join(dir, "fullchain.pem")
+		kp := filepath.Join(dir, "privatekey.pem")
+		if err := os.WriteFile(cp, certPEM, 0o600); err != nil {
+			return "", "", err
+		}
+		if err := os.WriteFile(kp, keyPEM, 0o600); err != nil {
+			return "", "", err
+		}
+		certPath, keyPath = cp, kp
+	}
+	return certPath, keyPath, nil
+}
+
+func removeCertFiles(certsDir string, record certstore.Record) error {
+	domains := record.Domains
+	if len(domains) == 0 {
+		domains = LegacyCertDomains(record.Domain, record.Wildcard)
+	}
+	var firstErr error
+	for _, name := range certStorageNames(domains) {
+		dir := filepath.Join(certsDir, name)
+		if err := os.RemoveAll(dir); err != nil && firstErr == nil {
+			firstErr = err
+		}
+	}
+	return firstErr
+}
+
+func certStorageNames(domains []string) []string {
+	seen := map[string]bool{}
+	var names []string
+	for _, domain := range domains {
+		name := domain
+		if strings.HasPrefix(domain, "*.") {
+			name = "wildcard." + strings.TrimPrefix(domain, "*.")
+		}
+		if seen[name] {
+			continue
+		}
+		seen[name] = true
+		names = append(names, name)
+	}
+	return names
+}
+
+func parseCertExpiry(pemBytes []byte) (time.Time, error) {
+	block, _ := pem.Decode(pemBytes)
+	if block == nil {
+		return time.Time{}, fmt.Errorf("invalid pem")
+	}
+	cert, err := x509.ParseCertificate(block.Bytes)
+	if err != nil {
+		return time.Time{}, err
+	}
+	return cert.NotAfter, nil
+}
+
+type acmeUser struct {
+	email        string
+	registration *registration.Resource
+	key          crypto.PrivateKey
+}
+
+func (u *acmeUser) GetEmail() string                        { return u.email }
+func (u *acmeUser) GetRegistration() *registration.Resource { return u.registration }
+func (u *acmeUser) GetPrivateKey() crypto.PrivateKey        { return u.key }
+
+func newUser(email string) (*acmeUser, error) {
+	privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	if err != nil {
+		return nil, err
+	}
+	return &acmeUser{email: email, key: privateKey}, nil
+}
diff --git a/internal/acme/volcengine_dns.go b/internal/acme/volcengine_dns.go
new file mode 100644
index 0000000..20ca455
--- /dev/null
+++ b/internal/acme/volcengine_dns.go
@@ -0,0 +1,37 @@
+package acme
+
+import (
+	"context"
+
+	"github.com/go-acme/lego/v4/challenge"
+	"github.com/go-acme/lego/v4/challenge/dns01"
+
+	"github.com/fonu/fonu/internal/ddns"
+)
+
+type volcengineDNS struct {
+	provider *ddns.Volcengine
+	cred     ddns.Credentials
+}
+
+func newVolcengineDNSProvider(cred ddns.Credentials) (challenge.Provider, error) {
+	if err := cred.Validate("volcengine"); err != nil {
+		return nil, err
+	}
+	return &volcengineDNS{
+		provider: ddns.NewVolcengine(),
+		cred:     cred,
+	}, nil
+}
+
+func (p *volcengineDNS) Present(domain, token, keyAuth string) error {
+	info := dns01.GetChallengeInfo(domain, keyAuth)
+	fqdn := dns01.UnFqdn(info.EffectiveFQDN)
+	return p.provider.UpsertTXTRecord(context.Background(), p.cred, fqdn, info.Value)
+}
+
+func (p *volcengineDNS) CleanUp(domain, token, keyAuth string) error {
+	info := dns01.GetChallengeInfo(domain, keyAuth)
+	fqdn := dns01.UnFqdn(info.EffectiveFQDN)
+	return p.provider.DeleteTXTRecord(context.Background(), p.cred, fqdn, info.Value)
+}
diff --git a/internal/acme/zerossl.go b/internal/acme/zerossl.go
new file mode 100644
index 0000000..979f219
--- /dev/null
+++ b/internal/acme/zerossl.go
@@ -0,0 +1,62 @@
+package acme
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+)
+
+type zeroSSLEABResponse struct {
+	Success    int    `json:"success"`
+	EABKid     string `json:"eab_kid"`
+	EABHmacKey string `json:"eab_hmac_key"`
+	Error      struct {
+		Code int    `json:"code"`
+		Type string `json:"type"`
+	} `json:"error"`
+}
+
+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)
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+	if err != nil {
+		return "", "", err
+	}
+
+	client := &http.Client{Timeout: 30 * time.Second}
+	resp, err := client.Do(req)
+	if err != nil {
+		return "", "", fmt.Errorf("请求 ZeroSSL API 失败:%w", err)
+	}
+	defer resp.Body.Close()
+
+	body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+	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)
+	}
+	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 未返回有效的 EAB 凭据")
+	}
+	return payload.EABKid, payload.EABHmacKey, nil
+}
diff --git a/internal/agent/server.go b/internal/agent/server.go
new file mode 100644
index 0000000..a666dec
--- /dev/null
+++ b/internal/agent/server.go
@@ -0,0 +1,1450 @@
+package agent
+
+import (
+	"archive/tar"
+	"compress/gzip"
+	"context"
+	"crypto/rand"
+	"encoding/hex"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"log"
+	"net"
+	"net/http"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"regexp"
+	"runtime"
+	"sort"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+)
+
+// AgentVersion 随每次 agent 行为变更手动递增;status/ping 与安装脚本都会回报,用于确认 VPS 上跑的是哪个版本。
+// 0.5.0:目录判定改用 nginx -T 自省(dump 完整生效配置,按 "# configuration file" 标记
+// 找出真正被加载的文件所在目录,零猜测);include 链递归与 FONU_AGENT_NGINX_CONF_DIR 保留为回退。
+// 0.9.0:Nginx 可用性显式探测(status 暴露 nginx_ok/nginx_missing)、部署前预检、一键安装 Nginx 端点。
+// 0.9.1:status 补充 nginx_running / nginx_version,供页面展示 Nginx 状态卡片。
+const AgentVersion = "0.9.1"
+
+// Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
+type Config struct {
+	ListenAddr     string
+	Token          string
+	NginxConfDir   string
+	NginxBinary    string
+	CertsDir       string
+	FrpsConfigPath string
+	FrpsBinary     string
+	DataDir        string
+}
+
+// Server 暴露 agent HTTP 服务。
+type Server struct {
+	cfg Config
+	mu  sync.Mutex
+	// nginx 可用性探测缓存:status 轮询频繁,避免每次都起 nginx -t 进程
+	nginxStatusMu sync.Mutex
+	nginxStatusAt time.Time
+	nginxStatus   nginxProbe
+}
+
+// nginxProbe Nginx 可用性探测结果。
+type nginxProbe struct {
+	OK      bool   `json:"ok"`      // nginx -t 通过
+	Missing bool   `json:"missing"` // 未找到可执行文件
+	Running bool   `json:"running"` // 有 nginx 进程在运行
+	Path    string `json:"path,omitempty"`
+	Version string `json:"version,omitempty"`
+	Error   string `json:"error,omitempty"`
+}
+
+// resolveNginxConfDir 预先解析并缓存 vhost 目录:探测结果落盘到 DataDir/nginx_conf_dir,
+// 启动时后台执行一次(不阻断监听),路由写入时优先读缓存。
+// 返回目录与错误;缓存命中时直接返回。
+func (s *Server) resolveNginxConfDir() (string, error) {
+	s.mu.Lock()
+	cached := strings.TrimSpace(s.cfg.NginxConfDir)
+	s.mu.Unlock()
+	if cached != "" {
+		return cached, nil
+	}
+	dir, err := detectNginxConfDir(s.nginxPath())
+	if err != nil {
+		return "", err
+	}
+	s.mu.Lock()
+	s.cfg.NginxConfDir = dir
+	s.mu.Unlock()
+	_ = os.MkdirAll(dir, 0o755)
+	// 结果落盘,下次启动优先复用(避免每次启动都跑 -T)
+	if s.cfg.DataDir != "" {
+		_ = os.WriteFile(filepath.Join(s.cfg.DataDir, "nginx_conf_dir"), []byte(dir), 0o644)
+	}
+	return dir, nil
+}
+
+// preloadNginxConfDir 启动后台预解析:成功与否不影响监听,结果进缓存供状态与路由写入使用。
+func (s *Server) preloadNginxConfDir() {
+	go func() {
+		if _, err := s.resolveNginxConfDir(); err != nil {
+			log.Printf("nginx conf dir 预解析失败(路由写入时会重试): %v", err)
+		}
+	}()
+}
+
+var domainPattern = regexp.MustCompile(`^(?i:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.(?i:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))+$`)
+
+func NewServer(cfg Config) (*Server, error) {
+	if strings.TrimSpace(cfg.ListenAddr) == "" {
+		cfg.ListenAddr = ":7700"
+	}
+	if strings.TrimSpace(cfg.NginxConfDir) == "" {
+		// 目录探测失败不阻断启动:agent 照常提供状态接口,路由写入时重试探测(systemd PATH 下可能找不到 nginx,写入前会补全路径)
+		cfg.NginxConfDir = ""
+	}
+	if strings.TrimSpace(cfg.NginxBinary) == "" {
+		cfg.NginxBinary = "nginx"
+	}
+	if strings.TrimSpace(cfg.CertsDir) == "" {
+		cfg.CertsDir = "/var/lib/fonu-agent/certs"
+	}
+	if strings.TrimSpace(cfg.FrpsConfigPath) == "" {
+		cfg.FrpsConfigPath = "/etc/frp/frps.toml"
+	}
+	if strings.TrimSpace(cfg.FrpsBinary) == "" {
+		cfg.FrpsBinary = "/usr/local/bin/frps"
+	} else if !filepath.IsAbs(cfg.FrpsBinary) {
+		// 安装器固定把 frps 装到 /usr/local/bin;状态展示与实际安装位置保持一致
+		cfg.FrpsBinary = "/usr/local/bin/" + filepath.Base(cfg.FrpsBinary)
+	}
+	if strings.TrimSpace(cfg.DataDir) == "" {
+		cfg.DataDir = "/var/lib/fonu-agent"
+	}
+	if strings.TrimSpace(cfg.Token) == "" {
+		return nil, errors.New("agent token is required")
+	}
+	for _, dir := range []string{cfg.NginxConfDir, cfg.CertsDir, cfg.DataDir} {
+		// NginxConfDir 可能为空(探测延迟到路由写入时重试),跳过而不是报错
+		if strings.TrimSpace(dir) == "" {
+			continue
+		}
+		if err := os.MkdirAll(dir, 0o750); err != nil {
+			return nil, fmt.Errorf("create agent directory: %w", err)
+		}
+	}
+	server := &Server{cfg: cfg}
+	// 启动后台预解析目录(不阻断监听);每次启动都会用最新探测逻辑重新解析并覆盖落盘缓存,
+	// 历史错误目录的缓存会被自动修正。
+	server.preloadNginxConfDir()
+	return server, nil
+}
+
+func (s *Server) Handler() http.Handler {
+	mux := http.NewServeMux()
+	mux.HandleFunc("GET /api/v1/ping", s.auth(s.ping))
+	mux.HandleFunc("GET /api/v1/status", s.auth(s.status))
+	mux.HandleFunc("GET /api/v1/routes", s.auth(s.routes))
+	mux.HandleFunc("PUT /api/v1/routes", s.auth(s.putRoute))
+	mux.HandleFunc("GET /api/v1/routes/{domain}/conf", s.auth(s.routeConf))
+	mux.HandleFunc("PUT /api/v1/china-cidr", s.auth(s.putChinaCIDR))
+	mux.HandleFunc("POST /api/v1/nginx/install", s.auth(s.installNginx))
+	mux.HandleFunc("DELETE /api/v1/routes/{domain}", s.auth(s.deleteRoute))
+	mux.HandleFunc("PUT /api/v1/frps/config", s.auth(s.putFRPSConfig))
+	mux.HandleFunc("POST /api/v1/frps/install", s.auth(s.installFRPS))
+	mux.HandleFunc("POST /api/v1/frps/action", s.auth(s.frpsAction))
+	mux.HandleFunc("POST /api/v1/certs", s.auth(s.putCertificate))
+	return mux
+}
+
+func (s *Server) ListenAndServe(ctx context.Context) error {
+	server := &http.Server{Addr: s.cfg.ListenAddr, Handler: s.Handler(), ReadHeaderTimeout: 10 * time.Second}
+	go func() {
+		<-ctx.Done()
+		shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+		defer cancel()
+		_ = server.Shutdown(shutdownCtx)
+	}()
+	return server.ListenAndServe()
+}
+
+func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		if r.Header.Get("Authorization") != "Bearer "+s.cfg.Token {
+			writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
+			return
+		}
+		next(w, r)
+	}
+}
+
+func (s *Server) ping(w http.ResponseWriter, _ *http.Request) {
+	writeJSON(w, http.StatusOK, map[string]any{"ok": true, "service": "fonu-agent"})
+}
+
+func (s *Server) status(w http.ResponseWriter, _ *http.Request) {
+	ports := map[string]bool{"80": portListening(80), "443": portListening(443), "7000": portListening(7000), "8080": portListening(8080)}
+	nginxProbe := s.probeNginx()
+	frpsActive := exec.Command("systemctl", "is-active", "--quiet", "frps").Run() == nil
+	writeJSON(w, http.StatusOK, map[string]any{
+		"service":          "fonu-agent",
+		"agent_version":    AgentVersion,
+		"nginx_binary":     s.cfg.NginxBinary,
+		"nginx_conf_dir":   s.cfg.NginxConfDir,
+		"nginx_ok":         nginxProbe.OK,
+		"nginx_missing":    nginxProbe.Missing,
+		"nginx_running":    nginxProbe.Running,
+		"nginx_path":       nginxProbe.Path,
+		"nginx_version":    nginxProbe.Version,
+		"nginx_error":      nginxProbe.Error,
+		"frps_binary":      s.cfg.FrpsBinary,
+		"frps_config_path": s.cfg.FrpsConfigPath,
+		"ports":            ports,
+		"frps_active":      frpsActive,
+		"frps_vhost_hint":  "curl -H 'Host: 你的域名' http://127.0.0.1:8080 可测试 frps vhost 是否通",
+		"frps_unit_exec":   strings.TrimSpace(execCommandOutput("systemctl", "show", "frps", "-p", "ExecStart", "--value")),
+		"frps_enabled":     exec.Command("systemctl", "is-enabled", "--quiet", "frps").Run() == nil,
+		"frps_version":     strings.TrimSpace(frpsVersion(s.cfg.FrpsBinary)),
+		"frps_log_tail":    strings.TrimSpace(journalTail("frps", 6)),
+		"dashboard_addr":   strings.TrimSpace(dashboardAddr(s.cfg.FrpsConfigPath)),
+		"routes":           s.routeNames(),
+		"checked_at":       time.Now().UTC().Format(time.RFC3339),
+	})
+}
+
+type frpsInstallRequest struct {
+	Version string `json:"version"`
+	Proxy   string `json:"proxy,omitempty"`
+}
+
+// installFRPS 下载指定版本的 frp release、安装 frps 二进制并注册 systemd 服务(幂等,重复调用=升级)。
+func (s *Server) installFRPS(w http.ResponseWriter, r *http.Request) {
+	var req frpsInstallRequest
+	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
+		return
+	}
+	version := strings.TrimPrefix(strings.TrimSpace(req.Version), "v")
+	if matched, _ := regexp.MatchString(`^\d+\.\d+\.\d+$`, version); !matched {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "version 需形如 0.71.0"})
+		return
+	}
+	arch := map[string]string{"amd64": "amd64", "arm64": "arm64"}[runtime.GOARCH]
+	if arch == "" {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unsupported agent arch: " + runtime.GOARCH})
+		return
+	}
+	downloadURL := fmt.Sprintf("https://github.com/fatedier/frp/releases/download/v%s/frp_%s_linux_%s.tar.gz", version, version, arch)
+	if proxy := strings.TrimSpace(req.Proxy); proxy != "" {
+		downloadURL = strings.TrimRight(proxy, "/") + "/" + downloadURL
+	}
+	workDir := filepath.Join(s.cfg.DataDir, "tmp-frps-"+version)
+	_ = os.RemoveAll(workDir)
+	defer os.RemoveAll(workDir)
+	if err := os.MkdirAll(workDir, 0o750); err != nil {
+		writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
+		return
+	}
+	tarball := filepath.Join(workDir, "frp.tar.gz")
+	if err := downloadToFile(downloadURL, tarball); err != nil {
+		writeJSON(w, http.StatusBadGateway, map[string]string{"error": "下载 frps 失败: " + err.Error()})
+		return
+	}
+	installPath := s.cfg.FrpsBinary
+	if !filepath.IsAbs(installPath) {
+		installPath = "/usr/local/bin/frps"
+	}
+	if err := extractFile(tarball, "frp_"+version+"_linux_"+arch+"/frps", installPath, 0o755); err != nil {
+		writeJSON(w, http.StatusBadGateway, map[string]string{"error": "解压 frps 失败: " + err.Error()})
+		return
+	}
+	notes := []string{"frps 二进制已安装: " + installPath}
+	unitPath := "/etc/systemd/system/frps.service"
+	writeUnit := false
+	if _, err := os.Stat(unitPath); errors.Is(err, os.ErrNotExist) {
+		writeUnit = true
+		notes = append(notes, "已创建 systemd 单位 frps.service")
+	} else if existing, err := os.ReadFile(unitPath); err == nil {
+		// 旧 unit(如 frps-admin 面板遗留)的 ExecStart 可能指向已不存在的二进制;检测到失效路径时覆写接管
+		for _, line := range strings.Split(string(existing), "\n") {
+			if execPath, found := strings.CutPrefix(strings.TrimSpace(line), "ExecStart="); found {
+				ref := strings.Fields(execPath)
+				if len(ref) > 0 {
+					if _, statErr := os.Stat(ref[0]); statErr != nil {
+						writeUnit = true
+						notes = append(notes, fmt.Sprintf("检测到现有 frps.service 的 ExecStart 指向不存在的 %s,已覆写接管", ref[0]))
+					}
+				}
+				break
+			}
+		}
+	}
+	if writeUnit {
+		unit := fmt.Sprintf("[Unit]\nDescription=frps service (managed by fonu-agent)\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nExecStart=%s -c %s\nRestart=on-failure\nRestartSec=3\n\n[Install]\nWantedBy=multi-user.target\n", installPath, s.cfg.FrpsConfigPath)
+		if err := atomicWrite(unitPath, []byte(unit), 0o644); err != nil {
+			writeJSON(w, http.StatusBadGateway, map[string]string{"error": "写入 frps.service 失败: " + err.Error()})
+			return
+		}
+		_ = exec.Command("systemctl", "daemon-reload").Run()
+	}
+	// nohup 手工跑的 frps 让位给 systemd;已在 systemd 下运行则重启以加载新二进制
+	if exec.Command("systemctl", "is-active", "--quiet", "frps").Run() != nil {
+		if exec.Command("pgrep", "-x", "frps").Run() == nil {
+			_ = exec.Command("pkill", "-x", "frps").Run()
+			time.Sleep(time.Second)
+			notes = append(notes, "已停止 nohup 方式运行的旧 frps 进程")
+		}
+		_ = exec.Command("systemctl", "enable", "--now", "frps").Run()
+		notes = append(notes, "frps.service 已启动")
+	} else {
+		_ = exec.Command("systemctl", "restart", "frps").Run()
+		notes = append(notes, "frps.service 已重启以加载新版本")
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version, "binary": installPath, "notes": notes})
+}
+
+type frpsActionRequest struct {
+	Action string `json:"action"` // start | stop | restart
+}
+
+// frpsAction 对 VPS 本机 frps 服务执行启停(仅白名单动作,经 systemctl)。
+func (s *Server) frpsAction(w http.ResponseWriter, r *http.Request) {
+	var req frpsActionRequest
+	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&req); err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
+		return
+	}
+	var command *exec.Cmd
+	switch req.Action {
+	case "start":
+		command = exec.Command("systemctl", "start", "frps")
+	case "stop":
+		command = exec.Command("systemctl", "stop", "frps")
+	case "restart":
+		command = exec.Command("systemctl", "restart", "frps")
+	default:
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "action 需为 start/stop/restart"})
+		return
+	}
+	if output, err := command.CombinedOutput(); err != nil {
+		writeJSON(w, http.StatusBadGateway, map[string]string{"error": fmt.Sprintf("systemctl %s frps 失败: %s", req.Action, strings.TrimSpace(string(output)))})
+		return
+	}
+	time.Sleep(800 * time.Millisecond) // 给 frps 启动留出窗口,失败时才能在 is-active/journal 里看到
+	active := exec.Command("systemctl", "is-active", "--quiet", "frps").Run() == nil
+	result := map[string]any{"ok": true, "action": req.Action, "frps_active": active}
+	if !active {
+		// 启动失败时把 frps 的失败原因带回去,而不是只说“启动成功”
+		journal, _ := exec.Command("journalctl", "-u", "frps", "-n", "8", "--no-pager").CombinedOutput()
+		result["ok"] = false
+		result["error"] = fmt.Sprintf("systemctl %s 已执行但 frps 未进入运行状态,最近日志:\n%s", req.Action, strings.TrimSpace(string(journal)))
+	}
+	writeJSON(w, http.StatusOK, result)
+}
+
+func downloadToFile(url, path string) error {
+	client := &http.Client{Timeout: 10 * time.Minute}
+	resp, err := client.Get(url)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		return fmt.Errorf("HTTP %d", resp.StatusCode)
+	}
+	out, err := os.Create(path)
+	if err != nil {
+		return err
+	}
+	defer out.Close()
+	_, err = io.Copy(out, io.LimitReader(resp.Body, 256<<20))
+	return err
+}
+
+// extractFile 从 tar.gz 中取出 name 对应的单个文件写入 dest。
+func extractFile(tarball, name, dest string, mode os.FileMode) error {
+	file, err := os.Open(tarball)
+	if err != nil {
+		return err
+	}
+	defer file.Close()
+	gz, err := gzip.NewReader(file)
+	if err != nil {
+		return err
+	}
+	defer gz.Close()
+	reader := tar.NewReader(gz)
+	for {
+		header, err := reader.Next()
+		if errors.Is(err, io.EOF) {
+			return fmt.Errorf("压缩包内未找到 %s", name)
+		}
+		if err != nil {
+			return err
+		}
+		if strings.TrimSpace(filepath.Clean(header.Name)) != name {
+			continue
+		}
+		data, err := io.ReadAll(io.LimitReader(reader, 256<<20))
+		if err != nil {
+			return err
+		}
+		return atomicWrite(dest, data, mode)
+	}
+}
+
+func execCommandOutput(name string, args ...string) string {
+	output, _ := exec.Command(name, args...).CombinedOutput()
+	return string(output)
+}
+
+// dashboardAddr 已废弃:面板地址改由 Fonu 前端从服务端表单的「frps 管理接口」字段拼出,agent 不再解析 frps.toml。
+func dashboardAddr(configPath string) string {
+	data, err := os.ReadFile(configPath)
+	if err != nil {
+		return ""
+	}
+	var addr, port string
+	for _, line := range strings.Split(string(data), "\n") {
+		line = strings.TrimSpace(line)
+		if value, found := strings.CutPrefix(line, "webServer.addr"); found {
+			addr = strings.Trim(strings.TrimSpace(strings.TrimPrefix(value, "=")), "\"")
+		} else if value, found := strings.CutPrefix(line, "webServer.port"); found {
+			port = strings.TrimSpace(strings.TrimPrefix(value, "="))
+		}
+	}
+	if addr == "" || port == "" || port == "0" {
+		return ""
+	}
+	if addr == "0.0.0.0" || addr == "::" {
+		addr = "127.0.0.1"
+	}
+	return fmt.Sprintf("http://%s:%s", addr, port)
+}
+
+func frpsVersion(binary string) string {
+	output, err := exec.Command(binary, "--version").CombinedOutput()
+	if err != nil {
+		return ""
+	}
+	return string(output)
+}
+
+func journalTail(unit string, lines int) string {
+	output, err := exec.Command("journalctl", "-u", unit, "-n", strconv.Itoa(lines), "--no-pager").CombinedOutput()
+	if err != nil {
+		return ""
+	}
+	return string(output)
+}
+
+// nginxBinaryPath 解析实际可用的 nginx 可执行文件:优先传入名称(PATH/绝对路径),
+// 否则从运行中的 nginx 主进程反查(/proc//exe),零硬编码安装位置。
+func nginxBinaryPath(configured string) string {
+	if p := strings.TrimSpace(configured); p != "" {
+		if filepath.IsAbs(p) {
+			if _, err := os.Stat(p); err == nil {
+				return p
+			}
+		} else if _, err := exec.LookPath(p); err == nil {
+			return p
+		}
+	}
+	// 从运行中的 nginx master 进程反查二进制(不依赖安装位置)
+	if entries, err := os.ReadDir("/proc"); err == nil {
+		for _, entry := range entries {
+			if !entry.IsDir() {
+				continue
+			}
+			if data, err := os.ReadFile("/proc/" + entry.Name() + "/comm"); err != nil || strings.TrimSpace(string(data)) != "nginx" {
+				continue
+			}
+			if link, err := os.Readlink("/proc/" + entry.Name() + "/exe"); err == nil && strings.Contains(strings.ToLower(link), "nginx") {
+				return link
+			}
+		}
+	}
+	return ""
+}
+
+// detectNginxConfDir 自动发现 Nginx 实际加载完整 server 配置的目录(零硬编码路径):
+// 1) nginx -T dump 完整生效配置,收集所有被加载的 .conf 所在目录(Nginx 亲口说的事实);
+// 2) 按内容启发排序:目录下已有含 server_name 的 .conf(HTTP vhost 语义)优先,片段目录(location-only)靠后;
+// 3) 逐个试探:写入 → nginx -t → 通过即选定并缓存;失败则清理该次写入并换下一个。
+// Nginx 自己是唯一裁判,agent 不猜测、不评分、不写死任何安装路径。
+func detectNginxConfDir(binary string) (string, error) {
+	nginx := nginxBinaryPath(binary)
+	if nginx == "" {
+		return "", fmt.Errorf("未找到可用的 nginx 可执行文件;请先在 VPS 安装 Nginx(apt install nginx / yum install nginx),或用 FONU_AGENT_NGINX_BIN 显式指定路径")
+	}
+	out, err := exec.Command(nginx, "-T").CombinedOutput()
+	if err != nil {
+		return "", fmt.Errorf("nginx -T 执行失败: %w", err)
+	}
+	loadedDirs := map[string]bool{}
+	for _, line := range strings.Split(string(out), "\n") {
+		line = strings.TrimSpace(line)
+		if value, found := strings.CutPrefix(line, "# configuration file "); found {
+			p := strings.TrimSuffix(strings.TrimSpace(value), ":")
+			if strings.HasSuffix(p, ".conf") {
+				loadedDirs[filepath.Dir(p)] = true
+			}
+		}
+	}
+	delete(loadedDirs, ".")
+	if len(loadedDirs) == 0 {
+		return "", fmt.Errorf("nginx -T 输出中未发现任何被加载的 .conf 目录")
+	}
+	// 内容启发:目录下已有含 server_name 的 .conf 视为 HTTP vhost 目录,优先尝试。
+	dirs := make([]string, 0, len(loadedDirs))
+	for dir := range loadedDirs {
+		dirs = append(dirs, dir)
+	}
+	sort.Slice(dirs, func(i, j int) bool {
+		si, sj := dirHasVhostConf(dirs[i]), dirHasVhostConf(dirs[j])
+		if si != sj {
+			return si // vhost 语义目录在前
+		}
+		return dirs[i] < dirs[j]
+	})
+	for _, dir := range dirs {
+		if err := os.MkdirAll(dir, 0o755); err != nil {
+			continue
+		}
+		probe := filepath.Join(dir, "fonu-probe.conf")
+		probeBody := "# Managed by fonu-agent. Do not edit.\nserver {\n    listen 127.0.0.1:1;\n    server_name fonu-probe.invalid;\n}\n"
+		if err := atomicWrite(probe, []byte(probeBody), 0o644); err != nil {
+			continue
+		}
+		// 两级验证:-t 仅验证语法,不保证文件被 include 加载(主配置目录等无 include 的目录也能通过)。
+		// 必须再执行 -T 确认探针文件真的出现在生效配置清单里,才算“会被加载”的目录。
+		if err := runCommand(nginx, "-t"); err == nil {
+			if tOut, tErr := exec.Command(nginx, "-T").CombinedOutput(); tErr == nil && strings.Contains(string(tOut), probe) {
+				_ = os.Remove(probe)
+				_ = runCommand(nginx, "-s", "reload") // 清除 probe 后恢复原状
+				return dir, nil
+			}
+			// -t 通过但 -T 不含探针:该目录不被 include 加载,删除探针换下一个
+			_ = os.Remove(probe)
+			continue
+		}
+		// 该目录不接受完整 server 块(片段目录):删除 probe 文件。
+		// 若 -t 失败信息指向历史残留的 fonu-*.conf,也一并清理(仅限 Agent 生成的文件)。
+		_ = os.Remove(probe)
+		if tOut, tErr := exec.Command(nginx, "-t").CombinedOutput(); tErr != nil {
+			cleanupLegacyFonuConf(string(tOut))
+			if err := runCommand(nginx, "-t"); err == nil {
+				continue // 清理残留后配置恢复可用,换下一个目录
+			}
+		}
+	}
+	return "", fmt.Errorf("在 %d 个被加载目录中均无法放置完整 server 配置(已尝试: %s);可用 FONU_AGENT_NGINX_CONF_DIR 显式指定", len(dirs), strings.Join(dirs, ", "))
+}
+
+// dirHasVhostConf 判断目录下是否已有含 server_name 的 .conf(HTTP vhost 语义)。
+func dirHasVhostConf(dir string) bool {
+	entries, err := os.ReadDir(dir)
+	if err != nil {
+		return false
+	}
+	for _, entry := range entries {
+		if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".conf") {
+			continue
+		}
+		if data, err := os.ReadFile(filepath.Join(dir, entry.Name())); err == nil && strings.Contains(string(data), "server_name") {
+			return true
+		}
+	}
+	return false
+}
+
+// cleanupLegacyFonuConf 根据 nginx -t 的报错文本清理 Agent 历史生成的坏文件(仅限 fonu- 前缀)。
+func cleanupLegacyFonuConf(nginxTestOutput string) {
+	for _, line := range strings.Split(nginxTestOutput, "\n") {
+		idx := strings.Index(line, "fonu-")
+		if idx < 0 {
+			continue
+		}
+		start, end := -1, -1
+		for i := idx; i >= 0 && line[i] != ' '; i-- {
+			start = i
+		}
+		for i := idx; i < len(line) && line[i] != ' ' && line[i] != ':'; i++ {
+			end = i + 1
+		}
+		if start < 0 || end <= start {
+			continue
+		}
+		path := line[start:end]
+		if !strings.HasSuffix(path, ".conf") {
+			continue
+		}
+		if data, err := os.ReadFile(path); err == nil && strings.HasPrefix(string(data), "# Managed by fonu-agent.") {
+			_ = os.Remove(path)
+		}
+	}
+}
+
+func portListening(port int) bool {
+	conn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)), 300*time.Millisecond)
+	if err != nil {
+		return false
+	}
+	_ = conn.Close()
+	return true
+}
+
+func (s *Server) routes(w http.ResponseWriter, _ *http.Request) {
+	writeJSON(w, http.StatusOK, s.routeDetails())
+}
+
+// routeDetails 返回每条路由的真实状态:文件存在/语法通过/被 Nginx 实际加载。
+func (s *Server) routeDetails() []map[string]any {
+	entries, err := os.ReadDir(s.cfg.NginxConfDir)
+	if err != nil {
+		return []map[string]any{}
+	}
+	details := []map[string]any{}
+	for _, entry := range entries {
+		if entry.IsDir() || !strings.HasPrefix(entry.Name(), "fonu-") || !strings.HasSuffix(entry.Name(), ".conf") {
+			continue
+		}
+		domain := strings.TrimSuffix(strings.TrimPrefix(entry.Name(), "fonu-"), ".conf")
+		path := filepath.Join(s.cfg.NginxConfDir, entry.Name())
+		item := map[string]any{
+			"domain":      domain,
+			"file_exists": true,
+			"syntax_ok":   false,
+			"loaded":      false,
+		}
+		// 语法与加载判定合并在一次 -T 中:-T 成功且输出含本文件 = 语法通过且被加载
+		if nginx := s.nginxPath(); nginx != "" {
+			if tOut, tErr := exec.Command(nginx, "-T").CombinedOutput(); tErr == nil {
+				item["syntax_ok"] = true
+				item["loaded"] = strings.Contains(string(tOut), path)
+			}
+		}
+		details = append(details, item)
+	}
+	sort.Slice(details, func(i, j int) bool { return details[i]["domain"].(string) < details[j]["domain"].(string) })
+	return details
+}
+
+type routeRequest struct {
+	Domain        string   `json:"domain"`
+	Upstream      string   `json:"upstream"`
+	TLS           bool     `json:"tls"`
+	CertDir       string   `json:"cert_dir,omitempty"`
+	WebSocket     bool     `json:"websocket,omitempty"`
+	VhostPort     int      `json:"vhost_port,omitempty"`      // 回源自检用:Fonu 传入本次部署的 frps vhost 端口
+	RedirectTLS   bool     `json:"redirect_https,omitempty"`  // HTTP 80 强制跳转 HTTPS
+	AllowIPs      []string `json:"allow_ips,omitempty"`       // IP 白名单;空则不限制
+	BasicAuthUser string   `json:"basic_auth_user,omitempty"` // Basic Auth 用户名;空则不启用(密码自动生成)
+	// 第一梯队部署设置(仅作用于公网 Nginx vhost,不写入 frpc.toml)
+	DenyIPs           []string `json:"deny_ips,omitempty"`             // IP 黑名单;与白名单可同时使用
+	BasicAuthPassword string   `json:"basic_auth_password,omitempty"`  // Basic Auth 自定义密码;空则首次自动生成
+	HTTPSDisabled     bool     `json:"https_disabled,omitempty"`       // 显式关闭 HTTPS(证书已推送也不监听 443)
+	ClientMaxBodySize string   `json:"client_max_body_size,omitempty"` // 上传大小限制,如 50m
+	ProxyReadTimeout  string   `json:"proxy_read_timeout,omitempty"`   // 代理读超时,如 3600s
+	SecurityHeaders   bool     `json:"security_headers,omitempty"`     // 安全响应头(HSTS 等)
+	TLS13Only         bool     `json:"tls13_only,omitempty"`           // 仅 TLS 1.3
+	// 第二梯队部署设置:限流 / 连接数 / 仅中国大陆 IP
+	RateLimitRate  int  `json:"rate_limit_rate,omitempty"`  // 请求限流:每秒请求数
+	RateLimitBurst int  `json:"rate_limit_burst,omitempty"` // 请求限流:突发允许量
+	ConnLimitMax   int  `json:"conn_limit_max,omitempty"`   // 每 IP 并发连接数上限
+	ChinaOnly      bool `json:"china_only,omitempty"`       // 仅允许中国大陆 IP(需先下发 IP 段)
+}
+
+func (s *Server) putRoute(w http.ResponseWriter, r *http.Request) {
+	var req routeRequest
+	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10)).Decode(&req); err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
+		return
+	}
+	if err := validateRoute(req); err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
+		return
+	}
+	// 部署预检:未装 Nginx 时给出人话提示,而不是后续的 nginx config rejected
+	if probe := s.probeNginx(); probe.Missing {
+		writeJSON(w, http.StatusBadGateway, map[string]string{"error": "VPS 未安装 Nginx(或不在常见路径):请先安装(apt install nginx / yum install nginx),或在「公网反代」页点「一键安装 Nginx」后再部署"})
+		return
+	}
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if err := s.writeRoute(req); err != nil {
+		writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
+		return
+	}
+	result := map[string]any{"ok": true, "domain": req.Domain, "websocket": req.WebSocket}
+	// 部署后回源自检:模拟 Nginx 的回源请求,区分三种状态:
+	// 2xx/3xx = 隧道通且服务响应;404 = 反代/frps 正常但无此域名的隧道注册;0/5xx = 回源失败或内网服务异常
+	if vhost := req.VhostPort; vhost > 0 {
+		code, body := s.probeVhost(vhost, req.Domain)
+		result["probe_status"] = code
+		result["probe_ok"] = code >= 200 && code < 400
+		result["probe_notfound"] = code == 404
+		result["probe_body_snip"] = body
+	}
+	writeJSON(w, http.StatusOK, result)
+}
+
+// probeVhost 从 VPS 本机模拟 Nginx 回源:返回 HTTP 状态码与响应片段(限长)。
+func (s *Server) probeVhost(port int, host string) (int, string) {
+	client := &http.Client{
+		Timeout: 5 * time.Second,
+		CheckRedirect: func(req *http.Request, via []*http.Request) error {
+			return http.ErrUseLastResponse
+		},
+	}
+	req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/", port), nil)
+	if err != nil {
+		return 0, err.Error()
+	}
+	req.Host = host
+	resp, err := client.Do(req)
+	if err != nil {
+		return 0, err.Error()
+	}
+	defer resp.Body.Close()
+	snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 200))
+	return resp.StatusCode, strings.TrimSpace(string(snippet))
+}
+
+// routeConf 返回指定域名的当前反代配置内容(查看生成的 nginx 配置)。
+func (s *Server) routeConf(w http.ResponseWriter, r *http.Request) {
+	domain := strings.ToLower(strings.TrimSpace(r.PathValue("domain")))
+	if !validDomain(domain) {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid domain"})
+		return
+	}
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	data, err := os.ReadFile(s.routePath(domain))
+	if err != nil {
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": "该域名尚未部署反代"})
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"domain": domain, "content": string(data)})
+}
+
+func (s *Server) deleteRoute(w http.ResponseWriter, r *http.Request) {
+	domain := strings.ToLower(strings.TrimSpace(r.PathValue("domain")))
+	if !validDomain(domain) {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid domain"})
+		return
+	}
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	path := s.routePath(domain)
+	if err := s.clearLimitState(domain); err != nil {
+		writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
+		return
+	}
+	if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
+		writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
+		return
+	}
+	if err := s.reloadNginx(); err != nil {
+		writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"ok": true, "domain": domain})
+}
+
+type frpsConfigRequest struct {
+	Content string `json:"content"`
+}
+
+func (s *Server) putFRPSConfig(w http.ResponseWriter, r *http.Request) {
+	var req frpsConfigRequest
+	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 512<<10)).Decode(&req); err != nil || strings.TrimSpace(req.Content) == "" {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "frps config content is required"})
+		return
+	}
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	backup := s.cfg.FrpsConfigPath + ".bak"
+	if old, err := os.ReadFile(s.cfg.FrpsConfigPath); err == nil {
+		if err := atomicWrite(backup, old, 0o600); err != nil {
+			writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
+			return
+		}
+	}
+	if err := atomicWrite(s.cfg.FrpsConfigPath, []byte(req.Content), 0o600); err != nil {
+		writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": s.cfg.FrpsConfigPath, "restart_required": true})
+}
+
+type certificateRequest struct {
+	Domain     string `json:"domain"`
+	Fullchain  string `json:"fullchain_pem"`
+	PrivateKey string `json:"key_pem"`
+}
+
+func (s *Server) putCertificate(w http.ResponseWriter, r *http.Request) {
+	var req certificateRequest
+	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<20)).Decode(&req); err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
+		return
+	}
+	req.Domain = strings.ToLower(strings.TrimSpace(req.Domain))
+	if !validDomain(req.Domain) || strings.TrimSpace(req.Fullchain) == "" || strings.TrimSpace(req.PrivateKey) == "" {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "domain and PEM values are required"})
+		return
+	}
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	dir := filepath.Join(s.cfg.CertsDir, req.Domain)
+	if err := os.MkdirAll(dir, 0o700); err != nil {
+		writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
+		return
+	}
+	if err := atomicWrite(filepath.Join(dir, "fullchain.pem"), []byte(req.Fullchain), 0o600); err != nil {
+		writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
+		return
+	}
+	if err := atomicWrite(filepath.Join(dir, "privkey.pem"), []byte(req.PrivateKey), 0o600); err != nil {
+		writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"ok": true, "domain": req.Domain, "directory": dir})
+}
+
+// nginxInstallResult 一键安装 Nginx 的执行结果。
+type nginxInstallResult struct {
+	OK      bool   `json:"ok"`
+	Manager string `json:"manager,omitempty"`
+	Output  string `json:"output,omitempty"`
+	Path    string `json:"path,omitempty"`
+	Error   string `json:"error,omitempty"`
+}
+
+// installNginx 一键安装 Nginx:识别 apt-get/dnf/yum/apk → 非交互安装 → 启动 → 回写探测结果。
+// 需要 agent 以 root 运行(systemd 单元默认如此)。
+func (s *Server) installNginx(w http.ResponseWriter, r *http.Request) {
+	if os.Geteuid() != 0 {
+		writeJSON(w, http.StatusForbidden, map[string]string{"error": "安装 Nginx 需要 root 权限:请以 root 运行 fonu-agent"})
+		return
+	}
+	if probe := s.probeNginx(); probe.OK {
+		writeJSON(w, http.StatusOK, nginxInstallResult{OK: true, Path: probe.Path, Output: "Nginx 已可用,无需安装"})
+		return
+	}
+	manager, installArgs := detectPackageManager()
+	if manager == "" {
+		writeJSON(w, http.StatusBadGateway, map[string]string{"error": "未识别到支持的包管理器(apt-get/dnf/yum/apk),请在 VPS 上手动安装 Nginx"})
+		return
+	}
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
+	defer cancel()
+	if manager == "apt-get" {
+		update := exec.CommandContext(ctx, "apt-get", "update", "-y")
+		update.Env = append(os.Environ(), "DEBIAN_FRONTEND=noninteractive")
+		_, _ = update.CombinedOutput() // 索引更新失败不阻断安装尝试
+	}
+	install := exec.CommandContext(ctx, manager, installArgs...)
+	install.Env = append(os.Environ(), "DEBIAN_FRONTEND=noninteractive")
+	out, err := install.CombinedOutput()
+	if err != nil {
+		writeJSON(w, http.StatusBadGateway, nginxInstallResult{Manager: manager, Output: tailText(string(out), 4000), Error: err.Error()})
+		return
+	}
+	// 启动并设置开机自启:systemd 优先,回退 service 命令(两者失败都忽略,最终以探测结果为准)
+	_ = exec.Command("systemctl", "enable", "--now", "nginx").Run()
+	_ = exec.Command("service", "nginx", "start").Run()
+	s.invalidateNginxProbe()
+	probe := s.probeNginx()
+	result := nginxInstallResult{OK: probe.OK, Manager: manager, Output: tailText(string(out), 4000), Path: probe.Path, Error: probe.Error}
+	if !probe.OK {
+		writeJSON(w, http.StatusBadGateway, result)
+		return
+	}
+	// 安装成功后解析并缓存 vhost 目录,省去后续首次部署的探测
+	if dir, err := detectNginxConfDir(s.nginxPath()); err == nil {
+		s.cfg.NginxConfDir = dir
+		_ = os.MkdirAll(dir, 0o755)
+		if s.cfg.DataDir != "" {
+			_ = os.WriteFile(filepath.Join(s.cfg.DataDir, "nginx_conf_dir"), []byte(dir), 0o644)
+		}
+	}
+	writeJSON(w, http.StatusOK, result)
+}
+
+type chinaCIDRRequest struct {
+	Content string `json:"content"`
+}
+
+// putChinaCIDR 接收 Fonu 下发的中国 IP 段(geo include 格式),写入 geo 变量文件并重载。
+func (s *Server) putChinaCIDR(w http.ResponseWriter, r *http.Request) {
+	var req chinaCIDRRequest
+	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<20)).Decode(&req); err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
+		return
+	}
+	content := strings.TrimSpace(req.Content)
+	if content == "" {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "中国 IP 段内容为空"})
+		return
+	}
+	if probe := s.probeNginx(); probe.Missing {
+		writeJSON(w, http.StatusBadGateway, map[string]string{"error": "VPS 未安装 Nginx:请先安装 Nginx 再同步中国 IP 段"})
+		return
+	}
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if strings.TrimSpace(s.cfg.NginxConfDir) == "" {
+		dir, err := detectNginxConfDir(s.cfg.NginxBinary)
+		if err != nil {
+			writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
+			return
+		}
+		s.cfg.NginxConfDir = dir
+	}
+	if err := atomicWrite(chinaListPath(), []byte(content+"\n"), 0o640); err != nil {
+		writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
+		return
+	}
+	geo := "# Managed by fonu-agent. Do not edit.\ngeo $fonu_china_client {\n    default 0;\n    include " + chinaListPath() + ";\n}\n"
+	if err := atomicWrite(s.chinaGeoFilePath(), []byte(geo), 0o640); err != nil {
+		writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
+		return
+	}
+	if err := s.validateNginx(); err != nil {
+		writeJSON(w, http.StatusBadGateway, map[string]string{"error": "nginx config rejected: " + err.Error()})
+		return
+	}
+	if err := s.reloadNginx(); err != nil {
+		writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"ok": true, "bytes": len(content)})
+}
+
+func (s *Server) writeRoute(req routeRequest) error {
+	// 目录为空时惰性探测(启动时探测失败不阻断,这里重试;systemd 下补 PATH)
+	if strings.TrimSpace(s.cfg.NginxConfDir) == "" {
+		dir, err := detectNginxConfDir(s.cfg.NginxBinary)
+		if err != nil {
+			return err
+		}
+		// putRoute 已持有 s.mu;这里不能再次加锁,否则会发生自锁死。
+		s.cfg.NginxConfDir = dir
+	}
+	certDir := req.CertDir
+	if certDir == "" {
+		certDir = filepath.Join(s.cfg.CertsDir, req.Domain)
+	}
+	// 仅中国大陆 IP 依赖已下发的 IP 段(geo 变量在 fonuagent-china.conf 中定义)
+	if req.ChinaOnly {
+		if _, err := os.Stat(chinaListPath()); err != nil {
+			return fmt.Errorf("启用「仅中国大陆 IP」前需先同步中国 IP 段(请在公网反代页触发同步后重试)")
+		}
+	}
+	content := nginxRoute(req, certDir)
+	path := s.routePath(req.Domain)
+	// 清理历史版本写到其他目录的同域名 Agent 文件:从 nginx -T 的生效清单反向比对,
+	// 非当前目录但内容带 Agent 标记的 fonu-.conf 一律删除(不碰宝塔自己的配置)。
+	if nginx := s.nginxPath(); nginx != "" {
+		if tOut, tErr := exec.Command(nginx, "-T").CombinedOutput(); tErr == nil {
+			for _, line := range strings.Split(string(tOut), "\n") {
+				line = strings.TrimSpace(line)
+				if value, found := strings.CutPrefix(line, "# configuration file "); found {
+					p := strings.TrimSuffix(strings.TrimSpace(value), ":")
+					base := filepath.Base(p)
+					if p != path && base == "fonu-"+req.Domain+".conf" {
+						if data, readErr := os.ReadFile(p); readErr == nil && strings.HasPrefix(string(data), "# Managed by fonu-agent.") {
+							_ = os.Remove(p)
+						}
+					}
+				}
+			}
+		}
+	}
+	// 先落 zone 文件再写 vhost:nginx -t 需要 zone 已存在
+	if err := s.applyLimitState(req); err != nil {
+		return err
+	}
+	if err := atomicWrite(path, []byte(content), 0o640); err != nil {
+		return err
+	}
+	if err := s.validateNginx(); err != nil {
+		_ = os.Remove(path)
+		return fmt.Errorf("nginx config rejected: %w", err)
+	}
+	return s.reloadNginx()
+}
+
+func (s *Server) routeNames() []string {
+	entries, err := os.ReadDir(s.cfg.NginxConfDir)
+	if err != nil {
+		return []string{}
+	}
+	var result []string
+	for _, entry := range entries {
+		if entry.IsDir() || !strings.HasPrefix(entry.Name(), "fonu-") || !strings.HasSuffix(entry.Name(), ".conf") {
+			continue
+		}
+		result = append(result, strings.TrimSuffix(strings.TrimPrefix(entry.Name(), "fonu-"), ".conf"))
+	}
+	sort.Strings(result)
+	return result
+}
+
+func (s *Server) routePath(domain string) string {
+	return filepath.Join(s.cfg.NginxConfDir, "fonu-"+domain+".conf")
+}
+
+func (s *Server) validateNginx() error {
+	return runCommand(s.nginxPath(), "-t")
+}
+
+func (s *Server) reloadNginx() error {
+	return runCommand(s.nginxPath(), "-s", "reload")
+}
+
+// nginxPath 返回可用的 nginx 绝对路径:配置的路径可用则用之;否则探测常见安装位置
+// (宝塔的 nginx 不在 systemd PATH 里,直接用名字会 exec 失败)。
+func (s *Server) nginxPath() string {
+	if p := strings.TrimSpace(s.cfg.NginxBinary); p != "" {
+		if filepath.IsAbs(p) {
+			if _, err := os.Stat(p); err == nil {
+				return p
+			}
+		} else if _, err := exec.LookPath(p); err == nil {
+			return p
+		}
+	}
+	for _, candidate := range []string{"/www/server/nginx/sbin/nginx", "/usr/local/nginx/sbin/nginx", "/usr/sbin/nginx"} {
+		if _, err := os.Stat(candidate); err == nil {
+			return candidate
+		}
+	}
+	return s.cfg.NginxBinary
+}
+
+// probeNginx 真实探测 Nginx 可用性:解析可执行文件并执行 nginx -t;结果缓存 30 秒。
+func (s *Server) probeNginx() nginxProbe {
+	s.nginxStatusMu.Lock()
+	defer s.nginxStatusMu.Unlock()
+	if !s.nginxStatusAt.IsZero() && time.Since(s.nginxStatusAt) < 30*time.Second {
+		return s.nginxStatus
+	}
+	probe := nginxProbe{}
+	path := strings.TrimSpace(s.nginxPath())
+	if path == "" {
+		probe.Missing = true
+		probe.Error = "未找到 nginx 可执行文件(已尝试配置值、常见安装路径与运行中进程)"
+	} else {
+		probe.Path = path
+		probe.Version = nginxVersion(path)
+		probe.Running = nginxRunning()
+		if out, err := exec.Command(path, "-t").CombinedOutput(); err != nil {
+			probe.Error = strings.TrimSpace(string(out))
+		} else {
+			probe.OK = true
+		}
+	}
+	s.nginxStatus = probe
+	s.nginxStatusAt = time.Now()
+	return probe
+}
+
+// invalidateNginxProbe 清除探测缓存(安装/升级 Nginx 后强制重新探测)。
+func (s *Server) invalidateNginxProbe() {
+	s.nginxStatusMu.Lock()
+	s.nginxStatusAt = time.Time{}
+	s.nginxStatusMu.Unlock()
+}
+
+// nginxRunning 判断是否有 nginx 进程在运行(扫描 /proc,不依赖端口占用)。
+func nginxRunning() bool {
+	entries, err := os.ReadDir("/proc")
+	if err != nil {
+		return false
+	}
+	for _, entry := range entries {
+		if !entry.IsDir() {
+			continue
+		}
+		if _, err := strconv.Atoi(entry.Name()); err != nil {
+			continue
+		}
+		if data, err := os.ReadFile("/proc/" + entry.Name() + "/comm"); err == nil && strings.TrimSpace(string(data)) == "nginx" {
+			return true
+		}
+	}
+	return false
+}
+
+// nginxVersion 返回 nginx 版本字符串(如 nginx/1.24.0);-v 输出走 stderr。
+func nginxVersion(path string) string {
+	out, _ := exec.Command(path, "-v").CombinedOutput()
+	text := strings.TrimSpace(string(out))
+	if idx := strings.Index(text, "nginx version: "); idx >= 0 {
+		return strings.TrimSpace(text[idx+len("nginx version: "):])
+	}
+	return text
+}
+
+// detectPackageManager 识别可用的包管理器与非交互安装参数。
+func detectPackageManager() (string, []string) {
+	candidates := []struct {
+		bin  string
+		args []string
+	}{
+		{"apt-get", []string{"install", "-y", "nginx"}},
+		{"dnf", []string{"install", "-y", "nginx"}},
+		{"yum", []string{"install", "-y", "nginx"}},
+		{"apk", []string{"add", "--no-cache", "nginx"}},
+	}
+	for _, candidate := range candidates {
+		if path, err := exec.LookPath(candidate.bin); err == nil && path != "" {
+			return candidate.bin, candidate.args
+		}
+	}
+	return "", nil
+}
+
+// tailText 截取文本尾部,避免把整个安装日志塞进响应。
+func tailText(value string, limit int) string {
+	value = strings.TrimSpace(value)
+	if len(value) <= limit {
+		return value
+	}
+	return "…(已截断)" + value[len(value)-limit:]
+}
+
+func runCommand(name string, args ...string) error {
+	output, err := exec.Command(name, args...).CombinedOutput()
+	if err != nil {
+		return fmt.Errorf("%s: %w: %s", name, err, strings.TrimSpace(string(output)))
+	}
+	return nil
+}
+
+func validateRoute(req routeRequest) error {
+	if !validDomain(strings.ToLower(strings.TrimSpace(req.Domain))) {
+		return errors.New("invalid domain")
+	}
+	if strings.TrimSpace(req.Upstream) == "" || strings.ContainsAny(req.Upstream, " \t\r\n;'\"") {
+		return errors.New("invalid upstream")
+	}
+	if req.TLS && strings.TrimSpace(req.CertDir) != "" && !filepath.IsAbs(req.CertDir) {
+		return errors.New("certificate directory must be absolute")
+	}
+	return nil
+}
+
+func validDomain(value string) bool {
+	if len(value) == 0 || len(value) > 253 || strings.Contains(value, "..") {
+		return false
+	}
+	return domainPattern.MatchString(value)
+}
+
+func nginxRoute(req routeRequest, certDir string) string {
+	var b strings.Builder
+	b.WriteString("# Managed by fonu-agent. Do not edit.\n")
+	// HTTPSDisabled 显式关闭 HTTPS:即使证书已推送也不监听 443(字段缺省 = 沿用现有行为)
+	tlsEnabled := req.TLS && !req.HTTPSDisabled
+	if req.RedirectTLS && tlsEnabled {
+		// 80 端口只做 301 跳转,正常代理由 443 TLS server 承担
+		fmt.Fprintf(&b, "server {\n    listen 80;\n    server_name %s;\n    return 301 https://$host$request_uri;\n}\n\n", req.Domain)
+		fmt.Fprintf(&b, "server {\n    listen 443 ssl;\n    server_name %s;\n", req.Domain)
+		b.WriteString(sslDirectives(certDir, req.TLS13Only))
+	} else {
+		fmt.Fprintf(&b, "server {\n    listen 80;\n    server_name %s;\n", req.Domain)
+		if tlsEnabled {
+			b.WriteString("    listen 443 ssl;\n")
+			b.WriteString(sslDirectives(certDir, req.TLS13Only))
+		}
+	}
+	// 上传大小与代理读超时:按规则覆盖 Nginx 默认值(大文件上传 / 长轮询 / 流媒体必需)
+	if size := strings.TrimSpace(req.ClientMaxBodySize); size != "" {
+		fmt.Fprintf(&b, "    client_max_body_size %s;\n", size)
+	}
+	if timeout := strings.TrimSpace(req.ProxyReadTimeout); timeout != "" {
+		fmt.Fprintf(&b, "    proxy_read_timeout %s;\n", timeout)
+	}
+	// 安全响应头:HSTS 仅在 HTTPS 实际启用时输出
+	if req.SecurityHeaders {
+		b.WriteString("    add_header X-Content-Type-Options \"nosniff\" always;\n")
+		b.WriteString("    add_header X-Frame-Options \"SAMEORIGIN\" always;\n")
+		b.WriteString("    add_header Referrer-Policy \"strict-origin-when-cross-origin\" always;\n")
+		if tlsEnabled {
+			b.WriteString("    add_header Strict-Transport-Security \"max-age=31536000; includeSubDomains\" always;\n")
+		}
+	}
+	// 限流/连接数的 zone 定义在 fonuagent-limits.conf(http 上下文),此处只声明状态码
+	if req.RateLimitRate > 0 {
+		b.WriteString("    limit_req_status 429;\n")
+	}
+	if req.ConnLimitMax > 0 {
+		b.WriteString("    limit_conn_status 503;\n")
+	}
+	if req.WebSocket {
+		b.WriteString("    proxy_http_version 1.1;\n")
+		b.WriteString("    proxy_set_header Upgrade $http_upgrade;\n")
+		b.WriteString("    proxy_set_header Connection \"upgrade\";\n")
+	}
+	// IP 访问控制:白名单(allow + deny all)与黑名单(deny)可同时生效;
+	// Nginx 按出现顺序匹配,因此 allow 在前、deny 在后。
+	var acl strings.Builder
+	for _, ip := range req.AllowIPs {
+		ip = strings.TrimSpace(ip)
+		if ip != "" {
+			fmt.Fprintf(&acl, "        allow %s;\n", ip)
+		}
+	}
+	for _, ip := range req.DenyIPs {
+		if ip = strings.TrimSpace(ip); ip != "" {
+			fmt.Fprintf(&acl, "        deny %s;\n", ip)
+		}
+	}
+	if len(req.AllowIPs) > 0 {
+		acl.WriteString("        deny all;\n")
+	}
+	// Basic Auth:密码优先用规则表单值(自定义),留空则首次自动生成并复用(htpasswd 格式)
+	authLine := ""
+	var authNote string
+	if user := strings.TrimSpace(req.BasicAuthUser); user != "" {
+		if password := strings.TrimSpace(req.BasicAuthPassword); password != "" {
+			if err := writeBasicAuthSecret(req.Domain, user, password); err == nil {
+				authLine = fmt.Sprintf("        auth_basic \"Fonu Protected\";\n        auth_basic_user_file %s;\n", basicAuthFilePath(req.Domain))
+			}
+		} else if generated, err := ensureBasicAuthSecret(req.Domain, user); err == nil {
+			authLine = fmt.Sprintf("        auth_basic \"Fonu Protected\";\n        auth_basic_user_file %s;\n", basicAuthFilePath(req.Domain))
+			if generated != "" {
+				authNote = fmt.Sprintf("# Basic Auth: user=%s password=%s\n", user, generated)
+			}
+		}
+	}
+	b.WriteString(authNote)
+	// 限流 / 连接数 / 仅中国大陆 IP 的 location 级指令(zone 与 geo 变量在 http 上下文定义)
+	var limitText strings.Builder
+	if req.RateLimitRate > 0 {
+		zone := zoneKey(req.Domain)
+		if req.RateLimitBurst > 0 {
+			fmt.Fprintf(&limitText, "        limit_req zone=%s_req burst=%d nodelay;\n", zone, req.RateLimitBurst)
+		} else {
+			fmt.Fprintf(&limitText, "        limit_req zone=%s_req nodelay;\n", zone)
+		}
+	}
+	if req.ConnLimitMax > 0 {
+		fmt.Fprintf(&limitText, "        limit_conn %s_conn %d;\n", zoneKey(req.Domain), req.ConnLimitMax)
+	}
+	if req.ChinaOnly {
+		limitText.WriteString("        if ($fonu_china_client = 0) { return 403; }\n")
+	}
+	aclText := acl.String()
+	fmt.Fprintf(&b, "    location / {\n%s%s%s        proxy_pass http://%s;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_http_version 1.1;\n    }\n}\n", aclText, authLine, limitText.String(), req.Upstream)
+	return b.String()
+}
+
+// agentStateDir 存放 Agent 的持久状态(htpasswd、geo IP 段、限流参数)。
+const agentStateDir = "/var/lib/fonu-agent"
+
+// zoneKey 把域名转成合法的 nginx zone 名(仅保留小写字母与数字,其余换下划线)。
+func zoneKey(domain string) string {
+	var b strings.Builder
+	b.WriteString("fonu_")
+	for _, r := range strings.ToLower(domain) {
+		if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
+			b.WriteRune(r)
+		} else {
+			b.WriteByte('_')
+		}
+	}
+	return b.String()
+}
+
+// limitsFilePath 返回 zone 定义文件(放在 vhost 目录,顶层指令即 http 上下文)。
+func (s *Server) limitsFilePath() string {
+	return filepath.Join(s.cfg.NginxConfDir, "fonuagent-limits.conf")
+}
+
+// chinaGeoFilePath 返回 geo 变量定义文件(同样位于 http 上下文)。
+func (s *Server) chinaGeoFilePath() string {
+	return filepath.Join(s.cfg.NginxConfDir, "fonuagent-china.conf")
+}
+
+// chinaListPath 返回中国 IP 段文件(geo include 格式: 1;)。
+func chinaListPath() string {
+	return filepath.Join(agentStateDir, "geo", "china.conf")
+}
+
+func limitsStatePath() string {
+	return filepath.Join(agentStateDir, "route-limits.json")
+}
+
+// routeLimit 单条路由的限流参数。
+type routeLimit struct {
+	Rate  int `json:"rate,omitempty"`
+	Burst int `json:"burst,omitempty"`
+	Conn  int `json:"conn,omitempty"`
+}
+
+func readLimitState() map[string]routeLimit {
+	state := map[string]routeLimit{}
+	data, err := os.ReadFile(limitsStatePath())
+	if err != nil {
+		return state
+	}
+	_ = json.Unmarshal(data, &state)
+	return state
+}
+
+// writeLimitState 落盘限流参数并重生成 zone 定义文件;无限流时删除该文件。
+func (s *Server) writeLimitState(state map[string]routeLimit) error {
+	data, err := json.Marshal(state)
+	if err != nil {
+		return err
+	}
+	if err := atomicWrite(limitsStatePath(), data, 0o640); err != nil {
+		return err
+	}
+	path := s.limitsFilePath()
+	if len(state) == 0 {
+		if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
+			return err
+		}
+		return nil
+	}
+	var b strings.Builder
+	b.WriteString("# Managed by fonu-agent. Do not edit.\n")
+	domains := make([]string, 0, len(state))
+	for domain := range state {
+		domains = append(domains, domain)
+	}
+	sort.Strings(domains)
+	for _, domain := range domains {
+		item := state[domain]
+		key := zoneKey(domain)
+		if item.Rate > 0 {
+			fmt.Fprintf(&b, "limit_req_zone $binary_remote_addr zone=%s_req:10m rate=%dr/s;\n", key, item.Rate)
+		}
+		if item.Conn > 0 {
+			fmt.Fprintf(&b, "limit_conn_zone $binary_remote_addr zone=%s_conn:10m;\n", key)
+		}
+	}
+	return atomicWrite(path, []byte(b.String()), 0o640)
+}
+
+// applyLimitState 记录本次路由的限流参数并刷新 zone 文件(必须在写 vhost 之前完成)。
+func (s *Server) applyLimitState(req routeRequest) error {
+	state := readLimitState()
+	if req.RateLimitRate > 0 || req.ConnLimitMax > 0 {
+		state[req.Domain] = routeLimit{Rate: req.RateLimitRate, Burst: req.RateLimitBurst, Conn: req.ConnLimitMax}
+	} else {
+		delete(state, req.Domain)
+	}
+	return s.writeLimitState(state)
+}
+
+// clearLimitState 删除该域名的限流参数并刷新 zone 文件。
+func (s *Server) clearLimitState(domain string) error {
+	state := readLimitState()
+	if _, ok := state[domain]; !ok {
+		return nil
+	}
+	delete(state, domain)
+	return s.writeLimitState(state)
+}
+
+// sslDirectives 生成证书与 TLS 协议指令;tls13Only 时只允许 TLS 1.3。
+func sslDirectives(certDir string, tls13Only bool) string {
+	var b strings.Builder
+	fmt.Fprintf(&b, "    ssl_certificate %s;\n    ssl_certificate_key %s;\n", filepath.Join(certDir, "fullchain.pem"), filepath.Join(certDir, "privkey.pem"))
+	if tls13Only {
+		b.WriteString("    ssl_protocols TLSv1.3;\n")
+	}
+	return b.String()
+}
+
+// writeBasicAuthSecret 用规则表单提供的密码写入 htpasswd(自定义密码场景)。
+func writeBasicAuthSecret(domain, user, password string) error {
+	out, err := exec.Command("openssl", "passwd", "-apr1", password).CombinedOutput()
+	if err != nil {
+		return fmt.Errorf("生成 htpasswd 失败: %w: %s", err, strings.TrimSpace(string(out)))
+	}
+	return atomicWrite(basicAuthFilePath(domain), []byte(user+":"+strings.TrimSpace(string(out))+"\n"), 0o640)
+}
+
+// basicAuthFilePath 返回指定域名的 htpasswd 文件路径(与反代配置同目录管理)。
+func basicAuthFilePath(domain string) string {
+	return filepath.Join("/var/lib/fonu-agent", "basicauth", domain+".htpasswd")
+}
+
+// ensureBasicAuthSecret 读取已有 Basic Auth 密码;不存在则生成随机密码并写入 htpasswd 文件。
+// 返回明文密码(只在生成配置时写进注释,供用户查看一次)。
+func ensureBasicAuthSecret(domain, user string) (string, error) {
+	path := basicAuthFilePath(domain)
+	if data, err := os.ReadFile(path); err == nil {
+		// 已有凭据:格式 user:$apr1$...,无法反解;返回空表示沿用旧密码
+		if len(data) > 0 {
+			return "", nil
+		}
+	}
+	password := randomToken()[:16]
+	// 用 openssl 生成 apr1 哈希(Nginx 原生支持);系统无 openssl 时退化为 crypt 失败报错
+	out, err := exec.Command("openssl", "passwd", "-apr1", password).CombinedOutput()
+	if err != nil {
+		return "", fmt.Errorf("生成 htpasswd 失败: %w: %s", err, strings.TrimSpace(string(out)))
+	}
+	hash := strings.TrimSpace(string(out))
+	if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
+		return "", err
+	}
+	if err := atomicWrite(path, []byte(user+":"+hash+"\n"), 0o640); err != nil {
+		return "", err
+	}
+	return password, nil
+}
+
+func atomicWrite(path string, data []byte, mode os.FileMode) error {
+	if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
+		return err
+	}
+	tmp, err := os.CreateTemp(filepath.Dir(path), ".fonu-agent-*")
+	if err != nil {
+		return err
+	}
+	tmpPath := tmp.Name()
+	defer os.Remove(tmpPath)
+	if err := tmp.Chmod(mode); err != nil {
+		tmp.Close()
+		return err
+	}
+	if _, err := tmp.Write(data); err != nil {
+		tmp.Close()
+		return err
+	}
+	if err := tmp.Close(); err != nil {
+		return err
+	}
+	return os.Rename(tmpPath, path)
+}
+
+func writeJSON(w http.ResponseWriter, status int, value any) {
+	w.Header().Set("Content-Type", "application/json; charset=utf-8")
+	w.WriteHeader(status)
+	_ = json.NewEncoder(w).Encode(value)
+}
+
+func randomToken() string {
+	buf := make([]byte, 32)
+	if _, err := rand.Read(buf); err != nil {
+		return hex.EncodeToString([]byte(time.Now().String()))
+	}
+	return hex.EncodeToString(buf)
+}
diff --git a/internal/api/auth_handler.go b/internal/api/auth_handler.go
new file mode 100644
index 0000000..59d8c26
--- /dev/null
+++ b/internal/api/auth_handler.go
@@ -0,0 +1,131 @@
+package api
+
+import (
+	"encoding/json"
+	"errors"
+	"net"
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/fonu/fonu/internal/auth"
+	"github.com/fonu/fonu/internal/notify"
+)
+
+type AuthHandler struct {
+	auth   *auth.Service
+	notify *notify.Service
+}
+
+func NewAuthHandler(authSvc *auth.Service, notifySvc *notify.Service) *AuthHandler {
+	return &AuthHandler{auth: authSvc, notify: notifySvc}
+}
+
+type loginRequest struct {
+	Username string `json:"username"`
+	Password string `json:"password"`
+}
+
+type authStatusResponse struct {
+	Initialized bool `json:"initialized"`
+	Authenticated bool `json:"authenticated"`
+}
+
+func (h *AuthHandler) Status(w http.ResponseWriter, r *http.Request) {
+	initialized, err := h.auth.IsInitialized(r.Context())
+	if err != nil {
+		writeError(r, w, http.StatusInternalServerError, "读取初始化状态失败")
+		return
+	}
+	authenticated := false
+	if sessionID := sessionIDFromContext(r.Context()); sessionID != "" {
+		if err := h.auth.ValidateSession(r.Context(), sessionID); err == nil {
+			authenticated = true
+		}
+	}
+	writeJSON(w, http.StatusOK, authStatusResponse{
+		Initialized:   initialized,
+		Authenticated: authenticated,
+	})
+}
+
+func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
+	var req loginRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(r, w, http.StatusBadRequest, "请求格式无效")
+		return
+	}
+	sessionID, err := h.auth.Login(r.Context(), req.Username, req.Password)
+	if err != nil {
+		if errors.Is(err, auth.ErrUnauthorized) {
+			if h.notify != nil {
+				h.notify.RecordLoginFailure(r.Context(), clientIP(r))
+			}
+			writeError(r, w, http.StatusUnauthorized, "用户名或密码错误")
+			return
+		}
+		writeError(r, w, http.StatusInternalServerError, "登录失败")
+		return
+	}
+	setSessionCookie(w, sessionID)
+	writeJSON(w, http.StatusOK, map[string]string{"message": "登录成功"})
+}
+
+func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
+	var req struct {
+		OldPassword string `json:"old_password"`
+		NewPassword string `json:"new_password"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(r, w, http.StatusBadRequest, "请求格式无效")
+		return
+	}
+	sessionID := sessionIDFromContext(r.Context())
+	adminID, err := h.auth.AdminIDForSession(r.Context(), sessionID)
+	if err != nil {
+		writeError(r, w, http.StatusUnauthorized, "未登录或会话已过期")
+		return
+	}
+	if err := h.auth.ChangePassword(r.Context(), adminID, req.OldPassword, req.NewPassword); err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]string{"message": "密码已更新"})
+}
+
+func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
+	if sessionID := sessionIDFromContext(r.Context()); sessionID != "" {
+		_ = h.auth.Logout(r.Context(), sessionID)
+	}
+	clearSessionCookie(w)
+	writeJSON(w, http.StatusOK, map[string]string{"message": "已退出登录"})
+}
+
+func setSessionCookie(w http.ResponseWriter, sessionID string) {
+	http.SetCookie(w, &http.Cookie{
+		Name:     "fonu_session",
+		Value:    sessionID,
+		Path:     "/",
+		HttpOnly: true,
+		SameSite: http.SameSiteLaxMode,
+		MaxAge:   int((7 * 24 * time.Hour).Seconds()),
+	})
+}
+
+func clientIP(r *http.Request) string {
+	host, _, err := net.SplitHostPort(r.RemoteAddr)
+	if err != nil {
+		return strings.TrimSpace(r.RemoteAddr)
+	}
+	return host
+}
+
+func clearSessionCookie(w http.ResponseWriter) {
+	http.SetCookie(w, &http.Cookie{
+		Name:     "fonu_session",
+		Value:    "",
+		Path:     "/",
+		HttpOnly: true,
+		MaxAge:   -1,
+	})
+}
diff --git a/internal/api/auth_handler_test.go b/internal/api/auth_handler_test.go
new file mode 100644
index 0000000..4a02302
--- /dev/null
+++ b/internal/api/auth_handler_test.go
@@ -0,0 +1,96 @@
+package api
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"github.com/fonu/fonu/internal/auth"
+	"github.com/fonu/fonu/internal/backup"
+	"github.com/fonu/fonu/internal/config"
+	"github.com/fonu/fonu/internal/db"
+	"github.com/fonu/fonu/internal/discovery"
+	"github.com/fonu/fonu/internal/certificate"
+	"github.com/fonu/fonu/internal/proxy"
+	"github.com/fonu/fonu/internal/service"
+	"github.com/fonu/fonu/internal/settings"
+)
+
+func TestAuthSetupAndLoginFlow(t *testing.T) {
+	conn, err := db.Open(filepath.Join(t.TempDir(), "fonu.db"))
+	if err != nil {
+		t.Fatalf("open db: %v", err)
+	}
+	defer conn.Close()
+
+	if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
+		t.Fatalf("migrate: %v", err)
+	}
+
+	tmpDir := t.TempDir()
+	authSvc := auth.New(conn)
+	proxySvc := service.NewProxyService(config.Config{DataDir: tmpDir, NginxPIDFile: tmpDir + "/nginx.pid"}, conn, proxy.NewStore(conn), certificate.NewStore(conn), settings.NewStore(conn), nil)
+	handler := NewRouter(Deps{
+		Config:    config.Config{DataDir: tmpDir, NginxPIDFile: tmpDir + "/nginx.pid"},
+		Auth:      authSvc,
+		Proxy:     proxySvc,
+		Backup:    backup.New(tmpDir),
+		Discovery: discovery.New(),
+		StartedAt: time.Now().UTC().Format(time.RFC3339),
+	})
+
+	statusReq := httptest.NewRequest(http.MethodGet, "/api/auth/status", nil)
+	statusRec := httptest.NewRecorder()
+	handler.ServeHTTP(statusRec, statusReq)
+	if statusRec.Code != http.StatusOK {
+		t.Fatalf("status code: %d", statusRec.Code)
+	}
+
+	if err := authSvc.Setup(context.Background(), "admin", "password123"); err != nil {
+		t.Fatalf("setup admin: %v", err)
+	}
+
+	loginBody, _ := json.Marshal(map[string]string{
+		"username": "admin",
+		"password": "password123",
+	})
+	loginReq := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(loginBody))
+	loginRec := httptest.NewRecorder()
+	handler.ServeHTTP(loginRec, loginReq)
+	if loginRec.Code != http.StatusOK {
+		t.Fatalf("login status: %d body=%s", loginRec.Code, loginRec.Body.String())
+	}
+
+	cookie := loginRec.Result().Cookies()[0]
+	if cookie.Name != "fonu_session" || cookie.HttpOnly == false {
+		t.Fatal("expected httponly session cookie")
+	}
+
+	authStatusReq := httptest.NewRequest(http.MethodGet, "/api/auth/status", nil)
+	authStatusReq.AddCookie(cookie)
+	authStatusRec := httptest.NewRecorder()
+	handler.ServeHTTP(authStatusRec, authStatusReq)
+	if authStatusRec.Code != http.StatusOK {
+		t.Fatalf("auth status code: %d", authStatusRec.Code)
+	}
+	var authStatus authStatusResponse
+	if err := json.Unmarshal(authStatusRec.Body.Bytes(), &authStatus); err != nil {
+		t.Fatalf("decode auth status: %v", err)
+	}
+	if !authStatus.Initialized || !authStatus.Authenticated {
+		t.Fatalf("expected authenticated status, got %+v", authStatus)
+	}
+
+	proxiesReq := httptest.NewRequest(http.MethodGet, "/api/proxies", nil)
+	proxiesReq.AddCookie(cookie)
+	proxiesRec := httptest.NewRecorder()
+	handler.ServeHTTP(proxiesRec, proxiesReq)
+	if proxiesRec.Code != http.StatusOK {
+		t.Fatalf("proxies status: %d", proxiesRec.Code)
+	}
+}
diff --git a/internal/api/backup_handler.go b/internal/api/backup_handler.go
new file mode 100644
index 0000000..7a6f739
--- /dev/null
+++ b/internal/api/backup_handler.go
@@ -0,0 +1,73 @@
+package api
+
+import (
+	"io"
+	"net/http"
+	"os"
+	"path/filepath"
+
+	"github.com/fonu/fonu/internal/backup"
+)
+
+type BackupHandler struct {
+	svc *backup.Service
+}
+
+func NewBackupHandler(svc *backup.Service) *BackupHandler {
+	return &BackupHandler{svc: svc}
+}
+
+func (h *BackupHandler) Export(w http.ResponseWriter, r *http.Request) {
+	path, err := h.svc.Export(r.Context())
+	if err != nil {
+		writeError(r, w, http.StatusInternalServerError, "创建备份失败")
+		return
+	}
+	defer os.Remove(path)
+
+	file, err := os.Open(path)
+	if err != nil {
+		writeError(r, w, http.StatusInternalServerError, "读取备份失败")
+		return
+	}
+	defer file.Close()
+
+	w.Header().Set("Content-Type", "application/gzip")
+	w.Header().Set("Content-Disposition", "attachment; filename="+filepath.Base(path))
+	_, _ = io.Copy(w, file)
+}
+
+func (h *BackupHandler) Restore(w http.ResponseWriter, r *http.Request) {
+	if err := r.ParseMultipartForm(64 << 20); err != nil {
+		writeError(r, w, http.StatusBadRequest, "上传文件无效")
+		return
+	}
+	file, header, err := r.FormFile("file")
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "请选择备份文件")
+		return
+	}
+	defer file.Close()
+
+	tmpPath := filepath.Join(os.TempDir(), header.Filename)
+	out, err := os.Create(tmpPath)
+	if err != nil {
+		writeError(r, w, http.StatusInternalServerError, "保存上传文件失败")
+		return
+	}
+	if _, err := io.Copy(out, file); err != nil {
+		out.Close()
+		_ = os.Remove(tmpPath)
+		writeError(r, w, http.StatusInternalServerError, "保存上传文件失败")
+		return
+	}
+	out.Close()
+
+	if err := h.svc.Restore(r.Context(), tmpPath); err != nil {
+		_ = os.Remove(tmpPath)
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	_ = os.Remove(tmpPath)
+	writeJSON(w, http.StatusOK, map[string]string{"message": "备份已恢复,建议重启 Fonu"})
+}
diff --git a/internal/api/cert_handler.go b/internal/api/cert_handler.go
new file mode 100644
index 0000000..b749fb2
--- /dev/null
+++ b/internal/api/cert_handler.go
@@ -0,0 +1,151 @@
+package api
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/fonu/fonu/internal/acme"
+)
+
+type CertHandler struct {
+	svc *acme.Service
+}
+
+func NewCertHandler(svc *acme.Service) *CertHandler {
+	return &CertHandler{svc: svc}
+}
+
+func (h *CertHandler) List(w http.ResponseWriter, r *http.Request) {
+	records, err := h.svc.List(r.Context())
+	if err != nil {
+		writeError(r, w, http.StatusInternalServerError, "读取证书失败")
+		return
+	}
+	writeJSON(w, http.StatusOK, records)
+}
+
+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)},
+		{"value": acme.CALetsEncryptStaging, "label": acme.CALabel(acme.CALetsEncryptStaging)},
+	})
+}
+
+func (h *CertHandler) Apply(w http.ResponseWriter, r *http.Request) {
+	var req struct {
+		Domain       string   `json:"domain"`
+		DNSZone      string   `json:"dns_zone"`
+		DDNSConfigID int64    `json:"ddns_config_id"`
+		Domains      []string `json:"domains"`
+		CA           string   `json:"ca"`
+		Email        string   `json:"email"`
+	}
+	_ = json.NewDecoder(r.Body).Decode(&req)
+	domains := req.Domains
+	if len(domains) == 0 && strings.TrimSpace(req.Domain) != "" {
+		domains = []string{req.Domain}
+	}
+	if req.DDNSConfigID <= 0 {
+		writeError(r, w, http.StatusBadRequest, "请选择 DNS 任务")
+		return
+	}
+	jobID, err := h.svc.StartApply(r.Context(), domains, req.CA, req.Email, req.DDNSConfigID)
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusAccepted, map[string]string{"job_id": jobID})
+}
+
+func (h *CertHandler) ApplyJobStream(w http.ResponseWriter, r *http.Request) {
+	jobID := strings.TrimSpace(r.PathValue("id"))
+	if jobID == "" {
+		writeError(r, w, http.StatusBadRequest, "无效的任务 ID")
+		return
+	}
+
+	w.Header().Set("Content-Type", "text/event-stream")
+	w.Header().Set("Cache-Control", "no-cache")
+	w.Header().Set("Connection", "keep-alive")
+
+	flusher, ok := w.(http.Flusher)
+	if !ok {
+		writeError(r, w, http.StatusInternalServerError, "SSE 不可用")
+		return
+	}
+	if err := h.svc.StreamJob(r.Context(), w, jobID, func() error {
+		flusher.Flush()
+		return nil
+	}); err != nil {
+		return
+	}
+}
+
+func (h *CertHandler) Import(w http.ResponseWriter, r *http.Request) {
+	var req struct {
+		Certificate string `json:"certificate"`
+		PrivateKey  string `json:"private_key"`
+		CertPath    string `json:"cert_path"`
+		KeyPath     string `json:"key_path"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(r, w, http.StatusBadRequest, "请求格式无效")
+		return
+	}
+	record, err := h.svc.Import(r.Context(), req.Certificate, req.PrivateKey, req.CertPath, req.KeyPath)
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, record)
+}
+
+func (h *CertHandler) Download(w http.ResponseWriter, r *http.Request) {
+	domain := strings.TrimSpace(r.PathValue("domain"))
+	if domain == "" {
+		writeError(r, w, http.StatusBadRequest, "域名不能为空")
+		return
+	}
+	part := r.URL.Query().Get("part")
+	filename, contentType, data, err := h.svc.Download(r.Context(), domain, part)
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	w.Header().Set("Content-Type", contentType)
+	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
+	w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
+	w.WriteHeader(http.StatusOK)
+	_, _ = w.Write(data)
+}
+
+func (h *CertHandler) Delete(w http.ResponseWriter, r *http.Request) {
+	domain := strings.TrimSpace(r.PathValue("domain"))
+	if domain == "" {
+		writeError(r, w, http.StatusBadRequest, "域名不能为空")
+		return
+	}
+	if err := h.svc.Delete(r.Context(), domain); err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]string{"message": "证书已删除"})
+}
+
+func (h *CertHandler) Renew(w http.ResponseWriter, r *http.Request) {
+	var req struct {
+		Domain string `json:"domain"`
+		CA     string `json:"ca"`
+	}
+	_ = json.NewDecoder(r.Body).Decode(&req)
+	record, err := h.svc.Renew(r.Context(), req.Domain, req.CA)
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, record)
+}
diff --git a/internal/api/chinacidr_handler.go b/internal/api/chinacidr_handler.go
new file mode 100644
index 0000000..3775792
--- /dev/null
+++ b/internal/api/chinacidr_handler.go
@@ -0,0 +1,29 @@
+package api
+
+import (
+	"net/http"
+
+	"github.com/fonu/fonu/internal/chinacidr"
+)
+
+type ChinaCIDRHandler struct {
+	svc *chinacidr.Service
+}
+
+func NewChinaCIDRHandler(svc *chinacidr.Service) *ChinaCIDRHandler {
+	return &ChinaCIDRHandler{svc: svc}
+}
+
+func (h *ChinaCIDRHandler) Status(w http.ResponseWriter, r *http.Request) {
+	writeJSON(w, http.StatusOK, h.svc.Status(r.Context()))
+}
+
+func (h *ChinaCIDRHandler) Refresh(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	if err := h.svc.Update(ctx); err != nil {
+		h.svc.RecordLastError(ctx, err)
+		writeError(r, w, http.StatusBadGateway, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, h.svc.Status(ctx))
+}
diff --git a/internal/api/ddns_handler.go b/internal/api/ddns_handler.go
new file mode 100644
index 0000000..292f764
--- /dev/null
+++ b/internal/api/ddns_handler.go
@@ -0,0 +1,157 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+	"strconv"
+
+	"github.com/fonu/fonu/internal/ddns"
+)
+
+type DDNSHandler struct {
+	svc *ddns.Service
+}
+
+func NewDDNSHandler(svc *ddns.Service) *DDNSHandler {
+	return &DDNSHandler{svc: svc}
+}
+
+type ddnsRequest struct {
+	Provider    string   `json:"provider"`
+	RootDomain  string   `json:"root_domain"`
+	RecordName  string   `json:"record_name"`
+	RecordNames []string `json:"record_names"`
+	Domains     []string `json:"domains"`
+	IPv4Enabled *bool    `json:"ipv4_enabled"`
+	IPv6Enabled *bool  `json:"ipv6_enabled"`
+	Enabled     *bool  `json:"enabled"`
+	CustomIPv4  string `json:"custom_ipv4"`
+	CustomIPv6  string `json:"custom_ipv6"`
+	APIToken    string `json:"api_token"`
+	APITokenID  string `json:"api_token_id"`
+	APISecret   string `json:"api_secret"`
+}
+
+func (h *DDNSHandler) toInput(req ddnsRequest) ddns.SaveInput {
+	return ddns.SaveInput{
+		Provider:    req.Provider,
+		RootDomain:  req.RootDomain,
+		RecordName:  req.RecordName,
+		RecordNames: req.RecordNames,
+		Domains:     req.Domains,
+		IPv4Enabled: boolDefault(req.IPv4Enabled, true),
+		IPv6Enabled: boolDefault(req.IPv6Enabled, false),
+		Enabled:     boolDefault(req.Enabled, true),
+		CustomIPv4:  req.CustomIPv4,
+		CustomIPv6:  req.CustomIPv6,
+		APIToken:    req.APIToken,
+		APITokenID:  req.APITokenID,
+		APISecret:   req.APISecret,
+	}
+}
+
+func (h *DDNSHandler) List(w http.ResponseWriter, r *http.Request) {
+	var configs []ddns.Config
+	var err error
+	if r.URL.Query().Get("lite") == "1" {
+		configs, err = h.svc.ListLite(r.Context())
+	} else {
+		configs, err = h.svc.List(r.Context())
+	}
+	if err != nil {
+		writeError(r, w, http.StatusInternalServerError, "读取 DDNS 配置失败")
+		return
+	}
+	writeJSON(w, http.StatusOK, configs)
+}
+
+func (h *DDNSHandler) Create(w http.ResponseWriter, r *http.Request) {
+	var req ddnsRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(r, w, http.StatusBadRequest, "请求格式无效")
+		return
+	}
+	cfg, err := h.svc.Create(r.Context(), h.toInput(req))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, cfg)
+}
+
+func (h *DDNSHandler) Update(w http.ResponseWriter, r *http.Request) {
+	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "无效的配置 ID")
+		return
+	}
+	var req ddnsRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(r, w, http.StatusBadRequest, "请求格式无效")
+		return
+	}
+	cfg, err := h.svc.Update(r.Context(), id, h.toInput(req))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, cfg)
+}
+
+func (h *DDNSHandler) Delete(w http.ResponseWriter, r *http.Request) {
+	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "无效的配置 ID")
+		return
+	}
+	if err := h.svc.Delete(r.Context(), id); err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	w.WriteHeader(http.StatusNoContent)
+}
+
+func (h *DDNSHandler) Test(w http.ResponseWriter, r *http.Request) {
+	var req struct {
+		ConfigID   int64  `json:"config_id"`
+		Provider   string `json:"provider"`
+		APIToken   string `json:"api_token"`
+		APITokenID string `json:"api_token_id"`
+		APISecret  string `json:"api_secret"`
+	}
+	_ = json.NewDecoder(r.Body).Decode(&req)
+	if err := h.svc.Test(r.Context(), ddns.TestInput{
+		ConfigID:   req.ConfigID,
+		Provider:   req.Provider,
+		APIToken:   req.APIToken,
+		APITokenID: req.APITokenID,
+		APISecret:  req.APISecret,
+	}); err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]string{"message": "连接成功"})
+}
+
+func (h *DDNSHandler) UpdateOne(w http.ResponseWriter, r *http.Request) {
+	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "无效的配置 ID")
+		return
+	}
+	cfg, err := h.svc.UpdateNow(r.Context(), id)
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, cfg)
+}
+
+func (h *DDNSHandler) UpdateAll(w http.ResponseWriter, r *http.Request) {
+	configs, err := h.svc.UpdateAll(r.Context())
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, configs)
+}
diff --git a/internal/api/deps.go b/internal/api/deps.go
new file mode 100644
index 0000000..7bd777d
--- /dev/null
+++ b/internal/api/deps.go
@@ -0,0 +1,37 @@
+package api
+
+import (
+	"io/fs"
+	"log/slog"
+
+	"github.com/fonu/fonu/internal/acme"
+	"github.com/fonu/fonu/internal/auth"
+	"github.com/fonu/fonu/internal/backup"
+	"github.com/fonu/fonu/internal/chinacidr"
+	"github.com/fonu/fonu/internal/config"
+	"github.com/fonu/fonu/internal/ddns"
+	"github.com/fonu/fonu/internal/discovery"
+	"github.com/fonu/fonu/internal/frp"
+	"github.com/fonu/fonu/internal/notify"
+	"github.com/fonu/fonu/internal/service"
+	"github.com/fonu/fonu/internal/settings"
+	"github.com/fonu/fonu/internal/traffic"
+)
+
+type Deps struct {
+	Config     config.Config
+	Logger     *slog.Logger
+	Auth       *auth.Service
+	Proxy      *service.ProxyService
+	DDNS       *ddns.Service
+	ACME       *acme.Service
+	Settings   *settings.Store
+	ChinaCIDR  *chinacidr.Service
+	Backup     *backup.Service
+	Discovery  *discovery.Service
+	FRPMulti    *frp.Service
+	Traffic    *traffic.Collector
+	Notify     *notify.Service
+	StaticFS   fs.FS
+	StartedAt  string
+}
diff --git a/internal/api/discovery_handler.go b/internal/api/discovery_handler.go
new file mode 100644
index 0000000..726890d
--- /dev/null
+++ b/internal/api/discovery_handler.go
@@ -0,0 +1,20 @@
+package api
+
+import (
+	"net/http"
+
+	"github.com/fonu/fonu/internal/discovery"
+)
+
+type DiscoveryHandler struct {
+	svc *discovery.Service
+}
+
+func NewDiscoveryHandler(svc *discovery.Service) *DiscoveryHandler {
+	return &DiscoveryHandler{svc: svc}
+}
+
+func (h *DiscoveryHandler) Scan(w http.ResponseWriter, r *http.Request) {
+	host := r.URL.Query().Get("host")
+	writeJSON(w, http.StatusOK, h.svc.Scan(r.Context(), host))
+}
diff --git a/internal/api/frpmulti_handler.go b/internal/api/frpmulti_handler.go
new file mode 100644
index 0000000..75617d9
--- /dev/null
+++ b/internal/api/frpmulti_handler.go
@@ -0,0 +1,703 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+
+	"github.com/fonu/fonu/internal/frp"
+)
+
+// FRPMultiHandler 承载多服务端 FRP 模块的 HTTP 接口(/api/frpmulti/*),
+// 包装 internal/frp 的 Service(0.1.25 核心迁移版)。
+type FRPMultiHandler struct {
+	svc *frp.Service
+}
+
+type frpServerRequest struct {
+	AgentURL               string            `json:"agent_url"`
+	AgentToken             string            `json:"agent_token"`
+	ClearAgentToken        bool              `json:"clear_agent_token"`
+	SSHHost                string            `json:"ssh_host"`
+	SSHPort                int               `json:"ssh_port"`
+	SSHUser                string            `json:"ssh_user"`
+	SSHAuth                string            `json:"ssh_auth"`
+	SSHSecret              string            `json:"ssh_secret"`
+	ClearSSHSecret         bool              `json:"clear_ssh_secret"`
+	MgmtEnabled            *bool             `json:"mgmt_enabled"`
+	Name                   string            `json:"name"`
+	ServerAddr             string            `json:"server_addr"`
+	ServerPort             int               `json:"server_port"`
+	TLSEnabled             *bool             `json:"tls_enabled"`
+	TLSServerName          string            `json:"tls_server_name"`
+	Enabled                *bool             `json:"enabled"`
+	Token                  string            `json:"token"`
+	ClearToken             bool              `json:"clear_token"`
+	Options                frp.ServerOptions `json:"options"`
+	OIDCClientSecret       string            `json:"oidc_client_secret"`
+	ClearOIDCClientSecret  bool              `json:"clear_oidc_client_secret"`
+	DashboardPassword      string            `json:"dashboard_password"`
+	ClearDashboardPassword bool              `json:"clear_dashboard_password"`
+	TLSCertificate         string            `json:"tls_certificate"`
+	TLSKey                 string            `json:"tls_key"`
+	TLSTrustedCA           string            `json:"tls_trusted_ca"`
+	ClearTLSCertificate    bool              `json:"clear_tls_certificate"`
+	ClearTLSKey            bool              `json:"clear_tls_key"`
+	ClearTLSTrustedCA      bool              `json:"clear_tls_trusted_ca"`
+}
+
+type frpProxyRequest struct {
+	ServerID          int64            `json:"server_id"`
+	Name              string           `json:"name"`
+	Type              string           `json:"type"`
+	LocalIP           string           `json:"local_ip"`
+	LocalPort         int              `json:"local_port"`
+	RemotePort        *int             `json:"remote_port"`
+	CustomDomains     []string         `json:"custom_domains"`
+	HostHeaderRewrite string           `json:"host_header_rewrite"`
+	Options           frp.ProxyOptions `json:"options"`
+	Enabled           *bool            `json:"enabled"`
+	Remark            string           `json:"remark"`
+}
+
+type frpDownloadProxyRequest struct {
+	Proxy string `json:"proxy"`
+}
+
+func NewFRPMultiHandler(svc *frp.Service) *FRPMultiHandler {
+	return &FRPMultiHandler{svc: svc}
+}
+
+func (h *FRPMultiHandler) Status(w http.ResponseWriter, r *http.Request) {
+	writeJSON(w, http.StatusOK, h.svc.Status(r.Context()))
+}
+
+func (h *FRPMultiHandler) Runtime(w http.ResponseWriter, r *http.Request) {
+	writeJSON(w, http.StatusOK, h.svc.Runtime(r.Context()))
+}
+
+func (h *FRPMultiHandler) SetDownloadProxy(w http.ResponseWriter, r *http.Request) {
+	var request frpDownloadProxyRequest
+	if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
+		writeError(r, w, http.StatusBadRequest, "请求格式无效")
+		return
+	}
+	runtime, err := h.svc.SetDownloadProxy(r.Context(), request.Proxy)
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, runtime)
+}
+
+func (h *FRPMultiHandler) Releases(w http.ResponseWriter, r *http.Request) {
+	releases, err := h.svc.ListReleases(r.Context())
+	if err != nil {
+		writeError(r, w, http.StatusBadGateway, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, releases)
+}
+
+func (h *FRPMultiHandler) Binaries(w http.ResponseWriter, r *http.Request) {
+	binaries, err := h.svc.ListBinaries()
+	if err != nil {
+		writeError(r, w, http.StatusInternalServerError, "读取已下载 FRP 客户端失败")
+		return
+	}
+	writeJSON(w, http.StatusOK, binaries)
+}
+
+func (h *FRPMultiHandler) DownloadStatus(w http.ResponseWriter, r *http.Request) {
+	writeJSON(w, http.StatusOK, h.svc.DownloadStatus())
+}
+
+func (h *FRPMultiHandler) DownloadRelease(w http.ResponseWriter, r *http.Request) {
+	version := r.PathValue("version")
+	task, err := h.svc.StartDownload(version)
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusAccepted, task)
+}
+
+func (h *FRPMultiHandler) ActivateBinary(w http.ResponseWriter, r *http.Request) {
+	version := r.PathValue("version")
+	status, err := h.svc.ActivateBinary(r.Context(), version)
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, status)
+}
+
+func (h *FRPMultiHandler) DeleteBinary(w http.ResponseWriter, r *http.Request) {
+	version := r.PathValue("version")
+	if err := h.svc.DeleteBinary(version); err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	w.WriteHeader(http.StatusNoContent)
+}
+
+func (h *FRPMultiHandler) ListServers(w http.ResponseWriter, r *http.Request) {
+	servers, err := h.svc.ListServers(r.Context())
+	if err != nil {
+		writeError(r, w, http.StatusInternalServerError, "读取 FRP 服务端失败")
+		return
+	}
+	writeJSON(w, http.StatusOK, servers)
+}
+
+func (h *FRPMultiHandler) ServerDiagnostics(w http.ResponseWriter, r *http.Request) {
+	id, err := parseID(r.PathValue("id"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+		return
+	}
+	diagnostics, err := h.svc.ServerDiagnostics(r.Context(), id)
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, diagnostics)
+}
+
+func (h *FRPMultiHandler) ServerDetail(w http.ResponseWriter, r *http.Request) {
+	id, err := parseID(r.PathValue("id"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+		return
+	}
+	detail, err := h.svc.ServerDetail(r.Context(), id)
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, detail)
+}
+
+func (h *FRPMultiHandler) StartServer(w http.ResponseWriter, r *http.Request) {
+	id, err := parseID(r.PathValue("id"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+		return
+	}
+	server, err := h.svc.StartServer(r.Context(), id)
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, server)
+}
+
+func (h *FRPMultiHandler) StopServer(w http.ResponseWriter, r *http.Request) {
+	id, err := parseID(r.PathValue("id"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+		return
+	}
+	server, err := h.svc.StopServer(r.Context(), id)
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, server)
+}
+
+func (h *FRPMultiHandler) CreateServer(w http.ResponseWriter, r *http.Request) {
+	request, ok := decodeFRPServerRequest(w, r)
+	if !ok {
+		return
+	}
+	server, err := h.svc.CreateServer(r.Context(), request.serverInput())
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, server)
+}
+
+func (h *FRPMultiHandler) UpdateServer(w http.ResponseWriter, r *http.Request) {
+	id, err := parseID(r.PathValue("id"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+		return
+	}
+	request, ok := decodeFRPServerRequest(w, r)
+	if !ok {
+		return
+	}
+	server, err := h.svc.UpdateServer(r.Context(), id, request.serverInput())
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, server)
+}
+
+func (h *FRPMultiHandler) DeleteServer(w http.ResponseWriter, r *http.Request) {
+	id, err := parseID(r.PathValue("id"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+		return
+	}
+	if err := h.svc.DeleteServer(r.Context(), id); err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	w.WriteHeader(http.StatusNoContent)
+}
+
+func (h *FRPMultiHandler) AgentProbe(w http.ResponseWriter, r *http.Request) {
+	id, err := parseID(r.PathValue("id"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+		return
+	}
+	result, err := h.svc.AgentProbe(r.Context(), id)
+	if err != nil {
+		writeError(r, w, http.StatusBadGateway, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, result)
+}
+
+func (h *FRPMultiHandler) AgentInstall(w http.ResponseWriter, r *http.Request) {
+	id, err := parseID(r.PathValue("id"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+		return
+	}
+	server, err := h.svc.AgentInstall(r.Context(), id)
+	if err != nil {
+		writeError(r, w, http.StatusBadGateway, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, server)
+}
+
+func (h *FRPMultiHandler) AgentUninstall(w http.ResponseWriter, r *http.Request) {
+	id, err := parseID(r.PathValue("id"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+		return
+	}
+	keepData := r.URL.Query().Get("keep_data") == "true"
+	output, err := h.svc.AgentUninstall(r.Context(), id, keepData)
+	if err != nil {
+		writeError(r, w, http.StatusBadGateway, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"ok": true, "output": output})
+}
+
+func (h *FRPMultiHandler) AgentSSHDiagnose(w http.ResponseWriter, r *http.Request) {
+	id, err := parseID(r.PathValue("id"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+		return
+	}
+	output, err := h.svc.AgentSSHDiagnose(r.Context(), id)
+	if err != nil {
+		writeError(r, w, http.StatusBadGateway, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"output": output})
+}
+
+func (h *FRPMultiHandler) AgentTest(w http.ResponseWriter, r *http.Request) {
+	id, err := parseID(r.PathValue("id"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+		return
+	}
+	if err := h.svc.AgentTest(r.Context(), id); err != nil {
+		writeError(r, w, http.StatusBadGateway, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+func (h *FRPMultiHandler) AgentStatus(w http.ResponseWriter, r *http.Request) {
+	id, err := parseID(r.PathValue("id"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+		return
+	}
+	status, err := h.svc.AgentStatus(r.Context(), id)
+	if err != nil {
+		writeError(r, w, http.StatusBadGateway, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, status)
+}
+
+func (h *FRPMultiHandler) AgentRouteCandidates(w http.ResponseWriter, r *http.Request) {
+	id, err := parseID(r.PathValue("id"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+		return
+	}
+	proxies, err := h.svc.ListAgentRouteCandidates(r.Context(), id)
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, proxies)
+}
+
+func (h *FRPMultiHandler) DeployAgentRoute(w http.ResponseWriter, r *http.Request) {
+	proxyID, err := parseID(r.PathValue("proxyID"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "FRP 规则 ID 无效")
+		return
+	}
+	if err := h.svc.DeployAgentRoute(r.Context(), proxyID); err != nil {
+		writeError(r, w, http.StatusBadGateway, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"ok": true, "proxy_id": proxyID})
+}
+
+func (h *FRPMultiHandler) RemoveAgentRoute(w http.ResponseWriter, r *http.Request) {
+	proxyID, err := parseID(r.PathValue("proxyID"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "FRP 规则 ID 无效")
+		return
+	}
+	if err := h.svc.RemoveAgentRoute(r.Context(), proxyID); err != nil {
+		writeError(r, w, http.StatusBadGateway, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"ok": true, "proxy_id": proxyID})
+}
+
+func (h *FRPMultiHandler) AgentRoutes(w http.ResponseWriter, r *http.Request) {
+	id, err := parseID(r.PathValue("id"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+		return
+	}
+	// 路由状态明细在独立的 /api/v1/routes 端点(status 里的 routes 仍是字符串数组),
+	// 从 agentClient 直取明细并透传。
+	client, ok := h.svc.AgentClient(r.Context(), id)
+	if !ok {
+		writeError(r, w, http.StatusBadGateway, "公网 agent 未配置或 Token 未保存")
+		return
+	}
+	details, err := client.RoutesDetail(r.Context())
+	if err != nil {
+		writeError(r, w, http.StatusBadGateway, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, details)
+}
+
+// AgentRouteConf 返回指定穿透规则(首个域名)当前反代配置内容。
+func (h *FRPMultiHandler) AgentRouteConf(w http.ResponseWriter, r *http.Request) {
+	id, err := parseID(r.PathValue("id"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+		return
+	}
+	proxyID, err := parseID(r.PathValue("proxyID"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "FRP 规则 ID 无效")
+		return
+	}
+	proxy, err := h.svc.GetProxy(r.Context(), proxyID)
+	if err != nil || proxy.ServerID != id {
+		writeError(r, w, http.StatusNotFound, "FRP 规则不存在")
+		return
+	}
+	if len(proxy.CustomDomains) == 0 {
+		writeError(r, w, http.StatusBadRequest, "该规则没有自定义域名")
+		return
+	}
+	client, ok := h.svc.AgentClient(r.Context(), id)
+	if !ok {
+		writeError(r, w, http.StatusBadGateway, "公网 agent 未配置或 Token 未保存")
+		return
+	}
+	content, err := client.RouteConf(r.Context(), proxy.CustomDomains[0])
+	if err != nil {
+		writeError(r, w, http.StatusBadGateway, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"proxy_id": proxyID, "domain": proxy.CustomDomains[0], "content": content})
+}
+
+// AgentInstallNginx 触发 VPS 上一键安装 Nginx(需 agent 以 root 运行)。
+func (h *FRPMultiHandler) AgentInstallNginx(w http.ResponseWriter, r *http.Request) {
+	id, err := parseID(r.PathValue("id"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+		return
+	}
+	result, err := h.svc.InstallAgentNginx(r.Context(), id)
+	if err != nil {
+		writeError(r, w, http.StatusBadGateway, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, result)
+}
+
+// AgentRouteHealth 返回指定服务端公网反代规则的四段健康状态(DNS/证书/隧道/内网服务)。
+func (h *FRPMultiHandler) AgentRouteHealth(w http.ResponseWriter, r *http.Request) {
+	id, err := parseID(r.PathValue("id"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+		return
+	}
+	health, err := h.svc.RouteHealth(r.Context(), id)
+	if err != nil {
+		writeError(r, w, http.StatusBadGateway, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, health)
+}
+
+func (h *FRPMultiHandler) AgentFRPSAction(w http.ResponseWriter, r *http.Request) {
+	id, err := parseID(r.PathValue("id"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+		return
+	}
+	var body struct {
+		Action string `json:"action"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+		writeError(r, w, http.StatusBadRequest, "请求格式无效")
+		return
+	}
+	if err := h.svc.AgentFRPSAction(r.Context(), id, body.Action); err != nil {
+		writeError(r, w, http.StatusBadGateway, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"ok": true, "action": body.Action})
+}
+
+func (h *FRPMultiHandler) InstallFRPSOnAgent(w http.ResponseWriter, r *http.Request) {
+	id, err := parseID(r.PathValue("id"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+		return
+	}
+	var body struct {
+		Version string `json:"version"`
+		Proxy   string `json:"proxy"`
+	}
+	_ = json.NewDecoder(r.Body).Decode(&body)
+	if body.Version == "" {
+		body.Version = "0.71.0"
+	}
+	result, err := h.svc.InstallFRPSOnAgent(r.Context(), id, body.Version, body.Proxy)
+	if err != nil {
+		writeError(r, w, http.StatusBadGateway, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, result)
+}
+
+func (h *FRPMultiHandler) PushFRPSConfigToAgent(w http.ResponseWriter, r *http.Request) {
+	id, err := parseID(r.PathValue("id"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+		return
+	}
+	if err := h.svc.PushFRPSConfigToAgent(r.Context(), id); err != nil {
+		writeError(r, w, http.StatusBadGateway, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+func (h *FRPMultiHandler) TestServer(w http.ResponseWriter, r *http.Request) {
+	id, err := parseID(r.PathValue("id"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+		return
+	}
+	if err := h.svc.Test(r.Context(), id); err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]string{"message": "FRP 配置校验通过"})
+}
+
+func (h *FRPMultiHandler) ListProxies(w http.ResponseWriter, r *http.Request) {
+	proxies, err := h.svc.ListProxies(r.Context())
+	if err != nil {
+		writeError(r, w, http.StatusInternalServerError, "读取 FRP 规则失败")
+		return
+	}
+	writeJSON(w, http.StatusOK, proxies)
+}
+
+// ListProxiesTraffic 返回各 frps 管理接口采集到的穿透规则流量(含速率)。
+func (h *FRPMultiHandler) ListProxiesTraffic(w http.ResponseWriter, r *http.Request) {
+	traffic, err := h.svc.ListProxiesTraffic(r.Context())
+	if err != nil {
+		writeError(r, w, http.StatusInternalServerError, "读取 FRP 规则流量失败")
+		return
+	}
+	writeJSON(w, http.StatusOK, traffic)
+}
+
+func (h *FRPMultiHandler) CreateProxy(w http.ResponseWriter, r *http.Request) {
+	request, ok := decodeFRPProxyRequest(w, r)
+	if !ok {
+		return
+	}
+	proxy, err := h.svc.CreateProxy(r.Context(), request.proxyInput())
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, proxy)
+}
+
+func (h *FRPMultiHandler) UpdateProxy(w http.ResponseWriter, r *http.Request) {
+	id, err := parseID(r.PathValue("id"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "FRP 规则 ID 无效")
+		return
+	}
+	request, ok := decodeFRPProxyRequest(w, r)
+	if !ok {
+		return
+	}
+	proxy, err := h.svc.UpdateProxy(r.Context(), id, request.proxyInput())
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, proxy)
+}
+
+func (h *FRPMultiHandler) DeleteProxy(w http.ResponseWriter, r *http.Request) {
+	id, err := parseID(r.PathValue("id"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "FRP 规则 ID 无效")
+		return
+	}
+	if err := h.svc.DeleteProxy(r.Context(), id); err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	w.WriteHeader(http.StatusNoContent)
+}
+
+func (h *FRPMultiHandler) Start(w http.ResponseWriter, r *http.Request) {
+	if err := h.svc.Start(r.Context()); err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, h.svc.Status(r.Context()))
+}
+
+func (h *FRPMultiHandler) Stop(w http.ResponseWriter, r *http.Request) {
+	if err := h.svc.Stop(r.Context()); err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, h.svc.Status(r.Context()))
+}
+
+func (h *FRPMultiHandler) Reload(w http.ResponseWriter, r *http.Request) {
+	if err := h.svc.Reload(r.Context()); err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, h.svc.Status(r.Context()))
+}
+
+func (h *FRPMultiHandler) Logs(w http.ResponseWriter, r *http.Request) {
+	lines, err := h.svc.ReadLogs(queryInt(r, "limit", 200), int64(queryInt(r, "server_id", 0)))
+	if err != nil {
+		writeError(r, w, http.StatusInternalServerError, "读取 FRP 日志失败")
+		return
+	}
+	writeJSON(w, http.StatusOK, lines)
+}
+
+// ProxyLogs 返回单条穿透规则的日志(按规则名过滤所属服务端 frpc 日志)。
+func (h *FRPMultiHandler) ProxyLogs(w http.ResponseWriter, r *http.Request) {
+	id, err := parseID(r.PathValue("id"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "FRP 规则 ID 无效")
+		return
+	}
+	lines, err := h.svc.ReadProxyLogs(r.Context(), id, queryInt(r, "limit", 200))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, lines)
+}
+
+func (h *FRPMultiHandler) ExportConfig(w http.ResponseWriter, r *http.Request) {
+	content, err := h.svc.ExportMaskedConfig(r.Context())
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]string{"content": content})
+}
+
+// FRPSServerConfig 返回指定服务端的 frps.toml 同源配置(bindPort/auth/webServer 与 frpc 侧一致)。
+func (h *FRPMultiHandler) FRPSServerConfig(w http.ResponseWriter, r *http.Request) {
+	id, err := parseID(r.PathValue("id"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+		return
+	}
+	content, err := h.svc.FRPSServerConfig(r.Context(), id)
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]string{"content": content})
+}
+
+func decodeFRPServerRequest(w http.ResponseWriter, r *http.Request) (frpServerRequest, bool) {
+	var request frpServerRequest
+	if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
+		writeError(r, w, http.StatusBadRequest, "请求格式无效")
+		return frpServerRequest{}, false
+	}
+	return request, true
+}
+
+func decodeFRPProxyRequest(w http.ResponseWriter, r *http.Request) (frpProxyRequest, bool) {
+	var request frpProxyRequest
+	if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
+		writeError(r, w, http.StatusBadRequest, "请求格式无效")
+		return frpProxyRequest{}, false
+	}
+	return request, true
+}
+
+func (request frpServerRequest) serverInput() frp.ServerInput {
+	return frp.ServerInput{
+		AgentURL: request.AgentURL, AgentToken: request.AgentToken, ClearAgentToken: request.ClearAgentToken,
+		SSHHost: request.SSHHost, SSHPort: request.SSHPort, SSHUser: request.SSHUser, SSHAuth: request.SSHAuth,
+		SSHSecret: request.SSHSecret, ClearSSHSecret: request.ClearSSHSecret, MgmtEnabled: boolDefault(request.MgmtEnabled, false),
+		Name: request.Name, ServerAddr: request.ServerAddr, ServerPort: request.ServerPort,
+		TLS: boolDefault(request.TLSEnabled, true), TLSServerName: request.TLSServerName,
+		Enabled: boolDefault(request.Enabled, true), Token: request.Token, ClearToken: request.ClearToken, Options: request.Options,
+		OIDCClientSecret: request.OIDCClientSecret, ClearOIDCClientSecret: request.ClearOIDCClientSecret,
+		DashboardPassword: request.DashboardPassword, ClearDashboardPassword: request.ClearDashboardPassword,
+		TLSCertificate: request.TLSCertificate, TLSKey: request.TLSKey, TLSTrustedCA: request.TLSTrustedCA,
+		ClearTLSCertificate: request.ClearTLSCertificate, ClearTLSKey: request.ClearTLSKey, ClearTLSTrustedCA: request.ClearTLSTrustedCA,
+	}
+}
+
+func (request frpProxyRequest) proxyInput() frp.ProxyInput {
+	return frp.ProxyInput{
+		ServerID: request.ServerID, Name: request.Name, Type: request.Type, LocalIP: request.LocalIP,
+		LocalPort: request.LocalPort, RemotePort: request.RemotePort, CustomDomains: request.CustomDomains,
+		HostHeaderRewrite: request.HostHeaderRewrite, Options: request.Options, Enabled: boolDefault(request.Enabled, true), Remark: request.Remark,
+	}
+}
diff --git a/internal/api/logs_handler.go b/internal/api/logs_handler.go
new file mode 100644
index 0000000..57d8475
--- /dev/null
+++ b/internal/api/logs_handler.go
@@ -0,0 +1,128 @@
+package api
+
+import (
+	"net/http"
+	"path/filepath"
+	"strconv"
+
+	"github.com/fonu/fonu/internal/config"
+	"github.com/fonu/fonu/internal/logstore"
+)
+
+type LogsHandler struct {
+	cfg config.Config
+}
+
+func NewLogsHandler(cfg config.Config) *LogsHandler {
+	return &LogsHandler{cfg: cfg}
+}
+
+func (h *LogsHandler) Access(w http.ResponseWriter, r *http.Request) {
+	limit := queryInt(r, "limit", 100)
+	keyword := r.URL.Query().Get("keyword")
+	status := queryInt(r, "status", 0)
+	entries, err := logstore.ReadAccess(filepath.Join(h.cfg.LogsDir(), "access.log"), limit, keyword, status)
+	if err != nil {
+		writeError(r, w, http.StatusInternalServerError, "读取访问日志失败")
+		return
+	}
+	writeJSON(w, http.StatusOK, entries)
+}
+
+func (h *LogsHandler) Error(w http.ResponseWriter, r *http.Request) {
+	limit := queryInt(r, "limit", 100)
+	keyword := r.URL.Query().Get("keyword")
+	lines, err := logstore.ReadError(filepath.Join(h.cfg.LogsDir(), "error.log"), limit, keyword)
+	if err != nil {
+		writeError(r, w, http.StatusInternalServerError, "读取错误日志失败")
+		return
+	}
+	writeJSON(w, http.StatusOK, lines)
+}
+
+func (h *LogsHandler) System(w http.ResponseWriter, r *http.Request) {
+	limit := queryInt(r, "limit", 100)
+	level := r.URL.Query().Get("level")
+	keyword := r.URL.Query().Get("keyword")
+	entries, err := logstore.ReadSystem(filepath.Join(h.cfg.LogsDir(), "app.log"), limit, level, keyword)
+	if err != nil {
+		writeError(r, w, http.StatusInternalServerError, "读取系统日志失败")
+		return
+	}
+	writeJSON(w, http.StatusOK, entries)
+}
+
+func (h *LogsHandler) Stream(w http.ResponseWriter, r *http.Request) {
+	kind := r.URL.Query().Get("type")
+	if kind == "" {
+		kind = "error"
+	}
+	var path string
+	switch kind {
+	case "access":
+		path = filepath.Join(h.cfg.LogsDir(), "access.log")
+	case "error":
+		path = filepath.Join(h.cfg.LogsDir(), "error.log")
+	default:
+		writeError(r, w, http.StatusBadRequest, "实时日志仅支持 Nginx 访问/错误日志")
+		return
+	}
+
+	w.Header().Set("Content-Type", "text/event-stream")
+	w.Header().Set("Cache-Control", "no-cache")
+	w.Header().Set("Connection", "keep-alive")
+
+	flusher, ok := w.(http.Flusher)
+	if !ok {
+		writeError(r, w, http.StatusInternalServerError, "SSE 不可用")
+		return
+	}
+	tail := queryInt(r, "tail", 100)
+	var filter logstore.LineFilter
+	if kind == "access" {
+		filter = logstore.AccessHostFilter(r.URL.Query()["host"])
+	}
+	_ = logstore.StreamFile(r.Context(), path, w, func() error {
+		flusher.Flush()
+		return nil
+	}, tail, filter)
+}
+
+func (h *LogsHandler) StreamAccessForHosts(w http.ResponseWriter, r *http.Request, hosts []string) {
+	endpoints := make([]logstore.AccessEndpoint, 0, len(hosts))
+	for _, host := range hosts {
+		endpoints = append(endpoints, logstore.AccessEndpoint{Host: host})
+	}
+	h.StreamAccessForEndpoints(w, r, endpoints)
+}
+
+func (h *LogsHandler) StreamAccessForEndpoints(w http.ResponseWriter, r *http.Request, endpoints []logstore.AccessEndpoint) {
+	w.Header().Set("Content-Type", "text/event-stream")
+	w.Header().Set("Cache-Control", "no-cache")
+	w.Header().Set("Connection", "keep-alive")
+
+	flusher, ok := w.(http.Flusher)
+	if !ok {
+		writeError(r, w, http.StatusInternalServerError, "SSE 不可用")
+		return
+	}
+	path := filepath.Join(h.cfg.LogsDir(), "access.log")
+	tail := queryInt(r, "tail", 100)
+	filter := logstore.AccessEndpointFilter(endpoints)
+	_ = logstore.StreamFile(r.Context(), path, w, func() error {
+		flusher.Flush()
+		return nil
+	}, tail, filter)
+}
+
+func queryInt(r *http.Request, key string, fallback int) int {
+	raw := r.URL.Query().Get(key)
+	if raw == "" {
+		return fallback
+	}
+	n, err := strconv.Atoi(raw)
+	if err != nil {
+		return fallback
+	}
+	return n
+}
diff --git a/internal/api/logs_handler_test.go b/internal/api/logs_handler_test.go
new file mode 100644
index 0000000..9236bc8
--- /dev/null
+++ b/internal/api/logs_handler_test.go
@@ -0,0 +1,65 @@
+package api
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"github.com/fonu/fonu/internal/auth"
+	"github.com/fonu/fonu/internal/backup"
+	"github.com/fonu/fonu/internal/config"
+	"github.com/fonu/fonu/internal/db"
+	"github.com/fonu/fonu/internal/discovery"
+	"github.com/fonu/fonu/internal/certificate"
+	"github.com/fonu/fonu/internal/proxy"
+	"github.com/fonu/fonu/internal/service"
+	"github.com/fonu/fonu/internal/settings"
+)
+
+func TestLogsAccessRoute(t *testing.T) {
+	dir := t.TempDir()
+	conn, err := db.Open(filepath.Join(dir, "fonu.db"))
+	if err != nil {
+		t.Fatalf("open db: %v", err)
+	}
+	defer conn.Close()
+	if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
+		t.Fatalf("migrate: %v", err)
+	}
+
+	authSvc := auth.New(conn)
+	if err := authSvc.Setup(context.Background(), "admin", "password123"); err != nil {
+		t.Fatalf("setup: %v", err)
+	}
+	sessionID, err := authSvc.Login(context.Background(), "admin", "password123")
+	if err != nil {
+		t.Fatalf("login: %v", err)
+	}
+
+	handler := NewRouter(Deps{
+		Config:    config.Config{DataDir: dir, NginxPIDFile: filepath.Join(dir, "nginx.pid")},
+		Auth:      authSvc,
+		Proxy:     service.NewProxyService(config.Config{DataDir: dir, NginxPIDFile: filepath.Join(dir, "nginx.pid")}, conn, proxy.NewStore(conn), certificate.NewStore(conn), settings.NewStore(conn), nil),
+		Backup:    backup.New(dir),
+		Discovery: discovery.New(),
+		StartedAt: time.Now().UTC().Format(time.RFC3339),
+	})
+
+	req := httptest.NewRequest(http.MethodGet, "/api/logs/access?limit=100", nil)
+	req.AddCookie(&http.Cookie{Name: "fonu_session", Value: sessionID})
+	rec := httptest.NewRecorder()
+	handler.ServeHTTP(rec, req)
+
+	if rec.Code != http.StatusOK {
+		t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
+	}
+
+	var body []map[string]any
+	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+		t.Fatalf("invalid json: %v", err)
+	}
+}
diff --git a/internal/api/middleware.go b/internal/api/middleware.go
new file mode 100644
index 0000000..986ec91
--- /dev/null
+++ b/internal/api/middleware.go
@@ -0,0 +1,43 @@
+package api
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/fonu/fonu/internal/auth"
+)
+
+type contextKey string
+
+const sessionContextKey contextKey = "session_id"
+
+func SessionMiddleware(authSvc *auth.Service) func(http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			cookie, err := r.Cookie("fonu_session")
+			if err == nil && cookie.Value != "" {
+				ctx := context.WithValue(r.Context(), sessionContextKey, cookie.Value)
+				r = r.WithContext(ctx)
+			}
+			next.ServeHTTP(w, r)
+		})
+	}
+}
+
+func RequireAuth(authSvc *auth.Service) func(http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			sessionID, _ := r.Context().Value(sessionContextKey).(string)
+			if err := authSvc.ValidateSession(r.Context(), sessionID); err != nil {
+				writeError(r, w, http.StatusUnauthorized, "未登录或会话已过期")
+				return
+			}
+			next.ServeHTTP(w, r)
+		})
+	}
+}
+
+func sessionIDFromContext(ctx context.Context) string {
+	v, _ := ctx.Value(sessionContextKey).(string)
+	return v
+}
diff --git a/internal/api/nginx_handler.go b/internal/api/nginx_handler.go
new file mode 100644
index 0000000..293d61e
--- /dev/null
+++ b/internal/api/nginx_handler.go
@@ -0,0 +1,136 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+
+	"github.com/fonu/fonu/internal/service"
+)
+
+type NginxHandler struct {
+	proxy *service.ProxyService
+}
+
+func NewNginxHandler(proxy *service.ProxyService) *NginxHandler {
+	return &NginxHandler{proxy: proxy}
+}
+
+type saveRuleNginxRequest struct {
+	Mode    string  `json:"mode"`
+	Content *string `json:"content"`
+}
+
+type rollbackNginxRequest struct {
+	Backup string `json:"backup"`
+}
+
+func (h *NginxHandler) GetRule(w http.ResponseWriter, r *http.Request) {
+	id, err := parseID(r.PathValue("id"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
+		return
+	}
+	view, err := h.proxy.GetRuleNginx(r.Context(), id)
+	if err != nil {
+		writeServiceError(r, w, err)
+		return
+	}
+	writeJSON(w, http.StatusOK, view)
+}
+
+func (h *NginxHandler) PutRule(w http.ResponseWriter, r *http.Request) {
+	id, err := parseID(r.PathValue("id"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
+		return
+	}
+	var req saveRuleNginxRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(r, w, http.StatusBadRequest, "请求格式无效")
+		return
+	}
+	view, err := h.proxy.SaveRuleNginx(r.Context(), id, service.SaveRuleNginxInput{
+		Mode:    req.Mode,
+		Content: req.Content,
+	})
+	if err != nil {
+		writeServiceError(r, w, err)
+		return
+	}
+	writeJSON(w, http.StatusOK, view)
+}
+
+func (h *NginxHandler) RollbackRule(w http.ResponseWriter, r *http.Request) {
+	id, err := parseID(r.PathValue("id"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
+		return
+	}
+	var req rollbackNginxRequest
+	if r.Body != nil && r.ContentLength != 0 {
+		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+			writeError(r, w, http.StatusBadRequest, "请求格式无效")
+			return
+		}
+	}
+	view, err := h.proxy.RollbackRuleNginx(r.Context(), id, req.Backup)
+	if err != nil {
+		writeServiceError(r, w, err)
+		return
+	}
+	writeJSON(w, http.StatusOK, view)
+}
+
+func (h *NginxHandler) GetGlobal(w http.ResponseWriter, r *http.Request) {
+	view, err := h.proxy.GetGlobalNginx(r.Context())
+	if err != nil {
+		writeServiceError(r, w, err)
+		return
+	}
+	writeJSON(w, http.StatusOK, view)
+}
+
+func (h *NginxHandler) PutGlobal(w http.ResponseWriter, r *http.Request) {
+	var req saveRuleNginxRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(r, w, http.StatusBadRequest, "请求格式无效")
+		return
+	}
+	view, err := h.proxy.SaveGlobalNginx(r.Context(), service.SaveGlobalNginxInput{
+		Mode:    req.Mode,
+		Content: req.Content,
+	})
+	if err != nil {
+		writeServiceError(r, w, err)
+		return
+	}
+	writeJSON(w, http.StatusOK, view)
+}
+
+func (h *NginxHandler) RollbackGlobal(w http.ResponseWriter, r *http.Request) {
+	var req rollbackNginxRequest
+	if r.Body != nil && r.ContentLength != 0 {
+		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+			writeError(r, w, http.StatusBadRequest, "请求格式无效")
+			return
+		}
+	}
+	view, err := h.proxy.RollbackGlobalNginx(r.Context(), req.Backup)
+	if err != nil {
+		writeServiceError(r, w, err)
+		return
+	}
+	writeJSON(w, http.StatusOK, view)
+}
+
+func writeServiceError(r *http.Request, w http.ResponseWriter, err error) {
+	if err == nil {
+		return
+	}
+	msg := err.Error()
+	if msg == "规则不存在" {
+		writeError(r, w, http.StatusNotFound, msg, err)
+		return
+	}
+	writeError(r, w, http.StatusBadRequest, msg, err)
+}
diff --git a/internal/api/notify_handler.go b/internal/api/notify_handler.go
new file mode 100644
index 0000000..d261a82
--- /dev/null
+++ b/internal/api/notify_handler.go
@@ -0,0 +1,33 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+
+	"github.com/fonu/fonu/internal/notify"
+)
+
+type NotifyHandler struct {
+	notify *notify.Service
+}
+
+func NewNotifyHandler(notifySvc *notify.Service) *NotifyHandler {
+	return &NotifyHandler{notify: notifySvc}
+}
+
+func (h *NotifyHandler) Test(w http.ResponseWriter, r *http.Request) {
+	if h.notify == nil {
+		writeError(r, w, http.StatusInternalServerError, "通知服务未初始化")
+		return
+	}
+	var req notify.TestInput
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(r, w, http.StatusBadRequest, "请求格式无效")
+		return
+	}
+	if err := h.notify.SendTest(r.Context(), req); err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error(), err)
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]string{"message": "测试通知已发送"})
+}
diff --git a/internal/api/proxy_handler.go b/internal/api/proxy_handler.go
new file mode 100644
index 0000000..156e4c6
--- /dev/null
+++ b/internal/api/proxy_handler.go
@@ -0,0 +1,260 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+	"strconv"
+
+	"github.com/fonu/fonu/internal/config"
+	"github.com/fonu/fonu/internal/logstore"
+	"github.com/fonu/fonu/internal/proxy"
+	"github.com/fonu/fonu/internal/service"
+	"github.com/fonu/fonu/internal/traffic"
+)
+
+type ProxyHandler struct {
+	cfg     config.Config
+	svc     *service.ProxyService
+	logs    *LogsHandler
+	traffic *traffic.Collector
+}
+
+func NewProxyHandler(cfg config.Config, svc *service.ProxyService, logs *LogsHandler, collector *traffic.Collector) *ProxyHandler {
+	return &ProxyHandler{cfg: cfg, svc: svc, logs: logs, traffic: collector}
+}
+
+type proxyRequest struct {
+	Domain       string           `json:"domain"`
+	Upstream     string           `json:"upstream"`
+	ListenPort   *int             `json:"listen_port"`
+	ListenIPv4   *bool            `json:"listen_ipv4"`
+	ListenIPv6   *bool            `json:"listen_ipv6"`
+	Hosts        []string         `json:"hosts"`
+	HTTPSEnabled *bool            `json:"https_enabled"`
+	HTTPRedirect *bool            `json:"http_redirect"`
+	Enabled      *bool            `json:"enabled"`
+	Name         *string          `json:"name"`
+	Remark       *string          `json:"remark"` // deprecated alias for name
+	Security     *securityRequest `json:"security,omitempty"`
+}
+
+type proxyReorderRequest struct {
+	IDs []int64 `json:"ids"`
+}
+
+func (h *ProxyHandler) List(w http.ResponseWriter, r *http.Request) {
+	rules, err := h.svc.List(r.Context())
+	if err != nil {
+		writeError(r, w, http.StatusInternalServerError, "读取反向代理规则失败")
+		return
+	}
+	writeJSON(w, http.StatusOK, proxy.RulesForAPI(rules))
+}
+
+func (h *ProxyHandler) Create(w http.ResponseWriter, r *http.Request) {
+	var req proxyRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(r, w, http.StatusBadRequest, "请求格式无效")
+		return
+	}
+
+	hosts := req.hosts()
+	if len(hosts) == 0 {
+		writeError(r, w, http.StatusBadRequest, "至少需要一个前端域名")
+		return
+	}
+
+	listenPort := intDefault(req.ListenPort, h.cfg.DefaultListenPort(boolDefault(req.HTTPSEnabled, true)))
+	name := ""
+	if resolved := req.resolveName(); resolved != nil {
+		name = *resolved
+	}
+	in := proxy.CreateInput{
+		Upstream:     req.Upstream,
+		ListenPort:   listenPort,
+		ListenIPv4:   boolDefault(req.ListenIPv4, true),
+		ListenIPv6:   boolDefault(req.ListenIPv6, false),
+		Hosts:        hosts,
+		HTTPSEnabled: boolDefault(req.HTTPSEnabled, true),
+		HTTPRedirect: boolDefault(req.HTTPRedirect, true),
+		Enabled:      boolDefault(req.Enabled, true),
+		Name:         name,
+		Security:     req.securityInput(),
+	}
+
+	rule, err := h.svc.Create(r.Context(), in)
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusCreated, proxy.RuleForAPI(rule))
+}
+
+func (h *ProxyHandler) Update(w http.ResponseWriter, r *http.Request) {
+	id, err := parseID(r.PathValue("id"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
+		return
+	}
+
+	var req proxyRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(r, w, http.StatusBadRequest, "请求格式无效")
+		return
+	}
+
+	in := proxy.UpdateInput{}
+	if req.Upstream != "" {
+		in.Upstream = &req.Upstream
+	}
+	if req.ListenPort != nil {
+		in.ListenPort = req.ListenPort
+	}
+	if req.ListenIPv4 != nil {
+		in.ListenIPv4 = req.ListenIPv4
+	}
+	if req.ListenIPv6 != nil {
+		in.ListenIPv6 = req.ListenIPv6
+	}
+	if hosts := req.hosts(); len(hosts) > 0 {
+		in.Hosts = &hosts
+	}
+	if req.HTTPSEnabled != nil {
+		in.HTTPSEnabled = req.HTTPSEnabled
+	}
+	if req.HTTPRedirect != nil {
+		in.HTTPRedirect = req.HTTPRedirect
+	}
+	if req.Enabled != nil {
+		in.Enabled = req.Enabled
+	}
+	if resolved := req.resolveName(); resolved != nil {
+		in.Name = resolved
+	}
+	if req.Security != nil {
+		sec := req.securityInput()
+		in.Security = &sec
+	}
+
+	rule, err := h.svc.Update(r.Context(), id, in)
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, proxy.RuleForAPI(rule))
+}
+
+func (h *ProxyHandler) Traffic(w http.ResponseWriter, r *http.Request) {
+	if h.traffic == nil {
+		writeJSON(w, http.StatusOK, []traffic.RuleTraffic{})
+		return
+	}
+	rules, err := h.svc.List(r.Context())
+	if err != nil {
+		writeError(r, w, http.StatusInternalServerError, "读取反向代理规则失败")
+		return
+	}
+	writeJSON(w, http.StatusOK, h.traffic.SnapshotForRules(rules))
+}
+
+func (h *ProxyHandler) Clients(w http.ResponseWriter, r *http.Request) {
+	if h.traffic == nil {
+		writeJSON(w, http.StatusOK, []traffic.ClientConn{})
+		return
+	}
+	id, err := parseID(r.PathValue("id"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
+		return
+	}
+	rule, err := h.svc.Get(r.Context(), id)
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "规则不存在")
+		return
+	}
+	writeJSON(w, http.StatusOK, h.traffic.ClientsForRule(rule))
+}
+
+func (h *ProxyHandler) StreamLogs(w http.ResponseWriter, r *http.Request) {
+	id, err := parseID(r.PathValue("id"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
+		return
+	}
+	rule, err := h.svc.Get(r.Context(), id)
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "规则不存在")
+		return
+	}
+	endpoints := rule.Endpoints()
+	if len(endpoints) == 0 {
+		writeError(r, w, http.StatusBadRequest, "该规则没有前端域名")
+		return
+	}
+	matches := make([]logstore.AccessEndpoint, 0, len(endpoints))
+	for _, ep := range endpoints {
+		matches = append(matches, logstore.AccessEndpoint{Host: ep.Hostname, Port: ep.Port})
+	}
+	h.logs.StreamAccessForEndpoints(w, r, matches)
+}
+
+func (h *ProxyHandler) Reorder(w http.ResponseWriter, r *http.Request) {
+	var req proxyReorderRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(r, w, http.StatusBadRequest, "请求格式无效")
+		return
+	}
+	if err := h.svc.Reorder(r.Context(), req.IDs); err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	w.WriteHeader(http.StatusNoContent)
+}
+
+func (h *ProxyHandler) Delete(w http.ResponseWriter, r *http.Request) {
+	id, err := parseID(r.PathValue("id"))
+	if err != nil {
+		writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
+		return
+	}
+	if err := h.svc.Delete(r.Context(), id); err != nil {
+		writeError(r, w, http.StatusBadRequest, err.Error())
+		return
+	}
+	w.WriteHeader(http.StatusNoContent)
+}
+
+func (req proxyRequest) resolveName() *string {
+	if req.Name != nil {
+		return req.Name
+	}
+	return req.Remark
+}
+
+func (req proxyRequest) hosts() []string {
+	if len(req.Hosts) > 0 {
+		return req.Hosts
+	}
+	if req.Domain != "" {
+		return []string{req.Domain}
+	}
+	return nil
+}
+
+func intDefault(v *int, fallback int) int {
+	if v == nil {
+		return fallback
+	}
+	return *v
+}
+
+func parseID(raw string) (int64, error) {
+	return strconv.ParseInt(raw, 10, 64)
+}
+
+func boolDefault(v *bool, fallback bool) bool {
+	if v == nil {
+		return fallback
+	}
+	return *v
+}
diff --git a/internal/api/proxy_security.go b/internal/api/proxy_security.go
new file mode 100644
index 0000000..42e3142
--- /dev/null
+++ b/internal/api/proxy_security.go
@@ -0,0 +1,60 @@
+package api
+
+import (
+	"github.com/fonu/fonu/internal/proxy"
+)
+
+type securityRequest struct {
+	IPBlacklist       []string               `json:"ip_blacklist,omitempty"`
+	IPWhitelist       []string               `json:"ip_whitelist,omitempty"`
+	IPWhitelistMode   *bool                  `json:"ip_whitelist_mode,omitempty"`
+	IPBlacklistText   *string                `json:"ip_blacklist_text,omitempty"`
+	IPWhitelistText   *string                `json:"ip_whitelist_text,omitempty"`
+	ChinaOnly         *bool                  `json:"china_only,omitempty"`
+	BasicAuth         *basicAuthRequest      `json:"basic_auth,omitempty"`
+	RateLimit         *proxy.RateLimitConfig `json:"rate_limit,omitempty"`
+	ConnLimit         *proxy.ConnLimitConfig `json:"conn_limit,omitempty"`
+	ProxySSLVerifyOff *bool                  `json:"proxy_ssl_verify_off,omitempty"`
+	ProxyHostUpstream *bool                  `json:"proxy_host_upstream,omitempty"`
+	TLSMin13Only      *bool                  `json:"tls_min_13_only,omitempty"`
+	SecurityHeaders   *bool                  `json:"security_headers,omitempty"`
+}
+
+type basicAuthRequest struct {
+	Enabled  *bool   `json:"enabled,omitempty"`
+	Username *string `json:"username,omitempty"`
+	Password *string `json:"password,omitempty"`
+}
+
+func (req *proxyRequest) securityInput() proxy.SecurityInput {
+	if req.Security == nil {
+		return proxy.SecurityInput{}
+	}
+	s := req.Security
+	in := proxy.SecurityInput{
+		IPBlacklist:       s.IPBlacklist,
+		IPWhitelist:       s.IPWhitelist,
+		IPWhitelistMode:   s.IPWhitelistMode,
+		ChinaOnly:         s.ChinaOnly,
+		RateLimit:         s.RateLimit,
+		ConnLimit:         s.ConnLimit,
+		ProxySSLVerifyOff: s.ProxySSLVerifyOff,
+		ProxyHostUpstream: s.ProxyHostUpstream,
+		TLSMin13Only:      s.TLSMin13Only,
+		SecurityHeaders:   s.SecurityHeaders,
+	}
+	if s.IPBlacklistText != nil {
+		in.IPBlacklist = proxy.ParseIPListText(*s.IPBlacklistText)
+	}
+	if s.IPWhitelistText != nil {
+		in.IPWhitelist = proxy.ParseIPListText(*s.IPWhitelistText)
+	}
+	if s.BasicAuth != nil {
+		in.BasicAuth = &proxy.BasicAuthInput{
+			Enabled:  s.BasicAuth.Enabled,
+			Username: s.BasicAuth.Username,
+			Password: s.BasicAuth.Password,
+		}
+	}
+	return in
+}
diff --git a/internal/api/response.go b/internal/api/response.go
new file mode 100644
index 0000000..fa53adc
--- /dev/null
+++ b/internal/api/response.go
@@ -0,0 +1,64 @@
+package api
+
+import (
+	"encoding/json"
+	"log/slog"
+	"net/http"
+	"reflect"
+)
+
+type ErrorBody struct {
+	Error string `json:"error"`
+}
+
+func writeJSON(w http.ResponseWriter, status int, payload any) {
+	w.Header().Set("Content-Type", "application/json; charset=utf-8")
+	w.WriteHeader(status)
+	if payload == nil {
+		_, _ = w.Write([]byte("null"))
+		return
+	}
+	// Go nil slices/maps encode as JSON null; clients expect [] / {}.
+	v := reflect.ValueOf(payload)
+	switch v.Kind() {
+	case reflect.Slice:
+		if v.IsNil() {
+			_, _ = w.Write([]byte("[]"))
+			return
+		}
+	case reflect.Map:
+		if v.IsNil() {
+			_, _ = w.Write([]byte("{}"))
+			return
+		}
+	}
+	_ = json.NewEncoder(w).Encode(payload)
+}
+
+func writeError(r *http.Request, w http.ResponseWriter, status int, message string, cause ...error) {
+	logAPIError(r, status, message, cause)
+	writeJSON(w, status, ErrorBody{Error: message})
+}
+
+func logAPIError(r *http.Request, status int, message string, cause []error) {
+	if status < 400 {
+		return
+	}
+	attrs := []any{
+		"module", "API",
+		"status", status,
+		"message", message,
+	}
+	if r != nil {
+		attrs = append(attrs, "method", r.Method, "path", r.URL.Path)
+	}
+	if len(cause) > 0 && cause[0] != nil {
+		attrs = append(attrs, "error", cause[0].Error())
+	}
+	switch {
+	case status >= 500:
+		slog.Error("request failed", attrs...)
+	default:
+		slog.Warn("request rejected", attrs...)
+	}
+}
diff --git a/internal/api/response_test.go b/internal/api/response_test.go
new file mode 100644
index 0000000..d0827ee
--- /dev/null
+++ b/internal/api/response_test.go
@@ -0,0 +1,64 @@
+package api
+
+import (
+	"bytes"
+	"encoding/json"
+	"log/slog"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+)
+
+func TestWriteJSONNilSlice(t *testing.T) {
+	var items []string
+	rec := httptest.NewRecorder()
+	writeJSON(rec, http.StatusOK, items)
+
+	if rec.Body.String() != "[]" {
+		t.Fatalf("expected [], got %q", rec.Body.String())
+	}
+}
+
+func TestWriteJSONNilMap(t *testing.T) {
+	var values map[string]string
+	rec := httptest.NewRecorder()
+	writeJSON(rec, http.StatusOK, values)
+
+	if rec.Body.String() != "{}" {
+		t.Fatalf("expected {}, got %q", rec.Body.String())
+	}
+}
+
+func TestWriteJSONSlice(t *testing.T) {
+	rec := httptest.NewRecorder()
+	writeJSON(rec, http.StatusOK, []int{1, 2})
+
+	var out []int
+	if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
+		t.Fatal(err)
+	}
+	if len(out) != 2 {
+		t.Fatalf("expected 2 items, got %v", out)
+	}
+}
+
+func TestWriteErrorLogsClientError(t *testing.T) {
+	var buf bytes.Buffer
+	prev := slog.Default()
+	slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})))
+	defer slog.SetDefault(prev)
+
+	req := httptest.NewRequest(http.MethodPost, "/api/settings/notify/test", nil)
+	rec := httptest.NewRecorder()
+	writeError(req, rec, http.StatusBadRequest, "请填写 SMTP 主机")
+
+	if rec.Code != http.StatusBadRequest {
+		t.Fatalf("expected 400, got %d", rec.Code)
+	}
+	if !bytes.Contains(buf.Bytes(), []byte("请填写 SMTP 主机")) {
+		t.Fatalf("expected log message, got %s", buf.String())
+	}
+	if !bytes.Contains(buf.Bytes(), []byte("/api/settings/notify/test")) {
+		t.Fatalf("expected log path, got %s", buf.String())
+	}
+}
diff --git a/internal/api/router.go b/internal/api/router.go
new file mode 100644
index 0000000..a78095d
--- /dev/null
+++ b/internal/api/router.go
@@ -0,0 +1,227 @@
+package api
+
+import (
+	"io/fs"
+	"log/slog"
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/fonu/fonu/internal/auth"
+)
+
+func NewRouter(deps Deps) http.Handler {
+	logsHandler := NewLogsHandler(deps.Config)
+	r := &Router{
+		authHandler:      NewAuthHandler(deps.Auth, deps.Notify),
+		proxyHandler:     NewProxyHandler(deps.Config, deps.Proxy, logsHandler, deps.Traffic),
+		nginxHandler:     NewNginxHandler(deps.Proxy),
+		statusHandler:    NewStatusHandler(deps.Config, deps.Proxy, deps.DDNS, deps.ACME, deps.StartedAt, deps.Config.NginxPIDFile),
+		ddnsHandler:      NewDDNSHandler(deps.DDNS),
+		certHandler:      NewCertHandler(deps.ACME),
+		settingsHandler:  NewSettingsHandler(deps.Settings, deps.Proxy, deps.Notify),
+		chinaCIDRHandler: NewChinaCIDRHandler(deps.ChinaCIDR),
+		logsHandler:      logsHandler,
+		backupHandler:    NewBackupHandler(deps.Backup),
+		discoveryHandler: NewDiscoveryHandler(deps.Discovery),
+		frpmultiHandler:  NewFRPMultiHandler(deps.FRPMulti),
+		authSvc:          deps.Auth,
+		staticFS:         deps.StaticFS,
+	}
+
+	mux := http.NewServeMux()
+
+	mux.HandleFunc("GET /api/version", Version(deps.Config))
+	mux.HandleFunc("GET /api/auth/status", r.authHandler.Status)
+	mux.HandleFunc("POST /api/auth/login", r.authHandler.Login)
+	mux.HandleFunc("POST /api/auth/logout", r.authHandler.Logout)
+
+	protect := func(pattern string, handler http.HandlerFunc) {
+		mux.Handle(pattern, SessionMiddleware(deps.Auth)(RequireAuth(deps.Auth)(http.HandlerFunc(handler))))
+	}
+
+	protect("POST /api/auth/password", r.authHandler.ChangePassword)
+	protect("GET /api/status", r.statusHandler.Get)
+	protect("GET /api/proxies", r.proxyHandler.List)
+	protect("GET /api/proxies/traffic", r.proxyHandler.Traffic)
+	protect("POST /api/proxies", r.proxyHandler.Create)
+	protect("PUT /api/proxies/reorder", r.proxyHandler.Reorder)
+	protect("PUT /api/proxies/{id}", r.proxyHandler.Update)
+	protect("DELETE /api/proxies/{id}", r.proxyHandler.Delete)
+	protect("GET /api/proxies/{id}/logs/stream", r.proxyHandler.StreamLogs)
+	protect("GET /api/proxies/{id}/clients", r.proxyHandler.Clients)
+	protect("GET /api/proxies/{id}/nginx", r.nginxHandler.GetRule)
+	protect("PUT /api/proxies/{id}/nginx", r.nginxHandler.PutRule)
+	protect("POST /api/proxies/{id}/nginx/rollback", r.nginxHandler.RollbackRule)
+	protect("GET /api/settings/nginx/global", r.nginxHandler.GetGlobal)
+	protect("PUT /api/settings/nginx/global", r.nginxHandler.PutGlobal)
+	protect("POST /api/settings/nginx/global/rollback", r.nginxHandler.RollbackGlobal)
+	protect("GET /api/ddns", r.ddnsHandler.List)
+	protect("POST /api/ddns", r.ddnsHandler.Create)
+	protect("PUT /api/ddns/{id}", r.ddnsHandler.Update)
+	protect("DELETE /api/ddns/{id}", r.ddnsHandler.Delete)
+	protect("POST /api/ddns/test", r.ddnsHandler.Test)
+	protect("POST /api/ddns/update", r.ddnsHandler.UpdateAll)
+	protect("POST /api/ddns/{id}/update", r.ddnsHandler.UpdateOne)
+	protect("GET /api/certificates", r.certHandler.List)
+	protect("GET /api/certificates/ca-options", r.certHandler.Options)
+	protect("POST /api/certificates/apply", r.certHandler.Apply)
+	protect("GET /api/certificates/jobs/{id}/stream", r.certHandler.ApplyJobStream)
+	protect("POST /api/certificates/import", r.certHandler.Import)
+	protect("POST /api/certificates/renew", r.certHandler.Renew)
+	protect("GET /api/certificates/{domain}/download", r.certHandler.Download)
+	protect("DELETE /api/certificates/{domain}", r.certHandler.Delete)
+	notifyHandler := NewNotifyHandler(deps.Notify)
+	protect("GET /api/settings", r.settingsHandler.Get)
+	protect("PUT /api/settings", r.settingsHandler.Put)
+	protect("POST /api/settings/notify/test", notifyHandler.Test)
+	if deps.ChinaCIDR != nil {
+		protect("GET /api/settings/china-cidr", r.chinaCIDRHandler.Status)
+		protect("POST /api/settings/china-cidr/refresh", r.chinaCIDRHandler.Refresh)
+	}
+	protect("GET /api/logs/access", r.logsHandler.Access)
+	protect("GET /api/logs/error", r.logsHandler.Error)
+	protect("GET /api/logs/system", r.logsHandler.System)
+	protect("GET /api/logs/stream", r.logsHandler.Stream)
+	protect("GET /api/backup/export", r.backupHandler.Export)
+	protect("POST /api/backup/restore", r.backupHandler.Restore)
+	protect("GET /api/discovery/scan", r.discoveryHandler.Scan)
+	if deps.FRPMulti != nil {
+		frpHandler := r.frpmultiHandler
+		protect("GET /api/frpmulti/status", frpHandler.Status)
+		protect("GET /api/frpmulti/runtime", frpHandler.Runtime)
+		protect("PUT /api/frpmulti/runtime/download-proxy", frpHandler.SetDownloadProxy)
+		protect("GET /api/frpmulti/releases", frpHandler.Releases)
+		protect("GET /api/frpmulti/binaries", frpHandler.Binaries)
+		protect("GET /api/frpmulti/download", frpHandler.DownloadStatus)
+		protect("POST /api/frpmulti/releases/{version}/download", frpHandler.DownloadRelease)
+		protect("POST /api/frpmulti/binaries/{version}/activate", frpHandler.ActivateBinary)
+		protect("DELETE /api/frpmulti/binaries/{version}", frpHandler.DeleteBinary)
+		protect("GET /api/frpmulti/servers", frpHandler.ListServers)
+		protect("GET /api/frpmulti/servers/{id}/detail", frpHandler.ServerDetail)
+		protect("GET /api/frpmulti/servers/{id}/diagnostics", frpHandler.ServerDiagnostics)
+		protect("GET /api/frpmulti/servers/{id}/frps-config", frpHandler.FRPSServerConfig)
+		protect("POST /api/frpmulti/servers/{id}/start", frpHandler.StartServer)
+		protect("POST /api/frpmulti/servers/{id}/stop", frpHandler.StopServer)
+		protect("POST /api/frpmulti/servers", frpHandler.CreateServer)
+		protect("PUT /api/frpmulti/servers/{id}", frpHandler.UpdateServer)
+		protect("DELETE /api/frpmulti/servers/{id}", frpHandler.DeleteServer)
+		protect("POST /api/frpmulti/servers/{id}/test", frpHandler.TestServer)
+		protect("POST /api/frpmulti/servers/{id}/agent/probe", frpHandler.AgentProbe)
+		protect("POST /api/frpmulti/servers/{id}/agent/install", frpHandler.AgentInstall)
+		protect("POST /api/frpmulti/servers/{id}/agent/uninstall", frpHandler.AgentUninstall)
+		protect("POST /api/frpmulti/servers/{id}/agent/ssh-diagnose", frpHandler.AgentSSHDiagnose)
+		protect("POST /api/frpmulti/servers/{id}/agent/test", frpHandler.AgentTest)
+		protect("GET /api/frpmulti/servers/{id}/agent/status", frpHandler.AgentStatus)
+		protect("GET /api/frpmulti/servers/{id}/agent/routes", frpHandler.AgentRoutes)
+		protect("GET /api/frpmulti/servers/{id}/agent/routes/health", frpHandler.AgentRouteHealth)
+		protect("POST /api/frpmulti/servers/{id}/agent/nginx/install", frpHandler.AgentInstallNginx)
+		protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/conf", frpHandler.AgentRouteConf)
+		protect("GET /api/frpmulti/servers/{id}/agent/route-candidates", frpHandler.AgentRouteCandidates)
+		protect("POST /api/frpmulti/servers/{id}/agent/routes/{proxyID}", frpHandler.DeployAgentRoute)
+		protect("DELETE /api/frpmulti/servers/{id}/agent/routes/{proxyID}", frpHandler.RemoveAgentRoute)
+		protect("POST /api/frpmulti/servers/{id}/agent/frps-install", frpHandler.InstallFRPSOnAgent)
+		protect("POST /api/frpmulti/servers/{id}/agent/frps-action", frpHandler.AgentFRPSAction)
+		protect("POST /api/frpmulti/servers/{id}/agent/frps-config", frpHandler.PushFRPSConfigToAgent)
+		protect("GET /api/frpmulti/proxies", frpHandler.ListProxies)
+		protect("GET /api/frpmulti/proxies/traffic", frpHandler.ListProxiesTraffic)
+		protect("POST /api/frpmulti/proxies", frpHandler.CreateProxy)
+		protect("PUT /api/frpmulti/proxies/{id}", frpHandler.UpdateProxy)
+		protect("DELETE /api/frpmulti/proxies/{id}", frpHandler.DeleteProxy)
+		protect("GET /api/frpmulti/proxies/{id}/logs", frpHandler.ProxyLogs)
+		protect("POST /api/frpmulti/start", frpHandler.Start)
+		protect("POST /api/frpmulti/stop", frpHandler.Stop)
+		protect("POST /api/frpmulti/reload", frpHandler.Reload)
+		protect("GET /api/frpmulti/logs", frpHandler.Logs)
+		protect("GET /api/frpmulti/config/export", frpHandler.ExportConfig)
+	}
+
+	if deps.StaticFS != nil {
+		mux.Handle("/", r.spaHandler())
+	}
+
+	logger := deps.Logger
+	if logger == nil {
+		logger = slog.Default()
+	}
+	return loggingMiddleware(logger, SessionMiddleware(deps.Auth)(mux))
+}
+
+type Router struct {
+	authHandler      *AuthHandler
+	proxyHandler     *ProxyHandler
+	nginxHandler     *NginxHandler
+	statusHandler    *StatusHandler
+	ddnsHandler      *DDNSHandler
+	certHandler      *CertHandler
+	settingsHandler  *SettingsHandler
+	chinaCIDRHandler *ChinaCIDRHandler
+	logsHandler      *LogsHandler
+	backupHandler    *BackupHandler
+	discoveryHandler *DiscoveryHandler
+	frpmultiHandler  *FRPMultiHandler
+	authSvc          *auth.Service
+	staticFS         fs.FS
+}
+
+func (r *Router) spaHandler() http.Handler {
+	fileServer := http.FileServer(http.FS(r.staticFS))
+	return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+		if strings.HasPrefix(req.URL.Path, "/api/") {
+			http.NotFound(w, req)
+			return
+		}
+
+		path := strings.TrimPrefix(req.URL.Path, "/")
+		if path == "" {
+			path = "index.html"
+		}
+		if _, err := fs.Stat(r.staticFS, path); err != nil {
+			// SPA fallback: unknown routes serve index.html
+			req.URL.Path = "/index.html"
+		}
+		fileServer.ServeHTTP(w, req)
+	})
+}
+
+type statusRecorder struct {
+	http.ResponseWriter
+	status int
+}
+
+func (w *statusRecorder) WriteHeader(code int) {
+	w.status = code
+	w.ResponseWriter.WriteHeader(code)
+}
+
+func (w *statusRecorder) Flush() {
+	if f, ok := w.ResponseWriter.(http.Flusher); ok {
+		f.Flush()
+	}
+}
+
+func loggingMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		start := time.Now()
+		rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
+		next.ServeHTTP(rec, r)
+		if strings.HasPrefix(r.URL.Path, "/api/") &&
+			!strings.Contains(r.URL.Path, "/logs/stream") &&
+			!strings.Contains(r.URL.Path, "/certificates/jobs/") &&
+			rec.status >= 400 {
+			level := slog.LevelWarn
+			msg := "api request completed with client error"
+			if rec.status >= 500 {
+				level = slog.LevelError
+				msg = "api request failed"
+			}
+			logger.Log(r.Context(), level, msg,
+				"module", "HTTP",
+				"method", r.Method,
+				"path", r.URL.Path,
+				"status", rec.status,
+				"duration_ms", time.Since(start).Milliseconds(),
+			)
+		}
+	})
+}
diff --git a/internal/api/settings_handler.go b/internal/api/settings_handler.go
new file mode 100644
index 0000000..bca490b
--- /dev/null
+++ b/internal/api/settings_handler.go
@@ -0,0 +1,204 @@
+package api
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strconv"
+
+	"github.com/fonu/fonu/internal/notify"
+	"github.com/fonu/fonu/internal/service"
+	"github.com/fonu/fonu/internal/settings"
+	"github.com/fonu/fonu/internal/validate"
+)
+
+type SettingsHandler struct {
+	store  *settings.Store
+	proxy  *service.ProxyService
+	notify *notify.Service
+}
+
+func NewSettingsHandler(store *settings.Store, proxySvc *service.ProxyService, notifySvc *notify.Service) *SettingsHandler {
+	return &SettingsHandler{store: store, proxy: proxySvc, notify: notifySvc}
+}
+
+func (h *SettingsHandler) Get(w http.ResponseWriter, r *http.Request) {
+	values, err := h.store.List(r.Context())
+	if err != nil {
+		writeError(r, w, http.StatusInternalServerError, "读取设置失败", err)
+		return
+	}
+	notify.SanitizeSettings(values)
+	writeJSON(w, http.StatusOK, values)
+}
+
+func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) {
+	var req map[string]string
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(r, w, http.StatusBadRequest, "请求格式无效")
+		return
+	}
+	allowed := map[string]bool{
+		settings.KeyTheme:                 true,
+		settings.KeyTimezone:              true,
+		settings.KeyLogRetentionDays:      true,
+		settings.KeyDDNSCheckInterval:     true,
+		settings.KeyCertRenewThreshold:    true,
+		settings.KeyRootDomain:            true,
+		settings.KeyACMEEmail:             true,
+		settings.KeyACMECA:                true,
+		settings.KeyZeroSSLAPIKey:         true,
+		settings.KeyNotifyWebhookURL:      true,
+		settings.KeyNotifyType:            true,
+		settings.KeyNotifyEmailJSON:       true,
+		settings.KeyNotifySMTPPassword:    true,
+		settings.KeyNotifyWebhookJSON:     true,
+		settings.KeyNotifyWebhookSecret:   true,
+		settings.KeyNotifyTelegramJSON:    true,
+		settings.KeyNotifyTelegramToken:   true,
+		settings.KeyNotifyOnDDNSIPChange:      true,
+		settings.KeyNotifyOnDDNSFailure:       true,
+		settings.KeyNotifyOnCertExpiry:          true,
+		settings.KeyNotifyOnCertRenewSuccess:    true,
+		settings.KeyNotifyOnIPFrequentAccess:    true,
+		settings.KeyNotifyOnCertRenewFailure:    true,
+		settings.KeyNotifyOnLoginFailure:        true,
+		settings.KeyNotifyOnNginxReloadFailure:  true,
+		settings.KeyNotifyIPFrequentThreshold:   true,
+		settings.KeyNotifyIPFrequentWindowSec:   true,
+		settings.KeyNotifyLoginFailureThreshold: true,
+		settings.KeyNotifyLoginFailureWindowSec: true,
+		settings.KeyTrustedProxy:          true,
+		settings.KeyGlobalIPBlacklist:     true,
+		settings.KeyGlobalIPWhitelist:     true,
+		settings.KeyChinaCIDRUpdateHours:  true,
+		settings.KeyChinaCIDRSourceV4:     true,
+		settings.KeyChinaCIDRSourceV6:     true,
+	}
+	filtered := map[string]string{}
+	for key, value := range req {
+		if !allowed[key] {
+			continue
+		}
+		if err := h.validateSetting(key, value); err != nil {
+			writeError(r, w, http.StatusBadRequest, err.Error())
+			return
+		}
+		filtered[key] = value
+	}
+	if h.notify != nil && h.notify.Store() != nil {
+		if in, ok := notify.ParseSaveInputFromMap(filtered); ok {
+			if err := h.notify.Store().Save(r.Context(), in); err != nil {
+				writeError(r, w, http.StatusBadRequest, err.Error())
+				return
+			}
+			for key := range filtered {
+				if isNotifySettingKey(key) {
+					delete(filtered, key)
+				}
+			}
+		}
+	}
+	if err := h.store.SetMany(r.Context(), filtered); err != nil {
+		writeError(r, w, http.StatusInternalServerError, "保存设置失败", err)
+		return
+	}
+	if h.proxy != nil && (filtered[settings.KeyTrustedProxy] != "" || filtered[settings.KeyGlobalIPBlacklist] != "" || filtered[settings.KeyGlobalIPWhitelist] != "") {
+		if err := h.proxy.ReloadAll(r.Context()); err != nil {
+			writeError(r, w, http.StatusInternalServerError, "设置已保存,但 Nginx 重载失败:"+err.Error(), err)
+			return
+		}
+	}
+	values, err := h.store.List(r.Context())
+	if err != nil {
+		writeError(r, w, http.StatusInternalServerError, "读取设置失败", err)
+		return
+	}
+	notify.SanitizeSettings(values)
+	writeJSON(w, http.StatusOK, values)
+}
+
+func isNotifySettingKey(key string) bool {
+	switch key {
+	case settings.KeyNotifyType,
+		settings.KeyNotifyEmailJSON,
+		settings.KeyNotifySMTPPassword,
+		settings.KeyNotifyWebhookJSON,
+		settings.KeyNotifyWebhookSecret,
+		settings.KeyNotifyTelegramJSON,
+		settings.KeyNotifyTelegramToken,
+		settings.KeyNotifyOnDDNSIPChange,
+		settings.KeyNotifyOnDDNSFailure,
+		settings.KeyNotifyOnCertExpiry,
+		settings.KeyNotifyOnCertRenewSuccess,
+		settings.KeyNotifyOnIPFrequentAccess,
+		settings.KeyNotifyOnCertRenewFailure,
+		settings.KeyNotifyOnLoginFailure,
+		settings.KeyNotifyOnNginxReloadFailure,
+		settings.KeyNotifyIPFrequentThreshold,
+		settings.KeyNotifyIPFrequentWindowSec,
+		settings.KeyNotifyLoginFailureThreshold,
+		settings.KeyNotifyLoginFailureWindowSec,
+		settings.KeyNotifyWebhookURL:
+		return true
+	default:
+		return false
+	}
+}
+
+func (h *SettingsHandler) validateSetting(key, value string) error {
+	switch key {
+	case settings.KeyNotifyEmailJSON, settings.KeyNotifyWebhookJSON, settings.KeyNotifyTelegramJSON:
+		if value != "" {
+			var raw map[string]any
+			if err := json.Unmarshal([]byte(value), &raw); err != nil {
+				return err
+			}
+		}
+	case settings.KeyNotifyType:
+		switch value {
+		case "", "email", "webhook", "telegram":
+		default:
+			return fmt.Errorf("无效的通知类型")
+		}
+	case settings.KeyNotifyIPFrequentThreshold:
+		if value != "" {
+			n, err := strconv.Atoi(value)
+			if err != nil || n < 10 || n > 10000 {
+				return fmt.Errorf("频繁访问阈值需在 10–10000 之间")
+			}
+		}
+	case settings.KeyNotifyIPFrequentWindowSec, settings.KeyNotifyLoginFailureWindowSec:
+		if value != "" {
+			n, err := strconv.Atoi(value)
+			if err != nil || n < 10 || n > 3600 {
+				return fmt.Errorf("统计窗口需在 10–3600 秒之间")
+			}
+		}
+	case settings.KeyNotifyLoginFailureThreshold:
+		if value != "" {
+			n, err := strconv.Atoi(value)
+			if err != nil || n < 3 || n > 100 {
+				return fmt.Errorf("登录失败阈值需在 3–100 之间")
+			}
+		}
+	case settings.KeyGlobalIPBlacklist, settings.KeyGlobalIPWhitelist:
+		var list []string
+		if value != "" && value != "[]" {
+			if err := json.Unmarshal([]byte(value), &list); err != nil {
+				return err
+			}
+			if err := validate.IPOrCIDRList(list); err != nil {
+				return err
+			}
+		}
+	case settings.KeyTrustedProxy:
+		if value != "" && value != "{}" {
+			var raw map[string]any
+			if err := json.Unmarshal([]byte(value), &raw); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
diff --git a/internal/api/status_handler.go b/internal/api/status_handler.go
new file mode 100644
index 0000000..bdf5fd8
--- /dev/null
+++ b/internal/api/status_handler.go
@@ -0,0 +1,183 @@
+package api
+
+import (
+	"context"
+	"fmt"
+	"math"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"github.com/fonu/fonu/internal/acme"
+	certstore "github.com/fonu/fonu/internal/certificate"
+	"github.com/fonu/fonu/internal/config"
+	"github.com/fonu/fonu/internal/ddns"
+	"github.com/fonu/fonu/internal/logstore"
+	"github.com/fonu/fonu/internal/publicip"
+	"github.com/fonu/fonu/internal/service"
+)
+
+type StatusHandler struct {
+	cfg          config.Config
+	proxySvc     *service.ProxyService
+	ddnsSvc      *ddns.Service
+	acmeSvc      *acme.Service
+	startedAt    string
+	nginxPIDFile string
+}
+
+func NewStatusHandler(cfg config.Config, proxySvc *service.ProxyService, ddnsSvc *ddns.Service, acmeSvc *acme.Service, startedAt, nginxPIDFile string) *StatusHandler {
+	return &StatusHandler{
+		cfg:          cfg,
+		proxySvc:     proxySvc,
+		ddnsSvc:      ddnsSvc,
+		acmeSvc:      acmeSvc,
+		startedAt:    startedAt,
+		nginxPIDFile: nginxPIDFile,
+	}
+}
+
+type statusResponse struct {
+	PublicIPv4        string  `json:"public_ipv4"`
+	PublicIPv6        string  `json:"public_ipv6"`
+	PublicIPSource    string  `json:"public_ip_source"`
+	DDNSStatus        string  `json:"ddns_status"`
+	DDNSCount         int     `json:"ddns_count"`
+	DDNSLastUpdated   string  `json:"ddns_last_updated,omitempty"`
+	CertificateStatus  string `json:"certificate_status"`
+	CertificateDays    int    `json:"certificate_days"`
+	CertificateCount   int    `json:"certificate_count"`
+	CertificateSummary string `json:"certificate_summary,omitempty"`
+	ProxyCount        int     `json:"proxy_count"`
+	NginxStatus       string  `json:"nginx_status"`
+	RequestToday         int      `json:"request_today"`
+	RequestYesterday     int      `json:"request_yesterday"`
+	RequestTrend         *int     `json:"request_trend,omitempty"`
+	RequestsHourly       []int    `json:"requests_hourly"`
+	RequestsHourlyLabels []string `json:"requests_hourly_labels"`
+	ErrorToday           int      `json:"error_today"`
+	AvgResponseMs     float64 `json:"avg_response_ms"`
+	StartedAt         string  `json:"started_at"`
+	UptimeSeconds     int64   `json:"uptime_seconds"`
+}
+
+func (h *StatusHandler) Get(w http.ResponseWriter, r *http.Request) {
+	rules, err := h.proxySvc.List(r.Context())
+	if err != nil {
+		writeError(r, w, http.StatusInternalServerError, "读取状态失败")
+		return
+	}
+
+	nginxStatus := "unknown"
+	if _, err := os.Stat(h.nginxPIDFile); err == nil {
+		nginxStatus = "running"
+	} else if os.IsNotExist(err) {
+		nginxStatus = "stopped"
+	}
+
+	ddnsStatus, ddnsLastUpdated, ddnsCount := h.ddnsSvc.Summary(r.Context())
+	ipv4, ipv6, ipSource := h.resolvePublicIPs(r.Context())
+
+	records, _ := h.acmeSvc.List(r.Context())
+	certStatus, certDays, certCount, certSummary := summarizeCertificates(records)
+
+	accessLog := filepath.Join(h.cfg.LogsDir(), "access.log")
+	total, errors, avgMs := logstore.CountTodayAccess(accessLog)
+	yesterday, _, _ := logstore.CountAccessForDay(accessLog, time.Now().AddDate(0, 0, -1))
+	hourly, hourlyLabels := logstore.HourlyAccessCounts(accessLog)
+
+	started, _ := time.Parse(time.RFC3339, h.startedAt)
+	uptime := time.Since(started).Seconds()
+
+	writeJSON(w, http.StatusOK, statusResponse{
+		PublicIPv4:        ipv4,
+		PublicIPv6:        ipv6,
+		PublicIPSource:    ipSource,
+		DDNSStatus:        ddnsStatus,
+		DDNSCount:         ddnsCount,
+		DDNSLastUpdated:   ddnsLastUpdated,
+		CertificateStatus:  certStatus,
+		CertificateDays:    certDays,
+		CertificateCount:   certCount,
+		CertificateSummary: certSummary,
+		ProxyCount:        len(rules),
+		NginxStatus:       nginxStatus,
+		RequestToday:         total,
+		RequestYesterday:     yesterday,
+		RequestTrend:         requestTrendPercent(total, yesterday),
+		RequestsHourly:       hourly,
+		RequestsHourlyLabels: hourlyLabels,
+		ErrorToday:           errors,
+		AvgResponseMs:     avgMs,
+		StartedAt:         h.startedAt,
+		UptimeSeconds: int64(uptime),
+	})
+}
+
+func (h *StatusHandler) resolvePublicIPs(ctx context.Context) (ipv4, ipv6, source string) {
+	if ipv4, ipv6, ok := h.ddnsSvc.PublicIPs(ctx); ok {
+		return ipv4, ipv6, "ddns"
+	}
+	if ipv4, ipv6, ok := h.ddnsSvc.LastKnownPublicIPs(ctx); ok {
+		return ipv4, ipv6, "ddns"
+	}
+	detectCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
+	defer cancel()
+	ipv4, ipv6, err := publicip.Detect(detectCtx)
+	if err != nil {
+		return "", ipv6, "none"
+	}
+	return ipv4, ipv6, "detect"
+}
+
+func summarizeCertificates(records []certstore.Record) (status string, days int, count int, summary string) {
+	if len(records) == 0 {
+		return "none", 0, 0, ""
+	}
+	count = len(records)
+	status = "ok"
+	days = records[0].DaysLeft
+	for _, rec := range records {
+		if rec.DaysLeft < days {
+			days = rec.DaysLeft
+		}
+		switch {
+		case rec.Status == "error" || rec.DaysLeft <= 0:
+			status = "error"
+		case rec.DaysLeft <= 30 && status != "error":
+			status = "warning"
+		}
+	}
+	if count == 1 {
+		summary = certificateSummaryName(records[0])
+	} else {
+		summary = fmt.Sprintf("%d 张证书", count)
+	}
+	return status, days, count, summary
+}
+
+func requestTrendPercent(today, yesterday int) *int {
+	if yesterday <= 0 {
+		if today <= 0 {
+			return nil
+		}
+		v := 100
+		return &v
+	}
+	v := int(math.Round(float64(today-yesterday) / float64(yesterday) * 100))
+	return &v
+}
+
+func certificateSummaryName(rec certstore.Record) string {
+	for _, domain := range rec.Domains {
+		if strings.HasPrefix(domain, "*.") {
+			return domain
+		}
+	}
+	if rec.Wildcard {
+		return "*." + rec.Domain
+	}
+	return rec.Domain
+}
diff --git a/internal/api/status_handler_test.go b/internal/api/status_handler_test.go
new file mode 100644
index 0000000..de1f6b5
--- /dev/null
+++ b/internal/api/status_handler_test.go
@@ -0,0 +1,18 @@
+package api
+
+import "testing"
+
+func TestRequestTrendPercent(t *testing.T) {
+	if got := requestTrendPercent(0, 0); got != nil {
+		t.Fatalf("expected nil trend, got %v", *got)
+	}
+	if got := requestTrendPercent(10, 0); got == nil || *got != 100 {
+		t.Fatalf("expected 100 for new traffic, got %v", got)
+	}
+	if got := requestTrendPercent(150, 100); got == nil || *got != 50 {
+		t.Fatalf("expected 50, got %v", got)
+	}
+	if got := requestTrendPercent(50, 100); got == nil || *got != -50 {
+		t.Fatalf("expected -50, got %v", got)
+	}
+}
diff --git a/internal/api/version_handler.go b/internal/api/version_handler.go
new file mode 100644
index 0000000..fb4cbd8
--- /dev/null
+++ b/internal/api/version_handler.go
@@ -0,0 +1,24 @@
+package api
+
+import (
+	"net/http"
+
+	"github.com/fonu/fonu/internal/config"
+	"github.com/fonu/fonu/internal/version"
+)
+
+type versionResponse struct {
+	Version           string `json:"version"`
+	NginxHTTPPort     int    `json:"nginx_http_port"`
+	NginxHTTPSPort    int    `json:"nginx_https_port"`
+}
+
+func Version(cfg config.Config) http.HandlerFunc {
+	return func(w http.ResponseWriter, _ *http.Request) {
+		writeJSON(w, http.StatusOK, versionResponse{
+			Version:        version.Version,
+			NginxHTTPPort:  cfg.NginxDefaultHTTPPort,
+			NginxHTTPSPort: cfg.NginxDefaultHTTPSPort,
+		})
+	}
+}
diff --git a/internal/api/version_handler_test.go b/internal/api/version_handler_test.go
new file mode 100644
index 0000000..cc022f6
--- /dev/null
+++ b/internal/api/version_handler_test.go
@@ -0,0 +1,36 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/fonu/fonu/internal/config"
+	"github.com/fonu/fonu/internal/version"
+)
+
+func TestVersionEndpoint(t *testing.T) {
+	version.Version = "0.1.6"
+	cfg := config.Config{NginxDefaultHTTPPort: 18080, NginxDefaultHTTPSPort: 9443}
+	req := httptest.NewRequest(http.MethodGet, "/api/version", nil)
+	rec := httptest.NewRecorder()
+	Version(cfg)(rec, req)
+
+	if rec.Code != http.StatusOK {
+		t.Fatalf("status %d", rec.Code)
+	}
+	var body map[string]interface{}
+	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+		t.Fatalf("decode: %v", err)
+	}
+	if body["version"] != "0.1.6" {
+		t.Fatalf("version %q", body["version"])
+	}
+	if body["nginx_http_port"] != float64(18080) {
+		t.Fatalf("nginx_http_port %v", body["nginx_http_port"])
+	}
+	if body["nginx_https_port"] != float64(9443) {
+		t.Fatalf("nginx_https_port %v", body["nginx_https_port"])
+	}
+}
diff --git a/internal/app/app.go b/internal/app/app.go
new file mode 100644
index 0000000..8dd20a3
--- /dev/null
+++ b/internal/app/app.go
@@ -0,0 +1,229 @@
+package app
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"io/fs"
+	"log/slog"
+	"net/http"
+	"os"
+	"path/filepath"
+	"time"
+
+	"github.com/fonu/fonu/internal/acme"
+	"github.com/fonu/fonu/internal/api"
+	"github.com/fonu/fonu/internal/auth"
+	"github.com/fonu/fonu/internal/backup"
+	"github.com/fonu/fonu/internal/certificate"
+	"github.com/fonu/fonu/internal/chinacidr"
+	"github.com/fonu/fonu/internal/config"
+	"github.com/fonu/fonu/internal/db"
+	"github.com/fonu/fonu/internal/ddns"
+	"github.com/fonu/fonu/internal/discovery"
+	"github.com/fonu/fonu/internal/frp"
+	"github.com/fonu/fonu/internal/logstore"
+	"github.com/fonu/fonu/internal/nginx"
+	"github.com/fonu/fonu/internal/notify"
+	"github.com/fonu/fonu/internal/proxy"
+	"github.com/fonu/fonu/internal/scheduler"
+	"github.com/fonu/fonu/internal/secret"
+	"github.com/fonu/fonu/internal/service"
+	"github.com/fonu/fonu/internal/settings"
+	"github.com/fonu/fonu/internal/traffic"
+)
+
+type App struct {
+	cfg        config.Config
+	logger     *slog.Logger
+	server     *http.Server
+	nginx      *nginx.Manager
+	frpMulti   *frp.Service
+	scheduler  *scheduler.Scheduler
+	shutdownFn context.CancelFunc
+	db         interface{ Close() error }
+}
+
+func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error) {
+	appLogWriter := logstore.NewAppLogWriter(cfg.LogsDir())
+	logger := slog.New(slog.NewJSONHandler(io.MultiWriter(os.Stdout, appLogWriter), &slog.HandlerOptions{Level: slog.LevelInfo}))
+	slog.SetDefault(logger)
+
+	for _, dir := range []string{cfg.DataDir, cfg.NginxDir(), cfg.LogsDir(), cfg.CertsDir(), cfg.FrpDir()} {
+		if err := os.MkdirAll(dir, 0o755); err != nil {
+			return nil, err
+		}
+	}
+
+	conn, err := db.Open(cfg.DBPath())
+	if err != nil {
+		return nil, err
+	}
+	if err := db.Migrate(context.Background(), conn, migrationsDir); err != nil {
+		conn.Close()
+		return nil, err
+	}
+
+	secretBox, err := secret.NewBox(cfg.SessionSecret)
+	if err != nil {
+		conn.Close()
+		return nil, err
+	}
+
+	authSvc := auth.New(conn)
+	if err := authSvc.EnsureDefaultAdmin(context.Background(), logger); err != nil {
+		conn.Close()
+		return nil, fmt.Errorf("bootstrap admin: %w", err)
+	}
+	proxyStore := proxy.NewStore(conn)
+	settingsStore := settings.NewStore(conn)
+	ddnsStore := ddns.NewStore(conn)
+	certStore := certificate.NewStore(conn)
+	nginxMgr := nginx.NewManager(cfg, logger.With("module", "NGINX"))
+	if err := nginxMgr.EnsureDirs(); err != nil {
+		conn.Close()
+		return nil, err
+	}
+
+	proxySvc := service.NewProxyService(cfg, conn, proxyStore, certStore, settingsStore, nginxMgr)
+	chinaCIDRSvc := chinacidr.New(cfg, settingsStore, nginxMgr, proxyStore, logger.With("module", "CHINA_CIDR"))
+	notifySvc := notify.New(settingsStore, secretBox, logger)
+	proxySvc.SetNotify(notifySvc)
+	ddnsSvc := ddns.NewService(ddnsStore, settingsStore, secretBox, logger, notifySvc)
+	acmeSvc := acme.NewService(cfg, certStore, ddnsSvc, settingsStore, proxySvc, logger, notifySvc)
+	backupSvc := backup.New(cfg.DataDir)
+	discoverySvc := discovery.New()
+	// 新增:多服务端 FRP 模块(接管原网关模式)
+	frpMultiSvc := frp.NewService(cfg, frp.NewStore(conn), secretBox, logger, certStore)
+	if err := frpMultiSvc.EnsureDirs(); err != nil {
+		conn.Close()
+		return nil, err
+	}
+	startedAt := time.Now().UTC().Format(time.RFC3339)
+	trafficCollector := traffic.NewCollector(conn, filepath.Join(cfg.LogsDir(), "access.log"), notifySvc)
+
+	handler := api.NewRouter(api.Deps{
+		Config:    cfg,
+		Logger:    logger,
+		Auth:      authSvc,
+		Proxy:     proxySvc,
+		DDNS:      ddnsSvc,
+		ACME:      acmeSvc,
+		Settings:  settingsStore,
+		Notify:    notifySvc,
+		ChinaCIDR: chinaCIDRSvc,
+		Backup:    backupSvc,
+		Discovery: discoverySvc,
+		FRPMulti:  frpMultiSvc,
+		Traffic:   trafficCollector,
+		StaticFS:  staticFS,
+		StartedAt: startedAt,
+	})
+	server := &http.Server{
+		Addr:              cfg.ListenAddr,
+		Handler:           handler,
+		ReadHeaderTimeout: 10 * time.Second,
+	}
+
+	ctx, cancel := context.WithCancel(context.Background())
+	intervalMinutes, _ := settingsStore.GetInt(ctx, settings.KeyDDNSCheckInterval)
+	if intervalMinutes <= 0 {
+		intervalMinutes = 5
+	}
+	chinaHours, _ := settingsStore.GetInt(ctx, settings.KeyChinaCIDRUpdateHours)
+	if chinaHours <= 0 {
+		chinaHours = 24
+	}
+	sched := scheduler.New(
+		scheduler.Job{
+			Name:     "ddns",
+			Interval: time.Duration(intervalMinutes) * time.Minute,
+			Run:      ddnsSvc.Tick,
+		},
+		scheduler.Job{
+			Name:     "acme",
+			Interval: 24 * time.Hour,
+			Run:      acmeSvc.Tick,
+		},
+		scheduler.Job{
+			Name:     "china_cidr",
+			Interval: time.Duration(chinaHours) * time.Hour,
+			Run:      chinaCIDRSvc.Tick,
+		},
+	)
+	sched.Start(ctx)
+	trafficCollector.Start(ctx)
+
+	app := &App{
+		cfg:        cfg,
+		logger:     logger,
+		server:     server,
+		nginx:      nginxMgr,
+		frpMulti:   frpMultiSvc,
+		scheduler:  sched,
+		shutdownFn: cancel,
+		db:         conn,
+	}
+
+	if err := app.bootstrapNginx(context.Background(), proxySvc); err != nil {
+		cancel()
+		conn.Close()
+		return nil, err
+	}
+	if err := frpMultiSvc.ReconcileRuntime(context.Background()); err != nil {
+		logger.Warn("initial frp runtime reconcile skipped", "module", "FRP", "error", err.Error())
+	}
+	if err := frpMultiSvc.AutoStart(context.Background()); err != nil {
+		logger.Warn("initial frp autostart skipped", "module", "FRP", "error", err.Error())
+	}
+
+	logger.Info("application started", "module", "SYSTEM", "listen", cfg.ListenAddr)
+	return app, nil
+}
+
+func (a *App) bootstrapNginx(ctx context.Context, proxySvc *service.ProxyService) error {
+	if err := proxySvc.ReloadAll(ctx); err != nil {
+		a.logger.Warn("initial nginx apply skipped", "module", "NGINX", "error", err.Error())
+	}
+	return nil
+}
+
+func (a *App) Run() error {
+	return a.server.ListenAndServe()
+}
+
+func (a *App) Shutdown(ctx context.Context) error {
+	a.logger.Info("application shutting down", "module", "SYSTEM")
+	if a.shutdownFn != nil {
+		a.shutdownFn()
+	}
+	if err := a.server.Shutdown(ctx); err != nil {
+		return err
+	}
+	if err := a.nginx.Stop(ctx); err != nil {
+		a.logger.Error("nginx stop failed", "module", "NGINX", "error", err.Error())
+	}
+	if a.frpMulti != nil {
+		if err := a.frpMulti.Stop(ctx); err != nil {
+			a.logger.Error("frpc stop failed", "module", "FRP", "error", err.Error())
+		}
+	}
+	if a.db != nil {
+		return a.db.Close()
+	}
+	return nil
+}
+
+func ResolveMigrationsDir() (string, error) {
+	candidates := []string{
+		"migrations",
+		filepath.Join("..", "migrations"),
+		filepath.Join("..", "..", "migrations"),
+	}
+	for _, c := range candidates {
+		if info, err := os.Stat(c); err == nil && info.IsDir() {
+			return c, nil
+		}
+	}
+	return "", fmt.Errorf("migrations directory not found")
+}
diff --git a/internal/auth/auth.go b/internal/auth/auth.go
new file mode 100644
index 0000000..fd86b0e
--- /dev/null
+++ b/internal/auth/auth.go
@@ -0,0 +1,204 @@
+package auth
+
+import (
+	"context"
+	"crypto/rand"
+	"database/sql"
+	"encoding/hex"
+	"errors"
+	"fmt"
+	"log/slog"
+	"time"
+
+	"golang.org/x/crypto/bcrypt"
+
+	"github.com/fonu/fonu/internal/validate"
+)
+
+const sessionTTL = 7 * 24 * time.Hour
+
+var ErrUnauthorized = errors.New("unauthorized")
+var ErrNotInitialized = errors.New("not initialized")
+
+type Service struct {
+	db *sql.DB
+}
+
+func New(db *sql.DB) *Service {
+	return &Service{db: db}
+}
+
+func (s *Service) IsInitialized(ctx context.Context) (bool, error) {
+	var count int
+	err := s.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM admins`).Scan(&count)
+	return count > 0, err
+}
+
+const defaultAdminUsername = "admin"
+
+func (s *Service) EnsureDefaultAdmin(ctx context.Context, log *slog.Logger) error {
+	initialized, err := s.IsInitialized(ctx)
+	if err != nil {
+		return err
+	}
+	if initialized {
+		return nil
+	}
+
+	password, err := generatePassword(16)
+	if err != nil {
+		return err
+	}
+	if err := s.Setup(ctx, defaultAdminUsername, password); err != nil {
+		return err
+	}
+
+	log.Info(
+		"首次启动已创建管理员账户,请使用以下凭据登录,并在「设置」中尽快修改密码",
+		"module", "SYSTEM",
+		"username", defaultAdminUsername,
+		"password", password,
+	)
+	return nil
+}
+
+func (s *Service) Setup(ctx context.Context, username, password string) error {
+	if err := validate.Username(username); err != nil {
+		return err
+	}
+	if err := validate.Password(password); err != nil {
+		return err
+	}
+
+	initialized, err := s.IsInitialized(ctx)
+	if err != nil {
+		return err
+	}
+	if initialized {
+		return fmt.Errorf("管理员已初始化")
+	}
+
+	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+	if err != nil {
+		return err
+	}
+
+	_, err = s.db.ExecContext(ctx, `INSERT INTO admins(username, password_hash) VALUES (?, ?)`, username, string(hash))
+	return err
+}
+
+func (s *Service) Login(ctx context.Context, username, password string) (string, error) {
+	var adminID int
+	var hash string
+	err := s.db.QueryRowContext(ctx, `SELECT id, password_hash FROM admins WHERE username = ?`, username).Scan(&adminID, &hash)
+	if err != nil {
+		if errors.Is(err, sql.ErrNoRows) {
+			return "", ErrUnauthorized
+		}
+		return "", err
+	}
+	if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
+		return "", ErrUnauthorized
+	}
+	return s.createSession(ctx, adminID)
+}
+
+func (s *Service) AdminIDForSession(ctx context.Context, sessionID string) (int, error) {
+	if sessionID == "" {
+		return 0, ErrUnauthorized
+	}
+	var adminID int
+	err := s.db.QueryRowContext(ctx, `SELECT admin_id FROM sessions WHERE id = ?`, sessionID).Scan(&adminID)
+	if err != nil {
+		if errors.Is(err, sql.ErrNoRows) {
+			return 0, ErrUnauthorized
+		}
+		return 0, err
+	}
+	return adminID, nil
+}
+
+func (s *Service) ChangePassword(ctx context.Context, adminID int, oldPassword, newPassword string) error {
+	if err := validate.Password(newPassword); err != nil {
+		return err
+	}
+	var hash string
+	err := s.db.QueryRowContext(ctx, `SELECT password_hash FROM admins WHERE id = ?`, adminID).Scan(&hash)
+	if err != nil {
+		return err
+	}
+	if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(oldPassword)); err != nil {
+		return fmt.Errorf("当前密码不正确")
+	}
+	newHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
+	if err != nil {
+		return err
+	}
+	_, err = s.db.ExecContext(ctx, `UPDATE admins SET password_hash = ? WHERE id = ?`, string(newHash), adminID)
+	return err
+}
+
+func (s *Service) Logout(ctx context.Context, sessionID string) error {
+	_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, sessionID)
+	return err
+}
+
+func (s *Service) ValidateSession(ctx context.Context, sessionID string) error {
+	if sessionID == "" {
+		return ErrUnauthorized
+	}
+	var expiresAt string
+	err := s.db.QueryRowContext(ctx, `SELECT expires_at FROM sessions WHERE id = ?`, sessionID).Scan(&expiresAt)
+	if err != nil {
+		if errors.Is(err, sql.ErrNoRows) {
+			return ErrUnauthorized
+		}
+		return err
+	}
+	expires, err := time.Parse(time.RFC3339, expiresAt)
+	if err != nil {
+		return ErrUnauthorized
+	}
+	if time.Now().After(expires) {
+		_, _ = s.db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, sessionID)
+		return ErrUnauthorized
+	}
+	return nil
+}
+
+func (s *Service) createSession(ctx context.Context, adminID int) (string, error) {
+	id, err := randomToken(32)
+	if err != nil {
+		return "", err
+	}
+	expires := time.Now().Add(sessionTTL).UTC().Format(time.RFC3339)
+	_, err = s.db.ExecContext(ctx, `INSERT INTO sessions(id, admin_id, expires_at) VALUES (?, ?, ?)`, id, adminID, expires)
+	if err != nil {
+		return "", err
+	}
+	return id, nil
+}
+
+func randomToken(n int) (string, error) {
+	b := make([]byte, n)
+	if _, err := rand.Read(b); err != nil {
+		return "", err
+	}
+	return hex.EncodeToString(b), nil
+}
+
+func generatePassword(length int) (string, error) {
+	const letters = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
+	if length < 8 {
+		length = 8
+	}
+	out := make([]byte, length)
+	randBytes := make([]byte, length)
+	if _, err := rand.Read(randBytes); err != nil {
+		return "", err
+	}
+	for i := range out {
+		out[i] = letters[int(randBytes[i])%len(letters)]
+	}
+	return string(out), nil
+}
diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go
new file mode 100644
index 0000000..78a322a
--- /dev/null
+++ b/internal/auth/auth_test.go
@@ -0,0 +1,41 @@
+package auth
+
+import (
+	"context"
+	"log/slog"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/fonu/fonu/internal/db"
+)
+
+func TestEnsureDefaultAdminCreatesOnce(t *testing.T) {
+	conn, err := db.Open(filepath.Join(t.TempDir(), "fonu.db"))
+	if err != nil {
+		t.Fatalf("open db: %v", err)
+	}
+	defer conn.Close()
+
+	if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
+		t.Fatalf("migrate: %v", err)
+	}
+
+	svc := New(conn)
+	logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
+
+	if err := svc.EnsureDefaultAdmin(context.Background(), logger); err != nil {
+		t.Fatalf("first bootstrap: %v", err)
+	}
+	initialized, err := svc.IsInitialized(context.Background())
+	if err != nil || !initialized {
+		t.Fatalf("expected initialized admin, got initialized=%v err=%v", initialized, err)
+	}
+
+	if err := svc.EnsureDefaultAdmin(context.Background(), logger); err != nil {
+		t.Fatalf("second bootstrap: %v", err)
+	}
+	if _, err := svc.Login(context.Background(), "admin", "wrong-password"); err != ErrUnauthorized {
+		t.Fatalf("expected unauthorized for wrong password, got %v", err)
+	}
+}
diff --git a/internal/backup/service.go b/internal/backup/service.go
new file mode 100644
index 0000000..78e2b7f
--- /dev/null
+++ b/internal/backup/service.go
@@ -0,0 +1,170 @@
+package backup
+
+import (
+	"archive/tar"
+	"compress/gzip"
+	"context"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+)
+
+type Service struct {
+	dataDir string
+}
+
+func New(dataDir string) *Service {
+	return &Service{dataDir: dataDir}
+}
+
+func (s *Service) Export(ctx context.Context) (string, error) {
+	tmpDir := filepath.Join(s.dataDir, "backups")
+	if err := os.MkdirAll(tmpDir, 0o755); err != nil {
+		return "", err
+	}
+	name := fmt.Sprintf("fonu-backup-%s.tar.gz", time.Now().Format("20060102-His"))
+	outPath := filepath.Join(tmpDir, name)
+	file, err := os.Create(outPath)
+	if err != nil {
+		return "", err
+	}
+	defer file.Close()
+
+	gw := gzip.NewWriter(file)
+	tw := tar.NewWriter(gw)
+
+	entries := []string{
+		"fonu.db",
+		"certs",
+		"nginx",
+	}
+	for _, entry := range entries {
+		if err := addToArchive(ctx, tw, s.dataDir, entry); err != nil {
+			_ = tw.Close()
+			_ = gw.Close()
+			_ = os.Remove(outPath)
+			return "", err
+		}
+	}
+
+	if err := tw.Close(); err != nil {
+		return "", err
+	}
+	if err := gw.Close(); err != nil {
+		return "", err
+	}
+	return outPath, nil
+}
+
+func (s *Service) Restore(ctx context.Context, archivePath string) error {
+	file, err := os.Open(archivePath)
+	if err != nil {
+		return err
+	}
+	defer file.Close()
+
+	gr, err := gzip.NewReader(file)
+	if err != nil {
+		return fmt.Errorf("无效的备份文件")
+	}
+	defer gr.Close()
+
+	tr := tar.NewReader(gr)
+	for {
+		header, err := tr.Next()
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			return err
+		}
+		if header.Typeflag != tar.TypeReg && header.Typeflag != tar.TypeDir {
+			continue
+		}
+		target := filepath.Join(s.dataDir, header.Name)
+		if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(s.dataDir)) {
+			return fmt.Errorf("备份条目非法")
+		}
+		switch header.Typeflag {
+		case tar.TypeDir:
+			if err := os.MkdirAll(target, 0o755); err != nil {
+				return err
+			}
+		case tar.TypeReg:
+			if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+				return err
+			}
+			out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode))
+			if err != nil {
+				return err
+			}
+			if _, err := io.Copy(out, tr); err != nil {
+				out.Close()
+				return err
+			}
+			out.Close()
+		}
+	}
+	return nil
+}
+
+func addToArchive(ctx context.Context, tw *tar.Writer, baseDir, rel string) error {
+	path := filepath.Join(baseDir, rel)
+	info, err := os.Stat(path)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return nil
+		}
+		return err
+	}
+	if info.IsDir() {
+		return addDir(ctx, tw, baseDir, rel)
+	}
+	return addFile(tw, baseDir, rel, info)
+}
+
+func addDir(ctx context.Context, tw *tar.Writer, baseDir, rel string) error {
+	root := filepath.Join(baseDir, rel)
+	return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
+		if err != nil {
+			return err
+		}
+		if d.IsDir() {
+			return nil
+		}
+		if strings.Contains(path, string(os.PathSeparator)+"backups"+string(os.PathSeparator)) {
+			return nil
+		}
+		info, err := d.Info()
+		if err != nil {
+			return err
+		}
+		relPath, err := filepath.Rel(baseDir, path)
+		if err != nil {
+			return err
+		}
+		return addFile(tw, baseDir, relPath, info)
+	})
+}
+
+func addFile(tw *tar.Writer, baseDir, rel string, info os.FileInfo) error {
+	path := filepath.Join(baseDir, rel)
+	file, err := os.Open(path)
+	if err != nil {
+		return err
+	}
+	defer file.Close()
+	header, err := tar.FileInfoHeader(info, "")
+	if err != nil {
+		return err
+	}
+	header.Name = filepath.ToSlash(rel)
+	if err := tw.WriteHeader(header); err != nil {
+		return err
+	}
+	_, err = io.Copy(tw, file)
+	return err
+}
diff --git a/internal/certificate/import.go b/internal/certificate/import.go
new file mode 100644
index 0000000..7091497
--- /dev/null
+++ b/internal/certificate/import.go
@@ -0,0 +1,214 @@
+package certificate
+
+import (
+	"crypto"
+	"crypto/ecdsa"
+	"crypto/rsa"
+	"crypto/x509"
+	"encoding/pem"
+	"fmt"
+	"net"
+	"os"
+	"strings"
+	"time"
+)
+
+type ImportInfo struct {
+	Primary   string
+	Domains   []string
+	Wildcard  bool
+	ExpiresAt time.Time
+}
+
+func InspectImport(certPEM, keyPEM []byte) (ImportInfo, error) {
+	cert, err := parseCertificate(certPEM)
+	if err != nil {
+		return ImportInfo{}, err
+	}
+	key, err := parsePrivateKey(keyPEM)
+	if err != nil {
+		return ImportInfo{}, err
+	}
+	if !publicKeysMatch(cert.PublicKey, key) {
+		return ImportInfo{}, fmt.Errorf("证书与私钥不匹配")
+	}
+	domains, err := ExtractCertDomains(cert)
+	if err != nil {
+		return ImportInfo{}, err
+	}
+	return ImportInfo{
+		Primary:   primaryDomain(domains),
+		Domains:   domains,
+		Wildcard:  hasWildcardDomain(domains),
+		ExpiresAt: cert.NotAfter.UTC(),
+	}, nil
+}
+
+func ValidatePEM(certPEM, keyPEM []byte) (time.Time, error) {
+	info, err := InspectImport(certPEM, keyPEM)
+	if err != nil {
+		return time.Time{}, err
+	}
+	return info.ExpiresAt, nil
+}
+
+func ExtractCertDomains(cert *x509.Certificate) ([]string, error) {
+	seen := map[string]bool{}
+	var domains []string
+	add := func(name string) error {
+		name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
+		if name == "" || net.ParseIP(name) != nil {
+			return nil
+		}
+		if seen[name] {
+			return nil
+		}
+		if strings.HasPrefix(name, "*.") {
+			if err := validateDomainLabel(strings.TrimPrefix(name, "*.")); err != nil {
+				return err
+			}
+		} else if err := validateDomainLabel(name); err != nil {
+			return err
+		}
+		seen[name] = true
+		domains = append(domains, name)
+		return nil
+	}
+
+	for _, name := range cert.DNSNames {
+		if err := add(name); err != nil {
+			return nil, err
+		}
+	}
+	if err := add(cert.Subject.CommonName); err != nil {
+		return nil, err
+	}
+	if len(domains) == 0 {
+		return nil, fmt.Errorf("证书中未包含有效域名")
+	}
+	return domains, nil
+}
+
+func primaryDomain(domains []string) string {
+	for _, domain := range domains {
+		if !strings.HasPrefix(domain, "*.") {
+			return domain
+		}
+	}
+	return strings.TrimPrefix(domains[0], "*.")
+}
+
+func hasWildcardDomain(domains []string) bool {
+	for _, domain := range domains {
+		if strings.HasPrefix(domain, "*.") {
+			return true
+		}
+	}
+	return false
+}
+
+func validateDomainLabel(domain string) error {
+	if domain == "" {
+		return fmt.Errorf("域名格式无效")
+	}
+	parts := strings.Split(domain, ".")
+	for _, part := range parts {
+		if part == "" || len(part) > 63 {
+			return fmt.Errorf("域名格式无效")
+		}
+	}
+	return nil
+}
+
+func parseCertificate(certPEM []byte) (*x509.Certificate, error) {
+	rest := certPEM
+	for {
+		block, remaining := pem.Decode(rest)
+		if block == nil {
+			return nil, fmt.Errorf("证书 PEM 格式无效")
+		}
+		if block.Type == "CERTIFICATE" {
+			cert, err := x509.ParseCertificate(block.Bytes)
+			if err != nil {
+				return nil, fmt.Errorf("解析证书失败:%w", err)
+			}
+			return cert, nil
+		}
+		rest = remaining
+		if len(rest) == 0 {
+			break
+		}
+	}
+	return nil, fmt.Errorf("证书 PEM 中未找到有效证书")
+}
+
+func parsePrivateKey(keyPEM []byte) (crypto.PrivateKey, error) {
+	block, _ := pem.Decode(keyPEM)
+	if block == nil {
+		return nil, fmt.Errorf("私钥 PEM 格式无效")
+	}
+	switch block.Type {
+	case "RSA PRIVATE KEY":
+		return x509.ParsePKCS1PrivateKey(block.Bytes)
+	case "EC PRIVATE KEY":
+		return x509.ParseECPrivateKey(block.Bytes)
+	case "PRIVATE KEY":
+		return x509.ParsePKCS8PrivateKey(block.Bytes)
+	default:
+		return nil, fmt.Errorf("不支持的私钥类型:%s", block.Type)
+	}
+}
+
+func publicKeysMatch(certKey crypto.PublicKey, privateKey crypto.PrivateKey) bool {
+	switch pub := certKey.(type) {
+	case *rsa.PublicKey:
+		priv, ok := privateKey.(*rsa.PrivateKey)
+		return ok && pub.N.Cmp(priv.N) == 0
+	case *ecdsa.PublicKey:
+		priv, ok := privateKey.(*ecdsa.PrivateKey)
+		return ok && pub.X.Cmp(priv.X) == 0 && pub.Y.Cmp(priv.Y) == 0
+	default:
+		return false
+	}
+}
+
+func NormalizePEM(v string) []byte {
+	return []byte(strings.TrimSpace(v))
+}
+
+func ResolvePEMSource(content, path string) ([]byte, error) {
+	path = strings.TrimSpace(path)
+	if path != "" {
+		return ReadPEMFile(path)
+	}
+	data := NormalizePEM(content)
+	if len(data) == 0 {
+		return nil, fmt.Errorf("内容不能为空")
+	}
+	return data, nil
+}
+
+func ReadPEMFile(path string) ([]byte, error) {
+	path = strings.TrimSpace(path)
+	if path == "" {
+		return nil, fmt.Errorf("文件路径不能为空")
+	}
+	info, err := os.Stat(path)
+	if err != nil {
+		return nil, fmt.Errorf("无法读取文件 %s:%w", path, err)
+	}
+	if info.IsDir() {
+		return nil, fmt.Errorf("%s 是目录,不是文件", path)
+	}
+	if info.Size() > 1<<20 {
+		return nil, fmt.Errorf("文件过大(最大 1MB)")
+	}
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return nil, fmt.Errorf("读取文件失败:%w", err)
+	}
+	if len(strings.TrimSpace(string(data))) == 0 {
+		return nil, fmt.Errorf("文件内容为空")
+	}
+	return data, nil
+}
diff --git a/internal/certificate/import_test.go b/internal/certificate/import_test.go
new file mode 100644
index 0000000..801b0a1
--- /dev/null
+++ b/internal/certificate/import_test.go
@@ -0,0 +1,118 @@
+package certificate
+
+import (
+	"crypto/ecdsa"
+	"crypto/elliptic"
+	"crypto/rand"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/pem"
+	"math/big"
+	"os"
+	"strings"
+	"testing"
+	"time"
+)
+
+func TestReadPEMFile(t *testing.T) {
+	certPEM, keyPEM := generateTestCert("example.com")
+	certPath := t.TempDir() + "/cert.pem"
+	keyPath := t.TempDir() + "/key.pem"
+	if err := os.WriteFile(certPath, certPEM, 0o600); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
+		t.Fatal(err)
+	}
+
+	readCert, err := ReadPEMFile(certPath)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if string(readCert) != string(certPEM) {
+		t.Fatal("cert content mismatch")
+	}
+
+	got, err := ResolvePEMSource("", certPath)
+	if err != nil || string(got) != string(certPEM) {
+		t.Fatalf("ResolvePEMSource path: %v", err)
+	}
+}
+
+func TestInspectImport(t *testing.T) {
+	certPEM, keyPEM := generateTestCertWithSANs([]string{"example.com", "*.example.com", "nas.example.com"})
+	info, err := InspectImport(certPEM, keyPEM)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if info.Primary != "example.com" {
+		t.Fatalf("primary: %s", info.Primary)
+	}
+	if !info.Wildcard {
+		t.Fatal("expected wildcard")
+	}
+	if len(info.Domains) != 3 {
+		t.Fatalf("domains: %v", info.Domains)
+	}
+}
+
+func TestValidatePEM(t *testing.T) {
+	certPEM, keyPEM := generateTestCert("example.com")
+
+	expiresAt, err := ValidatePEM(certPEM, keyPEM)
+	if err != nil {
+		t.Fatalf("ValidatePEM: %v", err)
+	}
+	if expiresAt.Before(time.Now()) {
+		t.Fatalf("expected future expiry, got %v", expiresAt)
+	}
+
+	_, err = ValidatePEM(certPEM, []byte("not a key"))
+	if err == nil {
+		t.Fatal("expected error for invalid key")
+	}
+
+	_, wrongKeyPEM := generateTestCert("other.example.com")
+	_, err = ValidatePEM(certPEM, wrongKeyPEM)
+	if err == nil {
+		t.Fatal("expected mismatch error")
+	}
+}
+
+func generateTestCert(domain string) ([]byte, []byte) {
+	return generateTestCertWithSANs([]string{domain})
+}
+
+func generateTestCertWithSANs(domains []string) ([]byte, []byte) {
+	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	if err != nil {
+		panic(err)
+	}
+	cn := domains[0]
+	if strings.HasPrefix(cn, "*.") {
+		cn = strings.TrimPrefix(cn, "*.")
+	}
+
+	template := &x509.Certificate{
+		SerialNumber: big.NewInt(1),
+		Subject:      pkix.Name{CommonName: cn},
+		DNSNames:     domains,
+		NotBefore:    time.Now().Add(-time.Hour),
+		NotAfter:     time.Now().Add(24 * time.Hour),
+		KeyUsage:     x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
+		ExtKeyUsage:  []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+	}
+
+	certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
+	if err != nil {
+		panic(err)
+	}
+
+	certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
+	keyDER, err := x509.MarshalECPrivateKey(key)
+	if err != nil {
+		panic(err)
+	}
+	keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
+	return certPEM, keyPEM
+}
diff --git a/internal/certificate/store.go b/internal/certificate/store.go
new file mode 100644
index 0000000..890111e
--- /dev/null
+++ b/internal/certificate/store.go
@@ -0,0 +1,168 @@
+package certificate
+
+import (
+	"context"
+	"database/sql"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"strings"
+	"time"
+)
+
+type Record struct {
+	ID          int64      `json:"id"`
+	Domain      string     `json:"domain"`
+	Domains     []string   `json:"domains,omitempty"`
+	Wildcard    bool       `json:"wildcard"`
+	ACMECA      string     `json:"acme_ca,omitempty"`
+	CertPath    string     `json:"cert_path,omitempty"`
+	KeyPath     string     `json:"key_path,omitempty"`
+	ExpiresAt   *time.Time `json:"expires_at,omitempty"`
+	LastRenewAt *time.Time `json:"last_renew_at,omitempty"`
+	Status      string     `json:"status"`
+	LastError   string     `json:"last_error,omitempty"`
+	DaysLeft    int        `json:"days_left"`
+}
+
+type Store struct {
+	db *sql.DB
+}
+
+func NewStore(db *sql.DB) *Store {
+	return &Store{db: db}
+}
+
+func (s *Store) List(ctx context.Context) ([]Record, error) {
+	rows, err := s.db.QueryContext(ctx, `
+		SELECT id, domain, COALESCE(domains, ''), wildcard, COALESCE(acme_ca, ''), COALESCE(cert_path, ''), COALESCE(key_path, ''),
+		       expires_at, last_renew_at, status, COALESCE(last_error, '')
+		FROM certificates ORDER BY id ASC
+	`)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var records []Record
+	for rows.Next() {
+		rec, err := scanRecord(rows)
+		if err != nil {
+			return nil, err
+		}
+		records = append(records, rec)
+	}
+	return records, rows.Err()
+}
+
+func (s *Store) Upsert(ctx context.Context, domain string, wildcard bool, domainsJSON, acmeCA, certPath, keyPath string, expiresAt time.Time, status string, lastError string) (Record, error) {
+	_, err := s.db.ExecContext(ctx, `
+		INSERT INTO certificates(domain, domains, wildcard, acme_ca, cert_path, key_path, expires_at, last_renew_at, status, last_error, updated_at)
+		VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), ?, ?, datetime('now'))
+		ON CONFLICT(domain) DO UPDATE SET
+			domains = excluded.domains,
+			wildcard = excluded.wildcard,
+			acme_ca = excluded.acme_ca,
+			cert_path = excluded.cert_path,
+			key_path = excluded.key_path,
+			expires_at = excluded.expires_at,
+			last_renew_at = datetime('now'),
+			status = excluded.status,
+			last_error = excluded.last_error,
+			updated_at = datetime('now')
+	`, domain, domainsJSON, boolInt(wildcard), acmeCA, certPath, keyPath, expiresAt.UTC().Format(time.RFC3339), status, lastError)
+	if err != nil {
+		return Record{}, err
+	}
+	return s.GetByDomain(ctx, domain)
+}
+
+func (s *Store) UpdateStatus(ctx context.Context, domain, status, lastError string) error {
+	_, err := s.db.ExecContext(ctx, `
+		UPDATE certificates SET status = ?, last_error = ?, updated_at = datetime('now') WHERE domain = ?
+	`, status, lastError, domain)
+	return err
+}
+
+func (s *Store) Delete(ctx context.Context, domain string) error {
+	res, err := s.db.ExecContext(ctx, `DELETE FROM certificates WHERE domain = ?`, domain)
+	if err != nil {
+		return err
+	}
+	n, err := res.RowsAffected()
+	if err != nil {
+		return err
+	}
+	if n == 0 {
+		return fmt.Errorf("证书不存在")
+	}
+	return nil
+}
+
+func (s *Store) GetByDomain(ctx context.Context, domain string) (Record, error) {
+	row := s.db.QueryRowContext(ctx, `
+		SELECT id, domain, COALESCE(domains, ''), wildcard, COALESCE(acme_ca, ''), COALESCE(cert_path, ''), COALESCE(key_path, ''),
+		       expires_at, last_renew_at, status, COALESCE(last_error, '')
+		FROM certificates WHERE domain = ?
+	`, domain)
+	rec, err := scanRecord(row)
+	if errors.Is(err, sql.ErrNoRows) {
+		return Record{}, fmt.Errorf("证书不存在")
+	}
+	return rec, err
+}
+
+func scanRecord(row interface{ Scan(dest ...any) error }) (Record, error) {
+	var rec Record
+	var wildcard int
+	var domainsJSON string
+	var expiresAt, lastRenewAt sql.NullString
+	if err := row.Scan(&rec.ID, &rec.Domain, &domainsJSON, &wildcard, &rec.ACMECA, &rec.CertPath, &rec.KeyPath, &expiresAt, &lastRenewAt, &rec.Status, &rec.LastError); err != nil {
+		return Record{}, err
+	}
+	rec.Wildcard = wildcard == 1
+	rec.Domains = decodeDomains(domainsJSON, rec.Domain, rec.Wildcard)
+	if expiresAt.Valid {
+		t := parseTime(expiresAt.String)
+		rec.ExpiresAt = &t
+		rec.DaysLeft = int(time.Until(t).Hours() / 24)
+	}
+	if lastRenewAt.Valid {
+		t := parseTime(lastRenewAt.String)
+		rec.LastRenewAt = &t
+	}
+	return rec, nil
+}
+
+func boolInt(v bool) int {
+	if v {
+		return 1
+	}
+	return 0
+}
+
+func decodeDomains(raw string, domain string, wildcard bool) []string {
+	raw = strings.TrimSpace(raw)
+	if raw == "" {
+		if wildcard {
+			return []string{domain, "*." + domain}
+		}
+		return []string{domain}
+	}
+	var domains []string
+	if err := json.Unmarshal([]byte(raw), &domains); err != nil || len(domains) == 0 {
+		if wildcard {
+			return []string{domain, "*." + domain}
+		}
+		return []string{domain}
+	}
+	return domains
+}
+
+func parseTime(v string) time.Time {
+	t, err := time.Parse(time.RFC3339, v)
+	if err != nil {
+		t, _ = time.Parse("2006-01-02 15:04:05", v)
+	}
+	return t.UTC()
+}
diff --git a/internal/chinacidr/download_test.go b/internal/chinacidr/download_test.go
new file mode 100644
index 0000000..367976b
--- /dev/null
+++ b/internal/chinacidr/download_test.go
@@ -0,0 +1,29 @@
+package chinacidr
+
+import (
+	"context"
+	"testing"
+)
+
+func TestDownloadDefaultV6Sources(t *testing.T) {
+	ctx := context.Background()
+	lines, err := downloadFromSources(ctx, defaultV6URLs...)
+	if err != nil {
+		t.Fatalf("download v6: %v", err)
+	}
+	if len(lines) < 10 {
+		t.Fatalf("expected at least 10 v6 entries, got %d", len(lines))
+	}
+}
+
+func TestGhfastProxyURL(t *testing.T) {
+	raw := "https://raw.githubusercontent.com/foo/bar.txt"
+	got := ghfastProxyURL(raw)
+	want := ghfastProxyPrefix + raw
+	if got != want {
+		t.Fatalf("got %q want %q", got, want)
+	}
+	if ghfastProxyURL(want) != "" {
+		t.Fatal("expected no double proxy")
+	}
+}
diff --git a/internal/chinacidr/service.go b/internal/chinacidr/service.go
new file mode 100644
index 0000000..0af1c85
--- /dev/null
+++ b/internal/chinacidr/service.go
@@ -0,0 +1,332 @@
+package chinacidr
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"log/slog"
+	"net"
+	"net/http"
+	"os"
+	"strings"
+	"sync/atomic"
+	"time"
+
+	"github.com/fonu/fonu/internal/config"
+	"github.com/fonu/fonu/internal/nginx"
+	"github.com/fonu/fonu/internal/proxy"
+	"github.com/fonu/fonu/internal/settings"
+)
+
+const (
+	defaultV4URL       = "https://raw.githubusercontent.com/17mon/china_ip_list/master/china_ip_list.txt"
+	ghfastProxyPrefix  = "https://ghfast.top/"
+)
+
+// metowolf/iplist 已下线 CN/ipv6.txt;按优先级尝试可用源。
+var defaultV6URLs = []string{
+	"https://gaoyifan.github.io/china-operator-ip/china6.txt",
+	"https://raw.githubusercontent.com/gaoyifan/china-operator-ip/china6.txt",
+	"https://raw.githubusercontent.com/carrnot/china-ip-list/release/ipv6.txt",
+}
+
+type Service struct {
+	cfg        config.Config
+	settings   *settings.Store
+	nginxMgr   *nginx.Manager
+	proxyStore *proxy.Store
+	logger     *slog.Logger
+	updating   atomic.Bool
+}
+
+type Status struct {
+	UpdatedAt    string `json:"updated_at,omitempty"`
+	EntryCountV4 int    `json:"entry_count_v4"`
+	EntryCountV6 int    `json:"entry_count_v6"`
+	LastError    string `json:"last_error,omitempty"`
+	Updating     bool   `json:"updating"`
+	Ready        bool   `json:"ready"`
+}
+
+func New(cfg config.Config, settingsStore *settings.Store, nginxMgr *nginx.Manager, proxyStore *proxy.Store, logger *slog.Logger) *Service {
+	return &Service{cfg: cfg, settings: settingsStore, nginxMgr: nginxMgr, proxyStore: proxyStore, logger: logger}
+}
+
+func (s *Service) Tick(ctx context.Context) {
+	if err := s.Update(ctx); err != nil {
+		s.logger.Warn("china cidr update failed", "module", "CHINA_CIDR", "error", err.Error())
+		s.RecordLastError(ctx, err)
+	}
+}
+
+func (s *Service) RecordLastError(ctx context.Context, err error) {
+	if err == nil {
+		return
+	}
+	_ = s.settings.Set(ctx, settings.KeyChinaCIDRLastError, err.Error())
+}
+
+func (s *Service) Update(ctx context.Context) error {
+	if !s.updating.CompareAndSwap(false, true) {
+		return fmt.Errorf("正在更新中,请稍后再试")
+	}
+	defer s.updating.Store(false)
+
+	v4URL, _ := s.settings.Get(ctx, settings.KeyChinaCIDRSourceV4)
+	customV6URL, _ := s.settings.Get(ctx, settings.KeyChinaCIDRSourceV6)
+	if isDeprecatedChinaCIDRSource(customV6URL) {
+		customV6URL = ""
+		_ = s.settings.Set(ctx, settings.KeyChinaCIDRSourceV6, "")
+	}
+
+	v4Lines, err := downloadFromSources(ctx, v4URL, defaultV4URL)
+	if err != nil {
+		return fmt.Errorf("下载 IPv4 CIDR 失败: %w", err)
+	}
+	v6Sources := append([]string{customV6URL}, defaultV6URLs...)
+	v6Lines, err := downloadFromSources(ctx, v6Sources...)
+	if err != nil {
+		return fmt.Errorf("下载 IPv6 CIDR 失败: %w", err)
+	}
+
+	body, v4Count, v6Count, err := buildCIDRFile(v4Lines, v6Lines)
+	if err != nil {
+		return err
+	}
+
+	tmpPath := s.cfg.ChinaCIDRTempPath()
+	if err := os.WriteFile(tmpPath, []byte(body), 0o644); err != nil {
+		return err
+	}
+
+	rules, err := s.proxyStore.ListEnabled(ctx)
+	if err != nil {
+		return err
+	}
+	opts := s.loadGenerateOptions(ctx)
+	validateOpts := opts
+	validateOpts.ChinaCIDRPathOverride = tmpPath
+	validateOpts.ChinaCIDRAvailable = true
+	if err := s.nginxMgr.ValidateOnlyWithOptions(ctx, rules, nil, validateOpts); err != nil {
+		_ = os.Remove(tmpPath)
+		return fmt.Errorf("nginx 校验失败: %w", err)
+	}
+
+	finalPath := s.cfg.ChinaCIDRPath()
+	if err := os.Rename(tmpPath, finalPath); err != nil {
+		return err
+	}
+
+	if _, err := s.nginxMgr.Apply(ctx, rules, nil); err != nil {
+		return fmt.Errorf("nginx 重载失败: %w", err)
+	}
+
+	now := time.Now().UTC().Format(time.RFC3339)
+	_ = s.settings.Set(ctx, settings.KeyChinaCIDRUpdatedAt, now)
+	_ = s.settings.Set(ctx, settings.KeyChinaCIDRCountV4, fmt.Sprintf("%d", v4Count))
+	_ = s.settings.Set(ctx, settings.KeyChinaCIDRCountV6, fmt.Sprintf("%d", v6Count))
+	_ = s.settings.Set(ctx, settings.KeyChinaCIDRLastError, "")
+
+	s.logger.Info("china cidr updated", "module", "CHINA_CIDR", "v4", v4Count, "v6", v6Count)
+	return nil
+}
+
+func (s *Service) Status(ctx context.Context) Status {
+	updatedAt, _ := s.settings.Get(ctx, settings.KeyChinaCIDRUpdatedAt)
+	v4, _ := s.settings.GetInt(ctx, settings.KeyChinaCIDRCountV4)
+	v6, _ := s.settings.GetInt(ctx, settings.KeyChinaCIDRCountV6)
+	lastErr, _ := s.settings.Get(ctx, settings.KeyChinaCIDRLastError)
+	ready := updatedAt != "" && lastErr == "" && v4 > 0 && v6 > 0
+	return Status{
+		UpdatedAt:    updatedAt,
+		EntryCountV4: v4,
+		EntryCountV6: v6,
+		LastError:    lastErr,
+		Updating:     s.updating.Load(),
+		Ready:        ready,
+	}
+}
+
+func isDeprecatedChinaCIDRSource(url string) bool {
+	url = strings.TrimSpace(url)
+	if url == "" {
+		return false
+	}
+	return strings.Contains(url, "metowolf/iplist") || strings.Contains(url, "iplist/meta")
+}
+
+func ghfastProxyURL(origin string) string {
+	origin = strings.TrimSpace(origin)
+	if origin == "" || strings.HasPrefix(origin, ghfastProxyPrefix) {
+		return ""
+	}
+	if !strings.HasPrefix(origin, "http://") && !strings.HasPrefix(origin, "https://") {
+		return ""
+	}
+	return ghfastProxyPrefix + origin
+}
+
+func expandDownloadSources(sources ...string) []string {
+	seen := make(map[string]struct{})
+	var out []string
+	add := func(url string) {
+		url = strings.TrimSpace(url)
+		if url == "" || isDeprecatedChinaCIDRSource(url) {
+			return
+		}
+		if _, ok := seen[url]; ok {
+			return
+		}
+		seen[url] = struct{}{}
+		out = append(out, url)
+	}
+	for _, raw := range sources {
+		add(raw)
+		if proxy := ghfastProxyURL(raw); proxy != "" {
+			add(proxy)
+		}
+	}
+	return out
+}
+
+func downloadFromSources(ctx context.Context, sources ...string) ([]string, error) {
+	var tried []string
+	var errs []string
+	for _, url := range expandDownloadSources(sources...) {
+		tried = append(tried, url)
+		lines, err := downloadLines(ctx, url)
+		if err == nil {
+			return lines, nil
+		}
+		errs = append(errs, fmt.Sprintf("%s (%v)", shortSourceURL(url), err))
+	}
+	if len(tried) == 0 {
+		return nil, fmt.Errorf("无可用的下载源")
+	}
+	return nil, fmt.Errorf("已尝试 %d 个源均失败: %s", len(tried), strings.Join(errs, "; "))
+}
+
+func shortSourceURL(url string) string {
+	if len(url) <= 72 {
+		return url
+	}
+	return url[:69] + "..."
+}
+
+func downloadLinesWithFallback(ctx context.Context, primary string, fallbacks ...string) ([]string, error) {
+	return downloadFromSources(ctx, append([]string{primary}, fallbacks...)...)
+}
+
+func downloadLines(ctx context.Context, url string) ([]string, error) {
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+	if err != nil {
+		return nil, err
+	}
+	client := &http.Client{Timeout: 30 * time.Second}
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
+	}
+	data, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
+	if err != nil {
+		return nil, err
+	}
+	var lines []string
+	for _, line := range strings.Split(string(data), "\n") {
+		line = strings.TrimSpace(line)
+		if line == "" || strings.HasPrefix(line, "#") {
+			continue
+		}
+		lines = append(lines, line)
+	}
+	return lines, nil
+}
+
+func buildCIDRFile(v4Lines, v6Lines []string) (string, int, int, error) {
+	var b strings.Builder
+	v4Count := 0
+	v6Count := 0
+	for _, line := range v4Lines {
+		cidr, err := normalizeCIDR(line)
+		if err != nil {
+			continue
+		}
+		if cidrTo4(cidr) == nil {
+			continue
+		}
+		b.WriteString(cidr + " 1;\n")
+		v4Count++
+	}
+	for _, line := range v6Lines {
+		cidr, err := normalizeCIDR(line)
+		if err != nil {
+			continue
+		}
+		if cidrTo4(cidr) != nil {
+			continue
+		}
+		b.WriteString(cidr + " 1;\n")
+		v6Count++
+	}
+	if v4Count < 100 {
+		return "", 0, 0, fmt.Errorf("IPv4 条目过少 (%d),拒绝更新", v4Count)
+	}
+	if v6Count < 10 {
+		return "", 0, 0, fmt.Errorf("IPv6 条目过少 (%d),拒绝更新", v6Count)
+	}
+	return b.String(), v4Count, v6Count, nil
+}
+
+func normalizeCIDR(raw string) (string, error) {
+	raw = strings.TrimSpace(raw)
+	if !strings.Contains(raw, "/") {
+		if ip := net.ParseIP(raw); ip != nil {
+			if v4 := ip.To4(); v4 != nil {
+				return v4.String() + "/32", nil
+			}
+			return ip.String() + "/128", nil
+		}
+		return "", fmt.Errorf("invalid")
+	}
+	_, ipNet, err := net.ParseCIDR(raw)
+	if err != nil {
+		return "", err
+	}
+	return ipNet.String(), nil
+}
+
+func cidrTo4(cidr string) net.IP {
+	ip, _, err := net.ParseCIDR(cidr)
+	if err != nil {
+		return nil
+	}
+	return ip.To4()
+}
+
+func (s *Service) loadGenerateOptions(ctx context.Context) nginx.GenerateOptions {
+	opts := nginx.GenerateOptions{}
+	raw, _ := s.settings.Get(ctx, settings.KeyTrustedProxy)
+	opts.TrustedProxy = nginx.ParseTrustedProxyJSON(raw)
+	blRaw, _ := s.settings.Get(ctx, settings.KeyGlobalIPBlacklist)
+	opts.GlobalIPBlacklist = ParseGlobalIPList(blRaw)
+	wlRaw, _ := s.settings.Get(ctx, settings.KeyGlobalIPWhitelist)
+	opts.GlobalIPWhitelist = ParseGlobalIPList(wlRaw)
+	return opts
+}
+
+func ParseGlobalIPList(raw string) []string {
+	raw = strings.TrimSpace(raw)
+	if raw == "" || raw == "[]" {
+		return nil
+	}
+	var list []string
+	if err := json.Unmarshal([]byte(raw), &list); err != nil {
+		return nil
+	}
+	return list
+}
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..5279847
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,161 @@
+package config
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"strconv"
+)
+
+type Config struct {
+	ListenAddr            string
+	DataDir               string
+	SessionSecret         string
+	NginxBin              string
+	NginxPIDFile          string
+	NginxMimeTypes        string
+	NginxDefaultHTTPPort  int
+	NginxDefaultHTTPSPort int
+	FrpcBin               string
+	FrpPIDFile            string
+}
+
+func Load() Config {
+	dataDir := envOr("FONU_DATA_DIR", defaultDataDir())
+	if abs, err := filepath.Abs(dataDir); err == nil {
+		dataDir = abs
+	}
+	return Config{
+		ListenAddr:            envOr("FONU_LISTEN", ":6893"),
+		DataDir:               dataDir,
+		SessionSecret:         envOr("FONU_SESSION_SECRET", "change-me-in-production"),
+		NginxBin:              envOr("FONU_NGINX_BIN", "nginx"),
+		NginxPIDFile:          envOr("FONU_NGINX_PID", dataDir+"/nginx/nginx.pid"),
+		NginxMimeTypes:        envOr("FONU_NGINX_MIME_TYPES", defaultMimeTypes()),
+		NginxDefaultHTTPPort:  envIntOr("FONU_NGINX_HTTP_PORT", 80),
+		NginxDefaultHTTPSPort: envIntOr("FONU_NGINX_HTTPS_PORT", 443),
+		FrpcBin:               envOr("FONU_FRPC_BIN", "frpc"),
+		FrpPIDFile:            envOr("FONU_FRP_PID", dataDir+"/frp/frpc.pid"),
+	}
+}
+
+func (c Config) DefaultListenPort(httpsEnabled bool) int {
+	if httpsEnabled {
+		return c.NginxDefaultHTTPSPort
+	}
+	return c.NginxDefaultHTTPPort
+}
+
+func (c Config) DBPath() string {
+	return c.DataDir + "/fonu.db"
+}
+
+func (c Config) NginxDir() string {
+	return c.DataDir + "/nginx"
+}
+
+func (c Config) NginxConfigPath() string {
+	return c.NginxDir() + "/nginx.conf"
+}
+
+func (c Config) NginxCustomDir() string {
+	return c.NginxDir() + "/custom"
+}
+
+func (c Config) NginxGlobalCustomPath() string {
+	return c.NginxCustomDir() + "/global.conf"
+}
+
+func (c Config) NginxRulesDir() string {
+	return c.NginxDir() + "/rules"
+}
+
+func (c Config) NginxRuleCustomPath(ruleID int64) string {
+	return filepath.Join(c.NginxRulesDir(), fmt.Sprintf("rule_%d.conf", ruleID))
+}
+
+func (c Config) NginxBackupsDir() string {
+	return c.NginxDir() + "/backups"
+}
+
+func (c Config) NginxGlobalBackupsDir() string {
+	return c.NginxBackupsDir() + "/global"
+}
+
+func (c Config) NginxRuleBackupsDir(ruleID int64) string {
+	return filepath.Join(c.NginxBackupsDir(), "rules", fmt.Sprintf("%d", ruleID))
+}
+
+func (c Config) LogsDir() string {
+	return c.DataDir + "/logs"
+}
+
+func (c Config) CertsDir() string {
+	return c.DataDir + "/certs"
+}
+
+func (c Config) ErrorsDir() string {
+	return c.NginxDir() + "/errors"
+}
+
+func (c Config) ChinaCIDRPath() string {
+	return c.NginxDir() + "/china_cidr.conf"
+}
+
+func (c Config) ChinaCIDRTempPath() string {
+	return c.NginxDir() + "/china_cidr.conf.tmp"
+}
+
+func (c Config) FrpDir() string {
+	return c.DataDir + "/frp"
+}
+
+func (c Config) FrpConfigPath() string {
+	return c.FrpDir() + "/frpc.toml"
+}
+
+func (c Config) FrpLogPath() string {
+	return filepath.Join(c.LogsDir(), "frpc.log")
+}
+
+func defaultDataDir() string {
+	if _, err := os.Stat("/data"); err == nil {
+		return "/data"
+	}
+	if cwd, err := os.Getwd(); err == nil {
+		return filepath.Join(cwd, ".data")
+	}
+	return "/data"
+}
+
+func envOr(key, fallback string) string {
+	if v := os.Getenv(key); v != "" {
+		return v
+	}
+	return fallback
+}
+
+func defaultMimeTypes() string {
+	candidates := []string{
+		"/etc/nginx/mime.types",
+		"deploy/nginx/mime.types",
+	}
+	for _, path := range candidates {
+		if _, err := os.Stat(path); err == nil {
+			return path
+		}
+	}
+	return "/etc/nginx/mime.types"
+}
+
+func envIntOr(key string, fallback int) int {
+	v := os.Getenv(key)
+	if v == "" {
+		return fallback
+	}
+	n, err := strconv.Atoi(v)
+	if err != nil {
+		return fallback
+	}
+	return n
+}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
new file mode 100644
index 0000000..54e38f7
--- /dev/null
+++ b/internal/config/config_test.go
@@ -0,0 +1,35 @@
+package config
+
+import (
+	"os"
+	"testing"
+)
+
+func TestLoadNginxDefaultPorts(t *testing.T) {
+	t.Setenv("FONU_NGINX_HTTP_PORT", "18080")
+	t.Setenv("FONU_NGINX_HTTPS_PORT", "9443")
+
+	cfg := Load()
+	if cfg.NginxDefaultHTTPPort != 18080 {
+		t.Fatalf("http port %d", cfg.NginxDefaultHTTPPort)
+	}
+	if cfg.NginxDefaultHTTPSPort != 9443 {
+		t.Fatalf("https port %d", cfg.NginxDefaultHTTPSPort)
+	}
+	if cfg.DefaultListenPort(false) != 18080 {
+		t.Fatalf("default http listen %d", cfg.DefaultListenPort(false))
+	}
+	if cfg.DefaultListenPort(true) != 9443 {
+		t.Fatalf("default https listen %d", cfg.DefaultListenPort(true))
+	}
+}
+
+func TestLoadNginxDefaultPortsFallback(t *testing.T) {
+	os.Unsetenv("FONU_NGINX_HTTP_PORT")
+	os.Unsetenv("FONU_NGINX_HTTPS_PORT")
+
+	cfg := Load()
+	if cfg.NginxDefaultHTTPPort != 80 || cfg.NginxDefaultHTTPSPort != 443 {
+		t.Fatalf("ports %d %d", cfg.NginxDefaultHTTPPort, cfg.NginxDefaultHTTPSPort)
+	}
+}
diff --git a/internal/db/db.go b/internal/db/db.go
new file mode 100644
index 0000000..775beae
--- /dev/null
+++ b/internal/db/db.go
@@ -0,0 +1,113 @@
+package db
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+
+	_ "modernc.org/sqlite"
+)
+
+func Open(path string) (*sql.DB, error) {
+	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+		return nil, err
+	}
+
+	dsn := fmt.Sprintf("file:%s?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)", path)
+	conn, err := sql.Open("sqlite", dsn)
+	if err != nil {
+		return nil, err
+	}
+	conn.SetMaxOpenConns(1)
+	return conn, nil
+}
+
+func Migrate(ctx context.Context, conn *sql.DB, migrationsDir string) error {
+	if err := ensureMigrationsTable(ctx, conn); err != nil {
+		return err
+	}
+
+	entries, err := os.ReadDir(migrationsDir)
+	if err != nil {
+		return err
+	}
+
+	var files []string
+	for _, e := range entries {
+		if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
+			continue
+		}
+		files = append(files, e.Name())
+	}
+	sort.Strings(files)
+
+	for _, name := range files {
+		version, err := parseVersion(name)
+		if err != nil {
+			return err
+		}
+		applied, err := isApplied(ctx, conn, version)
+		if err != nil {
+			return err
+		}
+		if applied {
+			continue
+		}
+
+		content, err := os.ReadFile(filepath.Join(migrationsDir, name))
+		if err != nil {
+			return err
+		}
+
+		tx, err := conn.BeginTx(ctx, nil)
+		if err != nil {
+			return err
+		}
+
+		if _, err := tx.ExecContext(ctx, string(content)); err != nil {
+			_ = tx.Rollback()
+			return fmt.Errorf("migration %s failed: %w", name, err)
+		}
+		if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version) VALUES (?)`, version); err != nil {
+			_ = tx.Rollback()
+			return fmt.Errorf("record migration %s failed: %w", name, err)
+		}
+		if err := tx.Commit(); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func ensureMigrationsTable(ctx context.Context, conn *sql.DB) error {
+	_, err := conn.ExecContext(ctx, `
+		CREATE TABLE IF NOT EXISTS schema_migrations (
+			version INTEGER PRIMARY KEY,
+			applied_at TEXT NOT NULL DEFAULT (datetime('now'))
+		)
+	`)
+	return err
+}
+
+func parseVersion(name string) (int, error) {
+	parts := strings.SplitN(name, "_", 2)
+	if len(parts) == 0 {
+		return 0, fmt.Errorf("invalid migration filename: %s", name)
+	}
+	var version int
+	if _, err := fmt.Sscanf(parts[0], "%d", &version); err != nil {
+		return 0, fmt.Errorf("invalid migration version in %s: %w", name, err)
+	}
+	return version, nil
+}
+
+func isApplied(ctx context.Context, conn *sql.DB, version int) (bool, error) {
+	var count int
+	err := conn.QueryRowContext(ctx, `SELECT COUNT(1) FROM schema_migrations WHERE version = ?`, version).Scan(&count)
+	return count > 0, err
+}
diff --git a/internal/db/db_test.go b/internal/db/db_test.go
new file mode 100644
index 0000000..2ac2197
--- /dev/null
+++ b/internal/db/db_test.go
@@ -0,0 +1,24 @@
+package db
+
+import (
+	"context"
+	"path/filepath"
+	"testing"
+)
+
+func TestMigrate(t *testing.T) {
+	dir := t.TempDir()
+	conn, err := Open(filepath.Join(dir, "fonu.db"))
+	if err != nil {
+		t.Fatalf("open db: %v", err)
+	}
+	defer conn.Close()
+
+	migrationsDir := filepath.Join("..", "..", "migrations")
+	if err := Migrate(context.Background(), conn, migrationsDir); err != nil {
+		t.Fatalf("migrate failed: %v", err)
+	}
+	if err := Migrate(context.Background(), conn, migrationsDir); err != nil {
+		t.Fatalf("second migrate failed: %v", err)
+	}
+}
diff --git a/internal/ddns/alidns.go b/internal/ddns/alidns.go
new file mode 100644
index 0000000..76a89d3
--- /dev/null
+++ b/internal/ddns/alidns.go
@@ -0,0 +1,244 @@
+package ddns
+
+import (
+	"context"
+	"crypto/hmac"
+	"crypto/sha1"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"sort"
+	"strings"
+	"time"
+)
+
+const alidnsEndpoint = "https://alidns.aliyuncs.com/"
+
+type AliDNS struct {
+	client *http.Client
+}
+
+func NewAliDNS() *AliDNS {
+	return &AliDNS{client: &http.Client{Timeout: 15 * time.Second}}
+}
+
+func (a *AliDNS) Name() string { return "alidns" }
+
+func (a *AliDNS) Verify(ctx context.Context, cred Credentials) error {
+	_, err := a.request(ctx, cred, map[string]string{
+		"Action":     "DescribeDomains",
+		"PageNumber": "1",
+		"PageSize":   "1",
+	})
+	return err
+}
+
+func (a *AliDNS) HasZone(ctx context.Context, cred Credentials, zone string) (bool, error) {
+	body, err := a.request(ctx, cred, map[string]string{
+		"Action":     "DescribeDomains",
+		"KeyWord":    zone,
+		"PageNumber": "1",
+		"PageSize":   "50",
+	})
+	if err != nil {
+		ok, lookupErr := zoneLookupOK(err)
+		return ok, lookupErr
+	}
+	var resp alidnsDomainsResponse
+	if err := json.Unmarshal(body, &resp); err != nil {
+		return false, err
+	}
+	zone = strings.ToLower(strings.TrimSpace(zone))
+	for _, item := range resp.Domains.Domain {
+		if strings.EqualFold(item.DomainName, zone) {
+			return true, nil
+		}
+	}
+	return false, nil
+}
+
+func (a *AliDNS) GetRecordIP(ctx context.Context, cred Credentials, rootDomain, recordName, recordType string) (string, error) {
+	subDomain := aliRR(recordName)
+	body, err := a.request(ctx, cred, map[string]string{
+		"Action":     "DescribeDomainRecords",
+		"DomainName": rootDomain,
+		"RRKeyWord":  subDomain,
+		"Type":       recordType,
+		"PageNumber": "1",
+		"PageSize":   "50",
+	})
+	if err != nil {
+		return "", err
+	}
+	var resp alidnsRecordsResponse
+	if err := json.Unmarshal(body, &resp); err != nil {
+		return "", err
+	}
+	for _, rec := range resp.DomainRecords.Record {
+		if strings.EqualFold(rec.Type, recordType) && rec.RR == subDomain {
+			return rec.Value, nil
+		}
+	}
+	return "", nil
+}
+
+func (a *AliDNS) UpdateRecord(ctx context.Context, cred Credentials, rootDomain, recordName, recordType, ip string) error {
+	subDomain := aliRR(recordName)
+	existingID, err := a.findRecordID(ctx, cred, rootDomain, subDomain, recordType)
+	if err != nil {
+		return err
+	}
+	if existingID == "" {
+		_, err = a.request(ctx, cred, map[string]string{
+			"Action":     "AddDomainRecord",
+			"DomainName": rootDomain,
+			"RR":         subDomain,
+			"Type":       recordType,
+			"Value":      ip,
+			"TTL":        "600",
+		})
+		return err
+	}
+	_, err = a.request(ctx, cred, map[string]string{
+		"Action":   "UpdateDomainRecord",
+		"RecordId": existingID,
+		"RR":       subDomain,
+		"Type":     recordType,
+		"Value":    ip,
+		"TTL":      "600",
+	})
+	return err
+}
+
+func (a *AliDNS) findRecordID(ctx context.Context, cred Credentials, rootDomain, subDomain, recordType string) (string, error) {
+	body, err := a.request(ctx, cred, map[string]string{
+		"Action":     "DescribeDomainRecords",
+		"DomainName": rootDomain,
+		"RRKeyWord":  subDomain,
+		"Type":       recordType,
+		"PageNumber": "1",
+		"PageSize":   "50",
+	})
+	if err != nil {
+		return "", err
+	}
+	var resp alidnsRecordsResponse
+	if err := json.Unmarshal(body, &resp); err != nil {
+		return "", err
+	}
+	for _, rec := range resp.DomainRecords.Record {
+		if strings.EqualFold(rec.Type, recordType) && rec.RR == subDomain {
+			return rec.RecordID, nil
+		}
+	}
+	return "", nil
+}
+
+func aliRR(recordName string) string {
+	subDomain := subDomainForRecord(recordName)
+	if subDomain == "@" {
+		return ""
+	}
+	return subDomain
+}
+
+func (a *AliDNS) request(ctx context.Context, cred Credentials, actionParams map[string]string) ([]byte, error) {
+	if cred.Token == "" || cred.Secret == "" {
+		return nil, fmt.Errorf("阿里云 DNS 凭证未配置")
+	}
+	query := map[string]string{
+		"Format":           "JSON",
+		"Version":          "2015-01-09",
+		"AccessKeyId":      cred.Token,
+		"SignatureMethod":  "HMAC-SHA1",
+		"Timestamp":        time.Now().UTC().Format("2006-01-02T15:04:05Z"),
+		"SignatureVersion": "1.0",
+		"SignatureNonce":   fmt.Sprintf("%d", time.Now().UnixNano()),
+	}
+	for k, v := range actionParams {
+		query[k] = v
+	}
+	query["Signature"] = signAliyun(query, cred.Secret)
+
+	values := url.Values{}
+	for k, v := range query {
+		values.Set(k, v)
+	}
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, alidnsEndpoint+"?"+values.Encode(), nil)
+	if err != nil {
+		return nil, err
+	}
+	resp, err := a.client.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+	body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+	if err != nil {
+		return nil, err
+	}
+	var envelope alidnsEnvelope
+	if err := json.Unmarshal(body, &envelope); err == nil && envelope.Code != "" {
+		return nil, fmt.Errorf("阿里云 DNS API 错误:%s", envelope.Message)
+	}
+	return body, nil
+}
+
+func signAliyun(params map[string]string, secret string) string {
+	keys := make([]string, 0, len(params))
+	for k := range params {
+		if k == "Signature" {
+			continue
+		}
+		keys = append(keys, k)
+	}
+	sort.Strings(keys)
+	var canonical strings.Builder
+	for i, k := range keys {
+		if i > 0 {
+			canonical.WriteByte('&')
+		}
+		canonical.WriteString(percentEncode(k))
+		canonical.WriteByte('=')
+		canonical.WriteString(percentEncode(params[k]))
+	}
+	stringToSign := "GET&%2F&" + percentEncode(canonical.String())
+	mac := hmac.New(sha1.New, []byte(secret+"&"))
+	mac.Write([]byte(stringToSign))
+	return base64.StdEncoding.EncodeToString(mac.Sum(nil))
+}
+
+func percentEncode(s string) string {
+	encoded := url.QueryEscape(s)
+	encoded = strings.ReplaceAll(encoded, "+", "%20")
+	encoded = strings.ReplaceAll(encoded, "*", "%2A")
+	encoded = strings.ReplaceAll(encoded, "%7E", "~")
+	return encoded
+}
+
+type alidnsEnvelope struct {
+	Code    string `json:"Code"`
+	Message string `json:"Message"`
+}
+
+type alidnsRecordsResponse struct {
+	DomainRecords struct {
+		Record []struct {
+			RecordID string `json:"RecordId"`
+			RR       string `json:"RR"`
+			Type     string `json:"Type"`
+			Value    string `json:"Value"`
+		} `json:"Record"`
+	} `json:"DomainRecords"`
+}
+
+type alidnsDomainsResponse struct {
+	Domains struct {
+		Domain []struct {
+			DomainName string `json:"DomainName"`
+		} `json:"Domain"`
+	} `json:"Domains"`
+}
diff --git a/internal/ddns/cloudflare.go b/internal/ddns/cloudflare.go
new file mode 100644
index 0000000..4333df2
--- /dev/null
+++ b/internal/ddns/cloudflare.go
@@ -0,0 +1,164 @@
+package ddns
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"time"
+)
+
+type Cloudflare struct {
+	client *http.Client
+}
+
+func NewCloudflare() *Cloudflare {
+	return &Cloudflare{client: &http.Client{Timeout: 15 * time.Second}}
+}
+
+func (c *Cloudflare) Name() string { return "cloudflare" }
+
+func (c *Cloudflare) Verify(ctx context.Context, cred Credentials) error {
+	_, err := c.request(ctx, cred.Token, http.MethodGet, "https://api.cloudflare.com/client/v4/user/tokens/verify", nil)
+	return err
+}
+
+func (c *Cloudflare) HasZone(ctx context.Context, cred Credentials, zone string) (bool, error) {
+	_, err := c.zoneID(ctx, cred.Token, zone)
+	return zoneLookupOK(err)
+}
+
+func (c *Cloudflare) GetRecordIP(ctx context.Context, cred Credentials, rootDomain, recordName, recordType string) (string, error) {
+	zoneID, err := c.zoneID(ctx, cred.Token, rootDomain)
+	if err != nil {
+		return "", err
+	}
+	fqdn := fqdnForRecord(rootDomain, recordName)
+	body, err := c.request(ctx, cred.Token, http.MethodGet, fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records?type=%s&name=%s", zoneID, recordType, fqdn), nil)
+	if err != nil {
+		return "", err
+	}
+	var resp cfRecordsResponse
+	if err := json.Unmarshal(body, &resp); err != nil {
+		return "", err
+	}
+	if len(resp.Result) == 0 {
+		return "", nil
+	}
+	return resp.Result[0].Content, nil
+}
+
+func (c *Cloudflare) UpdateRecord(ctx context.Context, cred Credentials, rootDomain, recordName, recordType, ip string) error {
+	zoneID, err := c.zoneID(ctx, cred.Token, rootDomain)
+	if err != nil {
+		return err
+	}
+	fqdn := fqdnForRecord(rootDomain, recordName)
+	existingID, _, err := c.findRecord(ctx, cred.Token, zoneID, recordType, fqdn)
+	if err != nil {
+		return err
+	}
+	payload := map[string]any{
+		"type":    recordType,
+		"name":    fqdn,
+		"content": ip,
+		"ttl":     120,
+		"proxied": false,
+	}
+	if existingID == "" {
+		_, err = c.request(ctx, cred.Token, http.MethodPost, fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records", zoneID), payload)
+		return err
+	}
+	_, err = c.request(ctx, cred.Token, http.MethodPut, fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, existingID), payload)
+	return err
+}
+
+func (c *Cloudflare) zoneID(ctx context.Context, token, rootDomain string) (string, error) {
+	body, err := c.request(ctx, token, http.MethodGet, fmt.Sprintf("https://api.cloudflare.com/client/v4/zones?name=%s", rootDomain), nil)
+	if err != nil {
+		return "", err
+	}
+	var resp cfZonesResponse
+	if err := json.Unmarshal(body, &resp); err != nil {
+		return "", err
+	}
+	if len(resp.Result) == 0 {
+		return "", fmt.Errorf("未找到域名 %s 对应的 Cloudflare Zone", rootDomain)
+	}
+	return resp.Result[0].ID, nil
+}
+
+func (c *Cloudflare) findRecord(ctx context.Context, token, zoneID, recordType, fqdn string) (string, string, error) {
+	body, err := c.request(ctx, token, http.MethodGet, fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records?type=%s&name=%s", zoneID, recordType, fqdn), nil)
+	if err != nil {
+		return "", "", err
+	}
+	var resp cfRecordsResponse
+	if err := json.Unmarshal(body, &resp); err != nil {
+		return "", "", err
+	}
+	if len(resp.Result) == 0 {
+		return "", "", nil
+	}
+	return resp.Result[0].ID, resp.Result[0].Content, nil
+}
+
+func (c *Cloudflare) request(ctx context.Context, token, method, url string, payload any) ([]byte, error) {
+	var body io.Reader
+	if payload != nil {
+		raw, err := json.Marshal(payload)
+		if err != nil {
+			return nil, err
+		}
+		body = bytes.NewReader(raw)
+	}
+	req, err := http.NewRequestWithContext(ctx, method, url, body)
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Authorization", "Bearer "+token)
+	req.Header.Set("Content-Type", "application/json")
+	resp, err := c.client.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+	raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+	if err != nil {
+		return nil, err
+	}
+	var envelope cfEnvelope
+	if err := json.Unmarshal(raw, &envelope); err != nil {
+		return nil, err
+	}
+	if !envelope.Success {
+		if len(envelope.Errors) > 0 {
+			return nil, fmt.Errorf("Cloudflare API 错误:%s", envelope.Errors[0].Message)
+		}
+		return nil, fmt.Errorf("Cloudflare API 请求失败")
+	}
+	return raw, nil
+}
+
+type cfEnvelope struct {
+	Success bool `json:"success"`
+	Errors  []struct {
+		Message string `json:"message"`
+	} `json:"errors"`
+}
+
+type cfZonesResponse struct {
+	Result []struct {
+		ID   string `json:"id"`
+		Name string `json:"name"`
+	} `json:"result"`
+}
+
+type cfRecordsResponse struct {
+	Result []struct {
+		ID      string `json:"id"`
+		Content string `json:"content"`
+	} `json:"result"`
+}
diff --git a/internal/ddns/credentials.go b/internal/ddns/credentials.go
new file mode 100644
index 0000000..759d2f5
--- /dev/null
+++ b/internal/ddns/credentials.go
@@ -0,0 +1,96 @@
+package ddns
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+)
+
+type Credentials struct {
+	Provider string `json:"provider,omitempty"`
+	Token    string `json:"token,omitempty"`
+	TokenID  string `json:"token_id,omitempty"`
+	Secret   string `json:"secret,omitempty"`
+}
+
+func CredentialsFromSave(in SaveInput) Credentials {
+	provider := strings.TrimSpace(in.Provider)
+	if provider == "" {
+		provider = "cloudflare"
+	}
+	switch provider {
+	case "dnspod":
+		return Credentials{Provider: "dnspod", TokenID: strings.TrimSpace(in.APITokenID), Token: strings.TrimSpace(in.APIToken)}
+	case "alidns":
+		return Credentials{Provider: "alidns", Token: strings.TrimSpace(in.APIToken), Secret: strings.TrimSpace(in.APISecret)}
+	case "tencentcloud":
+		return Credentials{Provider: "tencentcloud", Token: strings.TrimSpace(in.APIToken), Secret: strings.TrimSpace(in.APISecret)}
+	case "volcengine":
+		return Credentials{Provider: "volcengine", Token: strings.TrimSpace(in.APIToken), Secret: strings.TrimSpace(in.APISecret)}
+	default:
+		return Credentials{Provider: "cloudflare", Token: strings.TrimSpace(in.APIToken)}
+	}
+}
+
+func (c Credentials) HasValues() bool {
+	switch c.Provider {
+	case "dnspod":
+		return c.TokenID != "" && c.Token != ""
+	case "alidns", "tencentcloud", "volcengine":
+		return c.Token != "" && c.Secret != ""
+	default:
+		return c.Token != ""
+	}
+}
+
+func (c Credentials) Validate(provider string) error {
+	switch provider {
+	case "dnspod":
+		if c.TokenID == "" || c.Token == "" {
+			return fmt.Errorf("请填写 DNSPod ID 和 Token")
+		}
+	case "alidns":
+		if c.Token == "" || c.Secret == "" {
+			return fmt.Errorf("请填写阿里云 AccessKey ID 和 Secret")
+		}
+	case "tencentcloud":
+		if c.Token == "" || c.Secret == "" {
+			return fmt.Errorf("请填写腾讯云 SecretId 和 SecretKey")
+		}
+	case "volcengine":
+		if c.Token == "" || c.Secret == "" {
+			return fmt.Errorf("请填写火山引擎 AccessKey ID 和 Secret Access Key")
+		}
+	default:
+		if c.Token == "" {
+			return fmt.Errorf("请填写 API Token")
+		}
+	}
+	return nil
+}
+
+func (c Credentials) LoginToken() string {
+	return c.TokenID + "," + c.Token
+}
+
+func (c Credentials) Marshal() ([]byte, error) {
+	return json.Marshal(c)
+}
+
+func ParseCredentials(raw string) (Credentials, error) {
+	raw = strings.TrimSpace(raw)
+	if raw == "" {
+		return Credentials{}, fmt.Errorf("凭证未配置")
+	}
+	if strings.HasPrefix(raw, "{") {
+		var cred Credentials
+		if err := json.Unmarshal([]byte(raw), &cred); err != nil {
+			return Credentials{}, err
+		}
+		if cred.Provider == "" {
+			cred.Provider = "cloudflare"
+		}
+		return cred, nil
+	}
+	return Credentials{Provider: "cloudflare", Token: raw}, nil
+}
diff --git a/internal/ddns/credentials_test.go b/internal/ddns/credentials_test.go
new file mode 100644
index 0000000..0f92f87
--- /dev/null
+++ b/internal/ddns/credentials_test.go
@@ -0,0 +1,29 @@
+package ddns
+
+import "testing"
+
+func TestParseCredentialsLegacyToken(t *testing.T) {
+	cred, err := ParseCredentials("cf-token")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if cred.Provider != "cloudflare" || cred.Token != "cf-token" {
+		t.Fatalf("unexpected cred: %+v", cred)
+	}
+}
+
+func TestCredentialsLoginToken(t *testing.T) {
+	cred := Credentials{TokenID: "123", Token: "abc"}
+	if cred.LoginToken() != "123,abc" {
+		t.Fatal("login token mismatch")
+	}
+}
+
+func TestFqdnForRecord(t *testing.T) {
+	if fqdnForRecord("example.com", "*") != "*.example.com" {
+		t.Fatal("wildcard fqdn mismatch")
+	}
+	if fqdnForRecord("example.com", "@") != "example.com" {
+		t.Fatal("apex fqdn mismatch")
+	}
+}
diff --git a/internal/ddns/dnspod.go b/internal/ddns/dnspod.go
new file mode 100644
index 0000000..e93ba39
--- /dev/null
+++ b/internal/ddns/dnspod.go
@@ -0,0 +1,105 @@
+package ddns
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/nrdcg/dnspod-go"
+)
+
+type DNSPod struct {
+	client *http.Client
+}
+
+func NewDNSPod() *DNSPod {
+	return &DNSPod{client: &http.Client{Timeout: 15 * time.Second}}
+}
+
+func (d *DNSPod) Name() string { return "dnspod" }
+
+func (d *DNSPod) Verify(ctx context.Context, cred Credentials) error {
+	client := d.apiClient(cred)
+	_, _, err := client.Domains.List()
+	return err
+}
+
+func (d *DNSPod) HasZone(ctx context.Context, cred Credentials, zone string) (bool, error) {
+	_, err := d.domainID(d.apiClient(cred), zone)
+	return zoneLookupOK(err)
+}
+
+func (d *DNSPod) GetRecordIP(ctx context.Context, cred Credentials, rootDomain, recordName, recordType string) (string, error) {
+	client := d.apiClient(cred)
+	domainID, err := d.domainID(client, rootDomain)
+	if err != nil {
+		return "", err
+	}
+	subDomain := subDomainForRecord(recordName)
+	records, _, err := client.Records.List(domainID, subDomain)
+	if err != nil {
+		return "", err
+	}
+	for _, rec := range records {
+		if strings.EqualFold(rec.Type, recordType) {
+			return rec.Value, nil
+		}
+	}
+	return "", nil
+}
+
+func (d *DNSPod) UpdateRecord(ctx context.Context, cred Credentials, rootDomain, recordName, recordType, ip string) error {
+	client := d.apiClient(cred)
+	domainID, err := d.domainID(client, rootDomain)
+	if err != nil {
+		return err
+	}
+	subDomain := subDomainForRecord(recordName)
+	records, _, err := client.Records.List(domainID, subDomain)
+	if err != nil {
+		return err
+	}
+	for _, rec := range records {
+		if strings.EqualFold(rec.Type, recordType) {
+			_, _, err := client.Records.Update(domainID, rec.ID, dnspod.Record{
+				Type:  recordType,
+				Name:  subDomain,
+				Value: ip,
+				Line:  rec.Line,
+				TTL:   rec.TTL,
+			})
+			return err
+		}
+	}
+	_, _, err = client.Records.Create(domainID, dnspod.Record{
+		Type:  recordType,
+		Name:  subDomain,
+		Value: ip,
+		Line:  "默认",
+		TTL:   strconv.Itoa(120),
+	})
+	return err
+}
+
+func (d *DNSPod) domainID(client *dnspod.Client, rootDomain string) (string, error) {
+	domains, _, err := client.Domains.List()
+	if err != nil {
+		return "", err
+	}
+	for _, domain := range domains {
+		if domain.Name == rootDomain {
+			return domain.ID.String(), nil
+		}
+	}
+	return "", fmt.Errorf("未找到域名 %s 对应的 DNSPod 域名", rootDomain)
+}
+
+func (d *DNSPod) apiClient(cred Credentials) *dnspod.Client {
+	params := dnspod.CommonParams{LoginToken: cred.LoginToken(), Format: "json"}
+	client := dnspod.NewClient(params)
+	client.HTTPClient = d.client
+	return client
+}
diff --git a/internal/ddns/dnsutil.go b/internal/ddns/dnsutil.go
new file mode 100644
index 0000000..a6089d7
--- /dev/null
+++ b/internal/ddns/dnsutil.go
@@ -0,0 +1,25 @@
+package ddns
+
+import "strings"
+
+func fqdnForRecord(rootDomain, recordName string) string {
+	recordName = strings.TrimSpace(recordName)
+	if recordName == "" || recordName == "@" {
+		return rootDomain
+	}
+	if strings.Contains(recordName, ".") {
+		return recordName
+	}
+	if recordName == "*" {
+		return "*." + rootDomain
+	}
+	return recordName + "." + rootDomain
+}
+
+func subDomainForRecord(recordName string) string {
+	recordName = strings.TrimSpace(recordName)
+	if recordName == "" || recordName == "@" {
+		return "@"
+	}
+	return recordName
+}
diff --git a/internal/ddns/domain_records.go b/internal/ddns/domain_records.go
new file mode 100644
index 0000000..8799144
--- /dev/null
+++ b/internal/ddns/domain_records.go
@@ -0,0 +1,63 @@
+package ddns
+
+import (
+	"encoding/json"
+	"strings"
+)
+
+type DomainRecord struct {
+	Domain  string `json:"domain"`
+	IPv4    string `json:"ipv4,omitempty"`
+	IPv6    string `json:"ipv6,omitempty"`
+	Status  string `json:"status"`
+	Message string `json:"message,omitempty"`
+}
+
+func EncodeDomainRecords(records []DomainRecord) string {
+	if len(records) == 0 {
+		return "[]"
+	}
+	b, err := json.Marshal(records)
+	if err != nil {
+		return "[]"
+	}
+	return string(b)
+}
+
+func DecodeDomainRecords(raw string) []DomainRecord {
+	raw = strings.TrimSpace(raw)
+	if raw == "" || raw == "[]" {
+		return nil
+	}
+	var records []DomainRecord
+	if err := json.Unmarshal([]byte(raw), &records); err != nil {
+		return nil
+	}
+	return records
+}
+
+func BuildDomainRecords(cfg Config) []DomainRecord {
+	stored := cfg.DomainRecords
+	byDomain := make(map[string]DomainRecord, len(stored))
+	for _, rec := range stored {
+		byDomain[strings.ToLower(rec.Domain)] = rec
+	}
+
+	lines := cfg.DomainLines()
+	out := make([]DomainRecord, 0, len(lines))
+	for _, domain := range lines {
+		key := strings.ToLower(domain)
+		if rec, ok := byDomain[key]; ok {
+			rec.Domain = domain
+			out = append(out, rec)
+			continue
+		}
+		rec := DomainRecord{Domain: domain, Status: "unknown"}
+		if cfg.LastStatus != "" && cfg.LastStatus != "unknown" {
+			rec.Status = cfg.LastStatus
+			rec.Message = cfg.LastError
+		}
+		out = append(out, rec)
+	}
+	return out
+}
diff --git a/internal/ddns/managed_zones_test.go b/internal/ddns/managed_zones_test.go
new file mode 100644
index 0000000..614c6b8
--- /dev/null
+++ b/internal/ddns/managed_zones_test.go
@@ -0,0 +1,20 @@
+package ddns
+
+import "testing"
+
+func TestManagedDNSZonesMultiRoot(t *testing.T) {
+	cfg := Config{
+		RootDomain:  "roven.cc",
+		RecordNames: []string{"roven.cc", "chiak.cc"},
+	}
+	zones := cfg.ManagedDNSZones()
+	if len(zones) != 2 {
+		t.Fatalf("expected 2 zones, got %v", zones)
+	}
+	if !cfg.CoversDomain("chiak.cc") || !cfg.CoversDomain("*.chiak.cc") {
+		t.Fatal("expected chiak.cc coverage")
+	}
+	if cfg.CoversDomain("other.com") {
+		t.Fatal("unexpected coverage")
+	}
+}
diff --git a/internal/ddns/provider.go b/internal/ddns/provider.go
new file mode 100644
index 0000000..a03f942
--- /dev/null
+++ b/internal/ddns/provider.go
@@ -0,0 +1,11 @@
+package ddns
+
+import "context"
+
+type Provider interface {
+	Name() string
+	Verify(ctx context.Context, cred Credentials) error
+	HasZone(ctx context.Context, cred Credentials, zone string) (bool, error)
+	UpdateRecord(ctx context.Context, cred Credentials, rootDomain, recordName, recordType, ip string) error
+	GetRecordIP(ctx context.Context, cred Credentials, rootDomain, recordName, recordType string) (string, error)
+}
diff --git a/internal/ddns/records.go b/internal/ddns/records.go
new file mode 100644
index 0000000..f47fc00
--- /dev/null
+++ b/internal/ddns/records.go
@@ -0,0 +1,319 @@
+package ddns
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+)
+
+func ValidateDomainFormat(domain string) error {
+	domain = strings.TrimSpace(strings.ToLower(strings.TrimSuffix(domain, ".")))
+	if domain == "" {
+		return fmt.Errorf("域名不能为空")
+	}
+	if strings.HasPrefix(domain, "*.") {
+		domain = strings.TrimPrefix(domain, "*.")
+	}
+	parts := strings.Split(domain, ".")
+	if len(parts) < 2 {
+		return fmt.Errorf("请填写完整域名,例如 s.example.com")
+	}
+	for _, part := range parts {
+		if part == "" {
+			return fmt.Errorf("域名格式无效")
+		}
+		if len(part) > 63 {
+			return fmt.Errorf("域名标签过长")
+		}
+		for i := 0; i < len(part); i++ {
+			c := part[i]
+			if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' {
+				return fmt.Errorf("域名包含非法字符")
+			}
+		}
+		if part[0] == '-' || part[len(part)-1] == '-' {
+			return fmt.Errorf("域名标签不能以连字符开头或结尾")
+		}
+	}
+	return nil
+}
+
+func FQDNsFromSaveInput(in SaveInput) ([]string, error) {
+	var names []string
+	var err error
+	if len(in.Domains) > 0 {
+		_, names, err = ParseDomainLines(in.Domains)
+		if err != nil {
+			return nil, err
+		}
+	} else {
+		root := strings.ToLower(strings.TrimSpace(in.RootDomain))
+		if root == "" {
+			return nil, fmt.Errorf("至少需要一个域名")
+		}
+		recordNames, err := ParseRecordNames(in.RecordNames, in.RecordName)
+		if err != nil {
+			return nil, err
+		}
+		names = FormatDomainLines(root, recordNames)
+	}
+	for _, name := range names {
+		if err := ValidateDomainFormat(name); err != nil {
+			return nil, fmt.Errorf("%s:%s", name, err.Error())
+		}
+	}
+	return names, nil
+}
+
+func ParseDomainLines(lines []string) (rootDomain string, recordNames []string, err error) {
+	seen := make(map[string]struct{})
+	names := make([]string, 0, len(lines))
+	for _, line := range lines {
+		root, record, lineErr := parseDomainLine(line)
+		if lineErr != nil {
+			return "", nil, lineErr
+		}
+		if root == "" {
+			continue
+		}
+		fqdn := FormatDomainLine(root, record)
+		if _, ok := seen[fqdn]; ok {
+			return "", nil, fmt.Errorf("域名 %s 重复", fqdn)
+		}
+		seen[fqdn] = struct{}{}
+		if rootDomain == "" {
+			rootDomain = root
+		}
+		names = append(names, fqdn)
+	}
+	if len(names) == 0 {
+		return "", nil, fmt.Errorf("至少需要一个域名")
+	}
+	return rootDomain, names, nil
+}
+
+func ResolveDomainTarget(configRoot, name string) (rootDomain, recordName string) {
+	name = strings.TrimSpace(strings.ToLower(name))
+	if strings.Contains(name, ".") {
+		if root, record, err := parseDomainLine(name); err == nil {
+			return root, record
+		}
+	}
+	return strings.ToLower(strings.TrimSpace(configRoot)), name
+}
+
+func parseDomainLine(line string) (rootDomain, recordName string, err error) {
+	line = strings.TrimSpace(strings.ToLower(line))
+	if line == "" {
+		return "", "", nil
+	}
+	if idx := strings.Index(line, ":"); idx > 0 {
+		host := line[:idx]
+		if strings.Count(host, ":") <= 1 {
+			line = host
+		}
+	}
+	line = strings.TrimSuffix(line, ".")
+	if strings.HasPrefix(line, "*.") {
+		root := strings.TrimPrefix(line, "*.")
+		if root == "" || !strings.Contains(root, ".") {
+			return "", "", fmt.Errorf("域名格式无效: %s", line)
+		}
+		return root, "*", nil
+	}
+	parts := strings.Split(line, ".")
+	if len(parts) < 2 {
+		return "", "", fmt.Errorf("域名格式无效: %s", line)
+	}
+	rootDomain = parts[len(parts)-2] + "." + parts[len(parts)-1]
+	if len(parts) == 2 {
+		return rootDomain, "@", nil
+	}
+	recordName = strings.Join(parts[:len(parts)-2], ".")
+	return rootDomain, recordName, nil
+}
+
+func FormatDomainLine(rootDomain, recordName string) string {
+	recordName = strings.TrimSpace(strings.ToLower(recordName))
+	rootDomain = strings.TrimSpace(strings.ToLower(rootDomain))
+	if strings.Contains(recordName, ".") {
+		parts := strings.Split(recordName, ".")
+		if len(parts) >= 3 {
+			if root, record, err := parseDomainLine(recordName); err == nil {
+				return joinDomainLine(root, record)
+			}
+		}
+		if len(parts) == 2 {
+			if root, record, err := parseDomainLine(recordName); err == nil && record == "@" && recordName == root {
+				if rootDomain == "" || rootDomain == root {
+					return recordName
+				}
+			}
+		}
+	}
+	return joinDomainLine(rootDomain, recordName)
+}
+
+// canonicalFQDN returns name when it is already a complete FQDN (e.g. api.nas.example.com).
+func canonicalFQDN(name string) string {
+	name = strings.TrimSpace(strings.ToLower(strings.TrimSuffix(name, ".")))
+	if name == "" || name == "@" || !strings.Contains(name, ".") {
+		return ""
+	}
+	root, record, err := parseDomainLine(name)
+	if err != nil {
+		return name
+	}
+	fqdn := joinDomainLine(root, record)
+	if fqdn == name {
+		return name
+	}
+	return ""
+}
+
+func joinDomainLine(rootDomain, recordName string) string {
+	if recordName == "" || recordName == "@" {
+		return rootDomain
+	}
+	if recordName == "*" {
+		return "*." + rootDomain
+	}
+	return recordName + "." + rootDomain
+}
+
+func FormatDomainLines(rootDomain string, recordNames []string) []string {
+	out := make([]string, 0, len(recordNames))
+	for _, name := range recordNames {
+		if fqdn := canonicalFQDN(name); fqdn != "" {
+			out = append(out, fqdn)
+			continue
+		}
+		root, record := ResolveDomainTarget(rootDomain, name)
+		out = append(out, FormatDomainLine(root, record))
+	}
+	return out
+}
+
+func ParseRecordNames(recordNames []string, legacy string) ([]string, error) {
+	raw := recordNames
+	if len(raw) == 0 && strings.TrimSpace(legacy) != "" {
+		raw = strings.Split(legacy, "\n")
+	}
+	if len(raw) == 0 {
+		return nil, fmt.Errorf("至少需要一个子域名")
+	}
+
+	seen := make(map[string]struct{})
+	out := make([]string, 0, len(raw))
+	for _, item := range raw {
+		name := normalizeRecordName(item)
+		if name == "" {
+			continue
+		}
+		if _, ok := seen[name]; ok {
+			return nil, fmt.Errorf("子域名 %s 重复", name)
+		}
+		seen[name] = struct{}{}
+		out = append(out, name)
+	}
+	if len(out) == 0 {
+		return nil, fmt.Errorf("至少需要一个子域名")
+	}
+	return out, nil
+}
+
+func normalizeRecordName(raw string) string {
+	name := strings.TrimSpace(strings.ToLower(raw))
+	name = strings.TrimSuffix(name, ".")
+	if name == "" || name == "@" {
+		return "@"
+	}
+	if strings.HasPrefix(name, "*.") {
+		root := strings.TrimPrefix(name, "*.")
+		if root != "" {
+			return FormatDomainLine(root, "*")
+		}
+		return "*"
+	}
+	if strings.Contains(name, ".") {
+		root, record, err := parseDomainLine(name)
+		if err == nil {
+			return FormatDomainLine(root, record)
+		}
+	}
+	return name
+}
+
+func EncodeRecordNames(names []string) string {
+	if len(names) == 0 {
+		return "[]"
+	}
+	b, err := json.Marshal(names)
+	if err != nil {
+		return "[]"
+	}
+	return string(b)
+}
+
+func DecodeRecordNames(raw string, fallback string) []string {
+	raw = strings.TrimSpace(raw)
+	if raw != "" && raw != "[]" {
+		var names []string
+		if err := json.Unmarshal([]byte(raw), &names); err == nil && len(names) > 0 {
+			return names
+		}
+	}
+	if strings.TrimSpace(fallback) != "" {
+		return []string{normalizeRecordName(fallback)}
+	}
+	return nil
+}
+
+func (c Config) RecordNamesList() []string {
+	if len(c.RecordNames) > 0 {
+		return c.RecordNames
+	}
+	if names := DecodeRecordNames("", c.RecordName); len(names) > 0 {
+		return names
+	}
+	return []string{"@"}
+}
+
+func (c Config) DomainLines() []string {
+	return FormatDomainLines(c.RootDomain, c.RecordNamesList())
+}
+
+// ManagedDNSZones returns apex zones this DDNS credential can manage (for ACME DNS-01).
+func (c Config) ManagedDNSZones() []string {
+	seen := map[string]bool{}
+	var zones []string
+	add := func(zone string) {
+		zone = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(zone, "*.")))
+		if zone == "" || zone == "@" || seen[zone] {
+			return
+		}
+		seen[zone] = true
+		zones = append(zones, zone)
+	}
+	add(c.RootDomain)
+	for _, name := range c.RecordNamesList() {
+		fqdn := FQDNFromRecord(c, name)
+		add(fqdn)
+		root, record := ResolveDomainTarget(c.RootDomain, name)
+		add(root)
+		if record != "@" && record != "*" {
+			add(FormatDomainLine(root, record))
+		}
+	}
+	return zones
+}
+
+func (c Config) CoversDomain(domain string) bool {
+	domain = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(domain, "*.")))
+	for _, zone := range c.ManagedDNSZones() {
+		if domain == zone || strings.HasSuffix(domain, "."+zone) {
+			return true
+		}
+	}
+	return false
+}
diff --git a/internal/ddns/records_test.go b/internal/ddns/records_test.go
new file mode 100644
index 0000000..012ab76
--- /dev/null
+++ b/internal/ddns/records_test.go
@@ -0,0 +1,131 @@
+package ddns
+
+import "testing"
+
+func TestParseDomainLinesMultiple(t *testing.T) {
+	root, names, err := ParseDomainLines([]string{"s.example.com", "www.example.com", "example.com"})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if root != "example.com" || len(names) != 3 || names[0] != "s.example.com" || names[1] != "www.example.com" || names[2] != "example.com" {
+		t.Fatalf("unexpected: root=%s names=%v", root, names)
+	}
+}
+
+func TestParseDomainLinesAllowsMixedRoots(t *testing.T) {
+	root, names, err := ParseDomainLines([]string{"s.example.com", "api.example.com"})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if root != "example.com" || len(names) != 2 || names[1] != "api.example.com" {
+		t.Fatalf("unexpected: root=%s names=%v", root, names)
+	}
+}
+
+func TestParseDomainLinesWildcard(t *testing.T) {
+	root, names, err := ParseDomainLines([]string{"*.example.com"})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if root != "example.com" || names[0] != "*.example.com" {
+		t.Fatalf("unexpected: root=%s names=%v", root, names)
+	}
+}
+
+func TestFormatDomainLine(t *testing.T) {
+	if FormatDomainLine("example.com", "s") != "s.example.com" {
+		t.Fatal("subdomain format failed")
+	}
+	if FormatDomainLine("example.com", "@") != "example.com" {
+		t.Fatal("apex format failed")
+	}
+}
+
+func TestParseRecordNamesRejectsDuplicate(t *testing.T) {
+	_, err := ParseRecordNames([]string{"s", "s"}, "")
+	if err == nil {
+		t.Fatal("expected duplicate error")
+	}
+}
+
+func TestParseRecordNamesKeepsDelegatedSubdomainFQDN(t *testing.T) {
+	names, err := ParseRecordNames([]string{"s", "www.sub.example.com"}, "")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if names[0] != "s" || names[1] != "www.sub.example.com" {
+		t.Fatalf("unexpected names: %v", names)
+	}
+}
+
+func TestDomainLinesMixedRootDomains(t *testing.T) {
+	cfg := Config{
+		RootDomain:  "example.com",
+		RecordNames: []string{"s", "www.sub.example.com"},
+	}
+	lines := cfg.DomainLines()
+	if len(lines) != 2 || lines[0] != "s.example.com" || lines[1] != "www.sub.example.com" {
+		t.Fatalf("unexpected lines: %v", lines)
+	}
+}
+
+func TestFQDNFromRecordDelegatedSubdomainHost(t *testing.T) {
+	cfg := Config{RootDomain: "example.com"}
+	if got := FQDNFromRecord(cfg, "www.sub.example.com"); got != "www.sub.example.com" {
+		t.Fatalf("expected www.sub.example.com, got %s", got)
+	}
+}
+
+func TestFormatDomainLineMultiLabelHost(t *testing.T) {
+	if got := FormatDomainLine("example.com", "api.nas"); got != "api.nas.example.com" {
+		t.Fatalf("expected api.nas.example.com, got %s", got)
+	}
+}
+
+func TestParseDomainLinesDeepSubdomain(t *testing.T) {
+	_, names, err := ParseDomainLines([]string{"api.nas.example.com"})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if names[0] != "api.nas.example.com" {
+		t.Fatalf("expected full fqdn, got %s", names[0])
+	}
+}
+
+func TestDomainLinesPreservesDeepSubdomain(t *testing.T) {
+	cfg := Config{
+		RootDomain:  "example.com",
+		RecordNames: []string{"s.example.com", "api.nas.example.com"},
+	}
+	lines := cfg.DomainLines()
+	if len(lines) != 2 || lines[1] != "api.nas.example.com" {
+		t.Fatalf("unexpected lines: %v", lines)
+	}
+}
+
+func TestValidateDomainFormat(t *testing.T) {
+	if err := ValidateDomainFormat("s.example.com"); err != nil {
+		t.Fatal(err)
+	}
+	if err := ValidateDomainFormat("www.sub.example.com"); err != nil {
+		t.Fatal(err)
+	}
+	if ValidateDomainFormat("s") == nil {
+		t.Fatal("expected error for short domain")
+	}
+	if ValidateDomainFormat("bad_label-.com") == nil {
+		t.Fatal("expected error for invalid label")
+	}
+}
+
+func TestFQDNsFromSaveInput(t *testing.T) {
+	names, err := FQDNsFromSaveInput(SaveInput{
+		Domains: []string{"s.example.com", "www.sub.example.com"},
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(names) != 2 || names[1] != "www.sub.example.com" {
+		t.Fatalf("unexpected names: %v", names)
+	}
+}
diff --git a/internal/ddns/service.go b/internal/ddns/service.go
new file mode 100644
index 0000000..c044981
--- /dev/null
+++ b/internal/ddns/service.go
@@ -0,0 +1,709 @@
+package ddns
+
+import (
+	"context"
+	"fmt"
+	"log/slog"
+	"strings"
+	"time"
+
+	"github.com/fonu/fonu/internal/notify"
+	"github.com/fonu/fonu/internal/publicip"
+	"github.com/fonu/fonu/internal/secret"
+	"github.com/fonu/fonu/internal/settings"
+)
+
+type Service struct {
+	store     *Store
+	settings  *settings.Store
+	secretBox *secret.Box
+	providers map[string]Provider
+	logger    *slog.Logger
+	notify    *notify.Service
+}
+
+func NewService(store *Store, settings *settings.Store, secretBox *secret.Box, logger *slog.Logger, notifySvc *notify.Service) *Service {
+	return &Service{
+		store:     store,
+		settings:  settings,
+		secretBox: secretBox,
+		providers: map[string]Provider{
+			"cloudflare":   NewCloudflare(),
+			"dnspod":       NewDNSPod(),
+			"alidns":       NewAliDNS(),
+			"tencentcloud": NewTencentCloud(),
+			"volcengine":   NewVolcengine(),
+		},
+		logger: logger.With("module", "DDNS"),
+		notify: notifySvc,
+	}
+}
+
+func (s *Service) List(ctx context.Context) ([]Config, error) {
+	configs, err := s.store.List(ctx)
+	if err != nil {
+		return nil, err
+	}
+	for i := range configs {
+		s.enrichDomainRecords(ctx, &configs[i])
+		sanitizeConfig(&configs[i])
+	}
+	return configs, nil
+}
+
+func (s *Service) ListLite(ctx context.Context) ([]Config, error) {
+	configs, err := s.store.List(ctx)
+	if err != nil {
+		return nil, err
+	}
+	for i := range configs {
+		if len(configs[i].DomainRecords) == 0 {
+			configs[i].DomainRecords = BuildDomainRecords(configs[i])
+		}
+		sanitizeConfig(&configs[i])
+	}
+	return configs, nil
+}
+
+func (s *Service) Get(ctx context.Context, id int64) (Config, error) {
+	cfg, err := s.store.GetByID(ctx, id)
+	if err != nil {
+		return Config{}, err
+	}
+	s.enrichDomainRecords(ctx, &cfg)
+	sanitizeConfig(&cfg)
+	return cfg, nil
+}
+
+func (s *Service) CredentialsForDomain(ctx context.Context, rootDomain string) (string, Credentials, error) {
+	cfg, err := s.store.GetByRootDomain(ctx, rootDomain)
+	if err != nil {
+		return "", Credentials{}, err
+	}
+	return s.credentialsForConfig(ctx, cfg)
+}
+
+func (s *Service) CredentialsForConfigID(ctx context.Context, id int64) (Config, string, Credentials, error) {
+	cfg, err := s.Get(ctx, id)
+	if err != nil {
+		return Config{}, "", Credentials{}, fmt.Errorf("未找到所选 DNS 配置")
+	}
+	provider, cred, err := s.credentialsForConfig(ctx, cfg)
+	if err != nil {
+		return Config{}, "", Credentials{}, err
+	}
+	return cfg, provider, cred, nil
+}
+
+func (s *Service) ConfigForDNSZone(ctx context.Context, dnsZone string) (Config, error) {
+	cfg, err := s.store.GetByRootDomain(ctx, dnsZone)
+	if err != nil {
+		return Config{}, err
+	}
+	if err := requireEnabled(cfg); err != nil {
+		return Config{}, err
+	}
+	return cfg, nil
+}
+
+func (s *Service) ConfigForAnyDomain(ctx context.Context, domain string) (Config, error) {
+	domain = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(domain, "*.")))
+	configs, err := s.store.List(ctx)
+	if err != nil {
+		return Config{}, err
+	}
+	for _, cfg := range configs {
+		if !cfg.Enabled {
+			continue
+		}
+		if cfg.CoversDomain(domain) {
+			return cfg, nil
+		}
+	}
+	return Config{}, fmt.Errorf("未找到域名 %s 的 DDNS 配置", domain)
+}
+
+func (s *Service) CredentialsForAnyDomain(ctx context.Context, domain string) (string, Credentials, error) {
+	cfg, err := s.ConfigForAnyDomain(ctx, domain)
+	if err != nil {
+		return "", Credentials{}, fmt.Errorf("请先在 DDNS 页面配置域名 %s 的 DNS 凭证", strings.TrimPrefix(domain, "*."))
+	}
+	return s.credentialsForConfig(ctx, cfg)
+}
+
+func requireEnabled(cfg Config) error {
+	if !cfg.Enabled {
+		return fmt.Errorf("DNS 任务 %s 未启用,请先在 DDNS 页面开启", cfg.RootDomain)
+	}
+	return nil
+}
+
+func (s *Service) credentialsForConfig(ctx context.Context, cfg Config) (string, Credentials, error) {
+	if err := requireEnabled(cfg); err != nil {
+		return cfg.Provider, Credentials{}, err
+	}
+	cred, err := s.loadCredentialsByID(ctx, cfg.ID)
+	if err != nil {
+		return cfg.Provider, Credentials{}, err
+	}
+	cred.Provider = cfg.Provider
+	return cfg.Provider, cred, nil
+}
+
+func (s *Service) Create(ctx context.Context, in SaveInput) (Config, error) {
+	if err := validateSaveInput(in); err != nil {
+		return Config{}, err
+	}
+	if !in.HasCredentialUpdate() {
+		return Config{}, fmt.Errorf("请填写 DNS API 凭证")
+	}
+	tokenEnc, err := s.encryptCredentials(in)
+	if err != nil {
+		return Config{}, err
+	}
+	return s.store.Create(ctx, in, tokenEnc)
+}
+
+func (s *Service) Update(ctx context.Context, id int64, in SaveInput) (Config, error) {
+	if err := validateSaveInput(in); err != nil {
+		return Config{}, err
+	}
+	existing, err := s.store.GetByID(ctx, id)
+	if err != nil {
+		return Config{}, err
+	}
+	tokenEnc := ""
+	updateToken := in.HasCredentialUpdate()
+	if updateToken {
+		tokenEnc, err = s.encryptCredentials(in)
+		if err != nil {
+			return Config{}, err
+		}
+	} else if !existing.HasToken {
+		return Config{}, fmt.Errorf("请填写 DNS API 凭证")
+	}
+	return s.store.Update(ctx, id, in, tokenEnc, updateToken)
+}
+
+func (s *Service) Delete(ctx context.Context, id int64) error {
+	return s.store.Delete(ctx, id)
+}
+
+type TestInput struct {
+	ConfigID   int64
+	Provider   string
+	APIToken   string
+	APITokenID string
+	APISecret  string
+}
+
+func (s *Service) Test(ctx context.Context, in TestInput) error {
+	providerName := strings.TrimSpace(in.Provider)
+	cred := CredentialsFromSave(SaveInput{
+		Provider:   providerName,
+		APIToken:   in.APIToken,
+		APITokenID: in.APITokenID,
+		APISecret:  in.APISecret,
+	})
+
+	if cred.HasValues() {
+		if providerName == "" {
+			providerName = "cloudflare"
+		}
+		cred.Provider = providerName
+		return s.providerFor(providerName).Verify(ctx, cred)
+	}
+
+	if in.ConfigID > 0 {
+		cfg, err := s.store.GetByID(ctx, in.ConfigID)
+		if err != nil {
+			return err
+		}
+		cred, err = s.loadCredentialsByID(ctx, in.ConfigID)
+		if err != nil {
+			return err
+		}
+		cred.Provider = cfg.Provider
+		return s.providerFor(cfg.Provider).Verify(ctx, cred)
+	}
+
+	return fmt.Errorf("请填写 DNS API 凭证")
+}
+
+func (s *Service) UpdateNow(ctx context.Context, id int64) (Config, error) {
+	cfg, err := s.store.GetByID(ctx, id)
+	if err != nil {
+		return Config{}, err
+	}
+	if !cfg.Enabled {
+		return Config{}, fmt.Errorf("该 DDNS 配置未启用")
+	}
+	if err := s.runUpdate(ctx, cfg); err != nil {
+		return Config{}, err
+	}
+	return s.Get(ctx, id)
+}
+
+func (s *Service) UpdateAll(ctx context.Context) ([]Config, error) {
+	configs, err := s.store.List(ctx)
+	if err != nil {
+		return nil, err
+	}
+	var lastErr error
+	var failCount int
+	var runCount int
+	for _, cfg := range configs {
+		if !cfg.Enabled {
+			continue
+		}
+		runCount++
+		if err := s.runUpdate(ctx, cfg); err != nil {
+			lastErr = err
+			failCount++
+			s.logger.Error("ddns update failed", "domain", cfg.RootDomain, "error", err.Error())
+		}
+	}
+	list, listErr := s.List(ctx)
+	if listErr != nil {
+		return nil, listErr
+	}
+	if failCount > 0 && failCount == runCount {
+		return list, lastErr
+	}
+	return list, nil
+}
+
+func (s *Service) Tick(ctx context.Context) {
+	configs, err := s.store.List(ctx)
+	if err != nil {
+		return
+	}
+	for _, cfg := range configs {
+		if !cfg.Enabled {
+			continue
+		}
+		if err := s.runUpdate(ctx, cfg); err != nil {
+			s.logger.Error("ddns update failed", "domain", cfg.RootDomain, "error", err.Error())
+			if s.notify != nil {
+				s.notify.Alert(ctx, notify.EventDDNSFailure, "DDNS 更新失败", cfg.RootDomain+": "+err.Error())
+			}
+		}
+	}
+}
+
+// PublicIPs returns the most recently synced public IPs from enabled DDNS configs.
+func (s *Service) PublicIPs(ctx context.Context) (ipv4, ipv6 string, ok bool) {
+	return s.latestStoredPublicIPs(ctx, true)
+}
+
+// LastKnownPublicIPs returns stored public IPs from the most recently updated config,
+// including paused tasks. Used to avoid live IP detection when DDNS is temporarily disabled.
+func (s *Service) LastKnownPublicIPs(ctx context.Context) (ipv4, ipv6 string, ok bool) {
+	return s.latestStoredPublicIPs(ctx, false)
+}
+
+func (s *Service) latestStoredPublicIPs(ctx context.Context, enabledOnly bool) (ipv4, ipv6 string, ok bool) {
+	configs, err := s.store.List(ctx)
+	if err != nil || len(configs) == 0 {
+		return "", "", false
+	}
+	var latest *Config
+	for _, cfg := range configs {
+		if enabledOnly && !cfg.Enabled {
+			continue
+		}
+		if latest == nil || (cfg.LastUpdatedAt != nil && (latest.LastUpdatedAt == nil || cfg.LastUpdatedAt.After(*latest.LastUpdatedAt))) {
+			latest = &cfg
+		}
+	}
+	if latest == nil {
+		return "", "", false
+	}
+	if latest.LastIPv4 == "" && latest.LastIPv6 == "" {
+		return "", "", false
+	}
+	return latest.LastIPv4, latest.LastIPv6, true
+}
+
+func (s *Service) Summary(ctx context.Context) (status string, lastUpdated string, count int) {
+	configs, err := s.store.List(ctx)
+	if err != nil || len(configs) == 0 {
+		return "disabled", "", 0
+	}
+	count = len(configs)
+	status = "ok"
+	hasEnabled := false
+	var latest *Config
+	for _, cfg := range configs {
+		if cfg.Enabled {
+			hasEnabled = true
+		}
+		if latest == nil || (cfg.LastUpdatedAt != nil && (latest.LastUpdatedAt == nil || cfg.LastUpdatedAt.After(*latest.LastUpdatedAt))) {
+			latest = &cfg
+		}
+		if cfg.LastStatus == "error" {
+			status = "error"
+		}
+	}
+	if !hasEnabled {
+		return "disabled", "", count
+	}
+	if latest != nil && latest.LastUpdatedAt != nil {
+		lastUpdated = latest.LastUpdatedAt.UTC().Format(time.RFC3339)
+	}
+	if status != "error" && latest != nil && latest.LastStatus != "" {
+		status = latest.LastStatus
+	}
+	return status, lastUpdated, count
+}
+
+func sanitizeConfig(cfg *Config) {
+	if !publicip.IsPublicIPv4(cfg.LastIPv4) {
+		cfg.LastIPv4 = ""
+	}
+	if !publicip.IsPublicIPv6(cfg.LastIPv6) {
+		cfg.LastIPv6 = ""
+	}
+}
+
+func (s *Service) enrichDomainRecords(ctx context.Context, cfg *Config) {
+	records := BuildDomainRecords(*cfg)
+	if !cfg.HasToken || len(records) == 0 {
+		cfg.DomainRecords = records
+		return
+	}
+	cred, err := s.loadCredentialsByID(ctx, cfg.ID)
+	if err != nil {
+		cfg.DomainRecords = records
+		return
+	}
+	cred.Provider = cfg.Provider
+	provider := s.providerFor(cfg.Provider)
+	zoneCache := make(map[string]zoneHost)
+
+	for i := range records {
+		prevIPv4 := records[i].IPv4
+		prevIPv6 := records[i].IPv6
+		prevStatus := records[i].Status
+		prevMessage := records[i].Message
+
+		fqdn := FQDNFromRecord(*cfg, records[i].Domain)
+		zone, record, err := s.resolveZone(ctx, provider, cred, fqdn, zoneCache)
+		if err != nil {
+			records[i].Domain = fqdn
+			records[i].Status = "error"
+			records[i].Message = err.Error()
+			continue
+		}
+		records[i].Domain = FormatDomainLine(zone, record)
+
+		if cfg.IPv4Enabled {
+			ip, err := provider.GetRecordIP(ctx, cred, zone, record, "A")
+			switch {
+			case err != nil:
+				records[i].IPv4 = prevIPv4
+				if prevStatus == "" || prevStatus == "unknown" {
+					records[i].Status = "error"
+					records[i].Message = err.Error()
+				}
+			case ip != "":
+				records[i].IPv4 = ip
+				if prevStatus == "" || prevStatus == "unknown" {
+					records[i].Status = "unchanged"
+					records[i].Message = "记录未变化"
+				} else {
+					records[i].Status = prevStatus
+					records[i].Message = prevMessage
+				}
+			default:
+				records[i].IPv4 = prevIPv4
+			}
+		}
+		if cfg.IPv6Enabled {
+			ip, err := provider.GetRecordIP(ctx, cred, zone, record, "AAAA")
+			switch {
+			case err != nil:
+				records[i].IPv6 = prevIPv6
+				if records[i].Status == "" || records[i].Status == "unknown" {
+					records[i].Status = "error"
+					records[i].Message = err.Error()
+				}
+			case ip != "":
+				records[i].IPv6 = ip
+			default:
+				records[i].IPv6 = prevIPv6
+			}
+		}
+		if records[i].Status == "" {
+			records[i].Status = "unknown"
+		}
+	}
+	cfg.DomainRecords = records
+}
+
+func (s *Service) runUpdate(ctx context.Context, cfg Config) error {
+	prevIPv4 := cfg.LastIPv4
+	prevIPv6 := cfg.LastIPv6
+
+	cred, err := s.loadCredentialsByID(ctx, cfg.ID)
+	if err != nil {
+		_ = s.store.UpdateStatus(ctx, cfg.ID, cfg.LastIPv4, cfg.LastIPv6, "error", err.Error())
+		return err
+	}
+	cred.Provider = cfg.Provider
+	provider := s.providerFor(cfg.Provider)
+
+	detectedIPv4, detectedIPv6, _ := publicip.Detect(ctx)
+	if detectedIPv4 != "" && !publicip.IsPublicIPv4(detectedIPv4) {
+		detectedIPv4 = ""
+	}
+	if detectedIPv6 != "" && !publicip.IsPublicIPv6(detectedIPv6) {
+		detectedIPv6 = ""
+	}
+	// 自定义 IP 优先:填写后直接作为更新目标(如公网服务器 IP),不再依赖本机出口 IP 检测
+	customIPv4 := strings.TrimSpace(cfg.CustomIPv4)
+	customIPv6 := strings.TrimSpace(cfg.CustomIPv6)
+	if customIPv4 != "" && !publicip.IsPublicIPv4(customIPv4) {
+		msg := "自定义 IPv4 不是公网地址,已拒绝更新"
+		_ = s.store.UpdateStatus(ctx, cfg.ID, cfg.LastIPv4, cfg.LastIPv6, "error", msg)
+		return fmt.Errorf(msg)
+	}
+	if customIPv6 != "" && !publicip.IsPublicIPv6(customIPv6) {
+		msg := "自定义 IPv6 不是公网地址,已拒绝更新"
+		_ = s.store.UpdateStatus(ctx, cfg.ID, cfg.LastIPv4, cfg.LastIPv6, "error", msg)
+		return fmt.Errorf(msg)
+	}
+	if customIPv4 != "" {
+		detectedIPv4 = customIPv4
+	}
+	if customIPv6 != "" {
+		detectedIPv6 = customIPv6
+	}
+	storeIPv4 := cfg.LastIPv4
+	storeIPv6 := cfg.LastIPv6
+	if !publicip.IsPublicIPv4(storeIPv4) {
+		storeIPv4 = ""
+	}
+	if !publicip.IsPublicIPv6(storeIPv6) {
+		storeIPv6 = ""
+	}
+	if cfg.IPv4Enabled && detectedIPv4 != "" {
+		storeIPv4 = detectedIPv4
+	}
+	if cfg.IPv6Enabled && detectedIPv6 != "" {
+		storeIPv6 = detectedIPv6
+	}
+
+	if cfg.IPv4Enabled && detectedIPv4 == "" {
+		msg := "无法获取公网 IPv4,已跳过更新(不会写入内网 IP)"
+		_ = s.store.UpdateStatus(ctx, cfg.ID, storeIPv4, storeIPv6, "error", msg)
+		return fmt.Errorf(msg)
+	}
+	if cfg.IPv6Enabled && detectedIPv6 == "" {
+		msg := "无法获取公网 IPv6,已跳过更新"
+		_ = s.store.UpdateStatus(ctx, cfg.ID, storeIPv4, storeIPv6, "error", msg)
+		return fmt.Errorf(msg)
+	}
+
+	recordNames := cfg.RecordNamesList()
+	domainRecords := make([]DomainRecord, 0, len(recordNames))
+	var lastErr error
+	var errCount, okCount int
+	changed := false
+	zoneCache := make(map[string]zoneHost)
+
+	for _, name := range recordNames {
+		fqdn := FQDNFromRecord(cfg, name)
+		zone, record, zoneErr := s.resolveZone(ctx, provider, cred, fqdn, zoneCache)
+		if zoneErr != nil {
+			dr := DomainRecord{Domain: fqdn, Status: "error", Message: zoneErr.Error()}
+			domainRecords = append(domainRecords, dr)
+			lastErr = zoneErr
+			errCount++
+			continue
+		}
+		fqdn = FormatDomainLine(zone, record)
+		dr := DomainRecord{Domain: fqdn}
+
+		if cfg.IPv4Enabled && detectedIPv4 != "" {
+			current, getErr := provider.GetRecordIP(ctx, cred, zone, record, "A")
+			if getErr != nil {
+				dr.Status = "error"
+				dr.Message = getErr.Error()
+				lastErr = getErr
+				domainRecords = append(domainRecords, dr)
+				continue
+			}
+			dr.IPv4 = current
+			if current == detectedIPv4 {
+				dr.Status = "unchanged"
+				dr.Message = "记录未变化"
+			} else if !publicip.IsPublicIPv4(detectedIPv4) {
+				dr.Status = "error"
+				dr.Message = "检测到内网 IPv4,拒绝更新"
+				lastErr = fmt.Errorf("检测到内网 IPv4,拒绝更新")
+			} else if err := provider.UpdateRecord(ctx, cred, zone, record, "A", detectedIPv4); err != nil {
+				dr.Status = "error"
+				dr.Message = err.Error()
+				lastErr = err
+			} else {
+				dr.IPv4 = detectedIPv4
+				dr.Status = "ok"
+				dr.Message = "已更新"
+				changed = true
+				s.logger.Info("ddns ipv4 updated", "domain", fqdn, "ip", detectedIPv4)
+			}
+		}
+
+		if cfg.IPv6Enabled && detectedIPv6 != "" && dr.Status != "error" {
+			current, getErr := provider.GetRecordIP(ctx, cred, zone, record, "AAAA")
+			if getErr != nil {
+				dr.Status = "error"
+				dr.Message = getErr.Error()
+				lastErr = getErr
+			} else {
+				dr.IPv6 = current
+				if current == detectedIPv6 {
+					if dr.Status == "" {
+						dr.Status = "unchanged"
+						dr.Message = "记录未变化"
+					}
+				} else if !publicip.IsPublicIPv6(detectedIPv6) {
+					dr.Status = "error"
+					dr.Message = "检测到内网 IPv6,拒绝更新"
+					lastErr = fmt.Errorf("检测到内网 IPv6,拒绝更新")
+				} else if err := provider.UpdateRecord(ctx, cred, zone, record, "AAAA", detectedIPv6); err != nil {
+					dr.Status = "error"
+					dr.Message = err.Error()
+					lastErr = err
+				} else {
+					dr.IPv6 = detectedIPv6
+					dr.Status = "ok"
+					dr.Message = "已更新"
+					changed = true
+					s.logger.Info("ddns ipv6 updated", "domain", fqdn, "ip", detectedIPv6)
+				}
+			}
+		}
+
+		if dr.Status == "" {
+			dr.Status = "unchanged"
+			dr.Message = "记录未变化"
+		}
+		if dr.Status == "error" {
+			errCount++
+		} else {
+			okCount++
+		}
+		domainRecords = append(domainRecords, dr)
+	}
+
+	overallStatus := "ok"
+	overallError := ""
+	switch {
+	case errCount > 0 && okCount == 0:
+		overallStatus = "error"
+		if lastErr != nil {
+			overallError = lastErr.Error()
+		}
+	case errCount > 0:
+		overallStatus = "warning"
+		overallError = fmt.Sprintf("%d 个域名同步失败,其余正常", errCount)
+		lastErr = nil
+	}
+	if changed {
+		s.logger.Info("ddns records updated", "provider", cfg.Provider)
+		if s.notify != nil {
+			msg := formatDDNSIPChangeMessage(cfg.RootDomain, prevIPv4, prevIPv6, storeIPv4, storeIPv6)
+			s.notify.Alert(ctx, notify.EventDDNSIPChange, "DDNS IP 已变更", msg)
+		}
+	}
+	_ = s.store.UpdateSyncResult(ctx, cfg.ID, storeIPv4, storeIPv6, overallStatus, overallError, domainRecords)
+	return lastErr
+}
+
+func formatDDNSIPChangeMessage(domain, prevIPv4, prevIPv6, newIPv4, newIPv6 string) string {
+	var parts []string
+	if newIPv4 != "" && newIPv4 != prevIPv4 {
+		if prevIPv4 != "" {
+			parts = append(parts, fmt.Sprintf("IPv4: %s → %s", prevIPv4, newIPv4))
+		} else {
+			parts = append(parts, fmt.Sprintf("IPv4: %s", newIPv4))
+		}
+	}
+	if newIPv6 != "" && newIPv6 != prevIPv6 {
+		if prevIPv6 != "" {
+			parts = append(parts, fmt.Sprintf("IPv6: %s → %s", prevIPv6, newIPv6))
+		} else {
+			parts = append(parts, fmt.Sprintf("IPv6: %s", newIPv6))
+		}
+	}
+	if len(parts) == 0 {
+		return domain
+	}
+	return domain + "\n" + strings.Join(parts, "\n")
+}
+
+func (s *Service) encryptCredentials(in SaveInput) (string, error) {
+	cred := CredentialsFromSave(in)
+	if err := cred.Validate(in.Provider); err != nil {
+		return "", err
+	}
+	raw, err := cred.Marshal()
+	if err != nil {
+		return "", err
+	}
+	return s.secretBox.Encrypt(string(raw))
+}
+
+func (s *Service) loadCredentialsByID(ctx context.Context, id int64) (Credentials, error) {
+	enc, err := s.store.GetTokenByID(ctx, id)
+	if err != nil || enc == "" {
+		return Credentials{}, fmt.Errorf("DNS API 凭证未配置")
+	}
+	raw, err := s.secretBox.Decrypt(enc)
+	if err != nil {
+		return Credentials{}, secret.DecryptHint(err)
+	}
+	return ParseCredentials(raw)
+}
+
+func (s *Service) providerFor(name string) Provider {
+	if p, ok := s.providers[name]; ok {
+		return p
+	}
+	return s.providers["cloudflare"]
+}
+
+func (s *Service) resolveZone(ctx context.Context, provider Provider, cred Credentials, fqdn string, cache map[string]zoneHost) (zone, host string, err error) {
+	if cache != nil {
+		if item, ok := cache[fqdn]; ok {
+			return item.zone, item.host, item.err
+		}
+	}
+	zone, host, err = ResolveZoneForFQDN(ctx, func(candidate string) (bool, error) {
+		return provider.HasZone(ctx, cred, candidate)
+	}, fqdn)
+	if cache != nil {
+		cache[fqdn] = zoneHost{zone: zone, host: host, err: err}
+	}
+	return zone, host, err
+}
+
+type zoneHost struct {
+	zone string
+	host string
+	err  error
+}
+
+func validateSaveInput(in SaveInput) error {
+	if _, err := FQDNsFromSaveInput(in); err != nil {
+		return err
+	}
+	switch strings.TrimSpace(in.Provider) {
+	case "dnspod", "cloudflare", "alidns", "tencentcloud", "volcengine", "":
+	default:
+		return fmt.Errorf("不支持的 DNS Provider")
+	}
+	return nil
+}
diff --git a/internal/ddns/store.go b/internal/ddns/store.go
new file mode 100644
index 0000000..7379746
--- /dev/null
+++ b/internal/ddns/store.go
@@ -0,0 +1,281 @@
+package ddns
+
+import (
+	"context"
+	"database/sql"
+	"errors"
+	"fmt"
+	"strings"
+	"time"
+)
+
+type Config struct {
+	ID            int64      `json:"id"`
+	Provider      string     `json:"provider"`
+	RootDomain    string     `json:"root_domain"`
+	RecordName    string     `json:"record_name"`
+	RecordNames   []string   `json:"record_names"`
+	IPv4Enabled   bool       `json:"ipv4_enabled"`
+	IPv6Enabled   bool       `json:"ipv6_enabled"`
+	Enabled       bool       `json:"enabled"`
+	HasToken      bool       `json:"has_token"`
+	CustomIPv4    string     `json:"custom_ipv4,omitempty"`
+	CustomIPv6    string     `json:"custom_ipv6,omitempty"`
+	LastIPv4      string         `json:"last_ipv4"`
+	LastIPv6      string         `json:"last_ipv6"`
+	LastStatus    string         `json:"last_status"`
+	LastError     string         `json:"last_error,omitempty"`
+	DomainRecords []DomainRecord `json:"domain_records"`
+	LastUpdatedAt *time.Time     `json:"last_updated_at,omitempty"`
+}
+
+type SaveInput struct {
+	Provider    string
+	RootDomain  string
+	RecordName  string
+	RecordNames []string
+	Domains     []string
+	IPv4Enabled bool
+	IPv6Enabled bool
+	Enabled     bool
+	CustomIPv4  string
+	CustomIPv6  string
+	APIToken    string
+	APITokenID  string
+	APISecret   string
+}
+
+func (in SaveInput) HasCredentialUpdate() bool {
+	return strings.TrimSpace(in.APIToken) != "" ||
+		strings.TrimSpace(in.APITokenID) != "" ||
+		strings.TrimSpace(in.APISecret) != ""
+}
+
+type Store struct {
+	db *sql.DB
+}
+
+func NewStore(db *sql.DB) *Store {
+	return &Store{db: db}
+}
+
+func (s *Store) List(ctx context.Context) ([]Config, error) {
+	rows, err := s.db.QueryContext(ctx, `
+		SELECT id, provider, root_domain, record_name, COALESCE(record_names, '[]'), ipv4_enabled, ipv6_enabled, enabled,
+		       CASE WHEN api_token_enc IS NOT NULL AND api_token_enc != '' THEN 1 ELSE 0 END,
+		       COALESCE(last_ipv4, ''), COALESCE(last_ipv6, ''), last_status, COALESCE(last_error, ''),
+		       COALESCE(domain_records, '[]'), last_updated_at, COALESCE(custom_ipv4, ''), COALESCE(custom_ipv6, '')
+		FROM ddns_configs ORDER BY id ASC
+	`)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var configs []Config
+	for rows.Next() {
+		cfg, err := scanConfig(rows)
+		if err != nil {
+			return nil, err
+		}
+		configs = append(configs, cfg)
+	}
+	return configs, rows.Err()
+}
+
+func (s *Store) GetByID(ctx context.Context, id int64) (Config, error) {
+	row := s.db.QueryRowContext(ctx, `
+		SELECT id, provider, root_domain, record_name, COALESCE(record_names, '[]'), ipv4_enabled, ipv6_enabled, enabled,
+		       CASE WHEN api_token_enc IS NOT NULL AND api_token_enc != '' THEN 1 ELSE 0 END,
+		       COALESCE(last_ipv4, ''), COALESCE(last_ipv6, ''), last_status, COALESCE(last_error, ''),
+		       COALESCE(domain_records, '[]'), last_updated_at, COALESCE(custom_ipv4, ''), COALESCE(custom_ipv6, '')
+		FROM ddns_configs WHERE id = ?
+	`, id)
+	cfg, err := scanConfig(row)
+	if errors.Is(err, sql.ErrNoRows) {
+		return Config{}, fmt.Errorf("DDNS 配置不存在")
+	}
+	return cfg, err
+}
+
+func (s *Store) GetByRootDomain(ctx context.Context, rootDomain string) (Config, error) {
+	rootDomain = strings.ToLower(strings.TrimSpace(rootDomain))
+	row := s.db.QueryRowContext(ctx, `
+		SELECT id, provider, root_domain, record_name, COALESCE(record_names, '[]'), ipv4_enabled, ipv6_enabled, enabled,
+		       CASE WHEN api_token_enc IS NOT NULL AND api_token_enc != '' THEN 1 ELSE 0 END,
+		       COALESCE(last_ipv4, ''), COALESCE(last_ipv6, ''), last_status, COALESCE(last_error, ''),
+		       COALESCE(domain_records, '[]'), last_updated_at, COALESCE(custom_ipv4, ''), COALESCE(custom_ipv6, '')
+		FROM ddns_configs WHERE root_domain = ? ORDER BY id LIMIT 1
+	`, rootDomain)
+	cfg, err := scanConfig(row)
+	if errors.Is(err, sql.ErrNoRows) {
+		return Config{}, fmt.Errorf("未找到域名 %s 的 DDNS 配置", rootDomain)
+	}
+	return cfg, err
+}
+
+func (s *Store) GetTokenByID(ctx context.Context, id int64) (string, error) {
+	var enc string
+	err := s.db.QueryRowContext(ctx, `SELECT COALESCE(api_token_enc, '') FROM ddns_configs WHERE id = ?`, id).Scan(&enc)
+	if err != nil {
+		if errors.Is(err, sql.ErrNoRows) {
+			return "", fmt.Errorf("DDNS 配置不存在")
+		}
+		return "", err
+	}
+	return enc, nil
+}
+
+func (s *Store) Create(ctx context.Context, in SaveInput, tokenEnc string) (Config, error) {
+	rootDomain, recordName, recordNamesJSON, provider, err := normalizeInput(in)
+	if err != nil {
+		return Config{}, err
+	}
+	res, err := s.db.ExecContext(ctx, `
+		INSERT INTO ddns_configs(provider, root_domain, record_name, record_names, ipv4_enabled, ipv6_enabled, enabled, api_token_enc, custom_ipv4, custom_ipv6, updated_at)
+		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
+	`, provider, rootDomain, recordName, recordNamesJSON, boolInt(in.IPv4Enabled), boolInt(in.IPv6Enabled), boolInt(in.Enabled), tokenEnc, in.CustomIPv4, in.CustomIPv6)
+	if err != nil {
+		if strings.Contains(err.Error(), "UNIQUE") {
+			return Config{}, fmt.Errorf("该域名与子域名组合已存在")
+		}
+		return Config{}, err
+	}
+	id, _ := res.LastInsertId()
+	return s.GetByID(ctx, id)
+}
+
+func (s *Store) Update(ctx context.Context, id int64, in SaveInput, tokenEnc string, updateToken bool) (Config, error) {
+	rootDomain, recordName, recordNamesJSON, provider, err := normalizeInput(in)
+	if err != nil {
+		return Config{}, err
+	}
+	if updateToken {
+		_, err := s.db.ExecContext(ctx, `
+			UPDATE ddns_configs
+			SET provider = ?, root_domain = ?, record_name = ?, record_names = ?, ipv4_enabled = ?, ipv6_enabled = ?, enabled = ?,
+			    api_token_enc = ?, custom_ipv4 = ?, custom_ipv6 = ?, updated_at = datetime('now')
+			WHERE id = ?
+		`, provider, rootDomain, recordName, recordNamesJSON, boolInt(in.IPv4Enabled), boolInt(in.IPv6Enabled), boolInt(in.Enabled), tokenEnc, in.CustomIPv4, in.CustomIPv6, id)
+		if err != nil {
+			if strings.Contains(err.Error(), "UNIQUE") {
+				return Config{}, fmt.Errorf("该域名与子域名组合已存在")
+			}
+			return Config{}, err
+		}
+	} else {
+		_, err := s.db.ExecContext(ctx, `
+			UPDATE ddns_configs
+			SET provider = ?, root_domain = ?, record_name = ?, record_names = ?, ipv4_enabled = ?, ipv6_enabled = ?, enabled = ?,
+			    custom_ipv4 = ?, custom_ipv6 = ?, updated_at = datetime('now')
+			WHERE id = ?
+		`, provider, rootDomain, recordName, recordNamesJSON, boolInt(in.IPv4Enabled), boolInt(in.IPv6Enabled), boolInt(in.Enabled), in.CustomIPv4, in.CustomIPv6, id)
+		if err != nil {
+			if strings.Contains(err.Error(), "UNIQUE") {
+				return Config{}, fmt.Errorf("该域名与子域名组合已存在")
+			}
+			return Config{}, err
+		}
+	}
+	return s.GetByID(ctx, id)
+}
+
+func (s *Store) Delete(ctx context.Context, id int64) error {
+	res, err := s.db.ExecContext(ctx, `DELETE FROM ddns_configs WHERE id = ?`, id)
+	if err != nil {
+		return err
+	}
+	n, _ := res.RowsAffected()
+	if n == 0 {
+		return fmt.Errorf("DDNS 配置不存在")
+	}
+	return nil
+}
+
+func (s *Store) UpdateStatus(ctx context.Context, id int64, ipv4, ipv6, status, lastError string) error {
+	return s.UpdateSyncResult(ctx, id, ipv4, ipv6, status, lastError, nil)
+}
+
+func (s *Store) UpdateSyncResult(ctx context.Context, id int64, ipv4, ipv6, status, lastError string, records []DomainRecord) error {
+	domainRecordsJSON := ""
+	if records != nil {
+		domainRecordsJSON = EncodeDomainRecords(records)
+	}
+	if domainRecordsJSON != "" {
+		_, err := s.db.ExecContext(ctx, `
+			UPDATE ddns_configs
+			SET last_ipv4 = ?, last_ipv6 = ?, last_status = ?, last_error = ?, domain_records = ?,
+			    last_updated_at = datetime('now'), updated_at = datetime('now')
+			WHERE id = ?
+		`, ipv4, ipv6, status, lastError, domainRecordsJSON, id)
+		return err
+	}
+	_, err := s.db.ExecContext(ctx, `
+		UPDATE ddns_configs
+		SET last_ipv4 = ?, last_ipv6 = ?, last_status = ?, last_error = ?, last_updated_at = datetime('now'), updated_at = datetime('now')
+		WHERE id = ?
+	`, ipv4, ipv6, status, lastError, id)
+	return err
+}
+
+func normalizeInput(in SaveInput) (rootDomain, recordName, recordNamesJSON, provider string, err error) {
+	provider = strings.TrimSpace(in.Provider)
+	if provider == "" {
+		provider = "cloudflare"
+	}
+
+	var names []string
+	if len(in.Domains) > 0 {
+		rootDomain, names, err = ParseDomainLines(in.Domains)
+		if err != nil {
+			return "", "", "", "", err
+		}
+	} else {
+		rootDomain = strings.ToLower(strings.TrimSpace(in.RootDomain))
+		if rootDomain == "" {
+			return "", "", "", "", fmt.Errorf("至少需要一个域名")
+		}
+		names, err = ParseRecordNames(in.RecordNames, in.RecordName)
+		if err != nil {
+			return "", "", "", "", err
+		}
+	}
+	return rootDomain, names[0], EncodeRecordNames(names), provider, nil
+}
+
+func scanConfig(row interface{ Scan(dest ...any) error }) (Config, error) {
+	var cfg Config
+	var ipv4Enabled, ipv6Enabled, enabled, hasToken int
+	var recordNamesRaw, domainRecordsRaw string
+	var lastUpdated sql.NullString
+	if err := row.Scan(&cfg.ID, &cfg.Provider, &cfg.RootDomain, &cfg.RecordName, &recordNamesRaw, &ipv4Enabled, &ipv6Enabled, &enabled, &hasToken, &cfg.LastIPv4, &cfg.LastIPv6, &cfg.LastStatus, &cfg.LastError, &domainRecordsRaw, &lastUpdated, &cfg.CustomIPv4, &cfg.CustomIPv6); err != nil {
+		return Config{}, err
+	}
+	cfg.RecordNames = DecodeRecordNames(recordNamesRaw, cfg.RecordName)
+	cfg.DomainRecords = DecodeDomainRecords(domainRecordsRaw)
+	cfg.DomainRecords = BuildDomainRecords(cfg)
+	cfg.IPv4Enabled = ipv4Enabled == 1
+	cfg.IPv6Enabled = ipv6Enabled == 1
+	cfg.Enabled = enabled == 1
+	cfg.HasToken = hasToken == 1
+	if lastUpdated.Valid {
+		t := parseTime(lastUpdated.String)
+		cfg.LastUpdatedAt = &t
+	}
+	return cfg, nil
+}
+
+func boolInt(v bool) int {
+	if v {
+		return 1
+	}
+	return 0
+}
+
+func parseTime(v string) time.Time {
+	t, err := time.Parse("2006-01-02 15:04:05", v)
+	if err != nil {
+		t, _ = time.Parse(time.RFC3339, v)
+	}
+	return t.UTC()
+}
diff --git a/internal/ddns/tencentcloud.go b/internal/ddns/tencentcloud.go
new file mode 100644
index 0000000..9ba6ed8
--- /dev/null
+++ b/internal/ddns/tencentcloud.go
@@ -0,0 +1,186 @@
+package ddns
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"strings"
+
+	"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
+	errorsdk "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/errors"
+	"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
+	dnspod "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod/v20210323"
+)
+
+type TencentCloud struct{}
+
+func NewTencentCloud() *TencentCloud { return &TencentCloud{} }
+
+func (t *TencentCloud) Name() string { return "tencentcloud" }
+
+func (t *TencentCloud) Verify(ctx context.Context, cred Credentials) error {
+	client, err := t.client(cred)
+	if err != nil {
+		return err
+	}
+	req := dnspod.NewDescribeDomainListRequest()
+	req.Limit = common.Int64Ptr(1)
+	_, err = client.DescribeDomainList(req)
+	return wrapTencentErr(err)
+}
+
+func (t *TencentCloud) HasZone(ctx context.Context, cred Credentials, zone string) (bool, error) {
+	client, err := t.client(cred)
+	if err != nil {
+		return false, err
+	}
+	_, err = t.domainInfo(client, zone)
+	return zoneLookupOK(wrapTencentErr(err))
+}
+
+func (t *TencentCloud) GetRecordIP(ctx context.Context, cred Credentials, rootDomain, recordName, recordType string) (string, error) {
+	client, err := t.client(cred)
+	if err != nil {
+		return "", err
+	}
+	zone, err := t.domainInfo(client, rootDomain)
+	if err != nil {
+		return "", err
+	}
+	records, err := t.listRecords(client, zone, tencentSubdomain(recordName), recordType)
+	if err != nil {
+		return "", err
+	}
+	for _, rec := range records {
+		if rec.Value != nil {
+			return *rec.Value, nil
+		}
+	}
+	return "", nil
+}
+
+func (t *TencentCloud) UpdateRecord(ctx context.Context, cred Credentials, rootDomain, recordName, recordType, ip string) error {
+	client, err := t.client(cred)
+	if err != nil {
+		return err
+	}
+	zone, err := t.domainInfo(client, rootDomain)
+	if err != nil {
+		return err
+	}
+	subDomain := tencentSubdomain(recordName)
+	records, err := t.listRecords(client, zone, subDomain, recordType)
+	if err != nil {
+		return err
+	}
+	if len(records) > 0 && records[0].RecordId != nil {
+		req := dnspod.NewModifyRecordRequest()
+		req.Domain = zone.Name
+		req.DomainId = zone.DomainId
+		req.RecordId = records[0].RecordId
+		req.SubDomain = common.StringPtr(subDomain)
+		req.RecordType = common.StringPtr(recordType)
+		req.RecordLine = common.StringPtr(tencentRecordLine(records[0]))
+		req.Value = common.StringPtr(ip)
+		req.TTL = common.Uint64Ptr(600)
+		_, err = client.ModifyRecord(req)
+		return wrapTencentErr(err)
+	}
+
+	req := dnspod.NewCreateRecordRequest()
+	req.Domain = zone.Name
+	req.DomainId = zone.DomainId
+	req.SubDomain = common.StringPtr(subDomain)
+	req.RecordType = common.StringPtr(recordType)
+	req.RecordLine = common.StringPtr("默认")
+	req.Value = common.StringPtr(ip)
+	req.TTL = common.Uint64Ptr(600)
+	_, err = client.CreateRecord(req)
+	return wrapTencentErr(err)
+}
+
+func (t *TencentCloud) client(cred Credentials) (*dnspod.Client, error) {
+	if cred.Token == "" || cred.Secret == "" {
+		return nil, fmt.Errorf("腾讯云 DNS 凭证未配置")
+	}
+	credential := common.NewCredential(cred.Token, cred.Secret)
+	cpf := profile.NewClientProfile()
+	cpf.HttpProfile.Endpoint = "dnspod.tencentcloudapi.com"
+	return dnspod.NewClient(credential, "", cpf)
+}
+
+func (t *TencentCloud) domainInfo(client *dnspod.Client, rootDomain string) (*dnspod.DomainListItem, error) {
+	rootDomain = strings.ToLower(strings.TrimSpace(rootDomain))
+	req := dnspod.NewDescribeDomainListRequest()
+	var offset int64
+	for {
+		req.Offset = common.Int64Ptr(offset)
+		resp, err := client.DescribeDomainList(req)
+		if err != nil {
+			return nil, wrapTencentErr(err)
+		}
+		for _, zone := range resp.Response.DomainList {
+			if zone.Name != nil && strings.EqualFold(*zone.Name, rootDomain) {
+				return zone, nil
+			}
+			if zone.Punycode != nil && strings.EqualFold(*zone.Punycode, rootDomain) {
+				return zone, nil
+			}
+		}
+		total := int64(0)
+		if resp.Response.DomainCountInfo != nil && resp.Response.DomainCountInfo.AllTotal != nil {
+			total = int64(*resp.Response.DomainCountInfo.AllTotal)
+		}
+		offset += int64(len(resp.Response.DomainList))
+		if offset >= total || len(resp.Response.DomainList) == 0 {
+			break
+		}
+	}
+	return nil, fmt.Errorf("未找到域名 %s 对应的腾讯云 DNS 域名", rootDomain)
+}
+
+func (t *TencentCloud) listRecords(client *dnspod.Client, zone *dnspod.DomainListItem, subDomain, recordType string) ([]*dnspod.RecordListItem, error) {
+	req := dnspod.NewDescribeRecordListRequest()
+	req.Domain = zone.Name
+	req.DomainId = zone.DomainId
+	req.Subdomain = common.StringPtr(subDomain)
+	req.RecordType = common.StringPtr(recordType)
+	resp, err := client.DescribeRecordList(req)
+	if err != nil {
+		var sdkErr *errorsdk.TencentCloudSDKError
+		if errors.As(err, &sdkErr) && sdkErr.Code == dnspod.RESOURCENOTFOUND_NODATAOFRECORD {
+			return nil, nil
+		}
+		return nil, wrapTencentErr(err)
+	}
+	if resp.Response == nil {
+		return nil, nil
+	}
+	return resp.Response.RecordList, nil
+}
+
+func tencentSubdomain(recordName string) string {
+	sub := subDomainForRecord(recordName)
+	if sub == "@" {
+		return "@"
+	}
+	return sub
+}
+
+func tencentRecordLine(rec *dnspod.RecordListItem) string {
+	if rec != nil && rec.Line != nil && *rec.Line != "" {
+		return *rec.Line
+	}
+	return "默认"
+}
+
+func wrapTencentErr(err error) error {
+	if err == nil {
+		return nil
+	}
+	var sdkErr *errorsdk.TencentCloudSDKError
+	if errors.As(err, &sdkErr) {
+		return fmt.Errorf("腾讯云 DNS API 错误:%s", sdkErr.Message)
+	}
+	return err
+}
diff --git a/internal/ddns/tencentcloud_test.go b/internal/ddns/tencentcloud_test.go
new file mode 100644
index 0000000..aa9b9fb
--- /dev/null
+++ b/internal/ddns/tencentcloud_test.go
@@ -0,0 +1,26 @@
+package ddns
+
+import "testing"
+
+func TestTencentSubdomain(t *testing.T) {
+	if tencentSubdomain("@") != "@" {
+		t.Fatal("expected @")
+	}
+	if tencentSubdomain("www") != "www" {
+		t.Fatal("expected www")
+	}
+}
+
+func TestCredentialsTencentCloud(t *testing.T) {
+	cred := CredentialsFromSave(SaveInput{
+		Provider:   "tencentcloud",
+		APIToken:   "AKIDxxx",
+		APISecret:  "secret",
+	})
+	if cred.Provider != "tencentcloud" || !cred.HasValues() {
+		t.Fatal("expected tencentcloud credentials")
+	}
+	if err := cred.Validate("tencentcloud"); err != nil {
+		t.Fatal(err)
+	}
+}
diff --git a/internal/ddns/volcengine.go b/internal/ddns/volcengine.go
new file mode 100644
index 0000000..3a58a72
--- /dev/null
+++ b/internal/ddns/volcengine.go
@@ -0,0 +1,201 @@
+package ddns
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"strings"
+
+	"github.com/volcengine/volcengine-go-sdk/service/dns"
+	"github.com/volcengine/volcengine-go-sdk/volcengine"
+	"github.com/volcengine/volcengine-go-sdk/volcengine/credentials"
+	"github.com/volcengine/volcengine-go-sdk/volcengine/session"
+	"github.com/volcengine/volcengine-go-sdk/volcengine/volcengineerr"
+)
+
+const volcengineDNSRegion = "cn-beijing"
+
+type Volcengine struct{}
+
+func NewVolcengine() *Volcengine { return &Volcengine{} }
+
+func (v *Volcengine) Name() string { return "volcengine" }
+
+func (v *Volcengine) Verify(ctx context.Context, cred Credentials) error {
+	client, err := v.client(cred)
+	if err != nil {
+		return err
+	}
+	_, err = client.ListZonesWithContext(ctx, &dns.ListZonesInput{
+		PageNumber: volcengine.Int32(1),
+		PageSize:   volcengine.Int32(1),
+	})
+	return wrapVolcengineErr(err)
+}
+
+func (v *Volcengine) HasZone(ctx context.Context, cred Credentials, zone string) (bool, error) {
+	client, err := v.client(cred)
+	if err != nil {
+		return false, err
+	}
+	_, err = v.zoneID(ctx, client, zone)
+	return zoneLookupOK(err)
+}
+
+func (v *Volcengine) GetRecordIP(ctx context.Context, cred Credentials, rootDomain, recordName, recordType string) (string, error) {
+	client, err := v.client(cred)
+	if err != nil {
+		return "", err
+	}
+	zid, err := v.zoneID(ctx, client, rootDomain)
+	if err != nil {
+		return "", err
+	}
+	rec, err := v.findRecord(ctx, client, zid, volcengineHost(recordName), recordType)
+	if err != nil {
+		return "", err
+	}
+	if rec != nil && rec.Value != nil {
+		return *rec.Value, nil
+	}
+	return "", nil
+}
+
+func (v *Volcengine) UpdateRecord(ctx context.Context, cred Credentials, rootDomain, recordName, recordType, ip string) error {
+	client, err := v.client(cred)
+	if err != nil {
+		return err
+	}
+	zid, err := v.zoneID(ctx, client, rootDomain)
+	if err != nil {
+		return err
+	}
+	host := volcengineHost(recordName)
+	existing, err := v.findRecord(ctx, client, zid, host, recordType)
+	if err != nil {
+		return err
+	}
+	if existing != nil && existing.RecordID != nil {
+		line := volcengineLine(existing)
+		_, err = client.UpdateRecordWithContext(ctx, &dns.UpdateRecordInput{
+			RecordID: existing.RecordID,
+			Host:     volcengine.String(host),
+			Line:     volcengine.String(line),
+			Type:     volcengine.String(recordType),
+			Value:    volcengine.String(ip),
+			TTL:      volcengine.Int32(600),
+		})
+		return wrapVolcengineErr(err)
+	}
+
+	_, err = client.CreateRecordWithContext(ctx, &dns.CreateRecordInput{
+		ZID:   volcengine.Int64(zid),
+		Host:  volcengine.String(host),
+		Type:  volcengine.String(recordType),
+		Value: volcengine.String(ip),
+		Line:  volcengine.String("default"),
+		TTL:   volcengine.Int32(600),
+	})
+	return wrapVolcengineErr(err)
+}
+
+func (v *Volcengine) client(cred Credentials) (*dns.DNS, error) {
+	if cred.Token == "" || cred.Secret == "" {
+		return nil, fmt.Errorf("火山引擎 DNS 凭证未配置")
+	}
+	cfg := volcengine.NewConfig().
+		WithRegion(volcengineDNSRegion).
+		WithCredentials(credentials.NewStaticCredentials(cred.Token, cred.Secret, ""))
+	sess, err := session.NewSession(cfg)
+	if err != nil {
+		return nil, err
+	}
+	return dns.New(sess), nil
+}
+
+func (v *Volcengine) zoneID(ctx context.Context, client *dns.DNS, rootDomain string) (int64, error) {
+	rootDomain = strings.ToLower(strings.TrimSpace(rootDomain))
+	var page int32 = 1
+	const pageSize int32 = 100
+	for {
+		out, err := client.ListZonesWithContext(ctx, &dns.ListZonesInput{
+			Key:        volcengine.String(rootDomain),
+			PageNumber: volcengine.Int32(page),
+			PageSize:   volcengine.Int32(pageSize),
+		})
+		if err != nil {
+			return 0, wrapVolcengineErr(err)
+		}
+		for _, zone := range out.Zones {
+			if zone.ZoneName != nil && strings.EqualFold(*zone.ZoneName, rootDomain) && zone.ZID != nil {
+				return int64(*zone.ZID), nil
+			}
+		}
+		total := int32(0)
+		if out.Total != nil {
+			total = *out.Total
+		}
+		if len(out.Zones) == 0 || page*pageSize >= total {
+			break
+		}
+		page++
+	}
+	return 0, fmt.Errorf("未找到域名 %s 对应的火山引擎 DNS 域名", rootDomain)
+}
+
+func (v *Volcengine) findRecord(ctx context.Context, client *dns.DNS, zid int64, host, recordType string) (*dns.RecordForListRecordsOutput, error) {
+	var page int32 = 1
+	const pageSize int32 = 100
+	for {
+		out, err := client.ListRecordsWithContext(ctx, &dns.ListRecordsInput{
+			ZID:        volcengine.Int64(zid),
+			Host:       volcengine.String(host),
+			Type:       volcengine.String(recordType),
+			PageNumber: volcengine.Int32(page),
+			PageSize:   volcengine.Int32(pageSize),
+		})
+		if err != nil {
+			return nil, wrapVolcengineErr(err)
+		}
+		for _, rec := range out.Records {
+			if rec.Host != nil && *rec.Host == host && rec.Type != nil && strings.EqualFold(*rec.Type, recordType) {
+				return rec, nil
+			}
+		}
+		total := int32(0)
+		if out.TotalCount != nil {
+			total = *out.TotalCount
+		}
+		if len(out.Records) == 0 || page*pageSize >= total {
+			break
+		}
+		page++
+	}
+	return nil, nil
+}
+
+func volcengineHost(recordName string) string {
+	return subDomainForRecord(recordName)
+}
+
+func volcengineLine(rec *dns.RecordForListRecordsOutput) string {
+	if rec != nil && rec.Line != nil && strings.TrimSpace(*rec.Line) != "" {
+		return *rec.Line
+	}
+	return "default"
+}
+
+func wrapVolcengineErr(err error) error {
+	if err == nil {
+		return nil
+	}
+	var sdkErr volcengineerr.RequestFailure
+	if errors.As(err, &sdkErr) {
+		return fmt.Errorf("火山引擎 DNS API 错误:%s", sdkErr.Message())
+	}
+	var apiErr volcengineerr.Error
+	if errors.As(err, &apiErr) {
+		return fmt.Errorf("火山引擎 DNS API 错误:%s", apiErr.Message())
+	}
+	return err
+}
diff --git a/internal/ddns/volcengine_acme.go b/internal/ddns/volcengine_acme.go
new file mode 100644
index 0000000..d286751
--- /dev/null
+++ b/internal/ddns/volcengine_acme.go
@@ -0,0 +1,89 @@
+package ddns
+
+import (
+	"context"
+	"strings"
+
+	"github.com/volcengine/volcengine-go-sdk/service/dns"
+	"github.com/volcengine/volcengine-go-sdk/volcengine"
+)
+
+// UpsertTXTRecord creates or updates a TXT record for ACME DNS-01.
+func (v *Volcengine) UpsertTXTRecord(ctx context.Context, cred Credentials, fqdn, value string) error {
+	zone, host, err := ResolveZoneForFQDN(ctx, func(candidate string) (bool, error) {
+		return v.HasZone(ctx, cred, candidate)
+	}, fqdn)
+	if err != nil {
+		return err
+	}
+	return v.UpdateRecord(ctx, cred, zone, host, "TXT", value)
+}
+
+// DeleteTXTRecord removes TXT records matching value at fqdn (ACME cleanup).
+func (v *Volcengine) DeleteTXTRecord(ctx context.Context, cred Credentials, fqdn, value string) error {
+	zone, host, err := ResolveZoneForFQDN(ctx, func(candidate string) (bool, error) {
+		return v.HasZone(ctx, cred, candidate)
+	}, fqdn)
+	if err != nil {
+		return err
+	}
+	return v.deleteTXTRecords(ctx, cred, zone, host, value)
+}
+
+func (v *Volcengine) deleteTXTRecords(ctx context.Context, cred Credentials, rootDomain, recordName, value string) error {
+	client, err := v.client(cred)
+	if err != nil {
+		return err
+	}
+	zid, err := v.zoneID(ctx, client, rootDomain)
+	if err != nil {
+		return err
+	}
+	host := volcengineHost(recordName)
+	records, err := v.listRecords(ctx, client, zid, host, "TXT")
+	if err != nil {
+		return err
+	}
+	for _, rec := range records {
+		if rec.Value == nil || strings.TrimSpace(*rec.Value) != strings.TrimSpace(value) {
+			continue
+		}
+		if rec.RecordID == nil {
+			continue
+		}
+		if _, err := client.DeleteRecordWithContext(ctx, &dns.DeleteRecordInput{
+			RecordID: rec.RecordID,
+		}); err != nil {
+			return wrapVolcengineErr(err)
+		}
+	}
+	return nil
+}
+
+func (v *Volcengine) listRecords(ctx context.Context, client *dns.DNS, zid int64, host, recordType string) ([]*dns.RecordForListRecordsOutput, error) {
+	var out []*dns.RecordForListRecordsOutput
+	var page int32 = 1
+	const pageSize int32 = 100
+	for {
+		resp, err := client.ListRecordsWithContext(ctx, &dns.ListRecordsInput{
+			ZID:        volcengine.Int64(zid),
+			Host:       volcengine.String(host),
+			Type:       volcengine.String(recordType),
+			PageNumber: volcengine.Int32(page),
+			PageSize:   volcengine.Int32(pageSize),
+		})
+		if err != nil {
+			return nil, wrapVolcengineErr(err)
+		}
+		out = append(out, resp.Records...)
+		total := int32(0)
+		if resp.TotalCount != nil {
+			total = *resp.TotalCount
+		}
+		if len(resp.Records) == 0 || page*pageSize >= total {
+			break
+		}
+		page++
+	}
+	return out, nil
+}
diff --git a/internal/ddns/volcengine_acme_test.go b/internal/ddns/volcengine_acme_test.go
new file mode 100644
index 0000000..6363053
--- /dev/null
+++ b/internal/ddns/volcengine_acme_test.go
@@ -0,0 +1,21 @@
+package ddns
+
+import (
+	"context"
+	"testing"
+)
+
+func TestResolveZoneForACMEChallenge(t *testing.T) {
+	zones := map[string]bool{
+		"example.com": true,
+	}
+	zone, host, err := ResolveZoneForFQDN(context.Background(), func(candidate string) (bool, error) {
+		return zones[candidate], nil
+	}, "_acme-challenge.example.com")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if zone != "example.com" || host != "_acme-challenge" {
+		t.Fatalf("unexpected zone=%s host=%s", zone, host)
+	}
+}
diff --git a/internal/ddns/zone.go b/internal/ddns/zone.go
new file mode 100644
index 0000000..fb1a19b
--- /dev/null
+++ b/internal/ddns/zone.go
@@ -0,0 +1,87 @@
+package ddns
+
+import (
+	"context"
+	"fmt"
+	"strings"
+)
+
+type zoneChecker func(zone string) (bool, error)
+
+// ResolveZoneForFQDN finds the longest DNS zone suffix that exists at the provider.
+// For delegated zones (e.g. sub.example.com hosted separately), it prefers sub.example.com over example.com.
+func ResolveZoneForFQDN(ctx context.Context, check zoneChecker, fqdn string) (zone, host string, err error) {
+	fqdn = strings.ToLower(strings.TrimSpace(strings.TrimSuffix(fqdn, ".")))
+	if fqdn == "" {
+		return "", "", fmt.Errorf("域名不能为空")
+	}
+
+	if strings.HasPrefix(fqdn, "*.") {
+		zone, host, err := ResolveZoneForFQDN(ctx, check, strings.TrimPrefix(fqdn, "*."))
+		if err != nil {
+			return "", "", err
+		}
+		if host == "@" {
+			return zone, "*", nil
+		}
+		return zone, "*." + host, nil
+	}
+
+	parts := strings.Split(fqdn, ".")
+	if len(parts) < 2 {
+		return "", "", fmt.Errorf("域名格式无效: %s", fqdn)
+	}
+
+	for i := 0; i < len(parts)-1; i++ {
+		candidate := strings.Join(parts[i:], ".")
+		ok, err := check(candidate)
+		if err != nil {
+			return "", "", err
+		}
+		if !ok {
+			continue
+		}
+		host := "@"
+		if i > 0 {
+			host = strings.Join(parts[:i], ".")
+		}
+		return candidate, host, nil
+	}
+	return "", "", fmt.Errorf("未在 DNS 服务商中找到域名 %s 对应的 Zone", fqdn)
+}
+
+func FQDNFromRecord(cfg Config, name string) string {
+	name = strings.TrimSpace(strings.ToLower(name))
+	if name == "" {
+		return strings.ToLower(strings.TrimSpace(cfg.RootDomain))
+	}
+	if strings.Contains(name, ".") || strings.HasPrefix(name, "*.") {
+		root, record, err := parseDomainLine(name)
+		if err == nil {
+			return FormatDomainLine(root, record)
+		}
+		return strings.TrimSuffix(name, ".")
+	}
+	return FormatDomainLine(cfg.RootDomain, name)
+}
+
+func isZoneNotFound(err error) bool {
+	if err == nil {
+		return false
+	}
+	msg := strings.ToLower(err.Error())
+	return strings.Contains(msg, "未找到") ||
+		strings.Contains(msg, "not found") ||
+		strings.Contains(msg, "resourcenotfound") ||
+		strings.Contains(msg, "invaliddomain")
+}
+
+func zoneLookupOK(err error) (bool, error) {
+	if err == nil {
+		return true, nil
+	}
+	if isZoneNotFound(err) {
+		return false, nil
+	}
+	return false, err
+}
diff --git a/internal/ddns/zone_test.go b/internal/ddns/zone_test.go
new file mode 100644
index 0000000..4cc9216
--- /dev/null
+++ b/internal/ddns/zone_test.go
@@ -0,0 +1,74 @@
+package ddns
+
+import (
+	"context"
+	"testing"
+)
+
+func TestResolveZoneForFQDNDelegatedSubdomain(t *testing.T) {
+	zones := map[string]bool{
+		"sub.example.com": true,
+	}
+	zone, host, err := ResolveZoneForFQDN(context.Background(), func(candidate string) (bool, error) {
+		return zones[candidate], nil
+	}, "sub.example.com")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if zone != "sub.example.com" || host != "@" {
+		t.Fatalf("unexpected zone=%s host=%s", zone, host)
+	}
+}
+
+func TestResolveZoneForFQDNPrefersLongestZone(t *testing.T) {
+	zones := map[string]bool{
+		"sub.example.com": true,
+		"example.com":     true,
+	}
+	zone, host, err := ResolveZoneForFQDN(context.Background(), func(candidate string) (bool, error) {
+		return zones[candidate], nil
+	}, "www.sub.example.com")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if zone != "sub.example.com" || host != "www" {
+		t.Fatalf("unexpected zone=%s host=%s", zone, host)
+	}
+}
+
+func TestResolveZoneForFQDNClassicSubdomain(t *testing.T) {
+	zones := map[string]bool{
+		"example.com": true,
+	}
+	zone, host, err := ResolveZoneForFQDN(context.Background(), func(candidate string) (bool, error) {
+		return zones[candidate], nil
+	}, "s.example.com")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if zone != "example.com" || host != "s" {
+		t.Fatalf("unexpected zone=%s host=%s", zone, host)
+	}
+}
+
+func TestResolveZoneForFQDNWildcard(t *testing.T) {
+	zones := map[string]bool{
+		"sub.example.com": true,
+	}
+	zone, host, err := ResolveZoneForFQDN(context.Background(), func(candidate string) (bool, error) {
+		return zones[candidate], nil
+	}, "*.sub.example.com")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if zone != "sub.example.com" || host != "*" {
+		t.Fatalf("unexpected zone=%s host=%s", zone, host)
+	}
+}
+
+func TestFQDNFromRecordStoredFQDN(t *testing.T) {
+	cfg := Config{RootDomain: "example.com"}
+	if FQDNFromRecord(cfg, "sub.example.com") != "sub.example.com" {
+		t.Fatal("expected stored fqdn preserved")
+	}
+}
diff --git a/internal/discovery/service.go b/internal/discovery/service.go
new file mode 100644
index 0000000..fc165de
--- /dev/null
+++ b/internal/discovery/service.go
@@ -0,0 +1,127 @@
+package discovery
+
+import (
+	"context"
+	"fmt"
+	"net"
+	"net/http"
+	"strings"
+	"time"
+)
+
+type Service struct {
+	client *http.Client
+}
+
+type Result struct {
+	Name       string `json:"name"`
+	Port       int    `json:"port"`
+	Host       string `json:"host"`
+	Upstream   string `json:"upstream"`
+	Detected   bool   `json:"detected"`
+	Platform   string `json:"platform"`
+	Title      string `json:"title,omitempty"`
+	Suggestion string `json:"suggestion,omitempty"`
+}
+
+type catalogEntry struct {
+	Name     string
+	Port     int
+	Platform string
+	Keywords []string
+}
+
+var catalog = []catalogEntry{
+	{Name: "飞牛 fnOS", Port: 5666, Platform: "fnos", Keywords: []string{"fnos", "飞牛", "fnos.cn"}},
+	{Name: "AList", Port: 5244, Platform: "alist", Keywords: []string{"alist"}},
+	{Name: "Jellyfin", Port: 8096, Platform: "jellyfin", Keywords: []string{"jellyfin"}},
+	{Name: "PhotoPrism", Port: 2342, Platform: "photoprism", Keywords: []string{"photoprism"}},
+	{Name: "qBittorrent", Port: 8080, Platform: "qbittorrent", Keywords: []string{"qbittorrent"}},
+	{Name: "Portainer", Port: 9000, Platform: "portainer", Keywords: []string{"portainer"}},
+}
+
+func New() *Service {
+	return &Service{
+		client: &http.Client{Timeout: 2 * time.Second},
+	}
+}
+
+func (s *Service) Scan(ctx context.Context, host string) []Result {
+	host = strings.TrimSpace(host)
+	if host == "" {
+		host = "127.0.0.1"
+	}
+	out := make([]Result, 0, len(catalog))
+	for _, item := range catalog {
+		result := Result{
+			Name:     item.Name,
+			Port:     item.Port,
+			Host:     host,
+			Upstream: fmt.Sprintf("http://%s:%d", host, item.Port),
+			Platform: item.Platform,
+		}
+		if !s.portOpen(ctx, host, item.Port) {
+			out = append(out, result)
+			continue
+		}
+		result.Detected = true
+		title, body := s.probeHTTP(ctx, host, item.Port)
+		result.Title = title
+		if s.matchPlatform(item, title, body) {
+			result.Platform = item.Platform
+			if item.Platform == "fnos" {
+				result.Suggestion = "检测到飞牛 fnOS 服务,可使用 nas.<域名> 创建反向代理"
+			}
+		}
+		out = append(out, result)
+	}
+	return out
+}
+
+func (s *Service) portOpen(ctx context.Context, host string, port int) bool {
+	dialer := net.Dialer{Timeout: 800 * time.Millisecond}
+	conn, err := dialer.DialContext(ctx, "tcp", fmt.Sprintf("%s:%d", host, port))
+	if err != nil {
+		return false
+	}
+	conn.Close()
+	return true
+}
+
+func (s *Service) probeHTTP(ctx context.Context, host string, port int) (string, string) {
+	url := fmt.Sprintf("http://%s:%d/", host, port)
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+	if err != nil {
+		return "", ""
+	}
+	resp, err := s.client.Do(req)
+	if err != nil {
+		return "", ""
+	}
+	defer resp.Body.Close()
+	bodyBytes := make([]byte, 4096)
+	n, _ := resp.Body.Read(bodyBytes)
+	body := strings.ToLower(string(bodyBytes[:n]))
+	title := extractTitle(body)
+	return title, body
+}
+
+func (s *Service) matchPlatform(item catalogEntry, title, body string) bool {
+	content := strings.ToLower(title + " " + body)
+	for _, keyword := range item.Keywords {
+		if strings.Contains(content, strings.ToLower(keyword)) {
+			return true
+		}
+	}
+	return false
+}
+
+func extractTitle(body string) string {
+	lower := strings.ToLower(body)
+	start := strings.Index(lower, "")
+	end := strings.Index(lower, "")
+	if start >= 0 && end > start {
+		return strings.TrimSpace(body[start+7 : end])
+	}
+	return ""
+}
diff --git a/internal/frp-1.0.2/config.go b/internal/frp-1.0.2/config.go
new file mode 100644
index 0000000..e6a731b
--- /dev/null
+++ b/internal/frp-1.0.2/config.go
@@ -0,0 +1,208 @@
+package frp
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	"github.com/fonu/fonu/internal/secret"
+	"github.com/fonu/fonu/internal/settings"
+)
+
+const (
+	maskedToken = "********"
+)
+
+type Config struct {
+	Enabled       bool     `json:"enabled"`
+	ServerAddr    string   `json:"server_addr"`
+	ServerPort    int      `json:"server_port"`
+	AuthToken     string   `json:"auth_token"`
+	HasAuthToken  bool     `json:"has_auth_token"`
+	TLSEnabled    bool     `json:"tls_enabled"`
+	CustomDomains []string `json:"custom_domains"`
+}
+
+type SaveInput struct {
+	Enabled       bool
+	ServerAddr    string
+	ServerPort    int
+	AuthToken     string
+	TLSEnabled    bool
+	CustomDomains []string
+}
+
+type Store struct {
+	settings  *settings.Store
+	secretBox *secret.Box
+}
+
+func NewStore(settingsStore *settings.Store, secretBox *secret.Box) *Store {
+	return &Store{settings: settingsStore, secretBox: secretBox}
+}
+
+func (s *Store) Load(ctx context.Context) (Config, error) {
+	enabled, _ := s.settings.GetBool(ctx, settings.KeyFRPEnabled)
+	serverAddr, _ := s.settings.Get(ctx, settings.KeyFRPServerAddr)
+	serverPort, _ := s.settings.GetInt(ctx, settings.KeyFRPServerPort)
+	if serverPort <= 0 {
+		serverPort = 7000
+	}
+	tlsEnabled, _ := s.settings.GetBool(ctx, settings.KeyFRPTLSEnabled)
+	domains, err := s.loadDomains(ctx)
+	if err != nil {
+		return Config{}, err
+	}
+	encToken, _ := s.settings.Get(ctx, settings.KeyFRPAuthToken)
+	hasToken := strings.TrimSpace(encToken) != ""
+	token := ""
+	if hasToken {
+		token = maskedToken
+	}
+	return Config{
+		Enabled:       enabled,
+		ServerAddr:    serverAddr,
+		ServerPort:    serverPort,
+		AuthToken:     token,
+		HasAuthToken:  hasToken,
+		TLSEnabled:    tlsEnabled,
+		CustomDomains: domains,
+	}, nil
+}
+
+func (s *Store) LoadRuntime(ctx context.Context) (Config, string, error) {
+	cfg, err := s.Load(ctx)
+	if err != nil {
+		return Config{}, "", err
+	}
+	if !cfg.HasAuthToken {
+		return cfg, "", nil
+	}
+	enc, _ := s.settings.Get(ctx, settings.KeyFRPAuthToken)
+	plain, err := s.secretBox.Decrypt(enc)
+	if err != nil {
+		return cfg, "", secret.DecryptHint(err)
+	}
+	return cfg, plain, nil
+}
+
+func (s *Store) Save(ctx context.Context, in SaveInput, keepToken bool) error {
+	if err := validateSaveInput(in, keepToken); err != nil {
+		return err
+	}
+	if err := s.settings.SetBool(ctx, settings.KeyFRPEnabled, in.Enabled); err != nil {
+		return err
+	}
+	if err := s.settings.Set(ctx, settings.KeyFRPServerAddr, normalizeServerAddr(in.ServerAddr)); err != nil {
+		return err
+	}
+	port := in.ServerPort
+	if port <= 0 {
+		port = 7000
+	}
+	if err := s.settings.SetInt(ctx, settings.KeyFRPServerPort, port); err != nil {
+		return err
+	}
+	if err := s.settings.SetBool(ctx, settings.KeyFRPTLSEnabled, in.TLSEnabled); err != nil {
+		return err
+	}
+	domains := normalizeDomains(in.CustomDomains)
+	raw, err := json.Marshal(domains)
+	if err != nil {
+		return err
+	}
+	if err := s.settings.Set(ctx, settings.KeyFRPCustomDomains, string(raw)); err != nil {
+		return err
+	}
+	token := strings.TrimSpace(in.AuthToken)
+	if token != "" && token != maskedToken {
+		enc, err := s.secretBox.Encrypt(token)
+		if err != nil {
+			return err
+		}
+		if err := s.settings.Set(ctx, settings.KeyFRPAuthToken, enc); err != nil {
+			return err
+		}
+	} else if in.Enabled && !keepToken {
+		return fmt.Errorf("请填写 FRP 认证 Token")
+	}
+	return nil
+}
+
+func (s *Store) SetLastError(ctx context.Context, msg string) error {
+	return s.settings.Set(ctx, settings.KeyFRPLastError, strings.TrimSpace(msg))
+}
+
+func (s *Store) LastError(ctx context.Context) string {
+	v, _ := s.settings.Get(ctx, settings.KeyFRPLastError)
+	return strings.TrimSpace(v)
+}
+
+func (s *Store) StartedAt(ctx context.Context) string {
+	v, _ := s.settings.Get(ctx, settings.KeyFRPStartedAt)
+	return strings.TrimSpace(v)
+}
+
+func (s *Store) SetStartedAt(ctx context.Context, value string) error {
+	return s.settings.Set(ctx, settings.KeyFRPStartedAt, strings.TrimSpace(value))
+}
+
+func (s *Store) loadDomains(ctx context.Context) ([]string, error) {
+	raw, _ := s.settings.Get(ctx, settings.KeyFRPCustomDomains)
+	raw = strings.TrimSpace(raw)
+	if raw == "" {
+		return nil, nil
+	}
+	var domains []string
+	if err := json.Unmarshal([]byte(raw), &domains); err != nil {
+		return nil, fmt.Errorf("FRP 域名配置无效")
+	}
+	return normalizeDomains(domains), nil
+}
+
+func normalizeServerAddr(addr string) string {
+	addr = strings.TrimSpace(addr)
+	addr = strings.TrimPrefix(addr, "https://")
+	addr = strings.TrimPrefix(addr, "http://")
+	if idx := strings.IndexAny(addr, "/?#"); idx >= 0 {
+		addr = addr[:idx]
+	}
+	return strings.TrimSpace(addr)
+}
+
+func normalizeDomains(domains []string) []string {
+	seen := make(map[string]bool)
+	var out []string
+	for _, d := range domains {
+		d = strings.TrimSpace(d)
+		if d == "" || seen[strings.ToLower(d)] {
+			continue
+		}
+		seen[strings.ToLower(d)] = true
+		out = append(out, d)
+	}
+	return out
+}
+
+func validateSaveInput(in SaveInput, keepToken bool) error {
+	if !in.Enabled {
+		return nil
+	}
+	if strings.TrimSpace(in.ServerAddr) == "" {
+		return fmt.Errorf("请填写 FRP 服务器地址")
+	}
+	if in.ServerPort < 0 || in.ServerPort > 65535 {
+		return fmt.Errorf("FRP 服务器端口无效")
+	}
+	if len(normalizeDomains(in.CustomDomains)) == 0 {
+		return fmt.Errorf("请至少填写一个穿透域名")
+	}
+	token := strings.TrimSpace(in.AuthToken)
+	if token == "" || token == maskedToken {
+		if !keepToken {
+			return fmt.Errorf("请填写 FRP 认证 Token")
+		}
+	}
+	return nil
+}
diff --git a/internal/frp-1.0.2/config_test.go b/internal/frp-1.0.2/config_test.go
new file mode 100644
index 0000000..4be98f0
--- /dev/null
+++ b/internal/frp-1.0.2/config_test.go
@@ -0,0 +1,12 @@
+package frp
+
+import "testing"
+
+func TestNormalizeServerAddr(t *testing.T) {
+	if got := normalizeServerAddr("http://127.0.0.1"); got != "127.0.0.1" {
+		t.Fatalf("got %q", got)
+	}
+	if got := normalizeServerAddr("https://frp.example.com/path"); got != "frp.example.com" {
+		t.Fatalf("got %q", got)
+	}
+}
diff --git a/internal/frp-1.0.2/generate.go b/internal/frp-1.0.2/generate.go
new file mode 100644
index 0000000..d3736de
--- /dev/null
+++ b/internal/frp-1.0.2/generate.go
@@ -0,0 +1,70 @@
+package frp
+
+import (
+	"fmt"
+	"path/filepath"
+	"strings"
+
+	"github.com/fonu/fonu/internal/config"
+)
+
+func Generate(cfg config.Config, frpCfg Config, authToken string, httpPort, httpsPort int) (string, error) {
+	if strings.TrimSpace(frpCfg.ServerAddr) == "" {
+		return "", fmt.Errorf("FRP 服务器地址未配置")
+	}
+	if strings.TrimSpace(authToken) == "" {
+		return "", fmt.Errorf("FRP 认证 Token 未配置")
+	}
+	domains := normalizeDomains(frpCfg.CustomDomains)
+	if len(domains) == 0 {
+		return "", fmt.Errorf("请至少填写一个穿透域名")
+	}
+	if httpPort <= 0 || httpsPort <= 0 {
+		return "", fmt.Errorf("Nginx 端口未配置")
+	}
+
+	var b strings.Builder
+	b.WriteString(fmt.Sprintf("serverAddr = %q\n", frpCfg.ServerAddr))
+	b.WriteString(fmt.Sprintf("serverPort = %d\n\n", frpCfg.ServerPort))
+
+	b.WriteString(fmt.Sprintf("log.to = %q\n", tomlPath(cfg.FrpLogPath())))
+	b.WriteString("log.level = \"info\"\n\n")
+
+	b.WriteString("auth.method = \"token\"\n")
+	b.WriteString(fmt.Sprintf("auth.token = %q\n\n", authToken))
+
+	if frpCfg.TLSEnabled {
+		b.WriteString("transport.tls.enable = true\n\n")
+	} else {
+		b.WriteString("transport.tls.enable = false\n\n")
+	}
+
+	b.WriteString("[[proxies]]\n")
+	b.WriteString("name = \"fonu-nginx-http\"\n")
+	b.WriteString("type = \"http\"\n")
+	b.WriteString("localIP = \"127.0.0.1\"\n")
+	b.WriteString(fmt.Sprintf("localPort = %d\n", httpPort))
+	b.WriteString(fmt.Sprintf("customDomains = %s\n\n", formatTOMLStringArray(domains)))
+
+	b.WriteString("[[proxies]]\n")
+	b.WriteString("name = \"fonu-nginx-https\"\n")
+	b.WriteString("type = \"https\"\n")
+	b.WriteString(fmt.Sprintf("customDomains = %s\n", formatTOMLStringArray(domains)))
+	b.WriteString("[proxies.plugin]\n")
+	b.WriteString("type = \"https2https\"\n")
+	b.WriteString(fmt.Sprintf("localAddr = \"127.0.0.1:%d\"\n", httpsPort))
+
+	return b.String(), nil
+}
+
+func tomlPath(path string) string {
+	return filepath.ToSlash(filepath.Clean(path))
+}
+
+func formatTOMLStringArray(items []string) string {
+	parts := make([]string, 0, len(items))
+	for _, item := range items {
+		parts = append(parts, fmt.Sprintf("%q", item))
+	}
+	return "[" + strings.Join(parts, ", ") + "]"
+}
diff --git a/internal/frp-1.0.2/generate_test.go b/internal/frp-1.0.2/generate_test.go
new file mode 100644
index 0000000..bab99a0
--- /dev/null
+++ b/internal/frp-1.0.2/generate_test.go
@@ -0,0 +1,58 @@
+package frp
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/fonu/fonu/internal/config"
+)
+
+func TestGenerateNginxGateway(t *testing.T) {
+	cfg := config.Config{
+		DataDir:               "/data",
+		NginxDefaultHTTPPort:  18080,
+		NginxDefaultHTTPSPort: 9443,
+		FrpPIDFile:            "/data/frp/frpc.pid",
+	}
+	frpCfg := Config{
+		ServerAddr:    "vps.example.com",
+		ServerPort:    7000,
+		TLSEnabled:    true,
+		CustomDomains: []string{"nas.example.com", "*.example.com"},
+	}
+	out, err := Generate(cfg, frpCfg, "secret-token", 18080, 9443)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !strings.Contains(out, `serverAddr = "vps.example.com"`) {
+		t.Fatalf("missing server addr: %s", out)
+	}
+	if !strings.Contains(out, `localPort = 18080`) {
+		t.Fatalf("missing http local port: %s", out)
+	}
+	if !strings.Contains(out, `type = "https2https"`) {
+		t.Fatalf("missing https plugin: %s", out)
+	}
+	if !strings.Contains(out, `localAddr = "127.0.0.1:9443"`) {
+		t.Fatalf("missing https local addr: %s", out)
+	}
+	if !strings.Contains(out, `transport.tls.enable = true`) {
+		t.Fatalf("missing tls enable: %s", out)
+	}
+}
+
+func TestGenerateDisablesTLSByDefault(t *testing.T) {
+	cfg := config.Config{DataDir: "/data", FrpPIDFile: "/data/frp/frpc.pid"}
+	frpCfg := Config{
+		ServerAddr:    "vps.example.com",
+		ServerPort:    7000,
+		CustomDomains: []string{"nas.example.com"},
+	}
+	out, err := Generate(cfg, frpCfg, "secret-token", 18080, 9443)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !strings.Contains(out, `transport.tls.enable = false`) {
+		t.Fatalf("expected tls disabled: %s", out)
+	}
+}
diff --git a/internal/frp-1.0.2/logs.go b/internal/frp-1.0.2/logs.go
new file mode 100644
index 0000000..e7560b8
--- /dev/null
+++ b/internal/frp-1.0.2/logs.go
@@ -0,0 +1,27 @@
+package frp
+
+import (
+	"os"
+	"strings"
+)
+
+func ReadLogTail(path string, limit int) ([]string, error) {
+	if limit <= 0 {
+		limit = 100
+	}
+	data, err := os.ReadFile(path)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return []string{}, nil
+		}
+		return nil, err
+	}
+	lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
+	if len(lines) == 0 || (len(lines) == 1 && lines[0] == "") {
+		return []string{}, nil
+	}
+	if len(lines) > limit {
+		lines = lines[len(lines)-limit:]
+	}
+	return lines, nil
+}
diff --git a/internal/frp-1.0.2/manager.go b/internal/frp-1.0.2/manager.go
new file mode 100644
index 0000000..6744016
--- /dev/null
+++ b/internal/frp-1.0.2/manager.go
@@ -0,0 +1,421 @@
+package frp
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"log/slog"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"syscall"
+	"time"
+
+	"github.com/fonu/fonu/internal/config"
+	"github.com/fonu/fonu/internal/nginx"
+)
+
+const (
+	frpcCmdTimeout    = 8 * time.Second
+	frpcStartWait     = 5 * time.Second
+	frpcTerminateWait = 3 * time.Second
+)
+
+type Manager struct {
+	cfg    config.Config
+	store  *Store
+	logger *slog.Logger
+}
+
+type ApplyResult struct {
+	Message string
+}
+
+func NewManager(cfg config.Config, store *Store, logger *slog.Logger) *Manager {
+	return &Manager{cfg: cfg, store: store, logger: logger.With("module", "FRP")}
+}
+
+func (m *Manager) EnsureDirs() error {
+	return os.MkdirAll(m.cfg.FrpDir(), 0o755)
+}
+
+func (m *Manager) Apply(ctx context.Context, in SaveInput) (ApplyResult, error) {
+	existing, err := m.store.Load(ctx)
+	if err != nil {
+		return ApplyResult{}, err
+	}
+	keepToken := existing.HasAuthToken && (strings.TrimSpace(in.AuthToken) == "" || in.AuthToken == maskedToken)
+	if err := m.store.Save(ctx, in, keepToken); err != nil {
+		return ApplyResult{}, err
+	}
+
+	cfg, err := m.store.Load(ctx)
+	if err != nil {
+		return ApplyResult{}, err
+	}
+
+	if !cfg.Enabled {
+		if err := m.Stop(ctx); err != nil {
+			return ApplyResult{}, err
+		}
+		m.clearStarted(ctx)
+		_ = m.store.SetLastError(ctx, "")
+		return ApplyResult{Message: "FRP 已停用"}, nil
+	}
+
+	runtimeCfg, token, err := m.store.LoadRuntime(ctx)
+	if err != nil {
+		_ = m.store.SetLastError(ctx, err.Error())
+		return ApplyResult{}, err
+	}
+
+	if err := m.EnsureDirs(); err != nil {
+		return ApplyResult{}, err
+	}
+
+	content, err := Generate(m.cfg, runtimeCfg, token, m.cfg.NginxDefaultHTTPPort, m.cfg.NginxDefaultHTTPSPort)
+	if err != nil {
+		_ = m.store.SetLastError(ctx, err.Error())
+		return ApplyResult{}, err
+	}
+
+	tmpPath := m.cfg.FrpConfigPath() + ".tmp"
+	if err := os.WriteFile(tmpPath, []byte(content), 0o644); err != nil {
+		return ApplyResult{}, err
+	}
+	if err := os.Rename(tmpPath, m.cfg.FrpConfigPath()); err != nil {
+		_ = os.Remove(tmpPath)
+		return ApplyResult{}, err
+	}
+
+	if err := m.verifyConfig(ctx); err != nil {
+		_ = m.store.SetLastError(ctx, err.Error())
+		return ApplyResult{}, err
+	}
+
+	if !m.available() {
+		msg := "frpc 未安装,配置已保存"
+		_ = m.store.SetLastError(ctx, msg)
+		return ApplyResult{Message: msg}, nil
+	}
+
+	running, _ := m.isRunning()
+	if !running {
+		if err := m.start(ctx); err != nil {
+			_ = m.store.SetLastError(ctx, err.Error())
+			return ApplyResult{}, err
+		}
+		m.markStarted(ctx)
+		_ = m.store.SetLastError(ctx, "")
+		return ApplyResult{Message: "FRP 已启动"}, nil
+	}
+
+	if err := m.reload(ctx); err != nil {
+		m.logger.Warn("frpc reload failed, trying restart", "error", err)
+		if err := m.forceRestart(ctx); err != nil {
+			_ = m.store.SetLastError(ctx, err.Error())
+			return ApplyResult{}, err
+		}
+		m.markStarted(ctx)
+		_ = m.store.SetLastError(ctx, "")
+		return ApplyResult{Message: "FRP 已重新启动"}, nil
+	}
+	_ = m.store.SetLastError(ctx, "")
+	return ApplyResult{Message: "FRP 已重载"}, nil
+}
+
+func (m *Manager) Bootstrap(ctx context.Context) error {
+	cfg, err := m.store.Load(ctx)
+	if err != nil || !cfg.Enabled {
+		return nil
+	}
+	if !m.available() {
+		m.logger.Warn("frpc binary not found, skipping bootstrap", "bin", m.cfg.FrpcBin)
+		return nil
+	}
+	_, err = m.Apply(ctx, SaveInput{
+		Enabled:       cfg.Enabled,
+		ServerAddr:    cfg.ServerAddr,
+		ServerPort:    cfg.ServerPort,
+		AuthToken:     maskedToken,
+		TLSEnabled:    cfg.TLSEnabled,
+		CustomDomains: cfg.CustomDomains,
+	})
+	if err != nil {
+		m.logger.Warn("initial frpc apply skipped", "error", err.Error())
+	}
+	return nil
+}
+
+func (m *Manager) Load(ctx context.Context) (Config, error) {
+	return m.store.Load(ctx)
+}
+
+func (m *Manager) Status(ctx context.Context) Status {
+	cfg, err := m.store.Load(ctx)
+	if err != nil {
+		return Status{Message: "读取配置失败"}
+	}
+	return BuildStatus(
+		m.cfg,
+		cfg,
+		cfg.Enabled,
+		m.store.LastError(ctx),
+		m.store.StartedAt(ctx),
+		ResolveClientVersion(m.cfg.FrpcBin),
+	)
+}
+
+func (m *Manager) SyncDomains(ctx context.Context, domains []string) (ApplyResult, error) {
+	cfg, err := m.store.Load(ctx)
+	if err != nil {
+		return ApplyResult{}, err
+	}
+	merged := MergeDomains(cfg.CustomDomains, domains)
+	return m.Apply(ctx, SaveInput{
+		Enabled:       cfg.Enabled,
+		ServerAddr:    cfg.ServerAddr,
+		ServerPort:    cfg.ServerPort,
+		AuthToken:     maskedToken,
+		TLSEnabled:    cfg.TLSEnabled,
+		CustomDomains: merged,
+	})
+}
+
+func (m *Manager) Logs(ctx context.Context, limit int) ([]string, error) {
+	return ReadLogTail(m.cfg.FrpLogPath(), limit)
+}
+
+func (m *Manager) FRPSConfig(ctx context.Context) (string, error) {
+	cfg, err := m.store.Load(ctx)
+	if err != nil {
+		return "", err
+	}
+	_, token, err := m.store.LoadRuntime(ctx)
+	if err != nil {
+		return "", err
+	}
+	port := cfg.ServerPort
+	if port <= 0 {
+		port = 7000
+	}
+	return BuildFRPSConfig(port, m.cfg.NginxDefaultHTTPPort, m.cfg.NginxDefaultHTTPSPort, token), nil
+}
+
+func (m *Manager) NginxHTTPPort() int {
+	return m.cfg.NginxDefaultHTTPPort
+}
+
+func (m *Manager) NginxHTTPSPort() int {
+	return m.cfg.NginxDefaultHTTPSPort
+}
+
+func (m *Manager) verifyConfig(ctx context.Context) error {
+	if !m.available() {
+		return nil
+	}
+	return m.runFrpc(ctx, []string{"verify", "-c", m.cfg.FrpConfigPath()}, "FRP 配置校验失败")
+}
+
+func (m *Manager) markStarted(ctx context.Context) {
+	_ = m.store.SetStartedAt(ctx, time.Now().UTC().Format(time.RFC3339))
+}
+
+func (m *Manager) clearStarted(ctx context.Context) {
+	_ = m.store.SetStartedAt(ctx, "")
+}
+
+func (m *Manager) Stop(ctx context.Context) error {
+	if !m.isRunningQuick() {
+		removePIDFile(m.cfg.FrpPIDFile)
+		return nil
+	}
+	pid, err := readPIDFile(m.cfg.FrpPIDFile)
+	if err != nil {
+		removePIDFile(m.cfg.FrpPIDFile)
+		return nil
+	}
+	_ = terminateProcess(pid, syscall.SIGTERM)
+	waitProcessExit(pid, frpcTerminateWait)
+	if nginx.IsPIDAlive(pid) {
+		_ = terminateProcess(pid, syscall.SIGKILL)
+		waitProcessExit(pid, time.Second)
+	}
+	removePIDFile(m.cfg.FrpPIDFile)
+	m.clearStarted(ctx)
+	m.logger.Info("frpc stopped")
+	return nil
+}
+
+func (m *Manager) available() bool {
+	_, err := exec.LookPath(m.cfg.FrpcBin)
+	return err == nil
+}
+
+func (m *Manager) start(ctx context.Context) error {
+	if m.isRunningQuick() {
+		return m.reload(ctx)
+	}
+	args := []string{"-c", m.cfg.FrpConfigPath()}
+	if err := m.runFrpcStart(ctx, args, "FRP 启动失败"); err != nil {
+		return err
+	}
+	m.logger.Info("frpc started")
+	return nil
+}
+
+func (m *Manager) reload(ctx context.Context) error {
+	args := []string{"reload", "-c", m.cfg.FrpConfigPath()}
+	if err := m.runFrpc(ctx, args, "FRP 重载失败"); err != nil {
+		return err
+	}
+	m.logger.Info("frpc reloaded")
+	return nil
+}
+
+func (m *Manager) forceRestart(ctx context.Context) error {
+	_ = m.Stop(ctx)
+	return m.start(ctx)
+}
+
+func (m *Manager) runFrpc(ctx context.Context, args []string, prefix string) error {
+	ctx, cancel := context.WithTimeout(ctx, frpcCmdTimeout)
+	defer cancel()
+	cmd := exec.CommandContext(ctx, m.cfg.FrpcBin, args...)
+	var stderr, stdout bytes.Buffer
+	cmd.Stderr = &stderr
+	cmd.Stdout = &stdout
+	if err := cmd.Run(); err != nil {
+		msg := frpcCommandError(stdout.String(), stderr.String(), err)
+		if ctx.Err() == context.DeadlineExceeded {
+			msg = "操作超时"
+		}
+		return fmt.Errorf("%s:%s", prefix, msg)
+	}
+	return nil
+}
+
+func (m *Manager) runFrpcStart(ctx context.Context, args []string, prefix string) error {
+	cmd := exec.Command(m.cfg.FrpcBin, args...)
+	var stderr, stdout bytes.Buffer
+	cmd.Stderr = &stderr
+	cmd.Stdout = &stdout
+	if err := cmd.Start(); err != nil {
+		return fmt.Errorf("%s:%s", prefix, err.Error())
+	}
+	if cmd.Process != nil {
+		if err := writePIDFile(m.cfg.FrpPIDFile, cmd.Process.Pid); err != nil {
+			_ = cmd.Process.Kill()
+			return fmt.Errorf("%s:%s", prefix, err.Error())
+		}
+	}
+
+	waitDone := make(chan error, 1)
+	go func() { waitDone <- cmd.Wait() }()
+
+	deadline := time.Now().Add(frpcStartWait)
+	for time.Now().Before(deadline) {
+		if running, _ := m.isRunning(); running {
+			return nil
+		}
+		select {
+		case err := <-waitDone:
+			if running, _ := m.isRunning(); running {
+				return nil
+			}
+			msg := frpcCommandError(stdout.String(), stderr.String(), err)
+			m.logger.Warn("frpc exited during start", "error", msg)
+			return fmt.Errorf("%s:%s", prefix, msg)
+		default:
+		}
+		if ctx.Err() != nil {
+			return fmt.Errorf("%s:%s", prefix, "操作超时")
+		}
+		time.Sleep(100 * time.Millisecond)
+	}
+	if running, _ := m.isRunning(); running {
+		return nil
+	}
+	msg := frpcCommandError(stdout.String(), stderr.String(), nil)
+	if msg == "" {
+		msg = "未检测到运行中的进程"
+	}
+	return fmt.Errorf("%s:%s", prefix, msg)
+}
+
+func frpcCommandError(stdout, stderr string, err error) string {
+	msg := strings.TrimSpace(stderr)
+	if msg == "" {
+		msg = strings.TrimSpace(stdout)
+	}
+	if msg == "" && err != nil {
+		msg = err.Error()
+	}
+	if msg == "" {
+		msg = "进程已退出"
+	}
+	return msg
+}
+
+func writePIDFile(path string, pid int) error {
+	if pid <= 0 {
+		return fmt.Errorf("无效的 frpc 进程 ID")
+	}
+	dir := filepath.Dir(path)
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		return err
+	}
+	return os.WriteFile(path, []byte(fmt.Sprintf("%d\n", pid)), 0o644)
+}
+
+func (m *Manager) isRunning() (bool, error) {
+	pid, err := readPIDFile(m.cfg.FrpPIDFile)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return false, nil
+		}
+		removePIDFile(m.cfg.FrpPIDFile)
+		return false, nil
+	}
+	if !nginx.IsPIDAlive(pid) {
+		removePIDFile(m.cfg.FrpPIDFile)
+		return false, nil
+	}
+	return true, nil
+}
+
+func (m *Manager) isRunningQuick() bool {
+	ok, _ := m.isRunning()
+	return ok
+}
+
+func readPIDFile(path string) (int, error) {
+	return nginx.ReadPID(path)
+}
+
+func removePIDFile(path string) {
+	_ = os.Remove(path)
+}
+
+func waitProcessExit(pid int, timeout time.Duration) {
+	deadline := time.Now().Add(timeout)
+	for time.Now().Before(deadline) {
+		if !nginx.IsPIDAlive(pid) {
+			return
+		}
+		time.Sleep(100 * time.Millisecond)
+	}
+}
+
+func terminateProcess(pid int, sig syscall.Signal) error {
+	proc, err := os.FindProcess(pid)
+	if err != nil {
+		return err
+	}
+	return proc.Signal(sig)
+}
+
+func FrpConfigDir(cfg config.Config) string {
+	return filepath.Clean(cfg.FrpDir())
+}
diff --git a/internal/frp-1.0.2/status.go b/internal/frp-1.0.2/status.go
new file mode 100644
index 0000000..50cad25
--- /dev/null
+++ b/internal/frp-1.0.2/status.go
@@ -0,0 +1,157 @@
+package frp
+
+import (
+	"os"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/fonu/fonu/internal/config"
+	"github.com/fonu/fonu/internal/nginx"
+)
+
+type Status struct {
+	Enabled             bool   `json:"enabled"`
+	Connected           bool   `json:"connected"`
+	Message             string `json:"message"`
+	LastError           string `json:"last_error,omitempty"`
+	ServerEndpoint      string `json:"server_endpoint,omitempty"`
+	ConnectedAt         string `json:"connected_at,omitempty"`
+	UptimeSeconds       int64  `json:"uptime_seconds"`
+	ClientVersion       string `json:"client_version,omitempty"`
+	HTTPGatewayEnabled  bool   `json:"http_gateway_enabled"`
+	HTTPSGatewayEnabled bool   `json:"https_gateway_enabled"`
+	SyncedDomainCount   int    `json:"synced_domain_count"`
+}
+
+func BuildStatus(cfg config.Config, frpCfg Config, enabled bool, lastError, startedAt, clientVersion string) Status {
+	st := Status{
+		Enabled:           enabled,
+		Connected:         false,
+		Message:           "未启用",
+		LastError:         strings.TrimSpace(lastError),
+		ClientVersion:     clientVersion,
+		SyncedDomainCount: len(frpCfg.CustomDomains),
+	}
+	if enabled {
+		addr := strings.TrimSpace(frpCfg.ServerAddr)
+		if addr != "" {
+			port := frpCfg.ServerPort
+			if port <= 0 {
+				port = 7000
+			}
+			st.ServerEndpoint = addr + ":" + strconv.Itoa(port)
+		}
+		st.HTTPGatewayEnabled = len(frpCfg.CustomDomains) > 0
+		st.HTTPSGatewayEnabled = len(frpCfg.CustomDomains) > 0
+	}
+	if !enabled {
+		return st
+	}
+	running, err := isProcessRunning(cfg.FrpPIDFile)
+	if err != nil || !running {
+		st.Message = "未连接"
+		if st.LastError != "" {
+			st.Message = st.LastError
+		} else if hint := readLogHint(cfg.FrpLogPath()); hint != "" {
+			st.Message = hint
+		}
+		return st
+	}
+	ok, logHint := logIndicatesConnected(cfg.FrpLogPath())
+	if ok {
+		st.Connected = true
+		st.Message = "已连接"
+		st.LastError = ""
+		applyConnectedTiming(&st, startedAt)
+		return st
+	}
+	st.Message = "连接中"
+	if logHint != "" {
+		st.Message = logHint
+	}
+	if st.LastError != "" {
+		st.Message = st.LastError
+	}
+	return st
+}
+
+func applyConnectedTiming(st *Status, startedAt string) {
+	st.ConnectedAt = strings.TrimSpace(startedAt)
+	if st.ConnectedAt == "" {
+		return
+	}
+	t, err := time.Parse(time.RFC3339, st.ConnectedAt)
+	if err != nil {
+		return
+	}
+	st.UptimeSeconds = int64(time.Since(t).Seconds())
+	if st.UptimeSeconds < 0 {
+		st.UptimeSeconds = 0
+	}
+}
+
+func isProcessRunning(pidFile string) (bool, error) {
+	pid, err := nginx.ReadPID(pidFile)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return false, nil
+		}
+		return false, err
+	}
+	return nginx.IsPIDAlive(pid), nil
+}
+
+func readLogHint(path string) string {
+	lines := tailLines(path, 40)
+	for i := len(lines) - 1; i >= 0; i-- {
+		line := strings.ToLower(lines[i])
+		if strings.Contains(line, "login to server success") {
+			return ""
+		}
+		if strings.Contains(line, "connect to server error") ||
+			strings.Contains(line, "authorization failed") ||
+			strings.Contains(line, "token in login doesn't match") {
+			return sanitizeLogLine(lines[i])
+		}
+	}
+	return ""
+}
+
+func logIndicatesConnected(path string) (bool, string) {
+	lines := tailLines(path, 80)
+	for i := len(lines) - 1; i >= 0; i-- {
+		line := strings.ToLower(lines[i])
+		if strings.Contains(line, "login to server success") {
+			return true, ""
+		}
+		if strings.Contains(line, "connect to server error") ||
+			strings.Contains(line, "authorization failed") {
+			return false, sanitizeLogLine(lines[i])
+		}
+	}
+	return false, ""
+}
+
+func tailLines(path string, max int) []string {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return nil
+	}
+	lines := strings.Split(string(data), "\n")
+	if len(lines) > max {
+		lines = lines[len(lines)-max:]
+	}
+	return lines
+}
+
+func sanitizeLogLine(line string) string {
+	line = strings.TrimSpace(line)
+	if idx := strings.Index(line, "]"); idx >= 0 && idx+1 < len(line) {
+		line = strings.TrimSpace(line[idx+1:])
+	}
+	if len(line) > 200 {
+		line = line[:200] + "…"
+	}
+	return line
+}
diff --git a/internal/frp-1.0.2/sync.go b/internal/frp-1.0.2/sync.go
new file mode 100644
index 0000000..0e52f5e
--- /dev/null
+++ b/internal/frp-1.0.2/sync.go
@@ -0,0 +1,42 @@
+package frp
+
+import (
+	"strings"
+
+	"github.com/fonu/fonu/internal/proxy"
+)
+
+func DomainsFromProxyRules(rules []proxy.Rule) []string {
+	seen := make(map[string]bool)
+	var domains []string
+	for _, rule := range rules {
+		if !rule.Enabled {
+			continue
+		}
+		for _, host := range rule.Hostnames() {
+			if host == "" || seen[host] {
+				continue
+			}
+			seen[host] = true
+			domains = append(domains, host)
+		}
+	}
+	return domains
+}
+
+func MergeDomains(existing, incoming []string) []string {
+	seen := make(map[string]bool)
+	var out []string
+	for _, list := range [][]string{existing, incoming} {
+		for _, d := range list {
+			d = strings.TrimSpace(d)
+			key := strings.ToLower(d)
+			if d == "" || seen[key] {
+				continue
+			}
+			seen[key] = true
+			out = append(out, d)
+		}
+	}
+	return out
+}
diff --git a/internal/frp-1.0.2/sync_test.go b/internal/frp-1.0.2/sync_test.go
new file mode 100644
index 0000000..76ef79b
--- /dev/null
+++ b/internal/frp-1.0.2/sync_test.go
@@ -0,0 +1,19 @@
+package frp
+
+import (
+	"testing"
+
+	"github.com/fonu/fonu/internal/proxy"
+)
+
+func TestDomainsFromProxyRules(t *testing.T) {
+	rules := []proxy.Rule{
+		{Enabled: true, Hosts: []proxy.Host{{Hostname: "a.example.com"}}, Domain: "b.example.com"},
+		{Enabled: false, Hosts: []proxy.Host{{Hostname: "skip.example.com"}}},
+		{Enabled: true, Hosts: []proxy.Host{{Hostname: "a.example.com"}, {Hostname: "c.example.com"}}},
+	}
+	domains := DomainsFromProxyRules(rules)
+	if len(domains) != 2 {
+		t.Fatalf("expected 2 domains, got %v", domains)
+	}
+}
diff --git a/internal/frp-1.0.2/template.go b/internal/frp-1.0.2/template.go
new file mode 100644
index 0000000..a5cc7e3
--- /dev/null
+++ b/internal/frp-1.0.2/template.go
@@ -0,0 +1,29 @@
+package frp
+
+import "fmt"
+
+// BuildFRPSConfig generates frps.toml for the user's VPS, aligned with Fonu Nginx ports and auth.
+func BuildFRPSConfig(bindPort, httpPort, httpsPort int, token string) string {
+	if bindPort <= 0 {
+		bindPort = 7000
+	}
+	if httpPort <= 0 {
+		httpPort = 80
+	}
+	if httpsPort <= 0 {
+		httpsPort = 443
+	}
+	if token == "" {
+		token = "your-secret-token"
+	}
+	return fmt.Sprintf(`bindAddr = "0.0.0.0"
+bindPort = %d
+
+vhostHTTPPort = %d
+vhostHTTPSPort = %d
+
+[auth]
+method = "token"
+token = "%s"
+`, bindPort, httpPort, httpsPort, token)
+}
diff --git a/internal/frp-1.0.2/template_test.go b/internal/frp-1.0.2/template_test.go
new file mode 100644
index 0000000..b2fddcc
--- /dev/null
+++ b/internal/frp-1.0.2/template_test.go
@@ -0,0 +1,23 @@
+package frp
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestBuildFRPSConfig(t *testing.T) {
+	out := BuildFRPSConfig(7000, 18080, 9443, "fonu-dev-token")
+	if out == "" {
+		t.Fatal("empty config")
+	}
+	for _, want := range []string{
+		"bindPort = 7000",
+		"vhostHTTPPort = 18080",
+		"vhostHTTPSPort = 9443",
+		`token = "fonu-dev-token"`,
+	} {
+		if !strings.Contains(out, want) {
+			t.Fatalf("missing %q in:\n%s", want, out)
+		}
+	}
+}
diff --git a/internal/frp-1.0.2/version.go b/internal/frp-1.0.2/version.go
new file mode 100644
index 0000000..af7add1
--- /dev/null
+++ b/internal/frp-1.0.2/version.go
@@ -0,0 +1,27 @@
+package frp
+
+import (
+	"os/exec"
+	"strings"
+)
+
+const defaultClientVersion = "frpc v0.71.0"
+
+func ResolveClientVersion(bin string) string {
+	bin = strings.TrimSpace(bin)
+	if bin == "" {
+		return defaultClientVersion
+	}
+	out, err := exec.Command(bin, "--version").Output()
+	if err != nil {
+		return defaultClientVersion
+	}
+	line := strings.TrimSpace(string(out))
+	if line == "" {
+		return defaultClientVersion
+	}
+	if strings.HasPrefix(line, "frpc version ") {
+		return "frpc v" + strings.TrimPrefix(line, "frpc version ")
+	}
+	return line
+}
diff --git a/internal/frp/agent_client.go b/internal/frp/agent_client.go
new file mode 100644
index 0000000..58a7857
--- /dev/null
+++ b/internal/frp/agent_client.go
@@ -0,0 +1,184 @@
+package frp
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+	"time"
+)
+
+// AgentClient 调用公网 VPS 上的 fonu-agent。凭据由调用方解密后只在内存中使用。
+type AgentClient struct {
+	baseURL string
+	token   string
+	http    *http.Client
+}
+
+func NewAgentClient(baseURL, token string) *AgentClient {
+	return &AgentClient{
+		baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
+		token:   token,
+		// 路由写入链路含 nginx -T 自省 + -t 校验 + reload(宝塔带 lua/waf 模块时各步可达数秒),15s 会超时
+		http: &http.Client{Timeout: 60 * time.Second},
+	}
+}
+
+func (c *AgentClient) request(ctx context.Context, method, path string, payload any, result any) error {
+	if c.baseURL == "" || c.token == "" {
+		return fmt.Errorf("公网 agent 未配置")
+	}
+	var body io.Reader
+	if payload != nil {
+		data, err := json.Marshal(payload)
+		if err != nil {
+			return err
+		}
+		body = bytes.NewReader(data)
+	}
+	req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Authorization", "Bearer "+c.token)
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+	resp, err := c.http.Do(req)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+	data, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		return fmt.Errorf("agent 返回 HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
+	}
+	if result != nil && len(data) > 0 {
+		if err := json.Unmarshal(data, result); err != nil {
+			return fmt.Errorf("agent 响应格式无效: %w", err)
+		}
+	}
+	return nil
+}
+
+func (c *AgentClient) Ping(ctx context.Context) error {
+	return c.request(ctx, http.MethodGet, "/api/v1/ping", nil, nil)
+}
+
+func (c *AgentClient) Status(ctx context.Context) (map[string]any, error) {
+	var result map[string]any
+	err := c.request(ctx, http.MethodGet, "/api/v1/status", nil, &result)
+	return result, err
+}
+
+func (c *AgentClient) SyncRoute(ctx context.Context, domain, upstream string, tls bool, certDir string) error {
+	_, err := c.SyncRouteEx(ctx, domain, upstream, tls, certDir, false, 0, false, nil, "")
+	return err
+}
+
+// RouteSpec 公网反代单域名部署参数(字段名与 agent routeRequest 的 JSON key 严格一致)。
+type RouteSpec struct {
+	Domain, Upstream, CertDir                   string
+	TLS, WebSocket, RedirectHTTPS               bool
+	HTTPSDisabled, SecurityHeaders              bool
+	TLS13Only                                   bool
+	VhostPort                                   int
+	AllowIPs, DenyIPs                           []string
+	BasicAuthUser, BasicAuthPassword            string
+	ClientMaxBodySize, ProxyReadTimeout         string
+	RateLimitRate, RateLimitBurst, ConnLimitMax int
+	ChinaOnly                                   bool
+}
+
+// SyncRouteSpec 部署路由并要求回源自检;位置参数已到上限,新增选项一律走本方法。
+func (c *AgentClient) SyncRouteSpec(ctx context.Context, spec RouteSpec) (map[string]any, error) {
+	var result map[string]any
+	err := c.request(ctx, http.MethodPut, "/api/v1/routes", map[string]any{
+		"domain": spec.Domain, "upstream": spec.Upstream, "tls": spec.TLS, "cert_dir": spec.CertDir,
+		"websocket": spec.WebSocket, "vhost_port": spec.VhostPort,
+		"redirect_https": spec.RedirectHTTPS, "allow_ips": spec.AllowIPs, "basic_auth_user": spec.BasicAuthUser,
+		"deny_ips": spec.DenyIPs, "basic_auth_password": spec.BasicAuthPassword,
+		"https_disabled": spec.HTTPSDisabled, "client_max_body_size": spec.ClientMaxBodySize,
+		"proxy_read_timeout": spec.ProxyReadTimeout, "security_headers": spec.SecurityHeaders,
+		"tls13_only":      spec.TLS13Only,
+		"rate_limit_rate": spec.RateLimitRate, "rate_limit_burst": spec.RateLimitBurst,
+		"conn_limit_max": spec.ConnLimitMax, "china_only": spec.ChinaOnly,
+	}, &result)
+	return result, err
+}
+
+// InstallNginx 触发 VPS 上一键安装 Nginx(需 agent 以 root 运行)。
+func (c *AgentClient) InstallNginx(ctx context.Context) (map[string]any, error) {
+	var result map[string]any
+	err := c.request(ctx, http.MethodPost, "/api/v1/nginx/install", nil, &result)
+	return result, err
+}
+
+// SyncChinaCIDR 把中国 IP 段(geo include 格式)下发到公网 agent。
+func (c *AgentClient) SyncChinaCIDR(ctx context.Context, content string) error {
+	var result map[string]any
+	return c.request(ctx, http.MethodPut, "/api/v1/china-cidr", map[string]any{"content": content}, &result)
+}
+
+// SyncRouteEx 保留的位置参数兼容包装(新代码请用 SyncRouteSpec)。
+func (c *AgentClient) SyncRouteEx(ctx context.Context, domain, upstream string, tls bool, certDir string, websocket bool, vhostPort int, redirectHTTPS bool, allowIPs []string, basicAuthUser string) (map[string]any, error) {
+	return c.SyncRouteSpec(ctx, RouteSpec{
+		Domain: domain, Upstream: upstream, TLS: tls, CertDir: certDir,
+		WebSocket: websocket, VhostPort: vhostPort, RedirectHTTPS: redirectHTTPS,
+		AllowIPs: allowIPs, BasicAuthUser: basicAuthUser,
+	})
+}
+
+// RouteConf 返回指定域名当前反代配置内容。
+func (c *AgentClient) RouteConf(ctx context.Context, domain string) (string, error) {
+	var result struct {
+		Domain  string `json:"domain"`
+		Content string `json:"content"`
+	}
+	if err := c.request(ctx, http.MethodGet, "/api/v1/routes/"+domain+"/conf", nil, &result); err != nil {
+		return "", err
+	}
+	return result.Content, nil
+}
+
+// RouteDetail 单条公网反代路由的真实状态。
+type RouteDetail struct {
+	Domain     string `json:"domain"`
+	FileExists bool   `json:"file_exists"`
+	SyntaxOK   bool   `json:"syntax_ok"`
+	Loaded     bool   `json:"loaded"`
+}
+
+// RoutesDetail 返回全部公网反代路由的状态明细(文件/语法/被 Nginx 加载)。
+func (c *AgentClient) RoutesDetail(ctx context.Context) ([]RouteDetail, error) {
+	var result []RouteDetail
+	err := c.request(ctx, http.MethodGet, "/api/v1/routes", nil, &result)
+	return result, err
+}
+
+func (c *AgentClient) DeleteRoute(ctx context.Context, domain string) error {
+	return c.request(ctx, http.MethodDelete, "/api/v1/routes/"+domain, nil, nil)
+}
+
+func (c *AgentClient) FRPSAction(ctx context.Context, action string) error {
+	return c.request(ctx, http.MethodPost, "/api/v1/frps/action", map[string]string{"action": action}, nil)
+}
+
+func (c *AgentClient) InstallFRPS(ctx context.Context, version, proxy string) (map[string]any, error) {
+	var result map[string]any
+	err := c.request(ctx, http.MethodPost, "/api/v1/frps/install", map[string]string{"version": version, "proxy": proxy}, &result)
+	return result, err
+}
+
+func (c *AgentClient) PushFRPSConfig(ctx context.Context, content string) error {
+	return c.request(ctx, http.MethodPut, "/api/v1/frps/config", map[string]string{"content": content}, nil)
+}
+
+func (c *AgentClient) PushCertificate(ctx context.Context, domain, fullchain, privateKey string) error {
+	return c.request(ctx, http.MethodPost, "/api/v1/certs", map[string]string{
+		"domain": domain, "fullchain_pem": fullchain, "key_pem": privateKey,
+	}, nil)
+}
diff --git a/internal/frp/agent_installer.go b/internal/frp/agent_installer.go
new file mode 100644
index 0000000..28cecf9
--- /dev/null
+++ b/internal/frp/agent_installer.go
@@ -0,0 +1,172 @@
+package frp
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"github.com/fonu/fonu/internal/frp/agentbin"
+)
+
+// AgentInstaller 负责把内嵌的 fonu-agent 通过 SSH 装到 VPS 上,并收集安装结果。
+type AgentInstaller struct {
+	SSH SSHInstaller
+}
+
+// Probe 返回 VPS 环境摘要:架构、nginx 是否安装、agent 是否已装、systemd 可用性。
+func (i AgentInstaller) Probe(ctx context.Context) (map[string]any, error) {
+	client, err := i.SSH.client(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("SSH 连接失败: %w", err)
+	}
+	defer client.Close()
+	out := func(cmd string) string {
+		v, _ := runSSHSession(client, cmd)
+		return strings.TrimSpace(v)
+	}
+	arch := out("uname -m")
+	return map[string]any{
+		"arch":          arch,
+		"nginx_conf":    out(`conf=$(nginx -V 2>&1 | grep -o '\-\-conf-path=[^ ]*' | head -1 | cut -d= -f2-); [ -z "$conf" ] && conf=/etc/nginx/nginx.conf; grep '^[[:space:]]*include' "$conf" 2>/dev/null | grep -m1 -E 'conf\.d|sites-enabled|vhost/nginx' || ls -d /etc/nginx/conf.d 2>/dev/null`),
+		"agent_exist":   out("test -x /usr/local/bin/fonu-agent && echo yes || echo no") == "yes",
+		"agent_running": out("systemctl is-active fonu-agent 2>/dev/null || true"),
+		"agent_version": out("/usr/local/bin/fonu-agent -version 2>/dev/null || echo 未知"),
+		"agent_token":   out("test -f /etc/fonu-agent/agent.env && sed -n 's/^FONU_AGENT_TOKEN=//p' /etc/fonu-agent/agent.env || true"),
+		"frps_alive":    out("systemctl is-active frps 2>/dev/null; pgrep -x frps >/dev/null && echo active || true"),
+		"frps_version":  out("/usr/local/bin/frps --version 2>/dev/null || echo 未安装"),
+		"frps_config":   out("test -f /etc/frp/frps.toml && echo /etc/frp/frps.toml || echo 配置文件不存在"),
+		"frps_unit":     out("systemctl status frps --no-pager 2>/dev/null | head -3 || echo 无 systemd 单位"),
+		"frps_log":      out("journalctl -u frps -n 5 --no-pager 2>/dev/null || true"),
+		"port7000":      out("ss -tlnp 2>/dev/null | grep ':7000 ' | head -1"),
+		"port80":        out("ss -tlnp 2>/dev/null | grep ':80 ' | head -1"),
+		"port443":       out("ss -tlnp 2>/dev/null | grep ':443 ' | head -1"),
+	}, nil
+}
+
+// Install 上传内嵌二进制并执行安装脚本;重复执行等于覆盖升级。返回 agent Token。
+func (i AgentInstaller) Install(ctx context.Context) (string, error) {
+	arch, err := i.SSH.Run(ctx, "uname -m")
+	if err != nil {
+		return "", fmt.Errorf("探测架构失败: %w", err)
+	}
+	binary, ok := agentbin.BinaryForArch(strings.TrimSpace(arch))
+	if !ok {
+		return "", fmt.Errorf("不支持的 VPS 架构: %s", strings.TrimSpace(arch))
+	}
+	// 开发环境(go run / 非 Docker 构建)里内嵌的是占位文件;提前报错而不是让 VPS 上 systemd 反复 Exec format error
+	if !isELFBinary(binary) {
+		return "", fmt.Errorf("内嵌 agent 二进制无效(开发构建占位文件)。请用 Docker 构建或先执行构建脚本生成真实 agent:go build -o internal/frp/agentbin/fonu-agent-linux-amd64 ./cmd/fonu-agent(GOOS=linux GOARCH=amd64)")
+	}
+	script, err := agentInstallScript()
+	if err != nil {
+		return "", err
+	}
+	output, err := i.SSH.Install(ctx, binary, script)
+	if err != nil {
+		return "", err
+	}
+	token := extractToken(output)
+	if token == "" {
+		return "", fmt.Errorf("安装完成但未取回 Token: %s", strings.TrimSpace(output))
+	}
+	return token, nil
+}
+
+// Uninstall 停止并移除 agent 服务;--keep-data 保留路由与证书。
+func (i AgentInstaller) Uninstall(ctx context.Context, keepData bool) (string, error) {
+	command := "/usr/local/bin/fonu-agent-uninstall"
+	if keepData {
+		command += " --keep-data"
+	}
+	out, err := i.SSH.Run(ctx, fmt.Sprintf("if [ -x %s ]; then %s; else echo 'agent 未安装'; fi", "'"+command+"'", command))
+	return strings.TrimSpace(out), err
+}
+
+// Diagnose agent 失联时走 SSH 急救:进程、端口、服务日志尾部。
+func (i AgentInstaller) Diagnose(ctx context.Context) (string, error) {
+	return i.SSH.Run(ctx, "systemctl status fonu-agent --no-pager -l 2>/dev/null | head -20; echo '---'; journalctl -u fonu-agent -n 15 --no-pager 2>/dev/null; echo '---'; ss -tlnp 2>/dev/null | grep ':7700 ' || echo '7700 not listening'")
+}
+
+func extractToken(output string) string {
+	for _, line := range strings.Split(output, "\n") {
+		if token, found := strings.CutPrefix(strings.TrimSpace(line), "Token: "); found {
+			return strings.TrimSpace(token)
+		}
+	}
+	return ""
+}
+
+func agentInstallScript() ([]byte, error) {
+	// 与 scripts/agent-install.sh 同一份逻辑内嵌:支持传入二进制路径参数,卸载子命令。
+	return []byte(`#!/usr/bin/env bash
+set -eu
+if [ "$(id -u)" -ne 0 ]; then echo "请使用 root 或 sudo 运行" >&2; exit 1; fi
+AGENT_BINARY=${1:-/tmp/fonu-agent}
+PREFIX=/usr/local/bin/fonu-agent
+CONF_DIR=/etc/fonu-agent
+CONF_FILE="$CONF_DIR/agent.env"
+UNIT=/etc/systemd/system/fonu-agent.service
+
+if [ "${1:-}" = "--uninstall" ]; then
+  systemctl stop fonu-agent 2>/dev/null || true
+  systemctl disable fonu-agent 2>/dev/null || true
+  rm -f "$UNIT" "$PREFIX" /usr/local/bin/fonu-agent-uninstall
+  systemctl daemon-reload
+  if [ "${2:-}" != "--keep-data" ]; then rm -rf /var/lib/fonu-agent; fi
+  echo "fonu-agent 已卸载"
+  exit 0
+fi
+
+mkdir -p "$CONF_DIR" /var/lib/fonu-agent/certs
+# 先停服务,再原子替换;安装后必须确认目标存在、可执行且能输出版本
+systemctl stop fonu-agent 2>/dev/null || true
+TMP_PREFIX="${PREFIX}.new"
+install -m 0755 "$AGENT_BINARY" "$TMP_PREFIX"
+if [ ! -x "$TMP_PREFIX" ]; then
+  echo "agent 二进制安装失败:$TMP_PREFIX 不存在或不可执行" >&2
+  exit 1
+fi
+AGENT_VERSION=$($TMP_PREFIX -version 2>/dev/null) || {
+  echo "agent 二进制无法执行:$TMP_PREFIX" >&2
+  rm -f "$TMP_PREFIX"
+  exit 1
+}
+mv -f "$TMP_PREFIX" "$PREFIX"
+if [ ! -x "$PREFIX" ]; then
+  echo "agent 原子替换失败:$PREFIX 不存在或不可执行" >&2
+  exit 1
+fi
+printf 'agent version: %s\n' "$AGENT_VERSION"
+if [ ! -f "$CONF_FILE" ]; then
+  umask 077
+  printf 'FONU_AGENT_TOKEN=%s\n' "$(openssl rand -hex 32)" > "$CONF_FILE"
+fi
+cat > "$UNIT" <<'UNIT'
+[Unit]
+Description=Fonu FRPS management agent
+After=network-online.target nginx.service
+Wants=network-online.target
+
+[Service]
+Type=simple
+EnvironmentFile=/etc/fonu-agent/agent.env
+ExecStart=/usr/local/bin/fonu-agent
+Restart=on-failure
+RestartSec=3
+
+[Install]
+WantedBy=multi-user.target
+UNIT
+systemctl daemon-reload
+systemctl restart fonu-agent
+systemctl enable fonu-agent
+cp "$0" /usr/local/bin/fonu-agent-uninstall
+echo "fonu-agent installed"
+echo "Token: $(sed -n 's/^FONU_AGENT_TOKEN=//p' "$CONF_FILE")"
+`), nil
+}
+
+// isELFBinary 判断内容是否为 Linux ELF 可执行(魔数 0x7F 'ELF'),用于拦截开发构建的占位文件。
+func isELFBinary(data []byte) bool {
+	return len(data) > 4 && data[0] == 0x7F && data[1] == 'E' && data[2] == 'L' && data[3] == 'F'
+}
diff --git a/internal/frp/agentbin/embed.go b/internal/frp/agentbin/embed.go
new file mode 100644
index 0000000..be20eaf
--- /dev/null
+++ b/internal/frp/agentbin/embed.go
@@ -0,0 +1,25 @@
+// Package agentbin 内嵌各架构 fonu-agent 二进制,供 SSH 安装器按 VPS 架构选择。
+// 仓库中的占位文件保证开发机构建可通过;Docker 构建阶段会用真实二进制覆盖它们
+// (见 Dockerfile:先交叉编译 agent,再构建主程序)。
+package agentbin
+
+import (
+	_ "embed"
+)
+
+//go:embed fonu-agent-linux-amd64
+var LinuxAMD64 []byte
+
+//go:embed fonu-agent-linux-arm64
+var LinuxARM64 []byte
+
+// BinaryForArch 返回对应架构的 agent 二进制;arch 取 uname -m 输出(x86_64/aarch64)。
+func BinaryForArch(arch string) ([]byte, bool) {
+	switch arch {
+	case "x86_64", "amd64":
+		return LinuxAMD64, true
+	case "aarch64", "arm64":
+		return LinuxARM64, true
+	}
+	return nil, false
+}
diff --git a/internal/frp/agentbin/fonu-agent-linux-amd64 b/internal/frp/agentbin/fonu-agent-linux-amd64
new file mode 100644
index 0000000..7c51a7a
Binary files /dev/null and b/internal/frp/agentbin/fonu-agent-linux-amd64 differ
diff --git a/internal/frp/agentbin/fonu-agent-linux-arm64 b/internal/frp/agentbin/fonu-agent-linux-arm64
new file mode 100644
index 0000000..dc48497
Binary files /dev/null and b/internal/frp/agentbin/fonu-agent-linux-arm64 differ
diff --git a/internal/frp/config.go b/internal/frp/config.go
new file mode 100644
index 0000000..bb5a868
--- /dev/null
+++ b/internal/frp/config.go
@@ -0,0 +1,247 @@
+package frp
+
+import (
+	"fmt"
+	"sort"
+	"strconv"
+	"strings"
+)
+
+type renderedConfig struct {
+	Server           Server
+	Token            string
+	OIDCClientSecret string
+	LogPath          string
+	TLSCertPath      string
+	TLSKeyPath       string
+	TLSTrustedCAPath string
+	Proxies          []Proxy
+}
+
+func renderTOML(cfg renderedConfig) string {
+	var out strings.Builder
+	writeKeyValue(&out, "serverAddr", cfg.Server.ServerAddr)
+	writeKeyValue(&out, "serverPort", cfg.Server.ServerPort)
+	options := normalizeServerOptions(cfg.Server.Options)
+	if options.User != "" {
+		writeKeyValue(&out, "user", options.User)
+	}
+	if options.AuthMethod != "none" {
+		writeKeyValue(&out, "auth.method", options.AuthMethod)
+	}
+	if options.AuthMethod == "token" {
+		writeKeyValue(&out, "auth.token", cfg.Token)
+	}
+	if options.AuthMethod == "oidc" {
+		writeKeyValue(&out, "auth.oidc.clientID", options.OIDCClientID)
+		writeKeyValue(&out, "auth.oidc.clientSecret", cfg.OIDCClientSecret)
+		writeKeyValue(&out, "auth.oidc.tokenEndpointURL", options.OIDCTokenEndpointURL)
+		if options.OIDCAudience != "" {
+			writeKeyValue(&out, "auth.oidc.audience", options.OIDCAudience)
+		}
+		if options.OIDCScope != "" {
+			writeKeyValue(&out, "auth.oidc.scope", options.OIDCScope)
+		}
+	}
+	if cfg.LogPath != "" {
+		writeKeyValue(&out, "log.to", cfg.LogPath)
+	}
+	writeKeyValue(&out, "log.level", options.LogLevel)
+	writeKeyValue(&out, "log.maxDays", options.LogMaxDays)
+	writeKeyValue(&out, "transport.protocol", options.Protocol)
+	writeKeyValue(&out, "transport.tls.enable", cfg.Server.TLS)
+	writeKeyValue(&out, "transport.tls.disableCustomTLSFirstByte", options.TLSDisableCustomFirstByte)
+	if cfg.Server.TLSServerName != "" {
+		writeKeyValue(&out, "transport.tls.serverName", cfg.Server.TLSServerName)
+	}
+	if cfg.TLSCertPath != "" {
+		writeKeyValue(&out, "transport.tls.certFile", cfg.TLSCertPath)
+	}
+	if cfg.TLSKeyPath != "" {
+		writeKeyValue(&out, "transport.tls.keyFile", cfg.TLSKeyPath)
+	}
+	if cfg.TLSTrustedCAPath != "" {
+		writeKeyValue(&out, "transport.tls.trustedCaFile", cfg.TLSTrustedCAPath)
+	}
+	if options.ProxyURL != "" {
+		writeKeyValue(&out, "transport.proxyURL", options.ProxyURL)
+	}
+	writeKeyValue(&out, "transport.dialServerTimeout", options.DialServerTimeout)
+	writeKeyValue(&out, "transport.dialServerKeepalive", options.DialServerKeepalive)
+	if options.ConnectServerLocalIP != "" {
+		writeKeyValue(&out, "transport.connectServerLocalIP", options.ConnectServerLocalIP)
+	}
+	if options.DNSServer != "" {
+		writeKeyValue(&out, "transport.dnsServer", options.DNSServer)
+	}
+	writeKeyValue(&out, "transport.poolCount", options.PoolCount)
+	writeKeyValue(&out, "transport.tcpMux", options.TCPMux)
+	writeKeyValue(&out, "transport.tcpMuxKeepaliveInterval", options.TCPMuxKeepaliveInterval)
+	writeKeyValue(&out, "transport.heartbeatInterval", options.HeartbeatInterval)
+	writeKeyValue(&out, "transport.heartbeatTimeout", options.HeartbeatTimeout)
+	writeKeyValue(&out, "loginFailExit", options.LoginFailExit)
+
+	for _, proxy := range cfg.Proxies {
+		out.WriteString("\n[[proxies]]\n")
+		writeKeyValue(&out, "name", proxy.Name)
+		writeKeyValue(&out, "type", proxy.Type)
+		writeKeyValue(&out, "localIP", proxy.LocalIP)
+		writeKeyValue(&out, "localPort", proxy.LocalPort)
+		if proxy.Type == "tcp" && proxy.RemotePort != nil {
+			writeKeyValue(&out, "remotePort", *proxy.RemotePort)
+		}
+		if proxy.Type == "udp" && proxy.RemotePort != nil {
+			writeKeyValue(&out, "remotePort", *proxy.RemotePort)
+		}
+		if proxy.Type == "http" || proxy.Type == "https" || proxy.Type == "tcpmux" {
+			writeStringSlice(&out, "customDomains", proxy.CustomDomains)
+		}
+		if proxy.HostHeaderRewrite != "" {
+			writeKeyValue(&out, "hostHeaderRewrite", proxy.HostHeaderRewrite)
+		}
+		writeProxyOptions(&out, proxy)
+	}
+	return out.String()
+}
+
+func writeProxyOptions(out *strings.Builder, proxy Proxy) {
+	opts := proxy.Options
+	if opts.Subdomain != "" {
+		writeKeyValue(out, "subdomain", opts.Subdomain)
+	}
+	if len(opts.Locations) > 0 {
+		writeStringSlice(out, "locations", opts.Locations)
+	}
+	if opts.RouteByHTTPUser != "" {
+		writeKeyValue(out, "routeByHTTPUser", opts.RouteByHTTPUser)
+	}
+	if opts.HTTPUser != "" {
+		writeKeyValue(out, "httpUser", opts.HTTPUser)
+	}
+	if opts.HTTPPassword != "" {
+		writeKeyValue(out, "httpPassword", opts.HTTPPassword)
+	}
+	if opts.Multiplexer != "" {
+		writeKeyValue(out, "multiplexer", opts.Multiplexer)
+	}
+	if opts.SecretKey != "" {
+		writeKeyValue(out, "secretKey", opts.SecretKey)
+	}
+	if len(opts.AllowUsers) > 0 {
+		writeStringSlice(out, "allowUsers", opts.AllowUsers)
+	}
+	writeProxyTransport(out, opts.Transport)
+	writeProxyHealthCheck(out, opts.HealthCheck)
+	if opts.LoadBalancer.Group != "" {
+		writeKeyValue(out, "loadBalancer.group", opts.LoadBalancer.Group)
+	}
+	if opts.LoadBalancer.GroupKey != "" {
+		writeKeyValue(out, "loadBalancer.groupKey", opts.LoadBalancer.GroupKey)
+	}
+	writeStringMap(out, "metadatas", opts.Metadatas)
+	writeStringMap(out, "requestHeaders.set", opts.RequestHeaders)
+	writeStringMap(out, "responseHeaders.set", opts.ResponseHeaders)
+	// natTraversal 是较新版本 frpc 才支持的字段,旧版 frpc 会把未知字段判为校验错误;
+	// disableAssistedAddrs=false 与 frp 默认行为等价,因此仅在显式禁用时写入。
+	if opts.NATTraversal != nil && opts.NATTraversal.DisableAssistedAddrs {
+		writeKeyValue(out, "natTraversal.disableAssistedAddrs", true)
+	}
+	if opts.Plugin != nil {
+		writeProxyPlugin(out, *opts.Plugin)
+	}
+}
+
+func writeProxyTransport(out *strings.Builder, opts ProxyTransportOptions) {
+	if opts.BandwidthLimit != "" {
+		writeKeyValue(out, "transport.bandwidthLimit", opts.BandwidthLimit)
+	}
+	if opts.BandwidthLimitMode != "" {
+		writeKeyValue(out, "transport.bandwidthLimitMode", opts.BandwidthLimitMode)
+	}
+	if opts.UseEncryption {
+		writeKeyValue(out, "transport.useEncryption", true)
+	}
+	if opts.UseCompression {
+		writeKeyValue(out, "transport.useCompression", true)
+	}
+	if opts.ProxyProtocolVersion != "" {
+		writeKeyValue(out, "transport.proxyProtocolVersion", opts.ProxyProtocolVersion)
+	}
+}
+
+func writeProxyHealthCheck(out *strings.Builder, opts ProxyHealthCheckOptions) {
+	if opts.Type == "" {
+		return
+	}
+	writeKeyValue(out, "healthCheck.type", opts.Type)
+	if opts.Path != "" {
+		writeKeyValue(out, "healthCheck.path", opts.Path)
+	}
+	if opts.IntervalSeconds > 0 {
+		writeKeyValue(out, "healthCheck.intervalSeconds", opts.IntervalSeconds)
+	}
+	if opts.MaxFailed > 0 {
+		writeKeyValue(out, "healthCheck.maxFailed", opts.MaxFailed)
+	}
+	if opts.TimeoutSeconds > 0 {
+		writeKeyValue(out, "healthCheck.timeoutSeconds", opts.TimeoutSeconds)
+	}
+	if len(opts.HTTPHeaders) > 0 {
+		values := make([]string, 0, len(opts.HTTPHeaders))
+		for _, header := range opts.HTTPHeaders {
+			values = append(values, fmt.Sprintf("{ name = %s, value = %s }", strconv.Quote(header.Name), strconv.Quote(header.Value)))
+		}
+		fmt.Fprintf(out, "healthCheck.httpHeaders = [%s]\n", strings.Join(values, ", "))
+	}
+}
+
+func writeProxyPlugin(out *strings.Builder, plugin ProxyPluginOptions) {
+	out.WriteString("[proxies.plugin]\n")
+	writeKeyValue(out, "type", plugin.Type)
+	for _, item := range []struct{ key, value string }{
+		{"unixPath", plugin.UnixPath}, {"localPath", plugin.LocalPath}, {"stripPrefix", plugin.StripPrefix},
+		{"localAddr", plugin.LocalAddr}, {"httpUser", plugin.HTTPUser}, {"httpPassword", plugin.HTTPPassword},
+		{"username", plugin.Username}, {"password", plugin.Password}, {"crtPath", plugin.CRTPath}, {"keyPath", plugin.KeyPath},
+		{"hostHeaderRewrite", plugin.HostHeaderRewrite}, {"destinationIP", plugin.DestinationIP},
+	} {
+		if item.value != "" {
+			writeKeyValue(out, item.key, item.value)
+		}
+	}
+}
+
+func writeStringMap(out *strings.Builder, prefix string, values map[string]string) {
+	keys := make([]string, 0, len(values))
+	for key := range values {
+		keys = append(keys, key)
+	}
+	sort.Strings(keys)
+	for _, key := range keys {
+		if key != "" {
+			writeKeyValue(out, prefix+"."+key, values[key])
+		}
+	}
+}
+
+func renderMaskedTOML(server Server, proxies []Proxy) string {
+	return renderTOML(renderedConfig{Server: server, Token: "********", OIDCClientSecret: "********", Proxies: proxies})
+}
+
+func writeKeyValue(out *strings.Builder, key string, value any) {
+	switch v := value.(type) {
+	case string:
+		fmt.Fprintf(out, "%s = %s\n", key, strconv.Quote(v))
+	case int:
+		fmt.Fprintf(out, "%s = %d\n", key, v)
+	case bool:
+		fmt.Fprintf(out, "%s = %t\n", key, v)
+	}
+}
+
+func writeStringSlice(out *strings.Builder, key string, values []string) {
+	quoted := make([]string, 0, len(values))
+	for _, value := range values {
+		quoted = append(quoted, strconv.Quote(value))
+	}
+	fmt.Fprintf(out, "%s = [%s]\n", key, strings.Join(quoted, ", "))
+}
diff --git a/internal/frp/config_test.go b/internal/frp/config_test.go
new file mode 100644
index 0000000..3ca5cba
--- /dev/null
+++ b/internal/frp/config_test.go
@@ -0,0 +1,65 @@
+package frp
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestRenderTOML(t *testing.T) {
+	remotePort := 60022
+	content := renderTOML(renderedConfig{
+		Server: Server{ServerAddr: "vps.example.com", ServerPort: 7000, TLS: true},
+		Token:  "token with \"quotes\"",
+		Proxies: []Proxy{
+			{Name: "nas-ssh", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &remotePort},
+			{Name: "nas-web", Type: "http", LocalIP: "127.0.0.1", LocalPort: 18080, CustomDomains: []string{"nas.example.com"}},
+		},
+	})
+
+	for _, expected := range []string{
+		`serverAddr = "vps.example.com"`,
+		`auth.token = "token with \"quotes\""`,
+		`remotePort = 60022`,
+		`customDomains = ["nas.example.com"]`,
+	} {
+		if !strings.Contains(content, expected) {
+			t.Fatalf("配置缺少 %q:\n%s", expected, content)
+		}
+	}
+}
+
+func TestRenderMaskedTOMLDoesNotExposeToken(t *testing.T) {
+	content := renderMaskedTOML(Server{ServerAddr: "vps.example.com", ServerPort: 7000, TLS: true}, nil)
+	if !strings.Contains(content, `auth.token = "********"`) {
+		t.Fatalf("脱敏配置未隐藏 Token:\n%s", content)
+	}
+}
+
+func TestRenderProxyOptions(t *testing.T) {
+	content := renderTOML(renderedConfig{Server: Server{ServerAddr: "vps.example.com", ServerPort: 7000}, Proxies: []Proxy{
+		{Name: "udp", Type: "udp", LocalIP: "127.0.0.1", LocalPort: 53, RemotePort: intPtr(6002), Options: ProxyOptions{Transport: ProxyTransportOptions{UseEncryption: true, ProxyProtocolVersion: "v2"}, HealthCheck: ProxyHealthCheckOptions{Type: "tcp"}}},
+		{Name: "secret", Type: "stcp", LocalIP: "127.0.0.1", LocalPort: 22, Options: ProxyOptions{SecretKey: "secret", AllowUsers: []string{"*"}}},
+	}})
+	for _, expected := range []string{`type = "udp"`, `remotePort = 6002`, `transport.useEncryption = true`, `transport.proxyProtocolVersion = "v2"`, `healthCheck.type = "tcp"`, `secretKey = "secret"`, `allowUsers = ["*"]`} {
+		if !strings.Contains(content, expected) {
+			t.Fatalf("配置缺少 %q:\n%s", expected, content)
+		}
+	}
+}
+
+func TestRenderNATTraversalOnlyWhenDisabledAssistedAddrs(t *testing.T) {
+	omitted := renderTOML(renderedConfig{Proxies: []Proxy{
+		{Name: "p2p", Type: "xtcp", Options: ProxyOptions{NATTraversal: &ProxyNATTraversal{}}},
+	}})
+	if strings.Contains(omitted, "natTraversal") {
+		t.Fatalf("disableAssistedAddrs=false 不应渲染 natTraversal(旧版 frpc 校验会失败):\n%s", omitted)
+	}
+	enabled := renderTOML(renderedConfig{Proxies: []Proxy{
+		{Name: "p2p", Type: "xtcp", Options: ProxyOptions{NATTraversal: &ProxyNATTraversal{DisableAssistedAddrs: true}}},
+	}})
+	if !strings.Contains(enabled, "natTraversal.disableAssistedAddrs = true") {
+		t.Fatalf("disableAssistedAddrs=true 应渲染 natTraversal:\n%s", enabled)
+	}
+}
+
+func intPtr(value int) *int { return &value }
diff --git a/internal/frp/connection_state.go b/internal/frp/connection_state.go
new file mode 100644
index 0000000..5ed744c
--- /dev/null
+++ b/internal/frp/connection_state.go
@@ -0,0 +1,46 @@
+package frp
+
+import (
+	"os"
+	"strings"
+)
+
+const maxConnectionLogRead = 128 << 10
+
+func inspectConnectionLog(path, processState string) (string, string) {
+	if processState != "running" {
+		if processState == "error" {
+			return "error", "frpc 进程已退出"
+		}
+		return "disconnected", ""
+	}
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return "unknown", ""
+	}
+	if len(data) > maxConnectionLogRead {
+		data = data[len(data)-maxConnectionLogRead:]
+	}
+	lines := strings.Split(string(data), "\n")
+	for index := len(lines) - 1; index >= 0; index-- {
+		line := strings.TrimSpace(lines[index])
+		lower := strings.ToLower(line)
+		switch {
+		case strings.Contains(lower, "login to server success"):
+			return "connected", ""
+		case strings.Contains(lower, "login to server failed"), strings.Contains(lower, "connect to server error"):
+			return "error", trimConnectionError(line)
+		case strings.Contains(lower, "frpc service") && strings.Contains(lower, "stopped"):
+			return "disconnected", ""
+		}
+	}
+	return "unknown", ""
+}
+
+func trimConnectionError(value string) string {
+	value = strings.TrimSpace(value)
+	if len(value) > 500 {
+		return value[:500]
+	}
+	return value
+}
diff --git a/internal/frp/diagnostics.go b/internal/frp/diagnostics.go
new file mode 100644
index 0000000..441038b
--- /dev/null
+++ b/internal/frp/diagnostics.go
@@ -0,0 +1,251 @@
+package frp
+
+import (
+	"context"
+	"fmt"
+	"net"
+	"os"
+	"strings"
+	"time"
+)
+
+// 诊断状态常量:ok 已确认可用;failed 已确认失败;unknown 无法确认(区别于失败)。
+const (
+	diagStateOK      = "ok"
+	diagStateFailed  = "failed"
+	diagStateUnknown = "unknown"
+
+	diagTCPTimeout       = 3 * time.Second
+	diagDNSTimeout       = 2 * time.Second
+	diagnosticsCacheTTL  = 15 * time.Second
+	diagnosticsMaxInFlight = 4
+)
+
+type diagnosticsCacheEntry struct {
+	diagnostics ServerDiagnostics
+	expiresAt   time.Time
+}
+
+// ServerDiagnostics 返回服务端诊断结果。带短 TTL 缓存与并发上限:
+// 列表页轮询不应同步等待定位等外部 API,也不能无限叠加探测请求。
+func (s *Service) ServerDiagnostics(ctx context.Context, id int64) (ServerDiagnostics, error) {
+	server, err := s.store.GetServer(ctx, id)
+	if err != nil {
+		return ServerDiagnostics{}, err
+	}
+	return s.runDiagnostics(ctx, server), nil
+}
+
+func (s *Service) runDiagnostics(ctx context.Context, server Server) ServerDiagnostics {
+	s.diagMu.Lock()
+	if entry, ok := s.diagCache[server.ID]; ok && time.Now().Before(entry.expiresAt) {
+		cached := entry.diagnostics
+		s.diagMu.Unlock()
+		return cached
+	}
+	// 并发上限:超限时返回旧结果或 unknown,不排队等待
+	if s.diagInFlight >= diagnosticsMaxInFlight {
+		cached, ok := s.diagCache[server.ID]
+		s.diagMu.Unlock()
+		if ok {
+			return cached.diagnostics
+		}
+		return ServerDiagnostics{ServerID: server.ID, Checks: []DiagnosticCheck{{Name: "tcp_reachable", State: diagStateUnknown, Detail: "探测请求过多,请稍后重试"}}}
+	}
+	s.diagInFlight++
+	s.diagMu.Unlock()
+	defer func() {
+		s.diagMu.Lock()
+		s.diagInFlight--
+		s.diagMu.Unlock()
+	}()
+
+	result := s.computeDiagnostics(ctx, server)
+	s.diagMu.Lock()
+	s.diagCache[server.ID] = diagnosticsCacheEntry{diagnostics: result, expiresAt: time.Now().Add(diagnosticsCacheTTL)}
+	s.diagMu.Unlock()
+	return result
+}
+
+// computeDiagnostics 执行各诊断项。定位失败、日志缺失等一律记为 unknown 或非阻断展示,
+// 绝不写入 Error(Error 仅表示控制端口探测失败)。
+func (s *Service) computeDiagnostics(ctx context.Context, server Server) ServerDiagnostics {
+	dnsCheck := diagnoseDNS(ctx, server.ServerAddr)
+	tcpCheck := diagnoseTCP(ctx, server, dnsCheck)
+	diagnostics := ServerDiagnostics{
+		ServerID:  server.ID,
+		Available: tcpCheck.State == diagStateOK,
+		LatencyMS: tcpCheck.LatencyMS,
+		Checks:    []DiagnosticCheck{tcpCheck, s.diagnoseFRPCLogin(server.ID), dnsCheck},
+	}
+	if tcpCheck.State == diagStateFailed {
+		diagnostics.Error = tcpCheck.Detail
+	}
+
+	// location:非阻断展示项。缓存命中直接返回;未命中先展示 IP 占位并后台刷新,
+	// 不让列表页同步等待外部定位 API。
+	location, resolved := s.locationSnapshot(ctx, server.ServerAddr)
+	diagnostics.Location = location
+	locationCheck := DiagnosticCheck{Name: "location", State: diagStateOK, Detail: location}
+	if !resolved {
+		locationCheck.State = diagStateUnknown
+		locationCheck.Detail = "定位信息后台获取中"
+		go s.refreshLocation(server.ServerAddr)
+	}
+	diagnostics.Checks = append(diagnostics.Checks, locationCheck)
+	s.store.AddServerDiagnostic(ctx, diagnostics)
+	return diagnostics
+}
+
+// diagnoseTCP 控制端口 TCP 探测:只证明端口可达,不涉及认证与代理可用性。
+func diagnoseTCP(ctx context.Context, server Server, dnsCheck DiagnosticCheck) DiagnosticCheck {
+	check := DiagnosticCheck{Name: "tcp_reachable"}
+	if dnsCheck.State == diagStateFailed {
+		check.State = diagStateFailed
+		check.Detail = "已跳过:" + dnsCheck.Detail
+		return check
+	}
+	dialer := net.Dialer{Timeout: diagTCPTimeout}
+	started := time.Now()
+	conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(server.ServerAddr, fmt.Sprint(server.ServerPort)))
+	check.LatencyMS = time.Since(started).Milliseconds()
+	if check.LatencyMS < 1 {
+		check.LatencyMS = 1
+	}
+	if err != nil {
+		check.State = diagStateFailed
+		check.Detail = diagnoseDialError(err)
+		return check
+	}
+	_ = conn.Close()
+	check.State = diagStateOK
+	check.Detail = fmt.Sprintf("控制端口 %d 可达", server.ServerPort)
+	return check
+}
+
+// diagnoseDNS 记录域名解析结果:A/AAAA 地址、解析耗时;TTL 由系统解析器决定,标准库无法读取,不展示。
+func diagnoseDNS(ctx context.Context, host string) DiagnosticCheck {
+	check := DiagnosticCheck{Name: "dns_resolution"}
+	if ip := net.ParseIP(host); ip != nil {
+		check.State = diagStateOK
+		check.Detail = fmt.Sprintf("配置为 IP 地址 %s,未经过域名解析", ip)
+		return check
+	}
+	resolver := net.DefaultResolver
+	ctxTimeout, cancel := context.WithTimeout(ctx, diagDNSTimeout)
+	defer cancel()
+	started := time.Now()
+	addresses, err := resolver.LookupIPAddr(ctxTimeout, host)
+	elapsed := time.Since(started).Milliseconds()
+	if err != nil || len(addresses) == 0 {
+		check.State = diagStateFailed
+		check.Detail = "域名解析失败:" + diagnoseResolveError(err)
+		return check
+	}
+	var ipv4, ipv6 []string
+	for _, address := range addresses {
+		if address.IP.To4() != nil {
+			ipv4 = append(ipv4, address.IP.String())
+		} else {
+			ipv6 = append(ipv6, address.IP.String())
+		}
+	}
+	summary := "系统解析器;A=" + strings.Join(ipv4, ", ")
+	if len(ipv6) > 0 {
+		summary += ";AAAA=" + strings.Join(ipv6, ", ")
+	} else {
+		summary += ";AAAA=无"
+	}
+	check.LatencyMS = elapsed
+	check.Detail = fmt.Sprintf("%s(解析耗时 %d ms)", summary, elapsed)
+	return check
+}
+
+func diagnoseResolveError(err error) string {
+	if err == nil {
+		return "无解析结果"
+	}
+	if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound {
+		return "域名不存在"
+	}
+	return err.Error()
+}
+
+// diagnoseFRPCLogin 从 frpc 运行日志推断登录状态:ok 已登录,failed 最近一次登录失败,unknown 无证据。
+func (s *Service) diagnoseFRPCLogin(serverID int64) DiagnosticCheck {
+	check := DiagnosticCheck{Name: "frpc_login"}
+	data, err := os.ReadFile(s.serverLogPath(serverID))
+	if os.IsNotExist(err) {
+		check.State = diagStateUnknown
+		check.Detail = "frpc 尚未运行,无登录记录"
+		return check
+	}
+	if err != nil {
+		check.State = diagStateUnknown
+		check.Detail = "读取 frpc 日志失败"
+		return check
+	}
+	state, detail := summarizeFRPCLogin(string(data))
+	check.State = state
+	check.Detail = detail
+	return check
+}
+
+// summarizeFRPCLogin 从日志尾部向前找最近的登录结论;登录成功优先于更早的失败记录。
+func summarizeFRPCLogin(log string) (string, string) {
+	lines := strings.Split(strings.TrimRight(log, "\n"), "\n")
+	const window = 200
+	if len(lines) > window {
+		lines = lines[len(lines)-window:]
+	}
+	failureDetail := ""
+	for index := len(lines) - 1; index >= 0; index-- {
+		line := lines[index]
+		if prefix := strings.Index(line, "] "); prefix >= 0 {
+			line = line[prefix+2:]
+		}
+		if strings.Contains(line, "login to server success") {
+			return diagStateOK, "frpc 已登录 frps"
+		}
+		if failureDetail == "" && isLoginFailureLine(line) {
+			failureDetail = line
+		}
+	}
+	if failureDetail != "" {
+		if len(failureDetail) > 160 {
+			failureDetail = failureDetail[:160] + "…"
+		}
+		return diagStateFailed, "frpc 登录失败:" + failureDetail
+	}
+	return diagStateUnknown, "日志中暂无登录结果记录"
+}
+
+func isLoginFailureLine(line string) bool {
+	if strings.Contains(line, "login to the server failed") || strings.Contains(line, "connect to server error") || strings.Contains(line, "authentication failed") {
+		return true
+	}
+	return strings.Contains(line, "token") && strings.Contains(line, "error")
+}
+
+// locationSnapshot 返回定位缓存值;未命中时返回 IP 占位(resolved=false),由调用方后台刷新。
+func (s *Service) locationSnapshot(ctx context.Context, host string) (string, bool) {
+	ip := resolvePublicIP(ctx, host)
+	if ip == "" {
+		// 内网地址或解析失败:展示主机本身,不发起外部定位,也不视为错误
+		return strings.TrimSpace(host), true
+	}
+	s.locationMu.Lock()
+	cached, ok := s.locations[ip]
+	s.locationMu.Unlock()
+	if ok && time.Now().Before(cached.expiresAt) {
+		return cached.value, true
+	}
+	return ip, false
+}
+
+// refreshLocation 后台补全定位缓存;结果只影响展示,不参与可用性判定。
+func (s *Service) refreshLocation(host string) {
+	ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
+	defer cancel()
+	_ = s.serverLocation(ctx, host)
+}
diff --git a/internal/frp/frps_template.go b/internal/frp/frps_template.go
new file mode 100644
index 0000000..1d1b6c6
--- /dev/null
+++ b/internal/frp/frps_template.go
@@ -0,0 +1,216 @@
+package frp
+
+import (
+	"context"
+	"fmt"
+	"strconv"
+	"strings"
+)
+
+// buildFRPSServerConfig 把多服务端模块的 frpc 侧服务端记录渲染为 VPS 侧的 frps.toml,
+// bindPort、认证、管理接口与 frpc 配置同源生成,消除两端错配(官方 BuildFRPSConfig 的多服务端版)。
+// 密钥只在生成前一刻解密进模板,永不写入日志或其他 API 响应。
+func (s *Service) buildFRPSServerConfig(ctx context.Context, server Server) (string, error) {
+	// token 认证才需要取同一份 token;none / oidc 不涉及
+	token := ""
+	if server.Options.AuthMethod == "token" {
+		encoded, err := s.store.Token(ctx, server.ID)
+		if err != nil {
+			return "", err
+		}
+		if encoded != "" {
+			token, err = s.secretBox.Decrypt(encoded)
+			if err != nil {
+				return "", fmt.Errorf("FRP Token 无法解密,请重新保存服务端配置")
+			}
+		}
+	}
+
+	dashboardOn := server.Options.DashboardPort > 0 && strings.TrimSpace(server.Options.DashboardAddr) != ""
+	hasVhost, vhostErr := s.hasVhostProxies(ctx, server.ID)
+	// subDomainHost 缺口检测:frpc 侧规则填了 subdomain 时,frps 必须配置 subDomainHost 才生效
+	proxies, proxyErr := s.store.ListProxiesByServer(ctx, server.ID)
+	hasSubdomain := false
+	if proxyErr == nil {
+		for _, p := range proxies {
+			if strings.TrimSpace(p.Options.Subdomain) != "" {
+				hasSubdomain = true
+				break
+			}
+		}
+	}
+
+	var b strings.Builder
+	b.WriteString("# 由 Fonu 生成:与本服务端的 frpc 配置同源(bindPort/auth 与客户端一致)\n")
+	b.WriteString("# 格式:frp v0.52.0+ 的 TOML 配置(Docker 镜像内置 frp 0.71.0;INI 自 v0.52.0 起已被官方弃用)\n")
+	b.WriteString("# 用法:保存为 VPS 上的 frps.toml,执行 ./frps -c frps.toml\n\n")
+	// TOML 规范:顶层标量键必须写在任何 [table] 段之前,否则会被归入上一个表
+	b.WriteString("bindAddr = \"0.0.0.0\"\n")
+	b.WriteString(fmt.Sprintf("bindPort = %d\n", server.ServerPort))
+	switch server.Options.Protocol {
+	case "kcp":
+		b.WriteString(fmt.Sprintf("kcpBindPort = %d # frpc 使用 kcp 协议接入;官方允许与 bindPort 相同(UDP)\n", server.ServerPort))
+	case "quic":
+		b.WriteString(fmt.Sprintf("quicBindPort = %d # frpc 使用 quic 协议接入;UDP 端口官方允许与 bindPort 相同,如冲突请改为独立端口\n", server.ServerPort))
+	}
+	if vhostErr == nil && hasVhost {
+		// 官方默认 vhost 端口为 0(禁用)。默认 8080/8443:80/443 由 Nginx 占用(Nginx 80/443 →
+		// proxy_pass 127.0.0.1:8080 → frps vhost → 隧道),可在服务端表单自定义,与 Nginx upstream 同源
+		httpPort := server.Options.VhostHTTPPort
+		if httpPort <= 0 {
+			httpPort = 8080
+		}
+		httpsPort := server.Options.VhostHTTPSPort
+		if httpsPort <= 0 {
+			httpsPort = 8443
+		}
+		b.WriteString(fmt.Sprintf("vhostHTTPPort = %d\n", httpPort))
+		b.WriteString(fmt.Sprintf("vhostHTTPSPort = %d\n", httpsPort))
+	}
+	// 子域名根:规则填了 subdomain 时必须配置,否则 subdomain 不生效
+	if host := strings.TrimSpace(server.Options.SubDomainHost); host != "" {
+		b.WriteString(fmt.Sprintf("subDomainHost = %q\n", host))
+	}
+	// 自定义 404 页面(HTTP vhost 命中不到规则时的响应)
+	if page := strings.TrimSpace(server.Options.Custom404Page); page != "" {
+		b.WriteString(fmt.Sprintf("custom404Page = %q\n", page))
+	}
+	// tcpmux 规则的硬依赖:存在 tcpmux 规则时必须配置监听端口
+	if port := server.Options.TCPMuxHTTPConnectPort; port > 0 {
+		b.WriteString(fmt.Sprintf("tcpmuxHTTPConnectPort = %d\n", port))
+	}
+	// 强制 TLS:公网 frps 标准安全实践;开启后 frpc 不开 TLS 将无法连接
+	if server.Options.TLSForce {
+		b.WriteString("transport.tls.force = true # 只接受 TLS 连接(frpc 侧必须启用 transport.tls.enable)\n")
+	}
+	// 传输参数与 frpc 侧同源:两端不一致会导致连不上或池被截断
+	if !server.Options.TCPMux {
+		b.WriteString("transport.tcpMux = false # 与 frpc 侧 tcp_mux 同源(官方默认 true,仅在关闭时输出)\n")
+	}
+	if server.Options.PoolCount > 0 {
+		b.WriteString(fmt.Sprintf("transport.maxPoolCount = %d # 不低于 frpc 侧 pool_count,避免连接池被静默截断\n", server.Options.PoolCount))
+	}
+	// 端口白名单:逗号分隔的单端口或「起-止」范围,如 6000,6005-6010,7000-8000;留空不限制
+	if ports := strings.TrimSpace(server.Options.AllowPorts); ports != "" {
+		b.WriteString(fmt.Sprintf("allowPorts = [\n%s\n]\n", renderAllowPorts(ports)))
+	}
+	// 每客户端可绑定端口上限;0 表示不限制
+	if maxPorts := server.Options.MaxPortsPerClient; maxPorts > 0 {
+		b.WriteString(fmt.Sprintf("maxPortsPerClient = %d\n", maxPorts))
+	}
+	// frps 文件日志:journalctl 只保留尾部,排障需完整历史时开启;写入 /var/log/frps.log
+	if server.Options.FrpsLogToFile {
+		maxDays := server.Options.FrpsLogMaxDays
+		if maxDays <= 0 {
+			maxDays = 3
+		}
+		b.WriteString("\nlog.to = \"/var/log/frps.log\"\n")
+		b.WriteString(fmt.Sprintf("log.maxDays = %d\n", maxDays))
+	}
+	if !server.TLS {
+		b.WriteString("# 安全提示:frpc 侧未启用 TLS,frps 可加 transport.tls.force = true 强制 TLS(开启后 frpc 不开 TLS 将无法连接)\n")
+	}
+	switch server.Options.AuthMethod {
+	case "token":
+		if token != "" {
+			// 点式键与官方 frps_full_example.toml 同款(TOML 语义上与 [auth] 表等价)
+			b.WriteString("\nauth.method = \"token\"\n")
+			b.WriteString(fmt.Sprintf("auth.token = %q\n", token))
+		} else {
+			b.WriteString("\n# 注意:frpc 侧为 Token 认证但尚未保存 Token;保存后在 Fonu 重新生成本文件以保持两端一致\n")
+		}
+	case "none":
+		b.WriteString("\n# frpc 侧为无认证(auth.method = \"none\"),frps 无需 [auth] 配置;\n")
+		b.WriteString("# 公网暴露的 frps 建议配置认证或用 transport.tls.force = true 强制 TLS,防止任意客户端接入\n")
+	case "oidc":
+		b.WriteString("\n# frpc 侧使用 OIDC 认证;frps 侧的 oidc.* 校验参数(issuer、client_id 等)依赖你的身份提供商,\n")
+		b.WriteString("# 请参考官方 frps_full_example.toml 的 [auth] oidc 段手动配置\n")
+	}
+	if server.TLS {
+		b.WriteString("\n# frpc 侧已启用 transport.tls.enable;frps 的 transport.tls.force 默认为 false,无需额外 TLS 配置即可接受 TLS 连接\n")
+	}
+
+	// webServer(frps 管理接口):与客户端 options.dashboard_* 同源
+	if dashboardOn {
+		b.WriteString("\nwebServer.addr = \"0.0.0.0\"\n")
+		b.WriteString(fmt.Sprintf("webServer.port = %d\n", server.Options.DashboardPort))
+		if user := strings.TrimSpace(server.Options.DashboardUser); user != "" {
+			b.WriteString(fmt.Sprintf("webServer.user = %q\n", user))
+		}
+		if encoded, err := s.store.DashboardPassword(ctx, server.ID); err == nil && encoded != "" {
+			if password, err := s.secretBox.Decrypt(encoded); err == nil && password != "" {
+				b.WriteString(fmt.Sprintf("webServer.password = %q\n", password))
+			}
+		}
+		b.WriteString("\n# 安全提示:webServer 暴露穿透流量与连接信息,建议用防火墙限制仅 Fonu 所在机器访问该端口\n")
+	}
+
+	if hasSubdomain {
+		b.WriteString("\n# 注意:该服务端下存在填写 subdomain 的规则;frps 需配置 subDomainHost(如 frps.example.com)后,\n")
+		b.WriteString("# 规则的 subdomain 字段才会生效为 sub.frps.example.com;域名 DNS 也需指向本 VPS\n")
+	}
+	b.WriteString("\n# 防火墙放行清单(按本服务端当前配置生成)\n")
+	b.WriteString(fmt.Sprintf("# %d/tcp  # frps 控制端口\n", server.ServerPort))
+	if dashboardOn {
+		b.WriteString(fmt.Sprintf("# %d/tcp  # frps 管理接口(webServer)\n", server.Options.DashboardPort))
+	}
+	if vhostErr == nil && hasVhost {
+		httpPort := server.Options.VhostHTTPPort
+		if httpPort <= 0 {
+			httpPort = 8080
+		}
+		httpsPort := server.Options.VhostHTTPSPort
+		if httpsPort <= 0 {
+			httpsPort = 8443
+		}
+		b.WriteString(fmt.Sprintf("# %d, %d/tcp  # 上方 vhostHTTPPort / vhostHTTPSPort 对应端口(仅本机 Nginx 回源需要,可不对公网开放)\n", httpPort, httpsPort))
+	}
+	return b.String(), nil
+}
+
+// hasVhostProxies 判断该服务端下是否存在依赖 vhost 端口的规则(http/https/tcpmux)。
+func (s *Service) hasVhostProxies(ctx context.Context, serverID int64) (bool, error) {
+	proxies, err := s.store.ListProxiesByServer(ctx, serverID)
+	if err != nil {
+		return false, err
+	}
+	for _, p := range proxies {
+		if p.Type == "http" || p.Type == "https" || p.Type == "tcpmux" {
+			return true, nil
+		}
+	}
+	return false, nil
+}
+
+// renderAllowPorts 把表单端口白名单文本(如 "6000,6005-6010,7000-8000")渲染为 TOML 数组行;
+// 非法片段直接忽略,不中断生成。
+func renderAllowPorts(ports string) string {
+	var lines []string
+	for _, part := range strings.Split(ports, ",") {
+		part = strings.TrimSpace(part)
+		if part == "" {
+			continue
+		}
+		if start, end, ok := strings.Cut(part, "-"); ok {
+			startN, err1 := strconv.Atoi(strings.TrimSpace(start))
+			endN, err2 := strconv.Atoi(strings.TrimSpace(end))
+			if err1 == nil && err2 == nil && startN > 0 && endN >= startN && endN <= 65535 {
+				lines = append(lines, fmt.Sprintf("{ start = %d, end = %d },", startN, endN))
+			}
+			continue
+		}
+		if n, err := strconv.Atoi(part); err == nil && n > 0 && n <= 65535 {
+			lines = append(lines, fmt.Sprintf("{ single = %d },", n))
+		}
+	}
+	return strings.Join(lines, "\n")
+}
+
+// FRPSServerConfig 返回指定服务端的 frps.toml 同源配置(对外 API 入口)。
+func (s *Service) FRPSServerConfig(ctx context.Context, id int64) (string, error) {
+	server, err := s.store.GetServer(ctx, id)
+	if err != nil {
+		return "", err
+	}
+	return s.buildFRPSServerConfig(ctx, server)
+}
diff --git a/internal/frp/location.go b/internal/frp/location.go
new file mode 100644
index 0000000..78aaf59
--- /dev/null
+++ b/internal/frp/location.go
@@ -0,0 +1,99 @@
+package frp
+
+import (
+	"context"
+	"encoding/json"
+	"io"
+	"net"
+	"net/http"
+	"strings"
+	"time"
+)
+
+const locationCacheTTL = 24 * time.Hour
+
+type locationCache struct {
+	value     string
+	expiresAt time.Time
+}
+
+type ipLocationResponse struct {
+	Success bool   `json:"success"`
+	City    string `json:"city"`
+	Region  string `json:"region"`
+	Country string `json:"country"`
+}
+
+func (s *Service) serverLocation(ctx context.Context, host string) string {
+	ip := resolvePublicIP(ctx, host)
+	if ip == "" {
+		return strings.TrimSpace(host)
+	}
+	s.locationMu.Lock()
+	if cached, ok := s.locations[ip]; ok && time.Now().Before(cached.expiresAt) {
+		s.locationMu.Unlock()
+		return cached.value
+	}
+	s.locationMu.Unlock()
+	value := lookupIPLocation(ctx, ip)
+	// 定位失败只是展示信息缺失:缓存 IP 本身,不产生错误状态,下一次诊断会再次尝试
+	s.locationMu.Lock()
+	s.locations[ip] = locationCache{value: value, expiresAt: time.Now().Add(locationCacheTTL)}
+	s.locationMu.Unlock()
+	return value
+}
+
+func resolvePublicIP(ctx context.Context, host string) string {
+	if ip := net.ParseIP(host); isPublicIP(ip) {
+		return ip.String()
+	}
+	addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host)
+	if err != nil {
+		return ""
+	}
+	for _, address := range addresses {
+		if isPublicIP(address.IP) {
+			return address.IP.String()
+		}
+	}
+	return ""
+}
+
+func isPublicIP(ip net.IP) bool {
+	return ip != nil && ip.IsGlobalUnicast() && !ip.IsPrivate() && !ip.IsLoopback() && !ip.IsLinkLocalUnicast()
+}
+
+func lookupIPLocation(ctx context.Context, ip string) string {
+	request, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://ipwho.is/"+ip, nil)
+	if err != nil {
+		return "未定位"
+	}
+	client := &http.Client{Timeout: 4 * time.Second}
+	response, err := client.Do(request)
+	if err != nil {
+		return "未定位"
+	}
+	defer response.Body.Close()
+	if response.StatusCode != http.StatusOK {
+		return "未定位"
+	}
+	var data ipLocationResponse
+	if err := json.NewDecoder(io.LimitReader(response.Body, 128<<10)).Decode(&data); err != nil || !data.Success {
+		return "未定位"
+	}
+	return formatLocation(data)
+}
+
+func formatLocation(data ipLocationResponse) string {
+	parts := []string{data.City, data.Region, data.Country}
+	values := make([]string, 0, len(parts))
+	for _, part := range parts {
+		if part != "" && (len(values) == 0 || values[len(values)-1] != part) {
+			values = append(values, part)
+		}
+	}
+	if len(values) == 0 {
+		return "未定位"
+	}
+	return strings.Join(values, " · ")
+}
diff --git a/internal/frp/location_test.go b/internal/frp/location_test.go
new file mode 100644
index 0000000..321f499
--- /dev/null
+++ b/internal/frp/location_test.go
@@ -0,0 +1,22 @@
+package frp
+
+import (
+	"net"
+	"testing"
+)
+
+func TestFormatLocation(t *testing.T) {
+	value := formatLocation(ipLocationResponse{City: "Hong Kong", Region: "Hong Kong", Country: "China"})
+	if value != "Hong Kong · China" {
+		t.Fatalf("位置格式错误:%s", value)
+	}
+}
+
+func TestIsPublicIP(t *testing.T) {
+	if !isPublicIP(net.ParseIP("38.95.74.87")) {
+		t.Fatal("公网 IP 应被识别")
+	}
+	if isPublicIP(net.ParseIP("192.168.1.1")) {
+		t.Fatal("私网 IP 不应被定位")
+	}
+}
diff --git a/internal/frp/manager.go b/internal/frp/manager.go
new file mode 100644
index 0000000..c0f2f22
--- /dev/null
+++ b/internal/frp/manager.go
@@ -0,0 +1,300 @@
+package frp
+
+import (
+	"context"
+	"fmt"
+	"log/slog"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"sync"
+	"time"
+)
+
+type Manager struct {
+	bin        string
+	configPath string
+	logPath    string
+	logger     *slog.Logger
+
+	mu        sync.RWMutex
+	cmd       *exec.Cmd
+	orphan    *os.Process
+	orphanPID int
+	pid       int
+	version   string
+	started   time.Time
+	reloaded  time.Time
+	lastErr   string
+}
+
+func NewManager(bin, configPath, logPath string, logger *slog.Logger) *Manager {
+	return &Manager{bin: bin, configPath: configPath, logPath: logPath, logger: logger.With("module", "FRP")}
+}
+
+func (m *Manager) Verify(ctx context.Context, configPath string) error {
+	bin, err := m.BinaryPath()
+	if err != nil {
+		return err
+	}
+	cmd := exec.CommandContext(ctx, bin, "verify", "-c", configPath)
+	output, err := cmd.CombinedOutput()
+	if err != nil {
+		return fmt.Errorf("frpc 配置校验失败:%s", safeCommandOutput(output, err))
+	}
+	return nil
+}
+
+func (m *Manager) VerifyBinary(ctx context.Context) error {
+	bin, err := m.BinaryPath()
+	if err != nil {
+		return err
+	}
+	output, err := exec.CommandContext(ctx, bin, "--version").CombinedOutput()
+	if err != nil {
+		return fmt.Errorf("frpc 二进制校验失败:%s", safeCommandOutput(output, err))
+	}
+	return nil
+}
+
+func (m *Manager) Start() error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	if m.runningLocked() {
+		return nil
+	}
+	bin, err := m.binaryPathLocked()
+	if err != nil {
+		m.lastErr = err.Error()
+		return err
+	}
+	logFile, err := os.OpenFile(m.logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
+	if err != nil {
+		return err
+	}
+	cmd := exec.Command(bin, "-c", m.configPath)
+	cmd.Stdout = logFile
+	cmd.Stderr = logFile
+	if err := cmd.Start(); err != nil {
+		logFile.Close()
+		m.lastErr = err.Error()
+		return fmt.Errorf("启动 frpc 失败:%w", err)
+	}
+	m.cmd = cmd
+	m.pid = cmd.Process.Pid
+	m.started = time.Now().UTC()
+	m.reloaded = m.started
+	m.lastErr = ""
+	go m.wait(cmd, logFile)
+	return nil
+}
+
+func (m *Manager) Restart(ctx context.Context) error {
+	if err := m.Stop(ctx); err != nil {
+		return err
+	}
+	return m.Start()
+}
+
+func (m *Manager) Stop(ctx context.Context) error {
+	m.mu.Lock()
+	cmd := m.cmd
+	orphan := m.orphan
+	m.mu.Unlock()
+	if cmd == nil && orphan == nil {
+		return nil
+	}
+	process := orphan
+	if cmd != nil {
+		process = cmd.Process
+	}
+	if process == nil {
+		return nil
+	}
+	if err := process.Kill(); err != nil {
+		return fmt.Errorf("停止 frpc 失败:%w", err)
+	}
+	deadline := time.NewTimer(5 * time.Second)
+	defer deadline.Stop()
+	for {
+		if !m.running() {
+			m.mu.Lock()
+			m.lastErr = ""
+			m.orphan = nil
+			m.orphanPID = 0
+			m.mu.Unlock()
+			return nil
+		}
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		case <-deadline.C:
+			return fmt.Errorf("等待 frpc 停止超时")
+		case <-time.After(50 * time.Millisecond):
+		}
+	}
+}
+
+func (m *Manager) Status() RuntimeStatus {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+	return m.statusLocked()
+}
+
+func (m *Manager) statusLocked() RuntimeStatus {
+	status := RuntimeStatus{
+		Status:    "stopped",
+		PID:       m.pid,
+		Version:   m.version,
+		LastError: m.lastErr,
+	}
+	if m.runningLocked() {
+		status.Status = "running"
+		if status.PID == 0 {
+			status.PID = m.orphanPID
+		}
+	} else if m.orphan != nil {
+		status.PID = 0
+	}
+	if !m.started.IsZero() {
+		status.LastStartedAt = m.started.Format(time.RFC3339)
+	}
+	if !m.reloaded.IsZero() {
+		status.LastReloadedAt = m.reloaded.Format(time.RFC3339)
+	}
+	return status
+}
+
+func (m *Manager) RefreshVersion(ctx context.Context) {
+	bin, err := m.BinaryPath()
+	if err != nil {
+		return
+	}
+	output, err := exec.CommandContext(ctx, bin, "--version").Output()
+	if err != nil {
+		return
+	}
+	m.mu.Lock()
+	m.version = strings.TrimSpace(string(output))
+	m.mu.Unlock()
+}
+
+func (m *Manager) wait(cmd *exec.Cmd, logFile *os.File) {
+	err := cmd.Wait()
+	logFile.Close()
+	m.mu.Lock()
+	if m.cmd != cmd {
+		m.mu.Unlock()
+		return
+	}
+	m.cmd = nil
+	m.pid = 0
+	if err != nil {
+		// 退出原因在 frpc 自身日志的尾部(stderr 也写在那里),直接携带进应用日志,避免只看到 exit status 1
+		m.lastErr = fmt.Sprintf("frpc 已退出:%v(详见 %s 末尾)", err, m.logPath)
+		m.logger.Warn("frpc exited", "error", err.Error(), "log_tail", tailFile(m.logPath, 12))
+	}
+	m.mu.Unlock()
+}
+
+// tailFile 返回文件最后 maxLines 行文本;读不到时返回空串(不阻断退出处理)。
+func tailFile(path string, maxLines int) string {
+	data, err := os.ReadFile(path)
+	if err != nil || len(data) == 0 {
+		return ""
+	}
+	lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
+	if len(lines) > maxLines {
+		lines = lines[len(lines)-maxLines:]
+	}
+	return strings.Join(lines, " | ")
+}
+
+func (m *Manager) running() bool {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+	return m.runningLocked()
+}
+
+func (m *Manager) runningLocked() bool {
+	if m.cmd != nil && m.cmd.Process != nil {
+		return true
+	}
+	return m.orphan != nil && processAlive(m.orphanPID)
+}
+
+func (m *Manager) Attach(pid int) bool {
+	bin, err := m.BinaryPath()
+	if err != nil || !processMatches(pid, bin, m.configPath) {
+		return false
+	}
+	process, err := os.FindProcess(pid)
+	if err != nil {
+		return false
+	}
+	m.mu.Lock()
+	m.orphan = process
+	m.orphanPID = pid
+	m.pid = pid
+	m.lastErr = ""
+	m.mu.Unlock()
+	return true
+}
+
+func processAlive(pid int) bool {
+	alive, _ := probeProcess(pid)
+	return alive
+}
+
+func processMatches(pid int, binary, configPath string) bool {
+	alive, commandLine := probeProcess(pid)
+	if !alive {
+		return false
+	}
+	commandLine = strings.ToLower(filepath.ToSlash(commandLine))
+	return strings.Contains(commandLine, strings.ToLower(filepath.ToSlash(binary))) &&
+		strings.Contains(commandLine, strings.ToLower(filepath.ToSlash(configPath)))
+}
+
+func (m *Manager) BinaryPath() (string, error) {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+	return m.binaryPathLocked()
+}
+
+func (m *Manager) binaryPathLocked() (string, error) {
+	if strings.ContainsAny(m.bin, `/\\`) {
+		if _, err := os.Stat(m.bin); err != nil {
+			return "", fmt.Errorf("未找到 frpc 二进制:%s", m.bin)
+		}
+		return m.bin, nil
+	}
+	path, err := exec.LookPath(m.bin)
+	if err != nil {
+		return "", fmt.Errorf("未找到 frpc 二进制,请设置 FONU_FRPC_BIN")
+	}
+	return path, nil
+}
+
+func (m *Manager) SetBinary(bin string) {
+	m.mu.Lock()
+	m.bin = bin
+	m.version = ""
+	m.mu.Unlock()
+}
+
+func (m *Manager) LogPath() string {
+	return filepath.Clean(m.logPath)
+}
+
+func safeCommandOutput(output []byte, err error) string {
+	text := strings.TrimSpace(string(output))
+	if text == "" {
+		return err.Error()
+	}
+	if len(text) > 500 {
+		return text[:500]
+	}
+	return text
+}
diff --git a/internal/frp/model.go b/internal/frp/model.go
new file mode 100644
index 0000000..b857647
--- /dev/null
+++ b/internal/frp/model.go
@@ -0,0 +1,345 @@
+package frp
+
+import "time"
+
+type RuntimeInfo struct {
+	Platform        string   `json:"platform"`
+	ConfiguredBin   string   `json:"configured_bin"`
+	ActiveBin       string   `json:"active_bin,omitempty"`
+	ActiveVersion   string   `json:"active_version,omitempty"`
+	DetectedVersion string   `json:"detected_version,omitempty"`
+	DownloadProxy   string   `json:"download_proxy"`
+	ConfigPath      string   `json:"config_path"`
+	ConfigPaths     []string `json:"config_paths,omitempty"`
+	ConfigExists    bool     `json:"config_exists"`
+}
+
+type Release struct {
+	Version     string `json:"version"`
+	PublishedAt string `json:"published_at,omitempty"`
+	AssetName   string `json:"asset_name"`
+	Size        int64  `json:"size"`
+	Checksum    string `json:"-"`
+	AssetID     int64  `json:"-"`
+}
+
+type Binary struct {
+	Version     string `json:"version"`
+	Path        string `json:"path"`
+	Size        int64  `json:"size"`
+	InstalledAt string `json:"installed_at"`
+	Active      bool   `json:"active"`
+}
+
+// DiagnosticCheck 单项诊断结果;State 取 ok / failed / unknown(unknown 表示无法确认,不等于失败)。
+type DiagnosticCheck struct {
+	Name      string `json:"name"`
+	State     string `json:"state"`
+	Detail    string `json:"detail,omitempty"`
+	LatencyMS int64  `json:"latency_ms,omitempty"`
+}
+
+// ServerDiagnostics 服务端诊断结果。Available/LatencyMS/Error 保持 tcp_reachable 口径
+// 兼容历史延迟曲线;定位等非阻断信息只出现在 Checks 中,不参与 Error。
+type ServerDiagnostics struct {
+	ServerID  int64             `json:"server_id"`
+	Available bool              `json:"available"`
+	LatencyMS int64             `json:"latency_ms,omitempty"`
+	Location  string            `json:"location,omitempty"`
+	Checks    []DiagnosticCheck `json:"checks"`
+	Error     string            `json:"error,omitempty"`
+}
+
+type ServerSample struct {
+	CheckedAt string `json:"checked_at"`
+	Available bool   `json:"available"`
+	LatencyMS int64  `json:"latency_ms,omitempty"`
+	Error     string `json:"error,omitempty"`
+}
+
+type ServerStatusDetail struct {
+	Available      bool           `json:"available"`
+	Availability   float64        `json:"availability"`
+	AverageLatency int64          `json:"average_latency_ms"`
+	PeakLatency    int64          `json:"peak_latency_ms"`
+	Samples        []ServerSample `json:"samples"`
+	LastCheckedAt  string         `json:"last_checked_at,omitempty"`
+	LastError      string         `json:"last_error,omitempty"`
+}
+
+type ServerDetail struct {
+	Server  Server             `json:"server"`
+	Status  ServerStatusDetail `json:"status"`
+	Proxies []Proxy            `json:"proxies"`
+}
+
+type DownloadTask struct {
+	Version    string `json:"version,omitempty"`
+	Status     string `json:"status"`
+	Message    string `json:"message"`
+	Downloaded int64  `json:"downloaded"`
+	Total      int64  `json:"total"`
+	Error      string `json:"error,omitempty"`
+	UpdatedAt  string `json:"updated_at,omitempty"`
+}
+
+type Server struct {
+	ID                 int64         `json:"id"`
+	Name               string        `json:"name"`
+	AgentURL           string        `json:"agent_url,omitempty"`
+	AgentConfigured    bool          `json:"agent_configured"`
+	SSHConfigured      bool          `json:"ssh_configured"`
+	MgmtEnabled        bool          `json:"mgmt_enabled"`
+	SSHHost            string        `json:"ssh_host,omitempty"`
+	SSHPort            int           `json:"ssh_port,omitempty"`
+	SSHUser            string        `json:"ssh_user,omitempty"`
+	SSHAuth            string        `json:"ssh_auth,omitempty"`
+	ServerAddr         string        `json:"server_addr"`
+	ServerPort         int           `json:"server_port"`
+	TLS                bool          `json:"tls_enabled"`
+	TLSServerName      string        `json:"tls_server_name,omitempty"`
+	Enabled            bool          `json:"enabled"`
+	HasToken           bool          `json:"has_token"`
+	HasOIDCSecret      bool          `json:"has_oidc_client_secret"`
+	DashboardHasPwd    bool          `json:"dashboard_has_pwd"`
+	Options            ServerOptions `json:"options"`
+	BootStatus         string        `json:"boot_status"`
+	DesiredState       string        `json:"desired_state"`
+	ProcessState       string        `json:"process_state"`
+	ConnectionState    string        `json:"connection_state"`
+	StateSource        string        `json:"state_source"`
+	ProcessPID         int           `json:"process_pid,omitempty"`
+	ExitCode           int           `json:"exit_code,omitempty"`
+	LastConnectedAt    string        `json:"last_connected_at,omitempty"`
+	LastDisconnectedAt string        `json:"last_disconnected_at,omitempty"`
+	LastError          string        `json:"last_error,omitempty"`
+	LastStartedAt      string        `json:"last_started_at,omitempty"`
+	CreatedAt          time.Time     `json:"created_at"`
+	UpdatedAt          time.Time     `json:"updated_at"`
+}
+
+type ServerRuntimeStatus struct {
+	ServerID           int64
+	Status             string
+	DesiredState       string
+	ProcessState       string
+	ConnectionState    string
+	StateSource        string
+	ProcessPID         int
+	ExitCode           int
+	LastError          string
+	LastStartedAt      string
+	LastConnectedAt    string
+	LastDisconnectedAt string
+}
+
+type ServerOptions struct {
+	User                   string `json:"user,omitempty"`
+	Location               string `json:"location,omitempty"`
+	AuthMethod             string `json:"auth_method"`
+	OIDCClientID           string `json:"oidc_client_id,omitempty"`
+	OIDCAudience           string `json:"oidc_audience,omitempty"`
+	OIDCScope              string `json:"oidc_scope,omitempty"`
+	OIDCTokenEndpointURL   string `json:"oidc_token_endpoint_url,omitempty"`
+	OIDCTrustedCAPath      string `json:"oidc_trusted_ca_path,omitempty"`
+	OIDCInsecureSkipVerify bool   `json:"oidc_insecure_skip_verify"`
+	OIDCProxyURL           string `json:"oidc_proxy_url,omitempty"`
+	// VPS frps 的 vhost 监听端口(http/https 反代回源端口),与 frps.toml 生成器和 Nginx upstream 同源
+	VhostHTTPPort  int `json:"vhost_http_port,omitempty"`
+	VhostHTTPSPort int `json:"vhost_https_port,omitempty"`
+	// frps 进阶配置:子域名/404/强制 TLS/端口白名单/每客户端端口上限/tcpmux/文件日志
+	SubDomainHost         string `json:"sub_domain_host,omitempty"`
+	Custom404Page         string `json:"custom_404_page,omitempty"`
+	TLSForce              bool   `json:"tls_force"`
+	AllowPorts            string `json:"allow_ports,omitempty"`
+	MaxPortsPerClient     int    `json:"max_ports_per_client,omitempty"`
+	TCPMuxHTTPConnectPort int    `json:"tcpmux_http_connect_port,omitempty"`
+	FrpsLogToFile         bool   `json:"frps_log_to_file"`
+	FrpsLogMaxDays        int    `json:"frps_log_max_days,omitempty"`
+	// frps 管理接口(webServer),仅用于 Fonu 拉取穿透流量等只读信息,不写入 frpc.toml
+	DashboardAddr             string `json:"dashboard_addr,omitempty"`
+	DashboardPort             int    `json:"dashboard_port,omitempty"`
+	DashboardUser             string `json:"dashboard_user,omitempty"`
+	DashboardHasPwd           bool   `json:"dashboard_has_pwd"`
+	LogLevel                  string `json:"log_level"`
+	LogMaxDays                int    `json:"log_max_days"`
+	Protocol                  string `json:"protocol"`
+	ProxyURL                  string `json:"proxy_url,omitempty"`
+	DialServerTimeout         int    `json:"dial_server_timeout"`
+	DialServerKeepalive       int    `json:"dial_server_keepalive"`
+	ConnectServerLocalIP      string `json:"connect_server_local_ip,omitempty"`
+	DNSServer                 string `json:"dns_server,omitempty"`
+	PoolCount                 int    `json:"pool_count"`
+	TCPMux                    bool   `json:"tcp_mux"`
+	TCPMuxKeepaliveInterval   int    `json:"tcp_mux_keepalive_interval"`
+	HeartbeatInterval         int    `json:"heartbeat_interval"`
+	HeartbeatTimeout          int    `json:"heartbeat_timeout"`
+	LoginFailExit             bool   `json:"login_fail_exit"`
+	TLSDisableCustomFirstByte bool   `json:"tls_disable_custom_first_byte"`
+	AutoStart                 bool   `json:"auto_start"`
+	Remark                    string `json:"remark,omitempty"`
+	TLSCertificateConfigured  bool   `json:"tls_certificate_configured"`
+	TLSKeyConfigured          bool   `json:"tls_key_configured"`
+	TLSTrustedCAConfigured    bool   `json:"tls_trusted_ca_configured"`
+}
+
+type Proxy struct {
+	ID                int64        `json:"id"`
+	ServerID          int64        `json:"server_id"`
+	Name              string       `json:"name"`
+	Type              string       `json:"type"`
+	LocalIP           string       `json:"local_ip"`
+	LocalPort         int          `json:"local_port"`
+	RemotePort        *int         `json:"remote_port,omitempty"`
+	CustomDomains     []string     `json:"custom_domains"`
+	HostHeaderRewrite string       `json:"host_header_rewrite,omitempty"`
+	Options           ProxyOptions `json:"options"`
+	Enabled           bool         `json:"enabled"`
+	Remark            string       `json:"remark,omitempty"`
+	CreatedAt         time.Time    `json:"created_at"`
+	UpdatedAt         time.Time    `json:"updated_at"`
+}
+
+type ServerInput struct {
+	AgentURL        string
+	AgentToken      string
+	ClearAgentToken bool
+	SSHHost         string
+	SSHPort         int
+	SSHUser         string
+	SSHAuth         string
+	SSHSecret       string
+	ClearSSHSecret  bool
+	MgmtEnabled     bool
+
+	Name                   string
+	ServerAddr             string
+	ServerPort             int
+	TLS                    bool
+	TLSServerName          string
+	Enabled                bool
+	Token                  string
+	ClearToken             bool
+	Options                ServerOptions
+	OIDCClientSecret       string
+	ClearOIDCClientSecret  bool
+	DashboardPassword      string
+	ClearDashboardPassword bool
+	TLSCertificate         string
+	TLSKey                 string
+	TLSTrustedCA           string
+	ClearTLSCertificate    bool
+	ClearTLSKey            bool
+	ClearTLSTrustedCA      bool
+}
+
+type ProxyInput struct {
+	ServerID          int64
+	Name              string
+	Type              string
+	LocalIP           string
+	LocalPort         int
+	RemotePort        *int
+	CustomDomains     []string
+	HostHeaderRewrite string
+	Options           ProxyOptions
+	Enabled           bool
+	Remark            string
+}
+
+// ProxyOptions 对应 FRP [[proxies]] 中除基础地址外的扩展配置。
+type ProxyOptions struct {
+	Subdomain string `json:"subdomain,omitempty"`
+	// WebSocket 开关:仅作用于公网 Nginx 反代模板(加 Upgrade/Connection 头),不写入 frpc.toml
+	WebSocket bool `json:"websocket,omitempty"`
+	// 公网反代进阶选项(不写入 frpc.toml):强制跳转 HTTPS / IP 白名单 / Basic Auth 用户名(密码由 agent 生成)
+	RedirectHTTPS bool     `json:"redirect_https,omitempty"`
+	AllowIPs      []string `json:"allow_ips,omitempty"`
+	BasicAuthUser string   `json:"basic_auth_user,omitempty"`
+	// 公网反代第一梯队部署设置(同属公网 Nginx vhost 选项,不写入 frpc.toml)
+	DenyIPs           []string `json:"deny_ips,omitempty"`             // IP 黑名单
+	BasicAuthPassword string   `json:"basic_auth_password,omitempty"`  // 自定义 Basic Auth 密码;空则首次自动生成
+	HTTPSDisabled     bool     `json:"https_disabled,omitempty"`       // 显式关闭 HTTPS
+	ClientMaxBodySize string   `json:"client_max_body_size,omitempty"` // 上传大小,如 50m
+	ProxyReadTimeout  string   `json:"proxy_read_timeout,omitempty"`   // 代理读超时,如 3600s
+	SecurityHeaders   bool     `json:"security_headers,omitempty"`     // 安全响应头
+	TLS13Only         bool     `json:"tls13_only,omitempty"`           // 仅 TLS 1.3
+	// 第二梯队部署设置(同为公网 Nginx vhost 选项)
+	RateLimitRate   int                     `json:"rate_limit_rate,omitempty"`  // 请求限流:每秒请求数
+	RateLimitBurst  int                     `json:"rate_limit_burst,omitempty"` // 请求限流:突发允许量
+	ConnLimitMax    int                     `json:"conn_limit_max,omitempty"`   // 每 IP 并发连接数上限
+	ChinaOnly       bool                    `json:"china_only,omitempty"`       // 仅允许中国大陆 IP
+	Locations       []string                `json:"locations,omitempty"`
+	RouteByHTTPUser string                  `json:"route_by_http_user,omitempty"`
+	HTTPUser        string                  `json:"http_user,omitempty"`
+	HTTPPassword    string                  `json:"http_password,omitempty"`
+	Multiplexer     string                  `json:"multiplexer,omitempty"`
+	SecretKey       string                  `json:"secret_key,omitempty"`
+	AllowUsers      []string                `json:"allow_users,omitempty"`
+	Transport       ProxyTransportOptions   `json:"transport"`
+	HealthCheck     ProxyHealthCheckOptions `json:"health_check"`
+	LoadBalancer    ProxyLoadBalancer       `json:"load_balancer"`
+	Metadatas       map[string]string       `json:"metadatas,omitempty"`
+	RequestHeaders  map[string]string       `json:"request_headers,omitempty"`
+	ResponseHeaders map[string]string       `json:"response_headers,omitempty"`
+	Plugin          *ProxyPluginOptions     `json:"plugin,omitempty"`
+	NATTraversal    *ProxyNATTraversal      `json:"nat_traversal,omitempty"`
+}
+
+type ProxyTransportOptions struct {
+	BandwidthLimit       string `json:"bandwidth_limit,omitempty"`
+	BandwidthLimitMode   string `json:"bandwidth_limit_mode,omitempty"`
+	UseEncryption        bool   `json:"use_encryption"`
+	UseCompression       bool   `json:"use_compression"`
+	ProxyProtocolVersion string `json:"proxy_protocol_version,omitempty"`
+}
+
+type ProxyHealthCheckOptions struct {
+	Type            string        `json:"type,omitempty"`
+	Path            string        `json:"path,omitempty"`
+	IntervalSeconds int           `json:"interval_seconds,omitempty"`
+	MaxFailed       int           `json:"max_failed,omitempty"`
+	TimeoutSeconds  int           `json:"timeout_seconds,omitempty"`
+	HTTPHeaders     []ProxyHeader `json:"http_headers,omitempty"`
+}
+
+type ProxyHeader struct {
+	Name  string `json:"name"`
+	Value string `json:"value"`
+}
+
+type ProxyLoadBalancer struct {
+	Group    string `json:"group,omitempty"`
+	GroupKey string `json:"group_key,omitempty"`
+}
+
+type ProxyPluginOptions struct {
+	Type              string `json:"type"`
+	UnixPath          string `json:"unix_path,omitempty"`
+	LocalPath         string `json:"local_path,omitempty"`
+	StripPrefix       string `json:"strip_prefix,omitempty"`
+	LocalAddr         string `json:"local_addr,omitempty"`
+	HTTPUser          string `json:"http_user,omitempty"`
+	HTTPPassword      string `json:"http_password,omitempty"`
+	Username          string `json:"username,omitempty"`
+	Password          string `json:"password,omitempty"`
+	CRTPath           string `json:"crt_path,omitempty"`
+	KeyPath           string `json:"key_path,omitempty"`
+	HostHeaderRewrite string `json:"host_header_rewrite,omitempty"`
+	DestinationIP     string `json:"destination_ip,omitempty"`
+}
+
+type ProxyNATTraversal struct {
+	DisableAssistedAddrs bool `json:"disable_assisted_addrs"`
+}
+
+type RuntimeStatus struct {
+	Status         string `json:"status"`
+	PID            int    `json:"pid,omitempty"`
+	Version        string `json:"frpc_version,omitempty"`
+	LastStartedAt  string `json:"last_started_at,omitempty"`
+	LastReloadedAt string `json:"last_reloaded_at,omitempty"`
+	LastError      string `json:"last_error,omitempty"`
+	Configured     bool   `json:"configured"`
+	EnabledProxies int    `json:"enabled_proxies"`
+}
diff --git a/internal/frp/multi_manager.go b/internal/frp/multi_manager.go
new file mode 100644
index 0000000..478585f
--- /dev/null
+++ b/internal/frp/multi_manager.go
@@ -0,0 +1,104 @@
+package frp
+
+import (
+	"context"
+	"fmt"
+	"path/filepath"
+	"strings"
+)
+
+func (s *Service) managerForServer(serverID int64) *Manager {
+	s.managersMu.Lock()
+	defer s.managersMu.Unlock()
+	if manager, ok := s.managers[serverID]; ok {
+		return manager
+	}
+	bin, err := s.manager.BinaryPath()
+	if err != nil {
+		bin = s.cfg.FrpcBin
+	}
+	manager := NewManager(
+		bin,
+		s.serverConfigPath(serverID),
+		s.serverLogPath(serverID),
+		s.logger,
+	)
+	s.managers[serverID] = manager
+	return manager
+}
+
+func (s *Service) serverConfigPath(serverID int64) string {
+	return filepath.Join(s.cfg.FrpDir(), fmt.Sprintf("frpc-server-%d.toml", serverID))
+}
+
+func (s *Service) serverLogPath(serverID int64) string {
+	return filepath.Join(s.cfg.FrpDir(), fmt.Sprintf("frpc-server-%d.log", serverID))
+}
+
+func (s *Service) managerSnapshot() map[int64]*Manager {
+	s.managersMu.RLock()
+	defer s.managersMu.RUnlock()
+	result := make(map[int64]*Manager, len(s.managers))
+	for id, manager := range s.managers {
+		result[id] = manager
+	}
+	return result
+}
+
+func (s *Service) setManagerBinary(path string) {
+	s.manager.SetBinary(path)
+	for _, manager := range s.managerSnapshot() {
+		manager.SetBinary(path)
+	}
+}
+
+func (s *Service) removeManager(serverID int64) {
+	s.managersMu.Lock()
+	delete(s.managers, serverID)
+	s.managersMu.Unlock()
+}
+
+func latestRuntimeStatus(statuses []RuntimeStatus) RuntimeStatus {
+	result := RuntimeStatus{Status: "stopped"}
+	for _, status := range statuses {
+		if status.Status == "running" {
+			result.Status = "running"
+		}
+		if status.Configured {
+			result.Configured = true
+		}
+		result.EnabledProxies += status.EnabledProxies
+		if result.PID == 0 && status.PID != 0 {
+			result.PID = status.PID
+		}
+		if status.LastStartedAt > result.LastStartedAt {
+			result.LastStartedAt = status.LastStartedAt
+		}
+		if status.LastReloadedAt > result.LastReloadedAt {
+			result.LastReloadedAt = status.LastReloadedAt
+		}
+		if result.LastError == "" && status.LastError != "" {
+			result.LastError = status.LastError
+		}
+		if status.Version != "" && result.Version == "" {
+			result.Version = status.Version
+		}
+	}
+	if len(statuses) == 0 {
+		result.Status = "unknown"
+	}
+	return result
+}
+
+func (s *Service) waitForManagers(ctx context.Context, action func(*Manager) error) error {
+	var messages []string
+	for id, manager := range s.managerSnapshot() {
+		if err := action(manager); err != nil {
+			messages = append(messages, fmt.Sprintf("服务端 %d:%v", id, err))
+		}
+	}
+	if len(messages) > 0 {
+		return fmt.Errorf("%s", strings.Join(messages, ";"))
+	}
+	return nil
+}
diff --git a/internal/frp/process_probe_other.go b/internal/frp/process_probe_other.go
new file mode 100644
index 0000000..56a9295
--- /dev/null
+++ b/internal/frp/process_probe_other.go
@@ -0,0 +1,16 @@
+//go:build !linux && !darwin && !freebsd && !windows
+
+package frp
+
+import "os"
+
+func probeProcess(pid int) (bool, string) {
+	if pid <= 0 {
+		return false, ""
+	}
+	process, err := os.FindProcess(pid)
+	if err != nil {
+		return false, ""
+	}
+	return true, process.String()
+}
diff --git a/internal/frp/process_probe_unix.go b/internal/frp/process_probe_unix.go
new file mode 100644
index 0000000..5588879
--- /dev/null
+++ b/internal/frp/process_probe_unix.go
@@ -0,0 +1,25 @@
+//go:build linux || darwin || freebsd
+
+package frp
+
+import (
+	"os"
+	"os/exec"
+	"strconv"
+	"strings"
+)
+
+func probeProcess(pid int) (bool, string) {
+	if pid <= 0 {
+		return false, ""
+	}
+	if data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/cmdline"); err == nil {
+		commandLine := strings.ReplaceAll(string(data), "\x00", " ")
+		return strings.TrimSpace(commandLine) != "", strings.TrimSpace(commandLine)
+	}
+	output, err := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "command=").Output()
+	if err != nil {
+		return false, ""
+	}
+	return strings.TrimSpace(string(output)) != "", strings.TrimSpace(string(output))
+}
diff --git a/internal/frp/process_probe_windows.go b/internal/frp/process_probe_windows.go
new file mode 100644
index 0000000..b3c6fe3
--- /dev/null
+++ b/internal/frp/process_probe_windows.go
@@ -0,0 +1,22 @@
+//go:build windows
+
+package frp
+
+import (
+	"fmt"
+	"os/exec"
+	"strconv"
+	"strings"
+)
+
+func probeProcess(pid int) (bool, string) {
+	if pid <= 0 {
+		return false, ""
+	}
+	script := fmt.Sprintf("$p=Get-CimInstance Win32_Process -Filter \"ProcessId=%s\"; if ($p) { $p.CommandLine }", strconv.Itoa(pid))
+	output, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script).Output()
+	if err != nil {
+		return false, ""
+	}
+	return strings.TrimSpace(string(output)) != "", strings.TrimSpace(string(output))
+}
diff --git a/internal/frp/runtime.go b/internal/frp/runtime.go
new file mode 100644
index 0000000..bc9ec17
--- /dev/null
+++ b/internal/frp/runtime.go
@@ -0,0 +1,539 @@
+package frp
+
+import (
+	"archive/tar"
+	"archive/zip"
+	"bytes"
+	"compress/gzip"
+	"context"
+	"crypto/sha256"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"path/filepath"
+	"runtime"
+	"sort"
+	"strings"
+	"time"
+)
+
+const (
+	githubReleasesURL = "https://api.github.com/repos/fatedier/frp/releases"
+	maxArchiveSize    = 150 << 20
+)
+
+var downloadProxyOptions = map[string]struct{}{
+	"":                          {},
+	"https://gh-proxy.org/":     {},
+	"https://v4.gh-proxy.org/":  {},
+	"https://cdn.gh-proxy.org/": {},
+}
+
+var releaseProxyOrder = []string{
+	"https://gh-proxy.org/",
+	"https://v4.gh-proxy.org/",
+	"https://cdn.gh-proxy.org/",
+}
+
+type githubRelease struct {
+	TagName     string        `json:"tag_name"`
+	Draft       bool          `json:"draft"`
+	Prerelease  bool          `json:"prerelease"`
+	PublishedAt time.Time     `json:"published_at"`
+	Assets      []githubAsset `json:"assets"`
+}
+
+type githubAsset struct {
+	ID     int64  `json:"id"`
+	Name   string `json:"name"`
+	Size   int64  `json:"size"`
+	Digest string `json:"digest"`
+}
+
+type downloadReporter func(status, message string, downloaded, total int64, failure string)
+
+func runtimePlatform() string {
+	return runtime.GOOS + "/" + runtime.GOARCH
+}
+
+func fetchOfficialReleases(ctx context.Context, selectedProxy string) ([]Release, error) {
+	var failures []string
+	for _, url := range releaseListURLs(selectedProxy) {
+		releases, err := fetchOfficialReleasesAt(ctx, url)
+		if err == nil {
+			return releases, nil
+		}
+		failures = append(failures, err.Error())
+	}
+	return nil, fmt.Errorf("获取 FRP 官方版本列表失败:%s", strings.Join(failures, ";"))
+}
+
+func fetchOfficialReleasesAt(ctx context.Context, url string) ([]Release, error) {
+	request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+	if err != nil {
+		return nil, err
+	}
+	request.Header.Set("Accept", "application/vnd.github+json")
+	client := &http.Client{Timeout: 30 * time.Second}
+	response, err := client.Do(request)
+	if err != nil {
+		return nil, err
+	}
+	defer response.Body.Close()
+	if response.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("HTTP %d", response.StatusCode)
+	}
+	var source []githubRelease
+	if err := json.NewDecoder(io.LimitReader(response.Body, 2<<20)).Decode(&source); err != nil {
+		return nil, fmt.Errorf("响应解析失败:%w", err)
+	}
+	return supportedReleases(source), nil
+}
+
+func releaseListURLs(selectedProxy string) []string {
+	urls := []string{}
+	add := func(proxy string) {
+		url := githubReleasesURL
+		if proxy != "" {
+			url = proxy + githubReleasesURL
+		}
+		for _, item := range urls {
+			if item == url {
+				return
+			}
+		}
+		urls = append(urls, url)
+	}
+	add(selectedProxy)
+	add("")
+	for _, proxy := range releaseProxyOrder {
+		add(proxy)
+	}
+	return urls
+}
+
+func supportedReleases(source []githubRelease) []Release {
+	releases := make([]Release, 0, len(source))
+	for _, item := range source {
+		if item.Draft || item.Prerelease {
+			continue
+		}
+		asset, ok := findArchiveAsset(item)
+		if !ok {
+			continue
+		}
+		releases = append(releases, Release{Version: item.TagName, PublishedAt: item.PublishedAt.UTC().Format(time.RFC3339), AssetName: asset.Name, Size: asset.Size, Checksum: strings.TrimPrefix(asset.Digest, "sha256:"), AssetID: asset.ID})
+	}
+	return releases
+}
+
+func findArchiveAsset(release githubRelease) (githubAsset, bool) {
+	version := strings.TrimPrefix(release.TagName, "v")
+	prefix := "frp_" + version + "_" + runtime.GOOS + "_" + runtime.GOARCH
+	for _, asset := range release.Assets {
+		if asset.Name == prefix+".zip" || asset.Name == prefix+".tar.gz" {
+			return asset, true
+		}
+	}
+	return githubAsset{}, false
+}
+
+func findRelease(releases []Release, version string) (Release, bool) {
+	for _, release := range releases {
+		if release.Version == version {
+			return release, true
+		}
+	}
+	return Release{}, false
+}
+
+func downloadOfficialBinary(ctx context.Context, frpDir string, release Release, proxy string, report downloadReporter) (string, error) {
+	checksum := release.Checksum
+	if checksum == "" {
+		report("queued", "正在获取官方 SHA-256 校验信息", 0, 0, "")
+		var err error
+		checksum, err = fetchChecksum(ctx, release)
+		if err != nil {
+			return "", err
+		}
+	}
+	archivePath, err := downloadArchive(ctx, frpDir, release, checksum, proxy, report)
+	if err != nil {
+		return "", err
+	}
+	defer os.Remove(archivePath)
+	report("extracting", "SHA-256 校验通过,正在解压 frpc", 0, 0, "")
+	return extractClientBinary(archivePath, frpDir, release)
+}
+
+func fetchChecksum(ctx context.Context, release Release) (string, error) {
+	version := strings.TrimPrefix(release.Version, "v")
+	name := "frp_" + version + "_sha256sums.txt"
+	url := officialAssetURL(release.Version, name)
+	data, err := fetchBytes(ctx, url, 1<<20)
+	if err != nil {
+		return "", fmt.Errorf("下载 FRP 官方校验文件失败:%w", err)
+	}
+	for _, line := range strings.Split(string(data), "\n") {
+		fields := strings.Fields(line)
+		if len(fields) >= 2 && strings.TrimPrefix(fields[1], "*") == release.AssetName {
+			return fields[0], nil
+		}
+	}
+	return "", fmt.Errorf("FRP 官方校验文件中未找到 %s", release.AssetName)
+}
+
+func downloadArchive(ctx context.Context, frpDir string, release Release, checksum, proxy string, report downloadReporter) (string, error) {
+	report("downloading", "正在下载 FRP 官方安装包", 0, release.Size, "")
+	data, err := fetchBytesWithProgress(ctx, releaseDownloadURL(release, proxy), maxArchiveSize, func(downloaded, total int64) {
+		if total <= 0 {
+			total = release.Size
+		}
+		report("downloading", "正在下载 FRP 官方安装包", downloaded, total, "")
+	})
+	if err != nil {
+		return "", fmt.Errorf("下载 FRP 官方安装包失败:%w;请检查网络连接或设置 HTTPS_PROXY", err)
+	}
+	report("verifying", "安装包下载完成,正在校验 SHA-256", int64(len(data)), int64(len(data)), "")
+	sum := fmt.Sprintf("%x", sha256.Sum256(data))
+	if !strings.EqualFold(sum, checksum) {
+		return "", fmt.Errorf("FRP 安装包 SHA-256 校验失败")
+	}
+	dir := filepath.Join(frpDir, "downloads")
+	if err := os.MkdirAll(dir, 0o700); err != nil {
+		return "", err
+	}
+	path := filepath.Join(dir, release.AssetName)
+	if err := os.WriteFile(path, data, 0o600); err != nil {
+		return "", err
+	}
+	return path, nil
+}
+
+func fetchBytes(ctx context.Context, url string, limit int64) ([]byte, error) {
+	return fetchBytesWithProgress(ctx, url, limit, nil)
+}
+
+func fetchBytesWithProgress(ctx context.Context, url string, limit int64, progress func(int64, int64)) ([]byte, error) {
+	request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+	if err != nil {
+		return nil, err
+	}
+	request.Header.Set("User-Agent", "Fonu-FRP-Downloader")
+	if strings.HasPrefix(url, "https://api.github.com/repos/fatedier/frp/releases/assets/") {
+		request.Header.Set("Accept", "application/octet-stream")
+		request.Header.Set("X-GitHub-Api-Version", "2022-11-28")
+	}
+	client := &http.Client{Timeout: 5 * time.Minute}
+	response, err := client.Do(request)
+	if err != nil {
+		return nil, err
+	}
+	defer response.Body.Close()
+	if response.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("HTTP %d", response.StatusCode)
+	}
+	return readResponseBody(response.Body, response.ContentLength, limit, progress)
+}
+
+func readResponseBody(reader io.Reader, total, limit int64, progress func(int64, int64)) ([]byte, error) {
+	var output bytes.Buffer
+	buffer := make([]byte, 64*1024)
+	var downloaded int64
+	for {
+		count, err := reader.Read(buffer)
+		if count > 0 {
+			downloaded += int64(count)
+			if downloaded > limit {
+				return nil, fmt.Errorf("下载内容超过大小限制")
+			}
+			_, _ = output.Write(buffer[:count])
+			if progress != nil {
+				progress(downloaded, total)
+			}
+		}
+		if err == io.EOF {
+			return output.Bytes(), nil
+		}
+		if err != nil {
+			return nil, err
+		}
+	}
+}
+
+func officialAssetURL(version, name string) string {
+	return "https://github.com/fatedier/frp/releases/download/" + version + "/" + name
+}
+
+func releaseDownloadURL(release Release, proxy string) string {
+	if proxy != "" {
+		return proxy + officialAssetURL(release.Version, release.AssetName)
+	}
+	if release.AssetID > 0 {
+		return fmt.Sprintf("https://api.github.com/repos/fatedier/frp/releases/assets/%d", release.AssetID)
+	}
+	return officialAssetURL(release.Version, release.AssetName)
+}
+
+func downloadProxyPath(frpDir string) string {
+	return filepath.Join(frpDir, "download-proxy.json")
+}
+
+func readDownloadProxy(frpDir string) (string, error) {
+	data, err := os.ReadFile(downloadProxyPath(frpDir))
+	if os.IsNotExist(err) {
+		return "", nil
+	}
+	if err != nil {
+		return "", err
+	}
+	var state struct {
+		Proxy string `json:"proxy"`
+	}
+	if err := json.Unmarshal(data, &state); err != nil {
+		return "", fmt.Errorf("FRP 下载代理配置无效")
+	}
+	if !validDownloadProxy(state.Proxy) {
+		return "", fmt.Errorf("FRP 下载代理配置无效")
+	}
+	return state.Proxy, nil
+}
+
+func writeDownloadProxy(frpDir, proxy string) error {
+	if !validDownloadProxy(proxy) {
+		return fmt.Errorf("不支持的 FRP 下载加速代理")
+	}
+	if err := os.MkdirAll(frpDir, 0o700); err != nil {
+		return err
+	}
+	data, err := json.Marshal(struct {
+		Proxy string `json:"proxy"`
+	}{Proxy: proxy})
+	if err != nil {
+		return err
+	}
+	return os.WriteFile(downloadProxyPath(frpDir), data, 0o600)
+}
+
+func validDownloadProxy(proxy string) bool {
+	_, ok := downloadProxyOptions[proxy]
+	return ok
+}
+
+func extractClientBinary(archivePath, frpDir string, release Release) (string, error) {
+	data, err := os.ReadFile(archivePath)
+	if err != nil {
+		return "", err
+	}
+	binary, err := extractBinary(data, release.AssetName)
+	if err != nil {
+		return "", err
+	}
+	path := filepath.Join(frpDir, "bin", release.Version, clientBinaryName())
+	if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+		return "", err
+	}
+	if err := os.WriteFile(path, binary, 0o700); err != nil {
+		return "", err
+	}
+	return path, nil
+}
+
+func extractBinary(data []byte, assetName string) ([]byte, error) {
+	if strings.HasSuffix(assetName, ".zip") {
+		return extractZipBinary(data)
+	}
+	return extractTarBinary(data)
+}
+
+func extractZipBinary(data []byte) ([]byte, error) {
+	reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
+	if err != nil {
+		return nil, fmt.Errorf("FRP ZIP 安装包无效:%w", err)
+	}
+	for _, file := range reader.File {
+		if filepath.Base(file.Name) != clientBinaryName() || file.FileInfo().IsDir() {
+			continue
+		}
+		return readZipFile(file)
+	}
+	return nil, fmt.Errorf("FRP 安装包中未找到 %s", clientBinaryName())
+}
+
+func readZipFile(file *zip.File) ([]byte, error) {
+	if file.UncompressedSize64 > maxArchiveSize {
+		return nil, fmt.Errorf("FRP 客户端文件超过大小限制")
+	}
+	reader, err := file.Open()
+	if err != nil {
+		return nil, err
+	}
+	defer reader.Close()
+	return boundedRead(reader, maxArchiveSize)
+}
+
+func extractTarBinary(data []byte) ([]byte, error) {
+	gzipReader, err := gzip.NewReader(bytes.NewReader(data))
+	if err != nil {
+		return nil, fmt.Errorf("FRP TAR.GZ 安装包无效:%w", err)
+	}
+	defer gzipReader.Close()
+	reader := tar.NewReader(gzipReader)
+	for {
+		header, err := reader.Next()
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			return nil, err
+		}
+		if header.Typeflag != tar.TypeReg || filepath.Base(header.Name) != clientBinaryName() {
+			continue
+		}
+		if header.Size > maxArchiveSize {
+			return nil, fmt.Errorf("FRP 客户端文件超过大小限制")
+		}
+		return boundedRead(reader, maxArchiveSize)
+	}
+	return nil, fmt.Errorf("FRP 安装包中未找到 %s", clientBinaryName())
+}
+
+func boundedRead(reader io.Reader, limit int64) ([]byte, error) {
+	data, err := io.ReadAll(io.LimitReader(reader, limit+1))
+	if err != nil {
+		return nil, err
+	}
+	if int64(len(data)) > limit {
+		return nil, fmt.Errorf("FRP 客户端文件超过大小限制")
+	}
+	return data, nil
+}
+
+func clientBinaryName() string {
+	if runtime.GOOS == "windows" {
+		return "frpc.exe"
+	}
+	return "frpc"
+}
+
+func listBinaries(frpDir, active string) ([]Binary, error) {
+	root := filepath.Join(frpDir, "bin")
+	entries, err := os.ReadDir(root)
+	if os.IsNotExist(err) {
+		return []Binary{}, nil
+	}
+	if err != nil {
+		return nil, err
+	}
+	binaries := make([]Binary, 0, len(entries))
+	for _, entry := range entries {
+		binary, ok := binaryFromEntry(root, entry, active)
+		if ok {
+			binaries = append(binaries, binary)
+		}
+	}
+	sort.Slice(binaries, func(i, j int) bool { return binaries[i].Version > binaries[j].Version })
+	return binaries, nil
+}
+
+func binaryFromEntry(root string, entry os.DirEntry, active string) (Binary, bool) {
+	if !entry.IsDir() || !validVersion(entry.Name()) {
+		return Binary{}, false
+	}
+	path := filepath.Join(root, entry.Name(), clientBinaryName())
+	info, err := os.Stat(path)
+	if err != nil || info.IsDir() {
+		return Binary{}, false
+	}
+	return Binary{Version: entry.Name(), Path: path, Size: info.Size(), InstalledAt: info.ModTime().UTC().Format(time.RFC3339), Active: filepath.Clean(path) == filepath.Clean(active)}, true
+}
+
+func binaryPathForVersion(frpDir, version string) (string, error) {
+	if !validVersion(version) {
+		return "", fmt.Errorf("FRP 版本格式无效")
+	}
+	path := filepath.Join(frpDir, "bin", version, clientBinaryName())
+	if !fileExists(path) {
+		return "", fmt.Errorf("FRP %s 尚未下载", version)
+	}
+	return path, nil
+}
+
+func removeBinaryVersion(frpDir, version, binaryPath string) error {
+	root, err := filepath.Abs(filepath.Join(frpDir, "bin"))
+	if err != nil {
+		return err
+	}
+	target, err := filepath.Abs(filepath.Dir(binaryPath))
+	if err != nil {
+		return err
+	}
+	relative, err := filepath.Rel(root, target)
+	if err != nil || filepath.Clean(relative) != version {
+		return fmt.Errorf("FRP 客户端目录无效")
+	}
+	return os.RemoveAll(target)
+}
+
+func validVersion(version string) bool {
+	if version == "" || len(version) > 32 || strings.ContainsAny(version, `/\\`) {
+		return false
+	}
+	for _, char := range version {
+		if (char < '0' || char > '9') && char != '.' && char != 'v' && char != '-' {
+			return false
+		}
+	}
+	return true
+}
+
+func activeBinaryPath(frpDir string) string {
+	return filepath.Join(frpDir, "bin", "active.json")
+}
+
+func readActiveBinary(frpDir string) (string, error) {
+	data, err := os.ReadFile(activeBinaryPath(frpDir))
+	if err != nil {
+		return "", err
+	}
+	var state struct {
+		Path string `json:"path"`
+	}
+	if err := json.Unmarshal(data, &state); err != nil || versionFromBinaryPath(frpDir, state.Path) == "" || !fileExists(state.Path) {
+		return "", fmt.Errorf("已启用的 FRP 客户端无效")
+	}
+	return state.Path, nil
+}
+
+func writeActiveBinary(frpDir, path string) error {
+	if err := os.MkdirAll(filepath.Dir(activeBinaryPath(frpDir)), 0o700); err != nil {
+		return err
+	}
+	data, err := json.Marshal(struct {
+		Path string `json:"path"`
+	}{Path: path})
+	if err != nil {
+		return err
+	}
+	return os.WriteFile(activeBinaryPath(frpDir), data, 0o600)
+}
+
+func versionFromBinaryPath(frpDir, path string) string {
+	root := filepath.Join(frpDir, "bin")
+	relative, err := filepath.Rel(root, path)
+	if err != nil || strings.HasPrefix(relative, "..") {
+		return ""
+	}
+	parts := strings.Split(filepath.ToSlash(relative), "/")
+	if len(parts) != 2 || !validVersion(parts[0]) {
+		return ""
+	}
+	return parts[0]
+}
+
+func fileExists(path string) bool {
+	_, err := os.Stat(path)
+	return err == nil
+}
diff --git a/internal/frp/runtime_test.go b/internal/frp/runtime_test.go
new file mode 100644
index 0000000..e5f5dcf
--- /dev/null
+++ b/internal/frp/runtime_test.go
@@ -0,0 +1,88 @@
+package frp
+
+import (
+	"archive/zip"
+	"bytes"
+	"os"
+	"path/filepath"
+	"runtime"
+	"testing"
+	"time"
+)
+
+func TestSupportedReleasesUsesOfficialDigest(t *testing.T) {
+	version := "v0.99.0"
+	extension := ".tar.gz"
+	if runtime.GOOS == "windows" {
+		extension = ".zip"
+	}
+	assetName := "frp_0.99.0_" + runtime.GOOS + "_" + runtime.GOARCH + extension
+	releases := supportedReleases([]githubRelease{{
+		TagName:     version,
+		Assets:      []githubAsset{{ID: 123, Name: assetName, Size: 42, Digest: "sha256:abc123"}},
+		PublishedAt: time.Date(2026, 9, 15, 0, 0, 0, 0, time.UTC),
+	}})
+	if len(releases) != 1 || releases[0].Checksum != "abc123" || releases[0].AssetID != 123 {
+		t.Fatalf("未读取官方资产摘要:%+v", releases)
+	}
+}
+
+func TestExtractZipBinary(t *testing.T) {
+	var buffer bytes.Buffer
+	writer := zip.NewWriter(&buffer)
+	file, err := writer.Create("frp/" + clientBinaryName())
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, err := file.Write([]byte("client")); err != nil {
+		t.Fatal(err)
+	}
+	if err := writer.Close(); err != nil {
+		t.Fatal(err)
+	}
+	data, err := extractZipBinary(buffer.Bytes())
+	if err != nil {
+		t.Fatal(err)
+	}
+	if string(data) != "client" {
+		t.Fatalf("提取内容错误:%q", data)
+	}
+}
+
+func TestValidDownloadProxy(t *testing.T) {
+	for _, proxy := range []string{"", "https://gh-proxy.org/", "https://v4.gh-proxy.org/", "https://cdn.gh-proxy.org/"} {
+		if !validDownloadProxy(proxy) {
+			t.Fatalf("合法下载代理被拒绝:%s", proxy)
+		}
+	}
+	if validDownloadProxy("https://untrusted.example/") {
+		t.Fatal("非白名单下载代理不应被接受")
+	}
+}
+
+func TestReleaseListURLsPrioritizesSelectedProxy(t *testing.T) {
+	urls := releaseListURLs("https://v4.gh-proxy.org/")
+	if urls[0] != "https://v4.gh-proxy.org/"+githubReleasesURL {
+		t.Fatalf("未优先使用已选代理:%v", urls)
+	}
+	if urls[1] != githubReleasesURL {
+		t.Fatalf("直连地址缺失:%v", urls)
+	}
+}
+
+func TestRemoveBinaryVersion(t *testing.T) {
+	root := t.TempDir()
+	path := filepath.Join(root, "bin", "v0.71.0", clientBinaryName())
+	if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(path, []byte("client"), 0o700); err != nil {
+		t.Fatal(err)
+	}
+	if err := removeBinaryVersion(root, "v0.71.0", path); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := os.Stat(filepath.Dir(path)); !os.IsNotExist(err) {
+		t.Fatalf("版本目录未删除:%v", err)
+	}
+}
diff --git a/internal/frp/service.go b/internal/frp/service.go
new file mode 100644
index 0000000..cb2558a
--- /dev/null
+++ b/internal/frp/service.go
@@ -0,0 +1,1770 @@
+package frp
+
+import (
+	"context"
+	"fmt"
+	"log/slog"
+	"net"
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/fonu/fonu/internal/certificate"
+	"github.com/fonu/fonu/internal/config"
+	"github.com/fonu/fonu/internal/secret"
+)
+
+// maskedToken 是敏感凭据的统一回显掩码:API 永不返回明文;保存时收到掩码一律视为保留旧值
+// (对齐官方网关模式的「脱敏令牌闭环」,防止掩码被当新密钥覆盖真实凭据)。
+const maskedToken = "********"
+
+type Service struct {
+	cfg       config.Config
+	store     *Store
+	secretBox *secret.Box
+	manager   *Manager
+	logger    *slog.Logger
+	certStore *certificate.Store // 用于 HTTPS 规则部署时把 Fonu 本地证书推送到公网 agent
+
+	managersMu sync.RWMutex
+	managers   map[int64]*Manager
+
+	// 穿透流量速率采样缓存:key 为规则 ID,用于在服务端计算两次采集之间的速率
+	trafficMu     sync.Mutex
+	trafficClient *frpsTrafficClient
+	trafficLast   map[int64]trafficSample
+
+	downloadMu   sync.RWMutex
+	downloadTask DownloadTask
+	locationMu   sync.Mutex
+	locations    map[string]locationCache
+
+	// 诊断短 TTL 缓存与并发上限,避免列表页轮询叠加外部探测
+	diagMu       sync.Mutex
+	diagCache    map[int64]diagnosticsCacheEntry
+	diagInFlight int
+
+	// applyMu 保证 FRP 配置文件写入与批量启动/重载的原子边界(23.5):
+	// SQLite 事务只保护关系数据,不保护 frpc-server-*.toml 与 frpc 进程。
+	applyMu sync.Mutex
+}
+
+func NewService(cfg config.Config, store *Store, secretBox *secret.Box, logger *slog.Logger, certStore *certificate.Store) *Service {
+	bin := cfg.FrpcBin
+	if active, err := readActiveBinary(cfg.FrpDir()); err == nil && active != "" {
+		bin = active
+	}
+	service := &Service{
+		cfg:       cfg,
+		store:     store,
+		secretBox: secretBox,
+		manager:   NewManager(bin, cfg.FrpConfigPath(), cfg.FrpLogPath(), logger),
+		managers:  map[int64]*Manager{},
+		logger:    logger.With("module", "FRP"),
+		certStore: certStore,
+		downloadTask: DownloadTask{
+			Status: "idle",
+		},
+		locations:     map[string]locationCache{},
+		trafficClient: newFRPSTrafficClient(),
+		trafficLast:   map[int64]trafficSample{},
+		diagCache:     map[int64]diagnosticsCacheEntry{},
+	}
+	return service
+}
+
+func (s *Service) Runtime(ctx context.Context) RuntimeInfo {
+	s.manager.RefreshVersion(ctx)
+	bin, _ := s.manager.BinaryPath()
+	info := RuntimeInfo{
+		Platform: runtimePlatform(), ConfiguredBin: s.cfg.FrpcBin, ActiveBin: bin,
+		ConfigPath: s.cfg.FrpConfigPath(),
+	}
+	servers, _ := s.store.ListServers(ctx)
+	for _, server := range servers {
+		if !server.Enabled {
+			continue
+		}
+		info.ConfigPaths = append(info.ConfigPaths, s.serverConfigPath(server.ID))
+		if fileExists(s.serverConfigPath(server.ID)) {
+			info.ConfigExists = true
+		}
+	}
+	if len(info.ConfigPaths) == 0 {
+		info.ConfigExists = fileExists(info.ConfigPath)
+	}
+	info.DetectedVersion = s.manager.Status().Version
+	info.ActiveVersion = versionFromBinaryPath(s.cfg.FrpDir(), bin)
+	info.DownloadProxy, _ = readDownloadProxy(s.cfg.FrpDir())
+	return info
+}
+
+func (s *Service) ListReleases(ctx context.Context) ([]Release, error) {
+	proxy, err := readDownloadProxy(s.cfg.FrpDir())
+	if err != nil {
+		return nil, err
+	}
+	return fetchOfficialReleases(ctx, proxy)
+}
+
+func (s *Service) ListBinaries() ([]Binary, error) {
+	active, _ := s.manager.BinaryPath()
+	return listBinaries(s.cfg.FrpDir(), active)
+}
+
+func (s *Service) ServerDetail(ctx context.Context, id int64) (ServerDetail, error) {
+	server, err := s.store.GetServer(ctx, id)
+	if err != nil {
+		return ServerDetail{}, err
+	}
+	s.enrichTLSStatus(&server)
+	s.enrichServerRuntime(ctx, &server)
+	status, err := s.store.ServerDiagnosticDetail(ctx, id)
+	if err != nil {
+		return ServerDetail{}, err
+	}
+	proxies, err := s.store.ListProxiesByServer(ctx, id)
+	if err != nil {
+		return ServerDetail{}, err
+	}
+	return ServerDetail{Server: server, Status: status, Proxies: proxies}, nil
+}
+
+func diagnoseDialError(err error) string {
+	message := err.Error()
+	if strings.Contains(strings.ToLower(message), "access permissions") {
+		return "本机网络策略阻止连接:" + message
+	}
+	return "连接失败:" + message
+}
+
+func (s *Service) DeleteBinary(version string) error {
+	path, err := binaryPathForVersion(s.cfg.FrpDir(), version)
+	if err != nil {
+		return err
+	}
+	active, _ := s.manager.BinaryPath()
+	if filepath.Clean(path) == filepath.Clean(active) {
+		return fmt.Errorf("不能删除当前正在使用的 FRP 客户端")
+	}
+	return removeBinaryVersion(s.cfg.FrpDir(), version, path)
+}
+
+func (s *Service) StartDownload(version string) (DownloadTask, error) {
+	if !validVersion(version) {
+		return DownloadTask{}, fmt.Errorf("FRP 版本格式无效")
+	}
+	s.downloadMu.Lock()
+	defer s.downloadMu.Unlock()
+	if downloadInProgress(s.downloadTask.Status) {
+		return DownloadTask{}, fmt.Errorf("FRP %s 正在下载,请等待当前任务结束", s.downloadTask.Version)
+	}
+	s.downloadTask = DownloadTask{Version: version, Status: "queued", Message: "正在准备下载", UpdatedAt: time.Now().UTC().Format(time.RFC3339)}
+	go s.runDownload(version)
+	return s.downloadTask, nil
+}
+
+func (s *Service) DownloadStatus() DownloadTask {
+	s.downloadMu.RLock()
+	defer s.downloadMu.RUnlock()
+	return s.downloadTask
+}
+
+func (s *Service) runDownload(version string) {
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
+	defer cancel()
+	binary, err := s.downloadRelease(ctx, version, s.updateDownload)
+	if err != nil {
+		s.updateDownload("failed", "下载失败", 0, 0, err.Error())
+		return
+	}
+	s.updateDownload("completed", "下载、校验和解压完成", binary.Size, binary.Size, "")
+}
+
+func (s *Service) downloadRelease(ctx context.Context, version string, report downloadReporter) (Binary, error) {
+	report("queued", "正在获取官方版本信息", 0, 0, "")
+	releases, err := s.ListReleases(ctx)
+	if err != nil {
+		return Binary{}, err
+	}
+	release, ok := findRelease(releases, version)
+	if !ok {
+		return Binary{}, fmt.Errorf("未找到适用于当前系统的官方 FRP 版本")
+	}
+	proxy, err := readDownloadProxy(s.cfg.FrpDir())
+	if err != nil {
+		return Binary{}, err
+	}
+	path, err := downloadOfficialBinary(ctx, s.cfg.FrpDir(), release, proxy, report)
+	if err != nil {
+		return Binary{}, err
+	}
+	info, err := os.Stat(path)
+	if err != nil {
+		return Binary{}, err
+	}
+	return Binary{Version: release.Version, Path: path, Size: info.Size(), InstalledAt: info.ModTime().UTC().Format(time.RFC3339)}, nil
+}
+
+func (s *Service) updateDownload(status, message string, downloaded, total int64, failure string) {
+	s.downloadMu.Lock()
+	defer s.downloadMu.Unlock()
+	s.downloadTask.Status = status
+	s.downloadTask.Message = message
+	if downloaded > 0 {
+		s.downloadTask.Downloaded = downloaded
+	}
+	if total > 0 {
+		s.downloadTask.Total = total
+	}
+	s.downloadTask.Error = failure
+	s.downloadTask.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
+}
+
+func downloadInProgress(status string) bool {
+	return status == "queued" || status == "downloading" || status == "verifying" || status == "extracting"
+}
+
+func (s *Service) SetDownloadProxy(ctx context.Context, proxy string) (RuntimeInfo, error) {
+	if err := writeDownloadProxy(s.cfg.FrpDir(), proxy); err != nil {
+		return RuntimeInfo{}, err
+	}
+	return s.Runtime(ctx), nil
+}
+
+func (s *Service) ActivateBinary(ctx context.Context, version string) (RuntimeStatus, error) {
+	path, err := binaryPathForVersion(s.cfg.FrpDir(), version)
+	if err != nil {
+		return RuntimeStatus{}, err
+	}
+	old, _ := s.manager.BinaryPath()
+	s.setManagerBinary(path)
+	if err := s.verifyActiveBinary(ctx); err != nil {
+		s.setManagerBinary(old)
+		return RuntimeStatus{}, err
+	}
+	wasRunning := map[int64]bool{}
+	for id, manager := range s.managerSnapshot() {
+		wasRunning[id] = manager.Status().Status == "running"
+		if wasRunning[id] {
+			if err := manager.Stop(ctx); err != nil {
+				s.setManagerBinary(old)
+				return RuntimeStatus{}, err
+			}
+		}
+	}
+	if err := writeActiveBinary(s.cfg.FrpDir(), path); err != nil {
+		s.setManagerBinary(old)
+		return RuntimeStatus{}, err
+	}
+	for id, running := range wasRunning {
+		if running {
+			if err := s.managerForServer(id).Start(); err != nil {
+				return RuntimeStatus{}, err
+			}
+		}
+	}
+	s.manager.RefreshVersion(ctx)
+	return s.Status(ctx), nil
+}
+
+func (s *Service) verifyActiveBinary(ctx context.Context) error {
+	servers, err := s.store.ListServers(ctx)
+	if err != nil {
+		return err
+	}
+	verified := false
+	for _, server := range servers {
+		if !server.Enabled {
+			continue
+		}
+		verified = true
+		manager := s.managerForServer(server.ID)
+		if !fileExists(s.serverConfigPath(server.ID)) {
+			if err := manager.VerifyBinary(ctx); err != nil {
+				return err
+			}
+			continue
+		}
+		if err := manager.Verify(ctx, s.serverConfigPath(server.ID)); err != nil {
+			return err
+		}
+	}
+	if !verified {
+		return s.manager.VerifyBinary(ctx)
+	}
+	return nil
+}
+
+func (s *Service) EnsureDirs() error {
+	return os.MkdirAll(s.cfg.FrpDir(), 0o700)
+}
+
+func (s *Service) ListServers(ctx context.Context) ([]Server, error) {
+	servers, err := s.store.ListServers(ctx)
+	if err != nil {
+		return nil, err
+	}
+	for index := range servers {
+		s.enrichTLSStatus(&servers[index])
+		s.enrichServerRuntime(ctx, &servers[index])
+	}
+	return servers, nil
+}
+
+func (s *Service) enrichServerRuntime(ctx context.Context, server *Server) {
+	runtime, err := s.store.ServerRuntimeStatus(ctx, server.ID)
+	if err != nil {
+		runtime = ServerRuntimeStatus{Status: "error", DesiredState: "error", ProcessState: "error", ConnectionState: "unknown", LastError: "读取启动状态失败"}
+	} else {
+		runtime = s.observeServerRuntime(ctx, server.ID, runtime)
+	}
+	if runtime.DesiredState == "" {
+		runtime.DesiredState = runtime.Status
+	}
+	server.BootStatus = runtime.DesiredState
+	server.DesiredState = runtime.DesiredState
+	server.ProcessState = runtime.ProcessState
+	server.ConnectionState = runtime.ConnectionState
+	server.StateSource = runtime.StateSource
+	server.ProcessPID = runtime.ProcessPID
+	server.ExitCode = runtime.ExitCode
+	server.LastConnectedAt = runtime.LastConnectedAt
+	server.LastDisconnectedAt = runtime.LastDisconnectedAt
+	server.LastError = runtime.LastError
+	server.LastStartedAt = runtime.LastStartedAt
+}
+
+func (s *Service) setServerRuntime(ctx context.Context, serverID int64, status, lastError string) {
+	runtime := ServerRuntimeStatus{ServerID: serverID, Status: status, DesiredState: status, LastError: lastError}
+	if status == "starting" || status == "running" {
+		runtime.LastStartedAt = time.Now().UTC().Format(time.RFC3339)
+	}
+	if err := s.store.SetServerDesiredState(ctx, runtime); err != nil {
+		s.logger.Warn("保存服务端启动状态失败", "server_id", serverID, "error", err.Error())
+	}
+}
+
+func (s *Service) observeServerRuntime(ctx context.Context, serverID int64, runtime ServerRuntimeStatus) ServerRuntimeStatus {
+	manager := s.managerForServer(serverID)
+	process := manager.Status()
+	processState := process.Status
+	if processState == "stopped" && process.LastError != "" {
+		processState = "error"
+	}
+	connectionState, connectionError := inspectConnectionLog(s.serverLogPath(serverID), processState)
+	if connectionError != "" && process.LastError == "" {
+		process.LastError = connectionError
+	}
+	if connectionState != runtime.ConnectionState {
+		now := time.Now().UTC().Format(time.RFC3339)
+		if connectionState == "connected" {
+			runtime.LastConnectedAt = now
+		}
+		if connectionState == "disconnected" || connectionState == "error" {
+			runtime.LastDisconnectedAt = now
+		}
+	}
+	runtime.ProcessState = processState
+	runtime.ConnectionState = connectionState
+	runtime.StateSource = "manager+log"
+	runtime.ProcessPID = process.PID
+	runtime.LastError = process.LastError
+	if connectionError != "" {
+		runtime.LastError = connectionError
+	}
+	if err := s.store.SetServerObservedState(ctx, runtime); err != nil {
+		s.logger.Warn("保存 FRP 实际状态失败", "server_id", serverID, "error", err.Error())
+	}
+	return runtime
+}
+
+func (s *Service) StartServer(ctx context.Context, id int64) (Server, error) {
+	server, err := s.store.GetServer(ctx, id)
+	if err != nil {
+		return Server{}, err
+	}
+	s.setServerRuntime(ctx, id, "running", "")
+	s.enrichTLSStatus(&server)
+	s.enrichServerRuntime(ctx, &server)
+	return server, nil
+}
+
+func (s *Service) StopServer(ctx context.Context, id int64) (Server, error) {
+	server, err := s.store.GetServer(ctx, id)
+	if err != nil {
+		return Server{}, err
+	}
+	s.setServerRuntime(ctx, id, "stopped", "")
+	s.enrichTLSStatus(&server)
+	s.enrichServerRuntime(ctx, &server)
+	return server, nil
+}
+
+func (s *Service) ListProxies(ctx context.Context) ([]Proxy, error) {
+	return s.store.ListProxies(ctx)
+}
+
+// ListProxiesTraffic 采集各 frps 管理接口的代理流量并按规则 ID 聚合。
+// dashboard 地址/端口存于服务端 options,密码复用 Token 列;未配置或不可达的服务端按规则记入 Errors。
+func (s *Service) ListProxiesTraffic(ctx context.Context) (ProxiesTraffic, error) {
+	servers, err := s.store.ListServers(ctx)
+	if err != nil {
+		return ProxiesTraffic{}, err
+	}
+	proxies, err := s.store.ListProxies(ctx)
+	if err != nil {
+		return ProxiesTraffic{}, err
+	}
+	result := ProxiesTraffic{Items: map[int64]ProxyTraffic{}, Errors: map[int64]string{}, SampledAt: time.Now().UTC().Format(time.RFC3339)}
+	for _, proxy := range proxies {
+		if !proxy.Enabled {
+			continue
+		}
+		result.Items[proxy.ID] = ProxyTraffic{}
+	}
+	for _, server := range servers {
+		if server.Options.DashboardAddr == "" || server.Options.DashboardPort == 0 {
+			continue
+		}
+		var password string
+		if server.DashboardHasPwd {
+			encoded, err := s.store.DashboardPassword(ctx, server.ID)
+			if err != nil {
+				continue
+			}
+			value, err := s.secretBox.Decrypt(encoded)
+			if err != nil {
+				continue
+			}
+			password = value
+		}
+		traffic := s.trafficClient.fetchServerTraffic(ctx, server, password)
+		if !traffic.OK {
+			// 旧版 frps(= 2 && current.TrafficIn >= previous.in && current.TrafficOut >= previous.out {
+			current.TrafficInRate = int64(float64(current.TrafficIn-previous.in) / elapsed)
+			current.TrafficOutRate = int64(float64(current.TrafficOut-previous.out) / elapsed)
+		}
+	}
+	s.trafficLast[proxyID] = trafficSample{at: at, in: current.TrafficIn, out: current.TrafficOut}
+	return current
+}
+
+func (s *Service) CreateServer(ctx context.Context, in ServerInput) (Server, error) {
+	in = normalizeServerInput(in)
+	if err := validateServerInput(in); err != nil {
+		return Server{}, err
+	}
+	if err := s.validateCredentials(in, Server{}); err != nil {
+		return Server{}, err
+	}
+	token, err := s.encryptOptional(in.Token)
+	if err != nil {
+		return Server{}, err
+	}
+	agentToken, err := s.encryptOptional(in.AgentToken)
+	if err != nil {
+		return Server{}, err
+	}
+	sshSecret, err := s.encryptOptional(in.SSHSecret)
+	if err != nil {
+		return Server{}, err
+	}
+	server, err := s.store.CreateServer(ctx, in, token, agentToken, sshSecret)
+	if err != nil {
+		return Server{}, err
+	}
+	if err := s.saveOIDCSecret(ctx, server.ID, in); err != nil {
+		return Server{}, err
+	}
+	if err := s.saveDashboardPassword(ctx, server.ID, in); err != nil {
+		return Server{}, err
+	}
+	if err := s.saveTLSAssets(server.ID, in); err != nil {
+		return Server{}, err
+	}
+	server, err = s.store.GetServer(ctx, server.ID)
+	if err != nil {
+		return Server{}, err
+	}
+	return server, s.persistConfig(ctx)
+}
+
+func (s *Service) UpdateServer(ctx context.Context, id int64, in ServerInput) (Server, error) {
+	in = normalizeServerInput(in)
+	if err := validateServerInput(in); err != nil {
+		return Server{}, err
+	}
+	existing, err := s.store.GetServer(ctx, id)
+	if err != nil {
+		return Server{}, err
+	}
+	// 掩码往返保护:回传掩码视为保留旧 Token,前端原样回填掩码也不会覆盖真实凭据
+	updateToken := (in.Token != "" && in.Token != maskedToken) || in.ClearToken
+	token := ""
+	if in.Token != "" && in.Token != maskedToken {
+		token, err = s.secretBox.Encrypt(in.Token)
+		if err != nil {
+			return Server{}, err
+		}
+	}
+	if err := s.validateCredentials(in, existing); err != nil {
+		return Server{}, err
+	}
+	agentTokenUpdate := (in.AgentToken != "" && in.AgentToken != maskedToken) || in.ClearAgentToken
+	agentToken := ""
+	if in.AgentToken != "" && in.AgentToken != maskedToken {
+		agentToken, err = s.secretBox.Encrypt(in.AgentToken)
+		if err != nil {
+			return Server{}, err
+		}
+	}
+	sshSecretUpdate := (in.SSHSecret != "" && in.SSHSecret != maskedToken) || in.ClearSSHSecret
+	sshSecret := ""
+	if in.SSHSecret != "" && in.SSHSecret != maskedToken {
+		sshSecret, err = s.secretBox.Encrypt(in.SSHSecret)
+		if err != nil {
+			return Server{}, err
+		}
+	}
+	server, err := s.store.UpdateServer(ctx, id, in, token, updateToken, agentToken, agentTokenUpdate, sshSecret, sshSecretUpdate)
+	if err != nil {
+		return Server{}, err
+	}
+	if err := s.saveOIDCSecret(ctx, id, in); err != nil {
+		return Server{}, err
+	}
+	if err := s.saveDashboardPassword(ctx, id, in); err != nil {
+		return Server{}, err
+	}
+	if err := s.saveTLSAssets(id, in); err != nil {
+		return Server{}, err
+	}
+	server, err = s.store.GetServer(ctx, id)
+	if err != nil {
+		return Server{}, err
+	}
+	if !server.Enabled {
+		_ = s.managerForServer(id).Stop(ctx)
+	}
+	return server, s.persistConfig(ctx)
+}
+
+func (s *Service) DeleteServer(ctx context.Context, id int64) error {
+	if err := s.managerForServer(id).Stop(ctx); err != nil {
+		return err
+	}
+	if err := s.store.DeleteServer(ctx, id); err != nil {
+		return err
+	}
+	_ = os.Remove(s.serverConfigPath(id))
+	_ = os.Remove(s.serverLogPath(id))
+	s.removeManager(id)
+	return s.persistConfig(ctx)
+}
+
+func (s *Service) CreateProxy(ctx context.Context, in ProxyInput) (Proxy, error) {
+	in = normalizeProxyInput(in)
+	if err := validateProxyInput(in); err != nil {
+		return Proxy{}, err
+	}
+	if _, err := s.store.GetServer(ctx, in.ServerID); err != nil {
+		return Proxy{}, err
+	}
+	proxy, err := s.store.CreateProxy(ctx, in)
+	if err != nil {
+		return Proxy{}, err
+	}
+	if err := s.persistConfig(ctx); err != nil {
+		return Proxy{}, err
+	}
+	// 公网 Nginx 反代改为在 Agent 页面中手动部署,保存穿透规则不再阻塞于 VPS/Nginx。
+	return proxy, nil
+}
+
+func (s *Service) UpdateProxy(ctx context.Context, id int64, in ProxyInput) (Proxy, error) {
+	in = normalizeProxyInput(in)
+	if err := validateProxyInput(in); err != nil {
+		return Proxy{}, err
+	}
+	if _, err := s.store.GetServer(ctx, in.ServerID); err != nil {
+		return Proxy{}, err
+	}
+	proxy, err := s.store.UpdateProxy(ctx, id, in)
+	if err != nil {
+		return Proxy{}, err
+	}
+	if err := s.persistConfig(ctx); err != nil {
+		return Proxy{}, err
+	}
+	// 公网 Nginx 反代改为在 Agent 页面中手动部署,保存穿透规则不再阻塞于 VPS/Nginx。
+	return proxy, nil
+}
+
+func (s *Service) sshInstaller(ctx context.Context, serverID int64) (Server, SSHInstaller, error) {
+	server, err := s.store.GetServer(ctx, serverID)
+	if err != nil {
+		return Server{}, SSHInstaller{}, err
+	}
+	if server.SSHHost == "" || server.SSHUser == "" {
+		return Server{}, SSHInstaller{}, fmt.Errorf("请先在服务端表单填写 SSH 地址与用户")
+	}
+	secret := ""
+	if server.SSHConfigured {
+		encoded, err := s.store.SSHSecret(ctx, serverID)
+		if err != nil || encoded == "" {
+			return Server{}, SSHInstaller{}, fmt.Errorf("SSH 凭据未保存")
+		}
+		secret, err = s.secretBox.Decrypt(encoded)
+		if err != nil {
+			return Server{}, SSHInstaller{}, err
+		}
+	}
+	installer := SSHInstaller{Host: server.SSHHost, Port: server.SSHPort, User: server.SSHUser}
+	if server.SSHAuth == "password" {
+		installer.Password = secret
+	} else {
+		installer.PrivateKey = secret
+	}
+	return server, installer, nil
+}
+
+func (s *Service) AgentProbe(ctx context.Context, serverID int64) (map[string]any, error) {
+	_, installer, err := s.sshInstaller(ctx, serverID)
+	if err != nil {
+		return nil, err
+	}
+	return AgentInstaller{SSH: installer}.Probe(ctx)
+}
+
+func (s *Service) AgentInstall(ctx context.Context, serverID int64) (Server, error) {
+	server, installer, err := s.sshInstaller(ctx, serverID)
+	if err != nil {
+		return Server{}, err
+	}
+	token, err := AgentInstaller{SSH: installer}.Install(ctx)
+	if err != nil {
+		return Server{}, err
+	}
+	agentToken, err := s.secretBox.Encrypt(token)
+	if err != nil {
+		return Server{}, err
+	}
+	input := ServerInput{
+		Name: server.Name, ServerAddr: server.ServerAddr, ServerPort: server.ServerPort,
+		TLS: server.TLS, TLSServerName: server.TLSServerName, Enabled: server.Enabled,
+		Options: server.Options, AgentURL: fmt.Sprintf("http://%s:%d", server.SSHHost, 7700),
+		AgentToken: token, SSHHost: server.SSHHost, SSHPort: server.SSHPort, SSHUser: server.SSHUser,
+		SSHAuth: server.SSHAuth, MgmtEnabled: true,
+	}
+	updated, err := s.store.UpdateServer(ctx, serverID, input, "", false, agentToken, true, "", false)
+	if err != nil {
+		return Server{}, err
+	}
+	return updated, nil
+}
+
+func (s *Service) AgentUninstall(ctx context.Context, serverID int64, keepData bool) (string, error) {
+	_, installer, err := s.sshInstaller(ctx, serverID)
+	if err != nil {
+		return "", err
+	}
+	return AgentInstaller{SSH: installer}.Uninstall(ctx, keepData)
+}
+
+func (s *Service) AgentSSHDiagnose(ctx context.Context, serverID int64) (string, error) {
+	_, installer, err := s.sshInstaller(ctx, serverID)
+	if err != nil {
+		return "", err
+	}
+	return AgentInstaller{SSH: installer}.Diagnose(ctx)
+}
+
+func (s *Service) AgentTest(ctx context.Context, serverID int64) error {
+	client, ok := s.agentClient(ctx, serverID)
+	if !ok {
+		return fmt.Errorf("公网 agent 未配置或 Token 未保存")
+	}
+	return client.Ping(ctx)
+}
+
+// AgentClient 导出指定服务端的 agent 客户端(供 handler 直取明细端点);不可用返回 false。
+func (s *Service) AgentClient(ctx context.Context, serverID int64) (*AgentClient, bool) {
+	return s.agentClient(ctx, serverID)
+}
+
+func (s *Service) AgentStatus(ctx context.Context, serverID int64) (map[string]any, error) {
+	client, ok := s.agentClient(ctx, serverID)
+	if !ok {
+		return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
+	}
+	return client.Status(ctx)
+}
+
+func (s *Service) InstallFRPSOnAgent(ctx context.Context, serverID int64, version, proxy string) (map[string]any, error) {
+	client, ok := s.agentClient(ctx, serverID)
+	if !ok {
+		return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
+	}
+	result, err := client.InstallFRPS(ctx, version, proxy)
+	if err != nil {
+		return nil, err
+	}
+	// 安装后把同源 frps.toml 一起下发,保证 systemd 启动即指向正确配置
+	if server, err := s.store.GetServer(ctx, serverID); err == nil {
+		if content, err := s.buildFRPSServerConfig(ctx, server); err == nil {
+			_ = client.PushFRPSConfig(ctx, content)
+		}
+	}
+	return result, nil
+}
+
+func (s *Service) AgentFRPSAction(ctx context.Context, serverID int64, action string) error {
+	client, ok := s.agentClient(ctx, serverID)
+	if !ok {
+		return fmt.Errorf("公网 agent 未配置或 Token 未保存")
+	}
+	return client.FRPSAction(ctx, action)
+}
+
+func (s *Service) PushFRPSConfigToAgent(ctx context.Context, serverID int64) error {
+	client, ok := s.agentClient(ctx, serverID)
+	if !ok {
+		return fmt.Errorf("公网 agent 未配置或 Token 未保存")
+	}
+	server, err := s.store.GetServer(ctx, serverID)
+	if err != nil {
+		return err
+	}
+	// 下发的是 frps 服务端配置(vhost 端口/auth/webServer),不是 frpc 客户端配置
+	content, err := s.buildFRPSServerConfig(ctx, server)
+	if err != nil {
+		return err
+	}
+	return client.PushFRPSConfig(ctx, content)
+}
+
+// agentUpstreamFor 返回该规则反代时的回源地址(frps vhost 端口),端口来自服务端表单自定义,
+// 未配置时用默认 8080/8443,与 frps.toml 生成器同源。
+func (s *Service) agentUpstreamFor(ctx context.Context, proxy Proxy) (string, error) {
+	server, err := s.store.GetServer(ctx, proxy.ServerID)
+	if err != nil {
+		return "", err
+	}
+	httpPort := server.Options.VhostHTTPPort
+	if httpPort <= 0 {
+		httpPort = 8080
+	}
+	httpsPort := server.Options.VhostHTTPSPort
+	if httpsPort <= 0 {
+		httpsPort = 8443
+	}
+	if proxy.Type == "https" {
+		return fmt.Sprintf("127.0.0.1:%d", httpsPort), nil
+	}
+	return fmt.Sprintf("127.0.0.1:%d", httpPort), nil
+}
+
+// InstallAgentNginx 触发 VPS 上一键安装 Nginx(需 agent 以 root 运行)。
+func (s *Service) InstallAgentNginx(ctx context.Context, serverID int64) (map[string]any, error) {
+	client, ok := s.agentClient(ctx, serverID)
+	if !ok {
+		return nil, fmt.Errorf("该服务端未配置公网 agent(缺少地址或令牌)")
+	}
+	return client.InstallNginx(ctx)
+}
+
+// RouteHealthCandidate 公网反代规则的四段健康状态(DNS/证书/隧道/内网服务)。
+type RouteHealthCandidate struct {
+	ProxyID      int64  `json:"proxy_id"`
+	Domain       string `json:"domain"`
+	DNSOK        bool   `json:"dns_ok"`
+	DNSDetail    string `json:"dns_detail,omitempty"`
+	CertOK       bool   `json:"cert_ok"`
+	CertDetail   string `json:"cert_detail,omitempty"`
+	TunnelOK     bool   `json:"tunnel_ok"`
+	TunnelName   string `json:"tunnel_name,omitempty"`
+	TunnelDetail string `json:"tunnel_detail,omitempty"`
+	ServiceOK    bool   `json:"service_ok"`
+}
+
+// RouteHealth 批量返回公网反代规则的四段健康状态:
+// DNS 解析是否指向服务端地址、本地证书覆盖情况、frps 隧道注册状态、内网服务探活。
+func (s *Service) RouteHealth(ctx context.Context, serverID int64) ([]RouteHealthCandidate, error) {
+	candidates, err := s.ListAgentRouteCandidates(ctx, serverID)
+	if err != nil {
+		return nil, err
+	}
+	server, err := s.store.GetServer(ctx, serverID)
+	if err != nil {
+		return nil, err
+	}
+	// 隧道注册名集合:frps dashboard(webServer)可用时取其代理名列表;
+	// 调用细节(URL/认证/具体错误)记录到 dashboardDiag,失败时透出给前端。
+	tunnelNames := map[string]bool{}
+	// 由 dashboard 返回的真实代理名(例如 admin.ssssss),按域名映射用于展示。
+	tunnelNameByDomain := map[string]string{}
+	dashboardReady := false
+	dashboardDiag := ""
+	if server.Options.DashboardAddr == "" || server.Options.DashboardPort == 0 {
+		dashboardDiag = "frps 管理接口未配置(服务端表单缺少地址或端口)"
+	} else {
+		encoded, perr := s.store.DashboardPassword(ctx, serverID)
+		if perr != nil {
+			dashboardDiag = fmt.Sprintf("读取 dashboard 密码失败: %v", perr)
+		} else if encoded == "" {
+			dashboardDiag = "dashboard 密码未保存(服务端表单留空),frps 已开启认证时无法访问管理接口"
+		} else {
+			password, derr := s.secretBox.Decrypt(encoded)
+			if derr != nil {
+				dashboardDiag = fmt.Sprintf("dashboard 密码解密失败: %v", derr)
+			} else {
+				traffic := s.trafficClient.fetchServerTraffic(ctx, server, password)
+				if traffic.OK {
+					dashboardReady = true
+					for name := range traffic.Proxies {
+						tunnelNames[name] = true
+					}
+					for domain, name := range traffic.ProxyNamesByDomain {
+						tunnelNameByDomain[domain] = name
+					}
+				} else {
+					// frp 0.68.0 没有全量代理列表,只支持 /api/proxies/{实际代理名};
+					// 用本地候选规则逐条查询,兼容 user.proxyName(例如 admin.ssssss)。
+					names := make([]string, 0, len(candidates))
+					for _, candidate := range candidates {
+						names = append(names, candidate.Name)
+					}
+					legacyTraffic := s.trafficClient.fetchLegacyRouteProxies(ctx, server, password, names)
+					if legacyTraffic.OK {
+						dashboardReady = true
+						for name := range legacyTraffic.Proxies {
+							if !strings.HasPrefix(name, "domain:") {
+								tunnelNames[name] = true
+							}
+						}
+						for domain, name := range legacyTraffic.ProxyNamesByDomain {
+							tunnelNameByDomain[domain] = name
+						}
+					} else {
+						dashboardDiag = fmt.Sprintf("frps 管理接口调用失败: %s(旧版逐规则查询失败: %s;Basic Auth 用户 %q)", traffic.Error, legacyTraffic.Error, server.Options.DashboardUser)
+					}
+				}
+			}
+		}
+	}
+	// 本地证书覆盖:域名精确或通配符匹配
+	certRecords, cerr := s.certStore.List(ctx)
+	if cerr != nil {
+		certRecords = nil
+	}
+	certFor := func(domain string) (*certificate.Record, bool) {
+		for i := range certRecords {
+			rec := &certRecords[i]
+			if rec.Domain == domain {
+				return rec, true
+			}
+			for _, d := range rec.Domains {
+				if d == domain || (strings.HasPrefix(d, "*") && strings.HasSuffix(domain, strings.TrimPrefix(d, "*"))) {
+					return rec, true
+				}
+			}
+		}
+		return nil, false
+	}
+	resolver := &net.Resolver{}
+	out := make([]RouteHealthCandidate, 0, len(candidates))
+	for _, proxy := range candidates {
+		for _, domain := range proxy.CustomDomains {
+			item := RouteHealthCandidate{ProxyID: proxy.ID, Domain: domain}
+			// ① DNS:解析域名,与 server_addr(IP 或域名解析后)比对
+			ips, derr := resolver.LookupIPAddr(ctx, domain)
+			if derr != nil || len(ips) == 0 {
+				item.DNSDetail = "DNS 解析失败"
+			} else {
+				serverIPs, serr := resolver.LookupIPAddr(ctx, server.ServerAddr)
+				if serr != nil || len(serverIPs) == 0 {
+					item.DNSDetail = "服务端地址无法解析"
+				} else {
+					matched := false
+					for _, a := range ips {
+						for _, b := range serverIPs {
+							if a.IP.Equal(b.IP) {
+								matched = true
+							}
+						}
+					}
+					item.DNSOK = matched
+					if !matched {
+						item.DNSDetail = "未指向服务端地址"
+					}
+				}
+			}
+			// ② 证书:本地库覆盖 + 有效期
+			if rec, ok := certFor(domain); ok {
+				if rec.ExpiresAt != nil {
+					days := int(time.Until(*rec.ExpiresAt).Hours() / 24)
+					item.CertOK = days > 0
+					item.CertDetail = fmt.Sprintf("%d 天后到期", days)
+				} else {
+					item.CertOK = true
+					item.CertDetail = "有效期未知"
+				}
+			} else {
+				item.CertDetail = "本地无证书"
+			}
+			// ③ 隧道:frps dashboard 的代理名与规则名匹配。frpc 配置了 user 时,
+			// frp 会自动给代理名加 "." 前缀(如 admin.ssssss),两种形态都接受。
+			item.TunnelName = proxy.Name
+			if dashboardReady {
+				// 优先按域名索引匹配(V2 API 返回 spec.http.customDomains,代理名含 user 前缀不可靠),
+				// 再回退代理名(裸名或 user.前缀名)
+				switch {
+				case tunnelNames["domain:"+domain]:
+					item.TunnelOK = true
+					item.TunnelName = tunnelNameByDomain[strings.ToLower(domain)]
+					if item.TunnelName == "" {
+						item.TunnelName = proxy.Name
+					}
+				case tunnelNames[proxy.Name]:
+					item.TunnelOK = true
+					item.TunnelName = proxy.Name
+				case func() bool {
+					u := strings.TrimSpace(server.Options.User)
+					return u != "" && tunnelNames[u+"."+proxy.Name]
+				}():
+					item.TunnelOK = true
+					item.TunnelName = strings.TrimSpace(server.Options.User) + "." + proxy.Name
+				}
+				if item.TunnelOK {
+					item.TunnelDetail = "frps 已注册: " + item.TunnelName
+				} else {
+					// 诊断透出:dashboard 实际返回的代理名列表(供前端悬停排查)
+					names := make([]string, 0, len(tunnelNames))
+					for name := range tunnelNames {
+						if !strings.HasPrefix(name, "domain:") {
+							names = append(names, name)
+						}
+					}
+					sort.Strings(names)
+					item.TunnelDetail = fmt.Sprintf("frps 已注册代理: %s;但均未绑定域名 %s", strings.Join(names, ", "), domain)
+				}
+			} else {
+				item.TunnelDetail = dashboardDiag
+			}
+			// ④ 内网服务探活:Fonu 与服务同在内网,直接 TCP 拨测
+			conn, serr2 := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", proxy.LocalIP, proxy.LocalPort), 2*time.Second)
+			if serr2 == nil {
+				_ = conn.Close()
+				item.ServiceOK = true
+			}
+			out = append(out, item)
+		}
+	}
+	return out, nil
+}
+
+// GetProxy 导出单条穿透规则读取(供 handler 校验规则归属与域名)。
+func (s *Service) GetProxy(ctx context.Context, id int64) (Proxy, error) {
+	return s.store.GetProxy(ctx, id)
+}
+
+// ListAgentRouteCandidates 返回指定服务端可手动部署到公网 Nginx 的 HTTP/HTTPS 规则。
+func (s *Service) ListAgentRouteCandidates(ctx context.Context, serverID int64) ([]Proxy, error) {
+	proxies, err := s.store.ListProxiesByServer(ctx, serverID)
+	if err != nil {
+		return nil, err
+	}
+	candidates := make([]Proxy, 0, len(proxies))
+	for _, proxy := range proxies {
+		if proxy.Enabled && (proxy.Type == "http" || proxy.Type == "https") && len(proxy.CustomDomains) > 0 {
+			candidates = append(candidates, proxy)
+		}
+	}
+	return candidates, nil
+}
+
+// DeployAgentRoute 手动把单条穿透规则部署为公网 Nginx 反代。
+func (s *Service) DeployAgentRoute(ctx context.Context, proxyID int64) error {
+	proxy, err := s.store.GetProxy(ctx, proxyID)
+	if err != nil {
+		return err
+	}
+	if !proxy.Enabled || (proxy.Type != "http" && proxy.Type != "https") || len(proxy.CustomDomains) == 0 {
+		return fmt.Errorf("只有启用的 HTTP/HTTPS 规则可以部署公网反代")
+	}
+	client, ok := s.agentClient(ctx, proxy.ServerID)
+	if !ok {
+		return fmt.Errorf("公网 agent 未配置或 Token 未保存")
+	}
+	if proxy.Type == "https" {
+		// https 类型隧道是端到端透传:frps 不解密,要求内网服务自己持有证书并使用 TLS。
+		// 内网明文 HTTP 服务应改用 http 类型,公网 HTTPS 由 Nginx 挂证书终结(证书自动推送)。
+		return fmt.Errorf("https 类型隧道要求内网服务自己提供 TLS(192.168.11.30:8001 这类明文服务无法走通);" +
+			"请把规则类型改为 http(公网 HTTPS 由 Nginx 证书终结),或给内网服务配置证书")
+	}
+	upstream, uerr := s.agentUpstreamFor(ctx, proxy)
+	if uerr != nil {
+		return uerr
+	}
+	// vhost 端口供 agent 回源自检用
+	server, serr := s.store.GetServer(ctx, proxy.ServerID)
+	if serr != nil {
+		return serr
+	}
+	vhostPort := server.Options.VhostHTTPPort
+	if vhostPort <= 0 {
+		vhostPort = 8080
+	}
+	// 仅中国大陆 IP:先把本地已维护的中国 IP 段下发到 agent(geo 变量依赖它)
+	if proxy.Options.ChinaOnly {
+		data, err := os.ReadFile(s.cfg.ChinaCIDRPath())
+		if err != nil || strings.TrimSpace(string(data)) == "" {
+			return fmt.Errorf("启用「仅中国大陆 IP」前需先在「反向代理」页更新中国 IP 段(本地 china_cidr.conf 不存在或为空)")
+		}
+		if err := client.SyncChinaCIDR(ctx, string(data)); err != nil {
+			return fmt.Errorf("下发中国 IP 段失败: %w", err)
+		}
+	}
+	for _, domain := range proxy.CustomDomains {
+		// 证书用于公网 Nginx 的 TLS 终结(443 监听),与 frp 隧道类型解耦:
+		// 本地明文 HTTP 服务应选 http 类型规则,浏览器侧 HTTPS 由 Nginx 挂证书承担。
+		// https 类型隧道要求内网服务自己做 TLS,否则即使端口对上也无法工作。
+		tlsEdge := false
+		if certErr := s.pushAgentCertificate(ctx, client, domain); certErr == nil {
+			tlsEdge = true
+		} else if proxy.Type == "https" {
+			return fmt.Errorf("推送证书 %s 失败: %w", domain, certErr)
+		}
+		result, err := client.SyncRouteSpec(ctx, RouteSpec{
+			Domain: domain, Upstream: upstream, TLS: tlsEdge, CertDir: "",
+			WebSocket: proxy.Options.WebSocket, VhostPort: vhostPort,
+			RedirectHTTPS: proxy.Options.RedirectHTTPS,
+			AllowIPs:      proxy.Options.AllowIPs, DenyIPs: proxy.Options.DenyIPs,
+			BasicAuthUser:     proxy.Options.BasicAuthUser,
+			BasicAuthPassword: proxy.Options.BasicAuthPassword,
+			HTTPSDisabled:     proxy.Options.HTTPSDisabled,
+			ClientMaxBodySize: proxy.Options.ClientMaxBodySize,
+			ProxyReadTimeout:  proxy.Options.ProxyReadTimeout,
+			SecurityHeaders:   proxy.Options.SecurityHeaders,
+			TLS13Only:         proxy.Options.TLS13Only,
+			RateLimitRate:     proxy.Options.RateLimitRate,
+			RateLimitBurst:    proxy.Options.RateLimitBurst,
+			ConnLimitMax:      proxy.Options.ConnLimitMax,
+			ChinaOnly:         proxy.Options.ChinaOnly,
+		})
+		if err != nil {
+			return fmt.Errorf("部署域名 %s 失败: %w", domain, err)
+		}
+		// 回源自检分级提示,不掩盖灰区:
+		if code, ok := result["probe_status"].(float64); ok {
+			switch {
+			case code == 0:
+				return fmt.Errorf("反代已部署,但回源自检失败(连接不上 frps vhost :%d):检查 frps 是否运行、vhostHTTPPort 是否与表单一致", vhostPort)
+			case code == 404:
+				return fmt.Errorf("反代已部署,frps 正常但没有找到域名 %s 的隧道注册:请确认内网穿透里存在绑定该域名的 http 规则、类型为 http 且已启用,并已应用配置", domain)
+			case code >= 500:
+				return fmt.Errorf("反代已部署,隧道已连通但内网服务异常(HTTP %.0f):请检查内网服务 %s 是否存活", code, upstream)
+			}
+		}
+	}
+	return nil
+}
+
+// pushAgentCertificate 从 Fonu 本地证书存储读取域名对应证书并推送到公网 agent;
+// 支持精确域名与通配符(*.example.com)匹配。
+func (s *Service) pushAgentCertificate(ctx context.Context, client *AgentClient, domain string) error {
+	if s.certStore == nil {
+		return fmt.Errorf("证书存储不可用")
+	}
+	records, err := s.certStore.List(ctx)
+	if err != nil {
+		return err
+	}
+	for _, record := range records {
+		matched := record.Domain == domain
+		if !matched {
+			for _, d := range record.Domains {
+				if d == domain || (strings.HasPrefix(d, "*") && strings.HasSuffix(domain, strings.TrimPrefix(d, "*"))) {
+					matched = true
+					break
+				}
+			}
+		}
+		if !matched || record.CertPath == "" || record.KeyPath == "" {
+			continue
+		}
+		certPEM, err := os.ReadFile(record.CertPath)
+		if err != nil {
+			return fmt.Errorf("读取证书文件失败: %w", err)
+		}
+		keyPEM, err := os.ReadFile(record.KeyPath)
+		if err != nil {
+			return fmt.Errorf("读取私钥文件失败: %w", err)
+		}
+		return client.PushCertificate(ctx, domain, string(certPEM), string(keyPEM))
+	}
+	return fmt.Errorf("Fonu 本地没有域名 %s 的证书;请先在「证书」页申请,或改用 HTTP 类型规则", domain)
+}
+
+// RemoveAgentRoute 手动移除单条穿透规则对应的公网 Nginx 反代。
+func (s *Service) RemoveAgentRoute(ctx context.Context, proxyID int64) error {
+	proxy, err := s.store.GetProxy(ctx, proxyID)
+	if err != nil {
+		return err
+	}
+	client, ok := s.agentClient(ctx, proxy.ServerID)
+	if !ok {
+		return fmt.Errorf("公网 agent 未配置或 Token 未保存")
+	}
+	for _, domain := range proxy.CustomDomains {
+		if err := client.DeleteRoute(ctx, domain); err != nil {
+			return fmt.Errorf("移除域名 %s 失败: %w", domain, err)
+		}
+	}
+	return nil
+}
+
+func (s *Service) DeleteProxy(ctx context.Context, id int64) error {
+	if _, err := s.store.GetProxy(ctx, id); err != nil {
+		return err
+	}
+	if err := s.store.DeleteProxy(ctx, id); err != nil {
+		return err
+	}
+	if err := s.persistConfig(ctx); err != nil {
+		return err
+	}
+	// 公网 Nginx 反代由 Agent 页面手动管理,删除穿透规则不自动访问 VPS。
+	return nil
+}
+
+func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient, bool) {
+	server, err := s.store.GetServer(ctx, serverID)
+	if err != nil || !server.MgmtEnabled || server.AgentURL == "" || !server.AgentConfigured {
+		return nil, false
+	}
+	encoded, err := s.store.AgentToken(ctx, serverID)
+	if err != nil || encoded == "" {
+		return nil, false
+	}
+	token, err := s.secretBox.Decrypt(encoded)
+	if err != nil || token == "" {
+		return nil, false
+	}
+	return NewAgentClient(server.AgentURL, token), true
+}
+
+func (s *Service) syncAgentRoute(ctx context.Context, proxy Proxy) {
+	if !proxy.Enabled || (proxy.Type != "http" && proxy.Type != "https") || len(proxy.CustomDomains) == 0 {
+		return
+	}
+	client, ok := s.agentClient(ctx, proxy.ServerID)
+	if !ok {
+		return
+	}
+	upstream, uerr := s.agentUpstreamFor(ctx, proxy)
+	if uerr != nil {
+		s.logger.Warn("agent route sync skipped", "proxy_id", proxy.ID, "error", uerr)
+		return
+	}
+	for _, domain := range proxy.CustomDomains {
+		if err := client.SyncRoute(ctx, domain, upstream, proxy.Type == "https", ""); err != nil {
+			s.logger.Warn("agent route sync failed", "proxy_id", proxy.ID, "domain", domain, "error", err)
+		}
+	}
+}
+
+func (s *Service) deleteAgentRoute(ctx context.Context, proxy Proxy) {
+	client, ok := s.agentClient(ctx, proxy.ServerID)
+	if !ok {
+		return
+	}
+	for _, domain := range proxy.CustomDomains {
+		if err := client.DeleteRoute(ctx, domain); err != nil {
+			s.logger.Warn("agent route delete failed", "proxy_id", proxy.ID, "domain", domain, "error", err)
+		}
+	}
+}
+
+func (s *Service) Start(ctx context.Context) error {
+	s.applyMu.Lock()
+	defer s.applyMu.Unlock()
+	if err := s.persistConfigsLocked(ctx); err != nil {
+		return err
+	}
+	s.manager.RefreshVersion(ctx)
+	servers, err := s.store.ListEnabledServers(ctx)
+	if err != nil {
+		return err
+	}
+	if len(servers) == 0 {
+		return fmt.Errorf("未启用 FRP 服务端")
+	}
+	// 23.5:按服务端隔离——某个服务端校验/启动失败不停止其他服务端,
+	// 已启动的保持运行,失败原因聚合成一条错误返回。
+	var failures []string
+	for _, server := range servers {
+		manager := s.managerForServer(server.ID)
+		if err := manager.Verify(ctx, s.serverConfigPath(server.ID)); err != nil {
+			s.recordProcessState(ctx, server.ID, manager, err.Error())
+			failures = append(failures, fmt.Sprintf("服务端 %s 校验失败:%v", server.Name, err))
+			continue
+		}
+		if err := manager.Start(); err != nil {
+			s.recordProcessState(ctx, server.ID, manager, err.Error())
+			failures = append(failures, fmt.Sprintf("服务端 %s 启动失败:%v", server.Name, err))
+			continue
+		}
+		s.recordProcessState(ctx, server.ID, manager, "")
+	}
+	if len(failures) > 0 {
+		return fmt.Errorf("%s", strings.Join(failures, ";"))
+	}
+	return nil
+}
+
+func (s *Service) AutoStart(ctx context.Context) error {
+	servers, err := s.store.ListEnabledServers(ctx)
+	if err != nil {
+		return err
+	}
+	shouldStart := false
+	for _, server := range servers {
+		if server.Options.AutoStart {
+			shouldStart = true
+			break
+		}
+	}
+	if !shouldStart {
+		return nil
+	}
+	return s.Start(ctx)
+}
+
+func (s *Service) Stop(ctx context.Context) error {
+	_ = s.ReconcileRuntime(ctx)
+	servers, err := s.store.ListServers(ctx)
+	if err != nil {
+		return err
+	}
+	var messages []string
+	for _, server := range servers {
+		manager := s.managerForServer(server.ID)
+		if err := manager.Stop(ctx); err != nil {
+			messages = append(messages, fmt.Sprintf("服务端 %d:%v", server.ID, err))
+			continue
+		}
+		s.recordProcessState(ctx, server.ID, manager, "")
+	}
+	if len(messages) > 0 {
+		return fmt.Errorf("%s", strings.Join(messages, ";"))
+	}
+	return nil
+}
+
+func (s *Service) Reload(ctx context.Context) error {
+	s.applyMu.Lock()
+	defer s.applyMu.Unlock()
+	if err := s.persistConfigsLocked(ctx); err != nil {
+		return err
+	}
+	servers, err := s.store.ListEnabledServers(ctx)
+	if err != nil {
+		return err
+	}
+	// 23.5:按服务端隔离重载,失败不中断其他服务端,聚合报错。
+	var failures []string
+	for _, server := range servers {
+		manager := s.managerForServer(server.ID)
+		if err := manager.Verify(ctx, s.serverConfigPath(server.ID)); err != nil {
+			s.recordProcessState(ctx, server.ID, manager, err.Error())
+			failures = append(failures, fmt.Sprintf("服务端 %s 校验失败:%v", server.Name, err))
+			continue
+		}
+		if err := manager.Restart(ctx); err != nil {
+			s.recordProcessState(ctx, server.ID, manager, err.Error())
+			failures = append(failures, fmt.Sprintf("服务端 %s 重启失败:%v", server.Name, err))
+			continue
+		}
+		s.recordProcessState(ctx, server.ID, manager, "")
+	}
+	if len(failures) > 0 {
+		return fmt.Errorf("%s", strings.Join(failures, ";"))
+	}
+	return nil
+}
+
+func (s *Service) recordProcessState(ctx context.Context, serverID int64, manager *Manager, lastError string) {
+	status := manager.Status()
+	processState := status.Status
+	if lastError != "" {
+		processState = "error"
+	}
+	runtime := ServerRuntimeStatus{
+		ServerID: serverID, ProcessState: processState, ConnectionState: "unknown",
+		StateSource: "manager", ProcessPID: status.PID, LastStartedAt: status.LastStartedAt, LastError: lastError,
+	}
+	if err := s.store.SetServerObservedState(ctx, runtime); err != nil {
+		s.logger.Warn("保存 FRP 进程状态失败", "server_id", serverID, "error", err.Error())
+	}
+}
+
+func (s *Service) ReconcileRuntime(ctx context.Context) error {
+	servers, err := s.store.ListServers(ctx)
+	if err != nil {
+		return err
+	}
+	for _, server := range servers {
+		runtime, runtimeErr := s.store.ServerRuntimeStatus(ctx, server.ID)
+		if runtimeErr != nil || runtime.ProcessPID <= 0 {
+			continue
+		}
+		manager := s.managerForServer(server.ID)
+		if manager.Attach(runtime.ProcessPID) {
+			runtime.ProcessState = "running"
+			runtime.StateSource = "reconciled"
+			runtime.LastError = ""
+			if err := s.store.SetServerObservedState(ctx, runtime); err != nil {
+				return err
+			}
+			continue
+		}
+		if processAlive(runtime.ProcessPID) {
+			runtime.ProcessState = "unknown"
+			runtime.StateSource = "reconcile"
+			runtime.LastError = "检测到运行中的进程,但无法确认其属于当前 FRP 配置"
+		} else {
+			runtime.ProcessState = "stopped"
+			runtime.ProcessPID = 0
+			runtime.StateSource = "reconcile"
+			runtime.LastError = ""
+		}
+		if err := s.store.SetServerObservedState(ctx, runtime); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (s *Service) Test(ctx context.Context, id int64) error {
+	server, err := s.store.GetServer(ctx, id)
+	if err != nil {
+		return err
+	}
+	if err := s.persistServerConfigForVerify(ctx, server); err != nil {
+		return err
+	}
+	return s.managerForServer(id).Verify(ctx, s.serverConfigPath(id))
+}
+
+// persistServerConfigForVerify 为「测试」路径服务:渲染到临时文件并 verify,通过后才
+// 原子替换正式配置;不通过时删除临时文件、正式配置保持原样(23.5 的「临时配置加 verify,
+// 只有验证成功才替换正式配置」要求)。
+func (s *Service) persistServerConfigForVerify(ctx context.Context, server Server) error {
+	content, err := s.renderServerConfig(ctx, server)
+	if err != nil {
+		return err
+	}
+	officialPath := s.serverConfigPath(server.ID)
+	if err := os.WriteFile(officialPath+".verify", []byte(content), 0o600); err != nil {
+		return err
+	}
+	if err := s.managerForServer(server.ID).Verify(ctx, officialPath+".verify"); err != nil {
+		_ = os.Remove(officialPath + ".verify")
+		return err
+	}
+	if current, readErr := os.ReadFile(officialPath); readErr == nil {
+		_ = os.WriteFile(officialPath+".bak", current, 0o600)
+	}
+	return os.Rename(officialPath+".verify", officialPath)
+}
+
+func (s *Service) renderServerConfig(ctx context.Context, server Server) (string, error) {
+	proxies, err := s.store.ListEnabledProxies(ctx, server.ID)
+	if err != nil {
+		return "", err
+	}
+	token, oidcSecret, err := s.loadAuthSecrets(ctx, server)
+	if err != nil {
+		return "", err
+	}
+	return renderTOML(renderedConfig{
+		Server: server, Token: token, OIDCClientSecret: oidcSecret, LogPath: s.serverLogPath(server.ID),
+		TLSCertPath: s.existingTLSAssetPath(server.ID, "cert.pem"), TLSKeyPath: s.existingTLSAssetPath(server.ID, "key.pem"),
+		TLSTrustedCAPath: s.existingTLSAssetPath(server.ID, "ca.pem"), Proxies: proxies,
+	}), nil
+}
+
+func (s *Service) Status(ctx context.Context) RuntimeStatus {
+	versionCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
+	defer cancel()
+	s.manager.RefreshVersion(versionCtx)
+	servers, err := s.store.ListEnabledServers(ctx)
+	if err != nil {
+		return s.manager.Status()
+	}
+	statuses := make([]RuntimeStatus, 0, len(servers))
+	for _, server := range servers {
+		current := s.managerForServer(server.ID).Status()
+		current.Configured = s.serverConfigured(server)
+		if proxies, proxyErr := s.store.ListEnabledProxies(ctx, server.ID); proxyErr == nil {
+			current.EnabledProxies = len(proxies)
+		}
+		statuses = append(statuses, current)
+	}
+	return latestRuntimeStatus(statuses)
+}
+
+func (s *Service) ReadLogs(limit int, serverID int64) ([]string, error) {
+	if limit <= 0 || limit > 500 {
+		limit = 200
+	}
+	servers, err := s.store.ListServers(context.Background())
+	if err != nil {
+		return nil, err
+	}
+	lines := []string{}
+	for _, server := range servers {
+		// 指定 server_id 时只读该服务端的日志文件;0 表示全部服务端
+		if serverID > 0 && server.ID != serverID {
+			continue
+		}
+		data, readErr := os.ReadFile(s.serverLogPath(server.ID))
+		if os.IsNotExist(readErr) {
+			continue
+		}
+		if readErr != nil {
+			return nil, readErr
+		}
+		for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
+			if strings.TrimSpace(line) != "" {
+				lines = append(lines, fmt.Sprintf("[%s] %s", server.Name, line))
+			}
+		}
+	}
+	if len(lines) > limit {
+		lines = lines[len(lines)-limit:]
+	}
+	return lines, nil
+}
+
+// ReadProxyLogs 返回单条穿透规则的日志:读所属服务端的 frpc 日志并按规则名过滤。
+func (s *Service) ReadProxyLogs(ctx context.Context, proxyID int64, limit int) ([]string, error) {
+	if limit <= 0 || limit > 500 {
+		limit = 200
+	}
+	proxy, err := s.store.GetProxy(ctx, proxyID)
+	if err != nil {
+		return nil, err
+	}
+	data, readErr := os.ReadFile(s.serverLogPath(proxy.ServerID))
+	if os.IsNotExist(readErr) {
+		return []string{}, nil
+	}
+	if readErr != nil {
+		return nil, readErr
+	}
+	lines := []string{}
+	for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
+		if strings.TrimSpace(line) != "" && strings.Contains(line, proxy.Name) {
+			lines = append(lines, line)
+		}
+	}
+	if len(lines) > limit {
+		lines = lines[len(lines)-limit:]
+	}
+	return lines, nil
+}
+
+func (s *Service) ExportMaskedConfig(ctx context.Context) (string, error) {
+	servers, err := s.store.ListEnabledServers(ctx)
+	if err != nil {
+		return "", err
+	}
+	if len(servers) == 0 {
+		return "", fmt.Errorf("未启用 FRP 服务端")
+	}
+	var out strings.Builder
+	for _, server := range servers {
+		proxies, proxyErr := s.store.ListEnabledProxies(ctx, server.ID)
+		if proxyErr != nil {
+			return "", proxyErr
+		}
+		fmt.Fprintf(&out, "# 服务端:%s (%s:%d)\n", server.Name, server.ServerAddr, server.ServerPort)
+		out.WriteString(renderMaskedTOML(server, proxies))
+		out.WriteString("\n")
+	}
+	return out.String(), nil
+}
+
+func (s *Service) validateCredentials(in ServerInput, existing Server) error {
+	if !in.Enabled {
+		return nil
+	}
+	switch in.Options.AuthMethod {
+	case "none":
+		return nil
+	case "token":
+		if in.ClearToken || ((in.Token == "" || in.Token == maskedToken) && !existing.HasToken) {
+			return fmt.Errorf("Token 认证需要填写 FRP Token")
+		}
+	case "oidc":
+		if in.ClearOIDCClientSecret || ((in.OIDCClientSecret == "" || in.OIDCClientSecret == maskedToken) && !existing.HasOIDCSecret) {
+			return fmt.Errorf("OIDC 认证需要填写客户端密钥")
+		}
+	}
+	return nil
+}
+
+func (s *Service) encryptOptional(value string) (string, error) {
+	if value == "" {
+		return "", nil
+	}
+	return s.secretBox.Encrypt(value)
+}
+
+func (s *Service) saveOIDCSecret(ctx context.Context, serverID int64, in ServerInput) error {
+	if in.ClearOIDCClientSecret {
+		return s.store.SetOIDCSecret(ctx, serverID, "")
+	}
+	if in.OIDCClientSecret == "" || in.OIDCClientSecret == maskedToken {
+		return nil
+	}
+	value, err := s.secretBox.Encrypt(in.OIDCClientSecret)
+	if err != nil {
+		return err
+	}
+	return s.store.SetOIDCSecret(ctx, serverID, value)
+}
+
+// saveDashboardPassword 保存 frps 管理接口密码(与 frpc Token 无关,仅用于 Fonu 拉取流量信息)。
+func (s *Service) saveDashboardPassword(ctx context.Context, serverID int64, in ServerInput) error {
+	if in.ClearDashboardPassword {
+		return s.store.SetDashboardPassword(ctx, serverID, "")
+	}
+	if in.DashboardPassword == "" || in.DashboardPassword == maskedToken {
+		return nil
+	}
+	encoded, err := s.secretBox.Encrypt(in.DashboardPassword)
+	if err != nil {
+		return err
+	}
+	return s.store.SetDashboardPassword(ctx, serverID, encoded)
+}
+
+func (s *Service) loadAuthSecrets(ctx context.Context, server Server) (string, string, error) {
+	if server.Options.AuthMethod == "none" {
+		return "", "", nil
+	}
+	if server.Options.AuthMethod == "token" {
+		encoded, err := s.store.Token(ctx, server.ID)
+		if err != nil {
+			return "", "", err
+		}
+		value, err := s.secretBox.Decrypt(encoded)
+		if err != nil {
+			return "", "", fmt.Errorf("FRP Token 无法解密,请重新保存服务端配置")
+		}
+		return value, "", nil
+	}
+	encoded, err := s.store.OIDCSecret(ctx, server.ID)
+	if err != nil {
+		return "", "", err
+	}
+	value, err := s.secretBox.Decrypt(encoded)
+	if err != nil {
+		return "", "", fmt.Errorf("OIDC 客户端密钥无法解密,请重新保存服务端配置")
+	}
+	return "", value, nil
+}
+
+func (s *Service) serverConfigured(server Server) bool {
+	switch server.Options.AuthMethod {
+	case "none":
+		return true
+	case "oidc":
+		return server.HasOIDCSecret
+	default:
+		return server.HasToken
+	}
+}
+
+func (s *Service) enrichTLSStatus(server *Server) {
+	server.Options.TLSCertificateConfigured = s.existingTLSAssetPath(server.ID, "cert.pem") != ""
+	server.Options.TLSKeyConfigured = s.existingTLSAssetPath(server.ID, "key.pem") != ""
+	server.Options.TLSTrustedCAConfigured = s.existingTLSAssetPath(server.ID, "ca.pem") != ""
+}
+
+func (s *Service) tlsAssetPath(serverID int64, name string) string {
+	return filepath.Join(s.cfg.FrpDir(), "certs", fmt.Sprintf("server-%d-%s", serverID, name))
+}
+
+func (s *Service) existingTLSAssetPath(serverID int64, name string) string {
+	path := s.tlsAssetPath(serverID, name)
+	if _, err := os.Stat(path); err == nil {
+		return path
+	}
+	return ""
+}
+
+func (s *Service) saveTLSAssets(serverID int64, in ServerInput) error {
+	assets := []struct {
+		name, content string
+		clear         bool
+	}{
+		{"cert.pem", in.TLSCertificate, in.ClearTLSCertificate}, {"key.pem", in.TLSKey, in.ClearTLSKey}, {"ca.pem", in.TLSTrustedCA, in.ClearTLSTrustedCA},
+	}
+	for _, asset := range assets {
+		path := s.tlsAssetPath(serverID, asset.name)
+		if asset.clear {
+			if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+				return err
+			}
+			continue
+		}
+		if asset.content == "" {
+			continue
+		}
+		if !strings.Contains(asset.content, "-----BEGIN") {
+			return fmt.Errorf("TLS %s 内容不是有效 PEM", asset.name)
+		}
+		if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+			return err
+		}
+		if err := os.WriteFile(path, []byte(strings.TrimSpace(asset.content)+"\n"), 0o600); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (s *Service) persistConfig(ctx context.Context) error {
+	s.applyMu.Lock()
+	defer s.applyMu.Unlock()
+	return s.persistConfigsLocked(ctx)
+}
+
+// persistConfigsLocked 写全部服务端配置文件。调用方必须已持有 s.applyMu。
+// 单个服务端写入失败立即返回,其余服务端保持上一版配置,不会出现部分新部分旧混写。
+func (s *Service) persistConfigsLocked(ctx context.Context) error {
+	servers, err := s.store.ListServers(ctx)
+	if err != nil {
+		return err
+	}
+	for _, server := range servers {
+		path := s.serverConfigPath(server.ID)
+		if !server.Enabled {
+			if removeErr := os.Remove(path); removeErr != nil && !os.IsNotExist(removeErr) {
+				return removeErr
+			}
+			continue
+		}
+		if err := s.persistServerConfig(ctx, server); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (s *Service) persistServerConfig(ctx context.Context, server Server) error {
+	content, err := s.renderServerConfig(ctx, server)
+	if err != nil {
+		return err
+	}
+	return writeConfigAtomically(s.serverConfigPath(server.ID), content)
+}
+
+func writeConfigAtomically(path, content string) error {
+	dir := filepath.Dir(path)
+	if err := os.MkdirAll(dir, 0o700); err != nil {
+		return err
+	}
+	// 23.5:写入前把当前版本留存为 .bak。临时文件写入本身已原子(CreateTemp+Rename),
+	// 但 frpc verify 失败时上层需要回退目标,这里提供版本化备份。
+	if current, err := os.ReadFile(path); err == nil {
+		if err := os.WriteFile(path+".bak", current, 0o600); err != nil {
+			return err
+		}
+	}
+	tmp, err := os.CreateTemp(dir, "frpc-*.toml")
+	if err != nil {
+		return err
+	}
+	tmpPath := tmp.Name()
+	defer os.Remove(tmpPath)
+	if _, err := tmp.WriteString(content); err != nil {
+		tmp.Close()
+		return err
+	}
+	if err := tmp.Chmod(0o600); err != nil {
+		tmp.Close()
+		return err
+	}
+	if err := tmp.Close(); err != nil {
+		return err
+	}
+	return os.Rename(tmpPath, path)
+}
diff --git a/internal/frp/service_diagnostics_test.go b/internal/frp/service_diagnostics_test.go
new file mode 100644
index 0000000..fe27d8d
--- /dev/null
+++ b/internal/frp/service_diagnostics_test.go
@@ -0,0 +1,64 @@
+package frp
+
+import (
+	"context"
+	"errors"
+	"strings"
+	"testing"
+)
+
+func TestDiagnoseDialError(t *testing.T) {
+	err := errors.New("connectex: An attempt was made to access a socket in a way forbidden by its access permissions.")
+	message := diagnoseDialError(err)
+	if !strings.HasPrefix(message, "本机网络策略阻止连接:") {
+		t.Fatalf("unexpected message: %s", message)
+	}
+}
+
+// 23.3:诊断拆分为独立检查项,登录状态从 frpc 日志推断,unknown 不等于失败。
+func TestSummarizeFRPCLogin(t *testing.T) {
+	logLines := strings.Join([]string{
+		"2026/09/17 10:00:00 [I] [root.go:139] frpc service start",
+		"2026/09/17 10:00:01 [W] [control.go:167] login to the server failed: token error",
+		"2026/09/17 10:01:00 [I] [control.go:164] login to server success, get run id [abc]",
+	}, "\n")
+	state, detail := summarizeFRPCLogin(logLines)
+	if state != diagStateOK {
+		t.Fatalf("login success should win over earlier failure, got %s (%s)", state, detail)
+	}
+	failedLog := "2026/09/17 10:00:01 [W] [control.go:167] login to the server failed: token [wrong] error: authentication failed"
+	state, detail = summarizeFRPCLogin(failedLog)
+	if state != diagStateFailed || !strings.Contains(detail, "登录失败") {
+		t.Fatalf("login failure should be reported, got %s (%s)", state, detail)
+	}
+	state, _ = summarizeFRPCLogin("2026/09/17 10:00:00 [I] [root.go:139] frpc service start")
+	if state != diagStateUnknown {
+		t.Fatalf("no login evidence should be unknown, got %s", state)
+	}
+}
+
+func TestDiagnoseDNSFormatsRecords(t *testing.T) {
+	check := diagnoseDNS(context.Background(), "192.168.1.10")
+	if check.State != diagStateOK || !strings.Contains(check.Detail, "IP 地址") {
+		t.Fatalf("literal IP should skip resolution: %+v", check)
+	}
+}
+
+func TestSummarizeServerSamplesUsesLatestStatus(t *testing.T) {
+	detail := summarizeServerSamples([]ServerSample{
+		{CheckedAt: "2026-09-15 18:00:00", Available: true, LatencyMS: 20},
+		{CheckedAt: "2026-09-15 18:01:00", Available: false, Error: "连接失败"},
+	})
+	if detail.Available {
+		t.Fatal("latest failed sample should make current status unavailable")
+	}
+	if detail.Availability != 50 {
+		t.Fatalf("unexpected availability: %v", detail.Availability)
+	}
+	if detail.AverageLatency != 20 || detail.PeakLatency != 20 {
+		t.Fatalf("unexpected latency summary: average=%d peak=%d", detail.AverageLatency, detail.PeakLatency)
+	}
+	if detail.LastError != "连接失败" {
+		t.Fatalf("unexpected last error: %s", detail.LastError)
+	}
+}
diff --git a/internal/frp/ssh_installer.go b/internal/frp/ssh_installer.go
new file mode 100644
index 0000000..574fac8
--- /dev/null
+++ b/internal/frp/ssh_installer.go
@@ -0,0 +1,126 @@
+package frp
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"golang.org/x/crypto/ssh"
+)
+
+// SSHInstaller 通过固定白名单命令在 VPS 上安装/升级 fonu-agent。
+// agent 的日常管理仍走 AgentClient;SSH 只承担生命周期和失联自救。
+type SSHInstaller struct {
+	Host       string
+	Port       int
+	User       string
+	Password   string
+	PrivateKey string
+	Timeout    time.Duration
+}
+
+func (i SSHInstaller) client(ctx context.Context) (*ssh.Client, error) {
+	if i.Port == 0 {
+		i.Port = 22
+	}
+	var auth ssh.AuthMethod
+	if i.PrivateKey != "" {
+		signer, err := ssh.ParsePrivateKey([]byte(i.PrivateKey))
+		if err != nil {
+			return nil, fmt.Errorf("解析 SSH 私钥失败: %w", err)
+		}
+		auth = ssh.PublicKeys(signer)
+	} else if i.Password != "" {
+		auth = ssh.Password(i.Password)
+	} else {
+		return nil, fmt.Errorf("SSH 密码或私钥不能为空")
+	}
+	config := &ssh.ClientConfig{User: i.User, Auth: []ssh.AuthMethod{auth}, HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: i.Timeout}
+	if config.Timeout == 0 {
+		config.Timeout = 15 * time.Second
+	}
+	return ssh.Dial("tcp", fmt.Sprintf("%s:%d", i.Host, i.Port), config)
+}
+
+func (i SSHInstaller) Run(ctx context.Context, command string) (string, error) {
+	client, err := i.client(ctx)
+	if err != nil {
+		return "", err
+	}
+	defer client.Close()
+	session, err := client.NewSession()
+	if err != nil {
+		return "", err
+	}
+	defer session.Close()
+	var output strings.Builder
+	session.Stdout = &output
+	session.Stderr = &output
+	if err := session.Run(command); err != nil {
+		return output.String(), fmt.Errorf("远程命令失败: %w: %s", err, strings.TrimSpace(output.String()))
+	}
+	return output.String(), nil
+}
+
+// Install 只接受本地已构建的 agent 二进制与安装脚本,不接受用户任意命令。
+func (i SSHInstaller) Install(ctx context.Context, binary []byte, script []byte) (string, error) {
+	client, err := i.client(ctx)
+	if err != nil {
+		return "", err
+	}
+	defer client.Close()
+	if err := uploadBytes(client, "/tmp/fonu-agent", binary, 0o755); err != nil {
+		return "", err
+	}
+	if err := uploadBytes(client, "/tmp/fonu-agent-install.sh", script, 0o700); err != nil {
+		return "", err
+	}
+	return runSSHSession(client, "sudo /tmp/fonu-agent-install.sh /tmp/fonu-agent && test -x /usr/local/bin/fonu-agent && /usr/local/bin/fonu-agent -version && rm -f /tmp/fonu-agent /tmp/fonu-agent-install.sh")
+}
+
+func uploadBytes(client *ssh.Client, path string, data []byte, mode uint32) error {
+	session, err := client.NewSession()
+	if err != nil {
+		return err
+	}
+	defer session.Close()
+	stdin, err := session.StdinPipe()
+	if err != nil {
+		return err
+	}
+	command := fmt.Sprintf("umask 077; cat > %s; chmod %o %s", shellPath(path), mode, shellPath(path))
+	if err := session.Start(command); err != nil {
+		return err
+	}
+	if _, err := stdin.Write(data); err != nil {
+		return err
+	}
+	_ = stdin.Close()
+	return session.Wait()
+}
+
+func runSSHSession(client *ssh.Client, command string) (string, error) {
+	session, err := client.NewSession()
+	if err != nil {
+		return "", err
+	}
+	defer session.Close()
+	var output strings.Builder
+	session.Stdout = &output
+	session.Stderr = &output
+	if err := session.Run(command); err != nil {
+		return output.String(), fmt.Errorf("远程 agent 安装失败: %w: %s", err, strings.TrimSpace(output.String()))
+	}
+	return output.String(), nil
+}
+
+func shellPath(path string) string {
+	return "'" + strings.ReplaceAll(path, "'", "'\\''") + "'"
+}
+
+var _ io.Reader = (*os.File)(nil)
+var _ = filepath.Separator
diff --git a/internal/frp/store.go b/internal/frp/store.go
new file mode 100644
index 0000000..2edf2a2
--- /dev/null
+++ b/internal/frp/store.go
@@ -0,0 +1,496 @@
+package frp
+
+import (
+	"context"
+	"database/sql"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"strings"
+	"time"
+)
+
+type Store struct {
+	db *sql.DB
+}
+
+func NewStore(db *sql.DB) *Store {
+	return &Store{db: db}
+}
+
+func (s *Store) ListServers(ctx context.Context) ([]Server, error) {
+	rows, err := s.db.QueryContext(ctx, serverSelect+` ORDER BY id ASC`)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	servers := []Server{}
+	for rows.Next() {
+		server, err := scanServer(rows)
+		if err != nil {
+			return nil, err
+		}
+		servers = append(servers, server)
+	}
+	return servers, rows.Err()
+}
+
+func (s *Store) GetServer(ctx context.Context, id int64) (Server, error) {
+	server, err := scanServer(s.db.QueryRowContext(ctx, serverSelect+` WHERE id = ?`, id))
+	if errors.Is(err, sql.ErrNoRows) {
+		return Server{}, fmt.Errorf("FRP 服务端不存在")
+	}
+	return server, err
+}
+
+func (s *Store) GetEnabledServer(ctx context.Context) (Server, error) {
+	server, err := scanServer(s.db.QueryRowContext(ctx, serverSelect+` WHERE enabled = 1 ORDER BY id LIMIT 1`))
+	if errors.Is(err, sql.ErrNoRows) {
+		return Server{}, fmt.Errorf("未启用 FRP 服务端")
+	}
+	return server, err
+}
+
+func (s *Store) ListEnabledServers(ctx context.Context) ([]Server, error) {
+	rows, err := s.db.QueryContext(ctx, serverSelect+` WHERE enabled = 1 ORDER BY id ASC`)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	servers := []Server{}
+	for rows.Next() {
+		server, err := scanServer(rows)
+		if err != nil {
+			return nil, err
+		}
+		servers = append(servers, server)
+	}
+	return servers, rows.Err()
+}
+
+func (s *Store) CreateServer(ctx context.Context, in ServerInput, tokenEnc, agentTokenEnc, sshSecretEnc string) (Server, error) {
+	opts, _ := json.Marshal(in.Options)
+	result, err := s.db.ExecContext(ctx, `
+		INSERT INTO frp_servers(name, server_addr, server_port, auth_token_enc, tls_enabled, tls_server_name, enabled, config_json, agent_url, agent_token_enc, ssh_host, ssh_port, ssh_user, ssh_auth, ssh_secret_enc, mgmt_enabled, updated_at)
+		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
+	`, in.Name, in.ServerAddr, in.ServerPort, tokenEnc, boolInt(in.TLS), in.TLSServerName, boolInt(in.Enabled), string(opts), in.AgentURL, agentTokenEnc, in.SSHHost, in.SSHPort, in.SSHUser, in.SSHAuth, sshSecretEnc, boolInt(in.MgmtEnabled))
+	if err != nil {
+		return Server{}, mapUniqueError(err, "FRP 服务端名称已存在")
+	}
+	id, _ := result.LastInsertId()
+	return s.GetServer(ctx, id)
+}
+
+func (s *Store) UpdateServer(ctx context.Context, id int64, in ServerInput, tokenEnc string, updateToken bool, agentTokenEnc string, updateAgentToken bool, sshSecretEnc string, updateSSHSecret bool) (Server, error) {
+	options, _ := json.Marshal(in.Options)
+	query := `UPDATE frp_servers SET name = ?, server_addr = ?, server_port = ?, tls_enabled = ?, tls_server_name = ?, enabled = ?, config_json = ?, agent_url = ?, ssh_host = ?, ssh_port = ?, ssh_user = ?, ssh_auth = ?, mgmt_enabled = ?, updated_at = datetime('now') WHERE id = ?`
+	args := []any{in.Name, in.ServerAddr, in.ServerPort, boolInt(in.TLS), in.TLSServerName, boolInt(in.Enabled), string(options), in.AgentURL, in.SSHHost, in.SSHPort, in.SSHUser, in.SSHAuth, boolInt(in.MgmtEnabled), id}
+	if updateToken || updateAgentToken || updateSSHSecret {
+		query = `UPDATE frp_servers SET name = ?, server_addr = ?, server_port = ?, auth_token_enc = CASE WHEN ? THEN ? ELSE auth_token_enc END, agent_url = ?, agent_token_enc = CASE WHEN ? THEN ? ELSE agent_token_enc END, ssh_host = ?, ssh_port = ?, ssh_user = ?, ssh_auth = ?, ssh_secret_enc = CASE WHEN ? THEN ? ELSE ssh_secret_enc END, tls_enabled = ?, tls_server_name = ?, enabled = ?, config_json = ?, mgmt_enabled = ?, updated_at = datetime('now') WHERE id = ?`
+		args = []any{in.Name, in.ServerAddr, in.ServerPort, updateToken, tokenEnc, in.AgentURL, updateAgentToken, agentTokenEnc, in.SSHHost, in.SSHPort, in.SSHUser, in.SSHAuth, updateSSHSecret, sshSecretEnc, boolInt(in.TLS), in.TLSServerName, boolInt(in.Enabled), string(options), boolInt(in.MgmtEnabled), id}
+	}
+	result, err := s.db.ExecContext(ctx, query, args...)
+	if err != nil {
+		return Server{}, mapUniqueError(err, "FRP 服务端名称已存在")
+	}
+	count, _ := result.RowsAffected()
+	if count == 0 {
+		return Server{}, fmt.Errorf("FRP 服务端不存在")
+	}
+	return s.GetServer(ctx, id)
+}
+
+func (s *Store) DeleteServer(ctx context.Context, id int64) error {
+	result, err := s.db.ExecContext(ctx, `DELETE FROM frp_servers WHERE id = ?`, id)
+	if err != nil {
+		return err
+	}
+	count, _ := result.RowsAffected()
+	if count == 0 {
+		return fmt.Errorf("FRP 服务端不存在")
+	}
+	return nil
+}
+
+func (s *Store) ServerRuntimeStatus(ctx context.Context, serverID int64) (ServerRuntimeStatus, error) {
+	status := ServerRuntimeStatus{
+		ServerID: serverID, Status: "stopped", DesiredState: "stopped",
+		ProcessState: "stopped", ConnectionState: "unknown", StateSource: "legacy",
+	}
+	var pid, exitCode sql.NullInt64
+	err := s.db.QueryRowContext(ctx, `
+		SELECT status, COALESCE(desired_state, status), COALESCE(process_state, 'stopped'),
+		       COALESCE(connection_state, 'unknown'), COALESCE(state_source, 'legacy'),
+		       process_pid, exit_code, COALESCE(last_error, ''), COALESCE(last_started_at, ''),
+		       COALESCE(last_connected_at, ''), COALESCE(last_disconnected_at, '')
+		FROM frp_runtime_status WHERE server_id = ?
+	`, serverID).Scan(
+		&status.Status, &status.DesiredState, &status.ProcessState, &status.ConnectionState,
+		&status.StateSource, &pid, &exitCode, &status.LastError, &status.LastStartedAt,
+		&status.LastConnectedAt, &status.LastDisconnectedAt,
+	)
+	if errors.Is(err, sql.ErrNoRows) {
+		return status, nil
+	}
+	if pid.Valid {
+		status.ProcessPID = int(pid.Int64)
+	}
+	if exitCode.Valid {
+		status.ExitCode = int(exitCode.Int64)
+	}
+	return status, err
+}
+
+func (s *Store) SetServerDesiredState(ctx context.Context, status ServerRuntimeStatus) error {
+	_, err := s.db.ExecContext(ctx, `
+		INSERT INTO frp_runtime_status(server_id, status, desired_state, state_source, last_started_at, last_error, updated_at)
+		VALUES (?, ?, ?, 'manual', NULLIF(?, ''), ?, datetime('now'))
+		ON CONFLICT(server_id) DO UPDATE SET
+			status = excluded.status,
+			desired_state = excluded.desired_state,
+			state_source = 'manual',
+			last_started_at = COALESCE(excluded.last_started_at, frp_runtime_status.last_started_at),
+			last_error = excluded.last_error,
+			updated_at = datetime('now')
+	`, status.ServerID, status.Status, status.DesiredState, status.LastStartedAt, status.LastError)
+	return err
+}
+
+func (s *Store) SetServerObservedState(ctx context.Context, status ServerRuntimeStatus) error {
+	desiredState := "stopped"
+	if status.ProcessState == "running" {
+		desiredState = "running"
+	}
+	_, err := s.db.ExecContext(ctx, `
+		INSERT INTO frp_runtime_status(
+			server_id, status, desired_state, process_state, connection_state, state_source,
+			process_pid, exit_code, last_error, last_started_at, last_connected_at, last_disconnected_at, updated_at
+		)
+		VALUES (?, ?, ?, ?, ?, ?, NULLIF(?, 0), NULLIF(?, 0), ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), datetime('now'))
+		ON CONFLICT(server_id) DO UPDATE SET
+			process_state = excluded.process_state,
+			connection_state = excluded.connection_state,
+			state_source = excluded.state_source,
+			process_pid = excluded.process_pid,
+			exit_code = excluded.exit_code,
+			last_error = excluded.last_error,
+			last_started_at = COALESCE(NULLIF(excluded.last_started_at, ''), frp_runtime_status.last_started_at),
+			last_connected_at = COALESCE(NULLIF(excluded.last_connected_at, ''), frp_runtime_status.last_connected_at),
+			last_disconnected_at = COALESCE(NULLIF(excluded.last_disconnected_at, ''), frp_runtime_status.last_disconnected_at),
+			updated_at = datetime('now')
+	`, status.ServerID, desiredState, desiredState, status.ProcessState, status.ConnectionState, status.StateSource,
+		status.ProcessPID, status.ExitCode, status.LastError, status.LastStartedAt,
+		status.LastConnectedAt, status.LastDisconnectedAt)
+	return err
+}
+
+func (s *Store) AddServerDiagnostic(ctx context.Context, diagnostic ServerDiagnostics) error {
+	if _, err := s.db.ExecContext(ctx, `
+		INSERT INTO frp_server_diagnostics(server_id, checked_at, available, latency_ms, error)
+		VALUES (?, datetime('now'), ?, NULLIF(?, 0), ?)
+	`, diagnostic.ServerID, boolInt(diagnostic.Available), diagnostic.LatencyMS, diagnostic.Error); err != nil {
+		return err
+	}
+	// 只保留每个服务端最近 60 条采样,30 分钟曲线窗口远用不到这么多
+	_, err := s.db.ExecContext(ctx, `
+		DELETE FROM frp_server_diagnostics
+		WHERE server_id = ? AND id NOT IN (
+			SELECT id FROM frp_server_diagnostics WHERE server_id = ? ORDER BY id DESC LIMIT 60
+		)
+	`, diagnostic.ServerID, diagnostic.ServerID)
+	return err
+}
+
+func (s *Store) ServerDiagnosticDetail(ctx context.Context, serverID int64) (ServerStatusDetail, error) {
+	rows, err := s.db.QueryContext(ctx, `
+		SELECT checked_at, available, COALESCE(latency_ms, 0), COALESCE(error, '')
+		FROM frp_server_diagnostics
+		WHERE server_id = ? AND checked_at >= datetime('now', '-30 minutes')
+		ORDER BY checked_at ASC, id ASC
+	`, serverID)
+	if err != nil {
+		return ServerStatusDetail{}, err
+	}
+	defer rows.Close()
+	detail := ServerStatusDetail{Samples: []ServerSample{}}
+	for rows.Next() {
+		var sample ServerSample
+		var available int
+		if err := rows.Scan(&sample.CheckedAt, &available, &sample.LatencyMS, &sample.Error); err != nil {
+			return ServerStatusDetail{}, err
+		}
+		sample.Available = available == 1
+		detail.Samples = append(detail.Samples, sample)
+	}
+	if err := rows.Err(); err != nil {
+		return ServerStatusDetail{}, err
+	}
+	return summarizeServerSamples(detail.Samples), nil
+}
+
+func summarizeServerSamples(samples []ServerSample) ServerStatusDetail {
+	detail := ServerStatusDetail{Samples: samples}
+	if len(samples) == 0 {
+		return detail
+	}
+	detail.Available = samples[len(samples)-1].Available
+	detail.LastCheckedAt = samples[len(samples)-1].CheckedAt
+	detail.LastError = samples[len(samples)-1].Error
+	var total int64
+	var available int
+	for _, sample := range samples {
+		if !sample.Available {
+			continue
+		}
+		available++
+		total += sample.LatencyMS
+		if sample.LatencyMS > detail.PeakLatency {
+			detail.PeakLatency = sample.LatencyMS
+		}
+	}
+	detail.Availability = float64(available) / float64(len(samples)) * 100
+	if available > 0 {
+		detail.AverageLatency = total / int64(available)
+	}
+	return detail
+}
+
+func (s *Store) CountOtherEnabledServers(ctx context.Context, id int64) (int, error) {
+	var count int
+	err := s.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM frp_servers WHERE enabled = 1 AND id != ?`, id).Scan(&count)
+	return count, err
+}
+
+func (s *Store) SSHSecret(ctx context.Context, id int64) (string, error) {
+	var secret string
+	err := s.db.QueryRowContext(ctx, `SELECT COALESCE(ssh_secret_enc, '') FROM frp_servers WHERE id = ?`, id).Scan(&secret)
+	if errors.Is(err, sql.ErrNoRows) {
+		return "", fmt.Errorf("FRP 服务端不存在")
+	}
+	return secret, err
+}
+
+func (s *Store) AgentToken(ctx context.Context, id int64) (string, error) {
+	var token string
+	err := s.db.QueryRowContext(ctx, `SELECT COALESCE(agent_token_enc, '') FROM frp_servers WHERE id = ?`, id).Scan(&token)
+	if errors.Is(err, sql.ErrNoRows) {
+		return "", fmt.Errorf("FRP 服务端不存在")
+	}
+	return token, err
+}
+
+func (s *Store) Token(ctx context.Context, id int64) (string, error) {
+	var token string
+	err := s.db.QueryRowContext(ctx, `SELECT COALESCE(auth_token_enc, '') FROM frp_servers WHERE id = ?`, id).Scan(&token)
+	if errors.Is(err, sql.ErrNoRows) {
+		return "", fmt.Errorf("FRP 服务端不存在")
+	}
+	return token, err
+}
+
+func (s *Store) OIDCSecret(ctx context.Context, id int64) (string, error) {
+	var secret string
+	err := s.db.QueryRowContext(ctx, `SELECT COALESCE(oidc_client_secret_enc, '') FROM frp_servers WHERE id = ?`, id).Scan(&secret)
+	if errors.Is(err, sql.ErrNoRows) {
+		return "", fmt.Errorf("FRP 服务端不存在")
+	}
+	return secret, err
+}
+
+func (s *Store) SetOIDCSecret(ctx context.Context, id int64, value string) error {
+	result, err := s.db.ExecContext(ctx, `UPDATE frp_servers SET oidc_client_secret_enc = ?, updated_at = datetime('now') WHERE id = ?`, value, id)
+	if err != nil {
+		return err
+	}
+	count, _ := result.RowsAffected()
+	if count == 0 {
+		return fmt.Errorf("FRP 服务端不存在")
+	}
+	return nil
+}
+
+func (s *Store) DashboardPassword(ctx context.Context, id int64) (string, error) {
+	var value string
+	err := s.db.QueryRowContext(ctx, `SELECT COALESCE(dashboard_password_enc, '') FROM frp_servers WHERE id = ?`, id).Scan(&value)
+	if errors.Is(err, sql.ErrNoRows) {
+		return "", fmt.Errorf("FRP 服务端不存在")
+	}
+	return value, err
+}
+
+func (s *Store) SetDashboardPassword(ctx context.Context, id int64, value string) error {
+	result, err := s.db.ExecContext(ctx, `UPDATE frp_servers SET dashboard_password_enc = ?, updated_at = datetime('now') WHERE id = ?`, value, id)
+	if err != nil {
+		return err
+	}
+	count, _ := result.RowsAffected()
+	if count == 0 {
+		return fmt.Errorf("FRP 服务端不存在")
+	}
+	return nil
+}
+
+func (s *Store) ListProxies(ctx context.Context) ([]Proxy, error) {
+	rows, err := s.db.QueryContext(ctx, proxySelect+` ORDER BY server_id ASC, id ASC`)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	return scanProxies(rows)
+}
+
+func (s *Store) ListProxiesByServer(ctx context.Context, serverID int64) ([]Proxy, error) {
+	rows, err := s.db.QueryContext(ctx, proxySelect+` WHERE server_id = ? ORDER BY id ASC`, serverID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	return scanProxies(rows)
+}
+
+func (s *Store) ListEnabledProxies(ctx context.Context, serverID int64) ([]Proxy, error) {
+	rows, err := s.db.QueryContext(ctx, proxySelect+` WHERE server_id = ? AND enabled = 1 ORDER BY id ASC`, serverID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	return scanProxies(rows)
+}
+
+func (s *Store) GetProxy(ctx context.Context, id int64) (Proxy, error) {
+	proxy, err := scanProxy(s.db.QueryRowContext(ctx, proxySelect+` WHERE id = ?`, id))
+	if errors.Is(err, sql.ErrNoRows) {
+		return Proxy{}, fmt.Errorf("FRP 规则不存在")
+	}
+	return proxy, err
+}
+
+func (s *Store) CreateProxy(ctx context.Context, in ProxyInput) (Proxy, error) {
+	domains, _ := json.Marshal(in.CustomDomains)
+	options, _ := json.Marshal(in.Options)
+	result, err := s.db.ExecContext(ctx, `
+		INSERT INTO frp_proxies(server_id, name, type, local_ip, local_port, remote_port, custom_domains_json, host_header_rewrite, config_json, enabled, remark, updated_at)
+		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
+	`, in.ServerID, in.Name, in.Type, in.LocalIP, in.LocalPort, in.RemotePort, string(domains), in.HostHeaderRewrite, string(options), boolInt(in.Enabled), in.Remark)
+	if err != nil {
+		return Proxy{}, mapUniqueError(err, "FRP 规则名称或 TCP 远程端口已存在")
+	}
+	id, _ := result.LastInsertId()
+	return s.GetProxy(ctx, id)
+}
+
+func (s *Store) UpdateProxy(ctx context.Context, id int64, in ProxyInput) (Proxy, error) {
+	domains, _ := json.Marshal(in.CustomDomains)
+	options, _ := json.Marshal(in.Options)
+	result, err := s.db.ExecContext(ctx, `
+		UPDATE frp_proxies SET server_id = ?, name = ?, type = ?, local_ip = ?, local_port = ?, remote_port = ?, custom_domains_json = ?, host_header_rewrite = ?, config_json = ?, enabled = ?, remark = ?, updated_at = datetime('now')
+		WHERE id = ?
+	`, in.ServerID, in.Name, in.Type, in.LocalIP, in.LocalPort, in.RemotePort, string(domains), in.HostHeaderRewrite, string(options), boolInt(in.Enabled), in.Remark, id)
+	if err != nil {
+		return Proxy{}, mapUniqueError(err, "FRP 规则名称或 TCP 远程端口已存在")
+	}
+	count, _ := result.RowsAffected()
+	if count == 0 {
+		return Proxy{}, fmt.Errorf("FRP 规则不存在")
+	}
+	return s.GetProxy(ctx, id)
+}
+
+func (s *Store) DeleteProxy(ctx context.Context, id int64) error {
+	result, err := s.db.ExecContext(ctx, `DELETE FROM frp_proxies WHERE id = ?`, id)
+	if err != nil {
+		return err
+	}
+	count, _ := result.RowsAffected()
+	if count == 0 {
+		return fmt.Errorf("FRP 规则不存在")
+	}
+	return nil
+}
+
+func scanProxies(rows *sql.Rows) ([]Proxy, error) {
+	proxies := []Proxy{}
+	for rows.Next() {
+		proxy, err := scanProxy(rows)
+		if err != nil {
+			return nil, err
+		}
+		proxies = append(proxies, proxy)
+	}
+	return proxies, rows.Err()
+}
+
+const serverSelect = `
+SELECT id, name, server_addr, server_port, tls_enabled, tls_server_name, enabled,
+       CASE WHEN auth_token_enc IS NOT NULL AND auth_token_enc != '' THEN 1 ELSE 0 END,
+       CASE WHEN oidc_client_secret_enc IS NOT NULL AND oidc_client_secret_enc != '' THEN 1 ELSE 0 END,
+       CASE WHEN dashboard_password_enc IS NOT NULL AND dashboard_password_enc != '' THEN 1 ELSE 0 END,
+       COALESCE(agent_url, ''), CASE WHEN COALESCE(agent_token_enc, '') != '' THEN 1 ELSE 0 END,
+       COALESCE(ssh_host, ''), COALESCE(ssh_port, 22), COALESCE(ssh_user, ''), COALESCE(ssh_auth, 'key'),
+       CASE WHEN COALESCE(ssh_secret_enc, '') != '' THEN 1 ELSE 0 END, COALESCE(mgmt_enabled, 0),
+       COALESCE(config_json, '{}'), created_at, updated_at
+FROM frp_servers`
+
+const proxySelect = `
+SELECT id, server_id, name, type, local_ip, local_port, remote_port, custom_domains_json, host_header_rewrite, COALESCE(config_json, '{}'), enabled, remark, created_at, updated_at
+FROM frp_proxies`
+
+func scanServer(row interface{ Scan(dest ...any) error }) (Server, error) {
+	var server Server
+	var tls, enabled, token, oidcSecret, dashboardPwd, agentConfigured, sshConfigured, mgmtEnabled int
+	var configJSON, created, updated string
+	err := row.Scan(&server.ID, &server.Name, &server.ServerAddr, &server.ServerPort, &tls, &server.TLSServerName, &enabled, &token, &oidcSecret, &dashboardPwd, &server.AgentURL, &agentConfigured, &server.SSHHost, &server.SSHPort, &server.SSHUser, &server.SSHAuth, &sshConfigured, &mgmtEnabled, &configJSON, &created, &updated)
+	if err != nil {
+		return Server{}, err
+	}
+	server.TLS = tls == 1
+	server.Enabled = enabled == 1
+	server.HasToken = token == 1
+	server.HasOIDCSecret = oidcSecret == 1
+	server.DashboardHasPwd = dashboardPwd == 1
+	server.AgentConfigured = agentConfigured == 1
+	server.SSHConfigured = sshConfigured == 1
+	server.MgmtEnabled = mgmtEnabled == 1
+	_ = json.Unmarshal([]byte(configJSON), &server.Options)
+	server.Options = normalizeServerOptions(server.Options)
+	server.CreatedAt = parseTime(created)
+	server.UpdatedAt = parseTime(updated)
+	return server, nil
+}
+
+func scanProxy(row interface{ Scan(dest ...any) error }) (Proxy, error) {
+	var proxy Proxy
+	var domains, configJSON, created, updated string
+	var enabled int
+	if err := row.Scan(&proxy.ID, &proxy.ServerID, &proxy.Name, &proxy.Type, &proxy.LocalIP, &proxy.LocalPort, &proxy.RemotePort, &domains, &proxy.HostHeaderRewrite, &configJSON, &enabled, &proxy.Remark, &created, &updated); err != nil {
+		return Proxy{}, err
+	}
+	_ = json.Unmarshal([]byte(domains), &proxy.CustomDomains)
+	_ = json.Unmarshal([]byte(configJSON), &proxy.Options)
+	proxy.Enabled = enabled == 1
+	proxy.CreatedAt = parseTime(created)
+	proxy.UpdatedAt = parseTime(updated)
+	return proxy, nil
+}
+
+func mapUniqueError(err error, message string) error {
+	if strings.Contains(err.Error(), "UNIQUE") {
+		return fmt.Errorf(message)
+	}
+	return err
+}
+
+func boolInt(value bool) int {
+	if value {
+		return 1
+	}
+	return 0
+}
+
+func parseTime(value string) time.Time {
+	parsed, err := time.Parse("2006-01-02 15:04:05", value)
+	if err != nil {
+		parsed, _ = time.Parse(time.RFC3339, value)
+	}
+	return parsed.UTC()
+}
diff --git a/internal/frp/traffic.go b/internal/frp/traffic.go
new file mode 100644
index 0000000..96b30d5
--- /dev/null
+++ b/internal/frp/traffic.go
@@ -0,0 +1,226 @@
+package frp
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+	"time"
+)
+
+// frps 管理接口(webServer)返回的代理状态结构,仅取本模块用到的字段。
+type frpsProxyStatus struct {
+	Name            string `json:"name"`
+	Type            string `json:"type"`
+	TodayTrafficIn  int64  `json:"today_traffic_in"`
+	TodayTrafficOut int64  `json:"today_traffic_out"`
+	CurrentConns    int    `json:"cur_conns"`
+}
+
+type ProxyTraffic struct {
+	TrafficIn      int64 `json:"traffic_in"`
+	TrafficOut     int64 `json:"traffic_out"`
+	CurConns       int   `json:"cur_conns"`
+	TrafficInRate  int64 `json:"traffic_in_rate"`
+	TrafficOutRate int64 `json:"traffic_out_rate"`
+}
+
+type ServerTraffic struct {
+	ServerID           int64                   `json:"server_id"`
+	OK                 bool                    `json:"ok"`
+	Error              string                  `json:"error,omitempty"`
+	Proxies            map[string]ProxyTraffic `json:"proxies"`
+	ProxyNamesByDomain map[string]string       `json:"proxy_names_by_domain,omitempty"`
+}
+
+type ProxiesTraffic struct {
+	Items     map[int64]ProxyTraffic `json:"items"`
+	Errors    map[int64]string       `json:"errors,omitempty"`
+	SampledAt string                 `json:"sampled_at"`
+}
+
+type trafficSample struct {
+	at  time.Time
+	in  int64
+	out int64
+}
+
+type frpsTrafficClient struct {
+	httpClient *http.Client
+}
+
+func newFRPSTrafficClient() *frpsTrafficClient {
+	return &frpsTrafficClient{httpClient: &http.Client{Timeout: 6 * time.Second}}
+}
+
+// fetchServerTraffic 版本自适应采集:
+//   - frp >= v0.71.0:GET /api/v2/proxies(一次返回全部类型,data.items[].name 含 user 前缀);
+//   - frp < v0.71.0(如 v0.68.0):无 V2 API(/api/v2/* 404),用 GET /api/proxies(一次返回全部类型)。
+//
+// 判定方式:V2 返回 404 page not found 即回退旧端点;其余错误如实返回。
+func (c *frpsTrafficClient) fetchServerTraffic(ctx context.Context, server Server, password string) ServerTraffic {
+	empty := map[string]ProxyTraffic{}
+	if strings.TrimSpace(server.Options.DashboardAddr) == "" || server.Options.DashboardPort == 0 {
+		return ServerTraffic{ServerID: server.ID, Error: "未配置 frps 管理接口(webServer)", Proxies: empty}
+	}
+
+	// 优先 V2 API(v0.71.0+)
+	v2URL := fmt.Sprintf("http://%s:%d/api/v2/proxies", server.Options.DashboardAddr, server.Options.DashboardPort)
+	body, status, err := c.getJSON(ctx, v2URL, server, password)
+	if err != nil {
+		return ServerTraffic{ServerID: server.ID, Error: fmt.Sprintf("frps 管理接口请求失败(GET %s): %v", v2URL, err), Proxies: empty}
+	}
+	if status == http.StatusOK {
+		return c.parseV2Proxies(server, body)
+	}
+	if status != http.StatusNotFound {
+		return ServerTraffic{ServerID: server.ID, Error: fmt.Sprintf("frps 管理接口返回 %d(GET %s): %s", status, v2URL, strings.TrimSpace(string(body))), Proxies: empty}
+	}
+
+	// 旧版没有全量列表端点,流量采集无法在不知道代理名时继续;公网反代健康检查使用
+	// fetchLegacyRouteProxies 按候选规则逐条查询 /api/proxies/{user.name}。
+	return ServerTraffic{ServerID: server.ID, Error: "frps 旧版 dashboard 不支持全量代理列表,请通过规则名逐条查询", Proxies: empty}
+}
+
+// legacyProxyInfo 是 frp 0.68.0 的单代理响应结构。
+type legacyProxyInfo struct {
+	Name string `json:"name"`
+	User string `json:"user"`
+	Conf struct {
+		CustomDomains []string `json:"customDomains"`
+	} `json:"conf"`
+	Status          string `json:"status"`
+	TodayTrafficIn  int64  `json:"todayTrafficIn"`
+	TodayTrafficOut int64  `json:"todayTrafficOut"`
+	CurConns        int    `json:"curConns"`
+}
+
+// fetchLegacyProxyByName 兼容 frp 0.68.0:旧 dashboard 只能按实际代理名查询,
+// 代理名遵循 user.proxyName 规则(例如 admin.ssssss)。
+func (c *frpsTrafficClient) fetchLegacyProxyByName(ctx context.Context, server Server, password, proxyName string) (legacyProxyInfo, int, error) {
+	url := fmt.Sprintf("http://%s:%d/api/proxies/%s", server.Options.DashboardAddr, server.Options.DashboardPort, proxyName)
+	body, status, err := c.getJSON(ctx, url, server, password)
+	if err != nil {
+		return legacyProxyInfo{}, 0, err
+	}
+	if status != http.StatusOK {
+		return legacyProxyInfo{}, status, fmt.Errorf("GET %s 返回 %d: %s", url, status, strings.TrimSpace(string(body)))
+	}
+	var info legacyProxyInfo
+	if err := json.Unmarshal(body, &info); err != nil {
+		return legacyProxyInfo{}, status, fmt.Errorf("解析 GET %s 响应失败: %v", url, err)
+	}
+	return info, status, nil
+}
+
+// fetchLegacyRouteProxies 按候选规则逐条查询旧版 frps,并保留域名到真实代理名的映射。
+func (c *frpsTrafficClient) fetchLegacyRouteProxies(ctx context.Context, server Server, password string, names []string) ServerTraffic {
+	empty := map[string]ProxyTraffic{}
+	proxies := make(map[string]ProxyTraffic)
+	proxyNamesByDomain := make(map[string]string)
+	var lastErr error
+	for _, name := range names {
+		proxyName := name
+		if user := strings.TrimSpace(server.Options.User); user != "" && !strings.HasPrefix(proxyName, user+".") {
+			proxyName = user + "." + proxyName
+		}
+		info, _, err := c.fetchLegacyProxyByName(ctx, server, password, proxyName)
+		if err != nil {
+			lastErr = err
+			continue
+		}
+		traffic := ProxyTraffic{TrafficIn: info.TodayTrafficIn, TrafficOut: info.TodayTrafficOut, CurConns: info.CurConns}
+		proxies[info.Name] = traffic
+		for _, domain := range info.Conf.CustomDomains {
+			if domain = strings.ToLower(strings.TrimSpace(domain)); domain != "" {
+				proxies["domain:"+domain] = traffic
+				proxyNamesByDomain[domain] = info.Name
+			}
+		}
+	}
+	if len(proxies) == 0 && lastErr != nil {
+		return ServerTraffic{ServerID: server.ID, Error: lastErr.Error(), Proxies: empty}
+	}
+	return ServerTraffic{ServerID: server.ID, OK: true, Proxies: proxies, ProxyNamesByDomain: proxyNamesByDomain}
+}
+
+// getJSON 执行带 Basic Auth 的 GET 请求并返回响应体与状态码。
+func (c *frpsTrafficClient) getJSON(ctx context.Context, url string, server Server, password string) ([]byte, int, error) {
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+	if err != nil {
+		return nil, 0, err
+	}
+	req.Header.Set("Accept", "application/json")
+	if user := strings.TrimSpace(server.Options.DashboardUser); user != "" {
+		req.SetBasicAuth(user, password)
+	}
+	resp, err := c.httpClient.Do(req)
+	if err != nil {
+		return nil, 0, err
+	}
+	defer resp.Body.Close()
+	body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+	if err != nil {
+		return nil, resp.StatusCode, err
+	}
+	return body, resp.StatusCode, nil
+}
+
+// parseV2Proxies 解析 V2 API 响应(实测结构):
+// {"code":200,"data":{"items":[{"name":"admin.ssssss","user":"admin","spec":{"type":"http","http":{"customDomains":[...]}},"status":{"phase":"online",...}}]}}
+func (c *frpsTrafficClient) parseV2Proxies(server Server, body []byte) ServerTraffic {
+	empty := map[string]ProxyTraffic{}
+	var payload struct {
+		Data struct {
+			Items []struct {
+				Name string `json:"name"`
+				User string `json:"user"`
+				Spec struct {
+					Type string `json:"type"`
+					HTTP struct {
+						CustomDomains []string `json:"customDomains"`
+					} `json:"http"`
+				} `json:"spec"`
+				Status struct {
+					Phase           string `json:"phase"`
+					TodayTrafficIn  int64  `json:"todayTrafficIn"`
+					TodayTrafficOut int64  `json:"todayTrafficOut"`
+					CurConns        int    `json:"curConns"`
+				} `json:"status"`
+			} `json:"items"`
+		} `json:"data"`
+	}
+	if err := json.Unmarshal(body, &payload); err != nil {
+		return ServerTraffic{ServerID: server.ID, Error: fmt.Sprintf("解析 V2 响应失败: %v", err), Proxies: empty}
+	}
+	proxies := make(map[string]ProxyTraffic)
+	proxyNamesByDomain := make(map[string]string)
+	for _, item := range payload.Data.Items {
+		traffic := ProxyTraffic{
+			TrafficIn:  item.Status.TodayTrafficIn,
+			TrafficOut: item.Status.TodayTrafficOut,
+			CurConns:   item.Status.CurConns,
+		}
+		proxies[item.Name] = traffic
+		// 域名索引:健康检查按域名直接匹配(比代理名猜测可靠)
+		for _, d := range item.Spec.HTTP.CustomDomains {
+			domain := strings.ToLower(strings.TrimSpace(d))
+			if domain != "" {
+				proxies["domain:"+domain] = traffic
+				proxyNamesByDomain[domain] = item.Name
+			}
+		}
+	}
+	return ServerTraffic{ServerID: server.ID, OK: true, Proxies: proxies, ProxyNamesByDomain: proxyNamesByDomain}
+}
+
+// validDashboardAddr 宽松校验管理接口地址(IP 或域名,不带协议与端口)。
+func validDashboardAddr(addr string) bool {
+	addr = strings.TrimSpace(addr)
+	if addr == "" {
+		return true
+	}
+	return validHost(addr)
+}
diff --git a/internal/frp/validate.go b/internal/frp/validate.go
new file mode 100644
index 0000000..6559a1e
--- /dev/null
+++ b/internal/frp/validate.go
@@ -0,0 +1,361 @@
+package frp
+
+import (
+	"fmt"
+	"net"
+	"net/url"
+	"regexp"
+	"strings"
+)
+
+var namePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
+
+func validateServerInput(in ServerInput) error {
+	if strings.TrimSpace(in.Name) == "" || len([]rune(strings.TrimSpace(in.Name))) > 64 {
+		return fmt.Errorf("服务端名称长度应为 1-64 个字符")
+	}
+	if !validHost(in.ServerAddr) {
+		return fmt.Errorf("FRP 服务端地址无效")
+	}
+	if !validPort(in.ServerPort) {
+		return fmt.Errorf("FRP 服务端端口无效")
+	}
+	if in.Options.DashboardPort != 0 && !validPort(in.Options.DashboardPort) {
+		return fmt.Errorf("frps 管理接口端口无效")
+	}
+	if !validDashboardAddr(in.Options.DashboardAddr) {
+		return fmt.Errorf("frps 管理接口地址无效")
+	}
+	if strings.TrimSpace(in.TLSServerName) != "" && !validHost(in.TLSServerName) {
+		return fmt.Errorf("TLS 服务名无效")
+	}
+	if !validAuthMethod(in.Options.AuthMethod) {
+		return fmt.Errorf("不支持的认证方式")
+	}
+	if !validLogLevel(in.Options.LogLevel) {
+		return fmt.Errorf("日志级别无效")
+	}
+	if in.Options.LogMaxDays < 0 || in.Options.LogMaxDays > 3650 {
+		return fmt.Errorf("日志保留天数应为 0-3650")
+	}
+	if !validProtocol(in.Options.Protocol) {
+		return fmt.Errorf("传输协议无效")
+	}
+	if !validOptionalURL(in.Options.ProxyURL) || !validOptionalURL(in.Options.OIDCProxyURL) || !validOptionalURL(in.Options.OIDCTokenEndpointURL) {
+		return fmt.Errorf("代理或 OIDC 地址无效")
+	}
+	if in.Options.AuthMethod == "oidc" && (strings.TrimSpace(in.Options.OIDCClientID) == "" || strings.TrimSpace(in.Options.OIDCTokenEndpointURL) == "") {
+		return fmt.Errorf("OIDC 认证需要客户端 ID 和 Token Endpoint")
+	}
+	if in.Options.DialServerTimeout < 0 || in.Options.DialServerKeepalive < 0 || in.Options.PoolCount < 0 || in.Options.TCPMuxKeepaliveInterval < 0 || in.Options.HeartbeatInterval < 0 || in.Options.HeartbeatTimeout < 0 {
+		return fmt.Errorf("传输参数不能小于 0")
+	}
+	if len([]rune(in.Options.Location)) > 120 {
+		return fmt.Errorf("服务端位置不能超过 120 个字符")
+	}
+	return nil
+}
+
+func validateProxyInput(in ProxyInput) error {
+	if !namePattern.MatchString(strings.TrimSpace(in.Name)) {
+		return fmt.Errorf("规则名称仅支持字母、数字、下划线和连字符,长度 1-64")
+	}
+	if in.Options.Plugin == nil {
+		if !validHost(in.LocalIP) {
+			return fmt.Errorf("内网地址无效")
+		}
+		if !validPort(in.LocalPort) {
+			return fmt.Errorf("内网端口无效")
+		}
+	}
+	switch in.Type {
+	case "tcp", "udp":
+		if in.RemotePort == nil || !validPort(*in.RemotePort) {
+			return fmt.Errorf("%s 规则必须填写有效的远程端口", strings.ToUpper(in.Type))
+		}
+		if len(in.CustomDomains) > 0 {
+			return fmt.Errorf("%s 规则不能设置自定义域名", strings.ToUpper(in.Type))
+		}
+	case "http", "https":
+		if in.RemotePort != nil {
+			return fmt.Errorf("HTTP/HTTPS 规则不能设置远程端口")
+		}
+		if len(in.CustomDomains) == 0 {
+			return fmt.Errorf("HTTP/HTTPS 规则至少需要一个域名")
+		}
+		for _, domain := range in.CustomDomains {
+			if !validDomain(domain) {
+				return fmt.Errorf("自定义域名无效:%s", domain)
+			}
+		}
+	case "tcpmux":
+		if in.Options.Multiplexer != "httpconnect" {
+			return fmt.Errorf("TCPMUX 仅支持 httpconnect 多路复用器")
+		}
+		if len(in.CustomDomains) == 0 {
+			return fmt.Errorf("TCPMUX 规则至少需要一个自定义域名")
+		}
+		for _, domain := range in.CustomDomains {
+			if !validDomain(domain) {
+				return fmt.Errorf("自定义域名无效:%s", domain)
+			}
+		}
+		if in.RemotePort != nil {
+			return fmt.Errorf("TCPMUX 规则不能设置远程端口")
+		}
+	case "stcp", "sudp", "xtcp":
+		if strings.TrimSpace(in.Options.SecretKey) == "" {
+			return fmt.Errorf("%s 规则必须填写访问密钥", strings.ToUpper(in.Type))
+		}
+		if in.RemotePort != nil {
+			return fmt.Errorf("%s 规则不能设置远程端口", strings.ToUpper(in.Type))
+		}
+	default:
+		return fmt.Errorf("不支持的 FRP 规则类型")
+	}
+	if err := validateProxyOptions(in); err != nil {
+		return err
+	}
+	return nil
+}
+
+func validateProxyOptions(in ProxyInput) error {
+	opts := in.Options
+	if opts.Transport.BandwidthLimitMode != "" && opts.Transport.BandwidthLimitMode != "client" && opts.Transport.BandwidthLimitMode != "server" {
+		return fmt.Errorf("限速位置只能是 client 或 server")
+	}
+	if opts.Transport.ProxyProtocolVersion != "" && opts.Transport.ProxyProtocolVersion != "v1" && opts.Transport.ProxyProtocolVersion != "v2" {
+		return fmt.Errorf("代理协议版本只能是 v1 或 v2")
+	}
+	if opts.HealthCheck.Type != "" && opts.HealthCheck.Type != "tcp" && opts.HealthCheck.Type != "http" {
+		return fmt.Errorf("健康检查类型只能是 tcp 或 http")
+	}
+	if opts.HealthCheck.IntervalSeconds < 0 || opts.HealthCheck.MaxFailed < 0 || opts.HealthCheck.TimeoutSeconds < 0 {
+		return fmt.Errorf("健康检查参数不能小于 0")
+	}
+	if opts.LoadBalancer.Group != "" && opts.LoadBalancer.GroupKey == "" {
+		return fmt.Errorf("负载均衡组设置后必须填写组密钥")
+	}
+	return validateProxyPlugin(opts.Plugin)
+}
+
+// validateProxyPlugin 按插件类型分别校验必填字段;不能只判断插件类型非空。
+func validateProxyPlugin(plugin *ProxyPluginOptions) error {
+	if plugin == nil {
+		return nil
+	}
+	pluginType := strings.TrimSpace(plugin.Type)
+	if pluginType == "" {
+		return fmt.Errorf("插件类型不能为空")
+	}
+	validAddr := func(addr string, label string) error {
+		addr = strings.TrimSpace(addr)
+		host, portText, ok := strings.Cut(addr, ":")
+		if !ok || !validHost(host) || !validPort(parseInt(portText)) {
+			return fmt.Errorf("插件本地地址 %s 无效,应为 host:port 形式", label)
+		}
+		return nil
+	}
+	validSecretFile := func(path, label string) error {
+		if strings.TrimSpace(path) == "" {
+			return fmt.Errorf("插件 %s 需要提供 %s", pluginType, label)
+		}
+		return nil
+	}
+	switch pluginType {
+	case "unix_domain_socket":
+		if strings.TrimSpace(plugin.UnixPath) == "" {
+			return fmt.Errorf("插件 unix_domain_socket 需要提供 Unix Socket 路径")
+		}
+	case "static_file":
+		if strings.TrimSpace(plugin.LocalPath) == "" {
+			return fmt.Errorf("插件 static_file 需要提供静态文件目录")
+		}
+	case "http_proxy", "socks5":
+		// 用户名/密码可选
+	case "http2https", "http2http":
+		if err := validAddr(plugin.LocalAddr, "localAddr"); err != nil {
+			return err
+		}
+	case "https2http", "https2https", "tls2raw":
+		if err := validAddr(plugin.LocalAddr, "localAddr"); err != nil {
+			return err
+		}
+		if err := validSecretFile(plugin.CRTPath, "crtPath"); err != nil {
+			return err
+		}
+		if err := validSecretFile(plugin.KeyPath, "keyPath"); err != nil {
+			return err
+		}
+	default:
+		return fmt.Errorf("不支持的插件类型:%s", pluginType)
+	}
+	return nil
+}
+
+// parseInt 解析十进制端口号;非数字或越界返回 0,由 validPort 拒绝。
+func parseInt(value string) int {
+	value = strings.TrimSpace(value)
+	number := 0
+	if value == "" {
+		return 0
+	}
+	for _, ch := range value {
+		if ch < '0' || ch > '9' {
+			return 0
+		}
+		number = number*10 + int(ch-'0')
+	}
+	return number
+}
+
+// ValidateProxy 是 FRP 规则校验器的对外入口:关系表等跨模块模式校验必须复用此函数,
+// 不能在编排服务中另行实现一套判断(FRP_CROSS_MODULE_IMPLEMENTATION_PLAN.md 23.2)。
+func ValidateProxy(in ProxyInput) error {
+	return validateProxyInput(normalizeProxyInput(in))
+}
+
+func normalizeServerInput(in ServerInput) ServerInput {
+	in.Name = strings.TrimSpace(in.Name)
+	in.ServerAddr = strings.ToLower(strings.TrimSpace(in.ServerAddr))
+	in.TLSServerName = strings.ToLower(strings.TrimSpace(in.TLSServerName))
+	in.Token = strings.TrimSpace(in.Token)
+	in.OIDCClientSecret = strings.TrimSpace(in.OIDCClientSecret)
+	in.Options = normalizeServerOptions(in.Options)
+	return in
+}
+
+func normalizeServerOptions(in ServerOptions) ServerOptions {
+	in.User = strings.TrimSpace(in.User)
+	in.Location = strings.TrimSpace(in.Location)
+	in.AuthMethod = strings.ToLower(strings.TrimSpace(in.AuthMethod))
+	in.OIDCClientID = strings.TrimSpace(in.OIDCClientID)
+	in.OIDCAudience = strings.TrimSpace(in.OIDCAudience)
+	in.OIDCScope = strings.TrimSpace(in.OIDCScope)
+	in.OIDCTokenEndpointURL = strings.TrimSpace(in.OIDCTokenEndpointURL)
+	in.OIDCTrustedCAPath = strings.TrimSpace(in.OIDCTrustedCAPath)
+	in.OIDCProxyURL = strings.TrimSpace(in.OIDCProxyURL)
+	in.LogLevel = strings.ToLower(strings.TrimSpace(in.LogLevel))
+	in.Protocol = strings.ToLower(strings.TrimSpace(in.Protocol))
+	in.ProxyURL = strings.TrimSpace(in.ProxyURL)
+	in.ConnectServerLocalIP = strings.TrimSpace(in.ConnectServerLocalIP)
+	in.DNSServer = strings.TrimSpace(in.DNSServer)
+	in.Remark = strings.TrimSpace(in.Remark)
+	if in.AuthMethod == "" {
+		in.AuthMethod = "token"
+	}
+	if in.LogLevel == "" {
+		in.LogLevel = "info"
+	}
+	if in.Protocol == "" {
+		in.Protocol = "tcp"
+	}
+	if in.LogMaxDays == 0 {
+		in.LogMaxDays = 3
+	}
+	if in.DialServerTimeout == 0 {
+		in.DialServerTimeout = 10
+	}
+	if in.DialServerKeepalive == 0 {
+		in.DialServerKeepalive = 7200
+	}
+	if in.TCPMuxKeepaliveInterval == 0 {
+		in.TCPMuxKeepaliveInterval = 60
+	}
+	if in.HeartbeatInterval == 0 {
+		in.HeartbeatInterval = 30
+	}
+	if in.HeartbeatTimeout == 0 {
+		in.HeartbeatTimeout = 90
+	}
+	return in
+}
+
+func validAuthMethod(value string) bool {
+	return value == "none" || value == "token" || value == "oidc"
+}
+func validLogLevel(value string) bool {
+	return value == "trace" || value == "debug" || value == "info" || value == "warn" || value == "error"
+}
+func validProtocol(value string) bool {
+	return value == "tcp" || value == "kcp" || value == "quic" || value == "websocket" || value == "wss"
+}
+func validOptionalURL(value string) bool {
+	value = strings.TrimSpace(value)
+	if value == "" {
+		return true
+	}
+	parsed, err := url.Parse(value)
+	return err == nil && parsed.Scheme != "" && parsed.Host != ""
+}
+
+func normalizeProxyInput(in ProxyInput) ProxyInput {
+	in.Name = strings.TrimSpace(in.Name)
+	in.Type = strings.ToLower(strings.TrimSpace(in.Type))
+	in.LocalIP = strings.TrimSpace(in.LocalIP)
+	in.HostHeaderRewrite = strings.TrimSpace(in.HostHeaderRewrite)
+	in.Remark = strings.TrimSpace(in.Remark)
+	in.Options = normalizeProxyOptions(in.Options)
+	seen := map[string]bool{}
+	domains := make([]string, 0, len(in.CustomDomains))
+	for _, domain := range in.CustomDomains {
+		domain = strings.ToLower(strings.TrimSpace(domain))
+		if domain != "" && !seen[domain] {
+			seen[domain] = true
+			domains = append(domains, domain)
+		}
+	}
+	in.CustomDomains = domains
+	return in
+}
+
+func normalizeProxyOptions(in ProxyOptions) ProxyOptions {
+	in.Subdomain = strings.TrimSpace(in.Subdomain)
+	in.RouteByHTTPUser = strings.TrimSpace(in.RouteByHTTPUser)
+	in.HTTPUser = strings.TrimSpace(in.HTTPUser)
+	in.HTTPPassword = strings.TrimSpace(in.HTTPPassword)
+	in.Multiplexer = strings.ToLower(strings.TrimSpace(in.Multiplexer))
+	in.SecretKey = strings.TrimSpace(in.SecretKey)
+	for index, user := range in.AllowUsers {
+		in.AllowUsers[index] = strings.TrimSpace(user)
+	}
+	in.Transport.BandwidthLimit = strings.TrimSpace(in.Transport.BandwidthLimit)
+	in.Transport.BandwidthLimitMode = strings.ToLower(strings.TrimSpace(in.Transport.BandwidthLimitMode))
+	in.Transport.ProxyProtocolVersion = strings.ToLower(strings.TrimSpace(in.Transport.ProxyProtocolVersion))
+	in.HealthCheck.Type = strings.ToLower(strings.TrimSpace(in.HealthCheck.Type))
+	in.HealthCheck.Path = strings.TrimSpace(in.HealthCheck.Path)
+	return in
+}
+
+func validPort(port int) bool {
+	return port > 0 && port <= 65535
+}
+
+func validHost(host string) bool {
+	host = strings.TrimSpace(host)
+	if host == "" || len(host) > 253 || strings.ContainsAny(host, " \t\r\n/\\\"'") {
+		return false
+	}
+	if net.ParseIP(host) != nil || host == "localhost" {
+		return true
+	}
+	return validDomain(host)
+}
+
+func validDomain(domain string) bool {
+	domain = strings.TrimSuffix(strings.TrimSpace(domain), ".")
+	if domain == "" || len(domain) > 253 || strings.Contains(domain, "..") {
+		return false
+	}
+	for _, label := range strings.Split(domain, ".") {
+		if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
+			return false
+		}
+		for _, ch := range label {
+			if !(ch >= 'a' && ch <= 'z') && !(ch >= 'A' && ch <= 'Z') && !(ch >= '0' && ch <= '9') && ch != '-' {
+				return false
+			}
+		}
+	}
+	return true
+}
diff --git a/internal/frp/validate_test.go b/internal/frp/validate_test.go
new file mode 100644
index 0000000..ebfc192
--- /dev/null
+++ b/internal/frp/validate_test.go
@@ -0,0 +1,43 @@
+package frp
+
+import "testing"
+
+func TestValidateProxyInput(t *testing.T) {
+	port := 60022
+	cases := []struct {
+		name    string
+		input   ProxyInput
+		wantErr bool
+	}{
+		{name: "tcp", input: ProxyInput{Name: "nas_ssh", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port}},
+		{name: "http", input: ProxyInput{Name: "nas-web", Type: "http", LocalIP: "127.0.0.1", LocalPort: 18080, CustomDomains: []string{"nas.example.com"}}},
+		{name: "missing remote port", input: ProxyInput{Name: "nas", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22}, wantErr: true},
+		{name: "invalid name", input: ProxyInput{Name: "bad name", Type: "http", LocalIP: "127.0.0.1", LocalPort: 80, CustomDomains: []string{"nas.example.com"}}, wantErr: true},
+		{name: "invalid domain", input: ProxyInput{Name: "nas", Type: "http", LocalIP: "127.0.0.1", LocalPort: 80, CustomDomains: []string{"bad domain"}}, wantErr: true},
+		{name: "udp", input: ProxyInput{Name: "dns", Type: "udp", LocalIP: "127.0.0.1", LocalPort: 53, RemotePort: &port}},
+		{name: "stcp", input: ProxyInput{Name: "secret", Type: "stcp", LocalIP: "127.0.0.1", LocalPort: 22, Options: ProxyOptions{SecretKey: "abc"}}},
+		{name: "tcpmux missing multiplexer", input: ProxyInput{Name: "mux", Type: "tcpmux", LocalIP: "127.0.0.1", LocalPort: 80}, wantErr: true},
+		{name: "invalid bandwidth mode", input: ProxyInput{Name: "nas", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Transport: ProxyTransportOptions{BandwidthLimitMode: "wrong"}}}, wantErr: true},
+		{name: "udp remote port", input: ProxyInput{Name: "dns2", Type: "udp", LocalIP: "127.0.0.1", LocalPort: 53, RemotePort: &port}},
+		{name: "xtcp missing secret key", input: ProxyInput{Name: "p2p", Type: "xtcp", LocalIP: "127.0.0.1", LocalPort: 22}, wantErr: true},
+		{name: "plugin http2https missing local addr", input: ProxyInput{Name: "p1", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Plugin: &ProxyPluginOptions{Type: "https2http", CRTPath: "cert.pem", KeyPath: "key.pem"}}}, wantErr: true},
+		{name: "plugin https2http missing cert", input: ProxyInput{Name: "p2", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Plugin: &ProxyPluginOptions{Type: "https2http", LocalAddr: "127.0.0.1:80"}}}, wantErr: true},
+		{name: "plugin socks5 optional auth", input: ProxyInput{Name: "p3", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Plugin: &ProxyPluginOptions{Type: "socks5"}}}},
+		{name: "plugin unix_domain_socket missing path", input: ProxyInput{Name: "p4", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Plugin: &ProxyPluginOptions{Type: "unix_domain_socket"}}}, wantErr: true},
+		{name: "plugin static_file missing local path", input: ProxyInput{Name: "p5", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Plugin: &ProxyPluginOptions{Type: "static_file"}}}, wantErr: true},
+		{name: "plugin unknown type", input: ProxyInput{Name: "p6", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Plugin: &ProxyPluginOptions{Type: "whatever"}}}, wantErr: true},
+	}
+
+	for _, testCase := range cases {
+		t.Run(testCase.name, func(t *testing.T) {
+			err := validateProxyInput(testCase.input)
+			if (err != nil) != testCase.wantErr {
+				t.Fatalf("validateProxyInput() error = %v, wantErr %v", err, testCase.wantErr)
+			}
+			// 对外导出的校验入口应与内部入口行为一致(关系表模式校验复用此函数)
+			if (ValidateProxy(testCase.input) != nil) != testCase.wantErr {
+				t.Fatalf("ValidateProxy() error = %v, wantErr %v", ValidateProxy(testCase.input), testCase.wantErr)
+			}
+		})
+	}
+}
diff --git a/internal/logstore/access_parse_test.go b/internal/logstore/access_parse_test.go
new file mode 100644
index 0000000..f336a22
--- /dev/null
+++ b/internal/logstore/access_parse_test.go
@@ -0,0 +1,46 @@
+package logstore
+
+import "testing"
+
+func TestParseRealNginxAccessLine(t *testing.T) {
+	line := "2026-09-12T19:55:18+08:00 127.0.0.1 GET / 200 0.000 127.0.0.1 [::1]:5173"
+	entry, ok := parseAccess(line)
+	if !ok {
+		t.Fatal("parse failed")
+	}
+	if entry.Status != 200 {
+		t.Fatalf("status %d", entry.Status)
+	}
+}
+
+func TestParseAccessWithBytes(t *testing.T) {
+	line := "2026-09-12T19:55:18+08:00 speedtest.example.com 6893 GET /api 200 0.010 1.2.3.4 192.168.1.1:8080 512 4096"
+	entry, ok := parseAccess(line)
+	if !ok {
+		t.Fatal("parse failed")
+	}
+	if entry.ServerPort != 6893 {
+		t.Fatalf("server_port=%d", entry.ServerPort)
+	}
+	if entry.RequestLength != 512 || entry.BytesSent != 4096 {
+		t.Fatalf("bytes upload=%d download=%d", entry.RequestLength, entry.BytesSent)
+	}
+}
+
+func TestAccessEndpointFilterMatchesPort(t *testing.T) {
+	line := "2026-09-12T19:55:18+08:00 speedtest.example.com 6893 GET /api 200 0.010 1.2.3.4 192.168.1.1:8080"
+	other := "2026-09-12T19:55:19+08:00 speedtest.example.com 8443 GET / 404 0.010 1.2.3.4 192.168.1.2:8080"
+	filter := AccessEndpointFilter([]AccessEndpoint{{Host: "speedtest.example.com", Port: 6893}})
+	if filter == nil || !filter(line) || filter(other) {
+		t.Fatal("endpoint filter mismatch")
+	}
+}
+
+func TestAccessHostFilter(t *testing.T) {
+	line := "2026-09-12T19:55:18+08:00 speedtest.example.com GET /api 200 0.010 1.2.3.4 192.168.1.1:8080"
+	other := "2026-09-12T19:55:19+08:00 other.example.com GET / 404 0.010 1.2.3.4 192.168.1.2:8080"
+	filter := AccessHostFilter([]string{"speedtest.example.com"})
+	if filter == nil || !filter(line) || filter(other) {
+		t.Fatal("host filter mismatch")
+	}
+}
diff --git a/internal/logstore/dashboard_access_test.go b/internal/logstore/dashboard_access_test.go
new file mode 100644
index 0000000..4f98bd8
--- /dev/null
+++ b/internal/logstore/dashboard_access_test.go
@@ -0,0 +1,37 @@
+package logstore
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+)
+
+func TestDashboardAccessFilterSkipsNoisePaths(t *testing.T) {
+	if isDashboardAccess(AccessEntry{Path: "/login"}) != true {
+		t.Fatal("expected /login to count")
+	}
+	if isDashboardAccess(AccessEntry{Path: "/favicon.ico"}) {
+		t.Fatal("expected favicon to be excluded")
+	}
+	if isDashboardAccess(AccessEntry{Path: "/fonu-errors/error.png"}) {
+		t.Fatal("expected error asset to be excluded")
+	}
+}
+
+func TestCountAccessForDayExcludesNoisePaths(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "access.log")
+	now := time.Now()
+	line := formatAccessLine(now, "/login")
+	noise := formatAccessLine(now, "/favicon.ico")
+	if err := os.WriteFile(path, []byte(strings.Join([]string{line, noise}, "\n")), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	total, _, _ := CountAccessForDay(path, now)
+	if total != 1 {
+		t.Fatalf("expected 1 dashboard request, got %d", total)
+	}
+}
diff --git a/internal/logstore/display.go b/internal/logstore/display.go
new file mode 100644
index 0000000..bdb80ca
--- /dev/null
+++ b/internal/logstore/display.go
@@ -0,0 +1,211 @@
+package logstore
+
+import (
+	"fmt"
+	"strings"
+)
+
+func localizeSystemEntry(entry SystemEntry, raw map[string]any) SystemEntry {
+	msg := strings.TrimSpace(entry.Message)
+	if msg == "" {
+		return entry
+	}
+	entry.Module = localizeSystemModule(entry.Module)
+	entry.Message = localizeSystemMessage(msg, raw)
+	return entry
+}
+
+func localizeSystemModule(module string) string {
+	switch strings.ToUpper(strings.TrimSpace(module)) {
+	case "SYSTEM":
+		return "系统"
+	case "NGINX":
+		return "Nginx"
+	case "DDNS":
+		return "域名解析"
+	case "ACME", "CERT":
+		return "证书"
+	case "CHINA_CIDR":
+		return "中国 IP 段"
+	case "API":
+		return "API"
+	case "HTTP":
+		return "HTTP"
+	case "FRP":
+		return "内网穿透"
+	default:
+		if module == "" {
+			return "系统"
+		}
+		return module
+	}
+}
+
+func localizeSystemMessage(msg string, raw map[string]any) string {
+	if looksChinese(msg) {
+		return appendSystemAttrs(msg, raw)
+	}
+
+	switch msg {
+	case "application started":
+		if listen := rawString(raw, "listen"); listen != "" {
+			return fmt.Sprintf("应用已启动,监听 %s", listen)
+		}
+		return "应用已启动"
+	case "application shutting down":
+		return "应用正在关闭"
+	case "nginx started":
+		return "Nginx 已启动"
+	case "nginx stopped":
+		return "Nginx 已停止"
+	case "nginx reloaded":
+		return "Nginx 已重载"
+	case "nginx reloaded existing master":
+		return "Nginx 已重载(使用现有主进程)"
+	case "nginx start skipped duplicate master, reloaded instead":
+		return "检测到重复 Nginx 主进程,已改为重载"
+	case "nginx reload failed, trying restart":
+		return fmt.Sprintf("Nginx 重载失败,正在尝试重启:%s", rawString(raw, "error"))
+	case "nginx signal reload failed, falling back to cli":
+		return fmt.Sprintf("Nginx 信号重载失败,回退到命令行重载(PID %s):%s", rawString(raw, "pid"), rawString(raw, "error"))
+	case "nginx command failed":
+		return fmt.Sprintf("Nginx 命令执行失败(%s):%s", rawString(raw, "args"), rawString(raw, "error"))
+	case "nginx start failed":
+		return fmt.Sprintf("Nginx 启动失败(%s):%s", rawString(raw, "args"), rawString(raw, "error"))
+	case "nginx binary not found, applied syntax-only validation":
+		return fmt.Sprintf("未找到 Nginx 可执行文件,仅进行配置语法校验(%s)", rawString(raw, "bin"))
+	case "nginx binary not found, syntax-only validation":
+		return fmt.Sprintf("未找到 Nginx 可执行文件,仅进行语法校验(%s)", rawString(raw, "bin"))
+	case "initial nginx apply skipped":
+		return fmt.Sprintf("初始 Nginx 配置应用已跳过:%s", rawString(raw, "error"))
+	case "initial frpc bootstrap skipped":
+		return fmt.Sprintf("初始 frpc 引导已跳过:%s", rawString(raw, "error"))
+	case "nginx stop failed":
+		return fmt.Sprintf("Nginx 停止失败:%s", rawString(raw, "error"))
+	case "frpc stop failed":
+		return fmt.Sprintf("frpc 停止失败:%s", rawString(raw, "error"))
+	case "frpc started":
+		return "frpc 已启动"
+	case "frpc stopped":
+		return "frpc 已停止"
+	case "frpc reloaded":
+		return "frpc 已重载"
+	case "frpc reload failed, trying restart":
+		return fmt.Sprintf("frpc 重载失败,正在尝试重启:%s", rawString(raw, "error"))
+	case "frpc binary not found, skipping bootstrap":
+		return fmt.Sprintf("未找到 frpc 可执行文件,已跳过引导(%s)", rawString(raw, "bin"))
+	case "initial frpc apply skipped":
+		return fmt.Sprintf("初始 frpc 配置应用已跳过:%s", rawString(raw, "error"))
+	case "frpc exited during start":
+		return fmt.Sprintf("frpc 启动过程中退出:%s", rawString(raw, "error"))
+	case "china cidr update failed":
+		return fmt.Sprintf("中国 IP 段更新失败:%s", rawString(raw, "error"))
+	case "china cidr updated":
+		return fmt.Sprintf("中国 IP 段已更新(IPv4 %s 条,IPv6 %s 条)", rawString(raw, "v4"), rawString(raw, "v6"))
+	case "ddns update failed":
+		return fmt.Sprintf("DDNS 更新失败(%s):%s", rawString(raw, "domain"), rawString(raw, "error"))
+	case "ddns ipv4 updated":
+		return fmt.Sprintf("DDNS IPv4 已更新:%s → %s", rawString(raw, "domain"), rawString(raw, "ip"))
+	case "ddns ipv6 updated":
+		return fmt.Sprintf("DDNS IPv6 已更新:%s → %s", rawString(raw, "domain"), rawString(raw, "ip"))
+	case "ddns records updated":
+		return fmt.Sprintf("DDNS 记录已更新(服务商:%s)", rawString(raw, "provider"))
+	case "notify load config failed":
+		return fmt.Sprintf("通知配置加载失败:%s", rawString(raw, "error"))
+	case "notify send failed":
+		return fmt.Sprintf("通知发送失败(%s):%s", rawString(raw, "event"), rawString(raw, "error"))
+	case "notify test send failed":
+		return fmt.Sprintf("通知测试发送失败:%s", rawString(raw, "error"))
+	case "request failed":
+		return fmt.Sprintf("API 请求失败(%s %s,状态 %s):%s",
+			rawString(raw, "method"), rawString(raw, "path"), rawString(raw, "status"), rawString(raw, "message"))
+	case "request rejected":
+		return fmt.Sprintf("API 请求被拒绝(%s %s,状态 %s):%s",
+			rawString(raw, "method"), rawString(raw, "path"), rawString(raw, "status"), rawString(raw, "message"))
+	case "api request completed with client error":
+		return fmt.Sprintf("API 请求返回客户端错误(%s %s,状态 %s,耗时 %sms)",
+			rawString(raw, "method"), rawString(raw, "path"), rawString(raw, "status"), rawString(raw, "duration_ms"))
+	case "api request failed":
+		return fmt.Sprintf("API 请求返回服务端错误(%s %s,状态 %s,耗时 %sms)",
+			rawString(raw, "method"), rawString(raw, "path"), rawString(raw, "status"), rawString(raw, "duration_ms"))
+	case "certificate operation failed":
+		return fmt.Sprintf("证书操作失败(%s):%s", rawString(raw, "domain"), rawString(raw, "error"))
+	case "certificate obtained":
+		return fmt.Sprintf("证书已获取:%s(CA %s,到期 %s)",
+			rawString(raw, "domain"), rawString(raw, "ca"), rawString(raw, "expires_at"))
+	case "certificate imported":
+		return fmt.Sprintf("证书已导入:%s(到期 %s)", rawString(raw, "domain"), rawString(raw, "expires_at"))
+	case "certificate deleted":
+		return fmt.Sprintf("证书已删除:%s", rawString(raw, "domain"))
+	case "certificate renew failed":
+		return fmt.Sprintf("证书续签失败(%s):%s", rawString(raw, "domain"), rawString(raw, "error"))
+	case "save cert error status failed":
+		return fmt.Sprintf("保存证书错误状态失败(%s):%s", rawString(raw, "domain"), rawString(raw, "error"))
+	case "nginx reload after cert apply failed":
+		return fmt.Sprintf("证书申请后 Nginx 重载失败:%s", rawString(raw, "error"))
+	case "nginx reload after cert import failed":
+		return fmt.Sprintf("证书导入后 Nginx 重载失败:%s", rawString(raw, "error"))
+	case "nginx reload after cert delete failed":
+		return fmt.Sprintf("证书删除后 Nginx 重载失败:%s", rawString(raw, "error"))
+	case "nginx reload after cert renew failed":
+		return fmt.Sprintf("证书续签后 Nginx 重载失败:%s", rawString(raw, "error"))
+	case "save acme ca setting failed":
+		return fmt.Sprintf("保存 ACME 颁发机构设置失败:%s", rawString(raw, "error"))
+	case "save acme email failed":
+		return fmt.Sprintf("保存 ACME 邮箱失败:%s", rawString(raw, "error"))
+	case "remove cert files failed":
+		return fmt.Sprintf("删除证书文件失败(%s):%s", rawString(raw, "domain"), rawString(raw, "error"))
+	default:
+		return appendSystemAttrs(msg, raw)
+	}
+}
+
+func appendSystemAttrs(msg string, raw map[string]any) string {
+	if strings.Contains(msg, "首次启动已创建管理员账户") {
+		username := rawString(raw, "username")
+		password := rawString(raw, "password")
+		if username != "" && password != "" {
+			return fmt.Sprintf("%s(用户名:%s,密码:%s)", msg, username, password)
+		}
+	}
+	if err := rawString(raw, "error"); err != "" && !strings.Contains(msg, err) {
+		return fmt.Sprintf("%s:%s", msg, err)
+	}
+	return msg
+}
+
+func looksChinese(msg string) bool {
+	for _, r := range msg {
+		if r >= 0x4e00 && r <= 0x9fff {
+			return true
+		}
+	}
+	return false
+}
+
+func rawString(raw map[string]any, key string) string {
+	v, ok := raw[key]
+	if !ok || v == nil {
+		return ""
+	}
+	switch t := v.(type) {
+	case string:
+		return t
+	case float64:
+		if t == float64(int64(t)) {
+			return fmt.Sprintf("%d", int64(t))
+		}
+		return fmt.Sprintf("%v", t)
+	case int:
+		return fmt.Sprintf("%d", t)
+	case int64:
+		return fmt.Sprintf("%d", t)
+	case bool:
+		if t {
+			return "true"
+		}
+		return "false"
+	default:
+		return fmt.Sprintf("%v", t)
+	}
+}
diff --git a/internal/logstore/display_test.go b/internal/logstore/display_test.go
new file mode 100644
index 0000000..1748046
--- /dev/null
+++ b/internal/logstore/display_test.go
@@ -0,0 +1,54 @@
+package logstore
+
+import "testing"
+
+func TestLocalizeSystemMessage(t *testing.T) {
+	raw := map[string]any{
+		"msg":    "application started",
+		"listen": ":6893",
+	}
+	entry := localizeSystemEntry(SystemEntry{
+		Level:   "INFO",
+		Module:  "SYSTEM",
+		Message: "application started",
+	}, raw)
+	if entry.Message != "应用已启动,监听 :6893" {
+		t.Fatalf("unexpected message: %q", entry.Message)
+	}
+	if entry.Module != "系统" {
+		t.Fatalf("unexpected module: %q", entry.Module)
+	}
+}
+
+func TestLocalizeSystemMessageAdminBootstrap(t *testing.T) {
+	raw := map[string]any{
+		"msg":      "首次启动已创建管理员账户,请使用以下凭据登录,并在「设置」中尽快修改密码",
+		"username": "admin",
+		"password": "secret",
+	}
+	entry := localizeSystemEntry(SystemEntry{
+		Message: "首次启动已创建管理员账户,请使用以下凭据登录,并在「设置」中尽快修改密码",
+		Module:  "SYSTEM",
+	}, raw)
+	want := "首次启动已创建管理员账户,请使用以下凭据登录,并在「设置」中尽快修改密码(用户名:admin,密码:secret)"
+	if entry.Message != want {
+		t.Fatalf("unexpected message: %q", entry.Message)
+	}
+}
+
+func TestLocalizeSystemMessageAPIRejected(t *testing.T) {
+	raw := map[string]any{
+		"msg":     "request rejected",
+		"method":  "POST",
+		"path":    "/api/settings/notify/test",
+		"status":  400,
+		"message": "Telegram Bot Token 未配置",
+	}
+	entry := localizeSystemEntry(SystemEntry{Message: "request rejected", Module: "API"}, raw)
+	if entry.Message != "API 请求被拒绝(POST /api/settings/notify/test,状态 400):Telegram Bot Token 未配置" {
+		t.Fatalf("unexpected message: %q", entry.Message)
+	}
+	if entry.Module != "API" {
+		t.Fatalf("unexpected module: %q", entry.Module)
+	}
+}
diff --git a/internal/logstore/hourly_test.go b/internal/logstore/hourly_test.go
new file mode 100644
index 0000000..5b93140
--- /dev/null
+++ b/internal/logstore/hourly_test.go
@@ -0,0 +1,52 @@
+package logstore
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+)
+
+func TestHourlyAccessCountsCalendarDay(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "access.log")
+	now := time.Now()
+	todayMorning := time.Date(now.Year(), now.Month(), now.Day(), 8, 15, 0, 0, now.Location())
+	todayNoon := time.Date(now.Year(), now.Month(), now.Day(), 11, 30, 0, 0, now.Location())
+	yesterday := todayMorning.Add(-24 * time.Hour)
+	lines := []string{
+		formatAccessLine(todayMorning, "/"),
+		formatAccessLine(todayMorning.Add(30*time.Minute), "/"),
+		formatAccessLine(todayNoon, "/"),
+		formatAccessLine(yesterday, "/"),
+	}
+	if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	got, labels := HourlyAccessCounts(path)
+	if len(got) != 12 || len(labels) != 12 {
+		t.Fatalf("expected 12 buckets, got %d counts / %d labels", len(got), len(labels))
+	}
+	if labels[0] != "00:00" || labels[11] != "22:00" {
+		t.Fatalf("unexpected labels: %v", labels)
+	}
+	sum := 0
+	for _, n := range got {
+		sum += n
+	}
+	if sum != 3 {
+		t.Fatalf("expected 3 requests today, got %d (%v)", sum, got)
+	}
+	currentBucket := now.Hour() / 2
+	for i := currentBucket + 1; i < 12; i++ {
+		if got[i] != 0 {
+			t.Fatalf("future bucket %d should be 0, got %d", i, got[i])
+		}
+	}
+}
+
+func formatAccessLine(at time.Time, path string) string {
+	return at.Format(time.RFC3339) + " app.example.com GET " + path + " 200 0.010 1.2.3.4 127.0.0.1:8080"
+}
diff --git a/internal/logstore/reader.go b/internal/logstore/reader.go
new file mode 100644
index 0000000..4c48c36
--- /dev/null
+++ b/internal/logstore/reader.go
@@ -0,0 +1,423 @@
+package logstore
+
+import (
+	"bufio"
+	"encoding/json"
+	"fmt"
+	"os"
+	"regexp"
+	"strconv"
+	"strings"
+	"time"
+)
+
+type AccessEntry struct {
+	Time          string  `json:"time"`
+	Domain        string  `json:"domain"`
+	ServerPort    int     `json:"server_port,omitempty"`
+	Method        string  `json:"method"`
+	Path          string  `json:"path"`
+	Status        int     `json:"status"`
+	ResponseTime  float64 `json:"response_time"`
+	ClientIP      string  `json:"client_ip"`
+	Upstream      string  `json:"upstream"`
+	RequestLength int64   `json:"request_length,omitempty"`
+	BytesSent     int64   `json:"bytes_sent,omitempty"`
+}
+
+type SystemEntry struct {
+	Time    string `json:"time"`
+	Level   string `json:"level"`
+	Module  string `json:"module"`
+	Message string `json:"message"`
+}
+
+var accessRe = regexp.MustCompile(`^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\d{3})\s+([\d.]+)\s+(\S+)\s+(\S+)$`)
+var accessReWithBytes = regexp.MustCompile(`^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\d{3})\s+([\d.]+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\d+)$`)
+var accessReWithPort = regexp.MustCompile(`^(\S+)\s+(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d{3})\s+([\d.]+)\s+(\S+)\s+(\S+)$`)
+var accessReWithPortAndBytes = regexp.MustCompile(`^(\S+)\s+(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d{3})\s+([\d.]+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\d+)$`)
+
+func ParseAccess(line string) (AccessEntry, bool) {
+	return parseAccess(line)
+}
+
+func ReadAccess(path string, limit int, keyword string, status int) ([]AccessEntry, error) {
+	lines, err := tailLines(path, limit*4)
+	if err != nil {
+		return nil, err
+	}
+	var out []AccessEntry
+	for i := len(lines) - 1; i >= 0 && len(out) < limit; i-- {
+		entry, ok := parseAccess(lines[i])
+		if !ok {
+			continue
+		}
+		if keyword != "" && !strings.Contains(strings.ToLower(entry.Domain+" "+entry.Path), strings.ToLower(keyword)) {
+			continue
+		}
+		if status > 0 && entry.Status != status {
+			continue
+		}
+		out = append(out, entry)
+	}
+	return out, nil
+}
+
+func AccessHostFilter(hosts []string) LineFilter {
+	if len(hosts) == 0 {
+		return nil
+	}
+	endpoints := make([]AccessEndpoint, 0, len(hosts))
+	for _, host := range hosts {
+		endpoints = append(endpoints, AccessEndpoint{Host: host, Port: 0})
+	}
+	return AccessEndpointFilter(endpoints)
+}
+
+type AccessEndpoint struct {
+	Host string
+	Port int
+}
+
+func AccessEndpointFilter(endpoints []AccessEndpoint) LineFilter {
+	if len(endpoints) == 0 {
+		return nil
+	}
+	exact := make(map[string]bool, len(endpoints))
+	hosts := make(map[string]bool, len(endpoints))
+	for _, ep := range endpoints {
+		host := normalizeAccessHost(ep.Host)
+		if host == "" {
+			continue
+		}
+		hosts[host] = true
+		if ep.Port > 0 {
+			exact[endpointMatchKey(host, ep.Port)] = true
+		}
+	}
+	if len(hosts) == 0 {
+		return nil
+	}
+	return func(line string) bool {
+		entry, ok := parseAccess(line)
+		if !ok {
+			return false
+		}
+		host := normalizeAccessHost(entry.Domain)
+		if entry.ServerPort > 0 {
+			return exact[endpointMatchKey(host, entry.ServerPort)]
+		}
+		return hosts[host]
+	}
+}
+
+func parseAccess(line string) (AccessEntry, bool) {
+	line = strings.TrimSpace(line)
+	if m := accessReWithPortAndBytes.FindStringSubmatch(line); len(m) == 12 {
+		status, _ := strconv.Atoi(m[6])
+		rt, _ := strconv.ParseFloat(m[7], 64)
+		port, _ := strconv.Atoi(m[3])
+		reqLen, _ := strconv.ParseInt(m[10], 10, 64)
+		bytesSent, _ := strconv.ParseInt(m[11], 10, 64)
+		return AccessEntry{
+			Time:          m[1],
+			Domain:        m[2],
+			ServerPort:    port,
+			Method:        m[4],
+			Path:          m[5],
+			Status:        status,
+			ResponseTime:  rt,
+			ClientIP:      m[8],
+			Upstream:      m[9],
+			RequestLength: reqLen,
+			BytesSent:     bytesSent,
+		}, true
+	}
+	if m := accessReWithPort.FindStringSubmatch(line); len(m) == 10 {
+		status, _ := strconv.Atoi(m[6])
+		rt, _ := strconv.ParseFloat(m[7], 64)
+		port, _ := strconv.Atoi(m[3])
+		return AccessEntry{
+			Time:         m[1],
+			Domain:       m[2],
+			ServerPort:   port,
+			Method:       m[4],
+			Path:         m[5],
+			Status:       status,
+			ResponseTime: rt,
+			ClientIP:     m[8],
+			Upstream:     m[9],
+		}, true
+	}
+	if m := accessReWithBytes.FindStringSubmatch(line); len(m) == 11 {
+		status, _ := strconv.Atoi(m[5])
+		rt, _ := strconv.ParseFloat(m[6], 64)
+		reqLen, _ := strconv.ParseInt(m[9], 10, 64)
+		bytesSent, _ := strconv.ParseInt(m[10], 10, 64)
+		return AccessEntry{
+			Time:          m[1],
+			Domain:        m[2],
+			Method:        m[3],
+			Path:          m[4],
+			Status:        status,
+			ResponseTime:  rt,
+			ClientIP:      m[7],
+			Upstream:      m[8],
+			RequestLength: reqLen,
+			BytesSent:     bytesSent,
+		}, true
+	}
+	m := accessRe.FindStringSubmatch(line)
+	if len(m) != 9 {
+		return AccessEntry{}, false
+	}
+	status, _ := strconv.Atoi(m[5])
+	rt, _ := strconv.ParseFloat(m[6], 64)
+	return AccessEntry{
+		Time:         m[1],
+		Domain:       m[2],
+		Method:       m[3],
+		Path:         m[4],
+		Status:       status,
+		ResponseTime: rt,
+		ClientIP:     m[7],
+		Upstream:     m[8],
+	}, true
+}
+
+func normalizeAccessHost(host string) string {
+	host = strings.ToLower(strings.TrimSpace(host))
+	if i := strings.Index(host, ":"); i > 0 {
+		host = host[:i]
+	}
+	return host
+}
+
+func endpointMatchKey(host string, port int) string {
+	return fmt.Sprintf("%s:%d", normalizeAccessHost(host), port)
+}
+
+func ReadError(path string, limit int, keyword string) ([]string, error) {
+	lines, err := tailLines(path, limit*2)
+	if err != nil {
+		return nil, err
+	}
+	var out []string
+	for i := len(lines) - 1; i >= 0 && len(out) < limit; i-- {
+		line := strings.TrimSpace(lines[i])
+		if line == "" {
+			continue
+		}
+		if keyword != "" && !strings.Contains(strings.ToLower(line), strings.ToLower(keyword)) {
+			continue
+		}
+		out = append(out, line)
+	}
+	return out, nil
+}
+
+func isNoiseSystemEntry(entry SystemEntry) bool {
+	return entry.Module == "HTTP" && strings.EqualFold(entry.Level, "INFO")
+}
+
+func ReadSystem(path string, limit int, level string, keyword string) ([]SystemEntry, error) {
+	lines, err := tailLines(path, limit*20)
+	if err != nil {
+		return nil, err
+	}
+	var out []SystemEntry
+	for i := len(lines) - 1; i >= 0 && len(out) < limit; i-- {
+		entry, ok := parseSystem(lines[i])
+		if !ok {
+			continue
+		}
+		if isNoiseSystemEntry(entry) {
+			continue
+		}
+		if level != "" && !strings.EqualFold(entry.Level, level) {
+			continue
+		}
+		if keyword != "" && !strings.Contains(strings.ToLower(entry.Message+" "+entry.Module), strings.ToLower(keyword)) {
+			continue
+		}
+		out = append(out, entry)
+	}
+	return out, nil
+}
+
+func parseSystem(line string) (SystemEntry, bool) {
+	var raw map[string]any
+	if err := json.Unmarshal([]byte(line), &raw); err != nil {
+		return SystemEntry{}, false
+	}
+	entry := SystemEntry{
+		Time:    asString(raw["time"]),
+		Level:   asString(raw["level"]),
+		Message: asString(raw["msg"]),
+		Module:  asString(raw["module"]),
+	}
+	if entry.Time == "" {
+		entry.Time = time.Now().UTC().Format(time.RFC3339)
+	}
+	entry = localizeSystemEntry(entry, raw)
+	return entry, entry.Message != ""
+}
+
+const hourlyBucketCount = 12
+
+func isDashboardAccess(entry AccessEntry) bool {
+	switch entry.Path {
+	case "/favicon.ico", "/robots.txt":
+		return false
+	}
+	if strings.HasPrefix(entry.Path, "/fonu-errors/") {
+		return false
+	}
+	return true
+}
+
+func isSameCalendarDay(t, day time.Time) bool {
+	lt := t.In(day.Location())
+	dy, dm, dd := day.Date()
+	ty, tm, td := lt.Date()
+	return ty == dy && tm == dm && td == dd
+}
+
+// HourlyAccessCounts returns today's request counts on a fixed 0–24h axis (12 two-hour buckets).
+// Future buckets (after the current time) stay zero.
+func HourlyAccessCounts(path string) ([]int, []string) {
+	return hourlyAccessCountsForDay(path, time.Now())
+}
+
+func hourlyAccessCountsForDay(path string, day time.Time) ([]int, []string) {
+	now := time.Now()
+	currentBucket := now.Hour() / 2
+	if !isSameCalendarDay(day, now) {
+		currentBucket = hourlyBucketCount - 1
+	}
+	counts := make([]int, hourlyBucketCount)
+	labels := calendarHourlyLabels()
+
+	file, err := os.Open(path)
+	if err != nil {
+		return counts, labels
+	}
+	defer file.Close()
+
+	scanner := bufio.NewScanner(file)
+	scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+	for scanner.Scan() {
+		entry, ok := parseAccess(scanner.Text())
+		if !ok || !isDashboardAccess(entry) {
+			continue
+		}
+		t, ok := parseAccessTime(entry.Time)
+		if !ok || !isSameCalendarDay(t, day) {
+			continue
+		}
+		bucket := t.In(day.Location()).Hour() / 2
+		if bucket < 0 || bucket > currentBucket {
+			continue
+		}
+		counts[bucket]++
+	}
+	return counts, labels
+}
+
+func calendarHourlyLabels() []string {
+	labels := make([]string, hourlyBucketCount)
+	for i := 0; i < hourlyBucketCount; i++ {
+		labels[i] = fmt.Sprintf("%02d:00", i*2)
+	}
+	return labels
+}
+
+func parseAccessTime(raw string) (time.Time, bool) {
+	raw = strings.TrimSpace(raw)
+	for _, layout := range []string{
+		time.RFC3339Nano,
+		time.RFC3339,
+		"2006-01-02T15:04:05Z07:00",
+		"2006-01-02T15:04:05Z0700",
+		"2006-01-02T15:04:05",
+		"2006-01-02 15:04:05",
+	} {
+		if t, err := time.Parse(layout, raw); err == nil {
+			return t, true
+		}
+	}
+	return time.Time{}, false
+}
+
+func isLocalToday(t time.Time, now time.Time) bool {
+	return isSameCalendarDay(t, now)
+}
+
+func CountTodayAccess(path string) (total int, errors int, avgMs float64) {
+	return CountAccessForDay(path, time.Now())
+}
+
+func CountAccessForDay(path string, day time.Time) (total int, errors int, avgMs float64) {
+	file, err := os.Open(path)
+	if err != nil {
+		return 0, 0, 0
+	}
+	defer file.Close()
+
+	var sum float64
+	scanner := bufio.NewScanner(file)
+	scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+	for scanner.Scan() {
+		entry, ok := parseAccess(scanner.Text())
+		if !ok || !isDashboardAccess(entry) {
+			continue
+		}
+		t, ok := parseAccessTime(entry.Time)
+		if !ok || !isSameCalendarDay(t, day) {
+			continue
+		}
+		total++
+		sum += entry.ResponseTime
+		if entry.Status >= 400 {
+			errors++
+		}
+	}
+	if total > 0 {
+		avgMs = sum / float64(total) * 1000
+	}
+	return total, errors, avgMs
+}
+
+func tailLines(path string, max int) ([]string, error) {
+	file, err := os.Open(path)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return nil, nil
+		}
+		return nil, err
+	}
+	defer file.Close()
+
+	scanner := bufio.NewScanner(file)
+	scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+	var lines []string
+	for scanner.Scan() {
+		lines = append(lines, scanner.Text())
+		if len(lines) > max {
+			lines = lines[1:]
+		}
+	}
+	return lines, scanner.Err()
+}
+
+func asString(v any) string {
+	if v == nil {
+		return ""
+	}
+	switch t := v.(type) {
+	case string:
+		return t
+	default:
+		return ""
+	}
+}
diff --git a/internal/logstore/stream.go b/internal/logstore/stream.go
new file mode 100644
index 0000000..3063b48
--- /dev/null
+++ b/internal/logstore/stream.go
@@ -0,0 +1,78 @@
+package logstore
+
+import (
+	"bufio"
+	"context"
+	"fmt"
+	"io"
+	"os"
+	"strings"
+	"time"
+)
+
+type LineFilter func(line string) bool
+
+func StreamFile(ctx context.Context, path string, w io.Writer, flush func() error, tail int, filter LineFilter) error {
+	file, err := os.Open(path)
+	if err != nil {
+		if os.IsNotExist(err) {
+			fmt.Fprintf(w, "event: info\ndata: 日志文件尚未生成\n\n")
+			_ = flush()
+			return nil
+		}
+		return err
+	}
+	defer file.Close()
+
+	if tail > 0 {
+		lines, err := tailLines(path, tail)
+		if err != nil {
+			return err
+		}
+		for _, line := range lines {
+			if strings.TrimSpace(line) == "" {
+				continue
+			}
+			if filter != nil && !filter(line) {
+				continue
+			}
+			fmt.Fprintf(w, "event: log\ndata: %s\n\n", trimSSE(line+"\n"))
+			if err := flush(); err != nil {
+				return err
+			}
+		}
+	}
+
+	if _, err := file.Seek(0, io.SeekEnd); err != nil {
+		return err
+	}
+	reader := bufio.NewReader(file)
+	for {
+		if ctx.Err() != nil {
+			return ctx.Err()
+		}
+		line, err := reader.ReadString('\n')
+		if err != nil {
+			if err == io.EOF {
+				time.Sleep(500 * time.Millisecond)
+				continue
+			}
+			return err
+		}
+		if filter != nil && !filter(strings.TrimRight(line, "\r\n")) {
+			continue
+		}
+		fmt.Fprintf(w, "event: log\ndata: %s\n\n", trimSSE(line))
+		if err := flush(); err != nil {
+			return err
+		}
+	}
+}
+
+func trimSSE(line string) string {
+	line = line[:len(line)-1]
+	if len(line) > 0 && line[len(line)-1] == '\r' {
+		line = line[:len(line)-1]
+	}
+	return line
+}
diff --git a/internal/logstore/writer.go b/internal/logstore/writer.go
new file mode 100644
index 0000000..80b4c28
--- /dev/null
+++ b/internal/logstore/writer.go
@@ -0,0 +1,22 @@
+package logstore
+
+import (
+	"io"
+	"os"
+	"path/filepath"
+
+	"gopkg.in/natefinch/lumberjack.v2"
+)
+
+func NewAppLogWriter(logsDir string) io.Writer {
+	if err := os.MkdirAll(logsDir, 0o755); err != nil {
+		return os.Stdout
+	}
+	return &lumberjack.Logger{
+		Filename:   filepath.Join(logsDir, "app.log"),
+		MaxSize:    20,
+		MaxBackups: 10,
+		MaxAge:     30,
+		Compress:   true,
+	}
+}
diff --git a/internal/nginx/assets/429.png b/internal/nginx/assets/429.png
new file mode 100644
index 0000000..ab1071c
Binary files /dev/null and b/internal/nginx/assets/429.png differ
diff --git a/internal/nginx/assets/error.png b/internal/nginx/assets/error.png
new file mode 100644
index 0000000..f68b754
Binary files /dev/null and b/internal/nginx/assets/error.png differ
diff --git a/internal/nginx/certs.go b/internal/nginx/certs.go
new file mode 100644
index 0000000..7a5ec65
--- /dev/null
+++ b/internal/nginx/certs.go
@@ -0,0 +1,164 @@
+package nginx
+
+import (
+	"fmt"
+	"os"
+	"strings"
+
+	"github.com/fonu/fonu/internal/proxy"
+)
+
+func osReadDir(path string) ([]os.DirEntry, error) {
+	return os.ReadDir(path)
+}
+
+type CertSource struct {
+	Domains  []string
+	CertPath string
+	KeyPath  string
+}
+
+func FindCertificateForHosts(certsDir string, hostnames []string, certs []CertSource) *certFiles {
+	return findCertificateForHosts(certsDir, hostnames, certs)
+}
+
+func HasCertificateForHosts(certsDir string, hostnames []string, certs []CertSource) bool {
+	return certUsable(findCertificateForHosts(certsDir, hostnames, certs))
+}
+
+func findCertificateForHosts(certsDir string, hostnames []string, certs []CertSource) *certFiles {
+	for _, hostname := range hostnames {
+		for _, source := range certs {
+			if !hostCoveredByCert(hostname, source.Domains) {
+				continue
+			}
+			if cert := certFromSource(source); cert != nil {
+				return cert
+			}
+			for _, domain := range source.Domains {
+				if cert := certAt(certsDir, storageDirName(domain)); cert != nil && certUsable(cert) {
+					return cert
+				}
+			}
+		}
+		if cert := findCertificate(certsDir, hostname); cert != nil && certUsable(cert) {
+			return cert
+		}
+	}
+	return scanCertDirectories(certsDir, hostnames)
+}
+
+func storageDirName(domain string) string {
+	domain = strings.TrimSpace(domain)
+	if strings.HasPrefix(domain, "*.") {
+		return "wildcard." + strings.TrimPrefix(domain, "*.")
+	}
+	return domain
+}
+
+func certFromSource(source CertSource) *certFiles {
+	cert := &certFiles{
+		CertPath: absNginxPath(source.CertPath),
+		KeyPath:  absNginxPath(source.KeyPath),
+	}
+	if !certUsable(cert) {
+		return nil
+	}
+	return cert
+}
+
+func hostCoveredByCert(hostname string, domains []string) bool {
+	hostname = strings.ToLower(strings.TrimSpace(hostname))
+	for _, domain := range domains {
+		domain = strings.ToLower(strings.TrimSpace(domain))
+		if domain == "" {
+			continue
+		}
+		if domain == hostname {
+			return true
+		}
+		if strings.HasPrefix(domain, "*.") {
+			suffix := strings.TrimPrefix(domain, "*.")
+			if hostname == suffix || strings.HasSuffix(hostname, "."+suffix) {
+				return true
+			}
+		}
+	}
+	return false
+}
+
+func scanCertDirectories(certsDir string, hostnames []string) *certFiles {
+	entries, err := osReadDir(certsDir)
+	if err != nil {
+		return nil
+	}
+	for _, entry := range entries {
+		if !entry.IsDir() {
+			continue
+		}
+		pattern := dirNameToPattern(entry.Name())
+		for _, hostname := range hostnames {
+			if !hostCoveredByCert(hostname, []string{pattern, entry.Name()}) {
+				continue
+			}
+			if cert := certAt(certsDir, pattern); cert != nil && certUsable(cert) {
+				return cert
+			}
+			if cert := certAt(certsDir, entry.Name()); cert != nil && certUsable(cert) {
+				return cert
+			}
+		}
+	}
+	return nil
+}
+
+func dirNameToPattern(name string) string {
+	if strings.HasPrefix(name, "wildcard.") {
+		return "*." + strings.TrimPrefix(name, "wildcard.")
+	}
+	return name
+}
+
+func certSummary(certs []CertSource) string {
+	seen := map[string]struct{}{}
+	var domains []string
+	for _, cert := range certs {
+		for _, domain := range cert.Domains {
+			if domain == "" {
+				continue
+			}
+			if _, ok := seen[domain]; ok {
+				continue
+			}
+			seen[domain] = struct{}{}
+			domains = append(domains, domain)
+		}
+	}
+	if len(domains) == 0 {
+		return "无"
+	}
+	return strings.Join(domains, ", ")
+}
+
+func HTTPSCoverageError(hostname string, certs []CertSource) error {
+	return fmt.Errorf("域名 %s 未匹配到可用证书(当前证书覆盖:%s),请检查拼写或先在证书页导入/申请", hostname, certSummary(certs))
+}
+
+func ValidateHTTPSRules(rules []proxy.Rule, certs []CertSource, certsDir string) error {
+	for _, rule := range rules {
+		if !rule.Enabled || !rule.HTTPSEnabled {
+			continue
+		}
+		for _, group := range rule.PortGroups() {
+			cert := findCertificateForHosts(certsDir, group.Hostnames, certs)
+			if certUsable(cert) {
+				continue
+			}
+			if len(group.Hostnames) == 0 {
+				return fmt.Errorf("启用 HTTPS 的规则缺少前端域名")
+			}
+			return HTTPSCoverageError(group.Hostnames[0], certs)
+		}
+	}
+	return nil
+}
diff --git a/internal/nginx/certs_fallback_test.go b/internal/nginx/certs_fallback_test.go
new file mode 100644
index 0000000..002b677
--- /dev/null
+++ b/internal/nginx/certs_fallback_test.go
@@ -0,0 +1,35 @@
+package nginx
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+func TestFindCertificateForHostsFallsBackToDiskWhenDBPathsMissing(t *testing.T) {
+	dir := t.TempDir()
+	certDir := filepath.Join(dir, "certs", "wildcard.example.com")
+	if err := os.MkdirAll(certDir, 0o755); err != nil {
+		t.Fatal(err)
+	}
+	certPath := filepath.Join(certDir, "fullchain.pem")
+	keyPath := filepath.Join(certDir, "privatekey.pem")
+	if err := os.WriteFile(certPath, []byte("cert"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(keyPath, []byte("key"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	cert := findCertificateForHosts(filepath.Join(dir, "certs"), []string{"app.example.com"}, []CertSource{{
+		Domains:  []string{"example.com", "*.example.com"},
+		CertPath: "/data/certs/wildcard.example.com/missing.pem",
+		KeyPath:  "/data/certs/wildcard.example.com/missing-key.pem",
+	}})
+	if cert == nil || !certUsable(cert) {
+		t.Fatal("expected disk certificate fallback")
+	}
+	if filepath.Clean(cert.CertPath) != filepath.Clean(certPath) {
+		t.Fatalf("unexpected cert path: %s", cert.CertPath)
+	}
+}
diff --git a/internal/nginx/certs_test.go b/internal/nginx/certs_test.go
new file mode 100644
index 0000000..eef4d8a
--- /dev/null
+++ b/internal/nginx/certs_test.go
@@ -0,0 +1,13 @@
+package nginx
+
+import "testing"
+
+func TestHostCoveredByCert(t *testing.T) {
+	domains := []string{"example.com", "*.example.com"}
+	if !hostCoveredByCert("app.example.com", domains) {
+		t.Fatal("expected wildcard to cover subdomain")
+	}
+	if hostCoveredByCert("s.muksi.com", domains) {
+		t.Fatal("expected typo domain to be rejected")
+	}
+}
diff --git a/internal/nginx/cleanup_unix.go b/internal/nginx/cleanup_unix.go
new file mode 100644
index 0000000..6ad3dae
--- /dev/null
+++ b/internal/nginx/cleanup_unix.go
@@ -0,0 +1,32 @@
+//go:build !windows
+
+package nginx
+
+import (
+	"context"
+	"os/exec"
+	"syscall"
+	"time"
+)
+
+func prepareStartPlatform(m *Manager, ctx context.Context) {
+	stopOrphanMasters(ctx, m)
+}
+
+func stopOrphanMasters(ctx context.Context, m *Manager) {
+	pid, err := readPIDFile(m.cfg.NginxPIDFile)
+	if err == nil && isPIDAlive(pid) {
+		_ = terminateProcess(pid, syscall.SIGTERM)
+		waitProcessExit(pid, nginxTerminateWait)
+		if isPIDAlive(pid) {
+			_ = terminateProcess(pid, syscall.SIGKILL)
+			waitProcessExit(pid, time.Second)
+		}
+	}
+	_ = m.runNginx(ctx, []string{"-c", m.cfg.NginxConfigPath(), "-s", "quit"}, "")
+	removePIDFile(m.cfg.NginxPIDFile)
+	if _, err := exec.LookPath("pkill"); err == nil {
+		_ = exec.Command("pkill", "-x", "nginx").Run()
+		time.Sleep(200 * time.Millisecond)
+	}
+}
diff --git a/internal/nginx/cleanup_windows.go b/internal/nginx/cleanup_windows.go
new file mode 100644
index 0000000..b564c0c
--- /dev/null
+++ b/internal/nginx/cleanup_windows.go
@@ -0,0 +1,11 @@
+//go:build windows
+
+package nginx
+
+import "context"
+
+func prepareStartPlatform(m *Manager, ctx context.Context) {
+	stopOrphanMasters(m.cfg.NginxDir())
+	_ = m.runNginx(ctx, []string{"-c", m.cfg.NginxConfigPath(), "-s", "quit"}, "")
+	removePIDFile(m.cfg.NginxPIDFile)
+}
diff --git a/internal/nginx/comments.go b/internal/nginx/comments.go
new file mode 100644
index 0000000..44dce75
--- /dev/null
+++ b/internal/nginx/comments.go
@@ -0,0 +1,155 @@
+package nginx
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/fonu/fonu/internal/proxy"
+)
+
+func writeCommentLine(b *strings.Builder, indent int, text string) {
+	b.WriteString(strings.Repeat(" ", indent) + "# " + text + "\n")
+}
+
+func ruleDisplayName(rule proxy.Rule) string {
+	name := strings.TrimSpace(rule.Name)
+	if name != "" {
+		return name
+	}
+	if host := rule.PrimaryHost(); host != "" {
+		return host
+	}
+	return fmt.Sprintf("规则 #%d", rule.ID)
+}
+
+func writeRulePreambleComments(b *strings.Builder, rule proxy.Rule) {
+	sec := rule.Security.Normalize()
+	writeCommentLine(b, 0, fmt.Sprintf("规则:%s(ID %d)", ruleDisplayName(rule), rule.ID))
+	writeCommentLine(b, 0, fmt.Sprintf("上游:%s", rule.Upstream))
+	if sec.RateLimitEnabled() || sec.ConnLimitEnabled() {
+		writeCommentLine(b, 0, "限流 zone 在 http 块中自动生成,本段 location 内引用")
+	}
+	b.WriteString("\n")
+}
+
+func writeServerBlockHeaderComment(b *strings.Builder, rule proxy.Rule, serverNames string, port int, kind string) {
+	writeCommentLine(b, 0, fmt.Sprintf("规则:%s", ruleDisplayName(rule)))
+	writeCommentLine(b, 0, fmt.Sprintf("域名:%s(端口 %d)", serverNames, port))
+	switch kind {
+	case "https":
+		writeCommentLine(b, 0, "HTTPS 反代")
+	case "redirect":
+		writeCommentLine(b, 0, "HTTP 跳转 HTTPS")
+	default:
+		writeCommentLine(b, 0, "HTTP 反代")
+	}
+	b.WriteString("\n")
+}
+
+func writeListenComments(b *strings.Builder, port int, ssl bool, ipv4 bool, ipv6 bool) {
+	proto := "HTTP"
+	if ssl {
+		proto = "HTTPS"
+	}
+	if ipv4 {
+		writeCommentLine(b, 4, fmt.Sprintf("监听 %s IPv4 :%d", proto, port))
+	}
+	if ipv6 {
+		writeCommentLine(b, 4, fmt.Sprintf("监听 %s IPv6 :%d", proto, port))
+	}
+}
+
+func writeTLSProtocolComment(b *strings.Builder, sec proxy.SecurityConfig) {
+	if sec.TLSMin13Only {
+		writeCommentLine(b, 4, "TLS:仅 TLS 1.3")
+	} else {
+		writeCommentLine(b, 4, "TLS:TLS 1.2 / TLS 1.3")
+	}
+}
+
+func writeSecurityHeadersComment(b *strings.Builder) {
+	writeCommentLine(b, 4, "安全响应头:HSTS / X-Content-Type-Options / X-Frame-Options")
+}
+
+func writeErrorPagesComment(b *strings.Builder) {
+	writeCommentLine(b, 4, "Fonu 自定义错误页(403/404/429/5xx)")
+}
+
+func writeLocationHeaderComment(b *strings.Builder, upstream string) {
+	writeCommentLine(b, 4, fmt.Sprintf("反代 location / → %s", upstream))
+}
+
+func writeLocationSecurityComments(b *strings.Builder, rule proxy.Rule, opts GenerateOptions) {
+	sec := rule.Security.Normalize()
+
+	if len(sec.IPBlacklist) > 0 {
+		writeCommentLine(b, 8, "IP 黑名单")
+	}
+	if sec.ChinaOnly {
+		if opts.ChinaCIDRAvailable {
+			writeCommentLine(b, 8, "仅中国大陆:内网 IP 与白名单 IP 段自动豁免")
+		} else {
+			writeCommentLine(b, 8, "仅中国大陆:中国 IP 段未就绪,当前仅允许内网 IP")
+		}
+	}
+	if sec.WhitelistEnabled() {
+		writeCommentLine(b, 8, "IP 白名单模式:仅允许以下 IP,其余拒绝")
+	}
+	if sec.ConnLimitEnabled() {
+		writeCommentLine(b, 8, fmt.Sprintf("连接数限制:每 IP 最多 %d 并发", sec.ConnLimit.Max))
+	}
+	if sec.RateLimitEnabled() {
+		writeCommentLine(b, 8, fmt.Sprintf("请求限流:%d 次/秒,突发 %d(超出返回 429)", sec.RateLimit.Rate, sec.RateLimit.Burst))
+	}
+	if sec.BasicAuthEnabled() {
+		writeCommentLine(b, 8, "Basic Auth:浏览器弹窗认证")
+	}
+}
+
+func writeProxyBodyComments(b *strings.Builder, upstream string, sec proxy.SecurityConfig) {
+	writeCommentLine(b, 8, "反向代理与 WebSocket 头")
+	if sec.ProxyHostUpstream {
+		writeCommentLine(b, 8, "Host 头:使用上游地址($proxy_host)")
+	}
+	if strings.HasPrefix(upstream, "https://") {
+		if sec.ProxySSLVerifyOff {
+			writeCommentLine(b, 8, "上游 HTTPS:跳过证书校验")
+		} else {
+			writeCommentLine(b, 8, "上游 HTTPS:启用 SNI")
+		}
+	}
+}
+
+func writeLimitZoneComments(b *strings.Builder, rule proxy.Rule) {
+	sec := rule.Security.Normalize()
+	if sec.RateLimitEnabled() {
+		writeCommentLine(b, 4, fmt.Sprintf("规则 %s:请求限流 zone(%d 次/秒)", ruleDisplayName(rule), sec.RateLimit.Rate))
+	}
+	if sec.ConnLimitEnabled() {
+		writeCommentLine(b, 4, fmt.Sprintf("规则 %s:连接数 zone(每 IP %d 并发)", ruleDisplayName(rule), sec.ConnLimit.Max))
+	}
+}
+
+func writeGlobalAllowListComment(b *strings.Builder) {
+	writeCommentLine(b, 4, "全局 IP 白名单(含内网段默认放行,优先于黑名单,可豁免仅中国大陆限制)")
+}
+
+func writeGlobalDenyListComment(b *strings.Builder) {
+	writeCommentLine(b, 4, "全局 IP 黑名单(系统设置,优先于所有规则)")
+}
+
+func writeRealIPComment(b *strings.Builder, cfg TrustedProxyConfig) {
+	if len(cfg.effectiveCIDRs()) == 0 {
+		writeCommentLine(b, 4, "未启用信任代理:客户端 IP 取 $remote_addr")
+		return
+	}
+	writeCommentLine(b, 4, fmt.Sprintf("信任代理:从 %s 解析真实客户端 IP", cfg.effectiveHeader()))
+}
+
+func writeChinaGeoComment(b *strings.Builder) {
+	writeCommentLine(b, 4, "仅中国大陆:内网 IP 判定与中国 IP 段库")
+}
+
+func writeChinaBypassComment(b *strings.Builder, rule proxy.Rule) {
+	writeCommentLine(b, 4, fmt.Sprintf("规则 %s:仅中国大陆 — 白名单 IP 豁免段", ruleDisplayName(rule)))
+}
diff --git a/internal/nginx/config_validate.go b/internal/nginx/config_validate.go
new file mode 100644
index 0000000..d2e5bb0
--- /dev/null
+++ b/internal/nginx/config_validate.go
@@ -0,0 +1,75 @@
+package nginx
+
+import (
+	"fmt"
+	"strings"
+)
+
+func certUsable(cert *certFiles) bool {
+	if cert == nil {
+		return false
+	}
+	if strings.TrimSpace(cert.CertPath) == "" || strings.TrimSpace(cert.KeyPath) == "" {
+		return false
+	}
+	return fileExists(cert.CertPath) && fileExists(cert.KeyPath)
+}
+
+func assertValidSSLBlocks(content string) error {
+	inServer := false
+	depth := 0
+	serverSSL := false
+	serverCert := false
+
+	for _, raw := range strings.Split(content, "\n") {
+		line := strings.TrimSpace(raw)
+		if line == "" || strings.HasPrefix(line, "#") {
+			continue
+		}
+
+		if !inServer {
+			if strings.HasPrefix(line, "server") && strings.Contains(line, "{") {
+				inServer = true
+				depth = braceDelta(line)
+				serverSSL = false
+				serverCert = false
+			}
+			continue
+		}
+
+		depth += braceDelta(line)
+		if isSSLListenLine(line) {
+			serverSSL = true
+		}
+		if isSSLCertificateLine(line) {
+			serverCert = true
+		}
+
+		if depth <= 0 {
+			if serverSSL && !serverCert {
+				return fmt.Errorf("检测到未配置证书的 HTTPS 监听,请为域名申请或导入证书,或关闭 HTTPS")
+			}
+			inServer = false
+		}
+	}
+
+	if inServer && serverSSL && !serverCert {
+		return fmt.Errorf("检测到未配置证书的 HTTPS 监听,请为域名申请或导入证书,或关闭 HTTPS")
+	}
+	return nil
+}
+
+func braceDelta(line string) int {
+	return strings.Count(line, "{") - strings.Count(line, "}")
+}
+
+func isSSLListenLine(line string) bool {
+	if !strings.HasPrefix(line, "listen ") {
+		return false
+	}
+	return strings.Contains(line, " ssl;") || strings.HasSuffix(line, " ssl")
+}
+
+func isSSLCertificateLine(line string) bool {
+	return strings.HasPrefix(line, "ssl_certificate ") && !strings.HasPrefix(line, "ssl_certificate_key ")
+}
diff --git a/internal/nginx/config_validate_test.go b/internal/nginx/config_validate_test.go
new file mode 100644
index 0000000..fe39093
--- /dev/null
+++ b/internal/nginx/config_validate_test.go
@@ -0,0 +1,38 @@
+package nginx
+
+import "testing"
+
+func TestAssertValidSSLBlocksRejectsListenSSLWithoutCertificate(t *testing.T) {
+	content := `http {
+    server {
+        listen 8017 ssl;
+        listen [::]:8017 ssl;
+        server_name app.example.com;
+        location / {
+            proxy_pass http://127.0.0.1:1;
+        }
+    }
+}
+`
+	if err := assertValidSSLBlocks(content); err == nil {
+		t.Fatal("expected invalid ssl block to fail validation")
+	}
+}
+
+func TestAssertValidSSLBlocksAcceptsCertificateAfterLocation(t *testing.T) {
+	content := `http {
+    server {
+        listen 8017 ssl;
+        server_name app.example.com;
+        location / {
+            proxy_pass http://127.0.0.1:1;
+        }
+        ssl_certificate /data/certs/wildcard.example.com/fullchain.pem;
+        ssl_certificate_key /data/certs/wildcard.example.com/privatekey.pem;
+    }
+}
+`
+	if err := assertValidSSLBlocks(content); err != nil {
+		t.Fatalf("expected config with certificate directives to pass: %v", err)
+	}
+}
diff --git a/internal/nginx/custom.go b/internal/nginx/custom.go
new file mode 100644
index 0000000..c16c1b1
--- /dev/null
+++ b/internal/nginx/custom.go
@@ -0,0 +1,259 @@
+package nginx
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+	"time"
+
+	"github.com/fonu/fonu/internal/config"
+)
+
+const maxCustomBackups = 10
+
+type BackupEntry struct {
+	Name      string    `json:"name"`
+	CreatedAt time.Time `json:"created_at"`
+}
+
+func EnsureCustomDirs(cfg config.Config) error {
+	for _, dir := range []string{
+		cfg.NginxCustomDir(),
+		cfg.NginxRulesDir(),
+		cfg.NginxGlobalBackupsDir(),
+	} {
+		if err := os.MkdirAll(dir, 0o755); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func ReadGlobalCustom(cfg config.Config) (string, error) {
+	data, err := os.ReadFile(cfg.NginxGlobalCustomPath())
+	if err != nil {
+		if os.IsNotExist(err) {
+			return "", nil
+		}
+		return "", err
+	}
+	return string(data), nil
+}
+
+func ReadRuleCustom(cfg config.Config, ruleID int64) (string, error) {
+	data, err := os.ReadFile(cfg.NginxRuleCustomPath(ruleID))
+	if err != nil {
+		if os.IsNotExist(err) {
+			return "", nil
+		}
+		return "", err
+	}
+	return string(data), nil
+}
+
+func ruleCustomContent(cfg config.Config, ruleID int64, opts GenerateOptions) string {
+	if opts.RuleCustomOverrides != nil {
+		if override, ok := opts.RuleCustomOverrides[ruleID]; ok {
+			return override
+		}
+	}
+	content, _ := ReadRuleCustom(cfg, ruleID)
+	return content
+}
+
+func globalCustomPath(cfg config.Config, opts GenerateOptions) string {
+	if opts.GlobalCustomPathOverride != "" {
+		return opts.GlobalCustomPathOverride
+	}
+	return cfg.NginxGlobalCustomPath()
+}
+
+func globalCustomShouldInclude(cfg config.Config, opts GenerateOptions) bool {
+	if strings.TrimSpace(opts.GlobalCustomOverride) != "" {
+		return true
+	}
+	if opts.GlobalCustomPathOverride != "" {
+		data, err := os.ReadFile(opts.GlobalCustomPathOverride)
+		return err == nil && strings.TrimSpace(string(data)) != ""
+	}
+	content, _ := ReadGlobalCustom(cfg)
+	return strings.TrimSpace(content) != ""
+}
+
+func BackupFile(srcPath, backupDir string) (string, error) {
+	if err := os.MkdirAll(backupDir, 0o755); err != nil {
+		return "", err
+	}
+	data, err := os.ReadFile(srcPath)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return "", nil
+		}
+		return "", err
+	}
+	name := time.Now().UTC().Format("20060102T150405.000Z") + ".conf"
+	dst := filepath.Join(backupDir, name)
+	if err := os.WriteFile(dst, data, 0o644); err != nil {
+		return "", err
+	}
+	if err := pruneBackups(backupDir, maxCustomBackups); err != nil {
+		return "", err
+	}
+	return name, nil
+}
+
+func ListBackups(backupDir string) ([]BackupEntry, error) {
+	if err := os.MkdirAll(backupDir, 0o755); err != nil {
+		return nil, err
+	}
+	entries, err := os.ReadDir(backupDir)
+	if err != nil {
+		return nil, err
+	}
+	var out []BackupEntry
+	for _, entry := range entries {
+		if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".conf") {
+			continue
+		}
+		info, err := entry.Info()
+		if err != nil {
+			continue
+		}
+		out = append(out, BackupEntry{Name: entry.Name(), CreatedAt: info.ModTime().UTC()})
+	}
+	sort.Slice(out, func(i, j int) bool {
+		return out[i].Name > out[j].Name
+	})
+	return out, nil
+}
+
+func RestoreBackup(backupDir, backupName, targetPath string) error {
+	src := filepath.Join(backupDir, backupName)
+	if _, err := os.Stat(src); err != nil {
+		return fmt.Errorf("备份不存在")
+	}
+	if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
+		return err
+	}
+	data, err := os.ReadFile(src)
+	if err != nil {
+		return err
+	}
+	return os.WriteFile(targetPath, data, 0o644)
+}
+
+func WriteGlobalCustom(cfg config.Config, content string) error {
+	if err := EnsureCustomDirs(cfg); err != nil {
+		return err
+	}
+	return os.WriteFile(cfg.NginxGlobalCustomPath(), []byte(content), 0o644)
+}
+
+func WriteRuleCustom(cfg config.Config, ruleID int64, content string) error {
+	if err := os.MkdirAll(cfg.NginxRulesDir(), 0o755); err != nil {
+		return err
+	}
+	return os.WriteFile(cfg.NginxRuleCustomPath(ruleID), []byte(content), 0o644)
+}
+
+func RemoveRuleCustom(cfg config.Config, ruleID int64) error {
+	path := cfg.NginxRuleCustomPath(ruleID)
+	if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+		return err
+	}
+	backupDir := cfg.NginxRuleBackupsDir(ruleID)
+	_ = os.RemoveAll(backupDir)
+	return nil
+}
+
+func SaveGlobalCustomWithBackup(cfg config.Config, content string) (string, error) {
+	path := cfg.NginxGlobalCustomPath()
+	var backupName string
+	if _, err := os.Stat(path); err == nil {
+		name, err := BackupFile(path, cfg.NginxGlobalBackupsDir())
+		if err != nil {
+			return "", err
+		}
+		backupName = name
+	}
+	if err := WriteGlobalCustom(cfg, content); err != nil {
+		return "", err
+	}
+	return backupName, nil
+}
+
+func SaveRuleCustomWithBackup(cfg config.Config, ruleID int64, content string) (string, error) {
+	path := cfg.NginxRuleCustomPath(ruleID)
+	var backupName string
+	if _, err := os.Stat(path); err == nil {
+		name, err := BackupFile(path, cfg.NginxRuleBackupsDir(ruleID))
+		if err != nil {
+			return "", err
+		}
+		backupName = name
+	}
+	if err := WriteRuleCustom(cfg, ruleID, content); err != nil {
+		return "", err
+	}
+	return backupName, nil
+}
+
+func pruneBackups(dir string, keep int) error {
+	entries, err := ListBackups(dir)
+	if err != nil {
+		return err
+	}
+	for i := keep; i < len(entries); i++ {
+		_ = os.Remove(filepath.Join(dir, entries[i].Name))
+	}
+	return nil
+}
+
+func DefaultGlobalHTTPSnippet() string {
+	return `# 全局 http 块片段(自动生成)
+# 上传大小限制(全局默认 50M,单条规则可在 server 块覆盖)
+client_max_body_size 50m;
+`
+}
+
+func GenerateGlobalFramework(cfg config.Config) string {
+	return fmt.Sprintf(`# 以下部分由 Fonu 自动生成,请勿在此编辑
+
+# 主进程
+worker_processes auto;
+# 错误日志
+error_log %s warn;
+# PID 文件
+pid %s;
+
+# 事件模型
+events {
+    worker_connections 1024;
+}
+
+http {
+    # MIME 类型
+    include       %s;
+    default_type  application/octet-stream;
+
+    # 访问日志格式与路径
+    log_format fonu_access '$time_iso8601 $host $server_port $request_method $request_uri $status $request_time $remote_addr $upstream_addr $request_length $bytes_sent';
+    access_log %s fonu_access;
+
+    # 传输优化
+    sendfile on;
+    keepalive_timeout 65;
+
+    # WebSocket 升级映射
+    # map $http_upgrade $connection_upgrade { ... }
+
+    # limit_req_zone / geo / real_ip 等由规则与系统设置动态生成
+    # 全局自定义片段插入在上述动态块之后、各 server {} 之前
+}
+`, absNginxPath(filepath.Join(cfg.LogsDir(), "error.log")),
+		absNginxPath(cfg.NginxPIDFile),
+		absNginxPath(cfg.NginxMimeTypes),
+		absNginxPath(filepath.Join(cfg.LogsDir(), "access.log")))
+}
diff --git a/internal/nginx/custom_test.go b/internal/nginx/custom_test.go
new file mode 100644
index 0000000..7beea67
--- /dev/null
+++ b/internal/nginx/custom_test.go
@@ -0,0 +1,136 @@
+package nginx
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/fonu/fonu/internal/config"
+	"github.com/fonu/fonu/internal/proxy"
+)
+
+func TestGenerateRuleBlocks(t *testing.T) {
+	cfg := config.Config{DataDir: t.TempDir()}
+	rule := proxy.Rule{
+		ID:           1,
+		Upstream:     "http://127.0.0.1:8080",
+		ListenPort:   8011,
+		ListenIPv4:   true,
+		Hosts:        []proxy.Host{{Hostname: "example.com"}},
+		HTTPSEnabled: false,
+		Enabled:      true,
+	}
+	out, err := GenerateRuleBlocks(cfg, rule, nil, GenerateOptions{})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !strings.Contains(out, "server_name example.com") {
+		t.Fatalf("missing server_name: %s", out)
+	}
+	if !strings.Contains(out, "proxy_pass http://127.0.0.1:8080") {
+		t.Fatalf("missing proxy_pass: %s", out)
+	}
+}
+
+func TestGenerateWithCustomRuleAndGlobal(t *testing.T) {
+	dir := t.TempDir()
+	cfg := config.Config{DataDir: dir}
+	if err := EnsureCustomDirs(cfg); err != nil {
+		t.Fatal(err)
+	}
+	customGlobal := "gzip on;\n"
+	if err := WriteGlobalCustom(cfg, customGlobal); err != nil {
+		t.Fatal(err)
+	}
+	customRule := "server {\n    listen 8011;\n    server_name custom.test;\n    return 444;\n}\n"
+	if err := WriteRuleCustom(cfg, 7, customRule); err != nil {
+		t.Fatal(err)
+	}
+
+	rules := []proxy.Rule{{
+		ID:         7,
+		Upstream:   "http://127.0.0.1:1",
+		ListenPort: 8011,
+		ListenIPv4: true,
+		Hosts:      []proxy.Host{{Hostname: "ignored.test"}},
+		Enabled:    true,
+		NginxMode:  "custom",
+	}}
+	out, err := Generate(cfg, rules, nil, GenerateOptions{})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !strings.Contains(out, "include "+strings.ReplaceAll(absNginxPath(cfg.NginxGlobalCustomPath()), "\\", "/")) &&
+		!strings.Contains(out, absNginxPath(cfg.NginxGlobalCustomPath())) {
+		t.Fatalf("missing global include: %s", out)
+	}
+	if !strings.Contains(out, "server_name custom.test") {
+		t.Fatalf("missing custom rule block: %s", out)
+	}
+	if strings.Contains(out, "ignored.test") {
+		t.Fatalf("auto-generated host leaked into custom mode: %s", out)
+	}
+}
+
+func TestBackupFileKeepsRecent(t *testing.T) {
+	dir := t.TempDir()
+	src := filepath.Join(dir, "src.conf")
+	if err := os.WriteFile(src, []byte("v1"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	backupDir := filepath.Join(dir, "backups")
+	if _, err := BackupFile(src, backupDir); err != nil {
+		t.Fatal(err)
+	}
+	time.Sleep(2 * time.Millisecond)
+	if err := os.WriteFile(src, []byte("v2"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := BackupFile(src, backupDir); err != nil {
+		t.Fatal(err)
+	}
+	entries, err := ListBackups(backupDir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(entries) != 2 {
+		t.Fatalf("backups=%d want 2", len(entries))
+	}
+	if err := RestoreBackup(backupDir, entries[1].Name, src); err != nil {
+		t.Fatal(err)
+	}
+	data, err := os.ReadFile(src)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if string(data) != "v1" {
+		t.Fatalf("restored=%q want v1", string(data))
+	}
+}
+
+func TestSaveRuleCustomWithBackup(t *testing.T) {
+	dir := t.TempDir()
+	cfg := config.Config{DataDir: dir}
+	if err := WriteRuleCustom(cfg, 3, "first"); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := SaveRuleCustomWithBackup(cfg, 3, "second"); err != nil {
+		t.Fatal(err)
+	}
+	data, err := os.ReadFile(cfg.NginxRuleCustomPath(3))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if string(data) != "second" {
+		t.Fatalf("content=%q", string(data))
+	}
+	backups, err := ListBackups(cfg.NginxRuleBackupsDir(3))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(backups) != 1 {
+		t.Fatalf("backups=%d want 1", len(backups))
+	}
+}
diff --git a/internal/nginx/error_assets_embed.go b/internal/nginx/error_assets_embed.go
new file mode 100644
index 0000000..41f88d2
--- /dev/null
+++ b/internal/nginx/error_assets_embed.go
@@ -0,0 +1,8 @@
+package nginx
+
+import "embed"
+
+// Bundled for Docker/runtime when web/assets is not on disk.
+//
+//go:embed assets/error.png assets/429.png
+var errorAssetFS embed.FS
diff --git a/internal/nginx/errors.go b/internal/nginx/errors.go
new file mode 100644
index 0000000..23a6243
--- /dev/null
+++ b/internal/nginx/errors.go
@@ -0,0 +1,146 @@
+package nginx
+
+import (
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+
+	"github.com/fonu/fonu/internal/config"
+)
+
+func EnsureErrorPages(cfg config.Config) error {
+	dir := cfg.ErrorsDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		return err
+	}
+	ensureNginxReadable(dir, true)
+
+	pages := []struct {
+		file, sprite, bgPos, title, msg string
+		code                            int
+	}{
+		{"403.html", "s403", "0% 0%", "访问被拒绝", "你没有权限访问此资源。", 403},
+		{"404.html", "s404", "100% 0%", "页面不存在", "请求的地址不存在或已被移除。", 404},
+		{"500.html", "s500", "0% 100%", "服务异常", "服务器遇到错误,请稍后再试。", 500},
+		{"503.html", "s503", "100% 100%", "服务维护中", "服务暂时不可用,请稍后再试。", 503},
+		{"429.html", "429", "", "请求过于频繁", "你的请求次数过多,请稍后再试。", 429},
+	}
+	for _, p := range pages {
+		html := errorPageHTML(p.code, p.title, p.msg, p.sprite, p.bgPos)
+		target := filepath.Join(dir, p.file)
+		if err := os.WriteFile(target, []byte(html), 0o644); err != nil {
+			return err
+		}
+		ensureNginxReadable(target, false)
+	}
+
+	for _, name := range []string{"error.png", "429.png"} {
+		if err := ensureErrorAsset(dir, name); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func ensureErrorAsset(dir, name string) error {
+	target := filepath.Join(dir, name)
+	if src := findErrorAsset(name); src != "" {
+		if err := copyFileIfChanged(src, target); err != nil {
+			return err
+		}
+	} else if err := writeEmbeddedErrorAsset(name, target); err != nil {
+		return err
+	}
+	if err := verifyErrorAsset(target); err != nil {
+		return err
+	}
+	ensureNginxReadable(target, false)
+	return nil
+}
+
+func verifyErrorAsset(path string) error {
+	info, err := os.Stat(path)
+	if err != nil {
+		return fmt.Errorf("error asset missing after write: %s: %w", path, err)
+	}
+	if info.Size() == 0 {
+		return fmt.Errorf("error asset is empty: %s", path)
+	}
+	return nil
+}
+
+func writeEmbeddedErrorAsset(name, target string) error {
+	data, err := errorAssetFS.ReadFile("assets/" + name)
+	if err != nil {
+		return fmt.Errorf("embedded error asset %s: %w", name, err)
+	}
+	return os.WriteFile(target, data, 0o644)
+}
+
+func findErrorAsset(name string) string {
+	candidates := []string{
+		filepath.Join("web", "assets", "image", name),
+		filepath.Join("/app", "web", "assets", "image", name),
+		filepath.Join("internal", "nginx", "assets", name),
+	}
+	for _, c := range candidates {
+		if _, err := os.Stat(c); err == nil {
+			return c
+		}
+	}
+	return ""
+}
+
+func copyFileIfChanged(src, dst string) error {
+	in, err := os.Open(src)
+	if err != nil {
+		return err
+	}
+	defer in.Close()
+
+	out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
+	if err != nil {
+		return err
+	}
+	defer out.Close()
+
+	_, err = io.Copy(out, in)
+	return err
+}
+
+func errorPageHTML(code int, title, message, sprite, bgPos string) string {
+	var imgBlock string
+	if sprite == "429" {
+		imgBlock = ``
+	} else {
+		imgBlock = fmt.Sprintf(``, code)
+	}
+	return fmt.Sprintf(`
+
+
+
+
+%d - %s
+
+
+
+
+%s +
%d
+

%s

+

%s

+
+ +`, code, title, bgPos, imgBlock, code, title, message) +} diff --git a/internal/nginx/errors_test.go b/internal/nginx/errors_test.go new file mode 100644 index 0000000..67aa8d9 --- /dev/null +++ b/internal/nginx/errors_test.go @@ -0,0 +1,45 @@ +package nginx + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/fonu/fonu/internal/config" +) + +func TestEnsureErrorPagesWritesAssets(t *testing.T) { + dir := t.TempDir() + cfg := config.Config{DataDir: dir} + + if err := EnsureErrorPages(cfg); err != nil { + t.Fatal(err) + } + + errorsDir := cfg.ErrorsDir() + for _, name := range []string{"403.html", "404.html", "error.png", "429.png"} { + path := filepath.Join(errorsDir, name) + info, err := os.Stat(path) + if err != nil { + t.Fatalf("missing %s: %v", name, err) + } + if info.Size() == 0 { + t.Fatalf("%s is empty", name) + } + } + + html, err := os.ReadFile(filepath.Join(errorsDir, "403.html")) + if err != nil { + t.Fatal(err) + } + body := string(html) + for _, want := range []string{ + "/fonu-errors/error.png", + "background:url(/fonu-errors/error.png)", + } { + if !strings.Contains(body, want) { + t.Fatalf("403.html missing %q:\n%s", want, body) + } + } +} diff --git a/internal/nginx/fileperm.go b/internal/nginx/fileperm.go new file mode 100644 index 0000000..6fc4cb0 --- /dev/null +++ b/internal/nginx/fileperm.go @@ -0,0 +1,21 @@ +package nginx + +import ( + "os" + "runtime" +) + +// Debian/Ubuntu nginx worker user. +const nginxWorkerUID = 33 +const nginxWorkerGID = 33 + +func ensureNginxReadable(path string, dir bool) { + mode := os.FileMode(0o644) + if dir { + mode = 0o755 + } + _ = os.Chmod(path, mode) + if runtime.GOOS == "linux" { + _ = os.Chown(path, nginxWorkerUID, nginxWorkerGID) + } +} diff --git a/internal/nginx/generate.go b/internal/nginx/generate.go new file mode 100644 index 0000000..48e1018 --- /dev/null +++ b/internal/nginx/generate.go @@ -0,0 +1,273 @@ +package nginx + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/fonu/fonu/internal/config" + "github.com/fonu/fonu/internal/proxy" +) + +func Generate(cfg config.Config, rules []proxy.Rule, certs []CertSource, opts GenerateOptions) (string, error) { + var b strings.Builder + + b.WriteString(`worker_processes auto; +error_log ` + absNginxPath(filepath.Join(cfg.LogsDir(), "error.log")) + ` warn; +pid ` + absNginxPath(cfg.NginxPIDFile) + `; + +events { + worker_connections 1024; +} + +http { + include ` + absNginxPath(cfg.NginxMimeTypes) + `; + default_type application/octet-stream; + + log_format fonu_access '$time_iso8601 $host $server_port $request_method $request_uri $status $request_time $remote_addr $upstream_addr $request_length $bytes_sent'; + access_log ` + absNginxPath(filepath.Join(cfg.LogsDir(), "access.log")) + ` fonu_access; + + sendfile on; + keepalive_timeout 65; + + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + limit_req_status 429; + limit_conn_status 503; + +`) + + writeRealIPDirectives(&b, opts.TrustedProxy) + writeGlobalAllowList(&b, opts.GlobalIPWhitelist) + writeGlobalDenyList(&b, opts.GlobalIPBlacklist) + writeLimitZones(&b, rules) + writeChinaGeoBlocks(&b, cfg, opts, rules) + writeChinaOnlyBypassGeo(&b, rules, opts) + writeGlobalCustomInclude(&b, cfg, opts) + + for _, rule := range rules { + if !rule.Enabled { + continue + } + if rule.NginxMode == "custom" { + if err := writeCustomRuleBlocks(&b, cfg, rule.ID, opts); err != nil { + return "", err + } + continue + } + if err := writeRuleBlocks(&b, cfg, rule, certs, opts); err != nil { + return "", err + } + } + + b.WriteString("}\n") + content := b.String() + if err := assertValidSSLBlocks(content); err != nil { + return "", err + } + return content, nil +} + +func GenerateRuleBlocks(cfg config.Config, rule proxy.Rule, certs []CertSource, opts GenerateOptions) (string, error) { + var b strings.Builder + writeRulePreambleComments(&b, rule) + if err := writeRuleBlocks(&b, cfg, rule, certs, opts); err != nil { + return "", err + } + content := b.String() + if err := assertValidSSLBlocks(content); err != nil { + return "", err + } + return content, nil +} + +func writeGlobalCustomInclude(b *strings.Builder, cfg config.Config, opts GenerateOptions) { + if strings.TrimSpace(opts.GlobalCustomOverride) != "" { + writeIndentedHTTPSnippet(b, opts.GlobalCustomOverride) + return + } + if globalCustomShouldInclude(cfg, opts) { + path := globalCustomPath(cfg, opts) + b.WriteString(fmt.Sprintf(" include %s;\n\n", absNginxPath(path))) + return + } + writeIndentedHTTPSnippet(b, DefaultGlobalHTTPSnippet()) +} + +func writeIndentedHTTPSnippet(b *strings.Builder, content string) { + for _, line := range strings.Split(strings.TrimRight(content, "\n"), "\n") { + b.WriteString(" " + line + "\n") + } + b.WriteString("\n") +} + +func writeCustomRuleBlocks(b *strings.Builder, cfg config.Config, ruleID int64, opts GenerateOptions) error { + content := strings.TrimSpace(ruleCustomContent(cfg, ruleID, opts)) + if content == "" { + return fmt.Errorf("规则 %d 的自定义 Nginx 配置为空", ruleID) + } + if !strings.HasSuffix(content, "\n") { + content += "\n" + } + b.WriteString(content) + return nil +} + +func writeRuleBlocks(b *strings.Builder, cfg config.Config, rule proxy.Rule, certs []CertSource, opts GenerateOptions) error { + for _, group := range rule.PortGroups() { + if len(group.Hostnames) == 0 { + continue + } + serverNames := strings.Join(group.Hostnames, " ") + cert := findCertificateForHosts(cfg.CertsDir(), group.Hostnames, certs) + useHTTPS := rule.HTTPSEnabled && certUsable(cert) + + wroteBlock := false + if useHTTPS { + if !appendSSLServerBlock(b, cfg, group.Port, serverNames, cert, rule, opts) { + return fmt.Errorf("HTTPS 证书文件不可用,请重新导入证书或关闭 HTTPS") + } + wroteBlock = true + } + if rule.HTTPRedirect && useHTTPS { + writeServerBlockHeaderComment(b, rule, serverNames, group.Port, "redirect") + b.WriteString("server {\n") + writeListenComments(b, group.Port, false, rule.ListenIPv4, rule.ListenIPv6) + writeListenDirectives(b, group.Port, false, rule.ListenIPv4, rule.ListenIPv6) + b.WriteString(fmt.Sprintf(" server_name %s;\n", serverNames)) + writeErrorPages(b, cfg) + writeCommentLine(b, 4, "301 跳转至 HTTPS") + b.WriteString(" return 301 https://$host:$server_port$request_uri;\n") + b.WriteString("}\n") + wroteBlock = true + } + + if !wroteBlock { + writeServerBlockHeaderComment(b, rule, serverNames, group.Port, "http") + b.WriteString("server {\n") + writeListenComments(b, group.Port, false, rule.ListenIPv4, rule.ListenIPv6) + writeListenDirectives(b, group.Port, false, rule.ListenIPv4, rule.ListenIPv6) + b.WriteString(fmt.Sprintf(" server_name %s;\n", serverNames)) + writeErrorPages(b, cfg) + writeLocationWithSecurity(b, cfg, rule, opts) + b.WriteString("}\n") + } + } + return nil +} + +func appendSSLServerBlock(b *strings.Builder, cfg config.Config, port int, serverNames string, cert *certFiles, rule proxy.Rule, opts GenerateOptions) bool { + if !certUsable(cert) { + return false + } + var block strings.Builder + writeServerBlockHeaderComment(&block, rule, serverNames, port, "https") + block.WriteString("server {\n") + writeListenComments(&block, port, true, rule.ListenIPv4, rule.ListenIPv6) + writeListenDirectives(&block, port, true, rule.ListenIPv4, rule.ListenIPv6) + block.WriteString(fmt.Sprintf(" server_name %s;\n\n", serverNames)) + writeCommentLine(&block, 4, "SSL 证书") + block.WriteString(fmt.Sprintf(" ssl_certificate %s;\n", absNginxPath(cert.CertPath))) + block.WriteString(fmt.Sprintf(" ssl_certificate_key %s;\n", absNginxPath(cert.KeyPath))) + writeTLSProtocols(&block, rule.Security) + writeServerSecurityHeaders(&block, rule.Security, true) + writeErrorPages(&block, cfg) + writeLocationWithSecurity(&block, cfg, rule, opts) + block.WriteString("}\n") + b.WriteString(block.String()) + return true +} + +func writeLocationWithSecurity(b *strings.Builder, cfg config.Config, rule proxy.Rule, opts GenerateOptions) { + writeLocationHeaderComment(b, rule.Upstream) + b.WriteString(" location / {\n") + writeLocationSecurity(b, cfg, rule, opts) + writeProxyLocationBody(b, rule.Upstream, rule.Security) + b.WriteString(" }\n") +} + +func writeProxyLocationBody(b *strings.Builder, upstream string, sec proxy.SecurityConfig) { + sec = sec.Normalize() + writeProxyBodyComments(b, upstream, sec) + hostHeader := "$host" + if sec.ProxyHostUpstream { + hostHeader = "$proxy_host" + } + sslBlock := "" + if strings.HasPrefix(upstream, "https://") { + if sec.ProxySSLVerifyOff { + sslBlock = ` proxy_ssl_verify off; + proxy_ssl_server_name on; +` + } else { + sslBlock = ` proxy_ssl_server_name on; +` + } + } + b.WriteString(fmt.Sprintf(`%s proxy_pass %s; + proxy_http_version 1.1; + proxy_set_header Host %s; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header X-Real-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_buffering off; + proxy_read_timeout 3600s; +`, sslBlock, upstream, hostHeader)) +} + +func writeListenDirectives(b *strings.Builder, port int, ssl bool, ipv4 bool, ipv6 bool) { + sslSuffix := "" + if ssl { + sslSuffix = " ssl" + } + if ipv4 { + b.WriteString(fmt.Sprintf(" listen %d%s;\n", port, sslSuffix)) + } + if ipv6 { + b.WriteString(fmt.Sprintf(" listen [::]:%d%s;\n", port, sslSuffix)) + } +} + +type certFiles struct { + CertPath string + KeyPath string +} + +func findCertificate(certsDir, domain string) *certFiles { + parts := strings.Split(domain, ".") + for i := 0; i < len(parts)-1; i++ { + wildcard := "*." + strings.Join(parts[i:], ".") + if c := certAt(certsDir, wildcard); c != nil { + return c + } + } + if c := certAt(certsDir, domain); c != nil { + return c + } + return nil +} + +func certAt(certsDir, name string) *certFiles { + safeName := strings.ReplaceAll(name, "*", "wildcard") + dir := filepath.Join(certsDir, safeName) + certPath := filepath.Join(dir, "fullchain.pem") + keyPath := filepath.Join(dir, "privatekey.pem") + if fileExists(certPath) && fileExists(keyPath) { + return &certFiles{CertPath: absNginxPath(certPath), KeyPath: absNginxPath(keyPath)} + } + return nil +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/internal/nginx/generate_china_bypass_test.go b/internal/nginx/generate_china_bypass_test.go new file mode 100644 index 0000000..9002d36 --- /dev/null +++ b/internal/nginx/generate_china_bypass_test.go @@ -0,0 +1,56 @@ +package nginx + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/fonu/fonu/internal/config" + "github.com/fonu/fonu/internal/proxy" +) + +func TestChinaOnlyBypassGeoWithoutUserWhitelist(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "mime.types"), []byte("types { text/html html; }\n"), 0o644); err != nil { + t.Fatal(err) + } + cidrPath := filepath.Join(dir, "nginx", "china_cidr.conf") + if err := os.MkdirAll(filepath.Dir(cidrPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(cidrPath, []byte("1.0.0.0/8 1;\n"), 0o644); err != nil { + t.Fatal(err) + } + + cfg := config.Config{ + DataDir: dir, + NginxPIDFile: filepath.Join(dir, "nginx.pid"), + NginxMimeTypes: filepath.Join(dir, "mime.types"), + } + rules := []proxy.Rule{{ + ID: 3, + ListenPort: 8080, + ListenIPv4: true, + Hosts: []proxy.Host{{Hostname: "app.example.com"}}, + Upstream: "http://127.0.0.1:3000", + Enabled: true, + HTTPSEnabled: false, + Security: proxy.SecurityConfig{ChinaOnly: true}, + }} + + content, err := Generate(cfg, rules, nil, GenerateOptions{ChinaCIDRAvailable: true}) + if err != nil { + t.Fatalf("generate: %v", err) + } + for _, want := range []string{ + "geo $fonu_client_ip $fonu_rule_3_china_bypass", + "192.168.0.0/16 1;", + "10.0.0.0/8 1;", + "if ($fonu_rule_3_china_bypass = 1)", + } { + if !strings.Contains(content, want) { + t.Fatalf("missing %q in:\n%s", want, content) + } + } +} diff --git a/internal/nginx/generate_china_cidr_path_test.go b/internal/nginx/generate_china_cidr_path_test.go new file mode 100644 index 0000000..c45b094 --- /dev/null +++ b/internal/nginx/generate_china_cidr_path_test.go @@ -0,0 +1,59 @@ +package nginx + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/fonu/fonu/internal/config" + "github.com/fonu/fonu/internal/proxy" +) + +func TestGenerateChinaCIDRUsesFinalPathWhenOverrideCleared(t *testing.T) { + dir := t.TempDir() + cidrDir := filepath.Join(dir, "nginx") + if err := os.MkdirAll(cidrDir, 0o755); err != nil { + t.Fatal(err) + } + finalPath := filepath.Join(cidrDir, "china_cidr.conf") + if err := os.WriteFile(finalPath, []byte("1.0.0.0/8 1;\n"), 0o644); err != nil { + t.Fatal(err) + } + + cfg := config.Config{ + DataDir: dir, + NginxPIDFile: filepath.Join(dir, "nginx.pid"), + NginxMimeTypes: filepath.Join(dir, "mime.types"), + } + if err := os.WriteFile(cfg.NginxMimeTypes, []byte("types { text/html html; }\n"), 0o644); err != nil { + t.Fatal(err) + } + + rules := []proxy.Rule{{ + ID: 1, + ListenPort: 8080, + ListenIPv4: true, + Hosts: []proxy.Host{{Hostname: "app.example.com"}}, + Upstream: "http://127.0.0.1:3000", + Enabled: true, + Security: proxy.SecurityConfig{ChinaOnly: true}, + }} + + opts := GenerateOptions{ + ChinaCIDRAvailable: true, + ChinaCIDRPathOverride: filepath.Join(cidrDir, "china_cidr.conf.tmp"), + } + opts.ChinaCIDRPathOverride = "" + + content, err := Generate(cfg, rules, nil, opts) + if err != nil { + t.Fatal(err) + } + if strings.Contains(content, "china_cidr.conf.tmp") { + t.Fatalf("expected final china cidr path, got tmp reference:\n%s", content) + } + if !strings.Contains(content, "china_cidr.conf") { + t.Fatalf("missing china_cidr.conf include:\n%s", content) + } +} diff --git a/internal/nginx/generate_comments_test.go b/internal/nginx/generate_comments_test.go new file mode 100644 index 0000000..0d1b61e --- /dev/null +++ b/internal/nginx/generate_comments_test.go @@ -0,0 +1,49 @@ +package nginx + +import ( + "strings" + "testing" + + "github.com/fonu/fonu/internal/config" + "github.com/fonu/fonu/internal/proxy" +) + +func TestGenerateRuleBlocksIncludesComments(t *testing.T) { + cfg := config.Config{DataDir: t.TempDir()} + rule := proxy.Rule{ + ID: 3, + Name: "fonu", + Upstream: "http://127.0.0.1:8080", + ListenPort: 8011, + ListenIPv4: true, + Hosts: []proxy.Host{{Hostname: "s.example.com"}}, + HTTPSEnabled: false, + Enabled: true, + Security: proxy.SecurityConfig{ + IPBlacklist: []string{"192.168.8.2"}, + IPWhitelistMode: true, + IPWhitelist: []string{"192.168.8.1"}, + RateLimit: &proxy.RateLimitConfig{Enabled: true, Rate: 10, Burst: 20}, + ConnLimit: &proxy.ConnLimitConfig{Enabled: true, Max: 20}, + }, + } + out, err := GenerateRuleBlocks(cfg, rule, nil, GenerateOptions{}) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "# 规则:fonu", + "# 上游:http://127.0.0.1:8080", + "# 限流 zone 在 http 块中自动生成", + "# IP 黑名单", + "# IP 白名单模式", + "# 请求限流:10 次/秒,突发 20", + "# 连接数限制:每 IP 最多 20 并发", + "# 反代 location /", + "# 反向代理与 WebSocket 头", + } { + if !strings.Contains(out, want) { + t.Fatalf("missing comment %q in:\n%s", want, out) + } + } +} diff --git a/internal/nginx/generate_https_no_cert_test.go b/internal/nginx/generate_https_no_cert_test.go new file mode 100644 index 0000000..bad142b --- /dev/null +++ b/internal/nginx/generate_https_no_cert_test.go @@ -0,0 +1,38 @@ +package nginx + +import ( + "strings" + "testing" + + "github.com/fonu/fonu/internal/config" + "github.com/fonu/fonu/internal/proxy" +) + +func TestGenerateHTTPSWithoutCertUsesHTTPOnly(t *testing.T) { + cfg := config.Config{ + DataDir: t.TempDir(), + NginxPIDFile: t.TempDir() + "/nginx.pid", + NginxMimeTypes: t.TempDir() + "/mime.types", + } + rules := []proxy.Rule{{ + ListenPort: 8011, + ListenIPv4: true, + ListenIPv6: true, + Hosts: []proxy.Host{{Hostname: "app.example.com"}}, + Upstream: "http://192.168.8.3:6893", + HTTPSEnabled: true, + HTTPRedirect: true, + Enabled: true, + }} + + content, err := Generate(cfg, rules, nil, GenerateOptions{}) + if err != nil { + t.Fatalf("generate failed: %v", err) + } + if strings.Contains(content, " ssl") { + t.Fatalf("expected no ssl listen without certificate:\n%s", content) + } + if !strings.Contains(content, "proxy_pass http://192.168.8.3:6893") { + t.Fatalf("expected http proxy fallback:\n%s", content) + } +} diff --git a/internal/nginx/generate_https_with_cert_test.go b/internal/nginx/generate_https_with_cert_test.go new file mode 100644 index 0000000..3852b33 --- /dev/null +++ b/internal/nginx/generate_https_with_cert_test.go @@ -0,0 +1,56 @@ +package nginx + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/fonu/fonu/internal/config" + "github.com/fonu/fonu/internal/proxy" +) + +func TestGenerateHTTPSWithCertIncludesCertificateDirectives(t *testing.T) { + dir := t.TempDir() + certDir := filepath.Join(dir, "certs", "wildcard.example.com") + if err := os.MkdirAll(certDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(certDir, "fullchain.pem"), []byte("cert"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(certDir, "privatekey.pem"), []byte("key"), 0o644); err != nil { + t.Fatal(err) + } + + cfg := config.Config{ + DataDir: dir, + NginxPIDFile: filepath.Join(dir, "nginx.pid"), + NginxMimeTypes: filepath.Join(dir, "mime.types"), + } + rules := []proxy.Rule{{ + ListenPort: 8011, + ListenIPv4: true, + ListenIPv6: true, + Hosts: []proxy.Host{{Hostname: "app.example.com"}}, + Upstream: "http://192.168.8.3:6893", + HTTPSEnabled: true, + HTTPRedirect: true, + Enabled: true, + }} + + content, err := Generate(cfg, rules, []CertSource{{ + Domains: []string{"*.example.com", "example.com"}, + CertPath: filepath.Join(certDir, "fullchain.pem"), + KeyPath: filepath.Join(certDir, "privatekey.pem"), + }}, GenerateOptions{}) + if err != nil { + t.Fatalf("generate failed: %v", err) + } + if !strings.Contains(content, "listen 8011 ssl;") { + t.Fatalf("expected ssl listen:\n%s", content) + } + if !strings.Contains(content, "ssl_certificate") || !strings.Contains(content, "ssl_certificate_key") { + t.Fatalf("expected certificate directives:\n%s", content) + } +} diff --git a/internal/nginx/generate_multi_test.go b/internal/nginx/generate_multi_test.go new file mode 100644 index 0000000..3d6039b --- /dev/null +++ b/internal/nginx/generate_multi_test.go @@ -0,0 +1,51 @@ +package nginx + +import ( + "strings" + "testing" + + "github.com/fonu/fonu/internal/config" + "github.com/fonu/fonu/internal/proxy" +) + +func TestGenerateMultiHostCustomPort(t *testing.T) { + cfg := config.Config{ + DataDir: t.TempDir(), + NginxPIDFile: t.TempDir() + "/nginx.pid", + NginxMimeTypes: t.TempDir() + "/mime.types", + } + port6893 := 6893 + rules := []proxy.Rule{{ + ID: 1, + Upstream: "http://192.168.8.3:6893", + ListenPort: 8011, + ListenIPv4: true, + ListenIPv6: true, + Hosts: []proxy.Host{ + {Hostname: "1.example.com"}, + {Hostname: "2.example.com"}, + {Hostname: "example.org"}, + {Hostname: "example.com", ListenPort: &port6893}, + }, + HTTPSEnabled: false, + HTTPRedirect: false, + Enabled: true, + }} + + content, err := Generate(cfg, rules, nil, GenerateOptions{}) + if err != nil { + t.Fatalf("generate failed: %v", err) + } + for _, want := range []string{ + "listen 8011;", + "listen [::]:8011;", + "server_name 1.example.com 2.example.com example.org", + "listen 6893;", + "listen [::]:6893;", + "server_name example.com", + } { + if !strings.Contains(content, want) { + t.Fatalf("missing %q in generated config:\n%s", want, content) + } + } +} diff --git a/internal/nginx/generate_order_test.go b/internal/nginx/generate_order_test.go new file mode 100644 index 0000000..36010fb --- /dev/null +++ b/internal/nginx/generate_order_test.go @@ -0,0 +1,61 @@ +package nginx + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/fonu/fonu/internal/config" + "github.com/fonu/fonu/internal/proxy" +) + +func TestGenerateHTTPSBlockBeforeHTTPRedirect(t *testing.T) { + dir := t.TempDir() + certDir := filepath.Join(dir, "certs", "wildcard.example.com") + if err := os.MkdirAll(certDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(certDir, "fullchain.pem"), []byte("cert"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(certDir, "privatekey.pem"), []byte("key"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "mime.types"), []byte("types { text/html html; }\n"), 0o644); err != nil { + t.Fatal(err) + } + + cfg := config.Config{ + DataDir: dir, + NginxPIDFile: filepath.Join(dir, "nginx", "nginx.pid"), + NginxMimeTypes: filepath.Join(dir, "mime.types"), + } + rules := []proxy.Rule{{ + ListenPort: 8017, + ListenIPv4: true, + Hosts: []proxy.Host{{Hostname: "app.example.com"}}, + Upstream: "http://127.0.0.1:1", + HTTPSEnabled: true, + HTTPRedirect: true, + Enabled: true, + }} + certs := []CertSource{{ + Domains: []string{"*.example.com"}, + CertPath: filepath.Join(certDir, "fullchain.pem"), + KeyPath: filepath.Join(certDir, "privatekey.pem"), + }} + + content, err := Generate(cfg, rules, certs, GenerateOptions{}) + if err != nil { + t.Fatalf("generate: %v", err) + } + sslIdx := strings.Index(content, "listen 8017 ssl;") + redirectIdx := strings.Index(content, "return 301 https://") + if sslIdx < 0 || redirectIdx < 0 { + t.Fatalf("missing expected directives:\n%s", content) + } + if sslIdx > redirectIdx { + t.Fatalf("expected ssl server block before http redirect:\n%s", content) + } +} diff --git a/internal/nginx/generate_security_test.go b/internal/nginx/generate_security_test.go new file mode 100644 index 0000000..df84d19 --- /dev/null +++ b/internal/nginx/generate_security_test.go @@ -0,0 +1,95 @@ +package nginx + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/fonu/fonu/internal/config" + "github.com/fonu/fonu/internal/proxy" +) + +func TestGenerateSecurityDirectives(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "mime.types"), []byte("types { text/html html; }\n"), 0o644); err != nil { + t.Fatal(err) + } + cidrPath := filepath.Join(dir, "nginx", "china_cidr.conf") + if err := os.MkdirAll(filepath.Dir(cidrPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(cidrPath, []byte("1.0.0.0/8 1;\n"), 0o644); err != nil { + t.Fatal(err) + } + + cfg := config.Config{ + DataDir: dir, + NginxPIDFile: filepath.Join(dir, "nginx.pid"), + NginxMimeTypes: filepath.Join(dir, "mime.types"), + } + rules := []proxy.Rule{{ + ID: 7, + ListenPort: 8080, + ListenIPv4: true, + Hosts: []proxy.Host{{Hostname: "app.example.com"}}, + Upstream: "http://127.0.0.1:3000", + Enabled: true, + HTTPSEnabled: false, + Security: proxy.SecurityConfig{ + IPBlacklist: []string{"1.2.3.4", "10.0.0.0/8"}, + IPWhitelistMode: true, + IPWhitelist: []string{"192.168.1.0/24"}, + ChinaOnly: true, + BasicAuth: &proxy.BasicAuthConfig{ + Enabled: true, + Username: "user", + PasswordHash: "$2a$10$abcdefghijklmnopqrstuv", + }, + RateLimit: &proxy.RateLimitConfig{Enabled: true, Rate: 10, Burst: 20}, + ConnLimit: &proxy.ConnLimitConfig{Enabled: true, Max: 20}, + }, + }} + + opts := GenerateOptions{ + GlobalIPWhitelist: []string{"203.0.113.10"}, + GlobalIPBlacklist: []string{"8.8.8.8"}, + ChinaCIDRAvailable: true, + } + content, err := Generate(cfg, rules, nil, opts) + if err != nil { + t.Fatalf("generate: %v", err) + } + checks := []string{ + "allow 10.0.0.0/8;", + "allow 203.0.113.10;", + "deny 8.8.8.8;", + "limit_req_zone $binary_remote_addr zone=fonu_rule_7_req", + "limit_conn_zone $binary_remote_addr zone=fonu_rule_7_conn", + "deny 1.2.3.4;", + "geo $fonu_client_ip $fonu_is_private", + "geo $fonu_client_ip $fonu_rule_7_china_bypass", + "192.168.0.0/16 1;", + "192.168.1.0/24 1;", + "203.0.113.10 1;", + "set $fonu_china_block 0;", + "if ($fonu_is_private = 1)", + "if ($fonu_rule_7_china_bypass = 1)", + "allow 192.168.1.0/24;", + "deny all;", + "auth_basic", + "limit_req zone=fonu_rule_7_req burst=20", + "limit_conn fonu_rule_7_conn 20", + "error_page 403 /fonu-errors/403.html", + "location = /fonu-errors/error.png", + "location = /fonu-errors/429.png", + "X-Forwarded-Host $host", + "X-Forwarded-Port $server_port", + "X-Real-Proto $scheme", + } + for _, want := range checks { + if !strings.Contains(content, want) { + t.Fatalf("missing %q in:\n%s", want, content) + } + } +} diff --git a/internal/nginx/generate_seed_mix_test.go b/internal/nginx/generate_seed_mix_test.go new file mode 100644 index 0000000..3d6618b --- /dev/null +++ b/internal/nginx/generate_seed_mix_test.go @@ -0,0 +1,45 @@ +package nginx + +import ( + "os" + "path/filepath" + "testing" + + "github.com/fonu/fonu/internal/config" + "github.com/fonu/fonu/internal/proxy" +) + +func TestGenerateMixedRulesWithoutCerts(t *testing.T) { + dir := t.TempDir() + mimePath := filepath.Join(dir, "mime.types") + if err := os.WriteFile(mimePath, []byte("types { text/html html; }\n"), 0o644); err != nil { + t.Fatal(err) + } + + cfg := config.Config{ + DataDir: dir, + NginxPIDFile: filepath.Join(dir, "nginx.pid"), + NginxMimeTypes: mimePath, + } + + rules := []proxy.Rule{ + { + ID: 1, Upstream: "http://192.168.1.10:5666", ListenPort: 443, ListenIPv4: true, + Hosts: []proxy.Host{{Hostname: "nas.example.com"}}, + HTTPSEnabled: true, HTTPRedirect: true, Enabled: true, + }, + { + ID: 2, Upstream: "http://192.168.8.3:6893", ListenPort: 8011, ListenIPv4: true, ListenIPv6: true, + Hosts: []proxy.Host{{Hostname: "app.example.com"}}, + HTTPSEnabled: true, HTTPRedirect: true, Enabled: true, + }, + } + + content, err := Generate(cfg, rules, nil, GenerateOptions{}) + if err != nil { + t.Fatalf("generate failed: %v", err) + } + if err := assertValidSSLBlocks(content); err != nil { + t.Fatalf("invalid ssl blocks: %v\n%s", err, content) + } +} diff --git a/internal/nginx/generate_test.go b/internal/nginx/generate_test.go new file mode 100644 index 0000000..a63c38e --- /dev/null +++ b/internal/nginx/generate_test.go @@ -0,0 +1,44 @@ +package nginx + +import ( + "strings" + "testing" + + "github.com/fonu/fonu/internal/config" + "github.com/fonu/fonu/internal/proxy" +) + +func TestGenerateIncludesWebSocketAndProxy(t *testing.T) { + cfg := config.Config{ + DataDir: t.TempDir(), + NginxPIDFile: t.TempDir() + "/nginx.pid", + NginxMimeTypes: t.TempDir() + "/mime.types", + } + rules := []proxy.Rule{{ + ID: 1, + Upstream: "http://192.168.1.10:5666", + ListenPort: 80, + ListenIPv4: true, + Hosts: []proxy.Host{{Hostname: "nas.example.com"}}, + HTTPSEnabled: false, + HTTPRedirect: false, + Enabled: true, + }} + + content, err := Generate(cfg, rules, nil, GenerateOptions{}) + if err != nil { + t.Fatalf("generate failed: %v", err) + } + for _, want := range []string{ + "client_max_body_size 50m;", + "listen 80;", + "server_name nas.example.com", + "proxy_pass http://192.168.1.10:5666", + "proxy_set_header Upgrade $http_upgrade", + "proxy_buffering off", + } { + if !strings.Contains(content, want) { + t.Fatalf("missing %q in generated config", want) + } + } +} diff --git a/internal/nginx/global_framework_test.go b/internal/nginx/global_framework_test.go new file mode 100644 index 0000000..2d8bb43 --- /dev/null +++ b/internal/nginx/global_framework_test.go @@ -0,0 +1,37 @@ +package nginx + +import ( + "strings" + "testing" + + "github.com/fonu/fonu/internal/config" +) + +func TestGenerateGlobalFrameworkIncludesComments(t *testing.T) { + cfg := config.Config{DataDir: t.TempDir()} + out := GenerateGlobalFramework(cfg) + for _, want := range []string{ + "# 以下部分由 Fonu 自动生成", + "# 主进程", + "# MIME 类型", + "# 访问日志格式与路径", + "# 传输优化", + } { + if !strings.Contains(out, want) { + t.Fatalf("missing comment %q in:\n%s", want, out) + } + } + if strings.Contains(out, "client_max_body_size") { + t.Fatalf("framework should not include client_max_body_size:\n%s", out) + } +} + +func TestDefaultGlobalHTTPSnippet(t *testing.T) { + out := DefaultGlobalHTTPSnippet() + if !strings.Contains(out, "client_max_body_size 50m;") { + t.Fatalf("missing upload limit in snippet:\n%s", out) + } + if !strings.Contains(out, "# 上传大小限制") { + t.Fatalf("missing comment in snippet:\n%s", out) + } +} diff --git a/internal/nginx/htpasswd.go b/internal/nginx/htpasswd.go new file mode 100644 index 0000000..65e407a --- /dev/null +++ b/internal/nginx/htpasswd.go @@ -0,0 +1,70 @@ +package nginx + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/fonu/fonu/internal/config" + "github.com/fonu/fonu/internal/proxy" +) + +// nginxHtpasswdHash normalizes bcrypt hashes for nginx/apr auth_basic. +// Go's bcrypt uses the $2a$ prefix; some nginx builds only accept $2y$. +func nginxHtpasswdHash(hash string) string { + if strings.HasPrefix(hash, "$2a$") { + return "$2y$" + hash[4:] + } + return hash +} + +func HtpasswdDir(cfg config.Config) string { + return filepath.Join(cfg.NginxDir(), "htpasswd") +} + +func HtpasswdPath(cfg config.Config, ruleID int64) string { + return filepath.Join(HtpasswdDir(cfg), fmt.Sprintf("rule_%d.conf", ruleID)) +} + +func SyncHtpasswdFiles(cfg config.Config, rules []proxy.Rule) error { + dir := HtpasswdDir(cfg) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + ensureNginxReadable(dir, true) + + active := map[int64]bool{} + for _, rule := range rules { + path := HtpasswdPath(cfg, rule.ID) + if rule.Security.BasicAuthEnabled() { + active[rule.ID] = true + line := fmt.Sprintf("%s:%s\n", rule.Security.BasicAuth.Username, nginxHtpasswdHash(rule.Security.BasicAuth.PasswordHash)) + if err := os.WriteFile(path, []byte(line), 0o644); err != nil { + return err + } + ensureNginxReadable(path, false) + } + } + + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + var id int64 + if _, err := fmt.Sscanf(entry.Name(), "rule_%d.conf", &id); err != nil { + continue + } + if !active[id] { + _ = os.Remove(filepath.Join(dir, entry.Name())) + } + } + return nil +} diff --git a/internal/nginx/htpasswd_hash_test.go b/internal/nginx/htpasswd_hash_test.go new file mode 100644 index 0000000..a245e10 --- /dev/null +++ b/internal/nginx/htpasswd_hash_test.go @@ -0,0 +1,19 @@ +package nginx + +import "testing" + +func TestNginxHtpasswdHashNormalizesBcryptPrefix(t *testing.T) { + in := "$2a$10$abcdefghijklmnopqrstuv" + got := nginxHtpasswdHash(in) + want := "$2y$10$abcdefghijklmnopqrstuv" + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} + +func TestNginxHtpasswdHashLeavesOtherFormats(t *testing.T) { + in := "$apr1$abc123" + if got := nginxHtpasswdHash(in); got != in { + t.Fatalf("got %q want %q", got, in) + } +} diff --git a/internal/nginx/htpasswd_test.go b/internal/nginx/htpasswd_test.go new file mode 100644 index 0000000..dbe3fed --- /dev/null +++ b/internal/nginx/htpasswd_test.go @@ -0,0 +1,60 @@ +package nginx + +import ( + "os" + "runtime" + "strings" + "testing" + + "github.com/fonu/fonu/internal/config" + "github.com/fonu/fonu/internal/proxy" +) + +func TestSyncHtpasswdFilesWritesBasicAuth(t *testing.T) { + cfg := config.Config{DataDir: t.TempDir()} + rules := []proxy.Rule{{ + ID: 3, + Enabled: true, + Security: proxy.SecurityConfig{ + BasicAuth: &proxy.BasicAuthConfig{ + Enabled: true, + Username: "admin", + PasswordHash: "$2a$10$abcdefghijklmnopqrstuvwx.yz012345678901234567890", + }, + }, + }} + + if err := SyncHtpasswdFiles(cfg, rules); err != nil { + t.Fatalf("sync htpasswd: %v", err) + } + + path := HtpasswdPath(cfg, 3) + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read htpasswd: %v", err) + } + if !strings.HasPrefix(string(data), "admin:$2y$10$") { + t.Fatalf("unexpected htpasswd content: %q", data) + } + if runtime.GOOS == "linux" { + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat htpasswd: %v", err) + } + if info.Mode().Perm() != 0o644 { + t.Fatalf("expected htpasswd mode 0644, got %o", info.Mode().Perm()) + } + } + + stripped := rules[0].Security.ForAPI() + rules[0].Security = stripped + if rules[0].Security.BasicAuthEnabled() { + t.Fatal("ForAPI should strip password hash for API responses") + } + if err := SyncHtpasswdFiles(cfg, rules); err != nil { + t.Fatalf("sync stripped htpasswd: %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("expected htpasswd removed when hash stripped, stat err=%v", err) + } +} diff --git a/internal/nginx/nginx.go b/internal/nginx/nginx.go new file mode 100644 index 0000000..149b8c5 --- /dev/null +++ b/internal/nginx/nginx.go @@ -0,0 +1,417 @@ +package nginx + +import ( + "bytes" + "context" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/fonu/fonu/internal/config" + "github.com/fonu/fonu/internal/proxy" +) + +const ( + nginxCmdTimeout = 8 * time.Second + nginxStartWait = 5 * time.Second + nginxTerminateWait = 3 * time.Second +) + +type Manager struct { + cfg config.Config + logger *slog.Logger + opts GenerateOptions +} + +type ApplyResult struct { + Reloaded bool + Message string +} + +func NewManager(cfg config.Config, logger *slog.Logger) *Manager { + return &Manager{cfg: cfg, logger: logger} +} + +func (m *Manager) SetGenerateOptions(opts GenerateOptions) { + m.opts = opts +} + +func (m *Manager) CurrentOptions() GenerateOptions { + return m.opts +} + +func (m *Manager) EnsureDirs() error { + for _, dir := range []string{m.cfg.NginxDir(), m.cfg.LogsDir(), m.cfg.CertsDir()} { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + } + workDirs := []string{ + "logs", + "client_body_temp", + "proxy_temp", + "fastcgi_temp", + "uwsgi_temp", + "scgi_temp", + filepath.Join("temp", "client_body_temp"), + filepath.Join("temp", "proxy_temp"), + filepath.Join("temp", "fastcgi_temp"), + filepath.Join("temp", "uwsgi_temp"), + filepath.Join("temp", "scgi_temp"), + } + for _, name := range workDirs { + if err := os.MkdirAll(filepath.Join(m.cfg.NginxDir(), name), 0o755); err != nil { + return err + } + } + return nil +} + +func (m *Manager) nginxGlobalArgs() []string { + prefix := absNginxPath(m.cfg.NginxDir()) + if !strings.HasSuffix(prefix, "/") { + prefix += "/" + } + errorLog := absNginxPath(filepath.Join(m.cfg.LogsDir(), "error.log")) + return []string{"-p", prefix, "-e", errorLog} +} + +func (m *Manager) available() bool { + _, err := exec.LookPath(m.cfg.NginxBin) + return err == nil +} + +func (m *Manager) Apply(ctx context.Context, rules []proxy.Rule, certs []CertSource) (ApplyResult, error) { + if err := m.EnsureDirs(); err != nil { + return ApplyResult{}, err + } + + if err := EnsureErrorPages(m.cfg); err != nil { + return ApplyResult{}, err + } + if err := EnsureCustomDirs(m.cfg); err != nil { + return ApplyResult{}, err + } + if err := SyncHtpasswdFiles(m.cfg, rules); err != nil { + return ApplyResult{}, err + } + + opts := m.opts + // Path override is only for pre-rename validation during China CIDR updates. + opts.ChinaCIDRPathOverride = "" + opts.ChinaCIDRAvailable = chinaCIDRExists(m.cfg) + + content, err := Generate(m.cfg, rules, certs, opts) + if err != nil { + return ApplyResult{}, err + } + + tmpPath := m.cfg.NginxConfigPath() + ".tmp" + if err := os.WriteFile(tmpPath, []byte(content), 0o644); err != nil { + return ApplyResult{}, err + } + + if err := ValidateConfigSyntax(content); err != nil { + _ = os.Remove(tmpPath) + return ApplyResult{}, err + } + if m.available() { + if err := m.validate(ctx, tmpPath); err != nil { + _ = os.Remove(tmpPath) + return ApplyResult{}, err + } + } else { + m.logger.Warn("nginx binary not found, applied syntax-only validation", "bin", m.cfg.NginxBin) + } + + currentPath := m.cfg.NginxConfigPath() + backupPath := currentPath + ".bak" + if _, err := os.Stat(currentPath); err == nil { + if err := copyFile(currentPath, backupPath); err != nil { + _ = os.Remove(tmpPath) + return ApplyResult{}, err + } + } + + if err := os.Rename(tmpPath, currentPath); err != nil { + _ = os.Remove(tmpPath) + return ApplyResult{}, err + } + + if !m.available() { + return ApplyResult{Message: "Nginx 未安装,配置已保存"}, nil + } + + running, err := m.isRunning() + if err != nil { + return ApplyResult{}, err + } + + if !running { + if err := m.start(ctx); err != nil { + return ApplyResult{}, err + } + return ApplyResult{Reloaded: true, Message: "Nginx 已启动"}, nil + } + + if err := m.reload(ctx); err != nil { + m.logger.Warn("nginx reload failed, trying restart", "error", err) + if err := m.forceRestart(ctx); err != nil { + return ApplyResult{}, err + } + return ApplyResult{Reloaded: true, Message: "Nginx 已重新启动"}, nil + } + return ApplyResult{Reloaded: true, Message: "Nginx 已重载"}, nil +} + +func (m *Manager) ValidateOnly(ctx context.Context, rules []proxy.Rule, certs []CertSource) error { + return m.ValidateOnlyWithOptions(ctx, rules, certs, m.opts) +} + +func (m *Manager) ValidateOnlyWithOptions(ctx context.Context, rules []proxy.Rule, certs []CertSource, opts GenerateOptions) error { + if opts.ChinaCIDRPathOverride != "" { + opts.ChinaCIDRAvailable = true + } else { + opts.ChinaCIDRAvailable = chinaCIDRExists(m.cfg) + } + content, err := Generate(m.cfg, rules, certs, opts) + if err != nil { + return err + } + if err := ValidateConfigSyntax(content); err != nil { + return err + } + if !m.available() { + m.logger.Warn("nginx binary not found, syntax-only validation", "bin", m.cfg.NginxBin) + return nil + } + tmpPath := filepath.Join(m.cfg.NginxDir(), "validate.tmp.conf") + if err := os.WriteFile(tmpPath, []byte(content), 0o644); err != nil { + return err + } + defer os.Remove(tmpPath) + return m.validate(ctx, tmpPath) +} + +func (m *Manager) validate(ctx context.Context, configPath string) error { + tmpErr := filepath.Join(m.cfg.NginxDir(), "validate.error.log") + args := []string{"-e", absNginxPath(tmpErr), "-t", "-c", configPath} + err := m.runNginx(ctx, args, "Nginx 配置校验失败") + _ = os.Remove(tmpErr) + return err +} + +func (m *Manager) start(ctx context.Context) error { + running, err := m.isRunning() + if err != nil { + return err + } + if running { + return m.reload(ctx) + } + if m.trySilentReload(ctx) { + m.logger.Info("nginx reloaded existing master") + return nil + } + prepareStartPlatform(m, ctx) + if err := m.runNginxStart(ctx, []string{"-c", m.cfg.NginxConfigPath()}, "Nginx 启动失败"); err != nil { + if strings.Contains(err.Error(), "conflicting server name") && m.trySilentReload(ctx) { + m.logger.Warn("nginx start skipped duplicate master, reloaded instead") + return nil + } + return err + } + m.logger.Info("nginx started") + return nil +} + +func (m *Manager) trySilentReload(ctx context.Context) bool { + ctx, cancel := context.WithTimeout(ctx, nginxCmdTimeout) + defer cancel() + + fullArgs := append(m.nginxGlobalArgs(), "-c", m.cfg.NginxConfigPath(), "-s", "reload") + cmd := exec.CommandContext(ctx, m.cfg.NginxBin, fullArgs...) + return cmd.Run() == nil +} + +func (m *Manager) reload(ctx context.Context) error { + pid, err := readPIDFile(m.cfg.NginxPIDFile) + if err == nil && isPIDAlive(pid) { + if err := reloadProcess(pid); err == nil { + m.logger.Info("nginx reloaded") + return nil + } + m.logger.Warn("nginx signal reload failed, falling back to cli", "pid", pid, "error", err) + } else if err != nil && !os.IsNotExist(err) { + removePIDFile(m.cfg.NginxPIDFile) + } + + if err := m.runNginx(ctx, []string{"-c", m.cfg.NginxConfigPath(), "-s", "reload"}, "Nginx 重载失败"); err != nil { + return err + } + m.logger.Info("nginx reloaded") + return nil +} + +func (m *Manager) forceRestart(ctx context.Context) error { + m.terminateMaster() + removePIDFile(m.cfg.NginxPIDFile) + prepareStartPlatform(m, ctx) + return m.start(ctx) +} + +func (m *Manager) terminateMaster() { + pid, err := readPIDFile(m.cfg.NginxPIDFile) + if err != nil || !isPIDAlive(pid) { + return + } + _ = terminateProcess(pid, syscall.SIGTERM) + waitProcessExit(pid, nginxTerminateWait) + if isPIDAlive(pid) { + _ = terminateProcess(pid, syscall.SIGKILL) + waitProcessExit(pid, time.Second) + } +} + +func (m *Manager) Stop(ctx context.Context) error { + if !m.isRunningQuick() { + return nil + } + if err := m.runNginx(ctx, []string{"-c", m.cfg.NginxConfigPath(), "-s", "quit"}, "Nginx 停止失败"); err != nil { + return err + } + m.logger.Info("nginx stopped") + return nil +} + +func (m *Manager) runNginx(ctx context.Context, args []string, prefix string) error { + ctx, cancel := context.WithTimeout(ctx, nginxCmdTimeout) + defer cancel() + + fullArgs := append(m.nginxGlobalArgs(), args...) + cmd := exec.CommandContext(ctx, m.cfg.NginxBin, fullArgs...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + if ctx.Err() == context.DeadlineExceeded { + msg = "操作超时" + } + m.logger.Error("nginx command failed", "args", strings.Join(fullArgs, " "), "error", msg) + return fmt.Errorf("%s:%s", prefix, sanitizeNginxError(msg)) + } + return nil +} + +// runNginxStart launches nginx without waiting for the master process to exit. +// On Windows the master stays in the foreground and would block cmd.Run(). +func (m *Manager) runNginxStart(ctx context.Context, args []string, prefix string) error { + fullArgs := append(m.nginxGlobalArgs(), args...) + cmd := exec.Command(m.cfg.NginxBin, fullArgs...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Start(); err != nil { + return fmt.Errorf("%s:%s", prefix, err.Error()) + } + + waitDone := make(chan error, 1) + go func() { waitDone <- cmd.Wait() }() + + deadline := time.Now().Add(nginxStartWait) + for time.Now().Before(deadline) { + if running, err := m.isRunning(); err == nil && running { + return nil + } + select { + case err := <-waitDone: + if running, checkErr := m.isRunning(); checkErr == nil && running { + return nil + } + msg := strings.TrimSpace(stderr.String()) + if msg == "" && err != nil { + msg = err.Error() + } + if msg == "" { + msg = "进程已退出" + } + m.logger.Error("nginx start failed", "args", strings.Join(fullArgs, " "), "error", msg) + return fmt.Errorf("%s:%s", prefix, sanitizeNginxError(msg)) + default: + } + if ctx.Err() != nil { + return fmt.Errorf("%s:%s", prefix, "操作超时") + } + time.Sleep(100 * time.Millisecond) + } + if running, err := m.isRunning(); err == nil && running { + return nil + } + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = "未检测到运行中的进程" + } + return fmt.Errorf("%s:%s", prefix, sanitizeNginxError(msg)) +} + +func (m *Manager) isRunning() (bool, error) { + pid, err := readPIDFile(m.cfg.NginxPIDFile) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + removePIDFile(m.cfg.NginxPIDFile) + return false, nil + } + if !isPIDAlive(pid) { + removePIDFile(m.cfg.NginxPIDFile) + return false, nil + } + return true, nil +} + +func (m *Manager) isRunningQuick() bool { + ok, _ := m.isRunning() + return ok +} + +func (m *Manager) ValidateContent(ctx context.Context, content string) error { + if err := ValidateConfigSyntax(content); err != nil { + return err + } + if !m.available() { + m.logger.Warn("nginx binary not found, syntax-only validation", "bin", m.cfg.NginxBin) + return nil + } + tmpPath := filepath.Join(m.cfg.NginxDir(), "validate.tmp.conf") + if err := os.WriteFile(tmpPath, []byte(content), 0o644); err != nil { + return err + } + defer os.Remove(tmpPath) + return m.validate(ctx, tmpPath) +} + +func ChinaCIDRExists(cfg config.Config) bool { + return chinaCIDRExists(cfg) +} + +func copyFile(src, dst string) error { + data, err := os.ReadFile(src) + if err != nil { + return err + } + return os.WriteFile(dst, data, 0o644) +} + +func sanitizeNginxError(msg string) string { + msg = strings.ReplaceAll(msg, "\r\n", " ") + msg = strings.ReplaceAll(msg, "\n", " ") + return strings.TrimSpace(msg) +} diff --git a/internal/nginx/nginx_integration_test.go b/internal/nginx/nginx_integration_test.go new file mode 100644 index 0000000..509d7c5 --- /dev/null +++ b/internal/nginx/nginx_integration_test.go @@ -0,0 +1,58 @@ +package nginx + +import ( + "context" + "log/slog" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/fonu/fonu/internal/config" + "github.com/fonu/fonu/internal/proxy" +) + +func TestApplyWithNginxIfAvailable(t *testing.T) { + if _, err := exec.LookPath("nginx"); err != nil { + t.Skip("nginx not installed") + } + + dir := t.TempDir() + mimePath := filepath.Join(dir, "mime.types") + if err := os.WriteFile(mimePath, []byte("types { text/html html; }\n"), 0o644); err != nil { + t.Fatalf("write mime.types: %v", err) + } + + cfg := config.Config{ + DataDir: dir, + NginxBin: "nginx", + NginxPIDFile: filepath.Join(dir, "nginx", "nginx.pid"), + NginxMimeTypes: mimePath, + } + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + mgr := NewManager(cfg, logger) + + rules := []proxy.Rule{{ + ID: 1, + Upstream: "http://127.0.0.1:65535", + ListenPort: 18080, + ListenIPv4: true, + Hosts: []proxy.Host{{Hostname: "nas.example.com"}}, + HTTPSEnabled: false, + HTTPRedirect: false, + Enabled: true, + }} + + if _, err := mgr.Apply(context.Background(), rules, nil); err != nil { + t.Fatalf("apply failed: %v", err) + } + defer mgr.Stop(context.Background()) + + tmp := filepath.Join(dir, "nginx", "bad.conf") + if err := os.WriteFile(tmp, []byte("this is not valid nginx config"), 0o644); err != nil { + t.Fatalf("write bad config: %v", err) + } + if err := mgr.validate(context.Background(), tmp); err == nil { + t.Fatal("expected invalid config to fail validation") + } +} diff --git a/internal/nginx/nginx_unavailable_test.go b/internal/nginx/nginx_unavailable_test.go new file mode 100644 index 0000000..953ce3f --- /dev/null +++ b/internal/nginx/nginx_unavailable_test.go @@ -0,0 +1,50 @@ +package nginx + +import ( + "context" + "log/slog" + "os" + "path/filepath" + "testing" + + "github.com/fonu/fonu/internal/config" + "github.com/fonu/fonu/internal/proxy" +) + +func TestApplyWithoutNginxBinary(t *testing.T) { + dir := t.TempDir() + mimePath := filepath.Join(dir, "mime.types") + if err := os.WriteFile(mimePath, []byte("types { text/html html; }\n"), 0o644); err != nil { + t.Fatalf("write mime.types: %v", err) + } + + cfg := config.Config{ + DataDir: dir, + NginxBin: "fonu-missing-nginx-binary", + NginxPIDFile: filepath.Join(dir, "nginx", "nginx.pid"), + NginxMimeTypes: mimePath, + } + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + mgr := NewManager(cfg, logger) + + rules := []proxy.Rule{{ + ID: 1, + Upstream: "http://127.0.0.1:65535", + ListenPort: 8011, + ListenIPv4: true, + Hosts: []proxy.Host{{Hostname: "nas.example.com"}}, + HTTPSEnabled: false, + Enabled: true, + }} + + result, err := mgr.Apply(context.Background(), rules, nil) + if err != nil { + t.Fatalf("apply failed: %v", err) + } + if result.Message == "" { + t.Fatal("expected skip message") + } + if _, err := os.Stat(cfg.NginxConfigPath()); err != nil { + t.Fatalf("config not written: %v", err) + } +} diff --git a/internal/nginx/paths.go b/internal/nginx/paths.go new file mode 100644 index 0000000..8e515d1 --- /dev/null +++ b/internal/nginx/paths.go @@ -0,0 +1,14 @@ +package nginx + +import "path/filepath" + +func absNginxPath(path string) string { + if path == "" { + return "" + } + abs, err := filepath.Abs(path) + if err != nil { + return filepath.ToSlash(path) + } + return filepath.ToSlash(abs) +} diff --git a/internal/nginx/pid.go b/internal/nginx/pid.go new file mode 100644 index 0000000..30176af --- /dev/null +++ b/internal/nginx/pid.go @@ -0,0 +1,39 @@ +package nginx + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" +) + +func readPIDFile(path string) (int, error) { + data, err := os.ReadFile(path) + if err != nil { + return 0, err + } + pidStr := strings.TrimSpace(string(data)) + if pidStr == "" { + return 0, fmt.Errorf("empty pid file") + } + pid, err := strconv.Atoi(pidStr) + if err != nil || pid <= 0 { + return 0, fmt.Errorf("invalid pid: %q", pidStr) + } + return pid, nil +} + +func removePIDFile(path string) { + _ = os.Remove(path) +} + +func waitProcessExit(pid int, timeout time.Duration) { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if !isPIDAlive(pid) { + return + } + time.Sleep(100 * time.Millisecond) + } +} diff --git a/internal/nginx/pid_alive_test.go b/internal/nginx/pid_alive_test.go new file mode 100644 index 0000000..4b62565 --- /dev/null +++ b/internal/nginx/pid_alive_test.go @@ -0,0 +1,13 @@ +package nginx + +import ( + "os" + "testing" +) + +func TestIsPIDAliveCurrentProcess(t *testing.T) { + pid := os.Getpid() + if !isPIDAlive(pid) { + t.Fatalf("expected current pid %d to be alive", pid) + } +} diff --git a/internal/nginx/pid_export.go b/internal/nginx/pid_export.go new file mode 100644 index 0000000..0e01587 --- /dev/null +++ b/internal/nginx/pid_export.go @@ -0,0 +1,11 @@ +package nginx + +// ReadPID reads a PID from the given pid file. +func ReadPID(path string) (int, error) { + return readPIDFile(path) +} + +// IsPIDAlive reports whether the process with the given PID is running. +func IsPIDAlive(pid int) bool { + return isPIDAlive(pid) +} diff --git a/internal/nginx/pid_other.go b/internal/nginx/pid_other.go new file mode 100644 index 0000000..46a8716 --- /dev/null +++ b/internal/nginx/pid_other.go @@ -0,0 +1,25 @@ +//go:build !linux && !darwin && !freebsd && !windows + +package nginx + +import ( + "fmt" + "os" + "syscall" +) + +func reloadProcess(pid int) error { + return fmt.Errorf("signal reload unsupported") +} + +func terminateProcess(pid int, sig syscall.Signal) error { + return fmt.Errorf("signal terminate unsupported") +} + +func isPIDAlive(pid int) bool { + proc, err := os.FindProcess(pid) + if err != nil { + return false + } + return proc.Signal(syscall.Signal(0)) == nil +} diff --git a/internal/nginx/pid_test.go b/internal/nginx/pid_test.go new file mode 100644 index 0000000..57fdacc --- /dev/null +++ b/internal/nginx/pid_test.go @@ -0,0 +1,53 @@ +package nginx + +import ( + "os" + "path/filepath" + "testing" + + "github.com/fonu/fonu/internal/config" +) + +func TestReadPIDFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "nginx.pid") + + if _, err := readPIDFile(path); !os.IsNotExist(err) { + t.Fatalf("expected not exist, got %v", err) + } + + if err := os.WriteFile(path, []byte(""), 0o644); err != nil { + t.Fatal(err) + } + if _, err := readPIDFile(path); err == nil { + t.Fatal("expected error for empty pid file") + } + + if err := os.WriteFile(path, []byte("12345\n"), 0o644); err != nil { + t.Fatal(err) + } + pid, err := readPIDFile(path) + if err != nil || pid != 12345 { + t.Fatalf("expected pid 12345, got %d err=%v", pid, err) + } +} + +func TestIsRunningStalePIDFile(t *testing.T) { + dir := t.TempDir() + pidPath := filepath.Join(dir, "nginx.pid") + if err := os.WriteFile(pidPath, []byte(""), 0o644); err != nil { + t.Fatal(err) + } + + mgr := &Manager{cfg: config.Config{NginxPIDFile: pidPath}} + running, err := mgr.isRunning() + if err != nil { + t.Fatal(err) + } + if running { + t.Fatal("expected stale empty pid file to be treated as not running") + } + if _, err := os.Stat(pidPath); !os.IsNotExist(err) { + t.Fatal("expected stale pid file to be removed") + } +} diff --git a/internal/nginx/pid_unix.go b/internal/nginx/pid_unix.go new file mode 100644 index 0000000..505ca38 --- /dev/null +++ b/internal/nginx/pid_unix.go @@ -0,0 +1,24 @@ +//go:build linux || darwin || freebsd + +package nginx + +import ( + "os" + "syscall" +) + +func reloadProcess(pid int) error { + return syscall.Kill(pid, syscall.SIGHUP) +} + +func terminateProcess(pid int, sig syscall.Signal) error { + return syscall.Kill(pid, sig) +} + +func isPIDAlive(pid int) bool { + proc, err := os.FindProcess(pid) + if err != nil { + return false + } + return proc.Signal(syscall.Signal(0)) == nil +} diff --git a/internal/nginx/pid_windows.go b/internal/nginx/pid_windows.go new file mode 100644 index 0000000..9fc8bb2 --- /dev/null +++ b/internal/nginx/pid_windows.go @@ -0,0 +1,59 @@ +//go:build windows + +package nginx + +import ( + "fmt" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + + "golang.org/x/sys/windows" +) + +const stillActive = 259 + +func reloadProcess(pid int) error { + return fmt.Errorf("signal reload unsupported on windows") +} + +func terminateProcess(pid int, sig syscall.Signal) error { + args := []string{"/PID", strconv.Itoa(pid)} + if sig == syscall.SIGKILL { + args = append([]string{"/F"}, args...) + } + cmd := exec.Command("taskkill", args...) + if err := cmd.Run(); err != nil { + return fmt.Errorf("taskkill: %w", err) + } + return nil +} + +func isPIDAlive(pid int) bool { + if pid <= 0 { + return false + } + handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) + if err != nil { + return false + } + defer windows.CloseHandle(handle) + + var exitCode uint32 + if err := windows.GetExitCodeProcess(handle, &exitCode); err != nil { + return false + } + return exitCode == stillActive +} + +func stopOrphanMasters(nginxDir string) { + marker := strings.ToLower(filepath.ToSlash(absNginxPath(nginxDir))) + marker = strings.ReplaceAll(marker, "'", "''") + script := fmt.Sprintf( + `Get-CimInstance Win32_Process -Filter "Name='nginx.exe'" | Where-Object { $_.CommandLine -like '*%s*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }`, + marker, + ) + _ = exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script).Run() +} diff --git a/internal/nginx/realip.go b/internal/nginx/realip.go new file mode 100644 index 0000000..b57f88b --- /dev/null +++ b/internal/nginx/realip.go @@ -0,0 +1,99 @@ +package nginx + +import ( + "encoding/json" + "strings" +) + +type TrustedProxyConfig struct { + Enabled bool `json:"enabled"` + Preset string `json:"preset"` // "", "cloudflare", "custom" + CIDRs []string `json:"cidrs,omitempty"` + Header string `json:"header,omitempty"` // CF-Connecting-IP, X-Real-IP, X-Forwarded-For + Recursive bool `json:"recursive,omitempty"` +} + +var cloudflareIPv4 = []string{ + "173.245.48.0/20", + "103.21.244.0/22", + "103.22.200.0/22", + "103.31.4.0/22", + "141.101.64.0/18", + "108.162.192.0/18", + "190.93.240.0/20", + "188.114.96.0/20", + "197.234.240.0/22", + "198.41.128.0/17", + "162.158.0.0/15", + "104.16.0.0/13", + "104.24.0.0/14", + "172.64.0.0/13", + "131.0.72.0/22", +} + +var cloudflareIPv6 = []string{ + "2400:cb00::/32", + "2606:4700::/32", + "2803:f800::/32", + "2405:b500::/32", + "2405:8100::/32", + "2a06:98c0::/29", + "2c0f:f248::/32", +} + +func ParseTrustedProxyJSON(raw string) TrustedProxyConfig { + raw = strings.TrimSpace(raw) + if raw == "" || raw == "{}" { + return TrustedProxyConfig{} + } + var cfg TrustedProxyConfig + _ = json.Unmarshal([]byte(raw), &cfg) + return cfg +} + +func (c TrustedProxyConfig) effectiveCIDRs() []string { + if !c.Enabled { + return nil + } + switch c.Preset { + case "cloudflare": + out := make([]string, 0, len(cloudflareIPv4)+len(cloudflareIPv6)) + out = append(out, cloudflareIPv4...) + out = append(out, cloudflareIPv6...) + return out + default: + return c.CIDRs + } +} + +func (c TrustedProxyConfig) effectiveHeader() string { + if c.Header != "" { + return c.Header + } + if c.Preset == "cloudflare" { + return "CF-Connecting-IP" + } + return "X-Forwarded-For" +} + +func writeRealIPDirectives(b *strings.Builder, cfg TrustedProxyConfig) { + writeRealIPComment(b, cfg) + cidrs := cfg.effectiveCIDRs() + if len(cidrs) == 0 { + b.WriteString(" map $remote_addr $fonu_client_ip {\n") + b.WriteString(" default $remote_addr;\n") + b.WriteString(" }\n\n") + return + } + for _, cidr := range cidrs { + b.WriteString(" set_real_ip_from " + cidr + ";\n") + } + header := cfg.effectiveHeader() + b.WriteString(" real_ip_header " + header + ";\n") + if cfg.Recursive || header == "X-Forwarded-For" { + b.WriteString(" real_ip_recursive on;\n") + } + b.WriteString("\n map $remote_addr $fonu_client_ip {\n") + b.WriteString(" default $remote_addr;\n") + b.WriteString(" }\n\n") +} diff --git a/internal/nginx/security.go b/internal/nginx/security.go new file mode 100644 index 0000000..b42847d --- /dev/null +++ b/internal/nginx/security.go @@ -0,0 +1,257 @@ +package nginx + +import ( + "fmt" + "os" + "strings" + + "github.com/fonu/fonu/internal/config" + "github.com/fonu/fonu/internal/proxy" +) + +type GenerateOptions struct { + TrustedProxy TrustedProxyConfig + GlobalIPBlacklist []string + GlobalIPWhitelist []string + ChinaCIDRAvailable bool + ChinaCIDRPathOverride string + GlobalCustomOverride string + GlobalCustomPathOverride string + RuleCustomOverrides map[int64]string +} + +func writeGlobalAllowList(b *strings.Builder, cidrs []string) { + merged := proxy.ChinaBypassCIDRs(cidrs) + if len(merged) == 0 { + return + } + writeGlobalAllowListComment(b) + for _, cidr := range merged { + b.WriteString(fmt.Sprintf(" allow %s;\n", cidr)) + } + b.WriteString("\n") +} + +func writeGlobalDenyList(b *strings.Builder, cidrs []string) { + if len(cidrs) == 0 { + return + } + writeGlobalDenyListComment(b) + for _, cidr := range cidrs { + b.WriteString(fmt.Sprintf(" deny %s;\n", cidr)) + } + b.WriteString("\n") +} + +func writeLimitZones(b *strings.Builder, rules []proxy.Rule) { + for _, rule := range rules { + if !rule.Enabled { + continue + } + if rule.Security.RateLimitEnabled() || rule.Security.ConnLimitEnabled() { + writeLimitZoneComments(b, rule) + } + if rule.Security.RateLimitEnabled() { + rl := rule.Security.RateLimit + b.WriteString(fmt.Sprintf(" limit_req_zone $binary_remote_addr zone=fonu_rule_%d_req:10m rate=%dr/s;\n", rule.ID, rl.Rate)) + } + if rule.Security.ConnLimitEnabled() { + b.WriteString(fmt.Sprintf(" limit_conn_zone $binary_remote_addr zone=fonu_rule_%d_conn:10m;\n", rule.ID)) + } + } + if hasLimitZones(rules) { + b.WriteString("\n") + } +} + +func hasLimitZones(rules []proxy.Rule) bool { + for _, rule := range rules { + if rule.Enabled && (rule.Security.RateLimitEnabled() || rule.Security.ConnLimitEnabled()) { + return true + } + } + return false +} + +func writePrivateIPGeo(b *strings.Builder) { + b.WriteString(` geo $fonu_client_ip $fonu_is_private { + default 0; + 10.0.0.0/8 1; + 127.0.0.0/8 1; + 172.16.0.0/12 1; + 192.168.0.0/16 1; + ::1/128 1; + fc00::/7 1; + } + +`) +} + +func writeChinaGeoBlocks(b *strings.Builder, cfg config.Config, opts GenerateOptions, rules []proxy.Rule) { + needsChina := false + for _, rule := range rules { + if rule.Enabled && rule.Security.ChinaOnly { + needsChina = true + break + } + } + if !needsChina { + return + } + writeChinaGeoComment(b) + writePrivateIPGeo(b) + if !opts.ChinaCIDRAvailable { + return + } + cidrPath := absNginxPath(cfg.ChinaCIDRPath()) + if opts.ChinaCIDRPathOverride != "" { + cidrPath = absNginxPath(opts.ChinaCIDRPathOverride) + } + b.WriteString(fmt.Sprintf(` geo $fonu_client_ip $fonu_is_china { + default 0; + include %s; + } + +`, cidrPath)) +} + +func writeChinaOnlyBypassGeo(b *strings.Builder, rules []proxy.Rule, opts GenerateOptions) { + if !opts.ChinaCIDRAvailable { + return + } + for _, rule := range rules { + if !rule.Enabled || !rule.Security.ChinaOnly { + continue + } + sec := rule.Security.Normalize() + cidrs := chinaBypassCIDRs(sec, opts.GlobalIPWhitelist) + writeChinaBypassComment(b, rule) + b.WriteString(fmt.Sprintf(" geo $fonu_client_ip $fonu_rule_%d_china_bypass {\n", rule.ID)) + b.WriteString(" default 0;\n") + for _, cidr := range cidrs { + b.WriteString(fmt.Sprintf(" %s 1;\n", cidr)) + } + b.WriteString(" }\n\n") + } +} + +func writeChinaOnlyCheck(b *strings.Builder, rule proxy.Rule, opts GenerateOptions) { + sec := rule.Security.Normalize() + if !sec.ChinaOnly { + return + } + if !opts.ChinaCIDRAvailable { + b.WriteString(` if ($fonu_is_private = 0) { + return 403; + } +`) + return + } + b.WriteString(` set $fonu_china_block 0; + if ($fonu_is_china = 0) { + set $fonu_china_block 1; + } + if ($fonu_is_private = 1) { + set $fonu_china_block 0; + } +`) + b.WriteString(fmt.Sprintf(` if ($fonu_rule_%d_china_bypass = 1) { + set $fonu_china_block 0; + } +`, rule.ID)) + b.WriteString(` if ($fonu_china_block = 1) { + return 403; + } +`) +} + +func writeServerSecurityHeaders(b *strings.Builder, sec proxy.SecurityConfig, https bool) { + if !https || !sec.SecurityHeaders { + return + } + writeSecurityHeadersComment(b) + b.WriteString(` add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + +`) +} + +func writeTLSProtocols(b *strings.Builder, sec proxy.SecurityConfig) { + writeTLSProtocolComment(b, sec) + if sec.TLSMin13Only { + b.WriteString(" ssl_protocols TLSv1.3;\n\n") + } else { + b.WriteString(" ssl_protocols TLSv1.2 TLSv1.3;\n\n") + } +} + +func writeErrorPages(b *strings.Builder, cfg config.Config) { + errorsDir := absNginxPath(cfg.ErrorsDir()) + writeErrorPagesComment(b) + // HTML is served via internal error_page redirect; images are fetched by the browser + // as separate requests and must not use the internal-only location. + b.WriteString(` error_page 403 /fonu-errors/403.html; + error_page 404 /fonu-errors/404.html; + error_page 429 /fonu-errors/429.html; + error_page 500 /fonu-errors/500.html; + error_page 502 503 /fonu-errors/503.html; + + location = /fonu-errors/error.png { + alias ` + errorsDir + `/error.png; + } + location = /fonu-errors/429.png { + alias ` + errorsDir + `/429.png; + } + + location ^~ /fonu-errors/ { + internal; + alias ` + errorsDir + `/; + } + +`) +} + +func writeLocationSecurity(b *strings.Builder, cfg config.Config, rule proxy.Rule, opts GenerateOptions) { + sec := rule.Security.Normalize() + writeLocationSecurityComments(b, rule, opts) + + for _, cidr := range sec.IPBlacklist { + b.WriteString(fmt.Sprintf(" deny %s;\n", cidr)) + } + + writeChinaOnlyCheck(b, rule, opts) + + if sec.WhitelistEnabled() { + for _, cidr := range sec.IPWhitelist { + b.WriteString(fmt.Sprintf(" allow %s;\n", cidr)) + } + b.WriteString(" deny all;\n") + } + + if sec.ConnLimitEnabled() { + b.WriteString(fmt.Sprintf(" limit_conn fonu_rule_%d_conn %d;\n", rule.ID, sec.ConnLimit.Max)) + } + if sec.RateLimitEnabled() { + b.WriteString(fmt.Sprintf(" limit_req zone=fonu_rule_%d_req burst=%d nodelay;\n", rule.ID, sec.RateLimit.Burst)) + } + + if sec.BasicAuthEnabled() { + path := absNginxPath(HtpasswdPath(cfg, rule.ID)) + b.WriteString(fmt.Sprintf(` auth_basic "Restricted"; + auth_basic_user_file %s; +`, path)) + } +} + +func chinaBypassCIDRs(sec proxy.SecurityConfig, globalWhitelist []string) []string { + extra := make([]string, 0, len(globalWhitelist)+len(sec.IPWhitelist)) + extra = append(extra, globalWhitelist...) + extra = append(extra, sec.IPWhitelist...) + return proxy.ChinaBypassCIDRs(extra) +} + +func chinaCIDRExists(cfg config.Config) bool { + info, err := os.Stat(cfg.ChinaCIDRPath()) + return err == nil && !info.IsDir() && info.Size() > 0 +} diff --git a/internal/nginx/syntax.go b/internal/nginx/syntax.go new file mode 100644 index 0000000..a60e5f9 --- /dev/null +++ b/internal/nginx/syntax.go @@ -0,0 +1,68 @@ +package nginx + +import ( + "fmt" + "strings" +) + +// ValidateConfigSyntax performs a lightweight syntax check when nginx -t is unavailable. +func ValidateConfigSyntax(content string) error { + depth := 0 + for i, raw := range strings.Split(content, "\n") { + trimmed := strings.TrimSpace(raw) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + line := stripInlineComment(trimmed) + if line == "" { + continue + } + depth += strings.Count(line, "{") - strings.Count(line, "}") + if depth < 0 { + return fmt.Errorf("第 %d 行:多余的 `}`", i+1) + } + if err := validateConfigLine(line, i+1); err != nil { + return err + } + } + if depth != 0 { + return fmt.Errorf("配置块大括号未配对") + } + return nil +} + +func stripInlineComment(line string) string { + inSingle := false + inDouble := false + for i := 0; i < len(line); i++ { + ch := line[i] + switch ch { + case '\'': + if !inDouble { + inSingle = !inSingle + } + case '"': + if !inSingle { + inDouble = !inDouble + } + case '#': + if !inSingle && !inDouble { + return strings.TrimSpace(line[:i]) + } + } + } + return strings.TrimSpace(line) +} + +func validateConfigLine(line string, lineNo int) error { + if line == "}" || line == "{" { + return nil + } + if strings.HasSuffix(line, "{") { + return nil + } + if strings.HasSuffix(line, ";") { + return nil + } + return fmt.Errorf("第 %d 行:无效的 Nginx 语法(应以 ; 或 { 结尾)", lineNo) +} diff --git a/internal/nginx/syntax_test.go b/internal/nginx/syntax_test.go new file mode 100644 index 0000000..b37d3b6 --- /dev/null +++ b/internal/nginx/syntax_test.go @@ -0,0 +1,44 @@ +package nginx + +import "testing" + +func TestValidateConfigSyntax(t *testing.T) { + valid := `server { + listen 80; + location / { + proxy_pass http://127.0.0.1:8080; + } +}` + if err := ValidateConfigSyntax(valid); err != nil { + t.Fatalf("expected valid config: %v", err) + } + + cases := []struct { + name string + content string + }{ + {"garbage line", "server {\n 1 1\n}\n"}, + {"missing semicolon", "server {\n listen 80\n}\n"}, + {"unbalanced brace", "server {\n listen 80;\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := ValidateConfigSyntax(tc.content); err == nil { + t.Fatalf("expected error for %q", tc.name) + } + }) + } + + t.Run("allows comment lines", func(t *testing.T) { + content := `# 规则:测试 +server { + # 监听 80 + listen 80; +} +# HTTP 跳转 +` + if err := ValidateConfigSyntax(content); err != nil { + t.Fatalf("expected comments to pass: %v", err) + } + }) +} diff --git a/internal/notify/config.go b/internal/notify/config.go new file mode 100644 index 0000000..1b6ffd5 --- /dev/null +++ b/internal/notify/config.go @@ -0,0 +1,128 @@ +package notify + +const ( + EventDDNSIPChange = "ddns_ip_change" + EventDDNSFailure = "ddns_failure" + EventCertExpiry = "cert_expiry" + EventCertRenewSuccess = "cert_renew_success" + EventCertRenewFailure = "cert_renew_failure" + EventIPFrequentAccess = "ip_frequent_access" + EventLoginFailure = "login_failure" + EventNginxReloadFailure = "nginx_reload_failure" + + MaskedSecret = "********" + + DefaultBarkServer = "https://api.day.app" + DefaultIPFrequentThreshold = 100 + DefaultIPFrequentWindowSec = 60 + DefaultIPFrequentAlertCooldown = 15 * 60 // seconds + DefaultLoginFailureThreshold = 5 + DefaultLoginFailureWindowSec = 300 + DefaultLoginFailureAlertCooldown = 15 * 60 // seconds +) + +type NotifyType string + +const ( + NotifyTypeEmail NotifyType = "email" + NotifyTypeWebhook NotifyType = "webhook" + NotifyTypeTelegram NotifyType = "telegram" +) + +type WebhookProvider string + +const ( + WebhookBark WebhookProvider = "bark" + WebhookNtfy WebhookProvider = "ntfy" + WebhookGotify WebhookProvider = "gotify" + WebhookCustom WebhookProvider = "custom" +) + +type EmailConfig struct { + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + From string `json:"from"` + To []string `json:"to"` + TLS bool `json:"tls"` + HasPassword bool `json:"has_password"` +} + +type WebhookConfig struct { + Provider WebhookProvider `json:"provider"` + Server string `json:"server"` + Key string `json:"key"` + Topic string `json:"topic"` + URL string `json:"url"` + HasSecret bool `json:"has_secret"` +} + +type TelegramConfig struct { + ChatID string `json:"chat_id"` + ProxyURL string `json:"proxy_url"` + HasBotToken bool `json:"has_bot_token"` +} + +type Config struct { + Type NotifyType `json:"type"` + Email EmailConfig `json:"email"` + Webhook WebhookConfig `json:"webhook"` + Telegram TelegramConfig `json:"telegram"` + OnDDNSIPChange bool `json:"on_ddns_ip_change"` + OnDDNSFailure bool `json:"on_ddns_failure"` + OnCertExpiry bool `json:"on_cert_expiry"` + OnCertRenewSuccess bool `json:"on_cert_renew_success"` + OnCertRenewFailure bool `json:"on_cert_renew_failure"` + OnIPFrequentAccess bool `json:"on_ip_frequent_access"` + OnLoginFailure bool `json:"on_login_failure"` + OnNginxReloadFailure bool `json:"on_nginx_reload_failure"` + IPFrequentThreshold int `json:"ip_frequent_threshold"` + IPFrequentWindowSec int `json:"ip_frequent_window_sec"` + LoginFailureThreshold int `json:"login_failure_threshold"` + LoginFailureWindowSec int `json:"login_failure_window_sec"` +} + +type RuntimeConfig struct { + Config + SMTPPassword string + WebhookSecret string + TelegramToken string +} + +type SaveInput struct { + Type NotifyType + Email EmailConfig + SMTPPassword string + Webhook WebhookConfig + WebhookSecret string + Telegram TelegramConfig + TelegramToken string + OnDDNSIPChange bool + OnDDNSFailure bool + OnCertExpiry bool + OnCertRenewSuccess bool + OnCertRenewFailure bool + OnIPFrequentAccess bool + OnLoginFailure bool + OnNginxReloadFailure bool + IPFrequentThreshold int + IPFrequentWindowSec int + LoginFailureThreshold int + LoginFailureWindowSec int +} + +type TestInput struct { + Config + SMTPPassword string `json:"smtp_password"` + WebhookSecret string `json:"webhook_secret"` + TelegramToken string `json:"telegram_token"` +} + +func (c Config) Runtime(password, webhookSecret, telegramToken string) RuntimeConfig { + return RuntimeConfig{ + Config: c, + SMTPPassword: password, + WebhookSecret: webhookSecret, + TelegramToken: telegramToken, + } +} diff --git a/internal/notify/format.go b/internal/notify/format.go new file mode 100644 index 0000000..5a1c7b8 --- /dev/null +++ b/internal/notify/format.go @@ -0,0 +1,130 @@ +package notify + +import ( + "strings" + "time" +) + +func formatFooter(at time.Time) string { + return "Fonu · " + at.Format("2006-01-02 15:04:05") +} + +type eventMeta struct { + icon string + category string +} + +type AlertContent struct { + Event string + Subject string + PushTitle string + PlainBody string + HTMLBody string + Category string + Icon string + Detail string + Timestamp time.Time +} + +func FormatAlert(event, title, message string) AlertContent { + return FormatAlertAt(event, title, message, time.Now()) +} + +func FormatAlertAt(event, title, message string, at time.Time) AlertContent { + meta := eventMetaFor(event) + title = strings.TrimSpace(title) + message = strings.TrimSpace(message) + pushTitle := strings.TrimSpace(meta.icon + " " + title) + subject := "[Fonu] " + pushTitle + footer := formatFooter(at) + + detail := message + if detail == "" { + detail = "暂无更多详情" + } + + plain := strings.Join([]string{ + pushTitle, + strings.Repeat("─", 28), + "", + detail, + "", + strings.Repeat("─", 28), + footer, + }, "\n") + + html := formatTelegramHTML(pushTitle, detail, at) + + return AlertContent{ + Event: event, + Subject: subject, + PushTitle: pushTitle, + PlainBody: plain, + HTMLBody: html, + Category: meta.category, + Icon: meta.icon, + Detail: detail, + Timestamp: at, + } +} + +func eventMetaFor(event string) eventMeta { + switch event { + case EventDDNSIPChange: + return eventMeta{"🌐", "DDNS"} + case EventDDNSFailure: + return eventMeta{"⚠️", "DDNS"} + case EventCertExpiry: + return eventMeta{"📅", "证书"} + case EventCertRenewSuccess: + return eventMeta{"✅", "证书"} + case EventCertRenewFailure: + return eventMeta{"❌", "证书"} + case EventIPFrequentAccess: + return eventMeta{"🛡️", "安全"} + case EventLoginFailure: + return eventMeta{"🔐", "安全"} + case EventNginxReloadFailure: + return eventMeta{"⚙️", "系统"} + case "test": + return eventMeta{"🔔", "系统"} + default: + return eventMeta{"📣", "告警"} + } +} + +func formatTelegramHTML(title, detail string, at time.Time) string { + lines := []string{ + "" + escapeHTML(title) + "", + "", + "
" + escapeHTML(detail) + "
", + "", + "" + escapeHTML(formatFooter(at)) + "", + } + return strings.Join(lines, "\n") +} + +func escapeHTML(s string) string { + replacer := strings.NewReplacer( + "&", "&", + "<", "<", + ">", ">", + ) + return replacer.Replace(s) +} + +func (c AlertContent) webhookPayload() map[string]string { + return map[string]string{ + "event": c.Event, + "category": c.Category, + "icon": c.Icon, + "title": strings.TrimPrefix(c.PushTitle, c.Icon+" "), + "message": c.Detail, + "formatted": c.PlainBody, + "time": c.Timestamp.UTC().Format(time.RFC3339), + } +} + +func (c AlertContent) barkMessage() string { + return strings.Join([]string{c.Detail, "", "────────", formatFooter(c.Timestamp)}, "\n") +} diff --git a/internal/notify/format_test.go b/internal/notify/format_test.go new file mode 100644 index 0000000..15ba970 --- /dev/null +++ b/internal/notify/format_test.go @@ -0,0 +1,28 @@ +package notify + +import ( + "strings" + "testing" + "time" +) + +func TestFormatAlert(t *testing.T) { + at := time.Date(2026, 9, 18, 15, 49, 0, 0, time.FixedZone("CST", 8*3600)) + content := FormatAlertAt(EventDDNSIPChange, "DDNS IP 已变更", "example.com\nIPv4: 1.2.3.4 → 5.6.7.8", at) + + if content.Subject != "[Fonu] 🌐 DDNS IP 已变更" { + t.Fatalf("unexpected subject: %s", content.Subject) + } + if !strings.Contains(content.PlainBody, "example.com") { + t.Fatalf("missing detail: %s", content.PlainBody) + } + if !strings.Contains(content.PlainBody, "Fonu · 2026-09-18 15:49:00") { + t.Fatalf("missing footer: %s", content.PlainBody) + } + if !strings.Contains(content.HTMLBody, "") || !strings.Contains(content.HTMLBody, "
") {
+		t.Fatalf("unexpected html: %s", content.HTMLBody)
+	}
+	if content.webhookPayload()["category"] != "DDNS" {
+		t.Fatalf("unexpected webhook category: %v", content.webhookPayload())
+	}
+}
diff --git a/internal/notify/push.go b/internal/notify/push.go
new file mode 100644
index 0000000..5584ef4
--- /dev/null
+++ b/internal/notify/push.go
@@ -0,0 +1,154 @@
+package notify
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+)
+
+func SendWebhook(ctx context.Context, client *http.Client, cfg RuntimeConfig, content AlertContent) error {
+	switch cfg.Webhook.Provider {
+	case WebhookBark:
+		return sendBark(ctx, client, cfg, content.PushTitle, content.barkMessage())
+	case WebhookNtfy:
+		return sendNtfy(ctx, client, cfg, content.PushTitle, content.PlainBody)
+	case WebhookGotify:
+		return sendGotify(ctx, client, cfg, content.PushTitle, content.PlainBody)
+	case WebhookCustom:
+		return sendCustomWebhook(ctx, client, cfg, content)
+	default:
+		return fmt.Errorf("不支持的 Webhook 预设")
+	}
+}
+
+func webhookSecret(cfg RuntimeConfig) string {
+	if strings.TrimSpace(cfg.WebhookSecret) != "" {
+		return strings.TrimSpace(cfg.WebhookSecret)
+	}
+	return strings.TrimSpace(cfg.Webhook.Key)
+}
+
+func sendBark(ctx context.Context, client *http.Client, cfg RuntimeConfig, title, message string) error {
+	key := webhookSecret(cfg)
+	if key == "" {
+		return fmt.Errorf("Bark Device Key 未配置")
+	}
+	server := normalizeServerURL(cfg.Webhook.Server)
+	if server == "" {
+		server = DefaultBarkServer
+	}
+	endpoint := fmt.Sprintf("%s/%s/%s/%s", server, url.PathEscape(key), url.PathEscape(title), url.PathEscape(message))
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
+	if err != nil {
+		return err
+	}
+	return doRequest(client, req)
+}
+
+func sendNtfy(ctx context.Context, client *http.Client, cfg RuntimeConfig, title, message string) error {
+	topic := strings.TrimSpace(cfg.Webhook.Topic)
+	if topic == "" {
+		return fmt.Errorf("ntfy Topic 未配置")
+	}
+	server := normalizeServerURL(cfg.Webhook.Server)
+	if server == "" {
+		server = "https://ntfy.sh"
+	}
+	endpoint := joinURL(server, topic)
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(message))
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Title", title)
+	req.Header.Set("Content-Type", "text/plain; charset=utf-8")
+	if token := webhookSecret(cfg); token != "" {
+		req.Header.Set("Authorization", "Bearer "+token)
+	}
+	return doRequest(client, req)
+}
+
+func sendGotify(ctx context.Context, client *http.Client, cfg RuntimeConfig, title, message string) error {
+	token := webhookSecret(cfg)
+	if token == "" {
+		return fmt.Errorf("Gotify App Token 未配置")
+	}
+	server := normalizeServerURL(cfg.Webhook.Server)
+	if server == "" {
+		return fmt.Errorf("Gotify 服务地址未配置")
+	}
+	endpoint := joinURL(server, "message") + "?token=" + url.QueryEscape(token)
+	payload := map[string]any{
+		"title":    title,
+		"message":  message,
+		"priority": 5,
+	}
+	body, _ := json.Marshal(payload)
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Content-Type", "application/json")
+	return doRequest(client, req)
+}
+
+func sendCustomWebhook(ctx context.Context, client *http.Client, cfg RuntimeConfig, content AlertContent) error {
+	target := strings.TrimSpace(cfg.Webhook.URL)
+	if target == "" {
+		return fmt.Errorf("Webhook URL 未配置")
+	}
+	body, _ := json.Marshal(content.webhookPayload())
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, bytes.NewReader(body))
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Content-Type", "application/json")
+	return doRequest(client, req)
+}
+
+func doRequest(client *http.Client, req *http.Request) error {
+	if client == nil {
+		client = &http.Client{Timeout: 10 * time.Second}
+	}
+	resp, err := client.Do(req)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode >= 400 {
+		body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+		if len(body) > 0 {
+			return fmt.Errorf("请求失败 (%d): %s", resp.StatusCode, strings.TrimSpace(string(body)))
+		}
+		return fmt.Errorf("请求失败 (%d)", resp.StatusCode)
+	}
+	return nil
+}
+
+func normalizeServerURL(server string) string {
+	server = strings.TrimSpace(server)
+	server = strings.TrimRight(server, "/")
+	if server == "" {
+		return ""
+	}
+	if !strings.Contains(server, "://") {
+		server = "https://" + server
+	}
+	return server
+}
+
+func joinURL(base string, parts ...string) string {
+	base = strings.TrimRight(normalizeServerURL(base), "/")
+	for _, part := range parts {
+		part = strings.Trim(part, "/")
+		if part != "" {
+			base += "/" + part
+		}
+	}
+	return base
+}
diff --git a/internal/notify/push_test.go b/internal/notify/push_test.go
new file mode 100644
index 0000000..8602db7
--- /dev/null
+++ b/internal/notify/push_test.go
@@ -0,0 +1,73 @@
+package notify
+
+import (
+	"context"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+)
+
+func TestSendCustomWebhook(t *testing.T) {
+	var body string
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		raw, _ := io.ReadAll(r.Body)
+		body = string(raw)
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer server.Close()
+
+	cfg := RuntimeConfig{
+		Config: Config{
+			Type: NotifyTypeWebhook,
+			Webhook: WebhookConfig{
+				Provider: WebhookCustom,
+				URL:      server.URL,
+			},
+		},
+	}
+	content := FormatAlert("test", "title", "message")
+	if err := SendWebhook(context.Background(), server.Client(), cfg, content); err != nil {
+		t.Fatalf("send custom webhook: %v", err)
+	}
+	if !strings.Contains(body, `"title":"title"`) || !strings.Contains(body, `"message":"message"`) {
+		t.Fatalf("unexpected body: %s", body)
+	}
+	if !strings.Contains(body, `"formatted":`) {
+		t.Fatalf("missing formatted body: %s", body)
+	}
+}
+
+func TestSendNtfyUsesTopic(t *testing.T) {
+	var gotPath string
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		gotPath = r.URL.Path
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer server.Close()
+
+	cfg := RuntimeConfig{
+		Config: Config{
+			Type: NotifyTypeWebhook,
+			Webhook: WebhookConfig{
+				Provider: WebhookNtfy,
+				Server:   server.URL,
+				Topic:    "alerts",
+			},
+		},
+	}
+	if err := SendWebhook(context.Background(), server.Client(), cfg, FormatAlert("test", "title", "message")); err != nil {
+		t.Fatalf("send ntfy: %v", err)
+	}
+	if gotPath != "/alerts" {
+		t.Fatalf("expected /alerts got %s", gotPath)
+	}
+}
+
+func TestJoinURL(t *testing.T) {
+	got := joinURL("https://ntfy.sh", "topic")
+	if got != "https://ntfy.sh/topic" {
+		t.Fatalf("unexpected url: %s", got)
+	}
+}
diff --git a/internal/notify/service.go b/internal/notify/service.go
new file mode 100644
index 0000000..5cc20a2
--- /dev/null
+++ b/internal/notify/service.go
@@ -0,0 +1,248 @@
+package notify
+
+import (
+	"context"
+	"fmt"
+	"log/slog"
+	"net/http"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/fonu/fonu/internal/secret"
+	"github.com/fonu/fonu/internal/settings"
+)
+
+type Service struct {
+	settings *settings.Store
+	store    *Store
+	logger   *slog.Logger
+	client   *http.Client
+
+	accessMu      sync.Mutex
+	accessHits    map[string][]time.Time
+	accessAlerted map[string]time.Time
+
+	loginMu      sync.Mutex
+	loginHits    map[string][]time.Time
+	loginAlerted map[string]time.Time
+}
+
+func New(settingsStore *settings.Store, secretBox *secret.Box, logger *slog.Logger) *Service {
+	if logger == nil {
+		logger = slog.Default()
+	}
+	return &Service{
+		settings:      settingsStore,
+		store:         NewStore(settingsStore, secretBox),
+		logger:        logger,
+		client:        &http.Client{Timeout: 10 * time.Second},
+		accessHits:    map[string][]time.Time{},
+		accessAlerted: map[string]time.Time{},
+		loginHits:     map[string][]time.Time{},
+		loginAlerted:  map[string]time.Time{},
+	}
+}
+
+func (s *Service) Alert(ctx context.Context, event, title, message string) {
+	if s == nil || s.store == nil {
+		return
+	}
+	cfg, err := s.store.LoadRuntime(ctx)
+	if err != nil {
+		s.logger.Warn("notify load config failed", "error", err.Error())
+		return
+	}
+	if !s.enabledFor(cfg, event) {
+		return
+	}
+	if err := s.send(ctx, cfg, event, title, message); err != nil {
+		s.logger.Warn("notify send failed", "event", event, "error", err.Error())
+	}
+}
+
+func (s *Service) RecordAccessHit(ctx context.Context, ip string) {
+	s.recordThresholdHit(ctx, ip, EventIPFrequentAccess, func(cfg RuntimeConfig) (threshold, windowSec, cooldownSec int, enabled bool) {
+		enabled = s.enabledFor(cfg, EventIPFrequentAccess)
+		threshold = cfg.IPFrequentThreshold
+		if threshold <= 0 {
+			threshold = DefaultIPFrequentThreshold
+		}
+		windowSec = cfg.IPFrequentWindowSec
+		if windowSec <= 0 {
+			windowSec = DefaultIPFrequentWindowSec
+		}
+		return threshold, windowSec, DefaultIPFrequentAlertCooldown, enabled
+	}, &s.accessMu, s.accessHits, s.accessAlerted, func(ip string, windowSec, count, threshold int) string {
+		return fmt.Sprintf("IP %s 在 %d 秒内访问 %d 次(阈值 %d)", ip, windowSec, count, threshold)
+	}, "IP 频繁访问")
+}
+
+func (s *Service) RecordLoginFailure(ctx context.Context, ip string) {
+	s.recordThresholdHit(ctx, ip, EventLoginFailure, func(cfg RuntimeConfig) (threshold, windowSec, cooldownSec int, enabled bool) {
+		enabled = s.enabledFor(cfg, EventLoginFailure)
+		threshold = cfg.LoginFailureThreshold
+		if threshold <= 0 {
+			threshold = DefaultLoginFailureThreshold
+		}
+		windowSec = cfg.LoginFailureWindowSec
+		if windowSec <= 0 {
+			windowSec = DefaultLoginFailureWindowSec
+		}
+		return threshold, windowSec, DefaultLoginFailureAlertCooldown, enabled
+	}, &s.loginMu, s.loginHits, s.loginAlerted, func(ip string, windowSec, count, threshold int) string {
+		return fmt.Sprintf("IP %s 在 %d 秒内登录失败 %d 次(阈值 %d)", ip, windowSec, count, threshold)
+	}, "登录异常")
+}
+
+func (s *Service) recordThresholdHit(
+	ctx context.Context,
+	ip string,
+	event string,
+	cfgFn func(RuntimeConfig) (threshold, windowSec, cooldownSec int, enabled bool),
+	mu *sync.Mutex,
+	hits map[string][]time.Time,
+	alerted map[string]time.Time,
+	messageFn func(ip string, windowSec, count, threshold int) string,
+	title string,
+) {
+	if s == nil || s.store == nil {
+		return
+	}
+	ip = strings.TrimSpace(ip)
+	if ip == "" || ip == "-" {
+		return
+	}
+
+	cfg, err := s.store.LoadRuntime(ctx)
+	if err != nil {
+		return
+	}
+	threshold, windowSec, cooldownSec, enabled := cfgFn(cfg)
+	if !enabled {
+		return
+	}
+
+	window := time.Duration(windowSec) * time.Second
+	now := time.Now()
+	cutoff := now.Add(-window)
+
+	mu.Lock()
+	prev := hits[ip]
+	pruned := make([]time.Time, 0, len(prev)+1)
+	for _, at := range prev {
+		if !at.Before(cutoff) {
+			pruned = append(pruned, at)
+		}
+	}
+	pruned = append(pruned, now)
+	hits[ip] = pruned
+
+	count := len(pruned)
+	lastAlert := alerted[ip]
+	cooldown := time.Duration(cooldownSec) * time.Second
+	shouldAlert := count >= threshold && now.Sub(lastAlert) >= cooldown
+	if shouldAlert {
+		alerted[ip] = now
+	}
+	mu.Unlock()
+
+	if !shouldAlert {
+		return
+	}
+	s.Alert(ctx, event, title, messageFn(ip, windowSec, count, threshold))
+}
+
+func (s *Service) SendTest(ctx context.Context, in TestInput) error {
+	runtime, err := s.resolveTestRuntime(ctx, in)
+	if err != nil {
+		return err
+	}
+	if runtime.Type == "" {
+		return fmt.Errorf("请先选择通知方式")
+	}
+	if err := s.send(ctx, runtime, "test", "测试通知", "配置验证通过,当前通知通道可正常使用。"); err != nil {
+		s.logger.Warn("notify test send failed", "error", err.Error())
+		return err
+	}
+	return nil
+}
+
+func (s *Service) resolveTestRuntime(ctx context.Context, in TestInput) (RuntimeConfig, error) {
+	smtpPassword := strings.TrimSpace(in.SMTPPassword)
+	webhookSecret := strings.TrimSpace(in.WebhookSecret)
+	telegramToken := strings.TrimSpace(in.TelegramToken)
+
+	if key := strings.TrimSpace(in.Webhook.Key); key != "" && key != MaskedSecret {
+		webhookSecret = key
+	}
+
+	needStored := smtpPassword == "" || smtpPassword == MaskedSecret ||
+		webhookSecret == "" || webhookSecret == MaskedSecret ||
+		telegramToken == "" || telegramToken == MaskedSecret
+
+	if needStored && s.store != nil {
+		stored, err := s.store.LoadRuntime(ctx)
+		if err != nil {
+			return RuntimeConfig{}, err
+		}
+		if (smtpPassword == "" || smtpPassword == MaskedSecret) && in.Email.HasPassword {
+			smtpPassword = stored.SMTPPassword
+		}
+		if (webhookSecret == "" || webhookSecret == MaskedSecret) && in.Webhook.HasSecret {
+			webhookSecret = stored.WebhookSecret
+		}
+		if (telegramToken == "" || telegramToken == MaskedSecret) && in.Telegram.HasBotToken {
+			telegramToken = stored.TelegramToken
+		}
+	}
+
+	return in.Config.Runtime(smtpPassword, webhookSecret, telegramToken), nil
+}
+
+func (s *Service) Store() *Store {
+	if s == nil {
+		return nil
+	}
+	return s.store
+}
+
+func (s *Service) send(ctx context.Context, cfg RuntimeConfig, event, title, message string) error {
+	content := FormatAlert(event, title, message)
+	switch cfg.Type {
+	case NotifyTypeEmail:
+		return SendEmail(cfg, content.Subject, content.PlainBody)
+	case NotifyTypeWebhook:
+		return SendWebhook(ctx, s.client, cfg, content)
+	case NotifyTypeTelegram:
+		return SendTelegram(ctx, s.client, cfg, content)
+	default:
+		return nil
+	}
+}
+
+func (s *Service) enabledFor(cfg RuntimeConfig, event string) bool {
+	if cfg.Type == "" {
+		return false
+	}
+	switch event {
+	case EventDDNSIPChange:
+		return cfg.OnDDNSIPChange
+	case EventDDNSFailure:
+		return cfg.OnDDNSFailure
+	case EventCertExpiry:
+		return cfg.OnCertExpiry
+	case EventCertRenewSuccess:
+		return cfg.OnCertRenewSuccess
+	case EventCertRenewFailure:
+		return cfg.OnCertRenewFailure
+	case EventIPFrequentAccess:
+		return cfg.OnIPFrequentAccess
+	case EventLoginFailure:
+		return cfg.OnLoginFailure
+	case EventNginxReloadFailure:
+		return cfg.OnNginxReloadFailure
+	default:
+		return true
+	}
+}
diff --git a/internal/notify/service_test.go b/internal/notify/service_test.go
new file mode 100644
index 0000000..8fbc6b1
--- /dev/null
+++ b/internal/notify/service_test.go
@@ -0,0 +1,47 @@
+package notify
+
+import (
+	"context"
+	"encoding/json"
+	"testing"
+)
+
+func TestTestInputDecodesSecretFields(t *testing.T) {
+	raw := `{
+		"type": "telegram",
+		"telegram": {"chat_id": "743955235", "proxy_url": "http://127.0.0.1:7890", "has_bot_token": false},
+		"telegram_token": "123456:ABC-DEF"
+	}`
+	var in TestInput
+	if err := json.Unmarshal([]byte(raw), &in); err != nil {
+		t.Fatal(err)
+	}
+	if in.Type != NotifyTypeTelegram {
+		t.Fatalf("unexpected type: %s", in.Type)
+	}
+	if in.TelegramToken != "123456:ABC-DEF" {
+		t.Fatalf("telegram token not decoded: %q", in.TelegramToken)
+	}
+	if in.Telegram.ChatID != "743955235" {
+		t.Fatalf("chat id not decoded: %q", in.Telegram.ChatID)
+	}
+}
+
+func TestResolveTestRuntimeUsesPlainToken(t *testing.T) {
+	svc := &Service{}
+	runtime, err := svc.resolveTestRuntime(context.Background(), TestInput{
+		Config: Config{
+			Type: NotifyTypeTelegram,
+			Telegram: TelegramConfig{
+				ChatID: "1",
+			},
+		},
+		TelegramToken: "plain-token",
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if runtime.TelegramToken != "plain-token" {
+		t.Fatalf("expected plain-token, got %q", runtime.TelegramToken)
+	}
+}
diff --git a/internal/notify/smtp.go b/internal/notify/smtp.go
new file mode 100644
index 0000000..7962a26
--- /dev/null
+++ b/internal/notify/smtp.go
@@ -0,0 +1,90 @@
+package notify
+
+import (
+	"crypto/tls"
+	"fmt"
+	"net/smtp"
+	"strings"
+)
+
+func SendEmail(cfg RuntimeConfig, title, message string) error {
+	if strings.TrimSpace(cfg.Email.Host) == "" {
+		return fmt.Errorf("SMTP 主机未配置")
+	}
+	if len(cfg.Email.To) == 0 {
+		return fmt.Errorf("收件人未配置")
+	}
+	if strings.TrimSpace(cfg.SMTPPassword) == "" && strings.TrimSpace(cfg.Email.Username) != "" {
+		return fmt.Errorf("SMTP 密码未配置")
+	}
+
+	from := strings.TrimSpace(cfg.Email.From)
+	if from == "" {
+		from = strings.TrimSpace(cfg.Email.Username)
+	}
+	if from == "" {
+		return fmt.Errorf("发件人未配置")
+	}
+
+	body := buildMailBody(from, cfg.Email.To, title, message)
+	addr := fmt.Sprintf("%s:%d", cfg.Email.Host, cfg.Email.Port)
+	auth := smtp.PlainAuth("", cfg.Email.Username, cfg.SMTPPassword, cfg.Email.Host)
+
+	if cfg.Email.Port == 465 {
+		return sendSMTPS(addr, cfg.Email.Host, auth, from, cfg.Email.To, body)
+	}
+	return smtp.SendMail(addr, auth, from, cfg.Email.To, body)
+}
+
+func buildMailBody(from string, to []string, title, message string) []byte {
+	headers := []string{
+		"From: " + from,
+		"To: " + strings.Join(to, ", "),
+		"Subject: " + title,
+		"MIME-Version: 1.0",
+		"Content-Type: text/plain; charset=UTF-8",
+	}
+	bodyText := strings.TrimSpace(message)
+	if bodyText == "" {
+		bodyText = title
+	}
+	return []byte(strings.Join(headers, "\r\n") + "\r\n\r\n" + bodyText + "\r\n")
+}
+
+func sendSMTPS(addr, host string, auth smtp.Auth, from string, to []string, body []byte) error {
+	tlsCfg := &tls.Config{ServerName: host}
+	conn, err := tls.Dial("tcp", addr, tlsCfg)
+	if err != nil {
+		return err
+	}
+	client, err := smtp.NewClient(conn, host)
+	if err != nil {
+		return err
+	}
+	defer client.Close()
+
+	if auth != nil {
+		if err := client.Auth(auth); err != nil {
+			return err
+		}
+	}
+	if err := client.Mail(from); err != nil {
+		return err
+	}
+	for _, rcpt := range to {
+		if err := client.Rcpt(strings.TrimSpace(rcpt)); err != nil {
+			return err
+		}
+	}
+	writer, err := client.Data()
+	if err != nil {
+		return err
+	}
+	if _, err := writer.Write(body); err != nil {
+		return err
+	}
+	if err := writer.Close(); err != nil {
+		return err
+	}
+	return client.Quit()
+}
diff --git a/internal/notify/smtp_test.go b/internal/notify/smtp_test.go
new file mode 100644
index 0000000..2bdb205
--- /dev/null
+++ b/internal/notify/smtp_test.go
@@ -0,0 +1,17 @@
+package notify
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestBuildMailBody(t *testing.T) {
+	content := FormatAlert("test", "标题", "正文")
+	body := string(buildMailBody("from@example.com", []string{"to@example.com"}, content.Subject, content.PlainBody))
+	if !strings.Contains(body, "Subject: [Fonu]") {
+		t.Fatalf("missing subject: %s", body)
+	}
+	if !strings.Contains(body, "正文") {
+		t.Fatalf("missing message: %s", body)
+	}
+}
diff --git a/internal/notify/store.go b/internal/notify/store.go
new file mode 100644
index 0000000..d8a2137
--- /dev/null
+++ b/internal/notify/store.go
@@ -0,0 +1,456 @@
+package notify
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"strconv"
+	"strings"
+
+	"github.com/fonu/fonu/internal/secret"
+	"github.com/fonu/fonu/internal/settings"
+)
+
+type Store struct {
+	settings *settings.Store
+	secret   *secret.Box
+}
+
+func NewStore(settingsStore *settings.Store, secretBox *secret.Box) *Store {
+	return &Store{settings: settingsStore, secret: secretBox}
+}
+
+func (s *Store) Load(ctx context.Context) (Config, error) {
+	cfg, _, err := s.load(ctx, false)
+	return cfg, err
+}
+
+func (s *Store) LoadRuntime(ctx context.Context) (RuntimeConfig, error) {
+	cfg, runtime, err := s.load(ctx, true)
+	if err != nil {
+		return RuntimeConfig{}, err
+	}
+	return cfg.Runtime(runtime.smtpPassword, runtime.webhookSecret, runtime.telegramToken), nil
+}
+
+type runtimeSecrets struct {
+	smtpPassword   string
+	webhookSecret  string
+	telegramToken  string
+}
+
+func (s *Store) load(ctx context.Context, decrypt bool) (Config, runtimeSecrets, error) {
+	cfg := Config{
+		Email:    EmailConfig{Port: 587, TLS: true},
+		Webhook:  WebhookConfig{Provider: WebhookBark, Server: DefaultBarkServer},
+		Telegram: TelegramConfig{},
+	}
+	secrets := runtimeSecrets{}
+
+	typeRaw, _ := s.settings.Get(ctx, settings.KeyNotifyType)
+	cfg.Type = NotifyType(strings.TrimSpace(typeRaw))
+
+	emailRaw, _ := s.settings.Get(ctx, settings.KeyNotifyEmailJSON)
+	if emailRaw != "" {
+		_ = json.Unmarshal([]byte(emailRaw), &cfg.Email)
+	}
+	if cfg.Email.Port <= 0 {
+		cfg.Email.Port = 587
+	}
+
+	webhookRaw, _ := s.settings.Get(ctx, settings.KeyNotifyWebhookJSON)
+	if webhookRaw != "" {
+		_ = json.Unmarshal([]byte(webhookRaw), &cfg.Webhook)
+	}
+	if cfg.Webhook.Server == "" && cfg.Webhook.Provider == WebhookBark {
+		cfg.Webhook.Server = DefaultBarkServer
+	}
+
+	telegramRaw, _ := s.settings.Get(ctx, settings.KeyNotifyTelegramJSON)
+	if telegramRaw != "" {
+		_ = json.Unmarshal([]byte(telegramRaw), &cfg.Telegram)
+	}
+
+	cfg.IPFrequentThreshold = intSetting(s.settings, ctx, settings.KeyNotifyIPFrequentThreshold, DefaultIPFrequentThreshold)
+	cfg.IPFrequentWindowSec = intSetting(s.settings, ctx, settings.KeyNotifyIPFrequentWindowSec, DefaultIPFrequentWindowSec)
+
+	cfg.LoginFailureThreshold = intSetting(s.settings, ctx, settings.KeyNotifyLoginFailureThreshold, DefaultLoginFailureThreshold)
+	cfg.LoginFailureWindowSec = intSetting(s.settings, ctx, settings.KeyNotifyLoginFailureWindowSec, DefaultLoginFailureWindowSec)
+
+	if hasNotifyEventSettings(ctx, s.settings) {
+		cfg.OnDDNSIPChange = flag(s.settings, ctx, settings.KeyNotifyOnDDNSIPChange, true)
+		cfg.OnDDNSFailure = flag(s.settings, ctx, settings.KeyNotifyOnDDNSFailure, true)
+		cfg.OnCertExpiry = flag(s.settings, ctx, settings.KeyNotifyOnCertExpiry, true)
+		cfg.OnCertRenewSuccess = flag(s.settings, ctx, settings.KeyNotifyOnCertRenewSuccess, true)
+		cfg.OnCertRenewFailure = flag(s.settings, ctx, settings.KeyNotifyOnCertRenewFailure, true)
+		cfg.OnIPFrequentAccess = flag(s.settings, ctx, settings.KeyNotifyOnIPFrequentAccess, false)
+		cfg.OnLoginFailure = flag(s.settings, ctx, settings.KeyNotifyOnLoginFailure, false)
+		cfg.OnNginxReloadFailure = flag(s.settings, ctx, settings.KeyNotifyOnNginxReloadFailure, true)
+	} else {
+		legacyDDNS := flag(s.settings, ctx, settings.KeyNotifyOnDDNSError, true)
+		legacyCert := flag(s.settings, ctx, settings.KeyNotifyOnCertError, true)
+		cfg.OnDDNSFailure = legacyDDNS
+		cfg.OnCertExpiry = legacyCert
+		cfg.OnCertRenewFailure = legacyCert
+		cfg.OnDDNSIPChange = true
+		cfg.OnCertRenewSuccess = true
+		cfg.OnNginxReloadFailure = true
+		cfg.OnIPFrequentAccess = false
+		cfg.OnLoginFailure = false
+	}
+
+	smtpEnc, _ := s.settings.Get(ctx, settings.KeyNotifySMTPPassword)
+	if smtpEnc != "" {
+		cfg.Email.HasPassword = true
+		if decrypt && s.secret != nil {
+			plain, err := s.secret.Decrypt(smtpEnc)
+			if err != nil {
+				return Config{}, runtimeSecrets{}, secret.DecryptHint(err)
+			}
+			secrets.smtpPassword = plain
+		}
+	}
+
+	webhookEnc, _ := s.settings.Get(ctx, settings.KeyNotifyWebhookSecret)
+	if webhookEnc != "" {
+		cfg.Webhook.HasSecret = true
+		if decrypt && s.secret != nil {
+			plain, err := s.secret.Decrypt(webhookEnc)
+			if err != nil {
+				return Config{}, runtimeSecrets{}, secret.DecryptHint(err)
+			}
+			secrets.webhookSecret = plain
+		}
+	}
+
+	telegramEnc, _ := s.settings.Get(ctx, settings.KeyNotifyTelegramToken)
+	if telegramEnc != "" {
+		cfg.Telegram.HasBotToken = true
+		if decrypt && s.secret != nil {
+			plain, err := s.secret.Decrypt(telegramEnc)
+			if err != nil {
+				return Config{}, runtimeSecrets{}, secret.DecryptHint(err)
+			}
+			secrets.telegramToken = plain
+		}
+	}
+
+	if cfg.Type == "" {
+		legacyURL, _ := s.settings.Get(ctx, settings.KeyNotifyWebhookURL)
+		if strings.TrimSpace(legacyURL) != "" {
+			cfg.Type = NotifyTypeWebhook
+			cfg.Webhook.Provider = WebhookCustom
+			cfg.Webhook.URL = strings.TrimSpace(legacyURL)
+		}
+	}
+
+	return cfg, secrets, nil
+}
+
+func flag(store *settings.Store, ctx context.Context, key string, fallback bool) bool {
+	raw, err := store.Get(ctx, key)
+	if err != nil || raw == "" {
+		return fallback
+	}
+	return raw == "1" || strings.EqualFold(raw, "true")
+}
+
+func intSetting(store *settings.Store, ctx context.Context, key string, fallback int) int {
+	raw, err := store.Get(ctx, key)
+	if err != nil || strings.TrimSpace(raw) == "" {
+		return fallback
+	}
+	n, err := strconv.Atoi(strings.TrimSpace(raw))
+	if err != nil || n <= 0 {
+		return fallback
+	}
+	return n
+}
+
+func hasNotifyEventSettings(ctx context.Context, store *settings.Store) bool {
+	keys := []string{
+		settings.KeyNotifyOnDDNSIPChange,
+		settings.KeyNotifyOnDDNSFailure,
+		settings.KeyNotifyOnCertExpiry,
+		settings.KeyNotifyOnCertRenewSuccess,
+		settings.KeyNotifyOnIPFrequentAccess,
+		settings.KeyNotifyOnCertRenewFailure,
+		settings.KeyNotifyOnLoginFailure,
+		settings.KeyNotifyOnNginxReloadFailure,
+	}
+	for _, key := range keys {
+		raw, err := store.Get(ctx, key)
+		if err == nil && raw != "" {
+			return true
+		}
+	}
+	return false
+}
+
+func (s *Store) Save(ctx context.Context, in SaveInput) error {
+	if err := validateSaveInput(in); err != nil {
+		return err
+	}
+
+	if err := s.settings.Set(ctx, settings.KeyNotifyType, string(in.Type)); err != nil {
+		return err
+	}
+	if err := s.settings.SetBool(ctx, settings.KeyNotifyOnDDNSIPChange, in.OnDDNSIPChange); err != nil {
+		return err
+	}
+	if err := s.settings.SetBool(ctx, settings.KeyNotifyOnDDNSFailure, in.OnDDNSFailure); err != nil {
+		return err
+	}
+	if err := s.settings.SetBool(ctx, settings.KeyNotifyOnCertExpiry, in.OnCertExpiry); err != nil {
+		return err
+	}
+	if err := s.settings.SetBool(ctx, settings.KeyNotifyOnCertRenewSuccess, in.OnCertRenewSuccess); err != nil {
+		return err
+	}
+	if err := s.settings.SetBool(ctx, settings.KeyNotifyOnIPFrequentAccess, in.OnIPFrequentAccess); err != nil {
+		return err
+	}
+	if err := s.settings.SetBool(ctx, settings.KeyNotifyOnCertRenewFailure, in.OnCertRenewFailure); err != nil {
+		return err
+	}
+	if err := s.settings.SetBool(ctx, settings.KeyNotifyOnLoginFailure, in.OnLoginFailure); err != nil {
+		return err
+	}
+	if err := s.settings.SetBool(ctx, settings.KeyNotifyOnNginxReloadFailure, in.OnNginxReloadFailure); err != nil {
+		return err
+	}
+	threshold := in.IPFrequentThreshold
+	if threshold <= 0 {
+		threshold = DefaultIPFrequentThreshold
+	}
+	windowSec := in.IPFrequentWindowSec
+	if windowSec <= 0 {
+		windowSec = DefaultIPFrequentWindowSec
+	}
+	if err := s.settings.SetInt(ctx, settings.KeyNotifyIPFrequentThreshold, threshold); err != nil {
+		return err
+	}
+	if err := s.settings.SetInt(ctx, settings.KeyNotifyIPFrequentWindowSec, windowSec); err != nil {
+		return err
+	}
+	loginThreshold := in.LoginFailureThreshold
+	if loginThreshold <= 0 {
+		loginThreshold = DefaultLoginFailureThreshold
+	}
+	loginWindowSec := in.LoginFailureWindowSec
+	if loginWindowSec <= 0 {
+		loginWindowSec = DefaultLoginFailureWindowSec
+	}
+	if err := s.settings.SetInt(ctx, settings.KeyNotifyLoginFailureThreshold, loginThreshold); err != nil {
+		return err
+	}
+	if err := s.settings.SetInt(ctx, settings.KeyNotifyLoginFailureWindowSec, loginWindowSec); err != nil {
+		return err
+	}
+
+	email := in.Email
+	email.HasPassword = email.HasPassword || strings.TrimSpace(in.SMTPPassword) != "" && in.SMTPPassword != MaskedSecret
+	emailJSON, err := json.Marshal(email)
+	if err != nil {
+		return err
+	}
+	if err := s.settings.Set(ctx, settings.KeyNotifyEmailJSON, string(emailJSON)); err != nil {
+		return err
+	}
+
+	webhook := in.Webhook
+	secretValue := strings.TrimSpace(in.WebhookSecret)
+	if secretValue != "" && secretValue != MaskedSecret {
+		webhook.HasSecret = true
+	} else if webhook.Provider == WebhookBark || webhook.Provider == WebhookGotify {
+		if strings.TrimSpace(webhook.Key) != "" && webhook.Key != MaskedSecret {
+			webhook.HasSecret = true
+		}
+	}
+	webhookJSON, err := json.Marshal(webhook)
+	if err != nil {
+		return err
+	}
+	if err := s.settings.Set(ctx, settings.KeyNotifyWebhookJSON, string(webhookJSON)); err != nil {
+		return err
+	}
+
+	telegram := in.Telegram
+	if strings.TrimSpace(in.TelegramToken) != "" && in.TelegramToken != MaskedSecret {
+		telegram.HasBotToken = true
+	}
+	telegramJSON, err := json.Marshal(telegram)
+	if err != nil {
+		return err
+	}
+	if err := s.settings.Set(ctx, settings.KeyNotifyTelegramJSON, string(telegramJSON)); err != nil {
+		return err
+	}
+
+	if in.Type == NotifyTypeWebhook && webhook.Provider == WebhookCustom {
+		if err := s.settings.Set(ctx, settings.KeyNotifyWebhookURL, strings.TrimSpace(webhook.URL)); err != nil {
+			return err
+		}
+	}
+
+	if err := s.saveSecret(ctx, settings.KeyNotifySMTPPassword, in.SMTPPassword, in.Email.HasPassword); err != nil {
+		return err
+	}
+
+	webhookPlain := secretValue
+	if webhookPlain == "" || webhookPlain == MaskedSecret {
+		if webhook.Provider == WebhookBark || webhook.Provider == WebhookGotify {
+			if strings.TrimSpace(webhook.Key) != "" && webhook.Key != MaskedSecret {
+				webhookPlain = strings.TrimSpace(webhook.Key)
+			}
+		} else if webhook.Provider == WebhookNtfy {
+			if strings.TrimSpace(webhook.Key) != "" && webhook.Key != MaskedSecret {
+				webhookPlain = strings.TrimSpace(webhook.Key)
+			}
+		}
+	}
+	if err := s.saveSecret(ctx, settings.KeyNotifyWebhookSecret, webhookPlain, webhook.HasSecret); err != nil {
+		return err
+	}
+
+	if err := s.saveSecret(ctx, settings.KeyNotifyTelegramToken, in.TelegramToken, telegram.HasBotToken); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (s *Store) saveSecret(ctx context.Context, key, plain string, hasExisting bool) error {
+	plain = strings.TrimSpace(plain)
+	if plain == "" || plain == MaskedSecret {
+		if !hasExisting {
+			return nil
+		}
+		return nil
+	}
+	if s.secret == nil {
+		return fmt.Errorf("加密模块未初始化")
+	}
+	enc, err := s.secret.Encrypt(plain)
+	if err != nil {
+		return err
+	}
+	return s.settings.Set(ctx, key, enc)
+}
+
+func validateSaveInput(in SaveInput) error {
+	switch in.Type {
+	case "":
+		return nil
+	case NotifyTypeEmail:
+		if strings.TrimSpace(in.Email.Host) == "" {
+			return fmt.Errorf("请填写 SMTP 主机")
+		}
+		if len(in.Email.To) == 0 {
+			return fmt.Errorf("请填写收件人邮箱")
+		}
+		password := strings.TrimSpace(in.SMTPPassword)
+		if password == "" || password == MaskedSecret {
+			if !in.Email.HasPassword {
+				return fmt.Errorf("请填写 SMTP 密码")
+			}
+		}
+	case NotifyTypeWebhook:
+		switch in.Webhook.Provider {
+		case WebhookBark:
+			if strings.TrimSpace(in.Webhook.Key) == "" && !in.Webhook.HasSecret {
+				return fmt.Errorf("请填写 Bark Device Key")
+			}
+		case WebhookNtfy:
+			if strings.TrimSpace(in.Webhook.Topic) == "" {
+				return fmt.Errorf("请填写 ntfy Topic")
+			}
+		case WebhookGotify:
+			if strings.TrimSpace(in.Webhook.Key) == "" && !in.Webhook.HasSecret {
+				return fmt.Errorf("请填写 Gotify App Token")
+			}
+		case WebhookCustom:
+			if strings.TrimSpace(in.Webhook.URL) == "" {
+				return fmt.Errorf("请填写 Webhook URL")
+			}
+		default:
+			return fmt.Errorf("不支持的 Webhook 预设")
+		}
+	case NotifyTypeTelegram:
+		token := strings.TrimSpace(in.TelegramToken)
+		if token == "" && !in.Telegram.HasBotToken {
+			return fmt.Errorf("请填写 Telegram Bot Token")
+		}
+		if strings.TrimSpace(in.Telegram.ChatID) == "" {
+			return fmt.Errorf("请填写 Telegram Chat ID")
+		}
+	default:
+		return fmt.Errorf("不支持的通知类型")
+	}
+	return nil
+}
+
+func SanitizeSettings(values map[string]string) {
+	delete(values, settings.KeyNotifySMTPPassword)
+	delete(values, settings.KeyNotifyWebhookSecret)
+	delete(values, settings.KeyNotifyTelegramToken)
+}
+
+func ParseSaveInputFromMap(values map[string]string) (SaveInput, bool) {
+	if values[settings.KeyNotifyType] == "" &&
+		values[settings.KeyNotifyEmailJSON] == "" &&
+		values[settings.KeyNotifyWebhookJSON] == "" &&
+		values[settings.KeyNotifyTelegramJSON] == "" &&
+		values[settings.KeyNotifySMTPPassword] == "" &&
+		values[settings.KeyNotifyWebhookSecret] == "" &&
+		values[settings.KeyNotifyTelegramToken] == "" {
+		return SaveInput{}, false
+	}
+
+	in := SaveInput{
+		Type:           NotifyType(values[settings.KeyNotifyType]),
+		SMTPPassword:   values[settings.KeyNotifySMTPPassword],
+		WebhookSecret:  values[settings.KeyNotifyWebhookSecret],
+		TelegramToken:  values[settings.KeyNotifyTelegramToken],
+	}
+	if raw := values[settings.KeyNotifyEmailJSON]; raw != "" {
+		_ = json.Unmarshal([]byte(raw), &in.Email)
+	}
+	if raw := values[settings.KeyNotifyWebhookJSON]; raw != "" {
+		_ = json.Unmarshal([]byte(raw), &in.Webhook)
+	}
+	if raw := values[settings.KeyNotifyTelegramJSON]; raw != "" {
+		_ = json.Unmarshal([]byte(raw), &in.Telegram)
+	}
+	in.OnDDNSIPChange = values[settings.KeyNotifyOnDDNSIPChange] == "1" || strings.EqualFold(values[settings.KeyNotifyOnDDNSIPChange], "true")
+	in.OnDDNSFailure = values[settings.KeyNotifyOnDDNSFailure] == "1" || strings.EqualFold(values[settings.KeyNotifyOnDDNSFailure], "true")
+	if !in.OnDDNSFailure {
+		in.OnDDNSFailure = values[settings.KeyNotifyOnDDNSError] == "1" || strings.EqualFold(values[settings.KeyNotifyOnDDNSError], "true")
+	}
+	in.OnCertExpiry = values[settings.KeyNotifyOnCertExpiry] == "1" || strings.EqualFold(values[settings.KeyNotifyOnCertExpiry], "true")
+	if !in.OnCertExpiry {
+		in.OnCertExpiry = values[settings.KeyNotifyOnCertError] == "1" || strings.EqualFold(values[settings.KeyNotifyOnCertError], "true")
+	}
+	in.OnCertRenewSuccess = values[settings.KeyNotifyOnCertRenewSuccess] == "1" || strings.EqualFold(values[settings.KeyNotifyOnCertRenewSuccess], "true")
+	in.OnIPFrequentAccess = values[settings.KeyNotifyOnIPFrequentAccess] == "1" || strings.EqualFold(values[settings.KeyNotifyOnIPFrequentAccess], "true")
+	in.OnCertRenewFailure = values[settings.KeyNotifyOnCertRenewFailure] == "1" || strings.EqualFold(values[settings.KeyNotifyOnCertRenewFailure], "true")
+	in.OnLoginFailure = values[settings.KeyNotifyOnLoginFailure] == "1" || strings.EqualFold(values[settings.KeyNotifyOnLoginFailure], "true")
+	in.OnNginxReloadFailure = values[settings.KeyNotifyOnNginxReloadFailure] == "1" || strings.EqualFold(values[settings.KeyNotifyOnNginxReloadFailure], "true")
+	in.IPFrequentThreshold = intSettingFromMap(values, settings.KeyNotifyIPFrequentThreshold, DefaultIPFrequentThreshold)
+	in.IPFrequentWindowSec = intSettingFromMap(values, settings.KeyNotifyIPFrequentWindowSec, DefaultIPFrequentWindowSec)
+	in.LoginFailureThreshold = intSettingFromMap(values, settings.KeyNotifyLoginFailureThreshold, DefaultLoginFailureThreshold)
+	in.LoginFailureWindowSec = intSettingFromMap(values, settings.KeyNotifyLoginFailureWindowSec, DefaultLoginFailureWindowSec)
+	return in, true
+}
+
+func intSettingFromMap(values map[string]string, key string, fallback int) int {
+	raw := strings.TrimSpace(values[key])
+	if raw == "" {
+		return fallback
+	}
+	n, err := strconv.Atoi(raw)
+	if err != nil || n <= 0 {
+		return fallback
+	}
+	return n
+}
diff --git a/internal/notify/store_test.go b/internal/notify/store_test.go
new file mode 100644
index 0000000..236ceee
--- /dev/null
+++ b/internal/notify/store_test.go
@@ -0,0 +1,86 @@
+package notify
+
+import (
+	"context"
+	"path/filepath"
+	"testing"
+
+	"github.com/fonu/fonu/internal/db"
+	"github.com/fonu/fonu/internal/secret"
+	"github.com/fonu/fonu/internal/settings"
+)
+
+func TestStoreSaveTelegramPersistsType(t *testing.T) {
+	dir := t.TempDir()
+	conn, err := db.Open(filepath.Join(dir, "fonu.db"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer conn.Close()
+	if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
+		t.Fatal(err)
+	}
+
+	settingsStore := settings.NewStore(conn)
+	box, err := secret.NewBox("test-secret-key-32bytes-long!!!")
+	if err != nil {
+		t.Fatal(err)
+	}
+	store := NewStore(settingsStore, box)
+	if err := store.Save(context.Background(), SaveInput{
+		Type: NotifyTypeTelegram,
+		Telegram: TelegramConfig{
+			ChatID:      "743955235",
+			ProxyURL:    "http://127.0.0.1:7890",
+			HasBotToken: true,
+		},
+		TelegramToken: "123456:ABC-DEF",
+	}); err != nil {
+		t.Fatal(err)
+	}
+
+	rawType, err := settingsStore.Get(context.Background(), settings.KeyNotifyType)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if rawType != "telegram" {
+		t.Fatalf("notify_type=%q, want telegram", rawType)
+	}
+	cfg, err := store.Load(context.Background())
+	if err != nil {
+		t.Fatal(err)
+	}
+	if cfg.Type != NotifyTypeTelegram || cfg.Telegram.ChatID != "743955235" {
+		t.Fatalf("unexpected loaded config: %+v", cfg)
+	}
+}
+
+func TestStoreMigratesLegacyWebhookURL(t *testing.T) {
+	dir := t.TempDir()
+	conn, err := db.Open(filepath.Join(dir, "fonu.db"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer conn.Close()
+	if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
+		t.Fatal(err)
+	}
+
+	settingsStore := settings.NewStore(conn)
+	if err := settingsStore.Set(context.Background(), settings.KeyNotifyWebhookURL, "https://example.com/hook"); err != nil {
+		t.Fatal(err)
+	}
+
+	box, err := secret.NewBox("test-secret-key-32bytes-long!!!")
+	if err != nil {
+		t.Fatal(err)
+	}
+	store := NewStore(settingsStore, box)
+	cfg, err := store.Load(context.Background())
+	if err != nil {
+		t.Fatal(err)
+	}
+	if cfg.Type != NotifyTypeWebhook || cfg.Webhook.Provider != WebhookCustom || cfg.Webhook.URL != "https://example.com/hook" {
+		t.Fatalf("unexpected migrated config: %+v", cfg)
+	}
+}
diff --git a/internal/notify/telegram.go b/internal/notify/telegram.go
new file mode 100644
index 0000000..2fb7813
--- /dev/null
+++ b/internal/notify/telegram.go
@@ -0,0 +1,69 @@
+package notify
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+)
+
+func SendTelegram(ctx context.Context, client *http.Client, cfg RuntimeConfig, content AlertContent) error {
+	token := strings.TrimSpace(cfg.TelegramToken)
+	if token == "" {
+		return fmt.Errorf("Telegram Bot Token 未配置")
+	}
+	chatID := strings.TrimSpace(cfg.Telegram.ChatID)
+	if chatID == "" {
+		return fmt.Errorf("Telegram Chat ID 未配置")
+	}
+
+	payload := map[string]string{
+		"chat_id":    chatID,
+		"text":       content.HTMLBody,
+		"parse_mode": "HTML",
+	}
+	body, _ := json.Marshal(payload)
+	endpoint := "https://api.telegram.org/bot" + token + "/sendMessage"
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Content-Type", "application/json")
+
+	httpClient := client
+	if httpClient == nil {
+		httpClient = &http.Client{Timeout: 15 * time.Second}
+	}
+	if proxyURL := strings.TrimSpace(cfg.Telegram.ProxyURL); proxyURL != "" {
+		parsed, err := url.Parse(proxyURL)
+		if err != nil {
+			return fmt.Errorf("Telegram 代理地址无效")
+		}
+		httpClient = &http.Client{
+			Timeout: 15 * time.Second,
+			Transport: &http.Transport{
+				Proxy: http.ProxyURL(parsed),
+			},
+		}
+	}
+
+	resp, err := httpClient.Do(req)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode >= 400 {
+		raw, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+		if len(raw) > 0 {
+			return fmt.Errorf("Telegram 请求失败 (%d): %s", resp.StatusCode, strings.TrimSpace(string(raw)))
+		}
+		return fmt.Errorf("Telegram 请求失败 (%d)", resp.StatusCode)
+	}
+	return nil
+}
diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go
new file mode 100644
index 0000000..002f14e
--- /dev/null
+++ b/internal/proxy/proxy.go
@@ -0,0 +1,724 @@
+package proxy
+
+import (
+	"context"
+	"database/sql"
+	"errors"
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/fonu/fonu/internal/validate"
+)
+
+type Host struct {
+	ID         int64  `json:"id"`
+	Hostname   string `json:"hostname"`
+	ListenPort *int   `json:"listen_port,omitempty"`
+}
+
+type Rule struct {
+	ID           int64          `json:"id"`
+	Domain       string         `json:"domain"`
+	Upstream     string         `json:"upstream"`
+	ListenPort   int            `json:"listen_port"`
+	ListenIPv4   bool           `json:"listen_ipv4"`
+	ListenIPv6   bool           `json:"listen_ipv6"`
+	Hosts        []Host         `json:"hosts"`
+	HTTPSEnabled bool           `json:"https_enabled"`
+	HTTPRedirect bool           `json:"http_redirect"`
+	Enabled      bool           `json:"enabled"`
+	NginxMode    string         `json:"nginx_mode"`
+	Name         string         `json:"name"`
+	SortOrder    int            `json:"sort_order"`
+	Security     SecurityConfig `json:"security"`
+	CreatedAt    time.Time      `json:"created_at"`
+	UpdatedAt    time.Time      `json:"updated_at"`
+}
+
+type PortGroup struct {
+	Port      int
+	Hostnames []string
+}
+
+func (r Rule) PrimaryHost() string {
+	if len(r.Hosts) > 0 {
+		return r.Hosts[0].Hostname
+	}
+	return ""
+}
+
+type Endpoint struct {
+	Hostname string
+	Port     int
+}
+
+func (r Rule) Endpoints() []Endpoint {
+	seen := make(map[string]bool)
+	var out []Endpoint
+	for _, host := range r.Hosts {
+		name := strings.ToLower(strings.TrimSpace(host.Hostname))
+		if name == "" {
+			continue
+		}
+		port := hostEffectivePort(host, r.ListenPort)
+		key := fmt.Sprintf("%s:%d", name, port)
+		if seen[key] {
+			continue
+		}
+		seen[key] = true
+		out = append(out, Endpoint{Hostname: name, Port: port})
+	}
+	if len(out) == 0 {
+		domain := strings.ToLower(strings.TrimSpace(r.Domain))
+		if domain != "" {
+			out = append(out, Endpoint{Hostname: domain, Port: r.ListenPort})
+		}
+	}
+	return out
+}
+
+func (r Rule) Hostnames() []string {
+	seen := make(map[string]bool)
+	var names []string
+	for _, host := range r.Hosts {
+		name := strings.ToLower(strings.TrimSpace(host.Hostname))
+		if name == "" || seen[name] {
+			continue
+		}
+		seen[name] = true
+		names = append(names, name)
+	}
+	if len(names) == 0 {
+		domain := strings.ToLower(strings.TrimSpace(r.Domain))
+		if domain != "" {
+			names = append(names, domain)
+		}
+	}
+	return names
+}
+
+func (r Rule) PortGroups() []PortGroup {
+	groups := map[int][]string{}
+	ports := make([]int, 0)
+	for _, host := range r.Hosts {
+		port := r.ListenPort
+		if host.ListenPort != nil && *host.ListenPort > 0 {
+			port = *host.ListenPort
+		}
+		if _, ok := groups[port]; !ok {
+			ports = append(ports, port)
+		}
+		groups[port] = append(groups[port], host.Hostname)
+	}
+
+	out := make([]PortGroup, 0, len(ports))
+	for _, port := range ports {
+		out = append(out, PortGroup{Port: port, Hostnames: groups[port]})
+	}
+	return out
+}
+
+type CreateInput struct {
+	Upstream     string
+	ListenPort   int
+	ListenIPv4   bool
+	ListenIPv6   bool
+	Hosts        []string
+	HTTPSEnabled bool
+	HTTPRedirect bool
+	Enabled      bool
+	Name         string
+	Security     SecurityInput
+}
+
+type UpdateInput struct {
+	Upstream     *string
+	ListenPort   *int
+	ListenIPv4   *bool
+	ListenIPv6   *bool
+	Hosts        *[]string
+	HTTPSEnabled *bool
+	HTTPRedirect *bool
+	Enabled      *bool
+	Name         *string
+	Security     *SecurityInput
+}
+
+type Store struct {
+	db *sql.DB
+	tx *sql.Tx
+}
+
+func NewStore(db *sql.DB) *Store {
+	return &Store{db: db}
+}
+
+func NewStoreWithTx(tx *sql.Tx) *Store {
+	return &Store{tx: tx}
+}
+
+func (s *Store) querier() interface {
+	QueryContext(context.Context, string, ...any) (*sql.Rows, error)
+	QueryRowContext(context.Context, string, ...any) *sql.Row
+	ExecContext(context.Context, string, ...any) (sql.Result, error)
+} {
+	if s.tx != nil {
+		return s.tx
+	}
+	return s.db
+}
+
+func (s *Store) List(ctx context.Context) ([]Rule, error) {
+	if err := s.cleanupOrphanRules(ctx); err != nil {
+		return nil, err
+	}
+	q := s.querier()
+	rows, err := q.QueryContext(ctx, `
+		SELECT id, upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, nginx_mode, name, sort_order, security_json, created_at, updated_at
+		FROM proxy_rules
+		ORDER BY sort_order ASC, id ASC
+	`)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var rules []Rule
+	for rows.Next() {
+		rule, err := scanRule(rows)
+		if err != nil {
+			return nil, err
+		}
+		rules = append(rules, rule)
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return s.attachHosts(ctx, rules)
+}
+
+func (s *Store) Get(ctx context.Context, id int64) (Rule, error) {
+	row := s.querier().QueryRowContext(ctx, `
+		SELECT id, upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, nginx_mode, name, sort_order, security_json, created_at, updated_at
+		FROM proxy_rules WHERE id = ?
+	`, id)
+	rule, err := scanRule(row)
+	if errors.Is(err, sql.ErrNoRows) {
+		return Rule{}, fmt.Errorf("规则不存在")
+	}
+	if err != nil {
+		return Rule{}, err
+	}
+	rules, err := s.attachHosts(ctx, []Rule{rule})
+	if err != nil {
+		return Rule{}, err
+	}
+	return rules[0], nil
+}
+
+func (s *Store) Create(ctx context.Context, in CreateInput) (Rule, error) {
+	upstream, hosts, err := validateCreateInput(in)
+	if err != nil {
+		return Rule{}, err
+	}
+	if err := s.ensureHostsAvailable(ctx, hosts, in.ListenPort, 0); err != nil {
+		return Rule{}, err
+	}
+
+	name, err := normalizeName(in.Name)
+	if err != nil {
+		return Rule{}, err
+	}
+	sortOrder, err := s.nextSortOrder(ctx)
+	if err != nil {
+		return Rule{}, err
+	}
+
+	security, err := MergeSecurity(DefaultSecurityConfig(), in.Security)
+	if err != nil {
+		return Rule{}, err
+	}
+	securityJSON, err := security.ToJSON()
+	if err != nil {
+		return Rule{}, err
+	}
+
+	res, err := s.querier().ExecContext(ctx, `
+		INSERT INTO proxy_rules(upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, name, sort_order, security_json, updated_at)
+		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
+	`, upstream, in.ListenPort, boolInt(in.ListenIPv4), boolInt(in.ListenIPv6), boolInt(in.HTTPSEnabled), boolInt(in.HTTPRedirect), boolInt(in.Enabled), name, sortOrder, securityJSON)
+	if err != nil {
+		return Rule{}, err
+	}
+	id, err := res.LastInsertId()
+	if err != nil {
+		return Rule{}, err
+	}
+	if err := s.replaceHosts(ctx, id, in.ListenPort, hosts); err != nil {
+		_, _ = s.querier().ExecContext(ctx, `DELETE FROM proxy_rules WHERE id = ?`, id)
+		return Rule{}, err
+	}
+	return s.Get(ctx, id)
+}
+
+func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (Rule, error) {
+	current, err := s.Get(ctx, id)
+	if err != nil {
+		return Rule{}, err
+	}
+
+	oldListenPort := current.ListenPort
+	upstream := current.Upstream
+	listenPort := current.ListenPort
+	listenIPv4 := current.ListenIPv4
+	listenIPv6 := current.ListenIPv6
+	httpsEnabled := current.HTTPSEnabled
+	httpRedirect := current.HTTPRedirect
+	enabled := current.Enabled
+	name := current.Name
+	hosts := current.Hosts
+
+	if in.Upstream != nil {
+		upstream, err = validate.Upstream(*in.Upstream)
+		if err != nil {
+			return Rule{}, err
+		}
+	}
+	if in.ListenPort != nil {
+		if err := validate.ListenPort(*in.ListenPort); err != nil {
+			return Rule{}, err
+		}
+		listenPort = *in.ListenPort
+	}
+	if in.ListenIPv4 != nil {
+		listenIPv4 = *in.ListenIPv4
+	}
+	if in.ListenIPv6 != nil {
+		listenIPv6 = *in.ListenIPv6
+	}
+	if !listenIPv4 && !listenIPv6 {
+		return Rule{}, fmt.Errorf("至少需要启用 IPv4 或 IPv6 监听")
+	}
+	if in.HTTPSEnabled != nil {
+		httpsEnabled = *in.HTTPSEnabled
+	}
+	if in.HTTPRedirect != nil {
+		httpRedirect = *in.HTTPRedirect
+	}
+	if in.Enabled != nil {
+		enabled = *in.Enabled
+	}
+	if in.Name != nil {
+		name, err = normalizeName(*in.Name)
+		if err != nil {
+			return Rule{}, err
+		}
+	}
+	if in.Hosts != nil {
+		hosts, err = parseHosts(*in.Hosts, listenPort)
+		if err != nil {
+			return Rule{}, err
+		}
+		if len(hosts) == 0 {
+			return Rule{}, fmt.Errorf("至少需要一个前端域名")
+		}
+	}
+
+	security := current.Security
+	if in.Security != nil {
+		security, err = MergeSecurity(current.Security, *in.Security)
+		if err != nil {
+			return Rule{}, err
+		}
+	}
+	securityJSON, err := security.ToJSON()
+	if err != nil {
+		return Rule{}, err
+	}
+
+	if in.ListenPort != nil && *in.ListenPort != oldListenPort && in.Hosts == nil {
+		for _, host := range current.Hosts {
+			if hostEffectivePort(host, oldListenPort) != oldListenPort {
+				continue
+			}
+			if err := s.ensureHostAvailable(ctx, host.Hostname, listenPort, id); err != nil {
+				return Rule{}, err
+			}
+		}
+	}
+
+	_, err = s.querier().ExecContext(ctx, `
+		UPDATE proxy_rules
+		SET upstream = ?, listen_port = ?, listen_ipv4 = ?, listen_ipv6 = ?, https_enabled = ?, http_redirect = ?, enabled = ?, name = ?, security_json = ?, updated_at = datetime('now')
+		WHERE id = ?
+	`, upstream, listenPort, boolInt(listenIPv4), boolInt(listenIPv6), boolInt(httpsEnabled), boolInt(httpRedirect), boolInt(enabled), name, securityJSON, id)
+	if err != nil {
+		return Rule{}, err
+	}
+	if in.ListenPort != nil && *in.ListenPort != oldListenPort && in.Hosts == nil {
+		if _, err := s.querier().ExecContext(ctx, `
+			UPDATE proxy_hosts SET listen_port = ? WHERE rule_id = ? AND listen_port = ?
+		`, listenPort, id, oldListenPort); err != nil {
+			return Rule{}, err
+		}
+	}
+	if in.Hosts != nil {
+		if err := s.ensureHostsAvailable(ctx, hosts, listenPort, id); err != nil {
+			return Rule{}, err
+		}
+		if err := s.replaceHosts(ctx, id, listenPort, hosts); err != nil {
+			return Rule{}, err
+		}
+	}
+	return s.Get(ctx, id)
+}
+
+func (s *Store) SetNginxMode(ctx context.Context, id int64, mode string) error {
+	mode = strings.TrimSpace(mode)
+	if mode != "auto" && mode != "custom" {
+		return fmt.Errorf("无效的 nginx 模式")
+	}
+	res, err := s.querier().ExecContext(ctx, `
+		UPDATE proxy_rules SET nginx_mode = ?, updated_at = datetime('now') WHERE id = ?
+	`, mode, id)
+	if err != nil {
+		return err
+	}
+	n, err := res.RowsAffected()
+	if err != nil {
+		return err
+	}
+	if n == 0 {
+		return fmt.Errorf("规则不存在")
+	}
+	return nil
+}
+
+func (s *Store) Delete(ctx context.Context, id int64) error {
+	res, err := s.querier().ExecContext(ctx, `DELETE FROM proxy_rules WHERE id = ?`, id)
+	if err != nil {
+		return err
+	}
+	n, err := res.RowsAffected()
+	if err != nil {
+		return err
+	}
+	if n == 0 {
+		return fmt.Errorf("规则不存在")
+	}
+	return nil
+}
+
+func (s *Store) ListEnabled(ctx context.Context) ([]Rule, error) {
+	rows, err := s.querier().QueryContext(ctx, `
+		SELECT id, upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, nginx_mode, name, sort_order, security_json, created_at, updated_at
+		FROM proxy_rules WHERE enabled = 1 ORDER BY sort_order ASC, id ASC
+	`)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var rules []Rule
+	for rows.Next() {
+		rule, err := scanRule(rows)
+		if err != nil {
+			return nil, err
+		}
+		rules = append(rules, rule)
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return s.attachHosts(ctx, rules)
+}
+
+func validateCreateInput(in CreateInput) (string, []Host, error) {
+	upstream, err := validate.Upstream(in.Upstream)
+	if err != nil {
+		return "", nil, err
+	}
+	if err := validate.ListenPort(in.ListenPort); err != nil {
+		return "", nil, err
+	}
+	if !in.ListenIPv4 && !in.ListenIPv6 {
+		return "", nil, fmt.Errorf("至少需要启用 IPv4 或 IPv6 监听")
+	}
+	hosts, err := parseHosts(in.Hosts, in.ListenPort)
+	if err != nil {
+		return "", nil, err
+	}
+	if len(hosts) == 0 {
+		return "", nil, fmt.Errorf("至少需要一个前端域名")
+	}
+	return upstream, hosts, nil
+}
+
+func (s *Store) cleanupOrphanRules(ctx context.Context) error {
+	_, err := s.querier().ExecContext(ctx, `
+		DELETE FROM proxy_rules
+		WHERE id NOT IN (SELECT rule_id FROM proxy_hosts)
+	`)
+	return err
+}
+
+func hostEffectivePort(host Host, ruleListenPort int) int {
+	if host.ListenPort != nil && *host.ListenPort > 0 {
+		return *host.ListenPort
+	}
+	return ruleListenPort
+}
+
+func parseHosts(raw []string, ruleListenPort int) ([]Host, error) {
+	seen := make(map[string]struct{})
+	hosts := make([]Host, 0, len(raw))
+	for _, item := range raw {
+		hostname, port, err := validate.FrontendAddress(item)
+		if err != nil {
+			return nil, err
+		}
+		effectivePort := ruleListenPort
+		if port > 0 {
+			effectivePort = port
+		}
+		key := fmt.Sprintf("%s:%d", strings.ToLower(hostname), effectivePort)
+		if _, ok := seen[key]; ok {
+			return nil, fmt.Errorf("前端域名 %s:%d 重复", hostname, effectivePort)
+		}
+		seen[key] = struct{}{}
+		host := Host{Hostname: hostname}
+		if port > 0 {
+			host.ListenPort = &port
+		}
+		hosts = append(hosts, host)
+	}
+	return hosts, nil
+}
+
+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
+	}
+	if err != nil {
+		return err
+	}
+	if ownerID != excludeRuleID {
+		return fmt.Errorf("前端域名 %s:%d 已被其他规则使用", hostname, port)
+	}
+	return nil
+}
+
+func (s *Store) ensureHostsAvailable(ctx context.Context, hosts []Host, ruleListenPort int, excludeRuleID int64) error {
+	for _, host := range hosts {
+		port := hostEffectivePort(host, ruleListenPort)
+		if err := s.ensureHostAvailable(ctx, host.Hostname, port, excludeRuleID); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (s *Store) replaceHosts(ctx context.Context, ruleID int64, ruleListenPort int, hosts []Host) error {
+	if _, err := s.querier().ExecContext(ctx, `DELETE FROM proxy_hosts WHERE rule_id = ?`, ruleID); err != nil {
+		return err
+	}
+	for _, host := range hosts {
+		port := hostEffectivePort(host, ruleListenPort)
+		_, err := s.querier().ExecContext(ctx, `
+			INSERT INTO proxy_hosts(rule_id, hostname, listen_port)
+			VALUES (?, ?, ?)
+		`, ruleID, host.Hostname, port)
+		if err != nil {
+			if strings.Contains(err.Error(), "UNIQUE") {
+				return fmt.Errorf("前端域名 %s:%d 已被其他规则使用", host.Hostname, port)
+			}
+			return err
+		}
+	}
+	return nil
+}
+
+func (s *Store) attachHosts(ctx context.Context, rules []Rule) ([]Rule, error) {
+	if len(rules) == 0 {
+		return rules, nil
+	}
+	ids := make([]string, len(rules))
+	args := make([]any, len(rules))
+	for i, rule := range rules {
+		ids[i] = "?"
+		args[i] = rule.ID
+	}
+
+	query := `
+		SELECT id, rule_id, hostname, listen_port
+		FROM proxy_hosts
+		WHERE rule_id IN (` + strings.Join(ids, ",") + `)
+		ORDER BY id ASC
+	`
+	rows, err := s.querier().QueryContext(ctx, query, args...)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	byRule := make(map[int64][]Host)
+	for rows.Next() {
+		var host Host
+		var ruleID int64
+		var listenPort sql.NullInt64
+		if err := rows.Scan(&host.ID, &ruleID, &host.Hostname, &listenPort); err != nil {
+			return nil, err
+		}
+		if listenPort.Valid {
+			port := int(listenPort.Int64)
+			host.ListenPort = &port
+		}
+		byRule[ruleID] = append(byRule[ruleID], host)
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+
+	for i := range rules {
+		rules[i].Hosts = byRule[rules[i].ID]
+		rules[i].Domain = rules[i].PrimaryHost()
+	}
+	return rules, nil
+}
+
+type rowScanner interface {
+	Scan(dest ...any) error
+}
+
+func scanRule(row rowScanner) (Rule, error) {
+	var rule Rule
+	var listenIPv4 int
+	var listenIPv6 int
+	var httpsEnabled int
+	var httpRedirect int
+	var enabled int
+	var nginxMode string
+	var securityJSON string
+	var createdAt string
+	var updatedAt string
+	if err := row.Scan(
+		&rule.ID,
+		&rule.Upstream,
+		&rule.ListenPort,
+		&listenIPv4,
+		&listenIPv6,
+		&httpsEnabled,
+		&httpRedirect,
+		&enabled,
+		&nginxMode,
+		&rule.Name,
+		&rule.SortOrder,
+		&securityJSON,
+		&createdAt,
+		&updatedAt,
+	); err != nil {
+		return Rule{}, err
+	}
+	rule.ListenIPv4 = listenIPv4 == 1
+	rule.ListenIPv6 = listenIPv6 == 1
+	rule.HTTPSEnabled = httpsEnabled == 1
+	rule.HTTPRedirect = httpRedirect == 1
+	rule.Enabled = enabled == 1
+	if nginxMode == "" {
+		nginxMode = "auto"
+	}
+	rule.NginxMode = nginxMode
+	security, err := ParseSecurityJSON(securityJSON)
+	if err != nil {
+		return Rule{}, err
+	}
+	rule.Security = security
+	rule.CreatedAt = parseTime(createdAt)
+	rule.UpdatedAt = parseTime(updatedAt)
+	return rule, nil
+}
+
+func parseTime(v string) time.Time {
+	t, err := time.Parse("2006-01-02 15:04:05", v)
+	if err != nil {
+		t, _ = time.Parse(time.RFC3339, v)
+	}
+	return t.UTC()
+}
+
+func boolInt(v bool) int {
+	if v {
+		return 1
+	}
+	return 0
+}
+
+func normalizeName(raw string) (string, error) {
+	name := strings.TrimSpace(raw)
+	if len(name) > 100 {
+		return "", fmt.Errorf("名称不能超过 100 个字符")
+	}
+	return name, nil
+}
+
+func (s *Store) nextSortOrder(ctx context.Context) (int, error) {
+	var maxOrder sql.NullInt64
+	err := s.querier().QueryRowContext(ctx, `SELECT MAX(sort_order) FROM proxy_rules`).Scan(&maxOrder)
+	if err != nil {
+		return 0, err
+	}
+	if !maxOrder.Valid {
+		return 0, nil
+	}
+	return int(maxOrder.Int64) + 1, nil
+}
+
+func (s *Store) Reorder(ctx context.Context, ids []int64) error {
+	if len(ids) == 0 {
+		return nil
+	}
+	seen := make(map[int64]struct{}, len(ids))
+	for _, id := range ids {
+		if id <= 0 {
+			return fmt.Errorf("无效的规则 ID")
+		}
+		if _, ok := seen[id]; ok {
+			return fmt.Errorf("排序列表包含重复的规则 ID")
+		}
+		seen[id] = struct{}{}
+	}
+
+	var total int
+	if err := s.querier().QueryRowContext(ctx, `SELECT COUNT(*) FROM proxy_rules`).Scan(&total); err != nil {
+		return err
+	}
+	if len(ids) != total {
+		return fmt.Errorf("排序列表必须包含全部规则")
+	}
+
+	tx, err := s.db.BeginTx(ctx, nil)
+	if err != nil {
+		return err
+	}
+	defer tx.Rollback()
+
+	for index, id := range ids {
+		res, err := tx.ExecContext(ctx, `UPDATE proxy_rules SET sort_order = ?, updated_at = datetime('now') WHERE id = ?`, index, id)
+		if err != nil {
+			return err
+		}
+		n, err := res.RowsAffected()
+		if err != nil {
+			return err
+		}
+		if n == 0 {
+			return fmt.Errorf("规则不存在")
+		}
+	}
+	return tx.Commit()
+}
diff --git a/internal/proxy/proxy_endpoints_test.go b/internal/proxy/proxy_endpoints_test.go
new file mode 100644
index 0000000..c14ec3f
--- /dev/null
+++ b/internal/proxy/proxy_endpoints_test.go
@@ -0,0 +1,25 @@
+package proxy
+
+import "testing"
+
+func TestRuleEndpointsUsesListenPort(t *testing.T) {
+	rule := Rule{
+		ListenPort: 6893,
+		Hosts: []Host{
+			{Hostname: "xxx.com"},
+			{Hostname: "xxx.com", ListenPort: intPtr(8081)},
+		},
+	}
+	endpoints := rule.Endpoints()
+	if len(endpoints) != 2 {
+		t.Fatalf("expected 2 endpoints, got %d", len(endpoints))
+	}
+	if endpoints[0].Hostname != "xxx.com" || endpoints[0].Port != 6893 {
+		t.Fatalf("unexpected first endpoint: %+v", endpoints[0])
+	}
+	if endpoints[1].Port != 8081 {
+		t.Fatalf("expected custom port 8081, got %d", endpoints[1].Port)
+	}
+}
+
+func intPtr(v int) *int { return &v }
diff --git a/internal/proxy/proxy_hosts_test.go b/internal/proxy/proxy_hosts_test.go
new file mode 100644
index 0000000..2f17b33
--- /dev/null
+++ b/internal/proxy/proxy_hosts_test.go
@@ -0,0 +1,28 @@
+package proxy
+
+import (
+	"testing"
+)
+
+func TestParseHostsAllowsSameHostnameDifferentPorts(t *testing.T) {
+	hosts, err := parseHosts([]string{"example.com", "example.com:6893"}, 8011)
+	if err != nil {
+		t.Fatalf("parseHosts failed: %v", err)
+	}
+	if len(hosts) != 2 {
+		t.Fatalf("expected 2 hosts, got %d", len(hosts))
+	}
+	if hostEffectivePort(hosts[0], 8011) != 8011 {
+		t.Fatalf("expected default port 8011, got %d", hostEffectivePort(hosts[0], 8011))
+	}
+	if hostEffectivePort(hosts[1], 8011) != 6893 {
+		t.Fatalf("expected custom port 6893, got %d", hostEffectivePort(hosts[1], 8011))
+	}
+}
+
+func TestParseHostsRejectsDuplicateBinding(t *testing.T) {
+	_, err := parseHosts([]string{"example.com", "example.com"}, 8011)
+	if err == nil {
+		t.Fatal("expected duplicate binding error")
+	}
+}
diff --git a/internal/proxy/security.go b/internal/proxy/security.go
new file mode 100644
index 0000000..ff3d077
--- /dev/null
+++ b/internal/proxy/security.go
@@ -0,0 +1,292 @@
+package proxy
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	"github.com/fonu/fonu/internal/validate"
+)
+
+type BasicAuthConfig struct {
+	Enabled      bool   `json:"enabled,omitempty"`
+	Username     string `json:"username,omitempty"`
+	PasswordHash string `json:"password_hash,omitempty"` // bcrypt htpasswd line body
+	HasPassword  bool   `json:"has_password,omitempty"`  // API response only
+}
+
+type RateLimitConfig struct {
+	Enabled bool `json:"enabled,omitempty"`
+	Rate    int  `json:"rate,omitempty"`  // requests per second
+	Burst   int  `json:"burst,omitempty"` // burst count
+}
+
+type ConnLimitConfig struct {
+	Enabled bool `json:"enabled,omitempty"`
+	Max     int  `json:"max,omitempty"` // max concurrent connections per IP
+}
+
+type SecurityConfig struct {
+	IPBlacklist       []string         `json:"ip_blacklist,omitempty"`
+	IPWhitelist       []string         `json:"ip_whitelist,omitempty"`
+	IPWhitelistMode   bool             `json:"ip_whitelist_mode,omitempty"`
+	ChinaOnly         bool             `json:"china_only,omitempty"`
+	BasicAuth         *BasicAuthConfig `json:"basic_auth,omitempty"`
+	RateLimit         *RateLimitConfig `json:"rate_limit,omitempty"`
+	ConnLimit         *ConnLimitConfig `json:"conn_limit,omitempty"`
+	ProxySSLVerifyOff bool             `json:"proxy_ssl_verify_off,omitempty"`
+	ProxyHostUpstream bool             `json:"proxy_host_upstream,omitempty"` // use $proxy_host instead of $host
+	TLSMin13Only      bool             `json:"tls_min_13_only,omitempty"`
+	SecurityHeaders   bool             `json:"security_headers,omitempty"`
+}
+
+type SecurityInput struct {
+	IPBlacklist       []string
+	IPWhitelist       []string
+	IPWhitelistMode   *bool
+	ChinaOnly         *bool
+	BasicAuth         *BasicAuthInput
+	RateLimit         *RateLimitConfig
+	ConnLimit         *ConnLimitConfig
+	ProxySSLVerifyOff *bool
+	ProxyHostUpstream *bool
+	TLSMin13Only      *bool
+	SecurityHeaders   *bool
+}
+
+type BasicAuthInput struct {
+	Enabled  *bool
+	Username *string
+	Password *string // plaintext on write only
+}
+
+// DefaultPrivateCIDRs are always exempt from china_only restrictions.
+var DefaultPrivateCIDRs = []string{
+	"10.0.0.0/8",
+	"127.0.0.0/8",
+	"172.16.0.0/12",
+	"192.168.0.0/16",
+	"::1/128",
+	"fc00::/7",
+}
+
+func ChinaBypassCIDRs(extra []string) []string {
+	seen := make(map[string]bool, len(DefaultPrivateCIDRs)+len(extra))
+	out := make([]string, 0, len(DefaultPrivateCIDRs)+len(extra))
+	for _, cidr := range DefaultPrivateCIDRs {
+		if !seen[cidr] {
+			seen[cidr] = true
+			out = append(out, cidr)
+		}
+	}
+	for _, cidr := range extra {
+		cidr = strings.TrimSpace(cidr)
+		if cidr != "" && !seen[cidr] {
+			seen[cidr] = true
+			out = append(out, cidr)
+		}
+	}
+	return out
+}
+
+func DefaultSecurityConfig() SecurityConfig {
+	return SecurityConfig{}
+}
+
+func ParseSecurityJSON(raw string) (SecurityConfig, error) {
+	raw = strings.TrimSpace(raw)
+	if raw == "" || raw == "{}" {
+		return DefaultSecurityConfig(), nil
+	}
+	var cfg SecurityConfig
+	if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
+		return SecurityConfig{}, fmt.Errorf("安全配置格式无效")
+	}
+	return cfg.Normalize(), nil
+}
+
+func (c SecurityConfig) Normalize() SecurityConfig {
+	if c.RateLimit != nil && c.RateLimit.Enabled {
+		if c.RateLimit.Rate <= 0 {
+			c.RateLimit.Rate = 10
+		}
+		if c.RateLimit.Burst <= 0 {
+			c.RateLimit.Burst = 20
+		}
+	}
+	if c.ConnLimit != nil && c.ConnLimit.Enabled {
+		if c.ConnLimit.Max <= 0 {
+			c.ConnLimit.Max = 20
+		}
+	}
+	return c
+}
+
+func RuleForAPI(rule Rule) Rule {
+	rule.Security = rule.Security.ForAPI()
+	return rule
+}
+
+func RulesForAPI(rules []Rule) []Rule {
+	out := make([]Rule, len(rules))
+	for i, rule := range rules {
+		out[i] = RuleForAPI(rule)
+	}
+	return out
+}
+
+func (c SecurityConfig) ForAPI() SecurityConfig {
+	out := c.Normalize()
+	if out.BasicAuth != nil {
+		hasPassword := out.BasicAuth.PasswordHash != ""
+		out.BasicAuth = &BasicAuthConfig{
+			Enabled:     out.BasicAuth.Enabled,
+			Username:    out.BasicAuth.Username,
+			HasPassword: hasPassword,
+		}
+	}
+	return out
+}
+
+func (c SecurityConfig) MarshalJSON() ([]byte, error) {
+	type alias SecurityConfig
+	return json.Marshal(alias(c.Normalize()))
+}
+
+func (c SecurityConfig) ToJSON() (string, error) {
+	b, err := c.MarshalJSON()
+	if err != nil {
+		return "", err
+	}
+	return string(b), nil
+}
+
+func (c SecurityConfig) BasicAuthEnabled() bool {
+	return c.BasicAuth != nil && c.BasicAuth.Enabled && c.BasicAuth.Username != "" && c.BasicAuth.PasswordHash != ""
+}
+
+func (c SecurityConfig) RateLimitEnabled() bool {
+	return c.RateLimit != nil && c.RateLimit.Enabled
+}
+
+func (c SecurityConfig) ConnLimitEnabled() bool {
+	return c.ConnLimit != nil && c.ConnLimit.Enabled
+}
+
+func (c SecurityConfig) WhitelistEnabled() bool {
+	return c.IPWhitelistMode && len(c.IPWhitelist) > 0
+}
+
+func MergeSecurity(current SecurityConfig, in SecurityInput) (SecurityConfig, error) {
+	out := current.Normalize()
+
+	if in.IPBlacklist != nil {
+		out.IPBlacklist = in.IPBlacklist
+	}
+	if in.IPWhitelist != nil {
+		out.IPWhitelist = in.IPWhitelist
+	}
+	if in.IPWhitelistMode != nil {
+		out.IPWhitelistMode = *in.IPWhitelistMode
+	}
+	if in.ChinaOnly != nil {
+		out.ChinaOnly = *in.ChinaOnly
+	}
+	if in.RateLimit != nil {
+		out.RateLimit = in.RateLimit
+	}
+	if in.ConnLimit != nil {
+		out.ConnLimit = in.ConnLimit
+	}
+	if in.ProxySSLVerifyOff != nil {
+		out.ProxySSLVerifyOff = *in.ProxySSLVerifyOff
+	}
+	if in.ProxyHostUpstream != nil {
+		out.ProxyHostUpstream = *in.ProxyHostUpstream
+	}
+	if in.TLSMin13Only != nil {
+		out.TLSMin13Only = *in.TLSMin13Only
+	}
+	if in.SecurityHeaders != nil {
+		out.SecurityHeaders = *in.SecurityHeaders
+	}
+
+	if in.BasicAuth != nil {
+		if in.BasicAuth.Enabled != nil {
+			if out.BasicAuth == nil {
+				out.BasicAuth = &BasicAuthConfig{}
+			}
+			out.BasicAuth.Enabled = *in.BasicAuth.Enabled
+		}
+		if in.BasicAuth.Username != nil {
+			if out.BasicAuth == nil {
+				out.BasicAuth = &BasicAuthConfig{}
+			}
+			out.BasicAuth.Username = strings.TrimSpace(*in.BasicAuth.Username)
+		}
+		if in.BasicAuth.Password != nil && strings.TrimSpace(*in.BasicAuth.Password) != "" {
+			if out.BasicAuth == nil {
+				out.BasicAuth = &BasicAuthConfig{}
+			}
+			hash, err := validate.BasicAuthPasswordHash(out.BasicAuth.Username, *in.BasicAuth.Password)
+			if err != nil {
+				return SecurityConfig{}, err
+			}
+			out.BasicAuth.PasswordHash = hash
+		}
+	}
+
+	return out, ValidateSecurity(out)
+}
+
+func ValidateSecurity(cfg SecurityConfig) error {
+	cfg = cfg.Normalize()
+	if err := validate.IPOrCIDRList(cfg.IPBlacklist); err != nil {
+		return fmt.Errorf("IP 黑名单:%w", err)
+	}
+	if err := validate.IPOrCIDRList(cfg.IPWhitelist); err != nil {
+		return fmt.Errorf("IP 白名单:%w", err)
+	}
+	if cfg.IPWhitelistMode && len(cfg.IPWhitelist) == 0 {
+		return fmt.Errorf("启用白名单模式时至少添加一条 IP 或 CIDR")
+	}
+	if cfg.BasicAuth != nil && cfg.BasicAuth.Enabled {
+		if err := validate.Username(cfg.BasicAuth.Username); err != nil {
+			return fmt.Errorf("Basic Auth:%w", err)
+		}
+		if cfg.BasicAuth.PasswordHash == "" {
+			return fmt.Errorf("Basic Auth 需要设置密码")
+		}
+	}
+	if cfg.RateLimit != nil && cfg.RateLimit.Enabled {
+		if cfg.RateLimit.Rate < 1 || cfg.RateLimit.Rate > 10000 {
+			return fmt.Errorf("限流速率无效(1-10000 次/秒)")
+		}
+		if cfg.RateLimit.Burst < 1 || cfg.RateLimit.Burst > 100000 {
+			return fmt.Errorf("限流突发值无效(1-100000)")
+		}
+	}
+	if cfg.ConnLimit != nil && cfg.ConnLimit.Enabled {
+		if cfg.ConnLimit.Max < 1 || cfg.ConnLimit.Max > 10000 {
+			return fmt.Errorf("连接数限制无效(1-10000)")
+		}
+	}
+	return nil
+}
+
+func ParseIPListText(text string) []string {
+	lines := strings.Split(text, "\n")
+	out := make([]string, 0, len(lines))
+	for _, line := range lines {
+		line = strings.TrimSpace(line)
+		if line == "" || strings.HasPrefix(line, "#") {
+			continue
+		}
+		out = append(out, line)
+	}
+	return out
+}
+
+func FormatIPListText(list []string) string {
+	return strings.Join(list, "\n")
+}
diff --git a/internal/proxy/security_test.go b/internal/proxy/security_test.go
new file mode 100644
index 0000000..87613b6
--- /dev/null
+++ b/internal/proxy/security_test.go
@@ -0,0 +1,102 @@
+package proxy
+
+import (
+	"testing"
+
+	"github.com/fonu/fonu/internal/validate"
+)
+
+func TestForAPIStripsPasswordHashButRuntimeKeepsAuth(t *testing.T) {
+	hash := "$2a$10$abcdefghijklmnopqrstuv"
+	sec := SecurityConfig{
+		BasicAuth: &BasicAuthConfig{
+			Enabled:      true,
+			Username:     "admin",
+			PasswordHash: hash,
+		},
+	}
+	if !sec.BasicAuthEnabled() {
+		t.Fatal("expected runtime basic auth enabled")
+	}
+	api := sec.ForAPI()
+	if api.BasicAuth.PasswordHash != "" {
+		t.Fatal("expected API view to strip password hash")
+	}
+	if !api.BasicAuth.HasPassword {
+		t.Fatal("expected has_password flag in API view")
+	}
+	if api.BasicAuthEnabled() {
+		t.Fatal("API view must not be treated as nginx-ready basic auth config")
+	}
+}
+
+func TestMergeSecurityBasicAuthKeepsHash(t *testing.T) {
+	hash, err := validate.BasicAuthPasswordHash("admin", "password123")
+	if err != nil {
+		t.Fatal(err)
+	}
+	current := SecurityConfig{
+		BasicAuth: &BasicAuthConfig{
+			Enabled:      true,
+			Username:     "admin",
+			PasswordHash: hash,
+		},
+	}
+	enabled := true
+	out, err := MergeSecurity(current, SecurityInput{
+		BasicAuth: &BasicAuthInput{Enabled: &enabled, Username: strPtr("admin")},
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if out.BasicAuth.PasswordHash != hash {
+		t.Fatalf("expected hash preserved")
+	}
+}
+
+func TestValidateSecurityWhitelistRequiresEntries(t *testing.T) {
+	err := ValidateSecurity(SecurityConfig{IPWhitelistMode: true})
+	if err == nil {
+		t.Fatal("expected error")
+	}
+}
+
+func TestChinaBypassCIDRsIncludesDefaults(t *testing.T) {
+	got := ChinaBypassCIDRs(nil)
+	for _, want := range DefaultPrivateCIDRs {
+		if !contains(got, want) {
+			t.Fatalf("missing default %s in %v", want, got)
+		}
+	}
+}
+
+func TestChinaBypassCIDRsMergesExtra(t *testing.T) {
+	got := ChinaBypassCIDRs([]string{"203.0.113.0/24", "192.168.0.0/16"})
+	if !contains(got, "203.0.113.0/24") {
+		t.Fatalf("missing extra CIDR: %v", got)
+	}
+	if count(got, "192.168.0.0/16") != 1 {
+		t.Fatalf("expected single 192.168.0.0/16 entry: %v", got)
+	}
+}
+
+func contains(ss []string, s string) bool {
+	for _, v := range ss {
+		if v == s {
+			return true
+		}
+	}
+	return false
+}
+
+func count(ss []string, s string) int {
+	n := 0
+	for _, v := range ss {
+		if v == s {
+			n++
+		}
+	}
+	return n
+}
+
+func strPtr(s string) *string { return &s }
diff --git a/internal/publicip/publicip.go b/internal/publicip/publicip.go
new file mode 100644
index 0000000..716a53a
--- /dev/null
+++ b/internal/publicip/publicip.go
@@ -0,0 +1,122 @@
+package publicip
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"strings"
+	"time"
+)
+
+var ipv4Providers = []string{
+	"https://api.ipify.org",
+	"https://ipv4.icanhazip.com",
+	"https://ifconfig.me/ip",
+	"http://ipv4.icanhazip.com",
+}
+
+var ipv6Providers = []string{
+	"https://api64.ipify.org",
+	"https://ipv6.icanhazip.com",
+}
+
+func Detect(ctx context.Context) (ipv4, ipv6 string, err error) {
+	ipv4, v4err := detectIPv4(ctx)
+	ipv6, _ = detectIPv6(ctx)
+	if v4err != nil {
+		return "", ipv6, v4err
+	}
+	return ipv4, ipv6, nil
+}
+
+func detectClient() *http.Client {
+	return &http.Client{
+		Timeout: 8 * time.Second,
+		Transport: &http.Transport{
+			Proxy: nil,
+			DialContext: (&net.Dialer{
+				Timeout:   8 * time.Second,
+				KeepAlive: 30 * time.Second,
+			}).DialContext,
+		},
+	}
+}
+
+func detectIPv4(ctx context.Context) (string, error) {
+	client := detectClient()
+	var lastErr error
+	for _, url := range ipv4Providers {
+		ip, err := fetchIP(ctx, client, url, false)
+		if err == nil {
+			return ip, nil
+		}
+		lastErr = err
+	}
+	return "", fmt.Errorf("获取公网 IPv4 失败:%w", lastErr)
+}
+
+func detectIPv6(ctx context.Context) (string, error) {
+	client := detectClient()
+	for _, url := range ipv6Providers {
+		ip, err := fetchIP(ctx, client, url, true)
+		if err == nil {
+			return ip, nil
+		}
+	}
+	return "", fmt.Errorf("no ipv6")
+}
+
+func fetchIP(ctx context.Context, client *http.Client, url string, wantV6 bool) (string, error) {
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+	if err != nil {
+		return "", err
+	}
+	resp, err := client.Do(req)
+	if err != nil {
+		return "", err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		return "", fmt.Errorf("http %d", resp.StatusCode)
+	}
+	body, err := io.ReadAll(io.LimitReader(resp.Body, 128))
+	if err != nil {
+		return "", err
+	}
+	ip := strings.TrimSpace(string(body))
+	parsed := net.ParseIP(ip)
+	if parsed == nil {
+		return "", fmt.Errorf("invalid ip response")
+	}
+	if wantV6 && parsed.To4() != nil {
+		return "", fmt.Errorf("no ipv6")
+	}
+	if !wantV6 && parsed.To4() == nil {
+		return "", fmt.Errorf("no ipv4")
+	}
+	if !wantV6 && !IsPublicIPv4(ip) {
+		return "", fmt.Errorf("private ipv4")
+	}
+	if wantV6 && !IsPublicIPv6(ip) {
+		return "", fmt.Errorf("private ipv6")
+	}
+	return ip, nil
+}
+
+func IsPublicIPv4(ip string) bool {
+	parsed := net.ParseIP(strings.TrimSpace(ip))
+	if parsed == nil || parsed.To4() == nil {
+		return false
+	}
+	return !parsed.IsPrivate() && !parsed.IsLoopback() && !parsed.IsLinkLocalUnicast() && !parsed.IsUnspecified()
+}
+
+func IsPublicIPv6(ip string) bool {
+	parsed := net.ParseIP(strings.TrimSpace(ip))
+	if parsed == nil || parsed.To4() != nil {
+		return false
+	}
+	return !parsed.IsPrivate() && !parsed.IsLoopback() && !parsed.IsLinkLocalUnicast() && !parsed.IsUnspecified()
+}
diff --git a/internal/publicip/publicip_test.go b/internal/publicip/publicip_test.go
new file mode 100644
index 0000000..ed457cb
--- /dev/null
+++ b/internal/publicip/publicip_test.go
@@ -0,0 +1,12 @@
+package publicip
+
+import "testing"
+
+func TestIsPublicIPv4(t *testing.T) {
+	if IsPublicIPv4("192.168.1.1") {
+		t.Fatal("private ip should be rejected")
+	}
+	if !IsPublicIPv4("8.8.8.8") {
+		t.Fatal("public ip should be accepted")
+	}
+}
diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go
new file mode 100644
index 0000000..9b8aa41
--- /dev/null
+++ b/internal/scheduler/scheduler.go
@@ -0,0 +1,40 @@
+package scheduler
+
+import (
+	"context"
+	"time"
+)
+
+type Job struct {
+	Name     string
+	Interval time.Duration
+	Run      func(ctx context.Context)
+}
+
+type Scheduler struct {
+	jobs []Job
+}
+
+func New(jobs ...Job) *Scheduler {
+	return &Scheduler{jobs: jobs}
+}
+
+func (s *Scheduler) Start(ctx context.Context) {
+	for _, job := range s.jobs {
+		go s.runJob(ctx, job)
+	}
+}
+
+func (s *Scheduler) runJob(ctx context.Context, job Job) {
+	ticker := time.NewTicker(job.Interval)
+	defer ticker.Stop()
+	job.Run(ctx)
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		case <-ticker.C:
+			job.Run(ctx)
+		}
+	}
+}
diff --git a/internal/secret/secret.go b/internal/secret/secret.go
new file mode 100644
index 0000000..511420d
--- /dev/null
+++ b/internal/secret/secret.go
@@ -0,0 +1,84 @@
+package secret
+
+import (
+	"crypto/aes"
+	"crypto/cipher"
+	"crypto/rand"
+	"crypto/sha256"
+	"encoding/base64"
+	"errors"
+	"fmt"
+	"io"
+	"strings"
+)
+
+var ErrEmpty = errors.New("secret is empty")
+
+type Box struct {
+	gcm cipher.AEAD
+}
+
+func NewBox(keyMaterial string) (*Box, error) {
+	if keyMaterial == "" {
+		return nil, fmt.Errorf("encryption key material is required")
+	}
+	sum := sha256.Sum256([]byte(keyMaterial))
+	block, err := aes.NewCipher(sum[:])
+	if err != nil {
+		return nil, err
+	}
+	gcm, err := cipher.NewGCM(block)
+	if err != nil {
+		return nil, err
+	}
+	return &Box{gcm: gcm}, nil
+}
+
+func (b *Box) Encrypt(plain string) (string, error) {
+	if plain == "" {
+		return "", ErrEmpty
+	}
+	nonce := make([]byte, b.gcm.NonceSize())
+	if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
+		return "", err
+	}
+	ciphertext := b.gcm.Seal(nonce, nonce, []byte(plain), nil)
+	return base64.StdEncoding.EncodeToString(ciphertext), nil
+}
+
+func (b *Box) Decrypt(encoded string) (string, error) {
+	if encoded == "" {
+		return "", ErrEmpty
+	}
+	raw, err := base64.StdEncoding.DecodeString(encoded)
+	if err != nil {
+		return "", err
+	}
+	nonceSize := b.gcm.NonceSize()
+	if len(raw) < nonceSize {
+		return "", fmt.Errorf("invalid ciphertext")
+	}
+	nonce, ciphertext := raw[:nonceSize], raw[nonceSize:]
+	plain, err := b.gcm.Open(nil, nonce, ciphertext, nil)
+	if err != nil {
+		return "", err
+	}
+	return string(plain), nil
+}
+
+func IsDecryptFailure(err error) bool {
+	if err == nil {
+		return false
+	}
+	return strings.Contains(err.Error(), "message authentication failed")
+}
+
+func DecryptHint(err error) error {
+	if err == nil {
+		return nil
+	}
+	if IsDecryptFailure(err) {
+		return fmt.Errorf("DNS API 凭据无法解密,请在 DDNS 页面重新保存 API Token(服务加密密钥可能已变更)")
+	}
+	return err
+}
diff --git a/internal/service/nginx_config.go b/internal/service/nginx_config.go
new file mode 100644
index 0000000..e67eeaa
--- /dev/null
+++ b/internal/service/nginx_config.go
@@ -0,0 +1,331 @@
+package service
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"github.com/fonu/fonu/internal/nginx"
+	"github.com/fonu/fonu/internal/proxy"
+	"github.com/fonu/fonu/internal/settings"
+)
+
+type NginxBackupInfo struct {
+	Name      string `json:"name"`
+	CreatedAt string `json:"created_at"`
+}
+
+type RuleNginxView struct {
+	Mode      string            `json:"mode"`
+	Enabled   bool              `json:"enabled"`
+	Active    bool              `json:"active"`
+	Generated string            `json:"generated"`
+	Content   string            `json:"content"`
+	Backups   []NginxBackupInfo `json:"backups"`
+}
+
+type GlobalNginxView struct {
+	Mode                string            `json:"mode"`
+	GeneratedFramework  string            `json:"generated_framework"`
+	GeneratedSnippet    string            `json:"generated_snippet"`
+	Content             string            `json:"content"`
+	Backups             []NginxBackupInfo `json:"backups"`
+}
+
+type SaveRuleNginxInput struct {
+	Mode    string
+	Content *string
+}
+
+type SaveGlobalNginxInput struct {
+	Mode    string
+	Content *string
+}
+
+func (s *ProxyService) GetRuleNginx(ctx context.Context, id int64) (RuleNginxView, error) {
+	rule, err := s.Get(ctx, id)
+	if err != nil {
+		return RuleNginxView{}, err
+	}
+	certs, err := s.loadCertSources(ctx)
+	if err != nil {
+		return RuleNginxView{}, err
+	}
+	opts := s.loadGenerateOptions(ctx)
+	generated, err := nginx.GenerateRuleBlocks(s.cfg, rule, certs, opts)
+	if err != nil {
+		return RuleNginxView{}, err
+	}
+	mode := rule.NginxMode
+	if mode == "" {
+		mode = "auto"
+	}
+	content := generated
+	if mode == "custom" {
+		custom, err := nginx.ReadRuleCustom(s.cfg, id)
+		if err != nil {
+			return RuleNginxView{}, err
+		}
+		if strings.TrimSpace(custom) != "" {
+			content = custom
+		}
+	}
+	backups, err := nginx.ListBackups(s.cfg.NginxRuleBackupsDir(id))
+	if err != nil {
+		return RuleNginxView{}, err
+	}
+	return RuleNginxView{
+		Mode:      mode,
+		Enabled:   rule.Enabled,
+		Active:    rule.Enabled,
+		Generated: generated,
+		Content:   content,
+		Backups:   mapBackups(backups),
+	}, nil
+}
+
+func (s *ProxyService) SaveRuleNginx(ctx context.Context, id int64, in SaveRuleNginxInput) (RuleNginxView, error) {
+	rule, err := s.Get(ctx, id)
+	if err != nil {
+		return RuleNginxView{}, err
+	}
+	mode := strings.TrimSpace(in.Mode)
+	if mode == "" {
+		mode = rule.NginxMode
+	}
+	if mode != "auto" && mode != "custom" {
+		return RuleNginxView{}, fmt.Errorf("无效的 nginx 模式")
+	}
+
+	if mode == "auto" {
+		if err := s.store.SetNginxMode(ctx, id, "auto"); err != nil {
+			return RuleNginxView{}, err
+		}
+		if err := s.applyNginx(ctx); err != nil {
+			return RuleNginxView{}, err
+		}
+		return s.GetRuleNginx(ctx, id)
+	}
+
+	if in.Content == nil {
+		return RuleNginxView{}, fmt.Errorf("手动模式需要提供配置内容")
+	}
+	content := *in.Content
+	if strings.TrimSpace(content) == "" {
+		return RuleNginxView{}, fmt.Errorf("Nginx 配置不能为空")
+	}
+
+	if err := s.validateRuleNginxPending(ctx, rule, content); err != nil {
+		return RuleNginxView{}, err
+	}
+	if _, err := nginx.SaveRuleCustomWithBackup(s.cfg, id, content); err != nil {
+		return RuleNginxView{}, err
+	}
+	if err := s.store.SetNginxMode(ctx, id, "custom"); err != nil {
+		return RuleNginxView{}, err
+	}
+	if err := s.applyNginx(ctx); err != nil {
+		return RuleNginxView{}, err
+	}
+	return s.GetRuleNginx(ctx, id)
+}
+
+func (s *ProxyService) RollbackRuleNginx(ctx context.Context, id int64, backupName string) (RuleNginxView, error) {
+	if _, err := s.Get(ctx, id); err != nil {
+		return RuleNginxView{}, err
+	}
+	backups, err := nginx.ListBackups(s.cfg.NginxRuleBackupsDir(id))
+	if err != nil {
+		return RuleNginxView{}, err
+	}
+	if len(backups) == 0 {
+		return RuleNginxView{}, fmt.Errorf("没有可回滚的备份")
+	}
+	if backupName == "" {
+		backupName = backups[0].Name
+	}
+	if err := nginx.RestoreBackup(s.cfg.NginxRuleBackupsDir(id), backupName, s.cfg.NginxRuleCustomPath(id)); err != nil {
+		return RuleNginxView{}, err
+	}
+	custom, err := nginx.ReadRuleCustom(s.cfg, id)
+	if err != nil {
+		return RuleNginxView{}, err
+	}
+	rule, err := s.Get(ctx, id)
+	if err != nil {
+		return RuleNginxView{}, err
+	}
+	if err := s.validateRuleNginxPending(ctx, rule, custom); err != nil {
+		return RuleNginxView{}, err
+	}
+	if err := s.store.SetNginxMode(ctx, id, "custom"); err != nil {
+		return RuleNginxView{}, err
+	}
+	if err := s.applyNginx(ctx); err != nil {
+		return RuleNginxView{}, err
+	}
+	return s.GetRuleNginx(ctx, id)
+}
+
+func (s *ProxyService) GetGlobalNginx(ctx context.Context) (GlobalNginxView, error) {
+	mode, err := s.settings.Get(ctx, settings.KeyNginxGlobalMode)
+	if err != nil {
+		return GlobalNginxView{}, err
+	}
+	if mode == "" {
+		mode = "auto"
+	}
+	content, err := nginx.ReadGlobalCustom(s.cfg)
+	if err != nil {
+		return GlobalNginxView{}, err
+	}
+	backups, err := nginx.ListBackups(s.cfg.NginxGlobalBackupsDir())
+	if err != nil {
+		return GlobalNginxView{}, err
+	}
+	return GlobalNginxView{
+		Mode:               mode,
+		GeneratedFramework: nginx.GenerateGlobalFramework(s.cfg),
+		GeneratedSnippet:   nginx.DefaultGlobalHTTPSnippet(),
+		Content:            content,
+		Backups:            mapBackups(backups),
+	}, nil
+}
+
+func (s *ProxyService) SaveGlobalNginx(ctx context.Context, in SaveGlobalNginxInput) (GlobalNginxView, error) {
+	mode := strings.TrimSpace(in.Mode)
+	if mode == "" {
+		var err error
+		mode, err = s.settings.Get(ctx, settings.KeyNginxGlobalMode)
+		if err != nil {
+			return GlobalNginxView{}, err
+		}
+	}
+	if mode != "auto" && mode != "custom" {
+		return GlobalNginxView{}, fmt.Errorf("无效的 nginx 模式")
+	}
+
+	if mode == "auto" {
+		if err := s.settings.Set(ctx, settings.KeyNginxGlobalMode, "auto"); err != nil {
+			return GlobalNginxView{}, err
+		}
+		if err := s.applyNginx(ctx); err != nil {
+			return GlobalNginxView{}, err
+		}
+		return s.GetGlobalNginx(ctx)
+	}
+
+	if in.Content == nil {
+		return GlobalNginxView{}, fmt.Errorf("手动模式需要提供配置内容")
+	}
+	content := *in.Content
+	if err := s.validateGlobalNginxPending(ctx, content); err != nil {
+		return GlobalNginxView{}, err
+	}
+	if _, err := nginx.SaveGlobalCustomWithBackup(s.cfg, content); err != nil {
+		return GlobalNginxView{}, err
+	}
+	if err := s.settings.Set(ctx, settings.KeyNginxGlobalMode, "custom"); err != nil {
+		return GlobalNginxView{}, err
+	}
+	if err := s.applyNginx(ctx); err != nil {
+		return GlobalNginxView{}, err
+	}
+	return s.GetGlobalNginx(ctx)
+}
+
+func (s *ProxyService) RollbackGlobalNginx(ctx context.Context, backupName string) (GlobalNginxView, error) {
+	backups, err := nginx.ListBackups(s.cfg.NginxGlobalBackupsDir())
+	if err != nil {
+		return GlobalNginxView{}, err
+	}
+	if len(backups) == 0 {
+		return GlobalNginxView{}, fmt.Errorf("没有可回滚的备份")
+	}
+	if backupName == "" {
+		backupName = backups[0].Name
+	}
+	if err := nginx.RestoreBackup(s.cfg.NginxGlobalBackupsDir(), backupName, s.cfg.NginxGlobalCustomPath()); err != nil {
+		return GlobalNginxView{}, err
+	}
+	content, err := nginx.ReadGlobalCustom(s.cfg)
+	if err != nil {
+		return GlobalNginxView{}, err
+	}
+	if err := s.validateGlobalNginxPending(ctx, content); err != nil {
+		return GlobalNginxView{}, err
+	}
+	if err := s.settings.Set(ctx, settings.KeyNginxGlobalMode, "custom"); err != nil {
+		return GlobalNginxView{}, err
+	}
+	if err := s.applyNginx(ctx); err != nil {
+		return GlobalNginxView{}, err
+	}
+	return s.GetGlobalNginx(ctx)
+}
+
+func (s *ProxyService) validateRuleNginxPending(ctx context.Context, rule proxy.Rule, content string) error {
+	rules, err := s.store.ListEnabled(ctx)
+	if err != nil {
+		return err
+	}
+	merged := make([]proxy.Rule, 0, len(rules))
+	found := false
+	for _, r := range rules {
+		if r.ID == rule.ID {
+			ruleCopy := rule
+			ruleCopy.NginxMode = "custom"
+			ruleCopy.Enabled = true
+			merged = append(merged, ruleCopy)
+			found = true
+			continue
+		}
+		merged = append(merged, r)
+	}
+	if !found && rule.Enabled {
+		ruleCopy := rule
+		ruleCopy.NginxMode = "custom"
+		merged = append(merged, ruleCopy)
+	}
+	return s.validateWithOverrides(ctx, merged, nginx.GenerateOptions{
+		RuleCustomOverrides: map[int64]string{rule.ID: content},
+	})
+}
+
+func (s *ProxyService) validateGlobalNginxPending(ctx context.Context, content string) error {
+	rules, err := s.store.ListEnabled(ctx)
+	if err != nil {
+		return err
+	}
+	return s.validateWithOverrides(ctx, rules, nginx.GenerateOptions{
+		GlobalCustomOverride: content,
+	})
+}
+
+func (s *ProxyService) validateWithOverrides(ctx context.Context, rules []proxy.Rule, extra nginx.GenerateOptions) error {
+	certs, err := s.loadCertSources(ctx)
+	if err != nil {
+		return err
+	}
+	opts := s.loadGenerateOptions(ctx)
+	opts.GlobalCustomOverride = extra.GlobalCustomOverride
+	opts.GlobalCustomPathOverride = extra.GlobalCustomPathOverride
+	opts.RuleCustomOverrides = extra.RuleCustomOverrides
+	opts.ChinaCIDRAvailable = nginx.ChinaCIDRExists(s.cfg)
+	content, err := nginx.Generate(s.cfg, rules, certs, opts)
+	if err != nil {
+		return err
+	}
+	return s.nginx.ValidateContent(ctx, content)
+}
+
+func mapBackups(entries []nginx.BackupEntry) []NginxBackupInfo {
+	out := make([]NginxBackupInfo, 0, len(entries))
+	for _, entry := range entries {
+		out = append(out, NginxBackupInfo{
+			Name:      entry.Name,
+			CreatedAt: entry.CreatedAt.Format("2006-01-02T15:04:05Z"),
+		})
+	}
+	return out
+}
diff --git a/internal/service/proxy.go b/internal/service/proxy.go
new file mode 100644
index 0000000..e017e4c
--- /dev/null
+++ b/internal/service/proxy.go
@@ -0,0 +1,199 @@
+package service
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+	"strconv"
+
+	"github.com/fonu/fonu/internal/certificate"
+	"github.com/fonu/fonu/internal/config"
+	"github.com/fonu/fonu/internal/nginx"
+	"github.com/fonu/fonu/internal/notify"
+	"github.com/fonu/fonu/internal/proxy"
+	"github.com/fonu/fonu/internal/settings"
+	"github.com/fonu/fonu/internal/validate"
+)
+
+type ProxyService struct {
+	db        *sql.DB
+	cfg       config.Config
+	store     *proxy.Store
+	certStore *certificate.Store
+	settings  *settings.Store
+	nginx     *nginx.Manager
+	notify    *notify.Service
+}
+
+func NewProxyService(cfg config.Config, db *sql.DB, store *proxy.Store, certStore *certificate.Store, settingsStore *settings.Store, nginxMgr *nginx.Manager) *ProxyService {
+	return &ProxyService{db: db, cfg: cfg, store: store, certStore: certStore, settings: settingsStore, nginx: nginxMgr}
+}
+
+func (s *ProxyService) SetNotify(notifySvc *notify.Service) {
+	s.notify = notifySvc
+}
+
+func (s *ProxyService) List(ctx context.Context) ([]proxy.Rule, error) {
+	return s.store.List(ctx)
+}
+
+func (s *ProxyService) Get(ctx context.Context, id int64) (proxy.Rule, error) {
+	return s.store.Get(ctx, id)
+}
+
+func (s *ProxyService) Create(ctx context.Context, in proxy.CreateInput) (proxy.Rule, error) {
+	if err := s.validateHTTPSInput(ctx, in.Hosts, in.HTTPSEnabled); err != nil {
+		return proxy.Rule{}, err
+	}
+
+	rule, err := s.store.Create(ctx, in)
+	if err != nil {
+		return proxy.Rule{}, err
+	}
+	if err := s.applyNginx(ctx); err != nil {
+		return rule, fmt.Errorf("规则已保存,但 Nginx 重载失败:%w", err)
+	}
+	return rule, nil
+}
+
+func (s *ProxyService) Update(ctx context.Context, id int64, in proxy.UpdateInput) (proxy.Rule, error) {
+	current, err := s.Get(ctx, id)
+	if err != nil {
+		return proxy.Rule{}, err
+	}
+	httpsEnabled := current.HTTPSEnabled
+	if in.HTTPSEnabled != nil {
+		httpsEnabled = *in.HTTPSEnabled
+	}
+	hosts := hostsFromRule(current)
+	if in.Hosts != nil {
+		hosts = *in.Hosts
+	}
+	if err := s.validateHTTPSInput(ctx, hosts, httpsEnabled); err != nil {
+		return proxy.Rule{}, err
+	}
+
+	rule, err := s.store.Update(ctx, id, in)
+	if err != nil {
+		return proxy.Rule{}, err
+	}
+	if err := s.applyNginx(ctx); err != nil {
+		return rule, fmt.Errorf("规则已保存,但 Nginx 重载失败:%w", err)
+	}
+	return rule, nil
+}
+
+func (s *ProxyService) Reorder(ctx context.Context, ids []int64) error {
+	return s.store.Reorder(ctx, ids)
+}
+
+func (s *ProxyService) Delete(ctx context.Context, id int64) error {
+	if err := s.store.Delete(ctx, id); err != nil {
+		return err
+	}
+	_ = nginx.RemoveRuleCustom(s.cfg, id)
+	if err := s.applyNginx(ctx); err != nil {
+		return fmt.Errorf("规则已删除,但 Nginx 重载失败:%w", err)
+	}
+	return nil
+}
+
+func (s *ProxyService) ReloadAll(ctx context.Context) error {
+	return s.applyNginx(ctx)
+}
+
+func (s *ProxyService) applyNginx(ctx context.Context) error {
+	rules, err := s.store.ListEnabled(ctx)
+	if err != nil {
+		return err
+	}
+	certs, err := s.loadCertSources(ctx)
+	if err != nil {
+		return err
+	}
+	s.nginx.SetGenerateOptions(s.loadGenerateOptions(ctx))
+	_, err = s.nginx.Apply(ctx, rules, certs)
+	if err != nil && s.notify != nil {
+		s.notify.Alert(ctx, notify.EventNginxReloadFailure, "Nginx 重载失败", err.Error())
+	}
+	return err
+}
+
+func (s *ProxyService) ValidateRule(ctx context.Context, candidate proxy.Rule, existing []proxy.Rule) error {
+	rules := make([]proxy.Rule, 0, len(existing)+1)
+	replaced := false
+	for _, r := range existing {
+		if r.ID == candidate.ID {
+			if candidate.Enabled {
+				rules = append(rules, candidate)
+			}
+			replaced = true
+			continue
+		}
+		if r.Enabled {
+			rules = append(rules, r)
+		}
+	}
+	if !replaced && candidate.Enabled {
+		rules = append(rules, candidate)
+	}
+	certs, err := s.loadCertSources(ctx)
+	if err != nil {
+		return err
+	}
+	s.nginx.SetGenerateOptions(s.loadGenerateOptions(ctx))
+	if err := s.nginx.ValidateOnly(ctx, rules, certs); err != nil {
+		return err
+	}
+	return nil
+}
+
+func hostsFromRule(rule proxy.Rule) []string {
+	hosts := make([]string, 0, len(rule.Hosts))
+	for _, host := range rule.Hosts {
+		if host.ListenPort != nil && *host.ListenPort > 0 && *host.ListenPort != rule.ListenPort {
+			hosts = append(hosts, host.Hostname+":"+strconv.Itoa(*host.ListenPort))
+			continue
+		}
+		hosts = append(hosts, host.Hostname)
+	}
+	return hosts
+}
+
+func (s *ProxyService) validateHTTPSInput(ctx context.Context, hosts []string, httpsEnabled bool) error {
+	if !httpsEnabled || len(hosts) == 0 {
+		return nil
+	}
+	certs, err := s.loadCertSources(ctx)
+	if err != nil {
+		return err
+	}
+	parsedHosts := make([]string, 0, len(hosts))
+	for _, host := range hosts {
+		hostname, _, err := validate.FrontendAddress(host)
+		if err != nil {
+			return err
+		}
+		parsedHosts = append(parsedHosts, hostname)
+	}
+	if nginx.HasCertificateForHosts(s.cfg.CertsDir(), parsedHosts, certs) {
+		return nil
+	}
+	return nginx.HTTPSCoverageError(parsedHosts[0], certs)
+}
+
+func (s *ProxyService) loadCertSources(ctx context.Context) ([]nginx.CertSource, error) {
+	records, err := s.certStore.List(ctx)
+	if err != nil {
+		return nil, err
+	}
+	sources := make([]nginx.CertSource, 0, len(records))
+	for _, record := range records {
+		sources = append(sources, nginx.CertSource{
+			Domains:  record.Domains,
+			CertPath: record.CertPath,
+			KeyPath:  record.KeyPath,
+		})
+	}
+	return sources, nil
+}
diff --git a/internal/service/proxy_opts.go b/internal/service/proxy_opts.go
new file mode 100644
index 0000000..45f7f22
--- /dev/null
+++ b/internal/service/proxy_opts.go
@@ -0,0 +1,29 @@
+package service
+
+import (
+	"context"
+
+	"github.com/fonu/fonu/internal/chinacidr"
+	"github.com/fonu/fonu/internal/nginx"
+	"github.com/fonu/fonu/internal/settings"
+)
+
+func (s *ProxyService) loadGenerateOptions(ctx context.Context) nginx.GenerateOptions {
+	opts := nginx.GenerateOptions{}
+	if s.settings == nil {
+		return opts
+	}
+	raw, err := s.settings.Get(ctx, settings.KeyTrustedProxy)
+	if err == nil {
+		opts.TrustedProxy = nginx.ParseTrustedProxyJSON(raw)
+	}
+	blRaw, err := s.settings.Get(ctx, settings.KeyGlobalIPBlacklist)
+	if err == nil {
+		opts.GlobalIPBlacklist = chinacidr.ParseGlobalIPList(blRaw)
+	}
+	wlRaw, err := s.settings.Get(ctx, settings.KeyGlobalIPWhitelist)
+	if err == nil {
+		opts.GlobalIPWhitelist = chinacidr.ParseGlobalIPList(wlRaw)
+	}
+	return opts
+}
diff --git a/internal/settings/settings.go b/internal/settings/settings.go
new file mode 100644
index 0000000..85f873a
--- /dev/null
+++ b/internal/settings/settings.go
@@ -0,0 +1,181 @@
+package settings
+
+import (
+	"context"
+	"database/sql"
+	"strconv"
+	"strings"
+)
+
+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"
+	KeyNotifyWebhookURL      = "notify_webhook_url"
+	KeyNotifyType            = "notify_type"
+	KeyNotifyEmailJSON       = "notify_email_json"
+	KeyNotifySMTPPassword    = "notify_smtp_password"
+	KeyNotifyWebhookJSON     = "notify_webhook_json"
+	KeyNotifyWebhookSecret   = "notify_webhook_secret"
+	KeyNotifyTelegramJSON    = "notify_telegram_json"
+	KeyNotifyTelegramToken   = "notify_telegram_token"
+	KeyNotifyOnDDNSError            = "notify_on_ddns_error" // legacy, migrated to KeyNotifyOnDDNSFailure
+	KeyNotifyOnCertError            = "notify_on_cert_error" // legacy, migrated to KeyNotifyOnCertExpiry
+	KeyNotifyOnNginxError           = "notify_on_nginx_error" // legacy, ignored
+	KeyNotifyOnDDNSIPChange         = "notify_on_ddns_ip_change"
+	KeyNotifyOnDDNSFailure          = "notify_on_ddns_failure"
+	KeyNotifyOnCertExpiry           = "notify_on_cert_expiry"
+	KeyNotifyOnCertRenewSuccess     = "notify_on_cert_renew_success"
+	KeyNotifyOnIPFrequentAccess     = "notify_on_ip_frequent_access"
+	KeyNotifyOnCertRenewFailure     = "notify_on_cert_renew_failure"
+	KeyNotifyOnLoginFailure         = "notify_on_login_failure"
+	KeyNotifyOnNginxReloadFailure   = "notify_on_nginx_reload_failure"
+	KeyNotifyIPFrequentThreshold    = "notify_ip_frequent_threshold"
+	KeyNotifyIPFrequentWindowSec    = "notify_ip_frequent_window_sec"
+	KeyNotifyLoginFailureThreshold  = "notify_login_failure_threshold"
+	KeyNotifyLoginFailureWindowSec  = "notify_login_failure_window_sec"
+	KeyTrustedProxy          = "trusted_proxy_json"
+	KeyGlobalIPBlacklist     = "global_ip_blacklist"
+	KeyGlobalIPWhitelist     = "global_ip_whitelist"
+	KeyChinaCIDRUpdateHours  = "china_cidr_update_interval_hours"
+	KeyChinaCIDRSourceV4     = "china_cidr_source_url_v4"
+	KeyChinaCIDRSourceV6     = "china_cidr_source_url_v6"
+	KeyChinaCIDRUpdatedAt    = "china_cidr_updated_at"
+	KeyChinaCIDRCountV4      = "china_cidr_count_v4"
+	KeyChinaCIDRCountV6      = "china_cidr_count_v6"
+	KeyChinaCIDRLastError    = "china_cidr_last_error"
+	KeyFRPEnabled            = "frp_enabled"
+	KeyFRPServerAddr         = "frp_server_addr"
+	KeyFRPServerPort         = "frp_server_port"
+	KeyFRPAuthToken          = "frp_auth_token"
+	KeyFRPTLSEnabled         = "frp_tls_enabled"
+	KeyFRPCustomDomains      = "frp_custom_domains"
+	KeyFRPLastError          = "frp_last_error"
+	KeyFRPStartedAt          = "frp_started_at"
+	KeyNginxGlobalMode       = "nginx_global_mode"
+)
+
+var Defaults = map[string]string{
+	KeyTheme:              "system",
+	KeyTimezone:           "Asia/Shanghai",
+	KeyLogRetentionDays:   "30",
+	KeyDDNSCheckInterval:  "5",
+	KeyCertRenewThreshold: "30",
+	KeyACMECA:                    "letsencrypt",
+	KeyChinaCIDRUpdateHours:      "24",
+	KeyNginxGlobalMode:           "auto",
+}
+
+type Store struct {
+	db *sql.DB
+}
+
+func NewStore(db *sql.DB) *Store {
+	return &Store{db: db}
+}
+
+func (s *Store) Get(ctx context.Context, key string) (string, error) {
+	var value string
+	err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&value)
+	if err == sql.ErrNoRows {
+		if def, ok := Defaults[key]; ok {
+			return def, nil
+		}
+		return "", nil
+	}
+	return value, err
+}
+
+func (s *Store) Set(ctx context.Context, key, value string) error {
+	_, err := s.db.ExecContext(ctx, `
+		INSERT INTO settings(key, value) VALUES (?, ?)
+		ON CONFLICT(key) DO UPDATE SET value = excluded.value
+	`, key, value)
+	return err
+}
+
+func (s *Store) GetBool(ctx context.Context, key string) (bool, error) {
+	raw, err := s.Get(ctx, key)
+	if err != nil {
+		return false, err
+	}
+	switch strings.ToLower(strings.TrimSpace(raw)) {
+	case "1", "true", "yes", "on":
+		return true, nil
+	default:
+		return false, nil
+	}
+}
+
+func (s *Store) SetBool(ctx context.Context, key string, value bool) error {
+	if value {
+		return s.Set(ctx, key, "true")
+	}
+	return s.Set(ctx, key, "false")
+}
+
+func (s *Store) SetInt(ctx context.Context, key string, value int) error {
+	return s.Set(ctx, key, strconv.Itoa(value))
+}
+
+func (s *Store) GetInt(ctx context.Context, key string) (int, error) {
+	raw, err := s.Get(ctx, key)
+	if err != nil {
+		return 0, err
+	}
+	if raw == "" {
+		if def, ok := Defaults[key]; ok {
+			raw = def
+		}
+	}
+	n, err := strconv.Atoi(raw)
+	if err != nil {
+		return 0, err
+	}
+	return n, nil
+}
+
+func (s *Store) List(ctx context.Context) (map[string]string, error) {
+	rows, err := s.db.QueryContext(ctx, `SELECT key, value FROM settings`)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	out := map[string]string{}
+	for k, v := range Defaults {
+		out[k] = v
+	}
+	for rows.Next() {
+		var key, value string
+		if err := rows.Scan(&key, &value); err != nil {
+			return nil, err
+		}
+		out[key] = value
+	}
+	return out, rows.Err()
+}
+
+func (s *Store) SetMany(ctx context.Context, values map[string]string) error {
+	tx, err := s.db.BeginTx(ctx, nil)
+	if err != nil {
+		return err
+	}
+	defer func() { _ = tx.Rollback() }()
+
+	for key, value := range values {
+		if _, err := tx.ExecContext(ctx, `
+			INSERT INTO settings(key, value) VALUES (?, ?)
+			ON CONFLICT(key) DO UPDATE SET value = excluded.value
+		`, key, value); err != nil {
+			return err
+		}
+	}
+	return tx.Commit()
+}
diff --git a/internal/traffic/collector.go b/internal/traffic/collector.go
new file mode 100644
index 0000000..0d1ff26
--- /dev/null
+++ b/internal/traffic/collector.go
@@ -0,0 +1,328 @@
+package traffic
+
+import (
+	"bufio"
+	"context"
+	"database/sql"
+	"io"
+	"os"
+	"sort"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/fonu/fonu/internal/logstore"
+	"github.com/fonu/fonu/internal/notify"
+	"github.com/fonu/fonu/internal/proxy"
+)
+
+const (
+	rateWindow       = 5 * time.Second
+	activeWindow     = 65 * time.Second
+	persistInterval  = 30 * time.Second
+	maxSamplesPerHost = 256
+)
+
+type RuleTraffic struct {
+	RuleID       int64   `json:"rule_id"`
+	UploadTotal  int64   `json:"upload_total"`
+	DownloadTotal int64  `json:"download_total"`
+	UploadRate   float64 `json:"upload_rate"`
+	DownloadRate float64 `json:"download_rate"`
+	Connections  int     `json:"connections"`
+}
+
+type ClientConn struct {
+	IP       string `json:"ip"`
+	LastSeen string `json:"last_seen"`
+}
+
+type Collector struct {
+	path   string
+	store  *Store
+	notify *notify.Service
+
+	mu    sync.RWMutex
+	hosts map[string]*hostState
+}
+
+type hostState struct {
+	uploadTotal   int64
+	downloadTotal int64
+	clients       map[string]time.Time
+	samples       []sample
+}
+
+type sample struct {
+	at   time.Time
+	up   int64
+	down int64
+}
+
+func NewCollector(db *sql.DB, accessLogPath string, notifySvc *notify.Service) *Collector {
+	return &Collector{
+		path:   accessLogPath,
+		store:  NewStore(db),
+		notify: notifySvc,
+		hosts:  map[string]*hostState{},
+	}
+}
+
+func (c *Collector) Start(ctx context.Context) {
+	if totals, err := c.store.LoadTotals(ctx); err == nil {
+		c.mu.Lock()
+		for host, t := range totals {
+			st := c.ensureHost(host)
+			st.uploadTotal = t.Upload
+			st.downloadTotal = t.Download
+		}
+		c.mu.Unlock()
+	}
+
+	go c.tailLoop(ctx)
+	go c.persistLoop(ctx)
+}
+
+func (c *Collector) tailLoop(ctx context.Context) {
+	offset := c.logOffset()
+	for {
+		if ctx.Err() != nil {
+			return
+		}
+		offset = c.readFrom(offset)
+		time.Sleep(500 * time.Millisecond)
+	}
+}
+
+func (c *Collector) logOffset() int64 {
+	info, err := os.Stat(c.path)
+	if err != nil {
+		return 0
+	}
+	return info.Size()
+}
+
+func (c *Collector) readFrom(offset int64) int64 {
+	file, err := os.Open(c.path)
+	if err != nil {
+		return offset
+	}
+	defer file.Close()
+
+	info, err := file.Stat()
+	if err != nil {
+		return offset
+	}
+	if info.Size() < offset {
+		offset = 0
+	}
+	if _, err := file.Seek(offset, 0); err != nil {
+		return offset
+	}
+
+	reader := bufio.NewReader(file)
+	for {
+		line, err := reader.ReadString('\n')
+		if len(line) > 0 {
+			c.ingestLine(strings.TrimRight(line, "\r\n"), true)
+		}
+		if err != nil {
+			if err == io.EOF {
+				pos, seekErr := file.Seek(0, io.SeekCurrent)
+				if seekErr == nil {
+					return pos
+				}
+				return info.Size()
+			}
+			return offset
+		}
+	}
+}
+
+func (c *Collector) persistLoop(ctx context.Context) {
+	ticker := time.NewTicker(persistInterval)
+	defer ticker.Stop()
+	for {
+		select {
+		case <-ctx.Done():
+			_ = c.persist(context.Background())
+			return
+		case <-ticker.C:
+			_ = c.persist(ctx)
+		}
+	}
+}
+
+func (c *Collector) persist(ctx context.Context) error {
+	c.mu.RLock()
+	snapshot := make(map[string]hostTotals, len(c.hosts))
+	for host, st := range c.hosts {
+		snapshot[host] = hostTotals{Upload: st.uploadTotal, Download: st.downloadTotal}
+	}
+	c.mu.RUnlock()
+	return c.store.SaveTotals(ctx, snapshot)
+}
+
+func (c *Collector) ingestLine(line string, trackLive bool) {
+	entry, ok := logstore.ParseAccess(line)
+	if !ok || entry.Domain == "" {
+		return
+	}
+	host := EndpointKey(entry.Domain, entry.ServerPort)
+	up := entry.RequestLength
+	down := entry.BytesSent
+	if up == 0 && down == 0 && !trackLive {
+		return
+	}
+
+	now := time.Now()
+	c.mu.Lock()
+	st := c.ensureHost(host)
+	st.uploadTotal += up
+	st.downloadTotal += down
+	if trackLive {
+		if up > 0 || down > 0 {
+			st.samples = append(st.samples, sample{at: now, up: up, down: down})
+			if len(st.samples) > maxSamplesPerHost {
+				st.samples = st.samples[len(st.samples)-maxSamplesPerHost:]
+			}
+		}
+		if entry.ClientIP != "" && entry.ClientIP != "-" {
+			st.clients[entry.ClientIP] = now
+			if c.notify != nil {
+				c.notify.RecordAccessHit(context.Background(), entry.ClientIP)
+			}
+		}
+		c.pruneHost(st, now)
+	}
+	c.mu.Unlock()
+}
+
+func (c *Collector) ensureHost(host string) *hostState {
+	st, ok := c.hosts[host]
+	if !ok {
+		st = &hostState{clients: map[string]time.Time{}}
+		c.hosts[host] = st
+	}
+	return st
+}
+
+func (c *Collector) pruneHost(st *hostState, now time.Time) {
+	cutoff := now.Add(-rateWindow)
+	for i := 0; i < len(st.samples); {
+		if st.samples[i].at.Before(cutoff) {
+			st.samples = append(st.samples[:i], st.samples[i+1:]...)
+			continue
+		}
+		i++
+	}
+	activeCutoff := now.Add(-activeWindow)
+	for ip, seen := range st.clients {
+		if seen.Before(activeCutoff) {
+			delete(st.clients, ip)
+		}
+	}
+}
+
+func (c *Collector) aggregateEndpoints(endpoints []proxy.Endpoint, now time.Time) (uploadTotal, downloadTotal int64, uploadRate, downloadRate float64, connections int) {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+
+	var upSamples, downSamples int64
+	var oldest time.Time
+	clientSet := map[string]time.Time{}
+
+	for _, ep := range endpoints {
+		key := EndpointKey(ep.Hostname, ep.Port)
+		st, ok := c.hosts[key]
+		if !ok {
+			continue
+		}
+		uploadTotal += st.uploadTotal
+		downloadTotal += st.downloadTotal
+		cutoff := now.Add(-rateWindow)
+		for _, s := range st.samples {
+			if s.at.Before(cutoff) {
+				continue
+			}
+			upSamples += s.up
+			downSamples += s.down
+			if oldest.IsZero() || s.at.Before(oldest) {
+				oldest = s.at
+			}
+		}
+		activeCutoff := now.Add(-activeWindow)
+		for ip, seen := range st.clients {
+			if seen.Before(activeCutoff) {
+				continue
+			}
+			if prev, ok := clientSet[ip]; !ok || seen.After(prev) {
+				clientSet[ip] = seen
+			}
+		}
+	}
+
+	span := now.Sub(oldest).Seconds()
+	if span < 0.5 {
+		span = 0.5
+	}
+	if upSamples > 0 {
+		uploadRate = float64(upSamples) / span
+	}
+	if downSamples > 0 {
+		downloadRate = float64(downSamples) / span
+	}
+	connections = len(clientSet)
+	return uploadTotal, downloadTotal, uploadRate, downloadRate, connections
+}
+
+func (c *Collector) SnapshotForRules(rules []proxy.Rule) []RuleTraffic {
+	now := time.Now()
+	out := make([]RuleTraffic, 0, len(rules))
+	for _, rule := range rules {
+		endpoints := rule.Endpoints()
+		up, down, upRate, downRate, conns := c.aggregateEndpoints(endpoints, now)
+		out = append(out, RuleTraffic{
+			RuleID:        rule.ID,
+			UploadTotal:   up,
+			DownloadTotal: down,
+			UploadRate:    upRate,
+			DownloadRate:  downRate,
+			Connections:   conns,
+		})
+	}
+	return out
+}
+
+func (c *Collector) ClientsForRule(rule proxy.Rule) []ClientConn {
+	now := time.Now()
+	activeCutoff := now.Add(-activeWindow)
+	clientSet := map[string]time.Time{}
+
+	c.mu.RLock()
+	for _, ep := range rule.Endpoints() {
+		key := EndpointKey(ep.Hostname, ep.Port)
+		st, ok := c.hosts[key]
+		if !ok {
+			continue
+		}
+		for ip, seen := range st.clients {
+			if seen.Before(activeCutoff) {
+				continue
+			}
+			if prev, ok := clientSet[ip]; !ok || seen.After(prev) {
+				clientSet[ip] = seen
+			}
+		}
+	}
+	c.mu.RUnlock()
+
+	out := make([]ClientConn, 0, len(clientSet))
+	for ip, seen := range clientSet {
+		out = append(out, ClientConn{IP: ip, LastSeen: seen.UTC().Format(time.RFC3339)})
+	}
+	sort.Slice(out, func(i, j int) bool {
+		return out[i].LastSeen > out[j].LastSeen
+	})
+	return out
+}
diff --git a/internal/traffic/collector_test.go b/internal/traffic/collector_test.go
new file mode 100644
index 0000000..27c0317
--- /dev/null
+++ b/internal/traffic/collector_test.go
@@ -0,0 +1,53 @@
+package traffic
+
+import (
+	"testing"
+	"time"
+
+	"github.com/fonu/fonu/internal/proxy"
+)
+
+func TestCollectorAttributesConnectionsByHostAndPort(t *testing.T) {
+	c := NewCollector(nil, "", nil)
+	now := time.Now()
+	line6893 := "2026-09-17T08:30:00+08:00 xxx.com 6893 GET /login 200 0.010 218.88.23.20 192.168.8.3:6893 100 200"
+	line8443 := "2026-09-17T08:30:01+08:00 xxx.com 8443 GET / 200 0.010 218.88.23.20 192.168.8.3:8443 100 200"
+
+	c.ingestLine(line6893, true)
+	c.ingestLine(line8443, true)
+
+	rules := []proxy.Rule{
+		{
+			ID:         1,
+			ListenPort: 6893,
+			Hosts:      []proxy.Host{{Hostname: "xxx.com"}},
+		},
+		{
+			ID:         2,
+			ListenPort: 8443,
+			Hosts:      []proxy.Host{{Hostname: "xxx.com"}},
+		},
+	}
+
+	stats := c.SnapshotForRules(rules)
+	if len(stats) != 2 {
+		t.Fatalf("expected 2 stats, got %d", len(stats))
+	}
+	byID := map[int64]RuleTraffic{}
+	for _, s := range stats {
+		byID[s.RuleID] = s
+	}
+	if byID[1].Connections != 1 {
+		t.Fatalf("rule 6893 connections=%d want 1", byID[1].Connections)
+	}
+	if byID[2].Connections != 1 {
+		t.Fatalf("rule 8443 connections=%d want 1", byID[2].Connections)
+	}
+	_ = now
+}
+
+func TestEndpointKeyIncludesPort(t *testing.T) {
+	if got := EndpointKey("XXX.com", 6893); got != "xxx.com:6893" {
+		t.Fatalf("got %q", got)
+	}
+}
diff --git a/internal/traffic/endpoint.go b/internal/traffic/endpoint.go
new file mode 100644
index 0000000..7d2cc57
--- /dev/null
+++ b/internal/traffic/endpoint.go
@@ -0,0 +1,17 @@
+package traffic
+
+import (
+	"fmt"
+	"strings"
+)
+
+func EndpointKey(host string, port int) string {
+	host = strings.ToLower(strings.TrimSpace(host))
+	if i := strings.Index(host, ":"); i > 0 {
+		host = host[:i]
+	}
+	if port <= 0 {
+		return host
+	}
+	return fmt.Sprintf("%s:%d", host, port)
+}
diff --git a/internal/traffic/store.go b/internal/traffic/store.go
new file mode 100644
index 0000000..00a54ad
--- /dev/null
+++ b/internal/traffic/store.go
@@ -0,0 +1,63 @@
+package traffic
+
+import (
+	"context"
+	"database/sql"
+	"strings"
+	"time"
+)
+
+type Store struct {
+	db *sql.DB
+}
+
+func NewStore(db *sql.DB) *Store {
+	return &Store{db: db}
+}
+
+func (s *Store) LoadTotals(ctx context.Context) (map[string]hostTotals, error) {
+	rows, err := s.db.QueryContext(ctx, `SELECT host, upload_bytes, download_bytes FROM proxy_traffic`)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	out := map[string]hostTotals{}
+	for rows.Next() {
+		var host string
+		var up, down int64
+		if err := rows.Scan(&host, &up, &down); err != nil {
+			return nil, err
+		}
+		key := strings.ToLower(strings.TrimSpace(host))
+		out[key] = hostTotals{Upload: up, Download: down}
+	}
+	return out, rows.Err()
+}
+
+func (s *Store) SaveTotals(ctx context.Context, hosts map[string]hostTotals) error {
+	tx, err := s.db.BeginTx(ctx, nil)
+	if err != nil {
+		return err
+	}
+	for host, totals := range hosts {
+		_, err := tx.ExecContext(ctx, `
+			INSERT INTO proxy_traffic(host, upload_bytes, download_bytes, updated_at)
+			VALUES (?, ?, ?, ?)
+			ON CONFLICT(host) DO UPDATE SET
+				upload_bytes = excluded.upload_bytes,
+				download_bytes = excluded.download_bytes,
+				updated_at = excluded.updated_at
+		`, host, totals.Upload, totals.Download, time.Now().UTC().Format(time.RFC3339))
+		if err != nil {
+			_ = tx.Rollback()
+			return err
+		}
+	}
+	return tx.Commit()
+}
+
+type hostTotals struct {
+	Upload   int64
+	Download int64
+}
diff --git a/internal/validate/basicauth.go b/internal/validate/basicauth.go
new file mode 100644
index 0000000..3034b1c
--- /dev/null
+++ b/internal/validate/basicauth.go
@@ -0,0 +1,21 @@
+package validate
+
+import (
+	"fmt"
+
+	"golang.org/x/crypto/bcrypt"
+)
+
+func BasicAuthPasswordHash(username, password string) (string, error) {
+	if err := Username(username); err != nil {
+		return "", err
+	}
+	if err := Password(password); err != nil {
+		return "", err
+	}
+	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+	if err != nil {
+		return "", fmt.Errorf("密码加密失败")
+	}
+	return string(hash), nil
+}
diff --git a/internal/validate/ip.go b/internal/validate/ip.go
new file mode 100644
index 0000000..715e9f9
--- /dev/null
+++ b/internal/validate/ip.go
@@ -0,0 +1,38 @@
+package validate
+
+import (
+	"fmt"
+	"net"
+	"strings"
+)
+
+func IPOrCIDR(raw string) (string, error) {
+	raw = strings.TrimSpace(raw)
+	if raw == "" {
+		return "", fmt.Errorf("IP 或 CIDR 不能为空")
+	}
+	if strings.Contains(raw, "/") {
+		_, ipNet, err := net.ParseCIDR(raw)
+		if err != nil {
+			return "", fmt.Errorf("CIDR 格式无效")
+		}
+		return ipNet.String(), nil
+	}
+	ip := net.ParseIP(raw)
+	if ip == nil {
+		return "", fmt.Errorf("IP 格式无效")
+	}
+	if v4 := ip.To4(); v4 != nil {
+		return v4.String(), nil
+	}
+	return ip.String(), nil
+}
+
+func IPOrCIDRList(list []string) error {
+	for _, item := range list {
+		if _, err := IPOrCIDR(item); err != nil {
+			return err
+		}
+	}
+	return nil
+}
diff --git a/internal/validate/validate.go b/internal/validate/validate.go
new file mode 100644
index 0000000..6b690a3
--- /dev/null
+++ b/internal/validate/validate.go
@@ -0,0 +1,138 @@
+package validate
+
+import (
+	"fmt"
+	"net"
+	"net/url"
+	"regexp"
+	"strconv"
+	"strings"
+)
+
+var domainRe = regexp.MustCompile(`^(?i:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.(?i:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))*$`)
+
+func CertDomain(domain string) error {
+	domain = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(domain)), ".")
+	if domain == "" {
+		return fmt.Errorf("域名不能为空")
+	}
+	if strings.HasPrefix(domain, "*.") {
+		return Domain(strings.TrimPrefix(domain, "*."))
+	}
+	return Domain(domain)
+}
+
+func Domain(domain string) error {
+	domain = strings.TrimSpace(domain)
+	if domain == "" {
+		return fmt.Errorf("域名不能为空")
+	}
+	if len(domain) > 253 {
+		return fmt.Errorf("域名过长")
+	}
+	if strings.Contains(domain, "..") || strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") {
+		return fmt.Errorf("域名格式无效")
+	}
+	if !domainRe.MatchString(domain) {
+		return fmt.Errorf("域名格式无效")
+	}
+	return nil
+}
+
+func ListenPort(port int) error {
+	if port < 1 || port > 65535 {
+		return fmt.Errorf("监听端口无效")
+	}
+	return nil
+}
+
+// FrontendAddress parses a frontend binding like "example.com" or "example.com:8011".
+// A zero port means the rule-level default should be used.
+func FrontendAddress(raw string) (hostname string, port int, err error) {
+	raw = strings.TrimSpace(raw)
+	if raw == "" {
+		return "", 0, fmt.Errorf("前端地址不能为空")
+	}
+
+	host := raw
+	port = 0
+	if strings.Count(raw, ":") == 1 && !strings.Contains(raw, "]") {
+		parts := strings.SplitN(raw, ":", 2)
+		host = parts[0]
+		p, parseErr := strconv.Atoi(parts[1])
+		if parseErr != nil || p < 1 || p > 65535 {
+			return "", 0, fmt.Errorf("前端地址端口无效")
+		}
+		port = p
+	}
+
+	host = strings.ToLower(strings.TrimSpace(host))
+	if err := Domain(host); err != nil {
+		return "", 0, err
+	}
+	return host, port, nil
+}
+
+func Upstream(raw string) (string, error) {
+	raw = strings.TrimSpace(raw)
+	if raw == "" {
+		return "", fmt.Errorf("目标地址不能为空")
+	}
+
+	u, err := url.Parse(raw)
+	if err != nil {
+		return "", fmt.Errorf("目标地址格式无效")
+	}
+	if u.Scheme != "http" && u.Scheme != "https" {
+		return "", fmt.Errorf("目标地址仅支持 http:// 或 https://")
+	}
+	if u.User != nil {
+		return "", fmt.Errorf("目标地址不能包含用户名或密码")
+	}
+	if u.Host == "" {
+		return "", fmt.Errorf("目标地址缺少主机名")
+	}
+	if u.Path != "" && u.Path != "/" {
+		return "", fmt.Errorf("目标地址不能包含路径")
+	}
+	if u.RawQuery != "" || u.Fragment != "" {
+		return "", fmt.Errorf("目标地址不能包含查询参数或片段")
+	}
+
+	host := u.Hostname()
+	port := u.Port()
+	if host == "" {
+		return "", fmt.Errorf("目标地址主机名无效")
+	}
+	if ip := net.ParseIP(host); ip != nil {
+		if port == "" {
+			return "", fmt.Errorf("IP 地址目标必须指定端口")
+		}
+	} else if err := Domain(host); err != nil {
+		return "", fmt.Errorf("目标地址主机名无效")
+	}
+
+	normalized := fmt.Sprintf("%s://%s", u.Scheme, u.Host)
+	if !strings.HasPrefix(normalized, "http://") && !strings.HasPrefix(normalized, "https://") {
+		return "", fmt.Errorf("目标地址格式无效")
+	}
+	return normalized, nil
+}
+
+func Password(password string) error {
+	if len(password) < 8 {
+		return fmt.Errorf("密码长度至少 8 位")
+	}
+	return nil
+}
+
+func Username(username string) error {
+	username = strings.TrimSpace(username)
+	if username == "" {
+		return fmt.Errorf("用户名不能为空")
+	}
+	if len(username) > 64 {
+		return fmt.Errorf("用户名过长")
+	}
+	return nil
+}
diff --git a/internal/validate/validate_test.go b/internal/validate/validate_test.go
new file mode 100644
index 0000000..478ddae
--- /dev/null
+++ b/internal/validate/validate_test.go
@@ -0,0 +1,21 @@
+package validate
+
+import "testing"
+
+func TestFrontendAddress(t *testing.T) {
+	host, port, err := FrontendAddress("example.com:8011")
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if host != "example.com" || port != 8011 {
+		t.Fatalf("got %q %d", host, port)
+	}
+
+	host, port, err = FrontendAddress("nas.example.com")
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if host != "nas.example.com" || port != 0 {
+		t.Fatalf("got %q %d", host, port)
+	}
+}
diff --git a/internal/version/version.go b/internal/version/version.go
new file mode 100644
index 0000000..2076ecc
--- /dev/null
+++ b/internal/version/version.go
@@ -0,0 +1,4 @@
+package version
+
+// Version is set at build time via -ldflags.
+var Version = "dev"
diff --git a/migrations/001_init.sql b/migrations/001_init.sql
new file mode 100644
index 0000000..b366980
--- /dev/null
+++ b/migrations/001_init.sql
@@ -0,0 +1,36 @@
+CREATE TABLE IF NOT EXISTS schema_migrations (
+    version INTEGER PRIMARY KEY,
+    applied_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS admins (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    username TEXT NOT NULL UNIQUE,
+    password_hash TEXT NOT NULL,
+    created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS sessions (
+    id TEXT PRIMARY KEY,
+    admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
+    expires_at TEXT NOT NULL,
+    created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
+
+CREATE TABLE IF NOT EXISTS proxy_rules (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    domain TEXT NOT NULL UNIQUE COLLATE NOCASE,
+    upstream TEXT NOT NULL,
+    https_enabled INTEGER NOT NULL DEFAULT 1 CHECK (https_enabled IN (0, 1)),
+    http_redirect INTEGER NOT NULL DEFAULT 1 CHECK (http_redirect IN (0, 1)),
+    enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
+    created_at TEXT NOT NULL DEFAULT (datetime('now')),
+    updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS settings (
+    key TEXT PRIMARY KEY,
+    value TEXT NOT NULL
+);
diff --git a/migrations/002_phase2.sql b/migrations/002_phase2.sql
new file mode 100644
index 0000000..28c14bb
--- /dev/null
+++ b/migrations/002_phase2.sql
@@ -0,0 +1,31 @@
+CREATE TABLE IF NOT EXISTS ddns_configs (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    provider TEXT NOT NULL DEFAULT 'cloudflare',
+    root_domain TEXT NOT NULL,
+    record_name TEXT NOT NULL,
+    ipv4_enabled INTEGER NOT NULL DEFAULT 1 CHECK (ipv4_enabled IN (0, 1)),
+    ipv6_enabled INTEGER NOT NULL DEFAULT 0 CHECK (ipv6_enabled IN (0, 1)),
+    enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
+    api_token_enc TEXT,
+    last_ipv4 TEXT,
+    last_ipv6 TEXT,
+    last_status TEXT NOT NULL DEFAULT 'unknown',
+    last_error TEXT,
+    last_updated_at TEXT,
+    created_at TEXT NOT NULL DEFAULT (datetime('now')),
+    updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS certificates (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    domain TEXT NOT NULL UNIQUE,
+    wildcard INTEGER NOT NULL DEFAULT 0 CHECK (wildcard IN (0, 1)),
+    cert_path TEXT,
+    key_path TEXT,
+    expires_at TEXT,
+    last_renew_at TEXT,
+    status TEXT NOT NULL DEFAULT 'pending',
+    last_error TEXT,
+    created_at TEXT NOT NULL DEFAULT (datetime('now')),
+    updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
diff --git a/migrations/003_ddns_multi.sql b/migrations/003_ddns_multi.sql
new file mode 100644
index 0000000..678556b
--- /dev/null
+++ b/migrations/003_ddns_multi.sql
@@ -0,0 +1,2 @@
+CREATE UNIQUE INDEX IF NOT EXISTS idx_ddns_configs_domain_record
+ON ddns_configs(root_domain, record_name);
diff --git a/migrations/004_cert_acme_ca.sql b/migrations/004_cert_acme_ca.sql
new file mode 100644
index 0000000..9481a61
--- /dev/null
+++ b/migrations/004_cert_acme_ca.sql
@@ -0,0 +1 @@
+ALTER TABLE certificates ADD COLUMN acme_ca TEXT NOT NULL DEFAULT 'letsencrypt';
diff --git a/migrations/005_cert_domains.sql b/migrations/005_cert_domains.sql
new file mode 100644
index 0000000..aa121dd
--- /dev/null
+++ b/migrations/005_cert_domains.sql
@@ -0,0 +1 @@
+ALTER TABLE certificates ADD COLUMN domains TEXT NOT NULL DEFAULT '';
diff --git a/migrations/006_proxy_multi_host.sql b/migrations/006_proxy_multi_host.sql
new file mode 100644
index 0000000..ed9bcdc
--- /dev/null
+++ b/migrations/006_proxy_multi_host.sql
@@ -0,0 +1,50 @@
+CREATE TEMP TABLE proxy_domain_backup AS
+SELECT id, domain FROM proxy_rules;
+
+CREATE TABLE proxy_rules_new (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    upstream TEXT NOT NULL,
+    listen_port INTEGER NOT NULL DEFAULT 80 CHECK (listen_port > 0 AND listen_port <= 65535),
+    listen_ipv4 INTEGER NOT NULL DEFAULT 1 CHECK (listen_ipv4 IN (0, 1)),
+    listen_ipv6 INTEGER NOT NULL DEFAULT 0 CHECK (listen_ipv6 IN (0, 1)),
+    https_enabled INTEGER NOT NULL DEFAULT 1 CHECK (https_enabled IN (0, 1)),
+    http_redirect INTEGER NOT NULL DEFAULT 1 CHECK (http_redirect IN (0, 1)),
+    enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
+    created_at TEXT NOT NULL DEFAULT (datetime('now')),
+    updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+INSERT INTO proxy_rules_new (id, upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, created_at, updated_at)
+SELECT
+    id,
+    upstream,
+    CASE WHEN https_enabled = 1 THEN 443 ELSE 80 END,
+    1,
+    0,
+    https_enabled,
+    http_redirect,
+    enabled,
+    created_at,
+    updated_at
+FROM proxy_rules;
+
+DROP TABLE proxy_rules;
+ALTER TABLE proxy_rules_new RENAME TO proxy_rules;
+
+CREATE TABLE IF NOT EXISTS proxy_hosts (
+    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 CHECK (listen_port IS NULL OR (listen_port > 0 AND listen_port <= 65535)),
+    created_at TEXT NOT NULL DEFAULT (datetime('now')),
+    UNIQUE(hostname)
+);
+
+CREATE INDEX IF NOT EXISTS idx_proxy_hosts_rule_id ON proxy_hosts(rule_id);
+
+INSERT INTO proxy_hosts (rule_id, hostname, listen_port)
+SELECT id, domain, NULL
+FROM proxy_domain_backup
+WHERE domain IS NOT NULL AND TRIM(domain) != '';
+
+DROP TABLE proxy_domain_backup;
diff --git a/migrations/007_ddns_record_names.sql b/migrations/007_ddns_record_names.sql
new file mode 100644
index 0000000..1a43f7f
--- /dev/null
+++ b/migrations/007_ddns_record_names.sql
@@ -0,0 +1,5 @@
+ALTER TABLE ddns_configs ADD COLUMN record_names TEXT NOT NULL DEFAULT '[]';
+
+UPDATE ddns_configs
+SET record_names = json_array(record_name)
+WHERE record_names = '[]' OR record_names IS NULL OR record_names = '';
diff --git a/migrations/008_ddns_domain_records.sql b/migrations/008_ddns_domain_records.sql
new file mode 100644
index 0000000..1bcd97e
--- /dev/null
+++ b/migrations/008_ddns_domain_records.sql
@@ -0,0 +1 @@
+ALTER TABLE ddns_configs ADD COLUMN domain_records TEXT NOT NULL DEFAULT '[]';
diff --git a/migrations/009_proxy_traffic.sql b/migrations/009_proxy_traffic.sql
new file mode 100644
index 0000000..788cefe
--- /dev/null
+++ b/migrations/009_proxy_traffic.sql
@@ -0,0 +1,6 @@
+CREATE TABLE IF NOT EXISTS proxy_traffic (
+    host TEXT PRIMARY KEY,
+    upload_bytes INTEGER NOT NULL DEFAULT 0,
+    download_bytes INTEGER NOT NULL DEFAULT 0,
+    updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
diff --git a/migrations/010_proxy_host_port_unique.sql b/migrations/010_proxy_host_port_unique.sql
new file mode 100644
index 0000000..ff880b7
--- /dev/null
+++ b/migrations/010_proxy_host_port_unique.sql
@@ -0,0 +1,22 @@
+UPDATE proxy_hosts
+SET listen_port = (
+    SELECT listen_port FROM proxy_rules WHERE proxy_rules.id = proxy_hosts.rule_id
+)
+WHERE listen_port IS NULL;
+
+CREATE TABLE proxy_hosts_new (
+    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(hostname, listen_port)
+);
+
+INSERT INTO proxy_hosts_new (id, rule_id, hostname, listen_port, created_at)
+SELECT id, rule_id, hostname, listen_port, created_at FROM proxy_hosts;
+
+DROP TABLE proxy_hosts;
+ALTER TABLE proxy_hosts_new RENAME TO proxy_hosts;
+
+CREATE INDEX IF NOT EXISTS idx_proxy_hosts_rule_id ON proxy_hosts(rule_id);
diff --git a/migrations/011_proxy_remark.sql b/migrations/011_proxy_remark.sql
new file mode 100644
index 0000000..492c6fe
--- /dev/null
+++ b/migrations/011_proxy_remark.sql
@@ -0,0 +1 @@
+ALTER TABLE proxy_rules ADD COLUMN remark TEXT NOT NULL DEFAULT '';
diff --git a/migrations/012_proxy_name_sort.sql b/migrations/012_proxy_name_sort.sql
new file mode 100644
index 0000000..e233e59
--- /dev/null
+++ b/migrations/012_proxy_name_sort.sql
@@ -0,0 +1,7 @@
+ALTER TABLE proxy_rules RENAME COLUMN remark TO name;
+
+ALTER TABLE proxy_rules ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
+
+UPDATE proxy_rules SET sort_order = id;
+
+CREATE INDEX IF NOT EXISTS idx_proxy_rules_sort_order ON proxy_rules(sort_order);
diff --git a/migrations/013_proxy_security.sql b/migrations/013_proxy_security.sql
new file mode 100644
index 0000000..ab49f29
--- /dev/null
+++ b/migrations/013_proxy_security.sql
@@ -0,0 +1 @@
+ALTER TABLE proxy_rules ADD COLUMN security_json TEXT NOT NULL DEFAULT '{}';
diff --git a/migrations/014_nginx_custom.sql b/migrations/014_nginx_custom.sql
new file mode 100644
index 0000000..bc4ac52
--- /dev/null
+++ b/migrations/014_nginx_custom.sql
@@ -0,0 +1 @@
+ALTER TABLE proxy_rules ADD COLUMN nginx_mode TEXT NOT NULL DEFAULT 'auto';
diff --git a/migrations/015_frp.sql b/migrations/015_frp.sql
new file mode 100644
index 0000000..b832ceb
--- /dev/null
+++ b/migrations/015_frp.sql
@@ -0,0 +1,44 @@
+CREATE TABLE IF NOT EXISTS frp_servers (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    name TEXT NOT NULL UNIQUE COLLATE NOCASE,
+    server_addr TEXT NOT NULL,
+    server_port INTEGER NOT NULL CHECK (server_port > 0 AND server_port <= 65535),
+    auth_token_enc TEXT,
+    tls_enabled INTEGER NOT NULL DEFAULT 1 CHECK (tls_enabled IN (0, 1)),
+    tls_server_name TEXT NOT NULL DEFAULT '',
+    enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
+    created_at TEXT NOT NULL DEFAULT (datetime('now')),
+    updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS frp_proxies (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    server_id INTEGER NOT NULL REFERENCES frp_servers(id) ON DELETE CASCADE,
+    name TEXT NOT NULL COLLATE NOCASE,
+    type TEXT NOT NULL CHECK (type IN ('tcp', 'http', 'https')),
+    local_ip TEXT NOT NULL DEFAULT '127.0.0.1',
+    local_port INTEGER NOT NULL CHECK (local_port > 0 AND local_port <= 65535),
+    remote_port INTEGER CHECK (remote_port IS NULL OR (remote_port > 0 AND remote_port <= 65535)),
+    custom_domains_json TEXT NOT NULL DEFAULT '[]',
+    host_header_rewrite TEXT NOT NULL DEFAULT '',
+    enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
+    remark TEXT NOT NULL DEFAULT '',
+    created_at TEXT NOT NULL DEFAULT (datetime('now')),
+    updated_at TEXT NOT NULL DEFAULT (datetime('now')),
+    UNIQUE(server_id, name)
+);
+
+CREATE TABLE IF NOT EXISTS frp_runtime_status (
+    server_id INTEGER PRIMARY KEY REFERENCES frp_servers(id) ON DELETE CASCADE,
+    status TEXT NOT NULL DEFAULT 'stopped',
+    pid INTEGER,
+    frpc_version TEXT NOT NULL DEFAULT '',
+    last_started_at TEXT,
+    last_reloaded_at TEXT,
+    last_error TEXT NOT NULL DEFAULT '',
+    updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS idx_frp_proxies_tcp_remote_port
+ON frp_proxies(server_id, remote_port)
+WHERE type = 'tcp' AND remote_port IS NOT NULL;
diff --git a/migrations/016_frp_server_options.sql b/migrations/016_frp_server_options.sql
new file mode 100644
index 0000000..6ac997b
--- /dev/null
+++ b/migrations/016_frp_server_options.sql
@@ -0,0 +1,2 @@
+ALTER TABLE frp_servers ADD COLUMN config_json TEXT NOT NULL DEFAULT '{}';
+ALTER TABLE frp_servers ADD COLUMN oidc_client_secret_enc TEXT NOT NULL DEFAULT '';
diff --git a/migrations/017_frp_server_diagnostics.sql b/migrations/017_frp_server_diagnostics.sql
new file mode 100644
index 0000000..10c3940
--- /dev/null
+++ b/migrations/017_frp_server_diagnostics.sql
@@ -0,0 +1,11 @@
+CREATE TABLE IF NOT EXISTS frp_server_diagnostics (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    server_id INTEGER NOT NULL REFERENCES frp_servers(id) ON DELETE CASCADE,
+    checked_at TEXT NOT NULL DEFAULT (datetime('now')),
+    available INTEGER NOT NULL DEFAULT 0 CHECK (available IN (0, 1)),
+    latency_ms INTEGER,
+    error TEXT NOT NULL DEFAULT ''
+);
+
+CREATE INDEX IF NOT EXISTS idx_frp_server_diagnostics_server_time
+ON frp_server_diagnostics(server_id, checked_at);
diff --git a/migrations/018_frp_proxy_options.sql b/migrations/018_frp_proxy_options.sql
new file mode 100644
index 0000000..cd39db9
--- /dev/null
+++ b/migrations/018_frp_proxy_options.sql
@@ -0,0 +1 @@
+ALTER TABLE frp_proxies ADD COLUMN config_json TEXT NOT NULL DEFAULT '{}';
diff --git a/migrations/019_frp_runtime_states.sql b/migrations/019_frp_runtime_states.sql
new file mode 100644
index 0000000..a759d80
--- /dev/null
+++ b/migrations/019_frp_runtime_states.sql
@@ -0,0 +1,18 @@
+ALTER TABLE frp_runtime_status ADD COLUMN desired_state TEXT NOT NULL DEFAULT 'stopped';
+ALTER TABLE frp_runtime_status ADD COLUMN process_state TEXT NOT NULL DEFAULT 'stopped';
+ALTER TABLE frp_runtime_status ADD COLUMN connection_state TEXT NOT NULL DEFAULT 'unknown';
+ALTER TABLE frp_runtime_status ADD COLUMN state_source TEXT NOT NULL DEFAULT 'legacy';
+ALTER TABLE frp_runtime_status ADD COLUMN process_pid INTEGER;
+ALTER TABLE frp_runtime_status ADD COLUMN exit_code INTEGER;
+ALTER TABLE frp_runtime_status ADD COLUMN last_connected_at TEXT;
+ALTER TABLE frp_runtime_status ADD COLUMN last_disconnected_at TEXT;
+
+UPDATE frp_runtime_status
+SET desired_state = CASE WHEN status IN ('running', 'starting') THEN 'running' ELSE 'stopped' END,
+    process_state = CASE WHEN status IN ('running', 'starting') THEN 'unknown' ELSE 'stopped' END,
+    connection_state = CASE WHEN status = 'running' THEN 'unknown' ELSE 'unknown' END,
+    state_source = 'migration'
+WHERE desired_state = 'stopped' AND status IN ('running', 'starting');
+
+CREATE INDEX IF NOT EXISTS idx_frp_runtime_process_state
+ON frp_runtime_status(process_state, connection_state);
diff --git a/migrations/020_frp_dashboard.sql b/migrations/020_frp_dashboard.sql
new file mode 100644
index 0000000..4d6185b
--- /dev/null
+++ b/migrations/020_frp_dashboard.sql
@@ -0,0 +1 @@
+ALTER TABLE frp_servers ADD COLUMN dashboard_password_enc TEXT;
diff --git a/migrations/021_frp_proxy_types.sql b/migrations/021_frp_proxy_types.sql
new file mode 100644
index 0000000..220c085
--- /dev/null
+++ b/migrations/021_frp_proxy_types.sql
@@ -0,0 +1,42 @@
+-- FRP 规则类型与端口约束修正(文档 FRP_CROSS_MODULE_IMPLEMENTATION_PLAN.md 23.2)
+-- 原 014_frp.sql 的 type CHECK 只有 tcp/http/https,但校验器与前端已支持
+-- udp/tcpmux/stcp/sudp/xtcp:这些类型会在校验通过后因 SQLite CHECK 约束插入失败。
+-- SQLite 无法 ALTER CHECK 约束,这里重建 frp_proxies 表并统一约束。
+CREATE TABLE IF NOT EXISTS frp_proxies_new (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    server_id INTEGER NOT NULL REFERENCES frp_servers(id) ON DELETE CASCADE,
+    name TEXT NOT NULL COLLATE NOCASE,
+    type TEXT NOT NULL CHECK (type IN ('tcp', 'udp', 'http', 'https', 'tcpmux', 'stcp', 'sudp', 'xtcp')),
+    local_ip TEXT NOT NULL DEFAULT '127.0.0.1',
+    local_port INTEGER NOT NULL CHECK (local_port > 0 AND local_port <= 65535),
+    remote_port INTEGER CHECK (
+        remote_port IS NULL
+        OR (remote_port > 0 AND remote_port <= 65535 AND type IN ('tcp', 'udp'))
+    ),
+    custom_domains_json TEXT NOT NULL DEFAULT '[]',
+    host_header_rewrite TEXT NOT NULL DEFAULT '',
+    config_json TEXT NOT NULL DEFAULT '{}',
+    enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
+    remark TEXT NOT NULL DEFAULT '',
+    created_at TEXT NOT NULL DEFAULT (datetime('now')),
+    updated_at TEXT NOT NULL DEFAULT (datetime('now')),
+    UNIQUE(server_id, name)
+);
+
+INSERT INTO frp_proxies_new
+    (id, server_id, name, type, local_ip, local_port, remote_port, custom_domains_json, host_header_rewrite, config_json, enabled, remark, created_at, updated_at)
+SELECT
+    id, server_id, name, type, local_ip, local_port, remote_port, custom_domains_json, host_header_rewrite, COALESCE(config_json, '{}'), enabled, remark, created_at, updated_at
+FROM frp_proxies;
+
+DROP TABLE frp_proxies;
+ALTER TABLE frp_proxies_new RENAME TO frp_proxies;
+
+-- tcp 与 udp 的远程端口在同一 server_id 内分别查重(原索引混用两种协议)
+DROP INDEX IF EXISTS idx_frp_proxies_tcp_remote_port;
+CREATE UNIQUE INDEX IF NOT EXISTS idx_frp_proxies_tcp_remote_port
+ON frp_proxies(server_id, remote_port)
+WHERE type = 'tcp' AND remote_port IS NOT NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS idx_frp_proxies_udp_remote_port
+ON frp_proxies(server_id, remote_port)
+WHERE type = 'udp' AND remote_port IS NOT NULL;
diff --git a/migrations/022_ddns_custom_ip.sql b/migrations/022_ddns_custom_ip.sql
new file mode 100644
index 0000000..995a30f
--- /dev/null
+++ b/migrations/022_ddns_custom_ip.sql
@@ -0,0 +1,3 @@
+-- DDNS 支持自定义 IP:填写后更新记录时直接使用该 IP(如公网服务器 IP),不填则检测本机公网 IP。
+ALTER TABLE ddns_configs ADD COLUMN custom_ipv4 TEXT NOT NULL DEFAULT '';
+ALTER TABLE ddns_configs ADD COLUMN custom_ipv6 TEXT NOT NULL DEFAULT '';
diff --git a/migrations/023_frp_server_agent.sql b/migrations/023_frp_server_agent.sql
new file mode 100644
index 0000000..066aa34
--- /dev/null
+++ b/migrations/023_frp_server_agent.sql
@@ -0,0 +1,9 @@
+-- 公网 fonu-agent 远程管理配置
+ALTER TABLE frp_servers ADD COLUMN agent_url TEXT NOT NULL DEFAULT '';
+ALTER TABLE frp_servers ADD COLUMN agent_token_enc TEXT NOT NULL DEFAULT '';
+ALTER TABLE frp_servers ADD COLUMN ssh_host TEXT NOT NULL DEFAULT '';
+ALTER TABLE frp_servers ADD COLUMN ssh_port INTEGER NOT NULL DEFAULT 22;
+ALTER TABLE frp_servers ADD COLUMN ssh_user TEXT NOT NULL DEFAULT '';
+ALTER TABLE frp_servers ADD COLUMN ssh_auth TEXT NOT NULL DEFAULT 'key';
+ALTER TABLE frp_servers ADD COLUMN ssh_secret_enc TEXT NOT NULL DEFAULT '';
+ALTER TABLE frp_servers ADD COLUMN mgmt_enabled INTEGER NOT NULL DEFAULT 0 CHECK (mgmt_enabled IN (0, 1));
diff --git a/web/assets/image/429.png b/web/assets/image/429.png
new file mode 100644
index 0000000..ab1071c
Binary files /dev/null and b/web/assets/image/429.png differ
diff --git a/web/assets/image/aliyun.png b/web/assets/image/aliyun.png
new file mode 100644
index 0000000..a32460f
Binary files /dev/null and b/web/assets/image/aliyun.png differ
diff --git a/web/assets/image/banner.png b/web/assets/image/banner.png
new file mode 100644
index 0000000..9f745c4
Binary files /dev/null and b/web/assets/image/banner.png differ
diff --git a/web/assets/image/cloudflare.png b/web/assets/image/cloudflare.png
new file mode 100644
index 0000000..252ef9c
Binary files /dev/null and b/web/assets/image/cloudflare.png differ
diff --git a/web/assets/image/dnspod.png b/web/assets/image/dnspod.png
new file mode 100644
index 0000000..c06f2cd
Binary files /dev/null and b/web/assets/image/dnspod.png differ
diff --git a/web/assets/image/error.png b/web/assets/image/error.png
new file mode 100644
index 0000000..f68b754
Binary files /dev/null and b/web/assets/image/error.png differ
diff --git a/web/assets/image/issue.png b/web/assets/image/issue.png
new file mode 100644
index 0000000..4f77555
Binary files /dev/null and b/web/assets/image/issue.png differ
diff --git a/web/assets/image/logo.png b/web/assets/image/logo.png
new file mode 100644
index 0000000..45a4760
Binary files /dev/null and b/web/assets/image/logo.png differ
diff --git a/web/assets/image/tencentcloud.png b/web/assets/image/tencentcloud.png
new file mode 100644
index 0000000..e00336f
Binary files /dev/null and b/web/assets/image/tencentcloud.png differ
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..ac2f7e8
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,13 @@
+
+
+  
+    
+    
+    
+    Fonu
+  
+  
+    
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..a49e148 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2055 @@ +{ + "name": "fonu-web", + "version": "1.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fonu-web", + "version": "1.0.1", + "dependencies": { + "@codemirror/commands": "^6.11.1", + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/language": "^6.12.4", + "@codemirror/legacy-modes": "^6.5.4", + "@codemirror/state": "^6.7.5", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.43.12", + "@vicons/ionicons5": "^0.13.0", + "codemirror": "^6.0.2", + "echarts": "^6.1.0", + "naive-ui": "^2.40.1", + "sortablejs": "^1.15.7", + "vue": "^3.5.13", + "vue-codemirror": "^6.1.1", + "vue-echarts": "^8.3.0", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@types/sortablejs": "^1.15.9", + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "^5.7.2", + "vite": "^6.0.3", + "vue-tsc": "^2.2.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmmirror.com/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.11.1", + "resolved": "https://registry.npmmirror.com/@codemirror/commands/-/commands-6.11.1.tgz", + "integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmmirror.com/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://registry.npmmirror.com/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/legacy-modes": { + "version": "6.5.4", + "resolved": "https://registry.npmmirror.com/@codemirror/legacy-modes/-/legacy-modes-6.5.4.tgz", + "integrity": "sha512-/cZr6qZyl08iYNLGsJ862CXXNI51LryRFRE40ejgoIjXZz0C1rGkD3/Ek5jM/8w1ceRjqtt4qx/KLMh4zBTgew==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmmirror.com/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.2", + "resolved": "https://registry.npmmirror.com/@codemirror/search/-/search-6.7.2.tgz", + "integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.5", + "resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-6.7.5.tgz", + "integrity": "sha512-QjLbZmY1Au3JiRrDVYFLRD0BZ3SOKS9pR3yjIkd7u27YY8TFD9/Q9fhPnLV5l1mHFSo3hHU/N31vpwEJOx4owQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmmirror.com/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.12", + "resolved": "https://registry.npmmirror.com/@codemirror/view/-/view-6.43.12.tgz", + "integrity": "sha512-Nv0vxQ19NAqvB/c2pFzjIzFlzzJl7jmdtNkwOwGbn0Ks9mFAzibvumz7cQem5cRsFA2cEw2fg+uHZGbcHupLQQ==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@css-render/plugin-bem": { + "version": "0.15.14", + "resolved": "https://registry.npmmirror.com/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz", + "integrity": "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==", + "license": "MIT", + "peerDependencies": { + "css-render": "~0.15.14" + } + }, + "node_modules/@css-render/vue3-ssr": { + "version": "0.15.14", + "resolved": "https://registry.npmmirror.com/@css-render/vue3-ssr/-/vue3-ssr-0.15.14.tgz", + "integrity": "sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.0.11" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@juggle/resize-observer": { + "version": "3.4.0", + "resolved": "https://registry.npmmirror.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", + "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==", + "license": "Apache-2.0" + }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmmirror.com/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmmirror.com/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmmirror.com/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz", + "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==", + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.25", + "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.25.tgz", + "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/sortablejs": { + "version": "1.15.9", + "resolved": "https://registry.npmmirror.com/@types/sortablejs/-/sortablejs-1.15.9.tgz", + "integrity": "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vicons/ionicons5": { + "version": "0.13.0", + "resolved": "https://registry.npmmirror.com/@vicons/ionicons5/-/ionicons5-0.13.0.tgz", + "integrity": "sha512-zvZKBPjEXKN7AXNo2Na2uy+nvuv6SP4KAMQxpKL2vfHMj0fSvuw7JZcOPCjQC3e7ayssKnaoFVAhbYcW6v41qQ==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.42.tgz", + "integrity": "sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.42", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.42.tgz", + "integrity": "sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.42.tgz", + "integrity": "sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.42", + "@vue/compiler-dom": "3.5.42", + "@vue/compiler-ssr": "3.5.42", + "@vue/shared": "3.5.42", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.42.tgz", + "integrity": "sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmmirror.com/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.42.tgz", + "integrity": "sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.42.tgz", + "integrity": "sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.42.tgz", + "integrity": "sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.42", + "@vue/runtime-core": "3.5.42", + "@vue/shared": "3.5.42", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.42.tgz", + "integrity": "sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.42", + "@vue/runtime-dom": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.42.tgz", + "integrity": "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==", + "license": "MIT" + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, + "node_modules/css-render": { + "version": "0.15.14", + "resolved": "https://registry.npmmirror.com/css-render/-/css-render-0.15.14.tgz", + "integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "~0.8.0", + "csstype": "~3.0.5" + } + }, + "node_modules/css-render/node_modules/csstype": { + "version": "3.0.11", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.0.11.tgz", + "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmmirror.com/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-tz": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/date-fns-tz/-/date-fns-tz-3.2.0.tgz", + "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==", + "license": "MIT", + "peerDependencies": { + "date-fns": "^3.0.0 || ^4.0.0" + } + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/echarts": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/echarts/-/echarts-6.1.0.tgz", + "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.1.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/evtd": { + "version": "0.2.4", + "resolved": "https://registry.npmmirror.com/evtd/-/evtd-0.2.4.tgz", + "integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/highlight.js": { + "version": "11.12.0", + "resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.12.0.tgz", + "integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/naive-ui": { + "version": "2.45.3", + "resolved": "https://registry.npmmirror.com/naive-ui/-/naive-ui-2.45.3.tgz", + "integrity": "sha512-D83gJ+6zUdkQ5DuUbmlfyaM1t8pV5WMPa6KmL9mByqQuiL12qBdA0n/iEIth4dQw1x/duNGpOS2anpt5FKO1kw==", + "license": "MIT", + "dependencies": { + "@css-render/plugin-bem": "^0.15.14", + "@css-render/vue3-ssr": "^0.15.14", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "async-validator": "^4.2.5", + "css-render": "^0.15.14", + "csstype": "^3.1.3", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", + "evtd": "^0.2.4", + "highlight.js": "^11.8.0", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "seemly": "^0.3.10", + "treemate": "^0.3.11", + "vdirs": "^0.1.8", + "vooks": "^0.2.12", + "vueuc": "^0.4.66" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/seemly": { + "version": "0.3.10", + "resolved": "https://registry.npmmirror.com/seemly/-/seemly-0.3.10.tgz", + "integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==", + "license": "MIT" + }, + "node_modules/sortablejs": { + "version": "1.15.7", + "resolved": "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.15.7.tgz", + "integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmmirror.com/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/treemate": { + "version": "0.3.11", + "resolved": "https://registry.npmmirror.com/treemate/-/treemate-0.3.11.tgz", + "integrity": "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vdirs": { + "version": "0.1.8", + "resolved": "https://registry.npmmirror.com/vdirs/-/vdirs-0.1.8.tgz", + "integrity": "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==", + "license": "MIT", + "dependencies": { + "evtd": "^0.2.2" + }, + "peerDependencies": { + "vue": "^3.0.11" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vooks": { + "version": "0.2.12", + "resolved": "https://registry.npmmirror.com/vooks/-/vooks-0.2.12.tgz", + "integrity": "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==", + "license": "MIT", + "dependencies": { + "evtd": "^0.2.2" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/vscode-uri": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.2.0.tgz", + "integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.42.tgz", + "integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.42", + "@vue/compiler-sfc": "3.5.42", + "@vue/runtime-dom": "3.5.42", + "@vue/server-renderer": "3.5.42", + "@vue/shared": "3.5.42" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-codemirror": { + "version": "6.1.1", + "resolved": "https://registry.npmmirror.com/vue-codemirror/-/vue-codemirror-6.1.1.tgz", + "integrity": "sha512-rTAYo44owd282yVxKtJtnOi7ERAcXTeviwoPXjIc6K/IQYUsoDkzPvw/JDFtSP6T7Cz/2g3EHaEyeyaQCKoDMg==", + "license": "MIT", + "dependencies": { + "@codemirror/commands": "6.x", + "@codemirror/language": "6.x", + "@codemirror/state": "6.x", + "@codemirror/view": "6.x" + }, + "peerDependencies": { + "codemirror": "6.x", + "vue": "3.x" + } + }, + "node_modules/vue-echarts": { + "version": "8.3.0", + "resolved": "https://registry.npmmirror.com/vue-echarts/-/vue-echarts-8.3.0.tgz", + "integrity": "sha512-gqOZ5uuBF4x4KTXQpTmgH5BuAwUSO+y9U7L1pHCIESQvgnTitXtnyjrfePH+9YXMRhmTLjchEx3EnsPhoiIX/A==", + "license": "MIT", + "peerDependencies": { + "echarts": "^6.0.0", + "vue": "^3.3.0" + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/vueuc": { + "version": "0.4.66", + "resolved": "https://registry.npmmirror.com/vueuc/-/vueuc-0.4.66.tgz", + "integrity": "sha512-hHrZl+0AsrUvqYPBXSXQ2QaSrO3razjeUseLAhJWlOS/Hdfw/Y2MU05fOTP1qn1A3121gMxsHLaEz2V6CkWdTg==", + "license": "MIT", + "dependencies": { + "@css-render/vue3-ssr": "^0.15.10", + "@juggle/resize-observer": "^3.3.1", + "css-render": "^0.15.10", + "evtd": "^0.2.4", + "seemly": "^0.3.6", + "vdirs": "^0.1.4", + "vooks": "^0.2.4" + }, + "peerDependencies": { + "vue": "^3.0.11" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmmirror.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/zrender": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.1.0.tgz", + "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..1d43ebc --- /dev/null +++ b/web/package.json @@ -0,0 +1,37 @@ +{ + "name": "fonu-web", + "private": true, + "version": "1.0.2", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "vue-tsc --noEmit" + }, + "dependencies": { + "@codemirror/commands": "^6.11.1", + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/language": "^6.12.4", + "@codemirror/legacy-modes": "^6.5.4", + "@codemirror/state": "^6.7.5", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.43.12", + "@vicons/ionicons5": "^0.13.0", + "codemirror": "^6.0.2", + "echarts": "^6.1.0", + "naive-ui": "^2.40.1", + "sortablejs": "^1.15.7", + "vue": "^3.5.13", + "vue-codemirror": "^6.1.1", + "vue-echarts": "^8.3.0", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@types/sortablejs": "^1.15.9", + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "^5.7.2", + "vite": "^6.0.3", + "vue-tsc": "^2.2.0" + } +} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml new file mode 100644 index 0000000..c83fcb9 --- /dev/null +++ b/web/pnpm-lock.yaml @@ -0,0 +1,1423 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@codemirror/commands': + specifier: ^6.11.1 + version: 6.11.1 + '@codemirror/lang-javascript': + specifier: ^6.2.5 + version: 6.2.5 + '@codemirror/language': + specifier: ^6.12.4 + version: 6.12.4 + '@codemirror/legacy-modes': + specifier: ^6.5.4 + version: 6.5.4 + '@codemirror/state': + specifier: ^6.7.5 + version: 6.7.5 + '@codemirror/theme-one-dark': + specifier: ^6.1.3 + version: 6.1.3 + '@codemirror/view': + specifier: ^6.43.12 + version: 6.43.12 + '@vicons/ionicons5': + specifier: ^0.13.0 + version: 0.13.0 + codemirror: + specifier: ^6.0.2 + version: 6.0.2 + echarts: + specifier: ^6.1.0 + version: 6.1.0 + naive-ui: + specifier: ^2.40.1 + version: 2.45.3(vue@3.5.43(typescript@5.9.3)) + sortablejs: + specifier: ^1.15.7 + version: 1.15.7 + vue: + specifier: ^3.5.13 + version: 3.5.43(typescript@5.9.3) + vue-codemirror: + specifier: ^6.1.1 + version: 6.1.1(codemirror@6.0.2)(vue@3.5.43(typescript@5.9.3)) + vue-echarts: + specifier: ^8.3.0 + version: 8.3.0(echarts@6.1.0)(vue@3.5.43(typescript@5.9.3)) + vue-router: + specifier: ^4.5.0 + version: 4.6.4(vue@3.5.43(typescript@5.9.3)) + devDependencies: + '@types/sortablejs': + specifier: ^1.15.9 + version: 1.15.9 + '@vitejs/plugin-vue': + specifier: ^5.2.1 + version: 5.2.4(vite@6.4.3)(vue@3.5.43(typescript@5.9.3)) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vite: + specifier: ^6.0.3 + version: 6.4.3 + vue-tsc: + specifier: ^2.2.0 + version: 2.2.12(typescript@5.9.3) + +packages: + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@codemirror/autocomplete@6.20.3': + resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==} + + '@codemirror/commands@6.11.1': + resolution: {integrity: sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==} + + '@codemirror/lang-javascript@6.2.5': + resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==} + + '@codemirror/language@6.12.4': + resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==} + + '@codemirror/legacy-modes@6.5.4': + resolution: {integrity: sha512-/cZr6qZyl08iYNLGsJ862CXXNI51LryRFRE40ejgoIjXZz0C1rGkD3/Ek5jM/8w1ceRjqtt4qx/KLMh4zBTgew==} + + '@codemirror/lint@6.9.7': + resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==} + + '@codemirror/search@6.7.2': + resolution: {integrity: sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==} + + '@codemirror/state@6.7.5': + resolution: {integrity: sha512-QjLbZmY1Au3JiRrDVYFLRD0BZ3SOKS9pR3yjIkd7u27YY8TFD9/Q9fhPnLV5l1mHFSo3hHU/N31vpwEJOx4owQ==} + + '@codemirror/theme-one-dark@6.1.3': + resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==} + + '@codemirror/view@6.43.12': + resolution: {integrity: sha512-Nv0vxQ19NAqvB/c2pFzjIzFlzzJl7jmdtNkwOwGbn0Ks9mFAzibvumz7cQem5cRsFA2cEw2fg+uHZGbcHupLQQ==} + + '@css-render/plugin-bem@0.15.14': + resolution: {integrity: sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==} + peerDependencies: + css-render: ~0.15.14 + + '@css-render/vue3-ssr@0.15.14': + resolution: {integrity: sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==} + peerDependencies: + vue: ^3.0.11 + + '@emotion/hash@0.8.0': + resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@juggle/resize-observer@3.4.0': + resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} + + '@lezer/common@1.5.2': + resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/javascript@1.5.4': + resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} + + '@lezer/lr@1.4.10': + resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==} + + '@marijn/find-cluster-break@1.0.4': + resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-android-arm-eabi@4.63.3': + resolution: {integrity: sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.63.3': + resolution: {integrity: sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.63.3': + resolution: {integrity: sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.63.3': + resolution: {integrity: sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.63.3': + resolution: {integrity: sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.63.3': + resolution: {integrity: sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.63.3': + resolution: {integrity: sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.63.3': + resolution: {integrity: sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.63.3': + resolution: {integrity: sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.63.3': + resolution: {integrity: sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.63.3': + resolution: {integrity: sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.63.3': + resolution: {integrity: sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.63.3': + resolution: {integrity: sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.63.3': + resolution: {integrity: sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.63.3': + resolution: {integrity: sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.63.3': + resolution: {integrity: sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.63.3': + resolution: {integrity: sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.63.3': + resolution: {integrity: sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.63.3': + resolution: {integrity: sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.63.3': + resolution: {integrity: sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.63.3': + resolution: {integrity: sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.63.3': + resolution: {integrity: sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.63.3': + resolution: {integrity: sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.63.3': + resolution: {integrity: sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.63.3': + resolution: {integrity: sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA==} + cpu: [x64] + os: [win32] + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + + '@types/lodash@4.17.25': + resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==} + + '@types/sortablejs@1.15.9': + resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==} + + '@vicons/ionicons5@0.13.0': + resolution: {integrity: sha512-zvZKBPjEXKN7AXNo2Na2uy+nvuv6SP4KAMQxpKL2vfHMj0fSvuw7JZcOPCjQC3e7ayssKnaoFVAhbYcW6v41qQ==} + + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + + '@volar/language-core@2.4.15': + resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==} + + '@volar/source-map@2.4.15': + resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==} + + '@volar/typescript@2.4.15': + resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/compiler-core@3.5.43': + resolution: {integrity: sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==} + + '@vue/compiler-dom@3.5.43': + resolution: {integrity: sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==} + + '@vue/compiler-sfc@3.5.43': + resolution: {integrity: sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==} + + '@vue/compiler-ssr@3.5.43': + resolution: {integrity: sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==} + + '@vue/compiler-vue2@2.7.16': + resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/language-core@2.2.12': + resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/reactivity@3.5.43': + resolution: {integrity: sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==} + + '@vue/runtime-core@3.5.43': + resolution: {integrity: sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==} + + '@vue/runtime-dom@3.5.43': + resolution: {integrity: sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==} + + '@vue/server-renderer@3.5.43': + resolution: {integrity: sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==} + + '@vue/shared@3.5.43': + resolution: {integrity: sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==} + + alien-signals@1.0.13: + resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==} + + async-validator@4.2.5: + resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + brace-expansion@2.1.7: + resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==} + + codemirror@6.0.2: + resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} + + crelt@1.0.7: + resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} + + css-render@0.15.14: + resolution: {integrity: sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==} + + csstype@3.0.11: + resolution: {integrity: sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + date-fns-tz@3.2.0: + resolution: {integrity: sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==} + peerDependencies: + date-fns: ^3.0.0 || ^4.0.0 + + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + + echarts@6.1.0: + resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + evtd@0.2.4: + resolution: {integrity: sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + highlight.js@11.12.0: + resolution: {integrity: sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==} + engines: {node: '>=12.0.0'} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + naive-ui@2.45.3: + resolution: {integrity: sha512-D83gJ+6zUdkQ5DuUbmlfyaM1t8pV5WMPa6KmL9mByqQuiL12qBdA0n/iEIth4dQw1x/duNGpOS2anpt5FKO1kw==} + engines: {node: '>=20'} + peerDependencies: + vue: ^3.0.0 + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.63.3: + resolution: {integrity: sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + seemly@0.3.10: + resolution: {integrity: sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==} + + sortablejs@1.15.7: + resolution: {integrity: sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + treemate@0.3.11: + resolution: {integrity: sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==} + + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + vdirs@0.1.8: + resolution: {integrity: sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==} + peerDependencies: + vue: ^3.0.11 + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vooks@0.2.12: + resolution: {integrity: sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==} + peerDependencies: + vue: ^3.0.0 + + vscode-uri@3.2.0: + resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==} + + vue-codemirror@6.1.1: + resolution: {integrity: sha512-rTAYo44owd282yVxKtJtnOi7ERAcXTeviwoPXjIc6K/IQYUsoDkzPvw/JDFtSP6T7Cz/2g3EHaEyeyaQCKoDMg==} + peerDependencies: + codemirror: 6.x + vue: 3.x + + vue-echarts@8.3.0: + resolution: {integrity: sha512-gqOZ5uuBF4x4KTXQpTmgH5BuAwUSO+y9U7L1pHCIESQvgnTitXtnyjrfePH+9YXMRhmTLjchEx3EnsPhoiIX/A==} + peerDependencies: + echarts: ^6.0.0 + vue: ^3.3.0 + + vue-router@4.6.4: + resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} + peerDependencies: + vue: ^3.5.0 + + vue-tsc@2.2.12: + resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue@3.5.43: + resolution: {integrity: sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + vueuc@0.4.66: + resolution: {integrity: sha512-hHrZl+0AsrUvqYPBXSXQ2QaSrO3razjeUseLAhJWlOS/Hdfw/Y2MU05fOTP1qn1A3121gMxsHLaEz2V6CkWdTg==} + peerDependencies: + vue: ^3.0.11 + + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + + zrender@6.1.0: + resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==} + +snapshots: + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@codemirror/autocomplete@6.20.3': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.5 + '@codemirror/view': 6.43.12 + '@lezer/common': 1.5.2 + + '@codemirror/commands@6.11.1': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.5 + '@codemirror/view': 6.43.12 + '@lezer/common': 1.5.2 + + '@codemirror/lang-javascript@6.2.5': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/lint': 6.9.7 + '@codemirror/state': 6.7.5 + '@codemirror/view': 6.43.12 + '@lezer/common': 1.5.2 + '@lezer/javascript': 1.5.4 + + '@codemirror/language@6.12.4': + dependencies: + '@codemirror/state': 6.7.5 + '@codemirror/view': 6.43.12 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + style-mod: 4.1.3 + + '@codemirror/legacy-modes@6.5.4': + dependencies: + '@codemirror/language': 6.12.4 + + '@codemirror/lint@6.9.7': + dependencies: + '@codemirror/state': 6.7.5 + '@codemirror/view': 6.43.12 + crelt: 1.0.7 + + '@codemirror/search@6.7.2': + dependencies: + '@codemirror/state': 6.7.5 + '@codemirror/view': 6.43.12 + crelt: 1.0.7 + + '@codemirror/state@6.7.5': + dependencies: + '@marijn/find-cluster-break': 1.0.4 + + '@codemirror/theme-one-dark@6.1.3': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.5 + '@codemirror/view': 6.43.12 + '@lezer/highlight': 1.2.3 + + '@codemirror/view@6.43.12': + dependencies: + '@codemirror/state': 6.7.5 + crelt: 1.0.7 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + + '@css-render/plugin-bem@0.15.14(css-render@0.15.14)': + dependencies: + css-render: 0.15.14 + + '@css-render/vue3-ssr@0.15.14(vue@3.5.43(typescript@5.9.3))': + dependencies: + vue: 3.5.43(typescript@5.9.3) + + '@emotion/hash@0.8.0': {} + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@juggle/resize-observer@3.4.0': {} + + '@lezer/common@1.5.2': {} + + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/javascript@1.5.4': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/lr@1.4.10': + dependencies: + '@lezer/common': 1.5.2 + + '@marijn/find-cluster-break@1.0.4': {} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@rollup/rollup-android-arm-eabi@4.63.3': + optional: true + + '@rollup/rollup-android-arm64@4.63.3': + optional: true + + '@rollup/rollup-darwin-arm64@4.63.3': + optional: true + + '@rollup/rollup-darwin-x64@4.63.3': + optional: true + + '@rollup/rollup-freebsd-arm64@4.63.3': + optional: true + + '@rollup/rollup-freebsd-x64@4.63.3': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.63.3': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.63.3': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.63.3': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.63.3': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.63.3': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.63.3': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.63.3': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.63.3': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.63.3': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.63.3': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.63.3': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.63.3': + optional: true + + '@rollup/rollup-linux-x64-musl@4.63.3': + optional: true + + '@rollup/rollup-openbsd-x64@4.63.3': + optional: true + + '@rollup/rollup-openharmony-arm64@4.63.3': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.63.3': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.63.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.63.3': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.63.3': + optional: true + + '@types/estree@1.0.9': {} + + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.25 + + '@types/lodash@4.17.25': {} + + '@types/sortablejs@1.15.9': {} + + '@vicons/ionicons5@0.13.0': {} + + '@vitejs/plugin-vue@5.2.4(vite@6.4.3)(vue@3.5.43(typescript@5.9.3))': + dependencies: + vite: 6.4.3 + vue: 3.5.43(typescript@5.9.3) + + '@volar/language-core@2.4.15': + dependencies: + '@volar/source-map': 2.4.15 + + '@volar/source-map@2.4.15': {} + + '@volar/typescript@2.4.15(typescript@5.9.3)': + dependencies: + '@volar/language-core': 2.4.15 + path-browserify: 1.0.1 + vscode-uri: 3.2.0 + optionalDependencies: + typescript: 5.9.3 + + '@vue/compiler-core@3.5.43': + dependencies: + '@babel/parser': 7.29.8 + '@vue/shared': 3.5.43 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.43': + dependencies: + '@vue/compiler-core': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/compiler-sfc@3.5.43': + dependencies: + '@babel/parser': 7.29.8 + '@vue/compiler-core': 3.5.43 + '@vue/compiler-dom': 3.5.43 + '@vue/compiler-ssr': 3.5.43 + '@vue/shared': 3.5.43 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.28 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.43': + dependencies: + '@vue/compiler-dom': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/compiler-vue2@2.7.16': + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + '@vue/devtools-api@6.6.4': {} + + '@vue/language-core@2.2.12(typescript@5.9.3)': + dependencies: + '@volar/language-core': 2.4.15 + '@vue/compiler-dom': 3.5.43 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.43 + alien-signals: 1.0.13 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.9.3 + + '@vue/reactivity@3.5.43': + dependencies: + '@vue/shared': 3.5.43 + + '@vue/runtime-core@3.5.43': + dependencies: + '@vue/reactivity': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/runtime-dom@3.5.43': + dependencies: + '@vue/reactivity': 3.5.43 + '@vue/runtime-core': 3.5.43 + '@vue/shared': 3.5.43 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.43': + dependencies: + '@vue/compiler-ssr': 3.5.43 + '@vue/runtime-dom': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/shared@3.5.43': {} + + alien-signals@1.0.13: {} + + async-validator@4.2.5: {} + + balanced-match@1.0.2: {} + + brace-expansion@2.1.7: + dependencies: + balanced-match: 1.0.2 + + codemirror@6.0.2: + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/commands': 6.11.1 + '@codemirror/language': 6.12.4 + '@codemirror/lint': 6.9.7 + '@codemirror/search': 6.7.2 + '@codemirror/state': 6.7.5 + '@codemirror/view': 6.43.12 + + crelt@1.0.7: {} + + css-render@0.15.14: + dependencies: + '@emotion/hash': 0.8.0 + csstype: 3.0.11 + + csstype@3.0.11: {} + + csstype@3.2.3: {} + + date-fns-tz@3.2.0(date-fns@4.4.0): + dependencies: + date-fns: 4.4.0 + + date-fns@4.4.0: {} + + de-indent@1.0.2: {} + + echarts@6.1.0: + dependencies: + tslib: 2.3.0 + zrender: 6.1.0 + + entities@7.0.1: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + estree-walker@2.0.2: {} + + evtd@0.2.4: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + he@1.2.0: {} + + highlight.js@11.12.0: {} + + lodash-es@4.18.1: {} + + lodash@4.18.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.7 + + muggle-string@0.4.1: {} + + naive-ui@2.45.3(vue@3.5.43(typescript@5.9.3)): + dependencies: + '@css-render/plugin-bem': 0.15.14(css-render@0.15.14) + '@css-render/vue3-ssr': 0.15.14(vue@3.5.43(typescript@5.9.3)) + '@types/lodash': 4.17.25 + '@types/lodash-es': 4.17.12 + async-validator: 4.2.5 + css-render: 0.15.14 + csstype: 3.2.3 + date-fns: 4.4.0 + date-fns-tz: 3.2.0(date-fns@4.4.0) + evtd: 0.2.4 + highlight.js: 11.12.0 + lodash: 4.18.1 + lodash-es: 4.18.1 + seemly: 0.3.10 + treemate: 0.3.11 + vdirs: 0.1.8(vue@3.5.43(typescript@5.9.3)) + vooks: 0.2.12(vue@3.5.43(typescript@5.9.3)) + vue: 3.5.43(typescript@5.9.3) + vueuc: 0.4.66(vue@3.5.43(typescript@5.9.3)) + + nanoid@3.3.19: {} + + path-browserify@1.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.63.3: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.63.3 + '@rollup/rollup-android-arm64': 4.63.3 + '@rollup/rollup-darwin-arm64': 4.63.3 + '@rollup/rollup-darwin-x64': 4.63.3 + '@rollup/rollup-freebsd-arm64': 4.63.3 + '@rollup/rollup-freebsd-x64': 4.63.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.3 + '@rollup/rollup-linux-arm-musleabihf': 4.63.3 + '@rollup/rollup-linux-arm64-gnu': 4.63.3 + '@rollup/rollup-linux-arm64-musl': 4.63.3 + '@rollup/rollup-linux-loong64-gnu': 4.63.3 + '@rollup/rollup-linux-loong64-musl': 4.63.3 + '@rollup/rollup-linux-ppc64-gnu': 4.63.3 + '@rollup/rollup-linux-ppc64-musl': 4.63.3 + '@rollup/rollup-linux-riscv64-gnu': 4.63.3 + '@rollup/rollup-linux-riscv64-musl': 4.63.3 + '@rollup/rollup-linux-s390x-gnu': 4.63.3 + '@rollup/rollup-linux-x64-gnu': 4.63.3 + '@rollup/rollup-linux-x64-musl': 4.63.3 + '@rollup/rollup-openbsd-x64': 4.63.3 + '@rollup/rollup-openharmony-arm64': 4.63.3 + '@rollup/rollup-win32-arm64-msvc': 4.63.3 + '@rollup/rollup-win32-ia32-msvc': 4.63.3 + '@rollup/rollup-win32-x64-gnu': 4.63.3 + '@rollup/rollup-win32-x64-msvc': 4.63.3 + fsevents: 2.3.3 + + seemly@0.3.10: {} + + sortablejs@1.15.7: {} + + source-map-js@1.2.1: {} + + style-mod@4.1.3: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + treemate@0.3.11: {} + + tslib@2.3.0: {} + + typescript@5.9.3: {} + + vdirs@0.1.8(vue@3.5.43(typescript@5.9.3)): + dependencies: + evtd: 0.2.4 + vue: 3.5.43(typescript@5.9.3) + + vite@6.4.3: + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.28 + rollup: 4.63.3 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + vooks@0.2.12(vue@3.5.43(typescript@5.9.3)): + dependencies: + evtd: 0.2.4 + vue: 3.5.43(typescript@5.9.3) + + vscode-uri@3.2.0: {} + + vue-codemirror@6.1.1(codemirror@6.0.2)(vue@3.5.43(typescript@5.9.3)): + dependencies: + '@codemirror/commands': 6.11.1 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.5 + '@codemirror/view': 6.43.12 + codemirror: 6.0.2 + vue: 3.5.43(typescript@5.9.3) + + vue-echarts@8.3.0(echarts@6.1.0)(vue@3.5.43(typescript@5.9.3)): + dependencies: + echarts: 6.1.0 + vue: 3.5.43(typescript@5.9.3) + + vue-router@4.6.4(vue@3.5.43(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.43(typescript@5.9.3) + + vue-tsc@2.2.12(typescript@5.9.3): + dependencies: + '@volar/typescript': 2.4.15(typescript@5.9.3) + '@vue/language-core': 2.2.12(typescript@5.9.3) + typescript: 5.9.3 + + vue@3.5.43(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.43 + '@vue/compiler-sfc': 3.5.43 + '@vue/runtime-dom': 3.5.43 + '@vue/server-renderer': 3.5.43 + '@vue/shared': 3.5.43 + optionalDependencies: + typescript: 5.9.3 + + vueuc@0.4.66(vue@3.5.43(typescript@5.9.3)): + dependencies: + '@css-render/vue3-ssr': 0.15.14(vue@3.5.43(typescript@5.9.3)) + '@juggle/resize-observer': 3.4.0 + css-render: 0.15.14 + evtd: 0.2.4 + seemly: 0.3.10 + vdirs: 0.1.8(vue@3.5.43(typescript@5.9.3)) + vooks: 0.2.12(vue@3.5.43(typescript@5.9.3)) + vue: 3.5.43(typescript@5.9.3) + + w3c-keyname@2.2.8: {} + + zrender@6.1.0: + dependencies: + tslib: 2.3.0 diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml new file mode 100644 index 0000000..00f6fc4 --- /dev/null +++ b/web/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: set this to true or false diff --git a/web/public/favicon.png b/web/public/favicon.png new file mode 100644 index 0000000..eab0c1e Binary files /dev/null and b/web/public/favicon.png differ diff --git a/web/src/App.vue b/web/src/App.vue new file mode 100644 index 0000000..0ba8861 --- /dev/null +++ b/web/src/App.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..8885dbd --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,372 @@ +import type { + AccessLogEntry, + ApiError, + AppVersion, + AuthStatus, + CertificateCAOption, + CertificateRecord, + DashboardStatus, + DDNSConfig, + DDNSSavePayload, + DDNSTestPayload, + FRPResponse, + FRPSavePayload, + FRPSaveResponse, + FRPStatus, + FrpBinary, + FrpDownloadTask, + FrpProxy, + FrpProxyPayload, + FrpProxiesTraffic, + FrpRelease, + FrpRuntimeInfo, + FrpServer, + FrpServerDiagnostics, + FrpServerDetail, + FrpServerPayload, + FrpStatus, + DiscoveredService, + ProxyClientConn, + ChinaCIDRStatus, + ProxyRule, + ProxySavePayload, + ProxyTraffic, + GlobalNginxView, + RuleNginxView, + SettingsMap, + SystemLogEntry, + NotifyTestPayload, +} from './types' + +export function asList(value: T[] | null | undefined): T[] { + return Array.isArray(value) ? value : [] +} + +const REQUEST_TIMEOUT_MS = 30_000 +const CERT_REQUEST_TIMEOUT_MS = 10 * 60_000 + +async function request(path: string, init?: RequestInit, timeoutMs = REQUEST_TIMEOUT_MS): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + try { + return await requestWithSignal(path, init, controller.signal) + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + throw new Error( + timeoutMs > REQUEST_TIMEOUT_MS + ? '证书操作超时,请稍后在证书列表查看状态,或在「日志 → 实时日志」查看 Nginx 输出' + : '请求超时,请检查 Fonu 服务或 Nginx 状态', + ) + } + throw error + } finally { + clearTimeout(timeout) + } +} + +async function requestWithSignal(path: string, init?: RequestInit, signal?: AbortSignal): Promise { + const response = await fetch(path, { + credentials: 'include', + signal, + headers: { + 'Content-Type': 'application/json', + ...(init?.headers ?? {}), + }, + ...init, + }) + + if (!response.ok) { + let message = '请求失败' + try { + const body = (await response.json()) as ApiError + if (body.error) { + message = body.error + } + } catch { + // ignore + } + throw new Error(message) + } + + if (response.status === 204) { + return undefined as T + } + return (await response.json()) as T +} + +export const api = { + getVersion: () => request('/api/version'), + authStatus: () => request('/api/auth/status'), + login: (username: string, password: string) => + request<{ message: string }>('/api/auth/login', { + method: 'POST', + body: JSON.stringify({ username, password }), + }), + logout: () => request<{ message: string }>('/api/auth/logout', { method: 'POST' }), + changePassword: (old_password: string, new_password: string) => + request<{ message: string }>('/api/auth/password', { + method: 'POST', + body: JSON.stringify({ old_password, new_password }), + }), + + getStatus: () => request('/api/status'), + listProxies: () => request('/api/proxies'), + createProxy: (payload: ProxySavePayload) => + request('/api/proxies', { method: 'POST', body: JSON.stringify(payload) }), + updateProxy: (id: number, payload: Partial) => + request(`/api/proxies/${id}`, { method: 'PUT', body: JSON.stringify(payload) }), + deleteProxy: (id: number) => request(`/api/proxies/${id}`, { method: 'DELETE' }), + reorderProxies: (ids: number[]) => + request('/api/proxies/reorder', { method: 'PUT', body: JSON.stringify({ ids }) }), + getProxyTraffic: () => request('/api/proxies/traffic'), + getProxyClients: (id: number) => request(`/api/proxies/${id}/clients`), + getProxyNginx: (id: number) => request(`/api/proxies/${id}/nginx`), + saveProxyNginx: (id: number, payload: { mode: 'auto' | 'custom'; content?: string }) => + request(`/api/proxies/${id}/nginx`, { method: 'PUT', body: JSON.stringify(payload) }), + rollbackProxyNginx: (id: number, backup?: string) => + request(`/api/proxies/${id}/nginx/rollback`, { + method: 'POST', + body: JSON.stringify(backup ? { backup } : {}), + }), + + listDDNS: () => request('/api/ddns'), + listDDNSLite: () => request('/api/ddns?lite=1'), + createDDNS: (payload: DDNSSavePayload) => + request('/api/ddns', { method: 'POST', body: JSON.stringify(payload) }), + updateDDNS: (id: number, payload: DDNSSavePayload) => + request(`/api/ddns/${id}`, { method: 'PUT', body: JSON.stringify(payload) }), + deleteDDNS: (id: number) => request(`/api/ddns/${id}`, { method: 'DELETE' }), + testDDNS: (payload: DDNSTestPayload) => + request<{ message: string }>('/api/ddns/test', { + method: 'POST', + body: JSON.stringify(payload), + }), + updateAllDDNS: () => request('/api/ddns/update', { method: 'POST' }), + updateDDNSOne: (id: number) => request(`/api/ddns/${id}/update`, { method: 'POST' }), + + listCertificates: () => request('/api/certificates'), + listCertificateCAOptions: () => request('/api/certificates/ca-options'), + applyCertificate: (payload: { + dns_zone?: string + ddns_config_id?: number + domains: string[] + ca?: string + email?: string + }) => + request<{ job_id: string }>('/api/certificates/apply', { + method: 'POST', + body: JSON.stringify({ + dns_zone: payload.dns_zone ?? '', + ddns_config_id: payload.ddns_config_id ?? 0, + domains: payload.domains, + ca: payload.ca ?? '', + email: payload.email ?? '', + }), + }), + certificateApplyStreamURL: (jobId: string) => `/api/certificates/jobs/${encodeURIComponent(jobId)}/stream`, + importCertificate: (payload: { + certificate?: string + private_key?: string + cert_path?: string + key_path?: string + }) => + request('/api/certificates/import', { + method: 'POST', + body: JSON.stringify(payload), + }), + async downloadCertificate(domain: string, part: 'zip' | 'cert' | 'key' = 'zip') { + const response = await fetch( + `/api/certificates/${encodeURIComponent(domain)}/download?part=${part}`, + { credentials: 'include' }, + ) + if (!response.ok) { + let message = '下载证书失败' + try { + const body = (await response.json()) as ApiError + if (body.error) message = body.error + } catch { + // ignore + } + throw new Error(message) + } + const blob = await response.blob() + const disposition = response.headers.get('Content-Disposition') ?? '' + const match = disposition.match(/filename="?([^";\n]+)"?/) + const filename = match?.[1] ?? `${domain}.zip` + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename + link.click() + URL.revokeObjectURL(url) + }, + deleteCertificate: (domain: string) => + request<{ message: string }>(`/api/certificates/${encodeURIComponent(domain)}`, { method: 'DELETE' }), + renewCertificate: (domain?: string, ca?: string) => + request( + '/api/certificates/renew', + { + method: 'POST', + body: JSON.stringify({ domain: domain ?? '', ca: ca ?? '' }), + }, + CERT_REQUEST_TIMEOUT_MS, + ), + + getSettings: () => request('/api/settings'), + saveSettings: (payload: SettingsMap) => + request('/api/settings', { method: 'PUT', body: JSON.stringify(payload) }), + getGlobalNginx: () => request('/api/settings/nginx/global'), + saveGlobalNginx: (payload: { mode: 'auto' | 'custom'; content?: string }) => + request('/api/settings/nginx/global', { method: 'PUT', body: JSON.stringify(payload) }), + rollbackGlobalNginx: (backup?: string) => + request('/api/settings/nginx/global/rollback', { + method: 'POST', + body: JSON.stringify(backup ? { backup } : {}), + }), + getChinaCIDRStatus: () => request('/api/settings/china-cidr'), + refreshChinaCIDR: () => + request('/api/settings/china-cidr/refresh', { method: 'POST' }), + testNotify: (payload: NotifyTestPayload) => + request<{ message: string }>('/api/settings/notify/test', { + method: 'POST', + body: JSON.stringify(payload), + }), + + getFRP: () => request('/api/frp'), + saveFRP: (payload: FRPSavePayload) => + request('/api/frp', { + method: 'PUT', + body: JSON.stringify(payload), + }), + getFRPStatus: () => request('/api/frp/status'), + syncFRPDomains: () => + request('/api/frp/sync-domains', { + method: 'POST', + }), + getFRPLogs: (limit = 200) => request(`/api/frp/logs?limit=${limit}`), + + // ===== FRP 多服务端模块(/api/frpmulti)===== + getFrpStatus: () => request('/api/frpmulti/status'), + getFrpRuntime: () => request('/api/frpmulti/runtime'), + setFrpDownloadProxy: (proxy: string) => request('/api/frpmulti/runtime/download-proxy', { method: 'PUT', body: JSON.stringify({ proxy }) }), + listFrpReleases: () => request('/api/frpmulti/releases'), + listFrpBinaries: () => request('/api/frpmulti/binaries'), + getFrpDownloadStatus: () => request('/api/frpmulti/download'), + downloadFrpRelease: (version: string) => request(`/api/frpmulti/releases/${encodeURIComponent(version)}/download`, { method: 'POST' }), + activateFrpBinary: (version: string) => request(`/api/frpmulti/binaries/${encodeURIComponent(version)}/activate`, { method: 'POST' }), + deleteFrpBinary: (version: string) => request(`/api/frpmulti/binaries/${encodeURIComponent(version)}`, { method: 'DELETE' }), + listFrpServers: () => request('/api/frpmulti/servers'), + getFrpServerDiagnostics: (id: number) => request(`/api/frpmulti/servers/${id}/diagnostics`), + getFrpServerDetail: (id: number) => request(`/api/frpmulti/servers/${id}/detail`), + startFrpServer: (id: number) => request(`/api/frpmulti/servers/${id}/start`, { method: 'POST' }), + stopFrpServer: (id: number) => request(`/api/frpmulti/servers/${id}/stop`, { method: 'POST' }), + createFrpServer: (payload: FrpServerPayload) => + request('/api/frpmulti/servers', { method: 'POST', body: JSON.stringify(payload) }), + updateFrpServer: (id: number, payload: FrpServerPayload) => + request(`/api/frpmulti/servers/${id}`, { method: 'PUT', body: JSON.stringify(payload) }), + deleteFrpServer: (id: number) => request(`/api/frpmulti/servers/${id}`, { method: 'DELETE' }), + testFrpServer: (id: number) => request<{ message: string }>(`/api/frpmulti/servers/${id}/test`, { method: 'POST' }), + listFrpProxies: () => request('/api/frpmulti/proxies'), + getFrpProxyLogs: (id: number, limit = 200) => request(`/api/frpmulti/proxies/${id}/logs?limit=${limit}`), + listFrpProxiesTraffic: () => request('/api/frpmulti/proxies/traffic'), + createFrpProxy: (payload: FrpProxyPayload) => + request('/api/frpmulti/proxies', { method: 'POST', body: JSON.stringify(payload) }), + updateFrpProxy: (id: number, payload: FrpProxyPayload) => + request(`/api/frpmulti/proxies/${id}`, { method: 'PUT', body: JSON.stringify(payload) }), + deleteFrpProxy: (id: number) => request(`/api/frpmulti/proxies/${id}`, { method: 'DELETE' }), + startFrp: () => request('/api/frpmulti/start', { method: 'POST' }), + stopFrp: () => request('/api/frpmulti/stop', { method: 'POST' }), + reloadFrp: () => request('/api/frpmulti/reload', { method: 'POST' }), + getFrpLogs: (limit = 200, serverID?: number) => { + const query = new URLSearchParams({ limit: String(limit) }) + if (serverID) query.set('server_id', String(serverID)) + return request(`/api/frpmulti/logs?${query.toString()}`) + }, + exportFrpConfig: () => request<{ content: string }>('/api/frpmulti/config/export'), + getFrpFRPSConfig: (id: number) => request<{ content: string }>(`/api/frpmulti/servers/${id}/frps-config`), + testFrpAgent: (id: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${id}/agent/test`, { method: 'POST' }), + probeFrpAgent: (id: number) => request>(`/api/frpmulti/servers/${id}/agent/probe`, { method: 'POST' }), + installFrpAgent: (id: number) => request(`/api/frpmulti/servers/${id}/agent/install`, { method: 'POST' }), + uninstallFrpAgent: (id: number, keepData: boolean) => request<{ ok: boolean; output: string }>(`/api/frpmulti/servers/${id}/agent/uninstall?keep_data=${keepData}`, { method: 'POST' }), + sshDiagnoseFrpAgent: (id: number) => request<{ output: string }>(`/api/frpmulti/servers/${id}/agent/ssh-diagnose`, { method: 'POST' }), + getFrpAgentStatus: (id: number) => request(`/api/frpmulti/servers/${id}/agent/status`), + getFrpAgentRoutes: (id: number) => request(`/api/frpmulti/servers/${id}/agent/routes`), + getFrpAgentRouteCandidates: (id: number) => request(`/api/frpmulti/servers/${id}/agent/route-candidates`), + deployFrpAgentRoute: (serverId: number, proxyId: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}`, { method: 'POST' }), + getFrpAgentRouteConf: (serverId: number, proxyId: number) => + request<{ proxy_id: number; domain: string; content: string }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}/conf`), + installFrpAgentNginx: (serverId: number) => + request<{ ok: boolean; manager?: string; output?: string; path?: string; error?: string }>(`/api/frpmulti/servers/${serverId}/agent/nginx/install`, { method: 'POST' }), + getFrpAgentRouteHealth: (serverId: number) => + request(`/api/frpmulti/servers/${serverId}/agent/routes/health`), + removeFrpAgentRoute: (serverId: number, proxyId: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}`, { method: 'DELETE' }), + pushFrpConfigToAgent: (id: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${id}/agent/frps-config`, { method: 'POST' }), + frpsServerAction: (id: number, action: 'start' | 'stop' | 'restart') => + request<{ ok: boolean }>(`/api/frpmulti/servers/${id}/agent/frps-action`, { method: 'POST', body: JSON.stringify({ action }) }), + frpsServerActionRaw: (id: number, action: 'start' | 'stop' | 'restart') => + request<{ ok: boolean; error?: string }>(`/api/frpmulti/servers/${id}/agent/frps-action`, { method: 'POST', body: JSON.stringify({ action }) }), + installFrpServer: (id: number, version: string, proxy: string) => + request>(`/api/frpmulti/servers/${id}/agent/frps-install`, { method: 'POST', body: JSON.stringify({ version, proxy }) }), + + getAccessLogs: (params?: { limit?: number; keyword?: string; status?: number }) => { + const q = new URLSearchParams() + if (params?.limit) q.set('limit', String(params.limit)) + if (params?.keyword) q.set('keyword', params.keyword) + if (params?.status) q.set('status', String(params.status)) + return request(`/api/logs/access?${q}`) + }, + getErrorLogs: (params?: { limit?: number; keyword?: string }) => { + const q = new URLSearchParams() + if (params?.limit) q.set('limit', String(params.limit)) + if (params?.keyword) q.set('keyword', params.keyword) + return request(`/api/logs/error?${q}`) + }, + getSystemLogs: (params?: { limit?: number; level?: string; keyword?: string }) => { + const q = new URLSearchParams() + if (params?.limit) q.set('limit', String(params.limit)) + if (params?.level) q.set('level', params.level) + if (params?.keyword) q.set('keyword', params.keyword) + return request(`/api/logs/system?${q}`) + }, + + scanDiscovery: (host?: string) => { + const q = new URLSearchParams() + if (host) q.set('host', host) + return request(`/api/discovery/scan?${q}`) + }, + + async exportBackup() { + const response = await fetch('/api/backup/export', { credentials: 'include' }) + if (!response.ok) { + throw new Error('导出备份失败') + } + const blob = await response.blob() + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `fonu-backup-${new Date().toISOString().slice(0, 10)}.tar.gz` + link.click() + URL.revokeObjectURL(url) + }, + + async restoreBackup(file: File) { + const form = new FormData() + form.append('file', file) + const response = await fetch('/api/backup/restore', { + method: 'POST', + credentials: 'include', + body: form, + }) + if (!response.ok) { + let message = '恢复备份失败' + try { + const body = (await response.json()) as ApiError + if (body.error) message = body.error + } catch { + // ignore + } + throw new Error(message) + } + return (await response.json()) as { message: string } + }, +} diff --git a/web/src/api/types.ts b/web/src/api/types.ts new file mode 100644 index 0000000..d452686 --- /dev/null +++ b/web/src/api/types.ts @@ -0,0 +1,794 @@ +export interface ProxyHost { + id: number + hostname: string + listen_port?: number | null +} + +export interface BasicAuthConfig { + enabled?: boolean + username?: string + has_password?: boolean +} + +export interface RateLimitConfig { + enabled?: boolean + rate?: number + burst?: number +} + +export interface ConnLimitConfig { + enabled?: boolean + max?: number +} + +export interface ProxySecurityConfig { + ip_blacklist?: string[] + ip_whitelist?: string[] + ip_whitelist_mode?: boolean + china_only?: boolean + basic_auth?: BasicAuthConfig + rate_limit?: RateLimitConfig + conn_limit?: ConnLimitConfig + proxy_ssl_verify_off?: boolean + proxy_host_upstream?: boolean + tls_min_13_only?: boolean + security_headers?: boolean +} + +export interface ProxyRule { + id: number + domain: string + upstream: string + listen_port: number + listen_ipv4: boolean + listen_ipv6: boolean + hosts: ProxyHost[] + https_enabled: boolean + http_redirect: boolean + enabled: boolean + nginx_mode?: string + name: string + sort_order: number + security: ProxySecurityConfig + created_at: string + updated_at: string +} + +export interface ChinaCIDRStatus { + updated_at?: string + entry_count_v4: number + entry_count_v6: number + last_error?: string + updating?: boolean + ready?: boolean +} + +export interface ProxyTraffic { + rule_id: number + upload_total: number + download_total: number + upload_rate: number + download_rate: number + connections: number +} + +export interface ProxyClientConn { + ip: string + last_seen: string +} + +export interface ProxySecurityPayload { + ip_blacklist?: string[] + ip_whitelist?: string[] + ip_whitelist_mode?: boolean + ip_blacklist_text?: string + ip_whitelist_text?: string + china_only?: boolean + basic_auth?: { + enabled?: boolean + username?: string + password?: string + } + rate_limit?: RateLimitConfig + conn_limit?: ConnLimitConfig + proxy_ssl_verify_off?: boolean + proxy_host_upstream?: boolean + tls_min_13_only?: boolean + security_headers?: boolean +} + +export interface ProxySavePayload { + upstream: string + listen_port?: number + listen_ipv4?: boolean + listen_ipv6?: boolean + hosts: string[] + https_enabled?: boolean + http_redirect?: boolean + enabled?: boolean + name?: string + security?: ProxySecurityPayload +} + +export interface AuthStatus { + initialized: boolean + authenticated: boolean +} + +export interface AppVersion { + version: string + nginx_http_port: number + nginx_https_port: number +} + +export interface ApiError { + error: string +} + +export interface DashboardStatus { + public_ipv4: string + public_ipv6: string + public_ip_source?: 'ddns' | 'detect' | 'none' + ddns_status: string + ddns_count: number + ddns_last_updated?: string + certificate_status: string + certificate_days: number + certificate_count: number + certificate_summary?: string + proxy_count: number + nginx_status: string + request_today: number + request_yesterday?: number + request_trend?: number | null + requests_hourly: number[] + requests_hourly_labels?: string[] + error_today: number + avg_response_ms: number + started_at: string + uptime_seconds: number +} + +export interface DDNSDomainRecord { + domain: string + ipv4?: string + ipv6?: string + status: string + message?: string +} + +export interface DDNSConfig { + id: number + provider: string + root_domain: string + record_name: string + record_names?: string[] + ipv4_enabled: boolean + ipv6_enabled: boolean + enabled: boolean + has_token: boolean + last_ipv4?: string + last_ipv6?: string + last_status?: string + last_error?: string + custom_ipv4?: string + custom_ipv6?: string + domain_records?: DDNSDomainRecord[] + last_updated_at?: string +} + +export interface CertificateCAOption { + value: string + label: string +} + +export interface CertificateJobDone { + ok: boolean + error?: string + domain?: string + cert_path?: string + key_path?: string + cert_dir?: string + expires_at?: string +} + +export interface CertificateJobEvent { + type: 'log' | 'done' + level?: string + message?: string + result?: CertificateJobDone +} + +export interface CertificateRecord { + id: number + domain: string + domains?: string[] + wildcard: boolean + acme_ca?: string + status: string + days_left: number + last_error?: string + expires_at?: string + last_renew_at?: string +} + +export interface AccessLogEntry { + time: string + domain: string + server_port?: number + method: string + path: string + status: number + response_time: number + client_ip: string + upstream: string +} + +export interface SystemLogEntry { + time: string + level: string + module: string + message: string +} + +export type NotifyType = '' | 'email' | 'webhook' | 'telegram' +export type WebhookProvider = 'bark' | 'ntfy' | 'gotify' | 'custom' + +export interface NotifyEmailConfig { + host: string + port: number + username: string + from: string + to: string[] + tls: boolean + has_password?: boolean +} + +export interface NotifyWebhookConfig { + provider: WebhookProvider + server: string + key: string + topic: string + url: string + has_secret?: boolean +} + +export interface NotifyTelegramConfig { + chat_id: string + proxy_url: string + has_bot_token?: boolean +} + +export interface NotifyTestPayload { + type: NotifyType + email: NotifyEmailConfig + webhook: NotifyWebhookConfig + telegram: NotifyTelegramConfig + on_ddns_ip_change: boolean + on_ddns_failure: boolean + on_cert_expiry: boolean + on_cert_renew_success: boolean + on_cert_renew_failure: boolean + on_ip_frequent_access: boolean + on_login_failure: boolean + on_nginx_reload_failure: boolean + ip_frequent_threshold: number + ip_frequent_window_sec: number + login_failure_threshold: number + login_failure_window_sec: number + smtp_password?: string + webhook_secret?: string + telegram_token?: string +} + +export type SettingsMap = Record + +export interface DiscoveredService { + name: string + port: number + host: string + upstream: string + detected: boolean + platform: string + title?: string + suggestion?: string +} + +export interface DDNSSavePayload { + provider?: string + root_domain?: string + record_name?: string + record_names?: string[] + domains?: string[] + ipv4_enabled?: boolean + ipv6_enabled?: boolean + enabled?: boolean + custom_ipv4?: string + custom_ipv6?: string + api_token?: string + api_token_id?: string + api_secret?: string +} + +export interface DDNSTestPayload { + config_id?: number + provider?: string + api_token?: string + api_token_id?: string + api_secret?: string +} + +export interface FRPStatus { + enabled: boolean + connected: boolean + message: string + last_error?: string + server_endpoint?: string + connected_at?: string + uptime_seconds?: number + client_version?: string + http_gateway_enabled?: boolean + https_gateway_enabled?: boolean + synced_domain_count?: number +} + +export interface FRPConfig { + enabled: boolean + server_addr: string + server_port: number + auth_token: string + has_auth_token: boolean + tls_enabled: boolean + custom_domains: string[] +} + +export interface FRPResponse { + enabled: boolean + server_addr: string + server_port: number + auth_token: string + has_auth_token: boolean + tls_enabled: boolean + custom_domains: string[] + status: FRPStatus + frps_config: string + nginx_http_port: number + nginx_https_port: number +} + +export interface FRPSaveResponse { + message: string + config: FRPConfig + status: FRPStatus + frps_config: string + nginx_http_port: number + nginx_https_port: number +} + +export interface FRPSavePayload { + enabled: boolean + server_addr: string + server_port: number + auth_token?: string + tls_enabled: boolean + custom_domains: string[] +} + +// ===== FRP 多服务端模块(/api/frpmulti,0.1.25 核心迁移)===== + +export interface FrpServer { + agent_url?: string + agent_configured?: boolean + ssh_configured?: boolean + mgmt_enabled?: boolean + ssh_host?: string + ssh_port?: number + ssh_user?: string + ssh_auth?: string + id: number + name: string + server_addr: string + server_port: number + tls_enabled: boolean + tls_server_name?: string + enabled: boolean + has_token: boolean + has_oidc_client_secret: boolean + dashboard_has_pwd?: boolean + options: FrpServerOptions + boot_status: 'starting' | 'running' | 'stopped' | 'error' + desired_state: 'starting' | 'running' | 'stopped' | 'error' | string + process_state: 'starting' | 'running' | 'stopped' | 'error' | 'unknown' | string + connection_state: 'connected' | 'connecting' | 'disconnected' | 'error' | 'unknown' | string + state_source: string + process_pid?: number + exit_code?: number + last_connected_at?: string + last_disconnected_at?: string + last_error?: string + last_started_at?: string + created_at: string + updated_at: string +} + +export interface FrpServerOptions { + user?: string + location?: string + /** frps 的 vhost 监听端口(http/https 反代回源端口),与 frps.toml 和 Nginx upstream 同源;留空用默认 8080/8443 */ + vhost_http_port?: number + vhost_https_port?: number + /** frps 进阶:子域名根(规则 subdomain 生效前提)*/ + sub_domain_host?: string + /** HTTP vhost 404 页面路径(VPS 本地路径)*/ + custom_404_page?: string + /** 强制 TLS:只接受 TLS 连接(frpc 必须开 transport.tls.enable)*/ + tls_force?: boolean + /** 端口白名单:逗号分隔的单端口或范围,如 6000,6005-6010;留空不限制 */ + allow_ports?: string + /** 每客户端可绑定端口上限;0/留空不限 */ + max_ports_per_client?: number + /** tcpmux 规则的 HTTP CONNECT 监听端口 */ + tcpmux_http_connect_port?: number + /** frps 写文件日志 /var/log/frps.log(journalctl 之外)*/ + frps_log_to_file?: boolean + /** 文件日志保留天数,默认 3 */ + frps_log_max_days?: number + /** frps 管理接口(webServer),仅用于 Fonu 拉取穿透流量等只读信息 */ + dashboard_addr?: string + dashboard_port?: number + dashboard_user?: string + dashboard_has_pwd?: boolean + auth_method: 'none' | 'token' | 'oidc' + oidc_client_id?: string + oidc_audience?: string + oidc_scope?: string + oidc_token_endpoint_url?: string + oidc_trusted_ca_path?: string + oidc_insecure_skip_verify?: boolean + oidc_proxy_url?: string + log_level: 'trace' | 'debug' | 'info' | 'warn' | 'error' + log_max_days: number + protocol: 'tcp' | 'kcp' | 'quic' | 'websocket' | 'wss' + proxy_url?: string + dial_server_timeout: number + dial_server_keepalive: number + connect_server_local_ip?: string + dns_server?: string + pool_count: number + tcp_mux: boolean + tcp_mux_keepalive_interval: number + heartbeat_interval: number + heartbeat_timeout: number + login_fail_exit: boolean + tls_disable_custom_first_byte: boolean + auto_start: boolean + remark?: string + tls_certificate_configured?: boolean + tls_key_configured?: boolean + tls_trusted_ca_configured?: boolean +} + +export interface FrpProxy { + id: number + server_id: number + name: string + type: 'tcp' | 'udp' | 'http' | 'https' | 'tcpmux' | 'stcp' | 'sudp' | 'xtcp' + local_ip: string + local_port: number + remote_port?: number | null + custom_domains: string[] + host_header_rewrite?: string + options: FrpProxyOptions + enabled: boolean + remark?: string + created_at: string + updated_at: string +} + +export interface FrpProxyOptions { + subdomain?: string + /** WebSocket 开关:仅作用于公网 Nginx 反代模板(加 Upgrade/Connection 头) */ + websocket?: boolean + /** 公网反代:HTTP 80 强制 301 跳转 HTTPS(需公网证书已部署)*/ + redirect_https?: boolean + /** 公网反代:IP 白名单(allow/deny),空则不限制 */ + allow_ips?: string[] + /** 公网反代:Basic Auth 用户名;密码由 agent 首次部署自动生成(见配置注释)*/ + basic_auth_user?: string + /** 公网反代:IP 黑名单(deny),与白名单可同时使用 */ + deny_ips?: string[] + /** 公网反代:Basic Auth 自定义密码;留空则首次部署自动生成 */ + basic_auth_password?: string + /** 公网反代:显式关闭 HTTPS(证书已推送也不监听 443);缺省 = 启用 */ + https_disabled?: boolean + /** 公网反代:客户端上传大小限制,如 50m */ + client_max_body_size?: string + /** 公网反代:代理读超时,如 3600s(长轮询 / 流媒体 / 大文件必需)*/ + proxy_read_timeout?: string + /** 公网反代:安全响应头(HSTS / X-Frame-Options / X-Content-Type-Options / Referrer-Policy)*/ + security_headers?: boolean + /** 公网反代:仅允许 TLS 1.3 */ + tls13_only?: boolean + /** 公网反代:请求限流(次/秒);0 = 不限制 */ + rate_limit_rate?: number + /** 公网反代:限流突发允许量 */ + rate_limit_burst?: number + /** 公网反代:每 IP 并发连接数上限;0 = 不限制 */ + conn_limit_max?: number + /** 公网反代:仅允许中国大陆 IP(部署时自动下发本地维护的 IP 段)*/ + china_only?: boolean + locations?: string[] + route_by_http_user?: string + http_user?: string + http_password?: string + multiplexer?: string + secret_key?: string + allow_users?: string[] + transport: { + bandwidth_limit?: string + bandwidth_limit_mode?: 'client' | 'server' | string + use_encryption?: boolean + use_compression?: boolean + proxy_protocol_version?: 'v1' | 'v2' | string + } + health_check: { + type?: 'tcp' | 'http' | string + path?: string + interval_seconds?: number + max_failed?: number + timeout_seconds?: number + http_headers?: Array<{ name: string; value: string }> + } + load_balancer: { group?: string; group_key?: string } + metadatas?: Record + request_headers?: Record + response_headers?: Record + plugin?: { + type: string + unix_path?: string + local_path?: string + strip_prefix?: string + local_addr?: string + http_user?: string + http_password?: string + username?: string + password?: string + crt_path?: string + key_path?: string + host_header_rewrite?: string + destination_ip?: string + } + nat_traversal: { disable_assisted_addrs?: boolean } +} + +/** frps 侧单条穿透规则的流量快照;速率为两次采样间隔内的均值 */ +export interface FrpProxyTraffic { + traffic_in: number + traffic_out: number + cur_conns: number + traffic_in_rate: number + traffic_out_rate: number +} + +/** 按 frp 规则 ID 聚合的流量数据;errors 记录采集失败的服务端/规则错误 */ +export interface FrpProxiesTraffic { + items: Record + errors?: Record + sampled_at: string +} + +export interface FrpStatus { + status: 'running' | 'stopped' | 'unknown' + pid?: number + frpc_version?: string + last_started_at?: string + last_reloaded_at?: string + last_error?: string + configured: boolean + enabled_proxies: number +} + +export interface FrpRuntimeInfo { + platform: string + configured_bin: string + active_bin?: string + active_version?: string + detected_version?: string + download_proxy: string + config_path: string + config_paths?: string[] + config_exists: boolean +} + +export interface FrpRelease { + version: string + published_at?: string + asset_name: string + size: number +} + +export interface FrpBinary { + version: string + path: string + size: number + installed_at: string + active: boolean +} + +/** 单项诊断结果;state 为 ok/failed/unknown,unknown 表示无法确认而非失败 */ +export interface FrpDiagnosticCheck { + name: 'tcp_reachable' | 'frpc_login' | 'proxy_reachable' | 'dns_resolution' | 'location' | string + state: 'ok' | 'failed' | 'unknown' | string + detail?: string + latency_ms?: number +} + +export interface FrpServerDiagnostics { + server_id: number + available: boolean + latency_ms?: number + location?: string + checks?: FrpDiagnosticCheck[] + error?: string +} + +export interface FrpServerSample { + checked_at: string + available: boolean + latency_ms?: number + error?: string +} + +export interface FrpServerStatusDetail { + available: boolean + availability: number + average_latency_ms: number + peak_latency_ms: number + samples: FrpServerSample[] + last_checked_at?: string + last_error?: string +} + +export interface FrpServerDetail { + server: FrpServer + status: FrpServerStatusDetail + proxies: FrpProxy[] +} + +export interface FrpDownloadTask { + version?: string + status: 'idle' | 'queued' | 'downloading' | 'verifying' | 'extracting' | 'completed' | 'failed' + message: string + downloaded: number + total: number + error?: string + updated_at?: string +} + +export interface FrpServerPayload { + agent_url?: string + agent_token?: string + clear_agent_token?: boolean + ssh_host?: string + ssh_port?: number + ssh_user?: string + ssh_auth?: string + ssh_secret?: string + clear_ssh_secret?: boolean + mgmt_enabled?: boolean + name: string + server_addr: string + server_port: number + tls_enabled?: boolean + tls_server_name?: string + enabled?: boolean + token?: string + clear_token?: boolean + options: FrpServerOptions + oidc_client_secret?: string + clear_oidc_client_secret?: boolean + dashboard_password?: string + clear_dashboard_password?: boolean + tls_certificate?: string + tls_key?: string + tls_trusted_ca?: string + clear_tls_certificate?: boolean + clear_tls_key?: boolean + clear_tls_trusted_ca?: boolean +} + +export interface FrpAgentRouteCandidate { + id: number + name: string + type: 'http' | 'https' + custom_domains: string[] + local_ip: string + local_port: number + enabled: boolean +} + +export interface FrpAgentRouteHealth { + proxy_id: number + domain: string + dns_ok: boolean + dns_detail?: string + cert_ok: boolean + cert_detail?: string + tunnel_ok: boolean + tunnel_name?: string + tunnel_detail?: string + service_ok: boolean +} + +export interface FrpAgentRouteDetail { + domain: string + file_exists: boolean + syntax_ok: boolean + loaded: boolean +} + +export interface FrpAgentStatus { + agent_version?: string + ports?: Record + frps_active?: boolean + frps_enabled?: boolean + frps_version?: string + frps_log_tail?: string + frps_unit_exec?: string + dashboard_addr?: string + service: string + nginx_binary: string + nginx_conf_dir: string + /** nginx -t 是否通过 */ + nginx_ok?: boolean + /** 未找到 nginx 可执行文件 */ + nginx_missing?: boolean + /** 解析到的 nginx 路径 */ + nginx_path?: string + /** 有 nginx 进程在运行 */ + nginx_running?: boolean + /** nginx 版本,如 nginx/1.24.0 */ + nginx_version?: string + /** 探测失败原因(缺失或配置有误) */ + nginx_error?: string + frps_binary: string + frps_config_path: string + routes: string[] + checked_at: string +} + +export interface FrpProxyPayload { + server_id: number + name: string + type: FrpProxy['type'] + local_ip: string + local_port: number + remote_port?: number | null + custom_domains?: string[] + host_header_rewrite?: string + options?: FrpProxyOptions + enabled?: boolean + remark?: string +} + +export interface NginxBackupEntry { + name: string + created_at: string +} + +export interface RuleNginxView { + mode: 'auto' | 'custom' + enabled: boolean + active: boolean + generated: string + content: string + backups: NginxBackupEntry[] +} + +export interface GlobalNginxView { + mode: 'auto' | 'custom' + generated_framework: string + generated_snippet: string + content: string + backups: NginxBackupEntry[] +} diff --git a/web/src/assets/brand/banner-art.png b/web/src/assets/brand/banner-art.png new file mode 100644 index 0000000..36928c0 Binary files /dev/null and b/web/src/assets/brand/banner-art.png differ diff --git a/web/src/assets/brand/banner.png b/web/src/assets/brand/banner.png new file mode 100644 index 0000000..0604b2a Binary files /dev/null and b/web/src/assets/brand/banner.png differ diff --git a/web/src/assets/brand/ca/buypass.png b/web/src/assets/brand/ca/buypass.png new file mode 100644 index 0000000..9d0aa72 Binary files /dev/null and b/web/src/assets/brand/ca/buypass.png differ diff --git a/web/src/assets/brand/ca/ca-logos.png b/web/src/assets/brand/ca/ca-logos.png new file mode 100644 index 0000000..2a3942e Binary files /dev/null and b/web/src/assets/brand/ca/ca-logos.png differ diff --git a/web/src/assets/brand/ca/letsencrypt.png b/web/src/assets/brand/ca/letsencrypt.png new file mode 100644 index 0000000..d963dee Binary files /dev/null and b/web/src/assets/brand/ca/letsencrypt.png differ diff --git a/web/src/assets/brand/ca/zerossl.png b/web/src/assets/brand/ca/zerossl.png new file mode 100644 index 0000000..270f348 Binary files /dev/null and b/web/src/assets/brand/ca/zerossl.png differ diff --git a/web/src/assets/brand/dns/aliyun.png b/web/src/assets/brand/dns/aliyun.png new file mode 100644 index 0000000..d6fb921 Binary files /dev/null and b/web/src/assets/brand/dns/aliyun.png differ diff --git a/web/src/assets/brand/dns/cloudflare.png b/web/src/assets/brand/dns/cloudflare.png new file mode 100644 index 0000000..eb8303d Binary files /dev/null and b/web/src/assets/brand/dns/cloudflare.png differ diff --git a/web/src/assets/brand/dns/dnspod.png b/web/src/assets/brand/dns/dnspod.png new file mode 100644 index 0000000..c6f2731 Binary files /dev/null and b/web/src/assets/brand/dns/dnspod.png differ diff --git a/web/src/assets/brand/dns/tencentcloud.png b/web/src/assets/brand/dns/tencentcloud.png new file mode 100644 index 0000000..e00336f Binary files /dev/null and b/web/src/assets/brand/dns/tencentcloud.png differ diff --git a/web/src/assets/brand/dns/volcengine.png b/web/src/assets/brand/dns/volcengine.png new file mode 100644 index 0000000..97deb99 Binary files /dev/null and b/web/src/assets/brand/dns/volcengine.png differ diff --git a/web/src/assets/brand/logo.png b/web/src/assets/brand/logo.png new file mode 100644 index 0000000..eab0c1e Binary files /dev/null and b/web/src/assets/brand/logo.png differ diff --git a/web/src/components/ConfiguredSecretField.vue b/web/src/components/ConfiguredSecretField.vue new file mode 100644 index 0000000..04131f6 --- /dev/null +++ b/web/src/components/ConfiguredSecretField.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/web/src/components/EmptyState.vue b/web/src/components/EmptyState.vue new file mode 100644 index 0000000..a4f2f10 --- /dev/null +++ b/web/src/components/EmptyState.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/web/src/components/FonuCard.vue b/web/src/components/FonuCard.vue new file mode 100644 index 0000000..a01635e --- /dev/null +++ b/web/src/components/FonuCard.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/web/src/components/LoadError.vue b/web/src/components/LoadError.vue new file mode 100644 index 0000000..133d924 --- /dev/null +++ b/web/src/components/LoadError.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/web/src/components/MiniBarChart.vue b/web/src/components/MiniBarChart.vue new file mode 100644 index 0000000..0e4f58f --- /dev/null +++ b/web/src/components/MiniBarChart.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/web/src/components/MiniDonutChart.vue b/web/src/components/MiniDonutChart.vue new file mode 100644 index 0000000..c6cf38c --- /dev/null +++ b/web/src/components/MiniDonutChart.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/web/src/components/MiniTrafficChart.vue b/web/src/components/MiniTrafficChart.vue new file mode 100644 index 0000000..7a9cd3c --- /dev/null +++ b/web/src/components/MiniTrafficChart.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/web/src/components/NginxCodeEditor.vue b/web/src/components/NginxCodeEditor.vue new file mode 100644 index 0000000..24d8897 --- /dev/null +++ b/web/src/components/NginxCodeEditor.vue @@ -0,0 +1,181 @@ + + + + + diff --git a/web/src/components/PageHeader.vue b/web/src/components/PageHeader.vue new file mode 100644 index 0000000..9e53c7d --- /dev/null +++ b/web/src/components/PageHeader.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/web/src/components/ProxyAccessLogBox.vue b/web/src/components/ProxyAccessLogBox.vue new file mode 100644 index 0000000..c66bf5e --- /dev/null +++ b/web/src/components/ProxyAccessLogBox.vue @@ -0,0 +1,307 @@ + + + + + diff --git a/web/src/components/StatCard.vue b/web/src/components/StatCard.vue new file mode 100644 index 0000000..d9f3985 --- /dev/null +++ b/web/src/components/StatCard.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/web/src/components/StatusBadge.vue b/web/src/components/StatusBadge.vue new file mode 100644 index 0000000..c585841 --- /dev/null +++ b/web/src/components/StatusBadge.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/web/src/components/TaskProviderForm.vue b/web/src/components/TaskProviderForm.vue new file mode 100644 index 0000000..a21a67b --- /dev/null +++ b/web/src/components/TaskProviderForm.vue @@ -0,0 +1,255 @@ + + + + + diff --git a/web/src/composables/useTheme.ts b/web/src/composables/useTheme.ts new file mode 100644 index 0000000..73a0b19 --- /dev/null +++ b/web/src/composables/useTheme.ts @@ -0,0 +1,136 @@ +import { computed, ref, watch } from 'vue' +import { darkTheme, type GlobalThemeOverrides } from 'naive-ui' + +const themeKey = 'fonu-theme' +const themeMode = ref(localStorage.getItem(themeKey) ?? 'system') + +function systemDark() { + return window.matchMedia('(prefers-color-scheme: dark)').matches +} + +const shared = { + primaryColor: '#10B981', + primaryColorHover: '#059669', + primaryColorPressed: '#047857', + primaryColorSuppl: '#34D399', + borderRadius: '8px', + fontFamily: 'var(--fonu-font)', +} + +const lightOverrides: GlobalThemeOverrides = { + common: { + ...shared, + bodyColor: '#F8FAFC', + cardColor: '#FFFFFF', + modalColor: '#FFFFFF', + popoverColor: '#FFFFFF', + borderColor: '#E5E7EB', + dividerColor: '#E5E7EB', + textColor1: '#111827', + textColor2: '#6B7280', + textColor3: '#9CA3AF', + hoverColor: 'rgba(16, 185, 129, 0.08)', + }, + Card: { borderRadius: '12px', color: '#FFFFFF' }, + Button: { borderRadiusMedium: '8px', heightMedium: '36px' }, + Input: { borderRadius: '8px' }, + DataTable: { borderRadius: '12px', thColor: '#F9FAFB', tdColor: '#FFFFFF' }, + Tabs: { tabGapMediumLine: '32px', tabPaddingMediumLine: '12px 4px' }, + Layout: { color: '#FFFFFF', siderColor: '#FFFFFF' }, + Menu: { + itemColorActive: 'rgba(16, 185, 129, 0.12)', + itemTextColorActive: '#047857', + itemIconColorActive: '#10B981', + itemColorActiveHover: 'rgba(16, 185, 129, 0.16)', + }, + Radio: { + buttonBorderColorActive: '#10B981', + buttonTextColorActive: '#047857', + buttonColorActive: 'rgba(16, 185, 129, 0.12)', + buttonBoxShadowHover: 'none', + buttonBoxShadowFocus: 'none', + }, +} + +const darkOverrides: GlobalThemeOverrides = { + common: { + ...shared, + bodyColor: '#0F172A', + cardColor: '#1E293B', + modalColor: '#1E293B', + popoverColor: '#1E293B', + borderColor: '#334155', + dividerColor: '#334155', + textColor1: '#F1F5F9', + textColor2: '#94A3B8', + textColor3: '#64748B', + hoverColor: 'rgba(16, 185, 129, 0.12)', + }, + Card: { borderRadius: '12px', color: '#1E293B', borderColor: '#334155' }, + Button: { borderRadiusMedium: '8px', heightMedium: '36px' }, + Input: { borderRadius: '8px', color: '#0F172A' }, + DataTable: { borderRadius: '12px', thColor: '#0F172A', tdColor: '#1E293B', borderColor: '#334155' }, + Tabs: { tabGapMediumLine: '32px', tabPaddingMediumLine: '12px 4px' }, + Layout: { color: '#0F172A', siderColor: '#1E293B' }, + Menu: { + itemColorActive: 'rgba(16, 185, 129, 0.18)', + itemTextColorActive: '#6EE7B7', + itemIconColorActive: '#34D399', + itemColorActiveHover: 'rgba(16, 185, 129, 0.24)', + }, + Radio: { + buttonBorderColorActive: '#34D399', + buttonTextColorActive: '#6EE7B7', + buttonColorActive: 'rgba(16, 185, 129, 0.22)', + buttonBoxShadowHover: 'none', + buttonBoxShadowFocus: 'none', + }, +} + +function resolveDark(mode: string) { + if (mode === 'dark') return true + if (mode === 'light') return false + return systemDark() +} + +function applyDocumentTheme(dark: boolean) { + document.documentElement.style.colorScheme = dark ? 'dark' : 'light' + document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light') + document.documentElement.classList.toggle('dark', dark) +} + +export function useTheme() { + const isDark = computed(() => resolveDark(themeMode.value)) + + const naiveTheme = computed(() => (isDark.value ? darkTheme : null)) + const themeOverrides = computed(() => + isDark.value ? darkOverrides : lightOverrides, + ) + + function setThemeMode(mode: string) { + themeMode.value = mode + } + + function toggleTheme() { + themeMode.value = isDark.value ? 'light' : 'dark' + } + + watch( + themeMode, + (value) => { + localStorage.setItem(themeKey, value) + applyDocumentTheme(resolveDark(value)) + }, + { immediate: true }, + ) + + if (typeof window !== 'undefined') { + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { + if (themeMode.value === 'system') { + applyDocumentTheme(systemDark()) + } + }) + } + + return { themeMode, naiveTheme, themeOverrides, isDark, toggleTheme, setThemeMode } +} diff --git a/web/src/constants/app.ts b/web/src/constants/app.ts new file mode 100644 index 0000000..969b450 --- /dev/null +++ b/web/src/constants/app.ts @@ -0,0 +1,4 @@ +export const GITHUB_REPO = 'O96u/Fonu' +export const GITHUB_REPO_URL = `https://github.com/${GITHUB_REPO}` +export const GITHUB_REPO_DISPLAY = `github.com/${GITHUB_REPO}` +export const GITHUB_RELEASES_URL = `${GITHUB_REPO_URL}/releases` diff --git a/web/src/constants/ipPolicy.ts b/web/src/constants/ipPolicy.ts new file mode 100644 index 0000000..e747ddf --- /dev/null +++ b/web/src/constants/ipPolicy.ts @@ -0,0 +1,26 @@ +/** Mirrors internal/proxy.DefaultPrivateCIDRs — always exempt from china_only / global allow. */ +export const DEFAULT_PRIVATE_CIDRS = [ + '10.0.0.0/8', + '127.0.0.0/8', + '172.16.0.0/12', + '192.168.0.0/16', + '::1/128', + 'fc00::/7', +] + +export function mergeIPList(defaults: string[], custom: string[]): string[] { + const seen = new Set() + const out: string[] = [] + for (const cidr of [...defaults, ...custom]) { + const value = cidr.trim() + if (!value || seen.has(value)) continue + seen.add(value) + out.push(value) + } + return out +} + +export function stripDefaultIPList(defaults: string[], list: string[]): string[] { + const defaultSet = new Set(defaults) + return list.map((item) => item.trim()).filter((item) => item && !defaultSet.has(item)) +} diff --git a/web/src/constants/notifyEvents.ts b/web/src/constants/notifyEvents.ts new file mode 100644 index 0000000..710ee27 --- /dev/null +++ b/web/src/constants/notifyEvents.ts @@ -0,0 +1,81 @@ +export interface NotifyEventOption { + key: string + label: string + hint?: string + hasThreshold?: boolean + thresholdKey?: 'ip_frequent' | 'login_failure' +} + +export interface NotifyEventGroup { + title: string + events: NotifyEventOption[] +} + +export const NOTIFY_EVENT_GROUPS: NotifyEventGroup[] = [ + { + title: 'DDNS', + events: [ + { + key: 'on_ddns_ip_change', + label: 'IP 变更', + hint: '公网 IP 变更并成功同步到 DNS', + }, + { + key: 'on_ddns_failure', + label: '更新失败', + }, + ], + }, + { + title: '证书', + events: [ + { + key: 'on_cert_expiry', + label: '即将到期', + hint: '进入续签阈值后每天提醒一次', + }, + { + key: 'on_cert_renew_success', + label: '续签成功', + }, + { + key: 'on_cert_renew_failure', + label: '续签失败', + }, + ], + }, + { + title: '安全', + events: [ + { + key: 'on_ip_frequent_access', + label: 'IP 频繁访问', + hint: '同一 IP 在统计窗口内超过阈值', + hasThreshold: true, + thresholdKey: 'ip_frequent', + }, + { + key: 'on_login_failure', + label: '登录异常', + hint: '同一 IP 在窗口内多次登录失败', + hasThreshold: true, + thresholdKey: 'login_failure', + }, + ], + }, + { + title: '系统', + events: [ + { + key: 'on_nginx_reload_failure', + label: 'Nginx 重载失败', + hint: '保存反代或证书后 Nginx 未能成功重载', + }, + ], + }, +] + +export const DEFAULT_IP_FREQUENT_THRESHOLD = 100 +export const DEFAULT_IP_FREQUENT_WINDOW_SEC = 60 +export const DEFAULT_LOGIN_FAILURE_THRESHOLD = 5 +export const DEFAULT_LOGIN_FAILURE_WINDOW_SEC = 300 diff --git a/web/src/constants/notifyProviders.ts b/web/src/constants/notifyProviders.ts new file mode 100644 index 0000000..1b07dd3 --- /dev/null +++ b/web/src/constants/notifyProviders.ts @@ -0,0 +1,19 @@ +import type { NotifyType, WebhookProvider } from '../api/types' + +export const NOTIFY_TYPE_OPTIONS: { label: string; value: NotifyType }[] = [ + { label: '关闭', value: '' }, + { label: '邮件(SMTP)', value: 'email' }, + { label: 'Webhook', value: 'webhook' }, + { label: 'Telegram 机器人', value: 'telegram' }, +] + +export const WEBHOOK_PROVIDER_OPTIONS: { label: string; value: WebhookProvider }[] = [ + { label: 'Bark', value: 'bark' }, + { label: 'ntfy', value: 'ntfy' }, + { label: 'Gotify', value: 'gotify' }, + { label: '自定义 Webhook', value: 'custom' }, +] + +export const DEFAULT_BARK_SERVER = 'https://api.day.app' +export const DEFAULT_NTFY_SERVER = 'https://ntfy.sh' +export const MASKED_SECRET = '********' diff --git a/web/src/constants/secretField.ts b/web/src/constants/secretField.ts new file mode 100644 index 0000000..f9aa4b7 --- /dev/null +++ b/web/src/constants/secretField.ts @@ -0,0 +1,3 @@ +/** 敏感凭据字段:标签与占位符全站统一 */ +export const CONFIGURED_SECRET_TAG = '已配置' +export const CONFIGURED_SECRET_PLACEHOLDER = '已配置,留空表示不修改' diff --git a/web/src/env.d.ts b/web/src/env.d.ts new file mode 100644 index 0000000..fcde624 --- /dev/null +++ b/web/src/env.d.ts @@ -0,0 +1,14 @@ +/// + +declare const __APP_VERSION__: string + +declare module '*.png' { + const src: string + export default src +} + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent, Record, unknown> + export default component +} diff --git a/web/src/layouts/AppLayout.vue b/web/src/layouts/AppLayout.vue new file mode 100644 index 0000000..36d75e3 --- /dev/null +++ b/web/src/layouts/AppLayout.vue @@ -0,0 +1,484 @@ + + + + + diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..1f9fa5f --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,12 @@ +import { createApp } from 'vue' +import naive from 'naive-ui' +import App from './App.vue' +import router from './router' + +const app = createApp(App) + +app.config.errorHandler = (err, _instance, info) => { + console.error('[fonu]', info, err) +} + +app.use(router).use(naive).mount('#app') diff --git a/web/src/router/index.ts b/web/src/router/index.ts new file mode 100644 index 0000000..6d58c19 --- /dev/null +++ b/web/src/router/index.ts @@ -0,0 +1,92 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { api } from '../api/client' +import AppLayout from '../layouts/AppLayout.vue' +import LoginView from '../views/LoginView.vue' +import ProxyView from '../views/ProxyView.vue' +import DashboardView from '../views/DashboardView.vue' +import DdnsView from '../views/DdnsView.vue' +import CertificatesView from '../views/CertificatesView.vue' +import LogsView from '../views/LogsView.vue' +import SettingsView from '../views/SettingsView.vue' +import FrpView from '../views/FrpView.vue' +import AgentView from '../views/AgentView.vue' +import ProxyRemoteView from '../views/ProxyRemoteView.vue' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/setup', redirect: '/login' }, + { path: '/login', name: 'login', component: LoginView, meta: { public: true } }, + { + path: '/', + component: AppLayout, + meta: { requiresAuth: true }, + children: [ + { path: '', redirect: '/dashboard' }, + { + path: 'dashboard', + name: 'dashboard', + component: DashboardView, + meta: { title: '仪表盘', description: '查看 Fonu 运行状态与关键指标' }, + }, + { + path: 'ddns', + name: 'ddns', + component: DdnsView, + meta: { title: 'DDNS', description: '自动同步公网 IP 到 DNS 解析' }, + }, + { + path: 'certificates', + name: 'certificates', + component: CertificatesView, + meta: { title: '证书管理', description: '通过 ACME 自动申请和续签证书' }, + }, + { + path: 'proxies', + name: 'proxies', + component: ProxyView, + meta: { title: '反向代理', description: '管理通过域名访问的 NAS 服务' }, + }, + { + path: 'frp', + name: 'frp', + component: FrpView, + meta: { title: '内网穿透', description: '通过 FRP 将公网流量转发到 Fonu Nginx' }, + }, + { + path: 'frp-agent', + name: 'frp-agent', + component: AgentView, + meta: { title: '公网 Agent', description: '管理部署在公网 VPS 上的 fonu-agent 与 frps' }, + }, + { + path: 'public-proxy', + name: 'public-proxy', + component: ProxyRemoteView, + meta: { title: '公网反代', description: '管理 VPS 上 Nginx 反代的部署/移除与安全选项' }, + }, + { + path: 'logs', + name: 'logs', + component: LogsView, + meta: { title: '日志中心', description: '查看系统、访问与 Nginx 错误日志' }, + }, + { + path: 'settings', + name: 'settings', + component: SettingsView, + meta: { title: '系统设置', description: '配置系统参数与运行策略' }, + }, + ], + }, + ], +}) + +router.beforeEach(async (to) => { + const status = await api.authStatus() + if (to.meta.requiresAuth && !status.authenticated) return { name: 'login' } + if (to.name === 'login' && status.authenticated) return { name: 'dashboard' } + return true +}) + +export default router diff --git a/web/src/styles/variables.css b/web/src/styles/variables.css new file mode 100644 index 0000000..f8f7fad --- /dev/null +++ b/web/src/styles/variables.css @@ -0,0 +1,63 @@ +:root { + --fonu-brand: #10b981; + --fonu-brand-hover: #059669; + --fonu-brand-soft: rgba(16, 185, 129, 0.12); + --fonu-brand-text: #047857; + --fonu-brand-sidebar: #10b981; + + --fonu-success: #10b981; + --fonu-warning: #f59e0b; + --fonu-error: #ef4444; + --fonu-disabled: #94a3b8; + --fonu-info: #3b82f6; + --fonu-info-soft: rgba(59, 130, 246, 0.1); + + --fonu-bg: #f8fafc; + --fonu-bg-muted: #f3f4f6; + --fonu-banner-bg: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 40%, #a7f3d0 100%); + --fonu-banner-border: rgba(16, 185, 129, 0.2); + --fonu-surface: #ffffff; + --fonu-border: #e5e7eb; + --fonu-text: #111827; + --fonu-text-secondary: #6b7280; + --fonu-text-muted: #9ca3af; + + --fonu-radius: 12px; + --fonu-radius-sm: 8px; + --fonu-shadow: 0 1px 3px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.04); + --fonu-shadow-md: 0 4px 6px -1px rgba(15, 23, 42, 0.08), 0 2px 4px -2px rgba(15, 23, 42, 0.06); + + --fonu-sidebar-width: 240px; + --fonu-topbar-height: 56px; + --fonu-form-max: 720px; + + --fonu-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; + --fonu-mono: ui-monospace, 'Cascadia Code', 'Segoe UI Mono', monospace; + + --fonu-space-1: 4px; + --fonu-space-2: 8px; + --fonu-space-3: 12px; + --fonu-space-4: 16px; + --fonu-space-5: 24px; + --fonu-space-6: 32px; +} + +html.dark, +[data-theme='dark'] { + --fonu-brand-soft: rgba(16, 185, 129, 0.18); + --fonu-brand-text: #6ee7b7; + --fonu-info-soft: rgba(59, 130, 246, 0.16); + + --fonu-bg: #0f172a; + --fonu-bg-muted: #1e293b; + --fonu-surface: #1e293b; + --fonu-border: #334155; + --fonu-text: #f1f5f9; + --fonu-text-secondary: #94a3b8; + --fonu-text-muted: #64748b; + --fonu-banner-bg: linear-gradient(135deg, #064e3b 0%, #065f46 45%, #047857 100%); + --fonu-banner-border: rgba(52, 211, 153, 0.25); + + --fonu-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + --fonu-shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35); +} diff --git a/web/src/utils/accessService.ts b/web/src/utils/accessService.ts new file mode 100644 index 0000000..168a269 --- /dev/null +++ b/web/src/utils/accessService.ts @@ -0,0 +1,138 @@ +import type { AccessLogEntry, ProxyHost, ProxyRule } from '../api/types' + +export type ProxyBinding = { + name: string + upstream: string + host: string + port: number +} + +function bindingKey(host: string, port: number): string { + return `${host.toLowerCase()}:${port}` +} + +function normalizeAccessHost(domain: string): string { + const raw = domain.trim().toLowerCase() + if (!raw) return '' + if (raw.startsWith('[')) { + const end = raw.indexOf(']') + if (end > 0) return raw.slice(0, end + 1) + } + if (raw.includes(':') && !raw.includes(']')) { + const idx = raw.lastIndexOf(':') + const maybePort = Number.parseInt(raw.slice(idx + 1), 10) + if (idx > 0 && maybePort > 0 && maybePort <= 65535) { + return raw.slice(0, idx) + } + } + return raw +} + +export function buildProxyBindingIndex(rules: ProxyRule[]): Map { + const index = new Map() + for (const rule of rules) { + const fallbackName = rule.domain?.trim() || rule.upstream?.trim() || `规则 #${rule.id}` + const name = rule.name?.trim() || fallbackName + const hosts: ProxyHost[] = rule.hosts?.length + ? rule.hosts + : rule.domain + ? [{ id: 0, hostname: rule.domain }] + : [] + for (const host of hosts) { + const hostname = host.hostname?.trim() + if (!hostname) continue + const port = host.listen_port ?? rule.listen_port + if (port <= 0) continue + index.set(bindingKey(hostname, port), { + name, + upstream: rule.upstream, + host: hostname.toLowerCase(), + port, + }) + } + } + return index +} + +function formatUpstreamShort(upstream: string): string { + return upstream.replace(/^https?:\/\//i, '') +} + +/** Raw host:port / domain / IP shown when no proxy rule matches. */ +export function formatAccessFallback(entry: AccessLogEntry): string { + const rawDomain = entry.domain?.trim() + const host = normalizeAccessHost(entry.domain) + const port = entry.server_port ?? 0 + + if (rawDomain?.includes(':') && (rawDomain.includes(']') || rawDomain.lastIndexOf(':') > rawDomain.lastIndexOf(']'))) { + return rawDomain + } + if (rawDomain?.includes(':') && !rawDomain.includes(']')) { + const idx = rawDomain.lastIndexOf(':') + const maybePort = Number.parseInt(rawDomain.slice(idx + 1), 10) + if (idx > 0 && maybePort > 0 && maybePort <= 65535) { + return rawDomain + } + } + + if (host && port > 0) return `${host}:${port}` + if (rawDomain) return rawDomain + if (host) return host + return entry.client_ip?.trim() || '—' +} + +export function resolveAccessServiceLabel( + entry: AccessLogEntry, + index: Map, +): string { + const host = normalizeAccessHost(entry.domain) + const port = entry.server_port ?? 0 + + // 域名 + 端口(同域多端口) + if (host && port > 0) { + const hit = index.get(bindingKey(host, port)) + if (hit) return hit.name + } + + // 仅域名(单规则或同域单端口) + if (host) { + const matches = [...index.values()].filter((binding) => binding.host === host) + if (matches.length === 1) return matches[0].name + if (matches.length > 1 && port > 0) { + const byPort = matches.find((binding) => binding.port === port) + if (byPort) return byPort.name + } + } + + // 仅 IP / 域名直连、无 Host 匹配时走 upstream + const upstream = entry.upstream?.trim() + if (upstream && upstream !== '-') { + return formatUpstreamShort(upstream) + } + + return formatAccessFallback(entry) +} + +export function accessServiceTooltip(entry: AccessLogEntry, index: Map): string { + const host = normalizeAccessHost(entry.domain) + const port = entry.server_port ?? 0 + const label = resolveAccessServiceLabel(entry, index) + const parts: string[] = [label] + + const requestHost = formatAccessFallback(entry) + if (requestHost !== label) { + parts.push(`请求 ${requestHost}`) + } else if (host) { + parts.push(port > 0 ? `${host}:${port}` : host) + } + + const binding = host && port > 0 ? index.get(bindingKey(host, port)) : undefined + if (binding?.upstream) { + parts.push(`→ ${binding.upstream}`) + } else if (entry.upstream && entry.upstream !== '-') { + parts.push(`→ ${entry.upstream}`) + } + if (entry.client_ip) parts.push(`来自 ${entry.client_ip}`) + if (entry.path) parts.push(entry.path) + return parts.join(' · ') +} diff --git a/web/src/utils/format.ts b/web/src/utils/format.ts new file mode 100644 index 0000000..f61e73f --- /dev/null +++ b/web/src/utils/format.ts @@ -0,0 +1,103 @@ +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B' + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + let value = bytes + let unit = 0 + while (value >= 1024 && unit < units.length - 1) { + value /= 1024 + unit++ + } + const digits = value >= 100 || unit === 0 ? 0 : value >= 10 ? 1 : 2 + return `${value.toFixed(digits)} ${units[unit]}` +} + +export function formatRate(bytesPerSec: number): string { + if (!Number.isFinite(bytesPerSec) || bytesPerSec <= 0) return '0 B/s' + return `${formatBytes(bytesPerSec)}/s` +} + +export function formatUptime(seconds: number): string { + const h = Math.floor(seconds / 3600) + const m = Math.floor((seconds % 3600) / 60) + if (h > 0) return `${h} 小时 ${m} 分钟` + return `${m} 分钟` +} + +export function formatRelativeTime(iso?: string): string { + if (!iso) return '-' + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return iso + const diff = Date.now() - date.getTime() + const minutes = Math.floor(diff / 60000) + if (minutes < 1) return '刚刚' + if (minutes < 60) return `${minutes} 分钟前` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours} 小时前` + const days = Math.floor(hours / 24) + return `${days} 天前` +} + +export function formatMs(seconds: number): string { + const ms = Math.round(seconds * 1000) + return `${ms} ms` +} + +export function formatDate(iso?: string): string { + if (!iso) return '-' + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return iso + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) +} + +/** Normalize log timestamps to YYYY-MM-DD HH:mm:ss */ +export function formatLogTime(value?: string): string { + if (!value) return '-' + + const trimmed = value.trim() + + const standard = trimmed.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/) + if (standard) return standard[1] + + const iso = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})/) + if (iso) return `${iso[1]}-${iso[2]}-${iso[3]} ${iso[4]}:${iso[5]}:${iso[6]}` + + const nginx = trimmed.match(/^(\d{4})\/(\d{2})\/(\d{2}) (\d{2}):(\d{2}):(\d{2})/) + if (nginx) return `${nginx[1]}-${nginx[2]}-${nginx[3]} ${nginx[4]}:${nginx[5]}:${nginx[6]}` + + const date = new Date(trimmed) + if (!Number.isNaN(date.getTime())) { + const pad = (n: number) => String(n).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` + } + + return trimmed +} + +/** Normalize leading timestamp in a raw log line */ +export function formatLogLine(line: string): string { + const trimmed = line.trim() + if (!trimmed) return line + + const nginx = trimmed.match(/^(\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2})(.*)$/) + if (nginx) return formatLogTime(nginx[1]) + nginx[2] + + const iso = trimmed.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}|Z)?)(.*)$/) + if (iso) return formatLogTime(iso[1]) + iso[2] + + if (trimmed.startsWith('{')) { + try { + const raw = JSON.parse(trimmed) as { time?: string } + if (raw.time) return trimmed.replace(raw.time, formatLogTime(raw.time)) + } catch { + // not JSON + } + } + + return trimmed +} diff --git a/web/src/utils/logDisplay.ts b/web/src/utils/logDisplay.ts new file mode 100644 index 0000000..d8b695a --- /dev/null +++ b/web/src/utils/logDisplay.ts @@ -0,0 +1,165 @@ +import type { SystemLogEntry } from '../api/types' +import { formatLogTime } from './format' + +export type LogTagType = 'success' | 'info' | 'warning' | 'error' | 'default' + +export interface DisplaySystemLogEntry extends SystemLogEntry { + displayLevel: string + displayModule: string +} + +const SYSTEM_LEVEL_LABELS: Record = { + INFO: '信息', + WARN: '警告', + ERROR: '错误', + DEBUG: '调试', +} + +const SYSTEM_MODULE_LABELS: Record = { + SYSTEM: '系统', + NGINX: 'Nginx', + DDNS: '域名解析', + ACME: '证书', + CERT: '证书', + CHINA_CIDR: '中国 IP 段', + API: 'API', + HTTP: 'HTTP', + FRP: '内网穿透', +} + +export function systemLevelLabel(level: string): string { + const upper = level.trim().toUpperCase() + return SYSTEM_LEVEL_LABELS[upper] ?? level +} + +export function systemModuleLabel(module: string): string { + const trimmed = module.trim() + if (!trimmed) return '系统' + const upper = trimmed.toUpperCase() + return SYSTEM_MODULE_LABELS[upper] ?? trimmed +} + +export function displaySystemLog(entry: SystemLogEntry): DisplaySystemLogEntry { + const message = entry.message.trim() + const msgLower = message.toLowerCase() + let level = entry.level.trim().toUpperCase() + let displayModule = systemModuleLabel(entry.module) + + if ( + level === 'INFO' && + (msgLower.includes('server error') || + msgLower.includes(' failed') || + msgLower.startsWith('error') || + msgLower.includes('bind:')) + ) { + level = 'ERROR' + } + + return { + ...entry, + displayLevel: systemLevelLabel(level), + displayModule, + } +} + +export interface ParsedNginxError { + time: string + level: string + message: string + raw: string +} + +export function parseNginxErrorLine(line: string): ParsedNginxError { + const raw = line.trim() + const match = raw.match(/^(\d{4}[-/]\d{2}[-/]\d{2} \d{2}:\d{2}:\d{2})\s+\[([^\]]+)\]\s+(.+)$/) + if (!match) { + return { time: '', level: 'unknown', message: raw, raw } + } + return { + time: formatLogTime(match[1]), + level: match[2].toLowerCase(), + message: match[3], + raw, + } +} + +export function nginxLevelTagType(level: string): LogTagType { + switch (level.toLowerCase()) { + case 'emerg': + case 'alert': + case 'crit': + case 'error': + return 'error' + case 'warn': + case 'warning': + return 'warning' + case 'notice': + case 'info': + return 'info' + default: + return 'default' + } +} + +export function systemLevelTagType(level: string): LogTagType { + const upper = level.trim().toUpperCase() + if (upper === 'ERROR' || level === '错误') return 'error' + if (upper === 'WARN' || level === '警告') return 'warning' + if (upper === 'INFO' || level === '信息') return 'info' + return 'default' +} + +export function systemModuleTagType(module: string): LogTagType { + const upper = module.trim().toUpperCase() + switch (upper) { + case 'NGINX': + case 'Nginx': + return 'warning' + case 'SYSTEM': + case '系统': + return 'success' + case 'CHINA_CIDR': + case '中国 IP 段': + return 'info' + case 'DDNS': + case '域名解析': + return 'info' + case 'CERT': + case 'ACME': + case '证书': + return 'warning' + default: + return 'default' + } +} + +export function latencyClass(seconds: number): string { + const ms = seconds * 1000 + if (ms >= 3000) return 'log-duration log-duration--critical' + if (ms >= 1000) return 'log-duration log-duration--slow' + return 'log-duration' +} + +export function streamLineClass(line: string): string { + const parsed = parseNginxErrorLine(line) + if (parsed.level !== 'unknown') { + const type = nginxLevelTagType(parsed.level) + if (type === 'error') return 'level-error' + if (type === 'warning') return 'level-warn' + if (type === 'info') return 'level-info' + } + + const lower = line.toLowerCase() + if (lower.includes('[error]') || lower.includes(' emerg ') || lower.includes(' alert ') || lower.includes(' crit ')) { + return 'level-error' + } + if (lower.includes('[warn]') || lower.includes(' warning ')) return 'level-warn' + + const statusMatch = line.match(/\s(\d{3})\s+\d/) + if (statusMatch) { + const code = Number(statusMatch[1]) + if (code >= 500) return 'level-error' + if (code >= 400) return 'level-warn' + } + return '' +} diff --git a/web/src/utils/status.ts b/web/src/utils/status.ts new file mode 100644 index 0000000..6e06148 --- /dev/null +++ b/web/src/utils/status.ts @@ -0,0 +1,42 @@ +export type StatusKind = 'success' | 'warning' | 'error' | 'disabled' | 'unknown' + +const LABELS: Record = { + ok: '正常', + running: '运行中', + warning: '部分异常', + error: '异常', + disabled: '停用', + none: '未配置', + stopped: '已停止', + unknown: '未知', +} + +export function statusLabel(value?: string): string { + if (!value) return '未知' + return LABELS[value] ?? value +} + +export function statusKind(value?: string): StatusKind { + switch (value) { + case 'ok': + case 'running': + return 'success' + case 'warning': + return 'warning' + case 'error': + return 'error' + case 'disabled': + case 'stopped': + case 'none': + return 'disabled' + default: + return 'unknown' + } +} + +export function httpStatusKind(code: number): StatusKind { + if (code >= 500) return 'error' + if (code >= 400) return 'warning' + if (code >= 300) return 'unknown' + return 'success' +} diff --git a/web/src/utils/tableActions.ts b/web/src/utils/tableActions.ts new file mode 100644 index 0000000..636843b --- /dev/null +++ b/web/src/utils/tableActions.ts @@ -0,0 +1,34 @@ +import { h, type VNode } from 'vue' +import { NButton, NSpace } from 'naive-ui' +import type { ButtonProps } from 'naive-ui' + +export interface TableRowAction { + label: string + onClick: () => void + type?: ButtonProps['type'] + /** 为 false 时隐藏该操作,默认显示 */ + show?: boolean +} + +/** 表格操作列:所有按钮平铺展示,不使用下拉菜单 */ +export function renderTableRowActions(actions: TableRowAction[]): VNode { + const buttons = actions + .filter((action) => action.show !== false) + .map((action) => + h( + NButton, + { + size: 'small', + quaternary: true, + type: action.type, + onClick: (e: MouseEvent) => { + e.stopPropagation() + action.onClick() + }, + }, + () => action.label, + ), + ) + + return h(NSpace, { size: 4, wrap: false }, () => buttons) +} diff --git a/web/src/utils/version.ts b/web/src/utils/version.ts new file mode 100644 index 0000000..a83cce6 --- /dev/null +++ b/web/src/utils/version.ts @@ -0,0 +1,56 @@ +import { GITHUB_REPO } from '../constants/app' + +export interface LatestRelease { + version: string + url: string +} + +function parseVersion(version: string): number[] { + const clean = version.replace(/^v/i, '').split('-')[0] + return clean.split('.').map((part) => Number.parseInt(part, 10) || 0) +} + +export function compareVersions(a: string, b: string): number { + const left = parseVersion(a) + const right = parseVersion(b) + const length = Math.max(left.length, right.length) + + for (let i = 0; i < length; i += 1) { + const diff = (left[i] ?? 0) - (right[i] ?? 0) + if (diff !== 0) { + return diff > 0 ? 1 : -1 + } + } + + return 0 +} + +export function isNewerVersion(latest: string, current: string): boolean { + if (!latest || !current || current === 'dev') { + return false + } + return compareVersions(latest, current) > 0 +} + +export async function fetchLatestRelease(): Promise { + try { + const response = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, { + headers: { Accept: 'application/vnd.github+json' }, + }) + if (!response.ok) { + return null + } + + const data = (await response.json()) as { tag_name?: string; html_url?: string } + if (!data.tag_name || !data.html_url) { + return null + } + + return { + version: data.tag_name.replace(/^v/i, ''), + url: data.html_url, + } + } catch { + return null + } +} diff --git a/web/src/views/AgentView.vue b/web/src/views/AgentView.vue new file mode 100644 index 0000000..4b83f81 --- /dev/null +++ b/web/src/views/AgentView.vue @@ -0,0 +1,368 @@ + + + + + diff --git a/web/src/views/CertificatesView.vue b/web/src/views/CertificatesView.vue new file mode 100644 index 0000000..0d94813 --- /dev/null +++ b/web/src/views/CertificatesView.vue @@ -0,0 +1,1875 @@ + + + + + diff --git a/web/src/views/DashboardView.vue b/web/src/views/DashboardView.vue new file mode 100644 index 0000000..71cc28c --- /dev/null +++ b/web/src/views/DashboardView.vue @@ -0,0 +1,1214 @@ + + + + + diff --git a/web/src/views/DdnsView.vue b/web/src/views/DdnsView.vue new file mode 100644 index 0000000..9637514 --- /dev/null +++ b/web/src/views/DdnsView.vue @@ -0,0 +1,1746 @@ + + + + + diff --git a/web/src/views/FrpView-1.0.2.vue b/web/src/views/FrpView-1.0.2.vue new file mode 100644 index 0000000..12bddd1 --- /dev/null +++ b/web/src/views/FrpView-1.0.2.vue @@ -0,0 +1,1099 @@ + + + + + diff --git a/web/src/views/FrpView.vue b/web/src/views/FrpView.vue new file mode 100644 index 0000000..f255d10 --- /dev/null +++ b/web/src/views/FrpView.vue @@ -0,0 +1,2095 @@ + + + + + diff --git a/web/src/views/LoginView.vue b/web/src/views/LoginView.vue new file mode 100644 index 0000000..a509f42 --- /dev/null +++ b/web/src/views/LoginView.vue @@ -0,0 +1,217 @@ + + + + + + + + + + + diff --git a/web/src/views/LogsView.vue b/web/src/views/LogsView.vue new file mode 100644 index 0000000..0d2fb97 --- /dev/null +++ b/web/src/views/LogsView.vue @@ -0,0 +1,637 @@ + + + + + diff --git a/web/src/views/ProxyRemoteView.vue b/web/src/views/ProxyRemoteView.vue new file mode 100644 index 0000000..5815216 --- /dev/null +++ b/web/src/views/ProxyRemoteView.vue @@ -0,0 +1,583 @@ + + + + + diff --git a/web/src/views/ProxyView.vue b/web/src/views/ProxyView.vue new file mode 100644 index 0000000..5f08e07 --- /dev/null +++ b/web/src/views/ProxyView.vue @@ -0,0 +1,3484 @@ + + + + + diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue new file mode 100644 index 0000000..58929cf --- /dev/null +++ b/web/src/views/SettingsView.vue @@ -0,0 +1,1672 @@ + + + + + diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..af40b2d --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..6ec660b --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,33 @@ +import { readFileSync } from 'node:fs' +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8')) as { version: string } + +export default defineConfig({ + plugins: [vue()], + define: { + __APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || pkg.version), + }, + server: { + port: 5173, + allowedHosts: true, + proxy: { + '/api': { + target: 'http://localhost:6893', + configure(proxy) { + proxy.on('proxyRes', (proxyRes, req) => { + if (req.url?.includes('/stream')) { + proxyRes.headers['cache-control'] = 'no-cache' + proxyRes.headers['x-accel-buffering'] = 'no' + } + }) + }, + }, + }, + }, + build: { + outDir: 'dist', + emptyOutDir: true, + }, +})