feat(wireguard): multi-client support

WireGuard inbounds now manage per-client peers using xray-core's native WireGuard users (AddUser/RemoveUser). Each client lives in settings.clients (canonical, like every other protocol) and is projected to peers[] only when emitting the xray config, at level 0 so the dispatcher's per-user traffic/online counters work with no extra plumbing.

Backend: internal/util/wireguard gains KeyToHex (base64 to hex for the gRPC path), PublicKeyFromPrivate and GenerateWireguardPSK; xray/api.go builds a wireguard account in AddUser with hex keys (RemoveUser already worked); client CRUD generates a keypair and allocates a unique tunnel address per client and never rotates keys on edit; an idempotent migration converts legacy settings.peers into managed clients; WireGuard is included in the raw subscription.

Frontend: WireGuard in the add-client modal with keys on the credential tab, client schema, per-client QR/link/.conf, inbound form reduced to server settings; i18n added across 13 locales.

Fix: guard the settings[clients] assertion in add/update so a legacy WireGuard inbound stored without a clients key no longer panics.
This commit is contained in:
MHSanaei
2026-06-28 00:44:38 +02:00
parent 33aada0c7c
commit 9c8cd08f90
50 changed files with 2160 additions and 258 deletions
+73 -16
View File
@@ -295,6 +295,16 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
return false, err
}
if oldInbound.Protocol == model.WireGuard {
existing, gcErr := inboundSvc.GetClients(oldInbound)
if gcErr != nil {
return false, gcErr
}
if dErr := defaultWireguardClients(existing, clients, interfaceClients); dErr != nil {
return false, dErr
}
}
for _, client := range clients {
if strings.TrimSpace(client.Email) == "" {
return false, common.NewError("client email is required")
@@ -312,6 +322,10 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
if client.Auth == "" {
return false, common.NewError("empty client ID")
}
case "wireguard":
if client.PublicKey == "" {
return false, common.NewError("wireguard client requires a key")
}
default:
if client.ID == "" {
return false, common.NewError("empty client ID")
@@ -329,7 +343,7 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
applyShadowsocksClientMethod(interfaceClients, oldSettings)
}
oldClients := oldSettings["clients"].([]any)
oldClients, _ := oldSettings["clients"].([]any)
oldClients = compactOrphans(database.GetDB(), oldClients)
oldClients = append(oldClients, interfaceClients...)
@@ -395,13 +409,17 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
cipher = oldSettings["method"].(string)
}
err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
"email": client.Email,
"id": client.ID,
"auth": client.Auth,
"security": client.Security,
"flow": client.Flow,
"password": client.Password,
"cipher": cipher,
"email": client.Email,
"id": client.ID,
"auth": client.Auth,
"security": client.Security,
"flow": client.Flow,
"password": client.Password,
"cipher": cipher,
"publicKey": client.PublicKey,
"allowedIPs": client.AllowedIPs,
"preSharedKey": client.PreSharedKey,
"keepAlive": keepAliveStr(client.KeepAlive),
})
if err1 == nil {
logger.Debug("Client added on", rt.Name(), ":", client.Email)
@@ -472,6 +490,8 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
newClientId = clients[0].Email
case "hysteria":
newClientId = clients[0].Auth
case "wireguard":
newClientId = clients[0].Email
default:
newClientId = clients[0].ID
}
@@ -505,12 +525,34 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
}
}
// WireGuard keys are never rotated by an edit: when the incoming payload omits
// them (a metadata-only change), carry the stored credentials forward so the
// settings JSON and the running peer keep the client's identity.
if oldInbound.Protocol == model.WireGuard && clientIndex >= 0 && clientIndex < len(oldClients) {
old := oldClients[clientIndex]
if clients[0].PrivateKey == "" {
clients[0].PrivateKey = old.PrivateKey
}
if clients[0].PublicKey == "" {
clients[0].PublicKey = old.PublicKey
}
if len(clients[0].AllowedIPs) == 0 {
clients[0].AllowedIPs = old.AllowedIPs
}
if clients[0].PreSharedKey == "" {
clients[0].PreSharedKey = old.PreSharedKey
}
if clients[0].KeepAlive == 0 {
clients[0].KeepAlive = old.KeepAlive
}
}
var oldSettings map[string]any
err = json.Unmarshal([]byte(oldInbound.Settings), &oldSettings)
if err != nil {
return false, err
}
settingsClients := oldSettings["clients"].([]any)
settingsClients, _ := oldSettings["clients"].([]any)
var preservedCreated any
var preservedSubID string
if clientIndex >= 0 && clientIndex < len(settingsClients) {
@@ -536,6 +578,17 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
newMap["subId"] = random.NumLower(16)
}
}
if oldInbound.Protocol == model.WireGuard {
newMap["privateKey"] = clients[0].PrivateKey
newMap["publicKey"] = clients[0].PublicKey
newMap["allowedIPs"] = clients[0].AllowedIPs
if clients[0].PreSharedKey != "" {
newMap["preSharedKey"] = clients[0].PreSharedKey
}
if clients[0].KeepAlive > 0 {
newMap["keepAlive"] = clients[0].KeepAlive
}
}
interfaceClients[0] = newMap
}
}
@@ -681,13 +734,17 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
cipher = oldSettings["method"].(string)
}
err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
"email": clients[0].Email,
"id": clients[0].ID,
"security": clients[0].Security,
"flow": clients[0].Flow,
"auth": clients[0].Auth,
"password": clients[0].Password,
"cipher": cipher,
"email": clients[0].Email,
"id": clients[0].ID,
"security": clients[0].Security,
"flow": clients[0].Flow,
"auth": clients[0].Auth,
"password": clients[0].Password,
"cipher": cipher,
"publicKey": clients[0].PublicKey,
"allowedIPs": clients[0].AllowedIPs,
"preSharedKey": clients[0].PreSharedKey,
"keepAlive": keepAliveStr(clients[0].KeepAlive),
})
if err1 == nil {
logger.Debug("Client edited on", rt.Name(), ":", clients[0].Email)