Compare commits

..

11 Commits

38 changed files with 2237 additions and 418 deletions
+2 -2
View File
@@ -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
View File
@@ -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 }}
+16 -2
View File
@@ -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
}
}
+34 -7
View File
@@ -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)
}
+43 -10
View File
@@ -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, `"`, `""`) + `"`
}
+370
View File
@@ -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)
}
+6
View File
@@ -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)
}
+58
View File
@@ -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
}
+33
View File
@@ -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{}
+1
View File
@@ -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"`
}
+1
View File
@@ -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) {
+53 -2
View File
@@ -89,6 +89,10 @@ type snapshotRoute struct {
BasicAuthUsername string `json:"basic_auth_username,omitempty"`
BasicAuthPassword string `json:"basic_auth_password,omitempty"`
Remark string `json:"remark,omitempty"`
UpstreamType string `json:"upstream_type,omitempty"`
TunnelNodeID *uint `json:"tunnel_node_id,omitempty"`
TunnelTargetAddr string `json:"tunnel_target_addr,omitempty"`
TunnelTargetProto string `json:"tunnel_target_protocol,omitempty"`
}
type snapshotWAFRuleGroup struct {
@@ -500,10 +504,22 @@ func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) {
if err != nil {
return nil, fmt.Errorf("路由 %s 自定义请求头无效", route.Domain)
}
upstreamType := normalizeUpstreamType(route.UpstreamType)
originURL := route.OriginURL
upstreams, err := decodeStoredUpstreams(route.Upstreams, route.OriginURL)
if err != nil {
return nil, fmt.Errorf("路由 %s 上游配置无效", route.Domain)
}
var tunnelNodeID *uint
var tunnelTargetAddr string
var tunnelTargetProtocol string
if upstreamType == "tunnel" {
originURL = resolveTunnelOpenRestyUpstreamURL()
upstreams = []string{originURL}
tunnelNodeID = route.TunnelNodeID
tunnelTargetAddr = strings.TrimSpace(route.TunnelTargetAddr)
tunnelTargetProtocol = normalizeTunnelTargetProtocol(route.TunnelTargetProtocol)
}
cacheRules, err := decodeStoredCacheRules(route.CacheRules)
if err != nil {
return nil, fmt.Errorf("路由 %s 缓存规则无效", route.Domain)
@@ -520,7 +536,7 @@ func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) {
SiteName: normalizeProxyRouteSiteNameInput(route, route.SiteName, domains[0]),
Domain: domains[0],
Domains: domains,
OriginURL: route.OriginURL,
OriginURL: originURL,
OriginHost: route.OriginHost,
Upstreams: upstreams,
Enabled: route.Enabled,
@@ -542,11 +558,30 @@ func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) {
BasicAuthUsername: route.BasicAuthUsername,
BasicAuthPassword: route.BasicAuthPassword,
Remark: route.Remark,
UpstreamType: upstreamType,
TunnelNodeID: tunnelNodeID,
TunnelTargetAddr: tunnelTargetAddr,
TunnelTargetProto: tunnelTargetProtocol,
})
}
return items, nil
}
func resolveTunnelOpenRestyUpstreamURL() string {
relayNodes, err := model.ListNodesByType("tunnel_relay")
if err == nil && len(relayNodes) > 0 {
for _, node := range relayNodes {
if node != nil {
addr := relayAgentAddress(node)
if addr != "" {
return "http://" + addr
}
}
}
}
return "http://127.0.0.1:8080"
}
func buildSnapshotWAFDocument(routes []*model.ProxyRoute) (snapshotWAFDocument, error) {
if err := EnsureDefaultWAFRuleGroup(); err != nil {
return snapshotWAFDocument{}, err
@@ -778,6 +813,15 @@ func normalizeSnapshotRoutes(routes []snapshotRoute) []snapshotRoute {
routes[index].BasicAuthUsername = ""
routes[index].BasicAuthPassword = ""
}
routes[index].UpstreamType = normalizeUpstreamType(routes[index].UpstreamType)
if routes[index].UpstreamType == "tunnel" {
routes[index].TunnelTargetAddr = strings.TrimSpace(routes[index].TunnelTargetAddr)
routes[index].TunnelTargetProto = normalizeTunnelTargetProtocol(routes[index].TunnelTargetProto)
} else {
routes[index].TunnelNodeID = nil
routes[index].TunnelTargetAddr = ""
routes[index].TunnelTargetProto = ""
}
}
return routes
}
@@ -803,7 +847,7 @@ func flattenSnapshotRoutesByDomain(routes []snapshotRoute) map[string]snapshotRo
}
func snapshotRouteConfigEqual(left snapshotRoute, right snapshotRoute) bool {
if left.SiteName != right.SiteName || left.Domain != right.Domain || left.OriginURL != right.OriginURL || left.OriginHost != right.OriginHost || left.EnableHTTPS != right.EnableHTTPS || left.RedirectHTTP != right.RedirectHTTP || left.LimitConnPerServer != right.LimitConnPerServer || left.LimitConnPerIP != right.LimitConnPerIP || left.LimitRate != right.LimitRate || left.CacheEnabled != right.CacheEnabled || left.CachePolicy != right.CachePolicy || left.PoWEnabled != right.PoWEnabled || left.BasicAuthEnabled != right.BasicAuthEnabled || left.BasicAuthUsername != right.BasicAuthUsername || left.BasicAuthPassword != right.BasicAuthPassword || !uintSliceEqual(left.CertIDs, right.CertIDs) || !uintSliceEqual(left.DomainCertIDs, right.DomainCertIDs) {
if left.SiteName != right.SiteName || left.Domain != right.Domain || left.OriginURL != right.OriginURL || left.OriginHost != right.OriginHost || left.EnableHTTPS != right.EnableHTTPS || left.RedirectHTTP != right.RedirectHTTP || left.LimitConnPerServer != right.LimitConnPerServer || left.LimitConnPerIP != right.LimitConnPerIP || left.LimitRate != right.LimitRate || left.CacheEnabled != right.CacheEnabled || left.CachePolicy != right.CachePolicy || left.PoWEnabled != right.PoWEnabled || left.BasicAuthEnabled != right.BasicAuthEnabled || left.BasicAuthUsername != right.BasicAuthUsername || left.BasicAuthPassword != right.BasicAuthPassword || left.UpstreamType != right.UpstreamType || !uintPtrEqual(left.TunnelNodeID, right.TunnelNodeID) || left.TunnelTargetAddr != right.TunnelTargetAddr || left.TunnelTargetProto != right.TunnelTargetProto || !uintSliceEqual(left.CertIDs, right.CertIDs) || !uintSliceEqual(left.DomainCertIDs, right.DomainCertIDs) {
return false
}
if len(left.Domains) != len(right.Domains) {
@@ -1165,6 +1209,13 @@ func uintSliceEqual(left []uint, right []uint) bool {
return true
}
func uintPtrEqual(left *uint, right *uint) bool {
if left == nil || right == nil {
return left == nil && right == nil
}
return *left == *right
}
func nextVersionNumber(now time.Time) (string, error) {
prefix := now.Format("20060102")
var latest model.ConfigVersion
+4
View File
@@ -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
+26 -5
View File
@@ -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),
}
}
+46 -1
View File
@@ -63,6 +63,7 @@ type ProxyRouteInput struct {
Remark string `json:"remark"`
UpstreamType string `json:"upstream_type"`
TunnelNodeID *uint `json:"tunnel_node_id"`
TunnelID *uint `json:"tunnel_id"`
TunnelTargetAddr string `json:"tunnel_target_addr"`
TunnelTargetProtocol string `json:"tunnel_target_protocol"`
}
@@ -102,6 +103,7 @@ type ProxyRouteView struct {
Remark string `json:"remark"`
UpstreamType string `json:"upstream_type"`
TunnelNodeID *uint `json:"tunnel_node_id"`
TunnelID *uint `json:"tunnel_id"`
TunnelTargetAddr string `json:"tunnel_target_addr"`
TunnelTargetProtocol string `json:"tunnel_target_protocol"`
CreatedAt time.Time `json:"created_at"`
@@ -327,7 +329,14 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro
route.Remark = remark
route.UpstreamType = upstreamType
if upstreamType == "tunnel" {
route.TunnelNodeID = input.TunnelNodeID
tunnelNodeID, err := normalizeTunnelNodeID(input.TunnelNodeID, input.TunnelID)
if err != nil {
return nil, err
}
if err := validateTunnelRouteInput(tunnelNodeID, input.TunnelTargetAddr, input.TunnelTargetProtocol); err != nil {
return nil, err
}
route.TunnelNodeID = tunnelNodeID
route.TunnelTargetAddr = strings.TrimSpace(input.TunnelTargetAddr)
route.TunnelTargetProtocol = normalizeTunnelTargetProtocol(input.TunnelTargetProtocol)
} else {
@@ -422,6 +431,7 @@ func buildProxyRouteView(route *model.ProxyRoute) (*ProxyRouteView, error) {
Remark: route.Remark,
UpstreamType: route.UpstreamType,
TunnelNodeID: route.TunnelNodeID,
TunnelID: route.TunnelNodeID,
TunnelTargetAddr: route.TunnelTargetAddr,
TunnelTargetProtocol: route.TunnelTargetProtocol,
CreatedAt: route.CreatedAt,
@@ -429,6 +439,41 @@ func buildProxyRouteView(route *model.ProxyRoute) (*ProxyRouteView, error) {
}, nil
}
func normalizeTunnelNodeID(tunnelNodeID *uint, legacyTunnelID *uint) (*uint, error) {
if tunnelNodeID != nil && *tunnelNodeID != 0 {
return tunnelNodeID, nil
}
if legacyTunnelID != nil && *legacyTunnelID != 0 {
return legacyTunnelID, nil
}
return nil, errors.New("tunnel_node_id is required for tunnel upstream")
}
func validateTunnelRouteInput(tunnelNodeID *uint, targetAddr string, targetProtocol string) error {
if tunnelNodeID == nil || *tunnelNodeID == 0 {
return errors.New("tunnel_node_id is required for tunnel upstream")
}
tunnelNode, err := model.GetNodeByID(*tunnelNodeID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("tunnel client node does not exist")
}
return err
}
if tunnelNode.NodeType != "tunnel_client" {
return errors.New("tunnel_node_id must reference a tunnel_client node")
}
if strings.TrimSpace(targetAddr) == "" {
return errors.New("tunnel_target_addr is required for tunnel upstream")
}
switch strings.ToLower(strings.TrimSpace(targetProtocol)) {
case "", "http", "https":
return nil
default:
return errors.New("tunnel_target_protocol must be http or https")
}
}
func normalizeProxyRouteSiteNameInput(route *model.ProxyRoute, raw string, primaryDomain string) string {
siteName := strings.TrimSpace(raw)
if siteName != "" {
+155 -29
View File
@@ -4,8 +4,10 @@ import (
"errors"
"fmt"
"log/slog"
"net"
"openflare/common"
"openflare/model"
"strconv"
"strings"
"time"
@@ -14,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.
@@ -61,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 {
@@ -72,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
@@ -111,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
}
@@ -125,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()
}
@@ -136,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),
}
}
@@ -189,6 +233,7 @@ func normalizeRelayStatus(status string) string {
type FlaredHeartbeatPayload struct {
ClientVersion string `json:"client_version"`
FrpVersion string `json:"frp_version"`
IP string `json:"ip"`
TunnelStatus string `json:"tunnel_status"`
ConnectedRelays []FlaredConnectedRelay `json:"connected_relays"`
CurrentVersion string `json:"current_version"`
@@ -198,6 +243,7 @@ type FlaredHeartbeatPayload struct {
func normalizeFlaredHeartbeatPayload(payload FlaredHeartbeatPayload) FlaredHeartbeatPayload {
payload.ClientVersion = strings.TrimSpace(payload.ClientVersion)
payload.FrpVersion = strings.TrimSpace(payload.FrpVersion)
payload.IP = strings.TrimSpace(payload.IP)
payload.TunnelStatus = strings.ToLower(strings.TrimSpace(payload.TunnelStatus))
payload.CurrentVersion = strings.TrimSpace(payload.CurrentVersion)
payload.CurrentChecksum = strings.TrimSpace(payload.CurrentChecksum)
@@ -232,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 {
@@ -253,8 +306,33 @@ func HeartbeatFlared(node *model.Node, payload FlaredHeartbeatPayload) (*FlaredH
node.CurrentVersion = payload.CurrentVersion
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
}
if !node.GeoManualOverride {
applyGeoInfoFromIP(node, node.IP)
if previous.GeoName != node.GeoName {
changes["geo_name"] = node.GeoName
}
if !coordinatesEqual(previous.GeoLatitude, node.GeoLatitude) {
changes["geo_latitude"] = node.GeoLatitude
}
if !coordinatesEqual(previous.GeoLongitude, node.GeoLongitude) {
changes["geo_longitude"] = node.GeoLongitude
}
}
if len(changes) > 0 {
@@ -271,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
}
@@ -369,10 +447,7 @@ func GetFlaredTunnelConfig(node *model.Node) (*FlaredTunnelConfigResponse, error
relays := make([]FlaredRelayInfo, 0, len(relayNodes))
for _, node := range relayNodes {
if node.RelayStatus == "healthy" || node.Status == NodeStatusOnline {
addr := strings.TrimSpace(node.RelayClientAccessAddr)
if addr == "" {
addr = fmt.Sprintf("%s:%d", strings.TrimSpace(node.IP), node.RelayBindPort)
}
addr := relayClientAddress(node)
relays = append(relays, FlaredRelayInfo{
RelayNodeID: node.NodeID,
Address: addr,
@@ -413,6 +488,57 @@ func GetFlaredTunnelConfig(node *model.Node) (*FlaredTunnelConfigResponse, error
}, nil
}
func relayClientAddress(node *model.Node) string {
if node == nil {
return ""
}
port := node.RelayBindPort
if port <= 0 {
port = 7000
}
addr := strings.TrimSpace(node.RelayClientAccessAddr)
if addr == "" {
addr = strings.TrimSpace(node.IP)
}
if addr == "" {
return fmt.Sprintf("127.0.0.1:%d", port)
}
if _, _, err := net.SplitHostPort(addr); err == nil {
return addr
}
if strings.Contains(addr, ":") && strings.Count(addr, ":") > 1 {
return net.JoinHostPort(addr, strconv.Itoa(port))
}
return fmt.Sprintf("%s:%d", addr, port)
}
func relayAgentAddress(node *model.Node) string {
if node == nil {
return ""
}
port := node.RelayVhostHTTPPort
if port <= 0 {
port = 8080
}
addr := strings.TrimSpace(node.RelayAgentAccessAddr)
if addr == "" {
addr = strings.TrimSpace(node.RelayClientAccessAddr)
}
if addr == "" {
addr = strings.TrimSpace(node.IP)
}
if addr == "" {
return fmt.Sprintf("127.0.0.1:%d", port)
}
if _, _, err := net.SplitHostPort(addr); err == nil {
return addr
}
if strings.Contains(addr, ":") && strings.Count(addr, ":") > 1 {
return net.JoinHostPort(addr, strconv.Itoa(port))
}
return fmt.Sprintf("%s:%d", addr, port)
}
func parseTunnelTargetAddr(addr string) (string, int) {
addr = strings.TrimSpace(addr)
if addr == "" {
+232 -1
View File
@@ -3,6 +3,7 @@ package service
import (
"errors"
"openflare/model"
"strings"
"testing"
"time"
@@ -92,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)
}
}
@@ -149,6 +151,7 @@ func TestHeartbeatFlaredPersistsRuntime(t *testing.T) {
resp, err := HeartbeatFlared(node, FlaredHeartbeatPayload{
ClientVersion: " v0.2.0 ",
FrpVersion: " 0.61.1 ",
IP: " 192.168.1.10 ",
TunnelStatus: " RUNNING ",
ConnectedRelays: []FlaredConnectedRelay{
{RelayNodeID: " node-relay-1 ", Status: " HEALTHY ", ProxyCount: 3},
@@ -184,12 +187,39 @@ func TestHeartbeatFlaredPersistsRuntime(t *testing.T) {
if updated.ExtVersion != "0.61.1" {
t.Fatalf("expected frp_version to be trimmed and stored, got %q", updated.ExtVersion)
}
if updated.IP != "192.168.1.10" {
t.Fatalf("expected IP to be trimmed and stored, got %q", updated.IP)
}
if updated.CurrentVersion != "v1" {
t.Fatalf("expected current_version to be stored, got %q", updated.CurrentVersion)
}
if updated.LastSeenAt.IsZero() {
t.Fatal("expected last_seen_at to be updated")
}
// Test IPManualOverride
updated.IPManualOverride = true
if err := updated.Update(); err != nil {
t.Fatalf("failed to lock IP: %v", err)
}
_, err = HeartbeatFlared(updated, FlaredHeartbeatPayload{
ClientVersion: "v0.2.0",
FrpVersion: "0.61.1",
IP: "10.0.0.99",
TunnelStatus: "running",
})
if err != nil {
t.Fatalf("second HeartbeatFlared failed: %v", err)
}
lockedNode, err := model.GetNodeByNodeID(node.NodeID)
if err != nil {
t.Fatalf("failed to reload locked node: %v", err)
}
if lockedNode.IP != "192.168.1.10" {
t.Fatalf("expected IP to stay locked at 192.168.1.10, but got %q", lockedNode.IP)
}
}
func TestHeartbeatFlaredTrimsAndFiltersRelays(t *testing.T) {
@@ -295,3 +325,204 @@ func TestGetFlaredTunnelConfigRequiresActiveVersion(t *testing.T) {
t.Logf("GetFlaredTunnelConfig returned wrapped error: %v", err)
}
}
func TestTunnelRoutePublishAndFlaredConfigUseRelayPorts(t *testing.T) {
setupServiceTestDB(t)
relayNode := &model.Node{
NodeID: "node-relay-ports",
Name: "relay-ports",
IP: "85.235.64.179",
AccessToken: "relay-token-ports",
Status: NodeStatusOnline,
NodeType: "tunnel_relay",
RelayStatus: "healthy",
RelayBindPort: 17000,
RelayVhostHTTPPort: 18080,
RelayAuthToken: "relay-auth-token",
RelayClientAccessAddr: "de-e",
}
if err := relayNode.Insert(); err != nil {
t.Fatalf("failed to seed relay node: %v", err)
}
tunnelNode := &model.Node{
NodeID: "node-flared-ports",
Name: "flared-ports",
IP: "",
AccessToken: "tunnel-token-ports",
Status: NodeStatusOnline,
NodeType: "tunnel_client",
Version: "v0.2.0",
}
if err := tunnelNode.Insert(); err != nil {
t.Fatalf("failed to seed tunnel client node: %v", err)
}
route, err := CreateProxyRoute(ProxyRouteInput{
Domain: "flared.example.com",
UpstreamType: "tunnel",
TunnelID: &tunnelNode.ID,
TunnelTargetAddr: "10.0.0.8:8080",
TunnelTargetProtocol: "http",
Enabled: true,
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
if route.TunnelNodeID == nil || *route.TunnelNodeID != tunnelNode.ID {
t.Fatalf("expected legacy tunnel_id to bind tunnel_node_id, got %+v", route.TunnelNodeID)
}
result, err := PublishConfigVersion("root", false)
if err != nil {
t.Fatalf("PublishConfigVersion failed: %v", err)
}
if !strings.Contains(result.Version.RenderedConfig, "server de-e:18080 max_fails=3 fail_timeout=10s;") {
t.Fatalf("expected rendered OpenResty upstream to use relay vhost port, got:\n%s", result.Version.RenderedConfig)
}
config, err := GetFlaredTunnelConfig(tunnelNode)
if err != nil {
t.Fatalf("GetFlaredTunnelConfig failed: %v", err)
}
if len(config.Relays) != 1 {
t.Fatalf("expected one relay, got %+v", config.Relays)
}
if config.Relays[0].Address != "de-e:17000" {
t.Fatalf("expected relay client address to include bind port, got %q", config.Relays[0].Address)
}
if len(config.Proxies) != 1 {
t.Fatalf("expected one proxy, got %+v", config.Proxies)
}
proxy := config.Proxies[0]
if proxy.LocalAddr != "10.0.0.8" || proxy.LocalPort != 8080 {
t.Fatalf("unexpected proxy target: %+v", proxy)
}
if len(proxy.CustomDomains) != 1 || proxy.CustomDomains[0] != "flared.example.com" {
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)
}
}
@@ -47,9 +47,11 @@ const nodeEditorSchema = z
ip_manual_override: z.boolean(),
auto_update_enabled: z.boolean(),
relay_bind_port: z.string().trim(),
relay_vhost_http_port: z.string().trim(),
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 个字符'),
@@ -74,6 +76,14 @@ const nodeEditorSchema = z
message: '请填写正确的端口号 (1-65535)',
});
}
const vhostPort = Number(values.relay_vhost_http_port);
if (Number.isNaN(vhostPort) || vhostPort < 1 || vhostPort > 65535) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['relay_vhost_http_port'],
message: '请填写正确的端口号 (1-65535)',
});
}
}
if (!values.geo_manual_override) {
@@ -129,9 +139,11 @@ const defaultValues: NodeEditorValues = {
ip_manual_override: false,
auto_update_enabled: false,
relay_bind_port: '7000',
relay_vhost_http_port: '8080',
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: '',
@@ -219,9 +231,11 @@ function buildFormValues(node?: Partial<NodeItem> | null): NodeEditorValues {
ip_manual_override: node.ip_manual_override ?? false,
auto_update_enabled: node.auto_update_enabled ?? false,
relay_bind_port: String(node.relay_bind_port ?? 7000),
relay_vhost_http_port: String(node.relay_vhost_http_port ?? 8080),
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 ?? '',
@@ -244,9 +258,11 @@ function toPayload(values: NodeEditorValues): NodeMutationPayload {
ip_manual_override: values.ip_manual_override,
auto_update_enabled: values.auto_update_enabled,
relay_bind_port: values.type === 'tunnel_relay' ? Number(values.relay_bind_port) : undefined,
relay_vhost_http_port: values.type === 'tunnel_relay' ? Number(values.relay_vhost_http_port) : undefined,
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) {
@@ -384,21 +400,29 @@ export function NodeEditorModal({
</ResourceField>
<ResourceField
label="客户端接入地址 (Client Access Addr)"
hint="可选,如果不填默认使用节点的 IP + 绑定端口。"
error={form.formState.errors.relay_client_access_addr?.message}
label="中继 Vhost HTTP 端口 (Vhost HTTP Port)"
hint="中继服务端在此端口监听 HTTP 虚拟主机流量,用于 Openresty 转发到中继。"
error={form.formState.errors.relay_vhost_http_port?.message}
>
<ResourceInput placeholder="例如: relay.example.com:7000" {...form.register('relay_client_access_addr')} />
<ResourceInput placeholder="8080" {...form.register('relay_vhost_http_port')} />
</ResourceField>
<ResourceField
label="边缘节点接入地址 (Edge Node Access Addr)"
hint="可选,用于 Edge Node 转发请求。默认与 Client Access Addr 或 IP + 绑定端口一致。"
error={form.formState.errors.relay_agent_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="例如: 10.0.0.1:7000" {...form.register('relay_agent_access_addr')} />
</ResourceField>
<ResourceField
label="客户端接入地址 (Client Access Addr)"
hint="如果不填默认使用节点的 IP + 绑定端口。"
error={form.formState.errors.relay_client_access_addr?.message}
>
<ResourceInput placeholder="例如: relay.example.com:7000" {...form.register('relay_client_access_addr')} />
</ResourceField>
<ResourceField
label="代理地址 (Proxy URL)"
hint="可选,下发给客户端。当客户端连接中继需要经过 HTTP 代理时使用。"
@@ -406,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>
)}
@@ -367,9 +367,10 @@ export function NodesPage() {
)}
/>
) : node.node_type === 'tunnel_client' ? (
<span className="text-sm text-[var(--foreground-secondary)]">
-
</span>
<StatusBadge
label={node.status === 'online' ? '运行中' : '未知'}
variant={node.status === 'online' ? 'success' : 'warning'}
/>
) : (
<StatusBadge
label={getOpenrestyStatusLabel(
@@ -383,16 +384,20 @@ export function NodesPage() {
</div>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{node.current_version || '未应用'}
{node.current_version || (node.node_type === 'tunnel_relay' ? '实时配置' : '未应用')}
</td>
<td className="px-3 py-4">
<div className="space-y-2">
<StatusBadge
label={getApplyLabel(node.latest_apply_result)}
variant={getApplyVariant(
node.latest_apply_result,
)}
/>
{node.node_type === 'tunnel_relay' ? (
<span className="text-sm text-[var(--foreground-secondary)]">—</span>
) : (
<StatusBadge
label={getApplyLabel(node.latest_apply_result)}
variant={getApplyVariant(
node.latest_apply_result,
)}
/>
)}
</div>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
@@ -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>
@@ -8,11 +8,13 @@ export interface NodeItem {
ip: string;
ip_manual_override: boolean;
relay_bind_port: number;
relay_vhost_http_port: number;
relay_client_access_addr: string;
relay_agent_access_addr: string;
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;
@@ -54,9 +56,11 @@ export interface NodeMutationPayload {
ip: string;
ip_manual_override: boolean;
relay_bind_port?: number;
relay_vhost_http_port?: number;
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;
+5 -4
View File
@@ -1,9 +1,10 @@
import type { NodeItem } from '@/features/nodes/types';
export const WS_CONNECTED_LAST_SEEN = '__OPENFLARE_WS_CONNECTED__';
export const FLARED_WS_CONNECTED_LAST_SEEN = '__OPENFLARE_FLARED_WS_CONNECTED__';
export function isWSConnectedLastSeen(value: string | null | undefined) {
return value === WS_CONNECTED_LAST_SEEN;
return value === WS_CONNECTED_LAST_SEEN || value === FLARED_WS_CONNECTED_LAST_SEEN;
}
export function isMeaningfulTime(value: string | null | undefined) {
@@ -284,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}`,
@@ -561,7 +561,7 @@ function ReverseProxySection({
upstream_type: route.upstream_type || 'direct',
origin_urls_text: route.upstream_list.join('\n'),
origin_host: route.origin_host || '',
tunnel_id: route.tunnel_id ? String(route.tunnel_id) : '',
tunnel_id: route.tunnel_node_id ? String(route.tunnel_node_id) : '',
tunnel_target_addr: route.tunnel_target_addr || '',
tunnel_target_protocol: (route.tunnel_target_protocol as 'http' | 'https') || 'http',
custom_headers_text: customHeadersToText(route.custom_header_list),
@@ -574,7 +574,7 @@ function ReverseProxySection({
upstream_type: route.upstream_type || 'direct',
origin_urls_text: route.upstream_list.join('\n'),
origin_host: route.origin_host || '',
tunnel_id: route.tunnel_id ? String(route.tunnel_id) : '',
tunnel_id: route.tunnel_node_id ? String(route.tunnel_node_id) : '',
tunnel_target_addr: route.tunnel_target_addr || '',
tunnel_target_protocol: (route.tunnel_target_protocol as 'http' | 'https') || 'http',
custom_headers_text: customHeadersToText(route.custom_header_list),
@@ -632,7 +632,7 @@ function ReverseProxySection({
custom_headers: headers,
remark: values.remark.trim(),
upstream_type: values.upstream_type,
tunnel_id: values.upstream_type === 'tunnel' && values.tunnel_id ? Number(values.tunnel_id) : null,
tunnel_node_id: values.upstream_type === 'tunnel' && values.tunnel_id ? Number(values.tunnel_id) : null,
tunnel_target_addr: values.upstream_type === 'tunnel' ? values.tunnel_target_addr : '',
tunnel_target_protocol: values.upstream_type === 'tunnel' ? values.tunnel_target_protocol : '',
}),
@@ -249,7 +249,7 @@ export function ProxyRouteCreateDrawer({
basic_auth_enabled: false,
remark: values.remark.trim(),
upstream_type: values.upstream_type,
tunnel_id: values.upstream_type === 'tunnel' && values.tunnel_id ? Number(values.tunnel_id) : null,
tunnel_node_id: values.upstream_type === 'tunnel' && values.tunnel_id ? Number(values.tunnel_id) : null,
tunnel_target_addr: values.upstream_type === 'tunnel' ? values.tunnel_target_addr : '',
tunnel_target_protocol: values.upstream_type === 'tunnel' ? values.tunnel_target_protocol : '',
});
@@ -300,11 +300,20 @@ export function buildPayloadFromRoute(
basic_auth_enabled: route.basic_auth_enabled,
basic_auth_username: route.basic_auth_username,
basic_auth_password: route.basic_auth_password,
upstream_type: route.upstream_type,
tunnel_node_id: route.tunnel_node_id ?? route.tunnel_id ?? null,
tunnel_target_addr: route.tunnel_target_addr || '',
tunnel_target_protocol: route.tunnel_target_protocol || '',
...overrides,
};
}
export function getUpstreamSummary(route: ProxyRouteItem) {
if (route.upstream_type === 'tunnel') {
const protocol = route.tunnel_target_protocol || 'http';
const target = route.tunnel_target_addr || '未配置目标';
return `Tunnel → ${protocol}://${target}`;
}
if (route.upstream_list.length <= 1) {
return route.origin_url;
}
@@ -54,6 +54,7 @@ export interface ProxyRouteItem {
basic_auth_password: string;
remark: string;
upstream_type: 'direct' | 'tunnel';
tunnel_node_id?: number | null;
tunnel_id?: number | null;
tunnel_target_addr?: string;
tunnel_target_protocol?: string;
@@ -93,6 +94,7 @@ export interface ProxyRouteMutationPayload {
basic_auth_password?: string;
remark: string;
upstream_type?: 'direct' | 'tunnel';
tunnel_node_id?: number | null;
tunnel_id?: number | null;
tunnel_target_addr?: string;
tunnel_target_protocol?: string;
@@ -17,6 +17,8 @@ import {
getManagedDomains,
} from '@/features/managed-domains/api/managed-domains';
import { getProxyRoutes } from '@/features/proxy-routes/api/proxy-routes';
import { getUpstreamSummary } from '@/features/proxy-routes/helpers';
import {
deleteTlsCertificate,
getTlsCertificates,
@@ -539,7 +541,7 @@ export function WebsiteDetailPage({ websiteId }: { websiteId: string }) {
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
<div className="max-w-72 space-y-1 break-all">
<p>{route.origin_url}</p>
<p>{getUpstreamSummary(route)}</p>
<p className="text-xs text-[var(--foreground-muted)]">
回源主机名: {route.origin_host || '$host'}
</p>
@@ -12,6 +12,7 @@ function buildNode(overrides: Partial<NodeItem> = {}): NodeItem {
ip: '127.0.0.1',
ip_manual_override: false,
relay_bind_port: 7000,
relay_vhost_http_port: 8080,
relay_client_access_addr: '',
relay_agent_access_addr: '',
relay_client_proxy_url: '',
+15 -1
View File
@@ -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
}
}
+30 -14
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"log/slog"
"net"
"os"
"os/exec"
"path/filepath"
@@ -84,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)
@@ -101,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 {
@@ -114,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)
}
}
@@ -127,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) {
@@ -210,9 +218,17 @@ auth.token = "%s"
}
func parseAddr(addr string) (string, string) {
parts := strings.Split(addr, ":")
if len(parts) == 2 {
return parts[0], parts[1]
addr = strings.TrimSpace(addr)
if addr == "" {
return "127.0.0.1", "7000"
}
host, port, err := net.SplitHostPort(addr)
if err == nil {
return strings.Trim(host, "[]"), port
}
lastColon := strings.LastIndex(addr, ":")
if lastColon > 0 && strings.Count(addr, ":") == 1 {
return addr[:lastColon], addr[lastColon+1:]
}
return addr, "7000"
}
+97 -1
View File
@@ -3,18 +3,28 @@ package heartbeat
import (
"context"
"log/slog"
"net"
"time"
"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"
)
var (
lookupOutboundIP = geoip.GetOutboundIP
lookupLocalIP = detectLocalNodeIP
)
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 {
@@ -22,6 +32,7 @@ func New(client *httpclient.Client, manager *frpc.Manager, cfg *config.Config) *
client: client,
frpcManager: manager,
config: cfg,
updater: updater.New(),
}
}
@@ -45,19 +56,104 @@ func (s *Service) Run(ctx context.Context) {
func (s *Service) doHeartbeat(ctx context.Context) {
slog.Debug("sending flared heartbeat")
ip := detectNodeIP()
payload := service.FlaredHeartbeatPayload{
ClientVersion: config.Version,
FrpVersion: s.frpcManager.GetVersion(),
IP: ip,
TunnelStatus: "running", // TODO implement proper status tracking
ConnectedRelays: s.frpcManager.GetConnectedRelays(),
CurrentVersion: s.frpcManager.GetCurrentConfigVersion(),
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 {
if ip := detectOutboundNodeIP(); ip != "" {
return ip
}
return lookupLocalIP()
}
func detectOutboundNodeIP() string {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ip, err := lookupOutboundIP(ctx)
if err != nil || ip == nil {
return ""
}
return ip.String()
}
func detectLocalNodeIP() string {
interfaces, err := net.Interfaces()
if err != nil {
return ""
}
bestIP := ""
bestPriority := -1
for _, iface := range interfaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok || ipNet.IP == nil || ipNet.IP.IsLoopback() {
continue
}
ipv4 := ipNet.IP.To4()
if ipv4 == nil {
continue
}
priority := iputil.Score(ipv4)
if priority > bestPriority {
bestIP = ipv4.String()
bestPriority = priority
}
if bestPriority == 2 {
return bestIP
}
}
}
return bestIP
}
+2 -6
View File
@@ -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, `"`, `""`) + `"`
}
+370
View File
@@ -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)
}