2026-01-05 21:12:53 +00:00
|
|
|
// Package service provides Node management service for multi-node architecture.
|
|
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
2026-01-10 19:51:10 +00:00
|
|
|
"crypto/tls"
|
|
|
|
|
"crypto/x509"
|
2026-01-05 21:12:53 +00:00
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"net/http"
|
2026-01-10 19:51:10 +00:00
|
|
|
"os"
|
2026-01-05 23:27:12 +00:00
|
|
|
"strings"
|
2026-01-05 21:12:53 +00:00
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/mhsanaei/3x-ui/v2/database"
|
|
|
|
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
|
|
|
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
2026-01-12 02:01:31 +00:00
|
|
|
"github.com/mhsanaei/3x-ui/v2/util/random"
|
2026-01-05 23:27:12 +00:00
|
|
|
"github.com/mhsanaei/3x-ui/v2/xray"
|
2026-01-05 21:12:53 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// NodeService provides business logic for managing nodes in multi-node mode.
|
|
|
|
|
type NodeService struct{}
|
|
|
|
|
|
|
|
|
|
// GetAllNodes retrieves all nodes from the database.
|
|
|
|
|
func (s *NodeService) GetAllNodes() ([]*model.Node, error) {
|
|
|
|
|
db := database.GetDB()
|
|
|
|
|
var nodes []*model.Node
|
|
|
|
|
err := db.Find(&nodes).Error
|
|
|
|
|
return nodes, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GetNode retrieves a node by ID.
|
|
|
|
|
func (s *NodeService) GetNode(id int) (*model.Node, error) {
|
|
|
|
|
db := database.GetDB()
|
|
|
|
|
var node model.Node
|
|
|
|
|
err := db.First(&node, id).Error
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return &node, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// AddNode creates a new node.
|
|
|
|
|
func (s *NodeService) AddNode(node *model.Node) error {
|
|
|
|
|
db := database.GetDB()
|
|
|
|
|
return db.Create(node).Error
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-12 02:01:31 +00:00
|
|
|
// RegisterNode registers a node by sending it an API key generated by the panel.
|
|
|
|
|
// This method generates a unique API key, sends it to the node, and returns the key.
|
|
|
|
|
func (s *NodeService) RegisterNode(node *model.Node) (string, error) {
|
|
|
|
|
// Generate a unique API key (32 characters, alphanumeric)
|
|
|
|
|
apiKey := random.Seq(32)
|
|
|
|
|
|
|
|
|
|
// Determine panel URL to send to node
|
|
|
|
|
settingService := SettingService{}
|
|
|
|
|
protocol := "http"
|
|
|
|
|
if certFile, _ := settingService.GetCertFile(); certFile != "" {
|
|
|
|
|
protocol = "https"
|
|
|
|
|
}
|
|
|
|
|
listenIP, _ := settingService.GetListen()
|
|
|
|
|
listenPort, _ := settingService.GetPort()
|
|
|
|
|
basePath, _ := settingService.GetBasePath()
|
|
|
|
|
|
|
|
|
|
panelURL := fmt.Sprintf("%s://%s:%d%s", protocol, listenIP, listenPort, basePath)
|
|
|
|
|
|
|
|
|
|
// Prepare registration request
|
|
|
|
|
registerData := map[string]interface{}{
|
|
|
|
|
"apiKey": apiKey,
|
|
|
|
|
"panelUrl": panelURL,
|
|
|
|
|
"nodeAddress": node.Address,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Send registration request to node
|
|
|
|
|
client, err := s.createHTTPClient(node, 10*time.Second)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", fmt.Errorf("failed to create HTTP client: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
registerURL := fmt.Sprintf("%s/api/v1/register", node.Address)
|
|
|
|
|
jsonData, err := json.Marshal(registerData)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", fmt.Errorf("failed to marshal registration data: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
req, err := http.NewRequest("POST", registerURL, bytes.NewBuffer(jsonData))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", fmt.Errorf("failed to create registration request: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
|
|
|
|
resp, err := client.Do(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", fmt.Errorf("failed to register node: %w (check if node is accessible at %s)", err, node.Address)
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
|
|
|
return "", fmt.Errorf("node registration failed with status %d: %s", resp.StatusCode, string(body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Parse response to verify registration
|
|
|
|
|
var registerResp map[string]interface{}
|
|
|
|
|
if err := json.NewDecoder(resp.Body).Decode(®isterResp); err != nil {
|
|
|
|
|
return "", fmt.Errorf("failed to parse registration response: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.Infof("[Node: %s] Successfully registered node with API key", node.Name)
|
|
|
|
|
return apiKey, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-05 21:12:53 +00:00
|
|
|
// UpdateNode updates an existing node.
|
2026-01-06 00:08:00 +00:00
|
|
|
// Only updates fields that are provided (non-empty for strings, non-zero for integers).
|
2026-01-05 21:12:53 +00:00
|
|
|
func (s *NodeService) UpdateNode(node *model.Node) error {
|
|
|
|
|
db := database.GetDB()
|
2026-01-06 00:08:00 +00:00
|
|
|
|
|
|
|
|
// Get existing node to preserve fields that are not being updated
|
|
|
|
|
existingNode, err := s.GetNode(node.Id)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("failed to get existing node: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update only provided fields
|
|
|
|
|
updates := make(map[string]interface{})
|
|
|
|
|
|
|
|
|
|
if node.Name != "" {
|
|
|
|
|
updates["name"] = node.Name
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if node.Address != "" {
|
|
|
|
|
updates["address"] = node.Address
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if node.ApiKey != "" {
|
|
|
|
|
updates["api_key"] = node.ApiKey
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-10 19:51:10 +00:00
|
|
|
// Update TLS settings if provided
|
|
|
|
|
updates["use_tls"] = node.UseTLS
|
|
|
|
|
if node.CertPath != "" {
|
|
|
|
|
updates["cert_path"] = node.CertPath
|
|
|
|
|
}
|
|
|
|
|
if node.KeyPath != "" {
|
|
|
|
|
updates["key_path"] = node.KeyPath
|
|
|
|
|
}
|
|
|
|
|
updates["insecure_tls"] = node.InsecureTLS
|
|
|
|
|
|
2026-01-12 02:01:31 +00:00
|
|
|
// Update status, response_time, and last_check if provided (these are usually set by health checks, not user edits)
|
2026-01-06 00:08:00 +00:00
|
|
|
if node.Status != "" && node.Status != existingNode.Status {
|
|
|
|
|
updates["status"] = node.Status
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-12 02:01:31 +00:00
|
|
|
if node.ResponseTime > 0 && node.ResponseTime != existingNode.ResponseTime {
|
|
|
|
|
updates["response_time"] = node.ResponseTime
|
|
|
|
|
} else if node.ResponseTime == 0 && existingNode.ResponseTime > 0 {
|
|
|
|
|
// Allow resetting to 0 (e.g., on error)
|
|
|
|
|
updates["response_time"] = 0
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-06 00:08:00 +00:00
|
|
|
if node.LastCheck > 0 && node.LastCheck != existingNode.LastCheck {
|
|
|
|
|
updates["last_check"] = node.LastCheck
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If no fields to update, return early
|
|
|
|
|
if len(updates) == 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update only the specified fields
|
|
|
|
|
return db.Model(existingNode).Updates(updates).Error
|
2026-01-05 21:12:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// DeleteNode deletes a node by ID.
|
|
|
|
|
// This will cascade delete all InboundNodeMapping entries for this node.
|
|
|
|
|
func (s *NodeService) DeleteNode(id int) error {
|
|
|
|
|
db := database.GetDB()
|
|
|
|
|
|
|
|
|
|
// Delete all node mappings for this node (cascade delete)
|
|
|
|
|
err := db.Where("node_id = ?", id).Delete(&model.InboundNodeMapping{}).Error
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Delete the node itself
|
|
|
|
|
return db.Delete(&model.Node{}, id).Error
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-12 02:01:31 +00:00
|
|
|
// CheckNodeHealth checks if a node is online and updates its status and response time.
|
2026-01-05 21:12:53 +00:00
|
|
|
func (s *NodeService) CheckNodeHealth(node *model.Node) error {
|
2026-01-12 02:01:31 +00:00
|
|
|
status, responseTime, err := s.CheckNodeStatus(node)
|
2026-01-05 21:12:53 +00:00
|
|
|
if err != nil {
|
|
|
|
|
node.Status = "error"
|
2026-01-12 02:01:31 +00:00
|
|
|
node.ResponseTime = 0 // Set to 0 on error
|
2026-01-05 21:12:53 +00:00
|
|
|
node.LastCheck = time.Now().Unix()
|
2026-01-12 02:01:31 +00:00
|
|
|
if updateErr := s.UpdateNode(node); updateErr != nil {
|
|
|
|
|
logger.Errorf("[Node: %s] Failed to update node status: %v", node.Name, updateErr)
|
|
|
|
|
}
|
2026-01-05 21:12:53 +00:00
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
node.Status = status
|
2026-01-12 02:01:31 +00:00
|
|
|
node.ResponseTime = responseTime
|
2026-01-05 21:12:53 +00:00
|
|
|
node.LastCheck = time.Now().Unix()
|
2026-01-12 02:01:31 +00:00
|
|
|
logger.Debugf("[Node: %s] Health check: status=%s, responseTime=%d ms", node.Name, status, responseTime)
|
|
|
|
|
if updateErr := s.UpdateNode(node); updateErr != nil {
|
|
|
|
|
logger.Errorf("[Node: %s] Failed to update node with response time: %v", node.Name, updateErr)
|
|
|
|
|
return updateErr
|
|
|
|
|
}
|
|
|
|
|
return nil
|
2026-01-05 21:12:53 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-10 19:51:10 +00:00
|
|
|
// createHTTPClient creates an HTTP client configured for the node's TLS settings.
|
|
|
|
|
func (s *NodeService) createHTTPClient(node *model.Node, timeout time.Duration) (*http.Client, error) {
|
|
|
|
|
transport := &http.Transport{
|
|
|
|
|
TLSClientConfig: &tls.Config{
|
|
|
|
|
InsecureSkipVerify: node.InsecureTLS,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If custom certificates are provided, load them
|
|
|
|
|
if node.UseTLS && node.CertPath != "" {
|
|
|
|
|
// Load custom CA certificate
|
|
|
|
|
cert, err := os.ReadFile(node.CertPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to read certificate file: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
caCertPool := x509.NewCertPool()
|
|
|
|
|
if !caCertPool.AppendCertsFromPEM(cert) {
|
|
|
|
|
return nil, fmt.Errorf("failed to parse certificate")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
transport.TLSClientConfig.RootCAs = caCertPool
|
|
|
|
|
transport.TLSClientConfig.InsecureSkipVerify = false // Use custom CA
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If custom key is provided, load client certificate
|
|
|
|
|
if node.UseTLS && node.KeyPath != "" && node.CertPath != "" {
|
|
|
|
|
// Load client certificate (cert + key)
|
|
|
|
|
clientCert, err := tls.LoadX509KeyPair(node.CertPath, node.KeyPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to load client certificate: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
transport.TLSClientConfig.Certificates = []tls.Certificate{clientCert}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return &http.Client{
|
|
|
|
|
Timeout: timeout,
|
|
|
|
|
Transport: transport,
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-12 02:01:31 +00:00
|
|
|
// CheckNodeStatus performs a health check on a given node and measures response time.
|
|
|
|
|
// Returns status string and response time in milliseconds.
|
|
|
|
|
func (s *NodeService) CheckNodeStatus(node *model.Node) (string, int64, error) {
|
2026-01-10 19:51:10 +00:00
|
|
|
client, err := s.createHTTPClient(node, 5*time.Second)
|
|
|
|
|
if err != nil {
|
2026-01-12 02:01:31 +00:00
|
|
|
return "error", 0, err
|
2026-01-05 21:12:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
url := fmt.Sprintf("%s/health", node.Address)
|
2026-01-12 02:01:31 +00:00
|
|
|
|
|
|
|
|
// Measure response time
|
|
|
|
|
startTime := time.Now()
|
2026-01-05 21:12:53 +00:00
|
|
|
resp, err := client.Get(url)
|
2026-01-12 02:01:31 +00:00
|
|
|
responseTime := time.Since(startTime).Milliseconds()
|
|
|
|
|
|
2026-01-05 21:12:53 +00:00
|
|
|
if err != nil {
|
2026-01-12 02:01:31 +00:00
|
|
|
return "offline", 0, err
|
2026-01-05 21:12:53 +00:00
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode == http.StatusOK {
|
2026-01-12 02:01:31 +00:00
|
|
|
return "online", responseTime, nil
|
2026-01-05 21:12:53 +00:00
|
|
|
}
|
2026-01-12 02:01:31 +00:00
|
|
|
return "error", 0, fmt.Errorf("node returned status code %d", resp.StatusCode)
|
2026-01-05 21:12:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// CheckAllNodesHealth checks health of all nodes.
|
|
|
|
|
func (s *NodeService) CheckAllNodesHealth() {
|
|
|
|
|
nodes, err := s.GetAllNodes()
|
|
|
|
|
if err != nil {
|
|
|
|
|
logger.Errorf("Failed to get nodes for health check: %v", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, node := range nodes {
|
|
|
|
|
go s.CheckNodeHealth(node)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GetNodeForInbound returns the node assigned to an inbound, or nil if not assigned.
|
|
|
|
|
// Deprecated: Use GetNodesForInbound for multi-node support.
|
|
|
|
|
func (s *NodeService) GetNodeForInbound(inboundId int) (*model.Node, error) {
|
|
|
|
|
db := database.GetDB()
|
|
|
|
|
var mapping model.InboundNodeMapping
|
|
|
|
|
err := db.Where("inbound_id = ?", inboundId).First(&mapping).Error
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err // Not found is OK, means inbound is not assigned to any node
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return s.GetNode(mapping.NodeId)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GetNodesForInbound returns all nodes assigned to an inbound.
|
|
|
|
|
func (s *NodeService) GetNodesForInbound(inboundId int) ([]*model.Node, error) {
|
|
|
|
|
db := database.GetDB()
|
|
|
|
|
var mappings []model.InboundNodeMapping
|
|
|
|
|
err := db.Where("inbound_id = ?", inboundId).Find(&mappings).Error
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
nodes := make([]*model.Node, 0, len(mappings))
|
|
|
|
|
for _, mapping := range mappings {
|
|
|
|
|
node, err := s.GetNode(mapping.NodeId)
|
|
|
|
|
if err == nil && node != nil {
|
|
|
|
|
nodes = append(nodes, node)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nodes, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GetInboundsForNode returns all inbounds assigned to a node.
|
|
|
|
|
func (s *NodeService) GetInboundsForNode(nodeId int) ([]*model.Inbound, error) {
|
|
|
|
|
db := database.GetDB()
|
|
|
|
|
var mappings []model.InboundNodeMapping
|
|
|
|
|
err := db.Where("node_id = ?", nodeId).Find(&mappings).Error
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
inbounds := make([]*model.Inbound, 0, len(mappings))
|
|
|
|
|
for _, mapping := range mappings {
|
|
|
|
|
var inbound model.Inbound
|
|
|
|
|
err := db.First(&inbound, mapping.InboundId).Error
|
|
|
|
|
if err == nil {
|
|
|
|
|
inbounds = append(inbounds, &inbound)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return inbounds, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-05 23:27:12 +00:00
|
|
|
// NodeStatsResponse represents the response from node stats API.
|
|
|
|
|
type NodeStatsResponse struct {
|
|
|
|
|
Traffic []*NodeTraffic `json:"traffic"`
|
|
|
|
|
ClientTraffic []*NodeClientTraffic `json:"clientTraffic"`
|
|
|
|
|
OnlineClients []string `json:"onlineClients"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NodeTraffic represents traffic statistics from a node.
|
|
|
|
|
type NodeTraffic struct {
|
|
|
|
|
IsInbound bool `json:"isInbound"`
|
|
|
|
|
IsOutbound bool `json:"isOutbound"`
|
|
|
|
|
Tag string `json:"tag"`
|
|
|
|
|
Up int64 `json:"up"`
|
|
|
|
|
Down int64 `json:"down"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NodeClientTraffic represents client traffic statistics from a node.
|
|
|
|
|
type NodeClientTraffic struct {
|
|
|
|
|
Email string `json:"email"`
|
|
|
|
|
Up int64 `json:"up"`
|
|
|
|
|
Down int64 `json:"down"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GetNodeStats retrieves traffic and online clients statistics from a node.
|
|
|
|
|
func (s *NodeService) GetNodeStats(node *model.Node, reset bool) (*NodeStatsResponse, error) {
|
2026-01-10 19:51:10 +00:00
|
|
|
client, err := s.createHTTPClient(node, 10*time.Second)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to create HTTP client: %w", err)
|
2026-01-05 23:27:12 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
url := fmt.Sprintf("%s/api/v1/stats", node.Address)
|
|
|
|
|
if reset {
|
|
|
|
|
url += "?reset=true"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+node.ApiKey)
|
|
|
|
|
|
|
|
|
|
resp, err := client.Do(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to request node stats: %w", err)
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
|
|
|
return nil, fmt.Errorf("node returned status code %d: %s", resp.StatusCode, string(body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var stats NodeStatsResponse
|
|
|
|
|
if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return &stats, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// CollectNodeStats collects statistics from all nodes and aggregates them into the database.
|
|
|
|
|
// This should be called periodically (e.g., via cron job).
|
|
|
|
|
func (s *NodeService) CollectNodeStats() error {
|
|
|
|
|
// Check if multi-node mode is enabled
|
|
|
|
|
settingService := SettingService{}
|
|
|
|
|
multiMode, err := settingService.GetMultiNodeMode()
|
|
|
|
|
if err != nil || !multiMode {
|
|
|
|
|
return nil // Skip if multi-node mode is not enabled
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
nodes, err := s.GetAllNodes()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("failed to get nodes: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(nodes) == 0 {
|
|
|
|
|
return nil // No nodes to collect stats from
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Filter nodes: only collect stats from nodes that have assigned inbounds
|
|
|
|
|
nodesWithInbounds := make([]*model.Node, 0)
|
|
|
|
|
for _, node := range nodes {
|
|
|
|
|
inbounds, err := s.GetInboundsForNode(node.Id)
|
|
|
|
|
if err == nil && len(inbounds) > 0 {
|
|
|
|
|
// Only include nodes that have at least one assigned inbound
|
|
|
|
|
nodesWithInbounds = append(nodesWithInbounds, node)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(nodesWithInbounds) == 0 {
|
|
|
|
|
return nil // No nodes with assigned inbounds
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Import inbound service to aggregate traffic
|
|
|
|
|
inboundService := &InboundService{}
|
|
|
|
|
|
|
|
|
|
// Collect stats from nodes with assigned inbounds concurrently
|
|
|
|
|
type nodeStatsResult struct {
|
|
|
|
|
node *model.Node
|
|
|
|
|
stats *NodeStatsResponse
|
|
|
|
|
err error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
results := make(chan nodeStatsResult, len(nodesWithInbounds))
|
|
|
|
|
for _, node := range nodesWithInbounds {
|
|
|
|
|
go func(n *model.Node) {
|
|
|
|
|
stats, err := s.GetNodeStats(n, false) // Don't reset counters on collection
|
|
|
|
|
results <- nodeStatsResult{node: n, stats: stats, err: err}
|
|
|
|
|
}(node)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Aggregate all traffic
|
|
|
|
|
allTraffics := make([]*xray.Traffic, 0)
|
|
|
|
|
allClientTraffics := make([]*xray.ClientTraffic, 0)
|
|
|
|
|
onlineClientsMap := make(map[string]bool)
|
|
|
|
|
|
|
|
|
|
for i := 0; i < len(nodesWithInbounds); i++ {
|
|
|
|
|
result := <-results
|
|
|
|
|
if result.err != nil {
|
|
|
|
|
// Check if error is expected (XRAY not running, 404 for old nodes, etc.)
|
|
|
|
|
errMsg := result.err.Error()
|
|
|
|
|
if strings.Contains(errMsg, "XRAY is not running") ||
|
|
|
|
|
strings.Contains(errMsg, "status code 404") ||
|
|
|
|
|
strings.Contains(errMsg, "status code 500") {
|
|
|
|
|
// These are expected errors, log as debug only
|
2026-01-12 02:01:31 +00:00
|
|
|
logger.Debugf("[Node: %s] Skipping stats collection: %v", result.node.Name, result.err)
|
2026-01-05 23:27:12 +00:00
|
|
|
} else {
|
|
|
|
|
// Unexpected errors should be logged as warning
|
2026-01-12 02:01:31 +00:00
|
|
|
logger.Warningf("[Node: %s] Failed to get stats: %v", result.node.Name, result.err)
|
2026-01-05 23:27:12 +00:00
|
|
|
}
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if result.stats == nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Convert node traffic to xray.Traffic
|
|
|
|
|
for _, nt := range result.stats.Traffic {
|
|
|
|
|
allTraffics = append(allTraffics, &xray.Traffic{
|
|
|
|
|
IsInbound: nt.IsInbound,
|
|
|
|
|
IsOutbound: nt.IsOutbound,
|
|
|
|
|
Tag: nt.Tag,
|
|
|
|
|
Up: nt.Up,
|
|
|
|
|
Down: nt.Down,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Convert node client traffic to xray.ClientTraffic
|
|
|
|
|
for _, nct := range result.stats.ClientTraffic {
|
|
|
|
|
allClientTraffics = append(allClientTraffics, &xray.ClientTraffic{
|
|
|
|
|
Email: nct.Email,
|
|
|
|
|
Up: nct.Up,
|
|
|
|
|
Down: nct.Down,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Collect online clients
|
|
|
|
|
for _, email := range result.stats.OnlineClients {
|
|
|
|
|
onlineClientsMap[email] = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Aggregate traffic into database
|
|
|
|
|
if len(allTraffics) > 0 || len(allClientTraffics) > 0 {
|
|
|
|
|
_, needRestart := inboundService.AddTraffic(allTraffics, allClientTraffics)
|
|
|
|
|
if needRestart {
|
|
|
|
|
logger.Info("Traffic aggregation triggered client renewal/disabling, restart may be needed")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.Debugf("Collected stats from nodes: %d traffics, %d client traffics, %d online clients",
|
|
|
|
|
len(allTraffics), len(allClientTraffics), len(onlineClientsMap))
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-05 21:12:53 +00:00
|
|
|
// AssignInboundToNode assigns an inbound to a node.
|
|
|
|
|
func (s *NodeService) AssignInboundToNode(inboundId, nodeId int) error {
|
|
|
|
|
db := database.GetDB()
|
|
|
|
|
mapping := &model.InboundNodeMapping{
|
|
|
|
|
InboundId: inboundId,
|
|
|
|
|
NodeId: nodeId,
|
|
|
|
|
}
|
|
|
|
|
return db.Save(mapping).Error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// AssignInboundToNodes assigns an inbound to multiple nodes.
|
|
|
|
|
func (s *NodeService) AssignInboundToNodes(inboundId int, nodeIds []int) error {
|
|
|
|
|
db := database.GetDB()
|
|
|
|
|
// First, remove all existing assignments
|
|
|
|
|
if err := db.Where("inbound_id = ?", inboundId).Delete(&model.InboundNodeMapping{}).Error; err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Then, create new assignments
|
|
|
|
|
for _, nodeId := range nodeIds {
|
|
|
|
|
if nodeId > 0 {
|
|
|
|
|
mapping := &model.InboundNodeMapping{
|
|
|
|
|
InboundId: inboundId,
|
|
|
|
|
NodeId: nodeId,
|
|
|
|
|
}
|
|
|
|
|
if err := db.Create(mapping).Error; err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// UnassignInboundFromNode removes the assignment of an inbound from its node.
|
|
|
|
|
func (s *NodeService) UnassignInboundFromNode(inboundId int) error {
|
|
|
|
|
db := database.GetDB()
|
|
|
|
|
return db.Where("inbound_id = ?", inboundId).Delete(&model.InboundNodeMapping{}).Error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ApplyConfigToNode sends XRAY configuration to a node.
|
|
|
|
|
func (s *NodeService) ApplyConfigToNode(node *model.Node, xrayConfig []byte) error {
|
2026-01-10 19:51:10 +00:00
|
|
|
client, err := s.createHTTPClient(node, 30*time.Second)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("failed to create HTTP client: %w", err)
|
2026-01-05 21:12:53 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-12 02:01:31 +00:00
|
|
|
// Get panel URL to send to node
|
|
|
|
|
panelURL := s.getPanelURL()
|
|
|
|
|
|
|
|
|
|
// Prepare request body with config and panel URL
|
|
|
|
|
requestBody := map[string]interface{}{
|
|
|
|
|
"config": json.RawMessage(xrayConfig),
|
|
|
|
|
}
|
|
|
|
|
if panelURL != "" {
|
|
|
|
|
requestBody["panelUrl"] = panelURL
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
requestJSON, err := json.Marshal(requestBody)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("failed to marshal request: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-05 21:12:53 +00:00
|
|
|
url := fmt.Sprintf("%s/api/v1/apply-config", node.Address)
|
2026-01-12 02:01:31 +00:00
|
|
|
req, err := http.NewRequest("POST", url, bytes.NewBuffer(requestJSON))
|
2026-01-05 21:12:53 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", node.ApiKey))
|
|
|
|
|
|
|
|
|
|
resp, err := client.Do(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
|
|
|
return fmt.Errorf("node returned status %d: %s", resp.StatusCode, string(body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-12 02:01:31 +00:00
|
|
|
// getPanelURL constructs the panel URL from settings.
|
|
|
|
|
func (s *NodeService) getPanelURL() string {
|
|
|
|
|
settingService := SettingService{}
|
|
|
|
|
|
|
|
|
|
// Get panel settings
|
|
|
|
|
webListen, _ := settingService.GetListen()
|
|
|
|
|
webPort, _ := settingService.GetPort()
|
|
|
|
|
webDomain, _ := settingService.GetWebDomain()
|
|
|
|
|
webCertFile, _ := settingService.GetCertFile()
|
|
|
|
|
webKeyFile, _ := settingService.GetKeyFile()
|
|
|
|
|
webBasePath, _ := settingService.GetBasePath()
|
|
|
|
|
|
|
|
|
|
// Determine protocol
|
|
|
|
|
protocol := "http"
|
|
|
|
|
if webCertFile != "" || webKeyFile != "" {
|
|
|
|
|
protocol = "https"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Determine host
|
|
|
|
|
host := webDomain
|
|
|
|
|
if host == "" {
|
|
|
|
|
host = webListen
|
|
|
|
|
if host == "" {
|
|
|
|
|
// If no listen IP specified, use localhost (node should be able to reach panel)
|
|
|
|
|
host = "127.0.0.1"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Construct URL
|
|
|
|
|
url := fmt.Sprintf("%s://%s", protocol, host)
|
|
|
|
|
if webPort > 0 && webPort != 80 && webPort != 443 {
|
|
|
|
|
url += fmt.Sprintf(":%d", webPort)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add base path (remove trailing slash if present, we'll add it in node)
|
|
|
|
|
basePath := webBasePath
|
|
|
|
|
if basePath != "" && basePath != "/" {
|
|
|
|
|
if !strings.HasSuffix(basePath, "/") {
|
|
|
|
|
basePath += "/"
|
|
|
|
|
}
|
|
|
|
|
url += basePath
|
|
|
|
|
} else {
|
|
|
|
|
url += "/"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return url
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-06 00:08:00 +00:00
|
|
|
// ReloadNode reloads XRAY on a specific node.
|
|
|
|
|
func (s *NodeService) ReloadNode(node *model.Node) error {
|
2026-01-10 19:51:10 +00:00
|
|
|
client, err := s.createHTTPClient(node, 30*time.Second)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("failed to create HTTP client: %w", err)
|
2026-01-06 00:08:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
url := fmt.Sprintf("%s/api/v1/reload", node.Address)
|
|
|
|
|
req, err := http.NewRequest("POST", url, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", node.ApiKey))
|
|
|
|
|
|
|
|
|
|
resp, err := client.Do(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
|
|
|
return fmt.Errorf("node returned status %d: %s", resp.StatusCode, string(body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ForceReloadNode forcefully reloads XRAY on a specific node (even if hung).
|
|
|
|
|
func (s *NodeService) ForceReloadNode(node *model.Node) error {
|
2026-01-10 19:51:10 +00:00
|
|
|
client, err := s.createHTTPClient(node, 30*time.Second)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("failed to create HTTP client: %w", err)
|
2026-01-06 00:08:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
url := fmt.Sprintf("%s/api/v1/force-reload", node.Address)
|
|
|
|
|
req, err := http.NewRequest("POST", url, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", node.ApiKey))
|
|
|
|
|
|
|
|
|
|
resp, err := client.Do(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
|
|
|
return fmt.Errorf("node returned status %d: %s", resp.StatusCode, string(body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ReloadAllNodes reloads XRAY on all nodes.
|
|
|
|
|
func (s *NodeService) ReloadAllNodes() error {
|
|
|
|
|
nodes, err := s.GetAllNodes()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("failed to get nodes: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type reloadResult struct {
|
|
|
|
|
node *model.Node
|
|
|
|
|
err error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
results := make(chan reloadResult, len(nodes))
|
|
|
|
|
for _, node := range nodes {
|
|
|
|
|
go func(n *model.Node) {
|
|
|
|
|
err := s.ForceReloadNode(n) // Use force reload to handle hung nodes
|
|
|
|
|
results <- reloadResult{node: n, err: err}
|
|
|
|
|
}(node)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var errors []string
|
|
|
|
|
for i := 0; i < len(nodes); i++ {
|
|
|
|
|
result := <-results
|
|
|
|
|
if result.err != nil {
|
|
|
|
|
errors = append(errors, fmt.Sprintf("node %d (%s): %v", result.node.Id, result.node.Name, result.err))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(errors) > 0 {
|
|
|
|
|
return fmt.Errorf("failed to reload some nodes: %s", strings.Join(errors, "; "))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-05 21:12:53 +00:00
|
|
|
// ValidateApiKey validates the API key by making a test request to the node.
|
|
|
|
|
func (s *NodeService) ValidateApiKey(node *model.Node) error {
|
2026-01-10 19:51:10 +00:00
|
|
|
client, err := s.createHTTPClient(node, 5*time.Second)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("failed to create HTTP client: %w", err)
|
2026-01-05 21:12:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// First, check if node is reachable via health endpoint
|
|
|
|
|
healthURL := fmt.Sprintf("%s/health", node.Address)
|
|
|
|
|
healthResp, err := client.Get(healthURL)
|
|
|
|
|
if err != nil {
|
2026-01-12 02:01:31 +00:00
|
|
|
logger.Errorf("[Node: %s] Failed to connect at %s: %v", node.Name, healthURL, err)
|
2026-01-05 21:12:53 +00:00
|
|
|
return fmt.Errorf("failed to connect to node: %v", err)
|
|
|
|
|
}
|
|
|
|
|
healthResp.Body.Close()
|
|
|
|
|
|
|
|
|
|
if healthResp.StatusCode != http.StatusOK {
|
|
|
|
|
return fmt.Errorf("node health check failed with status %d", healthResp.StatusCode)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Try to get node status - this will validate the API key
|
|
|
|
|
url := fmt.Sprintf("%s/api/v1/status", node.Address)
|
|
|
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("failed to create request: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
authHeader := fmt.Sprintf("Bearer %s", node.ApiKey)
|
|
|
|
|
req.Header.Set("Authorization", authHeader)
|
|
|
|
|
|
2026-01-12 02:01:31 +00:00
|
|
|
logger.Debugf("[Node: %s] Validating API key at %s", node.Name, url)
|
2026-01-05 21:12:53 +00:00
|
|
|
|
|
|
|
|
resp, err := client.Do(req)
|
|
|
|
|
if err != nil {
|
2026-01-12 02:01:31 +00:00
|
|
|
logger.Errorf("[Node: %s] Failed to connect: %v", node.Name, err)
|
2026-01-05 21:12:53 +00:00
|
|
|
return fmt.Errorf("failed to connect to node: %v", err)
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode == http.StatusUnauthorized {
|
2026-01-12 02:01:31 +00:00
|
|
|
logger.Warningf("[Node: %s] Invalid API key: %s", node.Name, string(body))
|
2026-01-05 21:12:53 +00:00
|
|
|
return fmt.Errorf("invalid API key")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
2026-01-12 02:01:31 +00:00
|
|
|
logger.Errorf("[Node: %s] Returned status %d: %s", node.Name, resp.StatusCode, string(body))
|
2026-01-05 21:12:53 +00:00
|
|
|
return fmt.Errorf("node returned status %d: %s", resp.StatusCode, string(body))
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-12 02:01:31 +00:00
|
|
|
logger.Debugf("[Node: %s] API key validated successfully", node.Name)
|
2026-01-05 21:12:53 +00:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GetNodeStatus retrieves the status of a node.
|
|
|
|
|
func (s *NodeService) GetNodeStatus(node *model.Node) (map[string]interface{}, error) {
|
2026-01-10 19:51:10 +00:00
|
|
|
client, err := s.createHTTPClient(node, 5*time.Second)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to create HTTP client: %w", err)
|
2026-01-05 21:12:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
url := fmt.Sprintf("%s/api/v1/status", node.Address)
|
|
|
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", node.ApiKey))
|
|
|
|
|
|
|
|
|
|
resp, err := client.Do(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
return nil, fmt.Errorf("node returned status %d", resp.StatusCode)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var status map[string]interface{}
|
|
|
|
|
if err := json.NewDecoder(resp.Body).Decode(&status); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return status, nil
|
|
|
|
|
}
|
2026-01-12 02:01:31 +00:00
|
|
|
|
|
|
|
|
// GetNodeLogs retrieves XRAY access logs from a node.
|
|
|
|
|
// Returns raw log lines as strings.
|
|
|
|
|
func (s *NodeService) GetNodeLogs(node *model.Node, count int, filter string) ([]string, error) {
|
|
|
|
|
client, err := s.createHTTPClient(node, 10*time.Second)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to create HTTP client: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
url := fmt.Sprintf("%s/api/v1/logs?count=%d", node.Address, count)
|
|
|
|
|
if filter != "" {
|
|
|
|
|
url += "&filter=" + filter
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+node.ApiKey)
|
|
|
|
|
|
|
|
|
|
resp, err := client.Do(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to request node logs: %w", err)
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
|
|
|
return nil, fmt.Errorf("node returned status code %d: %s", resp.StatusCode, string(body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var response struct {
|
|
|
|
|
Logs []string `json:"logs"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return response.Logs, nil
|
|
|
|
|
}
|