[优化] 添加 OpenFlared API 支持,增强心跳和配置管理功能

This commit is contained in:
ryan
2026-06-01 16:41:46 +08:00
parent d09e64ddc6
commit df63b0113a
11 changed files with 821 additions and 2 deletions
+34
View File
@@ -22,6 +22,8 @@ OpenFlare 的管理端 API 与 Agent API 都使用 JSON。
| --- | --- |
| 管理端 API | 由管理端 Session 鉴权 |
| Agent API | 固定放在 `/api/agent/*` |
| Relay API | 固定放在 `/api/relay/*`,使用 `X-Agent-Token` 鉴权(与 Agent 复用同一 token) |
| OpenFlared API | 固定放在 `/api/flared/*`,使用 `X-Tunnel-Token` 鉴权(独立的 tunnel_token) |
| 只读接口 | 使用 `GET` |
| 变更类接口 | 使用 `POST` |
@@ -106,6 +108,38 @@ Agent 也可以在应用新版本后主动请求差异同步:
当 Server 侧 IP 组更新时,已连接的 Agent WebSocket 会收到 `type = "waf_ip_groups"` 的消息,payload 为发生变化的 IP 组数组。Agent 应只更新收到的组,不要求 Server 每次下发全部 IP 组。
## OpenFlared API
OpenFlared 客户端用于内网穿透场景,通过 `tunnel_token` 与 Server 通信,独立于 Agent 认证体系。所有接口都使用 `X-Tunnel-Token` 鉴权,Server 会校验节点 `node_type = tunnel_client`,否则返回 `403`。
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `POST` | `/api/flared/heartbeat` | 客户端心跳,刷新在线状态并返回 tunnel 配置版本摘要 |
| `GET` | `/api/flared/config/active` | 拉取完整的 tunnel 路由配置(relay 列表 + frpc 代理定义) |
| `POST` | `/api/flared/apply-log` | 上报配置应用结果(success / warning / failed) |
| `GET` | `/api/flared/ws` | 升级为 WebSocket,用于实时接收 `active_config` 推送 |
心跳请求示例:
```http
POST /api/flared/heartbeat
X-Tunnel-Token: <tunnel_token>
Content-Type: application/json
{
"client_version": "v0.2.0",
"frp_version": "0.61.0",
"tunnel_status": "running",
"connected_relays": [
{ "relay_node_id": "node-relay-1", "status": "healthy", "proxy_count": 3 }
],
"current_version": "v1",
"current_checksum": "sha256..."
}
```
心跳响应包含 `active_config` 摘要与 `tunnel_settings`(包含心跳间隔、WebSocket 升级开关等运行时参数)。当 Server 发布新版本时,已连接的 OpenFlared WebSocket 会收到 `type = "active_config"` 消息,payload 为版本摘要,客户端应立即拉取完整配置并应用。
日志中不得打印完整 Token。
## Swagger
+173
View File
@@ -0,0 +1,173 @@
package controller
import (
"log/slog"
"net"
"openflare/common"
"openflare/model"
"openflare/service"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/net/websocket"
)
// FlaredHeartbeat godoc
// @Summary Report OpenFlared heartbeat
// @Tags Flared
// @Accept json
// @Produce json
// @Security TunnelTokenAuth
// @Param payload body service.FlaredHeartbeatPayload true "Flared heartbeat payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/flared/heartbeat [post]
func FlaredHeartbeat(c *gin.Context) {
var payload service.FlaredHeartbeatPayload
if !bindJSON(c, &payload) {
return
}
authNode, ok := c.Get("flared_node")
if !ok {
respondUnauthorized(c, "无权进行此操作,Tunnel Token 无效")
return
}
node := authNode.(*model.Node)
response, err := service.HeartbeatFlared(node, payload)
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccess(c, response)
}
// FlaredGetActiveConfig godoc
// @Summary Get active tunnel config for OpenFlared
// @Tags Flared
// @Produce json
// @Security TunnelTokenAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/flared/config/active [get]
func FlaredGetActiveConfig(c *gin.Context) {
authNode, ok := c.Get("flared_node")
if !ok {
respondUnauthorized(c, "无权进行此操作,Tunnel Token 无效")
return
}
node := authNode.(*model.Node)
config, err := service.GetFlaredTunnelConfig(node)
if err != nil {
respondFailure(c, "无法生成隧道配置: "+err.Error())
return
}
respondSuccess(c, config)
}
// FlaredReportApplyLog godoc
// @Summary Report OpenFlared apply result
// @Tags Flared
// @Accept json
// @Produce json
// @Security TunnelTokenAuth
// @Param payload body service.ApplyLogPayload true "Apply log payload"
// @Success 200 {object} map[string]interface{}
// @Router /api/flared/apply-log [post]
func FlaredReportApplyLog(c *gin.Context) {
var payload service.ApplyLogPayload
if !bindJSON(c, &payload) {
return
}
if authNode, ok := c.Get("flared_node"); ok {
payload.NodeID = authNode.(*model.Node).NodeID
}
log, err := service.ReportApplyLog(payload)
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccess(c, log)
}
// FlaredWebSocket godoc
// @Summary Upgrade OpenFlared connection to websocket
// @Tags Flared
// @Security TunnelTokenAuth
// @Router /api/flared/ws [get]
func FlaredWebSocket(c *gin.Context) {
authNode, ok := c.Get("flared_node")
if !ok {
respondUnauthorized(c, "无权进行此操作,Tunnel Token 无效")
return
}
node := authNode.(*model.Node)
slog.Debug("flared ws upgrade requested", "node_id", node.NodeID, "remote", c.Request.RemoteAddr)
websocket.Handler(func(conn *websocket.Conn) {
client := service.RegisterFlaredWSClient(node.NodeID)
defer service.UnregisterFlaredWSClient(client)
defer func() {
_ = conn.Close()
slog.Debug("flared ws connection closed", "node_id", node.NodeID)
}()
slog.Debug("flared ws upgrade succeeded", "node_id", node.NodeID, "remote", c.Request.RemoteAddr)
go func() {
<-client.Done()
_ = conn.Close()
}()
go streamFlaredWSMessages(c, conn, client)
for {
var message service.WSMessage
_ = conn.SetReadDeadline(time.Now().Add(flaredWSReadTimeout()))
if err := websocket.JSON.Receive(conn, &message); err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
slog.Debug("flared ws receive timeout", "node_id", node.NodeID)
return
}
slog.Debug("flared ws receive failed", "node_id", node.NodeID, "error", err)
return
}
slog.Debug("flared ws message received", "node_id", node.NodeID, "type", message.Type)
switch message.Type {
case "ping":
if !service.SendFlaredWSPong(node.NodeID) {
slog.Debug("flared ws pong enqueue failed", "node_id", node.NodeID)
}
case "pong":
slog.Debug("flared ws pong received", "node_id", node.NodeID)
default:
slog.Debug("flared ws unsupported message type", "node_id", node.NodeID, "type", message.Type)
}
}
}).ServeHTTP(c.Writer, c.Request)
}
func streamFlaredWSMessages(c *gin.Context, conn *websocket.Conn, client *service.WSClient) {
for {
select {
case <-c.Request.Context().Done():
return
case <-client.Done():
return
case message, ok := <-client.Messages():
if !ok {
return
}
_ = conn.SetWriteDeadline(time.Now().Add(agentWSWriteTimeout()))
if err := websocket.JSON.Send(conn, message); err != nil {
slog.Debug("flared ws send failed", "node_id", client.ID(), "error", err)
return
}
}
}
}
func flaredWSReadTimeout() time.Duration {
timeout := time.Duration(common.AgentHeartbeatInterval) * time.Millisecond * 3
if timeout < 30*time.Second {
return 30 * time.Second
}
return timeout
}
@@ -0,0 +1,36 @@
package middleware
import (
"net/http"
"openflare/service"
"github.com/gin-gonic/gin"
)
// TunnelAuth authenticates OpenFlared client requests using the per-node
// tunnel_token carried in the X-Tunnel-Token header, and verifies the node is
// of the tunnel_client type.
func TunnelAuth() func(c *gin.Context) {
return func(c *gin.Context) {
token := c.GetHeader("X-Tunnel-Token")
node, err := service.AuthenticateAccessToken(token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "无权进行此操作,Tunnel Token 无效",
})
c.Abort()
return
}
if node.NodeType != "tunnel_client" {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "此节点不是 TunnelClient 类型",
})
c.Abort()
return
}
c.Set("flared_node", node)
c.Next()
}
}
+8
View File
@@ -232,6 +232,14 @@ func SetApiRouter(router *gin.Engine) {
relayRoute.POST("/heartbeat", controller.RelayHeartbeat)
relayRoute.GET("/ws", controller.RelayWebSocket)
}
flaredRoute := apiRouter.Group("/flared")
flaredRoute.Use(middleware.TunnelAuth())
{
flaredRoute.POST("/heartbeat", controller.FlaredHeartbeat)
flaredRoute.GET("/config/active", controller.FlaredGetActiveConfig)
flaredRoute.POST("/apply-log", controller.FlaredReportApplyLog)
flaredRoute.GET("/ws", controller.FlaredWebSocket)
}
}
}
+192
View File
@@ -0,0 +1,192 @@
package router_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"openflare/common"
"openflare/model"
"openflare/router"
"openflare/service"
"testing"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
)
func TestPhaseFlaredRoutesUnauthorized(t *testing.T) {
gin.SetMode(gin.TestMode)
common.RedisEnabled = false
setupTestDB(t)
engine := gin.New()
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
router.SetApiRouter(engine)
heartbeatReq := httptest.NewRequest(http.MethodPost, "/api/flared/heartbeat", bytes.NewReader([]byte(`{}`)))
heartbeatReq.Header.Set("Content-Type", "application/json")
heartbeatRec := httptest.NewRecorder()
engine.ServeHTTP(heartbeatRec, heartbeatReq)
if heartbeatRec.Code != http.StatusUnauthorized {
t.Fatalf("expected unauthorized status for missing token, got %d body=%s", heartbeatRec.Code, heartbeatRec.Body.String())
}
activeReq := httptest.NewRequest(http.MethodGet, "/api/flared/config/active", nil)
activeRec := httptest.NewRecorder()
engine.ServeHTTP(activeRec, activeReq)
if activeRec.Code != http.StatusUnauthorized {
t.Fatalf("expected unauthorized status for missing token on active config, got %d", activeRec.Code)
}
applyReq := httptest.NewRequest(http.MethodPost, "/api/flared/apply-log", bytes.NewReader([]byte(`{}`)))
applyReq.Header.Set("Content-Type", "application/json")
applyRec := httptest.NewRecorder()
engine.ServeHTTP(applyRec, applyReq)
if applyRec.Code != http.StatusUnauthorized {
t.Fatalf("expected unauthorized status for missing token on apply log, got %d", applyRec.Code)
}
}
func TestPhaseFlaredRoutesRejectWrongNodeType(t *testing.T) {
gin.SetMode(gin.TestMode)
common.RedisEnabled = false
setupTestDB(t)
engine := gin.New()
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
router.SetApiRouter(engine)
adminToken := prepareRootToken(t)
createNodeResp := performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/nodes/", map[string]any{
"name": "edge-for-flared-test",
"ip": "10.0.0.20",
})
var createdNode service.NodeView
decodeResponseData(t, createNodeResp, &createdNode)
heartbeatReq := httptest.NewRequest(http.MethodPost, "/api/flared/heartbeat", bytes.NewReader([]byte(`{}`)))
heartbeatReq.Header.Set("Content-Type", "application/json")
heartbeatReq.Header.Set("X-Tunnel-Token", createdNode.AccessToken)
heartbeatRec := httptest.NewRecorder()
engine.ServeHTTP(heartbeatRec, heartbeatReq)
if heartbeatRec.Code != http.StatusForbidden {
t.Fatalf("expected forbidden status for edge_node token, got %d body=%s", heartbeatRec.Code, heartbeatRec.Body.String())
}
}
func TestPhaseFlaredLifecycle(t *testing.T) {
gin.SetMode(gin.TestMode)
common.RedisEnabled = false
setupTestDB(t)
engine := gin.New()
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
router.SetApiRouter(engine)
adminToken := prepareRootToken(t)
// Create an enabled proxy route that will be served to the flared client
// through the tunnel upstream flow.
createRouteAndPublishVersion(t, engine, adminToken)
// Seed a tunnel_client node directly so we can use its access token as the
// tunnel_token when calling the flared endpoints.
tunnelNode := &model.Node{
NodeID: "tun-flared-1",
Name: "office-flared-1",
IP: "192.168.10.20",
AccessToken: "tunnel-token-phase",
Status: service.NodeStatusPending,
NodeType: "tunnel_client",
Version: "",
}
if err := tunnelNode.Insert(); err != nil {
t.Fatalf("failed to seed tunnel client node: %v", err)
}
heartbeatResp := performFlaredJSONRequest(t, engine, tunnelNode.AccessToken, http.MethodPost, "/api/flared/heartbeat", map[string]any{
"client_version": "v0.2.0",
"frp_version": "0.61.0",
"tunnel_status": "running",
"current_version": "",
})
if !heartbeatResp.Success {
t.Fatalf("flared heartbeat failed: %s", heartbeatResp.Message)
}
var heartbeatData service.FlaredHeartbeatResponse
if err := json.Unmarshal(heartbeatResp.Data, &heartbeatData); err != nil {
t.Fatalf("failed to decode flared heartbeat response: %v", err)
}
if heartbeatData.ActiveConfig == nil {
t.Fatal("expected heartbeat to return active config summary")
}
if heartbeatData.TunnelSettings == nil {
t.Fatal("expected heartbeat to return tunnel_settings")
}
// Re-fetch node and assert status flipped to online.
updated, err := model.GetNodeByNodeID(tunnelNode.NodeID)
if err != nil {
t.Fatalf("failed to reload flared node: %v", err)
}
if updated.Status != service.NodeStatusOnline {
t.Fatalf("expected flared node status to be online, got %q", updated.Status)
}
if updated.Version != "v0.2.0" {
t.Fatalf("expected flared client_version to be stored, got %q", updated.Version)
}
activeResp := performFlaredJSONRequest(t, engine, tunnelNode.AccessToken, http.MethodGet, "/api/flared/config/active", nil)
if !activeResp.Success {
t.Fatalf("flared get active config failed: %s", activeResp.Message)
}
var activeConfig service.FlaredTunnelConfigResponse
if err := json.Unmarshal(activeResp.Data, &activeConfig); err != nil {
t.Fatalf("failed to decode flared active config: %v", err)
}
if activeConfig.Version == "" || activeConfig.Checksum == "" {
t.Fatalf("expected flared active config to return version summary, got %+v", activeConfig)
}
applyResp := performFlaredJSONRequest(t, engine, tunnelNode.AccessToken, http.MethodPost, "/api/flared/apply-log", map[string]any{
"version": activeConfig.Version,
"result": service.ApplyResultOK,
"message": "apply ok",
"checksum": activeConfig.Checksum,
})
if !applyResp.Success {
t.Fatalf("flared apply log failed: %s", applyResp.Message)
}
}
func performFlaredJSONRequest(t *testing.T, engine http.Handler, token string, method string, path string, body any) apiResponse {
t.Helper()
var payload []byte
if body != nil {
var err error
payload, err = json.Marshal(body)
if err != nil {
t.Fatalf("failed to marshal request body: %v", err)
}
}
req := httptest.NewRequest(method, path, bytes.NewReader(payload))
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("X-Tunnel-Token", token)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("unexpected status %d for %s %s: %s", recorder.Code, method, path, recorder.Body.String())
}
var resp apiResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if !resp.Success {
t.Fatalf("request %s %s failed: %s", method, path, resp.Message)
}
return resp
}
+3
View File
@@ -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
}
+5 -2
View File
@@ -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
}
+54
View File
@@ -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
}
+4
View File
@@ -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
}
+110
View File
@@ -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"`
+202
View File
@@ -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)
}
}