mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-26 06:16:12 +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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user