mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 03:05:02 +03:00
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:
@@ -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])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -833,7 +833,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
|
||||
}
|
||||
peers = append(peers, oldClients[i].AllowedIPs...)
|
||||
}
|
||||
if entry, taken := wireguardAllowedIPsOverlap(normalized, peers); taken != "" {
|
||||
if entry, taken := wireguardAllowedIPsOverlap(withoutKeptDefaultRoutes(normalized, old.AllowedIPs), peers); taken != "" {
|
||||
return false, common.NewError("wireguard: allowedIPs entry", entry, "overlaps", taken, "used by another client")
|
||||
}
|
||||
clients[0].AllowedIPs = normalized
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -188,9 +189,9 @@ func normalizeWireguardAllowedIPs(values []string) ([]string, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// wireguardClaimedPrefix is the masked range an allowedIPs entry claims, as xray
|
||||
// reads it. A /0 default route claims no tunnel address, as legacy peers carry it.
|
||||
func wireguardClaimedPrefix(s string) (netip.Prefix, bool) {
|
||||
// parseWireguardAllowedIP reads an allowedIPs entry unmasked, a bare address as
|
||||
// a single host.
|
||||
func parseWireguardAllowedIP(s string) (netip.Prefix, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
p, err := netip.ParsePrefix(s)
|
||||
if err != nil {
|
||||
@@ -200,14 +201,35 @@ func wireguardClaimedPrefix(s string) (netip.Prefix, bool) {
|
||||
}
|
||||
p = netip.PrefixFrom(a, a.BitLen())
|
||||
}
|
||||
if p.Bits() == 0 {
|
||||
return p, true
|
||||
}
|
||||
|
||||
// isWireguardDefaultRoute reports whether an allowedIPs entry carries a /0 mask.
|
||||
func isWireguardDefaultRoute(s string) bool {
|
||||
p, ok := parseWireguardAllowedIP(s)
|
||||
return ok && p.Bits() == 0
|
||||
}
|
||||
|
||||
// wireguardClaimedPrefix is the range a stored allowedIPs entry keeps other clients
|
||||
// out of. Only a 0.0.0.0/0 or ::/0 default route claims nothing (legacy peers carry it).
|
||||
func wireguardClaimedPrefix(s string) (netip.Prefix, bool) {
|
||||
p, ok := parseWireguardAllowedIP(s)
|
||||
if !ok {
|
||||
return netip.Prefix{}, false
|
||||
}
|
||||
if p.Bits() == 0 {
|
||||
if p.Addr().IsUnspecified() {
|
||||
return netip.Prefix{}, false
|
||||
}
|
||||
// The host bits are stored with it and read back as the peer's tunnel
|
||||
// address (amneziawg.FirstIPv4 / FirstIPv6), so they are what it claims.
|
||||
return netip.PrefixFrom(p.Addr(), p.Addr().BitLen()), true
|
||||
}
|
||||
return p.Masked(), true
|
||||
}
|
||||
|
||||
// wireguardAllowedIPsOverlap returns the first entry whose range overlaps a used
|
||||
// one, and that used entry; xray routes and attributes by containment, not equality.
|
||||
// wireguardAllowedIPsOverlap returns the first entry whose whole range (a /0 holds its entire
|
||||
// family) overlaps a used one, and that entry; xray routes by containment, not equality.
|
||||
func wireguardAllowedIPsOverlap(entries, used []string) (entry, taken string) {
|
||||
usedPrefixes := make([]netip.Prefix, len(used))
|
||||
usedOK := make([]bool, len(used))
|
||||
@@ -215,10 +237,11 @@ func wireguardAllowedIPsOverlap(entries, used []string) (entry, taken string) {
|
||||
usedPrefixes[i], usedOK[i] = wireguardClaimedPrefix(u)
|
||||
}
|
||||
for _, e := range entries {
|
||||
ep, ok := wireguardClaimedPrefix(e)
|
||||
ep, ok := parseWireguardAllowedIP(e)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ep = ep.Masked()
|
||||
for i, up := range usedPrefixes {
|
||||
if usedOK[i] && ep.Overlaps(up) {
|
||||
return e, used[i]
|
||||
@@ -228,6 +251,14 @@ func wireguardAllowedIPsOverlap(entries, used []string) (entry, taken string) {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// withoutKeptDefaultRoutes drops the /0 entries stored already holds: an edit that
|
||||
// echoes a legacy peer's default route claims nothing new and must stay possible.
|
||||
func withoutKeptDefaultRoutes(entries, stored []string) []string {
|
||||
return slices.DeleteFunc(slices.Clone(entries), func(e string) bool {
|
||||
return isWireguardDefaultRoute(e) && slices.Contains(stored, e)
|
||||
})
|
||||
}
|
||||
|
||||
// defaultWireguardClients fills in blank WireGuard credentials for newly added
|
||||
// clients: a generated keypair when none was provided, a derived public key when
|
||||
// only a private key was given, and a unique tunnel address allocated from the
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
|
||||
)
|
||||
@@ -393,3 +394,89 @@ func TestAllocateWireguardAddressSkipsAddressesInsideUsedPrefixes(t *testing.T)
|
||||
t.Fatalf("got %s, want 10.0.0.4/32: .2 and .3 are both inside the used 10.0.0.2/31", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A /0 that is being written contains every other peer's address, and its host bits
|
||||
// are read back as the peer's own tunnel address (amneziawg.FirstIPv4). Only a stored
|
||||
// 0.0.0.0/0 or ::/0 keeps nobody out, as legacy peers carry it.
|
||||
func TestWireguardSlashZeroIsSubjectToTheOverlapGuard(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
entry, used string
|
||||
taken bool
|
||||
}{
|
||||
{"10.8.1.2/0", "10.8.1.2/32", true},
|
||||
{"10.8.1.3/0", "10.8.1.2/32", true},
|
||||
{"0.0.0.0/0", "10.8.1.2/32", true},
|
||||
{"::/0", "fd00::2/128", true},
|
||||
{"fd00::2/0", "fd00::2/128", true},
|
||||
{"::/0", "10.8.1.2/32", false},
|
||||
{"10.8.1.2/32", "10.8.1.2/0", true},
|
||||
{"fd00::2/128", "fd00::2/0", true},
|
||||
{"10.8.1.2/32", "10.8.1.3/0", false},
|
||||
{"10.8.1.2/32", "0.0.0.0/0", false},
|
||||
{"0.0.0.0/0", "0.0.0.0/0", false},
|
||||
} {
|
||||
t.Run(tt.entry+" next to "+tt.used, func(t *testing.T) {
|
||||
if _, taken := wireguardAllowedIPsOverlap([]string{tt.entry}, []string{tt.used}); (taken != "") != tt.taken {
|
||||
t.Errorf("taken = %q, want a collision: %v", taken, tt.taken)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
existing := []model.Client{{Email: "victim@wg", AllowedIPs: []string{"10.0.0.2/32"}}}
|
||||
awgSettings := `{"server":{"subnetIp":"10.0.0.0","subnetCidr":24}}`
|
||||
for _, entry := range []string{"10.0.0.2/0", "0.0.0.0/0"} {
|
||||
clients := []model.Client{{Email: "other@wg", AllowedIPs: []string{entry}}}
|
||||
err := defaultWireguardClients("", existing, clients, []any{map[string]any{}}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), entry+" overlaps 10.0.0.2/32") {
|
||||
t.Errorf("wireguard: %s next to 10.0.0.2/32: err = %v, want an overlap", entry, err)
|
||||
}
|
||||
err = defaultAmneziaWGClients(awgSettings, existing, clients, []any{map[string]any{}}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), entry+" overlaps 10.0.0.2/32") {
|
||||
t.Errorf("amneziawg: %s next to 10.0.0.2/32: err = %v, want an overlap", entry, err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := allocateWireguardAddress([]string{"10.0.0.2/0"}, "10.0.0.0/24", true)
|
||||
if err != nil || got != "10.0.0.3/32" {
|
||||
t.Errorf("allocated %q (%v) next to a stored 10.0.0.2/0, want 10.0.0.3/32", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The edit form echoes what the client holds, so a legacy peer's default route has
|
||||
// to survive a save next to other clients; no edit may bring in a new one.
|
||||
func TestUpdateInboundClientKeepsButNeverAddsDefaultRoute(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
seedInboundConflict(t, "own-wg", "0.0.0.0", 51830, model.WireGuard, ``, `{"clients":[`+
|
||||
`{"email":"legacy","subId":"s-legacy","enable":true,"publicKey":"QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE=","allowedIPs":["0.0.0.0/0","::/0"]},`+
|
||||
`{"email":"victim","subId":"s-victim","enable":true,"publicKey":"QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=","allowedIPs":["10.0.0.2/32"]},`+
|
||||
`{"email":"other","subId":"s-other","enable":true,"publicKey":"Q0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0M=","allowedIPs":["10.0.0.3/32"]}]}`)
|
||||
s, inboundSvc := &ClientService{}, &InboundService{}
|
||||
var own model.Inbound
|
||||
if err := database.GetDB().Where("tag = ?", "own-wg").First(&own).Error; err != nil {
|
||||
t.Fatalf("read seeded row: %v", err)
|
||||
}
|
||||
clients, err := inboundSvc.GetClients(&own)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClients: %v", err)
|
||||
}
|
||||
if err := s.SyncInbound(nil, own.Id, clients); err != nil {
|
||||
t.Fatalf("SyncInbound: %v", err)
|
||||
}
|
||||
save := func(email, allowedIPs string) error {
|
||||
data := &model.Inbound{Id: own.Id, Settings: `{"clients":[{"email":"` + email + `","subId":"s-` + email + `","enable":true,"allowedIPs":` + allowedIPs + `}]}`}
|
||||
_, err := s.UpdateInboundClient(inboundSvc, data, email)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := save("legacy", `["0.0.0.0/0","::/0"]`); err != nil {
|
||||
t.Errorf("saving the legacy peer as it is: %v", err)
|
||||
}
|
||||
for _, allowedIPs := range []string{`["0.0.0.0/0"]`, `["10.0.0.2/0"]`, `["10.0.0.3/32","0.0.0.0/0"]`} {
|
||||
if err := save("other", allowedIPs); err == nil || !strings.Contains(err.Error(), "overlaps 10.0.0.2/32") {
|
||||
t.Errorf("other saved with %s: err = %v, want an overlap with 10.0.0.2/32", allowedIPs, err)
|
||||
}
|
||||
}
|
||||
if err := save("legacy", `["10.0.0.2/0","::/0"]`); err == nil || !strings.Contains(err.Error(), "10.0.0.2/0 overlaps 10.0.0.2/32") {
|
||||
t.Errorf("the legacy peer took 10.0.0.2/0 next to 10.0.0.2/32: err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user