mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-06 23:56:37 +08:00
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:
@@ -0,0 +1,9 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package flared
|
||||
|
||||
const (
|
||||
errTunnelTokenInvalid = "无权进行此操作,Tunnel Token 无效"
|
||||
errTunnelNodeTypeMismatch = "此节点不是 TunnelClient 类型"
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user