diff --git a/internal/amneziawg/instance.go b/internal/amneziawg/instance.go index ad9752d81..42462062a 100644 --- a/internal/amneziawg/instance.go +++ b/internal/amneziawg/instance.go @@ -63,6 +63,7 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) { Tag: ib.Tag, InterfaceName: interfaceNameForID(ib.Id), ListenPort: ib.Port, + Listen: ib.Listen, PrivateKey: server.PrivateKey, PublicKey: server.PublicKey, Address: addresses, diff --git a/internal/amneziawg/instance_test.go b/internal/amneziawg/instance_test.go index aba5aa2b4..f026e4677 100644 --- a/internal/amneziawg/instance_test.go +++ b/internal/amneziawg/instance_test.go @@ -32,7 +32,7 @@ func TestInstanceFromInboundParsesEnabledPeers(t *testing.T) { {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} + ib := &model.Inbound{Id: 7, Tag: "awg-tag", Protocol: model.AmneziaWG, Port: 51820, Listen: "203.0.113.10", Settings: settings} inst, ok := InstanceFromInbound(ib) if !ok { @@ -41,6 +41,9 @@ func TestInstanceFromInboundParsesEnabledPeers(t *testing.T) { if inst.Id != 7 || inst.Tag != "awg-tag" || inst.ListenPort != 51820 { t.Fatalf("instance identity not carried over: %+v", inst) } + if inst.Listen != "203.0.113.10" { + t.Fatalf("Listen = %q, want inbound listen carried through", inst.Listen) + } if inst.InterfaceName != "awg7" { t.Fatalf("InterfaceName = %q, want awg7", inst.InterfaceName) } diff --git a/internal/amneziawg/types.go b/internal/amneziawg/types.go index 7d3020ded..6d6a8ef0b 100644 --- a/internal/amneziawg/types.go +++ b/internal/amneziawg/types.go @@ -75,8 +75,11 @@ type Instance struct { Tag string InterfaceName string ListenPort int - PrivateKey string - PublicKey string + // Listen is an optional host bind address (e.g. "203.0.113.10"). + // Empty/wildcard keeps dual-stack StdNetBind; a real IP pins the UDP socket. + Listen string + PrivateKey string + PublicKey string // Address holds the interface's own tunnel address(es), e.g. "10.8.1.1/24". // Carries both the IPv4 and (when enabled) IPv6 server address. Address []string diff --git a/internal/amneziawgnet/client_device.go b/internal/amneziawgnet/client_device.go index ce77cf5ea..022f4e0fe 100644 --- a/internal/amneziawgnet/client_device.go +++ b/internal/amneziawgnet/client_device.go @@ -114,7 +114,8 @@ func newUnconfiguredClientDevice(inst amneziawg.OutboundInstance, opts DeviceOpt if logger == nil { logger = device.NewLogger(device.LogLevelSilent, fmt.Sprintf("(awg-out %s) ", inst.Tag)) } - dev := device.NewDevice(tun, newResolvingBind(), logger) + bind := newResolvingBind("") + dev := device.NewDevice(tun, bind, logger) return &Device{Device: dev, Stack: gstack, localAddrs: addrs}, nil } diff --git a/internal/amneziawgnet/device.go b/internal/amneziawgnet/device.go index 8fd4905f4..5648f4b5a 100644 --- a/internal/amneziawgnet/device.go +++ b/internal/amneziawgnet/device.go @@ -134,7 +134,8 @@ func newUnconfiguredDevice(inst amneziawg.Instance, opts DeviceOptions) (*Device if logger == nil { logger = device.NewLogger(device.LogLevelSilent, "") } - dev := device.NewDevice(tun, newResolvingBind(), logger) + bind := newResolvingBind(inst.Listen) + dev := device.NewDevice(tun, bind, logger) return &Device{Device: dev, Stack: gstack, localAddrs: addrs}, nil } diff --git a/internal/amneziawgnet/manager.go b/internal/amneziawgnet/manager.go index 616b05fb8..03eb4d702 100644 --- a/internal/amneziawgnet/manager.go +++ b/internal/amneziawgnet/manager.go @@ -257,12 +257,12 @@ func socksRelayForInstance(inst amneziawg.Instance) SocksRelay { } } -// addressFingerprint captures what IpcSet can't change on a running Device, -// fixed when the netstack is built: address, and the S4-derived effective MTU. +// addressFingerprint captures Bind/netstack identity IpcSet cannot change. func addressFingerprint(inst amneziawg.Instance) string { - return fmt.Sprintf("%d|%s", + return fmt.Sprintf("%d|%s|%s", amneziawg.EffectiveMTU(inst.MTU, inst.Obfuscation.S4), - strings.Join(inst.Address, ",")) + strings.Join(inst.Address, ","), + normalizedListenFP(inst.Listen)) } // Reconcile brings every desired instance's embedded interface up to date diff --git a/internal/amneziawgnet/pinned_bind.go b/internal/amneziawgnet/pinned_bind.go new file mode 100644 index 000000000..f9b5c075b --- /dev/null +++ b/internal/amneziawgnet/pinned_bind.go @@ -0,0 +1,188 @@ +package amneziawgnet + +import ( + "context" + "fmt" + "net" + "net/netip" + "strconv" + "strings" + "sync" + + awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn" + + "github.com/mhsanaei/3x-ui/v3/internal/logger" +) + +// pinnedBind opens its UDP socket on exactly one host address (#6367). +// Empty/wildcard listen still uses StdNetBind via newListenBind. +type pinnedBind struct { + mu sync.Mutex + addr netip.Addr + conn *net.UDPConn +} + +func newPinnedBind(addr netip.Addr) *pinnedBind { + return &pinnedBind{addr: addr.Unmap()} +} + +func (b *pinnedBind) Open(uport uint16) ([]awgconn.ReceiveFunc, uint16, error) { + b.mu.Lock() + defer b.mu.Unlock() + if b.conn != nil { + return nil, 0, awgconn.ErrBindAlreadyOpen + } + + network := "udp4" + if b.addr.Is6() { + network = "udp6" + } + pc, err := (&net.ListenConfig{}).ListenPacket(context.Background(), network, net.JoinHostPort(b.addr.String(), strconv.Itoa(int(uport)))) + if err != nil { + return nil, 0, err + } + uc, ok := pc.(*net.UDPConn) + if !ok { + pc.Close() + return nil, 0, fmt.Errorf("amneziawgnet: listen %s returned %T, want *net.UDPConn", network, pc) + } + laddr, ok := uc.LocalAddr().(*net.UDPAddr) + if !ok { + uc.Close() + return nil, 0, fmt.Errorf("amneziawgnet: unexpected local addr %T", uc.LocalAddr()) + } + b.conn = uc + return []awgconn.ReceiveFunc{b.makeReceiveFunc(uc)}, uint16(laddr.Port), nil +} + +func (b *pinnedBind) makeReceiveFunc(uc *net.UDPConn) awgconn.ReceiveFunc { + return func(bufs [][]byte, sizes []int, eps []awgconn.Endpoint) (int, error) { + n, addr, err := uc.ReadFromUDPAddrPort(bufs[0]) + if err != nil { + return 0, err + } + sizes[0] = n + eps[0] = &awgconn.StdNetEndpoint{AddrPort: netip.AddrPortFrom(addr.Addr().Unmap(), addr.Port())} + return 1, nil + } +} + +func (b *pinnedBind) Close() error { + b.mu.Lock() + defer b.mu.Unlock() + if b.conn == nil { + return nil + } + err := b.conn.Close() + b.conn = nil + return err +} + +// SetMark is a no-op: the panel never configures a WireGuard fwmark here. +func (b *pinnedBind) SetMark(uint32) error { return nil } + +func (b *pinnedBind) Send(bufs [][]byte, ep awgconn.Endpoint) error { + std, ok := ep.(*awgconn.StdNetEndpoint) + if !ok { + return awgconn.ErrWrongEndpointType + } + b.mu.Lock() + uc := b.conn + b.mu.Unlock() + if uc == nil { + return net.ErrClosed + } + for _, buf := range bufs { + if _, err := uc.WriteToUDPAddrPort(buf, std.AddrPort); err != nil { + return err + } + } + return nil +} + +func (b *pinnedBind) ParseEndpoint(s string) (awgconn.Endpoint, error) { + ap, err := netip.ParseAddrPort(s) + if err != nil { + return nil, err + } + return &awgconn.StdNetEndpoint{AddrPort: netip.AddrPortFrom(ap.Addr().Unmap(), ap.Port())}, nil +} + +func (b *pinnedBind) BatchSize() int { return 1 } + +// isWildcardListen reports empty / dual-stack wildcard listen values. +// Includes ::0 (isAnyListen) and [::] so AmneziaWG keeps dual-stack StdNetBind. +func isWildcardListen(listen string) bool { + switch strings.TrimSpace(listen) { + case "", "0.0.0.0", "::", "::0", "[::]", "[::0]": + return true + default: + return false + } +} + +// parseListenAddr returns a concrete host address to pin. ok is false for +// wildcards and for values that are not a bare IP (previously inert for AWG). +func parseListenAddr(listen string) (addr netip.Addr, ok bool) { + listen = strings.TrimSpace(listen) + if isWildcardListen(listen) { + return netip.Addr{}, false + } + // Bracketed IPv6 literal e.g. [::1] — strip for ParseAddr. + if strings.HasPrefix(listen, "[") && strings.HasSuffix(listen, "]") { + listen = listen[1 : len(listen)-1] + } + addr, err := netip.ParseAddr(listen) + if err != nil { + return netip.Addr{}, false + } + return addr.Unmap(), true +} + +// listenBindable probes whether addr can be used as a UDP local address. +func listenBindable(addr netip.Addr) bool { + network := "udp4" + if addr.Is6() { + network = "udp6" + } + pc, err := (&net.ListenConfig{}).ListenPacket(context.Background(), network, net.JoinHostPort(addr.String(), "0")) + if err != nil { + return false + } + _ = pc.Close() + return true +} + +// newListenBind returns StdNetBind for wildcards / unusable listen values, or +// a pinnedBind for a real local address. Never fails the inbound on bad listen. +func newListenBind(listen string) awgconn.Bind { + raw := strings.TrimSpace(listen) + addr, pinned := parseListenAddr(raw) + if !pinned { + if raw != "" && !isWildcardListen(raw) { + logger.Warningf("amneziawgnet: listen %q is not a bindable IP; using dual-stack wildcard", raw) + } + return awgconn.NewDefaultBind() + } + if !listenBindable(addr) { + logger.Warningf("amneziawgnet: listen %q is not usable on this host; using dual-stack wildcard", raw) + return awgconn.NewDefaultBind() + } + return newPinnedBind(addr) +} + +// normalizedListenFP collapses wildcard spellings so fingerprint rebuilds +// only when the effective Bind actually changes. +func normalizedListenFP(listen string) string { + if isWildcardListen(listen) { + return "" + } + addr, ok := parseListenAddr(listen) + if !ok { + return "" // unusable → same Bind as wildcard fallback + } + if !listenBindable(addr) { + return "" + } + return addr.String() +} diff --git a/internal/amneziawgnet/pinned_bind_test.go b/internal/amneziawgnet/pinned_bind_test.go new file mode 100644 index 000000000..e0e66a262 --- /dev/null +++ b/internal/amneziawgnet/pinned_bind_test.go @@ -0,0 +1,160 @@ +package amneziawgnet + +import ( + "net" + "net/netip" + "strconv" + "testing" + "time" + + awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn" + + "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" +) + +func TestParseListenAddr(t *testing.T) { + cases := []struct { + in string + pinned bool + want string + }{ + {in: "", pinned: false}, + {in: " ", pinned: false}, + {in: "0.0.0.0", pinned: false}, + {in: "::", pinned: false}, + {in: "::0", pinned: false}, + {in: "[::]", pinned: false}, + {in: "[::0]", pinned: false}, + {in: "127.0.0.1", pinned: true, want: "127.0.0.1"}, + {in: "::1", pinned: true, want: "::1"}, + {in: "[::1]", pinned: true, want: "::1"}, + {in: "not-an-ip", pinned: false}, + {in: "/var/run/awg.sock", pinned: false}, + } + for _, tc := range cases { + addr, ok := parseListenAddr(tc.in) + if ok != tc.pinned { + t.Fatalf("parseListenAddr(%q) pinned=%v, want %v", tc.in, ok, tc.pinned) + } + if tc.pinned && addr.String() != tc.want { + t.Fatalf("parseListenAddr(%q) = %s, want %s", tc.in, addr, tc.want) + } + } +} + +func TestNewListenBindPinsSpecificAddress(t *testing.T) { + bind := newListenBind("127.0.0.1") + pb, ok := bind.(*pinnedBind) + if !ok { + t.Fatalf("bind type = %T, want *pinnedBind", bind) + } + fns, port, err := pb.Open(0) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer pb.Close() + if len(fns) != 1 { + t.Fatalf("ReceiveFuncs = %d, want 1", len(fns)) + } + if port == 0 { + t.Fatal("expected a concrete ephemeral port") + } + + laddr := pb.conn.LocalAddr().(*net.UDPAddr) + got := laddr.AddrPort().Addr().Unmap() + if got.String() != "127.0.0.1" { + t.Fatalf("LocalAddr = %v, want 127.0.0.1", got) + } + + clash := newListenBind("127.0.0.1") + if _, _, err := clash.Open(port); err == nil { + clash.Close() + t.Fatalf("Open(%d) unexpectedly succeeded on an already-bound address", port) + } +} + +func TestNewListenBindWildcardUsesDefault(t *testing.T) { + for _, listen := range []string{"", "0.0.0.0", "::", "::0", "[::]", "hostname.example", "203.0.113.10", "not-an-ip"} { + bind := newListenBind(listen) + if _, ok := bind.(*pinnedBind); ok { + t.Fatalf("newListenBind(%q) returned pinnedBind, want default StdNetBind", listen) + } + } +} + +func TestPinnedBindRoundTrip(t *testing.T) { + server := newListenBind("127.0.0.1") + recvFns, port, err := server.Open(0) + if err != nil { + t.Fatalf("server Open: %v", err) + } + defer server.Close() + + client, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatalf("client listen: %v", err) + } + defer client.Close() + + payload := []byte("hello-awg-listen") + dst := net.JoinHostPort("127.0.0.1", strconv.Itoa(int(port))) + ap, err := netip.ParseAddrPort(dst) + if err != nil { + t.Fatalf("ParseAddrPort: %v", err) + } + if _, err := client.WriteToUDPAddrPort(payload, ap); err != nil { + t.Fatalf("client write: %v", err) + } + + bufs := [][]byte{make([]byte, 1500)} + sizes := make([]int, 1) + eps := make([]awgconn.Endpoint, 1) + n, err := recvFns[0](bufs, sizes, eps) + if err != nil { + t.Fatalf("receive: %v", err) + } + if n != 1 || sizes[0] != len(payload) { + t.Fatalf("receive n=%d size=%d, want 1/%d", n, sizes[0], len(payload)) + } + if string(bufs[0][:sizes[0]]) != string(payload) { + t.Fatalf("payload = %q, want %q", bufs[0][:sizes[0]], payload) + } + + reply := []byte("pong") + if err := server.Send([][]byte{reply}, eps[0]); err != nil { + t.Fatalf("Send: %v", err) + } + _ = client.SetReadDeadline(time.Now().Add(2 * time.Second)) + buf := make([]byte, 1500) + rn, _, err := client.ReadFromUDP(buf) + if err != nil { + t.Fatalf("client read: %v", err) + } + if string(buf[:rn]) != string(reply) { + t.Fatalf("reply = %q, want %q", buf[:rn], reply) + } +} + +func TestAddressFingerprintIncludesListen(t *testing.T) { + base := amneziawg.Instance{ + MTU: 1420, + Address: []string{"10.8.1.1/24"}, + Obfuscation: amneziawg.Obfuscation31{}, + } + a := addressFingerprint(base) + base.Listen = "127.0.0.1" + b := addressFingerprint(base) + if a == b { + t.Fatalf("listen edit did not change addressFingerprint: %q", a) + } + base.Listen = "0.0.0.0" + if addressFingerprint(base) != a { + t.Fatal("wildcard spellings must share the empty-listen fingerprint") + } + base.Listen = "hostname.example" + if addressFingerprint(base) != a { + t.Fatal("unusable listen must fingerprint like wildcard fallback") + } +} + +var _ awgconn.Bind = (*pinnedBind)(nil) diff --git a/internal/amneziawgnet/resolving_bind.go b/internal/amneziawgnet/resolving_bind.go index b3dda894c..1f92c16da 100644 --- a/internal/amneziawgnet/resolving_bind.go +++ b/internal/amneziawgnet/resolving_bind.go @@ -15,8 +15,8 @@ import ( // endpointResolveTimeout bounds the one-time DNS lookup in ParseEndpoint. const endpointResolveTimeout = 5 * time.Second -// resolvingBind lets peer endpoints be hostnames: StdNetBind has no DNS and -// an unresolved name kills the whole IpcSet. Resolved once at configure. +// resolvingBind wraps a Bind so peer endpoints may be hostnames (#6367). +// Concrete listen values use pinnedBind; wildcards keep StdNetBind. type resolvingBind struct { awgconn.Bind } @@ -35,8 +35,8 @@ func defaultLookupEndpointHost(ctx context.Context, host string) ([]netip.Addr, return out, nil } -func newResolvingBind() *resolvingBind { - return &resolvingBind{Bind: awgconn.NewDefaultBind()} +func newResolvingBind(listen string) *resolvingBind { + return &resolvingBind{Bind: newListenBind(listen)} } // ParseEndpoint resolves hostnames before handing the address to amneziawg-go diff --git a/internal/amneziawgnet/resolving_bind_test.go b/internal/amneziawgnet/resolving_bind_test.go index 9c16153bf..d17b596f6 100644 --- a/internal/amneziawgnet/resolving_bind_test.go +++ b/internal/amneziawgnet/resolving_bind_test.go @@ -9,6 +9,11 @@ import ( awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn" ) +func mustResolvingBind(t *testing.T) *resolvingBind { + t.Helper() + return newResolvingBind("") +} + func endpointAddrPort(ep awgconn.Endpoint) netip.AddrPort { std, ok := ep.(*awgconn.StdNetEndpoint) if !ok { @@ -18,7 +23,7 @@ func endpointAddrPort(ep awgconn.Endpoint) netip.AddrPort { } func TestResolvingBind_ParseEndpointIPLiteral(t *testing.T) { - b := newResolvingBind() + b := mustResolvingBind(t) ep, err := b.ParseEndpoint("203.0.113.7:51820") if err != nil { t.Fatalf("IP endpoint rejected: %v", err) @@ -39,7 +44,7 @@ func TestResolvingBind_ParseEndpointHostnameResolves(t *testing.T) { } defer func() { lookupEndpointHost = orig }() - b := newResolvingBind() + b := mustResolvingBind(t) ep, err := b.ParseEndpoint("peer.example.test:443") if err != nil { t.Fatalf("hostname endpoint rejected: %v", err) @@ -56,14 +61,14 @@ func TestResolvingBind_ParseEndpointResolveFailureIsAnError(t *testing.T) { } defer func() { lookupEndpointHost = orig }() - b := newResolvingBind() + b := mustResolvingBind(t) if _, err := b.ParseEndpoint("missing.example.test:80"); err == nil { t.Fatal("expected resolve failure to surface as an error") } } func TestResolvingBind_ParseEndpointBadPortRejected(t *testing.T) { - b := newResolvingBind() + b := mustResolvingBind(t) if _, err := b.ParseEndpoint("203.0.113.7:none"); err == nil { t.Fatal("expected bad port to be rejected") }