feat(amneziawg): add AmneziaWG as an outbound protocol (#6320)

* feat(amneziawg): add AmneziaWG as an outbound protocol

- AmneziaWG outbound protocol end-to-end: config schema, socks bridge, netstack, panel UI
- Route amneziawg outbounds to HTTP probe in TCP mode (backend + frontend classifiers) with pinning test
- Add 2-minute idle read deadline to pumpUDPEgress to reap idle egress sessions
- Require SOCKS5 username/password auth on the egress server (reject NO-AUTH with 0xFF) with test
- Bound the egress TCP tunnel dial with portForwardDialTimeout (10s), matching portfwd.go
- Resolve UDP domain targets off the association's reader loop via deliverUDPDatagram; race-safe getOrDial starts the reply pump at session creation; client passed by value into resolver goroutines (pinned by TestEgressUDPDatagramDomainInterleavedClients)
- Reconcile early-returns on an empty desired set and closes the egress listener; EgressBasePort (64900) is reserved against local inbound port conflicts like the internal API port, with pinning tests for both the port reservation (TestCheckPortConflict_EgressPortBlockedLocal) and the Reconcile empty-desired Close/Listen lifecycle (TestOutboundManagerReconcileEmptyDesiredClosesEgress)
- Eliminate acceptLoop shutdown race by validating listener != nil and registering to tracked under s.mu before wg.Add; bound pre-auth handshake with deadline (pinned by TestEgressServerCloseDuringConcurrentAccepts)
- Support AAAA and dual-stack domain resolution in tunnel DNS resolver with v6 default fallback (DefaultTunnelDNSServerV6); add DNS field to frontend protocol form; avoid unneeded cache flushes on unchanged SetStack ticks

* fix(amneziawg): resolve IPv6-only DNS default fallback and validate required keys

- Default to IPv6 tunnel DNS on IPv6-only outbounds with blank dns
- Require non-empty secretKey and peer publicKey in ValidateAmneziaWGOutbound
- Add end-to-end IPv6 tunnel domain resolution test and test empty key rejection
- Trim comment blocks exceeding 2 lines across modified files
- Fix Storybook test execution on environments with POSIX locale

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: rqzbeh <rqzbeh@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
Rouzbeh†
2026-09-10 16:20:48 +03:30
committed by GitHub
parent 876497db6e
commit d5ab84e8d5
50 changed files with 4169 additions and 33 deletions
+67 -11
View File
@@ -1,24 +1,20 @@
package job
import (
"encoding/json"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// AmneziaWGJob reconciles the running embedded AmneziaWG interfaces
// (internal/amneziawgnet -- amneziawg-go over a gVisor netstack, no kernel
// module) against the enabled AmneziaWG inbounds in the database,
// rebuilding/reconfiguring any that drifted. Unlike the retired
// kernel-module Manager this job used to drive, there is no traffic/
// online-status accounting here at all: once a peer's decapsulated traffic
// is relayed into Xray's own SOCKS5 inbound (see
// internal/web/service/xray.go's injectAmneziawgnetSocks, and
// internal/amneziawgnet.Manager's automatic forwarder/relay wiring), it's
// an ordinary Xray user, and XrayTrafficJob's existing, protocol-blind
// stats/online-status polling already picks it up for free.
// AmneziaWGJob converges embedded AmneziaWG interfaces (inbounds AND the
// template's "amneziawg" outbounds) every 10s; stats stay with Xray.
type AmneziaWGJob struct {
inboundService service.InboundService
settingService service.SettingService
}
// NewAmneziaWGJob creates a new AmneziaWG reconcile job instance.
@@ -52,4 +48,64 @@ func (j *AmneziaWGJob) Run() {
})
}
amneziawgnet.GetManager().Reconcile(wanted)
outboundDesired, err := j.desiredOutboundInstances()
if err != nil {
logger.Warning("amneziawg job: get desired outbound instances failed:", err)
return
}
amneziawgnet.GetOutboundManager().Reconcile(outboundDesired)
}
// desiredOutboundInstances derives client instances per template "amneziawg" outbound.
func (j *AmneziaWGJob) desiredOutboundInstances() ([]amneziawgnet.OutboundDesired, error) {
template, err := j.settingService.GetXrayConfigTemplate()
if err != nil {
return nil, err
}
if template == "" {
return nil, nil
}
cfg := &xray.Config{}
if err := json.Unmarshal([]byte(template), cfg); err != nil {
return nil, err
}
if len(cfg.OutboundConfigs) == 0 {
return nil, nil
}
var raws []json.RawMessage
if err := json.Unmarshal(cfg.OutboundConfigs, &raws); err != nil {
return nil, err
}
out := make([]amneziawgnet.OutboundDesired, 0, len(raws))
for _, raw := range raws {
if !amneziawg.IsAmneziaWGOutbound(raw) {
continue
}
var probe struct {
Tag string `json:"tag"`
}
if err := json.Unmarshal(raw, &probe); err != nil || probe.Tag == "" {
continue
}
inst, ok := amneziawg.InstanceFromOutbound(probe.Tag, raw)
if !ok {
continue
}
out = append(out, amneziawgnet.OutboundDesired{
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,
},
})
}
return out, nil
}