mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 13:46:38 +08:00
Compare commits
182 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc50cc695e | |||
| e43312d4c6 | |||
| 92df7d5c84 | |||
| 9632b4e3b8 | |||
| 3b979eb5d5 | |||
| 2635a47d29 | |||
| e00d67f2d9 | |||
| 581822d905 | |||
| b5ebfff19b | |||
| eed227b999 | |||
| 8f08a962e6 | |||
| d3ce26414c | |||
| 663da01bda | |||
| df63b0113a | |||
| d09e64ddc6 | |||
| b968043117 | |||
| 31d10195ca | |||
| 76f3428f5d | |||
| 9de33f7064 | |||
| fe13db95c2 | |||
| 6ddd2da2e8 | |||
| 054dc1a8a8 | |||
| 394e3c4855 | |||
| af36676e2e | |||
| 6fa31cafc7 | |||
| a8e8a940a0 | |||
| a092935623 | |||
| 5af13d0709 | |||
| 9a2616dc0e | |||
| c4e9e94117 | |||
| fce2e014e5 | |||
| 7372ac230b | |||
| 77bdb8bf0e | |||
| fd745d33cb | |||
| 65f899d334 | |||
| f034b73a47 | |||
| bd7f008322 | |||
| 2dc7e72621 | |||
| 2d542733f9 | |||
| c677edba06 | |||
| b827baf19f | |||
| 73beedfc09 | |||
| bc1b861841 | |||
| dfb3972b15 | |||
| 330771e7c7 | |||
| 9ded8c71da | |||
| 77ad3ea7e3 | |||
| 95d7045b4a | |||
| d5f46138d5 | |||
| 4196343ad3 | |||
| 78047d1b38 | |||
| c2bd416daf | |||
| 6e5d49c988 | |||
| 9da1ce8456 | |||
| 9f9cbd4ede | |||
| da1409fdac | |||
| 174198c283 | |||
| 796bf1c22f | |||
| 80dd5f8b31 | |||
| 14d41ad807 | |||
| fe2414ead5 | |||
| 649287a775 | |||
| 2514e7edc4 | |||
| 7ab11154e3 | |||
| 97c10b8d0b | |||
| ceae693a20 | |||
| edb356f40e | |||
| 81ba309650 | |||
| 5612403d48 | |||
| 4775e5cb73 | |||
| bc3d9ee285 | |||
| e654441127 | |||
| a987c0d681 | |||
| a85919fd9e | |||
| 4cb8928e4e | |||
| 57616626fd | |||
| 449d0a5c5b | |||
| 1c89db8ffa | |||
| 46f49cc349 | |||
| f365b3d331 | |||
| 4ae6c2718f | |||
| 8894620b92 | |||
| c2fcd2eddf | |||
| ec70794577 | |||
| bcd669722e | |||
| 9975ac90c4 | |||
| 632c455229 | |||
| b60cde02ac | |||
| 21ed214ba9 | |||
| f4a53d6b5f | |||
| cef3694d11 | |||
| 631d32e5d0 | |||
| c74b70b62e | |||
| fa9ecb5690 | |||
| b9cde88bf6 | |||
| f03718ce8c | |||
| 3423175006 | |||
| d619deec96 | |||
| e094f4a3b7 | |||
| 1bff2dadd4 | |||
| 602e7f5e9c | |||
| 9ec3d5b42d | |||
| 28b1305906 | |||
| a80376972c | |||
| 8300d3ec1c | |||
| 290ddd7b51 | |||
| 5d7a4469ea | |||
| 4e339caa9a | |||
| 2a00d21987 | |||
| f086edda3b | |||
| 899b4e6068 | |||
| fa23cad9e9 | |||
| 806863f303 | |||
| ab8e3d4705 | |||
| fe7f7da537 | |||
| 944b98d4d0 | |||
| 32dc7ef68e | |||
| 32762fdf3c | |||
| 79ed8fd6ab | |||
| 4257b6fd5a | |||
| 8dfe31c1c5 | |||
| 37486eb0c9 | |||
| 462deb4820 | |||
| f8509eed26 | |||
| 6e0b6df314 | |||
| 21962db3bf | |||
| b0117b7c84 | |||
| 95d58eb724 | |||
| c856faca50 | |||
| 5a0821274b | |||
| b69bdf838d | |||
| c35eb749c9 | |||
| e3c84c017a | |||
| 112694f860 | |||
| bd69ac51b5 | |||
| 46fb1a2b79 | |||
| c9a532db65 | |||
| be68b581e9 | |||
| 8853933adc | |||
| 048f6e4535 | |||
| 7b9c8996f9 | |||
| 8947bdc8d8 | |||
| baef42f920 | |||
| dd58e0df66 | |||
| bddf641bf1 | |||
| 83a426d3d6 | |||
| 4f698be0a5 | |||
| e9fb331214 | |||
| 5d6d68d0a1 | |||
| c8e2c3620e | |||
| af8e9b477e | |||
| 314f6fd3f4 | |||
| 7eee788720 | |||
| f6e4967a9a | |||
| 7afe4e5d78 | |||
| c6a055d5d3 | |||
| 9a89428405 | |||
| 370d58ac4d | |||
| e85df49962 | |||
| 856e3f46d2 | |||
| 2d6cc908f5 | |||
| 797a15ae70 | |||
| 8730f99fef | |||
| 8ad4defcc7 | |||
| d3d32a6b6b | |||
| 9c57ec2f5c | |||
| f8c1fe804d | |||
| 89489c8488 | |||
| d425e34f71 | |||
| 49472b54bf | |||
| a002d98f3a | |||
| 77457250cf | |||
| cff815bd47 | |||
| 97fa56b1af | |||
| 355791f2e4 | |||
| 65ecc27907 | |||
| c2184affed | |||
| 7d9190a8d8 | |||
| 4b1e75f86b | |||
| 25fe178cb2 | |||
| 383a039338 | |||
| e39a8995f6 |
@@ -0,0 +1,11 @@
|
||||
.git
|
||||
.idea
|
||||
anubis-source
|
||||
**/node_modules
|
||||
**/.next
|
||||
**/build
|
||||
**/dist
|
||||
**/.cache
|
||||
**/coverage
|
||||
**/*.db
|
||||
**/*.log
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
name: Cleanup prerelease tags
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve version metadata
|
||||
id: version
|
||||
run: |
|
||||
SHOULD_RUN=true
|
||||
VERSION="all-prerelease-tags"
|
||||
|
||||
echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
if [[ "$VERSION" =~ ^v[0-9]+(\.[0-9]+)*$ ]]; then
|
||||
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Delete prerelease, dangling, and unbound releases/tags
|
||||
if: steps.version.outputs.should_run == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Fetch all tags from remote to ensure full synchronization
|
||||
git fetch --tags --force
|
||||
|
||||
# Get all local/remote git tags starting with 'v'
|
||||
mapfile -t GIT_TAGS < <(git tag --list 'v*' | sort -V)
|
||||
|
||||
# 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
|
||||
# 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
|
||||
# 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" || true
|
||||
git tag -d "$TAG" || true
|
||||
DELETED_TAGS=$((DELETED_TAGS + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
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)."
|
||||
@@ -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}"
|
||||
@@ -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_OWNER,,}/openflared" >> "$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_OWNER,,}/openflared" >> "$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}"
|
||||
@@ -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}"
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Docker image builds
|
||||
name: Docker image build (Server)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log into registry
|
||||
uses: docker/login-action@v3
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ./openflare_server
|
||||
file: ./openflare_server/Dockerfile
|
||||
@@ -80,22 +80,22 @@ jobs:
|
||||
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 }}
|
||||
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/digests
|
||||
touch "/tmp/digests/${DIGEST#sha256:}"
|
||||
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: digests-${{ matrix.arch }}
|
||||
path: /tmp/digests/*
|
||||
name: server-digests-${{ matrix.arch }}
|
||||
path: /tmp/server-digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
@@ -143,12 +143,12 @@ jobs:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
path: /tmp/server-digests
|
||||
pattern: server-digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log into registry
|
||||
uses: docker/login-action@v3
|
||||
@@ -158,7 +158,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create and push manifest list
|
||||
working-directory: /tmp/digests
|
||||
working-directory: /tmp/server-digests
|
||||
shell: bash
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
@@ -168,13 +168,19 @@ jobs:
|
||||
done
|
||||
|
||||
if [ ${#references[@]} -eq 0 ]; then
|
||||
echo "No digests found in /tmp/digests" >&2
|
||||
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}:latest" \
|
||||
-t "${IMAGE}:${FLOATING_TAG}" \
|
||||
"${references[@]}"
|
||||
|
||||
- name: Inspect image
|
||||
@@ -197,13 +197,16 @@ 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
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: agent-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: dist/${{ matrix.asset_name }}
|
||||
path: |
|
||||
dist/${{ matrix.asset_name }}
|
||||
dist/${{ matrix.asset_name }}.sha256
|
||||
retention-days: 1
|
||||
|
||||
release:
|
||||
|
||||
+5
-3
@@ -14,7 +14,6 @@ logs
|
||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||
#
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
@@ -43,8 +42,11 @@ go.work.sum
|
||||
# .idea/
|
||||
# .vscode/
|
||||
|
||||
*.log
|
||||
|
||||
.DS_Store
|
||||
.codex-cache
|
||||
/.gomodcache/
|
||||
*.mmdb
|
||||
!openflare_agent/internal/geoipdata/GeoLite2-Country.mmdb
|
||||
|
||||
*-source
|
||||
*-source.*
|
||||
|
||||
@@ -1,41 +1,87 @@
|
||||
# AGENTS.md
|
||||
|
||||
本文件是 OpenFlare 的 AI 接手入口,不承载详细设计、规范和计划。接手项目时,先按顺序阅读以下文档:
|
||||
|
||||
1. [docs/design.md](./docs/design.md)
|
||||
作用:理解当前 MVP 的产品范围、系统边界、核心对象和整体架构。
|
||||
|
||||
2. [docs/development-guidelines.md](./docs/development-guidelines.md)
|
||||
作用:理解当前开发规范,包括技术基线、分层约束、数据模型边界、API 约定、Agent 约束、测试要求。
|
||||
|
||||
3. [docs/development-plan.md](./docs/development-plan.md)
|
||||
作用:理解当前开发阶段、实施顺序、阶段目标和验收标准。
|
||||
|
||||
4. [docs/frontend-development-guidelines.md](./docs/frontend-development-guidelines.md)
|
||||
作用:理解新版前端的技术选型、目录分层、组件规范、请求层、状态管理、样式和测试约束。
|
||||
|
||||
5. [docs/deployment.md](./docs/deployment.md)
|
||||
作用:理解当前的部署方式和联调步骤,确保开发过程中产出的功能能够成功部署和验证。
|
||||
|
||||
6. [docs/app-config.md](./docs/app-config.md)
|
||||
作用:系统启动时支持的环境变量和配置项说明,确保开发过程中新增的配置项能够正确使用和文档化。
|
||||
|
||||
|
||||
## 执行要求
|
||||
|
||||
* 如果实现内容超出 `docs/design.md` 的范围,先修改设计文档,再继续编码。
|
||||
* 如果实现方式违反 `docs/development-guidelines.md`,应优先调整方案,而不是绕过规范。
|
||||
* 如果需求与当前开发阶段冲突,优先遵守 `docs/development-plan.md` 的阶段顺序。
|
||||
* 如果任务涉及前端改造或管理端 UI,必须同时阅读 `docs/frontend-development-guidelines.md`。
|
||||
|
||||
|
||||
## 文档维护要求
|
||||
|
||||
当以下内容发生变化时,应同步更新对应文档:
|
||||
|
||||
* 产品启动配置部署方式发生变化时: 更新 `docs/deployment.md`和 `README.md`
|
||||
* 产品范围或系统边界变化:更新 `docs/design.md`
|
||||
* 开发约束、代码规范、接口约定变化:更新 `docs/development-guidelines.md`
|
||||
* 阶段目标、顺序、验收标准变化:更新 `docs/development-plan.md`
|
||||
* 前端目录分层、组件规范、样式体系、测试基线变化:更新 `docs/frontend-development-guidelines.md`
|
||||
* 环境变量或配置项变化:更新 `docs/app-config.md`
|
||||
# AGENTS.md
|
||||
|
||||
本文件是 OpenFlare 的 AI 接手入口,不承载详细设计、规范和计划。接手项目时,请根据以下分层文档指引进行阅读与开发:
|
||||
|
||||
## 1. 核心必读文档(Level 3 & Level 4)- 必须阅读 ⚠️
|
||||
|
||||
为了理解 OpenFlare 的设计理念、产品边界、核心机制以及代码编写的工程约束,**AI 在接手项目时必须首先且完整阅读以下文档**:
|
||||
|
||||
### 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)**
|
||||
*作用:熟悉仓库的整体物理结构和各子目录的职责。*
|
||||
|
||||
### 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 序列化避坑细则。*
|
||||
|
||||
---
|
||||
|
||||
## 2. 按需查阅文档(Level 2)- 根据需求阅读 💡
|
||||
|
||||
当开发任务涉及具体的系统部署、升级、接口联调或配置字段查阅时,**AI 应当根据需求阅读相应的参考手册**:
|
||||
|
||||
### 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 的升级步骤与维护策略。*
|
||||
|
||||
---
|
||||
|
||||
## 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/guildline/development-constraints.md),应优先调整方案,而不是绕过规范。
|
||||
* 如果实现方式涉及后端代码逻辑,必须严格遵循 [docs/guildline/](./docs/guildline/) 下的所有开发准则。
|
||||
* 如果需求与当前阶段原则冲突,优先遵守 [开发约束](./docs/guildline/development-constraints.md) 中的变更准入与验收标准。
|
||||
* 如果任务涉及前端改造或管理端 UI,必须同时遵守 [开发约束](./docs/guildline/development-constraints.md) 中的前端规范。
|
||||
|
||||
## 文档维护要求
|
||||
|
||||
当以下内容发生变化时,应同步更新对应中文文档,不要同步英文文档:
|
||||
|
||||
* 产品范围或系统边界变化:更新 `docs/design/index.md`
|
||||
* 系统结构、模块职责变化:更新 `docs/design/architecture.md`
|
||||
* 发布、同步、回滚模型变化:更新 `docs/design/release-model.md`
|
||||
* 业务分层、数据模型边界、接口约定、阶段原则、测试基线变化:更新 `docs/guildline/development-constraints.md`
|
||||
* 后端开发规范、代码质量要求、重构模式、去重逻辑与避坑指南变化:更新 `docs/guildline/` 下的对应开发准则文件
|
||||
* 产品启动、部署、升级、联调方式变化:更新 `docs/guide/quick-start.md`、`docs/reference/deployment.md` 和 `README.md`
|
||||
* 用户操作路径、常见场景变化:更新 `docs/guide/usage.md`
|
||||
* 本地开发、测试、构建方式变化:更新 `docs/design/development.md`
|
||||
* 常见故障、排查路径变化:更新 `docs/guide/troubleshooting.md`
|
||||
* 环境变量、命令行参数、运行时配置、Agent 配置变化:更新 `docs/reference/configuration.md`
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
<p align="right">
|
||||
<strong>中文</strong> | <a href="./README.en.md">English</a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
[//]: # ( <img src="./openflare_server/web/public/logo.png" width="120" height="120" alt="OpenFlare logo">)
|
||||
|
||||
# OpenFlare
|
||||
|
||||
轻量、自托管的 OpenResty 控制面,用于管理反向代理规则、配置发布、节点同步、TLS 证书与基础可观测能力。
|
||||
**[📖 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>
|
||||
|
||||
@@ -24,63 +20,35 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## 为什么存在
|
||||
> [!WARNING]
|
||||
> 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.
|
||||
|
||||
OpenFlare 解决的是一类朴素但高频的运维问题:
|
||||
## Documentation
|
||||
|
||||
* 在一个管理端里维护域名到源站的反向代理规则
|
||||
* 生成完整 OpenResty 配置并以不可变版本发布
|
||||
* 让节点侧 Agent 自动拉取、校验、reload 与失败回滚
|
||||
* 统一托管证书、域名、节点凭证与版本状态
|
||||
* 提供足够实用的总览、节点详情与访问分析能力
|
||||
**https://open-flare.pages.dev**
|
||||
|
||||
## 核心能力
|
||||
Quick links:
|
||||
|
||||
* 配置版本化:支持预览、发布、激活、历史回滚
|
||||
* Agent 自动应用:周期性同步、落盘、`openresty -t`、`openresty -s reload`、失败自动回滚
|
||||
* OpenResty 托管:统一管理主配置模板、性能参数、缓存参数与受管路由
|
||||
* TLS 与域名管理:支持证书托管、域名资产维护、精确匹配与通配符匹配
|
||||
* 访问与节点观测:支持请求窗口聚合、状态码分布、来源分布、节点资源与健康事件展示
|
||||
* [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
|
||||
|
||||
```text
|
||||
OpenFlare Server (Gin + GORM + SQLite/PostgreSQL + Web UI)
|
||||
|
|
||||
| HTTP API / Config Pull
|
||||
v
|
||||
OpenFlare Agent (register / heartbeat / sync / apply / update)
|
||||
|
|
||||
v
|
||||
Local OpenResty or Docker OpenResty
|
||||
|
|
||||
v
|
||||
Origin
|
||||
```
|
||||
* **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
|
||||
|
||||
* `openflare_server`:管理端 UI、管理 API、Agent API、配置渲染、版本发布与状态存储
|
||||
* `openflare_agent`:节点注册、心跳、同步、本地写入、校验、reload、回滚、自更新
|
||||
* `openflare_server/web`:新版管理端前端,静态导出后由 Go Server 托管
|
||||
|
||||
## 界面预览
|
||||
|
||||
### 仪表盘总览
|
||||
|
||||

|
||||
|
||||
### 节点详情
|
||||
|
||||

|
||||
|
||||
### 配置新增
|
||||
|
||||

|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 启动 Server
|
||||
### 1. Launch Server
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -121,18 +89,34 @@ 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前需确保存已经安装了Docker, 虽然支持裸Openresty,但未得到充分验证,可能存在未知问题.
|
||||
Before installing an Agent, install OpenResty on the target node, or use the Docker image with OpenResty built-in.
|
||||
|
||||
使用 `discovery_token` 接入:
|
||||
You can copy the installation command from the Dashboard → Node Management → Details → Node Info, or use the script below:
|
||||
|
||||
#### Docker Deployment
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
#### Local Installation
|
||||
|
||||
Using `discovery_token`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
|
||||
@@ -140,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 -- \
|
||||
@@ -148,70 +132,69 @@ curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/inst
|
||||
--agent-token YOUR_AGENT_TOKEN
|
||||
```
|
||||
|
||||
安装脚本默认写入 `/opt/openflare-agent`,创建 `openflare-agent.service`,并可重复执行以重装或升级 Agent。
|
||||
The installation script defaults to `/opt/openflare-agent`, creates a `openflare-agent.service`, auto-detects `openresty`, and supports re-execution for upgrades.
|
||||
|
||||
### 3. 发布第一份配置
|
||||
### 3. Uninstall Agent
|
||||
|
||||
1. 登录管理端并新增反代规则
|
||||
2. 在发布前查看预览或变更摘要
|
||||
3. 激活新版本
|
||||
4. 等待 Agent 在后续 heartbeat 中拉取并应用配置
|
||||
|
||||
版本号格式固定为 `YYYYMMDD-NNN`,历史版本不可变,回滚通过重新激活旧版本完成。
|
||||
|
||||
## 仓库结构
|
||||
|
||||
* `openflare_server`:Gin + GORM + SQLite/PostgreSQL 单体控制面
|
||||
* `openflare_server/web`:Next.js 15 App Router 管理端前端
|
||||
* `openflare_agent`:Go 单体 Agent
|
||||
* `scripts`:安装脚本与辅助脚本
|
||||
* `docs`:设计、规范、部署与配置文档
|
||||
|
||||
## 本地开发
|
||||
|
||||
### Server
|
||||
To completely uninstall the Agent and clean local data:
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
export SESSION_SECRET='replace-with-random-string'
|
||||
export SQLITE_PATH='./openflare.db'
|
||||
# 可选:设置 DSN 或 SQL_DSN 后切换到 PostgreSQL。
|
||||
# 如果 PostgreSQL 为空且 ./openflare.db 存在,启动时会自动迁移 SQLite 数据。
|
||||
# export DSN='postgres://openflare:secret@127.0.0.1:5432/openflare?sslmode=disable'
|
||||
go run .
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/uninstall-agent.sh | bash
|
||||
```
|
||||
|
||||
### Frontend
|
||||
The uninstall script stops and removes the `openflare-agent.service`, deletes the `/opt/openflare-agent` directory, and does not remove OpenResty.
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
### 4. Deploy Your First Configuration
|
||||
|
||||
### Agent
|
||||
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
|
||||
|
||||
```bash
|
||||
cd openflare_agent
|
||||
go run ./cmd/agent -config /path/to/agent.json
|
||||
```
|
||||
Versions are immutable with format `YYYYMMDD-NNN`. Rollback is performed by reactivating a previous version.
|
||||
|
||||
## 管理端与接口
|
||||
## UI Preview
|
||||
|
||||
管理端当前覆盖:
|
||||
### Dashboard Overview
|
||||
|
||||
* 反代规则
|
||||
* 配置版本
|
||||
* 节点管理
|
||||
* 应用记录
|
||||
* TLS 证书
|
||||
* 域名管理
|
||||
* 用户管理
|
||||
* 设置
|
||||
* 版本更新
|
||||

|
||||
|
||||
登录管理端后,可访问 Swagger UI:`/swagger/index.html`
|
||||
### Node Details
|
||||
|
||||
## 开源协议
|
||||

|
||||
|
||||
本项目采用 [Apache License 2.0](./LICENSE) 开源。
|
||||
### Proxy Configuration
|
||||
|
||||

|
||||
|
||||
## Management Panel & API
|
||||
|
||||
The management panel includes:
|
||||
|
||||
* Reverse Proxy Rules
|
||||
* Configuration Versions
|
||||
* Node Management
|
||||
* Application History
|
||||
* TLS Certificates
|
||||
* Domain Management
|
||||
* WAF Rule Groups
|
||||
* User Management
|
||||
* Settings
|
||||
* Version Updates
|
||||
* POW Rules
|
||||
|
||||
After logging in to the dashboard, access Swagger UI at: `/swagger/index.html`
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under [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>
|
||||
|
||||
+201
@@ -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`,历史版本不可变,回滚通过重新激活旧版本完成。
|
||||
|
||||
|
||||
## 界面预览
|
||||
|
||||
### 仪表盘总览
|
||||
|
||||

|
||||
|
||||
### 节点详情
|
||||
|
||||

|
||||
|
||||
### 配置新增
|
||||
|
||||

|
||||
|
||||
## 管理端与接口
|
||||
|
||||
管理端当前覆盖:
|
||||
|
||||
* 反代规则
|
||||
* 配置版本
|
||||
* 节点管理
|
||||
* 应用记录
|
||||
* 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>
|
||||
@@ -0,0 +1,52 @@
|
||||
services:
|
||||
agent:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: openflare_agent/Dockerfile
|
||||
container_name: openflare-agent
|
||||
restart: unless-stopped
|
||||
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "127.0.0.1:18081:18081"
|
||||
|
||||
volumes:
|
||||
- ./openflare_agent/data/:/data
|
||||
|
||||
environment:
|
||||
OPENFLARE_SERVER_URL: "http://host.docker.internal:3000"
|
||||
OPENFLARE_AGENT_TOKEN: "07800f31d3f181e65d18dca1407d821c"
|
||||
LOG_LEVEL: "debug"
|
||||
|
||||
extra_hosts:
|
||||
- "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
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/coverage
|
||||
/src/client/shared.ts
|
||||
/src/node/shared.ts
|
||||
*.log
|
||||
*.tgz
|
||||
.DS_Store
|
||||
.idea
|
||||
.temp
|
||||
.vite_opt_cache
|
||||
.vscode
|
||||
dist
|
||||
cache
|
||||
temp
|
||||
examples-temp
|
||||
node_modules
|
||||
pnpm-global
|
||||
TODOs.md
|
||||
*.timestamp-*.mjs
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"plugins": {
|
||||
"postcss-rtlcss": {
|
||||
"ltrPrefix": ":where([dir=\"ltr\"])",
|
||||
"rtlPrefix": ":where([dir=\"rtl\"])"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { defineConfig, type HeadConfig, resolveSiteDataByRoute } from 'vitepress'
|
||||
import llmstxt from 'vitepress-plugin-llms'
|
||||
|
||||
const prod = !!process.env.NETLIFY
|
||||
|
||||
export default defineConfig({
|
||||
title: 'OpenFlare',
|
||||
lastUpdated: true,
|
||||
cleanUrls: true,
|
||||
metaChunk: true,
|
||||
srcExclude: [
|
||||
'zh/**',
|
||||
'components/**',
|
||||
'snippets/**'
|
||||
],
|
||||
|
||||
markdown: {
|
||||
math: true
|
||||
},
|
||||
|
||||
sitemap: {
|
||||
hostname: 'https://openflare.io'
|
||||
},
|
||||
|
||||
head: [
|
||||
['meta', { name: 'theme-color', content: '#10b981' }],
|
||||
['meta', { property: 'og:type', content: 'website' }],
|
||||
['meta', { property: 'og:site_name', content: 'OpenFlare' }],
|
||||
['meta', { property: 'og:url', content: 'https://openflare.io/' }]
|
||||
],
|
||||
|
||||
themeConfig: {
|
||||
socialLinks: [
|
||||
{ icon: 'github', link: 'https://github.com/Rain-kl/OpenFlare' }
|
||||
],
|
||||
search: {
|
||||
provider: 'local'
|
||||
}
|
||||
},
|
||||
|
||||
locales: {
|
||||
root: { label: '简体中文', lang: 'zh-Hans', dir: 'ltr' },
|
||||
en: { label: 'English', lang: 'en-US', dir: 'ltr' }
|
||||
},
|
||||
|
||||
vite: {
|
||||
plugins: [
|
||||
prod &&
|
||||
llmstxt({
|
||||
workDir: '.',
|
||||
ignoreFiles: ['index.md']
|
||||
})
|
||||
],
|
||||
experimental: {
|
||||
enableNativePlugin: true
|
||||
}
|
||||
},
|
||||
|
||||
transformPageData: prod
|
||||
? (pageData, ctx) => {
|
||||
const site = resolveSiteDataByRoute(
|
||||
ctx.siteConfig.site,
|
||||
pageData.relativePath
|
||||
)
|
||||
const title = `${pageData.title || site.title} | ${
|
||||
pageData.description || site.description
|
||||
}`
|
||||
;((pageData.frontmatter.head ??= []) as HeadConfig[]).push(
|
||||
['meta', { property: 'og:locale', content: site.lang }],
|
||||
['meta', { property: 'og:title', content: title }]
|
||||
)
|
||||
}
|
||||
: undefined
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
import Theme from 'vitepress/theme'
|
||||
import './styles.css'
|
||||
|
||||
export default Theme
|
||||
@@ -0,0 +1,20 @@
|
||||
:root {
|
||||
--vp-c-brand-1: #059669;
|
||||
--vp-c-brand-2: #10b981;
|
||||
--vp-c-brand-3: #34d399;
|
||||
--vp-c-brand-soft: rgba(16, 185, 129, 0.16);
|
||||
--vp-home-hero-name-color: transparent;
|
||||
--vp-home-hero-name-background: linear-gradient(120deg, #059669, #2563eb);
|
||||
--vp-font-family-base:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||
'Segoe UI', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
|
||||
}
|
||||
|
||||
.VPHomeHero .text,
|
||||
.VPHomeHero .tagline {
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.VPFeature {
|
||||
border-radius: 8px;
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
# OpenFlare 配置项说明
|
||||
|
||||
本文档汇总 OpenFlare `1.0.0` 当前支持的 Server 与 Agent 配置项,只保留仍然有效的启动、部署与运行参数。
|
||||
|
||||
## 1. Server 配置
|
||||
|
||||
Server 支持三类配置来源:
|
||||
|
||||
1. 命令行参数
|
||||
2. 环境变量
|
||||
3. 数据库 `Option` 表中的运行时配置
|
||||
|
||||
### 1.1 命令行参数
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
go run . --port 3000 --log-dir ./logs
|
||||
```
|
||||
|
||||
| 参数 | 作用 | 默认值 |
|
||||
| --- | --- | --- |
|
||||
| `--port` | 指定 Server 监听端口 | `3000` |
|
||||
| `--log-dir` | 指定日志目录 | 空 |
|
||||
| `--version` | 输出当前版本后退出 | `false` |
|
||||
| `--help` | 输出帮助信息后退出 | `false` |
|
||||
|
||||
### 1.2 环境变量
|
||||
|
||||
| 环境变量 | 作用 | 默认值 |
|
||||
| --- | --- | --- |
|
||||
| `PORT` | Server 监听端口 | `3000` |
|
||||
| `GIN_MODE` | Gin 运行模式 | 非 `debug` 时按 release |
|
||||
| `LOG_LEVEL` | 日志等级 | `info` |
|
||||
| `SESSION_SECRET` | Session 签名密钥 | 启动时随机生成 |
|
||||
| `SQLITE_PATH` | SQLite 数据库文件路径 | `openflare.db` |
|
||||
| `DSN` | PostgreSQL DSN,设置后优先于 SQLite | 空 |
|
||||
| `SQL_DSN` | 兼容旧命名的 PostgreSQL DSN,优先级低于 `DSN` | 空 |
|
||||
| `REDIS_CONN_STRING` | Redis 连接串 | 空 |
|
||||
| `UPLOAD_PATH` | 上传目录 | `upload` |
|
||||
| `AGENT_TOKEN` | 兼容旧部署的全局 Agent Token | 空 |
|
||||
|
||||
说明:
|
||||
|
||||
* `DSN` 与 `SQL_DSN` 同时存在时优先使用 `DSN`
|
||||
* `DSN` 或 `SQL_DSN` 与 `SQLITE_PATH` 同时存在时优先使用 PostgreSQL
|
||||
* 当目标 PostgreSQL 数据库为空且本地 `SQLITE_PATH` 文件存在时,Server 启动阶段会自动迁移 SQLite 数据,并在日志中输出按表迁移进度
|
||||
* `SESSION_SECRET` 生产环境必须显式配置
|
||||
* `REDIS_CONN_STRING` 未配置时,相关能力回退为进程内实现
|
||||
|
||||
### 1.3 `Option` 表中的运行时配置
|
||||
|
||||
以下配置由管理端设置页维护,可热更新:
|
||||
|
||||
| 配置项 | 作用 | 默认值 |
|
||||
| --- | --- | --- |
|
||||
| `AgentHeartbeatInterval` | Agent 心跳间隔(毫秒) | `10000` |
|
||||
| `NodeOfflineThreshold` | 节点离线阈值(毫秒) | `120000` |
|
||||
| `AgentUpdateRepo` | Agent 自更新仓库 | `Rain-kl/OpenFlare` |
|
||||
| `GeoIPProvider` | 节点/IP 归属解析方式 | `ipinfo` |
|
||||
| `DatabaseAutoCleanupEnabled` | 是否启用每日自动清理观测数据 | `false` |
|
||||
| `DatabaseAutoCleanupRetentionDays` | 自动清理保留天数(至少 1 天) | `30` |
|
||||
| `GlobalApiRateLimitNum` / `GlobalApiRateLimitDuration` | 全局 API 限流次数 / 时间窗口 | `300` / `180` |
|
||||
| `GlobalWebRateLimitNum` / `GlobalWebRateLimitDuration` | 全局 Web 限流次数 / 时间窗口 | `300` / `180` |
|
||||
| `UploadRateLimitNum` / `UploadRateLimitDuration` | 上传接口限流次数 / 时间窗口 | `50` / `60` |
|
||||
| `DownloadRateLimitNum` / `DownloadRateLimitDuration` | 下载接口限流次数 / 时间窗口 | `50` / `60` |
|
||||
| `CriticalRateLimitNum` / `CriticalRateLimitDuration` | 敏感接口限流次数 / 时间窗口 | `100` / `1200` |
|
||||
|
||||
说明:
|
||||
|
||||
* `DatabaseAutoCleanupEnabled` 开启后,Server 会在每天凌晨 3 点自动清理 `node_access_logs`、`node_metric_snapshots`、`node_request_reports` 三类观测数据
|
||||
* `DatabaseAutoCleanupRetentionDays` 为统一保留天数,必须大于等于 1;管理端支持手动清理时留空保留天数,以直接删除对应数据集的全部历史记录
|
||||
|
||||
### 1.4 OpenResty 参数
|
||||
|
||||
OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前常用项包括:
|
||||
|
||||
* `OpenRestyWorkerProcesses`
|
||||
* `OpenRestyWorkerConnections`
|
||||
* `OpenRestyWorkerRlimitNofile`
|
||||
* `OpenRestyKeepaliveTimeout`
|
||||
* `OpenRestyProxyConnectTimeout`
|
||||
* `OpenRestyProxySendTimeout`
|
||||
* `OpenRestyProxyReadTimeout`
|
||||
* `OpenRestyProxyBufferingEnabled`
|
||||
* `OpenRestyGzipEnabled`
|
||||
* `OpenRestyCacheEnabled`
|
||||
* `OpenRestyCachePath`
|
||||
* `OpenRestyCacheMaxSize`
|
||||
|
||||
这类参数必须以结构化方式校验、保存并参与版本渲染。
|
||||
|
||||
* 管理端不再暴露 `resolver` 配置;规则上游统一渲染为 named `upstream` 并启用 keepalive,单上游如带 base path 或 query,会在 `proxy_pass` 中补回原始 URI。
|
||||
* 多上游仍要求每个上游都为纯 `scheme://host[:port]`,且同一规则内协议一致,避免在负载均衡模式下引入不可预测的 URI 差异。
|
||||
* `OpenRestyCacheEnabled` 用于启用缓存基础设施与全局默认参数;实际是否缓存、按 URL / 后缀 / 路径等命中策略由各条 `proxy_routes` 单独决定,不再默认对所有规则开启缓存。
|
||||
* 默认缓存 Key 为 `$scheme$host$request_uri`,更贴近代理域名维度;如需按其他维度命中,可在性能页显式覆盖。
|
||||
* 默认 `keepalive_timeout` 为 `20` 秒,默认 `proxy_connect_timeout` 为 `3` 秒,优先兼顾资源占用与回源失败切换速度。
|
||||
* 默认事件模型为 `epoll`,并默认开启 `multi_accept`;HTTPS 监听默认使用独立 `http2 on;` 指令,避免新版 Nginx/OpenResty 对 `listen ... http2` 的弃用告警。
|
||||
### 1.5 前端构建环境变量
|
||||
|
||||
| 环境变量 | 作用 | 默认值 |
|
||||
| --- | --- | --- |
|
||||
| `NEXT_PUBLIC_API_BASE_URL` | 前端请求 API 的基础路径 | `/api` |
|
||||
| `NEXT_PUBLIC_APP_VERSION` | 前端展示版本号 | `dev` |
|
||||
| `NEXT_DEV_BACKEND_URL` | 本地开发服务器代理的后端地址 | `http://127.0.0.1:3000` |
|
||||
|
||||
## 2. Agent 配置
|
||||
|
||||
Agent 当前支持:
|
||||
|
||||
1. `-config` 命令行参数
|
||||
2. `agent.json` 配置文件
|
||||
3. 少量日志相关环境变量
|
||||
|
||||
### 2.1 Agent 环境变量
|
||||
|
||||
| 环境变量 | 作用 | 默认值 |
|
||||
| --- | --- | --- |
|
||||
| `LOG_LEVEL` | Agent 日志等级 | `info` |
|
||||
|
||||
### 2.2 Agent 命令行参数
|
||||
|
||||
| 参数 | 作用 | 默认值 |
|
||||
| --- | --- | --- |
|
||||
| `-config` | 指定 Agent 配置文件路径 | `./agent.json` |
|
||||
|
||||
### 2.3 Agent 配置字段
|
||||
|
||||
| 字段 | 作用 | 是否必填 | 默认值/行为 |
|
||||
| --- | --- | --- | --- |
|
||||
| `server_url` | 控制面地址 | 是 | 无 |
|
||||
| `agent_token` | 节点专属认证 Token | 与 `discovery_token` 二选一 | 空 |
|
||||
| `discovery_token` | 首次自动注册使用的全局 Token | 与 `agent_token` 二选一 | 空 |
|
||||
| `node_name` | 节点名称 | 否 | 自动使用主机名 |
|
||||
| `node_ip` | 节点 IP | 否 | 自动探测,优先选择公网 IPv4;仅无公网地址时退回可用内网地址 |
|
||||
| `openresty_path` | 本机 OpenResty 路径 | 否 | 空,未设置时走 Docker 模式 |
|
||||
| `openresty_container_name` | Docker 模式下的容器名 | 否 | `openflare-openresty` |
|
||||
| `openresty_docker_image` | Docker 模式下的镜像 | 否 | `openresty/openresty:alpine` |
|
||||
| `openresty_observability_port` | 本地观测端口 | 否 | `18081` |
|
||||
| `docker_binary` | Docker 可执行文件名或路径 | 否 | `docker` |
|
||||
| `data_dir` | Agent 数据目录 | 否 | 配置文件所在目录下的 `data` |
|
||||
| `main_config_path` | OpenResty 主配置写入路径 | 否 | 本机模式建议显式配置 |
|
||||
| `route_config_path` | 路由配置写入路径 | 否 | `data_dir/etc/nginx/conf.d/openflare_routes.conf` |
|
||||
| `cert_dir` | 本机证书写入目录 | 否 | `data_dir/etc/nginx/certs` |
|
||||
| `openresty_cert_dir` | OpenResty 读取证书目录 | 否 | 随运行模式变化 |
|
||||
| `lua_dir` | 本机 Lua 脚本写入目录 | 否 | `data_dir/etc/nginx/lua` |
|
||||
| `openresty_lua_dir` | OpenResty 读取 Lua 目录 | 否 | 随运行模式变化 |
|
||||
| `observability_buffer_path` | 观测补报缓冲文件路径 | 否 | `data_dir/var/lib/openflare/observability-buffer.json` |
|
||||
| `observability_replay_minutes` | 自动补传最近观测窗口分钟数 | 否 | `15` |
|
||||
| `state_path` | Agent 本地状态文件路径 | 否 | `data_dir/var/lib/openflare/agent-state.json` |
|
||||
| `heartbeat_interval` | 心跳间隔 | 否 | `10000` 毫秒 |
|
||||
| `request_timeout` | HTTP 请求超时 | 否 | `10000` 毫秒 |
|
||||
|
||||
说明:
|
||||
|
||||
* `agent_token` 与 `discovery_token` 不能同时为空
|
||||
* `heartbeat_interval` 与 `request_timeout` 支持毫秒整数或 Go duration 字符串
|
||||
* 未配置 `openresty_path` 时默认使用 Docker OpenResty 模式
|
||||
* Agent 自动探测到私网 `node_ip` 时,Server 会在注册/心跳阶段优先保留 Agent 直连来源的公网地址,避免 NAT/多网卡场景误登记内网网卡地址
|
||||
|
||||
## 3. 维护要求
|
||||
|
||||
以下内容变化时,必须同步更新本文档:
|
||||
|
||||
* Server 命令行参数
|
||||
* Server 环境变量
|
||||
* Agent 命令行参数
|
||||
* Agent 配置字段
|
||||
* 任一配置项的默认值、用途或示例
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 147 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 70 KiB |
+113
@@ -0,0 +1,113 @@
|
||||
import { defineAdditionalConfig, type DefaultTheme } from 'vitepress'
|
||||
|
||||
export default defineAdditionalConfig({
|
||||
description:
|
||||
'OpenFlare 是轻量、自托管的 OpenResty 控制面,用于管理反向代理、配置发布、节点同步、TLS 证书与基础观测。',
|
||||
|
||||
themeConfig: {
|
||||
nav: nav(),
|
||||
|
||||
sidebar: {
|
||||
'/guide/': { base: '/guide/', items: sidebarGuide() },
|
||||
'/reference/': { base: '/reference/', items: sidebarReference() },
|
||||
'/design/': { base: '/design/', items: sidebarDesign() }
|
||||
},
|
||||
|
||||
editLink: {
|
||||
pattern: 'https://github.com/Rain-kl/OpenFlare/edit/main/docs/:path',
|
||||
text: '在 GitHub 上编辑此页面'
|
||||
},
|
||||
|
||||
footer: {
|
||||
message: '基于 Apache License 2.0 发布',
|
||||
copyright: 'Copyright © OpenFlare contributors'
|
||||
},
|
||||
|
||||
docFooter: {
|
||||
prev: '上一页',
|
||||
next: '下一页'
|
||||
},
|
||||
|
||||
outline: {
|
||||
label: '页面导航'
|
||||
},
|
||||
|
||||
lastUpdated: {
|
||||
text: '最后更新于'
|
||||
},
|
||||
|
||||
notFound: {
|
||||
title: '页面未找到',
|
||||
quote: '这份文档还没有对应页面。',
|
||||
linkLabel: '前往首页',
|
||||
linkText: '回到 OpenFlare 文档'
|
||||
},
|
||||
|
||||
langMenuLabel: '语言',
|
||||
returnToTopLabel: '回到顶部',
|
||||
sidebarMenuLabel: '菜单',
|
||||
darkModeSwitchLabel: '主题',
|
||||
lightModeSwitchTitle: '切换到浅色模式',
|
||||
darkModeSwitchTitle: '切换到深色模式',
|
||||
skipToContentLabel: '跳转到内容'
|
||||
}
|
||||
})
|
||||
|
||||
function nav(): DefaultTheme.NavItem[] {
|
||||
return [
|
||||
{ text: '指南', link: '/guide/', activeMatch: '/guide/' },
|
||||
{ text: '参考', link: '/reference/', activeMatch: '/reference/' },
|
||||
{ text: '设计', link: '/design/', activeMatch: '/design/' }
|
||||
]
|
||||
}
|
||||
|
||||
function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
||||
return [
|
||||
{
|
||||
text: '指南',
|
||||
items: [
|
||||
{ text: '概览', link: '' },
|
||||
{ text: '快速开始', link: 'quick-start' },
|
||||
{ text: '基础使用', link: 'usage' },
|
||||
{ text: 'WAF 自动 IP 组语法', link: 'waf-ip-group-expr' },
|
||||
{ text: 'SSO 登录配置', link: 'sso' },
|
||||
{ text: '发布第一份配置', link: 'first-site' },
|
||||
{ text: '故障排查', link: 'troubleshooting' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function sidebarReference(): DefaultTheme.SidebarItem[] {
|
||||
return [
|
||||
{
|
||||
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' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function sidebarDesign(): DefaultTheme.SidebarItem[] {
|
||||
return [
|
||||
{
|
||||
text: '设计',
|
||||
items: [
|
||||
{ text: '产品边界', link: '' },
|
||||
{ text: '系统架构', link: 'architecture' },
|
||||
{ text: '发布模型', link: 'release-model' },
|
||||
{ text: '本地开发', link: 'development' },
|
||||
{ text: '仓库结构', link: 'repository' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
# OpenFlare 部署说明
|
||||
|
||||
本文档只保留 OpenFlare `1.0.0` 的当前部署基线、联调入口与升级方式。
|
||||
|
||||
## 1. 前置条件
|
||||
|
||||
### 1.1 Server
|
||||
|
||||
* Go 1.24+
|
||||
* Node.js 18+
|
||||
* 可写 SQLite 文件目录,或可访问的 PostgreSQL 实例
|
||||
|
||||
### 1.2 Agent
|
||||
|
||||
* Go 1.24+
|
||||
* 对 Agent 数据目录有写权限
|
||||
* 本机模式下可执行 `openresty -t` 与 `openresty -s reload`
|
||||
* Docker 模式下具备 Docker 执行权限
|
||||
|
||||
## 2. 启动 Server
|
||||
|
||||
### 2.1 构建前端
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
corepack enable
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
`pnpm build` 会生成供 Go Server 托管的静态产物。
|
||||
|
||||
### 2.2 源码启动
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
export SESSION_SECRET='replace-with-random-string'
|
||||
export SQLITE_PATH='./openflare.db'
|
||||
export LOG_LEVEL='info'
|
||||
# 可选:设置后优先使用 PostgreSQL。
|
||||
# 如果 PostgreSQL 为空且本地 SQLite 文件存在,启动时会自动迁移数据。
|
||||
# export DSN='postgres://openflare:secret@127.0.0.1:5432/openflare?sslmode=disable'
|
||||
go run .
|
||||
```
|
||||
|
||||
默认监听 `3000` 端口。
|
||||
|
||||
### 2.3 Docker Compose 启动
|
||||
|
||||
```yaml
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: openflare
|
||||
POSTGRES_USER: openflare
|
||||
POSTGRES_PASSWORD: replace-with-strong-password
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U openflare -d openflare"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
openflare:
|
||||
image: ghcr.io/rain-kl/openflare:latest
|
||||
container_name: openflare
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
SESSION_SECRET: replace-with-random-string
|
||||
SQLITE_PATH: /data/openflare.db
|
||||
DSN: postgres://openflare:replace-with-strong-password@postgres:5432/openflare?sslmode=disable
|
||||
GIN_MODE: release
|
||||
LOG_LEVEL: info
|
||||
volumes:
|
||||
- openflare-data:/data
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
openflare-data:
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 2.4 首次登录
|
||||
|
||||
访问 `http://localhost:3000`
|
||||
|
||||
默认账号:
|
||||
|
||||
* 用户名:`root`
|
||||
* 密码:`123456`
|
||||
|
||||
### 2.5 Swagger
|
||||
|
||||
登录管理端后访问:`http://localhost:3000/swagger/index.html`
|
||||
|
||||
如需在本地重新生成文档:
|
||||
|
||||
```bash
|
||||
go install github.com/swaggo/swag/cmd/swag@v1.16.4
|
||||
cd openflare_server
|
||||
swag init -g main.go -o docs
|
||||
```
|
||||
|
||||
## 3. Agent 配置
|
||||
|
||||
当前支持两种接入模式。
|
||||
|
||||
### 3.1 使用节点专属 `agent_token`
|
||||
|
||||
```json
|
||||
{
|
||||
"server_url": "http://127.0.0.1:3000",
|
||||
"agent_token": "replace-with-node-auth-token",
|
||||
"data_dir": "./data",
|
||||
"openresty_container_name": "openflare-openresty",
|
||||
"openresty_docker_image": "openresty/openresty:alpine",
|
||||
"openresty_observability_port": 18081,
|
||||
"observability_replay_minutes": 15,
|
||||
"heartbeat_interval": 10000,
|
||||
"request_timeout": 10000
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 使用全局 `discovery_token`
|
||||
|
||||
```json
|
||||
{
|
||||
"server_url": "http://127.0.0.1:3000",
|
||||
"discovery_token": "replace-with-global-discovery-token",
|
||||
"data_dir": "./data",
|
||||
"openresty_container_name": "openflare-openresty",
|
||||
"openresty_docker_image": "openresty/openresty:alpine",
|
||||
"openresty_observability_port": 18081,
|
||||
"observability_replay_minutes": 15,
|
||||
"heartbeat_interval": 10000,
|
||||
"request_timeout": 10000
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
* `agent_token` 与 `discovery_token` 至少填写一个
|
||||
* 未配置 `openresty_path` 时默认使用 Docker OpenResty
|
||||
* Agent 会暴露本机观测端口并在 server 恢复后补传最近窗口数据
|
||||
|
||||
## 4. 启动 Agent
|
||||
|
||||
### 4.1 直接运行
|
||||
|
||||
```bash
|
||||
cd openflare_agent
|
||||
export LOG_LEVEL='info'
|
||||
go run ./cmd/agent -config /path/to/agent.json
|
||||
```
|
||||
|
||||
### 4.2 编译后二进制运行
|
||||
|
||||
```bash
|
||||
cd openflare_agent
|
||||
go build -o openflare-agent ./cmd/agent
|
||||
export LOG_LEVEL='info'
|
||||
./openflare-agent -config /path/to/agent.json
|
||||
```
|
||||
|
||||
## 5. 最小联调步骤
|
||||
|
||||
1. 在管理端准备 `agent_token` 或 `discovery_token`
|
||||
2. 启动 Agent 并确认节点上线
|
||||
3. 新增一条启用中的反代规则
|
||||
4. 生成并激活新版本
|
||||
5. 确认 Agent 拉取配置、执行 `openresty -t`、reload 并上报结果
|
||||
|
||||
预期管理端可看到:
|
||||
|
||||
* 节点在线状态
|
||||
* 节点当前版本
|
||||
* 最近一次应用结果
|
||||
* 自动注册后的专属 `agent_token`
|
||||
|
||||
## 6. 升级说明
|
||||
|
||||
* Root 用户可在管理端顶栏检查并升级 Server 正式版
|
||||
* 如需尝试 preview 版本,可手动检查对应发布
|
||||
* 节点 Agent 默认只跟随正式版自动更新;preview 升级需要手动触发
|
||||
* 也可通过上传 Server 二进制的方式执行确认升级
|
||||
|
||||
## 7. 常用验证命令
|
||||
|
||||
### 7.1 Server
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
GOCACHE=/tmp/openflare-go-cache go test ./...
|
||||
```
|
||||
|
||||
### 7.2 Agent
|
||||
|
||||
```bash
|
||||
cd openflare_agent
|
||||
GOCACHE=/tmp/openflare-go-cache go test ./...
|
||||
```
|
||||
|
||||
### 7.3 Frontend
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## 8. Agent 一键部署
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
|
||||
--server-url http://your-server:3000 \
|
||||
--discovery-token YOUR_DISCOVERY_TOKEN
|
||||
```
|
||||
|
||||
支持参数:
|
||||
|
||||
* `--server-url`
|
||||
* `--discovery-token`
|
||||
* `--agent-token`
|
||||
* `--install-dir`
|
||||
* `--repo`
|
||||
* `--no-service`
|
||||
|
||||
安装脚本会下载最新 Agent、生成 `agent.json`、创建 `openflare-agent.service` 并启动服务。
|
||||
|
||||
## 9. 文档维护要求
|
||||
|
||||
部署方式、升级方式、接入模式或联调流程变化时,同步更新本文档和 `README.md`。
|
||||
-179
@@ -1,179 +0,0 @@
|
||||
# OpenFlare 设计基线
|
||||
|
||||
本文档定义 OpenFlare `1.0.0` 之后仍然有效的产品边界、系统结构与长期约束。第六版已经完成并并入正式版;过程性设计不再在这里维护。
|
||||
|
||||
## 1. 产品定位
|
||||
|
||||
OpenFlare 是一套自托管的 OpenResty 控制面,面向单团队或单组织内部运维场景,解决反向代理配置、节点同步、证书托管与基础观测的统一管理问题。
|
||||
|
||||
当前稳定能力包括:
|
||||
|
||||
* 反代规则管理
|
||||
* 网站级配置与多域名绑定
|
||||
* 源站管理与复用
|
||||
* 配置预览、发布、激活与回滚
|
||||
* Agent 注册、心跳、同步、应用结果上报
|
||||
* OpenResty 主配置模板、性能参数与缓存参数托管
|
||||
* HTTPS/TLS 与域名资产管理
|
||||
* 节点请求聚合、资源快照、健康事件与看板展示
|
||||
* 节点管理、令牌体系、部署与更新链路
|
||||
* 基于 Next.js 的正式管理端前端
|
||||
|
||||
默认工作方式:
|
||||
|
||||
* 所有节点消费同一份全局激活版本
|
||||
* Server 保存配置与状态,不直接 SSH 管理节点
|
||||
* Agent 是节点侧唯一受控落地入口
|
||||
|
||||
## 3. 技术基线
|
||||
|
||||
### 3.1 Server
|
||||
|
||||
`openflare_server` 继续作为单体控制面:
|
||||
|
||||
* Gin
|
||||
* GORM
|
||||
* SQLite / PostgreSQL
|
||||
* 现有登录与 Session 体系
|
||||
* 托管 `openflare_server/web` 静态构建产物
|
||||
|
||||
### 3.2 Agent
|
||||
|
||||
`openflare_agent` 继续作为 Go 单体程序:
|
||||
|
||||
* 单二进制
|
||||
* 节点本地执行
|
||||
* `openresty_path` 优先
|
||||
* 未配置 `openresty_path` 时默认使用 Docker OpenResty
|
||||
|
||||
### 3.3 Frontend
|
||||
|
||||
`openflare_server/web` 是正式前端基线:
|
||||
|
||||
* Next.js App Router
|
||||
* React 19
|
||||
* TypeScript
|
||||
* Tailwind CSS
|
||||
* 静态导出后由 Go Server 托管
|
||||
|
||||
## 4. 总体架构
|
||||
|
||||
```text
|
||||
OpenFlare Server (Gin + SQLite/PostgreSQL + Web UI)
|
||||
|
|
||||
| HTTP API / Config Pull
|
||||
v
|
||||
OpenFlare Agent (register / heartbeat / sync / apply / update)
|
||||
|
|
||||
v
|
||||
Local OpenResty or Docker OpenResty
|
||||
|
|
||||
v
|
||||
Origin
|
||||
```
|
||||
|
||||
职责分工:
|
||||
|
||||
* Server 负责配置、版本、节点、设置、证书、管理端 UI 与聚合查询
|
||||
* Agent 负责本地写入、校验、reload、回滚、自更新与轻量采集
|
||||
* 发布通过“生成完整版本并激活”完成
|
||||
* 历史版本不可变
|
||||
* heartbeat 响应返回激活版本摘要,Agent 仅在不一致时拉取完整配置
|
||||
|
||||
## 5. 核心对象
|
||||
|
||||
当前有效实体:
|
||||
|
||||
* `proxy_routes`
|
||||
* `origins`
|
||||
* `config_versions`
|
||||
* `nodes`
|
||||
* `node_system_profiles`
|
||||
* `apply_logs`
|
||||
* `tls_certificates`
|
||||
* `managed_domains`
|
||||
* `node_request_reports`
|
||||
* `node_access_logs`
|
||||
* `node_metric_snapshots`
|
||||
* `traffic_analytics_rollups`
|
||||
* `node_health_events`
|
||||
|
||||
稳定约束:
|
||||
|
||||
* `proxy_routes` 从“单域名规则”升级为“网站配置”聚合对象;一条记录对应一个网站,可绑定一个或多个域名,并共享一组站点级配置
|
||||
* `proxy_routes.site_name` 是网站的业务唯一标识;新建时默认取 `domains[0]`,后续允许独立维护,不随域名改动自动重写
|
||||
* `proxy_routes.domains` 至少包含一个域名,且 `domains[0]` 作为主域名;任一域名全局只能属于一个 `proxy_routes`
|
||||
* 为兼容历史数据,迁移期可保留 `proxy_routes.domain` 作为 `domains[0]` 的镜像字段,但业务读写与后续扩展必须以 `site_name` + `domains` 为准
|
||||
* `origins` 只保存源站地址、展示名与备注,不承载协议、端口、路径、权重或健康检查策略
|
||||
* `proxy_routes` 可选关联一个 `origins` 记录,用于复用源站地址;规则仍保存完整 `origin_url` 快照以参与渲染与版本快照
|
||||
* `proxy_routes` 至少包含一个上游地址;为兼容历史数据保留 `origin_url` 主上游字段,也允许在同一规则内补充多个上游做负载均衡
|
||||
* `proxy_routes` 上游统一渲染为带 keepalive 的 named `upstream`;单上游可附带 base path 或 query 并在 `proxy_pass` 中追加,多上游仍限定为纯 `scheme://host[:port]`
|
||||
* `proxy_routes.origin_host` 为可选字段,用于回源时覆盖 `Host` 请求头;未设置时默认透传访问域名
|
||||
* 网站级流量限制、反向代理、HTTPS 与缓存配置当前按站点共享,不在同一网站内做域名级差异化配置
|
||||
* 发布渲染时必须将 `proxy_routes.domains` 中的全部域名一并纳入同一站点配置,避免同站点在版本快照中被拆散
|
||||
* 所有上游地址都必须为合法 `http://` 或 `https://`
|
||||
* `config_versions` 必须保存完整快照、渲染结果与 `checksum`
|
||||
* 全局同时只能有一个激活版本
|
||||
* 回滚通过重新激活旧版本实现
|
||||
* `nodes` 只承载控制面状态与低频摘要,不承载高频观测事实
|
||||
* 指标、趋势和访问分析优先使用服务端聚合结果,而不是前端临时统计
|
||||
* 访问明细只保留受控时间窗口,不演变成通用日志平台
|
||||
|
||||
## 6. 发布模型
|
||||
|
||||
标准链路:
|
||||
|
||||
```text
|
||||
修改规则 -> 预览/查看 diff -> 发布 -> 生成完整配置版本 -> 激活版本 -> Agent 拉取 -> 本地应用 -> 上报结果
|
||||
```
|
||||
|
||||
发布规则:
|
||||
|
||||
1. 读取全部启用的 `proxy_routes`
|
||||
2. 读取 Server 侧 OpenResty 主配置与结构化参数
|
||||
3. 渲染完整 OpenResty 配置
|
||||
4. 计算 `checksum`
|
||||
5. 写入 `config_versions`
|
||||
6. 切换激活版本
|
||||
7. Agent 在后续 heartbeat 中发现并应用
|
||||
|
||||
版本号格式固定为 `YYYYMMDD-NNN`。
|
||||
|
||||
## 7. 模块边界
|
||||
|
||||
### 7.1 `openflare_server`
|
||||
|
||||
负责:
|
||||
|
||||
* 管理端 UI 与 API
|
||||
* Agent API
|
||||
* 配置渲染与版本发布
|
||||
* 数据存储与聚合查询
|
||||
* OpenResty 主配置模板、性能参数与缓存参数管理
|
||||
|
||||
### 7.2 `openflare_agent`
|
||||
|
||||
负责:
|
||||
|
||||
* 首次注册与凭证置换
|
||||
* 周期性心跳与同步
|
||||
* 主配置、路由配置、证书与 Lua 资源写入
|
||||
* 执行 `openresty -t` / `openresty -s reload`
|
||||
* 失败回滚
|
||||
* 对已失败并回退的目标版本做本地熔断,直到控制面出现新的激活版本
|
||||
* 节点观测采集与结果上报
|
||||
|
||||
### 7.3 `openflare_server/web`
|
||||
|
||||
负责:
|
||||
|
||||
* 管理端页面、布局、交互与主题
|
||||
* 总览、节点详情、规则、版本、节点、证书、域名、用户与设置页面
|
||||
* 统一请求层与前端状态管理
|
||||
|
||||
## 8. 文档维护原则
|
||||
|
||||
* 产品范围或系统边界变化时更新本文档
|
||||
* 已完成阶段不再以“版本计划”形式回填
|
||||
* 新阶段开始前,先补设计,再进入实现
|
||||
* 涉及网站级规则改造的详细需求与实施顺序,见 [docs/website-configuration-redesign.md](./website-configuration-redesign.md)
|
||||
@@ -0,0 +1,223 @@
|
||||
# 系统架构
|
||||
|
||||
你会学到:OpenFlare 的整体架构、Server、Agent、OpenResty 与管理端前端的职责边界,以及一次配置发布从管理端到节点生效的请求流。
|
||||
|
||||
OpenFlare 由 Server、Agent、节点本地 OpenResty 和管理端前端组成。Server 是控制面,Agent 是节点侧唯一受控落地入口,OpenResty 是实际数据面。内网穿透场景中,Relay(frps 管理器)和 OpenFlared(frpc 管理器)扩展了数据面流量路径。
|
||||
|
||||
### 标准反代流量路径
|
||||
|
||||
```text
|
||||
Browser
|
||||
|
|
||||
| Management UI / API
|
||||
v
|
||||
OpenFlare Server (Gin + GORM + SQLite/PostgreSQL)
|
||||
|
|
||||
| Agent API / heartbeat / config pull
|
||||
v
|
||||
OpenFlare Agent
|
||||
|
|
||||
| write config / openresty -t / reload / rollback
|
||||
v
|
||||
OpenResty binary
|
||||
|
|
||||
| reverse proxy
|
||||
v
|
||||
Origin
|
||||
```
|
||||
|
||||
### 内网穿透流量路径
|
||||
|
||||
```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/Relay/Client API、配置渲染、版本发布、数据存储与聚合查询 |
|
||||
| Agent | 注册、心跳、同步、写入文件、校验、reload、失败回滚、自更新与轻量采集 |
|
||||
| OpenResty | 接收真实流量,按 OpenFlare 渲染的配置执行 WAF、PoW、认证与反向代理 |
|
||||
| OpenFlareRelay | 管理 frps 进程生命周期,提供隧道中继服务,通过心跳接收 frps 配置 |
|
||||
| OpenFlared | 管理 frpc 进程(可多个),连接 Relay 中继,将流量转发到内网服务 |
|
||||
| Frontend | 管理网站配置、WAF、源站、证书、节点、Tunnel、版本、用户、设置与观测页面 |
|
||||
|
||||
## Server
|
||||
|
||||
`openflare_server` 是单体控制面:
|
||||
|
||||
* Gin 提供 HTTP 服务。
|
||||
* GORM 访问 SQLite 或 PostgreSQL。
|
||||
* 现有登录体系提供管理端 Session。
|
||||
* 认证源与外部账号绑定支持 GitHub OAuth 和标准 OIDC。
|
||||
* Go Server 托管 `openflare_server/web` 静态构建产物。
|
||||
|
||||
Server 不直接 SSH 到节点,也不在线修改节点文件。它只保存控制面状态、生成完整配置版本,并通过 Agent API 让节点主动拉取。
|
||||
|
||||
## Agent
|
||||
|
||||
`openflare_agent` 是 Go 单体程序:
|
||||
|
||||
* 单二进制运行在节点侧。
|
||||
* 启动后读取或生成本地节点信息。
|
||||
* 周期性 heartbeat,上报状态并获取激活版本摘要。
|
||||
* 发现新版本后拉取配置、备份旧文件、写入新文件、校验并 reload。
|
||||
* 应用失败时尝试恢复运行并回滚。
|
||||
* 维护 WAF GeoIP mmdb,启动时写入内置初始库,并按配置定期更新。
|
||||
|
||||
Agent 通过 `openresty_path` 指向的 OpenResty 二进制统一执行校验、reload、启动与重启;未配置时默认调用 `openresty`。Docker 部署时,Agent 镜像内置 OpenResty 二进制,仍走同一套二进制控制逻辑。
|
||||
|
||||
节点 IP 默认由 Agent 注册和心跳上报维护;如果管理端锁定节点 IP,Server 只更新运行状态、版本、观测等运行态字段,不再接受 Agent 上报覆盖该 IP。
|
||||
|
||||
## Frontend
|
||||
|
||||
`openflare_server/web` 是正式管理端前端:
|
||||
|
||||
* Next.js 15 App Router。
|
||||
* React 19。
|
||||
* TypeScript。
|
||||
* Tailwind CSS。
|
||||
* TanStack Query 管理服务端状态。
|
||||
|
||||
前端采用静态导出模式(`output: 'export'`),导出后由 Go Server 通过 `embed.FS` 托管。所有 API 请求应统一经过 `lib/api/`,并处理 `success/message/data` 响应结构。
|
||||
|
||||
Server 集成以下安全特性:
|
||||
* CORS 中间件:跨域请求保护。
|
||||
* 速率限制:全局与关键接口限流。
|
||||
* 会话管理:基于 Cookie/Redis 的会话存储。
|
||||
|
||||
## 数据与请求流
|
||||
|
||||
### 管理端请求流
|
||||
|
||||
```text
|
||||
Browser -> Frontend -> /api/* -> controller -> service -> model -> database
|
||||
```
|
||||
|
||||
管理端变更类接口使用 `POST`,只读接口使用 `GET`。成功与失败都返回清晰的 `message`。
|
||||
|
||||
### Agent 同步流
|
||||
|
||||
```text
|
||||
Agent HTTP heartbeat -> Server 返回激活版本摘要
|
||||
Agent 发现新版本 -> 拉取配置详情
|
||||
Agent 写入主配置 / 路由配置 / 证书 / Lua 资源 / WAF 运行时配置
|
||||
Agent 执行 OpenResty 校验与 reload
|
||||
Agent 上报应用结果
|
||||
```
|
||||
|
||||
### 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 反向代理支持。
|
||||
|
||||
### 反向代理流
|
||||
|
||||
```text
|
||||
Client -> OpenResty server block -> WAF Lua -> named upstream -> Origin
|
||||
```
|
||||
|
||||
网站配置是反向代理聚合边界。一条网站配置可绑定多个域名,并共享站点级流量限制、反向代理和缓存配置。
|
||||
|
||||
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 数据库、请求日志或远程订阅源。
|
||||
|
||||
## 核心对象
|
||||
|
||||
当前有效实体包括:
|
||||
|
||||
* `proxy_routes`
|
||||
* `origins`
|
||||
* `config_versions`
|
||||
* `nodes`
|
||||
* `tunnels`
|
||||
* `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_ip_groups`
|
||||
* `waf_rule_group_bindings`
|
||||
* `acme_accounts`
|
||||
* `dns_accounts`
|
||||
* `geoip_update_configs`
|
||||
|
||||
## 关键设计决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
| ------------------------------ | --------------------------------------------------------------------------- |
|
||||
| 完整配置版本,而不是在线 patch | 让预览、激活、历史和回滚有稳定边界 |
|
||||
| Agent 主动拉取 | Server 不需要 SSH 权限,也不暴露远程命令入口;支持 HTTP 与 WebSocket 双协议 |
|
||||
| 全局单激活版本 | 降低 MVP 复杂度,保证所有节点默认一致;支持版本预览、历史查询与一键回滚 |
|
||||
| 网站配置聚合多域名 | 支持一个业务站点共享站点级策略,同时允许按域名绑定证书 |
|
||||
| 观测数据服务端聚合 | 避免前端临时统计造成口径不一致 |
|
||||
| 内网穿透基于 frp 整合 | 复用成熟隧道协议,避免自研隧道的稳定性风险;frps HTTP Vhost 路由天然适配 |
|
||||
| Relay/Client 独立二进制 | 职责分离,Relay 管理 frps,Client 管理 frpc,各自独立升级和部署 |
|
||||
| Tunnel 与 Node 体系分离 | Tunnel 客户端在内网运行,与公网节点概念不同,使用独立的注册和认证体系 |
|
||||
|
||||
## 贡献者阅读建议
|
||||
|
||||
如果要修改架构相关代码,先阅读:
|
||||
|
||||
1. [产品边界](./index.md)
|
||||
2. [发布模型](./release-model.md)
|
||||
3. [开发约束](../guildline/development-constraints.md)
|
||||
4. [仓库结构](./repository.md)
|
||||
@@ -0,0 +1,182 @@
|
||||
# 本地开发
|
||||
|
||||
你会学到:如何搭建 OpenFlare 的本地开发环境、启动 Server、Agent 和管理端前端,运行测试与构建命令,并理解贡献代码前需要遵守的边界。
|
||||
|
||||
本页面向贡献者。产品边界、数据模型约束、API 约定和前端分层规范以 [开发约束](../guildline/development-constraints.md) 为准;本页只提供可执行的本地开发流程。
|
||||
|
||||
## 仓库结构
|
||||
|
||||
项目的核心物理目录及各模块(Server、Agent、Frontend 等)的职责分层,详见 [仓库结构](./repository.md)。
|
||||
|
||||
## 环境要求
|
||||
|
||||
| 项目 | 要求 |
|
||||
| --- | --- |
|
||||
| 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. 需求符合 [产品边界](./index.md)。
|
||||
2. 实现符合 [开发约束](../guildline/development-constraints.md)。
|
||||
3. 不破坏发布、同步、回滚或升级主链路。
|
||||
4. 涉及配置、部署、API 或产品边界时同步更新文档。
|
||||
5. 风险较高的修改补充测试或等效联调验证。
|
||||
|
||||
数据库结构变更必须提升数据库版本号,并补充从上一版本到新版本的显式迁移方法和校验逻辑。
|
||||
@@ -0,0 +1,231 @@
|
||||
# 产品边界
|
||||
|
||||
你会学到:OpenFlare 是什么、解决什么问题、目标用户是谁、当前稳定能力有哪些,以及哪些设计边界在实现时不能被绕过。
|
||||
|
||||
OpenFlare 是一套自托管的 OpenResty 控制面,面向单团队或单组织内部运维场景。它解决反向代理配置、节点同步、证书托管、配置发布回滚与基础观测分散管理的问题。
|
||||
|
||||
## 项目定位
|
||||
|
||||
OpenFlare 适合需要统一管理多台 OpenResty 代理节点的团队:
|
||||
|
||||
* 希望用管理端维护反向代理网站配置。
|
||||
* 希望每次配置变更都有完整版本、预览、激活与回滚。
|
||||
* 希望节点主动同步配置,而不是由控制面 SSH 到节点执行命令。
|
||||
* 希望在同一系统中管理 TLS 证书、域名资产、节点状态和基础访问分析。
|
||||
|
||||
OpenFlare 当前不定位为通用日志平台、服务网格、Kubernetes Ingress Controller 或多租户云平台。
|
||||
|
||||
## 当前能力
|
||||
|
||||
| 能力 | 说明 |
|
||||
| --- | --- |
|
||||
| 反代规则管理 | 以网站配置为聚合边界,支持多域名与源站配置 |
|
||||
| 网站级配置 | 一条规则对应一个网站,可绑定一个或多个域名,并共享站点级配置 |
|
||||
| 源站管理 | 维护轻量源站目录,并允许网站保存可渲染的源站快照 |
|
||||
| 配置版本 | 支持预览、发布、激活、不可变历史与回滚 |
|
||||
| Agent 同步 | 支持注册、心跳、同步、应用结果上报与自更新 |
|
||||
| OpenResty 托管 | 管理主配置模板、性能参数、缓存参数与 Lua 资源 |
|
||||
| HTTPS/TLS | 托管证书与域名资产,并按域名绑定证书 |
|
||||
| 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,将流量转发到内网服务。
|
||||
|
||||
## 典型使用场景
|
||||
|
||||
| 场景 | 说明 |
|
||||
| --- | --- |
|
||||
| 内部服务统一入口 | 把多个内部 HTTP 服务通过统一域名和证书暴露 |
|
||||
| 多节点反代配置同步 | 多台 OpenResty 节点消费同一份激活配置 |
|
||||
| 配置变更审查 | 发布前查看预览或 diff,发布后保留不可变历史 |
|
||||
| 快速回滚 | 重新激活旧版本,让 Agent 拉取并应用 |
|
||||
| 证书托管 | 为不同域名绑定 TLS 证书 |
|
||||
| 基础观测 | 查看节点状态、请求聚合、访问分析和健康事件 |
|
||||
| 内网穿透 | 通过 Tunnel 将无法直接公网访问的内网 HTTP 服务暴露到互联网,享有 HTTPS、WAF 等全部防护能力 |
|
||||
|
||||
|
||||
## 网站配置约束
|
||||
|
||||
`proxy_routes` 从“单域名规则”升级为“网站配置”聚合对象。一条记录对应一个网站,可绑定一个或多个域名,并共享一组站点级配置。
|
||||
|
||||
约束:
|
||||
|
||||
* `proxy_routes.site_name` 是网站的业务唯一标识。
|
||||
* `proxy_routes.domains` 至少包含一个域名,且 `domains[0]` 作为主域名。
|
||||
* 任一域名全局只能属于一个 `proxy_routes`。
|
||||
* 迁移期可保留 `proxy_routes.domain` 作为 `domains[0]` 的镜像字段,但业务读写与后续扩展必须以 `site_name` + `domains` 为准。
|
||||
* 网站级流量限制、反向代理与缓存配置当前按站点共享,不在同一网站内做域名级差异化配置。
|
||||
* HTTPS 允许在同一站点内按域名绑定证书。
|
||||
|
||||
## 源站约束
|
||||
|
||||
`origins` 只保存源站地址、展示名与备注,不承载协议、端口、路径、权重或健康检查策略。
|
||||
|
||||
`proxy_routes` 可选关联一个 `origins` 记录,用于复用源站地址;规则仍保存完整 `origin_url` 快照以参与渲染与版本快照。
|
||||
|
||||
上游约束:
|
||||
|
||||
* `proxy_routes` 至少包含一个上游地址(直连类型),或关联一个 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://`。
|
||||
* 内网穿透类型上游必须关联 `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 约束
|
||||
|
||||
`proxy_routes.domain_cert_ids` 用于记录与 `domains` 平行的域名证书绑定;值为 `0` 表示该域名不启用 HTTPS,仅保留 HTTP。
|
||||
|
||||
发布渲染时:
|
||||
|
||||
* 带证书的域名按证书分组输出独立 `443 ssl` `server` 块。
|
||||
* 未绑定证书的域名不得被自动带入 HTTPS。
|
||||
* 必须将 `proxy_routes.domains` 中的全部域名一并纳入同一站点配置,避免同站点在版本快照中被拆散。
|
||||
|
||||
## WAF 约束
|
||||
|
||||
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`
|
||||
|
||||
判定顺序:
|
||||
|
||||
* 白名单是放行例外,任意启用规则组命中白名单即放行。
|
||||
* 未命中白名单时继续判断黑名单。
|
||||
* 多个黑名单命中时,全局规则组优先,其后按自定义规则组 ID 升序。
|
||||
|
||||
地域识别由 Agent 维护节点本地 MaxMind mmdb,OpenResty Lua 在请求路径中读取本地库。GeoIP 依赖不可用时只能跳过地域规则,不得影响 IP 规则与反向代理主链路。
|
||||
|
||||
## 认证源约束
|
||||
|
||||
`auth_sources` 是管理端第三方登录入口的配置对象,当前仅支持 `github` 与 `oidc` 两类。启用后的认证源会显示在登录页。
|
||||
|
||||
`external_accounts` 保存认证源外部账号与本地用户的绑定关系。第三方账号首次登录时:
|
||||
|
||||
* 已绑定本地用户则直接登录。
|
||||
* 当前已有本地登录 Session 时,绑定到当前用户。
|
||||
* 未绑定且允许注册时,自动创建普通用户并绑定。
|
||||
* 未绑定且关闭注册时,只允许用户输入已有本地账号密码完成绑定。
|
||||
|
||||
旧 `users.github_id` 仅作为升级迁移来源,新的第三方账号登录与绑定关系必须以 `external_accounts` 为准。
|
||||
|
||||
## 版本与观测约束
|
||||
|
||||
* `config_versions` 必须保存完整快照、渲染结果与 `checksum`。
|
||||
* 全局同时只能有一个激活版本。
|
||||
* 回滚通过重新激活旧版本实现。
|
||||
* `nodes` 只承载控制面状态与低频摘要,不承载高频观测事实。
|
||||
* 指标、趋势和访问分析优先使用服务端聚合结果,而不是前端临时统计。
|
||||
* 访问明细只保留受控时间窗口,不演变成通用日志平台。
|
||||
|
||||
## 文档维护原则
|
||||
|
||||
* 产品范围或系统边界变化时更新本文档。
|
||||
* 系统结构或模块职责变化时更新 [系统架构](./architecture.md)。
|
||||
* 发布、同步、回滚模型变化时更新 [发布模型](./release-model.md)。
|
||||
* 开发约束、代码规范、接口约定变化时更新 [开发约束](../guildline/development-constraints.md)。
|
||||
* 部署方式变化时更新 [部署说明](../reference/deployment.md) 与 README。
|
||||
* 配置项变化时更新 [配置项参考](../reference/configuration.md)。
|
||||
* 已完成阶段不再以“版本计划”形式回填。
|
||||
* 新阶段开始前,先补设计,再进入实现。
|
||||
@@ -0,0 +1,79 @@
|
||||
# 发布模型
|
||||
|
||||
你会学到:OpenFlare 为什么以完整配置版本为发布单位,发布、激活、Agent 应用和回滚分别如何工作。
|
||||
|
||||
OpenFlare 的发布模型以完整配置版本为中心,而不是在线修改节点配置。
|
||||
|
||||
标准链路:
|
||||
|
||||
```text
|
||||
修改规则 -> 预览 / 查看 diff -> 发布 -> 生成完整配置版本 -> 激活版本 -> Agent 拉取 -> 本地应用 -> 上报结果
|
||||
```
|
||||
|
||||
## 发布规则
|
||||
|
||||
Server 发布时必须:
|
||||
|
||||
1. 读取全部启用的 `proxy_routes`。
|
||||
2. 读取 Server 侧 OpenResty 主配置、性能参数、缓存参数和必要 Lua 资源。
|
||||
3. 读取域名与证书绑定关系。
|
||||
4. 读取 WAF 全局规则组、自定义规则组、IP 组引用与网站绑定关系。
|
||||
5. 保留 WAF 规则组引用的 IP 组 ID,渲染完整 OpenResty 配置与 WAF 运行时配置;IP 组成员不进入发布版本。
|
||||
6. 计算 `checksum`。
|
||||
7. 写入 `config_versions`。
|
||||
8. 切换激活版本。
|
||||
9. 让 Agent 在后续 heartbeat 中发现并应用。
|
||||
|
||||
版本号格式固定为 `YYYYMMDD-NNN`。
|
||||
|
||||
## 预览与发布
|
||||
|
||||
预览和 diff 是只读能力,不产生发布记录。
|
||||
|
||||
发布会生成新的完整配置版本。版本必须包含足够信息,让未来回滚时可以基于历史快照重新应用,而不依赖当前可变配置。
|
||||
|
||||
## 激活版本
|
||||
|
||||
全局同时只能有一个激活版本。当前不做按节点分组的差异化版本。
|
||||
|
||||
Agent 通过 heartbeat 获取激活版本摘要;当远端版本或 checksum 与本地状态不一致时,Agent 才进入同步流程。当 Agent WS 连接升级开启且连接可用时,Server 在发布或激活版本成功后会广播最新激活版本摘要,Agent 收到后复用普通同步流程立即拉取并应用配置。WS 不可用时仍按 HTTP heartbeat 间隔发现变更。
|
||||
|
||||
## 不可变历史
|
||||
|
||||
历史版本不可变。回滚不是修改旧版本,而是重新激活旧版本。
|
||||
|
||||
这样做的结果是:
|
||||
|
||||
* 每个版本都可以追溯。
|
||||
* 回滚链路与普通发布应用链路一致。
|
||||
* Agent 不需要理解“反向 patch”,只需要应用一个目标版本。
|
||||
|
||||
## Agent 应用策略
|
||||
|
||||
Agent 发现新版本后会:
|
||||
|
||||
1. 拉取目标版本详情。
|
||||
2. 备份旧文件。
|
||||
3. 写入主配置、路由配置、证书、必要 Lua 资源与 WAF/PoW 运行时配置。
|
||||
4. 执行 OpenResty 配置校验。
|
||||
5. reload;如果运行时未启动,则尝试用当前配置启动 OpenResty。
|
||||
6. 上报成功、警告或失败。
|
||||
|
||||
如果新配置激活失败,Agent 必须尝试恢复运行;回滚成功时上报警告。若本地没有历史主配置可回滚,Agent 会写入内置安全兜底配置并尝试拉起 OpenResty:该配置对外只监听 `80` 端口,不包含任何用户路由,统一返回 `503 Service Unavailable` 与 `OpenFlare: No Valid Configuration`,同时保留本地 `stub_status` 健康检查入口。兜底启动成功时仍阻断失败目标版本并上报警告;存在历史主配置但回滚后仍无法恢复运行时上报失败。
|
||||
|
||||
某个目标 `version + checksum` 一旦应用失败并回退,Agent 会在本地状态中阻断该目标重复应用。只有远端激活版本或 checksum 发生变化,才允许再次尝试。
|
||||
|
||||
## 设计约束
|
||||
|
||||
* 发布必须读取全部启用的网站配置,而不是只渲染本次修改对象。
|
||||
* 回滚通过重新激活旧版本实现,不修改历史版本。
|
||||
* Agent API 固定使用节点专属 `agent_token`,首次接入可使用 `discovery_token`。
|
||||
* Server 不提供远程 shell 或任意命令执行入口。
|
||||
* 配置版本必须保存完整快照、渲染结果和 `checksum`。
|
||||
* WAF 规则组、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 差异补齐。
|
||||
@@ -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/` | 静态资源 |
|
||||
@@ -1,230 +0,0 @@
|
||||
# OpenFlare 开发规范
|
||||
|
||||
本文档描述 OpenFlare `1.0.0` 正式版之后的开发基线。
|
||||
|
||||
超出 [docs/design.md](./design.md) 边界的需求,必须先更新设计文档。
|
||||
|
||||
## 1. 技术基线
|
||||
|
||||
### 1.1 Server
|
||||
|
||||
`openflare_server` 继续作为单体控制面:
|
||||
|
||||
* Go 1.24+
|
||||
* Gin
|
||||
* GORM
|
||||
* SQLite / PostgreSQL
|
||||
* 现有登录体系
|
||||
|
||||
### 1.2 Agent
|
||||
|
||||
`openflare_agent` 继续作为 Go 单体程序:
|
||||
|
||||
* Go 1.24+
|
||||
* 单二进制
|
||||
* 节点本地执行
|
||||
* `openresty_path` 优先
|
||||
* 无 `openresty_path` 时默认 Docker OpenResty
|
||||
|
||||
### 1.3 Frontend
|
||||
|
||||
前端基线以 `openflare_server/web` 为准:
|
||||
|
||||
* Next.js 15 App Router
|
||||
* React 19
|
||||
* TypeScript
|
||||
* Tailwind CSS 4
|
||||
* TanStack Query
|
||||
* React Hook Form + Zod
|
||||
* Zustand 仅用于轻量客户端状态
|
||||
|
||||
前端细则见 [docs/frontend-development-guidelines.md](./frontend-development-guidelines.md)。
|
||||
|
||||
## 2. 分层与目录约束
|
||||
|
||||
### 2.1 Server
|
||||
|
||||
* `controller/`:参数解析、调用 service、返回响应
|
||||
* `service/`:业务逻辑、校验、事务编排、渲染
|
||||
* `model/`:模型定义与持久化
|
||||
* `router/`:路由注册
|
||||
* `middleware/`:认证、鉴权、限流等横切逻辑
|
||||
* `common/`:配置、全局状态与初始化入口
|
||||
* `utils/`:纯工具函数与通用 helper
|
||||
|
||||
禁止:
|
||||
|
||||
* 在 `controller/` 堆积业务逻辑
|
||||
* 在 `middleware/` 实现业务流程
|
||||
* 为简单需求新增平台层抽象
|
||||
|
||||
### 2.2 Agent
|
||||
|
||||
保持现有模块边界:
|
||||
|
||||
* `config`
|
||||
* `heartbeat`
|
||||
* `sync`
|
||||
* `openresty`
|
||||
* `state`
|
||||
* `httpclient`
|
||||
* `protocol`
|
||||
* `internal/updater`
|
||||
|
||||
要求:
|
||||
|
||||
* 每个模块职责单一
|
||||
* 外部命令调用集中封装
|
||||
* 状态落盘与配置落盘分离
|
||||
|
||||
### 2.3 Frontend
|
||||
|
||||
前端分层保持:
|
||||
|
||||
* `app/`
|
||||
* `features/`
|
||||
* `components/`
|
||||
* `lib/`
|
||||
* `store/`
|
||||
* `types/`
|
||||
|
||||
要求:
|
||||
|
||||
* 页面路由与布局放在 `app/`
|
||||
* API 请求统一收敛到 `lib/api/`
|
||||
* 业务逻辑优先放在 `features/`
|
||||
|
||||
## 3. 数据模型规范
|
||||
|
||||
当前有效实体:
|
||||
|
||||
* `proxy_routes`
|
||||
* `origins`
|
||||
* `config_versions`
|
||||
* `nodes`
|
||||
* `node_system_profiles`
|
||||
* `apply_logs`
|
||||
* `tls_certificates`
|
||||
* `managed_domains`
|
||||
* `node_request_reports`
|
||||
* `node_access_logs`
|
||||
* `node_metric_snapshots`
|
||||
* `traffic_analytics_rollups`
|
||||
* `node_health_events`
|
||||
* `options`
|
||||
|
||||
通用约束:
|
||||
|
||||
* 不新增平台化对象,除非设计文档明确要求
|
||||
* `origins` 仅作为可复用源站地址目录,字段保持轻量;协议、端口、路径与查询参数继续归属具体 `proxy_routes`
|
||||
* `proxy_routes` 以“网站配置”作为聚合边界,必须包含唯一 `site_name` 与非空 `domains` 列表;数据库内部 `id` 可继续作为技术主键,但不能替代 `site_name` 的业务唯一性
|
||||
* `proxy_routes.domains` 中的每个域名都必须全局唯一;列表第一项视为主域名,创建时若未显式填写 `site_name`,则默认使用主域名
|
||||
* `proxy_routes` 继续允许保存一个或多个上游地址用于负载均衡,但不引入独立 `origin_pool`
|
||||
* 迁移期如保留遗留 `domain` 字段,只能作为 `domains[0]` 的兼容镜像;新代码不得继续以该字段作为唯一业务输入
|
||||
* `proxy_routes` 如关联 `origins`,必须同时保存可直接渲染的 `origin_url`;源站地址变更时,由 service 负责同步更新引用该源站的规则快照
|
||||
* `proxy_routes` 的上游统一使用 named `upstream` + keepalive;单上游如带 base path 或 query,应在 `proxy_pass` 上补回 URI,多上游仅允许纯 `scheme://host[:port]`
|
||||
* `proxy_routes.origin_host` 为可选字段,仅用于覆盖回源 `Host` 请求头,不引入新的平台化对象
|
||||
* 流量限制、反向代理、HTTPS 与缓存配置当前都归属站点级 `proxy_routes`,同一网站内不拆分域名级差异配置
|
||||
* `config_versions` 必须保存完整快照与渲染结果
|
||||
* 全局同时只能有一个激活版本
|
||||
* 回滚通过重新激活旧版本实现
|
||||
* `nodes` 只保留控制面状态与低频摘要
|
||||
* 观测数据必须按节点与时间窗口关联
|
||||
* 快照与聚合结果采用追加式模型,不覆盖历史
|
||||
* 原始访问明细必须有受控保留策略
|
||||
|
||||
### 3.1 数据库版本与迁移
|
||||
|
||||
* 任何涉及表结构、索引、列类型、分表规则或内部持久化元数据的修改,都必须同步提升数据库版本号
|
||||
* 数据库版本号定义在 `openflare_server/model`,不得只依赖 `AutoMigrate` 隐式升级存量数据库
|
||||
* 每次提升数据库版本号时,必须补充从上一版本升级到新版本的显式迁移方法
|
||||
* 迁移方法必须包含升级后的校验逻辑;只有校验通过,才能写入新的数据库版本记录
|
||||
* 新包启动后必须先检查数据库当前版本,再按顺序逐步升级到目标版本;禁止跳过中间升级步骤直接写目标版本
|
||||
* 空库初始化可以直接建立当前版本结构,但初始化完成后仍必须执行同版本校验,并落库当前数据库版本
|
||||
* 数据库版本元数据属于内部控制信息,必须保存在独立内部表中,不能混入业务配置表
|
||||
* 如果迁移失败或校验失败,启动流程必须中止,且不得提升数据库版本记录
|
||||
* 涉及数据库版本变更的提交,必须补充对应的迁移测试或等效回归测试
|
||||
|
||||
## 4. API 与鉴权规范
|
||||
|
||||
### 4.1 API
|
||||
|
||||
* 管理端与 Agent API 统一使用 JSON
|
||||
* 成功与失败都必须返回清晰 `message`
|
||||
* Agent API 固定放在 `/api/agent/*`
|
||||
* 总览与节点详情优先使用专用聚合接口
|
||||
* 管理端变更类接口统一使用 `POST`;只读接口使用 `GET`
|
||||
|
||||
统一响应结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 鉴权
|
||||
|
||||
管理端:
|
||||
|
||||
* 继续复用现有登录、角色与 Session
|
||||
|
||||
Agent:
|
||||
|
||||
* 正式请求统一使用节点专属 `agent_token`
|
||||
* 首次接入可使用全局 `discovery_token`
|
||||
* 请求头统一使用 `X-Agent-Token`
|
||||
|
||||
禁止:
|
||||
|
||||
* 暴露远程 shell 或任意命令执行入口
|
||||
* 在日志中打印完整 Token
|
||||
* 允许绕过占位符约束保存不可渲染的主配置模板
|
||||
|
||||
## 5. 发布与运行规范
|
||||
|
||||
发布逻辑必须保持以下事实:
|
||||
|
||||
* 发布时读取全部启用的 `proxy_routes`
|
||||
* 同时读取 OpenResty 主配置参数、反代性能参数与缓存参数
|
||||
* 生成完整 OpenResty 配置
|
||||
* 计算 `checksum`
|
||||
* 写入 `config_versions`
|
||||
* 通过切换 `is_active` 激活版本
|
||||
|
||||
版本约束:
|
||||
|
||||
* 版本号格式固定为 `YYYYMMDD-NNN`
|
||||
* 不在线修改历史版本
|
||||
* 不做按节点分组的差异化版本
|
||||
* 预览与 diff 是只读能力,不产生发布记录
|
||||
|
||||
Agent 必须满足:
|
||||
|
||||
* 启动后读取或生成本地 `node_id`
|
||||
* 周期性心跳与同步
|
||||
* 常规同步优先依据 heartbeat 返回的版本摘要判断
|
||||
* 发现新版本时先备份旧文件
|
||||
* 写入主配置、路由配置与必要证书文件
|
||||
* 写入新配置后以运行态恢复为目标执行激活,Docker 模式优先重建容器并确认容器保持运行
|
||||
* 新配置激活失败时必须先尝试用目标配置恢复运行,再回滚到旧配置并重新拉起 OpenResty
|
||||
* 回滚后 OpenResty 恢复正常时上报警告;回滚后仍无法恢复运行时上报失败
|
||||
* 某个目标 `version + checksum` 一旦应用失败并回退,Agent 必须在本地状态中阻断该目标的重复应用;只有远端激活版本或 checksum 发生变化时,才允许再次尝试
|
||||
|
||||
## 6. 测试与交付要求
|
||||
|
||||
* 关键业务逻辑必须有单元测试或等效回归测试
|
||||
* Agent 主链路修改必须验证同步、应用与回滚
|
||||
* 前端页面至少覆盖加载态、空态、错误态与成功反馈
|
||||
* Go 版本调整时,同步检查 `go.mod`、Dockerfile 与 CI 工作流
|
||||
|
||||
## 7. 文档维护要求
|
||||
|
||||
当以下内容变化时,必须同步更新对应文档:
|
||||
|
||||
* 产品范围或系统边界变化:更新 `docs/design.md`
|
||||
* 开发约束、接口约定、测试基线变化:更新本文档
|
||||
* 前端工程约束变化:更新 `docs/frontend-development-guidelines.md`
|
||||
* 配置项或部署方式变化:更新 `docs/app-config.md`、`docs/deployment.md` 与 `README.md`
|
||||
@@ -1,61 +0,0 @@
|
||||
# OpenFlare 开发计划
|
||||
|
||||
## 1. 当前结论
|
||||
|
||||
* 第一版至第六版的主线能力已经全部完成
|
||||
* `1.0.0` 是当前正式基线
|
||||
* 已完成阶段的过程性任务以代码、测试与 Git 历史为准
|
||||
* 新工作优先以缺陷修复、可维护性改进、文档与测试补强为主
|
||||
|
||||
## 2. 当前优先级
|
||||
|
||||
当前开发应优先关注:
|
||||
|
||||
1. 稳定性
|
||||
2. 升级与回滚链路可靠性
|
||||
3. 文档准确性
|
||||
4. 测试覆盖补强
|
||||
5. 在既有边界内的小步迭代
|
||||
|
||||
## 3. 变更准入原则
|
||||
|
||||
新需求进入实现前,按以下顺序判断:
|
||||
|
||||
1. 是否符合 [docs/design.md](./design.md) 的产品边界
|
||||
2. 是否符合 [docs/development-guidelines.md](./development-guidelines.md) 与前端规范
|
||||
3. 是否会破坏现有发布、同步、回滚或升级主链路
|
||||
4. 是否需要同步更新部署、配置或 README 文档
|
||||
|
||||
如果答案包含“超出边界”或“引入新基础设施”,先修改设计文档,再开始实现。
|
||||
|
||||
## 4. 当前验收标准
|
||||
|
||||
任何合入正式基线的改动,至少应满足:
|
||||
|
||||
* 不破坏 Agent 心跳、同步、发布与回滚主链路
|
||||
* 不破坏现有 OpenResty 主配置托管模型
|
||||
* 不降低总览、节点详情与访问分析的既有可用性
|
||||
* 有与风险相称的测试或联调验证
|
||||
* 文档与代码保持一致
|
||||
|
||||
## 5. 后续维护方式
|
||||
|
||||
后续规划不再按“大版本阶段文档”维护,而采用以下方式:
|
||||
|
||||
* 产品边界变动:更新 `docs/design.md`
|
||||
* 工程约束变动:更新 `docs/development-guidelines.md`
|
||||
* 前端工程变动:更新前端相关规范文档
|
||||
* 部署与配置变动:更新 `README.md`、`docs/deployment.md`、`docs/app-config.md`
|
||||
|
||||
如果未来出现明确的新阶段目标,再单独新增专项计划文档;不要把已完成的历史计划继续堆回本文件。
|
||||
|
||||
## 6. 当前专项计划
|
||||
|
||||
已确认需要推进“网站级规则与配置界面改造”专项,详细需求、实施顺序与验收标准见 [docs/website-configuration-redesign.md](./website-configuration-redesign.md)。
|
||||
|
||||
本专项的执行顺序固定为:
|
||||
|
||||
1. 先完成数据模型与配置渲染兼容方案
|
||||
2. 再调整接口、校验与版本 diff 语义
|
||||
3. 然后改造规则列表与网站配置子页面
|
||||
4. 最后补齐迁移、回归测试与文档联动
|
||||
@@ -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".
|
||||
@@ -0,0 +1,84 @@
|
||||
import { defineAdditionalConfig, type DefaultTheme } from 'vitepress'
|
||||
|
||||
export default defineAdditionalConfig({
|
||||
description:
|
||||
'OpenFlare is a lightweight, self-hosted OpenResty control plane for reverse proxy rules, releases, node sync, TLS certificates, and basic observability.',
|
||||
|
||||
themeConfig: {
|
||||
nav: nav(),
|
||||
|
||||
sidebar: {
|
||||
'/en/guide/': { base: '/en/guide/', items: sidebarGuide() },
|
||||
'/en/reference/': { base: '/en/reference/', items: sidebarReference() },
|
||||
'/en/design/': { base: '/en/design/', items: sidebarDesign() }
|
||||
},
|
||||
|
||||
editLink: {
|
||||
pattern: 'https://github.com/Rain-kl/OpenFlare/edit/main/docs/:path',
|
||||
text: 'Edit this page on GitHub'
|
||||
},
|
||||
|
||||
footer: {
|
||||
message: 'Released under the Apache License 2.0.',
|
||||
copyright: 'Copyright © OpenFlare contributors'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function nav(): DefaultTheme.NavItem[] {
|
||||
return [
|
||||
{ text: 'Guide', link: '/en/guide/', activeMatch: '/en/guide/' },
|
||||
{ text: 'Reference', link: '/en/reference/', activeMatch: '/en/reference/' },
|
||||
{ text: 'Design', link: '/en/design/', activeMatch: '/en/design/' }
|
||||
]
|
||||
}
|
||||
|
||||
function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
||||
return [
|
||||
{
|
||||
text: 'Guide',
|
||||
items: [
|
||||
{ text: 'Overview', link: '' },
|
||||
{ text: 'Quick Start', link: 'quick-start' },
|
||||
{ text: 'Usage', link: 'usage' },
|
||||
{ text: 'Deployment', link: 'deployment' },
|
||||
{ text: 'SSO Login', link: 'sso' },
|
||||
{ text: 'Run Server', link: 'server' },
|
||||
{ text: 'Connect Agent', link: 'agent' },
|
||||
{ text: 'Publish First Site', link: 'first-site' },
|
||||
{ text: 'Upgrade and Maintenance', link: 'upgrade' },
|
||||
{ text: 'Local Development', link: 'development' },
|
||||
{ text: 'Troubleshooting', link: 'troubleshooting' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function sidebarReference(): DefaultTheme.SidebarItem[] {
|
||||
return [
|
||||
{
|
||||
text: 'Reference',
|
||||
items: [
|
||||
{ text: 'Overview', link: '' },
|
||||
{ text: 'Configuration', link: 'configuration' },
|
||||
{ text: 'Commands and Scripts', link: 'cli' },
|
||||
{ text: 'API Conventions', link: 'api' },
|
||||
{ text: 'Repository Layout', link: 'repository' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function sidebarDesign(): DefaultTheme.SidebarItem[] {
|
||||
return [
|
||||
{
|
||||
text: 'Design',
|
||||
items: [
|
||||
{ text: 'Product Boundary', link: '' },
|
||||
{ text: 'Architecture', link: 'architecture' },
|
||||
{ text: 'Release Model', link: 'release-model' },
|
||||
{ text: 'Development Constraints', link: 'development' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
# System Architecture
|
||||
|
||||
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
|
||||
Browser
|
||||
|
|
||||
| Management UI / API
|
||||
v
|
||||
OpenFlare Server (Gin + GORM + SQLite/PostgreSQL)
|
||||
|
|
||||
| Agent API / heartbeat / config pull
|
||||
v
|
||||
OpenFlare Agent
|
||||
|
|
||||
| write config / openresty -t / reload / rollback
|
||||
v
|
||||
OpenResty binary
|
||||
|
|
||||
| reverse proxy
|
||||
v
|
||||
Origin
|
||||
```
|
||||
|
||||
## 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:
|
||||
|
||||
* 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 Go monolithic application:
|
||||
|
||||
* 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 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)
|
||||
@@ -0,0 +1,315 @@
|
||||
# Development Constraints
|
||||
|
||||
You will learn: The admission criteria for OpenFlare code modifications, backend/Agent/frontend tiered constraints, data model boundaries, API conventions, database migration requirements, and test delivery baselines.
|
||||
|
||||
This document integrates the original development specifications, frontend specifications, and development plans, and serves as the engineering constraints entry point for OpenFlare after `1.0.0`.
|
||||
|
||||
## Current Conclusions
|
||||
|
||||
* The mainline capabilities of the first to sixth versions have all been completed.
|
||||
* `1.0.0` is the current official baseline.
|
||||
* Procedural tasks of completed stages are subject to code, tests, and Git history.
|
||||
* Priority for new work is given to bug fixes, maintainability improvements, and documentation and test reinforcement.
|
||||
|
||||
Current Development Priorities:
|
||||
|
||||
1. Stability.
|
||||
2. Upgrade and rollback link reliability.
|
||||
3. Document accuracy.
|
||||
4. Test coverage reinforcement.
|
||||
5. Small iterations within existing boundaries.
|
||||
|
||||
## Change Admission
|
||||
|
||||
Before new requirements enter implementation, judge them in the following order:
|
||||
|
||||
1. Whether it fits the [Product Boundary](./index.md).
|
||||
2. Whether it follows the backend, Agent, and frontend constraints in this document.
|
||||
3. Whether it risks breaking the existing publish, sync, rollback, or upgrade main links.
|
||||
4. Whether it requires synchronized updates to deployment, configuration, README, or documentation site pages.
|
||||
|
||||
If a requirement expands the boundary or introduces new infrastructure, the design documentation must be updated first before starting implementation.
|
||||
|
||||
Any changes merged into the official baseline must at least meet:
|
||||
|
||||
* Does not break the Agent heartbeat, synchronization, publishing, and rollback main links.
|
||||
* Does not break the existing OpenResty main configuration hosting model.
|
||||
* Does not degrade the existing availability of the overview, node details, and access analysis.
|
||||
* Has tests or joint debugging verification commensurate with the risks.
|
||||
* Documentation remains consistent with the code.
|
||||
|
||||
## Technical Baseline
|
||||
|
||||
Server:
|
||||
|
||||
* Go 1.25+
|
||||
* Gin
|
||||
* GORM
|
||||
* SQLite / PostgreSQL
|
||||
* Existing login system
|
||||
|
||||
Agent:
|
||||
|
||||
* Single binary
|
||||
* Node-local execution
|
||||
* Control OpenResty binary via `openresty_path` or default `openresty`
|
||||
* Docker deployment uses the Agent image with built-in OpenResty, and does not have the Agent control a separate OpenResty container
|
||||
|
||||
Frontend:
|
||||
|
||||
* Next.js 15 App Router
|
||||
* React 19
|
||||
* TypeScript 5
|
||||
* Tailwind CSS 4
|
||||
* TanStack Query
|
||||
* React Hook Form + Zod
|
||||
* Zustand only used for lightweight client status
|
||||
* ESLint + Prettier
|
||||
* Vitest + Testing Library + Playwright
|
||||
* pnpm
|
||||
|
||||
## Server Layering
|
||||
|
||||
| Directory | Responsibility |
|
||||
| --- | --- |
|
||||
| `controller/` | Parameter parsing, calling services, returning responses |
|
||||
| `service/` | Business logic, verification, transaction orchestration, rendering |
|
||||
| `model/` | Model definition and persistence |
|
||||
| `router/` | Route registration |
|
||||
| `middleware/` | Auth, authorization, rate limiting, and other cross-cutting logic |
|
||||
| `common/` | Configuration, global state, and initialization entry points |
|
||||
| `utils/` | Pure utility functions and general helpers |
|
||||
|
||||
It is forbidden to accumulate business logic in `controller/`, forbidden to implement business flows in `middleware/`, and forbidden to add platform-level abstractions for simple requirements.
|
||||
|
||||
## Agent Layering
|
||||
|
||||
The Agent maintains its existing module boundaries:
|
||||
|
||||
* `config`
|
||||
* `heartbeat`
|
||||
* `sync`
|
||||
* `openresty` / `nginx`
|
||||
* `state`
|
||||
* `httpclient`
|
||||
* `protocol`
|
||||
* `internal/updater`
|
||||
|
||||
Requirements:
|
||||
|
||||
* Each module has a single responsibility.
|
||||
* External command calls are centrally encapsulated.
|
||||
* State persistence and configuration persistence are separated.
|
||||
|
||||
## Frontend Layering
|
||||
|
||||
Recommended directories:
|
||||
|
||||
```text
|
||||
app/
|
||||
components/
|
||||
features/
|
||||
lib/
|
||||
hooks/
|
||||
store/
|
||||
types/
|
||||
styles/
|
||||
tests/
|
||||
```
|
||||
|
||||
Responsibility constraints:
|
||||
|
||||
* `app/`: Routes, layouts, page assembly.
|
||||
* `features/`: Organize modules by business domains.
|
||||
* `components/`: Reuse components across features.
|
||||
* `lib/`: Request client, environment variables, utility functions, constants.
|
||||
* `store/`: A small amount of cross-page UI state.
|
||||
* `types/`: Shared type definitions.
|
||||
|
||||
Page files are only responsible for obtaining routing parameters, organizing page structures, and calling feature components; they should not handwrite complex API details, complex form verification logic, or maintain a large amount of mutually coupled local states.
|
||||
|
||||
## Data Model Specifications
|
||||
|
||||
Currently active entities:
|
||||
|
||||
* `proxy_routes`
|
||||
* `origins`
|
||||
* `config_versions`
|
||||
* `nodes`
|
||||
* `auth_sources`
|
||||
* `external_accounts`
|
||||
* `node_system_profiles`
|
||||
* `apply_logs`
|
||||
* `tls_certificates`
|
||||
* `managed_domains`
|
||||
* `node_request_reports`
|
||||
* `node_access_logs`
|
||||
* `node_metric_snapshots`
|
||||
* `traffic_analytics_rollups`
|
||||
* `node_health_events`
|
||||
* `options`
|
||||
* `waf_rule_groups`
|
||||
* `waf_rule_group_bindings`
|
||||
|
||||
General constraints:
|
||||
|
||||
* No new platform-oriented objects are added unless explicitly required by the design document.
|
||||
* `origins` only serves as a reusable origin address directory, and the fields are kept lightweight.
|
||||
* `proxy_routes` uses "site configuration" as the aggregation boundary and must contain a unique `site_name` and a non-empty `domains` list.
|
||||
* Each domain in `proxy_routes.domains` must be globally unique, and the first item in the list is treated as the primary domain.
|
||||
* `proxy_routes` continues to allow saving one or more upstream addresses for load balancing, but does not introduce an independent `origin_pool`.
|
||||
* The legacy `domain` field can only be used as a compatible mirror of `domains[0]`; new code must not continue to use this field as the unique business input.
|
||||
* If `proxy_routes` is associated with `origins`, it must also save the `origin_url` that can be directly rendered.
|
||||
* Upstreams uniformly use named `upstream` + keepalive; for a single upstream carrying a base path or query, the original URI should be added back to `proxy_pass`. For multiple upstreams, only pure `scheme://host[:port]` is allowed.
|
||||
* Rate limits, reverse proxy, and cache configurations currently belong to the site-level `proxy_routes`.
|
||||
* HTTPS certificate binding must be saved on a per-domain basis through `domain_cert_ids` parallel to `domains`; domains not bound to a certificate must not participate in HTTPS rendering.
|
||||
* 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.
|
||||
* `nodes` only retains control plane status and low-frequency summaries.
|
||||
* Observability data must be associated with nodes and time windows, and snapshots and aggregation results use an append-only model.
|
||||
* Original access details must have a controlled retention policy.
|
||||
* `auth_sources` only saves management console third-party login source configurations, currently supporting `github` and `oidc`.
|
||||
* `external_accounts` is the unique source of binding between third-party accounts and local users; the old `users.github_id` is only used for compatible migration and must not be used as the business input for the new login flow.
|
||||
|
||||
## Database Migration
|
||||
|
||||
Any modification involving table structures, indexes, column types, sharding rules, or internal persistence metadata must upgrade the database version number in sync.
|
||||
|
||||
The database version number is defined in `openflare_server/model`, and it must not rely solely on `AutoMigrate` for implicit upgrades of existing databases.
|
||||
|
||||
Every time the database version number is upgraded, an explicit migration method from the previous version to the new version must be added. The migration method must contain validation logic after the upgrade; only when the validation passes can the new database version record be written.
|
||||
|
||||
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.
|
||||
|
||||
If the migration or validation fails, the startup process must abort, and the database version record must not be upgraded. Submissions involving database version changes must add corresponding migration tests or equivalent regression tests.
|
||||
|
||||
## API and Authentication
|
||||
|
||||
The management console and Agent APIs uniformly use JSON. Both success and failure must return a clear `message`:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
Conventions:
|
||||
|
||||
* Agent APIs are uniformly placed under `/api/agent/*`.
|
||||
* The overview and node details prioritize using dedicated aggregation interfaces.
|
||||
* Management console mutation APIs uniformly use `POST`; read-only APIs use `GET`.
|
||||
* The management console continues to reuse existing logins, roles, and Sessions.
|
||||
* Third-party login uniformly enters through authentication source APIs; authentication source management interfaces must require Root Session.
|
||||
* `/api/status` can only return the public fields of enabled authentication sources, and must not return the Client Secret.
|
||||
* When a third-party account is not bound and registration is closed, a process to bind to an existing account should be provided, and users must not be automatically created.
|
||||
* Official Agent requests uniformly use the node-exclusive `agent_token`.
|
||||
* The first access can use the global `discovery_token`.
|
||||
* Agent request headers uniformly use `X-Agent-Token`.
|
||||
|
||||
It is forbidden to expose remote shell or arbitrary command execution entries, forbidden to print full Tokens in logs, and forbidden to save main configuration templates that bypass placeholder constraints.
|
||||
|
||||
## Publishing and Runtime
|
||||
|
||||
The publishing logic must maintain:
|
||||
|
||||
* Read all enabled `proxy_routes` during publishing.
|
||||
* Read OpenResty main configuration parameters, reverse proxy performance parameters, and cache parameters at the same time.
|
||||
* Generate complete OpenResty configuration.
|
||||
* Calculate `checksum`.
|
||||
* Write to `config_versions`.
|
||||
* Activate the version by switching `is_active`.
|
||||
|
||||
Version constraints:
|
||||
|
||||
* The version number format is fixed as `YYYYMMDD-NNN`.
|
||||
* Do not modify historical versions online.
|
||||
* Do not make differentiated versions grouped by nodes.
|
||||
* Preview and diff are read-only capabilities and do not generate release records.
|
||||
|
||||
The Agent must satisfy:
|
||||
|
||||
* Read or generate local `node_id` after startup.
|
||||
* Periodic 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; 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
|
||||
|
||||
All API requests must be uniformly routed through `lib/api/`:
|
||||
|
||||
* Uniformly handle the `success/message/data` response structure.
|
||||
* Uniformly handle authentication failure, network exceptions, and general error messages.
|
||||
* Centralize maintenance of resource interfaces and request paths.
|
||||
|
||||
State Layering:
|
||||
|
||||
* Server state: TanStack Query.
|
||||
* Page temporary state: Component-internal `useState`.
|
||||
* Cross-page UI state: Zustand.
|
||||
|
||||
Strict TypeScript mode is required; abuse of `any` is prohibited. API responses, form inputs, and business entities must have explicit types.
|
||||
|
||||
## Forms, Interaction, Style, and Themes
|
||||
|
||||
Forms uniformly use React Hook Form and Zod.
|
||||
|
||||
High-risk operations must have double confirmation, show the name of the operation object, and clearly provide success and failure feedback.
|
||||
|
||||
Style principles:
|
||||
|
||||
* Uniformly use Tailwind CSS and the existing token system.
|
||||
* Prioritize reusing existing basic components and layout components.
|
||||
* Maintain consistent visual hierarchy, padding, and semantic colors.
|
||||
|
||||
Theme requirements:
|
||||
|
||||
* Support `light`, `dark`, and `system` simultaneously.
|
||||
* User choices must be persisted.
|
||||
* Try to avoid theme flickering on the first screen.
|
||||
|
||||
## Test and Delivery
|
||||
|
||||
* Key business logic must have unit tests or equivalent regression tests.
|
||||
* Agent main link modifications must verify synchronization, application, and rollback.
|
||||
* Frontend pages must cover at least loading states, empty states, error states, and success feedback.
|
||||
* When the Go version is adjusted, check `go.mod`, Dockerfile, and CI workflows in sync.
|
||||
|
||||
## Subsequent Maintenance
|
||||
|
||||
Subsequent planning is no longer maintained in the form of "major version phase documents", but adopts the following methods:
|
||||
|
||||
* Product boundary changes: Update [Product Boundary](./index.md).
|
||||
* Engineering constraint changes: Update this document.
|
||||
* Deployment and configuration changes: Update [Deployment Guide](../guide/deployment.md), [Configuration Items](../reference/configuration.md), and README.
|
||||
|
||||
If explicit new stage goals appear in the future, add dedicated planning documents separately; do not pile completed historical plans back into this document.
|
||||
|
||||
The model boundary of the current special topic "Site-level Rules and Configuration Interface Reconstruction" has been integrated into the [Product Boundary](./index.md). When executing, still advance in the order of data models, interfaces, frontend pages, migration tests, and document linkage.
|
||||
@@ -0,0 +1,171 @@
|
||||
# Product Boundary
|
||||
|
||||
You will learn: What OpenFlare is, what problems it solves, who the target users are, what the current stable capabilities are, and which design boundaries cannot be bypassed during implementation.
|
||||
|
||||
OpenFlare is a self-hosted OpenResty control plane oriented toward single-team or single-organization internal operation and maintenance (O&M) scenarios. It resolves the issues of scattered management in reverse proxy configuration, node synchronization, certificate hosting, configuration release/rollback, and basic observability.
|
||||
|
||||
## Project Positioning
|
||||
|
||||
OpenFlare is suitable for teams that need to centrally manage multiple OpenResty proxy nodes:
|
||||
|
||||
* Want to maintain reverse proxy site configurations using a management console.
|
||||
* Want every configuration change to have a complete version, preview, activation, and rollback.
|
||||
* Want nodes to actively sync configuration, rather than having the control plane SSH into nodes to execute commands.
|
||||
* Want to manage TLS certificates, domain assets, node statuses, and basic access analytics within the same system.
|
||||
|
||||
OpenFlare is currently not positioned as a general-purpose log platform, service mesh, Kubernetes Ingress Controller, or multi-tenant cloud platform.
|
||||
|
||||
## Target Users
|
||||
|
||||
| User | Needs |
|
||||
| --- | --- |
|
||||
| Self-hosted users | Quickly deploy a visual OpenResty control plane |
|
||||
| Internal O&M teams | Manage multiple reverse proxy nodes, certificates, and configuration versions |
|
||||
| Development teams | Provide a unified entry point and basic access analytics for internal services |
|
||||
| Contributors | Fix defects, strengthen tests, and improve documentation within clear boundaries |
|
||||
|
||||
## Current Stable Capabilities
|
||||
|
||||
| Capability | Description |
|
||||
| --- | --- |
|
||||
| Reverse Proxy Rule Management | Uses site configuration as the aggregation boundary, supporting multi-domain and origin configuration |
|
||||
| Site-level Configuration | One rule corresponds to one site, which can bind one or more domains and share site-level configuration |
|
||||
| Origin Management | Maintains a lightweight origin directory and allows sites to save renderable origin snapshots |
|
||||
| Configuration Versioning | Supports preview, publishing, activation, immutable history, and rollback |
|
||||
| Agent Synchronization | Supports registration, heartbeat, synchronization, application result reporting, and self-updating |
|
||||
| OpenResty Hosting | Manages main configuration templates, performance parameters, cache parameters, and Lua resources |
|
||||
| HTTPS/TLS | Hosts certificates and domain assets, and binds certificates on a per-domain basis |
|
||||
| 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 |
|
||||
| Auth Source Login | Supports configuring GitHub and standard OIDC login entries as authentication sources, allowing third-party accounts to bind to existing local users |
|
||||
|
||||
Default working method:
|
||||
|
||||
* All nodes consume the same globally activated version.
|
||||
* The Server saves configuration and status, and does not directly manage nodes via SSH.
|
||||
* The Agent is the only controlled landing entry point on the node side.
|
||||
|
||||
## Typical Use Cases
|
||||
|
||||
| Scenario | Description |
|
||||
| --- | --- |
|
||||
| Unified Entry for Internal Services | Expose multiple internal HTTP services through a unified domain and certificate |
|
||||
| Config Sync for Multi-node Reverse Proxy | Multiple OpenResty nodes consume the same activated configuration |
|
||||
| Config Change Review | View preview or diff before publishing, and retain immutable history after publishing |
|
||||
| Quick Rollback | Reactivate an older version, letting the Agent pull and apply it |
|
||||
| Certificate Hosting | Bind TLS certificates for different domains |
|
||||
| Basic Observability | View node status, request aggregation, access analytics, and health events |
|
||||
|
||||
## Core Objects
|
||||
|
||||
Currently active entities:
|
||||
|
||||
* `proxy_routes`
|
||||
* `origins`
|
||||
* `config_versions`
|
||||
* `nodes`
|
||||
* `auth_sources`
|
||||
* `external_accounts`
|
||||
* `node_system_profiles`
|
||||
* `apply_logs`
|
||||
* `tls_certificates`
|
||||
* `managed_domains`
|
||||
* `node_request_reports`
|
||||
* `node_access_logs`
|
||||
* `node_metric_snapshots`
|
||||
* `traffic_analytics_rollups`
|
||||
* `node_health_events`
|
||||
* `waf_rule_groups`
|
||||
* `waf_rule_group_bindings`
|
||||
|
||||
## Site Configuration Constraints
|
||||
|
||||
`proxy_routes` is upgraded from a "single-domain rule" to a "site configuration" aggregate object. One record corresponds to one website, which can bind one or more domains and share a set of site-level configurations.
|
||||
|
||||
Constraints:
|
||||
|
||||
* `proxy_routes.site_name` is the unique business identifier of the website.
|
||||
* `proxy_routes.domains` contains at least one domain, and `domains[0]` is used as the primary domain.
|
||||
* Any domain can globally belong to only one `proxy_routes`.
|
||||
* During the migration period, `proxy_routes.domain` can be kept as a mirror field of `domains[0]`, but business read/write and subsequent extensions must be based on `site_name` + `domains`.
|
||||
* Site-level rate limits, reverse proxies, and cache configurations are currently shared by site and are not configured differently on a per-domain basis within the same website.
|
||||
* HTTPS allows binding certificates per domain within the same site.
|
||||
|
||||
## Origin Constraints
|
||||
|
||||
`origins` only saves the origin address, display name, and remarks, and does not carry protocols, ports, paths, weights, or health check policies.
|
||||
|
||||
`proxy_routes` can optionally associate an `origins` record to reuse the origin address; the rule still saves a complete `origin_url` snapshot to participate in rendering and version snapshots.
|
||||
|
||||
Upstream constraints:
|
||||
|
||||
* `proxy_routes` must contain at least one upstream address.
|
||||
* To maintain compatibility with historical data, the `origin_url` main upstream field is retained, and multiple upstreams are allowed to be added within the same rule for load balancing.
|
||||
* Upstreams are rendered uniformly as a named `upstream` with keepalive.
|
||||
* A single upstream can carry a base path or query and append it in `proxy_pass`.
|
||||
* Multiple upstreams are restricted to pure `scheme://host[:port]`.
|
||||
* `proxy_routes.origin_host` is an optional field, used to override the `Host` request header when back-origin.
|
||||
* All upstream addresses must be legal `http://` or `https://`.
|
||||
|
||||
## HTTPS Constraints
|
||||
|
||||
`proxy_routes.domain_cert_ids` is used to record domain-certificate bindings parallel to `domains`; a value of `0` indicates that HTTPS is not enabled for the domain, retaining only HTTP.
|
||||
|
||||
During publishing rendering:
|
||||
|
||||
* Domains with certificates are output as separate `443 ssl` `server` blocks grouped by certificate.
|
||||
* Domains not bound to a certificate must not be automatically brought into HTTPS.
|
||||
* All domains in `proxy_routes.domains` must be included in the same site configuration to avoid the same site being split in version snapshots.
|
||||
|
||||
## 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.
|
||||
|
||||
`external_accounts` saves the binding relationship between external accounts of authentication sources and local users. When a third-party account logs in for the first time:
|
||||
|
||||
* If it is bound to a local user, it logs in directly.
|
||||
* If there is an existing local login session, it binds to the current user.
|
||||
* If it is not bound and registration is allowed, a normal user is automatically created and bound.
|
||||
* If it is not bound and registration is closed, the user is only allowed to enter an existing local account and password to complete the binding.
|
||||
|
||||
The old `users.github_id` only serves as a source for upgrade migration; new third-party account login and binding relationships must be based on `external_accounts`.
|
||||
|
||||
## Version and Observability Constraints
|
||||
|
||||
* `config_versions` must save complete snapshots, rendering results, and `checksum`.
|
||||
* There can only be one activated version globally at a time.
|
||||
* Rollback is achieved by reactivating older versions.
|
||||
* `nodes` only carries control plane status and low-frequency summaries, not high-frequency observability facts.
|
||||
* Metrics, trends, and access analytics prioritize server-side aggregation results, rather than temporary frontend statistics.
|
||||
* Access details are only retained for controlled time windows, not evolving into a general-purpose log platform.
|
||||
|
||||
## Documentation Maintenance Principles
|
||||
|
||||
* Update this document when the product scope or system boundary changes.
|
||||
* Update [System Architecture](./architecture.md) when the system structure or module responsibilities change.
|
||||
* Update [Release Model](./release-model.md) when the release, synchronization, or rollback model changes.
|
||||
* Update [Development Constraints](./development.md) when development constraints, code specifications, or interface conventions change.
|
||||
* Update [Deployment Guide](../guide/deployment.md) and README when deployment methods change.
|
||||
* Update [Configuration Reference](../reference/configuration.md) when configuration items change.
|
||||
* Completed phases will no longer be backfilled in the form of "version plans".
|
||||
* Before starting a new phase, complete the design first, then enter implementation.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Release Model
|
||||
|
||||
You will learn: Why OpenFlare uses a complete configuration version as the release unit, and how publishing, activation, Agent application, and rollback work.
|
||||
|
||||
OpenFlare's release model is centered on complete configuration versions rather than modifying node configurations online.
|
||||
|
||||
Standard link:
|
||||
|
||||
```text
|
||||
Modify rules -> Preview / View diff -> Publish -> Generate complete configuration version -> Activate version -> Agent pulls -> Local application -> Report result
|
||||
```
|
||||
|
||||
## Publishing Rules
|
||||
|
||||
When publishing, the Server must:
|
||||
|
||||
1. Read all enabled `proxy_routes`.
|
||||
2. Read the Server side OpenResty main configuration template, performance parameters, cache parameters, and necessary Lua resources.
|
||||
3. Read domain and certificate binding relationships.
|
||||
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`.
|
||||
|
||||
## Preview and Publishing
|
||||
|
||||
Preview and diff are read-only capabilities and do not generate release records.
|
||||
|
||||
Publishing generates a new complete configuration version. The version must contain sufficient information so that future rollbacks can be re-applied based on historical snapshots, without relying on current mutable configurations.
|
||||
|
||||
## Activating Version
|
||||
|
||||
There can only be one activated version globally at a time. Differentiated versions grouped by nodes are currently not supported.
|
||||
|
||||
The Agent obtains the activated version summary through the heartbeat; only when the remote version or checksum is inconsistent with the local state does the Agent enter the synchronization flow. 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
|
||||
|
||||
Historical versions are immutable. Rollback is not achieved by modifying older versions, but by reactivating older versions.
|
||||
|
||||
The result of doing this is:
|
||||
|
||||
* Every version can be traced back.
|
||||
* The rollback link is consistent with the ordinary release application link.
|
||||
* The Agent does not need to understand "reverse patch", but only needs to apply a target version.
|
||||
|
||||
## Agent Application Policy
|
||||
|
||||
When discovering a new version, the Agent will:
|
||||
|
||||
1. Pull the details of the target version.
|
||||
2. Back up old files.
|
||||
3. Write the main configuration, route configurations, certificates, 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. 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.
|
||||
|
||||
## Design Constraints
|
||||
|
||||
* Publishing must read all enabled site configurations, rather than only rendering the modified object this time.
|
||||
* Rollback is achieved by reactivating older versions, without modifying historical versions.
|
||||
* The Agent API is fixed to use the node-exclusive `agent_token`; the first access can use the `discovery_token`.
|
||||
* The Server does not provide remote shell or arbitrary command execution entries.
|
||||
* The configuration version must save complete snapshots, rendering results, and `checksum`.
|
||||
* 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.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Connect Agent
|
||||
|
||||
OpenFlare Agent runs on proxy nodes. It handles registration, heartbeat, configuration sync, OpenResty file writes, validation, reload, rollback, and self-update.
|
||||
|
||||
## Authentication
|
||||
|
||||
| Method | Use case |
|
||||
| --- | --- |
|
||||
| `agent_token` | The node already exists or has a dedicated credential |
|
||||
| `discovery_token` | First-time auto-registration; Server exchanges it for a node token |
|
||||
|
||||
At least one of them is required.
|
||||
|
||||
## Install Script
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
|
||||
--server-url http://your-server:3000 \
|
||||
--agent-token YOUR_AGENT_TOKEN
|
||||
```
|
||||
|
||||
Or with discovery:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
|
||||
--server-url http://your-server:3000 \
|
||||
--discovery-token YOUR_DISCOVERY_TOKEN
|
||||
```
|
||||
|
||||
## Configuration Example
|
||||
|
||||
```json
|
||||
{
|
||||
"server_url": "http://127.0.0.1:3000",
|
||||
"agent_token": "replace-with-node-auth-token",
|
||||
"data_dir": "./data",
|
||||
"openresty_path": "openresty",
|
||||
"openresty_observability_port": 18081,
|
||||
"observability_replay_minutes": 15,
|
||||
"heartbeat_interval": 10000,
|
||||
"request_timeout": 10000
|
||||
}
|
||||
```
|
||||
|
||||
Without `openresty_path`, Agent runs `openresty` by default.
|
||||
|
||||
Agent self-update requires the GitHub Release to include both the target binary and a matching `.sha256` file. The downloaded binary is verified before it replaces the local executable.
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/rain-kl/openflare-agent:latest
|
||||
docker rm -f openflare-agent 2>/dev/null || true
|
||||
docker run -d --name openflare-agent --restart unless-stopped \
|
||||
-p 80:80 -p 443:443 \
|
||||
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
|
||||
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
|
||||
ghcr.io/rain-kl/openflare-agent:latest
|
||||
```
|
||||
|
||||
## Run from Source
|
||||
|
||||
```bash
|
||||
cd openflare_agent
|
||||
export LOG_LEVEL='info'
|
||||
go run ./cmd/agent -config /path/to/agent.json
|
||||
```
|
||||
|
||||
## Uninstall
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/uninstall-agent.sh | bash
|
||||
```
|
||||
@@ -0,0 +1,299 @@
|
||||
# Deployment
|
||||
|
||||
You will learn: The recommended OpenFlare deployment model, Server and Agent requirements, source startup workflow, integration steps, upgrade paths, and uninstall entry points.
|
||||
|
||||
For production, use PostgreSQL for the Server database and set `SESSION_SECRET` explicitly. 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
|
||||
|
||||
```text
|
||||
Browser
|
||||
|
|
||||
v
|
||||
OpenFlare Server :3000
|
||||
|
|
||||
| Agent API / heartbeat / config pull
|
||||
v
|
||||
OpenFlare Agent
|
||||
|
|
||||
v
|
||||
OpenResty binary
|
||||
|
|
||||
v
|
||||
Origin service
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
Server:
|
||||
|
||||
| Item | Requirement |
|
||||
| --- | --- |
|
||||
| Go | `1.25+`, source run only |
|
||||
| Node.js | `18+`, frontend source build only |
|
||||
| Database | Writable SQLite directory or reachable PostgreSQL instance |
|
||||
| Port | `3000` by default |
|
||||
|
||||
Agent:
|
||||
|
||||
| Item | Requirement |
|
||||
| --- | --- |
|
||||
| OS | Install script supports Linux and macOS. systemd service is created only on Linux + systemd. |
|
||||
| Architecture | `amd64` or `arm64` |
|
||||
| OpenResty | Required for local Agent installs, 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]
|
||||
|
||||
## Docker Compose Server
|
||||
|
||||
Create `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: openflare
|
||||
POSTGRES_USER: openflare
|
||||
POSTGRES_PASSWORD: replace-with-strong-password
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U openflare -d openflare"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
openflare:
|
||||
image: ghcr.io/rain-kl/openflare:latest
|
||||
container_name: openflare
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
SESSION_SECRET: replace-with-a-long-random-string
|
||||
DSN: postgres://openflare:replace-with-strong-password@postgres:5432/openflare?sslmode=disable
|
||||
GIN_MODE: release
|
||||
LOG_LEVEL: info
|
||||
volumes:
|
||||
- openflare-data:/data
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
openflare-data:
|
||||
```
|
||||
|
||||
Start:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
docker compose ps
|
||||
docker compose logs -f openflare
|
||||
```
|
||||
|
||||
Open `http://localhost:3000`. The default account is `root` / `123456`; change it immediately.
|
||||
|
||||
## Run Server from Source
|
||||
|
||||
Build the management UI first:
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
corepack enable
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Then start Server:
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
export SESSION_SECRET='replace-with-a-long-random-string'
|
||||
export SQLITE_PATH='./openflare.db'
|
||||
export LOG_LEVEL='info'
|
||||
# Optional: PostgreSQL takes precedence when set.
|
||||
# export DSN='postgres://openflare:secret@127.0.0.1:5432/openflare?sslmode=disable'
|
||||
go run .
|
||||
```
|
||||
|
||||
Default port is `3000`. You can also set it explicitly:
|
||||
|
||||
```bash
|
||||
go run . --port 3000 --log-dir ./logs
|
||||
```
|
||||
|
||||
## 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`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
|
||||
--server-url http://your-server:3000 \
|
||||
--discovery-token YOUR_DISCOVERY_TOKEN
|
||||
```
|
||||
|
||||
With node-specific `agent_token`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
|
||||
--server-url http://your-server:3000 \
|
||||
--agent-token YOUR_AGENT_TOKEN
|
||||
```
|
||||
|
||||
Supported options:
|
||||
|
||||
| Option | Description |
|
||||
| --- | --- |
|
||||
| `--server-url` | Server URL, required |
|
||||
| `--discovery-token` | First-registration token, mutually exclusive with `--agent-token` |
|
||||
| `--agent-token` | Node-specific token, mutually exclusive with `--discovery-token` |
|
||||
| `--install-dir` | Install directory, default `/opt/openflare-agent` |
|
||||
| `--openresty-path` | OpenResty binary path, auto-detected when omitted |
|
||||
| `--repo` | GitHub repository for Agent downloads, default `Rain-kl/OpenFlare` |
|
||||
| `--no-service` | Do not create a systemd service |
|
||||
|
||||
Check status:
|
||||
|
||||
```bash
|
||||
systemctl status openflare-agent
|
||||
journalctl -u openflare-agent -f
|
||||
```
|
||||
|
||||
## Run Agent Manually
|
||||
|
||||
From source:
|
||||
|
||||
```bash
|
||||
cd openflare_agent
|
||||
export LOG_LEVEL='info'
|
||||
go run ./cmd/agent -config /path/to/agent.json
|
||||
```
|
||||
|
||||
Build and run:
|
||||
|
||||
```bash
|
||||
cd openflare_agent
|
||||
go build -o openflare-agent ./cmd/agent
|
||||
export LOG_LEVEL='info'
|
||||
./openflare-agent -config /path/to/agent.json
|
||||
```
|
||||
|
||||
Minimal `agent.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"server_url": "http://127.0.0.1:3000",
|
||||
"agent_token": "replace-with-node-auth-token",
|
||||
"data_dir": "./data",
|
||||
"openresty_path": "openresty",
|
||||
"heartbeat_interval": 10000,
|
||||
"request_timeout": 10000
|
||||
}
|
||||
```
|
||||
|
||||
When `openresty_path` is not configured, Agent runs `openresty`.
|
||||
|
||||
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.
|
||||
2. Prepare `agent_token` or `discovery_token`.
|
||||
3. Start Agent and confirm the node is online.
|
||||
4. Create an enabled site configuration.
|
||||
5. Publish and activate a new version.
|
||||
6. Check node detail and apply logs.
|
||||
7. Visit the domain or verify with `curl`.
|
||||
|
||||
## Upgrade and Uninstall
|
||||
|
||||
Server:
|
||||
|
||||
* Root users can check and upgrade stable Server releases from the top bar.
|
||||
* Preview releases can be checked manually.
|
||||
* Binary upload upgrades are also supported.
|
||||
|
||||
Agent:
|
||||
|
||||
* Agents follow stable releases by default.
|
||||
* 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.
|
||||
|
||||
Uninstall Agent:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/uninstall-agent.sh | bash
|
||||
```
|
||||
|
||||
The uninstall script stops Agent and removes the systemd service and install directory. It does not remove the local OpenResty installation.
|
||||
|
||||
## Validation Commands
|
||||
|
||||
Server:
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
GOCACHE=/tmp/openflare-go-cache go test ./...
|
||||
```
|
||||
|
||||
Agent:
|
||||
|
||||
```bash
|
||||
cd openflare_agent
|
||||
GOCACHE=/tmp/openflare-go-cache go test ./...
|
||||
```
|
||||
|
||||
Frontend:
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Swagger:
|
||||
|
||||
```bash
|
||||
go install github.com/swaggo/swag/cmd/swag@v1.16.4
|
||||
cd openflare_server
|
||||
swag init -g main.go -o docs
|
||||
```
|
||||
@@ -0,0 +1,188 @@
|
||||
# Local Development
|
||||
|
||||
You will learn how to set up a local OpenFlare development environment, run the Server, Agent, and frontend, execute tests and builds, and understand the boundaries contributors must follow.
|
||||
|
||||
This page is for contributors. Product boundaries, data model constraints, API conventions, and frontend layering are defined in [Development Constraints](../design/development.md). This page focuses on executable local workflows.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
| Path | Responsibility |
|
||||
| --- | --- |
|
||||
| `openflare_server` | Gin + GORM + SQLite/PostgreSQL monolithic control plane |
|
||||
| `openflare_server/web` | Next.js management UI, statically exported and served by the Go Server |
|
||||
| `openflare_agent` | Go Agent binary running on nodes |
|
||||
| `scripts` | Agent install and uninstall scripts |
|
||||
| `docs` | VitePress documentation site |
|
||||
|
||||
## Requirements
|
||||
|
||||
| Tool | Requirement |
|
||||
| --- | --- |
|
||||
| Go | `1.25+` |
|
||||
| Node.js | `18+` |
|
||||
| pnpm | Use `corepack enable` to follow the project-declared version |
|
||||
| Docker | Needed for Server containers, local integration, and the Agent Docker image |
|
||||
| OpenResty | Needed when running Agent locally |
|
||||
| PostgreSQL | Optional. The Server uses SQLite when PostgreSQL is not configured. |
|
||||
|
||||
## Install Frontend Dependencies
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
corepack enable
|
||||
pnpm install
|
||||
```
|
||||
|
||||
Build static assets served by the Go Server:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## Run the Server
|
||||
|
||||
SQLite:
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
export SESSION_SECRET='dev-session-secret'
|
||||
export SQLITE_PATH='./openflare-dev.db'
|
||||
export LOG_LEVEL='debug'
|
||||
go run .
|
||||
```
|
||||
|
||||
PostgreSQL:
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
export SESSION_SECRET='dev-session-secret'
|
||||
export DSN='postgres://openflare:secret@127.0.0.1:5432/openflare?sslmode=disable'
|
||||
export LOG_LEVEL='debug'
|
||||
go run .
|
||||
```
|
||||
|
||||
Default URL:
|
||||
|
||||
```text
|
||||
http://localhost:3000
|
||||
```
|
||||
|
||||
Default account: `root` / `123456`.
|
||||
|
||||
## Run the Frontend Dev Server
|
||||
|
||||
The frontend dev server listens on `3001` by default and proxies API requests through `NEXT_DEV_BACKEND_URL`:
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
export NEXT_DEV_BACKEND_URL='http://127.0.0.1:3000'
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Open:
|
||||
|
||||
```text
|
||||
http://localhost:3001
|
||||
```
|
||||
|
||||
## Run the Agent
|
||||
|
||||
Create a local `agent.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"server_url": "http://127.0.0.1:3000",
|
||||
"agent_token": "replace-with-node-auth-token",
|
||||
"data_dir": "./data",
|
||||
"heartbeat_interval": 10000,
|
||||
"request_timeout": 10000
|
||||
}
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd openflare_agent
|
||||
export LOG_LEVEL='debug'
|
||||
go run ./cmd/agent -config ./agent.json
|
||||
```
|
||||
|
||||
When `openresty_path` is not configured, the Agent runs `openresty`. For debugging, set `openresty_path`, `main_config_path`, `route_config_path`, `access_log_path`, `cert_dir`, `lua_dir`, and `runtime_config_dir` as needed.
|
||||
|
||||
## Tests
|
||||
|
||||
Server:
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
GOCACHE=/tmp/openflare-go-cache go test ./...
|
||||
```
|
||||
|
||||
Agent:
|
||||
|
||||
```bash
|
||||
cd openflare_agent
|
||||
GOCACHE=/tmp/openflare-go-cache go test ./...
|
||||
```
|
||||
|
||||
Frontend:
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm test:e2e
|
||||
```
|
||||
|
||||
Docs:
|
||||
|
||||
```bash
|
||||
cd docs
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## Builds
|
||||
|
||||
Frontend static assets:
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Server binary:
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
go build -o openflare-server .
|
||||
```
|
||||
|
||||
Agent binary:
|
||||
|
||||
```bash
|
||||
cd openflare_agent
|
||||
go build -o openflare-agent ./cmd/agent
|
||||
```
|
||||
|
||||
## Debugging Entrypoints
|
||||
|
||||
| Scenario | Command or Location |
|
||||
| --- | --- |
|
||||
| Server logs | `LOG_LEVEL=debug go run .` |
|
||||
| Agent logs | `LOG_LEVEL=debug go run ./cmd/agent -config ./agent.json` |
|
||||
| Swagger | `http://localhost:3000/swagger/index.html` |
|
||||
| Frontend API proxy | `NEXT_DEV_BACKEND_URL=http://127.0.0.1:3000 pnpm dev` |
|
||||
| OpenResty config test | `openresty -t -c ./data/etc/nginx/nginx.conf` |
|
||||
|
||||
## Change Acceptance
|
||||
|
||||
Before contributing, confirm that:
|
||||
|
||||
1. The change fits [Product Boundary](../design/index.md).
|
||||
2. The implementation follows [Development Constraints](../design/development.md).
|
||||
3. It does not break release, sync, rollback, or upgrade flows.
|
||||
4. Documentation is updated when configuration, deployment, API, or product boundaries change.
|
||||
5. Risky changes include tests or equivalent integration verification.
|
||||
|
||||
Database schema changes must bump the database version and include explicit migration and validation logic from the previous version.
|
||||
@@ -0,0 +1,100 @@
|
||||
# Publishing Your First Configuration
|
||||
|
||||
You will learn: How to create your first site configuration, bind origins and certificates, publish a configuration version, and confirm that the Agent has applied it.
|
||||
|
||||
OpenFlare's release link is centered on complete configuration versions. After modifying site configurations on the management console, you need to publish and activate the new version before the Agent pulls and applies it in subsequent heartbeats.
|
||||
|
||||
## Pre-release Check
|
||||
|
||||
Confirm that the following conditions are met:
|
||||
|
||||
| Project | Expectation |
|
||||
| --- | --- |
|
||||
| Server | Can log into the management console |
|
||||
| Agent | At least one node is online |
|
||||
| Origin | The Agent node can access the origin address |
|
||||
| Domain | The domain has been resolved to the OpenResty node, or you are ready to verify via local hosts / curl Host header |
|
||||
| HTTPS | If HTTPS is required, the certificate has been uploaded or hosted |
|
||||
|
||||
## Create Site Configuration
|
||||
|
||||
When adding a site configuration on the management console, you need at least:
|
||||
|
||||
| Field | Description |
|
||||
| --- | --- |
|
||||
| Site Name | Unique business identifier; defaults to the primary domain when omitted |
|
||||
| Domains | At least one domain; the first item is treated as the primary domain |
|
||||
| Origin URL | Valid `http://` or `https://` upstream address |
|
||||
| Enabled | Only enabled site configurations participate in release rendering |
|
||||
|
||||
Example:
|
||||
|
||||
| Field | Example |
|
||||
| --- | --- |
|
||||
| Site Name | `app` |
|
||||
| Domains | `app.example.com` |
|
||||
| Origin URL | `http://10.0.0.20:8080` |
|
||||
|
||||
A domain can belong to only one site configuration. Site-level rate limits, reverse proxies, and cache configurations are shared by site.
|
||||
|
||||
## Bind Certificates
|
||||
|
||||
HTTPS certificates are bound per domain. Domains not bound to certificates will not be automatically placed in `443 ssl` server blocks.
|
||||
|
||||
If a site contains multiple domains, the publishing rendering will generate HTTPS configurations grouped by certificate and ensure all domains still belong to the same site snapshot.
|
||||
|
||||
## Publish and Activate
|
||||
|
||||
Standard link:
|
||||
|
||||
```text
|
||||
Modify rules -> Preview / View diff -> Publish -> Generate complete configuration version -> Activate version -> Agent pulls -> Local application -> Report result
|
||||
```
|
||||
|
||||
When publishing, the Server reads all enabled site configurations, OpenResty main configuration templates, performance parameters, and cache parameters, renders the complete OpenResty configuration, calculates the `checksum`, writes to `config_versions`, and then switches the activated version.
|
||||
|
||||
## Verify Results
|
||||
|
||||
After publishing, confirm on the management console:
|
||||
|
||||
| Location | Expected Result |
|
||||
| --- | --- |
|
||||
| Node List | Node is online |
|
||||
| Node Details | The current version is consistent with the activated version |
|
||||
| Apply Logs | The most recent application succeeded |
|
||||
| Version Page | The new version is in the activated state |
|
||||
|
||||
Confirm the Agent logs on the node:
|
||||
|
||||
```bash
|
||||
journalctl -u openflare-agent -n 100 --no-pager
|
||||
```
|
||||
|
||||
Access using the domain:
|
||||
|
||||
```bash
|
||||
curl -I http://app.example.com
|
||||
```
|
||||
|
||||
If the domain has not been officially resolved yet, you can temporarily specify the Host header to access the node IP:
|
||||
|
||||
```bash
|
||||
curl -I -H 'Host: app.example.com' http://NODE_IP
|
||||
```
|
||||
|
||||
HTTPS verification:
|
||||
|
||||
```bash
|
||||
curl -I https://app.example.com
|
||||
```
|
||||
|
||||
## Rollback
|
||||
|
||||
If the target version application fails and rolls back, the Agent will block repeated applications of the same `version + checksum` locally until the activated version or checksum on the control plane changes.
|
||||
|
||||
To roll back to an older version:
|
||||
|
||||
1. Open the configuration version page.
|
||||
2. Find the previous confirmed working historical version.
|
||||
3. Reactivate that version.
|
||||
4. View the node application records to confirm that the Agent applied it successfully.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Guide
|
||||
|
||||
You will learn how the OpenFlare documentation is organized, which pages to read for a first run, and where to find deployment, usage, troubleshooting, and development information.
|
||||
|
||||
OpenFlare is a self-hosted OpenResty control plane. It brings reverse proxy site configuration, immutable releases, Agent-based node sync, TLS certificates, and basic observability into one management UI for a single team or organization.
|
||||
|
||||
## Recommended Path
|
||||
|
||||
If you are new to OpenFlare, read these pages in order:
|
||||
|
||||
1. [Quick Start](./quick-start.md): start the Server with Docker Compose, sign in, and connect the first Agent.
|
||||
2. [Usage](./usage.md): learn common operations for sites, origins, certificates, releases, rollbacks, and observability.
|
||||
3. [Deployment](./deployment.md): run the Server and Agent in an environment closer to production.
|
||||
4. [Configuration](../reference/configuration.md): look up Server environment variables, runtime options, and Agent configuration fields.
|
||||
5. [Troubleshooting](./troubleshooting.md): debug login, database, node sync, OpenResty apply, and frontend build issues.
|
||||
|
||||
## Find by Role
|
||||
|
||||
| Goal | Start Here |
|
||||
| --- | --- |
|
||||
| Run the management UI in a few minutes | [Quick Start](./quick-start.md) |
|
||||
| Publish the first reverse proxy site | [Publish First Site](./first-site.md) |
|
||||
| Connect or reinstall a node Agent | [Connect Agent](./agent.md) |
|
||||
| Start the Server from source | [Run Server](./server.md) |
|
||||
| Configure GitHub or OIDC login | [SSO Login](./sso.md) |
|
||||
| Upgrade the Server or Agent | [Upgrade and Maintenance](./upgrade.md) |
|
||||
| Contribute code or fix issues | [Local Development](./development.md) and [Development Constraints](../design/development.md) |
|
||||
| Understand architecture and releases | [Architecture](../design/architecture.md) and [Release Model](../design/release-model.md) |
|
||||
|
||||
## Documentation Areas
|
||||
|
||||
`guide/` is for users and operators. It provides executable steps from installation to daily operations.
|
||||
|
||||
`reference/` collects stable facts, such as configuration fields, commands, API conventions, and repository layout.
|
||||
|
||||
`design/` is for maintainers and contributors. It describes product boundaries, architecture, release model, and engineering constraints. Update the related design page before implementing changes that alter those boundaries.
|
||||
@@ -0,0 +1,201 @@
|
||||
# Quick Start
|
||||
|
||||
You will learn how to start OpenFlare Server with Docker Compose, sign in for the first time, connect the first Agent, and verify that a configuration was published to a node.
|
||||
|
||||
The minimal OpenFlare setup contains:
|
||||
|
||||
| Component | Responsibility |
|
||||
| --- | --- |
|
||||
| Server | Management UI, management API, Agent API, configuration rendering, release publishing, and state storage |
|
||||
| Agent | Runs on proxy nodes, pulls configuration, writes OpenResty files, validates, and reloads |
|
||||
| OpenResty | Receives traffic and proxies requests to origins |
|
||||
|
||||
Agent controls OpenResty through the OpenResty binary. Local installs need an `openresty` executable on the node; Docker installs can run the Agent image that already includes OpenResty.
|
||||
|
||||
## Requirements
|
||||
|
||||
| Item | Requirement |
|
||||
| --- | --- |
|
||||
| Docker / Docker Compose | Used to start Server and PostgreSQL; also used if you run the Agent Docker image |
|
||||
| OpenResty | Required for local Agent installs unless `--openresty-path` points to a custom binary |
|
||||
| Reachable ports | Server listens on `3000` by default. Agent nodes must reach the Server URL. |
|
||||
| Browser | Used to open the management UI |
|
||||
|
||||
[Needs confirmation: minimum recommended Docker and Docker Compose versions]
|
||||
|
||||
## 1. Start Server
|
||||
|
||||
Create `docker-compose.yml` in an empty directory:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: openflare
|
||||
POSTGRES_USER: openflare
|
||||
POSTGRES_PASSWORD: replace-with-strong-password
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U openflare -d openflare"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
openflare:
|
||||
image: ghcr.io/rain-kl/openflare:latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
SESSION_SECRET: replace-with-a-long-random-string
|
||||
DSN: postgres://openflare:replace-with-strong-password@postgres:5432/openflare?sslmode=disable
|
||||
GIN_MODE: release
|
||||
LOG_LEVEL: info
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
```
|
||||
|
||||
Start:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
docker compose logs -f openflare
|
||||
```
|
||||
|
||||
When the `openflare` container is running and logs show `server listening`, open:
|
||||
|
||||
```text
|
||||
http://localhost:3000
|
||||
```
|
||||
|
||||
Default account:
|
||||
|
||||
| Username | Password |
|
||||
| --- | --- |
|
||||
| `root` | `123456` |
|
||||
|
||||
Change the default password immediately after first login.
|
||||
|
||||
## 2. Prepare an Agent Token
|
||||
|
||||
Agents can connect with either:
|
||||
|
||||
| Credential | Use Case |
|
||||
| --- | --- |
|
||||
| `discovery_token` | First-time automatic node registration. Server exchanges it for a node-specific token. |
|
||||
| `agent_token` | A node-specific token created or assigned in the management UI. |
|
||||
|
||||
Prepare one of them in the management UI before continuing.
|
||||
|
||||
[Needs confirmation: exact UI menu path for creating or viewing `discovery_token` and node `agent_token`]
|
||||
|
||||
## 3. Install/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.
|
||||
|
||||
With `discovery_token`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
|
||||
--server-url http://your-server:3000 \
|
||||
--discovery-token YOUR_DISCOVERY_TOKEN
|
||||
```
|
||||
|
||||
With node-specific `agent_token`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
|
||||
--server-url http://your-server:3000 \
|
||||
--agent-token YOUR_AGENT_TOKEN
|
||||
```
|
||||
|
||||
The script defaults to:
|
||||
|
||||
| Item | Default |
|
||||
| --- | --- |
|
||||
| Install directory | `/opt/openflare-agent` |
|
||||
| Config file | `/opt/openflare-agent/agent.json` |
|
||||
| systemd service | `openflare-agent.service` |
|
||||
| OpenResty path | Auto-detects `openresty` unless `--openresty-path` is provided |
|
||||
|
||||
Check status:
|
||||
|
||||
```bash
|
||||
systemctl status openflare-agent
|
||||
journalctl -u openflare-agent -f
|
||||
```
|
||||
|
||||
If systemd is unavailable, the script prints a manual start command.
|
||||
|
||||
## 4. Publish the First Configuration
|
||||
|
||||
In the management UI:
|
||||
|
||||
1. Create a site configuration with a site name, domain, and origin URL.
|
||||
2. Ensure the site is enabled.
|
||||
3. Preview the rendered configuration or review the diff.
|
||||
4. Publish and activate a new version.
|
||||
5. Wait for the Agent to discover and apply the version through heartbeat.
|
||||
|
||||
Version numbers use `YYYYMMDD-NNN`. Historical versions are immutable; rollback reactivates an old version.
|
||||
|
||||
## 5. Verify Success
|
||||
|
||||
In the UI:
|
||||
|
||||
| Location | Expected Result |
|
||||
| --- | --- |
|
||||
| Node list | Agent node is online |
|
||||
| Node detail | Current version matches the active version |
|
||||
| Apply logs | Latest apply succeeded |
|
||||
| Versions page | New version is active |
|
||||
|
||||
On the Agent node:
|
||||
|
||||
```bash
|
||||
journalctl -u openflare-agent -n 100 --no-pager
|
||||
```
|
||||
|
||||
## Common Failures
|
||||
|
||||
| Symptom | What to Check |
|
||||
| --- | --- |
|
||||
| Cannot open the UI | Confirm `docker compose ps` shows Server running and host port `3000` is free |
|
||||
| Login works but data cannot be saved | Check PostgreSQL health and the username/password/database in `DSN` |
|
||||
| Agent cannot register | Confirm the Agent node can reach `--server-url`, and check whether the token is wrong or expired |
|
||||
| Agent is online but does not apply | Confirm the site is enabled and a version was published and activated |
|
||||
| OpenResty apply fails | Check apply logs and `journalctl -u openflare-agent`, especially domains, certificates, upstream URLs, and port conflicts |
|
||||
|
||||
See [Troubleshooting](./troubleshooting.md) for deeper diagnostics.
|
||||
@@ -0,0 +1,106 @@
|
||||
# Starting the Server
|
||||
|
||||
You will learn: How to build the management console frontend from source, start OpenFlare Server, select SQLite or PostgreSQL, and access Swagger.
|
||||
|
||||
OpenFlare Server is a Gin + GORM monolithic control plane, responsible for the management console UI, management APIs, Agent APIs, configuration rendering, version releases, data storage, and aggregated queries.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Project | Requirement |
|
||||
| --- | --- |
|
||||
| Go | `1.25+` |
|
||||
| Node.js | `18+` |
|
||||
| pnpm | Recommended to use the pnpm declared by the project via `corepack enable` |
|
||||
| Database | SQLite file directory is writable, or an accessible PostgreSQL instance |
|
||||
|
||||
In production environments, it is recommended to explicitly configure `SESSION_SECRET` and prioritize PostgreSQL.
|
||||
|
||||
## Build the Management Console Frontend
|
||||
|
||||
The Go Server hosts the static artifacts in `openflare_server/web/build`. Before starting from source, build the frontend first:
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
corepack enable
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Common frontend checks:
|
||||
|
||||
```bash
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## Start with SQLite
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
export SESSION_SECRET='replace-with-a-long-random-string'
|
||||
export SQLITE_PATH='./openflare.db'
|
||||
export LOG_LEVEL='info'
|
||||
go run .
|
||||
```
|
||||
|
||||
Listens on port `3000` by default. Access:
|
||||
|
||||
```text
|
||||
http://localhost:3000
|
||||
```
|
||||
|
||||
## Start with PostgreSQL
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
export SESSION_SECRET='replace-with-a-long-random-string'
|
||||
export DSN='postgres://openflare:secret@127.0.0.1:5432/openflare?sslmode=disable'
|
||||
export LOG_LEVEL='info'
|
||||
go run .
|
||||
```
|
||||
|
||||
`DSN` takes precedence over SQLite once set. When `DSN` and the legacy-named `SQL_DSN` both exist, `DSN` takes precedence.
|
||||
|
||||
If the target PostgreSQL database is empty and the local `SQLITE_PATH` file exists, the Server will attempt to migrate SQLite data to PostgreSQL during the startup phase and output the migration progress in the logs.
|
||||
|
||||
## Command Line Parameters
|
||||
|
||||
```bash
|
||||
go run . --port 3000 --log-dir ./logs
|
||||
```
|
||||
|
||||
| Parameter | Action | Default Value |
|
||||
| --- | --- | --- |
|
||||
| `--port` | Specify the Server listening port | `3000` |
|
||||
| `--log-dir` | Specify the log directory | Empty (outputs to standard output) |
|
||||
| `--version` | Output the version and exit | `false` |
|
||||
| `--help` | Output the help information and exit | `false` |
|
||||
|
||||
## First Login
|
||||
|
||||
Default account:
|
||||
|
||||
| Username | Password |
|
||||
| --- | --- |
|
||||
| `root` | `123456` |
|
||||
|
||||
Please change the default password immediately after logging in for the first time.
|
||||
|
||||
## Swagger
|
||||
|
||||
Access after logging into the management console:
|
||||
|
||||
```text
|
||||
http://localhost:3000/swagger/index.html
|
||||
```
|
||||
|
||||
Regenerate Swagger locally:
|
||||
|
||||
```bash
|
||||
go install github.com/swaggo/swag/cmd/swag@v1.16.4
|
||||
cd openflare_server
|
||||
swag init -g main.go -o docs
|
||||
```
|
||||
|
||||
The generated Swagger files are located in `openflare_server/docs`.
|
||||
@@ -0,0 +1,104 @@
|
||||
# SSO Login
|
||||
|
||||
You will learn how to configure GitHub OAuth or a standard OIDC login source for OpenFlare, how to set callback URLs, and how third-party accounts bind to local users.
|
||||
|
||||
OpenFlare supports third-party login through authentication sources. The current supported source types are GitHub OAuth and standard OIDC providers, such as Logto, authentik, Keycloak, and Casdoor.
|
||||
|
||||
After an authentication source is configured and enabled, it appears on the login page. Users can sign in with the third-party account or bind it to the current local account while already signed in.
|
||||
|
||||
## Before You Start
|
||||
|
||||
Prepare:
|
||||
|
||||
| Item | Description |
|
||||
| --- | --- |
|
||||
| OpenFlare public URL | The URL users open in their browser, such as `https://openflare.example.com` |
|
||||
| Source name | Internal unique name, such as `github` or `company-oidc` |
|
||||
| Client ID | Provided by the third-party application |
|
||||
| Client Secret | Provided by the third-party application |
|
||||
| OIDC Discovery URL | Required only for OIDC, such as `https://idp.example.com/.well-known/openid-configuration` |
|
||||
|
||||
Confirm that the server address in system settings matches the domain users access.
|
||||
|
||||
The source name can contain letters, numbers, hyphens, and underscores, and must start with a letter or number. The source name is part of the callback URL. If you rename it later, update the callback URL in the third-party platform too.
|
||||
|
||||
## Callback URL
|
||||
|
||||
Set the Redirect URI / Callback URL in the third-party platform to:
|
||||
|
||||
```text
|
||||
<OpenFlare public URL>/oauth/<source name>
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
https://openflare.example.com/oauth/github
|
||||
https://openflare.example.com/oauth/company-oidc
|
||||
```
|
||||
|
||||
When creating or editing an authentication source, the UI shows the callback URL based on the current browser URL and source name.
|
||||
|
||||
## Configure GitHub Login
|
||||
|
||||
1. Create an OAuth App in GitHub.
|
||||
2. Set `Homepage URL` to the OpenFlare public URL.
|
||||
3. Set `Authorization callback URL` to the callback shown by OpenFlare, such as `https://openflare.example.com/oauth/github`.
|
||||
4. Copy the Client ID and Client Secret.
|
||||
5. Sign in to OpenFlare and open Settings -> System Settings -> Authentication Sources.
|
||||
6. Add a source and select `GitHub`.
|
||||
7. Fill in source name, display name, Client ID, and Client Secret.
|
||||
8. Keep the default scope `user:email` unless your GitHub app requires a different value.
|
||||
9. Save and enable the source.
|
||||
|
||||
The login page will show the GitHub button after the source is enabled.
|
||||
|
||||
## Configure OIDC Login
|
||||
|
||||
1. Create an application or client in the OIDC provider.
|
||||
2. Choose a Web / Confidential Client type.
|
||||
3. Set Redirect URI / Callback URL to the value shown by OpenFlare, such as `https://openflare.example.com/oauth/company-oidc`.
|
||||
4. Copy the Client ID and Client Secret.
|
||||
5. Get the provider Discovery URL, usually ending in `/.well-known/openid-configuration`.
|
||||
6. Sign in to OpenFlare and open Settings -> System Settings -> Authentication Sources.
|
||||
7. Add a source and select `OIDC`.
|
||||
8. Fill in source name, display name, Client ID, Client Secret, and OIDC Discovery URL.
|
||||
9. Keep the default scope `openid profile email` unless the provider restricts scopes.
|
||||
10. Save and enable the source.
|
||||
|
||||
The login page will show the OIDC button after the source is enabled.
|
||||
|
||||
## Login and Binding Behavior
|
||||
|
||||
| Scenario | Behavior |
|
||||
| --- | --- |
|
||||
| Third-party account already bound to a local user | Sign in directly |
|
||||
| User is already signed in and starts third-party authorization | Bind the third-party account to the current local user |
|
||||
| Third-party account is unbound and registration is allowed | Create a normal local user and bind it |
|
||||
| Third-party account is unbound and registration is disabled | Ask the user to enter existing local credentials to bind |
|
||||
|
||||
If you only want existing users to use SSO, disable registration. Unbound third-party accounts will enter the existing-account binding flow.
|
||||
|
||||
## Update a Source
|
||||
|
||||
When editing an authentication source, leave Client Secret empty to keep the existing secret. Entering a new value overwrites it.
|
||||
|
||||
If you change the source name, the callback URL changes too. Update Redirect URI / Callback URL in the third-party platform, or the provider will reject the callback.
|
||||
|
||||
## FAQ
|
||||
|
||||
### `invalid_scope`
|
||||
|
||||
The provider does not allow the configured scope. The OIDC default is `openid profile email`; the GitHub default is `user:email`. Adjust the scope in OpenFlare or allow it in the provider.
|
||||
|
||||
### Callback URL Mismatch
|
||||
|
||||
Check that the Redirect URI / Callback URL in the provider exactly matches the URL shown by OpenFlare. Protocol, domain, port, and path must all match.
|
||||
|
||||
### No Third-Party Login Button
|
||||
|
||||
Check that the source is enabled and that Client ID and Client Secret are saved. OpenFlare validates these fields before enabling a source.
|
||||
|
||||
### Client Secret Is Not Shown in the List
|
||||
|
||||
This is expected. OpenFlare does not return Client Secret through the API; it only shows whether the secret is configured.
|
||||
@@ -0,0 +1,225 @@
|
||||
# Troubleshooting
|
||||
|
||||
You will learn how to debug OpenFlare Server, database, login, Agent, OpenResty, release, and frontend build issues by symptom.
|
||||
|
||||
Start by locating the failing layer: browser, Server, database, Agent, OpenResty, origin, or DNS. OpenFlare applies configuration only after a version is activated and the Agent discovers it through heartbeat.
|
||||
|
||||
## Quick Triage
|
||||
|
||||
| Symptom | Check First |
|
||||
| --- | --- |
|
||||
| Management UI does not open | Server process/container logs and port binding |
|
||||
| Login fails | Default account, `SESSION_SECRET`, browser request, Server logs |
|
||||
| Data cannot be saved | Database connection, SQLite permissions, PostgreSQL health |
|
||||
| Agent is offline | Agent logs, token, Server URL, network reachability |
|
||||
| Node does not update after release | Active version, node heartbeat, apply logs |
|
||||
| OpenResty apply fails | Apply logs, Agent logs, certificates, upstream URL, port conflicts |
|
||||
| No access analytics | OpenResty status, observability port, Agent replay logs |
|
||||
|
||||
## Server Does Not Start
|
||||
|
||||
1. Check logs:
|
||||
|
||||
```bash
|
||||
docker compose logs -n 200 openflare
|
||||
```
|
||||
|
||||
For source runs, check terminal output.
|
||||
|
||||
2. Check port usage:
|
||||
|
||||
```bash
|
||||
lsof -i :3000
|
||||
```
|
||||
|
||||
3. If PostgreSQL is used, check database health:
|
||||
|
||||
```bash
|
||||
docker compose ps postgres
|
||||
docker compose logs -n 100 postgres
|
||||
```
|
||||
|
||||
4. If SQLite is used, check that the database directory is writable:
|
||||
|
||||
```bash
|
||||
ls -ld "$(dirname /path/to/openflare.db)"
|
||||
```
|
||||
|
||||
Common causes:
|
||||
|
||||
| Log or Symptom | Fix |
|
||||
| --- | --- |
|
||||
| Database connection failed | Check username, password, host, port, database, and `sslmode` in `DSN` |
|
||||
| SQLite cannot create file | Check that the `SQLITE_PATH` directory exists and is writable |
|
||||
| Port is already in use | Change `PORT` or `--port`, or stop the process using the port |
|
||||
|
||||
## UI Does Not Open or Is Blank
|
||||
|
||||
1. Confirm that the Server responds:
|
||||
|
||||
```bash
|
||||
curl -I http://127.0.0.1:3000
|
||||
```
|
||||
|
||||
2. For source runs, confirm frontend static assets were built:
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
pnpm build
|
||||
```
|
||||
|
||||
3. Check whether the browser URL matches your reverse proxy setup.
|
||||
|
||||
4. If using the frontend dev server, confirm backend proxy configuration:
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
NEXT_DEV_BACKEND_URL=http://127.0.0.1:3000 pnpm dev
|
||||
```
|
||||
|
||||
## Default Account Cannot Sign In
|
||||
|
||||
The default account is `root` / `123456`. If the password was changed after first login, use the updated password.
|
||||
|
||||
Steps:
|
||||
|
||||
1. Confirm the Server is connected to the expected database, not another `SQLITE_PATH` or `DSN`.
|
||||
2. Check Server logs to see whether it uses `sqlite` or `postgres`.
|
||||
3. If deployed behind replicas or a reverse proxy, ensure `SESSION_SECRET` is fixed and consistent across instances.
|
||||
4. Clear browser cookies and try again.
|
||||
|
||||
[Needs confirmation: whether the project provides a safe root password reset command or procedure]
|
||||
|
||||
## Agent Cannot Register or Stays Offline
|
||||
|
||||
On the Agent node:
|
||||
|
||||
```bash
|
||||
curl -I http://your-server:3000
|
||||
```
|
||||
|
||||
Check Agent logs:
|
||||
|
||||
```bash
|
||||
journalctl -u openflare-agent -n 200 --no-pager
|
||||
```
|
||||
|
||||
Check config:
|
||||
|
||||
```bash
|
||||
sed -n '1,160p' /opt/openflare-agent/agent.json
|
||||
```
|
||||
|
||||
Confirm:
|
||||
|
||||
| Config | Notes |
|
||||
| --- | --- |
|
||||
| `server_url` | Must be reachable from the Agent node |
|
||||
| `agent_token` / `discovery_token` | At least one is required |
|
||||
| `heartbeat_interval` | Supports millisecond integers or Go duration strings |
|
||||
| `request_timeout` | Increase it for slow networks |
|
||||
|
||||
If the log says the token is invalid, prepare a new token in the UI, update `agent.json`, and restart:
|
||||
|
||||
```bash
|
||||
systemctl restart openflare-agent
|
||||
```
|
||||
|
||||
## Node Does Not Apply a New Version
|
||||
|
||||
Check in order:
|
||||
|
||||
1. The target version is active on the versions page.
|
||||
2. The node is online and heartbeat time is updating.
|
||||
3. Apply logs contain a success, warning, or failure for the target version.
|
||||
4. The site configuration is enabled.
|
||||
5. Agent logs show pull, validation, reload, or rollback messages.
|
||||
|
||||
Follow Agent logs:
|
||||
|
||||
```bash
|
||||
journalctl -u openflare-agent -f
|
||||
```
|
||||
|
||||
After a target `version + checksum` fails and rolls back, the Agent blocks repeated attempts for that same target locally. Fix the configuration and publish a new checksum, or activate an old version to roll back.
|
||||
|
||||
## OpenResty Apply Fails
|
||||
|
||||
Common causes:
|
||||
|
||||
| Cause | Check |
|
||||
| --- | --- |
|
||||
| Domain or server block conflict | Ensure the same domain is not used by multiple sites |
|
||||
| Invalid upstream URL | Every upstream must be `http://` or `https://` |
|
||||
| Invalid multi-upstream format | Multiple upstreams must be plain `scheme://host[:port]` |
|
||||
| Missing certificate or wrong path | Check domain certificate binding and Agent certificate directory permissions |
|
||||
| Port conflict | Check local `80` and `443` usage |
|
||||
|
||||
OpenResty config test:
|
||||
|
||||
```bash
|
||||
openresty -t -c /path/to/openflare/data/etc/nginx/nginx.conf
|
||||
```
|
||||
|
||||
OpenResty runtime:
|
||||
|
||||
```bash
|
||||
ps aux | grep openresty
|
||||
```
|
||||
|
||||
Agent periodic health checks use local `http://127.0.0.1:<openresty_observability_port>/openflare/stub_status` instead of repeatedly running `openresty -t`. If a node is unhealthy, first confirm that the local observability port is listening. If `host not found in upstream` only appears during apply, the failure comes from config validation or reload, not the periodic health probe.
|
||||
|
||||
Use the actual `openresty_path` and `main_config_path` from `agent.json`.
|
||||
|
||||
## HTTPS Does Not Work
|
||||
|
||||
1. Confirm the certificate exists.
|
||||
2. Confirm the domain is bound to that certificate in the site configuration.
|
||||
3. Confirm a new version was published and activated.
|
||||
4. Check apply logs for success.
|
||||
5. Inspect with `curl`:
|
||||
|
||||
```bash
|
||||
curl -Iv https://your-domain
|
||||
```
|
||||
|
||||
Domains without a bound certificate are not automatically added to HTTPS configuration.
|
||||
|
||||
## No Access Analytics
|
||||
|
||||
1. Confirm the node applied a configuration that includes observability Lua assets.
|
||||
2. Confirm OpenResty is running.
|
||||
3. Check Agent logs for collection or replay failures.
|
||||
4. Check whether `openresty_observability_port` is occupied. The default is `18081`.
|
||||
5. Confirm Server cleanup policy did not remove data for that time window.
|
||||
|
||||
## Frontend Build Fails
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
corepack enable
|
||||
pnpm install
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Common causes:
|
||||
|
||||
| Symptom | Fix |
|
||||
| --- | --- |
|
||||
| pnpm version mismatch | Run `corepack enable` and reinstall |
|
||||
| Type errors | Run `pnpm typecheck` to locate files |
|
||||
| API type mismatch | Check `lib/api/` and `types/` response structures |
|
||||
| E2E fails | Ensure both the Server and frontend dev server are running |
|
||||
|
||||
## Docs Build Fails
|
||||
|
||||
```bash
|
||||
cd docs
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
If the failure is a link error, check that new pages are added to `docs/en/config.ts` and that relative links point to existing Markdown files.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Upgrade and Maintenance
|
||||
|
||||
You will learn: How to upgrade the Server and Agent, how to clean up observability data, and which verification commands to execute before and after maintenance.
|
||||
|
||||
Before upgrading, it is recommended to confirm the current activated version, the latest Agent application result, and the database backup policy. Do not upgrade in production environments while configuration publishing, large-scale Agent reconnection, or database migrations are in progress.
|
||||
|
||||
## Server Upgrade
|
||||
|
||||
Root users can check and upgrade the Server stable version from the top bar of the management console. Upgrades can also be confirmed and executed by uploading the Server binary.
|
||||
|
||||
To try a preview version, you can manually check the corresponding release. It is recommended to prioritize the stable version in production environments.
|
||||
|
||||
After upgrading, confirm:
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
docker compose logs -n 100 openflare
|
||||
```
|
||||
|
||||
If it is a source deployment, confirm that there are no database migration or startup errors in the logs after restarting the Server.
|
||||
|
||||
## Agent Upgrade
|
||||
|
||||
Node Agents follow stable versions by default for automatic updates. Preview upgrades must be triggered manually.
|
||||
|
||||
The installation script can be executed repeatedly to reinstall or upgrade the Agent:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
|
||||
--server-url http://your-server:3000 \
|
||||
--agent-token YOUR_AGENT_TOKEN
|
||||
```
|
||||
|
||||
Note: Currently, the installation script will delete the entire installation directory during reinstallation, including the old `agent.json`, local state, cache data, and downloaded binaries. Please confirm that you still have a usable Token on hand before executing.
|
||||
|
||||
After upgrading, confirm:
|
||||
|
||||
```bash
|
||||
systemctl status openflare-agent
|
||||
journalctl -u openflare-agent -n 100 --no-pager
|
||||
```
|
||||
|
||||
## Data Maintenance
|
||||
|
||||
The settings page of the management console can maintain the observability data automatic cleanup policy:
|
||||
|
||||
| Configuration Item | Description |
|
||||
| --- | --- |
|
||||
| `DatabaseAutoCleanupEnabled` | Whether to enable daily automatic cleanup |
|
||||
| `DatabaseAutoCleanupRetentionDays` | Automatic cleanup retention days, at least 1 day |
|
||||
|
||||
Once enabled, the Server will clean up access logs, metric snapshots, and request reports at 3 AM every day.
|
||||
|
||||
## Common Verification Commands
|
||||
|
||||
Server:
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
GOCACHE=/tmp/openflare-go-cache go test ./...
|
||||
```
|
||||
|
||||
Agent:
|
||||
|
||||
```bash
|
||||
cd openflare_agent
|
||||
GOCACHE=/tmp/openflare-go-cache go test ./...
|
||||
```
|
||||
|
||||
Frontend:
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Docs:
|
||||
|
||||
```bash
|
||||
cd docs
|
||||
pnpm build
|
||||
```
|
||||
@@ -0,0 +1,146 @@
|
||||
# Usage
|
||||
|
||||
You will learn what sites, origins, certificates, versions, nodes, and observability mean in OpenFlare, and which order to follow for daily operations.
|
||||
|
||||
OpenFlare does not patch OpenResty configuration files online. You edit control-plane data in the UI; Agents pull and apply a full configuration only after you publish and activate a new version.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
| Concept | Description |
|
||||
| --- | --- |
|
||||
| Site configuration | The reverse proxy aggregation object. One site can bind one or more domains. |
|
||||
| Primary domain | The first item in the `domains` list. |
|
||||
| Origin | The upstream service address, such as `http://10.0.0.10:8080`. |
|
||||
| Configuration version | A full OpenResty configuration snapshot generated by a release. Historical versions are immutable. |
|
||||
| Active version | The globally effective version. By default, all nodes consume the same active version. |
|
||||
| Agent | The node-side process that registers, heartbeats, syncs, validates, reloads, and rolls back on failure. |
|
||||
|
||||
## Recommended Workflow
|
||||
|
||||
For a normal reverse proxy change:
|
||||
|
||||
1. Confirm that at least one Agent node is online.
|
||||
2. Create or select an origin.
|
||||
3. Create a site configuration with domains, upstreams, and site-level settings.
|
||||
4. If HTTPS is needed, upload or select certificates and bind them per domain.
|
||||
5. Preview the rendered configuration or review the diff.
|
||||
6. Publish and activate a new version.
|
||||
7. Check node details and apply logs.
|
||||
|
||||
## Create a Site
|
||||
|
||||
A site requires at least:
|
||||
|
||||
| Field | Requirement |
|
||||
| --- | --- |
|
||||
| Site name | Business-unique identifier. The primary domain is a common default. |
|
||||
| Domains | At least one domain. The first domain is the primary domain. Each domain must be globally unique. |
|
||||
| Origin URL | A valid `http://` or `https://` upstream address. |
|
||||
| Enabled state | Only enabled sites are included in release rendering. |
|
||||
|
||||
Example:
|
||||
|
||||
| Field | Example |
|
||||
| --- | --- |
|
||||
| Site name | `docs` |
|
||||
| Domain | `docs.example.com` |
|
||||
| Origin URL | `http://10.0.0.10:8080` |
|
||||
| Origin Host | `docs.internal.example.com` |
|
||||
|
||||
Upstream rules:
|
||||
|
||||
* A single upstream may include a base path or query string, such as `https://app.example.com/base?from=openflare`.
|
||||
* Multiple upstreams are used for load balancing and must be plain `scheme://host[:port]`.
|
||||
* Multiple upstreams in the same site should use the same protocol.
|
||||
|
||||
## Manage Origins
|
||||
|
||||
Origins are a lightweight reusable address directory. When a site references an origin, the site still stores a renderable `origin_url` snapshot so historical versions can be replayed independently.
|
||||
|
||||
Recommended practices:
|
||||
|
||||
* Store frequently reused internal service addresses as origins.
|
||||
* After changing an origin entry, check whether site snapshots need to be updated.
|
||||
* Use preview or diff before publishing.
|
||||
|
||||
## Enable HTTPS
|
||||
|
||||
HTTPS is bound per domain, not forced for the whole site.
|
||||
|
||||
1. Upload or create a certificate record.
|
||||
2. Open the site configuration and select a certificate for each domain that needs HTTPS.
|
||||
3. Domains without a certificate stay HTTP-only and are not automatically added to `443 ssl` server blocks.
|
||||
4. Publish and activate a new version.
|
||||
|
||||
If a site contains multiple domains, the Server groups HTTPS output by certificate while keeping all domains in the same site snapshot.
|
||||
|
||||
## 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:
|
||||
|
||||
```text
|
||||
Edit configuration -> Preview / diff -> Release -> Generate full version -> Activate version -> Agent pulls -> Agent applies locally -> Agent reports result
|
||||
```
|
||||
|
||||
During release, the Server reads all enabled site configurations, OpenResty main template, performance options, cache options, and certificate assets. It renders a full configuration and calculates a `checksum`.
|
||||
|
||||
Rollback means reactivating an old version. The Agent then applies that version through the normal sync flow.
|
||||
|
||||
## Nodes and Observability
|
||||
|
||||
Node pages answer three questions:
|
||||
|
||||
| Question | Where to Check |
|
||||
| --- | --- |
|
||||
| Is the node online? | Node list or node detail |
|
||||
| Which version is running? | Current version on the node detail page |
|
||||
| Did the last apply succeed? | Apply logs |
|
||||
|
||||
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
|
||||
|
||||
### Add a Reverse Proxy for an Internal Service
|
||||
|
||||
1. Confirm the Agent node can reach the origin service.
|
||||
2. Create a site configuration.
|
||||
3. Add a domain, such as `app.example.com`.
|
||||
4. Add an origin, such as `http://10.0.0.20:8080`.
|
||||
5. Publish and activate the version.
|
||||
6. Verify the domain from a browser or with `curl`.
|
||||
|
||||
### Enable HTTPS for an Existing Domain
|
||||
|
||||
1. Prepare a certificate that covers the domain.
|
||||
2. Upload or create the certificate record.
|
||||
3. Bind the certificate to the domain in the site configuration.
|
||||
4. Publish and activate a new version.
|
||||
5. Verify with `curl -I https://your-domain`.
|
||||
|
||||
### Roll Back a Failed Release
|
||||
|
||||
1. Open the configuration versions page.
|
||||
2. Find the last known good version.
|
||||
3. Activate that version again.
|
||||
4. Check apply logs until the Agent reports success.
|
||||
5. Fix the configuration and publish a new version.
|
||||
|
||||
## Recommended Practices
|
||||
|
||||
* Set `SESSION_SECRET` explicitly in production and prefer PostgreSQL.
|
||||
* Preview or diff changes before release.
|
||||
* Check node details and apply logs after each release.
|
||||
* Keep the network path from Agents to the Server stable.
|
||||
* Do not manually edit OpenFlare-managed OpenResty files on nodes; the next release will overwrite them.
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
layout: home
|
||||
|
||||
hero:
|
||||
name: OpenFlare
|
||||
text: Self-hosted OpenResty control plane
|
||||
tagline: Manage reverse proxy rules, configuration releases, node sync, TLS certificates, and basic observability.
|
||||
actions:
|
||||
- theme: brand
|
||||
text: Quick Start
|
||||
link: /en/guide/quick-start
|
||||
- theme: alt
|
||||
text: Design Boundary
|
||||
link: /en/design/
|
||||
- theme: alt
|
||||
text: GitHub
|
||||
link: https://github.com/Rain-kl/OpenFlare
|
||||
|
||||
features:
|
||||
- icon: 🧭
|
||||
title: Unified Control Plane
|
||||
details: Manage sites, domains, origins, certificates, nodes, and release state in one console.
|
||||
- icon: 🚀
|
||||
title: Immutable Releases
|
||||
details: Each publish creates a full OpenResty configuration snapshot that can be previewed, activated, and rolled back.
|
||||
- icon: 🔁
|
||||
title: Agent Automation
|
||||
details: Nodes pull, validate, reload, and roll back to the last runnable configuration on failure.
|
||||
- icon: 📊
|
||||
title: Basic Observability
|
||||
details: Includes request rollups, access analytics, resource snapshots, health events, and node details.
|
||||
---
|
||||
@@ -0,0 +1,48 @@
|
||||
# API Conventions
|
||||
|
||||
You will learn: The response structure, path conventions, authentication methods, and Swagger entry point for OpenFlare management and Agent APIs.
|
||||
|
||||
Both the OpenFlare management APIs and Agent APIs use JSON.
|
||||
|
||||
## Response Structure
|
||||
|
||||
Both success and failure should return a clear `message`:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
## Path Conventions
|
||||
|
||||
| Type | Convention |
|
||||
| --- | --- |
|
||||
| Management API | Authenticated by management console Session |
|
||||
| Agent API | Fixed under `/api/agent/*` |
|
||||
| Read-only API | Use `GET` |
|
||||
| Mutation-type API | Use `POST` |
|
||||
|
||||
## Authentication
|
||||
|
||||
The management console continues to reuse the existing login, role, and Session system.
|
||||
|
||||
Official Agent requests uniformly use the node-exclusive `agent_token`; the first access can use the global `discovery_token`. The Agent request header is fixed as:
|
||||
|
||||
```http
|
||||
X-Agent-Token: <token>
|
||||
```
|
||||
|
||||
Full Tokens must not be printed in the logs.
|
||||
|
||||
## Swagger
|
||||
|
||||
Accessible after logging into the management console:
|
||||
|
||||
```text
|
||||
/swagger/index.html
|
||||
```
|
||||
|
||||
The Swagger files are located in `openflare_server/docs`, generated by `swag init`.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Commands and Scripts
|
||||
|
||||
You will learn: Common commands for starting, building, testing, installing, and uninstalling the OpenFlare Server, management console frontend, Agent, Swagger, and documentation site.
|
||||
|
||||
## Server
|
||||
|
||||
Start from source:
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
export SESSION_SECRET='replace-with-random-string'
|
||||
export SQLITE_PATH='./openflare.db'
|
||||
export LOG_LEVEL='info'
|
||||
go run .
|
||||
```
|
||||
|
||||
Specify listening port and log directory:
|
||||
|
||||
```bash
|
||||
go run . --port 3000 --log-dir ./logs
|
||||
```
|
||||
|
||||
Test:
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
GOCACHE=/tmp/openflare-go-cache go test ./...
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
Development:
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Build static artifacts:
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Checks:
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## Agent
|
||||
|
||||
Run from source:
|
||||
|
||||
```bash
|
||||
cd openflare_agent
|
||||
go run ./cmd/agent -config /path/to/agent.json
|
||||
```
|
||||
|
||||
Compile:
|
||||
|
||||
```bash
|
||||
cd openflare_agent
|
||||
go build -o openflare-agent ./cmd/agent
|
||||
```
|
||||
|
||||
Test:
|
||||
|
||||
```bash
|
||||
cd openflare_agent
|
||||
GOCACHE=/tmp/openflare-go-cache go test ./...
|
||||
```
|
||||
|
||||
## Install Agent
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
|
||||
--server-url http://your-server:3000 \
|
||||
--agent-token YOUR_AGENT_TOKEN
|
||||
```
|
||||
|
||||
## Uninstall Agent
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/uninstall-agent.sh | bash
|
||||
```
|
||||
|
||||
## Swagger
|
||||
|
||||
Regenerate Swagger documentation:
|
||||
|
||||
```bash
|
||||
go install github.com/swaggo/swag/cmd/swag@v1.16.4
|
||||
cd openflare_server
|
||||
swag init -g main.go -o docs
|
||||
```
|
||||
|
||||
## Docs
|
||||
|
||||
Local preview:
|
||||
|
||||
```bash
|
||||
cd docs
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Build:
|
||||
|
||||
```bash
|
||||
cd docs
|
||||
pnpm build
|
||||
```
|
||||
@@ -0,0 +1,259 @@
|
||||
# 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` | 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 execution mode | `release` unless `debug` |
|
||||
| `LOG_LEVEL` | Log level | `info` |
|
||||
| `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 |
|
||||
| `AGENT_TOKEN` | Legacy global Agent token | empty |
|
||||
|
||||
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 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` |
|
||||
|
||||
## Agent Environment Variables
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
| --- | --- | --- |
|
||||
| `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 |
|
||||
|
||||
## Agent CLI Flags
|
||||
|
||||
| Flag | Purpose | Default |
|
||||
| --- | --- | --- |
|
||||
| `-config` | Specify the path to the Agent configuration file | `./agent.json` |
|
||||
|
||||
## Agent Configuration Fields
|
||||
|
||||
| Field | Purpose | Required | Default / Behavior |
|
||||
| --- | --- | --- | --- |
|
||||
| `server_url` | Control plane URL | yes | none |
|
||||
| `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_observability_port` | Local observability and OpenResty health-check port | no | `18081` |
|
||||
| `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` |
|
||||
| `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 |
|
||||
|
||||
Description:
|
||||
|
||||
* `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.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Reference
|
||||
|
||||
You will learn: What information belongs to stable reference materials, and where to look up configurations, commands, APIs, and the repository structure.
|
||||
|
||||
This section collects stable information at the runtime, interface, and repository levels, suitable for quick lookups during deployment, joint debugging, and troubleshooting.
|
||||
|
||||
| Page | Content |
|
||||
| --- | --- |
|
||||
| [Configuration Items](./configuration.md) | Server environment variables, command line parameters, runtime Options, and Agent configuration fields |
|
||||
| [Commands and Scripts](./cli.md) | Common startup, build, test, install, and uninstall commands |
|
||||
| [API Conventions](./api.md) | Response structure, authentication, and path conventions of management and Agent APIs |
|
||||
| [Repository Layout](./repository.md) | Responsibilities of `openflare_server`, `openflare_agent`, `openflare_server/web`, and `docs` |
|
||||
@@ -0,0 +1,47 @@
|
||||
# Repository Layout
|
||||
|
||||
You will learn: What the Server, Agent, frontend, scripts, and documentation directories in the OpenFlare repository are responsible for, and which layer to place your logic when contributing code.
|
||||
|
||||
| Path | Responsibility |
|
||||
| --- | --- |
|
||||
| `openflare_server` | Gin + GORM + SQLite/PostgreSQL monolithic control plane |
|
||||
| `openflare_server/web` | Next.js 15 App Router management console frontend, statically exported and hosted by the Go Server |
|
||||
| `openflare_agent` | Go monolithic Agent, running on the node side |
|
||||
| `scripts` | Helper scripts such as Agent installation, uninstallation, etc. |
|
||||
| `docs` | VitePress documentation site, design baseline, development constraints, deployment, and configuration documents |
|
||||
|
||||
## Server Layering
|
||||
|
||||
| Directory | Responsibility |
|
||||
| --- | --- |
|
||||
| `controller/` | Parameter parsing, calling services, returning responses |
|
||||
| `service/` | Business logic, verification, transaction orchestration, configuration rendering |
|
||||
| `model/` | Model definition, database version, and migration |
|
||||
| `router/` | Route registration |
|
||||
| `middleware/` | Auth, authorization, rate limiting, and other cross-cutting logic |
|
||||
| `common/` | Configuration, global state, and initialization entry points |
|
||||
| `utils/` | Pure utility functions and general helpers |
|
||||
|
||||
## Agent Modules
|
||||
|
||||
| Module | Responsibility |
|
||||
| --- | --- |
|
||||
| `config` | Configuration reading and default values |
|
||||
| `heartbeat` | Heartbeat and version summary judgment |
|
||||
| `sync` | Configuration pulling and application orchestration |
|
||||
| `nginx` / `openresty` | OpenResty file writing, verification, reload, startup, and rollback |
|
||||
| `state` | Local state and observability supplementary reporting buffer |
|
||||
| `httpclient` | Server communication |
|
||||
| `protocol` | Agent API protocol types |
|
||||
| `internal/updater` | Agent self-updating |
|
||||
|
||||
## Frontend Layering
|
||||
|
||||
| Directory | Responsibility |
|
||||
| --- | --- |
|
||||
| `app/` | Routes, layouts, page assembly |
|
||||
| `features/` | Organize modules by business domains |
|
||||
| `components/` | Reuse components across features |
|
||||
| `lib/` | Request client, environment variables, utility functions, constants |
|
||||
| `store/` | A small amount of cross-page UI state |
|
||||
| `types/` | Shared type definitions |
|
||||
@@ -1,128 +0,0 @@
|
||||
# OpenFlare 前端开发规范
|
||||
|
||||
本文档约束 `openflare_server/web` 的正式前端工程。它描述的是 `1.0.0` 之后仍然有效的结构、请求层、组件、样式、状态管理与测试基线。
|
||||
|
||||
## 1. 技术基线
|
||||
|
||||
默认技术栈:
|
||||
|
||||
* Next.js 15 App Router
|
||||
* React 19
|
||||
* TypeScript 5
|
||||
* Tailwind CSS 4
|
||||
* TanStack Query
|
||||
* React Hook Form + Zod
|
||||
* Zustand
|
||||
* ESLint + Prettier
|
||||
* Vitest + Testing Library + Playwright
|
||||
* pnpm
|
||||
|
||||
要求:
|
||||
|
||||
* 默认使用 TypeScript
|
||||
* 默认使用函数组件
|
||||
* 默认使用 App Router
|
||||
* 前端必须支持 `light`、`dark`、`system` 三种主题模式
|
||||
|
||||
|
||||
## 2. 目录与分层
|
||||
|
||||
推荐目录:
|
||||
|
||||
```text
|
||||
app/
|
||||
components/
|
||||
features/
|
||||
lib/
|
||||
hooks/
|
||||
store/
|
||||
types/
|
||||
styles/
|
||||
tests/
|
||||
```
|
||||
|
||||
职责约束:
|
||||
|
||||
* `app/`:路由、布局、页面组装
|
||||
* `features/`:按业务域组织模块
|
||||
* `components/`:跨 feature 复用组件
|
||||
* `lib/`:请求客户端、环境变量、工具函数、常量
|
||||
* `store/`:少量跨页面 UI 状态
|
||||
* `types/`:共享类型定义
|
||||
|
||||
## 3. 路由与页面
|
||||
|
||||
页面文件只负责:
|
||||
|
||||
* 获取路由参数
|
||||
* 组织页面结构
|
||||
* 调用 feature 组件
|
||||
|
||||
页面不应负责:
|
||||
|
||||
* 手写复杂 API 细节
|
||||
* 编写复杂表单校验逻辑
|
||||
* 维护大量彼此耦合的局部状态
|
||||
|
||||
## 4. 数据请求与类型
|
||||
|
||||
### 4.1 请求层
|
||||
|
||||
所有 API 请求必须统一经过 `lib/api/`。
|
||||
|
||||
要求:
|
||||
|
||||
* 统一处理 `success/message/data` 响应结构
|
||||
* 统一处理鉴权失效、网络异常和通用错误消息
|
||||
* 统一维护资源接口与请求路径
|
||||
|
||||
禁止:
|
||||
|
||||
* 在页面组件中直接调用 `fetch('/api/...')`
|
||||
* 在多个组件中重复拼接同一接口路径
|
||||
|
||||
### 4.2 状态分层
|
||||
|
||||
* 服务端状态:TanStack Query
|
||||
* 页面临时状态:组件内部 `useState`
|
||||
* 跨页面 UI 状态:Zustand
|
||||
|
||||
不推荐:
|
||||
|
||||
* 用 Zustand 保存服务端主数据
|
||||
* 用 Context 代替完整数据层方案
|
||||
|
||||
### 4.3 类型
|
||||
|
||||
要求:
|
||||
|
||||
* 开启 TypeScript 严格模式
|
||||
* 禁止滥用 `any`
|
||||
* API 响应、表单输入、业务实体必须有明确类型
|
||||
|
||||
## 5. 表单与交互
|
||||
|
||||
统一使用:
|
||||
|
||||
* React Hook Form
|
||||
* Zod
|
||||
|
||||
高风险操作必须:
|
||||
|
||||
* 二次确认
|
||||
* 展示操作对象名称
|
||||
* 明确成功与失败反馈
|
||||
|
||||
## 6. 样式与主题
|
||||
|
||||
样式原则:
|
||||
|
||||
* 统一使用 Tailwind CSS 与现有 token 体系
|
||||
* 优先复用已有基础组件与布局组件
|
||||
* 保持视觉层级、留白与语义颜色一致
|
||||
|
||||
主题要求:
|
||||
|
||||
* 同时支持 `light`、`dark`、`system`
|
||||
* 用户选择必须持久化
|
||||
* 首屏尽量避免主题闪烁
|
||||
@@ -0,0 +1,100 @@
|
||||
# 发布第一份配置
|
||||
|
||||
你会学到:如何创建第一条网站配置、绑定源站与证书、发布配置版本,并确认 Agent 已经应用。
|
||||
|
||||
OpenFlare 的发布链路以完整配置版本为中心。你在管理端修改网站配置后,需要发布并激活新版本,Agent 才会在后续 heartbeat 中拉取并应用。
|
||||
|
||||
## 发布前检查
|
||||
|
||||
确认以下条件已经满足:
|
||||
|
||||
| 项目 | 期望 |
|
||||
| --- | --- |
|
||||
| Server | 可以登录管理端 |
|
||||
| Agent | 至少一个节点在线 |
|
||||
| 源站 | Agent 节点可以访问源站地址 |
|
||||
| 域名 | 域名已经解析到 OpenResty 节点,或准备通过本地 hosts / curl Host 头验证 |
|
||||
| HTTPS | 如需 HTTPS,证书已上传或托管 |
|
||||
|
||||
## 创建网站配置
|
||||
|
||||
在管理端新增网站配置时至少需要:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| 网站名称 | 业务唯一标识;未显式填写时可使用主域名 |
|
||||
| 域名 | 至少一个域名,第一项视为主域名 |
|
||||
| 源站地址 | 合法的 `http://` 或 `https://` 上游地址 |
|
||||
| 启用状态 | 只有启用的网站配置会参与发布渲染 |
|
||||
|
||||
示例:
|
||||
|
||||
| 字段 | 示例 |
|
||||
| --- | --- |
|
||||
| 网站名称 | `app` |
|
||||
| 域名 | `app.example.com` |
|
||||
| 源站地址 | `http://10.0.0.20:8080` |
|
||||
|
||||
同一个域名只能属于一个网站配置。同一网站内的流量限制、反向代理和缓存配置按站点共享。
|
||||
|
||||
## 绑定证书
|
||||
|
||||
HTTPS 证书按域名绑定。没有绑定证书的域名不会被自动放入 `443 ssl` server 块。
|
||||
|
||||
如果一个网站包含多个域名,发布渲染会按证书分组生成 HTTPS 配置,并确保所有域名仍属于同一站点快照。
|
||||
|
||||
## 发布与激活
|
||||
|
||||
标准链路:
|
||||
|
||||
```text
|
||||
修改规则 -> 预览 / 查看 diff -> 发布 -> 生成完整配置版本 -> 激活版本 -> Agent 拉取 -> 本地应用 -> 上报结果
|
||||
```
|
||||
|
||||
发布时 Server 会读取全部启用的网站配置、OpenResty 主配置模板、性能参数与缓存参数,渲染完整 OpenResty 配置,计算 `checksum`,写入 `config_versions`,再切换激活版本。
|
||||
|
||||
## 验证结果
|
||||
|
||||
发布后在管理端确认:
|
||||
|
||||
| 位置 | 期望结果 |
|
||||
| --- | --- |
|
||||
| 节点列表 | 节点在线 |
|
||||
| 节点详情 | 当前版本与激活版本一致 |
|
||||
| 应用记录 | 最近一次应用成功 |
|
||||
| 版本页面 | 新版本处于激活状态 |
|
||||
|
||||
在节点上确认 Agent 日志:
|
||||
|
||||
```bash
|
||||
journalctl -u openflare-agent -n 100 --no-pager
|
||||
```
|
||||
|
||||
用域名访问:
|
||||
|
||||
```bash
|
||||
curl -I http://app.example.com
|
||||
```
|
||||
|
||||
如果域名还没有正式解析,可以临时指定 Host 头访问节点 IP:
|
||||
|
||||
```bash
|
||||
curl -I -H 'Host: app.example.com' http://NODE_IP
|
||||
```
|
||||
|
||||
HTTPS 验证:
|
||||
|
||||
```bash
|
||||
curl -I https://app.example.com
|
||||
```
|
||||
|
||||
## 回滚
|
||||
|
||||
如果目标版本应用失败并回滚,Agent 会在本地阻断同一 `version + checksum` 的重复应用,直到控制面激活版本或 checksum 发生变化。
|
||||
|
||||
回滚到旧版本:
|
||||
|
||||
1. 打开配置版本页面。
|
||||
2. 找到上一个确认可用的历史版本。
|
||||
3. 重新激活该版本。
|
||||
4. 查看节点应用记录,确认 Agent 应用成功。
|
||||
@@ -0,0 +1,38 @@
|
||||
# 指南
|
||||
|
||||
你会学到:OpenFlare 文档如何组织、首次运行应该读哪些页面,以及部署、使用、排查和开发分别从哪里开始。
|
||||
|
||||
OpenFlare 是一套自托管的 OpenResty 控制面。它把反向代理网站配置、配置版本发布、Agent 节点同步、TLS 证书和基础观测放到一个管理端中,适合单团队或单组织管理多台代理节点。
|
||||
|
||||
## 推荐阅读路径
|
||||
|
||||
如果你第一次接触 OpenFlare,按下面顺序阅读:
|
||||
|
||||
1. [快速开始](./quick-start.md):用 Docker Compose 启动 Server,登录管理端,并接入第一个 Agent。
|
||||
2. [基础使用](./usage.md):了解网站配置、源站、证书、发布、回滚和观测的常见操作。
|
||||
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 应用和前端构建问题。
|
||||
|
||||
## 按角色查找
|
||||
|
||||
| 你想做什么 | 推荐入口 |
|
||||
| --- | --- |
|
||||
| 5 分钟内跑起管理端 | [快速开始](./quick-start.md) |
|
||||
| 发布第一条反向代理配置 | [发布第一份配置](./first-site.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 | [升级与维护](../reference/upgrade.md) |
|
||||
| 参与开发或修复问题 | [本地开发](../design/development.md) 与 [开发约束](../guildline/development-constraints.md) |
|
||||
| 理解架构和发布模型 | [系统架构](../design/architecture.md) 与 [发布模型](../design/release-model.md) |
|
||||
|
||||
## 文档分区
|
||||
|
||||
`guide/` 面向使用者和部署者,提供从安装到日常操作的可执行步骤。
|
||||
|
||||
`reference/` 收敛稳定事实,例如配置字段、命令、API 响应约定和仓库结构。
|
||||
|
||||
`design/` 面向维护者和贡献者,描述产品边界、系统架构、发布模型和工程约束。新增能力或改变边界前,应先更新对应设计文档。
|
||||
@@ -0,0 +1,201 @@
|
||||
# 快速开始
|
||||
|
||||
你会学到:如何用 Docker Compose 启动 OpenFlare Server、完成首次登录、接入第一个 Agent,并验证一份配置是否已经发布到节点。
|
||||
|
||||
OpenFlare 的最小运行单元包含:
|
||||
|
||||
| 组件 | 职责 |
|
||||
| --- | --- |
|
||||
| Server | 管理端 UI、管理 API、Agent API、配置渲染、版本发布与状态存储 |
|
||||
| Agent | 运行在代理节点上,拉取配置、写入 OpenResty、执行校验与 reload |
|
||||
| OpenResty | 实际接收流量并反向代理到源站 |
|
||||
|
||||
Agent 统一通过 OpenResty 二进制控制运行时。本地部署需要节点上已有 `openresty` 可执行文件;Docker 部署可直接运行内置 OpenResty 的 Agent 镜像。
|
||||
|
||||
## 环境要求
|
||||
|
||||
| 项目 | 要求 |
|
||||
| --- | --- |
|
||||
| Docker / Docker Compose | 用于启动 Server 和 PostgreSQL;如果采用 Docker Agent 镜像,也用于运行 Agent |
|
||||
| OpenResty | 本地安装 Agent 时需要可执行 `openresty`,或在安装脚本中指定路径 |
|
||||
| 可访问端口 | Server 默认监听 `3000`,Agent 节点需要能访问 Server 地址 |
|
||||
| 浏览器 | 用于访问管理端 |
|
||||
|
||||
[需要确认:项目建议的最低 Docker 与 Docker Compose 版本]
|
||||
|
||||
## 1. 启动 Server
|
||||
|
||||
在空目录中创建 `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: openflare
|
||||
POSTGRES_USER: openflare
|
||||
POSTGRES_PASSWORD: replace-with-strong-password
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U openflare -d openflare"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
openflare:
|
||||
image: ghcr.io/rain-kl/openflare:latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
SESSION_SECRET: replace-with-a-long-random-string
|
||||
DSN: postgres://openflare:replace-with-strong-password@postgres:5432/openflare?sslmode=disable
|
||||
GIN_MODE: release
|
||||
LOG_LEVEL: info
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
```
|
||||
|
||||
启动服务:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
确认容器已经运行:
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
docker compose logs -f openflare
|
||||
```
|
||||
|
||||
看到 `server listening` 且 `openflare` 容器状态为 running 后,访问:
|
||||
|
||||
```text
|
||||
http://localhost:3000
|
||||
```
|
||||
|
||||
默认账号:
|
||||
|
||||
| 用户名 | 密码 |
|
||||
| --- | --- |
|
||||
| `root` | `123456` |
|
||||
|
||||
首次登录后请立即修改默认密码。
|
||||
|
||||
## 2. 准备 Agent Token
|
||||
|
||||
Agent 可以用两类凭证接入:
|
||||
|
||||
| 凭证 | 适用场景 |
|
||||
| --- | --- |
|
||||
| `discovery_token` | 首次自动注册节点,由 Server 换成节点专属 Token |
|
||||
| `agent_token` | 已经在管理端创建或分配节点,直接使用节点专属 Token |
|
||||
|
||||
在管理端准备其中一种凭证后,进入下一步。
|
||||
|
||||
[需要确认:当前管理端中创建或查看 `discovery_token` 与节点 `agent_token` 的准确菜单路径]
|
||||
|
||||
## 3. 安装/运行 Agent
|
||||
|
||||
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:执行安装脚本(本地部署)
|
||||
|
||||
在代理节点上执行安装脚本。
|
||||
|
||||
使用 `discovery_token`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
|
||||
--server-url http://your-server:3000 \
|
||||
--discovery-token YOUR_DISCOVERY_TOKEN
|
||||
```
|
||||
|
||||
使用节点专属 `agent_token`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
|
||||
--server-url http://your-server:3000 \
|
||||
--agent-token YOUR_AGENT_TOKEN
|
||||
```
|
||||
|
||||
脚本默认会:
|
||||
|
||||
| 项目 | 默认值 |
|
||||
| --- | --- |
|
||||
| 安装目录 | `/opt/openflare-agent` |
|
||||
| 配置文件 | `/opt/openflare-agent/agent.json` |
|
||||
| systemd 服务 | `openflare-agent.service` |
|
||||
| OpenResty 路径 | 未指定时自动查找 `openresty` |
|
||||
|
||||
确认 Agent 服务状态:
|
||||
|
||||
```bash
|
||||
systemctl status openflare-agent
|
||||
journalctl -u openflare-agent -f
|
||||
```
|
||||
|
||||
如果没有 systemd,脚本会输出手动启动命令。
|
||||
|
||||
## 4. 发布第一份配置
|
||||
|
||||
在管理端完成以下操作:
|
||||
|
||||
1. 新增网站配置,填写网站名称、域名和源站地址。
|
||||
2. 确认网站配置处于启用状态。
|
||||
3. 发布前查看预览或变更摘要。
|
||||
4. 发布并激活新版本。
|
||||
5. 等待 Agent 在后续 heartbeat 中发现版本并应用。
|
||||
|
||||
版本号格式为 `YYYYMMDD-NNN`。历史版本不可变,回滚通过重新激活旧版本完成。
|
||||
|
||||
## 5. 验证是否成功
|
||||
|
||||
在管理端确认:
|
||||
|
||||
| 位置 | 期望结果 |
|
||||
| --- | --- |
|
||||
| 节点列表 | Agent 节点在线 |
|
||||
| 节点详情 | 当前版本与激活版本一致 |
|
||||
| 应用记录 | 最近一次应用成功 |
|
||||
| 版本页面 | 新版本处于激活状态 |
|
||||
|
||||
在 Agent 节点确认:
|
||||
|
||||
```bash
|
||||
journalctl -u openflare-agent -n 100 --no-pager
|
||||
```
|
||||
|
||||
## 常见失败原因
|
||||
|
||||
| 现象 | 排查方向 |
|
||||
| --- | --- |
|
||||
| 浏览器打不开管理端 | 确认 `docker compose ps` 中 Server 正在运行,宿主机 `3000` 端口没有被占用 |
|
||||
| 登录后数据无法保存 | 检查 PostgreSQL 容器健康状态,以及 `DSN` 中的用户名、密码、库名是否一致 |
|
||||
| Agent 无法注册 | 确认 Agent 节点能访问 `--server-url`,并检查 Token 是否填错或已失效 |
|
||||
| Agent 在线但没有应用配置 | 确认网站配置已启用,并且已经发布并激活版本 |
|
||||
| OpenResty 应用失败 | 查看节点应用记录和 `journalctl -u openflare-agent`,重点检查域名、证书、上游地址和端口占用 |
|
||||
|
||||
更多排查路径见 [故障排查](./troubleshooting.md)。
|
||||
@@ -0,0 +1,106 @@
|
||||
# SSO 登录配置
|
||||
|
||||
你会学到:如何为 OpenFlare 配置 GitHub OAuth 或标准 OIDC 登录入口,如何填写回调地址,以及第三方账号如何绑定本地用户。
|
||||
|
||||
OpenFlare 支持通过认证源配置第三方登录入口。当前支持 GitHub OAuth 与标准 OIDC Provider,例如 Logto、authentik、Keycloak、Casdoor 等。
|
||||
|
||||
认证源配置完成并启用后,会显示在登录页的第三方账号登录区域。用户可以通过第三方账号登录,也可以在已登录状态下把第三方账号绑定到当前本地账号。
|
||||
|
||||
## 使用前准备
|
||||
|
||||
你需要先准备:
|
||||
|
||||
| 项目 | 说明 |
|
||||
| --- | --- |
|
||||
| OpenFlare 访问地址 | 用户浏览器实际访问的地址,例如 `https://openflare.example.com` |
|
||||
| 认证源名称 | OpenFlare 内部唯一标识,例如 `github`、`company-oidc` |
|
||||
| Client ID | 第三方平台创建应用后提供 |
|
||||
| Client Secret | 第三方平台创建应用后提供 |
|
||||
| OIDC Discovery URL | 仅 OIDC 需要,例如 `https://idp.example.com/.well-known/openid-configuration` |
|
||||
|
||||
**确认系统设置->通用设置->服务器地址能正确和域名匹配**
|
||||
|
||||
认证源名称只能包含字母、数字、短横线或下划线,并且必须以字母或数字开头。认证源名称会出现在回调地址中,保存后如需修改名称,也必须同步修改第三方平台中的回调地址。
|
||||
|
||||
## 回调地址
|
||||
|
||||
第三方平台中的 Redirect URI / Callback URL 填写格式为:
|
||||
|
||||
```text
|
||||
<OpenFlare 访问地址>/oauth/<认证源名称>
|
||||
```
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
https://openflare.example.com/oauth/github
|
||||
https://openflare.example.com/oauth/company-oidc
|
||||
```
|
||||
|
||||
在管理端新增或修改认证源时,表单会根据当前浏览器访问地址和你输入的认证源名称自动显示应填写的回调地址。
|
||||
|
||||
## 配置 GitHub 登录
|
||||
|
||||
1. 在 GitHub 创建 OAuth App。
|
||||
2. `Homepage URL` 填写 OpenFlare 访问地址。
|
||||
3. `Authorization callback URL` 填写 OpenFlare 显示的回调地址,例如 `https://openflare.example.com/oauth/github`。
|
||||
4. 复制 GitHub 提供的 Client ID 和 Client Secret。
|
||||
5. 登录 OpenFlare 管理端,进入“设置 -> 系统设置 -> 配置认证源”。
|
||||
6. 新增认证源,类型选择 `GitHub`。
|
||||
7. 填写认证源名称、展示名称、Client ID、Client Secret。
|
||||
8. Scope 默认使用 `user:email`,通常无需修改。
|
||||
9. 保存并启用认证源。
|
||||
|
||||
启用后,登录页会显示对应的 GitHub 登录按钮。
|
||||
|
||||
## 配置 OIDC 登录
|
||||
|
||||
1. 在 OIDC Provider 中创建应用或客户端。
|
||||
2. 应用类型选择 Web / Confidential Client。
|
||||
3. Redirect URI / Callback URL 填写 OpenFlare 显示的回调地址,例如 `https://openflare.example.com/oauth/company-oidc`。
|
||||
4. 复制 Client ID 和 Client Secret。
|
||||
5. 获取 Provider 的 Discovery URL,通常以 `/.well-known/openid-configuration` 结尾。
|
||||
6. 登录 OpenFlare 管理端,进入“设置 -> 系统设置 -> 配置认证源”。
|
||||
7. 新增认证源,类型选择 `OIDC`。
|
||||
8. 填写认证源名称、展示名称、Client ID、Client Secret、OIDC Discovery URL。
|
||||
9. Scope 默认使用 `openid profile email`。如果 Provider 限制了 scope,请按 Provider 允许的值调整。
|
||||
10. 保存并启用认证源。
|
||||
|
||||
启用后,登录页会显示对应的 OIDC 登录按钮。
|
||||
|
||||
## 登录与绑定行为
|
||||
|
||||
第三方账号回到 OpenFlare 后按以下规则处理:
|
||||
|
||||
| 场景 | 行为 |
|
||||
| --- | --- |
|
||||
| 第三方账号已绑定本地用户 | 直接登录 |
|
||||
| 用户已登录并发起第三方授权 | 绑定到当前本地用户 |
|
||||
| 第三方账号未绑定,且允许注册 | 自动创建普通用户并绑定 |
|
||||
| 第三方账号未绑定,且关闭注册 | 要求输入已有本地账号密码完成绑定 |
|
||||
|
||||
如果希望只允许已有用户使用 SSO,可以关闭用户注册。未绑定的第三方账号会进入绑定已有账号流程。
|
||||
|
||||
## 修改认证源
|
||||
|
||||
修改认证源时,Client Secret 输入框留空表示保留已有密钥;填写新值则会覆盖保存。
|
||||
|
||||
如果修改了认证源名称,回调地址也会随之变化。你必须到第三方平台同步修改 Redirect URI / Callback URL,否则第三方平台会拒绝回调或返回错误。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 返回 `invalid_scope`
|
||||
|
||||
说明第三方平台不允许当前配置的 Scope。OIDC 默认 Scope 是 `openid profile email`,GitHub 默认 Scope 是 `user:email`。请到认证源编辑页调整 Scope,或在第三方平台放行对应 Scope。
|
||||
|
||||
### 提示回调地址不匹配
|
||||
|
||||
检查第三方平台中配置的 Redirect URI / Callback URL 是否与 OpenFlare 表单提示完全一致。协议、域名、端口和路径都必须一致。
|
||||
|
||||
### 登录页没有显示第三方登录按钮
|
||||
|
||||
检查认证源是否已启用,并确认 Client ID 和 Client Secret 已保存。启用认证源前,OpenFlare 会校验这些字段。
|
||||
|
||||
### 已经保存 Client Secret,但列表不显示明文
|
||||
|
||||
这是预期行为。OpenFlare 不会通过 API 回显 Client Secret,只显示该密钥是否已配置。
|
||||
@@ -0,0 +1,229 @@
|
||||
# 故障排查
|
||||
|
||||
你会学到:如何按症状排查 OpenFlare Server、数据库、登录、Agent、OpenResty、配置发布和前端构建问题。
|
||||
|
||||
排查时先确认问题发生在哪一层:浏览器、Server、数据库、Agent、OpenResty、源站或 DNS。OpenFlare 的配置不会直接在线写入所有节点,只有激活版本变化后,Agent 才会在 heartbeat 中发现并应用。
|
||||
|
||||
## 快速定位
|
||||
|
||||
| 现象 | 先看哪里 |
|
||||
| --- | --- |
|
||||
| 管理端打不开 | Server 容器或进程日志、端口监听 |
|
||||
| 登录异常 | 默认账号、Session Secret、浏览器请求、Server 日志 |
|
||||
| 数据无法保存 | 数据库连接、SQLite 文件权限、PostgreSQL 健康状态 |
|
||||
| Agent 离线 | Agent 日志、Token、Server 地址、网络连通性 |
|
||||
| 发布后节点未更新 | 激活版本、节点 heartbeat、应用记录 |
|
||||
| OpenResty 应用失败 | 应用记录、Agent 日志、证书、上游地址、端口占用 |
|
||||
| 访问分析无数据 | OpenResty 容器状态、观测端口、Agent 补报日志 |
|
||||
|
||||
## Server 无法启动
|
||||
|
||||
1. 查看日志:
|
||||
|
||||
```bash
|
||||
docker compose logs -n 200 openflare
|
||||
```
|
||||
|
||||
源码运行时查看终端输出。
|
||||
|
||||
2. 检查端口占用:
|
||||
|
||||
```bash
|
||||
lsof -i :3000
|
||||
```
|
||||
|
||||
3. 如果使用 PostgreSQL,确认数据库健康:
|
||||
|
||||
```bash
|
||||
docker compose ps postgres
|
||||
docker compose logs -n 100 postgres
|
||||
```
|
||||
|
||||
4. 如果使用 SQLite,确认数据库文件目录可写:
|
||||
|
||||
```bash
|
||||
ls -ld "$(dirname /path/to/openflare.db)"
|
||||
```
|
||||
|
||||
常见原因:
|
||||
|
||||
| 日志或现象 | 处理 |
|
||||
| --- | --- |
|
||||
| 数据库连接失败 | 检查 `DSN` 中用户名、密码、主机、端口、库名和 `sslmode` |
|
||||
| SQLite 无法创建文件 | 检查 `SQLITE_PATH` 所在目录是否存在且可写 |
|
||||
| 端口被占用 | 修改 `PORT` 或 `--port`,或停止占用端口的进程 |
|
||||
|
||||
## 管理端打不开或空白
|
||||
|
||||
1. 确认 Server 正在监听:
|
||||
|
||||
```bash
|
||||
curl -I http://127.0.0.1:3000
|
||||
```
|
||||
|
||||
2. 如果是源码运行,确认已经构建前端静态产物:
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
pnpm build
|
||||
```
|
||||
|
||||
3. 检查浏览器访问地址是否与反向代理配置一致。
|
||||
|
||||
4. 如果通过前端开发服务器访问,确认后端代理地址:
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
NEXT_DEV_BACKEND_URL=http://127.0.0.1:3000 pnpm dev
|
||||
```
|
||||
|
||||
## 默认账号无法登录
|
||||
|
||||
默认账号是 `root` / `123456`。首次登录后如果已经修改密码,应使用修改后的密码。
|
||||
|
||||
排查步骤:
|
||||
|
||||
1. 确认连接的是预期数据库,避免 `SQLITE_PATH` 或 `DSN` 指向了另一个环境。
|
||||
2. 查看 Server 日志中使用的是 `sqlite` 还是 `postgres`。
|
||||
3. 如果部署在多副本或反向代理后,确认 `SESSION_SECRET` 固定且各实例一致。
|
||||
4. 清理浏览器 Cookie 后重新登录。
|
||||
|
||||
[需要确认:当前项目是否提供安全的 root 密码重置命令或流程]
|
||||
|
||||
## Agent 无法注册或一直离线
|
||||
|
||||
在 Agent 节点执行:
|
||||
|
||||
```bash
|
||||
curl -I http://your-server:3000
|
||||
```
|
||||
|
||||
查看 Agent 日志:
|
||||
|
||||
```bash
|
||||
journalctl -u openflare-agent -n 200 --no-pager
|
||||
```
|
||||
|
||||
检查配置文件:
|
||||
|
||||
```bash
|
||||
sed -n '1,160p' /opt/openflare-agent/agent.json
|
||||
```
|
||||
|
||||
重点确认:
|
||||
|
||||
| 配置 | 说明 |
|
||||
| --- | --- |
|
||||
| `server_url` | 必须是 Agent 节点能访问的 Server 地址 |
|
||||
| `agent_token` / `discovery_token` | 至少填写一个 |
|
||||
| `heartbeat_interval` | 支持毫秒整数或 Go duration 字符串 |
|
||||
| `request_timeout` | 网络较慢时可适当增大 |
|
||||
|
||||
如果日志提示 Token 无效,重新在管理端准备 Token 并更新 `agent.json`,然后重启:
|
||||
|
||||
```bash
|
||||
systemctl restart openflare-agent
|
||||
```
|
||||
|
||||
## 发布后节点没有应用新版本
|
||||
|
||||
按顺序检查:
|
||||
|
||||
1. 版本页面中是否已经激活目标版本。
|
||||
2. 节点是否在线,最近心跳时间是否更新。
|
||||
3. 应用记录中是否有目标版本的成功、警告或失败记录。
|
||||
4. 网站配置是否启用;未启用的网站不会参与发布渲染。
|
||||
5. Agent 日志是否出现拉取、校验、reload 或回滚信息。
|
||||
|
||||
查看 Agent 日志:
|
||||
|
||||
```bash
|
||||
journalctl -u openflare-agent -f
|
||||
```
|
||||
|
||||
注意:某个目标 `version + checksum` 一旦应用失败并回退,Agent 会在本地状态中阻断该目标重复应用。修正配置后需要重新发布生成新的 checksum,或激活旧版本回滚。
|
||||
|
||||
如果这是 Agent 首次应用配置,且本地没有历史 `nginx.conf` 可回滚,失败目标仍会被阻断,但 Agent 会尝试进入安全兜底运行态。此时应用记录和 Agent 日志会包含 `fallback runtime started`,OpenResty 对外只监听 `80` 端口并统一返回 `503` 与 `OpenFlare: No Valid Configuration`,同时保留本地 `stub_status` 健康检查入口。修正配置并重新发布新版本后,Agent 会覆盖兜底配置并恢复正常代理。
|
||||
|
||||
## OpenResty 应用失败
|
||||
|
||||
常见原因:
|
||||
|
||||
| 原因 | 排查 |
|
||||
| --- | --- |
|
||||
| 域名或 server 块冲突 | 检查同一域名是否被多个网站配置使用 |
|
||||
| 上游地址不合法 | 确认所有上游都是 `http://` 或 `https://` |
|
||||
| 多上游格式不符合约束 | 多上游必须是纯 `scheme://host[:port]` |
|
||||
| 证书缺失或路径错误 | 检查域名是否绑定证书,以及 Agent 证书目录是否可写 |
|
||||
| 端口被占用 | 检查本机 `80`、`443` 端口 |
|
||||
|
||||
OpenResty 配置校验:
|
||||
|
||||
```bash
|
||||
openresty -t -c /path/to/openflare/data/etc/nginx/nginx.conf
|
||||
```
|
||||
|
||||
OpenResty 运行状态:
|
||||
|
||||
```bash
|
||||
ps aux | grep openresty
|
||||
```
|
||||
|
||||
Agent 周期性健康检查通过本地 `http://127.0.0.1:<openresty_observability_port>/openflare/stub_status` 判断 OpenResty 是否存活,不会反复执行 `openresty -t`。如果节点被标记为 unhealthy,优先确认该本地观测端口是否正在监听;如果只在应用配置时出现 `host not found in upstream`,说明失败来自配置校验或 reload,而不是周期性健康探针。
|
||||
|
||||
实际二进制路径和主配置路径以 `agent.json` 中的 `openresty_path` 与 `main_config_path` 为准。
|
||||
|
||||
## HTTPS 不生效
|
||||
|
||||
1. 确认证书已经上传或托管。
|
||||
2. 确认网站配置中对应域名已经绑定证书。
|
||||
3. 确认发布并激活了新版本。
|
||||
4. 查看应用记录是否成功。
|
||||
5. 用 `curl` 查看证书和状态码:
|
||||
|
||||
```bash
|
||||
curl -Iv https://your-domain
|
||||
```
|
||||
|
||||
没有绑定证书的域名不会被自动加入 HTTPS 配置,这是预期行为。
|
||||
|
||||
## 访问分析没有数据
|
||||
|
||||
1. 确认节点已经成功应用包含观测 Lua 资源的配置。
|
||||
2. 确认 OpenResty 正在运行。
|
||||
3. 查看 Agent 日志是否有观测采集或补报失败信息。
|
||||
4. 检查 `openresty_observability_port` 是否被占用,默认是 `18081`。
|
||||
5. 确认 Server 侧没有因数据库清理策略删除对应时间窗口数据。
|
||||
|
||||
## 前端构建失败
|
||||
|
||||
执行:
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
corepack enable
|
||||
pnpm install
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm build
|
||||
```
|
||||
|
||||
常见原因:
|
||||
|
||||
| 现象 | 处理 |
|
||||
| --- | --- |
|
||||
| pnpm 版本不一致 | 使用 `corepack enable` 后重新安装 |
|
||||
| 类型错误 | 先运行 `pnpm typecheck` 定位具体文件 |
|
||||
| API 类型不一致 | 检查 `lib/api/` 和 `types/` 中的响应结构 |
|
||||
| E2E 失败 | 确认 Server 和前端开发服务器都已启动 |
|
||||
|
||||
## 文档站构建失败
|
||||
|
||||
```bash
|
||||
cd docs
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
如果是链接错误,检查新增页面是否已经加入 `docs/config.ts` 侧边栏,或者相对链接是否指向存在的 Markdown 文件。
|
||||
@@ -0,0 +1,151 @@
|
||||
# 基础使用
|
||||
|
||||
你会学到:OpenFlare 中网站配置、源站、证书、版本、节点和观测分别是什么,以及日常使用时应按什么顺序操作。
|
||||
|
||||
OpenFlare 不直接在线修改节点上的 Nginx/OpenResty 配置。你在管理端修改的是控制面数据;只有发布并激活新版本后,Agent 才会拉取完整配置并应用到节点。
|
||||
|
||||
## 核心概念
|
||||
|
||||
| 概念 | 说明 |
|
||||
| --- | --- |
|
||||
| 网站配置 | 反向代理配置的聚合对象,一条网站配置可以绑定一个或多个域名 |
|
||||
| 主域名 | `domains` 列表中的第一个域名,用作该网站的主要展示域名 |
|
||||
| 源站 | 被反向代理访问的上游地址,例如 `http://10.0.0.10:8080` |
|
||||
| 配置版本 | 一次发布生成的完整 OpenResty 配置快照,历史版本不可变 |
|
||||
| 激活版本 | 当前全局生效的配置版本,所有节点默认消费同一份激活版本 |
|
||||
| Agent | 节点侧进程,负责注册、心跳、同步、校验、reload 和失败回滚 |
|
||||
|
||||
## 推荐操作顺序
|
||||
|
||||
日常发布一条反向代理配置时,推荐按这个顺序:
|
||||
|
||||
1. 确认至少有一个 Agent 节点在线。
|
||||
2. 新增或选择源站地址。
|
||||
3. 新增网站配置,填写域名、源站和站点级配置。
|
||||
4. 如需 HTTPS,上传或选择证书,并按域名绑定。
|
||||
5. 预览配置或查看变更摘要。
|
||||
6. 发布并激活新版本。
|
||||
7. 在节点详情和应用记录中确认应用结果。
|
||||
|
||||
## 创建网站配置
|
||||
|
||||
网站配置至少需要:
|
||||
|
||||
| 字段 | 要求 |
|
||||
| --- | --- |
|
||||
| 网站名称 | 业务唯一标识;未显式填写时通常可使用主域名 |
|
||||
| 域名 | 至少一个域名,第一项为主域名;任一域名全局只能属于一个网站 |
|
||||
| 源站地址 | 合法的 `http://` 或 `https://` 地址 |
|
||||
| 启用状态 | 只有启用的网站配置会参与发布渲染 |
|
||||
|
||||
示例:
|
||||
|
||||
| 字段 | 示例 |
|
||||
| --- | --- |
|
||||
| 网站名称 | `docs` |
|
||||
| 域名 | `docs.example.com` |
|
||||
| 源站地址 | `http://10.0.0.10:8080` |
|
||||
| 回源 Host | `docs.internal.example.com` |
|
||||
|
||||
上游地址规则:
|
||||
|
||||
* 单上游可以携带 base path 或 query,例如 `https://app.example.com/base?from=openflare`。
|
||||
* 多上游用于负载均衡时,每个上游必须是纯 `scheme://host[:port]`。
|
||||
* 多上游在同一规则内应使用一致协议。
|
||||
|
||||
## 管理源站
|
||||
|
||||
源站是轻量目录,用来复用常见上游地址。网站配置关联源站后,仍会保存可渲染的 `origin_url` 快照,确保历史配置版本可以独立回放。
|
||||
|
||||
推荐做法:
|
||||
|
||||
* 把经常复用的内部服务地址维护为源站。
|
||||
* 修改源站目录后,检查已发布的网站配置是否需要同步更新源站快照。
|
||||
* 发布前使用预览或 diff 确认渲染结果。
|
||||
|
||||
## 启用 HTTPS
|
||||
|
||||
HTTPS 按域名绑定证书,而不是按整个网站统一强制启用。
|
||||
|
||||
操作顺序:
|
||||
|
||||
1. 在证书管理中上传或托管证书。
|
||||
2. 进入网站配置,为需要 HTTPS 的域名选择证书。
|
||||
3. 未绑定证书的域名会保留 HTTP,不会被自动放入 `443 ssl` server 块。
|
||||
4. 发布并激活新版本。
|
||||
|
||||
如果一个网站包含多个域名,Server 发布时会按证书分组渲染 HTTPS 配置,同时保持这些域名属于同一份网站快照。
|
||||
|
||||
## 配置 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 差异补齐。
|
||||
|
||||
## 发布、激活与回滚
|
||||
|
||||
标准链路:
|
||||
|
||||
```text
|
||||
修改配置 -> 预览 / diff -> 发布 -> 生成完整版本 -> 激活版本 -> Agent 拉取 -> 本地应用 -> 上报结果
|
||||
```
|
||||
|
||||
发布时 Server 会读取全部启用的网站配置、OpenResty 主配置模板、性能参数、缓存参数和证书资源,生成完整配置并计算 `checksum`。
|
||||
|
||||
回滚不是修改历史版本,而是重新激活旧版本。Agent 发现激活版本变化后,会按普通同步流程拉取并应用。
|
||||
|
||||
## 查看节点与观测
|
||||
|
||||
节点页面适合回答三个问题:
|
||||
|
||||
| 问题 | 查看位置 |
|
||||
| --- | --- |
|
||||
| 节点是否在线 | 节点列表或节点详情 |
|
||||
| 当前运行哪个版本 | 节点详情中的当前版本 |
|
||||
| 最近一次应用是否成功 | 应用记录 |
|
||||
|
||||
节点 IP 默认由 Agent 注册和后续心跳自动回填。若在管理端填写或修改 IP,节点编辑会默认开启“锁定节点 IP”;开启后 Agent 上报不会覆盖该 IP。关闭锁定后,下一次 Agent 心跳或 WebSocket 状态上报会重新按自动逻辑更新。
|
||||
|
||||
访问分析和资源快照用于基础观测。OpenFlare 只保留受控时间窗口内的访问明细,不定位为通用日志平台。如果需要长期日志检索,应接入独立日志系统。
|
||||
|
||||
## 常见场景
|
||||
|
||||
### 新增一个内部服务反代
|
||||
|
||||
1. 确认源站服务可从 Agent 节点访问。
|
||||
2. 在管理端新增网站配置。
|
||||
3. 填写域名,例如 `app.example.com`。
|
||||
4. 填写源站,例如 `http://10.0.0.20:8080`。
|
||||
5. 发布并激活版本。
|
||||
6. 在 Agent 节点或浏览器访问域名验证。
|
||||
|
||||
### 给已有域名启用 HTTPS
|
||||
|
||||
1. 准备覆盖该域名的证书。
|
||||
2. 在证书管理中上传或创建证书记录。
|
||||
3. 回到网站配置,为对应域名选择证书。
|
||||
4. 发布并激活版本。
|
||||
5. 用浏览器或 `curl -I https://your-domain` 验证证书链和状态码。
|
||||
|
||||
### 回滚一次失败发布
|
||||
|
||||
1. 打开配置版本页面。
|
||||
2. 找到上一个已知可用版本。
|
||||
3. 重新激活该版本。
|
||||
4. 查看节点应用记录,确认 Agent 已应用旧版本。
|
||||
5. 修正配置后再发布新版本。
|
||||
|
||||
## 推荐实践
|
||||
|
||||
* 生产环境显式配置 `SESSION_SECRET`,并优先使用 PostgreSQL。
|
||||
* 修改网站配置后先看预览或 diff,再发布。
|
||||
* 每次发布后检查节点详情与应用记录。
|
||||
* 多节点部署时保持 Agent 到 Server 的网络路径稳定。
|
||||
* 不在节点上手动修改 OpenFlare 托管的 OpenResty 配置文件;下次发布会覆盖这些文件。
|
||||
@@ -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 组。
|
||||
@@ -0,0 +1,188 @@
|
||||
你是一个资深 Go 后端工程师,负责维护和开发一个长期演进的 Go 应用。
|
||||
|
||||
你的目标不是“尽快写完代码”,而是产出可维护、可测试、可演进、符合 Go 生态习惯的高质量代码。禁止为了完成任务而堆砌临时代码、过度抽象、重复逻辑或破坏现有架构。
|
||||
|
||||
在任何开发前,你必须先阅读并理解现有代码结构,包括:
|
||||
- 项目目录结构
|
||||
- 入口文件
|
||||
- 配置管理方式
|
||||
- 数据库/缓存/消息队列访问方式
|
||||
- HTTP/RPC/API 层设计
|
||||
- service/usecase/domain/repository 等分层方式
|
||||
- 错误处理方式
|
||||
- 日志方式
|
||||
- 测试组织方式
|
||||
- 依赖注入方式
|
||||
- 现有编码风格
|
||||
|
||||
如果你不确定某个模块的职责,先通过代码上下文推断,不要随意新建重复模块。
|
||||
|
||||
开发原则:
|
||||
|
||||
1. 架构优先
|
||||
- 优先融入现有架构,而不是另起炉灶。
|
||||
- 不要随便新增 global variable、init 副作用、隐式依赖。
|
||||
- 不要把业务逻辑写进 handler/controller。
|
||||
- handler 只负责参数解析、鉴权上下文、调用 usecase/service、返回响应。
|
||||
- service/usecase 负责业务编排。
|
||||
- repository/dao 负责数据访问。
|
||||
- domain/model 负责核心业务对象和规则。
|
||||
- 基础设施代码与业务代码隔离。
|
||||
|
||||
2. Go 风格
|
||||
- 使用清晰、直接、朴素的 Go 代码。
|
||||
- 不要模仿 Java 式过度抽象。
|
||||
- interface 应该由使用方定义,而不是提供方强行定义。
|
||||
- 小接口优先。
|
||||
- 命名要准确,不使用 Manager、Helper、Util 这类含糊名称,除非确实必要。
|
||||
- 函数保持短小,单一职责。
|
||||
- 不要为了“看起来高级”引入泛型、反射、复杂设计模式。
|
||||
- 不要隐藏错误。
|
||||
- error 必须带上下文信息,必要时使用 fmt.Errorf("...: %w", err)。
|
||||
- 不要 panic,除非是程序启动阶段的不可恢复错误。
|
||||
|
||||
3. 可维护性
|
||||
- 修改前先分析影响范围。
|
||||
- 尽量最小改动,不做无关重构。
|
||||
- 不改变公开 API、数据库结构、配置格式,除非任务明确要求。
|
||||
- 如果必须改变,要说明兼容性影响和迁移方案。
|
||||
- 删除代码前确认没有调用方。
|
||||
- 避免复制粘贴已有逻辑,应抽取到合适位置,但不要过度抽象。
|
||||
- 对复杂业务逻辑添加必要注释,解释“为什么”,不要注释显而易见的“是什么”。
|
||||
|
||||
4. 测试要求
|
||||
- 新增业务逻辑必须补充单元测试。
|
||||
- 修复 bug 必须补充回归测试。
|
||||
- 测试应覆盖正常路径、异常路径、边界条件。
|
||||
- 不要为了测试方便破坏业务代码结构。
|
||||
- 外部依赖使用 mock/fake/stub 隔离。
|
||||
- 测试命名清晰,例如 TestXXX_WhenYYY_ShouldZZZ。
|
||||
- 表驱动测试优先,但不要为了表驱动牺牲可读性。
|
||||
|
||||
5. 并发与资源管理
|
||||
- goroutine 必须有退出机制。
|
||||
- 涉及 context 的地方必须正确传递 context.Context。
|
||||
- 不要随意使用 context.Background() 替代上游 context。
|
||||
- channel 必须明确关闭责任。
|
||||
- 锁的范围要小,避免死锁。
|
||||
- HTTP、数据库、文件、连接等资源必须正确关闭。
|
||||
- 注意 race condition、goroutine leak、连接泄露。
|
||||
|
||||
6. 数据库与事务
|
||||
- 数据库访问必须在 repository/dao 层。
|
||||
- 事务边界应由业务用例层控制,而不是散落在多个底层函数中。
|
||||
- 不要在循环中产生明显低效的 N+1 查询,除非数据量可控且有说明。
|
||||
- SQL 要可读、参数化,禁止拼接不可信输入。
|
||||
- schema 变更必须考虑迁移、回滚和兼容性。
|
||||
|
||||
7. API 设计
|
||||
- 请求参数必须校验。
|
||||
- 错误响应要稳定、清晰,不泄露内部敏感信息。
|
||||
- 日志中不要打印密码、token、密钥、身份证号等敏感数据。
|
||||
- 返回结构保持向后兼容。
|
||||
- HTTP 状态码要语义正确。
|
||||
|
||||
8. 日志与可观测性
|
||||
- 关键路径要有必要日志。
|
||||
- 错误日志要包含排查所需上下文,但不要泄露敏感数据。
|
||||
- 不要滥打日志。
|
||||
- 不要在库代码里直接 fmt.Println。
|
||||
- 如果项目已有 logger,要统一使用现有 logger。
|
||||
|
||||
9. 安全要求
|
||||
- 所有外部输入都不可信。
|
||||
- 不要硬编码密钥、token、密码。
|
||||
- 不要把敏感配置提交到代码。
|
||||
- 文件路径、URL、命令执行、SQL、模板渲染等位置必须注意注入风险。
|
||||
- 鉴权和权限判断必须放在明确的位置,不能依赖前端或调用方自觉。
|
||||
|
||||
10. 性能要求
|
||||
- 不要过早优化。
|
||||
- 但不能写明显低效代码。
|
||||
- 对热点路径要避免不必要的内存分配、大对象复制、重复解析。
|
||||
- 大数据量处理应考虑分页、流式处理、批量操作。
|
||||
- 如果引入缓存,必须说明一致性、过期策略和失效条件。
|
||||
|
||||
工作流程:
|
||||
|
||||
每次接到开发任务,你必须按以下步骤执行:
|
||||
|
||||
第一步:理解需求
|
||||
- 用自己的话简要复述需求。
|
||||
- 明确输入、输出、边界条件、异常情况。
|
||||
- 如果需求含糊,列出你的合理假设,不要直接乱写。
|
||||
|
||||
第二步:阅读现有代码
|
||||
- 找出相关模块、调用链、数据结构、接口、测试。
|
||||
- 说明当前代码是如何工作的。
|
||||
- 判断改动应该放在哪一层。
|
||||
|
||||
第三步:设计方案
|
||||
- 给出最小可行修改方案。
|
||||
- 说明为什么放在这些文件/模块中。
|
||||
- 说明是否影响已有 API、数据库、配置、测试。
|
||||
- 如果有多个方案,比较优缺点,选择更稳妥的方案。
|
||||
|
||||
第四步:编码
|
||||
- 只修改与任务相关的代码。
|
||||
- 保持现有代码风格。
|
||||
- 不引入不必要的新依赖。
|
||||
- 不制造重复逻辑。
|
||||
- 不留下 TODO、临时代码、调试代码。
|
||||
|
||||
第五步:测试
|
||||
- 补充或更新测试。
|
||||
- 说明测试覆盖了哪些场景。
|
||||
- 如果无法运行测试,要说明原因,并给出应该运行的命令。
|
||||
|
||||
第六步:交付说明
|
||||
- 总结改了什么。
|
||||
- 说明为什么这样改。
|
||||
- 说明潜在风险。
|
||||
- 给出验证方式。
|
||||
- 如果存在未完成项,必须明确列出,不要假装完成。
|
||||
|
||||
输出格式:
|
||||
|
||||
你每次回复都应包含:
|
||||
|
||||
1. 需求理解
|
||||
2. 现有代码分析
|
||||
3. 修改方案
|
||||
4. 具体改动
|
||||
5. 测试与验证
|
||||
6. 风险与注意事项
|
||||
|
||||
如果只是让我审查代码,则输出:
|
||||
1. 问题列表
|
||||
2. 严重程度:致命 / 高 / 中 / 低
|
||||
3. 影响说明
|
||||
4. 修改建议
|
||||
5. 推荐改法示例
|
||||
|
||||
代码质量红线:
|
||||
|
||||
禁止出现以下行为:
|
||||
- 为了完成需求复制粘贴大段重复代码
|
||||
- 在 handler 中塞业务逻辑
|
||||
- 到处传 map[string]interface{}
|
||||
- 使用全局变量绕过依赖注入
|
||||
- 随意新增 util/helper 垃圾桶包
|
||||
- 忽略 error
|
||||
- catch-all 式错误处理
|
||||
- 函数超过合理长度仍继续堆逻辑
|
||||
- 修改无关代码
|
||||
- 未经说明改变已有行为
|
||||
- 无测试地修改核心逻辑
|
||||
- 引入大型依赖只为解决小问题
|
||||
- 写完代码不说明验证方式
|
||||
- 不理解现有架构就直接重构
|
||||
|
||||
当你发现现有代码已经比较混乱时:
|
||||
- 不要一次性大重构。
|
||||
- 先局部止血。
|
||||
- 新代码尽量写在清晰边界内。
|
||||
- 对旧代码只做必要改动。
|
||||
- 如果需要重构,先提出分阶段计划。
|
||||
|
||||
请始终以“长期维护这个项目的人”的标准来写代码,而不是以“完成一次性任务”的标准来写代码。
|
||||
@@ -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. 所有类似的切片加工辅助函数都必须遵循此行为。
|
||||
@@ -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),执行时仍按数据模型、接口、前端页面、迁移测试与文档联动的顺序推进。
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
layout: home
|
||||
|
||||
hero:
|
||||
name: OpenFlare
|
||||
text: 自托管 OpenResty 控制面
|
||||
tagline: 管理反向代理规则、配置发布、节点同步、TLS 证书与基础观测。
|
||||
actions:
|
||||
- theme: brand
|
||||
text: 快速开始
|
||||
link: /guide/quick-start
|
||||
- theme: alt
|
||||
text: 设计边界
|
||||
link: /design/
|
||||
- theme: alt
|
||||
text: GitHub
|
||||
link: https://github.com/Rain-kl/OpenFlare
|
||||
|
||||
features:
|
||||
- icon: 🧭
|
||||
title: 统一控制面
|
||||
details: 在一个管理端维护网站、域名、源站、证书、节点与版本状态。
|
||||
- icon: 🚀
|
||||
title: 不可变发布
|
||||
details: 每次发布生成完整 OpenResty 配置快照,可预览、激活和回滚。
|
||||
- icon: 🔁
|
||||
title: Agent 自动应用
|
||||
details: 节点侧自动拉取、校验、reload,并在失败时回滚到可运行配置。
|
||||
- icon: 📊
|
||||
title: 基础观测
|
||||
details: 提供请求聚合、访问分析、资源快照、健康事件与节点详情。
|
||||
---
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "./node_modules/@lunariajs/core/config.schema.json",
|
||||
"repository": {
|
||||
"name": "Rain-kl/OpenFlare",
|
||||
"rootDir": "docs"
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"location": "**/config.ts",
|
||||
"pattern": "@lang/@path",
|
||||
"type": "universal"
|
||||
},
|
||||
{
|
||||
"location": "**/*.md",
|
||||
"pattern": "@lang/@path",
|
||||
"type": "universal"
|
||||
}
|
||||
],
|
||||
"defaultLocale": {
|
||||
"label": "简体中文",
|
||||
"lang": "zh"
|
||||
},
|
||||
"locales": [
|
||||
{
|
||||
"label": "English",
|
||||
"lang": "en"
|
||||
}
|
||||
],
|
||||
"outDir": ".vitepress/dist/_translations",
|
||||
"ignoreKeywords": ["lunaria-ignore"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "openflare-docs",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vitepress dev",
|
||||
"build": "vitepress build",
|
||||
"preview": "vitepress preview",
|
||||
"lunaria:build": "lunaria build",
|
||||
"lunaria:open": "open-cli .vitepress/dist/_translations/index.html"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lunariajs/core": "^0.1.1",
|
||||
"markdown-it-mathjax3": "^4.3.2",
|
||||
"open-cli": "^8.0.0",
|
||||
"postcss-rtlcss": "^5.7.1",
|
||||
"vitepress": "2.0.0-alpha.17",
|
||||
"vitepress-plugin-llms": "^1.11.0"
|
||||
}
|
||||
}
|
||||
Generated
+2940
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,172 @@
|
||||
# 接入 Agent
|
||||
|
||||
你会学到:Agent 的职责、两种接入 Token 的区别、安装脚本参数、`agent.json` 配置方式,以及如何确认节点已经上线。
|
||||
|
||||
OpenFlare Agent 运行在代理节点侧。它不会接收远程 shell 指令,而是通过 Agent API 拉取控制面发布的配置版本,在本地写入 OpenResty 文件、执行配置校验、reload,并在失败时尝试回滚到可运行配置。
|
||||
|
||||
## 接入方式
|
||||
|
||||
| 方式 | 适用场景 |
|
||||
| --- | --- |
|
||||
| `discovery_token` | 首次自动注册节点,由 Server 置换为节点专属凭证 |
|
||||
| `agent_token` | 已在管理端创建或分配节点,直接使用节点专属凭证接入 |
|
||||
|
||||
`agent_token` 与 `discovery_token` 至少填写一个。
|
||||
|
||||
[需要确认:当前管理端中创建或查看 `discovery_token` 与节点 `agent_token` 的准确菜单路径]
|
||||
|
||||
## 一键安装
|
||||
|
||||
使用 `discovery_token`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
|
||||
--server-url http://your-server:3000 \
|
||||
--discovery-token YOUR_DISCOVERY_TOKEN
|
||||
```
|
||||
|
||||
使用节点专属 `agent_token`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
|
||||
--server-url http://your-server:3000 \
|
||||
--agent-token YOUR_AGENT_TOKEN
|
||||
```
|
||||
|
||||
安装脚本会下载最新 Agent,默认写入 `/opt/openflare-agent`,生成 `agent.json`,并在 Linux + systemd 环境创建 `openflare-agent.service`。
|
||||
|
||||
支持参数:
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| `--server-url` | Server 地址,必填 |
|
||||
| `--discovery-token` | 首次自动注册 Token |
|
||||
| `--agent-token` | 节点专属 Token |
|
||||
| `--install-dir` | 安装目录,默认 `/opt/openflare-agent` |
|
||||
| `--openresty-path` | OpenResty 二进制路径,未传时自动查找 `openresty` |
|
||||
| `--repo` | 下载 Agent 的 GitHub 仓库,默认 `Rain-kl/OpenFlare` |
|
||||
| `--no-service` | 不创建 systemd 服务 |
|
||||
|
||||
## 配置文件
|
||||
|
||||
默认配置文件路径:
|
||||
|
||||
```text
|
||||
/opt/openflare-agent/agent.json
|
||||
```
|
||||
|
||||
本地配置示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"server_url": "http://127.0.0.1:3000",
|
||||
"agent_token": "replace-with-node-auth-token",
|
||||
"data_dir": "./data",
|
||||
"openresty_path": "openresty",
|
||||
"openresty_observability_port": 18081,
|
||||
"observability_replay_minutes": 15,
|
||||
"heartbeat_interval": 10000,
|
||||
"request_timeout": 10000
|
||||
}
|
||||
```
|
||||
|
||||
自定义 OpenResty 路径示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"server_url": "http://127.0.0.1:3000",
|
||||
"agent_token": "replace-with-node-auth-token",
|
||||
"data_dir": "/var/lib/openflare-agent",
|
||||
"openresty_path": "/usr/local/openresty/nginx/sbin/openresty",
|
||||
"main_config_path": "/var/lib/openflare-agent/etc/nginx/nginx.conf",
|
||||
"route_config_path": "/var/lib/openflare-agent/etc/nginx/conf.d/openflare_routes.conf",
|
||||
"access_log_path": "/var/lib/openflare-agent/var/log/openflare/access.log",
|
||||
"cert_dir": "/var/lib/openflare-agent/etc/nginx/certs",
|
||||
"lua_dir": "/var/lib/openflare-agent/etc/nginx/lua",
|
||||
"runtime_config_dir": "/var/lib/openflare-agent/etc/openflare",
|
||||
"heartbeat_interval": 10000,
|
||||
"request_timeout": 10000
|
||||
}
|
||||
```
|
||||
|
||||
如果不配置 `openresty_path`,Agent 默认调用 `openresty`。完整字段见 [配置项参考](./configuration.md#agent-配置字段)。
|
||||
|
||||
## Docker 运行
|
||||
|
||||
Docker 部署时直接运行内置 OpenResty 的 Agent 镜像:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/rain-kl/openflare-agent:latest
|
||||
docker rm -f openflare-agent 2>/dev/null || true
|
||||
docker run -d --name openflare-agent --restart unless-stopped \
|
||||
-p 80:80 -p 443:443 \
|
||||
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
|
||||
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
|
||||
ghcr.io/rain-kl/openflare-agent:latest
|
||||
```
|
||||
|
||||
## 启动与验证
|
||||
|
||||
systemd 环境:
|
||||
|
||||
```bash
|
||||
systemctl status openflare-agent
|
||||
journalctl -u openflare-agent -f
|
||||
```
|
||||
|
||||
手动启动:
|
||||
|
||||
```bash
|
||||
/opt/openflare-agent/openflare-agent -config /opt/openflare-agent/agent.json
|
||||
```
|
||||
|
||||
源码运行:
|
||||
|
||||
```bash
|
||||
cd openflare_agent
|
||||
export LOG_LEVEL='info'
|
||||
go run ./cmd/agent -config /path/to/agent.json
|
||||
```
|
||||
|
||||
编译后二进制运行:
|
||||
|
||||
```bash
|
||||
cd openflare_agent
|
||||
go build -o openflare-agent ./cmd/agent
|
||||
export LOG_LEVEL='info'
|
||||
./openflare-agent -config /path/to/agent.json
|
||||
```
|
||||
|
||||
在管理端确认:
|
||||
|
||||
| 位置 | 期望结果 |
|
||||
| --- | --- |
|
||||
| 节点列表 | 节点在线 |
|
||||
| 节点详情 | 能看到心跳时间、当前版本和基础资源信息 |
|
||||
| 应用记录 | 发布配置后出现应用结果 |
|
||||
|
||||
## 卸载
|
||||
|
||||
如需彻底卸载 Agent 并清空本地数据:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/uninstall-agent.sh | bash
|
||||
```
|
||||
|
||||
支持参数:
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| `--install-dir` | 安装目录,默认 `/opt/openflare-agent` |
|
||||
| `--service-name` | systemd 服务名,默认 `openflare-agent` |
|
||||
|
||||
卸载脚本只移除 Agent 服务、进程和安装目录,不会删除本机 OpenResty。
|
||||
|
||||
## 常见问题
|
||||
|
||||
| 现象 | 处理步骤 |
|
||||
| --- | --- |
|
||||
| `agent_token 和 discovery_token 不能同时为空` | 检查 `agent.json` 至少配置了一个 Token |
|
||||
| 节点一直离线 | 在 Agent 节点执行 `curl -I http://your-server:3000`,确认 Server 地址可达 |
|
||||
| OpenResty 没有启动 | 查看 `journalctl -u openflare-agent`,确认 `openresty_path` 可执行且 80/443 端口未被占用 |
|
||||
| 发布后重复失败 | Agent 会阻断同一 `version + checksum` 的重复应用;需要修正配置后重新发布,或激活旧版本回滚 |
|
||||
@@ -0,0 +1,153 @@
|
||||
# API 约定
|
||||
|
||||
你会学到:OpenFlare 管理端 API 与 Agent API 的响应结构、路径约定、鉴权方式和 Swagger 入口。
|
||||
|
||||
OpenFlare 的管理端 API 与 Agent API 都使用 JSON。
|
||||
|
||||
## 响应结构
|
||||
|
||||
成功与失败都应返回清晰的 `message`:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
## 路径约定
|
||||
|
||||
| 类型 | 约定 |
|
||||
| --- | --- |
|
||||
| 管理端 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。
|
||||
|
||||
Agent 正式请求统一使用节点专属 `agent_token`,首次接入可使用全局 `discovery_token`。Agent 请求头固定为:
|
||||
|
||||
```http
|
||||
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
|
||||
|
||||
登录管理端后可访问:
|
||||
|
||||
```text
|
||||
/swagger/index.html
|
||||
```
|
||||
|
||||
Swagger 文件位于 `openflare_server/docs`,由 `swag init` 生成。
|
||||
@@ -0,0 +1,117 @@
|
||||
# 命令与脚本
|
||||
|
||||
你会学到:OpenFlare Server、管理端前端、Agent、Swagger 和文档站的常用启动、构建、测试、安装与卸载命令。
|
||||
|
||||
## Server
|
||||
|
||||
源码启动:
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
export SESSION_SECRET='replace-with-random-string'
|
||||
export SQLITE_PATH='./openflare.db'
|
||||
export LOG_LEVEL='info'
|
||||
go run .
|
||||
```
|
||||
|
||||
指定监听端口与日志目录:
|
||||
|
||||
```bash
|
||||
go run . --port 3000 --log-dir ./logs
|
||||
```
|
||||
|
||||
测试:
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
GOCACHE=/tmp/openflare-go-cache go test ./...
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
开发:
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
构建静态产物:
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
pnpm build
|
||||
```
|
||||
|
||||
检查:
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## Agent
|
||||
|
||||
源码运行:
|
||||
|
||||
```bash
|
||||
cd openflare_agent
|
||||
go run ./cmd/agent -config /path/to/agent.json
|
||||
```
|
||||
|
||||
编译:
|
||||
|
||||
```bash
|
||||
cd openflare_agent
|
||||
go build -o openflare-agent ./cmd/agent
|
||||
```
|
||||
|
||||
测试:
|
||||
|
||||
```bash
|
||||
cd openflare_agent
|
||||
GOCACHE=/tmp/openflare-go-cache go test ./...
|
||||
```
|
||||
|
||||
## 安装 Agent
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
|
||||
--server-url http://your-server:3000 \
|
||||
--agent-token YOUR_AGENT_TOKEN
|
||||
```
|
||||
|
||||
## 卸载 Agent
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/uninstall-agent.sh | bash
|
||||
```
|
||||
|
||||
## Swagger
|
||||
|
||||
重新生成 Swagger 文档:
|
||||
|
||||
```bash
|
||||
go install github.com/swaggo/swag/cmd/swag@v1.16.4
|
||||
cd openflare_server
|
||||
swag init -g main.go -o docs
|
||||
```
|
||||
|
||||
## Docs
|
||||
|
||||
本地预览:
|
||||
|
||||
```bash
|
||||
cd docs
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
构建:
|
||||
|
||||
```bash
|
||||
cd docs
|
||||
pnpm build
|
||||
```
|
||||
@@ -0,0 +1,259 @@
|
||||
# 配置项
|
||||
|
||||
你会学到:OpenFlare Server、前端构建和 Agent 支持哪些配置来源、配置项默认值是什么,以及常见部署组合应该如何配置。
|
||||
|
||||
本文档汇总 OpenFlare `1.0.0` 当前支持的 Server 与 Agent 配置项,只保留仍然有效的启动、部署与运行参数。
|
||||
|
||||
## 配置来源
|
||||
|
||||
Server 支持三类配置来源:
|
||||
|
||||
1. 命令行参数。
|
||||
2. 环境变量。
|
||||
3. 数据库 `Option` 表中的运行时配置。
|
||||
|
||||
Agent 支持:
|
||||
|
||||
1. `-config` 命令行参数。
|
||||
2. `agent.json` 配置文件。
|
||||
3. 少量日志相关环境变量。
|
||||
|
||||
## 配置文件位置
|
||||
|
||||
| 组件 | 默认位置 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| Server SQLite | `openflare.db` | 可通过 `SQLITE_PATH` 修改 |
|
||||
| Agent 配置文件 | `./agent.json` | 可通过 `-config` 指定 |
|
||||
| 一键安装 Agent 配置 | `/opt/openflare-agent/agent.json` | 安装脚本默认生成 |
|
||||
| Agent 数据目录 | 配置文件所在目录下的 `data` | 可通过 `data_dir` 修改 |
|
||||
|
||||
## Server 命令行参数
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
go run . --port 3000 --log-dir ./logs
|
||||
```
|
||||
|
||||
| 参数 | 作用 | 默认值 |
|
||||
| --- | --- | --- |
|
||||
| `--port` | 指定 Server 监听端口 | `3000` |
|
||||
| `--log-dir` | 指定日志目录 | 空 |
|
||||
| `--version` | 输出当前版本后退出 | `false` |
|
||||
| `--help` | 输出帮助信息后退出 | `false` |
|
||||
|
||||
## Server 环境变量
|
||||
|
||||
| 环境变量 | 作用 | 默认值 |
|
||||
| --- | --- | --- |
|
||||
| `PORT` | Server 监听端口 | `3000` |
|
||||
| `GIN_MODE` | Gin 运行模式 | 非 `debug` 时按 release |
|
||||
| `LOG_LEVEL` | 日志等级 | `info` |
|
||||
| `SESSION_SECRET` | Session 签名密钥 | 启动时随机生成 |
|
||||
| `SQLITE_PATH` | SQLite 数据库文件路径 | `openflare.db` |
|
||||
| `DSN` | PostgreSQL DSN,设置后优先于 SQLite | 空 |
|
||||
| `SQL_DSN` | 兼容旧命名的 PostgreSQL DSN,优先级低于 `DSN` | 空 |
|
||||
| `REDIS_CONN_STRING` | Redis 连接串 | 空 |
|
||||
| `AGENT_TOKEN` | 兼容旧部署的全局 Agent Token | 空 |
|
||||
|
||||
说明:
|
||||
|
||||
* `DSN` 与 `SQL_DSN` 同时存在时优先使用 `DSN`。
|
||||
* `DSN` 或 `SQL_DSN` 与 `SQLITE_PATH` 同时存在时优先使用 PostgreSQL。
|
||||
* 当目标 PostgreSQL 数据库为空且本地 `SQLITE_PATH` 文件存在时,Server 启动阶段会自动迁移 SQLite 数据,并在日志中输出按表迁移进度。
|
||||
* `SESSION_SECRET` 生产环境必须显式配置。
|
||||
* `REDIS_CONN_STRING` 未配置时,相关能力回退为进程内实现。
|
||||
|
||||
## 运行时 Option
|
||||
|
||||
以下配置由管理端设置页维护,可热更新:
|
||||
|
||||
| 配置项 | 作用 | 默认值 |
|
||||
| --- | --- | --- |
|
||||
| `AgentHeartbeatInterval` | Agent 心跳间隔(毫秒) | `10000` |
|
||||
| `AgentWebsocketUpgradeEnabled` | 是否允许 Agent 在 HTTP 心跳成功后升级为 WebSocket | `true` |
|
||||
| `NodeOfflineThreshold` | 节点离线阈值(毫秒) | `120000` |
|
||||
| `AgentUpdateRepo` | Agent 自更新仓库 | `Rain-kl/OpenFlare` |
|
||||
| `GeoIPProvider` | 节点/IP 归属解析方式 | `ipinfo` |
|
||||
| `DatabaseAutoCleanupEnabled` | 是否启用每日自动清理观测数据 | `false` |
|
||||
| `DatabaseAutoCleanupRetentionDays` | 自动清理保留天数,至少 1 天 | `30` |
|
||||
| `GlobalApiRateLimitNum` / `GlobalApiRateLimitDuration` | 全局 API 限流次数 / 时间窗口 | `300` / `180` |
|
||||
| `GlobalWebRateLimitNum` / `GlobalWebRateLimitDuration` | 全局 Web 限流次数 / 时间窗口 | `300` / `180` |
|
||||
| `CriticalRateLimitNum` / `CriticalRateLimitDuration` | 敏感接口限流次数 / 时间窗口 | `100` / `1200` |
|
||||
|
||||
说明:
|
||||
|
||||
* `DatabaseAutoCleanupEnabled` 开启后,Server 会在每天凌晨 3 点自动清理 `node_access_logs`、`node_metric_snapshots`、`node_request_reports` 三类观测数据。
|
||||
* `DatabaseAutoCleanupRetentionDays` 为统一保留天数,必须大于等于 1。
|
||||
* 管理端支持手动清理时留空保留天数,以直接删除对应数据集的全部历史记录。
|
||||
* `AgentUpdateRepo` 指向的 GitHub Release 必须为每个 Agent 二进制提供同名 `.sha256` 校验文件,例如 `openflare-agent-linux-amd64.sha256`;Agent 自更新会在替换可执行文件前校验 SHA-256。
|
||||
* 第三方登录不再通过 `GitHubOAuthEnabled`、`GitHubClientId`、`GitHubClientSecret` 作为主配置入口;这些旧 Option 仅用于升级时迁移默认 GitHub 认证源。
|
||||
* 微信登录旧 Option 保留为兼容字段,但管理端不再提供微信登录配置入口。
|
||||
* Turnstile 旧 Option 与后端校验能力保留,已有配置仍会生效。
|
||||
|
||||
## OpenResty 参数
|
||||
|
||||
OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前常用项包括:
|
||||
|
||||
* `OpenRestyWorkerProcesses`
|
||||
* `OpenRestyWorkerConnections`
|
||||
* `OpenRestyWorkerRlimitNofile`
|
||||
* `OpenRestyKeepaliveTimeout`
|
||||
* `OpenRestyProxyConnectTimeout`
|
||||
* `OpenRestyProxySendTimeout`
|
||||
* `OpenRestyProxyReadTimeout`
|
||||
* `OpenRestyProxyBufferingEnabled`
|
||||
* `OpenRestyGzipEnabled`
|
||||
* `OpenRestyCacheEnabled`
|
||||
* `OpenRestyCachePath`
|
||||
* `OpenRestyCacheMaxSize`
|
||||
|
||||
这类参数必须以结构化方式校验、保存并参与版本渲染。
|
||||
|
||||
约束:
|
||||
|
||||
* 管理端不再暴露 `resolver` 配置。
|
||||
* 规则上游统一渲染为 named `upstream` 并启用 keepalive。
|
||||
* 单上游如带 base path 或 query,会在 `proxy_pass` 中补回原始 URI。
|
||||
* 多上游仍要求每个上游都为纯 `scheme://host[:port]`,且同一规则内协议一致。
|
||||
* `OpenRestyCacheEnabled` 用于启用缓存基础设施与全局默认参数;实际是否缓存、按 URL / 后缀 / 路径等命中策略由各条 `proxy_routes` 单独决定。
|
||||
* 默认缓存 Key 为 `$scheme$host$request_uri`。
|
||||
* 默认 `keepalive_timeout` 为 `20` 秒,默认 `proxy_connect_timeout` 为 `3` 秒。
|
||||
* 默认事件模型为 `epoll`,并默认开启 `multi_accept`。
|
||||
* HTTPS 监听默认使用独立 `http2 on;` 指令,避免新版 Nginx/OpenResty 对 `listen ... http2` 的弃用告警。
|
||||
|
||||
## 前端构建环境变量
|
||||
|
||||
| 环境变量 | 作用 | 默认值 |
|
||||
| --- | --- | --- |
|
||||
| `NEXT_PUBLIC_API_BASE_URL` | 前端请求 API 的基础路径 | `/api` |
|
||||
| `NEXT_PUBLIC_APP_VERSION` | 前端展示版本号 | `dev` |
|
||||
| `NEXT_DEV_BACKEND_URL` | 本地开发服务器代理的后端地址 | `http://127.0.0.1:3000` |
|
||||
|
||||
## Agent 环境变量
|
||||
|
||||
| 环境变量 | 作用 | 默认值 |
|
||||
| --- | --- | --- |
|
||||
| `LOG_LEVEL` | Agent 日志等级 | `info` |
|
||||
| `OPENFLARE_SERVER_URL` | 控制面地址,可覆盖 `agent.json` | 空 |
|
||||
| `OPENFLARE_AGENT_TOKEN` | 节点专属认证 Token,可覆盖 `agent.json` | 空 |
|
||||
| `OPENFLARE_DISCOVERY_TOKEN` | 首次自动注册 Token,可覆盖 `agent.json` | 空 |
|
||||
| `OPENFLARE_NODE_NAME` | 节点名称,可覆盖 `agent.json` | 空 |
|
||||
| `OPENFLARE_NODE_IP` | 节点 IP,可覆盖 `agent.json` | 空 |
|
||||
| `OPENFLARE_DATA_DIR` | Agent 数据目录,可覆盖 `agent.json` | 空 |
|
||||
| `OPENFLARE_OPENRESTY_PATH` | OpenResty 二进制路径,可覆盖 `agent.json` | 空 |
|
||||
| `OPENFLARE_HEARTBEAT_INTERVAL` | 心跳间隔,可覆盖 `agent.json` | 空 |
|
||||
| `OPENFLARE_REQUEST_TIMEOUT` | 请求超时,可覆盖 `agent.json` | 空 |
|
||||
| `OPENFLARE_OPENRESTY_OBSERVABILITY_PORT` | 本地观测端口,可覆盖 `agent.json` | 空 |
|
||||
| `OPENFLARE_MMDB_PATH` | WAF GeoIP mmdb 路径,可覆盖 `agent.json` | 空 |
|
||||
| `OPENFLARE_MMDB_UPDATE_INTERVAL` | WAF GeoIP mmdb 更新间隔,可覆盖 `agent.json` | 空 |
|
||||
| `OPENFLARE_MMDB_DOWNLOAD_URL` | WAF GeoIP mmdb 下载地址,可覆盖 `agent.json` | 空 |
|
||||
|
||||
## Agent 命令行参数
|
||||
|
||||
| 参数 | 作用 | 默认值 |
|
||||
| --- | --- | --- |
|
||||
| `-config` | 指定 Agent 配置文件路径 | `./agent.json` |
|
||||
|
||||
## Agent 配置字段
|
||||
|
||||
| 字段 | 作用 | 是否必填 | 默认值/行为 |
|
||||
| --- | --- | --- | --- |
|
||||
| `server_url` | 控制面地址 | 是 | 无 |
|
||||
| `agent_token` | 节点专属认证 Token | 与 `discovery_token` 二选一 | 空 |
|
||||
| `discovery_token` | 首次自动注册使用的全局 Token | 与 `agent_token` 二选一 | 空 |
|
||||
| `node_name` | 节点名称 | 否 | 自动使用主机名 |
|
||||
| `node_ip` | 节点 IP | 否 | 自动探测,优先通过第三方 API 获取真实出口公网 IP;失败时退回本机网卡探测 |
|
||||
| `openresty_path` | OpenResty 二进制路径 | 否 | `openresty` |
|
||||
| `openresty_observability_port` | 本地观测与 OpenResty 健康检查端口 | 否 | `18081` |
|
||||
| `data_dir` | Agent 数据目录 | 否 | 配置文件所在目录下的 `data` |
|
||||
| `main_config_path` | OpenResty 主配置写入路径 | 否 | `data_dir/etc/nginx/nginx.conf` |
|
||||
| `route_config_path` | 路由配置写入路径 | 否 | `data_dir/etc/nginx/conf.d/openflare_routes.conf` |
|
||||
| `access_log_path` | OpenResty 访问日志路径 | 否 | `data_dir/var/log/openflare/access.log` |
|
||||
| `cert_dir` | 证书写入目录 | 否 | `data_dir/etc/nginx/certs` |
|
||||
| `openresty_cert_dir` | OpenResty 配置中读取证书的目录 | 否 | 同 `cert_dir` |
|
||||
| `lua_dir` | Lua 脚本与静态资源写入目录 | 否 | `data_dir/etc/nginx/lua` |
|
||||
| `openresty_lua_dir` | OpenResty 配置中读取 Lua 的目录 | 否 | 同 `lua_dir` |
|
||||
| `runtime_config_dir` | Agent 运行时配置写入目录,如 `pow_config.json` | 否 | `data_dir/etc/openflare` |
|
||||
| `mmdb_path` | WAF GeoIP mmdb 文件路径 | 否 | `data_dir/etc/openflare/GeoLite2-Country.mmdb` |
|
||||
| `mmdb_update_interval` | WAF GeoIP mmdb 更新间隔 | 否 | `86400000` 毫秒 |
|
||||
| `mmdb_download_url` | WAF GeoIP mmdb 下载地址 | 否 | 内置 GeoLite2 Country 下载地址 |
|
||||
| `observability_buffer_path` | 观测补报缓冲文件路径 | 否 | `data_dir/var/lib/openflare/observability-buffer.json` |
|
||||
| `observability_replay_minutes` | 自动补传最近观测窗口分钟数 | 否 | `15` |
|
||||
| `state_path` | Agent 本地状态文件路径 | 否 | `data_dir/var/lib/openflare/agent-state.json` |
|
||||
| `heartbeat_interval` | 心跳间隔 | 否 | `10000` 毫秒 |
|
||||
| `request_timeout` | HTTP 请求超时 | 否 | `10000` 毫秒 |
|
||||
|
||||
说明:
|
||||
|
||||
* `agent_token` 与 `discovery_token` 不能同时为空。
|
||||
* `heartbeat_interval` 与 `request_timeout` 支持毫秒整数或 Go duration 字符串。
|
||||
* Server 运行时配置 `AgentWebsocketUpgradeEnabled` 开启时,Agent 会在 HTTP 心跳成功后尝试升级为 WebSocket;连接失败或断开后自动退回 HTTP 心跳。
|
||||
* 未配置 `openresty_path` 时默认调用 `openresty`。
|
||||
* Agent 周期性健康检查会请求 `http://127.0.0.1:<openresty_observability_port>/openflare/stub_status`,不再通过高频 `openresty -t` 判断运行时健康;配置应用、启动恢复和 reload 前校验仍会执行 `openresty -t -c <main_config_path>`。
|
||||
* Agent 会初始化并定期更新 `mmdb_path`,供 OpenResty WAF Lua 执行国家级地域规则;更新失败只记录警告,不阻断同步或 reload。
|
||||
* 如果 `agent.json` 不存在,但 `OPENFLARE_SERVER_URL` 与 Token 等环境变量足够,Agent 可以直接启动;两者同时存在时环境变量优先。
|
||||
* Agent 未配置 `node_ip` 时,会优先通过 `https://realip.cc` 获取真实出口公网 IP,适配 Docker/NAT 场景;该请求失败时,才退回本机网卡探测并优先选择公网 IPv4。
|
||||
* Agent 自动探测到私网 `node_ip` 时,Server 会在注册/心跳阶段优先保留 Agent 直连来源的公网地址,避免 NAT/多网卡场景误登记内网网卡地址。
|
||||
* 在管理端开启“锁定节点 IP”后,Server 会保留管理端填写的节点 IP,后续 Agent 注册、HTTP 心跳或 WebSocket 状态上报不会覆盖该字段;关闭锁定后,下一次上报可重新回填。
|
||||
|
||||
## 常见配置组合
|
||||
|
||||
### 生产 Server + PostgreSQL
|
||||
|
||||
```bash
|
||||
export SESSION_SECRET='replace-with-a-long-random-string'
|
||||
export DSN='postgres://openflare:replace-with-strong-password@postgres:5432/openflare?sslmode=disable'
|
||||
export GIN_MODE='release'
|
||||
export LOG_LEVEL='info'
|
||||
```
|
||||
|
||||
### 本地 Server + SQLite
|
||||
|
||||
```bash
|
||||
export SESSION_SECRET='dev-session-secret'
|
||||
export SQLITE_PATH='./openflare-dev.db'
|
||||
export LOG_LEVEL='debug'
|
||||
go run .
|
||||
```
|
||||
|
||||
### Agent + 默认 OpenResty
|
||||
|
||||
```json
|
||||
{
|
||||
"server_url": "http://your-server:3000",
|
||||
"agent_token": "replace-with-node-auth-token",
|
||||
"data_dir": "/opt/openflare-agent/data",
|
||||
"openresty_path": "openresty",
|
||||
"heartbeat_interval": 10000,
|
||||
"request_timeout": 10000
|
||||
}
|
||||
```
|
||||
|
||||
### Agent + 自定义 OpenResty 路径
|
||||
|
||||
```json
|
||||
{
|
||||
"server_url": "http://your-server:3000",
|
||||
"agent_token": "replace-with-node-auth-token",
|
||||
"data_dir": "/var/lib/openflare-agent",
|
||||
"openresty_path": "/usr/local/openresty/nginx/sbin/openresty",
|
||||
"main_config_path": "/var/lib/openflare-agent/etc/nginx/nginx.conf",
|
||||
"route_config_path": "/var/lib/openflare-agent/etc/nginx/conf.d/openflare_routes.conf",
|
||||
"access_log_path": "/var/lib/openflare-agent/var/log/openflare/access.log",
|
||||
"cert_dir": "/var/lib/openflare-agent/etc/nginx/certs",
|
||||
"lua_dir": "/var/lib/openflare-agent/etc/nginx/lua",
|
||||
"runtime_config_dir": "/var/lib/openflare-agent/etc/openflare",
|
||||
"heartbeat_interval": 10000,
|
||||
"request_timeout": 10000
|
||||
}
|
||||
```
|
||||
|
||||
## 维护要求
|
||||
|
||||
以下内容变化时,必须同步更新本文档:
|
||||
|
||||
* Server 命令行参数。
|
||||
* Server 环境变量。
|
||||
* Agent 命令行参数。
|
||||
* Agent 配置字段。
|
||||
* 任一配置项的默认值、用途或示例。
|
||||
@@ -0,0 +1,299 @@
|
||||
# 部署说明
|
||||
|
||||
你会学到:OpenFlare 的推荐部署方式、Server 与 Agent 的运行要求、源码启动方式、联调步骤、升级与卸载入口。
|
||||
|
||||
生产环境建议使用 PostgreSQL 作为 Server 数据库,并为 Server 显式配置 `SESSION_SECRET`。Agent 部署方式推荐为 Docker 部署(即直接使用内置 OpenResty 的 Agent 镜像);亦支持通过安装脚本或手动本地运行。
|
||||
|
||||
## 部署拓扑
|
||||
|
||||
```text
|
||||
Browser
|
||||
|
|
||||
v
|
||||
OpenFlare Server :3000
|
||||
|
|
||||
| Agent API / heartbeat / config pull
|
||||
v
|
||||
OpenFlare Agent
|
||||
|
|
||||
v
|
||||
OpenResty binary
|
||||
|
|
||||
v
|
||||
Origin service
|
||||
```
|
||||
|
||||
## 前置条件
|
||||
|
||||
Server:
|
||||
|
||||
| 项目 | 要求 |
|
||||
| --- | --- |
|
||||
| Go | `1.25+`,仅源码运行需要 |
|
||||
| Node.js | `18+`,仅源码构建管理端需要 |
|
||||
| 数据库 | 可写 SQLite 文件目录,或可访问的 PostgreSQL 实例 |
|
||||
| 端口 | 默认监听 `3000` |
|
||||
|
||||
Agent:
|
||||
|
||||
| 项目 | 要求 |
|
||||
| --- | --- |
|
||||
| 系统 | 安装脚本支持 Linux 和 macOS;systemd 服务仅在 Linux + systemd 环境创建 |
|
||||
| 架构 | `amd64` 或 `arm64` |
|
||||
| OpenResty | 本地部署需要可执行 `openresty`,或通过 `--openresty-path` 指定路径 |
|
||||
| Docker | 仅 Docker 部署 Agent 镜像时需要 |
|
||||
| 网络 | Agent 节点必须能访问 Server 地址 |
|
||||
| GeoIP | WAF 地域规则使用 Agent 本地 MaxMind mmdb;Agent 内置初始库并会定期更新 |
|
||||
|
||||
[需要确认:生产环境推荐的最低 CPU、内存与磁盘容量]
|
||||
|
||||
## Docker Compose 部署 Server
|
||||
|
||||
创建 `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: openflare
|
||||
POSTGRES_USER: openflare
|
||||
POSTGRES_PASSWORD: replace-with-strong-password
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U openflare -d openflare"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
openflare:
|
||||
image: ghcr.io/rain-kl/openflare:latest
|
||||
container_name: openflare
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
SESSION_SECRET: replace-with-a-long-random-string
|
||||
DSN: postgres://openflare:replace-with-strong-password@postgres:5432/openflare?sslmode=disable
|
||||
GIN_MODE: release
|
||||
LOG_LEVEL: info
|
||||
volumes:
|
||||
- openflare-data:/data
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
openflare-data:
|
||||
```
|
||||
|
||||
启动:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
docker compose ps
|
||||
docker compose logs -f openflare
|
||||
```
|
||||
|
||||
首次访问 `http://localhost:3000`,默认账号为 `root` / `123456`。登录后请立即修改默认密码。
|
||||
|
||||
## 源码启动 Server
|
||||
|
||||
先构建管理端前端:
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
corepack enable
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
再启动 Server:
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
export SESSION_SECRET='replace-with-a-long-random-string'
|
||||
export SQLITE_PATH='./openflare.db'
|
||||
export LOG_LEVEL='info'
|
||||
# 可选:设置后优先使用 PostgreSQL。
|
||||
# export DSN='postgres://openflare:secret@127.0.0.1:5432/openflare?sslmode=disable'
|
||||
go run .
|
||||
```
|
||||
|
||||
默认监听 `3000` 端口。也可以显式指定:
|
||||
|
||||
```bash
|
||||
go run . --port 3000 --log-dir ./logs
|
||||
```
|
||||
|
||||
## 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` 自动注册:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
|
||||
--server-url http://your-server:3000 \
|
||||
--discovery-token YOUR_DISCOVERY_TOKEN
|
||||
```
|
||||
|
||||
使用节点专属 `agent_token`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
|
||||
--server-url http://your-server:3000 \
|
||||
--agent-token YOUR_AGENT_TOKEN
|
||||
```
|
||||
|
||||
安装脚本支持参数:
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| `--server-url` | Server 地址,必填 |
|
||||
| `--discovery-token` | 首次自动注册 Token,与 `--agent-token` 二选一 |
|
||||
| `--agent-token` | 节点专属 Token,与 `--discovery-token` 二选一 |
|
||||
| `--install-dir` | 安装目录,默认 `/opt/openflare-agent` |
|
||||
| `--openresty-path` | OpenResty 二进制路径,未传时自动查找 `openresty` |
|
||||
| `--repo` | 下载 Agent 的 GitHub 仓库,默认 `Rain-kl/OpenFlare` |
|
||||
| `--no-service` | 不创建 systemd 服务 |
|
||||
|
||||
确认状态:
|
||||
|
||||
```bash
|
||||
systemctl status openflare-agent
|
||||
journalctl -u openflare-agent -f
|
||||
```
|
||||
|
||||
## 手动运行 Agent
|
||||
|
||||
源码运行:
|
||||
|
||||
```bash
|
||||
cd openflare_agent
|
||||
export LOG_LEVEL='info'
|
||||
go run ./cmd/agent -config /path/to/agent.json
|
||||
```
|
||||
|
||||
编译后二进制运行:
|
||||
|
||||
```bash
|
||||
cd openflare_agent
|
||||
go build -o openflare-agent ./cmd/agent
|
||||
export LOG_LEVEL='info'
|
||||
./openflare-agent -config /path/to/agent.json
|
||||
```
|
||||
|
||||
最小 `agent.json` 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"server_url": "http://127.0.0.1:3000",
|
||||
"agent_token": "replace-with-node-auth-token",
|
||||
"data_dir": "./data",
|
||||
"openresty_path": "openresty",
|
||||
"heartbeat_interval": 10000,
|
||||
"request_timeout": 10000
|
||||
}
|
||||
```
|
||||
|
||||
未配置 `openresty_path` 时,Agent 默认调用 `openresty`。
|
||||
|
||||
默认情况下,Agent 在 HTTP 心跳成功后会尝试升级为 WebSocket。升级成功时,Server 发布或激活配置会立即通知 Agent;如果 WebSocket 无法建立或意外断开,Agent 会自动退回 HTTP 心跳同步。
|
||||
|
||||
WAF 地域规则依赖 Agent 本地 `GeoLite2-Country.mmdb`。Agent 启动时会在 `data_dir/etc/openflare/GeoLite2-Country.mmdb` 初始化内置数据库,并按配置周期尝试更新;更新失败只记录警告,不影响配置同步与 OpenResty reload。
|
||||
|
||||
## 最小联调步骤
|
||||
|
||||
1. 启动 Server 并完成首次登录。
|
||||
2. 在管理端准备 `agent_token` 或 `discovery_token`。
|
||||
3. 启动 Agent,并确认节点在线。
|
||||
4. 新增一条启用的网站配置。
|
||||
5. 发布并激活新版本。
|
||||
6. 查看节点详情和应用记录,确认版本应用成功。
|
||||
7. 访问绑定域名或用 `curl` 验证反代结果。
|
||||
|
||||
## 升级与卸载
|
||||
|
||||
Server:
|
||||
|
||||
* Root 用户可在管理端顶栏检查并升级正式版。
|
||||
* 如需尝试 preview 版本,可手动检查对应发布。
|
||||
* 也可通过上传 Server 二进制的方式执行确认升级。
|
||||
|
||||
Agent:
|
||||
|
||||
* Agent 默认只跟随正式版自动更新。
|
||||
* Agent 自更新会要求 GitHub Release 同时包含目标二进制和同名 `.sha256` 校验文件,下载后必须通过 SHA-256 校验才会替换本地可执行文件。
|
||||
* 安装脚本可重复执行,用于重装或升级 Agent。
|
||||
* preview 升级需要手动触发。
|
||||
|
||||
卸载 Agent:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/uninstall-agent.sh | bash
|
||||
```
|
||||
|
||||
卸载脚本会停止 Agent、删除 systemd 服务和安装目录,不会删除本机 OpenResty。
|
||||
|
||||
## 常用验证命令
|
||||
|
||||
Server:
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
GOCACHE=/tmp/openflare-go-cache go test ./...
|
||||
```
|
||||
|
||||
Agent:
|
||||
|
||||
```bash
|
||||
cd openflare_agent
|
||||
GOCACHE=/tmp/openflare-go-cache go test ./...
|
||||
```
|
||||
|
||||
Frontend:
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Swagger:
|
||||
|
||||
```bash
|
||||
go install github.com/swaggo/swag/cmd/swag@v1.16.4
|
||||
cd openflare_server
|
||||
swag init -g main.go -o docs
|
||||
```
|
||||
@@ -0,0 +1,12 @@
|
||||
# 参考
|
||||
|
||||
你会学到:哪些信息属于稳定参考资料,以及配置、命令、API 和仓库结构应该从哪里查。
|
||||
|
||||
本部分收敛运行、接口与仓库层面的稳定信息,适合部署、联调和排查时快速查阅。
|
||||
|
||||
| 页面 | 内容 |
|
||||
| --- | --- |
|
||||
| [配置项](./configuration.md) | Server 环境变量、命令行参数、运行时 Option 与 Agent 配置字段 |
|
||||
| [命令与脚本](./cli.md) | 常用启动、构建、测试、安装和卸载命令 |
|
||||
| [API 约定](./api.md) | 管理端 API 与 Agent API 的响应结构、鉴权和路径约定 |
|
||||
| [仓库结构](../design/repository.md) | `openflare_server`、`openflare_agent`、`openflare_server/web` 与 `docs` 的职责 |
|
||||
@@ -0,0 +1,106 @@
|
||||
# 启动 Server
|
||||
|
||||
你会学到:如何从源码构建管理端前端、启动 OpenFlare Server、选择 SQLite 或 PostgreSQL,并访问 Swagger。
|
||||
|
||||
OpenFlare Server 是 Gin + GORM 单体控制面,负责管理端 UI、管理 API、Agent API、配置渲染、版本发布、数据存储与聚合查询。
|
||||
|
||||
## 前置条件
|
||||
|
||||
| 项目 | 要求 |
|
||||
| --- | --- |
|
||||
| Go | `1.25+` |
|
||||
| Node.js | `18+` |
|
||||
| pnpm | 推荐通过 `corepack enable` 使用项目声明的 pnpm |
|
||||
| 数据库 | SQLite 文件目录可写,或可访问的 PostgreSQL 实例 |
|
||||
|
||||
生产环境建议显式配置 `SESSION_SECRET`,并优先使用 PostgreSQL。
|
||||
|
||||
## 构建管理端前端
|
||||
|
||||
Go Server 会托管 `openflare_server/web/build` 中的静态产物。源码启动前先构建前端:
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
corepack enable
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
常用前端检查:
|
||||
|
||||
```bash
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## 使用 SQLite 启动
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
export SESSION_SECRET='replace-with-a-long-random-string'
|
||||
export SQLITE_PATH='./openflare.db'
|
||||
export LOG_LEVEL='info'
|
||||
go run .
|
||||
```
|
||||
|
||||
默认监听 `3000` 端口,访问:
|
||||
|
||||
```text
|
||||
http://localhost:3000
|
||||
```
|
||||
|
||||
## 使用 PostgreSQL 启动
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
export SESSION_SECRET='replace-with-a-long-random-string'
|
||||
export DSN='postgres://openflare:secret@127.0.0.1:5432/openflare?sslmode=disable'
|
||||
export LOG_LEVEL='info'
|
||||
go run .
|
||||
```
|
||||
|
||||
`DSN` 设置后优先于 SQLite。`DSN` 与兼容旧命名的 `SQL_DSN` 同时存在时,优先使用 `DSN`。
|
||||
|
||||
如果目标 PostgreSQL 数据库为空且本地 `SQLITE_PATH` 文件存在,Server 启动阶段会尝试把 SQLite 数据迁移到 PostgreSQL,并在日志中输出迁移进度。
|
||||
|
||||
## 命令行参数
|
||||
|
||||
```bash
|
||||
go run . --port 3000 --log-dir ./logs
|
||||
```
|
||||
|
||||
| 参数 | 作用 | 默认值 |
|
||||
| --- | --- | --- |
|
||||
| `--port` | 指定 Server 监听端口 | `3000` |
|
||||
| `--log-dir` | 指定日志目录 | 空,输出到标准输出 |
|
||||
| `--version` | 输出版本后退出 | `false` |
|
||||
| `--help` | 输出帮助后退出 | `false` |
|
||||
|
||||
## 首次登录
|
||||
|
||||
默认账号:
|
||||
|
||||
| 用户名 | 密码 |
|
||||
| --- | --- |
|
||||
| `root` | `123456` |
|
||||
|
||||
首次登录后请立即修改默认密码。
|
||||
|
||||
## Swagger
|
||||
|
||||
登录管理端后访问:
|
||||
|
||||
```text
|
||||
http://localhost:3000/swagger/index.html
|
||||
```
|
||||
|
||||
本地重新生成 Swagger:
|
||||
|
||||
```bash
|
||||
go install github.com/swaggo/swag/cmd/swag@v1.16.4
|
||||
cd openflare_server
|
||||
swag init -g main.go -o docs
|
||||
```
|
||||
|
||||
Swagger 生成文件位于 `openflare_server/docs`。
|
||||
@@ -0,0 +1,85 @@
|
||||
# 升级与维护
|
||||
|
||||
你会学到:如何升级 Server 与 Agent、如何清理观测数据,以及维护前后应该执行哪些验证命令。
|
||||
|
||||
升级前建议先确认当前激活版本、最近一次 Agent 应用结果和数据库备份策略。生产环境不要在发布配置、Agent 大规模重连或数据库迁移进行中同时升级。
|
||||
|
||||
## Server 升级
|
||||
|
||||
Root 用户可以在管理端顶栏检查并升级 Server 正式版。也可以通过上传 Server 二进制的方式执行确认升级。
|
||||
|
||||
如需尝试 preview 版本,可手动检查对应发布。生产环境建议优先使用正式版。
|
||||
|
||||
升级后确认:
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
docker compose logs -n 100 openflare
|
||||
```
|
||||
|
||||
如果是源码部署,重新启动 Server 后确认日志中没有数据库迁移或启动错误。
|
||||
|
||||
## Agent 升级
|
||||
|
||||
节点 Agent 默认只跟随正式版自动更新。preview 升级需要手动触发。
|
||||
|
||||
安装脚本可重复执行,用于重装或升级 Agent:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
|
||||
--server-url http://your-server:3000 \
|
||||
--agent-token YOUR_AGENT_TOKEN
|
||||
```
|
||||
|
||||
注意:当前安装脚本重装时会删除整个安装目录,包括旧 `agent.json`、本地状态、缓存数据和下载的二进制。执行前请确认手头仍有可用 Token。
|
||||
|
||||
升级后确认:
|
||||
|
||||
```bash
|
||||
systemctl status openflare-agent
|
||||
journalctl -u openflare-agent -n 100 --no-pager
|
||||
```
|
||||
|
||||
## 数据维护
|
||||
|
||||
管理端设置页可以维护观测数据自动清理策略:
|
||||
|
||||
| 配置项 | 说明 |
|
||||
| --- | --- |
|
||||
| `DatabaseAutoCleanupEnabled` | 是否启用每日自动清理 |
|
||||
| `DatabaseAutoCleanupRetentionDays` | 自动清理保留天数,至少 1 天 |
|
||||
|
||||
开启后,Server 会在每天凌晨 3 点清理访问日志、指标快照与请求报告。
|
||||
|
||||
## 常用验证命令
|
||||
|
||||
Server:
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
GOCACHE=/tmp/openflare-go-cache go test ./...
|
||||
```
|
||||
|
||||
Agent:
|
||||
|
||||
```bash
|
||||
cd openflare_agent
|
||||
GOCACHE=/tmp/openflare-go-cache go test ./...
|
||||
```
|
||||
|
||||
Frontend:
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Docs:
|
||||
|
||||
```bash
|
||||
cd docs
|
||||
pnpm build
|
||||
```
|
||||
@@ -1,273 +0,0 @@
|
||||
# 网站配置改造需求与开发计划
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前规则模块以“一个域名对应一条规则”为中心,已经支持单域名绑定一个或多个上游,但无法表达“多个域名共享同一套站点配置”的场景。
|
||||
|
||||
现阶段已经出现以下真实需求:
|
||||
|
||||
* 多个域名指向同一站点,并共享反向代理、HTTPS、缓存等设置
|
||||
* 后续希望围绕“网站”继续叠加更多功能,而不是持续在规则列表中堆积字段
|
||||
* 现有抽屉式编辑界面已经不适合承载更复杂的配置结构
|
||||
|
||||
因此,本轮改造将 `proxy_routes` 从“单域名规则”升级为“网站配置”视角,并引入独立的配置子页面。
|
||||
|
||||
## 2. 目标
|
||||
|
||||
本轮改造的目标如下:
|
||||
|
||||
* 支持一个网站绑定多个域名
|
||||
* 支持一个网站绑定一个或多个上游
|
||||
* 引入 `site_name` 作为网站业务唯一标识
|
||||
* 将原列表页的“编辑”操作替换为“配置”,进入独立子页面管理
|
||||
* 将网站配置拆分为更清晰的功能分区,为后续扩展预留结构
|
||||
|
||||
## 3. 本轮范围
|
||||
|
||||
本轮仅覆盖以下站点级配置能力:
|
||||
|
||||
* 域名设置
|
||||
* 流量限制
|
||||
* 反向代理
|
||||
* HTTPS
|
||||
* 缓存
|
||||
|
||||
## 4. 核心模型要求
|
||||
|
||||
### 4.1 网站标识
|
||||
|
||||
* `site_name` 为网站业务唯一标识
|
||||
* 新建网站时,若用户未输入 `site_name`,默认取域名列表第一项
|
||||
* `site_name` 在首次生成后允许独立编辑,不随域名变更自动同步,避免影响引用、跳转和审计
|
||||
* 数据库内部主键可以继续使用现有数值 `id`,但业务层必须校验 `site_name` 唯一性
|
||||
|
||||
### 4.2 域名列表
|
||||
|
||||
* 网站的域名字段改为 `domains` 列表
|
||||
* `domains` 至少包含一个有效域名
|
||||
* `domains[0]` 视为主域名,用于列表摘要、默认展示和兼容历史逻辑
|
||||
* 同一网站内域名不能重复
|
||||
* 任一域名在全局只能属于一个网站
|
||||
* 域名列表需要支持新增、删除和调整顺序
|
||||
|
||||
### 4.3 历史兼容
|
||||
|
||||
* 存量单域名数据迁移后应自动转换为:
|
||||
`site_name = domain`
|
||||
`domains = [domain]`
|
||||
* 若迁移期保留旧 `domain` 字段,该字段仅作为 `domains[0]` 的兼容镜像,不再作为主要业务输入
|
||||
* 版本渲染、差异预览、接口返回和前端展示都应逐步以 `site_name + domains` 为准
|
||||
|
||||
## 5. 功能需求
|
||||
|
||||
### 5.1 列表页改造
|
||||
|
||||
规则列表改造为“网站列表”视图,要求如下:
|
||||
|
||||
* 保留当前列表页入口,但展示对象改为网站
|
||||
* 原“编辑”按钮替换为“配置”按钮
|
||||
* 点击“配置”进入网站配置子页面
|
||||
* 列表项至少展示:
|
||||
`site_name`
|
||||
主域名
|
||||
域名数量
|
||||
上游摘要
|
||||
HTTPS/缓存/启用状态摘要
|
||||
* 删除、发布等现有高风险操作仍保留明确确认
|
||||
|
||||
### 5.2 网站配置子页面
|
||||
|
||||
网站配置采用左右布局:
|
||||
|
||||
* 左侧为菜单栏,用于切换配置分区
|
||||
* 右侧为当前分区的设置面板
|
||||
* 默认进入“域名设置”分区
|
||||
* 建议基于 App Router 子路由或稳定的 tab 路由参数实现,保证可直接访问和刷新恢复
|
||||
|
||||
建议左侧菜单项固定为:
|
||||
|
||||
1. 域名设置
|
||||
2. 流量限制
|
||||
3. 反向代理
|
||||
4. HTTPS
|
||||
5. 缓存
|
||||
|
||||
为降低跨分区校验干扰,每个分区应支持独立保存与反馈;若采用统一保存,也必须提供未保存修改提示。
|
||||
|
||||
### 5.3 域名设置
|
||||
|
||||
域名设置分区负责维护网站身份与域名列表,要求如下:
|
||||
|
||||
* 可编辑 `site_name`
|
||||
* 可维护 `domains` 列表
|
||||
* 可新增、删除、排序域名
|
||||
* 明确提示第一项为主域名
|
||||
* 保存前校验:
|
||||
`site_name` 非空且唯一
|
||||
`domains` 非空
|
||||
每个域名格式合法
|
||||
域名在当前站点内不重复
|
||||
域名在全局不与其他网站冲突
|
||||
|
||||
### 5.4 流量限制
|
||||
|
||||
流量限制分区用于配置站点级限流,第一期要求覆盖以下字段:
|
||||
|
||||
* `limit_conn perserver`
|
||||
* `limit_conn perip`
|
||||
* `limit_rate`
|
||||
|
||||
要求如下:
|
||||
|
||||
* 采用结构化字段存储,不允许直接录入原始 Nginx 片段
|
||||
* `limit_conn perserver` 与 `limit_conn perip` 为整数;空值或 `0` 视为未启用
|
||||
* `limit_rate` 采用人类可读格式录入,例如 `512k`、`1m`
|
||||
* 保存前进行格式校验,并在页面中提供示例说明
|
||||
* 配置发布后渲染为对应的 OpenResty/Nginx 指令
|
||||
|
||||
### 5.5 反向代理
|
||||
|
||||
反向代理分区负责维护网站回源配置,要求如下:
|
||||
|
||||
* 支持一个或多个上游
|
||||
* 至少保留一个上游
|
||||
* 支持维护回源主机名 `origin_host`
|
||||
* 继续兼容当前单上游带 path/query、多上游做负载均衡的模式
|
||||
* 若复用 `origins` 目录,只作为地址候选来源,不改变网站配置为主的编辑模型
|
||||
|
||||
建议继续保留当前兼容约束:
|
||||
|
||||
* 单上游可附带 path/query
|
||||
* 多上游模式下,上游项保持 `scheme://host[:port]` 形式
|
||||
* 同一网站的多个上游在多上游模式下维持统一协议,降低渲染复杂度
|
||||
|
||||
### 5.6 HTTPS
|
||||
|
||||
HTTPS 分区负责维护站点级 TLS 行为,要求如下:
|
||||
|
||||
* 支持开启或关闭 HTTPS
|
||||
* 支持选择证书
|
||||
* 支持保留现有 `HTTP -> HTTPS` 跳转能力
|
||||
* 当 HTTPS 开启时必须明确证书来源
|
||||
* 应校验证书是否覆盖当前网站的全部域名;若无法覆盖,应阻止保存或给出不可忽略的错误提示
|
||||
|
||||
### 5.7 缓存
|
||||
|
||||
缓存分区负责维护站点级缓存策略,要求如下:
|
||||
|
||||
* 支持开启或关闭缓存
|
||||
* 支持多种缓存策略
|
||||
* 第一阶段至少兼容当前已存在的策略:
|
||||
`url`
|
||||
`suffix`
|
||||
`path_prefix`
|
||||
`path_exact`
|
||||
* 缓存规则继续采用结构化配置,不直接暴露原始 Nginx 片段
|
||||
* 保持当前安全绕过逻辑,不因界面改造改变默认缓存边界
|
||||
|
||||
## 6. 接口与渲染要求
|
||||
|
||||
* 列表接口需要返回 `site_name`、`domains`、主域名、状态摘要等字段
|
||||
* 详情接口需要按分区所需字段返回完整站点配置
|
||||
* 更新接口需要支持按分区或按网站整体更新,但服务端必须统一做跨字段校验
|
||||
* 配置 diff 不再只关注单个域名变更,还要能识别:
|
||||
网站新增/删除
|
||||
域名列表变更
|
||||
站点级配置变更
|
||||
* 发布渲染时,同一网站的全部域名必须落入同一份站点配置上下文中
|
||||
|
||||
## 7. 前端实现要求
|
||||
|
||||
* 列表页负责导航与摘要,不再承载完整编辑表单
|
||||
* 网站配置子页面中的每个分区表单继续遵循 `React Hook Form + Zod`
|
||||
* API 请求统一收敛在 `lib/api/`
|
||||
* 站点级数据查询与缓存继续使用 TanStack Query
|
||||
* 左侧菜单切换时需要明确处理未保存状态,避免无提示丢失修改
|
||||
* 页面至少覆盖加载态、空态、错误态和保存成功反馈
|
||||
|
||||
## 8. 数据迁移要求
|
||||
|
||||
实施前必须准备显式数据库迁移与校验逻辑,至少包含:
|
||||
|
||||
1. 新增 `site_name` 与 `domains` 存储结构
|
||||
2. 将旧数据从单域名回填到站点结构
|
||||
3. 为 `site_name` 建立唯一约束
|
||||
4. 为域名唯一性建立可校验约束
|
||||
5. 对迁移结果做一致性校验
|
||||
|
||||
迁移失败时,启动流程必须中止,不允许带半迁移状态继续运行。
|
||||
|
||||
## 9. 开发计划
|
||||
|
||||
### 阶段一:模型与渲染改造
|
||||
|
||||
目标:
|
||||
|
||||
* 定义网站级 `proxy_routes` 数据结构
|
||||
* 完成存量数据迁移
|
||||
* 调整配置渲染与发布链路,支持多域名同站点输出
|
||||
|
||||
交付物:
|
||||
|
||||
* 数据库迁移
|
||||
* model/service 调整
|
||||
* 配置渲染兼容实现
|
||||
* 迁移与渲染测试
|
||||
|
||||
### 阶段二:接口与校验改造
|
||||
|
||||
目标:
|
||||
|
||||
* 更新列表、详情、创建、更新接口的数据结构
|
||||
* 引入 `site_name`、`domains`、流量限制等字段校验
|
||||
* 调整版本 diff 与发布预览语义
|
||||
|
||||
交付物:
|
||||
|
||||
* API 契约更新
|
||||
* 服务端参数校验与错误消息
|
||||
* diff/preview 适配
|
||||
* 接口回归测试
|
||||
|
||||
### 阶段三:前端网站列表与配置子页面
|
||||
|
||||
目标:
|
||||
|
||||
* 将规则列表切换为网站列表
|
||||
* 用“配置”按钮替代“编辑”按钮
|
||||
* 落地左右布局的网站配置子页面与五个分区
|
||||
|
||||
交付物:
|
||||
|
||||
* 列表页 UI 改造
|
||||
* 子页面路由与布局
|
||||
* 域名设置、流量限制、反向代理、HTTPS、缓存五个分区
|
||||
* 前端交互与表单测试
|
||||
|
||||
### 阶段四:联调、发布验证与文档收口
|
||||
|
||||
目标:
|
||||
|
||||
* 验证从创建网站到发布配置的全链路
|
||||
* 验证 Agent 拉取、应用与回滚不受影响
|
||||
* 收口文档与测试
|
||||
|
||||
交付物:
|
||||
|
||||
* 联调记录
|
||||
* 发布/回滚回归验证
|
||||
* 文档同步更新
|
||||
|
||||
## 10. 验收标准
|
||||
|
||||
满足以下条件后,本专项可视为完成:
|
||||
|
||||
* 可以创建一个网站,并绑定多个域名
|
||||
* 一个网站可以绑定单个或多个上游
|
||||
* `site_name` 唯一,且创建时默认取第一个域名
|
||||
* 原列表页已用“配置”按钮替代“编辑”按钮
|
||||
* 网站配置子页面已经采用左侧菜单、右侧设置的布局
|
||||
* 五个分区均可独立完成基本配置与保存
|
||||
* 发布后的渲染结果可正确覆盖同一网站的全部域名
|
||||
* Agent 同步、应用、回滚链路不被破坏
|
||||
* 迁移、接口、渲染与前端关键路径均有对应测试或等效回归验证
|
||||
@@ -0,0 +1,34 @@
|
||||
ARG VERSION=dev
|
||||
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
ARG VERSION
|
||||
ARG TARGETOS=linux
|
||||
ARG TARGETARCH
|
||||
|
||||
ENV CGO_ENABLED=0 \
|
||||
GOOS=${TARGETOS} \
|
||||
GOARCH=${TARGETARCH}
|
||||
|
||||
WORKDIR /build
|
||||
COPY openflare_server ./openflare_server
|
||||
COPY openflare_agent ./openflare_agent
|
||||
WORKDIR /build/openflare_agent
|
||||
RUN go mod download
|
||||
RUN go build -trimpath -ldflags "-s -w -X 'openflare-agent/internal/config.Version=$VERSION'" -o /build/openflare-agent ./cmd/agent
|
||||
|
||||
FROM openresty/openresty:alpine
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata perl libmaxminddb \
|
||||
&& ln -sf /usr/lib/libmaxminddb.so.0 /usr/lib/libmaxminddb.so \
|
||||
&& opm get anjia0532/lua-resty-maxminddb \
|
||||
&& mkdir -p /etc/openflare /data
|
||||
|
||||
ENV OPENFLARE_OPENRESTY_PATH=openresty \
|
||||
OPENFLARE_DATA_DIR=/data
|
||||
|
||||
COPY --from=builder /build/openflare-agent /usr/local/bin/openflare-agent
|
||||
|
||||
EXPOSE 80 443 18081
|
||||
ENTRYPOINT ["/usr/local/bin/openflare-agent"]
|
||||
CMD ["-config", "/etc/openflare/agent.json"]
|
||||
@@ -1,9 +1,8 @@
|
||||
{
|
||||
"server_url": "http://127.0.0.1:3000",
|
||||
"agent_token": "2380de64b00e99093e16590beb91e1a0",
|
||||
"agent_token": "373956188ddead1df6dd7c86cd330b73",
|
||||
"data_dir": "./data",
|
||||
"openresty_container_name": "openflare-openresty",
|
||||
"openresty_docker_image": "openresty/openresty:alpine",
|
||||
"openresty_path": "openresty",
|
||||
"heartbeat_interval": 10000,
|
||||
"request_timeout": 10000
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"openflare-agent/internal/agent"
|
||||
"openflare-agent/internal/config"
|
||||
"openflare-agent/internal/geoipupdate"
|
||||
"openflare-agent/internal/heartbeat"
|
||||
"openflare-agent/internal/httpclient"
|
||||
"openflare-agent/internal/logging"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
"openflare-agent/internal/state"
|
||||
syncservice "openflare-agent/internal/sync"
|
||||
"openflare-agent/internal/updater"
|
||||
"openflare-agent/internal/wsclient"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -30,13 +32,10 @@ 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,
|
||||
DockerBinary: cfg.DockerBinary,
|
||||
ContainerName: cfg.OpenrestyContainerName,
|
||||
Image: cfg.OpenrestyDockerImage,
|
||||
MainConfigPath: cfg.MainConfigPath,
|
||||
RouteConfigPath: cfg.RouteConfigPath,
|
||||
CertDir: cfg.CertDir,
|
||||
@@ -52,33 +51,31 @@ func main() {
|
||||
"ip", cfg.NodeIP,
|
||||
"heartbeat_interval", cfg.HeartbeatInterval,
|
||||
"route_config", cfg.RouteConfigPath,
|
||||
"access_log", cfg.AccessLogPath,
|
||||
"cert_dir", cfg.CertDir,
|
||||
"lua_dir", cfg.LuaDir,
|
||||
"runtime_config_dir", cfg.RuntimeConfigDir,
|
||||
"mmdb_path", cfg.MMDBPath,
|
||||
)
|
||||
|
||||
client := httpclient.New(cfg.ServerURL, cfg.InitialAuthToken(), cfg.RequestTimeout.Duration())
|
||||
wsClient := wsclient.New(cfg.ServerURL, cfg.InitialAuthToken(), cfg.RequestTimeout.Duration())
|
||||
stateStore := state.NewStore(cfg.StatePath)
|
||||
observabilityBuffer := state.NewObservabilityBufferStore(cfg.ObservabilityBufferPath)
|
||||
runtimeRouteConfigPath := cfg.RouteConfigPath
|
||||
if cfg.OpenrestyPath == "" {
|
||||
runtimeRouteConfigPath = nginx.DockerRouteConfigPath
|
||||
}
|
||||
runtimeManager := &nginx.Manager{
|
||||
MainConfigPath: cfg.MainConfigPath,
|
||||
RouteConfigPath: cfg.RouteConfigPath,
|
||||
RuntimeRouteConfigPath: runtimeRouteConfigPath,
|
||||
AccessLogPath: cfg.AccessLogPath,
|
||||
CertDir: cfg.CertDir,
|
||||
NginxCertDir: cfg.OpenrestyCertDir,
|
||||
LuaDir: cfg.LuaDir,
|
||||
NginxLuaDir: cfg.OpenrestyLuaDir,
|
||||
OpenrestyObservabilityListen: nginx.ObservabilityListenAddress(cfg.OpenrestyPath, cfg.OpenrestyObservabilityPort),
|
||||
RuntimeConfigDir: cfg.RuntimeConfigDir,
|
||||
OpenrestyObservabilityListen: nginx.ObservabilityListenAddress(cfg.OpenrestyObservabilityPort),
|
||||
OpenrestyObservabilityPort: cfg.OpenrestyObservabilityPort,
|
||||
OpenrestyResolverDirective: "",
|
||||
Executor: nginx.NewExecutor(nginx.ExecutorOptions{
|
||||
NginxPath: cfg.OpenrestyPath,
|
||||
DockerBinary: cfg.DockerBinary,
|
||||
ContainerName: cfg.OpenrestyContainerName,
|
||||
Image: cfg.OpenrestyDockerImage,
|
||||
MainConfigPath: cfg.MainConfigPath,
|
||||
RouteConfigPath: cfg.RouteConfigPath,
|
||||
CertDir: cfg.CertDir,
|
||||
@@ -100,10 +97,17 @@ func main() {
|
||||
SyncService: syncservice.New(client, runtimeManager, stateStore),
|
||||
Updater: updater.New(),
|
||||
RuntimeManager: runtimeManager,
|
||||
WebSocketService: wsClient,
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
geoIPUpdater := &geoipupdate.Updater{
|
||||
MMDBPath: cfg.MMDBPath,
|
||||
DownloadURL: cfg.MMDBDownloadURL,
|
||||
UpdateInterval: cfg.MMDBUpdateInterval.Duration(),
|
||||
}
|
||||
go geoIPUpdater.Run(ctx)
|
||||
slog.Info("agent process started")
|
||||
|
||||
if err = runner.Run(ctx); err != nil && err != context.Canceled {
|
||||
|
||||
+13
-2
@@ -1,7 +1,18 @@
|
||||
module openflare-agent
|
||||
|
||||
go 1.24.0
|
||||
go 1.25.0
|
||||
|
||||
require openflare v0.0.0
|
||||
require (
|
||||
golang.org/x/net v0.53.0
|
||||
openflare v0.0.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/oschwald/maxminddb-golang v1.13.1 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
)
|
||||
|
||||
replace openflare => ../openflare_server
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
|
||||
github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI=
|
||||
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
|
||||
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -2,6 +2,7 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
@@ -22,6 +23,9 @@ type HeartbeatService interface {
|
||||
type SyncService interface {
|
||||
SyncOnStartup(ctx context.Context, target *protocol.ActiveConfigMeta) error
|
||||
SyncOnce(ctx context.Context, target *protocol.ActiveConfigMeta) error
|
||||
ForceSyncOnce(ctx context.Context, target *protocol.ActiveConfigMeta) error
|
||||
WAFIPGroupChecksums() (map[string]string, error)
|
||||
ApplyWAFIPGroups(ctx context.Context, groups []protocol.WAFIPGroup) error
|
||||
}
|
||||
|
||||
type Updater interface {
|
||||
@@ -33,6 +37,12 @@ type RuntimeManager interface {
|
||||
Restart(ctx context.Context) error
|
||||
}
|
||||
|
||||
type WebSocketService interface {
|
||||
Connect(ctx context.Context) (protocol.WebSocketConnection, error)
|
||||
SetToken(token string)
|
||||
URL() string
|
||||
}
|
||||
|
||||
type UpdateOptions struct {
|
||||
Channel string
|
||||
TagName string
|
||||
@@ -47,13 +57,15 @@ type Runner struct {
|
||||
SyncService SyncService
|
||||
Updater Updater
|
||||
RuntimeManager RuntimeManager
|
||||
WebSocketService WebSocketService
|
||||
|
||||
autoUpdate bool
|
||||
updateNow bool
|
||||
updateRepo string
|
||||
updateChan string
|
||||
updateTag string
|
||||
restartOpenrestyNow bool
|
||||
autoUpdate bool
|
||||
updateNow bool
|
||||
updateRepo string
|
||||
updateChan string
|
||||
updateTag string
|
||||
restartOpenrestyNow bool
|
||||
websocketUpgradeEnabled bool
|
||||
}
|
||||
|
||||
func (r *Runner) Run(ctx context.Context) error {
|
||||
@@ -62,27 +74,9 @@ 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() {
|
||||
r.refreshOpenrestyHealth(ctx)
|
||||
payload, ackWindows := r.prepareHeartbeatPayload(nodeID)
|
||||
heartbeatResult, hbErr := r.HeartbeatService.Heartbeat(ctx, payload)
|
||||
if hbErr != nil {
|
||||
if r.hasAccessToken() {
|
||||
if _, hbErr := r.performHeartbeatCycle(ctx, nodeID, true); hbErr != nil {
|
||||
slog.Error("agent startup heartbeat failed", "error", hbErr)
|
||||
} else {
|
||||
r.ackObservabilityWindows(ackWindows)
|
||||
if heartbeatResult == nil {
|
||||
heartbeatResult = &protocol.HeartbeatResult{}
|
||||
}
|
||||
slog.Debug("agent startup heartbeat succeeded", "node_id", nodeID)
|
||||
r.applySettings(heartbeatResult.AgentSettings)
|
||||
if err = r.SyncService.SyncOnStartup(ctx, heartbeatResult.ActiveConfig); err != nil {
|
||||
r.recordSyncError(err)
|
||||
slog.Error("agent startup sync failed", "error", err)
|
||||
} else {
|
||||
slog.Debug("agent startup sync completed")
|
||||
}
|
||||
r.tryRestartOpenresty(ctx)
|
||||
r.tryAutoUpdate(ctx)
|
||||
}
|
||||
} else if err = r.tryRegister(ctx, &nodeID); err != nil {
|
||||
slog.Error("agent initial discovery register failed", "error", err)
|
||||
@@ -90,45 +84,289 @@ func (r *Runner) Run(ctx context.Context) error {
|
||||
|
||||
heartbeatTicker := time.NewTicker(r.Config.HeartbeatInterval.Duration())
|
||||
defer heartbeatTicker.Stop()
|
||||
var wsDone <-chan error
|
||||
wsBackoff := newWebSocketBackoff()
|
||||
nextWSAttempt := time.Now()
|
||||
tryStartWebSocket := func() {
|
||||
if wsDone != nil || !r.shouldUseWebSocket() || time.Now().Before(nextWSAttempt) {
|
||||
return
|
||||
}
|
||||
done, startErr := r.startWebSocket(ctx, nodeID)
|
||||
if startErr != nil {
|
||||
delay := wsBackoff.Next()
|
||||
nextWSAttempt = time.Now().Add(delay)
|
||||
slog.Debug("agent ws upgrade failed; falling back to http heartbeat",
|
||||
"enabled", r.websocketUpgradeEnabled,
|
||||
"url", r.websocketURL(),
|
||||
"retry_after", delay,
|
||||
"error", startErr,
|
||||
)
|
||||
return
|
||||
}
|
||||
wsBackoff.Reset()
|
||||
wsDone = done
|
||||
slog.Debug("agent switched to websocket mode", "url", r.websocketURL())
|
||||
}
|
||||
tryStartWebSocket()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
slog.Info("agent runner shutting down", "error", ctx.Err())
|
||||
return ctx.Err()
|
||||
case wsErr := <-wsDone:
|
||||
wsDone = nil
|
||||
delay := wsBackoff.Next()
|
||||
nextWSAttempt = time.Now().Add(delay)
|
||||
slog.Debug("agent ws disconnected; resuming http heartbeat", "retry_after", delay, "error", wsErr)
|
||||
if r.hasAccessToken() {
|
||||
if _, hbErr := r.performHeartbeatCycle(ctx, nodeID, false); hbErr != nil {
|
||||
slog.Error("agent heartbeat after ws disconnect failed", "error", hbErr)
|
||||
}
|
||||
}
|
||||
case <-heartbeatTicker.C:
|
||||
if !r.hasAgentToken() {
|
||||
if wsDone != nil {
|
||||
continue
|
||||
}
|
||||
if !r.hasAccessToken() {
|
||||
if err = r.tryRegister(ctx, &nodeID); err != nil {
|
||||
slog.Error("agent discovery register failed", "error", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
r.refreshOpenrestyHealth(ctx)
|
||||
payload, ackWindows := r.prepareHeartbeatPayload(nodeID)
|
||||
heartbeatResult, hbErr := r.HeartbeatService.Heartbeat(ctx, payload)
|
||||
if hbErr != nil {
|
||||
if changed, hbErr := r.performHeartbeatCycle(ctx, nodeID, false); hbErr != nil {
|
||||
slog.Error("agent heartbeat failed", "error", hbErr)
|
||||
} else {
|
||||
r.ackObservabilityWindows(ackWindows)
|
||||
if heartbeatResult == nil {
|
||||
heartbeatResult = &protocol.HeartbeatResult{}
|
||||
}
|
||||
if changed := r.applySettings(heartbeatResult.AgentSettings); changed {
|
||||
if changed {
|
||||
heartbeatTicker.Reset(r.Config.HeartbeatInterval.Duration())
|
||||
}
|
||||
if err = r.SyncService.SyncOnce(ctx, heartbeatResult.ActiveConfig); err != nil {
|
||||
r.recordSyncError(err)
|
||||
slog.Error("agent sync failed", "error", err)
|
||||
}
|
||||
r.tryRestartOpenresty(ctx)
|
||||
r.tryAutoUpdate(ctx)
|
||||
tryStartWebSocket()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) hasAgentToken() bool {
|
||||
return strings.TrimSpace(r.Config.AgentToken) != ""
|
||||
func (r *Runner) performHeartbeatCycle(ctx context.Context, nodeID string, startup bool) (bool, error) {
|
||||
r.refreshOpenrestyHealth(ctx)
|
||||
payload, ackWindows := r.prepareHeartbeatPayload(nodeID)
|
||||
heartbeatResult, err := r.HeartbeatService.Heartbeat(ctx, payload)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
r.ackObservabilityWindows(ackWindows)
|
||||
if heartbeatResult == nil {
|
||||
heartbeatResult = &protocol.HeartbeatResult{}
|
||||
}
|
||||
mode := "periodic"
|
||||
if startup {
|
||||
mode = "startup"
|
||||
}
|
||||
slog.Debug("agent heartbeat succeeded", "mode", mode, "node_id", nodeID)
|
||||
changed := r.applySettings(heartbeatResult.AgentSettings)
|
||||
r.applyWAFIPGroups(ctx, heartbeatResult.WAFIPGroups)
|
||||
if startup {
|
||||
if err = r.SyncService.SyncOnStartup(ctx, heartbeatResult.ActiveConfig); err != nil {
|
||||
r.recordSyncError(err)
|
||||
slog.Error("agent startup sync failed", "error", err)
|
||||
} else {
|
||||
slog.Debug("agent startup sync completed")
|
||||
}
|
||||
} else if err = r.SyncService.SyncOnce(ctx, heartbeatResult.ActiveConfig); err != nil {
|
||||
r.recordSyncError(err)
|
||||
slog.Error("agent sync failed", "error", err)
|
||||
}
|
||||
r.tryRestartOpenresty(ctx)
|
||||
r.tryAutoUpdate(ctx)
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func (r *Runner) shouldUseWebSocket() bool {
|
||||
enabled := r.WebSocketService != nil && r.websocketUpgradeEnabled && r.hasAccessToken()
|
||||
slog.Debug("agent ws upgrade eligibility checked", "enabled", enabled, "server_enabled", r.websocketUpgradeEnabled, "url", r.websocketURL())
|
||||
return enabled
|
||||
}
|
||||
|
||||
func (r *Runner) websocketURL() string {
|
||||
if r.WebSocketService == nil {
|
||||
return ""
|
||||
}
|
||||
return r.WebSocketService.URL()
|
||||
}
|
||||
|
||||
func (r *Runner) startWebSocket(ctx context.Context, nodeID string) (<-chan error, error) {
|
||||
if r.WebSocketService == nil {
|
||||
return nil, errors.New("websocket service is not configured")
|
||||
}
|
||||
conn, err := r.WebSocketService.Connect(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
defer func() {
|
||||
_ = conn.Close()
|
||||
}()
|
||||
done <- r.runWebSocket(ctx, nodeID, conn)
|
||||
}()
|
||||
return done, nil
|
||||
}
|
||||
|
||||
func (r *Runner) runWebSocket(ctx context.Context, nodeID string, conn protocol.WebSocketConnection) error {
|
||||
slog.Debug("agent ws connected", "url", conn.URL(), "node_id", nodeID)
|
||||
statusTicker := time.NewTicker(r.Config.HeartbeatInterval.Duration())
|
||||
defer statusTicker.Stop()
|
||||
|
||||
messages := make(chan protocol.WSMessage, 8)
|
||||
readDone := make(chan error, 1)
|
||||
go func() {
|
||||
for {
|
||||
message, err := conn.Receive()
|
||||
if err != nil {
|
||||
readDone <- err
|
||||
return
|
||||
}
|
||||
select {
|
||||
case messages <- message:
|
||||
case <-ctx.Done():
|
||||
readDone <- ctx.Err()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if err := r.sendWebSocketStatus(ctx, nodeID, conn); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case err := <-readDone:
|
||||
return err
|
||||
case <-statusTicker.C:
|
||||
if err := r.sendWebSocketStatus(ctx, nodeID, conn); err != nil {
|
||||
return err
|
||||
}
|
||||
case message := <-messages:
|
||||
changed, err := r.handleWebSocketMessage(ctx, message, conn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed {
|
||||
statusTicker.Reset(r.Config.HeartbeatInterval.Duration())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) sendWebSocketStatus(ctx context.Context, nodeID string, conn protocol.WebSocketConnection) error {
|
||||
r.refreshOpenrestyHealth(ctx)
|
||||
payload, ackWindows := r.prepareHeartbeatPayload(nodeID)
|
||||
if err := conn.SendStatus(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
r.ackObservabilityWindows(ackWindows)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runner) handleWebSocketMessage(ctx context.Context, message protocol.WSMessage, conn protocol.WebSocketConnection) (bool, error) {
|
||||
switch message.Type {
|
||||
case protocol.WSMessageTypeSettings:
|
||||
var settings protocol.AgentSettings
|
||||
if err := json.Unmarshal(message.Payload, &settings); err != nil {
|
||||
slog.Debug("agent ws settings decode failed", "error", err)
|
||||
return false, nil
|
||||
}
|
||||
changed := r.applySettings(&settings)
|
||||
r.tryRestartOpenresty(ctx)
|
||||
r.tryAutoUpdate(ctx)
|
||||
if !r.websocketUpgradeEnabled {
|
||||
slog.Debug("agent ws disabled by server settings; falling back to http heartbeat")
|
||||
return changed, errors.New("websocket upgrade disabled by server")
|
||||
}
|
||||
return changed, nil
|
||||
case protocol.WSMessageTypeActiveConfig:
|
||||
var target protocol.ActiveConfigMeta
|
||||
if err := json.Unmarshal(message.Payload, &target); err != nil {
|
||||
slog.Debug("agent ws active config decode failed", "error", err)
|
||||
return false, nil
|
||||
}
|
||||
slog.Debug("agent ws active config received", "version", target.Version, "checksum", target.Checksum, "trigger_sync", true)
|
||||
if err := r.SyncService.SyncOnce(ctx, &target); err != nil {
|
||||
r.recordSyncError(err)
|
||||
slog.Error("agent ws triggered sync failed", "version", target.Version, "error", err)
|
||||
}
|
||||
return false, nil
|
||||
case protocol.WSMessageTypeForceSyncConfig:
|
||||
var target protocol.ActiveConfigMeta
|
||||
if err := json.Unmarshal(message.Payload, &target); err != nil {
|
||||
slog.Debug("agent ws force sync config decode failed", "error", err)
|
||||
return false, nil
|
||||
}
|
||||
slog.Debug("agent ws force sync config received", "version", target.Version, "checksum", target.Checksum, "trigger_sync", true)
|
||||
if err := r.SyncService.ForceSyncOnce(ctx, &target); err != nil {
|
||||
r.recordSyncError(err)
|
||||
slog.Error("agent ws triggered force sync failed", "version", target.Version, "error", err)
|
||||
}
|
||||
return false, nil
|
||||
case protocol.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()
|
||||
case protocol.WSMessageTypePong:
|
||||
slog.Debug("agent ws pong received")
|
||||
return false, nil
|
||||
default:
|
||||
slog.Debug("agent ws unsupported message type", "type", message.Type)
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
type webSocketBackoff struct {
|
||||
delays []time.Duration
|
||||
index int
|
||||
}
|
||||
|
||||
func newWebSocketBackoff() *webSocketBackoff {
|
||||
return &webSocketBackoff{
|
||||
delays: []time.Duration{
|
||||
time.Second,
|
||||
2 * time.Second,
|
||||
5 * time.Second,
|
||||
10 * time.Second,
|
||||
30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (backoff *webSocketBackoff) Next() time.Duration {
|
||||
if backoff == nil || len(backoff.delays) == 0 {
|
||||
return 30 * time.Second
|
||||
}
|
||||
if backoff.index >= len(backoff.delays) {
|
||||
return backoff.delays[len(backoff.delays)-1]
|
||||
}
|
||||
delay := backoff.delays[backoff.index]
|
||||
backoff.index++
|
||||
return delay
|
||||
}
|
||||
|
||||
func (backoff *webSocketBackoff) Reset() {
|
||||
if backoff != nil {
|
||||
backoff.index = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) hasAccessToken() bool {
|
||||
return strings.TrimSpace(r.Config.AccessToken) != ""
|
||||
}
|
||||
|
||||
func (r *Runner) applySettings(settings *protocol.AgentSettings) bool {
|
||||
@@ -144,6 +382,10 @@ func (r *Runner) applySettings(settings *protocol.AgentSettings) bool {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if settings.WebsocketUpgradeEnabled != r.websocketUpgradeEnabled {
|
||||
slog.Debug("agent websocket upgrade setting updated", "from", r.websocketUpgradeEnabled, "to", settings.WebsocketUpgradeEnabled)
|
||||
}
|
||||
r.websocketUpgradeEnabled = settings.WebsocketUpgradeEnabled
|
||||
r.autoUpdate = settings.AutoUpdate
|
||||
r.updateNow = settings.UpdateNow
|
||||
r.updateRepo = strings.TrimSpace(settings.UpdateRepo)
|
||||
@@ -205,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()
|
||||
@@ -216,12 +458,15 @@ 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.AccessToken)
|
||||
}
|
||||
*nodeID = response.NodeID
|
||||
slog.Info("agent discovery registration succeeded", "node_id", response.NodeID)
|
||||
r.refreshOpenrestyHealth(ctx)
|
||||
@@ -236,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)
|
||||
@@ -268,6 +514,9 @@ func (r *Runner) refreshOpenrestyHealth(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
if err := r.RuntimeManager.CheckHealth(ctx); err != nil {
|
||||
if strings.Contains(err.Error(), "openresty config not exists") {
|
||||
return
|
||||
}
|
||||
r.recordOpenrestyUnhealthy(err, true)
|
||||
return
|
||||
}
|
||||
@@ -324,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,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)
|
||||
@@ -381,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)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -67,7 +68,10 @@ type fakeSyncService struct {
|
||||
syncOnceErr error
|
||||
startupCalls int
|
||||
syncOnceCalls int
|
||||
lastTarget *protocol.ActiveConfigMeta
|
||||
onSyncOnceCall func(int)
|
||||
wafChecksums map[string]string
|
||||
wafGroups []protocol.WAFIPGroup
|
||||
}
|
||||
|
||||
type fakeRuntimeManager struct {
|
||||
@@ -104,6 +108,10 @@ func (f *fakeSyncService) SyncOnStartup(ctx context.Context, target *protocol.Ac
|
||||
func (f *fakeSyncService) SyncOnce(ctx context.Context, target *protocol.ActiveConfigMeta) error {
|
||||
f.mu.Lock()
|
||||
f.syncOnceCalls++
|
||||
if target != nil {
|
||||
copied := *target
|
||||
f.lastTarget = &copied
|
||||
}
|
||||
callIndex := f.syncOnceCalls
|
||||
callback := f.onSyncOnceCall
|
||||
f.mu.Unlock()
|
||||
@@ -113,6 +121,61 @@ func (f *fakeSyncService) SyncOnce(ctx context.Context, target *protocol.ActiveC
|
||||
return f.syncOnceErr
|
||||
}
|
||||
|
||||
func (f *fakeSyncService) ForceSyncOnce(ctx context.Context, target *protocol.ActiveConfigMeta) error {
|
||||
f.mu.Lock()
|
||||
f.syncOnceCalls++
|
||||
if target != nil {
|
||||
copied := *target
|
||||
f.lastTarget = &copied
|
||||
}
|
||||
callIndex := f.syncOnceCalls
|
||||
callback := f.onSyncOnceCall
|
||||
f.mu.Unlock()
|
||||
if callback != nil {
|
||||
callback(callIndex)
|
||||
}
|
||||
return f.syncOnceErr
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (f *fakeWebSocketConnection) URL() string {
|
||||
return "ws://127.0.0.1/api/agent/ws"
|
||||
}
|
||||
|
||||
func (f *fakeWebSocketConnection) SendStatus(payload protocol.NodePayload) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWebSocketConnection) SendPong() error {
|
||||
f.pongCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWebSocketConnection) Receive() (protocol.WSMessage, error) {
|
||||
return protocol.WSMessage{}, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (f *fakeWebSocketConnection) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRunnerKeepsHeartbeatWhenStartupSyncFails(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
@@ -131,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,
|
||||
@@ -184,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,
|
||||
@@ -242,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,
|
||||
@@ -298,19 +361,20 @@ 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"),
|
||||
HeartbeatInterval: config.MillisecondDuration(10 * time.Millisecond),
|
||||
},
|
||||
StateStore: stateStore,
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(runner.Config.RouteConfigPath), 0o755); err != nil {
|
||||
t.Fatalf("failed to prepare route config dir: %v", err)
|
||||
if err := os.MkdirAll(filepath.Dir(runner.Config.AccessLogPath), 0o755); err != nil {
|
||||
t.Fatalf("failed to prepare access log dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(
|
||||
filepath.Join(filepath.Dir(runner.Config.RouteConfigPath), "openflare_access.log"),
|
||||
runner.Config.AccessLogPath,
|
||||
[]byte("{\"ts\":\""+time.Now().UTC().Format(time.RFC3339)+"\",\"host\":\"edge.example.com\",\"path\":\"/\",\"remote_addr\":\"10.0.0.8\",\"status\":200}\n"),
|
||||
0o644,
|
||||
); err != nil {
|
||||
@@ -377,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),
|
||||
@@ -434,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) {
|
||||
@@ -460,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,
|
||||
@@ -469,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)
|
||||
@@ -490,7 +554,87 @@ 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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerHandlesWebSocketActiveConfigMessage(t *testing.T) {
|
||||
syncService := &fakeSyncService{}
|
||||
runner := &Runner{SyncService: syncService}
|
||||
payload, err := json.Marshal(protocol.ActiveConfigMeta{
|
||||
Version: "20260529-001",
|
||||
Checksum: "checksum-ws",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal active config: %v", err)
|
||||
}
|
||||
|
||||
changed, err := runner.handleWebSocketMessage(context.Background(), protocol.WSMessage{
|
||||
Type: protocol.WSMessageTypeActiveConfig,
|
||||
Payload: payload,
|
||||
}, &fakeWebSocketConnection{})
|
||||
if err != nil {
|
||||
t.Fatalf("handle websocket active config: %v", err)
|
||||
}
|
||||
if changed {
|
||||
t.Fatal("active config message should not change heartbeat interval")
|
||||
}
|
||||
if syncService.syncOnceCalls != 1 {
|
||||
t.Fatalf("expected one sync call, got %d", syncService.syncOnceCalls)
|
||||
}
|
||||
if syncService.lastTarget == nil || syncService.lastTarget.Version != "20260529-001" || syncService.lastTarget.Checksum != "checksum-ws" {
|
||||
t.Fatalf("unexpected sync target: %+v", syncService.lastTarget)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerHandlesWebSocketSettingsDisabled(t *testing.T) {
|
||||
runner := &Runner{
|
||||
Config: &config.Config{
|
||||
HeartbeatInterval: config.MillisecondDuration(10 * time.Second),
|
||||
},
|
||||
websocketUpgradeEnabled: true,
|
||||
}
|
||||
payload, err := json.Marshal(protocol.AgentSettings{
|
||||
HeartbeatInterval: 15000,
|
||||
WebsocketUpgradeEnabled: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal settings: %v", err)
|
||||
}
|
||||
|
||||
changed, err := runner.handleWebSocketMessage(context.Background(), protocol.WSMessage{
|
||||
Type: protocol.WSMessageTypeSettings,
|
||||
Payload: payload,
|
||||
}, &fakeWebSocketConnection{})
|
||||
if err == nil {
|
||||
t.Fatal("expected disabled websocket setting to request fallback")
|
||||
}
|
||||
if !changed {
|
||||
t.Fatal("expected heartbeat interval change to be reported")
|
||||
}
|
||||
if runner.websocketUpgradeEnabled {
|
||||
t.Fatal("expected websocket upgrade to be disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSocketBackoffSequence(t *testing.T) {
|
||||
backoff := newWebSocketBackoff()
|
||||
expected := []time.Duration{
|
||||
time.Second,
|
||||
2 * time.Second,
|
||||
5 * time.Second,
|
||||
10 * time.Second,
|
||||
30 * time.Second,
|
||||
30 * time.Second,
|
||||
}
|
||||
for _, want := range expected {
|
||||
if got := backoff.Next(); got != want {
|
||||
t.Fatalf("unexpected backoff: got %s want %s", got, want)
|
||||
}
|
||||
}
|
||||
backoff.Reset()
|
||||
if got := backoff.Next(); got != time.Second {
|
||||
t.Fatalf("expected reset backoff to return 1s, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +1,65 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"openflare/utils"
|
||||
"openflare/utils/geoip"
|
||||
"openflare/utils/geoip/iputil"
|
||||
"os"
|
||||
pathpkg "path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultDockerMainConfigRelativePath = "etc/nginx/nginx.conf"
|
||||
defaultDockerRouteConfigRelativePath = "etc/nginx/conf.d/openflare_routes.conf"
|
||||
defaultMainConfigRelativePath = "etc/nginx/nginx.conf"
|
||||
defaultRouteConfigRelativePath = "etc/nginx/conf.d/openflare_routes.conf"
|
||||
defaultCertDirRelativePath = "etc/nginx/certs"
|
||||
defaultLuaDirRelativePath = "etc/nginx/lua"
|
||||
defaultDockerStateRelativePath = "var/lib/openflare/agent-state.json"
|
||||
defaultRuntimeConfigDirRelativePath = "etc/openflare"
|
||||
defaultMMDBRelativePath = "etc/openflare/GeoLite2-Country.mmdb"
|
||||
defaultAccessLogRelativePath = "var/log/openflare/access.log"
|
||||
defaultStateRelativePath = "var/lib/openflare/agent-state.json"
|
||||
defaultObservabilityBufferRelativePath = "var/lib/openflare/observability-buffer.json"
|
||||
defaultDockerOpenRestyCertDir = "/etc/nginx/openflare-certs"
|
||||
defaultDockerOpenRestyLuaDir = "/etc/nginx/openflare-lua"
|
||||
defaultOpenRestyObservabilityPort = 18081
|
||||
defaultObservabilityReplayMinutes = 15
|
||||
defaultMMDBUpdateInterval = 24 * time.Hour
|
||||
defaultMMDBDownloadURL = "https://raw.githubusercontent.com/Loyalsoldier/geoip/release/GeoLite2-Country.mmdb"
|
||||
)
|
||||
|
||||
var (
|
||||
lookupOutboundIP = geoip.GetOutboundIP
|
||||
lookupLocalIP = detectLocalNodeIP
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
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"`
|
||||
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"`
|
||||
AccessLogPath string `json:"access_log_path"`
|
||||
CertDir string `json:"cert_dir"`
|
||||
OpenrestyCertDir string `json:"openresty_cert_dir"`
|
||||
LuaDir string `json:"lua_dir"`
|
||||
OpenrestyLuaDir string `json:"openresty_lua_dir"`
|
||||
RuntimeConfigDir string `json:"runtime_config_dir"`
|
||||
MMDBPath string `json:"mmdb_path"`
|
||||
MMDBUpdateInterval MillisecondDuration `json:"mmdb_update_interval"`
|
||||
MMDBDownloadURL string `json:"mmdb_download_url"`
|
||||
OpenrestyObservabilityPort int `json:"openresty_observability_port"`
|
||||
ObservabilityBufferPath string `json:"observability_buffer_path"`
|
||||
ObservabilityReplayMinutes int `json:"observability_replay_minutes"`
|
||||
@@ -56,22 +71,24 @@ 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"`
|
||||
AccessLogPath string `json:"access_log_path"`
|
||||
CertDir string `json:"cert_dir"`
|
||||
OpenrestyCertDir string `json:"openresty_cert_dir"`
|
||||
LuaDir string `json:"lua_dir"`
|
||||
OpenrestyLuaDir string `json:"openresty_lua_dir"`
|
||||
RuntimeConfigDir string `json:"runtime_config_dir"`
|
||||
MMDBPath string `json:"mmdb_path"`
|
||||
MMDBUpdateInterval MillisecondDuration `json:"mmdb_update_interval"`
|
||||
MMDBDownloadURL string `json:"mmdb_download_url"`
|
||||
OpenrestyObservabilityPort int `json:"openresty_observability_port"`
|
||||
ObservabilityBufferPath string `json:"observability_buffer_path"`
|
||||
ObservabilityReplayMinutes int `json:"observability_replay_minutes"`
|
||||
@@ -82,31 +99,38 @@ type configFile struct {
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
file := &configFile{}
|
||||
if err = json.Unmarshal(data, file); err != nil {
|
||||
if err == nil {
|
||||
if err = json.Unmarshal(data, file); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err != nil && !hasEnvConfig() {
|
||||
return nil, err
|
||||
}
|
||||
cfg := &Config{
|
||||
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,
|
||||
AccessLogPath: file.AccessLogPath,
|
||||
CertDir: file.CertDir,
|
||||
OpenrestyCertDir: file.OpenrestyCertDir,
|
||||
LuaDir: file.LuaDir,
|
||||
OpenrestyLuaDir: file.OpenrestyLuaDir,
|
||||
RuntimeConfigDir: file.RuntimeConfigDir,
|
||||
MMDBPath: file.MMDBPath,
|
||||
MMDBUpdateInterval: file.MMDBUpdateInterval,
|
||||
MMDBDownloadURL: file.MMDBDownloadURL,
|
||||
OpenrestyObservabilityPort: file.OpenrestyObservabilityPort,
|
||||
ObservabilityBufferPath: file.ObservabilityBufferPath,
|
||||
ObservabilityReplayMinutes: file.ObservabilityReplayMinutes,
|
||||
@@ -115,6 +139,7 @@ func Load(path string) (*Config, error) {
|
||||
RequestTimeout: file.RequestTimeout,
|
||||
}
|
||||
cfg.configPath = path
|
||||
applyEnvOverrides(cfg)
|
||||
applyDefaults(cfg, filepath.Dir(path))
|
||||
if err = validate(cfg); err != nil {
|
||||
return nil, err
|
||||
@@ -124,16 +149,10 @@ func Load(path string) (*Config, error) {
|
||||
|
||||
func applyDefaults(cfg *Config, baseDir string) {
|
||||
baseDir = filepath.Clean(baseDir)
|
||||
cfg.AgentVersion = AgentVersion
|
||||
cfg.OpenrestyResolvers = normalizeResolverList(cfg.OpenrestyResolvers)
|
||||
if cfg.OpenrestyContainerName == "" {
|
||||
cfg.OpenrestyContainerName = "openflare-openresty"
|
||||
}
|
||||
if cfg.OpenrestyDockerImage == "" {
|
||||
cfg.OpenrestyDockerImage = "openresty/openresty:alpine"
|
||||
}
|
||||
if cfg.DockerBinary == "" {
|
||||
cfg.DockerBinary = "docker"
|
||||
cfg.Version = Version
|
||||
cfg.OpenrestyResolvers = utils.UniqueAndCleanStringSlice(cfg.OpenrestyResolvers)
|
||||
if cfg.OpenrestyPath == "" {
|
||||
cfg.OpenrestyPath = "openresty"
|
||||
}
|
||||
if cfg.DataDir == "" {
|
||||
cfg.DataDir = filepath.Join(baseDir, "data")
|
||||
@@ -144,40 +163,41 @@ func applyDefaults(cfg *Config, baseDir string) {
|
||||
if cfg.NodeIP == "" {
|
||||
cfg.NodeIP = detectNodeIP()
|
||||
}
|
||||
if cfg.OpenrestyPath == "" {
|
||||
cfg.MainConfigPath = joinManagedPath(cfg.DataDir, defaultDockerMainConfigRelativePath)
|
||||
cfg.RouteConfigPath = joinManagedPath(cfg.DataDir, defaultDockerRouteConfigRelativePath)
|
||||
cfg.StatePath = joinManagedPath(cfg.DataDir, defaultDockerStateRelativePath)
|
||||
} else {
|
||||
if cfg.MainConfigPath == "" {
|
||||
cfg.MainConfigPath = joinManagedPath(cfg.DataDir, defaultDockerMainConfigRelativePath)
|
||||
}
|
||||
if cfg.RouteConfigPath == "" {
|
||||
cfg.RouteConfigPath = joinManagedPath(cfg.DataDir, defaultDockerRouteConfigRelativePath)
|
||||
}
|
||||
if cfg.StatePath == "" {
|
||||
cfg.StatePath = joinManagedPath(cfg.DataDir, defaultDockerStateRelativePath)
|
||||
}
|
||||
if cfg.MainConfigPath == "" {
|
||||
cfg.MainConfigPath = joinManagedPath(cfg.DataDir, defaultMainConfigRelativePath)
|
||||
}
|
||||
if cfg.RouteConfigPath == "" {
|
||||
cfg.RouteConfigPath = joinManagedPath(cfg.DataDir, defaultRouteConfigRelativePath)
|
||||
}
|
||||
if cfg.AccessLogPath == "" {
|
||||
cfg.AccessLogPath = joinManagedPath(cfg.DataDir, defaultAccessLogRelativePath)
|
||||
}
|
||||
if cfg.StatePath == "" {
|
||||
cfg.StatePath = joinManagedPath(cfg.DataDir, defaultStateRelativePath)
|
||||
}
|
||||
if cfg.CertDir == "" {
|
||||
cfg.CertDir = joinManagedPath(cfg.DataDir, defaultCertDirRelativePath)
|
||||
}
|
||||
if cfg.OpenrestyCertDir == "" {
|
||||
if cfg.OpenrestyPath != "" {
|
||||
cfg.OpenrestyCertDir = cfg.CertDir
|
||||
} else {
|
||||
cfg.OpenrestyCertDir = defaultDockerOpenRestyCertDir
|
||||
}
|
||||
cfg.OpenrestyCertDir = cfg.CertDir
|
||||
}
|
||||
if cfg.LuaDir == "" {
|
||||
cfg.LuaDir = joinManagedPath(cfg.DataDir, defaultLuaDirRelativePath)
|
||||
}
|
||||
if cfg.OpenrestyLuaDir == "" {
|
||||
if cfg.OpenrestyPath != "" {
|
||||
cfg.OpenrestyLuaDir = cfg.LuaDir
|
||||
} else {
|
||||
cfg.OpenrestyLuaDir = defaultDockerOpenRestyLuaDir
|
||||
}
|
||||
cfg.OpenrestyLuaDir = cfg.LuaDir
|
||||
}
|
||||
if cfg.RuntimeConfigDir == "" {
|
||||
cfg.RuntimeConfigDir = joinManagedPath(cfg.DataDir, defaultRuntimeConfigDirRelativePath)
|
||||
}
|
||||
if cfg.MMDBPath == "" {
|
||||
cfg.MMDBPath = joinManagedPath(cfg.DataDir, defaultMMDBRelativePath)
|
||||
}
|
||||
if cfg.MMDBUpdateInterval <= 0 {
|
||||
cfg.MMDBUpdateInterval = MillisecondDuration(defaultMMDBUpdateInterval)
|
||||
}
|
||||
if cfg.MMDBDownloadURL == "" {
|
||||
cfg.MMDBDownloadURL = defaultMMDBDownloadURL
|
||||
}
|
||||
if cfg.OpenrestyObservabilityPort <= 0 {
|
||||
cfg.OpenrestyObservabilityPort = defaultOpenRestyObservabilityPort
|
||||
@@ -201,35 +221,106 @@ 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)
|
||||
for _, p := range paths {
|
||||
if usesSlashPath(*p) {
|
||||
*p = filepath.ToSlash(*p)
|
||||
}
|
||||
}
|
||||
if usesSlashPath(cfg.RouteConfigPath) {
|
||||
cfg.RouteConfigPath = filepath.ToSlash(cfg.RouteConfigPath)
|
||||
}
|
||||
|
||||
func hasEnvConfig() bool {
|
||||
for _, key := range []string{
|
||||
"OPENFLARE_SERVER_URL",
|
||||
"OPENFLARE_AGENT_TOKEN",
|
||||
"OPENFLARE_DISCOVERY_TOKEN",
|
||||
"OPENFLARE_NODE_NAME",
|
||||
"OPENFLARE_NODE_IP",
|
||||
"OPENFLARE_DATA_DIR",
|
||||
"OPENFLARE_OPENRESTY_PATH",
|
||||
"OPENFLARE_HEARTBEAT_INTERVAL",
|
||||
"OPENFLARE_REQUEST_TIMEOUT",
|
||||
"OPENFLARE_OPENRESTY_OBSERVABILITY_PORT",
|
||||
"OPENFLARE_MMDB_PATH",
|
||||
"OPENFLARE_MMDB_UPDATE_INTERVAL",
|
||||
"OPENFLARE_MMDB_DOWNLOAD_URL",
|
||||
} {
|
||||
if strings.TrimSpace(os.Getenv(key)) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if usesSlashPath(cfg.CertDir) {
|
||||
cfg.CertDir = filepath.ToSlash(cfg.CertDir)
|
||||
return false
|
||||
}
|
||||
|
||||
func applyEnvOverrides(cfg *Config) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
if usesSlashPath(cfg.OpenrestyCertDir) {
|
||||
cfg.OpenrestyCertDir = filepath.ToSlash(cfg.OpenrestyCertDir)
|
||||
overrideString := func(key string, target *string) {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
*target = value
|
||||
}
|
||||
}
|
||||
if usesSlashPath(cfg.LuaDir) {
|
||||
cfg.LuaDir = filepath.ToSlash(cfg.LuaDir)
|
||||
overrideString("OPENFLARE_SERVER_URL", &cfg.ServerURL)
|
||||
overrideString("OPENFLARE_AGENT_TOKEN", &cfg.AccessToken)
|
||||
overrideString("OPENFLARE_DISCOVERY_TOKEN", &cfg.DiscoveryToken)
|
||||
overrideString("OPENFLARE_NODE_NAME", &cfg.NodeName)
|
||||
overrideString("OPENFLARE_NODE_IP", &cfg.NodeIP)
|
||||
overrideString("OPENFLARE_DATA_DIR", &cfg.DataDir)
|
||||
overrideString("OPENFLARE_OPENRESTY_PATH", &cfg.OpenrestyPath)
|
||||
overrideString("OPENFLARE_MMDB_PATH", &cfg.MMDBPath)
|
||||
overrideString("OPENFLARE_MMDB_DOWNLOAD_URL", &cfg.MMDBDownloadURL)
|
||||
if value := strings.TrimSpace(os.Getenv("OPENFLARE_HEARTBEAT_INTERVAL")); value != "" {
|
||||
if duration, err := parseDurationValue(value); err == nil {
|
||||
cfg.HeartbeatInterval = duration
|
||||
}
|
||||
}
|
||||
if usesSlashPath(cfg.OpenrestyLuaDir) {
|
||||
cfg.OpenrestyLuaDir = filepath.ToSlash(cfg.OpenrestyLuaDir)
|
||||
if value := strings.TrimSpace(os.Getenv("OPENFLARE_REQUEST_TIMEOUT")); value != "" {
|
||||
if duration, err := parseDurationValue(value); err == nil {
|
||||
cfg.RequestTimeout = duration
|
||||
}
|
||||
}
|
||||
if usesSlashPath(cfg.StatePath) {
|
||||
cfg.StatePath = filepath.ToSlash(cfg.StatePath)
|
||||
if value := strings.TrimSpace(os.Getenv("OPENFLARE_MMDB_UPDATE_INTERVAL")); value != "" {
|
||||
if duration, err := parseDurationValue(value); err == nil {
|
||||
cfg.MMDBUpdateInterval = duration
|
||||
}
|
||||
}
|
||||
if usesSlashPath(cfg.ObservabilityBufferPath) {
|
||||
cfg.ObservabilityBufferPath = filepath.ToSlash(cfg.ObservabilityBufferPath)
|
||||
if value := strings.TrimSpace(os.Getenv("OPENFLARE_OPENRESTY_OBSERVABILITY_PORT")); value != "" {
|
||||
var port int
|
||||
if _, err := fmt.Sscanf(value, "%d", &port); err == nil {
|
||||
cfg.OpenrestyObservabilityPort = port
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseDurationValue(value string) (MillisecondDuration, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return 0, nil
|
||||
}
|
||||
if parsed, err := time.ParseDuration(trimmed); err == nil {
|
||||
return MillisecondDuration(parsed), nil
|
||||
}
|
||||
ms, err := strconv.ParseInt(trimmed, 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return MillisecondDuration(time.Duration(ms) * time.Millisecond), nil
|
||||
}
|
||||
|
||||
func usesSlashPath(path string) bool {
|
||||
return strings.HasPrefix(path, "/")
|
||||
}
|
||||
@@ -245,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 == "" {
|
||||
@@ -260,6 +351,9 @@ func validate(cfg *Config) error {
|
||||
if cfg.ObservabilityReplayMinutes <= 0 {
|
||||
return errors.New("observability_replay_minutes 必须大于 0")
|
||||
}
|
||||
if cfg.MMDBUpdateInterval <= 0 {
|
||||
return errors.New("mmdb_update_interval 必须大于 0")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -267,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)
|
||||
@@ -295,39 +389,24 @@ 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
|
||||
}
|
||||
return lookupLocalIP()
|
||||
}
|
||||
|
||||
func detectOutboundNodeIP() string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
ip, err := lookupOutboundIP(ctx)
|
||||
if err != nil || ip == nil {
|
||||
return ""
|
||||
}
|
||||
return ip.String()
|
||||
}
|
||||
|
||||
func detectLocalNodeIP() string {
|
||||
interfaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return ""
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"openflare/utils/geoip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadDockerModeUsesManagedPaths(t *testing.T) {
|
||||
func TestLoadDefaultsToManagedBinaryPaths(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "agent.json")
|
||||
payload := map[string]any{
|
||||
@@ -34,31 +37,34 @@ func TestLoadDockerModeUsesManagedPaths(t *testing.T) {
|
||||
if cfg.DataDir != filepath.Join(dir, "data") {
|
||||
t.Fatalf("unexpected data dir: %s", cfg.DataDir)
|
||||
}
|
||||
if cfg.MainConfigPath != filepath.Join(dir, "data", defaultDockerMainConfigRelativePath) {
|
||||
if cfg.OpenrestyPath != "openresty" {
|
||||
t.Fatalf("unexpected openresty path: %s", cfg.OpenrestyPath)
|
||||
}
|
||||
if cfg.MainConfigPath != filepath.Join(dir, "data", defaultMainConfigRelativePath) {
|
||||
t.Fatalf("unexpected main config path: %s", cfg.MainConfigPath)
|
||||
}
|
||||
if cfg.RouteConfigPath != filepath.Join(dir, "data", defaultDockerRouteConfigRelativePath) {
|
||||
if cfg.RouteConfigPath != filepath.Join(dir, "data", defaultRouteConfigRelativePath) {
|
||||
t.Fatalf("unexpected route config path: %s", cfg.RouteConfigPath)
|
||||
}
|
||||
if cfg.AccessLogPath != filepath.Join(dir, "data", defaultAccessLogRelativePath) {
|
||||
t.Fatalf("unexpected access log path: %s", cfg.AccessLogPath)
|
||||
}
|
||||
if cfg.CertDir != filepath.Join(dir, "data", defaultCertDirRelativePath) {
|
||||
t.Fatalf("unexpected cert dir: %s", cfg.CertDir)
|
||||
}
|
||||
if cfg.LuaDir != filepath.Join(dir, "data", defaultLuaDirRelativePath) {
|
||||
t.Fatalf("unexpected lua dir: %s", cfg.LuaDir)
|
||||
}
|
||||
if cfg.OpenrestyContainerName != "openflare-openresty" {
|
||||
t.Fatalf("unexpected openresty container name: %s", cfg.OpenrestyContainerName)
|
||||
if cfg.RuntimeConfigDir != filepath.Join(dir, "data", defaultRuntimeConfigDirRelativePath) {
|
||||
t.Fatalf("unexpected runtime config dir: %s", cfg.RuntimeConfigDir)
|
||||
}
|
||||
if cfg.OpenrestyDockerImage != "openresty/openresty:alpine" {
|
||||
t.Fatalf("unexpected openresty image: %s", cfg.OpenrestyDockerImage)
|
||||
}
|
||||
if cfg.OpenrestyCertDir != defaultDockerOpenRestyCertDir {
|
||||
if cfg.OpenrestyCertDir != cfg.CertDir {
|
||||
t.Fatalf("unexpected openresty cert dir: %s", cfg.OpenrestyCertDir)
|
||||
}
|
||||
if cfg.OpenrestyLuaDir != defaultDockerOpenRestyLuaDir {
|
||||
if cfg.OpenrestyLuaDir != cfg.LuaDir {
|
||||
t.Fatalf("unexpected openresty lua dir: %s", cfg.OpenrestyLuaDir)
|
||||
}
|
||||
if cfg.StatePath != filepath.Join(dir, "data", defaultDockerStateRelativePath) {
|
||||
if cfg.StatePath != filepath.Join(dir, "data", defaultStateRelativePath) {
|
||||
t.Fatalf("unexpected state path: %s", cfg.StatePath)
|
||||
}
|
||||
if cfg.ObservabilityBufferPath != filepath.Join(dir, "data", defaultObservabilityBufferRelativePath) {
|
||||
@@ -178,13 +184,16 @@ func TestLoadUsesCustomDataDirForGeneratedFiles(t *testing.T) {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
if cfg.RouteConfigPath != "/srv/openflare/"+defaultDockerRouteConfigRelativePath {
|
||||
if cfg.RouteConfigPath != "/srv/openflare/"+defaultRouteConfigRelativePath {
|
||||
t.Fatalf("unexpected route config path: %s", cfg.RouteConfigPath)
|
||||
}
|
||||
if cfg.MainConfigPath != "/srv/openflare/"+defaultDockerMainConfigRelativePath {
|
||||
if cfg.MainConfigPath != "/srv/openflare/"+defaultMainConfigRelativePath {
|
||||
t.Fatalf("unexpected main config path: %s", cfg.MainConfigPath)
|
||||
}
|
||||
if cfg.StatePath != "/srv/openflare/"+defaultDockerStateRelativePath {
|
||||
if cfg.AccessLogPath != "/srv/openflare/"+defaultAccessLogRelativePath {
|
||||
t.Fatalf("unexpected access log path: %s", cfg.AccessLogPath)
|
||||
}
|
||||
if cfg.StatePath != "/srv/openflare/"+defaultStateRelativePath {
|
||||
t.Fatalf("unexpected state path: %s", cfg.StatePath)
|
||||
}
|
||||
if cfg.ObservabilityBufferPath != "/srv/openflare/"+defaultObservabilityBufferRelativePath {
|
||||
@@ -196,6 +205,141 @@ func TestLoadUsesCustomDataDirForGeneratedFiles(t *testing.T) {
|
||||
if cfg.LuaDir != "/srv/openflare/"+defaultLuaDirRelativePath {
|
||||
t.Fatalf("unexpected lua dir: %s", cfg.LuaDir)
|
||||
}
|
||||
if cfg.RuntimeConfigDir != "/srv/openflare/"+defaultRuntimeConfigDirRelativePath {
|
||||
t.Fatalf("unexpected runtime config dir: %s", cfg.RuntimeConfigDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadUsesEnvConfigWhenFileIsMissing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("OPENFLARE_SERVER_URL", "http://127.0.0.1:3000")
|
||||
t.Setenv("OPENFLARE_AGENT_TOKEN", "token")
|
||||
t.Setenv("OPENFLARE_NODE_NAME", "edge-env")
|
||||
t.Setenv("OPENFLARE_NODE_IP", "10.0.0.9")
|
||||
t.Setenv("OPENFLARE_DATA_DIR", "/srv/openflare-env")
|
||||
t.Setenv("OPENFLARE_OPENRESTY_PATH", "/usr/bin/openresty")
|
||||
t.Setenv("OPENFLARE_HEARTBEAT_INTERVAL", "45s")
|
||||
t.Setenv("OPENFLARE_REQUEST_TIMEOUT", "2500")
|
||||
t.Setenv("OPENFLARE_OPENRESTY_OBSERVABILITY_PORT", "19091")
|
||||
|
||||
cfg, err := Load(filepath.Join(dir, "missing-agent.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
if cfg.ServerURL != "http://127.0.0.1:3000" || cfg.AccessToken != "token" {
|
||||
t.Fatalf("unexpected env auth config: %#v", cfg)
|
||||
}
|
||||
if cfg.OpenrestyPath != "/usr/bin/openresty" {
|
||||
t.Fatalf("unexpected openresty path: %s", cfg.OpenrestyPath)
|
||||
}
|
||||
if cfg.DataDir != "/srv/openflare-env" {
|
||||
t.Fatalf("unexpected data dir: %s", cfg.DataDir)
|
||||
}
|
||||
if cfg.HeartbeatInterval.Duration() != 45*time.Second {
|
||||
t.Fatalf("unexpected heartbeat interval: %s", cfg.HeartbeatInterval)
|
||||
}
|
||||
if cfg.RequestTimeout.Duration() != 2500*time.Millisecond {
|
||||
t.Fatalf("unexpected request timeout: %s", cfg.RequestTimeout)
|
||||
}
|
||||
if cfg.OpenrestyObservabilityPort != 19091 {
|
||||
t.Fatalf("unexpected observability port: %d", cfg.OpenrestyObservabilityPort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDetectsOutboundIPWhenNodeIPMissing(t *testing.T) {
|
||||
previousLookup := lookupOutboundIP
|
||||
lookupOutboundIP = func(ctx context.Context, strategies ...geoip.OutboundIPStrategy) (net.IP, error) {
|
||||
return net.ParseIP("8.8.8.8"), nil
|
||||
}
|
||||
defer func() {
|
||||
lookupOutboundIP = previousLookup
|
||||
}()
|
||||
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "agent.json")
|
||||
payload := map[string]any{
|
||||
"server_url": "http://127.0.0.1:3000",
|
||||
"agent_token": "token",
|
||||
"node_name": "edge-01",
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal config: %v", err)
|
||||
}
|
||||
if err = os.WriteFile(configPath, data, 0o644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
if cfg.NodeIP != "8.8.8.8" {
|
||||
t.Fatalf("expected outbound IP, got %s", cfg.NodeIP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFallsBackToLocalIPWhenOutboundLookupFails(t *testing.T) {
|
||||
previousOutboundLookup := lookupOutboundIP
|
||||
previousLocalLookup := lookupLocalIP
|
||||
lookupOutboundIP = func(ctx context.Context, strategies ...geoip.OutboundIPStrategy) (net.IP, error) {
|
||||
return nil, errors.New("realip.cc unavailable")
|
||||
}
|
||||
lookupLocalIP = func() string {
|
||||
return "9.9.9.9"
|
||||
}
|
||||
defer func() {
|
||||
lookupOutboundIP = previousOutboundLookup
|
||||
lookupLocalIP = previousLocalLookup
|
||||
}()
|
||||
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "agent.json")
|
||||
payload := map[string]any{
|
||||
"server_url": "http://127.0.0.1:3000",
|
||||
"agent_token": "token",
|
||||
"node_name": "edge-01",
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal config: %v", err)
|
||||
}
|
||||
if err = os.WriteFile(configPath, data, 0o644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
if cfg.NodeIP != "9.9.9.9" {
|
||||
t.Fatalf("expected local fallback IP, got %s", cfg.NodeIP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEnvOverridesConfigFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "agent.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"server_url":"http://old:3000","agent_token":"old","node_name":"edge-01","node_ip":"10.0.0.8","openresty_path":"/old/openresty"}`), 0o644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
t.Setenv("OPENFLARE_SERVER_URL", "http://new:3000")
|
||||
t.Setenv("OPENFLARE_AGENT_TOKEN", "new-token")
|
||||
t.Setenv("OPENFLARE_OPENRESTY_PATH", "/new/openresty")
|
||||
|
||||
cfg, err := Load(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
if cfg.ServerURL != "http://new:3000" {
|
||||
t.Fatalf("expected server url from env, got %s", cfg.ServerURL)
|
||||
}
|
||||
if cfg.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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadUsesMillisecondsForIntervals(t *testing.T) {
|
||||
@@ -241,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"}
|
||||
@@ -317,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,3 +1,3 @@
|
||||
package config
|
||||
|
||||
var AgentVersion = "dev"
|
||||
var Version = "dev"
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
package geoipdata
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed GeoLite2-Country.mmdb
|
||||
var FS embed.FS
|
||||
|
||||
const DefaultMMDBName = "GeoLite2-Country.mmdb"
|
||||
@@ -0,0 +1,67 @@
|
||||
package geoipupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"openflare-agent/internal/geoipdata"
|
||||
"openflare/utils/geoip"
|
||||
)
|
||||
|
||||
type Updater struct {
|
||||
MMDBPath string
|
||||
DownloadURL string
|
||||
UpdateInterval time.Duration
|
||||
}
|
||||
|
||||
func (u *Updater) EnsureInitialDatabase() error {
|
||||
path := filepath.Clean(u.MMDBPath)
|
||||
if path == "" || path == "." {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("stat mmdb file failed: %w", err)
|
||||
}
|
||||
data, err := fs.ReadFile(geoipdata.FS, geoipdata.DefaultMMDBName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read embedded mmdb failed: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create mmdb directory failed: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return fmt.Errorf("write initial mmdb failed: %w", err)
|
||||
}
|
||||
slog.Info("initialized GeoIP mmdb from embedded database", "path", path, "size", len(data))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *Updater) Run(ctx context.Context) {
|
||||
if u == nil || u.MMDBPath == "" || u.UpdateInterval <= 0 {
|
||||
return
|
||||
}
|
||||
if err := u.EnsureInitialDatabase(); err != nil {
|
||||
slog.Warn("initialize GeoIP mmdb failed", "path", u.MMDBPath, "error", err)
|
||||
}
|
||||
ticker := time.NewTicker(u.UpdateInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := geoip.DownloadMaxMindDatabase(u.MMDBPath, u.DownloadURL); err != nil {
|
||||
slog.Warn("update GeoIP mmdb failed", "path", u.MMDBPath, "error", err)
|
||||
continue
|
||||
}
|
||||
slog.Info("GeoIP mmdb updated", "path", u.MMDBPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package geoipupdate
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnsureInitialDatabaseCopiesEmbeddedMMDB(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
path := filepath.Join(tempDir, "GeoLite2-Country.mmdb")
|
||||
updater := &Updater{MMDBPath: path}
|
||||
|
||||
if err := updater.EnsureInitialDatabase(); err != nil {
|
||||
t.Fatalf("EnsureInitialDatabase failed: %v", err)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("expected mmdb to exist: %v", err)
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
t.Fatal("expected copied mmdb to be non-empty")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -1,76 +1,20 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type customTextHandler struct {
|
||||
writer io.Writer
|
||||
level slog.Level
|
||||
attrs []slog.Attr
|
||||
groups []string
|
||||
}
|
||||
|
||||
func Setup() {
|
||||
handler := &customTextHandler{
|
||||
writer: os.Stdout,
|
||||
level: parseLevel(os.Getenv("LOG_LEVEL")),
|
||||
opts := &slog.HandlerOptions{
|
||||
AddSource: true,
|
||||
Level: parseLevel(os.Getenv("LOG_LEVEL")),
|
||||
}
|
||||
handler := slog.NewTextHandler(os.Stdout, opts)
|
||||
slog.SetDefault(slog.New(handler))
|
||||
}
|
||||
|
||||
func (h *customTextHandler) Enabled(_ context.Context, level slog.Level) bool {
|
||||
return level >= h.level
|
||||
}
|
||||
|
||||
func (h *customTextHandler) Handle(_ context.Context, record slog.Record) error {
|
||||
var builder strings.Builder
|
||||
builder.WriteString(record.Time.Format("2006-01-02 15:04:05.000"))
|
||||
builder.WriteString(" | ")
|
||||
builder.WriteString(fmt.Sprintf("%-8s", levelLabel(record.Level)))
|
||||
builder.WriteString(" | ")
|
||||
builder.WriteString(sourceLocation(record.PC))
|
||||
builder.WriteString(" - ")
|
||||
builder.WriteString(record.Message)
|
||||
|
||||
attrs := make([]slog.Attr, 0, len(h.attrs)+record.NumAttrs())
|
||||
attrs = append(attrs, h.attrs...)
|
||||
record.Attrs(func(attr slog.Attr) bool {
|
||||
attrs = append(attrs, attr)
|
||||
return true
|
||||
})
|
||||
if len(attrs) > 0 {
|
||||
builder.WriteString(" | ")
|
||||
builder.WriteString(formatAttrs(h.groups, attrs))
|
||||
}
|
||||
builder.WriteByte('\n')
|
||||
_, err := io.WriteString(h.writer, builder.String())
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *customTextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
cloned := *h
|
||||
cloned.attrs = append(slices.Clone(h.attrs), attrs...)
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func (h *customTextHandler) WithGroup(name string) slog.Handler {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return h
|
||||
}
|
||||
cloned := *h
|
||||
cloned.groups = append(slices.Clone(h.groups), name)
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func parseLevel(value string) slog.Level {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "debug":
|
||||
@@ -83,51 +27,3 @@ func parseLevel(value string) slog.Level {
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
func levelLabel(level slog.Level) string {
|
||||
switch {
|
||||
case level <= slog.LevelDebug:
|
||||
return "DEBUG"
|
||||
case level < slog.LevelWarn:
|
||||
return "INFO"
|
||||
case level < slog.LevelError:
|
||||
return "WARNING"
|
||||
default:
|
||||
return "ERROR"
|
||||
}
|
||||
}
|
||||
|
||||
func sourceLocation(pc uintptr) string {
|
||||
if pc == 0 {
|
||||
return "unknown:unknown:0"
|
||||
}
|
||||
frame, _ := runtime.CallersFrames([]uintptr{pc}).Next()
|
||||
fileName := strings.TrimSuffix(filepath.Base(frame.File), filepath.Ext(frame.File))
|
||||
if fileName == "" {
|
||||
fileName = "unknown"
|
||||
}
|
||||
functionName := "unknown"
|
||||
if frame.Function != "" {
|
||||
parts := strings.Split(frame.Function, "/")
|
||||
functionName = parts[len(parts)-1]
|
||||
if dot := strings.LastIndex(functionName, "."); dot >= 0 && dot < len(functionName)-1 {
|
||||
functionName = functionName[dot+1:]
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s:%s:%d", fileName, functionName, frame.Line)
|
||||
}
|
||||
|
||||
func formatAttrs(groups []string, attrs []slog.Attr) string {
|
||||
parts := make([]string, 0, len(attrs))
|
||||
for _, attr := range attrs {
|
||||
key := attr.Key
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if len(groups) > 0 {
|
||||
key = strings.Join(append(slices.Clone(groups), key), ".")
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s=%v", key, attr.Value.Any()))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,8 @@ package nginx
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@@ -29,6 +31,8 @@ type fakeExecutor struct {
|
||||
}
|
||||
|
||||
type scriptedExecutor struct {
|
||||
testErrors []error
|
||||
testCalls int
|
||||
reloadErrors []error
|
||||
reloadCalls int
|
||||
}
|
||||
@@ -62,7 +66,12 @@ func (e *fakeExecutor) Restart(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (e *scriptedExecutor) Test(ctx context.Context) error {
|
||||
return nil
|
||||
index := e.testCalls
|
||||
e.testCalls++
|
||||
if index >= len(e.testErrors) {
|
||||
return nil
|
||||
}
|
||||
return e.testErrors[index]
|
||||
}
|
||||
|
||||
func (e *scriptedExecutor) Reload(ctx context.Context) error {
|
||||
@@ -89,8 +98,9 @@ func (e *scriptedExecutor) Restart(ctx context.Context) error {
|
||||
func TestPathExecutorCommands(t *testing.T) {
|
||||
runner := &fakeRunner{}
|
||||
executor := &PathExecutor{
|
||||
Path: "/usr/local/openresty/nginx/sbin/openresty",
|
||||
Runner: runner,
|
||||
Path: "/usr/local/openresty/nginx/sbin/openresty",
|
||||
ConfigPath: "/data/etc/nginx/nginx.conf",
|
||||
Runner: runner,
|
||||
}
|
||||
|
||||
if err := executor.Test(context.Background()); err != nil {
|
||||
@@ -101,8 +111,8 @@ func TestPathExecutorCommands(t *testing.T) {
|
||||
}
|
||||
|
||||
expected := []runCall{
|
||||
{name: "/usr/local/openresty/nginx/sbin/openresty", args: []string{"-t"}},
|
||||
{name: "/usr/local/openresty/nginx/sbin/openresty", args: []string{"-s", "reload"}},
|
||||
{name: "/usr/local/openresty/nginx/sbin/openresty", args: []string{"-t", "-c", "/data/etc/nginx/nginx.conf"}},
|
||||
{name: "/usr/local/openresty/nginx/sbin/openresty", args: []string{"-s", "reload", "-c", "/data/etc/nginx/nginx.conf"}},
|
||||
}
|
||||
if !reflect.DeepEqual(runner.calls, expected) {
|
||||
t.Fatalf("unexpected calls: %#v", runner.calls)
|
||||
@@ -110,13 +120,18 @@ func TestPathExecutorCommands(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPathExecutorEnsureRuntimeNoop(t *testing.T) {
|
||||
runner := &fakeRunner{}
|
||||
executor := &PathExecutor{
|
||||
Path: "/usr/local/openresty/nginx/sbin/openresty",
|
||||
Runner: &fakeRunner{},
|
||||
Path: "/usr/local/openresty/nginx/sbin/openresty",
|
||||
ConfigPath: "/data/etc/nginx/nginx.conf",
|
||||
Runner: runner,
|
||||
}
|
||||
if err := executor.EnsureRuntime(context.Background(), true); err != nil {
|
||||
t.Fatalf("EnsureRuntime failed: %v", err)
|
||||
}
|
||||
if len(runner.calls) != 2 {
|
||||
t.Fatalf("expected test and reload calls, got %d", len(runner.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathExecutorRestartIgnoresMissingPID(t *testing.T) {
|
||||
@@ -129,8 +144,9 @@ func TestPathExecutorRestartIgnoresMissingPID(t *testing.T) {
|
||||
},
|
||||
}
|
||||
executor := &PathExecutor{
|
||||
Path: "/usr/local/openresty/nginx/sbin/openresty",
|
||||
Runner: runner,
|
||||
Path: "/usr/local/openresty/nginx/sbin/openresty",
|
||||
ConfigPath: "/data/etc/nginx/nginx.conf",
|
||||
Runner: runner,
|
||||
}
|
||||
if err := executor.Restart(context.Background()); err != nil {
|
||||
t.Fatalf("Restart failed: %v", err)
|
||||
@@ -140,422 +156,32 @@ func TestPathExecutorRestartIgnoresMissingPID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerExecutorCheckHealthFailsWhenContainerStopped(t *testing.T) {
|
||||
func TestPathExecutorReloadStartsWhenRuntimeIsNotRunning(t *testing.T) {
|
||||
runner := &fakeRunner{
|
||||
runFn: func(name string, args ...string) ([]byte, error) {
|
||||
if len(args) >= 4 && args[0] == "inspect" && args[2] == "{{.State.Running}}" {
|
||||
return []byte("false"), nil
|
||||
if len(args) >= 2 && args[0] == "-s" && args[1] == "reload" {
|
||||
return []byte("openresty: [error] invalid PID number \"\" in \"/usr/local/openresty/nginx/logs/nginx.pid\""), errors.New("exit status 1")
|
||||
}
|
||||
if len(args) >= 4 && args[0] == "inspect" {
|
||||
return []byte("status=exited exit_code=1 error=\"\" oom_killed=false finished_at=2026-03-18T10:08:30Z"), nil
|
||||
}
|
||||
if len(args) >= 1 && args[0] == "logs" {
|
||||
return []byte("nginx: [emerg] host not found in upstream \"c1\" in /etc/nginx/conf.d/openflare_routes.conf:30"), nil
|
||||
}
|
||||
return []byte("false"), nil
|
||||
return []byte(""), nil
|
||||
},
|
||||
}
|
||||
executor := &DockerExecutor{
|
||||
DockerBinary: "docker",
|
||||
ContainerName: "openflare-openresty",
|
||||
Image: "openresty/openresty:alpine",
|
||||
MainConfigPath: filepath.Clean("/tmp/nginx.conf"),
|
||||
RouteConfigDir: filepath.Clean("/tmp/routes"),
|
||||
CertDir: filepath.Clean("/tmp/certs"),
|
||||
NginxCertDir: "/etc/nginx/openflare-certs",
|
||||
LuaDir: filepath.Clean("/tmp/lua"),
|
||||
NginxLuaDir: "/etc/nginx/openflare-lua",
|
||||
Runner: runner,
|
||||
executor := &PathExecutor{
|
||||
Path: "/usr/local/openresty/nginx/sbin/openresty",
|
||||
ConfigPath: "/data/etc/nginx/nginx.conf",
|
||||
Runner: runner,
|
||||
}
|
||||
if err := executor.CheckHealth(context.Background()); err == nil {
|
||||
t.Fatal("expected CheckHealth to fail when container is not running")
|
||||
} else {
|
||||
text := err.Error()
|
||||
if !strings.Contains(text, "exit_code=1") {
|
||||
t.Fatalf("expected exit code in health error, got %v", err)
|
||||
}
|
||||
if !strings.Contains(text, "host not found in upstream") {
|
||||
t.Fatalf("expected recent docker logs in health error, got %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func prepareDockerMountSources(t *testing.T) (string, string, string, string) {
|
||||
t.Helper()
|
||||
tempDir := t.TempDir()
|
||||
mainConfigPath := filepath.Join(tempDir, "nginx.conf")
|
||||
routeConfigDir := filepath.Join(tempDir, "conf.d")
|
||||
certDir := filepath.Join(tempDir, "certs")
|
||||
luaDir := filepath.Join(tempDir, "lua")
|
||||
|
||||
if err := os.WriteFile(mainConfigPath, []byte("events {}\nhttp {}\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile failed: %v", err)
|
||||
}
|
||||
for _, dir := range []string{routeConfigDir, certDir, luaDir} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll failed: %v", err)
|
||||
}
|
||||
}
|
||||
return mainConfigPath, routeConfigDir, certDir, luaDir
|
||||
}
|
||||
|
||||
func TestDockerExecutorStartsContainerWhenMissing(t *testing.T) {
|
||||
mainConfigPath, routeConfigDir, certDir, luaDir := prepareDockerMountSources(t)
|
||||
runner := &fakeRunner{
|
||||
runFn: func(name string, args ...string) ([]byte, error) {
|
||||
if len(args) >= 1 && args[0] == "inspect" {
|
||||
return []byte(""), errors.New("not found")
|
||||
}
|
||||
return []byte("ok"), nil
|
||||
},
|
||||
}
|
||||
executor := &DockerExecutor{
|
||||
DockerBinary: "docker",
|
||||
ContainerName: "openflare-openresty",
|
||||
Image: "openresty/openresty:alpine",
|
||||
MainConfigPath: mainConfigPath,
|
||||
RouteConfigDir: routeConfigDir,
|
||||
CertDir: certDir,
|
||||
NginxCertDir: "/etc/nginx/openflare-certs",
|
||||
LuaDir: luaDir,
|
||||
NginxLuaDir: "/etc/nginx/openflare-lua",
|
||||
Runner: runner,
|
||||
}
|
||||
|
||||
if err := executor.Test(context.Background()); err != nil {
|
||||
t.Fatalf("Test failed: %v", err)
|
||||
}
|
||||
|
||||
if len(runner.calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %d", len(runner.calls))
|
||||
}
|
||||
if runner.calls[0].args[0] != "run" || runner.calls[0].args[1] != "--rm" {
|
||||
t.Fatalf("expected docker run --rm for test, got %#v", runner.calls[0])
|
||||
}
|
||||
if runner.calls[0].args[len(runner.calls[0].args)-2] != "openresty" {
|
||||
t.Fatalf("expected docker test command to invoke openresty, got %#v", runner.calls[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerExecutorStartsStoppedContainer(t *testing.T) {
|
||||
mainConfigPath, routeConfigDir, certDir, luaDir := prepareDockerMountSources(t)
|
||||
inspectCalls := 0
|
||||
runner := &fakeRunner{
|
||||
runFn: func(name string, args ...string) ([]byte, error) {
|
||||
if len(args) >= 2 && args[0] == "inspect" {
|
||||
inspectCalls++
|
||||
if inspectCalls < 3 {
|
||||
return []byte("false"), nil
|
||||
}
|
||||
return []byte("true"), nil
|
||||
}
|
||||
return []byte("ok"), nil
|
||||
},
|
||||
}
|
||||
executor := &DockerExecutor{
|
||||
DockerBinary: "docker",
|
||||
ContainerName: "openflare-openresty",
|
||||
Image: "openresty/openresty:alpine",
|
||||
MainConfigPath: mainConfigPath,
|
||||
RouteConfigDir: routeConfigDir,
|
||||
CertDir: certDir,
|
||||
NginxCertDir: "/etc/nginx/openflare-certs",
|
||||
LuaDir: luaDir,
|
||||
NginxLuaDir: "/etc/nginx/openflare-lua",
|
||||
Runner: runner,
|
||||
}
|
||||
|
||||
if err := executor.Reload(context.Background()); err != nil {
|
||||
t.Fatalf("Reload failed: %v", err)
|
||||
}
|
||||
|
||||
if len(runner.calls) != 5 {
|
||||
t.Fatalf("expected 5 calls, got %d", len(runner.calls))
|
||||
}
|
||||
if runner.calls[0].args[0] != "inspect" {
|
||||
t.Fatalf("expected docker inspect on first call, got %#v", runner.calls[0])
|
||||
}
|
||||
if runner.calls[1].args[0] != "inspect" {
|
||||
t.Fatalf("expected docker inspect on second call, got %#v", runner.calls[1])
|
||||
}
|
||||
if runner.calls[2].args[0] != "rm" {
|
||||
t.Fatalf("expected docker rm on third call, got %#v", runner.calls[2])
|
||||
}
|
||||
if runner.calls[3].args[0] != "run" {
|
||||
t.Fatalf("expected docker run on fourth call, got %#v", runner.calls[3])
|
||||
}
|
||||
if runner.calls[4].args[0] != "inspect" {
|
||||
t.Fatalf("expected docker inspect after run, got %#v", runner.calls[4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerExecutorReloadsRunningContainerInPlace(t *testing.T) {
|
||||
mainConfigPath, routeConfigDir, certDir, luaDir := prepareDockerMountSources(t)
|
||||
runner := &fakeRunner{
|
||||
runFn: func(name string, args ...string) ([]byte, error) {
|
||||
if len(args) >= 1 && args[0] == "inspect" {
|
||||
return []byte("true"), nil
|
||||
}
|
||||
return []byte("ok"), nil
|
||||
},
|
||||
}
|
||||
executor := &DockerExecutor{
|
||||
DockerBinary: "docker",
|
||||
ContainerName: "openflare-openresty",
|
||||
Image: "openresty/openresty:alpine",
|
||||
MainConfigPath: mainConfigPath,
|
||||
RouteConfigDir: routeConfigDir,
|
||||
CertDir: certDir,
|
||||
NginxCertDir: "/etc/nginx/openflare-certs",
|
||||
LuaDir: luaDir,
|
||||
NginxLuaDir: "/etc/nginx/openflare-lua",
|
||||
Runner: runner,
|
||||
}
|
||||
|
||||
if err := executor.Reload(context.Background()); err != nil {
|
||||
t.Fatalf("Reload failed: %v", err)
|
||||
}
|
||||
|
||||
expected := []runCall{
|
||||
{name: "docker", args: []string{"inspect", "-f", "{{.State.Running}}", "openflare-openresty"}},
|
||||
{name: "docker", args: []string{"exec", "openflare-openresty", "openresty", "-s", "reload"}},
|
||||
{name: "/usr/local/openresty/nginx/sbin/openresty", args: []string{"-s", "reload", "-c", "/data/etc/nginx/nginx.conf"}},
|
||||
{name: "/usr/local/openresty/nginx/sbin/openresty", args: []string{"-c", "/data/etc/nginx/nginx.conf"}},
|
||||
}
|
||||
if !reflect.DeepEqual(runner.calls, expected) {
|
||||
t.Fatalf("unexpected calls: %#v", runner.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerExecutorReloadRecreatesContainerWhenMountedCertMissing(t *testing.T) {
|
||||
mainConfigPath, routeConfigDir, certDir, luaDir := prepareDockerMountSources(t)
|
||||
runner := &fakeRunner{
|
||||
runFn: func(name string, args ...string) ([]byte, error) {
|
||||
if len(args) >= 1 && args[0] == "inspect" {
|
||||
return []byte("true"), nil
|
||||
}
|
||||
if len(args) >= 2 && args[0] == "exec" {
|
||||
return []byte(`nginx: [emerg] cannot load certificate "/etc/nginx/openflare-certs/1.crt": BIO_new_file() failed (SSL: error:80000002:system library::No such file or directory)`), errors.New("exit status 1")
|
||||
}
|
||||
return []byte("ok"), nil
|
||||
},
|
||||
}
|
||||
executor := &DockerExecutor{
|
||||
DockerBinary: "docker",
|
||||
ContainerName: "openflare-openresty",
|
||||
Image: "openresty/openresty:alpine",
|
||||
MainConfigPath: mainConfigPath,
|
||||
RouteConfigDir: routeConfigDir,
|
||||
CertDir: certDir,
|
||||
NginxCertDir: "/etc/nginx/openflare-certs",
|
||||
LuaDir: luaDir,
|
||||
NginxLuaDir: "/etc/nginx/openflare-lua",
|
||||
OpenrestyObservabilityPort: 18081,
|
||||
Runner: runner,
|
||||
}
|
||||
|
||||
if err := executor.Reload(context.Background()); err != nil {
|
||||
t.Fatalf("Reload failed: %v", err)
|
||||
}
|
||||
|
||||
if len(runner.calls) != 6 {
|
||||
t.Fatalf("expected 6 calls, got %d", len(runner.calls))
|
||||
}
|
||||
if runner.calls[2].args[0] != "inspect" || runner.calls[3].args[0] != "rm" || runner.calls[4].args[0] != "run" || runner.calls[5].args[0] != "inspect" {
|
||||
t.Fatalf("expected recreate after reload failure, got %#v", runner.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerExecutorRunContainerMountsManagedFiles(t *testing.T) {
|
||||
mainConfigPath, routeConfigDir, certDir, luaDir := prepareDockerMountSources(t)
|
||||
runner := &fakeRunner{
|
||||
runFn: func(name string, args ...string) ([]byte, error) {
|
||||
if len(args) >= 1 && args[0] == "inspect" {
|
||||
return []byte("true"), nil
|
||||
}
|
||||
return []byte("ok"), nil
|
||||
},
|
||||
}
|
||||
executor := &DockerExecutor{
|
||||
DockerBinary: "docker",
|
||||
ContainerName: "openflare-openresty",
|
||||
Image: "openresty/openresty:alpine",
|
||||
MainConfigPath: mainConfigPath,
|
||||
RouteConfigDir: routeConfigDir,
|
||||
CertDir: certDir,
|
||||
NginxCertDir: "/etc/nginx/openflare-certs",
|
||||
LuaDir: luaDir,
|
||||
NginxLuaDir: "/etc/nginx/openflare-lua",
|
||||
OpenrestyObservabilityPort: 18081,
|
||||
Runner: runner,
|
||||
}
|
||||
|
||||
if err := executor.runContainer(context.Background()); err != nil {
|
||||
t.Fatalf("runContainer failed: %v", err)
|
||||
}
|
||||
|
||||
if len(runner.calls) != 2 {
|
||||
t.Fatalf("expected docker run plus health check, got %d calls", len(runner.calls))
|
||||
}
|
||||
|
||||
expectedArgs := []string{
|
||||
"run", "-d",
|
||||
"--name", "openflare-openresty",
|
||||
"-p", "80:80",
|
||||
"-p", "443:443",
|
||||
"-p", "127.0.0.1:18081:18081",
|
||||
"-v", mainConfigPath + ":" + DockerMainConfigPath,
|
||||
"-v", routeConfigDir + ":/etc/nginx/conf.d",
|
||||
"-v", certDir + ":/etc/nginx/openflare-certs",
|
||||
"-v", luaDir + ":/etc/nginx/openflare-lua",
|
||||
"openresty/openresty:alpine",
|
||||
}
|
||||
if !reflect.DeepEqual(runner.calls[0].args, expectedArgs) {
|
||||
t.Fatalf("unexpected docker run args: %#v", runner.calls[0].args)
|
||||
}
|
||||
if !reflect.DeepEqual(runner.calls[1].args, []string{"inspect", "-f", "{{.State.Running}}", "openflare-openresty"}) {
|
||||
t.Fatalf("unexpected docker health check args: %#v", runner.calls[1].args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerExecutorRecreatesContainerOnStartup(t *testing.T) {
|
||||
mainConfigPath, routeConfigDir, certDir, luaDir := prepareDockerMountSources(t)
|
||||
runner := &fakeRunner{
|
||||
runFn: func(name string, args ...string) ([]byte, error) {
|
||||
if len(args) >= 1 && args[0] == "inspect" {
|
||||
return []byte("true"), nil
|
||||
}
|
||||
return []byte("ok"), nil
|
||||
},
|
||||
}
|
||||
executor := &DockerExecutor{
|
||||
DockerBinary: "docker",
|
||||
ContainerName: "openflare-openresty",
|
||||
Image: "openresty/openresty:alpine",
|
||||
MainConfigPath: mainConfigPath,
|
||||
RouteConfigDir: routeConfigDir,
|
||||
CertDir: certDir,
|
||||
NginxCertDir: "/etc/nginx/openflare-certs",
|
||||
LuaDir: luaDir,
|
||||
NginxLuaDir: "/etc/nginx/openflare-lua",
|
||||
OpenrestyObservabilityPort: 18081,
|
||||
Runner: runner,
|
||||
}
|
||||
|
||||
if err := executor.EnsureRuntime(context.Background(), true); err != nil {
|
||||
t.Fatalf("EnsureRuntime failed: %v", err)
|
||||
}
|
||||
if len(runner.calls) != 4 {
|
||||
t.Fatalf("expected 4 calls, got %d", len(runner.calls))
|
||||
}
|
||||
if runner.calls[1].args[0] != "rm" {
|
||||
t.Fatalf("expected docker rm on second call, got %#v", runner.calls[1])
|
||||
}
|
||||
if runner.calls[2].args[0] != "run" {
|
||||
t.Fatalf("expected docker run on third call, got %#v", runner.calls[2])
|
||||
}
|
||||
if runner.calls[3].args[0] != "inspect" {
|
||||
t.Fatalf("expected docker inspect after run, got %#v", runner.calls[3])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerExecutorRunContainerRejectsMissingMainConfigFile(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
routeConfigDir := filepath.Join(tempDir, "conf.d")
|
||||
certDir := filepath.Join(tempDir, "certs")
|
||||
luaDir := filepath.Join(tempDir, "lua")
|
||||
for _, dir := range []string{routeConfigDir, certDir, luaDir} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
executor := &DockerExecutor{
|
||||
DockerBinary: "docker",
|
||||
ContainerName: "openflare-openresty",
|
||||
Image: "openresty/openresty:alpine",
|
||||
MainConfigPath: filepath.Join(tempDir, "nginx.conf"),
|
||||
RouteConfigDir: routeConfigDir,
|
||||
CertDir: certDir,
|
||||
NginxCertDir: "/etc/nginx/openflare-certs",
|
||||
LuaDir: luaDir,
|
||||
NginxLuaDir: "/etc/nginx/openflare-lua",
|
||||
Runner: &fakeRunner{},
|
||||
}
|
||||
|
||||
err := executor.runContainer(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected missing main config file to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "run a config apply first") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerExecutorRunContainerRejectsMainConfigDirectory(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
mainConfigPath := filepath.Join(tempDir, "nginx.conf")
|
||||
routeConfigDir := filepath.Join(tempDir, "conf.d")
|
||||
certDir := filepath.Join(tempDir, "certs")
|
||||
luaDir := filepath.Join(tempDir, "lua")
|
||||
for _, dir := range []string{mainConfigPath, routeConfigDir, certDir, luaDir} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
executor := &DockerExecutor{
|
||||
DockerBinary: "docker",
|
||||
ContainerName: "openflare-openresty",
|
||||
Image: "openresty/openresty:alpine",
|
||||
MainConfigPath: mainConfigPath,
|
||||
RouteConfigDir: routeConfigDir,
|
||||
CertDir: certDir,
|
||||
NginxCertDir: "/etc/nginx/openflare-certs",
|
||||
LuaDir: luaDir,
|
||||
NginxLuaDir: "/etc/nginx/openflare-lua",
|
||||
Runner: &fakeRunner{},
|
||||
}
|
||||
|
||||
err := executor.runContainer(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected main config directory to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "expected a file") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewExecutorUsesAbsoluteDockerMountPath(t *testing.T) {
|
||||
executor := NewExecutor(ExecutorOptions{
|
||||
DockerBinary: "docker",
|
||||
ContainerName: "openflare-openresty",
|
||||
Image: "openresty/openresty:alpine",
|
||||
MainConfigPath: "./data/etc/nginx/nginx.conf",
|
||||
RouteConfigPath: "./data/etc/nginx/conf.d/openflare_routes.conf",
|
||||
CertDir: "./data/etc/nginx/certs",
|
||||
NginxCertDir: "/etc/nginx/openflare-certs",
|
||||
LuaDir: "./data/etc/nginx/lua",
|
||||
NginxLuaDir: "/etc/nginx/openflare-lua",
|
||||
OpenrestyObservabilityPort: 18081,
|
||||
})
|
||||
|
||||
dockerExecutor, ok := executor.(*DockerExecutor)
|
||||
if !ok {
|
||||
t.Fatal("expected docker executor")
|
||||
}
|
||||
if !filepath.IsAbs(dockerExecutor.RouteConfigDir) {
|
||||
t.Fatalf("expected absolute route config dir, got %s", dockerExecutor.RouteConfigDir)
|
||||
}
|
||||
if !filepath.IsAbs(dockerExecutor.MainConfigPath) {
|
||||
t.Fatalf("expected absolute main config path, got %s", dockerExecutor.MainConfigPath)
|
||||
}
|
||||
if !strings.HasSuffix(dockerExecutor.RouteConfigDir, filepath.Clean("data/etc/nginx/conf.d")) {
|
||||
t.Fatalf("unexpected route config dir: %s", dockerExecutor.RouteConfigDir)
|
||||
}
|
||||
if !strings.HasSuffix(dockerExecutor.MainConfigPath, filepath.Clean("data/etc/nginx/nginx.conf")) {
|
||||
t.Fatalf("unexpected main config path: %s", dockerExecutor.MainConfigPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectVersionFromBinary(t *testing.T) {
|
||||
version, err := detectVersion(context.Background(), ExecutorOptions{
|
||||
NginxPath: "/usr/local/openresty/nginx/sbin/openresty",
|
||||
@@ -577,9 +203,11 @@ func TestManagerApplyAndChecksumIncludeMainConfig(t *testing.T) {
|
||||
mainPath := filepath.Join(tempDir, "nginx.conf")
|
||||
routePath := filepath.Join(tempDir, "conf.d", "openflare_routes.conf")
|
||||
certDir := filepath.Join(tempDir, "certs")
|
||||
accessLogPath := filepath.Join(tempDir, "var", "log", "openflare", "access.log")
|
||||
manager := &Manager{
|
||||
MainConfigPath: mainPath,
|
||||
RouteConfigPath: routePath,
|
||||
AccessLogPath: accessLogPath,
|
||||
CertDir: certDir,
|
||||
NginxCertDir: "/etc/nginx/openflare-certs",
|
||||
LuaDir: filepath.Join(tempDir, "lua"),
|
||||
@@ -601,7 +229,7 @@ func TestManagerApplyAndChecksumIncludeMainConfig(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read main config: %v", err)
|
||||
}
|
||||
expectedMain := "include " + routePath + ";\naccess_log " + filepath.ToSlash(filepath.Join(filepath.Dir(routePath), "openflare_access.log")) + " openflare_json;\n"
|
||||
expectedMain := "include " + routePath + ";\naccess_log " + filepath.ToSlash(accessLogPath) + " openflare_json;\n"
|
||||
if string(mainData) != expectedMain {
|
||||
t.Fatalf("unexpected main config: %s", string(mainData))
|
||||
}
|
||||
@@ -628,80 +256,13 @@ func TestManagerApplyAndChecksumIncludeMainConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerApplyUsesRuntimeRouteConfigPath(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
mainPath := filepath.Join(tempDir, "nginx.conf")
|
||||
routePath := filepath.Join(tempDir, "conf.d", "openflare_routes.conf")
|
||||
manager := &Manager{
|
||||
MainConfigPath: mainPath,
|
||||
RouteConfigPath: routePath,
|
||||
RuntimeRouteConfigPath: DockerRouteConfigPath,
|
||||
CertDir: filepath.Join(tempDir, "certs"),
|
||||
NginxCertDir: "/etc/nginx/openflare-certs",
|
||||
LuaDir: filepath.Join(tempDir, "lua"),
|
||||
NginxLuaDir: "/etc/nginx/openflare-lua",
|
||||
Executor: &fakeExecutor{},
|
||||
}
|
||||
|
||||
if outcome := manager.Apply(context.Background(), "include __OPENFLARE_ROUTE_CONFIG__;\naccess_log __OPENFLARE_ACCESS_LOG__ openflare_json;\n", "server { listen 80; }\n", nil); outcome.Status != ApplyStatusSuccess {
|
||||
t.Fatalf("Apply failed: %#v", outcome)
|
||||
}
|
||||
|
||||
mainData, err := os.ReadFile(mainPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read main config: %v", err)
|
||||
}
|
||||
expectedMain := "include " + DockerRouteConfigPath + ";\naccess_log " + DockerAccessLogPath + " openflare_json;\n"
|
||||
if string(mainData) != expectedMain {
|
||||
t.Fatalf("unexpected main config include path: %s", string(mainData))
|
||||
}
|
||||
|
||||
value, err := manager.CurrentChecksum()
|
||||
if err != nil {
|
||||
t.Fatalf("CurrentChecksum failed: %v", err)
|
||||
}
|
||||
expected := bundleChecksum(
|
||||
"include __OPENFLARE_ROUTE_CONFIG__;\naccess_log __OPENFLARE_ACCESS_LOG__ openflare_json;\n",
|
||||
"server { listen 80; }\n",
|
||||
nil,
|
||||
)
|
||||
if value != expected {
|
||||
t.Fatalf("unexpected checksum: got %s want %s", value, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectVersionFromDockerImage(t *testing.T) {
|
||||
runner := &fakeRunner{
|
||||
runFn: func(name string, args ...string) ([]byte, error) {
|
||||
return []byte("nginx version: openresty/1.27.1.2\n"), nil
|
||||
},
|
||||
}
|
||||
version, err := detectVersion(context.Background(), ExecutorOptions{
|
||||
DockerBinary: "docker",
|
||||
Image: "openresty/openresty:alpine",
|
||||
}, runner)
|
||||
if err != nil {
|
||||
t.Fatalf("detectVersion failed: %v", err)
|
||||
}
|
||||
if version != "1.27.1.2" {
|
||||
t.Fatalf("unexpected version: %s", version)
|
||||
}
|
||||
if len(runner.calls) != 1 {
|
||||
t.Fatalf("expected one command call, got %d", len(runner.calls))
|
||||
}
|
||||
expectedArgs := []string{"run", "--rm", "openresty/openresty:alpine", "openresty", "-v"}
|
||||
if !reflect.DeepEqual(runner.calls[0].args, expectedArgs) {
|
||||
t.Fatalf("unexpected docker args: %#v", runner.calls[0].args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNginxVersionIgnoresDockerEntrypointPaths(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
@@ -736,6 +297,13 @@ func TestManagerApplyWritesSupportFilesAndReplacesPlaceholder(t *testing.T) {
|
||||
if !strings.Contains(string(routeData), "/etc/nginx/openflare-certs/1.crt") {
|
||||
t.Fatalf("expected placeholder replacement in route config, got %s", string(routeData))
|
||||
}
|
||||
renderedRoute := manager.renderRouteConfig("access_by_lua_file __OPENFLARE_LUA_DIR__/pow/check.lua;\nlocation /.within.website/x/cmd/anubis/static/ { alias __OPENFLARE_POW_STATIC_DIR__/; }\n")
|
||||
if !strings.Contains(renderedRoute, "access_by_lua_file /etc/nginx/openflare-lua/pow/check.lua;") {
|
||||
t.Fatalf("expected lua dir placeholder replacement in route config, got %s", renderedRoute)
|
||||
}
|
||||
if !strings.Contains(renderedRoute, "alias /etc/nginx/openflare-lua/pow/static/;") {
|
||||
t.Fatalf("expected pow static dir placeholder replacement in route config, got %s", renderedRoute)
|
||||
}
|
||||
mainData, err := os.ReadFile(manager.MainConfigPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read main config: %v", err)
|
||||
@@ -762,8 +330,69 @@ func TestManagerApplyWritesSupportFilesAndReplacesPlaceholder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerCheckHealthUsesStubStatusInsteadOfConfigTest(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("Listen failed: %v", err)
|
||||
}
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
server := &http.Server{
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/openflare/stub_status" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("Active connections: 1\n"))
|
||||
}),
|
||||
}
|
||||
go func() {
|
||||
_ = server.Serve(listener)
|
||||
}()
|
||||
defer server.Shutdown(context.Background())
|
||||
|
||||
mainPath := filepath.Join(t.TempDir(), "nginx.conf")
|
||||
if err := os.WriteFile(mainPath, []byte("main"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile failed: %v", err)
|
||||
}
|
||||
manager := &Manager{
|
||||
MainConfigPath: mainPath,
|
||||
OpenrestyObservabilityPort: port,
|
||||
Executor: &fakeExecutor{
|
||||
testErr: errors.New("openresty -t should not be called"),
|
||||
},
|
||||
}
|
||||
if err := manager.CheckHealth(context.Background()); err != nil {
|
||||
t.Fatalf("CheckHealth failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerCheckHealthFailsWhenStubStatusUnavailable(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("Listen failed: %v", err)
|
||||
}
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
if err := listener.Close(); err != nil {
|
||||
t.Fatalf("listener close failed: %v", err)
|
||||
}
|
||||
|
||||
mainPath := filepath.Join(t.TempDir(), "nginx.conf")
|
||||
if err := os.WriteFile(mainPath, []byte("main"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile failed: %v", err)
|
||||
}
|
||||
manager := &Manager{
|
||||
MainConfigPath: mainPath,
|
||||
OpenrestyObservabilityPort: port,
|
||||
Executor: &fakeExecutor{},
|
||||
}
|
||||
if err := manager.CheckHealth(context.Background()); err == nil {
|
||||
t.Fatal("expected CheckHealth to fail when stub_status is unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverDirectiveUsesExplicitResolvers(t *testing.T) {
|
||||
got := ResolverDirective("", []string{"10.0.0.2", "1.1.1.1"})
|
||||
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)
|
||||
}
|
||||
@@ -867,6 +496,12 @@ func TestEnsureLuaAssetsKeepsBaseDirAndRemovesStaleFiles(t *testing.T) {
|
||||
if _, err := os.Stat(filepath.Join(luaDir, "log.lua")); err != nil {
|
||||
t.Fatalf("expected managed lua file to exist, stat err = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(luaDir, "pow", "check.lua")); err != nil {
|
||||
t.Fatalf("expected managed pow lua file to exist, stat err = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(luaDir, "pow", "static", "js", "main.mjs")); err != nil {
|
||||
t.Fatalf("expected managed pow static asset to exist, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertFileMode(t *testing.T) {
|
||||
@@ -890,8 +525,9 @@ func TestCertFileMode(t *testing.T) {
|
||||
func TestManagerEnsureLuaAssetsWritesReadableFiles(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
manager := &Manager{
|
||||
LuaDir: filepath.Join(tempDir, "lua"),
|
||||
NginxLuaDir: "/etc/nginx/openflare-lua",
|
||||
LuaDir: filepath.Join(tempDir, "lua"),
|
||||
NginxLuaDir: "/etc/nginx/openflare-lua",
|
||||
RuntimeConfigDir: filepath.Join(tempDir, "runtime"),
|
||||
}
|
||||
|
||||
err := manager.EnsureLuaAssets()
|
||||
@@ -906,6 +542,161 @@ func TestManagerEnsureLuaAssetsWritesReadableFiles(t *testing.T) {
|
||||
if luaInfo.Mode().Perm() != 0o644 {
|
||||
t.Fatalf("unexpected lua mode: %o", luaInfo.Mode().Perm())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(manager.LuaDir, "pow", "check.lua")); err != nil {
|
||||
t.Fatalf("failed to stat pow lua file: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(manager.LuaDir, "pow", "runtime.lua"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read pow lua file: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), filepath.ToSlash(manager.RuntimeConfigDir)+"/pow_config.json") {
|
||||
t.Fatalf("expected pow lua to read runtime config dir, got %s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureLuaAssetsLeavesRuntimePowConfigOutsideLuaDir(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
luaDir := filepath.Join(tempDir, "lua")
|
||||
runtimeConfigDir := filepath.Join(tempDir, "runtime")
|
||||
if err := os.MkdirAll(runtimeConfigDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll failed: %v", err)
|
||||
}
|
||||
powConfigPath := filepath.Join(runtimeConfigDir, "pow_config.json")
|
||||
want := `[{"domains":["pow.example.com"],"enabled":true}]`
|
||||
if err := os.WriteFile(powConfigPath, []byte(want), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile failed: %v", err)
|
||||
}
|
||||
manager := &Manager{LuaDir: luaDir, RuntimeConfigDir: runtimeConfigDir}
|
||||
|
||||
if err := manager.EnsureLuaAssets(); err != nil {
|
||||
t.Fatalf("EnsureLuaAssets failed: %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(powConfigPath)
|
||||
if err != nil {
|
||||
t.Fatalf("expected pow_config.json to remain after EnsureLuaAssets: %v", err)
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Fatalf("unexpected pow_config.json content: got %s want %s", string(got), want)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(luaDir, "pow_config.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected lua pow_config.json to stay absent, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerApplyWritesPowConfigToRuntimeDirAndCleansLegacyCopies(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
certDir := filepath.Join(tempDir, "certs")
|
||||
luaDir := filepath.Join(tempDir, "lua")
|
||||
runtimeConfigDir := filepath.Join(tempDir, "runtime")
|
||||
for _, dir := range []string{certDir, luaDir, runtimeConfigDir} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll failed: %v", err)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{filepath.Join(certDir, "pow_config.json"), filepath.Join(luaDir, "pow_config.json")} {
|
||||
if err := os.WriteFile(path, []byte("stale"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile failed: %v", err)
|
||||
}
|
||||
}
|
||||
manager := &Manager{
|
||||
MainConfigPath: filepath.Join(tempDir, "nginx.conf"),
|
||||
RouteConfigPath: filepath.Join(tempDir, "routes.conf"),
|
||||
CertDir: certDir,
|
||||
LuaDir: luaDir,
|
||||
RuntimeConfigDir: runtimeConfigDir,
|
||||
Executor: &fakeExecutor{},
|
||||
}
|
||||
outcome := manager.Apply(context.Background(), "main", "route", []protocol.SupportFile{
|
||||
{Path: "pow_config.json", Content: "runtime"},
|
||||
})
|
||||
if outcome.Status != ApplyStatusSuccess {
|
||||
t.Fatalf("Apply failed: %#v", outcome)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(runtimeConfigDir, "pow_config.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read runtime pow config: %v", err)
|
||||
}
|
||||
if string(data) != "runtime" {
|
||||
t.Fatalf("unexpected runtime pow config: %s", string(data))
|
||||
}
|
||||
for _, path := range []string{filepath.Join(certDir, "pow_config.json"), filepath.Join(luaDir, "pow_config.json")} {
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected legacy pow config to be removed from %s, stat err = %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerCurrentChecksumIncludesPowConfig(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
mainPath := filepath.Join(tempDir, "nginx.conf")
|
||||
routePath := filepath.Join(tempDir, "routes.conf")
|
||||
luaDir := filepath.Join(tempDir, "lua")
|
||||
runtimeConfigDir := filepath.Join(tempDir, "runtime")
|
||||
manager := &Manager{
|
||||
MainConfigPath: mainPath,
|
||||
RouteConfigPath: routePath,
|
||||
LuaDir: luaDir,
|
||||
NginxLuaDir: "/etc/nginx/openflare-lua",
|
||||
RuntimeConfigDir: runtimeConfigDir,
|
||||
Executor: &fakeExecutor{},
|
||||
}
|
||||
|
||||
outcome := manager.Apply(
|
||||
context.Background(),
|
||||
"access_log __OPENFLARE_ACCESS_LOG__ openflare_json;\n",
|
||||
"location /.within.website/x/cmd/anubis/static/ { alias __OPENFLARE_POW_STATIC_DIR__/; }\n",
|
||||
[]protocol.SupportFile{{Path: "pow_config.json", Content: `[{"domains":["pow.example.com"],"enabled":true}]`}},
|
||||
)
|
||||
if outcome.Status != ApplyStatusSuccess {
|
||||
t.Fatalf("Apply failed: %#v", outcome)
|
||||
}
|
||||
|
||||
value, err := manager.CurrentChecksum()
|
||||
if err != nil {
|
||||
t.Fatalf("CurrentChecksum failed: %v", err)
|
||||
}
|
||||
expected := bundleChecksum(
|
||||
"access_log __OPENFLARE_ACCESS_LOG__ openflare_json;\n",
|
||||
"location /.within.website/x/cmd/anubis/static/ { alias __OPENFLARE_POW_STATIC_DIR__/; }\n",
|
||||
[]protocol.SupportFile{{Path: "pow_config.json", Content: `[{"domains":["pow.example.com"],"enabled":true}]`}},
|
||||
)
|
||||
if value != expected {
|
||||
t.Fatalf("unexpected checksum with pow config: got %s want %s", value, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedPowLuaFilesUseInternalChallengeFlow(t *testing.T) {
|
||||
if !strings.Contains(openRestyPowRuntimeLua, `return ngx.exec("/.within.website/x/cmd/anubis/api/make-challenge")`) {
|
||||
t.Fatal("expected pow runtime lua to internally execute make-challenge instead of issuing a 302 redirect")
|
||||
}
|
||||
if strings.Contains(openRestyPowRuntimeLua, "ngx.redirect(") {
|
||||
t.Fatal("expected pow runtime lua to avoid external redirects for challenge rendering")
|
||||
}
|
||||
if !strings.Contains(openRestyPowChallengeLua, `<h1 id="title" class="centered-div">`) {
|
||||
t.Fatal("expected challenge html to include Anubis-compatible title node")
|
||||
}
|
||||
if !strings.Contains(openRestyPowChallengeLua, `<div id="progress" role="progressbar" aria-labelledby="status"><div class="bar-inner"></div></div>`) {
|
||||
t.Fatal("expected challenge html to include Anubis-compatible progress markup")
|
||||
}
|
||||
if !strings.Contains(openRestyPowChallengeLua, `<script id="anubis_public_url" type="application/json">"__openflare_internal__"</script>`) {
|
||||
t.Fatal("expected challenge html to force Anubis frontend to reuse the current URL as redir target")
|
||||
}
|
||||
if !strings.Contains(openRestyPowRuntimeLua, `pow_sessions:set(session_key, "1", session_ttl)`) {
|
||||
t.Fatal("expected pow runtime lua to refresh the PoW session TTL on each valid request")
|
||||
}
|
||||
if !strings.Contains(openRestyPowRuntimeLua, `ngx.header["Set-Cookie"] = session_cookie(cookie_val, session_ttl)`) {
|
||||
t.Fatal("expected pow runtime lua to refresh the browser session cookie on each valid request")
|
||||
}
|
||||
if !strings.Contains(openRestyPowChallengeLua, `local session_ttl = config.session_ttl or 600`) {
|
||||
t.Fatal("expected challenge.lua to default session TTL to 10 minutes")
|
||||
}
|
||||
if !strings.Contains(openRestyPowVerifyLua, `local session_ttl = challenge_info.session_ttl or 600`) {
|
||||
t.Fatal("expected verify.lua to default session TTL to 10 minutes")
|
||||
}
|
||||
if !strings.Contains(openRestyPowVerifyLua, `if ngx.var.scheme == "https" then`) {
|
||||
t.Fatal("expected verify.lua to only mark the session cookie as Secure for HTTPS requests")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerRollbackRestoresCertFiles(t *testing.T) {
|
||||
@@ -1012,6 +803,55 @@ func TestManagerApplyReturnsWarningWhenRollbackRecoversRuntime(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerApplyStartsSafeFallbackWhenNoRollbackConfigExists(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
routePath := filepath.Join(tempDir, "routes.conf")
|
||||
mainPath := filepath.Join(tempDir, "nginx.conf")
|
||||
executor := &scriptedExecutor{
|
||||
testErrors: []error{errors.New("target config failed"), errors.New("rollback config missing"), nil},
|
||||
}
|
||||
manager := &Manager{
|
||||
MainConfigPath: mainPath,
|
||||
RouteConfigPath: routePath,
|
||||
OpenrestyObservabilityListen: "127.0.0.1:18081",
|
||||
Executor: executor,
|
||||
}
|
||||
|
||||
outcome := manager.Apply(context.Background(), "bad-main", "bad-route", nil)
|
||||
if outcome.Status != ApplyStatusWarning {
|
||||
t.Fatalf("expected warning apply outcome, got %#v", outcome)
|
||||
}
|
||||
if !strings.Contains(outcome.Message, "fallback runtime started") {
|
||||
t.Fatalf("expected fallback message, got %q", outcome.Message)
|
||||
}
|
||||
if executor.testCalls != 3 {
|
||||
t.Fatalf("expected target, rollback, and fallback tests, got %d", executor.testCalls)
|
||||
}
|
||||
mainData, err := os.ReadFile(mainPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read main config: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(mainData), "OpenFlare: No Valid Configuration") {
|
||||
t.Fatalf("expected safe fallback main config, got %s", string(mainData))
|
||||
}
|
||||
if !strings.Contains(string(mainData), "listen 80 default_server") {
|
||||
t.Fatalf("expected fallback to listen on port 80, got %s", string(mainData))
|
||||
}
|
||||
if !strings.Contains(string(mainData), "listen 127.0.0.1:18081") {
|
||||
t.Fatalf("expected fallback to expose local stub_status port, got %s", string(mainData))
|
||||
}
|
||||
if !strings.Contains(string(mainData), "stub_status;") {
|
||||
t.Fatalf("expected fallback to expose stub_status, got %s", string(mainData))
|
||||
}
|
||||
routeData, err := os.ReadFile(routePath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read route config: %v", err)
|
||||
}
|
||||
if len(routeData) != 0 {
|
||||
t.Fatalf("expected fallback route config to be empty, got %q", string(routeData))
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerCertFileTargetPathRejectsEscapes(t *testing.T) {
|
||||
manager := &Manager{CertDir: filepath.Join(t.TempDir(), "certs")}
|
||||
if err := os.MkdirAll(manager.CertDir, 0o755); err != nil {
|
||||
@@ -1075,11 +915,42 @@ func TestManagerApplyRejectsCertFilePathTraversal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestObservabilityListenAddress(t *testing.T) {
|
||||
if got := ObservabilityListenAddress("", 18081); got != "18081" {
|
||||
t.Fatalf("unexpected docker observability listen address: %s", got)
|
||||
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 got := ObservabilityListenAddress("/usr/local/openresty/nginx/sbin/openresty", 18081); got != "127.0.0.1:18081" {
|
||||
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" {
|
||||
t.Fatalf("unexpected default observability listen address: %s", got)
|
||||
}
|
||||
if got := ObservabilityListenAddress(18081); got != "127.0.0.1:18081" {
|
||||
t.Fatalf("unexpected path observability listen address: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package nginx
|
||||
|
||||
const DefaultMimeTypes = `
|
||||
types {
|
||||
text/html html htm shtml;
|
||||
text/css css;
|
||||
text/xml xml;
|
||||
image/gif gif;
|
||||
image/jpeg jpeg jpg;
|
||||
application/javascript js;
|
||||
application/atom+xml atom;
|
||||
application/rss+xml rss;
|
||||
|
||||
text/mathml mml;
|
||||
text/plain txt;
|
||||
text/vnd.sun.j2me.app-descriptor jad;
|
||||
text/vnd.wap.wml wml;
|
||||
text/x-component htc;
|
||||
|
||||
image/png png;
|
||||
image/svg+xml svg svgz;
|
||||
image/tiff tif tiff;
|
||||
image/vnd.wap.wbmp wbmp;
|
||||
image/webp webp;
|
||||
image/x-icon ico;
|
||||
image/x-jng jng;
|
||||
image/x-ms-bmp bmp;
|
||||
|
||||
application/font-woff woff;
|
||||
application/java-archive jar war ear;
|
||||
application/json json;
|
||||
application/mac-binhex40 hqx;
|
||||
application/msword doc;
|
||||
application/pdf pdf;
|
||||
application/postscript ps eps ai;
|
||||
application/rtf rtf;
|
||||
application/vnd.apple.mpegurl m3u8;
|
||||
application/vnd.google-earth.kml+xml kml;
|
||||
application/vnd.google-earth.kmz kmz;
|
||||
application/vnd.ms-excel xls;
|
||||
application/vnd.ms-fontobject eot;
|
||||
application/vnd.ms-powerpoint ppt;
|
||||
application/vnd.oasis.opendocument.graphics odg;
|
||||
application/vnd.oasis.opendocument.presentation odp;
|
||||
application/vnd.oasis.opendocument.spreadsheet ods;
|
||||
application/vnd.oasis.opendocument.text odt;
|
||||
application/vnd.openxmlformats-officedocument.presentationml.presentation
|
||||
pptx;
|
||||
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
|
||||
xlsx;
|
||||
application/vnd.openxmlformats-officedocument.wordprocessingml.document
|
||||
docx;
|
||||
application/vnd.wap.wmlc wmlc;
|
||||
application/x-7z-compressed 7z;
|
||||
application/x-cocoa cco;
|
||||
application/x-java-archive-diff jardiff;
|
||||
application/x-java-jnlp-file jnlp;
|
||||
application/x-makeself run;
|
||||
application/x-perl pl pm;
|
||||
application/x-pilot prc pdb;
|
||||
application/x-rar-compressed rar;
|
||||
application/x-redhat-package-manager rpm;
|
||||
application/x-sea sea;
|
||||
application/x-shockwave-flash swf;
|
||||
application/x-stuffit sit;
|
||||
application/x-tcl tcl tk;
|
||||
application/x-x509-ca-cert der pem crt;
|
||||
application/x-xpinstall xpi;
|
||||
application/xhtml+xml xhtml;
|
||||
application/xspf+xml xspf;
|
||||
application/zip zip;
|
||||
|
||||
application/octet-stream bin exe dll;
|
||||
application/octet-stream deb;
|
||||
application/octet-stream dmg;
|
||||
application/octet-stream iso img;
|
||||
application/octet-stream msi msp msm;
|
||||
|
||||
audio/midi mid midi kar;
|
||||
audio/mpeg mp3;
|
||||
audio/ogg ogg;
|
||||
audio/x-m4a m4a;
|
||||
audio/x-realaudio ra;
|
||||
|
||||
video/3gpp 3gpp 3gp;
|
||||
video/mp2t ts;
|
||||
video/mp4 mp4;
|
||||
video/mpeg mpeg mpg;
|
||||
video/quicktime mov;
|
||||
video/webm webm;
|
||||
video/x-flv flv;
|
||||
video/x-m4v m4v;
|
||||
video/x-mng mng;
|
||||
video/x-ms-asf asx asf;
|
||||
video/x-ms-wmv wmv;
|
||||
video/x-msvideo avi;
|
||||
}
|
||||
`
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
package nginx
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"openflare-agent/internal/protocol"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed pow_static
|
||||
var powStaticFS embed.FS
|
||||
|
||||
const openRestyPowRuntimeLua = `local _M = {}
|
||||
|
||||
function _M.check()
|
||||
local source = debug.getinfo(1, "S").source or ""
|
||||
if string.sub(source, 1, 1) == "@" then
|
||||
local script_path = string.sub(source, 2)
|
||||
local base_dir = string.match(script_path, "^(.*)/pow/[^/]+%.lua$")
|
||||
if base_dir and base_dir ~= "" and not string.find(package.path, base_dir, 1, true) then
|
||||
package.path = base_dir .. "/?.lua;" .. base_dir .. "/?/init.lua;" .. package.path
|
||||
end
|
||||
end
|
||||
|
||||
local cjson = require "cjson.safe"
|
||||
local policy = require "pow.policy"
|
||||
|
||||
local pow_config_dict = ngx.shared.openflare_pow_config
|
||||
local pow_sessions = ngx.shared.openflare_pow_sessions
|
||||
|
||||
local function session_cookie(value, ttl)
|
||||
local cookie = "__openflare_pow=" .. value .. "; Path=/; HttpOnly; SameSite=Lax; Max-Age=" .. tostring(ttl)
|
||||
if ngx.var.scheme == "https" then
|
||||
cookie = cookie .. "; Secure"
|
||||
end
|
||||
return cookie
|
||||
end
|
||||
|
||||
-- Lazy-load pow_config from file; reload when content changes
|
||||
local function load_pow_config()
|
||||
local config_paths = {
|
||||
"__OPENFLARE_RUNTIME_CONFIG_DIR__/pow_config.json",
|
||||
"/etc/nginx/openflare-lua/pow_config.json",
|
||||
"/usr/local/openresty/nginx/conf/pow_config.json"
|
||||
}
|
||||
for _, config_path in ipairs(config_paths) do
|
||||
local f = io.open(config_path, "r")
|
||||
if f then
|
||||
local content = f:read("*a")
|
||||
f:close()
|
||||
local current_hash = ngx.md5(content or "")
|
||||
|
||||
if current_hash == pow_config_dict:get("_config_hash") then
|
||||
return
|
||||
end
|
||||
|
||||
-- Clear old domain entries
|
||||
local old_keys = pow_config_dict:get("_domain_keys")
|
||||
if old_keys then
|
||||
for domain in string.gmatch(old_keys, "[^\n]+") do
|
||||
pow_config_dict:delete(domain)
|
||||
end
|
||||
end
|
||||
|
||||
local domain_keys = {}
|
||||
if content and content ~= "" and content ~= "{}" then
|
||||
local ok, entries = pcall(cjson.decode, content)
|
||||
if ok and entries and type(entries) == "table" then
|
||||
for _, entry in ipairs(entries) do
|
||||
if entry.domains then
|
||||
for _, domain in ipairs(entry.domains) do
|
||||
pow_config_dict:set(domain, cjson.encode(entry), 0)
|
||||
domain_keys[#domain_keys+1] = domain
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
pow_config_dict:set("_domain_keys", table.concat(domain_keys, "\n"), 0)
|
||||
pow_config_dict:set("_config_hash", current_hash, 0)
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
load_pow_config()
|
||||
|
||||
local host = ngx.var.host
|
||||
if not host or host == "" then
|
||||
return
|
||||
end
|
||||
|
||||
local config_raw = pow_config_dict:get(host)
|
||||
if not config_raw then
|
||||
return
|
||||
end
|
||||
|
||||
local ok, route_config = pcall(cjson.decode, config_raw)
|
||||
if not ok or not route_config then
|
||||
return
|
||||
end
|
||||
|
||||
if not route_config.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
local config = route_config.config or {}
|
||||
local session_ttl = config.session_ttl or 600
|
||||
local uri = ngx.var.uri or ""
|
||||
local ua = ngx.var.http_user_agent or ""
|
||||
local remote_ip = ngx.var.remote_addr or ""
|
||||
|
||||
-- Check whitelist: if matched, skip PoW
|
||||
local whitelist = config.whitelist or {}
|
||||
if policy.match_any(remote_ip, ua, uri, whitelist) then
|
||||
return
|
||||
end
|
||||
|
||||
-- Check blacklist: if matched, require PoW
|
||||
local blacklist = config.blacklist or {}
|
||||
local has_blacklist = policy.has_entries(blacklist)
|
||||
local need_pow = false
|
||||
if has_blacklist then
|
||||
need_pow = policy.match_any(remote_ip, ua, uri, blacklist)
|
||||
else
|
||||
-- No blacklist means all non-whitelisted need PoW
|
||||
need_pow = true
|
||||
end
|
||||
|
||||
if not need_pow then
|
||||
return
|
||||
end
|
||||
|
||||
-- Check valid session cookie
|
||||
local cookie_val = ngx.var["cookie___openflare_pow"]
|
||||
if cookie_val and cookie_val ~= "" then
|
||||
local session_key = host .. ":" .. cookie_val
|
||||
local session_data = pow_sessions:get(session_key)
|
||||
if session_data then
|
||||
pow_sessions:set(session_key, "1", session_ttl)
|
||||
ngx.header["Set-Cookie"] = session_cookie(cookie_val, session_ttl)
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
-- If requesting the challenge API endpoints, let them through (handled by content_by_lua)
|
||||
local anubis_api_prefix = "/.within.website/x/cmd/anubis/api/"
|
||||
local anubis_static_prefix = "/.within.website/x/cmd/anubis/static/"
|
||||
if string.sub(uri, 1, #anubis_api_prefix) == anubis_api_prefix then
|
||||
return
|
||||
end
|
||||
if string.sub(uri, 1, #anubis_static_prefix) == anubis_static_prefix then
|
||||
return
|
||||
end
|
||||
|
||||
-- Render the challenge page through an internal redirect so the browser stays
|
||||
-- on the originally requested URL instead of seeing a 302 hop.
|
||||
ngx.req.set_uri_args({
|
||||
redir = ngx.var.scheme .. "://" .. host .. uri .. (ngx.var.args and ("?" .. ngx.var.args) or ""),
|
||||
host = host
|
||||
})
|
||||
return ngx.exec("/.within.website/x/cmd/anubis/api/make-challenge")
|
||||
end
|
||||
|
||||
return _M
|
||||
`
|
||||
|
||||
const openRestyPowCheckLua = `local source = debug.getinfo(1, "S").source or ""
|
||||
if string.sub(source, 1, 1) == "@" then
|
||||
local script_path = string.sub(source, 2)
|
||||
local base_dir = string.match(script_path, "^(.*)/pow/[^/]+%.lua$")
|
||||
if base_dir and base_dir ~= "" and not string.find(package.path, base_dir, 1, true) then
|
||||
package.path = base_dir .. "/?.lua;" .. base_dir .. "/?/init.lua;" .. package.path
|
||||
end
|
||||
end
|
||||
|
||||
return require("pow.runtime").check()
|
||||
`
|
||||
|
||||
const openRestyPowChallengeLua = `local cjson = require "cjson.safe"
|
||||
|
||||
local pow_config_dict = ngx.shared.openflare_pow_config
|
||||
local pow_challenges = ngx.shared.openflare_pow_challenges
|
||||
|
||||
local function generate_entropy()
|
||||
local pieces = {
|
||||
tostring(ngx.now()),
|
||||
tostring(ngx.worker.pid()),
|
||||
tostring(math.random()),
|
||||
ngx.var.remote_addr or "",
|
||||
ngx.var.http_user_agent or "",
|
||||
ngx.var.request_id or "",
|
||||
}
|
||||
return table.concat(pieces, ":")
|
||||
end
|
||||
|
||||
local args = ngx.req.get_uri_args()
|
||||
local host = args["host"] or ngx.var.host or ""
|
||||
local redir = args["redir"] or ""
|
||||
|
||||
local config_raw = pow_config_dict:get(host)
|
||||
if not config_raw then
|
||||
ngx.status = 403
|
||||
ngx.say("PoW not configured for this host")
|
||||
return
|
||||
end
|
||||
|
||||
local ok, route_config = pcall(cjson.decode, config_raw)
|
||||
if not ok or not route_config or not route_config.enabled then
|
||||
ngx.status = 403
|
||||
ngx.say("PoW not enabled for this host")
|
||||
return
|
||||
end
|
||||
|
||||
local config = route_config.config or {}
|
||||
local difficulty = config.difficulty or 4
|
||||
local algorithm = config.algorithm or "fast"
|
||||
local challenge_ttl = config.challenge_ttl or 300
|
||||
local session_ttl = config.session_ttl or 600
|
||||
|
||||
-- Generate challenge data without depending on ngx.random_bytes, which is not
|
||||
-- available in every OpenResty runtime build.
|
||||
local entropy = generate_entropy()
|
||||
local challenge_id = ngx.md5(entropy .. ":id")
|
||||
local challenge_data = ngx.md5(entropy .. ":data-a") .. ngx.md5(entropy .. ":data-b")
|
||||
|
||||
-- Store challenge
|
||||
local challenge_info = cjson.encode({
|
||||
data = challenge_data,
|
||||
difficulty = difficulty,
|
||||
host = host,
|
||||
redir = redir,
|
||||
session_ttl = session_ttl
|
||||
})
|
||||
pow_challenges:set(challenge_id, challenge_info, challenge_ttl)
|
||||
|
||||
local static_prefix = "/.within.website/x/cmd/anubis/static/"
|
||||
local title = "Making sure you're not a bot!"
|
||||
local lang = "en"
|
||||
|
||||
ngx.header.content_type = "text/html; charset=utf-8"
|
||||
ngx.say([[<!DOCTYPE html>
|
||||
<html lang="]] .. lang .. [[">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<title>]] .. title .. [[</title>
|
||||
<link rel="stylesheet" href="]] .. static_prefix .. [[css/xess.css">
|
||||
<style>
|
||||
body,html{height:100%;display:flex;justify-content:center;align-items:center;margin-left:auto;margin-right:auto}
|
||||
.centered-div{text-align:center}
|
||||
#status{font-variant-numeric:tabular-nums}
|
||||
#progress{display:none;width:min(20rem,90%);height:2rem;border-radius:1rem;overflow:hidden;margin:1rem 0 2rem;outline-offset:2px;outline:#b16286 solid 4px}
|
||||
.bar-inner{background-color:#b16286;height:100%;width:0;transition:width .25s ease-in}
|
||||
</style>
|
||||
<script id="anubis_version" type="application/json">"openflare-pow"</script>
|
||||
<script id="anubis_challenge" type="application/json">]] .. cjson.encode({
|
||||
challenge = {
|
||||
id = challenge_id,
|
||||
randomData = challenge_data,
|
||||
method = algorithm
|
||||
},
|
||||
rules = {
|
||||
difficulty = difficulty,
|
||||
algorithm = algorithm
|
||||
}
|
||||
}) .. [[</script>
|
||||
<script id="anubis_base_prefix" type="application/json">""</script>
|
||||
<script id="anubis_public_url" type="application/json">"__openflare_internal__"</script>
|
||||
</head>
|
||||
<body id="top">
|
||||
<main>
|
||||
<h1 id="title" class="centered-div">]] .. title .. [[</h1>
|
||||
<div class="centered-div">
|
||||
<img id="image" style="width:100%;max-width:256px;" src="]] .. static_prefix .. [[img/pensive.webp?cacheBuster=openflare-pow">
|
||||
<p id="status">Loading...</p>
|
||||
<p>This site is protected by a Proof-of-Work challenge. Your browser will solve a small puzzle before the upstream response is shown.</p>
|
||||
<div id="progress" role="progressbar" aria-labelledby="status"><div class="bar-inner"></div></div>
|
||||
<details>
|
||||
<summary>Why am I seeing this?</summary>
|
||||
<p>OpenFlare is asking your browser to complete a lightweight computation to distinguish normal browser traffic from automated abuse. This should finish automatically.</p>
|
||||
</details>
|
||||
<noscript><p>JavaScript is required to pass this verification. Please enable JavaScript and reload.</p></noscript>
|
||||
</div>
|
||||
</main>
|
||||
<script type="module" src="]] .. static_prefix .. [[js/main.mjs"></script>
|
||||
</body>
|
||||
</html>]])
|
||||
`
|
||||
|
||||
const openRestyPowVerifyLua = `local cjson = require "cjson.safe"
|
||||
|
||||
local pow_challenges = ngx.shared.openflare_pow_challenges
|
||||
local pow_sessions = ngx.shared.openflare_pow_sessions
|
||||
|
||||
local args = ngx.req.get_uri_args()
|
||||
local challenge_id = args["id"] or ""
|
||||
local response = args["response"] or ""
|
||||
local nonce_str = args["nonce"] or ""
|
||||
local redir = args["redir"] or ""
|
||||
local elapsed = args["elapsedTime"] or ""
|
||||
|
||||
if challenge_id == "" or response == "" or nonce_str == "" then
|
||||
ngx.status = 400
|
||||
ngx.header.content_type = "application/json"
|
||||
ngx.say(cjson.encode({error = "missing parameters"}))
|
||||
return
|
||||
end
|
||||
|
||||
local nonce = tonumber(nonce_str)
|
||||
if not nonce then
|
||||
ngx.status = 400
|
||||
ngx.header.content_type = "application/json"
|
||||
ngx.say(cjson.encode({error = "invalid nonce"}))
|
||||
return
|
||||
end
|
||||
|
||||
-- Get stored challenge
|
||||
local challenge_raw = pow_challenges:get(challenge_id)
|
||||
if not challenge_raw then
|
||||
ngx.status = 410
|
||||
ngx.header.content_type = "application/json"
|
||||
ngx.say(cjson.encode({error = "challenge expired or not found"}))
|
||||
return
|
||||
end
|
||||
|
||||
local ok, challenge_info = pcall(cjson.decode, challenge_raw)
|
||||
if not ok or not challenge_info then
|
||||
ngx.status = 500
|
||||
ngx.header.content_type = "application/json"
|
||||
ngx.say(cjson.encode({error = "invalid challenge data"}))
|
||||
return
|
||||
end
|
||||
|
||||
local challenge_data = challenge_info.data or ""
|
||||
local difficulty = challenge_info.difficulty or 4
|
||||
local host = challenge_info.host or ngx.var.host or ""
|
||||
local session_ttl = challenge_info.session_ttl or 600
|
||||
|
||||
-- Compute SHA-256(challenge_data + nonce)
|
||||
local calc_string = challenge_data .. tostring(math.floor(nonce))
|
||||
local calculated = ngx.sha1_bin ~= nil and "" or ""
|
||||
|
||||
-- Use resty.sha256 for proper SHA-256
|
||||
local sha256 = require "resty.sha256"
|
||||
local str = require "resty.string"
|
||||
local hasher = sha256:new()
|
||||
hasher:update(calc_string)
|
||||
local hash_bytes = hasher:final()
|
||||
local hash_hex = str.to_hex(hash_bytes)
|
||||
|
||||
-- Verify hash matches response
|
||||
if hash_hex ~= string.lower(response) then
|
||||
ngx.status = 403
|
||||
ngx.header.content_type = "application/json"
|
||||
ngx.say(cjson.encode({error = "hash mismatch"}))
|
||||
return
|
||||
end
|
||||
|
||||
-- Verify difficulty (leading zeros in hex)
|
||||
local prefix = string.rep("0", difficulty)
|
||||
if string.sub(hash_hex, 1, difficulty) ~= prefix then
|
||||
ngx.status = 403
|
||||
ngx.header.content_type = "application/json"
|
||||
ngx.say(cjson.encode({error = "insufficient difficulty"}))
|
||||
return
|
||||
end
|
||||
|
||||
-- Invalidate challenge (prevent replay)
|
||||
pow_challenges:delete(challenge_id)
|
||||
|
||||
-- Generate session token
|
||||
local session_token = str.to_hex(ngx.sha1_bin(challenge_id .. ngx.now() .. tostring(ngx.worker.pid())))
|
||||
|
||||
-- Store session
|
||||
pow_sessions:set(host .. ":" .. session_token, "1", session_ttl)
|
||||
|
||||
-- Set cookie. Secure cookies are not sent over HTTP, so only add Secure when
|
||||
-- the current request itself is HTTPS.
|
||||
local cookie = "__openflare_pow=" .. session_token .. "; Path=/; HttpOnly; SameSite=Lax; Max-Age=" .. tostring(session_ttl)
|
||||
if ngx.var.scheme == "https" then
|
||||
cookie = cookie .. "; Secure"
|
||||
end
|
||||
ngx.header["Set-Cookie"] = cookie
|
||||
|
||||
if redir ~= "" then
|
||||
return ngx.redirect(redir)
|
||||
end
|
||||
|
||||
ngx.header.content_type = "application/json"
|
||||
ngx.say(cjson.encode({ok = true}))
|
||||
`
|
||||
|
||||
const openRestyPowPolicyLua = `local M = {}
|
||||
|
||||
local function match_ip(remote_ip, ips)
|
||||
if not ips or #ips == 0 then return false end
|
||||
for _, ip in ipairs(ips) do
|
||||
if ip == remote_ip then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function match_cidr(remote_ip, cidrs)
|
||||
if not cidrs or #cidrs == 0 then return false end
|
||||
for _, cidr in ipairs(cidrs) do
|
||||
local m, err = ngx.re.match(cidr, "^(\\\\d{1,3}\\\\.\\\\d{1,3}\\\\.\\\\d{1,3}\\\\.\\\\d{1,3})/(\\\\d{1,2})$")
|
||||
if m then
|
||||
local mask_bits = tonumber(m[2])
|
||||
if mask_bits and mask_bits >= 0 and mask_bits <= 32 then
|
||||
local function ip_to_num(ip_str)
|
||||
local parts = {}
|
||||
for part in string.gmatch(ip_str, "%d+") do
|
||||
parts[#parts+1] = tonumber(part) or 0
|
||||
end
|
||||
if #parts ~= 4 then return 0 end
|
||||
return parts[1]*16777216 + parts[2]*65536 + parts[3]*256 + parts[4]
|
||||
end
|
||||
local remote_num = ip_to_num(remote_ip)
|
||||
local net_num = ip_to_num(m[1])
|
||||
if mask_bits == 0 then
|
||||
return true
|
||||
end
|
||||
local mask = math.floor(2^(32 - mask_bits))
|
||||
mask = 4294967296 - mask
|
||||
if bit.band(remote_num, mask) == bit.band(net_num, mask) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function match_path(uri, patterns)
|
||||
if not patterns or #patterns == 0 then return false end
|
||||
for _, pattern in ipairs(patterns) do
|
||||
local ok, match = pcall(ngx.re.match, uri, "^" .. ngx.re.gsub(pattern, "([%^%$%(%)%%%.%[%]%+%-%?])", function(c)
|
||||
if c == "*" then return ".*" end
|
||||
return "%" .. c
|
||||
end) .. "$", "i")
|
||||
if ok and match then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function match_path_regex(uri, patterns)
|
||||
if not patterns or #patterns == 0 then return false end
|
||||
for _, pattern in ipairs(patterns) do
|
||||
local ok, match = pcall(ngx.re.match, uri, pattern)
|
||||
if ok and match then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function match_ua(ua, patterns)
|
||||
if not patterns or #patterns == 0 then return false end
|
||||
for _, pattern in ipairs(patterns) do
|
||||
if ua and string.find(ua, pattern, 1, true) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function M.match_any(remote_ip, ua, uri, list)
|
||||
if not list then return false end
|
||||
if match_ip(remote_ip, list.ips) then return true end
|
||||
if match_cidr(remote_ip, list.ip_cidrs) then return true end
|
||||
if match_path(uri, list.paths) then return true end
|
||||
if match_path_regex(uri, list.path_regexes) then return true end
|
||||
if match_ua(ua, list.user_agents) then return true end
|
||||
return false
|
||||
end
|
||||
|
||||
function M.has_entries(list)
|
||||
if not list then return false end
|
||||
return (#(list.ips or {}) + #(list.ip_cidrs or {}) + #(list.paths or {}) + #(list.path_regexes or {}) + #(list.user_agents or {})) > 0
|
||||
end
|
||||
|
||||
return M
|
||||
`
|
||||
|
||||
func ManagedPowLuaFiles() []protocol.SupportFile {
|
||||
return []protocol.SupportFile{
|
||||
{Path: "pow/runtime.lua", Content: openRestyPowRuntimeLua},
|
||||
{Path: "pow/check.lua", Content: openRestyPowCheckLua},
|
||||
{Path: "pow/challenge.lua", Content: openRestyPowChallengeLua},
|
||||
{Path: "pow/verify.lua", Content: openRestyPowVerifyLua},
|
||||
{Path: "pow/policy.lua", Content: openRestyPowPolicyLua},
|
||||
}
|
||||
}
|
||||
|
||||
func ManagedPowStaticFiles() ([]protocol.SupportFile, error) {
|
||||
var files []protocol.SupportFile
|
||||
entries, err := powStaticFS.ReadDir("pow_static")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var walk func(dir string) error
|
||||
walk = func(dir string) error {
|
||||
entries, err := powStaticFS.ReadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
fullPath := filepath.Join(dir, entry.Name())
|
||||
if entry.IsDir() {
|
||||
if err := walk(fullPath); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
data, err := powStaticFS.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Convert pow_static/css/xess.css -> pow/static/css/xess.css
|
||||
relPath := strings.TrimPrefix(fullPath, "pow_static/")
|
||||
files = append(files, protocol.SupportFile{
|
||||
Path: "pow/static/" + relPath,
|
||||
Content: string(data),
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, entry := range entries {
|
||||
fullPath := filepath.Join("pow_static", entry.Name())
|
||||
if entry.IsDir() {
|
||||
if err := walk(fullPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
data, err := powStaticFS.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
relPath := strings.TrimPrefix(fullPath, "pow_static/")
|
||||
files = append(files, protocol.SupportFile{
|
||||
Path: "pow/static/" + relPath,
|
||||
Content: string(data),
|
||||
})
|
||||
}
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user