Compare commits

..

120 Commits

Author SHA1 Message Date
ryan fa9ecb5690 [优化] 界面优化 2026-05-30 16:25:17 +08:00
ryan b9cde88bf6 [优化] 重构 WAF 和 PoW 处理逻辑,使用 require 加载运行时模块,更新相关测试以验证新行为 2026-05-30 16:19:55 +08:00
ryan f03718ce8c [优化] 更新 Docker 部署指令,添加镜像拉取和容器移除命令 2026-05-30 16:08:43 +08:00
ryan 3423175006 [优化] 重构升级处理逻辑,添加备份二进制文件移除功能,更新相关测试以验证新行为 2026-05-30 16:05:48 +08:00
ryan d619deec96 [优化] 添加 WAF 阻止逻辑以短路 PoW 处理,更新测试以验证新行为 2026-05-30 15:46:34 +08:00
ryan e094f4a3b7 [优化] 移除不必要的支持文件过滤函数,更新相关测试以验证 WAF 配置包含 2026-05-30 15:39:18 +08:00
ryan 1bff2dadd4 [优化] 合并 WAF 和 PoW 访问处理逻辑,更新相关函数以支持新的配置格式 2026-05-30 15:12:49 +08:00
ryan 602e7f5e9c [优化] 修复 --version 错误 2026-05-30 13:24:23 +08:00
ryan 9ec3d5b42d [优化] 格式化 2026-05-30 13:15:20 +08:00
ryan 28b1305906 [优化] WAF 界面优化 2026-05-30 13:12:57 +08:00
ryan a80376972c [优化] 使用 slog 替代 fmt 进行日志输出 2026-05-30 12:27:00 +08:00
ryan 8300d3ec1c [新增] 添加 WAF 规则组及其绑定的 API 支持,更新前端页面以集成 WAF 功能 2026-05-30 12:16:28 +08:00
ryan 290ddd7b51 [优化] 添加获取折叠访问日志 IP 概要的 API 和前端支持 2026-05-30 10:48:06 +08:00
ryan 5d7a4469ea [优化] 增加 noop apply 报告逻辑,确保在配置未变更时记录应用日志 2026-05-30 10:27:10 +08:00
ryan 4e339caa9a [优化] 增加对节点 IP 的自动探测,优先通过第三方 API 获取公网 IP 2026-05-30 10:19:47 +08:00
ryan 2a00d21987 [文档] Doc 2026-05-30 09:51:36 +08:00
ryan f086edda3b [文档] Doc 2026-05-29 12:00:09 +08:00
ryan 899b4e6068 [优化] 优化 Docker 部署命令,移除不必要的端口映射 2026-05-29 11:46:09 +08:00
ryan fa23cad9e9 [#12] Auto-update downloads and executes binary with no signature or checksum verification 2026-05-29 11:29:33 +08:00
ryan 806863f303 [修复] 修复 WS 连接下更新无法下发 2026-05-29 11:08:05 +08:00
ryan ab8e3d4705 [优化] 更新 openresty_observability_port 描述,增强健康检查逻辑,使用 stub_status 代替 openresty -t 2026-05-29 10:52:25 +08:00
ryan fe7f7da537 [优化] 更新 openresty_observability_port 描述,增强健康检查逻辑,使用 stub_status 代替 openresty -t 2026-05-29 10:50:13 +08:00
ryan 944b98d4d0 [新增] 实现节点强制同步功能,允许通过 API 请求强制同步配置 2026-05-29 10:44:14 +08:00
ryan 32dc7ef68e [新增] 实现节点强制同步功能,允许通过 API 请求强制同步配置 2026-05-29 10:34:25 +08:00
ryan 32762fdf3c [新增] 实现安全兜底配置功能,允许在无历史配置时启动 OpenResty 并返回 503 状态 2026-05-29 10:07:49 +08:00
ryan 79ed8fd6ab [新增] 实现 Agent WebSocket 连接升级功能,支持状态上报和配置广播 2026-05-29 09:52:34 +08:00
ryan 4257b6fd5a [优化] 移除 Docker 运行命令中的数据卷挂载 2026-05-29 09:39:49 +08:00
ryan 8dfe31c1c5 [新增] 补充旧版本 agent 卸载脚本 2026-05-29 09:13:31 +08:00
ryan 37486eb0c9 [优化] 增强 OSCommandRunner 的命令执行逻辑,添加临时文件处理和详细日志记录 2026-05-28 23:50:07 +08:00
ryan 462deb4820 [新增] 添加 Docker 安装命令构建逻辑并更新节点详情页面 2026-05-28 23:41:18 +08:00
ryan f8509eed26 [优化] 优化对主配置路径的存在性检查以增强健康检查逻辑 2026-05-28 23:29:01 +08:00
ryan 6e0b6df314 [新增] 添加 MIME 类型支持和更新 Docker Compose 配置 2026-05-28 23:20:53 +08:00
ryan 21962db3bf [新增] 添加 Docker Compose 2026-05-28 23:15:04 +08:00
ryan b0117b7c84 [新增] 同步更新英文版文档 2026-05-28 23:00:48 +08:00
ryan 95d58eb724 [新增] Agent 架构调整, 采用集成镜像方式 2026-05-28 22:59:50 +08:00
ryan c856faca50 [新增] 更新文档 2026-05-28 22:56:39 +08:00
ryan 5a0821274b [新增] 更新文档 2026-05-28 22:50:24 +08:00
ryan b69bdf838d [新增] 优化版本号生成逻辑,确保使用最大日序列号 2026-05-26 21:29:03 +08:00
ryan c35eb749c9 [新增] 添加转换上传的 TLS 证书为 ACME 管理证书的功能 2026-05-26 21:16:03 +08:00
ryan e3c84c017a [新增] 添加转换上传的 TLS 证书为 ACME 管理证书的功能 2026-05-26 21:06:50 +08:00
ryan 112694f860 [新增] 添加删除预发布标签清理工作流 2026-05-26 11:18:20 +08:00
ryan bd69ac51b5 [新增] 添加预览预发布标签清理工作流 2026-05-26 11:15:48 +08:00
ryan 46fb1a2b79 Revert "[优化] 添加基本鉴权支持,更新相关逻辑以生成 htpasswd 文件"
This reverts commit c9a532db65.
2026-05-26 10:57:21 +08:00
ryan c9a532db65 [优化] 添加基本鉴权支持,更新相关逻辑以生成 htpasswd 文件 2026-05-26 10:41:41 +08:00
ryan be68b581e9 [优化] 添加基础鉴权支持,包括用户名和密码字段,并更新相关逻辑和测试用例 2026-05-26 10:36:00 +08:00
ryan 8853933adc [优化] 修复基本鉴权逻辑,确保 Lua 块正确关闭并添加相关测试用例 2026-05-26 10:29:40 +08:00
ryan 048f6e4535 [优化] 调整 Nginx 配置生成逻辑,优化访问控制和代理位置块的渲染顺序 2026-05-26 10:17:37 +08:00
ryan 7b9c8996f9 [优化] 更新数据库模式版本至12,添加基础鉴权字段支持 2026-05-26 09:57:56 +08:00
ryan 8947bdc8d8 [优化] 更新 PublishConfigVersion 函数以支持强制发布选项,并调整相关调用 2026-05-26 09:56:18 +08:00
ryan baef42f920 [优化] 添加基础鉴权配置支持,包括用户名和密码 2026-05-26 09:37:42 +08:00
ryan dd58e0df66 [优化] 添加 OpenRestyResolvers 配置支持自定义 DNS 解析器 2026-05-26 09:18:52 +08:00
ryan bddf641bf1 [优化] 添加 OpenRestyResolvers 配置支持自定义 DNS 解析器 2026-05-26 09:10:08 +08:00
ryan 83a426d3d6 [优化] 添加清理历史快照功能 2026-05-25 16:41:07 +08:00
ryan 4f698be0a5 [优化] 更新 CORS 配置以支持动态源和凭证 2026-05-25 16:29:07 +08:00
ryan e9fb331214 [fix] 修复构建 2026-05-25 16:22:51 +08:00
ryan 5d6d68d0a1 [优化] 更新 Go 版本要求至 1.25+ 2026-05-25 16:18:07 +08:00
ryan c8e2c3620e [优化] 结构优化 2026-05-25 16:12:22 +08:00
ryan af8e9b477e [优化] 导航调整 2026-05-25 16:05:56 +08:00
ryan 314f6fd3f4 [优化] 移除注册相关功能的代码和配置 2026-05-25 16:03:38 +08:00
ryan 7eee788720 [优化] UI improve 2026-05-25 15:47:56 +08:00
ryan f6e4967a9a [新增] 添加 ACME 和 DNS 账号管理功能,支持证书申请与续期 2026-05-25 14:56:05 +08:00
ryan 7afe4e5d78 [新增] 添加 ACME 和 DNS 账号管理功能,支持证书申请与续期 2026-05-25 14:53:22 +08:00
Ryan c6a055d5d3 Update README.md 2026-05-13 14:00:53 +08:00
ryan 9a89428405 [修复] 个人设置查看第三方认证源与增加解绑功能 2026-05-13 12:09:15 +08:00
ryan 370d58ac4d OIDC 文档 2026-05-13 11:46:33 +08:00
ryan e85df49962 OIDC 2026-05-13 11:44:01 +08:00
ryan 856e3f46d2 gitignore 2026-05-13 10:21:18 +08:00
ryan 2d6cc908f5 优化文档 2026-05-09 18:10:19 +08:00
ryan 797a15ae70 vite-press init 2026-05-09 17:37:06 +08:00
ryan 8730f99fef UPDATE README 2026-04-26 10:25:44 +08:00
ryan 8ad4defcc7 [功能] POW 有效期优化 2026-04-25 20:49:08 +08:00
ryan d3d32a6b6b [fix] anubis 2026-04-25 19:48:30 +08:00
ryan 9c57ec2f5c [功能] POW 集成 2026-04-19 23:00:57 +08:00
ryan f8c1fe804d [修复] 修复github登录问题 2026-04-01 10:44:34 +08:00
ryan 89489c8488 [功能] 添加卸载脚本以支持彻底卸载 OpenFlare Agent 并清空本地数据 2026-04-01 10:24:05 +08:00
ryan d425e34f71 [优化] 更新默认服务器块,添加 HTTPS 支持并启用 SSL 握手拒绝 2026-04-01 10:02:40 +08:00
ryan 49472b54bf [功能] 添加域名证书绑定支持,允许为每个域名单独选择证书并优化相关逻辑 2026-04-01 09:57:40 +08:00
ryan a002d98f3a [优化] 更新域名列表输入组件,优化按钮样式并支持自定义容器类型 2026-04-01 09:34:17 +08:00
ryan 77457250cf [功能] 更新域名列表输入组件,支持为每个域名选择证书并优化相关逻辑 2026-04-01 09:27:41 +08:00
ryan cff815bd47 [功能] 支持为 HTTPS 启用多个证书,更新相关逻辑和测试 2026-03-31 14:16:32 +08:00
ryan 97fa56b1af [功能] 添加域名列表输入组件,支持动态建议和批量输入 2026-03-31 13:29:30 +08:00
ryan 355791f2e4 [优化] 文本优化 2026-03-31 13:16:00 +08:00
ryan 65ecc27907 [优化] 文本优化 2026-03-31 13:13:38 +08:00
ryan c2184affed [功能] 添加批量更新选项接口,支持一次性更新多个配置项,更新相关逻辑和测试 2026-03-30 16:48:10 +08:00
ryan 7d9190a8d8 [功能] 禁用新用户注册功能,更新相关逻辑和测试 2026-03-30 16:01:25 +08:00
ryan 4b1e75f86b [修改] 文本优化 2026-03-30 15:46:54 +08:00
ryan 25fe178cb2 [功能] 添加网站创建抽屉组件,支持域名和上游地址输入,更新相关逻辑和测试 2026-03-30 15:07:59 +08:00
ryan 383a039338 [功能] 接口与校验改造 2026-03-30 14:45:28 +08:00
ryan e39a8995f6 [功能] 添加站点名称和多域名支持到代理路由,更新相关逻辑和测试 2026-03-30 14:11:30 +08:00
ryan 894745d43a [功能] 优化节点 IP 解析逻辑,优先使用公网地址并添加相关测试 2026-03-30 13:09:30 +08:00
ryan 39d54c2fe4 [文档] 升级代理路由规则为网站配置,支持多域名绑定与共享设置 2026-03-30 11:13:57 +08:00
ryan fdadd76945 [功能] 添加抽屉组件并重构代理路由页面,优化规则创建体验 2026-03-30 10:32:49 +08:00
ryan 6e109fd3f7 [功能] 移除前端开发规范中的禁止项和测试交付要求,简化文档内容 2026-03-27 13:55:15 +08:00
ryan f14ba66a11 [功能] 更新组件库hero3.0.1 2026-03-27 13:34:35 +08:00
ryan 6b1d2e8af9 [功能] 移除代理路由页面中的缓存和请求头列,简化显示内容 2026-03-27 11:18:37 +08:00
ryan a0fff76fcb [?] update 2026-03-24 19:02:00 +08:00
ryan 4fa8f073a3 [功能] 重构代理路由页面,优化输入组件和样式 2026-03-20 23:29:18 +08:00
ryan a6787ac30d [功能] 添加新的输入、文本区域、标签和开关组件,优化样式和功能 2026-03-20 23:15:35 +08:00
ryan 2c87254bb3 [功能] 更新代理路由页面,集成新的输入和选择组件,优化域名选择逻辑 2026-03-20 22:52:57 +08:00
ryan 1fd4b22b9c [功能] 重构代理路由页面的单元测试,优化fetch模拟和输入验证逻辑 2026-03-20 22:37:02 +08:00
ryan be9744abc6 [功能] 重构代理路由页面的单元测试,优化fetch模拟和输入验证逻辑 2026-03-20 22:20:13 +08:00
ryan afd891f0f6 [功能] 添加源站管理功能,包括源站的创建、更新、删除及列表展示 2026-03-20 20:01:42 +08:00
ryan edd31da527 [功能] 添加代理路由页面的单元测试,支持通配符和精确域名的规则生成 2026-03-20 19:42:29 +08:00
ryan 7b9377eb21 [文档] 文档更新 2026-03-19 21:17:23 +08:00
ryan dc72c78b7f [优化] 界面优化 2026-03-19 21:00:43 +08:00
ryan 9eeccb5fc6 [功能] 添加数据库观测数据清理功能,支持手动和自动清理策略 2026-03-19 20:48:45 +08:00
ryan a1b3204204 [功能] 添加遗留观察性索引和表的删除逻辑,优化数据库迁移过程 2026-03-19 20:26:18 +08:00
ryan 8737e146d1 [修改] 分片逻辑修改为基于ID 2026-03-19 17:57:30 +08:00
ryan ae72f2da9a [功能] 实现数据库版本管理与迁移逻辑,确保数据库结构与版本一致性 2026-03-19 16:45:22 +08:00
ryan f26fcd028e [功能] 添加迁移遗留观察性列的功能,支持从 raw_json 填充 metadata_json 2026-03-19 16:31:05 +08:00
ryan dd49b2777d [功能] 实现节点访问日志的分片支持,优化日志查询和管理逻辑 2026-03-19 16:19:46 +08:00
ryan 891cb7b9c1 [优化] 更新 swaggo/swag 依赖版本至 v1.16.4,并更新文档生成指令 2026-03-19 09:28:57 +08:00
ryan 007b1d8929 [优化] 移除 OpenRestyResolvers 配置,统一上游渲染为带 keepalive 的 named upstream 2026-03-18 23:24:37 +08:00
ryan 782304012c [功能] 添加节点健康事件清理功能,优化节点观测数据管理 2026-03-18 23:11:48 +08:00
ryan 4945b8b44f [修复] 更新数据库字段类型为text,添加消息截断逻辑以支持更长的消息内容 2026-03-18 22:57:14 +08:00
ryan 1fbe156a7c [功能] 添加支持多个上游地址,优化代理路由配置和负载均衡逻辑 2026-03-18 22:24:05 +08:00
ryan c844f4c784 [优化] 更新HTTPS配置,启用reuseport和epoll事件模型,优化性能 2026-03-18 22:15:48 +08:00
ryan 67197220ae [优化] 添加命名上游支持,优化代理配置生成逻辑 2026-03-18 22:15:48 +08:00
ryan 0cb4e06b11 [功能] 添加缓存策略支持,优化代理路由配置和验证逻辑 2026-03-18 22:08:55 +08:00
ryan c84d5bd540 [功能] 更新OpenResty配置,添加连接升级映射和默认服务器块,优化HTTPS和HTTP重定向逻辑 2026-03-18 22:02:32 +08:00
359 changed files with 74941 additions and 11565 deletions
+11
View File
@@ -0,0 +1,11 @@
.git
.idea
anubis-source
**/node_modules
**/.next
**/build
**/dist
**/.cache
**/coverage
**/*.db
**/*.log
+1 -5
View File
@@ -1,5 +1 @@
blank_issues_enabled: false
contact_links:
- name: 赞赏支持
url: https://iamazing.cn/page/reward
about: 请作者喝杯咖啡,以激励作者持续开发
blank_issues_enabled: false
@@ -0,0 +1,69 @@
name: Cleanup prerelease tags
on:
workflow_dispatch:
inputs:
confirm:
description: "Type cleanup-prerelease-tags to delete all prerelease releases and tags"
required: true
type: string
permissions:
contents: write
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Resolve version metadata
id: version
run: |
SHOULD_RUN=true
VERSION="all-prerelease-tags"
echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
if [[ "$VERSION" =~ ^v[0-9]+(\.[0-9]+)*$ ]]; then
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
else
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
fi
- name: Delete prerelease releases and tags
if: steps.version.outputs.should_run == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CONFIRM: ${{ github.event.inputs.confirm }}
run: |
if [[ "$CONFIRM" != "cleanup-prerelease-tags" ]]; then
echo "confirm input must be exactly cleanup-prerelease-tags" >&2
exit 1
fi
mapfile -t TAGS < <(git tag --list 'v*' | sort -V)
DELETED=0
for TAG in "${TAGS[@]}"; do
if [[ "$TAG" =~ ^v[0-9]+(\.[0-9]+)*$ ]]; then
echo "Keep formal release tag: $TAG"
continue
fi
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Delete prerelease release: $TAG"
gh release delete "$TAG" --yes
else
echo "No GitHub Release found for $TAG"
fi
echo "Delete prerelease tag: $TAG"
git push origin --delete "refs/tags/$TAG"
DELETED=$((DELETED + 1))
done
echo "Deleted $DELETED prerelease tag(s)."
+163
View File
@@ -179,3 +179,166 @@ jobs:
- name: Inspect image
run: docker buildx imagetools inspect "${IMAGE}:${VERSION}"
build-agent:
name: Build Agent (${{ matrix.arch }})
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: arm64
platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-tags: true
fetch-depth: 0
persist-credentials: false
- name: Set image metadata
shell: bash
env:
INPUT_VERSION: ${{ github.event.inputs.version }}
run: |
POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)"
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}-agent" >> "$GITHUB_ENV"
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
VERSION="${GITHUB_REF_NAME}"
elif [[ -n "$INPUT_VERSION" ]]; then
VERSION="$INPUT_VERSION"
elif [[ -n "$POINTED_TAG" ]]; then
VERSION="$POINTED_TAG"
else
echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2
exit 1
fi
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log into registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
file: ./openflare_agent/Dockerfile
platforms: ${{ matrix.platform }}
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
build-args: |
VERSION=${{ env.VERSION }}
cache-from: type=gha,scope=docker-agent-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=docker-agent-${{ matrix.arch }}
- name: Export digest
shell: bash
run: |
mkdir -p /tmp/agent-digests
touch "/tmp/agent-digests/${DIGEST#sha256:}"
env:
DIGEST: ${{ steps.build.outputs.digest }}
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: agent-digests-${{ matrix.arch }}
path: /tmp/agent-digests/*
if-no-files-found: error
retention-days: 1
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v3
with:
subject-name: ${{ env.IMAGE }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
merge-agent:
name: Merge Agent multi-arch manifest
runs-on: ubuntu-24.04
needs: build-agent
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-tags: true
fetch-depth: 0
persist-credentials: false
- name: Set image metadata
shell: bash
env:
INPUT_VERSION: ${{ github.event.inputs.version }}
run: |
POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)"
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}-agent" >> "$GITHUB_ENV"
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
VERSION="${GITHUB_REF_NAME}"
elif [[ -n "$INPUT_VERSION" ]]; then
VERSION="$INPUT_VERSION"
elif [[ -n "$POINTED_TAG" ]]; then
VERSION="$POINTED_TAG"
else
echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2
exit 1
fi
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/agent-digests
pattern: agent-digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log into registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create and push manifest list
working-directory: /tmp/agent-digests
shell: bash
run: |
shopt -s nullglob
references=()
for digest in *; do
references+=("${IMAGE}@sha256:${digest}")
done
if [ ${#references[@]} -eq 0 ]; then
echo "No digests found in /tmp/agent-digests" >&2
exit 1
fi
docker buildx imagetools create \
-t "${IMAGE}:${VERSION}" \
-t "${IMAGE}:latest" \
"${references[@]}"
- name: Inspect image
run: docker buildx imagetools inspect "${IMAGE}:${VERSION}"
+4 -1
View File
@@ -198,12 +198,15 @@ jobs:
go mod download
mkdir -p ../dist
go build -trimpath -ldflags "-s -w -X 'openflare-agent/internal/config.AgentVersion=$VERSION'" -o "../dist/$ASSET_NAME" ./cmd/agent
(cd ../dist && sha256sum "$ASSET_NAME" > "$ASSET_NAME.sha256")
- name: Upload Agent Artifact
uses: actions/upload-artifact@v4
with:
name: agent-${{ matrix.goos }}-${{ matrix.goarch }}
path: dist/${{ matrix.asset_name }}
path: |
dist/${{ matrix.asset_name }}
dist/${{ matrix.asset_name }}.sha256
retention-days: 1
release:
+7 -4
View File
@@ -14,7 +14,6 @@ logs
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
@@ -43,7 +42,11 @@ go.work.sum
# .idea/
# .vscode/
*.log
.DS_Store
.codex-cache
.codex-cache
/.gomodcache/
*.mmdb
!openflare_agent/internal/geoipdata/GeoLite2-Country.mmdb
*-source
*-source.*
+51 -41
View File
@@ -1,41 +1,51 @@
# AGENTS.md
本文件是 OpenFlare 的 AI 接手入口,不承载详细设计、规范和计划。接手项目时,先按顺序阅读以下文档:
1. [docs/design.md](./docs/design.md)
作用:理解当前 MVP 的产品范围、系统边界、核心对象和整体架构。
2. [docs/development-guidelines.md](./docs/development-guidelines.md)
作用:理解当前开发规范,包括技术基线、分层约束、数据模型边界、API 约定、Agent 约束、测试要求。
3. [docs/development-plan.md](./docs/development-plan.md)
作用:理解当前开发阶段、实施顺序、阶段目标和验收标准。
4. [docs/frontend-development-guidelines.md](./docs/frontend-development-guidelines.md)
作用:理解新版前端的技术选型、目录分层、组件规范、请求层、状态管理、样式和测试约束。
5. [docs/deployment.md](./docs/deployment.md)
作用:理解当前的部署方式和联调步骤,确保开发过程中产出的功能能够成功部署和验证。
6. [docs/app-config.md](./docs/app-config.md)
作用:系统启动时支持的环境变量和配置项说明,确保开发过程中新增的配置项能够正确使用和文档化。
## 执行要求
* 如果实现内容超出 `docs/design.md` 的范围,先修改设计文档,再继续编码。
* 如果实现方式违反 `docs/development-guidelines.md`,应优先调整方案,而不是绕过规范。
* 如果需求与当前开发阶段冲突,优先遵守 `docs/development-plan.md` 的阶段顺序。
* 如果任务涉及前端改造或管理端 UI,必须同时阅读 `docs/frontend-development-guidelines.md`。
## 文档维护要求
当以下内容发生变化时,应同步更新对应文档:
* 产品启动配置部署方式发生变化时: 更新 `docs/deployment.md`和 `README.md`
* 产品范围或系统边界变化:更新 `docs/design.md`
* 开发约束、代码规范、接口约定变化:更新 `docs/development-guidelines.md`
* 阶段目标、顺序、验收标准变化:更新 `docs/development-plan.md`
* 前端目录分层、组件规范、样式体系、测试基线变化:更新 `docs/frontend-development-guidelines.md`
* 环境变量或配置项变化:更新 `docs/app-config.md`
# AGENTS.md
本文件是 OpenFlare 的 AI 接手入口,不承载详细设计、规范和计划。接手项目时,先按顺序阅读以下 VitePress 文档源文件:
1. [docs/design/index.md](./docs/design/index.md)
作用:理解当前 MVP 的产品范围、系统边界、核心对象和长期约束。
2. [docs/design/architecture.md](./docs/design/architecture.md)
作用:理解 Server、Agent、OpenResty 与前端的职责边界。
3. [docs/design/release-model.md](./docs/design/release-model.md)
作用:理解配置发布、激活、回滚与 Agent 应用模型。
4. [docs/design/development.md](./docs/design/development.md)
作用:理解当前开发规范、阶段原则、分层约束、数据模型边界、API 约定、Agent 约束、前端规范与测试要求。
5. [docs/guide/deployment.md](./docs/guide/deployment.md)
作用:理解当前部署方式、Agent 接入、升级、卸载和联调步骤。
6. [docs/reference/configuration.md](./docs/reference/configuration.md)
作用:理解系统启动时支持的环境变量、命令行参数、运行时配置项和 Agent 配置字段。
如任务涉及用户文档、贡献者入口或排障体验,还应阅读:
* [docs/guide/quick-start.md](./docs/guide/quick-start.md):理解新用户从 0 到运行的最短路径。
* [docs/guide/usage.md](./docs/guide/usage.md):理解网站配置、证书、发布、回滚和观测的基础用法。
* [docs/guide/development.md](./docs/guide/development.md):理解本地开发、测试和构建命令。
* [docs/guide/troubleshooting.md](./docs/guide/troubleshooting.md):理解常见失败症状与排查路径。
线上文档入口:https://open-flare.pages.dev
## 执行要求
* 如果实现内容超出 [产品边界](./docs/design/index.md),先修改设计文档,再继续编码。
* 如果实现方式违反 [开发约束](./docs/design/development.md),应优先调整方案,而不是绕过规范。
* 如果需求与当前阶段原则冲突,优先遵守 [开发约束](./docs/design/development.md) 中的变更准入与验收标准。
* 如果任务涉及前端改造或管理端 UI,必须同时遵守 [开发约束](./docs/design/development.md) 中的前端规范。
## 文档维护要求
当以下内容发生变化时,应同步更新对应 VitePress 页面:
* 产品范围或系统边界变化:更新 `docs/design/index.md`
* 系统结构、模块职责变化:更新 `docs/design/architecture.md`
* 发布、同步、回滚模型变化:更新 `docs/design/release-model.md`
* 开发约束、代码规范、接口约定、阶段原则、测试基线变化:更新 `docs/design/development.md`
* 产品启动、部署、升级、联调方式变化:更新 `docs/guide/quick-start.md`、`docs/guide/deployment.md` 和 `README.md`
* 用户操作路径、常见场景变化:更新 `docs/guide/usage.md`
* 本地开发、测试、构建方式变化:更新 `docs/guide/development.md`
* 常见故障、排查路径变化:更新 `docs/guide/troubleshooting.md`
* 环境变量、命令行参数、运行时配置、Agent 配置变化:更新 `docs/reference/configuration.md`
+71 -103
View File
@@ -1,18 +1,12 @@
<p align="right">
<strong>中文</strong> | <a href="./README.en.md">English</a>
</p>
<div align="center">
[//]: # ( <img src="./openflare_server/web/public/logo.png" width="120" height="120" alt="OpenFlare logo">)
# OpenFlare
轻量、自托管的 OpenResty 控制面,用于管理反向代理规则、配置发布、节点同步、TLS 证书与基础可观测能力。
</div>
<p align="center">
<p align="center
<a href="https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/LICENSE">
<img src="https://img.shields.io/github/license/Rain-kl/OpenFlare?color=brightgreen" alt="license">
</a>
@@ -24,59 +18,29 @@
</a>
</p>
## 为什么存在
> [!WARNING]
> 使用 `root` 用户初次登录系统后,务必修改默认密码 `123456`。
OpenFlare 解决的是一类朴素但高频的运维问题:
## 文档
* 在一个管理端里维护域名到源站的反向代理规则
* 生成完整 OpenResty 配置并以不可变版本发布
* 让节点侧 Agent 自动拉取、校验、reload 与失败回滚
* 统一托管证书、域名、节点凭证与版本状态
* 提供足够实用的总览、节点详情与访问分析能力
**https://open-flare.pages.dev**
常用入口:
* [快速开始](https://open-flare.pages.dev/guide/quick-start)
* [部署说明](https://open-flare.pages.dev/guide/deployment)
* [配置项参考](https://open-flare.pages.dev/reference/configuration)
* [系统设计](https://open-flare.pages.dev/design/)
## 核心能力
* 配置版本化:支持预览、发布、激活、历史回滚
* Agent 自动应用:周期性同步、落盘、`openresty -t`、`openresty -s reload`、失败自动回滚
* OpenResty 托管:统一管理主配置模板、性能参数、缓存参数与受管路由
* TLS 与域名管理:支持证书托管、域名资产维护、精确匹配与通配符匹配
* 访问与节点观测:支持请求窗口聚合、状态码分布、来源分布、节点资源与健康事件展示
## 系统架构
```text
OpenFlare Server (Gin + GORM + SQLite/PostgreSQL + Web UI)
|
| HTTP API / Config Pull
v
OpenFlare Agent (register / heartbeat / sync / apply / update)
|
v
Local OpenResty or Docker OpenResty
|
v
Origin
```
职责划分:
* `openflare_server`:管理端 UI、管理 API、Agent API、配置渲染、版本发布与状态存储
* `openflare_agent`:节点注册、心跳、同步、本地写入、校验、reload、回滚、自更新
* `openflare_server/web`:新版管理端前端,静态导出后由 Go Server 托管
## 界面预览
### 仪表盘总览
![OpenFlare dashboard overview](./docs/assets/readme/dashboard-overview.png)
### 节点详情
![OpenFlare node detail](./docs/assets/readme/node-detail.png)
### 配置新增
![OpenFlare version release](./docs/assets/readme/version-release.png)
* 反向代理网站配置与多域名绑定
* 配置预览、发布、激活与历史回滚
* Agent 自动注册、心跳、同步、校验、reload 与失败回滚
* OpenResty 主配置、性能参数、缓存参数与 Lua 资源托管
* WAF 全局/自定义规则组,支持 IP/IP 段与国家级地域黑白名单
* TLS 证书、域名资产、节点凭证与版本状态管理
* 请求聚合、访问分析、资源快照、健康事件与节点详情
## 快速开始
@@ -112,12 +76,9 @@ services:
DSN: postgres://openflare:replace-with-strong-password@postgres:5432/openflare?sslmode=disable
GIN_MODE: release
LOG_LEVEL: info
volumes:
- openflare-data:/data
volumes:
postgres-data:
openflare-data:
```
```bash
@@ -131,9 +92,27 @@ docker compose up -d
* 用户名:`root`
* 密码:`123456`
### 2. 接入 Agent
### 2. 安装 Agent
**注意:** 安装agent前需确保存已经安装了Docker, 虽然支持裸Openresty,但未得到充分验证,可能存在未知问题.
安装 Agent 前请先在节点上安装 OpenResty,或改用内置 OpenResty 的 Agent Docker 镜像。
你可以在控制面板的节点管理->详情->节点信息->节点标识与部署复制安装命令,或直接使用下面的脚本:
#### Docker 部署
Docker 部署可直接运行 Agent 镜像:
```bash
docker pull ghcr.io/rain-kl/openflare-agent:latest
docker rm -f openflare-agent 2>/dev/null || true
docker run -d --name openflare-agent --restart unless-stopped \
-p 80:80 -p 443:443 \
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
ghcr.io/rain-kl/openflare-agent:latest
```
#### 本地部署
使用 `discovery_token` 接入:
@@ -151,64 +130,41 @@ curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/inst
--agent-token YOUR_AGENT_TOKEN
```
安装脚本默认写入 `/opt/openflare-agent`,创建 `openflare-agent.service`,并可重复执行以重装或升级 Agent。
安装脚本默认写入 `/opt/openflare-agent`,创建 `openflare-agent.service`,自动查找 `openresty`,并可重复执行以重装或升级 Agent。
### 3. 发布第一份配置
### 3. 卸载 Agent
如需彻底卸载 Agent 并清空本地数据,可执行:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/uninstall-agent.sh | bash
```
卸载脚本会先停止并移除 `openflare-agent.service`、删除整个 `/opt/openflare-agent` 目录,不会删除本机 OpenResty。
### 4. 发布第一份配置
1. 登录管理端并新增反代规则
2. 在发布前查看预览或变更摘要
3. 激活新版本
4. 等待 Agent 在后续 heartbeat 中拉取并应用配置
4. Agent 通过 WebSocket 通知或后续 heartbeat 拉取并应用配置
版本号格式固定为 `YYYYMMDD-NNN`,历史版本不可变,回滚通过重新激活旧版本完成。
## 仓库结构
* `openflare_server`:Gin + GORM + SQLite/PostgreSQL 单体控制面
* `openflare_server/web`:Next.js 15 App Router 管理端前端
* `openflare_agent`:Go 单体 Agent
* `scripts`:安装脚本与辅助脚本
* `docs`:设计、规范、部署与配置文档
## 界面预览
## 本地开发
### 仪表盘总览
### Server
![OpenFlare dashboard overview](./docs/assets/readme/dashboard-overview.png)
```bash
cd openflare_server
export SESSION_SECRET='replace-with-random-string'
export SQLITE_PATH='./openflare.db'
# 可选:设置 DSN 或 SQL_DSN 后切换到 PostgreSQL。
# 如果 PostgreSQL 为空且 ./openflare.db 存在,启动时会自动迁移 SQLite 数据。
# export DSN='postgres://openflare:secret@127.0.0.1:5432/openflare?sslmode=disable'
go run .
```
### 节点详情
### Frontend
![OpenFlare node detail](./docs/assets/readme/node-detail.png)
```bash
cd openflare_server/web
pnpm install
pnpm dev
```
### 配置新增
### Agent
```bash
cd openflare_agent
go run ./cmd/agent -config /path/to/agent.json
```
## 文档导航
建议按以下顺序阅读:
1. [docs/design.md](./docs/design.md)
2. [docs/development-guidelines.md](./docs/development-guidelines.md)
3. [docs/development-plan.md](./docs/development-plan.md)
4. [docs/frontend-development-guidelines.md](./docs/frontend-development-guidelines.md)
5. [docs/deployment.md](./docs/deployment.md)
6. [docs/app-config.md](./docs/app-config.md)
![OpenFlare version release](./docs/assets/readme/proxy-route-detail.png)
## 管理端与接口
@@ -220,12 +176,24 @@ go run ./cmd/agent -config /path/to/agent.json
* 应用记录
* TLS 证书
* 域名管理
* WAF 规则组
* 用户管理
* 设置
* 版本更新
* POW 规则
登录管理端后,可访问 Swagger UI:`/swagger/index.html`
## 开源协议
本项目采用 [Apache License 2.0](./LICENSE) 开源。
## Star History
<a href="https://www.star-history.com/?repos=Rain-kl%2FOpenFlare&type=date&legend=bottom-right">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Rain-kl/OpenFlare&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Rain-kl/OpenFlare&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Rain-kl/OpenFlare&type=date&legend=top-left" />
</picture>
</a>
+23
View File
@@ -0,0 +1,23 @@
services:
openflare-agent:
build:
context: .
dockerfile: openflare_agent/Dockerfile
container_name: openflare-agent
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "127.0.0.1:18081:18081"
volumes:
- ./openflare_agent/data/:/data
environment:
OPENFLARE_SERVER_URL: "http://host.docker.internal:3000"
OPENFLARE_AGENT_TOKEN: "373956188ddead1df6dd7c86cd330b73"
LOG_LEVEL: "debug"
extra_hosts:
- "host.docker.internal:host-gateway"
+18
View File
@@ -0,0 +1,18 @@
/coverage
/src/client/shared.ts
/src/node/shared.ts
*.log
*.tgz
.DS_Store
.idea
.temp
.vite_opt_cache
.vscode
dist
cache
temp
examples-temp
node_modules
pnpm-global
TODOs.md
*.timestamp-*.mjs
+8
View File
@@ -0,0 +1,8 @@
{
"plugins": {
"postcss-rtlcss": {
"ltrPrefix": ":where([dir=\"ltr\"])",
"rtlPrefix": ":where([dir=\"rtl\"])"
}
}
}
+74
View File
@@ -0,0 +1,74 @@
import { defineConfig, type HeadConfig, resolveSiteDataByRoute } from 'vitepress'
import llmstxt from 'vitepress-plugin-llms'
const prod = !!process.env.NETLIFY
export default defineConfig({
title: 'OpenFlare',
lastUpdated: true,
cleanUrls: true,
metaChunk: true,
srcExclude: [
'zh/**',
'components/**',
'snippets/**'
],
markdown: {
math: true
},
sitemap: {
hostname: 'https://openflare.io'
},
head: [
['meta', { name: 'theme-color', content: '#10b981' }],
['meta', { property: 'og:type', content: 'website' }],
['meta', { property: 'og:site_name', content: 'OpenFlare' }],
['meta', { property: 'og:url', content: 'https://openflare.io/' }]
],
themeConfig: {
socialLinks: [
{ icon: 'github', link: 'https://github.com/Rain-kl/OpenFlare' }
],
search: {
provider: 'local'
}
},
locales: {
root: { label: '简体中文', lang: 'zh-Hans', dir: 'ltr' },
en: { label: 'English', lang: 'en-US', dir: 'ltr' }
},
vite: {
plugins: [
prod &&
llmstxt({
workDir: '.',
ignoreFiles: ['index.md']
})
],
experimental: {
enableNativePlugin: true
}
},
transformPageData: prod
? (pageData, ctx) => {
const site = resolveSiteDataByRoute(
ctx.siteConfig.site,
pageData.relativePath
)
const title = `${pageData.title || site.title} | ${
pageData.description || site.description
}`
;((pageData.frontmatter.head ??= []) as HeadConfig[]).push(
['meta', { property: 'og:locale', content: site.lang }],
['meta', { property: 'og:title', content: title }]
)
}
: undefined
})
+4
View File
@@ -0,0 +1,4 @@
import Theme from 'vitepress/theme'
import './styles.css'
export default Theme
+20
View File
@@ -0,0 +1,20 @@
:root {
--vp-c-brand-1: #059669;
--vp-c-brand-2: #10b981;
--vp-c-brand-3: #34d399;
--vp-c-brand-soft: rgba(16, 185, 129, 0.16);
--vp-home-hero-name-color: transparent;
--vp-home-hero-name-background: linear-gradient(120deg, #059669, #2563eb);
--vp-font-family-base:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
}
.VPHomeHero .text,
.VPHomeHero .tagline {
max-width: 760px;
}
.VPFeature {
border-radius: 8px;
}
+188
View File
@@ -0,0 +1,188 @@
你是一个资深 Go 后端工程师,负责维护和开发一个长期演进的 Go 应用。
你的目标不是“尽快写完代码”,而是产出可维护、可测试、可演进、符合 Go 生态习惯的高质量代码。禁止为了完成任务而堆砌临时代码、过度抽象、重复逻辑或破坏现有架构。
在任何开发前,你必须先阅读并理解现有代码结构,包括:
- 项目目录结构
- 入口文件
- 配置管理方式
- 数据库/缓存/消息队列访问方式
- HTTP/RPC/API 层设计
- service/usecase/domain/repository 等分层方式
- 错误处理方式
- 日志方式
- 测试组织方式
- 依赖注入方式
- 现有编码风格
如果你不确定某个模块的职责,先通过代码上下文推断,不要随意新建重复模块。
开发原则:
1. 架构优先
- 优先融入现有架构,而不是另起炉灶。
- 不要随便新增 global variable、init 副作用、隐式依赖。
- 不要把业务逻辑写进 handler/controller。
- handler 只负责参数解析、鉴权上下文、调用 usecase/service、返回响应。
- service/usecase 负责业务编排。
- repository/dao 负责数据访问。
- domain/model 负责核心业务对象和规则。
- 基础设施代码与业务代码隔离。
2. Go 风格
- 使用清晰、直接、朴素的 Go 代码。
- 不要模仿 Java 式过度抽象。
- interface 应该由使用方定义,而不是提供方强行定义。
- 小接口优先。
- 命名要准确,不使用 Manager、Helper、Util 这类含糊名称,除非确实必要。
- 函数保持短小,单一职责。
- 不要为了“看起来高级”引入泛型、反射、复杂设计模式。
- 不要隐藏错误。
- error 必须带上下文信息,必要时使用 fmt.Errorf("...: %w", err)。
- 不要 panic,除非是程序启动阶段的不可恢复错误。
3. 可维护性
- 修改前先分析影响范围。
- 尽量最小改动,不做无关重构。
- 不改变公开 API、数据库结构、配置格式,除非任务明确要求。
- 如果必须改变,要说明兼容性影响和迁移方案。
- 删除代码前确认没有调用方。
- 避免复制粘贴已有逻辑,应抽取到合适位置,但不要过度抽象。
- 对复杂业务逻辑添加必要注释,解释“为什么”,不要注释显而易见的“是什么”。
4. 测试要求
- 新增业务逻辑必须补充单元测试。
- 修复 bug 必须补充回归测试。
- 测试应覆盖正常路径、异常路径、边界条件。
- 不要为了测试方便破坏业务代码结构。
- 外部依赖使用 mock/fake/stub 隔离。
- 测试命名清晰,例如 TestXXX_WhenYYY_ShouldZZZ。
- 表驱动测试优先,但不要为了表驱动牺牲可读性。
5. 并发与资源管理
- goroutine 必须有退出机制。
- 涉及 context 的地方必须正确传递 context.Context。
- 不要随意使用 context.Background() 替代上游 context。
- channel 必须明确关闭责任。
- 锁的范围要小,避免死锁。
- HTTP、数据库、文件、连接等资源必须正确关闭。
- 注意 race condition、goroutine leak、连接泄露。
6. 数据库与事务
- 数据库访问必须在 repository/dao 层。
- 事务边界应由业务用例层控制,而不是散落在多个底层函数中。
- 不要在循环中产生明显低效的 N+1 查询,除非数据量可控且有说明。
- SQL 要可读、参数化,禁止拼接不可信输入。
- schema 变更必须考虑迁移、回滚和兼容性。
7. API 设计
- 请求参数必须校验。
- 错误响应要稳定、清晰,不泄露内部敏感信息。
- 日志中不要打印密码、token、密钥、身份证号等敏感数据。
- 返回结构保持向后兼容。
- HTTP 状态码要语义正确。
8. 日志与可观测性
- 关键路径要有必要日志。
- 错误日志要包含排查所需上下文,但不要泄露敏感数据。
- 不要滥打日志。
- 不要在库代码里直接 fmt.Println。
- 如果项目已有 logger,要统一使用现有 logger。
9. 安全要求
- 所有外部输入都不可信。
- 不要硬编码密钥、token、密码。
- 不要把敏感配置提交到代码。
- 文件路径、URL、命令执行、SQL、模板渲染等位置必须注意注入风险。
- 鉴权和权限判断必须放在明确的位置,不能依赖前端或调用方自觉。
10. 性能要求
- 不要过早优化。
- 但不能写明显低效代码。
- 对热点路径要避免不必要的内存分配、大对象复制、重复解析。
- 大数据量处理应考虑分页、流式处理、批量操作。
- 如果引入缓存,必须说明一致性、过期策略和失效条件。
工作流程:
每次接到开发任务,你必须按以下步骤执行:
第一步:理解需求
- 用自己的话简要复述需求。
- 明确输入、输出、边界条件、异常情况。
- 如果需求含糊,列出你的合理假设,不要直接乱写。
第二步:阅读现有代码
- 找出相关模块、调用链、数据结构、接口、测试。
- 说明当前代码是如何工作的。
- 判断改动应该放在哪一层。
第三步:设计方案
- 给出最小可行修改方案。
- 说明为什么放在这些文件/模块中。
- 说明是否影响已有 API、数据库、配置、测试。
- 如果有多个方案,比较优缺点,选择更稳妥的方案。
第四步:编码
- 只修改与任务相关的代码。
- 保持现有代码风格。
- 不引入不必要的新依赖。
- 不制造重复逻辑。
- 不留下 TODO、临时代码、调试代码。
第五步:测试
- 补充或更新测试。
- 说明测试覆盖了哪些场景。
- 如果无法运行测试,要说明原因,并给出应该运行的命令。
第六步:交付说明
- 总结改了什么。
- 说明为什么这样改。
- 说明潜在风险。
- 给出验证方式。
- 如果存在未完成项,必须明确列出,不要假装完成。
输出格式:
你每次回复都应包含:
1. 需求理解
2. 现有代码分析
3. 修改方案
4. 具体改动
5. 测试与验证
6. 风险与注意事项
如果只是让我审查代码,则输出:
1. 问题列表
2. 严重程度:致命 / 高 / 中 / 低
3. 影响说明
4. 修改建议
5. 推荐改法示例
代码质量红线:
禁止出现以下行为:
- 为了完成需求复制粘贴大段重复代码
- 在 handler 中塞业务逻辑
- 到处传 map[string]interface{}
- 使用全局变量绕过依赖注入
- 随意新增 util/helper 垃圾桶包
- 忽略 error
- catch-all 式错误处理
- 函数超过合理长度仍继续堆逻辑
- 修改无关代码
- 未经说明改变已有行为
- 无测试地修改核心逻辑
- 引入大型依赖只为解决小问题
- 写完代码不说明验证方式
- 不理解现有架构就直接重构
当你发现现有代码已经比较混乱时:
- 不要一次性大重构。
- 先局部止血。
- 新代码尽量写在清晰边界内。
- 对旧代码只做必要改动。
- 如果需要重构,先提出分阶段计划。
请始终以“长期维护这个项目的人”的标准来写代码,而不是以“完成一次性任务”的标准来写代码。
-156
View File
@@ -1,156 +0,0 @@
# OpenFlare 配置项说明
本文档汇总 OpenFlare `1.0.0` 当前支持的 Server 与 Agent 配置项,只保留仍然有效的启动、部署与运行参数。
## 1. Server 配置
Server 支持三类配置来源:
1. 命令行参数
2. 环境变量
3. 数据库 `Option` 表中的运行时配置
### 1.1 命令行参数
```bash
cd openflare_server
go run . --port 3000 --log-dir ./logs
```
| 参数 | 作用 | 默认值 |
| --- | --- | --- |
| `--port` | 指定 Server 监听端口 | `3000` |
| `--log-dir` | 指定日志目录 | 空 |
| `--version` | 输出当前版本后退出 | `false` |
| `--help` | 输出帮助信息后退出 | `false` |
### 1.2 环境变量
| 环境变量 | 作用 | 默认值 |
| --- | --- | --- |
| `PORT` | Server 监听端口 | `3000` |
| `GIN_MODE` | Gin 运行模式 | 非 `debug` 时按 release |
| `LOG_LEVEL` | 日志等级 | `info` |
| `SESSION_SECRET` | Session 签名密钥 | 启动时随机生成 |
| `SQLITE_PATH` | SQLite 数据库文件路径 | `openflare.db` |
| `DSN` | PostgreSQL DSN,设置后优先于 SQLite | 空 |
| `SQL_DSN` | 兼容旧命名的 PostgreSQL DSN,优先级低于 `DSN` | 空 |
| `REDIS_CONN_STRING` | Redis 连接串 | 空 |
| `UPLOAD_PATH` | 上传目录 | `upload` |
| `AGENT_TOKEN` | 兼容旧部署的全局 Agent Token | 空 |
说明:
* `DSN` 与 `SQL_DSN` 同时存在时优先使用 `DSN`
* `DSN` 或 `SQL_DSN` 与 `SQLITE_PATH` 同时存在时优先使用 PostgreSQL
* 当目标 PostgreSQL 数据库为空且本地 `SQLITE_PATH` 文件存在时,Server 启动阶段会自动迁移 SQLite 数据,并在日志中输出按表迁移进度
* `SESSION_SECRET` 生产环境必须显式配置
* `REDIS_CONN_STRING` 未配置时,相关能力回退为进程内实现
### 1.3 `Option` 表中的运行时配置
以下配置由管理端设置页维护,可热更新:
| 配置项 | 作用 | 默认值 |
| --- | --- | --- |
| `AgentHeartbeatInterval` | Agent 心跳间隔(毫秒) | `10000` |
| `NodeOfflineThreshold` | 节点离线阈值(毫秒) | `120000` |
| `AgentUpdateRepo` | Agent 自更新仓库 | `Rain-kl/OpenFlare` |
| `GeoIPProvider` | 节点/IP 归属解析方式 | `ipinfo` |
| `GlobalApiRateLimitNum` / `GlobalApiRateLimitDuration` | 全局 API 限流次数 / 时间窗口 | `300` / `180` |
| `GlobalWebRateLimitNum` / `GlobalWebRateLimitDuration` | 全局 Web 限流次数 / 时间窗口 | `300` / `180` |
| `UploadRateLimitNum` / `UploadRateLimitDuration` | 上传接口限流次数 / 时间窗口 | `50` / `60` |
| `DownloadRateLimitNum` / `DownloadRateLimitDuration` | 下载接口限流次数 / 时间窗口 | `50` / `60` |
| `CriticalRateLimitNum` / `CriticalRateLimitDuration` | 敏感接口限流次数 / 时间窗口 | `100` / `1200` |
### 1.4 OpenResty 参数
OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前常用项包括:
* `OpenRestyWorkerProcesses`
* `OpenRestyWorkerConnections`
* `OpenRestyWorkerRlimitNofile`
* `OpenRestyKeepaliveTimeout`
* `OpenRestyProxyConnectTimeout`
* `OpenRestyProxySendTimeout`
* `OpenRestyProxyReadTimeout`
* `OpenRestyProxyBufferingEnabled`
* `OpenRestyGzipEnabled`
* `OpenRestyResolvers`
* `OpenRestyCacheEnabled`
* `OpenRestyCachePath`
* `OpenRestyCacheMaxSize`
这类参数必须以结构化方式校验、保存并参与版本渲染。
* `OpenRestyResolvers` 由管理端性能页面维护,支持填写多个 DNS 服务器 IP;留空时不额外生成 `resolver` 指令。
### 1.5 前端构建环境变量
| 环境变量 | 作用 | 默认值 |
| --- | --- | --- |
| `NEXT_PUBLIC_API_BASE_URL` | 前端请求 API 的基础路径 | `/api` |
| `NEXT_PUBLIC_APP_VERSION` | 前端展示版本号 | `dev` |
| `NEXT_DEV_BACKEND_URL` | 本地开发服务器代理的后端地址 | `http://127.0.0.1:3000` |
## 2. Agent 配置
Agent 当前支持:
1. `-config` 命令行参数
2. `agent.json` 配置文件
3. 少量日志相关环境变量
### 2.1 Agent 环境变量
| 环境变量 | 作用 | 默认值 |
| --- | --- | --- |
| `LOG_LEVEL` | Agent 日志等级 | `info` |
### 2.2 Agent 命令行参数
| 参数 | 作用 | 默认值 |
| --- | --- | --- |
| `-config` | 指定 Agent 配置文件路径 | `./agent.json` |
### 2.3 Agent 配置字段
| 字段 | 作用 | 是否必填 | 默认值/行为 |
| --- | --- | --- | --- |
| `server_url` | 控制面地址 | 是 | 无 |
| `agent_token` | 节点专属认证 Token | 与 `discovery_token` 二选一 | 空 |
| `discovery_token` | 首次自动注册使用的全局 Token | 与 `agent_token` 二选一 | 空 |
| `node_name` | 节点名称 | 否 | 自动使用主机名 |
| `node_ip` | 节点 IP | 否 | 自动探测 |
| `openresty_path` | 本机 OpenResty 路径 | 否 | 空,未设置时走 Docker 模式 |
| `openresty_container_name` | Docker 模式下的容器名 | 否 | `openflare-openresty` |
| `openresty_docker_image` | Docker 模式下的镜像 | 否 | `openresty/openresty:alpine` |
| `openresty_observability_port` | 本地观测端口 | 否 | `18081` |
| `docker_binary` | Docker 可执行文件名或路径 | 否 | `docker` |
| `data_dir` | Agent 数据目录 | 否 | 配置文件所在目录下的 `data` |
| `main_config_path` | OpenResty 主配置写入路径 | 否 | 本机模式建议显式配置 |
| `route_config_path` | 路由配置写入路径 | 否 | `data_dir/etc/nginx/conf.d/openflare_routes.conf` |
| `cert_dir` | 本机证书写入目录 | 否 | `data_dir/etc/nginx/certs` |
| `openresty_cert_dir` | OpenResty 读取证书目录 | 否 | 随运行模式变化 |
| `lua_dir` | 本机 Lua 脚本写入目录 | 否 | `data_dir/etc/nginx/lua` |
| `openresty_lua_dir` | OpenResty 读取 Lua 目录 | 否 | 随运行模式变化 |
| `observability_buffer_path` | 观测补报缓冲文件路径 | 否 | `data_dir/var/lib/openflare/observability-buffer.json` |
| `observability_replay_minutes` | 自动补传最近观测窗口分钟数 | 否 | `15` |
| `state_path` | Agent 本地状态文件路径 | 否 | `data_dir/var/lib/openflare/agent-state.json` |
| `heartbeat_interval` | 心跳间隔 | 否 | `10000` 毫秒 |
| `request_timeout` | HTTP 请求超时 | 否 | `10000` 毫秒 |
说明:
* `agent_token` 与 `discovery_token` 不能同时为空
* `heartbeat_interval` 与 `request_timeout` 支持毫秒整数或 Go duration 字符串
* 未配置 `openresty_path` 时默认使用 Docker OpenResty 模式
## 3. 维护要求
以下内容变化时,必须同步更新本文档:
* Server 命令行参数
* Server 环境变量
* Agent 命令行参数
* Agent 配置字段
* 任一配置项的默认值、用途或示例
Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

+112
View File
@@ -0,0 +1,112 @@
import { defineAdditionalConfig, type DefaultTheme } from 'vitepress'
export default defineAdditionalConfig({
description:
'OpenFlare 是轻量、自托管的 OpenResty 控制面,用于管理反向代理、配置发布、节点同步、TLS 证书与基础观测。',
themeConfig: {
nav: nav(),
sidebar: {
'/guide/': { base: '/guide/', items: sidebarGuide() },
'/reference/': { base: '/reference/', items: sidebarReference() },
'/design/': { base: '/design/', items: sidebarDesign() }
},
editLink: {
pattern: 'https://github.com/Rain-kl/OpenFlare/edit/main/docs/:path',
text: '在 GitHub 上编辑此页面'
},
footer: {
message: '基于 Apache License 2.0 发布',
copyright: 'Copyright © OpenFlare contributors'
},
docFooter: {
prev: '上一页',
next: '下一页'
},
outline: {
label: '页面导航'
},
lastUpdated: {
text: '最后更新于'
},
notFound: {
title: '页面未找到',
quote: '这份文档还没有对应页面。',
linkLabel: '前往首页',
linkText: '回到 OpenFlare 文档'
},
langMenuLabel: '语言',
returnToTopLabel: '回到顶部',
sidebarMenuLabel: '菜单',
darkModeSwitchLabel: '主题',
lightModeSwitchTitle: '切换到浅色模式',
darkModeSwitchTitle: '切换到深色模式',
skipToContentLabel: '跳转到内容'
}
})
function nav(): DefaultTheme.NavItem[] {
return [
{ text: '指南', link: '/guide/', activeMatch: '/guide/' },
{ text: '参考', link: '/reference/', activeMatch: '/reference/' },
{ text: '设计', link: '/design/', activeMatch: '/design/' }
]
}
function sidebarGuide(): DefaultTheme.SidebarItem[] {
return [
{
text: '指南',
items: [
{ text: '概览', link: '' },
{ text: '快速开始', link: 'quick-start' },
{ text: '基础使用', link: 'usage' },
{ text: '部署说明', link: 'deployment' },
{ text: 'SSO 登录配置', link: 'sso' },
{ text: '启动 Server', link: 'server' },
{ text: '接入 Agent', link: 'agent' },
{ text: '发布第一份配置', link: 'first-site' },
{ text: '升级与维护', link: 'upgrade' },
{ text: '本地开发', link: 'development' },
{ text: '故障排查', link: 'troubleshooting' }
]
}
]
}
function sidebarReference(): DefaultTheme.SidebarItem[] {
return [
{
text: '参考',
items: [
{ text: '概览', link: '' },
{ text: '配置项', link: 'configuration' },
{ text: '命令与脚本', link: 'cli' },
{ text: 'API 约定', link: 'api' },
{ text: '仓库结构', link: 'repository' }
]
}
]
}
function sidebarDesign(): DefaultTheme.SidebarItem[] {
return [
{
text: '设计',
items: [
{ text: '产品边界', link: '' },
{ text: '系统架构', link: 'architecture' },
{ text: '发布模型', link: 'release-model' },
{ text: '开发约束', link: 'development' }
]
}
]
}
-242
View File
@@ -1,242 +0,0 @@
# OpenFlare 部署说明
本文档只保留 OpenFlare `1.0.0` 的当前部署基线、联调入口与升级方式。
## 1. 前置条件
### 1.1 Server
* Go 1.24+
* Node.js 18+
* 可写 SQLite 文件目录,或可访问的 PostgreSQL 实例
### 1.2 Agent
* Go 1.23+
* 对 Agent 数据目录有写权限
* 本机模式下可执行 `openresty -t` 与 `openresty -s reload`
* Docker 模式下具备 Docker 执行权限
## 2. 启动 Server
### 2.1 构建前端
```bash
cd openflare_server/web
corepack enable
pnpm install
pnpm build
```
`pnpm build` 会生成供 Go Server 托管的静态产物。
### 2.2 源码启动
```bash
cd openflare_server
export SESSION_SECRET='replace-with-random-string'
export SQLITE_PATH='./openflare.db'
export LOG_LEVEL='info'
# 可选:设置后优先使用 PostgreSQL。
# 如果 PostgreSQL 为空且本地 SQLite 文件存在,启动时会自动迁移数据。
# export DSN='postgres://openflare:secret@127.0.0.1:5432/openflare?sslmode=disable'
go run .
```
默认监听 `3000` 端口。
### 2.3 Docker Compose 启动
```yaml
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: openflare
POSTGRES_USER: openflare
POSTGRES_PASSWORD: replace-with-strong-password
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U openflare -d openflare"]
interval: 10s
timeout: 5s
retries: 5
openflare:
image: ghcr.io/rain-kl/openflare:latest
container_name: openflare
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
ports:
- "3000:3000"
environment:
SESSION_SECRET: replace-with-random-string
SQLITE_PATH: /data/openflare.db
DSN: postgres://openflare:replace-with-strong-password@postgres:5432/openflare?sslmode=disable
GIN_MODE: release
LOG_LEVEL: info
volumes:
- openflare-data:/data
volumes:
postgres-data:
openflare-data:
```
```bash
docker compose up -d
```
### 2.4 首次登录
访问 `http://localhost:3000`
默认账号:
* 用户名:`root`
* 密码:`123456`
### 2.5 Swagger
登录管理端后访问:`http://localhost:3000/swagger/index.html`
如需在本地重新生成文档:
```bash
go install github.com/swaggo/swag/cmd/swag@latest
cd openflare_server
swag init -g main.go -o docs
```
## 3. Agent 配置
当前支持两种接入模式。
### 3.1 使用节点专属 `agent_token`
```json
{
"server_url": "http://127.0.0.1:3000",
"agent_token": "replace-with-node-auth-token",
"data_dir": "./data",
"openresty_container_name": "openflare-openresty",
"openresty_docker_image": "openresty/openresty:alpine",
"openresty_observability_port": 18081,
"observability_replay_minutes": 15,
"heartbeat_interval": 10000,
"request_timeout": 10000
}
```
### 3.2 使用全局 `discovery_token`
```json
{
"server_url": "http://127.0.0.1:3000",
"discovery_token": "replace-with-global-discovery-token",
"data_dir": "./data",
"openresty_container_name": "openflare-openresty",
"openresty_docker_image": "openresty/openresty:alpine",
"openresty_observability_port": 18081,
"observability_replay_minutes": 15,
"heartbeat_interval": 10000,
"request_timeout": 10000
}
```
说明:
* `agent_token` 与 `discovery_token` 至少填写一个
* 未配置 `openresty_path` 时默认使用 Docker OpenResty
* Agent 会暴露本机观测端口并在 server 恢复后补传最近窗口数据
## 4. 启动 Agent
### 4.1 直接运行
```bash
cd openflare_agent
export LOG_LEVEL='info'
go run ./cmd/agent -config /path/to/agent.json
```
### 4.2 编译后二进制运行
```bash
cd openflare_agent
go build -o openflare-agent ./cmd/agent
export LOG_LEVEL='info'
./openflare-agent -config /path/to/agent.json
```
## 5. 最小联调步骤
1. 在管理端准备 `agent_token` 或 `discovery_token`
2. 启动 Agent 并确认节点上线
3. 新增一条启用中的反代规则
4. 生成并激活新版本
5. 确认 Agent 拉取配置、执行 `openresty -t`、reload 并上报结果
预期管理端可看到:
* 节点在线状态
* 节点当前版本
* 最近一次应用结果
* 自动注册后的专属 `agent_token`
## 6. 升级说明
* Root 用户可在管理端顶栏检查并升级 Server 正式版
* 如需尝试 preview 版本,可手动检查对应发布
* 节点 Agent 默认只跟随正式版自动更新;preview 升级需要手动触发
* 也可通过上传 Server 二进制的方式执行确认升级
## 7. 常用验证命令
### 7.1 Server
```bash
cd openflare_server
GOCACHE=/tmp/openflare-go-cache go test ./...
```
### 7.2 Agent
```bash
cd openflare_agent
GOCACHE=/tmp/openflare-go-cache go test ./...
```
### 7.3 Frontend
```bash
cd openflare_server/web
pnpm build
```
## 8. Agent 一键部署
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
--server-url http://your-server:3000 \
--discovery-token YOUR_DISCOVERY_TOKEN
```
支持参数:
* `--server-url`
* `--discovery-token`
* `--agent-token`
* `--install-dir`
* `--repo`
* `--no-service`
安装脚本会下载最新 Agent、生成 `agent.json`、创建 `openflare-agent.service` 并启动服务。
## 9. 文档维护要求
部署方式、升级方式、接入模式或联调流程变化时,同步更新本文档和 `README.md`。
-188
View File
@@ -1,188 +0,0 @@
# OpenFlare 设计基线
本文档定义 OpenFlare `1.0.0` 之后仍然有效的产品边界、系统结构与长期约束。第六版已经完成并并入正式版;过程性设计不再在这里维护。
## 1. 产品定位
OpenFlare 是一套自托管的 OpenResty 控制面,面向单团队或单组织内部运维场景,解决反向代理配置、节点同步、证书托管与基础观测的统一管理问题。
当前稳定能力包括:
* 反代规则管理
* 配置预览、发布、激活与回滚
* Agent 注册、心跳、同步、应用结果上报
* OpenResty 主配置模板、性能参数与缓存参数托管
* HTTPS/TLS 与域名资产管理
* 节点请求聚合、资源快照、健康事件与看板展示
* 节点管理、令牌体系、部署与更新链路
* 基于 Next.js 的正式管理端前端
默认工作方式:
* 所有节点消费同一份全局激活版本
* Server 保存配置与状态,不直接 SSH 管理节点
* Agent 是节点侧唯一受控落地入口
## 2. 范围边界
当前明确不做:
* 多租户
* CDN SaaS 化能力
* GeoDNS、全球调度、智能选路
* WAF、Bot 管理、限流平台化
* 灰度百分比发布、按节点差异化配置
* 对象存储、消息队列、Prometheus、ClickHouse、Kafka 等前置基础设施
* 通用日志检索平台、APM、调用链系统、任意 BI 报表
* 证书自动签发与自动续期
* 平台化抽象对象,如 `zone`、`origin_pool`、`policy`、`deployment`
边界补充:
* OpenResty 代理缓存只作为当前反代链路优化能力存在,不扩展为独立缓存产品
* 主配置文件由 Server 统一渲染并由 Agent 受控写入,不支持节点侧手工编辑后回传合并
* 节点观测聚焦运营与运维排障所需的摘要、趋势和受控窗口数据,不提供长期日志托管
新增能力如果超出上述边界,先更新本文档,再进入实现。
## 3. 技术基线
### 3.1 Server
`openflare_server` 继续作为单体控制面:
* Gin
* GORM
* SQLite / PostgreSQL
* 现有登录与 Session 体系
* 托管 `openflare_server/web` 静态构建产物
### 3.2 Agent
`openflare_agent` 继续作为 Go 单体程序:
* 单二进制
* 节点本地执行
* `openresty_path` 优先
* 未配置 `openresty_path` 时默认使用 Docker OpenResty
### 3.3 Frontend
`openflare_server/web` 是正式前端基线:
* Next.js App Router
* React 19
* TypeScript
* Tailwind CSS
* 静态导出后由 Go Server 托管
## 4. 总体架构
```text
OpenFlare Server (Gin + SQLite/PostgreSQL + Web UI)
|
| HTTP API / Config Pull
v
OpenFlare Agent (register / heartbeat / sync / apply / update)
|
v
Local OpenResty or Docker OpenResty
|
v
Origin
```
职责分工:
* Server 负责配置、版本、节点、设置、证书、管理端 UI 与聚合查询
* Agent 负责本地写入、校验、reload、回滚、自更新与轻量采集
* 发布通过“生成完整版本并激活”完成
* 历史版本不可变
* heartbeat 响应返回激活版本摘要,Agent 仅在不一致时拉取完整配置
## 5. 核心对象
当前有效实体:
* `proxy_routes`
* `config_versions`
* `nodes`
* `node_system_profiles`
* `apply_logs`
* `tls_certificates`
* `managed_domains`
* `node_request_reports`
* `node_access_logs`
* `node_metric_snapshots`
* `traffic_analytics_rollups`
* `node_health_events`
稳定约束:
* 一个域名只对应一个 `origin_url`
* `proxy_routes.origin_host` 为可选字段,用于回源时覆盖 `Host` 请求头;未设置时默认透传访问域名
* `proxy_routes.domain` 必须唯一
* `origin_url` 必须为合法 `http://` 或 `https://`
* `config_versions` 必须保存完整快照、渲染结果与 `checksum`
* 全局同时只能有一个激活版本
* 回滚通过重新激活旧版本实现
* `nodes` 只承载控制面状态与低频摘要,不承载高频观测事实
* 指标、趋势和访问分析优先使用服务端聚合结果,而不是前端临时统计
* 访问明细只保留受控时间窗口,不演变成通用日志平台
## 6. 发布模型
标准链路:
```text
修改规则 -> 预览/查看 diff -> 发布 -> 生成完整配置版本 -> 激活版本 -> Agent 拉取 -> 本地应用 -> 上报结果
```
发布规则:
1. 读取全部启用的 `proxy_routes`
2. 读取 Server 侧 OpenResty 主配置与结构化参数
3. 渲染完整 OpenResty 配置
4. 计算 `checksum`
5. 写入 `config_versions`
6. 切换激活版本
7. Agent 在后续 heartbeat 中发现并应用
版本号格式固定为 `YYYYMMDD-NNN`。
## 7. 模块边界
### 7.1 `openflare_server`
负责:
* 管理端 UI 与 API
* Agent API
* 配置渲染与版本发布
* 数据存储与聚合查询
* OpenResty 主配置模板、性能参数与缓存参数管理
### 7.2 `openflare_agent`
负责:
* 首次注册与凭证置换
* 周期性心跳与同步
* 主配置、路由配置、证书与 Lua 资源写入
* 执行 `openresty -t` / `openresty -s reload`
* 失败回滚
* 节点观测采集与结果上报
### 7.3 `openflare_server/web`
负责:
* 管理端页面、布局、交互与主题
* 总览、节点详情、规则、版本、节点、证书、域名、用户与设置页面
* 统一请求层与前端状态管理
## 8. 文档维护原则
* 产品范围或系统边界变化时更新本文档
* 已完成阶段不再以“版本计划”形式回填
* 新阶段开始前,先补设计,再进入实现
+144
View File
@@ -0,0 +1,144 @@
# 系统架构
你会学到:OpenFlare 的整体架构、Server、Agent、OpenResty 与管理端前端的职责边界,以及一次配置发布从管理端到节点生效的请求流。
OpenFlare 由 Server、Agent、节点本地 OpenResty 和管理端前端组成。Server 是控制面,Agent 是节点侧唯一受控落地入口,OpenResty 是实际数据面。
```text
Browser
|
| Management UI / API
v
OpenFlare Server (Gin + GORM + SQLite/PostgreSQL)
|
| Agent API / heartbeat / config pull
v
OpenFlare Agent
|
| write config / openresty -t / reload / rollback
v
OpenResty binary
|
| reverse proxy
v
Origin
```
## 组件职责
| 组件 | 职责 |
| --- | --- |
| Server | 管理端 UI、管理 API、Agent API、配置渲染、版本发布、数据存储与聚合查询 |
| Agent | 注册、心跳、同步、写入文件、校验、reload、失败回滚、自更新与轻量采集 |
| OpenResty | 接收真实流量,按 OpenFlare 渲染的配置执行 WAF、PoW、认证与反向代理 |
| Frontend | 管理网站配置、WAF、源站、证书、节点、版本、用户、设置与观测页面 |
## Server
`openflare_server` 是单体控制面:
* Gin 提供 HTTP 服务。
* GORM 访问 SQLite 或 PostgreSQL。
* 现有登录体系提供管理端 Session。
* 认证源与外部账号绑定支持 GitHub OAuth 和标准 OIDC。
* Go Server 托管 `openflare_server/web` 静态构建产物。
Server 不直接 SSH 到节点,也不在线修改节点文件。它只保存控制面状态、生成完整配置版本,并通过 Agent API 让节点主动拉取。
## Agent
`openflare_agent` 是 Go 单体程序:
* 单二进制运行在节点侧。
* 启动后读取或生成本地节点信息。
* 周期性 heartbeat,上报状态并获取激活版本摘要。
* 发现新版本后拉取配置、备份旧文件、写入新文件、校验并 reload。
* 应用失败时尝试恢复运行并回滚。
* 维护 WAF GeoIP mmdb,启动时写入内置初始库,并按配置定期更新。
Agent 通过 `openresty_path` 指向的 OpenResty 二进制统一执行校验、reload、启动与重启;未配置时默认调用 `openresty`。Docker 部署时,Agent 镜像内置 OpenResty 二进制,仍走同一套二进制控制逻辑。
## Frontend
`openflare_server/web` 是正式管理端前端:
* Next.js App Router。
* React 19。
* TypeScript。
* Tailwind CSS。
* TanStack Query 管理服务端状态。
前端静态导出后由 Go Server 托管。所有 API 请求应统一经过 `lib/api/`,并处理 `success/message/data` 响应结构。
## 数据与请求流
### 管理端请求流
```text
Browser -> Frontend -> /api/* -> controller -> service -> model -> database
```
管理端变更类接口使用 `POST`,只读接口使用 `GET`。成功与失败都返回清晰的 `message`。
### Agent 同步流
```text
Agent heartbeat -> Server 返回激活版本摘要
Agent 发现新版本 -> 拉取配置详情
Agent 写入主配置 / 路由配置 / 证书 / Lua 资源 / WAF 运行时配置
Agent 执行 OpenResty 校验与 reload
Agent 上报应用结果
```
默认启用 WS 连接升级时,Agent 会先通过 HTTP heartbeat 获取设置,随后尝试连接 Agent WebSocket。WS 成功后,周期性状态上报改由 WS 承载;Server 发布或激活版本后会向已连接 Agent 广播激活版本摘要,使 Agent 立即进入既有同步流程。WS 断开或建立失败时,Agent 自动退回 HTTP heartbeat。
### 反向代理流
```text
Client -> OpenResty server block -> WAF Lua -> named upstream -> Origin
```
网站配置是反向代理聚合边界。一条网站配置可绑定多个域名,并共享站点级流量限制、反向代理和缓存配置。
WAF 在 OpenResty `access_by_lua_file` 阶段执行。规则来自当前激活版本携带的 `waf_config.json`,全局规则组默认生效,网站可叠加自定义规则组。
## 核心对象
当前有效实体包括:
* `proxy_routes`
* `origins`
* `config_versions`
* `nodes`
* `auth_sources`
* `external_accounts`
* `node_system_profiles`
* `apply_logs`
* `tls_certificates`
* `managed_domains`
* `node_request_reports`
* `node_access_logs`
* `node_metric_snapshots`
* `traffic_analytics_rollups`
* `node_health_events`
* `waf_rule_groups`
* `waf_rule_group_bindings`
## 关键设计决策
| 决策 | 原因 |
| --- | --- |
| 完整配置版本,而不是在线 patch | 让预览、激活、历史和回滚有稳定边界 |
| Agent 主动拉取 | Server 不需要 SSH 权限,也不暴露远程命令入口 |
| 全局单激活版本 | 降低 MVP 复杂度,保证所有节点默认一致 |
| 网站配置聚合多域名 | 支持一个业务站点共享站点级策略,同时允许按域名绑定证书 |
| 观测数据服务端聚合 | 避免前端临时统计造成口径不一致 |
## 贡献者阅读建议
如果要修改架构相关代码,先阅读:
1. [产品边界](./index.md)
2. [发布模型](./release-model.md)
3. [开发约束](./development.md)
4. [仓库结构](../reference/repository.md)
+303
View File
@@ -0,0 +1,303 @@
# 开发约束
你会学到:OpenFlare 代码修改的准入标准、后端/Agent/前端分层约束、数据模型边界、API 约定、数据库迁移要求和测试交付基线。
本文档融合原开发规范、前端规范与开发计划,是 OpenFlare `1.0.0` 之后的工程约束入口。
## 当前结论
* 第一版至第六版的主线能力已经全部完成。
* `1.0.0` 是当前正式基线。
* 已完成阶段的过程性任务以代码、测试与 Git 历史为准。
* 新工作优先以缺陷修复、可维护性改进、文档与测试补强为主。
当前开发优先级:
1. 稳定性。
2. 升级与回滚链路可靠性。
3. 文档准确性。
4. 测试覆盖补强。
5. 在既有边界内的小步迭代。
## 变更准入
新需求进入实现前,按以下顺序判断:
1. 是否符合 [产品边界](./)。
2. 是否符合本文档的后端、Agent 与前端约束。
3. 是否会破坏现有发布、同步、回滚或升级主链路。
4. 是否需要同步更新部署、配置、README 或文档站页面。
如果需求超出边界或引入新基础设施,应先更新设计文档,再开始实现。
任何合入正式基线的改动,至少应满足:
* 不破坏 Agent 心跳、同步、发布与回滚主链路。
* 不破坏现有 OpenResty 主配置托管模型。
* 不降低总览、节点详情与访问分析的既有可用性。
* 有与风险相称的测试或联调验证。
* 文档与代码保持一致。
## 技术基线
Server:
* Go 1.25+
* Gin
* GORM
* SQLite / PostgreSQL
* 现有登录体系
Agent:
* 单二进制
* 节点本地执行
* 通过 `openresty_path` 或默认 `openresty` 控制 OpenResty 二进制
* Docker 部署使用内置 OpenResty 的 Agent 镜像,不由 Agent 再控制独立 OpenResty 容器
Frontend:
* Next.js 15 App Router
* React 19
* TypeScript 5
* Tailwind CSS 4
* TanStack Query
* React Hook Form + Zod
* Zustand 仅用于轻量客户端状态
* ESLint + Prettier
* Vitest + Testing Library + Playwright
* pnpm
## Server 分层
| 目录 | 职责 |
| --- | --- |
| `controller/` | 参数解析、调用 service、返回响应 |
| `service/` | 业务逻辑、校验、事务编排、渲染 |
| `model/` | 模型定义与持久化 |
| `router/` | 路由注册 |
| `middleware/` | 认证、鉴权、限流等横切逻辑 |
| `common/` | 配置、全局状态与初始化入口 |
| `utils/` | 纯工具函数与通用 helper |
禁止在 `controller/` 堆积业务逻辑,禁止在 `middleware/` 实现业务流程,禁止为简单需求新增平台层抽象。
## Agent 分层
Agent 保持现有模块边界:
* `config`
* `heartbeat`
* `sync`
* `openresty` / `nginx`
* `state`
* `httpclient`
* `protocol`
* `internal/updater`
要求:
* 每个模块职责单一。
* 外部命令调用集中封装。
* 状态落盘与配置落盘分离。
## Frontend 分层
推荐目录:
```text
app/
components/
features/
lib/
hooks/
store/
types/
styles/
tests/
```
职责约束:
* `app/`:路由、布局、页面组装。
* `features/`:按业务域组织模块。
* `components/`:跨 feature 复用组件。
* `lib/`:请求客户端、环境变量、工具函数、常量。
* `store/`:少量跨页面 UI 状态。
* `types/`:共享类型定义。
页面文件只负责获取路由参数、组织页面结构、调用 feature 组件;不应手写复杂 API 细节、复杂表单校验逻辑或维护大量彼此耦合的局部状态。
## 数据模型规范
当前有效实体:
* `proxy_routes`
* `origins`
* `config_versions`
* `nodes`
* `auth_sources`
* `external_accounts`
* `node_system_profiles`
* `apply_logs`
* `tls_certificates`
* `managed_domains`
* `node_request_reports`
* `node_access_logs`
* `node_metric_snapshots`
* `traffic_analytics_rollups`
* `node_health_events`
* `options`
* `waf_rule_groups`
* `waf_rule_group_bindings`
通用约束:
* 不新增平台化对象,除非设计文档明确要求。
* `origins` 仅作为可复用源站地址目录,字段保持轻量。
* `proxy_routes` 以“网站配置”作为聚合边界,必须包含唯一 `site_name` 与非空 `domains` 列表。
* `proxy_routes.domains` 中的每个域名都必须全局唯一,列表第一项视为主域名。
* `proxy_routes` 继续允许保存一个或多个上游地址用于负载均衡,但不引入独立 `origin_pool`。
* 遗留 `domain` 字段只能作为 `domains[0]` 的兼容镜像;新代码不得继续以该字段作为唯一业务输入。
* `proxy_routes` 如关联 `origins`,必须同时保存可直接渲染的 `origin_url`。
* 上游统一使用 named `upstream` + keepalive;单上游如带 base path 或 query,应在 `proxy_pass` 上补回 URI,多上游仅允许纯 `scheme://host[:port]`。
* 流量限制、反向代理与缓存配置当前都归属站点级 `proxy_routes`。
* HTTPS 证书绑定必须通过与 `domains` 平行的 `domain_cert_ids` 逐域名保存;未绑定证书的域名不得参与 HTTPS 渲染。
* WAF 全局规则组默认应用到所有网站,自定义规则组通过 `waf_rule_group_bindings` 绑定到网站配置;发布时必须进入完整版本快照。
* `config_versions` 必须保存完整快照与渲染结果。
* 全局同时只能有一个激活版本。
* 回滚通过重新激活旧版本实现。
* `nodes` 只保留控制面状态与低频摘要。
* 观测数据必须按节点与时间窗口关联,快照与聚合结果采用追加式模型。
* 原始访问明细必须有受控保留策略。
* `auth_sources` 仅保存管理端第三方登录源配置,当前支持 `github` 与 `oidc`。
* `external_accounts` 是第三方账号与本地用户的唯一绑定来源;旧 `users.github_id` 仅用于兼容迁移,不得作为新登录流程的业务输入。
## 数据库迁移
任何涉及表结构、索引、列类型、分表规则或内部持久化元数据的修改,都必须同步提升数据库版本号。
数据库版本号定义在 `openflare_server/model`,不得只依赖 `AutoMigrate` 隐式升级存量数据库。
每次提升数据库版本号时,必须补充从上一版本升级到新版本的显式迁移方法。迁移方法必须包含升级后的校验逻辑;只有校验通过,才能写入新的数据库版本记录。
新包启动后必须先检查数据库当前版本,再按顺序逐步升级到目标版本;禁止跳过中间升级步骤直接写目标版本。
空库初始化可以直接建立当前版本结构,但初始化完成后仍必须执行同版本校验,并落库当前数据库版本。
如果迁移失败或校验失败,启动流程必须中止,且不得提升数据库版本记录。涉及数据库版本变更的提交,必须补充对应的迁移测试或等效回归测试。
## API 与鉴权
管理端与 Agent API 统一使用 JSON。成功与失败都必须返回清晰 `message`:
```json
{
"success": true,
"message": "",
"data": {}
}
```
约定:
* Agent API 固定放在 `/api/agent/*`。
* 总览与节点详情优先使用专用聚合接口。
* 管理端变更类接口统一使用 `POST`;只读接口使用 `GET`。
* 管理端继续复用现有登录、角色与 Session。
* 第三方登录统一通过认证源 API 进入,认证源管理接口必须要求 Root Session。
* `/api/status` 只能返回已启用认证源的公开字段,不得返回 Client Secret。
* 第三方账号未绑定且注册关闭时,应提供绑定已有账号流程,不得自动创建用户。
* Agent 正式请求统一使用节点专属 `agent_token`。
* 首次接入可使用全局 `discovery_token`。
* Agent 请求头统一使用 `X-Agent-Token`。
禁止暴露远程 shell 或任意命令执行入口,禁止在日志中打印完整 Token,禁止绕过占位符约束保存不可渲染的主配置模板。
## 发布与运行
发布逻辑必须保持:
* 发布时读取全部启用的 `proxy_routes`。
* 同时读取 OpenResty 主配置参数、反代性能参数与缓存参数。
* 生成完整 OpenResty 配置。
* 计算 `checksum`。
* 写入 `config_versions`。
* 通过切换 `is_active` 激活版本。
版本约束:
* 版本号格式固定为 `YYYYMMDD-NNN`。
* 不在线修改历史版本。
* 不做按节点分组的差异化版本。
* 预览与 diff 是只读能力,不产生发布记录。
Agent 必须满足:
* 启动后读取或生成本地 `node_id`。
* 周期性心跳与同步。
* 常规同步优先依据 heartbeat 返回的版本摘要判断。
* WS 连接升级开启且连接成功时,Agent 可通过 WS 接收激活版本摘要并立即同步;WS 失败或断开必须退回 HTTP heartbeat。
* 发现新版本时先备份旧文件。
* 写入主配置、路由配置与必要证书文件。
* 写入 WAF/PoW 运行时配置,并确保 WAF Lua 资源由 Agent 统一管理。
* 写入新配置后执行 `openresty -t -c <main_config_path>`,再 reload;reload 发现运行时未启动时允许直接启动 OpenResty。
* 周期性运行时健康检查不得调用 `openresty -t`,避免健康探针触发 upstream 域名同步解析;应优先请求本地 `openresty_observability_port` 上的 `/openflare/stub_status`,以 HTTP `200 OK` 作为 OpenResty 主进程和 worker 正在提供服务的判断依据。
* 新配置激活失败时必须先尝试用目标配置恢复运行,再回滚到旧配置并重新拉起 OpenResty。
* 回滚后 OpenResty 恢复正常时上报警告;如果本地没有历史主配置可恢复,必须允许写入内置安全兜底配置并拉起对外只监听 `80` 端口、统一返回 `503` 的 OpenResty 运行态;兜底配置仍需保留本地 `stub_status` 健康检查入口。
* 兜底运行态不得清除失败目标的阻断状态;应用记录必须能体现目标版本失败但 fallback runtime 已启动。存在历史主配置但回滚后仍无法恢复运行时上报失败。
* 某个目标 `version + checksum` 一旦应用失败并回退,Agent 必须在本地状态中阻断该目标的重复应用。
* Agent 维护本地 MaxMind mmdb 时,下载或刷新失败只能记录警告,不得阻断心跳、同步、配置应用或 OpenResty 健康检查。
## 前端请求、状态与类型
所有 API 请求必须统一经过 `lib/api/`:
* 统一处理 `success/message/data` 响应结构。
* 统一处理鉴权失效、网络异常和通用错误消息。
* 统一维护资源接口与请求路径。
状态分层:
* 服务端状态:TanStack Query。
* 页面临时状态:组件内部 `useState`。
* 跨页面 UI 状态:Zustand。
要求开启 TypeScript 严格模式,禁止滥用 `any`,API 响应、表单输入、业务实体必须有明确类型。
## 表单、交互、样式与主题
表单统一使用 React Hook Form 与 Zod。
高风险操作必须二次确认、展示操作对象名称,并明确成功与失败反馈。
样式原则:
* 统一使用 Tailwind CSS 与现有 token 体系。
* 优先复用已有基础组件与布局组件。
* 保持视觉层级、留白与语义颜色一致。
主题要求:
* 同时支持 `light`、`dark`、`system`。
* 用户选择必须持久化。
* 首屏尽量避免主题闪烁。
## 测试与交付
* 关键业务逻辑必须有单元测试或等效回归测试。
* Agent 主链路修改必须验证同步、应用与回滚。
* 前端页面至少覆盖加载态、空态、错误态与成功反馈。
* Go 版本调整时,同步检查 `go.mod`、Dockerfile 与 CI 工作流。
## 后续维护方式
后续规划不再按“大版本阶段文档”维护,而采用以下方式:
* 产品边界变动:更新 [产品边界](./)。
* 工程约束变动:更新本文档。
* 部署与配置变动:更新 [部署说明](../guide/deployment.md)、[配置项](../reference/configuration.md) 与 README。
如果未来出现明确的新阶段目标,再单独新增专项计划文档;不要把已完成的历史计划继续堆回本文档。
当前专项“网站级规则与配置界面改造”的模型边界已纳入 [产品边界](./),执行时仍按数据模型、接口、前端页面、迁移测试与文档联动的顺序推进。
+171
View File
@@ -0,0 +1,171 @@
# 产品边界
你会学到:OpenFlare 是什么、解决什么问题、目标用户是谁、当前稳定能力有哪些,以及哪些设计边界在实现时不能被绕过。
OpenFlare 是一套自托管的 OpenResty 控制面,面向单团队或单组织内部运维场景。它解决反向代理配置、节点同步、证书托管、配置发布回滚与基础观测分散管理的问题。
## 项目定位
OpenFlare 适合需要统一管理多台 OpenResty 代理节点的团队:
* 希望用管理端维护反向代理网站配置。
* 希望每次配置变更都有完整版本、预览、激活与回滚。
* 希望节点主动同步配置,而不是由控制面 SSH 到节点执行命令。
* 希望在同一系统中管理 TLS 证书、域名资产、节点状态和基础访问分析。
OpenFlare 当前不定位为通用日志平台、服务网格、Kubernetes Ingress Controller 或多租户云平台。
## 目标用户
| 用户 | 需求 |
| --- | --- |
| 自托管用户 | 快速部署一个可视化 OpenResty 控制面 |
| 内部运维团队 | 管理多个反向代理节点、证书和配置版本 |
| 开发团队 | 为内部服务提供统一入口和基础访问分析 |
| 贡献者 | 在明确边界内修复缺陷、补强测试和改进文档 |
## 当前稳定能力
| 能力 | 说明 |
| --- | --- |
| 反代规则管理 | 以网站配置为聚合边界,支持多域名与源站配置 |
| 网站级配置 | 一条规则对应一个网站,可绑定一个或多个域名,并共享站点级配置 |
| 源站管理 | 维护轻量源站目录,并允许网站保存可渲染的源站快照 |
| 配置版本 | 支持预览、发布、激活、不可变历史与回滚 |
| Agent 同步 | 支持注册、心跳、同步、应用结果上报与自更新 |
| OpenResty 托管 | 管理主配置模板、性能参数、缓存参数与 Lua 资源 |
| HTTPS/TLS | 托管证书与域名资产,并按域名绑定证书 |
| WAF | 以全局规则组与网站自定义规则组维护 IP/IP 段、国家级地域黑白名单 |
| 基础观测 | 聚合节点请求、资源快照、健康事件和访问分析 |
| 节点管理 | 节点状态、令牌体系、部署与更新链路 |
| 管理端前端 | 基于 Next.js 的正式管理端 |
| 认证源登录 | 支持以认证源形式配置 GitHub 与标准 OIDC 登录入口,并允许第三方账号绑定已有本地用户 |
默认工作方式:
* 所有节点消费同一份全局激活版本。
* Server 保存配置与状态,不直接 SSH 管理节点。
* Agent 是节点侧唯一受控落地入口。
## 典型使用场景
| 场景 | 说明 |
| --- | --- |
| 内部服务统一入口 | 把多个内部 HTTP 服务通过统一域名和证书暴露 |
| 多节点反代配置同步 | 多台 OpenResty 节点消费同一份激活配置 |
| 配置变更审查 | 发布前查看预览或 diff,发布后保留不可变历史 |
| 快速回滚 | 重新激活旧版本,让 Agent 拉取并应用 |
| 证书托管 | 为不同域名绑定 TLS 证书 |
| 基础观测 | 查看节点状态、请求聚合、访问分析和健康事件 |
## 核心对象
当前有效实体:
* `proxy_routes`
* `origins`
* `config_versions`
* `nodes`
* `auth_sources`
* `external_accounts`
* `node_system_profiles`
* `apply_logs`
* `tls_certificates`
* `managed_domains`
* `node_request_reports`
* `node_access_logs`
* `node_metric_snapshots`
* `traffic_analytics_rollups`
* `node_health_events`
* `waf_rule_groups`
* `waf_rule_group_bindings`
## 网站配置约束
`proxy_routes` 从“单域名规则”升级为“网站配置”聚合对象。一条记录对应一个网站,可绑定一个或多个域名,并共享一组站点级配置。
约束:
* `proxy_routes.site_name` 是网站的业务唯一标识。
* `proxy_routes.domains` 至少包含一个域名,且 `domains[0]` 作为主域名。
* 任一域名全局只能属于一个 `proxy_routes`。
* 迁移期可保留 `proxy_routes.domain` 作为 `domains[0]` 的镜像字段,但业务读写与后续扩展必须以 `site_name` + `domains` 为准。
* 网站级流量限制、反向代理与缓存配置当前按站点共享,不在同一网站内做域名级差异化配置。
* HTTPS 允许在同一站点内按域名绑定证书。
## 源站约束
`origins` 只保存源站地址、展示名与备注,不承载协议、端口、路径、权重或健康检查策略。
`proxy_routes` 可选关联一个 `origins` 记录,用于复用源站地址;规则仍保存完整 `origin_url` 快照以参与渲染与版本快照。
上游约束:
* `proxy_routes` 至少包含一个上游地址。
* 为兼容历史数据保留 `origin_url` 主上游字段,也允许在同一规则内补充多个上游做负载均衡。
* 上游统一渲染为带 keepalive 的 named `upstream`。
* 单上游可附带 base path 或 query 并在 `proxy_pass` 中追加。
* 多上游限定为纯 `scheme://host[:port]`。
* `proxy_routes.origin_host` 为可选字段,用于回源时覆盖 `Host` 请求头。
* 所有上游地址都必须为合法 `http://` 或 `https://`。
## HTTPS 约束
`proxy_routes.domain_cert_ids` 用于记录与 `domains` 平行的域名证书绑定;值为 `0` 表示该域名不启用 HTTPS,仅保留 HTTP。
发布渲染时:
* 带证书的域名按证书分组输出独立 `443 ssl` `server` 块。
* 未绑定证书的域名不得被自动带入 HTTPS。
* 必须将 `proxy_routes.domains` 中的全部域名一并纳入同一站点配置,避免同站点在版本快照中被拆散。
## WAF 约束
WAF 以规则组为配置边界。系统固定一个全局规则组,默认应用到所有网站;网站可叠加多个自定义规则组。
一期支持:
* IP / IP 段白名单与黑名单。
* 国家级地域白名单与黑名单。
* 规则组级拦截状态码与响应页面,默认 `418` 与空页面。
判定顺序:
* 白名单是放行例外,任意启用规则组命中白名单即放行。
* 未命中白名单时继续判断黑名单。
* 多个黑名单命中时,全局规则组优先,其后按自定义规则组 ID 升序。
地域识别由 Agent 维护节点本地 MaxMind mmdb,OpenResty Lua 在请求路径中读取本地库。GeoIP 依赖不可用时只能跳过地域规则,不得影响 IP 规则与反向代理主链路。
## 认证源约束
`auth_sources` 是管理端第三方登录入口的配置对象,当前仅支持 `github` 与 `oidc` 两类。启用后的认证源会显示在登录页。
`external_accounts` 保存认证源外部账号与本地用户的绑定关系。第三方账号首次登录时:
* 已绑定本地用户则直接登录。
* 当前已有本地登录 Session 时,绑定到当前用户。
* 未绑定且允许注册时,自动创建普通用户并绑定。
* 未绑定且关闭注册时,只允许用户输入已有本地账号密码完成绑定。
旧 `users.github_id` 仅作为升级迁移来源,新的第三方账号登录与绑定关系必须以 `external_accounts` 为准。
## 版本与观测约束
* `config_versions` 必须保存完整快照、渲染结果与 `checksum`。
* 全局同时只能有一个激活版本。
* 回滚通过重新激活旧版本实现。
* `nodes` 只承载控制面状态与低频摘要,不承载高频观测事实。
* 指标、趋势和访问分析优先使用服务端聚合结果,而不是前端临时统计。
* 访问明细只保留受控时间窗口,不演变成通用日志平台。
## 文档维护原则
* 产品范围或系统边界变化时更新本文档。
* 系统结构或模块职责变化时更新 [系统架构](./architecture.md)。
* 发布、同步、回滚模型变化时更新 [发布模型](./release-model.md)。
* 开发约束、代码规范、接口约定变化时更新 [开发约束](./development.md)。
* 部署方式变化时更新 [部署说明](../guide/deployment.md) 与 README。
* 配置项变化时更新 [配置项参考](../reference/configuration.md)。
* 已完成阶段不再以“版本计划”形式回填。
* 新阶段开始前,先补设计,再进入实现。
+73
View File
@@ -0,0 +1,73 @@
# 发布模型
你会学到:OpenFlare 为什么以完整配置版本为发布单位,发布、激活、Agent 应用和回滚分别如何工作。
OpenFlare 的发布模型以完整配置版本为中心,而不是在线修改节点配置。
标准链路:
```text
修改规则 -> 预览 / 查看 diff -> 发布 -> 生成完整配置版本 -> 激活版本 -> Agent 拉取 -> 本地应用 -> 上报结果
```
## 发布规则
Server 发布时必须:
1. 读取全部启用的 `proxy_routes`。
2. 读取 Server 侧 OpenResty 主配置、性能参数、缓存参数和必要 Lua 资源。
3. 读取域名与证书绑定关系。
4. 读取 WAF 全局规则组、自定义规则组与网站绑定关系。
5. 渲染完整 OpenResty 配置与 WAF 运行时配置。
6. 计算 `checksum`。
7. 写入 `config_versions`。
8. 切换激活版本。
9. 让 Agent 在后续 heartbeat 中发现并应用。
版本号格式固定为 `YYYYMMDD-NNN`。
## 预览与发布
预览和 diff 是只读能力,不产生发布记录。
发布会生成新的完整配置版本。版本必须包含足够信息,让未来回滚时可以基于历史快照重新应用,而不依赖当前可变配置。
## 激活版本
全局同时只能有一个激活版本。当前不做按节点分组的差异化版本。
Agent 通过 heartbeat 获取激活版本摘要;当远端版本或 checksum 与本地状态不一致时,Agent 才进入同步流程。当 Agent WS 连接升级开启且连接可用时,Server 在发布或激活版本成功后会广播最新激活版本摘要,Agent 收到后复用普通同步流程立即拉取并应用配置。WS 不可用时仍按 HTTP heartbeat 间隔发现变更。
## 不可变历史
历史版本不可变。回滚不是修改旧版本,而是重新激活旧版本。
这样做的结果是:
* 每个版本都可以追溯。
* 回滚链路与普通发布应用链路一致。
* Agent 不需要理解“反向 patch”,只需要应用一个目标版本。
## Agent 应用策略
Agent 发现新版本后会:
1. 拉取目标版本详情。
2. 备份旧文件。
3. 写入主配置、路由配置、证书、必要 Lua 资源与 WAF/PoW 运行时配置。
4. 执行 OpenResty 配置校验。
5. reload;如果运行时未启动,则尝试用当前配置启动 OpenResty。
6. 上报成功、警告或失败。
如果新配置激活失败,Agent 必须尝试恢复运行;回滚成功时上报警告。若本地没有历史主配置可回滚,Agent 会写入内置安全兜底配置并尝试拉起 OpenResty:该配置对外只监听 `80` 端口,不包含任何用户路由,统一返回 `503 Service Unavailable` 与 `OpenFlare: No Valid Configuration`,同时保留本地 `stub_status` 健康检查入口。兜底启动成功时仍阻断失败目标版本并上报警告;存在历史主配置但回滚后仍无法恢复运行时上报失败。
某个目标 `version + checksum` 一旦应用失败并回退,Agent 会在本地状态中阻断该目标重复应用。只有远端激活版本或 checksum 发生变化,才允许再次尝试。
## 设计约束
* 发布必须读取全部启用的网站配置,而不是只渲染本次修改对象。
* 回滚通过重新激活旧版本实现,不修改历史版本。
* Agent API 固定使用节点专属 `agent_token`,首次接入可使用 `discovery_token`。
* Server 不提供远程 shell 或任意命令执行入口。
* 配置版本必须保存完整快照、渲染结果和 `checksum`。
* WAF 规则组和网站绑定关系必须随完整配置版本进入快照与 checksum,回滚时不得依赖当前可变 WAF 配置。
-209
View File
@@ -1,209 +0,0 @@
# OpenFlare 开发规范
本文档描述 OpenFlare `1.0.0` 正式版之后的开发基线。
超出 [docs/design.md](./design.md) 边界的需求,必须先更新设计文档。
## 1. 技术基线
### 1.1 Server
`openflare_server` 继续作为单体控制面:
* Go 1.24+
* Gin
* GORM
* SQLite / PostgreSQL
* 现有登录体系
### 1.2 Agent
`openflare_agent` 继续作为 Go 单体程序:
* Go 1.23+
* 单二进制
* 节点本地执行
* `openresty_path` 优先
* 无 `openresty_path` 时默认 Docker OpenResty
### 1.3 Frontend
前端基线以 `openflare_server/web` 为准:
* Next.js 15 App Router
* React 19
* TypeScript
* Tailwind CSS 4
* TanStack Query
* React Hook Form + Zod
* Zustand 仅用于轻量客户端状态
前端细则见 [docs/frontend-development-guidelines.md](./frontend-development-guidelines.md)。
## 2. 分层与目录约束
### 2.1 Server
* `controller/`:参数解析、调用 service、返回响应
* `service/`:业务逻辑、校验、事务编排、渲染
* `model/`:模型定义与持久化
* `router/`:路由注册
* `middleware/`:认证、鉴权、限流等横切逻辑
* `common/`:配置、全局状态与初始化入口
* `utils/`:纯工具函数与通用 helper
禁止:
* 在 `controller/` 堆积业务逻辑
* 在 `middleware/` 实现业务流程
* 为简单需求新增平台层抽象
### 2.2 Agent
保持现有模块边界:
* `config`
* `heartbeat`
* `sync`
* `openresty`
* `state`
* `httpclient`
* `protocol`
* `internal/updater`
要求:
* 每个模块职责单一
* 外部命令调用集中封装
* 状态落盘与配置落盘分离
### 2.3 Frontend
前端分层保持:
* `app/`
* `features/`
* `components/`
* `lib/`
* `store/`
* `types/`
要求:
* 页面路由与布局放在 `app/`
* API 请求统一收敛到 `lib/api/`
* 业务逻辑优先放在 `features/`
## 3. 数据模型规范
当前有效实体:
* `proxy_routes`
* `config_versions`
* `nodes`
* `node_system_profiles`
* `apply_logs`
* `tls_certificates`
* `managed_domains`
* `node_request_reports`
* `node_access_logs`
* `node_metric_snapshots`
* `traffic_analytics_rollups`
* `node_health_events`
* `options`
通用约束:
* 不新增平台化对象,除非设计文档明确要求
* `proxy_routes` 维持一条域名对应一个 `origin_url`
* `proxy_routes.origin_host` 为可选字段,仅用于覆盖回源 `Host` 请求头,不引入新的平台化对象
* `config_versions` 必须保存完整快照与渲染结果
* 全局同时只能有一个激活版本
* 回滚通过重新激活旧版本实现
* `nodes` 只保留控制面状态与低频摘要
* 观测数据必须按节点与时间窗口关联
* 快照与聚合结果采用追加式模型,不覆盖历史
* 原始访问明细必须有受控保留策略
## 4. API 与鉴权规范
### 4.1 API
* 管理端与 Agent API 统一使用 JSON
* 成功与失败都必须返回清晰 `message`
* Agent API 固定放在 `/api/agent/*`
* 总览与节点详情优先使用专用聚合接口
* 管理端变更类接口统一使用 `POST`;只读接口使用 `GET`
统一响应结构:
```json
{
"success": true,
"message": "",
"data": {}
}
```
### 4.2 鉴权
管理端:
* 继续复用现有登录、角色与 Session
Agent:
* 正式请求统一使用节点专属 `agent_token`
* 首次接入可使用全局 `discovery_token`
* 请求头统一使用 `X-Agent-Token`
禁止:
* 暴露远程 shell 或任意命令执行入口
* 在日志中打印完整 Token
* 允许绕过占位符约束保存不可渲染的主配置模板
## 5. 发布与运行规范
发布逻辑必须保持以下事实:
* 发布时读取全部启用的 `proxy_routes`
* 同时读取 OpenResty 主配置参数、反代性能参数与缓存参数
* 生成完整 OpenResty 配置
* 计算 `checksum`
* 写入 `config_versions`
* 通过切换 `is_active` 激活版本
版本约束:
* 版本号格式固定为 `YYYYMMDD-NNN`
* 不在线修改历史版本
* 不做按节点分组的差异化版本
* 预览与 diff 是只读能力,不产生发布记录
Agent 必须满足:
* 启动后读取或生成本地 `node_id`
* 周期性心跳与同步
* 常规同步优先依据 heartbeat 返回的版本摘要判断
* 发现新版本时先备份旧文件
* 写入主配置、路由配置与必要证书文件
* 写入新配置后以运行态恢复为目标执行激活,Docker 模式优先重建容器并确认容器保持运行
* 新配置激活失败时必须先尝试用目标配置恢复运行,再回滚到旧配置并重新拉起 OpenResty
* 回滚后 OpenResty 恢复正常时上报警告;回滚后仍无法恢复运行时上报失败
## 6. 测试与交付要求
* 关键业务逻辑必须有单元测试或等效回归测试
* Agent 主链路修改必须验证同步、应用与回滚
* 前端页面至少覆盖加载态、空态、错误态与成功反馈
* Go 版本调整时,同步检查 `go.mod`、Dockerfile 与 CI 工作流
## 7. 文档维护要求
当以下内容变化时,必须同步更新对应文档:
* 产品范围或系统边界变化:更新 `docs/design.md`
* 开发约束、接口约定、测试基线变化:更新本文档
* 前端工程约束变化:更新 `docs/frontend-development-guidelines.md`
* 配置项或部署方式变化:更新 `docs/app-config.md`、`docs/deployment.md` 与 `README.md`
-50
View File
@@ -1,50 +0,0 @@
# OpenFlare 开发计划
## 1. 当前结论
* 第一版至第六版的主线能力已经全部完成
* `1.0.0` 是当前正式基线
* 已完成阶段的过程性任务以代码、测试与 Git 历史为准
* 新工作优先以缺陷修复、可维护性改进、文档与测试补强为主
## 2. 当前优先级
当前开发应优先关注:
1. 稳定性
2. 升级与回滚链路可靠性
3. 文档准确性
4. 测试覆盖补强
5. 在既有边界内的小步迭代
## 3. 变更准入原则
新需求进入实现前,按以下顺序判断:
1. 是否符合 [docs/design.md](./design.md) 的产品边界
2. 是否符合 [docs/development-guidelines.md](./development-guidelines.md) 与前端规范
3. 是否会破坏现有发布、同步、回滚或升级主链路
4. 是否需要同步更新部署、配置或 README 文档
如果答案包含“超出边界”或“引入新基础设施”,先修改设计文档,再开始实现。
## 4. 当前验收标准
任何合入正式基线的改动,至少应满足:
* 不破坏 Agent 心跳、同步、发布与回滚主链路
* 不破坏现有 OpenResty 主配置托管模型
* 不降低总览、节点详情与访问分析的既有可用性
* 有与风险相称的测试或联调验证
* 文档与代码保持一致
## 5. 后续维护方式
后续规划不再按“大版本阶段文档”维护,而采用以下方式:
* 产品边界变动:更新 `docs/design.md`
* 工程约束变动:更新 `docs/development-guidelines.md`
* 前端工程变动:更新前端相关规范文档
* 部署与配置变动:更新 `README.md`、`docs/deployment.md`、`docs/app-config.md`
如果未来出现明确的新阶段目标,再单独新增专项计划文档;不要把已完成的历史计划继续堆回本文件。
+84
View File
@@ -0,0 +1,84 @@
import { defineAdditionalConfig, type DefaultTheme } from 'vitepress'
export default defineAdditionalConfig({
description:
'OpenFlare is a lightweight, self-hosted OpenResty control plane for reverse proxy rules, releases, node sync, TLS certificates, and basic observability.',
themeConfig: {
nav: nav(),
sidebar: {
'/en/guide/': { base: '/en/guide/', items: sidebarGuide() },
'/en/reference/': { base: '/en/reference/', items: sidebarReference() },
'/en/design/': { base: '/en/design/', items: sidebarDesign() }
},
editLink: {
pattern: 'https://github.com/Rain-kl/OpenFlare/edit/main/docs/:path',
text: 'Edit this page on GitHub'
},
footer: {
message: 'Released under the Apache License 2.0.',
copyright: 'Copyright © OpenFlare contributors'
}
}
})
function nav(): DefaultTheme.NavItem[] {
return [
{ text: 'Guide', link: '/en/guide/', activeMatch: '/en/guide/' },
{ text: 'Reference', link: '/en/reference/', activeMatch: '/en/reference/' },
{ text: 'Design', link: '/en/design/', activeMatch: '/en/design/' }
]
}
function sidebarGuide(): DefaultTheme.SidebarItem[] {
return [
{
text: 'Guide',
items: [
{ text: 'Overview', link: '' },
{ text: 'Quick Start', link: 'quick-start' },
{ text: 'Usage', link: 'usage' },
{ text: 'Deployment', link: 'deployment' },
{ text: 'SSO Login', link: 'sso' },
{ text: 'Run Server', link: 'server' },
{ text: 'Connect Agent', link: 'agent' },
{ text: 'Publish First Site', link: 'first-site' },
{ text: 'Upgrade and Maintenance', link: 'upgrade' },
{ text: 'Local Development', link: 'development' },
{ text: 'Troubleshooting', link: 'troubleshooting' }
]
}
]
}
function sidebarReference(): DefaultTheme.SidebarItem[] {
return [
{
text: 'Reference',
items: [
{ text: 'Overview', link: '' },
{ text: 'Configuration', link: 'configuration' },
{ text: 'Commands and Scripts', link: 'cli' },
{ text: 'API Conventions', link: 'api' },
{ text: 'Repository Layout', link: 'repository' }
]
}
]
}
function sidebarDesign(): DefaultTheme.SidebarItem[] {
return [
{
text: 'Design',
items: [
{ text: 'Product Boundary', link: '' },
{ text: 'Architecture', link: 'architecture' },
{ text: 'Release Model', link: 'release-model' },
{ text: 'Development Constraints', link: 'development' }
]
}
]
}
+33
View File
@@ -0,0 +1,33 @@
# Architecture
OpenFlare consists of Server, Agent, and local OpenResty on each node.
```text
OpenFlare Server (Gin + SQLite/PostgreSQL + Web UI)
|
| HTTP API / Config Pull
v
OpenFlare Agent (register / heartbeat / sync / apply / update)
|
v
OpenResty binary
|
v
Origin
```
## Server
`openflare_server` is a monolithic control plane based on Gin, GORM, SQLite/PostgreSQL, the existing login/session system, and the static frontend build.
It owns the admin UI and API, Agent API, configuration rendering, version publishing, storage, and aggregate queries.
## Agent
`openflare_agent` is a single Go binary that runs on each node. It controls OpenResty through `openresty_path`, or `openresty` by default. Docker deployments use an Agent image that already includes OpenResty and follows the same binary-control flow.
It handles registration, heartbeat, sync, file writes, `openresty -t`, reload, rollback, self-update, and lightweight collection.
## Frontend
`openflare_server/web` is the production frontend baseline: Next.js App Router, React 19, TypeScript, and Tailwind CSS.
+295
View File
@@ -0,0 +1,295 @@
# Development Constraints
You will learn: The admission criteria for OpenFlare code modifications, backend/Agent/frontend tiered constraints, data model boundaries, API conventions, database migration requirements, and test delivery baselines.
This document integrates the original development specifications, frontend specifications, and development plans, and serves as the engineering constraints entry point for OpenFlare after `1.0.0`.
## Current Conclusions
* The mainline capabilities of the first to sixth versions have all been completed.
* `1.0.0` is the current official baseline.
* Procedural tasks of completed stages are subject to code, tests, and Git history.
* Priority for new work is given to bug fixes, maintainability improvements, and documentation and test reinforcement.
Current Development Priorities:
1. Stability.
2. Upgrade and rollback link reliability.
3. Document accuracy.
4. Test coverage reinforcement.
5. Small iterations within existing boundaries.
## Change Admission
Before new requirements enter implementation, judge them in the following order:
1. Whether it fits the [Product Boundary](./index.md).
2. Whether it follows the backend, Agent, and frontend constraints in this document.
3. Whether it risks breaking the existing publish, sync, rollback, or upgrade main links.
4. Whether it requires synchronized updates to deployment, configuration, README, or documentation site pages.
If a requirement expands the boundary or introduces new infrastructure, the design documentation must be updated first before starting implementation.
Any changes merged into the official baseline must at least meet:
* Does not break the Agent heartbeat, synchronization, publishing, and rollback main links.
* Does not break the existing OpenResty main configuration hosting model.
* Does not degrade the existing availability of the overview, node details, and access analysis.
* Has tests or joint debugging verification commensurate with the risks.
* Documentation remains consistent with the code.
## Technical Baseline
Server:
* Go 1.25+
* Gin
* GORM
* SQLite / PostgreSQL
* Existing login system
Agent:
* Single binary
* Node-local execution
* Control OpenResty binary via `openresty_path` or default `openresty`
* Docker deployment uses the Agent image with built-in OpenResty, and does not have the Agent control a separate OpenResty container
Frontend:
* Next.js 15 App Router
* React 19
* TypeScript 5
* Tailwind CSS 4
* TanStack Query
* React Hook Form + Zod
* Zustand only used for lightweight client status
* ESLint + Prettier
* Vitest + Testing Library + Playwright
* pnpm
## Server Layering
| Directory | Responsibility |
| --- | --- |
| `controller/` | Parameter parsing, calling services, returning responses |
| `service/` | Business logic, verification, transaction orchestration, rendering |
| `model/` | Model definition and persistence |
| `router/` | Route registration |
| `middleware/` | Auth, authorization, rate limiting, and other cross-cutting logic |
| `common/` | Configuration, global state, and initialization entry points |
| `utils/` | Pure utility functions and general helpers |
It is forbidden to accumulate business logic in `controller/`, forbidden to implement business flows in `middleware/`, and forbidden to add platform-level abstractions for simple requirements.
## Agent Layering
The Agent maintains its existing module boundaries:
* `config`
* `heartbeat`
* `sync`
* `openresty` / `nginx`
* `state`
* `httpclient`
* `protocol`
* `internal/updater`
Requirements:
* Each module has a single responsibility.
* External command calls are centrally encapsulated.
* State persistence and configuration persistence are separated.
## Frontend Layering
Recommended directories:
```text
app/
components/
features/
lib/
hooks/
store/
types/
styles/
tests/
```
Responsibility constraints:
* `app/`: Routes, layouts, page assembly.
* `features/`: Organize modules by business domains.
* `components/`: Reuse components across features.
* `lib/`: Request client, environment variables, utility functions, constants.
* `store/`: A small amount of cross-page UI state.
* `types/`: Shared type definitions.
Page files are only responsible for obtaining routing parameters, organizing page structures, and calling feature components; they should not handwrite complex API details, complex form verification logic, or maintain a large amount of mutually coupled local states.
## Data Model Specifications
Currently active entities:
* `proxy_routes`
* `origins`
* `config_versions`
* `nodes`
* `auth_sources`
* `external_accounts`
* `node_system_profiles`
* `apply_logs`
* `tls_certificates`
* `managed_domains`
* `node_request_reports`
* `node_access_logs`
* `node_metric_snapshots`
* `traffic_analytics_rollups`
* `node_health_events`
* `options`
General constraints:
* No new platform-oriented objects are added unless explicitly required by the design document.
* `origins` only serves as a reusable origin address directory, and the fields are kept lightweight.
* `proxy_routes` uses "site configuration" as the aggregation boundary and must contain a unique `site_name` and a non-empty `domains` list.
* Each domain in `proxy_routes.domains` must be globally unique, and the first item in the list is treated as the primary domain.
* `proxy_routes` continues to allow saving one or more upstream addresses for load balancing, but does not introduce an independent `origin_pool`.
* The legacy `domain` field can only be used as a compatible mirror of `domains[0]`; new code must not continue to use this field as the unique business input.
* If `proxy_routes` is associated with `origins`, it must also save the `origin_url` that can be directly rendered.
* Upstreams uniformly use named `upstream` + keepalive; for a single upstream carrying a base path or query, the original URI should be added back to `proxy_pass`. For multiple upstreams, only pure `scheme://host[:port]` is allowed.
* Rate limits, reverse proxy, and cache configurations currently belong to the site-level `proxy_routes`.
* HTTPS certificate binding must be saved on a per-domain basis through `domain_cert_ids` parallel to `domains`; domains not bound to a certificate must not participate in HTTPS rendering.
* `config_versions` must save complete snapshots and rendering results.
* There can only be one activated version globally at a time.
* Rollback is achieved by reactivating older versions.
* `nodes` only retains control plane status and low-frequency summaries.
* Observability data must be associated with nodes and time windows, and snapshots and aggregation results use an append-only model.
* Original access details must have a controlled retention policy.
* `auth_sources` only saves management console third-party login source configurations, currently supporting `github` and `oidc`.
* `external_accounts` is the unique source of binding between third-party accounts and local users; the old `users.github_id` is only used for compatible migration and must not be used as the business input for the new login flow.
## Database Migration
Any modification involving table structures, indexes, column types, sharding rules, or internal persistence metadata must upgrade the database version number in sync.
The database version number is defined in `openflare_server/model`, and it must not rely solely on `AutoMigrate` for implicit upgrades of existing databases.
Every time the database version number is upgraded, an explicit migration method from the previous version to the new version must be added. The migration method must contain validation logic after the upgrade; only when the validation passes can the new database version record be written.
After starting the new package, the database's current version must be checked first, and then upgraded step by step in order to the target version; skipping intermediate upgrade steps to directly write the target version is prohibited.
An empty database initialization can directly establish the current version structure, but the same-version validation must still be executed after the initialization is completed, and the current database version must be persisted.
If the migration or validation fails, the startup process must abort, and the database version record must not be upgraded. Submissions involving database version changes must add corresponding migration tests or equivalent regression tests.
## API and Authentication
The management console and Agent APIs uniformly use JSON. Both success and failure must return a clear `message`:
```json
{
"success": true,
"message": "",
"data": {}
}
```
Conventions:
* Agent APIs are uniformly placed under `/api/agent/*`.
* The overview and node details prioritize using dedicated aggregation interfaces.
* Management console mutation APIs uniformly use `POST`; read-only APIs use `GET`.
* The management console continues to reuse existing logins, roles, and Sessions.
* Third-party login uniformly enters through authentication source APIs; authentication source management interfaces must require Root Session.
* `/api/status` can only return the public fields of enabled authentication sources, and must not return the Client Secret.
* When a third-party account is not bound and registration is closed, a process to bind to an existing account should be provided, and users must not be automatically created.
* Official Agent requests uniformly use the node-exclusive `agent_token`.
* The first access can use the global `discovery_token`.
* Agent request headers uniformly use `X-Agent-Token`.
It is forbidden to expose remote shell or arbitrary command execution entries, forbidden to print full Tokens in logs, and forbidden to save main configuration templates that bypass placeholder constraints.
## Publishing and Runtime
The publishing logic must maintain:
* Read all enabled `proxy_routes` during publishing.
* Read OpenResty main configuration parameters, reverse proxy performance parameters, and cache parameters at the same time.
* Generate complete OpenResty configuration.
* Calculate `checksum`.
* Write to `config_versions`.
* Activate the version by switching `is_active`.
Version constraints:
* The version number format is fixed as `YYYYMMDD-NNN`.
* Do not modify historical versions online.
* Do not make differentiated versions grouped by nodes.
* Preview and diff are read-only capabilities and do not generate release records.
The Agent must satisfy:
* Read or generate local `node_id` after startup.
* Periodic heartbeat and synchronization.
* Conventional synchronization prioritizes judging based on the version summary returned by the heartbeat.
* Back up old files first when discovering a new version.
* Write main configurations, route configurations, and necessary certificate files.
* Execute `openresty -t -c <main_config_path>` after writing the new configuration, and then reload; direct startup of OpenResty is allowed when reload finds that it is not running.
* If the activation of the new configuration fails, the Agent must first try to restore execution with the target configuration, then roll back to the old configuration and pull up OpenResty again.
* Report warning when OpenResty recovers normally after rollback; report failure when it still cannot recover after rollback.
* Once a target `version + checksum` fails to apply and rolls back, the Agent must block repeated applications of this target in its local state.
## Frontend Requests, State, and Types
All API requests must be uniformly routed through `lib/api/`:
* Uniformly handle the `success/message/data` response structure.
* Uniformly handle authentication failure, network exceptions, and general error messages.
* Centralize maintenance of resource interfaces and request paths.
State Layering:
* Server state: TanStack Query.
* Page temporary state: Component-internal `useState`.
* Cross-page UI state: Zustand.
Strict TypeScript mode is required; abuse of `any` is prohibited. API responses, form inputs, and business entities must have explicit types.
## Forms, Interaction, Style, and Themes
Forms uniformly use React Hook Form and Zod.
High-risk operations must have double confirmation, show the name of the operation object, and clearly provide success and failure feedback.
Style principles:
* Uniformly use Tailwind CSS and the existing token system.
* Prioritize reusing existing basic components and layout components.
* Maintain consistent visual hierarchy, padding, and semantic colors.
Theme requirements:
* Support `light`, `dark`, and `system` simultaneously.
* User choices must be persisted.
* Try to avoid theme flickering on the first screen.
## Test and Delivery
* Key business logic must have unit tests or equivalent regression tests.
* Agent main link modifications must verify synchronization, application, and rollback.
* Frontend pages must cover at least loading states, empty states, error states, and success feedback.
* When the Go version is adjusted, check `go.mod`, Dockerfile, and CI workflows in sync.
## Subsequent Maintenance
Subsequent planning is no longer maintained in the form of "major version phase documents", but adopts the following methods:
* Product boundary changes: Update [Product Boundary](./index.md).
* Engineering constraint changes: Update this document.
* Deployment and configuration changes: Update [Deployment Guide](../guide/deployment.md), [Configuration Items](../reference/configuration.md), and README.
If explicit new stage goals appear in the future, add dedicated planning documents separately; do not pile completed historical plans back into this document.
The model boundary of the current special topic "Site-level Rules and Configuration Interface Reconstruction" has been integrated into the [Product Boundary](./index.md). When executing, still advance in the order of data models, interfaces, frontend pages, migration tests, and document linkage.
+150
View File
@@ -0,0 +1,150 @@
# Product Boundary
You will learn: What OpenFlare is, what problems it solves, who the target users are, what the current stable capabilities are, and which design boundaries cannot be bypassed during implementation.
OpenFlare is a self-hosted OpenResty control plane oriented toward single-team or single-organization internal operation and maintenance (O&M) scenarios. It resolves the issues of scattered management in reverse proxy configuration, node synchronization, certificate hosting, configuration release/rollback, and basic observability.
## Project Positioning
OpenFlare is suitable for teams that need to centrally manage multiple OpenResty proxy nodes:
* Want to maintain reverse proxy site configurations using a management console.
* Want every configuration change to have a complete version, preview, activation, and rollback.
* Want nodes to actively sync configuration, rather than having the control plane SSH into nodes to execute commands.
* Want to manage TLS certificates, domain assets, node statuses, and basic access analytics within the same system.
OpenFlare is currently not positioned as a general-purpose log platform, service mesh, Kubernetes Ingress Controller, or multi-tenant cloud platform.
## Target Users
| User | Needs |
| --- | --- |
| Self-hosted users | Quickly deploy a visual OpenResty control plane |
| Internal O&M teams | Manage multiple reverse proxy nodes, certificates, and configuration versions |
| Development teams | Provide a unified entry point and basic access analytics for internal services |
| Contributors | Fix defects, strengthen tests, and improve documentation within clear boundaries |
## Current Stable Capabilities
| Capability | Description |
| --- | --- |
| Reverse Proxy Rule Management | Uses site configuration as the aggregation boundary, supporting multi-domain and origin configuration |
| Site-level Configuration | One rule corresponds to one site, which can bind one or more domains and share site-level configuration |
| Origin Management | Maintains a lightweight origin directory and allows sites to save renderable origin snapshots |
| Configuration Versioning | Supports preview, publishing, activation, immutable history, and rollback |
| Agent Synchronization | Supports registration, heartbeat, synchronization, application result reporting, and self-updating |
| OpenResty Hosting | Manages main configuration templates, performance parameters, cache parameters, and Lua resources |
| HTTPS/TLS | Hosts certificates and domain assets, and binds certificates on a per-domain basis |
| Basic Observability | Aggregates node requests, resource snapshots, health events, and access analytics |
| Node Management | Node status, token systems, deployment, and update links |
| Console Frontend | Next.js-based official management console |
| Auth Source Login | Supports configuring GitHub and standard OIDC login entries as authentication sources, allowing third-party accounts to bind to existing local users |
Default working method:
* All nodes consume the same globally activated version.
* The Server saves configuration and status, and does not directly manage nodes via SSH.
* The Agent is the only controlled landing entry point on the node side.
## Typical Use Cases
| Scenario | Description |
| --- | --- |
| Unified Entry for Internal Services | Expose multiple internal HTTP services through a unified domain and certificate |
| Config Sync for Multi-node Reverse Proxy | Multiple OpenResty nodes consume the same activated configuration |
| Config Change Review | View preview or diff before publishing, and retain immutable history after publishing |
| Quick Rollback | Reactivate an older version, letting the Agent pull and apply it |
| Certificate Hosting | Bind TLS certificates for different domains |
| Basic Observability | View node status, request aggregation, access analytics, and health events |
## Core Objects
Currently active entities:
* `proxy_routes`
* `origins`
* `config_versions`
* `nodes`
* `auth_sources`
* `external_accounts`
* `node_system_profiles`
* `apply_logs`
* `tls_certificates`
* `managed_domains`
* `node_request_reports`
* `node_access_logs`
* `node_metric_snapshots`
* `traffic_analytics_rollups`
* `node_health_events`
## Site Configuration Constraints
`proxy_routes` is upgraded from a "single-domain rule" to a "site configuration" aggregate object. One record corresponds to one website, which can bind one or more domains and share a set of site-level configurations.
Constraints:
* `proxy_routes.site_name` is the unique business identifier of the website.
* `proxy_routes.domains` contains at least one domain, and `domains[0]` is used as the primary domain.
* Any domain can globally belong to only one `proxy_routes`.
* During the migration period, `proxy_routes.domain` can be kept as a mirror field of `domains[0]`, but business read/write and subsequent extensions must be based on `site_name` + `domains`.
* Site-level rate limits, reverse proxies, and cache configurations are currently shared by site and are not configured differently on a per-domain basis within the same website.
* HTTPS allows binding certificates per domain within the same site.
## Origin Constraints
`origins` only saves the origin address, display name, and remarks, and does not carry protocols, ports, paths, weights, or health check policies.
`proxy_routes` can optionally associate an `origins` record to reuse the origin address; the rule still saves a complete `origin_url` snapshot to participate in rendering and version snapshots.
Upstream constraints:
* `proxy_routes` must contain at least one upstream address.
* To maintain compatibility with historical data, the `origin_url` main upstream field is retained, and multiple upstreams are allowed to be added within the same rule for load balancing.
* Upstreams are rendered uniformly as a named `upstream` with keepalive.
* A single upstream can carry a base path or query and append it in `proxy_pass`.
* Multiple upstreams are restricted to pure `scheme://host[:port]`.
* `proxy_routes.origin_host` is an optional field, used to override the `Host` request header when back-origin.
* All upstream addresses must be legal `http://` or `https://`.
## HTTPS Constraints
`proxy_routes.domain_cert_ids` is used to record domain-certificate bindings parallel to `domains`; a value of `0` indicates that HTTPS is not enabled for the domain, retaining only HTTP.
During publishing rendering:
* Domains with certificates are output as separate `443 ssl` `server` blocks grouped by certificate.
* Domains not bound to a certificate must not be automatically brought into HTTPS.
* All domains in `proxy_routes.domains` must be included in the same site configuration to avoid the same site being split in version snapshots.
## Authentication Source Constraints
`auth_sources` is the configuration object for third-party login entries on the management console, currently supporting only two types: `github` and `oidc`. Enabled authentication sources will be displayed on the login page.
`external_accounts` saves the binding relationship between external accounts of authentication sources and local users. When a third-party account logs in for the first time:
* If it is bound to a local user, it logs in directly.
* If there is an existing local login session, it binds to the current user.
* If it is not bound and registration is allowed, a normal user is automatically created and bound.
* If it is not bound and registration is closed, the user is only allowed to enter an existing local account and password to complete the binding.
The old `users.github_id` only serves as a source for upgrade migration; new third-party account login and binding relationships must be based on `external_accounts`.
## Version and Observability Constraints
* `config_versions` must save complete snapshots, rendering results, and `checksum`.
* There can only be one activated version globally at a time.
* Rollback is achieved by reactivating older versions.
* `nodes` only carries control plane status and low-frequency summaries, not high-frequency observability facts.
* Metrics, trends, and access analytics prioritize server-side aggregation results, rather than temporary frontend statistics.
* Access details are only retained for controlled time windows, not evolving into a general-purpose log platform.
## Documentation Maintenance Principles
* Update this document when the product scope or system boundary changes.
* Update [System Architecture](./architecture.md) when the system structure or module responsibilities change.
* Update [Release Model](./release-model.md) when the release, synchronization, or rollback model changes.
* Update [Development Constraints](./development.md) when development constraints, code specifications, or interface conventions change.
* Update [Deployment Guide](../guide/deployment.md) and README when deployment methods change.
* Update [Configuration Reference](../reference/configuration.md) when configuration items change.
* Completed phases will no longer be backfilled in the form of "version plans".
* Before starting a new phase, complete the design first, then enter implementation.
+71
View File
@@ -0,0 +1,71 @@
# Release Model
You will learn: Why OpenFlare uses a complete configuration version as the release unit, and how publishing, activation, Agent application, and rollback work.
OpenFlare's release model is centered on complete configuration versions rather than modifying node configurations online.
Standard link:
```text
Modify rules -> Preview / View diff -> Publish -> Generate complete configuration version -> Activate version -> Agent pulls -> Local application -> Report result
```
## Publishing Rules
When publishing, the Server must:
1. Read all enabled `proxy_routes`.
2. Read the OpenResty main configuration template, performance parameters, cache parameters, and necessary Lua resources on the Server side.
3. Read domain and certificate binding relationships.
4. Render the complete OpenResty configuration.
5. Calculate the `checksum`.
6. Write to `config_versions`.
7. Switch the activated version.
8. Let the Agent discover and apply it in subsequent heartbeats.
The version number format is fixed as `YYYYMMDD-NNN`.
## Preview and Publishing
Preview and diff are read-only capabilities and do not generate release records.
Publishing generates a new complete configuration version. The version must contain sufficient information so that future rollbacks can be re-applied based on historical snapshots, without relying on current mutable configurations.
## Activating Version
There can only be one activated version globally at a time.Differentiated versions grouped by nodes are currently not supported.
The Agent obtains the activated version summary through the heartbeat; only when the remote version or checksum is inconsistent with the local state does the Agent enter the synchronization flow.
## Immutable History
Historical versions are immutable. Rollback is not achieved by modifying older versions, but by reactivating older versions.
The result of doing this is:
* Every version can be traced back.
* The rollback link is consistent with the ordinary release application link.
* The Agent does not need to understand "reverse patch", but only needs to apply a target version.
## Agent Application Policy
When discovering a new version, the Agent will:
1. Pull the details of the target version.
2. Back up old files.
3. Write the main configuration, route configurations, certificates, and necessary Lua resources.
4. Execute OpenResty configuration verification.
5. reload; if it is not started during runtime, try to start OpenResty with the current configuration.
6. Report success, warning, or failure.
If the activation of the new configuration fails, the Agent must try to restore execution; report a warning when the rollback succeeds, and report a failure when it still cannot recover after rollback.
Once a target `version + checksum` application fails and rolls back, the Agent will block repeated applications of this target in its local state. Only when the remote activated version or checksum changes is it allowed to try again.
## Design Constraints
* Publishing must read all enabled site configurations, rather than only rendering the modified object this time.
* Rollback is achieved by reactivating older versions, without modifying historical versions.
* The Agent API is fixed to use the node-exclusive `agent_token`; the first access can use the `discovery_token`.
* The Server does not provide remote shell or arbitrary command execution entries.
* The configuration version must save complete snapshots, rendering results, and `checksum`.
+73
View File
@@ -0,0 +1,73 @@
# Connect Agent
OpenFlare Agent runs on proxy nodes. It handles registration, heartbeat, configuration sync, OpenResty file writes, validation, reload, rollback, and self-update.
## Authentication
| Method | Use case |
| --- | --- |
| `agent_token` | The node already exists or has a dedicated credential |
| `discovery_token` | First-time auto-registration; Server exchanges it for a node token |
At least one of them is required.
## Install Script
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
--server-url http://your-server:3000 \
--agent-token YOUR_AGENT_TOKEN
```
Or with discovery:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
--server-url http://your-server:3000 \
--discovery-token YOUR_DISCOVERY_TOKEN
```
## Configuration Example
```json
{
"server_url": "http://127.0.0.1:3000",
"agent_token": "replace-with-node-auth-token",
"data_dir": "./data",
"openresty_path": "openresty",
"openresty_observability_port": 18081,
"observability_replay_minutes": 15,
"heartbeat_interval": 10000,
"request_timeout": 10000
}
```
Without `openresty_path`, Agent runs `openresty` by default.
Agent self-update requires the GitHub Release to include both the target binary and a matching `.sha256` file. The downloaded binary is verified before it replaces the local executable.
## Docker
```bash
docker pull ghcr.io/rain-kl/openflare-agent:latest
docker rm -f openflare-agent 2>/dev/null || true
docker run -d --name openflare-agent --restart unless-stopped \
-p 80:80 -p 443:443 \
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
ghcr.io/rain-kl/openflare-agent:latest
```
## Run from Source
```bash
cd openflare_agent
export LOG_LEVEL='info'
go run ./cmd/agent -config /path/to/agent.json
```
## Uninstall
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/uninstall-agent.sh | bash
```
+263
View File
@@ -0,0 +1,263 @@
# Deployment
You will learn the recommended OpenFlare deployment model, Server and Agent requirements, source startup workflow, integration steps, upgrade paths, and uninstall entry points.
For production, use PostgreSQL for the Server database and set `SESSION_SECRET` explicitly. Agent controls OpenResty through the OpenResty binary; Docker deployments run the Agent image that already includes OpenResty.
## Topology
```text
Browser
|
v
OpenFlare Server :3000
|
| Agent API / heartbeat / config pull
v
OpenFlare Agent
|
v
OpenResty binary
|
v
Origin service
```
## Requirements
Server:
| Item | Requirement |
| --- | --- |
| Go | `1.25+`, source run only |
| Node.js | `18+`, frontend source build only |
| Database | Writable SQLite directory or reachable PostgreSQL instance |
| Port | `3000` by default |
Agent:
| Item | Requirement |
| --- | --- |
| OS | Install script supports Linux and macOS. systemd service is created only on Linux + systemd. |
| Architecture | `amd64` or `arm64` |
| OpenResty | Required for local Agent installs |
| Docker | Required only when running the Agent Docker image |
| Network | Agent node must reach the Server URL |
[Needs confirmation: recommended production CPU, memory, and disk size]
## Docker Compose Server
Create `docker-compose.yml`:
```yaml
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: openflare
POSTGRES_USER: openflare
POSTGRES_PASSWORD: replace-with-strong-password
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U openflare -d openflare"]
interval: 10s
timeout: 5s
retries: 5
openflare:
image: ghcr.io/rain-kl/openflare:latest
container_name: openflare
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
ports:
- "3000:3000"
environment:
SESSION_SECRET: replace-with-a-long-random-string
DSN: postgres://openflare:replace-with-strong-password@postgres:5432/openflare?sslmode=disable
GIN_MODE: release
LOG_LEVEL: info
volumes:
- openflare-data:/data
volumes:
postgres-data:
openflare-data:
```
Start:
```bash
docker compose up -d
docker compose ps
docker compose logs -f openflare
```
Open `http://localhost:3000`. The default account is `root` / `123456`; change it immediately.
## Run Server from Source
Build the management UI first:
```bash
cd openflare_server/web
corepack enable
pnpm install
pnpm build
```
Then start Server:
```bash
cd openflare_server
export SESSION_SECRET='replace-with-a-long-random-string'
export SQLITE_PATH='./openflare.db'
export LOG_LEVEL='info'
# Optional: PostgreSQL takes precedence when set.
# export DSN='postgres://openflare:secret@127.0.0.1:5432/openflare?sslmode=disable'
go run .
```
Default port is `3000`. You can also set it explicitly:
```bash
go run . --port 3000 --log-dir ./logs
```
## Connect Agent
With `discovery_token`:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
--server-url http://your-server:3000 \
--discovery-token YOUR_DISCOVERY_TOKEN
```
With node-specific `agent_token`:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
--server-url http://your-server:3000 \
--agent-token YOUR_AGENT_TOKEN
```
Supported options:
| Option | Description |
| --- | --- |
| `--server-url` | Server URL, required |
| `--discovery-token` | First-registration token, mutually exclusive with `--agent-token` |
| `--agent-token` | Node-specific token, mutually exclusive with `--discovery-token` |
| `--install-dir` | Install directory, default `/opt/openflare-agent` |
| `--openresty-path` | OpenResty binary path, auto-detected when omitted |
| `--repo` | GitHub repository for Agent downloads, default `Rain-kl/OpenFlare` |
| `--no-service` | Do not create a systemd service |
Check status:
```bash
systemctl status openflare-agent
journalctl -u openflare-agent -f
```
## Run Agent Manually
From source:
```bash
cd openflare_agent
export LOG_LEVEL='info'
go run ./cmd/agent -config /path/to/agent.json
```
Build and run:
```bash
cd openflare_agent
go build -o openflare-agent ./cmd/agent
export LOG_LEVEL='info'
./openflare-agent -config /path/to/agent.json
```
Minimal `agent.json`:
```json
{
"server_url": "http://127.0.0.1:3000",
"agent_token": "replace-with-node-auth-token",
"data_dir": "./data",
"openresty_path": "openresty",
"heartbeat_interval": 10000,
"request_timeout": 10000
}
```
When `openresty_path` is not configured, Agent runs `openresty`.
## Minimal Integration Flow
1. Start Server and sign in.
2. Prepare `agent_token` or `discovery_token`.
3. Start Agent and confirm the node is online.
4. Create an enabled site configuration.
5. Publish and activate a new version.
6. Check node detail and apply logs.
7. Visit the domain or verify with `curl`.
## Upgrade and Uninstall
Server:
* Root users can check and upgrade stable Server releases from the top bar.
* Preview releases can be checked manually.
* Binary upload upgrades are also supported.
Agent:
* Agents follow stable releases by default.
* The install script can be rerun to reinstall or upgrade.
* Preview upgrades require manual action.
Uninstall Agent:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/uninstall-agent.sh | bash
```
The uninstall script stops Agent and removes the systemd service and install directory. It does not remove the local OpenResty installation.
## Validation Commands
Server:
```bash
cd openflare_server
GOCACHE=/tmp/openflare-go-cache go test ./...
```
Agent:
```bash
cd openflare_agent
GOCACHE=/tmp/openflare-go-cache go test ./...
```
Frontend:
```bash
cd openflare_server/web
pnpm build
```
Swagger:
```bash
go install github.com/swaggo/swag/cmd/swag@v1.16.4
cd openflare_server
swag init -g main.go -o docs
```
+188
View File
@@ -0,0 +1,188 @@
# Local Development
You will learn how to set up a local OpenFlare development environment, run the Server, Agent, and frontend, execute tests and builds, and understand the boundaries contributors must follow.
This page is for contributors. Product boundaries, data model constraints, API conventions, and frontend layering are defined in [Development Constraints](../design/development.md). This page focuses on executable local workflows.
## Repository Layout
| Path | Responsibility |
| --- | --- |
| `openflare_server` | Gin + GORM + SQLite/PostgreSQL monolithic control plane |
| `openflare_server/web` | Next.js management UI, statically exported and served by the Go Server |
| `openflare_agent` | Go Agent binary running on nodes |
| `scripts` | Agent install and uninstall scripts |
| `docs` | VitePress documentation site |
## Requirements
| Tool | Requirement |
| --- | --- |
| Go | `1.25+` |
| Node.js | `18+` |
| pnpm | Use `corepack enable` to follow the project-declared version |
| Docker | Needed for Server containers, local integration, and the Agent Docker image |
| OpenResty | Needed when running Agent locally |
| PostgreSQL | Optional. The Server uses SQLite when PostgreSQL is not configured. |
## Install Frontend Dependencies
```bash
cd openflare_server/web
corepack enable
pnpm install
```
Build static assets served by the Go Server:
```bash
pnpm build
```
## Run the Server
SQLite:
```bash
cd openflare_server
export SESSION_SECRET='dev-session-secret'
export SQLITE_PATH='./openflare-dev.db'
export LOG_LEVEL='debug'
go run .
```
PostgreSQL:
```bash
cd openflare_server
export SESSION_SECRET='dev-session-secret'
export DSN='postgres://openflare:secret@127.0.0.1:5432/openflare?sslmode=disable'
export LOG_LEVEL='debug'
go run .
```
Default URL:
```text
http://localhost:3000
```
Default account: `root` / `123456`.
## Run the Frontend Dev Server
The frontend dev server listens on `3001` by default and proxies API requests through `NEXT_DEV_BACKEND_URL`:
```bash
cd openflare_server/web
export NEXT_DEV_BACKEND_URL='http://127.0.0.1:3000'
pnpm dev
```
Open:
```text
http://localhost:3001
```
## Run the Agent
Create a local `agent.json`:
```json
{
"server_url": "http://127.0.0.1:3000",
"agent_token": "replace-with-node-auth-token",
"data_dir": "./data",
"heartbeat_interval": 10000,
"request_timeout": 10000
}
```
Run:
```bash
cd openflare_agent
export LOG_LEVEL='debug'
go run ./cmd/agent -config ./agent.json
```
When `openresty_path` is not configured, the Agent runs `openresty`. For debugging, set `openresty_path`, `main_config_path`, `route_config_path`, `access_log_path`, `cert_dir`, `lua_dir`, and `runtime_config_dir` as needed.
## Tests
Server:
```bash
cd openflare_server
GOCACHE=/tmp/openflare-go-cache go test ./...
```
Agent:
```bash
cd openflare_agent
GOCACHE=/tmp/openflare-go-cache go test ./...
```
Frontend:
```bash
cd openflare_server/web
pnpm lint
pnpm typecheck
pnpm test
pnpm test:e2e
```
Docs:
```bash
cd docs
pnpm build
```
## Builds
Frontend static assets:
```bash
cd openflare_server/web
pnpm build
```
Server binary:
```bash
cd openflare_server
go build -o openflare-server .
```
Agent binary:
```bash
cd openflare_agent
go build -o openflare-agent ./cmd/agent
```
## Debugging Entrypoints
| Scenario | Command or Location |
| --- | --- |
| Server logs | `LOG_LEVEL=debug go run .` |
| Agent logs | `LOG_LEVEL=debug go run ./cmd/agent -config ./agent.json` |
| Swagger | `http://localhost:3000/swagger/index.html` |
| Frontend API proxy | `NEXT_DEV_BACKEND_URL=http://127.0.0.1:3000 pnpm dev` |
| OpenResty config test | `openresty -t -c ./data/etc/nginx/nginx.conf` |
## Change Acceptance
Before contributing, confirm that:
1. The change fits [Product Boundary](../design/index.md).
2. The implementation follows [Development Constraints](../design/development.md).
3. It does not break release, sync, rollback, or upgrade flows.
4. Documentation is updated when configuration, deployment, API, or product boundaries change.
5. Risky changes include tests or equivalent integration verification.
Database schema changes must bump the database version and include explicit migration and validation logic from the previous version.
+100
View File
@@ -0,0 +1,100 @@
# Publishing Your First Configuration
You will learn: How to create your first site configuration, bind origins and certificates, publish a configuration version, and confirm that the Agent has applied it.
OpenFlare's release link is centered on complete configuration versions. After modifying site configurations on the management console, you need to publish and activate the new version before the Agent pulls and applies it in subsequent heartbeats.
## Pre-release Check
Confirm that the following conditions are met:
| Project | Expectation |
| --- | --- |
| Server | Can log into the management console |
| Agent | At least one node is online |
| Origin | The Agent node can access the origin address |
| Domain | The domain has been resolved to the OpenResty node, or you are ready to verify via local hosts / curl Host header |
| HTTPS | If HTTPS is required, the certificate has been uploaded or hosted |
## Create Site Configuration
When adding a site configuration on the management console, you need at least:
| Field | Description |
| --- | --- |
| Site Name | Unique business identifier; defaults to the primary domain when omitted |
| Domains | At least one domain; the first item is treated as the primary domain |
| Origin URL | Valid `http://` or `https://` upstream address |
| Enabled | Only enabled site configurations participate in release rendering |
Example:
| Field | Example |
| --- | --- |
| Site Name | `app` |
| Domains | `app.example.com` |
| Origin URL | `http://10.0.0.20:8080` |
A domain can belong to only one site configuration. Site-level rate limits, reverse proxies, and cache configurations are shared by site.
## Bind Certificates
HTTPS certificates are bound per domain. Domains not bound to certificates will not be automatically placed in `443 ssl` server blocks.
If a site contains multiple domains, the publishing rendering will generate HTTPS configurations grouped by certificate and ensure all domains still belong to the same site snapshot.
## Publish and Activate
Standard link:
```text
Modify rules -> Preview / View diff -> Publish -> Generate complete configuration version -> Activate version -> Agent pulls -> Local application -> Report result
```
When publishing, the Server reads all enabled site configurations, OpenResty main configuration templates, performance parameters, and cache parameters, renders the complete OpenResty configuration, calculates the `checksum`, writes to `config_versions`, and then switches the activated version.
## Verify Results
After publishing, confirm on the management console:
| Location | Expected Result |
| --- | --- |
| Node List | Node is online |
| Node Details | The current version is consistent with the activated version |
| Apply Logs | The most recent application succeeded |
| Version Page | The new version is in the activated state |
Confirm the Agent logs on the node:
```bash
journalctl -u openflare-agent -n 100 --no-pager
```
Access using the domain:
```bash
curl -I http://app.example.com
```
If the domain has not been officially resolved yet, you can temporarily specify the Host header to access the node IP:
```bash
curl -I -H 'Host: app.example.com' http://NODE_IP
```
HTTPS verification:
```bash
curl -I https://app.example.com
```
## Rollback
If the target version application fails and rolls back, the Agent will block repeated applications of the same `version + checksum` locally until the activated version or checksum on the control plane changes.
To roll back to an older version:
1. Open the configuration version page.
2. Find the previous confirmed working historical version.
3. Reactivate that version.
4. View the node application records to confirm that the Agent applied it successfully.
+36
View File
@@ -0,0 +1,36 @@
# Guide
You will learn how the OpenFlare documentation is organized, which pages to read for a first run, and where to find deployment, usage, troubleshooting, and development information.
OpenFlare is a self-hosted OpenResty control plane. It brings reverse proxy site configuration, immutable releases, Agent-based node sync, TLS certificates, and basic observability into one management UI for a single team or organization.
## Recommended Path
If you are new to OpenFlare, read these pages in order:
1. [Quick Start](./quick-start.md): start the Server with Docker Compose, sign in, and connect the first Agent.
2. [Usage](./usage.md): learn common operations for sites, origins, certificates, releases, rollbacks, and observability.
3. [Deployment](./deployment.md): run the Server and Agent in an environment closer to production.
4. [Configuration](../reference/configuration.md): look up Server environment variables, runtime options, and Agent configuration fields.
5. [Troubleshooting](./troubleshooting.md): debug login, database, node sync, OpenResty apply, and frontend build issues.
## Find by Role
| Goal | Start Here |
| --- | --- |
| Run the management UI in a few minutes | [Quick Start](./quick-start.md) |
| Publish the first reverse proxy site | [Publish First Site](./first-site.md) |
| Connect or reinstall a node Agent | [Connect Agent](./agent.md) |
| Start the Server from source | [Run Server](./server.md) |
| Configure GitHub or OIDC login | [SSO Login](./sso.md) |
| Upgrade the Server or Agent | [Upgrade and Maintenance](./upgrade.md) |
| Contribute code or fix issues | [Local Development](./development.md) and [Development Constraints](../design/development.md) |
| Understand architecture and releases | [Architecture](../design/architecture.md) and [Release Model](../design/release-model.md) |
## Documentation Areas
`guide/` is for users and operators. It provides executable steps from installation to daily operations.
`reference/` collects stable facts, such as configuration fields, commands, API conventions, and repository layout.
`design/` is for maintainers and contributors. It describes product boundaries, architecture, release model, and engineering constraints. Update the related design page before implementing changes that alter those boundaries.
+182
View File
@@ -0,0 +1,182 @@
# Quick Start
You will learn how to start OpenFlare Server with Docker Compose, sign in for the first time, connect the first Agent, and verify that a configuration was published to a node.
The minimal OpenFlare setup contains:
| Component | Responsibility |
| --- | --- |
| Server | Management UI, management API, Agent API, configuration rendering, release publishing, and state storage |
| Agent | Runs on proxy nodes, pulls configuration, writes OpenResty files, validates, and reloads |
| OpenResty | Receives traffic and proxies requests to origins |
Agent controls OpenResty through the OpenResty binary. Local installs need an `openresty` executable on the node; Docker installs can run the Agent image that already includes OpenResty.
## Requirements
| Item | Requirement |
| --- | --- |
| Docker / Docker Compose | Used to start Server and PostgreSQL; also used if you run the Agent Docker image |
| OpenResty | Required for local Agent installs unless `--openresty-path` points to a custom binary |
| Reachable ports | Server listens on `3000` by default. Agent nodes must reach the Server URL. |
| Browser | Used to open the management UI |
[Needs confirmation: minimum recommended Docker and Docker Compose versions]
## 1. Start Server
Create `docker-compose.yml` in an empty directory:
```yaml
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: openflare
POSTGRES_USER: openflare
POSTGRES_PASSWORD: replace-with-strong-password
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U openflare -d openflare"]
interval: 10s
timeout: 5s
retries: 5
openflare:
image: ghcr.io/rain-kl/openflare:latest
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
ports:
- "3000:3000"
environment:
SESSION_SECRET: replace-with-a-long-random-string
DSN: postgres://openflare:replace-with-strong-password@postgres:5432/openflare?sslmode=disable
GIN_MODE: release
LOG_LEVEL: info
volumes:
postgres-data:
```
Start:
```bash
docker compose up -d
```
Verify:
```bash
docker compose ps
docker compose logs -f openflare
```
When the `openflare` container is running and logs show `server listening`, open:
```text
http://localhost:3000
```
Default account:
| Username | Password |
| --- | --- |
| `root` | `123456` |
Change the default password immediately after first login.
## 2. Prepare an Agent Token
Agents can connect with either:
| Credential | Use Case |
| --- | --- |
| `discovery_token` | First-time automatic node registration. Server exchanges it for a node-specific token. |
| `agent_token` | A node-specific token created or assigned in the management UI. |
Prepare one of them in the management UI before continuing.
[Needs confirmation: exact UI menu path for creating or viewing `discovery_token` and node `agent_token`]
## 3. Install Agent
Run the install script on the proxy node.
With `discovery_token`:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
--server-url http://your-server:3000 \
--discovery-token YOUR_DISCOVERY_TOKEN
```
With node-specific `agent_token`:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
--server-url http://your-server:3000 \
--agent-token YOUR_AGENT_TOKEN
```
The script defaults to:
| Item | Default |
| --- | --- |
| Install directory | `/opt/openflare-agent` |
| Config file | `/opt/openflare-agent/agent.json` |
| systemd service | `openflare-agent.service` |
| OpenResty path | Auto-detects `openresty` unless `--openresty-path` is provided |
Check status:
```bash
systemctl status openflare-agent
journalctl -u openflare-agent -f
```
If systemd is unavailable, the script prints a manual start command.
## 4. Publish the First Configuration
In the management UI:
1. Create a site configuration with a site name, domain, and origin URL.
2. Ensure the site is enabled.
3. Preview the rendered configuration or review the diff.
4. Publish and activate a new version.
5. Wait for the Agent to discover and apply the version through heartbeat.
Version numbers use `YYYYMMDD-NNN`. Historical versions are immutable; rollback reactivates an old version.
## 5. Verify Success
In the UI:
| Location | Expected Result |
| --- | --- |
| Node list | Agent node is online |
| Node detail | Current version matches the active version |
| Apply logs | Latest apply succeeded |
| Versions page | New version is active |
On the Agent node:
```bash
journalctl -u openflare-agent -n 100 --no-pager
```
## Common Failures
| Symptom | What to Check |
| --- | --- |
| Cannot open the UI | Confirm `docker compose ps` shows Server running and host port `3000` is free |
| Login works but data cannot be saved | Check PostgreSQL health and the username/password/database in `DSN` |
| Agent cannot register | Confirm the Agent node can reach `--server-url`, and check whether the token is wrong or expired |
| Agent is online but does not apply | Confirm the site is enabled and a version was published and activated |
| OpenResty apply fails | Check apply logs and `journalctl -u openflare-agent`, especially domains, certificates, upstream URLs, and port conflicts |
See [Troubleshooting](./troubleshooting.md) for deeper diagnostics.
+106
View File
@@ -0,0 +1,106 @@
# Starting the Server
You will learn: How to build the management console frontend from source, start OpenFlare Server, select SQLite or PostgreSQL, and access Swagger.
OpenFlare Server is a Gin + GORM monolithic control plane, responsible for the management console UI, management APIs, Agent APIs, configuration rendering, version releases, data storage, and aggregated queries.
## Prerequisites
| Project | Requirement |
| --- | --- |
| Go | `1.25+` |
| Node.js | `18+` |
| pnpm | Recommended to use the pnpm declared by the project via `corepack enable` |
| Database | SQLite file directory is writable, or an accessible PostgreSQL instance |
In production environments, it is recommended to explicitly configure `SESSION_SECRET` and prioritize PostgreSQL.
## Build the Management Console Frontend
The Go Server hosts the static artifacts in `openflare_server/web/build`. Before starting from source, build the frontend first:
```bash
cd openflare_server/web
corepack enable
pnpm install
pnpm build
```
Common frontend checks:
```bash
pnpm lint
pnpm typecheck
pnpm test
```
## Start with SQLite
```bash
cd openflare_server
export SESSION_SECRET='replace-with-a-long-random-string'
export SQLITE_PATH='./openflare.db'
export LOG_LEVEL='info'
go run .
```
Listens on port `3000` by default. Access:
```text
http://localhost:3000
```
## Start with PostgreSQL
```bash
cd openflare_server
export SESSION_SECRET='replace-with-a-long-random-string'
export DSN='postgres://openflare:secret@127.0.0.1:5432/openflare?sslmode=disable'
export LOG_LEVEL='info'
go run .
```
`DSN` takes precedence over SQLite once set. When `DSN` and the legacy-named `SQL_DSN` both exist, `DSN` takes precedence.
If the target PostgreSQL database is empty and the local `SQLITE_PATH` file exists, the Server will attempt to migrate SQLite data to PostgreSQL during the startup phase and output the migration progress in the logs.
## Command Line Parameters
```bash
go run . --port 3000 --log-dir ./logs
```
| Parameter | Action | Default Value |
| --- | --- | --- |
| `--port` | Specify the Server listening port | `3000` |
| `--log-dir` | Specify the log directory | Empty (outputs to standard output) |
| `--version` | Output the version and exit | `false` |
| `--help` | Output the help information and exit | `false` |
## First Login
Default account:
| Username | Password |
| --- | --- |
| `root` | `123456` |
Please change the default password immediately after logging in for the first time.
## Swagger
Access after logging into the management console:
```text
http://localhost:3000/swagger/index.html
```
Regenerate Swagger locally:
```bash
go install github.com/swaggo/swag/cmd/swag@v1.16.4
cd openflare_server
swag init -g main.go -o docs
```
The generated Swagger files are located in `openflare_server/docs`.
+104
View File
@@ -0,0 +1,104 @@
# SSO Login
You will learn how to configure GitHub OAuth or a standard OIDC login source for OpenFlare, how to set callback URLs, and how third-party accounts bind to local users.
OpenFlare supports third-party login through authentication sources. The current supported source types are GitHub OAuth and standard OIDC providers, such as Logto, authentik, Keycloak, and Casdoor.
After an authentication source is configured and enabled, it appears on the login page. Users can sign in with the third-party account or bind it to the current local account while already signed in.
## Before You Start
Prepare:
| Item | Description |
| --- | --- |
| OpenFlare public URL | The URL users open in their browser, such as `https://openflare.example.com` |
| Source name | Internal unique name, such as `github` or `company-oidc` |
| Client ID | Provided by the third-party application |
| Client Secret | Provided by the third-party application |
| OIDC Discovery URL | Required only for OIDC, such as `https://idp.example.com/.well-known/openid-configuration` |
Confirm that the server address in system settings matches the domain users access.
The source name can contain letters, numbers, hyphens, and underscores, and must start with a letter or number. The source name is part of the callback URL. If you rename it later, update the callback URL in the third-party platform too.
## Callback URL
Set the Redirect URI / Callback URL in the third-party platform to:
```text
<OpenFlare public URL>/oauth/<source name>
```
Examples:
```text
https://openflare.example.com/oauth/github
https://openflare.example.com/oauth/company-oidc
```
When creating or editing an authentication source, the UI shows the callback URL based on the current browser URL and source name.
## Configure GitHub Login
1. Create an OAuth App in GitHub.
2. Set `Homepage URL` to the OpenFlare public URL.
3. Set `Authorization callback URL` to the callback shown by OpenFlare, such as `https://openflare.example.com/oauth/github`.
4. Copy the Client ID and Client Secret.
5. Sign in to OpenFlare and open Settings -> System Settings -> Authentication Sources.
6. Add a source and select `GitHub`.
7. Fill in source name, display name, Client ID, and Client Secret.
8. Keep the default scope `user:email` unless your GitHub app requires a different value.
9. Save and enable the source.
The login page will show the GitHub button after the source is enabled.
## Configure OIDC Login
1. Create an application or client in the OIDC provider.
2. Choose a Web / Confidential Client type.
3. Set Redirect URI / Callback URL to the value shown by OpenFlare, such as `https://openflare.example.com/oauth/company-oidc`.
4. Copy the Client ID and Client Secret.
5. Get the provider Discovery URL, usually ending in `/.well-known/openid-configuration`.
6. Sign in to OpenFlare and open Settings -> System Settings -> Authentication Sources.
7. Add a source and select `OIDC`.
8. Fill in source name, display name, Client ID, Client Secret, and OIDC Discovery URL.
9. Keep the default scope `openid profile email` unless the provider restricts scopes.
10. Save and enable the source.
The login page will show the OIDC button after the source is enabled.
## Login and Binding Behavior
| Scenario | Behavior |
| --- | --- |
| Third-party account already bound to a local user | Sign in directly |
| User is already signed in and starts third-party authorization | Bind the third-party account to the current local user |
| Third-party account is unbound and registration is allowed | Create a normal local user and bind it |
| Third-party account is unbound and registration is disabled | Ask the user to enter existing local credentials to bind |
If you only want existing users to use SSO, disable registration. Unbound third-party accounts will enter the existing-account binding flow.
## Update a Source
When editing an authentication source, leave Client Secret empty to keep the existing secret. Entering a new value overwrites it.
If you change the source name, the callback URL changes too. Update Redirect URI / Callback URL in the third-party platform, or the provider will reject the callback.
## FAQ
### `invalid_scope`
The provider does not allow the configured scope. The OIDC default is `openid profile email`; the GitHub default is `user:email`. Adjust the scope in OpenFlare or allow it in the provider.
### Callback URL Mismatch
Check that the Redirect URI / Callback URL in the provider exactly matches the URL shown by OpenFlare. Protocol, domain, port, and path must all match.
### No Third-Party Login Button
Check that the source is enabled and that Client ID and Client Secret are saved. OpenFlare validates these fields before enabling a source.
### Client Secret Is Not Shown in the List
This is expected. OpenFlare does not return Client Secret through the API; it only shows whether the secret is configured.
+225
View File
@@ -0,0 +1,225 @@
# Troubleshooting
You will learn how to debug OpenFlare Server, database, login, Agent, OpenResty, release, and frontend build issues by symptom.
Start by locating the failing layer: browser, Server, database, Agent, OpenResty, origin, or DNS. OpenFlare applies configuration only after a version is activated and the Agent discovers it through heartbeat.
## Quick Triage
| Symptom | Check First |
| --- | --- |
| Management UI does not open | Server process/container logs and port binding |
| Login fails | Default account, `SESSION_SECRET`, browser request, Server logs |
| Data cannot be saved | Database connection, SQLite permissions, PostgreSQL health |
| Agent is offline | Agent logs, token, Server URL, network reachability |
| Node does not update after release | Active version, node heartbeat, apply logs |
| OpenResty apply fails | Apply logs, Agent logs, certificates, upstream URL, port conflicts |
| No access analytics | OpenResty status, observability port, Agent replay logs |
## Server Does Not Start
1. Check logs:
```bash
docker compose logs -n 200 openflare
```
For source runs, check terminal output.
2. Check port usage:
```bash
lsof -i :3000
```
3. If PostgreSQL is used, check database health:
```bash
docker compose ps postgres
docker compose logs -n 100 postgres
```
4. If SQLite is used, check that the database directory is writable:
```bash
ls -ld "$(dirname /path/to/openflare.db)"
```
Common causes:
| Log or Symptom | Fix |
| --- | --- |
| Database connection failed | Check username, password, host, port, database, and `sslmode` in `DSN` |
| SQLite cannot create file | Check that the `SQLITE_PATH` directory exists and is writable |
| Port is already in use | Change `PORT` or `--port`, or stop the process using the port |
## UI Does Not Open or Is Blank
1. Confirm that the Server responds:
```bash
curl -I http://127.0.0.1:3000
```
2. For source runs, confirm frontend static assets were built:
```bash
cd openflare_server/web
pnpm build
```
3. Check whether the browser URL matches your reverse proxy setup.
4. If using the frontend dev server, confirm backend proxy configuration:
```bash
cd openflare_server/web
NEXT_DEV_BACKEND_URL=http://127.0.0.1:3000 pnpm dev
```
## Default Account Cannot Sign In
The default account is `root` / `123456`. If the password was changed after first login, use the updated password.
Steps:
1. Confirm the Server is connected to the expected database, not another `SQLITE_PATH` or `DSN`.
2. Check Server logs to see whether it uses `sqlite` or `postgres`.
3. If deployed behind replicas or a reverse proxy, ensure `SESSION_SECRET` is fixed and consistent across instances.
4. Clear browser cookies and try again.
[Needs confirmation: whether the project provides a safe root password reset command or procedure]
## Agent Cannot Register or Stays Offline
On the Agent node:
```bash
curl -I http://your-server:3000
```
Check Agent logs:
```bash
journalctl -u openflare-agent -n 200 --no-pager
```
Check config:
```bash
sed -n '1,160p' /opt/openflare-agent/agent.json
```
Confirm:
| Config | Notes |
| --- | --- |
| `server_url` | Must be reachable from the Agent node |
| `agent_token` / `discovery_token` | At least one is required |
| `heartbeat_interval` | Supports millisecond integers or Go duration strings |
| `request_timeout` | Increase it for slow networks |
If the log says the token is invalid, prepare a new token in the UI, update `agent.json`, and restart:
```bash
systemctl restart openflare-agent
```
## Node Does Not Apply a New Version
Check in order:
1. The target version is active on the versions page.
2. The node is online and heartbeat time is updating.
3. Apply logs contain a success, warning, or failure for the target version.
4. The site configuration is enabled.
5. Agent logs show pull, validation, reload, or rollback messages.
Follow Agent logs:
```bash
journalctl -u openflare-agent -f
```
After a target `version + checksum` fails and rolls back, the Agent blocks repeated attempts for that same target locally. Fix the configuration and publish a new checksum, or activate an old version to roll back.
## OpenResty Apply Fails
Common causes:
| Cause | Check |
| --- | --- |
| Domain or server block conflict | Ensure the same domain is not used by multiple sites |
| Invalid upstream URL | Every upstream must be `http://` or `https://` |
| Invalid multi-upstream format | Multiple upstreams must be plain `scheme://host[:port]` |
| Missing certificate or wrong path | Check domain certificate binding and Agent certificate directory permissions |
| Port conflict | Check local `80` and `443` usage |
OpenResty config test:
```bash
openresty -t -c /path/to/openflare/data/etc/nginx/nginx.conf
```
OpenResty runtime:
```bash
ps aux | grep openresty
```
Agent periodic health checks use local `http://127.0.0.1:<openresty_observability_port>/openflare/stub_status` instead of repeatedly running `openresty -t`. If a node is unhealthy, first confirm that the local observability port is listening. If `host not found in upstream` only appears during apply, the failure comes from config validation or reload, not the periodic health probe.
Use the actual `openresty_path` and `main_config_path` from `agent.json`.
## HTTPS Does Not Work
1. Confirm the certificate exists.
2. Confirm the domain is bound to that certificate in the site configuration.
3. Confirm a new version was published and activated.
4. Check apply logs for success.
5. Inspect with `curl`:
```bash
curl -Iv https://your-domain
```
Domains without a bound certificate are not automatically added to HTTPS configuration.
## No Access Analytics
1. Confirm the node applied a configuration that includes observability Lua assets.
2. Confirm OpenResty is running.
3. Check Agent logs for collection or replay failures.
4. Check whether `openresty_observability_port` is occupied. The default is `18081`.
5. Confirm Server cleanup policy did not remove data for that time window.
## Frontend Build Fails
```bash
cd openflare_server/web
corepack enable
pnpm install
pnpm lint
pnpm typecheck
pnpm test
pnpm build
```
Common causes:
| Symptom | Fix |
| --- | --- |
| pnpm version mismatch | Run `corepack enable` and reinstall |
| Type errors | Run `pnpm typecheck` to locate files |
| API type mismatch | Check `lib/api/` and `types/` response structures |
| E2E fails | Ensure both the Server and frontend dev server are running |
## Docs Build Fails
```bash
cd docs
pnpm install
pnpm build
```
If the failure is a link error, check that new pages are added to `docs/en/config.ts` and that relative links point to existing Markdown files.
+85
View File
@@ -0,0 +1,85 @@
# Upgrade and Maintenance
You will learn: How to upgrade the Server and Agent, how to clean up observability data, and which verification commands to execute before and after maintenance.
Before upgrading, it is recommended to confirm the current activated version, the latest Agent application result, and the database backup policy. Do not upgrade in production environments while configuration publishing, large-scale Agent reconnection, or database migrations are in progress.
## Server Upgrade
Root users can check and upgrade the Server stable version from the top bar of the management console. Upgrades can also be confirmed and executed by uploading the Server binary.
To try a preview version, you can manually check the corresponding release. It is recommended to prioritize the stable version in production environments.
After upgrading, confirm:
```bash
docker compose ps
docker compose logs -n 100 openflare
```
If it is a source deployment, confirm that there are no database migration or startup errors in the logs after restarting the Server.
## Agent Upgrade
Node Agents follow stable versions by default for automatic updates. Preview upgrades must be triggered manually.
The installation script can be executed repeatedly to reinstall or upgrade the Agent:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
--server-url http://your-server:3000 \
--agent-token YOUR_AGENT_TOKEN
```
Note: Currently, the installation script will delete the entire installation directory during reinstallation, including the old `agent.json`, local state, cache data, and downloaded binaries. Please confirm that you still have a usable Token on hand before executing.
After upgrading, confirm:
```bash
systemctl status openflare-agent
journalctl -u openflare-agent -n 100 --no-pager
```
## Data Maintenance
The settings page of the management console can maintain the observability data automatic cleanup policy:
| Configuration Item | Description |
| --- | --- |
| `DatabaseAutoCleanupEnabled` | Whether to enable daily automatic cleanup |
| `DatabaseAutoCleanupRetentionDays` | Automatic cleanup retention days, at least 1 day |
Once enabled, the Server will clean up access logs, metric snapshots, and request reports at 3 AM every day.
## Common Verification Commands
Server:
```bash
cd openflare_server
GOCACHE=/tmp/openflare-go-cache go test ./...
```
Agent:
```bash
cd openflare_agent
GOCACHE=/tmp/openflare-go-cache go test ./...
```
Frontend:
```bash
cd openflare_server/web
pnpm lint
pnpm typecheck
pnpm test
pnpm build
```
Docs:
```bash
cd docs
pnpm build
```
+134
View File
@@ -0,0 +1,134 @@
# Usage
You will learn what sites, origins, certificates, versions, nodes, and observability mean in OpenFlare, and which order to follow for daily operations.
OpenFlare does not patch OpenResty configuration files online. You edit control-plane data in the UI; Agents pull and apply a full configuration only after you publish and activate a new version.
## Core Concepts
| Concept | Description |
| --- | --- |
| Site configuration | The reverse proxy aggregation object. One site can bind one or more domains. |
| Primary domain | The first item in the `domains` list. |
| Origin | The upstream service address, such as `http://10.0.0.10:8080`. |
| Configuration version | A full OpenResty configuration snapshot generated by a release. Historical versions are immutable. |
| Active version | The globally effective version. By default, all nodes consume the same active version. |
| Agent | The node-side process that registers, heartbeats, syncs, validates, reloads, and rolls back on failure. |
## Recommended Workflow
For a normal reverse proxy change:
1. Confirm that at least one Agent node is online.
2. Create or select an origin.
3. Create a site configuration with domains, upstreams, and site-level settings.
4. If HTTPS is needed, upload or select certificates and bind them per domain.
5. Preview the rendered configuration or review the diff.
6. Publish and activate a new version.
7. Check node details and apply logs.
## Create a Site
A site requires at least:
| Field | Requirement |
| --- | --- |
| Site name | Business-unique identifier. The primary domain is a common default. |
| Domains | At least one domain. The first domain is the primary domain. Each domain must be globally unique. |
| Origin URL | A valid `http://` or `https://` upstream address. |
| Enabled state | Only enabled sites are included in release rendering. |
Example:
| Field | Example |
| --- | --- |
| Site name | `docs` |
| Domain | `docs.example.com` |
| Origin URL | `http://10.0.0.10:8080` |
| Origin Host | `docs.internal.example.com` |
Upstream rules:
* A single upstream may include a base path or query string, such as `https://app.example.com/base?from=openflare`.
* Multiple upstreams are used for load balancing and must be plain `scheme://host[:port]`.
* Multiple upstreams in the same site should use the same protocol.
## Manage Origins
Origins are a lightweight reusable address directory. When a site references an origin, the site still stores a renderable `origin_url` snapshot so historical versions can be replayed independently.
Recommended practices:
* Store frequently reused internal service addresses as origins.
* After changing an origin entry, check whether site snapshots need to be updated.
* Use preview or diff before publishing.
## Enable HTTPS
HTTPS is bound per domain, not forced for the whole site.
1. Upload or create a certificate record.
2. Open the site configuration and select a certificate for each domain that needs HTTPS.
3. Domains without a certificate stay HTTP-only and are not automatically added to `443 ssl` server blocks.
4. Publish and activate a new version.
If a site contains multiple domains, the Server groups HTTPS output by certificate while keeping all domains in the same site snapshot.
## Release, Activate, and Roll Back
Standard flow:
```text
Edit configuration -> Preview / diff -> Release -> Generate full version -> Activate version -> Agent pulls -> Agent applies locally -> Agent reports result
```
During release, the Server reads all enabled site configurations, OpenResty main template, performance options, cache options, and certificate assets. It renders a full configuration and calculates a `checksum`.
Rollback means reactivating an old version. The Agent then applies that version through the normal sync flow.
## Nodes and Observability
Node pages answer three questions:
| Question | Where to Check |
| --- | --- |
| Is the node online? | Node list or node detail |
| Which version is running? | Current version on the node detail page |
| Did the last apply succeed? | Apply logs |
Access analytics and resource snapshots provide basic observability. OpenFlare only keeps access details for a controlled time window; it is not a general-purpose log platform. Use a dedicated logging system for long-term log search.
## Common Scenarios
### Add a Reverse Proxy for an Internal Service
1. Confirm the Agent node can reach the origin service.
2. Create a site configuration.
3. Add a domain, such as `app.example.com`.
4. Add an origin, such as `http://10.0.0.20:8080`.
5. Publish and activate the version.
6. Verify the domain from a browser or with `curl`.
### Enable HTTPS for an Existing Domain
1. Prepare a certificate that covers the domain.
2. Upload or create the certificate record.
3. Bind the certificate to the domain in the site configuration.
4. Publish and activate a new version.
5. Verify with `curl -I https://your-domain`.
### Roll Back a Failed Release
1. Open the configuration versions page.
2. Find the last known good version.
3. Activate that version again.
4. Check apply logs until the Agent reports success.
5. Fix the configuration and publish a new version.
## Recommended Practices
* Set `SESSION_SECRET` explicitly in production and prefer PostgreSQL.
* Preview or diff changes before release.
* Check node details and apply logs after each release.
* Keep the network path from Agents to the Server stable.
* Do not manually edit OpenFlare-managed OpenResty files on nodes; the next release will overwrite them.
+32
View File
@@ -0,0 +1,32 @@
---
layout: home
hero:
name: OpenFlare
text: Self-hosted OpenResty control plane
tagline: Manage reverse proxy rules, configuration releases, node sync, TLS certificates, and basic observability.
actions:
- theme: brand
text: Quick Start
link: /en/guide/quick-start
- theme: alt
text: Design Boundary
link: /en/design/
- theme: alt
text: GitHub
link: https://github.com/Rain-kl/OpenFlare
features:
- icon: 🧭
title: Unified Control Plane
details: Manage sites, domains, origins, certificates, nodes, and release state in one console.
- icon: 🚀
title: Immutable Releases
details: Each publish creates a full OpenResty configuration snapshot that can be previewed, activated, and rolled back.
- icon: 🔁
title: Agent Automation
details: Nodes pull, validate, reload, and roll back to the last runnable configuration on failure.
- icon: 📊
title: Basic Observability
details: Includes request rollups, access analytics, resource snapshots, health events, and node details.
---
@@ -0,0 +1,84 @@
# Agent Unified OpenResty Binary Control Scheme
## Summary
Unify the Agent running model as "write to the managed configuration file, then call the `openresty` binary to execute `-t`, reload, start/restart". Docker deployment no longer has the Agent control another OpenResty container, but instead provides an independent `ghcr.io/rain-kl/openflare-agent` image; this image is based on `openresty/openresty`, with the Agent controller and OpenResty binary built-in.
## Key Changes
- Agent runtime:
- Remove the DockerExecutor / Docker container management logic from the production path.
- Default to using `openresty` when `openresty_path` is not configured.
- Uniformly execute binary calls with `-c <main_config_path>` to avoid misreading the OpenResty default configuration.
- The apply flow is: backup -> write files -> `openresty -t -c ...` -> reload; if reload indicates it is not running, then start.
- restart uses `openresty -c ... -s quit` followed by `openresty -c ...` to start, keeping fault tolerance for missing PIDs.
- Configurations and File Responsibilities:
- Keep parser compatibility for old fields `openresty_container_name`, `openresty_docker_image`, and `docker_binary`, but mark them as deprecated and no longer involved in control logic.
- Add `access_log_path`, defaulting to `data_dir/var/log/openflare/access.log`, and no longer placing access logs inside `conf.d`.
- Add `runtime_config_dir`, defaulting to `data_dir/etc/openflare`, where `pow_config.json` is written.
- `cert_dir` only writes certificate/key files; `lua_dir` only writes Lua code and static resources.
- Support splitting files before writing: certificate files go into `cert_dir`, and `pow_config.json` goes into `runtime_config_dir`.
- Docker Agent Image:
- Add `openflare_agent/Dockerfile`, with the runtime image based on `openresty/openresty:alpine`.
- Defaults to `OPENFLARE_OPENRESTY_PATH=openresty` and `OPENFLARE_DATA_DIR=/data`.
- Expose `80`, `443`, and `18081`.
- Support mounting `/etc/openflare/agent.json`, and also support environment variable configurations.
- CI publishes independent multi-architecture images: `ghcr.io/rain-kl/openflare-agent:<version>` and `latest`.
- Agent Configuration Entry:
- Keep `-config` + `agent.json`.
- Add environment variable overrides/fallbacks: `OPENFLARE_SERVER_URL`, `OPENFLARE_AGENT_TOKEN`, `OPENFLARE_DISCOVERY_TOKEN`, `OPENFLARE_NODE_NAME`, `OPENFLARE_NODE_IP`, `OPENFLARE_DATA_DIR`, `OPENFLARE_OPENRESTY_PATH`, `OPENFLARE_HEARTBEAT_INTERVAL`, `OPENFLARE_REQUEST_TIMEOUT`, `OPENFLARE_OPENRESTY_OBSERVABILITY_PORT`.
- If the configuration file does not exist but environment variables are sufficient, the Agent can start directly; if both exist, environment variables override file values.
- Scripts and Documentation:
- `install-agent.sh` becomes a local OpenResty deployment script, adding `--openresty-path` and automatically finding `openresty` when not passed.
- `uninstall-agent.sh` only uninstalls the Agent itself, and no longer deletes the Docker OpenResty container or image.
- Update architecture, development constraints, deployment instructions, Agent guide, configuration item reference, README, and old Docker control descriptions in English image documents.
## Public Interfaces
- Add Agent configuration fields:
- `access_log_path`
- `runtime_config_dir`
- Deprecated but compatibly read:
- `openresty_container_name`
- `openresty_docker_image`
- `docker_binary`
- Add Docker image:
- `ghcr.io/rain-kl/openflare-agent`
- Target Docker execution method examples:
- Mount configuration file: `-v ./agent.json:/etc/openflare/agent.json`
- Or environment variables: `-e OPENFLARE_SERVER_URL=... -e OPENFLARE_AGENT_TOKEN=...`
## Test Plan
- `openflare_agent/internal/config`:
- Default `openresty_path` is `openresty`.
- Old Docker fields can be read but do not affect the executor.
- Environment variables can start the Agent without a configuration file and can override the configuration file.
- New default paths conform to responsibility boundaries.
- `openflare_agent/internal/nginx`:
- Binary commands all include `-c <main_config_path>`.
- Apply success, reload failure rollback, and start fallback when not running.
- `pow_config.json` is no longer written to `cert_dir` or `lua_dir`.
- Stale `cert_dir/pow_config.json` and `lua_dir/pow_config.json` will be cleaned up.
- access log is rendered to `access_log_path`.
- checksum can still uniformly include the main config, route config, certificates, and PoW config into comparisons.
- Integration Regression:
- `cd openflare_agent && GOCACHE=/tmp/openflare-go-cache go test ./...`
- `cd openflare_server && GOCACHE=/tmp/openflare-go-cache go test ./...`
- Dockerfile build smoke test: build the Agent image and start it using env-only configuration to the executable stage.
## Assumptions
- Docker Agent image name is fixed as `ghcr.io/rain-kl/openflare-agent`.
- Old Docker control fields are compatibly preserved but are no longer a supported behavior.
- This phase does not modify Server APIs, does not modify database models, and does not introduce remote command capabilities.
- The OpenResty main configuration template continues to be generated by the Server; the Agent is only responsible for local path replacement, file landing, and binary control.
+48
View File
@@ -0,0 +1,48 @@
# API Conventions
You will learn: The response structure, path conventions, authentication methods, and Swagger entry point for OpenFlare management and Agent APIs.
Both the OpenFlare management APIs and Agent APIs use JSON.
## Response Structure
Both success and failure should return a clear `message`:
```json
{
"success": true,
"message": "",
"data": {}
}
```
## Path Conventions
| Type | Convention |
| --- | --- |
| Management API | Authenticated by management console Session |
| Agent API | Fixed under `/api/agent/*` |
| Read-only API | Use `GET` |
| Mutation-type API | Use `POST` |
## Authentication
The management console continues to reuse the existing login, role, and Session system.
Official Agent requests uniformly use the node-exclusive `agent_token`; the first access can use the global `discovery_token`. The Agent request header is fixed as:
```http
X-Agent-Token: <token>
```
Full Tokens must not be printed in the logs.
## Swagger
Accessible after logging into the management console:
```text
/swagger/index.html
```
The Swagger files are located in `openflare_server/docs`, generated by `swag init`.
+117
View File
@@ -0,0 +1,117 @@
# Commands and Scripts
You will learn: Common commands for starting, building, testing, installing, and uninstalling the OpenFlare Server, management console frontend, Agent, Swagger, and documentation site.
## Server
Start from source:
```bash
cd openflare_server
export SESSION_SECRET='replace-with-random-string'
export SQLITE_PATH='./openflare.db'
export LOG_LEVEL='info'
go run .
```
Specify listening port and log directory:
```bash
go run . --port 3000 --log-dir ./logs
```
Test:
```bash
cd openflare_server
GOCACHE=/tmp/openflare-go-cache go test ./...
```
## Frontend
Development:
```bash
cd openflare_server/web
pnpm install
pnpm dev
```
Build static artifacts:
```bash
cd openflare_server/web
pnpm build
```
Checks:
```bash
cd openflare_server/web
pnpm lint
pnpm typecheck
pnpm test
```
## Agent
Run from source:
```bash
cd openflare_agent
go run ./cmd/agent -config /path/to/agent.json
```
Compile:
```bash
cd openflare_agent
go build -o openflare-agent ./cmd/agent
```
Test:
```bash
cd openflare_agent
GOCACHE=/tmp/openflare-go-cache go test ./...
```
## Install Agent
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
--server-url http://your-server:3000 \
--agent-token YOUR_AGENT_TOKEN
```
## Uninstall Agent
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/uninstall-agent.sh | bash
```
## Swagger
Regenerate Swagger documentation:
```bash
go install github.com/swaggo/swag/cmd/swag@v1.16.4
cd openflare_server
swag init -g main.go -o docs
```
## Docs
Local preview:
```bash
cd docs
pnpm dev
```
Build:
```bash
cd docs
pnpm build
```
+78
View File
@@ -0,0 +1,78 @@
# Configuration
## Server CLI Flags
| Flag | Purpose | Default |
| --- | --- | --- |
| `--port` | Server listen port | `3000` |
| `--log-dir` | Log directory | empty |
| `--version` | Print version and exit | `false` |
| `--help` | Print help and exit | `false` |
## Server Environment Variables
| Variable | Purpose | Default |
| --- | --- | --- |
| `PORT` | Server listen port | `3000` |
| `GIN_MODE` | Gin mode | release unless `debug` |
| `LOG_LEVEL` | Log level | `info` |
| `SESSION_SECRET` | Session signing secret | random on startup |
| `SQLITE_PATH` | SQLite database path | `openflare.db` |
| `DSN` | PostgreSQL DSN, preferred over SQLite | empty |
| `SQL_DSN` | Legacy PostgreSQL DSN, lower priority than `DSN` | empty |
| `REDIS_CONN_STRING` | Redis connection string | empty |
| `UPLOAD_PATH` | Upload directory | `upload` |
| `AGENT_TOKEN` | Legacy global Agent token | empty |
When `DSN` and `SQL_DSN` both exist, `DSN` wins. PostgreSQL is preferred when configured. If PostgreSQL is empty and a local SQLite file exists, Server migrates SQLite data at startup.
## Frontend Build Variables
| Variable | Purpose | Default |
| --- | --- | --- |
| `NEXT_PUBLIC_API_BASE_URL` | Frontend API base path | `/api` |
| `NEXT_PUBLIC_APP_VERSION` | Displayed frontend version | `dev` |
| `NEXT_DEV_BACKEND_URL` | Local dev backend proxy target | `http://127.0.0.1:3000` |
## Runtime Options
The settings page maintains these hot-updatable options:
| Option | Purpose | Default |
| --- | --- | --- |
| `AgentHeartbeatInterval` | Agent heartbeat interval in milliseconds | `10000` |
| `NodeOfflineThreshold` | Node offline threshold in milliseconds | `120000` |
| `AgentUpdateRepo` | Agent update repository | `Rain-kl/OpenFlare` |
| `GeoIPProvider` | Node/IP region provider | `ipinfo` |
| `DatabaseAutoCleanupEnabled` | Enable daily observability cleanup | `false` |
| `DatabaseAutoCleanupRetentionDays` | Retention days | `30` |
OpenResty performance and cache options are also stored in the Option table, including `OpenRestyWorkerProcesses`, `OpenRestyWorkerConnections`, `OpenRestyProxyConnectTimeout`, `OpenRestyProxyReadTimeout`, `OpenRestyCacheEnabled`, `OpenRestyCachePath`, and `OpenRestyCacheMaxSize`.
`AgentUpdateRepo` releases must publish a matching `.sha256` file for each Agent binary, such as `openflare-agent-linux-amd64.sha256`. Agent self-update verifies the SHA-256 digest before replacing the executable.
## Agent Configuration
Agent supports the `-config` CLI flag, an `agent.json` file, and the `LOG_LEVEL` environment variable.
| Field | Purpose | Required | Default / behavior |
| --- | --- | --- | --- |
| `server_url` | Control plane URL | yes | none |
| `agent_token` | Node-specific auth token | one of `agent_token` / `discovery_token` | empty |
| `discovery_token` | Global token for first registration | one of `agent_token` / `discovery_token` | empty |
| `node_name` | Node name | no | host name |
| `node_ip` | Node IP | no | auto-detected; Agent first queries the public egress IP through a third-party API, then falls back to local interfaces |
| `openresty_path` | OpenResty binary path | no | `openresty` |
| `openresty_container_name` | Deprecated Docker-control field, read for compatibility only | no | empty |
| `openresty_docker_image` | Deprecated Docker-control field, read for compatibility only | no | empty |
| `openresty_observability_port` | Local observability and OpenResty health-check port | no | `18081` |
| `docker_binary` | Deprecated Docker-control field, read for compatibility only | no | empty |
| `data_dir` | Agent data directory | no | `data` under config directory |
| `access_log_path` | OpenResty access log path | no | `data_dir/var/log/openflare/access.log` |
| `runtime_config_dir` | Runtime config directory, including `pow_config.json` | no | `data_dir/etc/openflare` |
| `heartbeat_interval` | Heartbeat interval | no | `10000` ms |
| `request_timeout` | HTTP timeout | no | `10000` ms |
`heartbeat_interval` and `request_timeout` accept milliseconds or Go duration strings.
When `node_ip` is not configured, Agent first queries `https://realip.cc` for the real public egress IP, which avoids recording a Docker bridge address in container deployments. If that lookup fails, Agent falls back to local interface detection and prefers a public IPv4 address.
+12
View File
@@ -0,0 +1,12 @@
# Reference
You will learn: What information belongs to stable reference materials, and where to look up configurations, commands, APIs, and the repository structure.
This section collects stable information at the runtime, interface, and repository levels, suitable for quick lookups during deployment, joint debugging, and troubleshooting.
| Page | Content |
| --- | --- |
| [Configuration Items](./configuration.md) | Server environment variables, command line parameters, runtime Options, and Agent configuration fields |
| [Commands and Scripts](./cli.md) | Common startup, build, test, install, and uninstall commands |
| [API Conventions](./api.md) | Response structure, authentication, and path conventions of management and Agent APIs |
| [Repository Layout](./repository.md) | Responsibilities of `openflare_server`, `openflare_agent`, `openflare_server/web`, and `docs` |
+47
View File
@@ -0,0 +1,47 @@
# Repository Layout
You will learn: What the Server, Agent, frontend, scripts, and documentation directories in the OpenFlare repository are responsible for, and which layer to place your logic when contributing code.
| Path | Responsibility |
| --- | --- |
| `openflare_server` | Gin + GORM + SQLite/PostgreSQL monolithic control plane |
| `openflare_server/web` | Next.js 15 App Router management console frontend, statically exported and hosted by the Go Server |
| `openflare_agent` | Go monolithic Agent, running on the node side |
| `scripts` | Helper scripts such as Agent installation, uninstallation, etc. |
| `docs` | VitePress documentation site, design baseline, development constraints, deployment, and configuration documents |
## Server Layering
| Directory | Responsibility |
| --- | --- |
| `controller/` | Parameter parsing, calling services, returning responses |
| `service/` | Business logic, verification, transaction orchestration, configuration rendering |
| `model/` | Model definition, database version, and migration |
| `router/` | Route registration |
| `middleware/` | Auth, authorization, rate limiting, and other cross-cutting logic |
| `common/` | Configuration, global state, and initialization entry points |
| `utils/` | Pure utility functions and general helpers |
## Agent Modules
| Module | Responsibility |
| --- | --- |
| `config` | Configuration reading and default values |
| `heartbeat` | Heartbeat and version summary judgment |
| `sync` | Configuration pulling and application orchestration |
| `nginx` / `openresty` | OpenResty file writing, verification, reload, startup, and rollback |
| `state` | Local state and observability supplementary reporting buffer |
| `httpclient` | Server communication |
| `protocol` | Agent API protocol types |
| `internal/updater` | Agent self-updating |
## Frontend Layering
| Directory | Responsibility |
| --- | --- |
| `app/` | Routes, layouts, page assembly |
| `features/` | Organize modules by business domains |
| `components/` | Reuse components across features |
| `lib/` | Request client, environment variables, utility functions, constants |
| `store/` | A small amount of cross-page UI state |
| `types/` | Shared type definitions |
-154
View File
@@ -1,154 +0,0 @@
# OpenFlare 前端开发规范
本文档约束 `openflare_server/web` 的正式前端工程。它描述的是 `1.0.0` 之后仍然有效的结构、请求层、组件、样式、状态管理与测试基线。
## 1. 技术基线
默认技术栈:
* Next.js 15 App Router
* React 19
* TypeScript 5
* Tailwind CSS 4
* TanStack Query
* React Hook Form + Zod
* Zustand
* ESLint + Prettier
* Vitest + Testing Library + Playwright
* pnpm
要求:
* 默认使用 TypeScript
* 默认使用函数组件
* 默认使用 App Router
* 前端必须支持 `light`、`dark`、`system` 三种主题模式
禁止:
* 引入 Semantic UI
* 新增大型 UI 框架破坏现有组件基线
* 使用 jQuery 风格 DOM 操作
## 2. 目录与分层
推荐目录:
```text
app/
components/
features/
lib/
hooks/
store/
types/
styles/
tests/
```
职责约束:
* `app/`:路由、布局、页面组装
* `features/`:按业务域组织模块
* `components/`:跨 feature 复用组件
* `lib/`:请求客户端、环境变量、工具函数、常量
* `store/`:少量跨页面 UI 状态
* `types/`:共享类型定义
## 3. 路由与页面
页面文件只负责:
* 获取路由参数
* 组织页面结构
* 调用 feature 组件
页面不应负责:
* 手写复杂 API 细节
* 编写复杂表单校验逻辑
* 维护大量彼此耦合的局部状态
## 4. 数据请求与类型
### 4.1 请求层
所有 API 请求必须统一经过 `lib/api/`。
要求:
* 统一处理 `success/message/data` 响应结构
* 统一处理鉴权失效、网络异常和通用错误消息
* 统一维护资源接口与请求路径
禁止:
* 在页面组件中直接调用 `fetch('/api/...')`
* 在多个组件中重复拼接同一接口路径
### 4.2 状态分层
* 服务端状态:TanStack Query
* 页面临时状态:组件内部 `useState`
* 跨页面 UI 状态:Zustand
不推荐:
* 用 Zustand 保存服务端主数据
* 用 Context 代替完整数据层方案
### 4.3 类型
要求:
* 开启 TypeScript 严格模式
* 禁止滥用 `any`
* API 响应、表单输入、业务实体必须有明确类型
## 5. 表单与交互
统一使用:
* React Hook Form
* Zod
高风险操作必须:
* 二次确认
* 展示操作对象名称
* 明确成功与失败反馈
## 6. 样式与主题
样式原则:
* 统一使用 Tailwind CSS 与现有 token 体系
* 优先复用已有基础组件与布局组件
* 保持视觉层级、留白与语义颜色一致
主题要求:
* 同时支持 `light`、`dark`、`system`
* 用户选择必须持久化
* 首屏尽量避免主题闪烁
## 7. 测试与交付
每个页面至少具备:
* 加载态
* 空态
* 错误态
* 成功反馈
测试要求:
* 公共工具、类型转换、主题逻辑补单元测试
* 关键页面交互补组件测试
* 核心主链路补 Playwright 或等效联调验证
交付要求:
* 构建产物保持可静态导出
* 构建结果可被 Go Server 托管
* 新页面默认通过亮色与暗色模式验收
+172
View File
@@ -0,0 +1,172 @@
# 接入 Agent
你会学到:Agent 的职责、两种接入 Token 的区别、安装脚本参数、`agent.json` 配置方式,以及如何确认节点已经上线。
OpenFlare Agent 运行在代理节点侧。它不会接收远程 shell 指令,而是通过 Agent API 拉取控制面发布的配置版本,在本地写入 OpenResty 文件、执行配置校验、reload,并在失败时尝试回滚到可运行配置。
## 接入方式
| 方式 | 适用场景 |
| --- | --- |
| `discovery_token` | 首次自动注册节点,由 Server 置换为节点专属凭证 |
| `agent_token` | 已在管理端创建或分配节点,直接使用节点专属凭证接入 |
`agent_token` 与 `discovery_token` 至少填写一个。
[需要确认:当前管理端中创建或查看 `discovery_token` 与节点 `agent_token` 的准确菜单路径]
## 一键安装
使用 `discovery_token`:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
--server-url http://your-server:3000 \
--discovery-token YOUR_DISCOVERY_TOKEN
```
使用节点专属 `agent_token`:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
--server-url http://your-server:3000 \
--agent-token YOUR_AGENT_TOKEN
```
安装脚本会下载最新 Agent,默认写入 `/opt/openflare-agent`,生成 `agent.json`,并在 Linux + systemd 环境创建 `openflare-agent.service`。
支持参数:
| 参数 | 说明 |
| --- | --- |
| `--server-url` | Server 地址,必填 |
| `--discovery-token` | 首次自动注册 Token |
| `--agent-token` | 节点专属 Token |
| `--install-dir` | 安装目录,默认 `/opt/openflare-agent` |
| `--openresty-path` | OpenResty 二进制路径,未传时自动查找 `openresty` |
| `--repo` | 下载 Agent 的 GitHub 仓库,默认 `Rain-kl/OpenFlare` |
| `--no-service` | 不创建 systemd 服务 |
## 配置文件
默认配置文件路径:
```text
/opt/openflare-agent/agent.json
```
本地配置示例:
```json
{
"server_url": "http://127.0.0.1:3000",
"agent_token": "replace-with-node-auth-token",
"data_dir": "./data",
"openresty_path": "openresty",
"openresty_observability_port": 18081,
"observability_replay_minutes": 15,
"heartbeat_interval": 10000,
"request_timeout": 10000
}
```
自定义 OpenResty 路径示例:
```json
{
"server_url": "http://127.0.0.1:3000",
"agent_token": "replace-with-node-auth-token",
"data_dir": "/var/lib/openflare-agent",
"openresty_path": "/usr/local/openresty/nginx/sbin/openresty",
"main_config_path": "/var/lib/openflare-agent/etc/nginx/nginx.conf",
"route_config_path": "/var/lib/openflare-agent/etc/nginx/conf.d/openflare_routes.conf",
"access_log_path": "/var/lib/openflare-agent/var/log/openflare/access.log",
"cert_dir": "/var/lib/openflare-agent/etc/nginx/certs",
"lua_dir": "/var/lib/openflare-agent/etc/nginx/lua",
"runtime_config_dir": "/var/lib/openflare-agent/etc/openflare",
"heartbeat_interval": 10000,
"request_timeout": 10000
}
```
如果不配置 `openresty_path`,Agent 默认调用 `openresty`。完整字段见 [配置项参考](../reference/configuration.md#agent-配置字段)。
## Docker 运行
Docker 部署时直接运行内置 OpenResty 的 Agent 镜像:
```bash
docker pull ghcr.io/rain-kl/openflare-agent:latest
docker rm -f openflare-agent 2>/dev/null || true
docker run -d --name openflare-agent --restart unless-stopped \
-p 80:80 -p 443:443 \
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
ghcr.io/rain-kl/openflare-agent:latest
```
## 启动与验证
systemd 环境:
```bash
systemctl status openflare-agent
journalctl -u openflare-agent -f
```
手动启动:
```bash
/opt/openflare-agent/openflare-agent -config /opt/openflare-agent/agent.json
```
源码运行:
```bash
cd openflare_agent
export LOG_LEVEL='info'
go run ./cmd/agent -config /path/to/agent.json
```
编译后二进制运行:
```bash
cd openflare_agent
go build -o openflare-agent ./cmd/agent
export LOG_LEVEL='info'
./openflare-agent -config /path/to/agent.json
```
在管理端确认:
| 位置 | 期望结果 |
| --- | --- |
| 节点列表 | 节点在线 |
| 节点详情 | 能看到心跳时间、当前版本和基础资源信息 |
| 应用记录 | 发布配置后出现应用结果 |
## 卸载
如需彻底卸载 Agent 并清空本地数据:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/uninstall-agent.sh | bash
```
支持参数:
| 参数 | 说明 |
| --- | --- |
| `--install-dir` | 安装目录,默认 `/opt/openflare-agent` |
| `--service-name` | systemd 服务名,默认 `openflare-agent` |
卸载脚本只移除 Agent 服务、进程和安装目录,不会删除本机 OpenResty。
## 常见问题
| 现象 | 处理步骤 |
| --- | --- |
| `agent_token 和 discovery_token 不能同时为空` | 检查 `agent.json` 至少配置了一个 Token |
| 节点一直离线 | 在 Agent 节点执行 `curl -I http://your-server:3000`,确认 Server 地址可达 |
| OpenResty 没有启动 | 查看 `journalctl -u openflare-agent`,确认 `openresty_path` 可执行且 80/443 端口未被占用 |
| 发布后重复失败 | Agent 会阻断同一 `version + checksum` 的重复应用;需要修正配置后重新发布,或激活旧版本回滚 |
+297
View File
@@ -0,0 +1,297 @@
# 部署说明
你会学到:OpenFlare 的推荐部署方式、Server 与 Agent 的运行要求、源码启动方式、联调步骤、升级与卸载入口。
生产环境建议使用 PostgreSQL 作为 Server 数据库,并为 Server 显式配置 `SESSION_SECRET`。Agent 统一通过 OpenResty 二进制控制运行时;Docker 部署请直接使用内置 OpenResty 的 Agent 镜像。
## 部署拓扑
```text
Browser
|
v
OpenFlare Server :3000
|
| Agent API / heartbeat / config pull
v
OpenFlare Agent
|
v
OpenResty binary
|
v
Origin service
```
## 前置条件
Server:
| 项目 | 要求 |
| --- | --- |
| Go | `1.25+`,仅源码运行需要 |
| Node.js | `18+`,仅源码构建管理端需要 |
| 数据库 | 可写 SQLite 文件目录,或可访问的 PostgreSQL 实例 |
| 端口 | 默认监听 `3000` |
Agent:
| 项目 | 要求 |
| --- | --- |
| 系统 | 安装脚本支持 Linux 和 macOS;systemd 服务仅在 Linux + systemd 环境创建 |
| 架构 | `amd64` 或 `arm64` |
| OpenResty | 本地部署需要可执行 `openresty`,或通过 `--openresty-path` 指定路径 |
| Docker | 仅 Docker 部署 Agent 镜像时需要 |
| 网络 | Agent 节点必须能访问 Server 地址 |
| GeoIP | WAF 地域规则使用 Agent 本地 MaxMind mmdb;Agent 内置初始库并会定期更新 |
[需要确认:生产环境推荐的最低 CPU、内存与磁盘容量]
## Docker Compose 部署 Server
创建 `docker-compose.yml`:
```yaml
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: openflare
POSTGRES_USER: openflare
POSTGRES_PASSWORD: replace-with-strong-password
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U openflare -d openflare"]
interval: 10s
timeout: 5s
retries: 5
openflare:
image: ghcr.io/rain-kl/openflare:latest
container_name: openflare
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
ports:
- "3000:3000"
environment:
SESSION_SECRET: replace-with-a-long-random-string
DSN: postgres://openflare:replace-with-strong-password@postgres:5432/openflare?sslmode=disable
GIN_MODE: release
LOG_LEVEL: info
volumes:
- openflare-data:/data
volumes:
postgres-data:
openflare-data:
```
启动:
```bash
docker compose up -d
docker compose ps
docker compose logs -f openflare
```
首次访问 `http://localhost:3000`,默认账号为 `root` / `123456`。登录后请立即修改默认密码。
## 源码启动 Server
先构建管理端前端:
```bash
cd openflare_server/web
corepack enable
pnpm install
pnpm build
```
再启动 Server:
```bash
cd openflare_server
export SESSION_SECRET='replace-with-a-long-random-string'
export SQLITE_PATH='./openflare.db'
export LOG_LEVEL='info'
# 可选:设置后优先使用 PostgreSQL。
# export DSN='postgres://openflare:secret@127.0.0.1:5432/openflare?sslmode=disable'
go run .
```
默认监听 `3000` 端口。也可以显式指定:
```bash
go run . --port 3000 --log-dir ./logs
```
## Agent 接入
使用 `discovery_token` 自动注册:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
--server-url http://your-server:3000 \
--discovery-token YOUR_DISCOVERY_TOKEN
```
使用节点专属 `agent_token`:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
--server-url http://your-server:3000 \
--agent-token YOUR_AGENT_TOKEN
```
安装脚本支持参数:
| 参数 | 说明 |
| --- | --- |
| `--server-url` | Server 地址,必填 |
| `--discovery-token` | 首次自动注册 Token,与 `--agent-token` 二选一 |
| `--agent-token` | 节点专属 Token,与 `--discovery-token` 二选一 |
| `--install-dir` | 安装目录,默认 `/opt/openflare-agent` |
| `--openresty-path` | OpenResty 二进制路径,未传时自动查找 `openresty` |
| `--repo` | 下载 Agent 的 GitHub 仓库,默认 `Rain-kl/OpenFlare` |
| `--no-service` | 不创建 systemd 服务 |
确认状态:
```bash
systemctl status openflare-agent
journalctl -u openflare-agent -f
```
## Docker 运行 Agent
Docker 部署时直接运行 Agent 镜像。该镜像基于 OpenResty 镜像制作,内置 Agent 控制器与 OpenResty 二进制。未显式配置 `node_ip` 时,Agent 会优先通过第三方 API 获取真实出口 IP,避免把 Docker 网桥地址登记为节点 IP。
挂载配置文件:
```bash
docker pull ghcr.io/rain-kl/openflare-agent:latest
docker rm -f openflare-agent 2>/dev/null || true
docker run -d --name openflare-agent --restart unless-stopped \
-p 80:80 -p 443:443 \
-v openflare-agent-data:/data \
-v ./agent.json:/etc/openflare/agent.json:ro \
ghcr.io/rain-kl/openflare-agent:latest
```
使用环境变量:
```bash
docker pull ghcr.io/rain-kl/openflare-agent:latest
docker rm -f openflare-agent 2>/dev/null || true
docker run -d --name openflare-agent --restart unless-stopped \
-p 80:80 -p 443:443 \
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
ghcr.io/rain-kl/openflare-agent:latest
```
## 手动运行 Agent
源码运行:
```bash
cd openflare_agent
export LOG_LEVEL='info'
go run ./cmd/agent -config /path/to/agent.json
```
编译后二进制运行:
```bash
cd openflare_agent
go build -o openflare-agent ./cmd/agent
export LOG_LEVEL='info'
./openflare-agent -config /path/to/agent.json
```
最小 `agent.json` 示例:
```json
{
"server_url": "http://127.0.0.1:3000",
"agent_token": "replace-with-node-auth-token",
"data_dir": "./data",
"openresty_path": "openresty",
"heartbeat_interval": 10000,
"request_timeout": 10000
}
```
未配置 `openresty_path` 时,Agent 默认调用 `openresty`。
默认情况下,Agent 在 HTTP 心跳成功后会尝试升级为 WebSocket。升级成功时,Server 发布或激活配置会立即通知 Agent;如果 WebSocket 无法建立或意外断开,Agent 会自动退回 HTTP 心跳同步。
WAF 地域规则依赖 Agent 本地 `GeoLite2-Country.mmdb`。Agent 启动时会在 `data_dir/etc/openflare/GeoLite2-Country.mmdb` 初始化内置数据库,并按配置周期尝试更新;更新失败只记录警告,不影响配置同步与 OpenResty reload。
## 最小联调步骤
1. 启动 Server 并完成首次登录。
2. 在管理端准备 `agent_token` 或 `discovery_token`。
3. 启动 Agent,并确认节点在线。
4. 新增一条启用的网站配置。
5. 发布并激活新版本。
6. 查看节点详情和应用记录,确认版本应用成功。
7. 访问绑定域名或用 `curl` 验证反代结果。
## 升级与卸载
Server:
* Root 用户可在管理端顶栏检查并升级正式版。
* 如需尝试 preview 版本,可手动检查对应发布。
* 也可通过上传 Server 二进制的方式执行确认升级。
Agent:
* Agent 默认只跟随正式版自动更新。
* Agent 自更新会要求 GitHub Release 同时包含目标二进制和同名 `.sha256` 校验文件,下载后必须通过 SHA-256 校验才会替换本地可执行文件。
* 安装脚本可重复执行,用于重装或升级 Agent。
* preview 升级需要手动触发。
卸载 Agent:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/uninstall-agent.sh | bash
```
卸载脚本会停止 Agent、删除 systemd 服务和安装目录,不会删除本机 OpenResty。
## 常用验证命令
Server:
```bash
cd openflare_server
GOCACHE=/tmp/openflare-go-cache go test ./...
```
Agent:
```bash
cd openflare_agent
GOCACHE=/tmp/openflare-go-cache go test ./...
```
Frontend:
```bash
cd openflare_server/web
pnpm build
```
Swagger:
```bash
go install github.com/swaggo/swag/cmd/swag@v1.16.4
cd openflare_server
swag init -g main.go -o docs
```
+188
View File
@@ -0,0 +1,188 @@
# 本地开发
你会学到:如何搭建 OpenFlare 的本地开发环境、启动 Server、Agent 和管理端前端,运行测试与构建命令,并理解贡献代码前需要遵守的边界。
本页面向贡献者。产品边界、数据模型约束、API 约定和前端分层规范以 [开发约束](../design/development.md) 为准;本页只提供可执行的本地开发流程。
## 仓库结构
| 路径 | 职责 |
| --- | --- |
| `openflare_server` | Gin + GORM + SQLite/PostgreSQL 单体控制面 |
| `openflare_server/web` | Next.js 管理端前端,静态导出后由 Go Server 托管 |
| `openflare_agent` | Go 单体 Agent,运行在节点侧 |
| `scripts` | Agent 安装与卸载脚本 |
| `docs` | VitePress 文档站 |
## 环境要求
| 项目 | 要求 |
| --- | --- |
| Go | `1.25+` |
| Node.js | `18+` |
| pnpm | 推荐通过 `corepack enable` 使用项目声明版本 |
| Docker | Server 容器、本地联调和 Agent Docker 镜像需要 |
| OpenResty | 本地运行 Agent 时需要可执行 `openresty` |
| PostgreSQL | 可选;未配置时 Server 使用 SQLite |
## 初始化前端依赖
```bash
cd openflare_server/web
corepack enable
pnpm install
```
构建供 Go Server 托管的静态产物:
```bash
pnpm build
```
## 启动 Server
SQLite 模式:
```bash
cd openflare_server
export SESSION_SECRET='dev-session-secret'
export SQLITE_PATH='./openflare-dev.db'
export LOG_LEVEL='debug'
go run .
```
PostgreSQL 模式:
```bash
cd openflare_server
export SESSION_SECRET='dev-session-secret'
export DSN='postgres://openflare:secret@127.0.0.1:5432/openflare?sslmode=disable'
export LOG_LEVEL='debug'
go run .
```
默认访问地址:
```text
http://localhost:3000
```
默认账号是 `root` / `123456`。
## 启动前端开发服务器
前端开发服务器默认监听 `3001`,并通过 `NEXT_DEV_BACKEND_URL` 代理到后端:
```bash
cd openflare_server/web
export NEXT_DEV_BACKEND_URL='http://127.0.0.1:3000'
pnpm dev
```
访问:
```text
http://localhost:3001
```
## 启动 Agent
创建本地 `agent.json`:
```json
{
"server_url": "http://127.0.0.1:3000",
"agent_token": "replace-with-node-auth-token",
"data_dir": "./data",
"heartbeat_interval": 10000,
"request_timeout": 10000
}
```
运行:
```bash
cd openflare_agent
export LOG_LEVEL='debug'
go run ./cmd/agent -config ./agent.json
```
未配置 `openresty_path` 时,Agent 默认调用 `openresty`。调试时可显式配置 `openresty_path`、`main_config_path`、`route_config_path`、`access_log_path`、`cert_dir`、`lua_dir` 和 `runtime_config_dir`。
## 测试
Server:
```bash
cd openflare_server
GOCACHE=/tmp/openflare-go-cache go test ./...
```
Agent:
```bash
cd openflare_agent
GOCACHE=/tmp/openflare-go-cache go test ./...
```
Frontend:
```bash
cd openflare_server/web
pnpm lint
pnpm typecheck
pnpm test
pnpm test:e2e
```
Docs:
```bash
cd docs
pnpm build
```
## 构建
管理端静态产物:
```bash
cd openflare_server/web
pnpm build
```
Server 二进制:
```bash
cd openflare_server
go build -o openflare-server .
```
Agent 二进制:
```bash
cd openflare_agent
go build -o openflare-agent ./cmd/agent
```
## 调试入口
| 场景 | 命令或位置 |
| --- | --- |
| Server 日志 | `LOG_LEVEL=debug go run .` |
| Agent 日志 | `LOG_LEVEL=debug go run ./cmd/agent -config ./agent.json` |
| Swagger | `http://localhost:3000/swagger/index.html` |
| 前端 API 代理 | `NEXT_DEV_BACKEND_URL=http://127.0.0.1:3000 pnpm dev` |
| OpenResty 配置校验 | `openresty -t -c ./data/etc/nginx/nginx.conf` |
## 代码风格与变更准入
贡献前先确认:
1. 需求符合 [产品边界](../design/index.md)。
2. 实现符合 [开发约束](../design/development.md)。
3. 不破坏发布、同步、回滚或升级主链路。
4. 涉及配置、部署、API 或产品边界时同步更新文档。
5. 风险较高的修改补充测试或等效联调验证。
数据库结构变更必须提升数据库版本号,并补充从上一版本到新版本的显式迁移方法和校验逻辑。
+100
View File
@@ -0,0 +1,100 @@
# 发布第一份配置
你会学到:如何创建第一条网站配置、绑定源站与证书、发布配置版本,并确认 Agent 已经应用。
OpenFlare 的发布链路以完整配置版本为中心。你在管理端修改网站配置后,需要发布并激活新版本,Agent 才会在后续 heartbeat 中拉取并应用。
## 发布前检查
确认以下条件已经满足:
| 项目 | 期望 |
| --- | --- |
| Server | 可以登录管理端 |
| Agent | 至少一个节点在线 |
| 源站 | Agent 节点可以访问源站地址 |
| 域名 | 域名已经解析到 OpenResty 节点,或准备通过本地 hosts / curl Host 头验证 |
| HTTPS | 如需 HTTPS,证书已上传或托管 |
## 创建网站配置
在管理端新增网站配置时至少需要:
| 字段 | 说明 |
| --- | --- |
| 网站名称 | 业务唯一标识;未显式填写时可使用主域名 |
| 域名 | 至少一个域名,第一项视为主域名 |
| 源站地址 | 合法的 `http://` 或 `https://` 上游地址 |
| 启用状态 | 只有启用的网站配置会参与发布渲染 |
示例:
| 字段 | 示例 |
| --- | --- |
| 网站名称 | `app` |
| 域名 | `app.example.com` |
| 源站地址 | `http://10.0.0.20:8080` |
同一个域名只能属于一个网站配置。同一网站内的流量限制、反向代理和缓存配置按站点共享。
## 绑定证书
HTTPS 证书按域名绑定。没有绑定证书的域名不会被自动放入 `443 ssl` server 块。
如果一个网站包含多个域名,发布渲染会按证书分组生成 HTTPS 配置,并确保所有域名仍属于同一站点快照。
## 发布与激活
标准链路:
```text
修改规则 -> 预览 / 查看 diff -> 发布 -> 生成完整配置版本 -> 激活版本 -> Agent 拉取 -> 本地应用 -> 上报结果
```
发布时 Server 会读取全部启用的网站配置、OpenResty 主配置模板、性能参数与缓存参数,渲染完整 OpenResty 配置,计算 `checksum`,写入 `config_versions`,再切换激活版本。
## 验证结果
发布后在管理端确认:
| 位置 | 期望结果 |
| --- | --- |
| 节点列表 | 节点在线 |
| 节点详情 | 当前版本与激活版本一致 |
| 应用记录 | 最近一次应用成功 |
| 版本页面 | 新版本处于激活状态 |
在节点上确认 Agent 日志:
```bash
journalctl -u openflare-agent -n 100 --no-pager
```
用域名访问:
```bash
curl -I http://app.example.com
```
如果域名还没有正式解析,可以临时指定 Host 头访问节点 IP:
```bash
curl -I -H 'Host: app.example.com' http://NODE_IP
```
HTTPS 验证:
```bash
curl -I https://app.example.com
```
## 回滚
如果目标版本应用失败并回滚,Agent 会在本地阻断同一 `version + checksum` 的重复应用,直到控制面激活版本或 checksum 发生变化。
回滚到旧版本:
1. 打开配置版本页面。
2. 找到上一个确认可用的历史版本。
3. 重新激活该版本。
4. 查看节点应用记录,确认 Agent 应用成功。
+36
View File
@@ -0,0 +1,36 @@
# 指南
你会学到:OpenFlare 文档如何组织、首次运行应该读哪些页面,以及部署、使用、排查和开发分别从哪里开始。
OpenFlare 是一套自托管的 OpenResty 控制面。它把反向代理网站配置、配置版本发布、Agent 节点同步、TLS 证书和基础观测放到一个管理端中,适合单团队或单组织管理多台代理节点。
## 推荐阅读路径
如果你第一次接触 OpenFlare,按下面顺序阅读:
1. [快速开始](./quick-start.md):用 Docker Compose 启动 Server,登录管理端,并接入第一个 Agent。
2. [基础使用](./usage.md):了解网站配置、源站、证书、发布、回滚和观测的常见操作。
3. [部署说明](./deployment.md):把 Server 和 Agent 放到更接近生产的环境中运行。
4. [配置项参考](../reference/configuration.md):查 Server 环境变量、运行时 Option 和 Agent 配置字段。
5. [故障排查](./troubleshooting.md):按症状排查登录、数据库、节点同步、OpenResty 应用和前端构建问题。
## 按角色查找
| 你想做什么 | 推荐入口 |
| --- | --- |
| 5 分钟内跑起管理端 | [快速开始](./quick-start.md) |
| 发布第一条反向代理配置 | [发布第一份配置](./first-site.md) |
| 接入或重装节点 Agent | [接入 Agent](./agent.md) |
| 从源码启动 Server | [启动 Server](./server.md) |
| 配置 GitHub 或 OIDC 登录 | [SSO 登录配置](./sso.md) |
| 升级 Server 或 Agent | [升级与维护](./upgrade.md) |
| 参与开发或修复问题 | [本地开发](./development.md) 与 [开发约束](../design/development.md) |
| 理解架构和发布模型 | [系统架构](../design/architecture.md) 与 [发布模型](../design/release-model.md) |
## 文档分区
`guide/` 面向使用者和部署者,提供从安装到日常操作的可执行步骤。
`reference/` 收敛稳定事实,例如配置字段、命令、API 响应约定和仓库结构。
`design/` 面向维护者和贡献者,描述产品边界、系统架构、发布模型和工程约束。新增能力或改变边界前,应先更新对应设计文档。
+182
View File
@@ -0,0 +1,182 @@
# 快速开始
你会学到:如何用 Docker Compose 启动 OpenFlare Server、完成首次登录、接入第一个 Agent,并验证一份配置是否已经发布到节点。
OpenFlare 的最小运行单元包含:
| 组件 | 职责 |
| --- | --- |
| Server | 管理端 UI、管理 API、Agent API、配置渲染、版本发布与状态存储 |
| Agent | 运行在代理节点上,拉取配置、写入 OpenResty、执行校验与 reload |
| OpenResty | 实际接收流量并反向代理到源站 |
Agent 统一通过 OpenResty 二进制控制运行时。本地部署需要节点上已有 `openresty` 可执行文件;Docker 部署可直接运行内置 OpenResty 的 Agent 镜像。
## 环境要求
| 项目 | 要求 |
| --- | --- |
| Docker / Docker Compose | 用于启动 Server 和 PostgreSQL;如果采用 Docker Agent 镜像,也用于运行 Agent |
| OpenResty | 本地安装 Agent 时需要可执行 `openresty`,或在安装脚本中指定路径 |
| 可访问端口 | Server 默认监听 `3000`,Agent 节点需要能访问 Server 地址 |
| 浏览器 | 用于访问管理端 |
[需要确认:项目建议的最低 Docker 与 Docker Compose 版本]
## 1. 启动 Server
在空目录中创建 `docker-compose.yml`:
```yaml
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: openflare
POSTGRES_USER: openflare
POSTGRES_PASSWORD: replace-with-strong-password
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U openflare -d openflare"]
interval: 10s
timeout: 5s
retries: 5
openflare:
image: ghcr.io/rain-kl/openflare:latest
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
ports:
- "3000:3000"
environment:
SESSION_SECRET: replace-with-a-long-random-string
DSN: postgres://openflare:replace-with-strong-password@postgres:5432/openflare?sslmode=disable
GIN_MODE: release
LOG_LEVEL: info
volumes:
postgres-data:
```
启动服务:
```bash
docker compose up -d
```
确认容器已经运行:
```bash
docker compose ps
docker compose logs -f openflare
```
看到 `server listening` 且 `openflare` 容器状态为 running 后,访问:
```text
http://localhost:3000
```
默认账号:
| 用户名 | 密码 |
| --- | --- |
| `root` | `123456` |
首次登录后请立即修改默认密码。
## 2. 准备 Agent Token
Agent 可以用两类凭证接入:
| 凭证 | 适用场景 |
| --- | --- |
| `discovery_token` | 首次自动注册节点,由 Server 换成节点专属 Token |
| `agent_token` | 已经在管理端创建或分配节点,直接使用节点专属 Token |
在管理端准备其中一种凭证后,进入下一步。
[需要确认:当前管理端中创建或查看 `discovery_token` 与节点 `agent_token` 的准确菜单路径]
## 3. 安装 Agent
在代理节点上执行安装脚本。
使用 `discovery_token`:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
--server-url http://your-server:3000 \
--discovery-token YOUR_DISCOVERY_TOKEN
```
使用节点专属 `agent_token`:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
--server-url http://your-server:3000 \
--agent-token YOUR_AGENT_TOKEN
```
脚本默认会:
| 项目 | 默认值 |
| --- | --- |
| 安装目录 | `/opt/openflare-agent` |
| 配置文件 | `/opt/openflare-agent/agent.json` |
| systemd 服务 | `openflare-agent.service` |
| OpenResty 路径 | 未指定时自动查找 `openresty` |
确认 Agent 服务状态:
```bash
systemctl status openflare-agent
journalctl -u openflare-agent -f
```
如果没有 systemd,脚本会输出手动启动命令。
## 4. 发布第一份配置
在管理端完成以下操作:
1. 新增网站配置,填写网站名称、域名和源站地址。
2. 确认网站配置处于启用状态。
3. 发布前查看预览或变更摘要。
4. 发布并激活新版本。
5. 等待 Agent 在后续 heartbeat 中发现版本并应用。
版本号格式为 `YYYYMMDD-NNN`。历史版本不可变,回滚通过重新激活旧版本完成。
## 5. 验证是否成功
在管理端确认:
| 位置 | 期望结果 |
| --- | --- |
| 节点列表 | Agent 节点在线 |
| 节点详情 | 当前版本与激活版本一致 |
| 应用记录 | 最近一次应用成功 |
| 版本页面 | 新版本处于激活状态 |
在 Agent 节点确认:
```bash
journalctl -u openflare-agent -n 100 --no-pager
```
## 常见失败原因
| 现象 | 排查方向 |
| --- | --- |
| 浏览器打不开管理端 | 确认 `docker compose ps` 中 Server 正在运行,宿主机 `3000` 端口没有被占用 |
| 登录后数据无法保存 | 检查 PostgreSQL 容器健康状态,以及 `DSN` 中的用户名、密码、库名是否一致 |
| Agent 无法注册 | 确认 Agent 节点能访问 `--server-url`,并检查 Token 是否填错或已失效 |
| Agent 在线但没有应用配置 | 确认网站配置已启用,并且已经发布并激活版本 |
| OpenResty 应用失败 | 查看节点应用记录和 `journalctl -u openflare-agent`,重点检查域名、证书、上游地址和端口占用 |
更多排查路径见 [故障排查](./troubleshooting.md)。
+106
View File
@@ -0,0 +1,106 @@
# 启动 Server
你会学到:如何从源码构建管理端前端、启动 OpenFlare Server、选择 SQLite 或 PostgreSQL,并访问 Swagger。
OpenFlare Server 是 Gin + GORM 单体控制面,负责管理端 UI、管理 API、Agent API、配置渲染、版本发布、数据存储与聚合查询。
## 前置条件
| 项目 | 要求 |
| --- | --- |
| Go | `1.25+` |
| Node.js | `18+` |
| pnpm | 推荐通过 `corepack enable` 使用项目声明的 pnpm |
| 数据库 | SQLite 文件目录可写,或可访问的 PostgreSQL 实例 |
生产环境建议显式配置 `SESSION_SECRET`,并优先使用 PostgreSQL。
## 构建管理端前端
Go Server 会托管 `openflare_server/web/build` 中的静态产物。源码启动前先构建前端:
```bash
cd openflare_server/web
corepack enable
pnpm install
pnpm build
```
常用前端检查:
```bash
pnpm lint
pnpm typecheck
pnpm test
```
## 使用 SQLite 启动
```bash
cd openflare_server
export SESSION_SECRET='replace-with-a-long-random-string'
export SQLITE_PATH='./openflare.db'
export LOG_LEVEL='info'
go run .
```
默认监听 `3000` 端口,访问:
```text
http://localhost:3000
```
## 使用 PostgreSQL 启动
```bash
cd openflare_server
export SESSION_SECRET='replace-with-a-long-random-string'
export DSN='postgres://openflare:secret@127.0.0.1:5432/openflare?sslmode=disable'
export LOG_LEVEL='info'
go run .
```
`DSN` 设置后优先于 SQLite。`DSN` 与兼容旧命名的 `SQL_DSN` 同时存在时,优先使用 `DSN`。
如果目标 PostgreSQL 数据库为空且本地 `SQLITE_PATH` 文件存在,Server 启动阶段会尝试把 SQLite 数据迁移到 PostgreSQL,并在日志中输出迁移进度。
## 命令行参数
```bash
go run . --port 3000 --log-dir ./logs
```
| 参数 | 作用 | 默认值 |
| --- | --- | --- |
| `--port` | 指定 Server 监听端口 | `3000` |
| `--log-dir` | 指定日志目录 | 空,输出到标准输出 |
| `--version` | 输出版本后退出 | `false` |
| `--help` | 输出帮助后退出 | `false` |
## 首次登录
默认账号:
| 用户名 | 密码 |
| --- | --- |
| `root` | `123456` |
首次登录后请立即修改默认密码。
## Swagger
登录管理端后访问:
```text
http://localhost:3000/swagger/index.html
```
本地重新生成 Swagger:
```bash
go install github.com/swaggo/swag/cmd/swag@v1.16.4
cd openflare_server
swag init -g main.go -o docs
```
Swagger 生成文件位于 `openflare_server/docs`。
+106
View File
@@ -0,0 +1,106 @@
# SSO 登录配置
你会学到:如何为 OpenFlare 配置 GitHub OAuth 或标准 OIDC 登录入口,如何填写回调地址,以及第三方账号如何绑定本地用户。
OpenFlare 支持通过认证源配置第三方登录入口。当前支持 GitHub OAuth 与标准 OIDC Provider,例如 Logto、authentik、Keycloak、Casdoor 等。
认证源配置完成并启用后,会显示在登录页的第三方账号登录区域。用户可以通过第三方账号登录,也可以在已登录状态下把第三方账号绑定到当前本地账号。
## 使用前准备
你需要先准备:
| 项目 | 说明 |
| --- | --- |
| OpenFlare 访问地址 | 用户浏览器实际访问的地址,例如 `https://openflare.example.com` |
| 认证源名称 | OpenFlare 内部唯一标识,例如 `github`、`company-oidc` |
| Client ID | 第三方平台创建应用后提供 |
| Client Secret | 第三方平台创建应用后提供 |
| OIDC Discovery URL | 仅 OIDC 需要,例如 `https://idp.example.com/.well-known/openid-configuration` |
**确认系统设置->通用设置->服务器地址能正确和域名匹配**
认证源名称只能包含字母、数字、短横线或下划线,并且必须以字母或数字开头。认证源名称会出现在回调地址中,保存后如需修改名称,也必须同步修改第三方平台中的回调地址。
## 回调地址
第三方平台中的 Redirect URI / Callback URL 填写格式为:
```text
<OpenFlare 访问地址>/oauth/<认证源名称>
```
示例:
```text
https://openflare.example.com/oauth/github
https://openflare.example.com/oauth/company-oidc
```
在管理端新增或修改认证源时,表单会根据当前浏览器访问地址和你输入的认证源名称自动显示应填写的回调地址。
## 配置 GitHub 登录
1. 在 GitHub 创建 OAuth App。
2. `Homepage URL` 填写 OpenFlare 访问地址。
3. `Authorization callback URL` 填写 OpenFlare 显示的回调地址,例如 `https://openflare.example.com/oauth/github`。
4. 复制 GitHub 提供的 Client ID 和 Client Secret。
5. 登录 OpenFlare 管理端,进入“设置 -> 系统设置 -> 配置认证源”。
6. 新增认证源,类型选择 `GitHub`。
7. 填写认证源名称、展示名称、Client ID、Client Secret。
8. Scope 默认使用 `user:email`,通常无需修改。
9. 保存并启用认证源。
启用后,登录页会显示对应的 GitHub 登录按钮。
## 配置 OIDC 登录
1. 在 OIDC Provider 中创建应用或客户端。
2. 应用类型选择 Web / Confidential Client。
3. Redirect URI / Callback URL 填写 OpenFlare 显示的回调地址,例如 `https://openflare.example.com/oauth/company-oidc`。
4. 复制 Client ID 和 Client Secret。
5. 获取 Provider 的 Discovery URL,通常以 `/.well-known/openid-configuration` 结尾。
6. 登录 OpenFlare 管理端,进入“设置 -> 系统设置 -> 配置认证源”。
7. 新增认证源,类型选择 `OIDC`。
8. 填写认证源名称、展示名称、Client ID、Client Secret、OIDC Discovery URL。
9. Scope 默认使用 `openid profile email`。如果 Provider 限制了 scope,请按 Provider 允许的值调整。
10. 保存并启用认证源。
启用后,登录页会显示对应的 OIDC 登录按钮。
## 登录与绑定行为
第三方账号回到 OpenFlare 后按以下规则处理:
| 场景 | 行为 |
| --- | --- |
| 第三方账号已绑定本地用户 | 直接登录 |
| 用户已登录并发起第三方授权 | 绑定到当前本地用户 |
| 第三方账号未绑定,且允许注册 | 自动创建普通用户并绑定 |
| 第三方账号未绑定,且关闭注册 | 要求输入已有本地账号密码完成绑定 |
如果希望只允许已有用户使用 SSO,可以关闭用户注册。未绑定的第三方账号会进入绑定已有账号流程。
## 修改认证源
修改认证源时,Client Secret 输入框留空表示保留已有密钥;填写新值则会覆盖保存。
如果修改了认证源名称,回调地址也会随之变化。你必须到第三方平台同步修改 Redirect URI / Callback URL,否则第三方平台会拒绝回调或返回错误。
## 常见问题
### 返回 `invalid_scope`
说明第三方平台不允许当前配置的 Scope。OIDC 默认 Scope 是 `openid profile email`,GitHub 默认 Scope 是 `user:email`。请到认证源编辑页调整 Scope,或在第三方平台放行对应 Scope。
### 提示回调地址不匹配
检查第三方平台中配置的 Redirect URI / Callback URL 是否与 OpenFlare 表单提示完全一致。协议、域名、端口和路径都必须一致。
### 登录页没有显示第三方登录按钮
检查认证源是否已启用,并确认 Client ID 和 Client Secret 已保存。启用认证源前,OpenFlare 会校验这些字段。
### 已经保存 Client Secret,但列表不显示明文
这是预期行为。OpenFlare 不会通过 API 回显 Client Secret,只显示该密钥是否已配置。
+229
View File
@@ -0,0 +1,229 @@
# 故障排查
你会学到:如何按症状排查 OpenFlare Server、数据库、登录、Agent、OpenResty、配置发布和前端构建问题。
排查时先确认问题发生在哪一层:浏览器、Server、数据库、Agent、OpenResty、源站或 DNS。OpenFlare 的配置不会直接在线写入所有节点,只有激活版本变化后,Agent 才会在 heartbeat 中发现并应用。
## 快速定位
| 现象 | 先看哪里 |
| --- | --- |
| 管理端打不开 | Server 容器或进程日志、端口监听 |
| 登录异常 | 默认账号、Session Secret、浏览器请求、Server 日志 |
| 数据无法保存 | 数据库连接、SQLite 文件权限、PostgreSQL 健康状态 |
| Agent 离线 | Agent 日志、Token、Server 地址、网络连通性 |
| 发布后节点未更新 | 激活版本、节点 heartbeat、应用记录 |
| OpenResty 应用失败 | 应用记录、Agent 日志、证书、上游地址、端口占用 |
| 访问分析无数据 | OpenResty 容器状态、观测端口、Agent 补报日志 |
## Server 无法启动
1. 查看日志:
```bash
docker compose logs -n 200 openflare
```
源码运行时查看终端输出。
2. 检查端口占用:
```bash
lsof -i :3000
```
3. 如果使用 PostgreSQL,确认数据库健康:
```bash
docker compose ps postgres
docker compose logs -n 100 postgres
```
4. 如果使用 SQLite,确认数据库文件目录可写:
```bash
ls -ld "$(dirname /path/to/openflare.db)"
```
常见原因:
| 日志或现象 | 处理 |
| --- | --- |
| 数据库连接失败 | 检查 `DSN` 中用户名、密码、主机、端口、库名和 `sslmode` |
| SQLite 无法创建文件 | 检查 `SQLITE_PATH` 所在目录是否存在且可写 |
| 端口被占用 | 修改 `PORT` 或 `--port`,或停止占用端口的进程 |
## 管理端打不开或空白
1. 确认 Server 正在监听:
```bash
curl -I http://127.0.0.1:3000
```
2. 如果是源码运行,确认已经构建前端静态产物:
```bash
cd openflare_server/web
pnpm build
```
3. 检查浏览器访问地址是否与反向代理配置一致。
4. 如果通过前端开发服务器访问,确认后端代理地址:
```bash
cd openflare_server/web
NEXT_DEV_BACKEND_URL=http://127.0.0.1:3000 pnpm dev
```
## 默认账号无法登录
默认账号是 `root` / `123456`。首次登录后如果已经修改密码,应使用修改后的密码。
排查步骤:
1. 确认连接的是预期数据库,避免 `SQLITE_PATH` 或 `DSN` 指向了另一个环境。
2. 查看 Server 日志中使用的是 `sqlite` 还是 `postgres`。
3. 如果部署在多副本或反向代理后,确认 `SESSION_SECRET` 固定且各实例一致。
4. 清理浏览器 Cookie 后重新登录。
[需要确认:当前项目是否提供安全的 root 密码重置命令或流程]
## Agent 无法注册或一直离线
在 Agent 节点执行:
```bash
curl -I http://your-server:3000
```
查看 Agent 日志:
```bash
journalctl -u openflare-agent -n 200 --no-pager
```
检查配置文件:
```bash
sed -n '1,160p' /opt/openflare-agent/agent.json
```
重点确认:
| 配置 | 说明 |
| --- | --- |
| `server_url` | 必须是 Agent 节点能访问的 Server 地址 |
| `agent_token` / `discovery_token` | 至少填写一个 |
| `heartbeat_interval` | 支持毫秒整数或 Go duration 字符串 |
| `request_timeout` | 网络较慢时可适当增大 |
如果日志提示 Token 无效,重新在管理端准备 Token 并更新 `agent.json`,然后重启:
```bash
systemctl restart openflare-agent
```
## 发布后节点没有应用新版本
按顺序检查:
1. 版本页面中是否已经激活目标版本。
2. 节点是否在线,最近心跳时间是否更新。
3. 应用记录中是否有目标版本的成功、警告或失败记录。
4. 网站配置是否启用;未启用的网站不会参与发布渲染。
5. Agent 日志是否出现拉取、校验、reload 或回滚信息。
查看 Agent 日志:
```bash
journalctl -u openflare-agent -f
```
注意:某个目标 `version + checksum` 一旦应用失败并回退,Agent 会在本地状态中阻断该目标重复应用。修正配置后需要重新发布生成新的 checksum,或激活旧版本回滚。
如果这是 Agent 首次应用配置,且本地没有历史 `nginx.conf` 可回滚,失败目标仍会被阻断,但 Agent 会尝试进入安全兜底运行态。此时应用记录和 Agent 日志会包含 `fallback runtime started`,OpenResty 对外只监听 `80` 端口并统一返回 `503` 与 `OpenFlare: No Valid Configuration`,同时保留本地 `stub_status` 健康检查入口。修正配置并重新发布新版本后,Agent 会覆盖兜底配置并恢复正常代理。
## OpenResty 应用失败
常见原因:
| 原因 | 排查 |
| --- | --- |
| 域名或 server 块冲突 | 检查同一域名是否被多个网站配置使用 |
| 上游地址不合法 | 确认所有上游都是 `http://` 或 `https://` |
| 多上游格式不符合约束 | 多上游必须是纯 `scheme://host[:port]` |
| 证书缺失或路径错误 | 检查域名是否绑定证书,以及 Agent 证书目录是否可写 |
| 端口被占用 | 检查本机 `80`、`443` 端口 |
OpenResty 配置校验:
```bash
openresty -t -c /path/to/openflare/data/etc/nginx/nginx.conf
```
OpenResty 运行状态:
```bash
ps aux | grep openresty
```
Agent 周期性健康检查通过本地 `http://127.0.0.1:<openresty_observability_port>/openflare/stub_status` 判断 OpenResty 是否存活,不会反复执行 `openresty -t`。如果节点被标记为 unhealthy,优先确认该本地观测端口是否正在监听;如果只在应用配置时出现 `host not found in upstream`,说明失败来自配置校验或 reload,而不是周期性健康探针。
实际二进制路径和主配置路径以 `agent.json` 中的 `openresty_path` 与 `main_config_path` 为准。
## HTTPS 不生效
1. 确认证书已经上传或托管。
2. 确认网站配置中对应域名已经绑定证书。
3. 确认发布并激活了新版本。
4. 查看应用记录是否成功。
5. 用 `curl` 查看证书和状态码:
```bash
curl -Iv https://your-domain
```
没有绑定证书的域名不会被自动加入 HTTPS 配置,这是预期行为。
## 访问分析没有数据
1. 确认节点已经成功应用包含观测 Lua 资源的配置。
2. 确认 OpenResty 正在运行。
3. 查看 Agent 日志是否有观测采集或补报失败信息。
4. 检查 `openresty_observability_port` 是否被占用,默认是 `18081`。
5. 确认 Server 侧没有因数据库清理策略删除对应时间窗口数据。
## 前端构建失败
执行:
```bash
cd openflare_server/web
corepack enable
pnpm install
pnpm lint
pnpm typecheck
pnpm test
pnpm build
```
常见原因:
| 现象 | 处理 |
| --- | --- |
| pnpm 版本不一致 | 使用 `corepack enable` 后重新安装 |
| 类型错误 | 先运行 `pnpm typecheck` 定位具体文件 |
| API 类型不一致 | 检查 `lib/api/` 和 `types/` 中的响应结构 |
| E2E 失败 | 确认 Server 和前端开发服务器都已启动 |
## 文档站构建失败
```bash
cd docs
pnpm install
pnpm build
```
如果是链接错误,检查新增页面是否已经加入 `docs/config.ts` 侧边栏,或者相对链接是否指向存在的 Markdown 文件。
+85
View File
@@ -0,0 +1,85 @@
# 升级与维护
你会学到:如何升级 Server 与 Agent、如何清理观测数据,以及维护前后应该执行哪些验证命令。
升级前建议先确认当前激活版本、最近一次 Agent 应用结果和数据库备份策略。生产环境不要在发布配置、Agent 大规模重连或数据库迁移进行中同时升级。
## Server 升级
Root 用户可以在管理端顶栏检查并升级 Server 正式版。也可以通过上传 Server 二进制的方式执行确认升级。
如需尝试 preview 版本,可手动检查对应发布。生产环境建议优先使用正式版。
升级后确认:
```bash
docker compose ps
docker compose logs -n 100 openflare
```
如果是源码部署,重新启动 Server 后确认日志中没有数据库迁移或启动错误。
## Agent 升级
节点 Agent 默认只跟随正式版自动更新。preview 升级需要手动触发。
安装脚本可重复执行,用于重装或升级 Agent:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
--server-url http://your-server:3000 \
--agent-token YOUR_AGENT_TOKEN
```
注意:当前安装脚本重装时会删除整个安装目录,包括旧 `agent.json`、本地状态、缓存数据和下载的二进制。执行前请确认手头仍有可用 Token。
升级后确认:
```bash
systemctl status openflare-agent
journalctl -u openflare-agent -n 100 --no-pager
```
## 数据维护
管理端设置页可以维护观测数据自动清理策略:
| 配置项 | 说明 |
| --- | --- |
| `DatabaseAutoCleanupEnabled` | 是否启用每日自动清理 |
| `DatabaseAutoCleanupRetentionDays` | 自动清理保留天数,至少 1 天 |
开启后,Server 会在每天凌晨 3 点清理访问日志、指标快照与请求报告。
## 常用验证命令
Server:
```bash
cd openflare_server
GOCACHE=/tmp/openflare-go-cache go test ./...
```
Agent:
```bash
cd openflare_agent
GOCACHE=/tmp/openflare-go-cache go test ./...
```
Frontend:
```bash
cd openflare_server/web
pnpm lint
pnpm typecheck
pnpm test
pnpm build
```
Docs:
```bash
cd docs
pnpm build
```
+136
View File
@@ -0,0 +1,136 @@
# 基础使用
你会学到:OpenFlare 中网站配置、源站、证书、版本、节点和观测分别是什么,以及日常使用时应按什么顺序操作。
OpenFlare 不直接在线修改节点上的 Nginx/OpenResty 配置。你在管理端修改的是控制面数据;只有发布并激活新版本后,Agent 才会拉取完整配置并应用到节点。
## 核心概念
| 概念 | 说明 |
| --- | --- |
| 网站配置 | 反向代理配置的聚合对象,一条网站配置可以绑定一个或多个域名 |
| 主域名 | `domains` 列表中的第一个域名,用作该网站的主要展示域名 |
| 源站 | 被反向代理访问的上游地址,例如 `http://10.0.0.10:8080` |
| 配置版本 | 一次发布生成的完整 OpenResty 配置快照,历史版本不可变 |
| 激活版本 | 当前全局生效的配置版本,所有节点默认消费同一份激活版本 |
| Agent | 节点侧进程,负责注册、心跳、同步、校验、reload 和失败回滚 |
## 推荐操作顺序
日常发布一条反向代理配置时,推荐按这个顺序:
1. 确认至少有一个 Agent 节点在线。
2. 新增或选择源站地址。
3. 新增网站配置,填写域名、源站和站点级配置。
4. 如需 HTTPS,上传或选择证书,并按域名绑定。
5. 预览配置或查看变更摘要。
6. 发布并激活新版本。
7. 在节点详情和应用记录中确认应用结果。
## 创建网站配置
网站配置至少需要:
| 字段 | 要求 |
| --- | --- |
| 网站名称 | 业务唯一标识;未显式填写时通常可使用主域名 |
| 域名 | 至少一个域名,第一项为主域名;任一域名全局只能属于一个网站 |
| 源站地址 | 合法的 `http://` 或 `https://` 地址 |
| 启用状态 | 只有启用的网站配置会参与发布渲染 |
示例:
| 字段 | 示例 |
| --- | --- |
| 网站名称 | `docs` |
| 域名 | `docs.example.com` |
| 源站地址 | `http://10.0.0.10:8080` |
| 回源 Host | `docs.internal.example.com` |
上游地址规则:
* 单上游可以携带 base path 或 query,例如 `https://app.example.com/base?from=openflare`。
* 多上游用于负载均衡时,每个上游必须是纯 `scheme://host[:port]`。
* 多上游在同一规则内应使用一致协议。
## 管理源站
源站是轻量目录,用来复用常见上游地址。网站配置关联源站后,仍会保存可渲染的 `origin_url` 快照,确保历史配置版本可以独立回放。
推荐做法:
* 把经常复用的内部服务地址维护为源站。
* 修改源站目录后,检查已发布的网站配置是否需要同步更新源站快照。
* 发布前使用预览或 diff 确认渲染结果。
## 启用 HTTPS
HTTPS 按域名绑定证书,而不是按整个网站统一强制启用。
操作顺序:
1. 在证书管理中上传或托管证书。
2. 进入网站配置,为需要 HTTPS 的域名选择证书。
3. 未绑定证书的域名会保留 HTTP,不会被自动放入 `443 ssl` server 块。
4. 发布并激活新版本。
如果一个网站包含多个域名,Server 发布时会按证书分组渲染 HTTPS 配置,同时保持这些域名属于同一份网站快照。
## 发布、激活与回滚
标准链路:
```text
修改配置 -> 预览 / diff -> 发布 -> 生成完整版本 -> 激活版本 -> Agent 拉取 -> 本地应用 -> 上报结果
```
发布时 Server 会读取全部启用的网站配置、OpenResty 主配置模板、性能参数、缓存参数和证书资源,生成完整配置并计算 `checksum`。
回滚不是修改历史版本,而是重新激活旧版本。Agent 发现激活版本变化后,会按普通同步流程拉取并应用。
## 查看节点与观测
节点页面适合回答三个问题:
| 问题 | 查看位置 |
| --- | --- |
| 节点是否在线 | 节点列表或节点详情 |
| 当前运行哪个版本 | 节点详情中的当前版本 |
| 最近一次应用是否成功 | 应用记录 |
访问分析和资源快照用于基础观测。OpenFlare 只保留受控时间窗口内的访问明细,不定位为通用日志平台。如果需要长期日志检索,应接入独立日志系统。
## 常见场景
### 新增一个内部服务反代
1. 确认源站服务可从 Agent 节点访问。
2. 在管理端新增网站配置。
3. 填写域名,例如 `app.example.com`。
4. 填写源站,例如 `http://10.0.0.20:8080`。
5. 发布并激活版本。
6. 在 Agent 节点或浏览器访问域名验证。
### 给已有域名启用 HTTPS
1. 准备覆盖该域名的证书。
2. 在证书管理中上传或创建证书记录。
3. 回到网站配置,为对应域名选择证书。
4. 发布并激活版本。
5. 用浏览器或 `curl -I https://your-domain` 验证证书链和状态码。
### 回滚一次失败发布
1. 打开配置版本页面。
2. 找到上一个已知可用版本。
3. 重新激活该版本。
4. 查看节点应用记录,确认 Agent 已应用旧版本。
5. 修正配置后再发布新版本。
## 推荐实践
* 生产环境显式配置 `SESSION_SECRET`,并优先使用 PostgreSQL。
* 修改网站配置后先看预览或 diff,再发布。
* 每次发布后检查节点详情与应用记录。
* 多节点部署时保持 Agent 到 Server 的网络路径稳定。
* 不在节点上手动修改 OpenFlare 托管的 OpenResty 配置文件;下次发布会覆盖这些文件。
+32
View File
@@ -0,0 +1,32 @@
---
layout: home
hero:
name: OpenFlare
text: 自托管 OpenResty 控制面
tagline: 管理反向代理规则、配置发布、节点同步、TLS 证书与基础观测。
actions:
- theme: brand
text: 快速开始
link: /guide/quick-start
- theme: alt
text: 设计边界
link: /design/
- theme: alt
text: GitHub
link: https://github.com/Rain-kl/OpenFlare
features:
- icon: 🧭
title: 统一控制面
details: 在一个管理端维护网站、域名、源站、证书、节点与版本状态。
- icon: 🚀
title: 不可变发布
details: 每次发布生成完整 OpenResty 配置快照,可预览、激活和回滚。
- icon: 🔁
title: Agent 自动应用
details: 节点侧自动拉取、校验、reload,并在失败时回滚到可运行配置。
- icon: 📊
title: 基础观测
details: 提供请求聚合、访问分析、资源快照、健康事件与节点详情。
---
+31
View File
@@ -0,0 +1,31 @@
{
"$schema": "./node_modules/@lunariajs/core/config.schema.json",
"repository": {
"name": "Rain-kl/OpenFlare",
"rootDir": "docs"
},
"files": [
{
"location": "**/config.ts",
"pattern": "@lang/@path",
"type": "universal"
},
{
"location": "**/*.md",
"pattern": "@lang/@path",
"type": "universal"
}
],
"defaultLocale": {
"label": "简体中文",
"lang": "zh"
},
"locales": [
{
"label": "English",
"lang": "en"
}
],
"outDir": ".vitepress/dist/_translations",
"ignoreKeywords": ["lunaria-ignore"]
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "openflare-docs",
"private": true,
"type": "module",
"scripts": {
"dev": "vitepress dev",
"build": "vitepress build",
"preview": "vitepress preview",
"lunaria:build": "lunaria build",
"lunaria:open": "open-cli .vitepress/dist/_translations/index.html"
},
"devDependencies": {
"@lunariajs/core": "^0.1.1",
"markdown-it-mathjax3": "^4.3.2",
"open-cli": "^8.0.0",
"postcss-rtlcss": "^5.7.1",
"vitepress": "2.0.0-alpha.17",
"vitepress-plugin-llms": "^1.11.0"
}
}
@@ -0,0 +1,86 @@
# Agent Unified OpenResty Binary Control Scheme
# Agent 统一 OpenResty 二进制控制方案
## Summary
将 Agent 运行模型统一为“写入受管配置文件,然后调用 `openresty` 二进制执行 `-t`、reload、start/restart”。Docker 部署不再由 Agent 控制另一个 OpenResty 容器,而是提供独立的 `ghcr.io/rain-kl/openflare-agent` 镜像;该镜像基于 `openresty/openresty`,内置 Agent 控制器和 OpenResty 二进制。
## Key Changes
- Agent runtime:
- 移除生产路径中的 DockerExecutor / Docker 容器管理逻辑。
- `openresty_path` 未配置时默认使用 `openresty`。
- 二进制执行统一带 `-c <main_config_path>`,避免误读 OpenResty 默认配置。
- apply 流程为:备份 -> 写入文件 -> `openresty -t -c ...` -> reload;若 reload 表明未运行,则 start。
- restart 使用 `openresty -c ... -s quit` 后再 `openresty -c ...` 启动,保留缺失 PID 的容错。
- 配置与文件职责:
- 保留旧字段 `openresty_container_name`、`openresty_docker_image`、`docker_binary` 的解析兼容,但标记废弃且不再参与控制逻辑。
- 新增 `access_log_path`,默认 `data_dir/var/log/openflare/access.log`,不再把访问日志放进 `conf.d`。
- 新增 `runtime_config_dir`,默认 `data_dir/etc/openflare`,`pow_config.json` 写入这里。
- `cert_dir` 只写证书/密钥文件;`lua_dir` 只写 Lua 代码与静态资源。
- 支持文件写入前先拆分:证书文件进入 `cert_dir`,`pow_config.json` 进入 `runtime_config_dir`。
- Docker Agent 镜像:
- 新增 `openflare_agent/Dockerfile`,运行镜像基于 `openresty/openresty:alpine`。
- 默认 `OPENFLARE_OPENRESTY_PATH=openresty`、`OPENFLARE_DATA_DIR=/data`。
- 暴露 `80`、`443`、`18081`。
- 支持挂载 `/etc/openflare/agent.json`,也支持环境变量配置。
- CI 发布独立多架构镜像:`ghcr.io/rain-kl/openflare-agent:<version>` 和 `latest`。
- Agent 配置入口:
- 保留 `-config` + `agent.json`。
- 新增环境变量覆盖/兜底:`OPENFLARE_SERVER_URL`、`OPENFLARE_AGENT_TOKEN`、`OPENFLARE_DISCOVERY_TOKEN`、`OPENFLARE_NODE_NAME`、`OPENFLARE_NODE_IP`、`OPENFLARE_DATA_DIR`、`OPENFLARE_OPENRESTY_PATH`、`OPENFLARE_HEARTBEAT_INTERVAL`、`OPENFLARE_REQUEST_TIMEOUT`、`OPENFLARE_OPENRESTY_OBSERVABILITY_PORT`。
- 若配置文件不存在但环境变量足够,Agent 可直接启动;若两者都存在,环境变量覆盖文件值。
- 脚本与文档:
- `install-agent.sh` 转为本地 OpenResty 部署脚本,增加 `--openresty-path`,未传时自动查找 `openresty`。
- `uninstall-agent.sh` 只卸载 Agent 本身,不再删除 Docker OpenResty 容器或镜像。
- 更新架构、开发约束、部署说明、Agent 指南、配置项参考、README,以及英文镜像文档中的旧 Docker 控制说明。
## Public Interfaces
- 新增 Agent 配置字段:
- `access_log_path`
- `runtime_config_dir`
- 废弃但兼容读取:
- `openresty_container_name`
- `openresty_docker_image`
- `docker_binary`
- 新增 Docker 镜像:
- `ghcr.io/rain-kl/openflare-agent`
- Docker 运行方式示例目标:
- 挂载配置文件:`-v ./agent.json:/etc/openflare/agent.json`
- 或环境变量:`-e OPENFLARE_SERVER_URL=... -e OPENFLARE_AGENT_TOKEN=...`
## Test Plan
- `openflare_agent/internal/config`:
- 默认 `openresty_path` 为 `openresty`。
- 旧 Docker 字段可读取但不影响 executor。
- 环境变量可在无配置文件时启动,并可覆盖配置文件。
- 新默认路径符合职责边界。
- `openflare_agent/internal/nginx`:
- 二进制命令都包含 `-c <main_config_path>`。
- apply 成功、reload 失败后回滚、未运行时 start fallback。
- `pow_config.json` 不再写入 `cert_dir` 或 `lua_dir`。
- stale `cert_dir/pow_config.json` 与 `lua_dir/pow_config.json` 会被清理。
- access log 渲染到 `access_log_path`。
- checksum 仍能把主配置、路由配置、证书和 PoW 配置统一纳入比较。
- 集成回归:
- `cd openflare_agent && GOCACHE=/tmp/openflare-go-cache go test ./...`
- `cd openflare_server && GOCACHE=/tmp/openflare-go-cache go test ./...`
- Dockerfile 构建 smoke test:构建 Agent 镜像并用 env-only 配置启动到可执行阶段。
## Assumptions
- Docker Agent 镜像名固定为 `ghcr.io/rain-kl/openflare-agent`。
- 旧 Docker 控制字段保留兼容,但不再作为受支持行为。
- 本次不改 Server API、不改数据库模型、不引入远程命令能力。
- OpenResty 主配置模板继续由 Server 生成;Agent 只负责本地路径替换、文件落盘和二进制控制。
+2940
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
# API 约定
你会学到:OpenFlare 管理端 API 与 Agent API 的响应结构、路径约定、鉴权方式和 Swagger 入口。
OpenFlare 的管理端 API 与 Agent API 都使用 JSON。
## 响应结构
成功与失败都应返回清晰的 `message`:
```json
{
"success": true,
"message": "",
"data": {}
}
```
## 路径约定
| 类型 | 约定 |
| --- | --- |
| 管理端 API | 由管理端 Session 鉴权 |
| Agent API | 固定放在 `/api/agent/*` |
| 只读接口 | 使用 `GET` |
| 变更类接口 | 使用 `POST` |
## 鉴权
管理端继续复用现有登录、角色与 Session。
Agent 正式请求统一使用节点专属 `agent_token`,首次接入可使用全局 `discovery_token`。Agent 请求头固定为:
```http
X-Agent-Token: <token>
```
日志中不得打印完整 Token。
## Swagger
登录管理端后可访问:
```text
/swagger/index.html
```
Swagger 文件位于 `openflare_server/docs`,由 `swag init` 生成。
+117
View File
@@ -0,0 +1,117 @@
# 命令与脚本
你会学到:OpenFlare Server、管理端前端、Agent、Swagger 和文档站的常用启动、构建、测试、安装与卸载命令。
## Server
源码启动:
```bash
cd openflare_server
export SESSION_SECRET='replace-with-random-string'
export SQLITE_PATH='./openflare.db'
export LOG_LEVEL='info'
go run .
```
指定监听端口与日志目录:
```bash
go run . --port 3000 --log-dir ./logs
```
测试:
```bash
cd openflare_server
GOCACHE=/tmp/openflare-go-cache go test ./...
```
## Frontend
开发:
```bash
cd openflare_server/web
pnpm install
pnpm dev
```
构建静态产物:
```bash
cd openflare_server/web
pnpm build
```
检查:
```bash
cd openflare_server/web
pnpm lint
pnpm typecheck
pnpm test
```
## Agent
源码运行:
```bash
cd openflare_agent
go run ./cmd/agent -config /path/to/agent.json
```
编译:
```bash
cd openflare_agent
go build -o openflare-agent ./cmd/agent
```
测试:
```bash
cd openflare_agent
GOCACHE=/tmp/openflare-go-cache go test ./...
```
## 安装 Agent
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
--server-url http://your-server:3000 \
--agent-token YOUR_AGENT_TOKEN
```
## 卸载 Agent
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/uninstall-agent.sh | bash
```
## Swagger
重新生成 Swagger 文档:
```bash
go install github.com/swaggo/swag/cmd/swag@v1.16.4
cd openflare_server
swag init -g main.go -o docs
```
## Docs
本地预览:
```bash
cd docs
pnpm dev
```
构建:
```bash
cd docs
pnpm build
```
+265
View File
@@ -0,0 +1,265 @@
# 配置项
你会学到:OpenFlare Server、前端构建和 Agent 支持哪些配置来源、配置项默认值是什么,以及常见部署组合应该如何配置。
本文档汇总 OpenFlare `1.0.0` 当前支持的 Server 与 Agent 配置项,只保留仍然有效的启动、部署与运行参数。
## 配置来源
Server 支持三类配置来源:
1. 命令行参数。
2. 环境变量。
3. 数据库 `Option` 表中的运行时配置。
Agent 支持:
1. `-config` 命令行参数。
2. `agent.json` 配置文件。
3. 少量日志相关环境变量。
## 配置文件位置
| 组件 | 默认位置 | 说明 |
| --- | --- | --- |
| Server SQLite | `openflare.db` | 可通过 `SQLITE_PATH` 修改 |
| Server 上传目录 | `upload` | 可通过 `UPLOAD_PATH` 修改 |
| Agent 配置文件 | `./agent.json` | 可通过 `-config` 指定 |
| 一键安装 Agent 配置 | `/opt/openflare-agent/agent.json` | 安装脚本默认生成 |
| Agent 数据目录 | 配置文件所在目录下的 `data` | 可通过 `data_dir` 修改 |
## Server 命令行参数
```bash
cd openflare_server
go run . --port 3000 --log-dir ./logs
```
| 参数 | 作用 | 默认值 |
| --- | --- | --- |
| `--port` | 指定 Server 监听端口 | `3000` |
| `--log-dir` | 指定日志目录 | 空 |
| `--version` | 输出当前版本后退出 | `false` |
| `--help` | 输出帮助信息后退出 | `false` |
## Server 环境变量
| 环境变量 | 作用 | 默认值 |
| --- | --- | --- |
| `PORT` | Server 监听端口 | `3000` |
| `GIN_MODE` | Gin 运行模式 | 非 `debug` 时按 release |
| `LOG_LEVEL` | 日志等级 | `info` |
| `SESSION_SECRET` | Session 签名密钥 | 启动时随机生成 |
| `SQLITE_PATH` | SQLite 数据库文件路径 | `openflare.db` |
| `DSN` | PostgreSQL DSN,设置后优先于 SQLite | 空 |
| `SQL_DSN` | 兼容旧命名的 PostgreSQL DSN,优先级低于 `DSN` | 空 |
| `REDIS_CONN_STRING` | Redis 连接串 | 空 |
| `UPLOAD_PATH` | 上传目录 | `upload` |
| `AGENT_TOKEN` | 兼容旧部署的全局 Agent Token | 空 |
说明:
* `DSN` 与 `SQL_DSN` 同时存在时优先使用 `DSN`。
* `DSN` 或 `SQL_DSN` 与 `SQLITE_PATH` 同时存在时优先使用 PostgreSQL。
* 当目标 PostgreSQL 数据库为空且本地 `SQLITE_PATH` 文件存在时,Server 启动阶段会自动迁移 SQLite 数据,并在日志中输出按表迁移进度。
* `SESSION_SECRET` 生产环境必须显式配置。
* `REDIS_CONN_STRING` 未配置时,相关能力回退为进程内实现。
## 运行时 Option
以下配置由管理端设置页维护,可热更新:
| 配置项 | 作用 | 默认值 |
| --- | --- | --- |
| `AgentHeartbeatInterval` | Agent 心跳间隔(毫秒) | `10000` |
| `AgentWebsocketUpgradeEnabled` | 是否允许 Agent 在 HTTP 心跳成功后升级为 WebSocket | `true` |
| `NodeOfflineThreshold` | 节点离线阈值(毫秒) | `120000` |
| `AgentUpdateRepo` | Agent 自更新仓库 | `Rain-kl/OpenFlare` |
| `GeoIPProvider` | 节点/IP 归属解析方式 | `ipinfo` |
| `DatabaseAutoCleanupEnabled` | 是否启用每日自动清理观测数据 | `false` |
| `DatabaseAutoCleanupRetentionDays` | 自动清理保留天数,至少 1 天 | `30` |
| `GlobalApiRateLimitNum` / `GlobalApiRateLimitDuration` | 全局 API 限流次数 / 时间窗口 | `300` / `180` |
| `GlobalWebRateLimitNum` / `GlobalWebRateLimitDuration` | 全局 Web 限流次数 / 时间窗口 | `300` / `180` |
| `UploadRateLimitNum` / `UploadRateLimitDuration` | 上传接口限流次数 / 时间窗口 | `50` / `60` |
| `DownloadRateLimitNum` / `DownloadRateLimitDuration` | 下载接口限流次数 / 时间窗口 | `50` / `60` |
| `CriticalRateLimitNum` / `CriticalRateLimitDuration` | 敏感接口限流次数 / 时间窗口 | `100` / `1200` |
说明:
* `DatabaseAutoCleanupEnabled` 开启后,Server 会在每天凌晨 3 点自动清理 `node_access_logs`、`node_metric_snapshots`、`node_request_reports` 三类观测数据。
* `DatabaseAutoCleanupRetentionDays` 为统一保留天数,必须大于等于 1。
* 管理端支持手动清理时留空保留天数,以直接删除对应数据集的全部历史记录。
* `AgentUpdateRepo` 指向的 GitHub Release 必须为每个 Agent 二进制提供同名 `.sha256` 校验文件,例如 `openflare-agent-linux-amd64.sha256`;Agent 自更新会在替换可执行文件前校验 SHA-256。
* 第三方登录不再通过 `GitHubOAuthEnabled`、`GitHubClientId`、`GitHubClientSecret` 作为主配置入口;这些旧 Option 仅用于升级时迁移默认 GitHub 认证源。
* 微信登录旧 Option 保留为兼容字段,但管理端不再提供微信登录配置入口。
* Turnstile 旧 Option 与后端校验能力保留,已有配置仍会生效。
## OpenResty 参数
OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前常用项包括:
* `OpenRestyWorkerProcesses`
* `OpenRestyWorkerConnections`
* `OpenRestyWorkerRlimitNofile`
* `OpenRestyKeepaliveTimeout`
* `OpenRestyProxyConnectTimeout`
* `OpenRestyProxySendTimeout`
* `OpenRestyProxyReadTimeout`
* `OpenRestyProxyBufferingEnabled`
* `OpenRestyGzipEnabled`
* `OpenRestyCacheEnabled`
* `OpenRestyCachePath`
* `OpenRestyCacheMaxSize`
这类参数必须以结构化方式校验、保存并参与版本渲染。
约束:
* 管理端不再暴露 `resolver` 配置。
* 规则上游统一渲染为 named `upstream` 并启用 keepalive。
* 单上游如带 base path 或 query,会在 `proxy_pass` 中补回原始 URI。
* 多上游仍要求每个上游都为纯 `scheme://host[:port]`,且同一规则内协议一致。
* `OpenRestyCacheEnabled` 用于启用缓存基础设施与全局默认参数;实际是否缓存、按 URL / 后缀 / 路径等命中策略由各条 `proxy_routes` 单独决定。
* 默认缓存 Key 为 `$scheme$host$request_uri`。
* 默认 `keepalive_timeout` 为 `20` 秒,默认 `proxy_connect_timeout` 为 `3` 秒。
* 默认事件模型为 `epoll`,并默认开启 `multi_accept`。
* HTTPS 监听默认使用独立 `http2 on;` 指令,避免新版 Nginx/OpenResty 对 `listen ... http2` 的弃用告警。
## 前端构建环境变量
| 环境变量 | 作用 | 默认值 |
| --- | --- | --- |
| `NEXT_PUBLIC_API_BASE_URL` | 前端请求 API 的基础路径 | `/api` |
| `NEXT_PUBLIC_APP_VERSION` | 前端展示版本号 | `dev` |
| `NEXT_DEV_BACKEND_URL` | 本地开发服务器代理的后端地址 | `http://127.0.0.1:3000` |
## Agent 环境变量
| 环境变量 | 作用 | 默认值 |
| --- | --- | --- |
| `LOG_LEVEL` | Agent 日志等级 | `info` |
| `OPENFLARE_SERVER_URL` | 控制面地址,可覆盖 `agent.json` | 空 |
| `OPENFLARE_AGENT_TOKEN` | 节点专属认证 Token,可覆盖 `agent.json` | 空 |
| `OPENFLARE_DISCOVERY_TOKEN` | 首次自动注册 Token,可覆盖 `agent.json` | 空 |
| `OPENFLARE_NODE_NAME` | 节点名称,可覆盖 `agent.json` | 空 |
| `OPENFLARE_NODE_IP` | 节点 IP,可覆盖 `agent.json` | 空 |
| `OPENFLARE_DATA_DIR` | Agent 数据目录,可覆盖 `agent.json` | 空 |
| `OPENFLARE_OPENRESTY_PATH` | OpenResty 二进制路径,可覆盖 `agent.json` | 空 |
| `OPENFLARE_HEARTBEAT_INTERVAL` | 心跳间隔,可覆盖 `agent.json` | 空 |
| `OPENFLARE_REQUEST_TIMEOUT` | 请求超时,可覆盖 `agent.json` | 空 |
| `OPENFLARE_OPENRESTY_OBSERVABILITY_PORT` | 本地观测端口,可覆盖 `agent.json` | 空 |
| `OPENFLARE_MMDB_PATH` | WAF GeoIP mmdb 路径,可覆盖 `agent.json` | 空 |
| `OPENFLARE_MMDB_UPDATE_INTERVAL` | WAF GeoIP mmdb 更新间隔,可覆盖 `agent.json` | 空 |
| `OPENFLARE_MMDB_DOWNLOAD_URL` | WAF GeoIP mmdb 下载地址,可覆盖 `agent.json` | 空 |
## Agent 命令行参数
| 参数 | 作用 | 默认值 |
| --- | --- | --- |
| `-config` | 指定 Agent 配置文件路径 | `./agent.json` |
## Agent 配置字段
| 字段 | 作用 | 是否必填 | 默认值/行为 |
| --- | --- | --- | --- |
| `server_url` | 控制面地址 | 是 | 无 |
| `agent_token` | 节点专属认证 Token | 与 `discovery_token` 二选一 | 空 |
| `discovery_token` | 首次自动注册使用的全局 Token | 与 `agent_token` 二选一 | 空 |
| `node_name` | 节点名称 | 否 | 自动使用主机名 |
| `node_ip` | 节点 IP | 否 | 自动探测,优先通过第三方 API 获取真实出口公网 IP;失败时退回本机网卡探测 |
| `openresty_path` | OpenResty 二进制路径 | 否 | `openresty` |
| `openresty_container_name` | 旧 Docker 控制字段,仅兼容读取 | 否 | 空 |
| `openresty_docker_image` | 旧 Docker 控制字段,仅兼容读取 | 否 | 空 |
| `openresty_observability_port` | 本地观测与 OpenResty 健康检查端口 | 否 | `18081` |
| `docker_binary` | 旧 Docker 控制字段,仅兼容读取 | 否 | 空 |
| `data_dir` | Agent 数据目录 | 否 | 配置文件所在目录下的 `data` |
| `main_config_path` | OpenResty 主配置写入路径 | 否 | `data_dir/etc/nginx/nginx.conf` |
| `route_config_path` | 路由配置写入路径 | 否 | `data_dir/etc/nginx/conf.d/openflare_routes.conf` |
| `access_log_path` | OpenResty 访问日志路径 | 否 | `data_dir/var/log/openflare/access.log` |
| `cert_dir` | 证书写入目录 | 否 | `data_dir/etc/nginx/certs` |
| `openresty_cert_dir` | OpenResty 配置中读取证书的目录 | 否 | 同 `cert_dir` |
| `lua_dir` | Lua 脚本与静态资源写入目录 | 否 | `data_dir/etc/nginx/lua` |
| `openresty_lua_dir` | OpenResty 配置中读取 Lua 的目录 | 否 | 同 `lua_dir` |
| `runtime_config_dir` | Agent 运行时配置写入目录,如 `pow_config.json` | 否 | `data_dir/etc/openflare` |
| `mmdb_path` | WAF GeoIP mmdb 文件路径 | 否 | `data_dir/etc/openflare/GeoLite2-Country.mmdb` |
| `mmdb_update_interval` | WAF GeoIP mmdb 更新间隔 | 否 | `86400000` 毫秒 |
| `mmdb_download_url` | WAF GeoIP mmdb 下载地址 | 否 | 内置 GeoLite2 Country 下载地址 |
| `observability_buffer_path` | 观测补报缓冲文件路径 | 否 | `data_dir/var/lib/openflare/observability-buffer.json` |
| `observability_replay_minutes` | 自动补传最近观测窗口分钟数 | 否 | `15` |
| `state_path` | Agent 本地状态文件路径 | 否 | `data_dir/var/lib/openflare/agent-state.json` |
| `heartbeat_interval` | 心跳间隔 | 否 | `10000` 毫秒 |
| `request_timeout` | HTTP 请求超时 | 否 | `10000` 毫秒 |
说明:
* `agent_token` 与 `discovery_token` 不能同时为空。
* `heartbeat_interval` 与 `request_timeout` 支持毫秒整数或 Go duration 字符串。
* Server 运行时配置 `AgentWebsocketUpgradeEnabled` 开启时,Agent 会在 HTTP 心跳成功后尝试升级为 WebSocket;连接失败或断开后自动退回 HTTP 心跳。
* 未配置 `openresty_path` 时默认调用 `openresty`。
* Agent 周期性健康检查会请求 `http://127.0.0.1:<openresty_observability_port>/openflare/stub_status`,不再通过高频 `openresty -t` 判断运行时健康;配置应用、启动恢复和 reload 前校验仍会执行 `openresty -t -c <main_config_path>`。
* Agent 会初始化并定期更新 `mmdb_path`,供 OpenResty WAF Lua 执行国家级地域规则;更新失败只记录警告,不阻断同步或 reload。
* 如果 `agent.json` 不存在,但 `OPENFLARE_SERVER_URL` 与 Token 等环境变量足够,Agent 可以直接启动;两者同时存在时环境变量优先。
* Agent 未配置 `node_ip` 时,会优先通过 `https://realip.cc` 获取真实出口公网 IP,适配 Docker/NAT 场景;该请求失败时,才退回本机网卡探测并优先选择公网 IPv4。
* Agent 自动探测到私网 `node_ip` 时,Server 会在注册/心跳阶段优先保留 Agent 直连来源的公网地址,避免 NAT/多网卡场景误登记内网网卡地址。
## 常见配置组合
### 生产 Server + PostgreSQL
```bash
export SESSION_SECRET='replace-with-a-long-random-string'
export DSN='postgres://openflare:replace-with-strong-password@postgres:5432/openflare?sslmode=disable'
export GIN_MODE='release'
export LOG_LEVEL='info'
```
### 本地 Server + SQLite
```bash
export SESSION_SECRET='dev-session-secret'
export SQLITE_PATH='./openflare-dev.db'
export LOG_LEVEL='debug'
go run .
```
### Agent + 默认 OpenResty
```json
{
"server_url": "http://your-server:3000",
"agent_token": "replace-with-node-auth-token",
"data_dir": "/opt/openflare-agent/data",
"openresty_path": "openresty",
"heartbeat_interval": 10000,
"request_timeout": 10000
}
```
### Agent + 自定义 OpenResty 路径
```json
{
"server_url": "http://your-server:3000",
"agent_token": "replace-with-node-auth-token",
"data_dir": "/var/lib/openflare-agent",
"openresty_path": "/usr/local/openresty/nginx/sbin/openresty",
"main_config_path": "/var/lib/openflare-agent/etc/nginx/nginx.conf",
"route_config_path": "/var/lib/openflare-agent/etc/nginx/conf.d/openflare_routes.conf",
"access_log_path": "/var/lib/openflare-agent/var/log/openflare/access.log",
"cert_dir": "/var/lib/openflare-agent/etc/nginx/certs",
"lua_dir": "/var/lib/openflare-agent/etc/nginx/lua",
"runtime_config_dir": "/var/lib/openflare-agent/etc/openflare",
"heartbeat_interval": 10000,
"request_timeout": 10000
}
```
## 维护要求
以下内容变化时,必须同步更新本文档:
* Server 命令行参数。
* Server 环境变量。
* Agent 命令行参数。
* Agent 配置字段。
* 任一配置项的默认值、用途或示例。
+12
View File
@@ -0,0 +1,12 @@
# 参考
你会学到:哪些信息属于稳定参考资料,以及配置、命令、API 和仓库结构应该从哪里查。
本部分收敛运行、接口与仓库层面的稳定信息,适合部署、联调和排查时快速查阅。
| 页面 | 内容 |
| --- | --- |
| [配置项](./configuration.md) | Server 环境变量、命令行参数、运行时 Option 与 Agent 配置字段 |
| [命令与脚本](./cli.md) | 常用启动、构建、测试、安装和卸载命令 |
| [API 约定](./api.md) | 管理端 API 与 Agent API 的响应结构、鉴权和路径约定 |
| [仓库结构](./repository.md) | `openflare_server`、`openflare_agent`、`openflare_server/web` 与 `docs` 的职责 |
+47
View File
@@ -0,0 +1,47 @@
# 仓库结构
你会学到:OpenFlare 仓库中 Server、Agent、前端、脚本和文档目录分别负责什么,以及贡献代码时应把逻辑放到哪一层。
| 路径 | 职责 |
| --- | --- |
| `openflare_server` | Gin + GORM + SQLite/PostgreSQL 单体控制面 |
| `openflare_server/web` | Next.js 15 App Router 管理端前端,静态导出后由 Go Server 托管 |
| `openflare_agent` | Go 单体 Agent,运行在节点侧 |
| `scripts` | Agent 安装、卸载等辅助脚本 |
| `docs` | VitePress 文档站、设计基线、开发规范、部署与配置文档 |
## Server 分层
| 目录 | 职责 |
| --- | --- |
| `controller/` | 参数解析、调用 service、返回响应 |
| `service/` | 业务逻辑、校验、事务编排、配置渲染 |
| `model/` | 模型定义、数据库版本与迁移 |
| `router/` | 路由注册 |
| `middleware/` | 认证、鉴权、限流等横切逻辑 |
| `common/` | 配置、全局状态与初始化入口 |
| `utils/` | 纯工具函数与通用 helper |
## Agent 模块
| 模块 | 职责 |
| --- | --- |
| `config` | 配置读取与默认值 |
| `heartbeat` | 心跳与版本摘要判断 |
| `sync` | 配置拉取与应用编排 |
| `nginx` / `openresty` | OpenResty 文件写入、校验、reload、启动与回滚 |
| `state` | 本地状态与观测补报缓冲 |
| `httpclient` | Server 通信 |
| `protocol` | Agent API 协议类型 |
| `internal/updater` | Agent 自更新 |
## Frontend 分层
| 目录 | 职责 |
| --- | --- |
| `app/` | 路由、布局、页面组装 |
| `features/` | 按业务域组织模块 |
| `components/` | 跨 feature 复用组件 |
| `lib/` | 请求客户端、环境变量、工具函数、常量 |
| `store/` | 少量跨页面 UI 状态 |
| `types/` | 共享类型定义 |
+34
View File
@@ -0,0 +1,34 @@
ARG VERSION=dev
FROM golang:1.25-alpine AS builder
ARG VERSION
ARG TARGETOS=linux
ARG TARGETARCH
ENV CGO_ENABLED=0 \
GOOS=${TARGETOS} \
GOARCH=${TARGETARCH}
WORKDIR /build
COPY openflare_server ./openflare_server
COPY openflare_agent ./openflare_agent
WORKDIR /build/openflare_agent
RUN go mod download
RUN go build -trimpath -ldflags "-s -w -X 'openflare-agent/internal/config.AgentVersion=$VERSION'" -o /build/openflare-agent ./cmd/agent
FROM openresty/openresty:alpine
RUN apk add --no-cache ca-certificates tzdata perl libmaxminddb \
&& ln -sf /usr/lib/libmaxminddb.so.0 /usr/lib/libmaxminddb.so \
&& opm get anjia0532/lua-resty-maxminddb \
&& mkdir -p /etc/openflare /data
ENV OPENFLARE_OPENRESTY_PATH=openresty \
OPENFLARE_DATA_DIR=/data
COPY --from=builder /build/openflare-agent /usr/local/bin/openflare-agent
EXPOSE 80 443 18081
ENTRYPOINT ["/usr/local/bin/openflare-agent"]
CMD ["-config", "/etc/openflare/agent.json"]
+2 -3
View File
@@ -1,9 +1,8 @@
{
"server_url": "http://127.0.0.1:3000",
"agent_token": "2380de64b00e99093e16590beb91e1a0",
"agent_token": "373956188ddead1df6dd7c86cd330b73",
"data_dir": "./data",
"openresty_container_name": "openflare-openresty",
"openresty_docker_image": "openresty/openresty:alpine",
"openresty_path": "openresty",
"heartbeat_interval": 10000,
"request_timeout": 10000
}
+15 -11
View File
@@ -10,6 +10,7 @@ import (
"openflare-agent/internal/agent"
"openflare-agent/internal/config"
"openflare-agent/internal/geoipupdate"
"openflare-agent/internal/heartbeat"
"openflare-agent/internal/httpclient"
"openflare-agent/internal/logging"
@@ -17,6 +18,7 @@ import (
"openflare-agent/internal/state"
syncservice "openflare-agent/internal/sync"
"openflare-agent/internal/updater"
"openflare-agent/internal/wsclient"
)
func main() {
@@ -34,9 +36,6 @@ func main() {
context.Background(),
nginx.ExecutorOptions{
NginxPath: cfg.OpenrestyPath,
DockerBinary: cfg.DockerBinary,
ContainerName: cfg.OpenrestyContainerName,
Image: cfg.OpenrestyDockerImage,
MainConfigPath: cfg.MainConfigPath,
RouteConfigPath: cfg.RouteConfigPath,
CertDir: cfg.CertDir,
@@ -52,33 +51,31 @@ func main() {
"ip", cfg.NodeIP,
"heartbeat_interval", cfg.HeartbeatInterval,
"route_config", cfg.RouteConfigPath,
"access_log", cfg.AccessLogPath,
"cert_dir", cfg.CertDir,
"lua_dir", cfg.LuaDir,
"runtime_config_dir", cfg.RuntimeConfigDir,
"mmdb_path", cfg.MMDBPath,
)
client := httpclient.New(cfg.ServerURL, cfg.InitialAuthToken(), cfg.RequestTimeout.Duration())
wsClient := wsclient.New(cfg.ServerURL, cfg.InitialAuthToken(), cfg.RequestTimeout.Duration())
stateStore := state.NewStore(cfg.StatePath)
observabilityBuffer := state.NewObservabilityBufferStore(cfg.ObservabilityBufferPath)
runtimeRouteConfigPath := cfg.RouteConfigPath
if cfg.OpenrestyPath == "" {
runtimeRouteConfigPath = nginx.DockerRouteConfigPath
}
runtimeManager := &nginx.Manager{
MainConfigPath: cfg.MainConfigPath,
RouteConfigPath: cfg.RouteConfigPath,
RuntimeRouteConfigPath: runtimeRouteConfigPath,
AccessLogPath: cfg.AccessLogPath,
CertDir: cfg.CertDir,
NginxCertDir: cfg.OpenrestyCertDir,
LuaDir: cfg.LuaDir,
NginxLuaDir: cfg.OpenrestyLuaDir,
RuntimeConfigDir: cfg.RuntimeConfigDir,
OpenrestyObservabilityListen: nginx.ObservabilityListenAddress(cfg.OpenrestyPath, cfg.OpenrestyObservabilityPort),
OpenrestyObservabilityPort: cfg.OpenrestyObservabilityPort,
OpenrestyResolverDirective: "",
Executor: nginx.NewExecutor(nginx.ExecutorOptions{
NginxPath: cfg.OpenrestyPath,
DockerBinary: cfg.DockerBinary,
ContainerName: cfg.OpenrestyContainerName,
Image: cfg.OpenrestyDockerImage,
MainConfigPath: cfg.MainConfigPath,
RouteConfigPath: cfg.RouteConfigPath,
CertDir: cfg.CertDir,
@@ -100,10 +97,17 @@ func main() {
SyncService: syncservice.New(client, runtimeManager, stateStore),
Updater: updater.New(),
RuntimeManager: runtimeManager,
WebSocketService: wsClient,
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
geoIPUpdater := &geoipupdate.Updater{
MMDBPath: cfg.MMDBPath,
DownloadURL: cfg.MMDBDownloadURL,
UpdateInterval: cfg.MMDBUpdateInterval.Duration(),
}
go geoIPUpdater.Run(ctx)
slog.Info("agent process started")
if err = runner.Run(ctx); err != nil && err != context.Canceled {
+16 -1
View File
@@ -1,3 +1,18 @@
module openflare-agent
go 1.23.0
go 1.25.0
require (
golang.org/x/net v0.53.0
openflare v0.0.0
)
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/oschwald/maxminddb-golang v1.13.1 // indirect
golang.org/x/sys v0.43.0 // indirect
)
replace openflare => ../openflare_server
+22
View File
@@ -0,0 +1,22 @@
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
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/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+277 -40
View File
@@ -2,6 +2,7 @@ package agent
import (
"context"
"encoding/json"
"errors"
"log/slog"
"strings"
@@ -22,6 +23,7 @@ type HeartbeatService interface {
type SyncService interface {
SyncOnStartup(ctx context.Context, target *protocol.ActiveConfigMeta) error
SyncOnce(ctx context.Context, target *protocol.ActiveConfigMeta) error
ForceSyncOnce(ctx context.Context, target *protocol.ActiveConfigMeta) error
}
type Updater interface {
@@ -33,6 +35,12 @@ type RuntimeManager interface {
Restart(ctx context.Context) error
}
type WebSocketService interface {
Connect(ctx context.Context) (protocol.WebSocketConnection, error)
SetToken(token string)
URL() string
}
type UpdateOptions struct {
Channel string
TagName string
@@ -47,13 +55,15 @@ type Runner struct {
SyncService SyncService
Updater Updater
RuntimeManager RuntimeManager
WebSocketService WebSocketService
autoUpdate bool
updateNow bool
updateRepo string
updateChan string
updateTag string
restartOpenrestyNow bool
autoUpdate bool
updateNow bool
updateRepo string
updateChan string
updateTag string
restartOpenrestyNow bool
websocketUpgradeEnabled bool
}
func (r *Runner) Run(ctx context.Context) error {
@@ -63,26 +73,8 @@ func (r *Runner) Run(ctx context.Context) error {
}
slog.Info("agent runner started", "node_id", nodeID, "node", r.Config.NodeName, "ip", r.Config.NodeIP)
if r.hasAgentToken() {
r.refreshOpenrestyHealth(ctx)
payload, ackWindows := r.prepareHeartbeatPayload(nodeID)
heartbeatResult, hbErr := r.HeartbeatService.Heartbeat(ctx, payload)
if hbErr != nil {
if _, hbErr := r.performHeartbeatCycle(ctx, nodeID, true); hbErr != nil {
slog.Error("agent startup heartbeat failed", "error", hbErr)
} else {
r.ackObservabilityWindows(ackWindows)
if heartbeatResult == nil {
heartbeatResult = &protocol.HeartbeatResult{}
}
slog.Debug("agent startup heartbeat succeeded", "node_id", nodeID)
r.applySettings(heartbeatResult.AgentSettings)
if err = r.SyncService.SyncOnStartup(ctx, heartbeatResult.ActiveConfig); err != nil {
r.recordSyncError(err)
slog.Error("agent startup sync failed", "error", err)
} else {
slog.Debug("agent startup sync completed")
}
r.tryRestartOpenresty(ctx)
r.tryAutoUpdate(ctx)
}
} else if err = r.tryRegister(ctx, &nodeID); err != nil {
slog.Error("agent initial discovery register failed", "error", err)
@@ -90,43 +82,278 @@ func (r *Runner) Run(ctx context.Context) error {
heartbeatTicker := time.NewTicker(r.Config.HeartbeatInterval.Duration())
defer heartbeatTicker.Stop()
var wsDone <-chan error
wsBackoff := newWebSocketBackoff()
nextWSAttempt := time.Now()
tryStartWebSocket := func() {
if wsDone != nil || !r.shouldUseWebSocket() || time.Now().Before(nextWSAttempt) {
return
}
done, startErr := r.startWebSocket(ctx, nodeID)
if startErr != nil {
delay := wsBackoff.Next()
nextWSAttempt = time.Now().Add(delay)
slog.Debug("agent ws upgrade failed; falling back to http heartbeat",
"enabled", r.websocketUpgradeEnabled,
"url", r.websocketURL(),
"retry_after", delay,
"error", startErr,
)
return
}
wsBackoff.Reset()
wsDone = done
slog.Debug("agent switched to websocket mode", "url", r.websocketURL())
}
tryStartWebSocket()
for {
select {
case <-ctx.Done():
slog.Info("agent runner shutting down", "error", ctx.Err())
return ctx.Err()
case wsErr := <-wsDone:
wsDone = nil
delay := wsBackoff.Next()
nextWSAttempt = time.Now().Add(delay)
slog.Debug("agent ws disconnected; resuming http heartbeat", "retry_after", delay, "error", wsErr)
if r.hasAgentToken() {
if _, hbErr := r.performHeartbeatCycle(ctx, nodeID, false); hbErr != nil {
slog.Error("agent heartbeat after ws disconnect failed", "error", hbErr)
}
}
case <-heartbeatTicker.C:
if wsDone != nil {
continue
}
if !r.hasAgentToken() {
if err = r.tryRegister(ctx, &nodeID); err != nil {
slog.Error("agent discovery register failed", "error", err)
}
continue
}
r.refreshOpenrestyHealth(ctx)
payload, ackWindows := r.prepareHeartbeatPayload(nodeID)
heartbeatResult, hbErr := r.HeartbeatService.Heartbeat(ctx, payload)
if hbErr != nil {
if changed, hbErr := r.performHeartbeatCycle(ctx, nodeID, false); hbErr != nil {
slog.Error("agent heartbeat failed", "error", hbErr)
} else {
r.ackObservabilityWindows(ackWindows)
if heartbeatResult == nil {
heartbeatResult = &protocol.HeartbeatResult{}
}
if changed := r.applySettings(heartbeatResult.AgentSettings); changed {
if changed {
heartbeatTicker.Reset(r.Config.HeartbeatInterval.Duration())
}
if err = r.SyncService.SyncOnce(ctx, heartbeatResult.ActiveConfig); err != nil {
r.recordSyncError(err)
slog.Error("agent sync failed", "error", err)
}
r.tryRestartOpenresty(ctx)
r.tryAutoUpdate(ctx)
tryStartWebSocket()
}
}
}
}
func (r *Runner) performHeartbeatCycle(ctx context.Context, nodeID string, startup bool) (bool, error) {
r.refreshOpenrestyHealth(ctx)
payload, ackWindows := r.prepareHeartbeatPayload(nodeID)
heartbeatResult, err := r.HeartbeatService.Heartbeat(ctx, payload)
if err != nil {
return false, err
}
r.ackObservabilityWindows(ackWindows)
if heartbeatResult == nil {
heartbeatResult = &protocol.HeartbeatResult{}
}
mode := "periodic"
if startup {
mode = "startup"
}
slog.Debug("agent heartbeat succeeded", "mode", mode, "node_id", nodeID)
changed := r.applySettings(heartbeatResult.AgentSettings)
if startup {
if err = r.SyncService.SyncOnStartup(ctx, heartbeatResult.ActiveConfig); err != nil {
r.recordSyncError(err)
slog.Error("agent startup sync failed", "error", err)
} else {
slog.Debug("agent startup sync completed")
}
} else if err = r.SyncService.SyncOnce(ctx, heartbeatResult.ActiveConfig); err != nil {
r.recordSyncError(err)
slog.Error("agent sync failed", "error", err)
}
r.tryRestartOpenresty(ctx)
r.tryAutoUpdate(ctx)
return changed, nil
}
func (r *Runner) shouldUseWebSocket() bool {
enabled := r.WebSocketService != nil && r.websocketUpgradeEnabled && r.hasAgentToken()
slog.Debug("agent ws upgrade eligibility checked", "enabled", enabled, "server_enabled", r.websocketUpgradeEnabled, "url", r.websocketURL())
return enabled
}
func (r *Runner) websocketURL() string {
if r.WebSocketService == nil {
return ""
}
return r.WebSocketService.URL()
}
func (r *Runner) startWebSocket(ctx context.Context, nodeID string) (<-chan error, error) {
if r.WebSocketService == nil {
return nil, errors.New("websocket service is not configured")
}
conn, err := r.WebSocketService.Connect(ctx)
if err != nil {
return nil, err
}
done := make(chan error, 1)
go func() {
defer func() {
_ = conn.Close()
}()
done <- r.runWebSocket(ctx, nodeID, conn)
}()
return done, nil
}
func (r *Runner) runWebSocket(ctx context.Context, nodeID string, conn protocol.WebSocketConnection) error {
slog.Debug("agent ws connected", "url", conn.URL(), "node_id", nodeID)
statusTicker := time.NewTicker(r.Config.HeartbeatInterval.Duration())
defer statusTicker.Stop()
messages := make(chan protocol.WSMessage, 8)
readDone := make(chan error, 1)
go func() {
for {
message, err := conn.Receive()
if err != nil {
readDone <- err
return
}
select {
case messages <- message:
case <-ctx.Done():
readDone <- ctx.Err()
return
}
}
}()
if err := r.sendWebSocketStatus(ctx, nodeID, conn); err != nil {
return err
}
for {
select {
case <-ctx.Done():
return ctx.Err()
case err := <-readDone:
return err
case <-statusTicker.C:
if err := r.sendWebSocketStatus(ctx, nodeID, conn); err != nil {
return err
}
case message := <-messages:
changed, err := r.handleWebSocketMessage(ctx, message, conn)
if err != nil {
return err
}
if changed {
statusTicker.Reset(r.Config.HeartbeatInterval.Duration())
}
}
}
}
func (r *Runner) sendWebSocketStatus(ctx context.Context, nodeID string, conn protocol.WebSocketConnection) error {
r.refreshOpenrestyHealth(ctx)
payload, ackWindows := r.prepareHeartbeatPayload(nodeID)
if err := conn.SendStatus(payload); err != nil {
return err
}
r.ackObservabilityWindows(ackWindows)
return nil
}
func (r *Runner) handleWebSocketMessage(ctx context.Context, message protocol.WSMessage, conn protocol.WebSocketConnection) (bool, error) {
switch message.Type {
case protocol.WSMessageTypeSettings:
var settings protocol.AgentSettings
if err := json.Unmarshal(message.Payload, &settings); err != nil {
slog.Debug("agent ws settings decode failed", "error", err)
return false, nil
}
changed := r.applySettings(&settings)
r.tryRestartOpenresty(ctx)
r.tryAutoUpdate(ctx)
if !r.websocketUpgradeEnabled {
slog.Debug("agent ws disabled by server settings; falling back to http heartbeat")
return changed, errors.New("websocket upgrade disabled by server")
}
return changed, nil
case protocol.WSMessageTypeActiveConfig:
var target protocol.ActiveConfigMeta
if err := json.Unmarshal(message.Payload, &target); err != nil {
slog.Debug("agent ws active config decode failed", "error", err)
return false, nil
}
slog.Debug("agent ws active config received", "version", target.Version, "checksum", target.Checksum, "trigger_sync", true)
if err := r.SyncService.SyncOnce(ctx, &target); err != nil {
r.recordSyncError(err)
slog.Error("agent ws triggered sync failed", "version", target.Version, "error", err)
}
return false, nil
case protocol.WSMessageTypeForceSyncConfig:
var target protocol.ActiveConfigMeta
if err := json.Unmarshal(message.Payload, &target); err != nil {
slog.Debug("agent ws force sync config decode failed", "error", err)
return false, nil
}
slog.Debug("agent ws force sync config received", "version", target.Version, "checksum", target.Checksum, "trigger_sync", true)
if err := r.SyncService.ForceSyncOnce(ctx, &target); err != nil {
r.recordSyncError(err)
slog.Error("agent ws triggered force sync failed", "version", target.Version, "error", err)
}
return false, nil
case protocol.WSMessageTypePing:
slog.Debug("agent ws ping received")
return false, conn.SendPong()
case protocol.WSMessageTypePong:
slog.Debug("agent ws pong received")
return false, nil
default:
slog.Debug("agent ws unsupported message type", "type", message.Type)
return false, nil
}
}
type webSocketBackoff struct {
delays []time.Duration
index int
}
func newWebSocketBackoff() *webSocketBackoff {
return &webSocketBackoff{
delays: []time.Duration{
time.Second,
2 * time.Second,
5 * time.Second,
10 * time.Second,
30 * time.Second,
},
}
}
func (backoff *webSocketBackoff) Next() time.Duration {
if backoff == nil || len(backoff.delays) == 0 {
return 30 * time.Second
}
if backoff.index >= len(backoff.delays) {
return backoff.delays[len(backoff.delays)-1]
}
delay := backoff.delays[backoff.index]
backoff.index++
return delay
}
func (backoff *webSocketBackoff) Reset() {
if backoff != nil {
backoff.index = 0
}
}
func (r *Runner) hasAgentToken() bool {
return strings.TrimSpace(r.Config.AgentToken) != ""
}
@@ -144,6 +371,10 @@ func (r *Runner) applySettings(settings *protocol.AgentSettings) bool {
changed = true
}
}
if settings.WebsocketUpgradeEnabled != r.websocketUpgradeEnabled {
slog.Debug("agent websocket upgrade setting updated", "from", r.websocketUpgradeEnabled, "to", settings.WebsocketUpgradeEnabled)
}
r.websocketUpgradeEnabled = settings.WebsocketUpgradeEnabled
r.autoUpdate = settings.AutoUpdate
r.updateNow = settings.UpdateNow
r.updateRepo = strings.TrimSpace(settings.UpdateRepo)
@@ -222,6 +453,9 @@ func (r *Runner) tryRegister(ctx context.Context, nodeID *string) error {
return err
}
r.HeartbeatService.SetToken(response.AgentToken)
if r.WebSocketService != nil {
r.WebSocketService.SetToken(response.AgentToken)
}
*nodeID = response.NodeID
slog.Info("agent discovery registration succeeded", "node_id", response.NodeID)
r.refreshOpenrestyHealth(ctx)
@@ -268,6 +502,9 @@ func (r *Runner) refreshOpenrestyHealth(ctx context.Context) {
return
}
if err := r.RuntimeManager.CheckHealth(ctx); err != nil {
if strings.Contains(err.Error(), "openresty config not exists") {
return
}
r.recordOpenrestyUnhealthy(err, true)
return
}
+131 -3
View File
@@ -2,6 +2,7 @@ package agent
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
@@ -67,6 +68,7 @@ type fakeSyncService struct {
syncOnceErr error
startupCalls int
syncOnceCalls int
lastTarget *protocol.ActiveConfigMeta
onSyncOnceCall func(int)
}
@@ -104,6 +106,10 @@ func (f *fakeSyncService) SyncOnStartup(ctx context.Context, target *protocol.Ac
func (f *fakeSyncService) SyncOnce(ctx context.Context, target *protocol.ActiveConfigMeta) error {
f.mu.Lock()
f.syncOnceCalls++
if target != nil {
copied := *target
f.lastTarget = &copied
}
callIndex := f.syncOnceCalls
callback := f.onSyncOnceCall
f.mu.Unlock()
@@ -113,6 +119,47 @@ func (f *fakeSyncService) SyncOnce(ctx context.Context, target *protocol.ActiveC
return f.syncOnceErr
}
func (f *fakeSyncService) ForceSyncOnce(ctx context.Context, target *protocol.ActiveConfigMeta) error {
f.mu.Lock()
f.syncOnceCalls++
if target != nil {
copied := *target
f.lastTarget = &copied
}
callIndex := f.syncOnceCalls
callback := f.onSyncOnceCall
f.mu.Unlock()
if callback != nil {
callback(callIndex)
}
return f.syncOnceErr
}
type fakeWebSocketConnection struct {
pongCalls int
}
func (f *fakeWebSocketConnection) URL() string {
return "ws://127.0.0.1/api/agent/ws"
}
func (f *fakeWebSocketConnection) SendStatus(payload protocol.NodePayload) error {
return nil
}
func (f *fakeWebSocketConnection) SendPong() error {
f.pongCalls++
return nil
}
func (f *fakeWebSocketConnection) Receive() (protocol.WSMessage, error) {
return protocol.WSMessage{}, errors.New("not implemented")
}
func (f *fakeWebSocketConnection) Close() error {
return nil
}
func TestRunnerKeepsHeartbeatWhenStartupSyncFails(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@@ -302,15 +349,16 @@ func TestRunnerHeartbeatPayloadIncludesObservabilityExtensions(t *testing.T) {
NginxVersion: "1.27.1.2",
DataDir: tempDir,
RouteConfigPath: filepath.Join(tempDir, "conf.d", "openflare_routes.conf"),
AccessLogPath: filepath.Join(tempDir, "var", "log", "openflare", "access.log"),
HeartbeatInterval: config.MillisecondDuration(10 * time.Millisecond),
},
StateStore: stateStore,
}
if err := os.MkdirAll(filepath.Dir(runner.Config.RouteConfigPath), 0o755); err != nil {
t.Fatalf("failed to prepare route config dir: %v", err)
if err := os.MkdirAll(filepath.Dir(runner.Config.AccessLogPath), 0o755); err != nil {
t.Fatalf("failed to prepare access log dir: %v", err)
}
if err := os.WriteFile(
filepath.Join(filepath.Dir(runner.Config.RouteConfigPath), "openflare_access.log"),
runner.Config.AccessLogPath,
[]byte("{\"ts\":\""+time.Now().UTC().Format(time.RFC3339)+"\",\"host\":\"edge.example.com\",\"path\":\"/\",\"remote_addr\":\"10.0.0.8\",\"status\":200}\n"),
0o644,
); err != nil {
@@ -494,3 +542,83 @@ func TestRunnerDiscoveryRegisterUpdatesTokenAndNodeID(t *testing.T) {
t.Fatal("expected config token rotation to complete")
}
}
func TestRunnerHandlesWebSocketActiveConfigMessage(t *testing.T) {
syncService := &fakeSyncService{}
runner := &Runner{SyncService: syncService}
payload, err := json.Marshal(protocol.ActiveConfigMeta{
Version: "20260529-001",
Checksum: "checksum-ws",
})
if err != nil {
t.Fatalf("marshal active config: %v", err)
}
changed, err := runner.handleWebSocketMessage(context.Background(), protocol.WSMessage{
Type: protocol.WSMessageTypeActiveConfig,
Payload: payload,
}, &fakeWebSocketConnection{})
if err != nil {
t.Fatalf("handle websocket active config: %v", err)
}
if changed {
t.Fatal("active config message should not change heartbeat interval")
}
if syncService.syncOnceCalls != 1 {
t.Fatalf("expected one sync call, got %d", syncService.syncOnceCalls)
}
if syncService.lastTarget == nil || syncService.lastTarget.Version != "20260529-001" || syncService.lastTarget.Checksum != "checksum-ws" {
t.Fatalf("unexpected sync target: %+v", syncService.lastTarget)
}
}
func TestRunnerHandlesWebSocketSettingsDisabled(t *testing.T) {
runner := &Runner{
Config: &config.Config{
HeartbeatInterval: config.MillisecondDuration(10 * time.Second),
},
websocketUpgradeEnabled: true,
}
payload, err := json.Marshal(protocol.AgentSettings{
HeartbeatInterval: 15000,
WebsocketUpgradeEnabled: false,
})
if err != nil {
t.Fatalf("marshal settings: %v", err)
}
changed, err := runner.handleWebSocketMessage(context.Background(), protocol.WSMessage{
Type: protocol.WSMessageTypeSettings,
Payload: payload,
}, &fakeWebSocketConnection{})
if err == nil {
t.Fatal("expected disabled websocket setting to request fallback")
}
if !changed {
t.Fatal("expected heartbeat interval change to be reported")
}
if runner.websocketUpgradeEnabled {
t.Fatal("expected websocket upgrade to be disabled")
}
}
func TestWebSocketBackoffSequence(t *testing.T) {
backoff := newWebSocketBackoff()
expected := []time.Duration{
time.Second,
2 * time.Second,
5 * time.Second,
10 * time.Second,
30 * time.Second,
30 * time.Second,
}
for _, want := range expected {
if got := backoff.Next(); got != want {
t.Fatalf("unexpected backoff: got %s want %s", got, want)
}
}
backoff.Reset()
if got := backoff.Next(); got != time.Second {
t.Fatalf("expected reset backoff to return 1s, got %s", got)
}
}
+201 -46
View File
@@ -1,27 +1,40 @@
package config
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"openflare/utils/geoip"
"openflare/utils/geoip/iputil"
"os"
pathpkg "path"
"path/filepath"
"strconv"
"strings"
"time"
)
const (
defaultDockerMainConfigRelativePath = "etc/nginx/nginx.conf"
defaultDockerRouteConfigRelativePath = "etc/nginx/conf.d/openflare_routes.conf"
defaultMainConfigRelativePath = "etc/nginx/nginx.conf"
defaultRouteConfigRelativePath = "etc/nginx/conf.d/openflare_routes.conf"
defaultCertDirRelativePath = "etc/nginx/certs"
defaultLuaDirRelativePath = "etc/nginx/lua"
defaultDockerStateRelativePath = "var/lib/openflare/agent-state.json"
defaultRuntimeConfigDirRelativePath = "etc/openflare"
defaultMMDBRelativePath = "etc/openflare/GeoLite2-Country.mmdb"
defaultAccessLogRelativePath = "var/log/openflare/access.log"
defaultStateRelativePath = "var/lib/openflare/agent-state.json"
defaultObservabilityBufferRelativePath = "var/lib/openflare/observability-buffer.json"
defaultDockerOpenRestyCertDir = "/etc/nginx/openflare-certs"
defaultDockerOpenRestyLuaDir = "/etc/nginx/openflare-lua"
defaultOpenRestyObservabilityPort = 18081
defaultObservabilityReplayMinutes = 15
defaultMMDBUpdateInterval = 24 * time.Hour
defaultMMDBDownloadURL = "https://raw.githubusercontent.com/Loyalsoldier/geoip/release/GeoLite2-Country.mmdb"
)
var (
lookupOutboundIP = geoip.GetOutboundIP
lookupLocalIP = detectLocalNodeIP
)
type Config struct {
@@ -34,16 +47,21 @@ type Config struct {
NginxVersion string `json:"-"`
OpenrestyPath string `json:"openresty_path"`
OpenrestyResolvers []string `json:"openresty_resolvers,omitempty"`
OpenrestyContainerName string `json:"openresty_container_name"`
OpenrestyDockerImage string `json:"openresty_docker_image"`
DockerBinary string `json:"docker_binary"`
OpenrestyContainerName string `json:"openresty_container_name,omitempty"`
OpenrestyDockerImage string `json:"openresty_docker_image,omitempty"`
DockerBinary string `json:"docker_binary,omitempty"`
DataDir string `json:"data_dir"`
MainConfigPath string `json:"main_config_path"`
RouteConfigPath string `json:"route_config_path"`
AccessLogPath string `json:"access_log_path"`
CertDir string `json:"cert_dir"`
OpenrestyCertDir string `json:"openresty_cert_dir"`
LuaDir string `json:"lua_dir"`
OpenrestyLuaDir string `json:"openresty_lua_dir"`
RuntimeConfigDir string `json:"runtime_config_dir"`
MMDBPath string `json:"mmdb_path"`
MMDBUpdateInterval MillisecondDuration `json:"mmdb_update_interval"`
MMDBDownloadURL string `json:"mmdb_download_url"`
OpenrestyObservabilityPort int `json:"openresty_observability_port"`
ObservabilityBufferPath string `json:"observability_buffer_path"`
ObservabilityReplayMinutes int `json:"observability_replay_minutes"`
@@ -67,10 +85,15 @@ type configFile struct {
DataDir string `json:"data_dir"`
MainConfigPath string `json:"main_config_path"`
RouteConfigPath string `json:"route_config_path"`
AccessLogPath string `json:"access_log_path"`
CertDir string `json:"cert_dir"`
OpenrestyCertDir string `json:"openresty_cert_dir"`
LuaDir string `json:"lua_dir"`
OpenrestyLuaDir string `json:"openresty_lua_dir"`
RuntimeConfigDir string `json:"runtime_config_dir"`
MMDBPath string `json:"mmdb_path"`
MMDBUpdateInterval MillisecondDuration `json:"mmdb_update_interval"`
MMDBDownloadURL string `json:"mmdb_download_url"`
OpenrestyObservabilityPort int `json:"openresty_observability_port"`
ObservabilityBufferPath string `json:"observability_buffer_path"`
ObservabilityReplayMinutes int `json:"observability_replay_minutes"`
@@ -81,11 +104,16 @@ type configFile struct {
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
if err != nil && !os.IsNotExist(err) {
return nil, err
}
file := &configFile{}
if err = json.Unmarshal(data, file); err != nil {
if err == nil {
if err = json.Unmarshal(data, file); err != nil {
return nil, err
}
}
if err != nil && !hasEnvConfig() {
return nil, err
}
cfg := &Config{
@@ -102,10 +130,15 @@ func Load(path string) (*Config, error) {
DataDir: file.DataDir,
MainConfigPath: file.MainConfigPath,
RouteConfigPath: file.RouteConfigPath,
AccessLogPath: file.AccessLogPath,
CertDir: file.CertDir,
OpenrestyCertDir: file.OpenrestyCertDir,
LuaDir: file.LuaDir,
OpenrestyLuaDir: file.OpenrestyLuaDir,
RuntimeConfigDir: file.RuntimeConfigDir,
MMDBPath: file.MMDBPath,
MMDBUpdateInterval: file.MMDBUpdateInterval,
MMDBDownloadURL: file.MMDBDownloadURL,
OpenrestyObservabilityPort: file.OpenrestyObservabilityPort,
ObservabilityBufferPath: file.ObservabilityBufferPath,
ObservabilityReplayMinutes: file.ObservabilityReplayMinutes,
@@ -114,6 +147,7 @@ func Load(path string) (*Config, error) {
RequestTimeout: file.RequestTimeout,
}
cfg.configPath = path
applyEnvOverrides(cfg)
applyDefaults(cfg, filepath.Dir(path))
if err = validate(cfg); err != nil {
return nil, err
@@ -125,14 +159,8 @@ func applyDefaults(cfg *Config, baseDir string) {
baseDir = filepath.Clean(baseDir)
cfg.AgentVersion = AgentVersion
cfg.OpenrestyResolvers = normalizeResolverList(cfg.OpenrestyResolvers)
if cfg.OpenrestyContainerName == "" {
cfg.OpenrestyContainerName = "openflare-openresty"
}
if cfg.OpenrestyDockerImage == "" {
cfg.OpenrestyDockerImage = "openresty/openresty:alpine"
}
if cfg.DockerBinary == "" {
cfg.DockerBinary = "docker"
if cfg.OpenrestyPath == "" {
cfg.OpenrestyPath = "openresty"
}
if cfg.DataDir == "" {
cfg.DataDir = filepath.Join(baseDir, "data")
@@ -143,40 +171,41 @@ func applyDefaults(cfg *Config, baseDir string) {
if cfg.NodeIP == "" {
cfg.NodeIP = detectNodeIP()
}
if cfg.OpenrestyPath == "" {
cfg.MainConfigPath = joinManagedPath(cfg.DataDir, defaultDockerMainConfigRelativePath)
cfg.RouteConfigPath = joinManagedPath(cfg.DataDir, defaultDockerRouteConfigRelativePath)
cfg.StatePath = joinManagedPath(cfg.DataDir, defaultDockerStateRelativePath)
} else {
if cfg.MainConfigPath == "" {
cfg.MainConfigPath = joinManagedPath(cfg.DataDir, defaultDockerMainConfigRelativePath)
}
if cfg.RouteConfigPath == "" {
cfg.RouteConfigPath = joinManagedPath(cfg.DataDir, defaultDockerRouteConfigRelativePath)
}
if cfg.StatePath == "" {
cfg.StatePath = joinManagedPath(cfg.DataDir, defaultDockerStateRelativePath)
}
if cfg.MainConfigPath == "" {
cfg.MainConfigPath = joinManagedPath(cfg.DataDir, defaultMainConfigRelativePath)
}
if cfg.RouteConfigPath == "" {
cfg.RouteConfigPath = joinManagedPath(cfg.DataDir, defaultRouteConfigRelativePath)
}
if cfg.AccessLogPath == "" {
cfg.AccessLogPath = joinManagedPath(cfg.DataDir, defaultAccessLogRelativePath)
}
if cfg.StatePath == "" {
cfg.StatePath = joinManagedPath(cfg.DataDir, defaultStateRelativePath)
}
if cfg.CertDir == "" {
cfg.CertDir = joinManagedPath(cfg.DataDir, defaultCertDirRelativePath)
}
if cfg.OpenrestyCertDir == "" {
if cfg.OpenrestyPath != "" {
cfg.OpenrestyCertDir = cfg.CertDir
} else {
cfg.OpenrestyCertDir = defaultDockerOpenRestyCertDir
}
cfg.OpenrestyCertDir = cfg.CertDir
}
if cfg.LuaDir == "" {
cfg.LuaDir = joinManagedPath(cfg.DataDir, defaultLuaDirRelativePath)
}
if cfg.OpenrestyLuaDir == "" {
if cfg.OpenrestyPath != "" {
cfg.OpenrestyLuaDir = cfg.LuaDir
} else {
cfg.OpenrestyLuaDir = defaultDockerOpenRestyLuaDir
}
cfg.OpenrestyLuaDir = cfg.LuaDir
}
if cfg.RuntimeConfigDir == "" {
cfg.RuntimeConfigDir = joinManagedPath(cfg.DataDir, defaultRuntimeConfigDirRelativePath)
}
if cfg.MMDBPath == "" {
cfg.MMDBPath = joinManagedPath(cfg.DataDir, defaultMMDBRelativePath)
}
if cfg.MMDBUpdateInterval <= 0 {
cfg.MMDBUpdateInterval = MillisecondDuration(defaultMMDBUpdateInterval)
}
if cfg.MMDBDownloadURL == "" {
cfg.MMDBDownloadURL = defaultMMDBDownloadURL
}
if cfg.OpenrestyObservabilityPort <= 0 {
cfg.OpenrestyObservabilityPort = defaultOpenRestyObservabilityPort
@@ -209,6 +238,9 @@ func normalizeManagedPaths(cfg *Config) {
if usesSlashPath(cfg.RouteConfigPath) {
cfg.RouteConfigPath = filepath.ToSlash(cfg.RouteConfigPath)
}
if usesSlashPath(cfg.AccessLogPath) {
cfg.AccessLogPath = filepath.ToSlash(cfg.AccessLogPath)
}
if usesSlashPath(cfg.CertDir) {
cfg.CertDir = filepath.ToSlash(cfg.CertDir)
}
@@ -221,12 +253,97 @@ func normalizeManagedPaths(cfg *Config) {
if usesSlashPath(cfg.OpenrestyLuaDir) {
cfg.OpenrestyLuaDir = filepath.ToSlash(cfg.OpenrestyLuaDir)
}
if usesSlashPath(cfg.RuntimeConfigDir) {
cfg.RuntimeConfigDir = filepath.ToSlash(cfg.RuntimeConfigDir)
}
if usesSlashPath(cfg.StatePath) {
cfg.StatePath = filepath.ToSlash(cfg.StatePath)
}
if usesSlashPath(cfg.ObservabilityBufferPath) {
cfg.ObservabilityBufferPath = filepath.ToSlash(cfg.ObservabilityBufferPath)
}
if usesSlashPath(cfg.MMDBPath) {
cfg.MMDBPath = filepath.ToSlash(cfg.MMDBPath)
}
}
func hasEnvConfig() bool {
for _, key := range []string{
"OPENFLARE_SERVER_URL",
"OPENFLARE_AGENT_TOKEN",
"OPENFLARE_DISCOVERY_TOKEN",
"OPENFLARE_NODE_NAME",
"OPENFLARE_NODE_IP",
"OPENFLARE_DATA_DIR",
"OPENFLARE_OPENRESTY_PATH",
"OPENFLARE_HEARTBEAT_INTERVAL",
"OPENFLARE_REQUEST_TIMEOUT",
"OPENFLARE_OPENRESTY_OBSERVABILITY_PORT",
"OPENFLARE_MMDB_PATH",
"OPENFLARE_MMDB_UPDATE_INTERVAL",
"OPENFLARE_MMDB_DOWNLOAD_URL",
} {
if strings.TrimSpace(os.Getenv(key)) != "" {
return true
}
}
return false
}
func applyEnvOverrides(cfg *Config) {
if cfg == nil {
return
}
overrideString := func(key string, target *string) {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
*target = value
}
}
overrideString("OPENFLARE_SERVER_URL", &cfg.ServerURL)
overrideString("OPENFLARE_AGENT_TOKEN", &cfg.AgentToken)
overrideString("OPENFLARE_DISCOVERY_TOKEN", &cfg.DiscoveryToken)
overrideString("OPENFLARE_NODE_NAME", &cfg.NodeName)
overrideString("OPENFLARE_NODE_IP", &cfg.NodeIP)
overrideString("OPENFLARE_DATA_DIR", &cfg.DataDir)
overrideString("OPENFLARE_OPENRESTY_PATH", &cfg.OpenrestyPath)
overrideString("OPENFLARE_MMDB_PATH", &cfg.MMDBPath)
overrideString("OPENFLARE_MMDB_DOWNLOAD_URL", &cfg.MMDBDownloadURL)
if value := strings.TrimSpace(os.Getenv("OPENFLARE_HEARTBEAT_INTERVAL")); value != "" {
if duration, err := parseDurationValue(value); err == nil {
cfg.HeartbeatInterval = duration
}
}
if value := strings.TrimSpace(os.Getenv("OPENFLARE_REQUEST_TIMEOUT")); value != "" {
if duration, err := parseDurationValue(value); err == nil {
cfg.RequestTimeout = duration
}
}
if value := strings.TrimSpace(os.Getenv("OPENFLARE_MMDB_UPDATE_INTERVAL")); value != "" {
if duration, err := parseDurationValue(value); err == nil {
cfg.MMDBUpdateInterval = duration
}
}
if value := strings.TrimSpace(os.Getenv("OPENFLARE_OPENRESTY_OBSERVABILITY_PORT")); value != "" {
var port int
if _, err := fmt.Sscanf(value, "%d", &port); err == nil {
cfg.OpenrestyObservabilityPort = port
}
}
}
func parseDurationValue(value string) (MillisecondDuration, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return 0, nil
}
if parsed, err := time.ParseDuration(trimmed); err == nil {
return MillisecondDuration(parsed), nil
}
ms, err := strconv.ParseInt(trimmed, 10, 64)
if err != nil {
return 0, err
}
return MillisecondDuration(time.Duration(ms) * time.Millisecond), nil
}
func usesSlashPath(path string) bool {
@@ -259,6 +376,9 @@ func validate(cfg *Config) error {
if cfg.ObservabilityReplayMinutes <= 0 {
return errors.New("observability_replay_minutes 必须大于 0")
}
if cfg.MMDBUpdateInterval <= 0 {
return errors.New("mmdb_update_interval 必须大于 0")
}
return nil
}
@@ -327,10 +447,29 @@ func firstNonEmpty(values ...string) string {
}
func detectNodeIP() string {
if ip := detectOutboundNodeIP(); ip != "" {
return ip
}
return lookupLocalIP()
}
func detectOutboundNodeIP() string {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ip, err := lookupOutboundIP(ctx)
if err != nil || ip == nil {
return ""
}
return ip.String()
}
func detectLocalNodeIP() string {
interfaces, err := net.Interfaces()
if err != nil {
return ""
}
bestIP := ""
bestPriority := -1
for _, iface := range interfaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
@@ -344,11 +483,27 @@ func detectNodeIP() string {
if !ok || ipNet.IP == nil || ipNet.IP.IsLoopback() {
continue
}
ipv4 := ipNet.IP.To4()
if ipv4 != nil {
return ipv4.String()
ipv4 := normalizeIPv4(ipNet.IP)
priority := nodeIPPriority(ipv4)
if priority > bestPriority {
bestIP = ipv4.String()
bestPriority = priority
}
if bestPriority == 2 {
return bestIP
}
}
}
return ""
return bestIP
}
func normalizeIPv4(ip net.IP) net.IP {
if ip == nil {
return nil
}
return ip.To4()
}
func nodeIPPriority(ip net.IP) int {
return iputil.Score(ip)
}
+249 -14
View File
@@ -1,14 +1,18 @@
package config
import (
"context"
"encoding/json"
"errors"
"net"
"openflare/utils/geoip"
"os"
"path/filepath"
"testing"
"time"
)
func TestLoadDockerModeUsesManagedPaths(t *testing.T) {
func TestLoadDefaultsToManagedBinaryPaths(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "agent.json")
payload := map[string]any{
@@ -33,31 +37,34 @@ func TestLoadDockerModeUsesManagedPaths(t *testing.T) {
if cfg.DataDir != filepath.Join(dir, "data") {
t.Fatalf("unexpected data dir: %s", cfg.DataDir)
}
if cfg.MainConfigPath != filepath.Join(dir, "data", defaultDockerMainConfigRelativePath) {
if cfg.OpenrestyPath != "openresty" {
t.Fatalf("unexpected openresty path: %s", cfg.OpenrestyPath)
}
if cfg.MainConfigPath != filepath.Join(dir, "data", defaultMainConfigRelativePath) {
t.Fatalf("unexpected main config path: %s", cfg.MainConfigPath)
}
if cfg.RouteConfigPath != filepath.Join(dir, "data", defaultDockerRouteConfigRelativePath) {
if cfg.RouteConfigPath != filepath.Join(dir, "data", defaultRouteConfigRelativePath) {
t.Fatalf("unexpected route config path: %s", cfg.RouteConfigPath)
}
if cfg.AccessLogPath != filepath.Join(dir, "data", defaultAccessLogRelativePath) {
t.Fatalf("unexpected access log path: %s", cfg.AccessLogPath)
}
if cfg.CertDir != filepath.Join(dir, "data", defaultCertDirRelativePath) {
t.Fatalf("unexpected cert dir: %s", cfg.CertDir)
}
if cfg.LuaDir != filepath.Join(dir, "data", defaultLuaDirRelativePath) {
t.Fatalf("unexpected lua dir: %s", cfg.LuaDir)
}
if cfg.OpenrestyContainerName != "openflare-openresty" {
t.Fatalf("unexpected openresty container name: %s", cfg.OpenrestyContainerName)
if cfg.RuntimeConfigDir != filepath.Join(dir, "data", defaultRuntimeConfigDirRelativePath) {
t.Fatalf("unexpected runtime config dir: %s", cfg.RuntimeConfigDir)
}
if cfg.OpenrestyDockerImage != "openresty/openresty:alpine" {
t.Fatalf("unexpected openresty image: %s", cfg.OpenrestyDockerImage)
}
if cfg.OpenrestyCertDir != defaultDockerOpenRestyCertDir {
if cfg.OpenrestyCertDir != cfg.CertDir {
t.Fatalf("unexpected openresty cert dir: %s", cfg.OpenrestyCertDir)
}
if cfg.OpenrestyLuaDir != defaultDockerOpenRestyLuaDir {
if cfg.OpenrestyLuaDir != cfg.LuaDir {
t.Fatalf("unexpected openresty lua dir: %s", cfg.OpenrestyLuaDir)
}
if cfg.StatePath != filepath.Join(dir, "data", defaultDockerStateRelativePath) {
if cfg.StatePath != filepath.Join(dir, "data", defaultStateRelativePath) {
t.Fatalf("unexpected state path: %s", cfg.StatePath)
}
if cfg.ObservabilityBufferPath != filepath.Join(dir, "data", defaultObservabilityBufferRelativePath) {
@@ -154,6 +161,41 @@ func TestLoadNormalizesExplicitResolvers(t *testing.T) {
}
}
func TestLoadKeepsDeprecatedDockerFieldsForCompatibility(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "agent.json")
payload := map[string]any{
"server_url": "http://127.0.0.1:3000",
"agent_token": "token",
"node_name": "edge-01",
"node_ip": "10.0.0.8",
"openresty_container_name": "openflare-openresty",
"openresty_docker_image": "openresty/openresty:alpine",
"docker_binary": "docker",
}
data, err := json.Marshal(payload)
if err != nil {
t.Fatalf("failed to marshal config: %v", err)
}
if err = os.WriteFile(configPath, data, 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if cfg.OpenrestyContainerName != "openflare-openresty" {
t.Fatalf("unexpected container name: %s", cfg.OpenrestyContainerName)
}
if cfg.OpenrestyDockerImage != "openresty/openresty:alpine" {
t.Fatalf("unexpected image: %s", cfg.OpenrestyDockerImage)
}
if cfg.DockerBinary != "docker" {
t.Fatalf("unexpected docker binary: %s", cfg.DockerBinary)
}
}
func TestLoadUsesCustomDataDirForGeneratedFiles(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "agent.json")
@@ -177,13 +219,16 @@ func TestLoadUsesCustomDataDirForGeneratedFiles(t *testing.T) {
t.Fatalf("Load failed: %v", err)
}
if cfg.RouteConfigPath != "/srv/openflare/"+defaultDockerRouteConfigRelativePath {
if cfg.RouteConfigPath != "/srv/openflare/"+defaultRouteConfigRelativePath {
t.Fatalf("unexpected route config path: %s", cfg.RouteConfigPath)
}
if cfg.MainConfigPath != "/srv/openflare/"+defaultDockerMainConfigRelativePath {
if cfg.MainConfigPath != "/srv/openflare/"+defaultMainConfigRelativePath {
t.Fatalf("unexpected main config path: %s", cfg.MainConfigPath)
}
if cfg.StatePath != "/srv/openflare/"+defaultDockerStateRelativePath {
if cfg.AccessLogPath != "/srv/openflare/"+defaultAccessLogRelativePath {
t.Fatalf("unexpected access log path: %s", cfg.AccessLogPath)
}
if cfg.StatePath != "/srv/openflare/"+defaultStateRelativePath {
t.Fatalf("unexpected state path: %s", cfg.StatePath)
}
if cfg.ObservabilityBufferPath != "/srv/openflare/"+defaultObservabilityBufferRelativePath {
@@ -195,6 +240,141 @@ func TestLoadUsesCustomDataDirForGeneratedFiles(t *testing.T) {
if cfg.LuaDir != "/srv/openflare/"+defaultLuaDirRelativePath {
t.Fatalf("unexpected lua dir: %s", cfg.LuaDir)
}
if cfg.RuntimeConfigDir != "/srv/openflare/"+defaultRuntimeConfigDirRelativePath {
t.Fatalf("unexpected runtime config dir: %s", cfg.RuntimeConfigDir)
}
}
func TestLoadUsesEnvConfigWhenFileIsMissing(t *testing.T) {
dir := t.TempDir()
t.Setenv("OPENFLARE_SERVER_URL", "http://127.0.0.1:3000")
t.Setenv("OPENFLARE_AGENT_TOKEN", "token")
t.Setenv("OPENFLARE_NODE_NAME", "edge-env")
t.Setenv("OPENFLARE_NODE_IP", "10.0.0.9")
t.Setenv("OPENFLARE_DATA_DIR", "/srv/openflare-env")
t.Setenv("OPENFLARE_OPENRESTY_PATH", "/usr/bin/openresty")
t.Setenv("OPENFLARE_HEARTBEAT_INTERVAL", "45s")
t.Setenv("OPENFLARE_REQUEST_TIMEOUT", "2500")
t.Setenv("OPENFLARE_OPENRESTY_OBSERVABILITY_PORT", "19091")
cfg, err := Load(filepath.Join(dir, "missing-agent.json"))
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if cfg.ServerURL != "http://127.0.0.1:3000" || cfg.AgentToken != "token" {
t.Fatalf("unexpected env auth config: %#v", cfg)
}
if cfg.OpenrestyPath != "/usr/bin/openresty" {
t.Fatalf("unexpected openresty path: %s", cfg.OpenrestyPath)
}
if cfg.DataDir != "/srv/openflare-env" {
t.Fatalf("unexpected data dir: %s", cfg.DataDir)
}
if cfg.HeartbeatInterval.Duration() != 45*time.Second {
t.Fatalf("unexpected heartbeat interval: %s", cfg.HeartbeatInterval)
}
if cfg.RequestTimeout.Duration() != 2500*time.Millisecond {
t.Fatalf("unexpected request timeout: %s", cfg.RequestTimeout)
}
if cfg.OpenrestyObservabilityPort != 19091 {
t.Fatalf("unexpected observability port: %d", cfg.OpenrestyObservabilityPort)
}
}
func TestLoadDetectsOutboundIPWhenNodeIPMissing(t *testing.T) {
previousLookup := lookupOutboundIP
lookupOutboundIP = func(ctx context.Context, strategies ...geoip.OutboundIPStrategy) (net.IP, error) {
return net.ParseIP("8.8.8.8"), nil
}
defer func() {
lookupOutboundIP = previousLookup
}()
dir := t.TempDir()
configPath := filepath.Join(dir, "agent.json")
payload := map[string]any{
"server_url": "http://127.0.0.1:3000",
"agent_token": "token",
"node_name": "edge-01",
}
data, err := json.Marshal(payload)
if err != nil {
t.Fatalf("failed to marshal config: %v", err)
}
if err = os.WriteFile(configPath, data, 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if cfg.NodeIP != "8.8.8.8" {
t.Fatalf("expected outbound IP, got %s", cfg.NodeIP)
}
}
func TestLoadFallsBackToLocalIPWhenOutboundLookupFails(t *testing.T) {
previousOutboundLookup := lookupOutboundIP
previousLocalLookup := lookupLocalIP
lookupOutboundIP = func(ctx context.Context, strategies ...geoip.OutboundIPStrategy) (net.IP, error) {
return nil, errors.New("realip.cc unavailable")
}
lookupLocalIP = func() string {
return "9.9.9.9"
}
defer func() {
lookupOutboundIP = previousOutboundLookup
lookupLocalIP = previousLocalLookup
}()
dir := t.TempDir()
configPath := filepath.Join(dir, "agent.json")
payload := map[string]any{
"server_url": "http://127.0.0.1:3000",
"agent_token": "token",
"node_name": "edge-01",
}
data, err := json.Marshal(payload)
if err != nil {
t.Fatalf("failed to marshal config: %v", err)
}
if err = os.WriteFile(configPath, data, 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if cfg.NodeIP != "9.9.9.9" {
t.Fatalf("expected local fallback IP, got %s", cfg.NodeIP)
}
}
func TestLoadEnvOverridesConfigFile(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "agent.json")
if err := os.WriteFile(configPath, []byte(`{"server_url":"http://old:3000","agent_token":"old","node_name":"edge-01","node_ip":"10.0.0.8","openresty_path":"/old/openresty"}`), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
t.Setenv("OPENFLARE_SERVER_URL", "http://new:3000")
t.Setenv("OPENFLARE_AGENT_TOKEN", "new-token")
t.Setenv("OPENFLARE_OPENRESTY_PATH", "/new/openresty")
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if cfg.ServerURL != "http://new:3000" {
t.Fatalf("expected server url from env, got %s", cfg.ServerURL)
}
if cfg.AgentToken != "new-token" {
t.Fatalf("expected token from env, got %s", cfg.AgentToken)
}
if cfg.OpenrestyPath != "/new/openresty" {
t.Fatalf("expected openresty path from env, got %s", cfg.OpenrestyPath)
}
}
func TestLoadUsesMillisecondsForIntervals(t *testing.T) {
@@ -282,6 +462,15 @@ func TestSavePersistsMillisecondsAndOmitsRuntimeVersions(t *testing.T) {
if _, ok := decoded["nginx_path"]; ok {
t.Fatal("legacy nginx_path should not be persisted")
}
if _, ok := decoded["openresty_container_name"]; ok {
t.Fatal("deprecated openresty_container_name should not be persisted by default")
}
if _, ok := decoded["openresty_docker_image"]; ok {
t.Fatal("deprecated openresty_docker_image should not be persisted by default")
}
if _, ok := decoded["docker_binary"]; ok {
t.Fatal("deprecated docker_binary should not be persisted by default")
}
}
func TestInitialAuthToken(t *testing.T) {
@@ -326,3 +515,49 @@ func TestInitialAuthToken(t *testing.T) {
})
}
}
func TestNodeIPPriority(t *testing.T) {
tests := []struct {
name string
ip string
expected int
}{
{
name: "public ipv4 preferred",
ip: "8.8.8.8",
expected: 2,
},
{
name: "private ipv4 fallback",
ip: "10.0.0.8",
expected: 1,
},
{
name: "link local ignored",
ip: "169.254.1.10",
expected: -1,
},
{
name: "loopback ignored",
ip: "127.0.0.1",
expected: -1,
},
{
name: "nil ignored",
ip: "",
expected: -1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var parsed net.IP
if tt.ip != "" {
parsed = net.ParseIP(tt.ip)
}
if got := nodeIPPriority(parsed); got != tt.expected {
t.Fatalf("unexpected priority for %q: got %d want %d", tt.ip, got, tt.expected)
}
})
}
}
@@ -0,0 +1,8 @@
package geoipdata
import "embed"
//go:embed GeoLite2-Country.mmdb
var FS embed.FS
const DefaultMMDBName = "GeoLite2-Country.mmdb"
@@ -0,0 +1,67 @@
package geoipupdate
import (
"context"
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
"time"
"openflare-agent/internal/geoipdata"
"openflare/utils/geoip"
)
type Updater struct {
MMDBPath string
DownloadURL string
UpdateInterval time.Duration
}
func (u *Updater) EnsureInitialDatabase() error {
path := filepath.Clean(u.MMDBPath)
if path == "" || path == "." {
return nil
}
if _, err := os.Stat(path); err == nil {
return nil
} else if !os.IsNotExist(err) {
return fmt.Errorf("stat mmdb file failed: %w", err)
}
data, err := fs.ReadFile(geoipdata.FS, geoipdata.DefaultMMDBName)
if err != nil {
return fmt.Errorf("read embedded mmdb failed: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create mmdb directory failed: %w", err)
}
if err := os.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("write initial mmdb failed: %w", err)
}
slog.Info("initialized GeoIP mmdb from embedded database", "path", path, "size", len(data))
return nil
}
func (u *Updater) Run(ctx context.Context) {
if u == nil || u.MMDBPath == "" || u.UpdateInterval <= 0 {
return
}
if err := u.EnsureInitialDatabase(); err != nil {
slog.Warn("initialize GeoIP mmdb failed", "path", u.MMDBPath, "error", err)
}
ticker := time.NewTicker(u.UpdateInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := geoip.DownloadMaxMindDatabase(u.MMDBPath, u.DownloadURL); err != nil {
slog.Warn("update GeoIP mmdb failed", "path", u.MMDBPath, "error", err)
continue
}
slog.Info("GeoIP mmdb updated", "path", u.MMDBPath)
}
}
}
@@ -0,0 +1,24 @@
package geoipupdate
import (
"os"
"path/filepath"
"testing"
)
func TestEnsureInitialDatabaseCopiesEmbeddedMMDB(t *testing.T) {
tempDir := t.TempDir()
path := filepath.Join(tempDir, "GeoLite2-Country.mmdb")
updater := &Updater{MMDBPath: path}
if err := updater.EnsureInitialDatabase(); err != nil {
t.Fatalf("EnsureInitialDatabase failed: %v", err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatalf("expected mmdb to exist: %v", err)
}
if info.Size() == 0 {
t.Fatal("expected copied mmdb to be non-empty")
}
}
+4 -108
View File
@@ -1,76 +1,20 @@
package logging
import (
"context"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
)
type customTextHandler struct {
writer io.Writer
level slog.Level
attrs []slog.Attr
groups []string
}
func Setup() {
handler := &customTextHandler{
writer: os.Stdout,
level: parseLevel(os.Getenv("LOG_LEVEL")),
opts := &slog.HandlerOptions{
AddSource: true,
Level: parseLevel(os.Getenv("LOG_LEVEL")),
}
handler := slog.NewTextHandler(os.Stdout, opts)
slog.SetDefault(slog.New(handler))
}
func (h *customTextHandler) Enabled(_ context.Context, level slog.Level) bool {
return level >= h.level
}
func (h *customTextHandler) Handle(_ context.Context, record slog.Record) error {
var builder strings.Builder
builder.WriteString(record.Time.Format("2006-01-02 15:04:05.000"))
builder.WriteString(" | ")
builder.WriteString(fmt.Sprintf("%-8s", levelLabel(record.Level)))
builder.WriteString(" | ")
builder.WriteString(sourceLocation(record.PC))
builder.WriteString(" - ")
builder.WriteString(record.Message)
attrs := make([]slog.Attr, 0, len(h.attrs)+record.NumAttrs())
attrs = append(attrs, h.attrs...)
record.Attrs(func(attr slog.Attr) bool {
attrs = append(attrs, attr)
return true
})
if len(attrs) > 0 {
builder.WriteString(" | ")
builder.WriteString(formatAttrs(h.groups, attrs))
}
builder.WriteByte('\n')
_, err := io.WriteString(h.writer, builder.String())
return err
}
func (h *customTextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
cloned := *h
cloned.attrs = append(slices.Clone(h.attrs), attrs...)
return &cloned
}
func (h *customTextHandler) WithGroup(name string) slog.Handler {
if strings.TrimSpace(name) == "" {
return h
}
cloned := *h
cloned.groups = append(slices.Clone(h.groups), name)
return &cloned
}
func parseLevel(value string) slog.Level {
switch strings.ToLower(strings.TrimSpace(value)) {
case "debug":
@@ -83,51 +27,3 @@ func parseLevel(value string) slog.Level {
return slog.LevelInfo
}
}
func levelLabel(level slog.Level) string {
switch {
case level <= slog.LevelDebug:
return "DEBUG"
case level < slog.LevelWarn:
return "INFO"
case level < slog.LevelError:
return "WARNING"
default:
return "ERROR"
}
}
func sourceLocation(pc uintptr) string {
if pc == 0 {
return "unknown:unknown:0"
}
frame, _ := runtime.CallersFrames([]uintptr{pc}).Next()
fileName := strings.TrimSuffix(filepath.Base(frame.File), filepath.Ext(frame.File))
if fileName == "" {
fileName = "unknown"
}
functionName := "unknown"
if frame.Function != "" {
parts := strings.Split(frame.Function, "/")
functionName = parts[len(parts)-1]
if dot := strings.LastIndex(functionName, "."); dot >= 0 && dot < len(functionName)-1 {
functionName = functionName[dot+1:]
}
}
return fmt.Sprintf("%s:%s:%d", fileName, functionName, frame.Line)
}
func formatAttrs(groups []string, attrs []slog.Attr) string {
parts := make([]string, 0, len(attrs))
for _, attr := range attrs {
key := attr.Key
if key == "" {
continue
}
if len(groups) > 0 {
key = strings.Join(append(slices.Clone(groups), key), ".")
}
parts = append(parts, fmt.Sprintf("%s=%v", key, attr.Value.Any()))
}
return strings.Join(parts, " ")
}
+388 -368
View File
@@ -9,6 +9,7 @@ import (
"io/fs"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"os/exec"
@@ -16,6 +17,7 @@ import (
"regexp"
"sort"
"strings"
"time"
"openflare-agent/internal/protocol"
)
@@ -24,14 +26,11 @@ const CertDirPlaceholder = "__OPENFLARE_CERT_DIR__"
const RouteConfigPlaceholder = "__OPENFLARE_ROUTE_CONFIG__"
const AccessLogPlaceholder = "__OPENFLARE_ACCESS_LOG__"
const LuaDirPlaceholder = "__OPENFLARE_LUA_DIR__"
const RuntimeConfigDirPlaceholder = "__OPENFLARE_RUNTIME_CONFIG_DIR__"
const ObservabilityListenPlaceholder = "__OPENFLARE_OBSERVABILITY_LISTEN__"
const ObservabilityPortPlaceholder = "__OPENFLARE_OBSERVABILITY_PORT__"
const ResolverDirectivePlaceholder = "__OPENFLARE_RESOLVER_DIRECTIVE__"
const DockerMainConfigPath = "/usr/local/openresty/nginx/conf/nginx.conf"
const DockerRouteConfigPath = "/etc/nginx/conf.d/openflare_routes.conf"
const DockerAccessLogPath = "/etc/nginx/conf.d/openflare_access.log"
const dockerRuntimeCommand = "openresty"
const PowStaticDirPlaceholder = "__OPENFLARE_POW_STATIC_DIR__"
type Executor interface {
Test(ctx context.Context) error
@@ -48,19 +47,40 @@ type CommandRunner interface {
type OSCommandRunner struct{}
func (r *OSCommandRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
slog.Debug("OSCommandRunner starting command", "name", name, "args", args)
tmpFile, err := os.CreateTemp("", "openflare-cmd-*")
if err != nil {
slog.Error("OSCommandRunner failed to create temp file, falling back to CombinedOutput", "error", err)
cmd := exec.CommandContext(ctx, name, args...)
output, outErr := cmd.CombinedOutput()
slog.Debug("OSCommandRunner finished CombinedOutput", "name", name, "error", outErr)
return output, outErr
}
defer os.Remove(tmpFile.Name())
cmd := exec.CommandContext(ctx, name, args...)
output, err := cmd.CombinedOutput()
return output, err
cmd.Stdout = tmpFile
cmd.Stderr = tmpFile
slog.Debug("OSCommandRunner executing cmd.Run()", "name", name)
runErr := cmd.Run()
slog.Debug("OSCommandRunner cmd.Run() returned", "name", name, "error", runErr)
tmpFile.Close()
output, _ := os.ReadFile(tmpFile.Name())
slog.Debug("OSCommandRunner command complete", "name", name, "output_len", len(output))
return output, runErr
}
type PathExecutor struct {
Path string
Runner CommandRunner
Path string
ConfigPath string
Runner CommandRunner
}
func (e *PathExecutor) Test(ctx context.Context) error {
slog.Debug("running openresty test with binary", "path", e.Path)
output, err := e.Runner.Run(ctx, e.Path, "-t")
slog.Debug("running openresty test with binary", "path", e.Path, "config", e.ConfigPath)
output, err := e.Runner.Run(ctx, e.Path, "-t", "-c", e.ConfigPath)
if err != nil {
return fmt.Errorf("openresty -t failed: %w: %s", err, string(output))
}
@@ -69,9 +89,17 @@ func (e *PathExecutor) Test(ctx context.Context) error {
}
func (e *PathExecutor) Reload(ctx context.Context) error {
slog.Debug("running openresty reload with binary", "path", e.Path)
output, err := e.Runner.Run(ctx, e.Path, "-s", "reload")
slog.Debug("running openresty reload with binary", "path", e.Path, "config", e.ConfigPath)
output, err := e.Runner.Run(ctx, e.Path, "-s", "reload", "-c", e.ConfigPath)
if err != nil {
if isOpenrestyNotRunningError(string(output)) {
slog.Warn("openresty reload reported runtime is not running, starting binary", "path", e.Path)
startOutput, startErr := e.Runner.Run(ctx, e.Path, "-c", e.ConfigPath)
if startErr != nil {
return fmt.Errorf("openresty reload failed: %w: %s; start failed: %v: %s", err, string(output), startErr, string(startOutput))
}
return nil
}
return fmt.Errorf("openresty reload failed: %w: %s", err, string(output))
}
slog.Debug("openresty reload succeeded with binary", "path", e.Path)
@@ -79,7 +107,10 @@ func (e *PathExecutor) Reload(ctx context.Context) error {
}
func (e *PathExecutor) EnsureRuntime(ctx context.Context, recreate bool) error {
return nil
if err := e.Test(ctx); err != nil {
return err
}
return e.Reload(ctx)
}
func (e *PathExecutor) CheckHealth(ctx context.Context) error {
@@ -87,15 +118,15 @@ func (e *PathExecutor) CheckHealth(ctx context.Context) error {
}
func (e *PathExecutor) Restart(ctx context.Context) error {
slog.Info("restarting openresty with binary", "path", e.Path)
output, err := e.Runner.Run(ctx, e.Path, "-s", "quit")
slog.Info("restarting openresty with binary", "path", e.Path, "config", e.ConfigPath)
output, err := e.Runner.Run(ctx, e.Path, "-s", "quit", "-c", e.ConfigPath)
if err != nil {
text := string(output)
if !isIgnorableOpenrestyStopError(text) {
return fmt.Errorf("openresty stop failed: %w: %s", err, text)
}
}
output, err = e.Runner.Run(ctx, e.Path)
output, err = e.Runner.Run(ctx, e.Path, "-c", e.ConfigPath)
if err != nil {
return fmt.Errorf("openresty start failed: %w: %s", err, string(output))
}
@@ -103,257 +134,15 @@ func (e *PathExecutor) Restart(ctx context.Context) error {
return nil
}
type DockerExecutor struct {
DockerBinary string
ContainerName string
Image string
MainConfigPath string
RouteConfigDir string
CertDir string
NginxCertDir string
LuaDir string
NginxLuaDir string
OpenrestyObservabilityPort int
Runner CommandRunner
}
func (e *DockerExecutor) Test(ctx context.Context) error {
slog.Debug("running docker openresty test", "container", e.ContainerName, "image", e.Image)
if err := e.validateMountSources(); err != nil {
return err
}
output, err := e.runEphemeralRuntimeCommand(ctx, "-t")
if err != nil {
return fmt.Errorf("docker %s -t failed: %w: %s", dockerRuntimeCommand, err, string(output))
}
slog.Debug("docker openresty test succeeded", "container", e.ContainerName, "runtime", dockerRuntimeCommand)
return nil
}
func (e *DockerExecutor) Reload(ctx context.Context) error {
if err := e.validateMountSources(); err != nil {
return err
}
output, err := e.Runner.Run(ctx, e.DockerBinary, "inspect", "-f", "{{.State.Running}}", e.ContainerName)
if err != nil || strings.TrimSpace(string(output)) != "true" {
return e.EnsureRuntime(ctx, false)
}
output, err = e.Runner.Run(ctx, e.DockerBinary, "exec", e.ContainerName, dockerRuntimeCommand, "-s", "reload")
if err != nil {
if e.shouldRecreateAfterReloadFailure(string(output)) {
slog.Warn("docker openresty reload failed due to missing mounted files, recreating container", "container", e.ContainerName)
if recreateErr := e.EnsureRuntime(ctx, true); recreateErr != nil {
return fmt.Errorf("docker exec %s reload failed: %w: %s; recreate failed: %v", dockerRuntimeCommand, err, string(output), recreateErr)
}
return nil
}
return fmt.Errorf("docker exec %s reload failed: %w: %s", dockerRuntimeCommand, err, string(output))
}
return nil
}
func (e *DockerExecutor) EnsureRuntime(ctx context.Context, recreate bool) error {
slog.Info("ensuring docker openresty runtime", "container", e.ContainerName, "recreate", recreate)
output, err := e.Runner.Run(ctx, e.DockerBinary, "inspect", "-f", "{{.State.Running}}", e.ContainerName)
if err == nil {
if recreate {
if err := e.removeContainer(ctx); err != nil {
return err
}
return e.runContainer(ctx)
}
if strings.TrimSpace(string(output)) == "true" {
slog.Debug("docker openresty runtime already healthy", "container", e.ContainerName)
return nil
}
if err := e.removeContainer(ctx); err != nil {
return err
}
return e.runContainer(ctx)
}
return e.runContainer(ctx)
}
func (e *DockerExecutor) CheckHealth(ctx context.Context) error {
slog.Debug("checking docker openresty runtime health", "container", e.ContainerName)
output, err := e.Runner.Run(ctx, e.DockerBinary, "inspect", "-f", "{{.State.Running}}", e.ContainerName)
if err != nil {
return fmt.Errorf("docker inspect openresty failed: %w: %s", err, string(output))
}
if strings.TrimSpace(string(output)) != "true" {
return e.containerNotRunningError(ctx)
}
return nil
}
func (e *DockerExecutor) Restart(ctx context.Context) error {
return e.EnsureRuntime(ctx, true)
}
func (e *DockerExecutor) removeContainer(ctx context.Context) error {
slog.Info("removing docker openresty container", "container", e.ContainerName)
output, err := e.Runner.Run(ctx, e.DockerBinary, "rm", "-f", e.ContainerName)
if err != nil {
text := string(output)
if strings.Contains(text, "No such container") {
return nil
}
return fmt.Errorf("docker rm openresty failed: %w: %s", err, text)
}
slog.Info("docker openresty container removed", "container", e.ContainerName)
return nil
}
func (e *DockerExecutor) runContainer(ctx context.Context) error {
slog.Info("starting docker openresty container", "container", e.ContainerName, "image", e.Image)
if err := e.validateMountSources(); err != nil {
return err
}
runArgs := []string{
"run", "-d",
"--name", e.ContainerName,
"-p", "80:80",
"-p", "443:443",
"-p", fmt.Sprintf("127.0.0.1:%d:%d", e.OpenrestyObservabilityPort, e.OpenrestyObservabilityPort),
"-v", fmt.Sprintf("%s:%s", e.MainConfigPath, DockerMainConfigPath),
"-v", fmt.Sprintf("%s:/etc/nginx/conf.d", e.RouteConfigDir),
"-v", fmt.Sprintf("%s:%s", e.CertDir, e.NginxCertDir),
"-v", fmt.Sprintf("%s:%s", e.LuaDir, e.NginxLuaDir),
e.Image,
}
runOutput, runErr := e.Runner.Run(ctx, e.DockerBinary, runArgs...)
if runErr != nil {
return fmt.Errorf("docker run openresty failed: %w: %s", runErr, string(runOutput))
}
if err := e.CheckHealth(ctx); err != nil {
return err
}
slog.Info("docker openresty container started", "container", e.ContainerName)
return nil
}
func (e *DockerExecutor) validateMountSources() error {
if err := ensureRegularFile(e.MainConfigPath, "openresty main config"); err != nil {
return err
}
if err := ensureDirectory(e.RouteConfigDir, "openresty route config dir"); err != nil {
return err
}
if err := ensureDirectory(e.CertDir, "openresty cert dir"); err != nil {
return err
}
if err := ensureDirectory(e.LuaDir, "openresty lua dir"); err != nil {
return err
}
return nil
}
func (e *DockerExecutor) shouldRecreateAfterReloadFailure(output string) bool {
text := strings.ToLower(strings.TrimSpace(output))
if text == "" {
return false
}
if !strings.Contains(text, "no such file") && !strings.Contains(text, "cannot load certificate") {
return false
}
paths := []string{
strings.ToLower(e.NginxCertDir),
strings.ToLower(e.NginxLuaDir),
strings.ToLower(DockerMainConfigPath),
strings.ToLower("/etc/nginx/conf.d"),
}
for _, path := range paths {
if strings.TrimSpace(path) != "" && strings.Contains(text, path) {
return true
}
}
return false
}
func (e *DockerExecutor) containerNotRunningError(ctx context.Context) error {
inspectSummary := ""
inspectOutput, inspectErr := e.Runner.Run(ctx, e.DockerBinary, "inspect", "-f", "status={{.State.Status}} exit_code={{.State.ExitCode}} error={{printf \"%q\" .State.Error}} oom_killed={{.State.OOMKilled}} finished_at={{.State.FinishedAt}}", e.ContainerName)
if inspectErr == nil {
inspectSummary = strings.TrimSpace(string(inspectOutput))
}
logTail := ""
logOutput, logErr := e.Runner.Run(ctx, e.DockerBinary, "logs", "--tail", "50", e.ContainerName)
if logErr == nil {
logTail = strings.TrimSpace(string(logOutput))
}
message := "docker openresty container is not running"
if inspectSummary != "" {
message += ": " + inspectSummary
}
if logTail != "" {
message += "; recent logs: " + compactDiagnosticText(logTail)
}
return errors.New(message)
}
func compactDiagnosticText(text string) string {
trimmed := strings.TrimSpace(text)
if trimmed == "" {
return ""
}
lines := strings.Split(trimmed, "\n")
if len(lines) > 8 {
lines = lines[len(lines)-8:]
}
joined := strings.Join(lines, " | ")
joined = strings.Join(strings.Fields(joined), " ")
if len(joined) > 800 {
return joined[len(joined)-800:]
}
return joined
}
func ensureRegularFile(path string, label string) error {
cleanPath := strings.TrimSpace(path)
if cleanPath == "" {
return fmt.Errorf("%s path is empty", label)
}
info, err := os.Stat(cleanPath)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("%s %q does not exist; run a config apply first so Docker does not create a directory mount source", label, cleanPath)
}
return fmt.Errorf("stat %s %q failed: %w", label, cleanPath, err)
}
if info.IsDir() {
return fmt.Errorf("%s %q is a directory; expected a file for Docker bind mount", label, cleanPath)
}
return nil
}
func ensureDirectory(path string, label string) error {
cleanPath := strings.TrimSpace(path)
if cleanPath == "" {
return fmt.Errorf("%s path is empty", label)
}
info, err := os.Stat(cleanPath)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("%s %q does not exist; expected a directory for Docker bind mount", label, cleanPath)
}
return fmt.Errorf("stat %s %q failed: %w", label, cleanPath, err)
}
if !info.IsDir() {
return fmt.Errorf("%s %q is not a directory; expected a directory for Docker bind mount", label, cleanPath)
}
return nil
}
type Manager struct {
MainConfigPath string
RouteConfigPath string
RuntimeRouteConfigPath string
AccessLogPath string
CertDir string
NginxCertDir string
LuaDir string
NginxLuaDir string
RuntimeConfigDir string
OpenrestyObservabilityListen string
OpenrestyObservabilityPort int
OpenrestyResolverDirective string
@@ -368,6 +157,38 @@ const (
ApplyStatusFatal ApplyStatus = "fatal"
)
const safeDefaultFallbackMainConfig = `# This file is generated by OpenFlare safe default fallback.
worker_processes auto;
pid logs/nginx.pid;
events {
worker_connections 1024;
}
http {
default_type text/plain;
server {
listen 80 default_server;
server_name _;
return 503 "OpenFlare: No Valid Configuration\n";
}
%s
}
`
const safeDefaultFallbackObservabilityServerBlock = `
server {
listen %s;
server_name openflare-observability;
access_log off;
location = /openflare/stub_status {
stub_status;
}
}
`
type ApplyOutcome struct {
Status ApplyStatus
Message string
@@ -396,6 +217,15 @@ func (m *Manager) writeTargetFiles(mainConfig string, routeConfig string, suppor
if err := m.writeCertFiles(supportFiles); err != nil {
return err
}
if err := m.writePowConfig(supportFiles); err != nil {
return err
}
if err := m.writeWAFConfig(supportFiles); err != nil {
return err
}
if err := m.ensureMimeTypes(); err != nil {
return err
}
if strings.TrimSpace(m.OpenrestyResolverDirective) == "" && strings.Contains(routeConfig, "set $openflare_upstream ") {
slog.Warn("runtime-resolved hostname upstreams detected without available resolvers; hostname origin requests may fail until resolvers are configured")
}
@@ -414,8 +244,8 @@ func (m *Manager) activateConfig(ctx context.Context) error {
if m.Executor == nil {
return errors.New("executor 未配置")
}
if _, ok := m.Executor.(*DockerExecutor); ok {
return m.Executor.EnsureRuntime(ctx, true)
if err := m.Executor.Test(ctx); err != nil {
return err
}
return m.Executor.Reload(ctx)
}
@@ -426,7 +256,18 @@ func (m *Manager) rollbackAfterFailedApply(ctx context.Context, backup *backupSt
return fatalApplyOutcome(fmt.Errorf("restore openresty backup failed after apply error %v: %w", applyErr, err))
}
if err := m.activateConfig(ctx); err != nil {
return fatalApplyOutcome(fmt.Errorf("apply failed: %v; rollback recovery failed: %w", applyErr, err))
if backup != nil && backup.MainExisted {
return fatalApplyOutcome(fmt.Errorf("apply failed: %v; rollback recovery failed: %w", applyErr, err))
}
if fallbackErr := m.EnsureSafeFallbackRuntime(ctx, fmt.Sprintf("apply failed: %v; rollback recovery failed: %v", applyErr, err)); fallbackErr != nil {
return fatalApplyOutcome(fmt.Errorf("apply failed: %v; rollback recovery failed: %w; fallback recovery failed: %v", applyErr, err, fallbackErr))
}
message := fmt.Sprintf("apply failed, but fallback runtime started: %v; rollback recovery failed: %v", applyErr, err)
slog.Warn("openresty apply recovered with safe default fallback", "message", message)
return ApplyOutcome{
Status: ApplyStatusWarning,
Message: message,
}
}
message := fmt.Sprintf("apply failed, rolled back to previous config: %v", applyErr)
slog.Warn("openresty apply rolled back successfully", "message", message)
@@ -450,8 +291,15 @@ func (m *Manager) EnsureLuaAssets() error {
if strings.TrimSpace(m.LuaDir) == "" {
return nil
}
files := make([]managedFile, 0, len(ManagedObservabilityLuaFiles()))
for _, file := range ManagedObservabilityLuaFiles() {
allSupportFiles := append(ManagedObservabilityLuaFiles(), m.managedPowLuaFiles()...)
allSupportFiles = append(allSupportFiles, m.managedWAFLuaFiles()...)
powStaticFiles, err := ManagedPowStaticFiles()
if err != nil {
return fmt.Errorf("load pow static files: %w", err)
}
allSupportFiles = append(allSupportFiles, powStaticFiles...)
files := make([]managedFile, 0, len(allSupportFiles))
for _, file := range allSupportFiles {
targetPath, err := luaFileTargetPath(m.LuaDir, file.Path)
if err != nil {
return err
@@ -477,11 +325,38 @@ func (m *Manager) EnsureRuntime(ctx context.Context, recreate bool) error {
return m.Executor.EnsureRuntime(ctx, recreate)
}
func (m *Manager) EnsureSafeFallbackRuntime(ctx context.Context, reason string) error {
if m.Executor == nil {
return errors.New("executor 未配置")
}
trimmedReason := strings.TrimSpace(reason)
if trimmedReason == "" {
trimmedReason = "no valid local openresty config is available"
}
slog.Warn("starting openresty safe default fallback runtime", "reason", trimmedReason)
if err := m.writeSafeDefaultFallbackFiles(); err != nil {
return fmt.Errorf("write safe default fallback config failed: %w", err)
}
if err := m.activateConfig(ctx); err != nil {
return fmt.Errorf("activate safe default fallback runtime failed: %w", err)
}
slog.Warn("openresty safe default fallback runtime started", "main_config", m.MainConfigPath, "route_config", m.RouteConfigPath)
return nil
}
func (m *Manager) CheckHealth(ctx context.Context) error {
if m.Executor == nil {
return errors.New("executor 未配置")
}
return m.Executor.CheckHealth(ctx)
if m.MainConfigPath != "" {
if _, err := os.Stat(m.MainConfigPath); os.IsNotExist(err) {
return errors.New("openresty config not exists: waiting for initial sync")
}
}
if m.OpenrestyObservabilityPort <= 0 {
return m.Executor.CheckHealth(ctx)
}
return m.checkStubStatus(ctx)
}
func (m *Manager) Restart(ctx context.Context) error {
@@ -536,7 +411,11 @@ func (m *Manager) CurrentChecksum() (string, error) {
if m.NginxCertDir != "" {
normalizedRoute = strings.ReplaceAll(normalizedRoute, m.NginxCertDir, CertDirPlaceholder)
}
files, err := m.readCertFiles()
if luaDir := m.luaRuntimePath(); luaDir != "" {
normalizedRoute = strings.ReplaceAll(normalizedRoute, luaDir+"/pow/static", PowStaticDirPlaceholder)
normalizedRoute = strings.ReplaceAll(normalizedRoute, luaDir, LuaDirPlaceholder)
}
files, err := m.readManagedSupportFiles()
if err != nil {
return "", err
}
@@ -547,9 +426,6 @@ func (m *Manager) CurrentChecksum() (string, error) {
type ExecutorOptions struct {
NginxPath string
DockerBinary string
ContainerName string
Image string
MainConfigPath string
RouteConfigPath string
CertDir string
@@ -561,48 +437,10 @@ type ExecutorOptions struct {
func NewExecutor(options ExecutorOptions) Executor {
runner := &OSCommandRunner{}
if options.NginxPath != "" {
return &PathExecutor{
Path: options.NginxPath,
Runner: runner,
}
}
mainConfigPath := options.MainConfigPath
if mainConfigPath != "" {
if absPath, err := filepath.Abs(mainConfigPath); err == nil {
mainConfigPath = absPath
}
}
routeConfigDir := filepath.Dir(options.RouteConfigPath)
if options.RouteConfigPath != "" {
if absDir, err := filepath.Abs(routeConfigDir); err == nil {
routeConfigDir = absDir
}
}
certDir := options.CertDir
if certDir != "" {
if absDir, err := filepath.Abs(certDir); err == nil {
certDir = absDir
}
}
luaDir := options.LuaDir
if luaDir != "" {
if absDir, err := filepath.Abs(luaDir); err == nil {
luaDir = absDir
}
}
return &DockerExecutor{
DockerBinary: options.DockerBinary,
ContainerName: options.ContainerName,
Image: options.Image,
MainConfigPath: mainConfigPath,
RouteConfigDir: routeConfigDir,
CertDir: certDir,
NginxCertDir: options.NginxCertDir,
LuaDir: luaDir,
NginxLuaDir: options.NginxLuaDir,
OpenrestyObservabilityPort: options.OpenrestyObservabilityPort,
Runner: runner,
return &PathExecutor{
Path: strings.TrimSpace(options.NginxPath),
ConfigPath: strings.TrimSpace(options.MainConfigPath),
Runner: runner,
}
}
@@ -631,15 +469,7 @@ func detectVersion(ctx context.Context, options ExecutorOptions, runner CommandR
}
return version, nil
}
output, err := runDockerVersionProbe(ctx, runner, options.DockerBinary, options.Image)
if err != nil {
return "", fmt.Errorf("run docker %s -v failed: %w: %s", dockerRuntimeCommand, err, string(output))
}
version := parseNginxVersion(string(output))
if version == "" {
return "", errors.New("cannot parse runtime version from docker output")
}
return version, nil
return "", errors.New("openresty path is empty")
}
func parseNginxVersion(output string) string {
@@ -660,31 +490,14 @@ func isIgnorableOpenrestyStopError(output string) bool {
return strings.Contains(text, "invalid pid") || strings.Contains(text, "no such process")
}
func (e *DockerExecutor) runEphemeralRuntimeCommand(ctx context.Context, args ...string) ([]byte, error) {
return e.runEphemeralRuntimeCommandWithBinary(ctx, dockerRuntimeCommand, args...)
}
func (e *DockerExecutor) runEphemeralRuntimeCommandWithBinary(ctx context.Context, runtimeBinary string, args ...string) ([]byte, error) {
runtimeArgs := []string{
"run",
"--rm",
"-v",
fmt.Sprintf("%s:%s", e.MainConfigPath, DockerMainConfigPath),
"-v",
fmt.Sprintf("%s:/etc/nginx/conf.d", e.RouteConfigDir),
"-v",
fmt.Sprintf("%s:%s", e.CertDir, e.NginxCertDir),
"-v",
fmt.Sprintf("%s:%s", e.LuaDir, e.NginxLuaDir),
e.Image,
runtimeBinary,
func isOpenrestyNotRunningError(output string) bool {
text := strings.ToLower(strings.TrimSpace(output))
if text == "" {
return false
}
runtimeArgs = append(runtimeArgs, args...)
return e.Runner.Run(ctx, e.DockerBinary, runtimeArgs...)
}
func runDockerVersionProbe(ctx context.Context, runner CommandRunner, dockerBinary string, image string) ([]byte, error) {
return runner.Run(ctx, dockerBinary, "run", "--rm", image, dockerRuntimeCommand, "-v")
return strings.Contains(text, "invalid pid") ||
strings.Contains(text, "no such process") ||
strings.Contains(text, "open()") && strings.Contains(text, "nginx.pid") && strings.Contains(text, "failed")
}
type backupState struct {
@@ -693,6 +506,7 @@ type backupState struct {
RouteExisted bool
RouteData []byte
Files []protocol.SupportFile
PowConfig *protocol.SupportFile
}
type managedFile struct {
@@ -714,11 +528,21 @@ func (m *Manager) backup() (*backupState, error) {
if err := os.MkdirAll(filepath.Dir(m.RouteConfigPath), 0o755); err != nil {
return nil, err
}
if m.AccessLogPath != "" {
if err := os.MkdirAll(filepath.Dir(m.AccessLogPath), 0o755); err != nil {
return nil, err
}
}
if m.CertDir != "" {
if err := os.MkdirAll(m.CertDir, 0o755); err != nil {
return nil, err
}
}
if m.RuntimeConfigDir != "" {
if err := os.MkdirAll(m.RuntimeConfigDir, 0o755); err != nil {
return nil, err
}
}
state := &backupState{}
mainData, err := os.ReadFile(m.MainConfigPath)
if err == nil {
@@ -739,6 +563,11 @@ func (m *Manager) backup() (*backupState, error) {
return nil, err
}
state.Files = files
powConfig, err := m.readPowConfigFile()
if err != nil {
return nil, err
}
state.PowConfig = powConfig
slog.Debug("backup captured", "main_exists", state.MainExisted, "route_exists", state.RouteExisted, "cert_files", len(state.Files))
return state, nil
}
@@ -762,10 +591,12 @@ func (m *Manager) restore(state *backupState) error {
} else if err := os.Remove(m.RouteConfigPath); err != nil && !os.IsNotExist(err) {
return err
}
if m.CertDir == "" {
return nil
if m.CertDir != "" {
if err := m.writeManagedCertFiles(state.Files); err != nil {
return err
}
}
return m.writeManagedCertFiles(state.Files)
return m.restorePowConfig(state)
}
func (m *Manager) writeCertFiles(certFiles []protocol.SupportFile) error {
@@ -775,9 +606,58 @@ func (m *Manager) writeCertFiles(certFiles []protocol.SupportFile) error {
return m.writeManagedCertFiles(certFiles)
}
func (m *Manager) writePowConfig(supportFiles []protocol.SupportFile) error {
if m.RuntimeConfigDir == "" {
return nil
}
configPath := filepath.Join(m.RuntimeConfigDir, "pow_config.json")
for _, file := range supportFiles {
if file.Path == "pow_config.json" {
if err := os.WriteFile(configPath, []byte(file.Content), 0o644); err != nil {
return fmt.Errorf("write pow_config.json: %w", err)
}
slog.Info("wrote pow config", "path", configPath, "size", len(file.Content))
return nil
}
}
if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove pow_config.json: %w", err)
}
if err := removeLegacyPowConfig(filepath.Join(m.LuaDir, "pow_config.json")); err != nil {
return err
}
if err := removeLegacyPowConfig(filepath.Join(m.CertDir, "pow_config.json")); err != nil {
return err
}
return nil
}
func (m *Manager) writeWAFConfig(supportFiles []protocol.SupportFile) error {
if m.RuntimeConfigDir == "" {
return nil
}
configPath := filepath.Join(m.RuntimeConfigDir, "waf_config.json")
for _, file := range supportFiles {
if file.Path == "waf_config.json" {
if err := os.WriteFile(configPath, []byte(file.Content), 0o644); err != nil {
return fmt.Errorf("write waf_config.json: %w", err)
}
slog.Info("wrote waf config", "path", configPath, "size", len(file.Content))
return nil
}
}
if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove waf_config.json: %w", err)
}
return nil
}
func (m *Manager) writeManagedCertFiles(certFiles []protocol.SupportFile) error {
files := make([]managedFile, 0, len(certFiles))
for _, file := range certFiles {
if file.Path == "pow_config.json" || file.Path == "waf_config.json" {
continue
}
targetPath, err := m.certFileTargetPath(file.Path)
if err != nil {
return err
@@ -813,11 +693,14 @@ func (m *Manager) readCertFiles() ([]protocol.SupportFile, error) {
if info.IsDir() {
return nil
}
data, err := os.ReadFile(path)
relativePath, err := filepath.Rel(m.CertDir, path)
if err != nil {
return err
}
relativePath, err := filepath.Rel(m.CertDir, path)
if filepath.ToSlash(relativePath) == "pow_config.json" {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
@@ -836,6 +719,113 @@ func (m *Manager) readCertFiles() ([]protocol.SupportFile, error) {
return files, nil
}
func (m *Manager) readPowConfigFile() (*protocol.SupportFile, error) {
if m.RuntimeConfigDir == "" {
return nil, nil
}
configPath := filepath.Join(m.RuntimeConfigDir, "pow_config.json")
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
return &protocol.SupportFile{
Path: "pow_config.json",
Content: string(data),
}, nil
}
func (m *Manager) readManagedSupportFiles() ([]protocol.SupportFile, error) {
files, err := m.readCertFiles()
if err != nil {
return nil, err
}
powConfig, err := m.readPowConfigFile()
if err != nil {
return nil, err
}
if powConfig != nil {
files = append(files, *powConfig)
}
return files, nil
}
func (m *Manager) restorePowConfig(state *backupState) error {
if state == nil || m.RuntimeConfigDir == "" {
return nil
}
configPath := filepath.Join(m.RuntimeConfigDir, "pow_config.json")
if state.PowConfig == nil {
if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
return os.WriteFile(configPath, []byte(state.PowConfig.Content), 0o644)
}
func (m *Manager) writeSafeDefaultFallbackFiles() error {
if strings.TrimSpace(m.MainConfigPath) == "" {
return errors.New("main config path 不能为空")
}
if strings.TrimSpace(m.RouteConfigPath) == "" {
return errors.New("route config path 不能为空")
}
if err := os.MkdirAll(filepath.Dir(m.MainConfigPath), 0o755); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(m.RouteConfigPath), 0o755); err != nil {
return err
}
if err := os.WriteFile(m.RouteConfigPath, nil, 0o644); err != nil {
return err
}
if err := os.WriteFile(m.MainConfigPath, []byte(m.safeDefaultFallbackMainConfig()), 0o644); err != nil {
return err
}
return nil
}
func (m *Manager) safeDefaultFallbackMainConfig() string {
observabilityBlock := ""
if listen := strings.TrimSpace(m.OpenrestyObservabilityListen); listen != "" {
observabilityBlock = fmt.Sprintf(safeDefaultFallbackObservabilityServerBlock, listen)
}
return fmt.Sprintf(safeDefaultFallbackMainConfig, observabilityBlock)
}
func (m *Manager) checkStubStatus(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond)
defer cancel()
openrestyStubUrl := fmt.Sprintf("http://127.0.0.1:%d/openflare/stub_status", m.OpenrestyObservabilityPort)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, openrestyStubUrl, nil)
if err != nil {
return err
}
resp, err := (&http.Client{}).Do(req)
if err != nil {
return fmt.Errorf("openresty health endpoint unreachable: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("openresty health endpoint returned %s", resp.Status)
}
slog.Debug("openresty health endpoint is healthy", "url", openrestyStubUrl)
return nil
}
func removeLegacyPowConfig(path string) error {
if strings.TrimSpace(path) == "" {
return nil
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove legacy pow_config.json %q: %w", path, err)
}
return nil
}
func (m *Manager) certFileTargetPath(relativePath string) (string, error) {
if strings.TrimSpace(m.CertDir) == "" {
return "", errors.New("cert dir 不能为空")
@@ -987,11 +977,33 @@ func removeEmptyManagedDirs(baseDir string) error {
return nil
}
func (m *Manager) renderRouteConfig(content string) string {
if m.NginxCertDir == "" {
return content
func (m *Manager) ensureMimeTypes() error {
if m.MainConfigPath == "" {
return nil
}
return strings.ReplaceAll(content, CertDirPlaceholder, m.NginxCertDir)
configDir := filepath.Dir(m.MainConfigPath)
mimeTypesPath := filepath.Join(configDir, "mime.types")
if _, err := os.Stat(mimeTypesPath); err == nil {
return nil
} else if !os.IsNotExist(err) {
return err
}
if err := os.MkdirAll(configDir, 0o755); err != nil {
return err
}
return os.WriteFile(mimeTypesPath, []byte(DefaultMimeTypes), 0o644)
}
func (m *Manager) renderRouteConfig(content string) string {
rendered := content
if m.NginxCertDir != "" {
rendered = strings.ReplaceAll(rendered, CertDirPlaceholder, m.NginxCertDir)
}
if luaDir := m.luaRuntimePath(); luaDir != "" {
rendered = strings.ReplaceAll(rendered, LuaDirPlaceholder, luaDir)
rendered = strings.ReplaceAll(rendered, PowStaticDirPlaceholder, luaDir+"/pow/static")
}
return rendered
}
func (m *Manager) renderMainConfig(content string) string {
@@ -1017,14 +1029,29 @@ func (m *Manager) renderMainConfig(content string) string {
return rendered
}
func (m *Manager) managedPowLuaFiles() []protocol.SupportFile {
files := ManagedPowLuaFiles()
runtimeConfigDir := filepath.ToSlash(strings.TrimSpace(m.RuntimeConfigDir))
for index := range files {
files[index].Content = strings.ReplaceAll(files[index].Content, RuntimeConfigDirPlaceholder, runtimeConfigDir)
}
return files
}
func (m *Manager) managedWAFLuaFiles() []protocol.SupportFile {
files := ManagedWAFLuaFiles()
runtimeConfigDir := filepath.ToSlash(strings.TrimSpace(m.RuntimeConfigDir))
for index := range files {
files[index].Content = strings.ReplaceAll(files[index].Content, RuntimeConfigDirPlaceholder, runtimeConfigDir)
}
return files
}
func ObservabilityListenAddress(openrestyPath string, port int) string {
if port <= 0 {
return ""
}
if strings.TrimSpace(openrestyPath) != "" {
return fmt.Sprintf("127.0.0.1:%d", port)
}
return fmt.Sprintf("%d", port)
return fmt.Sprintf("127.0.0.1:%d", port)
}
func ResolverDirective(openrestyPath string, explicitResolvers []string) string {
@@ -1043,7 +1070,7 @@ func resolverAddresses(openrestyPath string, explicitResolvers []string) []strin
if err != nil {
return nil
}
return parseResolverAddresses(string(data), strings.TrimSpace(openrestyPath) == "")
return parseResolverAddresses(string(data), false)
}
func parseResolverAddresses(content string, dockerMode bool) []string {
@@ -1111,18 +1138,11 @@ func RequiresRuntimeResolver(originURL string) bool {
}
func (m *Manager) routeConfigIncludePath() string {
if strings.TrimSpace(m.RuntimeRouteConfigPath) != "" {
return strings.TrimSpace(m.RuntimeRouteConfigPath)
}
return strings.TrimSpace(m.RouteConfigPath)
}
func (m *Manager) accessLogRuntimePath() string {
includePath := m.routeConfigIncludePath()
if strings.TrimSpace(includePath) == "" {
return ""
}
return filepath.ToSlash(filepath.Join(filepath.Dir(includePath), "openflare_access.log"))
return filepath.ToSlash(strings.TrimSpace(m.AccessLogPath))
}
func (m *Manager) luaRuntimePath() string {
+321 -481
View File
@@ -3,6 +3,8 @@ package nginx
import (
"context"
"errors"
"net"
"net/http"
"os"
"path/filepath"
"reflect"
@@ -29,6 +31,8 @@ type fakeExecutor struct {
}
type scriptedExecutor struct {
testErrors []error
testCalls int
reloadErrors []error
reloadCalls int
}
@@ -62,7 +66,12 @@ func (e *fakeExecutor) Restart(ctx context.Context) error {
}
func (e *scriptedExecutor) Test(ctx context.Context) error {
return nil
index := e.testCalls
e.testCalls++
if index >= len(e.testErrors) {
return nil
}
return e.testErrors[index]
}
func (e *scriptedExecutor) Reload(ctx context.Context) error {
@@ -89,8 +98,9 @@ func (e *scriptedExecutor) Restart(ctx context.Context) error {
func TestPathExecutorCommands(t *testing.T) {
runner := &fakeRunner{}
executor := &PathExecutor{
Path: "/usr/local/openresty/nginx/sbin/openresty",
Runner: runner,
Path: "/usr/local/openresty/nginx/sbin/openresty",
ConfigPath: "/data/etc/nginx/nginx.conf",
Runner: runner,
}
if err := executor.Test(context.Background()); err != nil {
@@ -101,8 +111,8 @@ func TestPathExecutorCommands(t *testing.T) {
}
expected := []runCall{
{name: "/usr/local/openresty/nginx/sbin/openresty", args: []string{"-t"}},
{name: "/usr/local/openresty/nginx/sbin/openresty", args: []string{"-s", "reload"}},
{name: "/usr/local/openresty/nginx/sbin/openresty", args: []string{"-t", "-c", "/data/etc/nginx/nginx.conf"}},
{name: "/usr/local/openresty/nginx/sbin/openresty", args: []string{"-s", "reload", "-c", "/data/etc/nginx/nginx.conf"}},
}
if !reflect.DeepEqual(runner.calls, expected) {
t.Fatalf("unexpected calls: %#v", runner.calls)
@@ -110,13 +120,18 @@ func TestPathExecutorCommands(t *testing.T) {
}
func TestPathExecutorEnsureRuntimeNoop(t *testing.T) {
runner := &fakeRunner{}
executor := &PathExecutor{
Path: "/usr/local/openresty/nginx/sbin/openresty",
Runner: &fakeRunner{},
Path: "/usr/local/openresty/nginx/sbin/openresty",
ConfigPath: "/data/etc/nginx/nginx.conf",
Runner: runner,
}
if err := executor.EnsureRuntime(context.Background(), true); err != nil {
t.Fatalf("EnsureRuntime failed: %v", err)
}
if len(runner.calls) != 2 {
t.Fatalf("expected test and reload calls, got %d", len(runner.calls))
}
}
func TestPathExecutorRestartIgnoresMissingPID(t *testing.T) {
@@ -129,8 +144,9 @@ func TestPathExecutorRestartIgnoresMissingPID(t *testing.T) {
},
}
executor := &PathExecutor{
Path: "/usr/local/openresty/nginx/sbin/openresty",
Runner: runner,
Path: "/usr/local/openresty/nginx/sbin/openresty",
ConfigPath: "/data/etc/nginx/nginx.conf",
Runner: runner,
}
if err := executor.Restart(context.Background()); err != nil {
t.Fatalf("Restart failed: %v", err)
@@ -140,422 +156,32 @@ func TestPathExecutorRestartIgnoresMissingPID(t *testing.T) {
}
}
func TestDockerExecutorCheckHealthFailsWhenContainerStopped(t *testing.T) {
func TestPathExecutorReloadStartsWhenRuntimeIsNotRunning(t *testing.T) {
runner := &fakeRunner{
runFn: func(name string, args ...string) ([]byte, error) {
if len(args) >= 4 && args[0] == "inspect" && args[2] == "{{.State.Running}}" {
return []byte("false"), nil
if len(args) >= 2 && args[0] == "-s" && args[1] == "reload" {
return []byte("openresty: [error] invalid PID number \"\" in \"/usr/local/openresty/nginx/logs/nginx.pid\""), errors.New("exit status 1")
}
if len(args) >= 4 && args[0] == "inspect" {
return []byte("status=exited exit_code=1 error=\"\" oom_killed=false finished_at=2026-03-18T10:08:30Z"), nil
}
if len(args) >= 1 && args[0] == "logs" {
return []byte("nginx: [emerg] host not found in upstream \"c1\" in /etc/nginx/conf.d/openflare_routes.conf:30"), nil
}
return []byte("false"), nil
return []byte(""), nil
},
}
executor := &DockerExecutor{
DockerBinary: "docker",
ContainerName: "openflare-openresty",
Image: "openresty/openresty:alpine",
MainConfigPath: filepath.Clean("/tmp/nginx.conf"),
RouteConfigDir: filepath.Clean("/tmp/routes"),
CertDir: filepath.Clean("/tmp/certs"),
NginxCertDir: "/etc/nginx/openflare-certs",
LuaDir: filepath.Clean("/tmp/lua"),
NginxLuaDir: "/etc/nginx/openflare-lua",
Runner: runner,
executor := &PathExecutor{
Path: "/usr/local/openresty/nginx/sbin/openresty",
ConfigPath: "/data/etc/nginx/nginx.conf",
Runner: runner,
}
if err := executor.CheckHealth(context.Background()); err == nil {
t.Fatal("expected CheckHealth to fail when container is not running")
} else {
text := err.Error()
if !strings.Contains(text, "exit_code=1") {
t.Fatalf("expected exit code in health error, got %v", err)
}
if !strings.Contains(text, "host not found in upstream") {
t.Fatalf("expected recent docker logs in health error, got %v", err)
}
}
}
func prepareDockerMountSources(t *testing.T) (string, string, string, string) {
t.Helper()
tempDir := t.TempDir()
mainConfigPath := filepath.Join(tempDir, "nginx.conf")
routeConfigDir := filepath.Join(tempDir, "conf.d")
certDir := filepath.Join(tempDir, "certs")
luaDir := filepath.Join(tempDir, "lua")
if err := os.WriteFile(mainConfigPath, []byte("events {}\nhttp {}\n"), 0o644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
for _, dir := range []string{routeConfigDir, certDir, luaDir} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("MkdirAll failed: %v", err)
}
}
return mainConfigPath, routeConfigDir, certDir, luaDir
}
func TestDockerExecutorStartsContainerWhenMissing(t *testing.T) {
mainConfigPath, routeConfigDir, certDir, luaDir := prepareDockerMountSources(t)
runner := &fakeRunner{
runFn: func(name string, args ...string) ([]byte, error) {
if len(args) >= 1 && args[0] == "inspect" {
return []byte(""), errors.New("not found")
}
return []byte("ok"), nil
},
}
executor := &DockerExecutor{
DockerBinary: "docker",
ContainerName: "openflare-openresty",
Image: "openresty/openresty:alpine",
MainConfigPath: mainConfigPath,
RouteConfigDir: routeConfigDir,
CertDir: certDir,
NginxCertDir: "/etc/nginx/openflare-certs",
LuaDir: luaDir,
NginxLuaDir: "/etc/nginx/openflare-lua",
Runner: runner,
}
if err := executor.Test(context.Background()); err != nil {
t.Fatalf("Test failed: %v", err)
}
if len(runner.calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(runner.calls))
}
if runner.calls[0].args[0] != "run" || runner.calls[0].args[1] != "--rm" {
t.Fatalf("expected docker run --rm for test, got %#v", runner.calls[0])
}
if runner.calls[0].args[len(runner.calls[0].args)-2] != "openresty" {
t.Fatalf("expected docker test command to invoke openresty, got %#v", runner.calls[0])
}
}
func TestDockerExecutorStartsStoppedContainer(t *testing.T) {
mainConfigPath, routeConfigDir, certDir, luaDir := prepareDockerMountSources(t)
inspectCalls := 0
runner := &fakeRunner{
runFn: func(name string, args ...string) ([]byte, error) {
if len(args) >= 2 && args[0] == "inspect" {
inspectCalls++
if inspectCalls < 3 {
return []byte("false"), nil
}
return []byte("true"), nil
}
return []byte("ok"), nil
},
}
executor := &DockerExecutor{
DockerBinary: "docker",
ContainerName: "openflare-openresty",
Image: "openresty/openresty:alpine",
MainConfigPath: mainConfigPath,
RouteConfigDir: routeConfigDir,
CertDir: certDir,
NginxCertDir: "/etc/nginx/openflare-certs",
LuaDir: luaDir,
NginxLuaDir: "/etc/nginx/openflare-lua",
Runner: runner,
}
if err := executor.Reload(context.Background()); err != nil {
t.Fatalf("Reload failed: %v", err)
}
if len(runner.calls) != 5 {
t.Fatalf("expected 5 calls, got %d", len(runner.calls))
}
if runner.calls[0].args[0] != "inspect" {
t.Fatalf("expected docker inspect on first call, got %#v", runner.calls[0])
}
if runner.calls[1].args[0] != "inspect" {
t.Fatalf("expected docker inspect on second call, got %#v", runner.calls[1])
}
if runner.calls[2].args[0] != "rm" {
t.Fatalf("expected docker rm on third call, got %#v", runner.calls[2])
}
if runner.calls[3].args[0] != "run" {
t.Fatalf("expected docker run on fourth call, got %#v", runner.calls[3])
}
if runner.calls[4].args[0] != "inspect" {
t.Fatalf("expected docker inspect after run, got %#v", runner.calls[4])
}
}
func TestDockerExecutorReloadsRunningContainerInPlace(t *testing.T) {
mainConfigPath, routeConfigDir, certDir, luaDir := prepareDockerMountSources(t)
runner := &fakeRunner{
runFn: func(name string, args ...string) ([]byte, error) {
if len(args) >= 1 && args[0] == "inspect" {
return []byte("true"), nil
}
return []byte("ok"), nil
},
}
executor := &DockerExecutor{
DockerBinary: "docker",
ContainerName: "openflare-openresty",
Image: "openresty/openresty:alpine",
MainConfigPath: mainConfigPath,
RouteConfigDir: routeConfigDir,
CertDir: certDir,
NginxCertDir: "/etc/nginx/openflare-certs",
LuaDir: luaDir,
NginxLuaDir: "/etc/nginx/openflare-lua",
Runner: runner,
}
if err := executor.Reload(context.Background()); err != nil {
t.Fatalf("Reload failed: %v", err)
}
expected := []runCall{
{name: "docker", args: []string{"inspect", "-f", "{{.State.Running}}", "openflare-openresty"}},
{name: "docker", args: []string{"exec", "openflare-openresty", "openresty", "-s", "reload"}},
{name: "/usr/local/openresty/nginx/sbin/openresty", args: []string{"-s", "reload", "-c", "/data/etc/nginx/nginx.conf"}},
{name: "/usr/local/openresty/nginx/sbin/openresty", args: []string{"-c", "/data/etc/nginx/nginx.conf"}},
}
if !reflect.DeepEqual(runner.calls, expected) {
t.Fatalf("unexpected calls: %#v", runner.calls)
}
}
func TestDockerExecutorReloadRecreatesContainerWhenMountedCertMissing(t *testing.T) {
mainConfigPath, routeConfigDir, certDir, luaDir := prepareDockerMountSources(t)
runner := &fakeRunner{
runFn: func(name string, args ...string) ([]byte, error) {
if len(args) >= 1 && args[0] == "inspect" {
return []byte("true"), nil
}
if len(args) >= 2 && args[0] == "exec" {
return []byte(`nginx: [emerg] cannot load certificate "/etc/nginx/openflare-certs/1.crt": BIO_new_file() failed (SSL: error:80000002:system library::No such file or directory)`), errors.New("exit status 1")
}
return []byte("ok"), nil
},
}
executor := &DockerExecutor{
DockerBinary: "docker",
ContainerName: "openflare-openresty",
Image: "openresty/openresty:alpine",
MainConfigPath: mainConfigPath,
RouteConfigDir: routeConfigDir,
CertDir: certDir,
NginxCertDir: "/etc/nginx/openflare-certs",
LuaDir: luaDir,
NginxLuaDir: "/etc/nginx/openflare-lua",
OpenrestyObservabilityPort: 18081,
Runner: runner,
}
if err := executor.Reload(context.Background()); err != nil {
t.Fatalf("Reload failed: %v", err)
}
if len(runner.calls) != 6 {
t.Fatalf("expected 6 calls, got %d", len(runner.calls))
}
if runner.calls[2].args[0] != "inspect" || runner.calls[3].args[0] != "rm" || runner.calls[4].args[0] != "run" || runner.calls[5].args[0] != "inspect" {
t.Fatalf("expected recreate after reload failure, got %#v", runner.calls)
}
}
func TestDockerExecutorRunContainerMountsManagedFiles(t *testing.T) {
mainConfigPath, routeConfigDir, certDir, luaDir := prepareDockerMountSources(t)
runner := &fakeRunner{
runFn: func(name string, args ...string) ([]byte, error) {
if len(args) >= 1 && args[0] == "inspect" {
return []byte("true"), nil
}
return []byte("ok"), nil
},
}
executor := &DockerExecutor{
DockerBinary: "docker",
ContainerName: "openflare-openresty",
Image: "openresty/openresty:alpine",
MainConfigPath: mainConfigPath,
RouteConfigDir: routeConfigDir,
CertDir: certDir,
NginxCertDir: "/etc/nginx/openflare-certs",
LuaDir: luaDir,
NginxLuaDir: "/etc/nginx/openflare-lua",
OpenrestyObservabilityPort: 18081,
Runner: runner,
}
if err := executor.runContainer(context.Background()); err != nil {
t.Fatalf("runContainer failed: %v", err)
}
if len(runner.calls) != 2 {
t.Fatalf("expected docker run plus health check, got %d calls", len(runner.calls))
}
expectedArgs := []string{
"run", "-d",
"--name", "openflare-openresty",
"-p", "80:80",
"-p", "443:443",
"-p", "127.0.0.1:18081:18081",
"-v", mainConfigPath + ":" + DockerMainConfigPath,
"-v", routeConfigDir + ":/etc/nginx/conf.d",
"-v", certDir + ":/etc/nginx/openflare-certs",
"-v", luaDir + ":/etc/nginx/openflare-lua",
"openresty/openresty:alpine",
}
if !reflect.DeepEqual(runner.calls[0].args, expectedArgs) {
t.Fatalf("unexpected docker run args: %#v", runner.calls[0].args)
}
if !reflect.DeepEqual(runner.calls[1].args, []string{"inspect", "-f", "{{.State.Running}}", "openflare-openresty"}) {
t.Fatalf("unexpected docker health check args: %#v", runner.calls[1].args)
}
}
func TestDockerExecutorRecreatesContainerOnStartup(t *testing.T) {
mainConfigPath, routeConfigDir, certDir, luaDir := prepareDockerMountSources(t)
runner := &fakeRunner{
runFn: func(name string, args ...string) ([]byte, error) {
if len(args) >= 1 && args[0] == "inspect" {
return []byte("true"), nil
}
return []byte("ok"), nil
},
}
executor := &DockerExecutor{
DockerBinary: "docker",
ContainerName: "openflare-openresty",
Image: "openresty/openresty:alpine",
MainConfigPath: mainConfigPath,
RouteConfigDir: routeConfigDir,
CertDir: certDir,
NginxCertDir: "/etc/nginx/openflare-certs",
LuaDir: luaDir,
NginxLuaDir: "/etc/nginx/openflare-lua",
OpenrestyObservabilityPort: 18081,
Runner: runner,
}
if err := executor.EnsureRuntime(context.Background(), true); err != nil {
t.Fatalf("EnsureRuntime failed: %v", err)
}
if len(runner.calls) != 4 {
t.Fatalf("expected 4 calls, got %d", len(runner.calls))
}
if runner.calls[1].args[0] != "rm" {
t.Fatalf("expected docker rm on second call, got %#v", runner.calls[1])
}
if runner.calls[2].args[0] != "run" {
t.Fatalf("expected docker run on third call, got %#v", runner.calls[2])
}
if runner.calls[3].args[0] != "inspect" {
t.Fatalf("expected docker inspect after run, got %#v", runner.calls[3])
}
}
func TestDockerExecutorRunContainerRejectsMissingMainConfigFile(t *testing.T) {
tempDir := t.TempDir()
routeConfigDir := filepath.Join(tempDir, "conf.d")
certDir := filepath.Join(tempDir, "certs")
luaDir := filepath.Join(tempDir, "lua")
for _, dir := range []string{routeConfigDir, certDir, luaDir} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("MkdirAll failed: %v", err)
}
}
executor := &DockerExecutor{
DockerBinary: "docker",
ContainerName: "openflare-openresty",
Image: "openresty/openresty:alpine",
MainConfigPath: filepath.Join(tempDir, "nginx.conf"),
RouteConfigDir: routeConfigDir,
CertDir: certDir,
NginxCertDir: "/etc/nginx/openflare-certs",
LuaDir: luaDir,
NginxLuaDir: "/etc/nginx/openflare-lua",
Runner: &fakeRunner{},
}
err := executor.runContainer(context.Background())
if err == nil {
t.Fatal("expected missing main config file to be rejected")
}
if !strings.Contains(err.Error(), "run a config apply first") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDockerExecutorRunContainerRejectsMainConfigDirectory(t *testing.T) {
tempDir := t.TempDir()
mainConfigPath := filepath.Join(tempDir, "nginx.conf")
routeConfigDir := filepath.Join(tempDir, "conf.d")
certDir := filepath.Join(tempDir, "certs")
luaDir := filepath.Join(tempDir, "lua")
for _, dir := range []string{mainConfigPath, routeConfigDir, certDir, luaDir} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("MkdirAll failed: %v", err)
}
}
executor := &DockerExecutor{
DockerBinary: "docker",
ContainerName: "openflare-openresty",
Image: "openresty/openresty:alpine",
MainConfigPath: mainConfigPath,
RouteConfigDir: routeConfigDir,
CertDir: certDir,
NginxCertDir: "/etc/nginx/openflare-certs",
LuaDir: luaDir,
NginxLuaDir: "/etc/nginx/openflare-lua",
Runner: &fakeRunner{},
}
err := executor.runContainer(context.Background())
if err == nil {
t.Fatal("expected main config directory to be rejected")
}
if !strings.Contains(err.Error(), "expected a file") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestNewExecutorUsesAbsoluteDockerMountPath(t *testing.T) {
executor := NewExecutor(ExecutorOptions{
DockerBinary: "docker",
ContainerName: "openflare-openresty",
Image: "openresty/openresty:alpine",
MainConfigPath: "./data/etc/nginx/nginx.conf",
RouteConfigPath: "./data/etc/nginx/conf.d/openflare_routes.conf",
CertDir: "./data/etc/nginx/certs",
NginxCertDir: "/etc/nginx/openflare-certs",
LuaDir: "./data/etc/nginx/lua",
NginxLuaDir: "/etc/nginx/openflare-lua",
OpenrestyObservabilityPort: 18081,
})
dockerExecutor, ok := executor.(*DockerExecutor)
if !ok {
t.Fatal("expected docker executor")
}
if !filepath.IsAbs(dockerExecutor.RouteConfigDir) {
t.Fatalf("expected absolute route config dir, got %s", dockerExecutor.RouteConfigDir)
}
if !filepath.IsAbs(dockerExecutor.MainConfigPath) {
t.Fatalf("expected absolute main config path, got %s", dockerExecutor.MainConfigPath)
}
if !strings.HasSuffix(dockerExecutor.RouteConfigDir, filepath.Clean("data/etc/nginx/conf.d")) {
t.Fatalf("unexpected route config dir: %s", dockerExecutor.RouteConfigDir)
}
if !strings.HasSuffix(dockerExecutor.MainConfigPath, filepath.Clean("data/etc/nginx/nginx.conf")) {
t.Fatalf("unexpected main config path: %s", dockerExecutor.MainConfigPath)
}
}
func TestDetectVersionFromBinary(t *testing.T) {
version, err := detectVersion(context.Background(), ExecutorOptions{
NginxPath: "/usr/local/openresty/nginx/sbin/openresty",
@@ -577,9 +203,11 @@ func TestManagerApplyAndChecksumIncludeMainConfig(t *testing.T) {
mainPath := filepath.Join(tempDir, "nginx.conf")
routePath := filepath.Join(tempDir, "conf.d", "openflare_routes.conf")
certDir := filepath.Join(tempDir, "certs")
accessLogPath := filepath.Join(tempDir, "var", "log", "openflare", "access.log")
manager := &Manager{
MainConfigPath: mainPath,
RouteConfigPath: routePath,
AccessLogPath: accessLogPath,
CertDir: certDir,
NginxCertDir: "/etc/nginx/openflare-certs",
LuaDir: filepath.Join(tempDir, "lua"),
@@ -601,7 +229,7 @@ func TestManagerApplyAndChecksumIncludeMainConfig(t *testing.T) {
if err != nil {
t.Fatalf("failed to read main config: %v", err)
}
expectedMain := "include " + routePath + ";\naccess_log " + filepath.ToSlash(filepath.Join(filepath.Dir(routePath), "openflare_access.log")) + " openflare_json;\n"
expectedMain := "include " + routePath + ";\naccess_log " + filepath.ToSlash(accessLogPath) + " openflare_json;\n"
if string(mainData) != expectedMain {
t.Fatalf("unexpected main config: %s", string(mainData))
}
@@ -628,73 +256,6 @@ func TestManagerApplyAndChecksumIncludeMainConfig(t *testing.T) {
}
}
func TestManagerApplyUsesRuntimeRouteConfigPath(t *testing.T) {
tempDir := t.TempDir()
mainPath := filepath.Join(tempDir, "nginx.conf")
routePath := filepath.Join(tempDir, "conf.d", "openflare_routes.conf")
manager := &Manager{
MainConfigPath: mainPath,
RouteConfigPath: routePath,
RuntimeRouteConfigPath: DockerRouteConfigPath,
CertDir: filepath.Join(tempDir, "certs"),
NginxCertDir: "/etc/nginx/openflare-certs",
LuaDir: filepath.Join(tempDir, "lua"),
NginxLuaDir: "/etc/nginx/openflare-lua",
Executor: &fakeExecutor{},
}
if outcome := manager.Apply(context.Background(), "include __OPENFLARE_ROUTE_CONFIG__;\naccess_log __OPENFLARE_ACCESS_LOG__ openflare_json;\n", "server { listen 80; }\n", nil); outcome.Status != ApplyStatusSuccess {
t.Fatalf("Apply failed: %#v", outcome)
}
mainData, err := os.ReadFile(mainPath)
if err != nil {
t.Fatalf("failed to read main config: %v", err)
}
expectedMain := "include " + DockerRouteConfigPath + ";\naccess_log " + DockerAccessLogPath + " openflare_json;\n"
if string(mainData) != expectedMain {
t.Fatalf("unexpected main config include path: %s", string(mainData))
}
value, err := manager.CurrentChecksum()
if err != nil {
t.Fatalf("CurrentChecksum failed: %v", err)
}
expected := bundleChecksum(
"include __OPENFLARE_ROUTE_CONFIG__;\naccess_log __OPENFLARE_ACCESS_LOG__ openflare_json;\n",
"server { listen 80; }\n",
nil,
)
if value != expected {
t.Fatalf("unexpected checksum: got %s want %s", value, expected)
}
}
func TestDetectVersionFromDockerImage(t *testing.T) {
runner := &fakeRunner{
runFn: func(name string, args ...string) ([]byte, error) {
return []byte("nginx version: openresty/1.27.1.2\n"), nil
},
}
version, err := detectVersion(context.Background(), ExecutorOptions{
DockerBinary: "docker",
Image: "openresty/openresty:alpine",
}, runner)
if err != nil {
t.Fatalf("detectVersion failed: %v", err)
}
if version != "1.27.1.2" {
t.Fatalf("unexpected version: %s", version)
}
if len(runner.calls) != 1 {
t.Fatalf("expected one command call, got %d", len(runner.calls))
}
expectedArgs := []string{"run", "--rm", "openresty/openresty:alpine", "openresty", "-v"}
if !reflect.DeepEqual(runner.calls[0].args, expectedArgs) {
t.Fatalf("unexpected docker args: %#v", runner.calls[0].args)
}
}
func TestParseNginxVersionIgnoresDockerEntrypointPaths(t *testing.T) {
output := strings.Join([]string{
"/docker-entrypoint.sh: /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh: info: can not modify /etc/nginx/conf.d/default.conf (read-only file system?)",
@@ -736,6 +297,13 @@ func TestManagerApplyWritesSupportFilesAndReplacesPlaceholder(t *testing.T) {
if !strings.Contains(string(routeData), "/etc/nginx/openflare-certs/1.crt") {
t.Fatalf("expected placeholder replacement in route config, got %s", string(routeData))
}
renderedRoute := manager.renderRouteConfig("access_by_lua_file __OPENFLARE_LUA_DIR__/pow/check.lua;\nlocation /.within.website/x/cmd/anubis/static/ { alias __OPENFLARE_POW_STATIC_DIR__/; }\n")
if !strings.Contains(renderedRoute, "access_by_lua_file /etc/nginx/openflare-lua/pow/check.lua;") {
t.Fatalf("expected lua dir placeholder replacement in route config, got %s", renderedRoute)
}
if !strings.Contains(renderedRoute, "alias /etc/nginx/openflare-lua/pow/static/;") {
t.Fatalf("expected pow static dir placeholder replacement in route config, got %s", renderedRoute)
}
mainData, err := os.ReadFile(manager.MainConfigPath)
if err != nil {
t.Fatalf("failed to read main config: %v", err)
@@ -762,6 +330,67 @@ func TestManagerApplyWritesSupportFilesAndReplacesPlaceholder(t *testing.T) {
}
}
func TestManagerCheckHealthUsesStubStatusInsteadOfConfigTest(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Listen failed: %v", err)
}
port := listener.Addr().(*net.TCPAddr).Port
server := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/openflare/stub_status" {
http.NotFound(w, r)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("Active connections: 1\n"))
}),
}
go func() {
_ = server.Serve(listener)
}()
defer server.Shutdown(context.Background())
mainPath := filepath.Join(t.TempDir(), "nginx.conf")
if err := os.WriteFile(mainPath, []byte("main"), 0o644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
manager := &Manager{
MainConfigPath: mainPath,
OpenrestyObservabilityPort: port,
Executor: &fakeExecutor{
testErr: errors.New("openresty -t should not be called"),
},
}
if err := manager.CheckHealth(context.Background()); err != nil {
t.Fatalf("CheckHealth failed: %v", err)
}
}
func TestManagerCheckHealthFailsWhenStubStatusUnavailable(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Listen failed: %v", err)
}
port := listener.Addr().(*net.TCPAddr).Port
if err := listener.Close(); err != nil {
t.Fatalf("listener close failed: %v", err)
}
mainPath := filepath.Join(t.TempDir(), "nginx.conf")
if err := os.WriteFile(mainPath, []byte("main"), 0o644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
manager := &Manager{
MainConfigPath: mainPath,
OpenrestyObservabilityPort: port,
Executor: &fakeExecutor{},
}
if err := manager.CheckHealth(context.Background()); err == nil {
t.Fatal("expected CheckHealth to fail when stub_status is unavailable")
}
}
func TestResolverDirectiveUsesExplicitResolvers(t *testing.T) {
got := ResolverDirective("", []string{"10.0.0.2", "1.1.1.1"})
if !strings.Contains(got, "resolver 10.0.0.2 1.1.1.1") {
@@ -867,6 +496,12 @@ func TestEnsureLuaAssetsKeepsBaseDirAndRemovesStaleFiles(t *testing.T) {
if _, err := os.Stat(filepath.Join(luaDir, "log.lua")); err != nil {
t.Fatalf("expected managed lua file to exist, stat err = %v", err)
}
if _, err := os.Stat(filepath.Join(luaDir, "pow", "check.lua")); err != nil {
t.Fatalf("expected managed pow lua file to exist, stat err = %v", err)
}
if _, err := os.Stat(filepath.Join(luaDir, "pow", "static", "js", "main.mjs")); err != nil {
t.Fatalf("expected managed pow static asset to exist, stat err = %v", err)
}
}
func TestCertFileMode(t *testing.T) {
@@ -890,8 +525,9 @@ func TestCertFileMode(t *testing.T) {
func TestManagerEnsureLuaAssetsWritesReadableFiles(t *testing.T) {
tempDir := t.TempDir()
manager := &Manager{
LuaDir: filepath.Join(tempDir, "lua"),
NginxLuaDir: "/etc/nginx/openflare-lua",
LuaDir: filepath.Join(tempDir, "lua"),
NginxLuaDir: "/etc/nginx/openflare-lua",
RuntimeConfigDir: filepath.Join(tempDir, "runtime"),
}
err := manager.EnsureLuaAssets()
@@ -906,6 +542,161 @@ func TestManagerEnsureLuaAssetsWritesReadableFiles(t *testing.T) {
if luaInfo.Mode().Perm() != 0o644 {
t.Fatalf("unexpected lua mode: %o", luaInfo.Mode().Perm())
}
if _, err := os.Stat(filepath.Join(manager.LuaDir, "pow", "check.lua")); err != nil {
t.Fatalf("failed to stat pow lua file: %v", err)
}
data, err := os.ReadFile(filepath.Join(manager.LuaDir, "pow", "runtime.lua"))
if err != nil {
t.Fatalf("failed to read pow lua file: %v", err)
}
if !strings.Contains(string(data), filepath.ToSlash(manager.RuntimeConfigDir)+"/pow_config.json") {
t.Fatalf("expected pow lua to read runtime config dir, got %s", string(data))
}
}
func TestEnsureLuaAssetsLeavesRuntimePowConfigOutsideLuaDir(t *testing.T) {
tempDir := t.TempDir()
luaDir := filepath.Join(tempDir, "lua")
runtimeConfigDir := filepath.Join(tempDir, "runtime")
if err := os.MkdirAll(runtimeConfigDir, 0o755); err != nil {
t.Fatalf("MkdirAll failed: %v", err)
}
powConfigPath := filepath.Join(runtimeConfigDir, "pow_config.json")
want := `[{"domains":["pow.example.com"],"enabled":true}]`
if err := os.WriteFile(powConfigPath, []byte(want), 0o644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
manager := &Manager{LuaDir: luaDir, RuntimeConfigDir: runtimeConfigDir}
if err := manager.EnsureLuaAssets(); err != nil {
t.Fatalf("EnsureLuaAssets failed: %v", err)
}
got, err := os.ReadFile(powConfigPath)
if err != nil {
t.Fatalf("expected pow_config.json to remain after EnsureLuaAssets: %v", err)
}
if string(got) != want {
t.Fatalf("unexpected pow_config.json content: got %s want %s", string(got), want)
}
if _, err := os.Stat(filepath.Join(luaDir, "pow_config.json")); !os.IsNotExist(err) {
t.Fatalf("expected lua pow_config.json to stay absent, stat err = %v", err)
}
}
func TestManagerApplyWritesPowConfigToRuntimeDirAndCleansLegacyCopies(t *testing.T) {
tempDir := t.TempDir()
certDir := filepath.Join(tempDir, "certs")
luaDir := filepath.Join(tempDir, "lua")
runtimeConfigDir := filepath.Join(tempDir, "runtime")
for _, dir := range []string{certDir, luaDir, runtimeConfigDir} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("MkdirAll failed: %v", err)
}
}
for _, path := range []string{filepath.Join(certDir, "pow_config.json"), filepath.Join(luaDir, "pow_config.json")} {
if err := os.WriteFile(path, []byte("stale"), 0o644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
}
manager := &Manager{
MainConfigPath: filepath.Join(tempDir, "nginx.conf"),
RouteConfigPath: filepath.Join(tempDir, "routes.conf"),
CertDir: certDir,
LuaDir: luaDir,
RuntimeConfigDir: runtimeConfigDir,
Executor: &fakeExecutor{},
}
outcome := manager.Apply(context.Background(), "main", "route", []protocol.SupportFile{
{Path: "pow_config.json", Content: "runtime"},
})
if outcome.Status != ApplyStatusSuccess {
t.Fatalf("Apply failed: %#v", outcome)
}
data, err := os.ReadFile(filepath.Join(runtimeConfigDir, "pow_config.json"))
if err != nil {
t.Fatalf("failed to read runtime pow config: %v", err)
}
if string(data) != "runtime" {
t.Fatalf("unexpected runtime pow config: %s", string(data))
}
for _, path := range []string{filepath.Join(certDir, "pow_config.json"), filepath.Join(luaDir, "pow_config.json")} {
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("expected legacy pow config to be removed from %s, stat err = %v", path, err)
}
}
}
func TestManagerCurrentChecksumIncludesPowConfig(t *testing.T) {
tempDir := t.TempDir()
mainPath := filepath.Join(tempDir, "nginx.conf")
routePath := filepath.Join(tempDir, "routes.conf")
luaDir := filepath.Join(tempDir, "lua")
runtimeConfigDir := filepath.Join(tempDir, "runtime")
manager := &Manager{
MainConfigPath: mainPath,
RouteConfigPath: routePath,
LuaDir: luaDir,
NginxLuaDir: "/etc/nginx/openflare-lua",
RuntimeConfigDir: runtimeConfigDir,
Executor: &fakeExecutor{},
}
outcome := manager.Apply(
context.Background(),
"access_log __OPENFLARE_ACCESS_LOG__ openflare_json;\n",
"location /.within.website/x/cmd/anubis/static/ { alias __OPENFLARE_POW_STATIC_DIR__/; }\n",
[]protocol.SupportFile{{Path: "pow_config.json", Content: `[{"domains":["pow.example.com"],"enabled":true}]`}},
)
if outcome.Status != ApplyStatusSuccess {
t.Fatalf("Apply failed: %#v", outcome)
}
value, err := manager.CurrentChecksum()
if err != nil {
t.Fatalf("CurrentChecksum failed: %v", err)
}
expected := bundleChecksum(
"access_log __OPENFLARE_ACCESS_LOG__ openflare_json;\n",
"location /.within.website/x/cmd/anubis/static/ { alias __OPENFLARE_POW_STATIC_DIR__/; }\n",
[]protocol.SupportFile{{Path: "pow_config.json", Content: `[{"domains":["pow.example.com"],"enabled":true}]`}},
)
if value != expected {
t.Fatalf("unexpected checksum with pow config: got %s want %s", value, expected)
}
}
func TestManagedPowLuaFilesUseInternalChallengeFlow(t *testing.T) {
if !strings.Contains(openRestyPowRuntimeLua, `return ngx.exec("/.within.website/x/cmd/anubis/api/make-challenge")`) {
t.Fatal("expected pow runtime lua to internally execute make-challenge instead of issuing a 302 redirect")
}
if strings.Contains(openRestyPowRuntimeLua, "ngx.redirect(") {
t.Fatal("expected pow runtime lua to avoid external redirects for challenge rendering")
}
if !strings.Contains(openRestyPowChallengeLua, `<h1 id="title" class="centered-div">`) {
t.Fatal("expected challenge html to include Anubis-compatible title node")
}
if !strings.Contains(openRestyPowChallengeLua, `<div id="progress" role="progressbar" aria-labelledby="status"><div class="bar-inner"></div></div>`) {
t.Fatal("expected challenge html to include Anubis-compatible progress markup")
}
if !strings.Contains(openRestyPowChallengeLua, `<script id="anubis_public_url" type="application/json">"__openflare_internal__"</script>`) {
t.Fatal("expected challenge html to force Anubis frontend to reuse the current URL as redir target")
}
if !strings.Contains(openRestyPowRuntimeLua, `pow_sessions:set(session_key, "1", session_ttl)`) {
t.Fatal("expected pow runtime lua to refresh the PoW session TTL on each valid request")
}
if !strings.Contains(openRestyPowRuntimeLua, `ngx.header["Set-Cookie"] = session_cookie(cookie_val, session_ttl)`) {
t.Fatal("expected pow runtime lua to refresh the browser session cookie on each valid request")
}
if !strings.Contains(openRestyPowChallengeLua, `local session_ttl = config.session_ttl or 600`) {
t.Fatal("expected challenge.lua to default session TTL to 10 minutes")
}
if !strings.Contains(openRestyPowVerifyLua, `local session_ttl = challenge_info.session_ttl or 600`) {
t.Fatal("expected verify.lua to default session TTL to 10 minutes")
}
if !strings.Contains(openRestyPowVerifyLua, `if ngx.var.scheme == "https" then`) {
t.Fatal("expected verify.lua to only mark the session cookie as Secure for HTTPS requests")
}
}
func TestManagerRollbackRestoresCertFiles(t *testing.T) {
@@ -1012,6 +803,55 @@ func TestManagerApplyReturnsWarningWhenRollbackRecoversRuntime(t *testing.T) {
}
}
func TestManagerApplyStartsSafeFallbackWhenNoRollbackConfigExists(t *testing.T) {
tempDir := t.TempDir()
routePath := filepath.Join(tempDir, "routes.conf")
mainPath := filepath.Join(tempDir, "nginx.conf")
executor := &scriptedExecutor{
testErrors: []error{errors.New("target config failed"), errors.New("rollback config missing"), nil},
}
manager := &Manager{
MainConfigPath: mainPath,
RouteConfigPath: routePath,
OpenrestyObservabilityListen: "127.0.0.1:18081",
Executor: executor,
}
outcome := manager.Apply(context.Background(), "bad-main", "bad-route", nil)
if outcome.Status != ApplyStatusWarning {
t.Fatalf("expected warning apply outcome, got %#v", outcome)
}
if !strings.Contains(outcome.Message, "fallback runtime started") {
t.Fatalf("expected fallback message, got %q", outcome.Message)
}
if executor.testCalls != 3 {
t.Fatalf("expected target, rollback, and fallback tests, got %d", executor.testCalls)
}
mainData, err := os.ReadFile(mainPath)
if err != nil {
t.Fatalf("failed to read main config: %v", err)
}
if !strings.Contains(string(mainData), "OpenFlare: No Valid Configuration") {
t.Fatalf("expected safe fallback main config, got %s", string(mainData))
}
if !strings.Contains(string(mainData), "listen 80 default_server") {
t.Fatalf("expected fallback to listen on port 80, got %s", string(mainData))
}
if !strings.Contains(string(mainData), "listen 127.0.0.1:18081") {
t.Fatalf("expected fallback to expose local stub_status port, got %s", string(mainData))
}
if !strings.Contains(string(mainData), "stub_status;") {
t.Fatalf("expected fallback to expose stub_status, got %s", string(mainData))
}
routeData, err := os.ReadFile(routePath)
if err != nil {
t.Fatalf("failed to read route config: %v", err)
}
if len(routeData) != 0 {
t.Fatalf("expected fallback route config to be empty, got %q", string(routeData))
}
}
func TestManagerCertFileTargetPathRejectsEscapes(t *testing.T) {
manager := &Manager{CertDir: filepath.Join(t.TempDir(), "certs")}
if err := os.MkdirAll(manager.CertDir, 0o755); err != nil {
@@ -1076,8 +916,8 @@ func TestManagerApplyRejectsCertFilePathTraversal(t *testing.T) {
}
func TestObservabilityListenAddress(t *testing.T) {
if got := ObservabilityListenAddress("", 18081); got != "18081" {
t.Fatalf("unexpected docker observability listen address: %s", got)
if got := ObservabilityListenAddress("", 18081); got != "127.0.0.1:18081" {
t.Fatalf("unexpected default observability listen address: %s", got)
}
if got := ObservabilityListenAddress("/usr/local/openresty/nginx/sbin/openresty", 18081); got != "127.0.0.1:18081" {
t.Fatalf("unexpected path observability listen address: %s", got)
@@ -0,0 +1,98 @@
package nginx
const DefaultMimeTypes = `
types {
text/html html htm shtml;
text/css css;
text/xml xml;
image/gif gif;
image/jpeg jpeg jpg;
application/javascript js;
application/atom+xml atom;
application/rss+xml rss;
text/mathml mml;
text/plain txt;
text/vnd.sun.j2me.app-descriptor jad;
text/vnd.wap.wml wml;
text/x-component htc;
image/png png;
image/svg+xml svg svgz;
image/tiff tif tiff;
image/vnd.wap.wbmp wbmp;
image/webp webp;
image/x-icon ico;
image/x-jng jng;
image/x-ms-bmp bmp;
application/font-woff woff;
application/java-archive jar war ear;
application/json json;
application/mac-binhex40 hqx;
application/msword doc;
application/pdf pdf;
application/postscript ps eps ai;
application/rtf rtf;
application/vnd.apple.mpegurl m3u8;
application/vnd.google-earth.kml+xml kml;
application/vnd.google-earth.kmz kmz;
application/vnd.ms-excel xls;
application/vnd.ms-fontobject eot;
application/vnd.ms-powerpoint ppt;
application/vnd.oasis.opendocument.graphics odg;
application/vnd.oasis.opendocument.presentation odp;
application/vnd.oasis.opendocument.spreadsheet ods;
application/vnd.oasis.opendocument.text odt;
application/vnd.openxmlformats-officedocument.presentationml.presentation
pptx;
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
xlsx;
application/vnd.openxmlformats-officedocument.wordprocessingml.document
docx;
application/vnd.wap.wmlc wmlc;
application/x-7z-compressed 7z;
application/x-cocoa cco;
application/x-java-archive-diff jardiff;
application/x-java-jnlp-file jnlp;
application/x-makeself run;
application/x-perl pl pm;
application/x-pilot prc pdb;
application/x-rar-compressed rar;
application/x-redhat-package-manager rpm;
application/x-sea sea;
application/x-shockwave-flash swf;
application/x-stuffit sit;
application/x-tcl tcl tk;
application/x-x509-ca-cert der pem crt;
application/x-xpinstall xpi;
application/xhtml+xml xhtml;
application/xspf+xml xspf;
application/zip zip;
application/octet-stream bin exe dll;
application/octet-stream deb;
application/octet-stream dmg;
application/octet-stream iso img;
application/octet-stream msi msp msm;
audio/midi mid midi kar;
audio/mpeg mp3;
audio/ogg ogg;
audio/x-m4a m4a;
audio/x-realaudio ra;
video/3gpp 3gpp 3gp;
video/mp2t ts;
video/mp4 mp4;
video/mpeg mpeg mpg;
video/quicktime mov;
video/webm webm;
video/x-flv flv;
video/x-m4v m4v;
video/x-mng mng;
video/x-ms-asf asx asf;
video/x-ms-wmv wmv;
video/x-msvideo avi;
}
`
@@ -0,0 +1,554 @@
package nginx
import (
"embed"
"openflare-agent/internal/protocol"
"path/filepath"
"strings"
)
//go:embed pow_static
var powStaticFS embed.FS
const openRestyPowRuntimeLua = `local _M = {}
function _M.check()
local source = debug.getinfo(1, "S").source or ""
if string.sub(source, 1, 1) == "@" then
local script_path = string.sub(source, 2)
local base_dir = string.match(script_path, "^(.*)/pow/[^/]+%.lua$")
if base_dir and base_dir ~= "" then
package.path = base_dir .. "/?.lua;" .. base_dir .. "/?/init.lua;" .. package.path
end
end
local cjson = require "cjson.safe"
local policy = require "pow.policy"
local pow_config_dict = ngx.shared.openflare_pow_config
local pow_sessions = ngx.shared.openflare_pow_sessions
local function session_cookie(value, ttl)
local cookie = "__openflare_pow=" .. value .. "; Path=/; HttpOnly; SameSite=Lax; Max-Age=" .. tostring(ttl)
if ngx.var.scheme == "https" then
cookie = cookie .. "; Secure"
end
return cookie
end
-- Lazy-load pow_config from file; reload when content changes
local function load_pow_config()
local config_paths = {
"__OPENFLARE_RUNTIME_CONFIG_DIR__/pow_config.json",
"/etc/nginx/openflare-lua/pow_config.json",
"/usr/local/openresty/nginx/conf/pow_config.json"
}
for _, config_path in ipairs(config_paths) do
local f = io.open(config_path, "r")
if f then
local content = f:read("*a")
f:close()
local current_hash = ngx.md5(content or "")
if current_hash == pow_config_dict:get("_config_hash") then
return
end
-- Clear old domain entries
local old_keys = pow_config_dict:get("_domain_keys")
if old_keys then
for domain in string.gmatch(old_keys, "[^\n]+") do
pow_config_dict:delete(domain)
end
end
local domain_keys = {}
if content and content ~= "" and content ~= "{}" then
local ok, entries = pcall(cjson.decode, content)
if ok and entries and type(entries) == "table" then
for _, entry in ipairs(entries) do
if entry.domains then
for _, domain in ipairs(entry.domains) do
pow_config_dict:set(domain, cjson.encode(entry), 0)
domain_keys[#domain_keys+1] = domain
end
end
end
end
end
pow_config_dict:set("_domain_keys", table.concat(domain_keys, "\n"), 0)
pow_config_dict:set("_config_hash", current_hash, 0)
return
end
end
end
load_pow_config()
local host = ngx.var.host
if not host or host == "" then
return
end
local config_raw = pow_config_dict:get(host)
if not config_raw then
return
end
local ok, route_config = pcall(cjson.decode, config_raw)
if not ok or not route_config then
return
end
if not route_config.enabled then
return
end
local config = route_config.config or {}
local session_ttl = config.session_ttl or 600
local uri = ngx.var.uri or ""
local ua = ngx.var.http_user_agent or ""
local remote_ip = ngx.var.remote_addr or ""
-- Check whitelist: if matched, skip PoW
local whitelist = config.whitelist or {}
if policy.match_any(remote_ip, ua, uri, whitelist) then
return
end
-- Check blacklist: if matched, require PoW
local blacklist = config.blacklist or {}
local has_blacklist = policy.has_entries(blacklist)
local need_pow = false
if has_blacklist then
need_pow = policy.match_any(remote_ip, ua, uri, blacklist)
else
-- No blacklist means all non-whitelisted need PoW
need_pow = true
end
if not need_pow then
return
end
-- Check valid session cookie
local cookie_val = ngx.var["cookie___openflare_pow"]
if cookie_val and cookie_val ~= "" then
local session_key = host .. ":" .. cookie_val
local session_data = pow_sessions:get(session_key)
if session_data then
pow_sessions:set(session_key, "1", session_ttl)
ngx.header["Set-Cookie"] = session_cookie(cookie_val, session_ttl)
return
end
end
-- If requesting the challenge API endpoints, let them through (handled by content_by_lua)
local anubis_api_prefix = "/.within.website/x/cmd/anubis/api/"
local anubis_static_prefix = "/.within.website/x/cmd/anubis/static/"
if string.sub(uri, 1, #anubis_api_prefix) == anubis_api_prefix then
return
end
if string.sub(uri, 1, #anubis_static_prefix) == anubis_static_prefix then
return
end
-- Render the challenge page through an internal redirect so the browser stays
-- on the originally requested URL instead of seeing a 302 hop.
ngx.req.set_uri_args({
redir = ngx.var.scheme .. "://" .. host .. uri .. (ngx.var.args and ("?" .. ngx.var.args) or ""),
host = host
})
return ngx.exec("/.within.website/x/cmd/anubis/api/make-challenge")
end
return _M
`
const openRestyPowCheckLua = `local source = debug.getinfo(1, "S").source or ""
if string.sub(source, 1, 1) == "@" then
local script_path = string.sub(source, 2)
local base_dir = string.match(script_path, "^(.*)/pow/[^/]+%.lua$")
if base_dir and base_dir ~= "" then
package.path = base_dir .. "/?.lua;" .. base_dir .. "/?/init.lua;" .. package.path
end
end
return require("pow.runtime").check()
`
const openRestyPowChallengeLua = `local cjson = require "cjson.safe"
local pow_config_dict = ngx.shared.openflare_pow_config
local pow_challenges = ngx.shared.openflare_pow_challenges
local function generate_entropy()
local pieces = {
tostring(ngx.now()),
tostring(ngx.worker.pid()),
tostring(math.random()),
ngx.var.remote_addr or "",
ngx.var.http_user_agent or "",
ngx.var.request_id or "",
}
return table.concat(pieces, ":")
end
local args = ngx.req.get_uri_args()
local host = args["host"] or ngx.var.host or ""
local redir = args["redir"] or ""
local config_raw = pow_config_dict:get(host)
if not config_raw then
ngx.status = 403
ngx.say("PoW not configured for this host")
return
end
local ok, route_config = pcall(cjson.decode, config_raw)
if not ok or not route_config or not route_config.enabled then
ngx.status = 403
ngx.say("PoW not enabled for this host")
return
end
local config = route_config.config or {}
local difficulty = config.difficulty or 4
local algorithm = config.algorithm or "fast"
local challenge_ttl = config.challenge_ttl or 300
local session_ttl = config.session_ttl or 600
-- Generate challenge data without depending on ngx.random_bytes, which is not
-- available in every OpenResty runtime build.
local entropy = generate_entropy()
local challenge_id = ngx.md5(entropy .. ":id")
local challenge_data = ngx.md5(entropy .. ":data-a") .. ngx.md5(entropy .. ":data-b")
-- Store challenge
local challenge_info = cjson.encode({
data = challenge_data,
difficulty = difficulty,
host = host,
redir = redir,
session_ttl = session_ttl
})
pow_challenges:set(challenge_id, challenge_info, challenge_ttl)
local static_prefix = "/.within.website/x/cmd/anubis/static/"
local title = "Making sure you're not a bot!"
local lang = "en"
ngx.header.content_type = "text/html; charset=utf-8"
ngx.say([[<!DOCTYPE html>
<html lang="]] .. lang .. [[">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>]] .. title .. [[</title>
<link rel="stylesheet" href="]] .. static_prefix .. [[css/xess.css">
<style>
body,html{height:100%;display:flex;justify-content:center;align-items:center;margin-left:auto;margin-right:auto}
.centered-div{text-align:center}
#status{font-variant-numeric:tabular-nums}
#progress{display:none;width:min(20rem,90%);height:2rem;border-radius:1rem;overflow:hidden;margin:1rem 0 2rem;outline-offset:2px;outline:#b16286 solid 4px}
.bar-inner{background-color:#b16286;height:100%;width:0;transition:width .25s ease-in}
</style>
<script id="anubis_version" type="application/json">"openflare-pow"</script>
<script id="anubis_challenge" type="application/json">]] .. cjson.encode({
challenge = {
id = challenge_id,
randomData = challenge_data,
method = algorithm
},
rules = {
difficulty = difficulty,
algorithm = algorithm
}
}) .. [[</script>
<script id="anubis_base_prefix" type="application/json">""</script>
<script id="anubis_public_url" type="application/json">"__openflare_internal__"</script>
</head>
<body id="top">
<main>
<h1 id="title" class="centered-div">]] .. title .. [[</h1>
<div class="centered-div">
<img id="image" style="width:100%;max-width:256px;" src="]] .. static_prefix .. [[img/pensive.webp?cacheBuster=openflare-pow">
<p id="status">Loading...</p>
<p>This site is protected by a Proof-of-Work challenge. Your browser will solve a small puzzle before the upstream response is shown.</p>
<div id="progress" role="progressbar" aria-labelledby="status"><div class="bar-inner"></div></div>
<details>
<summary>Why am I seeing this?</summary>
<p>OpenFlare is asking your browser to complete a lightweight computation to distinguish normal browser traffic from automated abuse. This should finish automatically.</p>
</details>
<noscript><p>JavaScript is required to pass this verification. Please enable JavaScript and reload.</p></noscript>
</div>
</main>
<script type="module" src="]] .. static_prefix .. [[js/main.mjs"></script>
</body>
</html>]])
`
const openRestyPowVerifyLua = `local cjson = require "cjson.safe"
local pow_challenges = ngx.shared.openflare_pow_challenges
local pow_sessions = ngx.shared.openflare_pow_sessions
local args = ngx.req.get_uri_args()
local challenge_id = args["id"] or ""
local response = args["response"] or ""
local nonce_str = args["nonce"] or ""
local redir = args["redir"] or ""
local elapsed = args["elapsedTime"] or ""
if challenge_id == "" or response == "" or nonce_str == "" then
ngx.status = 400
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({error = "missing parameters"}))
return
end
local nonce = tonumber(nonce_str)
if not nonce then
ngx.status = 400
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({error = "invalid nonce"}))
return
end
-- Get stored challenge
local challenge_raw = pow_challenges:get(challenge_id)
if not challenge_raw then
ngx.status = 410
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({error = "challenge expired or not found"}))
return
end
local ok, challenge_info = pcall(cjson.decode, challenge_raw)
if not ok or not challenge_info then
ngx.status = 500
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({error = "invalid challenge data"}))
return
end
local challenge_data = challenge_info.data or ""
local difficulty = challenge_info.difficulty or 4
local host = challenge_info.host or ngx.var.host or ""
local session_ttl = challenge_info.session_ttl or 600
-- Compute SHA-256(challenge_data + nonce)
local calc_string = challenge_data .. tostring(math.floor(nonce))
local calculated = ngx.sha1_bin ~= nil and "" or ""
-- Use resty.sha256 for proper SHA-256
local sha256 = require "resty.sha256"
local str = require "resty.string"
local hasher = sha256:new()
hasher:update(calc_string)
local hash_bytes = hasher:final()
local hash_hex = str.to_hex(hash_bytes)
-- Verify hash matches response
if hash_hex ~= string.lower(response) then
ngx.status = 403
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({error = "hash mismatch"}))
return
end
-- Verify difficulty (leading zeros in hex)
local prefix = string.rep("0", difficulty)
if string.sub(hash_hex, 1, difficulty) ~= prefix then
ngx.status = 403
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({error = "insufficient difficulty"}))
return
end
-- Invalidate challenge (prevent replay)
pow_challenges:delete(challenge_id)
-- Generate session token
local session_token = str.to_hex(ngx.sha1_bin(challenge_id .. ngx.now() .. tostring(ngx.worker.pid())))
-- Store session
pow_sessions:set(host .. ":" .. session_token, "1", session_ttl)
-- Set cookie. Secure cookies are not sent over HTTP, so only add Secure when
-- the current request itself is HTTPS.
local cookie = "__openflare_pow=" .. session_token .. "; Path=/; HttpOnly; SameSite=Lax; Max-Age=" .. tostring(session_ttl)
if ngx.var.scheme == "https" then
cookie = cookie .. "; Secure"
end
ngx.header["Set-Cookie"] = cookie
if redir ~= "" then
return ngx.redirect(redir)
end
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({ok = true}))
`
const openRestyPowPolicyLua = `local M = {}
local function match_ip(remote_ip, ips)
if not ips or #ips == 0 then return false end
for _, ip in ipairs(ips) do
if ip == remote_ip then
return true
end
end
return false
end
local function match_cidr(remote_ip, cidrs)
if not cidrs or #cidrs == 0 then return false end
for _, cidr in ipairs(cidrs) do
local m, err = ngx.re.match(cidr, "^(\\\\d{1,3}\\\\.\\\\d{1,3}\\\\.\\\\d{1,3}\\\\.\\\\d{1,3})/(\\\\d{1,2})$")
if m then
local mask_bits = tonumber(m[2])
if mask_bits and mask_bits >= 0 and mask_bits <= 32 then
local function ip_to_num(ip_str)
local parts = {}
for part in string.gmatch(ip_str, "%d+") do
parts[#parts+1] = tonumber(part) or 0
end
if #parts ~= 4 then return 0 end
return parts[1]*16777216 + parts[2]*65536 + parts[3]*256 + parts[4]
end
local remote_num = ip_to_num(remote_ip)
local net_num = ip_to_num(m[1])
if mask_bits == 0 then
return true
end
local mask = math.floor(2^(32 - mask_bits))
mask = 4294967296 - mask
if bit.band(remote_num, mask) == bit.band(net_num, mask) then
return true
end
end
end
end
return false
end
local function match_path(uri, patterns)
if not patterns or #patterns == 0 then return false end
for _, pattern in ipairs(patterns) do
local ok, match = pcall(ngx.re.match, uri, "^" .. ngx.re.gsub(pattern, "([%^%$%(%)%%%.%[%]%+%-%?])", function(c)
if c == "*" then return ".*" end
return "%" .. c
end) .. "$", "i")
if ok and match then
return true
end
end
return false
end
local function match_path_regex(uri, patterns)
if not patterns or #patterns == 0 then return false end
for _, pattern in ipairs(patterns) do
local ok, match = pcall(ngx.re.match, uri, pattern)
if ok and match then
return true
end
end
return false
end
local function match_ua(ua, patterns)
if not patterns or #patterns == 0 then return false end
for _, pattern in ipairs(patterns) do
if ua and string.find(ua, pattern, 1, true) then
return true
end
end
return false
end
function M.match_any(remote_ip, ua, uri, list)
if not list then return false end
if match_ip(remote_ip, list.ips) then return true end
if match_cidr(remote_ip, list.ip_cidrs) then return true end
if match_path(uri, list.paths) then return true end
if match_path_regex(uri, list.path_regexes) then return true end
if match_ua(ua, list.user_agents) then return true end
return false
end
function M.has_entries(list)
if not list then return false end
return (#(list.ips or {}) + #(list.ip_cidrs or {}) + #(list.paths or {}) + #(list.path_regexes or {}) + #(list.user_agents or {})) > 0
end
return M
`
func ManagedPowLuaFiles() []protocol.SupportFile {
return []protocol.SupportFile{
{Path: "pow/runtime.lua", Content: openRestyPowRuntimeLua},
{Path: "pow/check.lua", Content: openRestyPowCheckLua},
{Path: "pow/challenge.lua", Content: openRestyPowChallengeLua},
{Path: "pow/verify.lua", Content: openRestyPowVerifyLua},
{Path: "pow/policy.lua", Content: openRestyPowPolicyLua},
}
}
func ManagedPowStaticFiles() ([]protocol.SupportFile, error) {
var files []protocol.SupportFile
entries, err := powStaticFS.ReadDir("pow_static")
if err != nil {
return nil, err
}
var walk func(dir string) error
walk = func(dir string) error {
entries, err := powStaticFS.ReadDir(dir)
if err != nil {
return err
}
for _, entry := range entries {
fullPath := filepath.Join(dir, entry.Name())
if entry.IsDir() {
if err := walk(fullPath); err != nil {
return err
}
continue
}
data, err := powStaticFS.ReadFile(fullPath)
if err != nil {
return err
}
// Convert pow_static/css/xess.css -> pow/static/css/xess.css
relPath := strings.TrimPrefix(fullPath, "pow_static/")
files = append(files, protocol.SupportFile{
Path: "pow/static/" + relPath,
Content: string(data),
})
}
return nil
}
for _, entry := range entries {
fullPath := filepath.Join("pow_static", entry.Name())
if entry.IsDir() {
if err := walk(fullPath); err != nil {
return nil, err
}
} else {
data, err := powStaticFS.ReadFile(fullPath)
if err != nil {
return nil, err
}
relPath := strings.TrimPrefix(fullPath, "pow_static/")
files = append(files, protocol.SupportFile{
Path: "pow/static/" + relPath,
Content: string(data),
})
}
}
return files, nil
}
@@ -0,0 +1,7 @@
@font-face {
font-family: "Podkova";
font-style: normal;
font-weight: 400 800;
font-display: swap;
src: url("podkova.woff2") format("woff2");
}
@@ -0,0 +1,149 @@
:root {
--body-sans-font: Geist, sans-serif;
--body-preformatted-font: Iosevka Curly Iaso, monospace;
--body-title-font: Podkova, serif;
--background: #1d2021;
--text: #f9f5d7;
--text-selection: #d3869b;
--preformatted-background: #3c3836;
--link-foreground: #b16286;
--link-background: #282828;
--blockquote-border-left: 1px solid #bdae93;
--progress-bar-outline: #b16286 solid 4px;
--progress-bar-fill: #b16286;
}
@media (prefers-color-scheme: light) {
:root {
--background: #f9f5d7;
--text: #1d2021;
--text-selection: #d3869b;
--preformatted-background: #ebdbb2;
--link-foreground: #b16286;
--link-background: #fbf1c7;
--blockquote-border-left: 1px solid #655c54;
}
}
@font-face {
font-family: "Geist";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("./static/geist.woff2") format("woff2");
}
@font-face {
font-family: "Podkova";
font-style: normal;
font-weight: 400 800;
font-display: swap;
src: url("./static/podkova.woff2") format("woff2");
}
@font-face {
font-family: "Iosevka Curly";
font-style: monospace;
font-display: swap;
src: url("./static/iosevka-curly.woff2") format("woff2");
}
main {
font-family: var(--body-sans-font);
max-width: 50rem;
padding: 2rem;
margin: auto;
}
::selection {
background: var(--text-selection);
}
body {
background: var(--background);
color: var(--text);
}
body,
html {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
margin-left: auto;
margin-right: auto;
}
.centered-div {
text-align: center;
}
#status {
font-variant-numeric: tabular-nums;
}
.centered-div {
text-align: center;
}
#status {
font-variant-numeric: tabular-nums;
}
#progress {
display: none;
width: min(20rem, 90%);
height: 2rem;
border-radius: 1rem;
overflow: hidden;
margin: 1rem 0 2rem;
outline-offset: 2px;
outline: var(--progress-bar-outline);
}
.bar-inner {
background-color: var(--progress-bar-fill);
height: 100%;
width: 0;
transition: width 0.25s ease-in;
}
@media (prefers-reduced-motion: no-preference) {
.bar-inner {
transition: width 0.25s ease-in;
}
}
pre {
background-color: var(--preformatted-background);
padding: 1em;
border: 0;
font-family: var(--body-preformatted-font);
}
a,
a:active,
a:visited {
color: var(--link-foreground);
background-color: var(--link-background);
}
h1,
h2,
h3,
h4,
h5 {
margin-bottom: 0.1rem;
font-family: var(--body-title-font);
}
blockquote {
border-left: var(--blockquote-border-left);
margin: 0.5em 10px;
padding: 0.5em 10px;
}
footer {
text-align: center;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

@@ -0,0 +1,32 @@
/*
@licstart The following is the entire license notice for the
JavaScript code in this page.
Copyright (c) 2025 Xe Iaso <xe.iaso@techaro.lol>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
Includes code from https://github.com/aws/aws-sdk-js-crypto-helpers which is
used under the terms of the Apache 2 license.
@licend The above is the entire license notice
for the JavaScript code in this page.
*/
(()=>{var k=()=>navigator.hardwareConcurrency!==void 0?navigator.hardwareConcurrency:1;function n(c,b,w=5,e=null,g,u=Math.trunc(Math.max(k()/2,1))){console.debug("fast algo");let s="purejs";return window.isSecureContext&&(s="webcrypto"),(navigator.userAgent.includes("Firefox")||navigator.userAgent.includes("Goanna"))&&(console.log("Firefox detected, using pure-JS fallback"),s="purejs"),new Promise((p,l)=>{let m=`${c.basePrefix}/.within.website/x/cmd/anubis/static/js/worker/sha256-${s}.mjs?cacheBuster=${c.version}`,f=[],d=!1,a=()=>{console.log("PoW aborted"),i(),l(new DOMException("Aborted","AbortError"))},i=()=>{d||(d=!0,f.forEach(r=>r.terminate()),e?.removeEventListener("abort",a))};if(e!=null){if(e.aborted)return a();e.addEventListener("abort",a,{once:!0})}for(let r=0;r<u;r++){let t=new Worker(m);t.onmessage=o=>{typeof o.data=="number"?g?.(o.data):(i(),p(o.data))},t.onerror=o=>{i(),l(o)},t.postMessage({data:b,difficulty:w,nonce:r,threads:u}),f.push(t)}})}var P={fast:n,slow:n};})();
//# sourceMappingURL=index.mjs.map
@@ -0,0 +1,32 @@
/*
@licstart The following is the entire license notice for the
JavaScript code in this page.
Copyright (c) 2025 Xe Iaso <xe.iaso@techaro.lol>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
Includes code from https://github.com/aws/aws-sdk-js-crypto-helpers which is
used under the terms of the Apache 2 license.
@licend The above is the entire license notice
for the JavaScript code in this page.
*/
(()=>{var I=()=>navigator.hardwareConcurrency!==void 0?navigator.hardwareConcurrency:1;function _(e,n,s=5,o=null,i,u=Math.trunc(Math.max(I()/2,1))){console.debug("fast algo");let a="purejs";return window.isSecureContext&&(a="webcrypto"),(navigator.userAgent.includes("Firefox")||navigator.userAgent.includes("Goanna"))&&(console.log("Firefox detected, using pure-JS fallback"),a="purejs"),new Promise((E,x)=>{let M=`${e.basePrefix}/.within.website/x/cmd/anubis/static/js/worker/sha256-${a}.mjs?cacheBuster=${e.version}`,p=[],d=!1,b=()=>{console.log("PoW aborted"),h(),x(new DOMException("Aborted","AbortError"))},h=()=>{d||(d=!0,p.forEach(c=>c.terminate()),o?.removeEventListener("abort",b))};if(o!=null){if(o.aborted)return b();o.addEventListener("abort",b,{once:!0})}for(let c=0;c<u;c++){let g=new Worker(M);g.onmessage=m=>{typeof m.data=="number"?i?.(m.data):(h(),E(m.data))},g.onerror=m=>{h(),x(m)},g.postMessage({data:n,difficulty:s,nonce:c,threads:u}),p.push(g)}})}var j={fast:_,slow:_};var v=(e="",n={})=>{let s=new URL(e,window.location.href);return Object.entries(n).forEach(([o,i])=>s.searchParams.set(o,i)),s.toString()},L=e=>{let n=document.getElementById(e);return n===null?null:JSON.parse(n.textContent)},k=(e,n,s)=>v(`${s}/.within.website/x/cmd/anubis/static/img/${e}.webp`,{cacheBuster:n});var W=async()=>document.documentElement.lang,S=async e=>{let n=L("anubis_base_prefix");if(n!==null)try{return await(await fetch(`${n}/.within.website/x/cmd/anubis/static/locales/${e}.json`)).json()}catch(s){if(console.warn(`Failed to load translations for ${e}, falling back to English`),e!=="en")return await S("en");throw s}},C=()=>{let e=L("anubis_public_url");if(e!==null)return e&&window.location.href.startsWith(e)?new URLSearchParams(window.location.search).get("redir"):window.location.href},$={},D,A=async()=>{D=await W(),$=await S(D)},r=e=>$[`js_${e}`]||$[e]||e;(async()=>{await A();let e=[{name:"Web Workers",msg:r("web_workers_error"),value:window.Worker},{name:"Cookies",msg:r("cookies_error"),value:navigator.cookieEnabled}],n=document.getElementById("status"),s=document.getElementById("image"),o=document.getElementById("title"),i=document.getElementById("progress"),u=L("anubis_version"),a=L("anubis_base_prefix"),E=document.querySelector("details"),x=!1;E&&E.addEventListener("toggle",()=>{E.open&&(x=!0)});let M=({titleMsg:l,statusMsg:f,imageSrc:w})=>{o.innerHTML=l,n.innerHTML=f,s.src=w,i.style.display="none"};n.innerHTML=r("calculating");for(let{value:l,name:f,msg:w}of e)if(!l){M({titleMsg:`${r("missing_feature")} ${f}`,statusMsg:w,imageSrc:k("reject",u,a)});return}let{challenge:p,rules:d}=L("anubis_challenge"),b=j[d.algorithm];if(!b){M({titleMsg:r("challenge_error"),statusMsg:r("challenge_error_msg"),imageSrc:k("reject",u,a)});return}n.innerHTML=`${r("calculating_difficulty")} ${d.difficulty}, `,i.style.display="inline-block";let h=document.createTextNode(`${r("speed")} 0kH/s`);n.appendChild(h);let c=0,g=!1,m=Math.pow(16,-d.difficulty);try{let l=Date.now(),{hash:f,nonce:w}=await b({basePrefix:a,version:u},p.randomData,d.difficulty,null,t=>{let y=Date.now()-l;y-c>1e3&&(c=y,h.data=`${r("speed")} ${(t/y).toFixed(3)}kH/s`);let T=Math.pow(1-m,t),P=(1-Math.pow(T,2))*100;i["aria-valuenow"]=P,i.firstElementChild!==null&&(i.firstElementChild.style.width=`${P}%`),T<.1&&!g&&(n.append(document.createElement("br"),document.createTextNode(r("verification_longer"))),g=!0)}),H=Date.now();if(console.log({hash:f,nonce:w}),x){let y=function(){let T=C();window.location.replace(v(`${a}/.within.website/x/cmd/anubis/api/pass-challenge`,{id:p.id,response:f,nonce:w,redir:T,elapsedTime:H-l}))},t=document.getElementById("progress");t.style.display="flex",t.style.alignItems="center",t.style.justifyContent="center",t.style.height="2rem",t.style.borderRadius="1rem",t.style.cursor="pointer",t.style.background="#b16286",t.style.color="white",t.style.fontWeight="bold",t.style.outline="4px solid #b16286",t.style.outlineOffset="2px",t.style.width="min(20rem, 90%)",t.style.margin="1rem auto 2rem",t.innerHTML=r("finished_reading"),t.onclick=y,setTimeout(y,3e4)}else{let t=C();window.location.replace(v(`${a}/.within.website/x/cmd/anubis/api/pass-challenge`,{id:p.id,response:f,nonce:w,redir:t,elapsedTime:H-l}))}}catch(l){M({titleMsg:r("calculation_error"),statusMsg:`${r("calculation_error_msg")} ${l.message}`,imageSrc:k("reject",u,a)})}})();})();
//# sourceMappingURL=main.mjs.map

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