mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
[优化] 添加 OpenFlared API 支持,增强心跳和配置管理功能
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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