Files
3x-ui/internal/web/runtime/local.go
T
YoungReckless4 3cd3836d77 fix(amneziawg): account for S4 junk in the default tunnel MTU (#6376)
* fix(amneziawg): account for S4 junk in the default tunnel MTU

amneziawg prepends S4 random bytes to every transport packet
(device.NewOutboundElement) and, unlike content padding and random trailers,
never clamps them against the tunnel MTU. A full-size packet therefore lands on
the wire at MTU + 60 + S4 bytes: 20 IPv4 + 8 UDP + S4 + 16 transport header +
16 poly1305 tag.

With the 1420 default that overflows a 1500-byte link once S4 exceeds 20, and
GenerateObfuscation31 draws S4 from 12..27 inclusive -- so roughly 44% of newly
created inbounds fragment every full-size packet they send.

Measured on a live pair of interfaces, predicted against observed:

    MTU 1380  S4 12  ->  1452 on the wire   (fits)
    MTU 1420  S4 12  ->  1492               (fits)
    MTU 1420  S4 20  ->  1500               (exactly at the limit)
    MTU 1420  S4 21  ->  1501               (fragments)
    MTU 1420  S4 27  ->  1507               (fragments)

EffectiveMTU now subtracts S4 from the default; an explicit MTU is untouched.

Client configs carry the same number. They previously omitted the MTU line
whenever the server had no explicit value, which left the client on its own
1420 default and fragmented the client-to-server direction even after the
server side was fixed -- silently, and only in one direction. All three
emitters (the Go subscription text and the two TypeScript ones) now agree,
which is what the existing parity test exists to protect.

* fix(amneziawg): rebuild the device when S4 changes the derived MTU

Addresses review feedback on the previous commit.

Deriving the default MTU from S4 made a construction-time-only property depend
on a hot-reloadable input, but addressFingerprint -- ensureLocked's only rebuild
trigger -- still hashed the raw inst.MTU. S4 is a UAPI field, so an S4-only edit
took the in-place IpcSet branch and the gVisor netstack kept the MTU derived
from the old S4 while all three client emitters already advertised the new one.

Every panel-created inbound leaves mtu unset, so that was the normal case, not
an edge one: with S4 raised far enough the fragmentation this fix exists to
remove came straight back, and stayed until a panel restart or an unrelated
address edit.

Folding EffectiveMTU into the fingerprint fixes it. An explicit MTU still takes
the in-place branch on an S4 edit, since it does not move the interface MTU.

Also trims four comment blocks to the 2-line cap in CLAUDE.md, and points
NewDevice's doc comment at EffectiveMTU instead of the deleted defaultMTU.
2026-09-08 16:55:32 +02:00

316 lines
9.4 KiB
Go

package runtime
import (
"context"
"encoding/json"
"errors"
"strconv"
"strings"
"sync"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
type LocalDeps struct {
APIPort func() int
SetNeedRestart func()
}
type Local struct {
deps LocalDeps
mu sync.Mutex
}
func NewLocal(deps LocalDeps) *Local {
return &Local{deps: deps}
}
func (l *Local) Name() string { return "local" }
func (l *Local) withAPI(fn func(api *xray.XrayAPI) error) error {
l.mu.Lock()
defer l.mu.Unlock()
port := l.deps.APIPort()
if port <= 0 {
return errors.New("local xray is not running")
}
var api xray.XrayAPI
if err := api.Init(port); err != nil {
return err
}
defer api.Close()
return fn(&api)
}
func (l *Local) AddInbound(_ context.Context, ib *model.Inbound) error {
if ib.Protocol == model.MTProto {
inst, ok := mtproto.InstanceFromInbound(ib)
if !ok {
return nil
}
return mtproto.GetManager().Ensure(inst)
}
if ib.Protocol == model.AmneziaWG {
inst, ok := amneziawg.InstanceFromInbound(ib)
if !ok {
return nil
}
err := amneziawgnet.GetManager().Ensure(amneziawgnet.Desired{
Instance: inst,
Options: amneziawgnet.DeviceOptions{
HeaderProtectionKey: inst.Obfuscation.HeaderProtectionKey,
ContentPaddingAddition: inst.Obfuscation.ContentPaddingAddition,
RekeyAfterTime: inst.Obfuscation.RekeyAfterTime,
RekeyTimeout: inst.Obfuscation.RekeyTimeout,
RejectAfterTime: inst.Obfuscation.RejectAfterTime,
KeepaliveTimeout: inst.Obfuscation.KeepaliveTimeout,
MaxHandshakeAttempts: inst.Obfuscation.MaxHandshakeAttempts,
RandomTrailers: inst.Obfuscation.RandomTrailers,
DisableCookies: inst.Obfuscation.DisableCookies,
},
})
// A brand new inbound can be the first one to qualify for
// injectAmneziawgnetSocks's Xray-side relay inbound (e.g. its first
// valid peer). Ensure only updates the embedded Device -- flag Xray
// for a resync so the relay actually gets created within the next
// ApplyPendingRestart tick instead of only at the next full restart.
if l.deps.SetNeedRestart != nil {
l.deps.SetNeedRestart()
}
return err
}
body, err := json.MarshalIndent(ib.GenXrayInboundConfig(), "", " ")
if err != nil {
return err
}
return l.withAPI(func(api *xray.XrayAPI) error {
return api.AddInbound(body)
})
}
func (l *Local) DelInbound(_ context.Context, ib *model.Inbound) error {
if ib.Protocol == model.MTProto {
mtproto.GetManager().Remove(ib.Id)
return nil
}
if ib.Protocol == model.AmneziaWG {
amneziawgnet.GetManager().Remove(ib.Id)
// The removed inbound may have been the only one backing Xray's
// injectAmneziawgnetSocks relay inbound for this tag -- flag a
// resync so the now-stale relay gets torn down promptly.
if l.deps.SetNeedRestart != nil {
l.deps.SetNeedRestart()
}
return nil
}
return l.withAPI(func(api *xray.XrayAPI) error {
return api.DelInbound(ib.Tag)
})
}
func (l *Local) UpdateInbound(ctx context.Context, oldIb, newIb *model.Inbound) error {
if oldIb.Protocol == model.MTProto || newIb.Protocol == model.MTProto {
return l.updateMtprotoInbound(ctx, oldIb, newIb)
}
if oldIb.Protocol == model.AmneziaWG || newIb.Protocol == model.AmneziaWG {
return l.updateAmneziaWGInbound(ctx, oldIb, newIb)
}
_ = l.DelInbound(ctx, oldIb)
if !newIb.Enable {
return nil
}
return l.AddInbound(ctx, newIb)
}
// updateMtprotoInbound applies an inbound update without the Del+Add sequence
// the xray path uses: Remove would drop the manager's fingerprint state, which
// is what lets Ensure keep the running mtg process (and its live connections)
// when nothing in the generated config changed. The sidecar is only stopped
// when the inbound is disabled, loses its last active secret, or moves to a
// different protocol.
func (l *Local) updateMtprotoInbound(ctx context.Context, oldIb, newIb *model.Inbound) error {
if oldIb.Protocol == model.MTProto && newIb.Protocol != model.MTProto {
mtproto.GetManager().Remove(oldIb.Id)
if !newIb.Enable {
return nil
}
return l.AddInbound(ctx, newIb)
}
if oldIb.Protocol != model.MTProto {
_ = l.DelInbound(ctx, oldIb)
}
if !newIb.Enable {
mtproto.GetManager().Remove(newIb.Id)
return nil
}
inst, ok := mtproto.InstanceFromInbound(newIb)
if !ok {
mtproto.GetManager().Remove(newIb.Id)
return nil
}
return mtproto.GetManager().Ensure(inst)
}
// updateAmneziaWGInbound mirrors updateMtprotoInbound: it skips the
// Remove+Ensure sequence a plain Del+Add would force so that, on an
// AmneziaWG-to-AmneziaWG edit, Manager.Ensure's own fingerprint comparison
// can reconfigure the running embedded Device in place via IpcSet instead
// of always rebuilding it (see internal/amneziawgnet.Manager.ensureLocked --
// only an address or effective-MTU change forces a rebuild there, S4
// included, not a peer edit).
//
// Every exit path below only touches the embedded Device via
// amneziawgnet.GetManager() -- none of it rebuilds Xray's own config, which
// is what actually creates/removes injectAmneziawgnetSocks's relay inbound.
// A peer edit that changes whether this inbound has a qualifying peer at
// all (its first peer added, or its last one removed) must still get that
// relay created or torn down, so flag Xray for a resync unconditionally
// here rather than trying to enumerate which of the branches below need it.
func (l *Local) updateAmneziaWGInbound(ctx context.Context, oldIb, newIb *model.Inbound) error {
if l.deps.SetNeedRestart != nil {
l.deps.SetNeedRestart()
}
if oldIb.Protocol == model.AmneziaWG && newIb.Protocol != model.AmneziaWG {
amneziawgnet.GetManager().Remove(oldIb.Id)
if !newIb.Enable {
return nil
}
return l.AddInbound(ctx, newIb)
}
if oldIb.Protocol != model.AmneziaWG {
_ = l.DelInbound(ctx, oldIb)
}
if !newIb.Enable {
amneziawgnet.GetManager().Remove(newIb.Id)
return nil
}
inst, ok := amneziawg.InstanceFromInbound(newIb)
if !ok {
amneziawgnet.GetManager().Remove(newIb.Id)
return nil
}
return amneziawgnet.GetManager().Ensure(amneziawgnet.Desired{
Instance: inst,
Options: amneziawgnet.DeviceOptions{
HeaderProtectionKey: inst.Obfuscation.HeaderProtectionKey,
ContentPaddingAddition: inst.Obfuscation.ContentPaddingAddition,
RekeyAfterTime: inst.Obfuscation.RekeyAfterTime,
RekeyTimeout: inst.Obfuscation.RekeyTimeout,
RejectAfterTime: inst.Obfuscation.RejectAfterTime,
KeepaliveTimeout: inst.Obfuscation.KeepaliveTimeout,
MaxHandshakeAttempts: inst.Obfuscation.MaxHandshakeAttempts,
RandomTrailers: inst.Obfuscation.RandomTrailers,
DisableCookies: inst.Obfuscation.DisableCookies,
},
})
}
func (l *Local) AddUser(_ context.Context, ib *model.Inbound, userMap map[string]any) error {
if ib.Protocol == model.MTProto || ib.Protocol == model.AmneziaWG {
return nil
}
return l.withAPI(func(api *xray.XrayAPI) error {
return api.AddUser(string(ib.Protocol), ib.Tag, userMap)
})
}
func (l *Local) RemoveUser(_ context.Context, ib *model.Inbound, email string) error {
if ib.Protocol == model.MTProto || ib.Protocol == model.AmneziaWG {
return nil
}
return l.withAPI(func(api *xray.XrayAPI) error {
return api.RemoveUser(ib.Tag, email)
})
}
func (l *Local) AddClient(ctx context.Context, ib *model.Inbound, client model.Client) error {
if !client.Enable {
return nil
}
user := map[string]any{
"email": client.Email,
"id": client.ID,
"security": client.Security,
"flow": client.Flow,
"auth": client.Auth,
"password": client.Password,
"publicKey": client.PublicKey,
"allowedIPs": client.AllowedIPs,
"preSharedKey": client.PreSharedKey,
"keepAlive": wgKeepAlive(client.KeepAlive),
}
return l.AddUser(ctx, ib, user)
}
func (l *Local) DeleteUser(ctx context.Context, ib *model.Inbound, email string) error {
if email == "" {
return nil
}
if err := l.RemoveUser(ctx, ib, email); err != nil {
if strings.Contains(err.Error(), "not found") {
return nil
}
return err
}
return nil
}
func (l *Local) DeleteClient(context.Context, string) error {
return nil
}
func (l *Local) UpdateUser(ctx context.Context, ib *model.Inbound, oldEmail string, payload model.Client) error {
if oldEmail != "" {
if err := l.RemoveUser(ctx, ib, oldEmail); err != nil && !strings.Contains(err.Error(), "not found") {
return err
}
}
if !payload.Enable {
return nil
}
user := map[string]any{
"email": payload.Email,
"id": payload.ID,
"security": payload.Security,
"flow": payload.Flow,
"auth": payload.Auth,
"password": payload.Password,
"publicKey": payload.PublicKey,
"allowedIPs": payload.AllowedIPs,
"preSharedKey": payload.PreSharedKey,
"keepAlive": wgKeepAlive(payload.KeepAlive),
}
return l.AddUser(ctx, ib, user)
}
func wgKeepAlive(seconds int) string {
if seconds <= 0 {
return ""
}
return strconv.Itoa(seconds)
}
func (l *Local) RestartXray(_ context.Context) error {
if l.deps.SetNeedRestart != nil {
l.deps.SetNeedRestart()
}
return nil
}
func (l *Local) ResetClientTraffic(_ context.Context, _ *model.Inbound, _ string) error {
return nil
}
func (l *Local) ResetAllTraffics(_ context.Context) error {
return nil
}
func (l *Local) ResetInboundTraffic(_ context.Context, _ *model.Inbound) error {
return nil
}