mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 13:46:38 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a850b0a188 | |||
| fd8148c0db | |||
| edd98f4ff0 | |||
| d8f98e218f | |||
| fefe205158 | |||
| d6e7e2baa2 | |||
| cc50cc695e |
@@ -46,7 +46,7 @@ jobs:
|
||||
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"
|
||||
echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/openflared" >> "$GITHUB_ENV"
|
||||
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
elif [[ -n "$INPUT_VERSION" ]]; then
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
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"
|
||||
echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/openflared" >> "$GITHUB_ENV"
|
||||
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
elif [[ -n "$INPUT_VERSION" ]]; then
|
||||
|
||||
+331
-205
@@ -1,7 +1,7 @@
|
||||
name: Release
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
name: Release
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -11,20 +11,20 @@ on:
|
||||
type: string
|
||||
push:
|
||||
tags: ["v*"]
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_run: ${{ steps.version.outputs.should_run }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_run: ${{ steps.version.outputs.should_run }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve version metadata
|
||||
id: version
|
||||
env:
|
||||
@@ -52,194 +52,320 @@ jobs:
|
||||
fi
|
||||
|
||||
echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
if [[ "$VERSION" =~ ^v[0-9]+(\.[0-9]+)*$ ]]; then
|
||||
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
build-frontend:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.should_run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Build Frontend
|
||||
env:
|
||||
CI: ""
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
cd openflare_server/web
|
||||
corepack enable
|
||||
pnpm install --frozen-lockfile
|
||||
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build
|
||||
|
||||
- name: Upload Frontend Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-build
|
||||
path: openflare_server/web/build
|
||||
retention-days: 1
|
||||
|
||||
build-binaries:
|
||||
needs:
|
||||
- prepare
|
||||
- build-frontend
|
||||
if: needs.prepare.outputs.should_run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
asset_name: openflare-server-linux-amd64
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
asset_name: openflare-server-linux-arm64
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
asset_name: openflare-server-darwin-amd64
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
asset_name: openflare-server-darwin-arm64
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
asset_name: openflare-server-windows-amd64.exe
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download Frontend Artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: frontend-build
|
||||
path: openflare_server/web/build
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: openflare_server/go.mod
|
||||
|
||||
- name: Build Server
|
||||
working-directory: openflare_server
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
ASSET_NAME: ${{ matrix.asset_name }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
go mod download
|
||||
mkdir -p ../dist
|
||||
go build -trimpath -ldflags "-s -w -X 'openflare/common.Version=$VERSION'" -o "../dist/$ASSET_NAME" .
|
||||
|
||||
- name: Upload Binary Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: server-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: dist/${{ matrix.asset_name }}
|
||||
retention-days: 1
|
||||
|
||||
build-agent-binaries:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.should_run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
asset_name: openflare-agent-linux-amd64
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
asset_name: openflare-agent-linux-arm64
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
asset_name: openflare-agent-darwin-amd64
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
asset_name: openflare-agent-darwin-arm64
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: openflare_agent/go.mod
|
||||
|
||||
- name: Build Agent
|
||||
working-directory: openflare_agent
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
ASSET_NAME: ${{ matrix.asset_name }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
go mod download
|
||||
mkdir -p ../dist
|
||||
go build -trimpath -ldflags "-s -w -X 'openflare-agent/internal/config.Version=$VERSION'" -o "../dist/$ASSET_NAME" ./cmd/agent
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
if [[ "$VERSION" =~ ^v[0-9]+(\.[0-9]+)*$ ]]; then
|
||||
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
build-frontend:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.should_run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Build Frontend
|
||||
env:
|
||||
CI: ""
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
cd openflare_server/web
|
||||
corepack enable
|
||||
pnpm install --frozen-lockfile
|
||||
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build
|
||||
|
||||
- name: Upload Frontend Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-build
|
||||
path: openflare_server/web/build
|
||||
retention-days: 1
|
||||
|
||||
build-binaries:
|
||||
needs:
|
||||
- prepare
|
||||
- build-frontend
|
||||
if: needs.prepare.outputs.should_run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
asset_name: openflare-server-linux-amd64
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
asset_name: openflare-server-linux-arm64
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
asset_name: openflare-server-darwin-amd64
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
asset_name: openflare-server-darwin-arm64
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
asset_name: openflare-server-windows-amd64.exe
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download Frontend Artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: frontend-build
|
||||
path: openflare_server/web/build
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: openflare_server/go.mod
|
||||
|
||||
- name: Build Server
|
||||
working-directory: openflare_server
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
ASSET_NAME: ${{ matrix.asset_name }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
go mod download
|
||||
mkdir -p ../dist
|
||||
go build -trimpath -ldflags "-s -w -X 'openflare/common.Version=$VERSION'" -o "../dist/$ASSET_NAME" .
|
||||
|
||||
- name: Upload Binary Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: server-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: dist/${{ matrix.asset_name }}
|
||||
retention-days: 1
|
||||
|
||||
build-agent-binaries:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.should_run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
asset_name: openflare-agent-linux-amd64
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
asset_name: openflare-agent-linux-arm64
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
asset_name: openflare-agent-darwin-amd64
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
asset_name: openflare-agent-darwin-arm64
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: openflare_agent/go.mod
|
||||
|
||||
- name: Build Agent
|
||||
working-directory: openflare_agent
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
ASSET_NAME: ${{ matrix.asset_name }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
go mod download
|
||||
mkdir -p ../dist
|
||||
go build -trimpath -ldflags "-s -w -X 'openflare-agent/internal/config.Version=$VERSION'" -o "../dist/$ASSET_NAME" ./cmd/agent
|
||||
(cd ../dist && sha256sum "$ASSET_NAME" > "$ASSET_NAME.sha256")
|
||||
|
||||
- name: Upload Agent Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: agent-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
|
||||
- name: Upload Agent Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: agent-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: |
|
||||
dist/${{ matrix.asset_name }}
|
||||
dist/${{ matrix.asset_name }}.sha256
|
||||
retention-days: 1
|
||||
|
||||
release:
|
||||
needs:
|
||||
- prepare
|
||||
- build-binaries
|
||||
- build-agent-binaries
|
||||
if: needs.prepare.outputs.should_run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download Server Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: "server-*"
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download Agent Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: "agent-*"
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ needs.prepare.outputs.version }}
|
||||
name: ${{ needs.prepare.outputs.version }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
files: dist/*
|
||||
draft: false
|
||||
prerelease: ${{ needs.prepare.outputs.is_prerelease == 'true' }}
|
||||
generate_release_notes: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
retention-days: 1
|
||||
|
||||
build-relay-binaries:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.should_run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
asset_name: openflare-relay-linux-amd64
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
asset_name: openflare-relay-linux-arm64
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
asset_name: openflare-relay-darwin-amd64
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
asset_name: openflare-relay-darwin-arm64
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: openflare_relay/go.mod
|
||||
|
||||
- name: Build Relay
|
||||
working-directory: openflare_relay
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
ASSET_NAME: ${{ matrix.asset_name }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
go mod download
|
||||
mkdir -p ../dist
|
||||
go build -trimpath -ldflags "-s -w -X 'openflare-relay/internal/config.Version=$VERSION'" -o "../dist/$ASSET_NAME" ./cmd/relay
|
||||
(cd ../dist && sha256sum "$ASSET_NAME" > "$ASSET_NAME.sha256")
|
||||
|
||||
- name: Upload Relay Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: relay-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: |
|
||||
dist/${{ matrix.asset_name }}
|
||||
dist/${{ matrix.asset_name }}.sha256
|
||||
retention-days: 1
|
||||
|
||||
build-flared-binaries:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.should_run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
asset_name: openflared-linux-amd64
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
asset_name: openflared-linux-arm64
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
asset_name: openflared-darwin-amd64
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
asset_name: openflared-darwin-arm64
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: openflared/go.mod
|
||||
|
||||
- name: Build Flared
|
||||
working-directory: openflared
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
ASSET_NAME: ${{ matrix.asset_name }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
go mod download
|
||||
mkdir -p ../dist
|
||||
go build -trimpath -ldflags "-s -w -X 'openflare-flared/internal/config.Version=$VERSION'" -o "../dist/$ASSET_NAME" ./cmd/flared
|
||||
(cd ../dist && sha256sum "$ASSET_NAME" > "$ASSET_NAME.sha256")
|
||||
|
||||
- name: Upload Flared Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: flared-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: |
|
||||
dist/${{ matrix.asset_name }}
|
||||
dist/${{ matrix.asset_name }}.sha256
|
||||
retention-days: 1
|
||||
|
||||
release:
|
||||
needs:
|
||||
- prepare
|
||||
- build-binaries
|
||||
- build-agent-binaries
|
||||
- build-relay-binaries
|
||||
- build-flared-binaries
|
||||
if: needs.prepare.outputs.should_run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download Server Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: "server-*"
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download Agent Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: "agent-*"
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download Relay Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: "relay-*"
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download Flared Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: "flared-*"
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ needs.prepare.outputs.version }}
|
||||
name: ${{ needs.prepare.outputs.version }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
files: dist/*
|
||||
draft: false
|
||||
prerelease: ${{ needs.prepare.outputs.is_prerelease == 'true' }}
|
||||
generate_release_notes: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"openflare-relay/internal/config"
|
||||
@@ -20,7 +21,7 @@ import (
|
||||
func main() {
|
||||
// Setup simple structured logging
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelDebug,
|
||||
Level: parseLevel(os.Getenv("LOG_LEVEL")),
|
||||
})))
|
||||
|
||||
configPath := flag.String("config", "./relay.json", "relay config path")
|
||||
@@ -44,7 +45,7 @@ func main() {
|
||||
stateStore := state.NewStore(cfg.StatePath)
|
||||
_ = stateStore // In the future we may use stateStore for auth caching
|
||||
|
||||
frpsManager := frps.NewManager(cfg.FrpsPath, cfg.DataDir)
|
||||
frpsManager := frps.NewManager(cfg.FrpsPath, cfg.DataDir, cfg.InitialAuthToken())
|
||||
|
||||
slog.Info("detected frps version", "version", frpsManager.GetVersion())
|
||||
|
||||
@@ -71,3 +72,16 @@ func main() {
|
||||
}
|
||||
slog.Info("relay process stopped")
|
||||
}
|
||||
|
||||
func parseLevel(value string) slog.Level {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "warn", "warning":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ type Manager struct {
|
||||
frpsPath string
|
||||
dataDir string
|
||||
configPath string
|
||||
agentToken string
|
||||
|
||||
mu sync.RWMutex
|
||||
activeConfig *service.RelayConfig
|
||||
@@ -34,15 +35,18 @@ type RuntimeStatus struct {
|
||||
LastError string
|
||||
Connections int
|
||||
ProxyCount int
|
||||
ClientCount int
|
||||
Proxies []service.RelayProxyStat
|
||||
ProcessAlive bool
|
||||
}
|
||||
|
||||
func NewManager(frpsPath string, dataDir string) *Manager {
|
||||
func NewManager(frpsPath string, dataDir string, agentToken string) *Manager {
|
||||
return &Manager{
|
||||
frpsPath: frpsPath,
|
||||
dataDir: dataDir,
|
||||
configPath: filepath.Join(dataDir, "frps.toml"),
|
||||
status: "unhealthy",
|
||||
status: "unknown", // 启动阶段尚未获取配置,状态未知;避免首次 heartbeat 误报 frps_unhealthy
|
||||
agentToken: agentToken,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,13 +71,19 @@ func (m *Manager) GetStatus() string {
|
||||
|
||||
func (m *Manager) GetRuntimeStatus() RuntimeStatus {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
status := m.status
|
||||
lastError := m.lastError
|
||||
cmd := m.cmd
|
||||
m.mu.RUnlock()
|
||||
|
||||
return RuntimeStatus{
|
||||
Status: m.status,
|
||||
LastError: m.lastError,
|
||||
Status: status,
|
||||
LastError: lastError,
|
||||
Connections: 0,
|
||||
ProxyCount: 0,
|
||||
ProcessAlive: m.cmd != nil && m.cmd.Process != nil,
|
||||
ClientCount: 0,
|
||||
Proxies: nil,
|
||||
ProcessAlive: cmd != nil && cmd.Process != nil,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +98,8 @@ func (m *Manager) UpdateConfig(cfg *service.RelayConfig) {
|
||||
if m.activeConfig != nil &&
|
||||
m.activeConfig.BindPort == cfg.BindPort &&
|
||||
m.activeConfig.VhostHTTPPort == cfg.VhostHTTPPort &&
|
||||
m.activeConfig.AuthToken == cfg.AuthToken {
|
||||
m.activeConfig.AuthToken == cfg.AuthToken &&
|
||||
m.activeConfig.WebServerEnabled == cfg.WebServerEnabled {
|
||||
if m.cmd == nil && !m.stopping {
|
||||
slog.Warn("frps config unchanged but process is not running, restarting")
|
||||
if err := m.restartProcess(); err != nil {
|
||||
@@ -136,6 +147,22 @@ func (m *Manager) renderConfig(cfg *service.RelayConfig) error {
|
||||
buf.WriteString(fmt.Sprintf("token = \"%s\"\n", cfg.AuthToken))
|
||||
}
|
||||
|
||||
// WebServer configuration
|
||||
buf.WriteString("\n[webServer]\n")
|
||||
if cfg.WebServerEnabled {
|
||||
buf.WriteString("addr = \"0.0.0.0\"\n")
|
||||
} else {
|
||||
buf.WriteString("addr = \"127.0.0.1\"\n")
|
||||
}
|
||||
buf.WriteString(fmt.Sprintf("port = %d\n", 17500))
|
||||
buf.WriteString("user = \"admin\"\n")
|
||||
|
||||
password := m.agentToken
|
||||
if password == "" {
|
||||
password = "admin"
|
||||
}
|
||||
buf.WriteString(fmt.Sprintf("password = \"%s\"\n", password))
|
||||
|
||||
return os.WriteFile(m.configPath, buf.Bytes(), 0644)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"openflare-relay/internal/httpclient"
|
||||
"openflare-relay/internal/observability"
|
||||
"openflare-relay/internal/state"
|
||||
"openflare-relay/internal/updater"
|
||||
"openflare/service"
|
||||
)
|
||||
|
||||
@@ -18,6 +19,7 @@ type Service struct {
|
||||
frpsManager *frps.Manager
|
||||
config *config.Config
|
||||
stateStore *state.Store
|
||||
updater *updater.Service
|
||||
}
|
||||
|
||||
func New(client *httpclient.Client, manager *frps.Manager, cfg *config.Config, stateStore *state.Store) *Service {
|
||||
@@ -26,6 +28,7 @@ func New(client *httpclient.Client, manager *frps.Manager, cfg *config.Config, s
|
||||
frpsManager: manager,
|
||||
config: cfg,
|
||||
stateStore: stateStore,
|
||||
updater: updater.New(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,16 +54,18 @@ func (s *Service) doHeartbeat(ctx context.Context) {
|
||||
|
||||
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),
|
||||
Version: config.Version,
|
||||
ExtVersion: s.frpsManager.GetVersion(),
|
||||
RelayStatus: runtimeStatus.Status,
|
||||
FrpsConnCount: runtimeStatus.Connections,
|
||||
FrpsProxyCount: runtimeStatus.ProxyCount,
|
||||
FrpsClientCount: runtimeStatus.ClientCount,
|
||||
FrpsProxies: runtimeStatus.Proxies,
|
||||
Name: s.config.NodeName,
|
||||
IP: s.config.NodeIP,
|
||||
Profile: observability.BuildProfile(s.config, s.stateStore),
|
||||
Snapshot: observability.BuildSnapshot(s.config, s.stateStore),
|
||||
HealthEvents: observability.BuildHealthEvents(runtimeStatus),
|
||||
}
|
||||
|
||||
resp, err := s.client.Heartbeat(ctx, payload)
|
||||
@@ -72,4 +77,32 @@ func (s *Service) doHeartbeat(ctx context.Context) {
|
||||
|
||||
// Update configs if changed
|
||||
s.frpsManager.UpdateConfig(resp.RelayConfig)
|
||||
|
||||
if resp != nil && resp.RelaySettings != nil {
|
||||
s.tryAutoUpdate(ctx, resp.RelaySettings)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) tryAutoUpdate(ctx context.Context, settings *service.RelaySettings) {
|
||||
if settings == nil || s.updater == nil {
|
||||
return
|
||||
}
|
||||
force := settings.UpdateNow
|
||||
shouldCheck := settings.AutoUpdate || force
|
||||
if !shouldCheck || settings.UpdateRepo == "" {
|
||||
return
|
||||
}
|
||||
channel := "stable"
|
||||
if force && settings.UpdateChannel != "" {
|
||||
channel = settings.UpdateChannel
|
||||
}
|
||||
slog.Info("checking for relay updates", "repo", settings.UpdateRepo, "channel", channel, "force", force)
|
||||
err := s.updater.CheckAndUpdate(ctx, settings.UpdateRepo, updater.UpdateOptions{
|
||||
Channel: channel,
|
||||
TagName: settings.UpdateTag,
|
||||
Force: force,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("relay update check failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
//go:build !windows
|
||||
|
||||
package updater
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func replaceAndRestart(execPath string, tmpPath string) error {
|
||||
backupPath := execPath + ".bak"
|
||||
if err := removeBackupBinary(backupPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(execPath, backupPath); err != nil {
|
||||
renameErr := err
|
||||
if err := os.Remove(tmpPath); err != nil && !os.IsNotExist(err) {
|
||||
slog.Error("remove tmp binary failed", "path", tmpPath, "error", err)
|
||||
return fmt.Errorf("backup current binary: %w; remove tmp binary: %v", renameErr, err)
|
||||
}
|
||||
return fmt.Errorf("backup current binary: %w", renameErr)
|
||||
}
|
||||
if err := os.Rename(tmpPath, execPath); err != nil {
|
||||
replaceErr := err
|
||||
if err := os.Rename(backupPath, execPath); err != nil {
|
||||
slog.Error("restore backup binary failed", "path", backupPath, "error", err)
|
||||
return fmt.Errorf("replace binary: %w; restore backup binary: %v", replaceErr, err)
|
||||
}
|
||||
return fmt.Errorf("replace binary: %w", replaceErr)
|
||||
}
|
||||
if err := removeBackupBinary(backupPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := syscall.Exec(execPath, os.Args, os.Environ()); err != nil {
|
||||
return fmt.Errorf("exec restart: %w", err)
|
||||
}
|
||||
return fmt.Errorf("unreachable after exec")
|
||||
}
|
||||
|
||||
func removeBackupBinary(path string) error {
|
||||
if err := os.Remove(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
slog.Error("remove backup binary failed", "path", path, "error", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//go:build windows
|
||||
|
||||
package updater
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func replaceAndRestart(execPath string, tmpPath string) error {
|
||||
backupPath := execPath + ".bak"
|
||||
scriptPath := execPath + ".update.cmd"
|
||||
script := fmt.Sprintf(`@echo off
|
||||
setlocal
|
||||
:waitloop
|
||||
move /Y "%s" "%s" >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
ping 127.0.0.1 -n 2 >nul
|
||||
goto waitloop
|
||||
)
|
||||
move /Y "%s" "%s" >nul 2>nul
|
||||
if errorlevel 1 exit /b 1
|
||||
start "" %s
|
||||
del /Q "%s" >nul 2>nul
|
||||
del /Q "%%~f0" >nul 2>nul
|
||||
`, execPath, backupPath, tmpPath, execPath, buildWindowsCommandLine(execPath, os.Args[1:]), backupPath)
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0o700); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("write restart script: %w", err)
|
||||
}
|
||||
cmd := exec.Command("cmd", "/C", "start", "", scriptPath)
|
||||
if err := cmd.Start(); err != nil {
|
||||
os.Remove(scriptPath)
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("schedule restart: %w", err)
|
||||
}
|
||||
os.Exit(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildWindowsCommandLine(execPath string, args []string) string {
|
||||
parts := []string{quoteWindowsArg(execPath)}
|
||||
for _, arg := range args {
|
||||
parts = append(parts, quoteWindowsArg(arg))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func quoteWindowsArg(value string) string {
|
||||
return `"` + strings.ReplaceAll(value, `"`, `""`) + `"`
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"openflare/utils"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"openflare-relay/internal/config"
|
||||
)
|
||||
|
||||
const maxChecksumAssetSize = 64 * 1024
|
||||
|
||||
var replaceAndRestartFunc = replaceAndRestart
|
||||
|
||||
type Service struct {
|
||||
httpClient *http.Client
|
||||
lastCheckKey string
|
||||
}
|
||||
|
||||
func New() *Service {
|
||||
return &Service{
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
type githubRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
Draft bool `json:"draft"`
|
||||
Assets []githubAsset `json:"assets"`
|
||||
}
|
||||
|
||||
type githubAsset struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
}
|
||||
|
||||
type UpdateOptions struct {
|
||||
Channel string
|
||||
TagName string
|
||||
Force bool
|
||||
}
|
||||
|
||||
func (s *Service) CheckAndUpdate(ctx context.Context, repo string, options UpdateOptions) error {
|
||||
release, err := s.getRelease(ctx, repo, options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check latest release: %w", err)
|
||||
}
|
||||
if release == nil || release.TagName == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
remoteVersion := normalizeVersion(release.TagName)
|
||||
localVersion := normalizeVersion(config.Version)
|
||||
checkKey := buildReleaseCheckKey(options, remoteVersion)
|
||||
|
||||
if remoteVersion == localVersion {
|
||||
return nil
|
||||
}
|
||||
if !options.Force && checkKey != "" && checkKey == s.lastCheckKey {
|
||||
return nil
|
||||
}
|
||||
if !isNewer(localVersion, remoteVersion) {
|
||||
s.lastCheckKey = checkKey
|
||||
return nil
|
||||
}
|
||||
|
||||
slog.Info("relay update available", "from", localVersion, "to", remoteVersion)
|
||||
assetName := assetNameForGOOSGOARCH(runtime.GOOS, runtime.GOARCH)
|
||||
checksumAssetName := assetName + ".sha256"
|
||||
|
||||
var downloadURL string
|
||||
var checksumURL string
|
||||
for _, asset := range release.Assets {
|
||||
switch asset.Name {
|
||||
case assetName:
|
||||
downloadURL = asset.BrowserDownloadURL
|
||||
case checksumAssetName:
|
||||
checksumURL = asset.BrowserDownloadURL
|
||||
}
|
||||
}
|
||||
if downloadURL == "" {
|
||||
s.lastCheckKey = checkKey
|
||||
return fmt.Errorf("no matching asset %q in release %s", assetName, release.TagName)
|
||||
}
|
||||
if checksumURL == "" {
|
||||
return fmt.Errorf("no matching checksum asset %q in release %s", checksumAssetName, release.TagName)
|
||||
}
|
||||
|
||||
expectedChecksum, err := s.downloadChecksum(ctx, checksumURL, assetName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download checksum: %w", err)
|
||||
}
|
||||
|
||||
execPath, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get executable path: %w", err)
|
||||
}
|
||||
if err = s.downloadAndRestart(ctx, downloadURL, expectedChecksum, execPath); err != nil {
|
||||
return fmt.Errorf("download and restart: %w", err)
|
||||
}
|
||||
s.lastCheckKey = checkKey
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) getRelease(ctx context.Context, repo string, options UpdateOptions) (*githubRelease, error) {
|
||||
tagName := strings.TrimSpace(options.TagName)
|
||||
if tagName != "" {
|
||||
return s.getReleaseByTag(ctx, repo, tagName)
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(options.Channel), "preview") {
|
||||
return s.getLatestPreviewRelease(ctx, repo)
|
||||
}
|
||||
return s.getLatestStableRelease(ctx, repo)
|
||||
}
|
||||
|
||||
func (s *Service) getLatestStableRelease(ctx context.Context, repo string) (*githubRelease, error) {
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo)
|
||||
return s.fetchReleaseFromURL(ctx, url)
|
||||
}
|
||||
|
||||
func (s *Service) getLatestPreviewRelease(ctx context.Context, repo string) (*githubRelease, error) {
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=20", repo)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("github api returned %s", resp.Status)
|
||||
}
|
||||
|
||||
var releases []githubRelease
|
||||
if err = json.NewDecoder(resp.Body).Decode(&releases); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, release := range releases {
|
||||
if release.Draft || !release.Prerelease {
|
||||
continue
|
||||
}
|
||||
releaseCopy := release
|
||||
return &releaseCopy, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *Service) getReleaseByTag(ctx context.Context, repo string, tag string) (*githubRelease, error) {
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/tags/%s", repo, strings.TrimSpace(tag))
|
||||
return s.fetchReleaseFromURL(ctx, url)
|
||||
}
|
||||
|
||||
func (s *Service) fetchReleaseFromURL(ctx context.Context, url string) (*githubRelease, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func(Body io.ReadCloser) {
|
||||
err := Body.Close()
|
||||
if err != nil {
|
||||
slog.Error("failed to close response body", "error", err)
|
||||
}
|
||||
}(resp.Body)
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("github api returned %s", resp.Status)
|
||||
}
|
||||
|
||||
return decodeRelease(resp.Body)
|
||||
}
|
||||
|
||||
func decodeRelease(reader io.Reader) (*githubRelease, error) {
|
||||
var release githubRelease
|
||||
if err := json.NewDecoder(reader).Decode(&release); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &release, nil
|
||||
}
|
||||
|
||||
func (s *Service) downloadChecksum(ctx context.Context, url string, assetName string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("checksum download returned %s", resp.Status)
|
||||
}
|
||||
|
||||
content, err := io.ReadAll(io.LimitReader(resp.Body, maxChecksumAssetSize+1))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(content) > maxChecksumAssetSize {
|
||||
return "", fmt.Errorf("checksum asset exceeds %d bytes", maxChecksumAssetSize)
|
||||
}
|
||||
checksum, err := parseSHA256Checksum(string(content), assetName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return checksum, nil
|
||||
}
|
||||
|
||||
func parseSHA256Checksum(content string, assetName string) (string, error) {
|
||||
assetName = strings.TrimSpace(assetName)
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if checksum, ok := parseSHA256Line(line, assetName); ok {
|
||||
return checksum, nil
|
||||
}
|
||||
}
|
||||
if assetName == "" {
|
||||
return "", fmt.Errorf("checksum asset does not contain a valid sha256 digest")
|
||||
}
|
||||
return "", fmt.Errorf("checksum asset does not contain a sha256 digest for %q", assetName)
|
||||
}
|
||||
|
||||
func parseSHA256Line(line string, assetName string) (string, bool) {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 1 && isSHA256Hex(fields[0]) {
|
||||
return strings.ToLower(fields[0]), true
|
||||
}
|
||||
if len(fields) >= 2 && isSHA256Hex(fields[0]) {
|
||||
fileName := strings.TrimPrefix(strings.TrimSpace(fields[1]), "*")
|
||||
if assetName == "" || fileName == assetName {
|
||||
return strings.ToLower(fields[0]), true
|
||||
}
|
||||
}
|
||||
|
||||
prefix := "SHA256("
|
||||
if strings.HasPrefix(line, prefix) {
|
||||
closing := strings.Index(line, ")")
|
||||
if closing > len(prefix) && closing+1 < len(line) {
|
||||
fileName := strings.TrimSpace(line[len(prefix):closing])
|
||||
rest := strings.TrimSpace(line[closing+1:])
|
||||
rest = strings.TrimPrefix(rest, "=")
|
||||
rest = strings.TrimSpace(rest)
|
||||
if isSHA256Hex(rest) && (assetName == "" || fileName == assetName) {
|
||||
return strings.ToLower(rest), true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func isSHA256Hex(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) != sha256.Size*2 {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(value)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *Service) downloadAndRestart(ctx context.Context, url string, expectedChecksum string, targetPath string) error {
|
||||
expectedChecksum = strings.ToLower(strings.TrimSpace(expectedChecksum))
|
||||
if !isSHA256Hex(expectedChecksum) {
|
||||
return fmt.Errorf("invalid expected sha256 checksum")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download returned %s", resp.Status)
|
||||
}
|
||||
|
||||
tmpPath := targetPath + ".update"
|
||||
if runtime.GOOS == "windows" && !strings.HasSuffix(strings.ToLower(tmpPath), ".exe") {
|
||||
tmpPath += ".exe"
|
||||
}
|
||||
tmpFile, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hasher := sha256.New()
|
||||
if _, err = io.Copy(io.MultiWriter(tmpFile, hasher), resp.Body); err != nil {
|
||||
tmpFile.Close()
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err = tmpFile.Close(); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
actualChecksum := hex.EncodeToString(hasher.Sum(nil))
|
||||
if actualChecksum != expectedChecksum {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("sha256 checksum mismatch: expected %s, got %s", expectedChecksum, actualChecksum)
|
||||
}
|
||||
if err = os.Chmod(tmpPath, 0o755); err != nil && runtime.GOOS != "windows" {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("set executable permission: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("relay binary updated, restarting")
|
||||
return replaceAndRestartFunc(targetPath, tmpPath)
|
||||
}
|
||||
|
||||
func assetNameForGOOSGOARCH(goos string, goarch string) string {
|
||||
name := fmt.Sprintf("openflare-relay-%s-%s", goos, goarch)
|
||||
if goos == "windows" {
|
||||
return name + ".exe"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func normalizeVersion(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
v = strings.TrimPrefix(v, "v")
|
||||
return v
|
||||
}
|
||||
|
||||
func isNewer(local, remote string) bool {
|
||||
return compareVersions(local, remote) < 0
|
||||
}
|
||||
|
||||
func buildReleaseCheckKey(options UpdateOptions, remoteVersion string) string {
|
||||
channel := strings.TrimSpace(options.Channel)
|
||||
if channel == "" {
|
||||
channel = "stable"
|
||||
}
|
||||
if tagName := strings.TrimSpace(options.TagName); tagName != "" {
|
||||
return channel + ":" + tagName
|
||||
}
|
||||
return channel + ":" + remoteVersion
|
||||
}
|
||||
|
||||
func compareVersions(local string, remote string) int {
|
||||
return utils.CompareVersions(local, remote)
|
||||
}
|
||||
@@ -92,6 +92,12 @@ func migrateV16(ctx Context, db *gorm.DB, backend string) error {
|
||||
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)
|
||||
}
|
||||
// Drop the legacy index idx_proxy_routes_tunnel_id if it exists, to avoid errors on dropping the tunnel_id column (especially on SQLite).
|
||||
if migrator.HasIndex(&proxyRouteV16{}, "idx_proxy_routes_tunnel_id") {
|
||||
if err := migrator.DropIndex(&proxyRouteV16{}, "idx_proxy_routes_tunnel_id"); err != nil {
|
||||
return fmt.Errorf("drop index idx_proxy_routes_tunnel_id failed: %w", err)
|
||||
}
|
||||
}
|
||||
if err := migrator.DropColumn(&proxyRouteV16{}, "tunnel_id"); err != nil {
|
||||
return fmt.Errorf("drop pre-release proxy_routes.tunnel_id: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type nodeV17 struct{}
|
||||
|
||||
func (nodeV17) TableName() string {
|
||||
return "nodes"
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(V17())
|
||||
}
|
||||
|
||||
func V17() Migration {
|
||||
return Migration{
|
||||
FromVersion: 16,
|
||||
ToVersion: 17,
|
||||
Migrate: migrateV17,
|
||||
Validate: validateV17,
|
||||
}
|
||||
}
|
||||
|
||||
func migrateV17(ctx Context, db *gorm.DB, backend string) error {
|
||||
if err := ctx.ApplyCurrentSchema(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateV17(ctx Context, db *gorm.DB, backend string) error {
|
||||
if err := ctx.ValidateDatabaseSchemaVersion(db, backend, 16); err != nil {
|
||||
return err
|
||||
}
|
||||
if db == nil {
|
||||
return fmt.Errorf("database handle is nil")
|
||||
}
|
||||
|
||||
migrator := db.Migrator()
|
||||
if !migrator.HasColumn(&nodeV17{}, "relay_web_server_enabled") {
|
||||
return fmt.Errorf("column nodes.relay_web_server_enabled is missing")
|
||||
}
|
||||
|
||||
// Validate columns on a sharded partition table
|
||||
for _, shard := range []string{"node_observation_frps_00"} {
|
||||
for _, column := range []string{"frps_client_count", "frps_proxies"} {
|
||||
if !migrator.HasColumn(shard, column) {
|
||||
return fmt.Errorf("column %s.%s is missing", shard, column)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -86,6 +86,8 @@ func (databaseSchemaMigrationContext) ValidateDatabaseSchemaVersion(db *gorm.DB,
|
||||
return validateDatabaseSchemaV15(db, backend)
|
||||
case 16:
|
||||
return validateDatabaseSchemaV16(db, backend)
|
||||
case 17:
|
||||
return validateDatabaseSchemaV17(db, backend)
|
||||
default:
|
||||
return fmt.Errorf("database schema validation for v%d is not defined", version)
|
||||
}
|
||||
@@ -202,6 +204,12 @@ func dropLegacyNodeColumns(db *gorm.DB, backend string) error {
|
||||
if db == nil || !db.Migrator().HasTable(&Node{}) {
|
||||
return nil
|
||||
}
|
||||
// Drop the legacy index idx_nodes_agent_token if it exists, to avoid errors on dropping the agent_token column (especially on SQLite).
|
||||
if db.Migrator().HasIndex(&Node{}, "idx_nodes_agent_token") {
|
||||
if err := db.Migrator().DropIndex(&Node{}, "idx_nodes_agent_token"); err != nil {
|
||||
return fmt.Errorf("drop index idx_nodes_agent_token failed: %w", err)
|
||||
}
|
||||
}
|
||||
legacyColumns := []struct {
|
||||
column string
|
||||
}{
|
||||
@@ -1301,6 +1309,31 @@ func validateDatabaseSchemaV16(db *gorm.DB, backend string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDatabaseSchemaV17(db *gorm.DB, backend string) error {
|
||||
if err := validateDatabaseSchemaV16(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
if db == nil {
|
||||
return fmt.Errorf("database handle is nil")
|
||||
}
|
||||
|
||||
migrator := db.Migrator()
|
||||
if !migrator.HasColumn(&Node{}, "relay_web_server_enabled") {
|
||||
return fmt.Errorf("column nodes.relay_web_server_enabled is missing")
|
||||
}
|
||||
|
||||
// Validate columns on a sharded partition table
|
||||
for _, shard := range []string{"node_observation_frps_00"} {
|
||||
for _, column := range []string{"frps_client_count", "frps_proxies"} {
|
||||
if !migrator.HasColumn(shard, column) {
|
||||
return fmt.Errorf("column %s.%s is missing", shard, column)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func databaseSchemaMigrations() []databaseSchemaMigration {
|
||||
ctx := databaseSchemaMigrationContext{}
|
||||
migrations := []databaseSchemaMigration{}
|
||||
|
||||
@@ -38,6 +38,7 @@ type Node struct {
|
||||
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'"`
|
||||
RelayWebServerEnabled bool `json:"relay_web_server_enabled" gorm:"not null;default:false"`
|
||||
}
|
||||
|
||||
func ListNodes() (nodes []*Node, err error) {
|
||||
|
||||
@@ -13,6 +13,8 @@ type NodeObservationFrps struct {
|
||||
CapturedAt time.Time `json:"captured_at" gorm:"index"`
|
||||
FrpsConnections int `json:"frps_connections"`
|
||||
FrpsProxyCount int `json:"frps_proxy_count"`
|
||||
FrpsClientCount int `json:"frps_client_count"`
|
||||
FrpsProxies string `json:"frps_proxies" gorm:"type:text"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
|
||||
@@ -171,6 +171,7 @@ type NodeView struct {
|
||||
RelayClientAccessAddr string `json:"relay_client_access_addr"`
|
||||
RelayClientProxyURL string `json:"relay_client_proxy_url"`
|
||||
RelayStatus string `json:"relay_status"`
|
||||
RelayWebServerEnabled bool `json:"relay_web_server_enabled"`
|
||||
}
|
||||
|
||||
func HeartbeatNode(node *model.Node, payload AgentNodePayload) (*HeartbeatResponse, error) {
|
||||
|
||||
@@ -31,6 +31,7 @@ type NodeInput struct {
|
||||
RelayAgentAccessAddr string `json:"relay_agent_access_addr"`
|
||||
RelayClientAccessAddr string `json:"relay_client_access_addr"`
|
||||
RelayClientProxyURL string `json:"relay_client_proxy_url"`
|
||||
RelayWebServerEnabled bool `json:"relay_web_server_enabled"`
|
||||
}
|
||||
|
||||
type NodeAgentUpdateInput struct {
|
||||
@@ -100,6 +101,7 @@ func CreateNode(input NodeInput) (*NodeView, error) {
|
||||
node.RelayAgentAccessAddr = strings.TrimSpace(input.RelayAgentAccessAddr)
|
||||
node.RelayClientAccessAddr = strings.TrimSpace(input.RelayClientAccessAddr)
|
||||
node.RelayClientProxyURL = strings.TrimSpace(input.RelayClientProxyURL)
|
||||
node.RelayWebServerEnabled = input.RelayWebServerEnabled
|
||||
}
|
||||
if !node.GeoManualOverride {
|
||||
applyGeoInfoFromIP(node, node.IP)
|
||||
@@ -137,6 +139,7 @@ func UpdateNode(id uint, input NodeInput) (*NodeView, error) {
|
||||
node.RelayAgentAccessAddr = strings.TrimSpace(input.RelayAgentAccessAddr)
|
||||
node.RelayClientAccessAddr = strings.TrimSpace(input.RelayClientAccessAddr)
|
||||
node.RelayClientProxyURL = strings.TrimSpace(input.RelayClientProxyURL)
|
||||
node.RelayWebServerEnabled = input.RelayWebServerEnabled
|
||||
if input.RelayBindPort > 0 {
|
||||
node.RelayBindPort = input.RelayBindPort
|
||||
}
|
||||
@@ -354,6 +357,7 @@ func buildNodeView(node *model.Node) *NodeView {
|
||||
view.RelayClientAccessAddr = node.RelayClientAccessAddr
|
||||
view.RelayClientProxyURL = node.RelayClientProxyURL
|
||||
view.RelayStatus = node.RelayStatus
|
||||
view.RelayWebServerEnabled = node.RelayWebServerEnabled
|
||||
view.Version = node.Version
|
||||
view.ExtVersion = node.ExtVersion
|
||||
return view
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"openflare/model"
|
||||
"time"
|
||||
@@ -146,24 +147,44 @@ func buildRelayDashboardSnapshot(node *model.Node, obs *model.NodeObservationFrp
|
||||
}
|
||||
totalProxies := 0
|
||||
totalConnections := 0
|
||||
clientCounts := 0
|
||||
proxies := []RelayProxyStat{}
|
||||
|
||||
if obs != nil {
|
||||
totalProxies = obs.FrpsProxyCount
|
||||
totalConnections = obs.FrpsConnections
|
||||
clientCounts = obs.FrpsClientCount
|
||||
if obs.FrpsProxies != "" {
|
||||
var decoded []RelayProxyStat
|
||||
if err := json.Unmarshal([]byte(obs.FrpsProxies), &decoded); err == nil {
|
||||
proxies = decoded
|
||||
}
|
||||
}
|
||||
}
|
||||
if totalProxies < 0 {
|
||||
totalProxies = 0
|
||||
}
|
||||
onlineProxies := totalProxies
|
||||
if node.RelayStatus != "healthy" {
|
||||
onlineProxies = 0
|
||||
onlineProxies := 0
|
||||
for _, p := range proxies {
|
||||
if p.Status == "online" {
|
||||
onlineProxies++
|
||||
}
|
||||
}
|
||||
// Fallback for backward compatibility
|
||||
if len(proxies) == 0 {
|
||||
onlineProxies = totalProxies
|
||||
if node.RelayStatus != "healthy" {
|
||||
onlineProxies = 0
|
||||
}
|
||||
}
|
||||
|
||||
return &RelayDashboardSnapshot{
|
||||
TotalProxies: totalProxies,
|
||||
OnlineProxies: onlineProxies,
|
||||
OfflineProxies: totalProxies - onlineProxies,
|
||||
Proxies: []RelayProxyStat{},
|
||||
Proxies: proxies,
|
||||
TotalConnections: maxInt(totalConnections, 0),
|
||||
ClientCounts: 0,
|
||||
ClientCounts: maxInt(clientCounts, 0),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,32 +16,40 @@ import (
|
||||
|
||||
// RelayHeartbeatPayload is the payload sent by OpenFlareRelay in each heartbeat.
|
||||
type RelayHeartbeatPayload struct {
|
||||
Version string `json:"version"`
|
||||
ExtVersion string `json:"frp_version"`
|
||||
RelayStatus string `json:"relay_status"`
|
||||
FrpsConnCount int `json:"frps_connections"`
|
||||
FrpsProxyCount int `json:"frps_proxy_count"`
|
||||
Name string `json:"name"`
|
||||
IP string `json:"ip"`
|
||||
Profile *AgentNodeSystemProfile `json:"profile,omitempty"`
|
||||
Snapshot *AgentNodeMetricSnapshot `json:"snapshot,omitempty"`
|
||||
HealthEvents []AgentNodeHealthEvent `json:"health_events,omitempty"`
|
||||
Version string `json:"version"`
|
||||
ExtVersion string `json:"frp_version"`
|
||||
RelayStatus string `json:"relay_status"`
|
||||
FrpsConnCount int `json:"frps_connections"`
|
||||
FrpsProxyCount int `json:"frps_proxy_count"`
|
||||
FrpsClientCount int `json:"frps_client_count"`
|
||||
FrpsProxies []RelayProxyStat `json:"frps_proxies,omitempty"`
|
||||
Name string `json:"name"`
|
||||
IP string `json:"ip"`
|
||||
Profile *AgentNodeSystemProfile `json:"profile,omitempty"`
|
||||
Snapshot *AgentNodeMetricSnapshot `json:"snapshot,omitempty"`
|
||||
HealthEvents []AgentNodeHealthEvent `json:"health_events,omitempty"`
|
||||
}
|
||||
|
||||
const relayFrpsUnhealthyEventType = "frps_unhealthy"
|
||||
|
||||
// RelayConfig is the frps configuration sent to the Relay.
|
||||
type RelayConfig struct {
|
||||
BindPort int `json:"bind_port"`
|
||||
VhostHTTPPort int `json:"vhost_http_port"`
|
||||
AuthToken string `json:"auth_token"`
|
||||
LogLevel string `json:"log_level"`
|
||||
BindPort int `json:"bind_port"`
|
||||
VhostHTTPPort int `json:"vhost_http_port"`
|
||||
AuthToken string `json:"auth_token"`
|
||||
LogLevel string `json:"log_level"`
|
||||
WebServerEnabled bool `json:"web_server_enabled"`
|
||||
}
|
||||
|
||||
// RelaySettings contains runtime settings for the Relay.
|
||||
type RelaySettings struct {
|
||||
HeartbeatInterval int `json:"heartbeat_interval"`
|
||||
WebsocketUpgradeEnabled bool `json:"websocket_upgrade_enabled"`
|
||||
HeartbeatInterval int `json:"heartbeat_interval"`
|
||||
WebsocketUpgradeEnabled bool `json:"websocket_upgrade_enabled"`
|
||||
AutoUpdate bool `json:"auto_update"`
|
||||
UpdateRepo string `json:"update_repo"`
|
||||
UpdateNow bool `json:"update_now"`
|
||||
UpdateChannel string `json:"update_channel"`
|
||||
UpdateTag string `json:"update_tag"`
|
||||
}
|
||||
|
||||
// RelayHeartbeatResponse is the response returned to the Relay from a heartbeat.
|
||||
@@ -63,6 +71,15 @@ func HeartbeatRelay(node *model.Node, payload RelayHeartbeatPayload) (*RelayHear
|
||||
payload.Name = strings.TrimSpace(payload.Name)
|
||||
payload.IP = strings.TrimSpace(payload.IP)
|
||||
|
||||
previous := *node
|
||||
updateNow := node.UpdateRequested
|
||||
updateChannel := normalizeReleaseChannel(node.UpdateChannel)
|
||||
updateTag := strings.TrimSpace(node.UpdateTag)
|
||||
|
||||
node.UpdateRequested = false
|
||||
node.UpdateChannel = ReleaseChannelStable.String()
|
||||
node.UpdateTag = ""
|
||||
|
||||
changes := make(map[string]any)
|
||||
appendRelayChange := func(key string, before any, after any) {
|
||||
if before != after {
|
||||
@@ -74,6 +91,16 @@ func HeartbeatRelay(node *model.Node, payload RelayHeartbeatPayload) (*RelayHear
|
||||
appendRelayChange("ext_version", node.ExtVersion, payload.ExtVersion)
|
||||
appendRelayChange("relay_status", node.RelayStatus, payload.RelayStatus)
|
||||
|
||||
if previous.UpdateRequested {
|
||||
appendRelayChange("update_requested", previous.UpdateRequested, false)
|
||||
}
|
||||
if previous.UpdateChannel != ReleaseChannelStable.String() {
|
||||
appendRelayChange("update_channel", previous.UpdateChannel, ReleaseChannelStable.String())
|
||||
}
|
||||
if previous.UpdateTag != "" {
|
||||
appendRelayChange("update_tag", previous.UpdateTag, "")
|
||||
}
|
||||
|
||||
if payload.Name != "" && strings.TrimSpace(node.Name) == "" {
|
||||
appendRelayChange("name", node.Name, payload.Name)
|
||||
node.Name = payload.Name
|
||||
@@ -113,7 +140,7 @@ func HeartbeatRelay(node *model.Node, payload RelayHeartbeatPayload) (*RelayHear
|
||||
|
||||
return &RelayHeartbeatResponse{
|
||||
RelayConfig: buildRelayConfig(node),
|
||||
RelaySettings: buildRelaySettings(),
|
||||
RelaySettings: buildRelaySettings(node, updateNow, updateChannel.String(), updateTag),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -127,8 +154,10 @@ func persistRelayHeartbeatObservability(nodeID string, payload RelayHeartbeatPay
|
||||
frpsObs := &model.NodeObservationFrps{
|
||||
NodeID: nodeID,
|
||||
CapturedAt: reportedAt,
|
||||
FrpsConnections: payload.FrpsConnCount,
|
||||
FrpsProxyCount: payload.FrpsProxyCount,
|
||||
FrpsConnections: 0,
|
||||
FrpsProxyCount: 0,
|
||||
FrpsClientCount: 0,
|
||||
FrpsProxies: "",
|
||||
}
|
||||
_ = frpsObs.Insert()
|
||||
}
|
||||
@@ -138,17 +167,30 @@ func buildRelayConfig(node *model.Node) *RelayConfig {
|
||||
return nil
|
||||
}
|
||||
return &RelayConfig{
|
||||
BindPort: node.RelayBindPort,
|
||||
VhostHTTPPort: node.RelayVhostHTTPPort,
|
||||
AuthToken: node.RelayAuthToken,
|
||||
LogLevel: "info",
|
||||
BindPort: node.RelayBindPort,
|
||||
VhostHTTPPort: node.RelayVhostHTTPPort,
|
||||
AuthToken: node.RelayAuthToken,
|
||||
LogLevel: "info",
|
||||
WebServerEnabled: node.RelayWebServerEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
func buildRelaySettings() *RelaySettings {
|
||||
func buildRelaySettings(node *model.Node, updateNow bool, updateChannel string, updateTag string) *RelaySettings {
|
||||
autoUpdate := false
|
||||
if node != nil {
|
||||
autoUpdate = node.AutoUpdateEnabled
|
||||
}
|
||||
if strings.TrimSpace(updateChannel) == "" {
|
||||
updateChannel = ReleaseChannelStable.String()
|
||||
}
|
||||
return &RelaySettings{
|
||||
HeartbeatInterval: common.AgentHeartbeatInterval,
|
||||
WebsocketUpgradeEnabled: common.AgentWebsocketUpgradeEnabled,
|
||||
AutoUpdate: autoUpdate,
|
||||
UpdateRepo: common.AgentUpdateRepo,
|
||||
UpdateNow: updateNow,
|
||||
UpdateChannel: updateChannel,
|
||||
UpdateTag: strings.TrimSpace(updateTag),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,6 +278,13 @@ func HeartbeatFlared(node *model.Node, payload FlaredHeartbeatPayload) (*FlaredH
|
||||
|
||||
now := time.Now()
|
||||
previous := *node
|
||||
updateNow := node.UpdateRequested
|
||||
updateChannel := normalizeReleaseChannel(node.UpdateChannel)
|
||||
updateTag := strings.TrimSpace(node.UpdateTag)
|
||||
|
||||
node.UpdateRequested = false
|
||||
node.UpdateChannel = ReleaseChannelStable.String()
|
||||
node.UpdateTag = ""
|
||||
|
||||
changes := make(map[string]any)
|
||||
if previous.Version != payload.ClientVersion {
|
||||
@@ -258,6 +307,16 @@ func HeartbeatFlared(node *model.Node, payload FlaredHeartbeatPayload) (*FlaredH
|
||||
node.LastSeenAt = now
|
||||
node.Status = NodeStatusOnline
|
||||
|
||||
if previous.UpdateRequested {
|
||||
changes["update_requested"] = false
|
||||
}
|
||||
if previous.UpdateChannel != ReleaseChannelStable.String() {
|
||||
changes["update_channel"] = ReleaseChannelStable.String()
|
||||
}
|
||||
if previous.UpdateTag != "" {
|
||||
changes["update_tag"] = ""
|
||||
}
|
||||
|
||||
if !node.IPManualOverride && payload.IP != "" && previous.IP != payload.IP {
|
||||
changes["ip"] = payload.IP
|
||||
node.IP = payload.IP
|
||||
@@ -290,7 +349,7 @@ func HeartbeatFlared(node *model.Node, payload FlaredHeartbeatPayload) (*FlaredH
|
||||
}
|
||||
return &FlaredHeartbeatResponse{
|
||||
ActiveConfig: activeConfig,
|
||||
TunnelSettings: buildRelaySettings(),
|
||||
TunnelSettings: buildRelaySettings(node, updateNow, updateChannel.String(), updateTag),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,8 @@ func TestHeartbeatRelayPersistsRuntimeAndObservability(t *testing.T) {
|
||||
if observability.RelayDashboard == nil {
|
||||
t.Fatal("expected relay dashboard snapshot")
|
||||
}
|
||||
if observability.RelayDashboard.TotalConnections != 7 || observability.RelayDashboard.TotalProxies != 3 {
|
||||
if observability.RelayDashboard.TotalConnections != 0 || observability.RelayDashboard.TotalProxies != 0 {
|
||||
// Frps telemetry collection is disabled; dashboard values are always zero.
|
||||
t.Fatalf("unexpected relay dashboard: %+v", observability.RelayDashboard)
|
||||
}
|
||||
}
|
||||
@@ -401,3 +402,127 @@ func TestTunnelRoutePublishAndFlaredConfigUseRelayPorts(t *testing.T) {
|
||||
t.Fatalf("unexpected proxy domains: %+v", proxy.CustomDomains)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatRelaySelfUpdatePropagationAndReset(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
node := &model.Node{
|
||||
NodeID: "relay-update-node",
|
||||
Name: "relay-u",
|
||||
IP: "1.1.1.1",
|
||||
AccessToken: "relay-update-token",
|
||||
Status: NodeStatusPending,
|
||||
NodeType: "tunnel_relay",
|
||||
AutoUpdateEnabled: true,
|
||||
UpdateRequested: true,
|
||||
UpdateChannel: "preview",
|
||||
UpdateTag: "v1.2.3",
|
||||
}
|
||||
if err := node.Insert(); err != nil {
|
||||
t.Fatalf("failed to seed relay node: %v", err)
|
||||
}
|
||||
|
||||
resp, err := HeartbeatRelay(node, RelayHeartbeatPayload{
|
||||
Version: "v1.0.0",
|
||||
ExtVersion: "0.61.0",
|
||||
RelayStatus: "healthy",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HeartbeatRelay failed: %v", err)
|
||||
}
|
||||
|
||||
if resp == nil || resp.RelaySettings == nil {
|
||||
t.Fatal("expected non-nil response with RelaySettings")
|
||||
}
|
||||
|
||||
settings := resp.RelaySettings
|
||||
if !settings.AutoUpdate {
|
||||
t.Error("expected AutoUpdate to be true")
|
||||
}
|
||||
if !settings.UpdateNow {
|
||||
t.Error("expected UpdateNow to be true")
|
||||
}
|
||||
if settings.UpdateChannel != "preview" {
|
||||
t.Errorf("expected UpdateChannel to be preview, got %q", settings.UpdateChannel)
|
||||
}
|
||||
if settings.UpdateTag != "v1.2.3" {
|
||||
t.Errorf("expected UpdateTag to be v1.2.3, got %q", settings.UpdateTag)
|
||||
}
|
||||
|
||||
// Verify that the requested update was cleared in the DB
|
||||
updated, err := model.GetNodeByNodeID(node.NodeID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to reload node: %v", err)
|
||||
}
|
||||
if updated.UpdateRequested {
|
||||
t.Error("expected UpdateRequested to be reset to false in the database")
|
||||
}
|
||||
if updated.UpdateChannel != "stable" {
|
||||
t.Errorf("expected UpdateChannel to be reset to stable, got %q", updated.UpdateChannel)
|
||||
}
|
||||
if updated.UpdateTag != "" {
|
||||
t.Errorf("expected UpdateTag to be reset to empty, got %q", updated.UpdateTag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatFlaredSelfUpdatePropagationAndReset(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
node := &model.Node{
|
||||
NodeID: "flared-update-node",
|
||||
Name: "flared-u",
|
||||
IP: "1.1.1.2",
|
||||
AccessToken: "flared-update-token",
|
||||
Status: NodeStatusPending,
|
||||
NodeType: "tunnel_client",
|
||||
AutoUpdateEnabled: true,
|
||||
UpdateRequested: true,
|
||||
UpdateChannel: "stable",
|
||||
UpdateTag: "v2.3.4",
|
||||
}
|
||||
if err := node.Insert(); err != nil {
|
||||
t.Fatalf("failed to seed flared node: %v", err)
|
||||
}
|
||||
|
||||
resp, err := HeartbeatFlared(node, FlaredHeartbeatPayload{
|
||||
ClientVersion: "v1.0.0",
|
||||
FrpVersion: "0.61.0",
|
||||
TunnelStatus: "running",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HeartbeatFlared failed: %v", err)
|
||||
}
|
||||
|
||||
if resp == nil || resp.TunnelSettings == nil {
|
||||
t.Fatal("expected non-nil response with TunnelSettings")
|
||||
}
|
||||
|
||||
settings := resp.TunnelSettings
|
||||
if !settings.AutoUpdate {
|
||||
t.Error("expected AutoUpdate to be true")
|
||||
}
|
||||
if !settings.UpdateNow {
|
||||
t.Error("expected UpdateNow to be true")
|
||||
}
|
||||
if settings.UpdateChannel != "stable" {
|
||||
t.Errorf("expected UpdateChannel to be stable, got %q", settings.UpdateChannel)
|
||||
}
|
||||
if settings.UpdateTag != "v2.3.4" {
|
||||
t.Errorf("expected UpdateTag to be v2.3.4, got %q", settings.UpdateTag)
|
||||
}
|
||||
|
||||
// Verify that the requested update was cleared in the DB
|
||||
updated, err := model.GetNodeByNodeID(node.NodeID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to reload node: %v", err)
|
||||
}
|
||||
if updated.UpdateRequested {
|
||||
t.Error("expected UpdateRequested to be reset to false in the database")
|
||||
}
|
||||
if updated.UpdateChannel != "stable" {
|
||||
t.Errorf("expected UpdateChannel to be reset to stable, got %q", updated.UpdateChannel)
|
||||
}
|
||||
if updated.UpdateTag != "" {
|
||||
t.Errorf("expected UpdateTag to be reset to empty, got %q", updated.UpdateTag)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ const nodeEditorSchema = z
|
||||
relay_client_access_addr: z.string().trim(),
|
||||
relay_agent_access_addr: z.string().trim(),
|
||||
relay_client_proxy_url: z.string().trim(),
|
||||
relay_web_server_enabled: z.boolean(),
|
||||
geo_manual_override: z.boolean(),
|
||||
geo_region: z.string(),
|
||||
geo_name: z.string().trim().max(128, '位置名不能超过 128 个字符'),
|
||||
@@ -142,6 +143,7 @@ const defaultValues: NodeEditorValues = {
|
||||
relay_client_access_addr: '',
|
||||
relay_agent_access_addr: '',
|
||||
relay_client_proxy_url: '',
|
||||
relay_web_server_enabled: true,
|
||||
geo_manual_override: false,
|
||||
geo_region: '',
|
||||
geo_name: '',
|
||||
@@ -233,6 +235,7 @@ function buildFormValues(node?: Partial<NodeItem> | null): NodeEditorValues {
|
||||
relay_client_access_addr: node.relay_client_access_addr ?? '',
|
||||
relay_agent_access_addr: node.relay_agent_access_addr ?? '',
|
||||
relay_client_proxy_url: node.relay_client_proxy_url ?? '',
|
||||
relay_web_server_enabled: node.relay_web_server_enabled ?? false,
|
||||
geo_manual_override: node.geo_manual_override ?? false,
|
||||
geo_region: node.geo_manual_override ? (node.geo_name ?? '') : '',
|
||||
geo_name: node.geo_name ?? '',
|
||||
@@ -259,6 +262,7 @@ function toPayload(values: NodeEditorValues): NodeMutationPayload {
|
||||
relay_client_access_addr: values.type === 'tunnel_relay' ? values.relay_client_access_addr.trim() : undefined,
|
||||
relay_agent_access_addr: values.type === 'tunnel_relay' ? values.relay_agent_access_addr.trim() : undefined,
|
||||
relay_client_proxy_url: values.type === 'tunnel_relay' ? values.relay_client_proxy_url.trim() : undefined,
|
||||
relay_web_server_enabled: values.type === 'tunnel_relay' ? values.relay_web_server_enabled : undefined,
|
||||
};
|
||||
|
||||
if (!values.geo_manual_override) {
|
||||
@@ -397,26 +401,26 @@ export function NodeEditorModal({
|
||||
|
||||
<ResourceField
|
||||
label="中继 Vhost HTTP 端口 (Vhost HTTP Port)"
|
||||
hint="中继服务端在此端口监听 HTTP 虚拟主机流量,用于 Nginx 转发到中继。"
|
||||
hint="中继服务端在此端口监听 HTTP 虚拟主机流量,用于 Openresty 转发到中继。"
|
||||
error={form.formState.errors.relay_vhost_http_port?.message}
|
||||
>
|
||||
<ResourceInput placeholder="8080" {...form.register('relay_vhost_http_port')} />
|
||||
</ResourceField>
|
||||
|
||||
|
||||
<ResourceField
|
||||
label="客户端接入地址 (Client Access Addr)"
|
||||
hint="可选,如果不填默认使用节点的 IP + 绑定端口。"
|
||||
error={form.formState.errors.relay_client_access_addr?.message}
|
||||
label="边缘节点接入地址 (Edge Node Access Addr)"
|
||||
hint="用于 Edge Node 转发请求。默认与 Client Access Addr 或 IP + 绑定端口一致。"
|
||||
error={form.formState.errors.relay_agent_access_addr?.message}
|
||||
>
|
||||
<ResourceInput placeholder="例如: relay.example.com:7000" {...form.register('relay_client_access_addr')} />
|
||||
<ResourceInput placeholder="例如: 10.0.0.1:7000" {...form.register('relay_agent_access_addr')} />
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="边缘节点接入地址 (Edge Node Access Addr)"
|
||||
hint="可选,用于 Edge Node 转发请求。默认与 Client Access Addr 或 IP + 绑定端口一致。"
|
||||
error={form.formState.errors.relay_agent_access_addr?.message}
|
||||
label="客户端接入地址 (Client Access Addr)"
|
||||
hint="如果不填默认使用节点的 IP + 绑定端口。"
|
||||
error={form.formState.errors.relay_client_access_addr?.message}
|
||||
>
|
||||
<ResourceInput placeholder="例如: 10.0.0.1:7000" {...form.register('relay_agent_access_addr')} />
|
||||
<ResourceInput placeholder="例如: relay.example.com:7000" {...form.register('relay_client_access_addr')} />
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
@@ -426,6 +430,18 @@ export function NodeEditorModal({
|
||||
>
|
||||
<ResourceInput placeholder="例如: http://10.0.0.1:3128" {...form.register('relay_client_proxy_url')} />
|
||||
</ResourceField>
|
||||
|
||||
<ToggleField
|
||||
label="开启 FRPS WebUI"
|
||||
description="访问端口 17500。用户 admin 密码为当前中继节点的接入 Token。"
|
||||
checked={form.watch('relay_web_server_enabled')}
|
||||
onChange={(checked) =>
|
||||
form.setValue('relay_web_server_enabled', checked, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -461,53 +461,6 @@ export function RelayDetailPage({ node }: { node: NodeItem }) {
|
||||
|
||||
{activeTab === 'dashboard' ? (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<SummaryStat
|
||||
label="运行诊断"
|
||||
value={
|
||||
activeHealthEvents.length
|
||||
? `${activeHealthEvents.length} 个活动异常`
|
||||
: '运行稳定'
|
||||
}
|
||||
hint={
|
||||
latestHealthEvent
|
||||
? `${getHealthEventLabel(latestHealthEvent)} · ${latestHealthEvent.message || '等待处理'}`
|
||||
: '当前没有活动中的健康事件'
|
||||
}
|
||||
/>
|
||||
<SummaryStat
|
||||
label="中继代理总数"
|
||||
value={
|
||||
observability?.relay_dashboard
|
||||
? `${observability.relay_dashboard.total_proxies}`
|
||||
: '—'
|
||||
}
|
||||
hint={
|
||||
observability?.relay_dashboard
|
||||
? `${observability.relay_dashboard.online_proxies} 个在线,${observability.relay_dashboard.offline_proxies} 个离线`
|
||||
: '暂无代理通道统计'
|
||||
}
|
||||
/>
|
||||
<SummaryStat
|
||||
label="活动并发连接数"
|
||||
value={
|
||||
observability?.relay_dashboard
|
||||
? `${observability.relay_dashboard.total_connections}`
|
||||
: '—'
|
||||
}
|
||||
hint="中继代理网络承载的当前活动连接总数"
|
||||
/>
|
||||
<SummaryStat
|
||||
label="活动客户端"
|
||||
value={
|
||||
observability?.relay_dashboard
|
||||
? `${observability.relay_dashboard.client_counts}`
|
||||
: '—'
|
||||
}
|
||||
hint="已注册连接到该中继的主动客户端数量"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
<AppCard title="系统画像">
|
||||
{observabilityQuery.isLoading ? (
|
||||
@@ -780,65 +733,7 @@ export function RelayDetailPage({ node }: { node: NodeItem }) {
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-1">
|
||||
<AppCard
|
||||
title="隧道活跃通道状态"
|
||||
description="展示中继节点上活跃并连接的 frps 网络隧道代理。"
|
||||
>
|
||||
{observability?.relay_dashboard?.proxies?.length ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm text-[var(--foreground-secondary)]">
|
||||
<thead className="border-b border-[var(--border-default)]">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium">
|
||||
代理名称 (Proxy Name)
|
||||
</th>
|
||||
<th className="px-4 py-3 font-medium">代理类型</th>
|
||||
<th className="px-4 py-3 font-medium">在线状态</th>
|
||||
<th className="px-4 py-3 font-medium">
|
||||
客户端接入地址
|
||||
</th>
|
||||
<th className="px-4 py-3 font-medium">
|
||||
最近连接时间
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border-default)]">
|
||||
{observability.relay_dashboard.proxies.map((proxy) => (
|
||||
<tr key={proxy.name}>
|
||||
<td className="px-4 py-3 font-medium text-[var(--foreground-primary)]">
|
||||
{proxy.name}
|
||||
</td>
|
||||
<td className="px-4 py-3">{proxy.type}</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge
|
||||
label={
|
||||
proxy.status === 'online' ? '在线' : '离线'
|
||||
}
|
||||
variant={
|
||||
proxy.status === 'online' ? 'success' : 'info'
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">{proxy.client_addr}</td>
|
||||
<td className="px-4 py-3">
|
||||
{isMeaningfulTime(proxy.last_start_time)
|
||||
? formatRelativeTime(proxy.last_start_time)
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="暂无通道活动"
|
||||
description="当前没有隧道客户端连接到该中继节点。"
|
||||
/>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -866,6 +761,21 @@ export function RelayDetailPage({ node }: { node: NodeItem }) {
|
||||
<p>Relay 中继版本:{node.version || 'unknown'}</p>
|
||||
<p>frps 核心版本:{node.ext_version || 'unknown'}</p>
|
||||
<p>中继网络接入:{node.relay_agent_access_addr || '—'}</p>
|
||||
{node.relay_web_server_enabled ? (
|
||||
<p>
|
||||
FRPS WebUI:
|
||||
<a
|
||||
href={`http://${node.ip || '127.0.0.1'}:${node.relay_bind_port + 500}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[var(--accent-strong)] hover:underline font-medium"
|
||||
>
|
||||
点击打开 Web 界面
|
||||
</a>
|
||||
</p>
|
||||
) : (
|
||||
<p>FRPS WebUI:已禁用</p>
|
||||
)}
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface NodeItem {
|
||||
relay_client_proxy_url: string;
|
||||
relay_auth_token: string;
|
||||
relay_status: string;
|
||||
relay_web_server_enabled: boolean;
|
||||
relay_frps_connections: number;
|
||||
relay_frps_proxy_count: number;
|
||||
geo_name: string;
|
||||
@@ -59,6 +60,7 @@ export interface NodeMutationPayload {
|
||||
relay_client_access_addr?: string;
|
||||
relay_agent_access_addr?: string;
|
||||
relay_client_proxy_url?: string;
|
||||
relay_web_server_enabled?: boolean;
|
||||
auto_update_enabled: boolean;
|
||||
geo_name: string;
|
||||
geo_latitude?: number | null;
|
||||
|
||||
@@ -285,12 +285,12 @@ export function buildTunnelDockerInstallCommand(
|
||||
serverUrl: string,
|
||||
tunnelToken: string,
|
||||
) {
|
||||
const image = 'ghcr.io/rain-kl/openflare-flared:latest';
|
||||
const image = 'ghcr.io/rain-kl/openflared:latest';
|
||||
|
||||
return [
|
||||
`docker pull ${image}`,
|
||||
`docker rm -f openflare-flared 2>/dev/null || true`,
|
||||
`docker run -d --name openflare-flared --restart unless-stopped \\`,
|
||||
`docker rm -f openflared 2>/dev/null || true`,
|
||||
`docker run -d --name openflared --restart unless-stopped \\`,
|
||||
` -e OPENFLARE_SERVER_URL=${serverUrl} \\`,
|
||||
` -e OPENFLARE_TUNNEL_TOKEN=${tunnelToken} \\`,
|
||||
` ${image}`,
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"openflare-flared/internal/config"
|
||||
@@ -20,7 +21,7 @@ import (
|
||||
func main() {
|
||||
// Setup simple structured logging
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelDebug,
|
||||
Level: parseLevel(os.Getenv("LOG_LEVEL")),
|
||||
})))
|
||||
|
||||
configPath := flag.String("config", "./flared.json", "flared config path")
|
||||
@@ -71,3 +72,16 @@ func main() {
|
||||
}
|
||||
slog.Info("flared process stopped")
|
||||
}
|
||||
|
||||
func parseLevel(value string) slog.Level {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "warn", "warning":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,11 +85,13 @@ func (m *Manager) UpdateConfig(ctx context.Context, newConfig *service.FlaredTun
|
||||
if newConfig == nil {
|
||||
return nil
|
||||
}
|
||||
if newConfig.Version == m.currentVersion && newConfig.Checksum == m.currentChecksum {
|
||||
return nil
|
||||
}
|
||||
|
||||
slog.Info("applying new tunnel config", "version", newConfig.Version)
|
||||
versionChanged := newConfig.Version != m.currentVersion || newConfig.Checksum != m.currentChecksum
|
||||
if versionChanged {
|
||||
slog.Info("applying new tunnel config", "version", newConfig.Version)
|
||||
} else {
|
||||
slog.Debug("tunnel config version unchanged, ensuring processes are running", "version", newConfig.Version)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(m.cfg.DataDir, 0o755); err != nil {
|
||||
return fmt.Errorf("create data dir failed: %w", err)
|
||||
@@ -102,10 +104,11 @@ func (m *Manager) UpdateConfig(ctx context.Context, newConfig *service.FlaredTun
|
||||
tomlContent := buildFrpcToml(relay, newConfig.Proxies)
|
||||
configPath := filepath.Join(m.cfg.DataDir, fmt.Sprintf("frpc_%s.toml", relay.RelayNodeID))
|
||||
|
||||
needsRestart := true
|
||||
needsRestart := false
|
||||
existingData, err := os.ReadFile(configPath)
|
||||
if err == nil && string(existingData) == tomlContent {
|
||||
needsRestart = false
|
||||
if err != nil || string(existingData) != tomlContent {
|
||||
// 配置文件不存在或内容有变化,需要写入并重启
|
||||
needsRestart = true
|
||||
}
|
||||
|
||||
if needsRestart {
|
||||
@@ -115,6 +118,8 @@ func (m *Manager) UpdateConfig(ctx context.Context, newConfig *service.FlaredTun
|
||||
}
|
||||
m.restartProcess(ctx, relay.RelayNodeID, configPath)
|
||||
} else if _, ok := m.processes[relay.RelayNodeID]; !ok {
|
||||
// 配置未变但进程不存在(如重启后),直接启动进程
|
||||
slog.Info("frpc process missing, starting", "relay_id", relay.RelayNodeID)
|
||||
m.restartProcess(ctx, relay.RelayNodeID, configPath)
|
||||
}
|
||||
}
|
||||
@@ -128,10 +133,12 @@ func (m *Manager) UpdateConfig(ctx context.Context, newConfig *service.FlaredTun
|
||||
}
|
||||
}
|
||||
|
||||
m.currentVersion = newConfig.Version
|
||||
m.currentChecksum = newConfig.Checksum
|
||||
|
||||
return m.saveState()
|
||||
if versionChanged {
|
||||
m.currentVersion = newConfig.Version
|
||||
m.currentChecksum = newConfig.Checksum
|
||||
return m.saveState()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) restartProcess(ctx context.Context, relayID string, configPath string) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"openflare-flared/internal/config"
|
||||
"openflare-flared/internal/frpc"
|
||||
"openflare-flared/internal/httpclient"
|
||||
"openflare-flared/internal/updater"
|
||||
"openflare/service"
|
||||
"openflare/utils/geoip"
|
||||
"openflare/utils/geoip/iputil"
|
||||
@@ -23,6 +24,7 @@ type Service struct {
|
||||
client *httpclient.Client
|
||||
frpcManager *frpc.Manager
|
||||
config *config.Config
|
||||
updater *updater.Service
|
||||
}
|
||||
|
||||
func New(client *httpclient.Client, manager *frpc.Manager, cfg *config.Config) *Service {
|
||||
@@ -30,6 +32,7 @@ func New(client *httpclient.Client, manager *frpc.Manager, cfg *config.Config) *
|
||||
client: client,
|
||||
frpcManager: manager,
|
||||
config: cfg,
|
||||
updater: updater.New(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,12 +68,40 @@ func (s *Service) doHeartbeat(ctx context.Context) {
|
||||
CurrentChecksum: s.frpcManager.GetCurrentConfigChecksum(),
|
||||
}
|
||||
|
||||
_, err := s.client.Heartbeat(ctx, payload)
|
||||
resp, err := s.client.Heartbeat(ctx, payload)
|
||||
if err != nil {
|
||||
slog.Error("flared heartbeat failed", "error", err)
|
||||
return
|
||||
}
|
||||
slog.Debug("flared heartbeat succeeded")
|
||||
|
||||
if resp != nil && resp.TunnelSettings != nil {
|
||||
s.tryAutoUpdate(ctx, resp.TunnelSettings)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) tryAutoUpdate(ctx context.Context, settings *service.RelaySettings) {
|
||||
if settings == nil || s.updater == nil {
|
||||
return
|
||||
}
|
||||
force := settings.UpdateNow
|
||||
shouldCheck := settings.AutoUpdate || force
|
||||
if !shouldCheck || settings.UpdateRepo == "" {
|
||||
return
|
||||
}
|
||||
channel := "stable"
|
||||
if force && settings.UpdateChannel != "" {
|
||||
channel = settings.UpdateChannel
|
||||
}
|
||||
slog.Info("checking for client updates", "repo", settings.UpdateRepo, "channel", channel, "force", force)
|
||||
err := s.updater.CheckAndUpdate(ctx, settings.UpdateRepo, updater.UpdateOptions{
|
||||
Channel: channel,
|
||||
TagName: settings.UpdateTag,
|
||||
Force: force,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("client update check failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func detectNodeIP() string {
|
||||
|
||||
@@ -61,12 +61,8 @@ func (s *Service) doSync(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if s.frpcManager.GetCurrentConfigVersion() == configResp.Version &&
|
||||
s.frpcManager.GetCurrentConfigChecksum() == configResp.Checksum {
|
||||
slog.Debug("tunnel config is up to date", "version", configResp.Version)
|
||||
return
|
||||
}
|
||||
|
||||
// 不在 sync 层做版本早退,由 frpcManager.UpdateConfig 负责判断。
|
||||
// 原因:重启后进程全部消失,即使版本/checksum 未变,仍需重新拉起 frpc 进程。
|
||||
err = s.frpcManager.UpdateConfig(ctx, configResp)
|
||||
|
||||
result := "success"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
//go:build !windows
|
||||
|
||||
package updater
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func replaceAndRestart(execPath string, tmpPath string) error {
|
||||
backupPath := execPath + ".bak"
|
||||
if err := removeBackupBinary(backupPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(execPath, backupPath); err != nil {
|
||||
renameErr := err
|
||||
if err := os.Remove(tmpPath); err != nil && !os.IsNotExist(err) {
|
||||
slog.Error("remove tmp binary failed", "path", tmpPath, "error", err)
|
||||
return fmt.Errorf("backup current binary: %w; remove tmp binary: %v", renameErr, err)
|
||||
}
|
||||
return fmt.Errorf("backup current binary: %w", renameErr)
|
||||
}
|
||||
if err := os.Rename(tmpPath, execPath); err != nil {
|
||||
replaceErr := err
|
||||
if err := os.Rename(backupPath, execPath); err != nil {
|
||||
slog.Error("restore backup binary failed", "path", backupPath, "error", err)
|
||||
return fmt.Errorf("replace binary: %w; restore backup binary: %v", replaceErr, err)
|
||||
}
|
||||
return fmt.Errorf("replace binary: %w", replaceErr)
|
||||
}
|
||||
if err := removeBackupBinary(backupPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := syscall.Exec(execPath, os.Args, os.Environ()); err != nil {
|
||||
return fmt.Errorf("exec restart: %w", err)
|
||||
}
|
||||
return fmt.Errorf("unreachable after exec")
|
||||
}
|
||||
|
||||
func removeBackupBinary(path string) error {
|
||||
if err := os.Remove(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
slog.Error("remove backup binary failed", "path", path, "error", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//go:build windows
|
||||
|
||||
package updater
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func replaceAndRestart(execPath string, tmpPath string) error {
|
||||
backupPath := execPath + ".bak"
|
||||
scriptPath := execPath + ".update.cmd"
|
||||
script := fmt.Sprintf(`@echo off
|
||||
setlocal
|
||||
:waitloop
|
||||
move /Y "%s" "%s" >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
ping 127.0.0.1 -n 2 >nul
|
||||
goto waitloop
|
||||
)
|
||||
move /Y "%s" "%s" >nul 2>nul
|
||||
if errorlevel 1 exit /b 1
|
||||
start "" %s
|
||||
del /Q "%s" >nul 2>nul
|
||||
del /Q "%%~f0" >nul 2>nul
|
||||
`, execPath, backupPath, tmpPath, execPath, buildWindowsCommandLine(execPath, os.Args[1:]), backupPath)
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0o700); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("write restart script: %w", err)
|
||||
}
|
||||
cmd := exec.Command("cmd", "/C", "start", "", scriptPath)
|
||||
if err := cmd.Start(); err != nil {
|
||||
os.Remove(scriptPath)
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("schedule restart: %w", err)
|
||||
}
|
||||
os.Exit(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildWindowsCommandLine(execPath string, args []string) string {
|
||||
parts := []string{quoteWindowsArg(execPath)}
|
||||
for _, arg := range args {
|
||||
parts = append(parts, quoteWindowsArg(arg))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func quoteWindowsArg(value string) string {
|
||||
return `"` + strings.ReplaceAll(value, `"`, `""`) + `"`
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"openflare/utils"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"openflare-flared/internal/config"
|
||||
)
|
||||
|
||||
const maxChecksumAssetSize = 64 * 1024
|
||||
|
||||
var replaceAndRestartFunc = replaceAndRestart
|
||||
|
||||
type Service struct {
|
||||
httpClient *http.Client
|
||||
lastCheckKey string
|
||||
}
|
||||
|
||||
func New() *Service {
|
||||
return &Service{
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
type githubRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
Draft bool `json:"draft"`
|
||||
Assets []githubAsset `json:"assets"`
|
||||
}
|
||||
|
||||
type githubAsset struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
}
|
||||
|
||||
type UpdateOptions struct {
|
||||
Channel string
|
||||
TagName string
|
||||
Force bool
|
||||
}
|
||||
|
||||
func (s *Service) CheckAndUpdate(ctx context.Context, repo string, options UpdateOptions) error {
|
||||
release, err := s.getRelease(ctx, repo, options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check latest release: %w", err)
|
||||
}
|
||||
if release == nil || release.TagName == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
remoteVersion := normalizeVersion(release.TagName)
|
||||
localVersion := normalizeVersion(config.Version)
|
||||
checkKey := buildReleaseCheckKey(options, remoteVersion)
|
||||
|
||||
if remoteVersion == localVersion {
|
||||
return nil
|
||||
}
|
||||
if !options.Force && checkKey != "" && checkKey == s.lastCheckKey {
|
||||
return nil
|
||||
}
|
||||
if !isNewer(localVersion, remoteVersion) {
|
||||
s.lastCheckKey = checkKey
|
||||
return nil
|
||||
}
|
||||
|
||||
slog.Info("flared update available", "from", localVersion, "to", remoteVersion)
|
||||
assetName := assetNameForGOOSGOARCH(runtime.GOOS, runtime.GOARCH)
|
||||
checksumAssetName := assetName + ".sha256"
|
||||
|
||||
var downloadURL string
|
||||
var checksumURL string
|
||||
for _, asset := range release.Assets {
|
||||
switch asset.Name {
|
||||
case assetName:
|
||||
downloadURL = asset.BrowserDownloadURL
|
||||
case checksumAssetName:
|
||||
checksumURL = asset.BrowserDownloadURL
|
||||
}
|
||||
}
|
||||
if downloadURL == "" {
|
||||
s.lastCheckKey = checkKey
|
||||
return fmt.Errorf("no matching asset %q in release %s", assetName, release.TagName)
|
||||
}
|
||||
if checksumURL == "" {
|
||||
return fmt.Errorf("no matching checksum asset %q in release %s", checksumAssetName, release.TagName)
|
||||
}
|
||||
|
||||
expectedChecksum, err := s.downloadChecksum(ctx, checksumURL, assetName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download checksum: %w", err)
|
||||
}
|
||||
|
||||
execPath, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get executable path: %w", err)
|
||||
}
|
||||
if err = s.downloadAndRestart(ctx, downloadURL, expectedChecksum, execPath); err != nil {
|
||||
return fmt.Errorf("download and restart: %w", err)
|
||||
}
|
||||
s.lastCheckKey = checkKey
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) getRelease(ctx context.Context, repo string, options UpdateOptions) (*githubRelease, error) {
|
||||
tagName := strings.TrimSpace(options.TagName)
|
||||
if tagName != "" {
|
||||
return s.getReleaseByTag(ctx, repo, tagName)
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(options.Channel), "preview") {
|
||||
return s.getLatestPreviewRelease(ctx, repo)
|
||||
}
|
||||
return s.getLatestStableRelease(ctx, repo)
|
||||
}
|
||||
|
||||
func (s *Service) getLatestStableRelease(ctx context.Context, repo string) (*githubRelease, error) {
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo)
|
||||
return s.fetchReleaseFromURL(ctx, url)
|
||||
}
|
||||
|
||||
func (s *Service) getLatestPreviewRelease(ctx context.Context, repo string) (*githubRelease, error) {
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=20", repo)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("github api returned %s", resp.Status)
|
||||
}
|
||||
|
||||
var releases []githubRelease
|
||||
if err = json.NewDecoder(resp.Body).Decode(&releases); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, release := range releases {
|
||||
if release.Draft || !release.Prerelease {
|
||||
continue
|
||||
}
|
||||
releaseCopy := release
|
||||
return &releaseCopy, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *Service) getReleaseByTag(ctx context.Context, repo string, tag string) (*githubRelease, error) {
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/tags/%s", repo, strings.TrimSpace(tag))
|
||||
return s.fetchReleaseFromURL(ctx, url)
|
||||
}
|
||||
|
||||
func (s *Service) fetchReleaseFromURL(ctx context.Context, url string) (*githubRelease, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func(Body io.ReadCloser) {
|
||||
err := Body.Close()
|
||||
if err != nil {
|
||||
slog.Error("failed to close response body", "error", err)
|
||||
}
|
||||
}(resp.Body)
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("github api returned %s", resp.Status)
|
||||
}
|
||||
|
||||
return decodeRelease(resp.Body)
|
||||
}
|
||||
|
||||
func decodeRelease(reader io.Reader) (*githubRelease, error) {
|
||||
var release githubRelease
|
||||
if err := json.NewDecoder(reader).Decode(&release); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &release, nil
|
||||
}
|
||||
|
||||
func (s *Service) downloadChecksum(ctx context.Context, url string, assetName string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("checksum download returned %s", resp.Status)
|
||||
}
|
||||
|
||||
content, err := io.ReadAll(io.LimitReader(resp.Body, maxChecksumAssetSize+1))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(content) > maxChecksumAssetSize {
|
||||
return "", fmt.Errorf("checksum asset exceeds %d bytes", maxChecksumAssetSize)
|
||||
}
|
||||
checksum, err := parseSHA256Checksum(string(content), assetName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return checksum, nil
|
||||
}
|
||||
|
||||
func parseSHA256Checksum(content string, assetName string) (string, error) {
|
||||
assetName = strings.TrimSpace(assetName)
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if checksum, ok := parseSHA256Line(line, assetName); ok {
|
||||
return checksum, nil
|
||||
}
|
||||
}
|
||||
if assetName == "" {
|
||||
return "", fmt.Errorf("checksum asset does not contain a valid sha256 digest")
|
||||
}
|
||||
return "", fmt.Errorf("checksum asset does not contain a sha256 digest for %q", assetName)
|
||||
}
|
||||
|
||||
func parseSHA256Line(line string, assetName string) (string, bool) {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 1 && isSHA256Hex(fields[0]) {
|
||||
return strings.ToLower(fields[0]), true
|
||||
}
|
||||
if len(fields) >= 2 && isSHA256Hex(fields[0]) {
|
||||
fileName := strings.TrimPrefix(strings.TrimSpace(fields[1]), "*")
|
||||
if assetName == "" || fileName == assetName {
|
||||
return strings.ToLower(fields[0]), true
|
||||
}
|
||||
}
|
||||
|
||||
prefix := "SHA256("
|
||||
if strings.HasPrefix(line, prefix) {
|
||||
closing := strings.Index(line, ")")
|
||||
if closing > len(prefix) && closing+1 < len(line) {
|
||||
fileName := strings.TrimSpace(line[len(prefix):closing])
|
||||
rest := strings.TrimSpace(line[closing+1:])
|
||||
rest = strings.TrimPrefix(rest, "=")
|
||||
rest = strings.TrimSpace(rest)
|
||||
if isSHA256Hex(rest) && (assetName == "" || fileName == assetName) {
|
||||
return strings.ToLower(rest), true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func isSHA256Hex(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) != sha256.Size*2 {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(value)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *Service) downloadAndRestart(ctx context.Context, url string, expectedChecksum string, targetPath string) error {
|
||||
expectedChecksum = strings.ToLower(strings.TrimSpace(expectedChecksum))
|
||||
if !isSHA256Hex(expectedChecksum) {
|
||||
return fmt.Errorf("invalid expected sha256 checksum")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download returned %s", resp.Status)
|
||||
}
|
||||
|
||||
tmpPath := targetPath + ".update"
|
||||
if runtime.GOOS == "windows" && !strings.HasSuffix(strings.ToLower(tmpPath), ".exe") {
|
||||
tmpPath += ".exe"
|
||||
}
|
||||
tmpFile, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hasher := sha256.New()
|
||||
if _, err = io.Copy(io.MultiWriter(tmpFile, hasher), resp.Body); err != nil {
|
||||
tmpFile.Close()
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err = tmpFile.Close(); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
actualChecksum := hex.EncodeToString(hasher.Sum(nil))
|
||||
if actualChecksum != expectedChecksum {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("sha256 checksum mismatch: expected %s, got %s", expectedChecksum, actualChecksum)
|
||||
}
|
||||
if err = os.Chmod(tmpPath, 0o755); err != nil && runtime.GOOS != "windows" {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("set executable permission: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("flared binary updated, restarting")
|
||||
return replaceAndRestartFunc(targetPath, tmpPath)
|
||||
}
|
||||
|
||||
func assetNameForGOOSGOARCH(goos string, goarch string) string {
|
||||
name := fmt.Sprintf("openflared-%s-%s", goos, goarch)
|
||||
if goos == "windows" {
|
||||
return name + ".exe"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func normalizeVersion(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
v = strings.TrimPrefix(v, "v")
|
||||
return v
|
||||
}
|
||||
|
||||
func isNewer(local, remote string) bool {
|
||||
return compareVersions(local, remote) < 0
|
||||
}
|
||||
|
||||
func buildReleaseCheckKey(options UpdateOptions, remoteVersion string) string {
|
||||
channel := strings.TrimSpace(options.Channel)
|
||||
if channel == "" {
|
||||
channel = "stable"
|
||||
}
|
||||
if tagName := strings.TrimSpace(options.TagName); tagName != "" {
|
||||
return channel + ":" + tagName
|
||||
}
|
||||
return channel + ":" + remoteVersion
|
||||
}
|
||||
|
||||
func compareVersions(local string, remote string) int {
|
||||
return utils.CompareVersions(local, remote)
|
||||
}
|
||||
Reference in New Issue
Block a user