init xboard node
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
package panel
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
)
|
||||
|
||||
// Client communicates with the Xboard panel API
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
nodeID int
|
||||
nodeType string
|
||||
httpClient *http.Client
|
||||
|
||||
configETag string
|
||||
userETag string
|
||||
|
||||
apiSuccess atomic.Uint64
|
||||
apiFailure atomic.Uint64
|
||||
}
|
||||
|
||||
// NewClient creates a new panel API client
|
||||
func NewClient(cfg config.PanelConfig) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(cfg.URL, "/"),
|
||||
token: cfg.Token,
|
||||
nodeID: cfg.NodeID,
|
||||
nodeType: cfg.NodeType,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 10,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Handshake calls the new v2 API to get WS config + initial data in one shot.
|
||||
func (c *Client) Handshake() (*HandshakeResponse, error) {
|
||||
resp, err := c.doRequest("POST", "/api/v2/server/handshake", nil, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("handshake: %w", err)
|
||||
}
|
||||
defer drainAndClose(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return nil, fmt.Errorf("handshake status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var hs HandshakeResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&hs); err != nil {
|
||||
return nil, fmt.Errorf("decode handshake: %w", err)
|
||||
}
|
||||
return &hs, nil
|
||||
}
|
||||
|
||||
// Report sends consolidated traffic + alive + status data to the panel.
|
||||
// The optional metrics map allows the node to submit richer telemetry
|
||||
// (active connections, per-core CPU, GC stats, limiter hits, etc.)
|
||||
// without changing the core schema of status.
|
||||
func (c *Client) Report(traffic map[int][2]int64, alive map[int][]string, online map[int]int,
|
||||
cpu float64, mem, swap, disk [2]uint64,
|
||||
metrics map[string]interface{},
|
||||
) error {
|
||||
payload := map[string]interface{}{}
|
||||
|
||||
if len(traffic) > 0 {
|
||||
t := make(map[string][2]int64, len(traffic))
|
||||
for uid, d := range traffic {
|
||||
t[strconv.Itoa(uid)] = d
|
||||
}
|
||||
payload["traffic"] = t
|
||||
}
|
||||
|
||||
if len(alive) > 0 {
|
||||
a := make(map[string][]string, len(alive))
|
||||
for uid, ips := range alive {
|
||||
a[strconv.Itoa(uid)] = ips
|
||||
}
|
||||
payload["alive"] = a
|
||||
}
|
||||
|
||||
if len(online) > 0 {
|
||||
o := make(map[string]int, len(online))
|
||||
for uid, count := range online {
|
||||
o[strconv.Itoa(uid)] = count
|
||||
}
|
||||
payload["online"] = o
|
||||
}
|
||||
|
||||
status := map[string]interface{}{
|
||||
"cpu": cpu,
|
||||
"mem": map[string]interface{}{"total": mem[0], "used": mem[1]},
|
||||
"swap": map[string]interface{}{"total": swap[0], "used": swap[1]},
|
||||
"disk": map[string]interface{}{"total": disk[0], "used": disk[1]},
|
||||
}
|
||||
payload["status"] = status
|
||||
|
||||
if len(metrics) > 0 {
|
||||
payload["metrics"] = metrics
|
||||
}
|
||||
|
||||
return c.postJSON("/api/v2/server/report", payload)
|
||||
}
|
||||
|
||||
// GetConfig fetches node configuration. Returns nil if not modified (304).
|
||||
func (c *Client) GetConfig() (*NodeConfig, error) {
|
||||
resp, err := c.doRequest("GET", "/api/v1/server/UniProxy/config", nil, c.configETag)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get config: %w", err)
|
||||
}
|
||||
defer drainAndClose(resp.Body)
|
||||
|
||||
if resp.StatusCode == http.StatusNotModified {
|
||||
return nil, nil
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return nil, fmt.Errorf("status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var cfg NodeConfig
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
|
||||
if etag := resp.Header.Get("ETag"); etag != "" {
|
||||
c.configETag = etag
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// GetUsers fetches available users. Returns nil if not modified (304).
|
||||
func (c *Client) GetUsers() ([]User, error) {
|
||||
resp, err := c.doRequest("GET", "/api/v1/server/UniProxy/user", nil, c.userETag)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get users: %w", err)
|
||||
}
|
||||
defer drainAndClose(resp.Body)
|
||||
|
||||
if resp.StatusCode == http.StatusNotModified {
|
||||
return nil, nil
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return nil, fmt.Errorf("status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var usersResp UsersResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&usersResp); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
|
||||
if etag := resp.Header.Get("ETag"); etag != "" {
|
||||
c.userETag = etag
|
||||
}
|
||||
return usersResp.Users, nil
|
||||
}
|
||||
|
||||
// PushTraffic submits per-user traffic data
|
||||
func (c *Client) PushTraffic(data map[int][2]int64) error {
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
payload := make(map[string]interface{}, len(data))
|
||||
for uid, traffic := range data {
|
||||
payload[strconv.Itoa(uid)] = traffic
|
||||
}
|
||||
return c.postJSON("/api/v1/server/UniProxy/push", payload)
|
||||
}
|
||||
|
||||
// PushAlive submits online user IPs
|
||||
func (c *Client) PushAlive(data map[int][]string) error {
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
payload := make(map[string]interface{}, len(data))
|
||||
for uid, ips := range data {
|
||||
payload[strconv.Itoa(uid)] = ips
|
||||
}
|
||||
return c.postJSON("/api/v1/server/UniProxy/alive", payload)
|
||||
}
|
||||
|
||||
// PushStatus submits system status to the panel
|
||||
func (c *Client) PushStatus(cpu float64, mem, swap, disk [2]uint64) error {
|
||||
payload := map[string]interface{}{
|
||||
"cpu": cpu,
|
||||
"mem": map[string]interface{}{"total": mem[0], "used": mem[1]},
|
||||
"swap": map[string]interface{}{"total": swap[0], "used": swap[1]},
|
||||
"disk": map[string]interface{}{"total": disk[0], "used": disk[1]},
|
||||
}
|
||||
return c.postJSON("/api/v1/server/UniProxy/status", payload)
|
||||
}
|
||||
|
||||
// ResetETags clears cached ETags, forcing full responses
|
||||
func (c *Client) ResetETags() {
|
||||
c.configETag = ""
|
||||
c.userETag = ""
|
||||
}
|
||||
|
||||
// postJSON marshals a map payload (with auth fields injected) and POSTs it.
|
||||
// Auth fields are injected before marshalling — single marshal pass.
|
||||
func (c *Client) postJSON(path string, payload map[string]interface{}) error {
|
||||
payload["token"] = c.token
|
||||
payload["node_id"] = c.nodeID
|
||||
if c.nodeType != "" {
|
||||
payload["node_type"] = c.nodeType
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.doRequest("POST", path, body, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("post %s: %w", path, err)
|
||||
}
|
||||
defer drainAndClose(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return fmt.Errorf("status %d: %s", resp.StatusCode, respBody)
|
||||
}
|
||||
c.apiSuccess.Add(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) doRequest(method, path string, body []byte, ifNoneMatch string) (*http.Response, error) {
|
||||
fullURL := c.baseURL + path
|
||||
|
||||
var bodyReader io.Reader
|
||||
if method == "GET" {
|
||||
q := url.Values{}
|
||||
q.Set("token", c.token)
|
||||
q.Set("node_id", strconv.Itoa(c.nodeID))
|
||||
if c.nodeType != "" {
|
||||
q.Set("node_type", c.nodeType)
|
||||
}
|
||||
fullURL += "?" + q.Encode()
|
||||
} else if body != nil {
|
||||
bodyReader = bytes.NewReader(body)
|
||||
} else {
|
||||
authOnly := map[string]interface{}{
|
||||
"token": c.token,
|
||||
"node_id": c.nodeID,
|
||||
}
|
||||
if c.nodeType != "" {
|
||||
authOnly["node_type"] = c.nodeType
|
||||
}
|
||||
merged, _ := json.Marshal(authOnly)
|
||||
bodyReader = bytes.NewReader(merged)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, fullURL, bodyReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if ifNoneMatch != "" {
|
||||
req.Header.Set("If-None-Match", ifNoneMatch)
|
||||
}
|
||||
|
||||
slog.Debug("panel request", "method", method, "path", path)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
c.apiFailure.Add(1)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 400 {
|
||||
c.apiSuccess.Add(1)
|
||||
} else {
|
||||
c.apiFailure.Add(1)
|
||||
}
|
||||
|
||||
slog.Debug("panel response", "method", method, "path", path, "status", resp.StatusCode)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// drainAndClose reads any remaining bytes (up to 512B) and closes the body.
|
||||
// This allows the HTTP transport to reuse the underlying TCP connection.
|
||||
func drainAndClose(body io.ReadCloser) {
|
||||
io.CopyN(io.Discard, body, 512)
|
||||
body.Close()
|
||||
}
|
||||
|
||||
// APIMetrics holds aggregated API call statistics.
|
||||
type APIMetrics struct {
|
||||
Success uint64
|
||||
Failure uint64
|
||||
}
|
||||
|
||||
// SnapshotMetrics returns a snapshot of API metrics.
|
||||
func (c *Client) SnapshotMetrics() APIMetrics {
|
||||
return APIMetrics{
|
||||
Success: c.apiSuccess.Load(),
|
||||
Failure: c.apiFailure.Load(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user