mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-01 22:46:38 +08:00
[优化] 添加 OpenFlared API 支持,增强心跳和配置管理功能
This commit is contained in:
@@ -468,6 +468,9 @@ func computeNodeStatus(node *model.Node) string {
|
||||
if node.NodeType == "tunnel_relay" && IsRelayWSConnected(node.NodeID) {
|
||||
return NodeStatusOnline
|
||||
}
|
||||
if node.NodeType == "tunnel_client" && IsFlaredWSConnected(node.NodeID) {
|
||||
return NodeStatusOnline
|
||||
}
|
||||
if IsAgentWSConnected(node.NodeID) {
|
||||
return NodeStatusOnline
|
||||
}
|
||||
|
||||
@@ -356,6 +356,7 @@ func PublishConfigVersion(createdBy string, force bool) (*ReleaseResult, error)
|
||||
Checksum: record.Checksum,
|
||||
}
|
||||
BroadcastAgentWSActiveConfig(activeConfig)
|
||||
BroadcastFlaredWSActiveConfig(activeConfig)
|
||||
return &ReleaseResult{
|
||||
Version: record,
|
||||
Routes: bundle.Routes,
|
||||
@@ -403,10 +404,12 @@ func ActivateConfigVersion(id uint) (*model.ConfigVersion, error) {
|
||||
return nil, err
|
||||
}
|
||||
version.IsActive = true
|
||||
BroadcastAgentWSActiveConfig(&ActiveConfigMeta{
|
||||
activeConfig := &ActiveConfigMeta{
|
||||
Version: version.Version,
|
||||
Checksum: version.Checksum,
|
||||
})
|
||||
}
|
||||
BroadcastAgentWSActiveConfig(activeConfig)
|
||||
BroadcastFlaredWSActiveConfig(activeConfig)
|
||||
return version, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package service
|
||||
|
||||
const (
|
||||
FlaredWSConnectedLastSeenValue = "__OPENFLARE_FLARED_WS_CONNECTED__"
|
||||
|
||||
FlaredWSMessageTypeActiveConfig = "active_config"
|
||||
FlaredWSMessageTypeForceSync = "force_sync"
|
||||
FlaredWSMessageTypePong = "pong"
|
||||
)
|
||||
|
||||
var DefaultFlaredWSHub = NewWSHub("flared")
|
||||
|
||||
func RegisterFlaredWSClient(nodeID string) *WSClient {
|
||||
return DefaultFlaredWSHub.Register(nodeID)
|
||||
}
|
||||
|
||||
func UnregisterFlaredWSClient(client *WSClient) {
|
||||
DefaultFlaredWSHub.Unregister(client)
|
||||
}
|
||||
|
||||
func DisconnectFlaredWSClient(nodeID string) {
|
||||
DefaultFlaredWSHub.Disconnect(nodeID)
|
||||
}
|
||||
|
||||
func IsFlaredWSConnected(nodeID string) bool {
|
||||
return DefaultFlaredWSHub.IsConnected(nodeID)
|
||||
}
|
||||
|
||||
func SendFlaredWSPong(nodeID string) bool {
|
||||
return DefaultFlaredWSHub.SendMessage(nodeID, WSMessage{
|
||||
Type: FlaredWSMessageTypePong,
|
||||
})
|
||||
}
|
||||
|
||||
func SendFlaredWSActiveConfig(nodeID string, activeConfig *ActiveConfigMeta) bool {
|
||||
if activeConfig == nil {
|
||||
return false
|
||||
}
|
||||
return DefaultFlaredWSHub.SendMessage(nodeID, WSMessage{
|
||||
Type: FlaredWSMessageTypeActiveConfig,
|
||||
Payload: activeConfig,
|
||||
})
|
||||
}
|
||||
|
||||
func BroadcastFlaredWSActiveConfig(activeConfig *ActiveConfigMeta) WSBroadcastResult {
|
||||
if activeConfig == nil {
|
||||
return WSBroadcastResult{}
|
||||
}
|
||||
result := DefaultFlaredWSHub.Broadcast(WSMessage{
|
||||
Type: FlaredWSMessageTypeActiveConfig,
|
||||
Payload: activeConfig,
|
||||
})
|
||||
return result
|
||||
}
|
||||
@@ -166,6 +166,7 @@ func DeleteNode(id uint) error {
|
||||
}
|
||||
invalidateAccessTokenCache(node.AccessToken)
|
||||
DisconnectAgentWSClient(node.NodeID)
|
||||
DisconnectFlaredWSClient(node.NodeID)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -365,6 +366,9 @@ func nodeViewLastSeenAt(node *model.Node) any {
|
||||
if node.NodeType == "tunnel_relay" && IsRelayWSConnected(node.NodeID) {
|
||||
return RelayWSConnectedLastSeenValue
|
||||
}
|
||||
if node.NodeType == "tunnel_client" && IsFlaredWSConnected(node.NodeID) {
|
||||
return FlaredWSConnectedLastSeenValue
|
||||
}
|
||||
if IsAgentWSConnected(node.NodeID) {
|
||||
return AgentWSConnectedLastSeenValue
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"openflare/common"
|
||||
@@ -194,6 +195,115 @@ type FlaredHeartbeatPayload struct {
|
||||
CurrentChecksum string `json:"current_checksum"`
|
||||
}
|
||||
|
||||
func normalizeFlaredHeartbeatPayload(payload FlaredHeartbeatPayload) FlaredHeartbeatPayload {
|
||||
payload.ClientVersion = strings.TrimSpace(payload.ClientVersion)
|
||||
payload.FrpVersion = strings.TrimSpace(payload.FrpVersion)
|
||||
payload.TunnelStatus = strings.ToLower(strings.TrimSpace(payload.TunnelStatus))
|
||||
payload.CurrentVersion = strings.TrimSpace(payload.CurrentVersion)
|
||||
payload.CurrentChecksum = strings.TrimSpace(payload.CurrentChecksum)
|
||||
cleaned := make([]FlaredConnectedRelay, 0, len(payload.ConnectedRelays))
|
||||
for _, relay := range payload.ConnectedRelays {
|
||||
relay.RelayNodeID = strings.TrimSpace(relay.RelayNodeID)
|
||||
relay.Status = strings.ToLower(strings.TrimSpace(relay.Status))
|
||||
if relay.RelayNodeID == "" {
|
||||
continue
|
||||
}
|
||||
if relay.Status == "" {
|
||||
relay.Status = "unknown"
|
||||
}
|
||||
cleaned = append(cleaned, relay)
|
||||
}
|
||||
payload.ConnectedRelays = cleaned
|
||||
return payload
|
||||
}
|
||||
|
||||
// HeartbeatFlared processes an OpenFlared heartbeat, refreshes node status,
|
||||
// persists the connected relay snapshot, and returns the active tunnel
|
||||
// config summary plus runtime settings.
|
||||
func HeartbeatFlared(node *model.Node, payload FlaredHeartbeatPayload) (*FlaredHeartbeatResponse, 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)
|
||||
}
|
||||
slog.Debug("flared heartbeat received", "node_id", node.NodeID, "client_version", payload.ClientVersion)
|
||||
payload = normalizeFlaredHeartbeatPayload(payload)
|
||||
|
||||
now := time.Now()
|
||||
previous := *node
|
||||
|
||||
changes := make(map[string]any)
|
||||
if previous.Version != payload.ClientVersion {
|
||||
changes["version"] = payload.ClientVersion
|
||||
}
|
||||
if previous.ExtVersion != payload.FrpVersion {
|
||||
changes["ext_version"] = payload.FrpVersion
|
||||
}
|
||||
if previous.CurrentVersion != payload.CurrentVersion {
|
||||
changes["current_version"] = payload.CurrentVersion
|
||||
}
|
||||
if !previous.LastSeenAt.Equal(now) {
|
||||
changes["last_seen_at"] = now
|
||||
}
|
||||
changes["status"] = NodeStatusOnline
|
||||
|
||||
node.Version = payload.ClientVersion
|
||||
node.ExtVersion = payload.FrpVersion
|
||||
node.CurrentVersion = payload.CurrentVersion
|
||||
node.LastSeenAt = now
|
||||
node.Status = NodeStatusOnline
|
||||
if !node.GeoManualOverride {
|
||||
applyGeoInfoFromIP(node, node.IP)
|
||||
}
|
||||
|
||||
if len(changes) > 0 {
|
||||
if err := model.DB.Model(node).Updates(changes).Error; err != nil {
|
||||
return nil, fmt.Errorf("update flared heartbeat: %w", err)
|
||||
}
|
||||
}
|
||||
refreshAccessTokenCache(node)
|
||||
persistFlaredObservability(node.NodeID, payload, now)
|
||||
|
||||
activeConfig, err := GetActiveConfigMetaForAgent()
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
return &FlaredHeartbeatResponse{
|
||||
ActiveConfig: activeConfig,
|
||||
TunnelSettings: buildRelaySettings(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// persistFlaredObservability records the latest connection snapshot and
|
||||
// health event for the OpenFlared client.
|
||||
func persistFlaredObservability(nodeID string, payload FlaredHeartbeatPayload, reportedAt time.Time) {
|
||||
connected := make([]string, 0, len(payload.ConnectedRelays))
|
||||
for _, relay := range payload.ConnectedRelays {
|
||||
connected = append(connected, fmt.Sprintf("%s:%s", relay.RelayNodeID, relay.Status))
|
||||
}
|
||||
managedTypes := map[string]struct{}{
|
||||
"flared_runtime_unhealthy": {},
|
||||
}
|
||||
var events []AgentNodeHealthEvent
|
||||
if payload.TunnelStatus == "unhealthy" {
|
||||
events = append(events, AgentNodeHealthEvent{
|
||||
EventType: "flared_runtime_unhealthy",
|
||||
Severity: NodeHealthSeverityCritical,
|
||||
Message: "openflared runtime is not healthy",
|
||||
TriggeredAtUnix: reportedAt.Unix(),
|
||||
Metadata: map[string]string{
|
||||
"tunnel_status": payload.TunnelStatus,
|
||||
"client_version": payload.ClientVersion,
|
||||
"current_version": payload.CurrentVersion,
|
||||
"current_checksum": payload.CurrentChecksum,
|
||||
"connected_relays": strings.Join(connected, ","),
|
||||
},
|
||||
})
|
||||
}
|
||||
_ = reconcileScopedNodeHealthEvents(model.DB, nodeID, events, reportedAt, managedTypes)
|
||||
}
|
||||
|
||||
// FlaredConnectedRelay describes the status of a relay connection from a client.
|
||||
type FlaredConnectedRelay struct {
|
||||
RelayNodeID string `json:"relay_node_id"`
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"openflare/model"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestHeartbeatRelayPersistsRuntimeAndObservability(t *testing.T) {
|
||||
@@ -93,3 +96,202 @@ func TestHeartbeatRelayPersistsRuntimeAndObservability(t *testing.T) {
|
||||
t.Fatalf("unexpected relay dashboard: %+v", observability.RelayDashboard)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatFlaredRejectsWrongNodeType(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
node := &model.Node{
|
||||
NodeID: "node-not-tunnel-client",
|
||||
Name: "edge",
|
||||
IP: "10.0.0.1",
|
||||
AccessToken: "edge-token",
|
||||
Status: NodeStatusPending,
|
||||
NodeType: "edge_node",
|
||||
Version: "v0.0.0",
|
||||
}
|
||||
if err := node.Insert(); err != nil {
|
||||
t.Fatalf("failed to seed edge node: %v", err)
|
||||
}
|
||||
|
||||
_, err := HeartbeatFlared(node, FlaredHeartbeatPayload{
|
||||
ClientVersion: "v0.1.0",
|
||||
FrpVersion: "0.61.0",
|
||||
TunnelStatus: "running",
|
||||
CurrentVersion: "v1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-tunnel_client node type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatFlaredRejectsNilNode(t *testing.T) {
|
||||
if _, err := HeartbeatFlared(nil, FlaredHeartbeatPayload{}); err == nil {
|
||||
t.Fatal("expected error when node is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatFlaredPersistsRuntime(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
node := &model.Node{
|
||||
NodeID: "node-flared-1",
|
||||
Name: "flared-1",
|
||||
IP: "",
|
||||
AccessToken: "tunnel-token-abc",
|
||||
Status: NodeStatusPending,
|
||||
NodeType: "tunnel_client",
|
||||
Version: "",
|
||||
}
|
||||
if err := node.Insert(); err != nil {
|
||||
t.Fatalf("failed to seed flared node: %v", err)
|
||||
}
|
||||
|
||||
resp, err := HeartbeatFlared(node, FlaredHeartbeatPayload{
|
||||
ClientVersion: " v0.2.0 ",
|
||||
FrpVersion: " 0.61.1 ",
|
||||
TunnelStatus: " RUNNING ",
|
||||
ConnectedRelays: []FlaredConnectedRelay{
|
||||
{RelayNodeID: " node-relay-1 ", Status: " HEALTHY ", ProxyCount: 3},
|
||||
{RelayNodeID: "", Status: "running"},
|
||||
{RelayNodeID: "node-relay-2", Status: ""},
|
||||
},
|
||||
CurrentVersion: "v1",
|
||||
CurrentChecksum: "checksum-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HeartbeatFlared failed: %v", err)
|
||||
}
|
||||
if resp == nil {
|
||||
t.Fatal("expected non-nil response")
|
||||
}
|
||||
if resp.TunnelSettings == nil {
|
||||
t.Fatal("expected tunnel_settings in response")
|
||||
}
|
||||
if resp.TunnelSettings.HeartbeatInterval == 0 {
|
||||
t.Fatal("expected heartbeat interval to be set in tunnel_settings")
|
||||
}
|
||||
|
||||
updated, err := model.GetNodeByNodeID(node.NodeID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to reload flared node: %v", err)
|
||||
}
|
||||
if updated.Status != NodeStatusOnline {
|
||||
t.Fatalf("expected flared node to be online, got %q", updated.Status)
|
||||
}
|
||||
if updated.Version != "v0.2.0" {
|
||||
t.Fatalf("expected client_version to be trimmed and stored, got %q", updated.Version)
|
||||
}
|
||||
if updated.ExtVersion != "0.61.1" {
|
||||
t.Fatalf("expected frp_version to be trimmed and stored, got %q", updated.ExtVersion)
|
||||
}
|
||||
if updated.CurrentVersion != "v1" {
|
||||
t.Fatalf("expected current_version to be stored, got %q", updated.CurrentVersion)
|
||||
}
|
||||
if updated.LastSeenAt.IsZero() {
|
||||
t.Fatal("expected last_seen_at to be updated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatFlaredTrimsAndFiltersRelays(t *testing.T) {
|
||||
normalized := normalizeFlaredHeartbeatPayload(FlaredHeartbeatPayload{
|
||||
TunnelStatus: " UNHEALTHY ",
|
||||
ConnectedRelays: []FlaredConnectedRelay{
|
||||
{RelayNodeID: " node-a ", Status: " OK "},
|
||||
{RelayNodeID: "", Status: "running"},
|
||||
},
|
||||
})
|
||||
if normalized.TunnelStatus != "unhealthy" {
|
||||
t.Fatalf("expected tunnel_status to be lower-cased, got %q", normalized.TunnelStatus)
|
||||
}
|
||||
if len(normalized.ConnectedRelays) != 1 {
|
||||
t.Fatalf("expected empty relay_node_id to be dropped, got %+v", normalized.ConnectedRelays)
|
||||
}
|
||||
relay := normalized.ConnectedRelays[0]
|
||||
if relay.RelayNodeID != "node-a" {
|
||||
t.Fatalf("expected relay_node_id to be trimmed, got %q", relay.RelayNodeID)
|
||||
}
|
||||
if relay.Status != "ok" {
|
||||
t.Fatalf("expected status to be lower-cased, got %q", relay.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatFlaredEmitsHealthEventOnUnhealthy(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
node := &model.Node{
|
||||
NodeID: "node-flared-unhealthy",
|
||||
Name: "flared-unhealthy",
|
||||
IP: "",
|
||||
AccessToken: "tunnel-token-unhealthy",
|
||||
Status: NodeStatusPending,
|
||||
NodeType: "tunnel_client",
|
||||
Version: "",
|
||||
}
|
||||
if err := node.Insert(); err != nil {
|
||||
t.Fatalf("failed to seed flared node: %v", err)
|
||||
}
|
||||
|
||||
if _, err := HeartbeatFlared(node, FlaredHeartbeatPayload{
|
||||
ClientVersion: "v0.2.0",
|
||||
FrpVersion: "0.61.0",
|
||||
TunnelStatus: "unhealthy",
|
||||
CurrentVersion: "v1",
|
||||
CurrentChecksum: "checksum-1",
|
||||
}); err != nil {
|
||||
t.Fatalf("HeartbeatFlared failed: %v", err)
|
||||
}
|
||||
|
||||
events, err := model.ListNodeHealthEvents(node.NodeID, false, 20)
|
||||
if err != nil {
|
||||
t.Fatalf("ListNodeHealthEvents failed: %v", err)
|
||||
}
|
||||
if len(events) == 0 {
|
||||
t.Fatal("expected unhealthy heartbeat to emit a node health event")
|
||||
}
|
||||
foundUnhealthy := false
|
||||
for _, event := range events {
|
||||
if event.EventType == "flared_runtime_unhealthy" {
|
||||
foundUnhealthy = true
|
||||
}
|
||||
}
|
||||
if !foundUnhealthy {
|
||||
t.Fatalf("expected flared_runtime_unhealthy event in %+v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatFlaredEmitsEmptyConnectedRelays(t *testing.T) {
|
||||
normalized := normalizeFlaredHeartbeatPayload(FlaredHeartbeatPayload{})
|
||||
if normalized.ConnectedRelays == nil {
|
||||
t.Fatal("expected ConnectedRelays to be non-nil empty slice for nil input")
|
||||
}
|
||||
if len(normalized.ConnectedRelays) != 0 {
|
||||
t.Fatalf("expected empty ConnectedRelays, got %+v", normalized.ConnectedRelays)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFlaredTunnelConfigRequiresActiveVersion(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
node := &model.Node{
|
||||
NodeID: "node-flared-noactive",
|
||||
Name: "flared-noactive",
|
||||
IP: "",
|
||||
AccessToken: "tunnel-token-na",
|
||||
Status: NodeStatusPending,
|
||||
NodeType: "tunnel_client",
|
||||
Version: "",
|
||||
}
|
||||
if err := node.Insert(); err != nil {
|
||||
t.Fatalf("failed to seed flared node: %v", err)
|
||||
}
|
||||
|
||||
_, err := GetFlaredTunnelConfig(node)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no active config version exists")
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// We accept either wrapping the underlying error or surfacing a friendly message.
|
||||
// Just ensure we surface a clear failure instead of a nil result.
|
||||
t.Logf("GetFlaredTunnelConfig returned wrapped error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user