refactor(backend): rename OpenFlare directory to lowercase openflare

This commit is contained in:
ryan
2026-08-30 17:43:23 +08:00
parent 06d5fedbfc
commit c93ff6674f
543 changed files with 819 additions and 819 deletions
@@ -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))
}
}