mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 21:56:36 +08:00
Compare commits
112 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a850b0a188 | |||
| fd8148c0db | |||
| edd98f4ff0 | |||
| d8f98e218f | |||
| fefe205158 | |||
| d6e7e2baa2 | |||
| 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 |
@@ -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
|
||||
|
||||
@@ -2,11 +2,6 @@ name: Cleanup prerelease tags
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirm:
|
||||
description: "Type cleanup-prerelease-tags to delete all prerelease releases and tags"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -34,36 +29,66 @@ jobs:
|
||||
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Delete prerelease releases and tags
|
||||
- name: Delete prerelease, dangling, and unbound releases/tags
|
||||
if: steps.version.outputs.should_run == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CONFIRM: ${{ github.event.inputs.confirm }}
|
||||
run: |
|
||||
if [[ "$CONFIRM" != "cleanup-prerelease-tags" ]]; then
|
||||
echo "confirm input must be exactly cleanup-prerelease-tags" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Fetch all tags from remote to ensure full synchronization
|
||||
git fetch --tags --force
|
||||
|
||||
mapfile -t TAGS < <(git tag --list 'v*' | sort -V)
|
||||
DELETED=0
|
||||
# Get all local/remote git tags starting with 'v'
|
||||
mapfile -t GIT_TAGS < <(git tag --list 'v*' | sort -V)
|
||||
|
||||
for TAG in "${TAGS[@]}"; do
|
||||
# Get all GitHub releases (tags associated with releases)
|
||||
mapfile -t GH_RELEASES < <(gh release list --limit 1000 --json tagName --jq '.[].tagName' 2>/dev/null || true)
|
||||
|
||||
# Helper function to check array containment
|
||||
contains_element() {
|
||||
local e match="$1"
|
||||
shift
|
||||
for e; do [[ "$e" == "$match" ]] && return 0; done
|
||||
return 1
|
||||
}
|
||||
|
||||
DELETED_TAGS=0
|
||||
DELETED_RELEASES=0
|
||||
|
||||
echo "=== Phase 1: Checking and cleaning Git tags ==="
|
||||
for TAG in "${GIT_TAGS[@]}"; do
|
||||
if [[ "$TAG" =~ ^v[0-9]+(\.[0-9]+)*$ ]]; then
|
||||
echo "Keep formal release tag: $TAG"
|
||||
continue
|
||||
fi
|
||||
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
echo "Delete prerelease release: $TAG"
|
||||
gh release delete "$TAG" --yes
|
||||
# Formal release tag
|
||||
if ! contains_element "$TAG" "${GH_RELEASES[@]}"; then
|
||||
echo "Delete formal tag not bound to any GitHub release: $TAG"
|
||||
git push origin --delete "refs/tags/$TAG" || true
|
||||
git tag -d "$TAG" || true
|
||||
DELETED_TAGS=$((DELETED_TAGS + 1))
|
||||
else
|
||||
echo "Keep formal release tag (bound to release): $TAG"
|
||||
fi
|
||||
else
|
||||
echo "No GitHub Release found for $TAG"
|
||||
fi
|
||||
# Prerelease tag
|
||||
if contains_element "$TAG" "${GH_RELEASES[@]}"; then
|
||||
echo "Delete prerelease release: $TAG"
|
||||
gh release delete "$TAG" --yes || true
|
||||
DELETED_RELEASES=$((DELETED_RELEASES + 1))
|
||||
fi
|
||||
|
||||
echo "Delete prerelease tag: $TAG"
|
||||
git push origin --delete "refs/tags/$TAG"
|
||||
DELETED=$((DELETED + 1))
|
||||
echo "Delete prerelease tag: $TAG"
|
||||
git push origin --delete "refs/tags/$TAG" || true
|
||||
git tag -d "$TAG" || true
|
||||
DELETED_TAGS=$((DELETED_TAGS + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Deleted $DELETED prerelease tag(s)."
|
||||
echo "=== Phase 2: Checking and cleaning dangling GitHub releases ==="
|
||||
for REL_TAG in "${GH_RELEASES[@]}"; do
|
||||
if ! contains_element "$REL_TAG" "${GIT_TAGS[@]}"; then
|
||||
echo "Delete GitHub release not bound to any Git tag: $REL_TAG"
|
||||
gh release delete "$REL_TAG" --yes || true
|
||||
DELETED_RELEASES=$((DELETED_RELEASES + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo "=== Summary ==="
|
||||
echo "Successfully deleted $DELETED_TAGS tag(s) and $DELETED_RELEASES release(s)."
|
||||
|
||||
@@ -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}"
|
||||
@@ -1,344 +0,0 @@
|
||||
name: Docker image builds
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Image version/tag to publish, for example v1.0.0-beta"
|
||||
required: false
|
||||
type: string
|
||||
push:
|
||||
tags: ["v*"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build (${{ matrix.arch }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
platform: linux/amd64
|
||||
runner: ubuntu-24.04
|
||||
- arch: arm64
|
||||
platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-tags: true
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set image metadata
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)"
|
||||
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
|
||||
|
||||
echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
|
||||
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
elif [[ -n "$INPUT_VERSION" ]]; then
|
||||
VERSION="$INPUT_VERSION"
|
||||
elif [[ -n "$POINTED_TAG" ]]; then
|
||||
VERSION="$POINTED_TAG"
|
||||
else
|
||||
echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log into registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./openflare_server
|
||||
file: ./openflare_server/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
build-args: |
|
||||
VERSION=${{ env.VERSION }}
|
||||
cache-from: type=gha,scope=docker-${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=docker-${{ matrix.arch }}
|
||||
|
||||
- name: Export digest
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
touch "/tmp/digests/${DIGEST#sha256:}"
|
||||
env:
|
||||
DIGEST: ${{ steps.build.outputs.digest }}
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ matrix.arch }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v3
|
||||
with:
|
||||
subject-name: ${{ env.IMAGE }}
|
||||
subject-digest: ${{ steps.build.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
merge:
|
||||
name: Merge multi-arch manifest
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-tags: true
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set image metadata
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)"
|
||||
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
|
||||
|
||||
echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
|
||||
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
elif [[ -n "$INPUT_VERSION" ]]; then
|
||||
VERSION="$INPUT_VERSION"
|
||||
elif [[ -n "$POINTED_TAG" ]]; then
|
||||
VERSION="$POINTED_TAG"
|
||||
else
|
||||
echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log into registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create and push manifest list
|
||||
working-directory: /tmp/digests
|
||||
shell: bash
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
references=()
|
||||
for digest in *; do
|
||||
references+=("${IMAGE}@sha256:${digest}")
|
||||
done
|
||||
|
||||
if [ ${#references[@]} -eq 0 ]; then
|
||||
echo "No digests found in /tmp/digests" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker buildx imagetools create \
|
||||
-t "${IMAGE}:${VERSION}" \
|
||||
-t "${IMAGE}:latest" \
|
||||
"${references[@]}"
|
||||
|
||||
- name: Inspect image
|
||||
run: docker buildx imagetools inspect "${IMAGE}:${VERSION}"
|
||||
|
||||
build-agent:
|
||||
name: Build Agent (${{ matrix.arch }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
platform: linux/amd64
|
||||
runner: ubuntu-24.04
|
||||
- arch: arm64
|
||||
platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-tags: true
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set image metadata
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)"
|
||||
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
|
||||
|
||||
echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}-agent" >> "$GITHUB_ENV"
|
||||
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
elif [[ -n "$INPUT_VERSION" ]]; then
|
||||
VERSION="$INPUT_VERSION"
|
||||
elif [[ -n "$POINTED_TAG" ]]; then
|
||||
VERSION="$POINTED_TAG"
|
||||
else
|
||||
echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log into registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./openflare_agent/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
build-args: |
|
||||
VERSION=${{ env.VERSION }}
|
||||
cache-from: type=gha,scope=docker-agent-${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=docker-agent-${{ matrix.arch }}
|
||||
|
||||
- name: Export digest
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p /tmp/agent-digests
|
||||
touch "/tmp/agent-digests/${DIGEST#sha256:}"
|
||||
env:
|
||||
DIGEST: ${{ steps.build.outputs.digest }}
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: agent-digests-${{ matrix.arch }}
|
||||
path: /tmp/agent-digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v3
|
||||
with:
|
||||
subject-name: ${{ env.IMAGE }}
|
||||
subject-digest: ${{ steps.build.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
merge-agent:
|
||||
name: Merge Agent multi-arch manifest
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build-agent
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-tags: true
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set image metadata
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)"
|
||||
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
|
||||
|
||||
echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}-agent" >> "$GITHUB_ENV"
|
||||
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
elif [[ -n "$INPUT_VERSION" ]]; then
|
||||
VERSION="$INPUT_VERSION"
|
||||
elif [[ -n "$POINTED_TAG" ]]; then
|
||||
VERSION="$POINTED_TAG"
|
||||
else
|
||||
echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/agent-digests
|
||||
pattern: agent-digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log into registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create and push manifest list
|
||||
working-directory: /tmp/agent-digests
|
||||
shell: bash
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
references=()
|
||||
for digest in *; do
|
||||
references+=("${IMAGE}@sha256:${digest}")
|
||||
done
|
||||
|
||||
if [ ${#references[@]} -eq 0 ]; then
|
||||
echo "No digests found in /tmp/agent-digests" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker buildx imagetools create \
|
||||
-t "${IMAGE}:${VERSION}" \
|
||||
-t "${IMAGE}:latest" \
|
||||
"${references[@]}"
|
||||
|
||||
- name: Inspect image
|
||||
run: docker buildx imagetools inspect "${IMAGE}:${VERSION}"
|
||||
@@ -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}"
|
||||
@@ -0,0 +1,187 @@
|
||||
name: Docker image build (Server)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Image version/tag to publish, for example v1.0.0-beta"
|
||||
required: false
|
||||
type: string
|
||||
push:
|
||||
tags: ["v*"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build (${{ matrix.arch }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
platform: linux/amd64
|
||||
runner: ubuntu-24.04
|
||||
- arch: arm64
|
||||
platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-tags: true
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set image metadata
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)"
|
||||
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
|
||||
|
||||
echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
|
||||
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
elif [[ -n "$INPUT_VERSION" ]]; then
|
||||
VERSION="$INPUT_VERSION"
|
||||
elif [[ -n "$POINTED_TAG" ]]; then
|
||||
VERSION="$POINTED_TAG"
|
||||
else
|
||||
echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log into registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ./openflare_server
|
||||
file: ./openflare_server/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
build-args: |
|
||||
VERSION=${{ env.VERSION }}
|
||||
cache-from: type=gha,scope=docker-server-${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,ignore-error=true,timeout=20m,scope=docker-server-${{ matrix.arch }}
|
||||
|
||||
- name: Export digest
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p /tmp/server-digests
|
||||
touch "/tmp/server-digests/${DIGEST#sha256:}"
|
||||
env:
|
||||
DIGEST: ${{ steps.build.outputs.digest }}
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: server-digests-${{ matrix.arch }}
|
||||
path: /tmp/server-digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v3
|
||||
with:
|
||||
subject-name: ${{ env.IMAGE }}
|
||||
subject-digest: ${{ steps.build.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
merge:
|
||||
name: Merge multi-arch manifest
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-tags: true
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set image metadata
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)"
|
||||
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
|
||||
|
||||
echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
|
||||
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
elif [[ -n "$INPUT_VERSION" ]]; then
|
||||
VERSION="$INPUT_VERSION"
|
||||
elif [[ -n "$POINTED_TAG" ]]; then
|
||||
VERSION="$POINTED_TAG"
|
||||
else
|
||||
echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/server-digests
|
||||
pattern: server-digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log into registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create and push manifest list
|
||||
working-directory: /tmp/server-digests
|
||||
shell: bash
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
references=()
|
||||
for digest in *; do
|
||||
references+=("${IMAGE}@sha256:${digest}")
|
||||
done
|
||||
|
||||
if [ ${#references[@]} -eq 0 ]; then
|
||||
echo "No digests found in /tmp/server-digests" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${VERSION}" =~ (alpha|beta|rc) ]]; then
|
||||
FLOATING_TAG="beta"
|
||||
else
|
||||
FLOATING_TAG="latest"
|
||||
fi
|
||||
|
||||
docker buildx imagetools create \
|
||||
-t "${IMAGE}:${VERSION}" \
|
||||
-t "${IMAGE}:${FLOATING_TAG}" \
|
||||
"${references[@]}"
|
||||
|
||||
- name: Inspect image
|
||||
run: docker buildx imagetools inspect "${IMAGE}:${VERSION}"
|
||||
+331
-205
@@ -1,7 +1,7 @@
|
||||
name: Release
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
name: Release
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -11,20 +11,20 @@ on:
|
||||
type: string
|
||||
push:
|
||||
tags: ["v*"]
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_run: ${{ steps.version.outputs.should_run }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_run: ${{ steps.version.outputs.should_run }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve version metadata
|
||||
id: version
|
||||
env:
|
||||
@@ -52,194 +52,320 @@ jobs:
|
||||
fi
|
||||
|
||||
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
|
||||
|
||||
build-frontend:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.should_run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Build Frontend
|
||||
env:
|
||||
CI: ""
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
cd openflare_server/web
|
||||
corepack enable
|
||||
pnpm install --frozen-lockfile
|
||||
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build
|
||||
|
||||
- name: Upload Frontend Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-build
|
||||
path: openflare_server/web/build
|
||||
retention-days: 1
|
||||
|
||||
build-binaries:
|
||||
needs:
|
||||
- prepare
|
||||
- build-frontend
|
||||
if: needs.prepare.outputs.should_run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
asset_name: openflare-server-linux-amd64
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
asset_name: openflare-server-linux-arm64
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
asset_name: openflare-server-darwin-amd64
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
asset_name: openflare-server-darwin-arm64
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
asset_name: openflare-server-windows-amd64.exe
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download Frontend Artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: frontend-build
|
||||
path: openflare_server/web/build
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: openflare_server/go.mod
|
||||
|
||||
- name: Build Server
|
||||
working-directory: openflare_server
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
ASSET_NAME: ${{ matrix.asset_name }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
go mod download
|
||||
mkdir -p ../dist
|
||||
go build -trimpath -ldflags "-s -w -X 'openflare/common.Version=$VERSION'" -o "../dist/$ASSET_NAME" .
|
||||
|
||||
- name: Upload Binary Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: server-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: dist/${{ matrix.asset_name }}
|
||||
retention-days: 1
|
||||
|
||||
build-agent-binaries:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.should_run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
asset_name: openflare-agent-linux-amd64
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
asset_name: openflare-agent-linux-arm64
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
asset_name: openflare-agent-darwin-amd64
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
asset_name: openflare-agent-darwin-arm64
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: openflare_agent/go.mod
|
||||
|
||||
- name: Build Agent
|
||||
working-directory: openflare_agent
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
ASSET_NAME: ${{ matrix.asset_name }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
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
|
||||
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
|
||||
|
||||
build-frontend:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.should_run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Build Frontend
|
||||
env:
|
||||
CI: ""
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
cd openflare_server/web
|
||||
corepack enable
|
||||
pnpm install --frozen-lockfile
|
||||
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build
|
||||
|
||||
- name: Upload Frontend Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-build
|
||||
path: openflare_server/web/build
|
||||
retention-days: 1
|
||||
|
||||
build-binaries:
|
||||
needs:
|
||||
- prepare
|
||||
- build-frontend
|
||||
if: needs.prepare.outputs.should_run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
asset_name: openflare-server-linux-amd64
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
asset_name: openflare-server-linux-arm64
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
asset_name: openflare-server-darwin-amd64
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
asset_name: openflare-server-darwin-arm64
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
asset_name: openflare-server-windows-amd64.exe
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download Frontend Artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: frontend-build
|
||||
path: openflare_server/web/build
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: openflare_server/go.mod
|
||||
|
||||
- name: Build Server
|
||||
working-directory: openflare_server
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
ASSET_NAME: ${{ matrix.asset_name }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
go mod download
|
||||
mkdir -p ../dist
|
||||
go build -trimpath -ldflags "-s -w -X 'openflare/common.Version=$VERSION'" -o "../dist/$ASSET_NAME" .
|
||||
|
||||
- name: Upload Binary Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: server-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: dist/${{ matrix.asset_name }}
|
||||
retention-days: 1
|
||||
|
||||
build-agent-binaries:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.should_run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
asset_name: openflare-agent-linux-amd64
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
asset_name: openflare-agent-linux-arm64
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
asset_name: openflare-agent-darwin-amd64
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
asset_name: openflare-agent-darwin-arm64
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: openflare_agent/go.mod
|
||||
|
||||
- name: Build Agent
|
||||
working-directory: openflare_agent
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
ASSET_NAME: ${{ matrix.asset_name }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
go mod download
|
||||
mkdir -p ../dist
|
||||
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 }}
|
||||
|
||||
- name: Upload Agent Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: agent-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: |
|
||||
dist/${{ matrix.asset_name }}
|
||||
dist/${{ matrix.asset_name }}.sha256
|
||||
retention-days: 1
|
||||
|
||||
release:
|
||||
needs:
|
||||
- prepare
|
||||
- build-binaries
|
||||
- build-agent-binaries
|
||||
if: needs.prepare.outputs.should_run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download Server Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: "server-*"
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download Agent Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: "agent-*"
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ needs.prepare.outputs.version }}
|
||||
name: ${{ needs.prepare.outputs.version }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
files: dist/*
|
||||
draft: false
|
||||
prerelease: ${{ needs.prepare.outputs.is_prerelease == 'true' }}
|
||||
generate_release_notes: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
retention-days: 1
|
||||
|
||||
build-relay-binaries:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.should_run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
asset_name: openflare-relay-linux-amd64
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
asset_name: openflare-relay-linux-arm64
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
asset_name: openflare-relay-darwin-amd64
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
asset_name: openflare-relay-darwin-arm64
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: openflare_relay/go.mod
|
||||
|
||||
- name: Build Relay
|
||||
working-directory: openflare_relay
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
ASSET_NAME: ${{ matrix.asset_name }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
go mod download
|
||||
mkdir -p ../dist
|
||||
go build -trimpath -ldflags "-s -w -X 'openflare-relay/internal/config.Version=$VERSION'" -o "../dist/$ASSET_NAME" ./cmd/relay
|
||||
(cd ../dist && sha256sum "$ASSET_NAME" > "$ASSET_NAME.sha256")
|
||||
|
||||
- name: Upload Relay Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: relay-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: |
|
||||
dist/${{ matrix.asset_name }}
|
||||
dist/${{ matrix.asset_name }}.sha256
|
||||
retention-days: 1
|
||||
|
||||
build-flared-binaries:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.should_run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
asset_name: openflared-linux-amd64
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
asset_name: openflared-linux-arm64
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
asset_name: openflared-darwin-amd64
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
asset_name: openflared-darwin-arm64
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: openflared/go.mod
|
||||
|
||||
- name: Build Flared
|
||||
working-directory: openflared
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
ASSET_NAME: ${{ matrix.asset_name }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
go mod download
|
||||
mkdir -p ../dist
|
||||
go build -trimpath -ldflags "-s -w -X 'openflare-flared/internal/config.Version=$VERSION'" -o "../dist/$ASSET_NAME" ./cmd/flared
|
||||
(cd ../dist && sha256sum "$ASSET_NAME" > "$ASSET_NAME.sha256")
|
||||
|
||||
- name: Upload Flared Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: flared-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: |
|
||||
dist/${{ matrix.asset_name }}
|
||||
dist/${{ matrix.asset_name }}.sha256
|
||||
retention-days: 1
|
||||
|
||||
release:
|
||||
needs:
|
||||
- prepare
|
||||
- build-binaries
|
||||
- build-agent-binaries
|
||||
- build-relay-binaries
|
||||
- build-flared-binaries
|
||||
if: needs.prepare.outputs.should_run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download Server Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: "server-*"
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download Agent Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: "agent-*"
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download Relay Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: "relay-*"
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download Flared Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: "flared-*"
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ needs.prepare.outputs.version }}
|
||||
name: ${{ needs.prepare.outputs.version }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
files: dist/*
|
||||
draft: false
|
||||
prerelease: ${{ needs.prepare.outputs.is_prerelease == 'true' }}
|
||||
generate_release_notes: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
+2
-1
@@ -46,6 +46,7 @@ go.work.sum
|
||||
.codex-cache
|
||||
/.gomodcache/
|
||||
*.mmdb
|
||||
!openflare_agent/internal/geoipdata/GeoLite2-Country.mmdb
|
||||
|
||||
*-source
|
||||
*-source.*
|
||||
*-source.*
|
||||
|
||||
@@ -1,51 +1,87 @@
|
||||
# AGENTS.md
|
||||
|
||||
本文件是 OpenFlare 的 AI 接手入口,不承载详细设计、规范和计划。接手项目时,先按顺序阅读以下 VitePress 文档源文件:
|
||||
本文件是 OpenFlare 的 AI 接手入口,不承载详细设计、规范和计划。接手项目时,请根据以下分层文档指引进行阅读与开发:
|
||||
|
||||
1. [docs/design/index.md](./docs/design/index.md)
|
||||
作用:理解当前 MVP 的产品范围、系统边界、核心对象和长期约束。
|
||||
## 1. 核心必读文档(Level 3 & Level 4)- 必须阅读 ⚠️
|
||||
|
||||
2. [docs/design/architecture.md](./docs/design/architecture.md)
|
||||
作用:理解 Server、Agent、OpenResty 与前端的职责边界。
|
||||
为了理解 OpenFlare 的设计理念、产品边界、核心机制以及代码编写的工程约束,**AI 在接手项目时必须首先且完整阅读以下文档**:
|
||||
|
||||
3. [docs/design/release-model.md](./docs/design/release-model.md)
|
||||
作用:理解配置发布、激活、回滚与 Agent 应用模型。
|
||||
### Level 3: 面向贡献者的参阅文档 (Contributor References)
|
||||
* **[docs/design/index.md](./docs/design/index.md)**
|
||||
*作用:理解当前 MVP 的产品范围、系统边界、核心对象和长期约束。*
|
||||
* **[docs/design/architecture.md](./docs/design/architecture.md)**
|
||||
*作用:理解 Server、Agent、OpenResty 与前端的职责边界与网络拓扑。*
|
||||
* **[docs/design/release-model.md](./docs/design/release-model.md)**
|
||||
*作用:理解配置发布、激活、回滚与 Agent 节点配置应用的模型。*
|
||||
* **[docs/design/development.md](./docs/design/development.md)**
|
||||
*作用:了解如何搭建本地开发环境,运行后端 Server、Agent 和前端开发服务器,以及运行测试与构建的命令。*
|
||||
* **[docs/design/repository.md](./docs/design/repository.md)**
|
||||
*作用:熟悉仓库的整体物理结构和各子目录的职责。*
|
||||
|
||||
4. [docs/design/development.md](./docs/design/development.md)
|
||||
作用:理解当前开发规范、阶段原则、分层约束、数据模型边界、API 约定、Agent 约束、前端规范与测试要求。
|
||||
### Level 4: 面向 AI 的开发指导规范 (AI Guidelines)
|
||||
* **[docs/guildline/development-constraints.md](./docs/guildline/development-constraints.md)**
|
||||
*作用:掌握核心后端/Agent/前端分层约束、数据模型规范、数据库迁移升级协议、API 与鉴权设计准则。*
|
||||
* **[docs/guildline/Guidelines.md](./docs/guildline/Guidelines.md)**
|
||||
*作用:通用的 Go 后端开发与高质量编码准则,包括架构、并发、错误处理、安全及工作流程。*
|
||||
* **[docs/guildline/Project.md](./docs/guildline/Project.md)**
|
||||
*作用:针对 OpenFlare 后端特定的控制器参数解析、响应处理、纯净工具类与数据库逻辑完全隔离、Go 泛型切片去重及 JSON 序列化避坑细则。*
|
||||
|
||||
5. [docs/guide/deployment.md](./docs/guide/deployment.md)
|
||||
作用:理解当前部署方式、Agent 接入、升级、卸载和联调步骤。
|
||||
---
|
||||
|
||||
6. [docs/reference/configuration.md](./docs/reference/configuration.md)
|
||||
作用:理解系统启动时支持的环境变量、命令行参数、运行时配置项和 Agent 配置字段。
|
||||
## 2. 按需查阅文档(Level 2)- 根据需求阅读 💡
|
||||
|
||||
如任务涉及用户文档、贡献者入口或排障体验,还应阅读:
|
||||
当开发任务涉及具体的系统部署、升级、接口联调或配置字段查阅时,**AI 应当根据需求阅读相应的参考手册**:
|
||||
|
||||
* [docs/guide/quick-start.md](./docs/guide/quick-start.md):理解新用户从 0 到运行的最短路径。
|
||||
* [docs/guide/usage.md](./docs/guide/usage.md):理解网站配置、证书、发布、回滚和观测的基础用法。
|
||||
* [docs/guide/development.md](./docs/guide/development.md):理解本地开发、测试和构建命令。
|
||||
* [docs/guide/troubleshooting.md](./docs/guide/troubleshooting.md):理解常见失败症状与排查路径。
|
||||
### Level 2: 面对高级用户/开发者的参阅文档 (Reference Manuals)
|
||||
* **[docs/reference/configuration.md](./docs/reference/configuration.md)**
|
||||
*作用:系统启动时支持的所有环境变量、命令行参数、运行时 Option 选项和 Agent 配置文件字段。*
|
||||
* **[docs/reference/cli.md](./docs/reference/cli.md)**
|
||||
*作用:Server 与 Agent 可用的命令行参数、安装/卸载脚本参数等参考。*
|
||||
* **[docs/reference/api.md](./docs/reference/api.md)**
|
||||
*作用:管理端 API 与 Agent API 的响应结构、路径和详细鉴权约定。*
|
||||
* **[docs/reference/deployment.md](./docs/reference/deployment.md)**
|
||||
*作用:理解 Server 和 Agent 的单机、Docker 部署配置,以及 Agent 接入、升级、卸载和联调步骤。*
|
||||
* **[docs/reference/server.md](./docs/reference/server.md)**
|
||||
*作用:如何配置系统配置、服务环境变量并正确启动 Server 服务。*
|
||||
* **[docs/reference/agent.md](./docs/reference/agent.md)**
|
||||
*作用:理解 Agent 接入的 discovery/agent 令牌鉴权机制、本地配置文件及 Docker 部署参数。*
|
||||
* **[docs/reference/upgrade.md](./docs/reference/upgrade.md)**
|
||||
*作用:Server 及各代理节点 Agent 的升级步骤与维护策略。*
|
||||
|
||||
线上文档入口:https://open-flare.pages.dev
|
||||
---
|
||||
|
||||
## 3. 新手与业务教程(Level 1)- 体验与排障参考 📘
|
||||
|
||||
如果任务涉及优化最终用户体验、丰富业务能力或排查常见故障,可参阅面向普通用户的指南:
|
||||
|
||||
### Level 1: 面向新手用户的教程文档 (Novice Tutorials)
|
||||
* **[docs/guide/quick-start.md](./docs/guide/quick-start.md)**:五分钟内基于 Docker Compose 快速跑起 Server 和首个 Agent 节点的完整闭环。
|
||||
* **[docs/guide/usage.md](./docs/guide/usage.md)**:反向代理网站、源站、证书托管、配置发布与回滚的常规界面操作与观测功能使用指南。
|
||||
* **[docs/guide/sso.md](./docs/guide/sso.md)**:系统如何配置 GitHub OAuth 及标准 OIDC 第三方登录,以及绑定本地账户的流程。
|
||||
* **[docs/guide/first-site.md](./docs/guide/first-site.md)**:从零开始配置、发布并验证第一个代理网站的完整步骤。
|
||||
* **[docs/guide/troubleshooting.md](./docs/guide/troubleshooting.md)**:常见数据库迁移、节点离线、OpenResty 校验失败、SSL 证书失效等故障的表现症状及标准排障路径。
|
||||
|
||||
---
|
||||
|
||||
## 执行要求
|
||||
|
||||
* 如果实现内容超出 [产品边界](./docs/design/index.md),先修改设计文档,再继续编码。
|
||||
* 如果实现方式违反 [开发约束](./docs/design/development.md),应优先调整方案,而不是绕过规范。
|
||||
* 如果需求与当前阶段原则冲突,优先遵守 [开发约束](./docs/design/development.md) 中的变更准入与验收标准。
|
||||
* 如果任务涉及前端改造或管理端 UI,必须同时遵守 [开发约束](./docs/design/development.md) 中的前端规范。
|
||||
* 如果实现方式违反 [开发约束](./docs/guildline/development-constraints.md),应优先调整方案,而不是绕过规范。
|
||||
* 如果实现方式涉及后端代码逻辑,必须严格遵循 [docs/guildline/](./docs/guildline/) 下的所有开发准则。
|
||||
* 如果需求与当前阶段原则冲突,优先遵守 [开发约束](./docs/guildline/development-constraints.md) 中的变更准入与验收标准。
|
||||
* 如果任务涉及前端改造或管理端 UI,必须同时遵守 [开发约束](./docs/guildline/development-constraints.md) 中的前端规范。
|
||||
|
||||
## 文档维护要求
|
||||
|
||||
当以下内容发生变化时,应同步更新对应 VitePress 页面:
|
||||
当以下内容发生变化时,应同步更新对应中文文档,不要同步英文文档:
|
||||
|
||||
* 产品范围或系统边界变化:更新 `docs/design/index.md`
|
||||
* 系统结构、模块职责变化:更新 `docs/design/architecture.md`
|
||||
* 发布、同步、回滚模型变化:更新 `docs/design/release-model.md`
|
||||
* 开发约束、代码规范、接口约定、阶段原则、测试基线变化:更新 `docs/design/development.md`
|
||||
* 产品启动、部署、升级、联调方式变化:更新 `docs/guide/quick-start.md`、`docs/guide/deployment.md` 和 `README.md`
|
||||
* 业务分层、数据模型边界、接口约定、阶段原则、测试基线变化:更新 `docs/guildline/development-constraints.md`
|
||||
* 后端开发规范、代码质量要求、重构模式、去重逻辑与避坑指南变化:更新 `docs/guildline/` 下的对应开发准则文件
|
||||
* 产品启动、部署、升级、联调方式变化:更新 `docs/guide/quick-start.md`、`docs/reference/deployment.md` 和 `README.md`
|
||||
* 用户操作路径、常见场景变化:更新 `docs/guide/usage.md`
|
||||
* 本地开发、测试、构建方式变化:更新 `docs/guide/development.md`
|
||||
* 本地开发、测试、构建方式变化:更新 `docs/design/development.md`
|
||||
* 常见故障、排查路径变化:更新 `docs/guide/troubleshooting.md`
|
||||
* 环境变量、命令行参数、运行时配置、Agent 配置变化:更新 `docs/reference/configuration.md`
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
# OpenFlare
|
||||
|
||||
轻量、自托管的 OpenResty 控制面,用于管理反向代理规则、配置发布、节点同步、TLS 证书与基础可观测能力。
|
||||
**[📖 English](./README.md) | [中文](./README.zh-CN.md)**
|
||||
|
||||
A lightweight, self-hosted control plane for OpenResty that manages reverse proxy rules, configuration releases, node synchronization, TLS certificates, and observability.
|
||||
|
||||
</div>
|
||||
|
||||
<p align="center
|
||||
<p align="center">
|
||||
<a href="https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/Rain-kl/OpenFlare?color=brightgreen" alt="license">
|
||||
</a>
|
||||
@@ -19,31 +21,34 @@
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
> 使用 `root` 用户初次登录系统后,务必修改默认密码 `123456`。
|
||||
> After the first login with the `root` user, you **must** change the default password `123456`.
|
||||
>
|
||||
> This BETA version is a temporary product in the development and testing phase. It may contain unknown issues and should not be used in production environments.
|
||||
|
||||
## 文档
|
||||
## Documentation
|
||||
|
||||
**https://open-flare.pages.dev**
|
||||
|
||||
常用入口:
|
||||
Quick links:
|
||||
|
||||
* [快速开始](https://open-flare.pages.dev/guide/quick-start)
|
||||
* [部署说明](https://open-flare.pages.dev/guide/deployment)
|
||||
* [配置项参考](https://open-flare.pages.dev/reference/configuration)
|
||||
* [系统设计](https://open-flare.pages.dev/design/)
|
||||
* [Quick Start](https://open-flare.pages.dev/guide/quick-start)
|
||||
* [Deployment Guide](https://open-flare.pages.dev/reference/deployment)
|
||||
* [Configuration Reference](https://open-flare.pages.dev/reference/configuration)
|
||||
* [System Design](https://open-flare.pages.dev/design/)
|
||||
|
||||
## 核心能力
|
||||
## Core Features
|
||||
|
||||
* 反向代理网站配置与多域名绑定
|
||||
* 配置预览、发布、激活与历史回滚
|
||||
* Agent 自动注册、心跳、同步、校验、reload 与失败回滚
|
||||
* OpenResty 主配置、性能参数、缓存参数与 Lua 资源托管
|
||||
* TLS 证书、域名资产、节点凭证与版本状态管理
|
||||
* 请求聚合、访问分析、资源快照、健康事件与节点详情
|
||||
* **Reverse Proxy Configuration**: Website management and multi-domain binding
|
||||
* **Configuration Lifecycle**: Preview, release, activation, and historical rollback
|
||||
* **Agent Management**: Auto-registration, heartbeat, sync, validation, reload, and failure rollback
|
||||
* **OpenResty Administration**: Main configuration, performance tuning, caching, and Lua resource hosting
|
||||
* **WAF Protection**: Global and custom rule groups with IP/CIDR and geographic blacklist/whitelist
|
||||
* **Certificate Management**: TLS certificates, domain assets, node credentials, and version control
|
||||
* **Observability**: Request aggregation, access analytics, resource snapshots, health events, and node metrics
|
||||
|
||||
## 快速开始
|
||||
## Quick Start
|
||||
|
||||
### 1. 启动 Server
|
||||
### 1. Launch Server
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -84,24 +89,24 @@ volumes:
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
访问地址:`http://localhost:3000`
|
||||
Access at: `http://localhost:3000`
|
||||
|
||||
默认账号:
|
||||
Default credentials:
|
||||
|
||||
* 用户名:`root`
|
||||
* 密码:`123456`
|
||||
* Username: `root`
|
||||
* Password: `123456`
|
||||
|
||||
### 2. 安装 Agent
|
||||
### 2. Install Agent
|
||||
|
||||
安装 Agent 前请先在节点上安装 OpenResty,或改用内置 OpenResty 的 Agent Docker 镜像。
|
||||
Before installing an Agent, install OpenResty on the target node, or use the Docker image with OpenResty built-in.
|
||||
|
||||
你可以在控制面板的节点管理->详情->节点信息->节点标识与部署复制安装命令,或直接使用下面的脚本:
|
||||
You can copy the installation command from the Dashboard → Node Management → Details → Node Info, or use the script below:
|
||||
|
||||
#### Docker 部署
|
||||
|
||||
Docker 部署可直接运行 Agent 镜像:
|
||||
#### Docker Deployment
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/rain-kl/openflare-agent:latest
|
||||
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 \
|
||||
@@ -109,9 +114,9 @@ docker run -d --name openflare-agent --restart unless-stopped \
|
||||
ghcr.io/rain-kl/openflare-agent:latest
|
||||
```
|
||||
|
||||
#### 本地部署
|
||||
#### Local Installation
|
||||
|
||||
使用 `discovery_token` 接入:
|
||||
Using `discovery_token`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
|
||||
@@ -119,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 -- \
|
||||
@@ -127,62 +132,62 @@ curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/inst
|
||||
--agent-token YOUR_AGENT_TOKEN
|
||||
```
|
||||
|
||||
安装脚本默认写入 `/opt/openflare-agent`,创建 `openflare-agent.service`,自动查找 `openresty`,并可重复执行以重装或升级 Agent。
|
||||
The installation script defaults to `/opt/openflare-agent`, creates a `openflare-agent.service`, auto-detects `openresty`, and supports re-execution for upgrades.
|
||||
|
||||
### 3. 卸载 Agent
|
||||
### 3. Uninstall Agent
|
||||
|
||||
如需彻底卸载 Agent 并清空本地数据,可执行:
|
||||
To completely uninstall the Agent and clean local data:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/uninstall-agent.sh | bash
|
||||
```
|
||||
|
||||
卸载脚本会先停止并移除 `openflare-agent.service`、删除整个 `/opt/openflare-agent` 目录,不会删除本机 OpenResty。
|
||||
The uninstall script stops and removes the `openflare-agent.service`, deletes the `/opt/openflare-agent` directory, and does not remove OpenResty.
|
||||
|
||||
### 4. 发布第一份配置
|
||||
### 4. Deploy Your First Configuration
|
||||
|
||||
1. 登录管理端并新增反代规则
|
||||
2. 在发布前查看预览或变更摘要
|
||||
3. 激活新版本
|
||||
4. Agent 通过 WebSocket 通知或后续 heartbeat 拉取并应用配置
|
||||
1. Log in to the dashboard and create a reverse proxy rule
|
||||
2. Preview changes or view the changelog before publishing
|
||||
3. Activate the new version
|
||||
4. Agents receive notifications via WebSocket or pull configuration on next heartbeat
|
||||
|
||||
版本号格式固定为 `YYYYMMDD-NNN`,历史版本不可变,回滚通过重新激活旧版本完成。
|
||||
Versions are immutable with format `YYYYMMDD-NNN`. Rollback is performed by reactivating a previous version.
|
||||
|
||||
## UI Preview
|
||||
|
||||
## 界面预览
|
||||
|
||||
### 仪表盘总览
|
||||
### Dashboard Overview
|
||||
|
||||

|
||||
|
||||
### 节点详情
|
||||
### Node Details
|
||||
|
||||

|
||||
|
||||
### 配置新增
|
||||
### Proxy Configuration
|
||||
|
||||

|
||||
|
||||
## 管理端与接口
|
||||
## Management Panel & API
|
||||
|
||||
管理端当前覆盖:
|
||||
The management panel includes:
|
||||
|
||||
* 反代规则
|
||||
* 配置版本
|
||||
* 节点管理
|
||||
* 应用记录
|
||||
* TLS 证书
|
||||
* 域名管理
|
||||
* 用户管理
|
||||
* 设置
|
||||
* 版本更新
|
||||
* POW 规则
|
||||
* Reverse Proxy Rules
|
||||
* Configuration Versions
|
||||
* Node Management
|
||||
* Application History
|
||||
* TLS Certificates
|
||||
* Domain Management
|
||||
* WAF Rule Groups
|
||||
* User Management
|
||||
* Settings
|
||||
* Version Updates
|
||||
* POW Rules
|
||||
|
||||
登录管理端后,可访问 Swagger UI:`/swagger/index.html`
|
||||
After logging in to the dashboard, access Swagger UI at: `/swagger/index.html`
|
||||
|
||||
## 开源协议
|
||||
## License
|
||||
|
||||
本项目采用 [Apache License 2.0](./LICENSE) 开源。
|
||||
This project is licensed under [Apache License 2.0](./LICENSE).
|
||||
|
||||
## Star History
|
||||
|
||||
|
||||
+201
@@ -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>
|
||||
+32
-3
@@ -1,5 +1,5 @@
|
||||
services:
|
||||
openflare-agent:
|
||||
agent:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: openflare_agent/Dockerfile
|
||||
@@ -16,8 +16,37 @@ services:
|
||||
|
||||
environment:
|
||||
OPENFLARE_SERVER_URL: "http://host.docker.internal:3000"
|
||||
OPENFLARE_AGENT_TOKEN: "373956188ddead1df6dd7c86cd330b73"
|
||||
OPENFLARE_AGENT_TOKEN: "07800f31d3f181e65d18dca1407d821c"
|
||||
LOG_LEVEL: "debug"
|
||||
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
relay:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: openflare_relay/Dockerfile
|
||||
container_name: openflare-relay
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
OPENFLARE_SERVER_URL: http://host.docker.internal:3000
|
||||
OPENFLARE_DISCOVERY_TOKEN: 85464eeb72c49abc430569d6b9c77f78
|
||||
LOG_LEVEL: "debug"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
|
||||
flared:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: openflared/Dockerfile
|
||||
container_name: openflare-flared
|
||||
network_mode: "host"
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./openflared/data/:/app/data
|
||||
environment:
|
||||
OPENFLARE_SERVER_URL: "http://host.docker.internal:3000"
|
||||
OPENFLARE_TUNNEL_TOKEN: deb0783ac1e264a9d86440169aca0f09
|
||||
|
||||
|
||||
+9
-8
@@ -69,13 +69,9 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
||||
{ text: '概览', link: '' },
|
||||
{ text: '快速开始', link: 'quick-start' },
|
||||
{ text: '基础使用', link: 'usage' },
|
||||
{ text: '部署说明', link: 'deployment' },
|
||||
{ text: 'WAF 自动 IP 组语法', link: 'waf-ip-group-expr' },
|
||||
{ text: 'SSO 登录配置', link: 'sso' },
|
||||
{ text: '启动 Server', link: 'server' },
|
||||
{ text: '接入 Agent', link: 'agent' },
|
||||
{ text: '发布第一份配置', link: 'first-site' },
|
||||
{ text: '升级与维护', link: 'upgrade' },
|
||||
{ text: '本地开发', link: 'development' },
|
||||
{ text: '故障排查', link: 'troubleshooting' }
|
||||
]
|
||||
}
|
||||
@@ -88,10 +84,14 @@ function sidebarReference(): DefaultTheme.SidebarItem[] {
|
||||
text: '参考',
|
||||
items: [
|
||||
{ text: '概览', link: '' },
|
||||
{ text: '系统架构', link: '../design/architecture' },
|
||||
{ text: '启动 Server', link: 'server' },
|
||||
{ text: '接入 Agent', link: 'agent' },
|
||||
{ text: '部署说明', link: 'deployment' },
|
||||
{ text: '升级与维护', link: 'upgrade' },
|
||||
{ text: '配置项', link: 'configuration' },
|
||||
{ text: '命令与脚本', link: 'cli' },
|
||||
{ text: 'API 约定', link: 'api' },
|
||||
{ text: '仓库结构', link: 'repository' }
|
||||
{ text: 'API 约定', link: 'api' }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -105,7 +105,8 @@ function sidebarDesign(): DefaultTheme.SidebarItem[] {
|
||||
{ text: '产品边界', link: '' },
|
||||
{ text: '系统架构', link: 'architecture' },
|
||||
{ text: '发布模型', link: 'release-model' },
|
||||
{ text: '开发约束', link: 'development' }
|
||||
{ text: '本地开发', link: 'development' },
|
||||
{ text: '仓库结构', link: 'repository' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
+106
-22
@@ -2,7 +2,9 @@
|
||||
|
||||
你会学到:OpenFlare 的整体架构、Server、Agent、OpenResty 与管理端前端的职责边界,以及一次配置发布从管理端到节点生效的请求流。
|
||||
|
||||
OpenFlare 由 Server、Agent、节点本地 OpenResty 和管理端前端组成。Server 是控制面,Agent 是节点侧唯一受控落地入口,OpenResty 是实际数据面。
|
||||
OpenFlare 由 Server、Agent、节点本地 OpenResty 和管理端前端组成。Server 是控制面,Agent 是节点侧唯一受控落地入口,OpenResty 是实际数据面。内网穿透场景中,Relay(frps 管理器)和 OpenFlared(frpc 管理器)扩展了数据面流量路径。
|
||||
|
||||
### 标准反代流量路径
|
||||
|
||||
```text
|
||||
Browser
|
||||
@@ -24,14 +26,38 @@ OpenResty binary
|
||||
Origin
|
||||
```
|
||||
|
||||
### 内网穿透流量路径
|
||||
|
||||
```text
|
||||
Browser
|
||||
|
|
||||
| HTTPS request
|
||||
v
|
||||
OpenResty (Agent, TLS/WAF) <-- TunnelRelay 节点
|
||||
|
|
||||
| proxy_pass http://localhost:vhost_port (Host header preserved)
|
||||
v
|
||||
OpenFlareRelay (frps) <-- TunnelRelay 节点,与 Agent 同机部署
|
||||
|
|
||||
| frp tunnel protocol (HTTP Vhost routing by Host header)
|
||||
v
|
||||
OpenFlared (frpc) <-- 内网服务器
|
||||
|
|
||||
| HTTP/HTTPS forward
|
||||
v
|
||||
Internal Service (192.168.x.x)
|
||||
```
|
||||
|
||||
## 组件职责
|
||||
|
||||
| 组件 | 职责 |
|
||||
| --- | --- |
|
||||
| Server | 管理端 UI、管理 API、Agent API、配置渲染、版本发布、数据存储与聚合查询 |
|
||||
| Agent | 注册、心跳、同步、写入文件、校验、reload、失败回滚、自更新与轻量采集 |
|
||||
| OpenResty | 接收真实流量,按 OpenFlare 渲染的配置执行反向代理 |
|
||||
| Frontend | 管理网站配置、源站、证书、节点、版本、用户、设置与观测页面 |
|
||||
| 组件 | 职责 |
|
||||
| --------------- | ---------------------------------------------------------------------- |
|
||||
| Server | 管理端 UI、管理 API、Agent/Relay/Client API、配置渲染、版本发布、数据存储与聚合查询 |
|
||||
| Agent | 注册、心跳、同步、写入文件、校验、reload、失败回滚、自更新与轻量采集 |
|
||||
| OpenResty | 接收真实流量,按 OpenFlare 渲染的配置执行 WAF、PoW、认证与反向代理 |
|
||||
| OpenFlareRelay | 管理 frps 进程生命周期,提供隧道中继服务,通过心跳接收 frps 配置 |
|
||||
| OpenFlared | 管理 frpc 进程(可多个),连接 Relay 中继,将流量转发到内网服务 |
|
||||
| Frontend | 管理网站配置、WAF、源站、证书、节点、Tunnel、版本、用户、设置与观测页面 |
|
||||
|
||||
## Server
|
||||
|
||||
@@ -54,20 +80,28 @@ Server 不直接 SSH 到节点,也不在线修改节点文件。它只保存
|
||||
* 周期性 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 App Router。
|
||||
* Next.js 15 App Router。
|
||||
* React 19。
|
||||
* TypeScript。
|
||||
* Tailwind CSS。
|
||||
* TanStack Query 管理服务端状态。
|
||||
|
||||
前端静态导出后由 Go Server 托管。所有 API 请求应统一经过 `lib/api/`,并处理 `success/message/data` 响应结构。
|
||||
前端采用静态导出模式(`output: 'export'`),导出后由 Go Server 通过 `embed.FS` 托管。所有 API 请求应统一经过 `lib/api/`,并处理 `success/message/data` 响应结构。
|
||||
|
||||
Server 集成以下安全特性:
|
||||
* CORS 中间件:跨域请求保护。
|
||||
* 速率限制:全局与关键接口限流。
|
||||
* 会话管理:基于 Cookie/Redis 的会话存储。
|
||||
|
||||
## 数据与请求流
|
||||
|
||||
@@ -82,23 +116,63 @@ Browser -> Frontend -> /api/* -> controller -> service -> model -> database
|
||||
### Agent 同步流
|
||||
|
||||
```text
|
||||
Agent heartbeat -> Server 返回激活版本摘要
|
||||
Agent HTTP heartbeat -> Server 返回激活版本摘要
|
||||
Agent 发现新版本 -> 拉取配置详情
|
||||
Agent 写入主配置 / 路由配置 / 证书 / Lua 资源
|
||||
Agent 写入主配置 / 路由配置 / 证书 / Lua 资源 / WAF 运行时配置
|
||||
Agent 执行 OpenResty 校验与 reload
|
||||
Agent 上报应用结果
|
||||
```
|
||||
|
||||
默认启用 WS 连接升级时,Agent 会先通过 HTTP heartbeat 获取设置,随后尝试连接 Agent WebSocket。WS 成功后,周期性状态上报改由 WS 承载;Server 发布或激活版本后会向已连接 Agent 广播激活版本摘要,使 Agent 立即进入既有同步流程。WS 断开或建立失败时,Agent 自动退回 HTTP heartbeat。
|
||||
### Relay 同步流
|
||||
|
||||
Relay(OpenFlareRelay 进程)运行在 TunnelRelay 节点上,与 Agent 共享同一 `agent_token`:
|
||||
|
||||
```text
|
||||
Relay HTTP heartbeat -> Server 返回 frps 基础配置 (bindPort, vhostHTTPPort, auth_token)
|
||||
Relay 生成 frps.toml 并启动或更新 frps 进程
|
||||
Relay 定期上报 frps 健康状态与连接统计
|
||||
Relay 尝试升级 WebSocket 连接以支持实时配置推送
|
||||
```
|
||||
|
||||
frps 配置相对静态(端口、认证 Token),通过心跳下发,**不纳入版本化发布流**。Relay 需要监听 frps 进程异常并自动恢复。认证方式:`X-Agent-Token` + API 路径前缀 `/api/relay/*`,Server 通过 `node_type = tunnel_relay` 区分。
|
||||
|
||||
### OpenFlared 同步流
|
||||
|
||||
OpenFlared(客户端)运行在内网服务器,使用独立的 `tunnel_token` 认证:
|
||||
|
||||
```text
|
||||
Client HTTP heartbeat -> Server 返回 tunnel 配置版本摘要 (version, checksum)
|
||||
Client 发现新版本 -> 拉取完整 tunnel 路由配置 (relay 列表 + frpc proxy 定义)
|
||||
Client 为每个 Relay 生成独立的 frpc.toml 配置文件
|
||||
Client 为新 Relay 启动 frpc 进程,或为已有 Relay 执行热重载 (frpc reload)
|
||||
Client 上报应用结果 (成功/失败原因)
|
||||
```
|
||||
|
||||
OpenFlared 通过 `/api/flared/*` 端点与 Server 通信,认证使用 `X-Tunnel-Token`。Tunnel 路由配置随发布流程版本化同步,所有配置变更通过单一版本号关联并一致性发布到 Agent 和 Client。
|
||||
|
||||
**WebSocket 升级流程**(可选,通过 `AgentWebsocketUpgradeEnabled` 选项控制):
|
||||
|
||||
当启用 WebSocket 升级时:
|
||||
1. Agent 通过 HTTP heartbeat 获取运行配置与设置。
|
||||
2. Agent 尝试升级连接到 `GET /api/agent/ws`(WebSocket)。
|
||||
3. WS 连接成功后,周期性状态上报和实时消息由 WebSocket 承载,降低延迟。
|
||||
4. Server 发布或激活版本后,可向已连接 Agent 立即广播激活版本摘要,使 Agent 立即进入同步流程。
|
||||
5. 若 WebSocket 断开或建立失败,Agent 自动降级回 HTTP heartbeat,保证可用性。
|
||||
|
||||
通过 `OpenRestyWebsocketEnabled` 选项,可在 OpenResty 层面启用或禁用 WebSocket 反向代理支持。
|
||||
|
||||
### 反向代理流
|
||||
|
||||
```text
|
||||
Client -> OpenResty server block -> named upstream -> Origin
|
||||
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 数据库、请求日志或远程订阅源。
|
||||
|
||||
## 核心对象
|
||||
|
||||
当前有效实体包括:
|
||||
@@ -107,6 +181,7 @@ Client -> OpenResty server block -> named upstream -> Origin
|
||||
* `origins`
|
||||
* `config_versions`
|
||||
* `nodes`
|
||||
* `tunnels`
|
||||
* `auth_sources`
|
||||
* `external_accounts`
|
||||
* `node_system_profiles`
|
||||
@@ -118,16 +193,25 @@ Client -> OpenResty server block -> named upstream -> Origin
|
||||
* `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 权限,也不暴露远程命令入口 |
|
||||
| 全局单激活版本 | 降低 MVP 复杂度,保证所有节点默认一致 |
|
||||
| 网站配置聚合多域名 | 支持一个业务站点共享站点级策略,同时允许按域名绑定证书 |
|
||||
| 观测数据服务端聚合 | 避免前端临时统计造成口径不一致 |
|
||||
| 决策 | 原因 |
|
||||
| ------------------------------ | --------------------------------------------------------------------------- |
|
||||
| 完整配置版本,而不是在线 patch | 让预览、激活、历史和回滚有稳定边界 |
|
||||
| Agent 主动拉取 | Server 不需要 SSH 权限,也不暴露远程命令入口;支持 HTTP 与 WebSocket 双协议 |
|
||||
| 全局单激活版本 | 降低 MVP 复杂度,保证所有节点默认一致;支持版本预览、历史查询与一键回滚 |
|
||||
| 网站配置聚合多域名 | 支持一个业务站点共享站点级策略,同时允许按域名绑定证书 |
|
||||
| 观测数据服务端聚合 | 避免前端临时统计造成口径不一致 |
|
||||
| 内网穿透基于 frp 整合 | 复用成熟隧道协议,避免自研隧道的稳定性风险;frps HTTP Vhost 路由天然适配 |
|
||||
| Relay/Client 独立二进制 | 职责分离,Relay 管理 frps,Client 管理 frpc,各自独立升级和部署 |
|
||||
| Tunnel 与 Node 体系分离 | Tunnel 客户端在内网运行,与公网节点概念不同,使用独立的注册和认证体系 |
|
||||
|
||||
## 贡献者阅读建议
|
||||
|
||||
@@ -135,5 +219,5 @@ Client -> OpenResty server block -> named upstream -> Origin
|
||||
|
||||
1. [产品边界](./index.md)
|
||||
2. [发布模型](./release-model.md)
|
||||
3. [开发约束](./development.md)
|
||||
4. [仓库结构](../reference/repository.md)
|
||||
3. [开发约束](../guildline/development-constraints.md)
|
||||
4. [仓库结构](./repository.md)
|
||||
|
||||
+125
-241
@@ -1,298 +1,182 @@
|
||||
# 开发约束
|
||||
# 本地开发
|
||||
|
||||
你会学到:OpenFlare 代码修改的准入标准、后端/Agent/前端分层约束、数据模型边界、API 约定、数据库迁移要求和测试交付基线。
|
||||
你会学到:如何搭建 OpenFlare 的本地开发环境、启动 Server、Agent 和管理端前端,运行测试与构建命令,并理解贡献代码前需要遵守的边界。
|
||||
|
||||
本文档融合原开发规范、前端规范与开发计划,是 OpenFlare `1.0.0` 之后的工程约束入口。
|
||||
本页面向贡献者。产品边界、数据模型约束、API 约定和前端分层规范以 [开发约束](../guildline/development-constraints.md) 为准;本页只提供可执行的本地开发流程。
|
||||
|
||||
## 当前结论
|
||||
## 仓库结构
|
||||
|
||||
* 第一版至第六版的主线能力已经全部完成。
|
||||
* `1.0.0` 是当前正式基线。
|
||||
* 已完成阶段的过程性任务以代码、测试与 Git 历史为准。
|
||||
* 新工作优先以缺陷修复、可维护性改进、文档与测试补强为主。
|
||||
项目的核心物理目录及各模块(Server、Agent、Frontend 等)的职责分层,详见 [仓库结构](./repository.md)。
|
||||
|
||||
当前开发优先级:
|
||||
## 环境要求
|
||||
|
||||
1. 稳定性。
|
||||
2. 升级与回滚链路可靠性。
|
||||
3. 文档准确性。
|
||||
4. 测试覆盖补强。
|
||||
5. 在既有边界内的小步迭代。
|
||||
|
||||
## 变更准入
|
||||
|
||||
新需求进入实现前,按以下顺序判断:
|
||||
|
||||
1. 是否符合 [产品边界](./)。
|
||||
2. 是否符合本文档的后端、Agent 与前端约束。
|
||||
3. 是否会破坏现有发布、同步、回滚或升级主链路。
|
||||
4. 是否需要同步更新部署、配置、README 或文档站页面。
|
||||
|
||||
如果需求超出边界或引入新基础设施,应先更新设计文档,再开始实现。
|
||||
|
||||
任何合入正式基线的改动,至少应满足:
|
||||
|
||||
* 不破坏 Agent 心跳、同步、发布与回滚主链路。
|
||||
* 不破坏现有 OpenResty 主配置托管模型。
|
||||
* 不降低总览、节点详情与访问分析的既有可用性。
|
||||
* 有与风险相称的测试或联调验证。
|
||||
* 文档与代码保持一致。
|
||||
|
||||
## 技术基线
|
||||
|
||||
Server:
|
||||
|
||||
* Go 1.25+
|
||||
* Gin
|
||||
* GORM
|
||||
* SQLite / PostgreSQL
|
||||
* 现有登录体系
|
||||
|
||||
Agent:
|
||||
|
||||
* 单二进制
|
||||
* 节点本地执行
|
||||
* 通过 `openresty_path` 或默认 `openresty` 控制 OpenResty 二进制
|
||||
* Docker 部署使用内置 OpenResty 的 Agent 镜像,不由 Agent 再控制独立 OpenResty 容器
|
||||
|
||||
Frontend:
|
||||
|
||||
* Next.js 15 App Router
|
||||
* React 19
|
||||
* TypeScript 5
|
||||
* Tailwind CSS 4
|
||||
* TanStack Query
|
||||
* React Hook Form + Zod
|
||||
* Zustand 仅用于轻量客户端状态
|
||||
* ESLint + Prettier
|
||||
* Vitest + Testing Library + Playwright
|
||||
* pnpm
|
||||
|
||||
## Server 分层
|
||||
|
||||
| 目录 | 职责 |
|
||||
| 项目 | 要求 |
|
||||
| --- | --- |
|
||||
| `controller/` | 参数解析、调用 service、返回响应 |
|
||||
| `service/` | 业务逻辑、校验、事务编排、渲染 |
|
||||
| `model/` | 模型定义与持久化 |
|
||||
| `router/` | 路由注册 |
|
||||
| `middleware/` | 认证、鉴权、限流等横切逻辑 |
|
||||
| `common/` | 配置、全局状态与初始化入口 |
|
||||
| `utils/` | 纯工具函数与通用 helper |
|
||||
| Go | `1.25+` |
|
||||
| Node.js | `18+` |
|
||||
| pnpm | 推荐通过 `corepack enable` 使用项目声明版本 |
|
||||
| Docker | Server 容器、本地联调和 Agent Docker 镜像需要 |
|
||||
| OpenResty | 本地运行 Agent 时需要可执行 `openresty` |
|
||||
| PostgreSQL | 可选;未配置时 Server 使用 SQLite |
|
||||
|
||||
禁止在 `controller/` 堆积业务逻辑,禁止在 `middleware/` 实现业务流程,禁止为简单需求新增平台层抽象。
|
||||
## 初始化前端依赖
|
||||
|
||||
## Agent 分层
|
||||
|
||||
Agent 保持现有模块边界:
|
||||
|
||||
* `config`
|
||||
* `heartbeat`
|
||||
* `sync`
|
||||
* `openresty` / `nginx`
|
||||
* `state`
|
||||
* `httpclient`
|
||||
* `protocol`
|
||||
* `internal/updater`
|
||||
|
||||
要求:
|
||||
|
||||
* 每个模块职责单一。
|
||||
* 外部命令调用集中封装。
|
||||
* 状态落盘与配置落盘分离。
|
||||
|
||||
## Frontend 分层
|
||||
|
||||
推荐目录:
|
||||
|
||||
```text
|
||||
app/
|
||||
components/
|
||||
features/
|
||||
lib/
|
||||
hooks/
|
||||
store/
|
||||
types/
|
||||
styles/
|
||||
tests/
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
corepack enable
|
||||
pnpm install
|
||||
```
|
||||
|
||||
职责约束:
|
||||
构建供 Go Server 托管的静态产物:
|
||||
|
||||
* `app/`:路由、布局、页面组装。
|
||||
* `features/`:按业务域组织模块。
|
||||
* `components/`:跨 feature 复用组件。
|
||||
* `lib/`:请求客户端、环境变量、工具函数、常量。
|
||||
* `store/`:少量跨页面 UI 状态。
|
||||
* `types/`:共享类型定义。
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
页面文件只负责获取路由参数、组织页面结构、调用 feature 组件;不应手写复杂 API 细节、复杂表单校验逻辑或维护大量彼此耦合的局部状态。
|
||||
## 启动 Server
|
||||
|
||||
## 数据模型规范
|
||||
SQLite 模式:
|
||||
|
||||
当前有效实体:
|
||||
```bash
|
||||
cd openflare_server
|
||||
export SESSION_SECRET='dev-session-secret'
|
||||
export SQLITE_PATH='./openflare-dev.db'
|
||||
export LOG_LEVEL='debug'
|
||||
go run .
|
||||
```
|
||||
|
||||
* `proxy_routes`
|
||||
* `origins`
|
||||
* `config_versions`
|
||||
* `nodes`
|
||||
* `auth_sources`
|
||||
* `external_accounts`
|
||||
* `node_system_profiles`
|
||||
* `apply_logs`
|
||||
* `tls_certificates`
|
||||
* `managed_domains`
|
||||
* `node_request_reports`
|
||||
* `node_access_logs`
|
||||
* `node_metric_snapshots`
|
||||
* `traffic_analytics_rollups`
|
||||
* `node_health_events`
|
||||
* `options`
|
||||
PostgreSQL 模式:
|
||||
|
||||
通用约束:
|
||||
```bash
|
||||
cd openflare_server
|
||||
export SESSION_SECRET='dev-session-secret'
|
||||
export DSN='postgres://openflare:secret@127.0.0.1:5432/openflare?sslmode=disable'
|
||||
export LOG_LEVEL='debug'
|
||||
go run .
|
||||
```
|
||||
|
||||
* 不新增平台化对象,除非设计文档明确要求。
|
||||
* `origins` 仅作为可复用源站地址目录,字段保持轻量。
|
||||
* `proxy_routes` 以“网站配置”作为聚合边界,必须包含唯一 `site_name` 与非空 `domains` 列表。
|
||||
* `proxy_routes.domains` 中的每个域名都必须全局唯一,列表第一项视为主域名。
|
||||
* `proxy_routes` 继续允许保存一个或多个上游地址用于负载均衡,但不引入独立 `origin_pool`。
|
||||
* 遗留 `domain` 字段只能作为 `domains[0]` 的兼容镜像;新代码不得继续以该字段作为唯一业务输入。
|
||||
* `proxy_routes` 如关联 `origins`,必须同时保存可直接渲染的 `origin_url`。
|
||||
* 上游统一使用 named `upstream` + keepalive;单上游如带 base path 或 query,应在 `proxy_pass` 上补回 URI,多上游仅允许纯 `scheme://host[:port]`。
|
||||
* 流量限制、反向代理与缓存配置当前都归属站点级 `proxy_routes`。
|
||||
* HTTPS 证书绑定必须通过与 `domains` 平行的 `domain_cert_ids` 逐域名保存;未绑定证书的域名不得参与 HTTPS 渲染。
|
||||
* `config_versions` 必须保存完整快照与渲染结果。
|
||||
* 全局同时只能有一个激活版本。
|
||||
* 回滚通过重新激活旧版本实现。
|
||||
* `nodes` 只保留控制面状态与低频摘要。
|
||||
* 观测数据必须按节点与时间窗口关联,快照与聚合结果采用追加式模型。
|
||||
* 原始访问明细必须有受控保留策略。
|
||||
* `auth_sources` 仅保存管理端第三方登录源配置,当前支持 `github` 与 `oidc`。
|
||||
* `external_accounts` 是第三方账号与本地用户的唯一绑定来源;旧 `users.github_id` 仅用于兼容迁移,不得作为新登录流程的业务输入。
|
||||
默认访问地址:
|
||||
|
||||
## 数据库迁移
|
||||
```text
|
||||
http://localhost:3000
|
||||
```
|
||||
|
||||
任何涉及表结构、索引、列类型、分表规则或内部持久化元数据的修改,都必须同步提升数据库版本号。
|
||||
默认账号是 `root` / `123456`。
|
||||
|
||||
数据库版本号定义在 `openflare_server/model`,不得只依赖 `AutoMigrate` 隐式升级存量数据库。
|
||||
## 启动前端开发服务器
|
||||
|
||||
每次提升数据库版本号时,必须补充从上一版本升级到新版本的显式迁移方法。迁移方法必须包含升级后的校验逻辑;只有校验通过,才能写入新的数据库版本记录。
|
||||
前端开发服务器默认监听 `3001`,并通过 `NEXT_DEV_BACKEND_URL` 代理到后端:
|
||||
|
||||
新包启动后必须先检查数据库当前版本,再按顺序逐步升级到目标版本;禁止跳过中间升级步骤直接写目标版本。
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
export NEXT_DEV_BACKEND_URL='http://127.0.0.1:3000'
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
空库初始化可以直接建立当前版本结构,但初始化完成后仍必须执行同版本校验,并落库当前数据库版本。
|
||||
访问:
|
||||
|
||||
如果迁移失败或校验失败,启动流程必须中止,且不得提升数据库版本记录。涉及数据库版本变更的提交,必须补充对应的迁移测试或等效回归测试。
|
||||
```text
|
||||
http://localhost:3001
|
||||
```
|
||||
|
||||
## API 与鉴权
|
||||
## 启动 Agent
|
||||
|
||||
管理端与 Agent API 统一使用 JSON。成功与失败都必须返回清晰 `message`:
|
||||
创建本地 `agent.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": {}
|
||||
"server_url": "http://127.0.0.1:3000",
|
||||
"agent_token": "replace-with-node-auth-token",
|
||||
"data_dir": "./data",
|
||||
"heartbeat_interval": 10000,
|
||||
"request_timeout": 10000
|
||||
}
|
||||
```
|
||||
|
||||
约定:
|
||||
运行:
|
||||
|
||||
* Agent API 固定放在 `/api/agent/*`。
|
||||
* 总览与节点详情优先使用专用聚合接口。
|
||||
* 管理端变更类接口统一使用 `POST`;只读接口使用 `GET`。
|
||||
* 管理端继续复用现有登录、角色与 Session。
|
||||
* 第三方登录统一通过认证源 API 进入,认证源管理接口必须要求 Root Session。
|
||||
* `/api/status` 只能返回已启用认证源的公开字段,不得返回 Client Secret。
|
||||
* 第三方账号未绑定且注册关闭时,应提供绑定已有账号流程,不得自动创建用户。
|
||||
* Agent 正式请求统一使用节点专属 `agent_token`。
|
||||
* 首次接入可使用全局 `discovery_token`。
|
||||
* Agent 请求头统一使用 `X-Agent-Token`。
|
||||
```bash
|
||||
cd openflare_agent
|
||||
export LOG_LEVEL='debug'
|
||||
go run ./cmd/agent -config ./agent.json
|
||||
```
|
||||
|
||||
禁止暴露远程 shell 或任意命令执行入口,禁止在日志中打印完整 Token,禁止绕过占位符约束保存不可渲染的主配置模板。
|
||||
未配置 `openresty_path` 时,Agent 默认调用 `openresty`。调试时可显式配置 `openresty_path`、`main_config_path`、`route_config_path`、`access_log_path`、`cert_dir`、`lua_dir` 和 `runtime_config_dir`。
|
||||
|
||||
## 发布与运行
|
||||
## 测试
|
||||
|
||||
发布逻辑必须保持:
|
||||
Server:
|
||||
|
||||
* 发布时读取全部启用的 `proxy_routes`。
|
||||
* 同时读取 OpenResty 主配置参数、反代性能参数与缓存参数。
|
||||
* 生成完整 OpenResty 配置。
|
||||
* 计算 `checksum`。
|
||||
* 写入 `config_versions`。
|
||||
* 通过切换 `is_active` 激活版本。
|
||||
```bash
|
||||
cd openflare_server
|
||||
GOCACHE=/tmp/openflare-go-cache go test ./...
|
||||
```
|
||||
|
||||
版本约束:
|
||||
Agent:
|
||||
|
||||
* 版本号格式固定为 `YYYYMMDD-NNN`。
|
||||
* 不在线修改历史版本。
|
||||
* 不做按节点分组的差异化版本。
|
||||
* 预览与 diff 是只读能力,不产生发布记录。
|
||||
```bash
|
||||
cd openflare_agent
|
||||
GOCACHE=/tmp/openflare-go-cache go test ./...
|
||||
```
|
||||
|
||||
Agent 必须满足:
|
||||
Frontend:
|
||||
|
||||
* 启动后读取或生成本地 `node_id`。
|
||||
* 周期性心跳与同步。
|
||||
* 常规同步优先依据 heartbeat 返回的版本摘要判断。
|
||||
* WS 连接升级开启且连接成功时,Agent 可通过 WS 接收激活版本摘要并立即同步;WS 失败或断开必须退回 HTTP heartbeat。
|
||||
* 发现新版本时先备份旧文件。
|
||||
* 写入主配置、路由配置与必要证书文件。
|
||||
* 写入新配置后执行 `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 必须在本地状态中阻断该目标的重复应用。
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm test:e2e
|
||||
```
|
||||
|
||||
## 前端请求、状态与类型
|
||||
Docs:
|
||||
|
||||
所有 API 请求必须统一经过 `lib/api/`:
|
||||
```bash
|
||||
cd docs
|
||||
pnpm build
|
||||
```
|
||||
|
||||
* 统一处理 `success/message/data` 响应结构。
|
||||
* 统一处理鉴权失效、网络异常和通用错误消息。
|
||||
* 统一维护资源接口与请求路径。
|
||||
## 构建
|
||||
|
||||
状态分层:
|
||||
管理端静态产物:
|
||||
|
||||
* 服务端状态:TanStack Query。
|
||||
* 页面临时状态:组件内部 `useState`。
|
||||
* 跨页面 UI 状态:Zustand。
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
pnpm build
|
||||
```
|
||||
|
||||
要求开启 TypeScript 严格模式,禁止滥用 `any`,API 响应、表单输入、业务实体必须有明确类型。
|
||||
Server 二进制:
|
||||
|
||||
## 表单、交互、样式与主题
|
||||
```bash
|
||||
cd openflare_server
|
||||
go build -o openflare-server .
|
||||
```
|
||||
|
||||
表单统一使用 React Hook Form 与 Zod。
|
||||
Agent 二进制:
|
||||
|
||||
高风险操作必须二次确认、展示操作对象名称,并明确成功与失败反馈。
|
||||
```bash
|
||||
cd openflare_agent
|
||||
go build -o openflare-agent ./cmd/agent
|
||||
```
|
||||
|
||||
样式原则:
|
||||
## 调试入口
|
||||
|
||||
* 统一使用 Tailwind CSS 与现有 token 体系。
|
||||
* 优先复用已有基础组件与布局组件。
|
||||
* 保持视觉层级、留白与语义颜色一致。
|
||||
| 场景 | 命令或位置 |
|
||||
| --- | --- |
|
||||
| Server 日志 | `LOG_LEVEL=debug go run .` |
|
||||
| Agent 日志 | `LOG_LEVEL=debug go run ./cmd/agent -config ./agent.json` |
|
||||
| Swagger | `http://localhost:3000/swagger/index.html` |
|
||||
| 前端 API 代理 | `NEXT_DEV_BACKEND_URL=http://127.0.0.1:3000 pnpm dev` |
|
||||
| OpenResty 配置校验 | `openresty -t -c ./data/etc/nginx/nginx.conf` |
|
||||
|
||||
主题要求:
|
||||
## 代码风格与变更准入
|
||||
|
||||
* 同时支持 `light`、`dark`、`system`。
|
||||
* 用户选择必须持久化。
|
||||
* 首屏尽量避免主题闪烁。
|
||||
贡献前先确认:
|
||||
|
||||
## 测试与交付
|
||||
1. 需求符合 [产品边界](./index.md)。
|
||||
2. 实现符合 [开发约束](../guildline/development-constraints.md)。
|
||||
3. 不破坏发布、同步、回滚或升级主链路。
|
||||
4. 涉及配置、部署、API 或产品边界时同步更新文档。
|
||||
5. 风险较高的修改补充测试或等效联调验证。
|
||||
|
||||
* 关键业务逻辑必须有单元测试或等效回归测试。
|
||||
* Agent 主链路修改必须验证同步、应用与回滚。
|
||||
* 前端页面至少覆盖加载态、空态、错误态与成功反馈。
|
||||
* Go 版本调整时,同步检查 `go.mod`、Dockerfile 与 CI 工作流。
|
||||
|
||||
## 后续维护方式
|
||||
|
||||
后续规划不再按“大版本阶段文档”维护,而采用以下方式:
|
||||
|
||||
* 产品边界变动:更新 [产品边界](./)。
|
||||
* 工程约束变动:更新本文档。
|
||||
* 部署与配置变动:更新 [部署说明](../guide/deployment.md)、[配置项](../reference/configuration.md) 与 README。
|
||||
|
||||
如果未来出现明确的新阶段目标,再单独新增专项计划文档;不要把已完成的历史计划继续堆回本文档。
|
||||
|
||||
当前专项“网站级规则与配置界面改造”的模型边界已纳入 [产品边界](./),执行时仍按数据模型、接口、前端页面、迁移测试与文档联动的顺序推进。
|
||||
数据库结构变更必须提升数据库版本号,并补充从上一版本到新版本的显式迁移方法和校验逻辑。
|
||||
|
||||
+114
-33
@@ -15,16 +15,7 @@ OpenFlare 适合需要统一管理多台 OpenResty 代理节点的团队:
|
||||
|
||||
OpenFlare 当前不定位为通用日志平台、服务网格、Kubernetes Ingress Controller 或多租户云平台。
|
||||
|
||||
## 目标用户
|
||||
|
||||
| 用户 | 需求 |
|
||||
| --- | --- |
|
||||
| 自托管用户 | 快速部署一个可视化 OpenResty 控制面 |
|
||||
| 内部运维团队 | 管理多个反向代理节点、证书和配置版本 |
|
||||
| 开发团队 | 为内部服务提供统一入口和基础访问分析 |
|
||||
| 贡献者 | 在明确边界内修复缺陷、补强测试和改进文档 |
|
||||
|
||||
## 当前稳定能力
|
||||
## 当前能力
|
||||
|
||||
| 能力 | 说明 |
|
||||
| --- | --- |
|
||||
@@ -35,16 +26,20 @@ OpenFlare 当前不定位为通用日志平台、服务网格、Kubernetes Ingre
|
||||
| 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,将流量转发到内网服务。
|
||||
|
||||
## 典型使用场景
|
||||
|
||||
@@ -56,26 +51,8 @@ OpenFlare 当前不定位为通用日志平台、服务网格、Kubernetes Ingre
|
||||
| 快速回滚 | 重新激活旧版本,让 Agent 拉取并应用 |
|
||||
| 证书托管 | 为不同域名绑定 TLS 证书 |
|
||||
| 基础观测 | 查看节点状态、请求聚合、访问分析和健康事件 |
|
||||
| 内网穿透 | 通过 Tunnel 将无法直接公网访问的内网 HTTP 服务暴露到互联网,享有 HTTPS、WAF 等全部防护能力 |
|
||||
|
||||
## 核心对象
|
||||
|
||||
当前有效实体:
|
||||
|
||||
* `proxy_routes`
|
||||
* `origins`
|
||||
* `config_versions`
|
||||
* `nodes`
|
||||
* `auth_sources`
|
||||
* `external_accounts`
|
||||
* `node_system_profiles`
|
||||
* `apply_logs`
|
||||
* `tls_certificates`
|
||||
* `managed_domains`
|
||||
* `node_request_reports`
|
||||
* `node_access_logs`
|
||||
* `node_metric_snapshots`
|
||||
* `traffic_analytics_rollups`
|
||||
* `node_health_events`
|
||||
|
||||
## 网站配置约束
|
||||
|
||||
@@ -98,13 +75,85 @@ OpenFlare 当前不定位为通用日志平台、服务网格、Kubernetes Ingre
|
||||
|
||||
上游约束:
|
||||
|
||||
* `proxy_routes` 至少包含一个上游地址。
|
||||
* `proxy_routes` 至少包含一个上游地址(直连类型),或关联一个 Tunnel(内网穿透类型)。
|
||||
* `proxy_routes.upstream_type` 区分上游类型:`direct`(默认,直连)或 `tunnel`(内网穿透)。
|
||||
* 为兼容历史数据保留 `origin_url` 主上游字段,也允许在同一规则内补充多个上游做负载均衡。
|
||||
* 上游统一渲染为带 keepalive 的 named `upstream`。
|
||||
* 单上游可附带 base path 或 query 并在 `proxy_pass` 中追加。
|
||||
* 多上游限定为纯 `scheme://host[:port]`。
|
||||
* `proxy_routes.origin_host` 为可选字段,用于回源时覆盖 `Host` 请求头。
|
||||
* 所有上游地址都必须为合法 `http://` 或 `https://`。
|
||||
* 所有直连类型上游地址都必须为合法 `http://` 或 `https://`。
|
||||
* 内网穿透类型上游必须关联 `tunnel_id`,并指定内网目标地址与协议。
|
||||
|
||||
## 内网穿透约束
|
||||
|
||||
OpenFlare 通过 TunnelRelay 节点与 OpenFlared 客户端实现内网穿透,底层基于 frp(快速反向代理)构建。
|
||||
|
||||
### 节点与组件模型
|
||||
|
||||
**节点类型**:
|
||||
|
||||
* `nodes.node_type` 区分节点类型:`edge_node`(边缘节点,默认)和 `tunnel_relay`(隧道中继)。
|
||||
* TunnelRelay 节点同时运行 Agent(OpenResty)和 Relay(frps 管理器),共享同一个 `agent_token`。
|
||||
- Agent 负责 HTTPS 终结、WAF 防护、缓存与流量限制等。
|
||||
- Relay 管理 frps 进程,为内网客户端提供隧道中继服务。
|
||||
* TunnelRelay 节点新增字段:`node_type`、`relay_bind_port`(frpc 连接端口,默认 7000)、`relay_vhost_http_port`(HTTP Vhost 端口,默认 8080)、`relay_auth_token`(自动生成)、`relay_status` 等。
|
||||
|
||||
**Tunnel 客户端**:
|
||||
|
||||
* `tunnels` 表独立存储内网穿透客户端注册信息,与 `nodes` 体系无关。
|
||||
* 每个 Tunnel 拥有唯一的 `tunnel_id`(格式 `tun-<32hex>`)和 `tunnel_token`(客户端认证凭据)。
|
||||
* OpenFlared 客户端运行在内网,不对外暴露,使用 `tunnel_token` 认证,通过 `/api/flared/*` 端点与 Server 通信。
|
||||
* 一个 OpenFlared 客户端可同时连接多个 Relay(为高可用)。
|
||||
|
||||
### 上游类型扩展
|
||||
|
||||
`proxy_routes` 的上游配置分为两种类型,通过 `upstream_type` 字段区分:
|
||||
|
||||
* **直连上游(`direct`,默认)**:直接将流量转发到源站地址,行为与现有完全一致。
|
||||
* **内网穿透上游(`tunnel`)**:通过 TunnelRelay 节点将流量转发到内网服务。
|
||||
- 必须指定 `tunnel_id`(关联 `tunnels` 表)。
|
||||
- 必须指定 `tunnel_target_addr`(内网目标地址,如 `192.168.1.100:8080`)和 `tunnel_target_protocol`(`http` 或 `https`)。
|
||||
- 发布时,Server 自动将上游地址替换为 `http://127.0.0.1:{relay_vhost_http_port}`。
|
||||
|
||||
### 流量路径与协议
|
||||
|
||||
**完整数据面流量路径**:
|
||||
|
||||
```
|
||||
浏览器 → OpenResty (Agent, TLS/WAF) [TunnelRelay 节点]
|
||||
↓
|
||||
frps (Relay, HTTP Vhost 路由) [TunnelRelay 节点, 127.0.0.1:{vhost_port}]
|
||||
↓
|
||||
frp 隧道协议 (Host 头路由)
|
||||
↓
|
||||
frpc (Client, 多进程) [内网服务器]
|
||||
↓
|
||||
内网服务 (192.168.x.x:port)
|
||||
```
|
||||
|
||||
**关键特性**:
|
||||
|
||||
* frps 使用 HTTP Vhost 单端口复用机制,所有 HTTP 隧道共享一个 `vhost_port`,通过 Host 头自动路由到对应 frpc。
|
||||
* Agent 保留原始 `Host` 请求头,frps 依据此头进行虚拟主机匹配。
|
||||
* 每个隧道对应一条 `proxy_routes`,可绑定多个域名。
|
||||
* OpenFlared 客户端为每个连接的 Relay 管理一个独立的 frpc 进程,通过单一 frp 隧道传输多个 HTTP 代理定义。
|
||||
|
||||
### 配置同步模型
|
||||
|
||||
发布流程同时生成两类配置版本数据,统一使用 `config_version` 版本号关联:
|
||||
|
||||
* **Agent 侧配置**:OpenResty 主配置 + 路由配置 + WAF 规则。包含 tunnel 上游时,自动渲染为 `http://127.0.0.1:{vhost_port}` 上游。
|
||||
* **Tunnel 侧配置**:Relay 列表 + frpc 代理定义。随发布流程版本化,变更时优先使用 `frpc reload` 热重载。
|
||||
* **Relay 配置**:通过心跳响应下发,相对静态,不纳入版本化流程。
|
||||
|
||||
### 当前阶段约束
|
||||
|
||||
* 仅支持 HTTP 协议隧道流量,保留未来 TCP/UDP 隧道扩展性。
|
||||
* Tunnel 类型上游的域名 DNS 应仅解析到 TunnelRelay 节点;EdgeNode 上对应请求会因 frps 不可达返回 502。
|
||||
* frp 版本使用 v0.61+(或更新稳定版),frp 二进制由部署脚本或 Docker 镜像提供。
|
||||
* 暂不支持 TCP/UDP 端口分配;HTTP 单端口复用已满足 MVP 需求。
|
||||
|
||||
|
||||
## HTTPS 约束
|
||||
|
||||
@@ -116,6 +165,38 @@ OpenFlare 当前不定位为通用日志平台、服务网格、Kubernetes Ingre
|
||||
* 未绑定证书的域名不得被自动带入 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` 两类。启用后的认证源会显示在登录页。
|
||||
@@ -143,8 +224,8 @@ OpenFlare 当前不定位为通用日志平台、服务网格、Kubernetes Ingre
|
||||
* 产品范围或系统边界变化时更新本文档。
|
||||
* 系统结构或模块职责变化时更新 [系统架构](./architecture.md)。
|
||||
* 发布、同步、回滚模型变化时更新 [发布模型](./release-model.md)。
|
||||
* 开发约束、代码规范、接口约定变化时更新 [开发约束](./development.md)。
|
||||
* 部署方式变化时更新 [部署说明](../guide/deployment.md) 与 README。
|
||||
* 开发约束、代码规范、接口约定变化时更新 [开发约束](../guildline/development-constraints.md)。
|
||||
* 部署方式变化时更新 [部署说明](../reference/deployment.md) 与 README。
|
||||
* 配置项变化时更新 [配置项参考](../reference/configuration.md)。
|
||||
* 已完成阶段不再以“版本计划”形式回填。
|
||||
* 新阶段开始前,先补设计,再进入实现。
|
||||
|
||||
@@ -17,11 +17,12 @@ Server 发布时必须:
|
||||
1. 读取全部启用的 `proxy_routes`。
|
||||
2. 读取 Server 侧 OpenResty 主配置、性能参数、缓存参数和必要 Lua 资源。
|
||||
3. 读取域名与证书绑定关系。
|
||||
4. 渲染完整 OpenResty 配置。
|
||||
5. 计算 `checksum`。
|
||||
6. 写入 `config_versions`。
|
||||
7. 切换激活版本。
|
||||
8. 让 Agent 在后续 heartbeat 中发现并应用。
|
||||
4. 读取 WAF 全局规则组、自定义规则组、IP 组引用与网站绑定关系。
|
||||
5. 保留 WAF 规则组引用的 IP 组 ID,渲染完整 OpenResty 配置与 WAF 运行时配置;IP 组成员不进入发布版本。
|
||||
6. 计算 `checksum`。
|
||||
7. 写入 `config_versions`。
|
||||
8. 切换激活版本。
|
||||
9. 让 Agent 在后续 heartbeat 中发现并应用。
|
||||
|
||||
版本号格式固定为 `YYYYMMDD-NNN`。
|
||||
|
||||
@@ -53,7 +54,7 @@ Agent 发现新版本后会:
|
||||
|
||||
1. 拉取目标版本详情。
|
||||
2. 备份旧文件。
|
||||
3. 写入主配置、路由配置、证书与必要 Lua 资源。
|
||||
3. 写入主配置、路由配置、证书、必要 Lua 资源与 WAF/PoW 运行时配置。
|
||||
4. 执行 OpenResty 配置校验。
|
||||
5. reload;如果运行时未启动,则尝试用当前配置启动 OpenResty。
|
||||
6. 上报成功、警告或失败。
|
||||
@@ -69,3 +70,10 @@ Agent 发现新版本后会:
|
||||
* 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/` | 静态资源 |
|
||||
@@ -0,0 +1,188 @@
|
||||
You are a senior Go backend engineer responsible for maintaining and developing a long-evolving Go application.
|
||||
|
||||
Your goal is not to "write code as quickly as possible," but to produce high-quality code that is maintainable, testable, evolvable, and conforms to Go ecosystem practices. It is forbidden to pile up temporary code, over-abstract, duplicate logic, or break the existing architecture just to complete tasks.
|
||||
|
||||
Before any development, you must first read and understand the existing code structure, including:
|
||||
- Project directory structure
|
||||
- Entry files
|
||||
- Configuration management methods
|
||||
- Database/cache/message queue access methods
|
||||
- HTTP/RPC/API layer design
|
||||
- Layering methods such as service/usecase/domain/repository
|
||||
- Error handling methods
|
||||
- Logging methods
|
||||
- Test organization methods
|
||||
- Dependency injection methods
|
||||
- Existing coding style
|
||||
|
||||
If you are unsure of the responsibility of a certain module, infer it from the code context first; do not arbitrarily create duplicate modules.
|
||||
|
||||
Development Principles:
|
||||
|
||||
1. Architecture First
|
||||
- Prioritize integrating into the existing architecture rather than starting from scratch.
|
||||
- Do not arbitrarily add global variables, init side effects, or implicit dependencies.
|
||||
- Do not write business logic into handlers/controllers.
|
||||
- Handlers are only responsible for parameter parsing, authentication contexts, calling usecases/services, and returning responses.
|
||||
- Services/usecases are responsible for business orchestration.
|
||||
- Repositories/daos are responsible for data access.
|
||||
- Domain/models are responsible for core business objects and rules.
|
||||
- Isolate infrastructure code from business code.
|
||||
|
||||
2. Go Style
|
||||
- Use clear, direct, and simple Go code.
|
||||
- Do not mimic Java-style over-abstraction.
|
||||
- Interfaces should be defined by the consumer, not forced by the provider.
|
||||
- Prioritize small interfaces.
|
||||
- Naming must be accurate. Do not use vague names like Manager, Helper, or Util unless absolutely necessary.
|
||||
- Keep functions short and single-responsibility.
|
||||
- Do not introduce generics, reflection, or complex design patterns just to "look advanced."
|
||||
- Do not hide errors.
|
||||
- Errors must contain context information; use `fmt.Errorf("...: %w", err)` when necessary.
|
||||
- Do not panic, except for unrecoverable errors during the program startup phase.
|
||||
|
||||
3. Maintainability
|
||||
- Analyze the scope of impact before making modifications.
|
||||
- Keep changes minimal and avoid unrelated refactoring.
|
||||
- Do not change public APIs, database structures, or configuration formats unless explicitly requested by the task.
|
||||
- If changes must be made, explain the compatibility impact and migration plan.
|
||||
- Confirm there are no callers before deleting code.
|
||||
- Avoid copy-pasting existing logic; extract it to an appropriate place, but do not over-abstract.
|
||||
- Add necessary comments to complex business logic to explain "why", not the obvious "what".
|
||||
|
||||
4. Testing Requirements
|
||||
- New business logic must be supplemented with unit tests.
|
||||
- Bug fixes must be supplemented with regression tests.
|
||||
- Tests should cover normal paths, exceptional paths, and boundary conditions.
|
||||
- Do not break the structure of business code for testing convenience.
|
||||
- Isolate external dependencies using mocks/fakes/stubs.
|
||||
- Name tests clearly, e.g., TestXXX_WhenYYY_ShouldZZZ.
|
||||
- Prioritize table-driven tests, but do not sacrifice readability for table-driven structure.
|
||||
|
||||
5. Concurrency and Resource Management
|
||||
- Goroutines must have exit mechanisms.
|
||||
- Where context is involved, context.Context must be passed correctly.
|
||||
- Do not arbitrarily use context.Background() to replace upstream contexts.
|
||||
- Channels must have clear responsibility for closing.
|
||||
- Keep lock scopes small to avoid deadlocks.
|
||||
- Correctly close resources such as HTTP, databases, files, and connections.
|
||||
- Pay attention to race conditions, goroutine leaks, and connection leaks.
|
||||
|
||||
6. Database and Transactions
|
||||
- Database access must be in the repository/dao layer.
|
||||
- Transaction boundaries should be controlled by the business use case layer, rather than scattered across multiple lower-level functions.
|
||||
- Do not produce obviously inefficient N+1 queries in loops, unless the data volume is controllable and explained.
|
||||
- SQL must be readable and parameterized; unsanitized inputs are strictly forbidden from concatenation.
|
||||
- Schema changes must consider migration, rollback, and compatibility.
|
||||
|
||||
7. API Design
|
||||
- Request parameters must be validated.
|
||||
- Error responses must be stable and clear, without leaking internal sensitive information.
|
||||
- Do not print sensitive data such as passwords, tokens, keys, or ID numbers in logs.
|
||||
- Keep return structures backward-compatible.
|
||||
- HTTP status codes must be semantically correct.
|
||||
|
||||
8. Logging and Observability
|
||||
- Key paths must have necessary logs.
|
||||
- Error logs must contain the context needed for troubleshooting, but must not leak sensitive data.
|
||||
- Do not print logs excessively.
|
||||
- Do not use fmt.Println directly in library code.
|
||||
- If the project already has a logger, use the existing logger uniformly.
|
||||
|
||||
9. Security Requirements
|
||||
- All external inputs are untrusted.
|
||||
- Do not hardcode keys, tokens, or passwords.
|
||||
- Do not commit sensitive configurations to the repository.
|
||||
- Be mindful of injection risks in file paths, URLs, command executions, SQL, template rendering, etc.
|
||||
- Authentication and permission checks must be placed in clear locations, and must not rely on the self-discipline of the frontend or callers.
|
||||
|
||||
10. Performance Requirements
|
||||
- Do not optimize prematurely.
|
||||
- However, do not write obviously inefficient code.
|
||||
- Avoid unnecessary memory allocations, large object copies, and repeated parsing on hot paths.
|
||||
- Page, stream, or batch operations should be considered for large data processing.
|
||||
- If caching is introduced, the consistency, expiration strategy, and invalidation conditions must be explained.
|
||||
|
||||
Workflow:
|
||||
|
||||
Every time you receive a development task, you must follow these steps:
|
||||
|
||||
Step 1: Understand Requirements
|
||||
- Briefly rephrase the requirements in your own words.
|
||||
- Clarify inputs, outputs, boundary conditions, and exceptional cases.
|
||||
- If requirements are vague, list your reasonable assumptions; do not write code blindly.
|
||||
|
||||
Step 2: Read Existing Code
|
||||
- Identify relevant modules, call chains, data structures, interfaces, and tests.
|
||||
- Explain how the current code works.
|
||||
- Determine which layer the modification should be placed in.
|
||||
|
||||
Step 3: Design Solution
|
||||
- Provide a minimal viable modification plan.
|
||||
- Explain why it is placed in these files/modules.
|
||||
- State whether it affects existing APIs, databases, configurations, or tests.
|
||||
- If there are multiple solutions, compare their pros and cons and select the more stable one.
|
||||
|
||||
Step 4: Coding
|
||||
- Only modify code related to the task.
|
||||
- Maintain the existing code style.
|
||||
- Do not introduce unnecessary new dependencies.
|
||||
- Do not create duplicate logic.
|
||||
- Do not leave TODOs, temporary code, or debugging code.
|
||||
|
||||
Step 5: Testing
|
||||
- Supplement or update tests.
|
||||
- Explain what scenarios the tests cover.
|
||||
- If tests cannot be run, explain why and give the commands that should be run.
|
||||
|
||||
Step 6: Delivery Explanation
|
||||
- Summarize what was modified.
|
||||
- Explain why it was modified this way.
|
||||
- Explain potential risks.
|
||||
- Provide verification methods.
|
||||
- If there are incomplete items, they must be explicitly listed; do not pretend they are complete.
|
||||
|
||||
Output Format:
|
||||
|
||||
Each of your replies should contain:
|
||||
|
||||
1. Requirements Understanding
|
||||
2. Existing Code Analysis
|
||||
3. Modification Plan
|
||||
4. Specific Changes
|
||||
5. Testing and Verification
|
||||
6. Risks and Precautions
|
||||
|
||||
If you are only asked to review code, output:
|
||||
1. Problem List
|
||||
2. Severity: Critical / High / Medium / Low
|
||||
3. Impact Explanation
|
||||
4. Modification Suggestions
|
||||
5. Recommended Modification Example
|
||||
|
||||
Code Quality Red Lines:
|
||||
|
||||
The following behaviors are strictly prohibited:
|
||||
- Copying and pasting large blocks of duplicate code to complete requirements.
|
||||
- Stuffing business logic into handlers.
|
||||
- Passing `map[string]interface{}` everywhere.
|
||||
- Using global variables to bypass dependency injection.
|
||||
- Arbitrarily adding util/helper trash-can packages.
|
||||
- Ignoring errors.
|
||||
- Catch-all style error handling.
|
||||
- Continuing to stack logic when functions exceed reasonable length.
|
||||
- Modifying unrelated code.
|
||||
- Changing existing behavior without explanation.
|
||||
- Modifying core logic without tests.
|
||||
- Introducing large dependencies just to solve small problems.
|
||||
- Writing code without explaining the verification method.
|
||||
- Refactoring directly without understanding the existing architecture.
|
||||
|
||||
When you find that the existing code is already messy:
|
||||
- Do not perform a major refactoring all at once.
|
||||
- Stop the bleeding locally first.
|
||||
- Write new code within clear boundaries as much as possible.
|
||||
- Only make necessary changes to old code.
|
||||
- If refactoring is needed, propose a phased plan first.
|
||||
|
||||
Please always write code to the standards of "someone who will maintain this project for a long time", rather than "someone who completes a one-time task".
|
||||
+129
-16
@@ -1,33 +1,146 @@
|
||||
# Architecture
|
||||
# System Architecture
|
||||
|
||||
OpenFlare consists of Server, Agent, and local OpenResty on each node.
|
||||
You will learn: The overall architecture of OpenFlare, the responsibility boundaries of Server, Agent, OpenResty, and the management console frontend, and the request flow of a configuration release from the management console to take effect on a node.
|
||||
|
||||
OpenFlare consists of the Server, the Agent, local OpenResty on each node, and the management console frontend. The Server is the control plane, the Agent is the only controlled landing entry point on the node side, and OpenResty is the actual data plane.
|
||||
|
||||
```text
|
||||
OpenFlare Server (Gin + SQLite/PostgreSQL + Web UI)
|
||||
|
|
||||
| HTTP API / Config Pull
|
||||
v
|
||||
OpenFlare Agent (register / heartbeat / sync / apply / update)
|
||||
|
|
||||
v
|
||||
Browser
|
||||
|
|
||||
| Management UI / API
|
||||
v
|
||||
OpenFlare Server (Gin + GORM + SQLite/PostgreSQL)
|
||||
|
|
||||
| Agent API / heartbeat / config pull
|
||||
v
|
||||
OpenFlare Agent
|
||||
|
|
||||
| write config / openresty -t / reload / rollback
|
||||
v
|
||||
OpenResty binary
|
||||
|
|
||||
v
|
||||
|
|
||||
| reverse proxy
|
||||
v
|
||||
Origin
|
||||
```
|
||||
|
||||
## Component Responsibilities
|
||||
|
||||
| Component | Responsibility |
|
||||
| --- | --- |
|
||||
| Server | Management UI, admin APIs, Agent APIs, configuration rendering, version publishing, data storage, and aggregate queries |
|
||||
| Agent | Registration, heartbeats, synchronization, writing files, configuration validation, reloads, fallback/rollbacks, self-updating, and lightweight data collection |
|
||||
| OpenResty | Receives real traffic, executes WAF, PoW, authentication, and reverse proxying according to configurations rendered by OpenFlare |
|
||||
| Frontend | Manages website configurations, WAF, origins, certificates, nodes, versions, users, settings, and observability pages |
|
||||
|
||||
## Server
|
||||
|
||||
`openflare_server` is a monolithic control plane based on Gin, GORM, SQLite/PostgreSQL, the existing login/session system, and the static frontend build.
|
||||
`openflare_server` is a monolithic control plane:
|
||||
|
||||
It owns the admin UI and API, Agent API, configuration rendering, version publishing, storage, and aggregate queries.
|
||||
* Gin provides HTTP services.
|
||||
* GORM accesses SQLite or PostgreSQL.
|
||||
* The existing login system provides management console Sessions.
|
||||
* Authentication source and external account binding support GitHub OAuth and standard OIDC.
|
||||
* The Go Server hosts the static build output of `openflare_server/web`.
|
||||
|
||||
The Server does not directly SSH into nodes, nor does it modify node files online. It only saves the control plane state, generates complete configuration versions, and lets nodes actively pull them via the Agent API.
|
||||
|
||||
## Agent
|
||||
|
||||
`openflare_agent` is a single Go binary that runs on each node. It controls OpenResty through `openresty_path`, or `openresty` by default. Docker deployments use an Agent image that already includes OpenResty and follows the same binary-control flow.
|
||||
`openflare_agent` is a Go monolithic application:
|
||||
|
||||
It handles registration, heartbeat, sync, file writes, `openresty -t`, reload, rollback, self-update, and lightweight collection.
|
||||
* Runs on nodes as a single binary.
|
||||
* Reads or generates local node information upon startup.
|
||||
* Performs periodic heartbeats to report status and fetch the active version summary.
|
||||
* Pulls configurations, backs up old files, writes new files, validates, and reloads upon discovering a new version.
|
||||
* Attempts to restore execution and roll back when the application fails.
|
||||
* Maintains the WAF GeoIP mmdb; writes the built-in initial database on startup and updates it regularly based on configuration.
|
||||
|
||||
The Agent uniformly executes validations, reloads, starts, and restarts via the OpenResty binary pointed to by `openresty_path`; it falls back to calling `openresty` by default when not configured. In Docker deployments, the Agent image includes the OpenResty binary and follows the same binary control logic.
|
||||
|
||||
Node IPs are maintained by Agent registration and heartbeat reports by default; when the admin UI locks a node IP, the Server continues updating runtime fields such as status, versions, and observability, but no longer accepts Agent reports to overwrite that IP.
|
||||
|
||||
## Frontend
|
||||
|
||||
`openflare_server/web` is the production frontend baseline: Next.js App Router, React 19, TypeScript, and Tailwind CSS.
|
||||
`openflare_server/web` is the official management console frontend:
|
||||
|
||||
* Next.js App Router.
|
||||
* React 19.
|
||||
* TypeScript.
|
||||
* Tailwind CSS.
|
||||
* TanStack Query manages server state.
|
||||
|
||||
The frontend is hosted by the Go Server after static export. All API requests must go through `lib/api/` uniformly and handle the `success/message/data` response structure.
|
||||
|
||||
## Data and Request Flow
|
||||
|
||||
### Management Console Request Flow
|
||||
|
||||
```text
|
||||
Browser -> Frontend -> /api/* -> controller -> service -> model -> database
|
||||
```
|
||||
|
||||
Mutation APIs on the management console use `POST`, while read-only APIs use `GET`. Both success and failure return a clear `message`.
|
||||
|
||||
### Agent Sync Flow
|
||||
|
||||
```text
|
||||
Agent heartbeat -> Server returns active version summary
|
||||
Agent discovers new version -> Pulls configuration details
|
||||
Agent writes main configuration / route configuration / certificates / Lua resources / WAF runtime configuration
|
||||
Agent executes OpenResty validation and reload
|
||||
Agent reports application result
|
||||
```
|
||||
|
||||
When WebSocket (WS) connection upgrade is enabled by default, the Agent first obtains settings through the HTTP heartbeat, and then attempts to connect to the Agent WebSocket. Once the WS connection is successful, periodic status reporting is carried by WS; when the Server publishes or activates a version, it broadcasts the active version summary to connected Agents, allowing them to enter the synchronization flow immediately. When the WS connection is disconnected or fails to establish, the Agent automatically falls back to the HTTP heartbeat.
|
||||
|
||||
### Reverse Proxy Flow
|
||||
|
||||
```text
|
||||
Client -> OpenResty server block -> WAF Lua -> named upstream -> Origin
|
||||
```
|
||||
|
||||
Website configuration is the aggregation boundary of reverse proxies. A website configuration can bind multiple domains and share site-level traffic limits, reverse proxies, and caching configurations.
|
||||
|
||||
WAF is executed in the OpenResty `access_by_lua_file` phase. Rules come from `waf_config.json` carried in the current active version; the global rule group takes effect by default, and websites can overlay custom rule groups.
|
||||
|
||||
## Core Objects
|
||||
|
||||
Currently active entities include:
|
||||
|
||||
* `proxy_routes`
|
||||
* `origins`
|
||||
* `config_versions`
|
||||
* `nodes`
|
||||
* `auth_sources`
|
||||
* `external_accounts`
|
||||
* `node_system_profiles`
|
||||
* `apply_logs`
|
||||
* `tls_certificates`
|
||||
* `managed_domains`
|
||||
* `node_request_reports`
|
||||
* `node_access_logs`
|
||||
* `node_metric_snapshots`
|
||||
* `traffic_analytics_rollups`
|
||||
* `node_health_events`
|
||||
* `waf_rule_groups`
|
||||
* `waf_rule_group_bindings`
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
| Decision | Reason |
|
||||
| --- | --- |
|
||||
| Complete configuration versions, instead of online patches | Gives previews, activations, history, and rollbacks stable boundaries |
|
||||
| Active pull by Agents | Server does not need SSH permissions, nor does it expose remote command execution entry points |
|
||||
| Global single active version | Reduces MVP complexity and ensures all nodes are consistent by default |
|
||||
| Website configurations aggregate multiple domains | Supports sharing site-level policies for a business site while allowing certificate binding per domain |
|
||||
| Server-side aggregation of observability data | Avoids inconsistent results caused by temporary frontend calculations |
|
||||
|
||||
## Contributor Reading Suggestions
|
||||
|
||||
If you want to modify architecture-related code, read these first:
|
||||
|
||||
1. [Product Boundary](./index.md)
|
||||
2. [Release Model](./release-model.md)
|
||||
3. [Development Constraints](./development.md)
|
||||
4. [Repository Structure](../reference/repository.md)
|
||||
|
||||
@@ -148,6 +148,8 @@ Currently active entities:
|
||||
* `traffic_analytics_rollups`
|
||||
* `node_health_events`
|
||||
* `options`
|
||||
* `waf_rule_groups`
|
||||
* `waf_rule_group_bindings`
|
||||
|
||||
General constraints:
|
||||
|
||||
@@ -161,6 +163,7 @@ General constraints:
|
||||
* Upstreams uniformly use named `upstream` + keepalive; for a single upstream carrying a base path or query, the original URI should be added back to `proxy_pass`. For multiple upstreams, only pure `scheme://host[:port]` is allowed.
|
||||
* Rate limits, reverse proxy, and cache configurations currently belong to the site-level `proxy_routes`.
|
||||
* HTTPS certificate binding must be saved on a per-domain basis through `domain_cert_ids` parallel to `domains`; domains not bound to a certificate must not participate in HTTPS rendering.
|
||||
* WAF global rule groups are applied to all websites by default, while custom rule groups are bound to site configurations via `waf_rule_group_bindings`; they must be included in the complete configuration version snapshot during publishing.
|
||||
* `config_versions` must save complete snapshots and rendering results.
|
||||
* There can only be one activated version globally at a time.
|
||||
* Rollback is achieved by reactivating older versions.
|
||||
@@ -178,6 +181,19 @@ The database version number is defined in `openflare_server/model`, and it must
|
||||
|
||||
Every time the database version number is upgraded, an explicit migration method from the previous version to the new version must be added. The migration method must contain validation logic after the upgrade; only when the validation passes can the new database version record be written.
|
||||
|
||||
Versions 1 through 7 are treated as the historical initial baseline and no longer keep per-version upgrade files. Starting from v8, database migrations must be placed under `openflare_server/model/migrate` and named after the target version, such as `v16.go`. Each version file registers its migration through `init()`, and the current database version is derived from the highest registered target version. Do not change the semantics of released v8+ migrations merely to reorganize files.
|
||||
|
||||
When performing a database upgrade, complete the following steps:
|
||||
|
||||
1. Decide whether a schema version bump is required: any addition, removal, or rename of tables, columns, indexes, constraints, column types, sharding rules, or persisted-data semantics must upgrade the version.
|
||||
2. Add `openflare_server/model/migrate/vN.go`, where `N` is the target version. The file header must include a comment explaining what this upgrade changes and why it is needed.
|
||||
3. Implement `VN()` in `vN.go`, and call `Register(VN())` from `init()`. `FromVersion` must be `N-1`, and `ToVersion` must be `N`.
|
||||
4. Implement the upgrade logic in `migrateVN`. Use `Context` to call shared capabilities such as `ApplyCurrentSchema`, historical backfills, and default-data initialization; complex data repairs must be explicit and must not rely on `AutoMigrate` alone.
|
||||
5. Implement post-upgrade validation in `validateVN`. Validation must cover at least the existence of new tables/columns/indexes, required default data, and required data backfills.
|
||||
6. If the migration needs new shared backfill or validation helpers, place them in `openflare_server/model/migrations.go` or another suitable model file, and expose them through `Context` to `model/migrate`; avoid reverse-importing `model` from the subpackage and creating an import cycle.
|
||||
7. Add migration tests covering at least upgrade from the `N-1` old database to `N`, including schema version, table/column structure, key data backfills, and validation results. The `model/migrate` registry test checks version continuity, but business-specific migrations still require tests.
|
||||
8. Update design/development docs; if management APIs, configuration fields, or user-visible behavior change, also update the relevant guides, configuration reference, and Swagger documents.
|
||||
|
||||
After starting the new package, the database's current version must be checked first, and then upgraded step by step in order to the target version; skipping intermediate upgrade steps to directly write the target version is prohibited.
|
||||
|
||||
An empty database initialization can directly establish the current version structure, but the same-version validation must still be executed after the initialization is completed, and the current database version must be persisted.
|
||||
@@ -232,14 +248,18 @@ Version constraints:
|
||||
The Agent must satisfy:
|
||||
|
||||
* Read or generate local `node_id` after startup.
|
||||
* Periodic heartbeat and synchronization.
|
||||
* Periodic heartbeats and synchronization.
|
||||
* Conventional synchronization prioritizes judging based on the version summary returned by the heartbeat.
|
||||
* When WS connection upgrade is enabled and the connection is successful, the Agent can receive active version summaries via WS and immediately synchronize; WS failure or disconnection must fall back to HTTP heartbeats.
|
||||
* Back up old files first when discovering a new version.
|
||||
* Write main configurations, route configurations, and necessary certificate files.
|
||||
* Write WAF/PoW runtime configurations, and ensure WAF Lua resources are managed uniformly by the Agent.
|
||||
* Execute `openresty -t -c <main_config_path>` after writing the new configuration, and then reload; direct startup of OpenResty is allowed when reload finds that it is not running.
|
||||
* Periodic runtime health checks must not call `openresty -t`, preventing health probes from triggering synchronous upstream domain name resolutions; they should prioritize requesting `/openflare/stub_status` on the local `openresty_observability_port`, using HTTP `200 OK` as the basis for judging that the OpenResty main process and workers are serving.
|
||||
* If the activation of the new configuration fails, the Agent must first try to restore execution with the target configuration, then roll back to the old configuration and pull up OpenResty again.
|
||||
* Report warning when OpenResty recovers normally after rollback; report failure when it still cannot recover after rollback.
|
||||
* Once a target `version + checksum` fails to apply and rolls back, the Agent must block repeated applications of this target in its local state.
|
||||
* Report warning when OpenResty recovers normally after rollback; if there is no historical main configuration to restore locally, it must be allowed to write the built-in safe fallback configuration and pull up an OpenResty runtime state that only listens to port `80` externally and uniformly returns `503 Service Unavailable` and `OpenFlare: No Valid Configuration`, while retaining the local `stub_status` health check entry. The fallback runtime state must not clear the blocked status of the failed target; the application logs must reflect that the target version failed but the fallback runtime has started. Report failure when there is a historical main configuration but it still cannot recover after rollback.
|
||||
* Once a target `version + checksum` application fails and rolls back, the Agent must block repeated applications of this target in its local state.
|
||||
* When the Agent maintains the local MaxMind mmdb, download or refresh failures can only record warnings, and must not block heartbeats, synchronization, configuration application, or OpenResty health checks.
|
||||
|
||||
## Frontend Requests, State, and Types
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ OpenFlare is currently not positioned as a general-purpose log platform, service
|
||||
| Agent Synchronization | Supports registration, heartbeat, synchronization, application result reporting, and self-updating |
|
||||
| OpenResty Hosting | Manages main configuration templates, performance parameters, cache parameters, and Lua resources |
|
||||
| HTTPS/TLS | Hosts certificates and domain assets, and binds certificates on a per-domain basis |
|
||||
| WAF | Maintains IP/IP ranges black/whitelists and country-level geographical black/whitelists with global and website-customized rule groups |
|
||||
| Basic Observability | Aggregates node requests, resource snapshots, health events, and access analytics |
|
||||
| Node Management | Node status, token systems, deployment, and update links |
|
||||
| Console Frontend | Next.js-based official management console |
|
||||
@@ -76,6 +77,8 @@ Currently active entities:
|
||||
* `node_metric_snapshots`
|
||||
* `traffic_analytics_rollups`
|
||||
* `node_health_events`
|
||||
* `waf_rule_groups`
|
||||
* `waf_rule_group_bindings`
|
||||
|
||||
## Site Configuration Constraints
|
||||
|
||||
@@ -116,6 +119,24 @@ During publishing rendering:
|
||||
* Domains not bound to a certificate must not be automatically brought into HTTPS.
|
||||
* All domains in `proxy_routes.domains` must be included in the same site configuration to avoid the same site being split in version snapshots.
|
||||
|
||||
## WAF Constraints
|
||||
|
||||
WAF uses rule groups as configuration boundaries. The system fixes a global rule group, which is applied to all websites by default; websites can overlay multiple custom rule groups.
|
||||
|
||||
Phase 1 supports:
|
||||
|
||||
* IP / IP range whitelists and blacklists.
|
||||
* Country-level region whitelists and blacklists.
|
||||
* Rule group-level blocking status codes and response pages, defaulting to `418` and an empty page.
|
||||
|
||||
Evaluation order:
|
||||
|
||||
* Whitelists are bypass exceptions; if any enabled rule group matches a whitelist, the request is allowed.
|
||||
* If no whitelist is matched, blacklists continue to be evaluated.
|
||||
* When multiple blacklists match, the global rule group takes precedence, followed by custom rule groups in ascending order of their IDs.
|
||||
|
||||
Region recognition is based on the MaxMind mmdb maintained locally on the node by the Agent, and the OpenResty Lua reads the local database during the request path. When GeoIP dependencies are unavailable, region rules must be skipped, without affecting IP rules and the reverse proxy main link.
|
||||
|
||||
## Authentication Source Constraints
|
||||
|
||||
`auth_sources` is the configuration object for third-party login entries on the management console, currently supporting only two types: `github` and `oidc`. Enabled authentication sources will be displayed on the login page.
|
||||
|
||||
@@ -15,13 +15,14 @@ Modify rules -> Preview / View diff -> Publish -> Generate complete configuratio
|
||||
When publishing, the Server must:
|
||||
|
||||
1. Read all enabled `proxy_routes`.
|
||||
2. Read the OpenResty main configuration template, performance parameters, cache parameters, and necessary Lua resources on the Server side.
|
||||
2. Read the Server side OpenResty main configuration template, performance parameters, cache parameters, and necessary Lua resources.
|
||||
3. Read domain and certificate binding relationships.
|
||||
4. Render the complete OpenResty configuration.
|
||||
5. Calculate the `checksum`.
|
||||
6. Write to `config_versions`.
|
||||
7. Switch the activated version.
|
||||
8. Let the Agent discover and apply it in subsequent heartbeats.
|
||||
4. Read the WAF global rule group, custom rule groups, and website binding relationships.
|
||||
5. Render the complete OpenResty configuration and WAF runtime configuration.
|
||||
6. Calculate the `checksum`.
|
||||
7. Write to `config_versions`.
|
||||
8. Switch the activated version.
|
||||
9. Let the Agent discover and apply it in subsequent heartbeats.
|
||||
|
||||
The version number format is fixed as `YYYYMMDD-NNN`.
|
||||
|
||||
@@ -33,9 +34,9 @@ Publishing generates a new complete configuration version. The version must cont
|
||||
|
||||
## Activating Version
|
||||
|
||||
There can only be one activated version globally at a time.Differentiated versions grouped by nodes are currently not supported.
|
||||
There can only be one activated version globally at a time. Differentiated versions grouped by nodes are currently not supported.
|
||||
|
||||
The Agent obtains the activated version summary through the heartbeat; only when the remote version or checksum is inconsistent with the local state does the Agent enter the synchronization flow.
|
||||
The Agent obtains the activated version summary through the heartbeat; only when the remote version or checksum is inconsistent with the local state does the Agent enter the synchronization flow. When Agent WS connection upgrade is enabled and the connection is available, the Server will broadcast the latest active version summary after successfully publishing or activating a version. Upon receiving it, the Agent immediately pulls and applies the configuration using the ordinary synchronization flow. When WS is unavailable, changes are still discovered at HTTP heartbeat intervals.
|
||||
|
||||
## Immutable History
|
||||
|
||||
@@ -53,12 +54,12 @@ When discovering a new version, the Agent will:
|
||||
|
||||
1. Pull the details of the target version.
|
||||
2. Back up old files.
|
||||
3. Write the main configuration, route configurations, certificates, and necessary Lua resources.
|
||||
3. Write the main configuration, route configurations, certificates, necessary Lua resources, and WAF/PoW runtime configurations.
|
||||
4. Execute OpenResty configuration verification.
|
||||
5. reload; if it is not started during runtime, try to start OpenResty with the current configuration.
|
||||
6. Report success, warning, or failure.
|
||||
|
||||
If the activation of the new configuration fails, the Agent must try to restore execution; report a warning when the rollback succeeds, and report a failure when it still cannot recover after rollback.
|
||||
If the activation of the new configuration fails, the Agent must try to restore execution; report a warning when the rollback succeeds. If there is no historical main configuration to roll back to locally, the Agent will write the built-in safe fallback configuration and try to pull up OpenResty: this configuration only listens to port `80` externally, contains no user routes, uniformly returns `503 Service Unavailable` and `OpenFlare: No Valid Configuration`, and retains the local `stub_status` health check entry. If fallback startup is successful, it still blocks the failed target version and reports a warning; report a failure when there is a historical main configuration but it still cannot recover after rollback.
|
||||
|
||||
Once a target `version + checksum` application fails and rolls back, the Agent will block repeated applications of this target in its local state. Only when the remote activated version or checksum changes is it allowed to try again.
|
||||
|
||||
@@ -69,3 +70,4 @@ Once a target `version + checksum` application fails and rolls back, the Agent w
|
||||
* The Agent API is fixed to use the node-exclusive `agent_token`; the first access can use the `discovery_token`.
|
||||
* The Server does not provide remote shell or arbitrary command execution entries.
|
||||
* The configuration version must save complete snapshots, rendering results, and `checksum`.
|
||||
* WAF rule groups and website binding relationships must enter the snapshot and checksum along with the complete configuration version, and must not rely on the current mutable WAF configuration when rolling back.
|
||||
|
||||
@@ -49,6 +49,8 @@ Agent self-update requires the GitHub Release to include both the target binary
|
||||
## 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 \
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Deployment
|
||||
|
||||
You will learn the recommended OpenFlare deployment model, Server and Agent requirements, source startup workflow, integration steps, upgrade paths, and uninstall entry points.
|
||||
You will learn: The recommended OpenFlare deployment model, Server and Agent requirements, source startup workflow, integration steps, upgrade paths, and uninstall entry points.
|
||||
|
||||
For production, use PostgreSQL for the Server database and set `SESSION_SECRET` explicitly. Agent controls OpenResty through the OpenResty binary; Docker deployments run the Agent image that already includes OpenResty.
|
||||
For production, use PostgreSQL for the Server database and set `SESSION_SECRET` explicitly. The recommended deployment method for the Agent is Docker deployment (i.e., running the Agent image that already includes OpenResty); it also supports shell-script installation or running manually.
|
||||
|
||||
## Topology
|
||||
|
||||
@@ -40,9 +40,10 @@ Agent:
|
||||
| --- | --- |
|
||||
| OS | Install script supports Linux and macOS. systemd service is created only on Linux + systemd. |
|
||||
| Architecture | `amd64` or `arm64` |
|
||||
| OpenResty | Required for local Agent installs |
|
||||
| OpenResty | Required for local Agent installs, or specified via `--openresty-path` |
|
||||
| Docker | Required only when running the Agent Docker image |
|
||||
| Network | Agent node must reach the Server URL |
|
||||
| GeoIP | WAF regional rules use local MaxMind mmdb; Agent initializes a built-in library and updates it periodically |
|
||||
|
||||
[Needs confirmation: recommended production CPU, memory, and disk size]
|
||||
|
||||
@@ -128,7 +129,37 @@ Default port is `3000`. You can also set it explicitly:
|
||||
go run . --port 3000 --log-dir ./logs
|
||||
```
|
||||
|
||||
## Connect Agent
|
||||
## Run Agent in Docker (Recommended)
|
||||
|
||||
Docker deployment is the recommended deployment method for the Agent. In Docker deployments, directly run the Agent image. This image is built on top of the OpenResty image and includes both the Agent controller and the OpenResty binary. When `node_ip` is not explicitly configured, the Agent prioritizes obtaining the real public egress IP via a third-party API, avoiding registering the Docker bridge address as the node IP.
|
||||
|
||||
Mounting the configuration file:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/rain-kl/openflare-agent:latest
|
||||
docker rm -f openflare-agent 2>/dev/null || true
|
||||
docker run -d --name openflare-agent --restart unless-stopped \
|
||||
-p 80:80 -p 443:443 \
|
||||
-v openflare-agent-data:/data \
|
||||
-v ./agent.json:/etc/openflare/agent.json:ro \
|
||||
ghcr.io/rain-kl/openflare-agent:latest
|
||||
```
|
||||
|
||||
Using environment variables:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/rain-kl/openflare-agent:latest
|
||||
docker rm -f openflare-agent 2>/dev/null || true
|
||||
docker run -d --name openflare-agent --restart unless-stopped \
|
||||
-p 80:80 -p 443:443 \
|
||||
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
|
||||
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
|
||||
ghcr.io/rain-kl/openflare-agent:latest
|
||||
```
|
||||
|
||||
## Connect Agent (Script Installation)
|
||||
|
||||
In addition to Docker deployment, you can also deploy the Agent on the local host using our installation script.
|
||||
|
||||
With `discovery_token`:
|
||||
|
||||
@@ -199,6 +230,10 @@ Minimal `agent.json`:
|
||||
|
||||
When `openresty_path` is not configured, Agent runs `openresty`.
|
||||
|
||||
By default, the Agent will attempt to upgrade to a WebSocket after a successful HTTP heartbeat. When the upgrade succeeds, the Server immediately notifies the Agent of any configuration publications or activations; if the WebSocket cannot be established or is unexpectedly disconnected, the Agent automatically falls back to HTTP heartbeat synchronization.
|
||||
|
||||
WAF regional rules rely on the Agent's local `GeoLite2-Country.mmdb`. Upon startup, the Agent initializes a built-in database at `data_dir/etc/openflare/GeoLite2-Country.mmdb` and attempts to update it periodically based on configuration; update failures only record warnings, and do not affect configuration synchronization or OpenResty reload.
|
||||
|
||||
## Minimal Integration Flow
|
||||
|
||||
1. Start Server and sign in.
|
||||
@@ -220,6 +255,7 @@ Server:
|
||||
Agent:
|
||||
|
||||
* Agents follow stable releases by default.
|
||||
* Agent autoupdate requires the GitHub Release to include both the target binary and a matching `.sha256` checksum file; the download must pass SHA-256 validation before the local executable is replaced.
|
||||
* The install script can be rerun to reinstall or upgrade.
|
||||
* Preview upgrades require manual action.
|
||||
|
||||
|
||||
@@ -102,7 +102,26 @@ Prepare one of them in the management UI before continuing.
|
||||
|
||||
[Needs confirmation: exact UI menu path for creating or viewing `discovery_token` and node `agent_token`]
|
||||
|
||||
## 3. Install Agent
|
||||
## 3. Install/Run Agent
|
||||
|
||||
The recommended deployment method for the Agent is Docker deployment (i.e., running the Agent image that already includes OpenResty); it also supports shell-script installation on the local host.
|
||||
|
||||
### Option A: Run Agent in Docker (Recommended)
|
||||
|
||||
Run the Agent Docker image on the proxy node:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/rain-kl/openflare-agent:latest
|
||||
docker rm -f openflare-agent 2>/dev/null || true
|
||||
docker run -d --name openflare-agent --restart unless-stopped \
|
||||
-p 80:80 -p 443:443 \
|
||||
-v openflare-agent-data:/data \
|
||||
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
|
||||
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
|
||||
ghcr.io/rain-kl/openflare-agent:latest
|
||||
```
|
||||
|
||||
### Option B: Run the Installation Script (Local Host)
|
||||
|
||||
Run the install script on the proxy node.
|
||||
|
||||
|
||||
@@ -74,6 +74,16 @@ HTTPS is bound per domain, not forced for the whole site.
|
||||
|
||||
If a site contains multiple domains, the Server groups HTTPS output by certificate while keeping all domains in the same site snapshot.
|
||||
|
||||
## Configure WAF and PoW
|
||||
|
||||
Security controls are managed from the **WAF** sidebar entry:
|
||||
|
||||
* The WAF page manages the global rule group and custom rule groups. The global rule group always applies to every site. Custom rule groups can be applied to selected sites from the rule group drawer or bound from the site detail `WAF` section.
|
||||
* `PoW` is a tab inside the selected rule group, between `Allow / Block Lists` and `Block Response`. It reuses the existing per-site PoW execution logic and can apply the current PoW policy to every site or the sites bound to the current rule group.
|
||||
* Site details no longer edit PoW directly. They show the always-on global WAF group and let you bind custom WAF rule groups. PoW rule content and scope should be maintained from the WAF page.
|
||||
|
||||
After changing WAF or PoW settings, publish and activate a new configuration version so Agents can apply the updated OpenResty runtime.
|
||||
|
||||
## Release, Activate, and Roll Back
|
||||
|
||||
Standard flow:
|
||||
@@ -96,6 +106,8 @@ Node pages answer three questions:
|
||||
| Which version is running? | Current version on the node detail page |
|
||||
| Did the last apply succeed? | Apply logs |
|
||||
|
||||
Node IPs are filled automatically by Agent registration and subsequent heartbeats by default. When you enter or change an IP in the admin UI, the node editor enables "Lock node IP" by default; Agent reports will not overwrite the IP while the lock is enabled. After unlocking, the next Agent heartbeat or WebSocket status report can update it again.
|
||||
|
||||
Access analytics and resource snapshots provide basic observability. OpenFlare only keeps access details for a controlled time window; it is not a general-purpose log platform. Use a dedicated logging system for long-term log search.
|
||||
|
||||
## Common Scenarios
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
# Agent Unified OpenResty Binary Control Scheme
|
||||
|
||||
## Summary
|
||||
|
||||
Unify the Agent running model as "write to the managed configuration file, then call the `openresty` binary to execute `-t`, reload, start/restart". Docker deployment no longer has the Agent control another OpenResty container, but instead provides an independent `ghcr.io/rain-kl/openflare-agent` image; this image is based on `openresty/openresty`, with the Agent controller and OpenResty binary built-in.
|
||||
|
||||
## Key Changes
|
||||
|
||||
- Agent runtime:
|
||||
- Remove the DockerExecutor / Docker container management logic from the production path.
|
||||
- Default to using `openresty` when `openresty_path` is not configured.
|
||||
- Uniformly execute binary calls with `-c <main_config_path>` to avoid misreading the OpenResty default configuration.
|
||||
- The apply flow is: backup -> write files -> `openresty -t -c ...` -> reload; if reload indicates it is not running, then start.
|
||||
- restart uses `openresty -c ... -s quit` followed by `openresty -c ...` to start, keeping fault tolerance for missing PIDs.
|
||||
|
||||
- Configurations and File Responsibilities:
|
||||
- Keep parser compatibility for old fields `openresty_container_name`, `openresty_docker_image`, and `docker_binary`, but mark them as deprecated and no longer involved in control logic.
|
||||
- Add `access_log_path`, defaulting to `data_dir/var/log/openflare/access.log`, and no longer placing access logs inside `conf.d`.
|
||||
- Add `runtime_config_dir`, defaulting to `data_dir/etc/openflare`, where `pow_config.json` is written.
|
||||
- `cert_dir` only writes certificate/key files; `lua_dir` only writes Lua code and static resources.
|
||||
- Support splitting files before writing: certificate files go into `cert_dir`, and `pow_config.json` goes into `runtime_config_dir`.
|
||||
|
||||
- Docker Agent Image:
|
||||
- Add `openflare_agent/Dockerfile`, with the runtime image based on `openresty/openresty:alpine`.
|
||||
- Defaults to `OPENFLARE_OPENRESTY_PATH=openresty` and `OPENFLARE_DATA_DIR=/data`.
|
||||
- Expose `80`, `443`, and `18081`.
|
||||
- Support mounting `/etc/openflare/agent.json`, and also support environment variable configurations.
|
||||
- CI publishes independent multi-architecture images: `ghcr.io/rain-kl/openflare-agent:<version>` and `latest`.
|
||||
|
||||
- Agent Configuration Entry:
|
||||
- Keep `-config` + `agent.json`.
|
||||
- Add environment variable overrides/fallbacks: `OPENFLARE_SERVER_URL`, `OPENFLARE_AGENT_TOKEN`, `OPENFLARE_DISCOVERY_TOKEN`, `OPENFLARE_NODE_NAME`, `OPENFLARE_NODE_IP`, `OPENFLARE_DATA_DIR`, `OPENFLARE_OPENRESTY_PATH`, `OPENFLARE_HEARTBEAT_INTERVAL`, `OPENFLARE_REQUEST_TIMEOUT`, `OPENFLARE_OPENRESTY_OBSERVABILITY_PORT`.
|
||||
- If the configuration file does not exist but environment variables are sufficient, the Agent can start directly; if both exist, environment variables override file values.
|
||||
|
||||
- Scripts and Documentation:
|
||||
- `install-agent.sh` becomes a local OpenResty deployment script, adding `--openresty-path` and automatically finding `openresty` when not passed.
|
||||
- `uninstall-agent.sh` only uninstalls the Agent itself, and no longer deletes the Docker OpenResty container or image.
|
||||
- Update architecture, development constraints, deployment instructions, Agent guide, configuration item reference, README, and old Docker control descriptions in English image documents.
|
||||
|
||||
## Public Interfaces
|
||||
|
||||
- Add Agent configuration fields:
|
||||
- `access_log_path`
|
||||
- `runtime_config_dir`
|
||||
|
||||
- Deprecated but compatibly read:
|
||||
- `openresty_container_name`
|
||||
- `openresty_docker_image`
|
||||
- `docker_binary`
|
||||
|
||||
- Add Docker image:
|
||||
- `ghcr.io/rain-kl/openflare-agent`
|
||||
|
||||
- Target Docker execution method examples:
|
||||
- Mount configuration file: `-v ./agent.json:/etc/openflare/agent.json`
|
||||
- Or environment variables: `-e OPENFLARE_SERVER_URL=... -e OPENFLARE_AGENT_TOKEN=...`
|
||||
|
||||
## Test Plan
|
||||
|
||||
- `openflare_agent/internal/config`:
|
||||
- Default `openresty_path` is `openresty`.
|
||||
- Old Docker fields can be read but do not affect the executor.
|
||||
- Environment variables can start the Agent without a configuration file and can override the configuration file.
|
||||
- New default paths conform to responsibility boundaries.
|
||||
|
||||
- `openflare_agent/internal/nginx`:
|
||||
- Binary commands all include `-c <main_config_path>`.
|
||||
- Apply success, reload failure rollback, and start fallback when not running.
|
||||
- `pow_config.json` is no longer written to `cert_dir` or `lua_dir`.
|
||||
- Stale `cert_dir/pow_config.json` and `lua_dir/pow_config.json` will be cleaned up.
|
||||
- access log is rendered to `access_log_path`.
|
||||
- checksum can still uniformly include the main config, route config, certificates, and PoW config into comparisons.
|
||||
|
||||
- Integration Regression:
|
||||
- `cd openflare_agent && GOCACHE=/tmp/openflare-go-cache go test ./...`
|
||||
- `cd openflare_server && GOCACHE=/tmp/openflare-go-cache go test ./...`
|
||||
- Dockerfile build smoke test: build the Agent image and start it using env-only configuration to the executable stage.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Docker Agent image name is fixed as `ghcr.io/rain-kl/openflare-agent`.
|
||||
- Old Docker control fields are compatibly preserved but are no longer a supported behavior.
|
||||
- This phase does not modify Server APIs, does not modify database models, and does not introduce remote command capabilities.
|
||||
- The OpenResty main configuration template continues to be generated by the Server; the Agent is only responsible for local path replacement, file landing, and binary control.
|
||||
@@ -1,78 +1,259 @@
|
||||
# Configuration
|
||||
# Configuration Reference
|
||||
|
||||
You will learn: What configuration sources are supported by OpenFlare Server, frontend builds, and Agents, what the default values of configuration items are, and how common deployment combinations should be configured.
|
||||
|
||||
This document summarizes the Server and Agent configuration items supported by OpenFlare `1.0.0`, retaining only the startup, deployment, and runtime parameters that remain valid.
|
||||
|
||||
## Configuration Sources
|
||||
|
||||
The Server supports three types of configuration sources:
|
||||
|
||||
1. Command-line parameters.
|
||||
2. Environment variables.
|
||||
3. Runtime configurations in the database `Option` table.
|
||||
|
||||
The Agent supports:
|
||||
|
||||
1. `-config` command-line parameter.
|
||||
2. `agent.json` configuration file.
|
||||
3. A few log-related environment variables.
|
||||
|
||||
## Configuration File Locations
|
||||
|
||||
| Component | Default Location | Description |
|
||||
| --- | --- | --- |
|
||||
| Server SQLite | `openflare.db` | Can be modified via `SQLITE_PATH` |
|
||||
| Agent Configuration File | `./agent.json` | Can be specified via `-config` |
|
||||
| One-click Install Agent Config | `/opt/openflare-agent/agent.json` | Default generated by the installation script |
|
||||
| Agent Data Directory | `data` under the config directory | Can be modified via `data_dir` |
|
||||
|
||||
## Server CLI Flags
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
go run . --port 3000 --log-dir ./logs
|
||||
```
|
||||
|
||||
| Flag | Purpose | Default |
|
||||
| --- | --- | --- |
|
||||
| `--port` | Server listen port | `3000` |
|
||||
| `--log-dir` | Log directory | empty |
|
||||
| `--version` | Print version and exit | `false` |
|
||||
| `--help` | Print help and exit | `false` |
|
||||
| `--port` | Specify the port the Server listens on | `3000` |
|
||||
| `--log-dir` | Specify the log directory | empty |
|
||||
| `--version` | Print the current version and exit | `false` |
|
||||
| `--help` | Print help information and exit | `false` |
|
||||
|
||||
## Server Environment Variables
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
| --- | --- | --- |
|
||||
| `PORT` | Server listen port | `3000` |
|
||||
| `GIN_MODE` | Gin mode | release unless `debug` |
|
||||
| `GIN_MODE` | Gin execution mode | `release` unless `debug` |
|
||||
| `LOG_LEVEL` | Log level | `info` |
|
||||
| `SESSION_SECRET` | Session signing secret | random on startup |
|
||||
| `SQLITE_PATH` | SQLite database path | `openflare.db` |
|
||||
| `DSN` | PostgreSQL DSN, preferred over SQLite | empty |
|
||||
| `SESSION_SECRET` | Session signing secret | randomly generated on startup |
|
||||
| `SQLITE_PATH` | SQLite database file path | `openflare.db` |
|
||||
| `DSN` | PostgreSQL DSN, preferred over SQLite when set | empty |
|
||||
| `SQL_DSN` | Legacy PostgreSQL DSN, lower priority than `DSN` | empty |
|
||||
| `REDIS_CONN_STRING` | Redis connection string | empty |
|
||||
| `UPLOAD_PATH` | Upload directory | `upload` |
|
||||
| `AGENT_TOKEN` | Legacy global Agent token | empty |
|
||||
|
||||
When `DSN` and `SQL_DSN` both exist, `DSN` wins. PostgreSQL is preferred when configured. If PostgreSQL is empty and a local SQLite file exists, Server migrates SQLite data at startup.
|
||||
Description:
|
||||
|
||||
* When both `DSN` and `SQL_DSN` exist, `DSN` takes precedence.
|
||||
* When `DSN`/`SQL_DSN` and `SQLITE_PATH` exist simultaneously, PostgreSQL takes precedence.
|
||||
* When the target PostgreSQL database is empty and a local SQLite file exists at `SQLITE_PATH`, the Server automatically migrates SQLite data at startup and prints table-by-table migration progress in the logs.
|
||||
* `SESSION_SECRET` must be explicitly configured in production.
|
||||
* When `REDIS_CONN_STRING` is not configured, related capabilities fall back to in-process implementations.
|
||||
|
||||
## Runtime Options
|
||||
|
||||
The following options are maintained on the settings page of the management console and can be hot-updated:
|
||||
|
||||
| Option | Purpose | Default |
|
||||
| --- | --- | --- |
|
||||
| `AgentHeartbeatInterval` | Agent heartbeat interval (milliseconds) | `10000` |
|
||||
| `AgentWebsocketUpgradeEnabled` | Whether to allow Agents to upgrade to WebSockets after successful HTTP heartbeats | `true` |
|
||||
| `NodeOfflineThreshold` | Node offline threshold (milliseconds) | `120000` |
|
||||
| `AgentUpdateRepo` | Agent self-update repository | `Rain-kl/OpenFlare` |
|
||||
| `GeoIPProvider` | Node/IP region lookup provider | `ipinfo` |
|
||||
| `DatabaseAutoCleanupEnabled` | Whether to enable daily automatic cleanup of observability data | `false` |
|
||||
| `DatabaseAutoCleanupRetentionDays` | In-database retention days, at least 1 day | `30` |
|
||||
| `GlobalApiRateLimitNum` / `GlobalApiRateLimitDuration` | Global API rate limit count / window | `300` / `180` |
|
||||
| `GlobalWebRateLimitNum` / `GlobalWebRateLimitDuration` | Global Web rate limit count / window | `300` / `180` |
|
||||
| `CriticalRateLimitNum` / `CriticalRateLimitDuration` | Sensitive API rate limit count / window | `100` / `1200` |
|
||||
|
||||
Description:
|
||||
|
||||
* When `DatabaseAutoCleanupEnabled` is enabled, the Server automatically cleans up three types of observability data (`node_access_logs`, `node_metric_snapshots`, `node_request_reports`) at 3:00 AM every day.
|
||||
* `DatabaseAutoCleanupRetentionDays` is the unified retention count and must be greater than or equal to 1.
|
||||
* The management console supports leaving the retention days blank during manual cleanup to directly delete all history of the corresponding datasets.
|
||||
* The GitHub Release pointed to by `AgentUpdateRepo` must provide a matching `.sha256` checksum file for each Agent binary, such as `openflare-agent-linux-amd64.sha256`; the self-update validates this SHA-256 digest before replacing the executable.
|
||||
* Third-party logins no longer use `GitHubOAuthEnabled`, `GitHubClientId`, or `GitHubClientSecret` as primary configuration entries; these legacy options are only used for migration to the default GitHub authentication source during upgrades.
|
||||
* Legacy options for WeChat login are retained for compatibility, but the management console no longer provides WeChat login configuration entries.
|
||||
* Legacy options for Turnstile and backend verification remain, and existing configurations will continue to take effect.
|
||||
|
||||
## OpenResty Parameters
|
||||
|
||||
OpenResty performance and caching parameters continue to be stored uniformly in the `Option` table. Currently common items include:
|
||||
|
||||
* `OpenRestyWorkerProcesses`
|
||||
* `OpenRestyWorkerConnections`
|
||||
* `OpenRestyWorkerRlimitNofile`
|
||||
* `OpenRestyKeepaliveTimeout`
|
||||
* `OpenRestyProxyConnectTimeout`
|
||||
* `OpenRestyProxySendTimeout`
|
||||
* `OpenRestyProxyReadTimeout`
|
||||
* `OpenRestyProxyBufferingEnabled`
|
||||
* `OpenRestyGzipEnabled`
|
||||
* `OpenRestyCacheEnabled`
|
||||
* `OpenRestyCachePath`
|
||||
* `OpenRestyCacheMaxSize`
|
||||
|
||||
These parameters must be validated, saved, and participate in version rendering in a structured way.
|
||||
|
||||
Constraints:
|
||||
|
||||
* The management console no longer exposes `resolver` configuration.
|
||||
* Upstreams are uniformly rendered as named `upstream` blocks with keepalives enabled.
|
||||
* A single upstream carrying a base path or query will append the original URI in `proxy_pass`.
|
||||
* Multiple upstreams still require each upstream to be pure `scheme://host[:port]`, and the protocol must be consistent within the same rule.
|
||||
* `OpenRestyCacheEnabled` is used to enable the caching infrastructure and global default parameters; the actual caching enablement and hit policies (based on URL, suffix, or path) are decided separately by each individual `proxy_routes`.
|
||||
* The default cache key is `$scheme$host$request_uri`.
|
||||
* The default `keepalive_timeout` is `20` seconds, and the default `proxy_connect_timeout` is `3` seconds.
|
||||
* The default event model is `epoll`, and `multi_accept` is enabled by default.
|
||||
* HTTPS listeners use the independent `http2 on;` directive by default to avoid deprecation warnings for `listen ... http2` in newer Nginx/OpenResty versions.
|
||||
|
||||
## Frontend Build Variables
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
| --- | --- | --- |
|
||||
| `NEXT_PUBLIC_API_BASE_URL` | Frontend API base path | `/api` |
|
||||
| `NEXT_PUBLIC_APP_VERSION` | Displayed frontend version | `dev` |
|
||||
| `NEXT_DEV_BACKEND_URL` | Local dev backend proxy target | `http://127.0.0.1:3000` |
|
||||
| `NEXT_PUBLIC_API_BASE_URL` | Frontend API request base path | `/api` |
|
||||
| `NEXT_PUBLIC_APP_VERSION` | Frontend displayed version number | `dev` |
|
||||
| `NEXT_DEV_BACKEND_URL` | Dev backend proxy target | `http://127.0.0.1:3000` |
|
||||
|
||||
## Runtime Options
|
||||
## Agent Environment Variables
|
||||
|
||||
The settings page maintains these hot-updatable options:
|
||||
|
||||
| Option | Purpose | Default |
|
||||
| Variable | Purpose | Default |
|
||||
| --- | --- | --- |
|
||||
| `AgentHeartbeatInterval` | Agent heartbeat interval in milliseconds | `10000` |
|
||||
| `NodeOfflineThreshold` | Node offline threshold in milliseconds | `120000` |
|
||||
| `AgentUpdateRepo` | Agent update repository | `Rain-kl/OpenFlare` |
|
||||
| `GeoIPProvider` | Node/IP region provider | `ipinfo` |
|
||||
| `DatabaseAutoCleanupEnabled` | Enable daily observability cleanup | `false` |
|
||||
| `DatabaseAutoCleanupRetentionDays` | Retention days | `30` |
|
||||
| `LOG_LEVEL` | Agent log level | `info` |
|
||||
| `OPENFLARE_SERVER_URL` | Control plane URL, can override `agent.json` | empty |
|
||||
| `OPENFLARE_AGENT_TOKEN` | Node-exclusive auth token, can override `agent.json` | empty |
|
||||
| `OPENFLARE_DISCOVERY_TOKEN` | Global token for first registration, can override `agent.json` | empty |
|
||||
| `OPENFLARE_NODE_NAME` | Node name, can override `agent.json` | empty |
|
||||
| `OPENFLARE_NODE_IP` | Node IP, can override `agent.json` | empty |
|
||||
| `OPENFLARE_DATA_DIR` | Agent data directory, can override `agent.json` | empty |
|
||||
| `OPENFLARE_OPENRESTY_PATH` | OpenResty binary path, can override `agent.json` | empty |
|
||||
| `OPENFLARE_HEARTBEAT_INTERVAL` | Heartbeat interval, can override `agent.json` | empty |
|
||||
| `OPENFLARE_REQUEST_TIMEOUT` | Request timeout, can override `agent.json` | empty |
|
||||
| `OPENFLARE_OPENRESTY_OBSERVABILITY_PORT` | Local observability port, can override `agent.json` | empty |
|
||||
| `OPENFLARE_MMDB_PATH` | WAF GeoIP mmdb path, can override `agent.json` | empty |
|
||||
| `OPENFLARE_MMDB_UPDATE_INTERVAL` | WAF GeoIP mmdb update interval, can override `agent.json` | empty |
|
||||
| `OPENFLARE_MMDB_DOWNLOAD_URL` | WAF GeoIP mmdb download URL, can override `agent.json` | empty |
|
||||
|
||||
OpenResty performance and cache options are also stored in the Option table, including `OpenRestyWorkerProcesses`, `OpenRestyWorkerConnections`, `OpenRestyProxyConnectTimeout`, `OpenRestyProxyReadTimeout`, `OpenRestyCacheEnabled`, `OpenRestyCachePath`, and `OpenRestyCacheMaxSize`.
|
||||
## Agent CLI Flags
|
||||
|
||||
`AgentUpdateRepo` releases must publish a matching `.sha256` file for each Agent binary, such as `openflare-agent-linux-amd64.sha256`. Agent self-update verifies the SHA-256 digest before replacing the executable.
|
||||
| Flag | Purpose | Default |
|
||||
| --- | --- | --- |
|
||||
| `-config` | Specify the path to the Agent configuration file | `./agent.json` |
|
||||
|
||||
## Agent Configuration
|
||||
## Agent Configuration Fields
|
||||
|
||||
Agent supports the `-config` CLI flag, an `agent.json` file, and the `LOG_LEVEL` environment variable.
|
||||
|
||||
| Field | Purpose | Required | Default / behavior |
|
||||
| Field | Purpose | Required | Default / Behavior |
|
||||
| --- | --- | --- | --- |
|
||||
| `server_url` | Control plane URL | yes | none |
|
||||
| `agent_token` | Node-specific auth token | one of `agent_token` / `discovery_token` | empty |
|
||||
| `discovery_token` | Global token for first registration | one of `agent_token` / `discovery_token` | empty |
|
||||
| `node_name` | Node name | no | host name |
|
||||
| `node_ip` | Node IP | no | auto-detected; Agent first queries the public egress IP through a third-party API, then falls back to local interfaces |
|
||||
| `agent_token` | Node-exclusive auth token | one of `agent_token`/`discovery_token` | empty |
|
||||
| `discovery_token` | Global token for first registration | one of `agent_token`/`discovery_token` | empty |
|
||||
| `node_name` | Node name | no | automatically uses host name |
|
||||
| `node_ip` | Node IP | no | auto-detected; prioritizes obtaining the real public egress IP via third-party APIs, falling back to local interfaces on failure |
|
||||
| `openresty_path` | OpenResty binary path | no | `openresty` |
|
||||
| `openresty_container_name` | Deprecated Docker-control field, read for compatibility only | no | empty |
|
||||
| `openresty_docker_image` | Deprecated Docker-control field, read for compatibility only | no | empty |
|
||||
| `openresty_observability_port` | Local observability and OpenResty health-check port | no | `18081` |
|
||||
| `docker_binary` | Deprecated Docker-control field, read for compatibility only | no | empty |
|
||||
| `data_dir` | Agent data directory | no | `data` under config directory |
|
||||
| `data_dir` | Agent data directory | no | `data` under the config file directory |
|
||||
| `main_config_path` | OpenResty main config write path | no | `data_dir/etc/nginx/nginx.conf` |
|
||||
| `route_config_path` | Route config write path | no | `data_dir/etc/nginx/conf.d/openflare_routes.conf` |
|
||||
| `access_log_path` | OpenResty access log path | no | `data_dir/var/log/openflare/access.log` |
|
||||
| `runtime_config_dir` | Runtime config directory, including `pow_config.json` | no | `data_dir/etc/openflare` |
|
||||
| `heartbeat_interval` | Heartbeat interval | no | `10000` ms |
|
||||
| `request_timeout` | HTTP timeout | no | `10000` ms |
|
||||
| `cert_dir` | Certificate write directory | no | `data_dir/etc/nginx/certs` |
|
||||
| `openresty_cert_dir` | Certificate read directory in OpenResty config | no | same as `cert_dir` |
|
||||
| `lua_dir` | Lua scripts and static resources write directory | no | `data_dir/etc/nginx/lua` |
|
||||
| `openresty_lua_dir` | Lua read directory in OpenResty config | no | same as `lua_dir` |
|
||||
| `runtime_config_dir` | Agent runtime config write directory, e.g., `pow_config.json` | no | `data_dir/etc/openflare` |
|
||||
| `mmdb_path` | WAF GeoIP mmdb file path | no | `data_dir/etc/openflare/GeoLite2-Country.mmdb` |
|
||||
| `mmdb_update_interval` | WAF GeoIP mmdb update interval | no | `86400000` milliseconds |
|
||||
| `mmdb_download_url` | WAF GeoIP mmdb download URL | no | built-in GeoLite2 Country download URL |
|
||||
| `observability_buffer_path` | Observability buffering file path | no | `data_dir/var/lib/openflare/observability-buffer.json` |
|
||||
| `observability_replay_minutes` | Minutes to automatically replay recent observability data | no | `15` |
|
||||
| `state_path` | Agent local state file path | no | `data_dir/var/lib/openflare/agent-state.json` |
|
||||
| `heartbeat_interval` | Heartbeat interval | no | `10000` milliseconds |
|
||||
| `request_timeout` | HTTP request timeout | no | `10000` milliseconds |
|
||||
|
||||
`heartbeat_interval` and `request_timeout` accept milliseconds or Go duration strings.
|
||||
Description:
|
||||
|
||||
When `node_ip` is not configured, Agent first queries `https://realip.cc` for the real public egress IP, which avoids recording a Docker bridge address in container deployments. If that lookup fails, Agent falls back to local interface detection and prefers a public IPv4 address.
|
||||
* `agent_token` and `discovery_token` cannot both be empty.
|
||||
* `heartbeat_interval` and `request_timeout` support integer milliseconds or Go duration strings.
|
||||
* When the Server runtime option `AgentWebsocketUpgradeEnabled` is enabled, the Agent will attempt to upgrade to a WebSocket after a successful HTTP heartbeat; it automatically falls back to HTTP heartbeats when connection fails or is disconnected.
|
||||
* When `openresty_path` is not configured, `openresty` is called by default.
|
||||
* The Agent's periodic health checks request `http://127.0.0.1:<openresty_observability_port>/openflare/stub_status`, no longer judging runtime health via high-frequency `openresty -t`; validation before configuration application, startup recovery, and reloads will still execute `openresty -t -c <main_config_path>`.
|
||||
* The Agent initializes and periodically updates `mmdb_path` for OpenResty WAF Lua to execute country-level geographical rules; update failures only record warnings, and do not block sync or reloads.
|
||||
* If `agent.json` does not exist but environment variables such as `OPENFLARE_SERVER_URL` and tokens are sufficient, the Agent can start directly; environment variables take precedence when both exist.
|
||||
* When the Agent is not configured with `node_ip`, it first queries `https://realip.cc` for the real public egress IP, adapting to Docker/NAT scenarios; it falls back to local interface detection on failure, preferring a public IPv4 address.
|
||||
* When the Agent automatically detects a private `node_ip`, the Server prioritizes retaining the public address of the Agent's direct connection during registration/heartbeat phases, avoiding misregistering internal interface addresses in NAT or multi-interface scenarios.
|
||||
* When "Lock node IP" is enabled in the admin UI, the Server keeps the manually configured node IP and Agent registration, HTTP heartbeat, or WebSocket status reports will not overwrite that field; after unlocking, the next report can fill it again.
|
||||
|
||||
## Common Configuration Combinations
|
||||
|
||||
### Production Server + PostgreSQL
|
||||
|
||||
```bash
|
||||
export SESSION_SECRET='replace-with-a-long-random-string'
|
||||
export DSN='postgres://openflare:replace-with-strong-password@postgres:5432/openflare?sslmode=disable'
|
||||
export GIN_MODE='release'
|
||||
export LOG_LEVEL='info'
|
||||
```
|
||||
|
||||
### Local Server + SQLite
|
||||
|
||||
```bash
|
||||
export SESSION_SECRET='dev-session-secret'
|
||||
export SQLITE_PATH='./openflare-dev.db'
|
||||
export LOG_LEVEL='debug'
|
||||
go run .
|
||||
```
|
||||
|
||||
### Agent + Default OpenResty
|
||||
|
||||
```json
|
||||
{
|
||||
"server_url": "http://your-server:3000",
|
||||
"agent_token": "replace-with-node-auth-token",
|
||||
"data_dir": "/opt/openflare-agent/data",
|
||||
"openresty_path": "openresty",
|
||||
"heartbeat_interval": 10000,
|
||||
"request_timeout": 10000
|
||||
}
|
||||
```
|
||||
|
||||
### Agent + Customized OpenResty Path
|
||||
|
||||
```json
|
||||
{
|
||||
"server_url": "http://your-server:3000",
|
||||
"agent_token": "replace-with-node-auth-token",
|
||||
"data_dir": "/var/lib/openflare-agent",
|
||||
"openresty_path": "/usr/local/openresty/nginx/sbin/openresty",
|
||||
"main_config_path": "/var/lib/openflare-agent/etc/nginx/nginx.conf",
|
||||
"route_config_path": "/var/lib/openflare-agent/etc/nginx/conf.d/openflare_routes.conf",
|
||||
"access_log_path": "/var/lib/openflare-agent/var/log/openflare/access.log",
|
||||
"cert_dir": "/var/lib/openflare-agent/etc/nginx/certs",
|
||||
"lua_dir": "/var/lib/openflare-agent/etc/nginx/lua",
|
||||
"runtime_config_dir": "/var/lib/openflare-agent/etc/openflare",
|
||||
"heartbeat_interval": 10000,
|
||||
"request_timeout": 10000
|
||||
}
|
||||
```
|
||||
|
||||
## Maintenance Requirements
|
||||
|
||||
When the following contents change, this document must be updated in sync:
|
||||
|
||||
* Server command-line parameters.
|
||||
* Server environment variables.
|
||||
* Agent command-line parameters.
|
||||
* Agent configuration fields.
|
||||
* Default values, purposes, or examples of any configuration items.
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
# 本地开发
|
||||
|
||||
你会学到:如何搭建 OpenFlare 的本地开发环境、启动 Server、Agent 和管理端前端,运行测试与构建命令,并理解贡献代码前需要遵守的边界。
|
||||
|
||||
本页面向贡献者。产品边界、数据模型约束、API 约定和前端分层规范以 [开发约束](../design/development.md) 为准;本页只提供可执行的本地开发流程。
|
||||
|
||||
## 仓库结构
|
||||
|
||||
| 路径 | 职责 |
|
||||
| --- | --- |
|
||||
| `openflare_server` | Gin + GORM + SQLite/PostgreSQL 单体控制面 |
|
||||
| `openflare_server/web` | Next.js 管理端前端,静态导出后由 Go Server 托管 |
|
||||
| `openflare_agent` | Go 单体 Agent,运行在节点侧 |
|
||||
| `scripts` | Agent 安装与卸载脚本 |
|
||||
| `docs` | VitePress 文档站 |
|
||||
|
||||
## 环境要求
|
||||
|
||||
| 项目 | 要求 |
|
||||
| --- | --- |
|
||||
| Go | `1.25+` |
|
||||
| Node.js | `18+` |
|
||||
| pnpm | 推荐通过 `corepack enable` 使用项目声明版本 |
|
||||
| Docker | Server 容器、本地联调和 Agent Docker 镜像需要 |
|
||||
| OpenResty | 本地运行 Agent 时需要可执行 `openresty` |
|
||||
| PostgreSQL | 可选;未配置时 Server 使用 SQLite |
|
||||
|
||||
## 初始化前端依赖
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
corepack enable
|
||||
pnpm install
|
||||
```
|
||||
|
||||
构建供 Go Server 托管的静态产物:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## 启动 Server
|
||||
|
||||
SQLite 模式:
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
export SESSION_SECRET='dev-session-secret'
|
||||
export SQLITE_PATH='./openflare-dev.db'
|
||||
export LOG_LEVEL='debug'
|
||||
go run .
|
||||
```
|
||||
|
||||
PostgreSQL 模式:
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
export SESSION_SECRET='dev-session-secret'
|
||||
export DSN='postgres://openflare:secret@127.0.0.1:5432/openflare?sslmode=disable'
|
||||
export LOG_LEVEL='debug'
|
||||
go run .
|
||||
```
|
||||
|
||||
默认访问地址:
|
||||
|
||||
```text
|
||||
http://localhost:3000
|
||||
```
|
||||
|
||||
默认账号是 `root` / `123456`。
|
||||
|
||||
## 启动前端开发服务器
|
||||
|
||||
前端开发服务器默认监听 `3001`,并通过 `NEXT_DEV_BACKEND_URL` 代理到后端:
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
export NEXT_DEV_BACKEND_URL='http://127.0.0.1:3000'
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
访问:
|
||||
|
||||
```text
|
||||
http://localhost:3001
|
||||
```
|
||||
|
||||
## 启动 Agent
|
||||
|
||||
创建本地 `agent.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"server_url": "http://127.0.0.1:3000",
|
||||
"agent_token": "replace-with-node-auth-token",
|
||||
"data_dir": "./data",
|
||||
"heartbeat_interval": 10000,
|
||||
"request_timeout": 10000
|
||||
}
|
||||
```
|
||||
|
||||
运行:
|
||||
|
||||
```bash
|
||||
cd openflare_agent
|
||||
export LOG_LEVEL='debug'
|
||||
go run ./cmd/agent -config ./agent.json
|
||||
```
|
||||
|
||||
未配置 `openresty_path` 时,Agent 默认调用 `openresty`。调试时可显式配置 `openresty_path`、`main_config_path`、`route_config_path`、`access_log_path`、`cert_dir`、`lua_dir` 和 `runtime_config_dir`。
|
||||
|
||||
## 测试
|
||||
|
||||
Server:
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
GOCACHE=/tmp/openflare-go-cache go test ./...
|
||||
```
|
||||
|
||||
Agent:
|
||||
|
||||
```bash
|
||||
cd openflare_agent
|
||||
GOCACHE=/tmp/openflare-go-cache go test ./...
|
||||
```
|
||||
|
||||
Frontend:
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm test:e2e
|
||||
```
|
||||
|
||||
Docs:
|
||||
|
||||
```bash
|
||||
cd docs
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## 构建
|
||||
|
||||
管理端静态产物:
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Server 二进制:
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
go build -o openflare-server .
|
||||
```
|
||||
|
||||
Agent 二进制:
|
||||
|
||||
```bash
|
||||
cd openflare_agent
|
||||
go build -o openflare-agent ./cmd/agent
|
||||
```
|
||||
|
||||
## 调试入口
|
||||
|
||||
| 场景 | 命令或位置 |
|
||||
| --- | --- |
|
||||
| Server 日志 | `LOG_LEVEL=debug go run .` |
|
||||
| Agent 日志 | `LOG_LEVEL=debug go run ./cmd/agent -config ./agent.json` |
|
||||
| Swagger | `http://localhost:3000/swagger/index.html` |
|
||||
| 前端 API 代理 | `NEXT_DEV_BACKEND_URL=http://127.0.0.1:3000 pnpm dev` |
|
||||
| OpenResty 配置校验 | `openresty -t -c ./data/etc/nginx/nginx.conf` |
|
||||
|
||||
## 代码风格与变更准入
|
||||
|
||||
贡献前先确认:
|
||||
|
||||
1. 需求符合 [产品边界](../design/index.md)。
|
||||
2. 实现符合 [开发约束](../design/development.md)。
|
||||
3. 不破坏发布、同步、回滚或升级主链路。
|
||||
4. 涉及配置、部署、API 或产品边界时同步更新文档。
|
||||
5. 风险较高的修改补充测试或等效联调验证。
|
||||
|
||||
数据库结构变更必须提升数据库版本号,并补充从上一版本到新版本的显式迁移方法和校验逻辑。
|
||||
+9
-7
@@ -10,9 +10,10 @@ OpenFlare 是一套自托管的 OpenResty 控制面。它把反向代理网站
|
||||
|
||||
1. [快速开始](./quick-start.md):用 Docker Compose 启动 Server,登录管理端,并接入第一个 Agent。
|
||||
2. [基础使用](./usage.md):了解网站配置、源站、证书、发布、回滚和观测的常见操作。
|
||||
3. [部署说明](./deployment.md):把 Server 和 Agent 放到更接近生产的环境中运行。
|
||||
4. [配置项参考](../reference/configuration.md):查 Server 环境变量、运行时 Option 和 Agent 配置字段。
|
||||
5. [故障排查](./troubleshooting.md):按症状排查登录、数据库、节点同步、OpenResty 应用和前端构建问题。
|
||||
3. [WAF 自动 IP 组语法](./waf-ip-group-expr.md):编写自动 IP 组 Expr 规则,了解关键字含义和预设规则。
|
||||
4. [部署说明](../reference/deployment.md):把 Server 和 Agent 放到更接近生产的环境中运行。
|
||||
5. [配置项参考](../reference/configuration.md):查 Server 环境变量、运行时 Option 和 Agent 配置字段。
|
||||
6. [故障排查](./troubleshooting.md):按症状排查登录、数据库、节点同步、OpenResty 应用和前端构建问题。
|
||||
|
||||
## 按角色查找
|
||||
|
||||
@@ -20,11 +21,12 @@ OpenFlare 是一套自托管的 OpenResty 控制面。它把反向代理网站
|
||||
| --- | --- |
|
||||
| 5 分钟内跑起管理端 | [快速开始](./quick-start.md) |
|
||||
| 发布第一条反向代理配置 | [发布第一份配置](./first-site.md) |
|
||||
| 接入或重装节点 Agent | [接入 Agent](./agent.md) |
|
||||
| 从源码启动 Server | [启动 Server](./server.md) |
|
||||
| 编写自动 IP 组规则 | [WAF 自动 IP 组语法](./waf-ip-group-expr.md) |
|
||||
| 接入或重装节点 Agent | [接入 Agent](../reference/agent.md) |
|
||||
| 从源码启动 Server | [启动 Server](../reference/server.md) |
|
||||
| 配置 GitHub 或 OIDC 登录 | [SSO 登录配置](./sso.md) |
|
||||
| 升级 Server 或 Agent | [升级与维护](./upgrade.md) |
|
||||
| 参与开发或修复问题 | [本地开发](./development.md) 与 [开发约束](../design/development.md) |
|
||||
| 升级 Server 或 Agent | [升级与维护](../reference/upgrade.md) |
|
||||
| 参与开发或修复问题 | [本地开发](../design/development.md) 与 [开发约束](../guildline/development-constraints.md) |
|
||||
| 理解架构和发布模型 | [系统架构](../design/architecture.md) 与 [发布模型](../design/release-model.md) |
|
||||
|
||||
## 文档分区
|
||||
|
||||
@@ -102,7 +102,26 @@ Agent 可以用两类凭证接入:
|
||||
|
||||
[需要确认:当前管理端中创建或查看 `discovery_token` 与节点 `agent_token` 的准确菜单路径]
|
||||
|
||||
## 3. 安装 Agent
|
||||
## 3. 安装/运行 Agent
|
||||
|
||||
Agent 部署方式推荐使用 Docker 部署(即直接运行内置 OpenResty 的 Agent 镜像);亦支持通过安装脚本将 Agent 部署在本地宿主机上。
|
||||
|
||||
### 方式 A:Docker 运行 Agent(推荐)
|
||||
|
||||
在代理节点上直接运行 Agent 镜像:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/rain-kl/openflare-agent:latest
|
||||
docker rm -f openflare-agent 2>/dev/null || true
|
||||
docker run -d --name openflare-agent --restart unless-stopped \
|
||||
-p 80:80 -p 443:443 \
|
||||
-v openflare-agent-data:/data \
|
||||
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
|
||||
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
|
||||
ghcr.io/rain-kl/openflare-agent:latest
|
||||
```
|
||||
|
||||
### 方式 B:执行安装脚本(本地部署)
|
||||
|
||||
在代理节点上执行安装脚本。
|
||||
|
||||
|
||||
@@ -76,6 +76,19 @@ HTTPS 按域名绑定证书,而不是按整个网站统一强制启用。
|
||||
|
||||
如果一个网站包含多个域名,Server 发布时会按证书分组渲染 HTTPS 配置,同时保持这些域名属于同一份网站快照。
|
||||
|
||||
## 配置 WAF 与 PoW
|
||||
|
||||
安全防护统一从管理端侧边栏的 **WAF** 入口进入:
|
||||
|
||||
* WAF 页面维护全局规则组和自定义规则组。全局规则组始终应用到全部网站;自定义规则组可以在规则组内一键选择网站,也可以在网站详情的 `WAF` 分区绑定。
|
||||
* 点击 WAF 页面中的 **管理 IP 组** 可以进入独立 IP 组页面。手动 IP 组直接维护 IP/IP 段;自动 IP 组使用 Expr 规则按单个 IP 聚合请求日志并定时更新名单;订阅 IP 组可从远程文本或 JSON 源定时同步。
|
||||
* 自动 IP 组页面提供两个预设:单个 IP 请求数大于 100 且 404 占比不低于 80%;单个 IP 通过 IP 地址访问次数大于 50 且该访问占比大于 50%。保存前可点击 **测试规则** 查看当前日志窗口命中的 IP,保存后可点击 **立即执行** 更新组内名单,语法见 [WAF 自动 IP 组规则语法](./waf-ip-group-expr.md)。
|
||||
* 在 WAF 规则组的黑白名单中,IP 维度既可以直接添加 IP/IP 段,也可以引用已有 IP 组。发布时版本只携带 IP 组引用 ID;Agent 会按 checksum 差异同步 IP 组成员,并在 Server 通过 WebSocket 广播 IP 组更新时实时落地到节点。
|
||||
* `PoW` 是规则组内的一个配置 Tab,位于 `黑白名单` 与 `拦截返回` 之间,复用站点已有 PoW 执行逻辑,可将当前 PoW 配置应用到全部网站或当前规则组绑定的网站。
|
||||
* 网站详情页不再单独编辑 PoW 规则,只展示全局 WAF 规则组并绑定自定义 WAF 规则组。PoW 的启用范围和规则内容应回到 WAF 页面统一维护。
|
||||
|
||||
WAF 规则组、网站绑定或 PoW 配置修改后,需要重新发布并激活配置版本,Agent 才会拉取并应用到 OpenResty。IP 组成员变化不需要重新发布版本;在线 Agent 会通过 WebSocket 增量更新,离线或未升级 WS 的 Agent 会在下一次心跳中按 checksum 差异补齐。
|
||||
|
||||
## 发布、激活与回滚
|
||||
|
||||
标准链路:
|
||||
@@ -98,6 +111,8 @@ HTTPS 按域名绑定证书,而不是按整个网站统一强制启用。
|
||||
| 当前运行哪个版本 | 节点详情中的当前版本 |
|
||||
| 最近一次应用是否成功 | 应用记录 |
|
||||
|
||||
节点 IP 默认由 Agent 注册和后续心跳自动回填。若在管理端填写或修改 IP,节点编辑会默认开启“锁定节点 IP”;开启后 Agent 上报不会覆盖该 IP。关闭锁定后,下一次 Agent 心跳或 WebSocket 状态上报会重新按自动逻辑更新。
|
||||
|
||||
访问分析和资源快照用于基础观测。OpenFlare 只保留受控时间窗口内的访问明细,不定位为通用日志平台。如果需要长期日志检索,应接入独立日志系统。
|
||||
|
||||
## 常见场景
|
||||
|
||||
@@ -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),执行时仍按数据模型、接口、前端页面、迁移测试与文档联动的顺序推进。
|
||||
@@ -1,86 +0,0 @@
|
||||
# Agent Unified OpenResty Binary Control Scheme
|
||||
|
||||
# Agent 统一 OpenResty 二进制控制方案
|
||||
|
||||
## Summary
|
||||
|
||||
将 Agent 运行模型统一为“写入受管配置文件,然后调用 `openresty` 二进制执行 `-t`、reload、start/restart”。Docker 部署不再由 Agent 控制另一个 OpenResty 容器,而是提供独立的 `ghcr.io/rain-kl/openflare-agent` 镜像;该镜像基于 `openresty/openresty`,内置 Agent 控制器和 OpenResty 二进制。
|
||||
|
||||
## Key Changes
|
||||
|
||||
- Agent runtime:
|
||||
- 移除生产路径中的 DockerExecutor / Docker 容器管理逻辑。
|
||||
- `openresty_path` 未配置时默认使用 `openresty`。
|
||||
- 二进制执行统一带 `-c <main_config_path>`,避免误读 OpenResty 默认配置。
|
||||
- apply 流程为:备份 -> 写入文件 -> `openresty -t -c ...` -> reload;若 reload 表明未运行,则 start。
|
||||
- restart 使用 `openresty -c ... -s quit` 后再 `openresty -c ...` 启动,保留缺失 PID 的容错。
|
||||
|
||||
- 配置与文件职责:
|
||||
- 保留旧字段 `openresty_container_name`、`openresty_docker_image`、`docker_binary` 的解析兼容,但标记废弃且不再参与控制逻辑。
|
||||
- 新增 `access_log_path`,默认 `data_dir/var/log/openflare/access.log`,不再把访问日志放进 `conf.d`。
|
||||
- 新增 `runtime_config_dir`,默认 `data_dir/etc/openflare`,`pow_config.json` 写入这里。
|
||||
- `cert_dir` 只写证书/密钥文件;`lua_dir` 只写 Lua 代码与静态资源。
|
||||
- 支持文件写入前先拆分:证书文件进入 `cert_dir`,`pow_config.json` 进入 `runtime_config_dir`。
|
||||
|
||||
- Docker Agent 镜像:
|
||||
- 新增 `openflare_agent/Dockerfile`,运行镜像基于 `openresty/openresty:alpine`。
|
||||
- 默认 `OPENFLARE_OPENRESTY_PATH=openresty`、`OPENFLARE_DATA_DIR=/data`。
|
||||
- 暴露 `80`、`443`、`18081`。
|
||||
- 支持挂载 `/etc/openflare/agent.json`,也支持环境变量配置。
|
||||
- CI 发布独立多架构镜像:`ghcr.io/rain-kl/openflare-agent:<version>` 和 `latest`。
|
||||
|
||||
- Agent 配置入口:
|
||||
- 保留 `-config` + `agent.json`。
|
||||
- 新增环境变量覆盖/兜底:`OPENFLARE_SERVER_URL`、`OPENFLARE_AGENT_TOKEN`、`OPENFLARE_DISCOVERY_TOKEN`、`OPENFLARE_NODE_NAME`、`OPENFLARE_NODE_IP`、`OPENFLARE_DATA_DIR`、`OPENFLARE_OPENRESTY_PATH`、`OPENFLARE_HEARTBEAT_INTERVAL`、`OPENFLARE_REQUEST_TIMEOUT`、`OPENFLARE_OPENRESTY_OBSERVABILITY_PORT`。
|
||||
- 若配置文件不存在但环境变量足够,Agent 可直接启动;若两者都存在,环境变量覆盖文件值。
|
||||
|
||||
- 脚本与文档:
|
||||
- `install-agent.sh` 转为本地 OpenResty 部署脚本,增加 `--openresty-path`,未传时自动查找 `openresty`。
|
||||
- `uninstall-agent.sh` 只卸载 Agent 本身,不再删除 Docker OpenResty 容器或镜像。
|
||||
- 更新架构、开发约束、部署说明、Agent 指南、配置项参考、README,以及英文镜像文档中的旧 Docker 控制说明。
|
||||
|
||||
## Public Interfaces
|
||||
|
||||
- 新增 Agent 配置字段:
|
||||
- `access_log_path`
|
||||
- `runtime_config_dir`
|
||||
|
||||
- 废弃但兼容读取:
|
||||
- `openresty_container_name`
|
||||
- `openresty_docker_image`
|
||||
- `docker_binary`
|
||||
|
||||
- 新增 Docker 镜像:
|
||||
- `ghcr.io/rain-kl/openflare-agent`
|
||||
|
||||
- Docker 运行方式示例目标:
|
||||
- 挂载配置文件:`-v ./agent.json:/etc/openflare/agent.json`
|
||||
- 或环境变量:`-e OPENFLARE_SERVER_URL=... -e OPENFLARE_AGENT_TOKEN=...`
|
||||
|
||||
## Test Plan
|
||||
|
||||
- `openflare_agent/internal/config`:
|
||||
- 默认 `openresty_path` 为 `openresty`。
|
||||
- 旧 Docker 字段可读取但不影响 executor。
|
||||
- 环境变量可在无配置文件时启动,并可覆盖配置文件。
|
||||
- 新默认路径符合职责边界。
|
||||
|
||||
- `openflare_agent/internal/nginx`:
|
||||
- 二进制命令都包含 `-c <main_config_path>`。
|
||||
- apply 成功、reload 失败后回滚、未运行时 start fallback。
|
||||
- `pow_config.json` 不再写入 `cert_dir` 或 `lua_dir`。
|
||||
- stale `cert_dir/pow_config.json` 与 `lua_dir/pow_config.json` 会被清理。
|
||||
- access log 渲染到 `access_log_path`。
|
||||
- checksum 仍能把主配置、路由配置、证书和 PoW 配置统一纳入比较。
|
||||
|
||||
- 集成回归:
|
||||
- `cd openflare_agent && GOCACHE=/tmp/openflare-go-cache go test ./...`
|
||||
- `cd openflare_server && GOCACHE=/tmp/openflare-go-cache go test ./...`
|
||||
- Dockerfile 构建 smoke test:构建 Agent 镜像并用 env-only 配置启动到可执行阶段。
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Docker Agent 镜像名固定为 `ghcr.io/rain-kl/openflare-agent`。
|
||||
- 旧 Docker 控制字段保留兼容,但不再作为受支持行为。
|
||||
- 本次不改 Server API、不改数据库模型、不引入远程命令能力。
|
||||
- OpenResty 主配置模板继续由 Server 生成;Agent 只负责本地路径替换、文件落盘和二进制控制。
|
||||
@@ -89,13 +89,15 @@ curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/inst
|
||||
}
|
||||
```
|
||||
|
||||
如果不配置 `openresty_path`,Agent 默认调用 `openresty`。完整字段见 [配置项参考](../reference/configuration.md#agent-配置字段)。
|
||||
如果不配置 `openresty_path`,Agent 默认调用 `openresty`。完整字段见 [配置项参考](./configuration.md#agent-配置字段)。
|
||||
|
||||
## Docker 运行
|
||||
|
||||
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 \
|
||||
@@ -22,9 +22,45 @@ OpenFlare 的管理端 API 与 Agent API 都使用 JSON。
|
||||
| --- | --- |
|
||||
| 管理端 API | 由管理端 Session 鉴权 |
|
||||
| Agent API | 固定放在 `/api/agent/*` |
|
||||
| Relay API | 固定放在 `/api/relay/*`,使用 `X-Agent-Token` 鉴权(与 Agent 复用同一 token) |
|
||||
| OpenFlared API | 固定放在 `/api/flared/*`,使用 `X-Tunnel-Token` 鉴权(独立的 tunnel_token) |
|
||||
| 只读接口 | 使用 `GET` |
|
||||
| 变更类接口 | 使用 `POST` |
|
||||
|
||||
## WAF IP 组接口
|
||||
|
||||
管理端 WAF IP 组接口统一要求管理端 Session 鉴权:
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/waf/ip-groups` | 查询 IP 组列表 |
|
||||
| `GET` | `/api/waf/ip-groups/:id` | 查询单个 IP 组 |
|
||||
| `POST` | `/api/waf/ip-groups` | 创建 IP 组 |
|
||||
| `POST` | `/api/waf/ip-groups/test` | 测试自动 IP 组 Expr 规则,不保存配置,返回当前日志窗口内命中的 IP 列表 |
|
||||
| `POST` | `/api/waf/ip-groups/:id/update` | 更新 IP 组 |
|
||||
| `POST` | `/api/waf/ip-groups/:id/delete` | 删除 IP 组;已被规则组引用时会拒绝 |
|
||||
| `POST` | `/api/waf/ip-groups/:id/sync` | 立即同步订阅型 IP 组或立即执行自动型 IP 组 |
|
||||
|
||||
IP 组 `type` 支持 `manual`、`automatic`、`subscription`。自动型 IP 组的 `auto_config` 是 JSON 对象,当前支持:
|
||||
|
||||
```json
|
||||
{
|
||||
"lookback_minutes": 60,
|
||||
"rules": [
|
||||
{
|
||||
"name": "单 IP 404 高频扫描",
|
||||
"expr": "request_count > 100 && status_404_ratio >= 0.8"
|
||||
},
|
||||
{
|
||||
"name": "单 IP 直连访问异常",
|
||||
"expr": "ip_host_count > 50 && ip_host_ratio > 0.5"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
自动规则使用 Expr 语法,表达式必须返回布尔值。规则按单个 IP 的请求日志聚合指标计算,可用字段包括 `ip`、`request_count`、`status_404_count`、`status_404_ratio`、`ip_host_count`、`ip_host_ratio`、`client_error_count`、`server_error_count`、`last_seen_unix`。完整语法和字段含义见 [WAF 自动 IP 组规则语法](../guide/waf-ip-group-expr.md)。订阅格式支持 `text` 与 `json`:文本格式按行解析 IP/IP 段并忽略空行和 `#` 开头的注释;JSON 格式可通过映射规则选择数组,默认读取根数组。
|
||||
|
||||
## 鉴权
|
||||
|
||||
管理端继续复用现有登录、角色与 Session。
|
||||
@@ -35,6 +71,75 @@ Agent 正式请求统一使用节点专属 `agent_token`,首次接入可使用
|
||||
X-Agent-Token: <token>
|
||||
```
|
||||
|
||||
### Agent WAF IP 组同步
|
||||
|
||||
Agent 心跳 payload 可携带本地 WAF IP 组 checksum:
|
||||
|
||||
```json
|
||||
{
|
||||
"waf_ip_group_checksums": {
|
||||
"1": "sha256..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Server 会根据当前激活版本引用的 IP 组 ID 对比 checksum,并在心跳响应顶层返回差异组:
|
||||
|
||||
```json
|
||||
{
|
||||
"waf_ip_groups": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "自动黑名单",
|
||||
"type": "automatic",
|
||||
"enabled": true,
|
||||
"ip_list": ["203.0.113.10"],
|
||||
"checksum": "sha256..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Agent 也可以在应用新版本后主动请求差异同步:
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `POST` | `/api/agent/waf/ip-groups/sync` | 根据 Agent 上报的 `ids` 与 `checksums` 返回不一致的 IP 组 |
|
||||
|
||||
当 Server 侧 IP 组更新时,已连接的 Agent WebSocket 会收到 `type = "waf_ip_groups"` 的消息,payload 为发生变化的 IP 组数组。Agent 应只更新收到的组,不要求 Server 每次下发全部 IP 组。
|
||||
|
||||
## OpenFlared API
|
||||
|
||||
OpenFlared 客户端用于内网穿透场景,通过 `tunnel_token` 与 Server 通信,独立于 Agent 认证体系。所有接口都使用 `X-Tunnel-Token` 鉴权,Server 会校验节点 `node_type = tunnel_client`,否则返回 `403`。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `POST` | `/api/flared/heartbeat` | 客户端心跳,刷新在线状态并返回 tunnel 配置版本摘要 |
|
||||
| `GET` | `/api/flared/config/active` | 拉取完整的 tunnel 路由配置(relay 列表 + frpc 代理定义) |
|
||||
| `POST` | `/api/flared/apply-log` | 上报配置应用结果(success / warning / failed) |
|
||||
| `GET` | `/api/flared/ws` | 升级为 WebSocket,用于实时接收 `active_config` 推送 |
|
||||
|
||||
心跳请求示例:
|
||||
|
||||
```http
|
||||
POST /api/flared/heartbeat
|
||||
X-Tunnel-Token: <tunnel_token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"client_version": "v0.2.0",
|
||||
"frp_version": "0.61.0",
|
||||
"tunnel_status": "running",
|
||||
"connected_relays": [
|
||||
{ "relay_node_id": "node-relay-1", "status": "healthy", "proxy_count": 3 }
|
||||
],
|
||||
"current_version": "v1",
|
||||
"current_checksum": "sha256..."
|
||||
}
|
||||
```
|
||||
|
||||
心跳响应包含 `active_config` 摘要与 `tunnel_settings`(包含心跳间隔、WebSocket 升级开关等运行时参数)。当 Server 发布新版本时,已连接的 OpenFlared WebSocket 会收到 `type = "active_config"` 消息,payload 为版本摘要,客户端应立即拉取完整配置并应用。
|
||||
|
||||
日志中不得打印完整 Token。
|
||||
|
||||
## Swagger
|
||||
|
||||
@@ -23,7 +23,6 @@ Agent 支持:
|
||||
| 组件 | 默认位置 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| Server SQLite | `openflare.db` | 可通过 `SQLITE_PATH` 修改 |
|
||||
| Server 上传目录 | `upload` | 可通过 `UPLOAD_PATH` 修改 |
|
||||
| Agent 配置文件 | `./agent.json` | 可通过 `-config` 指定 |
|
||||
| 一键安装 Agent 配置 | `/opt/openflare-agent/agent.json` | 安装脚本默认生成 |
|
||||
| Agent 数据目录 | 配置文件所在目录下的 `data` | 可通过 `data_dir` 修改 |
|
||||
@@ -54,7 +53,6 @@ go run . --port 3000 --log-dir ./logs
|
||||
| `DSN` | PostgreSQL DSN,设置后优先于 SQLite | 空 |
|
||||
| `SQL_DSN` | 兼容旧命名的 PostgreSQL DSN,优先级低于 `DSN` | 空 |
|
||||
| `REDIS_CONN_STRING` | Redis 连接串 | 空 |
|
||||
| `UPLOAD_PATH` | 上传目录 | `upload` |
|
||||
| `AGENT_TOKEN` | 兼容旧部署的全局 Agent Token | 空 |
|
||||
|
||||
说明:
|
||||
@@ -80,8 +78,6 @@ go run . --port 3000 --log-dir ./logs
|
||||
| `DatabaseAutoCleanupRetentionDays` | 自动清理保留天数,至少 1 天 | `30` |
|
||||
| `GlobalApiRateLimitNum` / `GlobalApiRateLimitDuration` | 全局 API 限流次数 / 时间窗口 | `300` / `180` |
|
||||
| `GlobalWebRateLimitNum` / `GlobalWebRateLimitDuration` | 全局 Web 限流次数 / 时间窗口 | `300` / `180` |
|
||||
| `UploadRateLimitNum` / `UploadRateLimitDuration` | 上传接口限流次数 / 时间窗口 | `50` / `60` |
|
||||
| `DownloadRateLimitNum` / `DownloadRateLimitDuration` | 下载接口限流次数 / 时间窗口 | `50` / `60` |
|
||||
| `CriticalRateLimitNum` / `CriticalRateLimitDuration` | 敏感接口限流次数 / 时间窗口 | `100` / `1200` |
|
||||
|
||||
说明:
|
||||
@@ -148,6 +144,9 @@ OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前
|
||||
| `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 命令行参数
|
||||
|
||||
@@ -165,10 +164,7 @@ OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前
|
||||
| `node_name` | 节点名称 | 否 | 自动使用主机名 |
|
||||
| `node_ip` | 节点 IP | 否 | 自动探测,优先通过第三方 API 获取真实出口公网 IP;失败时退回本机网卡探测 |
|
||||
| `openresty_path` | OpenResty 二进制路径 | 否 | `openresty` |
|
||||
| `openresty_container_name` | 旧 Docker 控制字段,仅兼容读取 | 否 | 空 |
|
||||
| `openresty_docker_image` | 旧 Docker 控制字段,仅兼容读取 | 否 | 空 |
|
||||
| `openresty_observability_port` | 本地观测与 OpenResty 健康检查端口 | 否 | `18081` |
|
||||
| `docker_binary` | 旧 Docker 控制字段,仅兼容读取 | 否 | 空 |
|
||||
| `data_dir` | Agent 数据目录 | 否 | 配置文件所在目录下的 `data` |
|
||||
| `main_config_path` | OpenResty 主配置写入路径 | 否 | `data_dir/etc/nginx/nginx.conf` |
|
||||
| `route_config_path` | 路由配置写入路径 | 否 | `data_dir/etc/nginx/conf.d/openflare_routes.conf` |
|
||||
@@ -178,6 +174,9 @@ OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前
|
||||
| `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` |
|
||||
@@ -191,9 +190,11 @@ OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前
|
||||
* 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 状态上报不会覆盖该字段;关闭锁定后,下一次上报可重新回填。
|
||||
|
||||
## 常见配置组合
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
你会学到:OpenFlare 的推荐部署方式、Server 与 Agent 的运行要求、源码启动方式、联调步骤、升级与卸载入口。
|
||||
|
||||
生产环境建议使用 PostgreSQL 作为 Server 数据库,并为 Server 显式配置 `SESSION_SECRET`。Agent 统一通过 OpenResty 二进制控制运行时;Docker 部署请直接使用内置 OpenResty 的 Agent 镜像。
|
||||
生产环境建议使用 PostgreSQL 作为 Server 数据库,并为 Server 显式配置 `SESSION_SECRET`。Agent 部署方式推荐为 Docker 部署(即直接使用内置 OpenResty 的 Agent 镜像);亦支持通过安装脚本或手动本地运行。
|
||||
|
||||
## 部署拓扑
|
||||
|
||||
@@ -43,6 +43,7 @@ Agent:
|
||||
| OpenResty | 本地部署需要可执行 `openresty`,或通过 `--openresty-path` 指定路径 |
|
||||
| Docker | 仅 Docker 部署 Agent 镜像时需要 |
|
||||
| 网络 | Agent 节点必须能访问 Server 地址 |
|
||||
| GeoIP | WAF 地域规则使用 Agent 本地 MaxMind mmdb;Agent 内置初始库并会定期更新 |
|
||||
|
||||
[需要确认:生产环境推荐的最低 CPU、内存与磁盘容量]
|
||||
|
||||
@@ -128,7 +129,37 @@ go run .
|
||||
go run . --port 3000 --log-dir ./logs
|
||||
```
|
||||
|
||||
## Agent 接入
|
||||
## Docker 运行 Agent(推荐)
|
||||
|
||||
Docker 部署是 Agent 推荐的部署方式。Docker 部署时直接运行 Agent 镜像,该镜像基于 OpenResty 镜像制作,内置 Agent 控制器与 OpenResty 二进制。未显式配置 `node_ip` 时,Agent 会优先通过第三方 API 获取真实出口 IP,避免把 Docker 网桥地址登记为节点 IP。
|
||||
|
||||
挂载配置文件:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/rain-kl/openflare-agent:latest
|
||||
docker rm -f openflare-agent 2>/dev/null || true
|
||||
docker run -d --name openflare-agent --restart unless-stopped \
|
||||
-p 80:80 -p 443:443 \
|
||||
-v openflare-agent-data:/data \
|
||||
-v ./agent.json:/etc/openflare/agent.json:ro \
|
||||
ghcr.io/rain-kl/openflare-agent:latest
|
||||
```
|
||||
|
||||
使用环境变量:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/rain-kl/openflare-agent:latest
|
||||
docker rm -f openflare-agent 2>/dev/null || true
|
||||
docker run -d --name openflare-agent --restart unless-stopped \
|
||||
-p 80:80 -p 443:443 \
|
||||
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
|
||||
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
|
||||
ghcr.io/rain-kl/openflare-agent:latest
|
||||
```
|
||||
|
||||
## Agent 接入(脚本安装)
|
||||
|
||||
除了 Docker 部署外,也支持通过安装脚本将 Agent 部署在本地宿主机上。
|
||||
|
||||
使用 `discovery_token` 自动注册:
|
||||
|
||||
@@ -165,30 +196,6 @@ systemctl status openflare-agent
|
||||
journalctl -u openflare-agent -f
|
||||
```
|
||||
|
||||
## Docker 运行 Agent
|
||||
|
||||
Docker 部署时直接运行 Agent 镜像。该镜像基于 OpenResty 镜像制作,内置 Agent 控制器与 OpenResty 二进制。未显式配置 `node_ip` 时,Agent 会优先通过第三方 API 获取真实出口 IP,避免把 Docker 网桥地址登记为节点 IP。
|
||||
|
||||
挂载配置文件:
|
||||
|
||||
```bash
|
||||
docker 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 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
|
||||
|
||||
源码运行:
|
||||
@@ -225,6 +232,8 @@ export LOG_LEVEL='info'
|
||||
|
||||
默认情况下,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 并完成首次登录。
|
||||
@@ -9,4 +9,4 @@
|
||||
| [配置项](./configuration.md) | Server 环境变量、命令行参数、运行时 Option 与 Agent 配置字段 |
|
||||
| [命令与脚本](./cli.md) | 常用启动、构建、测试、安装和卸载命令 |
|
||||
| [API 约定](./api.md) | 管理端 API 与 Agent API 的响应结构、鉴权和路径约定 |
|
||||
| [仓库结构](./repository.md) | `openflare_server`、`openflare_agent`、`openflare_server/web` 与 `docs` 的职责 |
|
||||
| [仓库结构](../design/repository.md) | `openflare_server`、`openflare_agent`、`openflare_server/web` 与 `docs` 的职责 |
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# 仓库结构
|
||||
|
||||
你会学到:OpenFlare 仓库中 Server、Agent、前端、脚本和文档目录分别负责什么,以及贡献代码时应把逻辑放到哪一层。
|
||||
|
||||
| 路径 | 职责 |
|
||||
| --- | --- |
|
||||
| `openflare_server` | Gin + GORM + SQLite/PostgreSQL 单体控制面 |
|
||||
| `openflare_server/web` | Next.js 15 App Router 管理端前端,静态导出后由 Go Server 托管 |
|
||||
| `openflare_agent` | Go 单体 Agent,运行在节点侧 |
|
||||
| `scripts` | Agent 安装、卸载等辅助脚本 |
|
||||
| `docs` | VitePress 文档站、设计基线、开发规范、部署与配置文档 |
|
||||
|
||||
## Server 分层
|
||||
|
||||
| 目录 | 职责 |
|
||||
| --- | --- |
|
||||
| `controller/` | 参数解析、调用 service、返回响应 |
|
||||
| `service/` | 业务逻辑、校验、事务编排、配置渲染 |
|
||||
| `model/` | 模型定义、数据库版本与迁移 |
|
||||
| `router/` | 路由注册 |
|
||||
| `middleware/` | 认证、鉴权、限流等横切逻辑 |
|
||||
| `common/` | 配置、全局状态与初始化入口 |
|
||||
| `utils/` | 纯工具函数与通用 helper |
|
||||
|
||||
## Agent 模块
|
||||
|
||||
| 模块 | 职责 |
|
||||
| --- | --- |
|
||||
| `config` | 配置读取与默认值 |
|
||||
| `heartbeat` | 心跳与版本摘要判断 |
|
||||
| `sync` | 配置拉取与应用编排 |
|
||||
| `nginx` / `openresty` | OpenResty 文件写入、校验、reload、启动与回滚 |
|
||||
| `state` | 本地状态与观测补报缓冲 |
|
||||
| `httpclient` | Server 通信 |
|
||||
| `protocol` | Agent API 协议类型 |
|
||||
| `internal/updater` | Agent 自更新 |
|
||||
|
||||
## Frontend 分层
|
||||
|
||||
| 目录 | 职责 |
|
||||
| --- | --- |
|
||||
| `app/` | 路由、布局、页面组装 |
|
||||
| `features/` | 按业务域组织模块 |
|
||||
| `components/` | 跨 feature 复用组件 |
|
||||
| `lib/` | 请求客户端、环境变量、工具函数、常量 |
|
||||
| `store/` | 少量跨页面 UI 状态 |
|
||||
| `types/` | 共享类型定义 |
|
||||
@@ -15,11 +15,13 @@ COPY openflare_server ./openflare_server
|
||||
COPY openflare_agent ./openflare_agent
|
||||
WORKDIR /build/openflare_agent
|
||||
RUN go mod download
|
||||
RUN go build -trimpath -ldflags "-s -w -X 'openflare-agent/internal/config.AgentVersion=$VERSION'" -o /build/openflare-agent ./cmd/agent
|
||||
RUN go build -trimpath -ldflags "-s -w -X 'openflare-agent/internal/config.Version=$VERSION'" -o /build/openflare-agent ./cmd/agent
|
||||
|
||||
FROM openresty/openresty:alpine
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata \
|
||||
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 \
|
||||
|
||||
@@ -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"
|
||||
@@ -31,7 +32,7 @@ func main() {
|
||||
slog.Error("load agent config failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
cfg.NginxVersion = nginx.DetectVersion(
|
||||
cfg.ExtVersion = nginx.DetectVersion(
|
||||
context.Background(),
|
||||
nginx.ExecutorOptions{
|
||||
NginxPath: cfg.OpenrestyPath,
|
||||
@@ -54,6 +55,7 @@ func main() {
|
||||
"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())
|
||||
@@ -69,7 +71,7 @@ func main() {
|
||||
LuaDir: cfg.LuaDir,
|
||||
NginxLuaDir: cfg.OpenrestyLuaDir,
|
||||
RuntimeConfigDir: cfg.RuntimeConfigDir,
|
||||
OpenrestyObservabilityListen: nginx.ObservabilityListenAddress(cfg.OpenrestyPath, cfg.OpenrestyObservabilityPort),
|
||||
OpenrestyObservabilityListen: nginx.ObservabilityListenAddress(cfg.OpenrestyObservabilityPort),
|
||||
OpenrestyObservabilityPort: cfg.OpenrestyObservabilityPort,
|
||||
OpenrestyResolverDirective: "",
|
||||
Executor: nginx.NewExecutor(nginx.ExecutorOptions{
|
||||
@@ -100,6 +102,12 @@ func main() {
|
||||
|
||||
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 {
|
||||
|
||||
@@ -24,6 +24,8 @@ type SyncService interface {
|
||||
SyncOnStartup(ctx context.Context, target *protocol.ActiveConfigMeta) error
|
||||
SyncOnce(ctx context.Context, target *protocol.ActiveConfigMeta) error
|
||||
ForceSyncOnce(ctx context.Context, target *protocol.ActiveConfigMeta) error
|
||||
WAFIPGroupChecksums() (map[string]string, error)
|
||||
ApplyWAFIPGroups(ctx context.Context, groups []protocol.WAFIPGroup) error
|
||||
}
|
||||
|
||||
type Updater interface {
|
||||
@@ -72,7 +74,7 @@ func (r *Runner) Run(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
slog.Info("agent runner started", "node_id", nodeID, "node", r.Config.NodeName, "ip", r.Config.NodeIP)
|
||||
if r.hasAgentToken() {
|
||||
if r.hasAccessToken() {
|
||||
if _, hbErr := r.performHeartbeatCycle(ctx, nodeID, true); hbErr != nil {
|
||||
slog.Error("agent startup heartbeat failed", "error", hbErr)
|
||||
}
|
||||
@@ -117,7 +119,7 @@ func (r *Runner) Run(ctx context.Context) error {
|
||||
delay := wsBackoff.Next()
|
||||
nextWSAttempt = time.Now().Add(delay)
|
||||
slog.Debug("agent ws disconnected; resuming http heartbeat", "retry_after", delay, "error", wsErr)
|
||||
if r.hasAgentToken() {
|
||||
if r.hasAccessToken() {
|
||||
if _, hbErr := r.performHeartbeatCycle(ctx, nodeID, false); hbErr != nil {
|
||||
slog.Error("agent heartbeat after ws disconnect failed", "error", hbErr)
|
||||
}
|
||||
@@ -126,7 +128,7 @@ func (r *Runner) Run(ctx context.Context) error {
|
||||
if wsDone != nil {
|
||||
continue
|
||||
}
|
||||
if !r.hasAgentToken() {
|
||||
if !r.hasAccessToken() {
|
||||
if err = r.tryRegister(ctx, &nodeID); err != nil {
|
||||
slog.Error("agent discovery register failed", "error", err)
|
||||
}
|
||||
@@ -161,6 +163,7 @@ func (r *Runner) performHeartbeatCycle(ctx context.Context, nodeID string, start
|
||||
}
|
||||
slog.Debug("agent heartbeat succeeded", "mode", mode, "node_id", nodeID)
|
||||
changed := r.applySettings(heartbeatResult.AgentSettings)
|
||||
r.applyWAFIPGroups(ctx, heartbeatResult.WAFIPGroups)
|
||||
if startup {
|
||||
if err = r.SyncService.SyncOnStartup(ctx, heartbeatResult.ActiveConfig); err != nil {
|
||||
r.recordSyncError(err)
|
||||
@@ -178,7 +181,7 @@ func (r *Runner) performHeartbeatCycle(ctx context.Context, nodeID string, start
|
||||
}
|
||||
|
||||
func (r *Runner) shouldUseWebSocket() bool {
|
||||
enabled := r.WebSocketService != nil && r.websocketUpgradeEnabled && r.hasAgentToken()
|
||||
enabled := r.WebSocketService != nil && r.websocketUpgradeEnabled && r.hasAccessToken()
|
||||
slog.Debug("agent ws upgrade eligibility checked", "enabled", enabled, "server_enabled", r.websocketUpgradeEnabled, "url", r.websocketURL())
|
||||
return enabled
|
||||
}
|
||||
@@ -307,6 +310,14 @@ func (r *Runner) handleWebSocketMessage(ctx context.Context, message protocol.WS
|
||||
slog.Error("agent ws triggered force sync failed", "version", target.Version, "error", err)
|
||||
}
|
||||
return false, nil
|
||||
case protocol.WSMessageTypeWAFIPGroups:
|
||||
var groups []protocol.WAFIPGroup
|
||||
if err := json.Unmarshal(message.Payload, &groups); err != nil {
|
||||
slog.Debug("agent ws waf ip groups decode failed", "error", err)
|
||||
return false, nil
|
||||
}
|
||||
r.applyWAFIPGroups(ctx, groups)
|
||||
return false, nil
|
||||
case protocol.WSMessageTypePing:
|
||||
slog.Debug("agent ws ping received")
|
||||
return false, conn.SendPong()
|
||||
@@ -354,8 +365,8 @@ func (backoff *webSocketBackoff) Reset() {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) hasAgentToken() bool {
|
||||
return strings.TrimSpace(r.Config.AgentToken) != ""
|
||||
func (r *Runner) hasAccessToken() bool {
|
||||
return strings.TrimSpace(r.Config.AccessToken) != ""
|
||||
}
|
||||
|
||||
func (r *Runner) applySettings(settings *protocol.AgentSettings) bool {
|
||||
@@ -436,7 +447,7 @@ func (r *Runner) tryRegister(ctx context.Context, nodeID *string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if response == nil || strings.TrimSpace(response.AgentToken) == "" || strings.TrimSpace(response.NodeID) == "" {
|
||||
if response == nil || strings.TrimSpace(response.AccessToken) == "" || strings.TrimSpace(response.NodeID) == "" {
|
||||
return errors.New("discovery register response 缺少 node_id 或 agent_token")
|
||||
}
|
||||
snapshot, err := r.StateStore.Load()
|
||||
@@ -447,14 +458,14 @@ func (r *Runner) tryRegister(ctx context.Context, nodeID *string) error {
|
||||
if err = r.StateStore.Save(snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
r.Config.AgentToken = response.AgentToken
|
||||
r.Config.AccessToken = response.AccessToken
|
||||
r.Config.DiscoveryToken = ""
|
||||
if err = r.Config.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
r.HeartbeatService.SetToken(response.AgentToken)
|
||||
r.HeartbeatService.SetToken(response.AccessToken)
|
||||
if r.WebSocketService != nil {
|
||||
r.WebSocketService.SetToken(response.AgentToken)
|
||||
r.WebSocketService.SetToken(response.AccessToken)
|
||||
}
|
||||
*nodeID = response.NodeID
|
||||
slog.Info("agent discovery registration succeeded", "node_id", response.NodeID)
|
||||
@@ -470,6 +481,7 @@ func (r *Runner) tryRegister(ctx context.Context, nodeID *string) error {
|
||||
heartbeatResult = &protocol.HeartbeatResult{}
|
||||
}
|
||||
r.applySettings(heartbeatResult.AgentSettings)
|
||||
r.applyWAFIPGroups(ctx, heartbeatResult.WAFIPGroups)
|
||||
if err = r.SyncService.SyncOnStartup(ctx, heartbeatResult.ActiveConfig); err != nil {
|
||||
r.recordSyncError(err)
|
||||
slog.Error("agent post-register startup sync failed", "error", err)
|
||||
@@ -561,23 +573,44 @@ func (r *Runner) nodePayload(nodeID string) protocol.NodePayload {
|
||||
if managedOpenRestyMetrics == nil {
|
||||
managedOpenRestyMetrics = fallbackMetrics
|
||||
}
|
||||
metricSnapshot := observability.BuildSnapshot(r.Config, r.StateStore, managedOpenRestyMetrics)
|
||||
metricSnapshot := observability.BuildSnapshot(r.Config, r.StateStore)
|
||||
openrestyObservation := observability.BuildOpenrestyObservation(managedOpenRestyMetrics)
|
||||
healthEvents := observability.BuildHealthEvents(snapshot)
|
||||
return protocol.NodePayload{
|
||||
NodeID: nodeID,
|
||||
Name: r.Config.NodeName,
|
||||
IP: r.Config.NodeIP,
|
||||
AgentVersion: r.Config.AgentVersion,
|
||||
NginxVersion: r.Config.NginxVersion,
|
||||
CurrentVersion: snapshot.CurrentVersion,
|
||||
LastError: snapshot.LastError,
|
||||
OpenrestyStatus: openrestyStatus,
|
||||
OpenrestyMessage: snapshot.OpenrestyMessage,
|
||||
Profile: profile,
|
||||
Snapshot: metricSnapshot,
|
||||
TrafficReport: trafficReport,
|
||||
AccessLogs: accessLogs,
|
||||
HealthEvents: healthEvents,
|
||||
payload := protocol.NodePayload{
|
||||
NodeID: nodeID,
|
||||
Name: r.Config.NodeName,
|
||||
IP: r.Config.NodeIP,
|
||||
Version: r.Config.Version,
|
||||
ExtVersion: r.Config.ExtVersion,
|
||||
CurrentVersion: snapshot.CurrentVersion,
|
||||
LastError: snapshot.LastError,
|
||||
OpenrestyStatus: openrestyStatus,
|
||||
OpenrestyMessage: snapshot.OpenrestyMessage,
|
||||
Profile: profile,
|
||||
Snapshot: metricSnapshot,
|
||||
OpenrestyObservation: openrestyObservation,
|
||||
TrafficReport: trafficReport,
|
||||
AccessLogs: accessLogs,
|
||||
HealthEvents: healthEvents,
|
||||
}
|
||||
if r.SyncService != nil {
|
||||
checksums, err := r.SyncService.WAFIPGroupChecksums()
|
||||
if err != nil {
|
||||
slog.Debug("load local waf ip group checksums failed", "error", err)
|
||||
} else if len(checksums) > 0 {
|
||||
payload.WAFIPGroupChecksums = checksums
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func (r *Runner) applyWAFIPGroups(ctx context.Context, groups []protocol.WAFIPGroup) {
|
||||
if len(groups) == 0 || r.SyncService == nil {
|
||||
return
|
||||
}
|
||||
if err := r.SyncService.ApplyWAFIPGroups(ctx, groups); err != nil {
|
||||
r.recordSyncError(err)
|
||||
slog.Error("agent apply waf ip groups failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,17 +621,18 @@ func (r *Runner) prepareHeartbeatPayload(nodeID string) (protocol.NodePayload, [
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
retainAfterUnix := now.Add(-time.Duration(r.Config.ObservabilityReplayMinutes) * time.Minute).Unix()
|
||||
windowStartedAtUnix := state.ObservabilityWindowStartedAt(payload.Snapshot, payload.TrafficReport)
|
||||
windowStartedAtUnix := state.ObservabilityWindowStartedAt(payload.Snapshot, payload.OpenrestyObservation, payload.TrafficReport)
|
||||
if windowStartedAtUnix <= 0 {
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
record := state.ObservabilityBufferRecord{
|
||||
WindowStartedAtUnix: windowStartedAtUnix,
|
||||
Snapshot: payload.Snapshot,
|
||||
TrafficReport: payload.TrafficReport,
|
||||
AccessLogs: payload.AccessLogs,
|
||||
QueuedAtUnix: now.Unix(),
|
||||
WindowStartedAtUnix: windowStartedAtUnix,
|
||||
Snapshot: payload.Snapshot,
|
||||
OpenrestyObservation: payload.OpenrestyObservation,
|
||||
TrafficReport: payload.TrafficReport,
|
||||
AccessLogs: payload.AccessLogs,
|
||||
QueuedAtUnix: now.Unix(),
|
||||
}
|
||||
if err := r.ObservabilityBuffer.Upsert(record, retainAfterUnix); err != nil {
|
||||
slog.Error("upsert observability buffer failed", "error", err)
|
||||
@@ -618,10 +652,11 @@ func (r *Runner) prepareHeartbeatPayload(nodeID string) (protocol.NodePayload, [
|
||||
continue
|
||||
}
|
||||
buffered = append(buffered, protocol.BufferedObservabilityRecord{
|
||||
WindowStartedAtUnix: item.WindowStartedAtUnix,
|
||||
Snapshot: item.Snapshot,
|
||||
TrafficReport: item.TrafficReport,
|
||||
AccessLogs: item.AccessLogs,
|
||||
WindowStartedAtUnix: item.WindowStartedAtUnix,
|
||||
Snapshot: item.Snapshot,
|
||||
OpenrestyObservation: item.OpenrestyObservation,
|
||||
TrafficReport: item.TrafficReport,
|
||||
AccessLogs: item.AccessLogs,
|
||||
})
|
||||
ackWindows = append(ackWindows, item.WindowStartedAtUnix)
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ type fakeSyncService struct {
|
||||
syncOnceCalls int
|
||||
lastTarget *protocol.ActiveConfigMeta
|
||||
onSyncOnceCall func(int)
|
||||
wafChecksums map[string]string
|
||||
wafGroups []protocol.WAFIPGroup
|
||||
}
|
||||
|
||||
type fakeRuntimeManager struct {
|
||||
@@ -135,6 +137,20 @@ func (f *fakeSyncService) ForceSyncOnce(ctx context.Context, target *protocol.Ac
|
||||
return f.syncOnceErr
|
||||
}
|
||||
|
||||
func (f *fakeSyncService) WAFIPGroupChecksums() (map[string]string, error) {
|
||||
if f.wafChecksums == nil {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
return f.wafChecksums, nil
|
||||
}
|
||||
|
||||
func (f *fakeSyncService) ApplyWAFIPGroups(ctx context.Context, groups []protocol.WAFIPGroup) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.wafGroups = append(f.wafGroups, groups...)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeWebSocketConnection struct {
|
||||
pongCalls int
|
||||
}
|
||||
@@ -178,11 +194,11 @@ func TestRunnerKeepsHeartbeatWhenStartupSyncFails(t *testing.T) {
|
||||
}
|
||||
runner := &Runner{
|
||||
Config: &config.Config{
|
||||
AgentToken: "agent-token",
|
||||
AccessToken: "agent-token",
|
||||
NodeName: "edge-01",
|
||||
NodeIP: "10.0.0.8",
|
||||
AgentVersion: config.AgentVersion,
|
||||
NginxVersion: "1.27.1.2",
|
||||
Version: config.Version,
|
||||
ExtVersion: "1.27.1.2",
|
||||
HeartbeatInterval: config.MillisecondDuration(10 * time.Millisecond),
|
||||
},
|
||||
StateStore: stateStore,
|
||||
@@ -231,11 +247,11 @@ func TestRunnerDoesNotExitOnHeartbeatOrSyncError(t *testing.T) {
|
||||
}
|
||||
runner := &Runner{
|
||||
Config: &config.Config{
|
||||
AgentToken: "agent-token",
|
||||
AccessToken: "agent-token",
|
||||
NodeName: "edge-01",
|
||||
NodeIP: "10.0.0.8",
|
||||
AgentVersion: config.AgentVersion,
|
||||
NginxVersion: "1.27.1.2",
|
||||
Version: config.Version,
|
||||
ExtVersion: "1.27.1.2",
|
||||
HeartbeatInterval: config.MillisecondDuration(10 * time.Millisecond),
|
||||
},
|
||||
StateStore: stateStore,
|
||||
@@ -289,11 +305,11 @@ func TestRunnerReportsOpenrestyHealthAndExecutesRestart(t *testing.T) {
|
||||
}
|
||||
runner := &Runner{
|
||||
Config: &config.Config{
|
||||
AgentToken: "agent-token",
|
||||
AccessToken: "agent-token",
|
||||
NodeName: "edge-01",
|
||||
NodeIP: "10.0.0.8",
|
||||
AgentVersion: config.AgentVersion,
|
||||
NginxVersion: "1.27.1.2",
|
||||
Version: config.Version,
|
||||
ExtVersion: "1.27.1.2",
|
||||
HeartbeatInterval: config.MillisecondDuration(10 * time.Millisecond),
|
||||
},
|
||||
StateStore: stateStore,
|
||||
@@ -345,8 +361,8 @@ func TestRunnerHeartbeatPayloadIncludesObservabilityExtensions(t *testing.T) {
|
||||
Config: &config.Config{
|
||||
NodeName: "edge-observe-1",
|
||||
NodeIP: "10.0.0.51",
|
||||
AgentVersion: config.AgentVersion,
|
||||
NginxVersion: "1.27.1.2",
|
||||
Version: config.Version,
|
||||
ExtVersion: "1.27.1.2",
|
||||
DataDir: tempDir,
|
||||
RouteConfigPath: filepath.Join(tempDir, "conf.d", "openflare_routes.conf"),
|
||||
AccessLogPath: filepath.Join(tempDir, "var", "log", "openflare", "access.log"),
|
||||
@@ -425,11 +441,11 @@ func TestRunnerReplaysBufferedObservabilityAfterHeartbeatRecovery(t *testing.T)
|
||||
}
|
||||
runner := &Runner{
|
||||
Config: &config.Config{
|
||||
AgentToken: "agent-token",
|
||||
AccessToken: "agent-token",
|
||||
NodeName: "edge-buffer-01",
|
||||
NodeIP: "10.0.0.52",
|
||||
AgentVersion: config.AgentVersion,
|
||||
NginxVersion: "1.27.1.2",
|
||||
Version: config.Version,
|
||||
ExtVersion: "1.27.1.2",
|
||||
DataDir: tempDir,
|
||||
RouteConfigPath: filepath.Join(tempDir, "conf.d", "openflare_routes.conf"),
|
||||
HeartbeatInterval: config.MillisecondDuration(10 * time.Millisecond),
|
||||
@@ -482,9 +498,9 @@ func TestRunnerDiscoveryRegisterUpdatesTokenAndNodeID(t *testing.T) {
|
||||
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
|
||||
heartbeatService := &fakeHeartbeatService{
|
||||
registerResp: &protocol.RegisterNodeResponse{
|
||||
NodeID: "node-server-assigned",
|
||||
AgentToken: "agent-token-issued",
|
||||
Name: "edge-01",
|
||||
NodeID: "node-server-assigned",
|
||||
AccessToken: "agent-token-issued",
|
||||
Name: "edge-01",
|
||||
},
|
||||
heartbeatResults: []*protocol.HeartbeatResult{{}},
|
||||
onHeartbeat: func(callCount int) {
|
||||
@@ -508,8 +524,8 @@ func TestRunnerDiscoveryRegisterUpdatesTokenAndNodeID(t *testing.T) {
|
||||
DiscoveryToken: cfg.DiscoveryToken,
|
||||
NodeName: cfg.NodeName,
|
||||
NodeIP: cfg.NodeIP,
|
||||
AgentVersion: config.AgentVersion,
|
||||
NginxVersion: "1.27.1.2",
|
||||
Version: config.Version,
|
||||
ExtVersion: "1.27.1.2",
|
||||
HeartbeatInterval: config.MillisecondDuration(10 * time.Millisecond),
|
||||
},
|
||||
StateStore: stateStore,
|
||||
@@ -517,8 +533,8 @@ func TestRunnerDiscoveryRegisterUpdatesTokenAndNodeID(t *testing.T) {
|
||||
SyncService: syncService,
|
||||
}
|
||||
runner.Config = cfg
|
||||
runner.Config.AgentVersion = config.AgentVersion
|
||||
runner.Config.NginxVersion = "1.27.1.2"
|
||||
runner.Config.Version = config.Version
|
||||
runner.Config.ExtVersion = "1.27.1.2"
|
||||
runner.Config.HeartbeatInterval = config.MillisecondDuration(10 * time.Millisecond)
|
||||
|
||||
err = runner.Run(ctx)
|
||||
@@ -538,7 +554,7 @@ func TestRunnerDiscoveryRegisterUpdatesTokenAndNodeID(t *testing.T) {
|
||||
if snapshot.NodeID != "node-server-assigned" {
|
||||
t.Fatalf("expected node id to be replaced, got %q", snapshot.NodeID)
|
||||
}
|
||||
if runner.Config.AgentToken != "agent-token-issued" || runner.Config.DiscoveryToken != "" {
|
||||
if runner.Config.AccessToken != "agent-token-issued" || runner.Config.DiscoveryToken != "" {
|
||||
t.Fatal("expected config token rotation to complete")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"openflare/utils"
|
||||
"openflare/utils/geoip"
|
||||
"openflare/utils/geoip/iputil"
|
||||
"os"
|
||||
@@ -22,11 +23,14 @@ const (
|
||||
defaultCertDirRelativePath = "etc/nginx/certs"
|
||||
defaultLuaDirRelativePath = "etc/nginx/lua"
|
||||
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"
|
||||
defaultOpenRestyObservabilityPort = 18081
|
||||
defaultObservabilityReplayMinutes = 15
|
||||
defaultMMDBUpdateInterval = 24 * time.Hour
|
||||
defaultMMDBDownloadURL = "https://raw.githubusercontent.com/Loyalsoldier/geoip/release/GeoLite2-Country.mmdb"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -36,17 +40,14 @@ var (
|
||||
|
||||
type Config struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
AccessToken string `json:"agent_token"`
|
||||
DiscoveryToken string `json:"discovery_token"`
|
||||
NodeName string `json:"node_name"`
|
||||
NodeIP string `json:"node_ip"`
|
||||
AgentVersion string `json:"-"`
|
||||
NginxVersion string `json:"-"`
|
||||
Version string `json:"-"`
|
||||
ExtVersion string `json:"-"`
|
||||
OpenrestyPath string `json:"openresty_path"`
|
||||
OpenrestyResolvers []string `json:"openresty_resolvers,omitempty"`
|
||||
OpenrestyContainerName string `json:"openresty_container_name,omitempty"`
|
||||
OpenrestyDockerImage string `json:"openresty_docker_image,omitempty"`
|
||||
DockerBinary string `json:"docker_binary,omitempty"`
|
||||
DataDir string `json:"data_dir"`
|
||||
MainConfigPath string `json:"main_config_path"`
|
||||
RouteConfigPath string `json:"route_config_path"`
|
||||
@@ -56,6 +57,9 @@ type Config struct {
|
||||
LuaDir string `json:"lua_dir"`
|
||||
OpenrestyLuaDir string `json:"openresty_lua_dir"`
|
||||
RuntimeConfigDir string `json:"runtime_config_dir"`
|
||||
MMDBPath string `json:"mmdb_path"`
|
||||
MMDBUpdateInterval MillisecondDuration `json:"mmdb_update_interval"`
|
||||
MMDBDownloadURL string `json:"mmdb_download_url"`
|
||||
OpenrestyObservabilityPort int `json:"openresty_observability_port"`
|
||||
ObservabilityBufferPath string `json:"observability_buffer_path"`
|
||||
ObservabilityReplayMinutes int `json:"observability_replay_minutes"`
|
||||
@@ -67,15 +71,12 @@ type Config struct {
|
||||
|
||||
type configFile struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
AccessToken string `json:"agent_token"`
|
||||
DiscoveryToken string `json:"discovery_token"`
|
||||
NodeName string `json:"node_name"`
|
||||
NodeIP string `json:"node_ip"`
|
||||
OpenrestyPath string `json:"openresty_path"`
|
||||
OpenrestyResolvers []string `json:"openresty_resolvers"`
|
||||
OpenrestyContainerName string `json:"openresty_container_name"`
|
||||
OpenrestyDockerImage string `json:"openresty_docker_image"`
|
||||
DockerBinary string `json:"docker_binary"`
|
||||
DataDir string `json:"data_dir"`
|
||||
MainConfigPath string `json:"main_config_path"`
|
||||
RouteConfigPath string `json:"route_config_path"`
|
||||
@@ -85,6 +86,9 @@ type configFile struct {
|
||||
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"`
|
||||
@@ -109,15 +113,12 @@ func Load(path string) (*Config, error) {
|
||||
}
|
||||
cfg := &Config{
|
||||
ServerURL: file.ServerURL,
|
||||
AgentToken: file.AgentToken,
|
||||
AccessToken: file.AccessToken,
|
||||
DiscoveryToken: file.DiscoveryToken,
|
||||
NodeName: file.NodeName,
|
||||
NodeIP: file.NodeIP,
|
||||
OpenrestyPath: file.OpenrestyPath,
|
||||
OpenrestyResolvers: append([]string{}, file.OpenrestyResolvers...),
|
||||
OpenrestyContainerName: file.OpenrestyContainerName,
|
||||
OpenrestyDockerImage: file.OpenrestyDockerImage,
|
||||
DockerBinary: file.DockerBinary,
|
||||
DataDir: file.DataDir,
|
||||
MainConfigPath: file.MainConfigPath,
|
||||
RouteConfigPath: file.RouteConfigPath,
|
||||
@@ -127,6 +128,9 @@ func Load(path string) (*Config, error) {
|
||||
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,
|
||||
@@ -145,8 +149,8 @@ func Load(path string) (*Config, error) {
|
||||
|
||||
func applyDefaults(cfg *Config, baseDir string) {
|
||||
baseDir = filepath.Clean(baseDir)
|
||||
cfg.AgentVersion = AgentVersion
|
||||
cfg.OpenrestyResolvers = normalizeResolverList(cfg.OpenrestyResolvers)
|
||||
cfg.Version = Version
|
||||
cfg.OpenrestyResolvers = utils.UniqueAndCleanStringSlice(cfg.OpenrestyResolvers)
|
||||
if cfg.OpenrestyPath == "" {
|
||||
cfg.OpenrestyPath = "openresty"
|
||||
}
|
||||
@@ -186,6 +190,15 @@ func applyDefaults(cfg *Config, baseDir string) {
|
||||
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
|
||||
}
|
||||
@@ -208,38 +221,24 @@ func normalizeManagedPaths(cfg *Config) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
if usesSlashPath(cfg.DataDir) {
|
||||
cfg.DataDir = filepath.ToSlash(cfg.DataDir)
|
||||
paths := []*string{
|
||||
&cfg.DataDir,
|
||||
&cfg.MainConfigPath,
|
||||
&cfg.RouteConfigPath,
|
||||
&cfg.AccessLogPath,
|
||||
&cfg.CertDir,
|
||||
&cfg.OpenrestyCertDir,
|
||||
&cfg.LuaDir,
|
||||
&cfg.OpenrestyLuaDir,
|
||||
&cfg.RuntimeConfigDir,
|
||||
&cfg.StatePath,
|
||||
&cfg.ObservabilityBufferPath,
|
||||
&cfg.MMDBPath,
|
||||
}
|
||||
if usesSlashPath(cfg.MainConfigPath) {
|
||||
cfg.MainConfigPath = filepath.ToSlash(cfg.MainConfigPath)
|
||||
}
|
||||
if usesSlashPath(cfg.RouteConfigPath) {
|
||||
cfg.RouteConfigPath = filepath.ToSlash(cfg.RouteConfigPath)
|
||||
}
|
||||
if usesSlashPath(cfg.AccessLogPath) {
|
||||
cfg.AccessLogPath = filepath.ToSlash(cfg.AccessLogPath)
|
||||
}
|
||||
if usesSlashPath(cfg.CertDir) {
|
||||
cfg.CertDir = filepath.ToSlash(cfg.CertDir)
|
||||
}
|
||||
if usesSlashPath(cfg.OpenrestyCertDir) {
|
||||
cfg.OpenrestyCertDir = filepath.ToSlash(cfg.OpenrestyCertDir)
|
||||
}
|
||||
if usesSlashPath(cfg.LuaDir) {
|
||||
cfg.LuaDir = filepath.ToSlash(cfg.LuaDir)
|
||||
}
|
||||
if usesSlashPath(cfg.OpenrestyLuaDir) {
|
||||
cfg.OpenrestyLuaDir = filepath.ToSlash(cfg.OpenrestyLuaDir)
|
||||
}
|
||||
if usesSlashPath(cfg.RuntimeConfigDir) {
|
||||
cfg.RuntimeConfigDir = filepath.ToSlash(cfg.RuntimeConfigDir)
|
||||
}
|
||||
if usesSlashPath(cfg.StatePath) {
|
||||
cfg.StatePath = filepath.ToSlash(cfg.StatePath)
|
||||
}
|
||||
if usesSlashPath(cfg.ObservabilityBufferPath) {
|
||||
cfg.ObservabilityBufferPath = filepath.ToSlash(cfg.ObservabilityBufferPath)
|
||||
for _, p := range paths {
|
||||
if usesSlashPath(*p) {
|
||||
*p = filepath.ToSlash(*p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +254,9 @@ func hasEnvConfig() bool {
|
||||
"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
|
||||
@@ -273,12 +275,14 @@ func applyEnvOverrides(cfg *Config) {
|
||||
}
|
||||
}
|
||||
overrideString("OPENFLARE_SERVER_URL", &cfg.ServerURL)
|
||||
overrideString("OPENFLARE_AGENT_TOKEN", &cfg.AgentToken)
|
||||
overrideString("OPENFLARE_AGENT_TOKEN", &cfg.AccessToken)
|
||||
overrideString("OPENFLARE_DISCOVERY_TOKEN", &cfg.DiscoveryToken)
|
||||
overrideString("OPENFLARE_NODE_NAME", &cfg.NodeName)
|
||||
overrideString("OPENFLARE_NODE_IP", &cfg.NodeIP)
|
||||
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
|
||||
@@ -289,6 +293,11 @@ func applyEnvOverrides(cfg *Config) {
|
||||
cfg.RequestTimeout = duration
|
||||
}
|
||||
}
|
||||
if value := strings.TrimSpace(os.Getenv("OPENFLARE_MMDB_UPDATE_INTERVAL")); value != "" {
|
||||
if duration, err := parseDurationValue(value); err == nil {
|
||||
cfg.MMDBUpdateInterval = duration
|
||||
}
|
||||
}
|
||||
if value := strings.TrimSpace(os.Getenv("OPENFLARE_OPENRESTY_OBSERVABILITY_PORT")); value != "" {
|
||||
var port int
|
||||
if _, err := fmt.Sscanf(value, "%d", &port); err == nil {
|
||||
@@ -327,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 == "" {
|
||||
@@ -342,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
|
||||
}
|
||||
|
||||
@@ -349,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)
|
||||
@@ -377,38 +389,6 @@ func detectHostname() string {
|
||||
return strings.TrimSpace(host)
|
||||
}
|
||||
|
||||
func normalizeResolverList(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func detectNodeIP() string {
|
||||
if ip := detectOutboundNodeIP(); ip != "" {
|
||||
return ip
|
||||
|
||||
@@ -161,41 +161,6 @@ func TestLoadNormalizesExplicitResolvers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadKeepsDeprecatedDockerFieldsForCompatibility(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "agent.json")
|
||||
payload := map[string]any{
|
||||
"server_url": "http://127.0.0.1:3000",
|
||||
"agent_token": "token",
|
||||
"node_name": "edge-01",
|
||||
"node_ip": "10.0.0.8",
|
||||
"openresty_container_name": "openflare-openresty",
|
||||
"openresty_docker_image": "openresty/openresty:alpine",
|
||||
"docker_binary": "docker",
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal config: %v", err)
|
||||
}
|
||||
if err = os.WriteFile(configPath, data, 0o644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
if cfg.OpenrestyContainerName != "openflare-openresty" {
|
||||
t.Fatalf("unexpected container name: %s", cfg.OpenrestyContainerName)
|
||||
}
|
||||
if cfg.OpenrestyDockerImage != "openresty/openresty:alpine" {
|
||||
t.Fatalf("unexpected image: %s", cfg.OpenrestyDockerImage)
|
||||
}
|
||||
if cfg.DockerBinary != "docker" {
|
||||
t.Fatalf("unexpected docker binary: %s", cfg.DockerBinary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadUsesCustomDataDirForGeneratedFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "agent.json")
|
||||
@@ -261,7 +226,7 @@ func TestLoadUsesEnvConfigWhenFileIsMissing(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
if cfg.ServerURL != "http://127.0.0.1:3000" || cfg.AgentToken != "token" {
|
||||
if cfg.ServerURL != "http://127.0.0.1:3000" || cfg.AccessToken != "token" {
|
||||
t.Fatalf("unexpected env auth config: %#v", cfg)
|
||||
}
|
||||
if cfg.OpenrestyPath != "/usr/bin/openresty" {
|
||||
@@ -369,8 +334,8 @@ func TestLoadEnvOverridesConfigFile(t *testing.T) {
|
||||
if cfg.ServerURL != "http://new:3000" {
|
||||
t.Fatalf("expected server url from env, got %s", cfg.ServerURL)
|
||||
}
|
||||
if cfg.AgentToken != "new-token" {
|
||||
t.Fatalf("expected token from env, got %s", cfg.AgentToken)
|
||||
if cfg.AccessToken != "new-token" {
|
||||
t.Fatalf("expected token from env, got %s", cfg.AccessToken)
|
||||
}
|
||||
if cfg.OpenrestyPath != "/new/openresty" {
|
||||
t.Fatalf("expected openresty path from env, got %s", cfg.OpenrestyPath)
|
||||
@@ -420,7 +385,7 @@ func TestSavePersistsMillisecondsAndOmitsRuntimeVersions(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
cfg.NginxVersion = "1.27.1.2"
|
||||
cfg.ExtVersion = "1.27.1.2"
|
||||
cfg.HeartbeatInterval = MillisecondDuration(5 * time.Second)
|
||||
cfg.RequestTimeout = MillisecondDuration(7 * time.Second)
|
||||
cfg.OpenrestyResolvers = []string{"10.0.0.2", "1.1.1.1"}
|
||||
@@ -462,15 +427,6 @@ func TestSavePersistsMillisecondsAndOmitsRuntimeVersions(t *testing.T) {
|
||||
if _, ok := decoded["nginx_path"]; ok {
|
||||
t.Fatal("legacy nginx_path should not be persisted")
|
||||
}
|
||||
if _, ok := decoded["openresty_container_name"]; ok {
|
||||
t.Fatal("deprecated openresty_container_name should not be persisted by default")
|
||||
}
|
||||
if _, ok := decoded["openresty_docker_image"]; ok {
|
||||
t.Fatal("deprecated openresty_docker_image should not be persisted by default")
|
||||
}
|
||||
if _, ok := decoded["docker_binary"]; ok {
|
||||
t.Fatal("deprecated docker_binary should not be persisted by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitialAuthToken(t *testing.T) {
|
||||
@@ -505,7 +461,7 @@ func TestInitialAuthToken(t *testing.T) {
|
||||
var cfg *Config
|
||||
if tt.name != "nil config returns empty string" {
|
||||
cfg = &Config{
|
||||
AgentToken: tt.agentToken,
|
||||
AccessToken: tt.agentToken,
|
||||
DiscoveryToken: tt.discoveryToken,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,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, " ")
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
@@ -19,18 +20,15 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"openflare/utils"
|
||||
openrestyrender "openflare/utils/render/openresty"
|
||||
|
||||
"openflare-agent/internal/protocol"
|
||||
)
|
||||
|
||||
const CertDirPlaceholder = "__OPENFLARE_CERT_DIR__"
|
||||
const RouteConfigPlaceholder = "__OPENFLARE_ROUTE_CONFIG__"
|
||||
const AccessLogPlaceholder = "__OPENFLARE_ACCESS_LOG__"
|
||||
const LuaDirPlaceholder = "__OPENFLARE_LUA_DIR__"
|
||||
const RuntimeConfigDirPlaceholder = "__OPENFLARE_RUNTIME_CONFIG_DIR__"
|
||||
const ObservabilityListenPlaceholder = "__OPENFLARE_OBSERVABILITY_LISTEN__"
|
||||
const ObservabilityPortPlaceholder = "__OPENFLARE_OBSERVABILITY_PORT__"
|
||||
const ResolverDirectivePlaceholder = "__OPENFLARE_RESOLVER_DIRECTIVE__"
|
||||
const PowStaticDirPlaceholder = "__OPENFLARE_POW_STATIC_DIR__"
|
||||
const WAFIPGroupsConfigFileName = "waf_ip_groups.json"
|
||||
|
||||
type Executor interface {
|
||||
Test(ctx context.Context) error
|
||||
@@ -106,7 +104,7 @@ func (e *PathExecutor) Reload(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *PathExecutor) EnsureRuntime(ctx context.Context, recreate bool) error {
|
||||
func (e *PathExecutor) EnsureRuntime(ctx context.Context, _ bool) error {
|
||||
if err := e.Test(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -194,6 +192,10 @@ type ApplyOutcome struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
type wafIPGroupsRuntimeConfig struct {
|
||||
Groups map[string]protocol.WAFIPGroup `json:"groups"`
|
||||
}
|
||||
|
||||
func (m *Manager) Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) ApplyOutcome {
|
||||
slog.Info("openresty apply started", "main_config", m.MainConfigPath, "route_config", m.RouteConfigPath, "cert_files", len(supportFiles))
|
||||
backup, err := m.backup()
|
||||
@@ -220,6 +222,12 @@ func (m *Manager) writeTargetFiles(mainConfig string, routeConfig string, suppor
|
||||
if err := m.writePowConfig(supportFiles); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.writeWAFConfig(supportFiles); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.writeSourceConfig(supportFiles); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.ensureMimeTypes(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -289,6 +297,7 @@ func (m *Manager) EnsureLuaAssets() error {
|
||||
return nil
|
||||
}
|
||||
allSupportFiles := append(ManagedObservabilityLuaFiles(), m.managedPowLuaFiles()...)
|
||||
allSupportFiles = append(allSupportFiles, m.managedWAFLuaFiles()...)
|
||||
powStaticFiles, err := ManagedPowStaticFiles()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load pow static files: %w", err)
|
||||
@@ -386,30 +395,32 @@ func (m *Manager) CurrentChecksum() (string, error) {
|
||||
}
|
||||
normalizedMain := string(mainData)
|
||||
if includePath := m.routeConfigIncludePath(); includePath != "" {
|
||||
normalizedMain = strings.ReplaceAll(normalizedMain, includePath, RouteConfigPlaceholder)
|
||||
normalizedMain = strings.ReplaceAll(normalizedMain, includePath, openrestyrender.RouteConfigPlaceholder)
|
||||
}
|
||||
if accessLogPath := m.accessLogRuntimePath(); accessLogPath != "" {
|
||||
normalizedMain = strings.ReplaceAll(normalizedMain, accessLogPath, AccessLogPlaceholder)
|
||||
normalizedMain = strings.ReplaceAll(normalizedMain, accessLogPath, openrestyrender.AccessLogPlaceholder)
|
||||
errorLogPath := filepath.Join(filepath.Dir(accessLogPath), "error.log")
|
||||
normalizedMain = strings.ReplaceAll(normalizedMain, filepath.ToSlash(errorLogPath), openrestyrender.ErrorLogPlaceholder)
|
||||
}
|
||||
if luaDir := m.luaRuntimePath(); luaDir != "" {
|
||||
normalizedMain = strings.ReplaceAll(normalizedMain, luaDir, LuaDirPlaceholder)
|
||||
normalizedMain = strings.ReplaceAll(normalizedMain, luaDir, openrestyrender.LuaDirPlaceholder)
|
||||
}
|
||||
if listen := strings.TrimSpace(m.OpenrestyObservabilityListen); listen != "" {
|
||||
normalizedMain = strings.ReplaceAll(normalizedMain, listen, ObservabilityListenPlaceholder)
|
||||
normalizedMain = strings.ReplaceAll(normalizedMain, listen, openrestyrender.ObservabilityListenPlaceholder)
|
||||
}
|
||||
if m.OpenrestyObservabilityPort > 0 {
|
||||
normalizedMain = strings.ReplaceAll(normalizedMain, fmt.Sprintf("%d", m.OpenrestyObservabilityPort), ObservabilityPortPlaceholder)
|
||||
normalizedMain = strings.ReplaceAll(normalizedMain, fmt.Sprintf("%d", m.OpenrestyObservabilityPort), openrestyrender.ObservabilityPortPlaceholder)
|
||||
}
|
||||
if resolverDirective := strings.TrimSpace(m.OpenrestyResolverDirective); resolverDirective != "" {
|
||||
normalizedMain = strings.ReplaceAll(normalizedMain, resolverDirective, ResolverDirectivePlaceholder)
|
||||
}
|
||||
normalizedRoute := string(data)
|
||||
if m.NginxCertDir != "" {
|
||||
normalizedRoute = strings.ReplaceAll(normalizedRoute, m.NginxCertDir, CertDirPlaceholder)
|
||||
normalizedRoute = strings.ReplaceAll(normalizedRoute, m.NginxCertDir, openrestyrender.CertDirPlaceholder)
|
||||
}
|
||||
if luaDir := m.luaRuntimePath(); luaDir != "" {
|
||||
normalizedRoute = strings.ReplaceAll(normalizedRoute, luaDir+"/pow/static", PowStaticDirPlaceholder)
|
||||
normalizedRoute = strings.ReplaceAll(normalizedRoute, luaDir, LuaDirPlaceholder)
|
||||
normalizedRoute = strings.ReplaceAll(normalizedRoute, luaDir+"/pow/static", openrestyrender.PowStaticDirPlaceholder)
|
||||
normalizedRoute = strings.ReplaceAll(normalizedRoute, luaDir, openrestyrender.LuaDirPlaceholder)
|
||||
}
|
||||
files, err := m.readManagedSupportFiles()
|
||||
if err != nil {
|
||||
@@ -420,6 +431,77 @@ func (m *Manager) CurrentChecksum() (string, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *Manager) WAFIPGroupChecksums() (map[string]string, error) {
|
||||
config, err := m.readWAFIPGroupsRuntimeConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[string]string, len(config.Groups))
|
||||
for id, group := range config.Groups {
|
||||
if strings.TrimSpace(group.Checksum) != "" {
|
||||
result[id] = strings.TrimSpace(group.Checksum)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *Manager) SyncWAFIPGroups(groups []protocol.WAFIPGroup) error {
|
||||
if m.RuntimeConfigDir == "" || len(groups) == 0 {
|
||||
return nil
|
||||
}
|
||||
config, err := m.readWAFIPGroupsRuntimeConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if config.Groups == nil {
|
||||
config.Groups = make(map[string]protocol.WAFIPGroup)
|
||||
}
|
||||
for _, group := range groups {
|
||||
if group.ID == 0 {
|
||||
continue
|
||||
}
|
||||
config.Groups[fmt.Sprintf("%d", group.ID)] = group
|
||||
}
|
||||
data, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(m.RuntimeConfigDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(m.RuntimeConfigDir, WAFIPGroupsConfigFileName)
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return fmt.Errorf("write %s: %w", WAFIPGroupsConfigFileName, err)
|
||||
}
|
||||
slog.Info("synced waf ip groups", "path", path, "group_count", len(groups))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) readWAFIPGroupsRuntimeConfig() (*wafIPGroupsRuntimeConfig, error) {
|
||||
config := &wafIPGroupsRuntimeConfig{Groups: map[string]protocol.WAFIPGroup{}}
|
||||
if m.RuntimeConfigDir == "" {
|
||||
return config, nil
|
||||
}
|
||||
path := filepath.Join(m.RuntimeConfigDir, WAFIPGroupsConfigFileName)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return config, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return config, nil
|
||||
}
|
||||
if err := json.Unmarshal(data, config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if config.Groups == nil {
|
||||
config.Groups = map[string]protocol.WAFIPGroup{}
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
type ExecutorOptions struct {
|
||||
NginxPath string
|
||||
MainConfigPath string
|
||||
@@ -459,7 +541,7 @@ func detectVersion(ctx context.Context, options ExecutorOptions, runner CommandR
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("run runtime -v failed: %w: %s", err, string(output))
|
||||
}
|
||||
version := parseNginxVersion(string(output))
|
||||
version := parseExtVersion(string(output))
|
||||
if version == "" {
|
||||
return "", errors.New("cannot parse runtime version from binary output")
|
||||
}
|
||||
@@ -468,7 +550,7 @@ func detectVersion(ctx context.Context, options ExecutorOptions, runner CommandR
|
||||
return "", errors.New("openresty path is empty")
|
||||
}
|
||||
|
||||
func parseNginxVersion(output string) string {
|
||||
func parseExtVersion(output string) string {
|
||||
matches := nginxVersionPattern.FindStringSubmatch(output)
|
||||
if len(matches) != 2 {
|
||||
return ""
|
||||
@@ -476,7 +558,7 @@ func parseNginxVersion(output string) string {
|
||||
return matches[1]
|
||||
}
|
||||
|
||||
var nginxVersionPattern = regexp.MustCompile(`(?im)(?:nginx|openresty) version:\s*(?:nginx|openresty)/([^\s]+)`)
|
||||
var nginxVersionPattern = regexp.MustCompile(`(?im)(?:nginx|openresty) version:\s*(?:nginx|openresty)/(\S+)`)
|
||||
|
||||
func isIgnorableOpenrestyStopError(output string) bool {
|
||||
text := strings.ToLower(strings.TrimSpace(output))
|
||||
@@ -503,6 +585,8 @@ type backupState struct {
|
||||
RouteData []byte
|
||||
Files []protocol.SupportFile
|
||||
PowConfig *protocol.SupportFile
|
||||
WAFConfig *protocol.SupportFile
|
||||
SourceConfig *protocol.SupportFile
|
||||
}
|
||||
|
||||
type managedFile struct {
|
||||
@@ -564,6 +648,16 @@ func (m *Manager) backup() (*backupState, error) {
|
||||
return nil, err
|
||||
}
|
||||
state.PowConfig = powConfig
|
||||
wafConfig, err := m.readRuntimeConfigFile("waf_config.json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state.WAFConfig = wafConfig
|
||||
sourceConfig, err := m.readRuntimeConfigFile(openrestyrender.SourceConfigFileName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state.SourceConfig = sourceConfig
|
||||
slog.Debug("backup captured", "main_exists", state.MainExisted, "route_exists", state.RouteExisted, "cert_files", len(state.Files))
|
||||
return state, nil
|
||||
}
|
||||
@@ -592,7 +686,13 @@ func (m *Manager) restore(state *backupState) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return m.restorePowConfig(state)
|
||||
if err := m.restoreRuntimeConfig(state.PowConfig, "pow_config.json"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.restoreRuntimeConfig(state.WAFConfig, "waf_config.json"); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.restoreRuntimeConfig(state.SourceConfig, openrestyrender.SourceConfigFileName)
|
||||
}
|
||||
|
||||
func (m *Manager) writeCertFiles(certFiles []protocol.SupportFile) error {
|
||||
@@ -628,10 +728,50 @@ func (m *Manager) writePowConfig(supportFiles []protocol.SupportFile) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) writeWAFConfig(supportFiles []protocol.SupportFile) error {
|
||||
if m.RuntimeConfigDir == "" {
|
||||
return nil
|
||||
}
|
||||
configPath := filepath.Join(m.RuntimeConfigDir, "waf_config.json")
|
||||
for _, file := range supportFiles {
|
||||
if file.Path == "waf_config.json" {
|
||||
if err := os.WriteFile(configPath, []byte(file.Content), 0o644); err != nil {
|
||||
return fmt.Errorf("write waf_config.json: %w", err)
|
||||
}
|
||||
slog.Info("wrote waf config", "path", configPath, "size", len(file.Content))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove waf_config.json: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) writeSourceConfig(supportFiles []protocol.SupportFile) error {
|
||||
if m.RuntimeConfigDir == "" {
|
||||
return nil
|
||||
}
|
||||
configPath := filepath.Join(m.RuntimeConfigDir, openrestyrender.SourceConfigFileName)
|
||||
for _, file := range supportFiles {
|
||||
if file.Path == openrestyrender.SourceConfigFileName {
|
||||
if err := os.WriteFile(configPath, []byte(file.Content), 0o644); err != nil {
|
||||
return fmt.Errorf("write %s: %w", openrestyrender.SourceConfigFileName, err)
|
||||
}
|
||||
slog.Info("wrote openresty source config", "path", configPath, "size", len(file.Content))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove %s: %w", openrestyrender.SourceConfigFileName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) writeManagedCertFiles(certFiles []protocol.SupportFile) error {
|
||||
files := make([]managedFile, 0, len(certFiles))
|
||||
for _, file := range certFiles {
|
||||
if file.Path == "pow_config.json" {
|
||||
if file.Path == "pow_config.json" || file.Path == "waf_config.json" || file.Path == openrestyrender.SourceConfigFileName {
|
||||
continue
|
||||
}
|
||||
targetPath, err := m.certFileTargetPath(file.Path)
|
||||
@@ -696,10 +836,14 @@ func (m *Manager) readCertFiles() ([]protocol.SupportFile, error) {
|
||||
}
|
||||
|
||||
func (m *Manager) readPowConfigFile() (*protocol.SupportFile, error) {
|
||||
return m.readRuntimeConfigFile("pow_config.json")
|
||||
}
|
||||
|
||||
func (m *Manager) readRuntimeConfigFile(name string) (*protocol.SupportFile, error) {
|
||||
if m.RuntimeConfigDir == "" {
|
||||
return nil, nil
|
||||
}
|
||||
configPath := filepath.Join(m.RuntimeConfigDir, "pow_config.json")
|
||||
configPath := filepath.Join(m.RuntimeConfigDir, name)
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
@@ -708,7 +852,7 @@ func (m *Manager) readPowConfigFile() (*protocol.SupportFile, error) {
|
||||
return nil, err
|
||||
}
|
||||
return &protocol.SupportFile{
|
||||
Path: "pow_config.json",
|
||||
Path: name,
|
||||
Content: string(data),
|
||||
}, nil
|
||||
}
|
||||
@@ -725,21 +869,28 @@ func (m *Manager) readManagedSupportFiles() ([]protocol.SupportFile, error) {
|
||||
if powConfig != nil {
|
||||
files = append(files, *powConfig)
|
||||
}
|
||||
wafConfig, err := m.readRuntimeConfigFile("waf_config.json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if wafConfig != nil {
|
||||
files = append(files, *wafConfig)
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (m *Manager) restorePowConfig(state *backupState) error {
|
||||
if state == nil || m.RuntimeConfigDir == "" {
|
||||
func (m *Manager) restoreRuntimeConfig(file *protocol.SupportFile, name string) error {
|
||||
if m.RuntimeConfigDir == "" {
|
||||
return nil
|
||||
}
|
||||
configPath := filepath.Join(m.RuntimeConfigDir, "pow_config.json")
|
||||
if state.PowConfig == nil {
|
||||
configPath := filepath.Join(m.RuntimeConfigDir, name)
|
||||
if file == nil {
|
||||
if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return os.WriteFile(configPath, []byte(state.PowConfig.Content), 0o644)
|
||||
return os.WriteFile(configPath, []byte(file.Content), 0o644)
|
||||
}
|
||||
|
||||
func (m *Manager) writeSafeDefaultFallbackFiles() error {
|
||||
@@ -973,11 +1124,11 @@ func (m *Manager) ensureMimeTypes() error {
|
||||
func (m *Manager) renderRouteConfig(content string) string {
|
||||
rendered := content
|
||||
if m.NginxCertDir != "" {
|
||||
rendered = strings.ReplaceAll(rendered, CertDirPlaceholder, m.NginxCertDir)
|
||||
rendered = strings.ReplaceAll(rendered, openrestyrender.CertDirPlaceholder, m.NginxCertDir)
|
||||
}
|
||||
if luaDir := m.luaRuntimePath(); luaDir != "" {
|
||||
rendered = strings.ReplaceAll(rendered, LuaDirPlaceholder, luaDir)
|
||||
rendered = strings.ReplaceAll(rendered, PowStaticDirPlaceholder, luaDir+"/pow/static")
|
||||
rendered = strings.ReplaceAll(rendered, openrestyrender.LuaDirPlaceholder, luaDir)
|
||||
rendered = strings.ReplaceAll(rendered, openrestyrender.PowStaticDirPlaceholder, luaDir+"/pow/static")
|
||||
}
|
||||
return rendered
|
||||
}
|
||||
@@ -985,19 +1136,21 @@ func (m *Manager) renderRouteConfig(content string) string {
|
||||
func (m *Manager) renderMainConfig(content string) string {
|
||||
rendered := content
|
||||
if includePath := m.routeConfigIncludePath(); includePath != "" {
|
||||
rendered = strings.ReplaceAll(rendered, RouteConfigPlaceholder, includePath)
|
||||
rendered = strings.ReplaceAll(rendered, openrestyrender.RouteConfigPlaceholder, includePath)
|
||||
}
|
||||
if accessLogPath := m.accessLogRuntimePath(); accessLogPath != "" {
|
||||
rendered = strings.ReplaceAll(rendered, AccessLogPlaceholder, accessLogPath)
|
||||
rendered = strings.ReplaceAll(rendered, openrestyrender.AccessLogPlaceholder, accessLogPath)
|
||||
errorLogPath := filepath.Join(filepath.Dir(accessLogPath), "error.log")
|
||||
rendered = strings.ReplaceAll(rendered, openrestyrender.ErrorLogPlaceholder, filepath.ToSlash(errorLogPath))
|
||||
}
|
||||
if luaDir := m.luaRuntimePath(); luaDir != "" {
|
||||
rendered = strings.ReplaceAll(rendered, LuaDirPlaceholder, luaDir)
|
||||
rendered = strings.ReplaceAll(rendered, openrestyrender.LuaDirPlaceholder, luaDir)
|
||||
}
|
||||
if listen := strings.TrimSpace(m.OpenrestyObservabilityListen); listen != "" {
|
||||
rendered = strings.ReplaceAll(rendered, ObservabilityListenPlaceholder, listen)
|
||||
rendered = strings.ReplaceAll(rendered, openrestyrender.ObservabilityListenPlaceholder, listen)
|
||||
}
|
||||
if m.OpenrestyObservabilityPort > 0 {
|
||||
rendered = strings.ReplaceAll(rendered, ObservabilityPortPlaceholder, fmt.Sprintf("%d", m.OpenrestyObservabilityPort))
|
||||
rendered = strings.ReplaceAll(rendered, openrestyrender.ObservabilityPortPlaceholder, fmt.Sprintf("%d", m.OpenrestyObservabilityPort))
|
||||
}
|
||||
if resolverDirective := strings.TrimSpace(m.OpenrestyResolverDirective); resolverDirective != "" {
|
||||
rendered = strings.ReplaceAll(rendered, ResolverDirectivePlaceholder, resolverDirective)
|
||||
@@ -1014,23 +1167,32 @@ func (m *Manager) managedPowLuaFiles() []protocol.SupportFile {
|
||||
return files
|
||||
}
|
||||
|
||||
func ObservabilityListenAddress(openrestyPath string, port int) string {
|
||||
func (m *Manager) managedWAFLuaFiles() []protocol.SupportFile {
|
||||
files := ManagedWAFLuaFiles()
|
||||
runtimeConfigDir := filepath.ToSlash(strings.TrimSpace(m.RuntimeConfigDir))
|
||||
for index := range files {
|
||||
files[index].Content = strings.ReplaceAll(files[index].Content, RuntimeConfigDirPlaceholder, runtimeConfigDir)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func ObservabilityListenAddress(port int) string {
|
||||
if port <= 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("127.0.0.1:%d", port)
|
||||
}
|
||||
|
||||
func ResolverDirective(openrestyPath string, explicitResolvers []string) string {
|
||||
resolvers := resolverAddresses(openrestyPath, explicitResolvers)
|
||||
func ResolverDirective(explicitResolvers []string) string {
|
||||
resolvers := resolverAddresses(explicitResolvers)
|
||||
if len(resolvers) == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" resolver %s valid=30s ipv6=off;\n resolver_timeout 5s;\n", strings.Join(resolvers, " "))
|
||||
}
|
||||
|
||||
func resolverAddresses(openrestyPath string, explicitResolvers []string) []string {
|
||||
if resolvers := normalizeResolverAddresses(explicitResolvers); len(resolvers) > 0 {
|
||||
func resolverAddresses(explicitResolvers []string) []string {
|
||||
if resolvers := utils.UniqueAndCleanStringSlice(explicitResolvers); len(resolvers) > 0 {
|
||||
return resolvers
|
||||
}
|
||||
data, err := os.ReadFile("/etc/resolv.conf")
|
||||
@@ -1073,29 +1235,6 @@ func isUsableDockerResolver(addr string) bool {
|
||||
return !ip.IsLoopback() && !ip.IsUnspecified()
|
||||
}
|
||||
|
||||
func normalizeResolverAddresses(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
resolvers := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
addr := strings.TrimSpace(value)
|
||||
if addr == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[addr]; ok {
|
||||
continue
|
||||
}
|
||||
seen[addr] = struct{}{}
|
||||
resolvers = append(resolvers, addr)
|
||||
}
|
||||
if len(resolvers) == 0 {
|
||||
return nil
|
||||
}
|
||||
return resolvers
|
||||
}
|
||||
|
||||
func RequiresRuntimeResolver(originURL string) bool {
|
||||
parsed, err := url.Parse(strings.TrimSpace(originURL))
|
||||
if err != nil || parsed.Hostname() == "" {
|
||||
|
||||
@@ -256,13 +256,13 @@ func TestManagerApplyAndChecksumIncludeMainConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNginxVersionIgnoresDockerEntrypointPaths(t *testing.T) {
|
||||
func TestParseExtVersionIgnoresDockerEntrypointPaths(t *testing.T) {
|
||||
output := strings.Join([]string{
|
||||
"/docker-entrypoint.sh: /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh: info: can not modify /etc/nginx/conf.d/default.conf (read-only file system?)",
|
||||
"nginx version: openresty/1.27.1.2",
|
||||
}, "\n")
|
||||
|
||||
version := parseNginxVersion(output)
|
||||
version := parseExtVersion(output)
|
||||
if version != "1.27.1.2" {
|
||||
t.Fatalf("unexpected version: %s", version)
|
||||
}
|
||||
@@ -392,7 +392,7 @@ func TestManagerCheckHealthFailsWhenStubStatusUnavailable(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolverDirectiveUsesExplicitResolvers(t *testing.T) {
|
||||
got := ResolverDirective("", []string{"10.0.0.2", "1.1.1.1"})
|
||||
got := ResolverDirective([]string{"10.0.0.2", "1.1.1.1"})
|
||||
if !strings.Contains(got, "resolver 10.0.0.2 1.1.1.1") {
|
||||
t.Fatalf("expected explicit resolver directive, got %q", got)
|
||||
}
|
||||
@@ -545,7 +545,7 @@ func TestManagerEnsureLuaAssetsWritesReadableFiles(t *testing.T) {
|
||||
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", "check.lua"))
|
||||
data, err := os.ReadFile(filepath.Join(manager.LuaDir, "pow", "runtime.lua"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read pow lua file: %v", err)
|
||||
}
|
||||
@@ -667,11 +667,11 @@ func TestManagerCurrentChecksumIncludesPowConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestManagedPowLuaFilesUseInternalChallengeFlow(t *testing.T) {
|
||||
if !strings.Contains(openRestyPowCheckLua, `return ngx.exec("/.within.website/x/cmd/anubis/api/make-challenge")`) {
|
||||
t.Fatal("expected check.lua to internally execute make-challenge instead of issuing a 302 redirect")
|
||||
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(openRestyPowCheckLua, "ngx.redirect(") {
|
||||
t.Fatal("expected check.lua to avoid external redirects for challenge rendering")
|
||||
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")
|
||||
@@ -682,11 +682,11 @@ func TestManagedPowLuaFilesUseInternalChallengeFlow(t *testing.T) {
|
||||
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(openRestyPowCheckLua, `pow_sessions:set(session_key, "1", session_ttl)`) {
|
||||
t.Fatal("expected check.lua to refresh the PoW session TTL on each valid request")
|
||||
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(openRestyPowCheckLua, `ngx.header["Set-Cookie"] = session_cookie(cookie_val, session_ttl)`) {
|
||||
t.Fatal("expected check.lua to refresh the browser session cookie 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")
|
||||
@@ -915,11 +915,42 @@ func TestManagerApplyRejectsCertFilePathTraversal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerSyncWAFIPGroupsWritesDeltaRuntimeFile(t *testing.T) {
|
||||
manager := &Manager{RuntimeConfigDir: t.TempDir()}
|
||||
|
||||
if err := manager.SyncWAFIPGroups([]protocol.WAFIPGroup{
|
||||
{ID: 1, Enabled: true, IPList: []string{"203.0.113.10"}, Checksum: "sum-1"},
|
||||
}); err != nil {
|
||||
t.Fatalf("SyncWAFIPGroups failed: %v", err)
|
||||
}
|
||||
if err := manager.SyncWAFIPGroups([]protocol.WAFIPGroup{
|
||||
{ID: 2, Enabled: true, IPList: []string{"198.51.100.10"}, Checksum: "sum-2"},
|
||||
}); err != nil {
|
||||
t.Fatalf("SyncWAFIPGroups second delta failed: %v", err)
|
||||
}
|
||||
|
||||
checksums, err := manager.WAFIPGroupChecksums()
|
||||
if err != nil {
|
||||
t.Fatalf("WAFIPGroupChecksums failed: %v", err)
|
||||
}
|
||||
if checksums["1"] != "sum-1" || checksums["2"] != "sum-2" {
|
||||
t.Fatalf("expected merged checksums, got %#v", checksums)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(manager.RuntimeConfigDir, WAFIPGroupsConfigFileName))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read runtime ip group file: %v", err)
|
||||
}
|
||||
text := string(data)
|
||||
if !strings.Contains(text, "203.0.113.10") || !strings.Contains(text, "198.51.100.10") {
|
||||
t.Fatalf("expected runtime file to keep both groups, got %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObservabilityListenAddress(t *testing.T) {
|
||||
if got := ObservabilityListenAddress("", 18081); got != "127.0.0.1:18081" {
|
||||
if got := ObservabilityListenAddress(18081); got != "127.0.0.1:18081" {
|
||||
t.Fatalf("unexpected default observability listen address: %s", got)
|
||||
}
|
||||
if got := ObservabilityListenAddress("/usr/local/openresty/nginx/sbin/openresty", 18081); got != "127.0.0.1:18081" {
|
||||
if got := ObservabilityListenAddress(18081); got != "127.0.0.1:18081" {
|
||||
t.Fatalf("unexpected path observability listen address: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ package nginx
|
||||
import "openflare-agent/internal/protocol"
|
||||
|
||||
const (
|
||||
openRestyObservabilityWindowTTL = 7200
|
||||
openRestyObservabilityWindowSize = 60
|
||||
openRestyObservabilityWindowTTL = "7200"
|
||||
openRestyObservabilityWindowSize = "60"
|
||||
)
|
||||
|
||||
const openRestyObservabilityInitLua = `local dict = ngx.shared.openflare_observability
|
||||
@@ -25,9 +25,9 @@ if request_uri == "/openflare/observability" or request_uri == "/openflare/stub_
|
||||
return
|
||||
end
|
||||
|
||||
local ttl = ` + "7200" + `
|
||||
local ttl = ` + openRestyObservabilityWindowTTL + `
|
||||
local now = ngx.time()
|
||||
local window_size = ` + "60" + `
|
||||
local window_size = ` + openRestyObservabilityWindowSize + `
|
||||
local window_start = now - (now % window_size)
|
||||
|
||||
local function ensure_counter(key)
|
||||
@@ -109,7 +109,7 @@ if not dict then
|
||||
end
|
||||
|
||||
local now = ngx.time()
|
||||
local window_size = ` + "60" + `
|
||||
local window_size = ` + openRestyObservabilityWindowSize + `
|
||||
local window_start = now - (now % window_size)
|
||||
local current_window = tostring(window_start)
|
||||
|
||||
|
||||
@@ -10,11 +10,14 @@ import (
|
||||
//go:embed pow_static
|
||||
var powStaticFS embed.FS
|
||||
|
||||
const openRestyPowCheckLua = `local source = debug.getinfo(1, "S").source or ""
|
||||
const openRestyPowRuntimeLua = `local _M = {}
|
||||
|
||||
function _M.check()
|
||||
local source = debug.getinfo(1, "S").source or ""
|
||||
if string.sub(source, 1, 1) == "@" then
|
||||
local script_path = string.sub(source, 2)
|
||||
local base_dir = string.match(script_path, "^(.*)/pow/[^/]+%.lua$")
|
||||
if base_dir and base_dir ~= "" then
|
||||
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
|
||||
@@ -158,6 +161,21 @@ ngx.req.set_uri_args({
|
||||
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"
|
||||
@@ -473,6 +491,7 @@ 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},
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
package nginx
|
||||
|
||||
import "openflare-agent/internal/protocol"
|
||||
|
||||
const openRestyWAFRuntimeLua = `local _M = {}
|
||||
|
||||
function _M.check()
|
||||
local cjson = require "cjson.safe"
|
||||
|
||||
local config_dict = ngx.shared.openflare_waf_config
|
||||
|
||||
local function read_file(path)
|
||||
local f = io.open(path, "r")
|
||||
if not f then
|
||||
return nil
|
||||
end
|
||||
local content = f:read("*a")
|
||||
f:close()
|
||||
return content
|
||||
end
|
||||
|
||||
local function load_config()
|
||||
local paths = {
|
||||
"__OPENFLARE_RUNTIME_CONFIG_DIR__/waf_config.json",
|
||||
"/etc/nginx/openflare-lua/waf_config.json",
|
||||
"/usr/local/openresty/nginx/conf/waf_config.json"
|
||||
}
|
||||
for _, path in ipairs(paths) do
|
||||
local content = read_file(path)
|
||||
if content and content ~= "" then
|
||||
local hash = ngx.md5(content)
|
||||
if config_dict:get("_config_hash") == hash then
|
||||
local cached = config_dict:get("_config_json")
|
||||
if cached then
|
||||
local decoded = cjson.decode(cached)
|
||||
if decoded then
|
||||
return decoded
|
||||
end
|
||||
end
|
||||
end
|
||||
local decoded = cjson.decode(content)
|
||||
if decoded then
|
||||
config_dict:set("_config_hash", hash, 0)
|
||||
config_dict:set("_config_json", content, 0)
|
||||
return decoded
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function load_ip_groups()
|
||||
local paths = {
|
||||
"__OPENFLARE_RUNTIME_CONFIG_DIR__/waf_ip_groups.json",
|
||||
"/etc/nginx/openflare-lua/waf_ip_groups.json",
|
||||
"/usr/local/openresty/nginx/conf/waf_ip_groups.json"
|
||||
}
|
||||
for _, path in ipairs(paths) do
|
||||
local content = read_file(path)
|
||||
if content and content ~= "" then
|
||||
local hash = ngx.md5(content)
|
||||
if config_dict:get("_ip_groups_hash") == hash then
|
||||
local cached = config_dict:get("_ip_groups_json")
|
||||
if cached then
|
||||
local decoded = cjson.decode(cached)
|
||||
if decoded then
|
||||
return decoded
|
||||
end
|
||||
end
|
||||
end
|
||||
local decoded = cjson.decode(content)
|
||||
if decoded then
|
||||
config_dict:set("_ip_groups_hash", hash, 0)
|
||||
config_dict:set("_ip_groups_json", content, 0)
|
||||
return decoded
|
||||
end
|
||||
end
|
||||
end
|
||||
return { groups = {} }
|
||||
end
|
||||
|
||||
local function list_contains(items, value)
|
||||
if not items or type(items) ~= "table" or not value or value == "" then
|
||||
return false
|
||||
end
|
||||
for _, item in ipairs(items) do
|
||||
if item == value then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function parse_ipv4(value)
|
||||
local a, b, c, d = string.match(value or "", "^(%d+)%.(%d+)%.(%d+)%.(%d+)$")
|
||||
if not a then
|
||||
return nil
|
||||
end
|
||||
a, b, c, d = tonumber(a), tonumber(b), tonumber(c), tonumber(d)
|
||||
if a > 255 or b > 255 or c > 255 or d > 255 then
|
||||
return nil
|
||||
end
|
||||
return ((a * 256 + b) * 256 + c) * 256 + d
|
||||
end
|
||||
|
||||
local function ipv4_in_cidr(ip, cidr)
|
||||
local base, bits = string.match(cidr or "", "^([^/]+)/(%d+)$")
|
||||
if not base then
|
||||
return false
|
||||
end
|
||||
bits = tonumber(bits)
|
||||
if not bits or bits < 0 or bits > 32 then
|
||||
return false
|
||||
end
|
||||
local ip_num = parse_ipv4(ip)
|
||||
local base_num = parse_ipv4(base)
|
||||
if not ip_num or not base_num then
|
||||
return false
|
||||
end
|
||||
if bits == 0 then
|
||||
return true
|
||||
end
|
||||
local mask = 4294967295 - (2 ^ (32 - bits) - 1)
|
||||
return (ip_num - (ip_num % (2 ^ (32 - bits)))) == (base_num - (base_num % (2 ^ (32 - bits))))
|
||||
end
|
||||
|
||||
local function ip_matches(items, ip)
|
||||
if not items or type(items) ~= "table" or not ip or ip == "" then
|
||||
return false
|
||||
end
|
||||
for _, item in ipairs(items) do
|
||||
if item == ip then
|
||||
return true
|
||||
end
|
||||
if string.find(item, "/", 1, true) and ipv4_in_cidr(ip, item) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function ip_matches_group_ids(group_ids, ip, ip_groups_config)
|
||||
if not group_ids or type(group_ids) ~= "table" or not ip or ip == "" then
|
||||
return false
|
||||
end
|
||||
local groups = (ip_groups_config or {}).groups or {}
|
||||
for _, id in ipairs(group_ids) do
|
||||
local group = groups[tostring(id)]
|
||||
if group and group.enabled and ip_matches(group.ip_list, ip) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function lookup_country(ip)
|
||||
local ok, maxminddb = pcall(require, "resty.maxminddb")
|
||||
if not ok or not maxminddb then
|
||||
return nil
|
||||
end
|
||||
local paths = {
|
||||
"__OPENFLARE_RUNTIME_CONFIG_DIR__/GeoLite2-Country.mmdb",
|
||||
"/etc/openflare/GeoLite2-Country.mmdb",
|
||||
"/usr/local/share/openflare/GeoLite2-Country.mmdb"
|
||||
}
|
||||
for _, path in ipairs(paths) do
|
||||
local opened = pcall(maxminddb.init, path)
|
||||
if opened then
|
||||
local res, err = maxminddb.lookup(ip)
|
||||
if res and res.country and res.country.iso_code then
|
||||
return string.upper(res.country.iso_code)
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function group_by_id(config)
|
||||
local result = {}
|
||||
for _, group in ipairs(config.rule_groups or {}) do
|
||||
result[tostring(group.id)] = group
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
local function active_groups(config, groups)
|
||||
local site = ngx.var.openflare_waf_site or ""
|
||||
local ids = (config.site_rule_groups or {})[site]
|
||||
local result = {}
|
||||
for _, group in ipairs(config.rule_groups or {}) do
|
||||
if group.is_global then
|
||||
result[#result + 1] = group
|
||||
end
|
||||
end
|
||||
if ids then
|
||||
local by_id = group_by_id(config)
|
||||
for _, id in ipairs(ids) do
|
||||
local group = by_id[tostring(id)]
|
||||
if group and not group.is_global then
|
||||
result[#result + 1] = group
|
||||
end
|
||||
end
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
local function exit_with_group(group)
|
||||
ngx.ctx.openflare_waf_blocked = true
|
||||
ngx.status = tonumber(group.block_status_code) or 418
|
||||
local body = group.block_response_body or ""
|
||||
if body ~= "" then
|
||||
ngx.header["Content-Type"] = "text/html; charset=utf-8"
|
||||
ngx.say(body)
|
||||
end
|
||||
return ngx.exit(ngx.status)
|
||||
end
|
||||
|
||||
local config = load_config()
|
||||
if not config then
|
||||
if config_dict:add("_missing_config_logged", true, 60) then
|
||||
ngx.log(ngx.WARN, "openflare waf config is missing or invalid; requests will be allowed")
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
local ip = ngx.var.remote_addr or ""
|
||||
local groups = active_groups(config)
|
||||
local ip_groups_config = load_ip_groups()
|
||||
if #groups == 0 then
|
||||
if config_dict:add("_empty_groups_logged", true, 60) then
|
||||
ngx.log(ngx.WARN, "openflare waf has no active rule group for site: ", ngx.var.openflare_waf_site or "")
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
for _, group in ipairs(groups) do
|
||||
if ip_matches(group.ip_whitelist, ip) or ip_matches_group_ids(group.ip_whitelist_group_ids, ip, ip_groups_config) then
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
local country = nil
|
||||
for _, group in ipairs(groups) do
|
||||
if type(group.country_whitelist) == "table" and #group.country_whitelist > 0 then
|
||||
country = country or lookup_country(ip)
|
||||
if list_contains(group.country_whitelist, country) then
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
for _, group in ipairs(groups) do
|
||||
if ip_matches(group.ip_blacklist, ip) or ip_matches_group_ids(group.ip_blacklist_group_ids, ip, ip_groups_config) then
|
||||
return exit_with_group(group)
|
||||
end
|
||||
end
|
||||
|
||||
for _, group in ipairs(groups) do
|
||||
if type(group.country_blacklist) == "table" and #group.country_blacklist > 0 then
|
||||
country = country or lookup_country(ip)
|
||||
if list_contains(group.country_blacklist, country) then
|
||||
return exit_with_group(group)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return "ok"
|
||||
end
|
||||
|
||||
return _M
|
||||
`
|
||||
|
||||
const openRestyWAFCheckLua = `local source = debug.getinfo(1, "S").source or ""
|
||||
if string.sub(source, 1, 1) == "@" then
|
||||
local script_path = string.sub(source, 2)
|
||||
local base_dir = string.match(script_path, "^(.*)/waf/[^/]+%.lua$")
|
||||
if base_dir and base_dir ~= "" 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("waf.runtime").check()
|
||||
`
|
||||
|
||||
func ManagedWAFLuaFiles() []protocol.SupportFile {
|
||||
return []protocol.SupportFile{
|
||||
{Path: "waf/runtime.lua", Content: openRestyWAFRuntimeLua},
|
||||
{Path: "waf/check.lua", Content: openRestyWAFCheckLua},
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ func BuildProfile(cfg *config.Config, stateStore *state.Store) *protocol.NodeSys
|
||||
return profile
|
||||
}
|
||||
|
||||
func BuildSnapshot(cfg *config.Config, stateStore *state.Store, managed *managedOpenRestyMetrics) *protocol.NodeMetricSnapshot {
|
||||
func BuildSnapshot(cfg *config.Config, stateStore *state.Store) *protocol.NodeMetricSnapshot {
|
||||
now := time.Now().UTC()
|
||||
metric := &protocol.NodeMetricSnapshot{
|
||||
CapturedAtUnix: now.Unix(),
|
||||
@@ -56,11 +56,6 @@ func BuildSnapshot(cfg *config.Config, stateStore *state.Store, managed *managed
|
||||
|
||||
metric.NetworkRxBytes, metric.NetworkTxBytes = readLinuxNetworkTotals()
|
||||
metric.DiskReadBytes, metric.DiskWriteBytes = readLinuxDiskTotals()
|
||||
if managed != nil {
|
||||
metric.OpenrestyRxBytes = managed.OpenrestyRxBytes
|
||||
metric.OpenrestyTxBytes = managed.OpenrestyTxBytes
|
||||
metric.OpenrestyConnections = managed.OpenrestyConnections
|
||||
}
|
||||
|
||||
if stateStore == nil {
|
||||
return metric
|
||||
@@ -86,6 +81,18 @@ func BuildSnapshot(cfg *config.Config, stateStore *state.Store, managed *managed
|
||||
return metric
|
||||
}
|
||||
|
||||
func BuildOpenrestyObservation(managed *ManagedOpenRestyMetrics) *protocol.NodeOpenrestyObservation {
|
||||
if managed == nil {
|
||||
return nil
|
||||
}
|
||||
return &protocol.NodeOpenrestyObservation{
|
||||
CapturedAtUnix: time.Now().UTC().Unix(),
|
||||
OpenrestyRxBytes: managed.OpenrestyRxBytes,
|
||||
OpenrestyTxBytes: managed.OpenrestyTxBytes,
|
||||
OpenrestyConnections: managed.OpenrestyConnections,
|
||||
}
|
||||
}
|
||||
|
||||
func BuildHealthEvents(snapshot *state.Snapshot) []protocol.NodeHealthEvent {
|
||||
if snapshot == nil {
|
||||
return []protocol.NodeHealthEvent{}
|
||||
|
||||
@@ -18,7 +18,7 @@ const openRestyStubStatusPath = "/openflare/stub_status"
|
||||
|
||||
var stubStatusActivePattern = regexp.MustCompile(`Active connections:\s+(\d+)`)
|
||||
|
||||
type managedOpenRestyMetrics struct {
|
||||
type ManagedOpenRestyMetrics struct {
|
||||
TrafficReport *protocol.NodeTrafficReport
|
||||
OpenrestyRxBytes int64
|
||||
OpenrestyTxBytes int64
|
||||
@@ -38,7 +38,7 @@ type openRestyObservabilityResponse struct {
|
||||
OpenrestyTxBytes int64 `json:"openresty_tx_bytes"`
|
||||
}
|
||||
|
||||
func CollectManagedOpenRestyMetrics(cfg *config.Config) *managedOpenRestyMetrics {
|
||||
func CollectManagedOpenRestyMetrics(cfg *config.Config) *ManagedOpenRestyMetrics {
|
||||
if cfg == nil || cfg.OpenrestyObservabilityPort <= 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func CollectManagedOpenRestyMetrics(cfg *config.Config) *managedOpenRestyMetrics
|
||||
return nil
|
||||
}
|
||||
|
||||
result := &managedOpenRestyMetrics{
|
||||
result := &ManagedOpenRestyMetrics{
|
||||
TrafficReport: &protocol.NodeTrafficReport{
|
||||
WindowStartedAtUnix: observabilityResp.WindowStartedAtUnix,
|
||||
WindowEndedAtUnix: observabilityResp.WindowEndedAtUnix,
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"openflare-agent/internal/config"
|
||||
"openflare-agent/internal/protocol"
|
||||
"openflare-agent/internal/state"
|
||||
@@ -26,7 +27,7 @@ type accessLogRecord struct {
|
||||
RequestLength int64 `json:"request_length"`
|
||||
}
|
||||
|
||||
var combinedAccessLogPattern = regexp.MustCompile(`^(\S+)\s+\S+\s+\S+\s+\[([^\]]+)\]\s+"(?:\S+)\s+(\S+)(?:\s+[^"]*)?"\s+(\d{3})\s+\S+`)
|
||||
var combinedAccessLogPattern = regexp.MustCompile(`^(\S+)\s+\S+\s+\S+\s+\[([^]]+)]\s+"\S+\s+(\S+)(?:\s+[^"]*)?"\s+(\d{3})\s+\S+`)
|
||||
|
||||
type trafficAggregate struct {
|
||||
windowStartedAt time.Time
|
||||
@@ -41,12 +42,12 @@ type trafficAggregate struct {
|
||||
logs []protocol.NodeAccessLog
|
||||
}
|
||||
|
||||
func BuildTrafficReport(cfg *config.Config, stateStore *state.Store, managed *managedOpenRestyMetrics) *protocol.NodeTrafficReport {
|
||||
func BuildTrafficReport(cfg *config.Config, stateStore *state.Store, managed *ManagedOpenRestyMetrics) *protocol.NodeTrafficReport {
|
||||
report, _, _ := BuildTrafficObservability(cfg, stateStore, managed)
|
||||
return report
|
||||
}
|
||||
|
||||
func BuildTrafficObservability(cfg *config.Config, stateStore *state.Store, managed *managedOpenRestyMetrics) (*protocol.NodeTrafficReport, []protocol.NodeAccessLog, *managedOpenRestyMetrics) {
|
||||
func BuildTrafficObservability(cfg *config.Config, stateStore *state.Store, managed *ManagedOpenRestyMetrics) (*protocol.NodeTrafficReport, []protocol.NodeAccessLog, *ManagedOpenRestyMetrics) {
|
||||
if cfg == nil || stateStore == nil {
|
||||
if managed != nil && managed.TrafficReport != nil {
|
||||
return managed.TrafficReport, nil, managed
|
||||
@@ -55,7 +56,7 @@ func BuildTrafficObservability(cfg *config.Config, stateStore *state.Store, mana
|
||||
}
|
||||
|
||||
aggregate := readAccessLogDelta(cfg, stateStore)
|
||||
accessLogs := []protocol.NodeAccessLog{}
|
||||
var accessLogs []protocol.NodeAccessLog
|
||||
if aggregate != nil {
|
||||
accessLogs = aggregate.accessLogs()
|
||||
}
|
||||
@@ -87,7 +88,12 @@ func readAccessLogDelta(cfg *config.Config, stateStore *state.Store) *trafficAgg
|
||||
}
|
||||
return nil
|
||||
}
|
||||
defer file.Close()
|
||||
defer func(file *os.File) {
|
||||
err := file.Close()
|
||||
if err != nil {
|
||||
slog.Error("failed to close access log file", "error", err)
|
||||
}
|
||||
}(file)
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
@@ -270,7 +276,7 @@ func (aggregate *trafficAggregate) accessLogs() []protocol.NodeAccessLog {
|
||||
return append([]protocol.NodeAccessLog(nil), aggregate.logs...)
|
||||
}
|
||||
|
||||
func (aggregate *trafficAggregate) managedMetrics() *managedOpenRestyMetrics {
|
||||
func (aggregate *trafficAggregate) managedMetrics() *ManagedOpenRestyMetrics {
|
||||
if aggregate == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -278,7 +284,7 @@ func (aggregate *trafficAggregate) managedMetrics() *managedOpenRestyMetrics {
|
||||
if report == nil && aggregate.openrestyRxBytes <= 0 && aggregate.openrestyTxBytes <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &managedOpenRestyMetrics{
|
||||
return &ManagedOpenRestyMetrics{
|
||||
TrafficReport: report,
|
||||
OpenrestyRxBytes: aggregate.openrestyRxBytes,
|
||||
OpenrestyTxBytes: aggregate.openrestyTxBytes,
|
||||
|
||||
@@ -167,7 +167,7 @@ func TestBuildTrafficReportParsesCombinedAccessLog(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildTrafficReportReturnsManagedWindowEvenWhenRequestCountZero(t *testing.T) {
|
||||
report := BuildTrafficReport(nil, nil, &managedOpenRestyMetrics{
|
||||
report := BuildTrafficReport(nil, nil, &ManagedOpenRestyMetrics{
|
||||
TrafficReport: &protocol.NodeTrafficReport{
|
||||
WindowStartedAtUnix: 1710403200,
|
||||
WindowEndedAtUnix: 1710403260,
|
||||
|
||||
@@ -14,11 +14,13 @@ type HeartbeatAPIResponse struct {
|
||||
Data any `json:"data"`
|
||||
AgentSettings *AgentSettings `json:"agent_settings,omitempty"`
|
||||
ActiveConfig *ActiveConfigMeta `json:"active_config,omitempty"`
|
||||
WAFIPGroups []WAFIPGroup `json:"waf_ip_groups,omitempty"`
|
||||
}
|
||||
|
||||
type HeartbeatResult struct {
|
||||
AgentSettings *AgentSettings
|
||||
ActiveConfig *ActiveConfigMeta
|
||||
WAFIPGroups []WAFIPGroup
|
||||
}
|
||||
|
||||
type AgentSettings struct {
|
||||
@@ -37,6 +39,7 @@ const (
|
||||
WSMessageTypeSettings = "settings"
|
||||
WSMessageTypeActiveConfig = "active_config"
|
||||
WSMessageTypeForceSyncConfig = "force_sync_config"
|
||||
WSMessageTypeWAFIPGroups = "waf_ip_groups"
|
||||
WSMessageTypePing = "ping"
|
||||
WSMessageTypePong = "pong"
|
||||
)
|
||||
@@ -69,18 +72,20 @@ type NodePayload struct {
|
||||
NodeID string `json:"node_id"`
|
||||
Name string `json:"name"`
|
||||
IP string `json:"ip"`
|
||||
AgentVersion string `json:"agent_version"`
|
||||
NginxVersion string `json:"nginx_version"`
|
||||
Version string `json:"version"`
|
||||
ExtVersion string `json:"ext_version"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
LastError string `json:"last_error"`
|
||||
OpenrestyStatus string `json:"openresty_status"`
|
||||
OpenrestyMessage string `json:"openresty_message"`
|
||||
Profile *NodeSystemProfile `json:"profile,omitempty"`
|
||||
Snapshot *NodeMetricSnapshot `json:"snapshot,omitempty"`
|
||||
OpenrestyObservation *NodeOpenrestyObservation `json:"openresty_observation,omitempty"`
|
||||
TrafficReport *NodeTrafficReport `json:"traffic_report,omitempty"`
|
||||
AccessLogs []NodeAccessLog `json:"access_logs,omitempty"`
|
||||
BufferedObservability []BufferedObservabilityRecord `json:"buffered_observability,omitempty"`
|
||||
HealthEvents []NodeHealthEvent `json:"health_events"`
|
||||
WAFIPGroupChecksums map[string]string `json:"waf_ip_group_checksums,omitempty"`
|
||||
}
|
||||
|
||||
type NodeSystemProfile struct {
|
||||
@@ -98,19 +103,23 @@ type NodeSystemProfile struct {
|
||||
}
|
||||
|
||||
type NodeMetricSnapshot struct {
|
||||
CapturedAtUnix int64 `json:"captured_at_unix"`
|
||||
CPUUsagePercent float64 `json:"cpu_usage_percent"`
|
||||
MemoryUsedBytes int64 `json:"memory_used_bytes"`
|
||||
MemoryTotalBytes int64 `json:"memory_total_bytes"`
|
||||
StorageUsedBytes int64 `json:"storage_used_bytes"`
|
||||
StorageTotalBytes int64 `json:"storage_total_bytes"`
|
||||
DiskReadBytes int64 `json:"disk_read_bytes"`
|
||||
DiskWriteBytes int64 `json:"disk_write_bytes"`
|
||||
NetworkRxBytes int64 `json:"network_rx_bytes"`
|
||||
NetworkTxBytes int64 `json:"network_tx_bytes"`
|
||||
OpenrestyRxBytes int64 `json:"openresty_rx_bytes"`
|
||||
OpenrestyTxBytes int64 `json:"openresty_tx_bytes"`
|
||||
OpenrestyConnections int64 `json:"openresty_connections"`
|
||||
CapturedAtUnix int64 `json:"captured_at_unix"`
|
||||
CPUUsagePercent float64 `json:"cpu_usage_percent"`
|
||||
MemoryUsedBytes int64 `json:"memory_used_bytes"`
|
||||
MemoryTotalBytes int64 `json:"memory_total_bytes"`
|
||||
StorageUsedBytes int64 `json:"storage_used_bytes"`
|
||||
StorageTotalBytes int64 `json:"storage_total_bytes"`
|
||||
DiskReadBytes int64 `json:"disk_read_bytes"`
|
||||
DiskWriteBytes int64 `json:"disk_write_bytes"`
|
||||
NetworkRxBytes int64 `json:"network_rx_bytes"`
|
||||
NetworkTxBytes int64 `json:"network_tx_bytes"`
|
||||
}
|
||||
|
||||
type NodeOpenrestyObservation struct {
|
||||
CapturedAtUnix int64 `json:"captured_at_unix"`
|
||||
OpenrestyRxBytes int64 `json:"openresty_rx_bytes"`
|
||||
OpenrestyTxBytes int64 `json:"openresty_tx_bytes"`
|
||||
OpenrestyConnections int64 `json:"openresty_connections"`
|
||||
}
|
||||
|
||||
type NodeTrafficReport struct {
|
||||
@@ -133,10 +142,11 @@ type NodeAccessLog struct {
|
||||
}
|
||||
|
||||
type BufferedObservabilityRecord struct {
|
||||
WindowStartedAtUnix int64 `json:"window_started_at_unix"`
|
||||
Snapshot *NodeMetricSnapshot `json:"snapshot,omitempty"`
|
||||
TrafficReport *NodeTrafficReport `json:"traffic_report,omitempty"`
|
||||
AccessLogs []NodeAccessLog `json:"access_logs,omitempty"`
|
||||
WindowStartedAtUnix int64 `json:"window_started_at_unix"`
|
||||
Snapshot *NodeMetricSnapshot `json:"snapshot,omitempty"`
|
||||
OpenrestyObservation *NodeOpenrestyObservation `json:"openresty_observation,omitempty"`
|
||||
TrafficReport *NodeTrafficReport `json:"traffic_report,omitempty"`
|
||||
AccessLogs []NodeAccessLog `json:"access_logs,omitempty"`
|
||||
}
|
||||
|
||||
type NodeHealthEvent struct {
|
||||
@@ -148,9 +158,9 @@ type NodeHealthEvent struct {
|
||||
}
|
||||
|
||||
type RegisterNodeResponse struct {
|
||||
NodeID string `json:"node_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Name string `json:"name"`
|
||||
NodeID string `json:"node_id"`
|
||||
AccessToken string `json:"agent_token"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type ApplyLogPayload struct {
|
||||
@@ -165,13 +175,11 @@ type ApplyLogPayload struct {
|
||||
}
|
||||
|
||||
type ActiveConfigResponse struct {
|
||||
Version string `json:"version"`
|
||||
Checksum string `json:"checksum"`
|
||||
MainConfig string `json:"main_config"`
|
||||
RouteConfig string `json:"route_config"`
|
||||
RenderedConfig string `json:"rendered_config"`
|
||||
SupportFiles []SupportFile `json:"support_files"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Version string `json:"version"`
|
||||
Checksum string `json:"checksum"`
|
||||
SourceConfigJSON string `json:"source_config_json"`
|
||||
SupportFiles []SupportFile `json:"support_files"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type ActiveConfigMeta struct {
|
||||
@@ -179,6 +187,24 @@ type ActiveConfigMeta struct {
|
||||
Checksum string `json:"checksum"`
|
||||
}
|
||||
|
||||
type WAFIPGroup struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Enabled bool `json:"enabled"`
|
||||
IPList []string `json:"ip_list"`
|
||||
Checksum string `json:"checksum"`
|
||||
}
|
||||
|
||||
type WAFIPGroupSyncRequest struct {
|
||||
IDs []uint `json:"ids,omitempty"`
|
||||
Checksums map[string]string `json:"checksums,omitempty"`
|
||||
}
|
||||
|
||||
type WAFIPGroupSyncResponse struct {
|
||||
Groups []WAFIPGroup `json:"groups"`
|
||||
}
|
||||
|
||||
type SupportFile struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
|
||||
@@ -14,11 +14,12 @@ import (
|
||||
const observabilityBufferWindowSeconds = 60
|
||||
|
||||
type ObservabilityBufferRecord struct {
|
||||
WindowStartedAtUnix int64 `json:"window_started_at_unix"`
|
||||
Snapshot *protocol.NodeMetricSnapshot `json:"snapshot,omitempty"`
|
||||
TrafficReport *protocol.NodeTrafficReport `json:"traffic_report,omitempty"`
|
||||
AccessLogs []protocol.NodeAccessLog `json:"access_logs,omitempty"`
|
||||
QueuedAtUnix int64 `json:"queued_at_unix"`
|
||||
WindowStartedAtUnix int64 `json:"window_started_at_unix"`
|
||||
Snapshot *protocol.NodeMetricSnapshot `json:"snapshot,omitempty"`
|
||||
OpenrestyObservation *protocol.NodeOpenrestyObservation `json:"openresty_observation,omitempty"`
|
||||
TrafficReport *protocol.NodeTrafficReport `json:"traffic_report,omitempty"`
|
||||
AccessLogs []protocol.NodeAccessLog `json:"access_logs,omitempty"`
|
||||
QueuedAtUnix int64 `json:"queued_at_unix"`
|
||||
}
|
||||
|
||||
type ObservabilityBufferStore struct {
|
||||
@@ -31,7 +32,7 @@ func NewObservabilityBufferStore(path string) *ObservabilityBufferStore {
|
||||
}
|
||||
|
||||
func (s *ObservabilityBufferStore) Upsert(record ObservabilityBufferRecord, retainAfterUnix int64) error {
|
||||
if s == nil || record.WindowStartedAtUnix <= 0 || (record.Snapshot == nil && record.TrafficReport == nil && len(record.AccessLogs) == 0) {
|
||||
if s == nil || record.WindowStartedAtUnix <= 0 || (record.Snapshot == nil && record.OpenrestyObservation == nil && record.TrafficReport == nil && len(record.AccessLogs) == 0) {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
@@ -65,6 +66,9 @@ func mergeObservabilityBufferRecord(existing ObservabilityBufferRecord, incoming
|
||||
if incoming.Snapshot != nil {
|
||||
merged.Snapshot = incoming.Snapshot
|
||||
}
|
||||
if incoming.OpenrestyObservation != nil {
|
||||
merged.OpenrestyObservation = incoming.OpenrestyObservation
|
||||
}
|
||||
if incoming.TrafficReport != nil {
|
||||
merged.TrafficReport = incoming.TrafficReport
|
||||
}
|
||||
@@ -191,10 +195,13 @@ func (s *ObservabilityBufferStore) saveUnlocked(records []ObservabilityBufferRec
|
||||
return os.WriteFile(s.path, data, 0o644)
|
||||
}
|
||||
|
||||
func ObservabilityWindowStartedAt(snapshot *protocol.NodeMetricSnapshot, traffic *protocol.NodeTrafficReport) int64 {
|
||||
func ObservabilityWindowStartedAt(snapshot *protocol.NodeMetricSnapshot, openresty *protocol.NodeOpenrestyObservation, traffic *protocol.NodeTrafficReport) int64 {
|
||||
if traffic != nil && traffic.WindowStartedAtUnix > 0 {
|
||||
return traffic.WindowStartedAtUnix - (traffic.WindowStartedAtUnix % observabilityBufferWindowSeconds)
|
||||
}
|
||||
if openresty != nil && openresty.CapturedAtUnix > 0 {
|
||||
return openresty.CapturedAtUnix - (openresty.CapturedAtUnix % observabilityBufferWindowSeconds)
|
||||
}
|
||||
if snapshot == nil || snapshot.CapturedAtUnix <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -89,10 +89,10 @@ func TestObservabilityBufferStoreMergesAccessLogsWithinWindow(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestObservabilityWindowStartedAt(t *testing.T) {
|
||||
if value := ObservabilityWindowStartedAt(nil, &protocol.NodeTrafficReport{WindowStartedAtUnix: 1710403200}); value != 1710403200 {
|
||||
if value := ObservabilityWindowStartedAt(nil, nil, &protocol.NodeTrafficReport{WindowStartedAtUnix: 1710403200}); value != 1710403200 {
|
||||
t.Fatalf("unexpected traffic window start: %d", value)
|
||||
}
|
||||
if value := ObservabilityWindowStartedAt(&protocol.NodeMetricSnapshot{CapturedAtUnix: 1710403259}, nil); value != 1710403200 {
|
||||
if value := ObservabilityWindowStartedAt(&protocol.NodeMetricSnapshot{CapturedAtUnix: 1710403259}, nil, nil); value != 1710403200 {
|
||||
t.Fatalf("unexpected snapshot-derived window start: %d", value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -19,3 +22,129 @@ func TestEnsureNodeIDPersists(t *testing.T) {
|
||||
t.Fatal("expected node id to persist across calls")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_Load_NonExistentFile(t *testing.T) {
|
||||
// Loading from a non-existent path should succeed and return an empty Snapshot
|
||||
tempFile := filepath.Join(t.TempDir(), "nonexistent.json")
|
||||
store := NewStore(tempFile)
|
||||
|
||||
snap, err := store.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("expected Load to succeed for non-existent file, got err: %v", err)
|
||||
}
|
||||
if snap == nil {
|
||||
t.Fatal("expected non-nil snapshot")
|
||||
}
|
||||
if snap.NodeID != "" || snap.CurrentVersion != "" {
|
||||
t.Errorf("expected empty snapshot, got: %+v", snap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_Load_EmptyFile(t *testing.T) {
|
||||
// Loading from an empty file should succeed and return an empty Snapshot
|
||||
tempFile := filepath.Join(t.TempDir(), "empty.json")
|
||||
if err := os.WriteFile(tempFile, []byte(""), 0644); err != nil {
|
||||
t.Fatalf("failed to create empty file: %v", err)
|
||||
}
|
||||
|
||||
store := NewStore(tempFile)
|
||||
snap, err := store.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("expected Load to succeed for empty file, got err: %v", err)
|
||||
}
|
||||
if snap == nil {
|
||||
t.Fatal("expected non-nil snapshot")
|
||||
}
|
||||
if snap.NodeID != "" {
|
||||
t.Errorf("expected empty snapshot, got: %+v", snap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_Load_InvalidJSON(t *testing.T) {
|
||||
// Loading from a corrupted file with invalid JSON should fail with parsing error
|
||||
tempFile := filepath.Join(t.TempDir(), "corrupted.json")
|
||||
if err := os.WriteFile(tempFile, []byte("{invalid-json"), 0644); err != nil {
|
||||
t.Fatalf("failed to create corrupted file: %v", err)
|
||||
}
|
||||
|
||||
store := NewStore(tempFile)
|
||||
_, err := store.Load()
|
||||
if err == nil {
|
||||
t.Fatal("expected Load to fail for corrupted JSON file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_SaveAndLoad(t *testing.T) {
|
||||
tempFile := filepath.Join(t.TempDir(), "state.json")
|
||||
store := NewStore(tempFile)
|
||||
|
||||
original := &Snapshot{
|
||||
NodeID: "node-test-123",
|
||||
CurrentVersion: "20260531-001",
|
||||
CurrentChecksum: "chk-active-xyz",
|
||||
BlockedVersion: "20260531-002",
|
||||
BlockedChecksum: "chk-blocked-abc",
|
||||
BlockedReason: "invalid upstream domain name",
|
||||
LastError: "configuration reload timeout",
|
||||
OpenrestyStatus: "unhealthy",
|
||||
}
|
||||
|
||||
if err := store.Save(original); err != nil {
|
||||
t.Fatalf("expected Save to succeed, got: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := store.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("expected Load to succeed, got: %v", err)
|
||||
}
|
||||
|
||||
if loaded.NodeID != original.NodeID ||
|
||||
loaded.CurrentVersion != original.CurrentVersion ||
|
||||
loaded.CurrentChecksum != original.CurrentChecksum ||
|
||||
loaded.BlockedVersion != original.BlockedVersion ||
|
||||
loaded.BlockedChecksum != original.BlockedChecksum ||
|
||||
loaded.BlockedReason != original.BlockedReason ||
|
||||
loaded.LastError != original.LastError ||
|
||||
loaded.OpenrestyStatus != original.OpenrestyStatus {
|
||||
t.Errorf("loaded snapshot does not match original: %+v vs %+v", loaded, original)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_ConcurrencySafety(t *testing.T) {
|
||||
tempFile := filepath.Join(t.TempDir(), "state.json")
|
||||
store := NewStore(tempFile)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
workers := 20
|
||||
iterations := 50
|
||||
|
||||
// Run concurrent writers and readers
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func(workerID int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
// Concurrently save
|
||||
snap := &Snapshot{
|
||||
NodeID: fmt.Sprintf("node-%d", workerID),
|
||||
CurrentVersion: fmt.Sprintf("v-%d", j),
|
||||
}
|
||||
if err := store.Save(snap); err != nil {
|
||||
t.Errorf("Save failed under concurrency: %v", err)
|
||||
}
|
||||
|
||||
// Concurrently load
|
||||
if _, err := store.Load(); err != nil {
|
||||
t.Errorf("Load failed under concurrency: %v", err)
|
||||
}
|
||||
|
||||
// Concurrently ensure ID
|
||||
if _, err := store.EnsureNodeID(); err != nil {
|
||||
t.Errorf("EnsureNodeID failed under concurrency: %v", err)
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
@@ -4,8 +4,12 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
openrestyrender "openflare/utils/render/openresty"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"openflare-agent/internal/nginx"
|
||||
@@ -22,6 +26,7 @@ const (
|
||||
type ConfigClient interface {
|
||||
GetActiveConfig(ctx context.Context) (*protocol.ActiveConfigResponse, error)
|
||||
ReportApplyLog(ctx context.Context, payload protocol.ApplyLogPayload) error
|
||||
SyncWAFIPGroups(ctx context.Context, payload protocol.WAFIPGroupSyncRequest) (*protocol.WAFIPGroupSyncResponse, error)
|
||||
}
|
||||
|
||||
type NginxManager interface {
|
||||
@@ -29,6 +34,8 @@ type NginxManager interface {
|
||||
EnsureRuntime(ctx context.Context, recreate bool) error
|
||||
EnsureSafeFallbackRuntime(ctx context.Context, reason string) error
|
||||
CurrentChecksum() (string, error)
|
||||
WAFIPGroupChecksums() (map[string]string, error)
|
||||
SyncWAFIPGroups(groups []protocol.WAFIPGroup) error
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
@@ -91,20 +98,16 @@ func (s *Service) sync(ctx context.Context, startup bool, target *protocol.Activ
|
||||
}
|
||||
|
||||
if currentChecksum == target.Checksum {
|
||||
if startup {
|
||||
config, fetchErr := s.client.GetActiveConfig(ctx)
|
||||
if fetchErr != nil {
|
||||
slog.Error("fetch active config failed", "mode", mode, "error", fetchErr)
|
||||
return fetchErr
|
||||
}
|
||||
return s.applyIfNeeded(ctx, mode, startup, snapshot, currentChecksum, target, config)
|
||||
}
|
||||
slog.Debug("local openresty config already up to date", "mode", mode, "version", target.Version)
|
||||
shouldReport := shouldReportNoopApply(snapshot, target.Version, target.Checksum)
|
||||
if startup {
|
||||
slog.Debug("ensuring openresty runtime on startup", "version", target.Version)
|
||||
if err = s.nginxManager.EnsureRuntime(ctx, true); err != nil {
|
||||
snapshot.OpenrestyStatus = protocol.OpenrestyStatusUnhealthy
|
||||
snapshot.OpenrestyMessage = err.Error()
|
||||
_ = s.stateStore.Save(snapshot)
|
||||
return err
|
||||
}
|
||||
slog.Debug("openresty runtime ensured on startup", "version", target.Version)
|
||||
snapshot.OpenrestyStatus = protocol.OpenrestyStatusHealthy
|
||||
snapshot.OpenrestyMessage = ""
|
||||
}
|
||||
if shouldReport {
|
||||
if err = s.reportNoopApply(ctx, snapshot.NodeID, target.Version, target.Checksum, "", "", 0); err != nil {
|
||||
return err
|
||||
@@ -152,31 +155,42 @@ func (s *Service) ForceSyncOnce(ctx context.Context, target *protocol.ActiveConf
|
||||
clearBlockedTarget(snapshot)
|
||||
_ = s.stateStore.Save(snapshot)
|
||||
}
|
||||
return s.SyncOnce(ctx, target)
|
||||
currentChecksum, err := s.nginxManager.CurrentChecksum()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
config, err := s.client.GetActiveConfig(ctx)
|
||||
if err != nil {
|
||||
slog.Error("fetch active config failed", "mode", "force", "error", err)
|
||||
return err
|
||||
}
|
||||
return s.applyIfNeeded(ctx, "force", true, snapshot, currentChecksum, target, config)
|
||||
}
|
||||
|
||||
func (s *Service) WAFIPGroupChecksums() (map[string]string, error) {
|
||||
if s.nginxManager == nil {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
return s.nginxManager.WAFIPGroupChecksums()
|
||||
}
|
||||
|
||||
func (s *Service) ApplyWAFIPGroups(ctx context.Context, groups []protocol.WAFIPGroup) error {
|
||||
if len(groups) == 0 || s.nginxManager == nil {
|
||||
return nil
|
||||
}
|
||||
return s.nginxManager.SyncWAFIPGroups(groups)
|
||||
}
|
||||
|
||||
func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool, snapshot *state.Snapshot, currentChecksum string, target *protocol.ActiveConfigMeta, config *protocol.ActiveConfigResponse) error {
|
||||
if currentChecksum == config.Checksum {
|
||||
if currentChecksum == config.Checksum && !startup {
|
||||
slog.Debug("local openresty config already up to date", "mode", mode, "version", config.Version)
|
||||
shouldReport := shouldReportNoopApply(snapshot, config.Version, config.Checksum)
|
||||
if startup {
|
||||
slog.Debug("ensuring openresty runtime on startup", "version", config.Version)
|
||||
if err := s.nginxManager.EnsureRuntime(ctx, true); err != nil {
|
||||
snapshot.OpenrestyStatus = protocol.OpenrestyStatusUnhealthy
|
||||
snapshot.OpenrestyMessage = err.Error()
|
||||
_ = s.stateStore.Save(snapshot)
|
||||
return err
|
||||
}
|
||||
slog.Debug("openresty runtime ensured on startup", "version", config.Version)
|
||||
snapshot.OpenrestyStatus = protocol.OpenrestyStatusHealthy
|
||||
snapshot.OpenrestyMessage = ""
|
||||
}
|
||||
if shouldReport {
|
||||
routeConfig := config.RouteConfig
|
||||
if routeConfig == "" {
|
||||
routeConfig = config.RenderedConfig
|
||||
rendered, renderErr := renderActiveConfig(config)
|
||||
if renderErr != nil {
|
||||
return renderErr
|
||||
}
|
||||
if err := s.reportNoopApply(ctx, snapshot.NodeID, config.Version, config.Checksum, checksumString(config.MainConfig), checksumString(routeConfig), len(config.SupportFiles)); err != nil {
|
||||
if err := s.reportNoopApply(ctx, snapshot.NodeID, config.Version, config.Checksum, checksumString(rendered.mainConfig), checksumString(rendered.routeConfig), len(rendered.supportFiles)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -207,14 +221,14 @@ func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool,
|
||||
slog.Debug("skipping apply because state already records target version/checksum", "version", config.Version, "checksum", config.Checksum)
|
||||
return s.stateStore.Save(snapshot)
|
||||
}
|
||||
routeConfig := config.RouteConfig
|
||||
if routeConfig == "" {
|
||||
routeConfig = config.RenderedConfig
|
||||
rendered, err := renderActiveConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mainConfigChecksum := checksumString(config.MainConfig)
|
||||
routeConfigChecksum := checksumString(routeConfig)
|
||||
mainConfigChecksum := checksumString(rendered.mainConfig)
|
||||
routeConfigChecksum := checksumString(rendered.routeConfig)
|
||||
slog.Info("applying new openresty config", "mode", mode, "from_version", snapshot.CurrentVersion, "to_version", config.Version, "old_checksum", currentChecksum, "new_checksum", config.Checksum)
|
||||
outcome := s.nginxManager.Apply(ctx, config.MainConfig, routeConfig, config.SupportFiles)
|
||||
outcome := s.nginxManager.Apply(ctx, rendered.mainConfig, rendered.routeConfig, rendered.supportFiles)
|
||||
message := strings.TrimSpace(outcome.Message)
|
||||
if outcome.Status == "" {
|
||||
outcome.Status = nginx.ApplyStatusFatal
|
||||
@@ -269,7 +283,7 @@ func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool,
|
||||
Checksum: config.Checksum,
|
||||
MainConfigChecksum: mainConfigChecksum,
|
||||
RouteConfigChecksum: routeConfigChecksum,
|
||||
SupportFileCount: len(config.SupportFiles),
|
||||
SupportFileCount: len(rendered.supportFiles),
|
||||
}); err != nil {
|
||||
slog.Error("report apply log failed", "version", config.Version, "result", reportResult, "error", err)
|
||||
return err
|
||||
@@ -278,10 +292,127 @@ func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool,
|
||||
slog.Warn("failed apply log reported", "version", config.Version)
|
||||
return outcomeError(config.Version, message)
|
||||
}
|
||||
if err := s.syncReferencedWAFIPGroups(ctx, rendered.supportFiles); err != nil {
|
||||
slog.Error("sync referenced waf ip groups failed", "version", config.Version, "error", err)
|
||||
return err
|
||||
}
|
||||
slog.Debug("apply log reported", "version", config.Version, "result", reportResult)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) syncReferencedWAFIPGroups(ctx context.Context, supportFiles []protocol.SupportFile) error {
|
||||
ids := referencedWAFIPGroupIDs(supportFiles)
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
checksums, err := s.WAFIPGroupChecksums()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response, err := s.client.SyncWAFIPGroups(ctx, protocol.WAFIPGroupSyncRequest{
|
||||
IDs: ids,
|
||||
Checksums: checksums,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if response == nil || len(response.Groups) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.ApplyWAFIPGroups(ctx, response.Groups)
|
||||
}
|
||||
|
||||
type renderedActiveConfig struct {
|
||||
mainConfig string
|
||||
routeConfig string
|
||||
supportFiles []protocol.SupportFile
|
||||
}
|
||||
|
||||
func renderActiveConfig(config *protocol.ActiveConfigResponse) (*renderedActiveConfig, error) {
|
||||
if config == nil {
|
||||
return nil, errors.New("active config is nil")
|
||||
}
|
||||
sourceJSON := strings.TrimSpace(config.SourceConfigJSON)
|
||||
if sourceJSON == "" {
|
||||
return nil, errors.New("active config source_config_json is empty")
|
||||
}
|
||||
rendered, err := openrestyrender.RenderJSON(sourceJSON, toOpenRestySupportFiles(config.SupportFiles))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files := fromOpenRestySupportFiles(rendered.SupportFiles)
|
||||
files = append(files, protocol.SupportFile{Path: openrestyrender.SourceConfigFileName, Content: sourceJSON})
|
||||
return &renderedActiveConfig{
|
||||
mainConfig: rendered.MainConfig,
|
||||
routeConfig: rendered.RouteConfig,
|
||||
supportFiles: files,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toOpenRestySupportFiles(files []protocol.SupportFile) []openrestyrender.SupportFile {
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]openrestyrender.SupportFile, 0, len(files))
|
||||
for _, file := range files {
|
||||
result = append(result, openrestyrender.SupportFile{Path: file.Path, Content: file.Content})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func fromOpenRestySupportFiles(files []openrestyrender.SupportFile) []protocol.SupportFile {
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]protocol.SupportFile, 0, len(files))
|
||||
for _, file := range files {
|
||||
result = append(result, protocol.SupportFile{Path: file.Path, Content: file.Content})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func referencedWAFIPGroupIDs(supportFiles []protocol.SupportFile) []uint {
|
||||
var content string
|
||||
for _, file := range supportFiles {
|
||||
if file.Path == "waf_config.json" {
|
||||
content = strings.TrimSpace(file.Content)
|
||||
break
|
||||
}
|
||||
}
|
||||
if content == "" {
|
||||
return []uint{}
|
||||
}
|
||||
var payload struct {
|
||||
RuleGroups []struct {
|
||||
IPWhitelistGroups []uint `json:"ip_whitelist_group_ids"`
|
||||
IPBlacklistGroups []uint `json:"ip_blacklist_group_ids"`
|
||||
} `json:"rule_groups"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(content), &payload); err != nil {
|
||||
slog.Debug("decode waf_config.json for ip group references failed", "error", err)
|
||||
return []uint{}
|
||||
}
|
||||
seen := make(map[uint]struct{})
|
||||
for _, group := range payload.RuleGroups {
|
||||
for _, id := range group.IPWhitelistGroups {
|
||||
if id > 0 {
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, id := range group.IPBlacklistGroups {
|
||||
if id > 0 {
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
ids := make([]uint, 0, len(seen))
|
||||
for id := range seen {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
return ids
|
||||
}
|
||||
|
||||
func shouldReportNoopApply(snapshot *state.Snapshot, version string, checksum string) bool {
|
||||
if snapshot == nil {
|
||||
return false
|
||||
|
||||
@@ -2,8 +2,10 @@ package sync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -36,6 +38,10 @@ type fakeManager struct {
|
||||
applyFiles [][]protocol.SupportFile
|
||||
}
|
||||
|
||||
func testSourceConfigJSON(workerProcesses string, listen int) string {
|
||||
return fmt.Sprintf(`{"routes":[{"id":1,"site_name":"example","domain":"example.com","domains":["example.com"],"origin_url":"http://127.0.0.1:%d","upstreams":["http://127.0.0.1:%d"],"enabled":true}],"openresty_config":{"worker_processes":"%s","worker_connections":1024,"worker_rlimit_nofile":65535,"events_multi_accept_enabled":true,"keepalive_timeout":20,"keepalive_requests":1000,"client_header_timeout":15,"client_body_timeout":15,"client_max_body_size":"64m","large_client_header_buffers":"4 16k","send_timeout":30,"proxy_connect_timeout":3,"proxy_send_timeout":60,"proxy_read_timeout":60,"websocket_enabled":true,"proxy_request_buffering":false,"proxy_buffering_enabled":true,"proxy_buffers":"16 16k","proxy_buffer_size":"8k","proxy_busy_buffers_size":"64k","gzip_enabled":true,"gzip_min_length":1024,"gzip_comp_level":5,"cache_enabled":false,"cache_levels":"1:2","cache_inactive":"30m","cache_max_size":"1g","cache_key_template":"$scheme$host$request_uri","cache_lock_enabled":true,"cache_lock_timeout":"5s","cache_use_stale":"error timeout updating http_500 http_502 http_503 http_504","main_config_template":"worker_processes {{OpenRestyWorkerProcesses}};"},"waf":{"rule_groups":[],"bindings":[]}}`, listen, listen, workerProcesses)
|
||||
}
|
||||
|
||||
func (f *fakeExecutor) Test(ctx context.Context) error {
|
||||
return f.testErr
|
||||
}
|
||||
@@ -66,6 +72,10 @@ func (f *fakeClient) ReportApplyLog(ctx context.Context, payload protocol.ApplyL
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeClient) SyncWAFIPGroups(ctx context.Context, payload protocol.WAFIPGroupSyncRequest) (*protocol.WAFIPGroupSyncResponse, error) {
|
||||
return &protocol.WAFIPGroupSyncResponse{}, nil
|
||||
}
|
||||
|
||||
func (m *fakeManager) Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) nginx.ApplyOutcome {
|
||||
m.applyMainContents = append(m.applyMainContents, mainConfig)
|
||||
m.applyRouteContents = append(m.applyRouteContents, routeConfig)
|
||||
@@ -90,16 +100,22 @@ func (m *fakeManager) CurrentChecksum() (string, error) {
|
||||
return m.currentChecksum, m.currentChecksumErr
|
||||
}
|
||||
|
||||
func (m *fakeManager) WAFIPGroupChecksums() (map[string]string, error) {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
|
||||
func (m *fakeManager) SyncWAFIPGroups(groups []protocol.WAFIPGroup) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSyncOnceSuccess(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
config: protocol.ActiveConfigResponse{
|
||||
Version: "20260309-001",
|
||||
Checksum: "checksum-1",
|
||||
MainConfig: "worker_processes auto;",
|
||||
RouteConfig: "server { listen 80; }",
|
||||
RenderedConfig: "server { listen 80; }",
|
||||
SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
Version: "20260309-001",
|
||||
Checksum: "checksum-1",
|
||||
SourceConfigJSON: testSourceConfigJSON("auto", 80),
|
||||
SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -132,7 +148,7 @@ func TestSyncOnceSuccess(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read route config: %v", err)
|
||||
}
|
||||
if string(data) != "server { listen 80; }" {
|
||||
if !strings.Contains(string(data), "listen 80;") || !strings.Contains(string(data), "server_name example.com;") {
|
||||
t.Fatal("expected rendered config to be written to route file")
|
||||
}
|
||||
mainData, err := os.ReadFile(filepath.Join(filepath.Dir(routePath), "nginx.conf"))
|
||||
@@ -158,7 +174,7 @@ func TestSyncOnceSuccess(t *testing.T) {
|
||||
if client.reports[0].MainConfigChecksum == "" || client.reports[0].RouteConfigChecksum == "" {
|
||||
t.Fatal("expected main and route config checksums to be reported")
|
||||
}
|
||||
if client.reports[0].SupportFileCount != 1 {
|
||||
if client.reports[0].SupportFileCount != 4 {
|
||||
t.Fatalf("expected support file count to be reported, got %d", client.reports[0].SupportFileCount)
|
||||
}
|
||||
}
|
||||
@@ -166,13 +182,11 @@ func TestSyncOnceSuccess(t *testing.T) {
|
||||
func TestSyncOnceRollbackOnNginxFailure(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
config: protocol.ActiveConfigResponse{
|
||||
Version: "20260309-002",
|
||||
Checksum: "checksum-2",
|
||||
MainConfig: "worker_processes 2;",
|
||||
RouteConfig: "server { listen 81; }",
|
||||
RenderedConfig: "server { listen 81; }",
|
||||
SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
Version: "20260309-002",
|
||||
Checksum: "checksum-2",
|
||||
SourceConfigJSON: testSourceConfigJSON("2", 81),
|
||||
SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -225,7 +239,7 @@ func TestSyncOnceRollbackOnNginxFailure(t *testing.T) {
|
||||
if client.reports[0].MainConfigChecksum == "" || client.reports[0].RouteConfigChecksum == "" {
|
||||
t.Fatal("expected failed report to include main and route config checksums")
|
||||
}
|
||||
if client.reports[0].SupportFileCount != 1 {
|
||||
if client.reports[0].SupportFileCount != 4 {
|
||||
t.Fatalf("expected failed report to include support file count, got %d", client.reports[0].SupportFileCount)
|
||||
}
|
||||
}
|
||||
@@ -233,13 +247,11 @@ func TestSyncOnceRollbackOnNginxFailure(t *testing.T) {
|
||||
func TestSyncOnceReportsWarningWhenRollbackKeepsOpenrestyHealthy(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
config: protocol.ActiveConfigResponse{
|
||||
Version: "20260309-002",
|
||||
Checksum: "checksum-2",
|
||||
MainConfig: "worker_processes 2;",
|
||||
RouteConfig: "server { listen 81; }",
|
||||
RenderedConfig: "server { listen 81; }",
|
||||
SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
Version: "20260309-002",
|
||||
Checksum: "checksum-2",
|
||||
SourceConfigJSON: testSourceConfigJSON("2", 81),
|
||||
SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -294,13 +306,11 @@ func TestSyncOnceReportsWarningWhenRollbackKeepsOpenrestyHealthy(t *testing.T) {
|
||||
func TestSyncOnStartupRecreatesRuntimeWhenChecksumMatches(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
config: protocol.ActiveConfigResponse{
|
||||
Version: "20260309-003",
|
||||
Checksum: "checksum-3",
|
||||
MainConfig: "worker_processes auto;",
|
||||
RouteConfig: "server { listen 82; }",
|
||||
RenderedConfig: "server { listen 82; }",
|
||||
SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
Version: "20260309-003",
|
||||
Checksum: "checksum-3",
|
||||
SourceConfigJSON: testSourceConfigJSON("auto", 82),
|
||||
SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
|
||||
@@ -320,14 +330,11 @@ func TestSyncOnStartupRecreatesRuntimeWhenChecksumMatches(t *testing.T) {
|
||||
}); err != nil {
|
||||
t.Fatalf("SyncOnStartup failed: %v", err)
|
||||
}
|
||||
if len(manager.ensureCalls) != 1 || !manager.ensureCalls[0] {
|
||||
t.Fatal("expected startup sync to recreate runtime")
|
||||
if len(manager.applyMainContents) != 1 {
|
||||
t.Fatal("expected startup sync to re-render and apply local config")
|
||||
}
|
||||
if len(client.reports) != 1 || client.reports[0].Result != ApplyResultSuccess {
|
||||
t.Fatal("expected startup sync to report noop success when state is refreshed")
|
||||
}
|
||||
if client.reports[0].Message != "local config already matches active version; apply skipped" {
|
||||
t.Fatalf("unexpected noop apply message: %q", client.reports[0].Message)
|
||||
t.Fatal("expected startup sync to report apply success when state is refreshed")
|
||||
}
|
||||
snapshot, err := stateStore.Load()
|
||||
if err != nil {
|
||||
@@ -416,12 +423,10 @@ func TestSyncOnceDoesNotRepeatNoopReportWhenStateAlreadyMatches(t *testing.T) {
|
||||
func TestSyncOnStartupRecordsRuntimeFailure(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
config: protocol.ActiveConfigResponse{
|
||||
Version: "20260309-004",
|
||||
Checksum: "checksum-4",
|
||||
MainConfig: "worker_processes 4;",
|
||||
RouteConfig: "server { listen 83; }",
|
||||
RenderedConfig: "server { listen 83; }",
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
Version: "20260309-004",
|
||||
Checksum: "checksum-4",
|
||||
SourceConfigJSON: testSourceConfigJSON("4", 83),
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
|
||||
@@ -435,7 +440,7 @@ func TestSyncOnStartupRecordsRuntimeFailure(t *testing.T) {
|
||||
|
||||
manager := &fakeManager{
|
||||
currentChecksum: "checksum-4",
|
||||
ensureErr: context.DeadlineExceeded,
|
||||
applyOutcome: nginx.ApplyOutcome{Status: nginx.ApplyStatusFatal, Message: context.DeadlineExceeded.Error()},
|
||||
}
|
||||
service := New(client, manager, stateStore)
|
||||
if err = service.SyncOnStartup(context.Background(), &protocol.ActiveConfigMeta{
|
||||
@@ -459,12 +464,10 @@ func TestSyncOnStartupRecordsRuntimeFailure(t *testing.T) {
|
||||
func TestSyncOnceSkipsPreviouslyBlockedVersion(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
config: protocol.ActiveConfigResponse{
|
||||
Version: "20260309-006",
|
||||
Checksum: "checksum-6",
|
||||
MainConfig: "worker_processes 6;",
|
||||
RouteConfig: "server { listen 86; }",
|
||||
RenderedConfig: "server { listen 86; }",
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
Version: "20260309-006",
|
||||
Checksum: "checksum-6",
|
||||
SourceConfigJSON: testSourceConfigJSON("6", 86),
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
|
||||
@@ -506,12 +509,10 @@ func TestSyncOnceSkipsPreviouslyBlockedVersion(t *testing.T) {
|
||||
func TestSyncOnStartupKeepsBlockedVersionSuppressedUntilNewTargetArrives(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
config: protocol.ActiveConfigResponse{
|
||||
Version: "20260309-007",
|
||||
Checksum: "checksum-7",
|
||||
MainConfig: "worker_processes 7;",
|
||||
RouteConfig: "server { listen 87; }",
|
||||
RenderedConfig: "server { listen 87; }",
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
Version: "20260309-007",
|
||||
Checksum: "checksum-7",
|
||||
SourceConfigJSON: testSourceConfigJSON("7", 87),
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
|
||||
@@ -565,12 +566,10 @@ func TestSyncOnStartupKeepsBlockedVersionSuppressedUntilNewTargetArrives(t *test
|
||||
func TestSyncOnStartupStartsFallbackWhenBlockedVersionHasNoLocalConfig(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
config: protocol.ActiveConfigResponse{
|
||||
Version: "20260309-007",
|
||||
Checksum: "checksum-7",
|
||||
MainConfig: "worker_processes 7;",
|
||||
RouteConfig: "server { listen 87; }",
|
||||
RenderedConfig: "server { listen 87; }",
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
Version: "20260309-007",
|
||||
Checksum: "checksum-7",
|
||||
SourceConfigJSON: testSourceConfigJSON("7", 87),
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
|
||||
@@ -625,12 +624,10 @@ func TestSyncOnStartupStartsFallbackWhenBlockedVersionHasNoLocalConfig(t *testin
|
||||
func TestSyncOnStartupStartsFallbackWhenResidualConfigCannotRecover(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
config: protocol.ActiveConfigResponse{
|
||||
Version: "20260309-007",
|
||||
Checksum: "checksum-7",
|
||||
MainConfig: "worker_processes 7;",
|
||||
RouteConfig: "server { listen 87; }",
|
||||
RenderedConfig: "server { listen 87; }",
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
Version: "20260309-007",
|
||||
Checksum: "checksum-7",
|
||||
SourceConfigJSON: testSourceConfigJSON("7", 87),
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
|
||||
@@ -679,12 +676,10 @@ func TestSyncOnStartupStartsFallbackWhenResidualConfigCannotRecover(t *testing.T
|
||||
func TestSyncOnceClearsBlockedTargetWhenNewVersionArrives(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
config: protocol.ActiveConfigResponse{
|
||||
Version: "20260309-008",
|
||||
Checksum: "checksum-8",
|
||||
MainConfig: "worker_processes 8;",
|
||||
RouteConfig: "server { listen 88; }",
|
||||
RenderedConfig: "server { listen 88; }",
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
Version: "20260309-008",
|
||||
Checksum: "checksum-8",
|
||||
SourceConfigJSON: testSourceConfigJSON("8", 88),
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
|
||||
@@ -732,12 +727,10 @@ func TestSyncOnceClearsBlockedTargetWhenNewVersionArrives(t *testing.T) {
|
||||
func TestSyncOnceSkipsFetchWhenHeartbeatChecksumMatches(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
config: protocol.ActiveConfigResponse{
|
||||
Version: "20260309-005",
|
||||
Checksum: "checksum-5",
|
||||
MainConfig: "worker_processes auto;",
|
||||
RouteConfig: "server { listen 84; }",
|
||||
RenderedConfig: "server { listen 84; }",
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
Version: "20260309-005",
|
||||
Checksum: "checksum-5",
|
||||
SourceConfigJSON: testSourceConfigJSON("auto", 84),
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
|
||||
|
||||
@@ -4,24 +4,48 @@ package updater
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func replaceAndRestart(execPath string, tmpPath string) error {
|
||||
backupPath := execPath + ".bak"
|
||||
os.Remove(backupPath)
|
||||
if err := removeBackupBinary(backupPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(execPath, backupPath); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("backup current binary: %w", err)
|
||||
renameErr := err
|
||||
if err := os.Remove(tmpPath); err != nil && !os.IsNotExist(err) {
|
||||
slog.Error("remove tmp binary failed", "path", tmpPath, "error", err)
|
||||
return fmt.Errorf("backup current binary: %w; remove tmp binary: %v", renameErr, err)
|
||||
}
|
||||
return fmt.Errorf("backup current binary: %w", renameErr)
|
||||
}
|
||||
if err := os.Rename(tmpPath, execPath); err != nil {
|
||||
os.Rename(backupPath, execPath)
|
||||
return fmt.Errorf("replace binary: %w", err)
|
||||
replaceErr := err
|
||||
if err := os.Rename(backupPath, execPath); err != nil {
|
||||
slog.Error("restore backup binary failed", "path", backupPath, "error", err)
|
||||
return fmt.Errorf("replace binary: %w; restore backup binary: %v", replaceErr, err)
|
||||
}
|
||||
return fmt.Errorf("replace binary: %w", replaceErr)
|
||||
}
|
||||
if err := removeBackupBinary(backupPath); err != nil {
|
||||
return err
|
||||
}
|
||||
os.Remove(backupPath)
|
||||
if err := syscall.Exec(execPath, os.Args, os.Environ()); err != nil {
|
||||
return fmt.Errorf("exec restart: %w", err)
|
||||
}
|
||||
return fmt.Errorf("unreachable after exec")
|
||||
}
|
||||
|
||||
func removeBackupBinary(path string) error {
|
||||
if err := os.Remove(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
slog.Error("remove backup binary failed", "path", path, "error", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
//go:build !windows
|
||||
|
||||
package updater
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRemoveBackupBinaryIgnoresMissingFile(t *testing.T) {
|
||||
backupPath := filepath.Join(t.TempDir(), "openflare-agent.bak")
|
||||
if err := removeBackupBinary(backupPath); err != nil {
|
||||
t.Fatalf("expected missing backup cleanup to be ignored: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"openflare/utils"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -56,7 +56,7 @@ func (s *Service) CheckAndUpdate(ctx context.Context, repo string, options agent
|
||||
}
|
||||
|
||||
remoteVersion := normalizeVersion(release.TagName)
|
||||
localVersion := normalizeVersion(config.AgentVersion)
|
||||
localVersion := normalizeVersion(config.Version)
|
||||
checkKey := buildReleaseCheckKey(options, remoteVersion)
|
||||
|
||||
if remoteVersion == localVersion {
|
||||
@@ -121,26 +121,7 @@ func (s *Service) getRelease(ctx context.Context, repo string, options agent.Upd
|
||||
|
||||
func (s *Service) getLatestStableRelease(ctx context.Context, repo string) (*githubRelease, error) {
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("github api returned %s", resp.Status)
|
||||
}
|
||||
|
||||
return decodeRelease(resp.Body)
|
||||
return s.fetchReleaseFromURL(ctx, url)
|
||||
}
|
||||
|
||||
func (s *Service) getLatestPreviewRelease(ctx context.Context, repo string) (*githubRelease, error) {
|
||||
@@ -177,6 +158,10 @@ func (s *Service) getLatestPreviewRelease(ctx context.Context, repo string) (*gi
|
||||
|
||||
func (s *Service) getReleaseByTag(ctx context.Context, repo string, tag string) (*githubRelease, error) {
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/tags/%s", repo, strings.TrimSpace(tag))
|
||||
return s.fetchReleaseFromURL(ctx, url)
|
||||
}
|
||||
|
||||
func (s *Service) fetchReleaseFromURL(ctx context.Context, url string) (*githubRelease, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -187,7 +172,12 @@ func (s *Service) getReleaseByTag(ctx context.Context, repo string, tag string)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
err := Body.Close()
|
||||
if err != nil {
|
||||
slog.Error("failed to close response body", "error", err)
|
||||
}
|
||||
}(resp.Body)
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil
|
||||
@@ -370,146 +360,6 @@ func buildReleaseCheckKey(options agent.UpdateOptions, remoteVersion string) str
|
||||
return channel + ":" + remoteVersion
|
||||
}
|
||||
|
||||
type versionInfo struct {
|
||||
valid bool
|
||||
isDev bool
|
||||
numbers []int
|
||||
prerelease []string
|
||||
}
|
||||
|
||||
func parseVersionInfo(version string) versionInfo {
|
||||
normalized := normalizeVersion(version)
|
||||
if normalized == "" || strings.EqualFold(normalized, "dev") {
|
||||
return versionInfo{isDev: strings.EqualFold(normalized, "dev")}
|
||||
}
|
||||
base := normalized
|
||||
prerelease := ""
|
||||
if index := strings.IndexRune(normalized, '-'); index >= 0 {
|
||||
base = normalized[:index]
|
||||
prerelease = normalized[index+1:]
|
||||
}
|
||||
segments := strings.Split(base, ".")
|
||||
parts := make([]int, 0, len(segments))
|
||||
for _, segment := range segments {
|
||||
segment = strings.TrimSpace(segment)
|
||||
if segment == "" {
|
||||
parts = append(parts, 0)
|
||||
continue
|
||||
}
|
||||
numeric := strings.Builder{}
|
||||
for _, r := range segment {
|
||||
if r < '0' || r > '9' {
|
||||
break
|
||||
}
|
||||
numeric.WriteRune(r)
|
||||
}
|
||||
if numeric.Len() == 0 {
|
||||
return versionInfo{}
|
||||
}
|
||||
value, err := strconv.Atoi(numeric.String())
|
||||
if err != nil {
|
||||
return versionInfo{}
|
||||
}
|
||||
parts = append(parts, value)
|
||||
}
|
||||
info := versionInfo{valid: len(parts) > 0, numbers: parts}
|
||||
if prerelease != "" {
|
||||
info.prerelease = splitPrereleaseIdentifiers(prerelease)
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func splitPrereleaseIdentifiers(value string) []string {
|
||||
parts := strings.FieldsFunc(strings.TrimSpace(value), func(r rune) bool {
|
||||
return r == '.' || r == '-'
|
||||
})
|
||||
filtered := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
filtered = append(filtered, part)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func compareVersions(local string, remote string) int {
|
||||
left := parseVersionInfo(local)
|
||||
right := parseVersionInfo(remote)
|
||||
if left.isDev {
|
||||
if right.valid {
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
if !left.valid || !right.valid {
|
||||
return 0
|
||||
}
|
||||
|
||||
maxLen := len(left.numbers)
|
||||
if len(right.numbers) > maxLen {
|
||||
maxLen = len(right.numbers)
|
||||
}
|
||||
for index := 0; index < maxLen; index++ {
|
||||
leftValue := 0
|
||||
rightValue := 0
|
||||
if index < len(left.numbers) {
|
||||
leftValue = left.numbers[index]
|
||||
}
|
||||
if index < len(right.numbers) {
|
||||
rightValue = right.numbers[index]
|
||||
}
|
||||
if leftValue < rightValue {
|
||||
return -1
|
||||
}
|
||||
if leftValue > rightValue {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
if len(left.prerelease) == 0 && len(right.prerelease) == 0 {
|
||||
return 0
|
||||
}
|
||||
if len(left.prerelease) == 0 {
|
||||
return 1
|
||||
}
|
||||
if len(right.prerelease) == 0 {
|
||||
return -1
|
||||
}
|
||||
maxLen = len(left.prerelease)
|
||||
if len(right.prerelease) > maxLen {
|
||||
maxLen = len(right.prerelease)
|
||||
}
|
||||
for index := 0; index < maxLen; index++ {
|
||||
if index >= len(left.prerelease) {
|
||||
return -1
|
||||
}
|
||||
if index >= len(right.prerelease) {
|
||||
return 1
|
||||
}
|
||||
leftPart := left.prerelease[index]
|
||||
rightPart := right.prerelease[index]
|
||||
leftNumber, leftErr := strconv.Atoi(leftPart)
|
||||
rightNumber, rightErr := strconv.Atoi(rightPart)
|
||||
switch {
|
||||
case leftErr == nil && rightErr == nil:
|
||||
if leftNumber < rightNumber {
|
||||
return -1
|
||||
}
|
||||
if leftNumber > rightNumber {
|
||||
return 1
|
||||
}
|
||||
case leftErr == nil && rightErr != nil:
|
||||
return -1
|
||||
case leftErr != nil && rightErr == nil:
|
||||
return 1
|
||||
default:
|
||||
if leftPart < rightPart {
|
||||
return -1
|
||||
}
|
||||
if leftPart > rightPart {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
return utils.CompareVersions(local, remote)
|
||||
}
|
||||
|
||||
@@ -75,10 +75,10 @@ func TestGetReleaseByTag(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCheckAndUpdateRequiresChecksumAsset(t *testing.T) {
|
||||
originalVersion := config.AgentVersion
|
||||
config.AgentVersion = "v1.0.0"
|
||||
originalVersion := config.Version
|
||||
config.Version = "v1.0.0"
|
||||
t.Cleanup(func() {
|
||||
config.AgentVersion = originalVersion
|
||||
config.Version = originalVersion
|
||||
})
|
||||
|
||||
assetName := assetNameForGOOSGOARCH(runtime.GOOS, runtime.GOARCH)
|
||||
|
||||
@@ -140,7 +140,8 @@ func (conn *Connection) Receive() (protocol.WSMessage, error) {
|
||||
}
|
||||
err := websocket.JSON.Receive(conn.conn, &message)
|
||||
if err != nil {
|
||||
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||
slog.Debug("agent ws receive timeout waiting for server message", "timeout", conn.readTimeout)
|
||||
}
|
||||
return message, err
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
ARG VERSION=dev
|
||||
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
ARG VERSION
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY openflare_relay/go.mod openflare_relay/go.sum ./
|
||||
COPY openflare_server /openflare_server
|
||||
COPY openflare_relay /openflare_relay
|
||||
|
||||
WORKDIR /openflare_relay
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-s -w -X 'openflare-relay/internal/config.Version=$VERSION'" -o openflare-relay ./cmd/relay
|
||||
|
||||
# Final runtime image
|
||||
FROM fatedier/frps:v0.69.0
|
||||
|
||||
# Copy openflare-relay binary
|
||||
COPY --from=builder /openflare_relay/openflare-relay /usr/local/bin/openflare-relay
|
||||
|
||||
VOLUME ["/var/lib/openflare-relay"]
|
||||
|
||||
ENV OPENFLARE_FRPS_PATH=/usr/bin/frps
|
||||
ENV OPENFLARE_DATA_DIR=/var/lib/openflare-relay
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/openflare-relay"]
|
||||
@@ -0,0 +1,87 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"openflare-relay/internal/config"
|
||||
"openflare-relay/internal/frps"
|
||||
"openflare-relay/internal/heartbeat"
|
||||
"openflare-relay/internal/httpclient"
|
||||
"openflare-relay/internal/relay"
|
||||
"openflare-relay/internal/state"
|
||||
"openflare-relay/internal/wsclient"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Setup simple structured logging
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: parseLevel(os.Getenv("LOG_LEVEL")),
|
||||
})))
|
||||
|
||||
configPath := flag.String("config", "./relay.json", "relay config path")
|
||||
flag.Parse()
|
||||
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
slog.Error("load relay config failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
slog.Info("relay config loaded",
|
||||
"server", cfg.ServerURL,
|
||||
"node", cfg.NodeName,
|
||||
"ip", cfg.NodeIP,
|
||||
"frps_path", cfg.FrpsPath,
|
||||
"data_dir", cfg.DataDir,
|
||||
"heartbeat_interval", cfg.HeartbeatInterval,
|
||||
)
|
||||
|
||||
stateStore := state.NewStore(cfg.StatePath)
|
||||
_ = stateStore // In the future we may use stateStore for auth caching
|
||||
|
||||
frpsManager := frps.NewManager(cfg.FrpsPath, cfg.DataDir, cfg.InitialAuthToken())
|
||||
|
||||
slog.Info("detected frps version", "version", frpsManager.GetVersion())
|
||||
|
||||
httpClient := httpclient.New(cfg.ServerURL, cfg.InitialAuthToken(), cfg.RequestTimeout.Duration())
|
||||
wsClient := wsclient.New(cfg.ServerURL, cfg.InitialAuthToken(), cfg.RequestTimeout.Duration())
|
||||
|
||||
runner := &relay.Runner{
|
||||
Config: cfg,
|
||||
StateStore: stateStore,
|
||||
FrpsManager: frpsManager,
|
||||
HttpClient: httpClient,
|
||||
WebSocketService: wsClient,
|
||||
HeartbeatService: heartbeat.New(httpClient, frpsManager, cfg, stateStore),
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
slog.Info("relay process started")
|
||||
|
||||
if err := runner.Run(ctx); err != nil && err != context.Canceled {
|
||||
slog.Error("relay process exited with error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info("relay process stopped")
|
||||
}
|
||||
|
||||
func parseLevel(value string) slog.Level {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "warn", "warning":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
module openflare-relay
|
||||
|
||||
go 1.25.0
|
||||
|
||||
replace openflare => ../openflare_server
|
||||
|
||||
require (
|
||||
golang.org/x/net v0.55.0
|
||||
openflare v0.0.0-00010101000000-000000000000
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bwmarrin/snowflake v0.3.0 // indirect
|
||||
github.com/bytedance/sonic v1.11.2 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
|
||||
github.com/chenzhuoyu/iasm v0.9.1 // indirect
|
||||
github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/expr-lang/expr v1.17.8 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/gin-gonic/gin v1.9.1 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/glebarez/sqlite v1.11.0 // indirect
|
||||
github.com/go-acme/lego/v4 v4.35.2 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.23.0 // indirect
|
||||
github.com/go-redis/redis/v8 v8.11.5 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/longbridgeapp/sqlparser v0.3.1 // indirect
|
||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||
github.com/miekg/dns v1.1.72 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||
github.com/oschwald/maxminddb-golang v1.13.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.1.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.7.0 // indirect
|
||||
golang.org/x/crypto v0.51.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gorm.io/driver/postgres v1.6.0 // indirect
|
||||
gorm.io/gorm v1.25.10 // indirect
|
||||
gorm.io/sharding v0.6.2 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,195 @@
|
||||
github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0=
|
||||
github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE=
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
|
||||
github.com/bytedance/sonic v1.11.2 h1:ywfwo0a/3j9HR8wsYGWsIWl2mvRsI950HyoxiBERw5A=
|
||||
github.com/bytedance/sonic v1.11.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
|
||||
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||
github.com/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0=
|
||||
github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
|
||||
github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI=
|
||||
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
|
||||
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM=
|
||||
github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-acme/lego/v4 v4.35.2 h1:uVQg+KC/yj9R2g7Q9W5wDqhvQvxV5SMu5eqFVoN5xZU=
|
||||
github.com/go-acme/lego/v4 v4.35.2/go.mod h1:pX2jN5n8OphMGY1IaMjYm5DAEzguBaKRt8AvJAgJXpc=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
|
||||
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
|
||||
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
||||
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
|
||||
github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU=
|
||||
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/longbridgeapp/assert v1.1.0 h1:L+/HISOhuGbNAAmJNXgk3+Tm5QmSB70kwdktJXgjL+I=
|
||||
github.com/longbridgeapp/assert v1.1.0/go.mod h1:UOI7O3rzlzlz715lQm0atWs6JbrYGuIJUEeOekutL6o=
|
||||
github.com/longbridgeapp/sqlparser v0.3.1 h1:iWOZWGIFgQrJRgobLXUNJdvqGRpbVXkyKUKUA5CNJBE=
|
||||
github.com/longbridgeapp/sqlparser v0.3.1/go.mod h1:GIHaUq8zvYyHLCLMJJykx1CdM6LHtkUih/QaJXySSx4=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
|
||||
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
|
||||
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
|
||||
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
|
||||
github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI=
|
||||
github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
|
||||
golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.5.1 h1:WUEH5VF9obL/lTtzjmML/5e6VfFR/788coz2uaVCAZw=
|
||||
gorm.io/driver/mysql v1.5.1/go.mod h1:Jo3Xu7mMhCyj8dlrb3WoCaRd1FhsVh+yMXb1jUInf5o=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
|
||||
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/hints v1.1.2 h1:b5j0kwk5p4+3BtDtYqqfY+ATSxjj+6ptPgVveuynn9o=
|
||||
gorm.io/hints v1.1.2/go.mod h1:/ARdpUHAtyEMCh5NNi3tI7FsGh+Cj/MIUlvNxCNCFWg=
|
||||
gorm.io/plugin/dbresolver v1.5.1 h1:s9Dj9f7r+1rE3nx/Ywzc85nXptUEaeOO0pt27xdopM8=
|
||||
gorm.io/plugin/dbresolver v1.5.1/go.mod h1:l4Cn87EHLEYuqUncpEeTC2tTJQkjngPSD+lo8hIvcT0=
|
||||
gorm.io/sharding v0.6.2 h1:V9inmbdhN+RfWPEKTvbKKKv7qxLz1CneBDQvuL5P7jg=
|
||||
gorm.io/sharding v0.6.2/go.mod h1:dXaAZv0qyUmLkLAciQ+NH2O1D1A4/ttrrZ/XK4xW9HU=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
@@ -0,0 +1,234 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"openflare/utils/geoip"
|
||||
"openflare/utils/geoip/iputil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type MillisecondDuration time.Duration
|
||||
|
||||
func (d *MillisecondDuration) UnmarshalJSON(b []byte) error {
|
||||
var v interface{}
|
||||
if err := json.Unmarshal(b, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
switch value := v.(type) {
|
||||
case float64:
|
||||
*d = MillisecondDuration(time.Duration(value) * time.Millisecond)
|
||||
return nil
|
||||
case string:
|
||||
duration, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*d = MillisecondDuration(duration)
|
||||
return nil
|
||||
default:
|
||||
return errors.New("invalid duration format")
|
||||
}
|
||||
}
|
||||
|
||||
func (d MillisecondDuration) Duration() time.Duration {
|
||||
return time.Duration(d)
|
||||
}
|
||||
|
||||
func (d MillisecondDuration) String() string {
|
||||
return time.Duration(d).String()
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
DiscoveryToken string `json:"discovery_token"`
|
||||
NodeName string `json:"node_name"`
|
||||
NodeIP string `json:"node_ip"`
|
||||
FrpsPath string `json:"frps_path"`
|
||||
DataDir string `json:"data_dir"`
|
||||
StatePath string `json:"state_path"`
|
||||
HeartbeatInterval MillisecondDuration `json:"heartbeat_interval"`
|
||||
RequestTimeout MillisecondDuration `json:"request_timeout"`
|
||||
configPath string
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
cfg := &Config{}
|
||||
if err == nil {
|
||||
if err = json.Unmarshal(data, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err != nil && !hasEnvConfig() {
|
||||
return nil, err
|
||||
}
|
||||
cfg.configPath = path
|
||||
applyEnvOverrides(cfg)
|
||||
applyDefaults(cfg, filepath.Dir(path))
|
||||
if err = validate(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func hasEnvConfig() bool {
|
||||
for _, key := range []string{
|
||||
"OPENFLARE_SERVER_URL",
|
||||
"OPENFLARE_AGENT_TOKEN",
|
||||
"OPENFLARE_DISCOVERY_TOKEN",
|
||||
"OPENFLARE_NODE_NAME",
|
||||
"OPENFLARE_NODE_IP",
|
||||
"OPENFLARE_DATA_DIR",
|
||||
"OPENFLARE_FRPS_PATH",
|
||||
} {
|
||||
if strings.TrimSpace(os.Getenv(key)) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func applyEnvOverrides(cfg *Config) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
overrideString := func(key string, target *string) {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
*target = value
|
||||
}
|
||||
}
|
||||
overrideString("OPENFLARE_SERVER_URL", &cfg.ServerURL)
|
||||
overrideString("OPENFLARE_AGENT_TOKEN", &cfg.AgentToken)
|
||||
overrideString("OPENFLARE_DISCOVERY_TOKEN", &cfg.DiscoveryToken)
|
||||
overrideString("OPENFLARE_NODE_NAME", &cfg.NodeName)
|
||||
overrideString("OPENFLARE_NODE_IP", &cfg.NodeIP)
|
||||
overrideString("OPENFLARE_DATA_DIR", &cfg.DataDir)
|
||||
overrideString("OPENFLARE_FRPS_PATH", &cfg.FrpsPath)
|
||||
}
|
||||
|
||||
func applyDefaults(cfg *Config, baseDir string) {
|
||||
baseDir = filepath.Clean(baseDir)
|
||||
if cfg.FrpsPath == "" {
|
||||
cfg.FrpsPath = "frps" // rely on PATH
|
||||
}
|
||||
if cfg.DataDir == "" {
|
||||
cfg.DataDir = filepath.Join(baseDir, "data")
|
||||
}
|
||||
if cfg.NodeName == "" {
|
||||
host, _ := os.Hostname()
|
||||
cfg.NodeName = strings.TrimSpace(host)
|
||||
}
|
||||
if cfg.NodeIP == "" {
|
||||
cfg.NodeIP = detectNodeIP()
|
||||
}
|
||||
if cfg.StatePath == "" {
|
||||
cfg.StatePath = filepath.Join(cfg.DataDir, "relay-state.json")
|
||||
}
|
||||
if cfg.HeartbeatInterval <= 0 {
|
||||
cfg.HeartbeatInterval = MillisecondDuration(10 * time.Second)
|
||||
}
|
||||
if cfg.RequestTimeout <= 0 {
|
||||
cfg.RequestTimeout = MillisecondDuration(10 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func validate(cfg *Config) error {
|
||||
if cfg.ServerURL == "" {
|
||||
return errors.New("server_url 不能为空")
|
||||
}
|
||||
if strings.TrimSpace(cfg.AgentToken) == "" && strings.TrimSpace(cfg.DiscoveryToken) == "" {
|
||||
return errors.New("agent_token 和 discovery_token 不能同时为空")
|
||||
}
|
||||
if cfg.NodeName == "" {
|
||||
return errors.New("node_name 不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) InitialAuthToken() string {
|
||||
if cfg == nil {
|
||||
return ""
|
||||
}
|
||||
if token := strings.TrimSpace(cfg.AgentToken); token != "" {
|
||||
return token
|
||||
}
|
||||
return strings.TrimSpace(cfg.DiscoveryToken)
|
||||
}
|
||||
|
||||
func (cfg *Config) Save() error {
|
||||
if cfg == nil {
|
||||
return errors.New("config 不能为空")
|
||||
}
|
||||
if cfg.configPath == "" {
|
||||
return errors.New("config path 未初始化")
|
||||
}
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(cfg.configPath, data, 0o644)
|
||||
}
|
||||
|
||||
func detectNodeIP() string {
|
||||
if ip := detectOutboundNodeIP(); ip != "" {
|
||||
return ip
|
||||
}
|
||||
return detectLocalNodeIP()
|
||||
}
|
||||
|
||||
func detectOutboundNodeIP() string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
ip, err := geoip.GetOutboundIP(ctx)
|
||||
if err != nil || ip == nil {
|
||||
return ""
|
||||
}
|
||||
return ip.String()
|
||||
}
|
||||
|
||||
func detectLocalNodeIP() string {
|
||||
interfaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
bestIP := ""
|
||||
bestPriority := -1
|
||||
for _, iface := range interfaces {
|
||||
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
ipNet, ok := addr.(*net.IPNet)
|
||||
if !ok || ipNet.IP == nil || ipNet.IP.IsLoopback() {
|
||||
continue
|
||||
}
|
||||
ipv4 := ipNet.IP.To4()
|
||||
if ipv4 == nil {
|
||||
continue
|
||||
}
|
||||
priority := iputil.Score(ipv4)
|
||||
if priority > bestPriority {
|
||||
bestIP = ipv4.String()
|
||||
bestPriority = priority
|
||||
}
|
||||
if bestPriority == 2 {
|
||||
return bestIP
|
||||
}
|
||||
}
|
||||
}
|
||||
return bestIP
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package config
|
||||
|
||||
var Version = "dev"
|
||||
@@ -0,0 +1,238 @@
|
||||
package frps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"openflare/service"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
frpsPath string
|
||||
dataDir string
|
||||
configPath string
|
||||
agentToken string
|
||||
|
||||
mu sync.RWMutex
|
||||
activeConfig *service.RelayConfig
|
||||
cmd *exec.Cmd
|
||||
status string
|
||||
lastError string
|
||||
generation uint64
|
||||
stopping bool
|
||||
}
|
||||
|
||||
type RuntimeStatus struct {
|
||||
Status string
|
||||
LastError string
|
||||
Connections int
|
||||
ProxyCount int
|
||||
ClientCount int
|
||||
Proxies []service.RelayProxyStat
|
||||
ProcessAlive bool
|
||||
}
|
||||
|
||||
func NewManager(frpsPath string, dataDir string, agentToken string) *Manager {
|
||||
return &Manager{
|
||||
frpsPath: frpsPath,
|
||||
dataDir: dataDir,
|
||||
configPath: filepath.Join(dataDir, "frps.toml"),
|
||||
status: "unknown", // 启动阶段尚未获取配置,状态未知;避免首次 heartbeat 误报 frps_unhealthy
|
||||
agentToken: agentToken,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) GetVersion() string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, m.frpsPath, "-v")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Error("failed to get frps version", "error", err)
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func (m *Manager) GetStatus() string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.status
|
||||
}
|
||||
|
||||
func (m *Manager) GetRuntimeStatus() RuntimeStatus {
|
||||
m.mu.RLock()
|
||||
status := m.status
|
||||
lastError := m.lastError
|
||||
cmd := m.cmd
|
||||
m.mu.RUnlock()
|
||||
|
||||
return RuntimeStatus{
|
||||
Status: status,
|
||||
LastError: lastError,
|
||||
Connections: 0,
|
||||
ProxyCount: 0,
|
||||
ClientCount: 0,
|
||||
Proxies: nil,
|
||||
ProcessAlive: cmd != nil && cmd.Process != nil,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) UpdateConfig(cfg *service.RelayConfig) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Check if config changed
|
||||
if m.activeConfig != nil &&
|
||||
m.activeConfig.BindPort == cfg.BindPort &&
|
||||
m.activeConfig.VhostHTTPPort == cfg.VhostHTTPPort &&
|
||||
m.activeConfig.AuthToken == cfg.AuthToken &&
|
||||
m.activeConfig.WebServerEnabled == cfg.WebServerEnabled {
|
||||
if m.cmd == nil && !m.stopping {
|
||||
slog.Warn("frps config unchanged but process is not running, restarting")
|
||||
if err := m.restartProcess(); err != nil {
|
||||
m.status = "unhealthy"
|
||||
m.lastError = err.Error()
|
||||
slog.Error("failed to restart frps with unchanged config", "error", err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
m.activeConfig = cfg
|
||||
m.stopping = false
|
||||
slog.Info("relay config updated, reloading frps")
|
||||
|
||||
if err := m.renderConfig(cfg); err != nil {
|
||||
slog.Error("failed to render frps config", "error", err)
|
||||
m.status = "unhealthy"
|
||||
m.lastError = err.Error()
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.restartProcess(); err != nil {
|
||||
slog.Error("failed to restart frps", "error", err)
|
||||
m.status = "unhealthy"
|
||||
m.lastError = err.Error()
|
||||
} else {
|
||||
m.status = "healthy"
|
||||
m.lastError = ""
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) renderConfig(cfg *service.RelayConfig) error {
|
||||
if err := os.MkdirAll(m.dataDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString(fmt.Sprintf("bindPort = %d\n", cfg.BindPort))
|
||||
if cfg.VhostHTTPPort > 0 {
|
||||
buf.WriteString(fmt.Sprintf("vhostHTTPPort = %d\n", cfg.VhostHTTPPort))
|
||||
}
|
||||
if cfg.AuthToken != "" {
|
||||
buf.WriteString("[auth]\n")
|
||||
buf.WriteString("method = \"token\"\n")
|
||||
buf.WriteString(fmt.Sprintf("token = \"%s\"\n", cfg.AuthToken))
|
||||
}
|
||||
|
||||
// WebServer configuration
|
||||
buf.WriteString("\n[webServer]\n")
|
||||
if cfg.WebServerEnabled {
|
||||
buf.WriteString("addr = \"0.0.0.0\"\n")
|
||||
} else {
|
||||
buf.WriteString("addr = \"127.0.0.1\"\n")
|
||||
}
|
||||
buf.WriteString(fmt.Sprintf("port = %d\n", 17500))
|
||||
buf.WriteString("user = \"admin\"\n")
|
||||
|
||||
password := m.agentToken
|
||||
if password == "" {
|
||||
password = "admin"
|
||||
}
|
||||
buf.WriteString(fmt.Sprintf("password = \"%s\"\n", password))
|
||||
|
||||
return os.WriteFile(m.configPath, buf.Bytes(), 0644)
|
||||
}
|
||||
|
||||
func (m *Manager) restartProcess() error {
|
||||
m.generation++
|
||||
generation := m.generation
|
||||
if m.cmd != nil && m.cmd.Process != nil {
|
||||
slog.Debug("stopping existing frps process")
|
||||
_ = m.cmd.Process.Kill()
|
||||
m.cmd = nil
|
||||
}
|
||||
return m.startProcessLocked(generation)
|
||||
}
|
||||
|
||||
func (m *Manager) startProcessLocked(generation uint64) error {
|
||||
cmd := exec.Command(m.frpsPath, "-c", m.configPath)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.cmd = cmd
|
||||
m.status = "healthy"
|
||||
m.lastError = ""
|
||||
|
||||
go func(c *exec.Cmd) {
|
||||
err := c.Wait()
|
||||
slog.Warn("frps process exited", "error", err)
|
||||
m.mu.Lock()
|
||||
if m.cmd == c {
|
||||
m.cmd = nil
|
||||
m.status = "unhealthy"
|
||||
if err != nil {
|
||||
m.lastError = err.Error()
|
||||
} else {
|
||||
m.lastError = "frps process exited"
|
||||
}
|
||||
}
|
||||
shouldRestart := !m.stopping && m.generation == generation
|
||||
m.mu.Unlock()
|
||||
if !shouldRestart {
|
||||
return
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.stopping || m.generation != generation {
|
||||
return
|
||||
}
|
||||
slog.Warn("restarting frps after unexpected exit")
|
||||
if err := m.startProcessLocked(generation); err != nil {
|
||||
m.status = "unhealthy"
|
||||
m.lastError = err.Error()
|
||||
slog.Error("failed to auto restart frps", "error", err)
|
||||
}
|
||||
}(cmd)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Stop() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.stopping = true
|
||||
m.generation++
|
||||
if m.cmd != nil && m.cmd.Process != nil {
|
||||
_ = m.cmd.Process.Kill()
|
||||
m.cmd = nil
|
||||
}
|
||||
m.status = "unhealthy"
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package heartbeat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"openflare-relay/internal/config"
|
||||
"openflare-relay/internal/frps"
|
||||
"openflare-relay/internal/httpclient"
|
||||
"openflare-relay/internal/observability"
|
||||
"openflare-relay/internal/state"
|
||||
"openflare-relay/internal/updater"
|
||||
"openflare/service"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
client *httpclient.Client
|
||||
frpsManager *frps.Manager
|
||||
config *config.Config
|
||||
stateStore *state.Store
|
||||
updater *updater.Service
|
||||
}
|
||||
|
||||
func New(client *httpclient.Client, manager *frps.Manager, cfg *config.Config, stateStore *state.Store) *Service {
|
||||
return &Service{
|
||||
client: client,
|
||||
frpsManager: manager,
|
||||
config: cfg,
|
||||
stateStore: stateStore,
|
||||
updater: updater.New(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Run(ctx context.Context) {
|
||||
ticker := time.NewTicker(s.config.HeartbeatInterval.Duration())
|
||||
defer ticker.Stop()
|
||||
|
||||
// initial heartbeat
|
||||
s.doHeartbeat(ctx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.doHeartbeat(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) doHeartbeat(ctx context.Context) {
|
||||
slog.Debug("sending heartbeat")
|
||||
|
||||
runtimeStatus := s.frpsManager.GetRuntimeStatus()
|
||||
payload := service.RelayHeartbeatPayload{
|
||||
Version: config.Version,
|
||||
ExtVersion: s.frpsManager.GetVersion(),
|
||||
RelayStatus: runtimeStatus.Status,
|
||||
FrpsConnCount: runtimeStatus.Connections,
|
||||
FrpsProxyCount: runtimeStatus.ProxyCount,
|
||||
FrpsClientCount: runtimeStatus.ClientCount,
|
||||
FrpsProxies: runtimeStatus.Proxies,
|
||||
Name: s.config.NodeName,
|
||||
IP: s.config.NodeIP,
|
||||
Profile: observability.BuildProfile(s.config, s.stateStore),
|
||||
Snapshot: observability.BuildSnapshot(s.config, s.stateStore),
|
||||
HealthEvents: observability.BuildHealthEvents(runtimeStatus),
|
||||
}
|
||||
|
||||
resp, err := s.client.Heartbeat(ctx, payload)
|
||||
if err != nil {
|
||||
slog.Error("heartbeat failed", "error", err)
|
||||
return
|
||||
}
|
||||
slog.Debug("heartbeat succeeded")
|
||||
|
||||
// Update configs if changed
|
||||
s.frpsManager.UpdateConfig(resp.RelayConfig)
|
||||
|
||||
if resp != nil && resp.RelaySettings != nil {
|
||||
s.tryAutoUpdate(ctx, resp.RelaySettings)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) tryAutoUpdate(ctx context.Context, settings *service.RelaySettings) {
|
||||
if settings == nil || s.updater == nil {
|
||||
return
|
||||
}
|
||||
force := settings.UpdateNow
|
||||
shouldCheck := settings.AutoUpdate || force
|
||||
if !shouldCheck || settings.UpdateRepo == "" {
|
||||
return
|
||||
}
|
||||
channel := "stable"
|
||||
if force && settings.UpdateChannel != "" {
|
||||
channel = settings.UpdateChannel
|
||||
}
|
||||
slog.Info("checking for relay updates", "repo", settings.UpdateRepo, "channel", channel, "force", force)
|
||||
err := s.updater.CheckAndUpdate(ctx, settings.UpdateRepo, updater.UpdateOptions{
|
||||
Channel: channel,
|
||||
TagName: settings.UpdateTag,
|
||||
Force: force,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("relay update check failed", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"openflare/service"
|
||||
)
|
||||
|
||||
type APIResponse[T any] struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data T `json:"data"`
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func New(baseURL string, token string, timeout time.Duration) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
token: token,
|
||||
httpClient: &http.Client{
|
||||
Timeout: timeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Heartbeat(ctx context.Context, payload service.RelayHeartbeatPayload) (*service.RelayHeartbeatResponse, error) {
|
||||
resp := APIResponse[service.RelayHeartbeatResponse]{}
|
||||
if err := c.postJSON(ctx, "/api/relay/heartbeat", payload, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !resp.Success {
|
||||
return nil, errors.New(resp.Message)
|
||||
}
|
||||
return &resp.Data, nil
|
||||
}
|
||||
|
||||
func (c *Client) SetToken(token string) {
|
||||
c.token = strings.TrimSpace(token)
|
||||
slog.Debug("http client token updated")
|
||||
}
|
||||
|
||||
func (c *Client) getJSON(ctx context.Context, path string, target any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("X-Agent-Token", c.token)
|
||||
return c.do(req, target)
|
||||
}
|
||||
|
||||
func (c *Client) postJSON(ctx context.Context, path string, body any, target any) error {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Agent-Token", c.token)
|
||||
return c.do(req, target)
|
||||
}
|
||||
|
||||
func (c *Client) do(req *http.Request, target any) error {
|
||||
res, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
slog.Error("http request failed", "method", req.Method, "path", req.URL.Path, "error", err)
|
||||
return err
|
||||
}
|
||||
defer func(Body io.ReadCloser) {
|
||||
err := Body.Close()
|
||||
if err != nil {
|
||||
slog.Error("failed to close response body", "error", err)
|
||||
}
|
||||
}(res.Body)
|
||||
if res.StatusCode != http.StatusOK {
|
||||
slog.Warn("http request returned non-200", "method", req.Method, "path", req.URL.Path, "status", res.Status)
|
||||
return errors.New(res.Status)
|
||||
}
|
||||
if target == nil {
|
||||
var wrapper APIResponse[json.RawMessage]
|
||||
if err = json.NewDecoder(res.Body).Decode(&wrapper); err != nil {
|
||||
slog.Error("http response decode failed", "method", req.Method, "path", req.URL.Path, "error", err)
|
||||
return err
|
||||
}
|
||||
if !wrapper.Success {
|
||||
slog.Warn("http api response failed", "method", req.Method, "path", req.URL.Path, "message", wrapper.Message)
|
||||
return errors.New(wrapper.Message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err = json.NewDecoder(res.Body).Decode(target); err != nil {
|
||||
slog.Error("http response decode failed", "method", req.Method, "path", req.URL.Path, "error", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"openflare-relay/internal/config"
|
||||
"openflare-relay/internal/frps"
|
||||
"openflare-relay/internal/state"
|
||||
"openflare/service"
|
||||
)
|
||||
|
||||
func BuildProfile(cfg *config.Config, stateStore *state.Store) *service.AgentNodeSystemProfile {
|
||||
profile := collectProfile(cfg)
|
||||
if profile == nil || stateStore == nil {
|
||||
return profile
|
||||
}
|
||||
fingerprint := fingerprintProfile(profile)
|
||||
snapshot, err := stateStore.Load()
|
||||
if err != nil {
|
||||
return profile
|
||||
}
|
||||
if snapshot.LastProfileFingerprint == fingerprint {
|
||||
return nil
|
||||
}
|
||||
snapshot.LastProfileFingerprint = fingerprint
|
||||
if err = stateStore.Save(snapshot); err != nil {
|
||||
return profile
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
func BuildSnapshot(cfg *config.Config, stateStore *state.Store) *service.AgentNodeMetricSnapshot {
|
||||
now := time.Now().UTC()
|
||||
metric := &service.AgentNodeMetricSnapshot{CapturedAtUnix: now.Unix()}
|
||||
|
||||
metric.MemoryTotalBytes, metric.MemoryUsedBytes = readMemInfo()
|
||||
metric.StorageTotalBytes, metric.StorageUsedBytes = statFilesystem(cfg.DataDir)
|
||||
metric.NetworkRxBytes, metric.NetworkTxBytes = readLinuxNetworkTotals()
|
||||
metric.DiskReadBytes, metric.DiskWriteBytes = readLinuxDiskTotals()
|
||||
|
||||
if stateStore == nil {
|
||||
return metric
|
||||
}
|
||||
totalCPU, idleCPU := readLinuxCPUStat()
|
||||
snapshot, err := stateStore.Load()
|
||||
if err != nil {
|
||||
return metric
|
||||
}
|
||||
if snapshot.LastCPUStatTotal > 0 && totalCPU > snapshot.LastCPUStatTotal && idleCPU >= snapshot.LastCPUStatIdle {
|
||||
deltaTotal := totalCPU - snapshot.LastCPUStatTotal
|
||||
deltaIdle := idleCPU - snapshot.LastCPUStatIdle
|
||||
if deltaTotal > 0 && deltaIdle <= deltaTotal {
|
||||
metric.CPUUsagePercent = float64(deltaTotal-deltaIdle) / float64(deltaTotal) * 100
|
||||
}
|
||||
}
|
||||
snapshot.LastCPUStatTotal = totalCPU
|
||||
snapshot.LastCPUStatIdle = idleCPU
|
||||
snapshot.LastMetricAtUnix = now.Unix()
|
||||
_ = stateStore.Save(snapshot)
|
||||
return metric
|
||||
}
|
||||
|
||||
func BuildHealthEvents(status frps.RuntimeStatus) []service.AgentNodeHealthEvent {
|
||||
if strings.TrimSpace(status.Status) == "healthy" {
|
||||
return []service.AgentNodeHealthEvent{}
|
||||
}
|
||||
message := strings.TrimSpace(status.LastError)
|
||||
if message == "" {
|
||||
message = "frps runtime is not healthy"
|
||||
}
|
||||
return []service.AgentNodeHealthEvent{{
|
||||
EventType: "frps_unhealthy",
|
||||
Severity: "critical",
|
||||
Message: message,
|
||||
TriggeredAtUnix: time.Now().UTC().Unix(),
|
||||
}}
|
||||
}
|
||||
|
||||
func collectProfile(cfg *config.Config) *service.AgentNodeSystemProfile {
|
||||
hostname, _ := os.Hostname()
|
||||
osName, osVersion := readLinuxOSRelease()
|
||||
totalMemory, _ := readMemInfo()
|
||||
totalDisk, _ := statFilesystem(cfg.DataDir)
|
||||
return &service.AgentNodeSystemProfile{
|
||||
Hostname: strings.TrimSpace(hostname),
|
||||
OSName: osName,
|
||||
OSVersion: osVersion,
|
||||
KernelVersion: readFirstLine("/proc/sys/kernel/osrelease"),
|
||||
Architecture: runtime.GOARCH,
|
||||
CPUModel: readLinuxCPUModel(),
|
||||
CPUCores: runtime.NumCPU(),
|
||||
TotalMemoryBytes: totalMemory,
|
||||
TotalDiskBytes: totalDisk,
|
||||
UptimeSeconds: readLinuxUptimeSeconds(),
|
||||
ReportedAtUnix: time.Now().UTC().Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
func fingerprintProfile(profile *service.AgentNodeSystemProfile) string {
|
||||
raw, err := json.Marshal(profile)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func readLinuxOSRelease() (string, string) {
|
||||
file, err := os.Open("/etc/os-release")
|
||||
if err != nil {
|
||||
return runtime.GOOS, ""
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
values := make(map[string]string)
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
key, value, ok := strings.Cut(strings.TrimSpace(scanner.Text()), "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
values[key] = strings.Trim(value, `"`)
|
||||
}
|
||||
if pretty := strings.TrimSpace(values["PRETTY_NAME"]); pretty != "" {
|
||||
return pretty, strings.TrimSpace(values["VERSION_ID"])
|
||||
}
|
||||
if name := strings.TrimSpace(values["NAME"]); name != "" {
|
||||
return name, strings.TrimSpace(values["VERSION_ID"])
|
||||
}
|
||||
return runtime.GOOS, ""
|
||||
}
|
||||
|
||||
func readLinuxCPUModel() string {
|
||||
file, err := os.Open("/proc/cpuinfo")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(strings.ToLower(line), "model name") {
|
||||
_, value, ok := strings.Cut(line, ":")
|
||||
if ok {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func readMemInfo() (int64, int64) {
|
||||
file, err := os.Open("/proc/meminfo")
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var totalKB, availableKB int64
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "MemTotal:") {
|
||||
totalKB = parseMemInfoValue(line)
|
||||
}
|
||||
if strings.HasPrefix(line, "MemAvailable:") {
|
||||
availableKB = parseMemInfoValue(line)
|
||||
}
|
||||
}
|
||||
total := totalKB * 1024
|
||||
used := total - availableKB*1024
|
||||
if used < 0 {
|
||||
used = 0
|
||||
}
|
||||
return total, used
|
||||
}
|
||||
|
||||
func parseMemInfoValue(line string) int64 {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
return 0
|
||||
}
|
||||
value, err := strconv.ParseInt(fields[1], 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func readLinuxUptimeSeconds() int64 {
|
||||
content, err := os.ReadFile("/proc/uptime")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
fields := strings.Fields(string(content))
|
||||
if len(fields) == 0 {
|
||||
return 0
|
||||
}
|
||||
value, err := strconv.ParseFloat(fields[0], 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return int64(value)
|
||||
}
|
||||
|
||||
func readLinuxCPUStat() (uint64, uint64) {
|
||||
content, err := os.ReadFile("/proc/stat")
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
for _, line := range strings.Split(string(content), "\n") {
|
||||
if !strings.HasPrefix(line, "cpu ") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 5 {
|
||||
return 0, 0
|
||||
}
|
||||
var total uint64
|
||||
for index := 1; index < len(fields); index++ {
|
||||
value, err := strconv.ParseUint(fields[index], 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
total += value
|
||||
}
|
||||
idle, err := strconv.ParseUint(fields[4], 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
return total, idle
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
func readLinuxNetworkTotals() (int64, int64) {
|
||||
file, err := os.Open("/proc/net/dev")
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var rx, tx int64
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
name, data, ok := strings.Cut(strings.TrimSpace(scanner.Text()), ":")
|
||||
if !ok || strings.TrimSpace(name) == "lo" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(data)
|
||||
if len(fields) < 16 {
|
||||
continue
|
||||
}
|
||||
if value, err := strconv.ParseInt(fields[0], 10, 64); err == nil {
|
||||
rx += value
|
||||
}
|
||||
if value, err := strconv.ParseInt(fields[8], 10, 64); err == nil {
|
||||
tx += value
|
||||
}
|
||||
}
|
||||
return rx, tx
|
||||
}
|
||||
|
||||
func readLinuxDiskTotals() (int64, int64) {
|
||||
file, err := os.Open("/proc/diskstats")
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var readBytes, writeBytes int64
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 14 || shouldSkipDiskDevice(fields[2]) {
|
||||
continue
|
||||
}
|
||||
if value, err := strconv.ParseInt(fields[5], 10, 64); err == nil {
|
||||
readBytes += value * 512
|
||||
}
|
||||
if value, err := strconv.ParseInt(fields[9], 10, 64); err == nil {
|
||||
writeBytes += value * 512
|
||||
}
|
||||
}
|
||||
return readBytes, writeBytes
|
||||
}
|
||||
|
||||
func shouldSkipDiskDevice(device string) bool {
|
||||
return device == "" || strings.HasPrefix(device, "loop") || strings.HasPrefix(device, "ram") || strings.HasPrefix(device, "dm-")
|
||||
}
|
||||
|
||||
func statFilesystem(path string) (int64, int64) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
path = string(os.PathSeparator)
|
||||
}
|
||||
var stat syscall.Statfs_t
|
||||
if err := syscall.Statfs(filepath.Clean(path), &stat); err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
total := int64(stat.Blocks) * int64(stat.Bsize)
|
||||
used := total - int64(stat.Bavail)*int64(stat.Bsize)
|
||||
if used < 0 {
|
||||
used = 0
|
||||
}
|
||||
return total, used
|
||||
}
|
||||
|
||||
func readFirstLine(path string) string {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(content))
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"openflare-relay/internal/config"
|
||||
"openflare-relay/internal/frps"
|
||||
"openflare-relay/internal/heartbeat"
|
||||
"openflare-relay/internal/httpclient"
|
||||
"openflare-relay/internal/state"
|
||||
"openflare-relay/internal/wsclient"
|
||||
"openflare/service"
|
||||
)
|
||||
|
||||
type Runner struct {
|
||||
Config *config.Config
|
||||
StateStore *state.Store
|
||||
HeartbeatService *heartbeat.Service
|
||||
FrpsManager *frps.Manager
|
||||
WebSocketService *wsclient.Client
|
||||
HttpClient *httpclient.Client
|
||||
}
|
||||
|
||||
func (r *Runner) Run(ctx context.Context) error {
|
||||
// Start heartbeat loop in background
|
||||
go r.HeartbeatService.Run(ctx)
|
||||
|
||||
// WebSocket reconnection loop
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
r.FrpsManager.Stop()
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
conn, err := r.WebSocketService.Connect(ctx)
|
||||
if err != nil {
|
||||
slog.Error("relay ws connect failed, will retry", "error", err)
|
||||
r.sleepContext(ctx, 5*time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
r.handleConnection(ctx, conn)
|
||||
_ = conn.Close()
|
||||
slog.Info("relay ws connection closed, reconnecting...")
|
||||
r.sleepContext(ctx, 2*time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) handleConnection(ctx context.Context, conn *wsclient.Connection) {
|
||||
// Send pings at 2× heartbeat interval to keep the server-side read deadline
|
||||
// from expiring (server closes the WS if no data arrives within ~30 s).
|
||||
pingInterval := r.Config.HeartbeatInterval.Duration() * 2
|
||||
pingTicker := time.NewTicker(pingInterval)
|
||||
defer pingTicker.Stop()
|
||||
|
||||
messages := make(chan service.WSMessage, 8)
|
||||
readDone := make(chan error, 1)
|
||||
go func() {
|
||||
for {
|
||||
msg, err := conn.Receive()
|
||||
if err != nil {
|
||||
readDone <- err
|
||||
return
|
||||
}
|
||||
select {
|
||||
case messages <- msg:
|
||||
case <-ctx.Done():
|
||||
readDone <- ctx.Err()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case err := <-readDone:
|
||||
slog.Error("relay ws receive failed", "error", err)
|
||||
return
|
||||
case <-pingTicker.C:
|
||||
if err := conn.SendPing(); err != nil {
|
||||
slog.Error("relay ws send ping failed", "error", err)
|
||||
return
|
||||
}
|
||||
case msg := <-messages:
|
||||
switch msg.Type {
|
||||
case "ping":
|
||||
_ = conn.SendPong()
|
||||
case "pong":
|
||||
slog.Debug("relay ws pong received")
|
||||
case "relay_config":
|
||||
payloadBytes, ok := msg.Payload.(json.RawMessage)
|
||||
if !ok {
|
||||
slog.Error("invalid relay_config payload type")
|
||||
continue
|
||||
}
|
||||
var cfg service.RelayConfig
|
||||
if err := json.Unmarshal(payloadBytes, &cfg); err != nil {
|
||||
slog.Error("failed to unmarshal relay_config", "error", err)
|
||||
continue
|
||||
}
|
||||
r.FrpsManager.UpdateConfig(&cfg)
|
||||
default:
|
||||
slog.Debug("ignored unknown ws message type", "type", msg.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) sleepContext(ctx context.Context, d time.Duration) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(d):
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
path string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type State struct {
|
||||
LastAuthToken string `json:"last_auth_token"`
|
||||
LastProfileFingerprint string `json:"last_profile_fingerprint"`
|
||||
LastCPUStatTotal uint64 `json:"last_cpu_stat_total"`
|
||||
LastCPUStatIdle uint64 `json:"last_cpu_stat_idle"`
|
||||
LastMetricAtUnix int64 `json:"last_metric_at_unix"`
|
||||
}
|
||||
|
||||
func NewStore(path string) *Store {
|
||||
return &Store{
|
||||
path: path,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) Load() (*State, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
data, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &State{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var state State
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return &State{}, nil // Return empty state on corrupted file
|
||||
}
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
func (s *Store) Save(state *State) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
data, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Debug("saving relay state")
|
||||
return os.WriteFile(s.path, data, 0644)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//go:build !windows
|
||||
|
||||
package updater
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func replaceAndRestart(execPath string, tmpPath string) error {
|
||||
backupPath := execPath + ".bak"
|
||||
if err := removeBackupBinary(backupPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(execPath, backupPath); err != nil {
|
||||
renameErr := err
|
||||
if err := os.Remove(tmpPath); err != nil && !os.IsNotExist(err) {
|
||||
slog.Error("remove tmp binary failed", "path", tmpPath, "error", err)
|
||||
return fmt.Errorf("backup current binary: %w; remove tmp binary: %v", renameErr, err)
|
||||
}
|
||||
return fmt.Errorf("backup current binary: %w", renameErr)
|
||||
}
|
||||
if err := os.Rename(tmpPath, execPath); err != nil {
|
||||
replaceErr := err
|
||||
if err := os.Rename(backupPath, execPath); err != nil {
|
||||
slog.Error("restore backup binary failed", "path", backupPath, "error", err)
|
||||
return fmt.Errorf("replace binary: %w; restore backup binary: %v", replaceErr, err)
|
||||
}
|
||||
return fmt.Errorf("replace binary: %w", replaceErr)
|
||||
}
|
||||
if err := removeBackupBinary(backupPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := syscall.Exec(execPath, os.Args, os.Environ()); err != nil {
|
||||
return fmt.Errorf("exec restart: %w", err)
|
||||
}
|
||||
return fmt.Errorf("unreachable after exec")
|
||||
}
|
||||
|
||||
func removeBackupBinary(path string) error {
|
||||
if err := os.Remove(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
slog.Error("remove backup binary failed", "path", path, "error", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//go:build windows
|
||||
|
||||
package updater
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func replaceAndRestart(execPath string, tmpPath string) error {
|
||||
backupPath := execPath + ".bak"
|
||||
scriptPath := execPath + ".update.cmd"
|
||||
script := fmt.Sprintf(`@echo off
|
||||
setlocal
|
||||
:waitloop
|
||||
move /Y "%s" "%s" >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
ping 127.0.0.1 -n 2 >nul
|
||||
goto waitloop
|
||||
)
|
||||
move /Y "%s" "%s" >nul 2>nul
|
||||
if errorlevel 1 exit /b 1
|
||||
start "" %s
|
||||
del /Q "%s" >nul 2>nul
|
||||
del /Q "%%~f0" >nul 2>nul
|
||||
`, execPath, backupPath, tmpPath, execPath, buildWindowsCommandLine(execPath, os.Args[1:]), backupPath)
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0o700); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("write restart script: %w", err)
|
||||
}
|
||||
cmd := exec.Command("cmd", "/C", "start", "", scriptPath)
|
||||
if err := cmd.Start(); err != nil {
|
||||
os.Remove(scriptPath)
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("schedule restart: %w", err)
|
||||
}
|
||||
os.Exit(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildWindowsCommandLine(execPath string, args []string) string {
|
||||
parts := []string{quoteWindowsArg(execPath)}
|
||||
for _, arg := range args {
|
||||
parts = append(parts, quoteWindowsArg(arg))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func quoteWindowsArg(value string) string {
|
||||
return `"` + strings.ReplaceAll(value, `"`, `""`) + `"`
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"openflare/utils"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"openflare-relay/internal/config"
|
||||
)
|
||||
|
||||
const maxChecksumAssetSize = 64 * 1024
|
||||
|
||||
var replaceAndRestartFunc = replaceAndRestart
|
||||
|
||||
type Service struct {
|
||||
httpClient *http.Client
|
||||
lastCheckKey string
|
||||
}
|
||||
|
||||
func New() *Service {
|
||||
return &Service{
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
type githubRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
Draft bool `json:"draft"`
|
||||
Assets []githubAsset `json:"assets"`
|
||||
}
|
||||
|
||||
type githubAsset struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
}
|
||||
|
||||
type UpdateOptions struct {
|
||||
Channel string
|
||||
TagName string
|
||||
Force bool
|
||||
}
|
||||
|
||||
func (s *Service) CheckAndUpdate(ctx context.Context, repo string, options UpdateOptions) error {
|
||||
release, err := s.getRelease(ctx, repo, options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check latest release: %w", err)
|
||||
}
|
||||
if release == nil || release.TagName == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
remoteVersion := normalizeVersion(release.TagName)
|
||||
localVersion := normalizeVersion(config.Version)
|
||||
checkKey := buildReleaseCheckKey(options, remoteVersion)
|
||||
|
||||
if remoteVersion == localVersion {
|
||||
return nil
|
||||
}
|
||||
if !options.Force && checkKey != "" && checkKey == s.lastCheckKey {
|
||||
return nil
|
||||
}
|
||||
if !isNewer(localVersion, remoteVersion) {
|
||||
s.lastCheckKey = checkKey
|
||||
return nil
|
||||
}
|
||||
|
||||
slog.Info("relay update available", "from", localVersion, "to", remoteVersion)
|
||||
assetName := assetNameForGOOSGOARCH(runtime.GOOS, runtime.GOARCH)
|
||||
checksumAssetName := assetName + ".sha256"
|
||||
|
||||
var downloadURL string
|
||||
var checksumURL string
|
||||
for _, asset := range release.Assets {
|
||||
switch asset.Name {
|
||||
case assetName:
|
||||
downloadURL = asset.BrowserDownloadURL
|
||||
case checksumAssetName:
|
||||
checksumURL = asset.BrowserDownloadURL
|
||||
}
|
||||
}
|
||||
if downloadURL == "" {
|
||||
s.lastCheckKey = checkKey
|
||||
return fmt.Errorf("no matching asset %q in release %s", assetName, release.TagName)
|
||||
}
|
||||
if checksumURL == "" {
|
||||
return fmt.Errorf("no matching checksum asset %q in release %s", checksumAssetName, release.TagName)
|
||||
}
|
||||
|
||||
expectedChecksum, err := s.downloadChecksum(ctx, checksumURL, assetName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download checksum: %w", err)
|
||||
}
|
||||
|
||||
execPath, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get executable path: %w", err)
|
||||
}
|
||||
if err = s.downloadAndRestart(ctx, downloadURL, expectedChecksum, execPath); err != nil {
|
||||
return fmt.Errorf("download and restart: %w", err)
|
||||
}
|
||||
s.lastCheckKey = checkKey
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) getRelease(ctx context.Context, repo string, options UpdateOptions) (*githubRelease, error) {
|
||||
tagName := strings.TrimSpace(options.TagName)
|
||||
if tagName != "" {
|
||||
return s.getReleaseByTag(ctx, repo, tagName)
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(options.Channel), "preview") {
|
||||
return s.getLatestPreviewRelease(ctx, repo)
|
||||
}
|
||||
return s.getLatestStableRelease(ctx, repo)
|
||||
}
|
||||
|
||||
func (s *Service) getLatestStableRelease(ctx context.Context, repo string) (*githubRelease, error) {
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo)
|
||||
return s.fetchReleaseFromURL(ctx, url)
|
||||
}
|
||||
|
||||
func (s *Service) getLatestPreviewRelease(ctx context.Context, repo string) (*githubRelease, error) {
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=20", repo)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("github api returned %s", resp.Status)
|
||||
}
|
||||
|
||||
var releases []githubRelease
|
||||
if err = json.NewDecoder(resp.Body).Decode(&releases); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, release := range releases {
|
||||
if release.Draft || !release.Prerelease {
|
||||
continue
|
||||
}
|
||||
releaseCopy := release
|
||||
return &releaseCopy, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *Service) getReleaseByTag(ctx context.Context, repo string, tag string) (*githubRelease, error) {
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/tags/%s", repo, strings.TrimSpace(tag))
|
||||
return s.fetchReleaseFromURL(ctx, url)
|
||||
}
|
||||
|
||||
func (s *Service) fetchReleaseFromURL(ctx context.Context, url string) (*githubRelease, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func(Body io.ReadCloser) {
|
||||
err := Body.Close()
|
||||
if err != nil {
|
||||
slog.Error("failed to close response body", "error", err)
|
||||
}
|
||||
}(resp.Body)
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("github api returned %s", resp.Status)
|
||||
}
|
||||
|
||||
return decodeRelease(resp.Body)
|
||||
}
|
||||
|
||||
func decodeRelease(reader io.Reader) (*githubRelease, error) {
|
||||
var release githubRelease
|
||||
if err := json.NewDecoder(reader).Decode(&release); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &release, nil
|
||||
}
|
||||
|
||||
func (s *Service) downloadChecksum(ctx context.Context, url string, assetName string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("checksum download returned %s", resp.Status)
|
||||
}
|
||||
|
||||
content, err := io.ReadAll(io.LimitReader(resp.Body, maxChecksumAssetSize+1))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(content) > maxChecksumAssetSize {
|
||||
return "", fmt.Errorf("checksum asset exceeds %d bytes", maxChecksumAssetSize)
|
||||
}
|
||||
checksum, err := parseSHA256Checksum(string(content), assetName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return checksum, nil
|
||||
}
|
||||
|
||||
func parseSHA256Checksum(content string, assetName string) (string, error) {
|
||||
assetName = strings.TrimSpace(assetName)
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if checksum, ok := parseSHA256Line(line, assetName); ok {
|
||||
return checksum, nil
|
||||
}
|
||||
}
|
||||
if assetName == "" {
|
||||
return "", fmt.Errorf("checksum asset does not contain a valid sha256 digest")
|
||||
}
|
||||
return "", fmt.Errorf("checksum asset does not contain a sha256 digest for %q", assetName)
|
||||
}
|
||||
|
||||
func parseSHA256Line(line string, assetName string) (string, bool) {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 1 && isSHA256Hex(fields[0]) {
|
||||
return strings.ToLower(fields[0]), true
|
||||
}
|
||||
if len(fields) >= 2 && isSHA256Hex(fields[0]) {
|
||||
fileName := strings.TrimPrefix(strings.TrimSpace(fields[1]), "*")
|
||||
if assetName == "" || fileName == assetName {
|
||||
return strings.ToLower(fields[0]), true
|
||||
}
|
||||
}
|
||||
|
||||
prefix := "SHA256("
|
||||
if strings.HasPrefix(line, prefix) {
|
||||
closing := strings.Index(line, ")")
|
||||
if closing > len(prefix) && closing+1 < len(line) {
|
||||
fileName := strings.TrimSpace(line[len(prefix):closing])
|
||||
rest := strings.TrimSpace(line[closing+1:])
|
||||
rest = strings.TrimPrefix(rest, "=")
|
||||
rest = strings.TrimSpace(rest)
|
||||
if isSHA256Hex(rest) && (assetName == "" || fileName == assetName) {
|
||||
return strings.ToLower(rest), true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func isSHA256Hex(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) != sha256.Size*2 {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(value)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *Service) downloadAndRestart(ctx context.Context, url string, expectedChecksum string, targetPath string) error {
|
||||
expectedChecksum = strings.ToLower(strings.TrimSpace(expectedChecksum))
|
||||
if !isSHA256Hex(expectedChecksum) {
|
||||
return fmt.Errorf("invalid expected sha256 checksum")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download returned %s", resp.Status)
|
||||
}
|
||||
|
||||
tmpPath := targetPath + ".update"
|
||||
if runtime.GOOS == "windows" && !strings.HasSuffix(strings.ToLower(tmpPath), ".exe") {
|
||||
tmpPath += ".exe"
|
||||
}
|
||||
tmpFile, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hasher := sha256.New()
|
||||
if _, err = io.Copy(io.MultiWriter(tmpFile, hasher), resp.Body); err != nil {
|
||||
tmpFile.Close()
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err = tmpFile.Close(); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
actualChecksum := hex.EncodeToString(hasher.Sum(nil))
|
||||
if actualChecksum != expectedChecksum {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("sha256 checksum mismatch: expected %s, got %s", expectedChecksum, actualChecksum)
|
||||
}
|
||||
if err = os.Chmod(tmpPath, 0o755); err != nil && runtime.GOOS != "windows" {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("set executable permission: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("relay binary updated, restarting")
|
||||
return replaceAndRestartFunc(targetPath, tmpPath)
|
||||
}
|
||||
|
||||
func assetNameForGOOSGOARCH(goos string, goarch string) string {
|
||||
name := fmt.Sprintf("openflare-relay-%s-%s", goos, goarch)
|
||||
if goos == "windows" {
|
||||
return name + ".exe"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func normalizeVersion(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
v = strings.TrimPrefix(v, "v")
|
||||
return v
|
||||
}
|
||||
|
||||
func isNewer(local, remote string) bool {
|
||||
return compareVersions(local, remote) < 0
|
||||
}
|
||||
|
||||
func buildReleaseCheckKey(options UpdateOptions, remoteVersion string) string {
|
||||
channel := strings.TrimSpace(options.Channel)
|
||||
if channel == "" {
|
||||
channel = "stable"
|
||||
}
|
||||
if tagName := strings.TrimSpace(options.TagName); tagName != "" {
|
||||
return channel + ":" + tagName
|
||||
}
|
||||
return channel + ":" + remoteVersion
|
||||
}
|
||||
|
||||
func compareVersions(local string, remote string) int {
|
||||
return utils.CompareVersions(local, remote)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package wsclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/websocket"
|
||||
"openflare/service"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
type Connection struct {
|
||||
conn *websocket.Conn
|
||||
url string
|
||||
readTimeout time.Duration
|
||||
}
|
||||
|
||||
func New(baseURL string, token string, timeout time.Duration) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
token: strings.TrimSpace(token),
|
||||
timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) SetToken(token string) {
|
||||
c.token = strings.TrimSpace(token)
|
||||
slog.Debug("relay ws client token updated")
|
||||
}
|
||||
|
||||
func (c *Client) Connect(ctx context.Context) (*Connection, error) {
|
||||
wsURL, err := buildWebsocketURL(c.baseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(c.token) == "" {
|
||||
return nil, errors.New("relay ws token is empty")
|
||||
}
|
||||
origin := strings.TrimSpace(c.baseURL)
|
||||
if origin == "" {
|
||||
origin = "http://localhost"
|
||||
}
|
||||
config, err := websocket.NewConfig(wsURL, origin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config.Header = http.Header{}
|
||||
config.Header.Set("X-Agent-Token", c.token)
|
||||
if c.timeout > 0 {
|
||||
config.Dialer = &net.Dialer{Timeout: c.timeout}
|
||||
}
|
||||
slog.Debug("relay ws dialing server", "url", wsURL)
|
||||
conn, err := config.DialContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slog.Debug("relay ws dial succeeded", "url", wsURL)
|
||||
return &Connection{conn: conn, url: wsURL, readTimeout: websocketReadTimeout(c.timeout)}, nil
|
||||
}
|
||||
|
||||
func buildWebsocketURL(baseURL string) (string, error) {
|
||||
parsed, err := url.Parse(strings.TrimRight(baseURL, "/"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch parsed.Scheme {
|
||||
case "http":
|
||||
parsed.Scheme = "ws"
|
||||
case "https":
|
||||
parsed.Scheme = "wss"
|
||||
case "ws", "wss":
|
||||
default:
|
||||
return "", errors.New("server_url scheme must be http, https, ws, or wss")
|
||||
}
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/") + "/api/relay/ws"
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func (conn *Connection) SendPing() error {
|
||||
if conn == nil || conn.conn == nil {
|
||||
return errors.New("relay ws connection is nil")
|
||||
}
|
||||
slog.Debug("relay ws sending ping")
|
||||
return websocket.JSON.Send(conn.conn, service.WSMessage{
|
||||
Type: "ping",
|
||||
})
|
||||
}
|
||||
|
||||
func (conn *Connection) SendPong() error {
|
||||
if conn == nil || conn.conn == nil {
|
||||
return errors.New("relay ws connection is nil")
|
||||
}
|
||||
slog.Debug("relay ws sending pong")
|
||||
return websocket.JSON.Send(conn.conn, service.WSMessage{
|
||||
Type: "pong",
|
||||
})
|
||||
}
|
||||
|
||||
func (conn *Connection) Receive() (service.WSMessage, error) {
|
||||
var message service.WSMessage
|
||||
if conn == nil || conn.conn == nil {
|
||||
return message, errors.New("relay ws connection is nil")
|
||||
}
|
||||
if conn.readTimeout > 0 {
|
||||
_ = conn.conn.SetReadDeadline(time.Now().Add(conn.readTimeout))
|
||||
}
|
||||
// Use custom json unmarshaling to handle any type
|
||||
var raw struct {
|
||||
Type string `json:"type"`
|
||||
Payload json.RawMessage `json:"payload,omitempty"`
|
||||
}
|
||||
err := websocket.JSON.Receive(conn.conn, &raw)
|
||||
if err != nil {
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||
slog.Debug("relay ws receive timeout waiting for server message", "timeout", conn.readTimeout)
|
||||
}
|
||||
return message, err
|
||||
}
|
||||
message.Type = raw.Type
|
||||
message.Payload = raw.Payload
|
||||
slog.Debug("relay ws received message", "type", message.Type)
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func websocketReadTimeout(requestTimeout time.Duration) time.Duration {
|
||||
timeout := requestTimeout * 6
|
||||
if timeout < 75*time.Second {
|
||||
return 75 * time.Second
|
||||
}
|
||||
return timeout
|
||||
}
|
||||
|
||||
func (conn *Connection) Close() error {
|
||||
if conn == nil || conn.conn == nil {
|
||||
return nil
|
||||
}
|
||||
return conn.conn.Close()
|
||||
}
|
||||
@@ -26,11 +26,10 @@ var OptionMapRWMutex sync.RWMutex
|
||||
var ItemsPerPage = 10
|
||||
|
||||
var PasswordLoginEnabled = true
|
||||
var PasswordRegisterEnabled = true
|
||||
var PasswordRegisterEnabled = false
|
||||
var EmailVerificationEnabled = false
|
||||
var GitHubOAuthEnabled = false
|
||||
var WeChatAuthEnabled = false
|
||||
var TurnstileCheckEnabled = false
|
||||
var RegisterEnabled = false
|
||||
|
||||
var SMTPServer = ""
|
||||
@@ -45,13 +44,12 @@ var WeChatServerAddress = ""
|
||||
var WeChatServerToken = ""
|
||||
var WeChatAccountQRCodeImageURL = ""
|
||||
|
||||
var TurnstileSiteKey = ""
|
||||
var TurnstileSecretKey = ""
|
||||
var AgentToken = ""
|
||||
var AccessToken = ""
|
||||
var AgentDiscoveryToken = ""
|
||||
var NodeOfflineThreshold = 2 * time.Minute
|
||||
|
||||
// V3 operational settings (hot-reloadable via Option table)
|
||||
|
||||
var AgentHeartbeatInterval = 10000 // milliseconds
|
||||
var AgentWebsocketUpgradeEnabled = true
|
||||
var AgentUpdateRepo = "Rain-kl/OpenFlare"
|
||||
@@ -60,6 +58,7 @@ var DatabaseAutoCleanupEnabled = false
|
||||
var DatabaseAutoCleanupRetentionDays = 30
|
||||
|
||||
// V5 OpenResty performance settings (hot-reloadable via Option table)
|
||||
|
||||
var OpenRestyWorkerProcesses = "auto"
|
||||
var OpenRestyWorkerConnections = 4096
|
||||
var OpenRestyWorkerRlimitNofile = 65535
|
||||
@@ -98,6 +97,7 @@ var OpenRestyMainConfigTemplate = `# This file is generated by OpenFlare. Do not
|
||||
worker_processes {{OpenRestyWorkerProcesses}};
|
||||
worker_rlimit_nofile {{OpenRestyWorkerRlimitNofile}};
|
||||
pid logs/nginx.pid;
|
||||
error_log {{OpenRestyErrorLogPath}} warn;
|
||||
|
||||
events {
|
||||
worker_connections {{OpenRestyWorkerConnections}};
|
||||
@@ -140,13 +140,6 @@ const (
|
||||
RoleRootUser = 100
|
||||
)
|
||||
|
||||
var (
|
||||
FileUploadPermission = RoleGuestUser
|
||||
FileDownloadPermission = RoleGuestUser
|
||||
ImageUploadPermission = RoleGuestUser
|
||||
ImageDownloadPermission = RoleGuestUser
|
||||
)
|
||||
|
||||
// All duration's unit is seconds
|
||||
// Shouldn't larger then RateLimitKeyExpirationDuration
|
||||
var (
|
||||
@@ -156,12 +149,6 @@ var (
|
||||
GlobalWebRateLimitNum = 300
|
||||
GlobalWebRateLimitDuration int64 = 3 * 60
|
||||
|
||||
UploadRateLimitNum = 50
|
||||
UploadRateLimitDuration int64 = 60
|
||||
|
||||
DownloadRateLimitNum = 50
|
||||
DownloadRateLimitDuration int64 = 60
|
||||
|
||||
CriticalRateLimitNum = 100
|
||||
CriticalRateLimitDuration int64 = 20 * 60
|
||||
)
|
||||
|
||||
@@ -16,9 +16,6 @@ var (
|
||||
LogDir = flag.String("log-dir", "", "specify the log directory")
|
||||
)
|
||||
|
||||
// UploadPath Maybe override by ENV_VAR
|
||||
var UploadPath = "upload"
|
||||
|
||||
func printHelp() {
|
||||
fmt.Println("OpenFlare " + Version + " - Internal OpenResty Control Plane.")
|
||||
fmt.Println("Copyright (C) 2023 JustSong. All rights reserved.")
|
||||
@@ -26,11 +23,17 @@ func printHelp() {
|
||||
fmt.Println("Usage: openflare [--port <port>] [--log-dir <log directory>] [--version] [--help]")
|
||||
}
|
||||
|
||||
func init() {
|
||||
// ParseFlags 在命令行参数被任何 import 链上的 init() 误解析之前,
|
||||
// 由各 binary 的 main() 显式调用一次。openflare_server 与 openflare-relay
|
||||
// 共用 flag.CommandLine,必须先注册各自的 flag 再调用本函数。
|
||||
// 测试场景(go test)下不会执行本函数,单元测试可直接跳过命令行解析。
|
||||
func ParseFlags() {
|
||||
executableName := strings.ToLower(filepath.Base(os.Args[0]))
|
||||
if !strings.Contains(executableName, ".test") {
|
||||
flag.Parse()
|
||||
isTest := strings.Contains(executableName, ".test") || flag.Lookup("test.v") != nil
|
||||
if isTest {
|
||||
return
|
||||
}
|
||||
flag.Parse()
|
||||
|
||||
if *PrintVersion {
|
||||
fmt.Println(Version)
|
||||
@@ -54,11 +57,9 @@ func init() {
|
||||
if os.Getenv("DSN") != "" {
|
||||
SQLDSN = os.Getenv("DSN")
|
||||
}
|
||||
if os.Getenv("UPLOAD_PATH") != "" {
|
||||
UploadPath = os.Getenv("UPLOAD_PATH")
|
||||
}
|
||||
|
||||
if os.Getenv("AGENT_TOKEN") != "" {
|
||||
AgentToken = os.Getenv("AGENT_TOKEN")
|
||||
AccessToken = os.Getenv("AGENT_TOKEN")
|
||||
}
|
||||
SetLogLevel(os.Getenv("LOG_LEVEL"))
|
||||
if *LogDir != "" {
|
||||
@@ -76,7 +77,5 @@ func init() {
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(UploadPath); os.IsNotExist(err) {
|
||||
_ = os.Mkdir(UploadPath, 0777)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package common
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -11,6 +10,8 @@ import (
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type logLevel int
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user