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(),
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user