feat: add standalone controlplane support
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
package singbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
singM "github.com/sagernet/sing/common/metadata"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
func TestSingBoxCapabilities(t *testing.T) {
|
||||
s := New(config.KernelConfig{Type: "sing-box"})
|
||||
caps := s.Capabilities()
|
||||
if !caps.PerUserSpeedLimit || !caps.DeviceLimit || !caps.AliveIPTracking || !caps.ForceCloseUser {
|
||||
t.Fatalf("unexpected sing-box capabilities: %+v", caps)
|
||||
}
|
||||
if caps.BuiltInTrafficStats || caps.ForceCloseConnection {
|
||||
t.Fatalf("unexpected sing-box capabilities: %+v", caps)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
type testConn struct {
|
||||
closed bool
|
||||
reads [][]byte
|
||||
writes [][]byte
|
||||
}
|
||||
|
||||
func (c *testConn) Read(b []byte) (int, error) {
|
||||
if len(c.reads) == 0 {
|
||||
return 0, errors.New("eof")
|
||||
}
|
||||
chunk := c.reads[0]
|
||||
c.reads = c.reads[1:]
|
||||
n := copy(b, chunk)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (c *testConn) Write(b []byte) (int, error) {
|
||||
cp := append([]byte(nil), b...)
|
||||
c.writes = append(c.writes, cp)
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
func (c *testConn) Close() error { c.closed = true; return nil }
|
||||
func (c *testConn) LocalAddr() net.Addr { return &net.TCPAddr{} }
|
||||
func (c *testConn) RemoteAddr() net.Addr { return &net.TCPAddr{} }
|
||||
func (c *testConn) SetDeadline(time.Time) error { return nil }
|
||||
func (c *testConn) SetReadDeadline(time.Time) error { return nil }
|
||||
func (c *testConn) SetWriteDeadline(time.Time) error { return nil }
|
||||
|
||||
func testInboundContext(uuid, ip string) adapter.InboundContext {
|
||||
return adapter.InboundContext{
|
||||
User: uuid,
|
||||
Source: singM.Socksaddr{Addr: netip.MustParseAddr(ip)},
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnTrackerRoutedConnectionTracksTrafficAndAliveIPs(t *testing.T) {
|
||||
tracker := NewConnTracker(0)
|
||||
tracker.SetUserMap(map[string]int{"uuid-1": 1})
|
||||
base := &testConn{reads: [][]byte{[]byte("hello")}}
|
||||
|
||||
wrapped := tracker.RoutedConnection(context.Background(), base, testInboundContext("uuid-1", "1.1.1.1"), nil, nil)
|
||||
|
||||
buf := make([]byte, 16)
|
||||
n, err := wrapped.Read(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("Read() error = %v", err)
|
||||
}
|
||||
if n != 5 {
|
||||
t.Fatalf("Read() bytes = %d, want 5", n)
|
||||
}
|
||||
if _, err := wrapped.Write([]byte("bye")); err != nil {
|
||||
t.Fatalf("Write() error = %v", err)
|
||||
}
|
||||
|
||||
traffic, aliveIPs, connCount := tracker.GetUserTraffic()
|
||||
if got := traffic[1]; got != [2]int64{5, 3} {
|
||||
t.Fatalf("traffic[1] = %v, want [5 3]", got)
|
||||
}
|
||||
if !aliveIPs[1]["1.1.1.1"] {
|
||||
t.Fatal("expected alive IP to include source address")
|
||||
}
|
||||
if connCount != 1 {
|
||||
t.Fatalf("connCount = %d, want 1", connCount)
|
||||
}
|
||||
|
||||
if err := wrapped.Close(); err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
_, aliveIPs, connCount = tracker.GetUserTraffic()
|
||||
if aliveIPs[1] != nil {
|
||||
t.Fatalf("aliveIPs after close = %v, want nil", aliveIPs[1])
|
||||
}
|
||||
if connCount != 0 {
|
||||
t.Fatalf("connCount after close = %d, want 0", connCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnTrackerRoutedConnectionRejectsWhenDeviceLimitExceeded(t *testing.T) {
|
||||
tracker := NewConnTracker(0)
|
||||
tracker.SetUserMap(map[string]int{"uuid-1": 1})
|
||||
tracker.SetDeviceLimitFunc(func(uuid string) (int, bool) {
|
||||
if uuid != "uuid-1" {
|
||||
return 0, false
|
||||
}
|
||||
return 1, true
|
||||
})
|
||||
|
||||
first := &testConn{}
|
||||
wrapped1 := tracker.RoutedConnection(context.Background(), first, testInboundContext("uuid-1", "1.1.1.1"), nil, nil)
|
||||
if wrapped1 == first {
|
||||
t.Fatal("expected first connection to be wrapped")
|
||||
}
|
||||
|
||||
second := &testConn{}
|
||||
wrapped2 := tracker.RoutedConnection(context.Background(), second, testInboundContext("uuid-1", "2.2.2.2"), nil, nil)
|
||||
if wrapped2 != second {
|
||||
t.Fatal("expected rejected connection to be returned unwrapped")
|
||||
}
|
||||
if !second.closed {
|
||||
t.Fatal("expected rejected connection to be closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnTrackerCheckDeviceGateMergesFreshGlobalDevices(t *testing.T) {
|
||||
tracker := NewConnTracker(0)
|
||||
tracker.SetUserMap(map[string]int{"uuid-1": 1})
|
||||
us := tracker.users[1]
|
||||
us.addConn("1.1.1.1")
|
||||
tracker.UpdateGlobalDevices(map[int][]string{1: {"9.9.9.9"}})
|
||||
|
||||
if tracker.checkDeviceGate(us, 1, "2.2.2.2", 2) {
|
||||
t.Fatal("expected lexicographically earlier candidate to remain allowed")
|
||||
}
|
||||
if !tracker.checkDeviceGate(us, 1, "99.99.99.99", 2) {
|
||||
t.Fatal("expected merged local+global device state to reject lexicographically later third device")
|
||||
}
|
||||
if tracker.checkDeviceGate(us, 1, "1.1.1.1", 2) {
|
||||
t.Fatal("existing local IP should still be allowed")
|
||||
}
|
||||
if tracker.checkDeviceGate(us, 1, "9.9.9.9", 2) {
|
||||
t.Fatal("existing global IP should still be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnTrackerCloseByIDClosesTrackedConnection(t *testing.T) {
|
||||
tracker := NewConnTracker(0)
|
||||
tracker.SetUserMap(map[string]int{"uuid-1": 1})
|
||||
base := &testConn{}
|
||||
wrapped := tracker.RoutedConnection(context.Background(), base, testInboundContext("uuid-1", "1.1.1.1"), nil, nil)
|
||||
tracked, ok := wrapped.(*trackedConn)
|
||||
if !ok {
|
||||
t.Fatalf("wrapped type = %T, want *trackedConn", wrapped)
|
||||
}
|
||||
if !tracker.CloseByID(tracked.connID) {
|
||||
t.Fatal("CloseByID() = false, want true")
|
||||
}
|
||||
if !base.closed {
|
||||
t.Fatal("expected underlying connection to be closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnTrackerRateLimitHonorsContextCancellation(t *testing.T) {
|
||||
tracker := NewConnTracker(0)
|
||||
tracker.SetUserMap(map[string]int{"uuid-1": 1})
|
||||
tracker.SetSpeedLimitFunc(func(uuid string) *rate.Limiter {
|
||||
if uuid != "uuid-1" {
|
||||
return nil
|
||||
}
|
||||
return rate.NewLimiter(rate.Limit(1), 1)
|
||||
})
|
||||
|
||||
readCtx, cancelRead := context.WithCancel(context.Background())
|
||||
readBase := &testConn{reads: [][]byte{[]byte("a"), []byte("a")}}
|
||||
readTracked := tracker.RoutedConnection(readCtx, readBase, testInboundContext("uuid-1", "1.1.1.1"), nil, nil).(*trackedConn)
|
||||
buf := make([]byte, 8)
|
||||
if n, err := readTracked.Read(buf); err != nil || n != 1 {
|
||||
t.Fatalf("first Read() = (%d, %v), want (1, nil)", n, err)
|
||||
}
|
||||
cancelRead()
|
||||
if n, err := readTracked.Read(buf); !errors.Is(err, context.Canceled) || n != 1 {
|
||||
t.Fatalf("second Read() = (%d, %v), want (1, context canceled)", n, err)
|
||||
}
|
||||
|
||||
writeCtx, cancelWrite := context.WithCancel(context.Background())
|
||||
writeBase := &testConn{}
|
||||
writeTracked := tracker.RoutedConnection(writeCtx, writeBase, testInboundContext("uuid-1", "1.1.1.2"), nil, nil).(*trackedConn)
|
||||
if n, err := writeTracked.Write([]byte("a")); err != nil || n != 1 {
|
||||
t.Fatalf("first Write() = (%d, %v), want (1, nil)", n, err)
|
||||
}
|
||||
cancelWrite()
|
||||
if n, err := writeTracked.Write([]byte("a")); !errors.Is(err, context.Canceled) || n != 0 {
|
||||
t.Fatalf("second Write() = (%d, %v), want (0, context canceled)", n, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user