373 lines
9.5 KiB
Go
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(),
|
|
}
|
|
}
|