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)
}
}
+44 -1
View File
@@ -21,6 +21,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/util/random"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
@@ -367,7 +368,7 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error)
JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id
JOIN clients ON clients.id = client_inbounds.client_id
WHERE
inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria')
inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria','wireguard')
AND clients.sub_id = ? AND inbounds.enable = ?
)`, subId, true).Order("sub_sort_index ASC").Order("id ASC").Find(&inbounds).Error
if err != nil {
@@ -501,10 +502,52 @@ func (s *SubService) GetLink(inbound *model.Inbound, email string) string {
return s.genHysteriaLink(inbound, email)
case "mtproto":
return s.genMtprotoLink(inbound, email)
case "wireguard":
return s.genWireguardLink(inbound, email)
}
return ""
}
// genWireguardLink builds a per-client wireguard:// share link mirroring the
// frontend genWireguardLink: the client's private key is the userinfo, the
// server public key (derived from the inbound secretKey) and the client's
// tunnel address ride in the query. Returns "" when the client has no key.
func (s *SubService) genWireguardLink(inbound *model.Inbound, email string) string {
if inbound.Protocol != model.WireGuard {
return ""
}
settings := map[string]any{}
_ = json.Unmarshal([]byte(inbound.Settings), &settings)
secretKey, _ := settings["secretKey"].(string)
clients, _ := s.inboundService.GetClients(inbound)
var client *model.Client
for i := range clients {
if clients[i].Email == email {
client = &clients[i]
break
}
}
if client == nil || client.PrivateKey == "" {
return ""
}
link := fmt.Sprintf("wireguard://%s@%s", encodeUserinfo(client.PrivateKey), joinHostPort(s.resolveInboundAddress(inbound), inbound.Port))
params := make(map[string]string)
if secretKey != "" {
if pub, err := wgutil.PublicKeyFromPrivate(secretKey); err == nil {
params["publickey"] = pub
}
}
if len(client.AllowedIPs) > 0 && client.AllowedIPs[0] != "" {
params["address"] = client.AllowedIPs[0]
}
if mtu, ok := settings["mtu"].(float64); ok && mtu > 0 {
params["mtu"] = strconv.Itoa(int(mtu))
}
return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", ""))
}
// genMtprotoLink builds a Telegram proxy deep link for an mtproto inbound:
func (s *SubService) genMtprotoLink(inbound *model.Inbound, _ string) string {
if inbound.Protocol != model.MTProto {
+102
View File
@@ -0,0 +1,102 @@
package sub
import (
"net/url"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
func TestGenWireguardLinkFields(t *testing.T) {
serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("keypair: %v", err)
}
clientPriv, _, err := wgutil.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("client keypair: %v", err)
}
inbound := &model.Inbound{
Listen: "203.0.113.7",
Port: 51820,
Protocol: model.WireGuard,
Remark: "wg-sub",
Settings: `{"secretKey":"` + serverPriv + `","mtu":1420,"clients":[{"email":"user","privateKey":"` + clientPriv + `","allowedIPs":["10.0.0.2/32"],"keepAlive":25}]}`,
}
s := &SubService{}
link := s.genWireguardLink(inbound, "user")
u, err := url.Parse(link)
if err != nil {
t.Fatalf("link does not parse: %v\n got: %s", err, link)
}
if u.Scheme != "wireguard" {
t.Fatalf("scheme = %q, want wireguard", u.Scheme)
}
if u.Host != "203.0.113.7:51820" {
t.Fatalf("host = %q, want 203.0.113.7:51820", u.Host)
}
if u.User.Username() != clientPriv {
t.Fatalf("userinfo = %q, want client private key %q", u.User.Username(), clientPriv)
}
q := u.Query()
if q.Get("publickey") != serverPub {
t.Fatalf("publickey = %q, want server public key %q", q.Get("publickey"), serverPub)
}
if q.Get("address") != "10.0.0.2/32" {
t.Fatalf("address = %q, want 10.0.0.2/32", q.Get("address"))
}
if q.Get("mtu") != "1420" {
t.Fatalf("mtu = %q, want 1420", q.Get("mtu"))
}
}
func TestGenWireguardLinkWrongProtocol(t *testing.T) {
s := &SubService{}
vless := &model.Inbound{Protocol: model.VLESS, Settings: `{"clients":[{"email":"user"}]}`}
if got := s.genWireguardLink(vless, "user"); got != "" {
t.Fatalf("wrong protocol should yield empty link, got %q", got)
}
}
func TestGenWireguardLinkNoKey(t *testing.T) {
s := &SubService{}
inbound := &model.Inbound{
Protocol: model.WireGuard,
Port: 51820,
Settings: `{"secretKey":"x","clients":[{"email":"user"}]}`,
}
if got := s.genWireguardLink(inbound, "user"); got != "" {
t.Fatalf("client without private key should yield empty link, got %q", got)
}
}
func TestGetInboundsBySubIdIncludesWireguard(t *testing.T) {
initSubDB(t)
db := database.GetDB()
in := &model.Inbound{Port: 51820, Protocol: model.WireGuard, Enable: true, Tag: "wg-sub", Settings: `{"secretKey":"x","clients":[]}`}
if err := db.Create(in).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
rec := &model.ClientRecord{Email: "u@wg", SubID: "subwg", Enable: true}
if err := db.Create(rec).Error; err != nil {
t.Fatalf("create client: %v", err)
}
if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: in.Id}).Error; err != nil {
t.Fatalf("create link: %v", err)
}
s := &SubService{}
inbounds, err := s.getInboundsBySubId("subwg")
if err != nil {
t.Fatalf("getInboundsBySubId: %v", err)
}
if len(inbounds) != 1 || inbounds[0].Id != in.Id {
t.Fatalf("wireguard inbound not returned for subId: %+v", inbounds)
}
}
+75
View File
@@ -3,6 +3,9 @@ package wireguard
import (
"crypto/rand"
"encoding/base64"
"encoding/hex"
"errors"
"strings"
"golang.org/x/crypto/curve25519"
)
@@ -22,3 +25,75 @@ func GenerateWireguardKeypair() (privateKey string, publicKey string, err error)
return base64.StdEncoding.EncodeToString(priv[:]), base64.StdEncoding.EncodeToString(pub[:]), nil
}
// GenerateWireguardPSK generates a base64 encoded 32-byte pre-shared key for Wireguard.
func GenerateWireguardPSK() (string, error) {
var psk [32]byte
if _, err := rand.Read(psk[:]); err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(psk[:]), nil
}
// PublicKeyFromPrivate derives the base64 public key for a base64 (or hex) Wireguard private key.
func PublicKeyFromPrivate(privateKey string) (string, error) {
priv, err := decodeWireguardKey(privateKey)
if err != nil {
return "", err
}
var pub [32]byte
curve25519.ScalarBaseMult(&pub, &priv)
return base64.StdEncoding.EncodeToString(pub[:]), nil
}
// KeyToHex converts a base64 (or already-hex) 32-byte Wireguard key into the
// lowercase hex form xray-core's wireguard proxy expects: its ParseKey uses
// hex.DecodeString, and the device IPC layer wants hex for public_key and
// preshared_key. An empty input yields an empty result so optional keys pass
// through untouched.
func KeyToHex(key string) (string, error) {
if key == "" {
return "", nil
}
raw, err := decodeWireguardKey(key)
if err != nil {
return "", err
}
return hex.EncodeToString(raw[:]), nil
}
// decodeWireguardKey accepts a 64-char hex key or a base64 key (standard or
// URL-safe alphabet, with or without padding) and returns the raw 32 bytes.
func decodeWireguardKey(key string) ([32]byte, error) {
var out [32]byte
if key == "" {
return out, errors.New("wireguard: empty key")
}
if len(key) == 64 {
if raw, err := hex.DecodeString(key); err == nil {
if len(raw) != 32 {
return out, errors.New("wireguard: key must decode to 32 bytes")
}
copy(out[:], raw)
return out, nil
}
}
trimmed := strings.TrimRight(key, "=")
var raw []byte
var err error
if strings.ContainsAny(trimmed, "+/") {
raw, err = base64.RawStdEncoding.DecodeString(trimmed)
} else {
raw, err = base64.RawURLEncoding.DecodeString(trimmed)
}
if err != nil {
return out, err
}
if len(raw) != 32 {
return out, errors.New("wireguard: key must decode to 32 bytes")
}
copy(out[:], raw)
return out, nil
}
+123
View File
@@ -0,0 +1,123 @@
package wireguard
import (
"encoding/base64"
"encoding/hex"
"strings"
"testing"
)
func TestGenerateWireguardKeypairRoundTrip(t *testing.T) {
priv, pub, err := GenerateWireguardKeypair()
if err != nil {
t.Fatalf("GenerateWireguardKeypair: %v", err)
}
for name, key := range map[string]string{"private": priv, "public": pub} {
raw, err := base64.StdEncoding.DecodeString(key)
if err != nil {
t.Fatalf("%s key not base64: %v", name, err)
}
if len(raw) != 32 {
t.Fatalf("%s key decodes to %d bytes, want 32", name, len(raw))
}
}
derived, err := PublicKeyFromPrivate(priv)
if err != nil {
t.Fatalf("PublicKeyFromPrivate: %v", err)
}
if derived != pub {
t.Fatalf("PublicKeyFromPrivate(priv) = %q, want %q", derived, pub)
}
}
func TestPublicKeyFromPrivateKnownVector(t *testing.T) {
privHex := "77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a"
wantPubHex := "8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a"
privBytes, err := hex.DecodeString(privHex)
if err != nil {
t.Fatalf("decode priv vector: %v", err)
}
pubB64, err := PublicKeyFromPrivate(base64.StdEncoding.EncodeToString(privBytes))
if err != nil {
t.Fatalf("PublicKeyFromPrivate: %v", err)
}
gotPubHex, err := KeyToHex(pubB64)
if err != nil {
t.Fatalf("KeyToHex: %v", err)
}
if gotPubHex != wantPubHex {
t.Fatalf("derived public key hex = %q, want %q", gotPubHex, wantPubHex)
}
}
func TestKeyToHex(t *testing.T) {
low := make([]byte, 32)
for i := range low {
low[i] = byte(i)
}
high := make([]byte, 32)
for i := range high {
high[i] = 0xff
}
for _, raw := range [][]byte{low, high} {
wantHex := hex.EncodeToString(raw)
std := base64.StdEncoding.EncodeToString(raw)
url := base64.URLEncoding.EncodeToString(raw)
padless := strings.TrimRight(std, "=")
for label, in := range map[string]string{"std": std, "url": url, "padless": padless, "hex": wantHex} {
got, err := KeyToHex(in)
if err != nil {
t.Fatalf("KeyToHex(%s=%q): %v", label, in, err)
}
if got != wantHex {
t.Fatalf("KeyToHex(%s) = %q, want %q", label, got, wantHex)
}
if back, err := hex.DecodeString(got); err != nil || len(back) != 32 {
t.Fatalf("KeyToHex output not a 32-byte hex key: err=%v len=%d", err, len(back))
}
}
}
}
func TestKeyToHexEmpty(t *testing.T) {
got, err := KeyToHex("")
if err != nil {
t.Fatalf("KeyToHex(\"\"): %v", err)
}
if got != "" {
t.Fatalf("KeyToHex(\"\") = %q, want empty", got)
}
}
func TestKeyToHexRejectsBadInput(t *testing.T) {
cases := map[string]string{
"not base64": "this is not base64 @@@@",
"wrong length": base64.StdEncoding.EncodeToString(make([]byte, 16)),
}
for name, in := range cases {
if _, err := KeyToHex(in); err == nil {
t.Fatalf("KeyToHex(%s=%q) expected error, got nil", name, in)
}
}
}
func TestGenerateWireguardPSK(t *testing.T) {
a, err := GenerateWireguardPSK()
if err != nil {
t.Fatalf("GenerateWireguardPSK: %v", err)
}
b, err := GenerateWireguardPSK()
if err != nil {
t.Fatalf("GenerateWireguardPSK: %v", err)
}
if a == b {
t.Fatalf("two PSKs are identical: %q", a)
}
raw, err := base64.StdEncoding.DecodeString(a)
if err != nil || len(raw) != 32 {
t.Fatalf("PSK not a 32-byte base64 key: err=%v len=%d", err, len(raw))
}
}
+28 -12
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"strconv"
"strings"
"sync"
@@ -102,12 +103,16 @@ func (l *Local) AddClient(ctx context.Context, ib *model.Inbound, client model.C
return nil
}
user := map[string]any{
"email": client.Email,
"id": client.ID,
"security": client.Security,
"flow": client.Flow,
"auth": client.Auth,
"password": client.Password,
"email": client.Email,
"id": client.ID,
"security": client.Security,
"flow": client.Flow,
"auth": client.Auth,
"password": client.Password,
"publicKey": client.PublicKey,
"allowedIPs": client.AllowedIPs,
"preSharedKey": client.PreSharedKey,
"keepAlive": wgKeepAlive(client.KeepAlive),
}
return l.AddUser(ctx, ib, user)
}
@@ -135,16 +140,27 @@ func (l *Local) UpdateUser(ctx context.Context, ib *model.Inbound, oldEmail stri
return nil
}
user := map[string]any{
"email": payload.Email,
"id": payload.ID,
"security": payload.Security,
"flow": payload.Flow,
"auth": payload.Auth,
"password": payload.Password,
"email": payload.Email,
"id": payload.ID,
"security": payload.Security,
"flow": payload.Flow,
"auth": payload.Auth,
"password": payload.Password,
"publicKey": payload.PublicKey,
"allowedIPs": payload.AllowedIPs,
"preSharedKey": payload.PreSharedKey,
"keepAlive": wgKeepAlive(payload.KeepAlive),
}
return l.AddUser(ctx, ib, user)
}
func wgKeepAlive(seconds int) string {
if seconds <= 0 {
return ""
}
return strconv.Itoa(seconds)
}
func (l *Local) RestartXray(_ context.Context) error {
if l.deps.SetNeedRestart != nil {
l.deps.SetNeedRestart()
+73 -16
View File
@@ -295,6 +295,16 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
return false, err
}
if oldInbound.Protocol == model.WireGuard {
existing, gcErr := inboundSvc.GetClients(oldInbound)
if gcErr != nil {
return false, gcErr
}
if dErr := defaultWireguardClients(existing, clients, interfaceClients); dErr != nil {
return false, dErr
}
}
for _, client := range clients {
if strings.TrimSpace(client.Email) == "" {
return false, common.NewError("client email is required")
@@ -312,6 +322,10 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
if client.Auth == "" {
return false, common.NewError("empty client ID")
}
case "wireguard":
if client.PublicKey == "" {
return false, common.NewError("wireguard client requires a key")
}
default:
if client.ID == "" {
return false, common.NewError("empty client ID")
@@ -329,7 +343,7 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
applyShadowsocksClientMethod(interfaceClients, oldSettings)
}
oldClients := oldSettings["clients"].([]any)
oldClients, _ := oldSettings["clients"].([]any)
oldClients = compactOrphans(database.GetDB(), oldClients)
oldClients = append(oldClients, interfaceClients...)
@@ -395,13 +409,17 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
cipher = oldSettings["method"].(string)
}
err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
"email": client.Email,
"id": client.ID,
"auth": client.Auth,
"security": client.Security,
"flow": client.Flow,
"password": client.Password,
"cipher": cipher,
"email": client.Email,
"id": client.ID,
"auth": client.Auth,
"security": client.Security,
"flow": client.Flow,
"password": client.Password,
"cipher": cipher,
"publicKey": client.PublicKey,
"allowedIPs": client.AllowedIPs,
"preSharedKey": client.PreSharedKey,
"keepAlive": keepAliveStr(client.KeepAlive),
})
if err1 == nil {
logger.Debug("Client added on", rt.Name(), ":", client.Email)
@@ -472,6 +490,8 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
newClientId = clients[0].Email
case "hysteria":
newClientId = clients[0].Auth
case "wireguard":
newClientId = clients[0].Email
default:
newClientId = clients[0].ID
}
@@ -505,12 +525,34 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
}
}
// WireGuard keys are never rotated by an edit: when the incoming payload omits
// them (a metadata-only change), carry the stored credentials forward so the
// settings JSON and the running peer keep the client's identity.
if oldInbound.Protocol == model.WireGuard && clientIndex >= 0 && clientIndex < len(oldClients) {
old := oldClients[clientIndex]
if clients[0].PrivateKey == "" {
clients[0].PrivateKey = old.PrivateKey
}
if clients[0].PublicKey == "" {
clients[0].PublicKey = old.PublicKey
}
if len(clients[0].AllowedIPs) == 0 {
clients[0].AllowedIPs = old.AllowedIPs
}
if clients[0].PreSharedKey == "" {
clients[0].PreSharedKey = old.PreSharedKey
}
if clients[0].KeepAlive == 0 {
clients[0].KeepAlive = old.KeepAlive
}
}
var oldSettings map[string]any
err = json.Unmarshal([]byte(oldInbound.Settings), &oldSettings)
if err != nil {
return false, err
}
settingsClients := oldSettings["clients"].([]any)
settingsClients, _ := oldSettings["clients"].([]any)
var preservedCreated any
var preservedSubID string
if clientIndex >= 0 && clientIndex < len(settingsClients) {
@@ -536,6 +578,17 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
newMap["subId"] = random.NumLower(16)
}
}
if oldInbound.Protocol == model.WireGuard {
newMap["privateKey"] = clients[0].PrivateKey
newMap["publicKey"] = clients[0].PublicKey
newMap["allowedIPs"] = clients[0].AllowedIPs
if clients[0].PreSharedKey != "" {
newMap["preSharedKey"] = clients[0].PreSharedKey
}
if clients[0].KeepAlive > 0 {
newMap["keepAlive"] = clients[0].KeepAlive
}
}
interfaceClients[0] = newMap
}
}
@@ -681,13 +734,17 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
cipher = oldSettings["method"].(string)
}
err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
"email": clients[0].Email,
"id": clients[0].ID,
"security": clients[0].Security,
"flow": clients[0].Flow,
"auth": clients[0].Auth,
"password": clients[0].Password,
"cipher": cipher,
"email": clients[0].Email,
"id": clients[0].ID,
"security": clients[0].Security,
"flow": clients[0].Flow,
"auth": clients[0].Auth,
"password": clients[0].Password,
"cipher": cipher,
"publicKey": clients[0].PublicKey,
"allowedIPs": clients[0].AllowedIPs,
"preSharedKey": clients[0].PreSharedKey,
"keepAlive": keepAliveStr(clients[0].KeepAlive),
})
if err1 == nil {
logger.Debug("Client edited on", rt.Name(), ":", clients[0].Email)
+11
View File
@@ -82,6 +82,17 @@ func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.
if incoming.Reverse != "" {
row.Reverse = incoming.Reverse
}
if incoming.PrivateKey != "" {
row.PrivateKey = incoming.PrivateKey
}
if incoming.PublicKey != "" {
row.PublicKey = incoming.PublicKey
}
if incoming.AllowedIPs != "" {
row.AllowedIPs = incoming.AllowedIPs
}
row.PreSharedKey = incoming.PreSharedKey
row.KeepAlive = incoming.KeepAlive
row.SubID = incoming.SubID
row.LimitIP = incoming.LimitIP
row.TotalGB = incoming.TotalGB
+115
View File
@@ -0,0 +1,115 @@
package service
import (
"net/netip"
"strconv"
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
const defaultWireguardBase = "10.0.0.0/24"
func keepAliveStr(seconds int) string {
if seconds <= 0 {
return ""
}
return strconv.Itoa(seconds)
}
func wireguardHostAddr(s string) netip.Addr {
s = strings.TrimSpace(s)
if s == "" {
return netip.Addr{}
}
if p, err := netip.ParsePrefix(s); err == nil {
return p.Addr()
}
if a, err := netip.ParseAddr(s); err == nil {
return a
}
return netip.Addr{}
}
// allocateWireguardAddress returns the first free /32 host address in base that
// is not already present in used. The server holds the first host (.1), so
// allocation starts at the second host (.2).
func allocateWireguardAddress(used []string, base string) (string, error) {
if base == "" {
base = defaultWireguardBase
}
prefix, err := netip.ParsePrefix(base)
if err != nil {
return "", err
}
taken := make(map[netip.Addr]struct{}, len(used))
for _, u := range used {
if a := wireguardHostAddr(u); a.IsValid() {
taken[a] = struct{}{}
}
}
addr := prefix.Masked().Addr().Next().Next()
for prefix.Contains(addr) {
if _, ok := taken[addr]; !ok {
return addr.String() + "/32", nil
}
addr = addr.Next()
}
return "", common.NewError("wireguard: no free address available in", base)
}
// defaultWireguardClients fills in blank WireGuard credentials for newly added
// clients: a generated keypair when none was provided, a derived public key when
// only a private key was given, and a unique tunnel address allocated from the
// inbound's subnet. It mutates both the typed clients and the parallel raw client
// maps that get persisted into the inbound settings. Existing values are never
// overwritten, so editing a client never rotates its keys.
func defaultWireguardClients(existing, clients []model.Client, interfaceClients []any) error {
used := make([]string, 0)
for i := range existing {
used = append(used, existing[i].AllowedIPs...)
}
for i := range clients {
c := &clients[i]
if c.PrivateKey == "" && c.PublicKey == "" {
priv, pub, err := wgutil.GenerateWireguardKeypair()
if err != nil {
return err
}
c.PrivateKey = priv
c.PublicKey = pub
} else if c.PublicKey == "" && c.PrivateKey != "" {
pub, err := wgutil.PublicKeyFromPrivate(c.PrivateKey)
if err != nil {
return err
}
c.PublicKey = pub
}
if len(c.AllowedIPs) == 0 {
addr, err := allocateWireguardAddress(used, defaultWireguardBase)
if err != nil {
return err
}
c.AllowedIPs = []string{addr}
}
used = append(used, c.AllowedIPs...)
if i < len(interfaceClients) {
if m, ok := interfaceClients[i].(map[string]any); ok {
m["privateKey"] = c.PrivateKey
m["publicKey"] = c.PublicKey
m["allowedIPs"] = c.AllowedIPs
if c.PreSharedKey != "" {
m["preSharedKey"] = c.PreSharedKey
}
if c.KeepAlive > 0 {
m["keepAlive"] = c.KeepAlive
}
interfaceClients[i] = m
}
}
}
return nil
}
@@ -0,0 +1,144 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func wgServerSettings() string {
return `{"secretKey":"` + wgTestSecretKey() + `","mtu":1420,"clients":[]}`
}
func lookupClientRecord(t *testing.T, email string) model.ClientRecord {
t.Helper()
var rec model.ClientRecord
if err := database.GetDB().Where("email = ?", email).First(&rec).Error; err != nil {
t.Fatalf("lookup client %q: %v", email, err)
}
return rec
}
func TestWireGuardClientAddUpdateDeleteRoundTrip(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
ib := mkInbound(t, 51900, model.WireGuard, wgServerSettings())
add := &model.Inbound{Id: ib.Id, Protocol: model.WireGuard, Settings: clientsSettings(t, []model.Client{
{Email: "alice@wg", Enable: true},
})}
if _, err := svc.AddInboundClient(inboundSvc, add); err != nil {
t.Fatalf("AddInboundClient: %v", err)
}
list, err := svc.ListForInbound(nil, ib.Id)
if err != nil {
t.Fatalf("ListForInbound: %v", err)
}
if len(list) != 1 {
t.Fatalf("expected 1 attached client, got %d", len(list))
}
created := list[0]
if created.PrivateKey == "" || created.PublicKey == "" {
t.Fatalf("keys not generated/persisted: %+v", created)
}
if len(created.AllowedIPs) == 0 {
t.Fatalf("allowedIPs not allocated: %+v", created)
}
rec := lookupClientRecord(t, "alice@wg")
if rec.PrivateKey == "" || rec.AllowedIPs == "" {
t.Fatalf("client record missing wg columns: %+v", rec)
}
update := &model.Inbound{Id: ib.Id, Protocol: model.WireGuard, Settings: clientsSettings(t, []model.Client{
{Email: "alice@wg", Enable: true, Comment: "renamed laptop"},
})}
if _, err := svc.UpdateInboundClient(inboundSvc, update, "alice@wg"); err != nil {
t.Fatalf("UpdateInboundClient: %v", err)
}
afterUpdate := lookupClientRecord(t, "alice@wg")
if afterUpdate.PrivateKey != created.PrivateKey {
t.Fatalf("private key rotated on metadata edit: was %q now %q", created.PrivateKey, afterUpdate.PrivateKey)
}
if afterUpdate.PublicKey != created.PublicKey {
t.Fatalf("public key rotated on metadata edit: was %q now %q", created.PublicKey, afterUpdate.PublicKey)
}
if afterUpdate.Comment != "renamed laptop" {
t.Fatalf("comment not updated: %q", afterUpdate.Comment)
}
listAfter, err := svc.ListForInbound(nil, ib.Id)
if err != nil {
t.Fatalf("ListForInbound after update: %v", err)
}
if len(listAfter) != 1 || len(listAfter[0].AllowedIPs) == 0 {
t.Fatalf("settings lost wg fields after metadata edit: %+v", listAfter)
}
if _, err := svc.DelInboundClientByEmail(inboundSvc, ib.Id, "alice@wg", false); err != nil {
t.Fatalf("DelInboundClientByEmail: %v", err)
}
final, err := svc.ListForInbound(nil, ib.Id)
if err != nil {
t.Fatalf("ListForInbound after delete: %v", err)
}
if len(final) != 0 {
t.Fatalf("client not detached after delete: %+v", final)
}
}
func TestWireGuardClientAddToInboundWithoutClientsKey(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
ib := mkInbound(t, 51902, model.WireGuard, `{"secretKey":"`+wgTestSecretKey()+`","mtu":1420,"peers":[]}`)
add := &model.Inbound{Id: ib.Id, Protocol: model.WireGuard, Settings: clientsSettings(t, []model.Client{
{Email: "first@wg", Enable: true},
})}
if _, err := svc.AddInboundClient(inboundSvc, add); err != nil {
t.Fatalf("AddInboundClient onto clients-less wireguard inbound: %v", err)
}
list, err := svc.ListForInbound(nil, ib.Id)
if err != nil {
t.Fatalf("ListForInbound: %v", err)
}
if len(list) != 1 || list[0].PrivateKey == "" || len(list[0].AllowedIPs) == 0 {
t.Fatalf("client not added with generated keys/address: %+v", list)
}
}
func TestWireGuardClientAllocatesUniqueIPsAcrossTwoAdds(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
ib := mkInbound(t, 51901, model.WireGuard, wgServerSettings())
for _, email := range []string{"one@wg", "two@wg"} {
add := &model.Inbound{Id: ib.Id, Protocol: model.WireGuard, Settings: clientsSettings(t, []model.Client{
{Email: email, Enable: true},
})}
if _, err := svc.AddInboundClient(inboundSvc, add); err != nil {
t.Fatalf("AddInboundClient(%s): %v", email, err)
}
}
list, err := svc.ListForInbound(nil, ib.Id)
if err != nil {
t.Fatalf("ListForInbound: %v", err)
}
if len(list) != 2 {
t.Fatalf("expected 2 clients, got %d", len(list))
}
if list[0].AllowedIPs[0] == list[1].AllowedIPs[0] {
t.Fatalf("two adds collided on address %q", list[0].AllowedIPs[0])
}
}
@@ -0,0 +1,110 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
func TestAllocateWireguardAddress(t *testing.T) {
tests := []struct {
name string
used []string
base string
want string
err bool
}{
{name: "empty starts at .2", used: nil, base: "10.0.0.0/24", want: "10.0.0.2/32"},
{name: "skips used", used: []string{"10.0.0.2/32"}, base: "10.0.0.0/24", want: "10.0.0.3/32"},
{name: "fills gap", used: []string{"10.0.0.3/32", "10.0.0.4/32"}, base: "10.0.0.0/24", want: "10.0.0.2/32"},
{name: "ignores catch-all", used: []string{"0.0.0.0/0", "::/0"}, base: "10.0.0.0/24", want: "10.0.0.2/32"},
{name: "default base when empty", used: nil, base: "", want: "10.0.0.2/32"},
{name: "exhausted /30", used: []string{"10.9.0.2/32", "10.9.0.3/32"}, base: "10.9.0.0/30", err: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := allocateWireguardAddress(tt.used, tt.base)
if tt.err {
if err == nil {
t.Fatalf("expected error, got %q", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Fatalf("got %q, want %q", got, tt.want)
}
})
}
}
func TestDefaultWireguardClientsGeneratesKeypair(t *testing.T) {
clients := []model.Client{{Email: "a@wg"}}
ifaces := []any{map[string]any{"email": "a@wg"}}
if err := defaultWireguardClients(nil, clients, ifaces); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
c := clients[0]
if c.PrivateKey == "" || c.PublicKey == "" {
t.Fatalf("keypair not generated: priv=%q pub=%q", c.PrivateKey, c.PublicKey)
}
if len(c.AllowedIPs) != 1 || c.AllowedIPs[0] != "10.0.0.2/32" {
t.Fatalf("allowedIPs not allocated: %v", c.AllowedIPs)
}
m := ifaces[0].(map[string]any)
if m["privateKey"] != c.PrivateKey || m["publicKey"] != c.PublicKey {
t.Fatalf("interface map not updated: %v", m)
}
}
func TestDefaultWireguardClientsDerivesPublicKey(t *testing.T) {
priv, _, err := wgutil.GenerateWireguardKeypair()
if err != nil {
t.Fatal(err)
}
wantPub, err := wgutil.PublicKeyFromPrivate(priv)
if err != nil {
t.Fatal(err)
}
clients := []model.Client{{Email: "b@wg", PrivateKey: priv}}
ifaces := []any{map[string]any{"email": "b@wg"}}
if err := defaultWireguardClients(nil, clients, ifaces); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
if clients[0].PublicKey != wantPub {
t.Fatalf("derived public key = %q, want %q", clients[0].PublicKey, wantPub)
}
}
func TestDefaultWireguardClientsPreservesProvided(t *testing.T) {
clients := []model.Client{{
Email: "c@wg",
PrivateKey: "keep-priv",
PublicKey: "keep-pub",
AllowedIPs: []string{"10.0.0.50/32"},
}}
ifaces := []any{map[string]any{"email": "c@wg"}}
if err := defaultWireguardClients(nil, clients, ifaces); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
if clients[0].PrivateKey != "keep-priv" || clients[0].PublicKey != "keep-pub" {
t.Fatalf("provided keys were rotated: %+v", clients[0])
}
if clients[0].AllowedIPs[0] != "10.0.0.50/32" {
t.Fatalf("provided allowedIPs changed: %v", clients[0].AllowedIPs)
}
}
func TestDefaultWireguardClientsAllocatesDistinctIPs(t *testing.T) {
clients := []model.Client{{Email: "x@wg"}, {Email: "y@wg"}}
ifaces := []any{map[string]any{"email": "x@wg"}, map[string]any{"email": "y@wg"}}
if err := defaultWireguardClients(nil, clients, ifaces); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
if clients[0].AllowedIPs[0] == clients[1].AllowedIPs[0] {
t.Fatalf("two clients got the same address: %v", clients[0].AllowedIPs)
}
}
+31 -4
View File
@@ -157,6 +157,7 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
}
var finalClients []any
var wgPeers []any
for i := range dbClients {
c := dbClients[i]
if enable, exists := enableMap[c.Email]; exists && !enable {
@@ -204,14 +205,40 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
if c.Auth != "" {
entry["auth"] = c.Auth
}
case model.WireGuard:
peer := map[string]any{"email": c.Email, "level": 0}
if c.PublicKey != "" {
peer["publicKey"] = c.PublicKey
}
if len(c.AllowedIPs) > 0 {
peer["allowedIPs"] = c.AllowedIPs
}
if c.PreSharedKey != "" {
peer["preSharedKey"] = c.PreSharedKey
}
if c.KeepAlive > 0 {
peer["keepAlive"] = c.KeepAlive
}
wgPeers = append(wgPeers, peer)
continue
}
finalClients = append(finalClients, entry)
}
_, hadClients := settings["clients"]
mutated := hadClients || len(finalClients) > 0
if mutated {
settings["clients"] = finalClients
var mutated bool
if inbound.Protocol == model.WireGuard {
delete(settings, "clients")
if wgPeers == nil {
wgPeers = []any{}
}
settings["peers"] = wgPeers
mutated = true
} else {
_, hadClients := settings["clients"]
mutated = hadClients || len(finalClients) > 0
if mutated {
settings["clients"] = finalClients
}
}
if inboundCanHostFallbacks(inbound) {
@@ -0,0 +1,154 @@
package service
import (
"encoding/base64"
"encoding/json"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func wgTestSecretKey() string {
return base64.StdEncoding.EncodeToString(make([]byte, 32))
}
func wgInboundEmittedSettings(t *testing.T, tag string) map[string]any {
t.Helper()
svc := &XrayService{}
cfg, err := svc.GetXrayConfig()
if err != nil {
t.Fatalf("GetXrayConfig: %v", err)
}
for i := range cfg.InboundConfigs {
ic := cfg.InboundConfigs[i]
if ic.Tag != tag {
continue
}
var s map[string]any
if err := json.Unmarshal([]byte(ic.Settings), &s); err != nil {
t.Fatalf("unmarshal emitted settings: %v", err)
}
return s
}
t.Fatalf("inbound %q not found in generated config", tag)
return nil
}
func seedWGInbound(t *testing.T, tag string, port int, clients []model.Client) {
t.Helper()
setupSettingTestDB(t)
db := database.GetDB()
in := &model.Inbound{
Tag: tag,
Enable: true,
Port: port,
Protocol: model.WireGuard,
Settings: `{"secretKey":"` + wgTestSecretKey() + `","mtu":1420}`,
}
if err := db.Create(in).Error; err != nil {
t.Fatalf("create wg inbound: %v", err)
}
svc := ClientService{}
if err := svc.SyncInbound(nil, in.Id, clients); err != nil {
t.Fatalf("SyncInbound: %v", err)
}
}
func wgPeerList(t *testing.T, settings map[string]any) []map[string]any {
t.Helper()
if _, ok := settings["clients"]; ok {
t.Fatalf("wireguard inbound must not emit a clients[] key: %v", settings["clients"])
}
rawPeers, ok := settings["peers"].([]any)
if !ok {
t.Fatalf("settings.peers is not an array: %T", settings["peers"])
}
out := make([]map[string]any, 0, len(rawPeers))
for _, p := range rawPeers {
m, ok := p.(map[string]any)
if !ok {
t.Fatalf("peer is not an object: %T", p)
}
out = append(out, m)
}
return out
}
func TestGetXrayConfigWireGuardPeers(t *testing.T) {
clients := []model.Client{
{Email: "alice@wg.test", Enable: true, PublicKey: "pub-alice", AllowedIPs: []string{"10.0.0.2/32"}, KeepAlive: 25},
{Email: "bob@wg.test", Enable: true, PublicKey: "pub-bob", AllowedIPs: []string{"10.0.0.3/32"}},
}
seedWGInbound(t, "wg-multi", 51820, clients)
settings := wgInboundEmittedSettings(t, "wg-multi")
if settings["secretKey"] != wgTestSecretKey() {
t.Errorf("secretKey not preserved: %v", settings["secretKey"])
}
if settings["mtu"] != float64(1420) {
t.Errorf("mtu not preserved: %v", settings["mtu"])
}
peers := wgPeerList(t, settings)
if len(peers) != 2 {
t.Fatalf("expected 2 peers, got %d: %v", len(peers), peers)
}
ips := map[string]bool{}
for _, p := range peers {
if p["email"] == nil || p["email"] == "" {
t.Errorf("peer missing email: %v", p)
}
if p["publicKey"] == nil || p["publicKey"] == "" {
t.Errorf("peer missing publicKey: %v", p)
}
if p["level"] != float64(0) {
t.Errorf("peer level = %v, want 0 (needed for per-user stats)", p["level"])
}
allowed, ok := p["allowedIPs"].([]any)
if !ok || len(allowed) == 0 {
t.Fatalf("peer missing allowedIPs: %v", p)
}
ips[allowed[0].(string)] = true
}
if len(ips) != 2 {
t.Errorf("peers must have distinct allowedIPs, got %v", ips)
}
}
func TestGetXrayConfigWireGuardDisabledClientExcluded(t *testing.T) {
clients := []model.Client{
{Email: "on@wg.test", Enable: true, PublicKey: "pub-on", AllowedIPs: []string{"10.0.0.2/32"}},
{Email: "off@wg.test", Enable: true, PublicKey: "pub-off", AllowedIPs: []string{"10.0.0.3/32"}},
}
seedWGInbound(t, "wg-disabled", 51821, clients)
if err := database.GetDB().Model(&model.ClientRecord{}).
Where("email = ?", "off@wg.test").Update("enable", false).Error; err != nil {
t.Fatalf("disable client: %v", err)
}
peers := wgPeerList(t, wgInboundEmittedSettings(t, "wg-disabled"))
if len(peers) != 1 {
t.Fatalf("expected 1 enabled peer, got %d: %v", len(peers), peers)
}
if peers[0]["email"] != "on@wg.test" {
t.Errorf("wrong peer kept: %v", peers[0])
}
}
func TestGetXrayConfigWireGuardNoClientsEmitsEmptyPeers(t *testing.T) {
seedWGInbound(t, "wg-empty", 51822, nil)
settings := wgInboundEmittedSettings(t, "wg-empty")
if _, ok := settings["clients"]; ok {
t.Fatalf("clients key must be absent")
}
peers, ok := settings["peers"].([]any)
if !ok {
t.Fatalf("peers must be an (empty) array, got %T", settings["peers"])
}
if len(peers) != 0 {
t.Fatalf("expected empty peers, got %v", peers)
}
}
+4
View File
@@ -885,6 +885,10 @@
"uuid": "UUID",
"flow": "Flow",
"vmessSecurity": "أمان VMess",
"wireguardPrivateKey": "مفتاح وايرغارد الخاص",
"wireguardPublicKey": "مفتاح وايرغارد العام",
"wireguardPreSharedKey": "مفتاح وايرغارد المشترك مسبقًا",
"wireguardAllowedIPs": "عناوين IP المسموحة لوايرغارد",
"reverseTag": "وسم عكسي",
"reverseTagPlaceholder": "Reverse tag اختياري",
"telegramId": "معرّف مستخدم تلغرام",
+4
View File
@@ -888,6 +888,10 @@
"uuid": "UUID",
"flow": "Flow",
"vmessSecurity": "VMess Security",
"wireguardPrivateKey": "WireGuard Private Key",
"wireguardPublicKey": "WireGuard Public Key",
"wireguardPreSharedKey": "WireGuard Pre-Shared Key",
"wireguardAllowedIPs": "WireGuard Allowed IPs",
"reverseTag": "Reverse tag",
"reverseTagPlaceholder": "Optional reverse tag",
"telegramId": "Telegram user ID",
+4
View File
@@ -885,6 +885,10 @@
"uuid": "UUID",
"flow": "Flow",
"vmessSecurity": "Seguridad VMess",
"wireguardPrivateKey": "Clave privada de WireGuard",
"wireguardPublicKey": "Clave pública de WireGuard",
"wireguardPreSharedKey": "Clave precompartida de WireGuard",
"wireguardAllowedIPs": "IP permitidas de WireGuard",
"reverseTag": "Etiqueta inversa",
"reverseTagPlaceholder": "Reverse tag opcional",
"telegramId": "ID de usuario de Telegram",
+4
View File
@@ -885,6 +885,10 @@
"uuid": "UUID",
"flow": "Flow",
"vmessSecurity": "امنیت VMess",
"wireguardPrivateKey": "کلید خصوصی وایرگارد",
"wireguardPublicKey": "کلید عمومی وایرگارد",
"wireguardPreSharedKey": "کلید پیش‌اشتراکی وایرگارد",
"wireguardAllowedIPs": "آی‌پی‌های مجاز وایرگارد",
"reverseTag": "تگ معکوس",
"reverseTagPlaceholder": "Reverse tag اختیاری",
"telegramId": "شناسه کاربر تلگرام",
+4
View File
@@ -885,6 +885,10 @@
"uuid": "UUID",
"flow": "Flow",
"vmessSecurity": "Keamanan VMess",
"wireguardPrivateKey": "Kunci Privat WireGuard",
"wireguardPublicKey": "Kunci Publik WireGuard",
"wireguardPreSharedKey": "Kunci Pra-Berbagi WireGuard",
"wireguardAllowedIPs": "IP yang Diizinkan WireGuard",
"reverseTag": "Reverse tag",
"reverseTagPlaceholder": "Reverse tag opsional",
"telegramId": "ID pengguna Telegram",
+4
View File
@@ -885,6 +885,10 @@
"uuid": "UUID",
"flow": "Flow",
"vmessSecurity": "VMess セキュリティ",
"wireguardPrivateKey": "WireGuard 秘密鍵",
"wireguardPublicKey": "WireGuard 公開鍵",
"wireguardPreSharedKey": "WireGuard 事前共有鍵",
"wireguardAllowedIPs": "WireGuard 許可IP",
"reverseTag": "Reverse tag",
"reverseTagPlaceholder": "任意の Reverse tag",
"telegramId": "Telegram ユーザー ID",
+4
View File
@@ -885,6 +885,10 @@
"uuid": "UUID",
"flow": "Flow",
"vmessSecurity": "Segurança VMess",
"wireguardPrivateKey": "Chave privada do WireGuard",
"wireguardPublicKey": "Chave pública do WireGuard",
"wireguardPreSharedKey": "Chave pré-compartilhada do WireGuard",
"wireguardAllowedIPs": "IPs permitidos do WireGuard",
"reverseTag": "Tag reversa",
"reverseTagPlaceholder": "Reverse tag opcional",
"telegramId": "ID de usuário do Telegram",
+4
View File
@@ -885,6 +885,10 @@
"uuid": "UUID",
"flow": "Flow",
"vmessSecurity": "VMess Security",
"wireguardPrivateKey": "Приватный ключ WireGuard",
"wireguardPublicKey": "Публичный ключ WireGuard",
"wireguardPreSharedKey": "Общий ключ WireGuard",
"wireguardAllowedIPs": "Разрешённые IP WireGuard",
"reverseTag": "Обратный тег",
"reverseTagPlaceholder": "Необязательный Reverse tag",
"telegramId": "ID пользователя Telegram",
+4
View File
@@ -885,6 +885,10 @@
"uuid": "UUID",
"flow": "Flow",
"vmessSecurity": "VMess Güvenlik",
"wireguardPrivateKey": "WireGuard Özel Anahtarı",
"wireguardPublicKey": "WireGuard Genel Anahtarı",
"wireguardPreSharedKey": "WireGuard Ön Paylaşımlı Anahtar",
"wireguardAllowedIPs": "WireGuard İzin Verilen IP'ler",
"reverseTag": "Reverse Tag",
"reverseTagPlaceholder": "İsteğe Bağlı Reverse Tag",
"telegramId": "Telegram Kullanıcı ID'si",
+4
View File
@@ -885,6 +885,10 @@
"uuid": "UUID",
"flow": "Flow",
"vmessSecurity": "Безпека VMess",
"wireguardPrivateKey": "Приватний ключ WireGuard",
"wireguardPublicKey": "Публічний ключ WireGuard",
"wireguardPreSharedKey": "Спільний ключ WireGuard",
"wireguardAllowedIPs": "Дозволені IP WireGuard",
"reverseTag": "Зворотний тег",
"reverseTagPlaceholder": "Необов'язковий Reverse tag",
"telegramId": "ID користувача Telegram",
+4
View File
@@ -885,6 +885,10 @@
"uuid": "UUID",
"flow": "Flow",
"vmessSecurity": "Bảo mật VMess",
"wireguardPrivateKey": "Khóa riêng WireGuard",
"wireguardPublicKey": "Khóa công khai WireGuard",
"wireguardPreSharedKey": "Khóa chia sẻ trước WireGuard",
"wireguardAllowedIPs": "IP được phép WireGuard",
"reverseTag": "Reverse tag",
"reverseTagPlaceholder": "Reverse tag tùy chọn",
"telegramId": "ID người dùng Telegram",
+4
View File
@@ -885,6 +885,10 @@
"uuid": "UUID",
"flow": "Flow",
"vmessSecurity": "VMess 加密",
"wireguardPrivateKey": "WireGuard 私钥",
"wireguardPublicKey": "WireGuard 公钥",
"wireguardPreSharedKey": "WireGuard 预共享密钥",
"wireguardAllowedIPs": "WireGuard 允许的 IP",
"reverseTag": "反向标签",
"reverseTagPlaceholder": "可选 Reverse tag",
"telegramId": "Telegram 用户 ID",
+4
View File
@@ -885,6 +885,10 @@
"uuid": "UUID",
"flow": "Flow",
"vmessSecurity": "VMess 加密",
"wireguardPrivateKey": "WireGuard 私鑰",
"wireguardPublicKey": "WireGuard 公鑰",
"wireguardPreSharedKey": "WireGuard 預共用金鑰",
"wireguardAllowedIPs": "WireGuard 允許的 IP",
"reverseTag": "反向標籤",
"reverseTagPlaceholder": "選用 Reverse tag",
"telegramId": "Telegram 使用者 ID",
+102 -29
View File
@@ -18,6 +18,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
"github.com/xtls/xray-core/app/proxyman/command"
routerService "github.com/xtls/xray-core/app/router/command"
@@ -32,6 +33,7 @@ import (
"github.com/xtls/xray-core/proxy/trojan"
"github.com/xtls/xray-core/proxy/vless"
"github.com/xtls/xray-core/proxy/vmess"
wireguard "github.com/xtls/xray-core/proxy/wireguard"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
@@ -408,40 +410,62 @@ func ensureXrayAssetLocation() {
}
}
// AddUser adds a user to an inbound in the Xray core using the specified protocol and user data.
func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]any) error {
userEmail, err := getRequiredUserString(user, "email")
if err != nil {
return err
// collectStringSlice normalizes a JSON-decoded value into a slice of non-empty
// strings, accepting both []string (typed maps) and []any (json.Unmarshal output).
func collectStringSlice(value any) []string {
switch v := value.(type) {
case []string:
out := make([]string, 0, len(v))
for _, s := range v {
if s != "" {
out = append(out, s)
}
}
return out
case []any:
out := make([]string, 0, len(v))
for _, e := range v {
if s, ok := e.(string); ok && s != "" {
out = append(out, s)
}
}
return out
default:
return nil
}
}
var account *serial.TypedMessage
switch Protocol {
// buildUserAccount constructs the typed xray account for a user of the given
// protocol. It returns (nil, nil) for protocols that cannot be altered live so
// callers skip the AlterInbound call. WireGuard keys must be converted to the
// hex form xray's wireguard proxy expects (its ParseKey uses hex.DecodeString),
// unlike the file-config path which accepts base64 and converts internally.
func buildUserAccount(protocolName string, user map[string]any) (*serial.TypedMessage, error) {
switch protocolName {
case "vmess":
userID, err := getRequiredUserString(user, "id")
if err != nil {
return err
return nil, err
}
account = serial.ToTypedMessage(&vmess.Account{
return serial.ToTypedMessage(&vmess.Account{
Id: userID,
})
}), nil
case "vless":
userID, err := getRequiredUserString(user, "id")
if err != nil {
return err
return nil, err
}
userFlow, err := getOptionalUserString(user, "flow")
if err != nil {
return err
return nil, err
}
vlessAccount := &vless.Account{
Id: userID,
Flow: userFlow,
}
// Add testseed if provided
if testseedVal, ok := user["testseed"]; ok {
if testseedArr, ok := testseedVal.([]any); ok && len(testseedArr) >= 4 {
testseed := make([]uint32, len(testseedArr))
@@ -455,7 +479,6 @@ func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]an
vlessAccount.Testseed = testseedArr
}
}
// Add testpre if provided (for outbound, but can be in user for compatibility)
if testpreVal, ok := user["testpre"]; ok {
if testpre, ok := testpreVal.(float64); ok && testpre > 0 {
vlessAccount.Testpre = uint32(testpre)
@@ -463,25 +486,25 @@ func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]an
vlessAccount.Testpre = testpre
}
}
account = serial.ToTypedMessage(vlessAccount)
return serial.ToTypedMessage(vlessAccount), nil
case "trojan":
password, err := getRequiredUserString(user, "password")
if err != nil {
return err
return nil, err
}
account = serial.ToTypedMessage(&trojan.Account{
return serial.ToTypedMessage(&trojan.Account{
Password: password,
})
}), nil
case "shadowsocks":
cipher, err := getOptionalUserString(user, "cipher")
if err != nil {
return err
return nil, err
}
password, err := getRequiredUserString(user, "password")
if err != nil {
return err
return nil, err
}
var ssCipherType shadowsocks.CipherType
@@ -497,25 +520,75 @@ func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]an
}
if ssCipherType != shadowsocks.CipherType_NONE {
account = serial.ToTypedMessage(&shadowsocks.Account{
return serial.ToTypedMessage(&shadowsocks.Account{
Password: password,
CipherType: ssCipherType,
})
} else {
account = serial.ToTypedMessage(&shadowsocks_2022.Account{
Key: password,
})
}), nil
}
return serial.ToTypedMessage(&shadowsocks_2022.Account{
Key: password,
}), nil
case "hysteria":
auth, err := getRequiredUserString(user, "auth")
if err != nil {
return err
return nil, err
}
account = serial.ToTypedMessage(&hysteriaAccount.Account{
return serial.ToTypedMessage(&hysteriaAccount.Account{
Auth: auth,
})
}), nil
case "wireguard":
pubB64, err := getRequiredUserString(user, "publicKey")
if err != nil {
return nil, err
}
pubHex, err := wgutil.KeyToHex(pubB64)
if err != nil {
return nil, fmt.Errorf("wireguard publicKey: %w", err)
}
pskB64, err := getOptionalUserString(user, "preSharedKey")
if err != nil {
return nil, err
}
pskHex, err := wgutil.KeyToHex(pskB64)
if err != nil {
return nil, fmt.Errorf("wireguard preSharedKey: %w", err)
}
allowed := collectStringSlice(user["allowedIPs"])
if len(allowed) == 0 {
return nil, common.NewError("wireguard: allowedIPs required")
}
keepAlive, err := getOptionalUserString(user, "keepAlive")
if err != nil {
return nil, err
}
return serial.ToTypedMessage(&wireguard.PeerConfig{
PublicKey: pubHex,
PreSharedKey: pskHex,
AllowedIps: allowed,
KeepAlive: keepAlive,
}), nil
default:
return nil, nil
}
}
// AddUser adds a user to an inbound in the Xray core using the specified protocol and user data.
func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]any) error {
userEmail, err := getRequiredUserString(user, "email")
if err != nil {
return err
}
account, err := buildUserAccount(Protocol, user)
if err != nil {
return err
}
if account == nil {
return nil
}
+129
View File
@@ -0,0 +1,129 @@
package xray
import (
"encoding/base64"
"encoding/hex"
"testing"
wireguard "github.com/xtls/xray-core/proxy/wireguard"
"google.golang.org/protobuf/proto"
)
func b64Key(seed byte) string {
raw := make([]byte, 32)
for i := range raw {
raw[i] = seed + byte(i)
}
return base64.StdEncoding.EncodeToString(raw)
}
func decodeWgAccount(t *testing.T, user map[string]any) *wireguard.PeerConfig {
t.Helper()
tm, err := buildUserAccount("wireguard", user)
if err != nil {
t.Fatalf("buildUserAccount: %v", err)
}
if tm == nil {
t.Fatal("buildUserAccount returned nil account for wireguard")
}
var pc wireguard.PeerConfig
if err := proto.Unmarshal(tm.Value, &pc); err != nil {
t.Fatalf("unmarshal PeerConfig: %v", err)
}
return &pc
}
func assertHexKey(t *testing.T, label, value string) {
t.Helper()
if len(value) != 64 {
t.Fatalf("%s = %q, want 64-char hex", label, value)
}
if raw, err := hex.DecodeString(value); err != nil || len(raw) != 32 {
t.Fatalf("%s is not a 32-byte hex key: err=%v len=%d", label, err, len(raw))
}
}
func TestBuildUserAccountWireGuardHexConversion(t *testing.T) {
pub := b64Key(1)
psk := b64Key(100)
user := map[string]any{
"email": "alice@example.test",
"publicKey": pub,
"preSharedKey": psk,
"allowedIPs": []any{"10.0.0.2/32", "fd00::2/128"},
"keepAlive": "25",
}
pc := decodeWgAccount(t, user)
assertHexKey(t, "PublicKey", pc.PublicKey)
assertHexKey(t, "PreSharedKey", pc.PreSharedKey)
wantPubHex, _ := hex.DecodeString(pc.PublicKey)
gotPub, _ := base64.StdEncoding.DecodeString(pub)
if string(wantPubHex) != string(gotPub) {
t.Fatal("PublicKey hex does not match the base64 input bytes")
}
if len(pc.AllowedIps) != 2 || pc.AllowedIps[0] != "10.0.0.2/32" || pc.AllowedIps[1] != "fd00::2/128" {
t.Fatalf("AllowedIps = %v, want [10.0.0.2/32 fd00::2/128]", pc.AllowedIps)
}
if pc.KeepAlive != "25" {
t.Fatalf("KeepAlive = %q, want %q", pc.KeepAlive, "25")
}
}
func TestBuildUserAccountWireGuardNoPSK(t *testing.T) {
user := map[string]any{
"email": "bob@example.test",
"publicKey": b64Key(2),
"allowedIPs": []string{"10.0.0.3/32"},
}
pc := decodeWgAccount(t, user)
if pc.PreSharedKey != "" {
t.Fatalf("PreSharedKey = %q, want empty", pc.PreSharedKey)
}
if pc.KeepAlive != "" {
t.Fatalf("KeepAlive = %q, want empty", pc.KeepAlive)
}
}
func TestBuildUserAccountWireGuardMissingPublicKey(t *testing.T) {
user := map[string]any{
"email": "c@example.test",
"allowedIPs": []any{"10.0.0.4/32"},
}
if _, err := buildUserAccount("wireguard", user); err == nil {
t.Fatal("expected error for missing publicKey")
}
}
func TestBuildUserAccountWireGuardMissingAllowedIPs(t *testing.T) {
user := map[string]any{
"email": "d@example.test",
"publicKey": b64Key(3),
}
if _, err := buildUserAccount("wireguard", user); err == nil {
t.Fatal("expected error for missing allowedIPs")
}
}
func TestBuildUserAccountWireGuardBadKey(t *testing.T) {
user := map[string]any{
"email": "e@example.test",
"publicKey": "not-a-valid-key",
"allowedIPs": []any{"10.0.0.5/32"},
}
if _, err := buildUserAccount("wireguard", user); err == nil {
t.Fatal("expected error for invalid publicKey")
}
}
func TestBuildUserAccountUnknownProtocolReturnsNil(t *testing.T) {
tm, err := buildUserAccount("mtproto", map[string]any{"email": "x@example.test"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tm != nil {
t.Fatal("expected nil account for unsupported protocol")
}
}