5 Commits a810f497e6 ... 43e64993fc

Autore SHA1 Messaggio Data
  BlindMaster24 43e64993fc fix(amneziawg): refuse a row's own relay port and keep a disabled row's slot reserved (#6544) 10 ore fa
  BlindMaster24 d52b598abf fix(amneziawg): reserve the relay port before an AmneziaWG inbound has a peer (#6542) 12 ore fa
  BlindMaster24 2d8d304850 fix(amneziawg): stop a disabled inbound's relay slot from being taken (#6540) 12 ore fa
  BlindMaster24 a036ddd66f fix(amneziawg): wrap the relay port window instead of refusing ids past it (#6539) 14 ore fa
  BlindMaster24 78ab7a9246 fix(amneziawg): read the outbound pseudo-protocol id like the core (#6531) 15 ore fa

+ 1 - 1
internal/amneziawg/outbound.go

@@ -112,7 +112,7 @@ func IsAmneziaWGOutbound(raw []byte) bool {
 	if err := json.Unmarshal(raw, &probe); err != nil {
 		return false
 	}
-	return probe.Protocol == "amneziawg"
+	return strings.EqualFold(probe.Protocol, "amneziawg")
 }
 
 // outboundSettingsOf extracts the nested "settings" block from a raw

+ 5 - 2
internal/amneziawgnet/socks_config.go

@@ -11,10 +11,13 @@ import (
 // own Xray SOCKS5 relay inbound (see relay.go/SocksInboundSettings).
 const SOCKSBasePort = 65100
 
+// relayPortSlots is how many ids fit above SOCKSBasePort before wrapping.
+const relayPortSlots = 65535 - SOCKSBasePort
+
 // SOCKSPortForInbound derives one inbound's loopback SOCKS5 relay port from
-// its id, so config generation and the dialing relay never need to negotiate.
+// its id, wrapping ids past relayPortSlots so no id ever lacks a port.
 func SOCKSPortForInbound(inboundID int) int {
-	return SOCKSBasePort + inboundID
+	return SOCKSBasePort + 1 + (inboundID-1)%relayPortSlots
 }
 
 var (

+ 26 - 0
internal/amneziawgnet/socks_config_test.go

@@ -0,0 +1,26 @@
+package amneziawgnet
+
+import "testing"
+
+// An inbound id past the slot count used to be refused outright, which capped a
+// database at 435 AmneziaWG inbounds for its whole life (#6537).
+func TestSOCKSPortForInboundKeepsEverySlotInsideTheWindow(t *testing.T) {
+	t.Run("no id derives a port outside the window", func(t *testing.T) {
+		for _, id := range []int{1, 2, 434, 435, 436, 437, 870, 871, 6537, 70350, 1_000_000} {
+			port := SOCKSPortForInbound(id)
+			if port < SOCKSBasePort+1 || port > 65535 {
+				t.Errorf("id %d derives relay port %d, outside %d..65535", id, port, SOCKSBasePort+1)
+			}
+		}
+	})
+
+	// Every id the old formula reached must keep its exact port, or upgrading
+	// moves a running relay. Ids 1..435 also leave SOCKSBasePort itself unused.
+	t.Run("ids up to the slot count keep the port they always had", func(t *testing.T) {
+		for id := 1; id <= 435; id++ {
+			if got, want := SOCKSPortForInbound(id), SOCKSBasePort+id; got != want {
+				t.Errorf("id %d moved from relay port %d to %d", id, want, got)
+			}
+		}
+	})
+}

+ 18 - 8
internal/web/service/inbound.go

@@ -17,7 +17,6 @@ import (
 	"github.com/google/uuid"
 
 	"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/logger"
@@ -1238,20 +1237,31 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 		if err := tx.Omit("ClientStats").Save(inbound).Error; err != nil {
 			return err
 		}
-		// The relay port is derived from the id, only known after Save; checkPortConflictTx
-		// ran the reverse-direction check above with ignoreId==0, so it couldn't yet.
-		if inbound.Protocol == model.AmneziaWG {
-			if amneziawgnet.SOCKSPortForInbound(inbound.Id) > 65535 {
-				return common.NewErrorf("amneziawg: inbound id %d exceeds the relay port window (ids above %d are not supported)",
-					inbound.Id, 65535-amneziawgnet.SOCKSBasePort)
+		// The relay port is derived from the id, only known after Save, and only a
+		// local row owns one: checkPortConflictTx ran no relay check with ignoreId==0.
+		if inbound.NodeID == nil && inbound.Protocol == model.AmneziaWG {
+			if self := amneziawgnetSocksSelfConflict(inbound, inbound.Id); self != "" {
+				return common.NewError(self)
 			}
-			conflict, cErr := checkAmneziawgnetSocksReverseConflict(tx, inbound.Id)
+			conflict, cErr := checkAmneziawgnetSocksRelayCollision(tx, inbound.Id)
 			if cErr != nil {
 				return cErr
 			}
 			if conflict != nil {
 				return common.NewError(conflict.String())
 			}
+			conflict, cErr = checkAmneziawgnetSocksReverseConflict(tx, inbound.Id)
+			if cErr != nil {
+				return cErr
+			}
+			if conflict != nil {
+				return common.NewError(conflict.String())
+			}
+			// The clients' forward specs were validated while this row had no id,
+			// so the ports it now derives were never in the guard's context.
+			if aErr := s.checkAmneziaWGForwardedPorts(tx, inbound.Settings); aErr != nil {
+				return aErr
+			}
 		}
 		// Emails seeded here (import's ClientStats, e.g. the controller's forced
 		// Enable=true on every imported stat row) are authoritative for this call

+ 37 - 21
internal/web/service/inbound_amneziawg.go

@@ -278,8 +278,8 @@ func (s *InboundService) normalizeAmneziaWGSettings(inbound *model.Inbound, oldS
 	}
 	for i := range parsed.Clients {
 		c := &parsed.Clients[i]
-		if hit := s.checkForwardedPortsConflict(portCtx, c.ForwardedPorts); hit != "" {
-			return fmt.Errorf("amneziawg: client %q forwardedPorts collides with %s", c.Email, hit)
+		if err := s.amneziaWGForwardedPortsConflict(portCtx, c); err != nil {
+			return err
 		}
 		if err := amneziawg.ValidateConfigValue("email", c.Email); err != nil {
 			return fmt.Errorf("amneziawg: %w", err)
@@ -313,21 +313,15 @@ func (s *InboundService) normalizeAmneziaWGSettings(inbound *model.Inbound, oldS
 	return nil
 }
 
-// portConflictContext caches the state checkForwardedPortsConflict needs —
-// the panel's own port and this host's enabled inbound ports — so validating
-// N clients in one save (normalizeAmneziaWGSettings, or a bulk client add)
-// costs one query total instead of N. Load it once with
-// loadPortConflictContext and pass it to every checkForwardedPortsConflict
-// call in that batch.
+// portConflictContext caches what checkForwardedPortsConflict needs — the panel's
+// own port and this host's enabled rows — so one save costs one query, not N.
 type portConflictContext struct {
 	webPort  int
 	inbounds []*model.Inbound
 }
 
-// loadPortConflictContext loads the panel's own port and every enabled
-// inbound hosted on THIS panel (node_id IS NULL) — an inbound hosted on a
-// different node listens on that node's own host, never this one, so it can
-// never collide with a DNAT rule this process installs.
+// loadPortConflictContext loads the panel's own port and every enabled inbound
+// hosted on THIS panel: a node-hosted one listens on that node's host, not here.
 func (s *InboundService) loadPortConflictContext(db *gorm.DB) (portConflictContext, error) {
 	var ctx portConflictContext
 	if webPort, err := (&SettingService{}).GetPort(); err == nil {
@@ -339,15 +333,37 @@ func (s *InboundService) loadPortConflictContext(db *gorm.DB) (portConflictConte
 	return ctx, err
 }
 
-// checkForwardedPortsConflict reports whether a client's ForwardedPorts spec
-// exceeds the cap, covers the panel's own web port, one of this host's own
-// enabled inbound listen ports, or an AmneziaWG inbound's own phantom SOCKS5
-// relay port (SOCKSPortForInbound -- never a real inbounds row, so the loop
-// below can't see it any other way). A collision on the SOCKS5 port would
-// let a port-forward listener race Xray's own relay for the bind and, if it
-// wins, take down that inbound's entire relay rather than just one forward.
-// Returns a human-readable description of the first collision found, or ""
-// when there is none.
+// amneziaWGForwardedPortsConflict renders one client's ForwardedPorts collision,
+// or nil: the single copy both the pre-Save pass and the post-Save re-run use.
+func (s *InboundService) amneziaWGForwardedPortsConflict(ctx portConflictContext, c *model.Client) error {
+	hit := s.checkForwardedPortsConflict(ctx, c.ForwardedPorts)
+	if hit == "" {
+		return nil
+	}
+	return fmt.Errorf("amneziawg: client %q forwardedPorts collides with %s", c.Email, hit)
+}
+
+// checkAmneziaWGForwardedPorts re-runs the guard over one row's stored clients:
+// on create it ran before Save, when the row's own ports were not in the context.
+func (s *InboundService) checkAmneziaWGForwardedPorts(db *gorm.DB, settings string) error {
+	var parsed amneziawg.InboundSettings
+	if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
+		return nil
+	}
+	ctx, err := s.loadPortConflictContext(db)
+	if err != nil {
+		return err
+	}
+	for i := range parsed.Clients {
+		if err := s.amneziaWGForwardedPortsConflict(ctx, &parsed.Clients[i]); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// checkForwardedPortsConflict names the panel, inbound or AmneziaWG relay port a
+// client's ForwardedPorts spec would collide with: a lost bind race kills the relay.
 func (s *InboundService) checkForwardedPortsConflict(ctx portConflictContext, forwardedPorts string) string {
 	if forwardedPorts == "" {
 		return ""

+ 294 - 0
internal/web/service/inbound_amneziawg_relay_window_test.go

@@ -0,0 +1,294 @@
+package service
+
+import (
+	"fmt"
+	"strings"
+	"testing"
+
+	"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"
+	wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
+)
+
+// awgRelayWindowSettings builds an AmneziaWG settings blob AddInbound accepts:
+// real X25519 keys, one enabled peer, and an email unique to tag.
+func awgRelayWindowSettings(t *testing.T, tag string) string {
+	t.Helper()
+	_, clientPub, err := wgutil.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("generate client keypair: %v", err)
+	}
+	return `{"server":{"privateKey":"` + awgTestPrivateKey + `","publicKey":"` + awgTestPublicKey +
+		`","subnetIp":"10.8.1.0","subnetCidr":24},"clients":[{"email":"` + tag + `@relay-window","enable":true,"publicKey":"` +
+		clientPub + `","allowedIPs":["10.8.1.2/32"]}]}`
+}
+
+// awgRelayWindowSettingsWithForward is awgRelayWindowSettings with one client's
+// forwardedPorts set, the field the create-time guard validates.
+func awgRelayWindowSettingsWithForward(t *testing.T, tag, forwardedPorts string) string {
+	t.Helper()
+	settings := awgRelayWindowSettings(t, tag)
+	return strings.Replace(settings, `"enable":true`, `"enable":true,"forwardedPorts":"`+forwardedPorts+`"`, 1)
+}
+
+// pushInboundIDSequence makes the next inbounds insert land on nextID, standing
+// in for a long-lived database whose AUTOINCREMENT counter has climbed there.
+func pushInboundIDSequence(t *testing.T, nextID int) {
+	t.Helper()
+	// The counter is a sqlite_sequence row, so this has no PostgreSQL equivalent.
+	if database.IsPostgres() {
+		t.Skip("the inbounds AUTOINCREMENT counter is a SQLite row")
+	}
+	res := database.GetDB().Exec("UPDATE sqlite_sequence SET seq = ? WHERE name = ?", nextID-1, "inbounds")
+	if res.Error != nil {
+		t.Fatalf("push the inbounds sequence to %d: %v", nextID, res.Error)
+	}
+	if res.RowsAffected != 1 {
+		t.Fatalf("inbounds has no AUTOINCREMENT counter row to push (%d rows updated)", res.RowsAffected)
+	}
+}
+
+func addAmneziaWGInbound(t *testing.T, tag string, port int, enable bool) *model.Inbound {
+	t.Helper()
+	created, _, err := (&InboundService{}).AddInbound(&model.Inbound{
+		Tag:      tag,
+		Enable:   enable,
+		Listen:   "0.0.0.0",
+		Port:     port,
+		Protocol: model.AmneziaWG,
+		Settings: awgRelayWindowSettings(t, tag),
+	})
+	if err != nil {
+		t.Fatalf("AddInbound(%s): %v", tag, err)
+	}
+	return created
+}
+
+// An id past the slot count used to be refused outright, which capped a
+// database at 435 AmneziaWG inbounds for its entire life (#6537).
+func TestAddInbound_AmneziawgPastTheRelayPortWindowStillCreates(t *testing.T) {
+	setupConflictDB(t)
+
+	// Self-check: the low-id path must work, or the assertion below could pass
+	// because the fixture never created an AmneziaWG inbound at all.
+	addAmneziaWGInbound(t, "awg-low-id", 51820, true)
+
+	pushInboundIDSequence(t, 70001)
+	created := addAmneziaWGInbound(t, "awg-past-window", 51821, true)
+	if created.Id < 436 {
+		t.Fatalf("fixture: inbound id %d is still inside the old window", created.Id)
+	}
+	if port := amneziawgnet.SOCKSPortForInbound(created.Id); port < amneziawgnet.SOCKSBasePort+1 || port > 65535 {
+		t.Fatalf("inbound %d derived relay port %d, outside %d..65535", created.Id, port, amneziawgnet.SOCKSBasePort+1)
+	}
+}
+
+// Wrapping ids makes the id -> relay-port map non-injective, so a create can
+// land on a port an existing inbound's relay already owns.
+func TestAddInbound_AmneziawgRefusesAClaimedRelayPort(t *testing.T) {
+	for _, blockerEnabled := range []bool{true, false} {
+		t.Run(fmt.Sprintf("blocker enabled=%t", blockerEnabled), func(t *testing.T) {
+			setupConflictDB(t)
+			blocker := addAmneziaWGInbound(t, "awg-blocker", 51820, blockerEnabled)
+
+			// One slot-window further on is the id that derives the blocker's port.
+			collidingID := blocker.Id + 435
+			pushInboundIDSequence(t, collidingID)
+
+			_, _, err := (&InboundService{}).AddInbound(&model.Inbound{
+				Tag:      "awg-collides",
+				Enable:   true,
+				Listen:   "0.0.0.0",
+				Port:     51821,
+				Protocol: model.AmneziaWG,
+				Settings: awgRelayWindowSettings(t, "awg-collides"),
+			})
+			if err == nil {
+				t.Fatalf("inbound %d derives relay port %d, already owned by %q; the create must be refused",
+					collidingID, amneziawgnet.SOCKSPortForInbound(blocker.Id), blocker.Tag)
+			}
+			if !strings.Contains(err.Error(), blocker.Tag) {
+				t.Fatalf("the conflict must name the inbound owning the port, got %v", err)
+			}
+			// The blocker's own port is its WireGuard one, so without this the
+			// message reads as if that inbound listened on an unrelated port.
+			if !strings.Contains(err.Error(), "relay port") {
+				t.Fatalf("the refusal must say the port is an automatic relay one, got %v", err)
+			}
+		})
+	}
+}
+
+// Wrapping makes id -> relay port non-injective, so an edit landing on a slot a
+// local inbound already owns has to be refused: the create guard never sees it.
+func TestCheckPortConflict_LocalAmneziawgRelayCollisionBlocksTheEdit(t *testing.T) {
+	setupConflictDB(t)
+	blocker := addAmneziaWGInbound(t, "awg-blocker", 51820, true)
+
+	local := &model.Inbound{
+		Tag:      "awg-edited",
+		Enable:   true,
+		Listen:   "0.0.0.0",
+		Port:     51821,
+		Protocol: model.AmneziaWG,
+		Settings: awgRelayWindowSettings(t, "awg-edited"),
+	}
+	collidingID := blocker.Id + 435
+
+	got, err := (&InboundService{}).checkPortConflict(local, collidingID)
+	if err != nil {
+		t.Fatalf("checkPortConflict: %v", err)
+	}
+	if got == nil {
+		t.Fatalf("id %d derives relay port %d, already owned by %q; the save must be refused",
+			collidingID, amneziawgnet.SOCKSPortForInbound(blocker.Id), blocker.Tag)
+	}
+	if !strings.Contains(got.String(), blocker.Tag) {
+		t.Fatalf("the conflict must name the inbound owning the port, got %q", got.String())
+	}
+}
+
+// A disabled row still owns the relay slot its id derives: SetInboundEnable
+// flips the column with no port check, so enabling it later would break Xray.
+func TestCheckPortConflict_DisabledAmneziawgStillOwnsItsRelaySlot(t *testing.T) {
+	setupConflictDB(t)
+	owner := addAmneziaWGInbound(t, "awg-disabled", 51820, false)
+	relayPort := amneziawgnet.SOCKSPortForInbound(owner.Id)
+
+	got, err := (&InboundService{}).checkPortConflict(&model.Inbound{
+		Tag:      "takes-the-slot",
+		Enable:   true,
+		Listen:   "0.0.0.0",
+		Port:     relayPort,
+		Protocol: model.VLESS,
+		Settings: `{"clients":[]}`,
+	}, 0)
+	if err != nil {
+		t.Fatalf("checkPortConflict: %v", err)
+	}
+	if got == nil {
+		t.Fatalf("inbound #%d is disabled but still owns relay port %d; the save must be refused",
+			owner.Id, relayPort)
+	}
+	if !strings.Contains(got.String(), owner.Tag) {
+		t.Fatalf("the conflict must name the inbound owning the port, got %q", got.String())
+	}
+}
+
+// The forwarded-ports guard runs before Save, when the row has no id yet, so a
+// client's spec never saw the relay port the row itself derives.
+func TestAddInbound_AmneziawgRefusesAClientForwardingItsOwnRelayPort(t *testing.T) {
+	setupConflictDB(t)
+
+	placeholder := addAmneziaWGInbound(t, "awg-placeholder", 51820, true)
+	ownPort := amneziawgnet.SOCKSPortForInbound(placeholder.Id + 1)
+
+	_, _, err := (&InboundService{}).AddInbound(&model.Inbound{
+		Tag:      "awg-forward",
+		Enable:   true,
+		Listen:   "0.0.0.0",
+		Port:     51821,
+		Protocol: model.AmneziaWG,
+		Settings: awgRelayWindowSettingsWithForward(t, "awg-forward", fmt.Sprintf("%d", ownPort)),
+	})
+	if err == nil {
+		t.Fatalf("inbound #%d derives relay port %d and its own client forwards that port; the create must be refused",
+			placeholder.Id+1, ownPort)
+	}
+	if !strings.Contains(err.Error(), "forwardedPorts") {
+		t.Fatalf("the refusal must come from the forwarded-ports guard, got %v", err)
+	}
+}
+
+// The row's own WireGuard port can be the relay port its own id derives, and
+// every relay check excludes that id, so nothing else compares the two.
+func TestAddInbound_AmneziawgRefusesItsOwnRelayPort(t *testing.T) {
+	setupConflictDB(t)
+
+	// Read the sequence instead of assuming id 1: the victim's own derived port
+	// has to be known before it is created.
+	placeholder := addAmneziaWGInbound(t, "awg-placeholder", 51820, true)
+	selfPort := amneziawgnet.SOCKSPortForInbound(placeholder.Id + 1)
+
+	_, _, err := (&InboundService{}).AddInbound(&model.Inbound{
+		Tag:      "awg-self",
+		Enable:   true,
+		Listen:   "0.0.0.0",
+		Port:     selfPort,
+		Protocol: model.AmneziaWG,
+		Settings: awgRelayWindowSettings(t, "awg-self"),
+	})
+	if err == nil {
+		t.Fatalf("WireGuard port %d is inbound #%d's own relay port; the create must be refused",
+			selfPort, placeholder.Id+1)
+	}
+	if !strings.Contains(err.Error(), "relay port") {
+		t.Fatalf("the refusal must say the port is an automatic relay one, got %v", err)
+	}
+}
+
+// The edit path knows the id the relay port comes from, so it has to refuse the
+// same self-collision -- the reverse check skips the row it computes for.
+func TestUpdateInbound_AmneziawgRefusesItsOwnRelayPort(t *testing.T) {
+	setupConflictDB(t)
+	created := addAmneziaWGInbound(t, "awg-self-edit", 51820, true)
+
+	edit := *created
+	edit.Port = amneziawgnet.SOCKSPortForInbound(created.Id)
+	if edit.Port == created.Port {
+		t.Fatalf("fixture: inbound #%d already listens on its derived relay port", created.Id)
+	}
+
+	_, _, err := (&InboundService{}).UpdateInbound(&edit)
+	if err == nil {
+		t.Fatalf("WireGuard port %d is inbound #%d's own relay port; the save must be refused",
+			edit.Port, created.Id)
+	}
+	if !strings.Contains(err.Error(), "relay port") {
+		t.Fatalf("the refusal must say the port is an automatic relay one, got %v", err)
+	}
+}
+
+// A row adopted from a node keeps the protocol it arrived with and its central
+// id (inbound_node.go:737), but gets no relay -- so its slot can never be taken.
+func TestCheckPortConflict_NodeAssignedAmneziawgOwnsNoRelaySlot(t *testing.T) {
+	setupConflictDB(t)
+	blocker := addAmneziaWGInbound(t, "awg-blocker", 51820, true)
+
+	nodeID := 7
+	adopted := &model.Inbound{
+		Tag:      "awg-adopted",
+		Enable:   true,
+		Listen:   "0.0.0.0",
+		Port:     51821,
+		Protocol: model.AmneziaWG,
+		Settings: awgRelayWindowSettings(t, "awg-adopted"),
+		NodeID:   &nodeID,
+	}
+	collidingID := blocker.Id + 435
+	if amneziawgnet.SOCKSPortForInbound(collidingID) != amneziawgnet.SOCKSPortForInbound(blocker.Id) {
+		t.Fatalf("fixture: id %d does not derive the blocker's relay port", collidingID)
+	}
+
+	got, err := (&InboundService{}).checkPortConflict(adopted, collidingID)
+	if err != nil {
+		t.Fatalf("checkPortConflict: %v", err)
+	}
+	if got != nil {
+		t.Fatalf("id %d is node-assigned and binds no relay, so it cannot collide; got %q",
+			collidingID, got.String())
+	}
+
+	// The same rule covers the row's own port: with no relay on this host, its
+	// WireGuard port may legitimately BE the port its id would derive.
+	adopted.Port = amneziawgnet.SOCKSPortForInbound(collidingID)
+	got, err = (&InboundService{}).checkPortConflict(adopted, collidingID)
+	if err != nil {
+		t.Fatalf("checkPortConflict: %v", err)
+	}
+	if got != nil {
+		t.Fatalf("id %d is node-assigned and binds no relay, so its own port is not a conflict; got %q",
+			collidingID, got.String())
+	}
+}

+ 76 - 28
internal/web/service/port_conflict.go

@@ -5,7 +5,6 @@ import (
 	"fmt"
 	"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"
@@ -103,11 +102,13 @@ func isAnyListen(s string) bool {
 }
 
 type portConflictDetail struct {
-	InboundID  int
-	Remark     string
-	Tag        string
-	Listen     string
-	Port       int
+	InboundID int
+	Remark    string
+	Tag       string
+	Listen    string
+	Port      int
+	// Relay marks Port as an automatic loopback relay port, not a configured one.
+	Relay      bool
 	Transports transportBits
 }
 
@@ -129,8 +130,12 @@ func (d *portConflictDetail) String() string {
 	if isAnyListen(listen) {
 		listen = "*"
 	}
-	return fmt.Sprintf("port %d (%s) already used by inbound %s on %s",
-		d.Port, transportTagSuffix(d.Transports), name, listen)
+	port := fmt.Sprintf("port %d", d.Port)
+	if d.Relay {
+		port = fmt.Sprintf("relay port %d", d.Port)
+	}
+	return fmt.Sprintf("%s (%s) already used by inbound %s on %s",
+		port, transportTagSuffix(d.Transports), name, listen)
 }
 
 // defaultXrayAPIPort is the loopback port of the internal Xray API inbound
@@ -215,10 +220,20 @@ func checkPortConflictTx(db *gorm.DB, inbound *model.Inbound, ignoreId int) (*po
 		}
 	}
 
-	// The reverse direction, only meaningful once the id is known (create's
-	// ignoreId==0 means AddInbound must run this itself after Save assigns one).
-	if inbound.Protocol == model.AmneziaWG && ignoreId > 0 {
-		conflict, err := checkAmneziawgnetSocksReverseConflict(db, ignoreId)
+	// The reverse direction, only meaningful once the id is known -- AddInbound
+	// runs it after Save. Only a local row owns a relay slot (#6537 review).
+	if inbound.NodeID == nil && inbound.Protocol == model.AmneziaWG && ignoreId > 0 {
+		if self := amneziawgnetSocksSelfConflict(inbound, ignoreId); self != "" {
+			return nil, common.NewError(self)
+		}
+		conflict, err := checkAmneziawgnetSocksRelayCollision(db, ignoreId)
+		if err != nil {
+			return nil, err
+		}
+		if conflict != nil {
+			return conflict, nil
+		}
+		conflict, err = checkAmneziawgnetSocksReverseConflict(db, ignoreId)
 		if err != nil {
 			return nil, err
 		}
@@ -260,31 +275,22 @@ func checkPortConflictTx(db *gorm.DB, inbound *model.Inbound, ignoreId int) (*po
 	return nil, nil
 }
 
-// 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. Takes db rather than fetching its own handle so it runs inside the
-// same serialized transaction as the rest of checkPortConflictTx (#6225) --
-// otherwise two concurrent AmneziaWG creates could both pass this check
-// before either row commits.
+// checkAmneziawgnetSocksConflict: inbound's port vs the relay port every matching
+// local row reserves, emitted or not; db keeps it in the caller's transaction (#6225).
 func checkAmneziawgnetSocksConflict(db *gorm.DB, inbound *model.Inbound, ignoreId int, newBits transportBits) (*portConflictDetail, error) {
+	// A disabled row still owns the slot its id derives: SetInboundEnable flips
+	// the column with no port check, so enabling it later must not collide.
 	var candidates []*model.Inbound
-	q := db.Model(model.Inbound{}).Where("protocol = ? AND enable = ? AND node_id IS NULL", model.AmneziaWG, true)
+	q := db.Model(model.Inbound{}).Where("protocol = ? AND node_id IS NULL", model.AmneziaWG)
 	if ignoreId > 0 {
 		q = q.Where("id != ?", ignoreId)
 	}
 	if err := q.Find(&candidates).Error; err != nil {
 		return nil, err
 	}
+	// Ownership does not depend on the peers: the relay appears when the first
+	// client is added, and the client paths run no port check at all.
 	for _, c := range candidates {
-		if _, ok := amneziawg.InstanceFromInbound(c); !ok {
-			continue
-		}
 		if amneziawgnet.SOCKSPortForInbound(c.Id) != inbound.Port {
 			continue
 		}
@@ -300,6 +306,47 @@ func checkAmneziawgnetSocksConflict(db *gorm.DB, inbound *model.Inbound, ignoreI
 	return nil, nil
 }
 
+// checkAmneziawgnetSocksRelayCollision reports whether id's derived relay port
+// is already claimed by another local AmneziaWG inbound, disabled rows included.
+func checkAmneziawgnetSocksRelayCollision(db *gorm.DB, id int) (*portConflictDetail, error) {
+	relayPort := amneziawgnet.SOCKSPortForInbound(id)
+	var candidates []*model.Inbound
+	if err := db.Model(model.Inbound{}).
+		Where("protocol = ? AND node_id IS NULL AND id != ?", model.AmneziaWG, id).
+		Find(&candidates).Error; err != nil {
+		return nil, err
+	}
+	for _, c := range candidates {
+		if amneziawgnet.SOCKSPortForInbound(c.Id) != relayPort {
+			continue
+		}
+		return &portConflictDetail{
+			InboundID:  c.Id,
+			Remark:     c.Remark,
+			Tag:        c.Tag,
+			Listen:     "127.0.0.1",
+			Port:       relayPort,
+			Relay:      true,
+			Transports: transportTCP,
+		}, nil
+	}
+	return nil, nil
+}
+
+// amneziawgnetSocksSelfConflict: a row's own WireGuard port vs the relay port its
+// own id derives -- all three checks below exclude that id, so nothing else does.
+func amneziawgnetSocksSelfConflict(inbound *model.Inbound, id int) string {
+	if id <= 0 || inbound.NodeID != nil || !listenOverlaps("127.0.0.1", inbound.Listen) {
+		return ""
+	}
+	relayPort := amneziawgnet.SOCKSPortForInbound(id)
+	if inbound.Port != relayPort {
+		return ""
+	}
+	return fmt.Sprintf("WireGuard port %d is inbound #%d's own SOCKS5 relay port on 127.0.0.1; choose a different WireGuard port",
+		relayPort, id)
+}
+
 // checkAmneziawgnetSocksReverseConflict mirrors checkAmneziawgnetSocksConflict:
 // does id's own derived relay port collide with some other inbound's port.
 func checkAmneziawgnetSocksReverseConflict(db *gorm.DB, id int) (*portConflictDetail, error) {
@@ -320,6 +367,7 @@ func checkAmneziawgnetSocksReverseConflict(db *gorm.DB, id int) (*portConflictDe
 			Tag:        c.Tag,
 			Listen:     c.Listen,
 			Port:       relayPort,
+			Relay:      true,
 			Transports: transportTCP,
 		}, nil
 	}

+ 15 - 30
internal/web/service/port_conflict_test.go

@@ -815,29 +815,6 @@ func TestCheckPortConflict_AmneziawgnetSocksRelayAllowedOnNode(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)
-	}
-	relayPort := amneziawgnet.SOCKSPortForInbound(awg.Id)
-
-	svc := &InboundService{}
-	candidate := &model.Inbound{
-		Tag:      "vless-bridge",
-		Listen:   "0.0.0.0",
-		Port:     relayPort,
-		Protocol: model.VLESS,
-	}
-	if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil {
-		t.Fatalf("a disabled AmneziaWG inbound's port must not be reserved; got=%v err=%v", got, err)
-	}
-}
-
 // 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,
@@ -869,12 +846,13 @@ func TestCheckPortConflict_AmneziawgnetSocksRelayReservedRegardlessOfLegacyRoute
 	}
 }
 
-// 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) {
+// A local AmneziaWG inbound owns its relay port from the row, not from its first
+// peer: the relay appears when a client is added, and that path runs no port check.
+func TestCheckPortConflict_AmneziawgnetSocksRelayReservedBeforeTheFirstPeer(t *testing.T) {
 	setupConflictDB(t)
-	seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, `{}`)
+	// The shape normalizeAmneziaWGSettings writes for a fresh AmneziaWG inbound.
+	seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``,
+		`{"server":{"privateKey":"priv","publicKey":"pub","subnetIp":"10.8.1.0","subnetCidr":24},"clients":[]}`)
 
 	var awgInbound model.Inbound
 	if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil {
@@ -889,8 +867,15 @@ func TestCheckPortConflict_AmneziawgnetSocksRelayIgnoredWhenNoQualifyingPeer(t *
 		Port:     relayPort,
 		Protocol: model.VLESS,
 	}
-	if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil {
-		t.Fatalf("an AmneziaWG inbound with no qualifying peer must not reserve its relay port; got=%v err=%v", got, err)
+	got, err := svc.checkPortConflict(candidate, 0)
+	if err != nil {
+		t.Fatalf("checkPortConflict: %v", err)
+	}
+	if got == nil {
+		t.Fatalf("an AmneziaWG inbound with no peer yet still owns relay port %d; the save must be refused", relayPort)
+	}
+	if !strings.Contains(got.String(), "awg-1") {
+		t.Fatalf("the conflict must name the inbound owning the port, got %q", got.String())
 	}
 }
 

+ 46 - 0
internal/web/service/xray_amneziawg_outbound_test.go

@@ -31,6 +31,52 @@ func makeAWGOutboundConfig(t *testing.T) *xray.Config {
 	return cfg
 }
 
+// The core folds the protocol id's case before resolving it, so a mixed-case
+// spelling must bridge here too or the raw pseudo-protocol reaches the core.
+func TestTransformAmneziaWGOutbounds_ReadsTheProtocolIDLikeTheCore(t *testing.T) {
+	for _, protocol := range []string{"amneziawg", "AmneziaWG", "AMNEZIAWG"} {
+		t.Run(protocol, func(t *testing.T) {
+			cfg := &xray.Config{}
+			raw := `{"outbounds":[
+				{"protocol":"freedom","tag":"direct"},
+				{"protocol":"` + protocol + `","tag":"awg-hop","settings":{"secretKey":"x"}}
+			]}`
+			if err := json.Unmarshal([]byte(raw), cfg); err != nil {
+				t.Fatal(err)
+			}
+			if err := transformAmneziaWGOutbounds(cfg); err != nil {
+				t.Fatal(err)
+			}
+
+			var outbounds []struct {
+				Protocol string `json:"protocol"`
+				Tag      string `json:"tag"`
+				Settings struct {
+					Address string `json:"address"`
+					Port    int    `json:"port"`
+					User    string `json:"user"`
+				} `json:"settings"`
+			}
+			if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil {
+				t.Fatal(err)
+			}
+			if len(outbounds) != 2 {
+				t.Fatalf("outbound count = %d, want 2 (no additions or drops)", len(outbounds))
+			}
+			got := outbounds[1]
+			if got.Protocol != "socks" {
+				t.Errorf("protocol = %q, want %q: the bridge never ran, so the raw pseudo-protocol reaches the core", got.Protocol, "socks")
+			}
+			if got.Tag != "awg-hop" {
+				t.Errorf("tag = %q, want %q", got.Tag, "awg-hop")
+			}
+			if got.Settings.Address != "127.0.0.1" || got.Settings.Port != amneziawgnetEgressPortForTest() || got.Settings.User != "awg-hop" {
+				t.Errorf("settings = %+v, want the socks bridge for tag %q on port %d", got.Settings, "awg-hop", amneziawgnetEgressPortForTest())
+			}
+		})
+	}
+}
+
 func TestTransformAmneziaWGOutbounds(t *testing.T) {
 	cfg := makeAWGOutboundConfig(t)
 	if err := transformAmneziaWGOutbounds(cfg); err != nil {