init xboard node

This commit is contained in:
xboard
2026-03-15 09:27:24 +08:00
commit 37e6c016cb
40 changed files with 11011 additions and 0 deletions
+318
View File
@@ -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(),
}
}
+295
View File
@@ -0,0 +1,295 @@
package panel
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/cedar2025/xboard-node/internal/config"
)
func newTestServer(handler http.HandlerFunc) (*httptest.Server, *Client) {
ts := httptest.NewServer(handler)
client := NewClient(config.PanelConfig{
URL: ts.URL,
Token: "test-token",
NodeID: 1,
})
return ts, client
}
func TestGetConfig_Success(t *testing.T) {
ts, client := newTestServer(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/server/UniProxy/config" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
if r.URL.Query().Get("token") != "test-token" {
t.Errorf("missing token in query")
}
if r.URL.Query().Get("node_id") != "1" {
t.Errorf("missing node_id in query")
}
w.Header().Set("ETag", `"etag-1"`)
json.NewEncoder(w).Encode(NodeConfig{
Protocol: "shadowsocks",
ServerPort: 111,
Cipher: "aes-128-gcm",
})
})
defer ts.Close()
cfg, err := client.GetConfig()
if err != nil {
t.Fatalf("GetConfig: %v", err)
}
if cfg == nil {
t.Fatal("expected non-nil config")
}
if cfg.Protocol != "shadowsocks" {
t.Errorf("protocol: got %q", cfg.Protocol)
}
if cfg.ServerPort != 111 {
t.Errorf("server_port: got %d", cfg.ServerPort)
}
}
func TestGetConfig_NotModified(t *testing.T) {
callCount := 0
ts, client := newTestServer(func(w http.ResponseWriter, r *http.Request) {
callCount++
if callCount == 1 {
w.Header().Set("ETag", `"etag-1"`)
json.NewEncoder(w).Encode(NodeConfig{Protocol: "shadowsocks"})
return
}
if r.Header.Get("If-None-Match") != `"etag-1"` {
t.Errorf("expected If-None-Match header, got %q", r.Header.Get("If-None-Match"))
}
w.WriteHeader(http.StatusNotModified)
})
defer ts.Close()
// First call — get config
cfg, err := client.GetConfig()
if err != nil || cfg == nil {
t.Fatalf("first GetConfig: err=%v cfg=%v", err, cfg)
}
// Second call — should be 304
cfg, err = client.GetConfig()
if err != nil {
t.Fatalf("second GetConfig: %v", err)
}
if cfg != nil {
t.Error("expected nil config for 304")
}
}
func TestGetConfig_ServerError(t *testing.T) {
ts, client := newTestServer(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("internal error"))
})
defer ts.Close()
_, err := client.GetConfig()
if err == nil {
t.Fatal("expected error for 500 response")
}
}
func TestGetUsers_Success(t *testing.T) {
ts, client := newTestServer(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/server/UniProxy/user" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
w.Header().Set("ETag", `"users-etag-1"`)
json.NewEncoder(w).Encode(UsersResponse{
Users: []User{
{ID: 1, UUID: "uuid-1", SpeedLimit: 3, DeviceLimit: 2},
{ID: 2, UUID: "uuid-2", SpeedLimit: 0, DeviceLimit: 0},
},
})
})
defer ts.Close()
users, err := client.GetUsers()
if err != nil {
t.Fatalf("GetUsers: %v", err)
}
if len(users) != 2 {
t.Fatalf("users count: got %d", len(users))
}
if users[0].UUID != "uuid-1" {
t.Errorf("user[0].uuid: got %q", users[0].UUID)
}
}
func TestGetUsers_NotModified(t *testing.T) {
callCount := 0
ts, client := newTestServer(func(w http.ResponseWriter, r *http.Request) {
callCount++
if callCount == 1 {
w.Header().Set("ETag", `"u-etag"`)
json.NewEncoder(w).Encode(UsersResponse{Users: []User{{ID: 1, UUID: "u1"}}})
return
}
w.WriteHeader(http.StatusNotModified)
})
defer ts.Close()
users, _ := client.GetUsers()
if len(users) != 1 {
t.Fatalf("first call: got %d users", len(users))
}
users, err := client.GetUsers()
if err != nil {
t.Fatalf("second GetUsers: %v", err)
}
if users != nil {
t.Error("expected nil for 304")
}
}
func TestPushTraffic_Success(t *testing.T) {
var received map[string]interface{}
ts, client := newTestServer(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Errorf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/api/v1/server/UniProxy/push" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
json.NewDecoder(r.Body).Decode(&received)
w.WriteHeader(http.StatusOK)
})
defer ts.Close()
data := map[int][2]int64{
1: {1024, 2048},
2: {512, 256},
}
if err := client.PushTraffic(data); err != nil {
t.Fatalf("PushTraffic: %v", err)
}
if received == nil {
t.Fatal("server received nil payload")
}
// Verify token was injected
if received["token"] != "test-token" {
t.Errorf("token: got %v", received["token"])
}
}
func TestPushTraffic_Empty(t *testing.T) {
called := false
ts, client := newTestServer(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
})
defer ts.Close()
if err := client.PushTraffic(nil); err != nil {
t.Fatalf("PushTraffic nil: %v", err)
}
if called {
t.Error("should not call server for empty traffic")
}
}
func TestPushAlive_Success(t *testing.T) {
var received map[string]interface{}
ts, client := newTestServer(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/server/UniProxy/alive" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
json.NewDecoder(r.Body).Decode(&received)
w.WriteHeader(http.StatusOK)
})
defer ts.Close()
data := map[int][]string{
1: {"1.2.3.4", "5.6.7.8"},
}
if err := client.PushAlive(data); err != nil {
t.Fatalf("PushAlive: %v", err)
}
if received == nil {
t.Fatal("server received nil")
}
}
func TestPushStatus_Success(t *testing.T) {
var received map[string]interface{}
ts, client := newTestServer(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/server/UniProxy/status" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
json.NewDecoder(r.Body).Decode(&received)
w.WriteHeader(http.StatusOK)
})
defer ts.Close()
err := client.PushStatus(50.0, [2]uint64{8192, 4096}, [2]uint64{2048, 1024}, [2]uint64{100000, 50000})
if err != nil {
t.Fatalf("PushStatus: %v", err)
}
if received["cpu"].(float64) != 50.0 {
t.Errorf("cpu: got %v", received["cpu"])
}
}
func TestResetETags(t *testing.T) {
callCount := 0
ts, client := newTestServer(func(w http.ResponseWriter, r *http.Request) {
callCount++
if callCount <= 2 {
w.Header().Set("ETag", `"e1"`)
json.NewEncoder(w).Encode(NodeConfig{Protocol: "ss"})
return
}
// After reset, should NOT send If-None-Match
if r.Header.Get("If-None-Match") != "" {
t.Errorf("expected no If-None-Match after reset, got %q", r.Header.Get("If-None-Match"))
}
json.NewEncoder(w).Encode(NodeConfig{Protocol: "ss"})
})
defer ts.Close()
client.GetConfig()
client.ResetETags()
client.GetConfig()
}
func TestPushTraffic_ServerError(t *testing.T) {
ts, client := newTestServer(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte("unavailable"))
})
defer ts.Close()
data := map[int][2]int64{1: {100, 200}}
err := client.PushTraffic(data)
if err == nil {
t.Fatal("expected error for 503")
}
}
func TestClient_NodeTypeInQuery(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("node_type") != "vmess" {
t.Errorf("node_type: got %q, want vmess", r.URL.Query().Get("node_type"))
}
json.NewEncoder(w).Encode(NodeConfig{Protocol: "vmess"})
}))
defer ts.Close()
client := NewClient(config.PanelConfig{
URL: ts.URL,
Token: "tok",
NodeID: 1,
NodeType: "vmess",
})
client.GetConfig()
}
+159
View File
@@ -0,0 +1,159 @@
package panel
import (
"encoding/json"
"fmt"
"strings"
)
// StringOrArray is a type that can unmarshal from either a JSON string or an array of strings.
// When it's an array, the elements are joined with newlines.
type StringOrArray string
func (s *StringOrArray) UnmarshalJSON(data []byte) error {
var str string
if err := json.Unmarshal(data, &str); err == nil {
*s = StringOrArray(str)
return nil
}
var arr []string
if err := json.Unmarshal(data, &arr); err == nil {
*s = StringOrArray(strings.Join(arr, "\n"))
return nil
}
return fmt.Errorf("StringOrArray: expected string or []string, got %s", string(data))
}
// HandshakeResponse is the response from POST /api/v2/server/handshake
type HandshakeResponse struct {
WebSocket WSConfig `json:"websocket"`
Settings Settings `json:"settings"`
}
func (h *HandshakeResponse) UnmarshalJSON(data []byte) error {
type Alias HandshakeResponse
aux := &struct {
Settings json.RawMessage `json:"settings"`
*Alias
}{
Alias: (*Alias)(h),
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
// If settings is an empty array [], skip unmarshaling it into the struct
if len(aux.Settings) > 0 && string(aux.Settings) != "[]" {
if err := json.Unmarshal(aux.Settings, &h.Settings); err != nil {
return err
}
}
return nil
}
// WSConfig holds WebSocket connection settings from the panel
type WSConfig struct {
Enabled bool `json:"enabled"`
WSURL string `json:"ws_url,omitempty"`
}
// Settings holds panel-defined intervals
type Settings struct {
PushInterval int `json:"push_interval"`
PullInterval int `json:"pull_interval"`
}
// NodeConfig is the response from GET /api/v1/server/UniProxy/config
type NodeConfig struct {
Protocol string `json:"protocol"`
ListenIP string `json:"listen_ip"`
ServerPort int `json:"server_port"`
Network string `json:"network"`
NetworkSettings map[string]interface{} `json:"networkSettings"`
BaseConfig BaseConfig `json:"base_config"`
Routes []RouteRule `json:"routes"`
// Kernel settings (Xboard extension)
KernelType string `json:"kernel_type,omitempty"` // "singbox" or "xray"
KernelLogLevel string `json:"kernel_log_level,omitempty"` // "info", "warn", etc.
CustomOutbounds []OutboundConfig `json:"custom_outbounds,omitempty"`
CustomRoutes []map[string]any `json:"custom_routes,omitempty"`
// Certificate settings (Xboard extension)
CertConfig *CertConfig `json:"cert_config,omitempty"`
AutoTLS bool `json:"auto_tls,omitempty"` // Deprecated: use CertConfig
Domain string `json:"domain,omitempty"` // Deprecated: use CertConfig
// Shadowsocks
Cipher string `json:"cipher,omitempty"`
Plugin string `json:"plugin,omitempty"`
PluginOpt string `json:"plugin_opts,omitempty"`
ServerKey string `json:"server_key,omitempty"`
// VMess / VLESS
TLS int `json:"tls,omitempty"`
Flow string `json:"flow,omitempty"`
TLSSettings map[string]interface{} `json:"tls_settings,omitempty"`
// Trojan
Host string `json:"host,omitempty"`
ServerName string `json:"server_name,omitempty"`
// Hysteria
Version int `json:"version,omitempty"`
UpMbps int `json:"up_mbps,omitempty"`
DownMbps int `json:"down_mbps,omitempty"`
Obfs string `json:"obfs,omitempty"`
ObfsPassword string `json:"obfs-password,omitempty"`
// TUIC
CongestionControl string `json:"congestion_control,omitempty"`
// AnyTLS
PaddingScheme StringOrArray `json:"padding_scheme,omitempty"`
}
// CertConfig holds certificate automation settings from the panel
type CertConfig struct {
CertMode string `json:"cert_mode"` // none, dns, http, self, file
Domain string `json:"domain"` // Certificate domain
Email string `json:"email"` // ACME email
DNSProvider string `json:"dns_provider"` // dns mode: cloudflare, alidns, etc.
DNSEnv map[string]string `json:"dns_env"` // Provider-specific API keys/tokens
HTTPPort int `json:"http_port"` // ACME HTTP-01 local port (default 80)
CertFile string `json:"cert_file"` // file mode: path to cert file
KeyFile string `json:"key_file"` // file mode: path to key file
CertContent string `json:"cert_content"` // content mode: certificate raw string
KeyContent string `json:"key_content"` // content mode: private key raw string
}
// OutboundConfig defines a custom outbound for kernel
type OutboundConfig struct {
Tag string `json:"tag"` // Unique tag for routing
Protocol string `json:"protocol"` // vmess, vless, shadowsocks, wireguard, etc.
Settings map[string]any `json:"settings,omitempty"` // Protocol-specific settings
ProxyTag string `json:"proxy_tag,omitempty"` // Chain proxy: next outbound tag
}
type BaseConfig struct {
PushInterval int `json:"push_interval"`
PullInterval int `json:"pull_interval"`
}
type RouteRule struct {
ID int `json:"id"`
Match []string `json:"match"`
Action string `json:"action"`
ActionValue string `json:"action_value,omitempty"`
}
// User represents a user returned by the panel
type User struct {
ID int `json:"id"`
UUID string `json:"uuid"`
SpeedLimit int `json:"speed_limit"` // Mbps, 0 = unlimited
DeviceLimit int `json:"device_limit"` // max devices, 0 = unlimited
}
type UsersResponse struct {
Users []User `json:"users"`
}
+140
View File
@@ -0,0 +1,140 @@
package panel
import (
"encoding/json"
"testing"
)
func TestStringOrArray_UnmarshalString(t *testing.T) {
input := `"hello world"`
var s StringOrArray
if err := json.Unmarshal([]byte(input), &s); err != nil {
t.Fatalf("unmarshal string: %v", err)
}
if string(s) != "hello world" {
t.Errorf("got %q, want %q", s, "hello world")
}
}
func TestStringOrArray_UnmarshalArray(t *testing.T) {
input := `["stop=8","0=30-30","1=100-400"]`
var s StringOrArray
if err := json.Unmarshal([]byte(input), &s); err != nil {
t.Fatalf("unmarshal array: %v", err)
}
want := "stop=8\n0=30-30\n1=100-400"
if string(s) != want {
t.Errorf("got %q, want %q", s, want)
}
}
func TestStringOrArray_UnmarshalEmptyArray(t *testing.T) {
input := `[]`
var s StringOrArray
if err := json.Unmarshal([]byte(input), &s); err != nil {
t.Fatalf("unmarshal empty array: %v", err)
}
if string(s) != "" {
t.Errorf("got %q, want empty", s)
}
}
func TestStringOrArray_UnmarshalNull(t *testing.T) {
input := `null`
var s StringOrArray
if err := json.Unmarshal([]byte(input), &s); err != nil {
t.Fatalf("unmarshal null: %v", err)
}
if string(s) != "" {
t.Errorf("got %q, want empty", s)
}
}
func TestNodeConfig_UnmarshalWithPaddingSchemeArray(t *testing.T) {
input := `{
"protocol": "anytls",
"server_port": 443,
"padding_scheme": ["stop=8", "0=30-30", "1=100-400"]
}`
var nc NodeConfig
if err := json.Unmarshal([]byte(input), &nc); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if nc.Protocol != "anytls" {
t.Errorf("protocol: got %q, want %q", nc.Protocol, "anytls")
}
want := "stop=8\n0=30-30\n1=100-400"
if string(nc.PaddingScheme) != want {
t.Errorf("padding_scheme: got %q, want %q", nc.PaddingScheme, want)
}
}
func TestNodeConfig_UnmarshalWithPaddingSchemeString(t *testing.T) {
input := `{
"protocol": "anytls",
"server_port": 443,
"padding_scheme": "stop=8\n0=30-30"
}`
var nc NodeConfig
if err := json.Unmarshal([]byte(input), &nc); err != nil {
t.Fatalf("unmarshal: %v", err)
}
want := "stop=8\n0=30-30"
if string(nc.PaddingScheme) != want {
t.Errorf("padding_scheme: got %q, want %q", nc.PaddingScheme, want)
}
}
func TestNodeConfig_UnmarshalFullPanelResponse(t *testing.T) {
input := `{
"protocol": "shadowsocks",
"listen_ip": "0.0.0.0",
"server_port": 111,
"cipher": "aes-128-gcm",
"network": null,
"networkSettings": null,
"server_name": "",
"base_config": {
"push_interval": 60,
"pull_interval": 60
}
}`
var nc NodeConfig
if err := json.Unmarshal([]byte(input), &nc); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if nc.Protocol != "shadowsocks" {
t.Errorf("protocol: got %q", nc.Protocol)
}
if nc.ServerPort != 111 {
t.Errorf("server_port: got %d", nc.ServerPort)
}
if nc.Cipher != "aes-128-gcm" {
t.Errorf("cipher: got %q", nc.Cipher)
}
if nc.BaseConfig.PushInterval != 60 {
t.Errorf("push_interval: got %d", nc.BaseConfig.PushInterval)
}
}
func TestUsersResponse_Unmarshal(t *testing.T) {
input := `{
"users": [
{"id": 1, "uuid": "abc-123", "speed_limit": 3, "device_limit": 2},
{"id": 2, "uuid": "def-456", "speed_limit": 0, "device_limit": 0}
]
}`
var resp UsersResponse
if err := json.Unmarshal([]byte(input), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(resp.Users) != 2 {
t.Fatalf("users count: got %d, want 2", len(resp.Users))
}
if resp.Users[0].SpeedLimit != 3 {
t.Errorf("user[0].speed_limit: got %d", resp.Users[0].SpeedLimit)
}
if resp.Users[1].DeviceLimit != 0 {
t.Errorf("user[1].device_limit: got %d", resp.Users[1].DeviceLimit)
}
}
+302
View File
@@ -0,0 +1,302 @@
package panel
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/url"
"strconv"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
)
// WSEvent types
const (
WSEventSyncConfig = "sync.config"
WSEventSyncUsers = "sync.users"
WSEventSyncUserDelta = "sync.user.delta"
)
// WSEvent is a parsed data event delivered to the service layer.
type WSEvent struct {
Type string
Config *NodeConfig
Users []User
DeltaAction string // "add" or "remove" (only for sync.user.delta)
DeltaUsers []User // users affected by the delta
}
// WSStatusChange notifies the service when WS connectivity changes.
type WSStatusChange struct {
Connected bool
}
// wsMessage is the JSON envelope for all WS messages.
type wsMessage struct {
Event string `json:"event"`
Data json.RawMessage `json:"data,omitempty"`
Timestamp int64 `json:"timestamp,omitempty"`
}
// Payload structures for data events
type syncConfigPayload struct {
Config NodeConfig `json:"config"`
Timestamp int64 `json:"timestamp"`
}
type syncUsersPayload struct {
Users []User `json:"users"`
Timestamp int64 `json:"timestamp"`
}
type syncUserDeltaPayload struct {
Action string `json:"action"`
Users []User `json:"users"`
Timestamp int64 `json:"timestamp"`
}
// WSClient connects to the panel's Workerman WS server using native WebSocket.
// Authentication is done via query parameters (token + node_id) during the
// WebSocket handshake — no separate auth step needed.
type WSClient struct {
wsURL string // base WS URL, e.g. ws://panel.example.com:8076
token string
nodeID int
onEvent func(WSEvent)
onStatus func(WSStatusChange)
connected atomic.Bool
}
// NewWSClient creates a new WebSocket client.
// wsURL is the base WebSocket URL (e.g. "ws://panel.example.com:8076").
// token and nodeID are used for authentication via query parameters.
func NewWSClient(wsURL string, token string, nodeID int, onEvent func(WSEvent), onStatus func(WSStatusChange)) *WSClient {
return &WSClient{
wsURL: wsURL,
token: token,
nodeID: nodeID,
onEvent: onEvent,
onStatus: onStatus,
}
}
func (w *WSClient) IsConnected() bool { return w.connected.Load() }
func (w *WSClient) notifyStatus(connected bool) {
if w.onStatus != nil {
w.onStatus(WSStatusChange{Connected: connected})
}
}
// Run connects and reconnects until ctx is cancelled.
func (w *WSClient) Run(ctx context.Context) {
backoff := time.Second
for {
start := time.Now()
err := w.connect(ctx)
wasConnected := w.connected.Swap(false)
if wasConnected {
w.notifyStatus(false)
}
if err != nil {
slog.Warn("ws disconnected", "error", err)
if !wasConnected {
w.notifyStatus(false)
}
}
select {
case <-ctx.Done():
return
default:
}
// Reset backoff if the connection was up for a meaningful duration.
if time.Since(start) > 2*time.Minute {
backoff = time.Second
}
timer := time.NewTimer(backoff)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
if backoff < 30*time.Second {
backoff = min(backoff*2, 30*time.Second)
}
}
}
func (w *WSClient) connect(ctx context.Context) error {
// Build WS URL with auth query params
u, err := url.Parse(w.wsURL)
if err != nil {
return fmt.Errorf("parse ws url: %w", err)
}
q := u.Query()
q.Set("token", w.token)
q.Set("node_id", strconv.Itoa(w.nodeID))
u.RawQuery = q.Encode()
slog.Debug("ws connecting", "url", u.Redacted())
dialer := websocket.Dialer{
HandshakeTimeout: 15 * time.Second,
}
conn, _, err := dialer.DialContext(ctx, u.String(), nil)
if err != nil {
return fmt.Errorf("dial: %w", err)
}
defer conn.Close()
conn.SetReadLimit(10 << 20) // 10MB max message size
// Read first message — expect auth.success or error
var firstMsg wsMessage
if err := conn.ReadJSON(&firstMsg); err != nil {
return fmt.Errorf("read auth response: %w", err)
}
if firstMsg.Event == "error" {
var errData struct {
Message string `json:"message"`
}
json.Unmarshal(firstMsg.Data, &errData)
return fmt.Errorf("auth failed: %s", errData.Message)
}
if firstMsg.Event != "auth.success" {
// It might be a data event already (server pushed sync before auth.success)
// Process it and continue
w.connected.Store(true)
w.notifyStatus(true)
slog.Info("ws connected (implicit auth)")
w.handleMessage(firstMsg)
} else {
w.connected.Store(true)
w.notifyStatus(true)
slog.Info("ws connected and authenticated")
}
// Ping interval: send pong responses to server pings
const pingInterval = 50 * time.Second
msgCh := make(chan wsMessage, 16)
errCh := make(chan error, 1)
done := make(chan struct{})
go func() {
defer close(done)
for {
var msg wsMessage
if err := conn.ReadJSON(&msg); err != nil {
select {
case errCh <- err:
default:
}
return
}
select {
case msgCh <- msg:
case <-ctx.Done():
return
}
}
}()
pingTimer := time.NewTimer(pingInterval)
defer pingTimer.Stop()
for {
select {
case <-ctx.Done():
conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
conn.WriteMessage(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
<-done
return nil
case err := <-errCh:
return fmt.Errorf("read: %w", err)
case msg := <-msgCh:
pingTimer.Reset(pingInterval)
w.handleMessage(msg)
case <-pingTimer.C:
// Send pong to keep connection alive
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := conn.WriteJSON(wsMessage{Event: "pong"}); err != nil {
return fmt.Errorf("pong: %w", err)
}
pingTimer.Reset(pingInterval)
}
}
}
func (w *WSClient) handleMessage(msg wsMessage) {
switch msg.Event {
case "ping":
// Server ping — handled by the pong timer reset above
slog.Debug("ws received ping")
case "auth.success":
slog.Debug("ws auth confirmed")
case WSEventSyncConfig:
w.handleDataEvent(msg)
case WSEventSyncUsers:
w.handleDataEvent(msg)
case WSEventSyncUserDelta:
w.handleDataEvent(msg)
default:
slog.Debug("ws unknown event", "event", msg.Event)
}
}
func (w *WSClient) handleDataEvent(msg wsMessage) {
var event WSEvent
event.Type = msg.Event
switch msg.Event {
case WSEventSyncConfig:
slog.Info("ws sync config event received")
var p syncConfigPayload
if err := json.Unmarshal(msg.Data, &p); err != nil {
slog.Warn("ws: cannot decode config payload", "error", err)
return
}
event.Config = &p.Config
case WSEventSyncUsers:
slog.Info("ws sync users event received")
var p syncUsersPayload
if err := json.Unmarshal(msg.Data, &p); err != nil {
slog.Warn("ws: cannot decode users payload", "error", err)
return
}
event.Users = p.Users
case WSEventSyncUserDelta:
slog.Info("ws sync user delta event received")
var p syncUserDeltaPayload
if err := json.Unmarshal(msg.Data, &p); err != nil {
slog.Warn("ws: cannot decode user delta payload", "error", err)
return
}
event.DeltaAction = p.Action
event.DeltaUsers = p.Users
}
w.onEvent(event)
}
+291
View File
@@ -0,0 +1,291 @@
package panel
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
)
// fakeWSServer simulates a minimal Workerman-style WS server for testing.
// It authenticates via token/node_id query params, sends auth.success, then
// delivers the provided events and handles pongs until the client disconnects.
func fakeWSServer(t *testing.T, events []wsMessage) *httptest.Server {
t.Helper()
upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Validate query params
q := r.URL.Query()
if q.Get("token") == "" || q.Get("node_id") == "" {
http.Error(w, "missing auth params", http.StatusUnauthorized)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
t.Logf("upgrade error: %v", err)
return
}
defer conn.Close()
// Send auth.success
if err := conn.WriteJSON(wsMessage{Event: "auth.success"}); err != nil {
return
}
// Send test events
for _, evt := range events {
time.Sleep(50 * time.Millisecond)
if err := conn.WriteJSON(evt); err != nil {
return
}
}
// Handle pongs/messages until connection closes
for {
var msg wsMessage
if err := conn.ReadJSON(&msg); err != nil {
return
}
if msg.Event == "pong" {
// Client pong — acknowledged
}
}
}))
}
func TestWSClient_ConnectAndReceiveDataEvents(t *testing.T) {
configPayload := syncConfigPayload{
Config: NodeConfig{Protocol: "vless", ServerPort: 443},
Timestamp: 1234,
}
usersPayload := syncUsersPayload{
Users: []User{{ID: 1, UUID: "abc", SpeedLimit: 100, DeviceLimit: 3}},
Timestamp: 1235,
}
configData, _ := json.Marshal(configPayload)
usersData, _ := json.Marshal(usersPayload)
events := []wsMessage{
{Event: WSEventSyncConfig, Data: configData},
{Event: WSEventSyncUsers, Data: usersData},
}
server := fakeWSServer(t, events)
defer server.Close()
host := strings.TrimPrefix(server.URL, "http://")
var mu sync.Mutex
var received []WSEvent
ws := NewWSClient("ws://"+host, "test-token", 1, func(event WSEvent) {
mu.Lock()
received = append(received, event)
mu.Unlock()
}, nil)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
go ws.Run(ctx)
// Wait for events
deadline := time.After(1500 * time.Millisecond)
for {
mu.Lock()
n := len(received)
mu.Unlock()
if n >= 2 {
break
}
select {
case <-deadline:
t.Fatalf("timed out waiting for events, got %d", n)
case <-time.After(50 * time.Millisecond):
}
}
mu.Lock()
defer mu.Unlock()
if len(received) != 2 {
t.Fatalf("expected 2 events, got %d", len(received))
}
// Verify config event
if received[0].Type != WSEventSyncConfig {
t.Errorf("event[0].Type = %q, want %q", received[0].Type, WSEventSyncConfig)
}
if received[0].Config == nil {
t.Fatal("event[0].Config is nil")
}
if received[0].Config.Protocol != "vless" {
t.Errorf("config.Protocol = %q, want %q", received[0].Config.Protocol, "vless")
}
if received[0].Config.ServerPort != 443 {
t.Errorf("config.ServerPort = %d, want 443", received[0].Config.ServerPort)
}
// Verify users event
if received[1].Type != WSEventSyncUsers {
t.Errorf("event[1].Type = %q, want %q", received[1].Type, WSEventSyncUsers)
}
if len(received[1].Users) != 1 {
t.Fatalf("expected 1 user, got %d", len(received[1].Users))
}
if received[1].Users[0].UUID != "abc" {
t.Errorf("user.UUID = %q, want %q", received[1].Users[0].UUID, "abc")
}
if !ws.IsConnected() {
t.Error("expected IsConnected() = true while server is running")
}
}
func TestWSClient_ReconnectOnDisconnect(t *testing.T) {
upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
var mu sync.Mutex
connectCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
mu.Lock()
connectCount++
count := connectCount
mu.Unlock()
// Send auth.success
conn.WriteJSON(wsMessage{Event: "auth.success"})
// Close immediately on first connection to trigger reconnect
if count == 1 {
conn.Close()
return
}
// Keep second connection alive
for {
var msg wsMessage
if err := conn.ReadJSON(&msg); err != nil {
return
}
}
}))
defer server.Close()
host := strings.TrimPrefix(server.URL, "http://")
ws := NewWSClient("ws://"+host, "reconnect-token", 1, func(WSEvent) {}, nil)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
go ws.Run(ctx)
// Wait for reconnection
deadline := time.After(4 * time.Second)
for {
mu.Lock()
n := connectCount
mu.Unlock()
if n >= 2 {
break
}
select {
case <-deadline:
mu.Lock()
t.Fatalf("expected at least 2 connections, got %d", connectCount)
mu.Unlock()
case <-time.After(100 * time.Millisecond):
}
}
if !ws.IsConnected() {
t.Error("expected IsConnected() = true after reconnect")
}
}
func TestWSClient_FallbackWhenNoServer(t *testing.T) {
ws := NewWSClient("ws://127.0.0.1:19999", "fallback-token", 1, func(WSEvent) {}, nil)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
go ws.Run(ctx)
time.Sleep(500 * time.Millisecond)
if ws.IsConnected() {
t.Error("expected IsConnected() = false when no server")
}
}
func TestWSClient_UserDeltaEvent(t *testing.T) {
deltaPayload := syncUserDeltaPayload{
Action: "add",
Users: []User{{ID: 42, UUID: "delta-uuid"}},
Timestamp: 1236,
}
deltaData, _ := json.Marshal(deltaPayload)
events := []wsMessage{
{Event: WSEventSyncUserDelta, Data: deltaData},
}
server := fakeWSServer(t, events)
defer server.Close()
host := strings.TrimPrefix(server.URL, "http://")
var mu sync.Mutex
var received []WSEvent
ws := NewWSClient("ws://"+host, "delta-token", 1, func(event WSEvent) {
mu.Lock()
received = append(received, event)
mu.Unlock()
}, nil)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
go ws.Run(ctx)
deadline := time.After(1500 * time.Millisecond)
for {
mu.Lock()
n := len(received)
mu.Unlock()
if n >= 1 {
break
}
select {
case <-deadline:
t.Fatal("timed out waiting for delta event")
case <-time.After(50 * time.Millisecond):
}
}
mu.Lock()
defer mu.Unlock()
if received[0].Type != WSEventSyncUserDelta {
t.Errorf("event.Type = %q, want %q", received[0].Type, WSEventSyncUserDelta)
}
if received[0].DeltaAction != "add" {
t.Errorf("event.DeltaAction = %q, want %q", received[0].DeltaAction, "add")
}
if len(received[0].DeltaUsers) != 1 || received[0].DeltaUsers[0].ID != 42 {
t.Errorf("unexpected DeltaUsers: %+v", received[0].DeltaUsers)
}
}