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
This commit is contained in:
2026-09-23 09:40:59 +08:00
commit 278f8267b7
333 changed files with 53562 additions and 0 deletions
+13
View File
@@ -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
+105
View File
@@ -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"
+51
View File
@@ -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
+10
View File
@@ -0,0 +1,10 @@
bin/
.data/
web/node_modules/
web/dist/
cmd/fonu/web/dist/
*.db
/data/
.DS_Store
.cursor/
scripts/
+416
View File
@@ -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` — 证书多域名
+64
View File
@@ -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"]
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
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.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
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 <https://www.gnu.org/licenses/>.
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:
<program> Copyright (C) <year> <name of author>
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
<https://www.gnu.org/licenses/>.
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
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+258
View File
@@ -0,0 +1,258 @@
<p align="center">
<img src="web/src/assets/brand/logo.png" alt="Fonu Logo" width="96" />
</p>
<h1 align="center">Fonu</h1>
<p align="center"><strong>让 NAS 访问更简单</strong></p>
<p align="center">
面向 <strong>飞牛 fnOS</strong> 的轻量公网访问管理工具<br />
反向代理 · DDNS · HTTPS 证书 · 内网穿透 · 一站式 Web 管理
</p>
<p align="center">
<img src="https://img.shields.io/badge/目标平台-飞牛_fnOS-22c55e?style=flat-square" alt="飞牛 fnOS" />
<img src="https://img.shields.io/badge/其他环境-未测试-94a3b8?style=flat-square" alt="其他环境未测试" />
<img src="https://img.shields.io/badge/Go-1.23-00ADD8?style=flat-square&logo=go&logoColor=white" alt="Go" />
<img src="https://img.shields.io/badge/Vue-3-4FC08D?style=flat-square&logo=vuedotjs&logoColor=white" alt="Vue 3" />
<img src="https://img.shields.io/badge/Nginx-内置-009639?style=flat-square&logo=nginx&logoColor=white" alt="Nginx" />
<a href="https://www.gnu.org/licenses/gpl-3.0.html"><img src="https://img.shields.io/badge/license-GPL--3.0-blue?style=flat-square" alt="GPL-3.0" /></a>
</p>
---
## 简介
**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 更新提示 |
## 界面展示
<table>
<tr>
<td align="center" width="50%">
<b>仪表盘</b><br>
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="仪表盘" width="100%" /></a>
</td>
<td align="center" width="50%">
<b>DDNS</b><br>
<a href="docs/screenshots/ddns.png"><img src="docs/screenshots/ddns.png" alt="DDNS" width="100%" /></a>
</td>
</tr>
<tr>
<td align="center">
<b>HTTPS 证书</b><br>
<a href="docs/screenshots/certificates.png"><img src="docs/screenshots/certificates.png" alt="HTTPS 证书" width="100%" /></a>
</td>
<td align="center">
<b>HTTPS 证书 · 申请向导</b><br>
<a href="docs/screenshots/certificates-apply.png"><img src="docs/screenshots/certificates-apply.png" alt="证书申请" width="100%" /></a>
</td>
</tr>
<tr>
<td align="center">
<b>反向代理</b><br>
<a href="docs/screenshots/proxy.png"><img src="docs/screenshots/proxy.png" alt="反向代理" width="100%" /></a>
</td>
<td align="center">
<b>反向代理 · 规则详情</b><br>
<a href="docs/screenshots/proxy-detail.png"><img src="docs/screenshots/proxy-detail.png" alt="反向代理规则详情" width="100%" /></a>
</td>
</tr>
<tr>
<td align="center">
<b>反向代理 · 规则配置</b><br>
<a href="docs/screenshots/proxy-rule-basic.png"><img src="docs/screenshots/proxy-rule-basic.png" alt="反向代理规则配置" width="100%" /></a>
</td>
<td align="center">
<b>反向代理 · 安全设置</b><br>
<a href="docs/screenshots/proxy-rule-security.png"><img src="docs/screenshots/proxy-rule-security.png" alt="反向代理安全设置" width="100%" /></a>
</td>
</tr>
<tr>
<td align="center">
<b>反向代理 · Nginx 配置</b><br>
<a href="docs/screenshots/proxy-rule-nginx.png"><img src="docs/screenshots/proxy-rule-nginx.png" alt="反向代理 Nginx 配置" width="100%" /></a>
</td>
<td align="center">
<b>内网穿透</b><br>
<a href="docs/screenshots/frp.png"><img src="docs/screenshots/frp.png" alt="内网穿透" width="100%" /></a>
</td>
</tr>
<tr>
<td align="center">
<b>系统设置</b><br>
<a href="docs/screenshots/settings.png"><img src="docs/screenshots/settings.png" alt="系统设置" width="100%" /></a>
</td>
<td align="center">
<b>系统设置 · 高级</b><br>
<a href="docs/screenshots/settings-advanced.png"><img src="docs/screenshots/settings-advanced.png" alt="系统设置高级" width="100%" /></a>
</td>
</tr>
<tr>
<td align="center" colspan="2">
<b>日志中心</b><br>
<a href="docs/screenshots/logs.png"><img src="docs/screenshots/logs.png" alt="日志中心" width="100%" /></a>
</td>
</tr>
</table>
## 技术栈
| 层级 | 技术 |
| ------------ | ----------------------------------------------------- |
| **后端** | 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://<NAS-IP>:6893` |
| HTTP 反代(默认) | `http://<NAS-IP>:18080` |
| HTTPS 反代(默认) | `https://<NAS-IP>:9443` |
- `./data` 挂载后自动作为数据目录,存放数据库、Nginx 配置、证书与日志
- 飞牛系统已占用 80/443,容器默认反代端口为 `18080`、`9443`,可通过环境变量修改
### 其他部署方式
<details>
<summary>Docker Compose</summary>
```bash
FONU_SESSION_SECRET=your-secret docker compose -f docker-compose.host.yml up -d
```
</details>
<details>
<summary>Bridge 网络(端口映射,非飞牛环境可自行尝试)</summary>
```bash
FONU_SESSION_SECRET=your-secret docker compose -f docker-compose.hub.yml up -d --build
```
</details>
<details>
<summary>本地构建</summary>
```bash
docker compose up -d --build
```
</details>
### 首次登录
1. 启动后查看容器/运行日志,获取自动生成的 **admin 初始密码**
2. 访问 `http://<NAS-IP>: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) 开源。
+1
View File
@@ -0,0 +1 @@
1.1.0
+51
View File
@@ -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
}
+54
View File
@@ -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)
}
}
+15
View File
@@ -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;
}
+25
View File
@@ -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:
+27
View File
@@ -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:
+26
View File
@@ -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:
+305
View File
@@ -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://<NAS-IP>:6893`,HTTP 反代 `http://<NAS-IP>:18080`,HTTPS 反代 `https://<NAS-IP>: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://<NAS-IP>: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://<NAS-IP>: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 # 删除镜像
```
+170
View File
@@ -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://<NAS-IP>: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 侧终止。
+163
View File
@@ -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:<vhost端口>
```
- 页面显示「部署成功 · 回源自检 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`、验证逻辑复用部署链路的探测段。
+209
View File
@@ -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:<frps vhost 端口>`)/ `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:<vhost端口>`,不涉及上游 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 </dev/null | head -3 # 仅 TLS1.3 时应握手失败
curl -sI -H 'Host: 7023.36800.cc' -d @bigfile http://127.0.0.1:9080 # 上传大小限制
for i in $(seq 1 50); do curl -s -o /dev/null -w '%{http_code} ' http://7023.36800.cc/; done # 限流 → 429(第二梯队)
```
排查口径沿用现有约定:健康检查五段徽标(DNS / 证书 / 隧道 / 服务 / 反代生效态)任一 ✗,
悬停看详情定位断点,不再靠猜。
---
## 六、兼容性与回滚
| 场景 | 期望行为 |
| --- | --- |
| 新 Fonu + 旧 agent | 旧 agent 忽略未知 JSON 字段(Go 默认行为),新选项静默不生效 → **部署前必须先「升级 Agent」**,页面提示 |
| 旧 Fonu + 新 agent | 新字段缺省,行为与现状一致(`https_disabled` 缺省 = 启用 TLS) |
| frps v0.68.0 / v0.71.0 | 与本方案无关(作用于 Nginx vhost,不依赖 dashboard API) |
| 回滚 | 关闭对应开关后重新部署即可恢复;agent 侧每域名配置为整文件覆盖写,无残留 |
---
## 七、附:与既有文档的关系
- `docs/agent-proxy-roadmap.md`:功能梯队总览(本方案是其「第二批」在部署设置维度的细化);
- `docs/frps-features.md`:frps 服务端配置能力对照(frps.toml 层面,与本文的 Nginx 层面互补)。
+121
View File
@@ -0,0 +1,121 @@
# frps(服务端)功能实现清单
> 基于 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:<port>`,三方由 `agentUpstreamFor`/生成器共用同一值;
- **agent 与 frps 解耦**:agent 只管 Nginx 片段/证书/frps 生命周期,frps 进程由 systemd 托管;二者通过 Fonu 下发配置保持一致。
+199
View File
@@ -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/<domain>/`(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 上没有任何管理界面。
+156
View File
@@ -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 视图(`<pre>` 左右对照期望/现状)、操作按钮(下发配置 / 释放端口 / 推送证书);
- 规则保存时的自动下发提示:非阻塞 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 与文件系统)。
Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

BIN
View File
Binary file not shown.
+51
View File
@@ -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
)
+908
View File
@@ -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=
+89
View File
@@ -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
}
+37
View File
@@ -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)
}
}
+98
View File
@@ -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
}
+23
View File
@@ -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")
}
}
+166
View File
@@ -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)
}
+43
View File
@@ -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)
}
}
+117
View File
@@ -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
}
+26
View File
@@ -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")
}
}
+183
View File
@@ -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
}
+621
View File
@@ -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
}
+37
View File
@@ -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)
}
+62
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
+131
View File
@@ -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,
})
}
+96
View File
@@ -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)
}
}
+73
View File
@@ -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"})
}
+151
View File
@@ -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)
}
+29
View File
@@ -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))
}
+157
View File
@@ -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)
}
+37
View File
@@ -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
}
+20
View File
@@ -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))
}
+703
View File
@@ -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,
}
}
+128
View File
@@ -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
}
+65
View File
@@ -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)
}
}
+43
View File
@@ -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
}
+136
View File
@@ -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)
}
+33
View File
@@ -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": "测试通知已发送"})
}
+260
View File
@@ -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
}
+60
View File
@@ -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
}
+64
View File
@@ -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...)
}
}
+64
View File
@@ -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())
}
}
+227
View File
@@ -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(),
)
}
})
}
+204
View File
@@ -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
}
+183
View File
@@ -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
}
+18
View File
@@ -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)
}
}
+24
View File
@@ -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,
})
}
}
+36
View File
@@ -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"])
}
}
+229
View File
@@ -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")
}
+204
View File
@@ -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
}
+41
View File
@@ -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)
}
}
+170
View File
@@ -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
}
+214
View File
@@ -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
}
+118
View File
@@ -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
}
+168
View File
@@ -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()
}
+29
View File
@@ -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")
}
}
+332
View File
@@ -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
}
+161
View File
@@ -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
}
+35
View File
@@ -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)
}
}
+113
View File
@@ -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
}
+24
View File
@@ -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)
}
}
+244
View File
@@ -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"`
}
+164
View File
@@ -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"`
}
+96
View File
@@ -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
}
+29
View File
@@ -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")
}
}
+105
View File
@@ -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
}
+25
View File
@@ -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
}
+63
View File
@@ -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
}
+20
View File
@@ -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")
}
}
+11
View File
@@ -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)
}
+319
View File
@@ -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
}
+131
View File
@@ -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)
}
}
+709
View File
@@ -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
}

Some files were not shown because too many files have changed in this diff Show More