Browse Source

fix(sub): preserve per-inbound tunnel identity in subscriptions (#6653)

matchingClients primed the per-request link cache with the shared clients rows, whose wg_* columns hold whichever WireGuard/AmneziaWG inbound synced last. A client on several such inbounds (one per node) therefore got the same tunnel address and keys in every subscription profile.

Membership, subId, enable, quota and expiry still come from the normalized tables. For WireGuard and AmneziaWG the tunnel identity (keys, AllowedIPs, keepalive) is now overlaid from this inbound's own settings, the source clientsForLinkExport already uses for direct links. A member with no settings entry, or malformed settings, yields no link for that inbound rather than another inbound's credentials.

Fixes #6641.
Matt Van Horn 9 hours ago
parent
commit
0dec3d65ba
3 changed files with 628 additions and 9 deletions
  1. 50 9
      internal/sub/service.go
  2. 320 0
      internal/sub/service_amneziawg_test.go
  3. 258 0
      internal/sub/service_wireguard_test.go

+ 50 - 9
internal/sub/service.go

@@ -300,15 +300,8 @@ func listenIsInternalOnly(listen string) bool {
 	return isLoopbackHost(listen)
 }
 
-// matchingClients returns the inbound's clients whose SubID equals subId,
-// resolved from the normalized clients/client_inbounds tables (both filter
-// columns indexed) instead of parsing the settings JSON — at large client
-// counts that parse made every subscription fetch cost seconds. The
-// case-insensitive email dedupe stays as cheap insurance even though
-// clients.email is unique, preserving the #5134 guarantee that duplicate
-// settings entries never fan out into duplicate profiles. Resolved clients
-// are primed into the per-request cache so the link generators don't parse
-// settings either.
+// matchingClients selects normalized subId members (email-deduped, #5134).
+// WG/AWG copy this inbound's settings tunnel identity first so shared wg_* columns cannot leak (#6641).
 func (s *SubService) matchingClients(inbound *model.Inbound, subId string) []model.Client {
 	clients, err := s.inboundService.GetClientsBySubId(inbound.Id, subId)
 	if err != nil {
@@ -325,10 +318,58 @@ func (s *SubService) matchingClients(inbound *model.Inbound, subId string) []mod
 		seen[key] = struct{}{}
 		out = append(out, client)
 	}
+	if len(out) > 0 && (inbound.Protocol == model.WireGuard || inbound.Protocol == model.AmneziaWG) {
+		overlaid, settingsErr := s.overlayInboundTunnelIdentity(inbound, out)
+		if settingsErr != nil {
+			logger.Errorf("SubService - matchingClients: inbound %d tunnel settings: %v", inbound.Id, settingsErr)
+			return nil
+		}
+		out = overlaid
+	}
 	s.primeLinkClients(inbound.Id, out, false)
 	return out
 }
 
+// overlayInboundTunnelIdentity copies per-inbound tunnel fields from settings.
+// An unmatched peer is dropped, malformed settings yield nothing, and empty optional secrets replace shared values (#6641).
+func (s *SubService) overlayInboundTunnelIdentity(inbound *model.Inbound, clients []model.Client) ([]model.Client, error) {
+	embedded, err := s.inboundService.GetClients(inbound)
+	if err != nil {
+		return nil, err
+	}
+	byEmail := make(map[string]model.Client, len(embedded))
+	for i := range embedded {
+		key := strings.ToLower(embedded[i].Email)
+		if key == "" {
+			continue
+		}
+		if _, exists := byEmail[key]; exists {
+			continue
+		}
+		byEmail[key] = embedded[i]
+	}
+	out := make([]model.Client, 0, len(clients))
+	for _, client := range clients {
+		peer, ok := byEmail[strings.ToLower(client.Email)]
+		if !ok {
+			continue
+		}
+		client.PrivateKey = peer.PrivateKey
+		client.PublicKey = peer.PublicKey
+		client.PreSharedKey = peer.PreSharedKey
+		// append onto nil copies a non-empty list and clears a shared address when settings omit one.
+		client.AllowedIPs = append([]string(nil), peer.AllowedIPs...)
+		if peer.KeepAlive == nil {
+			client.KeepAlive = nil
+		} else {
+			keepalive := *peer.KeepAlive
+			client.KeepAlive = &keepalive
+		}
+		out = append(out, client)
+	}
+	return out, nil
+}
+
 // RecordSubscriptionFetch records a successful subscription response for all clients sharing subId.
 func (s *SubService) RecordSubscriptionFetch(subId string) error {
 	if strings.TrimSpace(subId) == "" {

+ 320 - 0
internal/sub/service_amneziawg_test.go

@@ -2,6 +2,7 @@ package sub
 
 import (
 	"encoding/base64"
+	"fmt"
 	"slices"
 	"strconv"
 	"strings"
@@ -315,3 +316,322 @@ func TestAmneziaWGConfigTextAlwaysCarriesTheServerMTU(t *testing.T) {
 		})
 	}
 }
+
+func decodeAmneziaWGSubLink(t *testing.T, link string) string {
+	t.Helper()
+	if !strings.HasPrefix(link, "vpn://") {
+		t.Fatalf("link = %q, want vpn:// prefix", link)
+	}
+	raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(link, "vpn://"))
+	if err != nil {
+		t.Fatalf("decode vpn link: %v\n got: %s", err, link)
+	}
+	return string(raw)
+}
+
+// The shared clients row holds the last sync's tunnel identity. Each vpn://
+// entry must keep its own inbound address and private key, in either sort order (#6641).
+func TestGetSubs_PreservesPerInboundAmneziaWGIdentity(t *testing.T) {
+	serverAPriv, serverAPub := mustWireguardKeypair(t)
+	serverBPriv, serverBPub := mustWireguardKeypair(t)
+	privA, _ := mustWireguardKeypair(t)
+	privB, _ := mustWireguardKeypair(t)
+	mergedPriv, _ := mustWireguardKeypair(t)
+
+	const (
+		email      = "dual@awg"
+		subID      = "sub-awg-identity"
+		mergedAddr = "10.9.9.9/32"
+	)
+	nodes := []struct {
+		tag, listen, addr, priv, serverPriv, serverPub string
+		port                                           int
+	}{
+		{"awg-a", "203.0.113.10", "10.8.1.2/32", privA, serverAPriv, serverAPub, 51820},
+		{"awg-b", "203.0.113.11", "10.8.2.2/32", privB, serverBPriv, serverBPub, 51821},
+	}
+
+	for _, tc := range []struct {
+		name  string
+		sort  [2]int
+		order [2]int
+	}{
+		{name: "creation order", sort: [2]int{1, 2}, order: [2]int{0, 1}},
+		{name: "reversed subscription sort", sort: [2]int{2, 1}, order: [2]int{1, 0}},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			initSubDB(t)
+			db := database.GetDB()
+			inbounds := make([]*model.Inbound, len(nodes))
+			for i, n := range nodes {
+				settings := fmt.Sprintf(
+					`{"server":{"privateKey":%q,"publicKey":%q,"mtu":1420},"clients":[{"email":%q,"privateKey":%q,"allowedIPs":[%q],"enable":true}]}`,
+					n.serverPriv, n.serverPub, email, n.priv, n.addr,
+				)
+				ib := &model.Inbound{
+					UserId: 1, Tag: n.tag, Enable: true, Listen: n.listen, Port: n.port,
+					Protocol: model.AmneziaWG, Remark: n.tag, Settings: settings, SubSortIndex: tc.sort[i],
+				}
+				if err := db.Create(ib).Error; err != nil {
+					t.Fatalf("create %s: %v", n.tag, err)
+				}
+				inbounds[i] = ib
+			}
+			rec := &model.ClientRecord{
+				Email: email, SubID: subID, Enable: true,
+				PrivateKey: mergedPriv, AllowedIPs: mergedAddr,
+				PreSharedKey: "sharedpsk", KeepAlive: 25,
+			}
+			if err := db.Create(rec).Error; err != nil {
+				t.Fatalf("create client: %v", err)
+			}
+			for _, ib := range inbounds {
+				if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ib.Id}).Error; err != nil {
+					t.Fatalf("link %s: %v", ib.Tag, err)
+				}
+			}
+
+			links, _, _, _, err := NewSubService("").GetSubs(subID, "sub.example.com")
+			if err != nil {
+				t.Fatalf("GetSubs: %v", err)
+			}
+			if len(links) != len(nodes) {
+				t.Fatalf("links = %d, want %d: %v", len(links), len(nodes), links)
+			}
+			for outIdx, nodeIdx := range tc.order {
+				n := nodes[nodeIdx]
+				other := nodes[1-nodeIdx]
+				conf := decodeAmneziaWGSubLink(t, links[outIdx])
+				for _, want := range []string{
+					"PrivateKey = " + n.priv,
+					"Address = " + n.addr,
+					"PublicKey = " + n.serverPub,
+					fmt.Sprintf("Endpoint = %s:%d", n.listen, n.port),
+				} {
+					if !strings.Contains(conf, want) {
+						t.Fatalf("config missing %q\n%s", want, conf)
+					}
+				}
+				for _, leaked := range []string{mergedPriv, mergedAddr, "sharedpsk", "PresharedKey", "PersistentKeepalive", other.priv, other.addr, other.serverPub} {
+					if strings.Contains(conf, leaked) {
+						t.Fatalf("config leaked %q\n%s", leaked, conf)
+					}
+				}
+			}
+		})
+	}
+}
+
+// A peer missing from settings, or settings that do not parse, must not emit the
+// shared clients.wg_* identity. A sibling inbound with its own peer still does (#6641).
+func TestGetSubs_AmneziaWGUnavailableSettingsEmitNoSharedConfig(t *testing.T) {
+	initSubDB(t)
+	db := database.GetDB()
+	serverPriv, serverPub := mustWireguardKeypair(t)
+	validPriv, _ := mustWireguardKeypair(t)
+	otherPriv, _ := mustWireguardKeypair(t)
+	mergedPriv, _ := mustWireguardKeypair(t)
+
+	const (
+		email      = "dual@awg"
+		subID      = "sub-awg-missing"
+		validAddr  = "10.8.1.4/32"
+		mergedAddr = "10.9.9.9/32"
+	)
+	validSettings := fmt.Sprintf(
+		`{"server":{"privateKey":%q,"publicKey":%q,"mtu":1420},"clients":[{"email":%q,"privateKey":%q,"allowedIPs":[%q],"enable":true}]}`,
+		serverPriv, serverPub, email, validPriv, validAddr,
+	)
+	absentSettings := fmt.Sprintf(
+		`{"server":{"privateKey":%q,"publicKey":%q,"mtu":1420},"clients":[{"email":"someone-else@awg","privateKey":%q,"allowedIPs":["10.8.9.9/32"],"enable":true}]}`,
+		serverPriv, serverPub, otherPriv,
+	)
+	specs := []struct {
+		tag, listen, settings string
+		port                  int
+	}{
+		{"awg-bad-json", "203.0.113.31", `{not-json`, 51831},
+		{"awg-absent-peer", "203.0.113.32", absentSettings, 51832},
+		{"awg-valid", "203.0.113.33", validSettings, 51833},
+	}
+	inbounds := make([]*model.Inbound, len(specs))
+	for i, sp := range specs {
+		ib := &model.Inbound{
+			UserId: 1, Tag: sp.tag, Enable: true, Listen: sp.listen, Port: sp.port,
+			Protocol: model.AmneziaWG, Remark: sp.tag, Settings: sp.settings, SubSortIndex: i + 1,
+		}
+		if err := db.Create(ib).Error; err != nil {
+			t.Fatalf("create %s: %v", sp.tag, err)
+		}
+		inbounds[i] = ib
+	}
+	rec := &model.ClientRecord{
+		Email: email, SubID: subID, Enable: true,
+		PrivateKey: mergedPriv, AllowedIPs: mergedAddr,
+		PreSharedKey: "sharedpsk", KeepAlive: 25,
+	}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatalf("create client: %v", err)
+	}
+	for _, ib := range inbounds {
+		if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ib.Id}).Error; err != nil {
+			t.Fatalf("link %s: %v", ib.Tag, err)
+		}
+	}
+
+	links, _, _, _, err := NewSubService("").GetSubs(subID, "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs: %v", err)
+	}
+	if len(links) != 1 {
+		t.Fatalf("links = %d, want 1 (absent and malformed inbounds must not emit the shared row): %q", len(links), links)
+	}
+	conf := decodeAmneziaWGSubLink(t, links[0])
+	for _, want := range []string{
+		"PrivateKey = " + validPriv,
+		"Address = " + validAddr,
+		"Endpoint = 203.0.113.33:51833",
+	} {
+		if !strings.Contains(conf, want) {
+			t.Fatalf("config missing %q\n%s", want, conf)
+		}
+	}
+	for _, leaked := range []string{mergedPriv, mergedAddr, "sharedpsk", otherPriv, "10.8.9.9/32", "203.0.113.31", "203.0.113.32", "PresharedKey", "PersistentKeepalive"} {
+		if strings.Contains(conf, leaked) {
+			t.Fatalf("config leaked %q\n%s", leaked, conf)
+		}
+	}
+}
+
+// Explicit empty preshared key and keepalive must not inherit the shared row (#6641).
+func TestGetSubs_AmneziaWGEmptyOptionalTunnelFieldsDoNotInheritShared(t *testing.T) {
+	initSubDB(t)
+	db := database.GetDB()
+	serverPriv, serverPub := mustWireguardKeypair(t)
+	clientPriv, _ := mustWireguardKeypair(t)
+	mergedPriv, _ := mustWireguardKeypair(t)
+
+	const (
+		email      = "optional@awg"
+		subID      = "sub-awg-optional"
+		addr       = "10.8.1.8/32"
+		mergedAddr = "10.9.9.9/32"
+	)
+	settings := fmt.Sprintf(
+		`{"server":{"privateKey":%q,"publicKey":%q,"mtu":1420},"clients":[{"email":%q,"privateKey":%q,"allowedIPs":[%q],"preSharedKey":"","keepAlive":0,"enable":true}]}`,
+		serverPriv, serverPub, email, clientPriv, addr,
+	)
+	ib := &model.Inbound{
+		UserId: 1, Tag: "awg-optional", Enable: true, Listen: "203.0.113.40", Port: 51840,
+		Protocol: model.AmneziaWG, Remark: "awg-optional", Settings: settings,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+	rec := &model.ClientRecord{
+		Email: email, SubID: subID, Enable: true,
+		PrivateKey: mergedPriv, AllowedIPs: mergedAddr,
+		PreSharedKey: "sharedpsk", KeepAlive: 25,
+	}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatalf("create client: %v", err)
+	}
+	if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ib.Id}).Error; err != nil {
+		t.Fatalf("link client: %v", err)
+	}
+
+	links, _, _, _, err := NewSubService("").GetSubs(subID, "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs: %v", err)
+	}
+	if len(links) != 1 {
+		t.Fatalf("links = %d, want 1: %q", len(links), links)
+	}
+	conf := decodeAmneziaWGSubLink(t, links[0])
+	for _, want := range []string{"PrivateKey = " + clientPriv, "Address = " + addr} {
+		if !strings.Contains(conf, want) {
+			t.Fatalf("config missing %q\n%s", want, conf)
+		}
+	}
+	for _, leaked := range []string{"PresharedKey", "PersistentKeepalive", "sharedpsk", mergedPriv, mergedAddr} {
+		if strings.Contains(conf, leaked) {
+			t.Fatalf("config leaked %q\n%s", leaked, conf)
+		}
+	}
+}
+
+// Membership and account metadata stay on the normalized row. Settings may carry a
+// stale subId/enable and an extra email; tunnel fields still come from this inbound (#6641).
+func TestMatchingClients_TunnelMetadataStaysNormalized(t *testing.T) {
+	const (
+		subID       = "sub-meta"
+		email       = "user@awg"
+		freshID     = "11111111-2222-4333-8444-555555555555"
+		staleID     = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
+		settingsKey = "settings-private-key"
+		sharedKey   = "shared-private-key"
+		expiry      = int64(1700000000000)
+	)
+	clientsJSON := fmt.Sprintf(`[
+		{"id":%q,"email":"User@AWG","subId":"stale-sub","enable":false,"totalGB":1,"expiryTime":1,"comment":"stale","limitIp":9,"privateKey":%q,"publicKey":"settings-pub","allowedIPs":["10.8.1.2/32","fd00::2/128"],"preSharedKey":"settings-psk","keepAlive":15},
+		{"email":"settings-only@awg","subId":%q,"enable":true,"privateKey":"only-priv","allowedIPs":["10.8.1.9/32"]}
+	]`, staleID, settingsKey, subID)
+
+	for _, protocol := range []model.Protocol{model.AmneziaWG, model.WireGuard} {
+		t.Run(string(protocol), func(t *testing.T) {
+			initSubDB(t)
+			db := database.GetDB()
+			settings := `{"secretKey":"c2VydmVy","clients":` + clientsJSON + `}`
+			if protocol == model.AmneziaWG {
+				settings = `{"server":{"privateKey":"c2VydmVy","publicKey":"cHVi"},"clients":` + clientsJSON + `}`
+			}
+			ib := &model.Inbound{
+				UserId: 1, Tag: "meta-" + string(protocol), Enable: true, Listen: "203.0.113.50", Port: 51850,
+				Protocol: protocol, Remark: "meta", Settings: settings,
+			}
+			if err := db.Create(ib).Error; err != nil {
+				t.Fatalf("create inbound: %v", err)
+			}
+			rec := &model.ClientRecord{
+				Email: email, SubID: subID, UUID: freshID, Enable: true,
+				TotalGB: 5, ExpiryTime: expiry, Comment: "vip", LimitIP: 3,
+				PrivateKey: sharedKey, PublicKey: "shared-pub", AllowedIPs: "10.9.9.9/32",
+				PreSharedKey: "shared-psk", KeepAlive: 99,
+			}
+			other := &model.ClientRecord{
+				Email: "other-sub@awg", SubID: "other-sub", UUID: "22222222-2222-4333-8444-555555555555", Enable: true,
+			}
+			for _, row := range []*model.ClientRecord{rec, other} {
+				if err := db.Create(row).Error; err != nil {
+					t.Fatalf("create client %s: %v", row.Email, err)
+				}
+				if err := db.Create(&model.ClientInbound{ClientId: row.Id, InboundId: ib.Id}).Error; err != nil {
+					t.Fatalf("link %s: %v", row.Email, err)
+				}
+			}
+
+			s := &SubService{}
+			got := s.matchingClients(ib, subID)
+			if len(got) != 1 {
+				t.Fatalf("clients = %d, want the one normalized member: %+v", len(got), got)
+			}
+			c := got[0]
+			if c.Email != email || c.ID != freshID || c.SubID != subID || !c.Enable || c.TotalGB != 5 || c.ExpiryTime != expiry || c.Comment != "vip" || c.LimitIP != 3 {
+				t.Fatalf("normalized metadata = %+v", c)
+			}
+			if c.PrivateKey != settingsKey || c.PublicKey != "settings-pub" || c.PreSharedKey != "settings-psk" || c.KeepAliveSeconds() != 15 {
+				t.Fatalf("tunnel identity = key %q pub %q psk %q ka %d", c.PrivateKey, c.PublicKey, c.PreSharedKey, c.KeepAliveSeconds())
+			}
+			if !slices.Equal(c.AllowedIPs, []string{"10.8.1.2/32", "fd00::2/128"}) {
+				t.Fatalf("allowedIPs = %v, want this inbound's v4 and v6", c.AllowedIPs)
+			}
+			cached, ok := s.clientForLink(ib, email)
+			if !ok || cached.PrivateKey != settingsKey || cached.PreSharedKey != "settings-psk" || cached.KeepAliveSeconds() != 15 || !slices.Equal(cached.AllowedIPs, c.AllowedIPs) {
+				t.Fatalf("primed cache = %+v, ok %v", cached, ok)
+			}
+			if extra := s.matchingClients(ib, "nope"); len(extra) != 0 {
+				t.Fatalf("non-matching subId must yield 0 clients, got %d", len(extra))
+			}
+		})
+	}
+}

+ 258 - 0
internal/sub/service_wireguard_test.go

@@ -1,7 +1,9 @@
 package sub
 
 import (
+	"fmt"
 	"net/url"
+	"strings"
 	"testing"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
@@ -130,3 +132,259 @@ func TestGetInboundsBySubIdIncludesWireguard(t *testing.T) {
 		t.Fatalf("wireguard inbound not returned for subId: %+v", inbounds)
 	}
 }
+
+func mustWireguardKeypair(t *testing.T) (string, string) {
+	t.Helper()
+	priv, pub, err := wgutil.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("keypair: %v", err)
+	}
+	return priv, pub
+}
+
+func parseWireguardSubLink(t *testing.T, link string) *url.URL {
+	t.Helper()
+	u, err := url.Parse(link)
+	if err != nil {
+		t.Fatalf("parse wireguard link: %v\n got: %s", err, link)
+	}
+	if u.Scheme != "wireguard" {
+		t.Fatalf("scheme = %q, want wireguard (%s)", u.Scheme, link)
+	}
+	return u
+}
+
+// The shared clients row holds the last sync's tunnel identity. Each wireguard://
+// entry must keep its own key and both IPv4 and IPv6 addresses, in either sort order (#6641).
+func TestGetSubs_PreservesPerInboundWireGuardIdentity(t *testing.T) {
+	serverAPriv, serverAPub := mustWireguardKeypair(t)
+	serverBPriv, serverBPub := mustWireguardKeypair(t)
+	privA, _ := mustWireguardKeypair(t)
+	privB, _ := mustWireguardKeypair(t)
+	mergedPriv, _ := mustWireguardKeypair(t)
+
+	const (
+		email      = "dual@wg"
+		subID      = "sub-wg-identity"
+		mergedAddr = "10.9.9.9/32,fd00:9::9/128"
+	)
+	nodes := []struct {
+		tag, listen, priv, serverPriv, serverPub string
+		port                                     int
+		allowed                                  []string
+	}{
+		{"wg-a", "203.0.113.10", privA, serverAPriv, serverAPub, 51820, []string{"10.1.0.2/32", "fd00:1::2/128"}},
+		{"wg-b", "203.0.113.11", privB, serverBPriv, serverBPub, 51821, []string{"10.2.0.2/32", "fd00:2::2/128"}},
+	}
+
+	for _, tc := range []struct {
+		name  string
+		sort  [2]int
+		order [2]int
+	}{
+		{name: "creation order", sort: [2]int{1, 2}, order: [2]int{0, 1}},
+		{name: "reversed subscription sort", sort: [2]int{2, 1}, order: [2]int{1, 0}},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			initSubDB(t)
+			db := database.GetDB()
+			inbounds := make([]*model.Inbound, len(nodes))
+			for i, n := range nodes {
+				settings := fmt.Sprintf(
+					`{"secretKey":%q,"mtu":1420,"clients":[{"email":%q,"privateKey":%q,"allowedIPs":[%q,%q],"enable":true}]}`,
+					n.serverPriv, email, n.priv, n.allowed[0], n.allowed[1],
+				)
+				ib := &model.Inbound{
+					UserId: 1, Tag: n.tag, Enable: true, Listen: n.listen, Port: n.port,
+					Protocol: model.WireGuard, Remark: n.tag, Settings: settings, SubSortIndex: tc.sort[i],
+				}
+				if err := db.Create(ib).Error; err != nil {
+					t.Fatalf("create %s: %v", n.tag, err)
+				}
+				inbounds[i] = ib
+			}
+			rec := &model.ClientRecord{
+				Email: email, SubID: subID, Enable: true,
+				PrivateKey: mergedPriv, AllowedIPs: mergedAddr,
+				PreSharedKey: "sharedpsk", KeepAlive: 25,
+			}
+			if err := db.Create(rec).Error; err != nil {
+				t.Fatalf("create client: %v", err)
+			}
+			for _, ib := range inbounds {
+				if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ib.Id}).Error; err != nil {
+					t.Fatalf("link %s: %v", ib.Tag, err)
+				}
+			}
+
+			links, _, _, _, err := NewSubService("").GetSubs(subID, "sub.example.com")
+			if err != nil {
+				t.Fatalf("GetSubs: %v", err)
+			}
+			if len(links) != len(nodes) {
+				t.Fatalf("links = %d, want %d: %v", len(links), len(nodes), links)
+			}
+			for outIdx, nodeIdx := range tc.order {
+				n := nodes[nodeIdx]
+				other := nodes[1-nodeIdx]
+				u := parseWireguardSubLink(t, links[outIdx])
+				if u.Host != fmt.Sprintf("%s:%d", n.listen, n.port) {
+					t.Fatalf("host = %q, want %s:%d", u.Host, n.listen, n.port)
+				}
+				if u.User.Username() != n.priv {
+					t.Fatalf("private key = %q, want inbound key %q", u.User.Username(), n.priv)
+				}
+				q := u.Query()
+				if got, want := q.Get("address"), strings.Join(n.allowed, ","); got != want {
+					t.Fatalf("address = %q, want %q", got, want)
+				}
+				if q.Get("publickey") != n.serverPub {
+					t.Fatalf("publickey = %q, want %q", q.Get("publickey"), n.serverPub)
+				}
+				if q.Get("presharedkey") != "" || q.Get("keepalive") != "" {
+					t.Fatalf("optional fields inherited shared values: %s", u.RawQuery)
+				}
+				if u.User.Username() == mergedPriv || strings.Contains(q.Get("address"), "10.9.9.9") || strings.Contains(q.Get("address"), other.allowed[0]) || strings.Contains(q.Get("address"), other.allowed[1]) {
+					t.Fatalf("link borrowed another tunnel identity: %s", links[outIdx])
+				}
+			}
+		})
+	}
+}
+
+// A peer missing from settings, or settings that do not parse, must not emit the
+// shared clients.wg_* identity. A sibling inbound with its own peer still does (#6641).
+func TestGetSubs_WireGuardUnavailableSettingsEmitNoSharedConfig(t *testing.T) {
+	initSubDB(t)
+	db := database.GetDB()
+	serverPriv, serverPub := mustWireguardKeypair(t)
+	validPriv, _ := mustWireguardKeypair(t)
+	otherPriv, _ := mustWireguardKeypair(t)
+	mergedPriv, _ := mustWireguardKeypair(t)
+
+	const (
+		email      = "dual@wg"
+		subID      = "sub-wg-missing"
+		mergedAddr = "10.9.9.9/32,fd00:9::9/128"
+	)
+	validAllowed := []string{"10.4.0.2/32", "fd00:4::2/128"}
+	validSettings := fmt.Sprintf(
+		`{"secretKey":%q,"clients":[{"email":%q,"privateKey":%q,"allowedIPs":[%q,%q],"enable":true}]}`,
+		serverPriv, email, validPriv, validAllowed[0], validAllowed[1],
+	)
+	absentSettings := fmt.Sprintf(
+		`{"secretKey":%q,"clients":[{"email":"someone-else@wg","privateKey":%q,"allowedIPs":["10.8.9.9/32"],"enable":true}]}`,
+		serverPriv, otherPriv,
+	)
+	specs := []struct {
+		tag, listen, settings string
+		port                  int
+	}{
+		{"wg-bad-json", "203.0.113.31", `{not-json`, 51831},
+		{"wg-absent-peer", "203.0.113.32", absentSettings, 51832},
+		{"wg-valid", "203.0.113.33", validSettings, 51833},
+	}
+	inbounds := make([]*model.Inbound, len(specs))
+	for i, sp := range specs {
+		ib := &model.Inbound{
+			UserId: 1, Tag: sp.tag, Enable: true, Listen: sp.listen, Port: sp.port,
+			Protocol: model.WireGuard, Remark: sp.tag, Settings: sp.settings, SubSortIndex: i + 1,
+		}
+		if err := db.Create(ib).Error; err != nil {
+			t.Fatalf("create %s: %v", sp.tag, err)
+		}
+		inbounds[i] = ib
+	}
+	rec := &model.ClientRecord{
+		Email: email, SubID: subID, Enable: true,
+		PrivateKey: mergedPriv, AllowedIPs: mergedAddr,
+		PreSharedKey: "sharedpsk", KeepAlive: 25,
+	}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatalf("create client: %v", err)
+	}
+	for _, ib := range inbounds {
+		if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ib.Id}).Error; err != nil {
+			t.Fatalf("link %s: %v", ib.Tag, err)
+		}
+	}
+
+	links, _, _, _, err := NewSubService("").GetSubs(subID, "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs: %v", err)
+	}
+	if len(links) != 1 {
+		t.Fatalf("links = %d, want 1 (absent and malformed inbounds must not emit the shared row): %q", len(links), links)
+	}
+	u := parseWireguardSubLink(t, links[0])
+	if u.Host != "203.0.113.33:51833" {
+		t.Fatalf("host = %q, want the valid inbound", u.Host)
+	}
+	if u.User.Username() != validPriv {
+		t.Fatalf("private key = %q, want inbound key", u.User.Username())
+	}
+	if got, want := u.Query().Get("address"), strings.Join(validAllowed, ","); got != want {
+		t.Fatalf("address = %q, want %q", got, want)
+	}
+	if u.Query().Get("publickey") != serverPub || u.Query().Get("presharedkey") != "" || u.Query().Get("keepalive") != "" {
+		t.Fatalf("query borrowed shared or foreign tunnel fields: %s", u.RawQuery)
+	}
+}
+
+// Explicit empty preshared key and keepalive must not inherit the shared row (#6641).
+func TestGetSubs_WireGuardEmptyOptionalTunnelFieldsDoNotInheritShared(t *testing.T) {
+	initSubDB(t)
+	db := database.GetDB()
+	serverPriv, serverPub := mustWireguardKeypair(t)
+	clientPriv, _ := mustWireguardKeypair(t)
+	mergedPriv, _ := mustWireguardKeypair(t)
+
+	const (
+		email = "optional@wg"
+		subID = "sub-wg-optional"
+	)
+	allowed := []string{"10.5.0.2/32", "fd00:5::2/128"}
+	settings := fmt.Sprintf(
+		`{"secretKey":%q,"clients":[{"email":%q,"privateKey":%q,"allowedIPs":[%q,%q],"preSharedKey":"","keepAlive":0,"enable":true}]}`,
+		serverPriv, email, clientPriv, allowed[0], allowed[1],
+	)
+	ib := &model.Inbound{
+		UserId: 1, Tag: "wg-optional", Enable: true, Listen: "203.0.113.40", Port: 51840,
+		Protocol: model.WireGuard, Remark: "wg-optional", Settings: settings,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+	rec := &model.ClientRecord{
+		Email: email, SubID: subID, Enable: true,
+		PrivateKey: mergedPriv, AllowedIPs: "10.9.9.9/32,fd00:9::9/128",
+		PreSharedKey: "sharedpsk", KeepAlive: 25,
+	}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatalf("create client: %v", err)
+	}
+	if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ib.Id}).Error; err != nil {
+		t.Fatalf("link client: %v", err)
+	}
+
+	links, _, _, _, err := NewSubService("").GetSubs(subID, "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs: %v", err)
+	}
+	if len(links) != 1 {
+		t.Fatalf("links = %d, want 1: %q", len(links), links)
+	}
+	u := parseWireguardSubLink(t, links[0])
+	if u.User.Username() != clientPriv {
+		t.Fatalf("private key = %q, want inbound key", u.User.Username())
+	}
+	if got, want := u.Query().Get("address"), strings.Join(allowed, ","); got != want {
+		t.Fatalf("address = %q, want %q", got, want)
+	}
+	if u.Query().Get("publickey") != serverPub {
+		t.Fatalf("publickey = %q, want %q", u.Query().Get("publickey"), serverPub)
+	}
+	if u.Query().Get("presharedkey") != "" || u.Query().Get("keepalive") != "" || strings.Contains(u.RawQuery, "sharedpsk") || strings.Contains(u.Query().Get("address"), "10.9.9.9") || strings.Contains(u.Query().Get("address"), "fd00:9::9") {
+		t.Fatalf("link inherited shared tunnel fields: %s", links[0])
+	}
+}