mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 00:26:37 +08:00
refactor(backend): rename OpenFlare directory to lowercase openflare
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package config loads and persists relay daemon configuration.
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
edgeconfig "Wavelet/openflare/share/edge/config"
|
||||
"Wavelet/openflare/share/edge/nodeip"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultHeartbeatInterval = 10 * time.Second
|
||||
defaultRequestTimeout = 10 * time.Second
|
||||
configFilePerm = 0o600
|
||||
)
|
||||
|
||||
// MillisecondDuration is a JSON-friendly duration type shared with edge config.
|
||||
type MillisecondDuration = edgeconfig.MillisecondDuration
|
||||
|
||||
// Config holds relay daemon settings loaded from file and environment.
|
||||
type Config struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
DiscoveryToken string `json:"discovery_token"`
|
||||
NodeName string `json:"node_name"`
|
||||
NodeIP string `json:"node_ip"`
|
||||
FrpsPath string `json:"frps_path"`
|
||||
DataDir string `json:"data_dir"`
|
||||
StatePath string `json:"state_path"`
|
||||
HeartbeatInterval MillisecondDuration `json:"heartbeat_interval"`
|
||||
RequestTimeout MillisecondDuration `json:"request_timeout"`
|
||||
configPath string
|
||||
NodeIPConfigured bool
|
||||
}
|
||||
|
||||
// Load reads configuration from path, applying environment overrides and defaults.
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path) //nolint:gosec // path is the relay config file from startup
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
cfg := &Config{}
|
||||
if err == nil {
|
||||
if err = json.Unmarshal(data, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err != nil && !hasEnvConfig() {
|
||||
return nil, err
|
||||
}
|
||||
cfg.configPath = path
|
||||
applyEnvOverrides(cfg)
|
||||
cfg.NodeIPConfigured = cfg.NodeIP != ""
|
||||
applyDefaults(cfg, filepath.Dir(path))
|
||||
if err = validate(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func hasEnvConfig() bool {
|
||||
for _, key := range []string{
|
||||
"OPENFLARE_SERVER_URL",
|
||||
"OPENFLARE_AGENT_TOKEN",
|
||||
"OPENFLARE_DISCOVERY_TOKEN",
|
||||
"OPENFLARE_NODE_NAME",
|
||||
"OPENFLARE_NODE_IP",
|
||||
"OPENFLARE_DATA_DIR",
|
||||
"OPENFLARE_FRPS_PATH",
|
||||
} {
|
||||
if strings.TrimSpace(os.Getenv(key)) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func applyEnvOverrides(cfg *Config) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
overrideString := func(key string, target *string) {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
*target = value
|
||||
}
|
||||
}
|
||||
overrideString("OPENFLARE_SERVER_URL", &cfg.ServerURL)
|
||||
overrideString("OPENFLARE_AGENT_TOKEN", &cfg.AgentToken)
|
||||
overrideString("OPENFLARE_DISCOVERY_TOKEN", &cfg.DiscoveryToken)
|
||||
overrideString("OPENFLARE_NODE_NAME", &cfg.NodeName)
|
||||
overrideString("OPENFLARE_NODE_IP", &cfg.NodeIP)
|
||||
overrideString("OPENFLARE_DATA_DIR", &cfg.DataDir)
|
||||
overrideString("OPENFLARE_FRPS_PATH", &cfg.FrpsPath)
|
||||
}
|
||||
|
||||
func applyDefaults(cfg *Config, baseDir string) {
|
||||
baseDir = filepath.Clean(baseDir)
|
||||
if cfg.FrpsPath == "" {
|
||||
cfg.FrpsPath = "frps" // rely on PATH
|
||||
}
|
||||
if cfg.DataDir == "" {
|
||||
cfg.DataDir = filepath.Join(baseDir, "data")
|
||||
}
|
||||
if cfg.NodeName == "" {
|
||||
host, _ := os.Hostname()
|
||||
cfg.NodeName = strings.TrimSpace(host)
|
||||
}
|
||||
if cfg.NodeIP == "" {
|
||||
cfg.NodeIP = nodeip.Detect()
|
||||
}
|
||||
if cfg.StatePath == "" {
|
||||
cfg.StatePath = filepath.Join(cfg.DataDir, "relay-state.json")
|
||||
}
|
||||
if cfg.HeartbeatInterval <= 0 {
|
||||
cfg.HeartbeatInterval = MillisecondDuration(defaultHeartbeatInterval)
|
||||
}
|
||||
if cfg.RequestTimeout <= 0 {
|
||||
cfg.RequestTimeout = MillisecondDuration(defaultRequestTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func validate(cfg *Config) error {
|
||||
if cfg.ServerURL == "" {
|
||||
return errors.New("server_url 不能为空")
|
||||
}
|
||||
if strings.TrimSpace(cfg.AgentToken) == "" && strings.TrimSpace(cfg.DiscoveryToken) == "" {
|
||||
return errors.New("agent_token 和 discovery_token 不能同时为空")
|
||||
}
|
||||
if cfg.NodeName == "" {
|
||||
return errors.New("node_name 不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitialAuthToken returns the agent or discovery token used for authentication.
|
||||
func (cfg *Config) InitialAuthToken() string {
|
||||
if cfg == nil {
|
||||
return ""
|
||||
}
|
||||
if token := strings.TrimSpace(cfg.AgentToken); token != "" {
|
||||
return token
|
||||
}
|
||||
return strings.TrimSpace(cfg.DiscoveryToken)
|
||||
}
|
||||
|
||||
// Save writes the current configuration back to the loaded config path.
|
||||
func (cfg *Config) Save() error {
|
||||
if cfg == nil {
|
||||
return errors.New("config 不能为空")
|
||||
}
|
||||
if cfg.configPath == "" {
|
||||
return errors.New("config path 未初始化")
|
||||
}
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(cfg.configPath, data, configFilePerm)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config
|
||||
|
||||
// Version is the relay daemon build version string.
|
||||
var Version = "dev"
|
||||
@@ -0,0 +1,343 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package frps manages the lifecycle of the frps reverse-proxy process:
|
||||
// rendering its TOML config, supervising the child process with exponential-
|
||||
// backoff restarts, and exposing runtime status to the heartbeat subsystem.
|
||||
package frps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
service "Wavelet/openflare/share/protocol"
|
||||
)
|
||||
|
||||
const (
|
||||
statusUnhealthy = "unhealthy"
|
||||
|
||||
defaultFrpsWebServerPort = 17500
|
||||
frpsSupervisorPollInterval = 100 * time.Millisecond
|
||||
frpsOrphanProcessCleanupDelay = 500 * time.Millisecond
|
||||
frpsDataDirPerm = 0o750
|
||||
frpsConfigFilePerm = 0o600
|
||||
frpsPidFilePerm = 0o600
|
||||
)
|
||||
|
||||
// Manager controls a single frps child process. It renders TOML configuration,
|
||||
// supervises the process with automatic restarts, and reports runtime status.
|
||||
type Manager struct {
|
||||
frpsPath string
|
||||
dataDir string
|
||||
configPath string
|
||||
pidPath string
|
||||
agentToken string
|
||||
|
||||
mu sync.RWMutex
|
||||
activeConfig *service.RelayConfig
|
||||
cmd *exec.Cmd
|
||||
status string
|
||||
lastError string
|
||||
generation uint64
|
||||
stopping bool
|
||||
}
|
||||
|
||||
// RuntimeStatus is a snapshot of the frps process state at the time of the call.
|
||||
type RuntimeStatus struct {
|
||||
Status string
|
||||
LastError string
|
||||
Connections int
|
||||
ProxyCount int
|
||||
ClientCount int
|
||||
Proxies []service.RelayProxyStat
|
||||
ProcessAlive bool
|
||||
}
|
||||
|
||||
// NewManager constructs a Manager that will run frpsPath and store its PID file
|
||||
// and generated configuration under dataDir.
|
||||
func NewManager(frpsPath string, dataDir string, agentToken string) *Manager {
|
||||
return &Manager{
|
||||
frpsPath: frpsPath,
|
||||
dataDir: dataDir,
|
||||
configPath: filepath.Join(dataDir, "frps.toml"),
|
||||
pidPath: filepath.Join(dataDir, "frps.pid"),
|
||||
status: "unknown", // 启动阶段尚未获取配置,状态未知;避免首次 heartbeat 误报 frps_unhealthy
|
||||
agentToken: agentToken,
|
||||
}
|
||||
}
|
||||
|
||||
// GetVersion executes frps -v and returns the trimmed version string.
|
||||
func (m *Manager) GetVersion(ctx context.Context) string {
|
||||
cmd := exec.CommandContext(ctx, m.frpsPath, "-v") //nolint:gosec // frpsPath is the configured trusted frps binary location
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Error("failed to get frps version", "error", err)
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
// GetStatus returns the current status string (e.g. "healthy", "unhealthy") under
|
||||
// a read lock.
|
||||
func (m *Manager) GetStatus() string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.status
|
||||
}
|
||||
|
||||
// GetRuntimeStatus returns a RuntimeStatus snapshot without blocking the
|
||||
// supervisor goroutine for more than the duration of a read lock.
|
||||
func (m *Manager) GetRuntimeStatus() RuntimeStatus {
|
||||
m.mu.RLock()
|
||||
status := m.status
|
||||
lastError := m.lastError
|
||||
cmd := m.cmd
|
||||
m.mu.RUnlock()
|
||||
|
||||
return RuntimeStatus{
|
||||
Status: status,
|
||||
LastError: lastError,
|
||||
Connections: 0,
|
||||
ProxyCount: 0,
|
||||
ClientCount: 0,
|
||||
Proxies: nil,
|
||||
ProcessAlive: cmd != nil && cmd.Process != nil,
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateConfig applies a new RelayConfig. If the configuration has not changed
|
||||
// and frps is already running, this is a no-op. Otherwise the existing process
|
||||
// is killed and a new supervisor goroutine is started.
|
||||
func (m *Manager) UpdateConfig(ctx context.Context, cfg *service.RelayConfig) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Check if config changed
|
||||
if m.activeConfig != nil &&
|
||||
m.activeConfig.BindPort == cfg.BindPort &&
|
||||
m.activeConfig.VhostHTTPPort == cfg.VhostHTTPPort &&
|
||||
m.activeConfig.AuthToken == cfg.AuthToken &&
|
||||
m.activeConfig.WebServerEnabled == cfg.WebServerEnabled &&
|
||||
m.activeConfig.WebServerPort == cfg.WebServerPort {
|
||||
if m.cmd == nil && !m.stopping {
|
||||
slog.Warn("frps config unchanged but process is not running, restarting")
|
||||
m.stopping = false
|
||||
m.generation++
|
||||
generation := m.generation
|
||||
if err := m.renderConfig(cfg); err != nil {
|
||||
slog.Error("failed to render frps config", "error", err)
|
||||
m.status = statusUnhealthy
|
||||
m.lastError = err.Error()
|
||||
return
|
||||
}
|
||||
go m.supervise(ctx, generation)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
m.activeConfig = cfg
|
||||
m.stopping = false
|
||||
m.generation++
|
||||
generation := m.generation
|
||||
slog.Info("relay config updated, reloading frps")
|
||||
|
||||
if m.cmd != nil && m.cmd.Process != nil {
|
||||
slog.Debug("stopping existing frps process")
|
||||
_ = m.cmd.Process.Kill()
|
||||
m.cmd = nil
|
||||
}
|
||||
|
||||
if err := m.renderConfig(cfg); err != nil {
|
||||
slog.Error("failed to render frps config", "error", err)
|
||||
m.status = statusUnhealthy
|
||||
m.lastError = err.Error()
|
||||
return
|
||||
}
|
||||
|
||||
go m.supervise(ctx, generation)
|
||||
}
|
||||
|
||||
func (m *Manager) renderConfig(cfg *service.RelayConfig) error {
|
||||
if err := os.MkdirAll(m.dataDir, frpsDataDirPerm); err != nil {
|
||||
return err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
fmt.Fprintf(&buf, "bindPort = %d\n", cfg.BindPort)
|
||||
if cfg.VhostHTTPPort > 0 {
|
||||
fmt.Fprintf(&buf, "vhostHTTPPort = %d\n", cfg.VhostHTTPPort)
|
||||
}
|
||||
if cfg.AuthToken != "" {
|
||||
buf.WriteString("[auth]\n")
|
||||
buf.WriteString("method = \"token\"\n")
|
||||
buf.WriteString("token = " + service.TOMLQuote(cfg.AuthToken) + "\n")
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
port := cfg.WebServerPort
|
||||
if port <= 0 {
|
||||
port = defaultFrpsWebServerPort
|
||||
}
|
||||
fmt.Fprintf(&buf, "port = %d\n", port)
|
||||
buf.WriteString("user = \"admin\"\n")
|
||||
|
||||
password := m.agentToken
|
||||
if password == "" {
|
||||
password = "admin"
|
||||
}
|
||||
fmt.Fprintf(&buf, "password = %s\n", service.TOMLQuote(password))
|
||||
return os.WriteFile(m.configPath, buf.Bytes(), frpsConfigFilePerm)
|
||||
}
|
||||
|
||||
func (m *Manager) supervise(ctx context.Context, generation uint64) {
|
||||
procCtx := context.WithoutCancel(ctx)
|
||||
backoff := 1 * time.Second
|
||||
const maxBackoff = 60 * time.Second
|
||||
|
||||
for {
|
||||
m.mu.Lock()
|
||||
if m.stopping || m.generation != generation {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
ensureNoOrphanProcess(m.pidPath)
|
||||
|
||||
cmd := exec.CommandContext(procCtx, m.frpsPath, "-c", m.configPath) //nolint:gosec // frpsPath and configPath are managed trusted locations
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
err := cmd.Start()
|
||||
if err != nil {
|
||||
m.status = statusUnhealthy
|
||||
m.lastError = fmt.Sprintf("failed to start: %v", err)
|
||||
slog.Error("failed to start frps", "error", err, "generation", generation)
|
||||
m.mu.Unlock()
|
||||
|
||||
if !m.sleepOrInterrupt(generation, backoff) {
|
||||
return
|
||||
}
|
||||
backoff *= 2
|
||||
if backoff > maxBackoff {
|
||||
backoff = maxBackoff
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
_ = os.WriteFile(m.pidPath, fmt.Appendf(nil, "%d", cmd.Process.Pid), frpsPidFilePerm)
|
||||
|
||||
m.cmd = cmd
|
||||
m.status = "healthy"
|
||||
m.lastError = ""
|
||||
m.mu.Unlock()
|
||||
|
||||
startedAt := time.Now()
|
||||
waitErr := cmd.Wait()
|
||||
_ = os.Remove(m.pidPath)
|
||||
|
||||
m.mu.Lock()
|
||||
if m.cmd == cmd {
|
||||
m.cmd = nil
|
||||
m.status = statusUnhealthy
|
||||
if waitErr != nil {
|
||||
m.lastError = fmt.Sprintf("exited with error: %v", waitErr)
|
||||
} else {
|
||||
m.lastError = "exited unexpectedly"
|
||||
}
|
||||
slog.Warn("frps process exited unexpectedly", "error", waitErr, "generation", generation)
|
||||
}
|
||||
shouldContinue := !m.stopping && m.generation == generation
|
||||
m.mu.Unlock()
|
||||
|
||||
if !shouldContinue {
|
||||
return
|
||||
}
|
||||
|
||||
if time.Since(startedAt) >= 10*time.Second {
|
||||
backoff = 1 * time.Second
|
||||
}
|
||||
|
||||
if !m.sleepOrInterrupt(generation, backoff) {
|
||||
return
|
||||
}
|
||||
backoff *= 2
|
||||
if backoff > maxBackoff {
|
||||
backoff = maxBackoff
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) sleepOrInterrupt(generation uint64, d time.Duration) bool {
|
||||
ticker := time.NewTicker(frpsSupervisorPollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
deadline := time.Now().Add(d)
|
||||
for time.Now().Before(deadline) {
|
||||
<-ticker.C
|
||||
m.mu.RLock()
|
||||
interrupted := m.stopping || m.generation != generation
|
||||
m.mu.RUnlock()
|
||||
if interrupted {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Stop signals the supervisor to cease restarting frps and kills the running
|
||||
// process if one exists.
|
||||
func (m *Manager) Stop() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.stopping = true
|
||||
m.generation++
|
||||
if m.cmd != nil && m.cmd.Process != nil {
|
||||
_ = m.cmd.Process.Kill()
|
||||
m.cmd = nil
|
||||
}
|
||||
_ = os.Remove(m.pidPath)
|
||||
m.status = statusUnhealthy
|
||||
}
|
||||
|
||||
func ensureNoOrphanProcess(pidPath string) {
|
||||
data, err := os.ReadFile(pidPath) //nolint:gosec // pidPath is a managed internal path, not user input
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var pid int
|
||||
if _, err := fmt.Sscanf(string(data), "%d", &pid); err != nil {
|
||||
return
|
||||
}
|
||||
if pid <= 0 {
|
||||
return
|
||||
}
|
||||
process, err := os.FindProcess(pid)
|
||||
if err == nil && process != nil {
|
||||
err = process.Signal(syscall.Signal(0))
|
||||
if err == nil || errors.Is(err, os.ErrPermission) {
|
||||
slog.Warn("attempting to kill potentially orphan process", "pid", pid, "pid_path", pidPath)
|
||||
_ = process.Kill()
|
||||
// Wait a little bit to ensure the OS has reclaimed ports
|
||||
time.Sleep(frpsOrphanProcessCleanupDelay)
|
||||
}
|
||||
}
|
||||
_ = os.Remove(pidPath)
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package frps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
service "Wavelet/openflare/share/protocol"
|
||||
)
|
||||
|
||||
// Helper to write control file for the dummy script
|
||||
func writeControl(t *testing.T, dir string, exitCode int, delaySeconds int) {
|
||||
t.Helper()
|
||||
controlPath := filepath.Join(dir, "control.txt")
|
||||
content := fmt.Sprintf("%d %d\n", exitCode, delaySeconds)
|
||||
err := os.WriteFile(controlPath, []byte(content), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write control file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Setup a dummy executable script that reads control.txt to decide exit code and sleep duration
|
||||
func setupDummyScript(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
scriptPath := filepath.Join(dir, "dummy_frps")
|
||||
|
||||
// On macOS/Linux, we write a shell script
|
||||
scriptContent := fmt.Sprintf(`#!/bin/sh
|
||||
control_file="%s/control.txt"
|
||||
EXIT_CODE=0
|
||||
DELAY=0
|
||||
if [ -f "$control_file" ]; then
|
||||
read -r EXIT_CODE DELAY < "$control_file"
|
||||
fi
|
||||
if [ -n "$DELAY" ] && [ "$DELAY" -gt 0 ] 2>/dev/null; then
|
||||
sleep "$DELAY"
|
||||
fi
|
||||
exit "${EXIT_CODE:-0}"
|
||||
`, dir)
|
||||
|
||||
err := os.WriteFile(scriptPath, []byte(scriptContent), 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write dummy script: %v", err)
|
||||
}
|
||||
|
||||
return scriptPath, dir
|
||||
}
|
||||
|
||||
// Helper to poll for status to eliminate timing flakiness in tests
|
||||
func assertStatusEventually(t *testing.T, m *Manager, expectedStatus string, timeout time.Duration) {
|
||||
t.Helper()
|
||||
if timeout < 6*time.Second {
|
||||
timeout = 6 * time.Second
|
||||
}
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
rt := m.GetRuntimeStatus()
|
||||
if rt.Status == expectedStatus {
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
rt := m.GetRuntimeStatus()
|
||||
t.Fatalf("expected status eventually %s, got %s (err: %s)", expectedStatus, rt.Status, rt.LastError)
|
||||
}
|
||||
|
||||
func assertCommandExitedEventually(t *testing.T, cmd *exec.Cmd, timeout time.Duration) {
|
||||
t.Helper()
|
||||
if timeout < 6*time.Second {
|
||||
timeout = 6 * time.Second
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- cmd.Wait()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-time.After(timeout):
|
||||
t.Fatalf("expected process pid=%d to exit within %s", cmd.Process.Pid, timeout)
|
||||
case <-done:
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartProcessSuccess(t *testing.T) {
|
||||
scriptPath, dir := setupDummyScript(t)
|
||||
writeControl(t, dir, 0, 5) // exit code 0, sleep 5s
|
||||
|
||||
m := NewManager(scriptPath, dir, "agent-token")
|
||||
defer m.Stop()
|
||||
|
||||
cfg := &service.RelayConfig{
|
||||
BindPort: 7000,
|
||||
VhostHTTPPort: 8080,
|
||||
AuthToken: "test-auth",
|
||||
WebServerEnabled: false,
|
||||
}
|
||||
|
||||
m.UpdateConfig(context.Background(), cfg)
|
||||
|
||||
assertStatusEventually(t, m, "healthy", 2*time.Second)
|
||||
|
||||
rt := m.GetRuntimeStatus()
|
||||
if !rt.ProcessAlive {
|
||||
t.Error("expected process to be alive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartProcessFailureAndBackoff(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
invalidScriptPath := filepath.Join(dir, "non_existent_frps")
|
||||
|
||||
m := NewManager(invalidScriptPath, dir, "agent-token")
|
||||
defer m.Stop()
|
||||
|
||||
cfg := &service.RelayConfig{
|
||||
BindPort: 7000,
|
||||
VhostHTTPPort: 8080,
|
||||
AuthToken: "test-auth",
|
||||
WebServerEnabled: false,
|
||||
}
|
||||
|
||||
m.UpdateConfig(context.Background(), cfg)
|
||||
|
||||
assertStatusEventually(t, m, "unhealthy", 2*time.Second)
|
||||
|
||||
rt := m.GetRuntimeStatus()
|
||||
if !strings.Contains(rt.LastError, "failed to start") {
|
||||
t.Errorf("expected error message containing 'failed to start', got %s", rt.LastError)
|
||||
}
|
||||
|
||||
// Correct the path to dummy script
|
||||
scriptPath, _ := setupDummyScript(t)
|
||||
writeControl(t, filepath.Dir(scriptPath), 0, 5)
|
||||
|
||||
m.mu.Lock()
|
||||
m.frpsPath = scriptPath
|
||||
m.mu.Unlock()
|
||||
|
||||
// Wait for backoff retry (1s backoff)
|
||||
assertStatusEventually(t, m, "healthy", 3*time.Second)
|
||||
|
||||
rt = m.GetRuntimeStatus()
|
||||
if !rt.ProcessAlive {
|
||||
t.Error("expected process to be alive now")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnexpectedExitAndAutorestart(t *testing.T) {
|
||||
scriptPath, dir := setupDummyScript(t)
|
||||
// Start with immediate exit code 1
|
||||
writeControl(t, dir, 1, 0)
|
||||
|
||||
m := NewManager(scriptPath, dir, "agent-token")
|
||||
defer m.Stop()
|
||||
|
||||
cfg := &service.RelayConfig{
|
||||
BindPort: 7000,
|
||||
VhostHTTPPort: 8080,
|
||||
AuthToken: "test-auth",
|
||||
WebServerEnabled: false,
|
||||
}
|
||||
|
||||
m.UpdateConfig(context.Background(), cfg)
|
||||
|
||||
assertStatusEventually(t, m, "unhealthy", 2*time.Second)
|
||||
|
||||
rt := m.GetRuntimeStatus()
|
||||
if !strings.Contains(rt.LastError, "exited with error") {
|
||||
t.Errorf("expected exit error, got %s", rt.LastError)
|
||||
}
|
||||
|
||||
// Change control to be healthy (runs for 5s, exit 0)
|
||||
writeControl(t, dir, 0, 5)
|
||||
|
||||
// Wait for the retry to fire (backoff was 1s)
|
||||
assertStatusEventually(t, m, "healthy", 3*time.Second)
|
||||
}
|
||||
|
||||
func TestBackoffReset(t *testing.T) {
|
||||
scriptPath, dir := setupDummyScript(t)
|
||||
// Rapid exit to increase backoff
|
||||
writeControl(t, dir, 1, 0)
|
||||
|
||||
m := NewManager(scriptPath, dir, "agent-token")
|
||||
defer m.Stop()
|
||||
|
||||
cfg := &service.RelayConfig{
|
||||
BindPort: 7000,
|
||||
VhostHTTPPort: 8080,
|
||||
AuthToken: "test-auth",
|
||||
WebServerEnabled: false,
|
||||
}
|
||||
|
||||
m.UpdateConfig(context.Background(), cfg)
|
||||
|
||||
// Crashed once, backoff is 2s
|
||||
assertStatusEventually(t, m, "unhealthy", 2*time.Second)
|
||||
|
||||
// Now make it run successfully for 11 seconds (exit code 0, sleep 11s)
|
||||
writeControl(t, dir, 0, 11)
|
||||
|
||||
// Wait for next retry to start running
|
||||
assertStatusEventually(t, m, "healthy", 4*time.Second)
|
||||
|
||||
// Wait for process to run for 10.5 seconds to trigger backoff reset
|
||||
time.Sleep(10500 * time.Millisecond)
|
||||
|
||||
// Now make it crash again (exit code 1, sleep 0s)
|
||||
writeControl(t, dir, 1, 0)
|
||||
|
||||
// Wait for it to finish and crash
|
||||
assertStatusEventually(t, m, "unhealthy", 3*time.Second)
|
||||
|
||||
// It crashed. Since it ran for > 10s, backoff should have been reset to 1s.
|
||||
// We make it healthy again (exit code 0, sleep 5)
|
||||
writeControl(t, dir, 0, 5)
|
||||
|
||||
// Wait 1.5 seconds. If backoff was reset to 1s, it should be healthy now.
|
||||
assertStatusEventually(t, m, "healthy", 2*time.Second)
|
||||
}
|
||||
|
||||
func TestImmediateRestartOnSameConfigDeadProcess(t *testing.T) {
|
||||
scriptPath, dir := setupDummyScript(t)
|
||||
// Crashes immediately
|
||||
writeControl(t, dir, 1, 0)
|
||||
|
||||
m := NewManager(scriptPath, dir, "agent-token")
|
||||
defer m.Stop()
|
||||
|
||||
cfg := &service.RelayConfig{
|
||||
BindPort: 7000,
|
||||
VhostHTTPPort: 8080,
|
||||
AuthToken: "test-auth",
|
||||
WebServerEnabled: false,
|
||||
}
|
||||
|
||||
m.UpdateConfig(context.Background(), cfg)
|
||||
|
||||
// Let it crash
|
||||
assertStatusEventually(t, m, "unhealthy", 2*time.Second)
|
||||
|
||||
// Make it start successfully
|
||||
writeControl(t, dir, 0, 5)
|
||||
|
||||
// Send same config block to trigger immediate restart bypass of backoff sleep
|
||||
m.UpdateConfig(context.Background(), cfg)
|
||||
|
||||
// Check if it started immediately
|
||||
assertStatusEventually(t, m, "healthy", 2*time.Second)
|
||||
}
|
||||
|
||||
func TestSupervisorGenerationInterrupt(t *testing.T) {
|
||||
scriptPath, dir := setupDummyScript(t)
|
||||
writeControl(t, dir, 0, 10)
|
||||
|
||||
m := NewManager(scriptPath, dir, "agent-token")
|
||||
defer m.Stop()
|
||||
|
||||
cfg := &service.RelayConfig{
|
||||
BindPort: 7000,
|
||||
VhostHTTPPort: 8080,
|
||||
AuthToken: "test-auth",
|
||||
WebServerEnabled: false,
|
||||
}
|
||||
|
||||
m.UpdateConfig(context.Background(), cfg)
|
||||
|
||||
assertStatusEventually(t, m, "healthy", 2*time.Second)
|
||||
|
||||
m.mu.Lock()
|
||||
gen1 := m.generation
|
||||
cmd1 := m.cmd
|
||||
m.mu.Unlock()
|
||||
|
||||
if cmd1 == nil {
|
||||
t.Fatal("expected active process")
|
||||
}
|
||||
|
||||
// Update configuration with new bind port to trigger new generation
|
||||
cfg2 := &service.RelayConfig{
|
||||
BindPort: 7001,
|
||||
VhostHTTPPort: 8080,
|
||||
AuthToken: "test-auth",
|
||||
WebServerEnabled: false,
|
||||
}
|
||||
m.UpdateConfig(context.Background(), cfg2)
|
||||
|
||||
assertStatusEventually(t, m, "healthy", 2*time.Second)
|
||||
|
||||
m.mu.Lock()
|
||||
gen2 := m.generation
|
||||
cmd2 := m.cmd
|
||||
m.mu.Unlock()
|
||||
|
||||
if gen2 <= gen1 {
|
||||
t.Errorf("expected generation incremented, got gen1=%d gen2=%d", gen1, gen2)
|
||||
}
|
||||
if cmd2 == cmd1 {
|
||||
t.Error("expected old process killed and new command started")
|
||||
}
|
||||
|
||||
// Verify old process is actually killed:不要对受管 Cmd 调用 Wait(旧 supervise
|
||||
// goroutine 拥有 Wait 权,并发 Wait 会与 os/exec 内部状态竞争),改为探测
|
||||
// 进程是否已被收割(Signal(0) 在 Wait 后即报错)。
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if err := cmd1.Process.Signal(syscall.Signal(0)); err != nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Error("expected first process to be killed")
|
||||
}
|
||||
|
||||
func TestUpdateConfigKillsOrphanProcessBeforeRestart(t *testing.T) {
|
||||
scriptPath, dir := setupDummyScript(t)
|
||||
writeControl(t, dir, 0, 5)
|
||||
|
||||
m := NewManager(scriptPath, dir, "agent-token")
|
||||
defer m.Stop()
|
||||
|
||||
orphan := exec.Command("sh", "-c", "sleep 30")
|
||||
if err := orphan.Start(); err != nil {
|
||||
t.Fatalf("failed to start orphan process: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if orphan.Process != nil {
|
||||
_ = orphan.Process.Kill()
|
||||
}
|
||||
})
|
||||
|
||||
if err := os.WriteFile(m.pidPath, []byte(fmt.Sprintf("%d", orphan.Process.Pid)), 0o644); err != nil {
|
||||
t.Fatalf("failed to seed orphan pid file: %v", err)
|
||||
}
|
||||
|
||||
cfg := &service.RelayConfig{
|
||||
BindPort: 7000,
|
||||
VhostHTTPPort: 8080,
|
||||
AuthToken: "test-auth",
|
||||
WebServerEnabled: false,
|
||||
}
|
||||
|
||||
m.UpdateConfig(context.Background(), cfg)
|
||||
|
||||
assertCommandExitedEventually(t, orphan, 2*time.Second)
|
||||
assertStatusEventually(t, m, "healthy", 2*time.Second)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package heartbeat sends periodic relay node status to the control plane.
|
||||
package heartbeat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"Wavelet/openflare/plugins/relay/config"
|
||||
"Wavelet/openflare/plugins/relay/frps"
|
||||
"Wavelet/openflare/plugins/relay/httpclient"
|
||||
"Wavelet/openflare/plugins/relay/observability"
|
||||
"Wavelet/openflare/plugins/relay/state"
|
||||
"Wavelet/openflare/plugins/relay/updater"
|
||||
edgeheartbeat "Wavelet/openflare/share/edge/heartbeat"
|
||||
"Wavelet/openflare/share/edge/nodeip"
|
||||
service "Wavelet/openflare/share/protocol"
|
||||
)
|
||||
|
||||
// Service sends periodic heartbeat payloads to the server, updates the frps
|
||||
// configuration from the server response, and triggers auto-update checks.
|
||||
type Service struct {
|
||||
client *httpclient.Client
|
||||
frpsManager *frps.Manager
|
||||
config *config.Config
|
||||
stateStore *state.Store
|
||||
updater *updater.Service
|
||||
}
|
||||
|
||||
// New constructs a Service using the provided HTTP client, frps manager,
|
||||
// configuration, and persistent state store.
|
||||
func New(client *httpclient.Client, manager *frps.Manager, cfg *config.Config, stateStore *state.Store) *Service {
|
||||
return &Service{
|
||||
client: client,
|
||||
frpsManager: manager,
|
||||
config: cfg,
|
||||
stateStore: stateStore,
|
||||
updater: updater.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the heartbeat loop and blocks until ctx is cancelled.
|
||||
func (s *Service) Run(ctx context.Context) {
|
||||
edgeheartbeat.RunLoop(ctx, s.config.HeartbeatInterval.Duration(), s.doHeartbeat)
|
||||
}
|
||||
|
||||
func (s *Service) doHeartbeat(ctx context.Context) {
|
||||
slog.Debug("sending heartbeat")
|
||||
|
||||
runtimeStatus := s.frpsManager.GetRuntimeStatus()
|
||||
|
||||
ip := s.config.NodeIP
|
||||
if !s.config.NodeIPConfigured {
|
||||
ip = nodeip.DetectWithContext(ctx)
|
||||
}
|
||||
|
||||
payload := service.RelayHeartbeatPayload{
|
||||
Version: config.Version,
|
||||
ExtVersion: s.frpsManager.GetVersion(ctx),
|
||||
RelayStatus: runtimeStatus.Status,
|
||||
FrpsConnCount: runtimeStatus.Connections,
|
||||
FrpsProxyCount: runtimeStatus.ProxyCount,
|
||||
FrpsClientCount: runtimeStatus.ClientCount,
|
||||
FrpsProxies: runtimeStatus.Proxies,
|
||||
Name: s.config.NodeName,
|
||||
IP: ip,
|
||||
Profile: observability.BuildProfile(s.config, s.stateStore),
|
||||
Snapshot: observability.BuildSnapshot(s.config, s.stateStore),
|
||||
HealthEvents: observability.BuildHealthEvents(runtimeStatus),
|
||||
}
|
||||
|
||||
resp, err := s.client.Heartbeat(ctx, payload)
|
||||
if err != nil {
|
||||
slog.Error("heartbeat failed", "error", err)
|
||||
return
|
||||
}
|
||||
slog.Debug("heartbeat succeeded")
|
||||
|
||||
// Update configs if changed
|
||||
if resp != nil {
|
||||
s.frpsManager.UpdateConfig(ctx, resp.RelayConfig)
|
||||
|
||||
if resp.RelaySettings != nil {
|
||||
edgeheartbeat.TryAutoUpdate(ctx, s.updater, relaySettingsToAutoUpdate(resp.RelaySettings), "relay")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func relaySettingsToAutoUpdate(settings *service.RelaySettings) *edgeheartbeat.AutoUpdateSettings {
|
||||
if settings == nil {
|
||||
return nil
|
||||
}
|
||||
return &edgeheartbeat.AutoUpdateSettings{
|
||||
AutoUpdate: settings.AutoUpdate,
|
||||
UpdateNow: settings.UpdateNow,
|
||||
UpdateRepo: settings.UpdateRepo,
|
||||
UpdateChannel: settings.UpdateChannel,
|
||||
UpdateTag: settings.UpdateTag,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package httpclient provides an HTTP client for relay control-plane communication.
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
edgehttp "Wavelet/openflare/share/edge/httpclient"
|
||||
service "Wavelet/openflare/share/protocol"
|
||||
)
|
||||
|
||||
// APIResponse is an alias for service.APIResponse.
|
||||
type APIResponse[T any] = service.APIResponse[T]
|
||||
|
||||
// Client sends authenticated requests to the relay control-plane API.
|
||||
type Client struct {
|
||||
base *edgehttp.Client
|
||||
}
|
||||
|
||||
// New creates a relay HTTP client with the given base URL, token, and timeout.
|
||||
func New(baseURL string, token string, timeout time.Duration) *Client {
|
||||
return &Client{
|
||||
base: edgehttp.New(baseURL, token, timeout, "X-Agent-Token"),
|
||||
}
|
||||
}
|
||||
|
||||
// Heartbeat sends a relay heartbeat payload to the control plane.
|
||||
func (c *Client) Heartbeat(ctx context.Context, payload service.RelayHeartbeatPayload) (*service.RelayHeartbeatResponse, error) {
|
||||
resp := APIResponse[service.RelayHeartbeatResponse]{}
|
||||
if err := c.base.PostJSON(ctx, "/api/v1/relay/heartbeat", payload, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := edgehttp.APIError(resp.ErrorMsg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp.Data, nil
|
||||
}
|
||||
|
||||
// SetToken updates the authentication token used for API requests.
|
||||
func (c *Client) SetToken(token string) {
|
||||
c.base.SetToken(token)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package observability collects relay node profile data for heartbeat reporting.
|
||||
package observability
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"Wavelet/openflare/plugins/relay/config"
|
||||
"Wavelet/openflare/plugins/relay/frps"
|
||||
"Wavelet/openflare/plugins/relay/state"
|
||||
edgeobs "Wavelet/openflare/share/edge/observability"
|
||||
service "Wavelet/openflare/share/protocol"
|
||||
)
|
||||
|
||||
// BuildProfile collects the node's system profile and returns it only when the
|
||||
// fingerprint has changed since the last heartbeat, avoiding redundant uploads.
|
||||
func BuildProfile(cfg *config.Config, stateStore *state.Store) *service.AgentNodeSystemProfile {
|
||||
profile := collectProfile(cfg)
|
||||
if profile == nil || stateStore == nil {
|
||||
return profile
|
||||
}
|
||||
fingerprint := fingerprintProfile(profile)
|
||||
snapshot, err := stateStore.Load()
|
||||
if err != nil {
|
||||
return profile
|
||||
}
|
||||
if snapshot.LastProfileFingerprint == fingerprint {
|
||||
return nil
|
||||
}
|
||||
snapshot.LastProfileFingerprint = fingerprint
|
||||
if err = stateStore.Save(snapshot); err != nil {
|
||||
return profile
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
// BuildSnapshot captures a point-in-time metric snapshot including memory,
|
||||
// disk, network I/O, and CPU usage computed from a delta against the last
|
||||
// persisted CPU stat.
|
||||
func BuildSnapshot(cfg *config.Config, stateStore *state.Store) *service.AgentNodeMetricSnapshot {
|
||||
now := time.Now().UTC()
|
||||
metric := &service.AgentNodeMetricSnapshot{CapturedAtUnix: now.Unix()}
|
||||
|
||||
metric.MemoryTotalBytes, metric.MemoryUsedBytes = edgeobs.ReadMemInfo()
|
||||
metric.StorageTotalBytes, metric.StorageUsedBytes = edgeobs.StatFilesystem(cfg.DataDir)
|
||||
// Host NIC totals are not collected.
|
||||
metric.DiskReadBytes, metric.DiskWriteBytes = edgeobs.ReadLinuxDiskTotals()
|
||||
if stateStore == nil {
|
||||
return metric
|
||||
}
|
||||
totalCPU, idleCPU := edgeobs.ReadLinuxCPUStat()
|
||||
snapshot, err := stateStore.Load()
|
||||
if err != nil {
|
||||
return metric
|
||||
}
|
||||
if snapshot.LastCPUStatTotal > 0 && totalCPU > snapshot.LastCPUStatTotal && idleCPU >= snapshot.LastCPUStatIdle {
|
||||
deltaTotal := totalCPU - snapshot.LastCPUStatTotal
|
||||
deltaIdle := idleCPU - snapshot.LastCPUStatIdle
|
||||
if deltaTotal > 0 && deltaIdle <= deltaTotal {
|
||||
metric.CPUUsagePercent = float64(deltaTotal-deltaIdle) / float64(deltaTotal) * 100
|
||||
}
|
||||
}
|
||||
snapshot.LastCPUStatTotal = totalCPU
|
||||
snapshot.LastCPUStatIdle = idleCPU
|
||||
snapshot.LastMetricAtUnix = now.Unix()
|
||||
_ = stateStore.Save(snapshot)
|
||||
return metric
|
||||
}
|
||||
|
||||
// BuildHealthEvents converts a RuntimeStatus into a list of health events.
|
||||
// An empty slice is returned when frps is healthy.
|
||||
func BuildHealthEvents(status frps.RuntimeStatus) []service.AgentNodeHealthEvent {
|
||||
if strings.TrimSpace(status.Status) == "healthy" {
|
||||
return []service.AgentNodeHealthEvent{}
|
||||
}
|
||||
message := strings.TrimSpace(status.LastError)
|
||||
if message == "" {
|
||||
message = "frps runtime is not healthy"
|
||||
}
|
||||
return []service.AgentNodeHealthEvent{{
|
||||
EventType: "frps_unhealthy",
|
||||
Severity: "critical",
|
||||
Message: message,
|
||||
TriggeredAtUnix: time.Now().UTC().Unix(),
|
||||
}}
|
||||
}
|
||||
|
||||
func collectProfile(cfg *config.Config) *service.AgentNodeSystemProfile {
|
||||
hostname, _ := os.Hostname()
|
||||
osName, osVersion := edgeobs.ReadLinuxOSRelease()
|
||||
totalMemory, _ := edgeobs.ReadMemInfo()
|
||||
totalDisk, _ := edgeobs.StatFilesystem(cfg.DataDir)
|
||||
return &service.AgentNodeSystemProfile{
|
||||
Hostname: strings.TrimSpace(hostname),
|
||||
OSName: osName,
|
||||
OSVersion: osVersion,
|
||||
KernelVersion: edgeobs.ReadFirstLine("/proc/sys/kernel/osrelease"),
|
||||
Architecture: runtime.GOARCH,
|
||||
CPUModel: edgeobs.ReadLinuxCPUModel(),
|
||||
CPUCores: runtime.NumCPU(),
|
||||
TotalMemoryBytes: totalMemory,
|
||||
TotalDiskBytes: totalDisk,
|
||||
UptimeSeconds: edgeobs.ReadLinuxUptimeSeconds(),
|
||||
ReportedAtUnix: time.Now().UTC().Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
func fingerprintProfile(profile *service.AgentNodeSystemProfile) string {
|
||||
raw, err := json.Marshal(profile)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package relay 装载 OpenFlare 中继节点插件:frps 进程管理与心跳上报,
|
||||
// 以 Cordis 驱动形态在 profile "relay" 下运行。
|
||||
package relay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"Wavelet/core"
|
||||
"Wavelet/openflare/plugins/relay/config"
|
||||
"Wavelet/openflare/plugins/relay/frps"
|
||||
"Wavelet/openflare/plugins/relay/heartbeat"
|
||||
"Wavelet/openflare/plugins/relay/httpclient"
|
||||
relayrunner "Wavelet/openflare/plugins/relay/relay"
|
||||
"Wavelet/openflare/plugins/relay/state"
|
||||
"Wavelet/openflare/plugins/relay/wsclient"
|
||||
"Wavelet/pkg/util"
|
||||
)
|
||||
|
||||
// DriverTypeRelay 是中继守护进程专属的驱动类型。
|
||||
const DriverTypeRelay core.DriverType = "relay"
|
||||
|
||||
// Plugin 实现 core.Plugin 与 core.Driver。
|
||||
type Plugin struct {
|
||||
configPath string
|
||||
|
||||
runner *relayrunner.Runner
|
||||
done chan error
|
||||
started bool
|
||||
}
|
||||
|
||||
// New 创建 relay 插件,configPath 指向其 JSON 配置文件。
|
||||
func New(configPath string) *Plugin {
|
||||
return &Plugin{configPath: configPath, done: make(chan error, 1)}
|
||||
}
|
||||
|
||||
// Name 返回插件标识。
|
||||
func (p *Plugin) Name() string { return "relay" }
|
||||
|
||||
// Type 返回驱动类型。
|
||||
func (p *Plugin) Type() core.DriverType { return DriverTypeRelay }
|
||||
|
||||
// Apply 加载配置、装配 frps 管理器与各客户端,并注册驱动。
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
cfg, err := config.Load(p.configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load relay config: %w", err)
|
||||
}
|
||||
slog.Info("relay config loaded",
|
||||
"server", cfg.ServerURL,
|
||||
"node", cfg.NodeName,
|
||||
"ip", cfg.NodeIP,
|
||||
"frps_path", cfg.FrpsPath,
|
||||
"data_dir", cfg.DataDir,
|
||||
"heartbeat_interval", cfg.HeartbeatInterval,
|
||||
)
|
||||
|
||||
stateStore := state.NewStore(cfg.StatePath)
|
||||
frpsManager := frps.NewManager(cfg.FrpsPath, cfg.DataDir, cfg.InitialAuthToken())
|
||||
slog.Info("detected frps version", "version", frpsManager.GetVersion(context.Background()))
|
||||
|
||||
httpClient := httpclient.New(cfg.ServerURL, cfg.InitialAuthToken(), cfg.RequestTimeout.Duration())
|
||||
wsClient := wsclient.New(cfg.ServerURL, cfg.InitialAuthToken(), cfg.RequestTimeout.Duration())
|
||||
|
||||
p.runner = &relayrunner.Runner{
|
||||
Config: cfg,
|
||||
StateStore: stateStore,
|
||||
FrpsManager: frpsManager,
|
||||
HTTPClient: httpClient,
|
||||
WebSocketService: wsClient,
|
||||
HeartbeatService: heartbeat.New(httpClient, frpsManager, cfg, stateStore),
|
||||
}
|
||||
|
||||
return ctx.RegisterDriver(p)
|
||||
}
|
||||
|
||||
// Start 拉起中继主循环;runner.Run 阻塞至 ctx 取消,故置于独立 goroutine。
|
||||
func (p *Plugin) Start(ctx context.Context) error {
|
||||
util.Go(func() { p.done <- p.runner.Run(ctx) })
|
||||
p.started = true
|
||||
slog.Info("relay process started")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop 等待主循环退出并回传其结果。
|
||||
func (p *Plugin) Stop(ctx context.Context) error {
|
||||
if !p.started {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case err := <-p.done:
|
||||
p.started = false
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
return err
|
||||
}
|
||||
slog.Info("relay process stopped")
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
p.started = false
|
||||
return fmt.Errorf("relay shutdown timeout: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package relay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"Wavelet/core"
|
||||
)
|
||||
|
||||
func TestPluginIdentity(t *testing.T) {
|
||||
p := New("./relay.json")
|
||||
if got := p.Name(); got != "relay" {
|
||||
t.Errorf("Name() = %q, want %q", got, "relay")
|
||||
}
|
||||
// 驱动类型必须等于 profile 字符串,否则内核的 profile 过滤会漏掉本驱动。
|
||||
if got, want := string(p.Type()), string(core.Profile("relay")); got != want {
|
||||
t.Errorf("Type() = %q, want profile %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFailsOnMissingConfig(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
if err := New("./does-not-exist.json").Apply(ctx); err == nil {
|
||||
t.Fatal("Apply(missing config) error = nil, want error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package relay implements the relay node daemon runtime loop.
|
||||
package relay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
"Wavelet/openflare/plugins/relay/config"
|
||||
"Wavelet/openflare/plugins/relay/frps"
|
||||
"Wavelet/openflare/plugins/relay/heartbeat"
|
||||
"Wavelet/openflare/plugins/relay/httpclient"
|
||||
"Wavelet/openflare/plugins/relay/state"
|
||||
"Wavelet/openflare/plugins/relay/wsclient"
|
||||
edgerunner "Wavelet/openflare/share/edge/runner"
|
||||
service "Wavelet/openflare/share/protocol"
|
||||
)
|
||||
|
||||
// Runner manages the relay process.
|
||||
type Runner struct {
|
||||
Config *config.Config
|
||||
StateStore *state.Store
|
||||
HeartbeatService *heartbeat.Service
|
||||
FrpsManager *frps.Manager
|
||||
WebSocketService *wsclient.Client
|
||||
HTTPClient *httpclient.Client
|
||||
}
|
||||
|
||||
// Run starts the relay process by initiating the heartbeat and WS reconnection loop.
|
||||
func (r *Runner) Run(ctx context.Context) error {
|
||||
go r.HeartbeatService.Run(ctx)
|
||||
|
||||
return edgerunner.RunWSReconnectLoop(ctx, edgerunner.WSReconnectConfig{
|
||||
ComponentName: "relay",
|
||||
OnShutdown: r.FrpsManager.Stop,
|
||||
}, func(ctx context.Context) (edgerunner.WSConnection, error) {
|
||||
return r.WebSocketService.Connect(ctx)
|
||||
}, func(ctx context.Context, conn edgerunner.WSConnection) {
|
||||
r.handleConnection(ctx, conn)
|
||||
})
|
||||
}
|
||||
|
||||
type relayWSHandler struct {
|
||||
runner *Runner
|
||||
}
|
||||
|
||||
func (h *relayWSHandler) OnConnect(_ context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *relayWSHandler) HandleMessage(ctx context.Context, msg wsclient.WSMessage) error {
|
||||
switch msg.Type {
|
||||
case "relay_config":
|
||||
var cfg service.RelayConfig
|
||||
if err := json.Unmarshal(msg.Payload, &cfg); err != nil {
|
||||
slog.Error("failed to unmarshal relay_config", "error", err)
|
||||
return nil
|
||||
}
|
||||
h.runner.FrpsManager.UpdateConfig(ctx, &cfg)
|
||||
default:
|
||||
slog.Debug("ignored unknown ws message type", "type", msg.Type)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *relayWSHandler) OnClose(err error) {
|
||||
slog.Error("relay ws receive failed", "error", err)
|
||||
}
|
||||
|
||||
func (r *Runner) handleConnection(ctx context.Context, conn edgerunner.WSConnection) {
|
||||
wsConn, ok := conn.(*wsclient.Connection)
|
||||
if !ok {
|
||||
slog.Error("relay ws connection has unexpected type")
|
||||
return
|
||||
}
|
||||
_ = wsConn.RunReceiveLoop(ctx, &relayWSHandler{runner: r})
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package state persists relay runtime state to disk.
|
||||
package state
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const relayStateFilePerm = 0o600
|
||||
|
||||
// Store reads and writes relay state from a JSON file.
|
||||
type Store struct {
|
||||
path string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// State holds persisted relay metrics and authentication state.
|
||||
type State struct {
|
||||
LastAuthToken string `json:"last_auth_token"`
|
||||
LastProfileFingerprint string `json:"last_profile_fingerprint"`
|
||||
LastCPUStatTotal uint64 `json:"last_cpu_stat_total"`
|
||||
LastCPUStatIdle uint64 `json:"last_cpu_stat_idle"`
|
||||
LastMetricAtUnix int64 `json:"last_metric_at_unix"`
|
||||
}
|
||||
|
||||
// NewStore creates a state store backed by the file at path.
|
||||
func NewStore(path string) *Store {
|
||||
return &Store{
|
||||
path: path,
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads the persisted state from disk, returning an empty state if the file is missing.
|
||||
func (s *Store) Load() (*State, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
data, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &State{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var state State
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return &State{}, nil // Return empty state on corrupted file
|
||||
}
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
// Save writes state to disk.
|
||||
func (s *Store) Save(state *State) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
data, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Debug("saving relay state")
|
||||
return os.WriteFile(s.path, data, relayStateFilePerm)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package updater provides relay self-update integration with the edge updater.
|
||||
package updater
|
||||
|
||||
import (
|
||||
"Wavelet/openflare/plugins/relay/config"
|
||||
edgeupdater "Wavelet/openflare/share/edge/updater"
|
||||
)
|
||||
|
||||
// Service is an alias for the edge updater Service.
|
||||
type Service = edgeupdater.Service
|
||||
|
||||
// UpdateOptions is an alias for the edge updater UpdateOptions.
|
||||
type UpdateOptions = edgeupdater.UpdateOptions
|
||||
|
||||
// New creates and initializes a new updater Service for the relay.
|
||||
func New() *Service {
|
||||
return edgeupdater.New(edgeupdater.Config{
|
||||
LocalVersion: config.Version,
|
||||
AssetPrefix: "openflare-relay",
|
||||
LogLabel: "relay",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package wsclient provides a WebSocket client for relay control-plane communication.
|
||||
package wsclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
edgews "Wavelet/openflare/share/edge/wsclient"
|
||||
)
|
||||
|
||||
// WSMessage is a WebSocket message exchanged with the control plane.
|
||||
type WSMessage = edgews.WSMessage
|
||||
|
||||
// MessageHandler processes incoming WebSocket messages.
|
||||
type MessageHandler = edgews.MessageHandler
|
||||
|
||||
// Connection represents an active WebSocket connection.
|
||||
type Connection = edgews.Connection
|
||||
|
||||
// Client connects to the relay WebSocket endpoint on the control plane.
|
||||
type Client struct {
|
||||
inner *edgews.Client
|
||||
}
|
||||
|
||||
// New creates a WebSocket client for the relay control-plane endpoint.
|
||||
func New(baseURL, token string, timeout time.Duration) *Client {
|
||||
return &Client{
|
||||
inner: edgews.New(edgews.PresetRelay, baseURL, token, timeout),
|
||||
}
|
||||
}
|
||||
|
||||
// SetToken updates the authentication token used for the WebSocket connection.
|
||||
func (c *Client) SetToken(token string) {
|
||||
c.inner.SetToken(token)
|
||||
}
|
||||
|
||||
// Connect establishes a WebSocket connection to the control plane.
|
||||
func (c *Client) Connect(ctx context.Context) (*Connection, error) {
|
||||
return c.inner.Connect(ctx)
|
||||
}
|
||||
Reference in New Issue
Block a user