mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 21:56:36 +08:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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
|
||||
|
||||
@@ -29,32 +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: |
|
||||
|
||||
mapfile -t TAGS < <(git tag --list 'v*' | sort -V)
|
||||
DELETED=0
|
||||
# Fetch all tags from remote to ensure full synchronization
|
||||
git fetch --tags --force
|
||||
|
||||
for TAG in "${TAGS[@]}"; do
|
||||
# Get all local/remote git tags starting with 'v'
|
||||
mapfile -t GIT_TAGS < <(git tag --list 'v*' | sort -V)
|
||||
|
||||
# Get all GitHub releases (tags associated with releases)
|
||||
mapfile -t GH_RELEASES < <(gh release list --limit 1000 --json tagName --jq '.[].tagName' 2>/dev/null || true)
|
||||
|
||||
# Helper function to check array containment
|
||||
contains_element() {
|
||||
local e match="$1"
|
||||
shift
|
||||
for e; do [[ "$e" == "$match" ]] && return 0; done
|
||||
return 1
|
||||
}
|
||||
|
||||
DELETED_TAGS=0
|
||||
DELETED_RELEASES=0
|
||||
|
||||
echo "=== Phase 1: Checking and cleaning Git tags ==="
|
||||
for TAG in "${GIT_TAGS[@]}"; do
|
||||
if [[ "$TAG" =~ ^v[0-9]+(\.[0-9]+)*$ ]]; then
|
||||
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,,}-flared" >> "$GITHUB_ENV"
|
||||
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
elif [[ -n "$INPUT_VERSION" ]]; then
|
||||
VERSION="$INPUT_VERSION"
|
||||
elif [[ -n "$POINTED_TAG" ]]; then
|
||||
VERSION="$POINTED_TAG"
|
||||
else
|
||||
echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log into registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./openflared/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
build-args: |
|
||||
VERSION=${{ env.VERSION }}
|
||||
cache-from: type=gha,scope=docker-flared-${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,ignore-error=true,timeout=20m,scope=docker-flared-${{ matrix.arch }}
|
||||
|
||||
- name: Export digest
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p /tmp/flared-digests
|
||||
touch "/tmp/flared-digests/${DIGEST#sha256:}"
|
||||
env:
|
||||
DIGEST: ${{ steps.build.outputs.digest }}
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: flared-digests-${{ matrix.arch }}
|
||||
path: /tmp/flared-digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v3
|
||||
with:
|
||||
subject-name: ${{ env.IMAGE }}
|
||||
subject-digest: ${{ steps.build.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
merge:
|
||||
name: Merge multi-arch manifest
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-tags: true
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set image metadata
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)"
|
||||
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
|
||||
|
||||
echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}-flared" >> "$GITHUB_ENV"
|
||||
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
elif [[ -n "$INPUT_VERSION" ]]; then
|
||||
VERSION="$INPUT_VERSION"
|
||||
elif [[ -n "$POINTED_TAG" ]]; then
|
||||
VERSION="$POINTED_TAG"
|
||||
else
|
||||
echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/flared-digests
|
||||
pattern: flared-digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log into registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create and push manifest list
|
||||
working-directory: /tmp/flared-digests
|
||||
shell: bash
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
references=()
|
||||
for digest in *; do
|
||||
references+=("${IMAGE}@sha256:${digest}")
|
||||
done
|
||||
|
||||
if [ ${#references[@]} -eq 0 ]; then
|
||||
echo "No digests found in /tmp/flared-digests" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${VERSION}" =~ (alpha|beta|rc) ]]; then
|
||||
FLOATING_TAG="beta"
|
||||
else
|
||||
FLOATING_TAG="latest"
|
||||
fi
|
||||
|
||||
docker buildx imagetools create \
|
||||
-t "${IMAGE}:${VERSION}" \
|
||||
-t "${IMAGE}:${FLOATING_TAG}" \
|
||||
"${references[@]}"
|
||||
|
||||
- name: Inspect image
|
||||
run: docker buildx imagetools inspect "${IMAGE}:${VERSION}"
|
||||
@@ -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}"
|
||||
@@ -197,7 +197,7 @@ jobs:
|
||||
run: |
|
||||
go mod download
|
||||
mkdir -p ../dist
|
||||
go build -trimpath -ldflags "-s -w -X 'openflare-agent/internal/config.AgentVersion=$VERSION'" -o "../dist/$ASSET_NAME" ./cmd/agent
|
||||
go build -trimpath -ldflags "-s -w -X 'openflare-agent/internal/config.Version=$VERSION'" -o "../dist/$ASSET_NAME" ./cmd/agent
|
||||
(cd ../dist && sha256sum "$ASSET_NAME" > "$ASSET_NAME.sha256")
|
||||
|
||||
- name: Upload Agent Artifact
|
||||
|
||||
+6
-6
@@ -44,9 +44,9 @@ go.work.sum
|
||||
|
||||
.DS_Store
|
||||
.codex-cache
|
||||
/.gomodcache/
|
||||
*.mmdb
|
||||
!openflare_agent/internal/geoipdata/GeoLite2-Country.mmdb
|
||||
|
||||
*-source
|
||||
*-source.*
|
||||
/.gomodcache/
|
||||
*.mmdb
|
||||
!openflare_agent/internal/geoipdata/GeoLite2-Country.mmdb
|
||||
|
||||
*-source
|
||||
*-source.*
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
# OpenFlare
|
||||
|
||||
轻量、自托管的 OpenResty 控制面,用于管理反向代理规则、配置发布、节点同步、TLS 证书与基础可观测能力。
|
||||
**[📖 English](./README.md) | [中文](./README.zh-CN.md)**
|
||||
|
||||
A lightweight, self-hosted control plane for OpenResty that manages reverse proxy rules, configuration releases, node synchronization, TLS certificates, and observability.
|
||||
|
||||
</div>
|
||||
|
||||
<p align="center
|
||||
<p align="center">
|
||||
<a href="https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/Rain-kl/OpenFlare?color=brightgreen" alt="license">
|
||||
</a>
|
||||
@@ -19,32 +21,34 @@
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
> 使用 `root` 用户初次登录系统后,务必修改默认密码 `123456`。
|
||||
> After the first login with the `root` user, you **must** change the default password `123456`.
|
||||
>
|
||||
> This BETA version is a temporary product in the development and testing phase. It may contain unknown issues and should not be used in production environments.
|
||||
|
||||
## 文档
|
||||
## Documentation
|
||||
|
||||
**https://open-flare.pages.dev**
|
||||
|
||||
常用入口:
|
||||
Quick links:
|
||||
|
||||
* [快速开始](https://open-flare.pages.dev/guide/quick-start)
|
||||
* [部署说明](https://open-flare.pages.dev/guide/deployment)
|
||||
* [配置项参考](https://open-flare.pages.dev/reference/configuration)
|
||||
* [系统设计](https://open-flare.pages.dev/design/)
|
||||
* [Quick Start](https://open-flare.pages.dev/guide/quick-start)
|
||||
* [Deployment Guide](https://open-flare.pages.dev/reference/deployment)
|
||||
* [Configuration Reference](https://open-flare.pages.dev/reference/configuration)
|
||||
* [System Design](https://open-flare.pages.dev/design/)
|
||||
|
||||
## 核心能力
|
||||
## Core Features
|
||||
|
||||
* 反向代理网站配置与多域名绑定
|
||||
* 配置预览、发布、激活与历史回滚
|
||||
* Agent 自动注册、心跳、同步、校验、reload 与失败回滚
|
||||
* OpenResty 主配置、性能参数、缓存参数与 Lua 资源托管
|
||||
* WAF 全局/自定义规则组,支持 IP/IP 段与国家级地域黑白名单
|
||||
* TLS 证书、域名资产、节点凭证与版本状态管理
|
||||
* 请求聚合、访问分析、资源快照、健康事件与节点详情
|
||||
* **Reverse Proxy Configuration**: Website management and multi-domain binding
|
||||
* **Configuration Lifecycle**: Preview, release, activation, and historical rollback
|
||||
* **Agent Management**: Auto-registration, heartbeat, sync, validation, reload, and failure rollback
|
||||
* **OpenResty Administration**: Main configuration, performance tuning, caching, and Lua resource hosting
|
||||
* **WAF Protection**: Global and custom rule groups with IP/CIDR and geographic blacklist/whitelist
|
||||
* **Certificate Management**: TLS certificates, domain assets, node credentials, and version control
|
||||
* **Observability**: Request aggregation, access analytics, resource snapshots, health events, and node metrics
|
||||
|
||||
## 快速开始
|
||||
## Quick Start
|
||||
|
||||
### 1. 启动 Server
|
||||
### 1. Launch Server
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -85,22 +89,20 @@ volumes:
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
访问地址:`http://localhost:3000`
|
||||
Access at: `http://localhost:3000`
|
||||
|
||||
默认账号:
|
||||
Default credentials:
|
||||
|
||||
* 用户名:`root`
|
||||
* 密码:`123456`
|
||||
* Username: `root`
|
||||
* Password: `123456`
|
||||
|
||||
### 2. 安装 Agent
|
||||
### 2. Install Agent
|
||||
|
||||
安装 Agent 前请先在节点上安装 OpenResty,或改用内置 OpenResty 的 Agent Docker 镜像。
|
||||
Before installing an Agent, install OpenResty on the target node, or use the Docker image with OpenResty built-in.
|
||||
|
||||
你可以在控制面板的节点管理->详情->节点信息->节点标识与部署复制安装命令,或直接使用下面的脚本:
|
||||
You can copy the installation command from the Dashboard → Node Management → Details → Node Info, or use the script below:
|
||||
|
||||
#### Docker 部署
|
||||
|
||||
Docker 部署可直接运行 Agent 镜像:
|
||||
#### Docker Deployment
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/rain-kl/openflare-agent:latest
|
||||
@@ -112,9 +114,9 @@ docker run -d --name openflare-agent --restart unless-stopped \
|
||||
ghcr.io/rain-kl/openflare-agent:latest
|
||||
```
|
||||
|
||||
#### 本地部署
|
||||
#### Local Installation
|
||||
|
||||
使用 `discovery_token` 接入:
|
||||
Using `discovery_token`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
|
||||
@@ -122,7 +124,7 @@ curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/inst
|
||||
--discovery-token YOUR_DISCOVERY_TOKEN
|
||||
```
|
||||
|
||||
使用节点专属 `agent_token`:
|
||||
Using node-specific `agent_token`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
|
||||
@@ -130,63 +132,62 @@ curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/inst
|
||||
--agent-token YOUR_AGENT_TOKEN
|
||||
```
|
||||
|
||||
安装脚本默认写入 `/opt/openflare-agent`,创建 `openflare-agent.service`,自动查找 `openresty`,并可重复执行以重装或升级 Agent。
|
||||
The installation script defaults to `/opt/openflare-agent`, creates a `openflare-agent.service`, auto-detects `openresty`, and supports re-execution for upgrades.
|
||||
|
||||
### 3. 卸载 Agent
|
||||
### 3. Uninstall Agent
|
||||
|
||||
如需彻底卸载 Agent 并清空本地数据,可执行:
|
||||
To completely uninstall the Agent and clean local data:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/uninstall-agent.sh | bash
|
||||
```
|
||||
|
||||
卸载脚本会先停止并移除 `openflare-agent.service`、删除整个 `/opt/openflare-agent` 目录,不会删除本机 OpenResty。
|
||||
The uninstall script stops and removes the `openflare-agent.service`, deletes the `/opt/openflare-agent` directory, and does not remove OpenResty.
|
||||
|
||||
### 4. 发布第一份配置
|
||||
### 4. Deploy Your First Configuration
|
||||
|
||||
1. 登录管理端并新增反代规则
|
||||
2. 在发布前查看预览或变更摘要
|
||||
3. 激活新版本
|
||||
4. Agent 通过 WebSocket 通知或后续 heartbeat 拉取并应用配置
|
||||
1. Log in to the dashboard and create a reverse proxy rule
|
||||
2. Preview changes or view the changelog before publishing
|
||||
3. Activate the new version
|
||||
4. Agents receive notifications via WebSocket or pull configuration on next heartbeat
|
||||
|
||||
版本号格式固定为 `YYYYMMDD-NNN`,历史版本不可变,回滚通过重新激活旧版本完成。
|
||||
Versions are immutable with format `YYYYMMDD-NNN`. Rollback is performed by reactivating a previous version.
|
||||
|
||||
## UI Preview
|
||||
|
||||
## 界面预览
|
||||
|
||||
### 仪表盘总览
|
||||
### Dashboard Overview
|
||||
|
||||

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

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

|
||||
|
||||
## 管理端与接口
|
||||
## Management Panel & API
|
||||
|
||||
管理端当前覆盖:
|
||||
The management panel includes:
|
||||
|
||||
* 反代规则
|
||||
* 配置版本
|
||||
* 节点管理
|
||||
* 应用记录
|
||||
* TLS 证书
|
||||
* 域名管理
|
||||
* WAF 规则组
|
||||
* 用户管理
|
||||
* 设置
|
||||
* 版本更新
|
||||
* POW 规则
|
||||
* Reverse Proxy Rules
|
||||
* Configuration Versions
|
||||
* Node Management
|
||||
* Application History
|
||||
* TLS Certificates
|
||||
* Domain Management
|
||||
* WAF Rule Groups
|
||||
* User Management
|
||||
* Settings
|
||||
* Version Updates
|
||||
* POW Rules
|
||||
|
||||
登录管理端后,可访问 Swagger UI:`/swagger/index.html`
|
||||
After logging in to the dashboard, access Swagger UI at: `/swagger/index.html`
|
||||
|
||||
## 开源协议
|
||||
## License
|
||||
|
||||
本项目采用 [Apache License 2.0](./LICENSE) 开源。
|
||||
This project is licensed under [Apache License 2.0](./LICENSE).
|
||||
|
||||
## Star History
|
||||
|
||||
|
||||
+201
@@ -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>
|
||||
+31
-2
@@ -1,5 +1,5 @@
|
||||
services:
|
||||
openflare-agent:
|
||||
agent:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: openflare_agent/Dockerfile
|
||||
@@ -20,4 +20,33 @@ services:
|
||||
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
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
||||
{ text: '概览', link: '' },
|
||||
{ text: '快速开始', link: 'quick-start' },
|
||||
{ text: '基础使用', link: 'usage' },
|
||||
{ text: 'WAF 自动 IP 组语法', link: 'waf-ip-group-expr' },
|
||||
{ text: 'SSO 登录配置', link: 'sso' },
|
||||
{ text: '发布第一份配置', link: 'first-site' },
|
||||
{ text: '故障排查', link: 'troubleshooting' }
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
你会学到:OpenFlare 的整体架构、Server、Agent、OpenResty 与管理端前端的职责边界,以及一次配置发布从管理端到节点生效的请求流。
|
||||
|
||||
OpenFlare 由 Server、Agent、节点本地 OpenResty 和管理端前端组成。Server 是控制面,Agent 是节点侧唯一受控落地入口,OpenResty 是实际数据面。
|
||||
OpenFlare 由 Server、Agent、节点本地 OpenResty 和管理端前端组成。Server 是控制面,Agent 是节点侧唯一受控落地入口,OpenResty 是实际数据面。内网穿透场景中,Relay(frps 管理器)和 OpenFlared(frpc 管理器)扩展了数据面流量路径。
|
||||
|
||||
### 标准反代流量路径
|
||||
|
||||
```text
|
||||
Browser
|
||||
@@ -24,14 +26,38 @@ OpenResty binary
|
||||
Origin
|
||||
```
|
||||
|
||||
### 内网穿透流量路径
|
||||
|
||||
```text
|
||||
Browser
|
||||
|
|
||||
| HTTPS request
|
||||
v
|
||||
OpenResty (Agent, TLS/WAF) <-- TunnelRelay 节点
|
||||
|
|
||||
| proxy_pass http://localhost:vhost_port (Host header preserved)
|
||||
v
|
||||
OpenFlareRelay (frps) <-- TunnelRelay 节点,与 Agent 同机部署
|
||||
|
|
||||
| frp tunnel protocol (HTTP Vhost routing by Host header)
|
||||
v
|
||||
OpenFlared (frpc) <-- 内网服务器
|
||||
|
|
||||
| HTTP/HTTPS forward
|
||||
v
|
||||
Internal Service (192.168.x.x)
|
||||
```
|
||||
|
||||
## 组件职责
|
||||
|
||||
| 组件 | 职责 |
|
||||
| --------- | ---------------------------------------------------------------------- |
|
||||
| Server | 管理端 UI、管理 API、Agent API、配置渲染、版本发布、数据存储与聚合查询 |
|
||||
| Agent | 注册、心跳、同步、写入文件、校验、reload、失败回滚、自更新与轻量采集 |
|
||||
| OpenResty | 接收真实流量,按 OpenFlare 渲染的配置执行 WAF、PoW、认证与反向代理 |
|
||||
| Frontend | 管理网站配置、WAF、源站、证书、节点、版本、用户、设置与观测页面 |
|
||||
| 组件 | 职责 |
|
||||
| --------------- | ---------------------------------------------------------------------- |
|
||||
| Server | 管理端 UI、管理 API、Agent/Relay/Client API、配置渲染、版本发布、数据存储与聚合查询 |
|
||||
| Agent | 注册、心跳、同步、写入文件、校验、reload、失败回滚、自更新与轻量采集 |
|
||||
| OpenResty | 接收真实流量,按 OpenFlare 渲染的配置执行 WAF、PoW、认证与反向代理 |
|
||||
| OpenFlareRelay | 管理 frps 进程生命周期,提供隧道中继服务,通过心跳接收 frps 配置 |
|
||||
| OpenFlared | 管理 frpc 进程(可多个),连接 Relay 中继,将流量转发到内网服务 |
|
||||
| Frontend | 管理网站配置、WAF、源站、证书、节点、Tunnel、版本、用户、设置与观测页面 |
|
||||
|
||||
## Server
|
||||
|
||||
@@ -97,6 +123,33 @@ Agent 执行 OpenResty 校验与 reload
|
||||
Agent 上报应用结果
|
||||
```
|
||||
|
||||
### Relay 同步流
|
||||
|
||||
Relay(OpenFlareRelay 进程)运行在 TunnelRelay 节点上,与 Agent 共享同一 `agent_token`:
|
||||
|
||||
```text
|
||||
Relay HTTP heartbeat -> Server 返回 frps 基础配置 (bindPort, vhostHTTPPort, auth_token)
|
||||
Relay 生成 frps.toml 并启动或更新 frps 进程
|
||||
Relay 定期上报 frps 健康状态与连接统计
|
||||
Relay 尝试升级 WebSocket 连接以支持实时配置推送
|
||||
```
|
||||
|
||||
frps 配置相对静态(端口、认证 Token),通过心跳下发,**不纳入版本化发布流**。Relay 需要监听 frps 进程异常并自动恢复。认证方式:`X-Agent-Token` + API 路径前缀 `/api/relay/*`,Server 通过 `node_type = tunnel_relay` 区分。
|
||||
|
||||
### OpenFlared 同步流
|
||||
|
||||
OpenFlared(客户端)运行在内网服务器,使用独立的 `tunnel_token` 认证:
|
||||
|
||||
```text
|
||||
Client HTTP heartbeat -> Server 返回 tunnel 配置版本摘要 (version, checksum)
|
||||
Client 发现新版本 -> 拉取完整 tunnel 路由配置 (relay 列表 + frpc proxy 定义)
|
||||
Client 为每个 Relay 生成独立的 frpc.toml 配置文件
|
||||
Client 为新 Relay 启动 frpc 进程,或为已有 Relay 执行热重载 (frpc reload)
|
||||
Client 上报应用结果 (成功/失败原因)
|
||||
```
|
||||
|
||||
OpenFlared 通过 `/api/flared/*` 端点与 Server 通信,认证使用 `X-Tunnel-Token`。Tunnel 路由配置随发布流程版本化同步,所有配置变更通过单一版本号关联并一致性发布到 Agent 和 Client。
|
||||
|
||||
**WebSocket 升级流程**(可选,通过 `AgentWebsocketUpgradeEnabled` 选项控制):
|
||||
|
||||
当启用 WebSocket 升级时:
|
||||
@@ -116,7 +169,9 @@ Client -> OpenResty server block -> WAF Lua -> named upstream -> Origin
|
||||
|
||||
网站配置是反向代理聚合边界。一条网站配置可绑定多个域名,并共享站点级流量限制、反向代理和缓存配置。
|
||||
|
||||
WAF 在 OpenResty `access_by_lua_file` 阶段执行。规则来自当前激活版本携带的 `waf_config.json`,全局规则组默认生效,网站可叠加自定义规则组。
|
||||
WAF 在 OpenResty `access_by_lua_file` 阶段执行。规则来自当前激活版本携带的 `waf_config.json`,全局规则组默认生效,网站可叠加自定义规则组。`waf_config.json` 只保存规则组直接 IP 和 IP 组引用 ID;IP 组成员由 Agent 独立同步到本地 `waf_ip_groups.json`,OpenResty Lua 按引用 ID 合并判断。
|
||||
|
||||
WAF IP 组由 Server 管理。手动 IP 组直接保存 IP/IP 段列表;自动 IP 组由 Server 定时任务读取请求日志、按单个 IP 聚合指标并执行 Expr 规则;订阅 IP 组由 Server 定时任务同步远程文本或 JSON 源。Agent 心跳会上报本地 IP 组 checksum,Server 只返回不一致的 IP 组;Server 侧 IP 组更新时会通过 Agent WebSocket 广播变更组。OpenResty Lua 只读取 Agent 落地的运行时 JSON,不直接访问 Server 数据库、请求日志或远程订阅源。
|
||||
|
||||
## 核心对象
|
||||
|
||||
@@ -126,6 +181,7 @@ WAF 在 OpenResty `access_by_lua_file` 阶段执行。规则来自当前激活
|
||||
* `origins`
|
||||
* `config_versions`
|
||||
* `nodes`
|
||||
* `tunnels`
|
||||
* `auth_sources`
|
||||
* `external_accounts`
|
||||
* `node_system_profiles`
|
||||
@@ -138,6 +194,7 @@ WAF 在 OpenResty `access_by_lua_file` 阶段执行。规则来自当前激活
|
||||
* `traffic_analytics_rollups`
|
||||
* `node_health_events`
|
||||
* `waf_rule_groups`
|
||||
* `waf_ip_groups`
|
||||
* `waf_rule_group_bindings`
|
||||
* `acme_accounts`
|
||||
* `dns_accounts`
|
||||
@@ -152,6 +209,9 @@ WAF 在 OpenResty `access_by_lua_file` 阶段执行。规则来自当前激活
|
||||
| 全局单激活版本 | 降低 MVP 复杂度,保证所有节点默认一致;支持版本预览、历史查询与一键回滚 |
|
||||
| 网站配置聚合多域名 | 支持一个业务站点共享站点级策略,同时允许按域名绑定证书 |
|
||||
| 观测数据服务端聚合 | 避免前端临时统计造成口径不一致 |
|
||||
| 内网穿透基于 frp 整合 | 复用成熟隧道协议,避免自研隧道的稳定性风险;frps HTTP Vhost 路由天然适配 |
|
||||
| Relay/Client 独立二进制 | 职责分离,Relay 管理 frps,Client 管理 frpc,各自独立升级和部署 |
|
||||
| Tunnel 与 Node 体系分离 | Tunnel 客户端在内网运行,与公网节点概念不同,使用独立的注册和认证体系 |
|
||||
|
||||
## 贡献者阅读建议
|
||||
|
||||
|
||||
+93
-3
@@ -26,17 +26,20 @@ OpenFlare 当前不定位为通用日志平台、服务网格、Kubernetes Ingre
|
||||
| Agent 同步 | 支持注册、心跳、同步、应用结果上报与自更新 |
|
||||
| OpenResty 托管 | 管理主配置模板、性能参数、缓存参数与 Lua 资源 |
|
||||
| HTTPS/TLS | 托管证书与域名资产,并按域名绑定证书 |
|
||||
| WAF | 以全局规则组与网站自定义规则组维护 IP/IP 段、国家级地域黑白名单 |
|
||||
| WAF | 以全局规则组与网站自定义规则组维护 IP/IP 段、IP 组、国家级地域黑白名单 |
|
||||
| 基础观测 | 聚合节点请求、资源快照、健康事件和访问分析 |
|
||||
| 节点管理 | 节点状态、令牌体系、部署与更新链路 |
|
||||
| 管理端前端 | 基于 Next.js 的正式管理端 |
|
||||
| 认证源登录 | 支持以认证源形式配置 GitHub 与标准 OIDC 登录入口,并允许第三方账号绑定已有本地用户 |
|
||||
| 内网穿透 | 通过 TunnelRelay 节点与 OpenFlared 客户端,将内网 HTTP 服务安全暴露到公网,复用 Agent 的 HTTPS/WAF 能力 |
|
||||
|
||||
默认工作方式:
|
||||
|
||||
* 所有节点消费同一份全局激活版本。
|
||||
* Server 保存配置与状态,不直接 SSH 管理节点。
|
||||
* Agent 是节点侧唯一受控落地入口。
|
||||
* TunnelRelay 节点同时运行 Agent(OpenResty)和 Relay(frps),提供内网穿透中继。
|
||||
* OpenFlared 客户端在内网运行,管理 frpc 进程连接 Relay,将流量转发到内网服务。
|
||||
|
||||
## 典型使用场景
|
||||
|
||||
@@ -48,6 +51,7 @@ OpenFlare 当前不定位为通用日志平台、服务网格、Kubernetes Ingre
|
||||
| 快速回滚 | 重新激活旧版本,让 Agent 拉取并应用 |
|
||||
| 证书托管 | 为不同域名绑定 TLS 证书 |
|
||||
| 基础观测 | 查看节点状态、请求聚合、访问分析和健康事件 |
|
||||
| 内网穿透 | 通过 Tunnel 将无法直接公网访问的内网 HTTP 服务暴露到互联网,享有 HTTPS、WAF 等全部防护能力 |
|
||||
|
||||
|
||||
## 网站配置约束
|
||||
@@ -71,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 约束
|
||||
|
||||
@@ -96,9 +172,23 @@ WAF 以规则组为配置边界。系统固定一个全局规则组,默认应
|
||||
一期支持:
|
||||
|
||||
* IP / IP 段白名单与黑名单。
|
||||
* IP 组引用,支持手动、自动、订阅三类 IP 组。
|
||||
* 国家级地域白名单与黑名单。
|
||||
* 规则组级拦截状态码与响应页面,默认 `418` 与空页面。
|
||||
|
||||
IP 组约束:
|
||||
|
||||
* 手动 IP 组由管理端直接维护 IP/IP 段列表。
|
||||
* 自动 IP 组使用 Expr 语法保存自定义规则,由 Server 定时按单个 IP 聚合请求日志并更新 IP 列表。
|
||||
* 订阅 IP 组由 Server 定时从 HTTP/HTTPS URL 同步,支持文本列表和 JSON 映射。
|
||||
* WAF 运行时不访问数据库;发布版本只保存规则组引用的 IP 组 ID,不把 IP 组成员展开进版本快照。
|
||||
* Agent 通过心跳上报本地 IP 组 checksum,Server 仅返回 checksum 不一致的 IP 组;Server 侧 IP 组更新时会通过 Agent WebSocket 主动广播变更组,使节点可在不重新发布配置版本的情况下更新 WAF IP 组内容。
|
||||
|
||||
自动 IP 组首批内置预设规则:
|
||||
|
||||
* 单个 IP 请求数大于 100,且 404 状态码占比不低于 80%:`request_count > 100 && status_404_ratio >= 0.8`
|
||||
* 单个 IP 通过 IP 地址访问次数大于 50,且通过 IP 地址访问占比大于 50%:`ip_host_count > 50 && ip_host_ratio > 0.5`
|
||||
|
||||
判定顺序:
|
||||
|
||||
* 白名单是放行例外,任意启用规则组命中白名单即放行。
|
||||
|
||||
@@ -17,8 +17,8 @@ Server 发布时必须:
|
||||
1. 读取全部启用的 `proxy_routes`。
|
||||
2. 读取 Server 侧 OpenResty 主配置、性能参数、缓存参数和必要 Lua 资源。
|
||||
3. 读取域名与证书绑定关系。
|
||||
4. 读取 WAF 全局规则组、自定义规则组与网站绑定关系。
|
||||
5. 渲染完整 OpenResty 配置与 WAF 运行时配置。
|
||||
4. 读取 WAF 全局规则组、自定义规则组、IP 组引用与网站绑定关系。
|
||||
5. 保留 WAF 规则组引用的 IP 组 ID,渲染完整 OpenResty 配置与 WAF 运行时配置;IP 组成员不进入发布版本。
|
||||
6. 计算 `checksum`。
|
||||
7. 写入 `config_versions`。
|
||||
8. 切换激活版本。
|
||||
@@ -70,4 +70,10 @@ Agent 发现新版本后会:
|
||||
* Agent API 固定使用节点专属 `agent_token`,首次接入可使用 `discovery_token`。
|
||||
* Server 不提供远程 shell 或任意命令执行入口。
|
||||
* 配置版本必须保存完整快照、渲染结果和 `checksum`。
|
||||
* WAF 规则组和网站绑定关系必须随完整配置版本进入快照与 checksum,回滚时不得依赖当前可变 WAF 配置。
|
||||
* WAF 规则组、IP 组引用 ID 和网站绑定关系必须随完整配置版本进入快照与 checksum;IP 组成员由 Agent 独立按 checksum 差异同步,不受版本回滚影响。
|
||||
|
||||
## WAF IP 组运行时同步
|
||||
|
||||
WAF IP 组成员不纳入配置版本。发布版本只包含规则组直接 IP 与 `ip_whitelist_group_ids` / `ip_blacklist_group_ids`。Agent 应用版本后会从渲染出的 `waf_config.json` 中提取引用 ID,并向 Server 请求缺失或 checksum 不一致的 IP 组数据。
|
||||
|
||||
Agent 后续心跳会携带本地 IP 组 checksum。Server 根据当前激活版本引用的 IP 组 ID 对比 checksum,只返回差异组,避免每次心跳传输全部 IP 组。Server 在手动更新、订阅同步或自动规则执行后,会通过 Agent WebSocket 广播发生变化的 IP 组;WS 不可用时,下一次 HTTP heartbeat 仍会按 checksum 差异补齐。
|
||||
|
||||
@@ -23,7 +23,6 @@ The Agent supports:
|
||||
| Component | Default Location | Description |
|
||||
| --- | --- | --- |
|
||||
| Server SQLite | `openflare.db` | Can be modified via `SQLITE_PATH` |
|
||||
| Server Uploads Directory | `upload` | Can be modified via `UPLOAD_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` |
|
||||
@@ -54,7 +53,6 @@ go run . --port 3000 --log-dir ./logs
|
||||
| `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 |
|
||||
|
||||
Description:
|
||||
|
||||
+5
-3
@@ -10,9 +10,10 @@ OpenFlare 是一套自托管的 OpenResty 控制面。它把反向代理网站
|
||||
|
||||
1. [快速开始](./quick-start.md):用 Docker Compose 启动 Server,登录管理端,并接入第一个 Agent。
|
||||
2. [基础使用](./usage.md):了解网站配置、源站、证书、发布、回滚和观测的常见操作。
|
||||
3. [部署说明](../reference/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,6 +21,7 @@ OpenFlare 是一套自托管的 OpenResty 控制面。它把反向代理网站
|
||||
| --- | --- |
|
||||
| 5 分钟内跑起管理端 | [快速开始](./quick-start.md) |
|
||||
| 发布第一条反向代理配置 | [发布第一份配置](./first-site.md) |
|
||||
| 编写自动 IP 组规则 | [WAF 自动 IP 组语法](./waf-ip-group-expr.md) |
|
||||
| 接入或重装节点 Agent | [接入 Agent](../reference/agent.md) |
|
||||
| 从源码启动 Server | [启动 Server](../reference/server.md) |
|
||||
| 配置 GitHub 或 OIDC 登录 | [SSO 登录配置](./sso.md) |
|
||||
|
||||
+4
-1
@@ -81,10 +81,13 @@ HTTPS 按域名绑定证书,而不是按整个网站统一强制启用。
|
||||
安全防护统一从管理端侧边栏的 **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。
|
||||
WAF 规则组、网站绑定或 PoW 配置修改后,需要重新发布并激活配置版本,Agent 才会拉取并应用到 OpenResty。IP 组成员变化不需要重新发布版本;在线 Agent 会通过 WebSocket 增量更新,离线或未升级 WS 的 Agent 会在下一次心跳中按 checksum 差异补齐。
|
||||
|
||||
## 发布、激活与回滚
|
||||
|
||||
|
||||
@@ -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 组。
|
||||
@@ -83,22 +83,50 @@ Frontend:
|
||||
### 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_rule_group_bindings` (网站WAF绑定).
|
||||
* **安全与 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` 用于调试和可观测性。
|
||||
|
||||
## 数据库迁移
|
||||
|
||||
任何涉及表结构、索引、列类型、分表规则或内部持久化元数据的修改,都必须同步提升数据库版本号。
|
||||
@@ -128,7 +156,7 @@ v1-v7 视为历史初始基线,不再维护逐版本升级文件。从 v8 起
|
||||
|
||||
## API 与鉴权
|
||||
|
||||
管理端与 Agent API 统一使用 JSON。成功与失败都必须返回清晰 `message`:
|
||||
管理端与 Agent/Relay/Client API 统一使用 JSON。成功与失败都必须返回清晰 `message`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -140,16 +168,29 @@ v1-v7 视为历史初始基线,不再维护逐版本升级文件。从 v8 起
|
||||
|
||||
约定:
|
||||
|
||||
* Agent API 固定放在 `/api/agent/*`。
|
||||
* 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 正式请求统一使用节点专属 `agent_token`。
|
||||
* 首次接入可使用全局 `discovery_token`。
|
||||
* Agent 请求头统一使用 `X-Agent-Token`。
|
||||
* 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,禁止绕过占位符约束保存不可渲染的主配置模板。
|
||||
|
||||
@@ -159,14 +200,21 @@ v1-v7 视为历史初始基线,不再维护逐版本升级文件。从 v8 起
|
||||
|
||||
* 发布时读取全部启用的 `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`。
|
||||
* 写入 `config_versions`(OpenResty 部分)+ 生成或更新 tunnel 配置版本数据。
|
||||
* 通过切换 `is_active` 激活版本。
|
||||
|
||||
版本约束:
|
||||
|
||||
* 版本号格式固定为 `YYYYMMDD-NNN`。
|
||||
* 同一版本号同时关联 OpenResty 配置与 Tunnel 配置,保证一致性。
|
||||
* 不在线修改历史版本。
|
||||
* 不做按节点分组的差异化版本。
|
||||
* 预览与 diff 是只读能力,不产生发布记录。
|
||||
@@ -180,6 +228,7 @@ Agent 必须满足:
|
||||
* 发现新版本时先备份旧文件。
|
||||
* 写入主配置、路由配置与必要证书文件。
|
||||
* 写入 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。
|
||||
@@ -188,6 +237,25 @@ Agent 必须满足:
|
||||
* 某个目标 `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/`:
|
||||
|
||||
@@ -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 | 空 |
|
||||
|
||||
说明:
|
||||
|
||||
@@ -15,7 +15,7 @@ COPY openflare_server ./openflare_server
|
||||
COPY openflare_agent ./openflare_agent
|
||||
WORKDIR /build/openflare_agent
|
||||
RUN go mod download
|
||||
RUN go build -trimpath -ldflags "-s -w -X 'openflare-agent/internal/config.AgentVersion=$VERSION'" -o /build/openflare-agent ./cmd/agent
|
||||
RUN go build -trimpath -ldflags "-s -w -X 'openflare-agent/internal/config.Version=$VERSION'" -o /build/openflare-agent ./cmd/agent
|
||||
|
||||
FROM openresty/openresty:alpine
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ func main() {
|
||||
slog.Error("load agent config failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
cfg.NginxVersion = nginx.DetectVersion(
|
||||
cfg.ExtVersion = nginx.DetectVersion(
|
||||
context.Background(),
|
||||
nginx.ExecutorOptions{
|
||||
NginxPath: cfg.OpenrestyPath,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,12 +40,12 @@ 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"`
|
||||
DataDir string `json:"data_dir"`
|
||||
@@ -71,7 +71,7 @@ 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"`
|
||||
@@ -113,7 +113,7 @@ 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,
|
||||
@@ -149,7 +149,7 @@ func Load(path string) (*Config, error) {
|
||||
|
||||
func applyDefaults(cfg *Config, baseDir string) {
|
||||
baseDir = filepath.Clean(baseDir)
|
||||
cfg.AgentVersion = AgentVersion
|
||||
cfg.Version = Version
|
||||
cfg.OpenrestyResolvers = utils.UniqueAndCleanStringSlice(cfg.OpenrestyResolvers)
|
||||
if cfg.OpenrestyPath == "" {
|
||||
cfg.OpenrestyPath = "openresty"
|
||||
@@ -275,7 +275,7 @@ func applyEnvOverrides(cfg *Config) {
|
||||
}
|
||||
}
|
||||
overrideString("OPENFLARE_SERVER_URL", &cfg.ServerURL)
|
||||
overrideString("OPENFLARE_AGENT_TOKEN", &cfg.AgentToken)
|
||||
overrideString("OPENFLARE_AGENT_TOKEN", &cfg.AccessToken)
|
||||
overrideString("OPENFLARE_DISCOVERY_TOKEN", &cfg.DiscoveryToken)
|
||||
overrideString("OPENFLARE_NODE_NAME", &cfg.NodeName)
|
||||
overrideString("OPENFLARE_NODE_IP", &cfg.NodeIP)
|
||||
@@ -336,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 == "" {
|
||||
@@ -361,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)
|
||||
|
||||
@@ -226,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" {
|
||||
@@ -334,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)
|
||||
@@ -385,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"}
|
||||
@@ -461,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"
|
||||
|
||||
@@ -54,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
|
||||
}
|
||||
|
||||
@@ -74,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")
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
@@ -27,6 +28,7 @@ import (
|
||||
|
||||
const RuntimeConfigDirPlaceholder = "__OPENFLARE_RUNTIME_CONFIG_DIR__"
|
||||
const ResolverDirectivePlaceholder = "__OPENFLARE_RESOLVER_DIRECTIVE__"
|
||||
const WAFIPGroupsConfigFileName = "waf_ip_groups.json"
|
||||
|
||||
type Executor interface {
|
||||
Test(ctx context.Context) error
|
||||
@@ -190,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()
|
||||
@@ -425,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
|
||||
@@ -464,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")
|
||||
}
|
||||
@@ -473,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 ""
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -915,6 +915,37 @@ 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" {
|
||||
t.Fatalf("unexpected default observability listen address: %s", got)
|
||||
|
||||
@@ -49,6 +49,36 @@ local function load_config()
|
||||
return nil
|
||||
end
|
||||
|
||||
local function load_ip_groups()
|
||||
local paths = {
|
||||
"__OPENFLARE_RUNTIME_CONFIG_DIR__/waf_ip_groups.json",
|
||||
"/etc/nginx/openflare-lua/waf_ip_groups.json",
|
||||
"/usr/local/openresty/nginx/conf/waf_ip_groups.json"
|
||||
}
|
||||
for _, path in ipairs(paths) do
|
||||
local content = read_file(path)
|
||||
if content and content ~= "" then
|
||||
local hash = ngx.md5(content)
|
||||
if config_dict:get("_ip_groups_hash") == hash then
|
||||
local cached = config_dict:get("_ip_groups_json")
|
||||
if cached then
|
||||
local decoded = cjson.decode(cached)
|
||||
if decoded then
|
||||
return decoded
|
||||
end
|
||||
end
|
||||
end
|
||||
local decoded = cjson.decode(content)
|
||||
if decoded then
|
||||
config_dict:set("_ip_groups_hash", hash, 0)
|
||||
config_dict:set("_ip_groups_json", content, 0)
|
||||
return decoded
|
||||
end
|
||||
end
|
||||
end
|
||||
return { groups = {} }
|
||||
end
|
||||
|
||||
local function list_contains(items, value)
|
||||
if not items or type(items) ~= "table" or not value or value == "" then
|
||||
return false
|
||||
@@ -109,6 +139,20 @@ local function ip_matches(items, ip)
|
||||
return false
|
||||
end
|
||||
|
||||
local function ip_matches_group_ids(group_ids, ip, ip_groups_config)
|
||||
if not group_ids or type(group_ids) ~= "table" or not ip or ip == "" then
|
||||
return false
|
||||
end
|
||||
local groups = (ip_groups_config or {}).groups or {}
|
||||
for _, id in ipairs(group_ids) do
|
||||
local group = groups[tostring(id)]
|
||||
if group and group.enabled and ip_matches(group.ip_list, ip) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function lookup_country(ip)
|
||||
local ok, maxminddb = pcall(require, "resty.maxminddb")
|
||||
if not ok or not maxminddb then
|
||||
@@ -181,6 +225,7 @@ end
|
||||
|
||||
local ip = ngx.var.remote_addr or ""
|
||||
local groups = active_groups(config)
|
||||
local ip_groups_config = load_ip_groups()
|
||||
if #groups == 0 then
|
||||
if config_dict:add("_empty_groups_logged", true, 60) then
|
||||
ngx.log(ngx.WARN, "openflare waf has no active rule group for site: ", ngx.var.openflare_waf_site or "")
|
||||
@@ -189,7 +234,7 @@ if #groups == 0 then
|
||||
end
|
||||
|
||||
for _, group in ipairs(groups) do
|
||||
if ip_matches(group.ip_whitelist, ip) then
|
||||
if ip_matches(group.ip_whitelist, ip) or ip_matches_group_ids(group.ip_whitelist_group_ids, ip, ip_groups_config) then
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -205,7 +250,7 @@ for _, group in ipairs(groups) do
|
||||
end
|
||||
|
||||
for _, group in ipairs(groups) do
|
||||
if ip_matches(group.ip_blacklist, ip) then
|
||||
if ip_matches(group.ip_blacklist, ip) or ip_matches_group_ids(group.ip_blacklist_group_ids, ip, ip_groups_config) then
|
||||
return exit_with_group(group)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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 {
|
||||
@@ -177,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +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"
|
||||
@@ -24,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 {
|
||||
@@ -31,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 {
|
||||
@@ -162,6 +167,20 @@ func (s *Service) ForceSyncOnce(ctx context.Context, target *protocol.ActiveConf
|
||||
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 && !startup {
|
||||
slog.Debug("local openresty config already up to date", "mode", mode, "version", config.Version)
|
||||
@@ -273,10 +292,36 @@ 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
|
||||
@@ -326,6 +371,48 @@ func fromOpenRestySupportFiles(files []openrestyrender.SupportFile) []protocol.S
|
||||
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
|
||||
|
||||
@@ -72,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)
|
||||
@@ -96,6 +100,14 @@ 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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"openflare-relay/internal/config"
|
||||
"openflare-relay/internal/frps"
|
||||
"openflare-relay/internal/heartbeat"
|
||||
"openflare-relay/internal/httpclient"
|
||||
"openflare-relay/internal/relay"
|
||||
"openflare-relay/internal/state"
|
||||
"openflare-relay/internal/wsclient"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Setup simple structured logging
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelDebug,
|
||||
})))
|
||||
|
||||
configPath := flag.String("config", "./relay.json", "relay config path")
|
||||
flag.Parse()
|
||||
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
slog.Error("load relay config failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
slog.Info("relay config loaded",
|
||||
"server", cfg.ServerURL,
|
||||
"node", cfg.NodeName,
|
||||
"ip", cfg.NodeIP,
|
||||
"frps_path", cfg.FrpsPath,
|
||||
"data_dir", cfg.DataDir,
|
||||
"heartbeat_interval", cfg.HeartbeatInterval,
|
||||
)
|
||||
|
||||
stateStore := state.NewStore(cfg.StatePath)
|
||||
_ = stateStore // In the future we may use stateStore for auth caching
|
||||
|
||||
frpsManager := frps.NewManager(cfg.FrpsPath, cfg.DataDir)
|
||||
|
||||
slog.Info("detected frps version", "version", frpsManager.GetVersion())
|
||||
|
||||
httpClient := httpclient.New(cfg.ServerURL, cfg.InitialAuthToken(), cfg.RequestTimeout.Duration())
|
||||
wsClient := wsclient.New(cfg.ServerURL, cfg.InitialAuthToken(), cfg.RequestTimeout.Duration())
|
||||
|
||||
runner := &relay.Runner{
|
||||
Config: cfg,
|
||||
StateStore: stateStore,
|
||||
FrpsManager: frpsManager,
|
||||
HttpClient: httpClient,
|
||||
WebSocketService: wsClient,
|
||||
HeartbeatService: heartbeat.New(httpClient, frpsManager, cfg, stateStore),
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
slog.Info("relay process started")
|
||||
|
||||
if err := runner.Run(ctx); err != nil && err != context.Canceled {
|
||||
slog.Error("relay process exited with error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info("relay process stopped")
|
||||
}
|
||||
@@ -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,211 @@
|
||||
package frps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"openflare/service"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
frpsPath string
|
||||
dataDir string
|
||||
configPath string
|
||||
|
||||
mu sync.RWMutex
|
||||
activeConfig *service.RelayConfig
|
||||
cmd *exec.Cmd
|
||||
status string
|
||||
lastError string
|
||||
generation uint64
|
||||
stopping bool
|
||||
}
|
||||
|
||||
type RuntimeStatus struct {
|
||||
Status string
|
||||
LastError string
|
||||
Connections int
|
||||
ProxyCount int
|
||||
ProcessAlive bool
|
||||
}
|
||||
|
||||
func NewManager(frpsPath string, dataDir string) *Manager {
|
||||
return &Manager{
|
||||
frpsPath: frpsPath,
|
||||
dataDir: dataDir,
|
||||
configPath: filepath.Join(dataDir, "frps.toml"),
|
||||
status: "unhealthy",
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) GetVersion() string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, m.frpsPath, "-v")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Error("failed to get frps version", "error", err)
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func (m *Manager) GetStatus() string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.status
|
||||
}
|
||||
|
||||
func (m *Manager) GetRuntimeStatus() RuntimeStatus {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return RuntimeStatus{
|
||||
Status: m.status,
|
||||
LastError: m.lastError,
|
||||
Connections: 0,
|
||||
ProxyCount: 0,
|
||||
ProcessAlive: m.cmd != nil && m.cmd.Process != nil,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) UpdateConfig(cfg *service.RelayConfig) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Check if config changed
|
||||
if m.activeConfig != nil &&
|
||||
m.activeConfig.BindPort == cfg.BindPort &&
|
||||
m.activeConfig.VhostHTTPPort == cfg.VhostHTTPPort &&
|
||||
m.activeConfig.AuthToken == cfg.AuthToken {
|
||||
if m.cmd == nil && !m.stopping {
|
||||
slog.Warn("frps config unchanged but process is not running, restarting")
|
||||
if err := m.restartProcess(); err != nil {
|
||||
m.status = "unhealthy"
|
||||
m.lastError = err.Error()
|
||||
slog.Error("failed to restart frps with unchanged config", "error", err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
m.activeConfig = cfg
|
||||
m.stopping = false
|
||||
slog.Info("relay config updated, reloading frps")
|
||||
|
||||
if err := m.renderConfig(cfg); err != nil {
|
||||
slog.Error("failed to render frps config", "error", err)
|
||||
m.status = "unhealthy"
|
||||
m.lastError = err.Error()
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.restartProcess(); err != nil {
|
||||
slog.Error("failed to restart frps", "error", err)
|
||||
m.status = "unhealthy"
|
||||
m.lastError = err.Error()
|
||||
} else {
|
||||
m.status = "healthy"
|
||||
m.lastError = ""
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) renderConfig(cfg *service.RelayConfig) error {
|
||||
if err := os.MkdirAll(m.dataDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString(fmt.Sprintf("bindPort = %d\n", cfg.BindPort))
|
||||
if cfg.VhostHTTPPort > 0 {
|
||||
buf.WriteString(fmt.Sprintf("vhostHTTPPort = %d\n", cfg.VhostHTTPPort))
|
||||
}
|
||||
if cfg.AuthToken != "" {
|
||||
buf.WriteString("[auth]\n")
|
||||
buf.WriteString("method = \"token\"\n")
|
||||
buf.WriteString(fmt.Sprintf("token = \"%s\"\n", cfg.AuthToken))
|
||||
}
|
||||
|
||||
return os.WriteFile(m.configPath, buf.Bytes(), 0644)
|
||||
}
|
||||
|
||||
func (m *Manager) restartProcess() error {
|
||||
m.generation++
|
||||
generation := m.generation
|
||||
if m.cmd != nil && m.cmd.Process != nil {
|
||||
slog.Debug("stopping existing frps process")
|
||||
_ = m.cmd.Process.Kill()
|
||||
m.cmd = nil
|
||||
}
|
||||
return m.startProcessLocked(generation)
|
||||
}
|
||||
|
||||
func (m *Manager) startProcessLocked(generation uint64) error {
|
||||
cmd := exec.Command(m.frpsPath, "-c", m.configPath)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.cmd = cmd
|
||||
m.status = "healthy"
|
||||
m.lastError = ""
|
||||
|
||||
go func(c *exec.Cmd) {
|
||||
err := c.Wait()
|
||||
slog.Warn("frps process exited", "error", err)
|
||||
m.mu.Lock()
|
||||
if m.cmd == c {
|
||||
m.cmd = nil
|
||||
m.status = "unhealthy"
|
||||
if err != nil {
|
||||
m.lastError = err.Error()
|
||||
} else {
|
||||
m.lastError = "frps process exited"
|
||||
}
|
||||
}
|
||||
shouldRestart := !m.stopping && m.generation == generation
|
||||
m.mu.Unlock()
|
||||
if !shouldRestart {
|
||||
return
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.stopping || m.generation != generation {
|
||||
return
|
||||
}
|
||||
slog.Warn("restarting frps after unexpected exit")
|
||||
if err := m.startProcessLocked(generation); err != nil {
|
||||
m.status = "unhealthy"
|
||||
m.lastError = err.Error()
|
||||
slog.Error("failed to auto restart frps", "error", err)
|
||||
}
|
||||
}(cmd)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Stop() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.stopping = true
|
||||
m.generation++
|
||||
if m.cmd != nil && m.cmd.Process != nil {
|
||||
_ = m.cmd.Process.Kill()
|
||||
m.cmd = nil
|
||||
}
|
||||
m.status = "unhealthy"
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package heartbeat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"openflare-relay/internal/config"
|
||||
"openflare-relay/internal/frps"
|
||||
"openflare-relay/internal/httpclient"
|
||||
"openflare-relay/internal/observability"
|
||||
"openflare-relay/internal/state"
|
||||
"openflare/service"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
client *httpclient.Client
|
||||
frpsManager *frps.Manager
|
||||
config *config.Config
|
||||
stateStore *state.Store
|
||||
}
|
||||
|
||||
func New(client *httpclient.Client, manager *frps.Manager, cfg *config.Config, stateStore *state.Store) *Service {
|
||||
return &Service{
|
||||
client: client,
|
||||
frpsManager: manager,
|
||||
config: cfg,
|
||||
stateStore: stateStore,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Run(ctx context.Context) {
|
||||
ticker := time.NewTicker(s.config.HeartbeatInterval.Duration())
|
||||
defer ticker.Stop()
|
||||
|
||||
// initial heartbeat
|
||||
s.doHeartbeat(ctx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.doHeartbeat(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) doHeartbeat(ctx context.Context) {
|
||||
slog.Debug("sending heartbeat")
|
||||
|
||||
runtimeStatus := s.frpsManager.GetRuntimeStatus()
|
||||
payload := service.RelayHeartbeatPayload{
|
||||
Version: config.Version,
|
||||
ExtVersion: s.frpsManager.GetVersion(),
|
||||
RelayStatus: runtimeStatus.Status,
|
||||
FrpsConnCount: runtimeStatus.Connections,
|
||||
FrpsProxyCount: runtimeStatus.ProxyCount,
|
||||
Name: s.config.NodeName,
|
||||
IP: s.config.NodeIP,
|
||||
Profile: observability.BuildProfile(s.config, s.stateStore),
|
||||
Snapshot: observability.BuildSnapshot(s.config, s.stateStore),
|
||||
HealthEvents: observability.BuildHealthEvents(runtimeStatus),
|
||||
}
|
||||
|
||||
resp, err := s.client.Heartbeat(ctx, payload)
|
||||
if err != nil {
|
||||
slog.Error("heartbeat failed", "error", err)
|
||||
return
|
||||
}
|
||||
slog.Debug("heartbeat succeeded")
|
||||
|
||||
// Update configs if changed
|
||||
s.frpsManager.UpdateConfig(resp.RelayConfig)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"openflare/service"
|
||||
)
|
||||
|
||||
type APIResponse[T any] struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data T `json:"data"`
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func New(baseURL string, token string, timeout time.Duration) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
token: token,
|
||||
httpClient: &http.Client{
|
||||
Timeout: timeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Heartbeat(ctx context.Context, payload service.RelayHeartbeatPayload) (*service.RelayHeartbeatResponse, error) {
|
||||
resp := APIResponse[service.RelayHeartbeatResponse]{}
|
||||
if err := c.postJSON(ctx, "/api/relay/heartbeat", payload, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !resp.Success {
|
||||
return nil, errors.New(resp.Message)
|
||||
}
|
||||
return &resp.Data, nil
|
||||
}
|
||||
|
||||
func (c *Client) SetToken(token string) {
|
||||
c.token = strings.TrimSpace(token)
|
||||
slog.Debug("http client token updated")
|
||||
}
|
||||
|
||||
func (c *Client) getJSON(ctx context.Context, path string, target any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("X-Agent-Token", c.token)
|
||||
return c.do(req, target)
|
||||
}
|
||||
|
||||
func (c *Client) postJSON(ctx context.Context, path string, body any, target any) error {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Agent-Token", c.token)
|
||||
return c.do(req, target)
|
||||
}
|
||||
|
||||
func (c *Client) do(req *http.Request, target any) error {
|
||||
res, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
slog.Error("http request failed", "method", req.Method, "path", req.URL.Path, "error", err)
|
||||
return err
|
||||
}
|
||||
defer func(Body io.ReadCloser) {
|
||||
err := Body.Close()
|
||||
if err != nil {
|
||||
slog.Error("failed to close response body", "error", err)
|
||||
}
|
||||
}(res.Body)
|
||||
if res.StatusCode != http.StatusOK {
|
||||
slog.Warn("http request returned non-200", "method", req.Method, "path", req.URL.Path, "status", res.Status)
|
||||
return errors.New(res.Status)
|
||||
}
|
||||
if target == nil {
|
||||
var wrapper APIResponse[json.RawMessage]
|
||||
if err = json.NewDecoder(res.Body).Decode(&wrapper); err != nil {
|
||||
slog.Error("http response decode failed", "method", req.Method, "path", req.URL.Path, "error", err)
|
||||
return err
|
||||
}
|
||||
if !wrapper.Success {
|
||||
slog.Warn("http api response failed", "method", req.Method, "path", req.URL.Path, "message", wrapper.Message)
|
||||
return errors.New(wrapper.Message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err = json.NewDecoder(res.Body).Decode(target); err != nil {
|
||||
slog.Error("http response decode failed", "method", req.Method, "path", req.URL.Path, "error", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"openflare-relay/internal/config"
|
||||
"openflare-relay/internal/frps"
|
||||
"openflare-relay/internal/state"
|
||||
"openflare/service"
|
||||
)
|
||||
|
||||
func BuildProfile(cfg *config.Config, stateStore *state.Store) *service.AgentNodeSystemProfile {
|
||||
profile := collectProfile(cfg)
|
||||
if profile == nil || stateStore == nil {
|
||||
return profile
|
||||
}
|
||||
fingerprint := fingerprintProfile(profile)
|
||||
snapshot, err := stateStore.Load()
|
||||
if err != nil {
|
||||
return profile
|
||||
}
|
||||
if snapshot.LastProfileFingerprint == fingerprint {
|
||||
return nil
|
||||
}
|
||||
snapshot.LastProfileFingerprint = fingerprint
|
||||
if err = stateStore.Save(snapshot); err != nil {
|
||||
return profile
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
func BuildSnapshot(cfg *config.Config, stateStore *state.Store) *service.AgentNodeMetricSnapshot {
|
||||
now := time.Now().UTC()
|
||||
metric := &service.AgentNodeMetricSnapshot{CapturedAtUnix: now.Unix()}
|
||||
|
||||
metric.MemoryTotalBytes, metric.MemoryUsedBytes = readMemInfo()
|
||||
metric.StorageTotalBytes, metric.StorageUsedBytes = statFilesystem(cfg.DataDir)
|
||||
metric.NetworkRxBytes, metric.NetworkTxBytes = readLinuxNetworkTotals()
|
||||
metric.DiskReadBytes, metric.DiskWriteBytes = readLinuxDiskTotals()
|
||||
|
||||
if stateStore == nil {
|
||||
return metric
|
||||
}
|
||||
totalCPU, idleCPU := readLinuxCPUStat()
|
||||
snapshot, err := stateStore.Load()
|
||||
if err != nil {
|
||||
return metric
|
||||
}
|
||||
if snapshot.LastCPUStatTotal > 0 && totalCPU > snapshot.LastCPUStatTotal && idleCPU >= snapshot.LastCPUStatIdle {
|
||||
deltaTotal := totalCPU - snapshot.LastCPUStatTotal
|
||||
deltaIdle := idleCPU - snapshot.LastCPUStatIdle
|
||||
if deltaTotal > 0 && deltaIdle <= deltaTotal {
|
||||
metric.CPUUsagePercent = float64(deltaTotal-deltaIdle) / float64(deltaTotal) * 100
|
||||
}
|
||||
}
|
||||
snapshot.LastCPUStatTotal = totalCPU
|
||||
snapshot.LastCPUStatIdle = idleCPU
|
||||
snapshot.LastMetricAtUnix = now.Unix()
|
||||
_ = stateStore.Save(snapshot)
|
||||
return metric
|
||||
}
|
||||
|
||||
func BuildHealthEvents(status frps.RuntimeStatus) []service.AgentNodeHealthEvent {
|
||||
if strings.TrimSpace(status.Status) == "healthy" {
|
||||
return []service.AgentNodeHealthEvent{}
|
||||
}
|
||||
message := strings.TrimSpace(status.LastError)
|
||||
if message == "" {
|
||||
message = "frps runtime is not healthy"
|
||||
}
|
||||
return []service.AgentNodeHealthEvent{{
|
||||
EventType: "frps_unhealthy",
|
||||
Severity: "critical",
|
||||
Message: message,
|
||||
TriggeredAtUnix: time.Now().UTC().Unix(),
|
||||
}}
|
||||
}
|
||||
|
||||
func collectProfile(cfg *config.Config) *service.AgentNodeSystemProfile {
|
||||
hostname, _ := os.Hostname()
|
||||
osName, osVersion := readLinuxOSRelease()
|
||||
totalMemory, _ := readMemInfo()
|
||||
totalDisk, _ := statFilesystem(cfg.DataDir)
|
||||
return &service.AgentNodeSystemProfile{
|
||||
Hostname: strings.TrimSpace(hostname),
|
||||
OSName: osName,
|
||||
OSVersion: osVersion,
|
||||
KernelVersion: readFirstLine("/proc/sys/kernel/osrelease"),
|
||||
Architecture: runtime.GOARCH,
|
||||
CPUModel: readLinuxCPUModel(),
|
||||
CPUCores: runtime.NumCPU(),
|
||||
TotalMemoryBytes: totalMemory,
|
||||
TotalDiskBytes: totalDisk,
|
||||
UptimeSeconds: readLinuxUptimeSeconds(),
|
||||
ReportedAtUnix: time.Now().UTC().Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
func fingerprintProfile(profile *service.AgentNodeSystemProfile) string {
|
||||
raw, err := json.Marshal(profile)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func readLinuxOSRelease() (string, string) {
|
||||
file, err := os.Open("/etc/os-release")
|
||||
if err != nil {
|
||||
return runtime.GOOS, ""
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
values := make(map[string]string)
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
key, value, ok := strings.Cut(strings.TrimSpace(scanner.Text()), "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
values[key] = strings.Trim(value, `"`)
|
||||
}
|
||||
if pretty := strings.TrimSpace(values["PRETTY_NAME"]); pretty != "" {
|
||||
return pretty, strings.TrimSpace(values["VERSION_ID"])
|
||||
}
|
||||
if name := strings.TrimSpace(values["NAME"]); name != "" {
|
||||
return name, strings.TrimSpace(values["VERSION_ID"])
|
||||
}
|
||||
return runtime.GOOS, ""
|
||||
}
|
||||
|
||||
func readLinuxCPUModel() string {
|
||||
file, err := os.Open("/proc/cpuinfo")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(strings.ToLower(line), "model name") {
|
||||
_, value, ok := strings.Cut(line, ":")
|
||||
if ok {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func readMemInfo() (int64, int64) {
|
||||
file, err := os.Open("/proc/meminfo")
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var totalKB, availableKB int64
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "MemTotal:") {
|
||||
totalKB = parseMemInfoValue(line)
|
||||
}
|
||||
if strings.HasPrefix(line, "MemAvailable:") {
|
||||
availableKB = parseMemInfoValue(line)
|
||||
}
|
||||
}
|
||||
total := totalKB * 1024
|
||||
used := total - availableKB*1024
|
||||
if used < 0 {
|
||||
used = 0
|
||||
}
|
||||
return total, used
|
||||
}
|
||||
|
||||
func parseMemInfoValue(line string) int64 {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
return 0
|
||||
}
|
||||
value, err := strconv.ParseInt(fields[1], 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func readLinuxUptimeSeconds() int64 {
|
||||
content, err := os.ReadFile("/proc/uptime")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
fields := strings.Fields(string(content))
|
||||
if len(fields) == 0 {
|
||||
return 0
|
||||
}
|
||||
value, err := strconv.ParseFloat(fields[0], 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return int64(value)
|
||||
}
|
||||
|
||||
func readLinuxCPUStat() (uint64, uint64) {
|
||||
content, err := os.ReadFile("/proc/stat")
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
for _, line := range strings.Split(string(content), "\n") {
|
||||
if !strings.HasPrefix(line, "cpu ") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 5 {
|
||||
return 0, 0
|
||||
}
|
||||
var total uint64
|
||||
for index := 1; index < len(fields); index++ {
|
||||
value, err := strconv.ParseUint(fields[index], 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
total += value
|
||||
}
|
||||
idle, err := strconv.ParseUint(fields[4], 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
return total, idle
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
func readLinuxNetworkTotals() (int64, int64) {
|
||||
file, err := os.Open("/proc/net/dev")
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var rx, tx int64
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
name, data, ok := strings.Cut(strings.TrimSpace(scanner.Text()), ":")
|
||||
if !ok || strings.TrimSpace(name) == "lo" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(data)
|
||||
if len(fields) < 16 {
|
||||
continue
|
||||
}
|
||||
if value, err := strconv.ParseInt(fields[0], 10, 64); err == nil {
|
||||
rx += value
|
||||
}
|
||||
if value, err := strconv.ParseInt(fields[8], 10, 64); err == nil {
|
||||
tx += value
|
||||
}
|
||||
}
|
||||
return rx, tx
|
||||
}
|
||||
|
||||
func readLinuxDiskTotals() (int64, int64) {
|
||||
file, err := os.Open("/proc/diskstats")
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var readBytes, writeBytes int64
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 14 || shouldSkipDiskDevice(fields[2]) {
|
||||
continue
|
||||
}
|
||||
if value, err := strconv.ParseInt(fields[5], 10, 64); err == nil {
|
||||
readBytes += value * 512
|
||||
}
|
||||
if value, err := strconv.ParseInt(fields[9], 10, 64); err == nil {
|
||||
writeBytes += value * 512
|
||||
}
|
||||
}
|
||||
return readBytes, writeBytes
|
||||
}
|
||||
|
||||
func shouldSkipDiskDevice(device string) bool {
|
||||
return device == "" || strings.HasPrefix(device, "loop") || strings.HasPrefix(device, "ram") || strings.HasPrefix(device, "dm-")
|
||||
}
|
||||
|
||||
func statFilesystem(path string) (int64, int64) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
path = string(os.PathSeparator)
|
||||
}
|
||||
var stat syscall.Statfs_t
|
||||
if err := syscall.Statfs(filepath.Clean(path), &stat); err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
total := int64(stat.Blocks) * int64(stat.Bsize)
|
||||
used := total - int64(stat.Bavail)*int64(stat.Bsize)
|
||||
if used < 0 {
|
||||
used = 0
|
||||
}
|
||||
return total, used
|
||||
}
|
||||
|
||||
func readFirstLine(path string) string {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(content))
|
||||
}
|
||||
@@ -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,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()
|
||||
}
|
||||
@@ -44,7 +44,7 @@ var WeChatServerAddress = ""
|
||||
var WeChatServerToken = ""
|
||||
var WeChatAccountQRCodeImageURL = ""
|
||||
|
||||
var AgentToken = ""
|
||||
var AccessToken = ""
|
||||
var AgentDiscoveryToken = ""
|
||||
var NodeOfflineThreshold = 2 * time.Minute
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
// @Tags Agent
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security AgentTokenAuth
|
||||
// @Security AccessTokenAuth
|
||||
// @Param payload body service.AgentNodePayload true "Agent node payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
@@ -36,7 +36,7 @@ func AgentRegister(c *gin.Context) {
|
||||
err error
|
||||
)
|
||||
if authNode, ok := c.Get("agent_node"); ok {
|
||||
result, err = service.RegisterNodeWithAgentToken(authNode.(*model.Node), payload)
|
||||
result, err = service.RegisterNodeWithAccessToken(authNode.(*model.Node), payload)
|
||||
} else {
|
||||
result, err = service.RegisterNodeWithDiscovery(payload)
|
||||
}
|
||||
@@ -52,7 +52,7 @@ func AgentRegister(c *gin.Context) {
|
||||
// @Tags Agent
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security AgentTokenAuth
|
||||
// @Security AccessTokenAuth
|
||||
// @Param payload body service.AgentNodePayload true "Agent heartbeat payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
@@ -78,17 +78,58 @@ func AgentHeartbeat(c *gin.Context) {
|
||||
respondSuccessWithExtras(c, node.Node, gin.H{
|
||||
"agent_settings": node.AgentSettings,
|
||||
"active_config": node.ActiveConfig,
|
||||
"waf_ip_groups": node.WAFIPGroups,
|
||||
})
|
||||
}
|
||||
|
||||
// AgentSyncWAFIPGroups godoc
|
||||
// @Summary Sync WAF IP groups for agent
|
||||
// @Tags Agent
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security AccessTokenAuth
|
||||
// @Param payload body service.AgentWAFIPGroupSyncInput true "WAF IP group sync payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /api/agent/waf/ip-groups/sync [post]
|
||||
func AgentSyncWAFIPGroups(c *gin.Context) {
|
||||
var input service.AgentWAFIPGroupSyncInput
|
||||
if !bindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
result, err := service.SyncWAFIPGroupsForAgent(input)
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, result)
|
||||
}
|
||||
|
||||
// AgentGetActiveConfig godoc
|
||||
// @Summary Get active config for agent
|
||||
// @Tags Agent
|
||||
// @Produce json
|
||||
// @Security AgentTokenAuth
|
||||
// @Security AccessTokenAuth
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/agent/config-versions/active [get]
|
||||
func AgentGetActiveConfig(c *gin.Context) {
|
||||
authNode, ok := c.Get("agent_node")
|
||||
if !ok {
|
||||
respondUnauthorized(c, "Node object missing from context")
|
||||
return
|
||||
}
|
||||
node := authNode.(*model.Node)
|
||||
|
||||
if node.NodeType == "tunnel_client" {
|
||||
config, err := service.GetFlaredTunnelConfig(node)
|
||||
if err != nil {
|
||||
respondFailure(c, "无法生成隧道配置: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, config)
|
||||
return
|
||||
}
|
||||
|
||||
config, err := service.GetActiveConfigForAgent()
|
||||
if err != nil {
|
||||
respondFailure(c, "当前没有激活版本")
|
||||
@@ -102,7 +143,7 @@ func AgentGetActiveConfig(c *gin.Context) {
|
||||
// @Tags Agent
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security AgentTokenAuth
|
||||
// @Security AccessTokenAuth
|
||||
// @Param payload body service.ApplyLogPayload true "Apply log payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
@@ -128,7 +169,7 @@ func AgentReportApplyLog(c *gin.Context) {
|
||||
// AgentWebSocket godoc
|
||||
// @Summary Upgrade agent connection to websocket
|
||||
// @Tags Agent
|
||||
// @Security AgentTokenAuth
|
||||
// @Security AccessTokenAuth
|
||||
// @Router /api/agent/ws [get]
|
||||
func AgentWebSocket(c *gin.Context) {
|
||||
authNode, ok := c.Get("agent_node")
|
||||
@@ -191,31 +232,26 @@ func agentWSReadTimeout() time.Duration {
|
||||
return timeout
|
||||
}
|
||||
|
||||
func streamAgentWSMessages(c *gin.Context, conn *websocket.Conn, client *service.AgentWSClient) {
|
||||
pingTicker := time.NewTicker(30 * time.Second)
|
||||
defer pingTicker.Stop()
|
||||
func agentWSWriteTimeout() time.Duration {
|
||||
return 10 * time.Second
|
||||
}
|
||||
|
||||
func streamAgentWSMessages(c *gin.Context, conn *websocket.Conn, client *service.WSClient) {
|
||||
for {
|
||||
select {
|
||||
case message := <-client.Messages():
|
||||
slog.Debug("agent ws sending message", "node_id", client.NodeID(), "type", message.Type)
|
||||
if err := websocket.JSON.Send(conn, message); err != nil {
|
||||
slog.Debug("agent ws send failed", "node_id", client.NodeID(), "type", message.Type, "error", err)
|
||||
client.Close()
|
||||
return
|
||||
}
|
||||
case <-pingTicker.C:
|
||||
message := service.AgentWSOutboundMessage{Type: service.AgentWSMessageTypePing}
|
||||
slog.Debug("agent ws sending ping", "node_id", client.NodeID())
|
||||
if err := websocket.JSON.Send(conn, message); err != nil {
|
||||
slog.Debug("agent ws ping failed", "node_id", client.NodeID(), "error", err)
|
||||
client.Close()
|
||||
return
|
||||
}
|
||||
case <-c.Request.Context().Done():
|
||||
return
|
||||
case <-client.Done():
|
||||
return
|
||||
case <-c.Request.Context().Done():
|
||||
client.Close()
|
||||
return
|
||||
case message, ok := <-client.Messages():
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(agentWSWriteTimeout()))
|
||||
if err := websocket.JSON.Send(conn, message); err != nil {
|
||||
slog.Debug("agent ws send failed", "node_id", client.ID(), "error", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -242,12 +278,17 @@ func handleAgentWSStatus(c *gin.Context, node *model.Node, message service.Agent
|
||||
if response.ActiveConfig != nil {
|
||||
activeConfigSent = service.SendAgentWSActiveConfig(node.NodeID, response.ActiveConfig)
|
||||
}
|
||||
wafIPGroupsSent := false
|
||||
if len(response.WAFIPGroups) > 0 {
|
||||
wafIPGroupsSent = service.SendAgentWSWAFIPGroups(node.NodeID, response.WAFIPGroups)
|
||||
}
|
||||
slog.Debug("agent ws status processed",
|
||||
"node_id", node.NodeID,
|
||||
"current_version", payload.CurrentVersion,
|
||||
"openresty_status", payload.OpenrestyStatus,
|
||||
"settings_sent", settingsSent,
|
||||
"active_config_sent", activeConfigSent,
|
||||
"waf_ip_groups_sent", wafIPGroupsSent,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"openflare/common"
|
||||
"openflare/model"
|
||||
"openflare/service"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/net/websocket"
|
||||
)
|
||||
|
||||
// FlaredHeartbeat godoc
|
||||
// @Summary Report OpenFlared heartbeat
|
||||
// @Tags Flared
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security TunnelTokenAuth
|
||||
// @Param payload body service.FlaredHeartbeatPayload true "Flared heartbeat payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /api/flared/heartbeat [post]
|
||||
func FlaredHeartbeat(c *gin.Context) {
|
||||
var payload service.FlaredHeartbeatPayload
|
||||
if !bindJSON(c, &payload) {
|
||||
return
|
||||
}
|
||||
authNode, ok := c.Get("flared_node")
|
||||
if !ok {
|
||||
respondUnauthorized(c, "无权进行此操作,Tunnel Token 无效")
|
||||
return
|
||||
}
|
||||
node := authNode.(*model.Node)
|
||||
response, err := service.HeartbeatFlared(node, payload)
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, response)
|
||||
}
|
||||
|
||||
// FlaredGetActiveConfig godoc
|
||||
// @Summary Get active tunnel config for OpenFlared
|
||||
// @Tags Flared
|
||||
// @Produce json
|
||||
// @Security TunnelTokenAuth
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/flared/config/active [get]
|
||||
func FlaredGetActiveConfig(c *gin.Context) {
|
||||
authNode, ok := c.Get("flared_node")
|
||||
if !ok {
|
||||
respondUnauthorized(c, "无权进行此操作,Tunnel Token 无效")
|
||||
return
|
||||
}
|
||||
node := authNode.(*model.Node)
|
||||
config, err := service.GetFlaredTunnelConfig(node)
|
||||
if err != nil {
|
||||
respondFailure(c, "无法生成隧道配置: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, config)
|
||||
}
|
||||
|
||||
// FlaredReportApplyLog godoc
|
||||
// @Summary Report OpenFlared apply result
|
||||
// @Tags Flared
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security TunnelTokenAuth
|
||||
// @Param payload body service.ApplyLogPayload true "Apply log payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/flared/apply-log [post]
|
||||
func FlaredReportApplyLog(c *gin.Context) {
|
||||
var payload service.ApplyLogPayload
|
||||
if !bindJSON(c, &payload) {
|
||||
return
|
||||
}
|
||||
if authNode, ok := c.Get("flared_node"); ok {
|
||||
payload.NodeID = authNode.(*model.Node).NodeID
|
||||
}
|
||||
log, err := service.ReportApplyLog(payload)
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, log)
|
||||
}
|
||||
|
||||
// FlaredWebSocket godoc
|
||||
// @Summary Upgrade OpenFlared connection to websocket
|
||||
// @Tags Flared
|
||||
// @Security TunnelTokenAuth
|
||||
// @Router /api/flared/ws [get]
|
||||
func FlaredWebSocket(c *gin.Context) {
|
||||
authNode, ok := c.Get("flared_node")
|
||||
if !ok {
|
||||
respondUnauthorized(c, "无权进行此操作,Tunnel Token 无效")
|
||||
return
|
||||
}
|
||||
node := authNode.(*model.Node)
|
||||
slog.Debug("flared ws upgrade requested", "node_id", node.NodeID, "remote", c.Request.RemoteAddr)
|
||||
websocket.Handler(func(conn *websocket.Conn) {
|
||||
client := service.RegisterFlaredWSClient(node.NodeID)
|
||||
defer service.UnregisterFlaredWSClient(client)
|
||||
defer func() {
|
||||
_ = conn.Close()
|
||||
slog.Debug("flared ws connection closed", "node_id", node.NodeID)
|
||||
}()
|
||||
|
||||
slog.Debug("flared ws upgrade succeeded", "node_id", node.NodeID, "remote", c.Request.RemoteAddr)
|
||||
|
||||
go func() {
|
||||
<-client.Done()
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
go streamFlaredWSMessages(c, conn, client)
|
||||
|
||||
for {
|
||||
var message service.WSMessage
|
||||
_ = conn.SetReadDeadline(time.Now().Add(flaredWSReadTimeout()))
|
||||
if err := websocket.JSON.Receive(conn, &message); err != nil {
|
||||
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||
slog.Debug("flared ws receive timeout", "node_id", node.NodeID)
|
||||
return
|
||||
}
|
||||
slog.Debug("flared ws receive failed", "node_id", node.NodeID, "error", err)
|
||||
return
|
||||
}
|
||||
slog.Debug("flared ws message received", "node_id", node.NodeID, "type", message.Type)
|
||||
switch message.Type {
|
||||
case "ping":
|
||||
if !service.SendFlaredWSPong(node.NodeID) {
|
||||
slog.Debug("flared ws pong enqueue failed", "node_id", node.NodeID)
|
||||
}
|
||||
case "pong":
|
||||
slog.Debug("flared ws pong received", "node_id", node.NodeID)
|
||||
default:
|
||||
slog.Debug("flared ws unsupported message type", "node_id", node.NodeID, "type", message.Type)
|
||||
}
|
||||
}
|
||||
}).ServeHTTP(c.Writer, c.Request)
|
||||
}
|
||||
|
||||
func streamFlaredWSMessages(c *gin.Context, conn *websocket.Conn, client *service.WSClient) {
|
||||
for {
|
||||
select {
|
||||
case <-c.Request.Context().Done():
|
||||
return
|
||||
case <-client.Done():
|
||||
return
|
||||
case message, ok := <-client.Messages():
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(agentWSWriteTimeout()))
|
||||
if err := websocket.JSON.Send(conn, message); err != nil {
|
||||
slog.Debug("flared ws send failed", "node_id", client.ID(), "error", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func flaredWSReadTimeout() time.Duration {
|
||||
timeout := time.Duration(common.AgentHeartbeatInterval) * time.Millisecond * 3
|
||||
if timeout < 30*time.Second {
|
||||
return 30 * time.Second
|
||||
}
|
||||
return timeout
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"openflare/model"
|
||||
"openflare/service"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/net/websocket"
|
||||
)
|
||||
|
||||
// RelayHeartbeat godoc
|
||||
// @Summary Report relay heartbeat
|
||||
// @Tags Relay
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security AccessTokenAuth
|
||||
// @Param payload body service.RelayHeartbeatPayload true "Relay heartbeat payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /api/relay/heartbeat [post]
|
||||
func RelayHeartbeat(c *gin.Context) {
|
||||
var payload service.RelayHeartbeatPayload
|
||||
if !bindJSON(c, &payload) {
|
||||
return
|
||||
}
|
||||
payload.IP = service.ResolveReportedNodeIP(payload.IP, c.Request.RemoteAddr)
|
||||
authNode, ok := c.Get("relay_node")
|
||||
if !ok {
|
||||
respondUnauthorized(c, "无权进行此操作")
|
||||
return
|
||||
}
|
||||
node := authNode.(*model.Node)
|
||||
result, err := service.HeartbeatRelay(node, payload)
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, result)
|
||||
}
|
||||
|
||||
// RelayWebSocket godoc
|
||||
// @Summary Upgrade relay connection to websocket
|
||||
// @Tags Relay
|
||||
// @Security AccessTokenAuth
|
||||
// @Router /api/relay/ws [get]
|
||||
func RelayWebSocket(c *gin.Context) {
|
||||
authNode, ok := c.Get("relay_node")
|
||||
if !ok {
|
||||
respondUnauthorized(c, "无权进行此操作")
|
||||
return
|
||||
}
|
||||
node := authNode.(*model.Node)
|
||||
slog.Debug("relay ws upgrade requested", "node_id", node.NodeID, "remote", c.Request.RemoteAddr)
|
||||
websocket.Handler(func(conn *websocket.Conn) {
|
||||
client := service.RegisterRelayWSClient(node.NodeID)
|
||||
defer service.UnregisterRelayWSClient(client)
|
||||
defer func() {
|
||||
_ = conn.Close()
|
||||
slog.Debug("relay ws connection closed", "node_id", node.NodeID)
|
||||
}()
|
||||
|
||||
slog.Debug("relay ws upgrade succeeded", "node_id", node.NodeID, "remote", c.Request.RemoteAddr)
|
||||
|
||||
go func() {
|
||||
<-client.Done()
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
go streamRelayWSMessages(c, conn, client)
|
||||
|
||||
for {
|
||||
var message service.WSMessage
|
||||
_ = conn.SetReadDeadline(time.Now().Add(agentWSReadTimeout()))
|
||||
if err := websocket.JSON.Receive(conn, &message); err != nil {
|
||||
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||
slog.Debug("relay ws receive timeout", "node_id", node.NodeID)
|
||||
return
|
||||
}
|
||||
slog.Debug("relay ws receive failed", "node_id", node.NodeID, "error", err)
|
||||
return
|
||||
}
|
||||
slog.Debug("relay ws message received", "node_id", node.NodeID, "type", message.Type)
|
||||
switch message.Type {
|
||||
case "ping":
|
||||
if !service.SendRelayWSPong(node.NodeID) {
|
||||
slog.Debug("relay ws pong enqueue failed", "node_id", node.NodeID)
|
||||
}
|
||||
case "pong":
|
||||
slog.Debug("relay ws pong received", "node_id", node.NodeID)
|
||||
default:
|
||||
slog.Debug("relay ws unsupported message type", "node_id", node.NodeID, "type", message.Type)
|
||||
}
|
||||
}
|
||||
}).ServeHTTP(c.Writer, c.Request)
|
||||
}
|
||||
|
||||
func streamRelayWSMessages(c *gin.Context, conn *websocket.Conn, client *service.WSClient) {
|
||||
for {
|
||||
select {
|
||||
case <-c.Request.Context().Done():
|
||||
return
|
||||
case <-client.Done():
|
||||
return
|
||||
case message, ok := <-client.Messages():
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(agentWSWriteTimeout()))
|
||||
if err := websocket.JSON.Send(conn, message); err != nil {
|
||||
slog.Debug("relay ws send failed", "node_id", client.ID(), "error", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ func ListWAFRuleGroups(c *gin.Context) {
|
||||
}
|
||||
|
||||
func GetWAFRuleGroup(c *gin.Context) {
|
||||
id, ok := parseUintPathParam(c, "id")
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -47,7 +47,7 @@ func CreateWAFRuleGroup(c *gin.Context) {
|
||||
}
|
||||
|
||||
func UpdateWAFRuleGroup(c *gin.Context) {
|
||||
id, ok := parseUintPathParam(c, "id")
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func UpdateWAFRuleGroup(c *gin.Context) {
|
||||
}
|
||||
|
||||
func DeleteWAFRuleGroup(c *gin.Context) {
|
||||
id, ok := parseUintPathParam(c, "id")
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -76,7 +76,7 @@ func DeleteWAFRuleGroup(c *gin.Context) {
|
||||
}
|
||||
|
||||
func ReplaceWAFRuleGroupSites(c *gin.Context) {
|
||||
id, ok := parseUintPathParam(c, "id")
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -122,6 +122,96 @@ func ReplaceWAFSiteRuleGroups(c *gin.Context) {
|
||||
respondSuccess(c, view)
|
||||
}
|
||||
|
||||
func ListWAFIPGroups(c *gin.Context) {
|
||||
groups, err := service.ListWAFIPGroups()
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, groups)
|
||||
}
|
||||
|
||||
func GetWAFIPGroup(c *gin.Context) {
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
group, err := service.GetWAFIPGroup(id)
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, group)
|
||||
}
|
||||
|
||||
func CreateWAFIPGroup(c *gin.Context) {
|
||||
var input service.WAFIPGroupInput
|
||||
if !bindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
group, err := service.CreateWAFIPGroup(input)
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, group)
|
||||
}
|
||||
|
||||
func UpdateWAFIPGroup(c *gin.Context) {
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input service.WAFIPGroupInput
|
||||
if !bindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
group, err := service.UpdateWAFIPGroup(id, input)
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, group)
|
||||
}
|
||||
|
||||
func DeleteWAFIPGroup(c *gin.Context) {
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := service.DeleteWAFIPGroup(id); err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccessMessage(c, "")
|
||||
}
|
||||
|
||||
func SyncWAFIPGroup(c *gin.Context) {
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := service.SyncWAFIPGroup(id)
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, result)
|
||||
}
|
||||
|
||||
func TestWAFIPGroupAutoConfig(c *gin.Context) {
|
||||
var input service.WAFIPGroupAutoTestInput
|
||||
if !bindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
result, err := service.TestWAFIPGroupAutoConfig(input)
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, result)
|
||||
}
|
||||
|
||||
func parseUintPathParam(c *gin.Context, name string) (uint, bool) {
|
||||
id, err := strconv.ParseUint(c.Param(name), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
|
||||
@@ -355,7 +355,7 @@ const docTemplate = `{
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"AgentTokenAuth": []
|
||||
"AccessTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -401,7 +401,7 @@ const docTemplate = `{
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"AgentTokenAuth": []
|
||||
"AccessTokenAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -426,7 +426,7 @@ const docTemplate = `{
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"AgentTokenAuth": []
|
||||
"AccessTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -472,7 +472,7 @@ const docTemplate = `{
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"AgentTokenAuth": []
|
||||
"AccessTokenAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -2801,7 +2801,7 @@ const docTemplate = `{
|
||||
"$ref": "#/definitions/service.AgentNodeAccessLog"
|
||||
}
|
||||
},
|
||||
"agent_version": {
|
||||
"version": {
|
||||
"type": "string"
|
||||
},
|
||||
"buffered_observability": {
|
||||
@@ -2828,7 +2828,7 @@ const docTemplate = `{
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"nginx_version": {
|
||||
"ext_version": {
|
||||
"type": "string"
|
||||
},
|
||||
"node_id": {
|
||||
@@ -3186,7 +3186,7 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"securityDefinitions": {
|
||||
"AgentTokenAuth": {
|
||||
"AccessTokenAuth": {
|
||||
"description": "Agent API 使用节点专属 Agent Token 或全局 Discovery Token",
|
||||
"type": "apiKey",
|
||||
"name": "X-Agent-Token",
|
||||
|
||||
@@ -6,6 +6,7 @@ go 1.25.0
|
||||
require (
|
||||
github.com/bwmarrin/snowflake v0.3.0
|
||||
github.com/dgraph-io/ristretto/v2 v2.2.0
|
||||
github.com/expr-lang/expr v1.17.8
|
||||
github.com/gin-contrib/cors v1.6.0
|
||||
github.com/gin-contrib/sessions v0.0.5
|
||||
github.com/gin-contrib/static v0.0.1
|
||||
@@ -16,6 +17,7 @@ require (
|
||||
github.com/go-redis/redis/v8 v8.11.5
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/oschwald/maxminddb-golang v1.13.1
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.1
|
||||
github.com/swaggo/swag v1.16.4
|
||||
@@ -71,7 +73,6 @@ require (
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.1.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // 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
|
||||
|
||||
@@ -36,6 +36,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
|
||||
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=
|
||||
|
||||
@@ -19,6 +19,13 @@ func InitCronJobs() {
|
||||
slog.Info("registered SSL renew cron job")
|
||||
}
|
||||
|
||||
_, err = cronRunner.AddJob("@every 5m", &WAFIPGroupSyncJob{})
|
||||
if err != nil {
|
||||
slog.Error("failed to register WAF IP group sync cron job", "error", err)
|
||||
} else {
|
||||
slog.Info("registered WAF IP group sync cron job")
|
||||
}
|
||||
|
||||
cronRunner.Start()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"openflare/service"
|
||||
)
|
||||
|
||||
type WAFIPGroupSyncJob struct{}
|
||||
|
||||
func (j *WAFIPGroupSyncJob) Run() {
|
||||
if err := service.SyncDueWAFIPGroups(); err != nil {
|
||||
slog.Error("failed to sync due waf ip groups", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,6 @@ import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-contrib/sessions/redis"
|
||||
"github.com/gin-gonic/gin"
|
||||
"log/slog"
|
||||
"openflare/common"
|
||||
_ "openflare/docs"
|
||||
@@ -19,6 +15,11 @@ import (
|
||||
"openflare/utils/geoip"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-contrib/sessions/redis"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
//go:embed all:web/build
|
||||
@@ -36,11 +37,12 @@ var indexPage []byte
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description 管理端可使用 Bearer Token,例如:Bearer <token>
|
||||
// @securityDefinitions.apikey AgentTokenAuth
|
||||
// @securityDefinitions.apikey AccessTokenAuth
|
||||
// @in header
|
||||
// @name X-Agent-Token
|
||||
// @description Agent API 使用节点专属 Agent Token 或全局 Discovery Token
|
||||
func main() {
|
||||
common.ParseFlags()
|
||||
common.SetupGinLog()
|
||||
slog.Info("OpenFlare started", "version", common.Version)
|
||||
if os.Getenv("GIN_MODE") != "debug" {
|
||||
@@ -101,7 +103,7 @@ func main() {
|
||||
if common.SQLDSN != "" {
|
||||
dbBackend = "postgres"
|
||||
}
|
||||
slog.Info("server config", "port", port, "gin_mode", gin.Mode(), "log_level", common.GetLogLevel(), "db_backend", dbBackend, "sqlite_path", common.SQLitePath, "redis_enabled", common.RedisEnabled, "upload_path", common.UploadPath, "log_dir", valueOrDefault(*common.LogDir, "stdout"), "agent_token_configured", common.AgentToken != "", "node_offline_threshold", common.NodeOfflineThreshold)
|
||||
slog.Info("server config", "port", port, "gin_mode", gin.Mode(), "log_level", common.GetLogLevel(), "db_backend", dbBackend, "sqlite_path", common.SQLitePath, "redis_enabled", common.RedisEnabled, "log_dir", valueOrDefault(*common.LogDir, "stdout"), "access_token_configured", common.AccessToken != "", "node_offline_threshold", common.NodeOfflineThreshold)
|
||||
slog.Info("server listening", "address", fmt.Sprintf(":%s", port))
|
||||
err = server.Run(":" + port)
|
||||
if err != nil {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
func AgentAuth() func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
token := c.GetHeader("X-Agent-Token")
|
||||
node, err := service.AuthenticateAgentToken(token)
|
||||
node, err := service.AuthenticateAccessToken(token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
@@ -26,7 +26,7 @@ func AgentAuth() func(c *gin.Context) {
|
||||
func AgentRegisterAuth() func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
token := c.GetHeader("X-Agent-Token")
|
||||
if node, err := service.AuthenticateAgentToken(token); err == nil {
|
||||
if node, err := service.AuthenticateAccessToken(token); err == nil {
|
||||
c.Set("agent_node", node)
|
||||
c.Next()
|
||||
return
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
"openflare/service"
|
||||
)
|
||||
|
||||
// RelayAuth authenticates Relay requests using the shared agent token,
|
||||
// and verifies the node is a tunnel_relay type.
|
||||
func RelayAuth() func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
token := c.GetHeader("X-Agent-Token")
|
||||
node, err := service.AuthenticateAccessToken(token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "无权进行此操作,Agent Token 无效",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if node.NodeType != "tunnel_relay" {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "此节点不是 TunnelRelay 类型",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set("relay_node", node)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"openflare/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TunnelAuth authenticates OpenFlared client requests using the per-node
|
||||
// tunnel_token carried in the X-Tunnel-Token header, and verifies the node is
|
||||
// of the tunnel_client type.
|
||||
func TunnelAuth() func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
token := c.GetHeader("X-Tunnel-Token")
|
||||
node, err := service.AuthenticateAccessToken(token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "无权进行此操作,Tunnel Token 无效",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if node.NodeType != "tunnel_client" {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "此节点不是 TunnelClient 类型",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set("flared_node", node)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -34,17 +34,22 @@ func registeredModels() []any {
|
||||
&ProxyRoute{},
|
||||
&ConfigVersion{},
|
||||
&Node{},
|
||||
|
||||
&NodeSystemProfile{},
|
||||
&ApplyLog{},
|
||||
&NodeMetricSnapshot{},
|
||||
&NodeRequestReport{},
|
||||
&NodeAccessLog{},
|
||||
&NodeHealthEvent{},
|
||||
&NodeObservationOpenresty{},
|
||||
&NodeObservationFrps{},
|
||||
&NodeObservationFrpc{},
|
||||
&TLSCertificate{},
|
||||
&ManagedDomain{},
|
||||
&AcmeAccount{},
|
||||
&DnsAccount{},
|
||||
&WAFRuleGroup{},
|
||||
&WAFIPGroup{},
|
||||
&WAFRuleGroupBinding{},
|
||||
}
|
||||
}
|
||||
@@ -118,14 +123,40 @@ func openDatabase() (*gorm.DB, string, error) {
|
||||
}
|
||||
|
||||
func autoMigrateAll(db *gorm.DB) error {
|
||||
for _, item := range registeredModels() {
|
||||
if err := db.AutoMigrate(item); err != nil {
|
||||
return err
|
||||
return autoMigrateAllExcept(db, nil)
|
||||
}
|
||||
|
||||
func autoMigrateAllExcept(db *gorm.DB, excludedTables map[string]bool) error {
|
||||
models := registeredModels()
|
||||
for i, item := range models {
|
||||
name := fmt.Sprintf("%T", item)
|
||||
tableName, err := tableNameForModel(item)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve table name for %s failed: %w", name, err)
|
||||
}
|
||||
if excludedTables[tableName] {
|
||||
slog.Info("autoMigrateAll: skipped model", "index", fmt.Sprintf("%d/%d", i+1, len(models)), "model", name, "table", tableName)
|
||||
continue
|
||||
}
|
||||
slog.Info("autoMigrateAll: migrating model", "index", fmt.Sprintf("%d/%d", i+1, len(models)), "model", name)
|
||||
if err := db.AutoMigrate(item); err != nil {
|
||||
return fmt.Errorf("AutoMigrate %s failed: %w", name, err)
|
||||
}
|
||||
slog.Info("autoMigrateAll: migrated model", "model", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tableNameForModel(item any) (string, error) {
|
||||
namer := schema.NamingStrategy{}
|
||||
cache := &sync.Map{}
|
||||
parsed, err := schema.Parse(item, cache, namer)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parsed.Table, nil
|
||||
}
|
||||
|
||||
func isDatabaseEmpty(db *gorm.DB) (bool, error) {
|
||||
models, err := buildDBModels()
|
||||
if err != nil {
|
||||
|
||||
@@ -2,7 +2,13 @@ package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -184,6 +190,56 @@ func TestRegisterShardingAutoMigratesShardTables(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeDatabaseSchemaV15ToV16AppliesCompressedReleaseSchema(t *testing.T) {
|
||||
db := openBareTestSQLiteDB(t, "v16.db")
|
||||
if err := registerSharding(db, "sqlite"); err != nil {
|
||||
t.Fatalf("register sharding: %v", err)
|
||||
}
|
||||
if err := autoMigrateSchemaMetadata(db); err != nil {
|
||||
t.Fatalf("auto migrate schema metadata: %v", err)
|
||||
}
|
||||
if err := applyCurrentSchema(db, "sqlite"); err != nil {
|
||||
t.Fatalf("apply current schema: %v", err)
|
||||
}
|
||||
if err := ensureDefaultWAFRuleGroup(db); err != nil {
|
||||
t.Fatalf("ensure default waf rule group: %v", err)
|
||||
}
|
||||
if err := saveDatabaseSchemaVersion(db, 15); err != nil {
|
||||
t.Fatalf("save schema version: %v", err)
|
||||
}
|
||||
if err := upgradeDatabaseSchema(db, "sqlite", 15); err != nil {
|
||||
t.Fatalf("upgrade schema: %v", err)
|
||||
}
|
||||
if !db.Migrator().HasTable(&WAFIPGroup{}) {
|
||||
t.Fatal("expected waf_ip_groups table")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&WAFRuleGroup{}, "ip_whitelist_groups") {
|
||||
t.Fatal("expected waf_rule_groups.ip_whitelist_groups column")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&Node{}, "access_token") {
|
||||
t.Fatal("expected nodes.access_token column")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&Node{}, "version") {
|
||||
t.Fatal("expected nodes.version column")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&Node{}, "ext_version") {
|
||||
t.Fatal("expected nodes.ext_version column")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&ProxyRoute{}, "tunnel_node_id") {
|
||||
t.Fatal("expected proxy_routes.tunnel_node_id column")
|
||||
}
|
||||
if db.Migrator().HasTable("tunnels") {
|
||||
t.Fatal("expected pre-release tunnels table to be absent")
|
||||
}
|
||||
version, ok, err := loadDatabaseSchemaVersion(db)
|
||||
if err != nil {
|
||||
t.Fatalf("load schema version: %v", err)
|
||||
}
|
||||
if !ok || version != currentDatabaseSchemaVersion {
|
||||
t.Fatalf("unexpected schema version: got %d ok=%v want %d", version, ok, currentDatabaseSchemaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateObservabilityLegacyColumnsBackfillsHealthEventMetadata(t *testing.T) {
|
||||
db := openTestSQLiteDB(t, "legacy-health-events.db")
|
||||
|
||||
@@ -480,3 +536,193 @@ func TestEnsureDatabaseSchemaUpToDateAddsNodeIPManualOverride(t *testing.T) {
|
||||
t.Fatalf("unexpected schema version: got %d want %d", version, currentDatabaseSchemaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDatabaseSchemaUpToDateV16BackfillsNodeColumnsWhenNewColumnsAlreadyExist(t *testing.T) {
|
||||
db := openBareTestSQLiteDB(t, "node-v16-existing-target-columns.db")
|
||||
if err := registerSharding(db, "sqlite"); err != nil {
|
||||
t.Fatalf("register sharding: %v", err)
|
||||
}
|
||||
if err := applyCurrentSchema(db, "sqlite"); err != nil {
|
||||
t.Fatalf("apply current schema: %v", err)
|
||||
}
|
||||
if err := ensureDefaultWAFRuleGroup(db); err != nil {
|
||||
t.Fatalf("ensure default waf rule group: %v", err)
|
||||
}
|
||||
for _, stmt := range []string{
|
||||
`ALTER TABLE nodes ADD COLUMN agent_token text`,
|
||||
`ALTER TABLE nodes ADD COLUMN agent_version text`,
|
||||
`ALTER TABLE nodes ADD COLUMN nginx_version text`,
|
||||
`ALTER TABLE nodes ADD COLUMN relay_version text`,
|
||||
`ALTER TABLE nodes ADD COLUMN relay_frp_version text`,
|
||||
`ALTER TABLE nodes ADD COLUMN relay_frps_connections integer`,
|
||||
`ALTER TABLE nodes ADD COLUMN relay_frps_proxy_count integer`,
|
||||
} {
|
||||
if err := db.Exec(stmt).Error; err != nil {
|
||||
t.Fatalf("prepare legacy node column with %q: %v", stmt, err)
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
if err := db.Exec(`
|
||||
INSERT INTO nodes (
|
||||
node_id, name, ip, access_token, version, ext_version,
|
||||
agent_token, agent_version, nginx_version,
|
||||
status, last_seen_at, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "node-v16", "Node v16", "127.0.0.1", "", "", "", "legacy-token", "v2.0.0", "openresty/1.25.3", "offline", now, now, now).Error; err != nil {
|
||||
t.Fatalf("seed node with legacy columns: %v", err)
|
||||
}
|
||||
if err := saveDatabaseSchemaVersion(db, 15); err != nil {
|
||||
t.Fatalf("save schema version: %v", err)
|
||||
}
|
||||
|
||||
if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil {
|
||||
t.Fatalf("ensureDatabaseSchemaUpToDate: %v", err)
|
||||
}
|
||||
|
||||
var node Node
|
||||
if err := db.Where("node_id = ?", "node-v16").First(&node).Error; err != nil {
|
||||
t.Fatalf("query migrated node: %v", err)
|
||||
}
|
||||
if node.AccessToken != "legacy-token" {
|
||||
t.Fatalf("unexpected access_token: got %q", node.AccessToken)
|
||||
}
|
||||
if node.Version != "v2.0.0" {
|
||||
t.Fatalf("unexpected version: got %q", node.Version)
|
||||
}
|
||||
if node.ExtVersion != "openresty/1.25.3" {
|
||||
t.Fatalf("unexpected ext_version: got %q", node.ExtVersion)
|
||||
}
|
||||
for _, column := range []string{
|
||||
"agent_token",
|
||||
"agent_version",
|
||||
"nginx_version",
|
||||
"relay_version",
|
||||
"relay_frp_version",
|
||||
"relay_frps_connections",
|
||||
"relay_frps_proxy_count",
|
||||
} {
|
||||
exists, err := databaseColumnExists(db, "nodes", column)
|
||||
if err != nil {
|
||||
t.Fatalf("inspect legacy nodes.%s: %v", column, err)
|
||||
}
|
||||
if exists {
|
||||
t.Fatalf("expected migration to drop legacy nodes.%s column", column)
|
||||
}
|
||||
}
|
||||
version, exists, err := loadDatabaseSchemaVersion(db)
|
||||
if err != nil {
|
||||
t.Fatalf("loadDatabaseSchemaVersion: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
t.Fatal("expected schema version record to exist")
|
||||
}
|
||||
if version != currentDatabaseSchemaVersion {
|
||||
t.Fatalf("unexpected schema version: got %d want %d", version, currentDatabaseSchemaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDatabaseSchemaUpToDateV16DropsLegacyNodeColumnsWhenAlreadyCurrent(t *testing.T) {
|
||||
db := openBareTestSQLiteDB(t, "node-v16-current-legacy-columns.db")
|
||||
if err := registerSharding(db, "sqlite"); err != nil {
|
||||
t.Fatalf("register sharding: %v", err)
|
||||
}
|
||||
if err := applyCurrentSchema(db, "sqlite"); err != nil {
|
||||
t.Fatalf("apply current schema: %v", err)
|
||||
}
|
||||
for _, stmt := range []string{
|
||||
`ALTER TABLE nodes ADD COLUMN agent_token text`,
|
||||
`ALTER TABLE nodes ADD COLUMN agent_version text`,
|
||||
`ALTER TABLE nodes ADD COLUMN nginx_version text`,
|
||||
} {
|
||||
if err := db.Exec(stmt).Error; err != nil {
|
||||
t.Fatalf("prepare legacy node column with %q: %v", stmt, err)
|
||||
}
|
||||
}
|
||||
if err := saveDatabaseSchemaVersion(db, currentDatabaseSchemaVersion); err != nil {
|
||||
t.Fatalf("save schema version: %v", err)
|
||||
}
|
||||
|
||||
if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil {
|
||||
t.Fatalf("ensureDatabaseSchemaUpToDate: %v", err)
|
||||
}
|
||||
|
||||
for _, column := range []string{"agent_token", "agent_version", "nginx_version"} {
|
||||
exists, err := databaseColumnExists(db, "nodes", column)
|
||||
if err != nil {
|
||||
t.Fatalf("inspect legacy nodes.%s: %v", column, err)
|
||||
}
|
||||
if exists {
|
||||
t.Fatalf("expected current-schema cleanup to drop legacy nodes.%s column", column)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllRegisteredMigrationsHaveValidationDefined(t *testing.T) {
|
||||
ctx := databaseSchemaMigrationContext{}
|
||||
for _, migration := range databaseSchemaMigrations() {
|
||||
err := ctx.ValidateDatabaseSchemaVersion(nil, "sqlite", migration.toVersion)
|
||||
if err != nil && strings.Contains(err.Error(), "is not defined") {
|
||||
t.Fatalf("Validation is not defined in migrations.go for registered migration version v%d: %v", migration.toVersion, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllGORMModelsAreRegistered(t *testing.T) {
|
||||
// 1. Gather all registered model names
|
||||
registeredNames := make(map[string]bool)
|
||||
for _, item := range registeredModels() {
|
||||
name := reflect.TypeOf(item).Elem().Name()
|
||||
registeredNames[name] = true
|
||||
}
|
||||
for _, item := range schemaMetadataModels() {
|
||||
name := reflect.TypeOf(item).Elem().Name()
|
||||
registeredNames[name] = true
|
||||
}
|
||||
|
||||
// 2. Parse all .go files in model/ package
|
||||
fset := token.NewFileSet()
|
||||
pkgs, err := parser.ParseDir(fset, ".", func(info os.FileInfo) bool {
|
||||
// Only parse .go files, exclude _test.go files and subdirectories
|
||||
return !info.IsDir() && strings.HasSuffix(info.Name(), ".go") && !strings.HasSuffix(info.Name(), "_test.go")
|
||||
}, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse directory: %v", err)
|
||||
}
|
||||
|
||||
for _, pkg := range pkgs {
|
||||
for _, file := range pkg.Files {
|
||||
for _, decl := range file.Decls {
|
||||
genDecl, ok := decl.(*ast.GenDecl)
|
||||
if !ok || genDecl.Tok != token.TYPE {
|
||||
continue
|
||||
}
|
||||
for _, spec := range genDecl.Specs {
|
||||
typeSpec, ok := spec.(*ast.TypeSpec)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
structType, ok := typeSpec.Type.(*ast.StructType)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Verify if this struct has any field with a `gorm:"..."` tag
|
||||
isGORMModel := false
|
||||
for _, field := range structType.Fields.List {
|
||||
if field.Tag != nil && strings.Contains(field.Tag.Value, "gorm:") {
|
||||
isGORMModel = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if isGORMModel {
|
||||
structName := typeSpec.Name.Name
|
||||
if !registeredNames[structName] {
|
||||
t.Errorf("Model struct %q is defined with GORM tags but is NOT registered in registeredModels() or schemaMetadataModels() in model/main.go!", structName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ const BaseDatabaseSchemaVersion = 7
|
||||
|
||||
type Context interface {
|
||||
ApplyCurrentSchema(db *gorm.DB, backend string) error
|
||||
ApplyCurrentSchemaExcept(db *gorm.DB, backend string, excludedTables ...string) error
|
||||
BackfillOriginsFromProxyRoutes(db *gorm.DB) error
|
||||
BackfillProxyRouteSiteFields(db *gorm.DB) error
|
||||
EnsureProxyRouteSiteNameUniqueIndex(db *gorm.DB) error
|
||||
@@ -17,6 +18,7 @@ type Context interface {
|
||||
BackfillProxyRouteDomainCertificateFields(db *gorm.DB) error
|
||||
EnsureDefaultGitHubAuthSource(db *gorm.DB) error
|
||||
EnsureDefaultWAFRuleGroup(db *gorm.DB) error
|
||||
DropLegacyNodeColumns(db *gorm.DB, backend string) error
|
||||
ValidateDatabaseSchemaVersion(db *gorm.DB, backend string, version int) error
|
||||
}
|
||||
|
||||
|
||||
@@ -18,9 +18,6 @@ func V10() Migration {
|
||||
}
|
||||
|
||||
func migrateV10(ctx Context, db *gorm.DB, backend string) error {
|
||||
if err := ctx.ApplyCurrentSchema(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.EnsureDefaultGitHubAuthSource(db)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ func V11() Migration {
|
||||
}
|
||||
|
||||
func migrateV11(ctx Context, db *gorm.DB, backend string) error {
|
||||
return ctx.ApplyCurrentSchema(db, backend)
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateV11(ctx Context, db *gorm.DB, backend string) error {
|
||||
|
||||
@@ -18,7 +18,7 @@ func V12() Migration {
|
||||
}
|
||||
|
||||
func migrateV12(ctx Context, db *gorm.DB, backend string) error {
|
||||
return ctx.ApplyCurrentSchema(db, backend)
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateV12(ctx Context, db *gorm.DB, backend string) error {
|
||||
|
||||
@@ -18,9 +18,6 @@ func V13() Migration {
|
||||
}
|
||||
|
||||
func migrateV13(ctx Context, db *gorm.DB, backend string) error {
|
||||
if err := ctx.ApplyCurrentSchema(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.EnsureDefaultWAFRuleGroup(db)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,9 +18,6 @@ func V14() Migration {
|
||||
}
|
||||
|
||||
func migrateV14(ctx Context, db *gorm.DB, backend string) error {
|
||||
if err := ctx.ApplyCurrentSchema(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.EnsureDefaultWAFRuleGroup(db)
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,15 @@ func (nodeV15) TableName() string {
|
||||
}
|
||||
|
||||
func migrateV15(ctx Context, db *gorm.DB, backend string) error {
|
||||
return ctx.ApplyCurrentSchema(db, backend)
|
||||
if db == nil {
|
||||
return fmt.Errorf("database handle is nil")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&nodeV15{}, "ip_manual_override") {
|
||||
if err := db.Migrator().AddColumn(&nodeV15{}, "IPManualOverride"); err != nil {
|
||||
return fmt.Errorf("add nodes.ip_manual_override: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateV15(ctx Context, db *gorm.DB, backend string) error {
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
// v16 is the first database migration after the V15 formal release baseline.
|
||||
// It folds the previously drafted v16-v21 schema work into a single official
|
||||
// upgrade: tunnel-relay fields, WAF IP groups, current node identity/version
|
||||
// columns, and split node observation tables. The migration also backfills
|
||||
// legacy node columns and removes obsolete pre-release tunnel metadata when
|
||||
// present, so V15 deployments can upgrade directly to the new formal schema.
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (nodeV16) TableName() string {
|
||||
return "nodes"
|
||||
}
|
||||
|
||||
func (tunnelV16) TableName() string {
|
||||
return "tunnels"
|
||||
}
|
||||
|
||||
func (proxyRouteV16) TableName() string {
|
||||
return "proxy_routes"
|
||||
}
|
||||
|
||||
type nodeV16 struct{}
|
||||
|
||||
type tunnelV16 struct{}
|
||||
|
||||
type proxyRouteV16 struct{}
|
||||
|
||||
type wafIPGroupV16 struct{}
|
||||
|
||||
type wafRuleGroupV16 struct{}
|
||||
|
||||
func (wafIPGroupV16) TableName() string {
|
||||
return "waf_ip_groups"
|
||||
}
|
||||
|
||||
func (wafRuleGroupV16) TableName() string {
|
||||
return "waf_rule_groups"
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(V16())
|
||||
}
|
||||
|
||||
func V16() Migration {
|
||||
return Migration{
|
||||
FromVersion: 15,
|
||||
ToVersion: 16,
|
||||
Migrate: migrateV16,
|
||||
Validate: validateV16,
|
||||
}
|
||||
}
|
||||
|
||||
func migrateV16(ctx Context, db *gorm.DB, backend string) error {
|
||||
if err := ctx.ApplyCurrentSchema(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
migrator := db.Migrator()
|
||||
if migrator.HasColumn(&nodeV16{}, "agent_token") {
|
||||
if err := db.Exec(`UPDATE nodes SET access_token = agent_token WHERE access_token IS NULL OR access_token = ''`).Error; err != nil {
|
||||
return fmt.Errorf("backfill nodes.access_token from agent_token: %w", err)
|
||||
}
|
||||
}
|
||||
if migrator.HasColumn(&nodeV16{}, "agent_version") {
|
||||
if err := db.Exec(`UPDATE nodes SET version = agent_version WHERE version = '' OR version IS NULL`).Error; err != nil {
|
||||
return fmt.Errorf("backfill nodes.version from agent_version: %w", err)
|
||||
}
|
||||
}
|
||||
if migrator.HasColumn(&nodeV16{}, "nginx_version") {
|
||||
if err := db.Exec(`UPDATE nodes SET ext_version = nginx_version WHERE ext_version IS NULL OR ext_version = ''`).Error; err != nil {
|
||||
return fmt.Errorf("backfill nodes.ext_version from nginx_version: %w", err)
|
||||
}
|
||||
}
|
||||
if err := ctx.DropLegacyNodeColumns(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.Exec("UPDATE nodes SET node_type = 'edge_node' WHERE node_type = '' OR node_type IS NULL").Error; err != nil {
|
||||
return fmt.Errorf("backfill nodes.node_type: %w", err)
|
||||
}
|
||||
if err := db.Exec("UPDATE proxy_routes SET upstream_type = 'direct' WHERE upstream_type = '' OR upstream_type IS NULL").Error; err != nil {
|
||||
return fmt.Errorf("backfill proxy_routes.upstream_type: %w", err)
|
||||
}
|
||||
|
||||
if migrator.HasColumn(&proxyRouteV16{}, "tunnel_id") {
|
||||
if err := db.Model(&proxyRouteV16{}).Where("upstream_type = ?", "tunnel").Update("upstream_type", "direct").Error; err != nil {
|
||||
return fmt.Errorf("reset pre-release tunnel proxy routes: %w", err)
|
||||
}
|
||||
if err := migrator.DropColumn(&proxyRouteV16{}, "tunnel_id"); err != nil {
|
||||
return fmt.Errorf("drop pre-release proxy_routes.tunnel_id: %w", err)
|
||||
}
|
||||
}
|
||||
if migrator.HasTable(&tunnelV16{}) {
|
||||
if err := migrator.DropTable(&tunnelV16{}); err != nil {
|
||||
return fmt.Errorf("drop pre-release tunnels table: %w", err)
|
||||
}
|
||||
slog.Info("dropped pre-release tunnels table during v16 migration")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateV16(ctx Context, db *gorm.DB, backend string) error {
|
||||
if err := ctx.ValidateDatabaseSchemaVersion(db, backend, 15); err != nil {
|
||||
return err
|
||||
}
|
||||
if db == nil {
|
||||
return fmt.Errorf("database handle is nil")
|
||||
}
|
||||
|
||||
migrator := db.Migrator()
|
||||
for _, column := range []string{
|
||||
"access_token",
|
||||
"version",
|
||||
"ext_version",
|
||||
"node_type",
|
||||
"relay_bind_port",
|
||||
"relay_vhost_http_port",
|
||||
"relay_auth_token",
|
||||
"relay_agent_access_addr",
|
||||
"relay_client_access_addr",
|
||||
"relay_client_proxy_url",
|
||||
"relay_status",
|
||||
} {
|
||||
if !migrator.HasColumn(&nodeV16{}, column) {
|
||||
return fmt.Errorf("column nodes.%s is missing", column)
|
||||
}
|
||||
}
|
||||
for _, column := range []string{
|
||||
"upstream_type",
|
||||
"tunnel_node_id",
|
||||
"tunnel_target_addr",
|
||||
"tunnel_target_protocol",
|
||||
} {
|
||||
if !migrator.HasColumn(&proxyRouteV16{}, column) {
|
||||
return fmt.Errorf("column proxy_routes.%s is missing", column)
|
||||
}
|
||||
}
|
||||
if migrator.HasColumn(&proxyRouteV16{}, "tunnel_id") {
|
||||
return fmt.Errorf("column proxy_routes.tunnel_id should not exist in v16")
|
||||
}
|
||||
if migrator.HasTable(&tunnelV16{}) {
|
||||
return fmt.Errorf("table tunnels should not exist in v16")
|
||||
}
|
||||
for _, column := range []string{
|
||||
"agent_token",
|
||||
"agent_version",
|
||||
"nginx_version",
|
||||
"relay_version",
|
||||
"relay_frp_version",
|
||||
"relay_frps_connections",
|
||||
"relay_frps_proxy_count",
|
||||
} {
|
||||
if migrator.HasColumn(&nodeV16{}, column) {
|
||||
return fmt.Errorf("column nodes.%s should not exist in v16", column)
|
||||
}
|
||||
}
|
||||
if !migrator.HasTable(&wafIPGroupV16{}) {
|
||||
return fmt.Errorf("table waf_ip_groups is missing")
|
||||
}
|
||||
for _, column := range []string{
|
||||
"ip_whitelist_groups",
|
||||
"ip_blacklist_groups",
|
||||
} {
|
||||
if !migrator.HasColumn(&wafRuleGroupV16{}, column) {
|
||||
return fmt.Errorf("column waf_rule_groups.%s is missing", column)
|
||||
}
|
||||
}
|
||||
if !migrator.HasColumn(&wafIPGroupV16{}, "ext_ips") {
|
||||
return fmt.Errorf("column waf_ip_groups.ext_ips is missing")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -28,6 +28,10 @@ func (databaseSchemaMigrationContext) ApplyCurrentSchema(db *gorm.DB, backend st
|
||||
return applyCurrentSchema(db, backend)
|
||||
}
|
||||
|
||||
func (databaseSchemaMigrationContext) ApplyCurrentSchemaExcept(db *gorm.DB, backend string, excludedTables ...string) error {
|
||||
return applyCurrentSchemaExcept(db, backend, excludedTables...)
|
||||
}
|
||||
|
||||
func (databaseSchemaMigrationContext) BackfillOriginsFromProxyRoutes(db *gorm.DB) error {
|
||||
return backfillOriginsFromProxyRoutes(db)
|
||||
}
|
||||
@@ -56,6 +60,10 @@ func (databaseSchemaMigrationContext) EnsureDefaultWAFRuleGroup(db *gorm.DB) err
|
||||
return ensureDefaultWAFRuleGroup(db)
|
||||
}
|
||||
|
||||
func (databaseSchemaMigrationContext) DropLegacyNodeColumns(db *gorm.DB, backend string) error {
|
||||
return dropLegacyNodeColumns(db, backend)
|
||||
}
|
||||
|
||||
func (databaseSchemaMigrationContext) ValidateDatabaseSchemaVersion(db *gorm.DB, backend string, version int) error {
|
||||
switch version {
|
||||
case 7:
|
||||
@@ -74,6 +82,10 @@ func (databaseSchemaMigrationContext) ValidateDatabaseSchemaVersion(db *gorm.DB,
|
||||
return validateDatabaseSchemaV13(db, backend)
|
||||
case 14:
|
||||
return validateDatabaseSchemaV14(db, backend)
|
||||
case 15:
|
||||
return validateDatabaseSchemaV15(db, backend)
|
||||
case 16:
|
||||
return validateDatabaseSchemaV16(db, backend)
|
||||
default:
|
||||
return fmt.Errorf("database schema validation for v%d is not defined", version)
|
||||
}
|
||||
@@ -170,21 +182,81 @@ func migrateObservabilityLegacyColumns(db *gorm.DB) error {
|
||||
}
|
||||
|
||||
func applyCurrentSchema(db *gorm.DB, backend string) error {
|
||||
return applyCurrentSchemaExcept(db, backend)
|
||||
}
|
||||
|
||||
func databaseColumnExists(db *gorm.DB, tableName string, columnName string) (bool, error) {
|
||||
columnTypes, err := db.Migrator().ColumnTypes(tableName)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, columnType := range columnTypes {
|
||||
if strings.EqualFold(columnType.Name(), columnName) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func dropLegacyNodeColumns(db *gorm.DB, backend string) error {
|
||||
if db == nil || !db.Migrator().HasTable(&Node{}) {
|
||||
return nil
|
||||
}
|
||||
legacyColumns := []struct {
|
||||
column string
|
||||
}{
|
||||
{column: "agent_token"},
|
||||
{column: "agent_version"},
|
||||
{column: "nginx_version"},
|
||||
{column: "relay_version"},
|
||||
{column: "relay_frp_version"},
|
||||
{column: "relay_frps_connections"},
|
||||
{column: "relay_frps_proxy_count"},
|
||||
}
|
||||
for _, item := range legacyColumns {
|
||||
exists, err := databaseColumnExists(db, "nodes", item.column)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect legacy nodes.%s failed: %w", item.column, err)
|
||||
}
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
if err := db.Exec(fmt.Sprintf(`ALTER TABLE "nodes" DROP COLUMN "%s"`, item.column)).Error; err != nil {
|
||||
return fmt.Errorf("drop legacy nodes.%s failed: %w", item.column, err)
|
||||
}
|
||||
}
|
||||
_ = backend
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyCurrentSchemaExcept(db *gorm.DB, backend string, excludedTables ...string) error {
|
||||
excluded := make(map[string]bool, len(excludedTables))
|
||||
for _, table := range excludedTables {
|
||||
if table != "" {
|
||||
excluded[table] = true
|
||||
}
|
||||
}
|
||||
slog.Info("applyCurrentSchema: step 1/5 - auto migrate schema metadata")
|
||||
if err := autoMigrateSchemaMetadata(db); err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("applyCurrentSchema: step 2/5 - migrate proxy route https column")
|
||||
if err := migrateProxyRouteEnableHTTPSColumn(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := autoMigrateAll(db); err != nil {
|
||||
slog.Info("applyCurrentSchema: step 3/5 - auto migrate all models")
|
||||
if err := autoMigrateAllExcept(db, excluded); err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("applyCurrentSchema: step 4/5 - migrate text columns")
|
||||
if err := migrateTextColumns(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("applyCurrentSchema: step 5/5 - migrate observability legacy columns")
|
||||
if err := migrateObservabilityLegacyColumns(db); err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("applyCurrentSchema: completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1093,6 +1165,8 @@ func ensureDefaultWAFRuleGroup(db *gorm.DB) error {
|
||||
BlockStatusCode: 418,
|
||||
IPWhitelist: "[]",
|
||||
IPBlacklist: "[]",
|
||||
IPWhitelistGroups: "[]",
|
||||
IPBlacklistGroups: "[]",
|
||||
CountryWhitelist: "[]",
|
||||
CountryBlacklist: "[]",
|
||||
RegionWhitelist: "[]",
|
||||
@@ -1140,6 +1214,93 @@ func validateDatabaseSchemaV14(db *gorm.DB, backend string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDatabaseSchemaV15(db *gorm.DB, backend string) error {
|
||||
if err := validateDatabaseSchemaV14(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
if !db.Migrator().HasColumn(&Node{}, "ip_manual_override") {
|
||||
return fmt.Errorf("column nodes.ip_manual_override is missing")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDatabaseSchemaV16(db *gorm.DB, backend string) error {
|
||||
if err := validateDatabaseSchemaV15(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
if !db.Migrator().HasColumn(&Node{}, "access_token") {
|
||||
return fmt.Errorf("column nodes.access_token is missing")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&Node{}, "version") {
|
||||
return fmt.Errorf("column nodes.version is missing")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&Node{}, "ext_version") {
|
||||
return fmt.Errorf("column nodes.ext_version is missing")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&Node{}, "node_type") {
|
||||
return fmt.Errorf("column nodes.node_type is missing")
|
||||
}
|
||||
for _, column := range []string{
|
||||
"relay_bind_port",
|
||||
"relay_vhost_http_port",
|
||||
"relay_auth_token",
|
||||
"relay_agent_access_addr",
|
||||
"relay_client_access_addr",
|
||||
"relay_client_proxy_url",
|
||||
"relay_status",
|
||||
} {
|
||||
if !db.Migrator().HasColumn(&Node{}, column) {
|
||||
return fmt.Errorf("column nodes.%s is missing", column)
|
||||
}
|
||||
}
|
||||
for _, column := range []string{
|
||||
"upstream_type",
|
||||
"tunnel_node_id",
|
||||
"tunnel_target_addr",
|
||||
"tunnel_target_protocol",
|
||||
} {
|
||||
if !db.Migrator().HasColumn(&ProxyRoute{}, column) {
|
||||
return fmt.Errorf("column proxy_routes.%s is missing", column)
|
||||
}
|
||||
}
|
||||
if db.Migrator().HasTable("tunnels") {
|
||||
return fmt.Errorf("table tunnels should not exist in v16")
|
||||
}
|
||||
if db.Migrator().HasColumn(&ProxyRoute{}, "tunnel_id") {
|
||||
return fmt.Errorf("column proxy_routes.tunnel_id should not exist in v16")
|
||||
}
|
||||
for _, column := range []string{
|
||||
"agent_token",
|
||||
"agent_version",
|
||||
"nginx_version",
|
||||
"relay_version",
|
||||
"relay_frp_version",
|
||||
"relay_frps_connections",
|
||||
"relay_frps_proxy_count",
|
||||
} {
|
||||
exists, err := databaseColumnExists(db, "nodes", column)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect legacy nodes.%s failed: %w", column, err)
|
||||
}
|
||||
if exists {
|
||||
return fmt.Errorf("column nodes.%s should not exist in v16", column)
|
||||
}
|
||||
}
|
||||
if !db.Migrator().HasTable(&WAFIPGroup{}) {
|
||||
return fmt.Errorf("table waf_ip_groups is missing")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&WAFRuleGroup{}, "ip_whitelist_groups") {
|
||||
return fmt.Errorf("column waf_rule_groups.ip_whitelist_groups is missing")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&WAFRuleGroup{}, "ip_blacklist_groups") {
|
||||
return fmt.Errorf("column waf_rule_groups.ip_blacklist_groups is missing")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&WAFIPGroup{}, "ext_ips") {
|
||||
return fmt.Errorf("column waf_ip_groups.ext_ips is missing")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func databaseSchemaMigrations() []databaseSchemaMigration {
|
||||
ctx := databaseSchemaMigrationContext{}
|
||||
migrations := []databaseSchemaMigration{}
|
||||
@@ -1164,11 +1325,8 @@ func validateExternalDatabaseSchema(ctx databaseSchemaMigrationContext, db *gorm
|
||||
return ctx.ValidateDatabaseSchemaVersion(db, backend, targetVersion)
|
||||
}
|
||||
for _, migration := range schemamigrate.Migrations() {
|
||||
if migration.ToVersion > targetVersion {
|
||||
continue
|
||||
}
|
||||
if err := migration.Validate(ctx, db, backend); err != nil {
|
||||
return err
|
||||
if migration.ToVersion == targetVersion {
|
||||
return migration.Validate(ctx, db, backend)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -1279,7 +1437,10 @@ func ensureDatabaseSchemaUpToDate(db *gorm.DB, backend string) error {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return upgradeDatabaseSchema(db, backend, version)
|
||||
if err := upgradeDatabaseSchema(db, backend, version); err != nil {
|
||||
return err
|
||||
}
|
||||
return dropLegacyNodeColumns(db, backend)
|
||||
}
|
||||
empty, err := isDatabaseEmpty(db)
|
||||
if err != nil {
|
||||
|
||||
@@ -12,14 +12,14 @@ type Node struct {
|
||||
GeoLatitude *float64 `json:"geo_latitude"`
|
||||
GeoLongitude *float64 `json:"geo_longitude"`
|
||||
GeoManualOverride bool `json:"geo_manual_override" gorm:"not null;default:false"`
|
||||
AgentToken string `json:"-" gorm:"size:128;index"`
|
||||
AccessToken string `json:"-" gorm:"column:access_token;size:128;index"`
|
||||
AutoUpdateEnabled bool `json:"auto_update_enabled" gorm:"not null;default:false"`
|
||||
UpdateRequested bool `json:"update_requested" gorm:"not null;default:false"`
|
||||
UpdateChannel string `json:"update_channel" gorm:"size:16;not null;default:'stable'"`
|
||||
UpdateTag string `json:"update_tag" gorm:"size:64"`
|
||||
RestartOpenrestyRequested bool `json:"restart_openresty_requested" gorm:"not null;default:false"`
|
||||
AgentVersion string `json:"agent_version" gorm:"size:64;not null"`
|
||||
NginxVersion string `json:"nginx_version" gorm:"size:64"`
|
||||
Version string `json:"version" gorm:"size:64;not null;default:''"`
|
||||
ExtVersion string `json:"ext_version" gorm:"size:64"`
|
||||
OpenrestyStatus string `json:"openresty_status" gorm:"size:16;not null;default:'unknown'"`
|
||||
OpenrestyMessage string `json:"openresty_message" gorm:"type:text"`
|
||||
Status string `json:"status" gorm:"size:16;not null;default:'offline'"`
|
||||
@@ -28,6 +28,16 @@ type Node struct {
|
||||
LastError string `json:"last_error" gorm:"type:text"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// Node type: edge_node (default) | tunnel_relay | tunnel_client
|
||||
NodeType string `json:"node_type" gorm:"size:32;not null;default:'edge_node'"`
|
||||
// TunnelRelay specific fields
|
||||
RelayBindPort int `json:"relay_bind_port" gorm:"not null;default:0"`
|
||||
RelayVhostHTTPPort int `json:"relay_vhost_http_port" gorm:"not null;default:0"`
|
||||
RelayAuthToken string `json:"-" gorm:"size:128"`
|
||||
RelayAgentAccessAddr string `json:"relay_agent_access_addr" gorm:"size:255"`
|
||||
RelayClientAccessAddr string `json:"relay_client_access_addr" gorm:"size:255"`
|
||||
RelayClientProxyURL string `json:"relay_client_proxy_url" gorm:"size:512"`
|
||||
RelayStatus string `json:"relay_status" gorm:"size:16;not null;default:'unknown'"`
|
||||
}
|
||||
|
||||
func ListNodes() (nodes []*Node, err error) {
|
||||
@@ -55,9 +65,9 @@ func GetNodeByID(id uint) (*Node, error) {
|
||||
return node, err
|
||||
}
|
||||
|
||||
func GetNodeByAgentToken(token string) (*Node, error) {
|
||||
func GetNodeByAccessToken(token string) (*Node, error) {
|
||||
node := &Node{}
|
||||
err := DB.Where("agent_token = ?", token).First(node).Error
|
||||
err := DB.Where("access_token = ?", token).First(node).Error
|
||||
return node, err
|
||||
}
|
||||
|
||||
@@ -72,3 +82,8 @@ func (node *Node) Update() error {
|
||||
func (node *Node) Delete() error {
|
||||
return DB.Delete(node).Error
|
||||
}
|
||||
|
||||
func ListNodesByType(nodeType string) (nodes []*Node, err error) {
|
||||
err = DB.Where("node_type = ?", nodeType).Order("id desc").Find(&nodes).Error
|
||||
return nodes, err
|
||||
}
|
||||
|
||||
@@ -130,6 +130,10 @@ func ListNodeAccessLogs(query NodeAccessLogQuery) (logs []*NodeAccessLog, err er
|
||||
return all[start:end], nil
|
||||
}
|
||||
|
||||
func ListNodeAccessLogsForWAFIPGroup(query NodeAccessLogQuery) ([]*NodeAccessLog, error) {
|
||||
return listNodeAccessLogsAcrossShards(query)
|
||||
}
|
||||
|
||||
func CountNodeAccessLogs(query NodeAccessLogQuery) (totalRecords int64, totalIPs int64, err error) {
|
||||
all, err := listNodeAccessLogsAcrossShards(query)
|
||||
if err != nil {
|
||||
|
||||
@@ -8,22 +8,19 @@ import (
|
||||
)
|
||||
|
||||
type NodeMetricSnapshot struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
NodeID string `json:"node_id" gorm:"index;size:64;not null"`
|
||||
CapturedAt time.Time `json:"captured_at" gorm:"index"`
|
||||
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"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
NodeID string `json:"node_id" gorm:"index;size:64;not null"`
|
||||
CapturedAt time.Time `json:"captured_at" gorm:"index"`
|
||||
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"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (snapshot *NodeMetricSnapshot) GetID() uint {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"openflare/utils"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type NodeObservationFrpc struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
NodeID string `json:"node_id" gorm:"index;size:64;not null"`
|
||||
CapturedAt time.Time `json:"captured_at" gorm:"index"`
|
||||
TunnelStatus string `json:"tunnel_status" gorm:"size:16"`
|
||||
ConnectedRelaysCount int `json:"connected_relays_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (obs *NodeObservationFrpc) GetID() uint {
|
||||
return obs.ID
|
||||
}
|
||||
|
||||
func (obs *NodeObservationFrpc) GetTime() time.Time {
|
||||
return obs.CapturedAt
|
||||
}
|
||||
|
||||
func (obs *NodeObservationFrpc) BeforeCreate(tx *gorm.DB) error {
|
||||
return assignObservabilityID(&obs.ID)
|
||||
}
|
||||
|
||||
func (obs *NodeObservationFrpc) Insert() error {
|
||||
return DB.Create(obs).Error
|
||||
}
|
||||
|
||||
func ListNodeObservationFrpcs(nodeID string, since time.Time, limit int) (observations []*NodeObservationFrpc, err error) {
|
||||
rows, err := queryAcrossShards("node_observation_frpcs", func(tx *gorm.DB) ([]*NodeObservationFrpc, error) {
|
||||
var shardRows []*NodeObservationFrpc
|
||||
query := tx.Order("captured_at desc, id desc")
|
||||
if nodeID != "" {
|
||||
query = query.Where("node_id = ?", nodeID)
|
||||
}
|
||||
if !since.IsZero() {
|
||||
query = query.Where("captured_at >= ?", since)
|
||||
}
|
||||
if err := query.Find(&shardRows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return shardRows, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return utils.SortAndLimitRecords(rows, limit), nil
|
||||
}
|
||||
|
||||
func DeleteNodeObservationFrpcsBefore(db *gorm.DB, before time.Time) (int64, error) {
|
||||
return deleteAcrossShards(db, "node_observation_frpcs", &NodeObservationFrpc{}, func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Where("captured_at < ?", before)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"openflare/utils"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type NodeObservationFrps struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
NodeID string `json:"node_id" gorm:"index;size:64;not null"`
|
||||
CapturedAt time.Time `json:"captured_at" gorm:"index"`
|
||||
FrpsConnections int `json:"frps_connections"`
|
||||
FrpsProxyCount int `json:"frps_proxy_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (obs *NodeObservationFrps) GetID() uint {
|
||||
return obs.ID
|
||||
}
|
||||
|
||||
func (obs *NodeObservationFrps) GetTime() time.Time {
|
||||
return obs.CapturedAt
|
||||
}
|
||||
|
||||
func (obs *NodeObservationFrps) BeforeCreate(tx *gorm.DB) error {
|
||||
return assignObservabilityID(&obs.ID)
|
||||
}
|
||||
|
||||
func (obs *NodeObservationFrps) Insert() error {
|
||||
return DB.Create(obs).Error
|
||||
}
|
||||
|
||||
func ListNodeObservationFrps(nodeID string, since time.Time, limit int) (observations []*NodeObservationFrps, err error) {
|
||||
rows, err := queryAcrossShards("node_observation_frps", func(tx *gorm.DB) ([]*NodeObservationFrps, error) {
|
||||
var shardRows []*NodeObservationFrps
|
||||
query := tx.Order("captured_at desc, id desc")
|
||||
if nodeID != "" {
|
||||
query = query.Where("node_id = ?", nodeID)
|
||||
}
|
||||
if !since.IsZero() {
|
||||
query = query.Where("captured_at >= ?", since)
|
||||
}
|
||||
if err := query.Find(&shardRows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return shardRows, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return utils.SortAndLimitRecords(rows, limit), nil
|
||||
}
|
||||
|
||||
func DeleteNodeObservationFrpsBefore(db *gorm.DB, before time.Time) (int64, error) {
|
||||
return deleteAcrossShards(db, "node_observation_frps", &NodeObservationFrps{}, func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Where("captured_at < ?", before)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"openflare/utils"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type NodeObservationOpenresty struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
NodeID string `json:"node_id" gorm:"index;size:64;not null"`
|
||||
CapturedAt time.Time `json:"captured_at" gorm:"index"`
|
||||
OpenrestyRxBytes int64 `json:"openresty_rx_bytes"`
|
||||
OpenrestyTxBytes int64 `json:"openresty_tx_bytes"`
|
||||
OpenrestyConnections int64 `json:"openresty_connections"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (obs *NodeObservationOpenresty) GetID() uint {
|
||||
return obs.ID
|
||||
}
|
||||
|
||||
func (obs *NodeObservationOpenresty) GetTime() time.Time {
|
||||
return obs.CapturedAt
|
||||
}
|
||||
|
||||
func (obs *NodeObservationOpenresty) BeforeCreate(tx *gorm.DB) error {
|
||||
return assignObservabilityID(&obs.ID)
|
||||
}
|
||||
|
||||
func (obs *NodeObservationOpenresty) Insert() error {
|
||||
return DB.Create(obs).Error
|
||||
}
|
||||
|
||||
func ListNodeObservationOpenresty(nodeID string, since time.Time, limit int) (observations []*NodeObservationOpenresty, err error) {
|
||||
rows, err := queryAcrossShards("node_observation_openresties", func(tx *gorm.DB) ([]*NodeObservationOpenresty, error) {
|
||||
var shardRows []*NodeObservationOpenresty
|
||||
query := tx.Order("captured_at desc, id desc")
|
||||
if nodeID != "" {
|
||||
query = query.Where("node_id = ?", nodeID)
|
||||
}
|
||||
if !since.IsZero() {
|
||||
query = query.Where("captured_at >= ?", since)
|
||||
}
|
||||
if err := query.Find(&shardRows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return shardRows, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return utils.SortAndLimitRecords(rows, limit), nil
|
||||
}
|
||||
|
||||
func DeleteNodeObservationOpenrestiesBefore(db *gorm.DB, before time.Time) (int64, error) {
|
||||
return deleteAcrossShards(db, "node_observation_openresties", &NodeObservationOpenresty{}, func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Where("captured_at < ?", before)
|
||||
})
|
||||
}
|
||||
@@ -3,35 +3,39 @@ package model
|
||||
import "time"
|
||||
|
||||
type ProxyRoute struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
SiteName string `json:"site_name" gorm:"size:255;not null;default:''"`
|
||||
Domain string `json:"domain" gorm:"uniqueIndex;size:255;not null"`
|
||||
Domains string `json:"domains" gorm:"type:text;not null;default:'[]'"`
|
||||
OriginID *uint `json:"origin_id" gorm:"index"`
|
||||
OriginURL string `json:"origin_url" gorm:"size:2048;not null"`
|
||||
OriginHost string `json:"origin_host" gorm:"size:255"`
|
||||
Upstreams string `json:"upstreams" gorm:"type:text;not null;default:'[]'"`
|
||||
Enabled bool `json:"enabled" gorm:"not null;default:true"`
|
||||
EnableHTTPS bool `json:"enable_https" gorm:"column:enable_https;not null;default:false"`
|
||||
CertID *uint `json:"cert_id"`
|
||||
CertIDs string `json:"cert_ids" gorm:"type:text;not null;default:'[]'"`
|
||||
DomainCertIDs string `json:"domain_cert_ids" gorm:"type:text;not null;default:'[]'"`
|
||||
RedirectHTTP bool `json:"redirect_http" gorm:"not null;default:false"`
|
||||
LimitConnPerServer int `json:"limit_conn_per_server" gorm:"not null;default:0"`
|
||||
LimitConnPerIP int `json:"limit_conn_per_ip" gorm:"not null;default:0"`
|
||||
LimitRate string `json:"limit_rate" gorm:"size:32;not null;default:''"`
|
||||
CacheEnabled bool `json:"cache_enabled" gorm:"not null;default:false"`
|
||||
CachePolicy string `json:"cache_policy" gorm:"size:32;not null;default:''"`
|
||||
CacheRules string `json:"cache_rules" gorm:"type:text;not null;default:'[]'"`
|
||||
CustomHeaders string `json:"custom_headers" gorm:"type:text;not null;default:'[]'"`
|
||||
PoWEnabled bool `json:"pow_enabled" gorm:"column:pow_enabled;not null;default:false"`
|
||||
PoWConfig string `json:"pow_config" gorm:"column:pow_config;type:text;not null;default:'{}'"`
|
||||
BasicAuthEnabled bool `json:"basic_auth_enabled" gorm:"not null;default:false"`
|
||||
BasicAuthUsername string `json:"basic_auth_username" gorm:"size:255;not null;default:''"`
|
||||
BasicAuthPassword string `json:"basic_auth_password" gorm:"size:255;not null;default:''"`
|
||||
Remark string `json:"remark" gorm:"size:255"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
SiteName string `json:"site_name" gorm:"size:255;not null;default:''"`
|
||||
Domain string `json:"domain" gorm:"uniqueIndex;size:255;not null"`
|
||||
Domains string `json:"domains" gorm:"type:text;not null;default:'[]'"`
|
||||
OriginID *uint `json:"origin_id" gorm:"index"`
|
||||
OriginURL string `json:"origin_url" gorm:"size:2048;not null"`
|
||||
OriginHost string `json:"origin_host" gorm:"size:255"`
|
||||
Upstreams string `json:"upstreams" gorm:"type:text;not null;default:'[]'"`
|
||||
Enabled bool `json:"enabled" gorm:"not null;default:true"`
|
||||
EnableHTTPS bool `json:"enable_https" gorm:"column:enable_https;not null;default:false"`
|
||||
CertID *uint `json:"cert_id"`
|
||||
CertIDs string `json:"cert_ids" gorm:"type:text;not null;default:'[]'"`
|
||||
DomainCertIDs string `json:"domain_cert_ids" gorm:"type:text;not null;default:'[]'"`
|
||||
RedirectHTTP bool `json:"redirect_http" gorm:"not null;default:false"`
|
||||
LimitConnPerServer int `json:"limit_conn_per_server" gorm:"not null;default:0"`
|
||||
LimitConnPerIP int `json:"limit_conn_per_ip" gorm:"not null;default:0"`
|
||||
LimitRate string `json:"limit_rate" gorm:"size:32;not null;default:''"`
|
||||
CacheEnabled bool `json:"cache_enabled" gorm:"not null;default:false"`
|
||||
CachePolicy string `json:"cache_policy" gorm:"size:32;not null;default:''"`
|
||||
CacheRules string `json:"cache_rules" gorm:"type:text;not null;default:'[]'"`
|
||||
CustomHeaders string `json:"custom_headers" gorm:"type:text;not null;default:'[]'"`
|
||||
PoWEnabled bool `json:"pow_enabled" gorm:"column:pow_enabled;not null;default:false"`
|
||||
PoWConfig string `json:"pow_config" gorm:"column:pow_config;type:text;not null;default:'{}'"`
|
||||
BasicAuthEnabled bool `json:"basic_auth_enabled" gorm:"not null;default:false"`
|
||||
BasicAuthUsername string `json:"basic_auth_username" gorm:"size:255;not null;default:''"`
|
||||
BasicAuthPassword string `json:"basic_auth_password" gorm:"size:255;not null;default:''"`
|
||||
Remark string `json:"remark" gorm:"size:255"`
|
||||
UpstreamType string `json:"upstream_type" gorm:"size:32;not null;default:'direct'"`
|
||||
TunnelNodeID *uint `json:"tunnel_node_id" gorm:"index"`
|
||||
TunnelTargetAddr string `json:"tunnel_target_addr" gorm:"size:512"`
|
||||
TunnelTargetProtocol string `json:"tunnel_target_protocol" gorm:"size:16"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func ListProxyRoutes() (routes []*ProxyRoute, err error) {
|
||||
@@ -61,32 +65,36 @@ func (route *ProxyRoute) Insert() error {
|
||||
|
||||
func (route *ProxyRoute) Update() error {
|
||||
return DB.Model(&ProxyRoute{}).Where("id = ?", route.ID).Updates(map[string]any{
|
||||
"site_name": route.SiteName,
|
||||
"domain": route.Domain,
|
||||
"domains": route.Domains,
|
||||
"origin_id": route.OriginID,
|
||||
"origin_url": route.OriginURL,
|
||||
"origin_host": route.OriginHost,
|
||||
"upstreams": route.Upstreams,
|
||||
"enabled": route.Enabled,
|
||||
"enable_https": route.EnableHTTPS,
|
||||
"cert_id": route.CertID,
|
||||
"cert_ids": route.CertIDs,
|
||||
"domain_cert_ids": route.DomainCertIDs,
|
||||
"redirect_http": route.RedirectHTTP,
|
||||
"limit_conn_per_server": route.LimitConnPerServer,
|
||||
"limit_conn_per_ip": route.LimitConnPerIP,
|
||||
"limit_rate": route.LimitRate,
|
||||
"cache_enabled": route.CacheEnabled,
|
||||
"cache_policy": route.CachePolicy,
|
||||
"cache_rules": route.CacheRules,
|
||||
"custom_headers": route.CustomHeaders,
|
||||
"pow_enabled": route.PoWEnabled,
|
||||
"pow_config": route.PoWConfig,
|
||||
"basic_auth_enabled": route.BasicAuthEnabled,
|
||||
"basic_auth_username": route.BasicAuthUsername,
|
||||
"basic_auth_password": route.BasicAuthPassword,
|
||||
"remark": route.Remark,
|
||||
"site_name": route.SiteName,
|
||||
"domain": route.Domain,
|
||||
"domains": route.Domains,
|
||||
"origin_id": route.OriginID,
|
||||
"origin_url": route.OriginURL,
|
||||
"origin_host": route.OriginHost,
|
||||
"upstreams": route.Upstreams,
|
||||
"enabled": route.Enabled,
|
||||
"enable_https": route.EnableHTTPS,
|
||||
"cert_id": route.CertID,
|
||||
"cert_ids": route.CertIDs,
|
||||
"domain_cert_ids": route.DomainCertIDs,
|
||||
"redirect_http": route.RedirectHTTP,
|
||||
"limit_conn_per_server": route.LimitConnPerServer,
|
||||
"limit_conn_per_ip": route.LimitConnPerIP,
|
||||
"limit_rate": route.LimitRate,
|
||||
"cache_enabled": route.CacheEnabled,
|
||||
"cache_policy": route.CachePolicy,
|
||||
"cache_rules": route.CacheRules,
|
||||
"custom_headers": route.CustomHeaders,
|
||||
"pow_enabled": route.PoWEnabled,
|
||||
"pow_config": route.PoWConfig,
|
||||
"basic_auth_enabled": route.BasicAuthEnabled,
|
||||
"basic_auth_username": route.BasicAuthUsername,
|
||||
"basic_auth_password": route.BasicAuthPassword,
|
||||
"remark": route.Remark,
|
||||
"upstream_type": route.UpstreamType,
|
||||
"tunnel_node_id": route.TunnelNodeID,
|
||||
"tunnel_target_addr": route.TunnelTargetAddr,
|
||||
"tunnel_target_protocol": route.TunnelTargetProtocol,
|
||||
}).Error
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,9 @@ func shardedObservabilityTables() []any {
|
||||
&NodeMetricSnapshot{},
|
||||
&NodeRequestReport{},
|
||||
&NodeAccessLog{},
|
||||
&NodeObservationOpenresty{},
|
||||
&NodeObservationFrps{},
|
||||
&NodeObservationFrpc{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,12 +59,15 @@ func shardedObservabilityBaseTables() []string {
|
||||
"node_metric_snapshots",
|
||||
"node_request_reports",
|
||||
"node_access_logs",
|
||||
"node_observation_openresties",
|
||||
"node_observation_frps",
|
||||
"node_observation_frpcs",
|
||||
}
|
||||
}
|
||||
|
||||
func isShardedObservabilityTable(tableName string) bool {
|
||||
switch strings.TrimSpace(tableName) {
|
||||
case "node_metric_snapshots", "node_request_reports", "node_access_logs":
|
||||
case "node_metric_snapshots", "node_request_reports", "node_access_logs", "node_observation_openresties", "node_observation_frps", "node_observation_frpcs":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -11,6 +11,8 @@ type WAFRuleGroup struct {
|
||||
BlockResponseBody string `json:"block_response_body" gorm:"type:text;not null;default:''"`
|
||||
IPWhitelist string `json:"ip_whitelist" gorm:"type:text;not null;default:'[]'"`
|
||||
IPBlacklist string `json:"ip_blacklist" gorm:"type:text;not null;default:'[]'"`
|
||||
IPWhitelistGroups string `json:"ip_whitelist_group_ids" gorm:"type:text;not null;default:'[]'"`
|
||||
IPBlacklistGroups string `json:"ip_blacklist_group_ids" gorm:"type:text;not null;default:'[]'"`
|
||||
CountryWhitelist string `json:"country_whitelist" gorm:"type:text;not null;default:'[]'"`
|
||||
CountryBlacklist string `json:"country_blacklist" gorm:"type:text;not null;default:'[]'"`
|
||||
RegionWhitelist string `json:"region_whitelist" gorm:"type:text;not null;default:'[]'"`
|
||||
@@ -22,6 +24,27 @@ type WAFRuleGroup struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type WAFIPGroup struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Name string `json:"name" gorm:"size:255;not null"`
|
||||
Type string `json:"type" gorm:"size:32;not null;index"`
|
||||
Enabled bool `json:"enabled" gorm:"not null;default:true"`
|
||||
IPList string `json:"ip_list" gorm:"type:text;not null;default:'[]'"`
|
||||
AutoConfig string `json:"auto_config" gorm:"type:text;not null;default:'{}'"`
|
||||
ExtIPs string `json:"ext_ips" gorm:"type:text;not null;default:'[]'"`
|
||||
SubscriptionURL string `json:"subscription_url" gorm:"size:2048;not null;default:''"`
|
||||
SubscriptionFormat string `json:"subscription_format" gorm:"size:32;not null;default:'text'"`
|
||||
SubscriptionMappingRule string `json:"subscription_mapping_rule" gorm:"size:255;not null;default:''"`
|
||||
SyncIntervalMinutes int `json:"sync_interval_minutes" gorm:"not null;default:1440"`
|
||||
LastSyncedAt *time.Time `json:"last_synced_at"`
|
||||
NextSyncAt *time.Time `json:"next_sync_at" gorm:"index"`
|
||||
LastSyncStatus string `json:"last_sync_status" gorm:"size:32;not null;default:''"`
|
||||
LastSyncMessage string `json:"last_sync_message" gorm:"type:text;not null;default:''"`
|
||||
Remark string `json:"remark" gorm:"size:255"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type WAFRuleGroupBinding struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
RuleGroupID uint `json:"rule_group_id" gorm:"not null;uniqueIndex:idx_waf_group_route"`
|
||||
@@ -60,6 +83,8 @@ func (group *WAFRuleGroup) Update() error {
|
||||
"block_response_body": group.BlockResponseBody,
|
||||
"ip_whitelist": group.IPWhitelist,
|
||||
"ip_blacklist": group.IPBlacklist,
|
||||
"ip_whitelist_groups": group.IPWhitelistGroups,
|
||||
"ip_blacklist_groups": group.IPBlacklistGroups,
|
||||
"country_whitelist": group.CountryWhitelist,
|
||||
"country_blacklist": group.CountryBlacklist,
|
||||
"region_whitelist": group.RegionWhitelist,
|
||||
@@ -73,3 +98,71 @@ func (group *WAFRuleGroup) Update() error {
|
||||
func (group *WAFRuleGroup) Delete() error {
|
||||
return DB.Delete(group).Error
|
||||
}
|
||||
|
||||
func ListWAFIPGroups() ([]*WAFIPGroup, error) {
|
||||
var groups []*WAFIPGroup
|
||||
err := DB.Order("type asc").Order("id asc").Find(&groups).Error
|
||||
return groups, err
|
||||
}
|
||||
|
||||
func GetWAFIPGroupByID(id uint) (*WAFIPGroup, error) {
|
||||
group := &WAFIPGroup{}
|
||||
err := DB.First(group, id).Error
|
||||
return group, err
|
||||
}
|
||||
|
||||
func ListWAFIPGroupsByIDs(ids []uint) ([]*WAFIPGroup, error) {
|
||||
if len(ids) == 0 {
|
||||
return []*WAFIPGroup{}, nil
|
||||
}
|
||||
var groups []*WAFIPGroup
|
||||
err := DB.Where("id IN ?", ids).Order("id asc").Find(&groups).Error
|
||||
return groups, err
|
||||
}
|
||||
|
||||
func ListDueWAFIPGroups(now time.Time) ([]*WAFIPGroup, error) {
|
||||
var groups []*WAFIPGroup
|
||||
err := DB.Where("enabled = ? AND (type = ? OR (type = ? AND subscription_url <> '')) AND (next_sync_at IS NULL OR next_sync_at <= ?)", true, "automatic", "subscription", now).
|
||||
Order("id asc").
|
||||
Find(&groups).Error
|
||||
return groups, err
|
||||
}
|
||||
|
||||
func (group *WAFIPGroup) Insert() error {
|
||||
return DB.Create(group).Error
|
||||
}
|
||||
|
||||
func (group *WAFIPGroup) Update() error {
|
||||
return DB.Model(&WAFIPGroup{}).Where("id = ?", group.ID).Updates(map[string]any{
|
||||
"name": group.Name,
|
||||
"type": group.Type,
|
||||
"enabled": group.Enabled,
|
||||
"ip_list": group.IPList,
|
||||
"auto_config": group.AutoConfig,
|
||||
"ext_ips": group.ExtIPs,
|
||||
"subscription_url": group.SubscriptionURL,
|
||||
"subscription_format": group.SubscriptionFormat,
|
||||
"subscription_mapping_rule": group.SubscriptionMappingRule,
|
||||
"sync_interval_minutes": group.SyncIntervalMinutes,
|
||||
"next_sync_at": group.NextSyncAt,
|
||||
"last_sync_status": group.LastSyncStatus,
|
||||
"last_sync_message": group.LastSyncMessage,
|
||||
"remark": group.Remark,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (group *WAFIPGroup) UpdateSyncResult() error {
|
||||
return DB.Model(&WAFIPGroup{}).Where("id = ?", group.ID).Updates(map[string]any{
|
||||
"ip_list": group.IPList,
|
||||
"ext_ips": group.ExtIPs,
|
||||
"last_synced_at": group.LastSyncedAt,
|
||||
"next_sync_at": group.NextSyncAt,
|
||||
"last_sync_status": group.LastSyncStatus,
|
||||
"last_sync_message": group.LastSyncMessage,
|
||||
"subscription_format": group.SubscriptionFormat,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (group *WAFIPGroup) Delete() error {
|
||||
return DB.Delete(group).Error
|
||||
}
|
||||
|
||||
@@ -97,6 +97,13 @@ func SetApiRouter(router *gin.Engine) {
|
||||
wafRoute := apiRouter.Group("/waf")
|
||||
wafRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
wafRoute.GET("/ip-groups", controller.ListWAFIPGroups)
|
||||
wafRoute.GET("/ip-groups/:id", controller.GetWAFIPGroup)
|
||||
wafRoute.POST("/ip-groups", controller.CreateWAFIPGroup)
|
||||
wafRoute.POST("/ip-groups/test", controller.TestWAFIPGroupAutoConfig)
|
||||
wafRoute.POST("/ip-groups/:id/update", controller.UpdateWAFIPGroup)
|
||||
wafRoute.POST("/ip-groups/:id/delete", controller.DeleteWAFIPGroup)
|
||||
wafRoute.POST("/ip-groups/:id/sync", controller.SyncWAFIPGroup)
|
||||
wafRoute.GET("/rule-groups", controller.ListWAFRuleGroups)
|
||||
wafRoute.GET("/rule-groups/:id", controller.GetWAFRuleGroup)
|
||||
wafRoute.POST("/rule-groups", controller.CreateWAFRuleGroup)
|
||||
@@ -191,6 +198,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
applyLogRoute.GET("/", controller.GetApplyLogs)
|
||||
applyLogRoute.POST("/cleanup", controller.CleanupApplyLogs)
|
||||
}
|
||||
|
||||
accessLogRoute := apiRouter.Group("/access-logs")
|
||||
accessLogRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
@@ -214,8 +222,24 @@ func SetApiRouter(router *gin.Engine) {
|
||||
authorizedRoute.GET("/ws", controller.AgentWebSocket)
|
||||
authorizedRoute.POST("/nodes/heartbeat", controller.AgentHeartbeat)
|
||||
authorizedRoute.GET("/config-versions/active", controller.AgentGetActiveConfig)
|
||||
authorizedRoute.POST("/waf/ip-groups/sync", controller.AgentSyncWAFIPGroups)
|
||||
authorizedRoute.POST("/apply-logs", controller.AgentReportApplyLog)
|
||||
}
|
||||
}
|
||||
relayRoute := apiRouter.Group("/relay")
|
||||
relayRoute.Use(middleware.RelayAuth())
|
||||
{
|
||||
relayRoute.POST("/heartbeat", controller.RelayHeartbeat)
|
||||
relayRoute.GET("/ws", controller.RelayWebSocket)
|
||||
}
|
||||
flaredRoute := apiRouter.Group("/flared")
|
||||
flaredRoute.Use(middleware.TunnelAuth())
|
||||
{
|
||||
flaredRoute.POST("/heartbeat", controller.FlaredHeartbeat)
|
||||
flaredRoute.GET("/config/active", controller.FlaredGetActiveConfig)
|
||||
flaredRoute.POST("/apply-log", controller.FlaredReportApplyLog)
|
||||
flaredRoute.GET("/ws", controller.FlaredWebSocket)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
package router_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"openflare/common"
|
||||
"openflare/model"
|
||||
"openflare/router"
|
||||
"openflare/service"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestPhaseFlaredRoutesUnauthorized(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
common.RedisEnabled = false
|
||||
setupTestDB(t)
|
||||
|
||||
engine := gin.New()
|
||||
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
|
||||
router.SetApiRouter(engine)
|
||||
|
||||
heartbeatReq := httptest.NewRequest(http.MethodPost, "/api/flared/heartbeat", bytes.NewReader([]byte(`{}`)))
|
||||
heartbeatReq.Header.Set("Content-Type", "application/json")
|
||||
heartbeatRec := httptest.NewRecorder()
|
||||
engine.ServeHTTP(heartbeatRec, heartbeatReq)
|
||||
if heartbeatRec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected unauthorized status for missing token, got %d body=%s", heartbeatRec.Code, heartbeatRec.Body.String())
|
||||
}
|
||||
|
||||
activeReq := httptest.NewRequest(http.MethodGet, "/api/flared/config/active", nil)
|
||||
activeRec := httptest.NewRecorder()
|
||||
engine.ServeHTTP(activeRec, activeReq)
|
||||
if activeRec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected unauthorized status for missing token on active config, got %d", activeRec.Code)
|
||||
}
|
||||
|
||||
applyReq := httptest.NewRequest(http.MethodPost, "/api/flared/apply-log", bytes.NewReader([]byte(`{}`)))
|
||||
applyReq.Header.Set("Content-Type", "application/json")
|
||||
applyRec := httptest.NewRecorder()
|
||||
engine.ServeHTTP(applyRec, applyReq)
|
||||
if applyRec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected unauthorized status for missing token on apply log, got %d", applyRec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhaseFlaredRoutesRejectWrongNodeType(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
common.RedisEnabled = false
|
||||
setupTestDB(t)
|
||||
|
||||
engine := gin.New()
|
||||
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
|
||||
router.SetApiRouter(engine)
|
||||
|
||||
adminToken := prepareRootToken(t)
|
||||
createNodeResp := performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/nodes/", map[string]any{
|
||||
"name": "edge-for-flared-test",
|
||||
"ip": "10.0.0.20",
|
||||
})
|
||||
var createdNode service.NodeView
|
||||
decodeResponseData(t, createNodeResp, &createdNode)
|
||||
|
||||
heartbeatReq := httptest.NewRequest(http.MethodPost, "/api/flared/heartbeat", bytes.NewReader([]byte(`{}`)))
|
||||
heartbeatReq.Header.Set("Content-Type", "application/json")
|
||||
heartbeatReq.Header.Set("X-Tunnel-Token", createdNode.AccessToken)
|
||||
heartbeatRec := httptest.NewRecorder()
|
||||
engine.ServeHTTP(heartbeatRec, heartbeatReq)
|
||||
if heartbeatRec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected forbidden status for edge_node token, got %d body=%s", heartbeatRec.Code, heartbeatRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhaseFlaredLifecycle(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
common.RedisEnabled = false
|
||||
setupTestDB(t)
|
||||
|
||||
engine := gin.New()
|
||||
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
|
||||
router.SetApiRouter(engine)
|
||||
|
||||
adminToken := prepareRootToken(t)
|
||||
|
||||
// Create an enabled proxy route that will be served to the flared client
|
||||
// through the tunnel upstream flow.
|
||||
createRouteAndPublishVersion(t, engine, adminToken)
|
||||
|
||||
// Seed a tunnel_client node directly so we can use its access token as the
|
||||
// tunnel_token when calling the flared endpoints.
|
||||
tunnelNode := &model.Node{
|
||||
NodeID: "tun-flared-1",
|
||||
Name: "office-flared-1",
|
||||
IP: "192.168.10.20",
|
||||
AccessToken: "tunnel-token-phase",
|
||||
Status: service.NodeStatusPending,
|
||||
NodeType: "tunnel_client",
|
||||
Version: "",
|
||||
}
|
||||
if err := tunnelNode.Insert(); err != nil {
|
||||
t.Fatalf("failed to seed tunnel client node: %v", err)
|
||||
}
|
||||
|
||||
heartbeatResp := performFlaredJSONRequest(t, engine, tunnelNode.AccessToken, http.MethodPost, "/api/flared/heartbeat", map[string]any{
|
||||
"client_version": "v0.2.0",
|
||||
"frp_version": "0.61.0",
|
||||
"tunnel_status": "running",
|
||||
"current_version": "",
|
||||
})
|
||||
if !heartbeatResp.Success {
|
||||
t.Fatalf("flared heartbeat failed: %s", heartbeatResp.Message)
|
||||
}
|
||||
var heartbeatData service.FlaredHeartbeatResponse
|
||||
if err := json.Unmarshal(heartbeatResp.Data, &heartbeatData); err != nil {
|
||||
t.Fatalf("failed to decode flared heartbeat response: %v", err)
|
||||
}
|
||||
if heartbeatData.ActiveConfig == nil {
|
||||
t.Fatal("expected heartbeat to return active config summary")
|
||||
}
|
||||
if heartbeatData.TunnelSettings == nil {
|
||||
t.Fatal("expected heartbeat to return tunnel_settings")
|
||||
}
|
||||
|
||||
// Re-fetch node and assert status flipped to online.
|
||||
updated, err := model.GetNodeByNodeID(tunnelNode.NodeID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to reload flared node: %v", err)
|
||||
}
|
||||
if updated.Status != service.NodeStatusOnline {
|
||||
t.Fatalf("expected flared node status to be online, got %q", updated.Status)
|
||||
}
|
||||
if updated.Version != "v0.2.0" {
|
||||
t.Fatalf("expected flared client_version to be stored, got %q", updated.Version)
|
||||
}
|
||||
|
||||
activeResp := performFlaredJSONRequest(t, engine, tunnelNode.AccessToken, http.MethodGet, "/api/flared/config/active", nil)
|
||||
if !activeResp.Success {
|
||||
t.Fatalf("flared get active config failed: %s", activeResp.Message)
|
||||
}
|
||||
var activeConfig service.FlaredTunnelConfigResponse
|
||||
if err := json.Unmarshal(activeResp.Data, &activeConfig); err != nil {
|
||||
t.Fatalf("failed to decode flared active config: %v", err)
|
||||
}
|
||||
if activeConfig.Version == "" || activeConfig.Checksum == "" {
|
||||
t.Fatalf("expected flared active config to return version summary, got %+v", activeConfig)
|
||||
}
|
||||
|
||||
applyResp := performFlaredJSONRequest(t, engine, tunnelNode.AccessToken, http.MethodPost, "/api/flared/apply-log", map[string]any{
|
||||
"version": activeConfig.Version,
|
||||
"result": service.ApplyResultOK,
|
||||
"message": "apply ok",
|
||||
"checksum": activeConfig.Checksum,
|
||||
})
|
||||
if !applyResp.Success {
|
||||
t.Fatalf("flared apply log failed: %s", applyResp.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func performFlaredJSONRequest(t *testing.T, engine http.Handler, token string, method string, path string, body any) apiResponse {
|
||||
t.Helper()
|
||||
var payload []byte
|
||||
if body != nil {
|
||||
var err error
|
||||
payload, err = json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal request body: %v", err)
|
||||
}
|
||||
}
|
||||
req := httptest.NewRequest(method, path, bytes.NewReader(payload))
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("X-Tunnel-Token", token)
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, req)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status %d for %s %s: %s", recorder.Code, method, path, recorder.Body.String())
|
||||
}
|
||||
var resp apiResponse
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Fatalf("request %s %s failed: %s", method, path, resp.Message)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
@@ -363,19 +363,19 @@ func TestPhase1HTTPSAndCertificateImportLifecycle(t *testing.T) {
|
||||
t.Fatal("expected support files json to contain certificate artifacts")
|
||||
}
|
||||
if err := (&model.Node{
|
||||
NodeID: "phase1-node",
|
||||
Name: "phase1-node",
|
||||
IP: "10.0.0.8",
|
||||
AgentToken: common.AgentToken,
|
||||
AgentVersion: "0.1.0",
|
||||
NginxVersion: "1.25.5",
|
||||
Status: service.NodeStatusOnline,
|
||||
LastSeenAt: time.Now(),
|
||||
NodeID: "phase1-node",
|
||||
Name: "phase1-node",
|
||||
IP: "10.0.0.8",
|
||||
AccessToken: common.AccessToken,
|
||||
Version: "0.1.0",
|
||||
ExtVersion: "1.25.5",
|
||||
Status: service.NodeStatusOnline,
|
||||
LastSeenAt: time.Now(),
|
||||
}).Insert(); err != nil {
|
||||
t.Fatalf("failed to seed phase1 node: %v", err)
|
||||
}
|
||||
|
||||
agentResp := performAgentJSONRequestWithToken(t, engine, common.AgentToken, http.MethodGet, "/api/agent/config-versions/active", nil)
|
||||
agentResp := performAgentJSONRequestWithToken(t, engine, common.AccessToken, http.MethodGet, "/api/agent/config-versions/active", nil)
|
||||
var activeConfig map[string]any
|
||||
decodeResponseData(t, agentResp, &activeConfig)
|
||||
sourceConfigJSON, ok := activeConfig["source_config_json"].(string)
|
||||
@@ -481,7 +481,7 @@ func setupTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "phase1.db")
|
||||
common.SQLitePath = dbPath
|
||||
common.AgentToken = "phase1-agent-token"
|
||||
common.AccessToken = "phase1-agent-token"
|
||||
if err := model.InitDB(); err != nil {
|
||||
t.Fatalf("failed to init db: %v", err)
|
||||
}
|
||||
|
||||
@@ -426,7 +426,7 @@ func TestPhase2AgentLifecycle(t *testing.T) {
|
||||
})
|
||||
var createdNode service.NodeView
|
||||
decodeResponseData(t, createdNodeResp, &createdNode)
|
||||
if createdNode.AgentToken == "" || createdNode.Status != service.NodeStatusPending {
|
||||
if createdNode.AccessToken == "" || createdNode.Status != service.NodeStatusPending {
|
||||
t.Fatal("expected created node to expose agent token with pending status")
|
||||
}
|
||||
if createdNode.GeoName != "Shanghai" || createdNode.GeoLatitude == nil || createdNode.GeoLongitude == nil {
|
||||
@@ -437,31 +437,31 @@ func TestPhase2AgentLifecycle(t *testing.T) {
|
||||
"node_id": "spoofed-node-id",
|
||||
"name": "shanghai-edge-1",
|
||||
"ip": "10.0.0.9",
|
||||
"agent_version": "0.1.1",
|
||||
"nginx_version": "1.27.1.2",
|
||||
"version": "0.1.1",
|
||||
"ext_version": "1.27.1.2",
|
||||
"openresty_status": service.OpenrestyStatusUnhealthy,
|
||||
"openresty_message": "docker run openresty failed: bind 80 already allocated",
|
||||
"current_version": "",
|
||||
"last_error": "",
|
||||
}
|
||||
resp := performAgentJSONRequestWithTokenAndRemote(t, engine, createdNode.AgentToken, http.MethodPost, "/api/agent/nodes/heartbeat", heartbeatPayload, "198.51.100.10:1234")
|
||||
resp := performAgentJSONRequestWithTokenAndRemote(t, engine, createdNode.AccessToken, http.MethodPost, "/api/agent/nodes/heartbeat", heartbeatPayload, "198.51.100.10:1234")
|
||||
var registeredNode model.Node
|
||||
decodeResponseData(t, resp, ®isteredNode)
|
||||
if registeredNode.IP != "198.51.100.10" || registeredNode.AgentVersion != "0.1.1" || registeredNode.NodeID != createdNode.NodeID {
|
||||
if registeredNode.IP != "198.51.100.10" || registeredNode.Version != "0.1.1" || registeredNode.NodeID != createdNode.NodeID {
|
||||
t.Fatal("expected heartbeat to update node metadata")
|
||||
}
|
||||
if registeredNode.OpenrestyStatus != service.OpenrestyStatusUnhealthy {
|
||||
t.Fatal("expected heartbeat to update openresty status")
|
||||
}
|
||||
|
||||
activeConfigResp := performAgentJSONRequestWithToken(t, engine, createdNode.AgentToken, http.MethodGet, "/api/agent/config-versions/active", nil)
|
||||
activeConfigResp := performAgentJSONRequestWithToken(t, engine, createdNode.AccessToken, http.MethodGet, "/api/agent/config-versions/active", nil)
|
||||
var activeConfig service.AgentConfigResponse
|
||||
decodeResponseData(t, activeConfigResp, &activeConfig)
|
||||
if activeConfig.Version == "" || activeConfig.SourceConfigJSON == "" || activeConfig.Checksum == "" {
|
||||
t.Fatal("expected active config response to contain version payload")
|
||||
}
|
||||
|
||||
successApplyResp := performAgentJSONRequestWithToken(t, engine, createdNode.AgentToken, http.MethodPost, "/api/agent/apply-logs", map[string]any{
|
||||
successApplyResp := performAgentJSONRequestWithToken(t, engine, createdNode.AccessToken, http.MethodPost, "/api/agent/apply-logs", map[string]any{
|
||||
"node_id": "spoofed-node-id",
|
||||
"version": activeConfig.Version,
|
||||
"result": service.ApplyResultOK,
|
||||
@@ -473,7 +473,7 @@ func TestPhase2AgentLifecycle(t *testing.T) {
|
||||
t.Fatal("expected apply log success to be recorded")
|
||||
}
|
||||
|
||||
failedApplyResp := performAgentJSONRequestWithToken(t, engine, createdNode.AgentToken, http.MethodPost, "/api/agent/apply-logs", map[string]any{
|
||||
failedApplyResp := performAgentJSONRequestWithToken(t, engine, createdNode.AccessToken, http.MethodPost, "/api/agent/apply-logs", map[string]any{
|
||||
"node_id": "spoofed-node-id",
|
||||
"version": activeConfig.Version,
|
||||
"result": service.ApplyResultFailed,
|
||||
@@ -494,7 +494,7 @@ func TestPhase2AgentLifecycle(t *testing.T) {
|
||||
if nodes[0].Status != service.NodeStatusOnline {
|
||||
t.Fatal("expected registered node to become online")
|
||||
}
|
||||
if nodes[0].AgentToken != createdNode.AgentToken {
|
||||
if nodes[0].AccessToken != createdNode.AccessToken {
|
||||
t.Fatal("expected node auth token to remain stable after occupancy")
|
||||
}
|
||||
if nodes[0].LatestApplyResult != service.ApplyResultFailed || nodes[0].LatestApplyMessage != "openresty reload failed" {
|
||||
@@ -561,7 +561,7 @@ func TestPhase2AgentLifecycle(t *testing.T) {
|
||||
}
|
||||
restartHeartbeatReq := httptest.NewRequest(http.MethodPost, "/api/agent/nodes/heartbeat", bytes.NewReader(rawHeartbeatPayload))
|
||||
restartHeartbeatReq.Header.Set("Content-Type", "application/json")
|
||||
restartHeartbeatReq.Header.Set("X-Agent-Token", createdNode.AgentToken)
|
||||
restartHeartbeatReq.Header.Set("X-Agent-Token", createdNode.AccessToken)
|
||||
restartHeartbeatReq.RemoteAddr = "198.51.100.10:1234"
|
||||
restartHeartbeatRecorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(restartHeartbeatRecorder, restartHeartbeatReq)
|
||||
@@ -631,7 +631,7 @@ func TestPhase2AgentLifecycle(t *testing.T) {
|
||||
if logs.Total != 0 || len(logs.Rows) != 0 || logs.Current != 1 || logs.TotalPage != 0 {
|
||||
t.Fatalf("expected empty apply log page after delete-all cleanup, got %+v", logs)
|
||||
}
|
||||
postDeleteApplyResp := performAgentJSONRequestWithToken(t, engine, createdNode.AgentToken, http.MethodPost, "/api/agent/apply-logs", map[string]any{
|
||||
postDeleteApplyResp := performAgentJSONRequestWithToken(t, engine, createdNode.AccessToken, http.MethodPost, "/api/agent/apply-logs", map[string]any{
|
||||
"version": activeConfig.Version,
|
||||
"result": service.ApplyResultOK,
|
||||
"message": "local config already matches active version; apply skipped",
|
||||
@@ -678,9 +678,9 @@ func TestPhase2AgentLifecycle(t *testing.T) {
|
||||
t.Fatalf("expected delete node success, got %s", deleteResp.Message)
|
||||
}
|
||||
|
||||
deniedReq := httptest.NewRequest(http.MethodPost, "/api/agent/nodes/heartbeat", bytes.NewReader([]byte(`{"ip":"10.0.0.9","agent_version":"0.1.1"}`)))
|
||||
deniedReq := httptest.NewRequest(http.MethodPost, "/api/agent/nodes/heartbeat", bytes.NewReader([]byte(`{"ip":"10.0.0.9","version":"0.1.1"}`)))
|
||||
deniedReq.Header.Set("Content-Type", "application/json")
|
||||
deniedReq.Header.Set("X-Agent-Token", createdNode.AgentToken)
|
||||
deniedReq.Header.Set("X-Agent-Token", createdNode.AccessToken)
|
||||
deniedRecorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(deniedRecorder, deniedReq)
|
||||
if deniedRecorder.Code != http.StatusUnauthorized {
|
||||
@@ -853,14 +853,14 @@ func TestPhase2GlobalDiscoveryRegistration(t *testing.T) {
|
||||
"node_id": "local-node-id",
|
||||
"name": "bulk-edge-1",
|
||||
"ip": "10.0.0.18",
|
||||
"agent_version": "0.2.0",
|
||||
"nginx_version": "1.25.5",
|
||||
"version": "0.2.0",
|
||||
"ext_version": "1.25.5",
|
||||
"current_version": "",
|
||||
"last_error": "",
|
||||
}, "203.0.113.18:4321")
|
||||
var registration service.AgentRegistrationResponse
|
||||
decodeResponseData(t, resp, ®istration)
|
||||
if registration.AgentToken == "" || registration.NodeID == "" {
|
||||
if registration.AccessToken == "" || registration.NodeID == "" {
|
||||
t.Fatal("expected discovery registration to issue node-specific agent token")
|
||||
}
|
||||
|
||||
@@ -870,7 +870,7 @@ func TestPhase2GlobalDiscoveryRegistration(t *testing.T) {
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("expected 1 discovered node, got %d", len(nodes))
|
||||
}
|
||||
if nodes[0].Name != "bulk-edge-1" || nodes[0].AgentToken != registration.AgentToken || nodes[0].Status != service.NodeStatusOnline {
|
||||
if nodes[0].Name != "bulk-edge-1" || nodes[0].AccessToken != registration.AccessToken || nodes[0].Status != service.NodeStatusOnline {
|
||||
t.Fatal("expected discovered node to be created online with issued agent token")
|
||||
}
|
||||
if nodes[0].IP != "203.0.113.18" {
|
||||
|
||||
@@ -29,18 +29,20 @@ type AgentNodePayload 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 *AgentNodeSystemProfile `json:"profile,omitempty"`
|
||||
Snapshot *AgentNodeMetricSnapshot `json:"snapshot,omitempty"`
|
||||
OpenrestyObservation *AgentNodeOpenrestyObservation `json:"openresty_observation,omitempty"`
|
||||
TrafficReport *AgentNodeTrafficReport `json:"traffic_report,omitempty"`
|
||||
AccessLogs []AgentNodeAccessLog `json:"access_logs,omitempty"`
|
||||
BufferedObservability []AgentBufferedObservabilityRecord `json:"buffered_observability,omitempty"`
|
||||
HealthEvents []AgentNodeHealthEvent `json:"health_events"`
|
||||
WAFIPGroupChecksums map[string]string `json:"waf_ip_group_checksums,omitempty"`
|
||||
}
|
||||
|
||||
type ApplyLogPayload struct {
|
||||
@@ -107,6 +109,25 @@ type HeartbeatResponse struct {
|
||||
Node *model.Node `json:"node"`
|
||||
AgentSettings *AgentSettings `json:"agent_settings"`
|
||||
ActiveConfig *ActiveConfigMeta `json:"active_config"`
|
||||
WAFIPGroups []AgentWAFIPGroup `json:"waf_ip_groups,omitempty"`
|
||||
}
|
||||
|
||||
type AgentWAFIPGroup 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 AgentWAFIPGroupSyncInput struct {
|
||||
IDs []uint `json:"ids"`
|
||||
Checksums map[string]string `json:"checksums"`
|
||||
}
|
||||
|
||||
type AgentWAFIPGroupSyncResult struct {
|
||||
Groups []AgentWAFIPGroup `json:"groups"`
|
||||
}
|
||||
|
||||
type NodeView struct {
|
||||
@@ -119,14 +140,14 @@ type NodeView struct {
|
||||
GeoLatitude *float64 `json:"geo_latitude"`
|
||||
GeoLongitude *float64 `json:"geo_longitude"`
|
||||
GeoManualOverride bool `json:"geo_manual_override"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
AccessToken string `json:"access_token"`
|
||||
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
||||
UpdateRequested bool `json:"update_requested"`
|
||||
UpdateChannel string `json:"update_channel"`
|
||||
UpdateTag string `json:"update_tag"`
|
||||
RestartOpenrestyRequested bool `json:"restart_openresty_requested"`
|
||||
AgentVersion string `json:"agent_version"`
|
||||
NginxVersion string `json:"nginx_version"`
|
||||
Version string `json:"version"`
|
||||
ExtVersion string `json:"ext_version"`
|
||||
OpenrestyStatus string `json:"openresty_status"`
|
||||
OpenrestyMessage string `json:"openresty_message"`
|
||||
Status string `json:"status"`
|
||||
@@ -142,6 +163,14 @@ type NodeView struct {
|
||||
LatestApplyAt *time.Time `json:"latest_apply_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// TunnelRelay fields
|
||||
NodeType string `json:"node_type"`
|
||||
RelayBindPort int `json:"relay_bind_port"`
|
||||
RelayVhostHTTPPort int `json:"relay_vhost_http_port"`
|
||||
RelayAgentAccessAddr string `json:"relay_agent_access_addr"`
|
||||
RelayClientAccessAddr string `json:"relay_client_access_addr"`
|
||||
RelayClientProxyURL string `json:"relay_client_proxy_url"`
|
||||
RelayStatus string `json:"relay_status"`
|
||||
}
|
||||
|
||||
func HeartbeatNode(node *model.Node, payload AgentNodePayload) (*HeartbeatResponse, error) {
|
||||
@@ -167,16 +196,21 @@ func HeartbeatNode(node *model.Node, payload AgentNodePayload) (*HeartbeatRespon
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
refreshAgentTokenCache(node)
|
||||
refreshAccessTokenCache(node)
|
||||
persistHeartbeatObservability(node.NodeID, payload, node.LastSeenAt)
|
||||
activeConfig, err := GetActiveConfigMetaForAgent()
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
wafIPGroups, err := ChangedWAFIPGroupsForAgent(nil, payload.WAFIPGroupChecksums)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &HeartbeatResponse{
|
||||
Node: node,
|
||||
AgentSettings: buildAgentSettings(node, updateNow, updateChannel.String(), updateTag, restartOpenrestyNow),
|
||||
ActiveConfig: activeConfig,
|
||||
WAFIPGroups: wafIPGroups,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -431,6 +465,12 @@ func computeNodeStatus(node *model.Node) string {
|
||||
if node == nil {
|
||||
return NodeStatusOffline
|
||||
}
|
||||
if node.NodeType == "tunnel_relay" && IsRelayWSConnected(node.NodeID) {
|
||||
return NodeStatusOnline
|
||||
}
|
||||
if node.NodeType == "tunnel_client" && IsFlaredWSConnected(node.NodeID) {
|
||||
return NodeStatusOnline
|
||||
}
|
||||
if IsAgentWSConnected(node.NodeID) {
|
||||
return NodeStatusOnline
|
||||
}
|
||||
@@ -456,8 +496,8 @@ func collectNodeHeartbeatChanges(previous *model.Node, current *model.Node) map[
|
||||
appendIfChanged("name", previous.Name, current.Name)
|
||||
appendIfChanged("ip", previous.IP, current.IP)
|
||||
appendIfChanged("geo_name", previous.GeoName, current.GeoName)
|
||||
appendIfChanged("agent_version", previous.AgentVersion, current.AgentVersion)
|
||||
appendIfChanged("nginx_version", previous.NginxVersion, current.NginxVersion)
|
||||
appendIfChanged("version", previous.Version, current.Version)
|
||||
appendIfChanged("ext_version", previous.ExtVersion, current.ExtVersion)
|
||||
appendIfChanged("openresty_status", previous.OpenrestyStatus, current.OpenrestyStatus)
|
||||
appendIfChanged("openresty_message", previous.OpenrestyMessage, current.OpenrestyMessage)
|
||||
appendIfChanged("status", previous.Status, current.Status)
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
"openflare/model"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -74,6 +75,75 @@ func TestGetActiveConfigForAgentIncludesWAFConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangedWAFIPGroupsForAgentReturnsChecksumDelta(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
route, err := CreateProxyRoute(ProxyRouteInput{
|
||||
SiteName: "agent-waf-ip-group",
|
||||
Domains: []string{"agent-waf-ip-group.example.com"},
|
||||
OriginURL: "https://origin.internal",
|
||||
Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProxyRoute failed: %v", err)
|
||||
}
|
||||
ipGroup, err := CreateWAFIPGroup(WAFIPGroupInput{
|
||||
Name: "agent runtime group",
|
||||
Type: WAFIPGroupTypeManual,
|
||||
Enabled: true,
|
||||
IPList: []string{"203.0.113.44"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWAFIPGroup failed: %v", err)
|
||||
}
|
||||
ruleGroup, err := CreateWAFRuleGroup(WAFRuleGroupInput{
|
||||
Name: "agent refs",
|
||||
Enabled: true,
|
||||
IPBlacklistGroups: []uint{ipGroup.ID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWAFRuleGroup failed: %v", err)
|
||||
}
|
||||
if _, err = ReplaceWAFSiteRuleGroups(route.ID, []uint{ruleGroup.ID}); err != nil {
|
||||
t.Fatalf("ReplaceWAFSiteRuleGroups failed: %v", err)
|
||||
}
|
||||
if _, err = PublishConfigVersion("root", false); err != nil {
|
||||
t.Fatalf("PublishConfigVersion failed: %v", err)
|
||||
}
|
||||
|
||||
groups, err := ChangedWAFIPGroupsForAgent(nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ChangedWAFIPGroupsForAgent failed: %v", err)
|
||||
}
|
||||
if len(groups) != 1 || groups[0].ID != ipGroup.ID || groups[0].IPList[0] != "203.0.113.44" || groups[0].Checksum == "" {
|
||||
t.Fatalf("unexpected changed groups: %#v", groups)
|
||||
}
|
||||
groupKey := strconv.FormatUint(uint64(ipGroup.ID), 10)
|
||||
same, err := ChangedWAFIPGroupsForAgent(nil, map[string]string{groupKey: groups[0].Checksum})
|
||||
if err != nil {
|
||||
t.Fatalf("ChangedWAFIPGroupsForAgent with checksum failed: %v", err)
|
||||
}
|
||||
if len(same) != 0 {
|
||||
t.Fatalf("expected no delta for matching checksum, got %#v", same)
|
||||
}
|
||||
updated, err := UpdateWAFIPGroup(ipGroup.ID, WAFIPGroupInput{
|
||||
Name: "agent runtime group",
|
||||
Type: WAFIPGroupTypeManual,
|
||||
Enabled: true,
|
||||
IPList: []string{"203.0.113.45"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateWAFIPGroup failed: %v", err)
|
||||
}
|
||||
delta, err := ChangedWAFIPGroupsForAgent(nil, map[string]string{groupKey: groups[0].Checksum})
|
||||
if err != nil {
|
||||
t.Fatalf("ChangedWAFIPGroupsForAgent after update failed: %v", err)
|
||||
}
|
||||
if len(delta) != 1 || delta[0].ID != updated.ID || delta[0].IPList[0] != "203.0.113.45" || delta[0].Checksum == groups[0].Checksum {
|
||||
t.Fatalf("expected updated group delta, got %#v", delta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetActiveConfigForAgentUsesTenMinutePoWSessionDefault(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
@@ -107,7 +177,7 @@ func TestGetActiveConfigForAgentUsesTenMinutePoWSessionDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterNodeWithAgentToken(t *testing.T) {
|
||||
func TestRegisterNodeWithAccessToken(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
// 1. Success path
|
||||
@@ -133,17 +203,17 @@ func TestRegisterNodeWithAgentToken(t *testing.T) {
|
||||
payload := AgentNodePayload{
|
||||
Name: "payload-name-should-be-ignored",
|
||||
IP: "192.168.1.20",
|
||||
AgentVersion: "v1.0.1",
|
||||
NginxVersion: "1.27.1.3",
|
||||
Version: "v1.0.1",
|
||||
ExtVersion: "1.27.1.3",
|
||||
OpenrestyStatus: "healthy",
|
||||
}
|
||||
|
||||
resp, err := RegisterNodeWithAgentToken(stored, payload)
|
||||
resp, err := RegisterNodeWithAccessToken(stored, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterNodeWithAgentToken failed: %v", err)
|
||||
t.Fatalf("RegisterNodeWithAccessToken failed: %v", err)
|
||||
}
|
||||
|
||||
if resp.NodeID != stored.NodeID || resp.AgentToken != stored.AgentToken || resp.Name != "reserved-node-1" {
|
||||
if resp.NodeID != stored.NodeID || resp.AccessToken != stored.AccessToken || resp.Name != "reserved-node-1" {
|
||||
t.Errorf("unexpected response: %+v", resp)
|
||||
}
|
||||
|
||||
@@ -152,7 +222,7 @@ func TestRegisterNodeWithAgentToken(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to fetch updated node: %v", err)
|
||||
}
|
||||
if updated.AgentVersion != "v1.0.1" || updated.NginxVersion != "1.27.1.3" || updated.OpenrestyStatus != "healthy" {
|
||||
if updated.Version != "v1.0.1" || updated.ExtVersion != "1.27.1.3" || updated.OpenrestyStatus != "healthy" {
|
||||
t.Errorf("node attributes were not updated: %+v", updated)
|
||||
}
|
||||
// Name should be preserved since preserveName is true
|
||||
@@ -161,7 +231,7 @@ func TestRegisterNodeWithAgentToken(t *testing.T) {
|
||||
}
|
||||
|
||||
// 2. Fail path - Nil Node
|
||||
_, err = RegisterNodeWithAgentToken(nil, payload)
|
||||
_, err = RegisterNodeWithAccessToken(nil, payload)
|
||||
if err == nil || !strings.Contains(err.Error(), "节点不存在") {
|
||||
t.Errorf("expected error '节点不存在', got %v", err)
|
||||
}
|
||||
@@ -169,16 +239,16 @@ func TestRegisterNodeWithAgentToken(t *testing.T) {
|
||||
// 3. Fail path - Invalid Payload (empty IP)
|
||||
badPayload := payload
|
||||
badPayload.IP = ""
|
||||
_, err = RegisterNodeWithAgentToken(stored, badPayload)
|
||||
_, err = RegisterNodeWithAccessToken(stored, badPayload)
|
||||
if err == nil || !strings.Contains(err.Error(), "ip 不能为空") {
|
||||
t.Errorf("expected error 'ip 不能为空', got %v", err)
|
||||
}
|
||||
|
||||
// 4. Name update if empty
|
||||
emptyNameNode := &model.Node{
|
||||
NodeID: "node-empty-name",
|
||||
Name: "",
|
||||
AgentToken: "empty-name-token",
|
||||
NodeID: "node-empty-name",
|
||||
Name: "",
|
||||
AccessToken: "empty-name-token",
|
||||
}
|
||||
if err := emptyNameNode.Insert(); err != nil {
|
||||
t.Fatalf("failed to insert emptyNameNode: %v", err)
|
||||
@@ -186,9 +256,9 @@ func TestRegisterNodeWithAgentToken(t *testing.T) {
|
||||
payloadWithName := payload
|
||||
payloadWithName.Name = "filled-name"
|
||||
payloadWithName.IP = "192.168.1.30"
|
||||
_, err = RegisterNodeWithAgentToken(emptyNameNode, payloadWithName)
|
||||
_, err = RegisterNodeWithAccessToken(emptyNameNode, payloadWithName)
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterNodeWithAgentToken empty name node failed: %v", err)
|
||||
t.Fatalf("RegisterNodeWithAccessToken empty name node failed: %v", err)
|
||||
}
|
||||
updatedEmptyName, err := model.GetNodeByNodeID("node-empty-name")
|
||||
if err != nil {
|
||||
@@ -206,8 +276,8 @@ func TestRegisterNodeWithDiscovery(t *testing.T) {
|
||||
payload := AgentNodePayload{
|
||||
Name: "discovery-node",
|
||||
IP: "192.168.2.10",
|
||||
AgentVersion: "v1.0.0",
|
||||
NginxVersion: "1.27.1.3",
|
||||
Version: "v1.0.0",
|
||||
ExtVersion: "1.27.1.3",
|
||||
OpenrestyStatus: "healthy",
|
||||
}
|
||||
|
||||
@@ -216,7 +286,7 @@ func TestRegisterNodeWithDiscovery(t *testing.T) {
|
||||
t.Fatalf("RegisterNodeWithDiscovery failed: %v", err)
|
||||
}
|
||||
|
||||
if resp.NodeID == "" || resp.AgentToken == "" || resp.Name != "discovery-node" {
|
||||
if resp.NodeID == "" || resp.AccessToken == "" || resp.Name != "discovery-node" {
|
||||
t.Errorf("unexpected response: %+v", resp)
|
||||
}
|
||||
|
||||
@@ -225,7 +295,7 @@ func TestRegisterNodeWithDiscovery(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to fetch node: %v", err)
|
||||
}
|
||||
if node.IP != "192.168.2.10" || node.AgentVersion != "v1.0.0" || node.Name != "discovery-node" {
|
||||
if node.IP != "192.168.2.10" || node.Version != "v1.0.0" || node.Name != "discovery-node" {
|
||||
t.Errorf("unexpected stored node data: %+v", node)
|
||||
}
|
||||
|
||||
@@ -247,10 +317,10 @@ func TestRegisterNodeWithDiscovery(t *testing.T) {
|
||||
|
||||
// 3. Fail path - Invalid Payload (empty AgentVersion)
|
||||
badPayload := payload
|
||||
badPayload.AgentVersion = ""
|
||||
badPayload.Version = ""
|
||||
_, err = RegisterNodeWithDiscovery(badPayload)
|
||||
if err == nil || !strings.Contains(err.Error(), "agent_version 不能为空") {
|
||||
t.Errorf("expected error 'agent_version 不能为空', got %v", err)
|
||||
if err == nil || !strings.Contains(err.Error(), "version 不能为空") {
|
||||
t.Errorf("expected error 'version 不能为空', got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,12 +329,12 @@ func TestReportApplyLog_Success(t *testing.T) {
|
||||
|
||||
// Seed node
|
||||
node := &model.Node{
|
||||
NodeID: "node-apply-1",
|
||||
Name: "apply-edge",
|
||||
IP: "192.168.3.10",
|
||||
AgentToken: "apply-token",
|
||||
AgentVersion: "v1.0.0",
|
||||
Status: NodeStatusOffline,
|
||||
NodeID: "node-apply-1",
|
||||
Name: "apply-edge",
|
||||
IP: "192.168.3.10",
|
||||
AccessToken: "apply-token",
|
||||
Version: "v1.0.0",
|
||||
Status: NodeStatusOffline,
|
||||
}
|
||||
if err := node.Insert(); err != nil {
|
||||
t.Fatalf("failed to insert node: %v", err)
|
||||
@@ -317,8 +387,8 @@ func TestReportApplyLog_WarningAndFailure(t *testing.T) {
|
||||
NodeID: "node-apply-2",
|
||||
Name: "apply-edge-2",
|
||||
IP: "192.168.3.20",
|
||||
AgentToken: "apply-token-2",
|
||||
AgentVersion: "v1.0.0",
|
||||
AccessToken: "apply-token-2",
|
||||
Version: "v1.0.0",
|
||||
CurrentVersion: "20260531-001", // Old version
|
||||
Status: NodeStatusOnline,
|
||||
}
|
||||
@@ -382,12 +452,12 @@ func TestReportApplyLog_Failures(t *testing.T) {
|
||||
|
||||
// Seed node
|
||||
node := &model.Node{
|
||||
NodeID: "node-apply-3",
|
||||
Name: "apply-edge-3",
|
||||
IP: "192.168.3.30",
|
||||
AgentToken: "apply-token-3",
|
||||
AgentVersion: "v1.0.0",
|
||||
Status: NodeStatusOnline,
|
||||
NodeID: "node-apply-3",
|
||||
Name: "apply-edge-3",
|
||||
IP: "192.168.3.30",
|
||||
AccessToken: "apply-token-3",
|
||||
Version: "v1.0.0",
|
||||
Status: NodeStatusOnline,
|
||||
}
|
||||
if err := node.Insert(); err != nil {
|
||||
t.Fatalf("failed to insert node: %v", err)
|
||||
@@ -438,11 +508,11 @@ func TestListAndCleanupApplyLogs(t *testing.T) {
|
||||
|
||||
// Seed node
|
||||
node := &model.Node{
|
||||
NodeID: "node-logs",
|
||||
Name: "logs-edge",
|
||||
IP: "192.168.4.10",
|
||||
AgentToken: "logs-token",
|
||||
Status: NodeStatusOnline,
|
||||
NodeID: "node-logs",
|
||||
Name: "logs-edge",
|
||||
IP: "192.168.4.10",
|
||||
AccessToken: "logs-token",
|
||||
Status: NodeStatusOnline,
|
||||
}
|
||||
if err := node.Insert(); err != nil {
|
||||
t.Fatalf("failed to insert node: %v", err)
|
||||
|
||||
@@ -3,7 +3,6 @@ package service
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -11,6 +10,7 @@ const (
|
||||
AgentWSMessageTypeSettings = "settings"
|
||||
AgentWSMessageTypeActiveConfig = "active_config"
|
||||
AgentWSMessageTypeForceSyncConfig = "force_sync_config"
|
||||
AgentWSMessageTypeWAFIPGroups = "waf_ip_groups"
|
||||
AgentWSMessageTypePing = "ping"
|
||||
AgentWSMessageTypePong = "pong"
|
||||
|
||||
@@ -22,11 +22,6 @@ type AgentWSInboundMessage struct {
|
||||
Payload json.RawMessage `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
type AgentWSOutboundMessage struct {
|
||||
Type string `json:"type"`
|
||||
Payload any `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
type AgentWSBroadcastResult struct {
|
||||
Version string `json:"version"`
|
||||
Checksum string `json:"checksum"`
|
||||
@@ -35,133 +30,29 @@ type AgentWSBroadcastResult struct {
|
||||
FailedNodes []string `json:"failed_nodes"`
|
||||
}
|
||||
|
||||
type AgentWSClient struct {
|
||||
nodeID string
|
||||
send chan AgentWSOutboundMessage
|
||||
done chan struct{}
|
||||
once sync.Once
|
||||
var DefaultAgentWSHub = NewWSHub("agent")
|
||||
|
||||
func RegisterAgentWSClient(nodeID string) *WSClient {
|
||||
return DefaultAgentWSHub.Register(nodeID)
|
||||
}
|
||||
|
||||
func (client *AgentWSClient) NodeID() string {
|
||||
if client == nil {
|
||||
return ""
|
||||
}
|
||||
return client.nodeID
|
||||
}
|
||||
|
||||
func (client *AgentWSClient) Messages() <-chan AgentWSOutboundMessage {
|
||||
if client == nil {
|
||||
return nil
|
||||
}
|
||||
return client.send
|
||||
}
|
||||
|
||||
func (client *AgentWSClient) Done() <-chan struct{} {
|
||||
if client == nil {
|
||||
return nil
|
||||
}
|
||||
return client.done
|
||||
}
|
||||
|
||||
func (client *AgentWSClient) Send(message AgentWSOutboundMessage) bool {
|
||||
if client == nil {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case <-client.done:
|
||||
return false
|
||||
case client.send <- message:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (client *AgentWSClient) Close() {
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
client.once.Do(func() {
|
||||
close(client.done)
|
||||
})
|
||||
}
|
||||
|
||||
type agentWSHub struct {
|
||||
mu sync.RWMutex
|
||||
clients map[string]*AgentWSClient
|
||||
}
|
||||
|
||||
var defaultAgentWSHub = &agentWSHub{
|
||||
clients: make(map[string]*AgentWSClient),
|
||||
}
|
||||
|
||||
func RegisterAgentWSClient(nodeID string) *AgentWSClient {
|
||||
client := &AgentWSClient{
|
||||
nodeID: nodeID,
|
||||
send: make(chan AgentWSOutboundMessage, 16),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
defaultAgentWSHub.mu.Lock()
|
||||
if existing := defaultAgentWSHub.clients[nodeID]; existing != nil {
|
||||
slog.Debug("agent ws replacing existing connection", "node_id", nodeID)
|
||||
existing.Close()
|
||||
}
|
||||
defaultAgentWSHub.clients[nodeID] = client
|
||||
count := len(defaultAgentWSHub.clients)
|
||||
defaultAgentWSHub.mu.Unlock()
|
||||
slog.Debug("agent ws connection registered", "node_id", nodeID, "client_count", count)
|
||||
return client
|
||||
}
|
||||
|
||||
func UnregisterAgentWSClient(client *AgentWSClient) {
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
defaultAgentWSHub.mu.Lock()
|
||||
if current := defaultAgentWSHub.clients[client.nodeID]; current == client {
|
||||
delete(defaultAgentWSHub.clients, client.nodeID)
|
||||
}
|
||||
count := len(defaultAgentWSHub.clients)
|
||||
defaultAgentWSHub.mu.Unlock()
|
||||
client.Close()
|
||||
slog.Debug("agent ws connection unregistered", "node_id", client.nodeID, "client_count", count)
|
||||
func UnregisterAgentWSClient(client *WSClient) {
|
||||
DefaultAgentWSHub.Unregister(client)
|
||||
}
|
||||
|
||||
func DisconnectAgentWSClient(nodeID string) {
|
||||
defaultAgentWSHub.mu.Lock()
|
||||
client := defaultAgentWSHub.clients[nodeID]
|
||||
if client != nil {
|
||||
delete(defaultAgentWSHub.clients, nodeID)
|
||||
}
|
||||
count := len(defaultAgentWSHub.clients)
|
||||
defaultAgentWSHub.mu.Unlock()
|
||||
|
||||
if client != nil {
|
||||
client.Close()
|
||||
slog.Debug("agent ws connection forcefully disconnected", "node_id", nodeID, "client_count", count)
|
||||
}
|
||||
DefaultAgentWSHub.Disconnect(nodeID)
|
||||
}
|
||||
|
||||
func IsAgentWSConnected(nodeID string) bool {
|
||||
defaultAgentWSHub.mu.RLock()
|
||||
client := defaultAgentWSHub.clients[nodeID]
|
||||
defaultAgentWSHub.mu.RUnlock()
|
||||
if client == nil {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case <-client.done:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
return DefaultAgentWSHub.IsConnected(nodeID)
|
||||
}
|
||||
|
||||
func SendAgentWSSettings(nodeID string, settings *AgentSettings) bool {
|
||||
if settings == nil {
|
||||
return false
|
||||
}
|
||||
return sendAgentWSMessage(nodeID, AgentWSOutboundMessage{
|
||||
return DefaultAgentWSHub.SendMessage(nodeID, WSMessage{
|
||||
Type: AgentWSMessageTypeSettings,
|
||||
Payload: settings,
|
||||
})
|
||||
@@ -171,7 +62,7 @@ func SendAgentWSActiveConfig(nodeID string, activeConfig *ActiveConfigMeta) bool
|
||||
if activeConfig == nil {
|
||||
return false
|
||||
}
|
||||
return sendAgentWSMessage(nodeID, AgentWSOutboundMessage{
|
||||
return DefaultAgentWSHub.SendMessage(nodeID, WSMessage{
|
||||
Type: AgentWSMessageTypeActiveConfig,
|
||||
Payload: activeConfig,
|
||||
})
|
||||
@@ -181,60 +72,47 @@ func SendAgentWSForceSyncConfig(nodeID string, activeConfig *ActiveConfigMeta) b
|
||||
if activeConfig == nil {
|
||||
return false
|
||||
}
|
||||
return sendAgentWSMessage(nodeID, AgentWSOutboundMessage{
|
||||
return DefaultAgentWSHub.SendMessage(nodeID, WSMessage{
|
||||
Type: AgentWSMessageTypeForceSyncConfig,
|
||||
Payload: activeConfig,
|
||||
})
|
||||
}
|
||||
|
||||
func SendAgentWSWAFIPGroups(nodeID string, groups []AgentWAFIPGroup) bool {
|
||||
if len(groups) == 0 {
|
||||
return false
|
||||
}
|
||||
return DefaultAgentWSHub.SendMessage(nodeID, WSMessage{
|
||||
Type: AgentWSMessageTypeWAFIPGroups,
|
||||
Payload: groups,
|
||||
})
|
||||
}
|
||||
|
||||
func SendAgentWSPong(nodeID string) bool {
|
||||
return sendAgentWSMessage(nodeID, AgentWSOutboundMessage{
|
||||
return DefaultAgentWSHub.SendMessage(nodeID, WSMessage{
|
||||
Type: AgentWSMessageTypePong,
|
||||
})
|
||||
}
|
||||
|
||||
func sendAgentWSMessage(nodeID string, message AgentWSOutboundMessage) bool {
|
||||
defaultAgentWSHub.mu.RLock()
|
||||
client := defaultAgentWSHub.clients[nodeID]
|
||||
defaultAgentWSHub.mu.RUnlock()
|
||||
if client == nil {
|
||||
return false
|
||||
}
|
||||
ok := client.Send(message)
|
||||
if !ok {
|
||||
slog.Debug("agent ws send queued message failed", "node_id", nodeID, "type", message.Type)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
func BroadcastAgentWSActiveConfig(activeConfig *ActiveConfigMeta) AgentWSBroadcastResult {
|
||||
result := AgentWSBroadcastResult{}
|
||||
if activeConfig == nil {
|
||||
slog.Debug("agent ws broadcast skipped because active config is nil")
|
||||
return result
|
||||
return AgentWSBroadcastResult{}
|
||||
}
|
||||
result.Version = activeConfig.Version
|
||||
result.Checksum = activeConfig.Checksum
|
||||
|
||||
defaultAgentWSHub.mu.RLock()
|
||||
clients := make([]*AgentWSClient, 0, len(defaultAgentWSHub.clients))
|
||||
for _, client := range defaultAgentWSHub.clients {
|
||||
clients = append(clients, client)
|
||||
}
|
||||
defaultAgentWSHub.mu.RUnlock()
|
||||
|
||||
result.ClientCount = len(clients)
|
||||
message := AgentWSOutboundMessage{
|
||||
res := DefaultAgentWSHub.Broadcast(WSMessage{
|
||||
Type: AgentWSMessageTypeActiveConfig,
|
||||
Payload: activeConfig,
|
||||
})
|
||||
|
||||
result := AgentWSBroadcastResult{
|
||||
Version: activeConfig.Version,
|
||||
Checksum: activeConfig.Checksum,
|
||||
ClientCount: res.ClientCount,
|
||||
SuccessCount: res.SuccessCount,
|
||||
FailedNodes: res.FailedIDs,
|
||||
}
|
||||
for _, client := range clients {
|
||||
if client.Send(message) {
|
||||
result.SuccessCount++
|
||||
continue
|
||||
}
|
||||
result.FailedNodes = append(result.FailedNodes, client.NodeID())
|
||||
}
|
||||
|
||||
slog.Debug("agent ws broadcast active config",
|
||||
"version", result.Version,
|
||||
"checksum", result.Checksum,
|
||||
@@ -244,3 +122,20 @@ func BroadcastAgentWSActiveConfig(activeConfig *ActiveConfigMeta) AgentWSBroadca
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
func BroadcastAgentWSWAFIPGroups(groups []AgentWAFIPGroup) WSBroadcastResult {
|
||||
if len(groups) == 0 {
|
||||
return WSBroadcastResult{}
|
||||
}
|
||||
result := DefaultAgentWSHub.Broadcast(WSMessage{
|
||||
Type: AgentWSMessageTypeWAFIPGroups,
|
||||
Payload: groups,
|
||||
})
|
||||
slog.Debug("agent ws broadcast waf ip groups",
|
||||
"group_count", len(groups),
|
||||
"client_count", result.ClientCount,
|
||||
"success_count", result.SuccessCount,
|
||||
"failed_nodes", result.FailedIDs,
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -89,6 +89,10 @@ type snapshotRoute struct {
|
||||
BasicAuthUsername string `json:"basic_auth_username,omitempty"`
|
||||
BasicAuthPassword string `json:"basic_auth_password,omitempty"`
|
||||
Remark string `json:"remark,omitempty"`
|
||||
UpstreamType string `json:"upstream_type,omitempty"`
|
||||
TunnelNodeID *uint `json:"tunnel_node_id,omitempty"`
|
||||
TunnelTargetAddr string `json:"tunnel_target_addr,omitempty"`
|
||||
TunnelTargetProto string `json:"tunnel_target_protocol,omitempty"`
|
||||
}
|
||||
|
||||
type snapshotWAFRuleGroup struct {
|
||||
@@ -100,6 +104,8 @@ type snapshotWAFRuleGroup struct {
|
||||
BlockResponseBody string `json:"block_response_body,omitempty"`
|
||||
IPWhitelist []string `json:"ip_whitelist,omitempty"`
|
||||
IPBlacklist []string `json:"ip_blacklist,omitempty"`
|
||||
IPWhitelistGroups []uint `json:"ip_whitelist_group_ids,omitempty"`
|
||||
IPBlacklistGroups []uint `json:"ip_blacklist_group_ids,omitempty"`
|
||||
CountryWhitelist []string `json:"country_whitelist,omitempty"`
|
||||
CountryBlacklist []string `json:"country_blacklist,omitempty"`
|
||||
RegionWhitelist []string `json:"region_whitelist,omitempty"`
|
||||
@@ -108,6 +114,14 @@ type snapshotWAFRuleGroup struct {
|
||||
PoWConfig *ProxyRoutePoWConfig `json:"pow_config,omitempty"`
|
||||
}
|
||||
|
||||
type snapshotWAFIPGroup struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Enabled bool `json:"enabled"`
|
||||
IPList []string `json:"ip_list,omitempty"`
|
||||
}
|
||||
|
||||
type snapshotWAFBinding struct {
|
||||
RouteID uint `json:"route_id"`
|
||||
SiteName string `json:"site_name"`
|
||||
@@ -116,6 +130,7 @@ type snapshotWAFBinding struct {
|
||||
|
||||
type snapshotWAFDocument struct {
|
||||
RuleGroups []snapshotWAFRuleGroup `json:"rule_groups"`
|
||||
IPGroups []snapshotWAFIPGroup `json:"ip_groups,omitempty"`
|
||||
Bindings []snapshotWAFBinding `json:"bindings"`
|
||||
}
|
||||
|
||||
@@ -340,10 +355,12 @@ func PublishConfigVersion(createdBy string, force bool) (*ReleaseResult, error)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
BroadcastAgentWSActiveConfig(&ActiveConfigMeta{
|
||||
activeConfig := &ActiveConfigMeta{
|
||||
Version: record.Version,
|
||||
Checksum: record.Checksum,
|
||||
})
|
||||
}
|
||||
BroadcastAgentWSActiveConfig(activeConfig)
|
||||
BroadcastFlaredWSActiveConfig(activeConfig)
|
||||
return &ReleaseResult{
|
||||
Version: record,
|
||||
Routes: bundle.Routes,
|
||||
@@ -391,10 +408,12 @@ func ActivateConfigVersion(id uint) (*model.ConfigVersion, error) {
|
||||
return nil, err
|
||||
}
|
||||
version.IsActive = true
|
||||
BroadcastAgentWSActiveConfig(&ActiveConfigMeta{
|
||||
activeConfig := &ActiveConfigMeta{
|
||||
Version: version.Version,
|
||||
Checksum: version.Checksum,
|
||||
})
|
||||
}
|
||||
BroadcastAgentWSActiveConfig(activeConfig)
|
||||
BroadcastFlaredWSActiveConfig(activeConfig)
|
||||
return version, nil
|
||||
}
|
||||
|
||||
@@ -485,10 +504,22 @@ func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("路由 %s 自定义请求头无效", route.Domain)
|
||||
}
|
||||
upstreamType := normalizeUpstreamType(route.UpstreamType)
|
||||
originURL := route.OriginURL
|
||||
upstreams, err := decodeStoredUpstreams(route.Upstreams, route.OriginURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("路由 %s 上游配置无效", route.Domain)
|
||||
}
|
||||
var tunnelNodeID *uint
|
||||
var tunnelTargetAddr string
|
||||
var tunnelTargetProtocol string
|
||||
if upstreamType == "tunnel" {
|
||||
originURL = resolveTunnelOpenRestyUpstreamURL()
|
||||
upstreams = []string{originURL}
|
||||
tunnelNodeID = route.TunnelNodeID
|
||||
tunnelTargetAddr = strings.TrimSpace(route.TunnelTargetAddr)
|
||||
tunnelTargetProtocol = normalizeTunnelTargetProtocol(route.TunnelTargetProtocol)
|
||||
}
|
||||
cacheRules, err := decodeStoredCacheRules(route.CacheRules)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("路由 %s 缓存规则无效", route.Domain)
|
||||
@@ -505,7 +536,7 @@ func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) {
|
||||
SiteName: normalizeProxyRouteSiteNameInput(route, route.SiteName, domains[0]),
|
||||
Domain: domains[0],
|
||||
Domains: domains,
|
||||
OriginURL: route.OriginURL,
|
||||
OriginURL: originURL,
|
||||
OriginHost: route.OriginHost,
|
||||
Upstreams: upstreams,
|
||||
Enabled: route.Enabled,
|
||||
@@ -527,11 +558,30 @@ func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) {
|
||||
BasicAuthUsername: route.BasicAuthUsername,
|
||||
BasicAuthPassword: route.BasicAuthPassword,
|
||||
Remark: route.Remark,
|
||||
UpstreamType: upstreamType,
|
||||
TunnelNodeID: tunnelNodeID,
|
||||
TunnelTargetAddr: tunnelTargetAddr,
|
||||
TunnelTargetProto: tunnelTargetProtocol,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func resolveTunnelOpenRestyUpstreamURL() string {
|
||||
relayNodes, err := model.ListNodesByType("tunnel_relay")
|
||||
if err == nil && len(relayNodes) > 0 {
|
||||
for _, node := range relayNodes {
|
||||
if node != nil {
|
||||
addr := relayAgentAddress(node)
|
||||
if addr != "" {
|
||||
return "http://" + addr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "http://127.0.0.1:8080"
|
||||
}
|
||||
|
||||
func buildSnapshotWAFDocument(routes []*model.ProxyRoute) (snapshotWAFDocument, error) {
|
||||
if err := EnsureDefaultWAFRuleGroup(); err != nil {
|
||||
return snapshotWAFDocument{}, err
|
||||
@@ -554,6 +604,8 @@ func buildSnapshotWAFDocument(routes []*model.ProxyRoute) (snapshotWAFDocument,
|
||||
BlockResponseBody: view.BlockResponseBody,
|
||||
IPWhitelist: view.IPWhitelist,
|
||||
IPBlacklist: view.IPBlacklist,
|
||||
IPWhitelistGroups: view.IPWhitelistGroups,
|
||||
IPBlacklistGroups: view.IPBlacklistGroups,
|
||||
CountryWhitelist: view.CountryWhitelist,
|
||||
CountryBlacklist: view.CountryBlacklist,
|
||||
RegionWhitelist: view.RegionWhitelist,
|
||||
@@ -562,6 +614,10 @@ func buildSnapshotWAFDocument(routes []*model.ProxyRoute) (snapshotWAFDocument,
|
||||
PoWConfig: view.PoWConfig,
|
||||
})
|
||||
}
|
||||
ipGroups, err := buildSnapshotWAFIPGroups(ruleGroups)
|
||||
if err != nil {
|
||||
return snapshotWAFDocument{}, err
|
||||
}
|
||||
enabledRouteIDs := make(map[uint]string, len(routes))
|
||||
for _, route := range routes {
|
||||
if route == nil {
|
||||
@@ -599,7 +655,49 @@ func buildSnapshotWAFDocument(routes []*model.ProxyRoute) (snapshotWAFDocument,
|
||||
}
|
||||
return bindings[i].SiteName < bindings[j].SiteName
|
||||
})
|
||||
return snapshotWAFDocument{RuleGroups: ruleGroups, Bindings: bindings}, nil
|
||||
return snapshotWAFDocument{RuleGroups: ruleGroups, IPGroups: ipGroups, Bindings: bindings}, nil
|
||||
}
|
||||
|
||||
func buildSnapshotWAFIPGroups(ruleGroups []snapshotWAFRuleGroup) ([]snapshotWAFIPGroup, error) {
|
||||
idSet := make(map[uint]struct{})
|
||||
for _, group := range ruleGroups {
|
||||
for _, id := range group.IPWhitelistGroups {
|
||||
idSet[id] = struct{}{}
|
||||
}
|
||||
for _, id := range group.IPBlacklistGroups {
|
||||
idSet[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(idSet) == 0 {
|
||||
return []snapshotWAFIPGroup{}, nil
|
||||
}
|
||||
ids := make([]uint, 0, len(idSet))
|
||||
for id := range idSet {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
groups, err := model.ListWAFIPGroupsByIDs(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groupByID := make(map[uint]*model.WAFIPGroup, len(groups))
|
||||
for _, group := range groups {
|
||||
groupByID[group.ID] = group
|
||||
}
|
||||
snapshots := make([]snapshotWAFIPGroup, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
group := groupByID[id]
|
||||
if group == nil {
|
||||
return nil, fmt.Errorf("IP 组 %d 不存在", id)
|
||||
}
|
||||
snapshots = append(snapshots, snapshotWAFIPGroup{
|
||||
ID: group.ID,
|
||||
Name: group.Name,
|
||||
Type: group.Type,
|
||||
Enabled: group.Enabled,
|
||||
})
|
||||
}
|
||||
return snapshots, nil
|
||||
}
|
||||
|
||||
func mustDecodeSnapshotCertIDs(route *model.ProxyRoute) []uint {
|
||||
@@ -715,6 +813,15 @@ func normalizeSnapshotRoutes(routes []snapshotRoute) []snapshotRoute {
|
||||
routes[index].BasicAuthUsername = ""
|
||||
routes[index].BasicAuthPassword = ""
|
||||
}
|
||||
routes[index].UpstreamType = normalizeUpstreamType(routes[index].UpstreamType)
|
||||
if routes[index].UpstreamType == "tunnel" {
|
||||
routes[index].TunnelTargetAddr = strings.TrimSpace(routes[index].TunnelTargetAddr)
|
||||
routes[index].TunnelTargetProto = normalizeTunnelTargetProtocol(routes[index].TunnelTargetProto)
|
||||
} else {
|
||||
routes[index].TunnelNodeID = nil
|
||||
routes[index].TunnelTargetAddr = ""
|
||||
routes[index].TunnelTargetProto = ""
|
||||
}
|
||||
}
|
||||
return routes
|
||||
}
|
||||
@@ -740,7 +847,7 @@ func flattenSnapshotRoutesByDomain(routes []snapshotRoute) map[string]snapshotRo
|
||||
}
|
||||
|
||||
func snapshotRouteConfigEqual(left snapshotRoute, right snapshotRoute) bool {
|
||||
if left.SiteName != right.SiteName || left.Domain != right.Domain || left.OriginURL != right.OriginURL || left.OriginHost != right.OriginHost || left.EnableHTTPS != right.EnableHTTPS || left.RedirectHTTP != right.RedirectHTTP || left.LimitConnPerServer != right.LimitConnPerServer || left.LimitConnPerIP != right.LimitConnPerIP || left.LimitRate != right.LimitRate || left.CacheEnabled != right.CacheEnabled || left.CachePolicy != right.CachePolicy || left.PoWEnabled != right.PoWEnabled || left.BasicAuthEnabled != right.BasicAuthEnabled || left.BasicAuthUsername != right.BasicAuthUsername || left.BasicAuthPassword != right.BasicAuthPassword || !uintSliceEqual(left.CertIDs, right.CertIDs) || !uintSliceEqual(left.DomainCertIDs, right.DomainCertIDs) {
|
||||
if left.SiteName != right.SiteName || left.Domain != right.Domain || left.OriginURL != right.OriginURL || left.OriginHost != right.OriginHost || left.EnableHTTPS != right.EnableHTTPS || left.RedirectHTTP != right.RedirectHTTP || left.LimitConnPerServer != right.LimitConnPerServer || left.LimitConnPerIP != right.LimitConnPerIP || left.LimitRate != right.LimitRate || left.CacheEnabled != right.CacheEnabled || left.CachePolicy != right.CachePolicy || left.PoWEnabled != right.PoWEnabled || left.BasicAuthEnabled != right.BasicAuthEnabled || left.BasicAuthUsername != right.BasicAuthUsername || left.BasicAuthPassword != right.BasicAuthPassword || left.UpstreamType != right.UpstreamType || !uintPtrEqual(left.TunnelNodeID, right.TunnelNodeID) || left.TunnelTargetAddr != right.TunnelTargetAddr || left.TunnelTargetProto != right.TunnelTargetProto || !uintSliceEqual(left.CertIDs, right.CertIDs) || !uintSliceEqual(left.DomainCertIDs, right.DomainCertIDs) {
|
||||
return false
|
||||
}
|
||||
if len(left.Domains) != len(right.Domains) {
|
||||
@@ -1102,6 +1209,13 @@ func uintSliceEqual(left []uint, right []uint) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func uintPtrEqual(left *uint, right *uint) bool {
|
||||
if left == nil || right == nil {
|
||||
return left == nil && right == nil
|
||||
}
|
||||
return *left == *right
|
||||
}
|
||||
|
||||
func nextVersionNumber(now time.Time) (string, error) {
|
||||
prefix := now.Format("20060102")
|
||||
var latest model.ConfigVersion
|
||||
|
||||
@@ -93,6 +93,11 @@ func GetDashboardOverview() (*DashboardOverviewView, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
openrestySnapshots, err := model.ListNodeObservationOpenresty("", since, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
view := &DashboardOverviewView{
|
||||
GeneratedAt: now,
|
||||
Nodes: make([]DashboardNodeHealth, 0, len(nodes)),
|
||||
@@ -100,7 +105,7 @@ func GetDashboardOverview() (*DashboardOverviewView, error) {
|
||||
Trends: DashboardTrends{
|
||||
Traffic24h: buildTrafficTrendPoints(now, reports),
|
||||
Capacity24h: buildCapacityTrendPoints(now, snapshots),
|
||||
Network24h: buildNetworkTrendPoints(now, snapshots),
|
||||
Network24h: buildNetworkTrendPoints(now, snapshots, openrestySnapshots),
|
||||
DiskIO24h: buildDiskIOTrendPoints(now, snapshots),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package service
|
||||
|
||||
const (
|
||||
FlaredWSConnectedLastSeenValue = "__OPENFLARE_FLARED_WS_CONNECTED__"
|
||||
|
||||
FlaredWSMessageTypeActiveConfig = "active_config"
|
||||
FlaredWSMessageTypeForceSync = "force_sync"
|
||||
FlaredWSMessageTypePong = "pong"
|
||||
)
|
||||
|
||||
var DefaultFlaredWSHub = NewWSHub("flared")
|
||||
|
||||
func RegisterFlaredWSClient(nodeID string) *WSClient {
|
||||
return DefaultFlaredWSHub.Register(nodeID)
|
||||
}
|
||||
|
||||
func UnregisterFlaredWSClient(client *WSClient) {
|
||||
DefaultFlaredWSHub.Unregister(client)
|
||||
}
|
||||
|
||||
func DisconnectFlaredWSClient(nodeID string) {
|
||||
DefaultFlaredWSHub.Disconnect(nodeID)
|
||||
}
|
||||
|
||||
func IsFlaredWSConnected(nodeID string) bool {
|
||||
return DefaultFlaredWSHub.IsConnected(nodeID)
|
||||
}
|
||||
|
||||
func SendFlaredWSPong(nodeID string) bool {
|
||||
return DefaultFlaredWSHub.SendMessage(nodeID, WSMessage{
|
||||
Type: FlaredWSMessageTypePong,
|
||||
})
|
||||
}
|
||||
|
||||
func SendFlaredWSActiveConfig(nodeID string, activeConfig *ActiveConfigMeta) bool {
|
||||
if activeConfig == nil {
|
||||
return false
|
||||
}
|
||||
return DefaultFlaredWSHub.SendMessage(nodeID, WSMessage{
|
||||
Type: FlaredWSMessageTypeActiveConfig,
|
||||
Payload: activeConfig,
|
||||
})
|
||||
}
|
||||
|
||||
func BroadcastFlaredWSActiveConfig(activeConfig *ActiveConfigMeta) WSBroadcastResult {
|
||||
if activeConfig == nil {
|
||||
return WSBroadcastResult{}
|
||||
}
|
||||
result := DefaultFlaredWSHub.Broadcast(WSMessage{
|
||||
Type: FlaredWSMessageTypeActiveConfig,
|
||||
Payload: activeConfig,
|
||||
})
|
||||
return result
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user