Sfoglia il codice sorgente

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.
MHSanaei 15 ore fa
parent
commit
9c8cd08f90
50 ha cambiato i file con 2160 aggiunte e 258 eliminazioni
  1. 38 0
      frontend/public/openapi.json
  2. 12 0
      frontend/src/generated/examples.ts
  3. 38 0
      frontend/src/generated/schemas.ts
  4. 10 0
      frontend/src/generated/types.ts
  5. 10 0
      frontend/src/generated/zod.ts
  6. 6 10
      frontend/src/lib/xray/inbound-defaults.ts
  7. 2 0
      frontend/src/lib/xray/inbound-form-adapter.ts
  8. 23 4
      frontend/src/lib/xray/inbound-link.ts
  9. 1 1
      frontend/src/pages/clients/ClientBulkAddModal.tsx
  10. 75 2
      frontend/src/pages/clients/ClientFormModal.tsx
  11. 1 7
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  12. 3 123
      frontend/src/pages/inbounds/form/protocols/wireguard.tsx
  13. 5 0
      frontend/src/schemas/client.ts
  14. 26 0
      frontend/src/schemas/protocols/inbound/wireguard.ts
  15. 2 10
      frontend/src/test/__snapshots__/inbound-defaults.test.ts.snap
  16. 1 0
      frontend/src/test/__snapshots__/inbound-full.test.ts.snap
  17. 1 0
      frontend/src/test/__snapshots__/protocols.test.ts.snap
  18. 2 1
      frontend/src/test/inbound-defaults.test.ts
  19. 86 0
      frontend/src/test/wireguard-clients-link.test.ts
  20. 148 1
      internal/database/db.go
  21. 106 37
      internal/database/model/model.go
  22. 81 0
      internal/database/model/model_wireguard_test.go
  23. 190 0
      internal/database/wireguard_migration_test.go
  24. 44 1
      internal/sub/service.go
  25. 102 0
      internal/sub/service_wireguard_test.go
  26. 75 0
      internal/util/wireguard/wireguard.go
  27. 123 0
      internal/util/wireguard/wireguard_test.go
  28. 28 12
      internal/web/runtime/local.go
  29. 73 16
      internal/web/service/client_inbound_apply.go
  30. 11 0
      internal/web/service/client_link.go
  31. 115 0
      internal/web/service/client_wireguard.go
  32. 144 0
      internal/web/service/client_wireguard_crud_test.go
  33. 110 0
      internal/web/service/client_wireguard_test.go
  34. 31 4
      internal/web/service/xray.go
  35. 154 0
      internal/web/service/xray_wireguard_config_test.go
  36. 4 0
      internal/web/translation/ar-EG.json
  37. 4 0
      internal/web/translation/en-US.json
  38. 4 0
      internal/web/translation/es-ES.json
  39. 4 0
      internal/web/translation/fa-IR.json
  40. 4 0
      internal/web/translation/id-ID.json
  41. 4 0
      internal/web/translation/ja-JP.json
  42. 4 0
      internal/web/translation/pt-BR.json
  43. 4 0
      internal/web/translation/ru-RU.json
  44. 4 0
      internal/web/translation/tr-TR.json
  45. 4 0
      internal/web/translation/uk-UA.json
  46. 4 0
      internal/web/translation/vi-VN.json
  47. 4 0
      internal/web/translation/zh-CN.json
  48. 4 0
      internal/web/translation/zh-TW.json
  49. 102 29
      internal/xray/api.go
  50. 129 0
      internal/xray/api_wireguard_test.go

+ 38 - 0
frontend/public/openapi.json

@@ -1084,6 +1084,12 @@
       "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"
@@ -1120,6 +1126,9 @@
             "description": "Unique client identifier",
             "type": "string"
           },
+          "keepAlive": {
+            "type": "integer"
+          },
           "limitIp": {
             "description": "IP limit for this client",
             "type": "integer"
@@ -1128,6 +1137,15 @@
             "description": "Client password",
             "type": "string"
           },
+          "preSharedKey": {
+            "type": "string"
+          },
+          "privateKey": {
+            "type": "string"
+          },
+          "publicKey": {
+            "type": "string"
+          },
           "reset": {
             "description": "Reset period in days",
             "type": "integer"
@@ -1201,6 +1219,9 @@
       },
       "ClientRecord": {
         "properties": {
+          "allowedIPs": {
+            "type": "string"
+          },
           "auth": {
             "type": "string"
           },
@@ -1228,12 +1249,24 @@
           "id": {
             "type": "integer"
           },
+          "keepAlive": {
+            "type": "integer"
+          },
           "limitIp": {
             "type": "integer"
           },
           "password": {
             "type": "string"
           },
+          "preSharedKey": {
+            "type": "string"
+          },
+          "privateKey": {
+            "type": "string"
+          },
+          "publicKey": {
+            "type": "string"
+          },
           "reset": {
             "type": "integer"
           },
@@ -1258,6 +1291,7 @@
           }
         },
         "required": [
+          "allowedIPs",
           "auth",
           "comment",
           "createdAt",
@@ -1267,8 +1301,12 @@
           "flow",
           "group",
           "id",
+          "keepAlive",
           "limitIp",
           "password",
+          "preSharedKey",
+          "privateKey",
+          "publicKey",
           "reset",
           "reverse",
           "security",

+ 12 - 0
frontend/src/generated/examples.ts

@@ -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 - 0
frontend/src/generated/schemas.ts

@@ -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 - 0
frontend/src/generated/types.ts

@@ -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 - 0
frontend/src/generated/zod.ts

@@ -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(),

+ 6 - 10
frontend/src/lib/xray/inbound-defaults.ts

@@ -263,24 +263,20 @@ export interface WireguardInboundSeed {
   mtu?: number;
   secretKey?: string;
   noKernelTun?: boolean;
-  peerPrivateKey?: string;
 }
 
+// WireGuard is multi-client now: a new inbound holds only the server identity
+// (secretKey/mtu) and starts with no clients. Clients (peers) are added later
+// through the client modal, which generates each one's keypair and a unique
+// tunnel address. peers stays empty for backward-compatible parsing.
 export function createDefaultWireguardInboundSettings(
   seed: WireguardInboundSeed = {},
 ): WireguardInboundSettings {
-  const peerKp = seed.peerPrivateKey
-    ? { privateKey: seed.peerPrivateKey, publicKey: Wireguard.generateKeypair(seed.peerPrivateKey).publicKey }
-    : Wireguard.generateKeypair();
   return {
     mtu: seed.mtu ?? 1420,
     secretKey: seed.secretKey ?? Wireguard.generateKeypair().privateKey,
-    peers: [{
-      privateKey: peerKp.privateKey,
-      publicKey: peerKp.publicKey,
-      allowedIPs: ['10.0.0.2/32'],
-      keepAlive: 0,
-    }],
+    peers: [],
+    clients: [],
     noKernelTun: seed.noKernelTun ?? false,
   };
 }

+ 2 - 0
frontend/src/lib/xray/inbound-form-adapter.ts

@@ -6,6 +6,7 @@ import {
   TrojanClientSchema,
   VlessClientSchema,
   VmessClientSchema,
+  WireguardClientSchema,
 } from '@/schemas/protocols/inbound';
 import type { StreamSettings } from '@/schemas/api/inbound';
 import type { Sniffing } from '@/schemas/primitives';
@@ -234,6 +235,7 @@ function clientSchemaForProtocol(protocol: string): z.ZodType | null {
     case 'trojan': return TrojanClientSchema;
     case 'shadowsocks': return ShadowsocksClientSchema;
     case 'hysteria': return HysteriaClientSchema;
+    case 'wireguard': return WireguardClientSchema;
     default: return null;
   }
 }

+ 23 - 4
frontend/src/lib/xray/inbound-link.ts

@@ -1126,14 +1126,30 @@ export interface GenWireguardFanoutInput {
   fallbackHostname: string;
 }
 
+// WireGuard is multi-client: each client is one accepted peer. The canonical
+// store is settings.clients; legacy single-config inbounds (pre-migration) are
+// still rendered from settings.peers. Both carry the privateKey/allowedIPs/
+// preSharedKey/keepAlive the link and .conf need, so they project to the same
+// peer shape and reuse genWireguardLink/genWireguardConfig unchanged.
+function wgRenderPeers(settings: WireguardInboundSettings): WireguardInboundPeer[] {
+  const clients = settings.clients ?? [];
+  if (clients.length > 0) {
+    return clients.map((c) => ({ ...c, publicKey: c.publicKey ?? '' }));
+  }
+  return settings.peers;
+}
+
 export function genWireguardLinks(input: GenWireguardFanoutInput): string {
   const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
   if (inbound.protocol !== 'wireguard') return '';
   const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
   const sep = '-';
-  return inbound.settings.peers
+  const baseSettings = inbound.settings as WireguardInboundSettings;
+  const peers = wgRenderPeers(baseSettings);
+  const settings: WireguardInboundSettings = { ...baseSettings, peers };
+  return peers
     .map((p, i) => genWireguardLink({
-      settings: inbound.settings as WireguardInboundSettings,
+      settings,
       address: addr,
       port: inbound.port,
       remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(p)}`,
@@ -1147,9 +1163,12 @@ export function genWireguardConfigs(input: GenWireguardFanoutInput): string {
   if (inbound.protocol !== 'wireguard') return '';
   const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
   const sep = '-';
-  return inbound.settings.peers
+  const baseSettings = inbound.settings as WireguardInboundSettings;
+  const peers = wgRenderPeers(baseSettings);
+  const settings: WireguardInboundSettings = { ...baseSettings, peers };
+  return peers
     .map((p, i) => genWireguardConfig({
-      settings: inbound.settings as WireguardInboundSettings,
+      settings,
       address: addr,
       port: inbound.port,
       remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(p)}`,

+ 1 - 1
frontend/src/pages/clients/ClientBulkAddModal.tsx

@@ -16,7 +16,7 @@ import { ClientBulkAddFormSchema, type ClientBulkAddFormValues } from '@/schemas
 const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
 
 const MULTI_CLIENT_PROTOCOLS = new Set([
-  'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria',
+  'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'wireguard',
 ]);
 
 interface ClientBulkAddModalProps {

+ 75 - 2
frontend/src/pages/clients/ClientFormModal.tsx

@@ -22,7 +22,7 @@ import {
 import { DeleteOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, RetweetOutlined } from '@ant-design/icons';
 import dayjs from 'dayjs';
 import type { Dayjs } from 'dayjs';
-import { HttpUtil, RandomUtil } from '@/utils';
+import { HttpUtil, RandomUtil, Wireguard } from '@/utils';
 import { formatInboundLabel } from '@/lib/inbounds/label';
 import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
 import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
@@ -35,7 +35,7 @@ const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
 const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305', 'none', 'zero'] as const;
 
 const MULTI_CLIENT_PROTOCOLS = new Set([
-  'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria',
+  'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'wireguard',
 ]);
 
 const CLIENT_FORM_MODAL_Z_INDEX = 1000;
@@ -113,6 +113,10 @@ interface FormState {
   enable: boolean;
   inboundIds: number[];
   externalLinks: ExternalLinkRow[];
+  wgPrivateKey: string;
+  wgPublicKey: string;
+  wgPreSharedKey: string;
+  wgAllowedIPs: string;
 }
 
 function emptyForm(): FormState {
@@ -137,6 +141,10 @@ function emptyForm(): FormState {
     enable: true,
     inboundIds: [],
     externalLinks: [],
+    wgPrivateKey: '',
+    wgPublicKey: '',
+    wgPreSharedKey: '',
+    wgAllowedIPs: '',
   };
 }
 
@@ -237,6 +245,10 @@ export default function ClientFormModal({
         enable: !!client.enable,
         inboundIds: Array.isArray(attachedIds) ? [...attachedIds] : [],
         externalLinks: toExternalLinkRows(attachedExternalLinks),
+        wgPrivateKey: client.privateKey || '',
+        wgPublicKey: client.publicKey || '',
+        wgPreSharedKey: client.preSharedKey || '',
+        wgAllowedIPs: client.allowedIPs || '',
       };
       if (et < 0) {
         next.delayedStart = true;
@@ -250,6 +262,7 @@ export default function ClientFormModal({
       setForm(next);
       void loadIps();
     } else {
+      const wgKeypair = Wireguard.generateKeypair();
       setForm({
         ...emptyForm(),
         email: RandomUtil.randomLowerAndNum(10),
@@ -257,6 +270,8 @@ export default function ClientFormModal({
         subId: RandomUtil.randomLowerAndNum(16),
         password: RandomUtil.randomLowerAndNum(16),
         auth: RandomUtil.randomLowerAndNum(16),
+        wgPrivateKey: wgKeypair.privateKey,
+        wgPublicKey: wgKeypair.publicKey,
       });
     }
 
@@ -287,6 +302,14 @@ export default function ClientFormModal({
     return ids;
   }, [inbounds]);
 
+  const wireguardIds = useMemo(() => {
+    const ids = new Set<number>();
+    for (const row of inbounds || []) {
+      if (row && row.protocol === 'wireguard') ids.add(row.id);
+    }
+    return ids;
+  }, [inbounds]);
+
   const ss2022Method = useMemo(() => {
     for (const id of form.inboundIds || []) {
       const ib = (inbounds || []).find((row) => row.id === id);
@@ -317,6 +340,16 @@ export default function ClientFormModal({
     [form.inboundIds, vmessIds],
   );
 
+  const showWireguard = useMemo(
+    () => (form.inboundIds || []).some((id) => wireguardIds.has(id)),
+    [form.inboundIds, wireguardIds],
+  );
+
+  function regenerateWireguardKeys() {
+    const kp = Wireguard.generateKeypair();
+    setForm((prev) => ({ ...prev, wgPrivateKey: kp.privateKey, wgPublicKey: kp.publicKey }));
+  }
+
   useEffect(() => {
     if (!showFlow && form.flow) {
 
@@ -453,6 +486,14 @@ export default function ClientFormModal({
       clientPayload.reverse = { tag: reverseTag };
     }
 
+    if (showWireguard) {
+      clientPayload.privateKey = form.wgPrivateKey;
+      clientPayload.publicKey = form.wgPublicKey;
+      if (form.wgPreSharedKey) {
+        clientPayload.preSharedKey = form.wgPreSharedKey;
+      }
+    }
+
     const externalLinks: ExternalLinkInput[] = form.externalLinks
       .map((r) => ({ kind: r.kind, value: r.value.trim(), remark: '' }))
       .filter((r) => r.value !== '');
@@ -736,6 +777,38 @@ export default function ClientFormModal({
                         />
                       </Form.Item>
                     )}
+                    {showWireguard && (
+                      <>
+                        <Form.Item label={t('pages.clients.wireguardPrivateKey')}>
+                          <Space.Compact style={{ display: 'flex' }}>
+                            <Input
+                              value={form.wgPrivateKey}
+                              style={{ flex: 1 }}
+                              onChange={(e) => {
+                                const priv = e.target.value;
+                                update('wgPrivateKey', priv);
+                                update('wgPublicKey', priv ? Wireguard.generateKeypair(priv).publicKey : '');
+                              }}
+                            />
+                            <Button icon={<ReloadOutlined />} onClick={regenerateWireguardKeys} />
+                          </Space.Compact>
+                        </Form.Item>
+                        <Form.Item label={t('pages.clients.wireguardPublicKey')}>
+                          <Input value={form.wgPublicKey} disabled />
+                        </Form.Item>
+                        <Form.Item label={t('pages.clients.wireguardPreSharedKey')}>
+                          <Input
+                            value={form.wgPreSharedKey}
+                            onChange={(e) => update('wgPreSharedKey', e.target.value)}
+                          />
+                        </Form.Item>
+                        {isEdit && form.wgAllowedIPs && (
+                          <Form.Item label={t('pages.clients.wireguardAllowedIPs')}>
+                            <Input value={form.wgAllowedIPs} disabled />
+                          </Form.Item>
+                        )}
+                      </>
+                    )}
                   </>
                 ),
               },

+ 1 - 7
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -278,12 +278,6 @@ export default function InboundFormModal({
     form.setFieldValue(['settings', 'secretKey'], kp.privateKey);
   };
 
-  const regenWgPeerKeypair = (peerName: number) => {
-    const kp = Wireguard.generateKeypair();
-    form.setFieldValue(['settings', 'peers', peerName, 'privateKey'], kp.privateKey);
-    form.setFieldValue(['settings', 'peers', peerName, 'publicKey'], kp.publicKey);
-  };
-
   const matchesVlessAuth = (
     block: { id?: string; label?: string } | undefined | null,
     authId: string,
@@ -695,7 +689,7 @@ export default function InboundFormModal({
 
   const protocolTab = (
     <>
-      {protocol === Protocols.WIREGUARD && <WireguardFields wgPubKey={wgPubKey} regenInboundWg={regenInboundWg} regenWgPeerKeypair={regenWgPeerKeypair} />}
+      {protocol === Protocols.WIREGUARD && <WireguardFields wgPubKey={wgPubKey} regenInboundWg={regenInboundWg} />}
 
       {protocol === Protocols.TUN && <TunFields />}
 

+ 3 - 123
frontend/src/pages/inbounds/form/protocols/wireguard.tsx

@@ -1,44 +1,14 @@
 import { useTranslation } from 'react-i18next';
-import { Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
-import { MinusOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
-
-import { Wireguard } from '@/utils';
+import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
+import { ReloadOutlined } from '@ant-design/icons';
 
 interface WireguardFieldsProps {
   wgPubKey: string;
   regenInboundWg: () => void;
-  regenWgPeerKeypair: (name: number) => void;
-}
-
-function nextWgPeerAllowedIP(peers: Array<{ allowedIPs?: string[] }> | undefined): string {
-  const fallback = '10.0.0.2/32';
-  let maxInt = -1;
-  let prefix = 32;
-  for (const peer of peers ?? []) {
-    for (const ip of peer?.allowedIPs ?? []) {
-      const m = /^\s*(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,2}))?\s*$/.exec(String(ip));
-      if (!m) continue;
-      const octets = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
-      if (octets.some((o) => o > 255)) continue;
-      const asInt = octets[0] * 16777216 + octets[1] * 65536 + octets[2] * 256 + octets[3];
-      if (asInt > maxInt) {
-        maxInt = asInt;
-        prefix = m[5] !== undefined ? Math.min(Number(m[5]), 32) : 32;
-      }
-    }
-  }
-  if (maxInt < 0) return fallback;
-  const next = maxInt + 1;
-  const a = Math.floor(next / 16777216) % 256;
-  const b = Math.floor(next / 65536) % 256;
-  const c = Math.floor(next / 256) % 256;
-  const d = next % 256;
-  return `${a}.${b}.${c}.${d}/${prefix}`;
 }
 
-export default function WireguardFields({ wgPubKey, regenInboundWg, regenWgPeerKeypair }: WireguardFieldsProps) {
+export default function WireguardFields({ wgPubKey, regenInboundWg }: WireguardFieldsProps) {
   const { t } = useTranslation();
-  const form = Form.useFormInstance();
   return (
     <>
       <Form.Item label={t('pages.xray.wireguard.secretKey')}>
@@ -74,96 +44,6 @@ export default function WireguardFields({ wgPubKey, regenInboundWg, regenWgPeerK
           ]}
         />
       </Form.Item>
-      <Form.List name={['settings', 'peers']}>
-        {(fields, { add, remove }) => (
-          <>
-            <Form.Item label={t('pages.inbounds.form.peers')}>
-              <Button
-                size="small"
-                onClick={() => {
-                  const kp = Wireguard.generateKeypair();
-                  const peers = form.getFieldValue(['settings', 'peers']) as Array<{ allowedIPs?: string[] }> | undefined;
-                  add({
-                    privateKey: kp.privateKey,
-                    publicKey: kp.publicKey,
-                    allowedIPs: [nextWgPeerAllowedIP(peers)],
-                    keepAlive: 0,
-                  });
-                }}
-              >
-                <PlusOutlined /> {t('pages.inbounds.form.addPeer')}
-              </Button>
-            </Form.Item>
-            {fields.map((field, idx) => (
-              <div key={field.key} className="wg-peer">
-                <Divider titlePlacement="center">
-                  <Space>
-                    <span>{t('pages.inbounds.info.peerNumber', { n: idx + 1 })}</span>
-                    <Form.Item noStyle shouldUpdate>
-                      {() => {
-                        const comment = form.getFieldValue(['settings', 'peers', field.name, 'comment']) as string | undefined;
-                        return comment ? <span style={{ opacity: 0.65 }}>— {comment}</span> : null;
-                      }}
-                    </Form.Item>
-                    {fields.length > 1 && (
-                      <Button
-                        size="small"
-                        danger
-                        icon={<MinusOutlined />}
-                        onClick={() => remove(field.name)}
-                      />
-                    )}
-                  </Space>
-                </Divider>
-                <Form.Item name={[field.name, 'comment']} label={t('comment')}>
-                  <Input placeholder="e.g. Alice's laptop" />
-                </Form.Item>
-                <Form.Item label={t('pages.xray.wireguard.secretKey')}>
-                  <Space.Compact block>
-                    <Form.Item name={[field.name, 'privateKey']} noStyle>
-                      <Input style={{ width: 'calc(100% - 32px)' }} />
-                    </Form.Item>
-                    <Button
-                      icon={<ReloadOutlined />}
-                      onClick={() => regenWgPeerKeypair(field.name)}
-                    />
-                  </Space.Compact>
-                </Form.Item>
-                <Form.Item name={[field.name, 'publicKey']} label={t('pages.xray.wireguard.publicKey')}>
-                  <Input />
-                </Form.Item>
-                <Form.Item name={[field.name, 'preSharedKey']} label="PSK">
-                  <Input />
-                </Form.Item>
-                <Form.List name={[field.name, 'allowedIPs']}>
-                  {(ipFields, { add: addIp, remove: removeIp }) => (
-                    <Form.Item label={t('pages.xray.wireguard.allowedIPs')}>
-                      <Button size="small" onClick={() => addIp('')}>
-                        <PlusOutlined />
-                      </Button>
-                      {ipFields.map((ipField) => (
-                        <Space.Compact key={ipField.key} block className="mt-4">
-                          <Form.Item name={ipField.name} noStyle>
-                            <Input />
-                          </Form.Item>
-                          {ipFields.length > 1 && (
-                            <Button size="small" onClick={() => removeIp(ipField.name)}>
-                              <MinusOutlined />
-                            </Button>
-                          )}
-                        </Space.Compact>
-                      ))}
-                    </Form.Item>
-                  )}
-                </Form.List>
-                <Form.Item name={[field.name, 'keepAlive']} label={t('pages.inbounds.form.keepAlive')}>
-                  <InputNumber min={0} />
-                </Form.Item>
-              </div>
-            ))}
-          </>
-        )}
-      </Form.List>
     </>
   );
 }

+ 5 - 0
frontend/src/schemas/client.ts

@@ -32,6 +32,11 @@ export const ClientRecordSchema = z.object({
   inboundIds: nullableNumberArray.optional(),
   traffic: ClientTrafficSchema.nullable().optional(),
   reverse: z.object({ tag: z.string().optional() }).loose().nullable().optional(),
+  privateKey: z.string().optional(),
+  publicKey: z.string().optional(),
+  allowedIPs: z.string().optional(),
+  preSharedKey: z.string().optional(),
+  keepAlive: z.number().optional(),
   createdAt: z.number().optional(),
   updatedAt: z.number().optional(),
 }).loose();

+ 26 - 0
frontend/src/schemas/protocols/inbound/wireguard.ts

@@ -33,10 +33,36 @@ export const WireguardInboundPeerSchema = z.object({
 });
 export type WireguardInboundPeer = z.infer<typeof WireguardInboundPeerSchema>;
 
+// A WireGuard inbound client (multi-client model). Each client is one peer the
+// server accepts: the panel stores its keypair so it can render a full .conf/QR,
+// and allowedIPs is the client's unique tunnel address (allocated server-side
+// when left blank). Keys are optional on the wire — the backend generates them
+// when absent.
+export const WireguardClientSchema = z.object({
+  privateKey: z.string().optional(),
+  publicKey: z.string().optional(),
+  preSharedKey: z.string().optional(),
+  allowedIPs: z.array(z.string()).default([]),
+  keepAlive: optionalClearedInt(z.number().int().min(0)),
+  email: z.string().min(1),
+  limitIp: z.number().int().min(0).default(0),
+  totalGB: z.number().int().min(0).default(0),
+  expiryTime: z.number().int().default(0),
+  enable: z.boolean().default(true),
+  tgId: z.union([z.number(), z.string()]).transform((v) => Number(v) || 0).default(0),
+  subId: z.string().default(''),
+  comment: z.string().default(''),
+  reset: z.number().int().min(0).default(0),
+  created_at: z.number().int().optional(),
+  updated_at: z.number().int().optional(),
+});
+export type WireguardClient = z.infer<typeof WireguardClientSchema>;
+
 export const WireguardInboundSettingsSchema = z.object({
   mtu: optionalClearedInt(z.number().int().min(1)),
   secretKey: z.string().min(1),
   peers: z.array(WireguardInboundPeerSchema).default([]),
+  clients: z.array(WireguardClientSchema).default([]),
   noKernelTun: z.boolean().default(false),
   domainStrategy: WireguardDomainStrategySchema.optional(),
 });

+ 2 - 10
frontend/src/test/__snapshots__/inbound-defaults.test.ts.snap

@@ -49,18 +49,10 @@ exports[`createDefault*InboundSettings factories > vmess 1`] = `
 
 exports[`createDefault*InboundSettings factories > wireguard 1`] = `
 {
+  "clients": [],
   "mtu": 1420,
   "noKernelTun": false,
-  "peers": [
-    {
-      "allowedIPs": [
-        "10.0.0.2/32",
-      ],
-      "keepAlive": 0,
-      "privateKey": "cGVlci1maXh0dXJlLXByaXZhdGUta2V5LWZvci10ZXN0cw==",
-      "publicKey": "RNa/H++60PStnhoiiU/vIuwFimZUBuIkLkbrmEoDz34=",
-    },
-  ],
+  "peers": [],
   "secretKey": "QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=",
 }
 `;

+ 1 - 0
frontend/src/test/__snapshots__/inbound-full.test.ts.snap

@@ -608,6 +608,7 @@ exports[`InboundSchema (full) fixtures > parses wireguard-server byte-stably 1`]
   "protocol": "wireguard",
   "remark": "wg-server",
   "settings": {
+    "clients": [],
     "mtu": 1420,
     "noKernelTun": false,
     "peers": [

+ 1 - 0
frontend/src/test/__snapshots__/protocols.test.ts.snap

@@ -207,6 +207,7 @@ exports[`InboundSettingsSchema fixtures > parses wireguard-basic byte-stably 1`]
 {
   "protocol": "wireguard",
   "settings": {
+    "clients": [],
     "mtu": 1420,
     "noKernelTun": false,
     "peers": [

+ 2 - 1
frontend/src/test/inbound-defaults.test.ts

@@ -142,10 +142,11 @@ describe('createDefault*InboundSettings factories', () => {
   it('wireguard', () => {
     const s = createDefaultWireguardInboundSettings({
       secretKey: 'QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=',
-      peerPrivateKey: 'cGVlci1maXh0dXJlLXByaXZhdGUta2V5LWZvci10ZXN0cw==',
     });
     expect(s).toMatchSnapshot();
     expect(WireguardInboundSettingsSchema.parse(s)).toEqual(s);
+    expect(s.peers).toEqual([]);
+    expect(s.clients).toEqual([]);
   });
 });
 

+ 86 - 0
frontend/src/test/wireguard-clients-link.test.ts

@@ -0,0 +1,86 @@
+import { describe, expect, it } from 'vitest';
+
+import { genWireguardConfigs, genWireguardLinks } from '@/lib/xray/inbound-link';
+import { InboundSchema } from '@/schemas/api/inbound';
+
+// Multi-client WireGuard renders one link/config per entry in settings.clients
+// (the canonical store), not settings.peers. Each client carries its own
+// privateKey + allowedIPs; the server public key is derived from secretKey.
+function wgInbound() {
+  return InboundSchema.parse({
+    id: 90,
+    remark: 'wg-mc',
+    port: 51820,
+    protocol: 'wireguard',
+    settings: {
+      mtu: 1420,
+      secretKey: 'iJ2cBkrSGqRwIfYIDIxk7hr5RXfdR93MfJUL7yqkkH8=',
+      peers: [],
+      clients: [
+        {
+          email: 'alice',
+          privateKey: 'QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=',
+          publicKey: 'DGSYIcEKAUkA7HhzGSjxLZuV67BR3LeyU0BMLJzNVHQ=',
+          allowedIPs: ['10.0.0.2/32'],
+          keepAlive: 25,
+        },
+        {
+          email: 'bob',
+          privateKey: 'aGVsbG8td29ybGQtdGVzdC1wcml2YXRlLWtleS1ub3chIQ==',
+          publicKey: 'b3RoZXItcHVibGljLWtleS1mb3ItYm9iLXRlc3QtdmFsISE=',
+          allowedIPs: ['10.0.0.3/32'],
+        },
+      ],
+    },
+  });
+}
+
+describe('wireguard multi-client link/config fan-out', () => {
+  it('emits one link per client from settings.clients', () => {
+    const out = genWireguardLinks({
+      inbound: wgInbound(),
+      remark: 'wg-mc',
+      fallbackHostname: 'wg.example.test',
+    });
+    const links = out.split('\r\n').filter(Boolean);
+    expect(links).toHaveLength(2);
+    expect(links[0]).toContain('wireguard://');
+    expect(links[0]).toContain('address=10.0.0.2%2F32');
+    expect(links[1]).toContain('address=10.0.0.3%2F32');
+  });
+
+  it('emits one .conf per client with its own address', () => {
+    const out = genWireguardConfigs({
+      inbound: wgInbound(),
+      remark: 'wg-mc',
+      fallbackHostname: 'wg.example.test',
+    });
+    const configs = out.split('\r\n[Interface]').length;
+    expect(out).toContain('Address = 10.0.0.2/32');
+    expect(out).toContain('Address = 10.0.0.3/32');
+    expect(configs).toBe(2);
+  });
+
+  it('falls back to settings.peers for legacy single-config inbounds', () => {
+    const legacy = InboundSchema.parse({
+      id: 91,
+      remark: 'wg-legacy',
+      port: 51820,
+      protocol: 'wireguard',
+      settings: {
+        secretKey: 'iJ2cBkrSGqRwIfYIDIxk7hr5RXfdR93MfJUL7yqkkH8=',
+        peers: [
+          {
+            privateKey: 'QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=',
+            publicKey: 'DGSYIcEKAUkA7HhzGSjxLZuV67BR3LeyU0BMLJzNVHQ=',
+            allowedIPs: ['10.0.0.9/32'],
+          },
+        ],
+      },
+    });
+    const out = genWireguardLinks({ inbound: legacy, remark: 'wg-legacy', fallbackHostname: 'wg.example.test' });
+    const links = out.split('\r\n').filter(Boolean);
+    expect(links).toHaveLength(1);
+    expect(links[0]).toContain('address=10.0.0.9%2F32');
+  });
+});

+ 148 - 1
internal/database/db.go

@@ -192,6 +192,148 @@ func seedHostsFromExternalProxy() error {
 	})
 }
 
+// seedWireguardPeersToClients is a one-time, self-gated migration that converts
+// legacy single-config WireGuard inbounds into the multi-client model: each
+// settings.peers[] entry becomes a managed client in the clients table attached
+// to the inbound, and the inbound settings are rewritten so peers becomes a
+// clients[] array (GetXrayConfig re-projects clients back to peers for xray).
+// Idempotent: gated on the history row and skipped per-inbound once it already
+// has client links.
+func seedWireguardPeersToClients() error {
+	var history []string
+	if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &history).Error; err != nil {
+		return err
+	}
+	if slices.Contains(history, "WireguardPeersToClients") {
+		return nil
+	}
+
+	var inbounds []model.Inbound
+	if err := db.Where("protocol = ?", string(model.WireGuard)).Find(&inbounds).Error; err != nil {
+		return err
+	}
+
+	return db.Transaction(func(tx *gorm.DB) error {
+		usedEmails := map[string]struct{}{}
+		var existingEmails []string
+		if err := tx.Model(&model.ClientRecord{}).Pluck("email", &existingEmails).Error; err != nil {
+			return err
+		}
+		for _, e := range existingEmails {
+			usedEmails[e] = struct{}{}
+		}
+
+		for _, inbound := range inbounds {
+			if strings.TrimSpace(inbound.Settings) == "" {
+				continue
+			}
+			var settings map[string]any
+			if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
+				log.Printf("WireguardPeersToClients: skip inbound %d (invalid settings json): %v", inbound.Id, err)
+				continue
+			}
+			peers, ok := settings["peers"].([]any)
+			if !ok || len(peers) == 0 {
+				continue
+			}
+
+			var linkCount int64
+			if err := tx.Model(&model.ClientInbound{}).Where("inbound_id = ?", inbound.Id).Count(&linkCount).Error; err != nil {
+				return err
+			}
+			if linkCount > 0 {
+				continue
+			}
+
+			clientObjs := make([]any, 0, len(peers))
+			for i, raw := range peers {
+				obj, ok := raw.(map[string]any)
+				if !ok {
+					continue
+				}
+				email := wireguardPeerEmail(inbound.Remark, obj, i, usedEmails)
+				usedEmails[email] = struct{}{}
+				obj["email"] = email
+				if sub, _ := obj["subId"].(string); strings.TrimSpace(sub) == "" {
+					obj["subId"] = random.NumLower(16)
+				}
+				if _, ok := obj["enable"]; !ok {
+					obj["enable"] = true
+				}
+
+				blob, err := json.Marshal(obj)
+				if err != nil {
+					continue
+				}
+				var c model.Client
+				if err := json.Unmarshal(blob, &c); err != nil {
+					log.Printf("WireguardPeersToClients: skip peer in inbound %d: %v", inbound.Id, err)
+					continue
+				}
+				c.Email = email
+
+				incoming := c.ToRecord()
+				var row model.ClientRecord
+				err = tx.Where("email = ?", email).First(&row).Error
+				if errors.Is(err, gorm.ErrRecordNotFound) {
+					if err := tx.Create(incoming).Error; err != nil {
+						return err
+					}
+					row = *incoming
+				} else if err != nil {
+					return err
+				} else {
+					model.MergeClientRecord(&row, incoming)
+					if err := tx.Save(&row).Error; err != nil {
+						return err
+					}
+				}
+
+				link := model.ClientInbound{ClientId: row.Id, InboundId: inbound.Id}
+				if err := tx.Where("client_id = ? AND inbound_id = ?", row.Id, inbound.Id).
+					FirstOrCreate(&link).Error; err != nil {
+					return err
+				}
+
+				clientObjs = append(clientObjs, obj)
+			}
+
+			delete(settings, "peers")
+			settings["clients"] = clientObjs
+			newSettings, err := json.Marshal(settings)
+			if err != nil {
+				return err
+			}
+			if err := tx.Model(&model.Inbound{}).Where("id = ?", inbound.Id).
+				Update("settings", string(newSettings)).Error; err != nil {
+				return err
+			}
+		}
+		return tx.Create(&model.HistoryOfSeeders{SeederName: "WireguardPeersToClients"}).Error
+	})
+}
+
+// wireguardPeerEmail derives a stable, unique client email for a migrated peer
+// from the inbound remark plus the peer's comment (or its 1-based index).
+func wireguardPeerEmail(remark string, peer map[string]any, index int, used map[string]struct{}) string {
+	base := strings.TrimSpace(remark)
+	if base == "" {
+		base = "wg"
+	}
+	suffix := strconv.Itoa(index + 1)
+	if c, ok := peer["comment"].(string); ok && strings.TrimSpace(c) != "" {
+		suffix = strings.TrimSpace(c)
+	}
+	email := strings.ReplaceAll(base+"-"+suffix, " ", "-")
+	candidate := email
+	for n := 2; ; n++ {
+		if _, taken := used[candidate]; !taken {
+			return candidate
+		}
+		candidate = email + "-" + strconv.Itoa(n)
+	}
+}
+
 // CreateHostsFromExternalProxy parses a legacy streamSettings.externalProxy array
 // and inserts one Host row per entry on tx, returning the number of rows created.
 // It is the shared core of both the one-time seedHostsFromExternalProxy startup
@@ -387,7 +529,7 @@ func runSeeders(isUsersEmpty bool) error {
 	}
 
 	if empty && isUsersEmpty {
-		seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "ApiTokensHash", "LegacyProxySettingsCleanup"}
+		seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "ApiTokensHash", "LegacyProxySettingsCleanup", "WireguardPeersToClients"}
 		for _, name := range seeders {
 			if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
 				return err
@@ -490,6 +632,11 @@ func runSeeders(isUsersEmpty bool) error {
 	if err := resetIpLimitsWithoutFail2ban(); err != nil {
 		return err
 	}
+
+	// Self-gated on the "WireguardPeersToClients" row.
+	if err := seedWireguardPeersToClients(); err != nil {
+		return err
+	}
 	return nil
 }
 

+ 106 - 37
internal/database/model/model.go

@@ -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)

+ 81 - 0
internal/database/model/model_wireguard_test.go

@@ -0,0 +1,81 @@
+package model
+
+import (
+	"reflect"
+	"testing"
+)
+
+func TestClientToRecordRoundTripWireGuard(t *testing.T) {
+	c := &Client{
+		Email:        "[email protected]",
+		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: "[email protected]", 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:      "[email protected]",
+		PrivateKey: "existing-private",
+		PublicKey:  "existing-public",
+		AllowedIPs: "10.0.0.7/32",
+		UpdatedAt:  100,
+	}
+	incomingEmpty := &ClientRecord{Email: "[email protected]", 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:      "[email protected]",
+		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)
+	}
+}

+ 190 - 0
internal/database/wireguard_migration_test.go

@@ -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
internal/sub/service.go

@@ -21,6 +21,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/random"
+	wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
@@ -367,7 +368,7 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error)
 		JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id
 		JOIN clients ON clients.id = client_inbounds.client_id
 		WHERE
-			inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria')
+			inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria','wireguard')
 			AND clients.sub_id = ? AND inbounds.enable = ?
 	)`, subId, true).Order("sub_sort_index ASC").Order("id ASC").Find(&inbounds).Error
 	if err != nil {
@@ -501,10 +502,52 @@ func (s *SubService) GetLink(inbound *model.Inbound, email string) string {
 		return s.genHysteriaLink(inbound, email)
 	case "mtproto":
 		return s.genMtprotoLink(inbound, email)
+	case "wireguard":
+		return s.genWireguardLink(inbound, email)
 	}
 	return ""
 }
 
+// genWireguardLink builds a per-client wireguard:// share link mirroring the
+// frontend genWireguardLink: the client's private key is the userinfo, the
+// server public key (derived from the inbound secretKey) and the client's
+// tunnel address ride in the query. Returns "" when the client has no key.
+func (s *SubService) genWireguardLink(inbound *model.Inbound, email string) string {
+	if inbound.Protocol != model.WireGuard {
+		return ""
+	}
+	settings := map[string]any{}
+	_ = json.Unmarshal([]byte(inbound.Settings), &settings)
+	secretKey, _ := settings["secretKey"].(string)
+
+	clients, _ := s.inboundService.GetClients(inbound)
+	var client *model.Client
+	for i := range clients {
+		if clients[i].Email == email {
+			client = &clients[i]
+			break
+		}
+	}
+	if client == nil || client.PrivateKey == "" {
+		return ""
+	}
+
+	link := fmt.Sprintf("wireguard://%s@%s", encodeUserinfo(client.PrivateKey), joinHostPort(s.resolveInboundAddress(inbound), inbound.Port))
+	params := make(map[string]string)
+	if secretKey != "" {
+		if pub, err := wgutil.PublicKeyFromPrivate(secretKey); err == nil {
+			params["publickey"] = pub
+		}
+	}
+	if len(client.AllowedIPs) > 0 && client.AllowedIPs[0] != "" {
+		params["address"] = client.AllowedIPs[0]
+	}
+	if mtu, ok := settings["mtu"].(float64); ok && mtu > 0 {
+		params["mtu"] = strconv.Itoa(int(mtu))
+	}
+	return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", ""))
+}
+
 // genMtprotoLink builds a Telegram proxy deep link for an mtproto inbound:
 func (s *SubService) genMtprotoLink(inbound *model.Inbound, _ string) string {
 	if inbound.Protocol != model.MTProto {

+ 102 - 0
internal/sub/service_wireguard_test.go

@@ -0,0 +1,102 @@
+package sub
+
+import (
+	"net/url"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
+)
+
+func TestGenWireguardLinkFields(t *testing.T) {
+	serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("keypair: %v", err)
+	}
+	clientPriv, _, err := wgutil.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("client keypair: %v", err)
+	}
+
+	inbound := &model.Inbound{
+		Listen:   "203.0.113.7",
+		Port:     51820,
+		Protocol: model.WireGuard,
+		Remark:   "wg-sub",
+		Settings: `{"secretKey":"` + serverPriv + `","mtu":1420,"clients":[{"email":"user","privateKey":"` + clientPriv + `","allowedIPs":["10.0.0.2/32"],"keepAlive":25}]}`,
+	}
+
+	s := &SubService{}
+	link := s.genWireguardLink(inbound, "user")
+
+	u, err := url.Parse(link)
+	if err != nil {
+		t.Fatalf("link does not parse: %v\n got: %s", err, link)
+	}
+	if u.Scheme != "wireguard" {
+		t.Fatalf("scheme = %q, want wireguard", u.Scheme)
+	}
+	if u.Host != "203.0.113.7:51820" {
+		t.Fatalf("host = %q, want 203.0.113.7:51820", u.Host)
+	}
+	if u.User.Username() != clientPriv {
+		t.Fatalf("userinfo = %q, want client private key %q", u.User.Username(), clientPriv)
+	}
+	q := u.Query()
+	if q.Get("publickey") != serverPub {
+		t.Fatalf("publickey = %q, want server public key %q", q.Get("publickey"), serverPub)
+	}
+	if q.Get("address") != "10.0.0.2/32" {
+		t.Fatalf("address = %q, want 10.0.0.2/32", q.Get("address"))
+	}
+	if q.Get("mtu") != "1420" {
+		t.Fatalf("mtu = %q, want 1420", q.Get("mtu"))
+	}
+}
+
+func TestGenWireguardLinkWrongProtocol(t *testing.T) {
+	s := &SubService{}
+	vless := &model.Inbound{Protocol: model.VLESS, Settings: `{"clients":[{"email":"user"}]}`}
+	if got := s.genWireguardLink(vless, "user"); got != "" {
+		t.Fatalf("wrong protocol should yield empty link, got %q", got)
+	}
+}
+
+func TestGenWireguardLinkNoKey(t *testing.T) {
+	s := &SubService{}
+	inbound := &model.Inbound{
+		Protocol: model.WireGuard,
+		Port:     51820,
+		Settings: `{"secretKey":"x","clients":[{"email":"user"}]}`,
+	}
+	if got := s.genWireguardLink(inbound, "user"); got != "" {
+		t.Fatalf("client without private key should yield empty link, got %q", got)
+	}
+}
+
+func TestGetInboundsBySubIdIncludesWireguard(t *testing.T) {
+	initSubDB(t)
+	db := database.GetDB()
+
+	in := &model.Inbound{Port: 51820, Protocol: model.WireGuard, Enable: true, Tag: "wg-sub", Settings: `{"secretKey":"x","clients":[]}`}
+	if err := db.Create(in).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+	rec := &model.ClientRecord{Email: "u@wg", SubID: "subwg", Enable: true}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatalf("create client: %v", err)
+	}
+	if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: in.Id}).Error; err != nil {
+		t.Fatalf("create link: %v", err)
+	}
+
+	s := &SubService{}
+	inbounds, err := s.getInboundsBySubId("subwg")
+	if err != nil {
+		t.Fatalf("getInboundsBySubId: %v", err)
+	}
+	if len(inbounds) != 1 || inbounds[0].Id != in.Id {
+		t.Fatalf("wireguard inbound not returned for subId: %+v", inbounds)
+	}
+}

+ 75 - 0
internal/util/wireguard/wireguard.go

@@ -3,6 +3,9 @@ package wireguard
 import (
 	"crypto/rand"
 	"encoding/base64"
+	"encoding/hex"
+	"errors"
+	"strings"
 
 	"golang.org/x/crypto/curve25519"
 )
@@ -22,3 +25,75 @@ func GenerateWireguardKeypair() (privateKey string, publicKey string, err error)
 
 	return base64.StdEncoding.EncodeToString(priv[:]), base64.StdEncoding.EncodeToString(pub[:]), nil
 }
+
+// GenerateWireguardPSK generates a base64 encoded 32-byte pre-shared key for Wireguard.
+func GenerateWireguardPSK() (string, error) {
+	var psk [32]byte
+	if _, err := rand.Read(psk[:]); err != nil {
+		return "", err
+	}
+	return base64.StdEncoding.EncodeToString(psk[:]), nil
+}
+
+// PublicKeyFromPrivate derives the base64 public key for a base64 (or hex) Wireguard private key.
+func PublicKeyFromPrivate(privateKey string) (string, error) {
+	priv, err := decodeWireguardKey(privateKey)
+	if err != nil {
+		return "", err
+	}
+	var pub [32]byte
+	curve25519.ScalarBaseMult(&pub, &priv)
+	return base64.StdEncoding.EncodeToString(pub[:]), nil
+}
+
+// KeyToHex converts a base64 (or already-hex) 32-byte Wireguard key into the
+// lowercase hex form xray-core's wireguard proxy expects: its ParseKey uses
+// hex.DecodeString, and the device IPC layer wants hex for public_key and
+// preshared_key. An empty input yields an empty result so optional keys pass
+// through untouched.
+func KeyToHex(key string) (string, error) {
+	if key == "" {
+		return "", nil
+	}
+	raw, err := decodeWireguardKey(key)
+	if err != nil {
+		return "", err
+	}
+	return hex.EncodeToString(raw[:]), nil
+}
+
+// decodeWireguardKey accepts a 64-char hex key or a base64 key (standard or
+// URL-safe alphabet, with or without padding) and returns the raw 32 bytes.
+func decodeWireguardKey(key string) ([32]byte, error) {
+	var out [32]byte
+	if key == "" {
+		return out, errors.New("wireguard: empty key")
+	}
+
+	if len(key) == 64 {
+		if raw, err := hex.DecodeString(key); err == nil {
+			if len(raw) != 32 {
+				return out, errors.New("wireguard: key must decode to 32 bytes")
+			}
+			copy(out[:], raw)
+			return out, nil
+		}
+	}
+
+	trimmed := strings.TrimRight(key, "=")
+	var raw []byte
+	var err error
+	if strings.ContainsAny(trimmed, "+/") {
+		raw, err = base64.RawStdEncoding.DecodeString(trimmed)
+	} else {
+		raw, err = base64.RawURLEncoding.DecodeString(trimmed)
+	}
+	if err != nil {
+		return out, err
+	}
+	if len(raw) != 32 {
+		return out, errors.New("wireguard: key must decode to 32 bytes")
+	}
+	copy(out[:], raw)
+	return out, nil
+}

+ 123 - 0
internal/util/wireguard/wireguard_test.go

@@ -0,0 +1,123 @@
+package wireguard
+
+import (
+	"encoding/base64"
+	"encoding/hex"
+	"strings"
+	"testing"
+)
+
+func TestGenerateWireguardKeypairRoundTrip(t *testing.T) {
+	priv, pub, err := GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("GenerateWireguardKeypair: %v", err)
+	}
+	for name, key := range map[string]string{"private": priv, "public": pub} {
+		raw, err := base64.StdEncoding.DecodeString(key)
+		if err != nil {
+			t.Fatalf("%s key not base64: %v", name, err)
+		}
+		if len(raw) != 32 {
+			t.Fatalf("%s key decodes to %d bytes, want 32", name, len(raw))
+		}
+	}
+
+	derived, err := PublicKeyFromPrivate(priv)
+	if err != nil {
+		t.Fatalf("PublicKeyFromPrivate: %v", err)
+	}
+	if derived != pub {
+		t.Fatalf("PublicKeyFromPrivate(priv) = %q, want %q", derived, pub)
+	}
+}
+
+func TestPublicKeyFromPrivateKnownVector(t *testing.T) {
+	privHex := "77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a"
+	wantPubHex := "8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a"
+
+	privBytes, err := hex.DecodeString(privHex)
+	if err != nil {
+		t.Fatalf("decode priv vector: %v", err)
+	}
+	pubB64, err := PublicKeyFromPrivate(base64.StdEncoding.EncodeToString(privBytes))
+	if err != nil {
+		t.Fatalf("PublicKeyFromPrivate: %v", err)
+	}
+	gotPubHex, err := KeyToHex(pubB64)
+	if err != nil {
+		t.Fatalf("KeyToHex: %v", err)
+	}
+	if gotPubHex != wantPubHex {
+		t.Fatalf("derived public key hex = %q, want %q", gotPubHex, wantPubHex)
+	}
+}
+
+func TestKeyToHex(t *testing.T) {
+	low := make([]byte, 32)
+	for i := range low {
+		low[i] = byte(i)
+	}
+	high := make([]byte, 32)
+	for i := range high {
+		high[i] = 0xff
+	}
+
+	for _, raw := range [][]byte{low, high} {
+		wantHex := hex.EncodeToString(raw)
+		std := base64.StdEncoding.EncodeToString(raw)
+		url := base64.URLEncoding.EncodeToString(raw)
+		padless := strings.TrimRight(std, "=")
+		for label, in := range map[string]string{"std": std, "url": url, "padless": padless, "hex": wantHex} {
+			got, err := KeyToHex(in)
+			if err != nil {
+				t.Fatalf("KeyToHex(%s=%q): %v", label, in, err)
+			}
+			if got != wantHex {
+				t.Fatalf("KeyToHex(%s) = %q, want %q", label, got, wantHex)
+			}
+			if back, err := hex.DecodeString(got); err != nil || len(back) != 32 {
+				t.Fatalf("KeyToHex output not a 32-byte hex key: err=%v len=%d", err, len(back))
+			}
+		}
+	}
+}
+
+func TestKeyToHexEmpty(t *testing.T) {
+	got, err := KeyToHex("")
+	if err != nil {
+		t.Fatalf("KeyToHex(\"\"): %v", err)
+	}
+	if got != "" {
+		t.Fatalf("KeyToHex(\"\") = %q, want empty", got)
+	}
+}
+
+func TestKeyToHexRejectsBadInput(t *testing.T) {
+	cases := map[string]string{
+		"not base64":   "this is not base64 @@@@",
+		"wrong length": base64.StdEncoding.EncodeToString(make([]byte, 16)),
+	}
+	for name, in := range cases {
+		if _, err := KeyToHex(in); err == nil {
+			t.Fatalf("KeyToHex(%s=%q) expected error, got nil", name, in)
+		}
+	}
+}
+
+func TestGenerateWireguardPSK(t *testing.T) {
+	a, err := GenerateWireguardPSK()
+	if err != nil {
+		t.Fatalf("GenerateWireguardPSK: %v", err)
+	}
+	b, err := GenerateWireguardPSK()
+	if err != nil {
+		t.Fatalf("GenerateWireguardPSK: %v", err)
+	}
+	if a == b {
+		t.Fatalf("two PSKs are identical: %q", a)
+	}
+	raw, err := base64.StdEncoding.DecodeString(a)
+	if err != nil || len(raw) != 32 {
+		t.Fatalf("PSK not a 32-byte base64 key: err=%v len=%d", err, len(raw))
+	}
+}

+ 28 - 12
internal/web/runtime/local.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"encoding/json"
 	"errors"
+	"strconv"
 	"strings"
 	"sync"
 
@@ -102,12 +103,16 @@ func (l *Local) AddClient(ctx context.Context, ib *model.Inbound, client model.C
 		return nil
 	}
 	user := map[string]any{
-		"email":    client.Email,
-		"id":       client.ID,
-		"security": client.Security,
-		"flow":     client.Flow,
-		"auth":     client.Auth,
-		"password": client.Password,
+		"email":        client.Email,
+		"id":           client.ID,
+		"security":     client.Security,
+		"flow":         client.Flow,
+		"auth":         client.Auth,
+		"password":     client.Password,
+		"publicKey":    client.PublicKey,
+		"allowedIPs":   client.AllowedIPs,
+		"preSharedKey": client.PreSharedKey,
+		"keepAlive":    wgKeepAlive(client.KeepAlive),
 	}
 	return l.AddUser(ctx, ib, user)
 }
@@ -135,16 +140,27 @@ func (l *Local) UpdateUser(ctx context.Context, ib *model.Inbound, oldEmail stri
 		return nil
 	}
 	user := map[string]any{
-		"email":    payload.Email,
-		"id":       payload.ID,
-		"security": payload.Security,
-		"flow":     payload.Flow,
-		"auth":     payload.Auth,
-		"password": payload.Password,
+		"email":        payload.Email,
+		"id":           payload.ID,
+		"security":     payload.Security,
+		"flow":         payload.Flow,
+		"auth":         payload.Auth,
+		"password":     payload.Password,
+		"publicKey":    payload.PublicKey,
+		"allowedIPs":   payload.AllowedIPs,
+		"preSharedKey": payload.PreSharedKey,
+		"keepAlive":    wgKeepAlive(payload.KeepAlive),
 	}
 	return l.AddUser(ctx, ib, user)
 }
 
+func wgKeepAlive(seconds int) string {
+	if seconds <= 0 {
+		return ""
+	}
+	return strconv.Itoa(seconds)
+}
+
 func (l *Local) RestartXray(_ context.Context) error {
 	if l.deps.SetNeedRestart != nil {
 		l.deps.SetNeedRestart()

+ 73 - 16
internal/web/service/client_inbound_apply.go

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

+ 11 - 0
internal/web/service/client_link.go

@@ -82,6 +82,17 @@ func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.
 		if incoming.Reverse != "" {
 			row.Reverse = incoming.Reverse
 		}
+		if incoming.PrivateKey != "" {
+			row.PrivateKey = incoming.PrivateKey
+		}
+		if incoming.PublicKey != "" {
+			row.PublicKey = incoming.PublicKey
+		}
+		if incoming.AllowedIPs != "" {
+			row.AllowedIPs = incoming.AllowedIPs
+		}
+		row.PreSharedKey = incoming.PreSharedKey
+		row.KeepAlive = incoming.KeepAlive
 		row.SubID = incoming.SubID
 		row.LimitIP = incoming.LimitIP
 		row.TotalGB = incoming.TotalGB

+ 115 - 0
internal/web/service/client_wireguard.go

@@ -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
+}

+ 144 - 0
internal/web/service/client_wireguard_crud_test.go

@@ -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])
+	}
+}

+ 110 - 0
internal/web/service/client_wireguard_test.go

@@ -0,0 +1,110 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
+)
+
+func TestAllocateWireguardAddress(t *testing.T) {
+	tests := []struct {
+		name string
+		used []string
+		base string
+		want string
+		err  bool
+	}{
+		{name: "empty starts at .2", used: nil, base: "10.0.0.0/24", want: "10.0.0.2/32"},
+		{name: "skips used", used: []string{"10.0.0.2/32"}, base: "10.0.0.0/24", want: "10.0.0.3/32"},
+		{name: "fills gap", used: []string{"10.0.0.3/32", "10.0.0.4/32"}, base: "10.0.0.0/24", want: "10.0.0.2/32"},
+		{name: "ignores catch-all", used: []string{"0.0.0.0/0", "::/0"}, base: "10.0.0.0/24", want: "10.0.0.2/32"},
+		{name: "default base when empty", used: nil, base: "", want: "10.0.0.2/32"},
+		{name: "exhausted /30", used: []string{"10.9.0.2/32", "10.9.0.3/32"}, base: "10.9.0.0/30", err: true},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := allocateWireguardAddress(tt.used, tt.base)
+			if tt.err {
+				if err == nil {
+					t.Fatalf("expected error, got %q", got)
+				}
+				return
+			}
+			if err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+			if got != tt.want {
+				t.Fatalf("got %q, want %q", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestDefaultWireguardClientsGeneratesKeypair(t *testing.T) {
+	clients := []model.Client{{Email: "a@wg"}}
+	ifaces := []any{map[string]any{"email": "a@wg"}}
+	if err := defaultWireguardClients(nil, clients, ifaces); err != nil {
+		t.Fatalf("defaultWireguardClients: %v", err)
+	}
+	c := clients[0]
+	if c.PrivateKey == "" || c.PublicKey == "" {
+		t.Fatalf("keypair not generated: priv=%q pub=%q", c.PrivateKey, c.PublicKey)
+	}
+	if len(c.AllowedIPs) != 1 || c.AllowedIPs[0] != "10.0.0.2/32" {
+		t.Fatalf("allowedIPs not allocated: %v", c.AllowedIPs)
+	}
+	m := ifaces[0].(map[string]any)
+	if m["privateKey"] != c.PrivateKey || m["publicKey"] != c.PublicKey {
+		t.Fatalf("interface map not updated: %v", m)
+	}
+}
+
+func TestDefaultWireguardClientsDerivesPublicKey(t *testing.T) {
+	priv, _, err := wgutil.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatal(err)
+	}
+	wantPub, err := wgutil.PublicKeyFromPrivate(priv)
+	if err != nil {
+		t.Fatal(err)
+	}
+	clients := []model.Client{{Email: "b@wg", PrivateKey: priv}}
+	ifaces := []any{map[string]any{"email": "b@wg"}}
+	if err := defaultWireguardClients(nil, clients, ifaces); err != nil {
+		t.Fatalf("defaultWireguardClients: %v", err)
+	}
+	if clients[0].PublicKey != wantPub {
+		t.Fatalf("derived public key = %q, want %q", clients[0].PublicKey, wantPub)
+	}
+}
+
+func TestDefaultWireguardClientsPreservesProvided(t *testing.T) {
+	clients := []model.Client{{
+		Email:      "c@wg",
+		PrivateKey: "keep-priv",
+		PublicKey:  "keep-pub",
+		AllowedIPs: []string{"10.0.0.50/32"},
+	}}
+	ifaces := []any{map[string]any{"email": "c@wg"}}
+	if err := defaultWireguardClients(nil, clients, ifaces); err != nil {
+		t.Fatalf("defaultWireguardClients: %v", err)
+	}
+	if clients[0].PrivateKey != "keep-priv" || clients[0].PublicKey != "keep-pub" {
+		t.Fatalf("provided keys were rotated: %+v", clients[0])
+	}
+	if clients[0].AllowedIPs[0] != "10.0.0.50/32" {
+		t.Fatalf("provided allowedIPs changed: %v", clients[0].AllowedIPs)
+	}
+}
+
+func TestDefaultWireguardClientsAllocatesDistinctIPs(t *testing.T) {
+	clients := []model.Client{{Email: "x@wg"}, {Email: "y@wg"}}
+	ifaces := []any{map[string]any{"email": "x@wg"}, map[string]any{"email": "y@wg"}}
+	if err := defaultWireguardClients(nil, clients, ifaces); err != nil {
+		t.Fatalf("defaultWireguardClients: %v", err)
+	}
+	if clients[0].AllowedIPs[0] == clients[1].AllowedIPs[0] {
+		t.Fatalf("two clients got the same address: %v", clients[0].AllowedIPs)
+	}
+}

+ 31 - 4
internal/web/service/xray.go

@@ -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) {

+ 154 - 0
internal/web/service/xray_wireguard_config_test.go

@@ -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: "[email protected]", Enable: true, PublicKey: "pub-alice", AllowedIPs: []string{"10.0.0.2/32"}, KeepAlive: 25},
+		{Email: "[email protected]", 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: "[email protected]", Enable: true, PublicKey: "pub-on", AllowedIPs: []string{"10.0.0.2/32"}},
+		{Email: "[email protected]", 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 = ?", "[email protected]").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"] != "[email protected]" {
+		t.Errorf("wrong peer kept: %v", peers[0])
+	}
+}
+
+func TestGetXrayConfigWireGuardNoClientsEmitsEmptyPeers(t *testing.T) {
+	seedWGInbound(t, "wg-empty", 51822, nil)
+
+	settings := wgInboundEmittedSettings(t, "wg-empty")
+	if _, ok := settings["clients"]; ok {
+		t.Fatalf("clients key must be absent")
+	}
+	peers, ok := settings["peers"].([]any)
+	if !ok {
+		t.Fatalf("peers must be an (empty) array, got %T", settings["peers"])
+	}
+	if len(peers) != 0 {
+		t.Fatalf("expected empty peers, got %v", peers)
+	}
+}

+ 4 - 0
internal/web/translation/ar-EG.json

@@ -885,6 +885,10 @@
       "uuid": "UUID",
       "flow": "Flow",
       "vmessSecurity": "أمان VMess",
+      "wireguardPrivateKey": "مفتاح وايرغارد الخاص",
+      "wireguardPublicKey": "مفتاح وايرغارد العام",
+      "wireguardPreSharedKey": "مفتاح وايرغارد المشترك مسبقًا",
+      "wireguardAllowedIPs": "عناوين IP المسموحة لوايرغارد",
       "reverseTag": "وسم عكسي",
       "reverseTagPlaceholder": "Reverse tag اختياري",
       "telegramId": "معرّف مستخدم تلغرام",

+ 4 - 0
internal/web/translation/en-US.json

@@ -888,6 +888,10 @@
       "uuid": "UUID",
       "flow": "Flow",
       "vmessSecurity": "VMess Security",
+      "wireguardPrivateKey": "WireGuard Private Key",
+      "wireguardPublicKey": "WireGuard Public Key",
+      "wireguardPreSharedKey": "WireGuard Pre-Shared Key",
+      "wireguardAllowedIPs": "WireGuard Allowed IPs",
       "reverseTag": "Reverse tag",
       "reverseTagPlaceholder": "Optional reverse tag",
       "telegramId": "Telegram user ID",

+ 4 - 0
internal/web/translation/es-ES.json

@@ -885,6 +885,10 @@
       "uuid": "UUID",
       "flow": "Flow",
       "vmessSecurity": "Seguridad VMess",
+      "wireguardPrivateKey": "Clave privada de WireGuard",
+      "wireguardPublicKey": "Clave pública de WireGuard",
+      "wireguardPreSharedKey": "Clave precompartida de WireGuard",
+      "wireguardAllowedIPs": "IP permitidas de WireGuard",
       "reverseTag": "Etiqueta inversa",
       "reverseTagPlaceholder": "Reverse tag opcional",
       "telegramId": "ID de usuario de Telegram",

+ 4 - 0
internal/web/translation/fa-IR.json

@@ -885,6 +885,10 @@
       "uuid": "UUID",
       "flow": "Flow",
       "vmessSecurity": "امنیت VMess",
+      "wireguardPrivateKey": "کلید خصوصی وایرگارد",
+      "wireguardPublicKey": "کلید عمومی وایرگارد",
+      "wireguardPreSharedKey": "کلید پیش‌اشتراکی وایرگارد",
+      "wireguardAllowedIPs": "آی‌پی‌های مجاز وایرگارد",
       "reverseTag": "تگ معکوس",
       "reverseTagPlaceholder": "Reverse tag اختیاری",
       "telegramId": "شناسه کاربر تلگرام",

+ 4 - 0
internal/web/translation/id-ID.json

@@ -885,6 +885,10 @@
       "uuid": "UUID",
       "flow": "Flow",
       "vmessSecurity": "Keamanan VMess",
+      "wireguardPrivateKey": "Kunci Privat WireGuard",
+      "wireguardPublicKey": "Kunci Publik WireGuard",
+      "wireguardPreSharedKey": "Kunci Pra-Berbagi WireGuard",
+      "wireguardAllowedIPs": "IP yang Diizinkan WireGuard",
       "reverseTag": "Reverse tag",
       "reverseTagPlaceholder": "Reverse tag opsional",
       "telegramId": "ID pengguna Telegram",

+ 4 - 0
internal/web/translation/ja-JP.json

@@ -885,6 +885,10 @@
       "uuid": "UUID",
       "flow": "Flow",
       "vmessSecurity": "VMess セキュリティ",
+      "wireguardPrivateKey": "WireGuard 秘密鍵",
+      "wireguardPublicKey": "WireGuard 公開鍵",
+      "wireguardPreSharedKey": "WireGuard 事前共有鍵",
+      "wireguardAllowedIPs": "WireGuard 許可IP",
       "reverseTag": "Reverse tag",
       "reverseTagPlaceholder": "任意の Reverse tag",
       "telegramId": "Telegram ユーザー ID",

+ 4 - 0
internal/web/translation/pt-BR.json

@@ -885,6 +885,10 @@
       "uuid": "UUID",
       "flow": "Flow",
       "vmessSecurity": "Segurança VMess",
+      "wireguardPrivateKey": "Chave privada do WireGuard",
+      "wireguardPublicKey": "Chave pública do WireGuard",
+      "wireguardPreSharedKey": "Chave pré-compartilhada do WireGuard",
+      "wireguardAllowedIPs": "IPs permitidos do WireGuard",
       "reverseTag": "Tag reversa",
       "reverseTagPlaceholder": "Reverse tag opcional",
       "telegramId": "ID de usuário do Telegram",

+ 4 - 0
internal/web/translation/ru-RU.json

@@ -885,6 +885,10 @@
       "uuid": "UUID",
       "flow": "Flow",
       "vmessSecurity": "VMess Security",
+      "wireguardPrivateKey": "Приватный ключ WireGuard",
+      "wireguardPublicKey": "Публичный ключ WireGuard",
+      "wireguardPreSharedKey": "Общий ключ WireGuard",
+      "wireguardAllowedIPs": "Разрешённые IP WireGuard",
       "reverseTag": "Обратный тег",
       "reverseTagPlaceholder": "Необязательный Reverse tag",
       "telegramId": "ID пользователя Telegram",

+ 4 - 0
internal/web/translation/tr-TR.json

@@ -885,6 +885,10 @@
       "uuid": "UUID",
       "flow": "Flow",
       "vmessSecurity": "VMess Güvenlik",
+      "wireguardPrivateKey": "WireGuard Özel Anahtarı",
+      "wireguardPublicKey": "WireGuard Genel Anahtarı",
+      "wireguardPreSharedKey": "WireGuard Ön Paylaşımlı Anahtar",
+      "wireguardAllowedIPs": "WireGuard İzin Verilen IP'ler",
       "reverseTag": "Reverse Tag",
       "reverseTagPlaceholder": "İsteğe Bağlı Reverse Tag",
       "telegramId": "Telegram Kullanıcı ID'si",

+ 4 - 0
internal/web/translation/uk-UA.json

@@ -885,6 +885,10 @@
       "uuid": "UUID",
       "flow": "Flow",
       "vmessSecurity": "Безпека VMess",
+      "wireguardPrivateKey": "Приватний ключ WireGuard",
+      "wireguardPublicKey": "Публічний ключ WireGuard",
+      "wireguardPreSharedKey": "Спільний ключ WireGuard",
+      "wireguardAllowedIPs": "Дозволені IP WireGuard",
       "reverseTag": "Зворотний тег",
       "reverseTagPlaceholder": "Необов'язковий Reverse tag",
       "telegramId": "ID користувача Telegram",

+ 4 - 0
internal/web/translation/vi-VN.json

@@ -885,6 +885,10 @@
       "uuid": "UUID",
       "flow": "Flow",
       "vmessSecurity": "Bảo mật VMess",
+      "wireguardPrivateKey": "Khóa riêng WireGuard",
+      "wireguardPublicKey": "Khóa công khai WireGuard",
+      "wireguardPreSharedKey": "Khóa chia sẻ trước WireGuard",
+      "wireguardAllowedIPs": "IP được phép WireGuard",
       "reverseTag": "Reverse tag",
       "reverseTagPlaceholder": "Reverse tag tùy chọn",
       "telegramId": "ID người dùng Telegram",

+ 4 - 0
internal/web/translation/zh-CN.json

@@ -885,6 +885,10 @@
       "uuid": "UUID",
       "flow": "Flow",
       "vmessSecurity": "VMess 加密",
+      "wireguardPrivateKey": "WireGuard 私钥",
+      "wireguardPublicKey": "WireGuard 公钥",
+      "wireguardPreSharedKey": "WireGuard 预共享密钥",
+      "wireguardAllowedIPs": "WireGuard 允许的 IP",
       "reverseTag": "反向标签",
       "reverseTagPlaceholder": "可选 Reverse tag",
       "telegramId": "Telegram 用户 ID",

+ 4 - 0
internal/web/translation/zh-TW.json

@@ -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
internal/xray/api.go

@@ -18,6 +18,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/config"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
+	wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
 
 	"github.com/xtls/xray-core/app/proxyman/command"
 	routerService "github.com/xtls/xray-core/app/router/command"
@@ -32,6 +33,7 @@ import (
 	"github.com/xtls/xray-core/proxy/trojan"
 	"github.com/xtls/xray-core/proxy/vless"
 	"github.com/xtls/xray-core/proxy/vmess"
+	wireguard "github.com/xtls/xray-core/proxy/wireguard"
 	"google.golang.org/grpc"
 	"google.golang.org/grpc/codes"
 	"google.golang.org/grpc/credentials/insecure"
@@ -408,40 +410,62 @@ func ensureXrayAssetLocation() {
 	}
 }
 
-// AddUser adds a user to an inbound in the Xray core using the specified protocol and user data.
-func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]any) error {
-	userEmail, err := getRequiredUserString(user, "email")
-	if err != nil {
-		return err
+// collectStringSlice normalizes a JSON-decoded value into a slice of non-empty
+// strings, accepting both []string (typed maps) and []any (json.Unmarshal output).
+func collectStringSlice(value any) []string {
+	switch v := value.(type) {
+	case []string:
+		out := make([]string, 0, len(v))
+		for _, s := range v {
+			if s != "" {
+				out = append(out, s)
+			}
+		}
+		return out
+	case []any:
+		out := make([]string, 0, len(v))
+		for _, e := range v {
+			if s, ok := e.(string); ok && s != "" {
+				out = append(out, s)
+			}
+		}
+		return out
+	default:
+		return nil
 	}
+}
 
-	var account *serial.TypedMessage
-	switch Protocol {
+// buildUserAccount constructs the typed xray account for a user of the given
+// protocol. It returns (nil, nil) for protocols that cannot be altered live so
+// callers skip the AlterInbound call. WireGuard keys must be converted to the
+// hex form xray's wireguard proxy expects (its ParseKey uses hex.DecodeString),
+// unlike the file-config path which accepts base64 and converts internally.
+func buildUserAccount(protocolName string, user map[string]any) (*serial.TypedMessage, error) {
+	switch protocolName {
 	case "vmess":
 		userID, err := getRequiredUserString(user, "id")
 		if err != nil {
-			return err
+			return nil, err
 		}
 
-		account = serial.ToTypedMessage(&vmess.Account{
+		return serial.ToTypedMessage(&vmess.Account{
 			Id: userID,
-		})
+		}), nil
 	case "vless":
 		userID, err := getRequiredUserString(user, "id")
 		if err != nil {
-			return err
+			return nil, err
 		}
 
 		userFlow, err := getOptionalUserString(user, "flow")
 		if err != nil {
-			return err
+			return nil, err
 		}
 
 		vlessAccount := &vless.Account{
 			Id:   userID,
 			Flow: userFlow,
 		}
-		// Add testseed if provided
 		if testseedVal, ok := user["testseed"]; ok {
 			if testseedArr, ok := testseedVal.([]any); ok && len(testseedArr) >= 4 {
 				testseed := make([]uint32, len(testseedArr))
@@ -455,7 +479,6 @@ func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]an
 				vlessAccount.Testseed = testseedArr
 			}
 		}
-		// Add testpre if provided (for outbound, but can be in user for compatibility)
 		if testpreVal, ok := user["testpre"]; ok {
 			if testpre, ok := testpreVal.(float64); ok && testpre > 0 {
 				vlessAccount.Testpre = uint32(testpre)
@@ -463,25 +486,25 @@ func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]an
 				vlessAccount.Testpre = testpre
 			}
 		}
-		account = serial.ToTypedMessage(vlessAccount)
+		return serial.ToTypedMessage(vlessAccount), nil
 	case "trojan":
 		password, err := getRequiredUserString(user, "password")
 		if err != nil {
-			return err
+			return nil, err
 		}
 
-		account = serial.ToTypedMessage(&trojan.Account{
+		return serial.ToTypedMessage(&trojan.Account{
 			Password: password,
-		})
+		}), nil
 	case "shadowsocks":
 		cipher, err := getOptionalUserString(user, "cipher")
 		if err != nil {
-			return err
+			return nil, err
 		}
 
 		password, err := getRequiredUserString(user, "password")
 		if err != nil {
-			return err
+			return nil, err
 		}
 
 		var ssCipherType shadowsocks.CipherType
@@ -497,25 +520,75 @@ func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]an
 		}
 
 		if ssCipherType != shadowsocks.CipherType_NONE {
-			account = serial.ToTypedMessage(&shadowsocks.Account{
+			return serial.ToTypedMessage(&shadowsocks.Account{
 				Password:   password,
 				CipherType: ssCipherType,
-			})
-		} else {
-			account = serial.ToTypedMessage(&shadowsocks_2022.Account{
-				Key: password,
-			})
+			}), nil
 		}
+		return serial.ToTypedMessage(&shadowsocks_2022.Account{
+			Key: password,
+		}), nil
 	case "hysteria":
 		auth, err := getRequiredUserString(user, "auth")
 		if err != nil {
-			return err
+			return nil, err
 		}
 
-		account = serial.ToTypedMessage(&hysteriaAccount.Account{
+		return serial.ToTypedMessage(&hysteriaAccount.Account{
 			Auth: auth,
-		})
+		}), nil
+	case "wireguard":
+		pubB64, err := getRequiredUserString(user, "publicKey")
+		if err != nil {
+			return nil, err
+		}
+		pubHex, err := wgutil.KeyToHex(pubB64)
+		if err != nil {
+			return nil, fmt.Errorf("wireguard publicKey: %w", err)
+		}
+
+		pskB64, err := getOptionalUserString(user, "preSharedKey")
+		if err != nil {
+			return nil, err
+		}
+		pskHex, err := wgutil.KeyToHex(pskB64)
+		if err != nil {
+			return nil, fmt.Errorf("wireguard preSharedKey: %w", err)
+		}
+
+		allowed := collectStringSlice(user["allowedIPs"])
+		if len(allowed) == 0 {
+			return nil, common.NewError("wireguard: allowedIPs required")
+		}
+
+		keepAlive, err := getOptionalUserString(user, "keepAlive")
+		if err != nil {
+			return nil, err
+		}
+
+		return serial.ToTypedMessage(&wireguard.PeerConfig{
+			PublicKey:    pubHex,
+			PreSharedKey: pskHex,
+			AllowedIps:   allowed,
+			KeepAlive:    keepAlive,
+		}), nil
 	default:
+		return nil, nil
+	}
+}
+
+// AddUser adds a user to an inbound in the Xray core using the specified protocol and user data.
+func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]any) error {
+	userEmail, err := getRequiredUserString(user, "email")
+	if err != nil {
+		return err
+	}
+
+	account, err := buildUserAccount(Protocol, user)
+	if err != nil {
+		return err
+	}
+	if account == nil {
 		return nil
 	}
 

+ 129 - 0
internal/xray/api_wireguard_test.go

@@ -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":        "[email protected]",
+		"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":      "[email protected]",
+		"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":      "[email protected]",
+		"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":     "[email protected]",
+		"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":      "[email protected]",
+		"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": "[email protected]"})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if tm != nil {
+		t.Fatal("expected nil account for unsupported protocol")
+	}
+}