diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 7aab7fcd7..903ee14af 100644 --- a/frontend/public/openapi.json +++ b/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", diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 2329d5983..f067a8ab4 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -212,6 +212,9 @@ export const EXAMPLES: Record = { "token": "new-token-string" }, "Client": { + "allowedIPs": [ + "" + ], "auth": "", "comment": "", "created_at": 0, @@ -221,8 +224,12 @@ export const EXAMPLES: Record = { "flow": "", "group": "", "id": "", + "keepAlive": 0, "limitIp": 0, "password": "", + "preSharedKey": "", + "privateKey": "", + "publicKey": "", "reset": 0, "reverse": null, "security": "", @@ -238,6 +245,7 @@ export const EXAMPLES: Record = { "inboundId": 0 }, "ClientRecord": { + "allowedIPs": "", "auth": "", "comment": "", "createdAt": 0, @@ -247,8 +255,12 @@ export const EXAMPLES: Record = { "flow": "", "group": "", "id": 0, + "keepAlive": 0, "limitIp": 0, "password": "", + "preSharedKey": "", + "privateKey": "", + "publicKey": "", "reset": 0, "reverse": null, "security": "", diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index c0d89936f..b2a11819a 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -1058,6 +1058,12 @@ export const SCHEMAS: Record = { "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 = { "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 = { "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 = { }, "ClientRecord": { "properties": { + "allowedIPs": { + "type": "string" + }, "auth": { "type": "string" }, @@ -1202,12 +1223,24 @@ export const SCHEMAS: Record = { "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 = { } }, "required": [ + "allowedIPs", "auth", "comment", "createdAt", @@ -1241,8 +1275,12 @@ export const SCHEMAS: Record = { "flow", "group", "id", + "keepAlive", "limitIp", "password", + "preSharedKey", + "privateKey", + "publicKey", "reset", "reverse", "security", diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index f68d17fb9..5909724f8 100644 --- a/frontend/src/generated/types.ts +++ b/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; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index bc19547d9..8e7516055 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -238,6 +238,7 @@ export const ApiTokenViewSchema = z.object({ export type ApiTokenView = z.infer; 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; 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(), diff --git a/frontend/src/lib/xray/inbound-defaults.ts b/frontend/src/lib/xray/inbound-defaults.ts index 6b945db65..ad5b5be7f 100644 --- a/frontend/src/lib/xray/inbound-defaults.ts +++ b/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, }; } diff --git a/frontend/src/lib/xray/inbound-form-adapter.ts b/frontend/src/lib/xray/inbound-form-adapter.ts index 74d757fa6..86151c5ff 100644 --- a/frontend/src/lib/xray/inbound-form-adapter.ts +++ b/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; } } diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index a3fccca3c..ae0b4ceb7 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/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)}`, diff --git a/frontend/src/pages/clients/ClientBulkAddModal.tsx b/frontend/src/pages/clients/ClientBulkAddModal.tsx index d1f6d92e3..983cf1052 100644 --- a/frontend/src/pages/clients/ClientBulkAddModal.tsx +++ b/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 { diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index 4c94871f6..5e55891ad 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/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(); + 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({ /> )} + {showWireguard && ( + <> + + + { + const priv = e.target.value; + update('wgPrivateKey', priv); + update('wgPublicKey', priv ? Wireguard.generateKeypair(priv).publicKey : ''); + }} + /> + - - {fields.map((field, idx) => ( -
- - - {t('pages.inbounds.info.peerNumber', { n: idx + 1 })} - - {() => { - const comment = form.getFieldValue(['settings', 'peers', field.name, 'comment']) as string | undefined; - return comment ? — {comment} : null; - }} - - {fields.length > 1 && ( - - {ipFields.map((ipField) => ( - - - - - {ipFields.length > 1 && ( - - )} - - ))} - - )} - - - - -
- ))} - - )} - ); } diff --git a/frontend/src/schemas/client.ts b/frontend/src/schemas/client.ts index 732dfd585..ee46681d4 100644 --- a/frontend/src/schemas/client.ts +++ b/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(); diff --git a/frontend/src/schemas/protocols/inbound/wireguard.ts b/frontend/src/schemas/protocols/inbound/wireguard.ts index e038c8c99..effa98f1f 100644 --- a/frontend/src/schemas/protocols/inbound/wireguard.ts +++ b/frontend/src/schemas/protocols/inbound/wireguard.ts @@ -33,10 +33,36 @@ export const WireguardInboundPeerSchema = z.object({ }); export type WireguardInboundPeer = z.infer; +// 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; + 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(), }); diff --git a/frontend/src/test/__snapshots__/inbound-defaults.test.ts.snap b/frontend/src/test/__snapshots__/inbound-defaults.test.ts.snap index b923cd4a6..9766a4f5b 100644 --- a/frontend/src/test/__snapshots__/inbound-defaults.test.ts.snap +++ b/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=", } `; diff --git a/frontend/src/test/__snapshots__/inbound-full.test.ts.snap b/frontend/src/test/__snapshots__/inbound-full.test.ts.snap index 22342aac9..68c6dfce4 100644 --- a/frontend/src/test/__snapshots__/inbound-full.test.ts.snap +++ b/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": [ diff --git a/frontend/src/test/__snapshots__/protocols.test.ts.snap b/frontend/src/test/__snapshots__/protocols.test.ts.snap index 4c5d0b4a6..aea5d4db7 100644 --- a/frontend/src/test/__snapshots__/protocols.test.ts.snap +++ b/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": [ diff --git a/frontend/src/test/inbound-defaults.test.ts b/frontend/src/test/inbound-defaults.test.ts index 36dcfb00a..9bfb31768 100644 --- a/frontend/src/test/inbound-defaults.test.ts +++ b/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([]); }); }); diff --git a/frontend/src/test/wireguard-clients-link.test.ts b/frontend/src/test/wireguard-clients-link.test.ts new file mode 100644 index 000000000..678eb9aca --- /dev/null +++ b/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'); + }); +}); diff --git a/internal/database/db.go b/internal/database/db.go index 79173da6d..4de124d48 100644 --- a/internal/database/db.go +++ b/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 } diff --git a/internal/database/model/model.go b/internal/database/model/model.go index ffd54ac4c..575aeb7d2 100644 --- a/internal/database/model/model.go +++ b/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) diff --git a/internal/database/model/model_wireguard_test.go b/internal/database/model/model_wireguard_test.go new file mode 100644 index 000000000..6c5a38489 --- /dev/null +++ b/internal/database/model/model_wireguard_test.go @@ -0,0 +1,81 @@ +package model + +import ( + "reflect" + "testing" +) + +func TestClientToRecordRoundTripWireGuard(t *testing.T) { + c := &Client{ + Email: "alice@example.test", + Enable: true, + PrivateKey: "cGVlci1wcml2YXRlLWtleS1iYXNlNjQtMzJieXRlcw==", + PublicKey: "cGVlci1wdWJsaWMta2V5LWJhc2U2NC0zMmJ5dGVzISE=", + AllowedIPs: []string{"10.0.0.2/32", "fd00::2/128"}, + PreSharedKey: "cHNrLWJhc2U2NC0zMmJ5dGVzLXBsYWNlaG9sZGVyISE=", + KeepAlive: 25, + } + + rec := c.ToRecord() + if rec.AllowedIPs != "10.0.0.2/32,fd00::2/128" { + t.Fatalf("AllowedIPs CSV = %q, want %q", rec.AllowedIPs, "10.0.0.2/32,fd00::2/128") + } + + got := rec.ToClient() + for _, f := range []struct { + name string + a, b any + }{ + {"PrivateKey", c.PrivateKey, got.PrivateKey}, + {"PublicKey", c.PublicKey, got.PublicKey}, + {"PreSharedKey", c.PreSharedKey, got.PreSharedKey}, + {"KeepAlive", c.KeepAlive, got.KeepAlive}, + } { + if f.a != f.b { + t.Errorf("%s round-trip = %v, want %v", f.name, f.b, f.a) + } + } + if !reflect.DeepEqual(got.AllowedIPs, c.AllowedIPs) { + t.Errorf("AllowedIPs round-trip = %v, want %v", got.AllowedIPs, c.AllowedIPs) + } +} + +func TestClientRecordEmptyAllowedIPs(t *testing.T) { + rec := &ClientRecord{Email: "bob@example.test", AllowedIPs: ""} + if got := rec.ToClient().AllowedIPs; got != nil { + t.Fatalf("empty CSV → AllowedIPs = %v, want nil", got) + } + + rec.AllowedIPs = " 10.0.0.5/32 , ," + if got := rec.ToClient().AllowedIPs; !reflect.DeepEqual(got, []string{"10.0.0.5/32"}) { + t.Fatalf("trimmed CSV → AllowedIPs = %v, want [10.0.0.5/32]", got) + } +} + +func TestMergeClientRecordWireGuardKeysPreserved(t *testing.T) { + existing := &ClientRecord{ + Email: "carol@example.test", + PrivateKey: "existing-private", + PublicKey: "existing-public", + AllowedIPs: "10.0.0.7/32", + UpdatedAt: 100, + } + incomingEmpty := &ClientRecord{Email: "carol@example.test", UpdatedAt: 200} + MergeClientRecord(existing, incomingEmpty) + if existing.PrivateKey != "existing-private" || existing.PublicKey != "existing-public" { + t.Fatalf("empty incoming wiped keys: priv=%q pub=%q", existing.PrivateKey, existing.PublicKey) + } + if existing.AllowedIPs != "10.0.0.7/32" { + t.Fatalf("empty incoming wiped allowedIPs: %q", existing.AllowedIPs) + } + + incomingNewer := &ClientRecord{ + Email: "carol@example.test", + AllowedIPs: "10.0.0.8/32", + UpdatedAt: 300, + } + MergeClientRecord(existing, incomingNewer) + if existing.AllowedIPs != "10.0.0.8/32" { + t.Fatalf("newer allowedIPs not applied: %q", existing.AllowedIPs) + } +} diff --git a/internal/database/wireguard_migration_test.go b/internal/database/wireguard_migration_test.go new file mode 100644 index 000000000..3ad910e72 --- /dev/null +++ b/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) + } +} diff --git a/internal/sub/service.go b/internal/sub/service.go index 6e39b70a4..3e40f4e81 100644 --- a/internal/sub/service.go +++ b/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 { diff --git a/internal/sub/service_wireguard_test.go b/internal/sub/service_wireguard_test.go new file mode 100644 index 000000000..582f356a3 --- /dev/null +++ b/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) + } +} diff --git a/internal/util/wireguard/wireguard.go b/internal/util/wireguard/wireguard.go index f8732d404..73ce36a46 100644 --- a/internal/util/wireguard/wireguard.go +++ b/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 +} diff --git a/internal/util/wireguard/wireguard_test.go b/internal/util/wireguard/wireguard_test.go new file mode 100644 index 000000000..753a2b6a4 --- /dev/null +++ b/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)) + } +} diff --git a/internal/web/runtime/local.go b/internal/web/runtime/local.go index a481e948d..be5a960d8 100644 --- a/internal/web/runtime/local.go +++ b/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() diff --git a/internal/web/service/client_inbound_apply.go b/internal/web/service/client_inbound_apply.go index ec7684383..d9292e4c9 100644 --- a/internal/web/service/client_inbound_apply.go +++ b/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) diff --git a/internal/web/service/client_link.go b/internal/web/service/client_link.go index c867e1d86..6ddf1c215 100644 --- a/internal/web/service/client_link.go +++ b/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 diff --git a/internal/web/service/client_wireguard.go b/internal/web/service/client_wireguard.go new file mode 100644 index 000000000..c02d4afe8 --- /dev/null +++ b/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 +} diff --git a/internal/web/service/client_wireguard_crud_test.go b/internal/web/service/client_wireguard_crud_test.go new file mode 100644 index 000000000..8d2e1b1ae --- /dev/null +++ b/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]) + } +} diff --git a/internal/web/service/client_wireguard_test.go b/internal/web/service/client_wireguard_test.go new file mode 100644 index 000000000..9a59bf331 --- /dev/null +++ b/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) + } +} diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go index 77cb7b117..7e027a08d 100644 --- a/internal/web/service/xray.go +++ b/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) { diff --git a/internal/web/service/xray_wireguard_config_test.go b/internal/web/service/xray_wireguard_config_test.go new file mode 100644 index 000000000..1cc5922ff --- /dev/null +++ b/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: "alice@wg.test", Enable: true, PublicKey: "pub-alice", AllowedIPs: []string{"10.0.0.2/32"}, KeepAlive: 25}, + {Email: "bob@wg.test", Enable: true, PublicKey: "pub-bob", AllowedIPs: []string{"10.0.0.3/32"}}, + } + seedWGInbound(t, "wg-multi", 51820, clients) + + settings := wgInboundEmittedSettings(t, "wg-multi") + if settings["secretKey"] != wgTestSecretKey() { + t.Errorf("secretKey not preserved: %v", settings["secretKey"]) + } + if settings["mtu"] != float64(1420) { + t.Errorf("mtu not preserved: %v", settings["mtu"]) + } + + peers := wgPeerList(t, settings) + if len(peers) != 2 { + t.Fatalf("expected 2 peers, got %d: %v", len(peers), peers) + } + ips := map[string]bool{} + for _, p := range peers { + if p["email"] == nil || p["email"] == "" { + t.Errorf("peer missing email: %v", p) + } + if p["publicKey"] == nil || p["publicKey"] == "" { + t.Errorf("peer missing publicKey: %v", p) + } + if p["level"] != float64(0) { + t.Errorf("peer level = %v, want 0 (needed for per-user stats)", p["level"]) + } + allowed, ok := p["allowedIPs"].([]any) + if !ok || len(allowed) == 0 { + t.Fatalf("peer missing allowedIPs: %v", p) + } + ips[allowed[0].(string)] = true + } + if len(ips) != 2 { + t.Errorf("peers must have distinct allowedIPs, got %v", ips) + } +} + +func TestGetXrayConfigWireGuardDisabledClientExcluded(t *testing.T) { + clients := []model.Client{ + {Email: "on@wg.test", Enable: true, PublicKey: "pub-on", AllowedIPs: []string{"10.0.0.2/32"}}, + {Email: "off@wg.test", Enable: true, PublicKey: "pub-off", AllowedIPs: []string{"10.0.0.3/32"}}, + } + seedWGInbound(t, "wg-disabled", 51821, clients) + + if err := database.GetDB().Model(&model.ClientRecord{}). + Where("email = ?", "off@wg.test").Update("enable", false).Error; err != nil { + t.Fatalf("disable client: %v", err) + } + + peers := wgPeerList(t, wgInboundEmittedSettings(t, "wg-disabled")) + if len(peers) != 1 { + t.Fatalf("expected 1 enabled peer, got %d: %v", len(peers), peers) + } + if peers[0]["email"] != "on@wg.test" { + t.Errorf("wrong peer kept: %v", peers[0]) + } +} + +func TestGetXrayConfigWireGuardNoClientsEmitsEmptyPeers(t *testing.T) { + seedWGInbound(t, "wg-empty", 51822, nil) + + settings := wgInboundEmittedSettings(t, "wg-empty") + if _, ok := settings["clients"]; ok { + t.Fatalf("clients key must be absent") + } + peers, ok := settings["peers"].([]any) + if !ok { + t.Fatalf("peers must be an (empty) array, got %T", settings["peers"]) + } + if len(peers) != 0 { + t.Fatalf("expected empty peers, got %v", peers) + } +} diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index f2b78d94a..ca1970277 100644 --- a/internal/web/translation/ar-EG.json +++ b/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": "معرّف مستخدم تلغرام", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index f4604dda5..6963c2ad5 100644 --- a/internal/web/translation/en-US.json +++ b/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", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index cf41513bd..5cdd72474 100644 --- a/internal/web/translation/es-ES.json +++ b/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", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index e086311c2..5aba21112 100644 --- a/internal/web/translation/fa-IR.json +++ b/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": "شناسه کاربر تلگرام", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index cdd5396dc..47ff83eda 100644 --- a/internal/web/translation/id-ID.json +++ b/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", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index d9160949a..92faf933f 100644 --- a/internal/web/translation/ja-JP.json +++ b/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", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index c4f7a6f4d..430ffd5ef 100644 --- a/internal/web/translation/pt-BR.json +++ b/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", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 0d75fb743..d31c09988 100644 --- a/internal/web/translation/ru-RU.json +++ b/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", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 44cbd32e9..9e0baa888 100644 --- a/internal/web/translation/tr-TR.json +++ b/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", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 49f755934..6967ada9e 100644 --- a/internal/web/translation/uk-UA.json +++ b/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", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index f347ab6a7..4924919c7 100644 --- a/internal/web/translation/vi-VN.json +++ b/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", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 1ebe00aa4..95fe209fd 100644 --- a/internal/web/translation/zh-CN.json +++ b/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", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 457753dda..53a38e26b 100644 --- a/internal/web/translation/zh-TW.json +++ b/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", diff --git a/internal/xray/api.go b/internal/xray/api.go index 921e77a93..6bfa35645 100644 --- a/internal/xray/api.go +++ b/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 } diff --git a/internal/xray/api_wireguard_test.go b/internal/xray/api_wireguard_test.go new file mode 100644 index 000000000..c116e0166 --- /dev/null +++ b/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": "alice@example.test", + "publicKey": pub, + "preSharedKey": psk, + "allowedIPs": []any{"10.0.0.2/32", "fd00::2/128"}, + "keepAlive": "25", + } + + pc := decodeWgAccount(t, user) + assertHexKey(t, "PublicKey", pc.PublicKey) + assertHexKey(t, "PreSharedKey", pc.PreSharedKey) + + wantPubHex, _ := hex.DecodeString(pc.PublicKey) + gotPub, _ := base64.StdEncoding.DecodeString(pub) + if string(wantPubHex) != string(gotPub) { + t.Fatal("PublicKey hex does not match the base64 input bytes") + } + + if len(pc.AllowedIps) != 2 || pc.AllowedIps[0] != "10.0.0.2/32" || pc.AllowedIps[1] != "fd00::2/128" { + t.Fatalf("AllowedIps = %v, want [10.0.0.2/32 fd00::2/128]", pc.AllowedIps) + } + if pc.KeepAlive != "25" { + t.Fatalf("KeepAlive = %q, want %q", pc.KeepAlive, "25") + } +} + +func TestBuildUserAccountWireGuardNoPSK(t *testing.T) { + user := map[string]any{ + "email": "bob@example.test", + "publicKey": b64Key(2), + "allowedIPs": []string{"10.0.0.3/32"}, + } + pc := decodeWgAccount(t, user) + if pc.PreSharedKey != "" { + t.Fatalf("PreSharedKey = %q, want empty", pc.PreSharedKey) + } + if pc.KeepAlive != "" { + t.Fatalf("KeepAlive = %q, want empty", pc.KeepAlive) + } +} + +func TestBuildUserAccountWireGuardMissingPublicKey(t *testing.T) { + user := map[string]any{ + "email": "c@example.test", + "allowedIPs": []any{"10.0.0.4/32"}, + } + if _, err := buildUserAccount("wireguard", user); err == nil { + t.Fatal("expected error for missing publicKey") + } +} + +func TestBuildUserAccountWireGuardMissingAllowedIPs(t *testing.T) { + user := map[string]any{ + "email": "d@example.test", + "publicKey": b64Key(3), + } + if _, err := buildUserAccount("wireguard", user); err == nil { + t.Fatal("expected error for missing allowedIPs") + } +} + +func TestBuildUserAccountWireGuardBadKey(t *testing.T) { + user := map[string]any{ + "email": "e@example.test", + "publicKey": "not-a-valid-key", + "allowedIPs": []any{"10.0.0.5/32"}, + } + if _, err := buildUserAccount("wireguard", user); err == nil { + t.Fatal("expected error for invalid publicKey") + } +} + +func TestBuildUserAccountUnknownProtocolReturnsNil(t *testing.T) { + tm, err := buildUserAccount("mtproto", map[string]any{"email": "x@example.test"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tm != nil { + t.Fatal("expected nil account for unsupported protocol") + } +}