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
+12
View File
@@ -212,6 +212,9 @@ export const EXAMPLES: Record<string, unknown> = {
"token": "new-token-string"
},
"Client": {
"allowedIPs": [
""
],
"auth": "",
"comment": "",
"created_at": 0,
@@ -221,8 +224,12 @@ export const EXAMPLES: Record<string, unknown> = {
"flow": "",
"group": "",
"id": "",
"keepAlive": 0,
"limitIp": 0,
"password": "",
"preSharedKey": "",
"privateKey": "",
"publicKey": "",
"reset": 0,
"reverse": null,
"security": "",
@@ -238,6 +245,7 @@ export const EXAMPLES: Record<string, unknown> = {
"inboundId": 0
},
"ClientRecord": {
"allowedIPs": "",
"auth": "",
"comment": "",
"createdAt": 0,
@@ -247,8 +255,12 @@ export const EXAMPLES: Record<string, unknown> = {
"flow": "",
"group": "",
"id": 0,
"keepAlive": 0,
"limitIp": 0,
"password": "",
"preSharedKey": "",
"privateKey": "",
"publicKey": "",
"reset": 0,
"reverse": null,
"security": "",
+38
View File
@@ -1058,6 +1058,12 @@ export const SCHEMAS: Record<string, unknown> = {
"Client": {
"description": "Client represents a client configuration for Xray inbounds with traffic limits and settings.",
"properties": {
"allowedIPs": {
"items": {
"type": "string"
},
"type": "array"
},
"auth": {
"description": "Auth password (Hysteria)",
"type": "string"
@@ -1094,6 +1100,9 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Unique client identifier",
"type": "string"
},
"keepAlive": {
"type": "integer"
},
"limitIp": {
"description": "IP limit for this client",
"type": "integer"
@@ -1102,6 +1111,15 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Client password",
"type": "string"
},
"preSharedKey": {
"type": "string"
},
"privateKey": {
"type": "string"
},
"publicKey": {
"type": "string"
},
"reset": {
"description": "Reset period in days",
"type": "integer"
@@ -1175,6 +1193,9 @@ export const SCHEMAS: Record<string, unknown> = {
},
"ClientRecord": {
"properties": {
"allowedIPs": {
"type": "string"
},
"auth": {
"type": "string"
},
@@ -1202,12 +1223,24 @@ export const SCHEMAS: Record<string, unknown> = {
"id": {
"type": "integer"
},
"keepAlive": {
"type": "integer"
},
"limitIp": {
"type": "integer"
},
"password": {
"type": "string"
},
"preSharedKey": {
"type": "string"
},
"privateKey": {
"type": "string"
},
"publicKey": {
"type": "string"
},
"reset": {
"type": "integer"
},
@@ -1232,6 +1265,7 @@ export const SCHEMAS: Record<string, unknown> = {
}
},
"required": [
"allowedIPs",
"auth",
"comment",
"createdAt",
@@ -1241,8 +1275,12 @@ export const SCHEMAS: Record<string, unknown> = {
"flow",
"group",
"id",
"keepAlive",
"limitIp",
"password",
"preSharedKey",
"privateKey",
"publicKey",
"reset",
"reverse",
"security",
+10
View File
@@ -222,6 +222,7 @@ export interface ApiTokenView {
}
export interface Client {
allowedIPs?: string[];
auth?: string;
comment: string;
created_at?: number;
@@ -231,8 +232,12 @@ export interface Client {
flow?: string;
group?: string;
id?: string;
keepAlive?: number;
limitIp: number;
password?: string;
preSharedKey?: string;
privateKey?: string;
publicKey?: string;
reset: number;
reverse?: ClientReverse | null;
security: string;
@@ -250,6 +255,7 @@ export interface ClientInbound {
}
export interface ClientRecord {
allowedIPs: string;
auth: string;
comment: string;
createdAt: number;
@@ -259,8 +265,12 @@ export interface ClientRecord {
flow: string;
group: string;
id: number;
keepAlive: number;
limitIp: number;
password: string;
preSharedKey: string;
privateKey: string;
publicKey: string;
reset: number;
reverse: unknown;
security: string;
+10
View File
@@ -238,6 +238,7 @@ export const ApiTokenViewSchema = z.object({
export type ApiTokenView = z.infer<typeof ApiTokenViewSchema>;
export const ClientSchema = z.object({
allowedIPs: z.array(z.string()).optional(),
auth: z.string().optional(),
comment: z.string(),
created_at: z.number().int().optional(),
@@ -247,8 +248,12 @@ export const ClientSchema = z.object({
flow: z.string().optional(),
group: z.string().optional(),
id: z.string().optional(),
keepAlive: z.number().int().optional(),
limitIp: z.number().int(),
password: z.string().optional(),
preSharedKey: z.string().optional(),
privateKey: z.string().optional(),
publicKey: z.string().optional(),
reset: z.number().int(),
reverse: z.lazy(() => ClientReverseSchema).nullable().optional(),
security: z.string(),
@@ -268,6 +273,7 @@ export const ClientInboundSchema = z.object({
export type ClientInbound = z.infer<typeof ClientInboundSchema>;
export const ClientRecordSchema = z.object({
allowedIPs: z.string(),
auth: z.string(),
comment: z.string(),
createdAt: z.number().int(),
@@ -277,8 +283,12 @@ export const ClientRecordSchema = z.object({
flow: z.string(),
group: z.string(),
id: z.number().int(),
keepAlive: z.number().int(),
limitIp: z.number().int(),
password: z.string(),
preSharedKey: z.string(),
privateKey: z.string(),
publicKey: z.string(),
reset: z.number().int(),
reverse: z.unknown(),
security: z.string(),