mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-05 15:26:36 +08:00
refactor(backend): rename OpenFlare directory to lowercase openflare
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package config loads and persists flared daemon configuration.
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
edgeconfig "Wavelet/openflare/share/edge/config"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultHeartbeatInterval = 10 * time.Second
|
||||
defaultSyncInterval = 30 * time.Second
|
||||
defaultRequestTimeout = 10 * time.Second
|
||||
configFilePerm = 0o644
|
||||
)
|
||||
|
||||
// MillisecondDuration is a JSON-friendly duration type shared with edge config.
|
||||
type MillisecondDuration = edgeconfig.MillisecondDuration
|
||||
|
||||
// Config holds flared daemon settings loaded from file and environment.
|
||||
type Config struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
TunnelToken string `json:"tunnel_token"`
|
||||
FrpcPath string `json:"frpc_path"`
|
||||
DataDir string `json:"data_dir"`
|
||||
StatePath string `json:"state_path"`
|
||||
HeartbeatInterval MillisecondDuration `json:"heartbeat_interval"`
|
||||
SyncInterval MillisecondDuration `json:"sync_interval"`
|
||||
RequestTimeout MillisecondDuration `json:"request_timeout"`
|
||||
configPath string
|
||||
}
|
||||
|
||||
// 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 flared config file location from startup configuration
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
cfg := &Config{}
|
||||
if err == nil {
|
||||
if err = json.Unmarshal(data, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err != nil && !hasEnvConfig() {
|
||||
return nil, err
|
||||
}
|
||||
cfg.configPath = path
|
||||
applyEnvOverrides(cfg)
|
||||
applyDefaults(cfg, filepath.Dir(path))
|
||||
if err = validate(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func hasEnvConfig() bool {
|
||||
for _, key := range []string{
|
||||
"OPENFLARE_SERVER_URL",
|
||||
"OPENFLARE_TUNNEL_TOKEN",
|
||||
"OPENFLARE_DATA_DIR",
|
||||
"OPENFLARE_FRPC_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_TUNNEL_TOKEN", &cfg.TunnelToken)
|
||||
overrideString("OPENFLARE_DATA_DIR", &cfg.DataDir)
|
||||
overrideString("OPENFLARE_FRPC_PATH", &cfg.FrpcPath)
|
||||
}
|
||||
|
||||
func applyDefaults(cfg *Config, baseDir string) {
|
||||
baseDir = filepath.Clean(baseDir)
|
||||
if cfg.FrpcPath == "" {
|
||||
cfg.FrpcPath = "frpc" // rely on PATH
|
||||
}
|
||||
if cfg.DataDir == "" {
|
||||
cfg.DataDir = filepath.Join(baseDir, "data")
|
||||
}
|
||||
if cfg.StatePath == "" {
|
||||
cfg.StatePath = filepath.Join(cfg.DataDir, "flared-state.json")
|
||||
}
|
||||
if cfg.HeartbeatInterval <= 0 {
|
||||
cfg.HeartbeatInterval = MillisecondDuration(defaultHeartbeatInterval)
|
||||
}
|
||||
if cfg.SyncInterval <= 0 {
|
||||
cfg.SyncInterval = MillisecondDuration(defaultSyncInterval)
|
||||
}
|
||||
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.TunnelToken) == "" {
|
||||
return errors.New("tunnel_token 不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitialAuthToken returns the tunnel token used for initial authentication.
|
||||
func (cfg *Config) InitialAuthToken() string {
|
||||
if cfg == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(cfg.TunnelToken)
|
||||
}
|
||||
|
||||
// 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 flared daemon build version string.
|
||||
var Version = "dev"
|
||||
@@ -0,0 +1,77 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package flared implements the tunnel client daemon runtime loop.
|
||||
package flared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"Wavelet/openflare/plugins/flared/config"
|
||||
"Wavelet/openflare/plugins/flared/frpc"
|
||||
"Wavelet/openflare/plugins/flared/heartbeat"
|
||||
"Wavelet/openflare/plugins/flared/httpclient"
|
||||
"Wavelet/openflare/plugins/flared/sync"
|
||||
"Wavelet/openflare/plugins/flared/wsclient"
|
||||
edgerunner "Wavelet/openflare/share/edge/runner"
|
||||
)
|
||||
|
||||
// Runner is the top-level orchestrator for the flared agent. It wires together
|
||||
// heartbeat, sync, frpc management, and the WebSocket control-plane connection.
|
||||
type Runner struct {
|
||||
Config *config.Config
|
||||
HeartbeatService *heartbeat.Service
|
||||
FrpcManager *frpc.Manager
|
||||
SyncService *sync.Service
|
||||
WebSocketService *wsclient.Client
|
||||
HTTPClient *httpclient.Client
|
||||
}
|
||||
|
||||
// Run starts all background services and enters the WebSocket reconnect loop.
|
||||
// It blocks until ctx is cancelled or an unrecoverable error occurs.
|
||||
func (r *Runner) Run(ctx context.Context) error {
|
||||
go r.HeartbeatService.Run(ctx)
|
||||
go r.SyncService.Run(ctx)
|
||||
|
||||
return edgerunner.RunWSReconnectLoop(ctx, edgerunner.WSReconnectConfig{
|
||||
ComponentName: "flared",
|
||||
OnShutdown: r.FrpcManager.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 flaredWSHandler struct {
|
||||
runner *Runner
|
||||
}
|
||||
|
||||
func (h *flaredWSHandler) OnConnect(_ context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *flaredWSHandler) HandleMessage(_ context.Context, msg wsclient.WSMessage) error {
|
||||
switch msg.Type {
|
||||
case "active_config":
|
||||
slog.Info("received config update notification from server")
|
||||
h.runner.SyncService.Trigger()
|
||||
default:
|
||||
slog.Debug("ignored unknown ws message type", "type", msg.Type)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *flaredWSHandler) OnClose(err error) {
|
||||
slog.Error("flared ws receive failed", "error", err)
|
||||
}
|
||||
|
||||
func (r *Runner) handleConnection(ctx context.Context, conn edgerunner.WSConnection) {
|
||||
wsConn, ok := conn.(*wsclient.Connection)
|
||||
if !ok {
|
||||
slog.Error("flared ws connection has unexpected type")
|
||||
return
|
||||
}
|
||||
_ = wsConn.RunReceiveLoop(ctx, &flaredWSHandler{runner: r})
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package frpc manages frpc child processes for tunnel relay connections.
|
||||
package frpc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"Wavelet/openflare/plugins/flared/config"
|
||||
service "Wavelet/openflare/share/protocol"
|
||||
"Wavelet/pkg/util"
|
||||
)
|
||||
|
||||
const (
|
||||
dataDirPerm = 0o750
|
||||
frpcConfigFilePerm = 0o644
|
||||
orphanProcessKillDelay = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
// Manager supervises frpc processes for each active relay node.
|
||||
type Manager struct {
|
||||
cfg *config.Config
|
||||
processes map[string]*Process
|
||||
mu sync.RWMutex
|
||||
|
||||
currentVersion string
|
||||
currentChecksum string
|
||||
}
|
||||
|
||||
// Process tracks a single frpc child process and its runtime state.
|
||||
type Process struct {
|
||||
RelayID string
|
||||
Cmd *exec.Cmd
|
||||
Cancel context.CancelFunc
|
||||
Status string
|
||||
StartTime time.Time
|
||||
LastError string
|
||||
}
|
||||
|
||||
// NewManager creates a Manager using the given flared configuration.
|
||||
func NewManager(cfg *config.Config) *Manager {
|
||||
return &Manager{
|
||||
cfg: cfg,
|
||||
processes: make(map[string]*Process),
|
||||
}
|
||||
}
|
||||
|
||||
// GetVersion returns the installed frpc binary version string.
|
||||
func (m *Manager) GetVersion(ctx context.Context) string {
|
||||
cmd := exec.CommandContext(ctx, m.cfg.FrpcPath, "-v") //nolint:gosec // FrpcPath is the configured trusted frpc binary location
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
// GetConnectedRelays reports the relay nodes with active or managed frpc processes.
|
||||
func (m *Manager) GetConnectedRelays() []service.FlaredConnectedRelay {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
result := make([]service.FlaredConnectedRelay, 0, len(m.processes))
|
||||
for relayID, proc := range m.processes {
|
||||
result = append(result, service.FlaredConnectedRelay{
|
||||
RelayNodeID: relayID,
|
||||
Status: proc.Status,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetCurrentConfigVersion returns the version of the applied tunnel configuration.
|
||||
func (m *Manager) GetCurrentConfigVersion() string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.currentVersion
|
||||
}
|
||||
|
||||
// GetCurrentConfigChecksum returns the checksum of the applied tunnel configuration.
|
||||
func (m *Manager) GetCurrentConfigChecksum() string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.currentChecksum
|
||||
}
|
||||
|
||||
// UpdateConfig reconciles running frpc processes with the latest tunnel configuration.
|
||||
// The returned bool indicates whether the active config version or checksum changed.
|
||||
func (m *Manager) UpdateConfig(ctx context.Context, newConfig *service.FlaredTunnelConfigResponse) (bool, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if newConfig == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
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, dataDirPerm); err != nil {
|
||||
return false, fmt.Errorf("create data dir failed: %w", err)
|
||||
}
|
||||
|
||||
activeRelays := make(map[string]struct{})
|
||||
|
||||
for _, relay := range newConfig.Relays {
|
||||
activeRelays[relay.RelayNodeID] = struct{}{}
|
||||
tomlContent := buildFrpcToml(relay, newConfig.Proxies)
|
||||
configPath := filepath.Join(m.cfg.DataDir, fmt.Sprintf("frpc_%s.toml", relay.RelayNodeID))
|
||||
|
||||
needsRestart := false
|
||||
existingData, err := os.ReadFile(configPath) //nolint:gosec // configPath is under managed DataDir
|
||||
if err != nil || string(existingData) != tomlContent {
|
||||
// 配置文件不存在或内容有变化,需要写入并重启
|
||||
needsRestart = true
|
||||
}
|
||||
|
||||
if needsRestart {
|
||||
if err := os.WriteFile(configPath, []byte(tomlContent), frpcConfigFilePerm); err != nil {
|
||||
slog.Error("failed to write frpc config", "relay_id", relay.RelayNodeID, "error", err)
|
||||
continue
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop obsolete processes
|
||||
for relayID, proc := range m.processes {
|
||||
if _, ok := activeRelays[relayID]; !ok {
|
||||
slog.Info("stopping obsolete frpc process", "relay_id", relayID)
|
||||
proc.Cancel()
|
||||
pidPath := filepath.Join(m.cfg.DataDir, fmt.Sprintf("frpc_%s.pid", relayID))
|
||||
_ = os.Remove(pidPath)
|
||||
delete(m.processes, relayID)
|
||||
}
|
||||
}
|
||||
|
||||
if versionChanged {
|
||||
m.currentVersion = newConfig.Version
|
||||
m.currentChecksum = newConfig.Checksum
|
||||
return true, m.saveState()
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (m *Manager) restartProcess(ctx context.Context, relayID string, configPath string) {
|
||||
pidPath := filepath.Join(m.cfg.DataDir, fmt.Sprintf("frpc_%s.pid", relayID))
|
||||
if proc, ok := m.processes[relayID]; ok {
|
||||
proc.Cancel()
|
||||
_ = os.Remove(pidPath)
|
||||
}
|
||||
|
||||
procCtx, cancel := context.WithCancel(context.WithoutCancel(ctx))
|
||||
proc := &Process{
|
||||
RelayID: relayID,
|
||||
Cancel: cancel,
|
||||
Status: "starting",
|
||||
StartTime: time.Now(),
|
||||
}
|
||||
m.processes[relayID] = proc
|
||||
|
||||
util.Go(func() {
|
||||
backoff := 1 * time.Second
|
||||
const maxBackoff = 60 * time.Second
|
||||
|
||||
for {
|
||||
m.mu.Lock()
|
||||
if procCtx.Err() != nil {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
ensureNoOrphanProcess(pidPath)
|
||||
|
||||
cmd := exec.CommandContext(procCtx, m.cfg.FrpcPath, "-c", configPath) //nolint:gosec // FrpcPath and configPath are managed trusted locations
|
||||
var stderrBuf bytes.Buffer
|
||||
cmd.Stderr = &stderrBuf
|
||||
|
||||
// frpc 及其中间子进程必须整体随上下文终止:CommandContext 默认只杀
|
||||
// 直接子进程,孤儿孙进程会继续持有 stderr 管道导致 cmd.Wait 阻塞到其
|
||||
// 自然退出。这里为 frpc 单独建进程组并整组 SIGKILL。
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.Cancel = func() error {
|
||||
if cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
}
|
||||
|
||||
startedAt := time.Now()
|
||||
err := cmd.Start()
|
||||
if err == nil {
|
||||
// Start 成功后才发布句柄:Start 之前 cmd.Process 尚未赋值,
|
||||
// 提前挂到 proc.Cmd 会让读者(测试/状态接口)与 Start 竞争。
|
||||
m.mu.Lock()
|
||||
proc.Cmd = cmd
|
||||
proc.Status = "running"
|
||||
m.mu.Unlock()
|
||||
_ = os.WriteFile(pidPath, fmt.Appendf(nil, "%d", cmd.Process.Pid), frpcConfigFilePerm)
|
||||
err = cmd.Wait()
|
||||
}
|
||||
_ = os.Remove(pidPath)
|
||||
|
||||
m.mu.Lock()
|
||||
if procCtx.Err() != nil {
|
||||
proc.Status = "stopped"
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
stderrOutput := strings.TrimSpace(stderrBuf.String())
|
||||
if err != nil {
|
||||
proc.LastError = err.Error()
|
||||
proc.Status = "error"
|
||||
if stderrOutput != "" {
|
||||
slog.Error("frpc process exited unexpectedly", "relay_id", relayID, "error", err, "stderr", stderrOutput)
|
||||
} else {
|
||||
slog.Error("frpc process exited unexpectedly", "relay_id", relayID, "error", err)
|
||||
}
|
||||
} else {
|
||||
proc.Status = "stopped"
|
||||
proc.LastError = "exited unexpectedly with code 0"
|
||||
slog.Warn("frpc process exited unexpectedly with code 0", "relay_id", relayID)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if time.Since(startedAt) >= 10*time.Second {
|
||||
backoff = 1 * time.Second
|
||||
}
|
||||
|
||||
t := time.NewTimer(backoff)
|
||||
select {
|
||||
case <-procCtx.Done():
|
||||
t.Stop()
|
||||
return
|
||||
case <-t.C:
|
||||
backoff *= 2
|
||||
if backoff > maxBackoff {
|
||||
backoff = maxBackoff
|
||||
}
|
||||
}
|
||||
t.Stop()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Stop cancels and removes all managed frpc processes.
|
||||
func (m *Manager) Stop() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for relayID, proc := range m.processes {
|
||||
if proc != nil && proc.Cancel != nil {
|
||||
proc.Cancel()
|
||||
}
|
||||
pidPath := filepath.Join(m.cfg.DataDir, fmt.Sprintf("frpc_%s.pid", relayID))
|
||||
_ = os.Remove(pidPath)
|
||||
delete(m.processes, relayID)
|
||||
}
|
||||
}
|
||||
|
||||
func buildFrpcToml(relay service.FlaredRelayInfo, proxies []service.FlaredProxyEntry) string {
|
||||
var buf bytes.Buffer
|
||||
|
||||
host, port := parseAddr(relay.Address)
|
||||
|
||||
fmt.Fprintf(&buf, "serverAddr = %s\nserverPort = %s\n", service.TOMLQuote(host), service.TOMLQuote(port))
|
||||
|
||||
if relay.AuthToken != "" {
|
||||
fmt.Fprintf(&buf, "auth.method = \"token\"\nauth.token = %s\n", service.TOMLQuote(relay.AuthToken))
|
||||
}
|
||||
|
||||
if relay.ProxyURL != "" {
|
||||
fmt.Fprintf(&buf, "transport.proxyURL = %s\n", service.TOMLQuote(relay.ProxyURL))
|
||||
}
|
||||
|
||||
buf.WriteString("\n")
|
||||
|
||||
for _, proxy := range proxies {
|
||||
fmt.Fprintf(&buf, "[[proxies]]\nname = %s\ntype = %s\nlocalIP = %s\nlocalPort = %d\n",
|
||||
service.TOMLQuote(proxy.Name), service.TOMLQuote(proxy.Type), service.TOMLQuote(proxy.LocalAddr), proxy.LocalPort)
|
||||
if len(proxy.CustomDomains) > 0 {
|
||||
quoted := make([]string, len(proxy.CustomDomains))
|
||||
for i := range proxy.CustomDomains {
|
||||
quoted[i] = service.TOMLQuote(proxy.CustomDomains[i])
|
||||
}
|
||||
fmt.Fprintf(&buf, "customDomains = [%s]\n", strings.Join(quoted, ", "))
|
||||
}
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func parseAddr(addr string) (string, string) {
|
||||
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"
|
||||
}
|
||||
|
||||
// ManagerState persists the last applied tunnel configuration version and checksum.
|
||||
type ManagerState struct {
|
||||
Version string
|
||||
Checksum string
|
||||
}
|
||||
|
||||
func (m *Manager) saveState() error {
|
||||
state := ManagerState{
|
||||
Version: m.currentVersion,
|
||||
Checksum: m.currentChecksum,
|
||||
}
|
||||
data, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(m.cfg.StatePath, data, frpcConfigFilePerm)
|
||||
}
|
||||
|
||||
// LoadState restores the last applied configuration version and checksum from disk.
|
||||
func (m *Manager) LoadState() error {
|
||||
data, err := os.ReadFile(m.cfg.StatePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
var state ManagerState
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.currentVersion = state.Version
|
||||
m.currentChecksum = state.Checksum
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureNoOrphanProcess(pidPath string) {
|
||||
data, err := os.ReadFile(pidPath) //nolint:gosec // pidPath is under managed DataDir
|
||||
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(orphanProcessKillDelay)
|
||||
}
|
||||
}
|
||||
_ = os.Remove(pidPath)
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package frpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"Wavelet/openflare/plugins/flared/config"
|
||||
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_frpc")
|
||||
|
||||
// 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, relayID string, expectedStatus string, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
m.mu.RLock()
|
||||
proc, ok := m.processes[relayID]
|
||||
var status string
|
||||
if ok {
|
||||
status = proc.Status
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
if ok && status == expectedStatus {
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
m.mu.RLock()
|
||||
proc, ok := m.processes[relayID]
|
||||
var got string
|
||||
var errStr string
|
||||
if ok {
|
||||
got = proc.Status
|
||||
errStr = proc.LastError
|
||||
} else {
|
||||
got = "not_found"
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
t.Fatalf("expected status eventually %s, got %s (err: %s)", expectedStatus, got, errStr)
|
||||
}
|
||||
|
||||
// assertCommandExitedEventually 等待测试自建进程退出(本测试持有其 Wait 权)。
|
||||
func assertCommandExitedEventually(t *testing.T, cmd *exec.Cmd, timeout time.Duration) {
|
||||
t.Helper()
|
||||
|
||||
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:
|
||||
}
|
||||
}
|
||||
|
||||
// assertManagedCommandExitedEventually 探测受管进程是否已退出。不能对其调用
|
||||
// Wait —— Wait 由 Manager 拥有,测试并发 Wait 会与 os/exec 的 ctxResult
|
||||
// 通道竞争而永久挂起;Signal(0) 在进程被 Manager 收割后即报错。
|
||||
func assertManagedCommandExitedEventually(t *testing.T, cmd *exec.Cmd, timeout time.Duration) {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if err := cmd.Process.Signal(syscall.Signal(0)); err != nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("expected managed process pid=%d to exit within %s", cmd.Process.Pid, timeout)
|
||||
}
|
||||
|
||||
func TestStartProcessSuccess(t *testing.T) {
|
||||
scriptPath, dir := setupDummyScript(t)
|
||||
writeControl(t, dir, 0, 5) // exit code 0, sleep 5s
|
||||
|
||||
cfg := &config.Config{
|
||||
ServerURL: "http://localhost:8080",
|
||||
TunnelToken: "test-token",
|
||||
FrpcPath: scriptPath,
|
||||
DataDir: dir,
|
||||
StatePath: filepath.Join(dir, "flared-state.json"),
|
||||
}
|
||||
|
||||
m := NewManager(cfg)
|
||||
|
||||
newConfig := &service.FlaredTunnelConfigResponse{
|
||||
Version: "1",
|
||||
Checksum: "sum1",
|
||||
Relays: []service.FlaredRelayInfo{
|
||||
{
|
||||
RelayNodeID: "relay-1",
|
||||
Address: "127.0.0.1:7000",
|
||||
AuthToken: "auth-1",
|
||||
},
|
||||
},
|
||||
Proxies: nil,
|
||||
}
|
||||
|
||||
_, err := m.UpdateConfig(context.Background(), newConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to UpdateConfig: %v", err)
|
||||
}
|
||||
|
||||
assertStatusEventually(t, m, "relay-1", "running", 4*time.Second)
|
||||
|
||||
m.mu.RLock()
|
||||
proc := m.processes["relay-1"]
|
||||
m.mu.RUnlock()
|
||||
|
||||
proc.Cancel()
|
||||
assertStatusEventually(t, m, "relay-1", "stopped", 4*time.Second) // wait for clean stop
|
||||
}
|
||||
|
||||
func TestStartProcessFailureAndBackoff(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
invalidScriptPath := filepath.Join(dir, "non_existent_frpc")
|
||||
|
||||
cfg := &config.Config{
|
||||
ServerURL: "http://localhost:8080",
|
||||
TunnelToken: "test-token",
|
||||
FrpcPath: invalidScriptPath,
|
||||
DataDir: dir,
|
||||
StatePath: filepath.Join(dir, "flared-state.json"),
|
||||
}
|
||||
|
||||
m := NewManager(cfg)
|
||||
newConfig := &service.FlaredTunnelConfigResponse{
|
||||
Version: "1",
|
||||
Checksum: "sum1",
|
||||
Relays: []service.FlaredRelayInfo{
|
||||
{
|
||||
RelayNodeID: "relay-1",
|
||||
Address: "127.0.0.1:7000",
|
||||
AuthToken: "auth-1",
|
||||
},
|
||||
},
|
||||
Proxies: nil,
|
||||
}
|
||||
|
||||
_, _ = m.UpdateConfig(context.Background(), newConfig)
|
||||
|
||||
assertStatusEventually(t, m, "relay-1", "error", 4*time.Second)
|
||||
|
||||
// Correct the path to dummy script
|
||||
scriptPath, _ := setupDummyScript(t)
|
||||
writeControl(t, filepath.Dir(scriptPath), 0, 5)
|
||||
|
||||
m.mu.Lock()
|
||||
m.cfg.FrpcPath = scriptPath
|
||||
m.mu.Unlock()
|
||||
|
||||
// Wait for backoff retry (1s backoff)
|
||||
assertStatusEventually(t, m, "relay-1", "running", 4*time.Second)
|
||||
|
||||
m.mu.RLock()
|
||||
proc := m.processes["relay-1"]
|
||||
m.mu.RUnlock()
|
||||
proc.Cancel()
|
||||
}
|
||||
|
||||
func TestUnexpectedExit0CPUProtection(t *testing.T) {
|
||||
scriptPath, dir := setupDummyScript(t)
|
||||
// Start with immediate exit code 0
|
||||
writeControl(t, dir, 0, 0)
|
||||
|
||||
cfg := &config.Config{
|
||||
ServerURL: "http://localhost:8080",
|
||||
TunnelToken: "test-token",
|
||||
FrpcPath: scriptPath,
|
||||
DataDir: dir,
|
||||
StatePath: filepath.Join(dir, "flared-state.json"),
|
||||
}
|
||||
|
||||
m := NewManager(cfg)
|
||||
newConfig := &service.FlaredTunnelConfigResponse{
|
||||
Version: "1",
|
||||
Checksum: "sum1",
|
||||
Relays: []service.FlaredRelayInfo{
|
||||
{
|
||||
RelayNodeID: "relay-1",
|
||||
Address: "127.0.0.1:7000",
|
||||
AuthToken: "auth-1",
|
||||
},
|
||||
},
|
||||
Proxies: nil,
|
||||
}
|
||||
|
||||
_, _ = m.UpdateConfig(context.Background(), newConfig)
|
||||
|
||||
assertStatusEventually(t, m, "relay-1", "stopped", 4*time.Second)
|
||||
|
||||
m.mu.RLock()
|
||||
proc := m.processes["relay-1"]
|
||||
if !strings.Contains(proc.LastError, "exited unexpectedly with code 0") {
|
||||
t.Errorf("expected LastError to record exit status 0 warning, got %s", proc.LastError)
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
proc.Cancel()
|
||||
}
|
||||
|
||||
func TestBackoffReset(t *testing.T) {
|
||||
scriptPath, dir := setupDummyScript(t)
|
||||
// Rapid exit code 1 to increase backoff
|
||||
writeControl(t, dir, 1, 0)
|
||||
|
||||
cfg := &config.Config{
|
||||
ServerURL: "http://localhost:8080",
|
||||
TunnelToken: "test-token",
|
||||
FrpcPath: scriptPath,
|
||||
DataDir: dir,
|
||||
StatePath: filepath.Join(dir, "flared-state.json"),
|
||||
}
|
||||
|
||||
m := NewManager(cfg)
|
||||
newConfig := &service.FlaredTunnelConfigResponse{
|
||||
Version: "1",
|
||||
Checksum: "sum1",
|
||||
Relays: []service.FlaredRelayInfo{
|
||||
{
|
||||
RelayNodeID: "relay-1",
|
||||
Address: "127.0.0.1:7000",
|
||||
AuthToken: "auth-1",
|
||||
},
|
||||
},
|
||||
Proxies: nil,
|
||||
}
|
||||
|
||||
_, _ = m.UpdateConfig(context.Background(), newConfig)
|
||||
|
||||
// Wait to crash
|
||||
assertStatusEventually(t, m, "relay-1", "error", 4*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, "relay-1", "running", 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, "relay-1", "error", 4*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 running now.
|
||||
assertStatusEventually(t, m, "relay-1", "running", 4*time.Second)
|
||||
|
||||
m.mu.RLock()
|
||||
proc := m.processes["relay-1"]
|
||||
m.mu.RUnlock()
|
||||
proc.Cancel()
|
||||
}
|
||||
|
||||
func TestUpdateConfigKillsOrphanProcessBeforeRestart(t *testing.T) {
|
||||
scriptPath, dir := setupDummyScript(t)
|
||||
writeControl(t, dir, 0, 5)
|
||||
|
||||
cfg := &config.Config{
|
||||
ServerURL: "http://localhost:8080",
|
||||
TunnelToken: "test-token",
|
||||
FrpcPath: scriptPath,
|
||||
DataDir: dir,
|
||||
StatePath: filepath.Join(dir, "flared-state.json"),
|
||||
}
|
||||
|
||||
m := NewManager(cfg)
|
||||
|
||||
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()
|
||||
}
|
||||
})
|
||||
|
||||
pidPath := filepath.Join(dir, "frpc_relay-1.pid")
|
||||
if err := os.WriteFile(pidPath, []byte(fmt.Sprintf("%d", orphan.Process.Pid)), 0o644); err != nil {
|
||||
t.Fatalf("failed to seed orphan pid file: %v", err)
|
||||
}
|
||||
|
||||
newConfig := &service.FlaredTunnelConfigResponse{
|
||||
Version: "1",
|
||||
Checksum: "sum1",
|
||||
Relays: []service.FlaredRelayInfo{
|
||||
{
|
||||
RelayNodeID: "relay-1",
|
||||
Address: "127.0.0.1:7000",
|
||||
AuthToken: "auth-1",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if _, err := m.UpdateConfig(context.Background(), newConfig); err != nil {
|
||||
t.Fatalf("failed to UpdateConfig: %v", err)
|
||||
}
|
||||
|
||||
assertCommandExitedEventually(t, orphan, 2*time.Second)
|
||||
assertStatusEventually(t, m, "relay-1", "running", 4*time.Second)
|
||||
|
||||
m.mu.RLock()
|
||||
proc := m.processes["relay-1"]
|
||||
m.mu.RUnlock()
|
||||
proc.Cancel()
|
||||
}
|
||||
|
||||
func TestStopCancelsRunningProcesses(t *testing.T) {
|
||||
scriptPath, dir := setupDummyScript(t)
|
||||
writeControl(t, dir, 0, 30)
|
||||
|
||||
cfg := &config.Config{
|
||||
ServerURL: "http://localhost:8080",
|
||||
TunnelToken: "test-token",
|
||||
FrpcPath: scriptPath,
|
||||
DataDir: dir,
|
||||
StatePath: filepath.Join(dir, "flared-state.json"),
|
||||
}
|
||||
|
||||
m := NewManager(cfg)
|
||||
newConfig := &service.FlaredTunnelConfigResponse{
|
||||
Version: "1",
|
||||
Checksum: "sum1",
|
||||
Relays: []service.FlaredRelayInfo{
|
||||
{
|
||||
RelayNodeID: "relay-1",
|
||||
Address: "127.0.0.1:7000",
|
||||
AuthToken: "auth-1",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if _, err := m.UpdateConfig(context.Background(), newConfig); err != nil {
|
||||
t.Fatalf("failed to UpdateConfig: %v", err)
|
||||
}
|
||||
|
||||
assertStatusEventually(t, m, "relay-1", "running", 4*time.Second)
|
||||
|
||||
m.mu.RLock()
|
||||
proc := m.processes["relay-1"]
|
||||
if proc == nil || proc.Cmd == nil {
|
||||
m.mu.RUnlock()
|
||||
t.Fatal("expected running process to have a command handle")
|
||||
}
|
||||
cmd := proc.Cmd
|
||||
m.mu.RUnlock()
|
||||
|
||||
m.Stop()
|
||||
assertManagedCommandExitedEventually(t, cmd, 2*time.Second)
|
||||
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if len(m.processes) != 0 {
|
||||
t.Fatalf("expected no managed processes after stop, got %d", len(m.processes))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package heartbeat runs the periodic flared heartbeat loop against the control plane.
|
||||
package heartbeat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"Wavelet/openflare/plugins/flared/config"
|
||||
"Wavelet/openflare/plugins/flared/frpc"
|
||||
"Wavelet/openflare/plugins/flared/httpclient"
|
||||
"Wavelet/openflare/plugins/flared/updater"
|
||||
edgeheartbeat "Wavelet/openflare/share/edge/heartbeat"
|
||||
"Wavelet/openflare/share/edge/nodeip"
|
||||
service "Wavelet/openflare/share/protocol"
|
||||
)
|
||||
|
||||
// Service sends periodic heartbeat payloads and applies tunnel settings from responses.
|
||||
type Service struct {
|
||||
client *httpclient.Client
|
||||
frpcManager *frpc.Manager
|
||||
config *config.Config
|
||||
updater *updater.Service
|
||||
}
|
||||
|
||||
// New creates a heartbeat service with the given client, frpc manager, and config.
|
||||
func New(client *httpclient.Client, manager *frpc.Manager, cfg *config.Config) *Service {
|
||||
return &Service{
|
||||
client: client,
|
||||
frpcManager: manager,
|
||||
config: cfg,
|
||||
updater: updater.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the heartbeat loop until ctx is canceled.
|
||||
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 flared heartbeat")
|
||||
|
||||
payload := service.FlaredHeartbeatPayload{
|
||||
ClientVersion: config.Version,
|
||||
FrpVersion: s.frpcManager.GetVersion(ctx),
|
||||
IP: nodeip.DetectWithContext(ctx),
|
||||
TunnelStatus: "running",
|
||||
ConnectedRelays: s.frpcManager.GetConnectedRelays(),
|
||||
CurrentVersion: s.frpcManager.GetCurrentConfigVersion(),
|
||||
CurrentChecksum: s.frpcManager.GetCurrentConfigChecksum(),
|
||||
}
|
||||
|
||||
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 {
|
||||
edgeheartbeat.TryAutoUpdate(ctx, s.updater, tunnelSettingsToAutoUpdate(resp.TunnelSettings), "flared")
|
||||
}
|
||||
}
|
||||
|
||||
func tunnelSettingsToAutoUpdate(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,66 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package httpclient provides the HTTP client used by the flared agent to communicate with the Wavelet server.
|
||||
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 is the HTTP client for the flared tunnel API.
|
||||
type Client struct {
|
||||
base *edgehttp.Client
|
||||
}
|
||||
|
||||
// New creates a new Client configured with the given base URL, authentication token, and request timeout.
|
||||
func New(baseURL string, token string, timeout time.Duration) *Client {
|
||||
return &Client{
|
||||
base: edgehttp.New(baseURL, token, timeout, "X-Tunnel-Token"),
|
||||
}
|
||||
}
|
||||
|
||||
// Heartbeat sends a tunnel heartbeat payload and returns the server response.
|
||||
func (c *Client) Heartbeat(ctx context.Context, payload service.FlaredHeartbeatPayload) (*service.FlaredHeartbeatResponse, error) {
|
||||
resp := APIResponse[service.FlaredHeartbeatResponse]{}
|
||||
if err := c.base.PostJSON(ctx, "/api/v1/tunnel/heartbeat", payload, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := edgehttp.APIError(resp.ErrorMsg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp.Data, nil
|
||||
}
|
||||
|
||||
// GetActiveConfig fetches the currently active tunnel configuration from the server.
|
||||
func (c *Client) GetActiveConfig(ctx context.Context) (*service.FlaredTunnelConfigResponse, error) {
|
||||
resp := APIResponse[service.FlaredTunnelConfigResponse]{}
|
||||
if err := c.base.GetJSON(ctx, "/api/v1/tunnel/config/active", &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := edgehttp.APIError(resp.ErrorMsg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp.Data, nil
|
||||
}
|
||||
|
||||
// ReportApplyLog submits a configuration apply-log entry to the server.
|
||||
func (c *Client) ReportApplyLog(ctx context.Context, payload service.ApplyLogPayload) error {
|
||||
resp := APIResponse[any]{}
|
||||
if err := c.base.PostJSON(ctx, "/api/v1/tunnel/apply-log", payload, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
return edgehttp.APIError(resp.ErrorMsg)
|
||||
}
|
||||
|
||||
// SetToken updates the authentication token used by the client.
|
||||
func (c *Client) SetToken(token string) {
|
||||
c.base.SetToken(token)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package flared 装载 OpenFlare 隧道客户端插件:frpc 进程管理、配置同步与心跳上报,
|
||||
// 以 Cordis 驱动形态在 profile "flared" 下运行。
|
||||
package flared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"Wavelet/core"
|
||||
"Wavelet/openflare/plugins/flared/config"
|
||||
flaredrunner "Wavelet/openflare/plugins/flared/flared"
|
||||
"Wavelet/openflare/plugins/flared/frpc"
|
||||
"Wavelet/openflare/plugins/flared/heartbeat"
|
||||
"Wavelet/openflare/plugins/flared/httpclient"
|
||||
"Wavelet/openflare/plugins/flared/sync"
|
||||
"Wavelet/openflare/plugins/flared/wsclient"
|
||||
"Wavelet/pkg/util"
|
||||
)
|
||||
|
||||
// DriverTypeFlared 是隧道客户端守护进程专属的驱动类型。
|
||||
const DriverTypeFlared core.DriverType = "flared"
|
||||
|
||||
// Plugin 实现 core.Plugin 与 core.Driver。
|
||||
type Plugin struct {
|
||||
configPath string
|
||||
|
||||
runner *flaredrunner.Runner
|
||||
done chan error
|
||||
started bool
|
||||
}
|
||||
|
||||
// New 创建 flared 插件,configPath 指向其 JSON 配置文件。
|
||||
func New(configPath string) *Plugin {
|
||||
return &Plugin{configPath: configPath, done: make(chan error, 1)}
|
||||
}
|
||||
|
||||
// Name 返回插件标识。
|
||||
func (p *Plugin) Name() string { return "flared" }
|
||||
|
||||
// Type 返回驱动类型。
|
||||
func (p *Plugin) Type() core.DriverType { return DriverTypeFlared }
|
||||
|
||||
// Apply 加载配置、恢复 frpc 状态并装配各服务,然后注册驱动。
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
cfg, err := config.Load(p.configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load flared config: %w", err)
|
||||
}
|
||||
slog.Info("flared config loaded",
|
||||
"server", cfg.ServerURL,
|
||||
"frpc_path", cfg.FrpcPath,
|
||||
"data_dir", cfg.DataDir,
|
||||
"heartbeat_interval", cfg.HeartbeatInterval,
|
||||
"sync_interval", cfg.SyncInterval,
|
||||
)
|
||||
|
||||
frpcManager := frpc.NewManager(cfg)
|
||||
_ = frpcManager.LoadState()
|
||||
slog.Info("detected frpc version", "version", frpcManager.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 = &flaredrunner.Runner{
|
||||
Config: cfg,
|
||||
FrpcManager: frpcManager,
|
||||
HTTPClient: httpClient,
|
||||
WebSocketService: wsClient,
|
||||
HeartbeatService: heartbeat.New(httpClient, frpcManager, cfg),
|
||||
SyncService: sync.New(httpClient, frpcManager, cfg),
|
||||
}
|
||||
|
||||
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("flared 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("flared process stopped")
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
p.started = false
|
||||
return fmt.Errorf("flared shutdown timeout: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package flared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"Wavelet/core"
|
||||
)
|
||||
|
||||
func TestPluginIdentity(t *testing.T) {
|
||||
p := New("./flared.json")
|
||||
if got := p.Name(); got != "flared" {
|
||||
t.Errorf("Name() = %q, want %q", got, "flared")
|
||||
}
|
||||
// 驱动类型必须等于 profile 字符串,否则内核的 profile 过滤会漏掉本驱动。
|
||||
if got, want := string(p.Type()), string(core.Profile("flared")); 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,101 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package sync periodically fetches and applies the active tunnel configuration.
|
||||
package sync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"Wavelet/openflare/plugins/flared/config"
|
||||
"Wavelet/openflare/plugins/flared/frpc"
|
||||
"Wavelet/openflare/plugins/flared/httpclient"
|
||||
service "Wavelet/openflare/share/protocol"
|
||||
)
|
||||
|
||||
// Service synchronizes tunnel configuration from the control plane to the local frpc manager.
|
||||
type Service struct {
|
||||
client *httpclient.Client
|
||||
frpcManager *frpc.Manager
|
||||
config *config.Config
|
||||
triggerCh chan struct{}
|
||||
}
|
||||
|
||||
// New creates a sync service with the given client, frpc manager, and config.
|
||||
func New(client *httpclient.Client, manager *frpc.Manager, cfg *config.Config) *Service {
|
||||
return &Service{
|
||||
client: client,
|
||||
frpcManager: manager,
|
||||
config: cfg,
|
||||
triggerCh: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger requests an immediate configuration sync without waiting for the next interval.
|
||||
func (s *Service) Trigger() {
|
||||
select {
|
||||
case s.triggerCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the sync loop until ctx is canceled.
|
||||
func (s *Service) Run(ctx context.Context) {
|
||||
ticker := time.NewTicker(s.config.SyncInterval.Duration())
|
||||
defer ticker.Stop()
|
||||
|
||||
// initial sync
|
||||
s.doSync(ctx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.doSync(ctx)
|
||||
case <-s.triggerCh:
|
||||
s.doSync(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) doSync(ctx context.Context) {
|
||||
slog.Debug("fetching active tunnel config")
|
||||
configResp, err := s.client.GetActiveConfig(ctx)
|
||||
if err != nil {
|
||||
slog.Error("failed to fetch active tunnel config", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 不在 sync 层做版本早退,由 frpcManager.UpdateConfig 负责判断。
|
||||
// 原因:重启后进程全部消失,即使版本/checksum 未变,仍需重新拉起 frpc 进程。
|
||||
configChanged, err := s.frpcManager.UpdateConfig(ctx, configResp)
|
||||
if err != nil {
|
||||
slog.Error("failed to apply tunnel config", "error", err)
|
||||
s.reportApplyLog(ctx, configResp, "failed", err.Error())
|
||||
return
|
||||
}
|
||||
if configChanged {
|
||||
slog.Info("tunnel config applied successfully", "version", configResp.Version)
|
||||
s.reportApplyLog(ctx, configResp, "success", "apply success")
|
||||
return
|
||||
}
|
||||
slog.Debug("tunnel config unchanged, skipping apply log report", "version", configResp.Version)
|
||||
}
|
||||
|
||||
func (s *Service) reportApplyLog(ctx context.Context, configResp *service.FlaredTunnelConfigResponse, result string, message string) {
|
||||
if configResp == nil {
|
||||
return
|
||||
}
|
||||
logPayload := service.ApplyLogPayload{
|
||||
Version: configResp.Version,
|
||||
Result: result,
|
||||
Message: message,
|
||||
Checksum: configResp.Checksum,
|
||||
}
|
||||
if reportErr := s.client.ReportApplyLog(ctx, logPayload); reportErr != nil {
|
||||
slog.Error("failed to report apply log", "error", reportErr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package updater provides update service capabilities for flared.
|
||||
package updater
|
||||
|
||||
import (
|
||||
"Wavelet/openflare/plugins/flared/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 a new updater Service instance.
|
||||
func New() *Service {
|
||||
return edgeupdater.New(edgeupdater.Config{
|
||||
LocalVersion: config.Version,
|
||||
AssetPrefix: "openflared",
|
||||
LogLabel: "flared",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package wsclient provides a WebSocket client for flared 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 flared WebSocket endpoint on the control plane.
|
||||
type Client struct {
|
||||
inner *edgews.Client
|
||||
}
|
||||
|
||||
// New creates a WebSocket client for the flared control-plane endpoint.
|
||||
func New(baseURL, token string, timeout time.Duration) *Client {
|
||||
return &Client{
|
||||
inner: edgews.New(edgews.PresetFlared, 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