Files
xboard-node/internal/panel/client.go
T

373 lines
9.5 KiB
Go

package panel
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/cedar2025/xboard-node/internal/config"
"github.com/go-viper/mapstructure/v2"
)
var (
trafficMapPool = sync.Pool{New: func() interface{} { return make(map[string][2]int64) }}
aliveMapPool = sync.Pool{New: func() interface{} { return make(map[string][]string) }}
onlineMapPool = sync.Pool{New: func() interface{} { return make(map[string]int) }}
)
// 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 := make(map[string]interface{})
if len(traffic) > 0 {
t := trafficMapPool.Get().(map[string][2]int64)
for uid, d := range traffic {
t[strconv.Itoa(uid)] = d
}
payload["traffic"] = t
defer func() {
for k := range t {
delete(t, k)
}
trafficMapPool.Put(t)
}()
}
if len(alive) > 0 {
a := aliveMapPool.Get().(map[string][]string)
for uid, ips := range alive {
a[strconv.Itoa(uid)] = ips
}
payload["alive"] = a
defer func() {
for k := range a {
delete(a, k)
}
aliveMapPool.Put(a)
}()
}
if len(online) > 0 {
o := onlineMapPool.Get().(map[string]int)
for uid, count := range online {
o[strconv.Itoa(uid)] = count
}
payload["online"] = o
defer func() {
for k := range o {
delete(o, k)
}
onlineMapPool.Put(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)
}
// decodeWeakRaw decodes an interface (from JSON map) into a struct using weak type conversion.
func decodeWeakRaw(input interface{}, output interface{}) error {
config := &mapstructure.DecoderConfig{
Metadata: nil,
Result: output,
WeaklyTypedInput: true,
TagName: "json", // Follow JSON tags
}
decoder, err := mapstructure.NewDecoder(config)
if err != nil {
return err
}
return decoder.Decode(input)
}
// 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 raw map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return nil, fmt.Errorf("decode raw config: %w", err)
}
var cfg NodeConfig
// Use mapstructure for weak type conversion (string -> int, bool -> string, etc.)
if err := decodeWeakRaw(raw, &cfg); err != nil {
return nil, fmt.Errorf("weak decode config: %w", err)
}
// Basic validation
if cfg.Protocol == "" {
return nil, fmt.Errorf("invalid config: missing protocol")
}
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(),
}
}