Files
OpenFlare/Wavelet/internal/apps/openflare/flared/logics.go
T
2026-06-18 16:51:06 +08:00

304 lines
9.4 KiB
Go

// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package flared
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/agent"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/relay"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"gorm.io/gorm"
)
const (
nodeStatusOnline = "online"
applyResultOK = "success"
applyResultWarn = "warning"
applyResultFail = "failed"
)
// HeartbeatPayload is sent by OpenFlared on each heartbeat.
type HeartbeatPayload struct {
ClientVersion string `json:"client_version"`
FrpVersion string `json:"frp_version"`
IP string `json:"ip"`
TunnelStatus string `json:"tunnel_status"`
ConnectedRelays []ConnectedRelay `json:"connected_relays"`
CurrentVersion string `json:"current_version"`
CurrentChecksum string `json:"current_checksum"`
}
// ConnectedRelay describes relay connection status from the client.
type ConnectedRelay struct {
RelayNodeID string `json:"relay_node_id"`
Status string `json:"status"`
ProxyCount int `json:"proxy_count"`
}
// ActiveConfigMeta summarizes the active config version.
type ActiveConfigMeta struct {
Version string `json:"version"`
Checksum string `json:"checksum"`
}
// HeartbeatResponse is returned to the OpenFlared client.
type HeartbeatResponse struct {
ActiveConfig *ActiveConfigMeta `json:"active_config"`
TunnelSettings *relay.Settings `json:"tunnel_settings"`
}
// TunnelConfigResponse is the full tunnel routing config sent to the client.
type TunnelConfigResponse struct {
Version string `json:"version"`
Checksum string `json:"checksum"`
Relays []RelayInfo `json:"relays"`
Proxies []ProxyEntry `json:"proxies"`
}
// RelayInfo describes a relay the client should connect to.
type RelayInfo struct {
RelayNodeID string `json:"relay_node_id"`
Address string `json:"address"`
AuthToken string `json:"auth_token"`
ProxyURL string `json:"proxy_url"`
}
// ProxyEntry describes one frpc proxy definition.
type ProxyEntry struct {
Name string `json:"name"`
Type string `json:"type"`
LocalAddr string `json:"local_addr"`
LocalPort int `json:"local_port"`
CustomDomains []string `json:"custom_domains"`
}
// ApplyLogPayload is the apply result reported by OpenFlared.
type ApplyLogPayload struct {
NodeID string `json:"node_id"`
Version string `json:"version"`
Result string `json:"result"`
Message string `json:"message"`
Checksum string `json:"checksum"`
MainConfigChecksum string `json:"main_config_checksum"`
RouteConfigChecksum string `json:"route_config_checksum"`
SupportFileCount int `json:"support_file_count"`
}
// Heartbeat processes an OpenFlared heartbeat and returns runtime settings.
func Heartbeat(ctx context.Context, node *model.OpenFlareNode, payload HeartbeatPayload) (*HeartbeatResponse, error) {
if node == nil {
return nil, fmt.Errorf("tunnel client node is nil")
}
if node.NodeType != "tunnel_client" {
return nil, fmt.Errorf("node %s is not a tunnel_client", node.NodeID)
}
payload = normalizeFlaredHeartbeatPayload(payload)
previous := *node
updateNow := node.UpdateRequested
updateChannel := normalizeReleaseChannel(node.UpdateChannel)
updateTag := strings.TrimSpace(node.UpdateTag)
now := time.Now().UTC()
changes := map[string]any{
"version": payload.ClientVersion,
"ext_version": payload.FrpVersion,
"current_version": payload.CurrentVersion,
"last_seen_at": now,
"status": nodeStatusOnline,
"update_requested": false,
"update_channel": "stable",
"update_tag": "",
}
if !previous.UpdateRequested {
delete(changes, "update_requested")
}
if previous.UpdateChannel == "stable" {
delete(changes, "update_channel")
}
if previous.UpdateTag == "" {
delete(changes, "update_tag")
}
if !node.IPManualOverride && payload.IP != "" && previous.IP != payload.IP {
changes["ip"] = payload.IP
node.IP = payload.IP
}
node.Version = payload.ClientVersion
node.ExtVersion = payload.FrpVersion
node.CurrentVersion = payload.CurrentVersion
node.UpdateRequested = false
node.UpdateChannel = "stable"
node.UpdateTag = ""
lastSeen := now
node.LastSeenAt = &lastSeen
node.Status = nodeStatusOnline
if err := db.DB(ctx).Model(node).Updates(changes).Error; err != nil {
return nil, fmt.Errorf("update flared heartbeat: %w", err)
}
agent.RefreshAccessTokenCache(ctx, node)
persistFlaredObservability(ctx, node.NodeID, payload, now)
activeConfig, err := getActiveConfigMeta(ctx)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
return &HeartbeatResponse{
ActiveConfig: activeConfig,
TunnelSettings: buildTunnelSettings(node, updateNow, updateChannel, updateTag),
}, nil
}
// GetTunnelConfig builds the full tunnel routing config for an OpenFlared client.
func GetTunnelConfig(ctx context.Context, node *model.OpenFlareNode) (*TunnelConfigResponse, error) {
if node == nil {
return nil, fmt.Errorf("node is nil")
}
activeVersion, err := getActiveConfigVersion(ctx)
if err != nil {
return nil, fmt.Errorf("no active config version: %w", err)
}
routes, err := model.ListProxyRoutes(ctx)
if err != nil {
return nil, fmt.Errorf("get proxy routes: %w", err)
}
relayNodes, err := listTunnelRelayNodes(ctx)
if err != nil {
return nil, fmt.Errorf("get relay nodes: %w", err)
}
relays := make([]RelayInfo, 0, len(relayNodes))
for i := range relayNodes {
relayNode := relayNodes[i]
if relayNode.RelayStatus == "healthy" || relayNode.Status == nodeStatusOnline {
relays = append(relays, RelayInfo{
RelayNodeID: relayNode.NodeID,
Address: relayClientAddress(&relayNode),
AuthToken: relayNode.RelayAuthToken,
ProxyURL: strings.TrimSpace(relayNode.RelayClientProxyURL),
})
}
}
proxies := make([]ProxyEntry, 0)
for _, route := range routes {
if route == nil || route.UpstreamType != "tunnel" || route.TunnelNodeID == nil || *route.TunnelNodeID != node.ID {
continue
}
if !route.Enabled {
continue
}
domains, decodeErr := decodeStoredDomains(route.Domains, route.Domain)
if decodeErr != nil {
continue
}
localAddr, localPort := parseTunnelTargetAddr(route.TunnelTargetAddr)
proxies = append(proxies, ProxyEntry{
Name: fmt.Sprintf("%s-%s", node.NodeID, sanitizeProxyName(domains[0])),
Type: "http",
LocalAddr: localAddr,
LocalPort: localPort,
CustomDomains: domains,
})
}
return &TunnelConfigResponse{
Version: activeVersion.Version,
Checksum: activeVersion.Checksum,
Relays: relays,
Proxies: proxies,
}, nil
}
// ReportApplyLog records an apply result from OpenFlared.
func ReportApplyLog(ctx context.Context, payload ApplyLogPayload) (*model.OpenFlareApplyLog, error) {
now := time.Now().UTC()
payload = normalizeApplyLogPayload(payload)
if payload.NodeID == "" {
return nil, errors.New("node_id 不能为空")
}
if payload.Version == "" {
return nil, errors.New("version 不能为空")
}
if payload.Result != applyResultOK && payload.Result != applyResultWarn && payload.Result != applyResultFail {
return nil, errors.New("result 仅支持 success、warning 或 failed")
}
log := &model.OpenFlareApplyLog{
NodeID: payload.NodeID,
Version: payload.Version,
Result: payload.Result,
Message: payload.Message,
Checksum: payload.Checksum,
MainConfigChecksum: payload.MainConfigChecksum,
RouteConfigChecksum: payload.RouteConfigChecksum,
SupportFileCount: payload.SupportFileCount,
CreatedAt: now,
}
err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
var node model.OpenFlareNode
if err := tx.Where("node_id = ?", payload.NodeID).First(&node).Error; err != nil {
return err
}
node.Status = nodeStatusOnline
lastSeen := now
node.LastSeenAt = &lastSeen
if payload.Result == applyResultOK {
node.CurrentVersion = payload.Version
node.LastError = ""
} else {
node.LastError = payload.Message
}
if err := tx.Create(log).Error; err != nil {
return err
}
return tx.Model(&node).Select("status", "last_seen_at", "current_version", "last_error").Updates(&node).Error
})
if err != nil {
return nil, err
}
return log, nil
}
func getActiveConfigVersion(ctx context.Context) (*configVersionRow, error) {
conn := db.DB(ctx)
if conn == nil {
return nil, errors.New("database not initialized")
}
if !conn.Migrator().HasTable(&configVersionRow{}) {
return nil, gorm.ErrRecordNotFound
}
var version configVersionRow
if err := conn.Where("is_active = ?", true).Order("id desc").First(&version).Error; err != nil {
return nil, err
}
return &version, nil
}
func normalizeApplyLogPayload(payload ApplyLogPayload) ApplyLogPayload {
payload.NodeID = strings.TrimSpace(payload.NodeID)
payload.Version = strings.TrimSpace(payload.Version)
payload.Result = strings.ToLower(strings.TrimSpace(payload.Result))
payload.Message = strings.TrimSpace(payload.Message)
payload.Checksum = strings.TrimSpace(payload.Checksum)
payload.MainConfigChecksum = strings.TrimSpace(payload.MainConfigChecksum)
payload.RouteConfigChecksum = strings.TrimSpace(payload.RouteConfigChecksum)
if len(payload.Message) > 16000 {
payload.Message = payload.Message[:16000]
}
return payload
}