mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-09 14:45:02 +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:
@@ -1,8 +1,11 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
|
||||
@@ -102,6 +105,10 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
|
||||
tx = database.GetDB()
|
||||
}
|
||||
|
||||
if err := s.validateTuicIdentities(tx, inboundId, clients, detachEmails, prune); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
emails := make([]string, 0, len(clients))
|
||||
seen := make(map[string]struct{}, len(clients))
|
||||
for i := range clients {
|
||||
@@ -215,7 +222,42 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
|
||||
wantedIds = append(wantedIds, id)
|
||||
}
|
||||
|
||||
return s.reconcileInboundLinks(tx, inboundId, wantedFlow, wantedIds, detachEmails, prune)
|
||||
if err := s.reconcileInboundLinks(tx, inboundId, wantedFlow, wantedIds, detachEmails, prune); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
affected := map[int]struct{}{inboundId: {}}
|
||||
for _, ids := range chunkInts(wantedIds, sqlInChunk) {
|
||||
var inboundIDs []int
|
||||
if err := tx.Model(&model.ClientInbound{}).Distinct("inbound_id").Where("client_id IN ?", ids).Pluck("inbound_id", &inboundIDs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range inboundIDs {
|
||||
affected[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
affectedIDs := make([]int, 0, len(affected))
|
||||
for id := range affected {
|
||||
affectedIDs = append(affectedIDs, id)
|
||||
}
|
||||
for _, ids := range chunkInts(affectedIDs, sqlInChunk) {
|
||||
var duplicates int64
|
||||
err := tx.Raw(`SELECT COUNT(*) FROM (
|
||||
SELECT ci.inbound_id, LOWER(c.uuid) FROM clients c
|
||||
JOIN client_inbounds ci ON ci.client_id = c.id
|
||||
JOIN inbounds i ON i.id = ci.inbound_id
|
||||
WHERE i.protocol = ? AND c.uuid <> '' AND ci.inbound_id IN ?
|
||||
GROUP BY ci.inbound_id, LOWER(c.uuid) HAVING COUNT(*) > 1
|
||||
) AS duplicate_uuids`, model.TUIC, ids).Scan(&duplicates).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if duplicates > 0 {
|
||||
return fmt.Errorf("TUIC: duplicate client UUID within inbound")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// reconcileInboundLinks writes only the client_inbounds rows that differ. prune
|
||||
@@ -373,3 +415,47 @@ func (s *ClientService) ListForInboundBySubId(tx *gorm.DB, inboundId int, subId
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) validateTuicIdentities(tx *gorm.DB, inboundID int, changed []model.Client, detached []string, prune bool) error {
|
||||
var inbound model.Inbound
|
||||
if err := tx.Select("protocol").Where("id = ?", inboundID).Take(&inbound).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if inbound.Protocol != model.TUIC {
|
||||
return nil
|
||||
}
|
||||
candidates := append([]model.Client(nil), changed...)
|
||||
if !prune {
|
||||
current, err := s.ListForInbound(tx, inboundID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
excluded := make(map[string]bool)
|
||||
for _, client := range changed {
|
||||
excluded[client.Email] = true
|
||||
}
|
||||
for _, email := range detached {
|
||||
excluded[email] = true
|
||||
}
|
||||
for _, client := range current {
|
||||
if !excluded[client.Email] {
|
||||
candidates = append(candidates, client)
|
||||
}
|
||||
}
|
||||
}
|
||||
seen := make(map[uuid.UUID]string)
|
||||
for _, client := range candidates {
|
||||
if client.ID == "" {
|
||||
continue
|
||||
}
|
||||
id, err := uuid.Parse(client.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("TUIC: invalid client UUID")
|
||||
}
|
||||
if email, exists := seen[id]; exists && email != client.Email {
|
||||
return fmt.Errorf("TUIC: duplicate client UUID within inbound")
|
||||
}
|
||||
seen[id] = client.Email
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
// PostgreSQL before 16 rejects a FROM subquery without an alias, so the TUIC
|
||||
// duplicate-UUID guard must not break client sync for every protocol there.
|
||||
func TestSyncInboundClientsOnPostgres(t *testing.T) {
|
||||
db := durablePostgresDB(t)
|
||||
suffix := time.Now().UnixNano()
|
||||
inbound := durableTestInbound(nil, fmt.Sprintf("pg-sync-%d", suffix), 20000+int(suffix%20000))
|
||||
if err := db.Create(inbound).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
email := fmt.Sprintf("pg-sync-%d@x", suffix)
|
||||
t.Cleanup(func() {
|
||||
_ = db.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error
|
||||
_ = db.Where("email = ?", email).Delete(&model.ClientRecord{}).Error
|
||||
_ = db.Delete(&model.Inbound{}, inbound.Id).Error
|
||||
})
|
||||
|
||||
clients := []model.Client{{ID: "8a9c1b2e-1111-4c3d-9e8f-000000000001", Email: email, Enable: true}}
|
||||
if err := (&ClientService{}).SyncInbound(nil, inbound.Id, clients); err != nil {
|
||||
t.Fatalf("SyncInbound on PostgreSQL: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/maskcompat"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
|
||||
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
@@ -512,15 +513,22 @@ func inboundTuicServer(protocol string, settings string) *tuic.TuicServerSetting
|
||||
if protocol != string(model.TUIC) || strings.TrimSpace(settings) == "" {
|
||||
return nil
|
||||
}
|
||||
var parsed struct {
|
||||
Server *tuic.TuicServerSettings `json:"server"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(settings), &parsed); err != nil || parsed.Server == nil {
|
||||
inst, ok := tuic.InstanceFromInbound(&model.Inbound{Protocol: model.TUIC, Settings: settings})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
redacted := *parsed.Server
|
||||
redacted.PrivateKey = ""
|
||||
return &redacted
|
||||
return &tuic.TuicServerSettings{
|
||||
Certificate: inst.Certificate,
|
||||
CongestionControl: inst.CongestionControl,
|
||||
ALPN: inst.ALPN,
|
||||
UDPRelayMode: inst.UDPRelayMode,
|
||||
ZeroRTTHandshake: inst.ZeroRTTHandshake,
|
||||
LogLevel: inst.LogLevel,
|
||||
MaxIdleTime: inst.MaxIdleTime,
|
||||
AuthenticationTimeout: inst.AuthenticationTimeout,
|
||||
MaxUdpRelayPacketSize: inst.MaxUdpRelayPacketSize,
|
||||
SNI: inst.SNI,
|
||||
}
|
||||
}
|
||||
|
||||
// inboundMtprotoDomain returns the inbound-level FakeTLS default domain, used by
|
||||
@@ -1129,6 +1137,9 @@ func (s *InboundService) normalizeMtprotoXrayPort(inbound *model.Inbound, oldSet
|
||||
// Returns the created inbound, whether Xray needs restart, and any error.
|
||||
func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
|
||||
inbound.Id = 0
|
||||
if err := normalizeTuicSettings(inbound); err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
legacyShareAddr := legacyMtprotoShareAddr(inbound)
|
||||
inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
|
||||
// Normalize streamSettings based on protocol
|
||||
@@ -1310,6 +1321,25 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
||||
return aErr
|
||||
}
|
||||
}
|
||||
if inbound.NodeID == nil && inbound.Protocol == model.TUIC {
|
||||
if self := tuicSocksSelfConflict(inbound, inbound.Id); self != "" {
|
||||
return common.NewError(self)
|
||||
}
|
||||
conflict, cErr := checkTuicSocksRelayCollision(tx, inbound.Id)
|
||||
if cErr != nil {
|
||||
return cErr
|
||||
}
|
||||
if conflict != nil {
|
||||
return common.NewError(conflict.String())
|
||||
}
|
||||
conflict, cErr = checkTuicSocksReverseConflict(tx, inbound.Id)
|
||||
if cErr != nil {
|
||||
return cErr
|
||||
}
|
||||
if conflict != nil {
|
||||
return common.NewError(conflict.String())
|
||||
}
|
||||
}
|
||||
// Emails seeded here (import's ClientStats, e.g. the controller's forced
|
||||
// Enable=true on every imported stat row) are authoritative for this call
|
||||
// and must not be clobbered by the AddClientStat loop below, which derives
|
||||
@@ -1772,6 +1802,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
// Restore the stored NodeID before any host-scoped check so a node inbound
|
||||
// stays scoped to its own node (the payload's nodeId is unreliable, often absent).
|
||||
inbound.NodeID = oldInbound.NodeID
|
||||
if err := normalizeTuicSettings(inbound); err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
if err := s.normalizeAmneziaWGSettings(inbound, oldInbound.Settings); err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
@@ -2136,6 +2169,16 @@ func (s *InboundService) buildInboundForLocalRuntime(tx *gorm.DB, inbound *model
|
||||
return nil, err
|
||||
}
|
||||
|
||||
trafficIDs := make(map[string]int)
|
||||
if inbound.Protocol == model.TUIC {
|
||||
var rows []xray.ClientTraffic
|
||||
if err := tx.Select("id", "email").Where("email IN ?", emails).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
trafficIDs[row.Email] = row.Id
|
||||
}
|
||||
}
|
||||
finalClients := make([]any, 0, len(clients))
|
||||
for _, client := range clients {
|
||||
c, ok := client.(map[string]any)
|
||||
@@ -2149,6 +2192,12 @@ func (s *InboundService) buildInboundForLocalRuntime(tx *gorm.DB, inbound *model
|
||||
if manualEnable, ok := c["enable"].(bool); ok && !manualEnable {
|
||||
continue
|
||||
}
|
||||
if inbound.Protocol == model.TUIC {
|
||||
delete(c, "traffic_id")
|
||||
if id := trafficIDs[email]; id > 0 {
|
||||
c["traffic_id"] = id
|
||||
}
|
||||
}
|
||||
finalClients = append(finalClients, c)
|
||||
}
|
||||
settings["clients"] = finalClients
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
|
||||
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
|
||||
)
|
||||
|
||||
@@ -380,10 +381,18 @@ func (s *InboundService) checkForwardedPortsConflict(ctx portConflictContext, fo
|
||||
}
|
||||
return fmt.Sprintf("inbound '%s' (#%d, port %d)", name, ib.Id, ib.Port)
|
||||
}
|
||||
if ctx.onNode || ib.Protocol != model.AmneziaWG {
|
||||
if ctx.onNode {
|
||||
continue
|
||||
}
|
||||
var socksPort int
|
||||
switch ib.Protocol {
|
||||
case model.AmneziaWG:
|
||||
socksPort = amneziawgnet.SOCKSPortForInbound(ib.Id)
|
||||
case model.TUIC:
|
||||
socksPort = tuic.SOCKSPortForInbound(ib.Id)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
socksPort := amneziawgnet.SOCKSPortForInbound(ib.Id)
|
||||
if amneziawg.ForwardedPortsInclude(forwardedPorts, socksPort) {
|
||||
name := ib.Remark
|
||||
if name == "" {
|
||||
|
||||
@@ -223,3 +223,109 @@ func TestAddClientTraffic_ExpiryWriteOnlyForConvertedClients(t *testing.T) {
|
||||
t.Errorf("normal traffic not applied: up=%d down=%d, want 30/40", normal.Up, normal.Down)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddTrafficClientUpdateFailureRollsBackWholeBatch(t *testing.T) {
|
||||
dbDir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
|
||||
db := database.GetDB()
|
||||
|
||||
for _, email := range []string{"healthy@x", "rejected@x"} {
|
||||
if err := db.Create(&xray.ClientTraffic{Email: email, Enable: true}).Error; err != nil {
|
||||
t.Fatalf("create client traffic %s: %v", email, err)
|
||||
}
|
||||
}
|
||||
if err := db.Exec(`
|
||||
CREATE TRIGGER reject_client_traffic_update
|
||||
BEFORE UPDATE OF up, down ON client_traffics
|
||||
WHEN OLD.email = 'rejected@x'
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'blocked client traffic update');
|
||||
END`).Error; err != nil {
|
||||
t.Fatalf("create update trigger: %v", err)
|
||||
}
|
||||
|
||||
batch := []*xray.ClientTraffic{
|
||||
{Email: "healthy@x", Up: 100, Down: 200},
|
||||
{Email: "rejected@x", Up: 300, Down: 400},
|
||||
}
|
||||
svc := &InboundService{}
|
||||
if _, _, err := svc.AddTraffic(nil, batch); err == nil {
|
||||
t.Fatal("AddTraffic succeeded despite a client UPDATE failure")
|
||||
}
|
||||
assertTraffic := func(email string, up, down int64) {
|
||||
t.Helper()
|
||||
var got xray.ClientTraffic
|
||||
if err := db.Where("email = ?", email).First(&got).Error; err != nil {
|
||||
t.Fatalf("load traffic for %s: %v", email, err)
|
||||
}
|
||||
if got.Up != up || got.Down != down {
|
||||
t.Fatalf("traffic for %s = (%d,%d), want (%d,%d)", email, got.Up, got.Down, up, down)
|
||||
}
|
||||
}
|
||||
assertTraffic("healthy@x", 0, 0)
|
||||
assertTraffic("rejected@x", 0, 0)
|
||||
|
||||
if err := db.Exec("DROP TRIGGER reject_client_traffic_update").Error; err != nil {
|
||||
t.Fatalf("drop update trigger: %v", err)
|
||||
}
|
||||
if _, _, err := svc.AddTraffic(nil, batch); err != nil {
|
||||
t.Fatalf("retry AddTraffic: %v", err)
|
||||
}
|
||||
assertTraffic("healthy@x", 100, 200)
|
||||
assertTraffic("rejected@x", 300, 400)
|
||||
}
|
||||
|
||||
func TestAddClientTrafficResolvesRenamedTuicClientByStableIdentity(t *testing.T) {
|
||||
dbDir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
|
||||
db := database.GetDB()
|
||||
|
||||
const (
|
||||
inboundID = 18001
|
||||
clientUUID = "a0000000-0000-0000-0000-000000000021"
|
||||
oldEmail = "before-rename@x"
|
||||
newEmail = "after-rename@x"
|
||||
)
|
||||
inbound := &model.Inbound{Id: inboundID, Tag: "tuic-rename", Enable: true, Port: 0, Protocol: model.TUIC}
|
||||
if err := db.Create(inbound).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
client := &model.ClientRecord{Email: newEmail, UUID: clientUUID, Enable: true}
|
||||
if err := db.Create(client).Error; err != nil {
|
||||
t.Fatalf("create renamed client: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: inboundID}).Error; err != nil {
|
||||
t.Fatalf("create client-inbound link: %v", err)
|
||||
}
|
||||
for _, email := range []string{oldEmail, newEmail} {
|
||||
if err := db.Create(&xray.ClientTraffic{InboundId: inboundID, Email: email, Enable: true}).Error; err != nil {
|
||||
t.Fatalf("create traffic row %s: %v", email, err)
|
||||
}
|
||||
}
|
||||
|
||||
var currentTraffic xray.ClientTraffic
|
||||
if err := db.Where("email = ?", newEmail).First(¤tTraffic).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stableTrafficID := currentTraffic.Id
|
||||
|
||||
if err := (&InboundService{}).addClientTraffic(db, []*xray.ClientTraffic{{
|
||||
Email: oldEmail, TuicTrafficID: stableTrafficID, TuicUUID: clientUUID, TuicInboundId: inboundID, Up: 123, Down: 456,
|
||||
}}); err != nil {
|
||||
t.Fatalf("add retired snapshot traffic: %v", err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
email string
|
||||
up, down int64
|
||||
}{{oldEmail, 0, 0}, {newEmail, 123, 456}} {
|
||||
var got xray.ClientTraffic
|
||||
if err := db.Where("email = ?", test.email).First(&got).Error; err != nil {
|
||||
t.Fatalf("load traffic for %s: %v", test.email, err)
|
||||
}
|
||||
if got.Up != test.up || got.Down != test.down {
|
||||
t.Errorf("traffic for %s = (%d,%d), want (%d,%d)", test.email, got.Up, got.Down, test.up, test.down)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||
)
|
||||
|
||||
func TestBuildTargetClientFromSourceTuic(t *testing.T) {
|
||||
@@ -64,3 +65,61 @@ func TestAddInboundTuicClientValidation(t *testing.T) {
|
||||
t.Fatalf("expected 'empty client email' error, got %v", errNoEmail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTuicClientMutationsDoNotRequireRestart(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}})
|
||||
runtime.SetManager(mgr)
|
||||
t.Cleanup(func() { runtime.SetManager(nil) })
|
||||
|
||||
inboundSvc := &InboundService{}
|
||||
clientSvc := &ClientService{}
|
||||
|
||||
ib := &model.Inbound{
|
||||
Tag: "tuic-restart-test",
|
||||
Protocol: model.TUIC,
|
||||
Port: 54321,
|
||||
Enable: true,
|
||||
Settings: `{
|
||||
"certificate": "dummy-cert",
|
||||
"private_key": "dummy-key",
|
||||
"clients":[{"id":"a0000000-0000-0000-0000-000000000001","password":"p1","email":"user1@tuic.com","enable":true}]
|
||||
}`,
|
||||
}
|
||||
created, _, err := inboundSvc.AddInbound(ib)
|
||||
if err != nil {
|
||||
t.Fatalf("AddInbound failed: %v", err)
|
||||
}
|
||||
|
||||
addPayload := &model.Inbound{
|
||||
Id: created.Id,
|
||||
Settings: `{"clients":[{"id":"a0000000-0000-0000-0000-000000000002","password":"p2","email":"user2@tuic.com","enable":true}]}`,
|
||||
}
|
||||
needRestart, err := clientSvc.AddInboundClient(inboundSvc, addPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("AddInboundClient failed: %v", err)
|
||||
}
|
||||
if needRestart {
|
||||
t.Fatal("expected needRestart = false when adding TUIC client")
|
||||
}
|
||||
|
||||
updatePayload := &model.Inbound{
|
||||
Id: created.Id,
|
||||
Settings: `{"clients":[{"id":"a0000000-0000-0000-0000-000000000002","password":"new-p2","email":"user2@tuic.com","enable":true}]}`,
|
||||
}
|
||||
needRestart, err = clientSvc.UpdateInboundClient(inboundSvc, updatePayload, "user2@tuic.com")
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateInboundClient failed: %v", err)
|
||||
}
|
||||
if needRestart {
|
||||
t.Fatal("expected needRestart = false when updating TUIC client")
|
||||
}
|
||||
|
||||
needRestart, err = clientSvc.DelInboundClientByEmail(inboundSvc, created.Id, "user2@tuic.com", false, true)
|
||||
if err != nil {
|
||||
t.Fatalf("DelInboundClientByEmail failed: %v", err)
|
||||
}
|
||||
if needRestart {
|
||||
t.Fatal("expected needRestart = false when deleting TUIC client")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,10 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
|
||||
if len(traffics) == 0 {
|
||||
return nil
|
||||
}
|
||||
traffics, err = canonicalizeClientTraffic(tx, traffics)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve client traffic identities: %w", err)
|
||||
}
|
||||
|
||||
emails := make([]string, 0, len(traffics))
|
||||
for _, traffic := range traffics {
|
||||
@@ -150,6 +154,7 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
|
||||
// conflict, but this filter was removed rather than relying on that ordering).
|
||||
err = tx.Model(xray.ClientTraffic{}).
|
||||
Where("email IN (?)", emails).
|
||||
Order("id").
|
||||
Find(&dbClientTraffics).Error
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -192,7 +197,7 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
|
||||
),
|
||||
t.Up, t.Down, now, ct.Email,
|
||||
).Error; err != nil {
|
||||
logger.Warning("AddClientTraffic update data ", err)
|
||||
return fmt.Errorf("update traffic for %s: %w", ct.Email, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,13 +211,51 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
|
||||
`UPDATE client_traffics SET expiry_time = ? WHERE email = ? AND expiry_time < 0`,
|
||||
convertedExpiryByEmail[email], email,
|
||||
).Error; err != nil {
|
||||
logger.Warning("AddClientTraffic update expiry_time ", err)
|
||||
return fmt.Errorf("update expiry time for %s: %w", email, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func canonicalizeClientTraffic(tx *gorm.DB, traffics []*xray.ClientTraffic) ([]*xray.ClientTraffic, error) {
|
||||
byEmail := make(map[string]*xray.ClientTraffic, len(traffics))
|
||||
for _, traffic := range traffics {
|
||||
if traffic == nil {
|
||||
continue
|
||||
}
|
||||
email := traffic.Email
|
||||
if traffic.TuicTrafficID > 0 {
|
||||
var owner xray.ClientTraffic
|
||||
err := tx.Select("email").Where("id = ?", traffic.TuicTrafficID).Take(&owner).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
email = owner.Email
|
||||
}
|
||||
current := byEmail[email]
|
||||
if current == nil {
|
||||
copy := *traffic
|
||||
copy.Email = email
|
||||
copy.TuicUUID = ""
|
||||
copy.TuicInboundId = 0
|
||||
byEmail[email] = ©
|
||||
continue
|
||||
}
|
||||
current.Up += traffic.Up
|
||||
current.Down += traffic.Down
|
||||
current.Enable = current.Enable || traffic.Enable
|
||||
}
|
||||
result := make([]*xray.ClientTraffic, 0, len(byEmail))
|
||||
for _, traffic := range byEmail {
|
||||
result = append(result, traffic)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.ClientTraffic) ([]*xray.ClientTraffic, map[string]int64, error) {
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
|
||||
@@ -24,7 +24,11 @@ func (s *InboundService) DesiredTuicInstances() ([]tuic.Instance, error) {
|
||||
|
||||
instances := make([]tuic.Instance, 0, len(inbounds))
|
||||
for _, ib := range inbounds {
|
||||
inst, ok := tuic.InstanceFromInbound(ib)
|
||||
built, err := s.buildInboundForLocalRuntime(db, ib)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inst, ok := tuic.InstanceFromInbound(built)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
func TestInjectTuicSocks(t *testing.T) {
|
||||
cfg := &xray.Config{}
|
||||
inbounds := []*model.Inbound{
|
||||
{
|
||||
Id: 5,
|
||||
Tag: "tuic-in-5",
|
||||
Protocol: model.TUIC,
|
||||
Enable: true,
|
||||
Settings: `{
|
||||
"certificate": "dummy-cert",
|
||||
"private_key": "dummy-key",
|
||||
"clients": [
|
||||
{"uuid": "a0000000-0000-0000-0000-000000000001", "password": "pass1", "email": "user1@example.com", "enable": true}
|
||||
]
|
||||
}`,
|
||||
},
|
||||
}
|
||||
|
||||
injectTuicSocks(cfg, inbounds)
|
||||
|
||||
if len(cfg.InboundConfigs) != 1 {
|
||||
t.Fatalf("expected 1 injected SOCKS inbound, got %d", len(cfg.InboundConfigs))
|
||||
}
|
||||
|
||||
sc := cfg.InboundConfigs[0]
|
||||
if sc.Tag != "tuic-in-5" {
|
||||
t.Fatalf("expected tag tuic-in-5, got %s", sc.Tag)
|
||||
}
|
||||
if sc.Protocol != "socks" {
|
||||
t.Fatalf("expected protocol socks, got %s", sc.Protocol)
|
||||
}
|
||||
expectedPort := tuic.SOCKSPortForInbound(5)
|
||||
if sc.Port != expectedPort {
|
||||
t.Fatalf("expected port %d, got %d", expectedPort, sc.Port)
|
||||
}
|
||||
if string(sc.Listen) != `"127.0.0.1"` {
|
||||
t.Fatalf("expected listen 127.0.0.1, got %s", sc.Listen)
|
||||
}
|
||||
if string(sc.Sniffing) != tuicEgressSniffingSettings {
|
||||
t.Fatalf("expected sniffing settings %s, got %s", tuicEgressSniffingSettings, sc.Sniffing)
|
||||
}
|
||||
|
||||
var parsedSettings struct {
|
||||
Auth string `json:"auth"`
|
||||
UDP bool `json:"udp"`
|
||||
}
|
||||
if err := json.Unmarshal(sc.Settings, &parsedSettings); err != nil {
|
||||
t.Fatalf("failed to unmarshal settings: %v", err)
|
||||
}
|
||||
if parsedSettings.Auth != "noauth" || !parsedSettings.UDP {
|
||||
t.Fatalf("expected auth=noauth, udp=true, got %+v", parsedSettings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckTuicSocksConflict(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
// Seed TUIC inbound with ID 10
|
||||
tuicIb := &model.Inbound{
|
||||
Id: 10,
|
||||
Tag: "tuic-10",
|
||||
Protocol: model.TUIC,
|
||||
Enable: true,
|
||||
Listen: "0.0.0.0",
|
||||
Port: 8443,
|
||||
Settings: `{"clients":[{"uuid":"a0000000-0000-0000-0000-000000000001","password":"p","email":"u@test.com"}]}`,
|
||||
}
|
||||
if err := database.GetDB().Create(tuicIb).Error; err != nil {
|
||||
t.Fatalf("failed to seed TUIC inbound: %v", err)
|
||||
}
|
||||
|
||||
relayPort := tuic.SOCKSPortForInbound(10)
|
||||
|
||||
// Try to create a new TCP inbound on that relayPort on 127.0.0.1
|
||||
newIb := &model.Inbound{
|
||||
Tag: "colliding-inbound",
|
||||
Protocol: model.Mixed,
|
||||
Enable: true,
|
||||
Listen: "127.0.0.1",
|
||||
Port: relayPort,
|
||||
}
|
||||
|
||||
detail, err := checkTuicSocksConflict(database.GetDB(), newIb, 0, transportTCP)
|
||||
if err != nil {
|
||||
t.Fatalf("checkTuicSocksConflict error: %v", err)
|
||||
}
|
||||
if detail == nil {
|
||||
t.Fatalf("expected conflict on port %d, got none", relayPort)
|
||||
}
|
||||
if detail.Tag != "tuic-10" {
|
||||
t.Fatalf("expected conflict tag tuic-10, got %s", detail.Tag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckTuicSocksReverseConflict(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
targetPort := tuic.SOCKSPortForInbound(20)
|
||||
|
||||
// Seed existing inbound on targetPort on 127.0.0.1
|
||||
existing := &model.Inbound{
|
||||
Id: 99,
|
||||
Tag: "existing-on-relay-port",
|
||||
Protocol: model.Mixed,
|
||||
Enable: true,
|
||||
Listen: "127.0.0.1",
|
||||
Port: targetPort,
|
||||
}
|
||||
if err := database.GetDB().Create(existing).Error; err != nil {
|
||||
t.Fatalf("failed to seed existing inbound: %v", err)
|
||||
}
|
||||
|
||||
detail, err := checkTuicSocksReverseConflict(database.GetDB(), 20)
|
||||
if err != nil {
|
||||
t.Fatalf("checkTuicSocksReverseConflict error: %v", err)
|
||||
}
|
||||
if detail == nil {
|
||||
t.Fatalf("expected reverse conflict for id 20 on port %d, got none", targetPort)
|
||||
}
|
||||
if detail.Tag != "existing-on-relay-port" {
|
||||
t.Fatalf("expected tag existing-on-relay-port, got %s", detail.Tag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesiredTuicInstances(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
ib := &model.Inbound{
|
||||
Id: 30,
|
||||
Tag: "tuic-desired-test",
|
||||
Protocol: model.TUIC,
|
||||
Enable: true,
|
||||
Listen: "0.0.0.0",
|
||||
Port: 9443,
|
||||
Settings: `{
|
||||
"certificate": "cert",
|
||||
"private_key": "key",
|
||||
"clients": [
|
||||
{"uuid": "a0000000-0000-0000-0000-000000000001", "password": "p1", "email": "active@test.com", "enable": true},
|
||||
{"uuid": "a0000000-0000-0000-0000-000000000002", "password": "p2", "email": "disabled@test.com", "enable": true}
|
||||
]
|
||||
}`,
|
||||
}
|
||||
if err := database.GetDB().Create(ib).Error; err != nil {
|
||||
t.Fatalf("failed to seed inbound: %v", err)
|
||||
}
|
||||
|
||||
// Add client traffic entry disabling disabled@test.com
|
||||
ct := &xray.ClientTraffic{
|
||||
InboundId: 30,
|
||||
Email: "disabled@test.com",
|
||||
Enable: false,
|
||||
}
|
||||
if err := database.GetDB().Create(ct).Error; err != nil {
|
||||
t.Fatalf("failed to seed client traffic: %v", err)
|
||||
}
|
||||
|
||||
svc := &InboundService{}
|
||||
instances, err := svc.DesiredTuicInstances()
|
||||
if err != nil {
|
||||
t.Fatalf("DesiredTuicInstances failed: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, inst := range instances {
|
||||
if inst.Id == 30 {
|
||||
found = true
|
||||
if len(inst.Clients) != 1 || inst.Clients[0].Email != "active@test.com" {
|
||||
t.Fatalf("expected only active@test.com, got %+v", inst.Clients)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected to find instance for inbound 30")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckForwardedPortsConflict_CollidesWithTuicSocksPort(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
seedInboundConflict(t, "tuic-1", "0.0.0.0", 8443, model.TUIC, ``, `{"clients":[{"uuid":"u","password":"p","email":"e"}]}`)
|
||||
|
||||
var tuicInbound model.Inbound
|
||||
if err := database.GetDB().Where("tag = ?", "tuic-1").First(&tuicInbound).Error; err != nil {
|
||||
t.Fatalf("read seeded row: %v", err)
|
||||
}
|
||||
relayPort := tuic.SOCKSPortForInbound(tuicInbound.Id)
|
||||
|
||||
svc := &InboundService{}
|
||||
ctx, err := svc.loadPortConflictContext(database.GetDB(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("loadPortConflictContext: %v", err)
|
||||
}
|
||||
hit := svc.checkForwardedPortsConflict(ctx, fmt.Sprintf("%d", relayPort))
|
||||
if !strings.Contains(hit, "SOCKS5") {
|
||||
t.Fatalf("expected a collision naming the TUIC inbound's SOCKS5 relay port, got %q", hit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckTuicSocksConflict_DisabledInboundRetainsReservation(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
seedInboundConflict(t, "tuic-disabled", "0.0.0.0", 8443, model.TUIC, ``, `{"clients":[{"uuid":"u","password":"p","email":"e"}]}`)
|
||||
|
||||
var tuicIb model.Inbound
|
||||
if err := database.GetDB().Where("tag = ?", "tuic-disabled").First(&tuicIb).Error; err != nil {
|
||||
t.Fatalf("read seeded row: %v", err)
|
||||
}
|
||||
if err := database.GetDB().Model(&tuicIb).Update("enable", false).Error; err != nil {
|
||||
t.Fatalf("disable inbound: %v", err)
|
||||
}
|
||||
|
||||
relayPort := tuic.SOCKSPortForInbound(tuicIb.Id)
|
||||
|
||||
testIb := &model.Inbound{
|
||||
Tag: "conflict-test",
|
||||
Protocol: model.VLESS,
|
||||
Listen: "127.0.0.1",
|
||||
Port: relayPort,
|
||||
Enable: true,
|
||||
}
|
||||
|
||||
conflict, err := checkTuicSocksConflict(database.GetDB(), testIb, 0, transportTCP)
|
||||
if err != nil {
|
||||
t.Fatalf("checkTuicSocksConflict: %v", err)
|
||||
}
|
||||
if conflict == nil {
|
||||
t.Fatal("expected conflict on disabled TUIC inbound's SOCKS port, got nil")
|
||||
}
|
||||
if conflict.InboundID != tuicIb.Id {
|
||||
t.Fatalf("expected conflict with inbound %d, got %d", tuicIb.Id, conflict.InboundID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckTuicSocksRelayCollision(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
// Seed first TUIC inbound with ID 1
|
||||
ib1 := &model.Inbound{
|
||||
Id: 1,
|
||||
Tag: "tuic-1",
|
||||
Protocol: model.TUIC,
|
||||
Enable: true,
|
||||
Listen: "0.0.0.0",
|
||||
Port: 8443,
|
||||
Settings: `{"clients":[{"uuid":"a0000000-0000-0000-0000-000000000001","password":"p","email":"u1@test.com"}]}`,
|
||||
}
|
||||
if err := database.GetDB().Create(ib1).Error; err != nil {
|
||||
t.Fatalf("seed ib1: %v", err)
|
||||
}
|
||||
|
||||
// ID 1001 wraps to the same relay port (64001) as ID 1
|
||||
conflict, err := checkTuicSocksRelayCollision(database.GetDB(), 1001)
|
||||
if err != nil {
|
||||
t.Fatalf("checkTuicSocksRelayCollision: %v", err)
|
||||
}
|
||||
if conflict == nil {
|
||||
t.Fatal("expected collision between ID 1001 and ID 1, got nil")
|
||||
}
|
||||
if conflict.InboundID != 1 {
|
||||
t.Fatalf("expected collision with inbound 1, got %d", conflict.InboundID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTuicSocksSelfConflict(t *testing.T) {
|
||||
ib := &model.Inbound{
|
||||
Protocol: model.TUIC,
|
||||
Listen: "127.0.0.1",
|
||||
Port: tuic.SOCKSPortForInbound(5),
|
||||
}
|
||||
errStr := tuicSocksSelfConflict(ib, 5)
|
||||
if errStr == "" {
|
||||
t.Fatal("expected self conflict error string, got empty")
|
||||
}
|
||||
if !strings.Contains(errStr, "own SOCKS5 relay port") {
|
||||
t.Fatalf("unexpected error string: %s", errStr)
|
||||
}
|
||||
|
||||
// Different port should not conflict
|
||||
ib.Port = 9999
|
||||
if diff := tuicSocksSelfConflict(ib, 5); diff != "" {
|
||||
t.Fatalf("expected no conflict for different port, got %s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundTuicServerParsesLegacyFlatSettings(t *testing.T) {
|
||||
server := inboundTuicServer(string(model.TUIC), `{"certificate":"/cert.pem","private_key":"/secret-key.pem","congestion_control":" CuBiC ","udp_relay_mode":"quic","sni":"profile.example"}`)
|
||||
if server == nil {
|
||||
t.Fatal("expected legacy flat TUIC settings")
|
||||
}
|
||||
if server.CongestionControl != "cubic" || server.UDPRelayMode != "quic" || server.SNI != "profile.example" {
|
||||
t.Fatalf("legacy flat fields were not normalized: %+v", server)
|
||||
}
|
||||
if server.PrivateKey != "" {
|
||||
t.Fatal("client preview exposed the inbound private key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTuicSettingsCanonicalizesCongestionAndPacketLimit(t *testing.T) {
|
||||
ib := &model.Inbound{Protocol: model.TUIC, Settings: `{"congestion_control":"RENO","max_udp_relay_packet_size":65507,"server":{"congestion_control":" CuBiC ","max_udp_relay_packet_size":65500}}`}
|
||||
if err := normalizeTuicSettings(ib); err != nil {
|
||||
t.Fatalf("normalizeTuicSettings: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
CongestionControl string `json:"congestion_control"`
|
||||
MaxPacketSize int `json:"max_udp_relay_packet_size"`
|
||||
Server struct {
|
||||
CongestionControl string `json:"congestion_control"`
|
||||
MaxPacketSize int `json:"max_udp_relay_packet_size"`
|
||||
} `json:"server"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(ib.Settings), &got); err != nil {
|
||||
t.Fatalf("unmarshal normalized settings: %v", err)
|
||||
}
|
||||
if got.CongestionControl != "new_reno" || got.Server.CongestionControl != "cubic" {
|
||||
t.Fatalf("congestion controllers were not canonicalized: %+v", got)
|
||||
}
|
||||
if got.MaxPacketSize != 65245 || got.Server.MaxPacketSize != 65245 {
|
||||
t.Fatalf("packet limits were not clamped: %+v", got)
|
||||
}
|
||||
|
||||
ib.Settings = `{"server":{"congestion_control":"experimental"}}`
|
||||
if err := normalizeTuicSettings(ib); err == nil {
|
||||
t.Fatal("unsupported congestion controller was accepted")
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -284,6 +285,13 @@ func checkPortConflictTx(db *gorm.DB, inbound *model.Inbound, ignoreId int) (*po
|
||||
if conflict != nil {
|
||||
return conflict, nil
|
||||
}
|
||||
conflict, err = checkTuicSocksConflict(db, inbound, ignoreId, newBits)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if conflict != nil {
|
||||
return conflict, nil
|
||||
}
|
||||
}
|
||||
|
||||
// The reverse direction, only meaningful once the id is known -- AddInbound
|
||||
@@ -318,6 +326,25 @@ func checkPortConflictTx(db *gorm.DB, inbound *model.Inbound, ignoreId int) (*po
|
||||
forwardedBy.Transports = newBits
|
||||
return forwardedBy, nil
|
||||
}
|
||||
if inbound.NodeID == nil && inbound.Protocol == model.TUIC && ignoreId > 0 {
|
||||
if self := tuicSocksSelfConflict(inbound, ignoreId); self != "" {
|
||||
return nil, common.NewError(self)
|
||||
}
|
||||
conflict, err := checkTuicSocksRelayCollision(db, ignoreId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if conflict != nil {
|
||||
return conflict, nil
|
||||
}
|
||||
conflict, err = checkTuicSocksReverseConflict(db, ignoreId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if conflict != nil {
|
||||
return conflict, nil
|
||||
}
|
||||
}
|
||||
|
||||
var candidates []*model.Inbound
|
||||
q := db.Model(model.Inbound{}).Where("port = ?", inbound.Port)
|
||||
@@ -488,6 +515,96 @@ func checkAmneziawgnetSocksReverseConflict(db *gorm.DB, id int) (*portConflictDe
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func checkTuicSocksConflict(db *gorm.DB, inbound *model.Inbound, ignoreId int, newBits transportBits) (*portConflictDetail, error) {
|
||||
var candidates []*model.Inbound
|
||||
q := db.Model(model.Inbound{}).Where("protocol = ? AND node_id IS NULL", model.TUIC)
|
||||
if ignoreId > 0 {
|
||||
q = q.Where("id != ?", ignoreId)
|
||||
}
|
||||
if err := q.Find(&candidates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if _, ok := tuic.InstanceFromInbound(c); !ok {
|
||||
continue
|
||||
}
|
||||
if tuic.SOCKSPortForInbound(c.Id) != inbound.Port {
|
||||
continue
|
||||
}
|
||||
return &portConflictDetail{
|
||||
InboundID: c.Id,
|
||||
Remark: c.Remark,
|
||||
Tag: c.Tag,
|
||||
Listen: "127.0.0.1",
|
||||
Port: inbound.Port,
|
||||
Transports: newBits,
|
||||
}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func checkTuicSocksRelayCollision(db *gorm.DB, id int) (*portConflictDetail, error) {
|
||||
relayPort := tuic.SOCKSPortForInbound(id)
|
||||
var candidates []*model.Inbound
|
||||
if err := db.Model(model.Inbound{}).
|
||||
Where("protocol = ? AND node_id IS NULL AND id != ?", model.TUIC, id).
|
||||
Find(&candidates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if tuic.SOCKSPortForInbound(c.Id) != relayPort {
|
||||
continue
|
||||
}
|
||||
return &portConflictDetail{
|
||||
InboundID: c.Id,
|
||||
Remark: c.Remark,
|
||||
Tag: c.Tag,
|
||||
Listen: "127.0.0.1",
|
||||
Port: relayPort,
|
||||
Relay: true,
|
||||
Transports: transportTCP,
|
||||
}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func tuicSocksSelfConflict(inbound *model.Inbound, id int) string {
|
||||
if id <= 0 || inbound.NodeID != nil || !listenOverlaps(loopbackBind, inboundBindAddr(inbound)) {
|
||||
return ""
|
||||
}
|
||||
relayPort := tuic.SOCKSPortForInbound(id)
|
||||
if inbound.Port != relayPort {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("TUIC port %d is inbound #%d's own SOCKS5 relay port on 127.0.0.1; choose a different TUIC port",
|
||||
relayPort, id)
|
||||
}
|
||||
|
||||
func checkTuicSocksReverseConflict(db *gorm.DB, id int) (*portConflictDetail, error) {
|
||||
relayPort := tuic.SOCKSPortForInbound(id)
|
||||
var candidates []*model.Inbound
|
||||
if err := db.Model(model.Inbound{}).
|
||||
Where("port = ? AND node_id IS NULL AND id != ?", relayPort, id).
|
||||
Find(&candidates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if !listenOverlaps(loopbackBind, inboundBindAddr(c)) {
|
||||
continue
|
||||
}
|
||||
return &portConflictDetail{
|
||||
InboundID: c.Id,
|
||||
Remark: c.Remark,
|
||||
Tag: c.Tag,
|
||||
Listen: c.Listen,
|
||||
Port: relayPort,
|
||||
Relay: true,
|
||||
Transports: transportTCP,
|
||||
}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func sameNode(a, b *int) bool {
|
||||
if a == nil && b == nil {
|
||||
return true
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
func audit3CreateTuicClient(t *testing.T, svc *ClientService, inboundSvc *InboundService, ib *model.Inbound, email, id string) model.ClientRecord {
|
||||
t.Helper()
|
||||
if _, err := svc.Create(inboundSvc, &ClientCreatePayload{Client: model.Client{Email: email, ID: id, Password: "pw", Enable: true}, InboundIds: []int{ib.Id}}); err != nil {
|
||||
t.Fatalf("public Create %s: %v", email, err)
|
||||
}
|
||||
return lookupClientRecord(t, email)
|
||||
}
|
||||
|
||||
func audit3Traffic(t *testing.T, email string) xray.ClientTraffic {
|
||||
t.Helper()
|
||||
var out xray.ClientTraffic
|
||||
if err := database.GetDB().Where("email = ?", email).First(&out).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestAudit3DeletedIdentityMustNotChargeDuplicateUUID(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc, inboundSvc := &ClientService{}, &InboundService{}
|
||||
ibB := mkInbound(t, 28001, model.TUIC, `{"clients":[]}`)
|
||||
ibA := mkInbound(t, 28002, model.TUIC, `{"clients":[]}`)
|
||||
const id = "a0000000-0000-0000-0000-000000000031"
|
||||
audit3CreateTuicClient(t, svc, inboundSvc, ibB, "b@audit3", id)
|
||||
a := audit3CreateTuicClient(t, svc, inboundSvc, ibA, "a@audit3", id)
|
||||
trafficID := audit3RuntimeTrafficID(t, inboundSvc, ibA, "a@audit3")
|
||||
if _, err := svc.Delete(inboundSvc, a.Id, true); err != nil {
|
||||
t.Fatalf("public Delete keepTraffic: %v", err)
|
||||
}
|
||||
if _, _, err := inboundSvc.AddTraffic(nil, []*xray.ClientTraffic{{Email: "a@audit3", TuicTrafficID: trafficID, TuicUUID: id, TuicInboundId: ibA.Id, Up: 123, Down: 456}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old, other := audit3Traffic(t, "a@audit3"), audit3Traffic(t, "b@audit3")
|
||||
if old.Up != 123 || old.Down != 456 || other.Up != 0 || other.Down != 0 {
|
||||
t.Fatalf("retired A=%d/%d; unrelated B=%d/%d, want A=123/456 and B=0/0", old.Up, old.Down, other.Up, other.Down)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudit3RenameAndRotateCredentialsMustPreserveRetiredUsage(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc, inboundSvc := &ClientService{}, &InboundService{}
|
||||
ib := mkInbound(t, 28003, model.TUIC, `{"clients":[]}`)
|
||||
const oldID = "a0000000-0000-0000-0000-000000000032"
|
||||
a := audit3CreateTuicClient(t, svc, inboundSvc, ib, "old@audit3", oldID)
|
||||
trafficID := audit3RuntimeTrafficID(t, inboundSvc, ib, "old@audit3")
|
||||
if _, err := svc.Update(inboundSvc, a.Id, model.Client{Email: "new@audit3", ID: "a0000000-0000-0000-0000-000000000033", Password: "newpw", Enable: true}, 0); err != nil {
|
||||
t.Fatalf("public rename + rotate: %v", err)
|
||||
}
|
||||
if _, _, err := inboundSvc.AddTraffic(nil, []*xray.ClientTraffic{{Email: "old@audit3", TuicTrafficID: trafficID, TuicUUID: oldID, TuicInboundId: ib.Id, Up: 123, Down: 456}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := audit3Traffic(t, "new@audit3")
|
||||
if got.Up != 123 || got.Down != 456 {
|
||||
t.Fatalf("new account usage=%d/%d, want retired 123/456", got.Up, got.Down)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudit3PublicRenamePreservesRetiredUsage(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc, inboundSvc := &ClientService{}, &InboundService{}
|
||||
ib := mkInbound(t, 28004, model.TUIC, `{"clients":[]}`)
|
||||
const id = "a0000000-0000-0000-0000-000000000034"
|
||||
a := audit3CreateTuicClient(t, svc, inboundSvc, ib, "old@audit3", id)
|
||||
trafficID := audit3RuntimeTrafficID(t, inboundSvc, ib, "old@audit3")
|
||||
if _, err := svc.Update(inboundSvc, a.Id, model.Client{Email: "new@audit3", ID: id, Password: "pw", Enable: true}, 0); err != nil {
|
||||
t.Fatalf("public rename: %v", err)
|
||||
}
|
||||
if _, _, err := inboundSvc.AddTraffic(nil, []*xray.ClientTraffic{{Email: "old@audit3", TuicTrafficID: trafficID, TuicUUID: id, TuicInboundId: ib.Id, Up: 123, Down: 456}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := audit3Traffic(t, "new@audit3")
|
||||
if got.Up != 123 || got.Down != 456 {
|
||||
t.Fatalf("new account usage=%d/%d, want retired 123/456", got.Up, got.Down)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudit3SameInboundDuplicateUUIDMustBeRejectedOrBillMatchedPrincipal(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc, inboundSvc := &ClientService{}, &InboundService{}
|
||||
ib := mkInbound(t, 28005, model.TUIC, `{"clients":[]}`)
|
||||
const id = "a0000000-0000-0000-0000-000000000035"
|
||||
audit3CreateTuicClient(t, svc, inboundSvc, ib, "a@audit3", id)
|
||||
if _, err := svc.Create(inboundSvc, &ClientCreatePayload{Client: model.Client{Email: "b@audit3", ID: id, Password: "different-pw", Enable: true}, InboundIds: []int{ib.Id}}); err != nil {
|
||||
t.Logf("duplicate correctly rejected: %v", err)
|
||||
return
|
||||
}
|
||||
t.Fatal("public Create accepted duplicate UUID")
|
||||
}
|
||||
|
||||
func audit3RuntimeTrafficID(t *testing.T, service *InboundService, inbound *model.Inbound, email string) int {
|
||||
t.Helper()
|
||||
fresh, err := service.GetInbound(inbound.Id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
built, err := service.buildInboundForLocalRuntime(database.GetDB(), fresh)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
instance, ok := tuic.InstanceFromInbound(built)
|
||||
if !ok {
|
||||
t.Fatal("invalid runtime settings")
|
||||
}
|
||||
for _, client := range instance.Clients {
|
||||
if client.Email == email {
|
||||
if client.TrafficID == 0 || client.TrafficID != audit3Traffic(t, email).Id {
|
||||
t.Fatal("runtime snapshot lacks immutable accounting identity")
|
||||
}
|
||||
return client.TrafficID
|
||||
}
|
||||
}
|
||||
t.Fatal("runtime client missing")
|
||||
return 0
|
||||
}
|
||||
|
||||
func TestTuicDeletedTrafficRowCannotBillReusedEmail(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
db := database.GetDB()
|
||||
old := xray.ClientTraffic{Email: "reused@audit3", Enable: true}
|
||||
if err := db.Create(&old).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Delete(&old).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
replacement := xray.ClientTraffic{Email: old.Email, Enable: true}
|
||||
if err := db.Create(&replacement).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if old.Id == replacement.Id {
|
||||
t.Fatal("accounting primary key reused")
|
||||
}
|
||||
if _, _, err := (&InboundService{}).AddTraffic(nil, []*xray.ClientTraffic{{Email: old.Email, TuicTrafficID: old.Id, Up: 123, Down: 456}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := audit3Traffic(t, old.Email)
|
||||
if got.Up != 0 || got.Down != 0 {
|
||||
t.Fatalf("retired client billed replacement: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
|
||||
)
|
||||
|
||||
func normalizeTuicSettings(inbound *model.Inbound) error {
|
||||
if inbound == nil || inbound.Protocol != model.TUIC || inbound.Settings == "" {
|
||||
return nil
|
||||
}
|
||||
var root map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(inbound.Settings), &root); err != nil {
|
||||
return fmt.Errorf("invalid TUIC settings: %w", err)
|
||||
}
|
||||
if err := normalizeTuicSettingsBlock(root); err != nil {
|
||||
return err
|
||||
}
|
||||
if rawServer, ok := root["server"]; ok && len(rawServer) > 0 {
|
||||
var server map[string]json.RawMessage
|
||||
if err := json.Unmarshal(rawServer, &server); err != nil {
|
||||
return fmt.Errorf("invalid TUIC server settings: %w", err)
|
||||
}
|
||||
if err := normalizeTuicSettingsBlock(server); err != nil {
|
||||
return err
|
||||
}
|
||||
encoded, err := json.Marshal(server)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode TUIC server settings: %w", err)
|
||||
}
|
||||
root["server"] = encoded
|
||||
}
|
||||
encoded, err := json.Marshal(root)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode TUIC settings: %w", err)
|
||||
}
|
||||
inbound.Settings = string(encoded)
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeTuicSettingsBlock(settings map[string]json.RawMessage) error {
|
||||
if raw, ok := settings["congestion_control"]; ok {
|
||||
var value string
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return fmt.Errorf("TUIC congestion_control must be a string: %w", err)
|
||||
}
|
||||
normalized, err := tuic.NormalizeCongestionControl(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encoded, _ := json.Marshal(normalized)
|
||||
settings["congestion_control"] = encoded
|
||||
}
|
||||
if raw, ok := settings["log_level"]; ok {
|
||||
var value string
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return fmt.Errorf("TUIC log_level must be a string: %w", err)
|
||||
}
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "warning" {
|
||||
value = "warn"
|
||||
}
|
||||
if value == "" {
|
||||
value = "info"
|
||||
}
|
||||
switch value {
|
||||
case "debug", "info", "warn", "error":
|
||||
default:
|
||||
return fmt.Errorf("TUIC log_level %q is unsupported", value)
|
||||
}
|
||||
encoded, _ := json.Marshal(value)
|
||||
settings["log_level"] = encoded
|
||||
}
|
||||
if raw, ok := settings["udp_relay_mode"]; ok {
|
||||
var value string
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return fmt.Errorf("TUIC udp_relay_mode must be a string: %w", err)
|
||||
}
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
value = "native"
|
||||
}
|
||||
if value != "native" && value != "quic" {
|
||||
return fmt.Errorf("TUIC udp_relay_mode %q is unsupported", value)
|
||||
}
|
||||
encoded, _ := json.Marshal(value)
|
||||
settings["udp_relay_mode"] = encoded
|
||||
}
|
||||
if raw, ok := settings["max_udp_relay_packet_size"]; ok {
|
||||
var value int
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return fmt.Errorf("TUIC max_udp_relay_packet_size must be an integer: %w", err)
|
||||
}
|
||||
if value > 65507 {
|
||||
return fmt.Errorf("TUIC max_udp_relay_packet_size must not exceed %d", 65245)
|
||||
}
|
||||
if value > 65245 {
|
||||
value = 65245
|
||||
}
|
||||
encoded, _ := json.Marshal(value)
|
||||
settings["max_udp_relay_packet_size"] = encoded
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
// AddTuicTrafficBatch commits a journal batch and its receipt atomically. A
|
||||
// retained journal can therefore be replayed after an ambiguous commit result.
|
||||
func (s *InboundService) AddTuicTrafficBatch(id string, traffic []*xray.ClientTraffic) error {
|
||||
if id == "" {
|
||||
return errors.New("TUIC traffic batch has no ID")
|
||||
}
|
||||
return submitTrafficWrite(func() error {
|
||||
return database.GetDB().Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&model.TuicTrafficReceipt{ID: id})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.addClientTraffic(tx, traffic)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/maskcompat"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
@@ -426,6 +427,7 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
||||
// whatever rules the admin adds through the stock Routing page, exactly
|
||||
// like routing any other protocol.
|
||||
injectAmneziawgnetSocks(xrayConfig, inbounds)
|
||||
injectTuicSocks(xrayConfig, inbounds)
|
||||
|
||||
// Restores each opted-in peer's own distinct public IPv6 source identity
|
||||
// for its outbound connections — a peer that has an IPv6 address in its
|
||||
@@ -793,6 +795,38 @@ func injectAmneziawgnetSocks(cfg *xray.Config, inbounds []*model.Inbound) {
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
tuicEgressSocksSettings = `{"auth":"noauth","udp":true}`
|
||||
tuicEgressSniffingSettings = `{"enabled":true,"destOverride":["http","tls","quic","fakedns"]}`
|
||||
)
|
||||
|
||||
func injectTuicSocks(cfg *xray.Config, inbounds []*model.Inbound) {
|
||||
existingTags := make(map[string]struct{}, len(cfg.InboundConfigs))
|
||||
for i := range cfg.InboundConfigs {
|
||||
existingTags[cfg.InboundConfigs[i].Tag] = struct{}{}
|
||||
}
|
||||
|
||||
for _, inbound := range inbounds {
|
||||
if inbound.Protocol != model.TUIC || !inbound.Enable || inbound.NodeID != nil {
|
||||
continue
|
||||
}
|
||||
if _, taken := existingTags[inbound.Tag]; taken {
|
||||
logger.Warning("tuic socks: inbound tag [", inbound.Tag, "] already present in generated config, skipping its relay inbound")
|
||||
continue
|
||||
}
|
||||
|
||||
existingTags[inbound.Tag] = struct{}{}
|
||||
cfg.InboundConfigs = append(cfg.InboundConfigs, xray.InboundConfig{
|
||||
Listen: json_util.RawMessage(`"127.0.0.1"`),
|
||||
Port: tuic.SOCKSPortForInbound(inbound.Id),
|
||||
Protocol: "socks",
|
||||
Settings: json_util.RawMessage(tuicEgressSocksSettings),
|
||||
Sniffing: json_util.RawMessage(tuicEgressSniffingSettings),
|
||||
Tag: inbound.Tag,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// amneziawgV6EgressTag returns the stable, globally-unique freedom outbound
|
||||
// tag for one peer's IPv6 source-identity egress. Stable across config
|
||||
// regenerations (a pure function of two stable identifiers), so
|
||||
|
||||
Reference in New Issue
Block a user