mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-13 08:35:00 +03:00
feat(tuic): implement native in-process Go TUIC v5 server (#6577)
* feat(tuic): implement native in-process Go TUIC v5 server - Implement native TUIC v5 protocol server on pure Go using quic-go - Bridge decrypted TCP/UDP traffic into Xray-core via loopback SOCKS5 inbound - Support full Xray routing rules (geosite/geoip) and cascading outbounds - Implement atomic per-client traffic accounting with TotalGB and ExpiryTime - Add automatic legacy cleanup for older Rust tuic-server binaries, configs, and orphaned processes - Eliminate external Rust tuic-server downloads from install/CI scripts * fix(tuic): address traffic accounting, client reload, and socket lifecycle issues * fix(service): update checkTuicSocksReverseConflict to use bindAddr for listenOverlaps * fix(tuic): resolve traffic double-accounting, UDP fragmentation, and socket lifecycle issues * feat(tuic): complete native Go integration and address audit findings - Integrate an isolated QUIC fork pinned to a specific commit - Preserve original QUIC dependencies for Xray, Hysteria and Gin - Apply BBR, CUBIC and Reno to server connections and exported client profiles - Bridge Xray BBR with correct monotonic time and congestion type conversions - Handle congestion sender recreation after PMTU changes - Update congestion control for new connections without restarting the listener - Preserve existing connections and their selected congestion controller - Apply per-inbound log levels through the shared panel logger - Add lifecycle, authentication and TCP/UDP relay events without exposing secrets - Rate-limit repeated authentication and relay warnings - Support native and QUIC UDP relay modes on the same listener - Recover UDP associations after relay worker failures - Fix TCP relay cancellation, idle shutdown and half-close handling - Close active sessions when client credentials are revoked or disabled - Track traffic by immutable client statistics IDs across email and UUID changes - Prevent ambiguous accounting and duplicate UUIDs within TUIC inbounds - Persist pending traffic in a durable shutdown journal - Replay journal batches transactionally without duplicate accounting - Report server shutdown failures through the shared logger - Preserve legacy flat and nested TUIC settings compatibility - Normalize congestion controller values consistently across backend and frontend - Preserve controller, UDP mode and SNI in client links and subscriptions - Separate client profile options from server settings in the TUIC form - Keep certificate path autofill explicit when changing client SNI - Align UDP packet size validation with protocol limits - Simplify and localize TUIC field hints and certificate autofill messages - Add controller, TCP/UDP, logging and live settings update tests - Add accounting identity, journal replay and shutdown regression tests - Add relay recovery, session revocation and legacy frontend form tests * fix(service): alias the TUIC duplicate-UUID subquery for PostgreSQL < 16 syncInboundClients runs a COUNT(*) FROM (subquery) for every client sync, whatever the protocol. PostgreSQL before 16 rejects a FROM subquery with no alias, so on the distro PostgreSQL install.sh provisions (14 on Ubuntu 22.04, 15 on Debian 12) every client add or edit failed with SQLSTATE 42601. Reproduced against postgres:15 with the new env-gated test. * fix(database): create tuic_traffic_receipts through the model migration AddTuicTrafficBatch issued CREATE TABLE IF NOT EXISTS at runtime, a schema change outside db.go. The table was invisible to allModels and migrationModels, so x-ui migrate-db dropped the receipts and a retained journal could be counted twice after a SQLite to PostgreSQL move. It is now a GORM model in both lists, and the insert uses OnConflict DoNothing. * chore(tuic): skip the ICMP-dependent relay test on Windows, drop dead collectors Go disables SIO_UDP_CONNRESET on Windows, so a dead UDP bridge never fails a read there and TestAudit3UDPAssociationMustRecoverAfterBridgeReadFailure was red on every Windows run. Server.CollectTotalTraffic and Manager.CollectTraffic had no caller. * refactor(tuic): serve TUIC on apernet/quic-go instead of a personal fork The native server depended on github.com/poise52/quic-go, a personal fork of apernet/quic-go patched only to pick the congestion controller before the handshake. That put a second QUIC/TLS stack in the binary that no upstream security fix reaches. apernet/quic-go is already in the graph through xray-core and exposes SetCongestionControl, so BBR is now installed on each accepted connection with Xray's own congestion.UseBBR; the cross-module BBR adapter is gone. apernet ships New Reno as its only built-in sender, so a cubic setting is served as new_reno server-side (clients still get cubic in their profile). The test inspectors now read the sender under congestionMutex, which the post-handshake install writes under. Linux loopback, single stream through Xray: 2428 -> 3383 Mbit/s (bbr). --------- Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
package tuic
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUserNotFound = errors.New("tuic: user not found")
|
||||
ErrAuthFailed = errors.New("tuic: authentication failed")
|
||||
ErrInvalidTLSState = errors.New("tuic: TLS connection state not available")
|
||||
)
|
||||
|
||||
// User represents a configured TUIC client for authentication and billing.
|
||||
type User struct {
|
||||
TrafficID int
|
||||
UUID [16]byte
|
||||
UUIDStr string
|
||||
Password string
|
||||
Email string
|
||||
Traffic *UserTraffic
|
||||
sessions atomic.Int64
|
||||
}
|
||||
|
||||
type UserTraffic struct {
|
||||
BytesUp atomic.Int64
|
||||
BytesDown atomic.Int64
|
||||
}
|
||||
|
||||
// UserRegistry is a thread-safe registry of TUIC users for an inbound.
|
||||
type UserRegistry struct {
|
||||
mu sync.RWMutex
|
||||
users map[[16]byte]*User
|
||||
retired map[*User]struct{}
|
||||
}
|
||||
|
||||
// NewUserRegistry creates an empty UserRegistry.
|
||||
func NewUserRegistry() *UserRegistry {
|
||||
return &UserRegistry{
|
||||
users: make(map[[16]byte]*User),
|
||||
retired: make(map[*User]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// SetUsers updates the user list atomically in memory, preserving counters and
|
||||
// pointer stability for active sessions. It returns any users removed from the registry.
|
||||
func (ur *UserRegistry) SetUsers(clients []TuicClientSettings) (revoked []*User) {
|
||||
ur.mu.Lock()
|
||||
defer ur.mu.Unlock()
|
||||
|
||||
newMap := make(map[[16]byte]*User, len(clients))
|
||||
for _, c := range clients {
|
||||
parsed, err := uuid.Parse(c.UUID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if existing, ok := ur.users[parsed]; ok && existing.Password == c.Password && existing.Email == c.Email && existing.TrafficID == c.TrafficID {
|
||||
newMap[parsed] = existing
|
||||
} else if ok {
|
||||
ur.retired[existing] = struct{}{}
|
||||
revoked = append(revoked, existing)
|
||||
newMap[parsed] = newUser(parsed, c)
|
||||
} else {
|
||||
newMap[parsed] = newUser(parsed, c)
|
||||
}
|
||||
}
|
||||
for id, oldUser := range ur.users {
|
||||
if _, ok := newMap[id]; !ok {
|
||||
revoked = append(revoked, oldUser)
|
||||
ur.retired[oldUser] = struct{}{}
|
||||
}
|
||||
}
|
||||
ur.users = newMap
|
||||
return revoked
|
||||
}
|
||||
|
||||
func newUser(id [16]byte, c TuicClientSettings) *User {
|
||||
return &User{
|
||||
TrafficID: c.TrafficID, UUID: id, UUIDStr: uuid.UUID(id).String(), Password: c.Password, Email: c.Email,
|
||||
Traffic: &UserTraffic{},
|
||||
}
|
||||
}
|
||||
|
||||
// AddTestTraffic adds traffic counters to a user by email for testing purposes.
|
||||
func (ur *UserRegistry) AddTestTraffic(email string, up, down int64) bool {
|
||||
ur.mu.RLock()
|
||||
defer ur.mu.RUnlock()
|
||||
for _, u := range ur.users {
|
||||
if u.Email == email {
|
||||
u.Traffic.BytesUp.Add(up)
|
||||
u.Traffic.BytesDown.Add(down)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ClientTrafficDelta represents the traffic delta for a user.
|
||||
type ClientTrafficDelta struct {
|
||||
TrafficID int
|
||||
Email string
|
||||
UUID string
|
||||
InboundID int
|
||||
Up int64
|
||||
Down int64
|
||||
}
|
||||
|
||||
// CollectTrafficDeltas drains and returns byte deltas for all users since the last call.
|
||||
func (ur *UserRegistry) CollectTrafficDeltas() []ClientTrafficDelta {
|
||||
ur.mu.Lock()
|
||||
defer ur.mu.Unlock()
|
||||
|
||||
var deltas []ClientTrafficDelta
|
||||
collect := func(u *User, retired bool) {
|
||||
up := u.Traffic.BytesUp.Swap(0)
|
||||
down := u.Traffic.BytesDown.Swap(0)
|
||||
if up > 0 || down > 0 {
|
||||
deltas = append(deltas, ClientTrafficDelta{
|
||||
TrafficID: u.TrafficID,
|
||||
Email: u.Email,
|
||||
UUID: u.UUIDStr,
|
||||
Up: up,
|
||||
Down: down,
|
||||
})
|
||||
}
|
||||
if retired && u.sessions.Load() == 0 {
|
||||
delete(ur.retired, u)
|
||||
}
|
||||
}
|
||||
for _, u := range ur.users {
|
||||
collect(u, false)
|
||||
}
|
||||
for u := range ur.retired {
|
||||
collect(u, true)
|
||||
}
|
||||
return deltas
|
||||
}
|
||||
|
||||
func (ur *UserRegistry) sessionEnded(user *User) {
|
||||
if user != nil {
|
||||
user.sessions.Add(-1)
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate verifies the client's token using RFC 5705 Keying Material Exporter.
|
||||
// According to TUIC v5 specification:
|
||||
// - label: client UUID
|
||||
// - context: raw password
|
||||
// - length: 32 bytes
|
||||
func (ur *UserRegistry) Authenticate(cs *tls.ConnectionState, rawUUID [16]byte, token [32]byte) (*User, error) {
|
||||
return ur.authenticate(cs, rawUUID, token, nil)
|
||||
}
|
||||
|
||||
// AuthenticateAndRegister holds the registry read lock through connection
|
||||
// registration, making successful authentication atomic with user revocation.
|
||||
func (ur *UserRegistry) AuthenticateAndRegister(cs *tls.ConnectionState, rawUUID [16]byte, token [32]byte, register func(*User) bool) (*User, error) {
|
||||
return ur.authenticate(cs, rawUUID, token, register)
|
||||
}
|
||||
|
||||
func (ur *UserRegistry) authenticate(cs *tls.ConnectionState, rawUUID [16]byte, token [32]byte, register func(*User) bool) (*User, error) {
|
||||
if cs == nil {
|
||||
return nil, ErrInvalidTLSState
|
||||
}
|
||||
|
||||
ur.mu.RLock()
|
||||
defer ur.mu.RUnlock()
|
||||
user, exists := ur.users[rawUUID]
|
||||
|
||||
if !exists {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
if !cs.HandshakeComplete {
|
||||
return nil, ErrInvalidTLSState
|
||||
}
|
||||
|
||||
// Try with raw 16-byte UUID as label
|
||||
expectedToken, err := cs.ExportKeyingMaterial(string(rawUUID[:]), []byte(user.Password), 32)
|
||||
if err == nil && subtle.ConstantTimeCompare(token[:], expectedToken) == 1 {
|
||||
if register != nil && !register(user) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Fallback to formatted 36-char string representation of UUID as label
|
||||
expectedTokenStr, errStr := cs.ExportKeyingMaterial(user.UUIDStr, []byte(user.Password), 32)
|
||||
if errStr == nil && subtle.ConstantTimeCompare(token[:], expectedTokenStr) == 1 {
|
||||
if register != nil && !register(user) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
if err != nil && errStr != nil {
|
||||
return nil, fmt.Errorf("%w: export keying material: %w", ErrAuthFailed, err)
|
||||
}
|
||||
|
||||
return nil, ErrAuthFailed
|
||||
}
|
||||
|
||||
func ValidateClients(clients []TuicClientSettings) error {
|
||||
seen := make(map[uuid.UUID]bool, len(clients))
|
||||
for _, client := range clients {
|
||||
id, err := uuid.Parse(client.UUID)
|
||||
if err != nil {
|
||||
return errors.New("tuic: invalid client UUID")
|
||||
}
|
||||
if seen[id] {
|
||||
return errors.New("tuic: duplicate client UUID")
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package tuic
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestUserRegistryBasic(t *testing.T) {
|
||||
reg := NewUserRegistry()
|
||||
testUUID := uuid.New()
|
||||
|
||||
reg.SetUsers([]TuicClientSettings{
|
||||
{
|
||||
UUID: testUUID.String(),
|
||||
Password: "supersecretpassword",
|
||||
Email: "test@example.com",
|
||||
},
|
||||
})
|
||||
|
||||
var fakeUUID [16]byte
|
||||
copy(fakeUUID[:], testUUID[:])
|
||||
|
||||
var unknownUUID [16]byte
|
||||
_, _ = rand.Read(unknownUUID[:])
|
||||
_, err := reg.Authenticate(&tls.ConnectionState{}, unknownUUID, [32]byte{})
|
||||
if !errors.Is(err, ErrUserNotFound) {
|
||||
t.Fatalf("expected ErrUserNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRegistryCredentialUpdatesKeepOldCounters(t *testing.T) {
|
||||
reg := NewUserRegistry()
|
||||
u1 := uuid.New().String()
|
||||
u2 := uuid.New().String()
|
||||
|
||||
reg.SetUsers([]TuicClientSettings{
|
||||
{UUID: u1, Password: "pass1", Email: "u1@example.com"},
|
||||
{UUID: u2, Password: "pass2", Email: "u2@example.com"},
|
||||
})
|
||||
|
||||
reg.AddTestTraffic("u1@example.com", 100, 200)
|
||||
|
||||
parsedU1, _ := uuid.Parse(u1)
|
||||
user1Before := reg.users[parsedU1]
|
||||
if user1Before == nil {
|
||||
t.Fatalf("expected user1 in registry")
|
||||
}
|
||||
|
||||
revoked := reg.SetUsers([]TuicClientSettings{
|
||||
{UUID: u1, Password: "newpassword", Email: "u1-new@example.com"},
|
||||
{UUID: u2, Password: "pass2", Email: "u2@example.com"},
|
||||
})
|
||||
|
||||
if len(revoked) != 1 || revoked[0] != user1Before {
|
||||
t.Fatalf("expected changed user snapshot to be retired, got %+v", revoked)
|
||||
}
|
||||
|
||||
user1After := reg.users[parsedU1]
|
||||
if user1Before == user1After {
|
||||
t.Fatal("expected immutable user snapshot to be replaced")
|
||||
}
|
||||
if user1After.Password != "newpassword" || user1After.Email != "u1-new@example.com" {
|
||||
t.Fatalf("expected updated password and email, got %s, %s", user1After.Password, user1After.Email)
|
||||
}
|
||||
|
||||
deltas := reg.CollectTrafficDeltas()
|
||||
if len(deltas) != 1 || deltas[0].Email != "u1@example.com" || deltas[0].Up != 100 || deltas[0].Down != 200 {
|
||||
t.Fatalf("expected preserved traffic deltas, got %+v", deltas)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRegistryRevocation(t *testing.T) {
|
||||
reg := NewUserRegistry()
|
||||
u1 := uuid.New().String()
|
||||
u2 := uuid.New().String()
|
||||
|
||||
reg.SetUsers([]TuicClientSettings{
|
||||
{UUID: u1, Password: "pass1", Email: "u1@example.com"},
|
||||
{UUID: u2, Password: "pass2", Email: "u2@example.com"},
|
||||
})
|
||||
|
||||
// Remove u1, keep only u2
|
||||
revoked := reg.SetUsers([]TuicClientSettings{
|
||||
{UUID: u2, Password: "pass2", Email: "u2@example.com"},
|
||||
})
|
||||
|
||||
if len(revoked) != 1 || revoked[0].Email != "u1@example.com" {
|
||||
t.Fatalf("expected u1 revoked, got %+v", revoked)
|
||||
}
|
||||
|
||||
parsedU1, _ := uuid.Parse(u1)
|
||||
if _, exists := reg.users[parsedU1]; exists {
|
||||
t.Fatalf("expected u1 removed from registry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRegistryRetainsRevokedTrafficUntilSessionsFinish(t *testing.T) {
|
||||
reg := NewUserRegistry()
|
||||
uuidStr := uuid.New().String()
|
||||
reg.SetUsers([]TuicClientSettings{{UUID: uuidStr, Password: "p", Email: "revoked@example.test"}})
|
||||
parsed, _ := uuid.Parse(uuidStr)
|
||||
user := reg.users[parsed]
|
||||
user.sessions.Store(1)
|
||||
user.Traffic.BytesUp.Store(11)
|
||||
user.Traffic.BytesDown.Store(22)
|
||||
reg.SetUsers(nil)
|
||||
|
||||
if got := reg.CollectTrafficDeltas(); len(got) != 1 || got[0].Email != user.Email || got[0].Up != 11 || got[0].Down != 22 {
|
||||
t.Fatalf("revoked traffic delta = %+v", got)
|
||||
}
|
||||
user.Traffic.BytesUp.Add(3)
|
||||
if got := reg.CollectTrafficDeltas(); len(got) != 1 || got[0].Up != 3 {
|
||||
t.Fatalf("final active-session delta = %+v", got)
|
||||
}
|
||||
reg.sessionEnded(user)
|
||||
if got := reg.CollectTrafficDeltas(); len(got) != 0 {
|
||||
t.Fatalf("empty retired user produced another delta: %+v", got)
|
||||
}
|
||||
if len(reg.retired) != 0 {
|
||||
t.Fatalf("finished user remained retired: %+v", reg.retired)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRegistryConcurrentCredentialUpdatesAndAuthentication(t *testing.T) {
|
||||
certPEM, keyPEM := generateTestCert(t)
|
||||
certificate, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("tls.X509KeyPair: %v", err)
|
||||
}
|
||||
clientRaw, serverRaw := net.Pipe()
|
||||
clientConn := tls.Client(clientRaw, &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS13})
|
||||
serverConn := tls.Server(serverRaw, &tls.Config{Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS13})
|
||||
serverHandshake := make(chan error, 1)
|
||||
go func() { serverHandshake <- serverConn.Handshake() }()
|
||||
if err := clientConn.Handshake(); err != nil {
|
||||
t.Fatalf("client TLS handshake: %v", err)
|
||||
}
|
||||
if err := <-serverHandshake; err != nil {
|
||||
t.Fatalf("server TLS handshake: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = clientConn.Close()
|
||||
_ = serverConn.Close()
|
||||
})
|
||||
state := clientConn.ConnectionState()
|
||||
if !state.HandshakeComplete {
|
||||
t.Fatal("TLS handshake did not complete")
|
||||
}
|
||||
|
||||
reg := NewUserRegistry()
|
||||
uuidStr := uuid.New().String()
|
||||
parsed, _ := uuid.Parse(uuidStr)
|
||||
reg.SetUsers([]TuicClientSettings{{UUID: uuidStr, Password: "initial", Email: "user@example.test"}})
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := range 1000 {
|
||||
password := "a"
|
||||
if i%2 == 0 {
|
||||
password = "b"
|
||||
}
|
||||
reg.SetUsers([]TuicClientSettings{{UUID: uuidStr, Password: password, Email: "user@example.test"}})
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for range 1000 {
|
||||
_, _ = reg.Authenticate(&state, parsed, [32]byte{})
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package tuic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
)
|
||||
|
||||
type ServerConfig struct {
|
||||
Server string `json:"server"`
|
||||
Users map[string]string `json:"users"`
|
||||
Certificate string `json:"certificate"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
CongestionControl string `json:"congestion_control"`
|
||||
ALPN []string `json:"alpn"`
|
||||
ZeroRTTHandshake bool `json:"zero_rtt_handshake"`
|
||||
LogLevel string `json:"log_level"`
|
||||
MaxIdleTime string `json:"max_idle_time,omitempty"`
|
||||
AuthTimeout string `json:"auth_timeout,omitempty"`
|
||||
MaxExternalPacketSize int `json:"max_external_packet_size,omitempty"`
|
||||
}
|
||||
|
||||
// bind is where the sidecar itself listens: a loopback port behind the
|
||||
// panel's relay, never the inbound's public address (see udpRelay).
|
||||
func GenerateConfig(inst Instance, bind string) ([]byte, error) {
|
||||
users := make(map[string]string, len(inst.Clients))
|
||||
for _, c := range inst.Clients {
|
||||
if c.UUID != "" && c.Password != "" {
|
||||
users[c.UUID] = c.Password
|
||||
}
|
||||
}
|
||||
|
||||
authTimeoutStr := ""
|
||||
if inst.AuthenticationTimeout > 0 {
|
||||
authTimeoutStr = fmt.Sprintf("%ds", inst.AuthenticationTimeout)
|
||||
}
|
||||
|
||||
maxIdleStr := ""
|
||||
if inst.MaxIdleTime > 0 {
|
||||
maxIdleStr = fmt.Sprintf("%ds", inst.MaxIdleTime)
|
||||
}
|
||||
|
||||
logLevel := inst.LogLevel
|
||||
if logLevel == "" {
|
||||
logLevel = "info"
|
||||
}
|
||||
|
||||
cfg := ServerConfig{
|
||||
Server: bind,
|
||||
Users: users,
|
||||
Certificate: inst.Certificate,
|
||||
PrivateKey: inst.PrivateKey,
|
||||
CongestionControl: inst.CongestionControl,
|
||||
ALPN: inst.ALPN,
|
||||
ZeroRTTHandshake: inst.ZeroRTTHandshake,
|
||||
LogLevel: logLevel,
|
||||
AuthTimeout: authTimeoutStr,
|
||||
MaxIdleTime: maxIdleStr,
|
||||
MaxExternalPacketSize: inst.MaxUdpRelayPacketSize,
|
||||
}
|
||||
|
||||
return json.MarshalIndent(cfg, "", " ")
|
||||
}
|
||||
|
||||
func ConfigDir() string {
|
||||
return filepath.Join(config.GetBinFolderPath(), "tuic")
|
||||
}
|
||||
|
||||
func ConfigPathForID(id int) string {
|
||||
return filepath.Join(ConfigDir(), fmt.Sprintf("tuic_%d.json", id))
|
||||
}
|
||||
|
||||
func WriteConfigFile(id int, data []byte) (string, error) {
|
||||
dir := ConfigDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := ConfigPathForID(id)
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func RemoveConfigFile(id int) error {
|
||||
path := ConfigPathForID(id)
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
package tuic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateConfig(t *testing.T) {
|
||||
inst := Instance{
|
||||
Id: 1,
|
||||
Port: 8443,
|
||||
Listen: "0.0.0.0",
|
||||
Certificate: "/etc/ssl/cert.pem",
|
||||
PrivateKey: "/etc/ssl/key.pem",
|
||||
CongestionControl: "bbr",
|
||||
ALPN: []string{"h3", "spdy/3.1"},
|
||||
UDPRelayMode: "native",
|
||||
ZeroRTTHandshake: true,
|
||||
LogLevel: "info",
|
||||
MaxIdleTime: 15,
|
||||
AuthenticationTimeout: 3,
|
||||
MaxUdpRelayPacketSize: 1500,
|
||||
Clients: []TuicClientSettings{
|
||||
{UUID: "uuid-1", Password: "pass-1", Email: "e1"},
|
||||
{UUID: "uuid-2", Password: "pass-2", Email: "e2"},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := GenerateConfig(inst, "127.0.0.1:4433")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateConfig error: %v", err)
|
||||
}
|
||||
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
t.Fatalf("Unmarshal error: %v", err)
|
||||
}
|
||||
|
||||
if parsed["server"] != "127.0.0.1:4433" {
|
||||
t.Fatalf("expected the sidecar bound to the relay's loopback port, got %v", parsed["server"])
|
||||
}
|
||||
if parsed["certificate"] != "/etc/ssl/cert.pem" || parsed["private_key"] != "/etc/ssl/key.pem" {
|
||||
t.Fatalf("unexpected cert/key in json: %v", parsed)
|
||||
}
|
||||
|
||||
users, ok := parsed["users"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected users map, got %T", parsed["users"])
|
||||
}
|
||||
if users["uuid-1"] != "pass-1" || users["uuid-2"] != "pass-2" {
|
||||
t.Fatalf("unexpected users in json: %v", users)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateConfigLogLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"info", "info"},
|
||||
{"warn", "warn"},
|
||||
{"error", "error"},
|
||||
{"", "info"},
|
||||
{"debug", "debug"},
|
||||
{"trace", "trace"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
inst := Instance{
|
||||
Id: 1,
|
||||
Port: 8443,
|
||||
Listen: "0.0.0.0",
|
||||
LogLevel: tc.input,
|
||||
}
|
||||
data, err := GenerateConfig(inst, "127.0.0.1:4433")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateConfig error for %s: %v", tc.input, err)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
t.Fatalf("Unmarshal error for %s: %v", tc.input, err)
|
||||
}
|
||||
if parsed["log_level"] != tc.expected {
|
||||
t.Fatalf("expected log_level %s for input %s, got %v", tc.expected, tc.input, parsed["log_level"])
|
||||
}
|
||||
}
|
||||
}
|
||||
+114
-83
@@ -2,8 +2,6 @@ package tuic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -11,18 +9,17 @@ import (
|
||||
)
|
||||
|
||||
type managed struct {
|
||||
proc *Process
|
||||
relay *udpRelay
|
||||
server *Server
|
||||
tag string
|
||||
configPath string
|
||||
structuralFP string
|
||||
usersFP string
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
procs map[int]*managed
|
||||
lastStartErr map[int]string
|
||||
mu sync.Mutex
|
||||
servers map[int]*managed
|
||||
lastStartErr map[int]string
|
||||
pendingTraffic map[string]ClientTrafficDelta
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -33,11 +30,9 @@ var (
|
||||
func GetManager() *Manager {
|
||||
managerOnce.Do(func() {
|
||||
managerInstance = &Manager{
|
||||
procs: make(map[int]*managed),
|
||||
lastStartErr: make(map[int]string),
|
||||
}
|
||||
if n := killStrayTuicProcesses(GetBinaryPath()); n > 0 {
|
||||
logger.Warningf("tuic: terminated %d orphaned tuic-server process(es) from a previous run", n)
|
||||
servers: make(map[int]*managed),
|
||||
lastStartErr: make(map[int]string),
|
||||
pendingTraffic: make(map[string]ClientTrafficDelta),
|
||||
}
|
||||
})
|
||||
return managerInstance
|
||||
@@ -46,8 +41,8 @@ func GetManager() *Manager {
|
||||
func (m *Manager) HasRunning() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for _, mg := range m.procs {
|
||||
if mg.proc != nil && mg.proc.IsRunning() {
|
||||
for _, mg := range m.servers {
|
||||
if mg.server != nil && mg.server.IsRunning() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -61,6 +56,9 @@ func (m *Manager) Ensure(inst Instance) error {
|
||||
}
|
||||
|
||||
func (m *Manager) ensureLocked(inst Instance) error {
|
||||
if err := ValidateClients(inst.Clients); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(inst.Clients) == 0 {
|
||||
m.removeLocked(inst.Id)
|
||||
return nil
|
||||
@@ -69,78 +67,91 @@ func (m *Manager) ensureLocked(inst Instance) error {
|
||||
structuralFP := inst.StructuralFingerprint()
|
||||
usersFP := inst.UsersFingerprint()
|
||||
|
||||
uuidToEmail := make(map[string]string, len(inst.Clients))
|
||||
for _, c := range inst.Clients {
|
||||
if c.UUID != "" && c.Email != "" {
|
||||
uuidToEmail[c.UUID] = c.Email
|
||||
}
|
||||
}
|
||||
|
||||
if existing, ok := m.procs[inst.Id]; ok && existing != nil {
|
||||
if existing.proc != nil && existing.proc.IsRunning() &&
|
||||
existing.structuralFP == structuralFP && existing.usersFP == usersFP {
|
||||
if existing, ok := m.servers[inst.Id]; ok && existing != nil {
|
||||
if existing.server != nil && existing.server.IsRunning() && existing.structuralFP == structuralFP {
|
||||
existing.tag = inst.Tag
|
||||
existing.proc.UpdateClients(uuidToEmail)
|
||||
existing.server.UpdateRuntimeSettings(inst.Tag, inst.CongestionControl, inst.LogLevel)
|
||||
if existing.usersFP != usersFP {
|
||||
existing.usersFP = usersFP
|
||||
existing.server.UpdateUsers(inst.Clients)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
stopManaged(existing)
|
||||
delete(m.procs, inst.Id)
|
||||
m.stopAndDrainLocked(existing)
|
||||
delete(m.servers, inst.Id)
|
||||
}
|
||||
|
||||
proc, relay, configPath, err := m.startLocked(inst, uuidToEmail)
|
||||
server, err := m.startLocked(inst)
|
||||
if err != nil {
|
||||
if m.lastStartErr[inst.Id] != err.Error() {
|
||||
m.lastStartErr[inst.Id] = err.Error()
|
||||
logger.Warningf("tuic: failed to start tuic-server for inbound %d (%s): %v", inst.Id, inst.Tag, err)
|
||||
if tuicLogWarn >= parseLogLevel(inst.LogLevel) {
|
||||
logger.Warningf("tuic: inbound %d (%s): failed to start server: %v", inst.Id, inst.Tag, err)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
delete(m.lastStartErr, inst.Id)
|
||||
|
||||
m.procs[inst.Id] = &managed{
|
||||
proc: proc,
|
||||
relay: relay,
|
||||
m.servers[inst.Id] = &managed{
|
||||
server: server,
|
||||
tag: inst.Tag,
|
||||
configPath: configPath,
|
||||
structuralFP: structuralFP,
|
||||
usersFP: usersFP,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) startLocked(inst Instance, uuidToEmail map[string]string) (*Process, *udpRelay, string, error) {
|
||||
port, err := freeLoopbackUDPPort()
|
||||
func (m *Manager) startLocked(inst Instance) (*Server, error) {
|
||||
relay := &SocksRelay{
|
||||
Addr: fmt.Sprintf("127.0.0.1:%d", SOCKSPortForInbound(inst.Id)),
|
||||
Password: SocksPassword(),
|
||||
}
|
||||
server, err := NewServer(inst, relay)
|
||||
if err != nil {
|
||||
return nil, nil, "", fmt.Errorf("tuic: pick sidecar port for %d: %w", inst.Id, err)
|
||||
return nil, fmt.Errorf("tuic: init server for %d: %w", inst.Id, err)
|
||||
}
|
||||
upstream := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port}
|
||||
configBytes, err := GenerateConfig(inst, net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
|
||||
if err != nil {
|
||||
return nil, nil, "", fmt.Errorf("tuic: generate config for %d: %w", inst.Id, err)
|
||||
if err := server.Start(); err != nil {
|
||||
return nil, fmt.Errorf("tuic: start server on %s for %d: %w", inst.BindTo(), inst.Id, err)
|
||||
}
|
||||
configPath, err := WriteConfigFile(inst.Id, configBytes)
|
||||
if err != nil {
|
||||
return nil, nil, "", fmt.Errorf("tuic: write config for %d: %w", inst.Id, err)
|
||||
}
|
||||
relay, err := startUDPRelay(inst.BindTo(), upstream, relayFlowIdle)
|
||||
if err != nil {
|
||||
_ = RemoveConfigFile(inst.Id)
|
||||
return nil, nil, "", fmt.Errorf("tuic: listen on %s for %d: %w", inst.BindTo(), inst.Id, err)
|
||||
}
|
||||
proc := newProcess(configPath, inst.Tag, uuidToEmail)
|
||||
if err := proc.Start(); err != nil {
|
||||
relay.Close()
|
||||
_ = RemoveConfigFile(inst.Id)
|
||||
return nil, nil, "", err
|
||||
}
|
||||
return proc, relay, configPath, nil
|
||||
return server, nil
|
||||
}
|
||||
|
||||
func stopManaged(mg *managed) {
|
||||
if mg.proc != nil && mg.proc.IsRunning() {
|
||||
_ = mg.proc.Stop()
|
||||
func (m *Manager) stopAndDrainLocked(mg *managed) {
|
||||
if mg == nil || mg.server == nil {
|
||||
return
|
||||
}
|
||||
mg.relay.Close()
|
||||
_ = mg.server.Close()
|
||||
m.appendPendingTrafficLocked(mg.server.CollectClientTraffic())
|
||||
}
|
||||
|
||||
func (m *Manager) appendPendingTrafficLocked(deltas []ClientTrafficDelta) {
|
||||
if m.pendingTraffic == nil {
|
||||
m.pendingTraffic = make(map[string]ClientTrafficDelta)
|
||||
}
|
||||
for _, delta := range deltas {
|
||||
key := delta.Email
|
||||
if delta.TrafficID > 0 {
|
||||
key = fmt.Sprintf("traffic:%d", delta.TrafficID)
|
||||
}
|
||||
if delta.TrafficID == 0 && delta.InboundID > 0 && delta.UUID != "" {
|
||||
key = fmt.Sprintf("%d:%s", delta.InboundID, delta.UUID)
|
||||
}
|
||||
current := m.pendingTraffic[key]
|
||||
current.Email = delta.Email
|
||||
current.UUID = delta.UUID
|
||||
current.InboundID = delta.InboundID
|
||||
current.TrafficID = delta.TrafficID
|
||||
current.Up += delta.Up
|
||||
current.Down += delta.Down
|
||||
m.pendingTraffic[key] = current
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) RequeueClientTraffic(deltas []ClientTrafficDelta) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.appendPendingTrafficLocked(deltas)
|
||||
}
|
||||
|
||||
func (m *Manager) GetActiveClients(window time.Duration) ([]string, []string) {
|
||||
@@ -148,9 +159,9 @@ func (m *Manager) GetActiveClients(window time.Duration) ([]string, []string) {
|
||||
defer m.mu.Unlock()
|
||||
var emails []string
|
||||
var tags []string
|
||||
for _, mg := range m.procs {
|
||||
if mg.proc != nil && mg.proc.IsRunning() {
|
||||
active := mg.proc.GetActiveEmails(window)
|
||||
for _, mg := range m.servers {
|
||||
if mg.server != nil && mg.server.IsRunning() {
|
||||
active := mg.server.GetActiveEmails(window)
|
||||
if len(active) > 0 {
|
||||
emails = append(emails, active...)
|
||||
tags = append(tags, mg.tag)
|
||||
@@ -166,23 +177,45 @@ type InboundTrafficDelta struct {
|
||||
Down int64
|
||||
}
|
||||
|
||||
func (m *Manager) CollectTraffic() []InboundTrafficDelta {
|
||||
func (m *Manager) CollectClientTraffic() []ClientTrafficDelta {
|
||||
_, clients := m.CollectAllTraffic()
|
||||
return clients
|
||||
}
|
||||
|
||||
func (m *Manager) CollectAllTraffic() ([]InboundTrafficDelta, []ClientTrafficDelta) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var out []InboundTrafficDelta
|
||||
for _, mg := range m.procs {
|
||||
if mg.relay != nil && mg.proc != nil && mg.proc.IsRunning() {
|
||||
deltaUp, deltaDown := mg.relay.CollectTraffic()
|
||||
if deltaUp > 0 || deltaDown > 0 {
|
||||
out = append(out, InboundTrafficDelta{
|
||||
|
||||
var inbounds []InboundTrafficDelta
|
||||
clients := make([]ClientTrafficDelta, 0, len(m.pendingTraffic))
|
||||
for email, delta := range m.pendingTraffic {
|
||||
clients = append(clients, delta)
|
||||
delete(m.pendingTraffic, email)
|
||||
}
|
||||
|
||||
for _, mg := range m.servers {
|
||||
if mg.server != nil && mg.server.IsRunning() {
|
||||
up, down, cDeltas := mg.server.CollectAllTraffic()
|
||||
if up > 0 || down > 0 {
|
||||
inbounds = append(inbounds, InboundTrafficDelta{
|
||||
Tag: mg.tag,
|
||||
Up: deltaUp,
|
||||
Down: deltaDown,
|
||||
Up: up,
|
||||
Down: down,
|
||||
})
|
||||
}
|
||||
clients = append(clients, cDeltas...)
|
||||
}
|
||||
}
|
||||
return out
|
||||
return inbounds, clients
|
||||
}
|
||||
|
||||
func (m *Manager) AddTestTraffic(id int, email string, up, down int64) bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if mg, ok := m.servers[id]; ok && mg.server != nil {
|
||||
return mg.server.AddTestTraffic(email, up, down)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Manager) Remove(id int) {
|
||||
@@ -192,10 +225,9 @@ func (m *Manager) Remove(id int) {
|
||||
}
|
||||
|
||||
func (m *Manager) removeLocked(id int) {
|
||||
if existing, ok := m.procs[id]; ok && existing != nil {
|
||||
stopManaged(existing)
|
||||
_ = RemoveConfigFile(id)
|
||||
delete(m.procs, id)
|
||||
if existing, ok := m.servers[id]; ok && existing != nil {
|
||||
m.stopAndDrainLocked(existing)
|
||||
delete(m.servers, id)
|
||||
delete(m.lastStartErr, id)
|
||||
}
|
||||
}
|
||||
@@ -209,7 +241,7 @@ func (m *Manager) Reconcile(desired []Instance) {
|
||||
desiredMap[inst.Id] = inst
|
||||
}
|
||||
|
||||
for id := range m.procs {
|
||||
for id := range m.servers {
|
||||
if _, ok := desiredMap[id]; !ok {
|
||||
m.removeLocked(id)
|
||||
}
|
||||
@@ -223,9 +255,8 @@ func (m *Manager) Reconcile(desired []Instance) {
|
||||
func (m *Manager) StopAll() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for id, mg := range m.procs {
|
||||
stopManaged(mg)
|
||||
_ = RemoveConfigFile(id)
|
||||
for _, mg := range m.servers {
|
||||
m.stopAndDrainLocked(mg)
|
||||
}
|
||||
m.procs = make(map[int]*managed)
|
||||
m.servers = make(map[int]*managed)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
package tuic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/apernet/quic-go"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type reauditCCSnapshot struct {
|
||||
conn *quic.Conn
|
||||
chosen string
|
||||
actual string
|
||||
sender uintptr
|
||||
}
|
||||
|
||||
func reauditActualSender(conn *quic.Conn) (string, uintptr) {
|
||||
cc, unlock := lockedCongestion(conn)
|
||||
defer unlock()
|
||||
ptr := cc.Pointer()
|
||||
if cc.Type().String() == "*ackhandler.ccAdapterEx" || cc.Type().String() == "*ackhandler.ccAdapter" {
|
||||
sender := cc.Elem().FieldByName("CC").Elem()
|
||||
return sender.Type().String(), ptr
|
||||
}
|
||||
return fmt.Sprintf("%s reno=%t", cc.Type(), cc.Elem().FieldByName("reno").Bool()), ptr
|
||||
}
|
||||
|
||||
func reauditWantedSender(controller string) string {
|
||||
if controller == "bbr" {
|
||||
return "*bbr.bbrSender"
|
||||
}
|
||||
return "*congestion.cubicSender reno=true"
|
||||
}
|
||||
|
||||
func TestAudit3ManagerEnsureActualSendersWithPersistentTraffic(t *testing.T) {
|
||||
cert, key := generateTestCert(t)
|
||||
_, cleanup := audit3StartSocksForManager(t, "reaudit@example.test", SocksPassword(), 99115)
|
||||
defer cleanup()
|
||||
userID := uuid.MustParse("a0000000-0000-0000-0000-000000000015")
|
||||
inst := Instance{Id: 99115, Tag: "reaudit-cc", Listen: "127.0.0.1", Certificate: string(cert), PrivateKey: string(key), CongestionControl: "new_reno", AuthenticationTimeout: 3, MaxIdleTime: 30, Clients: []TuicClientSettings{{UUID: userID.String(), Password: "secret-reaudit", Email: "reaudit@example.test"}}}
|
||||
manager := &Manager{servers: map[int]*managed{}, lastStartErr: map[int]string{}}
|
||||
if err := manager.Ensure(inst); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer manager.StopAll()
|
||||
server := manager.servers[inst.Id].server
|
||||
listener := server.quicListener
|
||||
address := server.packetConn.LocalAddr().String()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
type peer struct {
|
||||
client *quic.Conn
|
||||
tcp *quic.Stream
|
||||
snapshot reauditCCSnapshot
|
||||
packetID uint16
|
||||
}
|
||||
var peers []*peer
|
||||
tcpEcho := func(p *peer, message []byte) {
|
||||
t.Helper()
|
||||
_ = p.tcp.SetDeadline(time.Now().Add(2 * time.Second))
|
||||
if _, err := p.tcp.Write(message); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reply := make([]byte, len(message))
|
||||
if _, err := io.ReadFull(p.tcp, reply); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(reply, message) {
|
||||
t.Fatalf("TCP echo mismatch: %q", reply)
|
||||
}
|
||||
}
|
||||
udpEcho := func(p *peer, streamMode bool, message []byte) {
|
||||
t.Helper()
|
||||
p.packetID++
|
||||
assoc := uint16(100)
|
||||
if streamMode {
|
||||
assoc = 200
|
||||
}
|
||||
var frame bytes.Buffer
|
||||
if err := WritePacket(&frame, assoc, p.packetID, 1, 0, &Address{Type: AddrTypeIPv4, IP: net.ParseIP("8.8.8.8"), Port: 53}, message); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var reader io.Reader
|
||||
if streamMode {
|
||||
stream, err := p.client.OpenUniStreamSync(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := stream.Write(frame.Bytes()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := stream.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := p.client.AcceptUniStream(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader = response
|
||||
} else {
|
||||
if err := p.client.SendDatagram(frame.Bytes()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := p.client.ReceiveDatagram(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader = bytes.NewReader(response)
|
||||
}
|
||||
_, command, err := ReadCommand(reader)
|
||||
if err != nil || command != CmdPacket {
|
||||
t.Fatalf("UDP response command=%d error=%v", command, err)
|
||||
}
|
||||
hdr, err := ReadPacketHeader(reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload, err := readPacketPayload(reader, hdr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hdr.AssocID != assoc || !bytes.Equal(payload, message) {
|
||||
t.Fatalf("UDP echo mismatch association=%d payload=%q", hdr.AssocID, payload)
|
||||
}
|
||||
}
|
||||
for step, controller := range []string{"new_reno", "reno", "bbr", "BBR", "cubic", "CuBiC", "", "invalid"} {
|
||||
inst.CongestionControl = controller
|
||||
if err := manager.Ensure(inst); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
normalized, _ := normalizeCongestionControl(controller)
|
||||
served := normalized
|
||||
if served == "cubic" {
|
||||
served = "new_reno"
|
||||
}
|
||||
if server.quicListener != listener || server.packetConn.LocalAddr().String() != address {
|
||||
t.Fatal("listener changed")
|
||||
}
|
||||
client, err := quic.DialAddr(ctx, address, &tls.Config{InsecureSkipVerify: true, NextProtos: []string{"h3"}}, &quic.Config{EnableDatagrams: true, MaxIdleTimeout: 30 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.CloseWithError(0, "")
|
||||
tlsState := client.ConnectionState().TLS
|
||||
token, err := tlsState.ExportKeyingMaterial(string(userID[:]), []byte("secret-reaudit"), 32)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
auth, err := client.OpenUniStreamSync(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authBytes := make([]byte, 50)
|
||||
authBytes[0], authBytes[1] = ProtocolVersion, CmdAuthenticate
|
||||
copy(authBytes[2:18], userID[:])
|
||||
copy(authBytes[18:], token)
|
||||
if _, err := auth.Write(authBytes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := auth.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
waitForClientCongestionSender(t, server, client, served)
|
||||
var serverConn *quic.Conn
|
||||
server.connectionsMu.Lock()
|
||||
for candidate := range server.connections {
|
||||
if matchesClientSocket(candidate, client) {
|
||||
serverConn = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
server.connectionsMu.Unlock()
|
||||
if serverConn == nil {
|
||||
t.Fatal("server connection missing")
|
||||
}
|
||||
actual, sender := reauditActualSender(serverConn)
|
||||
snap := reauditCCSnapshot{conn: serverConn, chosen: normalized, actual: actual, sender: sender}
|
||||
if actual != reauditWantedSender(normalized) {
|
||||
t.Fatalf("wrong sender: %s", actual)
|
||||
}
|
||||
tcp, err := client.OpenStreamSync(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var connect bytes.Buffer
|
||||
connect.Write([]byte{ProtocolVersion, CmdConnect})
|
||||
if err := WriteAddress(&connect, &Address{Type: AddrTypeIPv4, IP: net.ParseIP("1.1.1.1"), Port: 80}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tcp.Write(connect.Bytes()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range peers {
|
||||
if p.snapshot.sender == snap.sender {
|
||||
t.Fatal("sender reused across connections")
|
||||
}
|
||||
}
|
||||
peers = append(peers, &peer{client: client, tcp: tcp, snapshot: snap})
|
||||
for i, p := range peers {
|
||||
actual, ptr := reauditActualSender(p.snapshot.conn)
|
||||
if actual != p.snapshot.actual || ptr != p.snapshot.sender {
|
||||
t.Fatalf("existing connection sender changed: %s -> %s", p.snapshot.actual, actual)
|
||||
}
|
||||
msg := fmt.Appendf(nil, "live-step-%d-peer-%d", step, i)
|
||||
tcpEcho(p, msg)
|
||||
udpEcho(p, false, msg)
|
||||
udpEcho(p, true, msg)
|
||||
}
|
||||
t.Logf("step=%d new=%s old peers=%d usable TCP/native UDP/stream UDP; listener preserved", step, snap.actual, len(peers)-1)
|
||||
}
|
||||
}
|
||||
|
||||
func audit3StartSocksForManager(t *testing.T, expectedUser, expectedPass string, inboundID int) (string, func()) {
|
||||
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", SOCKSPortForInbound(inboundID)))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to listen: %v", err)
|
||||
}
|
||||
|
||||
stop := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
go handleMockSocksConn(conn, expectedUser, expectedPass)
|
||||
}
|
||||
}()
|
||||
|
||||
return ln.Addr().String(), func() {
|
||||
close(stop)
|
||||
_ = ln.Close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package tuic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
clientquic "github.com/quic-go/quic-go"
|
||||
)
|
||||
|
||||
func TestAudit3ManagerStopMustInterruptIdleTCPRelay(t *testing.T) {
|
||||
var listener net.Listener
|
||||
var err error
|
||||
var inboundID int
|
||||
for p := 64051; p < 64080; p++ {
|
||||
listener, err = net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", p))
|
||||
if err == nil {
|
||||
inboundID = 500000 + (p - 64000)
|
||||
break
|
||||
}
|
||||
}
|
||||
if listener == nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
peerFIN := make(chan struct{})
|
||||
ready := make(chan struct{})
|
||||
go func() {
|
||||
c, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
var greeting [4]byte
|
||||
if _, err := io.ReadFull(c, greeting[:]); err != nil {
|
||||
return
|
||||
}
|
||||
c.Write([]byte{5, 0})
|
||||
var req [10]byte
|
||||
if _, err := io.ReadFull(c, req[:]); err != nil {
|
||||
return
|
||||
}
|
||||
c.Write([]byte{5, 0, 0, 1, 127, 0, 0, 1, 0, 0})
|
||||
var payload [1]byte
|
||||
if _, err := io.ReadFull(c, payload[:]); err != nil {
|
||||
return
|
||||
}
|
||||
c.Write(payload[:])
|
||||
close(ready)
|
||||
io.Copy(io.Discard, c)
|
||||
close(peerFIN)
|
||||
<-release
|
||||
}()
|
||||
cert, key := generateTestCert(t)
|
||||
clientID := uuid.New()
|
||||
password := "audit3-password"
|
||||
m := &Manager{servers: make(map[int]*managed), lastStartErr: make(map[int]string), pendingTraffic: make(map[string]ClientTrafficDelta)}
|
||||
if err := m.Ensure(Instance{Id: inboundID, Tag: "audit3-manager-shutdown", Listen: "127.0.0.1", Port: 0, Certificate: string(cert), PrivateKey: string(key), ALPN: []string{"h3"}, AuthenticationTimeout: 2, MaxIdleTime: 30, Clients: []TuicClientSettings{{UUID: clientID.String(), Password: password, Email: "close-idle@audit3"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(m.StopAll)
|
||||
server := m.servers[inboundID].server
|
||||
dialCtx, dialCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer dialCancel()
|
||||
conn, err := clientquic.DialAddr(dialCtx, server.packetConn.LocalAddr().String(), &tls.Config{InsecureSkipVerify: true, NextProtos: []string{"h3"}}, &clientquic.Config{EnableDatagrams: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { conn.CloseWithError(0, "") })
|
||||
tlsState := conn.ConnectionState().TLS
|
||||
token, err := tlsState.ExportKeyingMaterial(string(clientID[:]), []byte(password), 32)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
auth, err := conn.OpenUniStreamSync(dialCtx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := append([]byte{ProtocolVersion, CmdAuthenticate}, clientID[:]...)
|
||||
payload = append(payload, token...)
|
||||
if _, err := auth.Write(payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
auth.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
stream, err := conn.OpenStreamSync(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var cmd bytes.Buffer
|
||||
cmd.Write([]byte{ProtocolVersion, CmdConnect})
|
||||
WriteAddress(&cmd, &Address{Type: AddrTypeIPv4, IP: net.ParseIP("1.1.1.1"), Port: 443})
|
||||
cmd.WriteByte('x')
|
||||
if _, err := stream.Write(cmd.Bytes()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var echo [1]byte
|
||||
if _, err := io.ReadFull(stream, echo[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
<-ready
|
||||
closed := make(chan error, 1)
|
||||
started := time.Now()
|
||||
go func() { m.StopAll(); closed <- nil }()
|
||||
|
||||
queried := make(chan bool, 1)
|
||||
go func() { queried <- m.HasRunning() }()
|
||||
select {
|
||||
case <-closed:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("StopAll blocked on idle TCP peer after %s", time.Since(started))
|
||||
}
|
||||
select {
|
||||
case <-queried:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("manager query blocked after StopAll")
|
||||
}
|
||||
select {
|
||||
case <-peerFIN:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("upstream connection remained open")
|
||||
}
|
||||
_, deltas := m.CollectAllTraffic()
|
||||
if len(deltas) != 1 || deltas[0].Up != 1 || deltas[0].Down != 1 {
|
||||
t.Fatalf("final counters: %+v", deltas)
|
||||
}
|
||||
_, again := m.CollectAllTraffic()
|
||||
if len(again) != 0 {
|
||||
t.Fatalf("repeated final counters: %+v", again)
|
||||
}
|
||||
}
|
||||
+359
-55
@@ -1,95 +1,399 @@
|
||||
package tuic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/apernet/quic-go"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
)
|
||||
|
||||
func TestEnsureFrontsSidecarWithRelayAndRemoveReleasesPort(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses a shell script as the sidecar binary")
|
||||
}
|
||||
bin := t.TempDir()
|
||||
t.Setenv("XUI_BIN_FOLDER", bin)
|
||||
if err := os.WriteFile(filepath.Join(bin, GetBinaryName()), []byte("#!/bin/sh\nexec sleep 300\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port, err := freeLoopbackUDPPort()
|
||||
func TestEnsureStartsServerAndReconciles(t *testing.T) {
|
||||
certPEM, keyPEM := generateTestCert(t)
|
||||
|
||||
// Find free UDP port
|
||||
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port := pc.LocalAddr().(*net.UDPAddr).Port
|
||||
_ = pc.Close()
|
||||
|
||||
inst := Instance{
|
||||
Id: 7, Tag: "tuic-7", Listen: "127.0.0.1", Port: port,
|
||||
Clients: []TuicClientSettings{{UUID: "u", Password: "p", Email: "e"}},
|
||||
Id: 11,
|
||||
Tag: "tuic-11",
|
||||
Listen: "127.0.0.1",
|
||||
Port: port,
|
||||
Certificate: string(certPEM),
|
||||
PrivateKey: string(keyPEM),
|
||||
Clients: []TuicClientSettings{{UUID: "a0000000-0000-0000-0000-000000000001", Password: "p", Email: "e1"}},
|
||||
}
|
||||
m := &Manager{procs: map[int]*managed{}, lastStartErr: map[int]string{}}
|
||||
|
||||
m := &Manager{servers: map[int]*managed{}, lastStartErr: map[int]string{}}
|
||||
t.Cleanup(m.StopAll)
|
||||
|
||||
if err := m.Ensure(inst); err != nil {
|
||||
t.Fatalf("Ensure: %v", err)
|
||||
}
|
||||
if c, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port}); err == nil {
|
||||
_ = c.Close()
|
||||
t.Fatal("the relay must own the inbound's public port while the sidecar runs")
|
||||
}
|
||||
raw, err := os.ReadFile(ConfigPathForID(7))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var cfg struct {
|
||||
Server string `json:"server"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
host, sidecarPort, err := net.SplitHostPort(cfg.Server)
|
||||
if err != nil || host != "127.0.0.1" || sidecarPort == "" || cfg.Server == inst.BindTo() {
|
||||
t.Fatalf("sidecar bound to %q, want a loopback port other than the public %q", cfg.Server, inst.BindTo())
|
||||
t.Fatalf("Ensure failed: %v", err)
|
||||
}
|
||||
|
||||
m.Remove(7)
|
||||
c, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port})
|
||||
if !m.HasRunning() {
|
||||
t.Fatal("expected manager to have running server")
|
||||
}
|
||||
|
||||
// Port should be taken by the server
|
||||
if c, err := net.ListenPacket("udp", inst.BindTo()); err == nil {
|
||||
_ = c.Close()
|
||||
t.Fatal("expected server to be listening on port")
|
||||
}
|
||||
|
||||
// Reconcile with empty list should remove it
|
||||
m.Reconcile([]Instance{})
|
||||
if m.HasRunning() {
|
||||
t.Fatal("expected no running servers after reconcile empty")
|
||||
}
|
||||
|
||||
// Port should now be released
|
||||
c, err := net.ListenPacket("udp", inst.BindTo())
|
||||
if err != nil {
|
||||
t.Fatalf("public port still held after Remove: %v", err)
|
||||
t.Fatalf("expected port to be free after reconcile: %v", err)
|
||||
}
|
||||
_ = c.Close()
|
||||
}
|
||||
|
||||
func TestEnsureUpdatesTagWithoutRestart(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses a shell script as the sidecar binary")
|
||||
}
|
||||
bin := t.TempDir()
|
||||
t.Setenv("XUI_BIN_FOLDER", bin)
|
||||
if err := os.WriteFile(filepath.Join(bin, GetBinaryName()), []byte("#!/bin/sh\nexec sleep 300\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port, err := freeLoopbackUDPPort()
|
||||
func TestEnsureHotUpdatesUsersWithoutRestart(t *testing.T) {
|
||||
certPEM, keyPEM := generateTestCert(t)
|
||||
|
||||
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port := pc.LocalAddr().(*net.UDPAddr).Port
|
||||
_ = pc.Close()
|
||||
|
||||
inst := Instance{
|
||||
Id: 8, Tag: "old-tag", Listen: "127.0.0.1", Port: port,
|
||||
Clients: []TuicClientSettings{{UUID: "u", Password: "p", Email: "e"}},
|
||||
Id: 12,
|
||||
Tag: "tuic-12",
|
||||
Listen: "127.0.0.1",
|
||||
Port: port,
|
||||
Certificate: string(certPEM),
|
||||
PrivateKey: string(keyPEM),
|
||||
Clients: []TuicClientSettings{{UUID: "a0000000-0000-0000-0000-000000000001", Password: "p1", Email: "e1"}},
|
||||
}
|
||||
m := &Manager{procs: map[int]*managed{}, lastStartErr: map[int]string{}}
|
||||
|
||||
m := &Manager{servers: map[int]*managed{}, lastStartErr: map[int]string{}}
|
||||
t.Cleanup(m.StopAll)
|
||||
|
||||
if err := m.Ensure(inst); err != nil {
|
||||
t.Fatalf("Ensure: %v", err)
|
||||
t.Fatalf("Ensure failed: %v", err)
|
||||
}
|
||||
|
||||
server1 := m.servers[12].server
|
||||
|
||||
// Update user list without changing port or certs
|
||||
inst.Clients = append(inst.Clients, TuicClientSettings{
|
||||
UUID: "a0000000-0000-0000-0000-000000000002",
|
||||
Password: "p2",
|
||||
Email: "e2",
|
||||
})
|
||||
|
||||
if err := m.Ensure(inst); err != nil {
|
||||
t.Fatalf("Ensure with updated clients failed: %v", err)
|
||||
}
|
||||
|
||||
server2 := m.servers[12].server
|
||||
if server1 != server2 {
|
||||
t.Fatal("expected server instance to be reused across user updates (zero-downtime hot update)")
|
||||
}
|
||||
|
||||
// Verify both users are now in user registry
|
||||
if len(server2.users.users) != 2 {
|
||||
t.Fatalf("expected 2 users in registry, got %d", len(server2.users.users))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureUpdatesTagWithoutRestart(t *testing.T) {
|
||||
certPEM, keyPEM := generateTestCert(t)
|
||||
|
||||
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port := pc.LocalAddr().(*net.UDPAddr).Port
|
||||
_ = pc.Close()
|
||||
|
||||
inst := Instance{
|
||||
Id: 13,
|
||||
Tag: "old-tag",
|
||||
Listen: "127.0.0.1",
|
||||
Port: port,
|
||||
Certificate: string(certPEM),
|
||||
PrivateKey: string(keyPEM),
|
||||
Clients: []TuicClientSettings{{UUID: "a0000000-0000-0000-0000-000000000001", Password: "p", Email: "e"}},
|
||||
}
|
||||
|
||||
m := &Manager{servers: map[int]*managed{}, lastStartErr: map[int]string{}}
|
||||
t.Cleanup(m.StopAll)
|
||||
|
||||
if err := m.Ensure(inst); err != nil {
|
||||
t.Fatalf("Ensure failed: %v", err)
|
||||
}
|
||||
|
||||
inst.Tag = "new-tag"
|
||||
if err := m.Ensure(inst); err != nil {
|
||||
t.Fatalf("Ensure updated tag: %v", err)
|
||||
t.Fatalf("Ensure updated tag failed: %v", err)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
gotTag := m.procs[8].tag
|
||||
gotTag := m.servers[13].tag
|
||||
m.mu.Unlock()
|
||||
if gotTag != "new-tag" {
|
||||
t.Fatalf("manager tag = %q, want %q", gotTag, "new-tag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureUpdatesControllerWithoutRestartForNewConnections(t *testing.T) {
|
||||
certPEM, keyPEM := generateTestCert(t)
|
||||
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port := pc.LocalAddr().(*net.UDPAddr).Port
|
||||
_ = pc.Close()
|
||||
|
||||
inst := Instance{
|
||||
Id: 14,
|
||||
Tag: "tuic-controller-reload",
|
||||
Listen: "127.0.0.1",
|
||||
Port: port,
|
||||
Certificate: string(certPEM),
|
||||
PrivateKey: string(keyPEM),
|
||||
CongestionControl: "bbr",
|
||||
LogLevel: "debug",
|
||||
MaxIdleTime: 30,
|
||||
AuthenticationTimeout: 30,
|
||||
Clients: []TuicClientSettings{{UUID: "a0000000-0000-0000-0000-000000000001", Password: "p", Email: "e"}},
|
||||
}
|
||||
m := &Manager{servers: map[int]*managed{}, lastStartErr: map[int]string{}}
|
||||
t.Cleanup(m.StopAll)
|
||||
if err := m.Ensure(inst); err != nil {
|
||||
t.Fatalf("Ensure failed: %v", err)
|
||||
}
|
||||
server := m.servers[inst.Id].server
|
||||
|
||||
dial := func() *quic.Conn {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||
defer cancel()
|
||||
conn, err := quic.DialAddr(ctx, server.packetConn.LocalAddr().String(), &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
NextProtos: []string{"h3"},
|
||||
}, &quic.Config{EnableDatagrams: true, MaxIdleTimeout: 30 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("QUIC dial failed: %v", err)
|
||||
}
|
||||
return conn
|
||||
}
|
||||
|
||||
connBBR := dial()
|
||||
defer connBBR.CloseWithError(0, "")
|
||||
waitForTuicLog(t, "applied bbr congestion controller")
|
||||
bbrSender := waitForClientCongestionSender(t, server, connBBR, "bbr")
|
||||
|
||||
inst.CongestionControl = "cubic"
|
||||
if err := m.Ensure(inst); err != nil {
|
||||
t.Fatalf("Ensure after CUBIC update failed: %v", err)
|
||||
}
|
||||
if m.servers[inst.Id].server != server {
|
||||
t.Fatal("changing congestion control restarted the listener")
|
||||
}
|
||||
if err := connBBR.Context().Err(); err != nil {
|
||||
t.Fatalf("existing BBR connection closed after controller update: %v", err)
|
||||
}
|
||||
if sender := congestionSenderForClient(t, server, connBBR); sender != bbrSender {
|
||||
t.Fatal("existing connection's BBR sender changed after hot update")
|
||||
}
|
||||
connCubic := dial()
|
||||
defer connCubic.CloseWithError(0, "")
|
||||
waitForTuicLog(t, "cubic is not available; applied new_reno congestion controller")
|
||||
cubicSender := waitForClientCongestionSender(t, server, connCubic, "new_reno")
|
||||
|
||||
inst.CongestionControl = "new_reno"
|
||||
if err := m.Ensure(inst); err != nil {
|
||||
t.Fatalf("Ensure after New Reno update failed: %v", err)
|
||||
}
|
||||
if err := connBBR.Context().Err(); err != nil {
|
||||
t.Fatalf("existing BBR connection closed after second update: %v", err)
|
||||
}
|
||||
if err := connCubic.Context().Err(); err != nil {
|
||||
t.Fatalf("existing CUBIC connection closed after second update: %v", err)
|
||||
}
|
||||
if sender := congestionSenderForClient(t, server, connBBR); sender != bbrSender {
|
||||
t.Fatal("existing connection's BBR sender changed after second hot update")
|
||||
}
|
||||
if sender := congestionSenderForClient(t, server, connCubic); sender != cubicSender {
|
||||
t.Fatal("existing connection's CUBIC sender changed after second hot update")
|
||||
}
|
||||
connReno := dial()
|
||||
defer connReno.CloseWithError(0, "")
|
||||
waitForTuicLog(t, "applied new_reno congestion controller")
|
||||
waitForClientCongestionSender(t, server, connReno, "new_reno")
|
||||
}
|
||||
|
||||
func waitForClientCongestionSender(t *testing.T, server *Server, client *quic.Conn, want string) uintptr {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(4 * time.Second)
|
||||
var observed []string
|
||||
seen := make(map[string]struct{})
|
||||
for time.Now().Before(deadline) {
|
||||
server.connectionsMu.Lock()
|
||||
for conn := range server.connections {
|
||||
remote := conn.RemoteAddr().String()
|
||||
actual, sender := inspectCongestionSender(conn)
|
||||
description := fmt.Sprintf("remote=%s controller=%s sender=%x", remote, actual, sender)
|
||||
if _, exists := seen[description]; !exists {
|
||||
seen[description] = struct{}{}
|
||||
observed = append(observed, description)
|
||||
}
|
||||
if !matchesClientSocket(conn, client) {
|
||||
continue
|
||||
}
|
||||
if actual == want {
|
||||
server.connectionsMu.Unlock()
|
||||
return sender
|
||||
}
|
||||
}
|
||||
server.connectionsMu.Unlock()
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("server connection did not install %s congestion sender for client %s (observed %v)", want, client.LocalAddr(), observed)
|
||||
return 0
|
||||
}
|
||||
|
||||
func congestionSenderForClient(t *testing.T, server *Server, client *quic.Conn) uintptr {
|
||||
t.Helper()
|
||||
server.connectionsMu.Lock()
|
||||
defer server.connectionsMu.Unlock()
|
||||
for conn := range server.connections {
|
||||
if matchesClientSocket(conn, client) {
|
||||
_, sender := inspectCongestionSender(conn)
|
||||
return sender
|
||||
}
|
||||
}
|
||||
t.Fatal("server connection for client is not registered")
|
||||
return 0
|
||||
}
|
||||
|
||||
func matchesClientSocket(serverConn, clientConn *quic.Conn) bool {
|
||||
serverAddr, serverOK := serverConn.RemoteAddr().(*net.UDPAddr)
|
||||
clientAddr, clientOK := clientConn.LocalAddr().(*net.UDPAddr)
|
||||
return serverOK && clientOK && serverAddr.Port == clientAddr.Port
|
||||
}
|
||||
|
||||
// lockedCongestion reads the sender under the mutex SetCongestionControl writes
|
||||
// it under, which the BBR install after the handshake races otherwise.
|
||||
func lockedCongestion(conn *quic.Conn) (reflect.Value, func()) {
|
||||
handler := reflect.ValueOf(conn).Elem().FieldByName("sentPacketHandler").Elem().Elem()
|
||||
mu := (*sync.RWMutex)(unsafe.Pointer(handler.FieldByName("congestionMutex").UnsafeAddr()))
|
||||
mu.RLock()
|
||||
return handler.FieldByName("congestion").Elem(), mu.RUnlock
|
||||
}
|
||||
|
||||
func inspectCongestionSender(conn *quic.Conn) (string, uintptr) {
|
||||
controller, unlock := lockedCongestion(conn)
|
||||
defer unlock()
|
||||
sender := controller
|
||||
if controller.Type().String() == "*ackhandler.ccAdapterEx" || controller.Type().String() == "*ackhandler.ccAdapter" {
|
||||
sender = controller.Elem().FieldByName("CC").Elem()
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(sender.Type().String(), "bbrSender"):
|
||||
return "bbr", sender.Pointer()
|
||||
case strings.Contains(sender.Type().String(), "cubicSender"):
|
||||
if sender.Elem().FieldByName("reno").Bool() {
|
||||
return "new_reno", sender.Pointer()
|
||||
}
|
||||
return "cubic", sender.Pointer()
|
||||
}
|
||||
return sender.Type().String(), sender.Pointer()
|
||||
}
|
||||
|
||||
func waitForTuicLog(t *testing.T, message string) {
|
||||
t.Helper()
|
||||
marker := "inbound 14 (tuic-controller-reload): " + message
|
||||
deadline := time.Now().Add(4 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if strings.Contains(strings.Join(logger.GetLogs(10000, "DEBUG"), "\n"), marker) {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for TUIC log %q", marker)
|
||||
}
|
||||
|
||||
func TestCollectAllTraffic(t *testing.T) {
|
||||
certPEM, keyPEM := generateTestCert(t)
|
||||
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port := pc.LocalAddr().(*net.UDPAddr).Port
|
||||
_ = pc.Close()
|
||||
|
||||
inst := Instance{
|
||||
Id: 20,
|
||||
Tag: "tuic-20",
|
||||
Listen: "127.0.0.1",
|
||||
Port: port,
|
||||
Certificate: string(certPEM),
|
||||
PrivateKey: string(keyPEM),
|
||||
Clients: []TuicClientSettings{{UUID: "a0000000-0000-0000-0000-000000000001", Password: "p", Email: "e1"}},
|
||||
}
|
||||
|
||||
m := &Manager{servers: map[int]*managed{}, lastStartErr: map[int]string{}}
|
||||
t.Cleanup(m.StopAll)
|
||||
|
||||
if err := m.Ensure(inst); err != nil {
|
||||
t.Fatalf("Ensure failed: %v", err)
|
||||
}
|
||||
|
||||
if !m.AddTestTraffic(20, "e1", 500, 1000) {
|
||||
t.Fatal("AddTestTraffic failed")
|
||||
}
|
||||
|
||||
inbounds, clients := m.CollectAllTraffic()
|
||||
if len(inbounds) != 1 || inbounds[0].Up != 500 || inbounds[0].Down != 1000 {
|
||||
t.Fatalf("unexpected inbounds: %+v", inbounds)
|
||||
}
|
||||
if len(clients) != 1 || clients[0].Up != 500 || clients[0].Down != 1000 || clients[0].Email != "e1" {
|
||||
t.Fatalf("unexpected clients: %+v", clients)
|
||||
}
|
||||
|
||||
// Subsequent call returns empty deltas
|
||||
inbounds2, clients2 := m.CollectAllTraffic()
|
||||
if len(inbounds2) != 0 || len(clients2) != 0 {
|
||||
t.Fatalf("expected empty deltas after drain, got %+v, %+v", inbounds2, clients2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerRequeuesClientTrafficWithoutLosingDeltas(t *testing.T) {
|
||||
m := &Manager{servers: make(map[int]*managed)}
|
||||
m.RequeueClientTraffic([]ClientTrafficDelta{{Email: "shared@example.test", Up: 10, Down: 20}})
|
||||
m.RequeueClientTraffic([]ClientTrafficDelta{{Email: "shared@example.test", Up: 30, Down: 40}})
|
||||
|
||||
_, got := m.CollectAllTraffic()
|
||||
if len(got) != 1 || got[0] != (ClientTrafficDelta{Email: "shared@example.test", Up: 40, Down: 60}) {
|
||||
t.Fatalf("requeued client traffic = %+v", got)
|
||||
}
|
||||
if _, got = m.CollectAllTraffic(); len(got) != 0 {
|
||||
t.Fatalf("requeued traffic was collected more than once: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
//go:build linux
|
||||
|
||||
package tuic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
func killStrayTuicProcesses(binaryPath string) int {
|
||||
base := filepath.Base(binaryPath)
|
||||
if base == "" || base == "." || base == string(filepath.Separator) {
|
||||
return 0
|
||||
}
|
||||
configDir := filepath.Clean(ConfigDir())
|
||||
self := os.Getpid()
|
||||
entries, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
killed := 0
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil || pid == self {
|
||||
continue
|
||||
}
|
||||
if procExeBase(pid) != base && cmdlineArgv0Base(pid) != base {
|
||||
continue
|
||||
}
|
||||
if !isManagedTuicCmdline(pid, configDir) {
|
||||
continue
|
||||
}
|
||||
if err := syscall.Kill(pid, syscall.SIGTERM); err == nil {
|
||||
killed++
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if err := syscall.Kill(pid, 0); err == nil {
|
||||
_ = syscall.Kill(pid, syscall.SIGKILL)
|
||||
}
|
||||
}
|
||||
}
|
||||
return killed
|
||||
}
|
||||
|
||||
func isManagedTuicCmdline(pid int, configDir string) bool {
|
||||
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
|
||||
if err != nil || len(data) == 0 {
|
||||
return false
|
||||
}
|
||||
args := strings.Split(string(data), "\x00")
|
||||
for i, arg := range args {
|
||||
if arg == "-c" && i+1 < len(args) {
|
||||
cfg := filepath.Clean(args[i+1])
|
||||
if strings.HasPrefix(cfg, configDir) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func procExeBase(pid int) string {
|
||||
exe, err := os.Readlink(fmt.Sprintf("/proc/%d/exe", pid))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Base(exe)
|
||||
}
|
||||
|
||||
func cmdlineArgv0Base(pid int) string {
|
||||
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
|
||||
if err != nil || len(data) == 0 {
|
||||
return ""
|
||||
}
|
||||
first := data
|
||||
for i, b := range data {
|
||||
if b == 0 {
|
||||
first = data[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
return filepath.Base(string(first))
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
//go:build !linux
|
||||
|
||||
package tuic
|
||||
|
||||
func killStrayTuicProcesses(_ string) int { return 0 }
|
||||
@@ -1,292 +0,0 @@
|
||||
package tuic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
)
|
||||
|
||||
func GetBinaryName() string {
|
||||
name := fmt.Sprintf("tuic-server-%s-%s", runtime.GOOS, runtime.GOARCH)
|
||||
if runtime.GOOS == "windows" {
|
||||
name += ".exe"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func GetBinaryPath() string {
|
||||
custom := filepath.Join(config.GetBinFolderPath(), GetBinaryName())
|
||||
if _, err := os.Stat(custom); err == nil {
|
||||
return custom
|
||||
}
|
||||
binTuic := filepath.Join(config.GetBinFolderPath(), "tuic-server")
|
||||
if runtime.GOOS == "windows" {
|
||||
binTuic += ".exe"
|
||||
}
|
||||
if _, err := os.Stat(binTuic); err == nil {
|
||||
return binTuic
|
||||
}
|
||||
for _, p := range []string{"/usr/local/bin/tuic-server", "/usr/bin/tuic-server"} {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
if path, err := exec.LookPath("tuic-server"); err == nil {
|
||||
return path
|
||||
}
|
||||
return binTuic
|
||||
}
|
||||
|
||||
var (
|
||||
gracefulStopTimeout = 5 * time.Second
|
||||
forceStopTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
type procLogWriter struct {
|
||||
mu sync.Mutex
|
||||
label string
|
||||
buf string
|
||||
lastLine string
|
||||
uuidToEmail map[string]string
|
||||
lastActive map[string]int64
|
||||
}
|
||||
|
||||
func (w *procLogWriter) Write(p []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.buf += string(p)
|
||||
for {
|
||||
i := strings.IndexByte(w.buf, '\n')
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
line := w.buf[:i]
|
||||
w.buf = w.buf[i+1:]
|
||||
w.emitLocked(line)
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (w *procLogWriter) Flush() {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.buf != "" {
|
||||
line := w.buf
|
||||
w.buf = ""
|
||||
w.emitLocked(line)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *procLogWriter) emitLocked(line string) {
|
||||
trimmed := strings.TrimSpace(strings.TrimRight(line, "\r"))
|
||||
if trimmed == "" {
|
||||
return
|
||||
}
|
||||
w.lastLine = trimmed
|
||||
logger.Infof("tuic: tuic-server %s | %s", w.label, trimmed)
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
lowerLine := strings.ToLower(line)
|
||||
for uuid, email := range w.uuidToEmail {
|
||||
if strings.Contains(lowerLine, uuid) {
|
||||
if w.lastActive == nil {
|
||||
w.lastActive = make(map[string]int64)
|
||||
}
|
||||
w.lastActive[email] = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *procLogWriter) LastLine() string {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.lastLine
|
||||
}
|
||||
|
||||
type Process struct {
|
||||
mu sync.RWMutex
|
||||
cmd *exec.Cmd
|
||||
done chan struct{}
|
||||
configPath string
|
||||
logWriter *procLogWriter
|
||||
exitErr error
|
||||
intentionalStop atomic.Bool
|
||||
}
|
||||
|
||||
func newProcess(configPath, label string, uuidToEmail map[string]string) *Process {
|
||||
return &Process{
|
||||
configPath: configPath,
|
||||
logWriter: &procLogWriter{
|
||||
label: label,
|
||||
uuidToEmail: uuidToEmail,
|
||||
lastActive: make(map[string]int64),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Process) GetActiveEmails(window time.Duration) []string {
|
||||
if p == nil || p.logWriter == nil {
|
||||
return nil
|
||||
}
|
||||
p.logWriter.mu.Lock()
|
||||
defer p.logWriter.mu.Unlock()
|
||||
cutoff := time.Now().Add(-window).UnixMilli()
|
||||
var active []string
|
||||
for email, last := range p.logWriter.lastActive {
|
||||
if last >= cutoff {
|
||||
active = append(active, email)
|
||||
}
|
||||
}
|
||||
return active
|
||||
}
|
||||
|
||||
func (p *Process) UpdateClients(uuidToEmail map[string]string) {
|
||||
if p == nil || p.logWriter == nil {
|
||||
return
|
||||
}
|
||||
p.logWriter.mu.Lock()
|
||||
defer p.logWriter.mu.Unlock()
|
||||
p.logWriter.uuidToEmail = uuidToEmail
|
||||
}
|
||||
|
||||
func (p *Process) IsRunning() bool {
|
||||
p.mu.RLock()
|
||||
cmd, done := p.cmd, p.done
|
||||
p.mu.RUnlock()
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return false
|
||||
}
|
||||
if done != nil {
|
||||
select {
|
||||
case <-done:
|
||||
return false
|
||||
default:
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *Process) GetResult() string {
|
||||
if line := p.logWriter.LastLine(); line != "" {
|
||||
return line
|
||||
}
|
||||
p.mu.RLock()
|
||||
exitErr := p.exitErr
|
||||
p.mu.RUnlock()
|
||||
if exitErr != nil {
|
||||
return exitErr.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (p *Process) Start() error {
|
||||
if p.IsRunning() {
|
||||
return errors.New("tuic-server is already running")
|
||||
}
|
||||
cmd := exec.CommandContext(context.Background(), GetBinaryPath(), "-c", p.configPath)
|
||||
cmd.Stdout = p.logWriter
|
||||
cmd.Stderr = p.logWriter
|
||||
done := make(chan struct{})
|
||||
p.mu.Lock()
|
||||
p.cmd = cmd
|
||||
p.done = done
|
||||
p.exitErr = nil
|
||||
p.mu.Unlock()
|
||||
p.intentionalStop.Store(false)
|
||||
if err := cmd.Start(); err != nil {
|
||||
close(done)
|
||||
p.mu.Lock()
|
||||
p.cmd = nil
|
||||
p.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
attachChildLifetime(cmd)
|
||||
go p.wait(cmd, done)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Process) wait(cmd *exec.Cmd, done chan struct{}) {
|
||||
defer close(done)
|
||||
err := cmd.Wait()
|
||||
p.logWriter.Flush()
|
||||
if err == nil || p.intentionalStop.Load() {
|
||||
return
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "exit status 1") {
|
||||
p.setExitErr(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
logger.Errorf("tuic: tuic-server process exited: %v", err)
|
||||
p.setExitErr(err)
|
||||
}
|
||||
|
||||
func (p *Process) setExitErr(err error) {
|
||||
p.mu.Lock()
|
||||
p.exitErr = err
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func (p *Process) Stop() error {
|
||||
if !p.IsRunning() {
|
||||
return errors.New("tuic-server is not running")
|
||||
}
|
||||
p.intentionalStop.Store(true)
|
||||
p.mu.RLock()
|
||||
cmd, done := p.cmd, p.done
|
||||
p.mu.RUnlock()
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return errors.New("tuic-server is not running")
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
if err := cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
||||
return err
|
||||
}
|
||||
return waitForExit(done, forceStopTimeout)
|
||||
}
|
||||
|
||||
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
||||
if errors.Is(err, os.ErrProcessDone) {
|
||||
return waitForExit(done, forceStopTimeout)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if err := waitForExit(done, gracefulStopTimeout); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Warning("tuic: tuic-server did not stop after SIGTERM, killing process")
|
||||
if err := cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
||||
return err
|
||||
}
|
||||
return waitForExit(done, forceStopTimeout)
|
||||
}
|
||||
|
||||
func waitForExit(done <-chan struct{}, timeout time.Duration) error {
|
||||
if done == nil {
|
||||
return nil
|
||||
}
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-timer.C:
|
||||
return fmt.Errorf("timed out waiting for tuic-server process to stop after %s", timeout)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package tuic
|
||||
|
||||
import "os/exec"
|
||||
|
||||
func attachChildLifetime(_ *exec.Cmd) {}
|
||||
@@ -1,67 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package tuic
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"sync"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
killOnExitJobOnce sync.Once
|
||||
killOnExitJob windows.Handle
|
||||
killOnExitJobErr error
|
||||
)
|
||||
|
||||
func ensureKillOnExitJob() (windows.Handle, error) {
|
||||
killOnExitJobOnce.Do(func() {
|
||||
h, err := windows.CreateJobObject(nil, nil)
|
||||
if err != nil {
|
||||
killOnExitJobErr = err
|
||||
return
|
||||
}
|
||||
info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{
|
||||
BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{
|
||||
LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
},
|
||||
}
|
||||
_, err = windows.SetInformationJobObject(
|
||||
h,
|
||||
windows.JobObjectExtendedLimitInformation,
|
||||
uintptr(unsafe.Pointer(&info)),
|
||||
uint32(unsafe.Sizeof(info)),
|
||||
)
|
||||
if err != nil {
|
||||
_ = windows.CloseHandle(h)
|
||||
killOnExitJobErr = err
|
||||
return
|
||||
}
|
||||
killOnExitJob = h
|
||||
})
|
||||
return killOnExitJob, killOnExitJobErr
|
||||
}
|
||||
|
||||
func attachChildLifetime(cmd *exec.Cmd) {
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return
|
||||
}
|
||||
job, err := ensureKillOnExitJob()
|
||||
if err != nil {
|
||||
logger.Warningf("tuic: kill-on-exit job unavailable: %v", err)
|
||||
return
|
||||
}
|
||||
h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(cmd.Process.Pid))
|
||||
if err != nil {
|
||||
logger.Warningf("tuic: OpenProcess for job attach failed: %v", err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(h) }()
|
||||
if err := windows.AssignProcessToJobObject(job, h); err != nil {
|
||||
logger.Warningf("tuic: AssignProcessToJobObject failed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package tuic
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
const (
|
||||
// ProtocolVersion is the TUIC protocol version (0x05).
|
||||
ProtocolVersion byte = 0x05
|
||||
|
||||
// Command types
|
||||
CmdAuthenticate byte = 0x00
|
||||
CmdConnect byte = 0x01
|
||||
CmdPacket byte = 0x02
|
||||
CmdDissociate byte = 0x03
|
||||
CmdHeartbeat byte = 0x04
|
||||
|
||||
// Address types
|
||||
AddrTypeDomain byte = 0x00
|
||||
AddrTypeIPv4 byte = 0x01
|
||||
AddrTypeIPv6 byte = 0x02
|
||||
AddrTypeNone byte = 0xff
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidVersion = errors.New("tuic: invalid protocol version")
|
||||
ErrInvalidCmd = errors.New("tuic: invalid command type")
|
||||
ErrInvalidAddr = errors.New("tuic: invalid address format")
|
||||
)
|
||||
|
||||
// Address represents a network endpoint (host + port) in TUIC v5.
|
||||
type Address struct {
|
||||
Type byte
|
||||
Host string
|
||||
IP net.IP
|
||||
Port uint16
|
||||
}
|
||||
|
||||
// String returns "host:port" suitable for net.Dial.
|
||||
func (a *Address) String() string {
|
||||
if a == nil || a.Type == AddrTypeNone {
|
||||
return ""
|
||||
}
|
||||
if len(a.IP) > 0 {
|
||||
return net.JoinHostPort(a.IP.String(), strconv.Itoa(int(a.Port)))
|
||||
}
|
||||
return net.JoinHostPort(a.Host, strconv.Itoa(int(a.Port)))
|
||||
}
|
||||
|
||||
// ReadAddress decodes a TUIC v5 address from the reader.
|
||||
func ReadAddress(r io.Reader) (*Address, error) {
|
||||
var typeBuf [1]byte
|
||||
if _, err := io.ReadFull(r, typeBuf[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addrType := typeBuf[0]
|
||||
|
||||
if addrType == AddrTypeNone {
|
||||
return &Address{Type: AddrTypeNone}, nil
|
||||
}
|
||||
|
||||
addr := &Address{Type: addrType}
|
||||
|
||||
switch addrType {
|
||||
case AddrTypeIPv4:
|
||||
var ip [4]byte
|
||||
if _, err := io.ReadFull(r, ip[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addr.IP = net.IP(ip[:])
|
||||
addr.Host = addr.IP.String()
|
||||
|
||||
case AddrTypeIPv6:
|
||||
var ip [16]byte
|
||||
if _, err := io.ReadFull(r, ip[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addr.IP = net.IP(ip[:])
|
||||
addr.Host = addr.IP.String()
|
||||
|
||||
case AddrTypeDomain:
|
||||
var lenBuf [1]byte
|
||||
if _, err := io.ReadFull(r, lenBuf[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dLen := int(lenBuf[0])
|
||||
if dLen == 0 {
|
||||
return nil, ErrInvalidAddr
|
||||
}
|
||||
domainBuf := make([]byte, dLen)
|
||||
if _, err := io.ReadFull(r, domainBuf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addr.Host = string(domainBuf)
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: unknown type 0x%02x", ErrInvalidAddr, addrType)
|
||||
}
|
||||
|
||||
var portBuf [2]byte
|
||||
if _, err := io.ReadFull(r, portBuf[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addr.Port = binary.BigEndian.Uint16(portBuf[:])
|
||||
|
||||
return addr, nil
|
||||
}
|
||||
|
||||
// WriteAddress encodes a TUIC v5 address to the writer.
|
||||
func WriteAddress(w io.Writer, addr *Address) error {
|
||||
if addr == nil || addr.Type == AddrTypeNone {
|
||||
_, err := w.Write([]byte{AddrTypeNone})
|
||||
return err
|
||||
}
|
||||
|
||||
var buf []byte
|
||||
switch addr.Type {
|
||||
case AddrTypeIPv4:
|
||||
ip4 := addr.IP.To4()
|
||||
if len(ip4) != 4 {
|
||||
return ErrInvalidAddr
|
||||
}
|
||||
buf = make([]byte, 1+4+2)
|
||||
buf[0] = AddrTypeIPv4
|
||||
copy(buf[1:5], ip4)
|
||||
binary.BigEndian.PutUint16(buf[5:7], addr.Port)
|
||||
|
||||
case AddrTypeIPv6:
|
||||
ip16 := addr.IP.To16()
|
||||
if len(ip16) != 16 {
|
||||
return ErrInvalidAddr
|
||||
}
|
||||
buf = make([]byte, 1+16+2)
|
||||
buf[0] = AddrTypeIPv6
|
||||
copy(buf[1:17], ip16)
|
||||
binary.BigEndian.PutUint16(buf[17:19], addr.Port)
|
||||
|
||||
case AddrTypeDomain:
|
||||
dLen := len(addr.Host)
|
||||
if dLen == 0 || dLen > 255 {
|
||||
return ErrInvalidAddr
|
||||
}
|
||||
buf = make([]byte, 1+1+dLen+2)
|
||||
buf[0] = AddrTypeDomain
|
||||
buf[1] = byte(dLen)
|
||||
copy(buf[2:2+dLen], []byte(addr.Host))
|
||||
binary.BigEndian.PutUint16(buf[2+dLen:4+dLen], addr.Port)
|
||||
|
||||
default:
|
||||
return ErrInvalidAddr
|
||||
}
|
||||
|
||||
_, err := w.Write(buf)
|
||||
return err
|
||||
}
|
||||
|
||||
// ReadCommand reads the 2-byte TUIC command header: [VER (1)][TYPE (1)].
|
||||
func ReadCommand(r io.Reader) (byte, byte, error) {
|
||||
var hdr [2]byte
|
||||
if _, err := io.ReadFull(r, hdr[:]); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if hdr[0] != ProtocolVersion {
|
||||
return hdr[0], hdr[1], fmt.Errorf("%w: got 0x%02x, want 0x%02x", ErrInvalidVersion, hdr[0], ProtocolVersion)
|
||||
}
|
||||
return hdr[0], hdr[1], nil
|
||||
}
|
||||
|
||||
// PacketHeader represents the header of a UDP Packet command (0x02).
|
||||
type PacketHeader struct {
|
||||
AssocID uint16
|
||||
PktID uint16
|
||||
FragTotal uint8
|
||||
FragID uint8
|
||||
Size uint16
|
||||
Addr *Address
|
||||
}
|
||||
|
||||
// ReadPacketHeader reads the packet command fields following [VER][0x02].
|
||||
func ReadPacketHeader(r io.Reader) (*PacketHeader, error) {
|
||||
var fixed [8]byte
|
||||
if _, err := io.ReadFull(r, fixed[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ph := &PacketHeader{
|
||||
AssocID: binary.BigEndian.Uint16(fixed[0:2]),
|
||||
PktID: binary.BigEndian.Uint16(fixed[2:4]),
|
||||
FragTotal: fixed[4],
|
||||
FragID: fixed[5],
|
||||
Size: binary.BigEndian.Uint16(fixed[6:8]),
|
||||
}
|
||||
|
||||
addr, err := ReadAddress(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ph.Addr = addr
|
||||
return ph, nil
|
||||
}
|
||||
|
||||
// WritePacket writes a complete Packet command frame to w.
|
||||
func WritePacket(w io.Writer, assocID, pktID uint16, fragTotal, fragID uint8, addr *Address, payload []byte) error {
|
||||
hdr := make([]byte, 10)
|
||||
hdr[0] = ProtocolVersion
|
||||
hdr[1] = CmdPacket
|
||||
binary.BigEndian.PutUint16(hdr[2:4], assocID)
|
||||
binary.BigEndian.PutUint16(hdr[4:6], pktID)
|
||||
hdr[6] = fragTotal
|
||||
hdr[7] = fragID
|
||||
binary.BigEndian.PutUint16(hdr[8:10], uint16(len(payload)))
|
||||
|
||||
if _, err := w.Write(hdr); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := WriteAddress(w, addr); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := w.Write(payload)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package tuic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAddressEncodingDecoding(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
addr *Address
|
||||
}{
|
||||
{
|
||||
name: "IPv4",
|
||||
addr: &Address{
|
||||
Type: AddrTypeIPv4,
|
||||
IP: net.ParseIP("1.2.3.4").To4(),
|
||||
Host: "1.2.3.4",
|
||||
Port: 443,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IPv6",
|
||||
addr: &Address{
|
||||
Type: AddrTypeIPv6,
|
||||
IP: net.ParseIP("2001:db8::1"),
|
||||
Host: "2001:db8::1",
|
||||
Port: 8080,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Domain",
|
||||
addr: &Address{
|
||||
Type: AddrTypeDomain,
|
||||
Host: "example.com",
|
||||
Port: 8443,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "None",
|
||||
addr: &Address{
|
||||
Type: AddrTypeNone,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := WriteAddress(&buf, tc.addr); err != nil {
|
||||
t.Fatalf("WriteAddress error: %v", err)
|
||||
}
|
||||
|
||||
decoded, err := ReadAddress(&buf)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAddress error: %v", err)
|
||||
}
|
||||
|
||||
if tc.addr.Type == AddrTypeNone {
|
||||
if decoded.Type != AddrTypeNone {
|
||||
t.Fatalf("expected None type, got %v", decoded.Type)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if decoded.Type != tc.addr.Type {
|
||||
t.Errorf("Type mismatch: got %v, want %v", decoded.Type, tc.addr.Type)
|
||||
}
|
||||
if decoded.Port != tc.addr.Port {
|
||||
t.Errorf("Port mismatch: got %v, want %v", decoded.Port, tc.addr.Port)
|
||||
}
|
||||
if tc.addr.Type == AddrTypeDomain {
|
||||
if decoded.Host != tc.addr.Host {
|
||||
t.Errorf("Host mismatch: got %v, want %v", decoded.Host, tc.addr.Host)
|
||||
}
|
||||
} else {
|
||||
if !decoded.IP.Equal(tc.addr.IP) {
|
||||
t.Errorf("IP mismatch: got %v, want %v", decoded.IP, tc.addr.IP)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandHeader(t *testing.T) {
|
||||
buf := bytes.NewBuffer([]byte{0x05, 0x01})
|
||||
ver, cmd, err := ReadCommand(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadCommand error: %v", err)
|
||||
}
|
||||
if ver != ProtocolVersion || cmd != CmdConnect {
|
||||
t.Fatalf("got ver=%d, cmd=%d; want ver=5, cmd=1", ver, cmd)
|
||||
}
|
||||
|
||||
invalidBuf := bytes.NewBuffer([]byte{0x04, 0x01})
|
||||
_, _, err = ReadCommand(invalidBuf)
|
||||
if err == nil {
|
||||
t.Fatal("expected error on invalid version, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPacketHeaderAndPayload(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
target := &Address{
|
||||
Type: AddrTypeDomain,
|
||||
Host: "dns.google",
|
||||
Port: 53,
|
||||
}
|
||||
payload := []byte("hello-udp")
|
||||
|
||||
err := WritePacket(&buf, 100, 1, 1, 0, target, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("WritePacket error: %v", err)
|
||||
}
|
||||
|
||||
ver, cmd, err := ReadCommand(&buf)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadCommand error: %v", err)
|
||||
}
|
||||
if ver != ProtocolVersion || cmd != CmdPacket {
|
||||
t.Fatalf("got ver=%d cmd=%d, want 5 and 2", ver, cmd)
|
||||
}
|
||||
|
||||
ph, err := ReadPacketHeader(&buf)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPacketHeader error: %v", err)
|
||||
}
|
||||
|
||||
if ph.AssocID != 100 || ph.PktID != 1 || ph.FragTotal != 1 || ph.FragID != 0 {
|
||||
t.Fatalf("PacketHeader mismatch: %+v", ph)
|
||||
}
|
||||
if ph.Addr.Host != "dns.google" || ph.Addr.Port != 53 {
|
||||
t.Fatalf("Packet address mismatch: %+v", ph.Addr)
|
||||
}
|
||||
|
||||
readPayload := make([]byte, ph.Size)
|
||||
if _, err := buf.Read(readPayload); err != nil {
|
||||
t.Fatalf("reading payload error: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(readPayload, payload) {
|
||||
t.Fatalf("payload mismatch: got %s, want %s", readPayload, payload)
|
||||
}
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
package tuic
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A QUIC flow the sidecar has not touched for this long is forgotten; QUIC's
|
||||
// own max_idle_time (15s by default) closes the session well before that.
|
||||
const relayFlowIdle = 2 * time.Minute
|
||||
|
||||
const (
|
||||
relaySocketBuffer = 4 << 20
|
||||
maxRelayFlows = 4096
|
||||
)
|
||||
|
||||
// udpRelay owns an inbound's public UDP port and counts the bytes it forwards to
|
||||
// the sidecar on loopback: tuic-server has no stats API and /proc/io stays at 0.
|
||||
type udpRelay struct {
|
||||
public *net.UDPConn
|
||||
upstream *net.UDPAddr
|
||||
idle time.Duration
|
||||
maxFlows int
|
||||
up atomic.Int64
|
||||
down atomic.Int64
|
||||
mu sync.Mutex
|
||||
flows map[string]*relayFlow
|
||||
done chan struct{}
|
||||
closeOnce sync.Once
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
type relayFlow struct {
|
||||
conn *net.UDPConn
|
||||
client *net.UDPAddr
|
||||
lastSeen atomic.Int64
|
||||
}
|
||||
|
||||
func startUDPRelay(bind string, upstream *net.UDPAddr, idle time.Duration) (*udpRelay, error) {
|
||||
addr, err := net.ResolveUDPAddr("udp", bind)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
public, err := net.ListenUDP("udp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = public.SetReadBuffer(relaySocketBuffer)
|
||||
_ = public.SetWriteBuffer(relaySocketBuffer)
|
||||
r := &udpRelay{
|
||||
public: public,
|
||||
upstream: upstream,
|
||||
idle: idle,
|
||||
maxFlows: maxRelayFlows,
|
||||
flows: make(map[string]*relayFlow),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
r.wg.Add(2)
|
||||
go r.serve()
|
||||
go r.sweep()
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func freeLoopbackUDPPort() (int, error) {
|
||||
c, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer c.Close()
|
||||
return c.LocalAddr().(*net.UDPAddr).Port, nil
|
||||
}
|
||||
|
||||
func (r *udpRelay) LocalAddr() net.Addr {
|
||||
return r.public.LocalAddr()
|
||||
}
|
||||
|
||||
// CollectTraffic returns the client-to-sidecar and sidecar-to-client bytes
|
||||
// relayed since the previous call.
|
||||
func (r *udpRelay) CollectTraffic() (up, down int64) {
|
||||
return r.up.Swap(0), r.down.Swap(0)
|
||||
}
|
||||
|
||||
func (r *udpRelay) Close() {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.closeOnce.Do(func() {
|
||||
close(r.done)
|
||||
_ = r.public.Close()
|
||||
r.mu.Lock()
|
||||
for key, f := range r.flows {
|
||||
_ = f.conn.Close()
|
||||
delete(r.flows, key)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
r.wg.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
func (r *udpRelay) serve() {
|
||||
defer r.wg.Done()
|
||||
buf := make([]byte, 65535)
|
||||
for {
|
||||
n, client, err := r.public.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
flow, err := r.flowFor(client)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, err := flow.conn.Write(buf[:n]); err == nil {
|
||||
r.up.Add(int64(n))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *udpRelay) flowFor(client *net.UDPAddr) (*relayFlow, error) {
|
||||
key := client.String()
|
||||
now := time.Now().UnixMilli()
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
select {
|
||||
case <-r.done:
|
||||
return nil, net.ErrClosed
|
||||
default:
|
||||
}
|
||||
if f, ok := r.flows[key]; ok {
|
||||
f.lastSeen.Store(now)
|
||||
return f, nil
|
||||
}
|
||||
if len(r.flows) >= r.maxFlows {
|
||||
r.evictLeastRecentLocked()
|
||||
}
|
||||
conn, err := net.DialUDP("udp", nil, r.upstream)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = conn.SetReadBuffer(relaySocketBuffer)
|
||||
_ = conn.SetWriteBuffer(relaySocketBuffer)
|
||||
f := &relayFlow{conn: conn, client: client}
|
||||
f.lastSeen.Store(now)
|
||||
r.flows[key] = f
|
||||
r.wg.Add(1)
|
||||
go r.pump(f)
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Refusing a newcomer at the cap let 4096 junk datagrams lock every new client
|
||||
// out until the sweep; the flow last seen longest ago is the junk one.
|
||||
func (r *udpRelay) evictLeastRecentLocked() {
|
||||
var oldestKey string
|
||||
oldest := int64(-1)
|
||||
for key, f := range r.flows {
|
||||
if seen := f.lastSeen.Load(); oldest < 0 || seen < oldest {
|
||||
oldest, oldestKey = seen, key
|
||||
}
|
||||
}
|
||||
if f, ok := r.flows[oldestKey]; ok {
|
||||
_ = f.conn.Close()
|
||||
delete(r.flows, oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *udpRelay) pump(f *relayFlow) {
|
||||
defer r.wg.Done()
|
||||
buf := make([]byte, 65535)
|
||||
for {
|
||||
n, err := f.conn.Read(buf)
|
||||
if err != nil {
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
return
|
||||
}
|
||||
// ICMP unreachable while the sidecar restarts: drop it, keep the flow.
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
if _, err := r.public.WriteToUDP(buf[:n], f.client); err == nil {
|
||||
r.down.Add(int64(n))
|
||||
}
|
||||
f.lastSeen.Store(time.Now().UnixMilli())
|
||||
}
|
||||
}
|
||||
|
||||
func (r *udpRelay) sweep() {
|
||||
defer r.wg.Done()
|
||||
ticker := time.NewTicker(r.idle / 2)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-r.done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
cutoff := time.Now().Add(-r.idle).UnixMilli()
|
||||
r.mu.Lock()
|
||||
for key, f := range r.flows {
|
||||
if f.lastSeen.Load() < cutoff {
|
||||
_ = f.conn.Close()
|
||||
delete(r.flows, key)
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package tuic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
clientquic "github.com/quic-go/quic-go"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
)
|
||||
|
||||
func audit3LogsStart(t *testing.T, level, marker, relayAddr string) (*Server, *clientquic.Conn, uuid.UUID, string, []byte) {
|
||||
t.Helper()
|
||||
cert, key := generateTestCert(t)
|
||||
id := uuid.New()
|
||||
password := "PASSWORD-CANARY-" + marker
|
||||
s, err := NewServer(Instance{
|
||||
Id: 192301, Tag: marker, Listen: "127.0.0.1", Port: 0,
|
||||
Certificate: string(cert), PrivateKey: string(key), ALPN: []string{"h3"},
|
||||
AuthenticationTimeout: 2, MaxIdleTime: 30, LogLevel: level,
|
||||
Clients: []TuicClientSettings{{UUID: id.String(), Password: password, Email: "audit3-log@example.test"}},
|
||||
}, &SocksRelay{Addr: relayAddr, Password: "audit3-socks-pass"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
c, err := clientquic.DialAddr(ctx, s.packetConn.LocalAddr().String(), &tls.Config{InsecureSkipVerify: true, NextProtos: []string{"h3"}}, &clientquic.Config{EnableDatagrams: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = c.CloseWithError(0, "audit3 finished") })
|
||||
tlsState := c.ConnectionState().TLS
|
||||
token, err := tlsState.ExportKeyingMaterial(string(id[:]), []byte(password), 32)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
auth, err := c.OpenUniStreamSync(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
frame := append([]byte{5, 0}, id[:]...)
|
||||
frame = append(frame, token...)
|
||||
if _, err := auth.Write(frame); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := auth.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = authenticatedServerConnection(t, s, id)
|
||||
return s, c, id, password, token
|
||||
}
|
||||
|
||||
func audit3LogsFor(marker string) string {
|
||||
var lines []string
|
||||
for _, line := range logger.GetLogs(10000, "DEBUG") {
|
||||
if strings.Contains(line, marker) {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func TestAudit3RealEventsRespectThresholdAndDoNotExposeSecrets(t *testing.T) {
|
||||
for _, level := range []string{"debug", "info", "warn", "error"} {
|
||||
t.Run(level, func(t *testing.T) {
|
||||
marker := fmt.Sprintf("audit3-logs-%s-%d", level, time.Now().UnixNano())
|
||||
relayAddr, cleanup := startMockSocks5Server(t, "audit3-log@example.test", "audit3-socks-pass")
|
||||
defer cleanup()
|
||||
s, c, id, password, token := audit3LogsStart(t, level, marker, relayAddr)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
payload := "PAYLOAD-CANARY-" + marker
|
||||
u, err := c.OpenStreamSync(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var frame bytes.Buffer
|
||||
frame.Write([]byte{5, 1})
|
||||
injectedDomain := "audit.invalid-FORGED-ENTRY-" + marker
|
||||
if err := WriteAddress(&frame, &Address{Type: AddrTypeDomain, Host: injectedDomain, Port: 443}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
frame.WriteString(payload)
|
||||
if _, err := u.Write(frame.Bytes()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := u.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := make([]byte, len(payload))
|
||||
if _, err := io.ReadFull(u, got); err != nil || string(got) != payload {
|
||||
t.Fatalf("TCP echo %q, %v", got, err)
|
||||
}
|
||||
if _, err := io.Copy(io.Discard, u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u.CancelRead(0)
|
||||
var udp bytes.Buffer
|
||||
if err := WritePacket(&udp, 23456, 1, 1, 0, &Address{Type: AddrTypeIPv4, IP: net.IPv4(8, 8, 8, 8), Port: 53}, []byte(payload)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.SendDatagram(udp.Bytes()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.ReceiveDatagram(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dissociate, err := c.OpenUniStreamSync(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := dissociate.Write([]byte{5, 3, 0x5b, 0xa0}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = dissociate.Close()
|
||||
// The malformed frame includes traffic content as a canary; it must remain absent from logs.
|
||||
if err := c.SendDatagram(append([]byte{5, 2}, []byte(payload)...)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bad, err := c.OpenStreamSync(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := bad.Write([]byte{5, 1, 0xff}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := io.Copy(io.Discard, bad); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = bad.Close()
|
||||
// Trigger a rejected Authenticate event using a changed token on the authenticated connection.
|
||||
badAuth, err := c.OpenUniStreamSync(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wrongToken := bytes.Repeat([]byte{0x6d}, 32)
|
||||
badFrame := append([]byte{5, 0}, id[:]...)
|
||||
badFrame = append(badFrame, wrongToken...)
|
||||
if _, err := badAuth.Write(badFrame); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = badAuth.Close()
|
||||
select {
|
||||
case <-c.Context().Done():
|
||||
case <-ctx.Done():
|
||||
t.Fatal("bad auth did not close connection")
|
||||
}
|
||||
_ = s.packetConn.Close()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for s.IsRunning() && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
_ = s.Close()
|
||||
logs := audit3LogsFor(marker)
|
||||
for _, secret := range []string{password, id.String(), hex.EncodeToString(id[:]), hex.EncodeToString(token), hex.EncodeToString(wrongToken), payload, injectedDomain} {
|
||||
if strings.Contains(logs, secret) {
|
||||
t.Fatalf("logs expose canary %q", secret)
|
||||
}
|
||||
}
|
||||
wantInfo := level == "debug" || level == "info"
|
||||
wantWarn := level != "error"
|
||||
for _, event := range []string{"listener started", "client authenticated", "TCP relay started", "UDP association 23456 started", "listener stopped"} {
|
||||
if got := strings.Contains(logs, "): "+event); got != wantInfo {
|
||||
t.Errorf("event %q present=%t, want %t\n%s", event, got, wantInfo, logs)
|
||||
}
|
||||
}
|
||||
for _, event := range []string{"TCP relay failed", "client authentication rejected"} {
|
||||
if got := strings.Contains(logs, event); got != wantWarn {
|
||||
t.Errorf("event %q present=%t, want %t\n%s", event, got, wantWarn, logs)
|
||||
}
|
||||
}
|
||||
if got := strings.Contains(logs, "applied bbr congestion controller"); got != (level == "debug") {
|
||||
t.Errorf("debug controller event=%t", got)
|
||||
}
|
||||
if !strings.Contains(logs, "QUIC listener stopped accepting connections") {
|
||||
t.Errorf("actual listener error event missing at %s\n%s", level, logs)
|
||||
}
|
||||
t.Logf("actual logger events at %s: %d", level, strings.Count(logs, "tuic: inbound"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudit3TCPFailuresMustNotFloodPanelLogs(t *testing.T) {
|
||||
marker := fmt.Sprintf("audit3-flood-%d", time.Now().UnixNano())
|
||||
relayAddr, cleanup := startMockSocks5Server(t, "audit3-log@example.test", "audit3-socks-pass")
|
||||
defer cleanup()
|
||||
_, c, _, _, _ := audit3LogsStart(t, "warn", marker, relayAddr)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
for i := 0; i < 110; i++ {
|
||||
u, err := c.OpenStreamSync(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CONNECT%d: %v", i, err)
|
||||
}
|
||||
if _, err := u.Write([]byte{5, 1, 0xff}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := io.Copy(io.Discard, u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = u.Close()
|
||||
}
|
||||
count := strings.Count(audit3LogsFor(marker), "TCP relay failed")
|
||||
if count != 1 {
|
||||
t.Fatalf("one authenticated QUIC connection emitted %d TCP failure warnings for 110 commands; expected a bounded warning category", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudit3BiStreamCreditKeepsTCPEchoUsable(t *testing.T) {
|
||||
marker := fmt.Sprintf("audit3-credit-%d", time.Now().UnixNano())
|
||||
relayAddr, cleanup := startMockSocks5Server(t, "audit3-log@example.test", "audit3-socks-pass")
|
||||
defer cleanup()
|
||||
_, c, _, _, _ := audit3LogsStart(t, "error", marker, relayAddr)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
for i := 0; i < 110; i++ {
|
||||
openCtx, openCancel := context.WithTimeout(ctx, 700*time.Millisecond)
|
||||
u, err := c.OpenStreamSync(openCtx)
|
||||
openCancel()
|
||||
if err != nil {
|
||||
t.Fatalf("malformed stream%d open: %v", i, err)
|
||||
}
|
||||
if _, err := u.Write([]byte{5, 0xff}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := io.Copy(io.Discard, u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = u.Close()
|
||||
}
|
||||
u, err := c.OpenStreamSync(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var frame bytes.Buffer
|
||||
frame.Write([]byte{5, 1})
|
||||
if err := WriteAddress(&frame, &Address{Type: AddrTypeIPv4, IP: net.IPv4(8, 8, 8, 8), Port: 443}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
frame.WriteString("after-credit-errors")
|
||||
if _, err := u.Write(frame.Bytes()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = u.Close()
|
||||
result, err := io.ReadAll(u)
|
||||
if err != nil || string(result) != "after-credit-errors" {
|
||||
t.Fatalf("subsequent real TCP relay result=%q err=%v", result, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package tuic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"net"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
|
||||
clientquic "github.com/quic-go/quic-go"
|
||||
)
|
||||
|
||||
func audit3RestartableSOCKS(t *testing.T) (string, *net.UDPConn, *net.UDPAddr) {
|
||||
t.Helper()
|
||||
u, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
udpAddr := u.LocalAddr().(*net.UDPAddr)
|
||||
ln, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = ln.Close(); _ = u.Close() })
|
||||
go func() {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func(c net.Conn) {
|
||||
defer c.Close()
|
||||
var greet [4]byte
|
||||
if _, err := io.ReadFull(c, greet[:]); err != nil {
|
||||
return
|
||||
}
|
||||
if _, err := c.Write([]byte{5, 0}); err != nil {
|
||||
return
|
||||
}
|
||||
var req [10]byte
|
||||
if _, err := io.ReadFull(c, req[:]); err != nil {
|
||||
return
|
||||
}
|
||||
reply := []byte{5, 0, 0, 1, 127, 0, 0, 1, 0, 0}
|
||||
binary.BigEndian.PutUint16(reply[8:], uint16(udpAddr.Port))
|
||||
if _, err := c.Write(reply); err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, c)
|
||||
}(c)
|
||||
}
|
||||
}()
|
||||
return ln.Addr().String(), u, udpAddr
|
||||
}
|
||||
|
||||
func audit3StartUDPEcho(u *net.UDPConn, arrived chan<- struct{}) {
|
||||
go func() {
|
||||
buf := make([]byte, 2048)
|
||||
for {
|
||||
n, src, err := u.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = u.WriteToUDP(buf[:n], src)
|
||||
if arrived != nil {
|
||||
select {
|
||||
case arrived <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func audit3SendPacket(t *testing.T, c *clientquic.Conn, mode uint8, assoc, pkt uint16, payload string) {
|
||||
t.Helper()
|
||||
var b bytes.Buffer
|
||||
if err := WritePacket(&b, assoc, pkt, 1, 0, &Address{Type: AddrTypeIPv4, IP: net.IPv4(8, 8, 8, 8), Port: 53}, []byte(payload)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mode == packetTransportDatagram {
|
||||
if err := c.SendDatagram(b.Bytes()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
s, err := c.OpenUniStreamSync(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Write(b.Bytes()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = s.Close()
|
||||
}
|
||||
|
||||
func audit3ReceivePacket(c *clientquic.Conn, mode uint8, duration time.Duration) (*PacketHeader, []byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), duration)
|
||||
defer cancel()
|
||||
var r io.Reader
|
||||
if mode == packetTransportDatagram {
|
||||
b, err := c.ReceiveDatagram(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
r = bytes.NewReader(b)
|
||||
} else {
|
||||
s, err := c.AcceptUniStream(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer s.CancelRead(0)
|
||||
_ = s.SetReadDeadline(time.Now().Add(duration))
|
||||
r = s
|
||||
}
|
||||
if _, _, err := ReadCommand(r); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
h, err := ReadPacketHeader(r)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
p, err := readPacketPayload(r, h)
|
||||
return h, p, err
|
||||
}
|
||||
|
||||
func TestAudit3UDPAssociationMustRecoverAfterBridgeReadFailure(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Go disables SIO_UDP_CONNRESET on Windows, so a dead UDP bridge never fails a read there")
|
||||
}
|
||||
for _, mode := range []uint8{packetTransportDatagram, packetTransportStream} {
|
||||
name := "datagram"
|
||||
if mode == packetTransportStream {
|
||||
name = "stream"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
relayAddr, u, udpAddr := audit3RestartableSOCKS(t)
|
||||
audit3StartUDPEcho(u, nil)
|
||||
s, c, id, _ := startLifecycleTestServer(t, relayAddr, "audit3-recovery@x")
|
||||
_, user := authenticatedServerConnection(t, s, id)
|
||||
audit3SendPacket(t, c, mode, 42131, 1, "before")
|
||||
if _, p, err := audit3ReceivePacket(c, mode, time.Second); err != nil || string(p) != "before" {
|
||||
t.Fatalf("initial echo %q %v", p, err)
|
||||
}
|
||||
|
||||
s.UpdateRuntimeSettings("recovery-"+name, "bbr", "warn")
|
||||
_ = u.Close()
|
||||
audit3SendPacket(t, c, mode, 42131, 2, "while-down")
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
found := false
|
||||
for _, line := range logger.GetLogs(10000, "DEBUG") {
|
||||
if strings.Contains(line, "recovery-"+name) && strings.Contains(line, "UDP relay receive failed") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if found {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("closed UDP bridge did not terminate the response reader")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
u2, err := net.ListenUDP("udp4", udpAddr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = u2.Close() })
|
||||
arrived := make(chan struct{}, 4)
|
||||
audit3StartUDPEcho(u2, arrived)
|
||||
audit3SendPacket(t, c, mode, 42131, 3, "after")
|
||||
select {
|
||||
case <-arrived:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("restarted bridge did not receive the retained association request")
|
||||
}
|
||||
_, payload, oldErr := audit3ReceivePacket(c, mode, 300*time.Millisecond)
|
||||
// A different association proves QUIC, the restarted SOCKS bridge, and both transport modes remain functional.
|
||||
audit3SendPacket(t, c, mode, 42132, 4, "fresh")
|
||||
h, p, newErr := audit3ReceivePacket(c, mode, time.Second)
|
||||
if newErr != nil || h.AssocID != 42132 || string(p) != "fresh" {
|
||||
t.Fatalf("fresh association probe %v %q %v", h, p, newErr)
|
||||
}
|
||||
if oldErr != nil || string(payload) != "after" {
|
||||
t.Fatalf("retained association became receive blackhole after read failure: response %q err=%v; fresh association works; user up=%d down=%d", payload, oldErr, user.Traffic.BytesUp.Load(), user.Traffic.BytesDown.Load())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudit3FirstTransportReturnsPositiveEchoAfterOppositeModePacket(t *testing.T) {
|
||||
for _, first := range []uint8{packetTransportDatagram, packetTransportStream} {
|
||||
name := "datagram-first"
|
||||
if first == packetTransportStream {
|
||||
name = "stream-first"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
relayAddr, u, _ := audit3RestartableSOCKS(t)
|
||||
audit3StartUDPEcho(u, nil)
|
||||
_, c, _, _ := startLifecycleTestServer(t, relayAddr, "audit3-first-mode@x")
|
||||
audit3SendPacket(t, c, first, 43221, 1, "first")
|
||||
if _, p, err := audit3ReceivePacket(c, first, time.Second); err != nil || string(p) != "first" {
|
||||
t.Fatalf("initial mode echo %q %v", p, err)
|
||||
}
|
||||
audit3SendPacket(t, c, 1-first, 43221, 2, "opposite")
|
||||
if _, p, err := audit3ReceivePacket(c, first, time.Second); err != nil || string(p) != "opposite" {
|
||||
t.Fatalf("opposite request response did not keep first transport: %q %v", p, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUDPReaderCleanupCannotDeleteReplacementAssociation(t *testing.T) {
|
||||
registry := newUdpAssociationRegistry(1500)
|
||||
header := &PacketHeader{AssocID: 7, FragTotal: 1, Size: 1, Addr: &Address{Type: AddrTypeIPv4, IP: net.IPv4(1, 1, 1, 1), Port: 53}}
|
||||
old, _, _, complete := registry.feed(packetTransportDatagram, header, []byte("a"))
|
||||
if !complete {
|
||||
t.Fatal("first packet incomplete")
|
||||
}
|
||||
registry.dissociate(7)
|
||||
replacement, _, _, complete := registry.feed(packetTransportStream, header, []byte("b"))
|
||||
if !complete || replacement == old {
|
||||
t.Fatal("association generation was reused")
|
||||
}
|
||||
registry.release(7, old)
|
||||
if !registry.touch(7, replacement, time.Now()) {
|
||||
t.Fatal("late reader cleanup deleted the new association")
|
||||
}
|
||||
registry.release(7, replacement)
|
||||
if registry.touch(7, replacement, time.Now()) {
|
||||
t.Fatal("current reader cleanup retained dead association")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package tuic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAudit3CloseMustInterruptIdleTCPRelay(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
peerFIN := make(chan struct{})
|
||||
ready := make(chan struct{})
|
||||
go func() {
|
||||
c, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
var greeting [4]byte
|
||||
if _, err := io.ReadFull(c, greeting[:]); err != nil {
|
||||
return
|
||||
}
|
||||
c.Write([]byte{5, 0})
|
||||
var req [10]byte
|
||||
if _, err := io.ReadFull(c, req[:]); err != nil {
|
||||
return
|
||||
}
|
||||
c.Write([]byte{5, 0, 0, 1, 127, 0, 0, 1, 0, 0})
|
||||
var payload [1]byte
|
||||
if _, err := io.ReadFull(c, payload[:]); err != nil {
|
||||
return
|
||||
}
|
||||
c.Write(payload[:])
|
||||
close(ready)
|
||||
io.Copy(io.Discard, c)
|
||||
close(peerFIN)
|
||||
<-release
|
||||
}()
|
||||
server, conn, _, _ := startLifecycleTestServer(t, listener.Addr().String(), "close-idle@audit3")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
stream, err := conn.OpenStreamSync(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var cmd bytes.Buffer
|
||||
cmd.Write([]byte{ProtocolVersion, CmdConnect})
|
||||
WriteAddress(&cmd, &Address{Type: AddrTypeIPv4, IP: net.ParseIP("1.1.1.1"), Port: 443})
|
||||
cmd.WriteByte('x')
|
||||
if _, err := stream.Write(cmd.Bytes()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var echo [1]byte
|
||||
if _, err := io.ReadFull(stream, echo[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
<-ready
|
||||
closed := make(chan error, 1)
|
||||
started := time.Now()
|
||||
go func() { closed <- server.Close() }()
|
||||
|
||||
select {
|
||||
case err := <-closed:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("Server.Close waited for idle TCP peer after %s", time.Since(started))
|
||||
}
|
||||
select {
|
||||
case <-peerFIN:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("upstream socket did not close")
|
||||
}
|
||||
_, _, deltas := server.CollectAllTraffic()
|
||||
if len(deltas) != 1 || deltas[0].Up != 1 || deltas[0].Down != 1 {
|
||||
t.Fatalf("final traffic: %+v", deltas)
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
package tuic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// doublingEcho answers every datagram with the payload repeated twice, so a
|
||||
// relay that mislabels directions or clients cannot pass by accident.
|
||||
func doublingEcho(t *testing.T) *net.UDPAddr {
|
||||
t.Helper()
|
||||
echo, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = echo.Close() })
|
||||
go func() {
|
||||
buf := make([]byte, 65535)
|
||||
for {
|
||||
n, from, err := echo.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = echo.WriteToUDP(append(append([]byte{}, buf[:n]...), buf[:n]...), from)
|
||||
}
|
||||
}()
|
||||
return echo.LocalAddr().(*net.UDPAddr)
|
||||
}
|
||||
|
||||
func roundTrip(t *testing.T, relay *udpRelay, payload []byte) int {
|
||||
t.Helper()
|
||||
c, err := net.DialUDP("udp", nil, relay.LocalAddr().(*net.UDPAddr))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
if _, err := c.Write(payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = c.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
buf := make([]byte, 65535)
|
||||
n, err := c.Read(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("no reply through the relay: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func collectUntil(t *testing.T, relay *udpRelay, wantUp, wantDown int64) (int64, int64) {
|
||||
t.Helper()
|
||||
var up, down int64
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
u, d := relay.CollectTraffic()
|
||||
up, down = up+u, down+d
|
||||
if (up >= wantUp && down >= wantDown) || time.Now().After(deadline) {
|
||||
return up, down
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUDPRelayMetersBothDirectionsPerClient(t *testing.T) {
|
||||
relay, err := startUDPRelay("127.0.0.1:0", doublingEcho(t), relayFlowIdle)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(relay.Close)
|
||||
|
||||
if got := roundTrip(t, relay, bytes.Repeat([]byte("a"), 100)); got != 200 {
|
||||
t.Fatalf("client A reply = %d bytes, want 200", got)
|
||||
}
|
||||
if got := roundTrip(t, relay, bytes.Repeat([]byte("b"), 50)); got != 100 {
|
||||
t.Fatalf("client B reply = %d bytes, want 100", got)
|
||||
}
|
||||
if up, down := collectUntil(t, relay, 150, 300); up != 150 || down != 300 {
|
||||
t.Fatalf("delta = (%d up, %d down), want (150, 300)", up, down)
|
||||
}
|
||||
if up, down := relay.CollectTraffic(); up != 0 || down != 0 {
|
||||
t.Fatalf("second collect = (%d, %d), want (0, 0): deltas must reset", up, down)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUDPRelayExpiresIdleFlows(t *testing.T) {
|
||||
relay, err := startUDPRelay("127.0.0.1:0", doublingEcho(t), 50*time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(relay.Close)
|
||||
|
||||
roundTrip(t, relay, []byte("hello"))
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
relay.mu.Lock()
|
||||
n := len(relay.flows)
|
||||
relay.mu.Unlock()
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("%d flow(s) still open after the idle window", n)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if got := roundTrip(t, relay, []byte("again")); got != 10 {
|
||||
t.Fatalf("reply after expiry = %d bytes, want 10", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUDPRelayRefusesFlowsAfterClose(t *testing.T) {
|
||||
relay, err := startUDPRelay("127.0.0.1:0", doublingEcho(t), relayFlowIdle)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
relay.Close()
|
||||
if _, err := relay.flowFor(&net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 9}); err == nil {
|
||||
t.Fatal("flowFor after Close must refuse: its pump would outlive the relay and hang Close's WaitGroup")
|
||||
}
|
||||
relay.mu.Lock()
|
||||
n := len(relay.flows)
|
||||
relay.mu.Unlock()
|
||||
if n != 0 {
|
||||
t.Fatalf("%d flow(s) registered after Close", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUDPRelayFullTableAdmitsNewClient(t *testing.T) {
|
||||
relay, err := startUDPRelay("127.0.0.1:0", doublingEcho(t), relayFlowIdle)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(relay.Close)
|
||||
relay.mu.Lock()
|
||||
relay.maxFlows = 2
|
||||
relay.mu.Unlock()
|
||||
|
||||
roundTrip(t, relay, []byte("a"))
|
||||
roundTrip(t, relay, []byte("b"))
|
||||
if got := roundTrip(t, relay, []byte("c")); got != 2 {
|
||||
t.Fatalf("third client reply = %d bytes, want 2: a full table must evict, not refuse", got)
|
||||
}
|
||||
relay.mu.Lock()
|
||||
n := len(relay.flows)
|
||||
relay.mu.Unlock()
|
||||
if n != 2 {
|
||||
t.Fatalf("flow table holds %d flows after admitting a third client, want the cap of 2", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package tuic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAudit3ActualSessionRevokedAndSnapshotDrain(t *testing.T) {
|
||||
for _, operation := range []string{"remove", "password", "email", "uuid"} {
|
||||
t.Run(operation, func(t *testing.T) {
|
||||
server, conn, id, password := startLifecycleTestServer(t, "127.0.0.1:1", "old@audit3")
|
||||
_, user := authenticatedServerConnection(t, server, id)
|
||||
user.Traffic.BytesUp.Add(123)
|
||||
user.Traffic.BytesDown.Add(456)
|
||||
client := TuicClientSettings{UUID: id.String(), Password: password, Email: "old@audit3"}
|
||||
switch operation {
|
||||
case "password":
|
||||
client.Password = "rotated"
|
||||
case "email":
|
||||
client.Email = "new@audit3"
|
||||
case "uuid":
|
||||
client.UUID = "10000000-0000-0000-0000-000000000001"
|
||||
}
|
||||
if operation == "remove" {
|
||||
server.UpdateUsers(nil)
|
||||
} else {
|
||||
server.UpdateUsers([]TuicClientSettings{client})
|
||||
}
|
||||
select {
|
||||
case <-conn.Context().Done():
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("revoked QUIC remains connected")
|
||||
}
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for user.sessions.Load() > 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
deltas := server.CollectClientTraffic()
|
||||
if len(deltas) != 1 || deltas[0].Email != "old@audit3" || deltas[0].UUID != id.String() || deltas[0].Up != 123 || deltas[0].Down != 456 {
|
||||
t.Fatalf("retired snapshot=%+v", deltas)
|
||||
}
|
||||
if again := server.CollectClientTraffic(); len(again) != 0 {
|
||||
t.Fatalf("double drain=%+v", again)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,129 @@
|
||||
package tuic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
)
|
||||
|
||||
func TestNormalizeCongestionControl(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
wantValid bool
|
||||
}{
|
||||
{name: "default", want: "bbr", wantValid: true},
|
||||
{name: "bbr", input: "bbr", want: "bbr", wantValid: true},
|
||||
{name: "cubic", input: "cubic", want: "cubic", wantValid: true},
|
||||
{name: "new reno", input: "new_reno", want: "new_reno", wantValid: true},
|
||||
{name: "reno alias", input: "reno", want: "new_reno", wantValid: true},
|
||||
{name: "unknown falls back to reno", input: "quic", want: "new_reno"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, valid := normalizeCongestionControl(test.input)
|
||||
if got != test.want || valid != test.wantValid {
|
||||
t.Fatalf("normalizeCongestionControl(%q) = (%q, %t), want (%q, %t)", test.input, got, valid, test.want, test.wantValid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogfUsesCommonLoggerAndHonorsThreshold(t *testing.T) {
|
||||
tests := []struct {
|
||||
level string
|
||||
want []string
|
||||
}{
|
||||
{level: "debug", want: []string{"debug", "info", "warn", "error"}},
|
||||
{level: "info", want: []string{"info", "warn", "error"}},
|
||||
{level: "warn", want: []string{"warn", "error"}},
|
||||
{level: "error", want: []string{"error"}},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.level, func(t *testing.T) {
|
||||
marker := fmt.Sprintf("tuic-log-%s-%d", test.level, time.Now().UnixNano())
|
||||
server := &Server{id: 99001}
|
||||
server.updateRuntimeSettings("log-test", "bbr", test.level)
|
||||
for _, level := range []struct {
|
||||
name string
|
||||
value uint32
|
||||
}{{"debug", tuicLogDebug}, {"info", tuicLogInfo}, {"warn", tuicLogWarn}, {"error", tuicLogError}} {
|
||||
server.logf(level.value, "%s-%s", marker, level.name)
|
||||
}
|
||||
|
||||
logs := strings.Join(logger.GetLogs(10000, "DEBUG"), "\n")
|
||||
for _, name := range []string{"debug", "info", "warn", "error"} {
|
||||
want := slices.Contains(test.want, name)
|
||||
got := strings.Contains(logs, marker+"-"+name)
|
||||
if got != want {
|
||||
t.Errorf("log level %s present = %t, want %t", name, got, want)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(logs, "inbound 99001 (log-test)") {
|
||||
t.Fatal("TUIC event was not written through the shared 3x-ui logger")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogLevelsAreIsolatedPerInboundAndUpdateLive(t *testing.T) {
|
||||
marker := fmt.Sprintf("tuic-log-isolation-%d", time.Now().UnixNano())
|
||||
debugInbound := &Server{id: 99011}
|
||||
errorInbound := &Server{id: 99012}
|
||||
debugInbound.updateRuntimeSettings("debug-inbound", "bbr", "debug")
|
||||
errorInbound.updateRuntimeSettings("error-inbound", "bbr", "error")
|
||||
debugInbound.logf(tuicLogInfo, "%s-debug", marker)
|
||||
errorInbound.logf(tuicLogInfo, "%s-hidden", marker)
|
||||
debugInbound.UpdateRuntimeSettings("debug-inbound", "bbr", "warn")
|
||||
debugInbound.logf(tuicLogInfo, "%s-hidden-after-update", marker)
|
||||
debugInbound.logf(tuicLogWarn, "%s-warn-after-update", marker)
|
||||
|
||||
logs := strings.Join(logger.GetLogs(10000, "DEBUG"), "\n")
|
||||
if !strings.Contains(logs, marker+"-debug") || !strings.Contains(logs, marker+"-warn-after-update") {
|
||||
t.Fatal("expected permitted events from debug inbound")
|
||||
}
|
||||
if strings.Contains(logs, marker+"-hidden") || strings.Contains(logs, marker+"-hidden-after-update") {
|
||||
t.Fatal("a TUIC inbound emitted an event below its own log threshold")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureStartupFailureHonorsInboundLogThreshold(t *testing.T) {
|
||||
tests := []struct {
|
||||
level string
|
||||
want bool
|
||||
}{
|
||||
{level: "error", want: false},
|
||||
{level: "warn", want: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.level, func(t *testing.T) {
|
||||
marker := fmt.Sprintf("tuic-start-failure-%s-%d", test.level, time.Now().UnixNano())
|
||||
manager := &Manager{servers: map[int]*managed{}, lastStartErr: map[int]string{}, pendingTraffic: map[string]ClientTrafficDelta{}}
|
||||
err := manager.Ensure(Instance{
|
||||
Id: 99031,
|
||||
Tag: marker,
|
||||
Listen: "127.0.0.1",
|
||||
Port: 0,
|
||||
LogLevel: test.level,
|
||||
CongestionControl: "bbr",
|
||||
Clients: []TuicClientSettings{{UUID: "a0000000-0000-0000-0000-000000000031", Password: "p", Email: "startup@x"}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Ensure unexpectedly started without a certificate")
|
||||
}
|
||||
|
||||
logs := strings.Join(logger.GetLogs(10000, "DEBUG"), "\n")
|
||||
got := strings.Contains(logs, marker+"): failed to start server")
|
||||
if got != test.want {
|
||||
t.Fatalf("startup warning logged = %t, want %t", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,758 @@
|
||||
package tuic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/quic-go/quic-go"
|
||||
)
|
||||
|
||||
func generateTestCert(t *testing.T) (certPEM, keyPEM []byte) {
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate private key: %v", err)
|
||||
}
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"Test TUIC Server"},
|
||||
},
|
||||
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
DNSNames: []string{"localhost"},
|
||||
}
|
||||
|
||||
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create certificate: %v", err)
|
||||
}
|
||||
|
||||
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
|
||||
|
||||
privBytes, err := x509.MarshalECPrivateKey(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal private key: %v", err)
|
||||
}
|
||||
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: privBytes})
|
||||
|
||||
return certPEM, keyPEM
|
||||
}
|
||||
|
||||
func TestServerTCPConnectE2E(t *testing.T) {
|
||||
for _, controller := range []string{"bbr", "cubic", "new_reno"} {
|
||||
t.Run(controller, func(t *testing.T) {
|
||||
testServerTCPConnectE2E(t, controller)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testServerTCPConnectE2E(t *testing.T, controller string) {
|
||||
certPEM, keyPEM := generateTestCert(t)
|
||||
|
||||
// Start mock SOCKS5 server on loopback
|
||||
socksAddr, socksCleanup := startMockSocks5Server(t, "alice@example.com", "mock-socks-pass")
|
||||
defer socksCleanup()
|
||||
|
||||
testUUID := uuid.New()
|
||||
testPassword := "secret-client-password"
|
||||
|
||||
inst := Instance{
|
||||
Id: 1,
|
||||
Tag: "tuic-test",
|
||||
Listen: "127.0.0.1",
|
||||
Port: 0,
|
||||
Certificate: string(certPEM),
|
||||
PrivateKey: string(keyPEM),
|
||||
CongestionControl: controller,
|
||||
ALPN: []string{"h3"},
|
||||
MaxIdleTime: 5,
|
||||
AuthenticationTimeout: 2,
|
||||
Clients: []TuicClientSettings{
|
||||
{
|
||||
UUID: testUUID.String(),
|
||||
Password: testPassword,
|
||||
Email: "alice@example.com",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
relay := &SocksRelay{
|
||||
Addr: socksAddr,
|
||||
Password: "mock-socks-pass",
|
||||
}
|
||||
|
||||
server, err := NewServer(inst, relay)
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer failed: %v", err)
|
||||
}
|
||||
|
||||
if err := server.Start(); err != nil {
|
||||
t.Fatalf("Server.Start failed: %v", err)
|
||||
}
|
||||
defer server.Close()
|
||||
|
||||
serverAddr := server.packetConn.LocalAddr().String()
|
||||
|
||||
// Connect client to TUIC server via QUIC
|
||||
clientTLS := &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
NextProtos: []string{"h3"},
|
||||
}
|
||||
quicConfig := &quic.Config{
|
||||
EnableDatagrams: true,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
conn, err := quic.DialAddr(ctx, serverAddr, clientTLS, quicConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("quic.DialAddr failed: %v", err)
|
||||
}
|
||||
defer conn.CloseWithError(0, "")
|
||||
|
||||
// 1. Authenticate client on a uni stream
|
||||
tlsState := conn.ConnectionState().TLS
|
||||
token, err := tlsState.ExportKeyingMaterial(string(testUUID[:]), []byte(testPassword), 32)
|
||||
if err != nil {
|
||||
t.Fatalf("ExportKeyingMaterial failed: %v", err)
|
||||
}
|
||||
|
||||
uniStream, err := conn.OpenUniStreamSync(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenUniStreamSync failed: %v", err)
|
||||
}
|
||||
// Send: [VER (0x05)][0x00][UUID (16)][TOKEN (32)]
|
||||
authPayload := make([]byte, 2+16+32)
|
||||
authPayload[0] = ProtocolVersion
|
||||
authPayload[1] = CmdAuthenticate
|
||||
copy(authPayload[2:18], testUUID[:])
|
||||
copy(authPayload[18:50], token)
|
||||
|
||||
if _, err := uniStream.Write(authPayload); err != nil {
|
||||
t.Fatalf("write auth payload failed: %v", err)
|
||||
}
|
||||
_ = uniStream.Close()
|
||||
|
||||
// 2. Open bidirectional stream for TCP Connect
|
||||
biStream, err := conn.OpenStreamSync(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStreamSync failed: %v", err)
|
||||
}
|
||||
defer biStream.Close()
|
||||
|
||||
// Send: [VER (0x05)][0x01][ADDR]
|
||||
target := &Address{
|
||||
Type: AddrTypeIPv4,
|
||||
IP: net.ParseIP("1.1.1.1"),
|
||||
Port: 80,
|
||||
}
|
||||
var connectBuf bytes.Buffer
|
||||
connectBuf.WriteByte(ProtocolVersion)
|
||||
connectBuf.WriteByte(CmdConnect)
|
||||
if err := WriteAddress(&connectBuf, target); err != nil {
|
||||
t.Fatalf("WriteAddress failed: %v", err)
|
||||
}
|
||||
if _, err := biStream.Write(connectBuf.Bytes()); err != nil {
|
||||
t.Fatalf("write connect cmd failed: %v", err)
|
||||
}
|
||||
|
||||
// 3. Send test data and read echo response back through SOCKS5 bridge
|
||||
testMsg := []byte("ping pong over native go tuic!")
|
||||
if _, err := biStream.Write(testMsg); err != nil {
|
||||
t.Fatalf("write test message failed: %v", err)
|
||||
}
|
||||
|
||||
recvBuf := make([]byte, len(testMsg))
|
||||
if _, err := io.ReadFull(biStream, recvBuf); err != nil {
|
||||
t.Fatalf("read echo failed: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(recvBuf, testMsg) {
|
||||
t.Fatalf("expected %q, got %q", testMsg, recvBuf)
|
||||
}
|
||||
|
||||
// 4. Verify traffic was recorded for alice@example.com
|
||||
activeEmails := server.GetActiveEmails(10 * time.Second)
|
||||
if len(activeEmails) == 0 || activeEmails[0] != "alice@example.com" {
|
||||
t.Fatalf("expected active email alice@example.com, got %v", activeEmails)
|
||||
}
|
||||
|
||||
deltas := server.CollectClientTraffic()
|
||||
if len(deltas) == 0 {
|
||||
t.Fatalf("expected traffic deltas, got none")
|
||||
}
|
||||
if deltas[0].Email != "alice@example.com" || deltas[0].Up < int64(len(testMsg)) || deltas[0].Down < int64(len(testMsg)) {
|
||||
t.Fatalf("unexpected traffic deltas: %+v", deltas[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerUDPDatagramE2E(t *testing.T) {
|
||||
for _, controller := range []string{"bbr", "cubic", "new_reno"} {
|
||||
t.Run(controller, func(t *testing.T) {
|
||||
testServerUDPDatagramE2E(t, controller)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testServerUDPDatagramE2E(t *testing.T, controller string) {
|
||||
certPEM, keyPEM := generateTestCert(t)
|
||||
|
||||
socksAddr, socksCleanup := startMockSocks5Server(t, "bob@example.com", "mock-socks-pass")
|
||||
defer socksCleanup()
|
||||
|
||||
testUUID := uuid.New()
|
||||
testPassword := "secret-bob-password"
|
||||
|
||||
inst := Instance{
|
||||
Id: 2,
|
||||
Tag: "tuic-udp-test",
|
||||
Listen: "127.0.0.1",
|
||||
Port: 0,
|
||||
Certificate: string(certPEM),
|
||||
PrivateKey: string(keyPEM),
|
||||
CongestionControl: controller,
|
||||
ALPN: []string{"h3"},
|
||||
MaxIdleTime: 5,
|
||||
AuthenticationTimeout: 2,
|
||||
Clients: []TuicClientSettings{
|
||||
{
|
||||
UUID: testUUID.String(),
|
||||
Password: testPassword,
|
||||
Email: "bob@example.com",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
relay := &SocksRelay{
|
||||
Addr: socksAddr,
|
||||
Password: "mock-socks-pass",
|
||||
}
|
||||
|
||||
server, err := NewServer(inst, relay)
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer failed: %v", err)
|
||||
}
|
||||
|
||||
if err := server.Start(); err != nil {
|
||||
t.Fatalf("Server.Start failed: %v", err)
|
||||
}
|
||||
defer server.Close()
|
||||
|
||||
serverAddr := server.packetConn.LocalAddr().String()
|
||||
|
||||
clientTLS := &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
NextProtos: []string{"h3"},
|
||||
}
|
||||
quicConfig := &quic.Config{
|
||||
EnableDatagrams: true,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
conn, err := quic.DialAddr(ctx, serverAddr, clientTLS, quicConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("quic.DialAddr failed: %v", err)
|
||||
}
|
||||
defer conn.CloseWithError(0, "")
|
||||
|
||||
// 1. Authenticate via uni stream
|
||||
tlsState := conn.ConnectionState().TLS
|
||||
token, err := tlsState.ExportKeyingMaterial(string(testUUID[:]), []byte(testPassword), 32)
|
||||
if err != nil {
|
||||
t.Fatalf("ExportKeyingMaterial failed: %v", err)
|
||||
}
|
||||
|
||||
uniStream, err := conn.OpenUniStreamSync(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenUniStreamSync failed: %v", err)
|
||||
}
|
||||
authPayload := make([]byte, 2+16+32)
|
||||
authPayload[0] = ProtocolVersion
|
||||
authPayload[1] = CmdAuthenticate
|
||||
copy(authPayload[2:18], testUUID[:])
|
||||
copy(authPayload[18:50], token)
|
||||
if _, err := uniStream.Write(authPayload); err != nil {
|
||||
t.Fatalf("write auth payload failed: %v", err)
|
||||
}
|
||||
_ = uniStream.Close()
|
||||
|
||||
// 2. Send UDP datagram
|
||||
target := &Address{
|
||||
Type: AddrTypeIPv4,
|
||||
IP: net.ParseIP("8.8.8.8"),
|
||||
Port: 53,
|
||||
}
|
||||
udpMsg := bytes.Repeat([]byte("d"), 1300)
|
||||
|
||||
// Give a tiny moment for auth to register
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
fragmentTotal := (len(udpMsg) + maxDatagramFragmentSize - 1) / maxDatagramFragmentSize
|
||||
for i := 0; i < fragmentTotal; i++ {
|
||||
start := i * maxDatagramFragmentSize
|
||||
end := min(start+maxDatagramFragmentSize, len(udpMsg))
|
||||
addr := (*Address)(nil)
|
||||
if i == 0 {
|
||||
addr = target
|
||||
}
|
||||
var frame bytes.Buffer
|
||||
if err := WritePacket(&frame, 100, 1, uint8(fragmentTotal), uint8(i), addr, udpMsg[start:end]); err != nil {
|
||||
t.Fatalf("WritePacket failed: %v", err)
|
||||
}
|
||||
if err := conn.SendDatagram(frame.Bytes()); err != nil {
|
||||
t.Fatalf("SendDatagram failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Receive and reassemble the echo reply via datagrams.
|
||||
replyReassembler := newPacketReassembler(1500)
|
||||
var replyPayload []byte
|
||||
for replyPayload == nil {
|
||||
recvDgram, err := conn.ReceiveDatagram(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ReceiveDatagram failed: %v", err)
|
||||
}
|
||||
if len(recvDgram) < 2 || recvDgram[0] != ProtocolVersion || recvDgram[1] != CmdPacket {
|
||||
t.Fatalf("unexpected datagram reply: %x", recvDgram)
|
||||
}
|
||||
pktReader := bytes.NewReader(recvDgram[2:])
|
||||
hdr, err := ReadPacketHeader(pktReader)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPacketHeader failed: %v", err)
|
||||
}
|
||||
fragment, err := readPacketPayload(pktReader, hdr)
|
||||
if err != nil || pktReader.Len() != 0 {
|
||||
t.Fatalf("read reply payload failed: %v", err)
|
||||
}
|
||||
_, assembled, complete := replyReassembler.feed(packetTransportDatagram, hdr, fragment)
|
||||
if complete {
|
||||
replyPayload = assembled
|
||||
}
|
||||
}
|
||||
|
||||
if !bytes.Equal(replyPayload, udpMsg) {
|
||||
t.Fatalf("expected %q, got %q", udpMsg, replyPayload)
|
||||
}
|
||||
|
||||
// 4. Verify traffic
|
||||
deltas := server.CollectClientTraffic()
|
||||
if len(deltas) == 0 {
|
||||
t.Fatalf("expected traffic deltas, got none")
|
||||
}
|
||||
if deltas[0].Email != "bob@example.com" || deltas[0].Up < int64(len(udpMsg)) || deltas[0].Down < int64(len(udpMsg)) {
|
||||
t.Fatalf("unexpected traffic deltas: %+v", deltas[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerUDPStreamE2E(t *testing.T) {
|
||||
for _, controller := range []string{"bbr", "cubic", "new_reno"} {
|
||||
t.Run(controller, func(t *testing.T) {
|
||||
testServerUDPStreamE2E(t, controller)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testServerUDPStreamE2E(t *testing.T, controller string) {
|
||||
certPEM, keyPEM := generateTestCert(t)
|
||||
socksAddr, socksCleanup := startMockSocks5Server(t, "stream@example.com", "mock-socks-pass")
|
||||
defer socksCleanup()
|
||||
|
||||
testUUID := uuid.New()
|
||||
testPassword := "secret-stream-password"
|
||||
server, err := NewServer(Instance{
|
||||
Id: 3,
|
||||
Tag: "tuic-udp-stream-test",
|
||||
Listen: "127.0.0.1",
|
||||
Certificate: string(certPEM),
|
||||
PrivateKey: string(keyPEM),
|
||||
ALPN: []string{"h3"},
|
||||
MaxIdleTime: 5,
|
||||
AuthenticationTimeout: 2,
|
||||
MaxUdpRelayPacketSize: maxUdpRelayPacketSize,
|
||||
CongestionControl: controller,
|
||||
Clients: []TuicClientSettings{{
|
||||
UUID: testUUID.String(),
|
||||
Password: testPassword,
|
||||
Email: "stream@example.com",
|
||||
}},
|
||||
}, &SocksRelay{Addr: socksAddr, Password: "mock-socks-pass"})
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer failed: %v", err)
|
||||
}
|
||||
if err := server.Start(); err != nil {
|
||||
t.Fatalf("Server.Start failed: %v", err)
|
||||
}
|
||||
defer server.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
conn, err := quic.DialAddr(ctx, server.packetConn.LocalAddr().String(), &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
NextProtos: []string{"h3"},
|
||||
}, &quic.Config{EnableDatagrams: true})
|
||||
if err != nil {
|
||||
t.Fatalf("quic.DialAddr failed: %v", err)
|
||||
}
|
||||
defer conn.CloseWithError(0, "")
|
||||
|
||||
tlsState := conn.ConnectionState().TLS
|
||||
token, err := tlsState.ExportKeyingMaterial(string(testUUID[:]), []byte(testPassword), 32)
|
||||
if err != nil {
|
||||
t.Fatalf("ExportKeyingMaterial failed: %v", err)
|
||||
}
|
||||
authStream, err := conn.OpenUniStreamSync(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenUniStreamSync for authentication failed: %v", err)
|
||||
}
|
||||
authPayload := make([]byte, 2+16+32)
|
||||
authPayload[0] = ProtocolVersion
|
||||
authPayload[1] = CmdAuthenticate
|
||||
copy(authPayload[2:18], testUUID[:])
|
||||
copy(authPayload[18:], token)
|
||||
if _, err := authStream.Write(authPayload); err != nil {
|
||||
t.Fatalf("write authentication payload failed: %v", err)
|
||||
}
|
||||
if err := authStream.Close(); err != nil {
|
||||
t.Fatalf("close authentication stream failed: %v", err)
|
||||
}
|
||||
|
||||
target := &Address{Type: AddrTypeIPv4, IP: net.ParseIP("8.8.8.8"), Port: 53}
|
||||
udpMsg := bytes.Repeat([]byte("s"), 8500)
|
||||
fragmentTotal := (len(udpMsg) + maxStreamFragmentSize - 1) / maxStreamFragmentSize
|
||||
for i := 0; i < fragmentTotal; i++ {
|
||||
start := i * maxStreamFragmentSize
|
||||
end := min(start+maxStreamFragmentSize, len(udpMsg))
|
||||
addr := (*Address)(nil)
|
||||
if i == 0 {
|
||||
addr = target
|
||||
}
|
||||
var frame bytes.Buffer
|
||||
if err := WritePacket(&frame, 300, 1, uint8(fragmentTotal), uint8(i), addr, udpMsg[start:end]); err != nil {
|
||||
t.Fatalf("WritePacket failed: %v", err)
|
||||
}
|
||||
packetStream, err := conn.OpenUniStreamSync(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenUniStreamSync for packet failed: %v", err)
|
||||
}
|
||||
if _, err := packetStream.Write(frame.Bytes()); err != nil {
|
||||
t.Fatalf("write packet frame failed: %v", err)
|
||||
}
|
||||
if err := packetStream.Close(); err != nil {
|
||||
t.Fatalf("close packet stream failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
replyReassembler := newPacketReassembler(maxUdpRelayPacketSize)
|
||||
var reply []byte
|
||||
for reply == nil {
|
||||
responseStream, err := conn.AcceptUniStream(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("AcceptUniStream for response failed: %v", err)
|
||||
}
|
||||
_, command, err := ReadCommand(responseStream)
|
||||
if err != nil {
|
||||
t.Fatalf("read response command: %v", err)
|
||||
}
|
||||
if command != CmdPacket {
|
||||
t.Fatalf("response command = %d, want %d", command, CmdPacket)
|
||||
}
|
||||
hdr, err := ReadPacketHeader(responseStream)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPacketHeader failed: %v", err)
|
||||
}
|
||||
fragment, err := readPacketPayload(responseStream, hdr)
|
||||
if err != nil {
|
||||
t.Fatalf("read response payload failed: %v", err)
|
||||
}
|
||||
_, assembled, complete := replyReassembler.feed(packetTransportStream, hdr, fragment)
|
||||
if complete {
|
||||
reply = assembled
|
||||
}
|
||||
}
|
||||
if !bytes.Equal(reply, udpMsg) {
|
||||
t.Fatalf("stream response size = %d, want %d", len(reply), len(udpMsg))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServerRejectsOversizedMaxUdpRelayPacketSize(t *testing.T) {
|
||||
certPEM, keyPEM := generateTestCert(t)
|
||||
_, err := NewServer(Instance{
|
||||
Listen: "127.0.0.1",
|
||||
Port: 8443,
|
||||
Certificate: string(certPEM),
|
||||
PrivateKey: string(keyPEM),
|
||||
MaxUdpRelayPacketSize: maxLegacyUdpRelayPacketSize + 1,
|
||||
}, &SocksRelay{Addr: "127.0.0.1:1"})
|
||||
if err == nil {
|
||||
t.Fatal("expected oversized max UDP relay packet size to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServerClampsLegacyUdpPayloadLimit(t *testing.T) {
|
||||
certPEM, keyPEM := generateTestCert(t)
|
||||
server, err := NewServer(Instance{
|
||||
Listen: "127.0.0.1",
|
||||
Port: 0,
|
||||
Certificate: string(certPEM),
|
||||
PrivateKey: string(keyPEM),
|
||||
MaxUdpRelayPacketSize: maxLegacyUdpRelayPacketSize,
|
||||
}, &SocksRelay{})
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
if server.maxUdpRelayPacketSize != maxSafeUdpRelayPacketSize {
|
||||
t.Fatalf("legacy UDP limit = %d, want clamped limit %d", server.maxUdpRelayPacketSize, maxSafeUdpRelayPacketSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPacketReassemblerInvalidatesAssemblyWhenFragmentTotalChanges(t *testing.T) {
|
||||
reassembler := newPacketReassembler(64)
|
||||
first := &PacketHeader{AssocID: 7, PktID: 9, FragTotal: 2, FragID: 0, Addr: &Address{Type: AddrTypeIPv4, IP: net.ParseIP("127.0.0.1"), Port: 53}, Size: 1}
|
||||
if _, _, complete := reassembler.feed(packetTransportDatagram, first, []byte("A")); complete {
|
||||
t.Fatal("first fragment unexpectedly completed")
|
||||
}
|
||||
single := &PacketHeader{AssocID: 7, PktID: 9, FragTotal: 1, FragID: 0, Addr: first.Addr, Size: 1}
|
||||
if _, got, complete := reassembler.feed(packetTransportDatagram, single, []byte("Z")); !complete || string(got) != "Z" {
|
||||
t.Fatalf("single packet = %q, complete=%v; want Z", got, complete)
|
||||
}
|
||||
last := &PacketHeader{AssocID: 7, PktID: 9, FragTotal: 2, FragID: 1, Size: 1}
|
||||
if _, _, complete := reassembler.feed(packetTransportDatagram, last, []byte("B")); complete {
|
||||
t.Fatal("stale first fragment was combined with a later packet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUdpAssociationPinsFirstPacketModeAndDissociateClearsFragments(t *testing.T) {
|
||||
registry := newUdpAssociationRegistry(64)
|
||||
addr := &Address{Type: AddrTypeIPv4, IP: net.ParseIP("127.0.0.1"), Port: 53}
|
||||
first := &PacketHeader{AssocID: 3, PktID: 1, FragTotal: 2, FragID: 0, Addr: addr, Size: 1}
|
||||
association, _, _, complete := registry.feed(packetTransportDatagram, first, []byte("A"))
|
||||
if association == nil || complete {
|
||||
t.Fatal("expected first native fragment to establish an incomplete association")
|
||||
}
|
||||
singleStream := &PacketHeader{AssocID: 3, PktID: 2, FragTotal: 1, FragID: 0, Addr: addr, Size: 1}
|
||||
association, _, _, complete = registry.feed(packetTransportStream, singleStream, []byte("S"))
|
||||
if association.responseTransport != packetTransportDatagram || !complete {
|
||||
t.Fatalf("mixed-mode packet changed response mode: association=%+v complete=%v", association, complete)
|
||||
}
|
||||
|
||||
if !registry.dissociate(3) {
|
||||
t.Fatal("expected dissociate to remove association")
|
||||
}
|
||||
late := &PacketHeader{AssocID: 3, PktID: 1, FragTotal: 2, FragID: 1, Size: 1}
|
||||
_, _, _, complete = registry.feed(packetTransportDatagram, late, []byte("B"))
|
||||
if complete {
|
||||
t.Fatal("late fragment completed an assembly from before dissociate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPacketReassembler(t *testing.T) {
|
||||
pr := newPacketReassembler(1500)
|
||||
targetAddr := &Address{Type: AddrTypeIPv4, IP: net.ParseIP("1.1.1.1"), Port: 53}
|
||||
|
||||
// 1. Unfragmented packet
|
||||
hdrSingle := &PacketHeader{
|
||||
AssocID: 1,
|
||||
PktID: 1,
|
||||
FragTotal: 1,
|
||||
FragID: 0,
|
||||
Size: uint16(len("hello single")),
|
||||
Addr: targetAddr,
|
||||
}
|
||||
addr, payload, complete := pr.feed(packetTransportDatagram, hdrSingle, []byte("hello single"))
|
||||
if addr == nil || !complete || string(payload) != "hello single" {
|
||||
t.Fatalf("unexpected single packet result: %v, %s", addr, payload)
|
||||
}
|
||||
|
||||
// 2. In-order fragments (3 parts)
|
||||
hdr0 := &PacketHeader{AssocID: 2, PktID: 10, FragTotal: 3, FragID: 0, Size: 6, Addr: targetAddr}
|
||||
hdr1 := &PacketHeader{AssocID: 2, PktID: 10, FragTotal: 3, FragID: 1, Size: 6, Addr: &Address{Type: AddrTypeNone}}
|
||||
hdr2 := &PacketHeader{AssocID: 2, PktID: 10, FragTotal: 3, FragID: 2, Size: 5, Addr: &Address{Type: AddrTypeNone}}
|
||||
|
||||
_, p0, complete := pr.feed(packetTransportDatagram, hdr0, []byte("part0-"))
|
||||
if p0 != nil || complete {
|
||||
t.Fatalf("expected nil before all fragments arrive, got %s", p0)
|
||||
}
|
||||
_, p1, complete := pr.feed(packetTransportDatagram, hdr1, []byte("part1-"))
|
||||
if p1 != nil || complete {
|
||||
t.Fatalf("expected nil before all fragments arrive, got %s", p1)
|
||||
}
|
||||
a2, p2, complete := pr.feed(packetTransportDatagram, hdr2, []byte("part2"))
|
||||
if a2 == nil || !complete || string(p2) != "part0-part1-part2" {
|
||||
t.Fatalf("expected reassembled payload 'part0-part1-part2', got %v, %s", a2, p2)
|
||||
}
|
||||
|
||||
// 3. Out-of-order fragments (parts 1, 2, 0)
|
||||
hdrOO0 := &PacketHeader{AssocID: 3, PktID: 20, FragTotal: 3, FragID: 0, Size: 6, Addr: targetAddr}
|
||||
hdrOO1 := &PacketHeader{AssocID: 3, PktID: 20, FragTotal: 3, FragID: 1, Size: 7, Addr: &Address{Type: AddrTypeNone}}
|
||||
hdrOO2 := &PacketHeader{AssocID: 3, PktID: 20, FragTotal: 3, FragID: 2, Size: 3, Addr: &Address{Type: AddrTypeNone}}
|
||||
|
||||
if _, p, done := pr.feed(packetTransportDatagram, hdrOO1, []byte("MIDDLE-")); p != nil || done {
|
||||
t.Fatalf("expected nil, got %s", p)
|
||||
}
|
||||
if _, p, done := pr.feed(packetTransportDatagram, hdrOO2, []byte("END")); p != nil || done {
|
||||
t.Fatalf("expected nil, got %s", p)
|
||||
}
|
||||
aOO, pOO, done := pr.feed(packetTransportDatagram, hdrOO0, []byte("START-"))
|
||||
if aOO == nil || !done || string(pOO) != "START-MIDDLE-END" {
|
||||
t.Fatalf("expected 'START-MIDDLE-END', got %s", pOO)
|
||||
}
|
||||
|
||||
// 4. Invalid FragID >= FragTotal
|
||||
hdrInv := &PacketHeader{AssocID: 4, PktID: 30, FragTotal: 2, FragID: 2, Size: 7, Addr: targetAddr}
|
||||
if _, p, done := pr.feed(packetTransportDatagram, hdrInv, []byte("invalid")); p != nil || done {
|
||||
t.Fatalf("expected nil for invalid FragID, got %s", p)
|
||||
}
|
||||
|
||||
// A changed fragment total for an in-flight packet must discard the packet safely.
|
||||
hdrMixed0 := &PacketHeader{AssocID: 5, PktID: 40, FragTotal: 2, FragID: 0, Size: 1, Addr: targetAddr}
|
||||
hdrMixed3 := &PacketHeader{AssocID: 5, PktID: 40, FragTotal: 4, FragID: 3, Size: 1, Addr: &Address{Type: AddrTypeNone}}
|
||||
if _, _, done := pr.feed(packetTransportDatagram, hdrMixed0, []byte("a")); done {
|
||||
t.Fatal("expected first mixed-total fragment to remain incomplete")
|
||||
}
|
||||
if _, _, done := pr.feed(packetTransportDatagram, hdrMixed3, []byte("b")); done {
|
||||
t.Fatal("expected inconsistent fragment total to be discarded")
|
||||
}
|
||||
if _, ok := pr.packets[packetFragmentKey{assocID: 5, pktID: 40, transport: packetTransportDatagram}]; ok {
|
||||
t.Fatal("inconsistent packet assembly was not discarded")
|
||||
}
|
||||
|
||||
// Fragments from different transports cannot be combined into one packet.
|
||||
streamFirst := &PacketHeader{AssocID: 6, PktID: 50, FragTotal: 2, FragID: 0, Size: 1, Addr: targetAddr}
|
||||
datagramLast := &PacketHeader{AssocID: 6, PktID: 50, FragTotal: 2, FragID: 1, Size: 1, Addr: &Address{Type: AddrTypeNone}}
|
||||
if _, _, done := pr.feed(packetTransportStream, streamFirst, []byte("a")); done {
|
||||
t.Fatal("expected first stream fragment to remain incomplete")
|
||||
}
|
||||
if _, _, done := pr.feed(packetTransportDatagram, datagramLast, []byte("b")); done {
|
||||
t.Fatal("fragments from different transports must not combine")
|
||||
}
|
||||
if _, _, done := pr.feed(packetTransportStream, datagramLast, []byte("b")); !done {
|
||||
t.Fatal("expected stream fragments to reassemble")
|
||||
}
|
||||
|
||||
// The configured size limit caps both complete packets and reassembly state.
|
||||
limited := newPacketReassembler(3)
|
||||
tooLarge0 := &PacketHeader{AssocID: 7, PktID: 60, FragTotal: 2, FragID: 0, Size: 2, Addr: targetAddr}
|
||||
tooLarge1 := &PacketHeader{AssocID: 7, PktID: 60, FragTotal: 2, FragID: 1, Size: 2, Addr: &Address{Type: AddrTypeNone}}
|
||||
if _, _, done := limited.feed(packetTransportDatagram, tooLarge0, []byte("ab")); done {
|
||||
t.Fatal("expected first oversized packet fragment to remain incomplete")
|
||||
}
|
||||
if _, _, done := limited.feed(packetTransportDatagram, tooLarge1, []byte("cd")); done {
|
||||
t.Fatal("oversized reassembled packet must be rejected")
|
||||
}
|
||||
if len(limited.packets) != 0 {
|
||||
t.Fatal("oversized reassembly state was not discarded")
|
||||
}
|
||||
|
||||
bounded := newPacketReassembler(1500)
|
||||
for i := 0; i < maxPendingPacketAssemblies; i++ {
|
||||
hdr := &PacketHeader{
|
||||
AssocID: 8,
|
||||
PktID: uint16(i),
|
||||
FragTotal: 2,
|
||||
FragID: 0,
|
||||
Size: 1,
|
||||
Addr: targetAddr,
|
||||
}
|
||||
if _, _, done := bounded.feed(packetTransportDatagram, hdr, []byte("a")); done {
|
||||
t.Fatal("expected pending fragment to remain incomplete")
|
||||
}
|
||||
}
|
||||
if len(bounded.packets) != maxPendingPacketAssemblies {
|
||||
t.Fatalf("pending assembly count = %d, want %d", len(bounded.packets), maxPendingPacketAssemblies)
|
||||
}
|
||||
extra := &PacketHeader{AssocID: 8, PktID: 100, FragTotal: 2, FragID: 0, Size: 1, Addr: targetAddr}
|
||||
if _, _, done := bounded.feed(packetTransportDatagram, extra, []byte("a")); done {
|
||||
t.Fatal("expected new assembly to be rejected when the pending limit is reached")
|
||||
}
|
||||
if len(bounded.packets) != maxPendingPacketAssemblies {
|
||||
t.Fatalf("pending assembly count after overflow = %d, want %d", len(bounded.packets), maxPendingPacketAssemblies)
|
||||
}
|
||||
|
||||
for _, packet := range bounded.packets {
|
||||
packet.updatedAt = time.Now().Add(-packetAssemblyTimeout - time.Second)
|
||||
}
|
||||
if _, _, done := bounded.feed(packetTransportDatagram, extra, []byte("a")); done {
|
||||
t.Fatal("expected new fragment to remain incomplete after stale entries are evicted")
|
||||
}
|
||||
if len(bounded.packets) != 1 {
|
||||
t.Fatalf("pending assembly count after stale cleanup = %d, want 1", len(bounded.packets))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticationTimeoutClosesUnauthenticatedConnections(t *testing.T) {
|
||||
for _, partial := range []bool{false, true} {
|
||||
name := "no-authenticate"
|
||||
if partial {
|
||||
name = "partial-authenticate"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
certPEM, keyPEM := generateTestCert(t)
|
||||
server, err := NewServer(Instance{
|
||||
Id: 99010,
|
||||
Tag: "auth-timeout-test",
|
||||
Listen: "127.0.0.1",
|
||||
Port: 0,
|
||||
Certificate: string(certPEM),
|
||||
PrivateKey: string(keyPEM),
|
||||
ALPN: []string{"h3"},
|
||||
MaxIdleTime: 5,
|
||||
AuthenticationTimeout: 1,
|
||||
}, &SocksRelay{})
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
if err := server.Start(); err != nil {
|
||||
t.Fatalf("Server.Start: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = server.Close() })
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||
defer cancel()
|
||||
conn, err := quic.DialAddr(ctx, server.packetConn.LocalAddr().String(), &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
NextProtos: []string{"h3"},
|
||||
}, &quic.Config{EnableDatagrams: true, KeepAlivePeriod: time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("quic.DialAddr: %v", err)
|
||||
}
|
||||
defer conn.CloseWithError(0, "")
|
||||
|
||||
if partial {
|
||||
stream, err := conn.OpenUniStreamSync(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenUniStreamSync: %v", err)
|
||||
}
|
||||
if _, err := stream.Write([]byte{ProtocolVersion, CmdAuthenticate, 1}); err != nil {
|
||||
t.Fatalf("write partial Authenticate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-conn.Context().Done():
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("server left unauthenticated QUIC connection open: %v", ctx.Err())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
package tuic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrUdpPayloadTooLarge = errors.New("tuic socks: UDP packet exceeds the maximum SOCKS datagram size")
|
||||
|
||||
const maxSocksUdpDatagramSize = 65507
|
||||
|
||||
// SocksRelay describes the loopback SOCKS5 endpoint where decrypted TUIC traffic is forwarded.
|
||||
type SocksRelay struct {
|
||||
Addr string // e.g. "127.0.0.1:63201"
|
||||
Password string // internal shared password for the SOCKS inbound
|
||||
}
|
||||
|
||||
// CountingConn wraps a net.Conn and tracks bytes read and written atomically.
|
||||
type CountingConn struct {
|
||||
net.Conn
|
||||
bytesRead *atomic.Int64
|
||||
bytesWritten *atomic.Int64
|
||||
}
|
||||
|
||||
func (c *CountingConn) Read(p []byte) (int, error) {
|
||||
n, err := c.Conn.Read(p)
|
||||
if n > 0 && c.bytesRead != nil {
|
||||
c.bytesRead.Add(int64(n))
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (c *CountingConn) Write(p []byte) (int, error) {
|
||||
n, err := c.Conn.Write(p)
|
||||
if n > 0 && c.bytesWritten != nil {
|
||||
c.bytesWritten.Add(int64(n))
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// DialTCP establishes a SOCKS5 CONNECT tunnel to the target address on behalf of user.
|
||||
func (r *SocksRelay) DialTCP(ctx context.Context, user string, target *Address) (net.Conn, error) {
|
||||
dialer := net.Dialer{Timeout: 10 * time.Second}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", r.Addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tuic socks: dial relay %s: %w", r.Addr, err)
|
||||
}
|
||||
|
||||
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
|
||||
if err := socks5Handshake(conn, user, r.Password); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Send SOCKS5 CONNECT request
|
||||
req := buildSocks5ConnectRequest(target)
|
||||
if req == nil {
|
||||
conn.Close()
|
||||
return nil, ErrInvalidAddr
|
||||
}
|
||||
if _, err := conn.Write(req); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("tuic socks: send CONNECT request: %w", err)
|
||||
}
|
||||
|
||||
if _, err := readSocks5Reply(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func buildSocks5ConnectRequest(target *Address) []byte {
|
||||
if target == nil {
|
||||
return nil
|
||||
}
|
||||
var req []byte
|
||||
switch target.Type {
|
||||
case AddrTypeIPv4:
|
||||
ip4 := target.IP.To4()
|
||||
if len(ip4) != 4 {
|
||||
return nil
|
||||
}
|
||||
req = make([]byte, 4+4+2)
|
||||
req[0] = 0x05 // SOCKS5
|
||||
req[1] = 0x01 // CONNECT
|
||||
req[2] = 0x00 // RSV
|
||||
req[3] = 0x01 // ATYP IPv4
|
||||
copy(req[4:8], ip4)
|
||||
binary.BigEndian.PutUint16(req[8:10], target.Port)
|
||||
|
||||
case AddrTypeIPv6:
|
||||
ip16 := target.IP.To16()
|
||||
if len(ip16) != 16 {
|
||||
return nil
|
||||
}
|
||||
req = make([]byte, 4+16+2)
|
||||
req[0] = 0x05
|
||||
req[1] = 0x01
|
||||
req[2] = 0x00
|
||||
req[3] = 0x04 // ATYP IPv6
|
||||
copy(req[4:20], ip16)
|
||||
binary.BigEndian.PutUint16(req[20:22], target.Port)
|
||||
|
||||
case AddrTypeDomain:
|
||||
dLen := len(target.Host)
|
||||
if dLen == 0 || dLen > 255 {
|
||||
return nil
|
||||
}
|
||||
req = make([]byte, 4+1+dLen+2)
|
||||
req[0] = 0x05
|
||||
req[1] = 0x01
|
||||
req[2] = 0x00
|
||||
req[3] = 0x03 // ATYP Domain
|
||||
req[4] = byte(dLen)
|
||||
copy(req[5:5+dLen], []byte(target.Host))
|
||||
binary.BigEndian.PutUint16(req[5+dLen:7+dLen], target.Port)
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// SocksUDPSession manages a SOCKS5 UDP ASSOCIATE tunnel to Xray.
|
||||
type SocksUDPSession struct {
|
||||
ctrl net.Conn
|
||||
udpConn *net.UDPConn
|
||||
targetEP net.Addr
|
||||
user string
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
// DialUDP establishes a SOCKS5 UDP ASSOCIATE tunnel to Xray.
|
||||
func (r *SocksRelay) DialUDP(ctx context.Context, user string) (*SocksUDPSession, error) {
|
||||
dialer := net.Dialer{Timeout: 10 * time.Second}
|
||||
ctrl, err := dialer.DialContext(ctx, "tcp", r.Addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tuic socks: dial UDP control connection: %w", err)
|
||||
}
|
||||
|
||||
_ = ctrl.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
|
||||
if err := socks5Handshake(ctrl, user, r.Password); err != nil {
|
||||
ctrl.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// SOCKS5 UDP ASSOCIATE (0x03), dst 0.0.0.0:0
|
||||
if _, err := ctrl.Write([]byte{0x05, 0x03, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); err != nil {
|
||||
ctrl.Close()
|
||||
return nil, fmt.Errorf("tuic socks: send UDP ASSOCIATE request: %w", err)
|
||||
}
|
||||
|
||||
bind, err := readSocks5Reply(ctrl)
|
||||
if err != nil {
|
||||
ctrl.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_ = ctrl.SetDeadline(time.Time{})
|
||||
|
||||
udpConn, err := net.DialUDP("udp", nil, net.UDPAddrFromAddrPort(bind))
|
||||
if err != nil {
|
||||
ctrl.Close()
|
||||
return nil, fmt.Errorf("tuic socks: dial UDP relay endpoint %s: %w", bind, err)
|
||||
}
|
||||
|
||||
return &SocksUDPSession{
|
||||
ctrl: ctrl,
|
||||
udpConn: udpConn,
|
||||
targetEP: udpConn.RemoteAddr(),
|
||||
user: user,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Send sends a UDP payload to target via the SOCKS5 UDP ASSOCIATE relay.
|
||||
func (s *SocksUDPSession) Send(target *Address, payload []byte) (int, error) {
|
||||
if s.closed.Load() {
|
||||
return 0, net.ErrClosed
|
||||
}
|
||||
|
||||
packet, err := buildSocks5UDPRequest(target, payload)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return s.udpConn.Write(packet)
|
||||
}
|
||||
|
||||
func buildSocks5UDPRequest(target *Address, payload []byte) ([]byte, error) {
|
||||
hdr := buildSocks5UDPHeader(target)
|
||||
if hdr == nil {
|
||||
return nil, ErrInvalidAddr
|
||||
}
|
||||
if len(hdr)+len(payload) > maxSocksUdpDatagramSize {
|
||||
return nil, ErrUdpPayloadTooLarge
|
||||
}
|
||||
packet := make([]byte, len(hdr)+len(payload))
|
||||
copy(packet, hdr)
|
||||
copy(packet[len(hdr):], payload)
|
||||
return packet, nil
|
||||
}
|
||||
|
||||
// Receive reads a relayed UDP payload and extracts its original source address.
|
||||
func (s *SocksUDPSession) Receive(buf []byte) (*Address, []byte, error) {
|
||||
if s.closed.Load() {
|
||||
return nil, nil, net.ErrClosed
|
||||
}
|
||||
|
||||
n, err := s.udpConn.Read(buf)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if n < 4 {
|
||||
return nil, nil, fmt.Errorf("tuic socks: UDP packet too short (%d bytes)", n)
|
||||
}
|
||||
|
||||
// SOCKS5 UDP header: [RSV(2)][FRAG(1)][ATYP(1)]
|
||||
atyp := buf[3]
|
||||
var addr *Address
|
||||
var offset int
|
||||
|
||||
switch atyp {
|
||||
case 0x01: // IPv4
|
||||
if n < 10 {
|
||||
return nil, nil, fmt.Errorf("tuic socks: truncated IPv4 UDP reply")
|
||||
}
|
||||
ip := net.IP(buf[4:8])
|
||||
port := binary.BigEndian.Uint16(buf[8:10])
|
||||
addr = &Address{Type: AddrTypeIPv4, IP: ip, Host: ip.String(), Port: port}
|
||||
offset = 10
|
||||
|
||||
case 0x04: // IPv6
|
||||
if n < 22 {
|
||||
return nil, nil, fmt.Errorf("tuic socks: truncated IPv6 UDP reply")
|
||||
}
|
||||
ip := net.IP(buf[4:20])
|
||||
port := binary.BigEndian.Uint16(buf[20:22])
|
||||
addr = &Address{Type: AddrTypeIPv6, IP: ip, Host: ip.String(), Port: port}
|
||||
offset = 22
|
||||
|
||||
case 0x03: // Domain
|
||||
dLen := int(buf[4])
|
||||
if n < 5+dLen+2 {
|
||||
return nil, nil, fmt.Errorf("tuic socks: truncated domain UDP reply")
|
||||
}
|
||||
host := string(buf[5 : 5+dLen])
|
||||
port := binary.BigEndian.Uint16(buf[5+dLen : 7+dLen])
|
||||
addr = &Address{Type: AddrTypeDomain, Host: host, Port: port}
|
||||
offset = 7 + dLen
|
||||
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("tuic socks: unsupported reply ATYP 0x%02x", atyp)
|
||||
}
|
||||
|
||||
return addr, buf[offset:n], nil
|
||||
}
|
||||
|
||||
// Close closes the SOCKS5 UDP session.
|
||||
func (s *SocksUDPSession) Close() error {
|
||||
if s.closed.Swap(true) {
|
||||
return nil
|
||||
}
|
||||
_ = s.udpConn.Close()
|
||||
return s.ctrl.Close()
|
||||
}
|
||||
|
||||
func buildSocks5UDPHeader(target *Address) []byte {
|
||||
if target == nil {
|
||||
return nil
|
||||
}
|
||||
var hdr []byte
|
||||
switch target.Type {
|
||||
case AddrTypeIPv4:
|
||||
ip4 := target.IP.To4()
|
||||
if len(ip4) != 4 {
|
||||
return nil
|
||||
}
|
||||
hdr = make([]byte, 10)
|
||||
hdr[0] = 0x00 // RSV
|
||||
hdr[1] = 0x00 // RSV
|
||||
hdr[2] = 0x00 // FRAG
|
||||
hdr[3] = 0x01 // ATYP IPv4
|
||||
copy(hdr[4:8], ip4)
|
||||
binary.BigEndian.PutUint16(hdr[8:10], target.Port)
|
||||
|
||||
case AddrTypeIPv6:
|
||||
ip16 := target.IP.To16()
|
||||
if len(ip16) != 16 {
|
||||
return nil
|
||||
}
|
||||
hdr = make([]byte, 22)
|
||||
hdr[0] = 0x00
|
||||
hdr[1] = 0x00
|
||||
hdr[2] = 0x00
|
||||
hdr[3] = 0x04 // ATYP IPv6
|
||||
copy(hdr[4:20], ip16)
|
||||
binary.BigEndian.PutUint16(hdr[20:22], target.Port)
|
||||
|
||||
case AddrTypeDomain:
|
||||
dLen := len(target.Host)
|
||||
if dLen == 0 || dLen > 255 {
|
||||
return nil
|
||||
}
|
||||
hdr = make([]byte, 4+1+dLen+2)
|
||||
hdr[0] = 0x00
|
||||
hdr[1] = 0x00
|
||||
hdr[2] = 0x00
|
||||
hdr[3] = 0x03 // ATYP Domain
|
||||
hdr[4] = byte(dLen)
|
||||
copy(hdr[5:5+dLen], []byte(target.Host))
|
||||
binary.BigEndian.PutUint16(hdr[5+dLen:7+dLen], target.Port)
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return hdr
|
||||
}
|
||||
|
||||
func socks5Handshake(conn net.Conn, user, password string) error {
|
||||
if _, err := conn.Write([]byte{0x05, 0x02, 0x00, 0x02}); err != nil {
|
||||
return fmt.Errorf("tuic socks: send greeting: %w", err)
|
||||
}
|
||||
var resp [2]byte
|
||||
if _, err := io.ReadFull(conn, resp[:]); err != nil {
|
||||
return fmt.Errorf("tuic socks: read greeting reply: %w", err)
|
||||
}
|
||||
if resp[0] != 0x05 {
|
||||
return fmt.Errorf("tuic socks: unexpected SOCKS version %d", resp[0])
|
||||
}
|
||||
switch resp[1] {
|
||||
case 0x00: // no auth
|
||||
return nil
|
||||
case 0x02: // username/password
|
||||
req := make([]byte, 0, 3+len(user)+len(password))
|
||||
req = append(req, 0x01, byte(len(user)))
|
||||
req = append(req, user...)
|
||||
req = append(req, byte(len(password)))
|
||||
req = append(req, password...)
|
||||
if _, err := conn.Write(req); err != nil {
|
||||
return fmt.Errorf("tuic socks: send auth: %w", err)
|
||||
}
|
||||
var authResp [2]byte
|
||||
if _, err := io.ReadFull(conn, authResp[:]); err != nil {
|
||||
return fmt.Errorf("tuic socks: read auth reply: %w", err)
|
||||
}
|
||||
if authResp[1] != 0x00 {
|
||||
return fmt.Errorf("tuic socks: auth rejected (code %d)", authResp[1])
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("tuic socks: unsupported auth method %d", resp[1])
|
||||
}
|
||||
}
|
||||
|
||||
func readSocks5Reply(r io.Reader) (netip.AddrPort, error) {
|
||||
var hdr [4]byte
|
||||
if _, err := io.ReadFull(r, hdr[:]); err != nil {
|
||||
return netip.AddrPort{}, fmt.Errorf("tuic socks: read reply header: %w", err)
|
||||
}
|
||||
if hdr[0] != 0x05 {
|
||||
return netip.AddrPort{}, fmt.Errorf("tuic socks: unexpected SOCKS version %d", hdr[0])
|
||||
}
|
||||
if hdr[1] != 0x00 {
|
||||
return netip.AddrPort{}, fmt.Errorf("tuic socks: request rejected (code %d)", hdr[1])
|
||||
}
|
||||
addr, err := readSocks5Addr(r, hdr[3])
|
||||
if err != nil {
|
||||
return netip.AddrPort{}, err
|
||||
}
|
||||
var portBytes [2]byte
|
||||
if _, err := io.ReadFull(r, portBytes[:]); err != nil {
|
||||
return netip.AddrPort{}, fmt.Errorf("tuic socks: read reply port: %w", err)
|
||||
}
|
||||
return netip.AddrPortFrom(addr, binary.BigEndian.Uint16(portBytes[:])), nil
|
||||
}
|
||||
|
||||
func readSocks5Addr(r io.Reader, atyp byte) (netip.Addr, error) {
|
||||
switch atyp {
|
||||
case 0x01:
|
||||
var b [4]byte
|
||||
if _, err := io.ReadFull(r, b[:]); err != nil {
|
||||
return netip.Addr{}, err
|
||||
}
|
||||
return netip.AddrFrom4(b), nil
|
||||
case 0x04:
|
||||
var b [16]byte
|
||||
if _, err := io.ReadFull(r, b[:]); err != nil {
|
||||
return netip.Addr{}, err
|
||||
}
|
||||
return netip.AddrFrom16(b), nil
|
||||
case 0x03:
|
||||
var l [1]byte
|
||||
if _, err := io.ReadFull(r, l[:]); err != nil {
|
||||
return netip.Addr{}, err
|
||||
}
|
||||
name := make([]byte, l[0])
|
||||
if _, err := io.ReadFull(r, name); err != nil {
|
||||
return netip.Addr{}, err
|
||||
}
|
||||
resolved, err := net.ResolveIPAddr("ip", string(name))
|
||||
if err != nil {
|
||||
return netip.Addr{}, fmt.Errorf("tuic socks: resolve domain reply %q: %w", name, err)
|
||||
}
|
||||
addr, ok := netip.AddrFromSlice(resolved.IP)
|
||||
if !ok {
|
||||
return netip.Addr{}, fmt.Errorf("tuic socks: unparseable domain reply address")
|
||||
}
|
||||
return addr, nil
|
||||
default:
|
||||
return netip.Addr{}, fmt.Errorf("tuic socks: unsupported SOCKS5 address type %d", atyp)
|
||||
}
|
||||
}
|
||||
|
||||
// halfCloseIdle bounds how long the surviving direction of a half-closed pair
|
||||
// may sit idle, so a peer that vanished mid-transfer cannot pin it forever.
|
||||
const halfCloseIdle = 2 * time.Minute
|
||||
|
||||
type closeWriter interface {
|
||||
CloseWrite() error
|
||||
}
|
||||
|
||||
type readDeadliner interface {
|
||||
SetReadDeadline(t time.Time) error
|
||||
}
|
||||
|
||||
type guardedReader struct {
|
||||
r io.Reader
|
||||
dl readDeadliner
|
||||
armed atomic.Bool
|
||||
}
|
||||
|
||||
func newGuardedReader(r io.Reader) *guardedReader {
|
||||
gr := &guardedReader{r: r}
|
||||
if dl, ok := r.(readDeadliner); ok {
|
||||
gr.dl = dl
|
||||
}
|
||||
return gr
|
||||
}
|
||||
|
||||
func (g *guardedReader) Read(p []byte) (int, error) {
|
||||
if g.armed.Load() && g.dl != nil {
|
||||
_ = g.dl.SetReadDeadline(time.Now().Add(halfCloseIdle))
|
||||
}
|
||||
return g.r.Read(p)
|
||||
}
|
||||
|
||||
func (g *guardedReader) arm() {
|
||||
g.armed.Store(true)
|
||||
if g.dl != nil {
|
||||
_ = g.dl.SetReadDeadline(time.Now().Add(halfCloseIdle))
|
||||
}
|
||||
}
|
||||
|
||||
// PipeBiDirectional pipes data between two connections and tracks byte counts in each direction.
|
||||
func PipeBiDirectional(a, b io.ReadWriteCloser, upCounter, downCounter *atomic.Int64) {
|
||||
PipeBiDirectionalContext(context.Background(), a, b, upCounter, downCounter)
|
||||
}
|
||||
|
||||
func PipeBiDirectionalContext(ctx context.Context, a, b io.ReadWriteCloser, upCounter, downCounter *atomic.Int64) {
|
||||
closeBoth := func() { _ = a.Close(); _ = b.Close() }
|
||||
stop := context.AfterFunc(ctx, closeBoth)
|
||||
defer stop()
|
||||
ga := newGuardedReader(a)
|
||||
gb := newGuardedReader(b)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
pipe := func(dst io.Writer, dstGuard *guardedReader, src *guardedReader, counter *atomic.Int64) {
|
||||
defer wg.Done()
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := src.Read(buf)
|
||||
if n > 0 {
|
||||
if counter != nil {
|
||||
counter.Add(int64(n))
|
||||
}
|
||||
if _, werr := dst.Write(buf[:n]); werr != nil {
|
||||
closeBoth()
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if !errors.Is(err, io.EOF) {
|
||||
closeBoth()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if cw, ok := dst.(closeWriter); ok {
|
||||
_ = cw.CloseWrite()
|
||||
} else if closer, ok := dst.(io.Closer); ok {
|
||||
_ = closer.Close()
|
||||
}
|
||||
dstGuard.arm()
|
||||
}
|
||||
|
||||
// a -> b (upload: client to upstream)
|
||||
go pipe(b, gb, ga, upCounter)
|
||||
// b -> a (download: upstream to client)
|
||||
go pipe(a, ga, gb, downCounter)
|
||||
|
||||
wg.Wait()
|
||||
_ = a.Close()
|
||||
_ = b.Close()
|
||||
}
|
||||
|
||||
// SOCKSBasePort is the first loopback port used for a TUIC inbound's
|
||||
// internal Xray SOCKS5 relay inbound.
|
||||
const SOCKSBasePort = 64000
|
||||
|
||||
// relayPortSlots is how many ids fit in the TUIC relay port window (64001..65000).
|
||||
const relayPortSlots = 1000
|
||||
|
||||
// SOCKSPortForInbound derives one inbound's loopback SOCKS5 relay port from
|
||||
// its id, bounded within the dedicated range 64001..65000 so it never collides
|
||||
// with AmneziaWG (65101..65535), API (62789), or node egress (62800..63800).
|
||||
func SOCKSPortForInbound(inboundID int) int {
|
||||
if inboundID <= 0 {
|
||||
return SOCKSBasePort + 1
|
||||
}
|
||||
return SOCKSBasePort + 1 + (inboundID-1)%relayPortSlots
|
||||
}
|
||||
|
||||
var (
|
||||
socksPasswordOnce sync.Once
|
||||
socksPassword string
|
||||
)
|
||||
|
||||
// SocksPassword returns the process-wide password used to authenticate into
|
||||
// every TUIC SOCKS5 relay inbound.
|
||||
func SocksPassword() string {
|
||||
socksPasswordOnce.Do(func() {
|
||||
var b [24]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
socksPassword = fmt.Sprintf("tuic-fallback-%x", b)
|
||||
return
|
||||
}
|
||||
socksPassword = base64.RawURLEncoding.EncodeToString(b[:])
|
||||
})
|
||||
return socksPassword
|
||||
}
|
||||
|
||||
// SocksInboundSettings builds the JSON `settings` block for a stock Xray
|
||||
// SOCKS5 inbound with one username/password account per email, all sharing
|
||||
// password. UDP is enabled for UDP ASSOCIATE proxying.
|
||||
func SocksInboundSettings(emails []string, password string) ([]byte, error) {
|
||||
type account struct {
|
||||
User string `json:"user"`
|
||||
Pass string `json:"pass"`
|
||||
}
|
||||
settings := struct {
|
||||
Auth string `json:"auth"`
|
||||
UDP bool `json:"udp"`
|
||||
Accounts []account `json:"accounts"`
|
||||
}{Auth: "password", UDP: true}
|
||||
for _, email := range emails {
|
||||
settings.Accounts = append(settings.Accounts, account{User: email, Pass: password})
|
||||
}
|
||||
return json.Marshal(settings)
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
package tuic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuildSocks5ConnectRequest(t *testing.T) {
|
||||
// IPv4
|
||||
ip4 := net.ParseIP("1.2.3.4")
|
||||
req4 := buildSocks5ConnectRequest(&Address{Type: AddrTypeIPv4, IP: ip4, Port: 8080})
|
||||
if len(req4) != 10 || req4[0] != 0x05 || req4[1] != 0x01 || req4[3] != 0x01 {
|
||||
t.Fatalf("unexpected IPv4 CONNECT request: %x", req4)
|
||||
}
|
||||
if binary.BigEndian.Uint16(req4[8:10]) != 8080 {
|
||||
t.Fatalf("expected port 8080, got %d", binary.BigEndian.Uint16(req4[8:10]))
|
||||
}
|
||||
|
||||
// IPv6
|
||||
ip6 := net.ParseIP("2001:db8::1")
|
||||
req6 := buildSocks5ConnectRequest(&Address{Type: AddrTypeIPv6, IP: ip6, Port: 443})
|
||||
if len(req6) != 22 || req6[3] != 0x04 {
|
||||
t.Fatalf("unexpected IPv6 CONNECT request: %x", req6)
|
||||
}
|
||||
if binary.BigEndian.Uint16(req6[20:22]) != 443 {
|
||||
t.Fatalf("expected port 443, got %d", binary.BigEndian.Uint16(req6[20:22]))
|
||||
}
|
||||
|
||||
// Domain
|
||||
reqD := buildSocks5ConnectRequest(&Address{Type: AddrTypeDomain, Host: "example.com", Port: 80})
|
||||
if reqD == nil || reqD[3] != 0x03 || reqD[4] != byte(len("example.com")) {
|
||||
t.Fatalf("unexpected Domain CONNECT request: %x", reqD)
|
||||
}
|
||||
if binary.BigEndian.Uint16(reqD[len(reqD)-2:]) != 80 {
|
||||
t.Fatalf("expected port 80, got %d", binary.BigEndian.Uint16(reqD[len(reqD)-2:]))
|
||||
}
|
||||
|
||||
// Nil target
|
||||
if buildSocks5ConnectRequest(nil) != nil {
|
||||
t.Fatalf("expected nil for nil target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSocks5UDPHeader(t *testing.T) {
|
||||
// IPv4
|
||||
ip4 := net.ParseIP("192.168.1.1")
|
||||
hdr4 := buildSocks5UDPHeader(&Address{Type: AddrTypeIPv4, IP: ip4, Port: 53})
|
||||
if len(hdr4) != 10 || hdr4[3] != 0x01 || binary.BigEndian.Uint16(hdr4[8:10]) != 53 {
|
||||
t.Fatalf("unexpected IPv4 UDP header: %x", hdr4)
|
||||
}
|
||||
|
||||
// IPv6
|
||||
ip6 := net.ParseIP("::1")
|
||||
hdr6 := buildSocks5UDPHeader(&Address{Type: AddrTypeIPv6, IP: ip6, Port: 5353})
|
||||
if len(hdr6) != 22 || hdr6[3] != 0x04 || binary.BigEndian.Uint16(hdr6[20:22]) != 5353 {
|
||||
t.Fatalf("unexpected IPv6 UDP header: %x", hdr6)
|
||||
}
|
||||
|
||||
// Domain
|
||||
hdrD := buildSocks5UDPHeader(&Address{Type: AddrTypeDomain, Host: "dns.google", Port: 53})
|
||||
if hdrD == nil || hdrD[3] != 0x03 || hdrD[4] != byte(len("dns.google")) {
|
||||
t.Fatalf("unexpected Domain UDP header: %x", hdrD)
|
||||
}
|
||||
|
||||
// Nil target
|
||||
if buildSocks5UDPHeader(nil) != nil {
|
||||
t.Fatalf("expected nil for nil target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSocks5UDPRequestHonorsMaximumForAddressOverhead(t *testing.T) {
|
||||
domain := &Address{Type: AddrTypeDomain, Host: strings.Repeat("a", 255), Port: 53}
|
||||
packet, err := buildSocks5UDPRequest(domain, make([]byte, maxSafeUdpRelayPacketSize))
|
||||
if err != nil {
|
||||
t.Fatalf("maximum safe payload was rejected: %v", err)
|
||||
}
|
||||
if len(packet) != maxSocksUdpDatagramSize {
|
||||
t.Fatalf("encoded SOCKS datagram = %d bytes, want %d", len(packet), maxSocksUdpDatagramSize)
|
||||
}
|
||||
if _, err := buildSocks5UDPRequest(domain, make([]byte, maxSafeUdpRelayPacketSize+1)); !errors.Is(err, ErrUdpPayloadTooLarge) {
|
||||
t.Fatalf("oversized SOCKS datagram error = %v, want %v", err, ErrUdpPayloadTooLarge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountingConn(t *testing.T) {
|
||||
serverConn, clientConn := net.Pipe()
|
||||
defer serverConn.Close()
|
||||
defer clientConn.Close()
|
||||
|
||||
var bytesRead atomic.Int64
|
||||
var bytesWritten atomic.Int64
|
||||
c := &CountingConn{
|
||||
Conn: clientConn,
|
||||
bytesRead: &bytesRead,
|
||||
bytesWritten: &bytesWritten,
|
||||
}
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 100)
|
||||
n, _ := serverConn.Read(buf)
|
||||
_, _ = serverConn.Write(buf[:n])
|
||||
}()
|
||||
|
||||
msg := []byte("hello counting conn")
|
||||
n, err := c.Write(msg)
|
||||
if err != nil || n != len(msg) {
|
||||
t.Fatalf("write failed: %v", err)
|
||||
}
|
||||
if bytesWritten.Load() != int64(len(msg)) {
|
||||
t.Fatalf("expected %d written, got %d", len(msg), bytesWritten.Load())
|
||||
}
|
||||
|
||||
resp := make([]byte, 100)
|
||||
rn, err := c.Read(resp)
|
||||
if err != nil || rn != len(msg) {
|
||||
t.Fatalf("read failed: %v", err)
|
||||
}
|
||||
if bytesRead.Load() != int64(len(msg)) {
|
||||
t.Fatalf("expected %d read, got %d", len(msg), bytesRead.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipeBiDirectional(t *testing.T) {
|
||||
a1, a2 := net.Pipe()
|
||||
b1, b2 := net.Pipe()
|
||||
|
||||
var up, down atomic.Int64
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
PipeBiDirectional(a1, b1, &up, &down)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Send from a2 -> a1 -> b1 -> b2 (upload)
|
||||
testDataUp := []byte("upload stream test")
|
||||
go func() {
|
||||
_, _ = a2.Write(testDataUp)
|
||||
}()
|
||||
bufUp := make([]byte, len(testDataUp))
|
||||
_, err := io.ReadFull(b2, bufUp)
|
||||
if err != nil || !bytes.Equal(bufUp, testDataUp) {
|
||||
t.Fatalf("upload read failed: %v", err)
|
||||
}
|
||||
|
||||
// Send from b2 -> b1 -> a1 -> a2 (download)
|
||||
testDataDown := []byte("download stream test")
|
||||
go func() {
|
||||
_, _ = b2.Write(testDataDown)
|
||||
}()
|
||||
bufDown := make([]byte, len(testDataDown))
|
||||
_, err = io.ReadFull(a2, bufDown)
|
||||
if err != nil || !bytes.Equal(bufDown, testDataDown) {
|
||||
t.Fatalf("download read failed: %v", err)
|
||||
}
|
||||
|
||||
_ = a2.Close()
|
||||
_ = b2.Close()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("PipeBiDirectional timed out waiting to finish")
|
||||
}
|
||||
|
||||
if up.Load() < int64(len(testDataUp)) {
|
||||
t.Fatalf("expected at least %d up, got %d", len(testDataUp), up.Load())
|
||||
}
|
||||
if down.Load() < int64(len(testDataDown)) {
|
||||
t.Fatalf("expected at least %d down, got %d", len(testDataDown), down.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func startMockSocks5Server(t *testing.T, expectedUser, expectedPass string) (string, func()) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to listen: %v", err)
|
||||
}
|
||||
|
||||
stop := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
go handleMockSocksConn(conn, expectedUser, expectedPass)
|
||||
}
|
||||
}()
|
||||
|
||||
return ln.Addr().String(), func() {
|
||||
close(stop)
|
||||
_ = ln.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func handleMockSocksConn(conn net.Conn, expectedUser, expectedPass string) {
|
||||
defer conn.Close()
|
||||
// Read greeting
|
||||
var greeting [4]byte
|
||||
if _, err := io.ReadFull(conn, greeting[:]); err != nil {
|
||||
return
|
||||
}
|
||||
// Select user/password auth (0x02)
|
||||
if _, err := conn.Write([]byte{0x05, 0x02}); err != nil {
|
||||
return
|
||||
}
|
||||
// Auth negotiation
|
||||
var authVer [2]byte
|
||||
if _, err := io.ReadFull(conn, authVer[:]); err != nil {
|
||||
return
|
||||
}
|
||||
uLen := int(authVer[1])
|
||||
user := make([]byte, uLen)
|
||||
if _, err := io.ReadFull(conn, user); err != nil {
|
||||
return
|
||||
}
|
||||
var pLen [1]byte
|
||||
if _, err := io.ReadFull(conn, pLen[:]); err != nil {
|
||||
return
|
||||
}
|
||||
pass := make([]byte, int(pLen[0]))
|
||||
if _, err := io.ReadFull(conn, pass); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if string(user) != expectedUser || string(pass) != expectedPass {
|
||||
_, _ = conn.Write([]byte{0x01, 0x01}) // auth failure
|
||||
return
|
||||
}
|
||||
_, _ = conn.Write([]byte{0x01, 0x00}) // auth success
|
||||
|
||||
// Read command
|
||||
var cmdHdr [4]byte
|
||||
if _, err := io.ReadFull(conn, cmdHdr[:]); err != nil {
|
||||
return
|
||||
}
|
||||
cmd := cmdHdr[1]
|
||||
atyp := cmdHdr[3]
|
||||
|
||||
// Read dest address
|
||||
switch atyp {
|
||||
case 0x01:
|
||||
var ip [4]byte
|
||||
_, _ = io.ReadFull(conn, ip[:])
|
||||
case 0x04:
|
||||
var ip [16]byte
|
||||
_, _ = io.ReadFull(conn, ip[:])
|
||||
case 0x03:
|
||||
var dLen [1]byte
|
||||
_, _ = io.ReadFull(conn, dLen[:])
|
||||
domain := make([]byte, dLen[0])
|
||||
_, _ = io.ReadFull(conn, domain)
|
||||
}
|
||||
var port [2]byte
|
||||
_, _ = io.ReadFull(conn, port[:])
|
||||
|
||||
switch cmd {
|
||||
case 0x01: // CONNECT
|
||||
// Send success reply: 0x05 0x00 0x00 0x01 (IPv4 127.0.0.1:0)
|
||||
_, _ = conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1, 0x1f, 0x90})
|
||||
// Echo server for testing
|
||||
_, _ = io.Copy(conn, conn)
|
||||
case 0x03: // UDP ASSOCIATE
|
||||
// Bind a UDP listener for the mock
|
||||
u, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer u.Close()
|
||||
bindAddr := u.LocalAddr().(*net.UDPAddr)
|
||||
bindPort := uint16(bindAddr.Port)
|
||||
|
||||
resp := make([]byte, 10)
|
||||
resp[0] = 0x05
|
||||
resp[1] = 0x00
|
||||
resp[2] = 0x00
|
||||
resp[3] = 0x01
|
||||
copy(resp[4:8], bindAddr.IP.To4())
|
||||
binary.BigEndian.PutUint16(resp[8:10], bindPort)
|
||||
if _, err := conn.Write(resp); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, maxUdpRelayPacketSize)
|
||||
for {
|
||||
n, remoteAddr, err := u.ReadFrom(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = u.WriteTo(buf[:n], remoteAddr)
|
||||
}
|
||||
}()
|
||||
|
||||
// Keep conn open until closed
|
||||
buf := make([]byte, 1)
|
||||
_, _ = conn.Read(buf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSocksRelayDialTCP(t *testing.T) {
|
||||
addr, cleanup := startMockSocks5Server(t, "user@test.com", "secretpass")
|
||||
defer cleanup()
|
||||
|
||||
relay := &SocksRelay{
|
||||
Addr: addr,
|
||||
Password: "secretpass",
|
||||
}
|
||||
|
||||
target := &Address{
|
||||
Type: AddrTypeIPv4,
|
||||
IP: net.ParseIP("93.184.216.34"),
|
||||
Port: 80,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
conn, err := relay.DialTCP(ctx, "user@test.com", target)
|
||||
if err != nil {
|
||||
t.Fatalf("DialTCP failed: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Send echo payload
|
||||
msg := []byte("ping through socks")
|
||||
if _, err := conn.Write(msg); err != nil {
|
||||
t.Fatalf("write failed: %v", err)
|
||||
}
|
||||
reply := make([]byte, len(msg))
|
||||
if _, err := io.ReadFull(conn, reply); err != nil {
|
||||
t.Fatalf("read failed: %v", err)
|
||||
}
|
||||
if !bytes.Equal(reply, msg) {
|
||||
t.Fatalf("expected %q, got %q", msg, reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSocksRelayDialUDP(t *testing.T) {
|
||||
addr, cleanup := startMockSocks5Server(t, "user@test.com", "secretpass")
|
||||
defer cleanup()
|
||||
|
||||
relay := &SocksRelay{
|
||||
Addr: addr,
|
||||
Password: "secretpass",
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
session, err := relay.DialUDP(ctx, "user@test.com")
|
||||
if err != nil {
|
||||
t.Fatalf("DialUDP failed: %v", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
target := &Address{
|
||||
Type: AddrTypeIPv4,
|
||||
IP: net.ParseIP("8.8.8.8"),
|
||||
Port: 53,
|
||||
}
|
||||
payload := []byte("dns packet payload")
|
||||
|
||||
n, err := session.Send(target, payload)
|
||||
if err != nil || n == 0 {
|
||||
t.Fatalf("Send failed: %v", err)
|
||||
}
|
||||
|
||||
buf := make([]byte, 2048)
|
||||
recvAddr, recvPayload, err := session.Receive(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("Receive failed: %v", err)
|
||||
}
|
||||
if !bytes.Equal(recvPayload, payload) {
|
||||
t.Fatalf("expected payload %q, got %q", payload, recvPayload)
|
||||
}
|
||||
if recvAddr.IP.String() != "8.8.8.8" || recvAddr.Port != 53 {
|
||||
t.Fatalf("unexpected addr: %v", recvAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSOCKSPortForInboundKeepsEverySlotInsideTheWindow(t *testing.T) {
|
||||
for id := 1; id <= 3000; id++ {
|
||||
port := SOCKSPortForInbound(id)
|
||||
if port < 64001 || port > 65000 {
|
||||
t.Fatalf("id %d derived port %d outside window [64001, 65000]", id, port)
|
||||
}
|
||||
}
|
||||
if got := SOCKSPortForInbound(1); got != 64001 {
|
||||
t.Fatalf("expected 64001 for id 1, got %d", got)
|
||||
}
|
||||
if got := SOCKSPortForInbound(1000); got != 65000 {
|
||||
t.Fatalf("expected 65000 for id 1000, got %d", got)
|
||||
}
|
||||
if got := SOCKSPortForInbound(1001); got != 64001 {
|
||||
t.Fatalf("expected 64001 for id 1001, got %d", got)
|
||||
}
|
||||
if got := SOCKSPortForInbound(0); got != 64001 {
|
||||
t.Fatalf("expected 64001 for id 0, got %d", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package tuic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
serverquic "github.com/apernet/quic-go"
|
||||
"github.com/google/uuid"
|
||||
clientquic "github.com/quic-go/quic-go"
|
||||
)
|
||||
|
||||
func startLifecycleTestServer(t *testing.T, relayAddr, email string) (*Server, *clientquic.Conn, uuid.UUID, string) {
|
||||
t.Helper()
|
||||
cert, key := generateTestCert(t)
|
||||
clientID := uuid.New()
|
||||
password := "lifecycle-test-password"
|
||||
server, err := NewServer(Instance{
|
||||
Id: 99101, Tag: "lifecycle-test", Listen: "127.0.0.1", Port: 0,
|
||||
Certificate: string(cert), PrivateKey: string(key), ALPN: []string{"h3"},
|
||||
AuthenticationTimeout: 2, MaxIdleTime: 30,
|
||||
Clients: []TuicClientSettings{{UUID: clientID.String(), Password: password, Email: email}},
|
||||
}, &SocksRelay{Addr: relayAddr, Password: "lifecycle-socks-password"})
|
||||
if err != nil {
|
||||
t.Fatalf("create TUIC server: %v", err)
|
||||
}
|
||||
if err := server.Start(); err != nil {
|
||||
t.Fatalf("start TUIC server: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = server.Close() })
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
client, err := clientquic.DialAddr(ctx, server.packetConn.LocalAddr().String(),
|
||||
&tls.Config{InsecureSkipVerify: true, NextProtos: []string{"h3"}},
|
||||
&clientquic.Config{EnableDatagrams: true})
|
||||
if err != nil {
|
||||
t.Fatalf("dial TUIC server: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = client.CloseWithError(0, "test complete") })
|
||||
|
||||
tlsState := client.ConnectionState().TLS
|
||||
token, err := tlsState.ExportKeyingMaterial(string(clientID[:]), []byte(password), 32)
|
||||
if err != nil {
|
||||
t.Fatalf("derive authentication token: %v", err)
|
||||
}
|
||||
stream, err := client.OpenUniStreamSync(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("open authentication stream: %v", err)
|
||||
}
|
||||
auth := append([]byte{ProtocolVersion, CmdAuthenticate}, clientID[:]...)
|
||||
auth = append(auth, token...)
|
||||
if _, err := stream.Write(auth); err != nil {
|
||||
t.Fatalf("write authentication: %v", err)
|
||||
}
|
||||
if err := stream.Close(); err != nil {
|
||||
t.Fatalf("close authentication stream: %v", err)
|
||||
}
|
||||
return server, client, clientID, password
|
||||
}
|
||||
|
||||
func authenticatedServerConnection(t *testing.T, server *Server, clientID uuid.UUID) (*serverquic.Conn, *User) {
|
||||
t.Helper()
|
||||
id := [16]byte(clientID)
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
server.activeConnsMu.Lock()
|
||||
for conn, user := range server.activeConns[id] {
|
||||
server.activeConnsMu.Unlock()
|
||||
return conn, user
|
||||
}
|
||||
server.activeConnsMu.Unlock()
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatal("server did not register authenticated QUIC connection")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestMalformedBiStreamDelayedFINReleasesReceiveCredit(t *testing.T) {
|
||||
_, client, _, _ := startLifecycleTestServer(t, "127.0.0.1:1", "bidi-lifecycle@x")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
for i := 0; i < 110; i++ {
|
||||
openCtx, openCancel := context.WithTimeout(ctx, 700*time.Millisecond)
|
||||
stream, err := client.OpenStreamSync(openCtx)
|
||||
openCancel()
|
||||
if err != nil {
|
||||
t.Fatalf("bidirectional stream %d blocked after unsupported commands: %v", i+1, err)
|
||||
}
|
||||
if _, err := stream.Write([]byte{ProtocolVersion, 0xff}); err != nil {
|
||||
t.Fatalf("write unsupported command %d: %v", i+1, err)
|
||||
}
|
||||
stream.SetReadDeadline(time.Now().Add(time.Second))
|
||||
if _, err := io.Copy(io.Discard, stream); err != nil {
|
||||
t.Fatalf("wait for unsupported stream %d to close: %v", i+1, err)
|
||||
}
|
||||
_ = stream.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownstreamUDPResponseRefreshesAssociationIdleTime(t *testing.T) {
|
||||
socksAddr, cleanup := startMockSocks5Server(t, "udp-lifecycle@x", "lifecycle-socks-password")
|
||||
defer cleanup()
|
||||
server, client, clientID, _ := startLifecycleTestServer(t, socksAddr, "udp-lifecycle@x")
|
||||
serverConn, user := authenticatedServerConnection(t, server, clientID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
session, err := server.relay.DialUDP(ctx, user.Email)
|
||||
if err != nil {
|
||||
t.Fatalf("open SOCKS UDP session: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = session.Close() })
|
||||
const associationID uint16 = 61244
|
||||
oldActive := time.Now().Add(-udpAssociationIdleTimeout - time.Second)
|
||||
association := &udpAssociation{
|
||||
responseTransport: packetTransportDatagram,
|
||||
relay: &udpRelaySession{relay: session, responseTransport: packetTransportDatagram},
|
||||
lastActive: oldActive,
|
||||
}
|
||||
registry := newUdpAssociationRegistry(maxUdpRelayPacketSize)
|
||||
registry.associations[associationID] = association
|
||||
responseDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(responseDone)
|
||||
server.relayUDPResponses(ctx, serverConn, user, associationID, association, registry, association.relay)
|
||||
}()
|
||||
|
||||
target := &Address{Type: AddrTypeIPv4, IP: net.IPv4(8, 8, 8, 8), Port: 53}
|
||||
if _, err := session.Send(target, []byte("seed")); err != nil {
|
||||
t.Fatalf("send SOCKS seed datagram: %v", err)
|
||||
}
|
||||
response, err := client.ReceiveDatagram(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("receive echoed UDP response: %v", err)
|
||||
}
|
||||
if len(response) < 2 || response[0] != ProtocolVersion || response[1] != CmdPacket {
|
||||
t.Fatalf("unexpected TUIC UDP response: %x", response)
|
||||
}
|
||||
reader := bytes.NewReader(response[2:])
|
||||
header, err := ReadPacketHeader(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("read response header: %v", err)
|
||||
}
|
||||
got, err := readPacketPayload(reader, header)
|
||||
if err != nil || !bytes.Equal(got, []byte("seed")) {
|
||||
t.Fatalf("echo response payload = %q, error=%v", got, err)
|
||||
}
|
||||
if header.AssocID != associationID {
|
||||
t.Fatalf("response association id = %d, want %d", header.AssocID, associationID)
|
||||
}
|
||||
|
||||
registry.mu.Lock()
|
||||
refreshedAt := association.lastActive
|
||||
registry.mu.Unlock()
|
||||
if !refreshedAt.After(oldActive) {
|
||||
t.Fatal("successful downstream response did not refresh association activity")
|
||||
}
|
||||
registry.reapIdle(oldActive.Add(udpAssociationIdleTimeout + time.Second))
|
||||
registry.mu.Lock()
|
||||
remaining := registry.associations[associationID]
|
||||
registry.mu.Unlock()
|
||||
if remaining != association {
|
||||
t.Fatal("association was reaped despite a recently delivered downstream response")
|
||||
}
|
||||
_ = session.Close()
|
||||
select {
|
||||
case <-responseDone:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("UDP response relay did not stop after SOCKS session closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUdpAssociationTouchDoesNotRefreshReusedID(t *testing.T) {
|
||||
registry := newUdpAssociationRegistry(maxUdpRelayPacketSize)
|
||||
addr := &Address{Type: AddrTypeIPv4, IP: net.IPv4(8, 8, 8, 8), Port: 53}
|
||||
header := &PacketHeader{AssocID: 17, PktID: 1, FragTotal: 1, FragID: 0, Size: 1, Addr: addr}
|
||||
old, _, _, complete := registry.feed(packetTransportDatagram, header, []byte("x"))
|
||||
if !complete {
|
||||
t.Fatal("failed to create first association generation")
|
||||
}
|
||||
oldTime := old.lastActive
|
||||
if !registry.dissociate(header.AssocID) {
|
||||
t.Fatal("failed to dissociate first association generation")
|
||||
}
|
||||
newGeneration, _, _, complete := registry.feed(packetTransportDatagram, header, []byte("x"))
|
||||
if !complete || newGeneration == old {
|
||||
t.Fatal("failed to create replacement association generation")
|
||||
}
|
||||
if registry.touch(header.AssocID, old, oldTime.Add(time.Hour)) {
|
||||
t.Fatal("late response refreshed a replacement association generation")
|
||||
}
|
||||
if !newGeneration.lastActive.Before(oldTime.Add(time.Hour)) {
|
||||
t.Fatal("replacement association timestamp changed after stale touch")
|
||||
}
|
||||
}
|
||||
+47
-23
@@ -1,9 +1,11 @@
|
||||
package tuic
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -26,9 +28,10 @@ type TuicServerSettings struct {
|
||||
}
|
||||
|
||||
type TuicClientSettings struct {
|
||||
UUID string `json:"uuid"`
|
||||
Password string `json:"password"`
|
||||
Email string `json:"email"`
|
||||
TrafficID int `json:"-"`
|
||||
UUID string `json:"uuid"`
|
||||
Password string `json:"password"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type Instance struct {
|
||||
@@ -38,48 +41,50 @@ type Instance struct {
|
||||
Port int
|
||||
Certificate string
|
||||
PrivateKey string
|
||||
CongestionControl string
|
||||
CongestionControl string // Applied to new native QUIC connections and exported to client profiles.
|
||||
ALPN []string
|
||||
UDPRelayMode string
|
||||
UDPRelayMode string // Default mode exported to client links; the listener accepts both modes.
|
||||
ZeroRTTHandshake bool
|
||||
LogLevel string
|
||||
MaxIdleTime int
|
||||
AuthenticationTimeout int
|
||||
MaxUdpRelayPacketSize int
|
||||
SNI string
|
||||
SNI string // Exported to client links; it doesn't change the native listener certificate.
|
||||
Clients []TuicClientSettings
|
||||
}
|
||||
|
||||
func (inst Instance) BindTo() string {
|
||||
listen := inst.Listen
|
||||
if listen == "" {
|
||||
listen = "0.0.0.0"
|
||||
}
|
||||
return net.JoinHostPort(listen, strconv.Itoa(inst.Port))
|
||||
return net.JoinHostPort(inst.Listen, strconv.Itoa(inst.Port))
|
||||
}
|
||||
|
||||
func (inst Instance) StructuralFingerprint() string {
|
||||
parts := []string{
|
||||
inst.BindTo(),
|
||||
inst.Certificate,
|
||||
inst.PrivateKey,
|
||||
inst.CongestionControl,
|
||||
fingerprintMaterial(inst.Certificate),
|
||||
fingerprintMaterial(inst.PrivateKey),
|
||||
strings.Join(inst.ALPN, ","),
|
||||
inst.UDPRelayMode,
|
||||
strconv.FormatBool(inst.ZeroRTTHandshake),
|
||||
inst.LogLevel,
|
||||
strconv.Itoa(inst.MaxIdleTime),
|
||||
strconv.Itoa(inst.AuthenticationTimeout),
|
||||
strconv.Itoa(inst.MaxUdpRelayPacketSize),
|
||||
inst.SNI,
|
||||
}
|
||||
return strings.Join(parts, "|")
|
||||
}
|
||||
|
||||
func fingerprintMaterial(value string) string {
|
||||
material := []byte(value)
|
||||
if value != "" && !strings.Contains(value, "-----BEGIN ") {
|
||||
if file, err := os.ReadFile(value); err == nil {
|
||||
material = file
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%x", sha256.Sum256(material))
|
||||
}
|
||||
|
||||
func (inst Instance) UsersFingerprint() string {
|
||||
pairs := make([]string, 0, len(inst.Clients))
|
||||
for _, c := range inst.Clients {
|
||||
pairs = append(pairs, fmt.Sprintf("%s=%s:%s", c.Email, c.UUID, c.Password))
|
||||
pairs = append(pairs, fmt.Sprintf("%d:%s=%s:%s", c.TrafficID, c.Email, c.UUID, c.Password))
|
||||
}
|
||||
slices.Sort(pairs)
|
||||
return strings.Join(pairs, "|")
|
||||
@@ -120,6 +125,7 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
|
||||
SNI string `json:"sni"`
|
||||
} `json:"server"`
|
||||
Clients []struct {
|
||||
TrafficID int `json:"traffic_id"`
|
||||
UUID string `json:"uuid"`
|
||||
ID string `json:"id"`
|
||||
Password string `json:"password"`
|
||||
@@ -187,8 +193,10 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
if cc == "" {
|
||||
cc = "bbr"
|
||||
if normalized, err := NormalizeCongestionControl(cc); err == nil {
|
||||
cc = normalized
|
||||
} else {
|
||||
cc = "new_reno"
|
||||
}
|
||||
if len(alpn) == 0 {
|
||||
alpn = []string{"h3", "spdy/3.1"}
|
||||
@@ -207,6 +215,8 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
|
||||
}
|
||||
if maxPacketSize <= 0 {
|
||||
maxPacketSize = 1500
|
||||
} else if maxPacketSize > maxSafeUdpRelayPacketSize && maxPacketSize <= maxLegacyUdpRelayPacketSize {
|
||||
maxPacketSize = maxSafeUdpRelayPacketSize
|
||||
}
|
||||
|
||||
clients := make([]TuicClientSettings, 0, len(parsed.Clients))
|
||||
@@ -222,9 +232,10 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
|
||||
continue
|
||||
}
|
||||
clients = append(clients, TuicClientSettings{
|
||||
UUID: uuidVal,
|
||||
Password: c.Password,
|
||||
Email: c.Email,
|
||||
TrafficID: c.TrafficID,
|
||||
UUID: uuidVal,
|
||||
Password: c.Password,
|
||||
Email: c.Email,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -247,3 +258,16 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
|
||||
Clients: clients,
|
||||
}, true
|
||||
}
|
||||
|
||||
func NormalizeCongestionControl(value string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "bbr":
|
||||
return "bbr", nil
|
||||
case "cubic":
|
||||
return "cubic", nil
|
||||
case "new_reno", "reno":
|
||||
return "new_reno", nil
|
||||
default:
|
||||
return "", fmt.Errorf("tuic: unsupported congestion controller %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package tuic
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
@@ -74,6 +76,26 @@ func TestInstanceFromInbound(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestStructuralFingerprintDetectsCertificateRenewalAtSamePath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "fullchain.pem")
|
||||
keyPath := filepath.Join(dir, "privkey.pem")
|
||||
if err := os.WriteFile(certPath, []byte("certificate-v1"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, []byte("key-v1"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inst := Instance{Listen: "0.0.0.0", Port: 443, Certificate: certPath, PrivateKey: keyPath}
|
||||
before := inst.StructuralFingerprint()
|
||||
if err := os.WriteFile(certPath, []byte("certificate-v2"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if before == inst.StructuralFingerprint() {
|
||||
t.Fatal("certificate renewal at the same path did not change the structural fingerprint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFingerprints(t *testing.T) {
|
||||
inst1 := Instance{
|
||||
Id: 1,
|
||||
@@ -101,6 +123,20 @@ func TestFingerprints(t *testing.T) {
|
||||
if inst1.UsersFingerprint() != inst2.UsersFingerprint() {
|
||||
t.Fatalf("users fingerprint must be stable under reordering: %s vs %s", inst1.UsersFingerprint(), inst2.UsersFingerprint())
|
||||
}
|
||||
|
||||
profileOnlyChange := inst1
|
||||
profileOnlyChange.CongestionControl = "new_reno"
|
||||
profileOnlyChange.UDPRelayMode = "quic"
|
||||
profileOnlyChange.SNI = "client-profile.example"
|
||||
if inst1.StructuralFingerprint() != profileOnlyChange.StructuralFingerprint() {
|
||||
t.Fatal("client-profile defaults must not restart the native TUIC listener")
|
||||
}
|
||||
|
||||
packetLimitChange := inst1
|
||||
packetLimitChange.MaxUdpRelayPacketSize = 4096
|
||||
if inst1.StructuralFingerprint() == packetLimitChange.StructuralFingerprint() {
|
||||
t.Fatal("changing the UDP packet limit must update the native TUIC listener")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindTo(t *testing.T) {
|
||||
@@ -108,7 +144,7 @@ func TestBindTo(t *testing.T) {
|
||||
listen string
|
||||
want string
|
||||
}{
|
||||
{"", "0.0.0.0:8443"},
|
||||
{"", ":8443"},
|
||||
{"127.0.0.1", "127.0.0.1:8443"},
|
||||
{"::", "[::]:8443"},
|
||||
{"2001:db8::1", "[2001:db8::1]:8443"},
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package tuic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
maxUdpAssociations = 256
|
||||
udpAssociationIdleTimeout = 5 * time.Minute
|
||||
)
|
||||
|
||||
var errUdpAssociationClosed = errors.New("tuic: UDP association is closed")
|
||||
|
||||
type udpAssociation struct {
|
||||
responseTransport uint8
|
||||
relay *udpRelaySession
|
||||
lastActive time.Time
|
||||
}
|
||||
|
||||
type udpAssociationRegistry struct {
|
||||
mu sync.Mutex
|
||||
associations map[uint16]*udpAssociation
|
||||
datagramReassembler *packetReassembler
|
||||
streamReassembler *packetReassembler
|
||||
maxPacketSize int
|
||||
}
|
||||
|
||||
func newUdpAssociationRegistry(maxPacketSize int) *udpAssociationRegistry {
|
||||
return &udpAssociationRegistry{
|
||||
associations: make(map[uint16]*udpAssociation),
|
||||
datagramReassembler: newPacketReassembler(maxPacketSize),
|
||||
streamReassembler: newPacketReassembler(maxPacketSize),
|
||||
maxPacketSize: maxPacketSize,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *udpAssociationRegistry) feed(transport uint8, hdr *PacketHeader, payload []byte) (*udpAssociation, *Address, []byte, bool) {
|
||||
if !validPacketFragment(hdr, payload, r.maxPacketSize) {
|
||||
return nil, nil, nil, false
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.expireLocked(now)
|
||||
|
||||
association := r.associations[hdr.AssocID]
|
||||
if association == nil {
|
||||
if len(r.associations) >= maxUdpAssociations {
|
||||
return nil, nil, nil, false
|
||||
}
|
||||
association = &udpAssociation{responseTransport: transport, lastActive: now}
|
||||
r.associations[hdr.AssocID] = association
|
||||
}
|
||||
association.lastActive = now
|
||||
|
||||
reassembler := r.datagramReassembler
|
||||
if transport == packetTransportStream {
|
||||
reassembler = r.streamReassembler
|
||||
}
|
||||
addr, completePayload, complete := reassembler.feed(transport, hdr, payload)
|
||||
return association, addr, completePayload, complete
|
||||
}
|
||||
|
||||
func validPacketFragment(hdr *PacketHeader, payload []byte, maxPacketSize int) bool {
|
||||
if hdr == nil || hdr.FragTotal == 0 || hdr.FragID >= hdr.FragTotal || int(hdr.Size) != len(payload) || len(payload) > maxPacketSize {
|
||||
return false
|
||||
}
|
||||
if hdr.FragTotal == 1 {
|
||||
return hdr.FragID == 0 && isPacketTarget(hdr.Addr)
|
||||
}
|
||||
return (hdr.FragID != 0 || isPacketTarget(hdr.Addr)) &&
|
||||
(hdr.FragID == 0 || hdr.Addr == nil || hdr.Addr.Type == AddrTypeNone)
|
||||
}
|
||||
|
||||
func (r *udpAssociationRegistry) ensureRelay(ctx context.Context, assocID uint16, expected *udpAssociation, user *User, relay *SocksRelay) (*udpAssociation, bool, error) {
|
||||
r.mu.Lock()
|
||||
association := r.associations[assocID]
|
||||
if association == nil || association != expected {
|
||||
r.mu.Unlock()
|
||||
return nil, false, errUdpAssociationClosed
|
||||
}
|
||||
if association.relay != nil {
|
||||
association.lastActive = time.Now()
|
||||
r.mu.Unlock()
|
||||
return association, false, nil
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
socksSession, err := relay.DialUDP(ctx, user.Email)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
current := r.associations[assocID]
|
||||
if current != association {
|
||||
_ = socksSession.Close()
|
||||
return nil, false, errUdpAssociationClosed
|
||||
}
|
||||
if current.relay != nil {
|
||||
_ = socksSession.Close()
|
||||
current.lastActive = time.Now()
|
||||
return current, false, nil
|
||||
}
|
||||
current.relay = &udpRelaySession{relay: socksSession, responseTransport: current.responseTransport}
|
||||
current.lastActive = time.Now()
|
||||
return current, true, nil
|
||||
}
|
||||
|
||||
func (r *udpAssociationRegistry) dissociate(assocID uint16) bool {
|
||||
r.mu.Lock()
|
||||
association := r.associations[assocID]
|
||||
delete(r.associations, assocID)
|
||||
r.datagramReassembler.clearAssociation(assocID)
|
||||
r.streamReassembler.clearAssociation(assocID)
|
||||
r.mu.Unlock()
|
||||
if association == nil {
|
||||
return false
|
||||
}
|
||||
if association.relay != nil {
|
||||
_ = association.relay.relay.Close()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *udpAssociationRegistry) touch(assocID uint16, expected *udpAssociation, now time.Time) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
current := r.associations[assocID]
|
||||
if current == nil || current != expected {
|
||||
return false
|
||||
}
|
||||
current.lastActive = now
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *udpAssociationRegistry) reapIdle(now time.Time) {
|
||||
r.mu.Lock()
|
||||
r.expireLocked(now)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *udpAssociationRegistry) expireLocked(now time.Time) {
|
||||
for assocID, association := range r.associations {
|
||||
if now.Sub(association.lastActive) <= udpAssociationIdleTimeout {
|
||||
continue
|
||||
}
|
||||
delete(r.associations, assocID)
|
||||
r.datagramReassembler.clearAssociation(assocID)
|
||||
r.streamReassembler.clearAssociation(assocID)
|
||||
if association.relay != nil {
|
||||
_ = association.relay.relay.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *udpAssociationRegistry) closeAll() {
|
||||
r.mu.Lock()
|
||||
for _, association := range r.associations {
|
||||
if association.relay != nil {
|
||||
_ = association.relay.relay.Close()
|
||||
}
|
||||
}
|
||||
r.associations = make(map[uint16]*udpAssociation)
|
||||
r.datagramReassembler.clearAll()
|
||||
r.streamReassembler.clearAll()
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *udpAssociationRegistry) release(id uint16, expected *udpAssociation) {
|
||||
r.mu.Lock()
|
||||
if r.associations[id] == expected {
|
||||
delete(r.associations, id)
|
||||
r.datagramReassembler.clearAssociation(id)
|
||||
r.streamReassembler.clearAssociation(id)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
if expected.relay != nil {
|
||||
_ = expected.relay.relay.Close()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user