From 58671533bba04ab2c3bfa5d92a3286a50653d2bb Mon Sep 17 00:00:00 2001 From: Kuzz007 Date: Sun, 2 Aug 2026 13:48:13 +0300 Subject: [PATCH 01/12] feat(amneziawg): add embedded amneziawg-go device package (Phase 1) New internal/amneziawgnet package: builds a real amneziawg-go Device over a gVisor netstack from an existing amneziawg.Instance, with a TCP/UDP forwarder that recovers each tunnel connection's real destination and a peer-identity index keyed by AllowedIPs. This is the foundation for migrating AmneziaWG off the kernel-module+TPROXY path (see the AmneziaWG-go vs kernel-module decision) -- nothing wires into live traffic yet, that's Phase 2 (relay into Xray's own SOCKS5 inbound). Covered by three real end-to-end tests: a genuine handshake + TCP forwarder + identity resolution, the same for UDP (including a reply routed back through the tunnel), and the manager's reconfigure-in-place vs. rebuild lifecycle. Co-Authored-By: Claude Sonnet 5 --- go.mod | 4 +- go.sum | 2 + internal/amneziawgnet/device.go | 185 ++++++++++++++++++++++++ internal/amneziawgnet/device_test.go | 161 +++++++++++++++++++++ internal/amneziawgnet/forwarder.go | 43 ++++++ internal/amneziawgnet/identity.go | 62 ++++++++ internal/amneziawgnet/manager.go | 180 ++++++++++++++++++++++++ internal/amneziawgnet/manager_test.go | 85 +++++++++++ internal/amneziawgnet/netstack.go | 194 ++++++++++++++++++++++++++ internal/amneziawgnet/udp.go | 100 +++++++++++++ internal/amneziawgnet/udp_test.go | 150 ++++++++++++++++++++ 11 files changed, 1165 insertions(+), 1 deletion(-) create mode 100644 internal/amneziawgnet/device.go create mode 100644 internal/amneziawgnet/device_test.go create mode 100644 internal/amneziawgnet/forwarder.go create mode 100644 internal/amneziawgnet/identity.go create mode 100644 internal/amneziawgnet/manager.go create mode 100644 internal/amneziawgnet/manager_test.go create mode 100644 internal/amneziawgnet/netstack.go create mode 100644 internal/amneziawgnet/udp.go create mode 100644 internal/amneziawgnet/udp_test.go diff --git a/go.mod b/go.mod index 041ebe2e8..680c2da2d 100644 --- a/go.mod +++ b/go.mod @@ -35,6 +35,8 @@ require ( pgregory.net/rapid v1.3.0 ) +require github.com/amnezia-vpn/amneziawg-go/v3 v3.0.3 + require ( github.com/Azure/go-ntlmssp v0.1.1 // indirect github.com/andybalholm/brotli v1.2.2 // indirect @@ -110,6 +112,6 @@ require ( golang.zx2c4.com/wireguard/windows v1.0.1 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0 // indirect google.golang.org/protobuf v1.36.11 - gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect + gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 lukechampine.com/blake3 v1.4.1 // indirect ) diff --git a/go.sum b/go.sum index 66d000f5e..10c490ca7 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/amnezia-vpn/amneziawg-go/v3 v3.0.3 h1:XYR85mN53hj2DTzToHs3OxIHrNA59QMg1m3+oiOnBi4= +github.com/amnezia-vpn/amneziawg-go/v3 v3.0.3/go.mod h1:YoPc6qcOZqD7TXZ1xpedD8Sx3aSKsxN05ZqEFmXDNHk= github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 h1:J1O+xpLuJWkdYbw5JPGwBqIHs2J8tiEP7Py9lPqkN2I= diff --git a/internal/amneziawgnet/device.go b/internal/amneziawgnet/device.go new file mode 100644 index 000000000..a5a7df695 --- /dev/null +++ b/internal/amneziawgnet/device.go @@ -0,0 +1,185 @@ +package amneziawgnet + +import ( + "fmt" + "net/netip" + "strings" + + awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn" + "github.com/amnezia-vpn/amneziawg-go/v3/device" + "gvisor.dev/gvisor/pkg/tcpip/stack" + + "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" + "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" +) + +// defaultMTU matches internal/amneziawg's own kernel-module interface +// default -- 1420, WireGuard/AmneziaWG's usual accounting for tunnel +// encapsulation overhead on a standard 1500-byte-MTU host link. +const defaultMTU = 1420 + +// DeviceOptions carries the AmneziaWG 3.0 header-protection fields, kept out +// of amneziawg.Instance/Obfuscation20 deliberately: those are the shared, +// DB-backed types the still-live kernel-module path also reads and writes, +// and 3.0 header protection is a device-wide, strictly opt-in setting (see +// the migration plan's "Reference material" section) that isn't wired into +// that shared schema yet. Zero-value DeviceOptions means classic +// (non-3.0) obfuscation only, matching the kernel-module path's own +// defaults today. +type DeviceOptions struct { + // HeaderProtectionKey is a base64 32-byte key. Empty disables AWG 3.0 + // header protection entirely. Non-empty requires every one of + // Obfuscation20.S1-S4 to be >= 12 (amneziawg-go's own HeaderCipherNonceSize + // requirement) -- IpcSet will reject the config otherwise. + HeaderProtectionKey string + // ContentPaddingAddition is a "low-high" range (or a bare integer) per + // amneziawg-tools' own u16_range_from_string grammar. Empty disables it. + ContentPaddingAddition string + // Logger is passed to device.NewDevice as-is; nil uses a silent logger + // (device.NewLogger(device.LogLevelSilent, "")). + Logger *device.Logger +} + +// Device is one running embedded AmneziaWG interface: an amneziawg-go +// Device over a gVisor netstack, plus the raw *stack.Stack a caller needs to +// attach a TCP/UDP forwarder (see forwarder.go / udp.go). Closing it tears +// down both the WireGuard device and the underlying tun/stack. +type Device struct { + *device.Device + Stack *stack.Stack +} + +// NewDevice constructs and brings up an embedded AmneziaWG interface for +// inst: a gVisor-backed tun.Device sized to inst.MTU (or defaultMTU), +// addressed with inst.Address, configured via UAPI with inst.Obfuscation, +// inst.PrivateKey, opts' AWG 3.0 fields, and one UAPI peer per inst.Peers +// entry. It does not attach a forwarder or start relaying traffic -- +// that's the caller's job (see AttachTCPForwarder / AttachUDPHandler), +// keeping this constructor usable both for a real relay and for a plain +// mechanical test. +func NewDevice(inst amneziawg.Instance, opts DeviceOptions) (*Device, error) { + addrs, err := hostAddresses(inst.Address) + if err != nil { + return nil, fmt.Errorf("amneziawgnet: %w", err) + } + + mtu := inst.MTU + if mtu <= 0 { + mtu = defaultMTU + } + + tun, gstack, err := createNetTUNWithStack(addrs, mtu) + if err != nil { + return nil, fmt.Errorf("amneziawgnet: create netstack: %w", err) + } + + logger := opts.Logger + if logger == nil { + logger = device.NewLogger(device.LogLevelSilent, "") + } + dev := device.NewDevice(tun, awgconn.NewDefaultBind(), logger) + + conf, err := buildUAPIConfig(inst, opts) + if err != nil { + dev.Close() + return nil, fmt.Errorf("amneziawgnet: %w", err) + } + if err := dev.IpcSet(conf); err != nil { + dev.Close() + return nil, fmt.Errorf("amneziawgnet: IpcSet for inbound %d: %w", inst.Id, err) + } + if err := dev.Up(); err != nil { + dev.Close() + return nil, fmt.Errorf("amneziawgnet: bring up inbound %d: %w", inst.Id, err) + } + + return &Device{Device: dev, Stack: gstack}, nil +} + +// hostAddresses parses each of inst.Address's CIDR strings (e.g. +// "10.8.1.1/24") down to the bare host address the netstack's NIC gets +// configured with -- the interface's own address, not the subnet it routes. +func hostAddresses(addresses []string) ([]netip.Addr, error) { + out := make([]netip.Addr, 0, len(addresses)) + for _, a := range addresses { + prefix, err := netip.ParsePrefix(a) + if err != nil { + return nil, fmt.Errorf("invalid interface address %q: %w", a, err) + } + out = append(out, prefix.Addr()) + } + return out, nil +} + +// buildUAPIConfig renders inst (plus opts' AWG 3.0 fields) as a WireGuard +// UAPI "set" configuration string -- private_key/listen_port/jc.../s1-s4/ +// h1-h4/i1 device lines, the AWG 3.0 device lines when opts asks for them, +// then one public_key/preshared_key/allowed_ip block per peer. Field names +// and format match amneziawg-go v3.0.3's device/uapi.go exactly (confirmed +// against its real source during Phase 0 spiking, not just its docs). +func buildUAPIConfig(inst amneziawg.Instance, opts DeviceOptions) (string, error) { + var b strings.Builder + + privHex, err := wireguard.KeyToHex(inst.PrivateKey) + if err != nil { + return "", fmt.Errorf("invalid server private key: %w", err) + } + fmt.Fprintf(&b, "private_key=%s\n", privHex) + fmt.Fprintf(&b, "listen_port=%d\n", inst.ListenPort) + // replace_peers makes every apply a full resync (matches this package's + // own Manager.Ensure semantics): peers no longer in inst.Peers are + // dropped instead of lingering from a previous IpcSet call. + b.WriteString("replace_peers=true\n") + + o := inst.Obfuscation + fmt.Fprintf(&b, "jc=%d\njmin=%d\njmax=%d\n", o.Jc, o.Jmin, o.Jmax) + fmt.Fprintf(&b, "s1=%d\ns2=%d\ns3=%d\ns4=%d\n", o.S1, o.S2, o.S3, o.S4) + writeHLine(&b, "h1", o.H1) + writeHLine(&b, "h2", o.H2) + writeHLine(&b, "h3", o.H3) + writeHLine(&b, "h4", o.H4) + if o.I1 != "" { + fmt.Fprintf(&b, "i1=%s\n", o.I1) + } + + if opts.HeaderProtectionKey != "" { + hpHex, err := wireguard.KeyToHex(opts.HeaderProtectionKey) + if err != nil { + return "", fmt.Errorf("invalid header protection key: %w", err) + } + fmt.Fprintf(&b, "header_protection_key=%s\n", hpHex) + } + if opts.ContentPaddingAddition != "" { + fmt.Fprintf(&b, "content_padding_addition=%s\n", opts.ContentPaddingAddition) + } + + for _, p := range inst.Peers { + pubHex, err := wireguard.KeyToHex(p.PublicKey) + if err != nil { + return "", fmt.Errorf("peer %q: invalid public key: %w", p.Email, err) + } + fmt.Fprintf(&b, "public_key=%s\n", pubHex) + if p.PresharedKey != "" { + pskHex, err := wireguard.KeyToHex(p.PresharedKey) + if err != nil { + return "", fmt.Errorf("peer %q: invalid preshared key: %w", p.Email, err) + } + fmt.Fprintf(&b, "preshared_key=%s\n", pskHex) + } + for _, allowedIP := range p.AllowedIPs { + fmt.Fprintf(&b, "allowed_ip=%s\n", allowedIP) + } + } + + return b.String(), nil +} + +// writeHLine writes an hN UAPI line only when v is set -- an empty H value +// means "let amneziawg-go fall back to its own default," mirroring how +// internal/amneziawg's generateServerConfig treats the same optional field. +func writeHLine(b *strings.Builder, name, v string) { + if v == "" { + return + } + fmt.Fprintf(b, "%s=%s\n", name, v) +} diff --git a/internal/amneziawgnet/device_test.go b/internal/amneziawgnet/device_test.go new file mode 100644 index 000000000..667bc48d0 --- /dev/null +++ b/internal/amneziawgnet/device_test.go @@ -0,0 +1,161 @@ +package amneziawgnet + +import ( + "context" + "fmt" + "io" + "net/netip" + "testing" + "time" + + awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn" + "github.com/amnezia-vpn/amneziawg-go/v3/device" + "github.com/amnezia-vpn/amneziawg-go/v3/tun/netstack" + "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet" + + "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" + "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" +) + +// TestNewDeviceHandshakeForwarderAndIdentity is Phase 1's real end-to-end +// proof, not just a compile check: a genuine amneziawg-go client (via that +// project's own tun/netstack.CreateNetTUN -- the client side doesn't need a +// forwarder or peer-identity resolution, only this package's server side +// does) completes a real 3-way handshake against a Device built by +// NewDevice, dials a destination that was never configured anywhere on the +// server, and the test verifies AttachTCPForwarder recovers that exact +// destination *and* PeerIndex.Lookup resolves the connection's source back +// to the right peer's Email -- Phase 1a/1b/1c working together, the same +// mechanism Phase 0's throwaway spike validated, now as a real, repo-owned, +// repeatable test instead of scratch code. +func TestNewDeviceHandshakeForwarderAndIdentity(t *testing.T) { + serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("generate server keypair: %v", err) + } + clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("generate client keypair: %v", err) + } + + const listenPort = 58712 // fixed loopback test port, matches the validated Phase 0 spike approach + const wantEmail = "test-peer@example.com" + + inst := amneziawg.Instance{ + Id: 1, + InterfaceName: "awgtest1", + ListenPort: listenPort, + PrivateKey: serverPriv, + PublicKey: serverPub, + Address: []string{"10.201.0.1/24"}, + MTU: 1420, + Obfuscation: amneziawg.Obfuscation20{ + Jc: 4, Jmin: 40, Jmax: 70, + S1: 20, S2: 30, S3: 20, S4: 20, + }, + Peers: []amneziawg.Peer{{ + Email: wantEmail, + PublicKey: clientPub, + AllowedIPs: []string{"10.201.0.2/32"}, + }}, + } + + dev, err := NewDevice(inst, DeviceOptions{}) + if err != nil { + t.Fatalf("NewDevice: %v", err) + } + defer dev.Close() + + idx := NewPeerIndex(inst.Peers) + + type recovered struct { + email string + ok bool + dest netip.AddrPort + } + got := make(chan recovered, 1) + + // Never configured anywhere server-side: the forwarder must recover it + // purely from the decapsulated packet, not from any routing table. + wantDest := netip.MustParseAddrPort("10.201.9.9:9999") + + AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) { + defer conn.Close() + srcAddrPort, parseErr := netip.ParseAddrPort(conn.RemoteAddr().String()) + var peer amneziawg.Peer + var ok bool + if parseErr == nil { + peer, ok = idx.Lookup(srcAddrPort.Addr().Unmap()) + } + got <- recovered{email: peer.Email, ok: ok, dest: dest} + io.Copy(io.Discard, conn) + }) + + clientTun, clientNet, err := netstack.CreateNetTUN( + []netip.Addr{netip.MustParseAddr("10.201.0.2")}, + []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420) + if err != nil { + t.Fatalf("client CreateNetTUN: %v", err) + } + clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, "")) + defer clientDev.Close() + + clientPrivHex, err := wireguard.KeyToHex(clientPriv) + if err != nil { + t.Fatalf("client key to hex: %v", err) + } + serverPubHex, err := wireguard.KeyToHex(serverPub) + if err != nil { + t.Fatalf("server key to hex: %v", err) + } + // allowed_ip=0.0.0.0/0 on the client matches a real VPN client's own + // config (route everything through the tunnel) -- it's also what makes + // dialing an arbitrary, never-configured destination like wantDest + // actually get routed to the server peer at all: a narrower AllowedIPs + // here would make the client's own Device drop the packet as + // non-matching before it ever reached the wire. + clientConf := fmt.Sprintf( + "private_key=%s\njc=4\njmin=40\njmax=70\ns1=20\ns2=30\ns3=20\ns4=20\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n", + clientPrivHex, serverPubHex, listenPort) + if err := clientDev.IpcSet(clientConf); err != nil { + t.Fatalf("client IpcSet: %v", err) + } + if err := clientDev.Up(); err != nil { + t.Fatalf("client Up: %v", err) + } + + // Retry the dial rather than guessing a fixed handshake delay: the + // first attempts may race the handshake, later ones should succeed + // once it completes. + dialCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var lastErr error + for { + conn, dialErr := clientNet.DialContext(dialCtx, "tcp", wantDest.String()) + if dialErr == nil { + conn.Close() + break + } + lastErr = dialErr + select { + case <-dialCtx.Done(): + t.Fatalf("client dial never succeeded: %v", lastErr) + case <-time.After(100 * time.Millisecond): + } + } + + select { + case r := <-got: + if !r.ok { + t.Fatal("forwarder: peer identity lookup failed for the recovered connection") + } + if r.email != wantEmail { + t.Errorf("resolved peer email = %q, want %q", r.email, wantEmail) + } + if r.dest != wantDest { + t.Errorf("recovered destination = %v, want %v", r.dest, wantDest) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for the forwarder to hand back the recovered connection") + } +} diff --git a/internal/amneziawgnet/forwarder.go b/internal/amneziawgnet/forwarder.go new file mode 100644 index 000000000..63c13496d --- /dev/null +++ b/internal/amneziawgnet/forwarder.go @@ -0,0 +1,43 @@ +package amneziawgnet + +import ( + "net/netip" + + "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet" + "gvisor.dev/gvisor/pkg/tcpip/stack" + "gvisor.dev/gvisor/pkg/tcpip/transport/tcp" + "gvisor.dev/gvisor/pkg/waiter" +) + +// AttachTCPForwarder attaches a TCP forwarder to gstack in promiscuous + +// spoofing mode, so it accepts connections addressed to any destination -- +// not just the stack's own configured local address -- and hands the +// handler both the accepted connection and the tunnel client's real, +// dynamically-arbitrary destination (recovered from the connection's own +// TransportEndpointID, not from any preconfigured routing table). This is +// the mechanism the whole embedded-AmneziaWG design depends on: what the +// handler does with that destination (dial it directly, relay it into +// Xray's SOCKS5 inbound, ...) is entirely up to the caller. +// +// Adapted from xtls/xray-core's proxy/wireguard/tun.go createForwarder (MIT). +func AttachTCPForwarder(gstack *stack.Stack, handler func(conn *gonet.TCPConn, dest netip.AddrPort)) { + enablePromiscuousRouting(gstack) + + fwd := tcp.NewForwarder(gstack, 0, 65535, func(r *tcp.ForwarderRequest) { + go func(r *tcp.ForwarderRequest) { + var wq waiter.Queue + id := r.ID() + + ep, err := r.CreateEndpoint(&wq) + if err != nil { + r.Complete(true) + return + } + dest := netip.AddrPortFrom(addrFromTcpip(id.LocalAddress), id.LocalPort) + handler(gonet.NewTCPConn(&wq, ep), dest) + ep.Close() + r.Complete(false) + }(r) + }) + gstack.SetTransportProtocolHandler(tcp.ProtocolNumber, fwd.HandlePacket) +} diff --git a/internal/amneziawgnet/identity.go b/internal/amneziawgnet/identity.go new file mode 100644 index 000000000..087774f00 --- /dev/null +++ b/internal/amneziawgnet/identity.go @@ -0,0 +1,62 @@ +package amneziawgnet + +import ( + "net/netip" + + "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" +) + +// PeerIndex resolves a decapsulated connection's tunnel-internal source +// address back to the peer it belongs to, the same role Xray-core's own +// wireguard proxy's GetUserByAddr plays -- sourced here from an +// amneziawg.Instance's own Peers (already carries Email per peer, no new +// data needed) rather than a separate user table. +type PeerIndex struct { + entries []peerIndexEntry +} + +type peerIndexEntry struct { + prefix netip.Prefix + peer amneziawg.Peer +} + +// NewPeerIndex builds a lookup index from peers' AllowedIPs. Entries with an +// unparseable AllowedIPs value are skipped rather than failing the whole +// index -- by the time an Instance reaches this package, AllowedIPs has +// already been accepted at save time (see internal/amneziawg's own +// validation), so a bad entry here would only mean stale/manually-edited +// data, not something worth refusing to serve the rest of the peers over. +func NewPeerIndex(peers []amneziawg.Peer) *PeerIndex { + idx := &PeerIndex{} + for _, p := range peers { + for _, a := range p.AllowedIPs { + prefix, err := netip.ParsePrefix(a) + if err != nil { + continue + } + idx.entries = append(idx.entries, peerIndexEntry{prefix: prefix, peer: p}) + } + } + return idx +} + +// Lookup returns the peer whose AllowedIPs most specifically contains addr -- +// the same longest-prefix-match rule a real AmneziaWG interface's own +// AllowedIPs routing table uses for outbound packets, applied here in +// reverse to attribute an inbound (tunnel-internal-source) packet back to +// its owning peer. +func (idx *PeerIndex) Lookup(addr netip.Addr) (amneziawg.Peer, bool) { + bestBits := -1 + var bestPeer amneziawg.Peer + for _, e := range idx.entries { + if e.prefix.Bits() <= bestBits || !e.prefix.Contains(addr) { + continue + } + bestBits = e.prefix.Bits() + bestPeer = e.peer + } + if bestBits < 0 { + return amneziawg.Peer{}, false + } + return bestPeer, true +} diff --git a/internal/amneziawgnet/manager.go b/internal/amneziawgnet/manager.go new file mode 100644 index 000000000..ca8276ae8 --- /dev/null +++ b/internal/amneziawgnet/manager.go @@ -0,0 +1,180 @@ +package amneziawgnet + +import ( + "fmt" + "strings" + "sync" + + "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" + "github.com/mhsanaei/3x-ui/v3/internal/logger" +) + +// Desired pairs an amneziawg.Instance (the shared, DB-backed shape +// internal/amneziawg's own kernel-module Manager also reconciles toward) +// with this package's own embedded-only DeviceOptions -- the AWG 3.0 fields +// that shared type doesn't carry, see DeviceOptions' doc comment. +type Desired struct { + Instance amneziawg.Instance + Options DeviceOptions +} + +// managed is one running embedded interface: the live Device, the peer +// lookup index built from its current peer list, and enough of its own +// configuration to decide whether a later Ensure call can reconfigure it in +// place or needs to rebuild it from scratch. +type managed struct { + dev *Device + peers *PeerIndex + inst amneziawg.Instance + structFP string +} + +// Manager owns the set of running embedded AmneziaWG interfaces, keyed by +// inbound id -- the same shape as internal/amneziawg.Manager (GetManager() +// + sync.Once, mu-guarded map, Ensure/Reconcile/StopAll/HasRunning), so a +// caller already familiar with that Manager needs to learn nothing new here. +// Unlike that Manager, this one doesn't attach any traffic handling by +// itself: Ensure/Reconcile only bring each Instance's Device up to date. +// Attaching a forwarder/UDP handler (see forwarder.go / udp.go) using the +// Device and PeerIndex returned by Lookup is left to the caller -- today a +// test harness, later the Phase 2 SOCKS5 relay wiring -- since this package +// doesn't yet know what that handler should do with a recovered connection. +type Manager struct { + mu sync.Mutex + ifaces map[int]*managed +} + +var ( + managerOnce sync.Once + manager *Manager +) + +// GetManager returns the process-wide embedded-AmneziaWG manager singleton. +func GetManager() *Manager { + managerOnce.Do(func() { + manager = &Manager{ifaces: map[int]*managed{}} + }) + return manager +} + +// Ensure brings inbound d.Instance.Id's embedded interface to the state +// d describes, creating it if it doesn't exist yet. A no-op only when +// nothing has changed since the last successful Ensure/Reconcile. +func (m *Manager) Ensure(d Desired) error { + m.mu.Lock() + defer m.mu.Unlock() + return m.ensureLocked(d) +} + +// ensureLocked decides between three actions: nothing changed since the +// last apply (skip entirely); only peers/obfuscation/keys/listen_port +// changed (reconfigure the existing Device in place via IpcSet, which +// already sends replace_peers=true -- see buildUAPIConfig -- so removed +// peers are dropped correctly without a full rebuild); or the interface's +// own address(es)/MTU changed (these are fixed at netstack-construction +// time, so the only option is closing the old Device and building a fresh +// one). This is a coarser split than internal/amneziawg's own three-tier +// noop/reload/restart fingerprinting (that one also tracks host-side +// TPROXY/NDP rules this embedded path has no equivalent of) -- correct and +// sufficient for Phase 1; revisit only if reconcile frequency at real scale +// makes the address/MTU rebuild path worth avoiding too. +func (m *Manager) ensureLocked(d Desired) error { + inst, opts := d.Instance, d.Options + structFP := addressFingerprint(inst) + + cur, exists := m.ifaces[inst.Id] + if exists && cur.structFP == structFP { + conf, err := buildUAPIConfig(inst, opts) + if err != nil { + return fmt.Errorf("amneziawgnet: %w", err) + } + if err := cur.dev.IpcSet(conf); err != nil { + return fmt.Errorf("amneziawgnet: reconfigure inbound %d: %w", inst.Id, err) + } + cur.peers = NewPeerIndex(inst.Peers) + cur.inst = inst + return nil + } + + if exists { + cur.dev.Close() + delete(m.ifaces, inst.Id) + } + dev, err := NewDevice(inst, opts) + if err != nil { + return err + } + m.ifaces[inst.Id] = &managed{ + dev: dev, + peers: NewPeerIndex(inst.Peers), + inst: inst, + structFP: structFP, + } + logger.Infof("amneziawgnet: started embedded interface %s for inbound %d", inst.InterfaceName, inst.Id) + return nil +} + +// addressFingerprint captures the two Instance fields that can't be changed +// on a running Device via IpcSet alone (they're fixed when the gVisor +// netstack is built) -- everything else (keys, listen port, obfuscation, +// AWG 3.0 options, peers) amneziawg-go's own UAPI can hot-reconfigure. +func addressFingerprint(inst amneziawg.Instance) string { + return fmt.Sprintf("%d|%s", inst.MTU, strings.Join(inst.Address, ",")) +} + +// Reconcile brings every desired instance's embedded interface up to date +// and stops any managed interface whose inbound is no longer desired -- +// mirroring internal/amneziawg.Manager.Reconcile's per-tick contract. +func (m *Manager) Reconcile(desired []Desired) { + m.mu.Lock() + defer m.mu.Unlock() + + want := make(map[int]struct{}, len(desired)) + for _, d := range desired { + want[d.Instance.Id] = struct{}{} + } + for id, cur := range m.ifaces { + if _, ok := want[id]; ok { + continue + } + cur.dev.Close() + delete(m.ifaces, id) + logger.Infof("amneziawgnet: stopped embedded interface for removed inbound %d", id) + } + for _, d := range desired { + if err := m.ensureLocked(d); err != nil { + logger.Warningf("amneziawgnet: reconcile failed for inbound %d: %v", d.Instance.Id, err) + } + } +} + +// StopAll tears down every managed interface. Called on panel shutdown. +func (m *Manager) StopAll() { + m.mu.Lock() + defer m.mu.Unlock() + for id, cur := range m.ifaces { + cur.dev.Close() + delete(m.ifaces, id) + } +} + +// HasRunning reports whether any embedded interface is currently managed. +func (m *Manager) HasRunning() bool { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.ifaces) > 0 +} + +// Lookup returns the running Device and PeerIndex for inbound id, if any -- +// for a caller that wants to attach its own forwarder/handler (a test +// harness today, the Phase 2 SOCKS5 relay wiring later) once the interface +// is up. +func (m *Manager) Lookup(id int) (dev *Device, peers *PeerIndex, ok bool) { + m.mu.Lock() + defer m.mu.Unlock() + cur, exists := m.ifaces[id] + if !exists { + return nil, nil, false + } + return cur.dev, cur.peers, true +} diff --git a/internal/amneziawgnet/manager_test.go b/internal/amneziawgnet/manager_test.go new file mode 100644 index 000000000..3f2741c2b --- /dev/null +++ b/internal/amneziawgnet/manager_test.go @@ -0,0 +1,85 @@ +package amneziawgnet + +import ( + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" + "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" +) + +// TestManagerLifecycle exercises Ensure/Reconcile's reconfigure-in-place vs. +// rebuild split (see ensureLocked's doc comment) and Reconcile's stop path, +// using a throwaway Manager rather than the process-wide singleton so this +// test doesn't interact with any other test's state. +func TestManagerLifecycle(t *testing.T) { + priv, pub, err := wireguard.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("generate keypair: %v", err) + } + + m := &Manager{ifaces: map[int]*managed{}} + inst := amneziawg.Instance{ + Id: 3, + InterfaceName: "awgtest3", + ListenPort: 58714, + PrivateKey: priv, + PublicKey: pub, + Address: []string{"10.203.0.1/24"}, + MTU: 1420, + Obfuscation: amneziawg.Obfuscation20{ + Jc: 4, Jmin: 40, Jmax: 70, + S1: 20, S2: 30, S3: 20, S4: 20, + }, + } + defer m.StopAll() + + if err := m.Ensure(Desired{Instance: inst}); err != nil { + t.Fatalf("Ensure (create): %v", err) + } + if !m.HasRunning() { + t.Fatal("HasRunning() = false after Ensure created an interface") + } + dev1, _, ok := m.Lookup(inst.Id) + if !ok { + t.Fatal("Lookup after Ensure: not found") + } + + // Same Instance again: same address fingerprint, so this should + // reconfigure the existing Device via IpcSet rather than rebuild it -- + // verify by checking the *Device pointer survived unchanged. + if err := m.Ensure(Desired{Instance: inst}); err != nil { + t.Fatalf("Ensure (unchanged): %v", err) + } + dev2, _, ok := m.Lookup(inst.Id) + if !ok { + t.Fatal("Lookup after second Ensure: not found") + } + if dev1 != dev2 { + t.Error("Ensure with an unchanged Instance rebuilt the Device; expected an in-place reconfigure") + } + + // Changing the interface address is structural (fixed at netstack + // construction time) and must force a rebuild -- verify by checking the + // *Device pointer changed. + changed := inst + changed.Address = []string{"10.203.1.1/24"} + if err := m.Ensure(Desired{Instance: changed}); err != nil { + t.Fatalf("Ensure (address changed): %v", err) + } + dev3, _, ok := m.Lookup(inst.Id) + if !ok { + t.Fatal("Lookup after address-changing Ensure: not found") + } + if dev3 == dev2 { + t.Error("Ensure with a changed address reconfigured in place; expected a rebuild") + } + + // Reconcile with nothing desired stops every managed interface. + m.Reconcile(nil) + if m.HasRunning() { + t.Error("HasRunning() = true after Reconcile([]) should have stopped everything") + } + if _, _, ok := m.Lookup(inst.Id); ok { + t.Error("Lookup succeeded after Reconcile([]) removed the interface") + } +} diff --git a/internal/amneziawgnet/netstack.go b/internal/amneziawgnet/netstack.go new file mode 100644 index 000000000..984ebceaa --- /dev/null +++ b/internal/amneziawgnet/netstack.go @@ -0,0 +1,194 @@ +// Package amneziawgnet embeds amneziawg-go (a userspace AmneziaWG +// implementation, https://github.com/amnezia-vpn/amneziawg-go) directly in +// the panel process, as an alternative to internal/amneziawg's +// kernel-module (DKMS) + awg-quick approach. A gVisor userspace network +// stack (gvisor.dev/gvisor/pkg/tcpip -- already an indirect dependency via +// xray-core's own proxy/wireguard support) terminates each tunnel, and a +// forwarder recovers each connection's real, dynamically-arbitrary +// destination for the caller to relay onward (see Phase 2 of the migration +// plan: a loopback SOCKS5 dial into Xray, giving native stats/routing/ +// sniffing for free). +package amneziawgnet + +import ( + "fmt" + "net/netip" + "os" + "syscall" + + awgtun "github.com/amnezia-vpn/amneziawg-go/v3/tun" + + "gvisor.dev/gvisor/pkg/buffer" + "gvisor.dev/gvisor/pkg/tcpip" + "gvisor.dev/gvisor/pkg/tcpip/header" + "gvisor.dev/gvisor/pkg/tcpip/link/channel" + "gvisor.dev/gvisor/pkg/tcpip/network/ipv4" + "gvisor.dev/gvisor/pkg/tcpip/network/ipv6" + "gvisor.dev/gvisor/pkg/tcpip/stack" + "gvisor.dev/gvisor/pkg/tcpip/transport/icmp" + "gvisor.dev/gvisor/pkg/tcpip/transport/tcp" + "gvisor.dev/gvisor/pkg/tcpip/transport/udp" +) + +// stackTun implements amneziawg-go's tun.Device directly against a gVisor +// channel endpoint, the same approach amneziawg-go's own tun/netstack +// package and xray-core's proxy/wireguard/netstack.go both take. Neither of +// those exposes the raw *stack.Stack a forwarder needs (amneziawg-go's Net +// type keeps it unexported), so this is a local, from-source reimplementation +// rather than a wrapper -- adapted from amneziawg-go v3.0.3's +// tun/netstack/tun.go (MIT licensed), trimmed to the constructor this +// package needs. +type stackTun struct { + ep *channel.Endpoint + stack *stack.Stack + events chan awgtun.Event + notifyHandle *channel.NotificationHandle + incomingPacket chan *buffer.View + mtu int +} + +// createNetTUNWithStack builds a gVisor-backed tun.Device for the given +// local addresses (interface address(es), one per family) and returns the +// underlying *stack.Stack alongside it so a caller can attach a forwarder +// (see forwarder.go / udp.go). +func createNetTUNWithStack(localAddresses []netip.Addr, mtu int) (awgtun.Device, *stack.Stack, error) { + opts := stack.Options{ + NetworkProtocols: []stack.NetworkProtocolFactory{ipv4.NewProtocol, ipv6.NewProtocol}, + TransportProtocols: []stack.TransportProtocolFactory{tcp.NewProtocol, udp.NewProtocol, icmp.NewProtocol6, icmp.NewProtocol4}, + // HandleLocal must stay false: promiscuous+spoofing mode (see + // forwarder.go) is what lets a destination other than the stack's + // own configured address reach the forwarder at all. + HandleLocal: false, + } + dev := &stackTun{ + ep: channel.New(1024, uint32(mtu), ""), + stack: stack.New(opts), + events: make(chan awgtun.Event, 10), + incomingPacket: make(chan *buffer.View), + mtu: mtu, + } + sackEnabledOpt := tcpip.TCPSACKEnabled(true) + if err := dev.stack.SetTransportProtocolOption(tcp.ProtocolNumber, &sackEnabledOpt); err != nil { + return nil, nil, fmt.Errorf("amneziawgnet: enable TCP SACK: %s", err) + } + dev.notifyHandle = dev.ep.AddNotify(dev) + if err := dev.stack.CreateNIC(1, dev.ep); err != nil { + return nil, nil, fmt.Errorf("amneziawgnet: CreateNIC: %s", err) + } + + var hasV4, hasV6 bool + for _, ip := range localAddresses { + var protoNumber tcpip.NetworkProtocolNumber + switch { + case ip.Is4(): + protoNumber = ipv4.ProtocolNumber + hasV4 = true + case ip.Is6(): + protoNumber = ipv6.ProtocolNumber + hasV6 = true + default: + continue + } + protoAddr := tcpip.ProtocolAddress{ + Protocol: protoNumber, + AddressWithPrefix: tcpip.AddrFromSlice(ip.AsSlice()).WithPrefix(), + } + if err := dev.stack.AddProtocolAddress(1, protoAddr, stack.AddressProperties{}); err != nil { + return nil, nil, fmt.Errorf("amneziawgnet: AddProtocolAddress(%v): %s", ip, err) + } + } + if hasV4 { + dev.stack.AddRoute(tcpip.Route{Destination: header.IPv4EmptySubnet, NIC: 1}) + } + if hasV6 { + dev.stack.AddRoute(tcpip.Route{Destination: header.IPv6EmptySubnet, NIC: 1}) + } + dev.events <- awgtun.EventUp + return dev, dev.stack, nil +} + +func (t *stackTun) Name() (string, error) { return "amneziawgnet", nil } +func (t *stackTun) File() *os.File { return nil } +func (t *stackTun) Events() <-chan awgtun.Event { return t.events } +func (t *stackTun) MTU() (int, error) { return t.mtu, nil } +func (t *stackTun) BatchSize() int { return 1 } + +func (t *stackTun) Read(buf [][]byte, sizes []int, offset int) (int, error) { + view, ok := <-t.incomingPacket + if !ok { + return 0, os.ErrClosed + } + n, err := view.Read(buf[0][offset:]) + if err != nil { + return 0, err + } + sizes[0] = n + return 1, nil +} + +func (t *stackTun) Write(buf [][]byte, offset int) (int, error) { + for _, b := range buf { + packet := b[offset:] + if len(packet) == 0 { + continue + } + pkb := stack.NewPacketBuffer(stack.PacketBufferOptions{Payload: buffer.MakeWithData(packet)}) + switch packet[0] >> 4 { + case 4: + t.ep.InjectInbound(header.IPv4ProtocolNumber, pkb) + case 6: + t.ep.InjectInbound(header.IPv6ProtocolNumber, pkb) + default: + return 0, syscall.EAFNOSUPPORT + } + } + return len(buf), nil +} + +func (t *stackTun) WriteNotify() { + pkt := t.ep.Read() + if pkt == nil { + return + } + view := pkt.ToView() + pkt.DecRef() + t.incomingPacket <- view +} + +func (t *stackTun) Close() error { + t.stack.RemoveNIC(1) + t.stack.Close() + t.ep.RemoveNotify(t.notifyHandle) + t.ep.Close() + if t.events != nil { + close(t.events) + } + if t.incomingPacket != nil { + close(t.incomingPacket) + } + return nil +} + +// enablePromiscuousRouting puts the NIC into promiscuous + spoofing mode, +// the precondition both AttachTCPForwarder and AttachUDPHandler need to see +// packets addressed to a destination other than the stack's own configured +// local address. Safe to call from both (and more than once): gVisor's +// SetPromiscuousMode/SetSpoofing just set a bool on the NIC, not something +// that accumulates or needs undoing between calls. +func enablePromiscuousRouting(gstack *stack.Stack) { + gstack.SetPromiscuousMode(1, true) + gstack.SetSpoofing(1, true) +} + +// addrFromTcpip converts a gVisor tcpip.Address (4 or 16 raw bytes) to the +// stdlib netip.Addr type the rest of this package and its callers use. +func addrFromTcpip(a tcpip.Address) netip.Addr { + if a.Len() == 4 { + var b [4]byte + copy(b[:], a.AsSlice()) + return netip.AddrFrom4(b) + } + var b [16]byte + copy(b[:], a.AsSlice()) + return netip.AddrFrom16(b) +} diff --git a/internal/amneziawgnet/udp.go b/internal/amneziawgnet/udp.go new file mode 100644 index 000000000..52814de4e --- /dev/null +++ b/internal/amneziawgnet/udp.go @@ -0,0 +1,100 @@ +package amneziawgnet + +import ( + "fmt" + "net/netip" + + "gvisor.dev/gvisor/pkg/buffer" + "gvisor.dev/gvisor/pkg/tcpip" + "gvisor.dev/gvisor/pkg/tcpip/checksum" + "gvisor.dev/gvisor/pkg/tcpip/header" + "gvisor.dev/gvisor/pkg/tcpip/stack" + "gvisor.dev/gvisor/pkg/tcpip/transport/udp" +) + +// UDPHandler is called for every UDP packet a tunnel client sends, with its +// source (the peer's tunnel-internal address) and its real, +// dynamically-arbitrary destination -- recovered the same way the TCP +// forwarder recovers its destination, from the packet's own transport +// endpoint ID, never from a preconfigured table. The handler owns all flow +// tracking and reply delivery (via WriteUDPReply): gVisor has no +// udp.NewForwarder the way it does for TCP, so unlike AttachTCPForwarder +// this can't just hand back a ready net.Conn. +type UDPHandler func(src, dst netip.AddrPort, payload []byte) + +// AttachUDPHandler attaches a raw UDP handler to gstack, independently +// enabling the same promiscuous+spoofing mode AttachTCPForwarder needs -- +// safe and idempotent to call regardless of whether AttachTCPForwarder was +// attached to the same stack first, or at all. Adapted from xtls/xray-core's +// proxy/wireguard/tun.go UDP path (MIT), which hand-tracks flows for the +// identical reason: gVisor doesn't provide a UDP forwarder. +func AttachUDPHandler(gstack *stack.Stack, handler UDPHandler) { + enablePromiscuousRouting(gstack) + + gstack.SetTransportProtocolHandler(udp.ProtocolNumber, func(id stack.TransportEndpointID, pkt *stack.PacketBuffer) bool { + data := pkt.Clone().Data().AsRange().ToSlice() + src := netip.AddrPortFrom(addrFromTcpip(id.RemoteAddress), id.RemotePort) + dst := netip.AddrPortFrom(addrFromTcpip(id.LocalAddress), id.LocalPort) + handler(src, dst, data) + return true + }) +} + +// WriteUDPReply injects a UDP packet into gstack as if it arrived from +// `from` addressed to `to` -- i.e. a reply travelling back into the tunnel +// toward the client -- constructed by hand since gVisor exposes no +// connected-socket-style Write for an address the stack doesn't itself own. +func WriteUDPReply(gstack *stack.Stack, from, to netip.AddrPort, payload []byte) error { + udpLen := header.UDPMinimumSize + len(payload) + srcIP := tcpip.AddrFromSlice(from.Addr().AsSlice()) + dstIP := tcpip.AddrFromSlice(to.Addr().AsSlice()) + + isIPv4 := from.Addr().Is4() + ipHdrSize := header.IPv6MinimumSize + ipProtocol := header.IPv6ProtocolNumber + if isIPv4 { + ipHdrSize = header.IPv4MinimumSize + ipProtocol = header.IPv4ProtocolNumber + } + + pkt := stack.NewPacketBuffer(stack.PacketBufferOptions{ + ReserveHeaderBytes: ipHdrSize + header.UDPMinimumSize, + Payload: buffer.MakeWithData(payload), + }) + defer pkt.DecRef() + + udpHdr := header.UDP(pkt.TransportHeader().Push(header.UDPMinimumSize)) + udpHdr.Encode(&header.UDPFields{ + SrcPort: from.Port(), + DstPort: to.Port(), + Length: uint16(udpLen), + }) + xsum := header.PseudoHeaderChecksum(header.UDPProtocolNumber, srcIP, dstIP, uint16(udpLen)) + udpHdr.SetChecksum(^udpHdr.CalculateChecksum(checksum.Checksum(payload, xsum))) + + if isIPv4 { + ipHdr := header.IPv4(pkt.NetworkHeader().Push(header.IPv4MinimumSize)) + ipHdr.Encode(&header.IPv4Fields{ + TotalLength: uint16(header.IPv4MinimumSize + udpLen), + TTL: 64, + Protocol: uint8(header.UDPProtocolNumber), + SrcAddr: srcIP, + DstAddr: dstIP, + }) + ipHdr.SetChecksum(^ipHdr.CalculateChecksum()) + } else { + ipHdr := header.IPv6(pkt.NetworkHeader().Push(header.IPv6MinimumSize)) + ipHdr.Encode(&header.IPv6Fields{ + PayloadLength: uint16(udpLen), + TransportProtocol: header.UDPProtocolNumber, + HopLimit: 64, + SrcAddr: srcIP, + DstAddr: dstIP, + }) + } + + if tcpipErr := gstack.WriteRawPacket(1, ipProtocol, buffer.MakeWithView(pkt.ToView())); tcpipErr != nil { + return fmt.Errorf("amneziawgnet: WriteRawPacket: %s", tcpipErr) + } + return nil +} diff --git a/internal/amneziawgnet/udp_test.go b/internal/amneziawgnet/udp_test.go new file mode 100644 index 000000000..8ca31e42f --- /dev/null +++ b/internal/amneziawgnet/udp_test.go @@ -0,0 +1,150 @@ +package amneziawgnet + +import ( + "fmt" + "net/netip" + "testing" + "time" + + awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn" + "github.com/amnezia-vpn/amneziawg-go/v3/device" + "github.com/amnezia-vpn/amneziawg-go/v3/tun/netstack" + + "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" + "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" +) + +// TestNewDeviceUDPHandlerAndReply is the UDP counterpart of +// TestNewDeviceHandshakeForwarderAndIdentity: this package's own udp.go was +// refactored from the Phase 0 spike's bake-the-dial-in version to a generic +// handler-plus-reply-injection design (see AttachUDPHandler/WriteUDPReply's +// doc comments), a real behavior change worth its own verification rather +// than assuming the port preserved correctness -- UDP was flagged as "the +// harder half" in the migration plan's own risk list, precisely because +// gVisor has no udp.NewForwarder and the reply path has to be constructed +// by hand. +func TestNewDeviceUDPHandlerAndReply(t *testing.T) { + serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("generate server keypair: %v", err) + } + clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("generate client keypair: %v", err) + } + + const listenPort = 58713 // distinct from the TCP test's port + const wantEmail = "udp-test-peer@example.com" + const echoPayload = "hello-from-client" + + inst := amneziawg.Instance{ + Id: 2, + InterfaceName: "awgtest2", + ListenPort: listenPort, + PrivateKey: serverPriv, + PublicKey: serverPub, + Address: []string{"10.202.0.1/24"}, + MTU: 1420, + Obfuscation: amneziawg.Obfuscation20{ + Jc: 4, Jmin: 40, Jmax: 70, + S1: 20, S2: 30, S3: 20, S4: 20, + }, + Peers: []amneziawg.Peer{{ + Email: wantEmail, + PublicKey: clientPub, + AllowedIPs: []string{"10.202.0.2/32"}, + }}, + } + + dev, err := NewDevice(inst, DeviceOptions{}) + if err != nil { + t.Fatalf("NewDevice: %v", err) + } + defer dev.Close() + + idx := NewPeerIndex(inst.Peers) + // Never configured anywhere server-side, same idea as the TCP test. + wantDest := netip.MustParseAddrPort("10.202.9.9:5353") + + identityErrCh := make(chan error, 8) + AttachUDPHandler(dev.Stack, func(src, dst netip.AddrPort, payload []byte) { + if peer, ok := idx.Lookup(src.Addr()); !ok || peer.Email != wantEmail { + identityErrCh <- fmt.Errorf("peer identity lookup for src %v: ok=%v email=%q, want %q", src, ok, peer.Email, wantEmail) + return + } + if dst != wantDest { + identityErrCh <- fmt.Errorf("recovered dest = %v, want %v", dst, wantDest) + return + } + // Echo the payload back, posing as a reply from the destination the + // client dialed -- exactly what a real relay's downstream reply + // would look like from the tunnel's point of view. + if err := WriteUDPReply(dev.Stack, dst, src, payload); err != nil { + identityErrCh <- fmt.Errorf("WriteUDPReply: %w", err) + } + }) + + clientTun, clientNet, err := netstack.CreateNetTUN( + []netip.Addr{netip.MustParseAddr("10.202.0.2")}, + []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420) + if err != nil { + t.Fatalf("client CreateNetTUN: %v", err) + } + clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, "")) + defer clientDev.Close() + + clientPrivHex, err := wireguard.KeyToHex(clientPriv) + if err != nil { + t.Fatalf("client key to hex: %v", err) + } + serverPubHex, err := wireguard.KeyToHex(serverPub) + if err != nil { + t.Fatalf("server key to hex: %v", err) + } + clientConf := fmt.Sprintf( + "private_key=%s\njc=4\njmin=40\njmax=70\ns1=20\ns2=30\ns3=20\ns4=20\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n", + clientPrivHex, serverPubHex, listenPort) + if err := clientDev.IpcSet(clientConf); err != nil { + t.Fatalf("client IpcSet: %v", err) + } + if err := clientDev.Up(); err != nil { + t.Fatalf("client Up: %v", err) + } + + conn, err := clientNet.DialUDPAddrPort(netip.AddrPort{}, wantDest) + if err != nil { + t.Fatalf("client DialUDPAddrPort: %v", err) + } + defer conn.Close() + + deadline := time.Now().Add(5 * time.Second) + var buf [256]byte + for { + select { + case err := <-identityErrCh: + t.Fatal(err) + default: + } + + _ = conn.SetWriteDeadline(time.Now().Add(200 * time.Millisecond)) + if _, err := conn.Write([]byte(echoPayload)); err != nil { + if time.Now().After(deadline) { + t.Fatalf("client write never succeeded: %v", err) + } + continue + } + + _ = conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond)) + n, err := conn.Read(buf[:]) + if err != nil { + if time.Now().After(deadline) { + t.Fatalf("client never received a reply: %v", err) + } + continue + } + if got := string(buf[:n]); got != echoPayload { + t.Fatalf("echoed payload = %q, want %q", got, echoPayload) + } + return + } +} From d163e6ac2da9ee5da9420a18453d7d307bfc44a0 Mon Sep 17 00:00:00 2001 From: Kuzz007 Date: Sun, 2 Aug 2026 13:59:13 +0300 Subject: [PATCH 02/12] feat(amneziawg): add SOCKS5 relay for the embedded amneziawg-go path (Phase 2) relay.go relays a recovered tunnel connection into Xray's own stock SOCKS5 inbound, authenticating as the peer's email -- the mechanism that gives embedded AmneziaWG traffic real Xray stats/routing/sniffing with no Xray-core fork. TCP goes through golang.org/x/net/proxy; UDP needed a hand-rolled SOCKS5 UDP ASSOCIATE client since neither that package nor xray-core's own internal socks client expose one. Verified end-to-end against a real xray-core process (gated behind XRAY_E2E_BINARY, matching internal/xray's own e2e test convention): a real TCP and UDP round trip through the whole chain, plus real per-peer stats counters in Xray's own log. Xray-config auto-injection (a real SOCKS5 inbound wired into the generated panel config) is deliberately not part of this commit -- it would require deciding which AmneziaWG inbounds run on the kernel-module path vs. this one, and that's an explicit later decision, not something to back into here. Co-Authored-By: Claude Sonnet 5 --- internal/amneziawgnet/relay.go | 387 +++++++++++++++++++++ internal/amneziawgnet/relay_e2e_test.go | 424 ++++++++++++++++++++++++ 2 files changed, 811 insertions(+) create mode 100644 internal/amneziawgnet/relay.go create mode 100644 internal/amneziawgnet/relay_e2e_test.go diff --git a/internal/amneziawgnet/relay.go b/internal/amneziawgnet/relay.go new file mode 100644 index 000000000..f17b42995 --- /dev/null +++ b/internal/amneziawgnet/relay.go @@ -0,0 +1,387 @@ +// Phase 2: relaying a recovered tunnel connection into Xray's own, +// completely stock SOCKS5 inbound -- authenticating as the owning peer's +// email -- is what gives every embedded AmneziaWG connection real, native +// Xray stats/routing/sniffing with no Xray-core fork at all (Finding 3 of +// the migration plan: a stock SOCKS5 inbound sets its per-connection stats +// identity directly from the SOCKS5 auth username). +package amneziawgnet + +import ( + "encoding/binary" + "encoding/json" + "fmt" + "io" + "net" + "net/netip" + "sync" + "time" + + "golang.org/x/net/proxy" + "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet" + "gvisor.dev/gvisor/pkg/tcpip/stack" + + "github.com/mhsanaei/3x-ui/v3/internal/logger" +) + +// SocksRelay describes the loopback SOCKS5 inbound decapsulated AmneziaWG +// traffic gets relayed into. +type SocksRelay struct { + // Addr is the SOCKS5 inbound's own address, e.g. "127.0.0.1:11500". + Addr string + // Password is shared across every account. This traffic never leaves + // loopback, so the password is not a real secrecy boundary -- it only + // needs to satisfy Xray's SOCKS5 inbound requiring *some* username/ + // password auth before it will accept a connection and use the + // username as the stats identity. Document this reasoning wherever a + // caller generates or displays it, so it's never mistaken later for a + // real credential. + Password string +} + +// SocksInboundSettings builds the JSON `settings` block for a stock Xray +// SOCKS5 inbound with one username/password account per email, all sharing +// password (see SocksRelay's doc comment). udp:true is required: RelayUDP +// depends on the inbound accepting UDP ASSOCIATE, not just CONNECT. +func SocksInboundSettings(emails []string, password string) ([]byte, error) { + type account struct { + User string `json:"user"` + Pass string `json:"pass"` + } + settings := struct { + Auth string `json:"auth"` + UDP bool `json:"udp"` + Accounts []account `json:"accounts"` + }{Auth: "password", UDP: true} + for _, email := range emails { + settings.Accounts = append(settings.Accounts, account{User: email, Pass: password}) + } + return json.Marshal(settings) +} + +// RelayTCP dials r.Addr, authenticates as email, issues a SOCKS5 CONNECT to +// dest, and pipes bytes both ways until either side closes or errors. +// Blocks until the relay ends; meant to be called from (or as) an +// AttachTCPForwarder handler, which already runs each connection on its own +// goroutine. +func (r SocksRelay) RelayTCP(conn *gonet.TCPConn, email string, dest netip.AddrPort) { + defer conn.Close() + + auth := &proxy.Auth{User: email, Password: r.Password} + dialer, err := proxy.SOCKS5("tcp", r.Addr, auth, proxy.Direct) + if err != nil { + logger.Warningf("amneziawgnet: RelayTCP: build SOCKS5 dialer: %v", err) + return + } + upstream, err := dialer.Dial("tcp", dest.String()) + if err != nil { + logger.Warningf("amneziawgnet: RelayTCP: SOCKS5 CONNECT to %s as %q: %v", dest, email, err) + return + } + defer upstream.Close() + + done := make(chan struct{}, 2) + go func() { io.Copy(upstream, conn); done <- struct{}{} }() + go func() { io.Copy(conn, upstream); done <- struct{}{} }() + <-done +} + +// socks5UDPSession is one established SOCKS5 UDP ASSOCIATE session: udpConn +// is the actual socket packets are sent to (and replies read from); ctrl is +// the TCP control connection that must stay open for the session's +// lifetime -- per RFC 1928, closing it tears the association down. +type socks5UDPSession struct { + ctrl net.Conn + udpConn *net.UDPConn +} + +// newSocks5UDPSession performs the SOCKS5 greeting, username/password auth, +// and UDP ASSOCIATE request/reply by hand: golang.org/x/net/proxy's SOCKS5 +// client (used by RelayTCP above) only implements CONNECT, and xray-core's +// own proxy/socks/client.go is written against its internal transport +// types, not reusable as a standalone dialer -- so this is a small, direct, +// from-the-RFC implementation rather than an existing library call. +func newSocks5UDPSession(addr, user, password string) (*socks5UDPSession, error) { + ctrl, err := net.DialTimeout("tcp", addr, 5*time.Second) + if err != nil { + return nil, fmt.Errorf("amneziawgnet: dial SOCKS5 control connection: %w", err) + } + if err := socks5Handshake(ctrl, user, password); err != nil { + ctrl.Close() + return nil, err + } + + // UDP ASSOCIATE, dst 0.0.0.0:0 ("I don't know my own source yet, and I + // don't need to specify one for a loopback relay"). + if _, err := ctrl.Write([]byte{0x05, 0x03, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); err != nil { + ctrl.Close() + return nil, fmt.Errorf("amneziawgnet: send UDP ASSOCIATE request: %w", err) + } + bind, err := readSocks5Reply(ctrl) + if err != nil { + ctrl.Close() + return nil, err + } + + udpConn, err := net.DialUDP("udp", nil, net.UDPAddrFromAddrPort(bind)) + if err != nil { + ctrl.Close() + return nil, fmt.Errorf("amneziawgnet: dial SOCKS5 UDP relay endpoint %s: %w", bind, err) + } + return &socks5UDPSession{ctrl: ctrl, udpConn: udpConn}, nil +} + +// socks5Handshake performs the version greeting and (if the server +// requires it) username/password auth. Xray's SOCKS5 inbound with +// auth:"password" always requires it; the no-auth branch exists so this +// helper isn't silently wrong against a differently-configured server. +func socks5Handshake(conn net.Conn, user, password string) error { + if _, err := conn.Write([]byte{0x05, 0x02, 0x00, 0x02}); err != nil { + return fmt.Errorf("amneziawgnet: send SOCKS5 greeting: %w", err) + } + var resp [2]byte + if _, err := io.ReadFull(conn, resp[:]); err != nil { + return fmt.Errorf("amneziawgnet: read SOCKS5 greeting reply: %w", err) + } + if resp[0] != 0x05 { + return fmt.Errorf("amneziawgnet: unexpected SOCKS5 version %d", resp[0]) + } + switch resp[1] { + case 0x00: // no auth required + return nil + case 0x02: // username/password + req := make([]byte, 0, 3+len(user)+len(password)) + req = append(req, 0x01, byte(len(user))) + req = append(req, user...) + req = append(req, byte(len(password))) + req = append(req, password...) + if _, err := conn.Write(req); err != nil { + return fmt.Errorf("amneziawgnet: send SOCKS5 auth: %w", err) + } + var authResp [2]byte + if _, err := io.ReadFull(conn, authResp[:]); err != nil { + return fmt.Errorf("amneziawgnet: read SOCKS5 auth reply: %w", err) + } + if authResp[1] != 0x00 { + return fmt.Errorf("amneziawgnet: SOCKS5 auth rejected (status %d)", authResp[1]) + } + return nil + default: + return fmt.Errorf("amneziawgnet: SOCKS5 server offered unsupported auth method %d", resp[1]) + } +} + +// readSocks5Reply reads a SOCKS5 reply (the common format shared by CONNECT +// and UDP ASSOCIATE replies) and returns its bound address. +func readSocks5Reply(r io.Reader) (netip.AddrPort, error) { + var hdr [4]byte + if _, err := io.ReadFull(r, hdr[:]); err != nil { + return netip.AddrPort{}, fmt.Errorf("amneziawgnet: read SOCKS5 reply header: %w", err) + } + if hdr[0] != 0x05 { + return netip.AddrPort{}, fmt.Errorf("amneziawgnet: unexpected SOCKS5 reply version %d", hdr[0]) + } + if hdr[1] != 0x00 { + return netip.AddrPort{}, fmt.Errorf("amneziawgnet: SOCKS5 request failed (reply code %d)", hdr[1]) + } + addr, err := readSocks5Addr(r, hdr[3]) + if err != nil { + return netip.AddrPort{}, err + } + var portBytes [2]byte + if _, err := io.ReadFull(r, portBytes[:]); err != nil { + return netip.AddrPort{}, fmt.Errorf("amneziawgnet: read SOCKS5 reply port: %w", err) + } + return netip.AddrPortFrom(addr, binary.BigEndian.Uint16(portBytes[:])), nil +} + +// readSocks5Addr reads the address portion of a SOCKS5 reply for the given +// address type (IPv4, IPv6, or domain -- resolved locally since a loopback +// Xray inbound is not expected to reply with one, but it's cheap to handle +// correctly rather than fail oddly if it ever does). +func readSocks5Addr(r io.Reader, atyp byte) (netip.Addr, error) { + switch atyp { + case 0x01: + var b [4]byte + if _, err := io.ReadFull(r, b[:]); err != nil { + return netip.Addr{}, err + } + return netip.AddrFrom4(b), nil + case 0x04: + var b [16]byte + if _, err := io.ReadFull(r, b[:]); err != nil { + return netip.Addr{}, err + } + return netip.AddrFrom16(b), nil + case 0x03: + var l [1]byte + if _, err := io.ReadFull(r, l[:]); err != nil { + return netip.Addr{}, err + } + name := make([]byte, l[0]) + if _, err := io.ReadFull(r, name); err != nil { + return netip.Addr{}, err + } + resolved, err := net.ResolveIPAddr("ip", string(name)) + if err != nil { + return netip.Addr{}, fmt.Errorf("amneziawgnet: resolve SOCKS5 domain reply %q: %w", name, err) + } + addr, ok := netip.AddrFromSlice(resolved.IP) + if !ok { + return netip.Addr{}, fmt.Errorf("amneziawgnet: unparseable resolved SOCKS5 domain reply address") + } + return addr, nil + default: + return netip.Addr{}, fmt.Errorf("amneziawgnet: unsupported SOCKS5 address type %d", atyp) + } +} + +// Close ends the UDP ASSOCIATE session: closing ctrl tells the SOCKS5 +// server to tear down its relay side too (RFC 1928). +func (s *socks5UDPSession) Close() error { + s.udpConn.Close() + return s.ctrl.Close() +} + +// sendTo wraps payload in a SOCKS5 UDP request header addressed to dest and +// sends it to the session's relay endpoint. +func (s *socks5UDPSession) sendTo(dest netip.AddrPort, payload []byte) error { + hdr := make([]byte, 0, 3+1+16+2+len(payload)) + hdr = append(hdr, 0x00, 0x00, 0x00) // RSV RSV FRAG(=0, no fragmentation) + if dest.Addr().Is4() { + b := dest.Addr().As4() + hdr = append(hdr, 0x01) + hdr = append(hdr, b[:]...) + } else { + b := dest.Addr().As16() + hdr = append(hdr, 0x04) + hdr = append(hdr, b[:]...) + } + var portBytes [2]byte + binary.BigEndian.PutUint16(portBytes[:], dest.Port()) + hdr = append(hdr, portBytes[:]...) + hdr = append(hdr, payload...) + _, err := s.udpConn.Write(hdr) + return err +} + +// receive reads one reply datagram into buf, returning the address the +// SOCKS5 server says it came from and the actual payload (a sub-slice of +// buf -- valid only until the next receive call). +func (s *socks5UDPSession) receive(buf []byte) (netip.AddrPort, []byte, error) { + n, err := s.udpConn.Read(buf) + if err != nil { + return netip.AddrPort{}, nil, err + } + data := buf[:n] + if len(data) < 4 { + return netip.AddrPort{}, nil, fmt.Errorf("amneziawgnet: short SOCKS5 UDP reply (%d bytes)", n) + } + atyp := data[3] + data = data[4:] + addr, err := readSocks5Addr(bytesReader{data}, atyp) + if err != nil { + return netip.AddrPort{}, nil, err + } + switch atyp { + case 0x01: + data = data[4:] + case 0x04: + data = data[16:] + } + if len(data) < 2 { + return netip.AddrPort{}, nil, fmt.Errorf("amneziawgnet: truncated SOCKS5 UDP reply port") + } + port := binary.BigEndian.Uint16(data[:2]) + return netip.AddrPortFrom(addr, port), data[2:], nil +} + +// bytesReader is the minimal io.Reader readSocks5Addr needs, over an +// in-memory slice that's already fully available (a received UDP +// datagram) -- avoids pulling in bytes.Reader just for this. +type bytesReader struct{ b []byte } + +func (r bytesReader) Read(p []byte) (int, error) { + n := copy(p, r.b) + if n < len(p) { + return n, io.ErrUnexpectedEOF + } + return n, nil +} + +// UDPRelay tracks one SOCKS5 UDP ASSOCIATE session per source (tunnel- +// internal client) flow, relaying each into r's SOCKS5 inbound and writing +// replies back through gstack -- the UDP counterpart of RelayTCP, meant to +// be driven by an AttachUDPHandler callback (see udp.go). +type UDPRelay struct { + relay SocksRelay + gstack *stack.Stack + + mu sync.Mutex + sessions map[string]*socks5UDPSession +} + +// NewUDPRelay creates a UDPRelay for one embedded AmneziaWG Device's stack. +func NewUDPRelay(relay SocksRelay, gstack *stack.Stack) *UDPRelay { + return &UDPRelay{relay: relay, gstack: gstack, sessions: map[string]*socks5UDPSession{}} +} + +// Handle relays one packet from src (the peer's tunnel-internal source) to +// dst (its real, recovered destination), opening a fresh SOCKS5 UDP +// ASSOCIATE session for src the first time it's seen (authenticating as +// email, so Xray attributes the whole flow's stats to the right peer) and +// reusing it for subsequent packets from the same src. +func (u *UDPRelay) Handle(src, dst netip.AddrPort, email string, payload []byte) { + u.mu.Lock() + sess, ok := u.sessions[src.String()] + u.mu.Unlock() + + if !ok { + var err error + sess, err = newSocks5UDPSession(u.relay.Addr, email, u.relay.Password) + if err != nil { + logger.Warningf("amneziawgnet: UDPRelay: SOCKS5 associate for %q: %v", email, err) + return + } + u.mu.Lock() + u.sessions[src.String()] = sess + u.mu.Unlock() + go u.pump(src, sess) + } + if err := sess.sendTo(dst, payload); err != nil { + logger.Warningf("amneziawgnet: UDPRelay: send to %s: %v", dst, err) + } +} + +// pump reads replies from sess and writes them back into the tunnel toward +// src until the session errors out or goes idle for 2 minutes, then tears +// it down -- both the map entry and the underlying SOCKS5 association. +func (u *UDPRelay) pump(src netip.AddrPort, sess *socks5UDPSession) { + defer func() { + u.mu.Lock() + delete(u.sessions, src.String()) + u.mu.Unlock() + sess.Close() + }() + buf := make([]byte, 65536) + for { + _ = sess.udpConn.SetReadDeadline(time.Now().Add(2 * time.Minute)) + from, payload, err := sess.receive(buf) + if err != nil { + return + } + if err := WriteUDPReply(u.gstack, from, src, payload); err != nil { + logger.Warningf("amneziawgnet: UDPRelay: reply write: %v", err) + } + } +} + +// Close tears down every open session. Call when the owning Device is +// closed. +func (u *UDPRelay) Close() { + u.mu.Lock() + defer u.mu.Unlock() + for k, s := range u.sessions { + s.Close() + delete(u.sessions, k) + } +} diff --git a/internal/amneziawgnet/relay_e2e_test.go b/internal/amneziawgnet/relay_e2e_test.go new file mode 100644 index 000000000..b312438a2 --- /dev/null +++ b/internal/amneziawgnet/relay_e2e_test.go @@ -0,0 +1,424 @@ +package amneziawgnet + +import ( + "encoding/json" + "fmt" + "net" + "net/netip" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn" + "github.com/amnezia-vpn/amneziawg-go/v3/device" + "github.com/amnezia-vpn/amneziawg-go/v3/tun/netstack" + "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet" + + "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" + "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" +) + +// TestSocksRelayAgainstRealXray is Phase 2's real end-to-end proof: a +// genuine amneziawg-go client completes a real handshake against a Device +// built by NewDevice, dials a real TCP echo server and sends a real UDP +// echo datagram, and this package's own AttachTCPForwarder/AttachUDPHandler +// handlers relay both through RelayTCP/UDPRelay into an *actual xray-core +// process* (not a mock) running a SOCKS5 inbound built by +// SocksInboundSettings. Verifies real data round-trips on both protocols, +// then greps the real process's own debug log for +// "user>>>{email}>>>traffic>>>{up,down}link" -- the same proof Finding 3 of +// the migration plan established manually in Phase 0, now permanent, +// repo-owned test infrastructure. The UDP half in particular is the first +// real test of this package's hand-rolled SOCKS5 UDP ASSOCIATE client +// (relay.go) against an independent, authoritative implementation of the +// protocol rather than a mock this same session wrote. +// +// Skipped unless XRAY_E2E_BINARY points at an xray executable built from +// the same xray-core version as go.mod, matching internal/xray's own +// TestXrayAPI_E2E convention: +// +// go install github.com/xtls/xray-core/main@ +// XRAY_E2E_BINARY=$GOBIN/main go test ./internal/amneziawgnet -run TestSocksRelayAgainstRealXray -v +func TestSocksRelayAgainstRealXray(t *testing.T) { + bin := os.Getenv("XRAY_E2E_BINARY") + if bin == "" { + t.Skip("set XRAY_E2E_BINARY to an xray binary to run this test") + } + + localIP, ok := firstNonLoopbackIPv4() + if !ok { + t.Skip("no non-loopback IPv4 address available on this host") + } + + const wantEmail = "e2e-peer@example.com" + const socksPassword = "loopback-only-not-a-real-secret" + + // --- real TCP + UDP echo servers on a real, non-loopback address --- + // (dialing 127.0.0.1 as a tunnel-internal destination hangs -- gVisor + // won't route loopback out an arbitrary NIC -- so the client dials + // localIP instead; it must still be a *real* address since the actual + // relay leg is a genuine OS-level dial from the xray-core process, not + // anything inside the tunnel's virtual netstack.) + tcpEcho, tcpEchoAddr := startTCPEcho(t, localIP) + defer tcpEcho.Close() + udpEcho, udpEchoAddr := startUDPEcho(t, localIP) + defer udpEcho.Close() + + // --- real embedded AmneziaWG server + client, same shape as Phase 1's tests --- + serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("generate server keypair: %v", err) + } + clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("generate client keypair: %v", err) + } + + const listenPort = 58715 + inst := amneziawg.Instance{ + Id: 4, + InterfaceName: "awgtest4", + ListenPort: listenPort, + PrivateKey: serverPriv, + PublicKey: serverPub, + Address: []string{"10.204.0.1/24"}, + MTU: 1420, + Obfuscation: amneziawg.Obfuscation20{ + Jc: 4, Jmin: 40, Jmax: 70, + S1: 20, S2: 30, S3: 20, S4: 20, + }, + Peers: []amneziawg.Peer{{ + Email: wantEmail, + PublicKey: clientPub, + AllowedIPs: []string{"10.204.0.2/32"}, + }}, + } + dev, err := NewDevice(inst, DeviceOptions{}) + if err != nil { + t.Fatalf("NewDevice: %v", err) + } + defer dev.Close() + idx := NewPeerIndex(inst.Peers) + + // --- real xray-core process with a SOCKS5 inbound built by this package --- + socksPort := freePort(t) + settingsJSON, err := SocksInboundSettings([]string{wantEmail}, socksPassword) + if err != nil { + t.Fatalf("SocksInboundSettings: %v", err) + } + var rawSettings any + if err := json.Unmarshal(settingsJSON, &rawSettings); err != nil { + t.Fatalf("unmarshal generated SOCKS5 settings: %v", err) + } + xrayCfg := map[string]any{ + "log": map[string]any{"loglevel": "debug"}, + "inbounds": []any{ + map[string]any{ + "listen": "127.0.0.1", + "port": socksPort, + "protocol": "socks", + "settings": rawSettings, + "tag": "awg-e2e-socks", + }, + }, + "outbounds": []any{ + map[string]any{"protocol": "freedom", "settings": map[string]any{}, "tag": "direct"}, + }, + "policy": map[string]any{ + "levels": map[string]any{ + "0": map[string]any{"statsUserUplink": true, "statsUserDownlink": true}, + }, + }, + "stats": map[string]any{}, + } + cfgBytes, err := json.MarshalIndent(xrayCfg, "", " ") + if err != nil { + t.Fatalf("marshal xray config: %v", err) + } + cfgPath := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(cfgPath, cfgBytes, 0o644); err != nil { + t.Fatalf("write xray config: %v", err) + } + + var xrayLog syncBuffer + cmd := exec.Command(bin, "-c", cfgPath) + cmd.Stdout = &xrayLog + cmd.Stderr = &xrayLog + if err := cmd.Start(); err != nil { + t.Fatalf("start xray: %v", err) + } + defer func() { + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + }() + waitForPort(t, socksPort) + + socksAddr := fmt.Sprintf("127.0.0.1:%d", socksPort) + relay := SocksRelay{Addr: socksAddr, Password: socksPassword} + udpRelay := NewUDPRelay(relay, dev.Stack) + defer udpRelay.Close() + + AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) { + srcAddrPort, err := netip.ParseAddrPort(conn.RemoteAddr().String()) + if err != nil { + conn.Close() + return + } + peer, ok := idx.Lookup(srcAddrPort.Addr().Unmap()) + if !ok { + conn.Close() + return + } + relay.RelayTCP(conn, peer.Email, dest) + }) + AttachUDPHandler(dev.Stack, func(src, dst netip.AddrPort, payload []byte) { + peer, ok := idx.Lookup(src.Addr()) + if !ok { + return + } + udpRelay.Handle(src, dst, peer.Email, payload) + }) + + // --- real client, real handshake, real traffic through the whole chain --- + clientTun, clientNet, err := netstack.CreateNetTUN( + []netip.Addr{netip.MustParseAddr("10.204.0.2")}, + []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420) + if err != nil { + t.Fatalf("client CreateNetTUN: %v", err) + } + clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, "")) + defer clientDev.Close() + + clientPrivHex, err := wireguard.KeyToHex(clientPriv) + if err != nil { + t.Fatalf("client key to hex: %v", err) + } + serverPubHex, err := wireguard.KeyToHex(serverPub) + if err != nil { + t.Fatalf("server key to hex: %v", err) + } + clientConf := fmt.Sprintf( + "private_key=%s\njc=4\njmin=40\njmax=70\ns1=20\ns2=30\ns3=20\ns4=20\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n", + clientPrivHex, serverPubHex, listenPort) + if err := clientDev.IpcSet(clientConf); err != nil { + t.Fatalf("client IpcSet: %v", err) + } + if err := clientDev.Up(); err != nil { + t.Fatalf("client Up: %v", err) + } + + // TCP round trip. + const tcpMsg = "hello over amneziawgnet+socks5+xray" + dialDeadline := time.Now().Add(10 * time.Second) + var tcpConn interface { + Write([]byte) (int, error) + Read([]byte) (int, error) + Close() error + } + for { + c, dialErr := clientNet.DialContext(t.Context(), "tcp", tcpEchoAddr.String()) + if dialErr == nil { + tcpConn = c + break + } + if time.Now().After(dialDeadline) { + t.Fatalf("client TCP dial via tunnel never succeeded: %v", dialErr) + } + time.Sleep(150 * time.Millisecond) + } + defer tcpConn.Close() + if _, err := tcpConn.Write([]byte(tcpMsg)); err != nil { + t.Fatalf("client TCP write: %v", err) + } + tcpBuf := make([]byte, len(tcpMsg)) + if _, err := readFull(tcpConn, tcpBuf, 10*time.Second); err != nil { + t.Fatalf("client TCP read: %v", err) + } + if string(tcpBuf) != tcpMsg { + t.Errorf("TCP echo = %q, want %q", tcpBuf, tcpMsg) + } + + // UDP round trip. + const udpMsg = "hello-udp-over-socks5" + uconn, err := clientNet.DialUDPAddrPort(netip.AddrPort{}, udpEchoAddr) + if err != nil { + t.Fatalf("client DialUDPAddrPort: %v", err) + } + defer uconn.Close() + udpDeadline := time.Now().Add(10 * time.Second) + var udpBuf [256]byte + var gotUDP string + for time.Now().Before(udpDeadline) { + _ = uconn.SetWriteDeadline(time.Now().Add(300 * time.Millisecond)) + if _, err := uconn.Write([]byte(udpMsg)); err != nil { + continue + } + _ = uconn.SetReadDeadline(time.Now().Add(300 * time.Millisecond)) + n, err := uconn.Read(udpBuf[:]) + if err == nil { + gotUDP = string(udpBuf[:n]) + break + } + } + if gotUDP != udpMsg { + t.Fatalf("UDP echo = %q, want %q (xray log follows)\n%s", gotUDP, udpMsg, xrayLog.String()) + } + + // Real per-peer stats attribution: stop xray so its log is complete, then + // look for both directions' counters keyed by the peer's real email -- + // the exact proof Finding 3 established manually in Phase 0. + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + log := xrayLog.String() + wantUp := fmt.Sprintf("user>>>%s>>>traffic>>>uplink", wantEmail) + wantDown := fmt.Sprintf("user>>>%s>>>traffic>>>downlink", wantEmail) + if !strings.Contains(log, wantUp) { + t.Errorf("xray log missing uplink stats counter %q\nfull log:\n%s", wantUp, log) + } + if !strings.Contains(log, wantDown) { + t.Errorf("xray log missing downlink stats counter %q\nfull log:\n%s", wantDown, log) + } +} + +// firstNonLoopbackIPv4 finds a real, locally-bound IPv4 address suitable as +// a relay-reachable test destination. +func firstNonLoopbackIPv4() (netip.Addr, bool) { + addrs, err := net.InterfaceAddrs() + if err != nil { + return netip.Addr{}, false + } + for _, a := range addrs { + ipNet, ok := a.(*net.IPNet) + if !ok || ipNet.IP.IsLoopback() { + continue + } + if v4 := ipNet.IP.To4(); v4 != nil { + addr, ok := netip.AddrFromSlice(v4) + if ok { + return addr, true + } + } + } + return netip.Addr{}, false +} + +func startTCPEcho(t *testing.T, addr netip.Addr) (io interface{ Close() error }, ap netip.AddrPort) { + t.Helper() + ln, err := net.Listen("tcp", net.JoinHostPort(addr.String(), "0")) + if err != nil { + t.Fatalf("start TCP echo listener: %v", err) + } + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + go func() { + defer c.Close() + buf := make([]byte, 4096) + for { + n, err := c.Read(buf) + if n > 0 { + if _, werr := c.Write(buf[:n]); werr != nil { + return + } + } + if err != nil { + return + } + } + }() + } + }() + port := ln.Addr().(*net.TCPAddr).Port + return ln, netip.AddrPortFrom(addr, uint16(port)) +} + +func startUDPEcho(t *testing.T, addr netip.Addr) (io interface{ Close() error }, ap netip.AddrPort) { + t.Helper() + pc, err := net.ListenPacket("udp", net.JoinHostPort(addr.String(), "0")) + if err != nil { + t.Fatalf("start UDP echo listener: %v", err) + } + go func() { + buf := make([]byte, 4096) + for { + n, raddr, err := pc.ReadFrom(buf) + if err != nil { + return + } + if _, err := pc.WriteTo(buf[:n], raddr); err != nil { + return + } + } + }() + port := pc.LocalAddr().(*net.UDPAddr).Port + return pc, netip.AddrPortFrom(addr, uint16(port)) +} + +// readFull reads exactly len(buf) bytes or fails after timeout, since +// gonet.TCPConn (and net.Conn generally) may return short reads. +func readFull(r interface{ Read([]byte) (int, error) }, buf []byte, timeout time.Duration) (int, error) { + deadline := time.Now().Add(timeout) + total := 0 + for total < len(buf) { + if time.Now().After(deadline) { + return total, fmt.Errorf("timed out after reading %d/%d bytes", total, len(buf)) + } + n, err := r.Read(buf[total:]) + total += n + if err != nil { + return total, err + } + } + return total, nil +} + +// syncBuffer is a concurrency-safe bytes buffer for capturing a subprocess's +// combined stdout/stderr while the test may read it from another goroutine. +type syncBuffer struct { + mu sync.Mutex + buf strings.Builder +} + +func (s *syncBuffer) Write(p []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.buf.Write(p) +} + +func (s *syncBuffer) String() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.buf.String() +} + +func freePort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer l.Close() + return l.Addr().(*net.TCPAddr).Port +} + +func waitForPort(t *testing.T, port int) { + t.Helper() + deadline := time.Now().Add(15 * time.Second) + addr := fmt.Sprintf("127.0.0.1:%d", port) + for time.Now().Before(deadline) { + conn, err := net.DialTimeout("tcp", addr, time.Second) + if err == nil { + conn.Close() + return + } + time.Sleep(200 * time.Millisecond) + } + t.Fatalf("xray port %d did not open in time", port) +} From 3450d872d92f78466f39bdefaceb35cb1047cbc3 Mon Sep 17 00:00:00 2001 From: Kuzz007 Date: Sun, 2 Aug 2026 14:33:35 +0300 Subject: [PATCH 03/12] feat(amneziawg): replace the TPROXY bridge with a SOCKS5 relay inbound (Phase 3 start) Hard cutover, part 1: injectAmneziawgnetSocks replaces injectAmneziawgEgress as the AmneziaWG-side Xray config injector. Every enabled AmneziaWG inbound now gets an always-on loopback SOCKS5 inbound (built by amneziawgnet.SocksInboundSettings) instead of an opt-in dokodemo-door TPROXY bridge -- there's no RouteThroughXray gate anymore since the embedded path has no alternative datapath once traffic is decapsulated in gVisor. Reuses the real inbound's own tag, same as before, so per-inbound stats totals keep matching. internal/amneziawgnet gains SOCKSPortForInbound (deterministic port derivation, its own range distinct from the kernel-module bridge's) and SocksPassword (a process-wide, lazily-generated, not-persisted password -- this traffic never leaves loopback). port_conflict.go's port-reservation check is updated to match: the new SOCKS5 relay port is reserved unconditionally for every qualifying AmneziaWG inbound, not gated on RouteThroughXray. Not yet done (tracked in the migration plan): swapping the actual manager call sites (cron job, immediate-apply CRUD, shutdown) from the kernel-module Manager to amneziawgnet's, and deleting the now-dead TPROXY/awg-quick code. This commit could not be locally verified beyond internal/amneziawgnet itself (this machine has no C compiler, so internal/database and anything that imports it -- including internal/web/service -- can't be built or vetted here); pushing for real CI feedback before continuing. Co-Authored-By: Claude Sonnet 5 --- internal/amneziawgnet/socks_config.go | 55 +++++++ internal/web/service/port_conflict.go | 41 ++--- internal/web/service/port_conflict_test.go | 102 ++++++++----- internal/web/service/xray.go | 144 ++++++++---------- .../web/service/xray_config_inject_test.go | 74 ++++----- 5 files changed, 249 insertions(+), 167 deletions(-) create mode 100644 internal/amneziawgnet/socks_config.go diff --git a/internal/amneziawgnet/socks_config.go b/internal/amneziawgnet/socks_config.go new file mode 100644 index 000000000..da8c7d0f8 --- /dev/null +++ b/internal/amneziawgnet/socks_config.go @@ -0,0 +1,55 @@ +package amneziawgnet + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "sync" +) + +// SOCKSBasePort is the first loopback port used for an AmneziaWG inbound's +// own Xray SOCKS5 relay inbound (see relay.go/SocksInboundSettings). Its own +// range, distinct from amneziawg.EgressBasePort (63100, the kernel-module +// path's TPROXY bridge port) so the two can never collide even if both +// happen to be reachable during a transition. +const SOCKSBasePort = 65100 + +// SOCKSPortForInbound returns the loopback port of one AmneziaWG inbound's +// own Xray SOCKS5 relay inbound, derived deterministically from its id so +// the config-generation code (which builds the inbound) and the relay code +// (which dials it) never have to agree on a runtime-negotiated value -- +// mirrors amneziawg.EgressPortForInbound's own reasoning exactly. +func SOCKSPortForInbound(inboundID int) int { + return SOCKSBasePort + inboundID +} + +var ( + socksPasswordOnce sync.Once + socksPassword string +) + +// SocksPassword returns the process-wide password used to authenticate into +// every AmneziaWG SOCKS5 relay inbound, generating and caching it once +// (lazily, on first use) rather than persisting it anywhere: this traffic +// never leaves loopback, both the config generator (SocksInboundSettings' +// caller) and the relay dialer (SocksRelay/UDPRelay) live in this same +// process, and Xray's own generated config is already rebuilt from scratch +// on every reconcile -- there is nothing for a stored value to survive +// across that a fresh one wouldn't equally satisfy. Not a real secret (see +// SocksRelay's own doc comment); this only needs to be unpredictable enough +// that nothing outside this process could plausibly guess it and dial in +// over loopback. +func SocksPassword() string { + socksPasswordOnce.Do(func() { + var b [24]byte + if _, err := rand.Read(b[:]); err != nil { + // crypto/rand failing is effectively unrecoverable for a + // process that generates real WireGuard keys elsewhere too; + // a fixed fallback keeps this from panicking outright. + socksPassword = fmt.Sprintf("amneziawgnet-fallback-%x", b) + return + } + socksPassword = base64.RawURLEncoding.EncodeToString(b[:]) + }) + return socksPassword +} diff --git a/internal/web/service/port_conflict.go b/internal/web/service/port_conflict.go index eda27ef76..80c6f81ff 100644 --- a/internal/web/service/port_conflict.go +++ b/internal/web/service/port_conflict.go @@ -6,6 +6,7 @@ import ( "strings" "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/util/common" @@ -177,15 +178,15 @@ func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int) } // Every enabled local AmneziaWG inbound gets its own automatic Xray - // bridge (see injectAmneziawgEgress) on 127.0.0.1 at a port derived - // purely from its id (amneziawg.EgressPortForInbound) -- like the - // internal Xray API inbound above, that bridge is not itself a database - // row, so the ordinary DB-backed query below can never see it. Without - // this check, an unrelated inbound saved onto that exact port silently - // fails at the next Xray start, taking every other protocol down with - // it, not just AmneziaWG. + // SOCKS5 relay inbound (see injectAmneziawgnetSocks) on 127.0.0.1 at a + // port derived purely from its id (amneziawgnet.SOCKSPortForInbound) -- + // like the internal Xray API inbound above, that relay inbound is not + // itself a database row, so the ordinary DB-backed query below can never + // see it. Without this check, an unrelated inbound saved onto that exact + // port silently fails at the next Xray start, taking every other + // protocol down with it, not just AmneziaWG. if inbound.NodeID == nil && listenOverlaps("127.0.0.1", inbound.Listen) { - conflict, err := s.checkAmneziawgEgressConflict(inbound, ignoreId, newBits) + conflict, err := s.checkAmneziawgnetSocksConflict(inbound, ignoreId, newBits) if err != nil { return nil, err } @@ -229,15 +230,16 @@ func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int) return nil, nil } -// checkAmneziawgEgressConflict reports whether inbound's own port collides -// with an existing, enabled local AmneziaWG inbound's automatic Xray bridge -// port. Only inbounds that actually have RouteThroughXray on ever get a -// bridge (see injectAmneziawgEgress); the others' "reserved" port isn't -// really reserved, so they must not be flagged. ignoreId excludes one -// inbound id from the AmneziaWG candidates, the same way the general -// DB-backed conflict query above excludes the inbound being edited from -// matching itself. -func (s *InboundService) checkAmneziawgEgressConflict(inbound *model.Inbound, ignoreId int, newBits transportBits) (*portConflictDetail, error) { +// checkAmneziawgnetSocksConflict reports whether inbound's own port +// collides with an existing, enabled local AmneziaWG inbound's automatic +// Xray SOCKS5 relay port. Unlike the retired kernel-module bridge this +// checks every qualifying AmneziaWG inbound unconditionally: the embedded +// relay has no RouteThroughXray-style opt-in, every one of them gets a +// relay inbound (see injectAmneziawgnetSocks). ignoreId excludes one inbound +// id from the AmneziaWG candidates, the same way the general DB-backed +// conflict query above excludes the inbound being edited from matching +// itself. +func (s *InboundService) checkAmneziawgnetSocksConflict(inbound *model.Inbound, ignoreId int, newBits transportBits) (*portConflictDetail, error) { db := database.GetDB() var candidates []*model.Inbound q := db.Model(model.Inbound{}).Where("protocol = ? AND enable = ? AND node_id IS NULL", model.AmneziaWG, true) @@ -248,11 +250,10 @@ func (s *InboundService) checkAmneziawgEgressConflict(inbound *model.Inbound, ig return nil, err } for _, c := range candidates { - inst, ok := amneziawg.InstanceFromInbound(c) - if !ok || !inst.RouteThroughXray { + if _, ok := amneziawg.InstanceFromInbound(c); !ok { continue } - if amneziawg.EgressPortForInbound(c.Id) != inbound.Port { + if amneziawgnet.SOCKSPortForInbound(c.Id) != inbound.Port { continue } return &portConflictDetail{ diff --git a/internal/web/service/port_conflict_test.go b/internal/web/service/port_conflict_test.go index df9dd0103..7669a2f7c 100644 --- a/internal/web/service/port_conflict_test.go +++ b/internal/web/service/port_conflict_test.go @@ -8,7 +8,7 @@ import ( "github.com/op/go-logging" - "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" xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger" @@ -732,16 +732,18 @@ func TestCheckPortConflict_ReservedAPIPortUDPCoexists(t *testing.T) { } // amneziawgRoutedSettings builds a minimal but complete AmneziaWG settings -// blob with one qualifying, enabled peer and RouteThroughXray on -- the -// shape that actually makes injectAmneziawgEgress (and therefore -// checkAmneziawgEgressConflict) create a bridge at all. +// blob with one qualifying, enabled peer -- the shape that makes +// injectAmneziawgnetSocks (and therefore checkAmneziawgnetSocksConflict) +// create a relay inbound at all. The routeThroughXray field is kept in the +// JSON (a stale value from a pre-cutover install) specifically to prove +// it's now ignored -- see the "RouteThroughXrayOff" test below. const amneziawgRoutedSettings = `{"server":{"privateKey":"priv","publicKey":"pub","subnetIp":"10.8.1.0","subnetCidr":24,"routeThroughXray":true},"clients":[{"email":"a@x","enable":true,"publicKey":"pub-a","allowedIPs":["10.8.1.2/32"]}]}` -// An enabled AmneziaWG inbound's automatic Xray bridge (injectAmneziawgEgress) -// is a synthetic loopback dokodemo-door inbound, not a database row, so -// checkPortConflict needs its own check to catch a collision -- exactly the -// same shape of problem as the reserved API port above. -func TestCheckPortConflict_AmneziawgEgressBridgeBlockedLocal(t *testing.T) { +// An enabled AmneziaWG inbound's automatic Xray SOCKS5 relay inbound +// (injectAmneziawgnetSocks) is a synthetic loopback inbound, not a database +// row, so checkPortConflict needs its own check to catch a collision -- +// exactly the same shape of problem as the reserved API port above. +func TestCheckPortConflict_AmneziawgnetSocksRelayBlockedLocal(t *testing.T) { setupConflictDB(t) seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, amneziawgRoutedSettings) @@ -749,13 +751,13 @@ func TestCheckPortConflict_AmneziawgEgressBridgeBlockedLocal(t *testing.T) { if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil { t.Fatalf("read seeded row: %v", err) } - bridgePort := amneziawg.EgressPortForInbound(awgInbound.Id) + relayPort := amneziawgnet.SOCKSPortForInbound(awgInbound.Id) svc := &InboundService{} candidate := &model.Inbound{ Tag: "vless-bridge", Listen: "0.0.0.0", - Port: bridgePort, + Port: relayPort, Protocol: model.VLESS, } got, err := svc.checkPortConflict(candidate, 0) @@ -763,7 +765,7 @@ func TestCheckPortConflict_AmneziawgEgressBridgeBlockedLocal(t *testing.T) { t.Fatalf("checkPortConflict: %v", err) } if got == nil { - t.Fatalf("a local inbound on the AmneziaWG bridge port %d must conflict", bridgePort) + t.Fatalf("a local inbound on the AmneziaWG relay port %d must conflict", relayPort) } if msg := got.String(); !strings.Contains(msg, "awg-1") { t.Fatalf("conflict message should name the owning AmneziaWG inbound; got %q", msg) @@ -771,9 +773,9 @@ func TestCheckPortConflict_AmneziawgEgressBridgeBlockedLocal(t *testing.T) { } // Nodes run their own Xray, so a node inbound landing on the central panel's -// AmneziaWG bridge port must be allowed -- the bridge only ever binds +// AmneziaWG relay port must be allowed -- the relay inbound only ever binds // 127.0.0.1 on the local panel's own Xray. -func TestCheckPortConflict_AmneziawgEgressBridgeAllowedOnNode(t *testing.T) { +func TestCheckPortConflict_AmneziawgnetSocksRelayAllowedOnNode(t *testing.T) { setupConflictDB(t) seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, amneziawgRoutedSettings) @@ -781,37 +783,37 @@ func TestCheckPortConflict_AmneziawgEgressBridgeAllowedOnNode(t *testing.T) { if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil { t.Fatalf("read seeded row: %v", err) } - bridgePort := amneziawg.EgressPortForInbound(awgInbound.Id) + relayPort := amneziawgnet.SOCKSPortForInbound(awgInbound.Id) svc := &InboundService{} candidate := &model.Inbound{ Tag: "node-bridge", Listen: "0.0.0.0", - Port: bridgePort, + Port: relayPort, Protocol: model.VLESS, NodeID: new(1), } if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil { - t.Fatalf("a node inbound on the local AmneziaWG bridge port must be allowed; got=%v err=%v", got, err) + t.Fatalf("a node inbound on the local AmneziaWG relay port must be allowed; got=%v err=%v", got, err) } } -// A disabled AmneziaWG inbound never gets a bridge injected -// (injectAmneziawgEgress skips !inbound.Enable), so its "reserved" port must -// not block anything. -func TestCheckPortConflict_AmneziawgEgressBridgeIgnoredWhenDisabled(t *testing.T) { +// A disabled AmneziaWG inbound never gets a relay inbound injected +// (injectAmneziawgnetSocks skips !inbound.Enable), so its "reserved" port +// must not block anything. +func TestCheckPortConflict_AmneziawgnetSocksRelayIgnoredWhenDisabled(t *testing.T) { setupConflictDB(t) awg := &model.Inbound{Tag: "awg-1", Enable: false, Listen: "0.0.0.0", Port: 51820, Protocol: model.AmneziaWG, Settings: `{}`} if err := database.GetDB().Create(awg).Error; err != nil { t.Fatalf("seed disabled awg inbound: %v", err) } - bridgePort := amneziawg.EgressPortForInbound(awg.Id) + relayPort := amneziawgnet.SOCKSPortForInbound(awg.Id) svc := &InboundService{} candidate := &model.Inbound{ Tag: "vless-bridge", Listen: "0.0.0.0", - Port: bridgePort, + Port: relayPort, Protocol: model.VLESS, } if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil { @@ -819,11 +821,41 @@ func TestCheckPortConflict_AmneziawgEgressBridgeIgnoredWhenDisabled(t *testing.T } } -// An enabled AmneziaWG inbound with RouteThroughXray off never gets a bridge -// injected either (injectAmneziawgEgress requires it), so its port isn't -// reserved -- an inbound created with the default settings, not just an -// explicitly disabled one, must not block anything. -func TestCheckPortConflict_AmneziawgEgressBridgeIgnoredWhenRouteThroughXrayOff(t *testing.T) { +// Unlike the retired kernel-module bridge, the embedded relay has no +// RouteThroughXray-style opt-in -- every qualifying AmneziaWG inbound +// reserves its relay port regardless of that (now-vestigial) field's value, +// including a stale routeThroughXray:true left over from a pre-cutover +// install (amneziawgRoutedSettings). +func TestCheckPortConflict_AmneziawgnetSocksRelayReservedRegardlessOfLegacyRouteThroughXrayField(t *testing.T) { + setupConflictDB(t) + seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, `{"server":{"privateKey":"priv","publicKey":"pub","subnetIp":"10.8.1.0","subnetCidr":24},"clients":[{"email":"a@x","enable":true,"publicKey":"pub-a","allowedIPs":["10.8.1.2/32"]}]}`) + + var awgInbound model.Inbound + if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil { + t.Fatalf("read seeded row: %v", err) + } + relayPort := amneziawgnet.SOCKSPortForInbound(awgInbound.Id) + + svc := &InboundService{} + candidate := &model.Inbound{ + Tag: "vless-bridge", + Listen: "0.0.0.0", + Port: relayPort, + Protocol: model.VLESS, + } + got, err := svc.checkPortConflict(candidate, 0) + if err != nil { + t.Fatalf("checkPortConflict: %v", err) + } + if got == nil { + t.Fatalf("an enabled, qualifying AmneziaWG inbound must reserve its relay port even with RouteThroughXray left at its default") + } +} + +// A qualifying AmneziaWG inbound with no enabled/valid peer at all never +// gets a relay inbound (amneziawg.InstanceFromInbound returns ok=false), so +// its port isn't reserved. +func TestCheckPortConflict_AmneziawgnetSocksRelayIgnoredWhenNoQualifyingPeer(t *testing.T) { setupConflictDB(t) seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, `{}`) @@ -831,24 +863,24 @@ func TestCheckPortConflict_AmneziawgEgressBridgeIgnoredWhenRouteThroughXrayOff(t if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil { t.Fatalf("read seeded row: %v", err) } - bridgePort := amneziawg.EgressPortForInbound(awgInbound.Id) + relayPort := amneziawgnet.SOCKSPortForInbound(awgInbound.Id) svc := &InboundService{} candidate := &model.Inbound{ Tag: "vless-bridge", Listen: "0.0.0.0", - Port: bridgePort, + Port: relayPort, Protocol: model.VLESS, } if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil { - t.Fatalf("an AmneziaWG inbound with RouteThroughXray off must not reserve its bridge port; got=%v err=%v", got, err) + t.Fatalf("an AmneziaWG inbound with no qualifying peer must not reserve its relay port; got=%v err=%v", got, err) } } -// An unrelated port never conflicts with the bridge. -func TestCheckPortConflict_AmneziawgEgressBridgeDifferentPortAllowed(t *testing.T) { +// An unrelated port never conflicts with the relay inbound. +func TestCheckPortConflict_AmneziawgnetSocksRelayDifferentPortAllowed(t *testing.T) { setupConflictDB(t) - seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, `{}`) + seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, amneziawgRoutedSettings) svc := &InboundService{} candidate := &model.Inbound{ @@ -858,6 +890,6 @@ func TestCheckPortConflict_AmneziawgEgressBridgeDifferentPortAllowed(t *testing. Protocol: model.VLESS, } if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil { - t.Fatalf("an unrelated port must not conflict with the AmneziaWG bridge; got=%v err=%v", got, err) + t.Fatalf("an unrelated port must not conflict with the AmneziaWG relay inbound; got=%v err=%v", got, err) } } diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go index 2eccd35a5..fd76ed855 100644 --- a/internal/web/service/xray.go +++ b/internal/web/service/xray.go @@ -11,6 +11,7 @@ import ( "sync" "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" + "github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet" "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" @@ -367,14 +368,16 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) { injectMtprotoEgress(xrayConfig, inbound) } - // Route opted-in AmneziaWG peers through the core's router. Unlike mtg, - // AmneziaWG has no sidecar process of its own making outbound connections - // to dial through a bridge — it's a kernel tunnel interface, so the host - // side (internal/amneziawg's defaultPostUpDown) TPROXYs each opted-in - // peer's traffic to one loopback bridge shared by every AmneziaWG - // instance; this call is what creates that bridge and, per peer, the - // routing rule matching its preserved source IP to its chosen outbound. - injectAmneziawgEgress(xrayConfig, inbounds) + // Every AmneziaWG inbound is embedded (internal/amneziawgnet: amneziawg-go + // over a gVisor netstack, no kernel module) and relays every peer's + // decapsulated traffic into its own loopback SOCKS5 inbound, always on — + // unlike mtproto's bridge above, there's no opt-in gate here: once + // traffic is decapsulated in gVisor, Xray's own freedom outbound is the + // only way it reaches the real internet at all, not an optional extra + // hop. Whether it goes anywhere beyond Xray's default routing is up to + // whatever rules the admin adds through the stock Routing page, exactly + // like routing any other protocol. + injectAmneziawgnetSocks(xrayConfig, inbounds) // Wire the panel's own HTTP traffic through the configured outbound, after // the subscription merge so subscription outbound tags are valid targets. @@ -670,61 +673,40 @@ func injectMtprotoEgress(cfg *xray.Config, inbound *model.Inbound) { }) } -// amneziawgEgressDokodemoSettings is the dokodemo-door settings block for the -// shared AmneziaWG TPROXY bridge: accept both TCP and UDP, and (per this -// fork's existing "Tunnel" protocol convention — see -// frontend/src/lib/xray/inbound-tag.ts) use followRedirect mode so the -// destination comes from the TPROXY-preserved original address rather than a -// fixed port/address pair. -const amneziawgEgressDokodemoSettings = `{"allowedNetwork":"tcp,udp","followRedirect":true}` - -// amneziawgEgressStreamSettings turns the bridge's listening socket into a -// TPROXY target, matching internal/amneziawg's iptables `-j TPROXY` rules — -// without this, the kernel-redirected packets never reach a listening -// socket. -const amneziawgEgressStreamSettings = `{"sockopt":{"tproxy":"tproxy"}}` - -// amneziawgEgressSniffingSettings enables sniffing on the bridge, matching -// this fork's own normal per-inbound default (see default.json's "mixed" -// inbound). Without this, domain-based Routing rules can never match a -// single byte of RouteThroughXray traffic: an AmneziaWG peer resolves DNS +// amneziawgEgressSniffingSettings enables sniffing on the AmneziaWG SOCKS5 +// relay inbound, matching this fork's own normal per-inbound default (see +// default.json's "mixed" inbound). Without this, domain-based Routing rules +// can never match a single byte of AmneziaWG traffic: a peer resolves DNS // itself, through the tunnel, before ever sending a packet — by the time -// TPROXY hands the decapsulated traffic to this bridge, the destination is -// already a bare IP, with no domain name attached at the network layer at -// all. Sniffing recovers it from the payload itself (TLS SNI / HTTP Host / -// QUIC) the same way it already does for every other inbound; without it, -// only tag/IP/network-based rules can ever match this bridge's traffic, -// and any domain rule above it in the list is silently unreachable. +// the embedded forwarder recovers the decapsulated traffic, the destination +// is already a bare IP, with no domain name attached at the network layer +// at all. Sniffing recovers it from the payload itself (TLS SNI / HTTP Host +// / QUIC) the same way it already does for every other inbound; without +// it, only tag/IP/network-based rules can ever match this traffic, and any +// domain rule above it in the list is silently unreachable. const amneziawgEgressSniffingSettings = `{"enabled":true,"destOverride":["http","tls","quic","fakedns"]}` -// injectAmneziawgEgress gives every enabled, RouteThroughXray-opted-in -// AmneziaWG inbound with at least one qualifying peer its own loopback -// dokodemo-door bridge — tagged with that inbound's own real tag, so it's -// already selectable in the panel's stock Routing page's inbound-tag -// picker, exactly the way an mtproto inbound's own bridge already is (see -// injectMtprotoEgress): the picker's tag list comes from -// InboundService.GetInboundTags(), a plain, -// protocol-blind SELECT over every inbound row's tag, so reusing a real -// inbound's own tag needs no dedicated UI plumbing at all. +// injectAmneziawgnetSocks gives every enabled AmneziaWG inbound with at +// least one qualifying peer its own loopback SOCKS5 inbound for the +// embedded (amneziawg-go) relay path (internal/amneziawgnet) -- always on, +// unlike injectAmneziawgEgress's opt-in RouteThroughXray bridge above, since +// there is no alternative datapath once traffic is decapsulated in gVisor: +// Xray's own freedom outbound is how it reaches the real internet at all +// (see internal/amneziawgnet/relay.go's doc comment, Finding 3 of the +// migration plan). Tagged with the inbound's own real tag, for the same two +// reasons injectAmneziawgEgress already is: it's already selectable in the +// panel's stock Routing page (InboundService.GetInboundTags is +// protocol-blind), and per-inbound traffic totals +// (internal/web/service/inbound_traffic.go's addClientTraffic) match by +// exact tag -- reusing it isn't a style choice. // -// RouteThroughXray is a per-inbound opt-in, off by default: when it's off, -// no bridge is created at all and the tunnel has no Xray dependency -// whatsoever. When it's on, every peer's traffic lands on the bridge — -// internal/amneziawg's defaultPostUpDown TPROXYs it there, there is no -// further per-peer opt-in — but this function never generates a routing -// rule of its own. Whether that traffic goes anywhere beyond Xray's default -// routing is entirely up to whatever rules the admin adds through that same -// stock Routing page (inboundTag + an optional sourceIP to target one -// specific peer + outboundTag, exactly like routing any other protocol). -// -// An inbound is skipped, individually, when its own tag is already taken by -// another config entry — mirroring injectMtprotoEgress/injectPanelEgress's -// own defensive check, even though a real collision shouldn't be possible -// (inbound tags are unique, and the main GenXrayInboundConfig loop already -// excludes mtproto/amneziawg inbounds from ever claiming their own tag -// there). Generated state is hot-appliable and never modifies the stored -// template or restarts the core. -func injectAmneziawgEgress(cfg *xray.Config, inbounds []*model.Inbound) { +// No RouteThroughXray gate, no qualifying-peer IPv4 check the way +// injectAmneziawgEgress needs one: amneziawg.InstanceFromInbound already +// returns ok=false for zero qualifying peers (Enable && PublicKey != "" && +// len(AllowedIPs) > 0), and peer identity here comes from Email directly, +// not an IPv4 lookup, so a v6-only peer is just as valid an account as any +// other. +func injectAmneziawgnetSocks(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{}{} @@ -735,32 +717,38 @@ func injectAmneziawgEgress(cfg *xray.Config, inbounds []*model.Inbound) { continue } inst, ok := amneziawg.InstanceFromInbound(inbound) - if !ok || !inst.RouteThroughXray { - continue - } - hasQualifyingPeer := false - for _, p := range inst.Peers { - if amneziawg.FirstIPv4(p.AllowedIPs) != "" { - hasQualifyingPeer = true - break - } - } - if !hasQualifyingPeer { + if !ok { continue } if _, taken := existingTags[inbound.Tag]; taken { - logger.Warning("amneziawg egress: inbound tag [", inbound.Tag, "] already present in generated config, skipping its bridge") + logger.Warning("amneziawgnet socks: inbound tag [", inbound.Tag, "] already present in generated config, skipping its relay inbound") continue } + + emails := make([]string, 0, len(inst.Peers)) + for _, p := range inst.Peers { + if p.Email != "" { + emails = append(emails, p.Email) + } + } + if len(emails) == 0 { + continue + } + + settings, err := amneziawgnet.SocksInboundSettings(emails, amneziawgnet.SocksPassword()) + if err != nil { + logger.Warning("amneziawgnet socks: building settings for inbound [", inbound.Tag, "]: ", err) + continue + } + existingTags[inbound.Tag] = struct{}{} cfg.InboundConfigs = append(cfg.InboundConfigs, xray.InboundConfig{ - Listen: json_util.RawMessage(`"127.0.0.1"`), - Port: amneziawg.EgressPortForInbound(inbound.Id), - Protocol: "dokodemo-door", - Settings: json_util.RawMessage(amneziawgEgressDokodemoSettings), - StreamSettings: json_util.RawMessage(amneziawgEgressStreamSettings), - Sniffing: json_util.RawMessage(amneziawgEgressSniffingSettings), - Tag: inbound.Tag, + Listen: json_util.RawMessage(`"127.0.0.1"`), + Port: amneziawgnet.SOCKSPortForInbound(inbound.Id), + Protocol: "socks", + Settings: json_util.RawMessage(settings), + Sniffing: json_util.RawMessage(amneziawgEgressSniffingSettings), + Tag: inbound.Tag, }) } } diff --git a/internal/web/service/xray_config_inject_test.go b/internal/web/service/xray_config_inject_test.go index 6d930ee24..0fe077c97 100644 --- a/internal/web/service/xray_config_inject_test.go +++ b/internal/web/service/xray_config_inject_test.go @@ -7,6 +7,7 @@ import ( "testing" "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" xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/util/json_util" @@ -561,46 +562,46 @@ func TestInjectMtprotoEgress_BadRoutingSkips(t *testing.T) { } func amneziawgInbound(id int, tag string, clients []model.Client) *model.Inbound { - server := amneziawg.ServerSettings{SubnetIP: "10.8.1.0", SubnetCIDR: 24, RouteThroughXray: true} + server := amneziawg.ServerSettings{SubnetIP: "10.8.1.0", SubnetCIDR: 24} settings, _ := json.Marshal(amneziawg.InboundSettings{Server: &server, Clients: clients}) return &model.Inbound{Id: id, Tag: tag, Protocol: model.AmneziaWG, Enable: true, Settings: string(settings)} } -func TestInjectAmneziawgEgress_CreatesBridgeTaggedWithInboundsOwnTag(t *testing.T) { +func TestInjectAmneziawgnetSocks_CreatesRelayTaggedWithInboundsOwnTag(t *testing.T) { cfg := egressTestConfig() before := string(cfg.RouterConfig) inbound := amneziawgInbound(7, "awg-7", []model.Client{ {Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}}, }) - injectAmneziawgEgress(cfg, []*model.Inbound{inbound}) + injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound}) if len(cfg.InboundConfigs) != 2 { - t.Fatalf("expected the bridge to be appended, got %d inbounds", len(cfg.InboundConfigs)) + t.Fatalf("expected the relay inbound to be appended, got %d inbounds", len(cfg.InboundConfigs)) } ib := cfg.InboundConfigs[1] - if ib.Tag != "awg-7" || ib.Protocol != "dokodemo-door" || ib.Port != amneziawg.EgressPortForInbound(7) { - t.Fatalf("bridge must reuse the inbound's own tag (so it's already selectable in the stock Routing page) and this instance's own derived port, got %+v", ib) + if ib.Tag != "awg-7" || ib.Protocol != "socks" || ib.Port != amneziawgnet.SOCKSPortForInbound(7) { + t.Fatalf("relay inbound must reuse the inbound's own tag (so per-inbound stats totals keep matching, and it's already selectable in the stock Routing page) and this instance's own derived port, got %+v", ib) } if string(ib.Listen) != `"127.0.0.1"` { - t.Fatalf("bridge must listen on loopback, got %s", ib.Listen) + t.Fatalf("relay inbound must listen on loopback, got %s", ib.Listen) } - if !strings.Contains(string(ib.StreamSettings), `"tproxy":"tproxy"`) { - t.Fatalf("bridge must set sockopt.tproxy, got %s", ib.StreamSettings) + if !strings.Contains(string(ib.Settings), `"auth":"password"`) || !strings.Contains(string(ib.Settings), `"udp":true`) { + t.Fatalf("relay inbound must require password auth and allow UDP ASSOCIATE, got %s", ib.Settings) } - if !strings.Contains(string(ib.Settings), `"followRedirect":true`) { - t.Fatalf("bridge must set followRedirect, got %s", ib.Settings) + if !strings.Contains(string(ib.Settings), `"a@x"`) { + t.Fatalf("relay inbound must have an account for the peer's email, got %s", ib.Settings) } if !strings.Contains(string(ib.Sniffing), `"enabled":true`) { - t.Fatalf("bridge must enable sniffing -- a peer's own DNS resolution means the decapsulated traffic never carries a domain at the network layer, so domain-based Routing rules can only ever match via sniffing the payload, got %s", ib.Sniffing) + t.Fatalf("relay inbound must enable sniffing -- a peer's own DNS resolution means the decapsulated traffic never carries a domain at the network layer, so domain-based Routing rules can only ever match via sniffing the payload, got %s", ib.Sniffing) } // No auto-generated routing rule: it's entirely up to the admin's own // Routing-page rules, same as any other protocol's inbound tag. if string(cfg.RouterConfig) != before { - t.Fatalf("injectAmneziawgEgress must never touch the routing section, got %s", cfg.RouterConfig) + t.Fatalf("injectAmneziawgnetSocks must never touch the routing section, got %s", cfg.RouterConfig) } } -func TestInjectAmneziawgEgress_MultipleInboundsEachGetOwnBridge(t *testing.T) { +func TestInjectAmneziawgnetSocks_MultipleInboundsEachGetOwnRelay(t *testing.T) { cfg := egressTestConfig() inbound1 := amneziawgInbound(1, "awg-1", []model.Client{ {Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}}, @@ -608,21 +609,21 @@ func TestInjectAmneziawgEgress_MultipleInboundsEachGetOwnBridge(t *testing.T) { inbound2 := amneziawgInbound(2, "awg-2", []model.Client{ {Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"10.9.1.2/32"}}, }) - injectAmneziawgEgress(cfg, []*model.Inbound{inbound1, inbound2}) + injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound1, inbound2}) if len(cfg.InboundConfigs) != 3 { - t.Fatalf("expected one bridge per inbound (plus the pre-existing one), got %d inbounds: %+v", len(cfg.InboundConfigs), cfg.InboundConfigs) + t.Fatalf("expected one relay inbound per inbound (plus the pre-existing one), got %d inbounds: %+v", len(cfg.InboundConfigs), cfg.InboundConfigs) } byTag := map[string]int{} for _, ib := range cfg.InboundConfigs[1:] { byTag[ib.Tag] = ib.Port } - if byTag["awg-1"] != amneziawg.EgressPortForInbound(1) || byTag["awg-2"] != amneziawg.EgressPortForInbound(2) { + if byTag["awg-1"] != amneziawgnet.SOCKSPortForInbound(1) || byTag["awg-2"] != amneziawgnet.SOCKSPortForInbound(2) { t.Fatalf("each inbound must get its own tag and its own derived port, got %+v", byTag) } } -func TestInjectAmneziawgEgress_NoQualifyingPeerSkipsBridge(t *testing.T) { +func TestInjectAmneziawgnetSocks_NoQualifyingPeerSkipsRelay(t *testing.T) { cases := []struct { name string client model.Client @@ -632,13 +633,14 @@ func TestInjectAmneziawgEgress_NoQualifyingPeerSkipsBridge(t *testing.T) { {"no PublicKey", model.Client{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}}, true}, {"no AllowedIPs", model.Client{Email: "a@x", Enable: true, PublicKey: "pub-a"}, true}, {"inbound disabled", model.Client{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}}, false}, + {"no Email", model.Client{Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}}, true}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { cfg := egressTestConfig() inbound := amneziawgInbound(1, "awg-1", []model.Client{c.client}) inbound.Enable = c.enable - injectAmneziawgEgress(cfg, []*model.Inbound{inbound}) + injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound}) if len(cfg.InboundConfigs) != 1 { t.Fatalf("%s must be a no-op, got %d inbounds", c.name, len(cfg.InboundConfigs)) } @@ -646,9 +648,13 @@ func TestInjectAmneziawgEgress_NoQualifyingPeerSkipsBridge(t *testing.T) { } } -func TestInjectAmneziawgEgress_RouteThroughXrayOffSkipsBridge(t *testing.T) { +func TestInjectAmneziawgnetSocks_AlwaysOnRegardlessOfLegacyRouteThroughXrayField(t *testing.T) { + // Unlike the retired kernel-module bridge, the embedded relay has no + // opt-in gate: there is no alternative datapath once traffic is + // decapsulated in gVisor. A stale RouteThroughXray=false left over from + // a pre-cutover install must not suppress the relay inbound. cfg := egressTestConfig() - server := amneziawg.ServerSettings{SubnetIP: "10.8.1.0", SubnetCIDR: 24} // RouteThroughXray left false + server := amneziawg.ServerSettings{SubnetIP: "10.8.1.0", SubnetCIDR: 24, RouteThroughXray: false} settings, _ := json.Marshal(amneziawg.InboundSettings{ Server: &server, Clients: []model.Client{ @@ -656,13 +662,13 @@ func TestInjectAmneziawgEgress_RouteThroughXrayOffSkipsBridge(t *testing.T) { }, }) inbound := &model.Inbound{Id: 1, Tag: "awg-1", Protocol: model.AmneziaWG, Enable: true, Settings: string(settings)} - injectAmneziawgEgress(cfg, []*model.Inbound{inbound}) - if len(cfg.InboundConfigs) != 1 { - t.Fatalf("an inbound with RouteThroughXray off must never get a bridge, got %+v", cfg.InboundConfigs) + injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound}) + if len(cfg.InboundConfigs) != 2 { + t.Fatalf("the relay inbound must always be created regardless of RouteThroughXray, got %+v", cfg.InboundConfigs) } } -func TestInjectAmneziawgEgress_WrongProtocolOrNodeSkipped(t *testing.T) { +func TestInjectAmneziawgnetSocks_WrongProtocolOrNodeSkipped(t *testing.T) { cfg := egressTestConfig() vless := &model.Inbound{Id: 1, Tag: "in-1", Protocol: model.VLESS, Enable: true} nodeID := 5 @@ -670,13 +676,13 @@ func TestInjectAmneziawgEgress_WrongProtocolOrNodeSkipped(t *testing.T) { {Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}}, }) nodeHosted.NodeID = &nodeID - injectAmneziawgEgress(cfg, []*model.Inbound{vless, nodeHosted}) + injectAmneziawgnetSocks(cfg, []*model.Inbound{vless, nodeHosted}) if len(cfg.InboundConfigs) != 1 { - t.Fatalf("a non-AmneziaWG or node-hosted inbound must never get a bridge, got %+v", cfg.InboundConfigs) + t.Fatalf("a non-AmneziaWG or node-hosted inbound must never get a relay inbound, got %+v", cfg.InboundConfigs) } } -func TestInjectAmneziawgEgress_TagCollisionSkipsThatInboundOnly(t *testing.T) { +func TestInjectAmneziawgnetSocks_TagCollisionSkipsThatInboundOnly(t *testing.T) { cfg := egressTestConfig() cfg.InboundConfigs = append(cfg.InboundConfigs, xray.InboundConfig{Port: 1234, Protocol: "vless", Tag: "awg-1"}) @@ -686,20 +692,20 @@ func TestInjectAmneziawgEgress_TagCollisionSkipsThatInboundOnly(t *testing.T) { inbound2 := amneziawgInbound(2, "awg-2", []model.Client{ {Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"10.9.1.2/32"}}, }) - injectAmneziawgEgress(cfg, []*model.Inbound{inbound1, inbound2}) + injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound1, inbound2}) - // Started with 2 (api + the colliding vless entry); only awg-2's bridge - // should have been added, awg-1's skipped since its tag is taken. + // Started with 2 (api + the colliding vless entry); only awg-2's relay + // inbound should have been added, awg-1's skipped since its tag is taken. if len(cfg.InboundConfigs) != 3 { - t.Fatalf("expected only the non-colliding inbound's bridge to be added, got %+v", cfg.InboundConfigs) + t.Fatalf("expected only the non-colliding inbound's relay inbound to be added, got %+v", cfg.InboundConfigs) } found := false for _, ib := range cfg.InboundConfigs { - if ib.Tag == "awg-2" && ib.Protocol == "dokodemo-door" { + if ib.Tag == "awg-2" && ib.Protocol == "socks" { found = true } } if !found { - t.Fatal("awg-2's bridge must still be created despite awg-1's tag collision") + t.Fatal("awg-2's relay inbound must still be created despite awg-1's tag collision") } } From 124665c5ef4d33f05ebac04ae5a9720d2bfe8b3c Mon Sep 17 00:00:00 2001 From: Kuzz007 Date: Sun, 2 Aug 2026 14:42:00 +0300 Subject: [PATCH 04/12] feat(amneziawg): auto-wire the SOCKS5 relay into every embedded Device Manager.ensureLocked now attaches AttachTCPForwarder/AttachUDPHandler to every Device it builds, relaying into that instance's own loopback SOCKS5 inbound automatically -- no caller needs to know relay.go exists at all. Peer identity is re-looked-up via Manager.Lookup on every connection rather than captured once at attach time, so a reconfigure-in-place (peers added/ removed without a full rebuild) doesn't leave the forwarder working off a stale peer index. Added TestManagerEnsureAutomaticallyWiresRelay: drives this through the real Manager.Ensure entry point (not manual wiring like the existing relay_e2e_test.go) against a real xray-core process, confirming the automatic attachment and the port/password Manager derives internally actually agree with what a real SOCKS5 inbound expects. Co-Authored-By: Claude Sonnet 5 --- internal/amneziawgnet/manager.go | 83 +++++++++-- internal/amneziawgnet/relay_e2e_test.go | 187 ++++++++++++++++++++++++ 2 files changed, 257 insertions(+), 13 deletions(-) diff --git a/internal/amneziawgnet/manager.go b/internal/amneziawgnet/manager.go index ca8276ae8..ae0b82993 100644 --- a/internal/amneziawgnet/manager.go +++ b/internal/amneziawgnet/manager.go @@ -2,9 +2,12 @@ package amneziawgnet import ( "fmt" + "net/netip" "strings" "sync" + "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet" + "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" "github.com/mhsanaei/3x-ui/v3/internal/logger" ) @@ -18,12 +21,13 @@ type Desired struct { Options DeviceOptions } -// managed is one running embedded interface: the live Device, the peer -// lookup index built from its current peer list, and enough of its own -// configuration to decide whether a later Ensure call can reconfigure it in -// place or needs to rebuild it from scratch. +// managed is one running embedded interface: the live Device, its UDP relay +// sessions, the peer lookup index built from its current peer list, and +// enough of its own configuration to decide whether a later Ensure call can +// reconfigure it in place or needs to rebuild it from scratch. type managed struct { dev *Device + udpRelay *UDPRelay peers *PeerIndex inst amneziawg.Instance structFP string @@ -33,12 +37,11 @@ type managed struct { // inbound id -- the same shape as internal/amneziawg.Manager (GetManager() // + sync.Once, mu-guarded map, Ensure/Reconcile/StopAll/HasRunning), so a // caller already familiar with that Manager needs to learn nothing new here. -// Unlike that Manager, this one doesn't attach any traffic handling by -// itself: Ensure/Reconcile only bring each Instance's Device up to date. -// Attaching a forwarder/UDP handler (see forwarder.go / udp.go) using the -// Device and PeerIndex returned by Lookup is left to the caller -- today a -// test harness, later the Phase 2 SOCKS5 relay wiring -- since this package -// doesn't yet know what that handler should do with a recovered connection. +// Every Device this Manager builds gets its TCP forwarder and UDP handler +// attached automatically (see ensureLocked), relaying into that instance's +// own loopback SOCKS5 inbound (SOCKSPortForInbound/SocksPassword) -- a +// caller only needs to keep calling Ensure/Reconcile with fresh Instance +// data; it doesn't need to know relay.go exists at all. type Manager struct { mu sync.Mutex ifaces map[int]*managed @@ -97,6 +100,7 @@ func (m *Manager) ensureLocked(d Desired) error { } if exists { + cur.udpRelay.Close() cur.dev.Close() delete(m.ifaces, inst.Id) } @@ -104,8 +108,47 @@ func (m *Manager) ensureLocked(d Desired) error { if err != nil { return err } + + relay := socksRelayForInstance(inst) + udpRelay := NewUDPRelay(relay, dev.Stack) + inboundID := inst.Id // captured for the closures below, which outlive this call + AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) { + srcAddrPort, err := netip.ParseAddrPort(conn.RemoteAddr().String()) + if err != nil { + conn.Close() + return + } + // Re-fetched on every connection, not captured once at attach time: + // a reconfigure-in-place (peers added/removed, no rebuild) replaces + // cur.peers without ever re-attaching the forwarder, so a stale + // captured index would silently miss newly-added peers. + _, peers, ok := m.Lookup(inboundID) + if !ok { + conn.Close() + return + } + peer, ok := peers.Lookup(srcAddrPort.Addr().Unmap()) + if !ok { + conn.Close() + return + } + relay.RelayTCP(conn, peer.Email, dest) + }) + AttachUDPHandler(dev.Stack, func(src, dst netip.AddrPort, payload []byte) { + _, peers, ok := m.Lookup(inboundID) + if !ok { + return + } + peer, ok := peers.Lookup(src.Addr()) + if !ok { + return + } + udpRelay.Handle(src, dst, peer.Email, payload) + }) + m.ifaces[inst.Id] = &managed{ dev: dev, + udpRelay: udpRelay, peers: NewPeerIndex(inst.Peers), inst: inst, structFP: structFP, @@ -114,6 +157,17 @@ func (m *Manager) ensureLocked(d Desired) error { return nil } +// socksRelayForInstance derives the loopback SOCKS5 relay address/password +// for inst -- both fully determined by its id and the process-wide +// password (SOCKSPortForInbound/SocksPassword), so no per-instance state +// needs threading through Desired/DeviceOptions for this. +func socksRelayForInstance(inst amneziawg.Instance) SocksRelay { + return SocksRelay{ + Addr: fmt.Sprintf("127.0.0.1:%d", SOCKSPortForInbound(inst.Id)), + Password: SocksPassword(), + } +} + // addressFingerprint captures the two Instance fields that can't be changed // on a running Device via IpcSet alone (they're fixed when the gVisor // netstack is built) -- everything else (keys, listen port, obfuscation, @@ -137,6 +191,7 @@ func (m *Manager) Reconcile(desired []Desired) { if _, ok := want[id]; ok { continue } + cur.udpRelay.Close() cur.dev.Close() delete(m.ifaces, id) logger.Infof("amneziawgnet: stopped embedded interface for removed inbound %d", id) @@ -153,6 +208,7 @@ func (m *Manager) StopAll() { m.mu.Lock() defer m.mu.Unlock() for id, cur := range m.ifaces { + cur.udpRelay.Close() cur.dev.Close() delete(m.ifaces, id) } @@ -166,9 +222,10 @@ func (m *Manager) HasRunning() bool { } // Lookup returns the running Device and PeerIndex for inbound id, if any -- -// for a caller that wants to attach its own forwarder/handler (a test -// harness today, the Phase 2 SOCKS5 relay wiring later) once the interface -// is up. +// the forwarder/UDP-handler closures ensureLocked attaches use this to +// re-fetch the current peer index on every connection (see ensureLocked's +// comment on why), and it's equally available to a test harness or any +// other caller that wants read access to a managed interface's state. func (m *Manager) Lookup(id int) (dev *Device, peers *PeerIndex, ok bool) { m.mu.Lock() defer m.mu.Unlock() diff --git a/internal/amneziawgnet/relay_e2e_test.go b/internal/amneziawgnet/relay_e2e_test.go index b312438a2..c5100674c 100644 --- a/internal/amneziawgnet/relay_e2e_test.go +++ b/internal/amneziawgnet/relay_e2e_test.go @@ -284,6 +284,193 @@ func TestSocksRelayAgainstRealXray(t *testing.T) { } } +// TestManagerEnsureAutomaticallyWiresRelay is Phase 3's own real proof: unlike +// TestSocksRelayAgainstRealXray above (which builds a Device and attaches +// RelayTCP/UDPRelay by hand), this drives everything through the public +// Manager.Ensure entry point the real app actually calls -- confirming +// ensureLocked's own forwarder/UDP-handler attachment (added this phase) +// really does relay a fresh Device's traffic into Xray with zero manual +// wiring from the caller. Uses the exact port/password +// (SOCKSPortForInbound/SocksPassword) the Manager computes internally, so +// this only passes if that internal derivation and the externally-visible +// contract genuinely agree. +func TestManagerEnsureAutomaticallyWiresRelay(t *testing.T) { + bin := os.Getenv("XRAY_E2E_BINARY") + if bin == "" { + t.Skip("set XRAY_E2E_BINARY to an xray binary to run this test") + } + localIP, ok := firstNonLoopbackIPv4() + if !ok { + t.Skip("no non-loopback IPv4 address available on this host") + } + + const wantEmail = "manager-e2e-peer@example.com" + const listenPort = 58716 + const inboundID = 5 + + tcpEcho, tcpEchoAddr := startTCPEcho(t, localIP) + defer tcpEcho.Close() + + serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("generate server keypair: %v", err) + } + clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("generate client keypair: %v", err) + } + + inst := amneziawg.Instance{ + Id: inboundID, + InterfaceName: "awgtest5", + ListenPort: listenPort, + PrivateKey: serverPriv, + PublicKey: serverPub, + Address: []string{"10.205.0.1/24"}, + MTU: 1420, + Obfuscation: amneziawg.Obfuscation20{ + Jc: 4, Jmin: 40, Jmax: 70, + S1: 20, S2: 30, S3: 20, S4: 20, + }, + Peers: []amneziawg.Peer{{ + Email: wantEmail, + PublicKey: clientPub, + AllowedIPs: []string{"10.205.0.2/32"}, + }}, + } + + // A real xray-core process with a SOCKS5 inbound at exactly the port and + // password ensureLocked will derive on its own for this instance -- + // SocksPassword() is cached (sync.Once), so calling it here first and + // again inside Manager.Ensure below returns the identical value. + socksPort := SOCKSPortForInbound(inboundID) + password := SocksPassword() + settingsJSON, err := SocksInboundSettings([]string{wantEmail}, password) + if err != nil { + t.Fatalf("SocksInboundSettings: %v", err) + } + var rawSettings any + if err := json.Unmarshal(settingsJSON, &rawSettings); err != nil { + t.Fatalf("unmarshal generated SOCKS5 settings: %v", err) + } + xrayCfg := map[string]any{ + "log": map[string]any{"loglevel": "debug"}, + "inbounds": []any{ + map[string]any{ + "listen": "127.0.0.1", + "port": socksPort, + "protocol": "socks", + "settings": rawSettings, + "tag": "awg-e2e-manager", + }, + }, + "outbounds": []any{ + map[string]any{"protocol": "freedom", "settings": map[string]any{}, "tag": "direct"}, + }, + "policy": map[string]any{ + "levels": map[string]any{ + "0": map[string]any{"statsUserUplink": true, "statsUserDownlink": true}, + }, + }, + "stats": map[string]any{}, + } + cfgBytes, err := json.MarshalIndent(xrayCfg, "", " ") + if err != nil { + t.Fatalf("marshal xray config: %v", err) + } + cfgPath := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(cfgPath, cfgBytes, 0o644); err != nil { + t.Fatalf("write xray config: %v", err) + } + + var xrayLog syncBuffer + cmd := exec.Command(bin, "-c", cfgPath) + cmd.Stdout = &xrayLog + cmd.Stderr = &xrayLog + if err := cmd.Start(); err != nil { + t.Fatalf("start xray: %v", err) + } + defer func() { + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + }() + waitForPort(t, socksPort) + + // A throwaway Manager, not the process-wide singleton, so this test + // doesn't interact with any other test's state. + m := &Manager{ifaces: map[int]*managed{}} + defer m.StopAll() + if err := m.Ensure(Desired{Instance: inst}); err != nil { + t.Fatalf("Manager.Ensure: %v", err) + } + dev, _, ok := m.Lookup(inboundID) + if !ok { + t.Fatal("Lookup after Ensure: not found") + } + defer dev.Close() // StopAll would also do this; explicit for clarity + + clientTun, clientNet, err := netstack.CreateNetTUN( + []netip.Addr{netip.MustParseAddr("10.205.0.2")}, + []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420) + if err != nil { + t.Fatalf("client CreateNetTUN: %v", err) + } + clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, "")) + defer clientDev.Close() + + clientPrivHex, err := wireguard.KeyToHex(clientPriv) + if err != nil { + t.Fatalf("client key to hex: %v", err) + } + serverPubHex, err := wireguard.KeyToHex(serverPub) + if err != nil { + t.Fatalf("server key to hex: %v", err) + } + clientConf := fmt.Sprintf( + "private_key=%s\njc=4\njmin=40\njmax=70\ns1=20\ns2=30\ns3=20\ns4=20\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n", + clientPrivHex, serverPubHex, listenPort) + if err := clientDev.IpcSet(clientConf); err != nil { + t.Fatalf("client IpcSet: %v", err) + } + if err := clientDev.Up(); err != nil { + t.Fatalf("client Up: %v", err) + } + + const tcpMsg = "hello via Manager.Ensure's automatic relay wiring" + dialDeadline := time.Now().Add(10 * time.Second) + var conn net.Conn + for { + c, dialErr := clientNet.DialContext(t.Context(), "tcp", tcpEchoAddr.String()) + if dialErr == nil { + conn = c + break + } + if time.Now().After(dialDeadline) { + t.Fatalf("client TCP dial via tunnel never succeeded: %v", dialErr) + } + time.Sleep(150 * time.Millisecond) + } + defer conn.Close() + if _, err := conn.Write([]byte(tcpMsg)); err != nil { + t.Fatalf("client TCP write: %v", err) + } + buf := make([]byte, len(tcpMsg)) + if _, err := readFull(conn, buf, 10*time.Second); err != nil { + t.Fatalf("client TCP read: %v", err) + } + if string(buf) != tcpMsg { + t.Errorf("TCP echo = %q, want %q", buf, tcpMsg) + } + + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + log := xrayLog.String() + wantUp := fmt.Sprintf("user>>>%s>>>traffic>>>uplink", wantEmail) + if !strings.Contains(log, wantUp) { + t.Errorf("xray log missing uplink stats counter %q (Manager.Ensure's automatic relay wiring may not be attributing traffic correctly)\nfull log:\n%s", wantUp, log) + } +} + // firstNonLoopbackIPv4 finds a real, locally-bound IPv4 address suitable as // a relay-reachable test destination. func firstNonLoopbackIPv4() (netip.Addr, bool) { From efca370bfc3abdd0b6ce2d9c2886af2eaa5bc48d Mon Sep 17 00:00:00 2001 From: Kuzz007 Date: Sun, 2 Aug 2026 14:49:53 +0300 Subject: [PATCH 05/12] fix(amneziawg): satisfy golangci-lint in relay.go errcheck: explicitly discard io.Copy's error in the two fire-and-forget relay goroutines -- a copy error there just means the connection closed, which is the expected/normal way this loop ends, not something to handle further. noctx: net.DialTimeout must not be called per this repo's lint config; use (*net.Dialer).DialContext with Timeout set instead, same as the rest of the codebase already does. Co-Authored-By: Claude Sonnet 5 --- internal/amneziawgnet/relay.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/amneziawgnet/relay.go b/internal/amneziawgnet/relay.go index f17b42995..33f3b7662 100644 --- a/internal/amneziawgnet/relay.go +++ b/internal/amneziawgnet/relay.go @@ -7,6 +7,7 @@ package amneziawgnet import ( + "context" "encoding/binary" "encoding/json" "fmt" @@ -80,8 +81,8 @@ func (r SocksRelay) RelayTCP(conn *gonet.TCPConn, email string, dest netip.AddrP defer upstream.Close() done := make(chan struct{}, 2) - go func() { io.Copy(upstream, conn); done <- struct{}{} }() - go func() { io.Copy(conn, upstream); done <- struct{}{} }() + go func() { _, _ = io.Copy(upstream, conn); done <- struct{}{} }() + go func() { _, _ = io.Copy(conn, upstream); done <- struct{}{} }() <-done } @@ -101,7 +102,8 @@ type socks5UDPSession struct { // types, not reusable as a standalone dialer -- so this is a small, direct, // from-the-RFC implementation rather than an existing library call. func newSocks5UDPSession(addr, user, password string) (*socks5UDPSession, error) { - ctrl, err := net.DialTimeout("tcp", addr, 5*time.Second) + dialer := net.Dialer{Timeout: 5 * time.Second} + ctrl, err := dialer.DialContext(context.Background(), "tcp", addr) if err != nil { return nil, fmt.Errorf("amneziawgnet: dial SOCKS5 control connection: %w", err) } From f78dfa6f6726a641a82e5438b482ec3d29b5b273 Mon Sep 17 00:00:00 2001 From: Kuzz007 Date: Sun, 2 Aug 2026 15:04:51 +0300 Subject: [PATCH 06/12] feat(amneziawg): swap the app's integration points to the embedded manager Hard cutover, part 2: every real call site that used to drive internal/amneziawg's kernel-module Manager now drives internal/amneziawgnet's instead -- - internal/web/job/amneziawg_job.go: the reconcile cron job. Traffic/ online-status accounting is dropped entirely (not ported) -- once a peer's traffic is relayed through Xray's own SOCKS5 inbound, it's an ordinary Xray user and XrayTrafficJob's existing generic stats polling already handles it, with zero AmneziaWG-specific code. - internal/web/runtime/local.go: the immediate-apply CRUD path (AddInbound/DelInbound/updateAmneziaWGInbound). - internal/web/web.go: panel shutdown's StopAll. internal/amneziawgnet.Manager gains Remove(id) to match the kernel-module Manager's shape at these call sites (Reconcile alone doesn't cover a single-inbound removal outside a full reconcile pass). internal/web/service/inbound_amneziawg.go's applyLocalAmneziaWG needed no change: it already goes through runtime.Runtime.UpdateInbound, which now resolves to the updated local.go path. Co-Authored-By: Claude Sonnet 5 --- internal/amneziawgnet/manager.go | 17 +++++++ internal/web/job/amneziawg_job.go | 85 +++++++------------------------ internal/web/runtime/local.go | 18 ++++--- internal/web/web.go | 4 +- 4 files changed, 46 insertions(+), 78 deletions(-) diff --git a/internal/amneziawgnet/manager.go b/internal/amneziawgnet/manager.go index ae0b82993..9a4c326ad 100644 --- a/internal/amneziawgnet/manager.go +++ b/internal/amneziawgnet/manager.go @@ -203,6 +203,23 @@ func (m *Manager) Reconcile(desired []Desired) { } } +// Remove tears down inbound id's embedded interface, if any -- mirrors +// internal/amneziawg.Manager.Remove, for a caller that needs to drop a +// single inbound outside a full Reconcile pass (e.g. the immediate-apply +// CRUD path in internal/web/runtime/local.go). +func (m *Manager) Remove(id int) { + m.mu.Lock() + defer m.mu.Unlock() + cur, exists := m.ifaces[id] + if !exists { + return + } + cur.udpRelay.Close() + cur.dev.Close() + delete(m.ifaces, id) + logger.Infof("amneziawgnet: stopped embedded interface for removed inbound %d", id) +} + // StopAll tears down every managed interface. Called on panel shutdown. func (m *Manager) StopAll() { m.mu.Lock() diff --git a/internal/web/job/amneziawg_job.go b/internal/web/job/amneziawg_job.go index ddd21ae92..2fd0a3162 100644 --- a/internal/web/job/amneziawg_job.go +++ b/internal/web/job/amneziawg_job.go @@ -1,33 +1,32 @@ package job import ( - "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" - "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "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 AmneziaWG interfaces against the -// enabled AmneziaWG inbounds in the database, restarts/reloads any that -// drifted, and folds the per-peer traffic scraped from `awg show dump` into -// the usual client and inbound traffic accounting. Mirrors MtprotoJob. +// 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. type AmneziaWGJob struct { inboundService service.InboundService - // warnedMissing tracks whether the "awg/awg-quick not found" warning has - // already been logged, so a host without the AmneziaWG kernel module - // (RHEL, Arch, a container, or a failed install.sh PPA step) logs it - // once instead of every @every-10s tick forever. - warnedMissing bool } -// NewAmneziaWGJob creates a new AmneziaWG reconcile/traffic job instance. +// NewAmneziaWGJob creates a new AmneziaWG reconcile job instance. func NewAmneziaWGJob() *AmneziaWGJob { return new(AmneziaWGJob) } -// Run reconciles desired AmneziaWG inbounds with running interfaces and -// records per-peer traffic deltas and online status. +// Run reconciles desired AmneziaWG inbounds with running embedded interfaces. func (j *AmneziaWGJob) Run() { desired, err := j.inboundService.DesiredAmneziaWGInstances() if err != nil { @@ -35,59 +34,9 @@ func (j *AmneziaWGJob) Run() { return } - // Only relevant once an admin actually has an AmneziaWG inbound: no - // point warning about a missing binary the panel never needed to touch. - if len(desired) > 0 && !amneziawg.IsAwgInstalled() { - if !j.warnedMissing { - j.warnedMissing = true - logger.Warningf("amneziawg job: %d AmneziaWG inbound(s) configured but awg/awg-quick not found on PATH; skipping reconcile until installed", len(desired)) - } - return - } - j.warnedMissing = false - - activeTags := make([]string, 0, len(desired)) + wanted := make([]amneziawgnet.Desired, 0, len(desired)) for _, inst := range desired { - activeTags = append(activeTags, inst.Tag) + wanted = append(wanted, amneziawgnet.Desired{Instance: inst}) } - - mgr := amneziawg.GetManager() - mgr.Reconcile(desired) - - deltas, onlineEmails := mgr.CollectTraffic() - - clientTraffics := make([]*xray.ClientTraffic, 0, len(deltas)) - inboundUp := make(map[string]int64) - inboundDown := make(map[string]int64) - for _, d := range deltas { - clientTraffics = append(clientTraffics, &xray.ClientTraffic{ - Email: d.Email, - Up: d.Up, - Down: d.Down, - }) - inboundUp[d.Tag] += d.Up - inboundDown[d.Tag] += d.Down - } - - traffics := make([]*xray.Traffic, 0, len(inboundUp)) - for tag, up := range inboundUp { - traffics = append(traffics, &xray.Traffic{ - IsInbound: true, - Tag: tag, - Up: up, - Down: inboundDown[tag], - }) - } - - if len(traffics) > 0 || len(clientTraffics) > 0 { - if _, _, err := j.inboundService.AddTraffic(traffics, clientTraffics); err != nil { - logger.Warning("amneziawg job: add traffic failed:", err) - } - } - - // Live speed: AmneziaWG never runs inside xray-core, so XrayTrafficJob's - // own 5s broadcast never mentions these tags. See sidecar_traffic.go. - broadcastSidecarTraffic(string(model.AmneziaWG), traffics, clientTraffics) - - j.inboundService.RefreshLocalOnlineClients(onlineEmails, activeTags) + amneziawgnet.GetManager().Reconcile(wanted) } diff --git a/internal/web/runtime/local.go b/internal/web/runtime/local.go index 4ae401d6a..978003cce 100644 --- a/internal/web/runtime/local.go +++ b/internal/web/runtime/local.go @@ -9,6 +9,7 @@ import ( "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" @@ -59,7 +60,7 @@ func (l *Local) AddInbound(_ context.Context, ib *model.Inbound) error { if !ok { return nil } - return amneziawg.GetManager().Ensure(inst) + return amneziawgnet.GetManager().Ensure(amneziawgnet.Desired{Instance: inst}) } body, err := json.MarshalIndent(ib.GenXrayInboundConfig(), "", " ") if err != nil { @@ -76,7 +77,7 @@ func (l *Local) DelInbound(_ context.Context, ib *model.Inbound) error { return nil } if ib.Protocol == model.AmneziaWG { - amneziawg.GetManager().Remove(ib.Id) + amneziawgnet.GetManager().Remove(ib.Id) return nil } return l.withAPI(func(api *xray.XrayAPI) error { @@ -130,11 +131,12 @@ func (l *Local) updateMtprotoInbound(ctx context.Context, oldIb, newIb *model.In // 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 pick a peers-only `syncconf` instead of always bouncing the interface -// (see internal/amneziawg.Manager.ensureLocked). +// can reconfigure the running embedded Device in place via IpcSet instead +// of always rebuilding it (see internal/amneziawgnet.Manager.ensureLocked -- +// only an address/MTU change forces a rebuild there, not a peer edit). func (l *Local) updateAmneziaWGInbound(ctx context.Context, oldIb, newIb *model.Inbound) error { if oldIb.Protocol == model.AmneziaWG && newIb.Protocol != model.AmneziaWG { - amneziawg.GetManager().Remove(oldIb.Id) + amneziawgnet.GetManager().Remove(oldIb.Id) if !newIb.Enable { return nil } @@ -144,15 +146,15 @@ func (l *Local) updateAmneziaWGInbound(ctx context.Context, oldIb, newIb *model. _ = l.DelInbound(ctx, oldIb) } if !newIb.Enable { - amneziawg.GetManager().Remove(newIb.Id) + amneziawgnet.GetManager().Remove(newIb.Id) return nil } inst, ok := amneziawg.InstanceFromInbound(newIb) if !ok { - amneziawg.GetManager().Remove(newIb.Id) + amneziawgnet.GetManager().Remove(newIb.Id) return nil } - return amneziawg.GetManager().Ensure(inst) + return amneziawgnet.GetManager().Ensure(amneziawgnet.Desired{Instance: inst}) } func (l *Local) AddUser(_ context.Context, ib *model.Inbound, userMap map[string]any) error { diff --git a/internal/web/web.go b/internal/web/web.go index b12708d56..6b4183fcc 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -16,7 +16,7 @@ import ( "strings" "time" - "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" + "github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet" "github.com/mhsanaei/3x-ui/v3/internal/config" "github.com/mhsanaei/3x-ui/v3/internal/eventbus" "github.com/mhsanaei/3x-ui/v3/internal/logger" @@ -689,7 +689,7 @@ func (s *Server) stop(stopXray bool, stopTgBot bool) error { if stopXray { _ = s.xrayService.StopXray() mtproto.GetManager().StopAll() - amneziawg.GetManager().StopAll() + amneziawgnet.GetManager().StopAll() } if s.cron != nil { s.cron.Stop() From 59dff059c146ab514f76cc91a1473b201c2a90d9 Mon Sep 17 00:00:00 2001 From: Kuzz007 Date: Sun, 2 Aug 2026 20:01:08 +0300 Subject: [PATCH 07/12] feat(amneziawg): retire the kernel-module OS-shellout code and install.sh path Hard cutover, part 3: everything that only ever existed to drive the kernel-module (DKMS) + awg-quick + TPROXY architecture is gone now that internal/amneziawgnet's embedded path is wired in as the real thing. internal/amneziawg/manager.go -> instance.go (renamed, ~90% smaller): kept InstanceFromInbound and its direct helpers (interfaceNameForID, serverAddress, serverAddressV6) plus the exported FirstIPv4 (still used by server.go's access-log email index) -- all pure, protocol-shape-only code with no OS dependency, reused by both the old and new paths historically. Deleted the old Manager (GetManager/Ensure/Reconcile/StopAll/CollectTraffic/ the fingerprint methods), generateServerConfig and everything under it (writeObfuscation, defaultPostUpDown, appendOrTrue, detectDefaultInterface), and process control (interfaceUp/Down, syncConfig, getPeerStats, IsAwgInstalled). route_egress.go deleted entirely (the TPROXY bridge's port/fwmark/table constants and rule-rendering, fully superseded by internal/amneziawgnet's SOCKSPortForInbound/SocksPassword). portfwd.go trimmed to just the parsing/validation half (ForwardedPortsInclude, still used for save-time conflict checks); the iptables DNAT rendering half is gone -- per-client port-forwarding has no equivalent under the embedded path yet (tracked as Phase 3.6). install.sh: removed install_ndppd, enable_ipv6_forwarding, enable_tproxy_support, should/install_amneziawg, and check_secure_boot (and their call sites) -- roughly 265 lines. No more DKMS build, PPA/keyring setup, TPROXY kernel module loading, or Secure Boot warning: the embedded path needs none of it. Not in this commit (tracked as an explicit follow-up, not silently dropped): the frontend's routeThroughXray toggle is now vestigial (the field stays in the Go/JSON schema for backward compat with existing stored settings, see types.go) but its UI/schema removal needs the frontend type-regen + openapi.json hand-patch dance this fork always does for a settings-shape change, which is its own separate pass. Co-Authored-By: Claude Sonnet 5 --- install.sh | 265 ------ internal/amneziawg/instance.go | 143 +++ internal/amneziawg/instance_test.go | 122 +++ internal/amneziawg/manager.go | 1045 ---------------------- internal/amneziawg/manager_test.go | 566 ------------ internal/amneziawg/params.go | 15 +- internal/amneziawg/params_test.go | 9 - internal/amneziawg/portfwd.go | 104 +-- internal/amneziawg/route_egress.go | 83 -- internal/amneziawg/types.go | 49 +- internal/web/service/inbound_protocol.go | 2 +- 11 files changed, 312 insertions(+), 2091 deletions(-) create mode 100644 internal/amneziawg/instance.go create mode 100644 internal/amneziawg/instance_test.go delete mode 100644 internal/amneziawg/manager.go delete mode 100644 internal/amneziawg/manager_test.go delete mode 100644 internal/amneziawg/route_egress.go diff --git a/install.sh b/install.sh index 75d302919..71b2bbf77 100644 --- a/install.sh +++ b/install.sh @@ -123,234 +123,6 @@ install_base() { esac } -url_reachable() { - curl --connect-timeout 5 --max-time 10 -sSIL -o /dev/null "$1" 2>/dev/null -} - -# Probes URL reachability before relying on it (namely the AmneziaWG PPA host, -# which hosting providers — especially Russian VPS — frequently block). -# Non-interactive installs always skip-and-continue rather than block on a -# prompt; interactive installs ask, defaulting to skip so a flaky network -# doesn't abort the whole run over one optional feature. -check_url_or_skip() { - local url="$1" - local label="$2" - if url_reachable "$url"; then - return 0 - fi - echo "" - echo -e "${yellow}══════════════════════════════════════════════════════${plain}" - echo -e "${yellow} Failed to reach: ${url}${plain}" - echo -e "${yellow} Module / file: ${label}${plain}" - echo -e "${yellow}══════════════════════════════════════════════════════${plain}" - if [[ "$NONINTERACTIVE" == "1" ]]; then - echo -e "${yellow}Non-interactive install: skipping ${label}.${plain}" - return 1 - fi - read -rp "Continue without it? [Y/n]: " __skip_choice - case "${__skip_choice,,}" in - n | no) - echo -e "${red}Aborted by user.${plain}" - exit 1 - ;; - *) - echo -e "${yellow}Skipping ${label}.${plain}" - return 1 - ;; - esac -} - -# Installs ndppd (IPv6 NDP proxy), used by a future AmneziaWG IPv6 mode so -# clients can get a native public IPv6 address without NAT66. Not wired into -# the panel yet (tracked separately) — installed now so it's already in place -# once that lands. Best-effort: never fatal. -install_ndppd() { - case "${release}" in - ubuntu | debian | armbian) - apt-get install -y -q ndppd 2>/dev/null || true - ;; - fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol | centos) - dnf install -y ndppd 2>/dev/null || yum install -y ndppd 2>/dev/null || true - ;; - arch | manjaro | parch) - # -Sy (not -Syu): every other pacman call in this script only - # refreshes the package database, never does a full system - # upgrade as a side effect of installing one package. - pacman -Sy --noconfirm ndppd 2>/dev/null || true - ;; - esac -} - -# Persists IPv4/IPv6 forwarding across reboots. AmneziaWG's own PostUp already -# sets net.ipv4.ip_forward=1 for the current boot (see -# internal/amneziawg/manager.go's defaultPostUpDown), so this is a belt-and- -# suspenders persistence step, not the only place it's set. -enable_ipv6_forwarding() { - # Checking /etc/sysctl.conf by name is not reliable: many distros split - # sysctl settings across /etc/sysctl.d/*.conf, and /etc/sysctl.conf is - # sometimes just a symlink into that directory, so grep can miss an - # already-active setting (false negative -> harmless duplicate line) or - # match a disabled/commented one (false positive -> forwarding silently - # stays off). Querying the live value directly is accurate regardless of - # which file actually set it. - if [ "$(sysctl -n net.ipv6.conf.all.forwarding 2>/dev/null)" != "1" ]; then - echo "net.ipv6.conf.all.forwarding = 1" >> /etc/sysctl.conf - fi - if [ "$(sysctl -n net.ipv4.ip_forward 2>/dev/null)" != "1" ]; then - echo "net.ipv4.ip_forward = 1" >> /etc/sysctl.conf - fi - sysctl -p >/dev/null 2>&1 || true -} - -# Loads the mainline TPROXY kernel modules, used by AmneziaWG's optional -# per-client "route via Xray" toggle (see internal/amneziawg's EgressPort and -# defaultPostUpDown's `-j TPROXY` rules). Unlike the AmneziaWG module itself, -# these are standard upstream modules present on any modern distro kernel — -# no DKMS/PPA needed, just loading them. Best-effort: a panel without them -# still works fine, that one toggle just won't redirect traffic until -# they're available. -enable_tproxy_support() { - modprobe xt_TPROXY 2>/dev/null || true - modprobe nf_tproxy_ipv4 2>/dev/null || true - modprobe nf_tproxy_ipv6 2>/dev/null || true -} - -# Installs the AmneziaWG DKMS kernel module + amneziawg-tools (awg/awg-quick) -# so an AmneziaWG inbound created in the panel can actually bring up an -# interface. Best-effort and never fatal to the overall x-ui install: the -# panel works fine without it, an AmneziaWG inbound just won't start its -# tunnel until the module is installed (surfaced in the panel/logs, not here). -# AmneziaWG is this fork's signature feature, so it installs by default on -# every install/migration/update (see should_install_amneziawg below) -- -# opt-out, not opt-in, via XUI_INSTALL_AMNEZIAWG=false for anyone who -# specifically doesn't want the DKMS kernel module + host-wide IPv4/IPv6 -# forwarding it brings. -# -# should_install_amneziawg decides whether to run install_amneziawg at all. -# Short-circuits to yes when awg is already on PATH, so `x-ui update` on a -# host that already has it doesn't re-prompt an admin who already answered -# this once -- install_amneziawg's own case statement would just skip the -# actual DKMS/package work again anyway, but the interactive prompt itself -# still fired every run, and answering "n" out of habit (since AmneziaWG is -# already installed and working) skipped the harmless modprobe/ndppd/sysctl -# refresh that same case statement also does unconditionally. -# XUI_INSTALL_AMNEZIAWG=true/false answers it outright (for non-interactive/ -# cloud-init runs); otherwise an interactive install prompts (default: yes), -# and a non-interactive one with nothing to answer the prompt defaults to -# installing it too. -should_install_amneziawg() { - command -v awg &>/dev/null && return 0 - case "${XUI_INSTALL_AMNEZIAWG:-}" in - true | TRUE | 1 | yes | y | Y) return 0 ;; - false | FALSE | 0 | no | n | N) return 1 ;; - esac - if [[ "$NONINTERACTIVE" == "1" ]]; then - return 0 - fi - local reply - read -rp "Install native AmneziaWG support (WireGuard + DPI-resistant obfuscation)? This builds a DKMS kernel module and enables host-wide IP forwarding. (Y/n): " reply - [[ -z "$reply" || "$reply" == "y" || "$reply" == "Y" ]] -} - -# ppa:amnezia/ppa (Ubuntu/Debian/Armbian) is the primary, tested path; other -# distros fall back to plain wireguard-tools with a manual-install pointer. -# See https://github.com/amnezia-vpn/amneziawg-linux-kernel-module. -# -# Also requires Secure Boot to be OFF (checked separately, see -# check_secure_boot below) — a DKMS-built module is unsigned and the kernel -# refuses to load it while Secure Boot is enforced. -install_amneziawg() { - if command -v awg &>/dev/null; then - echo -e "${green}AmneziaWG (awg) already installed.${plain}" - modprobe amneziawg 2>/dev/null || true - install_ndppd - enable_ipv6_forwarding - enable_tproxy_support - return - fi - - echo -e "${green}Installing AmneziaWG...${plain}" - export DEBIAN_FRONTEND=noninteractive - export DEBCONF_NONINTERACTIVE_SEEN=true - - case "${release}" in - ubuntu | debian | armbian) - if ! check_url_or_skip "https://ppa.launchpadcontent.net/amnezia/ppa/ubuntu/dists/focal/Release" "AmneziaWG (ppa.launchpadcontent.net)"; then - echo -e "${yellow}Install it manually later if needed:${plain}" - echo -e "${yellow} https://github.com/amnezia-vpn/amneziawg-linux-kernel-module${plain}" - install_ndppd - return - fi - echo -e "${yellow}Installing amneziawg from ppa:amnezia/ppa...${plain}" - apt-get install -y -q software-properties-common python3-launchpadlib gnupg2 "linux-headers-$(uname -r)" 2>/dev/null || true - # Ensure deb-src is present (required for the PPA's DKMS build). - if ! grep -q "^deb-src" /etc/apt/sources.list 2>/dev/null; then - grep "^deb " /etc/apt/sources.list | sed 's/^deb /deb-src /' >> /etc/apt/sources.list - fi - if [[ "${release}" == "ubuntu" ]]; then - add-apt-repository -y ppa:amnezia/ppa 2>/dev/null && - apt-get update -q && - apt-get install -y amneziawg && - echo -e "${green}AmneziaWG installed successfully via PPA.${plain}" || - echo -e "${red}PPA install failed. Install amneziawg manually: https://github.com/amnezia-vpn/amneziawg-linux-kernel-module${plain}" - else - # apt-key is deprecated/removed on Debian 12+ and Ubuntu 24.04; - # fetch the key into its own keyring file and reference it via - # signed-by= instead of the removed system-wide trust store. - local amneziawg_keyring="/etc/apt/keyrings/amneziawg.gpg" - local amneziawg_list_entry="deb [signed-by=${amneziawg_keyring}] https://ppa.launchpadcontent.net/amnezia/ppa/ubuntu focal main" - local amneziawg_src_entry="deb-src [signed-by=${amneziawg_keyring}] https://ppa.launchpadcontent.net/amnezia/ppa/ubuntu focal main" - install -d -m 755 /etc/apt/keyrings - gpg --no-default-keyring --keyring "$amneziawg_keyring" --keyserver keyserver.ubuntu.com --recv-keys 57290828 2>/dev/null || true - # Guarded so a retried install (the PPA step failed last time, - # or install.sh simply ran again) doesn't keep appending - # duplicate sources.list entries. - grep -qxF "$amneziawg_list_entry" /etc/apt/sources.list 2>/dev/null || echo "$amneziawg_list_entry" >> /etc/apt/sources.list - grep -qxF "$amneziawg_src_entry" /etc/apt/sources.list 2>/dev/null || echo "$amneziawg_src_entry" >> /etc/apt/sources.list - apt-get update -q && - apt-get install -y amneziawg && - echo -e "${green}AmneziaWG installed successfully.${plain}" || - echo -e "${red}Install failed. Install amneziawg manually: https://github.com/amnezia-vpn/amneziawg-linux-kernel-module${plain}" - fi - modprobe amneziawg 2>/dev/null || true - install_ndppd - ;; - fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol | centos) - echo -e "${yellow}AmneziaWG has no prebuilt package for ${release}. Installing WireGuard as a fallback...${plain}" - dnf install -y -q wireguard-tools 2>/dev/null || yum install -y wireguard-tools 2>/dev/null || true - echo -e "${yellow}Note: for full AmneziaWG (obfuscated) support, install amneziawg-tools manually:${plain}" - echo -e "${yellow} https://github.com/amnezia-vpn/amneziawg-linux-kernel-module${plain}" - install_ndppd - ;; - arch | manjaro | parch) - pacman -Sy --noconfirm wireguard-tools 2>/dev/null || true - if command -v yay &>/dev/null; then - yay -S --noconfirm amneziawg-dkms amneziawg-tools 2>/dev/null || true - elif command -v paru &>/dev/null; then - paru -S --noconfirm amneziawg-dkms amneziawg-tools 2>/dev/null || true - else - echo -e "${yellow}Install an AUR helper (yay/paru) for amneziawg-dkms, or build it manually:${plain}" - echo -e "${yellow} https://github.com/amnezia-vpn/amneziawg-linux-kernel-module${plain}" - fi - install_ndppd - ;; - *) - echo -e "${yellow}${release}: no automated AmneziaWG install path. Install it manually if needed:${plain}" - echo -e "${yellow} https://github.com/amnezia-vpn/amneziawg-linux-kernel-module${plain}" - ;; - esac - - if command -v awg &>/dev/null; then - echo -e "${green}awg: $(awg --version 2>/dev/null || echo 'installed')${plain}" - else - echo -e "${yellow}Warning: 'awg' binary not found. The panel will work, but an AmneziaWG${plain}" - echo -e "${yellow}inbound's tunnel will not start until you install it manually.${plain}" - fi - - enable_ipv6_forwarding - enable_tproxy_support -} - gen_random_string() { local length="$1" openssl rand -base64 $((length * 2)) \ @@ -1966,41 +1738,4 @@ install_x-ui() { echo -e "${green}Running...${plain}" install_base -if should_install_amneziawg; then - install_amneziawg -else - echo -e "${yellow}Skipping AmneziaWG setup. To install it later, re-run with the variable${plain}" - echo -e "${yellow}exported first (a piped 'VAR=val curl ... | bash' only sets it for curl,${plain}" - echo -e "${yellow}not for bash -- export it in the current shell instead):${plain}" - echo -e "${yellow} export XUI_INSTALL_AMNEZIAWG=true${plain}" - echo -e "${yellow} curl -fsSL https://raw.githubusercontent.com/Kuzz007/3x-ui/main/install.sh | bash${plain}" - echo -e "${yellow}...or install it manually: https://github.com/amnezia-vpn/amneziawg-linux-kernel-module${plain}" -fi install_x-ui $1 - -# Secure Boot blocks the AmneziaWG DKMS module from loading (it's unsigned). -# Try mokutil first, fall back to reading the EFI variable directly. -check_secure_boot() { - if command -v mokutil &>/dev/null; then - mokutil --sb-state 2>/dev/null | grep -q "SecureBoot enabled" - return $? - fi - local sb_var - sb_var=$(find /sys/firmware/efi/efivars -name "SecureBoot-*" 2>/dev/null | head -1) - if [[ -n "$sb_var" ]]; then - [[ "$(od -An -tu1 -j4 -N1 "$sb_var" 2>/dev/null | tr -d ' ')" == "1" ]] - return $? - fi - return 1 -} - -if command -v awg &>/dev/null && check_secure_boot; then - echo -e "" - echo -e "${red}[!] WARNING: Secure Boot is ENABLED${plain}" - echo -e "${yellow}AmneziaWG's kernel module is unsigned and cannot load while Secure Boot${plain}" - echo -e "${yellow}is active — AmneziaWG tunnels will NOT work until it is disabled.${plain}" - echo -e "${yellow}Fix: turn off Secure Boot in your VPS provider's control panel, or in${plain}" - echo -e "${yellow}the VM's firmware/BIOS settings, then reboot. No reinstall needed${plain}" - echo -e "${yellow}afterward — AmneziaWG will start working on its own.${plain}" - echo -e "" -fi diff --git a/internal/amneziawg/instance.go b/internal/amneziawg/instance.go new file mode 100644 index 000000000..c6459b0d4 --- /dev/null +++ b/internal/amneziawg/instance.go @@ -0,0 +1,143 @@ +// Package amneziawg holds the AmneziaWG protocol's shared, DB-backed shapes +// (Instance, Peer, Obfuscation20, ServerSettings/InboundSettings) and the +// pure functions that derive an Instance from a stored inbound row. It no +// longer manages any OS-level interface itself: that was the kernel-module +// (DKMS) + awg-quick + TPROXY architecture this fork shipped originally, +// retired in favor of an embedded, pure-Go one (amneziawg-go over a gVisor +// netstack, see internal/amneziawgnet) in a hard cutover. This package's +// remaining code is deliberately protocol-shape-only, with no OS dependency +// at all, so both the (now-removed) kernel-module path and the embedded +// path could read -- and, historically, did read -- it identically. +package amneziawg + +import ( + "encoding/json" + "fmt" + "net/netip" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// InstanceFromInbound derives a desired Instance from an AmneziaWG inbound, +// building one peer per active client. Returns false when the inbound is not +// a usable AmneziaWG inbound (wrong protocol, unparseable settings, or no +// server block) or has no enabled peer to serve — mirroring +// mtproto.InstanceFromInbound, which skips the sidecar entirely rather than +// run it with nothing to serve. +func InstanceFromInbound(ib *model.Inbound) (Instance, bool) { + if ib == nil || ib.Protocol != model.AmneziaWG { + return Instance{}, false + } + var parsed InboundSettings + if err := json.Unmarshal([]byte(ib.Settings), &parsed); err != nil || parsed.Server == nil { + return Instance{}, false + } + server := parsed.Server + + peers := make([]Peer, 0, len(parsed.Clients)) + for _, c := range parsed.Clients { + if !c.Enable || c.PublicKey == "" || len(c.AllowedIPs) == 0 { + continue + } + peers = append(peers, Peer{ + Email: c.Email, + PublicKey: c.PublicKey, + PresharedKey: c.PreSharedKey, + AllowedIPs: c.AllowedIPs, + ForwardedPorts: c.ForwardedPorts, + }) + } + if len(peers) == 0 { + return Instance{}, false + } + + addresses := []string{serverAddress(server.SubnetIP, server.SubnetCIDR)} + if server.IPv6Enabled { + if v6, ok := serverAddressV6(server.IPv6Subnet); ok { + addresses = append(addresses, v6) + } + } + + return Instance{ + Id: ib.Id, + Tag: ib.Tag, + InterfaceName: interfaceNameForID(ib.Id), + ListenPort: ib.Port, + PrivateKey: server.PrivateKey, + PublicKey: server.PublicKey, + Address: addresses, + MTU: server.MTU, + Obfuscation: server.Obfuscation(), + Peers: peers, + ExternalInterface: server.ExternalInterface, + IPv6Enabled: server.IPv6Enabled, + IPv6ExternalInterface: server.IPv6ExternalInterface, + RouteThroughXray: server.RouteThroughXray, + }, true +} + +// interfaceNameForID derives the OS-level interface name for an inbound, e.g. +// "awg42". Kept even though the embedded path has no real kernel interface +// of its own: internal/amneziawgnet still uses the same name as a purely +// cosmetic/log-friendly label, so an existing peer's identity/history +// doesn't shift across the cutover. +func interfaceNameForID(id int) string { + return fmt.Sprintf("awg%d", id) +} + +// serverAddress returns the server's own tunnel address for a subnet base, +// e.g. "10.8.1.1/24" for base "10.8.1.0" or "10.8.1.5". The server always +// holds the first usable host of the network subnetIP/cidr actually +// describes -- derived via netip rather than assuming subnetIP already ends +// in ".0", so a subnetIP that isn't a bare network address (a typo, or a +// manually edited value) can never collide with peer addresses, which are +// allocated starting from the network's second host upward (see +// allocateWireguardAddress). Falls back to the previous literal behavior +// only if subnetIP/cidr doesn't parse as an IPv4 network at all -- normal +// saves never reach that path since ValidateSubnetIPv4 already rejects it. +func serverAddress(subnetIP string, cidr int) string { + if cidr <= 0 { + cidr = 24 + } + // A /32 has no host bits at all -- "first usable host" is meaningless, + // and Next() would step outside the block entirely -- so a single-host + // base is used exactly as given, same as before this fix. + prefix, err := netip.ParsePrefix(fmt.Sprintf("%s/%d", subnetIP, cidr)) + if err != nil || !prefix.Addr().Is4() || cidr >= 32 { + return fmt.Sprintf("%s/%d", subnetIP, cidr) + } + host := prefix.Masked().Addr().Next() + return fmt.Sprintf("%s/%d", host, cidr) +} + +// serverAddressV6 returns the server's own IPv6 tunnel address for a subnet +// CIDR (e.g. "fd86:ea04:1115::1/64" for "fd86:ea04:1115::/64"), the first +// usable host in the prefix. ok is false when subnetCIDR is empty or not a +// valid IPv6 prefix. +func serverAddressV6(subnetCIDR string) (addr string, ok bool) { + prefix, err := netip.ParsePrefix(subnetCIDR) + if err != nil || !prefix.Addr().Is6() { + return "", false + } + host := prefix.Masked().Addr().Next() + return fmt.Sprintf("%s/%d", host, prefix.Bits()), true +} + +// FirstIPv4 returns the first IPv4 address (mask stripped) among allowedIPs, +// or "" if none — used by internal/web/service/server.go's +// amneziawgEmailIndex to derive a peer's tunnel IPv4 address for the panel's +// access-log viewer. +func FirstIPv4(allowedIPs []string) string { + for _, a := range allowedIPs { + if prefix, err := netip.ParsePrefix(a); err == nil { + if prefix.Addr().Is4() { + return prefix.Addr().String() + } + continue + } + if addr, err := netip.ParseAddr(a); err == nil && addr.Is4() { + return addr.String() + } + } + return "" +} diff --git a/internal/amneziawg/instance_test.go b/internal/amneziawg/instance_test.go new file mode 100644 index 000000000..1ec382b36 --- /dev/null +++ b/internal/amneziawg/instance_test.go @@ -0,0 +1,122 @@ +package amneziawg + +import ( + "encoding/json" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +func mkInboundSettings(t *testing.T, server *ServerSettings, clients []model.Client) string { + t.Helper() + bs, err := json.Marshal(InboundSettings{Server: server, Clients: clients}) + if err != nil { + t.Fatalf("marshal settings: %v", err) + } + return string(bs) +} + +func validServer() *ServerSettings { + return &ServerSettings{ + PrivateKey: "serverPriv", + PublicKey: "serverPub", + SubnetIP: "10.8.1.0", + SubnetCIDR: 24, + } +} + +func TestInstanceFromInboundParsesEnabledPeers(t *testing.T) { + settings := mkInboundSettings(t, validServer(), []model.Client{ + {Email: "a@x", Enable: true, PublicKey: "pubA", PreSharedKey: "pskA", AllowedIPs: []string{"10.8.1.2/32"}}, + {Email: "b@x", Enable: false, PublicKey: "pubB", AllowedIPs: []string{"10.8.1.3/32"}}, + {Email: "c@x", Enable: true, PublicKey: "", AllowedIPs: []string{"10.8.1.4/32"}}, // no key: skipped + {Email: "d@x", Enable: true, PublicKey: "pubD", AllowedIPs: nil}, // no address: skipped + }) + ib := &model.Inbound{Id: 7, Tag: "awg-tag", Protocol: model.AmneziaWG, Port: 51820, Settings: settings} + + inst, ok := InstanceFromInbound(ib) + if !ok { + t.Fatal("expected a usable instance") + } + if inst.Id != 7 || inst.Tag != "awg-tag" || inst.ListenPort != 51820 { + t.Fatalf("instance identity not carried over: %+v", inst) + } + if inst.InterfaceName != "awg7" { + t.Fatalf("InterfaceName = %q, want awg7", inst.InterfaceName) + } + if len(inst.Address) != 1 || inst.Address[0] != "10.8.1.1/24" { + t.Fatalf("Address = %v, want [10.8.1.1/24]", inst.Address) + } + if len(inst.Peers) != 1 { + t.Fatalf("Peers = %+v, want exactly 1 (only a@x qualifies)", inst.Peers) + } + p := inst.Peers[0] + if p.Email != "a@x" || p.PublicKey != "pubA" || p.PresharedKey != "pskA" || len(p.AllowedIPs) != 1 || p.AllowedIPs[0] != "10.8.1.2/32" { + t.Fatalf("peer mismatch: %+v", p) + } +} + +func TestInstanceFromInboundRejectsWrongProtocol(t *testing.T) { + settings := mkInboundSettings(t, validServer(), []model.Client{ + {Email: "a@x", Enable: true, PublicKey: "pubA", AllowedIPs: []string{"10.8.1.2/32"}}, + }) + ib := &model.Inbound{Id: 1, Protocol: model.VLESS, Settings: settings} + if _, ok := InstanceFromInbound(ib); ok { + t.Fatal("non-AmneziaWG inbound must be rejected") + } +} + +func TestInstanceFromInboundRejectsNil(t *testing.T) { + if _, ok := InstanceFromInbound(nil); ok { + t.Fatal("nil inbound must be rejected") + } +} + +func TestInstanceFromInboundRejectsMissingServer(t *testing.T) { + ib := &model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: `{"clients":[]}`} + if _, ok := InstanceFromInbound(ib); ok { + t.Fatal("settings with no server block must be rejected") + } +} + +func TestInstanceFromInboundRejectsUnparseableSettings(t *testing.T) { + ib := &model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: `not json`} + if _, ok := InstanceFromInbound(ib); ok { + t.Fatal("unparseable settings must be rejected") + } +} + +func TestInstanceFromInboundEmptyWhenNoEnabledPeers(t *testing.T) { + settings := mkInboundSettings(t, validServer(), []model.Client{ + {Email: "a@x", Enable: false, PublicKey: "pubA", AllowedIPs: []string{"10.8.1.2/32"}}, + }) + ib := &model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: settings} + if _, ok := InstanceFromInbound(ib); ok { + t.Fatal("an inbound with zero enabled peers must be skipped, like mtproto.InstanceFromInbound") + } +} + +func TestServerAddress(t *testing.T) { + cases := []struct { + subnet string + cidr int + want string + }{ + {"10.8.1.0", 24, "10.8.1.1/24"}, + {"10.8.1.0", 0, "10.8.1.1/24"}, // cidr <= 0 defaults to /24 + {"10.8.1.5", 24, "10.8.1.1/24"}, // non-network base: must not collide with peer allocation starting at .2 + {"10.8.1.254", 24, "10.8.1.1/24"}, + {"192.168.5.10", 32, "192.168.5.10/32"}, // /32 has no host bits: used as-is + } + for _, c := range cases { + if got := serverAddress(c.subnet, c.cidr); got != c.want { + t.Errorf("serverAddress(%q, %d) = %q, want %q", c.subnet, c.cidr, got, c.want) + } + } +} + +func TestInterfaceNameForID(t *testing.T) { + if got := interfaceNameForID(42); got != "awg42" { + t.Errorf("interfaceNameForID(42) = %q, want awg42", got) + } +} diff --git a/internal/amneziawg/manager.go b/internal/amneziawg/manager.go deleted file mode 100644 index 90206ec0a..000000000 --- a/internal/amneziawg/manager.go +++ /dev/null @@ -1,1045 +0,0 @@ -package amneziawg - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "fmt" - "maps" - "net" - "net/netip" - "os" - "os/exec" - "path/filepath" - "slices" - "strconv" - "strings" - "sync" - "time" - - "github.com/mhsanaei/3x-ui/v3/internal/database/model" - "github.com/mhsanaei/3x-ui/v3/internal/logger" -) - -// configDir is where awg-quick expects to find .conf, matching -// the AmneziaWG DKMS package's own layout. -const configDir = "/etc/amnezia/amneziawg" - -// onlineWindow is how recent a peer's last handshake must be to count it as -// online, matching the typical WireGuard rekey interval (every 120s) plus -// margin. -const onlineWindow = 180 * time.Second - -// InstanceFromInbound derives a desired Instance from an AmneziaWG inbound, -// building one peer per active client. Returns false when the inbound is not -// a usable AmneziaWG inbound (wrong protocol, unparseable settings, or no -// server block) or has no enabled peer to serve — mirroring -// mtproto.InstanceFromInbound, which skips the sidecar entirely rather than -// run it with nothing to serve. -func InstanceFromInbound(ib *model.Inbound) (Instance, bool) { - if ib == nil || ib.Protocol != model.AmneziaWG { - return Instance{}, false - } - var parsed InboundSettings - if err := json.Unmarshal([]byte(ib.Settings), &parsed); err != nil || parsed.Server == nil { - return Instance{}, false - } - server := parsed.Server - - peers := make([]Peer, 0, len(parsed.Clients)) - for _, c := range parsed.Clients { - if !c.Enable || c.PublicKey == "" || len(c.AllowedIPs) == 0 { - continue - } - peers = append(peers, Peer{ - Email: c.Email, - PublicKey: c.PublicKey, - PresharedKey: c.PreSharedKey, - AllowedIPs: c.AllowedIPs, - ForwardedPorts: c.ForwardedPorts, - }) - } - if len(peers) == 0 { - return Instance{}, false - } - - addresses := []string{serverAddress(server.SubnetIP, server.SubnetCIDR)} - if server.IPv6Enabled { - if v6, ok := serverAddressV6(server.IPv6Subnet); ok { - addresses = append(addresses, v6) - } - } - - return Instance{ - Id: ib.Id, - Tag: ib.Tag, - InterfaceName: interfaceNameForID(ib.Id), - ListenPort: ib.Port, - PrivateKey: server.PrivateKey, - PublicKey: server.PublicKey, - Address: addresses, - MTU: server.MTU, - Obfuscation: server.Obfuscation(), - Peers: peers, - ExternalInterface: server.ExternalInterface, - IPv6Enabled: server.IPv6Enabled, - IPv6ExternalInterface: server.IPv6ExternalInterface, - RouteThroughXray: server.RouteThroughXray, - }, true -} - -// interfaceNameForID derives the OS-level interface name for an inbound, e.g. -// "awg42". -func interfaceNameForID(id int) string { - return fmt.Sprintf("awg%d", id) -} - -// serverAddress returns the server's own tunnel address for a subnet base, -// e.g. "10.8.1.1/24" for base "10.8.1.0" or "10.8.1.5". The server always -// holds the first usable host of the network subnetIP/cidr actually -// describes -- derived via netip rather than assuming subnetIP already ends -// in ".0", so a subnetIP that isn't a bare network address (a typo, or a -// manually edited value) can never collide with peer addresses, which are -// allocated starting from the network's second host upward (see -// allocateWireguardAddress). Falls back to the previous literal behavior -// only if subnetIP/cidr doesn't parse as an IPv4 network at all -- normal -// saves never reach that path since ValidateSubnetIPv4 already rejects it. -func serverAddress(subnetIP string, cidr int) string { - if cidr <= 0 { - cidr = 24 - } - // A /32 has no host bits at all -- "first usable host" is meaningless, - // and Next() would step outside the block entirely -- so a single-host - // base is used exactly as given, same as before this fix. - prefix, err := netip.ParsePrefix(fmt.Sprintf("%s/%d", subnetIP, cidr)) - if err != nil || !prefix.Addr().Is4() || cidr >= 32 { - return fmt.Sprintf("%s/%d", subnetIP, cidr) - } - host := prefix.Masked().Addr().Next() - return fmt.Sprintf("%s/%d", host, cidr) -} - -// serverAddressV6 returns the server's own IPv6 tunnel address for a subnet -// CIDR (e.g. "fd86:ea04:1115::1/64" for "fd86:ea04:1115::/64"), the first -// usable host in the prefix. ok is false when subnetCIDR is empty or not a -// valid IPv6 prefix. -func serverAddressV6(subnetCIDR string) (addr string, ok bool) { - prefix, err := netip.ParsePrefix(subnetCIDR) - if err != nil || !prefix.Addr().Is6() { - return "", false - } - host := prefix.Masked().Addr().Next() - return fmt.Sprintf("%s/%d", host, prefix.Bits()), true -} - -// structuralFingerprint changes whenever a value that requires a full -// interface bounce (awg-quick down + up) changes. -func (inst Instance) structuralFingerprint() string { - o := inst.Obfuscation - parts := []string{ - inst.InterfaceName, - strconv.Itoa(inst.ListenPort), - inst.PrivateKey, - strings.Join(inst.Address, ","), - strconv.Itoa(inst.MTU), - strconv.Itoa(o.Jc), strconv.Itoa(o.Jmin), strconv.Itoa(o.Jmax), - strconv.Itoa(o.S1), strconv.Itoa(o.S2), strconv.Itoa(o.S3), strconv.Itoa(o.S4), - o.H1, o.H2, o.H3, o.H4, o.I1, - inst.ExternalInterface, - strconv.FormatBool(inst.IPv6Enabled), - inst.IPv6ExternalInterface, - strconv.FormatBool(inst.RouteThroughXray), - } - return strings.Join(parts, "|") -} - -// peersFingerprint identifies the reloadable peer set regardless of order, so -// a reordered clients array in the stored settings does not read as a -// change. It moves whenever a peer is added, removed, disabled, re-keyed, or -// re-addressed — all of which `awg syncconf` applies in place. Deliberately -// excludes ForwardedPorts: those live in PostUp/PostDown, not the WireGuard -// peer table, so a ports-only change needs hostRulesFingerprint's full -// bounce instead of a syncconf reload. -func (inst Instance) peersFingerprint() string { - pairs := make([]string, 0, len(inst.Peers)) - for _, p := range inst.Peers { - pairs = append(pairs, fmt.Sprintf("%s=%s;psk=%s;ips=%s", p.Email, p.PublicKey, p.PresharedKey, strings.Join(p.AllowedIPs, ","))) - } - slices.Sort(pairs) - return strings.Join(pairs, "|") -} - -// hostRulesFingerprint identifies per-peer state that only ever takes effect -// through PostUp/PostDown shell rules — forwarded ports (whose DNAT rules -// are keyed on the peer's IPv4 address, the same as the TPROXY rule below); -// when RouteThroughXray is on, every peer's IPv4 address (the TPROXY rule -// into this instance's own Xray bridge is keyed on it); and when IPv6 is -// enabled, the peer's IPv6 address (its NDP-proxy PostUp/PostDown entry) — -// rather than the WireGuard peer table itself. It is checked separately from -// peersFingerprint because `awg syncconf` never re-runs PostUp/PostDown, so -// a change here must force a full interface bounce (ensureRestart) to -// actually take effect, unlike a key-only change that syncconf can apply in -// place. The IPv4 component is included whenever RouteThroughXray is on OR -// the peer has forwarded ports — either one means PostUp/PostDown text is -// keyed on that address, so a re-IP with either feature off must still force -// a bounce (otherwise the old DNAT/TPROXY rule survives pointed at an -// address the reconciler is now free to hand to a different peer). The IPv6 -// component stays IPv6Enabled-gated only, matching the single feature that -// reads it. Skipping both entirely when neither applies preserves the -// syncconf fast path for a plain instance's peer add/remove/re-IP. -func (inst Instance) hostRulesFingerprint() string { - pairs := make([]string, 0, len(inst.Peers)) - for _, p := range inst.Peers { - v := fmt.Sprintf("%s=fwd:%s", p.Email, p.ForwardedPorts) - if inst.RouteThroughXray || p.ForwardedPorts != "" { - v += ";ip:" + FirstIPv4(p.AllowedIPs) - } - if inst.IPv6Enabled { - v += ";ip6:" + firstIPv6(p.AllowedIPs) - } - pairs = append(pairs, v) - } - slices.Sort(pairs) - return strings.Join(pairs, "|") -} - -// peerCounters is the last-seen cumulative transfer counters for one peer, -// used to compute per-poll deltas the same way mtproto tracks per-secret -// counters. -type peerCounters struct { - rx int64 - tx int64 -} - -type managed struct { - inst Instance - structuralFP string - peersFP string - hostRulesFP string - last map[string]peerCounters // keyed by peer public key -} - -// Manager owns the set of running AmneziaWG interfaces keyed by inbound id. -type Manager struct { - mu sync.Mutex - ifaces map[int]*managed - // swept records that the one-time startup cleanup of orphaned interfaces - // (survivors of a previous x-ui run) has already run. - swept bool -} - -var ( - managerOnce sync.Once - manager *Manager -) - -// GetManager returns the process-wide AmneziaWG manager singleton. -func GetManager() *Manager { - managerOnce.Do(func() { - manager = &Manager{ifaces: map[int]*managed{}} - }) - return manager -} - -// ensureAction is what ensureLocked must do to move a running interface to a -// desired instance: leave it alone, hot-reload just its peers, or fully -// bounce it. -type ensureAction int - -const ( - ensureNoop ensureAction = iota - ensureReload - ensureRestart -) - -// ensureActionFor decides how to apply a desired instance to the currently -// managed interface. A structural change, a host-rules change (forwarded -// ports, or simply a peer's presence/IP — its always-on TPROXY rule only -// lives in PostUp/PostDown), or a down interface all force a restart; a -// peers-only change (keys only, no IP/presence change) is a candidate for -// an in-place `syncconf`; identical fingerprints on an up interface need -// nothing. -func ensureActionFor(up bool, curStructFP, curHostRulesFP, curPeersFP, newStructFP, newHostRulesFP, newPeersFP string) ensureAction { - if !up || curStructFP != newStructFP || curHostRulesFP != newHostRulesFP { - return ensureRestart - } - if curPeersFP != newPeersFP { - return ensureReload - } - return ensureNoop -} - -// Ensure brings one interface to its desired state, or restarts/reloads it -// when its configuration changed. A no-op when it already matches. -func (m *Manager) Ensure(inst Instance) error { - m.mu.Lock() - defer m.mu.Unlock() - return m.ensureLocked(inst) -} - -func (m *Manager) ensureLocked(inst Instance) error { - structFP := inst.structuralFingerprint() - hostRulesFP := inst.hostRulesFingerprint() - peersFP := inst.peersFingerprint() - - cur, exists := m.ifaces[inst.Id] - action := ensureRestart - if exists { - action = ensureActionFor(isInterfaceUp(cur.inst.InterfaceName), cur.structuralFP, cur.hostRulesFP, cur.peersFP, structFP, hostRulesFP, peersFP) - } - - switch action { - case ensureNoop: - cur.inst = inst - return nil - case ensureReload: - if err := writeConfigFile(inst); err != nil { - return err - } - if err := syncConfig(inst); err != nil { - return err - } - case ensureRestart: - // Checked against the interface's actual kernel state, not `exists`: - // after an ungraceful exit (kill -9, OOM, panic) the previous - // process's interface can still be up even though this fresh - // Manager has never seen it (exists is always false on a cold - // start). Skipping the teardown in that case would send - // interfaceUp straight into "ip link add" against a name that - // already exists, which fails and leaves this inbound stuck - // retrying every reconcile forever. - if isInterfaceUp(inst.InterfaceName) { - _ = interfaceDown(inst.InterfaceName) - } - if err := writeConfigFile(inst); err != nil { - return err - } - if err := interfaceUp(inst.InterfaceName); err != nil { - return err - } - logger.Infof("amneziawg: started interface %s for inbound %d", inst.InterfaceName, inst.Id) - } - - last := map[string]peerCounters{} - if exists { - last = nextTrafficBaseline(action, cur.last) - } - m.ifaces[inst.Id] = &managed{inst: inst, structuralFP: structFP, hostRulesFP: hostRulesFP, peersFP: peersFP, last: last} - return nil -} - -// nextTrafficBaseline decides what per-peer traffic counters ensureLocked -// should carry into the next managed entry. Only a reload (awg syncconf) -// preserves the kernel's own per-peer transfer counters; a full down+up -// zeroes them. Carrying the old baseline forward after a restart would make -// the next CollectTraffic compute a large negative delta (clamped to 0 by -// the caller), silently discarding whatever the peers transferred since the -// previous poll instead of just resuming the count from zero. -func nextTrafficBaseline(action ensureAction, prev map[string]peerCounters) map[string]peerCounters { - if action == ensureReload { - return prev - } - return map[string]peerCounters{} -} - -// Remove tears down and forgets the interface for an inbound id. -func (m *Manager) Remove(id int) { - m.mu.Lock() - defer m.mu.Unlock() - if cur, ok := m.ifaces[id]; ok { - _ = interfaceDown(cur.inst.InterfaceName) - removeConfigFile(cur.inst.InterfaceName) - delete(m.ifaces, id) - logger.Infof("amneziawg: stopped interface %s for inbound %d", cur.inst.InterfaceName, id) - } -} - -// sweepOrphansLocked tears down any AmneziaWG interface and config file left -// behind by a previous x-ui process whose inbound is no longer in the -// current desired set — most commonly because it was deleted from the -// database entirely while the panel was down, so it will never again appear -// in any future Reconcile call and would otherwise never be discovered (it -// has no entry in m.ifaces for the per-id cleanup loop below to catch, -// because that map always starts empty on a fresh process). Runs once per -// process lifetime, mirroring mtproto.Manager.sweepOrphansLocked. -// -// Deliberately only called from Reconcile, not Ensure: Ensure only ever -// carries a single instance, and a `want` set of just that one id would -// misidentify every other still-desired-but-not-yet-reconciled-this-process -// interface as an orphan. A crashed-but-still-wanted interface is instead -// recovered normally by ensureLocked's ensureRestart branch, which checks -// the interface's actual kernel state rather than this manager's in-memory -// bookkeeping. -func (m *Manager) sweepOrphansLocked(want map[int]struct{}) { - if m.swept { - return - } - entries, err := os.ReadDir(configDir) - if err != nil { - // Left false on purpose: a transient error (the directory not existing - // yet, a momentary filesystem hiccup) should let the next Reconcile - // tick retry the sweep, rather than permanently disabling it for this - // process's whole lifetime over a failure that may not recur. - return - } - m.swept = true - names := make([]string, 0, len(entries)) - for _, entry := range entries { - if !entry.IsDir() { - names = append(names, entry.Name()) - } - } - for _, ifaceName := range orphanedInterfaces(names, want) { - if isInterfaceUp(ifaceName) { - _ = interfaceDown(ifaceName) - logger.Warningf("amneziawg: tore down orphaned interface %s (its inbound no longer exists)", ifaceName) - } - removeConfigFile(ifaceName) - } -} - -// orphanedInterfaces returns the interface names among confFileNames (the -// basenames of configDir's entries) whose parsed inbound id is not present -// in want — the pure decision sweepOrphansLocked acts on. -func orphanedInterfaces(confFileNames []string, want map[int]struct{}) []string { - var out []string - for _, name := range confFileNames { - if !strings.HasSuffix(name, ".conf") { - continue - } - ifaceName := strings.TrimSuffix(name, ".conf") - id, ok := inboundIDForInterfaceName(ifaceName) - if !ok { - continue - } - if _, wanted := want[id]; wanted { - continue - } - out = append(out, ifaceName) - } - return out -} - -// inboundIDForInterfaceName parses the inbound id back out of an interface -// name produced by interfaceNameForID, e.g. "awg42" -> 42, ok=true. Requires -// the suffix to be all decimal digits so a stray or hand-crafted file name -// (e.g. "awg-1.conf") can never resolve to a negative id. -func inboundIDForInterfaceName(name string) (int, bool) { - suffix, ok := strings.CutPrefix(name, "awg") - if !ok || suffix == "" { - return 0, false - } - for _, r := range suffix { - if r < '0' || r > '9' { - return 0, false - } - } - id, err := strconv.Atoi(suffix) - if err != nil { - return 0, false - } - return id, true -} - -// Reconcile drives the running set toward the desired instances: it tears -// down interfaces that are no longer wanted and ensures the rest. Used at -// boot and periodically to recover from crashes or an out-of-band `awg-quick -// down`. -func (m *Manager) Reconcile(desired []Instance) { - m.mu.Lock() - defer m.mu.Unlock() - want := make(map[int]struct{}, len(desired)) - for _, inst := range desired { - want[inst.Id] = struct{}{} - } - m.sweepOrphansLocked(want) - for id, cur := range m.ifaces { - if _, ok := want[id]; !ok { - _ = interfaceDown(cur.inst.InterfaceName) - removeConfigFile(cur.inst.InterfaceName) - delete(m.ifaces, id) - logger.Infof("amneziawg: stopped interface %s for removed inbound %d", cur.inst.InterfaceName, id) - } - } - for _, inst := range desired { - if err := m.ensureLocked(inst); err != nil { - logger.Warningf("amneziawg: reconcile failed for inbound %d: %v", inst.Id, err) - } - } -} - -// StopAll tears down every managed interface. Called on panel shutdown. -func (m *Manager) StopAll() { - m.mu.Lock() - defer m.mu.Unlock() - for id, cur := range m.ifaces { - _ = interfaceDown(cur.inst.InterfaceName) - delete(m.ifaces, id) - } -} - -// HasRunning reports whether any managed interface is currently up. -func (m *Manager) HasRunning() bool { - m.mu.Lock() - defer m.mu.Unlock() - for _, cur := range m.ifaces { - if isInterfaceUp(cur.inst.InterfaceName) { - return true - } - } - return false -} - -// Traffic is a per-peer traffic delta scraped from `awg show dump`. -// Tag is the owning inbound's tag and Email is the client the bytes belong -// to. -type Traffic struct { - Tag string - Email string - Up int64 - Down int64 -} - -// CollectTraffic polls `awg show dump` for every running interface -// and returns the per-peer byte deltas since the previous poll, plus the -// emails of peers with a handshake inside onlineWindow. -func (m *Manager) CollectTraffic() ([]Traffic, []string) { - type snap struct { - id int - inst Instance - last map[string]peerCounters - // entry is the exact *managed snapshotted below, kept so the - // write-back can detect a concurrent ensureRestart/ensureReload - // (which replaces the map entry with a fresh pointer, see - // ensureLocked) that happened while getPeerStats ran lock-free. - entry *managed - } - m.mu.Lock() - snaps := make([]snap, 0, len(m.ifaces)) - for id, cur := range m.ifaces { - lastCopy := make(map[string]peerCounters, len(cur.last)) - maps.Copy(lastCopy, cur.last) - snaps = append(snaps, snap{id: id, inst: cur.inst, last: lastCopy, entry: cur}) - } - m.mu.Unlock() - - var out []Traffic - var online []string - now := time.Now() - - for _, s := range snaps { - stats, err := getPeerStats(s.inst.InterfaceName) - if err != nil { - continue - } - emailByKey := make(map[string]string, len(s.inst.Peers)) - for _, p := range s.inst.Peers { - emailByKey[p.PublicKey] = p.Email - } - - newLast := make(map[string]peerCounters, len(stats)) - for _, st := range stats { - email, ok := emailByKey[st.publicKey] - if !ok || email == "" { - continue - } - newLast[st.publicKey] = peerCounters{rx: st.rx, tx: st.tx} - if st.latestHandshake > 0 && now.Sub(time.Unix(st.latestHandshake, 0)) < onlineWindow { - online = append(online, email) - } - prev, had := s.last[st.publicKey] - if !had { - continue - } - du := st.rx - prev.rx // client upload = bytes the server received - dd := st.tx - prev.tx // client download = bytes the server sent - if du < 0 { - du = 0 - } - if dd < 0 { - dd = 0 - } - if du > 0 || dd > 0 { - out = append(out, Traffic{Tag: s.inst.Tag, Email: email, Up: du, Down: dd}) - } - } - - m.mu.Lock() - // Only write back if this is still the exact entry snapshotted above: - // getPeerStats ran without the lock held, so ensureLocked could have - // restarted (or reloaded) this same interface in the meantime, - // replacing the map entry with a fresh *managed and, for a restart, - // resetting last to empty (kernel counters zero on down+up). Writing - // newLast back over that unconditionally would silently resurrect the - // pre-restart counters as the new baseline, making the next poll - // compute a negative delta and clamp a real poll's worth of traffic - // to zero. - if cur, ok := m.ifaces[s.id]; ok && cur == s.entry { - cur.last = newLast - } - m.mu.Unlock() - } - return out, online -} - -// --- config rendering --- - -// generateServerConfig builds the awg-quick .conf content for an interface: -// its own [Interface] block (keys, address, obfuscation, NAT PostUp/PostDown) -// followed by one [Peer] block per client. -func generateServerConfig(inst Instance) string { - var b strings.Builder - - b.WriteString("[Interface]\n") - fmt.Fprintf(&b, "PrivateKey = %s\n", sanitizeConfigValue(inst.PrivateKey)) - if len(inst.Address) > 0 { - fmt.Fprintf(&b, "Address = %s\n", strings.Join(inst.Address, ", ")) - } - fmt.Fprintf(&b, "ListenPort = %d\n", inst.ListenPort) - if inst.MTU > 0 { - fmt.Fprintf(&b, "MTU = %d\n", inst.MTU) - } - writeObfuscation(&b, inst.Obfuscation) - - ext := inst.ExternalInterface - if ext == "" { - ext = detectDefaultInterface() - } - postUp, postDown := defaultPostUpDown(inst, ext) - fmt.Fprintf(&b, "PostUp = %s\n", postUp) - fmt.Fprintf(&b, "PostDown = %s\n", postDown) - - for _, p := range inst.Peers { - b.WriteString("\n[Peer]\n") - if p.Email != "" { - fmt.Fprintf(&b, "# %s\n", sanitizeConfigValue(p.Email)) - } - fmt.Fprintf(&b, "PublicKey = %s\n", sanitizeConfigValue(p.PublicKey)) - if p.PresharedKey != "" { - fmt.Fprintf(&b, "PresharedKey = %s\n", sanitizeConfigValue(p.PresharedKey)) - } - fmt.Fprintf(&b, "AllowedIPs = %s\n", strings.Join(p.AllowedIPs, ", ")) - } - - return b.String() -} - -// sanitizeConfigValue strips newlines, carriage returns, and other control -// characters from a value about to be interpolated into the generated -// .conf. ValidateConfigValue rejects these at save time, but a row that -// predates that validation (an upgrade, a node sync, a restored backup, a -// direct DB edit) would otherwise still reach awg-quick's parser, where a -// newline lets a later line re-open a new section and smuggle in a hook -// awg-quick executes as root. This is the render-time backstop; it -// silently drops the offending bytes rather than failing the whole config -// build, matching how hOrDefault degrades a blank H value instead of -// emitting an invalid line. -func sanitizeConfigValue(v string) string { - return strings.Map(func(r rune) rune { - if r == '\n' || r == '\r' || r < 0x20 || r == 0x7f { - return -1 - } - return r - }, v) -} - -// writeObfuscation writes the AmneziaWG obfuscation parameters that must be -// identical on both ends of a tunnel. S3/S4 and I1 are emitted only when set, -// so a plain 1.x-equivalent set (S3=S4=0, I1="") produces the classic -// generator's output; a 2.0 set adds the extra padding, header ranges and CPS -// packet. -func writeObfuscation(b *strings.Builder, o Obfuscation20) { - fmt.Fprintf(b, "Jc = %d\n", o.Jc) - fmt.Fprintf(b, "Jmin = %d\n", o.Jmin) - fmt.Fprintf(b, "Jmax = %d\n", o.Jmax) - fmt.Fprintf(b, "S1 = %d\n", o.S1) - fmt.Fprintf(b, "S2 = %d\n", o.S2) - if o.S3 > 0 { - fmt.Fprintf(b, "S3 = %d\n", o.S3) - } - if o.S4 > 0 { - fmt.Fprintf(b, "S4 = %d\n", o.S4) - } - fmt.Fprintf(b, "H1 = %s\n", hOrDefault(o.H1, "1")) - fmt.Fprintf(b, "H2 = %s\n", hOrDefault(o.H2, "2")) - fmt.Fprintf(b, "H3 = %s\n", hOrDefault(o.H3, "3")) - fmt.Fprintf(b, "H4 = %s\n", hOrDefault(o.H4, "4")) - if o.I1 != "" { - fmt.Fprintf(b, "I1 = %s\n", sanitizeConfigValue(o.I1)) - } -} - -// hOrDefault returns def when v is blank, guarding against an empty H value -// (which would emit an invalid "H1 = " line) on legacy/partial records. -func hOrDefault(v, def string) string { - if strings.TrimSpace(v) == "" { - return def - } - return v -} - -// defaultPostUpDown returns NAT + forwarding rules: MASQUERADE the tunnel -// subnet out the external interface, accept forwarded traffic in both -// directions, and — when the instance has IPv6 enabled — the IPv6-forward -// rules, proxy_ndp sysctl, and one `ip -6 neigh add proxy` entry per enabled -// peer with an IPv6 address, so upstream routers see each client's IPv6 as -// directly reachable on the LAN without NAT66. Also emits DNAT+FORWARD rules -// for each enabled peer with a non-empty ForwardedPorts spec, and — only -// when the instance has RouteThroughXray enabled — a mangle-table TPROXY -// rule redirecting every peer's traffic into this instance's own Xray -// bridge (see EgressPortForInbound), plus the one-time policy route TPROXY -// needs to deliver it there. RouteThroughXray is off by default: a plain -// AmneziaWG tunnel has no Xray dependency at all unless the admin opts in. -// When it is on, it is entirely up to the admin's own Xray Routing rules -// (targeting this inbound's own tag, which injectAmneziawgEgress reuses for -// the bridge) whether that traffic ever actually goes anywhere beyond -// Xray's default routing. -func defaultPostUpDown(inst Instance, ext string) (postUp, postDown string) { - iface := inst.InterfaceName - up := []string{ - fmt.Sprintf("iptables -A FORWARD -i %s -j ACCEPT", iface), - fmt.Sprintf("iptables -A FORWARD -o %s -j ACCEPT", iface), - } - down := []string{ - fmt.Sprintf("iptables -D FORWARD -i %s -j ACCEPT", iface), - fmt.Sprintf("iptables -D FORWARD -o %s -j ACCEPT", iface), - } - if subnet := firstAddress(inst.Address); subnet != "" && ext != "" { - up = append([]string{fmt.Sprintf("iptables -t nat -A POSTROUTING -s %s -o %s -j MASQUERADE", subnet, ext)}, up...) - down = append([]string{fmt.Sprintf("iptables -t nat -D POSTROUTING -s %s -o %s -j MASQUERADE", subnet, ext)}, down...) - } - - if inst.IPv6Enabled { - ext6 := inst.IPv6ExternalInterface - if ext6 == "" { - ext6 = ext - } - up = append(up, - fmt.Sprintf("ip6tables -A FORWARD -i %s -j ACCEPT", iface), - fmt.Sprintf("ip6tables -A FORWARD -o %s -j ACCEPT", iface), - fmt.Sprintf("ip6tables -A FORWARD -i %s -o %s -j ACCEPT", ext6, iface), - "sysctl -w net.ipv6.conf.all.forwarding=1", - fmt.Sprintf("sysctl -w net.ipv6.conf.%s.proxy_ndp=1", ext6), - ) - down = append(down, - fmt.Sprintf("ip6tables -D FORWARD -i %s -j ACCEPT", iface), - fmt.Sprintf("ip6tables -D FORWARD -o %s -j ACCEPT", iface), - fmt.Sprintf("ip6tables -D FORWARD -i %s -o %s -j ACCEPT", ext6, iface), - ) - for _, p := range inst.Peers { - ip6 := firstIPv6(p.AllowedIPs) - if ip6 == "" { - continue - } - up = append(up, fmt.Sprintf("ip -6 neigh add proxy %s dev %s", ip6, ext6)) - down = append(down, fmt.Sprintf("ip -6 neigh del proxy %s dev %s", ip6, ext6)) - } - } - - for _, p := range inst.Peers { - if p.ForwardedPorts == "" { - continue - } - clientIP := FirstIPv4(p.AllowedIPs) - if clientIP == "" { - continue - } - up = append(up, portForwardLines("-A", ext, iface, clientIP, p.Email, p.ForwardedPorts)...) - down = append(down, portForwardLines("-D", ext, iface, clientIP, p.Email, p.ForwardedPorts)...) - } - - if inst.RouteThroughXray { - egressPort := EgressPortForInbound(inst.Id) - anyPeerTproxied := false - for _, p := range inst.Peers { - clientIP := FirstIPv4(p.AllowedIPs) - if clientIP == "" { - continue - } - up = append(up, routeEgressLines("-A", iface, clientIP, p.Email, egressPort)...) - down = append(down, routeEgressLines("-D", iface, clientIP, p.Email, egressPort)...) - anyPeerTproxied = true - } - if anyPeerTproxied { - // The fwmark->table->local-everywhere policy route is what lets TPROXY - // deliver a peer's packets to this instance's own Xray bridge even - // though their destination is never one of this host's own addresses. - // It is system-wide, not interface-specific, so — like the - // IPv6-forwarding sysctl above — it is added idempotently here and - // never torn down in PostDown; a second AmneziaWG instance must find - // it already in place, not race to remove what the first still needs. - // "ip rule add" is not itself idempotent (a second call inserts a - // duplicate rather than deduplicating), and hostRulesFingerprint keys - // on every peer's presence/IP when RouteThroughXray is on, so PostUp - // re-runs on any client add/remove/re-IP — without the existence - // check below, "ip rule show" would accumulate one duplicate entry - // per bounce forever. - // - // TPROXY never rewrites the packet's own destination address — only - // the routing decision changes, via the fwmark+table trick above — so - // by the time this packet reaches the host's own INPUT chain, its - // destination still looks like some remote address (e.g. 8.8.8.8), - // never this host's own. A default-deny firewall whose INPUT chain - // sanity-checks "is this destination actually local" (UFW's - // ufw-not-local, using addrtype --dst-type LOCAL, is exactly this) can - // never see it as legitimate and silently drops it before Xray's - // socket ever sees a single byte — TPROXY's own counters keep - // incrementing the whole time, making this look like a Xray-side bug - // even though Xray never gets the chance to fail. The fix is the same - // shape as the policy route above: an idempotent, never-torn-down, - // system-wide accept for this fwmark, inserted at the very front of - // the base INPUT chain so it runs before any such sanity check, - // regardless of which firewall manager (ufw, firewalld, bare - // iptables) owns the rest of that chain. - up = append(up, - // grep -c (not -q): -q exits as soon as it matches, so "ip rule - // list" can take SIGPIPE; under `set -o pipefail` the pipeline then - // reports 141 even though the rule WAS found, and "ip rule add" - // below runs anyway -- reintroducing the exact duplicate-rule - // accumulation this existence check exists to prevent. -c reads - // every line to completion and still exits 1 on no match. - fmt.Sprintf("ip rule list | grep -c 'fwmark %#x lookup %d' >/dev/null || ip rule add fwmark %#x lookup %d", EgressFwmark, EgressTable, EgressFwmark, EgressTable), - fmt.Sprintf("ip route replace local 0.0.0.0/0 dev lo table %d", EgressTable), - fmt.Sprintf("iptables -C INPUT -m mark --mark %#x -j ACCEPT 2>/dev/null || iptables -I INPUT 1 -m mark --mark %#x -j ACCEPT", EgressFwmark, EgressFwmark), - ) - } - } - - up = append(up, "sysctl -w net.ipv4.ip_forward=1") - return strings.Join(up, "; "), strings.Join(appendOrTrue(down), "; ") -} - -// appendOrTrue suffixes every command with " || true", making the whole -// PostDown chain best-effort. wg-quick/awg-quick joins hook commands with -// "; " and runs the result under `set -e -o pipefail`, so the first non-zero -// command aborts everything after it. On teardown that matters: if -// something has already flushed the filter table out from under the -// interface (a ufw/firewalld reload, fail2ban rebuilding its chains), the -// first "-D" fails and every command after it — including the nat-table DNAT -// deletes a flush does NOT remove — is skipped, and the next PostUp re-adds -// them, accumulating one set per bounce. PostUp is left alone: a real setup -// failure there should still surface, not be silently swallowed. -func appendOrTrue(cmds []string) []string { - out := make([]string, len(cmds)) - for i, c := range cmds { - out[i] = c + " || true" - } - return out -} - -// firstAddress returns the first configured interface address, used as the -// NAT source subnet for PostUp/PostDown. -func firstAddress(addresses []string) string { - if len(addresses) == 0 { - return "" - } - return addresses[0] -} - -// firstIPv6 returns the first IPv6 address (mask stripped) among allowedIPs, -// or "" if none — used to build one NDP proxy PostUp/PostDown entry per peer. -func firstIPv6(allowedIPs []string) string { - for _, a := range allowedIPs { - if prefix, err := netip.ParsePrefix(a); err == nil { - if prefix.Addr().Is6() { - return prefix.Addr().String() - } - continue - } - if addr, err := netip.ParseAddr(a); err == nil && addr.Is6() { - return addr.String() - } - } - return "" -} - -// FirstIPv4 returns the first IPv4 address (mask stripped) among allowedIPs, -// or "" if none — used as the DNAT target for a peer's forwarded ports and, -// by internal/web/service's injectAmneziawgEgress, as the source-IP match for -// a routed peer's Xray rule. Exported so both packages derive a peer's -// tunnel IPv4 address the exact same way. -func FirstIPv4(allowedIPs []string) string { - for _, a := range allowedIPs { - if prefix, err := netip.ParsePrefix(a); err == nil { - if prefix.Addr().Is4() { - return prefix.Addr().String() - } - continue - } - if addr, err := netip.ParseAddr(a); err == nil && addr.Is4() { - return addr.String() - } - } - return "" -} - -// detectDefaultInterface returns the first non-loopback, non-tunnel, UP -// interface that has a routable IPv4 address. Falls back to "eth0" only if -// nothing is found. -func detectDefaultInterface() string { - ifaces, err := net.Interfaces() - if err != nil { - return "eth0" - } - for _, iface := range ifaces { - if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 { - continue - } - if strings.HasPrefix(iface.Name, "awg") || strings.HasPrefix(iface.Name, "wg") || - strings.HasPrefix(iface.Name, "docker") || strings.HasPrefix(iface.Name, "br-") || - strings.HasPrefix(iface.Name, "veth") { - continue - } - addrs, err := iface.Addrs() - if err != nil || len(addrs) == 0 { - continue - } - for _, addr := range addrs { - if ipNet, ok := addr.(*net.IPNet); ok && !ipNet.IP.IsLinkLocalUnicast() && ipNet.IP.To4() != nil { - return iface.Name - } - } - } - return "eth0" -} - -// --- process control --- - -func configPath(interfaceName string) string { - return filepath.Join(configDir, interfaceName+".conf") -} - -// writeConfigFile renders and persists the .conf file awg-quick reads. -func writeConfigFile(inst Instance) error { - if err := os.MkdirAll(configDir, 0o700); err != nil { - return fmt.Errorf("amneziawg: create config dir: %w", err) - } - if err := os.WriteFile(configPath(inst.InterfaceName), []byte(generateServerConfig(inst)), 0o600); err != nil { - return fmt.Errorf("amneziawg: write config for %s: %w", inst.InterfaceName, err) - } - return nil -} - -// removeConfigFile deletes the config file for an interface, best-effort. -func removeConfigFile(interfaceName string) { - if err := os.Remove(configPath(interfaceName)); err != nil && !os.IsNotExist(err) { - logger.Warningf("amneziawg: failed to remove config file for %s: %v", interfaceName, err) - } -} - -// awgCommandTimeout bounds every short-lived awg/awg-quick invocation so a -// hung command (e.g. a stuck kernel module operation) can't block the -// reconcile job indefinitely. -const awgCommandTimeout = 30 * time.Second - -// interfaceUp brings an AmneziaWG interface up via awg-quick. -func interfaceUp(interfaceName string) error { - ctx, cancel := context.WithTimeout(context.Background(), awgCommandTimeout) - defer cancel() - out, err := exec.CommandContext(ctx, "awg-quick", "up", configPath(interfaceName)).CombinedOutput() - if err != nil { - return fmt.Errorf("awg-quick up %s failed: %s: %w", interfaceName, strings.TrimSpace(string(out)), err) - } - return nil -} - -// interfaceDown takes an AmneziaWG interface down via awg-quick. -func interfaceDown(interfaceName string) error { - ctx, cancel := context.WithTimeout(context.Background(), awgCommandTimeout) - defer cancel() - out, err := exec.CommandContext(ctx, "awg-quick", "down", configPath(interfaceName)).CombinedOutput() - if err != nil { - return fmt.Errorf("awg-quick down %s failed: %s: %w", interfaceName, strings.TrimSpace(string(out)), err) - } - return nil -} - -// isInterfaceUp checks whether the named AmneziaWG interface currently -// exists. -func isInterfaceUp(interfaceName string) bool { - ctx, cancel := context.WithTimeout(context.Background(), awgCommandTimeout) - defer cancel() - return exec.CommandContext(ctx, "awg", "show", interfaceName).Run() == nil -} - -// syncConfig applies a peers-only config change without dropping existing -// connections on other peers, falling back to a full restart when the live -// interface won't accept the diff (or isn't up yet). -func syncConfig(inst Instance) error { - if !isInterfaceUp(inst.InterfaceName) { - return interfaceUp(inst.InterfaceName) - } - - ctx, cancel := context.WithTimeout(context.Background(), awgCommandTimeout) - defer cancel() - stripped, err := exec.CommandContext(ctx, "awg-quick", "strip", configPath(inst.InterfaceName)).Output() - if err != nil { - logger.Warningf("amneziawg: awg-quick strip failed for %s, restarting: %v", inst.InterfaceName, err) - return restartInterface(inst.InterfaceName) - } - - syncCtx, syncCancel := context.WithTimeout(context.Background(), awgCommandTimeout) - defer syncCancel() - sync := exec.CommandContext(syncCtx, "awg", "syncconf", inst.InterfaceName, "/dev/stdin") - sync.Stdin = bytes.NewReader(stripped) - if out, err := sync.CombinedOutput(); err != nil { - logger.Warningf("amneziawg: awg syncconf failed for %s, restarting: %s: %v", inst.InterfaceName, strings.TrimSpace(string(out)), err) - return restartInterface(inst.InterfaceName) - } - return nil -} - -// restartInterface performs a full down+up cycle. -func restartInterface(interfaceName string) error { - _ = interfaceDown(interfaceName) - return interfaceUp(interfaceName) -} - -// peerStat is one peer's runtime stats parsed from `awg show dump`. -type peerStat struct { - publicKey string - latestHandshake int64 // unix seconds - rx int64 // bytes received from the peer (its upload) - tx int64 // bytes sent to the peer (its download) -} - -// getPeerStats parses `awg show dump`. The dump format is -// tab-separated: line 1 is the interface (private-key, public-key, -// listen-port, fwmark); each following line is one peer (public-key, -// preshared-key, endpoint, allowed-ips, latest-handshake, transfer-rx, -// transfer-tx, persistent-keepalive). -func getPeerStats(interfaceName string) ([]peerStat, error) { - ctx, cancel := context.WithTimeout(context.Background(), awgCommandTimeout) - defer cancel() - out, err := exec.CommandContext(ctx, "awg", "show", interfaceName, "dump").Output() - if err != nil { - return nil, fmt.Errorf("awg show %s dump failed: %w", interfaceName, err) - } - - var stats []peerStat - scanner := bufio.NewScanner(bytes.NewReader(out)) - first := true - for scanner.Scan() { - if first { - first = false - continue - } - fields := strings.Split(scanner.Text(), "\t") - if len(fields) < 8 { - continue - } - handshake, _ := strconv.ParseInt(fields[4], 10, 64) - rx, _ := strconv.ParseInt(fields[5], 10, 64) - tx, _ := strconv.ParseInt(fields[6], 10, 64) - stats = append(stats, peerStat{publicKey: fields[0], latestHandshake: handshake, rx: rx, tx: tx}) - } - return stats, nil -} - -// IsAwgInstalled reports whether the awg and awg-quick binaries are on PATH. -func IsAwgInstalled() bool { - _, err1 := exec.LookPath("awg") - _, err2 := exec.LookPath("awg-quick") - return err1 == nil && err2 == nil -} diff --git a/internal/amneziawg/manager_test.go b/internal/amneziawg/manager_test.go deleted file mode 100644 index 6a6d1e061..000000000 --- a/internal/amneziawg/manager_test.go +++ /dev/null @@ -1,566 +0,0 @@ -package amneziawg - -import ( - "encoding/json" - "fmt" - "slices" - "strings" - "testing" - - "github.com/mhsanaei/3x-ui/v3/internal/database/model" -) - -func mkInboundSettings(t *testing.T, server *ServerSettings, clients []model.Client) string { - t.Helper() - bs, err := json.Marshal(InboundSettings{Server: server, Clients: clients}) - if err != nil { - t.Fatalf("marshal settings: %v", err) - } - return string(bs) -} - -func validServer() *ServerSettings { - return &ServerSettings{ - PrivateKey: "serverPriv", - PublicKey: "serverPub", - SubnetIP: "10.8.1.0", - SubnetCIDR: 24, - } -} - -func TestInstanceFromInboundParsesEnabledPeers(t *testing.T) { - settings := mkInboundSettings(t, validServer(), []model.Client{ - {Email: "a@x", Enable: true, PublicKey: "pubA", PreSharedKey: "pskA", AllowedIPs: []string{"10.8.1.2/32"}}, - {Email: "b@x", Enable: false, PublicKey: "pubB", AllowedIPs: []string{"10.8.1.3/32"}}, - {Email: "c@x", Enable: true, PublicKey: "", AllowedIPs: []string{"10.8.1.4/32"}}, // no key: skipped - {Email: "d@x", Enable: true, PublicKey: "pubD", AllowedIPs: nil}, // no address: skipped - }) - ib := &model.Inbound{Id: 7, Tag: "awg-tag", Protocol: model.AmneziaWG, Port: 51820, Settings: settings} - - inst, ok := InstanceFromInbound(ib) - if !ok { - t.Fatal("expected a usable instance") - } - if inst.Id != 7 || inst.Tag != "awg-tag" || inst.ListenPort != 51820 { - t.Fatalf("instance identity not carried over: %+v", inst) - } - if inst.InterfaceName != "awg7" { - t.Fatalf("InterfaceName = %q, want awg7", inst.InterfaceName) - } - if len(inst.Address) != 1 || inst.Address[0] != "10.8.1.1/24" { - t.Fatalf("Address = %v, want [10.8.1.1/24]", inst.Address) - } - if len(inst.Peers) != 1 { - t.Fatalf("Peers = %+v, want exactly 1 (only a@x qualifies)", inst.Peers) - } - p := inst.Peers[0] - if p.Email != "a@x" || p.PublicKey != "pubA" || p.PresharedKey != "pskA" || len(p.AllowedIPs) != 1 || p.AllowedIPs[0] != "10.8.1.2/32" { - t.Fatalf("peer mismatch: %+v", p) - } -} - -func TestInstanceFromInboundRejectsWrongProtocol(t *testing.T) { - settings := mkInboundSettings(t, validServer(), []model.Client{ - {Email: "a@x", Enable: true, PublicKey: "pubA", AllowedIPs: []string{"10.8.1.2/32"}}, - }) - ib := &model.Inbound{Id: 1, Protocol: model.VLESS, Settings: settings} - if _, ok := InstanceFromInbound(ib); ok { - t.Fatal("non-AmneziaWG inbound must be rejected") - } -} - -func TestInstanceFromInboundRejectsNil(t *testing.T) { - if _, ok := InstanceFromInbound(nil); ok { - t.Fatal("nil inbound must be rejected") - } -} - -func TestInstanceFromInboundRejectsMissingServer(t *testing.T) { - ib := &model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: `{"clients":[]}`} - if _, ok := InstanceFromInbound(ib); ok { - t.Fatal("settings with no server block must be rejected") - } -} - -func TestInstanceFromInboundRejectsUnparseableSettings(t *testing.T) { - ib := &model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: `not json`} - if _, ok := InstanceFromInbound(ib); ok { - t.Fatal("unparseable settings must be rejected") - } -} - -func TestInstanceFromInboundEmptyWhenNoEnabledPeers(t *testing.T) { - settings := mkInboundSettings(t, validServer(), []model.Client{ - {Email: "a@x", Enable: false, PublicKey: "pubA", AllowedIPs: []string{"10.8.1.2/32"}}, - }) - ib := &model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: settings} - if _, ok := InstanceFromInbound(ib); ok { - t.Fatal("an inbound with zero enabled peers must be skipped, like mtproto.InstanceFromInbound") - } -} - -func TestServerAddress(t *testing.T) { - cases := []struct { - subnet string - cidr int - want string - }{ - {"10.8.1.0", 24, "10.8.1.1/24"}, - {"10.8.1.0", 0, "10.8.1.1/24"}, // cidr <= 0 defaults to /24 - {"10.8.1.5", 24, "10.8.1.1/24"}, // non-network base: must not collide with peer allocation starting at .2 - {"10.8.1.254", 24, "10.8.1.1/24"}, - {"192.168.5.10", 32, "192.168.5.10/32"}, // /32 has no host bits: used as-is - } - for _, c := range cases { - if got := serverAddress(c.subnet, c.cidr); got != c.want { - t.Errorf("serverAddress(%q, %d) = %q, want %q", c.subnet, c.cidr, got, c.want) - } - } -} - -// fixedObfuscation is a deterministic Obfuscation20 for tests that compare -// two instances for equality — GenerateObfuscation20 is randomized per call -// by design (see its doc comment) and must never be used where the test -// expects two "identical" instances to actually match. -func fixedObfuscation() Obfuscation20 { - return Obfuscation20{Jc: 4, Jmin: 40, Jmax: 100, S1: 30, S2: 90, S3: 20, S4: 10, H1: "10-2000", H2: "3000-5000", H3: "6000-8000", H4: "9000-11000", I1: ""} -} - -func baseInstance() Instance { - return Instance{ - Id: 1, - Tag: "awg-1", - InterfaceName: "awg1", - ListenPort: 51820, - PrivateKey: "priv", - PublicKey: "pub", - Address: []string{"10.8.1.1/24"}, - Obfuscation: fixedObfuscation(), - Peers: []Peer{ - {Email: "a@x", PublicKey: "pubA", PresharedKey: "pskA", AllowedIPs: []string{"10.8.1.2/32"}}, - {Email: "b@x", PublicKey: "pubB", AllowedIPs: []string{"10.8.1.3/32"}}, - }, - } -} - -func TestStructuralFingerprintStableAndSensitive(t *testing.T) { - a := baseInstance() - b := baseInstance() - if a.structuralFingerprint() != b.structuralFingerprint() { - t.Fatal("identical instances must produce the same structural fingerprint") - } - b.ListenPort = 51821 - if a.structuralFingerprint() == b.structuralFingerprint() { - t.Fatal("a listen port change must change the structural fingerprint") - } - c := baseInstance() - c.Peers[0].AllowedIPs = []string{"10.8.1.99/32"} - if a.structuralFingerprint() != c.structuralFingerprint() { - t.Fatal("a peer-only change must NOT change the structural fingerprint") - } - - d := baseInstance() - d.IPv6Enabled = true - if a.structuralFingerprint() == d.structuralFingerprint() { - t.Fatal("enabling IPv6 must change the structural fingerprint") - } - - e := baseInstance() - e.IPv6Enabled = true - f := baseInstance() - f.IPv6Enabled = true - f.IPv6ExternalInterface = "eth1" - if e.structuralFingerprint() == f.structuralFingerprint() { - t.Fatal("changing IPv6ExternalInterface must change the structural fingerprint -- otherwise the edit is a complete no-op") - } - - g := baseInstance() - g.RouteThroughXray = true - if a.structuralFingerprint() == g.structuralFingerprint() { - t.Fatal("toggling RouteThroughXray must change the structural fingerprint -- it changes whether PostUp/PostDown contain any TPROXY rules at all") - } -} - -func TestPeersFingerprintOrderIndependentButContentSensitive(t *testing.T) { - a := baseInstance() - reordered := baseInstance() - reordered.Peers[0], reordered.Peers[1] = reordered.Peers[1], reordered.Peers[0] - if a.peersFingerprint() != reordered.peersFingerprint() { - t.Fatal("reordering peers must not change the peers fingerprint") - } - - changed := baseInstance() - changed.Peers[0].AllowedIPs = []string{"10.8.1.250/32"} - if a.peersFingerprint() == changed.peersFingerprint() { - t.Fatal("changing a peer's AllowedIPs must change the peers fingerprint") - } - - fewer := baseInstance() - fewer.Peers = fewer.Peers[:1] - if a.peersFingerprint() == fewer.peersFingerprint() { - t.Fatal("removing a peer must change the peers fingerprint") - } -} - -func TestEnsureActionFor(t *testing.T) { - cases := []struct { - name string - up bool - curStruct, curHostRules, curPeers string - newStruct, newHostRules, newPeers string - want ensureAction - }{ - {"down forces restart even if identical", false, "s", "f", "p", "s", "f", "p", ensureRestart}, - {"structural change forces restart", true, "s1", "f", "p", "s2", "f", "p", ensureRestart}, - {"port-forward change forces restart", true, "s", "f1", "p", "s", "f2", "p", ensureRestart}, - {"peer-ip change (its TPROXY rule) forces restart", true, "s", "ip:old", "p", "s", "ip:new", "p", ensureRestart}, - {"peers-only change reloads", true, "s", "f", "p1", "s", "f", "p2", ensureReload}, - {"identical up interface is a noop", true, "s", "f", "p", "s", "f", "p", ensureNoop}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - got := ensureActionFor(c.up, c.curStruct, c.curHostRules, c.curPeers, c.newStruct, c.newHostRules, c.newPeers) - if got != c.want { - t.Errorf("ensureActionFor() = %v, want %v", got, c.want) - } - }) - } -} - -func TestNextTrafficBaseline(t *testing.T) { - prev := map[string]peerCounters{"pubA": {rx: 100, tx: 200}} - - if got := nextTrafficBaseline(ensureReload, prev); len(got) != 1 || got["pubA"] != prev["pubA"] { - t.Errorf("a reload must preserve the previous baseline (syncconf never resets kernel counters), got %v", got) - } - if got := nextTrafficBaseline(ensureRestart, prev); len(got) != 0 { - t.Errorf("a restart must reset the baseline to empty (awg-quick down+up zeroes kernel counters), got %v", got) - } -} - -func TestHostRulesFingerprintCoversForwardedPortsAndPeerIP(t *testing.T) { - a := baseInstance() - b := baseInstance() - if a.hostRulesFingerprint() != b.hostRulesFingerprint() { - t.Fatal("identical instances must produce the same host-rules fingerprint") - } - - forwarded := baseInstance() - forwarded.Peers[0].ForwardedPorts = "80,443" - if a.hostRulesFingerprint() == forwarded.hostRulesFingerprint() { - t.Fatal("adding ForwardedPorts must change the host-rules fingerprint") - } - - fewer := baseInstance() - fewer.Peers = fewer.Peers[:1] - if a.hostRulesFingerprint() == fewer.hostRulesFingerprint() { - t.Fatal("removing a peer must change the host-rules fingerprint -- one fewer peer entry exists regardless of what's tracked per peer") - } - - // RouteThroughXray off (baseInstance's default): no TPROXY rule depends - // on a peer's IPv4 address, so re-IPing one must NOT force a bounce -- - // this is the whole point of making the bridge opt-in: an instance that - // never uses it keeps the syncconf fast path for a plain re-IP. - reIPedNoRoute := baseInstance() - reIPedNoRoute.Peers[0].AllowedIPs = []string{"10.8.1.250/32"} - if a.hostRulesFingerprint() != reIPedNoRoute.hostRulesFingerprint() { - t.Fatal("with RouteThroughXray off, changing a peer's IP must NOT change the host-rules fingerprint") - } - - // A peer with forwarded ports has its DNAT rule keyed on its IPv4 address - // too, regardless of RouteThroughXray -- re-IPing it must force a bounce, - // or the old DNAT rule survives pointed at an address a different peer - // can be handed next. - forwardedReIPed := baseInstance() - forwardedReIPed.Peers[0].ForwardedPorts = "80,443" - forwardedBase := baseInstance() - forwardedBase.Peers[0].ForwardedPorts = "80,443" - forwardedReIPed.Peers[0].AllowedIPs = []string{"10.8.1.250/32"} - if forwardedBase.hostRulesFingerprint() == forwardedReIPed.hostRulesFingerprint() { - t.Fatal("with RouteThroughXray off but ForwardedPorts set, changing a peer's IP must change the host-rules fingerprint -- its DNAT rule is keyed on that IP") - } - - // RouteThroughXray on: now the TPROXY rule really is keyed on the IP. - routed := baseInstance() - routed.RouteThroughXray = true - routedReIPed := baseInstance() - routedReIPed.RouteThroughXray = true - routedReIPed.Peers[0].AllowedIPs = []string{"10.8.1.250/32"} - if routed.hostRulesFingerprint() == routedReIPed.hostRulesFingerprint() { - t.Fatal("with RouteThroughXray on, changing a peer's IP must change the host-rules fingerprint -- its TPROXY rule is keyed on that IP") - } - - // IPv6Enabled off (baseInstance's default): no NDP-proxy entry depends - // on a peer's IPv6 address either, so adding one must not force a bounce. - ip6AddedNoIPv6 := baseInstance() - ip6AddedNoIPv6.Peers[0].AllowedIPs = []string{"10.8.1.2/32", "fd86:ea04:1115::2/128"} - if a.hostRulesFingerprint() != ip6AddedNoIPv6.hostRulesFingerprint() { - t.Fatal("with IPv6Enabled off, adding a peer's IPv6 address must NOT change the host-rules fingerprint") - } - - ip6Base := baseInstance() - ip6Base.IPv6Enabled = true - ip6Added := baseInstance() - ip6Added.IPv6Enabled = true - ip6Added.Peers[0].AllowedIPs = []string{"10.8.1.2/32", "fd86:ea04:1115::2/128"} - if ip6Base.hostRulesFingerprint() == ip6Added.hostRulesFingerprint() { - t.Fatal("with IPv6Enabled on, adding a peer's IPv6 address must change the host-rules fingerprint -- its NDP-proxy entry is keyed on it, and a change here must force the full bounce that (re-)runs PostUp") - } -} - -func TestRouteEgressComment(t *testing.T) { - if got := routeEgressComment(""); got != "awg-route" { - t.Errorf("empty email must fall back to awg-route, got %q", got) - } - a := routeEgressComment("a@x") - b := routeEgressComment("b@x") - if a == b { - t.Fatal("different emails must produce different comment tags") - } - if a != routeEgressComment("a@x") { - t.Fatal("the same email must always produce the same comment tag") - } -} - -func TestRouteEgressLines(t *testing.T) { - up := routeEgressLines("-A", "awg1", "10.8.1.2/32", "a@x", 63101) - if len(up) != 2 { - t.Fatalf("expected one TPROXY line per protocol (tcp+udp), got %d: %v", len(up), up) - } - for _, proto := range []string{"tcp", "udp"} { - found := false - for _, l := range up { - if !strings.Contains(l, "-p "+proto) { - continue - } - found = true - if !strings.Contains(l, "-i awg1") || !strings.Contains(l, "-s 10.8.1.2") || - !strings.Contains(l, "--on-port 63101") || - !strings.Contains(l, "--on-ip 127.0.0.1") || - !strings.Contains(l, fmt.Sprintf("--tproxy-mark %#x/%#x", EgressFwmark, EgressFwmark)) || - !strings.Contains(l, "-A PREROUTING") { - t.Errorf("%s line missing expected fields: %s", proto, l) - } - } - if !found { - t.Errorf("missing a %s TPROXY line in %v", proto, up) - } - } - if strings.Contains(up[0], "10.8.1.2/32") { - t.Errorf("expected the /32 mask stripped from the source match, got %s", up[0]) - } - - down := routeEgressLines("-D", "awg1", "10.8.1.2/32", "a@x", 63101) - if len(down) != 2 || !strings.Contains(down[0], "-D PREROUTING") { - t.Fatalf("expected symmetric -D lines, got %v", down) - } - - if got := routeEgressLines("-A", "awg1", "", "a@x", 63101); got != nil { - t.Errorf("empty clientIP must yield no lines, got %v", got) - } -} - -func TestEgressPortForInbound(t *testing.T) { - if got := EgressPortForInbound(1); got != EgressBasePort+1 { - t.Errorf("EgressPortForInbound(1) = %d, want %d", got, EgressBasePort+1) - } - if EgressPortForInbound(1) == EgressPortForInbound(2) { - t.Fatal("different inbound ids must derive different ports") - } -} - -func TestDefaultPostUpDownOmitsTproxyWhenRouteThroughXrayOff(t *testing.T) { - inst := baseInstance() // RouteThroughXray defaults to false - up, down := defaultPostUpDown(inst, "eth0") - - if strings.Contains(up, "TPROXY") || strings.Contains(up, "ip rule add fwmark") { - t.Errorf("RouteThroughXray off must emit no TPROXY/policy-route lines in PostUp, got:\n%s", up) - } - if strings.Contains(down, "TPROXY") { - t.Errorf("RouteThroughXray off must emit no TPROXY lines in PostDown, got:\n%s", down) - } -} - -func TestDefaultPostUpDownEmitsTproxyForEveryPeerWhenRouteThroughXrayOn(t *testing.T) { - inst := baseInstance() // two peers, a@x and b@x - inst.RouteThroughXray = true - up, down := defaultPostUpDown(inst, "eth0") - - wantPort := fmt.Sprintf("--on-port %d", EgressPortForInbound(inst.Id)) - if !strings.Contains(up, "TPROXY") || !strings.Contains(up, wantPort) { - t.Errorf("expected TPROXY rules targeting this instance's own bridge port in PostUp, got:\n%s", up) - } - if !strings.Contains(down, "TPROXY") { - t.Errorf("expected matching TPROXY removals in PostDown, got:\n%s", down) - } - if !strings.Contains(up, fmt.Sprintf("ip rule add fwmark %#x", EgressFwmark)) { - t.Errorf("expected the shared policy route to be added once in PostUp, got:\n%s", up) - } - // grep -c, not -q: -q's early exit can SIGPIPE "ip rule list" and, under - // pipefail, make the existence check itself report failure even when the - // rule was found -- which would re-run "ip rule add" and reintroduce the - // exact duplicate this check exists to prevent. - if wantCheck := fmt.Sprintf("ip rule list | grep -c 'fwmark %#x lookup %d' >/dev/null", EgressFwmark, EgressTable); !strings.Contains(up, wantCheck) { - t.Errorf("expected a pipefail-safe existence check before 'ip rule add', so repeated bounces don't accumulate duplicate rules, got:\n%s", up) - } - if strings.Contains(down, "ip rule") || strings.Contains(down, "ip route") { - t.Error("the shared policy route must never be removed in PostDown -- other instances may still need it") - } - // Both peers get TPROXY'd once opted in: 2 peers * 2 protocols. - if got := strings.Count(up, "TPROXY"); got != 4 { - t.Errorf("expected exactly 4 TPROXY lines (tcp+udp for each of the 2 peers), got %d in:\n%s", got, up) - } - - none := Instance{Id: 2, InterfaceName: "awg2", RouteThroughXray: true} // no peers at all - upNone, _ := defaultPostUpDown(none, "eth0") - if strings.Contains(upNone, "TPROXY") || strings.Contains(upNone, "ip rule add fwmark") { - t.Errorf("an instance with no peers must not emit any TPROXY/policy-route lines, got:\n%s", upNone) - } -} - -// TPROXY never rewrites a packet's own destination address -- only the -// routing decision changes -- so a default-deny INPUT chain that sanity-checks -// "is this destination actually local" (e.g. UFW's ufw-not-local, via -// addrtype --dst-type LOCAL) drops it before Xray's socket ever sees it, even -// though TPROXY's own mangle-table counters keep incrementing the whole time. -// This was a real, hard-to-diagnose production outage: RouteThroughXray -// looked fully configured (TPROXY rule present, Xray socket listening with -// IP_TRANSPARENT set) yet every peer's traffic silently vanished. -func TestDefaultPostUpDownAddsInputAcceptForFwmarkWhenRouteThroughXrayOn(t *testing.T) { - inst := baseInstance() // two peers, a@x and b@x - inst.RouteThroughXray = true - up, down := defaultPostUpDown(inst, "eth0") - - wantCheck := fmt.Sprintf("iptables -C INPUT -m mark --mark %#x -j ACCEPT", EgressFwmark) - wantInsert := fmt.Sprintf("iptables -I INPUT 1 -m mark --mark %#x -j ACCEPT", EgressFwmark) - if !strings.Contains(up, wantCheck) || !strings.Contains(up, wantInsert) { - t.Errorf("expected an idempotent INPUT accept for the shared fwmark in PostUp, got:\n%s", up) - } - if strings.Contains(down, "-m mark --mark") { - t.Error("the shared INPUT accept must never be removed in PostDown -- other instances may still need it, same as the policy route") - } - - none := Instance{Id: 2, InterfaceName: "awg2", RouteThroughXray: true} // no peers at all - upNone, _ := defaultPostUpDown(none, "eth0") - if strings.Contains(upNone, "-m mark --mark") { - t.Errorf("an instance with no peers must not emit the INPUT accept either, got:\n%s", upNone) - } -} - -// PostDown is joined with "; " and run under `set -e`, so one command that -// fails because something already flushed the firewall state out from under -// the interface (a ufw/firewalld reload) would otherwise abort every command -// after it -- including the nat-table DNAT deletes a filter-table flush does -// NOT remove, which then survive and accumulate across bounces. Every -// teardown command must be best-effort; PostUp must not be. -func TestDefaultPostUpDownMakesEveryTeardownCommandBestEffort(t *testing.T) { - inst := baseInstance() // two peers, a@x and b@x - inst.RouteThroughXray = true - inst.IPv6Enabled = true - inst.Peers[0].ForwardedPorts = "80,443" - up, down := defaultPostUpDown(inst, "eth0") - - for _, cmd := range strings.Split(down, "; ") { - if !strings.HasSuffix(cmd, "|| true") { - t.Errorf("every PostDown command must end with '|| true' so a flushed firewall doesn't abort the rest, got: %q", cmd) - } - } - if strings.Contains(up, "|| true") { - t.Error("PostUp must stay strict -- a real setup failure there should surface, not be silently swallowed") - } -} - -func TestGenerateServerConfigContainsExpectedLines(t *testing.T) { - inst := baseInstance() - inst.ExternalInterface = "eth0" - cfg := generateServerConfig(inst) - - want := []string{ - "[Interface]", - "PrivateKey = priv", - "Address = 10.8.1.1/24", - "ListenPort = 51820", - "[Peer]", - "PublicKey = pubA", - "PresharedKey = pskA", - "AllowedIPs = 10.8.1.2/32", - "PublicKey = pubB", - "AllowedIPs = 10.8.1.3/32", - "MASQUERADE", - } - for _, w := range want { - if !strings.Contains(cfg, w) { - t.Errorf("generated config missing %q\n---\n%s", w, cfg) - } - } - // The second peer has no PresharedKey — its block must not emit the field at all. - if strings.Count(cfg, "PresharedKey") != 1 { - t.Errorf("expected exactly one PresharedKey line (peer b@x has none), got config:\n%s", cfg) - } -} - -func TestWriteObfuscationDefaultsBlankH(t *testing.T) { - var b strings.Builder - writeObfuscation(&b, Obfuscation20{}) - out := b.String() - for i, want := range []string{"H1 = 1", "H2 = 2", "H3 = 3", "H4 = 4"} { - if !strings.Contains(out, want) { - t.Errorf("blank H%d must fall back to default %q, got:\n%s", i+1, want, out) - } - } - // S3/S4/I1 are zero-valued here and must be omitted entirely. - if strings.Contains(out, "S3") || strings.Contains(out, "S4") || strings.Contains(out, "I1") { - t.Errorf("zero-valued S3/S4/I1 must be omitted, got:\n%s", out) - } -} - -func TestInterfaceNameForID(t *testing.T) { - if got := interfaceNameForID(42); got != "awg42" { - t.Errorf("interfaceNameForID(42) = %q, want awg42", got) - } -} - -func TestInboundIDForInterfaceName(t *testing.T) { - cases := []struct { - name string - wantID int - wantOK bool - }{ - {"awg42", 42, true}, - {"awg0", 0, true}, - {"awg", 0, false}, // no digits after the prefix - {"wg0", 0, false}, // wrong prefix entirely (plain WireGuard) - {"awgabc", 0, false}, // non-numeric suffix - {"awg-1", 0, false}, // Atoi rejects the leading '-' as part of TrimPrefix's leftover, but guard anyway - } - for _, c := range cases { - id, ok := inboundIDForInterfaceName(c.name) - if ok != c.wantOK || (ok && id != c.wantID) { - t.Errorf("inboundIDForInterfaceName(%q) = (%d, %v), want (%d, %v)", c.name, id, ok, c.wantID, c.wantOK) - } - } -} - -func TestOrphanedInterfaces(t *testing.T) { - confFiles := []string{ - "awg1.conf", // in want -> not orphaned - "awg2.conf", // not in want -> orphaned - "awg3.conf", // not in want -> orphaned - "notes.txt", // wrong suffix -> ignored - "awgxyz.conf", // unparseable id -> ignored - } - want := map[int]struct{}{1: {}} - - got := orphanedInterfaces(confFiles, want) - slices.Sort(got) - if wantOut := []string{"awg2", "awg3"}; !slices.Equal(got, wantOut) { - t.Errorf("orphanedInterfaces() = %v, want %v", got, wantOut) - } -} - -func TestOrphanedInterfacesEmptyWantOrphansEverything(t *testing.T) { - got := orphanedInterfaces([]string{"awg5.conf"}, map[int]struct{}{}) - if want := []string{"awg5"}; !slices.Equal(got, want) { - t.Errorf("orphanedInterfaces() = %v, want %v", got, want) - } -} diff --git a/internal/amneziawg/params.go b/internal/amneziawg/params.go index 02829fd69..5ae75607e 100644 --- a/internal/amneziawg/params.go +++ b/internal/amneziawg/params.go @@ -148,13 +148,14 @@ var interfaceNamePattern = regexp.MustCompile(`^[A-Za-z0-9_.@:-]{1,15}$`) // ValidateInterfaceName rejects a value that isn't a plausible network // interface name before it's saved. ExternalInterface and -// IPv6ExternalInterface are interpolated unescaped into a shell-executed -// PostUp/PostDown line by generateServerConfig, so — unlike the client email -// (already hashed for exactly this reason, see routeEgressComment) — an -// unvalidated value here could carry a shell metacharacter straight into a -// root-executed command. A blank value is allowed: it means "auto-detect" -// for ExternalInterface, or "reuse ExternalInterface" for -// IPv6ExternalInterface. +// IPv6ExternalInterface are vestigial as of the hard cutover to the +// embedded path (see types.go's ServerSettings), but this validation stays: +// Phase 3.5's planned real-IPv6-address-alias mechanism will shell out to +// `ip -6 addr add ... dev `, and an unvalidated value here could carry +// a shell metacharacter straight into that root-executed command, the same +// risk the retired kernel-module PostUp/PostDown generator had. A blank +// value is allowed: it means "auto-detect" for ExternalInterface, or "reuse +// ExternalInterface" for IPv6ExternalInterface. func ValidateInterfaceName(name string) error { if name == "" { return nil diff --git a/internal/amneziawg/params_test.go b/internal/amneziawg/params_test.go index 05f90fa57..2829c3b6e 100644 --- a/internal/amneziawg/params_test.go +++ b/internal/amneziawg/params_test.go @@ -233,12 +233,3 @@ func TestValidateConfigValueRejectsControlCharacters(t *testing.T) { } } } - -func TestSanitizeConfigValueStripsControlCharactersOnly(t *testing.T) { - if got := sanitizeConfigValue("a@x\nPostUp = evil\r\n"); got != "a@xPostUp = evil" { - t.Errorf("sanitizeConfigValue must drop newlines/CR without altering the rest, got %q", got) - } - if got := sanitizeConfigValue("plain-value_123"); got != "plain-value_123" { - t.Errorf("sanitizeConfigValue must not touch an already-clean value, got %q", got) - } -} diff --git a/internal/amneziawg/portfwd.go b/internal/amneziawg/portfwd.go index be6847112..6650f3254 100644 --- a/internal/amneziawg/portfwd.go +++ b/internal/amneziawg/portfwd.go @@ -2,7 +2,6 @@ package amneziawg import ( "fmt" - "hash/fnv" "strconv" "strings" ) @@ -13,31 +12,10 @@ type portSpec struct { end int } -func (p portSpec) isRange() bool { return p.end > p.start } - -// dportArg returns the iptables --dport argument: "N" or "N:M". -func (p portSpec) dportArg() string { - if p.isRange() { - return fmt.Sprintf("%d:%d", p.start, p.end) - } - return strconv.Itoa(p.start) -} - -// dnatTarget returns the DNAT target: "ip:N" or "ip:N-M". -func (p portSpec) dnatTarget(clientIP string) string { - if p.isRange() { - return fmt.Sprintf("%s:%d-%d", clientIP, p.start, p.end) - } - return fmt.Sprintf("%s:%d", clientIP, p.start) -} - // parseForwardedPorts splits a user-supplied string ("80, 443; 8000-8100") // into validated port specs. Tokens are separated by comma or semicolon; // whitespace is ignored. Invalid tokens are silently dropped — the input is -// a free-form text field and validation is best-effort by design. Every -// returned spec's bounds are integers in [1, 65535], so callers can safely -// embed them in a shell-executed PostUp/PostDown line without further -// escaping. +// a free-form text field and validation is best-effort by design. func parseForwardedPorts(input string) []portSpec { if input == "" { return nil @@ -91,13 +69,20 @@ func parsePortNumber(s string) (int, bool) { } // ForwardedPortsInclude reports whether port is covered by any spec in a raw -// ForwardedPorts string (a single port or an inclusive range). For callers -// outside this package that need to check a spec against something other -// than rendering it into iptables rules -- e.g. save-time validation that a -// client isn't about to hijack the panel's own port or another inbound's -// port (portForwardLines has no -d restriction, so a forwarded port that -// collides with one already in use on the host silently redirects it to the -// tunnel client instead). +// ForwardedPorts string (a single port or an inclusive range). Used for +// save-time validation that a client isn't about to hijack the panel's own +// port or another inbound's port -- see +// internal/web/service/inbound_amneziawg.go's port-conflict checks. +// +// The field itself is currently inert: per-client port-forwarding was +// implemented via PostUp/PostDown iptables DNAT rules under the retired +// kernel-module architecture (internal/amneziawg's old Manager), which had +// no equivalent under the embedded amneziawg-go path +// (internal/amneziawgnet) as of the hard cutover -- see the migration +// plan's Phase 3.6 for the panel-side relay design that will restore it. +// The field and this validation are kept so existing values aren't lost and +// re-validated identically once that phase lands, not because anything +// currently acts on them. func ForwardedPortsInclude(forwardedPorts string, port int) bool { for _, spec := range parseForwardedPorts(forwardedPorts) { if port >= spec.start && port <= spec.end { @@ -106,62 +91,3 @@ func ForwardedPortsInclude(forwardedPorts string, port int) bool { } return false } - -// portForwardComment returns a short, shell-safe iptables comment tag for one -// peer's forwarded-port rules, so PostDown removes exactly what PostUp added -// regardless of ordering. Derived from a hash of the peer's email rather than -// the email itself: email is admin/API-supplied free text that ends up -// embedded in a shell-executed PostUp/PostDown line, and a hash can never -// carry a shell metacharacter through. -func portForwardComment(email string) string { - if email == "" { - return "awg-fwd" - } - h := fnv.New32a() - _, _ = h.Write([]byte(email)) - return fmt.Sprintf("awg-fwd-%08x", h.Sum32()) -} - -// portForwardLines returns the PostUp ("-A") or PostDown ("-D") iptables -// lines for one peer's forwarded-ports spec: a DNAT rule (tcp and udp) per -// spec in the nat table, plus a matching FORWARD accept rule. UDP is -// included unconditionally since many common uses (games, P2P) need it. -// Returns nil when forwardedPorts has no valid spec or clientIP is empty. -func portForwardLines(action, extIface, tunIface, clientIP, email, forwardedPorts string) []string { - specs := parseForwardedPorts(forwardedPorts) - if len(specs) == 0 { - return nil - } - clientIP = stripCIDRMask(clientIP) - if clientIP == "" { - return nil - } - comment := portForwardComment(email) - - lines := make([]string, 0, len(specs)*4) - for _, spec := range specs { - dport := spec.dportArg() - target := spec.dnatTarget(clientIP) - for _, proto := range []string{"tcp", "udp"} { - nat := fmt.Sprintf("iptables -t nat %s PREROUTING -p %s", action, proto) - if extIface != "" { - nat += fmt.Sprintf(" -i %s", extIface) - } - nat += fmt.Sprintf(" --dport %s -m comment --comment %s -j DNAT --to-destination %s", dport, comment, target) - lines = append(lines, nat) - - fwd := fmt.Sprintf("iptables %s FORWARD -d %s -p %s -o %s --dport %s -m comment --comment %s -j ACCEPT", - action, clientIP, proto, tunIface, dport, comment) - lines = append(lines, fwd) - } - } - return lines -} - -// stripCIDRMask removes a "/N" suffix if present. -func stripCIDRMask(addr string) string { - if idx := strings.IndexByte(addr, '/'); idx >= 0 { - return addr[:idx] - } - return addr -} diff --git a/internal/amneziawg/route_egress.go b/internal/amneziawg/route_egress.go deleted file mode 100644 index c18d8f320..000000000 --- a/internal/amneziawg/route_egress.go +++ /dev/null @@ -1,83 +0,0 @@ -package amneziawg - -import ( - "fmt" - "hash/fnv" -) - -// EgressBasePort is the first loopback port used for an AmneziaWG inbound's -// own Xray TPROXY bridge. The bridge is opt-in per inbound, gated on -// Instance.RouteThroughXray (off by default): only when it's on does -// defaultPostUpDown's TPROXY rules redirect a peer's traffic there, and only -// then does internal/web/service's injectAmneziawgEgress create the matching -// dokodemo-door inbound, tagged with the AmneziaWG inbound's own real tag so -// it's already selectable in the panel's stock Routing page (the same -// mechanism that already makes an mtproto inbound's own bridge routable -// there — see injectMtprotoEgress). A plain AmneziaWG tunnel with routing -// left off never depends on Xray being up at all. Whether — and where — -// routed traffic actually goes anywhere beyond Xray's default routing is -// entirely up to whatever rules the admin adds on that page; this package -// and injectAmneziawgEgress never generate a routing rule themselves. -// -// EgressPortForInbound derives each inbound's own port deterministically -// from its id, so the two independent reconcile loops (this package's -// PostUp generator and the Xray-config generator, in a different package) -// never have to agree on a runtime-negotiated value. -const EgressBasePort = 63100 - -// EgressPortForInbound returns the loopback port of one AmneziaWG inbound's -// own Xray TPROXY bridge. -func EgressPortForInbound(inboundID int) int { - return EgressBasePort + inboundID -} - -// EgressFwmark and EgressTable are the fwmark and policy-routing table -// TPROXY needs to deliver a peer's packets to a local socket even though -// their destination is never one of this host's own addresses. Shared by -// every AmneziaWG instance's bridge — only the port differs per instance. -// Chosen to be distinctive; if either happens to collide with something else -// already using fwmarks/routing tables on the host, change the values here — -// nothing outside this package and its own PostUp/PostDown output depends on -// the actual numbers. -const ( - EgressFwmark = 0x2377 - EgressTable = 87 -) - -// routeEgressComment returns a short, shell-safe iptables comment tag for one -// peer's TPROXY rule, so PostDown removes exactly what PostUp added -// regardless of ordering. Derived from a hash of the peer's email for the -// same reason portForwardComment is: email is admin/API-supplied free text -// that ends up embedded in a shell-executed PostUp/PostDown line, and a hash -// can never carry a shell metacharacter through. -func routeEgressComment(email string) string { - if email == "" { - return "awg-route" - } - h := fnv.New32a() - _, _ = h.Write([]byte(email)) - return fmt.Sprintf("awg-route-%08x", h.Sum32()) -} - -// routeEgressLines returns the PostUp ("-A") or PostDown ("-D") mangle-table -// TPROXY lines that redirect one peer's traffic — matched by its tunnel -// source IP, arriving on tunIface — into that instance's own Xray bridge on -// port. Both TCP and UDP are covered since every peer's whole traffic is -// meant to reach the bridge, not just a specific protocol or port; which -// outbound (if any) it then takes is entirely up to the admin's own Routing -// rules. Returns nil when clientIP is empty. -func routeEgressLines(action, tunIface, clientIP, email string, port int) []string { - clientIP = stripCIDRMask(clientIP) - if clientIP == "" { - return nil - } - comment := routeEgressComment(email) - lines := make([]string, 0, 2) - for _, proto := range []string{"tcp", "udp"} { - lines = append(lines, fmt.Sprintf( - "iptables -t mangle %s PREROUTING -i %s -s %s -p %s -m comment --comment %s -j TPROXY --on-port %d --on-ip 127.0.0.1 --tproxy-mark %#x/%#x", - action, tunIface, clientIP, proto, comment, port, EgressFwmark, EgressFwmark, - )) - } - return lines -} diff --git a/internal/amneziawg/types.go b/internal/amneziawg/types.go index d08b7cbba..5964389ec 100644 --- a/internal/amneziawg/types.go +++ b/internal/amneziawg/types.go @@ -58,27 +58,29 @@ type Instance struct { Obfuscation Obfuscation20 Peers []Peer - // ExternalInterface is the host NIC PostUp/PostDown NAT rules attach to. - // Empty means auto-detect at config-generation time. + // ExternalInterface named the host NIC PostUp/PostDown NAT rules + // attached to under the retired kernel-module architecture. Not read by + // the embedded path (internal/amneziawgnet) as of the hard cutover -- + // kept for Phase 3.5's planned real-IPv6-address-alias mechanism, which + // will need to know which host NIC to alias an address onto. ExternalInterface string - // IPv6Enabled turns on the per-peer NDP proxy PostUp/PostDown entries - // (ip -6 neigh add/del proxy) for peers that have an IPv6 AllowedIPs - // entry. IPv6ExternalInterface overrides ExternalInterface for those - // entries specifically; empty means reuse ExternalInterface. + // IPv6Enabled/IPv6ExternalInterface controlled the per-peer NDP proxy + // PostUp/PostDown entries (ip -6 neigh add/del proxy) under the retired + // kernel-module architecture. Not read by the embedded path as of the + // hard cutover -- distinct-per-peer public IPv6 identity is Phase 3.5, + // see the migration plan. IPv6Enabled bool IPv6ExternalInterface string - // RouteThroughXray gates the entire TPROXY-into-Xray bridge (see - // EgressPortForInbound / injectAmneziawgEgress) for this instance: off by - // default, so a plain AmneziaWG tunnel never depends on Xray being up at - // all. Turning it on makes every peer's traffic TPROXY'd into this - // instance's own loopback Xray bridge, tagged with the inbound's own - // tag; the actual routing decision from there is left entirely to the - // panel's stock Routing page (pick this inbound's tag as source, an - // outbound, and optionally a peer's IP), exactly like routing any other - // protocol -- only whether the bridge exists at all is a per-inbound - // choice. + // RouteThroughXray gated the kernel-module architecture's opt-in + // TPROXY-into-Xray bridge. The embedded path (internal/amneziawgnet) + // has no equivalent opt-in at all -- every peer's traffic already goes + // through Xray's own SOCKS5 inbound unconditionally, since there's no + // other way for decapsulated gVisor traffic to reach the real internet + // -- so this field is now vestigial: read from existing stored settings + // for backward compatibility, but not acted on by anything. Slated for + // removal alongside the frontend toggle in a follow-up. RouteThroughXray bool } @@ -99,22 +101,17 @@ type ServerSettings struct { PrimaryDNS string `json:"primaryDns,omitempty"` SecondaryDNS string `json:"secondaryDns,omitempty"` - // ExternalInterface is the host NIC PostUp/PostDown NAT rules attach to. - // Empty means auto-detect. + // ExternalInterface, IPv6Enabled/IPv6Subnet/IPv6ExternalInterface, and + // RouteThroughXray are all vestigial as of the hard cutover to the + // embedded path (internal/amneziawgnet) -- see the matching fields on + // Instance for what each used to do under the retired kernel-module + // architecture and what (if anything) is planned to read them again. ExternalInterface string `json:"externalInterface,omitempty"` - // IPv6Enabled turns on native IPv6 for clients: an IPv6 host address is - // allocated from IPv6Subnet alongside each client's IPv4 one, and the - // server proxies NDP for each enabled client's address so upstream - // routers see it as directly reachable (no NAT66). IPv6ExternalInterface - // overrides ExternalInterface for the NDP-proxy PostUp/PostDown entries - // specifically; empty reuses ExternalInterface. IPv6Enabled bool `json:"ipv6Enabled,omitempty"` IPv6Subnet string `json:"ipv6Subnet,omitempty"` IPv6ExternalInterface string `json:"ipv6ExternalInterface,omitempty"` - // RouteThroughXray turns on this inbound's TPROXY-into-Xray bridge; see - // Instance.RouteThroughXray for what that means. Off by default. RouteThroughXray bool `json:"routeThroughXray,omitempty"` // Obfuscation20's fields, repeated flat (not embedded) rather than diff --git a/internal/web/service/inbound_protocol.go b/internal/web/service/inbound_protocol.go index 9325195f7..e4a99a7ea 100644 --- a/internal/web/service/inbound_protocol.go +++ b/internal/web/service/inbound_protocol.go @@ -57,7 +57,7 @@ func inboundCanEnableTlsFlow(protocol, streamSettings, settings string) bool { // (frontend/src/pages/inbounds/form/InboundFormModal.tsx), which hides the // "Deploy To" node picker for anything not in this set. MTProto and // AmneziaWG are both sidecar-managed rather than plain Xray inbounds, and -// their reconcile loops (mtproto.Manager, amneziawg.Manager) only ever +// their reconcile loops (mtproto.Manager, amneziawgnet.Manager) only ever // query for NodeID IS NULL rows -- a node-assigned instance of either would // never be reconciled by the master, yet nothing previously stopped one // from being created that way (the frontend allowlist has no server-side From e4fc9d50316d29fbaf06c39049f523e3eda1f55d Mon Sep 17 00:00:00 2001 From: Kuzz007 Date: Sun, 2 Aug 2026 20:05:10 +0300 Subject: [PATCH 08/12] chore(frontend): regenerate schemas/types/openapi after the amneziawg trim npm run gen, matching CI's own codegen check: picks up the updated ServerSettings/Instance doc comments (types.go) and drops ensureAction (the old kernel-module Manager's now-deleted internal enum, which tools/openapigen was scanning and emitting bindings for even though it was never meant to be part of the public API surface). Co-Authored-By: Claude Sonnet 5 --- frontend/public/openapi.json | 4 +--- frontend/src/generated/schemas.ts | 4 +--- frontend/src/generated/types.ts | 1 - frontend/src/generated/zod.ts | 3 --- 4 files changed, 2 insertions(+), 10 deletions(-) diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index ad1d468dc..157a74f2f 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -2857,7 +2857,7 @@ "description": "ServerSettings is the \"server\" block of an AmneziaWG inbound's Settings\nJSON: the interface-level configuration shared by every client/peer. The\nlisten port is deliberately not duplicated here — it lives on the inbound\nrow itself (Inbound.Port), like every other protocol.", "properties": { "externalInterface": { - "description": "ExternalInterface is the host NIC PostUp/PostDown NAT rules attach to.\nEmpty means auto-detect.", + "description": "ExternalInterface, IPv6Enabled/IPv6Subnet/IPv6ExternalInterface, and\nRouteThroughXray are all vestigial as of the hard cutover to the\nembedded path (internal/amneziawgnet) -- see the matching fields on\nInstance for what each used to do under the retired kernel-module\narchitecture and what (if anything) is planned to read them again.", "type": "string" }, "h1": { @@ -2876,7 +2876,6 @@ "type": "string" }, "ipv6Enabled": { - "description": "IPv6Enabled turns on native IPv6 for clients: an IPv6 host address is\nallocated from IPv6Subnet alongside each client's IPv4 one, and the\nserver proxies NDP for each enabled client's address so upstream\nrouters see it as directly reachable (no NAT66). IPv6ExternalInterface\noverrides ExternalInterface for the NDP-proxy PostUp/PostDown entries\nspecifically; empty reuses ExternalInterface.", "type": "boolean" }, "ipv6ExternalInterface": { @@ -2909,7 +2908,6 @@ "type": "string" }, "routeThroughXray": { - "description": "RouteThroughXray turns on this inbound's TPROXY-into-Xray bridge; see\nInstance.RouteThroughXray for what that means. Off by default.", "type": "boolean" }, "s1": { diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 20e008d93..5a2b19c43 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -2831,7 +2831,7 @@ export const SCHEMAS: Record = { "description": "ServerSettings is the \"server\" block of an AmneziaWG inbound's Settings\nJSON: the interface-level configuration shared by every client/peer. The\nlisten port is deliberately not duplicated here — it lives on the inbound\nrow itself (Inbound.Port), like every other protocol.", "properties": { "externalInterface": { - "description": "ExternalInterface is the host NIC PostUp/PostDown NAT rules attach to.\nEmpty means auto-detect.", + "description": "ExternalInterface, IPv6Enabled/IPv6Subnet/IPv6ExternalInterface, and\nRouteThroughXray are all vestigial as of the hard cutover to the\nembedded path (internal/amneziawgnet) -- see the matching fields on\nInstance for what each used to do under the retired kernel-module\narchitecture and what (if anything) is planned to read them again.", "type": "string" }, "h1": { @@ -2850,7 +2850,6 @@ export const SCHEMAS: Record = { "type": "string" }, "ipv6Enabled": { - "description": "IPv6Enabled turns on native IPv6 for clients: an IPv6 host address is\nallocated from IPv6Subnet alongside each client's IPv4 one, and the\nserver proxies NDP for each enabled client's address so upstream\nrouters see it as directly reachable (no NAT66). IPv6ExternalInterface\noverrides ExternalInterface for the NDP-proxy PostUp/PostDown entries\nspecifically; empty reuses ExternalInterface.", "type": "boolean" }, "ipv6ExternalInterface": { @@ -2883,7 +2882,6 @@ export const SCHEMAS: Record = { "type": "string" }, "routeThroughXray": { - "description": "RouteThroughXray turns on this inbound's TPROXY-into-Xray bridge; see\nInstance.RouteThroughXray for what that means. Off by default.", "type": "boolean" }, "s1": { diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 22693bd03..2332a0c1b 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -3,7 +3,6 @@ export type OnlineAPISupport = number; export type ProcessState = string; export type Protocol = string; export type SubLinkProvider = unknown; -export type ensureAction = number; export type geodataFileKind = number; export type staticEgressResolver = string; export type transportBits = number; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index f3be7c2fc..3e510ee98 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -12,9 +12,6 @@ export type Protocol = z.infer; export const SubLinkProviderSchema = z.unknown(); export type SubLinkProvider = z.infer; -export const ensureActionSchema = z.number().int(); -export type ensureAction = z.infer; - export const geodataFileKindSchema = z.number().int(); export type geodataFileKind = z.infer; From 7550072c7b318189caa5fa25a8b5eb584e7cbec0 Mon Sep 17 00:00:00 2001 From: Kuzz007 Date: Sun, 2 Aug 2026 20:20:42 +0300 Subject: [PATCH 09/12] feat(amneziawg): remove the vestigial routeThroughXray toggle from the UI Hard cutover, part 4: the embedded path has no opt-in gate for Xray routing at all (every peer's traffic already goes through Xray's own SOCKS5 relay unconditionally -- see xray.go's injectAmneziawgnetSocks), so a toggle that no longer does anything would just confuse admins. Removed from the form (amneziawg.tsx), the per-protocol Zod schema and its new-inbound default, and the routeThroughXray/routeThroughXrayHint i18n strings across all 13 locales. The Go-side field stays (see internal/amneziawg's ServerSettings/Instance, already annotated as vestigial in the previous commit) for backward compatibility with existing stored settings -- z.object's default unknown-key stripping means the form simply drops it from an existing inbound's settings on its next save, no migration needed. Co-Authored-By: Claude Sonnet 5 --- frontend/src/lib/xray/inbound-defaults.ts | 1 - .../pages/inbounds/form/protocols/amneziawg.tsx | 8 -------- .../src/schemas/protocols/inbound/amneziawg.ts | 14 ++++++++++---- internal/web/translation/ar-EG.json | 2 -- internal/web/translation/en-US.json | 2 -- internal/web/translation/es-ES.json | 2 -- internal/web/translation/fa-IR.json | 2 -- internal/web/translation/id-ID.json | 2 -- internal/web/translation/ja-JP.json | 2 -- internal/web/translation/pt-BR.json | 2 -- internal/web/translation/ru-RU.json | 2 -- internal/web/translation/tr-TR.json | 2 -- internal/web/translation/uk-UA.json | 2 -- internal/web/translation/vi-VN.json | 2 -- internal/web/translation/zh-CN.json | 2 -- internal/web/translation/zh-TW.json | 2 -- 16 files changed, 10 insertions(+), 39 deletions(-) diff --git a/frontend/src/lib/xray/inbound-defaults.ts b/frontend/src/lib/xray/inbound-defaults.ts index 51682fdf0..6098e5b16 100644 --- a/frontend/src/lib/xray/inbound-defaults.ts +++ b/frontend/src/lib/xray/inbound-defaults.ts @@ -298,7 +298,6 @@ export function createDefaultAmneziawgInboundSettings(): AmneziawgInboundSetting ipv6Enabled: false, ipv6Subnet: '', ipv6ExternalInterface: '', - routeThroughXray: false, jc: 5, jmin: 10, jmax: 50, diff --git a/frontend/src/pages/inbounds/form/protocols/amneziawg.tsx b/frontend/src/pages/inbounds/form/protocols/amneziawg.tsx index 60680ccd7..76da588e6 100644 --- a/frontend/src/pages/inbounds/form/protocols/amneziawg.tsx +++ b/frontend/src/pages/inbounds/form/protocols/amneziawg.tsx @@ -68,14 +68,6 @@ export default function AmneziawgFields({ awgPubKey, regenInboundAwg, regenInbou > - - -