mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-10 13:21:00 +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)
|
||||
}
|
||||
}
|
||||
+44
-1
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -885,6 +885,10 @@
|
||||
"uuid": "UUID",
|
||||
"flow": "Flow",
|
||||
"vmessSecurity": "أمان VMess",
|
||||
"wireguardPrivateKey": "مفتاح وايرغارد الخاص",
|
||||
"wireguardPublicKey": "مفتاح وايرغارد العام",
|
||||
"wireguardPreSharedKey": "مفتاح وايرغارد المشترك مسبقًا",
|
||||
"wireguardAllowedIPs": "عناوين IP المسموحة لوايرغارد",
|
||||
"reverseTag": "وسم عكسي",
|
||||
"reverseTagPlaceholder": "Reverse tag اختياري",
|
||||
"telegramId": "معرّف مستخدم تلغرام",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -885,6 +885,10 @@
|
||||
"uuid": "UUID",
|
||||
"flow": "Flow",
|
||||
"vmessSecurity": "امنیت VMess",
|
||||
"wireguardPrivateKey": "کلید خصوصی وایرگارد",
|
||||
"wireguardPublicKey": "کلید عمومی وایرگارد",
|
||||
"wireguardPreSharedKey": "کلید پیشاشتراکی وایرگارد",
|
||||
"wireguardAllowedIPs": "آیپیهای مجاز وایرگارد",
|
||||
"reverseTag": "تگ معکوس",
|
||||
"reverseTagPlaceholder": "Reverse tag اختیاری",
|
||||
"telegramId": "شناسه کاربر تلگرام",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user