[优化] Phase4

This commit is contained in:
ryan
2026-06-01 09:03:55 +08:00
parent 77ad3ea7e3
commit 9ded8c71da
11 changed files with 1381 additions and 0 deletions
+158
View File
@@ -0,0 +1,158 @@
package config
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"time"
)
type MillisecondDuration time.Duration
func (d *MillisecondDuration) UnmarshalJSON(b []byte) error {
var v interface{}
if err := json.Unmarshal(b, &v); err != nil {
return err
}
switch value := v.(type) {
case float64:
*d = MillisecondDuration(time.Duration(value) * time.Millisecond)
return nil
case string:
duration, err := time.ParseDuration(value)
if err != nil {
return err
}
*d = MillisecondDuration(duration)
return nil
default:
return errors.New("invalid duration format")
}
}
func (d MillisecondDuration) Duration() time.Duration {
return time.Duration(d)
}
func (d MillisecondDuration) String() string {
return time.Duration(d).String()
}
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
}
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
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(10 * time.Second)
}
if cfg.SyncInterval <= 0 {
cfg.SyncInterval = MillisecondDuration(30 * time.Second)
}
if cfg.RequestTimeout <= 0 {
cfg.RequestTimeout = MillisecondDuration(10 * time.Second)
}
}
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
}
func (cfg *Config) InitialAuthToken() string {
if cfg == nil {
return ""
}
return strings.TrimSpace(cfg.TunnelToken)
}
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, 0o644)
}
+84
View File
@@ -0,0 +1,84 @@
package flared
import (
"context"
"log/slog"
"time"
"openflare-flared/internal/config"
"openflare-flared/internal/frpc"
"openflare-flared/internal/heartbeat"
"openflare-flared/internal/httpclient"
"openflare-flared/internal/sync"
"openflare-flared/internal/wsclient"
)
type Runner struct {
Config *config.Config
HeartbeatService *heartbeat.Service
FrpcManager *frpc.Manager
SyncService *sync.Service
WebSocketService *wsclient.Client
HttpClient *httpclient.Client
}
func (r *Runner) Run(ctx context.Context) error {
// Start background services
go r.HeartbeatService.Run(ctx)
go r.SyncService.Run(ctx)
// WebSocket reconnection loop
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
conn, err := r.WebSocketService.Connect(ctx)
if err != nil {
slog.Error("flared ws connect failed, will retry", "error", err)
r.sleepContext(ctx, 5*time.Second)
continue
}
r.handleConnection(ctx, conn)
_ = conn.Close()
slog.Info("flared ws connection closed, reconnecting...")
r.sleepContext(ctx, 2*time.Second)
}
}
func (r *Runner) handleConnection(ctx context.Context, conn *wsclient.Connection) {
for {
select {
case <-ctx.Done():
return
default:
}
msg, err := conn.Receive()
if err != nil {
slog.Error("flared ws receive failed", "error", err)
return
}
switch msg.Type {
case "ping":
_ = conn.SendPong()
case "active_config":
// Server notifies there is a new config available
slog.Info("received config update notification from server")
r.SyncService.Trigger()
default:
slog.Debug("ignored unknown ws message type", "type", msg.Type)
}
}
}
func (r *Runner) sleepContext(ctx context.Context, d time.Duration) {
select {
case <-ctx.Done():
case <-time.After(d):
}
}
+255
View File
@@ -0,0 +1,255 @@
package frpc
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"openflare-flared/internal/config"
"openflare/service"
)
type Manager struct {
cfg *config.Config
processes map[string]*Process
mu sync.RWMutex
currentVersion string
currentChecksum string
}
type Process struct {
RelayID string
Cmd *exec.Cmd
Cancel context.CancelFunc
Status string
StartTime time.Time
LastError string
}
func NewManager(cfg *config.Config) *Manager {
return &Manager{
cfg: cfg,
processes: make(map[string]*Process),
}
}
func (m *Manager) GetVersion() string {
cmd := exec.Command(m.cfg.FrpcPath, "-v")
out, err := cmd.Output()
if err != nil {
return "unknown"
}
return strings.TrimSpace(string(out))
}
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
}
func (m *Manager) GetCurrentConfigVersion() string {
m.mu.RLock()
defer m.mu.RUnlock()
return m.currentVersion
}
func (m *Manager) GetCurrentConfigChecksum() string {
m.mu.RLock()
defer m.mu.RUnlock()
return m.currentChecksum
}
func (m *Manager) UpdateConfig(ctx context.Context, newConfig *service.FlaredTunnelConfigResponse) error {
m.mu.Lock()
defer m.mu.Unlock()
if newConfig == nil {
return nil
}
if newConfig.Version == m.currentVersion && newConfig.Checksum == m.currentChecksum {
return nil
}
slog.Info("applying new tunnel config", "version", newConfig.Version)
if err := os.MkdirAll(m.cfg.DataDir, 0o755); err != nil {
return 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 := true
existingData, err := os.ReadFile(configPath)
if err == nil && string(existingData) == tomlContent {
needsRestart = false
}
if needsRestart {
if err := os.WriteFile(configPath, []byte(tomlContent), 0o644); 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 {
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()
delete(m.processes, relayID)
}
}
m.currentVersion = newConfig.Version
m.currentChecksum = newConfig.Checksum
return m.saveState()
}
func (m *Manager) restartProcess(ctx context.Context, relayID string, configPath string) {
if proc, ok := m.processes[relayID]; ok {
proc.Cancel()
}
procCtx, cancel := context.WithCancel(context.Background())
proc := &Process{
RelayID: relayID,
Cancel: cancel,
Status: "starting",
StartTime: time.Now(),
}
m.processes[relayID] = proc
go func() {
for {
select {
case <-procCtx.Done():
return
default:
}
cmd := exec.CommandContext(procCtx, m.cfg.FrpcPath, "-c", configPath)
proc.Cmd = cmd
proc.Status = "running"
err := cmd.Run()
if err != nil {
if procCtx.Err() != nil {
return
}
proc.LastError = err.Error()
proc.Status = "error"
slog.Error("frpc process exited unexpectedly", "relay_id", relayID, "error", err)
time.Sleep(5 * time.Second) // backoff
} else {
proc.Status = "stopped"
}
}
}()
}
func buildFrpcToml(relay service.FlaredRelayInfo, proxies []service.FlaredProxyEntry) string {
var buf bytes.Buffer
host, port := parseAddr(relay.Address)
buf.WriteString(fmt.Sprintf(`serverAddr = "%s"
serverPort = %s
`, host, port))
if relay.AuthToken != "" {
buf.WriteString(fmt.Sprintf(`auth.method = "token"
auth.token = "%s"
`, relay.AuthToken))
}
if relay.ProxyURL != "" {
buf.WriteString(fmt.Sprintf(`transport.proxyURL = "%s"
`, relay.ProxyURL))
}
buf.WriteString("\n")
for _, proxy := range proxies {
buf.WriteString(fmt.Sprintf("[[proxies]]\nname = \"%s\"\ntype = \"%s\"\nlocalIP = \"%s\"\nlocalPort = %d\n",
proxy.Name, proxy.Type, proxy.LocalAddr, proxy.LocalPort))
if len(proxy.CustomDomains) > 0 {
buf.WriteString(fmt.Sprintf("customDomains = [\"%s\"]\n", strings.Join(proxy.CustomDomains, "\", \"")))
}
buf.WriteString("\n")
}
return buf.String()
}
func parseAddr(addr string) (string, string) {
parts := strings.Split(addr, ":")
if len(parts) == 2 {
return parts[0], parts[1]
}
return addr, "7000"
}
// State persistence
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, 0o644)
}
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
}
+63
View File
@@ -0,0 +1,63 @@
package heartbeat
import (
"context"
"log/slog"
"time"
"openflare-flared/internal/config"
"openflare-flared/internal/frpc"
"openflare-flared/internal/httpclient"
"openflare/service"
)
type Service struct {
client *httpclient.Client
frpcManager *frpc.Manager
config *config.Config
}
func New(client *httpclient.Client, manager *frpc.Manager, cfg *config.Config) *Service {
return &Service{
client: client,
frpcManager: manager,
config: cfg,
}
}
func (s *Service) Run(ctx context.Context) {
ticker := time.NewTicker(s.config.HeartbeatInterval.Duration())
defer ticker.Stop()
// initial heartbeat
s.doHeartbeat(ctx)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.doHeartbeat(ctx)
}
}
}
func (s *Service) doHeartbeat(ctx context.Context) {
slog.Debug("sending flared heartbeat")
payload := service.FlaredHeartbeatPayload{
ClientVersion: "0.1.0", // TODO dynamically inject build version
FrpVersion: s.frpcManager.GetVersion(),
TunnelStatus: "running", // TODO implement proper status tracking
ConnectedRelays: s.frpcManager.GetConnectedRelays(),
CurrentVersion: s.frpcManager.GetCurrentConfigVersion(),
CurrentChecksum: s.frpcManager.GetCurrentConfigChecksum(),
}
_, err := s.client.Heartbeat(ctx, payload)
if err != nil {
slog.Error("flared heartbeat failed", "error", err)
return
}
slog.Debug("flared heartbeat succeeded")
}
+133
View File
@@ -0,0 +1,133 @@
package httpclient
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strings"
"time"
"openflare/service"
)
type APIResponse[T any] struct {
Success bool `json:"success"`
Message string `json:"message"`
Data T `json:"data"`
}
type Client struct {
baseURL string
token string
httpClient *http.Client
}
func New(baseURL string, token string, timeout time.Duration) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
token: token,
httpClient: &http.Client{
Timeout: timeout,
},
}
}
func (c *Client) Heartbeat(ctx context.Context, payload service.FlaredHeartbeatPayload) (*service.FlaredHeartbeatResponse, error) {
resp := APIResponse[service.FlaredHeartbeatResponse]{}
if err := c.postJSON(ctx, "/api/flared/heartbeat", payload, &resp); err != nil {
return nil, err
}
if !resp.Success {
return nil, errors.New(resp.Message)
}
return &resp.Data, nil
}
func (c *Client) GetActiveConfig(ctx context.Context) (*service.FlaredTunnelConfigResponse, error) {
resp := APIResponse[service.FlaredTunnelConfigResponse]{}
if err := c.getJSON(ctx, "/api/flared/config/active", &resp); err != nil {
return nil, err
}
if !resp.Success {
return nil, errors.New(resp.Message)
}
return &resp.Data, nil
}
func (c *Client) ReportApplyLog(ctx context.Context, payload service.ApplyLogPayload) error {
resp := APIResponse[any]{}
if err := c.postJSON(ctx, "/api/flared/apply-log", payload, &resp); err != nil {
return err
}
if !resp.Success {
return errors.New(resp.Message)
}
return nil
}
func (c *Client) SetToken(token string) {
c.token = strings.TrimSpace(token)
slog.Debug("http client token updated")
}
func (c *Client) getJSON(ctx context.Context, path string, target any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
if err != nil {
return err
}
req.Header.Set("X-Tunnel-Token", c.token)
return c.do(req, target)
}
func (c *Client) postJSON(ctx context.Context, path string, body any, target any) error {
data, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Tunnel-Token", c.token)
return c.do(req, target)
}
func (c *Client) do(req *http.Request, target any) error {
res, err := c.httpClient.Do(req)
if err != nil {
slog.Error("http request failed", "method", req.Method, "path", req.URL.Path, "error", err)
return err
}
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
slog.Error("failed to close response body", "error", err)
}
}(res.Body)
if res.StatusCode != http.StatusOK {
slog.Warn("http request returned non-200", "method", req.Method, "path", req.URL.Path, "status", res.Status)
return errors.New(res.Status)
}
if target == nil {
var wrapper APIResponse[json.RawMessage]
if err = json.NewDecoder(res.Body).Decode(&wrapper); err != nil {
slog.Error("http response decode failed", "method", req.Method, "path", req.URL.Path, "error", err)
return err
}
if !wrapper.Success {
slog.Warn("http api response failed", "method", req.Method, "path", req.URL.Path, "message", wrapper.Message)
return errors.New(wrapper.Message)
}
return nil
}
if err = json.NewDecoder(res.Body).Decode(target); err != nil {
slog.Error("http response decode failed", "method", req.Method, "path", req.URL.Path, "error", err)
return err
}
return nil
}
+92
View File
@@ -0,0 +1,92 @@
package sync
import (
"context"
"log/slog"
"time"
"openflare-flared/internal/config"
"openflare-flared/internal/frpc"
"openflare-flared/internal/httpclient"
"openflare/service"
)
type Service struct {
client *httpclient.Client
frpcManager *frpc.Manager
config *config.Config
triggerCh chan struct{}
}
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),
}
}
func (s *Service) Trigger() {
select {
case s.triggerCh <- struct{}{}:
default:
}
}
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
}
if s.frpcManager.GetCurrentConfigVersion() == configResp.Version &&
s.frpcManager.GetCurrentConfigChecksum() == configResp.Checksum {
slog.Debug("tunnel config is up to date", "version", configResp.Version)
return
}
err = s.frpcManager.UpdateConfig(ctx, configResp)
result := "success"
message := "apply success"
if err != nil {
result = "failed"
message = err.Error()
slog.Error("failed to apply tunnel config", "error", err)
} else {
slog.Info("tunnel config applied successfully", "version", configResp.Version)
}
// Report apply log
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)
}
}
+143
View File
@@ -0,0 +1,143 @@
package wsclient
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net"
"net/http"
"net/url"
"strings"
"time"
"golang.org/x/net/websocket"
"openflare/service"
)
type Client struct {
baseURL string
token string
timeout time.Duration
}
type Connection struct {
conn *websocket.Conn
url string
readTimeout time.Duration
}
func New(baseURL string, token string, timeout time.Duration) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
token: strings.TrimSpace(token),
timeout: timeout,
}
}
func (c *Client) SetToken(token string) {
c.token = strings.TrimSpace(token)
slog.Debug("flared ws client token updated")
}
func (c *Client) Connect(ctx context.Context) (*Connection, error) {
wsURL, err := buildWebsocketURL(c.baseURL)
if err != nil {
return nil, err
}
if strings.TrimSpace(c.token) == "" {
return nil, errors.New("flared ws token is empty")
}
origin := strings.TrimSpace(c.baseURL)
if origin == "" {
origin = "http://localhost"
}
config, err := websocket.NewConfig(wsURL, origin)
if err != nil {
return nil, err
}
config.Header = http.Header{}
config.Header.Set("X-Tunnel-Token", c.token)
if c.timeout > 0 {
config.Dialer = &net.Dialer{Timeout: c.timeout}
}
slog.Debug("flared ws dialing server", "url", wsURL)
conn, err := config.DialContext(ctx)
if err != nil {
return nil, err
}
slog.Debug("flared ws dial succeeded", "url", wsURL)
return &Connection{conn: conn, url: wsURL, readTimeout: websocketReadTimeout(c.timeout)}, nil
}
func buildWebsocketURL(baseURL string) (string, error) {
parsed, err := url.Parse(strings.TrimRight(baseURL, "/"))
if err != nil {
return "", err
}
switch parsed.Scheme {
case "http":
parsed.Scheme = "ws"
case "https":
parsed.Scheme = "wss"
case "ws", "wss":
default:
return "", errors.New("server_url scheme must be http, https, ws, or wss")
}
parsed.Path = strings.TrimRight(parsed.Path, "/") + "/api/flared/ws"
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String(), nil
}
func (conn *Connection) SendPong() error {
if conn == nil || conn.conn == nil {
return errors.New("flared ws connection is nil")
}
slog.Debug("flared ws sending pong")
return websocket.JSON.Send(conn.conn, service.WSMessage{
Type: "pong",
})
}
func (conn *Connection) Receive() (service.WSMessage, error) {
var message service.WSMessage
if conn == nil || conn.conn == nil {
return message, errors.New("flared ws connection is nil")
}
if conn.readTimeout > 0 {
_ = conn.conn.SetReadDeadline(time.Now().Add(conn.readTimeout))
}
// Use custom json unmarshaling to handle any type
var raw struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload,omitempty"`
}
err := websocket.JSON.Receive(conn.conn, &raw)
if err != nil {
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
slog.Debug("flared ws receive timeout waiting for server message", "timeout", conn.readTimeout)
}
return message, err
}
message.Type = raw.Type
message.Payload = raw.Payload
slog.Debug("flared ws received message", "type", message.Type)
return message, nil
}
func websocketReadTimeout(requestTimeout time.Duration) time.Duration {
timeout := requestTimeout * 6
if timeout < 75*time.Second {
return 75 * time.Second
}
return timeout
}
func (conn *Connection) Close() error {
if conn == nil || conn.conn == nil {
return nil
}
return conn.conn.Close()
}