Files
3x-ui/internal/web/service/inbound_amneziawg.go
T
Egor 0054e671f8 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>
2026-10-03 02:56:02 +02:00

430 lines
15 KiB
Go

package service
import (
"context"
"encoding/json"
"fmt"
"strings"
"gorm.io/gorm"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"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/logger"
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
// DesiredAmneziaWGInstances derives the AmneziaWG interfaces this panel
// should be running: one instance per enabled local AmneziaWG inbound,
// serving only the peers of clients that are both enabled in the inbound
// settings and not depletion-disabled in client_traffics. That is the same
// effective peer set buildInboundForLocalRuntime pushes on interactive edits,
// so the reconcile job and the push path agree on one fingerprint — see
// DesiredMtprotoInstances, which this mirrors exactly.
func (s *InboundService) DesiredAmneziaWGInstances() ([]amneziawg.Instance, error) {
db := database.GetDB()
var inbounds []*model.Inbound
err := db.Model(model.Inbound{}).
Where("protocol = ? AND enable = ? AND node_id IS NULL", model.AmneziaWG, true).
Find(&inbounds).Error
if err != nil {
return nil, err
}
if len(inbounds) == 0 {
return nil, nil
}
instances := make([]amneziawg.Instance, 0, len(inbounds))
for _, ib := range inbounds {
inst, ok := amneziawg.InstanceFromInbound(ib)
if !ok {
continue
}
instances = append(instances, inst)
}
emails := make([]string, 0)
for _, inst := range instances {
for _, e := range inst.Peers {
emails = append(emails, e.Email)
}
}
disabled, err := trafficDisabledEmails(db, emails)
if err != nil {
return nil, err
}
served := instances[:0]
for _, inst := range instances {
kept := make([]amneziawg.Peer, 0, len(inst.Peers))
for _, e := range inst.Peers {
if _, off := disabled[e.Email]; !off {
kept = append(kept, e)
}
}
inst.Peers = kept
if len(kept) > 0 {
served = append(served, inst)
}
}
return served, nil
}
// applyLocalAmneziaWG pushes a single local AmneziaWG inbound's current peer
// set to its interface right after a client edit commits, so an add,
// removal, re-key or enable-toggle takes effect immediately instead of
// waiting up to 10s for the reconcile job. It re-reads the inbound so it sees
// the committed settings, filters depleted clients exactly like the
// reconcile job, and is a no-op for node-owned or non-AmneziaWG inbounds.
// Failures are logged and swallowed: the reconcile job is the backstop.
// Mirrors applyLocalMtproto.
func (s *InboundService) applyLocalAmneziaWG(inboundId int) {
inbound, err := s.GetInbound(inboundId)
if err != nil || inbound == nil || inbound.Protocol != model.AmneziaWG || inbound.NodeID != nil {
return
}
rt, err := s.runtimeFor(inbound)
if err != nil {
return
}
payload := inbound
if inbound.Enable {
if built, bErr := s.buildInboundForLocalRuntime(database.GetDB(), inbound); bErr == nil {
payload = built
}
}
if err := rt.UpdateInbound(context.Background(), inbound, payload); err != nil {
logger.Debugf("amneziawg: immediate apply failed for inbound %d: %v", inboundId, err)
}
}
// defaultAmneziaWGServer builds a fresh server block: a random AmneziaWG 3.1
// obfuscation set, the default tunnel subnet/DNS, and a freshly generated
// keypair.
func defaultAmneziaWGServer() (*amneziawg.ServerSettings, error) {
obf := amneziawg.GenerateObfuscation31()
server := &amneziawg.ServerSettings{
SubnetIP: "10.8.1.0",
SubnetCIDR: 24,
PrimaryDNS: "8.8.8.8",
SecondaryDNS: "8.8.4.4",
Jc: obf.Jc,
Jmin: obf.Jmin,
Jmax: obf.Jmax,
S1: obf.S1,
S2: obf.S2,
S3: obf.S3,
S4: obf.S4,
H1: obf.H1,
H2: obf.H2,
H3: obf.H3,
H4: obf.H4,
I1: obf.I1,
HeaderProtectionKey: obf.HeaderProtectionKey,
ContentPaddingAddition: obf.ContentPaddingAddition,
RekeyAfterTime: obf.RekeyAfterTime,
RekeyTimeout: obf.RekeyTimeout,
RejectAfterTime: obf.RejectAfterTime,
KeepaliveTimeout: obf.KeepaliveTimeout,
MaxHandshakeAttempts: obf.MaxHandshakeAttempts,
RandomTrailers: obf.RandomTrailers,
DisableCookies: obf.DisableCookies,
}
if err := fillAmneziaWGServerKeys(server); err != nil {
return nil, err
}
return server, nil
}
// fillAmneziaWGServerKeys generates a real WireGuard-compatible keypair for
// the server block when one is missing.
func fillAmneziaWGServerKeys(server *amneziawg.ServerSettings) error {
priv, pub, err := wgutil.GenerateWireguardKeypair()
if err != nil {
return fmt.Errorf("amneziawg: generate server keypair: %w", err)
}
server.PrivateKey = priv
server.PublicKey = pub
return nil
}
// resolveAmneziaWGServerKeys settles the server keypair for a save. An omitted
// key means "unchanged", never "mint a new one": rotating it silently
// invalidates every client config already handed out.
func resolveAmneziaWGServerKeys(server *amneziawg.ServerSettings, oldSettings string) error {
if server.PrivateKey == "" {
storedPriv, storedPub := storedAmneziaWGServerKeys(oldSettings)
if storedPriv == "" {
return fillAmneziaWGServerKeys(server)
}
server.PrivateKey, server.PublicKey = storedPriv, storedPub
}
if server.PublicKey == "" {
pub, err := wgutil.PublicKeyFromPrivate(server.PrivateKey)
if err != nil {
return fmt.Errorf("amneziawg: derive server public key: %w", err)
}
server.PublicKey = pub
}
return nil
}
// storedAmneziaWGServerKeys returns the keypair already saved for this inbound.
// oldSettings is empty on a first save, and need not be valid AmneziaWG JSON.
func storedAmneziaWGServerKeys(oldSettings string) (priv, pub string) {
if strings.TrimSpace(oldSettings) == "" {
return "", ""
}
var prev amneziawg.InboundSettings
if err := json.Unmarshal([]byte(oldSettings), &prev); err != nil || prev.Server == nil {
return "", ""
}
return prev.Server.PrivateKey, prev.Server.PublicKey
}
// normalizeAmneziaWGSettings ensures an AmneziaWG inbound's settings have a
// valid server block, generating one (fresh obfuscation params + keypair) on
// first save and validating a manually-edited one so a bad entry can't bring
// the interface down on the next apply. A no-op for every other protocol.
func (s *InboundService) normalizeAmneziaWGSettings(inbound *model.Inbound, oldSettings string) error {
if inbound.Protocol != model.AmneziaWG {
return nil
}
trimmed := strings.TrimSpace(inbound.Settings)
if trimmed == "" || trimmed == "null" || trimmed == "{}" {
server, err := defaultAmneziaWGServer()
if err != nil {
return err
}
settings := amneziawg.InboundSettings{Server: server, Clients: []model.Client{}}
bs, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return err
}
inbound.Settings = string(bs)
return nil
}
var parsed amneziawg.InboundSettings
if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil {
return fmt.Errorf("amneziawg: invalid settings: %w", err)
}
if parsed.Server == nil {
server, err := defaultAmneziaWGServer()
if err != nil {
return err
}
parsed.Server = server
} else if err := resolveAmneziaWGServerKeys(parsed.Server, oldSettings); err != nil {
return err
}
parsed.Server.HeaderProtectionKey = strings.TrimSpace(parsed.Server.HeaderProtectionKey)
for _, f := range []*string{
&parsed.Server.ContentPaddingAddition, &parsed.Server.RekeyAfterTime,
&parsed.Server.RekeyTimeout, &parsed.Server.RejectAfterTime,
&parsed.Server.KeepaliveTimeout, &parsed.Server.MaxHandshakeAttempts,
} {
*f = amneziawg.CanonicalizeUintRange(*f)
}
if err := amneziawg.ValidateServerObfuscation(parsed.Server.Obfuscation()); err != nil {
return fmt.Errorf("amneziawg: %w", err)
}
if err := amneziawg.ValidateIPv6Subnet(parsed.Server.IPv6Enabled, parsed.Server.IPv6Subnet); err != nil {
return fmt.Errorf("amneziawg: %w", err)
}
if err := amneziawg.ValidateSubnetIPv4(parsed.Server.SubnetIP, parsed.Server.SubnetCIDR); err != nil {
return fmt.Errorf("amneziawg: %w", err)
}
if err := amneziawg.ValidateInterfaceName(parsed.Server.ExternalInterface); err != nil {
return fmt.Errorf("amneziawg: externalInterface: %w", err)
}
if err := amneziawg.ValidateInterfaceName(parsed.Server.IPv6ExternalInterface); err != nil {
return fmt.Errorf("amneziawg: ipv6ExternalInterface: %w", err)
}
if err := amneziawg.ValidateConfigValue("privateKey", parsed.Server.PrivateKey); err != nil {
return fmt.Errorf("amneziawg: %w", err)
}
if err := amneziawg.ValidateConfigValue("publicKey", parsed.Server.PublicKey); err != nil {
return fmt.Errorf("amneziawg: %w", err)
}
signaturePackets := []struct{ field, v string }{
{"i1", parsed.Server.I1},
{"i2", parsed.Server.I2},
{"i3", parsed.Server.I3},
{"i4", parsed.Server.I4},
{"i5", parsed.Server.I5},
}
for _, sp := range signaturePackets {
if err := amneziawg.ValidateConfigValue(sp.field, sp.v); err != nil {
return fmt.Errorf("amneziawg: %w", err)
}
}
portCtx, err := s.loadPortConflictContext(database.GetDB(), inbound.NodeID)
if err != nil {
return err
}
for i := range parsed.Clients {
c := &parsed.Clients[i]
if err := s.amneziaWGForwardedPortsConflict(portCtx, c); err != nil {
return err
}
if err := amneziawg.ValidateConfigValue("email", c.Email); err != nil {
return fmt.Errorf("amneziawg: %w", err)
}
if err := amneziawg.ValidateConfigValue("publicKey", c.PublicKey); err != nil {
return fmt.Errorf("amneziawg: client %q: %w", c.Email, err)
}
if err := amneziawg.ValidateConfigValue("preSharedKey", c.PreSharedKey); err != nil {
return fmt.Errorf("amneziawg: client %q: %w", c.Email, err)
}
// AllowedIPs lands verbatim in a rendered [Peer] block, so a newline here
// re-opens an [Interface] section whose PostUp runs as root once the
// downloaded config is applied (client app, or awg-quick directly).
normalized, err := normalizeWireguardAllowedIPs(c.AllowedIPs)
if err != nil {
return fmt.Errorf("amneziawg: client %q: %w", c.Email, err)
}
// An enabled peer with no address is skipped by InstanceFromInbound, and
// if it was the only one the whole inbound never starts, silently.
if c.Enable && len(normalized) == 0 {
return fmt.Errorf("amneziawg: client %q: allowedIPs is required", c.Email)
}
c.AllowedIPs = normalized
}
bs, err := json.MarshalIndent(parsed, "", " ")
if err != nil {
return err
}
inbound.Settings = string(bs)
return nil
}
// portConflictContext caches what checkForwardedPortsConflict needs about the
// host a forward listener binds on, so one save costs one query, not N.
type portConflictContext struct {
webPort int
inbounds []*model.Inbound
// onNode: the host is a node, whose web port and relay ports (derived from
// its own inbound ids) this panel does not know; the node re-checks both.
onNode bool
}
// loadPortConflictContext loads every enabled inbound hosted where nodeID's rows
// run -- this panel for nil, else that node -- plus this panel's own port.
func (s *InboundService) loadPortConflictContext(db *gorm.DB, nodeID *int) (portConflictContext, error) {
var ctx portConflictContext
q := db.Model(model.Inbound{}).Where("enable = ?", true)
if nodeID != nil {
ctx.onNode = true
q = q.Where("node_id = ?", *nodeID)
} else {
if webPort, err := (&SettingService{}).GetPort(); err == nil {
ctx.webPort = webPort
}
q = q.Where("node_id IS NULL")
}
err := q.Find(&ctx.inbounds).Error
return ctx, err
}
// amneziaWGForwardedPortsConflict renders one client's ForwardedPorts collision,
// or nil: the single copy both the pre-Save pass and the post-Save re-run use.
func (s *InboundService) amneziaWGForwardedPortsConflict(ctx portConflictContext, c *model.Client) error {
hit := s.checkForwardedPortsConflict(ctx, c.ForwardedPorts)
if hit == "" {
return nil
}
return fmt.Errorf("amneziawg: client %q forwardedPorts collides with %s", c.Email, hit)
}
// checkAmneziaWGForwardedPorts re-runs the guard over one row's stored clients:
// on create it ran before Save, when the row's own ports were not in the context.
func (s *InboundService) checkAmneziaWGForwardedPorts(db *gorm.DB, settings string) error {
var parsed amneziawg.InboundSettings
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
return nil
}
ctx, err := s.loadPortConflictContext(db, nil)
if err != nil {
return err
}
for i := range parsed.Clients {
if err := s.amneziaWGForwardedPortsConflict(ctx, &parsed.Clients[i]); err != nil {
return err
}
}
return nil
}
// checkForwardedPortsConflict names the panel, inbound or AmneziaWG relay port a
// client's ForwardedPorts spec would collide with: a lost bind race kills the relay.
func (s *InboundService) checkForwardedPortsConflict(ctx portConflictContext, forwardedPorts string) string {
if forwardedPorts == "" {
return ""
}
if amneziawg.ExceedsForwardedPortsCap(forwardedPorts) {
return fmt.Sprintf("more than %d forwarded ports", amneziawg.MaxForwardedPorts)
}
if ctx.webPort > 0 && amneziawg.ForwardedPortsInclude(forwardedPorts, ctx.webPort) {
return fmt.Sprintf("the panel's own port (%d)", ctx.webPort)
}
for _, ib := range ctx.inbounds {
if amneziawg.ForwardedPortsInclude(forwardedPorts, ib.Port) {
name := ib.Remark
if name == "" {
name = ib.Tag
}
return fmt.Sprintf("inbound '%s' (#%d, port %d)", name, ib.Id, ib.Port)
}
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
}
if amneziawg.ForwardedPortsInclude(forwardedPorts, socksPort) {
name := ib.Remark
if name == "" {
name = ib.Tag
}
return fmt.Sprintf("inbound '%s' (#%d)'s own SOCKS5 relay port (%d)", name, ib.Id, socksPort)
}
}
return ""
}
// GetAmneziaWGDiagnostics returns a live diagnostics snapshot for inbound
// id: interface up/down, listen port, and per-client handshake/traffic
// state, read entirely from data amneziawgnet.Manager already tracks --
// gathering it can never itself change anything. Returns an error only
// when id doesn't name an AmneziaWG inbound at all; an inbound that simply
// isn't running right now (disabled, no enabled clients, or reconcile
// hasn't caught up yet) comes back as amneziawgnet.Diagnostics{}
// (Running=false), not an error, since that's a normal state an admin
// might specifically be checking for.
func (s *InboundService) GetAmneziaWGDiagnostics(id int) (amneziawgnet.Diagnostics, error) {
inbound, err := s.GetInbound(id)
if err != nil {
return amneziawgnet.Diagnostics{}, err
}
if inbound.Protocol != model.AmneziaWG {
return amneziawgnet.Diagnostics{}, fmt.Errorf("inbound %d is not an AmneziaWG inbound", id)
}
inst, ok := amneziawg.InstanceFromInbound(inbound)
if !ok {
return amneziawgnet.Diagnostics{}, nil
}
return amneziawgnet.Diagnose(inst.Id, inst.Peers), nil
}