feat(wireguard): multi-client support

WireGuard inbounds now manage per-client peers using xray-core's native WireGuard users (AddUser/RemoveUser). Each client lives in settings.clients (canonical, like every other protocol) and is projected to peers[] only when emitting the xray config, at level 0 so the dispatcher's per-user traffic/online counters work with no extra plumbing.

Backend: internal/util/wireguard gains KeyToHex (base64 to hex for the gRPC path), PublicKeyFromPrivate and GenerateWireguardPSK; xray/api.go builds a wireguard account in AddUser with hex keys (RemoveUser already worked); client CRUD generates a keypair and allocates a unique tunnel address per client and never rotates keys on edit; an idempotent migration converts legacy settings.peers into managed clients; WireGuard is included in the raw subscription.

Frontend: WireGuard in the add-client modal with keys on the credential tab, client schema, per-client QR/link/.conf, inbound form reduced to server settings; i18n added across 13 locales.

Fix: guard the settings[clients] assertion in add/update so a legacy WireGuard inbound stored without a clients key no longer panics.
This commit is contained in:
MHSanaei
2026-06-28 00:44:38 +02:00
parent 33aada0c7c
commit 9c8cd08f90
50 changed files with 2160 additions and 258 deletions
+148 -1
View File
@@ -192,6 +192,148 @@ func seedHostsFromExternalProxy() error {
})
}
// seedWireguardPeersToClients is a one-time, self-gated migration that converts
// legacy single-config WireGuard inbounds into the multi-client model: each
// settings.peers[] entry becomes a managed client in the clients table attached
// to the inbound, and the inbound settings are rewritten so peers becomes a
// clients[] array (GetXrayConfig re-projects clients back to peers for xray).
// Idempotent: gated on the history row and skipped per-inbound once it already
// has client links.
func seedWireguardPeersToClients() error {
var history []string
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &history).Error; err != nil {
return err
}
if slices.Contains(history, "WireguardPeersToClients") {
return nil
}
var inbounds []model.Inbound
if err := db.Where("protocol = ?", string(model.WireGuard)).Find(&inbounds).Error; err != nil {
return err
}
return db.Transaction(func(tx *gorm.DB) error {
usedEmails := map[string]struct{}{}
var existingEmails []string
if err := tx.Model(&model.ClientRecord{}).Pluck("email", &existingEmails).Error; err != nil {
return err
}
for _, e := range existingEmails {
usedEmails[e] = struct{}{}
}
for _, inbound := range inbounds {
if strings.TrimSpace(inbound.Settings) == "" {
continue
}
var settings map[string]any
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
log.Printf("WireguardPeersToClients: skip inbound %d (invalid settings json): %v", inbound.Id, err)
continue
}
peers, ok := settings["peers"].([]any)
if !ok || len(peers) == 0 {
continue
}
var linkCount int64
if err := tx.Model(&model.ClientInbound{}).Where("inbound_id = ?", inbound.Id).Count(&linkCount).Error; err != nil {
return err
}
if linkCount > 0 {
continue
}
clientObjs := make([]any, 0, len(peers))
for i, raw := range peers {
obj, ok := raw.(map[string]any)
if !ok {
continue
}
email := wireguardPeerEmail(inbound.Remark, obj, i, usedEmails)
usedEmails[email] = struct{}{}
obj["email"] = email
if sub, _ := obj["subId"].(string); strings.TrimSpace(sub) == "" {
obj["subId"] = random.NumLower(16)
}
if _, ok := obj["enable"]; !ok {
obj["enable"] = true
}
blob, err := json.Marshal(obj)
if err != nil {
continue
}
var c model.Client
if err := json.Unmarshal(blob, &c); err != nil {
log.Printf("WireguardPeersToClients: skip peer in inbound %d: %v", inbound.Id, err)
continue
}
c.Email = email
incoming := c.ToRecord()
var row model.ClientRecord
err = tx.Where("email = ?", email).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
if err := tx.Create(incoming).Error; err != nil {
return err
}
row = *incoming
} else if err != nil {
return err
} else {
model.MergeClientRecord(&row, incoming)
if err := tx.Save(&row).Error; err != nil {
return err
}
}
link := model.ClientInbound{ClientId: row.Id, InboundId: inbound.Id}
if err := tx.Where("client_id = ? AND inbound_id = ?", row.Id, inbound.Id).
FirstOrCreate(&link).Error; err != nil {
return err
}
clientObjs = append(clientObjs, obj)
}
delete(settings, "peers")
settings["clients"] = clientObjs
newSettings, err := json.Marshal(settings)
if err != nil {
return err
}
if err := tx.Model(&model.Inbound{}).Where("id = ?", inbound.Id).
Update("settings", string(newSettings)).Error; err != nil {
return err
}
}
return tx.Create(&model.HistoryOfSeeders{SeederName: "WireguardPeersToClients"}).Error
})
}
// wireguardPeerEmail derives a stable, unique client email for a migrated peer
// from the inbound remark plus the peer's comment (or its 1-based index).
func wireguardPeerEmail(remark string, peer map[string]any, index int, used map[string]struct{}) string {
base := strings.TrimSpace(remark)
if base == "" {
base = "wg"
}
suffix := strconv.Itoa(index + 1)
if c, ok := peer["comment"].(string); ok && strings.TrimSpace(c) != "" {
suffix = strings.TrimSpace(c)
}
email := strings.ReplaceAll(base+"-"+suffix, " ", "-")
candidate := email
for n := 2; ; n++ {
if _, taken := used[candidate]; !taken {
return candidate
}
candidate = email + "-" + strconv.Itoa(n)
}
}
// CreateHostsFromExternalProxy parses a legacy streamSettings.externalProxy array
// and inserts one Host row per entry on tx, returning the number of rows created.
// It is the shared core of both the one-time seedHostsFromExternalProxy startup
@@ -387,7 +529,7 @@ func runSeeders(isUsersEmpty bool) error {
}
if empty && isUsersEmpty {
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "ApiTokensHash", "LegacyProxySettingsCleanup"}
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "ApiTokensHash", "LegacyProxySettingsCleanup", "WireguardPeersToClients"}
for _, name := range seeders {
if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
return err
@@ -490,6 +632,11 @@ func runSeeders(isUsersEmpty bool) error {
if err := resetIpLimitsWithoutFail2ban(); err != nil {
return err
}
// Self-gated on the "WireguardPeersToClients" row.
if err := seedWireguardPeersToClients(); err != nil {
return err
}
return nil
}
+106 -37
View File
@@ -592,46 +592,56 @@ type ClientReverse struct {
// Client represents a client configuration for Xray inbounds with traffic limits and settings.
type Client struct {
ID string `json:"id,omitempty"` // Unique client identifier
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
Password string `json:"password,omitempty"` // Client password
Flow string `json:"flow,omitempty"` // Flow control (XTLS)
Reverse *ClientReverse `json:"reverse,omitempty"` // VLESS simple reverse proxy settings
Auth string `json:"auth,omitempty"` // Auth password (Hysteria)
Email string `json:"email"` // Client email identifier
LimitIP int `json:"limitIp"` // IP limit for this client
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
SubID string `json:"subId" form:"subId"` // Subscription identifier
Group string `json:"group,omitempty" form:"group"` // Logical grouping label
Comment string `json:"comment" form:"comment"` // Client comment
Reset int `json:"reset" form:"reset"` // Reset period in days
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
ID string `json:"id,omitempty"` // Unique client identifier
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
Password string `json:"password,omitempty"` // Client password
Flow string `json:"flow,omitempty"` // Flow control (XTLS)
Reverse *ClientReverse `json:"reverse,omitempty"` // VLESS simple reverse proxy settings
Auth string `json:"auth,omitempty"` // Auth password (Hysteria)
PrivateKey string `json:"privateKey,omitempty"`
PublicKey string `json:"publicKey,omitempty"`
AllowedIPs []string `json:"allowedIPs,omitempty"`
PreSharedKey string `json:"preSharedKey,omitempty"`
KeepAlive int `json:"keepAlive,omitempty"`
Email string `json:"email"` // Client email identifier
LimitIP int `json:"limitIp"` // IP limit for this client
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
SubID string `json:"subId" form:"subId"` // Subscription identifier
Group string `json:"group,omitempty" form:"group"` // Logical grouping label
Comment string `json:"comment" form:"comment"` // Client comment
Reset int `json:"reset" form:"reset"` // Reset period in days
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
}
type ClientRecord struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
Email string `json:"email" gorm:"uniqueIndex;not null"`
SubID string `json:"subId" gorm:"index;column:sub_id"`
UUID string `json:"uuid" gorm:"column:uuid"`
Password string `json:"password"`
Auth string `json:"auth"`
Flow string `json:"flow"`
Security string `json:"security"`
Reverse string `json:"reverse" gorm:"column:reverse"`
LimitIP int `json:"limitIp" gorm:"column:limit_ip"`
TotalGB int64 `json:"totalGB" gorm:"column:total_gb"`
ExpiryTime int64 `json:"expiryTime" gorm:"column:expiry_time"`
Enable bool `json:"enable" gorm:"default:true"`
TgID int64 `json:"tgId" gorm:"column:tg_id"`
Group string `json:"group" gorm:"column:group_name;default:'';index:idx_client_record_group"`
Comment string `json:"comment"`
Reset int `json:"reset" gorm:"default:0"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
Email string `json:"email" gorm:"uniqueIndex;not null"`
SubID string `json:"subId" gorm:"index;column:sub_id"`
UUID string `json:"uuid" gorm:"column:uuid"`
Password string `json:"password"`
Auth string `json:"auth"`
Flow string `json:"flow"`
Security string `json:"security"`
Reverse string `json:"reverse" gorm:"column:reverse"`
PrivateKey string `json:"privateKey" gorm:"column:wg_private_key"`
PublicKey string `json:"publicKey" gorm:"column:wg_public_key"`
AllowedIPs string `json:"allowedIPs" gorm:"column:wg_allowed_ips"`
PreSharedKey string `json:"preSharedKey" gorm:"column:wg_pre_shared_key"`
KeepAlive int `json:"keepAlive" gorm:"column:wg_keep_alive;default:0"`
LimitIP int `json:"limitIp" gorm:"column:limit_ip"`
TotalGB int64 `json:"totalGB" gorm:"column:total_gb"`
ExpiryTime int64 `json:"expiryTime" gorm:"column:expiry_time"`
Enable bool `json:"enable" gorm:"default:true"`
TgID int64 `json:"tgId" gorm:"column:tg_id"`
Group string `json:"group" gorm:"column:group_name;default:'';index:idx_client_record_group"`
Comment string `json:"comment"`
Reset int `json:"reset" gorm:"default:0"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
}
func (ClientRecord) TableName() string { return "clients" }
@@ -799,6 +809,12 @@ func (c *Client) ToRecord() *ClientRecord {
Reset: c.Reset,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
PrivateKey: c.PrivateKey,
PublicKey: c.PublicKey,
AllowedIPs: strings.Join(c.AllowedIPs, ","),
PreSharedKey: c.PreSharedKey,
KeepAlive: c.KeepAlive,
}
if c.Reverse != nil {
if b, err := json.Marshal(c.Reverse); err == nil {
@@ -808,6 +824,23 @@ func (c *Client) ToRecord() *ClientRecord {
return rec
}
func splitWireguardAllowedIPs(csv string) []string {
if csv == "" {
return nil
}
parts := strings.Split(csv, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if trimmed := strings.TrimSpace(p); trimmed != "" {
out = append(out, trimmed)
}
}
if len(out) == 0 {
return nil
}
return out
}
func (r *ClientRecord) ToClient() *Client {
c := &Client{
ID: r.UUID,
@@ -827,6 +860,12 @@ func (r *ClientRecord) ToClient() *Client {
Reset: r.Reset,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
PrivateKey: r.PrivateKey,
PublicKey: r.PublicKey,
AllowedIPs: splitWireguardAllowedIPs(r.AllowedIPs),
PreSharedKey: r.PreSharedKey,
KeepAlive: r.KeepAlive,
}
if r.Reverse != "" {
var rev ClientReverse
@@ -960,6 +999,36 @@ func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientM
existing.Reverse = incoming.Reverse
}
}
if existing.PrivateKey != incoming.PrivateKey && incoming.PrivateKey != "" {
if incomingNewer || existing.PrivateKey == "" {
existing.PrivateKey = incoming.PrivateKey
keepSecret("privateKey")
}
}
if existing.PublicKey != incoming.PublicKey && incoming.PublicKey != "" {
if incomingNewer || existing.PublicKey == "" {
existing.PublicKey = incoming.PublicKey
keepSecret("publicKey")
}
}
if existing.PreSharedKey != incoming.PreSharedKey && incoming.PreSharedKey != "" {
if incomingNewer || existing.PreSharedKey == "" {
existing.PreSharedKey = incoming.PreSharedKey
keepSecret("preSharedKey")
}
}
if existing.AllowedIPs != incoming.AllowedIPs && incoming.AllowedIPs != "" {
if incomingNewer || existing.AllowedIPs == "" {
keep("allowedIPs", existing.AllowedIPs, incoming.AllowedIPs, incoming.AllowedIPs)
existing.AllowedIPs = incoming.AllowedIPs
}
}
if existing.KeepAlive != incoming.KeepAlive && incoming.KeepAlive != 0 {
if incomingNewer || existing.KeepAlive == 0 {
keep("keepAlive", existing.KeepAlive, incoming.KeepAlive, incoming.KeepAlive)
existing.KeepAlive = incoming.KeepAlive
}
}
if existing.Comment != incoming.Comment && incoming.Comment != "" {
if incomingNewer || existing.Comment == "" {
keep("comment", existing.Comment, incoming.Comment, incoming.Comment)
@@ -0,0 +1,81 @@
package model
import (
"reflect"
"testing"
)
func TestClientToRecordRoundTripWireGuard(t *testing.T) {
c := &Client{
Email: "alice@example.test",
Enable: true,
PrivateKey: "cGVlci1wcml2YXRlLWtleS1iYXNlNjQtMzJieXRlcw==",
PublicKey: "cGVlci1wdWJsaWMta2V5LWJhc2U2NC0zMmJ5dGVzISE=",
AllowedIPs: []string{"10.0.0.2/32", "fd00::2/128"},
PreSharedKey: "cHNrLWJhc2U2NC0zMmJ5dGVzLXBsYWNlaG9sZGVyISE=",
KeepAlive: 25,
}
rec := c.ToRecord()
if rec.AllowedIPs != "10.0.0.2/32,fd00::2/128" {
t.Fatalf("AllowedIPs CSV = %q, want %q", rec.AllowedIPs, "10.0.0.2/32,fd00::2/128")
}
got := rec.ToClient()
for _, f := range []struct {
name string
a, b any
}{
{"PrivateKey", c.PrivateKey, got.PrivateKey},
{"PublicKey", c.PublicKey, got.PublicKey},
{"PreSharedKey", c.PreSharedKey, got.PreSharedKey},
{"KeepAlive", c.KeepAlive, got.KeepAlive},
} {
if f.a != f.b {
t.Errorf("%s round-trip = %v, want %v", f.name, f.b, f.a)
}
}
if !reflect.DeepEqual(got.AllowedIPs, c.AllowedIPs) {
t.Errorf("AllowedIPs round-trip = %v, want %v", got.AllowedIPs, c.AllowedIPs)
}
}
func TestClientRecordEmptyAllowedIPs(t *testing.T) {
rec := &ClientRecord{Email: "bob@example.test", AllowedIPs: ""}
if got := rec.ToClient().AllowedIPs; got != nil {
t.Fatalf("empty CSV → AllowedIPs = %v, want nil", got)
}
rec.AllowedIPs = " 10.0.0.5/32 , ,"
if got := rec.ToClient().AllowedIPs; !reflect.DeepEqual(got, []string{"10.0.0.5/32"}) {
t.Fatalf("trimmed CSV → AllowedIPs = %v, want [10.0.0.5/32]", got)
}
}
func TestMergeClientRecordWireGuardKeysPreserved(t *testing.T) {
existing := &ClientRecord{
Email: "carol@example.test",
PrivateKey: "existing-private",
PublicKey: "existing-public",
AllowedIPs: "10.0.0.7/32",
UpdatedAt: 100,
}
incomingEmpty := &ClientRecord{Email: "carol@example.test", UpdatedAt: 200}
MergeClientRecord(existing, incomingEmpty)
if existing.PrivateKey != "existing-private" || existing.PublicKey != "existing-public" {
t.Fatalf("empty incoming wiped keys: priv=%q pub=%q", existing.PrivateKey, existing.PublicKey)
}
if existing.AllowedIPs != "10.0.0.7/32" {
t.Fatalf("empty incoming wiped allowedIPs: %q", existing.AllowedIPs)
}
incomingNewer := &ClientRecord{
Email: "carol@example.test",
AllowedIPs: "10.0.0.8/32",
UpdatedAt: 300,
}
MergeClientRecord(existing, incomingNewer)
if existing.AllowedIPs != "10.0.0.8/32" {
t.Fatalf("newer allowedIPs not applied: %q", existing.AllowedIPs)
}
}
@@ -0,0 +1,190 @@
package database
import (
"encoding/json"
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func initWGMigrationDB(t *testing.T) {
t.Helper()
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB failed: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
}
func createWGInbound(t *testing.T, remark string, port int, peers []any) *model.Inbound {
t.Helper()
settings, err := json.Marshal(map[string]any{
"secretKey": "c2VjcmV0LWtleS1iYXNlNjQtMzJieXRlcy1wbGFjZWg=",
"mtu": 1420,
"peers": peers,
})
if err != nil {
t.Fatalf("marshal settings: %v", err)
}
in := &model.Inbound{
UserId: 1,
Remark: remark,
Port: port,
Protocol: model.WireGuard,
Settings: string(settings),
Tag: remark,
}
if err := db.Create(in).Error; err != nil {
t.Fatalf("create wg inbound: %v", err)
}
return in
}
func clearWGMigrationHistory(t *testing.T) {
t.Helper()
if err := db.Where("seeder_name = ?", "WireguardPeersToClients").Delete(&model.HistoryOfSeeders{}).Error; err != nil {
t.Fatalf("clear history: %v", err)
}
}
func reloadInboundSettings(t *testing.T, id int) map[string]any {
t.Helper()
var in model.Inbound
if err := db.First(&in, id).Error; err != nil {
t.Fatalf("reload inbound: %v", err)
}
var settings map[string]any
if err := json.Unmarshal([]byte(in.Settings), &settings); err != nil {
t.Fatalf("unmarshal settings: %v", err)
}
return settings
}
func wgPeer(comment, priv, pub, ip string, keepAlive int) any {
m := map[string]any{
"privateKey": priv,
"publicKey": pub,
"allowedIPs": []any{ip},
"keepAlive": keepAlive,
}
if comment != "" {
m["comment"] = comment
}
return m
}
func TestSeedWireguardPeersToClientsCreatesClients(t *testing.T) {
initWGMigrationDB(t)
in := createWGInbound(t, "wg-server", 51820, []any{
wgPeer("laptop", "priv-1", "pub-1", "10.0.0.2/32", 25),
})
clearWGMigrationHistory(t)
if err := seedWireguardPeersToClients(); err != nil {
t.Fatalf("seedWireguardPeersToClients: %v", err)
}
var rec model.ClientRecord
if err := db.Where("email = ?", "wg-server-laptop").First(&rec).Error; err != nil {
t.Fatalf("migrated client not found: %v", err)
}
if rec.PrivateKey != "priv-1" || rec.PublicKey != "pub-1" || rec.AllowedIPs != "10.0.0.2/32" {
t.Fatalf("wg columns not migrated: %+v", rec)
}
var linkCount int64
db.Model(&model.ClientInbound{}).Where("inbound_id = ? AND client_id = ?", in.Id, rec.Id).Count(&linkCount)
if linkCount != 1 {
t.Fatalf("expected 1 client_inbounds link, got %d", linkCount)
}
settings := reloadInboundSettings(t, in.Id)
if _, ok := settings["peers"]; ok {
t.Fatalf("peers key must be removed from stored settings")
}
clients, ok := settings["clients"].([]any)
if !ok || len(clients) != 1 {
t.Fatalf("settings.clients not written: %v", settings["clients"])
}
if settings["secretKey"] == nil || settings["mtu"] == nil {
t.Fatalf("server fields not preserved: %v", settings)
}
}
func TestSeedWireguardPeersToClientsIdempotent(t *testing.T) {
initWGMigrationDB(t)
in := createWGInbound(t, "wg-idem", 51823, []any{
wgPeer("", "priv-a", "pub-a", "10.0.0.2/32", 0),
})
clearWGMigrationHistory(t)
if err := seedWireguardPeersToClients(); err != nil {
t.Fatalf("first run: %v", err)
}
if err := seedWireguardPeersToClients(); err != nil {
t.Fatalf("second run (history gate): %v", err)
}
clearWGMigrationHistory(t)
if err := seedWireguardPeersToClients(); err != nil {
t.Fatalf("third run (linkCount gate): %v", err)
}
var clientCount int64
db.Model(&model.ClientInbound{}).Where("inbound_id = ?", in.Id).Count(&clientCount)
if clientCount != 1 {
t.Fatalf("expected exactly 1 link after repeated runs, got %d", clientCount)
}
}
func TestSeedWireguardPeersToClientsSkipsNonWireguard(t *testing.T) {
initWGMigrationDB(t)
vless := &model.Inbound{UserId: 1, Port: 41001, Protocol: model.VLESS, Tag: "vless-x", Settings: `{"clients":[]}`}
if err := db.Create(vless).Error; err != nil {
t.Fatalf("create vless: %v", err)
}
clearWGMigrationHistory(t)
if err := seedWireguardPeersToClients(); err != nil {
t.Fatalf("seed: %v", err)
}
var linkCount int64
db.Model(&model.ClientInbound{}).Where("inbound_id = ?", vless.Id).Count(&linkCount)
if linkCount != 0 {
t.Fatalf("vless inbound must be untouched, got %d links", linkCount)
}
}
func TestSeedWireguardPeersToClientsMultiplePeers(t *testing.T) {
initWGMigrationDB(t)
in := createWGInbound(t, "wg-multi", 51824, []any{
wgPeer("alpha", "p1", "pub1", "10.0.0.2/32", 0),
wgPeer("beta", "p2", "pub2", "10.0.0.3/32", 0),
})
clearWGMigrationHistory(t)
if err := seedWireguardPeersToClients(); err != nil {
t.Fatalf("seed: %v", err)
}
var links []model.ClientInbound
if err := db.Where("inbound_id = ?", in.Id).Find(&links).Error; err != nil {
t.Fatalf("load links: %v", err)
}
if len(links) != 2 {
t.Fatalf("expected 2 links, got %d", len(links))
}
settings := reloadInboundSettings(t, in.Id)
clients := settings["clients"].([]any)
ips := map[string]bool{}
emails := map[string]bool{}
for _, c := range clients {
m := c.(map[string]any)
emails[m["email"].(string)] = true
ip := m["allowedIPs"].([]any)[0].(string)
ips[ip] = true
}
if len(ips) != 2 || len(emails) != 2 {
t.Fatalf("expected distinct emails/ips, got emails=%v ips=%v", emails, ips)
}
}