mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-13 16:16: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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user