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.
This commit is contained in:
Matt Van Horn
2026-09-26 11:31:47 -07:00
committed by GitHub
parent 07ee638a50
commit 0dec3d65ba
3 changed files with 628 additions and 9 deletions
+258
View File
@@ -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])
}
}