// Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 package node import ( "context" "errors" "fmt" "strings" "time" cf "Wavelet/openflare/plugins/server/domain/cloudflare" ofws "Wavelet/openflare/plugins/server/domain/fleet/websocket" "Wavelet/openflare/plugins/server/domain/observability" "Wavelet/openflare/plugins/server/kernel/model" "Wavelet/openflare/plugins/server/kernel/repository" "Wavelet/pkg/logger" "gorm.io/gorm" ) const ( defaultRelayBindPort = 7000 defaultRelayVhostHTTPPort = 8080 ) // getAgentUpdateRepo 从 SystemConfig 读取 Agent 更新仓库配置 func getAgentUpdateRepo(ctx context.Context) string { config, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyAgentUpdateRepo) if err != nil || strings.TrimSpace(config.Value) == "" { return "Rain-kl/OpenFlare" // 默认值 } return strings.TrimSpace(config.Value) } // Input is the create/update node payload. type Input struct { Name string `json:"name"` IP string `json:"ip"` IPManualOverride *bool `json:"ip_manual_override"` AutoUpdateEnabled bool `json:"auto_update_enabled"` GeoName string `json:"geo_name"` GeoLatitude *float64 `json:"geo_latitude"` GeoLongitude *float64 `json:"geo_longitude"` GeoManualOverride bool `json:"geo_manual_override"` NodeType string `json:"node_type"` RelayBindPort int `json:"relay_bind_port"` RelayVhostHTTPPort int `json:"relay_vhost_http_port"` RelayAgentAccessAddr string `json:"relay_agent_access_addr"` RelayClientAccessAddr string `json:"relay_client_access_addr"` RelayClientProxyURL string `json:"relay_client_proxy_url"` RelayWebServerEnabled bool `json:"relay_web_server_enabled"` } // AgentUpdateInput requests an agent self-update on a node. type AgentUpdateInput struct { Channel string `json:"channel"` TagName string `json:"tag_name"` } // AgentReleaseInfo describes the latest agent release for a node. type AgentReleaseInfo struct { TagName string `json:"tag_name"` Body string `json:"body"` HTMLURL string `json:"html_url"` PublishedAt string `json:"published_at"` CurrentVersion string `json:"current_version"` HasUpdate bool `json:"has_update"` Channel string `json:"channel"` Prerelease bool `json:"prerelease"` UpdateRequested bool `json:"update_requested"` RequestedChannel string `json:"requested_channel"` RequestedTag string `json:"requested_tag"` } // BootstrapView exposes the global discovery token. type BootstrapView struct { DiscoveryToken string `json:"discovery_token"` } // View is the admin-facing node representation. type View struct { ID uint `json:"id"` NodeID string `json:"node_id"` Name string `json:"name"` IP string `json:"ip"` IPManualOverride bool `json:"ip_manual_override"` GeoName string `json:"geo_name"` GeoLatitude *float64 `json:"geo_latitude"` GeoLongitude *float64 `json:"geo_longitude"` GeoManualOverride bool `json:"geo_manual_override"` AccessToken string `json:"access_token"` AutoUpdateEnabled bool `json:"auto_update_enabled"` UpdateRequested bool `json:"update_requested"` UpdateChannel string `json:"update_channel"` UpdateTag string `json:"update_tag"` RestartOpenrestyRequested bool `json:"restart_openresty_requested"` Version string `json:"version"` ExtVersion string `json:"ext_version"` OpenrestyStatus string `json:"openresty_status"` OpenrestyMessage string `json:"openresty_message"` Status string `json:"status"` CurrentVersion string `json:"current_version"` LastSeenAt any `json:"last_seen_at"` LastError string `json:"last_error"` LatestApplyResult string `json:"latest_apply_result"` LatestApplyMessage string `json:"latest_apply_message"` LatestApplyChecksum string `json:"latest_apply_checksum"` LatestMainConfigChecksum string `json:"latest_main_config_checksum"` LatestRouteConfigChecksum string `json:"latest_route_config_checksum"` LatestSupportFileCount int `json:"latest_support_file_count"` LatestApplyAt *time.Time `json:"latest_apply_at"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` NodeType string `json:"node_type"` RelayBindPort int `json:"relay_bind_port"` RelayVhostHTTPPort int `json:"relay_vhost_http_port"` RelayAgentAccessAddr string `json:"relay_agent_access_addr"` RelayClientAccessAddr string `json:"relay_client_access_addr"` RelayClientProxyURL string `json:"relay_client_proxy_url"` RelayStatus string `json:"relay_status"` RelayWebServerEnabled bool `json:"relay_web_server_enabled"` } // ObservabilityQuery filters node observability data. type ObservabilityQuery struct { Hours int `json:"hours"` Limit int `json:"limit"` } // ObservabilityView is the node observability API response. type ObservabilityView = observability.NodeView // HealthEventCleanupResult reports health event cleanup outcome. type HealthEventCleanupResult = observability.HealthEventCleanupResult // ListNodes returns all node views with latest apply log metadata. func ListNodes(ctx context.Context) ([]*View, error) { nodes, err := repository.ListOpenFlareNodes(ctx) if err != nil { return nil, err } nodeIDs := make([]string, 0, len(nodes)) for _, node := range nodes { nodeIDs = append(nodeIDs, node.NodeID) } latestLogs, err := repository.GetLatestOpenFlareApplyLogsByNodeIDs(ctx, nodeIDs) if err != nil { return nil, err } views := make([]*View, 0, len(nodes)) for _, node := range nodes { view := buildNodeView(&node) view.Status = computeNodeStatus(&node) if log, ok := latestLogs[node.NodeID]; ok { view.LatestApplyResult = log.Result view.LatestApplyMessage = log.Message view.LatestApplyChecksum = log.Checksum view.LatestMainConfigChecksum = log.MainConfigChecksum view.LatestRouteConfigChecksum = log.RouteConfigChecksum view.LatestSupportFileCount = log.SupportFileCount view.LatestApplyAt = &log.CreatedAt } views = append(views, view) } return views, nil } // CreateNode creates a reserved node with generated node_id and access_token. func CreateNode(ctx context.Context, input Input) (*View, error) { name, ip, geoName, geoLatitude, geoLongitude, geoManualOverride, err := normalizeNodeInput(input) if err != nil { return nil, err } if name == "" { return nil, errors.New(errNodeNameRequired) } ipManualOverride := resolveNodeIPManualOverride(input, nil, ip) node := &model.OpenFlareNode{ Name: name, IP: ip, IPManualOverride: ipManualOverride, GeoName: geoName, GeoLatitude: geoLatitude, GeoLongitude: geoLongitude, GeoManualOverride: geoManualOverride, Version: "", ExtVersion: "", Status: nodeStatusPending, AutoUpdateEnabled: input.AutoUpdateEnabled, NodeType: normalizeNodeType(input.NodeType), CapabilitiesJSON: "[]", } node.NodeID, err = newServerNodeID() if err != nil { return nil, err } node.AccessToken, err = newRandomToken() if err != nil { return nil, err } if node.NodeType == "tunnel_relay" { node.RelayBindPort = normalizeRelayPort(input.RelayBindPort, defaultRelayBindPort) node.RelayVhostHTTPPort = normalizeRelayPort(input.RelayVhostHTTPPort, defaultRelayVhostHTTPPort) node.RelayAuthToken, err = newRandomToken() if err != nil { return nil, err } node.RelayAgentAccessAddr = strings.TrimSpace(input.RelayAgentAccessAddr) node.RelayClientAccessAddr = strings.TrimSpace(input.RelayClientAccessAddr) node.RelayClientProxyURL = strings.TrimSpace(input.RelayClientProxyURL) node.RelayWebServerEnabled = input.RelayWebServerEnabled } if err = repository.CreateOpenFlareNode(ctx, node); err != nil { if isUniqueConstraintError(err) { return nil, errors.New(errNodeIDConflict) } return nil, err } return buildNodeView(node), nil } // UpdateNode updates an existing node. func UpdateNode(ctx context.Context, id uint, input Input) (*View, error) { name, ip, geoName, geoLatitude, geoLongitude, geoManualOverride, err := normalizeNodeInput(input) if err != nil { return nil, err } if name == "" { return nil, errors.New(errNodeNameRequired) } node, err := repository.GetOpenFlareNodeByID(ctx, id) if err != nil { return nil, err } ipManualOverride := resolveNodeIPManualOverride(input, node, ip) previousIP := node.IP node.Name = name node.IP = ip node.IPManualOverride = ipManualOverride node.GeoName = geoName node.GeoLatitude = geoLatitude node.GeoLongitude = geoLongitude node.GeoManualOverride = geoManualOverride node.AutoUpdateEnabled = input.AutoUpdateEnabled if node.NodeType == "tunnel_relay" { node.RelayAgentAccessAddr = strings.TrimSpace(input.RelayAgentAccessAddr) node.RelayClientAccessAddr = strings.TrimSpace(input.RelayClientAccessAddr) node.RelayClientProxyURL = strings.TrimSpace(input.RelayClientProxyURL) node.RelayWebServerEnabled = input.RelayWebServerEnabled if input.RelayBindPort > 0 { node.RelayBindPort = input.RelayBindPort } if input.RelayVhostHTTPPort > 0 { node.RelayVhostHTTPPort = input.RelayVhostHTTPPort } } if err = repository.SaveOpenFlareNode(ctx, node); err != nil { return nil, err } if strings.TrimSpace(previousIP) != strings.TrimSpace(node.IP) { if _, dispatchErr := cf.DispatchNodeSync(ctx, node.ID, "cloudflare_node_ip_update"); dispatchErr != nil { logger.ErrorF(ctx, "[Cloudflare] enqueue node sync failed: node_id=%d error=%v", node.ID, dispatchErr) } } return buildNodeView(node), nil } // DeleteNode removes a node by id. func DeleteNode(ctx context.Context, id uint) error { if _, err := repository.GetOpenFlareNodeByID(ctx, id); err != nil { return err } return repository.DeleteOpenFlareNode(ctx, id) } // GetBootstrapToken returns the global discovery token, creating one if missing. func GetBootstrapToken(ctx context.Context) (*BootstrapView, error) { token, err := ensureGlobalDiscoveryToken(ctx) if err != nil { return nil, err } return &BootstrapView{DiscoveryToken: token}, nil } // RotateBootstrapToken rotates the global discovery token. func RotateBootstrapToken(ctx context.Context) (*BootstrapView, error) { token, err := newRandomToken() if err != nil { return nil, err } if err = repository.SaveOrUpdateSystemConfig(ctx, model.ConfigKeyAgentDiscoveryToken, token); err != nil { return nil, err } return &BootstrapView{DiscoveryToken: token}, nil } // GetAgentRelease checks the latest agent release for a node. func GetAgentRelease(ctx context.Context, id uint, channel string) (*AgentReleaseInfo, error) { node, err := repository.GetOpenFlareNodeByID(ctx, id) if err != nil { return nil, err } release, err := fetchLatestGitHubRelease(ctx, getAgentUpdateRepo(ctx), normalizeReleaseChannel(channel)) if err != nil { return nil, err } return buildNodeAgentReleaseView(node, release, normalizeReleaseChannel(channel)), nil } // RequestAgentUpdate marks a node for manual agent update. func RequestAgentUpdate(ctx context.Context, id uint, input AgentUpdateInput) (*View, error) { node, err := repository.GetOpenFlareNodeByID(ctx, id) if err != nil { return nil, err } channel := normalizeReleaseChannel(input.Channel) tagName := strings.TrimSpace(input.TagName) if tagName != "" { release, releaseErr := fetchGitHubReleaseByTag(ctx, getAgentUpdateRepo(ctx), tagName) if releaseErr != nil { return nil, releaseErr } if channel == releaseChannelPreview && !release.Prerelease { return nil, errors.New(errAgentPreviewTagInvalid) } if channel == releaseChannelStable && release.Prerelease { return nil, errors.New(errAgentStableTagInvalid) } } node.UpdateRequested = true node.UpdateChannel = channel.String() node.UpdateTag = tagName if err = repository.UpdateOpenFlareNodeFields(ctx, node, "update_requested", "update_channel", "update_tag"); err != nil { return nil, err } return buildNodeView(node), nil } // RequestOpenrestyRestart marks a node for openresty restart. func RequestOpenrestyRestart(ctx context.Context, id uint) (*View, error) { node, err := repository.GetOpenFlareNodeByID(ctx, id) if err != nil { return nil, err } node.RestartOpenrestyRequested = true if err = repository.UpdateOpenFlareNodeFields(ctx, node, "restart_openresty_requested"); err != nil { return nil, err } return buildNodeView(node), nil } // RequestForceSync pushes force_sync_config to a connected agent websocket. func RequestForceSync(ctx context.Context, id uint) (*View, error) { node, err := repository.GetOpenFlareNodeByID(ctx, id) if err != nil { return nil, err } activeConfig, err := repository.GetActiveConfigVersion(ctx) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fmt.Errorf("无法获取当前激活的配置版本:%s", errNoActiveConfigVersion) } return nil, fmt.Errorf("无法获取当前激活的配置版本:%w", err) } if !ofws.SendForceSyncConfig(node.NodeID, forceSyncConfigPayload{ Version: activeConfig.Version, Checksum: activeConfig.Checksum, }) { return nil, errors.New(errNodeForceSyncFailed) } return buildNodeView(node), nil } // GetObservability returns observability details for a node. func GetObservability(ctx context.Context, id uint, query ObservabilityQuery) (*ObservabilityView, error) { return observability.GetNodeObservability(ctx, id, observability.NodeQuery{ Hours: query.Hours, Limit: query.Limit, }) } // CleanupHealthEvents removes all health events for a node. func CleanupHealthEvents(ctx context.Context, id uint) (*HealthEventCleanupResult, error) { return observability.CleanupHealthEvents(ctx, id) } func ensureGlobalDiscoveryToken(ctx context.Context) (string, error) { // 从 SystemConfig 读取 Agent 发现令牌 config, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyAgentDiscoveryToken) if err == nil && strings.TrimSpace(config.Value) != "" { return strings.TrimSpace(config.Value), nil } // 如果不存在,生成新令牌并保存 token, err := newRandomToken() if err != nil { return "", err } // 更新到 SystemConfig if err = repository.SaveOrUpdateSystemConfig(ctx, model.ConfigKeyAgentDiscoveryToken, token); err != nil { return "", err } return token, nil } type forceSyncConfigPayload struct { Version string `json:"version"` Checksum string `json:"checksum"` } // ValidateDiscoveryToken validates the global discovery token. func ValidateDiscoveryToken(ctx context.Context, token string) error { token = strings.TrimSpace(token) if token == "" { return errors.New("缺少 Discovery Token") } discoveryToken, err := ensureGlobalDiscoveryToken(ctx) if err != nil { return err } if !tokenEqual(token, discoveryToken) { return errors.New("discovery Token 无效") // error 消息首字母小写 } return nil }