Compare commits

...

92 Commits

Author SHA1 Message Date
ryan e43312d4c6 [优化] 增加 IP 处理逻辑并优化心跳负载 2026-06-01 21:16:14 +08:00
ryan 92df7d5c84 [优化] 增加中继 Vhost HTTP 端口支持并优化相关逻辑 2026-06-01 21:11:25 +08:00
ryan 9632b4e3b8 [优化] 增加中继 Vhost HTTP 端口支持并优化相关逻辑 2026-06-01 21:02:15 +08:00
ryan 3b979eb5d5 [优化] 修复隧道相关配置支持 2026-06-01 20:34:02 +08:00
ryan 2635a47d29 [优化] 优化脚本 2026-06-01 20:07:50 +08:00
ryan e00d67f2d9 [优化] 压缩脚本到 V16 2026-06-01 19:36:52 +08:00
ryan 581822d905 [优化] 修复 Agent CI 2026-06-01 19:04:01 +08:00
ryan b5ebfff19b [优化] Action 稳定性提升 2026-06-01 18:53:21 +08:00
ryan eed227b999 [优化] 修复迁移 2026-06-01 18:43:10 +08:00
ryan 8f08a962e6 [优化] 修复迁移 2026-06-01 17:50:49 +08:00
ryan d3ce26414c [优化] 移除数据库迁移中的 ApplyCurrentSchema 调用 2026-06-01 17:32:30 +08:00
ryan 663da01bda [优化] 修复数据库迁移问题 2026-06-01 17:15:17 +08:00
ryan df63b0113a [优化] 添加 OpenFlared API 支持,增强心跳和配置管理功能 2026-06-01 16:41:46 +08:00
ryan d09e64ddc6 [优化] 修复 2026-06-01 16:33:22 +08:00
ryan b968043117 [优化] 修复 2026-06-01 16:20:19 +08:00
ryan 31d10195ca [优化] 提升WS连接稳定性 2026-06-01 16:02:15 +08:00
ryan 76f3428f5d [优化] 字段修复 2026-06-01 16:02:02 +08:00
ryan 9de33f7064 [优化] 数据库结构优化 2026-06-01 15:32:08 +08:00
ryan fe13db95c2 [优化] Relay节点 IP 检测功能,自动设置 NodeIP 配置 2026-06-01 14:42:50 +08:00
ryan 6ddd2da2e8 [优化] 重构节点详情页面 2026-06-01 14:39:33 +08:00
ryan 054dc1a8a8 [优化] 添加 Relay frps 连接和代理计数字段,重构相关逻辑以支持监控和可观测性 2026-06-01 14:31:42 +08:00
ryan 394e3c4855 [优化] 更新数据库迁移逻辑,添加 v19 版本验证,重构隧道相关表结构 2026-06-01 14:06:36 +08:00
ryan af36676e2e Merge branch 'doc' 2026-06-01 14:01:40 +08:00
ryan 6fa31cafc7 [优化] 更新节点类型支持,添加隧道客户端,重构相关路由和配置 2026-06-01 14:01:05 +08:00
ryan a8e8a940a0 [优化] 优化 WAF IP 组同步功能及相关文档更新 2026-06-01 13:55:04 +08:00
ryan a092935623 [优化] 文档优化 2026-06-01 12:22:16 +08:00
ryan 5af13d0709 [优化] 修复 docker 2026-06-01 12:03:09 +08:00
ryan 9a2616dc0e [优化] 文档 2026-06-01 11:48:55 +08:00
ryan c4e9e94117 [优化] 修复 Docker 启动 2026-06-01 11:35:15 +08:00
ryan fce2e014e5 [修复] 节点类型字段名不匹配:前端 type 改为 node_type 对齐后端 JSON tag 2026-06-01 11:26:01 +08:00
ryan 7372ac230b [优化] 优化界面 2026-06-01 11:11:48 +08:00
ryan 77bdb8bf0e [优化] 优化界面 2026-06-01 11:05:00 +08:00
ryan fd745d33cb [优化] 更新 InlineMessage 组件,支持动态反馈和静态警告显示 2026-06-01 10:45:24 +08:00
ryan 65f899d334 [新增] 集成 Sonner 通知库,添加 Toaster 组件并在 InlineMessage 中使用 2026-06-01 10:45:24 +08:00
ryan f034b73a47 [新增] 添加数据库迁移和 GORM 模型验证测试,确保所有模型均已注册 2026-06-01 10:33:59 +08:00
ryan bd7f008322 [新增] 添加自动 IP 组规则的抓取记录功能,支持查看已抓取 IP 列表及其到期状态 2026-06-01 10:33:59 +08:00
ryan 2dc7e72621 [优化] 更新 go.mod 和 go.sum,移除不必要的依赖并添加新的依赖项 2026-06-01 10:11:51 +08:00
ryan 2d542733f9 [优化] 更新清理预发布标签的脚本,支持删除未绑定的正式标签和悬空的 GitHub 发布 2026-06-01 10:06:08 +08:00
ryan c677edba06 [新增] action 2026-06-01 09:57:41 +08:00
ryan b827baf19f [新增] 添加自动 IP 组规则测试功能,支持在保存前验证 Expr 规则命中情况 2026-06-01 09:50:43 +08:00
ryan 73beedfc09 [优化] 调整 openflared 和 openflare_relay 基础镜像为 frp 官方镜像 2026-06-01 09:37:54 +08:00
ryan bc1b861841 [优化] 添加自动 IP 组功能,支持按 Expr 规则聚合请求日志并更新 IP 列表 2026-06-01 09:34:33 +08:00
ryan dfb3972b15 [优化] DockerFile 2026-06-01 09:34:17 +08:00
ryan 330771e7c7 [新增] 内网穿透隧道前端管理与代理规则绑定支持 (P5) 2026-06-01 09:20:45 +08:00
ryan 9ded8c71da [优化] Phase4 2026-06-01 09:03:55 +08:00
ryan 77ad3ea7e3 [优化] 代码优化 2026-06-01 09:03:35 +08:00
ryan 95d7045b4a [优化] 添加 WAF IP 组功能,包括 CRUD 接口和前端页面支持 2026-06-01 08:53:03 +08:00
ryan d5f46138d5 [优化] Phase3 2026-06-01 08:47:09 +08:00
ryan 4196343ad3 [优化] Phase2 2026-06-01 08:38:31 +08:00
ryan 78047d1b38 [优化] 更新 docker-image.yml,调整浮动标签逻辑以支持 beta 版本 2026-05-31 22:16:49 +08:00
ryan c2bd416daf [优化] 更新 README.md,添加 BETA 版本警告信息 2026-05-31 22:10:39 +08:00
ryan 6e5d49c988 [优化] 更新 README.md,添加 BETA 版本警告信息 2026-05-31 22:10:23 +08:00
ryan 9da1ce8456 [优化] 移除清理预发布标签工作流中的确认输入 2026-05-31 22:04:06 +08:00
ryan 9f9cbd4ede [优化] 移除清理预发布标签工作流中的确认输入 2026-05-31 22:02:49 +08:00
ryan da1409fdac [优化] 优化界面 2026-05-31 21:58:30 +08:00
ryan 174198c283 [修复] 更新 OpenResty 模板和 Lua 逻辑以增强可读性和稳定性 2026-05-31 21:49:43 +08:00
ryan 796bf1c22f [优化] 添加错误日志路径占位符并更新相关逻辑 2026-05-31 21:26:45 +08:00
ryan 80dd5f8b31 [优化] 更新 Lua 包路径检查逻辑以避免重复添加 2026-05-31 21:17:36 +08:00
ryan 14d41ad807 [优化] 代码优化 2026-05-31 21:03:41 +08:00
ryan fe2414ead5 [优化] 代码优化 2026-05-31 20:48:15 +08:00
ryan 649287a775 [优化] 代码优化 2026-05-31 20:47:03 +08:00
ryan 2514e7edc4 [优化] 代码优化 2026-05-31 20:31:57 +08:00
ryan 7ab11154e3 [优化] 代码优化 2026-05-31 20:29:15 +08:00
ryan 97c10b8d0b [优化] 代码优化 2026-05-31 20:21:46 +08:00
ryan ceae693a20 [优化] 代码优化 2026-05-31 20:14:19 +08:00
ryan edb356f40e [优化] 代码优化 2026-05-31 20:09:40 +08:00
ryan 81ba309650 [优化] 代码优化 2026-05-31 20:05:35 +08:00
ryan 5612403d48 [优化] 代码优化 2026-05-31 20:02:01 +08:00
ryan 4775e5cb73 [优化] 代码优化 2026-05-31 19:59:09 +08:00
ryan bc3d9ee285 [优化] 代码优化 2026-05-31 19:54:17 +08:00
ryan e654441127 [优化] 配置渲染从 Server 转移到 Agent 2026-05-31 19:51:25 +08:00
ryan a987c0d681 [优化] 优化界面 2026-05-31 15:31:18 +08:00
ryan a85919fd9e [优化] 更新文档 2026-05-31 15:24:02 +08:00
ryan 4cb8928e4e [优化] 移除 Turnstile 相关功能和配置 2026-05-31 15:22:30 +08:00
ryan 57616626fd [优化] 优化代码 2026-05-31 14:53:04 +08:00
ryan 449d0a5c5b [优化] 更新文档 2026-05-31 14:52:37 +08:00
ryan 1c89db8ffa [优化] 补充测试 2026-05-31 14:49:21 +08:00
ryan 46f49cc349 [优化] 重构数据库迁移逻辑,添加版本管理和验证功能 2026-05-31 14:39:37 +08:00
ryan f365b3d331 [优化] 重构数据库迁移逻辑,添加版本管理和验证功能 2026-05-31 14:32:43 +08:00
ryan 4ae6c2718f [优化] 添加节点 IP 手动覆盖功能,更新相关文档和测试用例 2026-05-31 14:12:29 +08:00
ryan 8894620b92 [优化] 代码优化 2026-05-31 14:02:49 +08:00
ryan c2fcd2eddf [优化] 重构 ACME 客户端逻辑,简化证书获取和 DNS 提供者设置 2026-05-31 14:02:49 +08:00
ryan ec70794577 [优化] 重构 API 处理逻辑,简化参数解析和响应处理 2026-05-31 14:02:48 +08:00
ryan bcd669722e [优化] 优化代码 2026-05-31 13:23:49 +08:00
ryan 9975ac90c4 [优化] 邮件工具类去耦合 2026-05-31 13:19:47 +08:00
ryan 632c455229 docs: update deployment instructions for Agent to recommend Docker method 2026-05-31 13:13:52 +08:00
ryan b60cde02ac docs: sync and translate english documentation 2026-05-31 13:10:51 +08:00
ryan 21ed214ba9 [优化] 移除过时的 Docker 相关字段和测试用例 2026-05-31 13:09:53 +08:00
ryan f4a53d6b5f [优化] 增加断开 WebSocket 客户端的功能,优化连接管理 2026-05-30 19:05:20 +08:00
ryan cef3694d11 [优化] 界面优化 2026-05-30 18:07:18 +08:00
ryan 631d32e5d0 [优化] POW 与 WAF 合并 2026-05-30 17:42:23 +08:00
ryan c74b70b62e [优化] 界面优化 2026-05-30 16:31:26 +08:00
266 changed files with 24523 additions and 13656 deletions
+1 -1
View File
@@ -100,7 +100,7 @@ jobs:
while read -r GOOS GOARCH ASSET_NAME; do
GOOS="$GOOS" GOARCH="$GOARCH" \
go build -trimpath -ldflags "-s -w -X 'openflare-agent/internal/config.AgentVersion=$VERSION'" -o "../dist/$ASSET_NAME" ./cmd/agent
go build -trimpath -ldflags "-s -w -X 'openflare-agent/internal/config.Version=$VERSION'" -o "../dist/$ASSET_NAME" ./cmd/agent
done <<'EOF'
linux amd64 openflare-agent-linux-amd64
linux arm64 openflare-agent-linux-arm64
+52 -27
View File
@@ -2,11 +2,6 @@ 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
@@ -34,36 +29,66 @@ jobs:
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
fi
- name: Delete prerelease releases and tags
- name: Delete prerelease, dangling, and unbound releases/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
# Fetch all tags from remote to ensure full synchronization
git fetch --tags --force
mapfile -t TAGS < <(git tag --list 'v*' | sort -V)
DELETED=0
# Get all local/remote git tags starting with 'v'
mapfile -t GIT_TAGS < <(git tag --list 'v*' | sort -V)
for TAG in "${TAGS[@]}"; do
# Get all GitHub releases (tags associated with releases)
mapfile -t GH_RELEASES < <(gh release list --limit 1000 --json tagName --jq '.[].tagName' 2>/dev/null || true)
# Helper function to check array containment
contains_element() {
local e match="$1"
shift
for e; do [[ "$e" == "$match" ]] && return 0; done
return 1
}
DELETED_TAGS=0
DELETED_RELEASES=0
echo "=== Phase 1: Checking and cleaning Git tags ==="
for TAG in "${GIT_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
# Formal release tag
if ! contains_element "$TAG" "${GH_RELEASES[@]}"; then
echo "Delete formal tag not bound to any GitHub release: $TAG"
git push origin --delete "refs/tags/$TAG" || true
git tag -d "$TAG" || true
DELETED_TAGS=$((DELETED_TAGS + 1))
else
echo "Keep formal release tag (bound to release): $TAG"
fi
else
echo "No GitHub Release found for $TAG"
fi
# Prerelease tag
if contains_element "$TAG" "${GH_RELEASES[@]}"; then
echo "Delete prerelease release: $TAG"
gh release delete "$TAG" --yes || true
DELETED_RELEASES=$((DELETED_RELEASES + 1))
fi
echo "Delete prerelease tag: $TAG"
git push origin --delete "refs/tags/$TAG"
DELETED=$((DELETED + 1))
echo "Delete prerelease tag: $TAG"
git push origin --delete "refs/tags/$TAG" || true
git tag -d "$TAG" || true
DELETED_TAGS=$((DELETED_TAGS + 1))
fi
done
echo "Deleted $DELETED prerelease tag(s)."
echo "=== Phase 2: Checking and cleaning dangling GitHub releases ==="
for REL_TAG in "${GH_RELEASES[@]}"; do
if ! contains_element "$REL_TAG" "${GIT_TAGS[@]}"; then
echo "Delete GitHub release not bound to any Git tag: $REL_TAG"
gh release delete "$REL_TAG" --yes || true
DELETED_RELEASES=$((DELETED_RELEASES + 1))
fi
done
echo "=== Summary ==="
echo "Successfully deleted $DELETED_TAGS tag(s) and $DELETED_RELEASES release(s)."
+187
View File
@@ -0,0 +1,187 @@
name: Docker image build (Agent)
on:
workflow_dispatch:
inputs:
version:
description: "Image version/tag to publish, for example v1.0.0-beta"
required: false
type: string
push:
tags: ["v*"]
permissions:
contents: read
packages: write
attestations: write
id-token: write
jobs:
build:
name: Build (${{ 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@v4
- 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@v7
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,ignore-error=true,timeout=20m,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:
name: Merge multi-arch manifest
runs-on: ubuntu-24.04
needs: build
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@v4
- 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
if [[ "${VERSION}" =~ (alpha|beta|rc) ]]; then
FLOATING_TAG="beta"
else
FLOATING_TAG="latest"
fi
docker buildx imagetools create \
-t "${IMAGE}:${VERSION}" \
-t "${IMAGE}:${FLOATING_TAG}" \
"${references[@]}"
- name: Inspect image
run: docker buildx imagetools inspect "${IMAGE}:${VERSION}"
-344
View File
@@ -1,344 +0,0 @@
name: Docker image builds
on:
workflow_dispatch:
inputs:
version:
description: "Image version/tag to publish, for example v1.0.0-beta"
required: false
type: string
push:
tags: ["v*"]
permissions:
contents: read
packages: write
attestations: write
id-token: write
jobs:
build:
name: Build (${{ 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,,}" >> "$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: ./openflare_server
file: ./openflare_server/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-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=docker-${{ matrix.arch }}
- name: Export digest
shell: bash
run: |
mkdir -p /tmp/digests
touch "/tmp/digests/${DIGEST#sha256:}"
env:
DIGEST: ${{ steps.build.outputs.digest }}
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.arch }}
path: /tmp/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:
name: Merge multi-arch manifest
runs-on: ubuntu-24.04
needs: build
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,,}" >> "$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/digests
pattern: 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/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/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}"
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}"
+187
View File
@@ -0,0 +1,187 @@
name: Docker image build (OpenFlared)
on:
workflow_dispatch:
inputs:
version:
description: "Image version/tag to publish, for example v1.0.0-beta"
required: false
type: string
push:
tags: ["v*"]
permissions:
contents: read
packages: write
attestations: write
id-token: write
jobs:
build:
name: Build (${{ 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,,}-flared" >> "$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@v4
- 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@v7
with:
context: .
file: ./openflared/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-flared-${{ matrix.arch }}
cache-to: type=gha,mode=max,ignore-error=true,timeout=20m,scope=docker-flared-${{ matrix.arch }}
- name: Export digest
shell: bash
run: |
mkdir -p /tmp/flared-digests
touch "/tmp/flared-digests/${DIGEST#sha256:}"
env:
DIGEST: ${{ steps.build.outputs.digest }}
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: flared-digests-${{ matrix.arch }}
path: /tmp/flared-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:
name: Merge multi-arch manifest
runs-on: ubuntu-24.04
needs: build
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,,}-flared" >> "$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/flared-digests
pattern: flared-digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- 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/flared-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/flared-digests" >&2
exit 1
fi
if [[ "${VERSION}" =~ (alpha|beta|rc) ]]; then
FLOATING_TAG="beta"
else
FLOATING_TAG="latest"
fi
docker buildx imagetools create \
-t "${IMAGE}:${VERSION}" \
-t "${IMAGE}:${FLOATING_TAG}" \
"${references[@]}"
- name: Inspect image
run: docker buildx imagetools inspect "${IMAGE}:${VERSION}"
+187
View File
@@ -0,0 +1,187 @@
name: Docker image build (Relay)
on:
workflow_dispatch:
inputs:
version:
description: "Image version/tag to publish, for example v1.0.0-beta"
required: false
type: string
push:
tags: ["v*"]
permissions:
contents: read
packages: write
attestations: write
id-token: write
jobs:
build:
name: Build (${{ 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,,}-relay" >> "$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@v4
- 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@v7
with:
context: .
file: ./openflare_relay/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-relay-${{ matrix.arch }}
cache-to: type=gha,mode=max,ignore-error=true,timeout=20m,scope=docker-relay-${{ matrix.arch }}
- name: Export digest
shell: bash
run: |
mkdir -p /tmp/relay-digests
touch "/tmp/relay-digests/${DIGEST#sha256:}"
env:
DIGEST: ${{ steps.build.outputs.digest }}
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: relay-digests-${{ matrix.arch }}
path: /tmp/relay-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:
name: Merge multi-arch manifest
runs-on: ubuntu-24.04
needs: build
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,,}-relay" >> "$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/relay-digests
pattern: relay-digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- 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/relay-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/relay-digests" >&2
exit 1
fi
if [[ "${VERSION}" =~ (alpha|beta|rc) ]]; then
FLOATING_TAG="beta"
else
FLOATING_TAG="latest"
fi
docker buildx imagetools create \
-t "${IMAGE}:${VERSION}" \
-t "${IMAGE}:${FLOATING_TAG}" \
"${references[@]}"
- name: Inspect image
run: docker buildx imagetools inspect "${IMAGE}:${VERSION}"
+187
View File
@@ -0,0 +1,187 @@
name: Docker image build (Server)
on:
workflow_dispatch:
inputs:
version:
description: "Image version/tag to publish, for example v1.0.0-beta"
required: false
type: string
push:
tags: ["v*"]
permissions:
contents: read
packages: write
attestations: write
id-token: write
jobs:
build:
name: Build (${{ 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,,}" >> "$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@v4
- 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@v7
with:
context: ./openflare_server
file: ./openflare_server/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-server-${{ matrix.arch }}
cache-to: type=gha,mode=max,ignore-error=true,timeout=20m,scope=docker-server-${{ matrix.arch }}
- name: Export digest
shell: bash
run: |
mkdir -p /tmp/server-digests
touch "/tmp/server-digests/${DIGEST#sha256:}"
env:
DIGEST: ${{ steps.build.outputs.digest }}
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: server-digests-${{ matrix.arch }}
path: /tmp/server-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:
name: Merge multi-arch manifest
runs-on: ubuntu-24.04
needs: build
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,,}" >> "$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/server-digests
pattern: server-digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- 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/server-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/server-digests" >&2
exit 1
fi
if [[ "${VERSION}" =~ (alpha|beta|rc) ]]; then
FLOATING_TAG="beta"
else
FLOATING_TAG="latest"
fi
docker buildx imagetools create \
-t "${IMAGE}:${VERSION}" \
-t "${IMAGE}:${FLOATING_TAG}" \
"${references[@]}"
- name: Inspect image
run: docker buildx imagetools inspect "${IMAGE}:${VERSION}"
+1 -1
View File
@@ -197,7 +197,7 @@ jobs:
run: |
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
go build -trimpath -ldflags "-s -w -X 'openflare-agent/internal/config.Version=$VERSION'" -o "../dist/$ASSET_NAME" ./cmd/agent
(cd ../dist && sha256sum "$ASSET_NAME" > "$ASSET_NAME.sha256")
- name: Upload Agent Artifact
+6 -6
View File
@@ -44,9 +44,9 @@ go.work.sum
.DS_Store
.codex-cache
/.gomodcache/
*.mmdb
!openflare_agent/internal/geoipdata/GeoLite2-Country.mmdb
*-source
*-source.*
/.gomodcache/
*.mmdb
!openflare_agent/internal/geoipdata/GeoLite2-Country.mmdb
*-source
*-source.*
+62 -26
View File
@@ -1,51 +1,87 @@
# AGENTS.md
本文件是 OpenFlare 的 AI 接手入口,不承载详细设计、规范和计划。接手项目时,先按顺序阅读以下 VitePress 文档源文件:
本文件是 OpenFlare 的 AI 接手入口,不承载详细设计、规范和计划。接手项目时,请根据以下分层文档指引进行阅读与开发:
1. [docs/design/index.md](./docs/design/index.md)
作用:理解当前 MVP 的产品范围、系统边界、核心对象和长期约束。
## 1. 核心必读文档(Level 3 & Level 4)- 必须阅读 ⚠️
2. [docs/design/architecture.md](./docs/design/architecture.md)
作用:理解 Server、Agent、OpenResty 与前端的职责边界。
为了理解 OpenFlare 的设计理念、产品边界、核心机制以及代码编写的工程约束,**AI 在接手项目时必须首先且完整阅读以下文档**:
3. [docs/design/release-model.md](./docs/design/release-model.md)
作用:理解配置发布、激活、回滚与 Agent 应用模型。
### Level 3: 面向贡献者的参阅文档 (Contributor References)
* **[docs/design/index.md](./docs/design/index.md)**
*作用:理解当前 MVP 的产品范围、系统边界、核心对象和长期约束。*
* **[docs/design/architecture.md](./docs/design/architecture.md)**
*作用:理解 Server、Agent、OpenResty 与前端的职责边界与网络拓扑。*
* **[docs/design/release-model.md](./docs/design/release-model.md)**
*作用:理解配置发布、激活、回滚与 Agent 节点配置应用的模型。*
* **[docs/design/development.md](./docs/design/development.md)**
*作用:了解如何搭建本地开发环境,运行后端 Server、Agent 和前端开发服务器,以及运行测试与构建的命令。*
* **[docs/design/repository.md](./docs/design/repository.md)**
*作用:熟悉仓库的整体物理结构和各子目录的职责。*
4. [docs/design/development.md](./docs/design/development.md)
作用:理解当前开发规范、阶段原则、分层约束、数据模型边界、API 约定、Agent 约束、前端规范与测试要求。
### Level 4: 面向 AI 的开发指导规范 (AI Guidelines)
* **[docs/guildline/development-constraints.md](./docs/guildline/development-constraints.md)**
*作用:掌握核心后端/Agent/前端分层约束、数据模型规范、数据库迁移升级协议、API 与鉴权设计准则。*
* **[docs/guildline/Guidelines.md](./docs/guildline/Guidelines.md)**
*作用:通用的 Go 后端开发与高质量编码准则,包括架构、并发、错误处理、安全及工作流程。*
* **[docs/guildline/Project.md](./docs/guildline/Project.md)**
*作用:针对 OpenFlare 后端特定的控制器参数解析、响应处理、纯净工具类与数据库逻辑完全隔离、Go 泛型切片去重及 JSON 序列化避坑细则。*
5. [docs/guide/deployment.md](./docs/guide/deployment.md)
作用:理解当前部署方式、Agent 接入、升级、卸载和联调步骤。
---
6. [docs/reference/configuration.md](./docs/reference/configuration.md)
作用:理解系统启动时支持的环境变量、命令行参数、运行时配置项和 Agent 配置字段。
## 2. 按需查阅文档(Level 2)- 根据需求阅读 💡
如任务涉及用户文档、贡献者入口或排障体验,还应阅读:
当开发任务涉及具体的系统部署、升级、接口联调或配置字段查阅时,**AI 应当根据需求阅读相应的参考手册**:
* [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):理解常见失败症状与排查路径。
### Level 2: 面对高级用户/开发者的参阅文档 (Reference Manuals)
* **[docs/reference/configuration.md](./docs/reference/configuration.md)**
*作用:系统启动时支持的所有环境变量、命令行参数、运行时 Option 选项和 Agent 配置文件字段。*
* **[docs/reference/cli.md](./docs/reference/cli.md)**
*作用:Server 与 Agent 可用的命令行参数、安装/卸载脚本参数等参考。*
* **[docs/reference/api.md](./docs/reference/api.md)**
*作用:管理端 API 与 Agent API 的响应结构、路径和详细鉴权约定。*
* **[docs/reference/deployment.md](./docs/reference/deployment.md)**
*作用:理解 Server 和 Agent 的单机、Docker 部署配置,以及 Agent 接入、升级、卸载和联调步骤。*
* **[docs/reference/server.md](./docs/reference/server.md)**
*作用:如何配置系统配置、服务环境变量并正确启动 Server 服务。*
* **[docs/reference/agent.md](./docs/reference/agent.md)**
*作用:理解 Agent 接入的 discovery/agent 令牌鉴权机制、本地配置文件及 Docker 部署参数。*
* **[docs/reference/upgrade.md](./docs/reference/upgrade.md)**
*作用:Server 及各代理节点 Agent 的升级步骤与维护策略。*
线上文档入口:https://open-flare.pages.dev
---
## 3. 新手与业务教程(Level 1)- 体验与排障参考 📘
如果任务涉及优化最终用户体验、丰富业务能力或排查常见故障,可参阅面向普通用户的指南:
### Level 1: 面向新手用户的教程文档 (Novice Tutorials)
* **[docs/guide/quick-start.md](./docs/guide/quick-start.md)**:五分钟内基于 Docker Compose 快速跑起 Server 和首个 Agent 节点的完整闭环。
* **[docs/guide/usage.md](./docs/guide/usage.md)**:反向代理网站、源站、证书托管、配置发布与回滚的常规界面操作与观测功能使用指南。
* **[docs/guide/sso.md](./docs/guide/sso.md)**:系统如何配置 GitHub OAuth 及标准 OIDC 第三方登录,以及绑定本地账户的流程。
* **[docs/guide/first-site.md](./docs/guide/first-site.md)**:从零开始配置、发布并验证第一个代理网站的完整步骤。
* **[docs/guide/troubleshooting.md](./docs/guide/troubleshooting.md)**:常见数据库迁移、节点离线、OpenResty 校验失败、SSL 证书失效等故障的表现症状及标准排障路径。
---
## 执行要求
* 如果实现内容超出 [产品边界](./docs/design/index.md),先修改设计文档,再继续编码。
* 如果实现方式违反 [开发约束](./docs/design/development.md),应优先调整方案,而不是绕过规范。
* 如果需求与当前阶段原则冲突,优先遵守 [开发约束](./docs/design/development.md) 中的变更准入与验收标准。
* 如果任务涉及前端改造或管理端 UI,必须同时遵守 [开发约束](./docs/design/development.md) 中的前端规范。
* 如果实现方式违反 [开发约束](./docs/guildline/development-constraints.md),应优先调整方案,而不是绕过规范。
* 如果实现方式涉及后端代码逻辑,必须严格遵循 [docs/guildline/](./docs/guildline/) 下的所有开发准则。
* 如果需求与当前阶段原则冲突,优先遵守 [开发约束](./docs/guildline/development-constraints.md) 中的变更准入与验收标准。
* 如果任务涉及前端改造或管理端 UI,必须同时遵守 [开发约束](./docs/guildline/development-constraints.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/guildline/development-constraints.md`
* 后端开发规范、代码质量要求、重构模式、去重逻辑与避坑指南变化:更新 `docs/guildline/` 下的对应开发准则文件
* 产品启动、部署、升级、联调方式变化:更新 `docs/guide/quick-start.md`、`docs/reference/deployment.md` 和 `README.md`
* 用户操作路径、常见场景变化:更新 `docs/guide/usage.md`
* 本地开发、测试、构建方式变化:更新 `docs/guide/development.md`
* 本地开发、测试、构建方式变化:更新 `docs/design/development.md`
* 常见故障、排查路径变化:更新 `docs/guide/troubleshooting.md`
* 环境变量、命令行参数、运行时配置、Agent 配置变化:更新 `docs/reference/configuration.md`
+64 -63
View File
@@ -2,11 +2,13 @@
# OpenFlare
轻量、自托管的 OpenResty 控制面,用于管理反向代理规则、配置发布、节点同步、TLS 证书与基础可观测能力。
**[📖 English](./README.md) | [中文](./README.zh-CN.md)**
A lightweight, self-hosted control plane for OpenResty that manages reverse proxy rules, configuration releases, node synchronization, TLS certificates, and observability.
</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>
@@ -19,32 +21,34 @@
</p>
> [!WARNING]
> 使用 `root` 用户初次登录系统后,务必修改默认密码 `123456`。
> After the first login with the `root` user, you **must** change the default password `123456`.
>
> This BETA version is a temporary product in the development and testing phase. It may contain unknown issues and should not be used in production environments.
## 文档
## Documentation
**https://open-flare.pages.dev**
常用入口:
Quick links:
* [快速开始](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/)
* [Quick Start](https://open-flare.pages.dev/guide/quick-start)
* [Deployment Guide](https://open-flare.pages.dev/reference/deployment)
* [Configuration Reference](https://open-flare.pages.dev/reference/configuration)
* [System Design](https://open-flare.pages.dev/design/)
## 核心能力
## Core Features
* 反向代理网站配置与多域名绑定
* 配置预览、发布、激活与历史回滚
* Agent 自动注册、心跳、同步、校验、reload 与失败回滚
* OpenResty 主配置、性能参数、缓存参数与 Lua 资源托管
* WAF 全局/自定义规则组,支持 IP/IP 段与国家级地域黑白名单
* TLS 证书、域名资产、节点凭证与版本状态管理
* 请求聚合、访问分析、资源快照、健康事件与节点详情
* **Reverse Proxy Configuration**: Website management and multi-domain binding
* **Configuration Lifecycle**: Preview, release, activation, and historical rollback
* **Agent Management**: Auto-registration, heartbeat, sync, validation, reload, and failure rollback
* **OpenResty Administration**: Main configuration, performance tuning, caching, and Lua resource hosting
* **WAF Protection**: Global and custom rule groups with IP/CIDR and geographic blacklist/whitelist
* **Certificate Management**: TLS certificates, domain assets, node credentials, and version control
* **Observability**: Request aggregation, access analytics, resource snapshots, health events, and node metrics
## 快速开始
## Quick Start
### 1. 启动 Server
### 1. Launch Server
```yaml
services:
@@ -85,22 +89,20 @@ volumes:
docker compose up -d
```
访问地址:`http://localhost:3000`
Access at: `http://localhost:3000`
默认账号:
Default credentials:
* 用户名:`root`
* 密码:`123456`
* Username: `root`
* Password: `123456`
### 2. 安装 Agent
### 2. Install Agent
安装 Agent 前请先在节点上安装 OpenResty,或改用内置 OpenResty 的 Agent Docker 镜像。
Before installing an Agent, install OpenResty on the target node, or use the Docker image with OpenResty built-in.
你可以在控制面板的节点管理->详情->节点信息->节点标识与部署复制安装命令,或直接使用下面的脚本:
You can copy the installation command from the Dashboard → Node Management → Details → Node Info, or use the script below:
#### Docker 部署
Docker 部署可直接运行 Agent 镜像:
#### Docker Deployment
```bash
docker pull ghcr.io/rain-kl/openflare-agent:latest
@@ -112,9 +114,9 @@ docker run -d --name openflare-agent --restart unless-stopped \
ghcr.io/rain-kl/openflare-agent:latest
```
#### 本地部署
#### Local Installation
使用 `discovery_token` 接入:
Using `discovery_token`:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
@@ -122,7 +124,7 @@ curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/inst
--discovery-token YOUR_DISCOVERY_TOKEN
```
使用节点专属 `agent_token`:
Using node-specific `agent_token`:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
@@ -130,63 +132,62 @@ curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/inst
--agent-token YOUR_AGENT_TOKEN
```
安装脚本默认写入 `/opt/openflare-agent`,创建 `openflare-agent.service`,自动查找 `openresty`,并可重复执行以重装或升级 Agent。
The installation script defaults to `/opt/openflare-agent`, creates a `openflare-agent.service`, auto-detects `openresty`, and supports re-execution for upgrades.
### 3. 卸载 Agent
### 3. Uninstall Agent
如需彻底卸载 Agent 并清空本地数据,可执行:
To completely uninstall the Agent and clean local data:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/uninstall-agent.sh | bash
```
卸载脚本会先停止并移除 `openflare-agent.service`、删除整个 `/opt/openflare-agent` 目录,不会删除本机 OpenResty。
The uninstall script stops and removes the `openflare-agent.service`, deletes the `/opt/openflare-agent` directory, and does not remove OpenResty.
### 4. 发布第一份配置
### 4. Deploy Your First Configuration
1. 登录管理端并新增反代规则
2. 在发布前查看预览或变更摘要
3. 激活新版本
4. Agent 通过 WebSocket 通知或后续 heartbeat 拉取并应用配置
1. Log in to the dashboard and create a reverse proxy rule
2. Preview changes or view the changelog before publishing
3. Activate the new version
4. Agents receive notifications via WebSocket or pull configuration on next heartbeat
版本号格式固定为 `YYYYMMDD-NNN`,历史版本不可变,回滚通过重新激活旧版本完成。
Versions are immutable with format `YYYYMMDD-NNN`. Rollback is performed by reactivating a previous version.
## UI Preview
## 界面预览
### 仪表盘总览
### Dashboard Overview
![OpenFlare dashboard overview](./docs/assets/readme/dashboard-overview.png)
### 节点详情
### Node Details
![OpenFlare node detail](./docs/assets/readme/node-detail.png)
### 配置新增
### Proxy Configuration
![OpenFlare version release](./docs/assets/readme/proxy-route-detail.png)
## 管理端与接口
## Management Panel & API
管理端当前覆盖:
The management panel includes:
* 反代规则
* 配置版本
* 节点管理
* 应用记录
* TLS 证书
* 域名管理
* WAF 规则组
* 用户管理
* 设置
* 版本更新
* POW 规则
* Reverse Proxy Rules
* Configuration Versions
* Node Management
* Application History
* TLS Certificates
* Domain Management
* WAF Rule Groups
* User Management
* Settings
* Version Updates
* POW Rules
登录管理端后,可访问 Swagger UI:`/swagger/index.html`
After logging in to the dashboard, access Swagger UI at: `/swagger/index.html`
## 开源协议
## License
本项目采用 [Apache License 2.0](./LICENSE) 开源。
This project is licensed under [Apache License 2.0](./LICENSE).
## Star History
+201
View File
@@ -0,0 +1,201 @@
<div align="center">
# OpenFlare
轻量、自托管的 OpenResty 控制面,用于管理反向代理规则、配置发布、节点同步、TLS 证书与基础可观测能力。
</div>
<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>
<a href="https://github.com/Rain-kl/OpenFlare/releases/latest">
<img src="https://img.shields.io/github/v/release/Rain-kl/OpenFlare?color=brightgreen&include_prereleases" alt="release">
</a>
<a href="https://github.com/Rain-kl/OpenFlare/pkgs/container/openflare">
<img src="https://img.shields.io/badge/GHCR-ghcr.io%2Frain--kl%2Fopenflare-brightgreen" alt="ghcr">
</a>
</p>
> [!WARNING]
> 使用 `root` 用户初次登录系统后,务必修改默认密码 `123456`。
>
> BETA 版本为开发测试阶段的临时产物,可能存在未知问题,请勿在生产环境使用。
## 文档
**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 自动注册、心跳、同步、校验、reload 与失败回滚
* OpenResty 主配置、性能参数、缓存参数与 Lua 资源托管
* WAF 全局/自定义规则组,支持 IP/IP 段与国家级地域黑白名单
* TLS 证书、域名资产、节点凭证与版本状态管理
* 请求聚合、访问分析、资源快照、健康事件与节点详情
## 快速开始
### 1. 启动 Server
```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-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
```
访问地址:`http://localhost:3000`
默认账号:
* 用户名:`root`
* 密码:`123456`
### 2. 安装 Agent
安装 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` 接入:
```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`,创建 `openflare-agent.service`,自动查找 `openresty`,并可重复执行以重装或升级 Agent。
### 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 通过 WebSocket 通知或后续 heartbeat 拉取并应用配置
版本号格式固定为 `YYYYMMDD-NNN`,历史版本不可变,回滚通过重新激活旧版本完成。
## 界面预览
### 仪表盘总览
![OpenFlare dashboard overview](./docs/assets/readme/dashboard-overview.png)
### 节点详情
![OpenFlare node detail](./docs/assets/readme/node-detail.png)
### 配置新增
![OpenFlare version release](./docs/assets/readme/proxy-route-detail.png)
## 管理端与接口
管理端当前覆盖:
* 反代规则
* 配置版本
* 节点管理
* 应用记录
* 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>
-1
View File
@@ -1 +0,0 @@
1.0.x
+32 -3
View File
@@ -1,5 +1,5 @@
services:
openflare-agent:
agent:
build:
context: .
dockerfile: openflare_agent/Dockerfile
@@ -16,8 +16,37 @@ services:
environment:
OPENFLARE_SERVER_URL: "http://host.docker.internal:3000"
OPENFLARE_AGENT_TOKEN: "373956188ddead1df6dd7c86cd330b73"
OPENFLARE_AGENT_TOKEN: "07800f31d3f181e65d18dca1407d821c"
LOG_LEVEL: "debug"
extra_hosts:
- "host.docker.internal:host-gateway"
- "host.docker.internal:host-gateway"
relay:
build:
context: .
dockerfile: openflare_relay/Dockerfile
container_name: openflare-relay
network_mode: host
restart: unless-stopped
environment:
OPENFLARE_SERVER_URL: http://host.docker.internal:3000
OPENFLARE_DISCOVERY_TOKEN: 85464eeb72c49abc430569d6b9c77f78
LOG_LEVEL: "debug"
extra_hosts:
- "host.docker.internal:host-gateway"
flared:
build:
context: .
dockerfile: openflared/Dockerfile
container_name: openflare-flared
network_mode: "host"
restart: unless-stopped
volumes:
- ./openflared/data/:/app/data
environment:
OPENFLARE_SERVER_URL: "http://host.docker.internal:3000"
OPENFLARE_TUNNEL_TOKEN: deb0783ac1e264a9d86440169aca0f09
+9 -8
View File
@@ -69,13 +69,9 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
{ text: '概览', link: '' },
{ text: '快速开始', link: 'quick-start' },
{ text: '基础使用', link: 'usage' },
{ text: '部署说明', link: 'deployment' },
{ text: 'WAF 自动 IP 组语法', link: 'waf-ip-group-expr' },
{ 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' }
]
}
@@ -88,10 +84,14 @@ function sidebarReference(): DefaultTheme.SidebarItem[] {
text: '参考',
items: [
{ text: '概览', link: '' },
{ text: '系统架构', link: '../design/architecture' },
{ text: '启动 Server', link: 'server' },
{ text: '接入 Agent', link: 'agent' },
{ text: '部署说明', link: 'deployment' },
{ text: '升级与维护', link: 'upgrade' },
{ text: '配置项', link: 'configuration' },
{ text: '命令与脚本', link: 'cli' },
{ text: 'API 约定', link: 'api' },
{ text: '仓库结构', link: 'repository' }
{ text: 'API 约定', link: 'api' }
]
}
]
@@ -105,7 +105,8 @@ function sidebarDesign(): DefaultTheme.SidebarItem[] {
{ text: '产品边界', link: '' },
{ text: '系统架构', link: 'architecture' },
{ text: '发布模型', link: 'release-model' },
{ text: '开发约束', link: 'development' }
{ text: '本地开发', link: 'development' },
{ text: '仓库结构', link: 'repository' }
]
}
]
+100 -21
View File
@@ -2,7 +2,9 @@
你会学到:OpenFlare 的整体架构、Server、Agent、OpenResty 与管理端前端的职责边界,以及一次配置发布从管理端到节点生效的请求流。
OpenFlare 由 Server、Agent、节点本地 OpenResty 和管理端前端组成。Server 是控制面,Agent 是节点侧唯一受控落地入口,OpenResty 是实际数据面。
OpenFlare 由 Server、Agent、节点本地 OpenResty 和管理端前端组成。Server 是控制面,Agent 是节点侧唯一受控落地入口,OpenResty 是实际数据面。内网穿透场景中,Relay(frps 管理器)和 OpenFlared(frpc 管理器)扩展了数据面流量路径。
### 标准反代流量路径
```text
Browser
@@ -24,14 +26,38 @@ OpenResty binary
Origin
```
### 内网穿透流量路径
```text
Browser
|
| HTTPS request
v
OpenResty (Agent, TLS/WAF) <-- TunnelRelay 节点
|
| proxy_pass http://localhost:vhost_port (Host header preserved)
v
OpenFlareRelay (frps) <-- TunnelRelay 节点,与 Agent 同机部署
|
| frp tunnel protocol (HTTP Vhost routing by Host header)
v
OpenFlared (frpc) <-- 内网服务器
|
| HTTP/HTTPS forward
v
Internal Service (192.168.x.x)
```
## 组件职责
| 组件 | 职责 |
| --- | --- |
| Server | 管理端 UI、管理 API、Agent API、配置渲染、版本发布、数据存储与聚合查询 |
| Agent | 注册、心跳、同步、写入文件、校验、reload、失败回滚、自更新与轻量采集 |
| OpenResty | 接收真实流量,按 OpenFlare 渲染的配置执行 WAF、PoW、认证与反向代理 |
| Frontend | 管理网站配置、WAF、源站、证书、节点、版本、用户、设置与观测页面 |
| 组件 | 职责 |
| --------------- | ---------------------------------------------------------------------- |
| Server | 管理端 UI、管理 API、Agent/Relay/Client API、配置渲染、版本发布、数据存储与聚合查询 |
| Agent | 注册、心跳、同步、写入文件、校验、reload、失败回滚、自更新与轻量采集 |
| OpenResty | 接收真实流量,按 OpenFlare 渲染的配置执行 WAF、PoW、认证与反向代理 |
| OpenFlareRelay | 管理 frps 进程生命周期,提供隧道中继服务,通过心跳接收 frps 配置 |
| OpenFlared | 管理 frpc 进程(可多个),连接 Relay 中继,将流量转发到内网服务 |
| Frontend | 管理网站配置、WAF、源站、证书、节点、Tunnel、版本、用户、设置与观测页面 |
## Server
@@ -58,17 +84,24 @@ Server 不直接 SSH 到节点,也不在线修改节点文件。它只保存
Agent 通过 `openresty_path` 指向的 OpenResty 二进制统一执行校验、reload、启动与重启;未配置时默认调用 `openresty`。Docker 部署时,Agent 镜像内置 OpenResty 二进制,仍走同一套二进制控制逻辑。
节点 IP 默认由 Agent 注册和心跳上报维护;如果管理端锁定节点 IP,Server 只更新运行状态、版本、观测等运行态字段,不再接受 Agent 上报覆盖该 IP。
## Frontend
`openflare_server/web` 是正式管理端前端:
* Next.js App Router。
* Next.js 15 App Router。
* React 19。
* TypeScript。
* Tailwind CSS。
* TanStack Query 管理服务端状态。
前端静态导出后由 Go Server 托管。所有 API 请求应统一经过 `lib/api/`,并处理 `success/message/data` 响应结构。
前端采用静态导出模式(`output: 'export'`),导出后由 Go Server 通过 `embed.FS` 托管。所有 API 请求应统一经过 `lib/api/`,并处理 `success/message/data` 响应结构。
Server 集成以下安全特性:
* CORS 中间件:跨域请求保护。
* 速率限制:全局与关键接口限流。
* 会话管理:基于 Cookie/Redis 的会话存储。
## 数据与请求流
@@ -83,14 +116,50 @@ Browser -> Frontend -> /api/* -> controller -> service -> model -> database
### Agent 同步流
```text
Agent heartbeat -> Server 返回激活版本摘要
Agent HTTP 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。
### Relay 同步流
Relay(OpenFlareRelay 进程)运行在 TunnelRelay 节点上,与 Agent 共享同一 `agent_token`:
```text
Relay HTTP heartbeat -> Server 返回 frps 基础配置 (bindPort, vhostHTTPPort, auth_token)
Relay 生成 frps.toml 并启动或更新 frps 进程
Relay 定期上报 frps 健康状态与连接统计
Relay 尝试升级 WebSocket 连接以支持实时配置推送
```
frps 配置相对静态(端口、认证 Token),通过心跳下发,**不纳入版本化发布流**。Relay 需要监听 frps 进程异常并自动恢复。认证方式:`X-Agent-Token` + API 路径前缀 `/api/relay/*`,Server 通过 `node_type = tunnel_relay` 区分。
### OpenFlared 同步流
OpenFlared(客户端)运行在内网服务器,使用独立的 `tunnel_token` 认证:
```text
Client HTTP heartbeat -> Server 返回 tunnel 配置版本摘要 (version, checksum)
Client 发现新版本 -> 拉取完整 tunnel 路由配置 (relay 列表 + frpc proxy 定义)
Client 为每个 Relay 生成独立的 frpc.toml 配置文件
Client 为新 Relay 启动 frpc 进程,或为已有 Relay 执行热重载 (frpc reload)
Client 上报应用结果 (成功/失败原因)
```
OpenFlared 通过 `/api/flared/*` 端点与 Server 通信,认证使用 `X-Tunnel-Token`。Tunnel 路由配置随发布流程版本化同步,所有配置变更通过单一版本号关联并一致性发布到 Agent 和 Client。
**WebSocket 升级流程**(可选,通过 `AgentWebsocketUpgradeEnabled` 选项控制):
当启用 WebSocket 升级时:
1. Agent 通过 HTTP heartbeat 获取运行配置与设置。
2. Agent 尝试升级连接到 `GET /api/agent/ws`(WebSocket)。
3. WS 连接成功后,周期性状态上报和实时消息由 WebSocket 承载,降低延迟。
4. Server 发布或激活版本后,可向已连接 Agent 立即广播激活版本摘要,使 Agent 立即进入同步流程。
5. 若 WebSocket 断开或建立失败,Agent 自动降级回 HTTP heartbeat,保证可用性。
通过 `OpenRestyWebsocketEnabled` 选项,可在 OpenResty 层面启用或禁用 WebSocket 反向代理支持。
### 反向代理流
@@ -100,7 +169,9 @@ Client -> OpenResty server block -> WAF Lua -> named upstream -> Origin
网站配置是反向代理聚合边界。一条网站配置可绑定多个域名,并共享站点级流量限制、反向代理和缓存配置。
WAF 在 OpenResty `access_by_lua_file` 阶段执行。规则来自当前激活版本携带的 `waf_config.json`,全局规则组默认生效,网站可叠加自定义规则组。
WAF 在 OpenResty `access_by_lua_file` 阶段执行。规则来自当前激活版本携带的 `waf_config.json`,全局规则组默认生效,网站可叠加自定义规则组。`waf_config.json` 只保存规则组直接 IP 和 IP 组引用 ID;IP 组成员由 Agent 独立同步到本地 `waf_ip_groups.json`,OpenResty Lua 按引用 ID 合并判断。
WAF IP 组由 Server 管理。手动 IP 组直接保存 IP/IP 段列表;自动 IP 组由 Server 定时任务读取请求日志、按单个 IP 聚合指标并执行 Expr 规则;订阅 IP 组由 Server 定时任务同步远程文本或 JSON 源。Agent 心跳会上报本地 IP 组 checksum,Server 只返回不一致的 IP 组;Server 侧 IP 组更新时会通过 Agent WebSocket 广播变更组。OpenResty Lua 只读取 Agent 落地的运行时 JSON,不直接访问 Server 数据库、请求日志或远程订阅源。
## 核心对象
@@ -110,6 +181,7 @@ WAF 在 OpenResty `access_by_lua_file` 阶段执行。规则来自当前激活
* `origins`
* `config_versions`
* `nodes`
* `tunnels`
* `auth_sources`
* `external_accounts`
* `node_system_profiles`
@@ -122,17 +194,24 @@ WAF 在 OpenResty `access_by_lua_file` 阶段执行。规则来自当前激活
* `traffic_analytics_rollups`
* `node_health_events`
* `waf_rule_groups`
* `waf_ip_groups`
* `waf_rule_group_bindings`
* `acme_accounts`
* `dns_accounts`
* `geoip_update_configs`
## 关键设计决策
| 决策 | 原因 |
| --- | --- |
| 完整配置版本,而不是在线 patch | 让预览、激活、历史和回滚有稳定边界 |
| Agent 主动拉取 | Server 不需要 SSH 权限,也不暴露远程命令入口 |
| 全局单激活版本 | 降低 MVP 复杂度,保证所有节点默认一致 |
| 网站配置聚合多域名 | 支持一个业务站点共享站点级策略,同时允许按域名绑定证书 |
| 观测数据服务端聚合 | 避免前端临时统计造成口径不一致 |
| 决策 | 原因 |
| ------------------------------ | --------------------------------------------------------------------------- |
| 完整配置版本,而不是在线 patch | 让预览、激活、历史和回滚有稳定边界 |
| Agent 主动拉取 | Server 不需要 SSH 权限,也不暴露远程命令入口;支持 HTTP 与 WebSocket 双协议 |
| 全局单激活版本 | 降低 MVP 复杂度,保证所有节点默认一致;支持版本预览、历史查询与一键回滚 |
| 网站配置聚合多域名 | 支持一个业务站点共享站点级策略,同时允许按域名绑定证书 |
| 观测数据服务端聚合 | 避免前端临时统计造成口径不一致 |
| 内网穿透基于 frp 整合 | 复用成熟隧道协议,避免自研隧道的稳定性风险;frps HTTP Vhost 路由天然适配 |
| Relay/Client 独立二进制 | 职责分离,Relay 管理 frps,Client 管理 frpc,各自独立升级和部署 |
| Tunnel 与 Node 体系分离 | Tunnel 客户端在内网运行,与公网节点概念不同,使用独立的注册和认证体系 |
## 贡献者阅读建议
@@ -140,5 +219,5 @@ WAF 在 OpenResty `access_by_lua_file` 阶段执行。规则来自当前激活
1. [产品边界](./index.md)
2. [发布模型](./release-model.md)
3. [开发约束](./development.md)
4. [仓库结构](../reference/repository.md)
3. [开发约束](../guildline/development-constraints.md)
4. [仓库结构](./repository.md)
+125 -246
View File
@@ -1,303 +1,182 @@
# 开发约束
# 本地开发
你会学到:OpenFlare 代码修改的准入标准、后端/Agent/前端分层约束、数据模型边界、API 约定、数据库迁移要求和测试交付基线。
你会学到:如何搭建 OpenFlare 的本地开发环境、启动 Server、Agent 和管理端前端,运行测试与构建命令,并理解贡献代码前需要遵守的边界。
本文档融合原开发规范、前端规范与开发计划,是 OpenFlare `1.0.0` 之后的工程约束入口。
本页面向贡献者。产品边界、数据模型约束、API 约定和前端分层规范以 [开发约束](../guildline/development-constraints.md) 为准;本页只提供可执行的本地开发流程。
## 当前结论
## 仓库结构
* 第一版至第六版的主线能力已经全部完成。
* `1.0.0` 是当前正式基线。
* 已完成阶段的过程性任务以代码、测试与 Git 历史为准。
* 新工作优先以缺陷修复、可维护性改进、文档与测试补强为主。
项目的核心物理目录及各模块(Server、Agent、Frontend 等)的职责分层,详见 [仓库结构](./repository.md)。
当前开发优先级:
## 环境要求
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 |
| Go | `1.25+` |
| Node.js | `18+` |
| pnpm | 推荐通过 `corepack enable` 使用项目声明版本 |
| Docker | Server 容器、本地联调和 Agent Docker 镜像需要 |
| OpenResty | 本地运行 Agent 时需要可执行 `openresty` |
| PostgreSQL | 可选;未配置时 Server 使用 SQLite |
禁止在 `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/
```bash
cd openflare_server/web
corepack enable
pnpm install
```
职责约束:
构建供 Go Server 托管的静态产物:
* `app/`:路由、布局、页面组装。
* `features/`:按业务域组织模块。
* `components/`:跨 feature 复用组件。
* `lib/`:请求客户端、环境变量、工具函数、常量。
* `store/`:少量跨页面 UI 状态。
* `types/`:共享类型定义。
```bash
pnpm build
```
页面文件只负责获取路由参数、组织页面结构、调用 feature 组件;不应手写复杂 API 细节、复杂表单校验逻辑或维护大量彼此耦合的局部状态。
## 启动 Server
## 数据模型规范
SQLite 模式:
当前有效实体:
```bash
cd openflare_server
export SESSION_SECRET='dev-session-secret'
export SQLITE_PATH='./openflare-dev.db'
export LOG_LEVEL='debug'
go run .
```
* `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`
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 .
```
* 不新增平台化对象,除非设计文档明确要求。
* `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` 仅用于兼容迁移,不得作为新登录流程的业务输入。
默认访问地址:
## 数据库迁移
```text
http://localhost:3000
```
任何涉及表结构、索引、列类型、分表规则或内部持久化元数据的修改,都必须同步提升数据库版本号。
默认账号是 `root` / `123456`。
数据库版本号定义在 `openflare_server/model`,不得只依赖 `AutoMigrate` 隐式升级存量数据库。
## 启动前端开发服务器
每次提升数据库版本号时,必须补充从上一版本升级到新版本的显式迁移方法。迁移方法必须包含升级后的校验逻辑;只有校验通过,才能写入新的数据库版本记录。
前端开发服务器默认监听 `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
```
## API 与鉴权
## 启动 Agent
管理端与 Agent API 统一使用 JSON。成功与失败都必须返回清晰 `message`:
创建本地 `agent.json`:
```json
{
"success": true,
"message": "",
"data": {}
"server_url": "http://127.0.0.1:3000",
"agent_token": "replace-with-node-auth-token",
"data_dir": "./data",
"heartbeat_interval": 10000,
"request_timeout": 10000
}
```
约定:
运行:
* Agent API 固定放在 `/api/agent/*`。
* 总览与节点详情优先使用专用聚合接口。
* 管理端变更类接口统一使用 `POST`;只读接口使用 `GET`。
* 管理端继续复用现有登录、角色与 Session。
* 第三方登录统一通过认证源 API 进入,认证源管理接口必须要求 Root Session。
* `/api/status` 只能返回已启用认证源的公开字段,不得返回 Client Secret。
* 第三方账号未绑定且注册关闭时,应提供绑定已有账号流程,不得自动创建用户。
* Agent 正式请求统一使用节点专属 `agent_token`。
* 首次接入可使用全局 `discovery_token`。
* Agent 请求头统一使用 `X-Agent-Token`。
```bash
cd openflare_agent
export LOG_LEVEL='debug'
go run ./cmd/agent -config ./agent.json
```
禁止暴露远程 shell 或任意命令执行入口,禁止在日志中打印完整 Token,禁止绕过占位符约束保存不可渲染的主配置模板。
未配置 `openresty_path` 时,Agent 默认调用 `openresty`。调试时可显式配置 `openresty_path`、`main_config_path`、`route_config_path`、`access_log_path`、`cert_dir`、`lua_dir` 和 `runtime_config_dir`。
## 发布与运行
## 测试
发布逻辑必须保持:
Server:
* 发布时读取全部启用的 `proxy_routes`。
* 同时读取 OpenResty 主配置参数、反代性能参数与缓存参数。
* 生成完整 OpenResty 配置。
* 计算 `checksum`。
* 写入 `config_versions`。
* 通过切换 `is_active` 激活版本。
```bash
cd openflare_server
GOCACHE=/tmp/openflare-go-cache go test ./...
```
版本约束:
Agent:
* 版本号格式固定为 `YYYYMMDD-NNN`。
* 不在线修改历史版本。
* 不做按节点分组的差异化版本。
* 预览与 diff 是只读能力,不产生发布记录。
```bash
cd openflare_agent
GOCACHE=/tmp/openflare-go-cache go test ./...
```
Agent 必须满足:
Frontend:
* 启动后读取或生成本地 `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 健康检查。
```bash
cd openflare_server/web
pnpm lint
pnpm typecheck
pnpm test
pnpm test:e2e
```
## 前端请求、状态与类型
Docs:
所有 API 请求必须统一经过 `lib/api/`:
```bash
cd docs
pnpm build
```
* 统一处理 `success/message/data` 响应结构。
* 统一处理鉴权失效、网络异常和通用错误消息。
* 统一维护资源接口与请求路径。
## 构建
状态分层:
管理端静态产物:
* 服务端状态:TanStack Query。
* 页面临时状态:组件内部 `useState`。
* 跨页面 UI 状态:Zustand。
```bash
cd openflare_server/web
pnpm build
```
要求开启 TypeScript 严格模式,禁止滥用 `any`,API 响应、表单输入、业务实体必须有明确类型。
Server 二进制:
## 表单、交互、样式与主题
```bash
cd openflare_server
go build -o openflare-server .
```
表单统一使用 React Hook Form 与 Zod。
Agent 二进制:
高风险操作必须二次确认、展示操作对象名称,并明确成功与失败反馈。
```bash
cd openflare_agent
go build -o openflare-agent ./cmd/agent
```
样式原则:
## 调试入口
* 统一使用 Tailwind CSS 与现有 token 体系。
* 优先复用已有基础组件与布局组件。
* 保持视觉层级、留白与语义颜色一致。
| 场景 | 命令或位置 |
| --- | --- |
| 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` |
主题要求:
## 代码风格与变更准入
* 同时支持 `light`、`dark`、`system`。
* 用户选择必须持久化。
* 首屏尽量避免主题闪烁。
贡献前先确认:
## 测试与交付
1. 需求符合 [产品边界](./index.md)。
2. 实现符合 [开发约束](../guildline/development-constraints.md)。
3. 不破坏发布、同步、回滚或升级主链路。
4. 涉及配置、部署、API 或产品边界时同步更新文档。
5. 风险较高的修改补充测试或等效联调验证。
* 关键业务逻辑必须有单元测试或等效回归测试。
* Agent 主链路修改必须验证同步、应用与回滚。
* 前端页面至少覆盖加载态、空态、错误态与成功反馈。
* Go 版本调整时,同步检查 `go.mod`、Dockerfile 与 CI 工作流。
## 后续维护方式
后续规划不再按“大版本阶段文档”维护,而采用以下方式:
* 产品边界变动:更新 [产品边界](./)。
* 工程约束变动:更新本文档。
* 部署与配置变动:更新 [部署说明](../guide/deployment.md)、[配置项](../reference/configuration.md) 与 README。
如果未来出现明确的新阶段目标,再单独新增专项计划文档;不要把已完成的历史计划继续堆回本文档。
当前专项“网站级规则与配置界面改造”的模型边界已纳入 [产品边界](./),执行时仍按数据模型、接口、前端页面、迁移测试与文档联动的顺序推进。
数据库结构变更必须提升数据库版本号,并补充从上一版本到新版本的显式迁移方法和校验逻辑。
+96 -36
View File
@@ -15,16 +15,7 @@ OpenFlare 适合需要统一管理多台 OpenResty 代理节点的团队:
OpenFlare 当前不定位为通用日志平台、服务网格、Kubernetes Ingress Controller 或多租户云平台。
## 目标用户
| 用户 | 需求 |
| --- | --- |
| 自托管用户 | 快速部署一个可视化 OpenResty 控制面 |
| 内部运维团队 | 管理多个反向代理节点、证书和配置版本 |
| 开发团队 | 为内部服务提供统一入口和基础访问分析 |
| 贡献者 | 在明确边界内修复缺陷、补强测试和改进文档 |
## 当前稳定能力
## 当前能力
| 能力 | 说明 |
| --- | --- |
@@ -35,17 +26,20 @@ OpenFlare 当前不定位为通用日志平台、服务网格、Kubernetes Ingre
| Agent 同步 | 支持注册、心跳、同步、应用结果上报与自更新 |
| OpenResty 托管 | 管理主配置模板、性能参数、缓存参数与 Lua 资源 |
| HTTPS/TLS | 托管证书与域名资产,并按域名绑定证书 |
| WAF | 以全局规则组与网站自定义规则组维护 IP/IP 段、国家级地域黑白名单 |
| WAF | 以全局规则组与网站自定义规则组维护 IP/IP 段、IP 组、国家级地域黑白名单 |
| 基础观测 | 聚合节点请求、资源快照、健康事件和访问分析 |
| 节点管理 | 节点状态、令牌体系、部署与更新链路 |
| 管理端前端 | 基于 Next.js 的正式管理端 |
| 认证源登录 | 支持以认证源形式配置 GitHub 与标准 OIDC 登录入口,并允许第三方账号绑定已有本地用户 |
| 内网穿透 | 通过 TunnelRelay 节点与 OpenFlared 客户端,将内网 HTTP 服务安全暴露到公网,复用 Agent 的 HTTPS/WAF 能力 |
默认工作方式:
* 所有节点消费同一份全局激活版本。
* Server 保存配置与状态,不直接 SSH 管理节点。
* Agent 是节点侧唯一受控落地入口。
* TunnelRelay 节点同时运行 Agent(OpenResty)和 Relay(frps),提供内网穿透中继。
* OpenFlared 客户端在内网运行,管理 frpc 进程连接 Relay,将流量转发到内网服务。
## 典型使用场景
@@ -57,28 +51,8 @@ OpenFlare 当前不定位为通用日志平台、服务网格、Kubernetes Ingre
| 快速回滚 | 重新激活旧版本,让 Agent 拉取并应用 |
| 证书托管 | 为不同域名绑定 TLS 证书 |
| 基础观测 | 查看节点状态、请求聚合、访问分析和健康事件 |
| 内网穿透 | 通过 Tunnel 将无法直接公网访问的内网 HTTP 服务暴露到互联网,享有 HTTPS、WAF 等全部防护能力 |
## 核心对象
当前有效实体:
* `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`
## 网站配置约束
@@ -101,13 +75,85 @@ OpenFlare 当前不定位为通用日志平台、服务网格、Kubernetes Ingre
上游约束:
* `proxy_routes` 至少包含一个上游地址。
* `proxy_routes` 至少包含一个上游地址(直连类型),或关联一个 Tunnel(内网穿透类型)。
* `proxy_routes.upstream_type` 区分上游类型:`direct`(默认,直连)或 `tunnel`(内网穿透)。
* 为兼容历史数据保留 `origin_url` 主上游字段,也允许在同一规则内补充多个上游做负载均衡。
* 上游统一渲染为带 keepalive 的 named `upstream`。
* 单上游可附带 base path 或 query 并在 `proxy_pass` 中追加。
* 多上游限定为纯 `scheme://host[:port]`。
* `proxy_routes.origin_host` 为可选字段,用于回源时覆盖 `Host` 请求头。
* 所有上游地址都必须为合法 `http://` 或 `https://`。
* 所有直连类型上游地址都必须为合法 `http://` 或 `https://`。
* 内网穿透类型上游必须关联 `tunnel_id`,并指定内网目标地址与协议。
## 内网穿透约束
OpenFlare 通过 TunnelRelay 节点与 OpenFlared 客户端实现内网穿透,底层基于 frp(快速反向代理)构建。
### 节点与组件模型
**节点类型**:
* `nodes.node_type` 区分节点类型:`edge_node`(边缘节点,默认)和 `tunnel_relay`(隧道中继)。
* TunnelRelay 节点同时运行 Agent(OpenResty)和 Relay(frps 管理器),共享同一个 `agent_token`。
- Agent 负责 HTTPS 终结、WAF 防护、缓存与流量限制等。
- Relay 管理 frps 进程,为内网客户端提供隧道中继服务。
* TunnelRelay 节点新增字段:`node_type`、`relay_bind_port`(frpc 连接端口,默认 7000)、`relay_vhost_http_port`(HTTP Vhost 端口,默认 8080)、`relay_auth_token`(自动生成)、`relay_status` 等。
**Tunnel 客户端**:
* `tunnels` 表独立存储内网穿透客户端注册信息,与 `nodes` 体系无关。
* 每个 Tunnel 拥有唯一的 `tunnel_id`(格式 `tun-<32hex>`)和 `tunnel_token`(客户端认证凭据)。
* OpenFlared 客户端运行在内网,不对外暴露,使用 `tunnel_token` 认证,通过 `/api/flared/*` 端点与 Server 通信。
* 一个 OpenFlared 客户端可同时连接多个 Relay(为高可用)。
### 上游类型扩展
`proxy_routes` 的上游配置分为两种类型,通过 `upstream_type` 字段区分:
* **直连上游(`direct`,默认)**:直接将流量转发到源站地址,行为与现有完全一致。
* **内网穿透上游(`tunnel`)**:通过 TunnelRelay 节点将流量转发到内网服务。
- 必须指定 `tunnel_id`(关联 `tunnels` 表)。
- 必须指定 `tunnel_target_addr`(内网目标地址,如 `192.168.1.100:8080`)和 `tunnel_target_protocol`(`http` 或 `https`)。
- 发布时,Server 自动将上游地址替换为 `http://127.0.0.1:{relay_vhost_http_port}`。
### 流量路径与协议
**完整数据面流量路径**:
```
浏览器 → OpenResty (Agent, TLS/WAF) [TunnelRelay 节点]
↓
frps (Relay, HTTP Vhost 路由) [TunnelRelay 节点, 127.0.0.1:{vhost_port}]
↓
frp 隧道协议 (Host 头路由)
↓
frpc (Client, 多进程) [内网服务器]
↓
内网服务 (192.168.x.x:port)
```
**关键特性**:
* frps 使用 HTTP Vhost 单端口复用机制,所有 HTTP 隧道共享一个 `vhost_port`,通过 Host 头自动路由到对应 frpc。
* Agent 保留原始 `Host` 请求头,frps 依据此头进行虚拟主机匹配。
* 每个隧道对应一条 `proxy_routes`,可绑定多个域名。
* OpenFlared 客户端为每个连接的 Relay 管理一个独立的 frpc 进程,通过单一 frp 隧道传输多个 HTTP 代理定义。
### 配置同步模型
发布流程同时生成两类配置版本数据,统一使用 `config_version` 版本号关联:
* **Agent 侧配置**:OpenResty 主配置 + 路由配置 + WAF 规则。包含 tunnel 上游时,自动渲染为 `http://127.0.0.1:{vhost_port}` 上游。
* **Tunnel 侧配置**:Relay 列表 + frpc 代理定义。随发布流程版本化,变更时优先使用 `frpc reload` 热重载。
* **Relay 配置**:通过心跳响应下发,相对静态,不纳入版本化流程。
### 当前阶段约束
* 仅支持 HTTP 协议隧道流量,保留未来 TCP/UDP 隧道扩展性。
* Tunnel 类型上游的域名 DNS 应仅解析到 TunnelRelay 节点;EdgeNode 上对应请求会因 frps 不可达返回 502。
* frp 版本使用 v0.61+(或更新稳定版),frp 二进制由部署脚本或 Docker 镜像提供。
* 暂不支持 TCP/UDP 端口分配;HTTP 单端口复用已满足 MVP 需求。
## HTTPS 约束
@@ -126,9 +172,23 @@ WAF 以规则组为配置边界。系统固定一个全局规则组,默认应
一期支持:
* IP / IP 段白名单与黑名单。
* IP 组引用,支持手动、自动、订阅三类 IP 组。
* 国家级地域白名单与黑名单。
* 规则组级拦截状态码与响应页面,默认 `418` 与空页面。
IP 组约束:
* 手动 IP 组由管理端直接维护 IP/IP 段列表。
* 自动 IP 组使用 Expr 语法保存自定义规则,由 Server 定时按单个 IP 聚合请求日志并更新 IP 列表。
* 订阅 IP 组由 Server 定时从 HTTP/HTTPS URL 同步,支持文本列表和 JSON 映射。
* WAF 运行时不访问数据库;发布版本只保存规则组引用的 IP 组 ID,不把 IP 组成员展开进版本快照。
* Agent 通过心跳上报本地 IP 组 checksum,Server 仅返回 checksum 不一致的 IP 组;Server 侧 IP 组更新时会通过 Agent WebSocket 主动广播变更组,使节点可在不重新发布配置版本的情况下更新 WAF IP 组内容。
自动 IP 组首批内置预设规则:
* 单个 IP 请求数大于 100,且 404 状态码占比不低于 80%:`request_count > 100 && status_404_ratio >= 0.8`
* 单个 IP 通过 IP 地址访问次数大于 50,且通过 IP 地址访问占比大于 50%:`ip_host_count > 50 && ip_host_ratio > 0.5`
判定顺序:
* 白名单是放行例外,任意启用规则组命中白名单即放行。
@@ -164,8 +224,8 @@ WAF 以规则组为配置边界。系统固定一个全局规则组,默认应
* 产品范围或系统边界变化时更新本文档。
* 系统结构或模块职责变化时更新 [系统架构](./architecture.md)。
* 发布、同步、回滚模型变化时更新 [发布模型](./release-model.md)。
* 开发约束、代码规范、接口约定变化时更新 [开发约束](./development.md)。
* 部署方式变化时更新 [部署说明](../guide/deployment.md) 与 README。
* 开发约束、代码规范、接口约定变化时更新 [开发约束](../guildline/development-constraints.md)。
* 部署方式变化时更新 [部署说明](../reference/deployment.md) 与 README。
* 配置项变化时更新 [配置项参考](../reference/configuration.md)。
* 已完成阶段不再以“版本计划”形式回填。
* 新阶段开始前,先补设计,再进入实现。
+9 -3
View File
@@ -17,8 +17,8 @@ Server 发布时必须:
1. 读取全部启用的 `proxy_routes`。
2. 读取 Server 侧 OpenResty 主配置、性能参数、缓存参数和必要 Lua 资源。
3. 读取域名与证书绑定关系。
4. 读取 WAF 全局规则组、自定义规则组与网站绑定关系。
5. 渲染完整 OpenResty 配置与 WAF 运行时配置。
4. 读取 WAF 全局规则组、自定义规则组、IP 组引用与网站绑定关系。
5. 保留 WAF 规则组引用的 IP 组 ID,渲染完整 OpenResty 配置与 WAF 运行时配置;IP 组成员不进入发布版本。
6. 计算 `checksum`。
7. 写入 `config_versions`。
8. 切换激活版本。
@@ -70,4 +70,10 @@ Agent 发现新版本后会:
* Agent API 固定使用节点专属 `agent_token`,首次接入可使用 `discovery_token`。
* Server 不提供远程 shell 或任意命令执行入口。
* 配置版本必须保存完整快照、渲染结果和 `checksum`。
* WAF 规则组和网站绑定关系必须随完整配置版本进入快照与 checksum,回滚时不得依赖当前可变 WAF 配置。
* WAF 规则组、IP 组引用 ID 和网站绑定关系必须随完整配置版本进入快照与 checksum;IP 组成员由 Agent 独立按 checksum 差异同步,不受版本回滚影响。
## WAF IP 组运行时同步
WAF IP 组成员不纳入配置版本。发布版本只包含规则组直接 IP 与 `ip_whitelist_group_ids` / `ip_blacklist_group_ids`。Agent 应用版本后会从渲染出的 `waf_config.json` 中提取引用 ID,并向 Server 请求缺失或 checksum 不一致的 IP 组数据。
Agent 后续心跳会携带本地 IP 组 checksum。Server 根据当前激活版本引用的 IP 组 ID 对比 checksum,只返回差异组,避免每次心跳传输全部 IP 组。Server 在手动更新、订阅同步或自动规则执行后,会通过 Agent WebSocket 广播发生变化的 IP 组;WS 不可用时,下一次 HTTP heartbeat 仍会按 checksum 差异补齐。
+62
View File
@@ -0,0 +1,62 @@
# 仓库结构
你会学到: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 文档站、设计基线、开发规范、部署与配置文档 |
| `docs/en` | 英文版文档 |
## Server 分层
| 目录 | 职责 |
| ------------- | ------------------------------------------------ |
| `controller/` | 参数解析、调用 service、返回响应 |
| `service/` | 业务逻辑、校验、事务编排、配置渲染 |
| `model/` | 模型定义、数据库版本与迁移 |
| `router/` | 路由注册 |
| `middleware/` | 认证、鉴权、限流、CORS、Turnstile 验证等横切逻辑 |
| `common/` | 配置、全局状态与初始化入口 |
| `utils/` | 纯工具函数与通用 helper |
| `job/` | 定时任务(如 SSL 证书续期) |
| `upload/` | 文件上传处理 |
| `docs/` | API 文档(Swagger) |
| `data/` | 静态数据(如 GeoIP 数据库) |
## Agent 模块
| 模块 | 职责 |
| ---------------- | -------------------------------------------- |
| `config/` | 配置读取与默认值 |
| `heartbeat/` | 心跳与版本摘要判断 |
| `sync/` | 配置拉取与应用编排 |
| `nginx/` | OpenResty 文件写入、校验、reload、启动与回滚 |
| `state/` | 本地状态与观测补报缓冲 |
| `httpclient/` | Server 通信 |
| `wsclient/` | WebSocket 客户端通信 |
| `protocol/` | Agent API 协议类型 |
| `updater/` | Agent 自更新逻辑 |
| `logging/` | 日志处理 |
| `observability/` | 可观测性(指标、链路等) |
| `geoipdata/` | GeoIP 数据处理 |
| `geoipupdate/` | GeoIP 数据更新 |
| `agent/` | 核心 Agent 逻辑与生命周期 |
## Frontend 分层
| 目录 | 职责 |
| ------------- | -------------------------------------------- |
| `app/` | Next.js App Router 路由、布局、页面组装 |
| `features/` | 按业务域组织的功能模块 |
| `components/` | 跨 feature 复用的 UI 组件 |
| `lib/` | 请求客户端、环境变量、工具函数、常量 |
| `store/` | 少量跨页面 UI 状态管理 |
| `types/` | 共享类型定义 |
| `styles/` | 全局样式 |
| `tests/` | 前端单元测试与集成测试(Vitest、Playwright) |
| `scripts/` | 构建和部署相关脚本 |
| `public/` | 静态资源 |
+188
View File
@@ -0,0 +1,188 @@
You are a senior Go backend engineer responsible for maintaining and developing a long-evolving Go application.
Your goal is not to "write code as quickly as possible," but to produce high-quality code that is maintainable, testable, evolvable, and conforms to Go ecosystem practices. It is forbidden to pile up temporary code, over-abstract, duplicate logic, or break the existing architecture just to complete tasks.
Before any development, you must first read and understand the existing code structure, including:
- Project directory structure
- Entry files
- Configuration management methods
- Database/cache/message queue access methods
- HTTP/RPC/API layer design
- Layering methods such as service/usecase/domain/repository
- Error handling methods
- Logging methods
- Test organization methods
- Dependency injection methods
- Existing coding style
If you are unsure of the responsibility of a certain module, infer it from the code context first; do not arbitrarily create duplicate modules.
Development Principles:
1. Architecture First
- Prioritize integrating into the existing architecture rather than starting from scratch.
- Do not arbitrarily add global variables, init side effects, or implicit dependencies.
- Do not write business logic into handlers/controllers.
- Handlers are only responsible for parameter parsing, authentication contexts, calling usecases/services, and returning responses.
- Services/usecases are responsible for business orchestration.
- Repositories/daos are responsible for data access.
- Domain/models are responsible for core business objects and rules.
- Isolate infrastructure code from business code.
2. Go Style
- Use clear, direct, and simple Go code.
- Do not mimic Java-style over-abstraction.
- Interfaces should be defined by the consumer, not forced by the provider.
- Prioritize small interfaces.
- Naming must be accurate. Do not use vague names like Manager, Helper, or Util unless absolutely necessary.
- Keep functions short and single-responsibility.
- Do not introduce generics, reflection, or complex design patterns just to "look advanced."
- Do not hide errors.
- Errors must contain context information; use `fmt.Errorf("...: %w", err)` when necessary.
- Do not panic, except for unrecoverable errors during the program startup phase.
3. Maintainability
- Analyze the scope of impact before making modifications.
- Keep changes minimal and avoid unrelated refactoring.
- Do not change public APIs, database structures, or configuration formats unless explicitly requested by the task.
- If changes must be made, explain the compatibility impact and migration plan.
- Confirm there are no callers before deleting code.
- Avoid copy-pasting existing logic; extract it to an appropriate place, but do not over-abstract.
- Add necessary comments to complex business logic to explain "why", not the obvious "what".
4. Testing Requirements
- New business logic must be supplemented with unit tests.
- Bug fixes must be supplemented with regression tests.
- Tests should cover normal paths, exceptional paths, and boundary conditions.
- Do not break the structure of business code for testing convenience.
- Isolate external dependencies using mocks/fakes/stubs.
- Name tests clearly, e.g., TestXXX_WhenYYY_ShouldZZZ.
- Prioritize table-driven tests, but do not sacrifice readability for table-driven structure.
5. Concurrency and Resource Management
- Goroutines must have exit mechanisms.
- Where context is involved, context.Context must be passed correctly.
- Do not arbitrarily use context.Background() to replace upstream contexts.
- Channels must have clear responsibility for closing.
- Keep lock scopes small to avoid deadlocks.
- Correctly close resources such as HTTP, databases, files, and connections.
- Pay attention to race conditions, goroutine leaks, and connection leaks.
6. Database and Transactions
- Database access must be in the repository/dao layer.
- Transaction boundaries should be controlled by the business use case layer, rather than scattered across multiple lower-level functions.
- Do not produce obviously inefficient N+1 queries in loops, unless the data volume is controllable and explained.
- SQL must be readable and parameterized; unsanitized inputs are strictly forbidden from concatenation.
- Schema changes must consider migration, rollback, and compatibility.
7. API Design
- Request parameters must be validated.
- Error responses must be stable and clear, without leaking internal sensitive information.
- Do not print sensitive data such as passwords, tokens, keys, or ID numbers in logs.
- Keep return structures backward-compatible.
- HTTP status codes must be semantically correct.
8. Logging and Observability
- Key paths must have necessary logs.
- Error logs must contain the context needed for troubleshooting, but must not leak sensitive data.
- Do not print logs excessively.
- Do not use fmt.Println directly in library code.
- If the project already has a logger, use the existing logger uniformly.
9. Security Requirements
- All external inputs are untrusted.
- Do not hardcode keys, tokens, or passwords.
- Do not commit sensitive configurations to the repository.
- Be mindful of injection risks in file paths, URLs, command executions, SQL, template rendering, etc.
- Authentication and permission checks must be placed in clear locations, and must not rely on the self-discipline of the frontend or callers.
10. Performance Requirements
- Do not optimize prematurely.
- However, do not write obviously inefficient code.
- Avoid unnecessary memory allocations, large object copies, and repeated parsing on hot paths.
- Page, stream, or batch operations should be considered for large data processing.
- If caching is introduced, the consistency, expiration strategy, and invalidation conditions must be explained.
Workflow:
Every time you receive a development task, you must follow these steps:
Step 1: Understand Requirements
- Briefly rephrase the requirements in your own words.
- Clarify inputs, outputs, boundary conditions, and exceptional cases.
- If requirements are vague, list your reasonable assumptions; do not write code blindly.
Step 2: Read Existing Code
- Identify relevant modules, call chains, data structures, interfaces, and tests.
- Explain how the current code works.
- Determine which layer the modification should be placed in.
Step 3: Design Solution
- Provide a minimal viable modification plan.
- Explain why it is placed in these files/modules.
- State whether it affects existing APIs, databases, configurations, or tests.
- If there are multiple solutions, compare their pros and cons and select the more stable one.
Step 4: Coding
- Only modify code related to the task.
- Maintain the existing code style.
- Do not introduce unnecessary new dependencies.
- Do not create duplicate logic.
- Do not leave TODOs, temporary code, or debugging code.
Step 5: Testing
- Supplement or update tests.
- Explain what scenarios the tests cover.
- If tests cannot be run, explain why and give the commands that should be run.
Step 6: Delivery Explanation
- Summarize what was modified.
- Explain why it was modified this way.
- Explain potential risks.
- Provide verification methods.
- If there are incomplete items, they must be explicitly listed; do not pretend they are complete.
Output Format:
Each of your replies should contain:
1. Requirements Understanding
2. Existing Code Analysis
3. Modification Plan
4. Specific Changes
5. Testing and Verification
6. Risks and Precautions
If you are only asked to review code, output:
1. Problem List
2. Severity: Critical / High / Medium / Low
3. Impact Explanation
4. Modification Suggestions
5. Recommended Modification Example
Code Quality Red Lines:
The following behaviors are strictly prohibited:
- Copying and pasting large blocks of duplicate code to complete requirements.
- Stuffing business logic into handlers.
- Passing `map[string]interface{}` everywhere.
- Using global variables to bypass dependency injection.
- Arbitrarily adding util/helper trash-can packages.
- Ignoring errors.
- Catch-all style error handling.
- Continuing to stack logic when functions exceed reasonable length.
- Modifying unrelated code.
- Changing existing behavior without explanation.
- Modifying core logic without tests.
- Introducing large dependencies just to solve small problems.
- Writing code without explaining the verification method.
- Refactoring directly without understanding the existing architecture.
When you find that the existing code is already messy:
- Do not perform a major refactoring all at once.
- Stop the bleeding locally first.
- Write new code within clear boundaries as much as possible.
- Only make necessary changes to old code.
- If refactoring is needed, propose a phased plan first.
Please always write code to the standards of "someone who will maintain this project for a long time", rather than "someone who completes a one-time task".
+129 -16
View File
@@ -1,33 +1,146 @@
# Architecture
# System Architecture
OpenFlare consists of Server, Agent, and local OpenResty on each node.
You will learn: The overall architecture of OpenFlare, the responsibility boundaries of Server, Agent, OpenResty, and the management console frontend, and the request flow of a configuration release from the management console to take effect on a node.
OpenFlare consists of the Server, the Agent, local OpenResty on each node, and the management console frontend. The Server is the control plane, the Agent is the only controlled landing entry point on the node side, and OpenResty is the actual data plane.
```text
OpenFlare Server (Gin + SQLite/PostgreSQL + Web UI)
|
| HTTP API / Config Pull
v
OpenFlare Agent (register / heartbeat / sync / apply / update)
|
v
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
|
v
|
| reverse proxy
v
Origin
```
## Component Responsibilities
| Component | Responsibility |
| --- | --- |
| Server | Management UI, admin APIs, Agent APIs, configuration rendering, version publishing, data storage, and aggregate queries |
| Agent | Registration, heartbeats, synchronization, writing files, configuration validation, reloads, fallback/rollbacks, self-updating, and lightweight data collection |
| OpenResty | Receives real traffic, executes WAF, PoW, authentication, and reverse proxying according to configurations rendered by OpenFlare |
| Frontend | Manages website configurations, WAF, origins, certificates, nodes, versions, users, settings, and observability pages |
## Server
`openflare_server` is a monolithic control plane based on Gin, GORM, SQLite/PostgreSQL, the existing login/session system, and the static frontend build.
`openflare_server` is a monolithic control plane:
It owns the admin UI and API, Agent API, configuration rendering, version publishing, storage, and aggregate queries.
* Gin provides HTTP services.
* GORM accesses SQLite or PostgreSQL.
* The existing login system provides management console Sessions.
* Authentication source and external account binding support GitHub OAuth and standard OIDC.
* The Go Server hosts the static build output of `openflare_server/web`.
The Server does not directly SSH into nodes, nor does it modify node files online. It only saves the control plane state, generates complete configuration versions, and lets nodes actively pull them via the Agent API.
## 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.
`openflare_agent` is a Go monolithic application:
It handles registration, heartbeat, sync, file writes, `openresty -t`, reload, rollback, self-update, and lightweight collection.
* Runs on nodes as a single binary.
* Reads or generates local node information upon startup.
* Performs periodic heartbeats to report status and fetch the active version summary.
* Pulls configurations, backs up old files, writes new files, validates, and reloads upon discovering a new version.
* Attempts to restore execution and roll back when the application fails.
* Maintains the WAF GeoIP mmdb; writes the built-in initial database on startup and updates it regularly based on configuration.
The Agent uniformly executes validations, reloads, starts, and restarts via the OpenResty binary pointed to by `openresty_path`; it falls back to calling `openresty` by default when not configured. In Docker deployments, the Agent image includes the OpenResty binary and follows the same binary control logic.
Node IPs are maintained by Agent registration and heartbeat reports by default; when the admin UI locks a node IP, the Server continues updating runtime fields such as status, versions, and observability, but no longer accepts Agent reports to overwrite that IP.
## Frontend
`openflare_server/web` is the production frontend baseline: Next.js App Router, React 19, TypeScript, and Tailwind CSS.
`openflare_server/web` is the official management console frontend:
* Next.js App Router.
* React 19.
* TypeScript.
* Tailwind CSS.
* TanStack Query manages server state.
The frontend is hosted by the Go Server after static export. All API requests must go through `lib/api/` uniformly and handle the `success/message/data` response structure.
## Data and Request Flow
### Management Console Request Flow
```text
Browser -> Frontend -> /api/* -> controller -> service -> model -> database
```
Mutation APIs on the management console use `POST`, while read-only APIs use `GET`. Both success and failure return a clear `message`.
### Agent Sync Flow
```text
Agent heartbeat -> Server returns active version summary
Agent discovers new version -> Pulls configuration details
Agent writes main configuration / route configuration / certificates / Lua resources / WAF runtime configuration
Agent executes OpenResty validation and reload
Agent reports application result
```
When WebSocket (WS) connection upgrade is enabled by default, the Agent first obtains settings through the HTTP heartbeat, and then attempts to connect to the Agent WebSocket. Once the WS connection is successful, periodic status reporting is carried by WS; when the Server publishes or activates a version, it broadcasts the active version summary to connected Agents, allowing them to enter the synchronization flow immediately. When the WS connection is disconnected or fails to establish, the Agent automatically falls back to the HTTP heartbeat.
### Reverse Proxy Flow
```text
Client -> OpenResty server block -> WAF Lua -> named upstream -> Origin
```
Website configuration is the aggregation boundary of reverse proxies. A website configuration can bind multiple domains and share site-level traffic limits, reverse proxies, and caching configurations.
WAF is executed in the OpenResty `access_by_lua_file` phase. Rules come from `waf_config.json` carried in the current active version; the global rule group takes effect by default, and websites can overlay custom rule groups.
## Core Objects
Currently active entities include:
* `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`
## Key Design Decisions
| Decision | Reason |
| --- | --- |
| Complete configuration versions, instead of online patches | Gives previews, activations, history, and rollbacks stable boundaries |
| Active pull by Agents | Server does not need SSH permissions, nor does it expose remote command execution entry points |
| Global single active version | Reduces MVP complexity and ensures all nodes are consistent by default |
| Website configurations aggregate multiple domains | Supports sharing site-level policies for a business site while allowing certificate binding per domain |
| Server-side aggregation of observability data | Avoids inconsistent results caused by temporary frontend calculations |
## Contributor Reading Suggestions
If you want to modify architecture-related code, read these first:
1. [Product Boundary](./index.md)
2. [Release Model](./release-model.md)
3. [Development Constraints](./development.md)
4. [Repository Structure](../reference/repository.md)
+23 -3
View File
@@ -148,6 +148,8 @@ Currently active entities:
* `traffic_analytics_rollups`
* `node_health_events`
* `options`
* `waf_rule_groups`
* `waf_rule_group_bindings`
General constraints:
@@ -161,6 +163,7 @@ General constraints:
* 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.
* WAF global rule groups are applied to all websites by default, while custom rule groups are bound to site configurations via `waf_rule_group_bindings`; they must be included in the complete configuration version snapshot during publishing.
* `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.
@@ -178,6 +181,19 @@ The database version number is defined in `openflare_server/model`, and it must
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.
Versions 1 through 7 are treated as the historical initial baseline and no longer keep per-version upgrade files. Starting from v8, database migrations must be placed under `openflare_server/model/migrate` and named after the target version, such as `v16.go`. Each version file registers its migration through `init()`, and the current database version is derived from the highest registered target version. Do not change the semantics of released v8+ migrations merely to reorganize files.
When performing a database upgrade, complete the following steps:
1. Decide whether a schema version bump is required: any addition, removal, or rename of tables, columns, indexes, constraints, column types, sharding rules, or persisted-data semantics must upgrade the version.
2. Add `openflare_server/model/migrate/vN.go`, where `N` is the target version. The file header must include a comment explaining what this upgrade changes and why it is needed.
3. Implement `VN()` in `vN.go`, and call `Register(VN())` from `init()`. `FromVersion` must be `N-1`, and `ToVersion` must be `N`.
4. Implement the upgrade logic in `migrateVN`. Use `Context` to call shared capabilities such as `ApplyCurrentSchema`, historical backfills, and default-data initialization; complex data repairs must be explicit and must not rely on `AutoMigrate` alone.
5. Implement post-upgrade validation in `validateVN`. Validation must cover at least the existence of new tables/columns/indexes, required default data, and required data backfills.
6. If the migration needs new shared backfill or validation helpers, place them in `openflare_server/model/migrations.go` or another suitable model file, and expose them through `Context` to `model/migrate`; avoid reverse-importing `model` from the subpackage and creating an import cycle.
7. Add migration tests covering at least upgrade from the `N-1` old database to `N`, including schema version, table/column structure, key data backfills, and validation results. The `model/migrate` registry test checks version continuity, but business-specific migrations still require tests.
8. Update design/development docs; if management APIs, configuration fields, or user-visible behavior change, also update the relevant guides, configuration reference, and Swagger documents.
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.
@@ -232,14 +248,18 @@ Version constraints:
The Agent must satisfy:
* Read or generate local `node_id` after startup.
* Periodic heartbeat and synchronization.
* Periodic heartbeats and synchronization.
* Conventional synchronization prioritizes judging based on the version summary returned by the heartbeat.
* When WS connection upgrade is enabled and the connection is successful, the Agent can receive active version summaries via WS and immediately synchronize; WS failure or disconnection must fall back to HTTP heartbeats.
* Back up old files first when discovering a new version.
* Write main configurations, route configurations, and necessary certificate files.
* Write WAF/PoW runtime configurations, and ensure WAF Lua resources are managed uniformly by the Agent.
* 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.
* Periodic runtime health checks must not call `openresty -t`, preventing health probes from triggering synchronous upstream domain name resolutions; they should prioritize requesting `/openflare/stub_status` on the local `openresty_observability_port`, using HTTP `200 OK` as the basis for judging that the OpenResty main process and workers are serving.
* 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.
* Report warning when OpenResty recovers normally after rollback; if there is no historical main configuration to restore locally, it must be allowed to write the built-in safe fallback configuration and pull up an OpenResty runtime state that only listens to port `80` externally and uniformly returns `503 Service Unavailable` and `OpenFlare: No Valid Configuration`, while retaining the local `stub_status` health check entry. The fallback runtime state must not clear the blocked status of the failed target; the application logs must reflect that the target version failed but the fallback runtime has started. Report failure when there is a historical main configuration but it still cannot recover after rollback.
* Once a target `version + checksum` application fails and rolls back, the Agent must block repeated applications of this target in its local state.
* When the Agent maintains the local MaxMind mmdb, download or refresh failures can only record warnings, and must not block heartbeats, synchronization, configuration application, or OpenResty health checks.
## Frontend Requests, State, and Types
+21
View File
@@ -35,6 +35,7 @@ OpenFlare is currently not positioned as a general-purpose log platform, service
| 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 |
| WAF | Maintains IP/IP ranges black/whitelists and country-level geographical black/whitelists with global and website-customized rule groups |
| 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 |
@@ -76,6 +77,8 @@ Currently active entities:
* `node_metric_snapshots`
* `traffic_analytics_rollups`
* `node_health_events`
* `waf_rule_groups`
* `waf_rule_group_bindings`
## Site Configuration Constraints
@@ -116,6 +119,24 @@ During publishing rendering:
* 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.
## WAF Constraints
WAF uses rule groups as configuration boundaries. The system fixes a global rule group, which is applied to all websites by default; websites can overlay multiple custom rule groups.
Phase 1 supports:
* IP / IP range whitelists and blacklists.
* Country-level region whitelists and blacklists.
* Rule group-level blocking status codes and response pages, defaulting to `418` and an empty page.
Evaluation order:
* Whitelists are bypass exceptions; if any enabled rule group matches a whitelist, the request is allowed.
* If no whitelist is matched, blacklists continue to be evaluated.
* When multiple blacklists match, the global rule group takes precedence, followed by custom rule groups in ascending order of their IDs.
Region recognition is based on the MaxMind mmdb maintained locally on the node by the Agent, and the OpenResty Lua reads the local database during the request path. When GeoIP dependencies are unavailable, region rules must be skipped, without affecting IP rules and the reverse proxy main link.
## 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.
+12 -10
View File
@@ -15,13 +15,14 @@ Modify rules -> Preview / View diff -> Publish -> Generate complete configuratio
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.
2. Read the Server side OpenResty main configuration template, performance parameters, cache parameters, and necessary Lua resources.
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.
4. Read the WAF global rule group, custom rule groups, and website binding relationships.
5. Render the complete OpenResty configuration and WAF runtime configuration.
6. Calculate the `checksum`.
7. Write to `config_versions`.
8. Switch the activated version.
9. Let the Agent discover and apply it in subsequent heartbeats.
The version number format is fixed as `YYYYMMDD-NNN`.
@@ -33,9 +34,9 @@ Publishing generates a new complete configuration version. The version must cont
## Activating Version
There can only be one activated version globally at a time.Differentiated versions grouped by nodes are currently not supported.
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.
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. When Agent WS connection upgrade is enabled and the connection is available, the Server will broadcast the latest active version summary after successfully publishing or activating a version. Upon receiving it, the Agent immediately pulls and applies the configuration using the ordinary synchronization flow. When WS is unavailable, changes are still discovered at HTTP heartbeat intervals.
## Immutable History
@@ -53,12 +54,12 @@ 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.
3. Write the main configuration, route configurations, certificates, necessary Lua resources, and WAF/PoW runtime configurations.
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.
If the activation of the new configuration fails, the Agent must try to restore execution; report a warning when the rollback succeeds. If there is no historical main configuration to roll back to locally, the Agent will write the built-in safe fallback configuration and try to pull up OpenResty: this configuration only listens to port `80` externally, contains no user routes, uniformly returns `503 Service Unavailable` and `OpenFlare: No Valid Configuration`, and retains the local `stub_status` health check entry. If fallback startup is successful, it still blocks the failed target version and reports a warning; report a failure when there is a historical main configuration but 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.
@@ -69,3 +70,4 @@ Once a target `version + checksum` application fails and rolls back, the Agent w
* 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`.
* WAF rule groups and website binding relationships must enter the snapshot and checksum along with the complete configuration version, and must not rely on the current mutable WAF configuration when rolling back.
+40 -4
View File
@@ -1,8 +1,8 @@
# Deployment
You will learn the recommended OpenFlare deployment model, Server and Agent requirements, source startup workflow, integration steps, upgrade paths, and uninstall entry points.
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.
For production, use PostgreSQL for the Server database and set `SESSION_SECRET` explicitly. The recommended deployment method for the Agent is Docker deployment (i.e., running the Agent image that already includes OpenResty); it also supports shell-script installation or running manually.
## Topology
@@ -40,9 +40,10 @@ Agent:
| --- | --- |
| 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 |
| OpenResty | Required for local Agent installs, or specified via `--openresty-path` |
| Docker | Required only when running the Agent Docker image |
| Network | Agent node must reach the Server URL |
| GeoIP | WAF regional rules use local MaxMind mmdb; Agent initializes a built-in library and updates it periodically |
[Needs confirmation: recommended production CPU, memory, and disk size]
@@ -128,7 +129,37 @@ Default port is `3000`. You can also set it explicitly:
go run . --port 3000 --log-dir ./logs
```
## Connect Agent
## Run Agent in Docker (Recommended)
Docker deployment is the recommended deployment method for the Agent. In Docker deployments, directly run the Agent image. This image is built on top of the OpenResty image and includes both the Agent controller and the OpenResty binary. When `node_ip` is not explicitly configured, the Agent prioritizes obtaining the real public egress IP via a third-party API, avoiding registering the Docker bridge address as the node IP.
Mounting the configuration file:
```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
```
Using environment variables:
```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
```
## Connect Agent (Script Installation)
In addition to Docker deployment, you can also deploy the Agent on the local host using our installation script.
With `discovery_token`:
@@ -199,6 +230,10 @@ Minimal `agent.json`:
When `openresty_path` is not configured, Agent runs `openresty`.
By default, the Agent will attempt to upgrade to a WebSocket after a successful HTTP heartbeat. When the upgrade succeeds, the Server immediately notifies the Agent of any configuration publications or activations; if the WebSocket cannot be established or is unexpectedly disconnected, the Agent automatically falls back to HTTP heartbeat synchronization.
WAF regional rules rely on the Agent's local `GeoLite2-Country.mmdb`. Upon startup, the Agent initializes a built-in database at `data_dir/etc/openflare/GeoLite2-Country.mmdb` and attempts to update it periodically based on configuration; update failures only record warnings, and do not affect configuration synchronization or OpenResty reload.
## Minimal Integration Flow
1. Start Server and sign in.
@@ -220,6 +255,7 @@ Server:
Agent:
* Agents follow stable releases by default.
* Agent autoupdate requires the GitHub Release to include both the target binary and a matching `.sha256` checksum file; the download must pass SHA-256 validation before the local executable is replaced.
* The install script can be rerun to reinstall or upgrade.
* Preview upgrades require manual action.
+20 -1
View File
@@ -102,7 +102,26 @@ 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
## 3. Install/Run Agent
The recommended deployment method for the Agent is Docker deployment (i.e., running the Agent image that already includes OpenResty); it also supports shell-script installation on the local host.
### Option A: Run Agent in Docker (Recommended)
Run the Agent Docker image on the proxy node:
```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 \
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
ghcr.io/rain-kl/openflare-agent:latest
```
### Option B: Run the Installation Script (Local Host)
Run the install script on the proxy node.
+12
View File
@@ -74,6 +74,16 @@ HTTPS is bound per domain, not forced for the whole site.
If a site contains multiple domains, the Server groups HTTPS output by certificate while keeping all domains in the same site snapshot.
## Configure WAF and PoW
Security controls are managed from the **WAF** sidebar entry:
* The WAF page manages the global rule group and custom rule groups. The global rule group always applies to every site. Custom rule groups can be applied to selected sites from the rule group drawer or bound from the site detail `WAF` section.
* `PoW` is a tab inside the selected rule group, between `Allow / Block Lists` and `Block Response`. It reuses the existing per-site PoW execution logic and can apply the current PoW policy to every site or the sites bound to the current rule group.
* Site details no longer edit PoW directly. They show the always-on global WAF group and let you bind custom WAF rule groups. PoW rule content and scope should be maintained from the WAF page.
After changing WAF or PoW settings, publish and activate a new configuration version so Agents can apply the updated OpenResty runtime.
## Release, Activate, and Roll Back
Standard flow:
@@ -96,6 +106,8 @@ Node pages answer three questions:
| Which version is running? | Current version on the node detail page |
| Did the last apply succeed? | Apply logs |
Node IPs are filled automatically by Agent registration and subsequent heartbeats by default. When you enter or change an IP in the admin UI, the node editor enables "Lock node IP" by default; Agent reports will not overwrite the IP while the lock is enabled. After unlocking, the next Agent heartbeat or WebSocket status report can update it again.
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
@@ -1,84 +0,0 @@
# 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.
+224 -43
View File
@@ -1,78 +1,259 @@
# Configuration
# Configuration Reference
You will learn: What configuration sources are supported by OpenFlare Server, frontend builds, and Agents, what the default values of configuration items are, and how common deployment combinations should be configured.
This document summarizes the Server and Agent configuration items supported by OpenFlare `1.0.0`, retaining only the startup, deployment, and runtime parameters that remain valid.
## Configuration Sources
The Server supports three types of configuration sources:
1. Command-line parameters.
2. Environment variables.
3. Runtime configurations in the database `Option` table.
The Agent supports:
1. `-config` command-line parameter.
2. `agent.json` configuration file.
3. A few log-related environment variables.
## Configuration File Locations
| Component | Default Location | Description |
| --- | --- | --- |
| Server SQLite | `openflare.db` | Can be modified via `SQLITE_PATH` |
| Agent Configuration File | `./agent.json` | Can be specified via `-config` |
| One-click Install Agent Config | `/opt/openflare-agent/agent.json` | Default generated by the installation script |
| Agent Data Directory | `data` under the config directory | Can be modified via `data_dir` |
## Server CLI Flags
```bash
cd openflare_server
go run . --port 3000 --log-dir ./logs
```
| 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` |
| `--port` | Specify the port the Server listens on | `3000` |
| `--log-dir` | Specify the log directory | empty |
| `--version` | Print the current version and exit | `false` |
| `--help` | Print help information and exit | `false` |
## Server Environment Variables
| Variable | Purpose | Default |
| --- | --- | --- |
| `PORT` | Server listen port | `3000` |
| `GIN_MODE` | Gin mode | release unless `debug` |
| `GIN_MODE` | Gin execution 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 |
| `SESSION_SECRET` | Session signing secret | randomly generated on startup |
| `SQLITE_PATH` | SQLite database file path | `openflare.db` |
| `DSN` | PostgreSQL DSN, preferred over SQLite when set | 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.
Description:
* When both `DSN` and `SQL_DSN` exist, `DSN` takes precedence.
* When `DSN`/`SQL_DSN` and `SQLITE_PATH` exist simultaneously, PostgreSQL takes precedence.
* When the target PostgreSQL database is empty and a local SQLite file exists at `SQLITE_PATH`, the Server automatically migrates SQLite data at startup and prints table-by-table migration progress in the logs.
* `SESSION_SECRET` must be explicitly configured in production.
* When `REDIS_CONN_STRING` is not configured, related capabilities fall back to in-process implementations.
## Runtime Options
The following options are maintained on the settings page of the management console and can be hot-updated:
| Option | Purpose | Default |
| --- | --- | --- |
| `AgentHeartbeatInterval` | Agent heartbeat interval (milliseconds) | `10000` |
| `AgentWebsocketUpgradeEnabled` | Whether to allow Agents to upgrade to WebSockets after successful HTTP heartbeats | `true` |
| `NodeOfflineThreshold` | Node offline threshold (milliseconds) | `120000` |
| `AgentUpdateRepo` | Agent self-update repository | `Rain-kl/OpenFlare` |
| `GeoIPProvider` | Node/IP region lookup provider | `ipinfo` |
| `DatabaseAutoCleanupEnabled` | Whether to enable daily automatic cleanup of observability data | `false` |
| `DatabaseAutoCleanupRetentionDays` | In-database retention days, at least 1 day | `30` |
| `GlobalApiRateLimitNum` / `GlobalApiRateLimitDuration` | Global API rate limit count / window | `300` / `180` |
| `GlobalWebRateLimitNum` / `GlobalWebRateLimitDuration` | Global Web rate limit count / window | `300` / `180` |
| `CriticalRateLimitNum` / `CriticalRateLimitDuration` | Sensitive API rate limit count / window | `100` / `1200` |
Description:
* When `DatabaseAutoCleanupEnabled` is enabled, the Server automatically cleans up three types of observability data (`node_access_logs`, `node_metric_snapshots`, `node_request_reports`) at 3:00 AM every day.
* `DatabaseAutoCleanupRetentionDays` is the unified retention count and must be greater than or equal to 1.
* The management console supports leaving the retention days blank during manual cleanup to directly delete all history of the corresponding datasets.
* The GitHub Release pointed to by `AgentUpdateRepo` must provide a matching `.sha256` checksum file for each Agent binary, such as `openflare-agent-linux-amd64.sha256`; the self-update validates this SHA-256 digest before replacing the executable.
* Third-party logins no longer use `GitHubOAuthEnabled`, `GitHubClientId`, or `GitHubClientSecret` as primary configuration entries; these legacy options are only used for migration to the default GitHub authentication source during upgrades.
* Legacy options for WeChat login are retained for compatibility, but the management console no longer provides WeChat login configuration entries.
* Legacy options for Turnstile and backend verification remain, and existing configurations will continue to take effect.
## OpenResty Parameters
OpenResty performance and caching parameters continue to be stored uniformly in the `Option` table. Currently common items include:
* `OpenRestyWorkerProcesses`
* `OpenRestyWorkerConnections`
* `OpenRestyWorkerRlimitNofile`
* `OpenRestyKeepaliveTimeout`
* `OpenRestyProxyConnectTimeout`
* `OpenRestyProxySendTimeout`
* `OpenRestyProxyReadTimeout`
* `OpenRestyProxyBufferingEnabled`
* `OpenRestyGzipEnabled`
* `OpenRestyCacheEnabled`
* `OpenRestyCachePath`
* `OpenRestyCacheMaxSize`
These parameters must be validated, saved, and participate in version rendering in a structured way.
Constraints:
* The management console no longer exposes `resolver` configuration.
* Upstreams are uniformly rendered as named `upstream` blocks with keepalives enabled.
* A single upstream carrying a base path or query will append the original URI in `proxy_pass`.
* Multiple upstreams still require each upstream to be pure `scheme://host[:port]`, and the protocol must be consistent within the same rule.
* `OpenRestyCacheEnabled` is used to enable the caching infrastructure and global default parameters; the actual caching enablement and hit policies (based on URL, suffix, or path) are decided separately by each individual `proxy_routes`.
* The default cache key is `$scheme$host$request_uri`.
* The default `keepalive_timeout` is `20` seconds, and the default `proxy_connect_timeout` is `3` seconds.
* The default event model is `epoll`, and `multi_accept` is enabled by default.
* HTTPS listeners use the independent `http2 on;` directive by default to avoid deprecation warnings for `listen ... http2` in newer Nginx/OpenResty versions.
## 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` |
| `NEXT_PUBLIC_API_BASE_URL` | Frontend API request base path | `/api` |
| `NEXT_PUBLIC_APP_VERSION` | Frontend displayed version number | `dev` |
| `NEXT_DEV_BACKEND_URL` | Dev backend proxy target | `http://127.0.0.1:3000` |
## Runtime Options
## Agent Environment Variables
The settings page maintains these hot-updatable options:
| Option | Purpose | Default |
| Variable | 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` |
| `LOG_LEVEL` | Agent log level | `info` |
| `OPENFLARE_SERVER_URL` | Control plane URL, can override `agent.json` | empty |
| `OPENFLARE_AGENT_TOKEN` | Node-exclusive auth token, can override `agent.json` | empty |
| `OPENFLARE_DISCOVERY_TOKEN` | Global token for first registration, can override `agent.json` | empty |
| `OPENFLARE_NODE_NAME` | Node name, can override `agent.json` | empty |
| `OPENFLARE_NODE_IP` | Node IP, can override `agent.json` | empty |
| `OPENFLARE_DATA_DIR` | Agent data directory, can override `agent.json` | empty |
| `OPENFLARE_OPENRESTY_PATH` | OpenResty binary path, can override `agent.json` | empty |
| `OPENFLARE_HEARTBEAT_INTERVAL` | Heartbeat interval, can override `agent.json` | empty |
| `OPENFLARE_REQUEST_TIMEOUT` | Request timeout, can override `agent.json` | empty |
| `OPENFLARE_OPENRESTY_OBSERVABILITY_PORT` | Local observability port, can override `agent.json` | empty |
| `OPENFLARE_MMDB_PATH` | WAF GeoIP mmdb path, can override `agent.json` | empty |
| `OPENFLARE_MMDB_UPDATE_INTERVAL` | WAF GeoIP mmdb update interval, can override `agent.json` | empty |
| `OPENFLARE_MMDB_DOWNLOAD_URL` | WAF GeoIP mmdb download URL, can override `agent.json` | empty |
OpenResty performance and cache options are also stored in the Option table, including `OpenRestyWorkerProcesses`, `OpenRestyWorkerConnections`, `OpenRestyProxyConnectTimeout`, `OpenRestyProxyReadTimeout`, `OpenRestyCacheEnabled`, `OpenRestyCachePath`, and `OpenRestyCacheMaxSize`.
## Agent CLI Flags
`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.
| Flag | Purpose | Default |
| --- | --- | --- |
| `-config` | Specify the path to the Agent configuration file | `./agent.json` |
## Agent Configuration
## Agent Configuration Fields
Agent supports the `-config` CLI flag, an `agent.json` file, and the `LOG_LEVEL` environment variable.
| Field | Purpose | Required | Default / behavior |
| 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 |
| `agent_token` | Node-exclusive 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 | automatically uses host name |
| `node_ip` | Node IP | no | auto-detected; prioritizes obtaining the real public egress IP via third-party APIs, falling back to local interfaces on failure |
| `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 |
| `data_dir` | Agent data directory | no | `data` under the config file directory |
| `main_config_path` | OpenResty main config write path | no | `data_dir/etc/nginx/nginx.conf` |
| `route_config_path` | Route config write path | no | `data_dir/etc/nginx/conf.d/openflare_routes.conf` |
| `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 |
| `cert_dir` | Certificate write directory | no | `data_dir/etc/nginx/certs` |
| `openresty_cert_dir` | Certificate read directory in OpenResty config | no | same as `cert_dir` |
| `lua_dir` | Lua scripts and static resources write directory | no | `data_dir/etc/nginx/lua` |
| `openresty_lua_dir` | Lua read directory in OpenResty config | no | same as `lua_dir` |
| `runtime_config_dir` | Agent runtime config write directory, e.g., `pow_config.json` | no | `data_dir/etc/openflare` |
| `mmdb_path` | WAF GeoIP mmdb file path | no | `data_dir/etc/openflare/GeoLite2-Country.mmdb` |
| `mmdb_update_interval` | WAF GeoIP mmdb update interval | no | `86400000` milliseconds |
| `mmdb_download_url` | WAF GeoIP mmdb download URL | no | built-in GeoLite2 Country download URL |
| `observability_buffer_path` | Observability buffering file path | no | `data_dir/var/lib/openflare/observability-buffer.json` |
| `observability_replay_minutes` | Minutes to automatically replay recent observability data | no | `15` |
| `state_path` | Agent local state file path | no | `data_dir/var/lib/openflare/agent-state.json` |
| `heartbeat_interval` | Heartbeat interval | no | `10000` milliseconds |
| `request_timeout` | HTTP request timeout | no | `10000` milliseconds |
`heartbeat_interval` and `request_timeout` accept milliseconds or Go duration strings.
Description:
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.
* `agent_token` and `discovery_token` cannot both be empty.
* `heartbeat_interval` and `request_timeout` support integer milliseconds or Go duration strings.
* When the Server runtime option `AgentWebsocketUpgradeEnabled` is enabled, the Agent will attempt to upgrade to a WebSocket after a successful HTTP heartbeat; it automatically falls back to HTTP heartbeats when connection fails or is disconnected.
* When `openresty_path` is not configured, `openresty` is called by default.
* The Agent's periodic health checks request `http://127.0.0.1:<openresty_observability_port>/openflare/stub_status`, no longer judging runtime health via high-frequency `openresty -t`; validation before configuration application, startup recovery, and reloads will still execute `openresty -t -c <main_config_path>`.
* The Agent initializes and periodically updates `mmdb_path` for OpenResty WAF Lua to execute country-level geographical rules; update failures only record warnings, and do not block sync or reloads.
* If `agent.json` does not exist but environment variables such as `OPENFLARE_SERVER_URL` and tokens are sufficient, the Agent can start directly; environment variables take precedence when both exist.
* When the Agent is not configured with `node_ip`, it first queries `https://realip.cc` for the real public egress IP, adapting to Docker/NAT scenarios; it falls back to local interface detection on failure, preferring a public IPv4 address.
* When the Agent automatically detects a private `node_ip`, the Server prioritizes retaining the public address of the Agent's direct connection during registration/heartbeat phases, avoiding misregistering internal interface addresses in NAT or multi-interface scenarios.
* When "Lock node IP" is enabled in the admin UI, the Server keeps the manually configured node IP and Agent registration, HTTP heartbeat, or WebSocket status reports will not overwrite that field; after unlocking, the next report can fill it again.
## Common Configuration Combinations
### Production 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'
```
### Local Server + SQLite
```bash
export SESSION_SECRET='dev-session-secret'
export SQLITE_PATH='./openflare-dev.db'
export LOG_LEVEL='debug'
go run .
```
### Agent + Default 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 + Customized OpenResty Path
```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
}
```
## Maintenance Requirements
When the following contents change, this document must be updated in sync:
* Server command-line parameters.
* Server environment variables.
* Agent command-line parameters.
* Agent configuration fields.
* Default values, purposes, or examples of any configuration items.
-188
View File
@@ -1,188 +0,0 @@
# 本地开发
你会学到:如何搭建 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. 风险较高的修改补充测试或等效联调验证。
数据库结构变更必须提升数据库版本号,并补充从上一版本到新版本的显式迁移方法和校验逻辑。
+9 -7
View File
@@ -10,9 +10,10 @@ OpenFlare 是一套自托管的 OpenResty 控制面。它把反向代理网站
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 应用和前端构建问题。
3. [WAF 自动 IP 组语法](./waf-ip-group-expr.md):编写自动 IP 组 Expr 规则,了解关键字含义和预设规则。
4. [部署说明](../reference/deployment.md):把 Server 和 Agent 放到更接近生产的环境中运行。
5. [配置项参考](../reference/configuration.md):查 Server 环境变量、运行时 Option 和 Agent 配置字段。
6. [故障排查](./troubleshooting.md):按症状排查登录、数据库、节点同步、OpenResty 应用和前端构建问题。
## 按角色查找
@@ -20,11 +21,12 @@ OpenFlare 是一套自托管的 OpenResty 控制面。它把反向代理网站
| --- | --- |
| 5 分钟内跑起管理端 | [快速开始](./quick-start.md) |
| 发布第一条反向代理配置 | [发布第一份配置](./first-site.md) |
| 接入或重装节点 Agent | [接入 Agent](./agent.md) |
| 从源码启动 Server | [启动 Server](./server.md) |
| 编写自动 IP 组规则 | [WAF 自动 IP 组语法](./waf-ip-group-expr.md) |
| 接入或重装节点 Agent | [接入 Agent](../reference/agent.md) |
| 从源码启动 Server | [启动 Server](../reference/server.md) |
| 配置 GitHub 或 OIDC 登录 | [SSO 登录配置](./sso.md) |
| 升级 Server 或 Agent | [升级与维护](./upgrade.md) |
| 参与开发或修复问题 | [本地开发](./development.md) 与 [开发约束](../design/development.md) |
| 升级 Server 或 Agent | [升级与维护](../reference/upgrade.md) |
| 参与开发或修复问题 | [本地开发](../design/development.md) 与 [开发约束](../guildline/development-constraints.md) |
| 理解架构和发布模型 | [系统架构](../design/architecture.md) 与 [发布模型](../design/release-model.md) |
## 文档分区
+20 -1
View File
@@ -102,7 +102,26 @@ Agent 可以用两类凭证接入:
[需要确认:当前管理端中创建或查看 `discovery_token` 与节点 `agent_token` 的准确菜单路径]
## 3. 安装 Agent
## 3. 安装/运行 Agent
Agent 部署方式推荐使用 Docker 部署(即直接运行内置 OpenResty 的 Agent 镜像);亦支持通过安装脚本将 Agent 部署在本地宿主机上。
### 方式 A:Docker 运行 Agent(推荐)
在代理节点上直接运行 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 \
-v openflare-agent-data:/data \
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
ghcr.io/rain-kl/openflare-agent:latest
```
### 方式 B:执行安装脚本(本地部署)
在代理节点上执行安装脚本。
+15
View File
@@ -76,6 +76,19 @@ HTTPS 按域名绑定证书,而不是按整个网站统一强制启用。
如果一个网站包含多个域名,Server 发布时会按证书分组渲染 HTTPS 配置,同时保持这些域名属于同一份网站快照。
## 配置 WAF 与 PoW
安全防护统一从管理端侧边栏的 **WAF** 入口进入:
* WAF 页面维护全局规则组和自定义规则组。全局规则组始终应用到全部网站;自定义规则组可以在规则组内一键选择网站,也可以在网站详情的 `WAF` 分区绑定。
* 点击 WAF 页面中的 **管理 IP 组** 可以进入独立 IP 组页面。手动 IP 组直接维护 IP/IP 段;自动 IP 组使用 Expr 规则按单个 IP 聚合请求日志并定时更新名单;订阅 IP 组可从远程文本或 JSON 源定时同步。
* 自动 IP 组页面提供两个预设:单个 IP 请求数大于 100 且 404 占比不低于 80%;单个 IP 通过 IP 地址访问次数大于 50 且该访问占比大于 50%。保存前可点击 **测试规则** 查看当前日志窗口命中的 IP,保存后可点击 **立即执行** 更新组内名单,语法见 [WAF 自动 IP 组规则语法](./waf-ip-group-expr.md)。
* 在 WAF 规则组的黑白名单中,IP 维度既可以直接添加 IP/IP 段,也可以引用已有 IP 组。发布时版本只携带 IP 组引用 ID;Agent 会按 checksum 差异同步 IP 组成员,并在 Server 通过 WebSocket 广播 IP 组更新时实时落地到节点。
* `PoW` 是规则组内的一个配置 Tab,位于 `黑白名单` 与 `拦截返回` 之间,复用站点已有 PoW 执行逻辑,可将当前 PoW 配置应用到全部网站或当前规则组绑定的网站。
* 网站详情页不再单独编辑 PoW 规则,只展示全局 WAF 规则组并绑定自定义 WAF 规则组。PoW 的启用范围和规则内容应回到 WAF 页面统一维护。
WAF 规则组、网站绑定或 PoW 配置修改后,需要重新发布并激活配置版本,Agent 才会拉取并应用到 OpenResty。IP 组成员变化不需要重新发布版本;在线 Agent 会通过 WebSocket 增量更新,离线或未升级 WS 的 Agent 会在下一次心跳中按 checksum 差异补齐。
## 发布、激活与回滚
标准链路:
@@ -98,6 +111,8 @@ HTTPS 按域名绑定证书,而不是按整个网站统一强制启用。
| 当前运行哪个版本 | 节点详情中的当前版本 |
| 最近一次应用是否成功 | 应用记录 |
节点 IP 默认由 Agent 注册和后续心跳自动回填。若在管理端填写或修改 IP,节点编辑会默认开启“锁定节点 IP”;开启后 Agent 上报不会覆盖该 IP。关闭锁定后,下一次 Agent 心跳或 WebSocket 状态上报会重新按自动逻辑更新。
访问分析和资源快照用于基础观测。OpenFlare 只保留受控时间窗口内的访问明细,不定位为通用日志平台。如果需要长期日志检索,应接入独立日志系统。
## 常见场景
+161
View File
@@ -0,0 +1,161 @@
# WAF 自动 IP 组规则语法
自动 IP 组用于从请求日志中按单个客户端 IP 聚合指标,再用 Expr 表达式判断是否把该 IP 加入组内名单。自动 IP 组可以被 WAF 规则组的 IP 黑名单或白名单引用;发布配置时,Server 只把 IP 组引用 ID 写入 `waf_config.json`,IP 组成员由 Agent 独立同步到本地运行时文件。
## 配置结构
自动 IP 组的配置是一个 JSON 对象:
```json
{
"lookback_minutes": 60,
"rules": [
{
"name": "单 IP 404 高频扫描",
"expr": "request_count > 100 && status_404_ratio >= 0.8"
}
]
}
```
字段说明:
| 字段 | 类型 | 作用 |
| --- | --- | --- |
| `lookback_minutes` | number | 每次执行时回看多少分钟内的请求日志。未填写时默认 60 分钟,最小 5 分钟,最大 43200 分钟。 |
| `rules` | array | 自动规则列表。任意一条规则命中时,该 IP 会进入自动 IP 组名单。 |
| `rules[].name` | string | 规则名称,只用于界面展示和错误提示。 |
| `rules[].expr` | string | Expr 表达式,必须返回布尔值。 |
## 执行口径
自动规则不是逐条请求判断,而是先按单个客户端 IP 聚合:
1. Server 读取最近 `lookback_minutes` 分钟内的请求日志。
2. 按 `remote_addr` 归一化后的 IP 分组。
3. 为每个 IP 计算请求数、404 数、直连 IP Host 次数等指标。
4. 逐个 IP 执行 `rules[].expr`。
5. 只要某个 IP 命中任意规则,就写入该自动 IP 组的 `IP / IP 段` 列表。
Host 是否为“通过 IP 访问”按请求日志中的 `Host` 字段判断:如果 Host 是 IPv4 或 IPv6 字面量,例如 `203.0.113.10`、`[2001:db8::10]`、`203.0.113.10:443`,就计入 `ip_host_count`。
## 可用关键字
表达式中可以直接使用以下字段:
| 关键字 | 类型 | 作用 |
| --- | --- | --- |
| `ip` | string | 当前正在判断的客户端 IP。 |
| `request_count` | number | 当前 IP 在回看窗口内的总请求数。 |
| `status_404_count` | number | 当前 IP 在回看窗口内返回 404 的请求数。 |
| `status_404_ratio` | number | 404 请求占比,计算方式为 `status_404_count / request_count`。 |
| `ip_host_count` | number | 当前 IP 通过 IP 地址作为 Host 访问的请求数。 |
| `ip_host_ratio` | number | 通过 IP 地址访问的占比,计算方式为 `ip_host_count / request_count`。 |
| `client_error_count` | number | 当前 IP 返回 4xx 状态码的请求数。 |
| `server_error_count` | number | 当前 IP 返回 5xx 状态码的请求数。 |
| `last_seen_unix` | number | 当前 IP 在回看窗口内最后一次请求的 Unix 秒级时间戳。 |
比例字段都是 `0` 到 `1` 之间的小数。80% 应写成 `0.8`,50% 应写成 `0.5`。
## Expr 常用写法
自动 IP 组使用 Expr 语法,当前表达式必须返回布尔值。
常用运算符:
| 写法 | 作用 | 示例 |
| --- | --- | --- |
| `>`、`>=`、`<`、`<=` | 数值比较 | `request_count > 100` |
| `==`、`!=` | 相等或不相等 | `ip != "127.0.0.1"` |
| `&&` | 并且 | `request_count > 100 && status_404_ratio >= 0.8` |
| `||` | 或者 | `status_404_ratio >= 0.8 || server_error_count > 20` |
| `!` | 取反 | `!(ip == "127.0.0.1")` |
| `in` | 判断值是否在列表中 | `ip in ["203.0.113.10", "198.51.100.20"]` |
| `not in` | 判断值是否不在列表中 | `ip not in ["127.0.0.1"]` |
| `()` | 分组控制优先级 | `(request_count > 100 && status_404_ratio >= 0.8) || server_error_count > 50` |
## 内置预设
管理端内置两个预设规则,可以直接添加后再按需调整:
```json
{
"name": "单 IP 404 高频扫描",
"expr": "request_count > 100 && status_404_ratio >= 0.8"
}
```
含义:单个 IP 在回看窗口内请求数大于 100,并且 404 状态码占比不低于 80%。
```json
{
"name": "单 IP 直连访问异常",
"expr": "ip_host_count > 50 && ip_host_ratio > 0.5"
}
```
含义:单个 IP 通过 IP 地址作为 Host 访问的次数大于 50,并且这种访问占比大于 50%。
## 示例
高频 404 扫描:
```json
{
"lookback_minutes": 60,
"rules": [
{
"name": "高频 404 扫描",
"expr": "request_count > 100 && status_404_ratio >= 0.8"
}
]
}
```
IP 直连访问异常:
```json
{
"lookback_minutes": 30,
"rules": [
{
"name": "IP 直连访问异常",
"expr": "ip_host_count > 50 && ip_host_ratio > 0.5"
}
]
}
```
同时捕获高 4xx 与高 5xx:
```json
{
"lookback_minutes": 120,
"rules": [
{
"name": "异常错误率",
"expr": "(client_error_count > 80 && request_count > 100) || server_error_count > 30"
}
]
}
```
排除可信 IP:
```json
{
"lookback_minutes": 60,
"rules": [
{
"name": "排除可信 IP 的 404 扫描",
"expr": "ip not in [\"203.0.113.10\", \"198.51.100.20\"] && request_count > 100 && status_404_ratio >= 0.8"
}
]
}
```
## 使用建议
先用较短的回看窗口和较高阈值观察命中结果,再逐步调整阈值。管理端 IP 组页面支持在保存前点击 **测试规则**,直接查看当前回看窗口内命中的 IP;自动 IP 组真正执行后会覆盖该组的 IP 列表。如果要长期保留某些地址,建议放入手动 IP 组,并在 WAF 规则组中同时引用手动组和自动组。
自动 IP 组更新后不需要重新发布配置版本。在线 Agent 会通过 WebSocket 收到变更 IP 组并更新本地 `waf_ip_groups.json`;WebSocket 不可用时,Agent 会在下一次心跳中上报本地 IP 组 checksum,Server 只返回 checksum 不一致的 IP 组。
+57
View File
@@ -0,0 +1,57 @@
# OpenFlare 特定项目开发准则 (Project Guidelines)
本文档定义了针对 **OpenFlare** 项目特定的后端开发约束、架构设计模式、GORM 数据库交互规范以及关键的 JSON 序列化避坑指南。所有参与项目后端开发的代码必须严格遵守。
---
## 1. 统一接口输入与响应处理(Controller 约束)
为了保证 API 的一致性,并消除控制器层中大量的样板代码,所有 Gin Controller 必须遵守以下规范:
### 1.1 参数解析与绑定
- **URL ID 参数解析**:必须调用统一的 `parseIDParam(c)` 辅助函数。严禁手写 `strconv.ParseUint(c.Param("id"), ...)`。
- **JSON 请求体绑定**:必须调用统一的 `bindJSON(c, &input)` 辅助函数。严禁手动调用 `c.ShouldBindJSON` 或 `json.NewDecoder` 并重复编写错误返回逻辑。
### 1.2 标准 API 响应
- 所有控制器方法的返回必须统一使用 `respondSuccess`、`respondFailure`、`respondBadRequest` 等标准方法。
- **严禁手写** `c.JSON(http.StatusOK, gin.H{...})`,以确保全局 API 响应字段结构(`success`/`message`/`data`)的百分之百一致。
> [!IMPORTANT]
> 接口的入参解析与响应统一规范定义在 [openflare_server/controller/response.go](file:///Users/ryan/DEV/Go/OpenFlare/openflare_server/controller/response.go) 中。
---
## 2. 纯净工具类与数据库逻辑完全隔离(Utils 约束)
为了确保代码的可测试性、高内聚和低耦合,`utils/` 目录下的工具包必须保持纯净性:
### 2.1 无副作用与解耦原则
- 所有底层客户端与外部服务对接包(如 `utils/acme` 证书操作、邮件发送、DNS 供应商对接等)**必须完全剥离数据库或 GORM 依赖**。
- 工具包中严禁导入 `openflare/model` 包或直接访问数据库连接。它们应当只接受基础数据类型(如 `string`、`[]byte` 等)或本地无依赖结构体作为输入,并返回纯粹的计算或请求结果。
### 2.2 业务服务层(Service)职责
- 业务服务层 `service/` 负责数据库实体的加载、组装、事务持久化,并将底层的具体网络或加密操作委托给 `utils/` 工具包。
- 这样不仅保证了底层工具类的百分之百可单元测试性,也维护了清晰的系统分层。
---
## 3. Go 泛型切片去重与 JSON 序列化陷阱(Slice 约束)
在进行切片操作和去重时,必须使用泛型辅助函数,并注意 Go Slice 的空/零值在 JSON 序列化中的表现。
### 3.1 避免重复编写 map-seen 逻辑
- 禁止在 `service/` 或 `model/` 中手写临时的 map-seen 去重样板代码。
- 必须统一调用基于 Go 泛型实现的 [openflare_server/utils/slice.go](file:///Users/ryan/DEV/Go/OpenFlare/openflare_server/utils/slice.go) 中的 `utils.Unique()` 辅助函数。
### 3.2 关键的 JSON 序列化规则(Nil vs. Empty Slice)
在 Go 中,未初始化的 `nil` 切片和已初始化的空切片 `[]T{}` 在内存中不同,它们在序列化为 JSON 时也有着决定性的区别:
- **`nil` 切片**:序列化为 JSON `null`。
- **空切片 (`make([]T, 0)`)**:序列化为 JSON `[]`。
> [!CAUTION]
> **开发避坑准则**:
> 1. GORM 数据库的很多 JSON/Array 字段(例如 `domain_cert_ids`、`upstreams` 等)或配置版本变更检测机制(如 `checksum` 计算和 `diff` 检测),要求空数组在 JSON 中必须表示为 `[]` 而非 `null`,否则会触发重复发布或解析失败的 bug。
> 2. `utils.Unique` 必须具备 **Nil-Preservation(空值保留)** 特性:
> - 如果传入的 Slice 是 `nil`,它必须返回 `nil`,以支持 `omitempty` 或在需要表示“缺失”的场景中输出 `null`。
> - 如果传入的 Slice 不是 `nil`(即使长度为 0 或去重后长度为 0),它必须返回非 nil 的空切片 `make([]T, 0)`,以确保序列化为 `[]`。
> 3. 所有类似的切片加工辅助函数都必须遵循此行为。
+310
View File
@@ -0,0 +1,310 @@
# 开发约束
你会学到:OpenFlare 代码修改的准入标准、后端/Agent/前端分层约束、数据模型边界、API 约定、数据库迁移要求和测试交付基线。
本文档融合原开发规范、前端规范与开发计划,是 OpenFlare `1.0.0` 之后的工程约束入口。
## 当前结论
* 第一版至第六版的主线能力已经全部完成。
* `1.0.0` 是当前正式基线。
* 已完成阶段的过程性任务以代码、测试与 Git 历史为准。
* 新工作优先以缺陷修复、可维护性改进、文档与测试补强为主。
当前开发优先级:
1. 稳定性。
2. 升级与回滚链路可靠性。
3. 文档准确性。
4. 测试覆盖补强。
5. 在既有边界内的小步迭代。
## 变更准入
新需求进入实现前,按以下顺序判断:
1. 是否符合 [产品边界](../design/index.md)。
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、Agent、Frontend)的物理目录分层职责详见 [仓库结构](../design/repository.md)。在此结构下,开发必须遵守以下核心分层规则:
* **Server 开发规则**:禁止在 `controller/` 堆积业务逻辑,禁止在 `middleware/` 实现业务流程,禁止为简单需求新增平台层抽象。
* **Agent 开发规则**:每个模块职责单一,外部命令调用集中封装,状态落盘与配置落盘分离。
* **Frontend 开发规则**:页面文件只负责获取路由参数、组织页面结构、调用 feature 组件;不应手写复杂 API 细节、复杂表单校验逻辑或维护大量彼此耦合的局部状态。
## 数据模型规范
在定义和修改 Go/GORM 模型实体时,所有模型的业务边界与设计约束必须严格符合 [产品边界](../design/index.md)。
### 1. 当前有效实体
* **核心配置与反代**:`proxy_routes` (网站配置), `origins` (源站), `config_versions` (配置版本), `tls_certificates` (证书), `managed_domains` (托管域名).
* **节点与状态**:`nodes` (节点), `node_system_profiles` (系统概况), `apply_logs` (应用日志).
* **内网穿透**:`tunnels` (隧道客户端), `tunnel_tokens` (隧道认证令牌,可选持久化).
* **观测与分析**:`node_request_reports` (请求上报), `node_access_logs` (访问明细), `node_metric_snapshots` (指标快照), `traffic_analytics_rollups` (流量聚合), `node_health_events` (健康事件).
* **系统配置与第三方登录**:`options` (全局参数), `auth_sources` (第三方认证源), `external_accounts` (外部绑定账号).
* **安全与 WAF**:`waf_rule_groups` (WAF规则组), `waf_ip_groups` (WAF IP组), `waf_rule_group_bindings` (网站WAF绑定).
### 2. 底层数据库技术约束
在编写或修改模型时,必须严格遵守以下持久化与数据库设计准则:
* **禁止随意引入平台化新实体**:除非 [产品边界](../design/index.md) 设计发生调整并经评审。
* **业务唯一性保障**:
* `proxy_routes.site_name` 作为业务唯一主标识。
* `proxy_routes.domains` 中的各域名必须全局唯一,不可跨站点冲突,列表第一项视为主域名。
* `nodes.node_id` 唯一标识节点(自动生成或由用户指定)。
* `tunnels.tunnel_id` 唯一标识内网穿透客户端(格式 `tun-<32hex>`,自动生成)。
* **兼容字段处理**:遗留的 `proxy_routes.domain` 只能作为 `domains[0]` 的只读/兼容镜像,新代码不得以该字段为唯一业务输入。
* **多上游及 Keepalive**:单上游时应支持 base path/query 并在 `proxy_pass` 中正确补齐 URI;多上游负载均衡时仅允许纯 `scheme://host[:port]`。
* **证书映射**:证书绑定必须通过逐域名平行的 `domain_cert_ids` 字段精确保存,未绑定证书的域名不得参与 HTTPS 渲染。
* **版本快照一致性**:`config_versions` 必须保存版本发布时的完整快照及 checksum 校验码,确保渲染结果不可变且全局单激活版本。
* **外部账户唯一绑定**:第三方登录必须通过 `external_accounts` 映射至本地唯一用户,原 `users.github_id` 仅用于向后兼容迁移,任何新登录流程禁止以此为业务输入。
* **Tunnel 与上游关联**:
* `proxy_routes.upstream_type = 'tunnel'` 时,必须指定 `tunnel_id`(关联到 `tunnels` 表)。
* 必须指定 `tunnel_target_addr`(内网目标地址,如 `192.168.1.100:8080`)和 `tunnel_target_protocol`(`http` 或 `https`)。
* 发布配置时,Server 自动将此上游渲染为 `http://127.0.0.1:{relay_vhost_port}`,Agent 依据 Host 头由 frps 路由。
* **TunnelRelay 节点配置**:
* `nodes.node_type = 'tunnel_relay'` 时,新增字段 `relay_bind_port`、`relay_vhost_http_port`、`relay_auth_token` 必须有合理默认值。
* `relay_bind_port` 默认 7000,`relay_vhost_http_port` 默认 8080。
* `relay_auth_token` 由 Server 自动生成(32 位随机字符串),不由用户输入。
* 相对静态配置(如 `relay_agent_access_addr`、`relay_client_access_addr`)由 Relay 心跳下发,Server 可记录但不纳入版本化流。
* **Tunnel 客户端状态**:
* `tunnels.status` 记录客户端在线/离线/待激活状态。
* `tunnels.current_version` / `tunnels.current_checksum` 记录当前已应用的配置版本。
* `tunnels.connected_relays` 以 JSON 数组形式存储已连接 Relay 的信息(relay_node_id、连接状态等)。
* `last_seen_at`、`last_error` 用于调试和可观测性。
## 数据库迁移
任何涉及表结构、索引、列类型、分表规则或内部持久化元数据的修改,都必须同步提升数据库版本号。
数据库版本号定义在 `openflare_server/model`,不得只依赖 `AutoMigrate` 隐式升级存量数据库。
每次提升数据库版本号时,必须补充从上一版本升级到新版本的显式迁移方法。迁移方法必须包含升级后的校验逻辑;只有校验通过,才能写入新的数据库版本记录。
v1-v7 视为历史初始基线,不再维护逐版本升级文件。从 v8 起,数据库迁移必须放在 `openflare_server/model/migrate` 目录中,并以目标版本命名文件,例如 `v16.go`。每个版本文件通过 `init()` 注册自己的迁移,当前数据库版本取已注册迁移的最大目标版本。不得为了整理文件而改变已发布 v8+ 迁移的语义。
执行数据库升级时必须按以下步骤完成:
1. 判断是否需要升级数据库版本:凡是新增/删除/重命名表、字段、索引、约束、列类型、分表规则,或改变持久化数据语义,都必须升级。
2. 新增 `openflare_server/model/migrate/vN.go`,其中 `N` 为目标版本号。文件头部必须包含注释,说明本次升级了什么内容,以及为什么需要升级。
3. 在 `vN.go` 中实现 `VN()`,并在 `init()` 中调用 `Register(VN())`。`FromVersion` 必须等于 `N-1`,`ToVersion` 必须等于 `N`。
4. 在 `migrateVN` 中写入升级逻辑。可通过 `Context` 调用 `ApplyCurrentSchema`、历史 backfill、默认数据初始化等公共能力;复杂数据修复必须显式处理,不得只依赖 `AutoMigrate`。
5. 在 `validateVN` 中写入升级后的校验逻辑。校验至少要覆盖新增表/字段/索引是否存在、关键默认数据是否存在、必要的数据回填是否成功。
6. 如果新迁移需要新的公共 backfill 或校验辅助函数,将其放在 `openflare_server/model/migrations.go` 或更合适的 model 文件中,并通过 `Context` 暴露给 `model/migrate`,避免子包反向 import `model` 造成循环依赖。
7. 补充迁移测试:至少覆盖从 `N-1` 老库升级到 `N` 后 schema version、字段/表结构、关键数据回填和校验结果。注册表连续性由 `model/migrate` 测试兜底,但具体业务迁移仍必须有测试。
8. 同步更新设计/开发文档;如果管理端 API、配置项或用户可见行为变化,还要同步更新对应指南、配置参考和 Swagger 文档。
新包启动后必须先检查数据库当前版本,再按顺序逐步升级到目标版本;禁止跳过中间升级步骤直接写目标版本。
空库初始化可以直接建立当前版本结构,但初始化完成后仍必须执行同版本校验,并落库当前数据库版本。
如果迁移失败或校验失败,启动流程必须中止,且不得提升数据库版本记录。涉及数据库版本变更的提交,必须补充对应的迁移测试或等效回归测试。
## API 与鉴权
管理端与 Agent/Relay/Client API 统一使用 JSON。成功与失败都必须返回清晰 `message`:
```json
{
"success": true,
"message": "",
"data": {}
}
```
约定:
* Agent API 固定放在 `/api/agent/*`,使用 `X-Agent-Token` 认证(节点专属 token)。
* **Relay API** 固定放在 `/api/relay/*`,使用 `X-Agent-Token` 认证(同 TunnelRelay 节点)。
- Server 通过 token + `/api/relay/*` 路径区分 Relay 请求。
- Relay 心跳返回 frps 配置(bindPort、vhostHTTPPort、authToken)。
- Relay 上报进程状态、连接数、proxy 列表等指标。
* **Tunnel Client API** 固定放在 `/api/flared/*`,使用 `X-Tunnel-Token` 认证(独立的 tunnel_token)。
- OpenFlared 使用 `tunnel_token` 与 Server 通信,独立于 Agent 认证体系。
- Client 心跳返回 tunnel 配置版本摘要。
- Client 可拉取完整配置(relay 列表 + frpc 代理定义)。
- Client 上报配置应用结果。
* **Admin Tunnel 管理 API** - `/api/tunnels/*`,要求 Admin Session。
- CRUD tunnel 实体(创建、查询、更新、删除)。
- Token 管理(生成、轮换)。
- 强制同步(触发 Client 立即拉取新配置)。
* 总览与节点详情优先使用专用聚合接口。
* 管理端变更类接口统一使用 `POST`;只读接口使用 `GET`。
* 管理端继续复用现有登录、角色与 Session。
* 第三方登录统一通过认证源 API 进入,认证源管理接口必须要求 Root Session。
* `/api/status` 只能返回已启用认证源的公开字段,不得返回 Client Secret。
* 第三方账号未绑定且注册关闭时,应提供绑定已有账号流程,不得自动创建用户。
* Agent/Relay/Client 正式请求统一使用对应的专属 token(`agent_token` / `relay_token`(即 agent_token) / `tunnel_token`)。
* 首次接入 Agent 可使用全局 `discovery_token`;首次接入 Client 由 Server 生成 tunnel_token,直接用于部署命令。
* Agent/Relay 请求头统一使用 `X-Agent-Token`;Client 请求头统一使用 `X-Tunnel-Token`。
禁止暴露远程 shell 或任意命令执行入口,禁止在日志中打印完整 Token,禁止绕过占位符约束保存不可渲染的主配置模板。
## 发布与运行
发布逻辑必须保持:
* 发布时读取全部启用的 `proxy_routes`。
* 同时读取 OpenResty 主配置参数、反代性能参数与缓存参数。
* 读取 WAF 规则组、规则组引用的 IP 组与网站绑定关系,并在发布快照中保存可回放数据。
* 自动型 WAF IP 组只能由 Server 定时任务读取请求日志并执行 Expr 布尔规则,OpenResty Lua 与 Agent 不得直接访问请求日志库或执行自动挖掘逻辑。
* 发布版本不得展开 WAF IP 组成员;Agent 必须通过独立的 IP 组 checksum 差异同步和 WebSocket 增量广播维护本地 `waf_ip_groups.json`。
* **内网穿透配置扩展**:区分上游类型,为 `upstream_type = 'tunnel'` 的代理规则生成独立的 tunnel 配置数据。
* OpenResty 侧:将 tunnel 上游自动渲染为 `http://127.0.0.1:{relay_vhost_port}`,必须保留原始 `Host` 请求头。
* Tunnel 侧:为每个 Client 生成完整的 relay 列表与 frpc 代理定义(frpc proxy 配置)。
* 生成完整 OpenResty 配置。
* 计算 `checksum`。
* 写入 `config_versions`(OpenResty 部分)+ 生成或更新 tunnel 配置版本数据。
* 通过切换 `is_active` 激活版本。
版本约束:
* 版本号格式固定为 `YYYYMMDD-NNN`。
* 同一版本号同时关联 OpenResty 配置与 Tunnel 配置,保证一致性。
* 不在线修改历史版本。
* 不做按节点分组的差异化版本。
* 预览与 diff 是只读能力,不产生发布记录。
Agent 必须满足:
* 启动后读取或生成本地 `node_id`。
* 周期性心跳与同步。
* 常规同步优先依据 heartbeat 返回的版本摘要判断。
* WS 连接升级开启且连接成功时,Agent 可通过 WS 接收激活版本摘要并立即同步;WS 失败或断开必须退回 HTTP heartbeat。
* 发现新版本时先备份旧文件。
* 写入主配置、路由配置与必要证书文件。
* 写入 WAF/PoW 运行时配置,并确保 WAF Lua 资源由 Agent 统一管理。
* WAF IP 组同步必须按组增量更新,不得在每次心跳或每次同步中传输全部 IP 组。
* 写入新配置后执行 `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 健康检查。
OpenFlareRelay 必须满足:
* 启动后从 config 读取 Server 地址和 `agent_token`。
* 周期性向 Server 发送心跳,获取 frps 配置(bindPort、vhostHTTPPort、authToken)。
* 根据心跳响应生成 frps.toml,启动或更新 frps 进程。
* 上报 frps 进程健康状态、连接数、proxy 数等指标。
* frps 进程异常时自动重启,并上报失败信息。
* 可选支持 WebSocket 升级连接,接收实时配置推送。
OpenFlared 必须满足:
* 启动后从 config 读取 Server 地址和 `tunnel_token`。
* 周期性向 Server 发送心跳,获取 tunnel 配置版本摘要。
* 发现新版本后拉取完整 tunnel 配置(relay 列表 + frpc 代理定义)。
* 为每个 relay 生成独立 frpc.toml,启动新 frpc 进程或对已有进程执行热重载。
* 上报每个 frpc 进程的健康状态与连接情况。
* 配置应用失败时记录错误并上报,支持重试。
* 可选支持 WebSocket 升级连接,接收实时配置变更通知。
## 前端请求、状态与类型
所有 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 工作流。
## 后续维护方式
后续规划不再按“大版本阶段文档”维护,而采用以下方式:
* 产品边界变动:更新 [产品边界](../design/index.md)。
* 工程约束变动:更新本文档。
* 部署与配置变动:更新 [部署说明](../reference/deployment.md)、[配置项](../reference/configuration.md) 与 README。
如果未来出现明确的新阶段目标,再单独新增专项计划文档;不要把已完成的历史计划继续堆回本文档。
当前专项“网站级规则与配置界面改造”的模型边界已纳入 [产品边界](../design/index.md),执行时仍按数据模型、接口、前端页面、迁移测试与文档联动的顺序推进。
@@ -1,86 +0,0 @@
# 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 只负责本地路径替换、文件落盘和二进制控制。
@@ -89,7 +89,7 @@ curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/inst
}
```
如果不配置 `openresty_path`,Agent 默认调用 `openresty`。完整字段见 [配置项参考](../reference/configuration.md#agent-配置字段)。
如果不配置 `openresty_path`,Agent 默认调用 `openresty`。完整字段见 [配置项参考](./configuration.md#agent-配置字段)。
## Docker 运行
+105
View File
@@ -22,9 +22,45 @@ OpenFlare 的管理端 API 与 Agent API 都使用 JSON。
| --- | --- |
| 管理端 API | 由管理端 Session 鉴权 |
| Agent API | 固定放在 `/api/agent/*` |
| Relay API | 固定放在 `/api/relay/*`,使用 `X-Agent-Token` 鉴权(与 Agent 复用同一 token) |
| OpenFlared API | 固定放在 `/api/flared/*`,使用 `X-Tunnel-Token` 鉴权(独立的 tunnel_token) |
| 只读接口 | 使用 `GET` |
| 变更类接口 | 使用 `POST` |
## WAF IP 组接口
管理端 WAF IP 组接口统一要求管理端 Session 鉴权:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/api/waf/ip-groups` | 查询 IP 组列表 |
| `GET` | `/api/waf/ip-groups/:id` | 查询单个 IP 组 |
| `POST` | `/api/waf/ip-groups` | 创建 IP 组 |
| `POST` | `/api/waf/ip-groups/test` | 测试自动 IP 组 Expr 规则,不保存配置,返回当前日志窗口内命中的 IP 列表 |
| `POST` | `/api/waf/ip-groups/:id/update` | 更新 IP 组 |
| `POST` | `/api/waf/ip-groups/:id/delete` | 删除 IP 组;已被规则组引用时会拒绝 |
| `POST` | `/api/waf/ip-groups/:id/sync` | 立即同步订阅型 IP 组或立即执行自动型 IP 组 |
IP 组 `type` 支持 `manual`、`automatic`、`subscription`。自动型 IP 组的 `auto_config` 是 JSON 对象,当前支持:
```json
{
"lookback_minutes": 60,
"rules": [
{
"name": "单 IP 404 高频扫描",
"expr": "request_count > 100 && status_404_ratio >= 0.8"
},
{
"name": "单 IP 直连访问异常",
"expr": "ip_host_count > 50 && ip_host_ratio > 0.5"
}
]
}
```
自动规则使用 Expr 语法,表达式必须返回布尔值。规则按单个 IP 的请求日志聚合指标计算,可用字段包括 `ip`、`request_count`、`status_404_count`、`status_404_ratio`、`ip_host_count`、`ip_host_ratio`、`client_error_count`、`server_error_count`、`last_seen_unix`。完整语法和字段含义见 [WAF 自动 IP 组规则语法](../guide/waf-ip-group-expr.md)。订阅格式支持 `text` 与 `json`:文本格式按行解析 IP/IP 段并忽略空行和 `#` 开头的注释;JSON 格式可通过映射规则选择数组,默认读取根数组。
## 鉴权
管理端继续复用现有登录、角色与 Session。
@@ -35,6 +71,75 @@ Agent 正式请求统一使用节点专属 `agent_token`,首次接入可使用
X-Agent-Token: <token>
```
### Agent WAF IP 组同步
Agent 心跳 payload 可携带本地 WAF IP 组 checksum:
```json
{
"waf_ip_group_checksums": {
"1": "sha256..."
}
}
```
Server 会根据当前激活版本引用的 IP 组 ID 对比 checksum,并在心跳响应顶层返回差异组:
```json
{
"waf_ip_groups": [
{
"id": 1,
"name": "自动黑名单",
"type": "automatic",
"enabled": true,
"ip_list": ["203.0.113.10"],
"checksum": "sha256..."
}
]
}
```
Agent 也可以在应用新版本后主动请求差异同步:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `POST` | `/api/agent/waf/ip-groups/sync` | 根据 Agent 上报的 `ids` 与 `checksums` 返回不一致的 IP 组 |
当 Server 侧 IP 组更新时,已连接的 Agent WebSocket 会收到 `type = "waf_ip_groups"` 的消息,payload 为发生变化的 IP 组数组。Agent 应只更新收到的组,不要求 Server 每次下发全部 IP 组。
## OpenFlared API
OpenFlared 客户端用于内网穿透场景,通过 `tunnel_token` 与 Server 通信,独立于 Agent 认证体系。所有接口都使用 `X-Tunnel-Token` 鉴权,Server 会校验节点 `node_type = tunnel_client`,否则返回 `403`。
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `POST` | `/api/flared/heartbeat` | 客户端心跳,刷新在线状态并返回 tunnel 配置版本摘要 |
| `GET` | `/api/flared/config/active` | 拉取完整的 tunnel 路由配置(relay 列表 + frpc 代理定义) |
| `POST` | `/api/flared/apply-log` | 上报配置应用结果(success / warning / failed) |
| `GET` | `/api/flared/ws` | 升级为 WebSocket,用于实时接收 `active_config` 推送 |
心跳请求示例:
```http
POST /api/flared/heartbeat
X-Tunnel-Token: <tunnel_token>
Content-Type: application/json
{
"client_version": "v0.2.0",
"frp_version": "0.61.0",
"tunnel_status": "running",
"connected_relays": [
{ "relay_node_id": "node-relay-1", "status": "healthy", "proxy_count": 3 }
],
"current_version": "v1",
"current_checksum": "sha256..."
}
```
心跳响应包含 `active_config` 摘要与 `tunnel_settings`(包含心跳间隔、WebSocket 升级开关等运行时参数)。当 Server 发布新版本时,已连接的 OpenFlared WebSocket 会收到 `type = "active_config"` 消息,payload 为版本摘要,客户端应立即拉取完整配置并应用。
日志中不得打印完整 Token。
## Swagger
+1 -7
View File
@@ -23,7 +23,6 @@ Agent 支持:
| 组件 | 默认位置 | 说明 |
| --- | --- | --- |
| Server SQLite | `openflare.db` | 可通过 `SQLITE_PATH` 修改 |
| Server 上传目录 | `upload` | 可通过 `UPLOAD_PATH` 修改 |
| Agent 配置文件 | `./agent.json` | 可通过 `-config` 指定 |
| 一键安装 Agent 配置 | `/opt/openflare-agent/agent.json` | 安装脚本默认生成 |
| Agent 数据目录 | 配置文件所在目录下的 `data` | 可通过 `data_dir` 修改 |
@@ -54,7 +53,6 @@ go run . --port 3000 --log-dir ./logs
| `DSN` | PostgreSQL DSN,设置后优先于 SQLite | 空 |
| `SQL_DSN` | 兼容旧命名的 PostgreSQL DSN,优先级低于 `DSN` | 空 |
| `REDIS_CONN_STRING` | Redis 连接串 | 空 |
| `UPLOAD_PATH` | 上传目录 | `upload` |
| `AGENT_TOKEN` | 兼容旧部署的全局 Agent Token | 空 |
说明:
@@ -80,8 +78,6 @@ go run . --port 3000 --log-dir ./logs
| `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` |
说明:
@@ -168,10 +164,7 @@ OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前
| `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` |
@@ -201,6 +194,7 @@ OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前
* 如果 `agent.json` 不存在,但 `OPENFLARE_SERVER_URL` 与 Token 等环境变量足够,Agent 可以直接启动;两者同时存在时环境变量优先。
* Agent 未配置 `node_ip` 时,会优先通过 `https://realip.cc` 获取真实出口公网 IP,适配 Docker/NAT 场景;该请求失败时,才退回本机网卡探测并优先选择公网 IPv4。
* Agent 自动探测到私网 `node_ip` 时,Server 会在注册/心跳阶段优先保留 Agent 直连来源的公网地址,避免 NAT/多网卡场景误登记内网网卡地址。
* 在管理端开启“锁定节点 IP”后,Server 会保留管理端填写的节点 IP,后续 Agent 注册、HTTP 心跳或 WebSocket 状态上报不会覆盖该字段;关闭锁定后,下一次上报可重新回填。
## 常见配置组合
@@ -2,7 +2,7 @@
你会学到:OpenFlare 的推荐部署方式、Server 与 Agent 的运行要求、源码启动方式、联调步骤、升级与卸载入口。
生产环境建议使用 PostgreSQL 作为 Server 数据库,并为 Server 显式配置 `SESSION_SECRET`。Agent 统一通过 OpenResty 二进制控制运行时;Docker 部署请直接使用内置 OpenResty 的 Agent 镜像。
生产环境建议使用 PostgreSQL 作为 Server 数据库,并为 Server 显式配置 `SESSION_SECRET`。Agent 部署方式推荐为 Docker 部署(即直接使用内置 OpenResty 的 Agent 镜像);亦支持通过安装脚本或手动本地运行。
## 部署拓扑
@@ -129,7 +129,37 @@ go run .
go run . --port 3000 --log-dir ./logs
```
## Agent 接入
## Docker 运行 Agent(推荐)
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 接入(脚本安装)
除了 Docker 部署外,也支持通过安装脚本将 Agent 部署在本地宿主机上。
使用 `discovery_token` 自动注册:
@@ -166,34 +196,6 @@ 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
源码运行:
+1 -1
View File
@@ -9,4 +9,4 @@
| [配置项](./configuration.md) | Server 环境变量、命令行参数、运行时 Option 与 Agent 配置字段 |
| [命令与脚本](./cli.md) | 常用启动、构建、测试、安装和卸载命令 |
| [API 约定](./api.md) | 管理端 API 与 Agent API 的响应结构、鉴权和路径约定 |
| [仓库结构](./repository.md) | `openflare_server`、`openflare_agent`、`openflare_server/web` 与 `docs` 的职责 |
| [仓库结构](../design/repository.md) | `openflare_server`、`openflare_agent`、`openflare_server/web` 与 `docs` 的职责 |
-47
View File
@@ -1,47 +0,0 @@
# 仓库结构
你会学到: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/` | 共享类型定义 |
+1 -1
View File
@@ -15,7 +15,7 @@ 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
RUN go build -trimpath -ldflags "-s -w -X 'openflare-agent/internal/config.Version=$VERSION'" -o /build/openflare-agent ./cmd/agent
FROM openresty/openresty:alpine
+2 -2
View File
@@ -32,7 +32,7 @@ func main() {
slog.Error("load agent config failed", "error", err)
os.Exit(1)
}
cfg.NginxVersion = nginx.DetectVersion(
cfg.ExtVersion = nginx.DetectVersion(
context.Background(),
nginx.ExecutorOptions{
NginxPath: cfg.OpenrestyPath,
@@ -71,7 +71,7 @@ func main() {
LuaDir: cfg.LuaDir,
NginxLuaDir: cfg.OpenrestyLuaDir,
RuntimeConfigDir: cfg.RuntimeConfigDir,
OpenrestyObservabilityListen: nginx.ObservabilityListenAddress(cfg.OpenrestyPath, cfg.OpenrestyObservabilityPort),
OpenrestyObservabilityListen: nginx.ObservabilityListenAddress(cfg.OpenrestyObservabilityPort),
OpenrestyObservabilityPort: cfg.OpenrestyObservabilityPort,
OpenrestyResolverDirective: "",
Executor: nginx.NewExecutor(nginx.ExecutorOptions{
+71 -36
View File
@@ -24,6 +24,8 @@ 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
WAFIPGroupChecksums() (map[string]string, error)
ApplyWAFIPGroups(ctx context.Context, groups []protocol.WAFIPGroup) error
}
type Updater interface {
@@ -72,7 +74,7 @@ func (r *Runner) Run(ctx context.Context) error {
return err
}
slog.Info("agent runner started", "node_id", nodeID, "node", r.Config.NodeName, "ip", r.Config.NodeIP)
if r.hasAgentToken() {
if r.hasAccessToken() {
if _, hbErr := r.performHeartbeatCycle(ctx, nodeID, true); hbErr != nil {
slog.Error("agent startup heartbeat failed", "error", hbErr)
}
@@ -117,7 +119,7 @@ func (r *Runner) Run(ctx context.Context) error {
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 r.hasAccessToken() {
if _, hbErr := r.performHeartbeatCycle(ctx, nodeID, false); hbErr != nil {
slog.Error("agent heartbeat after ws disconnect failed", "error", hbErr)
}
@@ -126,7 +128,7 @@ func (r *Runner) Run(ctx context.Context) error {
if wsDone != nil {
continue
}
if !r.hasAgentToken() {
if !r.hasAccessToken() {
if err = r.tryRegister(ctx, &nodeID); err != nil {
slog.Error("agent discovery register failed", "error", err)
}
@@ -161,6 +163,7 @@ func (r *Runner) performHeartbeatCycle(ctx context.Context, nodeID string, start
}
slog.Debug("agent heartbeat succeeded", "mode", mode, "node_id", nodeID)
changed := r.applySettings(heartbeatResult.AgentSettings)
r.applyWAFIPGroups(ctx, heartbeatResult.WAFIPGroups)
if startup {
if err = r.SyncService.SyncOnStartup(ctx, heartbeatResult.ActiveConfig); err != nil {
r.recordSyncError(err)
@@ -178,7 +181,7 @@ func (r *Runner) performHeartbeatCycle(ctx context.Context, nodeID string, start
}
func (r *Runner) shouldUseWebSocket() bool {
enabled := r.WebSocketService != nil && r.websocketUpgradeEnabled && r.hasAgentToken()
enabled := r.WebSocketService != nil && r.websocketUpgradeEnabled && r.hasAccessToken()
slog.Debug("agent ws upgrade eligibility checked", "enabled", enabled, "server_enabled", r.websocketUpgradeEnabled, "url", r.websocketURL())
return enabled
}
@@ -307,6 +310,14 @@ func (r *Runner) handleWebSocketMessage(ctx context.Context, message protocol.WS
slog.Error("agent ws triggered force sync failed", "version", target.Version, "error", err)
}
return false, nil
case protocol.WSMessageTypeWAFIPGroups:
var groups []protocol.WAFIPGroup
if err := json.Unmarshal(message.Payload, &groups); err != nil {
slog.Debug("agent ws waf ip groups decode failed", "error", err)
return false, nil
}
r.applyWAFIPGroups(ctx, groups)
return false, nil
case protocol.WSMessageTypePing:
slog.Debug("agent ws ping received")
return false, conn.SendPong()
@@ -354,8 +365,8 @@ func (backoff *webSocketBackoff) Reset() {
}
}
func (r *Runner) hasAgentToken() bool {
return strings.TrimSpace(r.Config.AgentToken) != ""
func (r *Runner) hasAccessToken() bool {
return strings.TrimSpace(r.Config.AccessToken) != ""
}
func (r *Runner) applySettings(settings *protocol.AgentSettings) bool {
@@ -436,7 +447,7 @@ func (r *Runner) tryRegister(ctx context.Context, nodeID *string) error {
if err != nil {
return err
}
if response == nil || strings.TrimSpace(response.AgentToken) == "" || strings.TrimSpace(response.NodeID) == "" {
if response == nil || strings.TrimSpace(response.AccessToken) == "" || strings.TrimSpace(response.NodeID) == "" {
return errors.New("discovery register response 缺少 node_id 或 agent_token")
}
snapshot, err := r.StateStore.Load()
@@ -447,14 +458,14 @@ func (r *Runner) tryRegister(ctx context.Context, nodeID *string) error {
if err = r.StateStore.Save(snapshot); err != nil {
return err
}
r.Config.AgentToken = response.AgentToken
r.Config.AccessToken = response.AccessToken
r.Config.DiscoveryToken = ""
if err = r.Config.Save(); err != nil {
return err
}
r.HeartbeatService.SetToken(response.AgentToken)
r.HeartbeatService.SetToken(response.AccessToken)
if r.WebSocketService != nil {
r.WebSocketService.SetToken(response.AgentToken)
r.WebSocketService.SetToken(response.AccessToken)
}
*nodeID = response.NodeID
slog.Info("agent discovery registration succeeded", "node_id", response.NodeID)
@@ -470,6 +481,7 @@ func (r *Runner) tryRegister(ctx context.Context, nodeID *string) error {
heartbeatResult = &protocol.HeartbeatResult{}
}
r.applySettings(heartbeatResult.AgentSettings)
r.applyWAFIPGroups(ctx, heartbeatResult.WAFIPGroups)
if err = r.SyncService.SyncOnStartup(ctx, heartbeatResult.ActiveConfig); err != nil {
r.recordSyncError(err)
slog.Error("agent post-register startup sync failed", "error", err)
@@ -561,23 +573,44 @@ func (r *Runner) nodePayload(nodeID string) protocol.NodePayload {
if managedOpenRestyMetrics == nil {
managedOpenRestyMetrics = fallbackMetrics
}
metricSnapshot := observability.BuildSnapshot(r.Config, r.StateStore, managedOpenRestyMetrics)
metricSnapshot := observability.BuildSnapshot(r.Config, r.StateStore)
openrestyObservation := observability.BuildOpenrestyObservation(managedOpenRestyMetrics)
healthEvents := observability.BuildHealthEvents(snapshot)
return protocol.NodePayload{
NodeID: nodeID,
Name: r.Config.NodeName,
IP: r.Config.NodeIP,
AgentVersion: r.Config.AgentVersion,
NginxVersion: r.Config.NginxVersion,
CurrentVersion: snapshot.CurrentVersion,
LastError: snapshot.LastError,
OpenrestyStatus: openrestyStatus,
OpenrestyMessage: snapshot.OpenrestyMessage,
Profile: profile,
Snapshot: metricSnapshot,
TrafficReport: trafficReport,
AccessLogs: accessLogs,
HealthEvents: healthEvents,
payload := protocol.NodePayload{
NodeID: nodeID,
Name: r.Config.NodeName,
IP: r.Config.NodeIP,
Version: r.Config.Version,
ExtVersion: r.Config.ExtVersion,
CurrentVersion: snapshot.CurrentVersion,
LastError: snapshot.LastError,
OpenrestyStatus: openrestyStatus,
OpenrestyMessage: snapshot.OpenrestyMessage,
Profile: profile,
Snapshot: metricSnapshot,
OpenrestyObservation: openrestyObservation,
TrafficReport: trafficReport,
AccessLogs: accessLogs,
HealthEvents: healthEvents,
}
if r.SyncService != nil {
checksums, err := r.SyncService.WAFIPGroupChecksums()
if err != nil {
slog.Debug("load local waf ip group checksums failed", "error", err)
} else if len(checksums) > 0 {
payload.WAFIPGroupChecksums = checksums
}
}
return payload
}
func (r *Runner) applyWAFIPGroups(ctx context.Context, groups []protocol.WAFIPGroup) {
if len(groups) == 0 || r.SyncService == nil {
return
}
if err := r.SyncService.ApplyWAFIPGroups(ctx, groups); err != nil {
r.recordSyncError(err)
slog.Error("agent apply waf ip groups failed", "error", err)
}
}
@@ -588,17 +621,18 @@ func (r *Runner) prepareHeartbeatPayload(nodeID string) (protocol.NodePayload, [
}
now := time.Now().UTC()
retainAfterUnix := now.Add(-time.Duration(r.Config.ObservabilityReplayMinutes) * time.Minute).Unix()
windowStartedAtUnix := state.ObservabilityWindowStartedAt(payload.Snapshot, payload.TrafficReport)
windowStartedAtUnix := state.ObservabilityWindowStartedAt(payload.Snapshot, payload.OpenrestyObservation, payload.TrafficReport)
if windowStartedAtUnix <= 0 {
return payload, nil
}
record := state.ObservabilityBufferRecord{
WindowStartedAtUnix: windowStartedAtUnix,
Snapshot: payload.Snapshot,
TrafficReport: payload.TrafficReport,
AccessLogs: payload.AccessLogs,
QueuedAtUnix: now.Unix(),
WindowStartedAtUnix: windowStartedAtUnix,
Snapshot: payload.Snapshot,
OpenrestyObservation: payload.OpenrestyObservation,
TrafficReport: payload.TrafficReport,
AccessLogs: payload.AccessLogs,
QueuedAtUnix: now.Unix(),
}
if err := r.ObservabilityBuffer.Upsert(record, retainAfterUnix); err != nil {
slog.Error("upsert observability buffer failed", "error", err)
@@ -618,10 +652,11 @@ func (r *Runner) prepareHeartbeatPayload(nodeID string) (protocol.NodePayload, [
continue
}
buffered = append(buffered, protocol.BufferedObservabilityRecord{
WindowStartedAtUnix: item.WindowStartedAtUnix,
Snapshot: item.Snapshot,
TrafficReport: item.TrafficReport,
AccessLogs: item.AccessLogs,
WindowStartedAtUnix: item.WindowStartedAtUnix,
Snapshot: item.Snapshot,
OpenrestyObservation: item.OpenrestyObservation,
TrafficReport: item.TrafficReport,
AccessLogs: item.AccessLogs,
})
ackWindows = append(ackWindows, item.WindowStartedAtUnix)
}
+38 -22
View File
@@ -70,6 +70,8 @@ type fakeSyncService struct {
syncOnceCalls int
lastTarget *protocol.ActiveConfigMeta
onSyncOnceCall func(int)
wafChecksums map[string]string
wafGroups []protocol.WAFIPGroup
}
type fakeRuntimeManager struct {
@@ -135,6 +137,20 @@ func (f *fakeSyncService) ForceSyncOnce(ctx context.Context, target *protocol.Ac
return f.syncOnceErr
}
func (f *fakeSyncService) WAFIPGroupChecksums() (map[string]string, error) {
if f.wafChecksums == nil {
return map[string]string{}, nil
}
return f.wafChecksums, nil
}
func (f *fakeSyncService) ApplyWAFIPGroups(ctx context.Context, groups []protocol.WAFIPGroup) error {
f.mu.Lock()
defer f.mu.Unlock()
f.wafGroups = append(f.wafGroups, groups...)
return nil
}
type fakeWebSocketConnection struct {
pongCalls int
}
@@ -178,11 +194,11 @@ func TestRunnerKeepsHeartbeatWhenStartupSyncFails(t *testing.T) {
}
runner := &Runner{
Config: &config.Config{
AgentToken: "agent-token",
AccessToken: "agent-token",
NodeName: "edge-01",
NodeIP: "10.0.0.8",
AgentVersion: config.AgentVersion,
NginxVersion: "1.27.1.2",
Version: config.Version,
ExtVersion: "1.27.1.2",
HeartbeatInterval: config.MillisecondDuration(10 * time.Millisecond),
},
StateStore: stateStore,
@@ -231,11 +247,11 @@ func TestRunnerDoesNotExitOnHeartbeatOrSyncError(t *testing.T) {
}
runner := &Runner{
Config: &config.Config{
AgentToken: "agent-token",
AccessToken: "agent-token",
NodeName: "edge-01",
NodeIP: "10.0.0.8",
AgentVersion: config.AgentVersion,
NginxVersion: "1.27.1.2",
Version: config.Version,
ExtVersion: "1.27.1.2",
HeartbeatInterval: config.MillisecondDuration(10 * time.Millisecond),
},
StateStore: stateStore,
@@ -289,11 +305,11 @@ func TestRunnerReportsOpenrestyHealthAndExecutesRestart(t *testing.T) {
}
runner := &Runner{
Config: &config.Config{
AgentToken: "agent-token",
AccessToken: "agent-token",
NodeName: "edge-01",
NodeIP: "10.0.0.8",
AgentVersion: config.AgentVersion,
NginxVersion: "1.27.1.2",
Version: config.Version,
ExtVersion: "1.27.1.2",
HeartbeatInterval: config.MillisecondDuration(10 * time.Millisecond),
},
StateStore: stateStore,
@@ -345,8 +361,8 @@ func TestRunnerHeartbeatPayloadIncludesObservabilityExtensions(t *testing.T) {
Config: &config.Config{
NodeName: "edge-observe-1",
NodeIP: "10.0.0.51",
AgentVersion: config.AgentVersion,
NginxVersion: "1.27.1.2",
Version: config.Version,
ExtVersion: "1.27.1.2",
DataDir: tempDir,
RouteConfigPath: filepath.Join(tempDir, "conf.d", "openflare_routes.conf"),
AccessLogPath: filepath.Join(tempDir, "var", "log", "openflare", "access.log"),
@@ -425,11 +441,11 @@ func TestRunnerReplaysBufferedObservabilityAfterHeartbeatRecovery(t *testing.T)
}
runner := &Runner{
Config: &config.Config{
AgentToken: "agent-token",
AccessToken: "agent-token",
NodeName: "edge-buffer-01",
NodeIP: "10.0.0.52",
AgentVersion: config.AgentVersion,
NginxVersion: "1.27.1.2",
Version: config.Version,
ExtVersion: "1.27.1.2",
DataDir: tempDir,
RouteConfigPath: filepath.Join(tempDir, "conf.d", "openflare_routes.conf"),
HeartbeatInterval: config.MillisecondDuration(10 * time.Millisecond),
@@ -482,9 +498,9 @@ func TestRunnerDiscoveryRegisterUpdatesTokenAndNodeID(t *testing.T) {
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
heartbeatService := &fakeHeartbeatService{
registerResp: &protocol.RegisterNodeResponse{
NodeID: "node-server-assigned",
AgentToken: "agent-token-issued",
Name: "edge-01",
NodeID: "node-server-assigned",
AccessToken: "agent-token-issued",
Name: "edge-01",
},
heartbeatResults: []*protocol.HeartbeatResult{{}},
onHeartbeat: func(callCount int) {
@@ -508,8 +524,8 @@ func TestRunnerDiscoveryRegisterUpdatesTokenAndNodeID(t *testing.T) {
DiscoveryToken: cfg.DiscoveryToken,
NodeName: cfg.NodeName,
NodeIP: cfg.NodeIP,
AgentVersion: config.AgentVersion,
NginxVersion: "1.27.1.2",
Version: config.Version,
ExtVersion: "1.27.1.2",
HeartbeatInterval: config.MillisecondDuration(10 * time.Millisecond),
},
StateStore: stateStore,
@@ -517,8 +533,8 @@ func TestRunnerDiscoveryRegisterUpdatesTokenAndNodeID(t *testing.T) {
SyncService: syncService,
}
runner.Config = cfg
runner.Config.AgentVersion = config.AgentVersion
runner.Config.NginxVersion = "1.27.1.2"
runner.Config.Version = config.Version
runner.Config.ExtVersion = "1.27.1.2"
runner.Config.HeartbeatInterval = config.MillisecondDuration(10 * time.Millisecond)
err = runner.Run(ctx)
@@ -538,7 +554,7 @@ func TestRunnerDiscoveryRegisterUpdatesTokenAndNodeID(t *testing.T) {
if snapshot.NodeID != "node-server-assigned" {
t.Fatalf("expected node id to be replaced, got %q", snapshot.NodeID)
}
if runner.Config.AgentToken != "agent-token-issued" || runner.Config.DiscoveryToken != "" {
if runner.Config.AccessToken != "agent-token-issued" || runner.Config.DiscoveryToken != "" {
t.Fatal("expected config token rotation to complete")
}
}
+28 -85
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"net"
"openflare/utils"
"openflare/utils/geoip"
"openflare/utils/geoip/iputil"
"os"
@@ -39,17 +40,14 @@ var (
type Config struct {
ServerURL string `json:"server_url"`
AgentToken string `json:"agent_token"`
AccessToken string `json:"agent_token"`
DiscoveryToken string `json:"discovery_token"`
NodeName string `json:"node_name"`
NodeIP string `json:"node_ip"`
AgentVersion string `json:"-"`
NginxVersion string `json:"-"`
Version string `json:"-"`
ExtVersion string `json:"-"`
OpenrestyPath string `json:"openresty_path"`
OpenrestyResolvers []string `json:"openresty_resolvers,omitempty"`
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"`
@@ -73,15 +71,12 @@ type Config struct {
type configFile struct {
ServerURL string `json:"server_url"`
AgentToken string `json:"agent_token"`
AccessToken string `json:"agent_token"`
DiscoveryToken string `json:"discovery_token"`
NodeName string `json:"node_name"`
NodeIP string `json:"node_ip"`
OpenrestyPath string `json:"openresty_path"`
OpenrestyResolvers []string `json:"openresty_resolvers"`
OpenrestyContainerName string `json:"openresty_container_name"`
OpenrestyDockerImage string `json:"openresty_docker_image"`
DockerBinary string `json:"docker_binary"`
DataDir string `json:"data_dir"`
MainConfigPath string `json:"main_config_path"`
RouteConfigPath string `json:"route_config_path"`
@@ -118,15 +113,12 @@ func Load(path string) (*Config, error) {
}
cfg := &Config{
ServerURL: file.ServerURL,
AgentToken: file.AgentToken,
AccessToken: file.AccessToken,
DiscoveryToken: file.DiscoveryToken,
NodeName: file.NodeName,
NodeIP: file.NodeIP,
OpenrestyPath: file.OpenrestyPath,
OpenrestyResolvers: append([]string{}, file.OpenrestyResolvers...),
OpenrestyContainerName: file.OpenrestyContainerName,
OpenrestyDockerImage: file.OpenrestyDockerImage,
DockerBinary: file.DockerBinary,
DataDir: file.DataDir,
MainConfigPath: file.MainConfigPath,
RouteConfigPath: file.RouteConfigPath,
@@ -157,8 +149,8 @@ func Load(path string) (*Config, error) {
func applyDefaults(cfg *Config, baseDir string) {
baseDir = filepath.Clean(baseDir)
cfg.AgentVersion = AgentVersion
cfg.OpenrestyResolvers = normalizeResolverList(cfg.OpenrestyResolvers)
cfg.Version = Version
cfg.OpenrestyResolvers = utils.UniqueAndCleanStringSlice(cfg.OpenrestyResolvers)
if cfg.OpenrestyPath == "" {
cfg.OpenrestyPath = "openresty"
}
@@ -229,41 +221,24 @@ func normalizeManagedPaths(cfg *Config) {
if cfg == nil {
return
}
if usesSlashPath(cfg.DataDir) {
cfg.DataDir = filepath.ToSlash(cfg.DataDir)
paths := []*string{
&cfg.DataDir,
&cfg.MainConfigPath,
&cfg.RouteConfigPath,
&cfg.AccessLogPath,
&cfg.CertDir,
&cfg.OpenrestyCertDir,
&cfg.LuaDir,
&cfg.OpenrestyLuaDir,
&cfg.RuntimeConfigDir,
&cfg.StatePath,
&cfg.ObservabilityBufferPath,
&cfg.MMDBPath,
}
if usesSlashPath(cfg.MainConfigPath) {
cfg.MainConfigPath = filepath.ToSlash(cfg.MainConfigPath)
}
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)
}
if usesSlashPath(cfg.OpenrestyCertDir) {
cfg.OpenrestyCertDir = filepath.ToSlash(cfg.OpenrestyCertDir)
}
if usesSlashPath(cfg.LuaDir) {
cfg.LuaDir = filepath.ToSlash(cfg.LuaDir)
}
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)
for _, p := range paths {
if usesSlashPath(*p) {
*p = filepath.ToSlash(*p)
}
}
}
@@ -300,7 +275,7 @@ func applyEnvOverrides(cfg *Config) {
}
}
overrideString("OPENFLARE_SERVER_URL", &cfg.ServerURL)
overrideString("OPENFLARE_AGENT_TOKEN", &cfg.AgentToken)
overrideString("OPENFLARE_AGENT_TOKEN", &cfg.AccessToken)
overrideString("OPENFLARE_DISCOVERY_TOKEN", &cfg.DiscoveryToken)
overrideString("OPENFLARE_NODE_NAME", &cfg.NodeName)
overrideString("OPENFLARE_NODE_IP", &cfg.NodeIP)
@@ -361,7 +336,7 @@ func validate(cfg *Config) error {
if cfg.ServerURL == "" {
return errors.New("server_url 不能为空")
}
if strings.TrimSpace(cfg.AgentToken) == "" && strings.TrimSpace(cfg.DiscoveryToken) == "" {
if strings.TrimSpace(cfg.AccessToken) == "" && strings.TrimSpace(cfg.DiscoveryToken) == "" {
return errors.New("agent_token 和 discovery_token 不能同时为空")
}
if cfg.NodeName == "" {
@@ -386,7 +361,7 @@ func (cfg *Config) InitialAuthToken() string {
if cfg == nil {
return ""
}
if token := strings.TrimSpace(cfg.AgentToken); token != "" {
if token := strings.TrimSpace(cfg.AccessToken); token != "" {
return token
}
return strings.TrimSpace(cfg.DiscoveryToken)
@@ -414,38 +389,6 @@ func detectHostname() string {
return strings.TrimSpace(host)
}
func normalizeResolverList(values []string) []string {
if len(values) == 0 {
return nil
}
result := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
result = append(result, trimmed)
}
if len(result) == 0 {
return nil
}
return result
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func detectNodeIP() string {
if ip := detectOutboundNodeIP(); ip != "" {
return ip
+5 -49
View File
@@ -161,41 +161,6 @@ 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")
@@ -261,7 +226,7 @@ func TestLoadUsesEnvConfigWhenFileIsMissing(t *testing.T) {
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if cfg.ServerURL != "http://127.0.0.1:3000" || cfg.AgentToken != "token" {
if cfg.ServerURL != "http://127.0.0.1:3000" || cfg.AccessToken != "token" {
t.Fatalf("unexpected env auth config: %#v", cfg)
}
if cfg.OpenrestyPath != "/usr/bin/openresty" {
@@ -369,8 +334,8 @@ func TestLoadEnvOverridesConfigFile(t *testing.T) {
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.AccessToken != "new-token" {
t.Fatalf("expected token from env, got %s", cfg.AccessToken)
}
if cfg.OpenrestyPath != "/new/openresty" {
t.Fatalf("expected openresty path from env, got %s", cfg.OpenrestyPath)
@@ -420,7 +385,7 @@ func TestSavePersistsMillisecondsAndOmitsRuntimeVersions(t *testing.T) {
if err != nil {
t.Fatalf("Load failed: %v", err)
}
cfg.NginxVersion = "1.27.1.2"
cfg.ExtVersion = "1.27.1.2"
cfg.HeartbeatInterval = MillisecondDuration(5 * time.Second)
cfg.RequestTimeout = MillisecondDuration(7 * time.Second)
cfg.OpenrestyResolvers = []string{"10.0.0.2", "1.1.1.1"}
@@ -462,15 +427,6 @@ 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) {
@@ -505,7 +461,7 @@ func TestInitialAuthToken(t *testing.T) {
var cfg *Config
if tt.name != "nil config returns empty string" {
cfg = &Config{
AgentToken: tt.agentToken,
AccessToken: tt.agentToken,
DiscoveryToken: tt.discoveryToken,
}
}
+1 -1
View File
@@ -1,3 +1,3 @@
package config
var AgentVersion = "dev"
var Version = "dev"
+19 -1
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strings"
@@ -53,6 +54,7 @@ func (c *Client) Heartbeat(ctx context.Context, payload protocol.NodePayload) (*
return &protocol.HeartbeatResult{
AgentSettings: resp.AgentSettings,
ActiveConfig: resp.ActiveConfig,
WAFIPGroups: resp.WAFIPGroups,
}, nil
}
@@ -73,6 +75,17 @@ func (c *Client) ReportApplyLog(ctx context.Context, payload protocol.ApplyLogPa
return c.postJSON(ctx, "/api/agent/apply-logs", payload, nil)
}
func (c *Client) SyncWAFIPGroups(ctx context.Context, payload protocol.WAFIPGroupSyncRequest) (*protocol.WAFIPGroupSyncResponse, error) {
resp := protocol.APIResponse[protocol.WAFIPGroupSyncResponse]{}
if err := c.postJSON(ctx, "/api/agent/waf/ip-groups/sync", payload, &resp); err != nil {
return nil, err
}
if !resp.Success {
return nil, errors.New(resp.Message)
}
return &resp.Data, nil
}
func (c *Client) SetToken(token string) {
c.token = strings.TrimSpace(token)
slog.Debug("http client token updated")
@@ -107,7 +120,12 @@ func (c *Client) do(req *http.Request, target any) error {
slog.Error("http request failed", "method", req.Method, "path", req.URL.Path, "error", err)
return err
}
defer res.Body.Close()
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
slog.Error("failed to close response body", "error", err)
}
}(res.Body)
if res.StatusCode != http.StatusOK {
slog.Warn("http request returned non-200", "method", req.Method, "path", req.URL.Path, "status", res.Status)
return errors.New(res.Status)
+170 -64
View File
@@ -4,6 +4,7 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
@@ -19,18 +20,15 @@ import (
"strings"
"time"
"openflare/utils"
openrestyrender "openflare/utils/render/openresty"
"openflare-agent/internal/protocol"
)
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 PowStaticDirPlaceholder = "__OPENFLARE_POW_STATIC_DIR__"
const WAFIPGroupsConfigFileName = "waf_ip_groups.json"
type Executor interface {
Test(ctx context.Context) error
@@ -106,7 +104,7 @@ func (e *PathExecutor) Reload(ctx context.Context) error {
return nil
}
func (e *PathExecutor) EnsureRuntime(ctx context.Context, recreate bool) error {
func (e *PathExecutor) EnsureRuntime(ctx context.Context, _ bool) error {
if err := e.Test(ctx); err != nil {
return err
}
@@ -194,6 +192,10 @@ type ApplyOutcome struct {
Message string
}
type wafIPGroupsRuntimeConfig struct {
Groups map[string]protocol.WAFIPGroup `json:"groups"`
}
func (m *Manager) Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) ApplyOutcome {
slog.Info("openresty apply started", "main_config", m.MainConfigPath, "route_config", m.RouteConfigPath, "cert_files", len(supportFiles))
backup, err := m.backup()
@@ -223,6 +225,9 @@ func (m *Manager) writeTargetFiles(mainConfig string, routeConfig string, suppor
if err := m.writeWAFConfig(supportFiles); err != nil {
return err
}
if err := m.writeSourceConfig(supportFiles); err != nil {
return err
}
if err := m.ensureMimeTypes(); err != nil {
return err
}
@@ -390,30 +395,32 @@ func (m *Manager) CurrentChecksum() (string, error) {
}
normalizedMain := string(mainData)
if includePath := m.routeConfigIncludePath(); includePath != "" {
normalizedMain = strings.ReplaceAll(normalizedMain, includePath, RouteConfigPlaceholder)
normalizedMain = strings.ReplaceAll(normalizedMain, includePath, openrestyrender.RouteConfigPlaceholder)
}
if accessLogPath := m.accessLogRuntimePath(); accessLogPath != "" {
normalizedMain = strings.ReplaceAll(normalizedMain, accessLogPath, AccessLogPlaceholder)
normalizedMain = strings.ReplaceAll(normalizedMain, accessLogPath, openrestyrender.AccessLogPlaceholder)
errorLogPath := filepath.Join(filepath.Dir(accessLogPath), "error.log")
normalizedMain = strings.ReplaceAll(normalizedMain, filepath.ToSlash(errorLogPath), openrestyrender.ErrorLogPlaceholder)
}
if luaDir := m.luaRuntimePath(); luaDir != "" {
normalizedMain = strings.ReplaceAll(normalizedMain, luaDir, LuaDirPlaceholder)
normalizedMain = strings.ReplaceAll(normalizedMain, luaDir, openrestyrender.LuaDirPlaceholder)
}
if listen := strings.TrimSpace(m.OpenrestyObservabilityListen); listen != "" {
normalizedMain = strings.ReplaceAll(normalizedMain, listen, ObservabilityListenPlaceholder)
normalizedMain = strings.ReplaceAll(normalizedMain, listen, openrestyrender.ObservabilityListenPlaceholder)
}
if m.OpenrestyObservabilityPort > 0 {
normalizedMain = strings.ReplaceAll(normalizedMain, fmt.Sprintf("%d", m.OpenrestyObservabilityPort), ObservabilityPortPlaceholder)
normalizedMain = strings.ReplaceAll(normalizedMain, fmt.Sprintf("%d", m.OpenrestyObservabilityPort), openrestyrender.ObservabilityPortPlaceholder)
}
if resolverDirective := strings.TrimSpace(m.OpenrestyResolverDirective); resolverDirective != "" {
normalizedMain = strings.ReplaceAll(normalizedMain, resolverDirective, ResolverDirectivePlaceholder)
}
normalizedRoute := string(data)
if m.NginxCertDir != "" {
normalizedRoute = strings.ReplaceAll(normalizedRoute, m.NginxCertDir, CertDirPlaceholder)
normalizedRoute = strings.ReplaceAll(normalizedRoute, m.NginxCertDir, openrestyrender.CertDirPlaceholder)
}
if luaDir := m.luaRuntimePath(); luaDir != "" {
normalizedRoute = strings.ReplaceAll(normalizedRoute, luaDir+"/pow/static", PowStaticDirPlaceholder)
normalizedRoute = strings.ReplaceAll(normalizedRoute, luaDir, LuaDirPlaceholder)
normalizedRoute = strings.ReplaceAll(normalizedRoute, luaDir+"/pow/static", openrestyrender.PowStaticDirPlaceholder)
normalizedRoute = strings.ReplaceAll(normalizedRoute, luaDir, openrestyrender.LuaDirPlaceholder)
}
files, err := m.readManagedSupportFiles()
if err != nil {
@@ -424,6 +431,77 @@ func (m *Manager) CurrentChecksum() (string, error) {
return result, nil
}
func (m *Manager) WAFIPGroupChecksums() (map[string]string, error) {
config, err := m.readWAFIPGroupsRuntimeConfig()
if err != nil {
return nil, err
}
result := make(map[string]string, len(config.Groups))
for id, group := range config.Groups {
if strings.TrimSpace(group.Checksum) != "" {
result[id] = strings.TrimSpace(group.Checksum)
}
}
return result, nil
}
func (m *Manager) SyncWAFIPGroups(groups []protocol.WAFIPGroup) error {
if m.RuntimeConfigDir == "" || len(groups) == 0 {
return nil
}
config, err := m.readWAFIPGroupsRuntimeConfig()
if err != nil {
return err
}
if config.Groups == nil {
config.Groups = make(map[string]protocol.WAFIPGroup)
}
for _, group := range groups {
if group.ID == 0 {
continue
}
config.Groups[fmt.Sprintf("%d", group.ID)] = group
}
data, err := json.Marshal(config)
if err != nil {
return err
}
if err := os.MkdirAll(m.RuntimeConfigDir, 0o755); err != nil {
return err
}
path := filepath.Join(m.RuntimeConfigDir, WAFIPGroupsConfigFileName)
if err := os.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("write %s: %w", WAFIPGroupsConfigFileName, err)
}
slog.Info("synced waf ip groups", "path", path, "group_count", len(groups))
return nil
}
func (m *Manager) readWAFIPGroupsRuntimeConfig() (*wafIPGroupsRuntimeConfig, error) {
config := &wafIPGroupsRuntimeConfig{Groups: map[string]protocol.WAFIPGroup{}}
if m.RuntimeConfigDir == "" {
return config, nil
}
path := filepath.Join(m.RuntimeConfigDir, WAFIPGroupsConfigFileName)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return config, nil
}
return nil, err
}
if len(data) == 0 {
return config, nil
}
if err := json.Unmarshal(data, config); err != nil {
return nil, err
}
if config.Groups == nil {
config.Groups = map[string]protocol.WAFIPGroup{}
}
return config, nil
}
type ExecutorOptions struct {
NginxPath string
MainConfigPath string
@@ -463,7 +541,7 @@ func detectVersion(ctx context.Context, options ExecutorOptions, runner CommandR
if err != nil {
return "", fmt.Errorf("run runtime -v failed: %w: %s", err, string(output))
}
version := parseNginxVersion(string(output))
version := parseExtVersion(string(output))
if version == "" {
return "", errors.New("cannot parse runtime version from binary output")
}
@@ -472,7 +550,7 @@ func detectVersion(ctx context.Context, options ExecutorOptions, runner CommandR
return "", errors.New("openresty path is empty")
}
func parseNginxVersion(output string) string {
func parseExtVersion(output string) string {
matches := nginxVersionPattern.FindStringSubmatch(output)
if len(matches) != 2 {
return ""
@@ -480,7 +558,7 @@ func parseNginxVersion(output string) string {
return matches[1]
}
var nginxVersionPattern = regexp.MustCompile(`(?im)(?:nginx|openresty) version:\s*(?:nginx|openresty)/([^\s]+)`)
var nginxVersionPattern = regexp.MustCompile(`(?im)(?:nginx|openresty) version:\s*(?:nginx|openresty)/(\S+)`)
func isIgnorableOpenrestyStopError(output string) bool {
text := strings.ToLower(strings.TrimSpace(output))
@@ -507,6 +585,8 @@ type backupState struct {
RouteData []byte
Files []protocol.SupportFile
PowConfig *protocol.SupportFile
WAFConfig *protocol.SupportFile
SourceConfig *protocol.SupportFile
}
type managedFile struct {
@@ -568,6 +648,16 @@ func (m *Manager) backup() (*backupState, error) {
return nil, err
}
state.PowConfig = powConfig
wafConfig, err := m.readRuntimeConfigFile("waf_config.json")
if err != nil {
return nil, err
}
state.WAFConfig = wafConfig
sourceConfig, err := m.readRuntimeConfigFile(openrestyrender.SourceConfigFileName)
if err != nil {
return nil, err
}
state.SourceConfig = sourceConfig
slog.Debug("backup captured", "main_exists", state.MainExisted, "route_exists", state.RouteExisted, "cert_files", len(state.Files))
return state, nil
}
@@ -596,7 +686,13 @@ func (m *Manager) restore(state *backupState) error {
return err
}
}
return m.restorePowConfig(state)
if err := m.restoreRuntimeConfig(state.PowConfig, "pow_config.json"); err != nil {
return err
}
if err := m.restoreRuntimeConfig(state.WAFConfig, "waf_config.json"); err != nil {
return err
}
return m.restoreRuntimeConfig(state.SourceConfig, openrestyrender.SourceConfigFileName)
}
func (m *Manager) writeCertFiles(certFiles []protocol.SupportFile) error {
@@ -652,10 +748,30 @@ func (m *Manager) writeWAFConfig(supportFiles []protocol.SupportFile) error {
return nil
}
func (m *Manager) writeSourceConfig(supportFiles []protocol.SupportFile) error {
if m.RuntimeConfigDir == "" {
return nil
}
configPath := filepath.Join(m.RuntimeConfigDir, openrestyrender.SourceConfigFileName)
for _, file := range supportFiles {
if file.Path == openrestyrender.SourceConfigFileName {
if err := os.WriteFile(configPath, []byte(file.Content), 0o644); err != nil {
return fmt.Errorf("write %s: %w", openrestyrender.SourceConfigFileName, err)
}
slog.Info("wrote openresty source config", "path", configPath, "size", len(file.Content))
return nil
}
}
if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove %s: %w", openrestyrender.SourceConfigFileName, 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" {
if file.Path == "pow_config.json" || file.Path == "waf_config.json" || file.Path == openrestyrender.SourceConfigFileName {
continue
}
targetPath, err := m.certFileTargetPath(file.Path)
@@ -720,10 +836,14 @@ func (m *Manager) readCertFiles() ([]protocol.SupportFile, error) {
}
func (m *Manager) readPowConfigFile() (*protocol.SupportFile, error) {
return m.readRuntimeConfigFile("pow_config.json")
}
func (m *Manager) readRuntimeConfigFile(name string) (*protocol.SupportFile, error) {
if m.RuntimeConfigDir == "" {
return nil, nil
}
configPath := filepath.Join(m.RuntimeConfigDir, "pow_config.json")
configPath := filepath.Join(m.RuntimeConfigDir, name)
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
@@ -732,7 +852,7 @@ func (m *Manager) readPowConfigFile() (*protocol.SupportFile, error) {
return nil, err
}
return &protocol.SupportFile{
Path: "pow_config.json",
Path: name,
Content: string(data),
}, nil
}
@@ -749,21 +869,28 @@ func (m *Manager) readManagedSupportFiles() ([]protocol.SupportFile, error) {
if powConfig != nil {
files = append(files, *powConfig)
}
wafConfig, err := m.readRuntimeConfigFile("waf_config.json")
if err != nil {
return nil, err
}
if wafConfig != nil {
files = append(files, *wafConfig)
}
return files, nil
}
func (m *Manager) restorePowConfig(state *backupState) error {
if state == nil || m.RuntimeConfigDir == "" {
func (m *Manager) restoreRuntimeConfig(file *protocol.SupportFile, name string) error {
if m.RuntimeConfigDir == "" {
return nil
}
configPath := filepath.Join(m.RuntimeConfigDir, "pow_config.json")
if state.PowConfig == nil {
configPath := filepath.Join(m.RuntimeConfigDir, name)
if file == nil {
if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
return os.WriteFile(configPath, []byte(state.PowConfig.Content), 0o644)
return os.WriteFile(configPath, []byte(file.Content), 0o644)
}
func (m *Manager) writeSafeDefaultFallbackFiles() error {
@@ -997,11 +1124,11 @@ func (m *Manager) ensureMimeTypes() error {
func (m *Manager) renderRouteConfig(content string) string {
rendered := content
if m.NginxCertDir != "" {
rendered = strings.ReplaceAll(rendered, CertDirPlaceholder, m.NginxCertDir)
rendered = strings.ReplaceAll(rendered, openrestyrender.CertDirPlaceholder, m.NginxCertDir)
}
if luaDir := m.luaRuntimePath(); luaDir != "" {
rendered = strings.ReplaceAll(rendered, LuaDirPlaceholder, luaDir)
rendered = strings.ReplaceAll(rendered, PowStaticDirPlaceholder, luaDir+"/pow/static")
rendered = strings.ReplaceAll(rendered, openrestyrender.LuaDirPlaceholder, luaDir)
rendered = strings.ReplaceAll(rendered, openrestyrender.PowStaticDirPlaceholder, luaDir+"/pow/static")
}
return rendered
}
@@ -1009,19 +1136,21 @@ func (m *Manager) renderRouteConfig(content string) string {
func (m *Manager) renderMainConfig(content string) string {
rendered := content
if includePath := m.routeConfigIncludePath(); includePath != "" {
rendered = strings.ReplaceAll(rendered, RouteConfigPlaceholder, includePath)
rendered = strings.ReplaceAll(rendered, openrestyrender.RouteConfigPlaceholder, includePath)
}
if accessLogPath := m.accessLogRuntimePath(); accessLogPath != "" {
rendered = strings.ReplaceAll(rendered, AccessLogPlaceholder, accessLogPath)
rendered = strings.ReplaceAll(rendered, openrestyrender.AccessLogPlaceholder, accessLogPath)
errorLogPath := filepath.Join(filepath.Dir(accessLogPath), "error.log")
rendered = strings.ReplaceAll(rendered, openrestyrender.ErrorLogPlaceholder, filepath.ToSlash(errorLogPath))
}
if luaDir := m.luaRuntimePath(); luaDir != "" {
rendered = strings.ReplaceAll(rendered, LuaDirPlaceholder, luaDir)
rendered = strings.ReplaceAll(rendered, openrestyrender.LuaDirPlaceholder, luaDir)
}
if listen := strings.TrimSpace(m.OpenrestyObservabilityListen); listen != "" {
rendered = strings.ReplaceAll(rendered, ObservabilityListenPlaceholder, listen)
rendered = strings.ReplaceAll(rendered, openrestyrender.ObservabilityListenPlaceholder, listen)
}
if m.OpenrestyObservabilityPort > 0 {
rendered = strings.ReplaceAll(rendered, ObservabilityPortPlaceholder, fmt.Sprintf("%d", m.OpenrestyObservabilityPort))
rendered = strings.ReplaceAll(rendered, openrestyrender.ObservabilityPortPlaceholder, fmt.Sprintf("%d", m.OpenrestyObservabilityPort))
}
if resolverDirective := strings.TrimSpace(m.OpenrestyResolverDirective); resolverDirective != "" {
rendered = strings.ReplaceAll(rendered, ResolverDirectivePlaceholder, resolverDirective)
@@ -1047,23 +1176,23 @@ func (m *Manager) managedWAFLuaFiles() []protocol.SupportFile {
return files
}
func ObservabilityListenAddress(openrestyPath string, port int) string {
func ObservabilityListenAddress(port int) string {
if port <= 0 {
return ""
}
return fmt.Sprintf("127.0.0.1:%d", port)
}
func ResolverDirective(openrestyPath string, explicitResolvers []string) string {
resolvers := resolverAddresses(openrestyPath, explicitResolvers)
func ResolverDirective(explicitResolvers []string) string {
resolvers := resolverAddresses(explicitResolvers)
if len(resolvers) == 0 {
return ""
}
return fmt.Sprintf(" resolver %s valid=30s ipv6=off;\n resolver_timeout 5s;\n", strings.Join(resolvers, " "))
}
func resolverAddresses(openrestyPath string, explicitResolvers []string) []string {
if resolvers := normalizeResolverAddresses(explicitResolvers); len(resolvers) > 0 {
func resolverAddresses(explicitResolvers []string) []string {
if resolvers := utils.UniqueAndCleanStringSlice(explicitResolvers); len(resolvers) > 0 {
return resolvers
}
data, err := os.ReadFile("/etc/resolv.conf")
@@ -1106,29 +1235,6 @@ func isUsableDockerResolver(addr string) bool {
return !ip.IsLoopback() && !ip.IsUnspecified()
}
func normalizeResolverAddresses(values []string) []string {
if len(values) == 0 {
return nil
}
resolvers := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
addr := strings.TrimSpace(value)
if addr == "" {
continue
}
if _, ok := seen[addr]; ok {
continue
}
seen[addr] = struct{}{}
resolvers = append(resolvers, addr)
}
if len(resolvers) == 0 {
return nil
}
return resolvers
}
func RequiresRuntimeResolver(originURL string) bool {
parsed, err := url.Parse(strings.TrimSpace(originURL))
if err != nil || parsed.Hostname() == "" {
+36 -5
View File
@@ -256,13 +256,13 @@ func TestManagerApplyAndChecksumIncludeMainConfig(t *testing.T) {
}
}
func TestParseNginxVersionIgnoresDockerEntrypointPaths(t *testing.T) {
func TestParseExtVersionIgnoresDockerEntrypointPaths(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?)",
"nginx version: openresty/1.27.1.2",
}, "\n")
version := parseNginxVersion(output)
version := parseExtVersion(output)
if version != "1.27.1.2" {
t.Fatalf("unexpected version: %s", version)
}
@@ -392,7 +392,7 @@ func TestManagerCheckHealthFailsWhenStubStatusUnavailable(t *testing.T) {
}
func TestResolverDirectiveUsesExplicitResolvers(t *testing.T) {
got := ResolverDirective("", []string{"10.0.0.2", "1.1.1.1"})
got := ResolverDirective([]string{"10.0.0.2", "1.1.1.1"})
if !strings.Contains(got, "resolver 10.0.0.2 1.1.1.1") {
t.Fatalf("expected explicit resolver directive, got %q", got)
}
@@ -915,11 +915,42 @@ func TestManagerApplyRejectsCertFilePathTraversal(t *testing.T) {
}
}
func TestManagerSyncWAFIPGroupsWritesDeltaRuntimeFile(t *testing.T) {
manager := &Manager{RuntimeConfigDir: t.TempDir()}
if err := manager.SyncWAFIPGroups([]protocol.WAFIPGroup{
{ID: 1, Enabled: true, IPList: []string{"203.0.113.10"}, Checksum: "sum-1"},
}); err != nil {
t.Fatalf("SyncWAFIPGroups failed: %v", err)
}
if err := manager.SyncWAFIPGroups([]protocol.WAFIPGroup{
{ID: 2, Enabled: true, IPList: []string{"198.51.100.10"}, Checksum: "sum-2"},
}); err != nil {
t.Fatalf("SyncWAFIPGroups second delta failed: %v", err)
}
checksums, err := manager.WAFIPGroupChecksums()
if err != nil {
t.Fatalf("WAFIPGroupChecksums failed: %v", err)
}
if checksums["1"] != "sum-1" || checksums["2"] != "sum-2" {
t.Fatalf("expected merged checksums, got %#v", checksums)
}
data, err := os.ReadFile(filepath.Join(manager.RuntimeConfigDir, WAFIPGroupsConfigFileName))
if err != nil {
t.Fatalf("failed to read runtime ip group file: %v", err)
}
text := string(data)
if !strings.Contains(text, "203.0.113.10") || !strings.Contains(text, "198.51.100.10") {
t.Fatalf("expected runtime file to keep both groups, got %s", text)
}
}
func TestObservabilityListenAddress(t *testing.T) {
if got := ObservabilityListenAddress("", 18081); got != "127.0.0.1:18081" {
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" {
if got := ObservabilityListenAddress(18081); got != "127.0.0.1:18081" {
t.Fatalf("unexpected path observability listen address: %s", got)
}
}
@@ -3,8 +3,8 @@ package nginx
import "openflare-agent/internal/protocol"
const (
openRestyObservabilityWindowTTL = 7200
openRestyObservabilityWindowSize = 60
openRestyObservabilityWindowTTL = "7200"
openRestyObservabilityWindowSize = "60"
)
const openRestyObservabilityInitLua = `local dict = ngx.shared.openflare_observability
@@ -25,9 +25,9 @@ if request_uri == "/openflare/observability" or request_uri == "/openflare/stub_
return
end
local ttl = ` + "7200" + `
local ttl = ` + openRestyObservabilityWindowTTL + `
local now = ngx.time()
local window_size = ` + "60" + `
local window_size = ` + openRestyObservabilityWindowSize + `
local window_start = now - (now % window_size)
local function ensure_counter(key)
@@ -109,7 +109,7 @@ if not dict then
end
local now = ngx.time()
local window_size = ` + "60" + `
local window_size = ` + openRestyObservabilityWindowSize + `
local window_start = now - (now % window_size)
local current_window = tostring(window_start)
+2 -2
View File
@@ -17,7 +17,7 @@ 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
if base_dir and base_dir ~= "" and not string.find(package.path, base_dir, 1, true) then
package.path = base_dir .. "/?.lua;" .. base_dir .. "/?/init.lua;" .. package.path
end
end
@@ -170,7 +170,7 @@ 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
if base_dir and base_dir ~= "" and not string.find(package.path, base_dir, 1, true) then
package.path = base_dir .. "/?.lua;" .. base_dir .. "/?/init.lua;" .. package.path
end
end
+52 -7
View File
@@ -49,8 +49,38 @@ local function load_config()
return nil
end
local function load_ip_groups()
local paths = {
"__OPENFLARE_RUNTIME_CONFIG_DIR__/waf_ip_groups.json",
"/etc/nginx/openflare-lua/waf_ip_groups.json",
"/usr/local/openresty/nginx/conf/waf_ip_groups.json"
}
for _, path in ipairs(paths) do
local content = read_file(path)
if content and content ~= "" then
local hash = ngx.md5(content)
if config_dict:get("_ip_groups_hash") == hash then
local cached = config_dict:get("_ip_groups_json")
if cached then
local decoded = cjson.decode(cached)
if decoded then
return decoded
end
end
end
local decoded = cjson.decode(content)
if decoded then
config_dict:set("_ip_groups_hash", hash, 0)
config_dict:set("_ip_groups_json", content, 0)
return decoded
end
end
end
return { groups = {} }
end
local function list_contains(items, value)
if not items or not value or value == "" then
if not items or type(items) ~= "table" or not value or value == "" then
return false
end
for _, item in ipairs(items) do
@@ -95,7 +125,7 @@ local function ipv4_in_cidr(ip, cidr)
end
local function ip_matches(items, ip)
if not items or not ip or ip == "" then
if not items or type(items) ~= "table" or not ip or ip == "" then
return false
end
for _, item in ipairs(items) do
@@ -109,6 +139,20 @@ local function ip_matches(items, ip)
return false
end
local function ip_matches_group_ids(group_ids, ip, ip_groups_config)
if not group_ids or type(group_ids) ~= "table" or not ip or ip == "" then
return false
end
local groups = (ip_groups_config or {}).groups or {}
for _, id in ipairs(group_ids) do
local group = groups[tostring(id)]
if group and group.enabled and ip_matches(group.ip_list, ip) then
return true
end
end
return false
end
local function lookup_country(ip)
local ok, maxminddb = pcall(require, "resty.maxminddb")
if not ok or not maxminddb then
@@ -181,6 +225,7 @@ end
local ip = ngx.var.remote_addr or ""
local groups = active_groups(config)
local ip_groups_config = load_ip_groups()
if #groups == 0 then
if config_dict:add("_empty_groups_logged", true, 60) then
ngx.log(ngx.WARN, "openflare waf has no active rule group for site: ", ngx.var.openflare_waf_site or "")
@@ -189,14 +234,14 @@ if #groups == 0 then
end
for _, group in ipairs(groups) do
if ip_matches(group.ip_whitelist, ip) then
if ip_matches(group.ip_whitelist, ip) or ip_matches_group_ids(group.ip_whitelist_group_ids, ip, ip_groups_config) then
return
end
end
local country = nil
for _, group in ipairs(groups) do
if group.country_whitelist and #group.country_whitelist > 0 then
if type(group.country_whitelist) == "table" and #group.country_whitelist > 0 then
country = country or lookup_country(ip)
if list_contains(group.country_whitelist, country) then
return
@@ -205,13 +250,13 @@ for _, group in ipairs(groups) do
end
for _, group in ipairs(groups) do
if ip_matches(group.ip_blacklist, ip) then
if ip_matches(group.ip_blacklist, ip) or ip_matches_group_ids(group.ip_blacklist_group_ids, ip, ip_groups_config) then
return exit_with_group(group)
end
end
for _, group in ipairs(groups) do
if group.country_blacklist and #group.country_blacklist > 0 then
if type(group.country_blacklist) == "table" and #group.country_blacklist > 0 then
country = country or lookup_country(ip)
if list_contains(group.country_blacklist, country) then
return exit_with_group(group)
@@ -229,7 +274,7 @@ const openRestyWAFCheckLua = `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, "^(.*)/waf/[^/]+%.lua$")
if base_dir and base_dir ~= "" then
if base_dir and base_dir ~= "" and not string.find(package.path, base_dir, 1, true) then
package.path = base_dir .. "/?.lua;" .. base_dir .. "/?/init.lua;" .. package.path
end
end
@@ -40,7 +40,7 @@ func BuildProfile(cfg *config.Config, stateStore *state.Store) *protocol.NodeSys
return profile
}
func BuildSnapshot(cfg *config.Config, stateStore *state.Store, managed *managedOpenRestyMetrics) *protocol.NodeMetricSnapshot {
func BuildSnapshot(cfg *config.Config, stateStore *state.Store) *protocol.NodeMetricSnapshot {
now := time.Now().UTC()
metric := &protocol.NodeMetricSnapshot{
CapturedAtUnix: now.Unix(),
@@ -56,11 +56,6 @@ func BuildSnapshot(cfg *config.Config, stateStore *state.Store, managed *managed
metric.NetworkRxBytes, metric.NetworkTxBytes = readLinuxNetworkTotals()
metric.DiskReadBytes, metric.DiskWriteBytes = readLinuxDiskTotals()
if managed != nil {
metric.OpenrestyRxBytes = managed.OpenrestyRxBytes
metric.OpenrestyTxBytes = managed.OpenrestyTxBytes
metric.OpenrestyConnections = managed.OpenrestyConnections
}
if stateStore == nil {
return metric
@@ -86,6 +81,18 @@ func BuildSnapshot(cfg *config.Config, stateStore *state.Store, managed *managed
return metric
}
func BuildOpenrestyObservation(managed *ManagedOpenRestyMetrics) *protocol.NodeOpenrestyObservation {
if managed == nil {
return nil
}
return &protocol.NodeOpenrestyObservation{
CapturedAtUnix: time.Now().UTC().Unix(),
OpenrestyRxBytes: managed.OpenrestyRxBytes,
OpenrestyTxBytes: managed.OpenrestyTxBytes,
OpenrestyConnections: managed.OpenrestyConnections,
}
}
func BuildHealthEvents(snapshot *state.Snapshot) []protocol.NodeHealthEvent {
if snapshot == nil {
return []protocol.NodeHealthEvent{}
@@ -18,7 +18,7 @@ const openRestyStubStatusPath = "/openflare/stub_status"
var stubStatusActivePattern = regexp.MustCompile(`Active connections:\s+(\d+)`)
type managedOpenRestyMetrics struct {
type ManagedOpenRestyMetrics struct {
TrafficReport *protocol.NodeTrafficReport
OpenrestyRxBytes int64
OpenrestyTxBytes int64
@@ -38,7 +38,7 @@ type openRestyObservabilityResponse struct {
OpenrestyTxBytes int64 `json:"openresty_tx_bytes"`
}
func CollectManagedOpenRestyMetrics(cfg *config.Config) *managedOpenRestyMetrics {
func CollectManagedOpenRestyMetrics(cfg *config.Config) *ManagedOpenRestyMetrics {
if cfg == nil || cfg.OpenrestyObservabilityPort <= 0 {
return nil
}
@@ -51,7 +51,7 @@ func CollectManagedOpenRestyMetrics(cfg *config.Config) *managedOpenRestyMetrics
return nil
}
result := &managedOpenRestyMetrics{
result := &ManagedOpenRestyMetrics{
TrafficReport: &protocol.NodeTrafficReport{
WindowStartedAtUnix: observabilityResp.WindowStartedAtUnix,
WindowEndedAtUnix: observabilityResp.WindowEndedAtUnix,
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"io"
"log/slog"
"openflare-agent/internal/config"
"openflare-agent/internal/protocol"
"openflare-agent/internal/state"
@@ -26,7 +27,7 @@ type accessLogRecord struct {
RequestLength int64 `json:"request_length"`
}
var combinedAccessLogPattern = regexp.MustCompile(`^(\S+)\s+\S+\s+\S+\s+\[([^\]]+)\]\s+"(?:\S+)\s+(\S+)(?:\s+[^"]*)?"\s+(\d{3})\s+\S+`)
var combinedAccessLogPattern = regexp.MustCompile(`^(\S+)\s+\S+\s+\S+\s+\[([^]]+)]\s+"\S+\s+(\S+)(?:\s+[^"]*)?"\s+(\d{3})\s+\S+`)
type trafficAggregate struct {
windowStartedAt time.Time
@@ -41,12 +42,12 @@ type trafficAggregate struct {
logs []protocol.NodeAccessLog
}
func BuildTrafficReport(cfg *config.Config, stateStore *state.Store, managed *managedOpenRestyMetrics) *protocol.NodeTrafficReport {
func BuildTrafficReport(cfg *config.Config, stateStore *state.Store, managed *ManagedOpenRestyMetrics) *protocol.NodeTrafficReport {
report, _, _ := BuildTrafficObservability(cfg, stateStore, managed)
return report
}
func BuildTrafficObservability(cfg *config.Config, stateStore *state.Store, managed *managedOpenRestyMetrics) (*protocol.NodeTrafficReport, []protocol.NodeAccessLog, *managedOpenRestyMetrics) {
func BuildTrafficObservability(cfg *config.Config, stateStore *state.Store, managed *ManagedOpenRestyMetrics) (*protocol.NodeTrafficReport, []protocol.NodeAccessLog, *ManagedOpenRestyMetrics) {
if cfg == nil || stateStore == nil {
if managed != nil && managed.TrafficReport != nil {
return managed.TrafficReport, nil, managed
@@ -55,7 +56,7 @@ func BuildTrafficObservability(cfg *config.Config, stateStore *state.Store, mana
}
aggregate := readAccessLogDelta(cfg, stateStore)
accessLogs := []protocol.NodeAccessLog{}
var accessLogs []protocol.NodeAccessLog
if aggregate != nil {
accessLogs = aggregate.accessLogs()
}
@@ -87,7 +88,12 @@ func readAccessLogDelta(cfg *config.Config, stateStore *state.Store) *trafficAgg
}
return nil
}
defer file.Close()
defer func(file *os.File) {
err := file.Close()
if err != nil {
slog.Error("failed to close access log file", "error", err)
}
}(file)
info, err := file.Stat()
if err != nil {
@@ -270,7 +276,7 @@ func (aggregate *trafficAggregate) accessLogs() []protocol.NodeAccessLog {
return append([]protocol.NodeAccessLog(nil), aggregate.logs...)
}
func (aggregate *trafficAggregate) managedMetrics() *managedOpenRestyMetrics {
func (aggregate *trafficAggregate) managedMetrics() *ManagedOpenRestyMetrics {
if aggregate == nil {
return nil
}
@@ -278,7 +284,7 @@ func (aggregate *trafficAggregate) managedMetrics() *managedOpenRestyMetrics {
if report == nil && aggregate.openrestyRxBytes <= 0 && aggregate.openrestyTxBytes <= 0 {
return nil
}
return &managedOpenRestyMetrics{
return &ManagedOpenRestyMetrics{
TrafficReport: report,
OpenrestyRxBytes: aggregate.openrestyRxBytes,
OpenrestyTxBytes: aggregate.openrestyTxBytes,
@@ -167,7 +167,7 @@ func TestBuildTrafficReportParsesCombinedAccessLog(t *testing.T) {
}
func TestBuildTrafficReportReturnsManagedWindowEvenWhenRequestCountZero(t *testing.T) {
report := BuildTrafficReport(nil, nil, &managedOpenRestyMetrics{
report := BuildTrafficReport(nil, nil, &ManagedOpenRestyMetrics{
TrafficReport: &protocol.NodeTrafficReport{
WindowStartedAtUnix: 1710403200,
WindowEndedAtUnix: 1710403260,
+55 -29
View File
@@ -14,11 +14,13 @@ type HeartbeatAPIResponse struct {
Data any `json:"data"`
AgentSettings *AgentSettings `json:"agent_settings,omitempty"`
ActiveConfig *ActiveConfigMeta `json:"active_config,omitempty"`
WAFIPGroups []WAFIPGroup `json:"waf_ip_groups,omitempty"`
}
type HeartbeatResult struct {
AgentSettings *AgentSettings
ActiveConfig *ActiveConfigMeta
WAFIPGroups []WAFIPGroup
}
type AgentSettings struct {
@@ -37,6 +39,7 @@ const (
WSMessageTypeSettings = "settings"
WSMessageTypeActiveConfig = "active_config"
WSMessageTypeForceSyncConfig = "force_sync_config"
WSMessageTypeWAFIPGroups = "waf_ip_groups"
WSMessageTypePing = "ping"
WSMessageTypePong = "pong"
)
@@ -69,18 +72,20 @@ type NodePayload struct {
NodeID string `json:"node_id"`
Name string `json:"name"`
IP string `json:"ip"`
AgentVersion string `json:"agent_version"`
NginxVersion string `json:"nginx_version"`
Version string `json:"version"`
ExtVersion string `json:"ext_version"`
CurrentVersion string `json:"current_version"`
LastError string `json:"last_error"`
OpenrestyStatus string `json:"openresty_status"`
OpenrestyMessage string `json:"openresty_message"`
Profile *NodeSystemProfile `json:"profile,omitempty"`
Snapshot *NodeMetricSnapshot `json:"snapshot,omitempty"`
OpenrestyObservation *NodeOpenrestyObservation `json:"openresty_observation,omitempty"`
TrafficReport *NodeTrafficReport `json:"traffic_report,omitempty"`
AccessLogs []NodeAccessLog `json:"access_logs,omitempty"`
BufferedObservability []BufferedObservabilityRecord `json:"buffered_observability,omitempty"`
HealthEvents []NodeHealthEvent `json:"health_events"`
WAFIPGroupChecksums map[string]string `json:"waf_ip_group_checksums,omitempty"`
}
type NodeSystemProfile struct {
@@ -98,19 +103,23 @@ type NodeSystemProfile struct {
}
type NodeMetricSnapshot struct {
CapturedAtUnix int64 `json:"captured_at_unix"`
CPUUsagePercent float64 `json:"cpu_usage_percent"`
MemoryUsedBytes int64 `json:"memory_used_bytes"`
MemoryTotalBytes int64 `json:"memory_total_bytes"`
StorageUsedBytes int64 `json:"storage_used_bytes"`
StorageTotalBytes int64 `json:"storage_total_bytes"`
DiskReadBytes int64 `json:"disk_read_bytes"`
DiskWriteBytes int64 `json:"disk_write_bytes"`
NetworkRxBytes int64 `json:"network_rx_bytes"`
NetworkTxBytes int64 `json:"network_tx_bytes"`
OpenrestyRxBytes int64 `json:"openresty_rx_bytes"`
OpenrestyTxBytes int64 `json:"openresty_tx_bytes"`
OpenrestyConnections int64 `json:"openresty_connections"`
CapturedAtUnix int64 `json:"captured_at_unix"`
CPUUsagePercent float64 `json:"cpu_usage_percent"`
MemoryUsedBytes int64 `json:"memory_used_bytes"`
MemoryTotalBytes int64 `json:"memory_total_bytes"`
StorageUsedBytes int64 `json:"storage_used_bytes"`
StorageTotalBytes int64 `json:"storage_total_bytes"`
DiskReadBytes int64 `json:"disk_read_bytes"`
DiskWriteBytes int64 `json:"disk_write_bytes"`
NetworkRxBytes int64 `json:"network_rx_bytes"`
NetworkTxBytes int64 `json:"network_tx_bytes"`
}
type NodeOpenrestyObservation struct {
CapturedAtUnix int64 `json:"captured_at_unix"`
OpenrestyRxBytes int64 `json:"openresty_rx_bytes"`
OpenrestyTxBytes int64 `json:"openresty_tx_bytes"`
OpenrestyConnections int64 `json:"openresty_connections"`
}
type NodeTrafficReport struct {
@@ -133,10 +142,11 @@ type NodeAccessLog struct {
}
type BufferedObservabilityRecord struct {
WindowStartedAtUnix int64 `json:"window_started_at_unix"`
Snapshot *NodeMetricSnapshot `json:"snapshot,omitempty"`
TrafficReport *NodeTrafficReport `json:"traffic_report,omitempty"`
AccessLogs []NodeAccessLog `json:"access_logs,omitempty"`
WindowStartedAtUnix int64 `json:"window_started_at_unix"`
Snapshot *NodeMetricSnapshot `json:"snapshot,omitempty"`
OpenrestyObservation *NodeOpenrestyObservation `json:"openresty_observation,omitempty"`
TrafficReport *NodeTrafficReport `json:"traffic_report,omitempty"`
AccessLogs []NodeAccessLog `json:"access_logs,omitempty"`
}
type NodeHealthEvent struct {
@@ -148,9 +158,9 @@ type NodeHealthEvent struct {
}
type RegisterNodeResponse struct {
NodeID string `json:"node_id"`
AgentToken string `json:"agent_token"`
Name string `json:"name"`
NodeID string `json:"node_id"`
AccessToken string `json:"agent_token"`
Name string `json:"name"`
}
type ApplyLogPayload struct {
@@ -165,13 +175,11 @@ type ApplyLogPayload struct {
}
type ActiveConfigResponse struct {
Version string `json:"version"`
Checksum string `json:"checksum"`
MainConfig string `json:"main_config"`
RouteConfig string `json:"route_config"`
RenderedConfig string `json:"rendered_config"`
SupportFiles []SupportFile `json:"support_files"`
CreatedAt string `json:"created_at"`
Version string `json:"version"`
Checksum string `json:"checksum"`
SourceConfigJSON string `json:"source_config_json"`
SupportFiles []SupportFile `json:"support_files"`
CreatedAt string `json:"created_at"`
}
type ActiveConfigMeta struct {
@@ -179,6 +187,24 @@ type ActiveConfigMeta struct {
Checksum string `json:"checksum"`
}
type WAFIPGroup struct {
ID uint `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
IPList []string `json:"ip_list"`
Checksum string `json:"checksum"`
}
type WAFIPGroupSyncRequest struct {
IDs []uint `json:"ids,omitempty"`
Checksums map[string]string `json:"checksums,omitempty"`
}
type WAFIPGroupSyncResponse struct {
Groups []WAFIPGroup `json:"groups"`
}
type SupportFile struct {
Path string `json:"path"`
Content string `json:"content"`
@@ -14,11 +14,12 @@ import (
const observabilityBufferWindowSeconds = 60
type ObservabilityBufferRecord struct {
WindowStartedAtUnix int64 `json:"window_started_at_unix"`
Snapshot *protocol.NodeMetricSnapshot `json:"snapshot,omitempty"`
TrafficReport *protocol.NodeTrafficReport `json:"traffic_report,omitempty"`
AccessLogs []protocol.NodeAccessLog `json:"access_logs,omitempty"`
QueuedAtUnix int64 `json:"queued_at_unix"`
WindowStartedAtUnix int64 `json:"window_started_at_unix"`
Snapshot *protocol.NodeMetricSnapshot `json:"snapshot,omitempty"`
OpenrestyObservation *protocol.NodeOpenrestyObservation `json:"openresty_observation,omitempty"`
TrafficReport *protocol.NodeTrafficReport `json:"traffic_report,omitempty"`
AccessLogs []protocol.NodeAccessLog `json:"access_logs,omitempty"`
QueuedAtUnix int64 `json:"queued_at_unix"`
}
type ObservabilityBufferStore struct {
@@ -31,7 +32,7 @@ func NewObservabilityBufferStore(path string) *ObservabilityBufferStore {
}
func (s *ObservabilityBufferStore) Upsert(record ObservabilityBufferRecord, retainAfterUnix int64) error {
if s == nil || record.WindowStartedAtUnix <= 0 || (record.Snapshot == nil && record.TrafficReport == nil && len(record.AccessLogs) == 0) {
if s == nil || record.WindowStartedAtUnix <= 0 || (record.Snapshot == nil && record.OpenrestyObservation == nil && record.TrafficReport == nil && len(record.AccessLogs) == 0) {
return nil
}
s.mu.Lock()
@@ -65,6 +66,9 @@ func mergeObservabilityBufferRecord(existing ObservabilityBufferRecord, incoming
if incoming.Snapshot != nil {
merged.Snapshot = incoming.Snapshot
}
if incoming.OpenrestyObservation != nil {
merged.OpenrestyObservation = incoming.OpenrestyObservation
}
if incoming.TrafficReport != nil {
merged.TrafficReport = incoming.TrafficReport
}
@@ -191,10 +195,13 @@ func (s *ObservabilityBufferStore) saveUnlocked(records []ObservabilityBufferRec
return os.WriteFile(s.path, data, 0o644)
}
func ObservabilityWindowStartedAt(snapshot *protocol.NodeMetricSnapshot, traffic *protocol.NodeTrafficReport) int64 {
func ObservabilityWindowStartedAt(snapshot *protocol.NodeMetricSnapshot, openresty *protocol.NodeOpenrestyObservation, traffic *protocol.NodeTrafficReport) int64 {
if traffic != nil && traffic.WindowStartedAtUnix > 0 {
return traffic.WindowStartedAtUnix - (traffic.WindowStartedAtUnix % observabilityBufferWindowSeconds)
}
if openresty != nil && openresty.CapturedAtUnix > 0 {
return openresty.CapturedAtUnix - (openresty.CapturedAtUnix % observabilityBufferWindowSeconds)
}
if snapshot == nil || snapshot.CapturedAtUnix <= 0 {
return 0
}
@@ -89,10 +89,10 @@ func TestObservabilityBufferStoreMergesAccessLogsWithinWindow(t *testing.T) {
}
func TestObservabilityWindowStartedAt(t *testing.T) {
if value := ObservabilityWindowStartedAt(nil, &protocol.NodeTrafficReport{WindowStartedAtUnix: 1710403200}); value != 1710403200 {
if value := ObservabilityWindowStartedAt(nil, nil, &protocol.NodeTrafficReport{WindowStartedAtUnix: 1710403200}); value != 1710403200 {
t.Fatalf("unexpected traffic window start: %d", value)
}
if value := ObservabilityWindowStartedAt(&protocol.NodeMetricSnapshot{CapturedAtUnix: 1710403259}, nil); value != 1710403200 {
if value := ObservabilityWindowStartedAt(&protocol.NodeMetricSnapshot{CapturedAtUnix: 1710403259}, nil, nil); value != 1710403200 {
t.Fatalf("unexpected snapshot-derived window start: %d", value)
}
}
@@ -1,7 +1,10 @@
package state
import (
"fmt"
"os"
"path/filepath"
"sync"
"testing"
)
@@ -19,3 +22,129 @@ func TestEnsureNodeIDPersists(t *testing.T) {
t.Fatal("expected node id to persist across calls")
}
}
func TestStore_Load_NonExistentFile(t *testing.T) {
// Loading from a non-existent path should succeed and return an empty Snapshot
tempFile := filepath.Join(t.TempDir(), "nonexistent.json")
store := NewStore(tempFile)
snap, err := store.Load()
if err != nil {
t.Fatalf("expected Load to succeed for non-existent file, got err: %v", err)
}
if snap == nil {
t.Fatal("expected non-nil snapshot")
}
if snap.NodeID != "" || snap.CurrentVersion != "" {
t.Errorf("expected empty snapshot, got: %+v", snap)
}
}
func TestStore_Load_EmptyFile(t *testing.T) {
// Loading from an empty file should succeed and return an empty Snapshot
tempFile := filepath.Join(t.TempDir(), "empty.json")
if err := os.WriteFile(tempFile, []byte(""), 0644); err != nil {
t.Fatalf("failed to create empty file: %v", err)
}
store := NewStore(tempFile)
snap, err := store.Load()
if err != nil {
t.Fatalf("expected Load to succeed for empty file, got err: %v", err)
}
if snap == nil {
t.Fatal("expected non-nil snapshot")
}
if snap.NodeID != "" {
t.Errorf("expected empty snapshot, got: %+v", snap)
}
}
func TestStore_Load_InvalidJSON(t *testing.T) {
// Loading from a corrupted file with invalid JSON should fail with parsing error
tempFile := filepath.Join(t.TempDir(), "corrupted.json")
if err := os.WriteFile(tempFile, []byte("{invalid-json"), 0644); err != nil {
t.Fatalf("failed to create corrupted file: %v", err)
}
store := NewStore(tempFile)
_, err := store.Load()
if err == nil {
t.Fatal("expected Load to fail for corrupted JSON file")
}
}
func TestStore_SaveAndLoad(t *testing.T) {
tempFile := filepath.Join(t.TempDir(), "state.json")
store := NewStore(tempFile)
original := &Snapshot{
NodeID: "node-test-123",
CurrentVersion: "20260531-001",
CurrentChecksum: "chk-active-xyz",
BlockedVersion: "20260531-002",
BlockedChecksum: "chk-blocked-abc",
BlockedReason: "invalid upstream domain name",
LastError: "configuration reload timeout",
OpenrestyStatus: "unhealthy",
}
if err := store.Save(original); err != nil {
t.Fatalf("expected Save to succeed, got: %v", err)
}
loaded, err := store.Load()
if err != nil {
t.Fatalf("expected Load to succeed, got: %v", err)
}
if loaded.NodeID != original.NodeID ||
loaded.CurrentVersion != original.CurrentVersion ||
loaded.CurrentChecksum != original.CurrentChecksum ||
loaded.BlockedVersion != original.BlockedVersion ||
loaded.BlockedChecksum != original.BlockedChecksum ||
loaded.BlockedReason != original.BlockedReason ||
loaded.LastError != original.LastError ||
loaded.OpenrestyStatus != original.OpenrestyStatus {
t.Errorf("loaded snapshot does not match original: %+v vs %+v", loaded, original)
}
}
func TestStore_ConcurrencySafety(t *testing.T) {
tempFile := filepath.Join(t.TempDir(), "state.json")
store := NewStore(tempFile)
var wg sync.WaitGroup
workers := 20
iterations := 50
// Run concurrent writers and readers
for i := 0; i < workers; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for j := 0; j < iterations; j++ {
// Concurrently save
snap := &Snapshot{
NodeID: fmt.Sprintf("node-%d", workerID),
CurrentVersion: fmt.Sprintf("v-%d", j),
}
if err := store.Save(snap); err != nil {
t.Errorf("Save failed under concurrency: %v", err)
}
// Concurrently load
if _, err := store.Load(); err != nil {
t.Errorf("Load failed under concurrency: %v", err)
}
// Concurrently ensure ID
if _, err := store.EnsureNodeID(); err != nil {
t.Errorf("EnsureNodeID failed under concurrency: %v", err)
}
}
}(i)
}
wg.Wait()
}
+168 -37
View File
@@ -4,8 +4,12 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
openrestyrender "openflare/utils/render/openresty"
"sort"
"strings"
"openflare-agent/internal/nginx"
@@ -22,6 +26,7 @@ const (
type ConfigClient interface {
GetActiveConfig(ctx context.Context) (*protocol.ActiveConfigResponse, error)
ReportApplyLog(ctx context.Context, payload protocol.ApplyLogPayload) error
SyncWAFIPGroups(ctx context.Context, payload protocol.WAFIPGroupSyncRequest) (*protocol.WAFIPGroupSyncResponse, error)
}
type NginxManager interface {
@@ -29,6 +34,8 @@ type NginxManager interface {
EnsureRuntime(ctx context.Context, recreate bool) error
EnsureSafeFallbackRuntime(ctx context.Context, reason string) error
CurrentChecksum() (string, error)
WAFIPGroupChecksums() (map[string]string, error)
SyncWAFIPGroups(groups []protocol.WAFIPGroup) error
}
type Service struct {
@@ -91,20 +98,16 @@ func (s *Service) sync(ctx context.Context, startup bool, target *protocol.Activ
}
if currentChecksum == target.Checksum {
if startup {
config, fetchErr := s.client.GetActiveConfig(ctx)
if fetchErr != nil {
slog.Error("fetch active config failed", "mode", mode, "error", fetchErr)
return fetchErr
}
return s.applyIfNeeded(ctx, mode, startup, snapshot, currentChecksum, target, config)
}
slog.Debug("local openresty config already up to date", "mode", mode, "version", target.Version)
shouldReport := shouldReportNoopApply(snapshot, target.Version, target.Checksum)
if startup {
slog.Debug("ensuring openresty runtime on startup", "version", target.Version)
if err = s.nginxManager.EnsureRuntime(ctx, true); err != nil {
snapshot.OpenrestyStatus = protocol.OpenrestyStatusUnhealthy
snapshot.OpenrestyMessage = err.Error()
_ = s.stateStore.Save(snapshot)
return err
}
slog.Debug("openresty runtime ensured on startup", "version", target.Version)
snapshot.OpenrestyStatus = protocol.OpenrestyStatusHealthy
snapshot.OpenrestyMessage = ""
}
if shouldReport {
if err = s.reportNoopApply(ctx, snapshot.NodeID, target.Version, target.Checksum, "", "", 0); err != nil {
return err
@@ -152,31 +155,42 @@ func (s *Service) ForceSyncOnce(ctx context.Context, target *protocol.ActiveConf
clearBlockedTarget(snapshot)
_ = s.stateStore.Save(snapshot)
}
return s.SyncOnce(ctx, target)
currentChecksum, err := s.nginxManager.CurrentChecksum()
if err != nil {
return err
}
config, err := s.client.GetActiveConfig(ctx)
if err != nil {
slog.Error("fetch active config failed", "mode", "force", "error", err)
return err
}
return s.applyIfNeeded(ctx, "force", true, snapshot, currentChecksum, target, config)
}
func (s *Service) WAFIPGroupChecksums() (map[string]string, error) {
if s.nginxManager == nil {
return map[string]string{}, nil
}
return s.nginxManager.WAFIPGroupChecksums()
}
func (s *Service) ApplyWAFIPGroups(ctx context.Context, groups []protocol.WAFIPGroup) error {
if len(groups) == 0 || s.nginxManager == nil {
return nil
}
return s.nginxManager.SyncWAFIPGroups(groups)
}
func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool, snapshot *state.Snapshot, currentChecksum string, target *protocol.ActiveConfigMeta, config *protocol.ActiveConfigResponse) error {
if currentChecksum == config.Checksum {
if currentChecksum == config.Checksum && !startup {
slog.Debug("local openresty config already up to date", "mode", mode, "version", config.Version)
shouldReport := shouldReportNoopApply(snapshot, config.Version, config.Checksum)
if startup {
slog.Debug("ensuring openresty runtime on startup", "version", config.Version)
if err := s.nginxManager.EnsureRuntime(ctx, true); err != nil {
snapshot.OpenrestyStatus = protocol.OpenrestyStatusUnhealthy
snapshot.OpenrestyMessage = err.Error()
_ = s.stateStore.Save(snapshot)
return err
}
slog.Debug("openresty runtime ensured on startup", "version", config.Version)
snapshot.OpenrestyStatus = protocol.OpenrestyStatusHealthy
snapshot.OpenrestyMessage = ""
}
if shouldReport {
routeConfig := config.RouteConfig
if routeConfig == "" {
routeConfig = config.RenderedConfig
rendered, renderErr := renderActiveConfig(config)
if renderErr != nil {
return renderErr
}
if err := s.reportNoopApply(ctx, snapshot.NodeID, config.Version, config.Checksum, checksumString(config.MainConfig), checksumString(routeConfig), len(config.SupportFiles)); err != nil {
if err := s.reportNoopApply(ctx, snapshot.NodeID, config.Version, config.Checksum, checksumString(rendered.mainConfig), checksumString(rendered.routeConfig), len(rendered.supportFiles)); err != nil {
return err
}
}
@@ -207,14 +221,14 @@ func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool,
slog.Debug("skipping apply because state already records target version/checksum", "version", config.Version, "checksum", config.Checksum)
return s.stateStore.Save(snapshot)
}
routeConfig := config.RouteConfig
if routeConfig == "" {
routeConfig = config.RenderedConfig
rendered, err := renderActiveConfig(config)
if err != nil {
return err
}
mainConfigChecksum := checksumString(config.MainConfig)
routeConfigChecksum := checksumString(routeConfig)
mainConfigChecksum := checksumString(rendered.mainConfig)
routeConfigChecksum := checksumString(rendered.routeConfig)
slog.Info("applying new openresty config", "mode", mode, "from_version", snapshot.CurrentVersion, "to_version", config.Version, "old_checksum", currentChecksum, "new_checksum", config.Checksum)
outcome := s.nginxManager.Apply(ctx, config.MainConfig, routeConfig, config.SupportFiles)
outcome := s.nginxManager.Apply(ctx, rendered.mainConfig, rendered.routeConfig, rendered.supportFiles)
message := strings.TrimSpace(outcome.Message)
if outcome.Status == "" {
outcome.Status = nginx.ApplyStatusFatal
@@ -269,7 +283,7 @@ func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool,
Checksum: config.Checksum,
MainConfigChecksum: mainConfigChecksum,
RouteConfigChecksum: routeConfigChecksum,
SupportFileCount: len(config.SupportFiles),
SupportFileCount: len(rendered.supportFiles),
}); err != nil {
slog.Error("report apply log failed", "version", config.Version, "result", reportResult, "error", err)
return err
@@ -278,10 +292,127 @@ func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool,
slog.Warn("failed apply log reported", "version", config.Version)
return outcomeError(config.Version, message)
}
if err := s.syncReferencedWAFIPGroups(ctx, rendered.supportFiles); err != nil {
slog.Error("sync referenced waf ip groups failed", "version", config.Version, "error", err)
return err
}
slog.Debug("apply log reported", "version", config.Version, "result", reportResult)
return nil
}
func (s *Service) syncReferencedWAFIPGroups(ctx context.Context, supportFiles []protocol.SupportFile) error {
ids := referencedWAFIPGroupIDs(supportFiles)
if len(ids) == 0 {
return nil
}
checksums, err := s.WAFIPGroupChecksums()
if err != nil {
return err
}
response, err := s.client.SyncWAFIPGroups(ctx, protocol.WAFIPGroupSyncRequest{
IDs: ids,
Checksums: checksums,
})
if err != nil {
return err
}
if response == nil || len(response.Groups) == 0 {
return nil
}
return s.ApplyWAFIPGroups(ctx, response.Groups)
}
type renderedActiveConfig struct {
mainConfig string
routeConfig string
supportFiles []protocol.SupportFile
}
func renderActiveConfig(config *protocol.ActiveConfigResponse) (*renderedActiveConfig, error) {
if config == nil {
return nil, errors.New("active config is nil")
}
sourceJSON := strings.TrimSpace(config.SourceConfigJSON)
if sourceJSON == "" {
return nil, errors.New("active config source_config_json is empty")
}
rendered, err := openrestyrender.RenderJSON(sourceJSON, toOpenRestySupportFiles(config.SupportFiles))
if err != nil {
return nil, err
}
files := fromOpenRestySupportFiles(rendered.SupportFiles)
files = append(files, protocol.SupportFile{Path: openrestyrender.SourceConfigFileName, Content: sourceJSON})
return &renderedActiveConfig{
mainConfig: rendered.MainConfig,
routeConfig: rendered.RouteConfig,
supportFiles: files,
}, nil
}
func toOpenRestySupportFiles(files []protocol.SupportFile) []openrestyrender.SupportFile {
if len(files) == 0 {
return nil
}
result := make([]openrestyrender.SupportFile, 0, len(files))
for _, file := range files {
result = append(result, openrestyrender.SupportFile{Path: file.Path, Content: file.Content})
}
return result
}
func fromOpenRestySupportFiles(files []openrestyrender.SupportFile) []protocol.SupportFile {
if len(files) == 0 {
return nil
}
result := make([]protocol.SupportFile, 0, len(files))
for _, file := range files {
result = append(result, protocol.SupportFile{Path: file.Path, Content: file.Content})
}
return result
}
func referencedWAFIPGroupIDs(supportFiles []protocol.SupportFile) []uint {
var content string
for _, file := range supportFiles {
if file.Path == "waf_config.json" {
content = strings.TrimSpace(file.Content)
break
}
}
if content == "" {
return []uint{}
}
var payload struct {
RuleGroups []struct {
IPWhitelistGroups []uint `json:"ip_whitelist_group_ids"`
IPBlacklistGroups []uint `json:"ip_blacklist_group_ids"`
} `json:"rule_groups"`
}
if err := json.Unmarshal([]byte(content), &payload); err != nil {
slog.Debug("decode waf_config.json for ip group references failed", "error", err)
return []uint{}
}
seen := make(map[uint]struct{})
for _, group := range payload.RuleGroups {
for _, id := range group.IPWhitelistGroups {
if id > 0 {
seen[id] = struct{}{}
}
}
for _, id := range group.IPBlacklistGroups {
if id > 0 {
seen[id] = struct{}{}
}
}
}
ids := make([]uint, 0, len(seen))
for id := range seen {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids
}
func shouldReportNoopApply(snapshot *state.Snapshot, version string, checksum string) bool {
if snapshot == nil {
return false
+73 -80
View File
@@ -2,8 +2,10 @@ package sync
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -36,6 +38,10 @@ type fakeManager struct {
applyFiles [][]protocol.SupportFile
}
func testSourceConfigJSON(workerProcesses string, listen int) string {
return fmt.Sprintf(`{"routes":[{"id":1,"site_name":"example","domain":"example.com","domains":["example.com"],"origin_url":"http://127.0.0.1:%d","upstreams":["http://127.0.0.1:%d"],"enabled":true}],"openresty_config":{"worker_processes":"%s","worker_connections":1024,"worker_rlimit_nofile":65535,"events_multi_accept_enabled":true,"keepalive_timeout":20,"keepalive_requests":1000,"client_header_timeout":15,"client_body_timeout":15,"client_max_body_size":"64m","large_client_header_buffers":"4 16k","send_timeout":30,"proxy_connect_timeout":3,"proxy_send_timeout":60,"proxy_read_timeout":60,"websocket_enabled":true,"proxy_request_buffering":false,"proxy_buffering_enabled":true,"proxy_buffers":"16 16k","proxy_buffer_size":"8k","proxy_busy_buffers_size":"64k","gzip_enabled":true,"gzip_min_length":1024,"gzip_comp_level":5,"cache_enabled":false,"cache_levels":"1:2","cache_inactive":"30m","cache_max_size":"1g","cache_key_template":"$scheme$host$request_uri","cache_lock_enabled":true,"cache_lock_timeout":"5s","cache_use_stale":"error timeout updating http_500 http_502 http_503 http_504","main_config_template":"worker_processes {{OpenRestyWorkerProcesses}};"},"waf":{"rule_groups":[],"bindings":[]}}`, listen, listen, workerProcesses)
}
func (f *fakeExecutor) Test(ctx context.Context) error {
return f.testErr
}
@@ -66,6 +72,10 @@ func (f *fakeClient) ReportApplyLog(ctx context.Context, payload protocol.ApplyL
return nil
}
func (f *fakeClient) SyncWAFIPGroups(ctx context.Context, payload protocol.WAFIPGroupSyncRequest) (*protocol.WAFIPGroupSyncResponse, error) {
return &protocol.WAFIPGroupSyncResponse{}, nil
}
func (m *fakeManager) Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) nginx.ApplyOutcome {
m.applyMainContents = append(m.applyMainContents, mainConfig)
m.applyRouteContents = append(m.applyRouteContents, routeConfig)
@@ -90,16 +100,22 @@ func (m *fakeManager) CurrentChecksum() (string, error) {
return m.currentChecksum, m.currentChecksumErr
}
func (m *fakeManager) WAFIPGroupChecksums() (map[string]string, error) {
return map[string]string{}, nil
}
func (m *fakeManager) SyncWAFIPGroups(groups []protocol.WAFIPGroup) error {
return nil
}
func TestSyncOnceSuccess(t *testing.T) {
client := &fakeClient{
config: protocol.ActiveConfigResponse{
Version: "20260309-001",
Checksum: "checksum-1",
MainConfig: "worker_processes auto;",
RouteConfig: "server { listen 80; }",
RenderedConfig: "server { listen 80; }",
SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
CreatedAt: time.Now().Format(time.RFC3339),
Version: "20260309-001",
Checksum: "checksum-1",
SourceConfigJSON: testSourceConfigJSON("auto", 80),
SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
CreatedAt: time.Now().Format(time.RFC3339),
},
}
@@ -132,7 +148,7 @@ func TestSyncOnceSuccess(t *testing.T) {
if err != nil {
t.Fatalf("failed to read route config: %v", err)
}
if string(data) != "server { listen 80; }" {
if !strings.Contains(string(data), "listen 80;") || !strings.Contains(string(data), "server_name example.com;") {
t.Fatal("expected rendered config to be written to route file")
}
mainData, err := os.ReadFile(filepath.Join(filepath.Dir(routePath), "nginx.conf"))
@@ -158,7 +174,7 @@ func TestSyncOnceSuccess(t *testing.T) {
if client.reports[0].MainConfigChecksum == "" || client.reports[0].RouteConfigChecksum == "" {
t.Fatal("expected main and route config checksums to be reported")
}
if client.reports[0].SupportFileCount != 1 {
if client.reports[0].SupportFileCount != 4 {
t.Fatalf("expected support file count to be reported, got %d", client.reports[0].SupportFileCount)
}
}
@@ -166,13 +182,11 @@ func TestSyncOnceSuccess(t *testing.T) {
func TestSyncOnceRollbackOnNginxFailure(t *testing.T) {
client := &fakeClient{
config: protocol.ActiveConfigResponse{
Version: "20260309-002",
Checksum: "checksum-2",
MainConfig: "worker_processes 2;",
RouteConfig: "server { listen 81; }",
RenderedConfig: "server { listen 81; }",
SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
CreatedAt: time.Now().Format(time.RFC3339),
Version: "20260309-002",
Checksum: "checksum-2",
SourceConfigJSON: testSourceConfigJSON("2", 81),
SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
CreatedAt: time.Now().Format(time.RFC3339),
},
}
@@ -225,7 +239,7 @@ func TestSyncOnceRollbackOnNginxFailure(t *testing.T) {
if client.reports[0].MainConfigChecksum == "" || client.reports[0].RouteConfigChecksum == "" {
t.Fatal("expected failed report to include main and route config checksums")
}
if client.reports[0].SupportFileCount != 1 {
if client.reports[0].SupportFileCount != 4 {
t.Fatalf("expected failed report to include support file count, got %d", client.reports[0].SupportFileCount)
}
}
@@ -233,13 +247,11 @@ func TestSyncOnceRollbackOnNginxFailure(t *testing.T) {
func TestSyncOnceReportsWarningWhenRollbackKeepsOpenrestyHealthy(t *testing.T) {
client := &fakeClient{
config: protocol.ActiveConfigResponse{
Version: "20260309-002",
Checksum: "checksum-2",
MainConfig: "worker_processes 2;",
RouteConfig: "server { listen 81; }",
RenderedConfig: "server { listen 81; }",
SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
CreatedAt: time.Now().Format(time.RFC3339),
Version: "20260309-002",
Checksum: "checksum-2",
SourceConfigJSON: testSourceConfigJSON("2", 81),
SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
CreatedAt: time.Now().Format(time.RFC3339),
},
}
@@ -294,13 +306,11 @@ func TestSyncOnceReportsWarningWhenRollbackKeepsOpenrestyHealthy(t *testing.T) {
func TestSyncOnStartupRecreatesRuntimeWhenChecksumMatches(t *testing.T) {
client := &fakeClient{
config: protocol.ActiveConfigResponse{
Version: "20260309-003",
Checksum: "checksum-3",
MainConfig: "worker_processes auto;",
RouteConfig: "server { listen 82; }",
RenderedConfig: "server { listen 82; }",
SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
CreatedAt: time.Now().Format(time.RFC3339),
Version: "20260309-003",
Checksum: "checksum-3",
SourceConfigJSON: testSourceConfigJSON("auto", 82),
SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
CreatedAt: time.Now().Format(time.RFC3339),
},
}
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
@@ -320,14 +330,11 @@ func TestSyncOnStartupRecreatesRuntimeWhenChecksumMatches(t *testing.T) {
}); err != nil {
t.Fatalf("SyncOnStartup failed: %v", err)
}
if len(manager.ensureCalls) != 1 || !manager.ensureCalls[0] {
t.Fatal("expected startup sync to recreate runtime")
if len(manager.applyMainContents) != 1 {
t.Fatal("expected startup sync to re-render and apply local config")
}
if len(client.reports) != 1 || client.reports[0].Result != ApplyResultSuccess {
t.Fatal("expected startup sync to report noop success when state is refreshed")
}
if client.reports[0].Message != "local config already matches active version; apply skipped" {
t.Fatalf("unexpected noop apply message: %q", client.reports[0].Message)
t.Fatal("expected startup sync to report apply success when state is refreshed")
}
snapshot, err := stateStore.Load()
if err != nil {
@@ -416,12 +423,10 @@ func TestSyncOnceDoesNotRepeatNoopReportWhenStateAlreadyMatches(t *testing.T) {
func TestSyncOnStartupRecordsRuntimeFailure(t *testing.T) {
client := &fakeClient{
config: protocol.ActiveConfigResponse{
Version: "20260309-004",
Checksum: "checksum-4",
MainConfig: "worker_processes 4;",
RouteConfig: "server { listen 83; }",
RenderedConfig: "server { listen 83; }",
CreatedAt: time.Now().Format(time.RFC3339),
Version: "20260309-004",
Checksum: "checksum-4",
SourceConfigJSON: testSourceConfigJSON("4", 83),
CreatedAt: time.Now().Format(time.RFC3339),
},
}
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
@@ -435,7 +440,7 @@ func TestSyncOnStartupRecordsRuntimeFailure(t *testing.T) {
manager := &fakeManager{
currentChecksum: "checksum-4",
ensureErr: context.DeadlineExceeded,
applyOutcome: nginx.ApplyOutcome{Status: nginx.ApplyStatusFatal, Message: context.DeadlineExceeded.Error()},
}
service := New(client, manager, stateStore)
if err = service.SyncOnStartup(context.Background(), &protocol.ActiveConfigMeta{
@@ -459,12 +464,10 @@ func TestSyncOnStartupRecordsRuntimeFailure(t *testing.T) {
func TestSyncOnceSkipsPreviouslyBlockedVersion(t *testing.T) {
client := &fakeClient{
config: protocol.ActiveConfigResponse{
Version: "20260309-006",
Checksum: "checksum-6",
MainConfig: "worker_processes 6;",
RouteConfig: "server { listen 86; }",
RenderedConfig: "server { listen 86; }",
CreatedAt: time.Now().Format(time.RFC3339),
Version: "20260309-006",
Checksum: "checksum-6",
SourceConfigJSON: testSourceConfigJSON("6", 86),
CreatedAt: time.Now().Format(time.RFC3339),
},
}
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
@@ -506,12 +509,10 @@ func TestSyncOnceSkipsPreviouslyBlockedVersion(t *testing.T) {
func TestSyncOnStartupKeepsBlockedVersionSuppressedUntilNewTargetArrives(t *testing.T) {
client := &fakeClient{
config: protocol.ActiveConfigResponse{
Version: "20260309-007",
Checksum: "checksum-7",
MainConfig: "worker_processes 7;",
RouteConfig: "server { listen 87; }",
RenderedConfig: "server { listen 87; }",
CreatedAt: time.Now().Format(time.RFC3339),
Version: "20260309-007",
Checksum: "checksum-7",
SourceConfigJSON: testSourceConfigJSON("7", 87),
CreatedAt: time.Now().Format(time.RFC3339),
},
}
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
@@ -565,12 +566,10 @@ func TestSyncOnStartupKeepsBlockedVersionSuppressedUntilNewTargetArrives(t *test
func TestSyncOnStartupStartsFallbackWhenBlockedVersionHasNoLocalConfig(t *testing.T) {
client := &fakeClient{
config: protocol.ActiveConfigResponse{
Version: "20260309-007",
Checksum: "checksum-7",
MainConfig: "worker_processes 7;",
RouteConfig: "server { listen 87; }",
RenderedConfig: "server { listen 87; }",
CreatedAt: time.Now().Format(time.RFC3339),
Version: "20260309-007",
Checksum: "checksum-7",
SourceConfigJSON: testSourceConfigJSON("7", 87),
CreatedAt: time.Now().Format(time.RFC3339),
},
}
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
@@ -625,12 +624,10 @@ func TestSyncOnStartupStartsFallbackWhenBlockedVersionHasNoLocalConfig(t *testin
func TestSyncOnStartupStartsFallbackWhenResidualConfigCannotRecover(t *testing.T) {
client := &fakeClient{
config: protocol.ActiveConfigResponse{
Version: "20260309-007",
Checksum: "checksum-7",
MainConfig: "worker_processes 7;",
RouteConfig: "server { listen 87; }",
RenderedConfig: "server { listen 87; }",
CreatedAt: time.Now().Format(time.RFC3339),
Version: "20260309-007",
Checksum: "checksum-7",
SourceConfigJSON: testSourceConfigJSON("7", 87),
CreatedAt: time.Now().Format(time.RFC3339),
},
}
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
@@ -679,12 +676,10 @@ func TestSyncOnStartupStartsFallbackWhenResidualConfigCannotRecover(t *testing.T
func TestSyncOnceClearsBlockedTargetWhenNewVersionArrives(t *testing.T) {
client := &fakeClient{
config: protocol.ActiveConfigResponse{
Version: "20260309-008",
Checksum: "checksum-8",
MainConfig: "worker_processes 8;",
RouteConfig: "server { listen 88; }",
RenderedConfig: "server { listen 88; }",
CreatedAt: time.Now().Format(time.RFC3339),
Version: "20260309-008",
Checksum: "checksum-8",
SourceConfigJSON: testSourceConfigJSON("8", 88),
CreatedAt: time.Now().Format(time.RFC3339),
},
}
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
@@ -732,12 +727,10 @@ func TestSyncOnceClearsBlockedTargetWhenNewVersionArrives(t *testing.T) {
func TestSyncOnceSkipsFetchWhenHeartbeatChecksumMatches(t *testing.T) {
client := &fakeClient{
config: protocol.ActiveConfigResponse{
Version: "20260309-005",
Checksum: "checksum-5",
MainConfig: "worker_processes auto;",
RouteConfig: "server { listen 84; }",
RenderedConfig: "server { listen 84; }",
CreatedAt: time.Now().Format(time.RFC3339),
Version: "20260309-005",
Checksum: "checksum-5",
SourceConfigJSON: testSourceConfigJSON("auto", 84),
CreatedAt: time.Now().Format(time.RFC3339),
},
}
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
+14 -164
View File
@@ -9,9 +9,9 @@ import (
"io"
"log/slog"
"net/http"
"openflare/utils"
"os"
"runtime"
"strconv"
"strings"
"time"
@@ -56,7 +56,7 @@ func (s *Service) CheckAndUpdate(ctx context.Context, repo string, options agent
}
remoteVersion := normalizeVersion(release.TagName)
localVersion := normalizeVersion(config.AgentVersion)
localVersion := normalizeVersion(config.Version)
checkKey := buildReleaseCheckKey(options, remoteVersion)
if remoteVersion == localVersion {
@@ -121,26 +121,7 @@ func (s *Service) getRelease(ctx context.Context, repo string, options agent.Upd
func (s *Service) getLatestStableRelease(ctx context.Context, repo string) (*githubRelease, error) {
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("github api returned %s", resp.Status)
}
return decodeRelease(resp.Body)
return s.fetchReleaseFromURL(ctx, url)
}
func (s *Service) getLatestPreviewRelease(ctx context.Context, repo string) (*githubRelease, error) {
@@ -177,6 +158,10 @@ func (s *Service) getLatestPreviewRelease(ctx context.Context, repo string) (*gi
func (s *Service) getReleaseByTag(ctx context.Context, repo string, tag string) (*githubRelease, error) {
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/tags/%s", repo, strings.TrimSpace(tag))
return s.fetchReleaseFromURL(ctx, url)
}
func (s *Service) fetchReleaseFromURL(ctx context.Context, url string) (*githubRelease, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
@@ -187,7 +172,12 @@ func (s *Service) getReleaseByTag(ctx context.Context, repo string, tag string)
if err != nil {
return nil, err
}
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
slog.Error("failed to close response body", "error", err)
}
}(resp.Body)
if resp.StatusCode == http.StatusNotFound {
return nil, nil
@@ -370,146 +360,6 @@ func buildReleaseCheckKey(options agent.UpdateOptions, remoteVersion string) str
return channel + ":" + remoteVersion
}
type versionInfo struct {
valid bool
isDev bool
numbers []int
prerelease []string
}
func parseVersionInfo(version string) versionInfo {
normalized := normalizeVersion(version)
if normalized == "" || strings.EqualFold(normalized, "dev") {
return versionInfo{isDev: strings.EqualFold(normalized, "dev")}
}
base := normalized
prerelease := ""
if index := strings.IndexRune(normalized, '-'); index >= 0 {
base = normalized[:index]
prerelease = normalized[index+1:]
}
segments := strings.Split(base, ".")
parts := make([]int, 0, len(segments))
for _, segment := range segments {
segment = strings.TrimSpace(segment)
if segment == "" {
parts = append(parts, 0)
continue
}
numeric := strings.Builder{}
for _, r := range segment {
if r < '0' || r > '9' {
break
}
numeric.WriteRune(r)
}
if numeric.Len() == 0 {
return versionInfo{}
}
value, err := strconv.Atoi(numeric.String())
if err != nil {
return versionInfo{}
}
parts = append(parts, value)
}
info := versionInfo{valid: len(parts) > 0, numbers: parts}
if prerelease != "" {
info.prerelease = splitPrereleaseIdentifiers(prerelease)
}
return info
}
func splitPrereleaseIdentifiers(value string) []string {
parts := strings.FieldsFunc(strings.TrimSpace(value), func(r rune) bool {
return r == '.' || r == '-'
})
filtered := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
filtered = append(filtered, part)
}
}
return filtered
}
func compareVersions(local string, remote string) int {
left := parseVersionInfo(local)
right := parseVersionInfo(remote)
if left.isDev {
if right.valid {
return -1
}
return 0
}
if !left.valid || !right.valid {
return 0
}
maxLen := len(left.numbers)
if len(right.numbers) > maxLen {
maxLen = len(right.numbers)
}
for index := 0; index < maxLen; index++ {
leftValue := 0
rightValue := 0
if index < len(left.numbers) {
leftValue = left.numbers[index]
}
if index < len(right.numbers) {
rightValue = right.numbers[index]
}
if leftValue < rightValue {
return -1
}
if leftValue > rightValue {
return 1
}
}
if len(left.prerelease) == 0 && len(right.prerelease) == 0 {
return 0
}
if len(left.prerelease) == 0 {
return 1
}
if len(right.prerelease) == 0 {
return -1
}
maxLen = len(left.prerelease)
if len(right.prerelease) > maxLen {
maxLen = len(right.prerelease)
}
for index := 0; index < maxLen; index++ {
if index >= len(left.prerelease) {
return -1
}
if index >= len(right.prerelease) {
return 1
}
leftPart := left.prerelease[index]
rightPart := right.prerelease[index]
leftNumber, leftErr := strconv.Atoi(leftPart)
rightNumber, rightErr := strconv.Atoi(rightPart)
switch {
case leftErr == nil && rightErr == nil:
if leftNumber < rightNumber {
return -1
}
if leftNumber > rightNumber {
return 1
}
case leftErr == nil && rightErr != nil:
return -1
case leftErr != nil && rightErr == nil:
return 1
default:
if leftPart < rightPart {
return -1
}
if leftPart > rightPart {
return 1
}
}
}
return 0
return utils.CompareVersions(local, remote)
}
@@ -75,10 +75,10 @@ func TestGetReleaseByTag(t *testing.T) {
}
func TestCheckAndUpdateRequiresChecksumAsset(t *testing.T) {
originalVersion := config.AgentVersion
config.AgentVersion = "v1.0.0"
originalVersion := config.Version
config.Version = "v1.0.0"
t.Cleanup(func() {
config.AgentVersion = originalVersion
config.Version = originalVersion
})
assetName := assetNameForGOOSGOARCH(runtime.GOOS, runtime.GOARCH)
+2 -1
View File
@@ -140,7 +140,8 @@ func (conn *Connection) Receive() (protocol.WSMessage, error) {
}
err := websocket.JSON.Receive(conn.conn, &message)
if err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
slog.Debug("agent ws receive timeout waiting for server message", "timeout", conn.readTimeout)
}
return message, err
+27
View File
@@ -0,0 +1,27 @@
ARG VERSION=dev
FROM golang:1.25-alpine AS builder
ARG VERSION
WORKDIR /build
COPY openflare_relay/go.mod openflare_relay/go.sum ./
COPY openflare_server /openflare_server
COPY openflare_relay /openflare_relay
WORKDIR /openflare_relay
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-s -w -X 'openflare-relay/internal/config.Version=$VERSION'" -o openflare-relay ./cmd/relay
# Final runtime image
FROM fatedier/frps:v0.69.0
# Copy openflare-relay binary
COPY --from=builder /openflare_relay/openflare-relay /usr/local/bin/openflare-relay
VOLUME ["/var/lib/openflare-relay"]
ENV OPENFLARE_FRPS_PATH=/usr/bin/frps
ENV OPENFLARE_DATA_DIR=/var/lib/openflare-relay
ENTRYPOINT ["/usr/local/bin/openflare-relay"]
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"context"
"flag"
"log/slog"
"os"
"os/signal"
"syscall"
"openflare-relay/internal/config"
"openflare-relay/internal/frps"
"openflare-relay/internal/heartbeat"
"openflare-relay/internal/httpclient"
"openflare-relay/internal/relay"
"openflare-relay/internal/state"
"openflare-relay/internal/wsclient"
)
func main() {
// Setup simple structured logging
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
})))
configPath := flag.String("config", "./relay.json", "relay config path")
flag.Parse()
cfg, err := config.Load(*configPath)
if err != nil {
slog.Error("load relay config failed", "error", err)
os.Exit(1)
}
slog.Info("relay config loaded",
"server", cfg.ServerURL,
"node", cfg.NodeName,
"ip", cfg.NodeIP,
"frps_path", cfg.FrpsPath,
"data_dir", cfg.DataDir,
"heartbeat_interval", cfg.HeartbeatInterval,
)
stateStore := state.NewStore(cfg.StatePath)
_ = stateStore // In the future we may use stateStore for auth caching
frpsManager := frps.NewManager(cfg.FrpsPath, cfg.DataDir)
slog.Info("detected frps version", "version", frpsManager.GetVersion())
httpClient := httpclient.New(cfg.ServerURL, cfg.InitialAuthToken(), cfg.RequestTimeout.Duration())
wsClient := wsclient.New(cfg.ServerURL, cfg.InitialAuthToken(), cfg.RequestTimeout.Duration())
runner := &relay.Runner{
Config: cfg,
StateStore: stateStore,
FrpsManager: frpsManager,
HttpClient: httpClient,
WebSocketService: wsClient,
HeartbeatService: heartbeat.New(httpClient, frpsManager, cfg, stateStore),
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
slog.Info("relay process started")
if err := runner.Run(ctx); err != nil && err != context.Canceled {
slog.Error("relay process exited with error", "error", err)
os.Exit(1)
}
slog.Info("relay process stopped")
}
+72
View File
@@ -0,0 +1,72 @@
module openflare-relay
go 1.25.0
replace openflare => ../openflare_server
require (
golang.org/x/net v0.55.0
openflare v0.0.0-00010101000000-000000000000
)
require (
github.com/bwmarrin/snowflake v0.3.0 // indirect
github.com/bytedance/sonic v1.11.2 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
github.com/chenzhuoyu/iasm v0.9.1 // indirect
github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/expr-lang/expr v1.17.8 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/gin-gonic/gin v1.9.1 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/glebarez/sqlite v1.11.0 // indirect
github.com/go-acme/lego/v4 v4.35.2 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.23.0 // indirect
github.com/go-redis/redis/v8 v8.11.5 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/longbridgeapp/sqlparser v0.3.1 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/oschwald/maxminddb-golang v1.13.1 // indirect
github.com/pelletier/go-toml/v2 v2.1.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.7.0 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.44.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/postgres v1.6.0 // indirect
gorm.io/gorm v1.25.10 // indirect
gorm.io/sharding v0.6.2 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)
+195
View File
@@ -0,0 +1,195 @@
github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0=
github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
github.com/bytedance/sonic v1.11.2 h1:ywfwo0a/3j9HR8wsYGWsIWl2mvRsI950HyoxiBERw5A=
github.com/bytedance/sonic v1.11.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
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/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
github.com/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0=
github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
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/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM=
github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-acme/lego/v4 v4.35.2 h1:uVQg+KC/yj9R2g7Q9W5wDqhvQvxV5SMu5eqFVoN5xZU=
github.com/go-acme/lego/v4 v4.35.2/go.mod h1:pX2jN5n8OphMGY1IaMjYm5DAEzguBaKRt8AvJAgJXpc=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU=
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/longbridgeapp/assert v1.1.0 h1:L+/HISOhuGbNAAmJNXgk3+Tm5QmSB70kwdktJXgjL+I=
github.com/longbridgeapp/assert v1.1.0/go.mod h1:UOI7O3rzlzlz715lQm0atWs6JbrYGuIJUEeOekutL6o=
github.com/longbridgeapp/sqlparser v0.3.1 h1:iWOZWGIFgQrJRgobLXUNJdvqGRpbVXkyKUKUA5CNJBE=
github.com/longbridgeapp/sqlparser v0.3.1/go.mod h1:GIHaUq8zvYyHLCLMJJykx1CdM6LHtkUih/QaJXySSx4=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
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/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI=
github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.1 h1:WUEH5VF9obL/lTtzjmML/5e6VfFR/788coz2uaVCAZw=
gorm.io/driver/mysql v1.5.1/go.mod h1:Jo3Xu7mMhCyj8dlrb3WoCaRd1FhsVh+yMXb1jUInf5o=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/hints v1.1.2 h1:b5j0kwk5p4+3BtDtYqqfY+ATSxjj+6ptPgVveuynn9o=
gorm.io/hints v1.1.2/go.mod h1:/ARdpUHAtyEMCh5NNi3tI7FsGh+Cj/MIUlvNxCNCFWg=
gorm.io/plugin/dbresolver v1.5.1 h1:s9Dj9f7r+1rE3nx/Ywzc85nXptUEaeOO0pt27xdopM8=
gorm.io/plugin/dbresolver v1.5.1/go.mod h1:l4Cn87EHLEYuqUncpEeTC2tTJQkjngPSD+lo8hIvcT0=
gorm.io/sharding v0.6.2 h1:V9inmbdhN+RfWPEKTvbKKKv7qxLz1CneBDQvuL5P7jg=
gorm.io/sharding v0.6.2/go.mod h1:dXaAZv0qyUmLkLAciQ+NH2O1D1A4/ttrrZ/XK4xW9HU=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+234
View File
@@ -0,0 +1,234 @@
package config
import (
"context"
"encoding/json"
"errors"
"net"
"openflare/utils/geoip"
"openflare/utils/geoip/iputil"
"os"
"path/filepath"
"strings"
"time"
)
type MillisecondDuration time.Duration
func (d *MillisecondDuration) UnmarshalJSON(b []byte) error {
var v interface{}
if err := json.Unmarshal(b, &v); err != nil {
return err
}
switch value := v.(type) {
case float64:
*d = MillisecondDuration(time.Duration(value) * time.Millisecond)
return nil
case string:
duration, err := time.ParseDuration(value)
if err != nil {
return err
}
*d = MillisecondDuration(duration)
return nil
default:
return errors.New("invalid duration format")
}
}
func (d MillisecondDuration) Duration() time.Duration {
return time.Duration(d)
}
func (d MillisecondDuration) String() string {
return time.Duration(d).String()
}
type Config struct {
ServerURL string `json:"server_url"`
AgentToken string `json:"agent_token"`
DiscoveryToken string `json:"discovery_token"`
NodeName string `json:"node_name"`
NodeIP string `json:"node_ip"`
FrpsPath string `json:"frps_path"`
DataDir string `json:"data_dir"`
StatePath string `json:"state_path"`
HeartbeatInterval MillisecondDuration `json:"heartbeat_interval"`
RequestTimeout MillisecondDuration `json:"request_timeout"`
configPath string
}
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return nil, err
}
cfg := &Config{}
if err == nil {
if err = json.Unmarshal(data, cfg); err != nil {
return nil, err
}
}
if err != nil && !hasEnvConfig() {
return nil, err
}
cfg.configPath = path
applyEnvOverrides(cfg)
applyDefaults(cfg, filepath.Dir(path))
if err = validate(cfg); err != nil {
return nil, err
}
return cfg, nil
}
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_FRPS_PATH",
} {
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_FRPS_PATH", &cfg.FrpsPath)
}
func applyDefaults(cfg *Config, baseDir string) {
baseDir = filepath.Clean(baseDir)
if cfg.FrpsPath == "" {
cfg.FrpsPath = "frps" // rely on PATH
}
if cfg.DataDir == "" {
cfg.DataDir = filepath.Join(baseDir, "data")
}
if cfg.NodeName == "" {
host, _ := os.Hostname()
cfg.NodeName = strings.TrimSpace(host)
}
if cfg.NodeIP == "" {
cfg.NodeIP = detectNodeIP()
}
if cfg.StatePath == "" {
cfg.StatePath = filepath.Join(cfg.DataDir, "relay-state.json")
}
if cfg.HeartbeatInterval <= 0 {
cfg.HeartbeatInterval = MillisecondDuration(10 * time.Second)
}
if cfg.RequestTimeout <= 0 {
cfg.RequestTimeout = MillisecondDuration(10 * time.Second)
}
}
func validate(cfg *Config) error {
if cfg.ServerURL == "" {
return errors.New("server_url 不能为空")
}
if strings.TrimSpace(cfg.AgentToken) == "" && strings.TrimSpace(cfg.DiscoveryToken) == "" {
return errors.New("agent_token 和 discovery_token 不能同时为空")
}
if cfg.NodeName == "" {
return errors.New("node_name 不能为空")
}
return nil
}
func (cfg *Config) InitialAuthToken() string {
if cfg == nil {
return ""
}
if token := strings.TrimSpace(cfg.AgentToken); token != "" {
return token
}
return strings.TrimSpace(cfg.DiscoveryToken)
}
func (cfg *Config) Save() error {
if cfg == nil {
return errors.New("config 不能为空")
}
if cfg.configPath == "" {
return errors.New("config path 未初始化")
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(cfg.configPath, data, 0o644)
}
func detectNodeIP() string {
if ip := detectOutboundNodeIP(); ip != "" {
return ip
}
return detectLocalNodeIP()
}
func detectOutboundNodeIP() string {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ip, err := geoip.GetOutboundIP(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
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok || ipNet.IP == nil || ipNet.IP.IsLoopback() {
continue
}
ipv4 := ipNet.IP.To4()
if ipv4 == nil {
continue
}
priority := iputil.Score(ipv4)
if priority > bestPriority {
bestIP = ipv4.String()
bestPriority = priority
}
if bestPriority == 2 {
return bestIP
}
}
}
return bestIP
}
@@ -0,0 +1,3 @@
package config
var Version = "dev"
+211
View File
@@ -0,0 +1,211 @@
package frps
import (
"bytes"
"context"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"openflare/service"
)
type Manager struct {
frpsPath string
dataDir string
configPath string
mu sync.RWMutex
activeConfig *service.RelayConfig
cmd *exec.Cmd
status string
lastError string
generation uint64
stopping bool
}
type RuntimeStatus struct {
Status string
LastError string
Connections int
ProxyCount int
ProcessAlive bool
}
func NewManager(frpsPath string, dataDir string) *Manager {
return &Manager{
frpsPath: frpsPath,
dataDir: dataDir,
configPath: filepath.Join(dataDir, "frps.toml"),
status: "unhealthy",
}
}
func (m *Manager) GetVersion() string {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, m.frpsPath, "-v")
out, err := cmd.CombinedOutput()
if err != nil {
slog.Error("failed to get frps version", "error", err)
return ""
}
return strings.TrimSpace(string(out))
}
func (m *Manager) GetStatus() string {
m.mu.RLock()
defer m.mu.RUnlock()
return m.status
}
func (m *Manager) GetRuntimeStatus() RuntimeStatus {
m.mu.RLock()
defer m.mu.RUnlock()
return RuntimeStatus{
Status: m.status,
LastError: m.lastError,
Connections: 0,
ProxyCount: 0,
ProcessAlive: m.cmd != nil && m.cmd.Process != nil,
}
}
func (m *Manager) UpdateConfig(cfg *service.RelayConfig) {
if cfg == nil {
return
}
m.mu.Lock()
defer m.mu.Unlock()
// Check if config changed
if m.activeConfig != nil &&
m.activeConfig.BindPort == cfg.BindPort &&
m.activeConfig.VhostHTTPPort == cfg.VhostHTTPPort &&
m.activeConfig.AuthToken == cfg.AuthToken {
if m.cmd == nil && !m.stopping {
slog.Warn("frps config unchanged but process is not running, restarting")
if err := m.restartProcess(); err != nil {
m.status = "unhealthy"
m.lastError = err.Error()
slog.Error("failed to restart frps with unchanged config", "error", err)
}
}
return
}
m.activeConfig = cfg
m.stopping = false
slog.Info("relay config updated, reloading frps")
if err := m.renderConfig(cfg); err != nil {
slog.Error("failed to render frps config", "error", err)
m.status = "unhealthy"
m.lastError = err.Error()
return
}
if err := m.restartProcess(); err != nil {
slog.Error("failed to restart frps", "error", err)
m.status = "unhealthy"
m.lastError = err.Error()
} else {
m.status = "healthy"
m.lastError = ""
}
}
func (m *Manager) renderConfig(cfg *service.RelayConfig) error {
if err := os.MkdirAll(m.dataDir, 0755); err != nil {
return err
}
var buf bytes.Buffer
buf.WriteString(fmt.Sprintf("bindPort = %d\n", cfg.BindPort))
if cfg.VhostHTTPPort > 0 {
buf.WriteString(fmt.Sprintf("vhostHTTPPort = %d\n", cfg.VhostHTTPPort))
}
if cfg.AuthToken != "" {
buf.WriteString("[auth]\n")
buf.WriteString("method = \"token\"\n")
buf.WriteString(fmt.Sprintf("token = \"%s\"\n", cfg.AuthToken))
}
return os.WriteFile(m.configPath, buf.Bytes(), 0644)
}
func (m *Manager) restartProcess() error {
m.generation++
generation := m.generation
if m.cmd != nil && m.cmd.Process != nil {
slog.Debug("stopping existing frps process")
_ = m.cmd.Process.Kill()
m.cmd = nil
}
return m.startProcessLocked(generation)
}
func (m *Manager) startProcessLocked(generation uint64) error {
cmd := exec.Command(m.frpsPath, "-c", m.configPath)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
return err
}
m.cmd = cmd
m.status = "healthy"
m.lastError = ""
go func(c *exec.Cmd) {
err := c.Wait()
slog.Warn("frps process exited", "error", err)
m.mu.Lock()
if m.cmd == c {
m.cmd = nil
m.status = "unhealthy"
if err != nil {
m.lastError = err.Error()
} else {
m.lastError = "frps process exited"
}
}
shouldRestart := !m.stopping && m.generation == generation
m.mu.Unlock()
if !shouldRestart {
return
}
time.Sleep(2 * time.Second)
m.mu.Lock()
defer m.mu.Unlock()
if m.stopping || m.generation != generation {
return
}
slog.Warn("restarting frps after unexpected exit")
if err := m.startProcessLocked(generation); err != nil {
m.status = "unhealthy"
m.lastError = err.Error()
slog.Error("failed to auto restart frps", "error", err)
}
}(cmd)
return nil
}
func (m *Manager) Stop() {
m.mu.Lock()
defer m.mu.Unlock()
m.stopping = true
m.generation++
if m.cmd != nil && m.cmd.Process != nil {
_ = m.cmd.Process.Kill()
m.cmd = nil
}
m.status = "unhealthy"
}
@@ -0,0 +1,75 @@
package heartbeat
import (
"context"
"log/slog"
"time"
"openflare-relay/internal/config"
"openflare-relay/internal/frps"
"openflare-relay/internal/httpclient"
"openflare-relay/internal/observability"
"openflare-relay/internal/state"
"openflare/service"
)
type Service struct {
client *httpclient.Client
frpsManager *frps.Manager
config *config.Config
stateStore *state.Store
}
func New(client *httpclient.Client, manager *frps.Manager, cfg *config.Config, stateStore *state.Store) *Service {
return &Service{
client: client,
frpsManager: manager,
config: cfg,
stateStore: stateStore,
}
}
func (s *Service) Run(ctx context.Context) {
ticker := time.NewTicker(s.config.HeartbeatInterval.Duration())
defer ticker.Stop()
// initial heartbeat
s.doHeartbeat(ctx)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.doHeartbeat(ctx)
}
}
}
func (s *Service) doHeartbeat(ctx context.Context) {
slog.Debug("sending heartbeat")
runtimeStatus := s.frpsManager.GetRuntimeStatus()
payload := service.RelayHeartbeatPayload{
Version: config.Version,
ExtVersion: s.frpsManager.GetVersion(),
RelayStatus: runtimeStatus.Status,
FrpsConnCount: runtimeStatus.Connections,
FrpsProxyCount: runtimeStatus.ProxyCount,
Name: s.config.NodeName,
IP: s.config.NodeIP,
Profile: observability.BuildProfile(s.config, s.stateStore),
Snapshot: observability.BuildSnapshot(s.config, s.stateStore),
HealthEvents: observability.BuildHealthEvents(runtimeStatus),
}
resp, err := s.client.Heartbeat(ctx, payload)
if err != nil {
slog.Error("heartbeat failed", "error", err)
return
}
slog.Debug("heartbeat succeeded")
// Update configs if changed
s.frpsManager.UpdateConfig(resp.RelayConfig)
}
@@ -0,0 +1,111 @@
package httpclient
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strings"
"time"
"openflare/service"
)
type APIResponse[T any] struct {
Success bool `json:"success"`
Message string `json:"message"`
Data T `json:"data"`
}
type Client struct {
baseURL string
token string
httpClient *http.Client
}
func New(baseURL string, token string, timeout time.Duration) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
token: token,
httpClient: &http.Client{
Timeout: timeout,
},
}
}
func (c *Client) Heartbeat(ctx context.Context, payload service.RelayHeartbeatPayload) (*service.RelayHeartbeatResponse, error) {
resp := APIResponse[service.RelayHeartbeatResponse]{}
if err := c.postJSON(ctx, "/api/relay/heartbeat", payload, &resp); err != nil {
return nil, err
}
if !resp.Success {
return nil, errors.New(resp.Message)
}
return &resp.Data, nil
}
func (c *Client) SetToken(token string) {
c.token = strings.TrimSpace(token)
slog.Debug("http client token updated")
}
func (c *Client) getJSON(ctx context.Context, path string, target any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
if err != nil {
return err
}
req.Header.Set("X-Agent-Token", c.token)
return c.do(req, target)
}
func (c *Client) postJSON(ctx context.Context, path string, body any, target any) error {
data, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Agent-Token", c.token)
return c.do(req, target)
}
func (c *Client) do(req *http.Request, target any) error {
res, err := c.httpClient.Do(req)
if err != nil {
slog.Error("http request failed", "method", req.Method, "path", req.URL.Path, "error", err)
return err
}
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
slog.Error("failed to close response body", "error", err)
}
}(res.Body)
if res.StatusCode != http.StatusOK {
slog.Warn("http request returned non-200", "method", req.Method, "path", req.URL.Path, "status", res.Status)
return errors.New(res.Status)
}
if target == nil {
var wrapper APIResponse[json.RawMessage]
if err = json.NewDecoder(res.Body).Decode(&wrapper); err != nil {
slog.Error("http response decode failed", "method", req.Method, "path", req.URL.Path, "error", err)
return err
}
if !wrapper.Success {
slog.Warn("http api response failed", "method", req.Method, "path", req.URL.Path, "message", wrapper.Message)
return errors.New(wrapper.Message)
}
return nil
}
if err = json.NewDecoder(res.Body).Decode(target); err != nil {
slog.Error("http response decode failed", "method", req.Method, "path", req.URL.Path, "error", err)
return err
}
return nil
}
@@ -0,0 +1,325 @@
package observability
import (
"bufio"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"syscall"
"time"
"openflare-relay/internal/config"
"openflare-relay/internal/frps"
"openflare-relay/internal/state"
"openflare/service"
)
func BuildProfile(cfg *config.Config, stateStore *state.Store) *service.AgentNodeSystemProfile {
profile := collectProfile(cfg)
if profile == nil || stateStore == nil {
return profile
}
fingerprint := fingerprintProfile(profile)
snapshot, err := stateStore.Load()
if err != nil {
return profile
}
if snapshot.LastProfileFingerprint == fingerprint {
return nil
}
snapshot.LastProfileFingerprint = fingerprint
if err = stateStore.Save(snapshot); err != nil {
return profile
}
return profile
}
func BuildSnapshot(cfg *config.Config, stateStore *state.Store) *service.AgentNodeMetricSnapshot {
now := time.Now().UTC()
metric := &service.AgentNodeMetricSnapshot{CapturedAtUnix: now.Unix()}
metric.MemoryTotalBytes, metric.MemoryUsedBytes = readMemInfo()
metric.StorageTotalBytes, metric.StorageUsedBytes = statFilesystem(cfg.DataDir)
metric.NetworkRxBytes, metric.NetworkTxBytes = readLinuxNetworkTotals()
metric.DiskReadBytes, metric.DiskWriteBytes = readLinuxDiskTotals()
if stateStore == nil {
return metric
}
totalCPU, idleCPU := readLinuxCPUStat()
snapshot, err := stateStore.Load()
if err != nil {
return metric
}
if snapshot.LastCPUStatTotal > 0 && totalCPU > snapshot.LastCPUStatTotal && idleCPU >= snapshot.LastCPUStatIdle {
deltaTotal := totalCPU - snapshot.LastCPUStatTotal
deltaIdle := idleCPU - snapshot.LastCPUStatIdle
if deltaTotal > 0 && deltaIdle <= deltaTotal {
metric.CPUUsagePercent = float64(deltaTotal-deltaIdle) / float64(deltaTotal) * 100
}
}
snapshot.LastCPUStatTotal = totalCPU
snapshot.LastCPUStatIdle = idleCPU
snapshot.LastMetricAtUnix = now.Unix()
_ = stateStore.Save(snapshot)
return metric
}
func BuildHealthEvents(status frps.RuntimeStatus) []service.AgentNodeHealthEvent {
if strings.TrimSpace(status.Status) == "healthy" {
return []service.AgentNodeHealthEvent{}
}
message := strings.TrimSpace(status.LastError)
if message == "" {
message = "frps runtime is not healthy"
}
return []service.AgentNodeHealthEvent{{
EventType: "frps_unhealthy",
Severity: "critical",
Message: message,
TriggeredAtUnix: time.Now().UTC().Unix(),
}}
}
func collectProfile(cfg *config.Config) *service.AgentNodeSystemProfile {
hostname, _ := os.Hostname()
osName, osVersion := readLinuxOSRelease()
totalMemory, _ := readMemInfo()
totalDisk, _ := statFilesystem(cfg.DataDir)
return &service.AgentNodeSystemProfile{
Hostname: strings.TrimSpace(hostname),
OSName: osName,
OSVersion: osVersion,
KernelVersion: readFirstLine("/proc/sys/kernel/osrelease"),
Architecture: runtime.GOARCH,
CPUModel: readLinuxCPUModel(),
CPUCores: runtime.NumCPU(),
TotalMemoryBytes: totalMemory,
TotalDiskBytes: totalDisk,
UptimeSeconds: readLinuxUptimeSeconds(),
ReportedAtUnix: time.Now().UTC().Unix(),
}
}
func fingerprintProfile(profile *service.AgentNodeSystemProfile) string {
raw, err := json.Marshal(profile)
if err != nil {
return ""
}
sum := sha256.Sum256(raw)
return hex.EncodeToString(sum[:])
}
func readLinuxOSRelease() (string, string) {
file, err := os.Open("/etc/os-release")
if err != nil {
return runtime.GOOS, ""
}
defer file.Close()
values := make(map[string]string)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
key, value, ok := strings.Cut(strings.TrimSpace(scanner.Text()), "=")
if !ok {
continue
}
values[key] = strings.Trim(value, `"`)
}
if pretty := strings.TrimSpace(values["PRETTY_NAME"]); pretty != "" {
return pretty, strings.TrimSpace(values["VERSION_ID"])
}
if name := strings.TrimSpace(values["NAME"]); name != "" {
return name, strings.TrimSpace(values["VERSION_ID"])
}
return runtime.GOOS, ""
}
func readLinuxCPUModel() string {
file, err := os.Open("/proc/cpuinfo")
if err != nil {
return ""
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(strings.ToLower(line), "model name") {
_, value, ok := strings.Cut(line, ":")
if ok {
return strings.TrimSpace(value)
}
}
}
return ""
}
func readMemInfo() (int64, int64) {
file, err := os.Open("/proc/meminfo")
if err != nil {
return 0, 0
}
defer file.Close()
var totalKB, availableKB int64
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "MemTotal:") {
totalKB = parseMemInfoValue(line)
}
if strings.HasPrefix(line, "MemAvailable:") {
availableKB = parseMemInfoValue(line)
}
}
total := totalKB * 1024
used := total - availableKB*1024
if used < 0 {
used = 0
}
return total, used
}
func parseMemInfoValue(line string) int64 {
fields := strings.Fields(line)
if len(fields) < 2 {
return 0
}
value, err := strconv.ParseInt(fields[1], 10, 64)
if err != nil {
return 0
}
return value
}
func readLinuxUptimeSeconds() int64 {
content, err := os.ReadFile("/proc/uptime")
if err != nil {
return 0
}
fields := strings.Fields(string(content))
if len(fields) == 0 {
return 0
}
value, err := strconv.ParseFloat(fields[0], 64)
if err != nil {
return 0
}
return int64(value)
}
func readLinuxCPUStat() (uint64, uint64) {
content, err := os.ReadFile("/proc/stat")
if err != nil {
return 0, 0
}
for _, line := range strings.Split(string(content), "\n") {
if !strings.HasPrefix(line, "cpu ") {
continue
}
fields := strings.Fields(line)
if len(fields) < 5 {
return 0, 0
}
var total uint64
for index := 1; index < len(fields); index++ {
value, err := strconv.ParseUint(fields[index], 10, 64)
if err != nil {
return 0, 0
}
total += value
}
idle, err := strconv.ParseUint(fields[4], 10, 64)
if err != nil {
return 0, 0
}
return total, idle
}
return 0, 0
}
func readLinuxNetworkTotals() (int64, int64) {
file, err := os.Open("/proc/net/dev")
if err != nil {
return 0, 0
}
defer file.Close()
var rx, tx int64
scanner := bufio.NewScanner(file)
for scanner.Scan() {
name, data, ok := strings.Cut(strings.TrimSpace(scanner.Text()), ":")
if !ok || strings.TrimSpace(name) == "lo" {
continue
}
fields := strings.Fields(data)
if len(fields) < 16 {
continue
}
if value, err := strconv.ParseInt(fields[0], 10, 64); err == nil {
rx += value
}
if value, err := strconv.ParseInt(fields[8], 10, 64); err == nil {
tx += value
}
}
return rx, tx
}
func readLinuxDiskTotals() (int64, int64) {
file, err := os.Open("/proc/diskstats")
if err != nil {
return 0, 0
}
defer file.Close()
var readBytes, writeBytes int64
scanner := bufio.NewScanner(file)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 14 || shouldSkipDiskDevice(fields[2]) {
continue
}
if value, err := strconv.ParseInt(fields[5], 10, 64); err == nil {
readBytes += value * 512
}
if value, err := strconv.ParseInt(fields[9], 10, 64); err == nil {
writeBytes += value * 512
}
}
return readBytes, writeBytes
}
func shouldSkipDiskDevice(device string) bool {
return device == "" || strings.HasPrefix(device, "loop") || strings.HasPrefix(device, "ram") || strings.HasPrefix(device, "dm-")
}
func statFilesystem(path string) (int64, int64) {
if strings.TrimSpace(path) == "" {
path = string(os.PathSeparator)
}
var stat syscall.Statfs_t
if err := syscall.Statfs(filepath.Clean(path), &stat); err != nil {
return 0, 0
}
total := int64(stat.Blocks) * int64(stat.Bsize)
used := total - int64(stat.Bavail)*int64(stat.Bsize)
if used < 0 {
used = 0
}
return total, used
}
func readFirstLine(path string) string {
content, err := os.ReadFile(path)
if err != nil {
return ""
}
return strings.TrimSpace(string(content))
}
+121
View File
@@ -0,0 +1,121 @@
package relay
import (
"context"
"encoding/json"
"log/slog"
"time"
"openflare-relay/internal/config"
"openflare-relay/internal/frps"
"openflare-relay/internal/heartbeat"
"openflare-relay/internal/httpclient"
"openflare-relay/internal/state"
"openflare-relay/internal/wsclient"
"openflare/service"
)
type Runner struct {
Config *config.Config
StateStore *state.Store
HeartbeatService *heartbeat.Service
FrpsManager *frps.Manager
WebSocketService *wsclient.Client
HttpClient *httpclient.Client
}
func (r *Runner) Run(ctx context.Context) error {
// Start heartbeat loop in background
go r.HeartbeatService.Run(ctx)
// WebSocket reconnection loop
for {
select {
case <-ctx.Done():
r.FrpsManager.Stop()
return ctx.Err()
default:
}
conn, err := r.WebSocketService.Connect(ctx)
if err != nil {
slog.Error("relay ws connect failed, will retry", "error", err)
r.sleepContext(ctx, 5*time.Second)
continue
}
r.handleConnection(ctx, conn)
_ = conn.Close()
slog.Info("relay ws connection closed, reconnecting...")
r.sleepContext(ctx, 2*time.Second)
}
}
func (r *Runner) handleConnection(ctx context.Context, conn *wsclient.Connection) {
// Send pings at 2× heartbeat interval to keep the server-side read deadline
// from expiring (server closes the WS if no data arrives within ~30 s).
pingInterval := r.Config.HeartbeatInterval.Duration() * 2
pingTicker := time.NewTicker(pingInterval)
defer pingTicker.Stop()
messages := make(chan service.WSMessage, 8)
readDone := make(chan error, 1)
go func() {
for {
msg, err := conn.Receive()
if err != nil {
readDone <- err
return
}
select {
case messages <- msg:
case <-ctx.Done():
readDone <- ctx.Err()
return
}
}
}()
for {
select {
case <-ctx.Done():
return
case err := <-readDone:
slog.Error("relay ws receive failed", "error", err)
return
case <-pingTicker.C:
if err := conn.SendPing(); err != nil {
slog.Error("relay ws send ping failed", "error", err)
return
}
case msg := <-messages:
switch msg.Type {
case "ping":
_ = conn.SendPong()
case "pong":
slog.Debug("relay ws pong received")
case "relay_config":
payloadBytes, ok := msg.Payload.(json.RawMessage)
if !ok {
slog.Error("invalid relay_config payload type")
continue
}
var cfg service.RelayConfig
if err := json.Unmarshal(payloadBytes, &cfg); err != nil {
slog.Error("failed to unmarshal relay_config", "error", err)
continue
}
r.FrpsManager.UpdateConfig(&cfg)
default:
slog.Debug("ignored unknown ws message type", "type", msg.Type)
}
}
}
}
func (r *Runner) sleepContext(ctx context.Context, d time.Duration) {
select {
case <-ctx.Done():
case <-time.After(d):
}
}
+59
View File
@@ -0,0 +1,59 @@
package state
import (
"encoding/json"
"log/slog"
"os"
"sync"
)
type Store struct {
path string
mu sync.RWMutex
}
type State struct {
LastAuthToken string `json:"last_auth_token"`
LastProfileFingerprint string `json:"last_profile_fingerprint"`
LastCPUStatTotal uint64 `json:"last_cpu_stat_total"`
LastCPUStatIdle uint64 `json:"last_cpu_stat_idle"`
LastMetricAtUnix int64 `json:"last_metric_at_unix"`
}
func NewStore(path string) *Store {
return &Store{
path: path,
}
}
func (s *Store) Load() (*State, error) {
s.mu.RLock()
defer s.mu.RUnlock()
data, err := os.ReadFile(s.path)
if err != nil {
if os.IsNotExist(err) {
return &State{}, nil
}
return nil, err
}
var state State
if err := json.Unmarshal(data, &state); err != nil {
return &State{}, nil // Return empty state on corrupted file
}
return &state, nil
}
func (s *Store) Save(state *State) error {
s.mu.Lock()
defer s.mu.Unlock()
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
slog.Debug("saving relay state")
return os.WriteFile(s.path, data, 0644)
}
+153
View File
@@ -0,0 +1,153 @@
package wsclient
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net"
"net/http"
"net/url"
"strings"
"time"
"golang.org/x/net/websocket"
"openflare/service"
)
type Client struct {
baseURL string
token string
timeout time.Duration
}
type Connection struct {
conn *websocket.Conn
url string
readTimeout time.Duration
}
func New(baseURL string, token string, timeout time.Duration) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
token: strings.TrimSpace(token),
timeout: timeout,
}
}
func (c *Client) SetToken(token string) {
c.token = strings.TrimSpace(token)
slog.Debug("relay ws client token updated")
}
func (c *Client) Connect(ctx context.Context) (*Connection, error) {
wsURL, err := buildWebsocketURL(c.baseURL)
if err != nil {
return nil, err
}
if strings.TrimSpace(c.token) == "" {
return nil, errors.New("relay ws token is empty")
}
origin := strings.TrimSpace(c.baseURL)
if origin == "" {
origin = "http://localhost"
}
config, err := websocket.NewConfig(wsURL, origin)
if err != nil {
return nil, err
}
config.Header = http.Header{}
config.Header.Set("X-Agent-Token", c.token)
if c.timeout > 0 {
config.Dialer = &net.Dialer{Timeout: c.timeout}
}
slog.Debug("relay ws dialing server", "url", wsURL)
conn, err := config.DialContext(ctx)
if err != nil {
return nil, err
}
slog.Debug("relay ws dial succeeded", "url", wsURL)
return &Connection{conn: conn, url: wsURL, readTimeout: websocketReadTimeout(c.timeout)}, nil
}
func buildWebsocketURL(baseURL string) (string, error) {
parsed, err := url.Parse(strings.TrimRight(baseURL, "/"))
if err != nil {
return "", err
}
switch parsed.Scheme {
case "http":
parsed.Scheme = "ws"
case "https":
parsed.Scheme = "wss"
case "ws", "wss":
default:
return "", errors.New("server_url scheme must be http, https, ws, or wss")
}
parsed.Path = strings.TrimRight(parsed.Path, "/") + "/api/relay/ws"
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String(), nil
}
func (conn *Connection) SendPing() error {
if conn == nil || conn.conn == nil {
return errors.New("relay ws connection is nil")
}
slog.Debug("relay ws sending ping")
return websocket.JSON.Send(conn.conn, service.WSMessage{
Type: "ping",
})
}
func (conn *Connection) SendPong() error {
if conn == nil || conn.conn == nil {
return errors.New("relay ws connection is nil")
}
slog.Debug("relay ws sending pong")
return websocket.JSON.Send(conn.conn, service.WSMessage{
Type: "pong",
})
}
func (conn *Connection) Receive() (service.WSMessage, error) {
var message service.WSMessage
if conn == nil || conn.conn == nil {
return message, errors.New("relay ws connection is nil")
}
if conn.readTimeout > 0 {
_ = conn.conn.SetReadDeadline(time.Now().Add(conn.readTimeout))
}
// Use custom json unmarshaling to handle any type
var raw struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload,omitempty"`
}
err := websocket.JSON.Receive(conn.conn, &raw)
if err != nil {
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
slog.Debug("relay ws receive timeout waiting for server message", "timeout", conn.readTimeout)
}
return message, err
}
message.Type = raw.Type
message.Payload = raw.Payload
slog.Debug("relay ws received message", "type", message.Type)
return message, nil
}
func websocketReadTimeout(requestTimeout time.Duration) time.Duration {
timeout := requestTimeout * 6
if timeout < 75*time.Second {
return 75 * time.Second
}
return timeout
}
func (conn *Connection) Close() error {
if conn == nil || conn.conn == nil {
return nil
}
return conn.conn.Close()
}
+5 -18
View File
@@ -26,11 +26,10 @@ var OptionMapRWMutex sync.RWMutex
var ItemsPerPage = 10
var PasswordLoginEnabled = true
var PasswordRegisterEnabled = true
var PasswordRegisterEnabled = false
var EmailVerificationEnabled = false
var GitHubOAuthEnabled = false
var WeChatAuthEnabled = false
var TurnstileCheckEnabled = false
var RegisterEnabled = false
var SMTPServer = ""
@@ -45,13 +44,12 @@ var WeChatServerAddress = ""
var WeChatServerToken = ""
var WeChatAccountQRCodeImageURL = ""
var TurnstileSiteKey = ""
var TurnstileSecretKey = ""
var AgentToken = ""
var AccessToken = ""
var AgentDiscoveryToken = ""
var NodeOfflineThreshold = 2 * time.Minute
// V3 operational settings (hot-reloadable via Option table)
var AgentHeartbeatInterval = 10000 // milliseconds
var AgentWebsocketUpgradeEnabled = true
var AgentUpdateRepo = "Rain-kl/OpenFlare"
@@ -60,6 +58,7 @@ var DatabaseAutoCleanupEnabled = false
var DatabaseAutoCleanupRetentionDays = 30
// V5 OpenResty performance settings (hot-reloadable via Option table)
var OpenRestyWorkerProcesses = "auto"
var OpenRestyWorkerConnections = 4096
var OpenRestyWorkerRlimitNofile = 65535
@@ -98,6 +97,7 @@ var OpenRestyMainConfigTemplate = `# This file is generated by OpenFlare. Do not
worker_processes {{OpenRestyWorkerProcesses}};
worker_rlimit_nofile {{OpenRestyWorkerRlimitNofile}};
pid logs/nginx.pid;
error_log {{OpenRestyErrorLogPath}} warn;
events {
worker_connections {{OpenRestyWorkerConnections}};
@@ -140,13 +140,6 @@ const (
RoleRootUser = 100
)
var (
FileUploadPermission = RoleGuestUser
FileDownloadPermission = RoleGuestUser
ImageUploadPermission = RoleGuestUser
ImageDownloadPermission = RoleGuestUser
)
// All duration's unit is seconds
// Shouldn't larger then RateLimitKeyExpirationDuration
var (
@@ -156,12 +149,6 @@ var (
GlobalWebRateLimitNum = 300
GlobalWebRateLimitDuration int64 = 3 * 60
UploadRateLimitNum = 50
UploadRateLimitDuration int64 = 60
DownloadRateLimitNum = 50
DownloadRateLimitDuration int64 = 60
CriticalRateLimitNum = 100
CriticalRateLimitDuration int64 = 20 * 60
)
+12 -13
View File
@@ -16,9 +16,6 @@ var (
LogDir = flag.String("log-dir", "", "specify the log directory")
)
// UploadPath Maybe override by ENV_VAR
var UploadPath = "upload"
func printHelp() {
fmt.Println("OpenFlare " + Version + " - Internal OpenResty Control Plane.")
fmt.Println("Copyright (C) 2023 JustSong. All rights reserved.")
@@ -26,11 +23,17 @@ func printHelp() {
fmt.Println("Usage: openflare [--port <port>] [--log-dir <log directory>] [--version] [--help]")
}
func init() {
// ParseFlags 在命令行参数被任何 import 链上的 init() 误解析之前,
// 由各 binary 的 main() 显式调用一次。openflare_server 与 openflare-relay
// 共用 flag.CommandLine,必须先注册各自的 flag 再调用本函数。
// 测试场景(go test)下不会执行本函数,单元测试可直接跳过命令行解析。
func ParseFlags() {
executableName := strings.ToLower(filepath.Base(os.Args[0]))
if !strings.Contains(executableName, ".test") {
flag.Parse()
isTest := strings.Contains(executableName, ".test") || flag.Lookup("test.v") != nil
if isTest {
return
}
flag.Parse()
if *PrintVersion {
fmt.Println(Version)
@@ -54,11 +57,9 @@ func init() {
if os.Getenv("DSN") != "" {
SQLDSN = os.Getenv("DSN")
}
if os.Getenv("UPLOAD_PATH") != "" {
UploadPath = os.Getenv("UPLOAD_PATH")
}
if os.Getenv("AGENT_TOKEN") != "" {
AgentToken = os.Getenv("AGENT_TOKEN")
AccessToken = os.Getenv("AGENT_TOKEN")
}
SetLogLevel(os.Getenv("LOG_LEVEL"))
if *LogDir != "" {
@@ -76,7 +77,5 @@ func init() {
}
}
}
if _, err := os.Stat(UploadPath); os.IsNotExist(err) {
_ = os.Mkdir(UploadPath, 0777)
}
}
+2 -1
View File
@@ -3,7 +3,6 @@ package common
import (
"context"
"fmt"
"github.com/gin-gonic/gin"
"io"
"log/slog"
"os"
@@ -11,6 +10,8 @@ import (
"runtime"
"slices"
"strings"
"github.com/gin-gonic/gin"
)
type logLevel int
+2 -1
View File
@@ -2,10 +2,11 @@ package common
import (
"context"
"github.com/go-redis/redis/v8"
"log/slog"
"os"
"time"
"github.com/go-redis/redis/v8"
)
var RDB *redis.Client
+8 -14
View File
@@ -1,7 +1,6 @@
package controller
import (
"net/http"
"openflare/service"
"strconv"
@@ -11,7 +10,7 @@ import (
// GetAccessLogs godoc
// @Summary List access logs
// @Tags AccessLogs
// @Produce json
// @Produce JSON
// @Security BearerAuth
// @Param node_id query string false "Node ID"
// @Param remote_addr query string false "Remote address"
@@ -35,7 +34,7 @@ func GetAccessLogs(c *gin.Context) {
// GetFoldedAccessLogs godoc
// @Summary List folded access logs
// @Tags AccessLogs
// @Produce json
// @Produce JSON
// @Security BearerAuth
// @Param node_id query string false "Node ID"
// @Param remote_addr query string false "Remote address"
@@ -62,7 +61,7 @@ func GetFoldedAccessLogs(c *gin.Context) {
// GetFoldedAccessLogIPs godoc
// @Summary List folded access log IP summaries
// @Tags AccessLogs
// @Produce json
// @Produce JSON
// @Security BearerAuth
// @Param node_id query string false "Node ID"
// @Param remote_addr query string false "Remote address"
@@ -99,7 +98,7 @@ func GetFoldedAccessLogIPs(c *gin.Context) {
// GetAccessLogIPSummaries godoc
// @Summary List access log IP summaries
// @Tags AccessLogs
// @Produce json
// @Produce JSON
// @Security BearerAuth
// @Param node_id query string false "Node ID"
// @Param remote_addr query string false "Remote address"
@@ -130,7 +129,7 @@ func GetAccessLogIPSummaries(c *gin.Context) {
// GetAccessLogIPTrend godoc
// @Summary Get access log IP trend
// @Tags AccessLogs
// @Produce json
// @Produce JSON
// @Security BearerAuth
// @Param node_id query string false "Node ID"
// @Param remote_addr query string true "Remote address"
@@ -157,19 +156,14 @@ func GetAccessLogIPTrend(c *gin.Context) {
// CleanupAccessLogs godoc
// @Summary Cleanup access logs by retention days
// @Tags AccessLogs
// @Accept json
// @Produce json
// @Accept JSON
// @Produce JSON
// @Security BearerAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/access-logs/cleanup [post]
func CleanupAccessLogs(c *gin.Context) {
var input service.AccessLogCleanupInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "参数错误",
"error": err.Error(),
})
if !bindJSON(c, &input) {
return
}
result, err := service.CleanupAccessLogs(input)
+4 -11
View File
@@ -1,9 +1,9 @@
package controller
import (
"github.com/gin-gonic/gin"
"net/http"
"openflare/model"
"github.com/gin-gonic/gin"
)
// GetDefaultAcmeAccount godoc
@@ -16,15 +16,8 @@ import (
func GetDefaultAcmeAccount(c *gin.Context) {
account, err := model.GetDefaultAcmeAccount()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
respondFailure(c, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": account,
})
respondSuccess(c, account)
}
+78 -35
View File
@@ -19,15 +19,14 @@ import (
// @Tags Agent
// @Accept json
// @Produce json
// @Security AgentTokenAuth
// @Security AccessTokenAuth
// @Param payload body service.AgentNodePayload true "Agent node payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/agent/nodes/register [post]
func AgentRegister(c *gin.Context) {
var payload service.AgentNodePayload
if err := decodeJSONBody(c.Request.Body, &payload); err != nil {
respondBadRequest(c, "")
if !bindJSON(c, &payload) {
return
}
payload.IP = service.ResolveReportedNodeIP(payload.IP, c.Request.RemoteAddr)
@@ -37,7 +36,7 @@ func AgentRegister(c *gin.Context) {
err error
)
if authNode, ok := c.Get("agent_node"); ok {
result, err = service.RegisterNodeWithAgentToken(authNode.(*model.Node), payload)
result, err = service.RegisterNodeWithAccessToken(authNode.(*model.Node), payload)
} else {
result, err = service.RegisterNodeWithDiscovery(payload)
}
@@ -53,15 +52,14 @@ func AgentRegister(c *gin.Context) {
// @Tags Agent
// @Accept json
// @Produce json
// @Security AgentTokenAuth
// @Security AccessTokenAuth
// @Param payload body service.AgentNodePayload true "Agent heartbeat payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/agent/nodes/heartbeat [post]
func AgentHeartbeat(c *gin.Context) {
var payload service.AgentNodePayload
if err := decodeJSONBody(c.Request.Body, &payload); err != nil {
respondBadRequest(c, "")
if !bindJSON(c, &payload) {
return
}
payload.IP = service.ResolveReportedNodeIP(payload.IP, c.Request.RemoteAddr)
@@ -80,17 +78,58 @@ func AgentHeartbeat(c *gin.Context) {
respondSuccessWithExtras(c, node.Node, gin.H{
"agent_settings": node.AgentSettings,
"active_config": node.ActiveConfig,
"waf_ip_groups": node.WAFIPGroups,
})
}
// AgentSyncWAFIPGroups godoc
// @Summary Sync WAF IP groups for agent
// @Tags Agent
// @Accept json
// @Produce json
// @Security AccessTokenAuth
// @Param payload body service.AgentWAFIPGroupSyncInput true "WAF IP group sync payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/agent/waf/ip-groups/sync [post]
func AgentSyncWAFIPGroups(c *gin.Context) {
var input service.AgentWAFIPGroupSyncInput
if !bindJSON(c, &input) {
return
}
result, err := service.SyncWAFIPGroupsForAgent(input)
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccess(c, result)
}
// AgentGetActiveConfig godoc
// @Summary Get active config for agent
// @Tags Agent
// @Produce json
// @Security AgentTokenAuth
// @Security AccessTokenAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/agent/config-versions/active [get]
func AgentGetActiveConfig(c *gin.Context) {
authNode, ok := c.Get("agent_node")
if !ok {
respondUnauthorized(c, "Node object missing from context")
return
}
node := authNode.(*model.Node)
if node.NodeType == "tunnel_client" {
config, err := service.GetFlaredTunnelConfig(node)
if err != nil {
respondFailure(c, "无法生成隧道配置: "+err.Error())
return
}
respondSuccess(c, config)
return
}
config, err := service.GetActiveConfigForAgent()
if err != nil {
respondFailure(c, "当前没有激活版本")
@@ -104,15 +143,14 @@ func AgentGetActiveConfig(c *gin.Context) {
// @Tags Agent
// @Accept json
// @Produce json
// @Security AgentTokenAuth
// @Security AccessTokenAuth
// @Param payload body service.ApplyLogPayload true "Apply log payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/agent/apply-logs [post]
func AgentReportApplyLog(c *gin.Context) {
var payload service.ApplyLogPayload
if err := decodeJSONBody(c.Request.Body, &payload); err != nil {
respondBadRequest(c, "")
if !bindJSON(c, &payload) {
return
}
@@ -131,7 +169,7 @@ func AgentReportApplyLog(c *gin.Context) {
// AgentWebSocket godoc
// @Summary Upgrade agent connection to websocket
// @Tags Agent
// @Security AgentTokenAuth
// @Security AccessTokenAuth
// @Router /api/agent/ws [get]
func AgentWebSocket(c *gin.Context) {
authNode, ok := c.Get("agent_node")
@@ -150,6 +188,12 @@ func AgentWebSocket(c *gin.Context) {
}()
slog.Debug("agent ws upgrade succeeded", "node_id", node.NodeID, "remote", c.Request.RemoteAddr)
go func() {
<-client.Done()
_ = conn.Close()
}()
go streamAgentWSMessages(c, conn, client)
for {
@@ -188,31 +232,26 @@ func agentWSReadTimeout() time.Duration {
return timeout
}
func streamAgentWSMessages(c *gin.Context, conn *websocket.Conn, client *service.AgentWSClient) {
pingTicker := time.NewTicker(30 * time.Second)
defer pingTicker.Stop()
func agentWSWriteTimeout() time.Duration {
return 10 * time.Second
}
func streamAgentWSMessages(c *gin.Context, conn *websocket.Conn, client *service.WSClient) {
for {
select {
case message := <-client.Messages():
slog.Debug("agent ws sending message", "node_id", client.NodeID(), "type", message.Type)
if err := websocket.JSON.Send(conn, message); err != nil {
slog.Debug("agent ws send failed", "node_id", client.NodeID(), "type", message.Type, "error", err)
client.Close()
return
}
case <-pingTicker.C:
message := service.AgentWSOutboundMessage{Type: service.AgentWSMessageTypePing}
slog.Debug("agent ws sending ping", "node_id", client.NodeID())
if err := websocket.JSON.Send(conn, message); err != nil {
slog.Debug("agent ws ping failed", "node_id", client.NodeID(), "error", err)
client.Close()
return
}
case <-c.Request.Context().Done():
return
case <-client.Done():
return
case <-c.Request.Context().Done():
client.Close()
return
case message, ok := <-client.Messages():
if !ok {
return
}
_ = conn.SetWriteDeadline(time.Now().Add(agentWSWriteTimeout()))
if err := websocket.JSON.Send(conn, message); err != nil {
slog.Debug("agent ws send failed", "node_id", client.ID(), "error", err)
return
}
}
}
}
@@ -239,12 +278,17 @@ func handleAgentWSStatus(c *gin.Context, node *model.Node, message service.Agent
if response.ActiveConfig != nil {
activeConfigSent = service.SendAgentWSActiveConfig(node.NodeID, response.ActiveConfig)
}
wafIPGroupsSent := false
if len(response.WAFIPGroups) > 0 {
wafIPGroupsSent = service.SendAgentWSWAFIPGroups(node.NodeID, response.WAFIPGroups)
}
slog.Debug("agent ws status processed",
"node_id", node.NodeID,
"current_version", payload.CurrentVersion,
"openresty_status", payload.OpenrestyStatus,
"settings_sent", settingsSent,
"active_config_sent", activeConfigSent,
"waf_ip_groups_sent", wafIPGroupsSent,
)
}
@@ -295,8 +339,7 @@ func GetApplyLogs(c *gin.Context) {
// @Router /api/apply-logs/cleanup [post]
func CleanupApplyLogs(c *gin.Context) {
var input service.ApplyLogCleanupInput
if err := c.ShouldBindJSON(&input); err != nil {
respondBadRequest(c, "")
if !bindJSON(c, &input) {
return
}
result, err := service.CleanupApplyLogs(input)
+23 -91
View File
@@ -1,9 +1,7 @@
package controller
import (
"net/http"
"openflare/service"
"strconv"
"github.com/gin-gonic/gin"
)
@@ -18,17 +16,10 @@ import (
func GetConfigVersions(c *gin.Context) {
versions, err := service.ListConfigVersions()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
respondFailure(c, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": versions,
})
respondSuccess(c, versions)
}
// GetConfigVersion godoc
@@ -41,27 +32,16 @@ func GetConfigVersions(c *gin.Context) {
// @Failure 400 {object} map[string]interface{}
// @Router /api/config-versions/{id} [get]
func GetConfigVersion(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "invalid id",
})
id, ok := parseIDParam(c)
if !ok {
return
}
version, err := service.GetConfigVersionDetail(uint(id))
version, err := service.GetConfigVersionDetail(id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
respondFailure(c, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": version,
})
respondSuccess(c, version)
}
// GetActiveConfigVersion godoc
@@ -74,17 +54,10 @@ func GetConfigVersion(c *gin.Context) {
func GetActiveConfigVersion(c *gin.Context) {
version, err := service.GetActiveConfigVersion()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "当前没有激活版本",
})
respondFailure(c, "当前没有激活版本")
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": version,
})
respondSuccess(c, version)
}
// PreviewConfigVersion godoc
@@ -97,17 +70,10 @@ func GetActiveConfigVersion(c *gin.Context) {
func PreviewConfigVersion(c *gin.Context) {
preview, err := service.PreviewConfigVersion()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
respondFailure(c, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": preview,
})
respondSuccess(c, preview)
}
// DiffConfigVersion godoc
@@ -120,17 +86,10 @@ func PreviewConfigVersion(c *gin.Context) {
func DiffConfigVersion(c *gin.Context) {
diff, err := service.DiffConfigVersion()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
respondFailure(c, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": diff,
})
respondSuccess(c, diff)
}
// PublishConfigVersion godoc
@@ -145,17 +104,10 @@ func PublishConfigVersion(c *gin.Context) {
force := c.Query("force") == "true"
result, err := service.PublishConfigVersion(username, force)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
respondFailure(c, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": result.Version,
})
respondSuccess(c, result.Version)
}
// ActivateConfigVersion godoc
@@ -168,27 +120,16 @@ func PublishConfigVersion(c *gin.Context) {
// @Failure 400 {object} map[string]interface{}
// @Router /api/config-versions/{id}/activate [post]
func ActivateConfigVersion(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "invalid id",
})
id, ok := parseIDParam(c)
if !ok {
return
}
version, err := service.ActivateConfigVersion(uint(id))
version, err := service.ActivateConfigVersion(id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
respondFailure(c, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": version,
})
respondSuccess(c, version)
}
type CleanupConfigVersionRequest struct {
@@ -206,26 +147,17 @@ type CleanupConfigVersionRequest struct {
// @Router /api/config-versions/cleanup [post]
func CleanupConfigVersions(c *gin.Context) {
var req CleanupConfigVersionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "参数错误",
})
if !bindJSON(c, &req) {
return
}
deletedCount, err := service.CleanupConfigVersions(req.KeepCount)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
respondFailure(c, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
respondSuccessWithExtras(c, map[string]interface{}{"deleted_count": deletedCount}, gin.H{
"message": "清理成功",
"data": map[string]interface{}{"deleted_count": deletedCount},
})
}
+1 -6
View File
@@ -1,7 +1,6 @@
package controller
import (
"net/http"
"openflare/service"
"github.com/gin-gonic/gin"
@@ -18,11 +17,7 @@ import (
func CleanupDatabaseObservability(c *gin.Context) {
var input service.DatabaseCleanupInput
if err := decodeOptionalJSONBody(c.Request.Body, &input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "参数错误",
"error": err.Error(),
})
respondBadRequest(c, "")
return
}
result, err := service.CleanupDatabaseObservability(input)
+21 -75
View File
@@ -1,11 +1,9 @@
package controller
import (
"encoding/json"
"github.com/gin-gonic/gin"
"net/http"
"openflare/model"
"strconv"
"github.com/gin-gonic/gin"
)
type DnsAccountInput struct {
@@ -24,17 +22,10 @@ type DnsAccountInput struct {
func GetDnsAccounts(c *gin.Context) {
accounts, err := model.ListDnsAccounts()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
respondFailure(c, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": accounts,
})
respondSuccess(c, accounts)
}
// CreateDnsAccount godoc
@@ -48,11 +39,7 @@ func GetDnsAccounts(c *gin.Context) {
// @Router /api/dns-accounts/ [post]
func CreateDnsAccount(c *gin.Context) {
var input DnsAccountInput
if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
if !bindJSON(c, &input) {
return
}
@@ -63,18 +50,11 @@ func CreateDnsAccount(c *gin.Context) {
}
if err := account.Insert(); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
respondFailure(c, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": account,
})
respondSuccess(c, account)
}
// UpdateDnsAccount godoc
@@ -88,30 +68,19 @@ func CreateDnsAccount(c *gin.Context) {
// @Success 200 {object} map[string]interface{}
// @Router /api/dns-accounts/{id}/update [post]
func UpdateDnsAccount(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
id, ok := parseIDParam(c)
if !ok {
return
}
var input DnsAccountInput
if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
if !bindJSON(c, &input) {
return
}
account, err := model.GetDnsAccountByID(uint(id))
account, err := model.GetDnsAccountByID(id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
respondFailure(c, err.Error())
return
}
@@ -120,18 +89,11 @@ func UpdateDnsAccount(c *gin.Context) {
account.Authorization = input.Authorization
if err := account.Update(); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
respondFailure(c, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": account,
})
respondSuccess(c, account)
}
// DeleteDnsAccount godoc
@@ -143,21 +105,14 @@ func UpdateDnsAccount(c *gin.Context) {
// @Success 200 {object} map[string]interface{}
// @Router /api/dns-accounts/{id}/delete [post]
func DeleteDnsAccount(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
id, ok := parseIDParam(c)
if !ok {
return
}
account, err := model.GetDnsAccountByID(uint(id))
account, err := model.GetDnsAccountByID(id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
respondFailure(c, err.Error())
return
}
@@ -165,23 +120,14 @@ func DeleteDnsAccount(c *gin.Context) {
var count int64
model.DB.Model(&model.TLSCertificate{}).Where("dns_account_id = ?", id).Count(&count)
if count > 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该 DNS 账号已被证书使用,无法删除",
})
respondFailure(c, "该 DNS 账号已被证书使用,无法删除")
return
}
if err := account.Delete(); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
respondFailure(c, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
respondSuccess(c, nil)
}
+173
View File
@@ -0,0 +1,173 @@
package controller
import (
"log/slog"
"net"
"openflare/common"
"openflare/model"
"openflare/service"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/net/websocket"
)
// FlaredHeartbeat godoc
// @Summary Report OpenFlared heartbeat
// @Tags Flared
// @Accept json
// @Produce json
// @Security TunnelTokenAuth
// @Param payload body service.FlaredHeartbeatPayload true "Flared heartbeat payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/flared/heartbeat [post]
func FlaredHeartbeat(c *gin.Context) {
var payload service.FlaredHeartbeatPayload
if !bindJSON(c, &payload) {
return
}
authNode, ok := c.Get("flared_node")
if !ok {
respondUnauthorized(c, "无权进行此操作,Tunnel Token 无效")
return
}
node := authNode.(*model.Node)
response, err := service.HeartbeatFlared(node, payload)
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccess(c, response)
}
// FlaredGetActiveConfig godoc
// @Summary Get active tunnel config for OpenFlared
// @Tags Flared
// @Produce json
// @Security TunnelTokenAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/flared/config/active [get]
func FlaredGetActiveConfig(c *gin.Context) {
authNode, ok := c.Get("flared_node")
if !ok {
respondUnauthorized(c, "无权进行此操作,Tunnel Token 无效")
return
}
node := authNode.(*model.Node)
config, err := service.GetFlaredTunnelConfig(node)
if err != nil {
respondFailure(c, "无法生成隧道配置: "+err.Error())
return
}
respondSuccess(c, config)
}
// FlaredReportApplyLog godoc
// @Summary Report OpenFlared apply result
// @Tags Flared
// @Accept json
// @Produce json
// @Security TunnelTokenAuth
// @Param payload body service.ApplyLogPayload true "Apply log payload"
// @Success 200 {object} map[string]interface{}
// @Router /api/flared/apply-log [post]
func FlaredReportApplyLog(c *gin.Context) {
var payload service.ApplyLogPayload
if !bindJSON(c, &payload) {
return
}
if authNode, ok := c.Get("flared_node"); ok {
payload.NodeID = authNode.(*model.Node).NodeID
}
log, err := service.ReportApplyLog(payload)
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccess(c, log)
}
// FlaredWebSocket godoc
// @Summary Upgrade OpenFlared connection to websocket
// @Tags Flared
// @Security TunnelTokenAuth
// @Router /api/flared/ws [get]
func FlaredWebSocket(c *gin.Context) {
authNode, ok := c.Get("flared_node")
if !ok {
respondUnauthorized(c, "无权进行此操作,Tunnel Token 无效")
return
}
node := authNode.(*model.Node)
slog.Debug("flared ws upgrade requested", "node_id", node.NodeID, "remote", c.Request.RemoteAddr)
websocket.Handler(func(conn *websocket.Conn) {
client := service.RegisterFlaredWSClient(node.NodeID)
defer service.UnregisterFlaredWSClient(client)
defer func() {
_ = conn.Close()
slog.Debug("flared ws connection closed", "node_id", node.NodeID)
}()
slog.Debug("flared ws upgrade succeeded", "node_id", node.NodeID, "remote", c.Request.RemoteAddr)
go func() {
<-client.Done()
_ = conn.Close()
}()
go streamFlaredWSMessages(c, conn, client)
for {
var message service.WSMessage
_ = conn.SetReadDeadline(time.Now().Add(flaredWSReadTimeout()))
if err := websocket.JSON.Receive(conn, &message); err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
slog.Debug("flared ws receive timeout", "node_id", node.NodeID)
return
}
slog.Debug("flared ws receive failed", "node_id", node.NodeID, "error", err)
return
}
slog.Debug("flared ws message received", "node_id", node.NodeID, "type", message.Type)
switch message.Type {
case "ping":
if !service.SendFlaredWSPong(node.NodeID) {
slog.Debug("flared ws pong enqueue failed", "node_id", node.NodeID)
}
case "pong":
slog.Debug("flared ws pong received", "node_id", node.NodeID)
default:
slog.Debug("flared ws unsupported message type", "node_id", node.NodeID, "type", message.Type)
}
}
}).ServeHTTP(c.Writer, c.Request)
}
func streamFlaredWSMessages(c *gin.Context, conn *websocket.Conn, client *service.WSClient) {
for {
select {
case <-c.Request.Context().Done():
return
case <-client.Done():
return
case message, ok := <-client.Messages():
if !ok {
return
}
_ = conn.SetWriteDeadline(time.Now().Add(agentWSWriteTimeout()))
if err := websocket.JSON.Send(conn, message); err != nil {
slog.Debug("flared ws send failed", "node_id", client.ID(), "error", err)
return
}
}
}
}
func flaredWSReadTimeout() time.Duration {
timeout := time.Duration(common.AgentHeartbeatInterval) * time.Millisecond * 3
if timeout < 30*time.Second {
return 30 * time.Second
}
return timeout
}
+1 -2
View File
@@ -22,8 +22,7 @@ type geoIPLookupRequest struct {
// @Router /api/option/geoip/lookup [post]
func LookupGeoIP(c *gin.Context) {
var request geoIPLookupRequest
if err := decodeJSONBody(c.Request.Body, &request); err != nil {
respondBadRequest(c, "")
if !bindJSON(c, &request) {
return
}
+15 -69
View File
@@ -5,14 +5,14 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"log/slog"
"net/http"
"openflare/common"
"openflare/model"
"strconv"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
type GitHubOAuthResponse struct {
@@ -87,19 +87,13 @@ func GitHubOAuth(c *gin.Context) {
}
if !common.GitHubOAuthEnabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未开启通过 GitHub 登录以及注册",
})
respondFailure(c, "管理员未开启通过 GitHub 登录以及注册")
return
}
code := c.Query("code")
githubUser, err := getGitHubUserInfoByCode(code)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
respondFailure(c, err.Error())
return
}
user := model.User{
@@ -108,45 +102,16 @@ func GitHubOAuth(c *gin.Context) {
if model.IsGitHubIdAlreadyTaken(user.GitHubId) {
err := user.FillUserByGitHubId()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
respondFailure(c, err.Error())
return
}
} else {
if common.RegisterEnabled {
user.Username = "github_" + strconv.Itoa(model.GetMaxUserId()+1)
if githubUser.Name != "" {
user.DisplayName = githubUser.Name
} else {
user.DisplayName = "GitHub User"
}
user.Email = githubUser.Email
user.Role = common.RoleCommonUser
user.Status = common.UserStatusEnabled
if err := user.Insert(); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
} else {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员关闭了新用户注册",
})
return
}
respondFailure(c, "管理员关闭了新用户注册")
return
}
if user.Status != common.UserStatusEnabled {
c.JSON(http.StatusOK, gin.H{
"message": "用户已被封禁",
"success": false,
})
respondFailure(c, "用户已被封禁")
return
}
setupLogin(&user, c)
@@ -154,29 +119,20 @@ func GitHubOAuth(c *gin.Context) {
func GitHubBind(c *gin.Context) {
if !common.GitHubOAuthEnabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未开启通过 GitHub 登录以及注册",
})
respondFailure(c, "管理员未开启通过 GitHub 登录以及注册")
return
}
code := c.Query("code")
githubUser, err := getGitHubUserInfoByCode(code)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
respondFailure(c, err.Error())
return
}
user := model.User{
GitHubId: githubUser.Login,
}
if model.IsGitHubIdAlreadyTaken(user.GitHubId) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该 GitHub 账户已被绑定",
})
respondFailure(c, "该 GitHub 账户已被绑定")
return
}
session := sessions.Default(c)
@@ -185,24 +141,14 @@ func GitHubBind(c *gin.Context) {
user.Id = id.(int)
err = user.FillUserById()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
respondFailure(c, err.Error())
return
}
user.GitHubId = githubUser.Login
err = user.Update(false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
respondFailure(c, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "bind",
})
return
respondSuccessMessage(c, "bind")
}
+20 -72
View File
@@ -1,12 +1,10 @@
package controller
import (
"encoding/json"
"github.com/gin-gonic/gin"
"net/http"
"openflare/service"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
// GetManagedDomains godoc
@@ -19,17 +17,10 @@ import (
func GetManagedDomains(c *gin.Context) {
domains, err := service.ListManagedDomains()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
respondFailure(c, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": domains,
})
respondSuccess(c, domains)
}
// CreateManagedDomain godoc
@@ -44,26 +35,15 @@ func GetManagedDomains(c *gin.Context) {
// @Router /api/managed-domains/ [post]
func CreateManagedDomain(c *gin.Context) {
var input service.ManagedDomainInput
if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
if !bindJSON(c, &input) {
return
}
domain, err := service.CreateManagedDomain(input)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
respondFailure(c, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": domain,
})
respondSuccess(c, domain)
}
// UpdateManagedDomain godoc
@@ -78,35 +58,20 @@ func CreateManagedDomain(c *gin.Context) {
// @Failure 400 {object} map[string]interface{}
// @Router /api/managed-domains/{id}/update [post]
func UpdateManagedDomain(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
id, ok := parseIDParam(c)
if !ok {
return
}
var input service.ManagedDomainInput
if err = json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
if !bindJSON(c, &input) {
return
}
domain, err := service.UpdateManagedDomain(uint(id), input)
domain, err := service.UpdateManagedDomain(id, input)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
respondFailure(c, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": domain,
})
respondSuccess(c, domain)
}
// DeleteManagedDomain godoc
@@ -119,25 +84,15 @@ func UpdateManagedDomain(c *gin.Context) {
// @Failure 400 {object} map[string]interface{}
// @Router /api/managed-domains/{id}/delete [post]
func DeleteManagedDomain(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
id, ok := parseIDParam(c)
if !ok {
return
}
if err = service.DeleteManagedDomain(uint(id)); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
if err := service.DeleteManagedDomain(id); err != nil {
respondFailure(c, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
respondSuccess(c, nil)
}
// MatchManagedDomainCertificate godoc
@@ -152,15 +107,8 @@ func MatchManagedDomainCertificate(c *gin.Context) {
domain := strings.TrimSpace(c.Query("domain"))
result, err := service.MatchManagedDomainCertificate(domain)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
respondFailure(c, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": result,
})
respondSuccess(c, result)
}

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