mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 11:15:03 +03:00
daf5b16eda
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.
160 lines
7.5 KiB
Go
160 lines
7.5 KiB
Go
package service
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
)
|
|
|
|
// TestHasTunnelAttachmentDetectsWireguardOrAmneziaWG backs the fix for a
|
|
// real production bug: Attach copies an identity's stored AllowedIPs into
|
|
// every inbound it processes (so the same person keeps the same tunnel
|
|
// address across protocols), but when an identity has been fully detached
|
|
// from every WireGuard/AmneziaWG inbound, that stored address is a leftover
|
|
// nothing reserves anymore -- reusing it can skip past address space that's
|
|
// genuinely free (a real user's own case: address .21 resurrected instead
|
|
// of the actually-free .3). hasTunnelAttachment is what Attach checks to
|
|
// decide whether to clear the stored address before its loop, so it needs
|
|
// to correctly tell "still has an active tunnel elsewhere" (preserve) apart
|
|
// from "no tunnel attachment at all" (clear, allocate fresh).
|
|
func TestHasTunnelAttachmentDetectsWireguardOrAmneziaWG(t *testing.T) {
|
|
setupConflictDB(t)
|
|
seedInboundConflict(t, "awg-1", "0.0.0.0", 443, model.AmneziaWG, ``, `{"server":{"subnetIp":"10.8.1.0","subnetCidr":24},"clients":[]}`)
|
|
seedInboundConflict(t, "wg-1", "0.0.0.0", 51820, model.WireGuard, ``, `{"clients":[]}`)
|
|
seedInboundConflict(t, "vless-1", "0.0.0.0", 8443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`)
|
|
|
|
var awgInbound, wgInbound, vlessInbound model.Inbound
|
|
if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil {
|
|
t.Fatalf("read seeded awg row: %v", err)
|
|
}
|
|
if err := database.GetDB().Where("tag = ?", "wg-1").First(&wgInbound).Error; err != nil {
|
|
t.Fatalf("read seeded wg row: %v", err)
|
|
}
|
|
if err := database.GetDB().Where("tag = ?", "vless-1").First(&vlessInbound).Error; err != nil {
|
|
t.Fatalf("read seeded vless row: %v", err)
|
|
}
|
|
|
|
s := &ClientService{}
|
|
inboundSvc := &InboundService{}
|
|
|
|
if s.hasTunnelAttachment(inboundSvc, nil) {
|
|
t.Error("empty inboundIds must report no tunnel attachment")
|
|
}
|
|
if s.hasTunnelAttachment(inboundSvc, []int{vlessInbound.Id}) {
|
|
t.Error("a VLESS-only attachment must not count as a tunnel attachment")
|
|
}
|
|
if s.hasTunnelAttachment(inboundSvc, []int{99999}) {
|
|
t.Error("a nonexistent inbound id must not count as a tunnel attachment")
|
|
}
|
|
if !s.hasTunnelAttachment(inboundSvc, []int{vlessInbound.Id, wgInbound.Id}) {
|
|
t.Error("a WireGuard inbound among others must count as a tunnel attachment")
|
|
}
|
|
if !s.hasTunnelAttachment(inboundSvc, []int{awgInbound.Id}) {
|
|
t.Error("an AmneziaWG inbound must count as a tunnel attachment")
|
|
}
|
|
}
|
|
|
|
// TestAddressesFitAmneziaWGInbound is a regression test for a real
|
|
// production bug: hasTunnelAttachment only asked "does this identity have
|
|
// ANY tunnel attachment", not "is the address it would inherit actually
|
|
// valid for THIS inbound" -- so an identity whose stored address came from
|
|
// WireGuard's own fallback subnet (10.0.0.0/24, used when that inbound has
|
|
// no other clients to infer a base from) got that exact address silently
|
|
// carried over onto a second, AmneziaWG inbound configured for a completely
|
|
// different subnet (10.8.1.0/24). defaultAmneziaWGClients's already-set
|
|
// branch only checks for collisions, not subnet membership, so the mismatch
|
|
// was accepted with no error -- producing a peer that can never actually
|
|
// connect (an AmneziaWG address must fall inside the kernel interface's own
|
|
// configured subnet to be routable at all). addressesFitAmneziaWGInbound is
|
|
// the check Attach now runs per inbound before deciding whether to keep an
|
|
// inherited address or force a fresh allocation.
|
|
func TestAddressesFitAmneziaWGInbound(t *testing.T) {
|
|
setupConflictDB(t)
|
|
seedInboundConflict(t, "awg-1", "0.0.0.0", 443, model.AmneziaWG, ``, `{"server":{"subnetIp":"10.8.1.0","subnetCidr":24},"clients":[]}`)
|
|
seedInboundConflict(t, "wg-1", "0.0.0.0", 51820, model.WireGuard, ``, `{"clients":[]}`)
|
|
|
|
var awgInbound, wgInbound model.Inbound
|
|
if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil {
|
|
t.Fatalf("read seeded awg row: %v", err)
|
|
}
|
|
if err := database.GetDB().Where("tag = ?", "wg-1").First(&wgInbound).Error; err != nil {
|
|
t.Fatalf("read seeded wg row: %v", err)
|
|
}
|
|
|
|
if !addressesFitAmneziaWGInbound(nil, &awgInbound) {
|
|
t.Error("no addresses at all must trivially fit (Attach's own fresh-allocate path)")
|
|
}
|
|
if !addressesFitAmneziaWGInbound([]string{"10.0.0.2/32"}, &wgInbound) {
|
|
t.Error("WireGuard has no strict subnet requirement -- must never be rejected here")
|
|
}
|
|
if addressesFitAmneziaWGInbound([]string{"10.0.0.2/32"}, &awgInbound) {
|
|
t.Fatal("the real bug: a WireGuard-fallback-subnet address must NOT be accepted as fitting an AmneziaWG inbound configured for a different subnet")
|
|
}
|
|
if !addressesFitAmneziaWGInbound([]string{"10.8.1.21/32"}, &awgInbound) {
|
|
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])
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|