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 relay
const (
errAgentTokenInvalid = "无权进行此操作,Agent Token 无效"
errRelayNodeTypeMismatch = "此节点不是 TunnelRelay 类型"
)
+112
View File
@@ -0,0 +1,112 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package relay
import (
"net"
"strings"
"github.com/Rain-kl/Wavelet/internal/model"
)
func normalizeRelayStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "healthy":
return "healthy"
case "unhealthy":
return "unhealthy"
default:
return "unknown"
}
}
func normalizeReleaseChannel(channel string) string {
if strings.ToLower(strings.TrimSpace(channel)) == "preview" {
return "preview"
}
return "stable"
}
func resolveReportedNodeIP(reportedIP string, remoteAddr string) string {
reported := normalizeNodeIP(reportedIP)
remote := normalizeRemoteAddr(remoteAddr)
if reported == "" {
return remote
}
if isPublicNodeIP(reported) {
return reported
}
if isPublicNodeIP(remote) {
return remote
}
return reported
}
func normalizeNodeIP(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if host, _, err := net.SplitHostPort(raw); err == nil {
raw = host
}
raw = strings.Trim(raw, "[]")
return raw
}
func normalizeRemoteAddr(remoteAddr string) string {
remoteAddr = strings.TrimSpace(remoteAddr)
if remoteAddr == "" {
return ""
}
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
return normalizeNodeIP(remoteAddr)
}
return normalizeNodeIP(host)
}
func isPublicNodeIP(raw string) bool {
ip := net.ParseIP(strings.TrimSpace(raw))
if ip == nil {
return false
}
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
return false
}
return true
}
func buildRelayConfig(node *model.OpenFlareNode) *Config {
if node == nil {
return nil
}
return &Config{
BindPort: node.RelayBindPort,
VhostHTTPPort: node.RelayVhostHTTPPort,
AuthToken: node.RelayAuthToken,
LogLevel: "info",
WebServerEnabled: node.RelayWebServerEnabled,
}
}
// BuildSettings returns runtime settings shared by relay and flared clients.
func BuildSettings(node *model.OpenFlareNode, updateNow bool, updateChannel, updateTag string) *Settings {
autoUpdate := false
if node != nil {
autoUpdate = node.AutoUpdateEnabled
}
if strings.TrimSpace(updateChannel) == "" {
updateChannel = "stable"
}
return &Settings{
HeartbeatInterval: model.AgentHeartbeatInterval,
WebsocketUpgradeEnabled: model.AgentWebsocketUpgradeEnabled,
AutoUpdate: autoUpdate,
UpdateRepo: model.AgentUpdateRepo,
UpdateNow: updateNow,
UpdateChannel: updateChannel,
UpdateTag: strings.TrimSpace(updateTag),
}
}
+141
View File
@@ -0,0 +1,141 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package relay
import (
"context"
"fmt"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/agent"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
)
const nodeStatusOnline = "online"
// ProxyStat describes a single frps proxy reported by the relay.
type ProxyStat struct {
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
ClientVersion string `json:"client_version"`
LastStartTime string `json:"last_start_time"`
LastCloseTime string `json:"last_close_time"`
ClientAddr string `json:"client_addr"`
}
// HeartbeatPayload is sent by OpenFlareRelay on each heartbeat.
type HeartbeatPayload struct {
Version string `json:"version"`
ExtVersion string `json:"frp_version"`
RelayStatus string `json:"relay_status"`
FrpsConnCount int `json:"frps_connections"`
FrpsProxyCount int `json:"frps_proxy_count"`
FrpsClientCount int `json:"frps_client_count"`
FrpsProxies []ProxyStat `json:"frps_proxies,omitempty"`
Name string `json:"name"`
IP string `json:"ip"`
Profile *agent.NodeSystemProfile `json:"profile,omitempty"`
Snapshot *agent.NodeMetricSnapshot `json:"snapshot,omitempty"`
HealthEvents []agent.NodeHealthEvent `json:"health_events,omitempty"`
}
// Config is the frps configuration sent to the relay.
type Config struct {
BindPort int `json:"bind_port"`
VhostHTTPPort int `json:"vhost_http_port"`
AuthToken string `json:"auth_token"`
LogLevel string `json:"log_level"`
WebServerEnabled bool `json:"web_server_enabled"`
}
// Settings contains runtime settings for relay and flared clients.
type Settings struct {
HeartbeatInterval int `json:"heartbeat_interval"`
WebsocketUpgradeEnabled bool `json:"websocket_upgrade_enabled"`
AutoUpdate bool `json:"auto_update"`
UpdateRepo string `json:"update_repo"`
UpdateNow bool `json:"update_now"`
UpdateChannel string `json:"update_channel"`
UpdateTag string `json:"update_tag"`
}
// HeartbeatResponse is returned from a relay heartbeat.
type HeartbeatResponse struct {
RelayConfig *Config `json:"relay_config"`
RelaySettings *Settings `json:"relay_settings"`
}
// Heartbeat processes a relay heartbeat, updates node status, and returns config.
func Heartbeat(ctx context.Context, node *model.OpenFlareNode, payload HeartbeatPayload) (*HeartbeatResponse, error) {
if node == nil {
return nil, fmt.Errorf("relay node is nil")
}
payload.Version = strings.TrimSpace(payload.Version)
payload.ExtVersion = strings.TrimSpace(payload.ExtVersion)
payload.RelayStatus = normalizeRelayStatus(payload.RelayStatus)
payload.Name = strings.TrimSpace(payload.Name)
payload.IP = strings.TrimSpace(payload.IP)
previous := *node
updateNow := node.UpdateRequested
updateChannel := normalizeReleaseChannel(node.UpdateChannel)
updateTag := strings.TrimSpace(node.UpdateTag)
now := time.Now().UTC()
changes := map[string]any{
"version": payload.Version,
"ext_version": payload.ExtVersion,
"relay_status": payload.RelayStatus,
"last_seen_at": now,
"status": nodeStatusOnline,
"update_requested": false,
"update_channel": "stable",
"update_tag": "",
}
if payload.Name != "" && strings.TrimSpace(node.Name) == "" {
changes["name"] = payload.Name
node.Name = payload.Name
}
if payload.IP != "" && !node.IPManualOverride {
changes["ip"] = payload.IP
node.IP = payload.IP
}
if !previous.UpdateRequested {
delete(changes, "update_requested")
}
if previous.UpdateChannel == "stable" {
delete(changes, "update_channel")
}
if previous.UpdateTag == "" {
delete(changes, "update_tag")
}
node.Version = payload.Version
node.ExtVersion = payload.ExtVersion
node.RelayStatus = payload.RelayStatus
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 relay heartbeat: %w", err)
}
if err := reconcileRelayHealthEvents(ctx, node.NodeID, payload.RelayStatus, now); err != nil {
return nil, fmt.Errorf("reconcile relay health events: %w", err)
}
agent.RefreshAccessTokenCache(ctx, node)
persistRelayHeartbeatObservability(ctx, node.NodeID, payload, now)
return &HeartbeatResponse{
RelayConfig: buildRelayConfig(node),
RelaySettings: BuildSettings(node, updateNow, updateChannel, updateTag),
}, nil
}
@@ -0,0 +1,174 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package relay
import (
"context"
"encoding/json"
"testing"
"time"
"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 setupRelayTestDB(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.OpenFlareOption{},
&model.OpenFlareNodeSystemProfile{},
&model.OpenFlareMetricSnapshot{},
&model.OpenFlareHealthEvent{},
&model.OpenFlareNodeObservationFrps{},
))
db.SetDB(sqliteDB)
option.ResetInitializationForTest()
agent.ResetAuthCacheForTest()
return func() {
db.SetDB(nil)
option.ResetInitializationForTest()
agent.ResetAuthCacheForTest()
}
}
func TestHeartbeatPayloadBindingAndFrpsObservationInsert(t *testing.T) {
cleanup := setupRelayTestDB(t)
defer cleanup()
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Second)
node := &model.OpenFlareNode{
NodeID: "node-relay-observe",
Name: "relay-1",
AccessToken: "relay-token",
Status: "pending",
NodeType: "tunnel_relay",
RelayStatus: "unknown",
}
require.NoError(t, db.DB(ctx).Create(node).Error)
proxies := []ProxyStat{
{
Name: "proxy-a",
Type: "http",
Status: "online",
ClientVersion: "0.61.0",
ClientAddr: "10.0.0.2:12345",
},
}
_, err := Heartbeat(ctx, node, HeartbeatPayload{
Version: "v0.1.0",
ExtVersion: "0.61.0",
RelayStatus: "healthy",
FrpsConnCount: 7,
FrpsProxyCount: 3,
FrpsClientCount: 2,
FrpsProxies: proxies,
Name: "relay-runtime",
IP: "203.0.113.9",
Profile: &agent.NodeSystemProfile{
Hostname: "relay-runtime",
OSName: "Ubuntu",
OSVersion: "24.04",
Architecture: "amd64",
CPUCores: 4,
ReportedAtUnix: now.Unix(),
},
Snapshot: &agent.NodeMetricSnapshot{
CapturedAtUnix: now.Unix(),
CPUUsagePercent: 12.5,
NetworkRxBytes: 1024,
NetworkTxBytes: 2048,
},
HealthEvents: []agent.NodeHealthEvent{},
})
require.NoError(t, err)
var stored model.OpenFlareNode
require.NoError(t, db.DB(ctx).Where("node_id = ?", node.NodeID).First(&stored).Error)
assert.Equal(t, "online", stored.Status)
assert.Equal(t, "healthy", stored.RelayStatus)
assert.Equal(t, "203.0.113.9", stored.IP)
assert.Equal(t, "v0.1.0", stored.Version)
assert.Equal(t, "0.61.0", stored.ExtVersion)
profile, err := model.GetOpenFlareNodeSystemProfile(ctx, node.NodeID)
require.NoError(t, err)
assert.Equal(t, "relay-runtime", profile.Hostname)
assert.Equal(t, "Ubuntu", profile.OSName)
snapshots, err := model.ListOpenFlareMetricSnapshotsSince(ctx, node.NodeID, now.Add(-time.Minute), 10)
require.NoError(t, err)
require.Len(t, snapshots, 1)
assert.Equal(t, 12.5, snapshots[0].CPUUsagePercent)
frpsObs, err := model.ListOpenFlareNodeObservationFrps(ctx, node.NodeID, time.Time{}, 1)
require.NoError(t, err)
require.Len(t, frpsObs, 1)
assert.Equal(t, 7, frpsObs[0].FrpsConnections)
assert.Equal(t, 3, frpsObs[0].FrpsProxyCount)
assert.Equal(t, 2, frpsObs[0].FrpsClientCount)
var decoded []ProxyStat
require.NoError(t, json.Unmarshal([]byte(frpsObs[0].FrpsProxies), &decoded))
require.Len(t, decoded, 1)
assert.Equal(t, "proxy-a", decoded[0].Name)
assert.Equal(t, "online", decoded[0].Status)
}
func TestHeartbeatRelayReconcilesFrpsUnhealthyEvent(t *testing.T) {
cleanup := setupRelayTestDB(t)
defer cleanup()
ctx := context.Background()
node := &model.OpenFlareNode{
NodeID: "node-relay-unhealthy",
Name: "relay-unhealthy",
AccessToken: "relay-token-unhealthy",
Status: "pending",
NodeType: "tunnel_relay",
RelayStatus: "healthy",
}
require.NoError(t, db.DB(ctx).Create(node).Error)
_, err := Heartbeat(ctx, node, HeartbeatPayload{
Version: "v0.1.0",
ExtVersion: "0.61.0",
RelayStatus: "unhealthy",
})
require.NoError(t, err)
events, err := model.ListOpenFlareHealthEvents(ctx, node.NodeID, true, 10)
require.NoError(t, err)
require.Len(t, events, 1)
assert.Equal(t, relayFrpsUnhealthyEventType, events[0].EventType)
assert.Equal(t, "active", events[0].Status)
_, err = Heartbeat(ctx, node, HeartbeatPayload{
Version: "v0.1.0",
ExtVersion: "0.61.0",
RelayStatus: "healthy",
})
require.NoError(t, err)
events, err = model.ListOpenFlareHealthEvents(ctx, node.NodeID, false, 10)
require.NoError(t, err)
require.Len(t, events, 1)
assert.Equal(t, "resolved", events[0].Status)
}
@@ -0,0 +1,49 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package relay
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 ctxRelayNodeKey = "relay_node"
// RelayAuth authenticates relay requests using X-Agent-Token and verifies tunnel_relay type.
func RelayAuth() gin.HandlerFunc {
return func(c *gin.Context) {
token := strings.TrimSpace(c.GetHeader("X-Agent-Token"))
node, err := authenticateAccessToken(c.Request.Context(), token)
if err != nil {
response.AbortUnauthorized(c, errAgentTokenInvalid)
return
}
if node.NodeType != "tunnel_relay" {
response.AbortForbidden(c, errRelayNodeTypeMismatch)
return
}
c.Set(ctxRelayNodeKey, node)
c.Next()
}
}
func authenticateAccessToken(ctx context.Context, token string) (*model.OpenFlareNode, error) {
if token == "" {
return nil, errors.New("missing agent token")
}
node, err := model.GetOpenFlareNodeByAccessToken(ctx, token)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("invalid agent token")
}
return nil, err
}
return node, nil
}
@@ -0,0 +1,109 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package relay
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 setupRelayMiddlewareTestDB(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 seedRelayNode(t *testing.T, nodeType, accessToken string) *model.OpenFlareNode {
t.Helper()
ctx := context.Background()
node := &model.OpenFlareNode{
NodeID: "relay-test-node",
Name: "relay-test",
Status: "pending",
NodeType: nodeType,
AccessToken: accessToken,
}
require.NoError(t, model.CreateOpenFlareNode(ctx, node))
return node
}
func TestRelayAuthMissingToken(t *testing.T) {
cleanup := setupRelayMiddlewareTestDB(t)
defer cleanup()
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.Use(response.ErrorHandlerMiddleware())
engine.GET("/relay/test", RelayAuth(), func(c *gin.Context) {
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/relay/test", nil)
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
}
func TestRelayAuthRejectsWrongNodeType(t *testing.T) {
cleanup := setupRelayMiddlewareTestDB(t)
defer cleanup()
seedRelayNode(t, "edge_node", "edge-token-relay")
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.Use(response.ErrorHandlerMiddleware())
engine.GET("/relay/test", RelayAuth(), func(c *gin.Context) {
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/relay/test", nil)
req.Header.Set("X-Agent-Token", "edge-token-relay")
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
assert.Equal(t, http.StatusForbidden, rec.Code)
}
func TestRelayAuthAcceptsTunnelRelay(t *testing.T) {
cleanup := setupRelayMiddlewareTestDB(t)
defer cleanup()
node := seedRelayNode(t, "tunnel_relay", "relay-token-valid")
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.GET("/relay/test", RelayAuth(), func(c *gin.Context) {
authNode, ok := c.Get(ctxRelayNodeKey)
require.True(t, ok)
assert.Equal(t, node.NodeID, authNode.(*model.OpenFlareNode).NodeID)
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/relay/test", nil)
req.Header.Set("X-Agent-Token", "relay-token-valid")
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
}
@@ -0,0 +1,69 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package relay
import (
"context"
"time"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/agent"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"go.uber.org/zap"
"gorm.io/gorm"
)
const relayFrpsUnhealthyEventType = "frps_unhealthy"
func reconcileRelayHealthEvents(ctx context.Context, nodeID string, relayStatus string, reportedAt time.Time) error {
if relayStatus == "unknown" {
return nil
}
managedTypes := map[string]struct{}{
relayFrpsUnhealthyEventType: {},
}
events := []agent.NodeHealthEvent{}
if relayStatus == "unhealthy" {
events = append(events, agent.NodeHealthEvent{
EventType: relayFrpsUnhealthyEventType,
Severity: "critical",
Message: "frps runtime is not healthy",
TriggeredAtUnix: reportedAt.Unix(),
Metadata: map[string]string{
"relay_status": relayStatus,
},
})
}
conn := db.DB(ctx)
if conn == nil {
return nil
}
return conn.Transaction(func(tx *gorm.DB) error {
return agent.ReconcileScopedNodeHealthEvents(tx, nodeID, events, reportedAt, managedTypes)
})
}
func persistRelayHeartbeatObservability(ctx context.Context, nodeID string, payload HeartbeatPayload, reportedAt time.Time) {
agent.PersistHeartbeatObservability(ctx, nodeID, agent.NodePayload{
Profile: payload.Profile,
Snapshot: payload.Snapshot,
HealthEvents: payload.HealthEvents,
}, reportedAt)
conn := db.DB(ctx)
if conn == nil {
return
}
frpsObs := &model.OpenFlareNodeObservationFrps{
NodeID: nodeID,
CapturedAt: reportedAt,
FrpsConnections: payload.FrpsConnCount,
FrpsProxyCount: payload.FrpsProxyCount,
FrpsClientCount: payload.FrpsClientCount,
FrpsProxies: agent.MarshalJSON(payload.FrpsProxies),
}
if err := conn.Create(frpsObs).Error; err != nil {
zap.L().Error("persist relay frps observation failed", zap.String("node_id", nodeID), zap.Error(err))
}
}
+66
View File
@@ -0,0 +1,66 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package relay
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 /relay/heartbeat.
// @Summary 上报 Relay 心跳
// @Description Relay 节点定期上报运行状态与 frps 观测数据,返回运行时配置
// @Tags openflare-relay
// @Accept json
// @Produce json
// @Security AgentTokenAuth
// @Param body body relay.HeartbeatPayload true "心跳载荷"
// @Success 200 {object} response.Any{data=relay.HeartbeatResponse} "心跳响应"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "Agent Token 无效"
// @Failure 403 {object} response.Any "节点类型不匹配"
// @Router /api/v1/relay/heartbeat [post]
func PostHeartbeat(c *gin.Context) {
var payload HeartbeatPayload
if !apiutil.BindJSON(c, &payload) {
return
}
payload.IP = resolveReportedNodeIP(payload.IP, c.Request.RemoteAddr)
authNode, ok := c.Get(ctxRelayNodeKey)
if !ok {
response.AbortUnauthorized(c, errAgentTokenInvalid)
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))
}
// GetWebSocket handles GET /relay/ws.
// @Summary 升级 Relay WebSocket 连接
// @Description 将已认证的 Relay 连接升级为 WebSocket 长连接,用于配置推送
// @Tags openflare-relay
// @Security AgentTokenAuth
// @Failure 401 {object} response.Any "Agent Token 无效"
// @Failure 403 {object} response.Any "节点类型不匹配"
// @Router /api/v1/relay/ws [get]
func GetWebSocket(c *gin.Context) {
authNode, ok := c.Get(ctxRelayNodeKey)
if !ok {
response.AbortUnauthorized(c, errAgentTokenInvalid)
return
}
node := authNode.(*model.OpenFlareNode)
ofws.ServeRelay(c, node.NodeID)
}