fix(wireguard): hold /0 allowedIPs to the overlap guard

The tunnel overlap guard treated every /0 allowedIPs entry as claiming
no address, while the entry was stored with its host bits and the
consumers read them back: the AmneziaWG port forwarder resolves a peer's
target from them and the device routes the /0 by longest prefix. An
entry such as 10.8.1.2/0 therefore passed next to another client's
10.8.1.2/32, and forwarded ports reached that client's device.

A written entry now counts with its whole range, so a /0 collides with
every held address of its family. On the stored side only a literal
0.0.0.0/0 or ::/0 default route keeps nobody out, and a /0 with host
bits claims that host. Editing a legacy peer that echoes its stored
default route still works.
This commit is contained in:
MHSanaei
2026-11-01 00:06:09 +01:00
parent d114845e44
commit daf5b16eda
8 changed files with 196 additions and 14 deletions
@@ -1,6 +1,8 @@
package service
import (
"errors"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
@@ -95,3 +97,63 @@ func TestAddressesFitAmneziaWGInbound(t *testing.T) {
t.Error("an address genuinely inside the awg inbound's own configured subnet must fit")
}
}
// A /0 reported by a node contains every address of the target inbound, and its
// host bits are what the AmneziaWG forwarder reads as the peer's address, so
// Attach refuses it next to another client exactly as it refuses the /32.
func TestAttachRefusesDefaultRouteNextToHeldAddress(t *testing.T) {
attaches := map[string]func(*ClientService, *InboundService, string, int) error{
"Attach": func(s *ClientService, inboundSvc *InboundService, email string, ibId int) error {
_, err := s.AttachByEmail(inboundSvc, email, []int{ibId})
return err
},
"BulkAttach": func(s *ClientService, inboundSvc *InboundService, email string, ibId int) error {
result, _, err := s.BulkAttach(inboundSvc, []string{email}, []int{ibId})
if err == nil && len(result.Errors) > 0 {
err = errors.New(strings.Join(result.Errors, "; "))
}
return err
},
}
for name, attach := range attaches {
for _, protocol := range []model.Protocol{model.WireGuard, model.AmneziaWG} {
t.Run(name+"/"+string(protocol), func(t *testing.T) {
setupConflictDB(t)
node := 1
seedInboundConflict(t, "own", "0.0.0.0", 51830, protocol, ``, `{"subnetIp":"10.8.1.0","subnetCidr":24,"server":{"subnetIp":"10.8.1.0","subnetCidr":24},"clients":[`+
`{"email":"local","subId":"s-local","enable":true,"publicKey":"QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE=","allowedIPs":["10.8.1.2/32"]}]}`)
seedInboundConflictNode(t, "node", "0.0.0.0", 44301, protocol, ``, `{"clients":[`+
`{"email":"host-bits","subId":"s-host-bits","enable":true,"publicKey":"QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=","allowedIPs":["10.8.1.2/0"]}]}`, &node)
s := &ClientService{}
inboundSvc := &InboundService{}
var own model.Inbound
for _, tag := range []string{"node", "own"} {
own = model.Inbound{}
if err := database.GetDB().Where("tag = ?", tag).First(&own).Error; err != nil {
t.Fatalf("read seeded %s row: %v", tag, err)
}
clients, err := inboundSvc.GetClients(&own)
if err != nil {
t.Fatalf("GetClients(%s): %v", tag, err)
}
if err := s.SyncInbound(nil, own.Id, clients); err != nil {
t.Fatalf("SyncInbound(%s): %v", tag, err)
}
}
err := attach(s, inboundSvc, "host-bits", own.Id)
if err == nil || !strings.Contains(err.Error(), "10.8.1.2/0 overlaps 10.8.1.2/32") {
t.Errorf("attach of host-bits (10.8.1.2/0): err = %v, want an overlap with 10.8.1.2/32", err)
}
got, gErr := s.TunnelAllowedIPsByInbound(inboundSvc, "host-bits", []int{own.Id})
if gErr != nil {
t.Fatalf("TunnelAllowedIPsByInbound: %v", gErr)
}
if _, held := got[own.Id]; held {
t.Errorf("host-bits landed on the master's inbound with %q", got[own.Id])
}
})
}
}
}