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
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -0,0 +1,10 @@
|
||||
bin/
|
||||
.data/
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
cmd/fonu/web/dist/
|
||||
*.db
|
||||
/data/
|
||||
.DS_Store
|
||||
.cursor/
|
||||
scripts/
|
||||
@@ -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` — 证书多域名
|
||||
@@ -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"]
|
||||
@@ -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>.
|
||||
@@ -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) 开源。
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -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 # 删除镜像
|
||||
```
|
||||
@@ -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 侧终止。
|
||||
@@ -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`、验证逻辑复用部署链路的探测段。
|
||||
@@ -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 层面互补)。
|
||||
@@ -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 下发配置保持一致。
|
||||
@@ -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 上没有任何管理界面。
|
||||
@@ -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 与文件系统)。
|
||||
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 201 KiB |
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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": "测试通知已发送"})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||