mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-14 00:26:06 +00:00
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:
+148
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user