refactor(repo): consolidate openflare-server to root and move subprojects to internal/apps

- Merge all files inside openflare-server to the repository root directory.
- Relocate agent, relay, and flared subprojects from internal/ to internal/apps/.
- Combine docker-compose files and update build context paths to root.
- Update GitHub workflows and Dockerfiles to refer to new directories and package names.
- Rewrite Go package imports across all files.
- Resolve database renew test race condition and clean up docs.
This commit is contained in:
ryan
2026-06-19 14:23:29 +08:00
parent 19d476ed7f
commit 63cd906cfc
1064 changed files with 366 additions and 1397 deletions
+9
View File
@@ -0,0 +1,9 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package flared
const (
errTunnelTokenInvalid = "无权进行此操作,Tunnel Token 无效"
errTunnelNodeTypeMismatch = "此节点不是 TunnelClient 类型"
)
+176
View File
@@ -0,0 +1,176 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package flared
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"strconv"
"strings"
"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"
)
type configVersionRow struct {
Version string `gorm:"column:version"`
Checksum string `gorm:"column:checksum"`
}
func (configVersionRow) TableName() string {
return "of_config_versions"
}
func normalizeReleaseChannel(channel string) string {
if strings.ToLower(strings.TrimSpace(channel)) == "preview" {
return "preview"
}
return "stable"
}
func normalizeFlaredHeartbeatPayload(payload HeartbeatPayload) HeartbeatPayload {
payload.ClientVersion = strings.TrimSpace(payload.ClientVersion)
payload.FrpVersion = strings.TrimSpace(payload.FrpVersion)
payload.IP = strings.TrimSpace(payload.IP)
payload.TunnelStatus = strings.ToLower(strings.TrimSpace(payload.TunnelStatus))
payload.CurrentVersion = strings.TrimSpace(payload.CurrentVersion)
payload.CurrentChecksum = strings.TrimSpace(payload.CurrentChecksum)
cleaned := make([]ConnectedRelay, 0, len(payload.ConnectedRelays))
for _, item := range payload.ConnectedRelays {
item.RelayNodeID = strings.TrimSpace(item.RelayNodeID)
item.Status = strings.ToLower(strings.TrimSpace(item.Status))
if item.RelayNodeID == "" {
continue
}
if item.Status == "" {
item.Status = "unknown"
}
cleaned = append(cleaned, item)
}
payload.ConnectedRelays = cleaned
return payload
}
func getActiveConfigMeta(ctx context.Context) (*ActiveConfigMeta, 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
err := conn.Where("is_active = ?", true).Order("id desc").First(&version).Error
if err != nil {
return nil, err
}
return &ActiveConfigMeta{
Version: version.Version,
Checksum: version.Checksum,
}, nil
}
func listTunnelRelayNodes(ctx context.Context) ([]model.OpenFlareNode, error) {
nodes, err := model.ListOpenFlareNodes(ctx)
if err != nil {
return nil, err
}
relays := make([]model.OpenFlareNode, 0)
for _, node := range nodes {
if node.NodeType == "tunnel_relay" {
relays = append(relays, node)
}
}
return relays, nil
}
func relayClientAddress(node *model.OpenFlareNode) string {
if node == nil {
return ""
}
port := node.RelayBindPort
if port <= 0 {
port = 7000
}
addr := strings.TrimSpace(node.RelayClientAccessAddr)
if addr == "" {
addr = strings.TrimSpace(node.IP)
}
if addr == "" {
return fmt.Sprintf("127.0.0.1:%d", port)
}
if _, _, err := net.SplitHostPort(addr); err == nil {
return addr
}
if strings.Contains(addr, ":") && strings.Count(addr, ":") > 1 {
return net.JoinHostPort(addr, strconv.Itoa(port))
}
return fmt.Sprintf("%s:%d", addr, port)
}
func decodeStoredDomains(raw string, fallbackDomain string) ([]string, error) {
text := strings.TrimSpace(raw)
if text == "" {
domain := strings.ToLower(strings.TrimSpace(fallbackDomain))
if domain == "" {
return nil, errors.New("domain is required")
}
return []string{domain}, nil
}
var domains []string
if err := json.Unmarshal([]byte(text), &domains); err != nil {
return nil, errors.New("domains payload is invalid")
}
normalized := make([]string, 0, len(domains))
for _, item := range domains {
domain := strings.ToLower(strings.TrimSpace(item))
if domain == "" {
continue
}
normalized = append(normalized, domain)
}
if len(normalized) == 0 {
return nil, errors.New("domain is required")
}
return normalized, nil
}
func parseTunnelTargetAddr(addr string) (string, int) {
addr = strings.TrimSpace(addr)
if addr == "" {
return "127.0.0.1", 80
}
host, portStr, err := net.SplitHostPort(addr)
if err != nil {
lastColon := strings.LastIndex(addr, ":")
if lastColon < 0 {
return addr, 80
}
host = addr[:lastColon]
portStr = addr[lastColon+1:]
}
port := 80
if _, scanErr := fmt.Sscanf(portStr, "%d", &port); scanErr != nil {
port = 80
}
if host == "" {
host = "127.0.0.1"
}
return host, port
}
func sanitizeProxyName(domain string) string {
return strings.ReplaceAll(strings.ReplaceAll(domain, ".", "-"), "*", "wildcard")
}
func buildTunnelSettings(node *model.OpenFlareNode, updateNow bool, updateChannel, updateTag string) *relay.Settings {
return relay.BuildSettings(node, updateNow, updateChannel, updateTag)
}
+303
View File
@@ -0,0 +1,303 @@
// 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
}
@@ -0,0 +1,49 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package flared
import (
"context"
"errors"
"strings"
"github.com/Rain-kl/Wavelet/internal/common/response"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const ctxFlaredNodeKey = "flared_node"
// TunnelAuth authenticates flared requests using X-Tunnel-Token and verifies tunnel_client type.
func TunnelAuth() gin.HandlerFunc {
return func(c *gin.Context) {
token := strings.TrimSpace(c.GetHeader("X-Tunnel-Token"))
node, err := authenticateAccessToken(c.Request.Context(), token)
if err != nil {
response.AbortUnauthorized(c, errTunnelTokenInvalid)
return
}
if node.NodeType != "tunnel_client" {
response.AbortForbidden(c, errTunnelNodeTypeMismatch)
return
}
c.Set(ctxFlaredNodeKey, node)
c.Next()
}
}
func authenticateAccessToken(ctx context.Context, token string) (*model.OpenFlareNode, error) {
if token == "" {
return nil, errors.New("missing tunnel token")
}
node, err := model.GetOpenFlareNodeByAccessToken(ctx, token)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("invalid tunnel token")
}
return nil, err
}
return node, nil
}
@@ -0,0 +1,109 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package flared
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/Rain-kl/Wavelet/internal/common/response"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupFlaredMiddlewareTestDB(t *testing.T) func() {
t.Helper()
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
require.NoError(t, err)
require.NoError(t, sqliteDB.AutoMigrate(&model.OpenFlareNode{}))
db.SetDB(sqliteDB)
return func() {
db.SetDB(nil)
}
}
func seedFlaredNode(t *testing.T, nodeType, accessToken string) *model.OpenFlareNode {
t.Helper()
ctx := context.Background()
node := &model.OpenFlareNode{
NodeID: "flared-test-node",
Name: "flared-test",
Status: "pending",
NodeType: nodeType,
AccessToken: accessToken,
}
require.NoError(t, model.CreateOpenFlareNode(ctx, node))
return node
}
func TestTunnelAuthMissingToken(t *testing.T) {
cleanup := setupFlaredMiddlewareTestDB(t)
defer cleanup()
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.Use(response.ErrorHandlerMiddleware())
engine.GET("/flared/test", TunnelAuth(), func(c *gin.Context) {
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/flared/test", nil)
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
}
func TestTunnelAuthRejectsWrongNodeType(t *testing.T) {
cleanup := setupFlaredMiddlewareTestDB(t)
defer cleanup()
seedFlaredNode(t, "edge_node", "edge-token-flared")
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.Use(response.ErrorHandlerMiddleware())
engine.GET("/flared/test", TunnelAuth(), func(c *gin.Context) {
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/flared/test", nil)
req.Header.Set("X-Tunnel-Token", "edge-token-flared")
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
assert.Equal(t, http.StatusForbidden, rec.Code)
}
func TestTunnelAuthAcceptsTunnelClient(t *testing.T) {
cleanup := setupFlaredMiddlewareTestDB(t)
defer cleanup()
node := seedFlaredNode(t, "tunnel_client", "tunnel-token-valid")
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.GET("/flared/test", TunnelAuth(), func(c *gin.Context) {
authNode, ok := c.Get(ctxFlaredNodeKey)
require.True(t, ok)
assert.Equal(t, node.NodeID, authNode.(*model.OpenFlareNode).NodeID)
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/flared/test", nil)
req.Header.Set("X-Tunnel-Token", "tunnel-token-valid")
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
}
@@ -0,0 +1,50 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package flared
import (
"context"
"fmt"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/agent"
"github.com/Rain-kl/Wavelet/internal/db"
"go.uber.org/zap"
)
const flaredRuntimeUnhealthyEventType = "flared_runtime_unhealthy"
func persistFlaredObservability(ctx context.Context, nodeID string, payload HeartbeatPayload, 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{}{
flaredRuntimeUnhealthyEventType: {},
}
var events []agent.NodeHealthEvent
if payload.TunnelStatus == "unhealthy" {
events = append(events, agent.NodeHealthEvent{
EventType: flaredRuntimeUnhealthyEventType,
Severity: "critical",
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, ","),
},
})
}
conn := db.DB(ctx)
if conn == nil {
return
}
if err := agent.ReconcileScopedNodeHealthEvents(conn, nodeID, events, reportedAt, managedTypes); err != nil {
zap.L().Error("persist flared health events failed", zap.String("node_id", nodeID), zap.Error(err))
}
}
@@ -0,0 +1,71 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package flared
import (
"context"
"testing"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/agent"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/option"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupFlaredObservabilityTestDB(t *testing.T) func() {
t.Helper()
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
require.NoError(t, err)
require.NoError(t, sqliteDB.AutoMigrate(
&model.OpenFlareNode{},
&model.OpenFlareHealthEvent{},
))
db.SetDB(sqliteDB)
option.ResetInitializationForTest()
agent.ResetAuthCacheForTest()
return func() {
db.SetDB(nil)
option.ResetInitializationForTest()
agent.ResetAuthCacheForTest()
}
}
func TestHeartbeatFlaredEmitsHealthEventOnUnhealthy(t *testing.T) {
cleanup := setupFlaredObservabilityTestDB(t)
defer cleanup()
ctx := context.Background()
node := &model.OpenFlareNode{
NodeID: "node-flared-unhealthy",
Name: "flared-unhealthy",
AccessToken: "tunnel-token-unhealthy",
Status: "pending",
NodeType: "tunnel_client",
}
require.NoError(t, db.DB(ctx).Create(node).Error)
_, err := Heartbeat(ctx, node, HeartbeatPayload{
ClientVersion: "v0.2.0",
FrpVersion: "0.61.0",
TunnelStatus: "unhealthy",
CurrentVersion: "v1",
CurrentChecksum: "checksum-1",
})
require.NoError(t, err)
events, err := model.ListOpenFlareHealthEvents(ctx, node.NodeID, false, 20)
require.NoError(t, err)
require.Len(t, events, 1)
assert.Equal(t, flaredRuntimeUnhealthyEventType, events[0].EventType)
assert.Equal(t, "active", events[0].Status)
}
+120
View File
@@ -0,0 +1,120 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package flared
import (
"net/http"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
ofws "github.com/Rain-kl/Wavelet/internal/apps/openflare/websocket"
"github.com/Rain-kl/Wavelet/internal/common/response"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/gin-gonic/gin"
)
// PostHeartbeat handles POST /tunnel/heartbeat.
// @Summary 上报 Tunnel 心跳
// @Description Tunnel 客户端定期上报运行状态与中继连接信息,返回活跃配置元数据与隧道设置
// @Tags openflare-tunnel
// @Accept json
// @Produce json
// @Security TunnelTokenAuth
// @Param body body flared.HeartbeatPayload true "心跳载荷"
// @Success 200 {object} response.Any{data=flared.HeartbeatResponse} "心跳响应"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "Tunnel Token 无效"
// @Failure 403 {object} response.Any "节点类型不匹配"
// @Router /api/v1/tunnel/heartbeat [post]
func PostHeartbeat(c *gin.Context) {
var payload HeartbeatPayload
if !apiutil.BindJSON(c, &payload) {
return
}
authNode, ok := c.Get(ctxFlaredNodeKey)
if !ok {
response.AbortUnauthorized(c, errTunnelTokenInvalid)
return
}
node := authNode.(*model.OpenFlareNode)
result, err := Heartbeat(c.Request.Context(), node, payload)
if apiutil.AbortBadRequestOnError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(result))
}
// GetActiveConfig handles GET /tunnel/config/active.
// @Summary 获取活跃隧道配置
// @Description 返回 Tunnel 客户端当前应应用的完整路由配置(含中继列表与代理定义)
// @Tags openflare-tunnel
// @Produce json
// @Security TunnelTokenAuth
// @Success 200 {object} response.Any{data=flared.TunnelConfigResponse} "隧道配置"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "Tunnel Token 无效"
// @Failure 403 {object} response.Any "节点类型不匹配"
// @Router /api/v1/tunnel/config/active [get]
func GetActiveConfig(c *gin.Context) {
authNode, ok := c.Get(ctxFlaredNodeKey)
if !ok {
response.AbortUnauthorized(c, errTunnelTokenInvalid)
return
}
node := authNode.(*model.OpenFlareNode)
config, err := GetTunnelConfig(c.Request.Context(), node)
if apiutil.AbortBadRequestOnError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(config))
}
// PostApplyLog handles POST /tunnel/apply-log.
// @Summary 上报 Tunnel 配置下发结果
// @Description Tunnel 客户端上报配置应用结果,服务端记录下发日志
// @Tags openflare-tunnel
// @Accept json
// @Produce json
// @Security TunnelTokenAuth
// @Param body body flared.ApplyLogPayload true "下发结果载荷"
// @Success 200 {object} response.Any{data=model.OpenFlareApplyLog} "下发日志记录"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "Tunnel Token 无效"
// @Failure 403 {object} response.Any "节点类型不匹配"
// @Router /api/v1/tunnel/apply-log [post]
func PostApplyLog(c *gin.Context) {
var payload ApplyLogPayload
if !apiutil.BindJSON(c, &payload) {
return
}
if authNode, ok := c.Get(ctxFlaredNodeKey); ok {
payload.NodeID = authNode.(*model.OpenFlareNode).NodeID
}
log, err := ReportApplyLog(c.Request.Context(), payload)
if apiutil.AbortBadRequestOnError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(log))
}
// GetWebSocket handles GET /tunnel/ws.
// @Summary 升级 Tunnel WebSocket 连接
// @Description 将已认证的 Tunnel 客户端连接升级为 WebSocket 长连接,用于配置推送
// @Tags openflare-tunnel
// @Security TunnelTokenAuth
// @Failure 401 {object} response.Any "Tunnel Token 无效"
// @Failure 403 {object} response.Any "节点类型不匹配"
// @Router /api/v1/tunnel/ws [get]
func GetWebSocket(c *gin.Context) {
authNode, ok := c.Get(ctxFlaredNodeKey)
if !ok {
response.AbortUnauthorized(c, errTunnelTokenInvalid)
return
}
node := authNode.(*model.OpenFlareNode)
ofws.ServeFlared(c, node.NodeID)
}