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
+21
View File
@@ -0,0 +1,21 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package node
const (
errNodeNameRequired = "节点名不能为空"
errNodeIPTooLong = "节点 IP 不能超过 64 个字符"
errNodeIPInvalid = "节点 IP 格式无效"
errNodeIPManualRequired = "锁定节点 IP 时必须填写节点 IP"
errNodeGeoNameTooLong = "节点位置名不能超过 128 个字符"
errNodeGeoCoordinateMismatch = "地图坐标必须同时填写纬度和经度"
errNodeGeoLatitudeInvalid = "纬度必须在 -90 到 90 之间"
errNodeGeoLongitudeInvalid = "经度必须在 -180 到 180 之间"
errNodeIDConflict = "节点标识生成冲突,请重试"
errNodeNotFound = "节点不存在"
errNodeForceSyncFailed = "节点不在线或通过 WebSocket 发送同步指令失败"
errNoActiveConfigVersion = "当前没有激活版本"
errAgentPreviewTagInvalid = "指定版本不是 preview 发布"
errAgentStableTagInvalid = "正式版更新不能选择 preview 发布"
)
+473
View File
@@ -0,0 +1,473 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package node
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strconv"
"strings"
"time"
ofws "github.com/Rain-kl/Wavelet/internal/apps/openflare/websocket"
"github.com/Rain-kl/Wavelet/internal/model"
)
const (
nodeStatusOnline = "online"
nodeStatusOffline = "offline"
nodeStatusPending = "pending"
openrestyStatusHealthy = "healthy"
openrestyStatusUnhealthy = "unhealthy"
openrestyStatusUnknown = "unknown"
githubReleasesAPIBase = "https://api.github.com/repos/%s/releases"
)
type releaseChannel string
const (
releaseChannelStable releaseChannel = "stable"
releaseChannelPreview releaseChannel = "preview"
)
var releaseHTTPClient = &http.Client{Timeout: 30 * time.Second}
type githubReleaseResponse struct {
TagName string `json:"tag_name"`
Body string `json:"body"`
HTMLURL string `json:"html_url"`
PublishedAt string `json:"published_at"`
Prerelease bool `json:"prerelease"`
Draft bool `json:"draft"`
}
func newRandomToken() (string, error) {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func newServerNodeID() (string, error) {
token, err := newRandomToken()
if err != nil {
return "", err
}
return "node-" + token, nil
}
func normalizeNodeType(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "tunnel_relay":
return "tunnel_relay"
case "tunnel_client":
return "tunnel_client"
default:
return "edge_node"
}
}
func normalizeRelayPort(port int, defaultPort int) int {
if port <= 0 || port > 65535 {
return defaultPort
}
return port
}
func normalizeReleaseChannel(channel string) releaseChannel {
switch strings.ToLower(strings.TrimSpace(channel)) {
case string(releaseChannelPreview):
return releaseChannelPreview
default:
return releaseChannelStable
}
}
func (channel releaseChannel) String() string {
if channel == releaseChannelPreview {
return string(releaseChannelPreview)
}
return string(releaseChannelStable)
}
func normalizeOpenrestyStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case openrestyStatusHealthy:
return openrestyStatusHealthy
case openrestyStatusUnhealthy:
return openrestyStatusUnhealthy
default:
return openrestyStatusUnknown
}
}
func cloneCoordinate(value *float64) *float64 {
if value == nil {
return nil
}
cloned := *value
return &cloned
}
func resolveNodeIPManualOverride(input Input, existing *model.OpenFlareNode, normalizedIP string) bool {
if input.IPManualOverride != nil {
return *input.IPManualOverride
}
if existing == nil {
return strings.TrimSpace(normalizedIP) != ""
}
if existing.IPManualOverride {
return true
}
return strings.TrimSpace(normalizedIP) != "" && strings.TrimSpace(normalizedIP) != strings.TrimSpace(existing.IP)
}
func normalizeNodeInput(input Input) (string, string, string, *float64, *float64, bool, error) {
name := strings.TrimSpace(input.Name)
ip := strings.TrimSpace(input.IP)
geoName := strings.TrimSpace(input.GeoName)
manualOverride := input.GeoManualOverride || geoName != "" || input.GeoLatitude != nil || input.GeoLongitude != nil
if len(ip) > 64 {
return "", "", "", nil, nil, false, fmt.Errorf("%s", errNodeIPTooLong)
}
if ip != "" && net.ParseIP(ip) == nil {
return "", "", "", nil, nil, false, fmt.Errorf("%s", errNodeIPInvalid)
}
if input.IPManualOverride != nil && *input.IPManualOverride && ip == "" {
return "", "", "", nil, nil, false, fmt.Errorf("%s", errNodeIPManualRequired)
}
if len(geoName) > 128 {
return "", "", "", nil, nil, false, fmt.Errorf("%s", errNodeGeoNameTooLong)
}
geoLatitude := cloneCoordinate(input.GeoLatitude)
geoLongitude := cloneCoordinate(input.GeoLongitude)
if (geoLatitude == nil) != (geoLongitude == nil) {
return "", "", "", nil, nil, false, fmt.Errorf("%s", errNodeGeoCoordinateMismatch)
}
if geoLatitude != nil && (*geoLatitude < -90 || *geoLatitude > 90) {
return "", "", "", nil, nil, false, fmt.Errorf("%s", errNodeGeoLatitudeInvalid)
}
if geoLongitude != nil && (*geoLongitude < -180 || *geoLongitude > 180) {
return "", "", "", nil, nil, false, fmt.Errorf("%s", errNodeGeoLongitudeInvalid)
}
if !manualOverride {
return name, ip, "", nil, nil, false, nil
}
if geoLatitude == nil && geoLongitude == nil && geoName == "" {
return name, ip, "", nil, nil, false, nil
}
return name, ip, geoName, geoLatitude, geoLongitude, true, nil
}
func computeNodeStatus(node *model.OpenFlareNode) string {
if node == nil {
return nodeStatusOffline
}
if node.LastSeenAt == nil || node.LastSeenAt.IsZero() {
return nodeStatusPending
}
if time.Since(*node.LastSeenAt) > model.NodeOfflineThreshold {
return nodeStatusOffline
}
return nodeStatusOnline
}
func nodeViewLastSeenAt(node *model.OpenFlareNode) any {
if node == nil {
return time.Time{}
}
nodeType := strings.TrimSpace(node.NodeType)
if nodeType == "" {
nodeType = "edge_node"
}
if nodeType == "tunnel_relay" && ofws.IsRelayConnected(node.NodeID) {
return ofws.RelayWSConnectedLastSeenValue
}
if nodeType == "tunnel_client" && ofws.IsFlaredConnected(node.NodeID) {
return ofws.FlaredWSConnectedLastSeenValue
}
if ofws.IsAgentConnected(node.NodeID) {
return ofws.AgentWSConnectedLastSeenValue
}
if node.LastSeenAt == nil {
return time.Time{}
}
return *node.LastSeenAt
}
func buildNodeView(node *model.OpenFlareNode) *View {
if node == nil {
return nil
}
status := computeNodeStatus(node)
view := &View{
ID: node.ID,
NodeID: node.NodeID,
Name: node.Name,
IP: node.IP,
IPManualOverride: node.IPManualOverride,
GeoName: strings.TrimSpace(node.GeoName),
GeoLatitude: node.GeoLatitude,
GeoLongitude: node.GeoLongitude,
GeoManualOverride: node.GeoManualOverride,
AccessToken: node.AccessToken,
UpdateChannel: strings.TrimSpace(node.UpdateChannel),
UpdateTag: strings.TrimSpace(node.UpdateTag),
RestartOpenrestyRequested: node.RestartOpenrestyRequested,
Version: node.Version,
ExtVersion: node.ExtVersion,
OpenrestyStatus: normalizeOpenrestyStatus(node.OpenrestyStatus),
OpenrestyMessage: strings.TrimSpace(node.OpenrestyMessage),
Status: status,
CurrentVersion: node.CurrentVersion,
LastSeenAt: nodeViewLastSeenAt(node),
LastError: node.LastError,
CreatedAt: node.CreatedAt,
UpdatedAt: node.UpdatedAt,
AutoUpdateEnabled: node.AutoUpdateEnabled,
UpdateRequested: node.UpdateRequested,
NodeType: node.NodeType,
RelayBindPort: node.RelayBindPort,
RelayVhostHTTPPort: node.RelayVhostHTTPPort,
RelayAgentAccessAddr: node.RelayAgentAccessAddr,
RelayClientAccessAddr: node.RelayClientAccessAddr,
RelayClientProxyURL: node.RelayClientProxyURL,
RelayStatus: node.RelayStatus,
RelayWebServerEnabled: node.RelayWebServerEnabled,
}
if view.UpdateChannel == "" {
view.UpdateChannel = releaseChannelStable.String()
}
if view.NodeType == "" {
view.NodeType = "edge_node"
}
return view
}
func buildNodeAgentReleaseView(node *model.OpenFlareNode, release *githubReleaseResponse, channel releaseChannel) *AgentReleaseInfo {
currentVersion := strings.TrimSpace(node.Version)
view := &AgentReleaseInfo{
CurrentVersion: currentVersion,
Channel: channel.String(),
UpdateRequested: node.UpdateRequested,
RequestedChannel: normalizeReleaseChannel(node.UpdateChannel).String(),
RequestedTag: strings.TrimSpace(node.UpdateTag),
}
if release == nil {
return view
}
view.TagName = release.TagName
view.Body = release.Body
view.HTMLURL = release.HTMLURL
view.PublishedAt = release.PublishedAt
view.Prerelease = release.Prerelease
view.HasUpdate = isVersionNewer(currentVersion, release.TagName)
return view
}
func isVersionNewer(current string, latest string) bool {
return compareVersions(current, latest) < 0
}
func compareVersions(local, remote string) int {
left := parseVersionInfo(local)
right := parseVersionInfo(remote)
if left.isDev {
if right.valid {
return -1
}
return 0
}
if !left.valid || !right.valid {
return 0
}
maxLen := len(left.numbers)
if len(right.numbers) > maxLen {
maxLen = len(right.numbers)
}
for index := 0; index < maxLen; index++ {
leftValue := 0
rightValue := 0
if index < len(left.numbers) {
leftValue = left.numbers[index]
}
if index < len(right.numbers) {
rightValue = right.numbers[index]
}
if leftValue < rightValue {
return -1
}
if leftValue > rightValue {
return 1
}
}
return 0
}
type versionInfo struct {
valid bool
isDev bool
numbers []int
}
func parseVersionInfo(version string) versionInfo {
normalized := strings.TrimSpace(strings.TrimPrefix(version, "v"))
if normalized == "" || normalized == "dev" {
return versionInfo{isDev: strings.EqualFold(normalized, "dev")}
}
base := normalized
if separator := strings.IndexRune(normalized, '-'); separator >= 0 {
base = normalized[:separator]
}
segments := strings.Split(base, ".")
parts := make([]int, 0, len(segments))
for _, segment := range segments {
segment = strings.TrimSpace(segment)
if segment == "" {
parts = append(parts, 0)
continue
}
numeric := strings.Builder{}
for _, r := range segment {
if r < '0' || r > '9' {
break
}
numeric.WriteRune(r)
}
if numeric.Len() == 0 {
parts = append(parts, 0)
continue
}
value, err := strconv.Atoi(numeric.String())
if err != nil {
return versionInfo{}
}
parts = append(parts, value)
}
return versionInfo{valid: len(parts) > 0, numbers: parts}
}
func fetchLatestGitHubRelease(ctx context.Context, repo string, channel releaseChannel) (*githubReleaseResponse, error) {
switch normalizeReleaseChannel(string(channel)) {
case releaseChannelPreview:
return fetchLatestPreviewGitHubRelease(ctx, repo)
default:
return fetchLatestStableGitHubRelease(ctx, repo)
}
}
func fetchLatestStableGitHubRelease(ctx context.Context, repo string) (*githubReleaseResponse, error) {
url := fmt.Sprintf(githubReleasesAPIBase+"/latest", strings.TrimSpace(repo))
req, err := newGitHubReleaseRequest(ctx, url)
if err != nil {
return nil, fmt.Errorf("创建更新请求失败")
}
resp, err := releaseHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("获取最新版本失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub 返回异常状态: %s", resp.Status)
}
return decodeGitHubRelease(resp.Body)
}
func fetchLatestPreviewGitHubRelease(ctx context.Context, repo string) (*githubReleaseResponse, error) {
url := fmt.Sprintf(githubReleasesAPIBase+"?per_page=20", strings.TrimSpace(repo))
req, err := newGitHubReleaseRequest(ctx, url)
if err != nil {
return nil, fmt.Errorf("创建更新请求失败")
}
resp, err := releaseHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("获取 preview 版本失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub 返回异常状态: %s", resp.Status)
}
var releases []githubReleaseResponse
if err = json.NewDecoder(resp.Body).Decode(&releases); err != nil {
return nil, fmt.Errorf("解析 preview 版本信息失败")
}
for _, release := range releases {
if release.Draft || !release.Prerelease {
continue
}
releaseCopy := release
return &releaseCopy, nil
}
return nil, fmt.Errorf("当前没有可用的 preview 发布")
}
func fetchGitHubReleaseByTag(ctx context.Context, repo string, tag string) (*githubReleaseResponse, error) {
tag = strings.TrimSpace(tag)
if tag == "" {
return nil, fmt.Errorf("缺少发布版本号")
}
url := fmt.Sprintf(githubReleasesAPIBase+"/tags/%s", strings.TrimSpace(repo), tag)
req, err := newGitHubReleaseRequest(ctx, url)
if err != nil {
return nil, fmt.Errorf("创建更新请求失败")
}
resp, err := releaseHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("获取指定版本失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("未找到指定版本: %s", tag)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub 返回异常状态: %s", resp.Status)
}
return decodeGitHubRelease(resp.Body)
}
func newGitHubReleaseRequest(ctx context.Context, url string) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "OpenFlare-Server")
return req, nil
}
func decodeGitHubRelease(reader io.Reader) (*githubReleaseResponse, error) {
var release githubReleaseResponse
if err := json.NewDecoder(reader).Decode(&release); err != nil {
return nil, fmt.Errorf("解析版本信息失败")
}
return &release, nil
}
func isUniqueConstraintError(err error) bool {
if err == nil {
return false
}
return strings.Contains(strings.ToLower(err.Error()), "unique")
}
func setReleaseHTTPClientForTest(client *http.Client) *http.Client {
previous := releaseHTTPClient
if client == nil {
releaseHTTPClient = &http.Client{Timeout: 30 * time.Second}
} else {
releaseHTTPClient = client
}
return previous
}
+405
View File
@@ -0,0 +1,405 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package node
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/observability"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/option"
ofws "github.com/Rain-kl/Wavelet/internal/apps/openflare/websocket"
"github.com/Rain-kl/Wavelet/internal/model"
"gorm.io/gorm"
)
// 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 := model.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 := model.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, 7000)
node.RelayVhostHTTPPort = normalizeRelayPort(input.RelayVhostHTTPPort, 8080)
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 = model.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 := model.GetOpenFlareNodeByID(ctx, id)
if err != nil {
return nil, err
}
ipManualOverride := resolveNodeIPManualOverride(input, 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 = model.SaveOpenFlareNode(ctx, node); err != nil {
return nil, err
}
return buildNodeView(node), nil
}
// DeleteNode removes a node by id.
func DeleteNode(ctx context.Context, id uint) error {
if _, err := model.GetOpenFlareNodeByID(ctx, id); err != nil {
return err
}
return model.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 = model.UpdateOpenFlareOption(ctx, "AgentDiscoveryToken", 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 := model.GetOpenFlareNodeByID(ctx, id)
if err != nil {
return nil, err
}
release, err := fetchLatestGitHubRelease(ctx, model.AgentUpdateRepo, 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 := model.GetOpenFlareNodeByID(ctx, id)
if err != nil {
return nil, err
}
channel := normalizeReleaseChannel(input.Channel)
tagName := strings.TrimSpace(input.TagName)
if tagName != "" {
release, releaseErr := fetchGitHubReleaseByTag(ctx, model.AgentUpdateRepo, 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 = model.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 := model.GetOpenFlareNodeByID(ctx, id)
if err != nil {
return nil, err
}
node.RestartOpenrestyRequested = true
if err = model.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 := model.GetOpenFlareNodeByID(ctx, id)
if err != nil {
return nil, err
}
activeConfig, err := model.GetActiveConfigVersion(ctx)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("无法获取当前激活的配置版本:%s", errNoActiveConfigVersion)
}
return nil, fmt.Errorf("无法获取当前激活的配置版本:%v", 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) {
if err := option.EnsureInitialized(ctx); err != nil {
return "", err
}
model.OptionMapRWMutex.RLock()
token := strings.TrimSpace(model.AgentDiscoveryToken)
model.OptionMapRWMutex.RUnlock()
if token != "" {
return token, nil
}
token, err := newRandomToken()
if err != nil {
return "", err
}
if err = model.UpdateOpenFlareOption(ctx, "AgentDiscoveryToken", 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 fmt.Errorf("缺少 Discovery Token")
}
discoveryToken, err := ensureGlobalDiscoveryToken(ctx)
if err != nil {
return err
}
if token != discoveryToken {
return fmt.Errorf("Discovery Token 无效")
}
return nil
}
+325
View File
@@ -0,0 +1,325 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package node
import (
"context"
"io"
"net/http"
"strings"
"testing"
"time"
"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 setupNodeTestDB(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.OpenFlareApplyLog{},
))
db.SetDB(sqliteDB)
option.ResetInitializationForTest()
resetAccessLogStore := model.SetAccessLogStoreForTest(model.NewMemoryAccessLogStore())
return func() {
resetAccessLogStore()
db.SetDB(nil)
option.ResetInitializationForTest()
}
}
func TestCreateEdgeNode(t *testing.T) {
cleanup := setupNodeTestDB(t)
defer cleanup()
ctx := context.Background()
view, err := CreateNode(ctx, Input{
Name: "edge-1",
IP: "10.0.0.1",
AutoUpdateEnabled: true,
})
require.NoError(t, err)
assert.NotZero(t, view.ID)
assert.True(t, strings.HasPrefix(view.NodeID, "node-"))
assert.Len(t, view.AccessToken, 32)
assert.Equal(t, "edge_node", view.NodeType)
assert.Equal(t, nodeStatusPending, view.Status)
assert.True(t, view.AutoUpdateEnabled)
}
func TestCreateTunnelRelayNode(t *testing.T) {
cleanup := setupNodeTestDB(t)
defer cleanup()
ctx := context.Background()
view, err := CreateNode(ctx, Input{
Name: "relay-1",
NodeType: "tunnel_relay",
})
require.NoError(t, err)
assert.Equal(t, "tunnel_relay", view.NodeType)
assert.Equal(t, 7000, view.RelayBindPort)
assert.Equal(t, 8080, view.RelayVhostHTTPPort)
stored, err := model.GetOpenFlareNodeByID(ctx, view.ID)
require.NoError(t, err)
assert.NotEmpty(t, stored.RelayAuthToken)
}
func TestCreateTunnelClientNode(t *testing.T) {
cleanup := setupNodeTestDB(t)
defer cleanup()
ctx := context.Background()
view, err := CreateNode(ctx, Input{
Name: "client-1",
NodeType: "tunnel_client",
})
require.NoError(t, err)
assert.Equal(t, "tunnel_client", view.NodeType)
}
func TestCreateNodeRequiresName(t *testing.T) {
cleanup := setupNodeTestDB(t)
defer cleanup()
ctx := context.Background()
_, err := CreateNode(ctx, Input{IP: "10.0.0.2"})
require.Error(t, err)
assert.Equal(t, errNodeNameRequired, err.Error())
}
func TestUpdateNode(t *testing.T) {
cleanup := setupNodeTestDB(t)
defer cleanup()
ctx := context.Background()
created, err := CreateNode(ctx, Input{Name: "edge-update"})
require.NoError(t, err)
updated, err := UpdateNode(ctx, created.ID, Input{
Name: "edge-updated",
IP: "192.168.1.10",
AutoUpdateEnabled: true,
})
require.NoError(t, err)
assert.Equal(t, "edge-updated", updated.Name)
assert.Equal(t, "192.168.1.10", updated.IP)
assert.True(t, updated.AutoUpdateEnabled)
}
func TestDeleteNode(t *testing.T) {
cleanup := setupNodeTestDB(t)
defer cleanup()
ctx := context.Background()
created, err := CreateNode(ctx, Input{Name: "edge-delete"})
require.NoError(t, err)
require.NoError(t, DeleteNode(ctx, created.ID))
_, err = model.GetOpenFlareNodeByID(ctx, created.ID)
require.Error(t, err)
assert.ErrorIs(t, err, gorm.ErrRecordNotFound)
}
func TestListNodesWithApplyLogMetadata(t *testing.T) {
cleanup := setupNodeTestDB(t)
defer cleanup()
ctx := context.Background()
created, err := CreateNode(ctx, Input{Name: "edge-list"})
require.NoError(t, err)
applyAt := time.Now().UTC().Truncate(time.Second)
require.NoError(t, db.DB(ctx).Create(&model.OpenFlareApplyLog{
NodeID: created.NodeID,
Version: "20260618-001",
Result: "success",
Message: "ok",
Checksum: "checksum-1",
MainConfigChecksum: "main-1",
RouteConfigChecksum: "route-1",
SupportFileCount: 3,
CreatedAt: applyAt,
}).Error)
views, err := ListNodes(ctx)
require.NoError(t, err)
require.Len(t, views, 1)
assert.Equal(t, "success", views[0].LatestApplyResult)
assert.Equal(t, "checksum-1", views[0].LatestApplyChecksum)
assert.Equal(t, 3, views[0].LatestSupportFileCount)
require.NotNil(t, views[0].LatestApplyAt)
assert.Equal(t, applyAt, views[0].LatestApplyAt.UTC())
}
func TestBootstrapTokenLifecycle(t *testing.T) {
cleanup := setupNodeTestDB(t)
defer cleanup()
ctx := context.Background()
first, err := GetBootstrapToken(ctx)
require.NoError(t, err)
assert.Len(t, first.DiscoveryToken, 32)
second, err := GetBootstrapToken(ctx)
require.NoError(t, err)
assert.Equal(t, first.DiscoveryToken, second.DiscoveryToken)
rotated, err := RotateBootstrapToken(ctx)
require.NoError(t, err)
assert.NotEqual(t, first.DiscoveryToken, rotated.DiscoveryToken)
assert.Equal(t, rotated.DiscoveryToken, model.OptionValue("AgentDiscoveryToken"))
}
func TestValidateDiscoveryToken(t *testing.T) {
cleanup := setupNodeTestDB(t)
defer cleanup()
ctx := context.Background()
bootstrap, err := GetBootstrapToken(ctx)
require.NoError(t, err)
require.NoError(t, ValidateDiscoveryToken(ctx, bootstrap.DiscoveryToken))
require.Error(t, ValidateDiscoveryToken(ctx, "invalid-token"))
}
func TestRequestAgentUpdateWithPreviewTag(t *testing.T) {
cleanup := setupNodeTestDB(t)
defer cleanup()
ctx := context.Background()
created, err := CreateNode(ctx, Input{Name: "edge-update-agent"})
require.NoError(t, err)
originalClient := setReleaseHTTPClientForTest(&http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
expected := "https://api.github.com/repos/" + model.AgentUpdateRepo + "/releases/tags/v0.5.0-rc.1"
require.Equal(t, expected, req.URL.String())
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"tag_name":"v0.5.0-rc.1","prerelease":true}`)),
}, nil
}),
})
t.Cleanup(func() {
setReleaseHTTPClientForTest(originalClient)
})
updated, err := RequestAgentUpdate(ctx, created.ID, AgentUpdateInput{
Channel: "preview",
TagName: "v0.5.0-rc.1",
})
require.NoError(t, err)
assert.True(t, updated.UpdateRequested)
assert.Equal(t, "preview", updated.UpdateChannel)
assert.Equal(t, "v0.5.0-rc.1", updated.UpdateTag)
}
func TestRequestOpenrestyRestart(t *testing.T) {
cleanup := setupNodeTestDB(t)
defer cleanup()
ctx := context.Background()
created, err := CreateNode(ctx, Input{Name: "edge-restart"})
require.NoError(t, err)
updated, err := RequestOpenrestyRestart(ctx, created.ID)
require.NoError(t, err)
assert.True(t, updated.RestartOpenrestyRequested)
}
func seedActiveConfigVersion(t *testing.T, ctx context.Context) {
t.Helper()
conn := db.DB(ctx)
require.NotNil(t, conn)
require.NoError(t, conn.AutoMigrate(&model.ConfigVersion{}))
require.NoError(t, conn.Create(&model.ConfigVersion{
Version: "20260618-001",
SnapshotJSON: `{}`,
RenderedConfig: `server {}`,
Checksum: "abc123",
IsActive: true,
CreatedBy: "test",
}).Error)
}
func TestRequestForceSyncRequiresWebSocket(t *testing.T) {
cleanup := setupNodeTestDB(t)
defer cleanup()
ctx := context.Background()
seedActiveConfigVersion(t, ctx)
created, err := CreateNode(ctx, Input{Name: "edge-sync"})
require.NoError(t, err)
_, err = RequestForceSync(ctx, created.ID)
require.Error(t, err)
assert.Equal(t, errNodeForceSyncFailed, err.Error())
}
func TestRequestForceSyncRequiresActiveConfig(t *testing.T) {
cleanup := setupNodeTestDB(t)
defer cleanup()
ctx := context.Background()
conn := db.DB(ctx)
require.NotNil(t, conn)
require.NoError(t, conn.AutoMigrate(&model.ConfigVersion{}))
created, err := CreateNode(ctx, Input{Name: "edge-sync-active"})
require.NoError(t, err)
_, err = RequestForceSync(ctx, created.ID)
require.Error(t, err)
assert.Contains(t, err.Error(), errNoActiveConfigVersion)
}
func TestGetObservabilityStub(t *testing.T) {
cleanup := setupNodeTestDB(t)
defer cleanup()
ctx := context.Background()
created, err := CreateNode(ctx, Input{Name: "edge-obs"})
require.NoError(t, err)
view, err := GetObservability(ctx, created.ID, ObservabilityQuery{Hours: 24, Limit: 50})
require.NoError(t, err)
assert.Equal(t, created.NodeID, view.NodeID)
assert.Empty(t, view.MetricSnapshots)
}
func TestComputeNodeStatus(t *testing.T) {
now := time.Now()
pending := &model.OpenFlareNode{}
assert.Equal(t, nodeStatusPending, computeNodeStatus(pending))
online := &model.OpenFlareNode{LastSeenAt: &now}
assert.Equal(t, nodeStatusOnline, computeNodeStatus(online))
offlineAt := now.Add(-model.NodeOfflineThreshold - time.Minute)
offline := &model.OpenFlareNode{LastSeenAt: &offlineAt}
assert.Equal(t, nodeStatusOffline, computeNodeStatus(offline))
}
type roundTripFunc func(req *http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
+324
View File
@@ -0,0 +1,324 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package node
import (
"encoding/json"
"errors"
"io"
"net/http"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
"github.com/Rain-kl/Wavelet/internal/common/response"
"github.com/gin-gonic/gin"
)
func handleLogicError(c *gin.Context, err error) bool {
if err == nil {
return false
}
return apiutil.AbortNotFoundIfMissing(c, err, errNodeNotFound)
}
// ListNodesHandler lists all nodes.
// @Summary 获取节点列表
// @Description 返回所有节点及最新配置下发记录,需要管理员权限
// @Tags openflare-node
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=[]node.View} "节点列表"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Router /api/v1/d/nodes [get]
func ListNodesHandler(c *gin.Context) {
nodes, err := ListNodes(c.Request.Context())
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(nodes))
}
// CreateNodeHandler creates a node.
// @Summary 创建节点
// @Description 创建新的边缘节点记录,需要管理员权限
// @Tags openflare-node
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param body body node.Input true "节点参数"
// @Success 200 {object} response.Any{data=node.View} "创建成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Router /api/v1/d/nodes [post]
func CreateNodeHandler(c *gin.Context) {
var input Input
if !apiutil.BindJSON(c, &input) {
return
}
view, err := CreateNode(c.Request.Context(), input)
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(view))
}
// UpdateNodeHandler updates a node.
// @Summary 更新节点
// @Description 更新指定节点的配置信息,需要管理员权限
// @Tags openflare-node
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "节点 ID"
// @Param body body node.Input true "节点参数"
// @Success 200 {object} response.Any{data=node.View} "更新成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或节点不存在"
// @Router /api/v1/d/nodes/{id}/update [post]
func UpdateNodeHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
var input Input
if !apiutil.BindJSON(c, &input) {
return
}
view, err := UpdateNode(c.Request.Context(), id, input)
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(view))
}
// DeleteNodeHandler deletes a node.
// @Summary 删除节点
// @Description 删除指定节点记录,需要管理员权限
// @Tags openflare-node
// @Produce json
// @Security SessionCookie
// @Param id path int true "节点 ID"
// @Success 200 {object} response.Any{data=string} "删除成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或节点不存在"
// @Router /api/v1/d/nodes/{id}/delete [post]
func DeleteNodeHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
if err := DeleteNode(c.Request.Context(), id); handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OKNil())
}
// GetBootstrapTokenHandler returns the global discovery token.
// @Summary 获取引导令牌
// @Description 返回全局节点发现引导令牌,需要管理员权限
// @Tags openflare-node
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=node.BootstrapView} "引导令牌"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Router /api/v1/d/nodes/bootstrap-token [get]
func GetBootstrapTokenHandler(c *gin.Context) {
view, err := GetBootstrapToken(c.Request.Context())
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(view))
}
// RotateBootstrapTokenHandler rotates the global discovery token.
// @Summary 轮换引导令牌
// @Description 重新生成全局节点发现引导令牌,需要管理员权限
// @Tags openflare-node
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=node.BootstrapView} "新引导令牌"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Router /api/v1/d/nodes/bootstrap-token/rotate [post]
func RotateBootstrapTokenHandler(c *gin.Context) {
view, err := RotateBootstrapToken(c.Request.Context())
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(view))
}
// GetAgentReleaseHandler returns the latest agent release for a node.
// @Summary 获取 Agent 发布信息
// @Description 返回指定节点可用的最新 Agent 版本信息,需要管理员权限
// @Tags openflare-node
// @Produce json
// @Security SessionCookie
// @Param id path int true "节点 ID"
// @Param channel query string false "发布渠道"
// @Success 200 {object} response.Any{data=node.AgentReleaseInfo} "Agent 发布信息"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或节点不存在"
// @Router /api/v1/d/nodes/{id}/agent-release [get]
func GetAgentReleaseHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
release, err := GetAgentRelease(c.Request.Context(), id, c.Query("channel"))
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(release))
}
// RequestAgentUpdateHandler requests agent self-update on a node.
// @Summary 请求 Agent 更新
// @Description 向指定节点下发 Agent 自更新指令,需要管理员权限
// @Tags openflare-node
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "节点 ID"
// @Param body body node.AgentUpdateInput false "更新参数(可选)"
// @Success 200 {object} response.Any{data=node.View} "更新请求已下发"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或节点不存在"
// @Router /api/v1/d/nodes/{id}/agent-update [post]
func RequestAgentUpdateHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
var request AgentUpdateInput
if c.Request.ContentLength > 0 {
if err := bindOptionalJSON(c.Request.Body, &request); err != nil {
response.AbortBadRequest(c, "参数错误")
return
}
}
view, err := RequestAgentUpdate(c.Request.Context(), id, request)
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(view))
}
// RequestOpenrestyRestartHandler requests openresty restart on a node.
// @Summary 请求重启 OpenResty
// @Description 向指定节点下发 OpenResty 重启指令,需要管理员权限
// @Tags openflare-node
// @Produce json
// @Security SessionCookie
// @Param id path int true "节点 ID"
// @Success 200 {object} response.Any{data=node.View} "重启请求已下发"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或节点不存在"
// @Router /api/v1/d/nodes/{id}/openresty-restart [post]
func RequestOpenrestyRestartHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
view, err := RequestOpenrestyRestart(c.Request.Context(), id)
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(view))
}
// RequestForceSyncHandler requests force sync on a node.
// @Summary 请求强制同步配置
// @Description 向指定节点下发强制同步当前活跃配置的指令,需要管理员权限
// @Tags openflare-node
// @Produce json
// @Security SessionCookie
// @Param id path int true "节点 ID"
// @Success 200 {object} response.Any{data=node.View} "同步请求已下发"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或节点不存在"
// @Router /api/v1/d/nodes/{id}/force-sync [post]
func RequestForceSyncHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
view, err := RequestForceSync(c.Request.Context(), id)
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(view))
}
// GetObservabilityHandler returns node observability details.
// @Summary 获取节点可观测性数据
// @Description 返回指定节点的指标、健康事件与流量分析数据,需要管理员权限
// @Tags openflare-node
// @Produce json
// @Security SessionCookie
// @Param id path int true "节点 ID"
// @Param hours query int false "统计时间范围(小时)"
// @Param limit query int false "返回记录数量上限"
// @Success 200 {object} response.Any{data=node.ObservabilityView} "可观测性数据"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或节点不存在"
// @Router /api/v1/d/nodes/{id}/observability [get]
func GetObservabilityHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
var query ObservabilityQuery
if err := c.ShouldBindQuery(&query); err != nil {
response.AbortBadRequest(c, "参数错误")
return
}
view, err := GetObservability(c.Request.Context(), id, query)
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(view))
}
// CleanupHealthEventsHandler cleans up node health events.
// @Summary 清理节点健康事件
// @Description 清理指定节点的历史健康事件记录,需要管理员权限
// @Tags openflare-node
// @Produce json
// @Security SessionCookie
// @Param id path int true "节点 ID"
// @Success 200 {object} response.Any{data=node.HealthEventCleanupResult} "清理结果"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或节点不存在"
// @Router /api/v1/d/nodes/{id}/observability/cleanup [post]
func CleanupHealthEventsHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
result, err := CleanupHealthEvents(c.Request.Context(), id)
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(result))
}
func bindOptionalJSON(body io.Reader, target any) error {
if err := json.NewDecoder(body).Decode(target); err != nil && !errors.Is(err, io.EOF) {
return err
}
return nil
}