feat(wireguard): multi-client support

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

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

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

Fix: guard the settings[clients] assertion in add/update so a legacy WireGuard inbound stored without a clients key no longer panics.
This commit is contained in:
MHSanaei
2026-06-28 00:44:38 +02:00
parent 33aada0c7c
commit 9c8cd08f90
50 changed files with 2160 additions and 258 deletions
+38
View File
@@ -1084,6 +1084,12 @@
"Client": {
"description": "Client represents a client configuration for Xray inbounds with traffic limits and settings.",
"properties": {
"allowedIPs": {
"items": {
"type": "string"
},
"type": "array"
},
"auth": {
"description": "Auth password (Hysteria)",
"type": "string"
@@ -1120,6 +1126,9 @@
"description": "Unique client identifier",
"type": "string"
},
"keepAlive": {
"type": "integer"
},
"limitIp": {
"description": "IP limit for this client",
"type": "integer"
@@ -1128,6 +1137,15 @@
"description": "Client password",
"type": "string"
},
"preSharedKey": {
"type": "string"
},
"privateKey": {
"type": "string"
},
"publicKey": {
"type": "string"
},
"reset": {
"description": "Reset period in days",
"type": "integer"
@@ -1201,6 +1219,9 @@
},
"ClientRecord": {
"properties": {
"allowedIPs": {
"type": "string"
},
"auth": {
"type": "string"
},
@@ -1228,12 +1249,24 @@
"id": {
"type": "integer"
},
"keepAlive": {
"type": "integer"
},
"limitIp": {
"type": "integer"
},
"password": {
"type": "string"
},
"preSharedKey": {
"type": "string"
},
"privateKey": {
"type": "string"
},
"publicKey": {
"type": "string"
},
"reset": {
"type": "integer"
},
@@ -1258,6 +1291,7 @@
}
},
"required": [
"allowedIPs",
"auth",
"comment",
"createdAt",
@@ -1267,8 +1301,12 @@
"flow",
"group",
"id",
"keepAlive",
"limitIp",
"password",
"preSharedKey",
"privateKey",
"publicKey",
"reset",
"reverse",
"security",
+12
View File
@@ -212,6 +212,9 @@ export const EXAMPLES: Record<string, unknown> = {
"token": "new-token-string"
},
"Client": {
"allowedIPs": [
""
],
"auth": "",
"comment": "",
"created_at": 0,
@@ -221,8 +224,12 @@ export const EXAMPLES: Record<string, unknown> = {
"flow": "",
"group": "",
"id": "",
"keepAlive": 0,
"limitIp": 0,
"password": "",
"preSharedKey": "",
"privateKey": "",
"publicKey": "",
"reset": 0,
"reverse": null,
"security": "",
@@ -238,6 +245,7 @@ export const EXAMPLES: Record<string, unknown> = {
"inboundId": 0
},
"ClientRecord": {
"allowedIPs": "",
"auth": "",
"comment": "",
"createdAt": 0,
@@ -247,8 +255,12 @@ export const EXAMPLES: Record<string, unknown> = {
"flow": "",
"group": "",
"id": 0,
"keepAlive": 0,
"limitIp": 0,
"password": "",
"preSharedKey": "",
"privateKey": "",
"publicKey": "",
"reset": 0,
"reverse": null,
"security": "",
+38
View File
@@ -1058,6 +1058,12 @@ export const SCHEMAS: Record<string, unknown> = {
"Client": {
"description": "Client represents a client configuration for Xray inbounds with traffic limits and settings.",
"properties": {
"allowedIPs": {
"items": {
"type": "string"
},
"type": "array"
},
"auth": {
"description": "Auth password (Hysteria)",
"type": "string"
@@ -1094,6 +1100,9 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Unique client identifier",
"type": "string"
},
"keepAlive": {
"type": "integer"
},
"limitIp": {
"description": "IP limit for this client",
"type": "integer"
@@ -1102,6 +1111,15 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Client password",
"type": "string"
},
"preSharedKey": {
"type": "string"
},
"privateKey": {
"type": "string"
},
"publicKey": {
"type": "string"
},
"reset": {
"description": "Reset period in days",
"type": "integer"
@@ -1175,6 +1193,9 @@ export const SCHEMAS: Record<string, unknown> = {
},
"ClientRecord": {
"properties": {
"allowedIPs": {
"type": "string"
},
"auth": {
"type": "string"
},
@@ -1202,12 +1223,24 @@ export const SCHEMAS: Record<string, unknown> = {
"id": {
"type": "integer"
},
"keepAlive": {
"type": "integer"
},
"limitIp": {
"type": "integer"
},
"password": {
"type": "string"
},
"preSharedKey": {
"type": "string"
},
"privateKey": {
"type": "string"
},
"publicKey": {
"type": "string"
},
"reset": {
"type": "integer"
},
@@ -1232,6 +1265,7 @@ export const SCHEMAS: Record<string, unknown> = {
}
},
"required": [
"allowedIPs",
"auth",
"comment",
"createdAt",
@@ -1241,8 +1275,12 @@ export const SCHEMAS: Record<string, unknown> = {
"flow",
"group",
"id",
"keepAlive",
"limitIp",
"password",
"preSharedKey",
"privateKey",
"publicKey",
"reset",
"reverse",
"security",
+10
View File
@@ -222,6 +222,7 @@ export interface ApiTokenView {
}
export interface Client {
allowedIPs?: string[];
auth?: string;
comment: string;
created_at?: number;
@@ -231,8 +232,12 @@ export interface Client {
flow?: string;
group?: string;
id?: string;
keepAlive?: number;
limitIp: number;
password?: string;
preSharedKey?: string;
privateKey?: string;
publicKey?: string;
reset: number;
reverse?: ClientReverse | null;
security: string;
@@ -250,6 +255,7 @@ export interface ClientInbound {
}
export interface ClientRecord {
allowedIPs: string;
auth: string;
comment: string;
createdAt: number;
@@ -259,8 +265,12 @@ export interface ClientRecord {
flow: string;
group: string;
id: number;
keepAlive: number;
limitIp: number;
password: string;
preSharedKey: string;
privateKey: string;
publicKey: string;
reset: number;
reverse: unknown;
security: string;
+10
View File
@@ -238,6 +238,7 @@ export const ApiTokenViewSchema = z.object({
export type ApiTokenView = z.infer<typeof ApiTokenViewSchema>;
export const ClientSchema = z.object({
allowedIPs: z.array(z.string()).optional(),
auth: z.string().optional(),
comment: z.string(),
created_at: z.number().int().optional(),
@@ -247,8 +248,12 @@ export const ClientSchema = z.object({
flow: z.string().optional(),
group: z.string().optional(),
id: z.string().optional(),
keepAlive: z.number().int().optional(),
limitIp: z.number().int(),
password: z.string().optional(),
preSharedKey: z.string().optional(),
privateKey: z.string().optional(),
publicKey: z.string().optional(),
reset: z.number().int(),
reverse: z.lazy(() => ClientReverseSchema).nullable().optional(),
security: z.string(),
@@ -268,6 +273,7 @@ export const ClientInboundSchema = z.object({
export type ClientInbound = z.infer<typeof ClientInboundSchema>;
export const ClientRecordSchema = z.object({
allowedIPs: z.string(),
auth: z.string(),
comment: z.string(),
createdAt: z.number().int(),
@@ -277,8 +283,12 @@ export const ClientRecordSchema = z.object({
flow: z.string(),
group: z.string(),
id: z.number().int(),
keepAlive: z.number().int(),
limitIp: z.number().int(),
password: z.string(),
preSharedKey: z.string(),
privateKey: z.string(),
publicKey: z.string(),
reset: z.number().int(),
reverse: z.unknown(),
security: z.string(),
+6 -10
View File
@@ -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,
};
}
@@ -6,6 +6,7 @@ import {
TrojanClientSchema,
VlessClientSchema,
VmessClientSchema,
WireguardClientSchema,
} from '@/schemas/protocols/inbound';
import type { StreamSettings } from '@/schemas/api/inbound';
import type { Sniffing } from '@/schemas/primitives';
@@ -234,6 +235,7 @@ function clientSchemaForProtocol(protocol: string): z.ZodType | null {
case 'trojan': return TrojanClientSchema;
case 'shadowsocks': return ShadowsocksClientSchema;
case 'hysteria': return HysteriaClientSchema;
case 'wireguard': return WireguardClientSchema;
default: return null;
}
}
+23 -4
View File
@@ -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)}`,
@@ -16,7 +16,7 @@ import { ClientBulkAddFormSchema, type ClientBulkAddFormValues } from '@/schemas
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
const MULTI_CLIENT_PROTOCOLS = new Set([
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria',
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'wireguard',
]);
interface ClientBulkAddModalProps {
+75 -2
View File
@@ -22,7 +22,7 @@ import {
import { DeleteOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, RetweetOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import { HttpUtil, RandomUtil } from '@/utils';
import { HttpUtil, RandomUtil, Wireguard } from '@/utils';
import { formatInboundLabel } from '@/lib/inbounds/label';
import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
@@ -35,7 +35,7 @@ const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305', 'none', 'zero'] as const;
const MULTI_CLIENT_PROTOCOLS = new Set([
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria',
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'wireguard',
]);
const CLIENT_FORM_MODAL_Z_INDEX = 1000;
@@ -113,6 +113,10 @@ interface FormState {
enable: boolean;
inboundIds: number[];
externalLinks: ExternalLinkRow[];
wgPrivateKey: string;
wgPublicKey: string;
wgPreSharedKey: string;
wgAllowedIPs: string;
}
function emptyForm(): FormState {
@@ -137,6 +141,10 @@ function emptyForm(): FormState {
enable: true,
inboundIds: [],
externalLinks: [],
wgPrivateKey: '',
wgPublicKey: '',
wgPreSharedKey: '',
wgAllowedIPs: '',
};
}
@@ -237,6 +245,10 @@ export default function ClientFormModal({
enable: !!client.enable,
inboundIds: Array.isArray(attachedIds) ? [...attachedIds] : [],
externalLinks: toExternalLinkRows(attachedExternalLinks),
wgPrivateKey: client.privateKey || '',
wgPublicKey: client.publicKey || '',
wgPreSharedKey: client.preSharedKey || '',
wgAllowedIPs: client.allowedIPs || '',
};
if (et < 0) {
next.delayedStart = true;
@@ -250,6 +262,7 @@ export default function ClientFormModal({
setForm(next);
void loadIps();
} else {
const wgKeypair = Wireguard.generateKeypair();
setForm({
...emptyForm(),
email: RandomUtil.randomLowerAndNum(10),
@@ -257,6 +270,8 @@ export default function ClientFormModal({
subId: RandomUtil.randomLowerAndNum(16),
password: RandomUtil.randomLowerAndNum(16),
auth: RandomUtil.randomLowerAndNum(16),
wgPrivateKey: wgKeypair.privateKey,
wgPublicKey: wgKeypair.publicKey,
});
}
@@ -287,6 +302,14 @@ export default function ClientFormModal({
return ids;
}, [inbounds]);
const wireguardIds = useMemo(() => {
const ids = new Set<number>();
for (const row of inbounds || []) {
if (row && row.protocol === 'wireguard') ids.add(row.id);
}
return ids;
}, [inbounds]);
const ss2022Method = useMemo(() => {
for (const id of form.inboundIds || []) {
const ib = (inbounds || []).find((row) => row.id === id);
@@ -317,6 +340,16 @@ export default function ClientFormModal({
[form.inboundIds, vmessIds],
);
const showWireguard = useMemo(
() => (form.inboundIds || []).some((id) => wireguardIds.has(id)),
[form.inboundIds, wireguardIds],
);
function regenerateWireguardKeys() {
const kp = Wireguard.generateKeypair();
setForm((prev) => ({ ...prev, wgPrivateKey: kp.privateKey, wgPublicKey: kp.publicKey }));
}
useEffect(() => {
if (!showFlow && form.flow) {
@@ -453,6 +486,14 @@ export default function ClientFormModal({
clientPayload.reverse = { tag: reverseTag };
}
if (showWireguard) {
clientPayload.privateKey = form.wgPrivateKey;
clientPayload.publicKey = form.wgPublicKey;
if (form.wgPreSharedKey) {
clientPayload.preSharedKey = form.wgPreSharedKey;
}
}
const externalLinks: ExternalLinkInput[] = form.externalLinks
.map((r) => ({ kind: r.kind, value: r.value.trim(), remark: '' }))
.filter((r) => r.value !== '');
@@ -736,6 +777,38 @@ export default function ClientFormModal({
/>
</Form.Item>
)}
{showWireguard && (
<>
<Form.Item label={t('pages.clients.wireguardPrivateKey')}>
<Space.Compact style={{ display: 'flex' }}>
<Input
value={form.wgPrivateKey}
style={{ flex: 1 }}
onChange={(e) => {
const priv = e.target.value;
update('wgPrivateKey', priv);
update('wgPublicKey', priv ? Wireguard.generateKeypair(priv).publicKey : '');
}}
/>
<Button icon={<ReloadOutlined />} onClick={regenerateWireguardKeys} />
</Space.Compact>
</Form.Item>
<Form.Item label={t('pages.clients.wireguardPublicKey')}>
<Input value={form.wgPublicKey} disabled />
</Form.Item>
<Form.Item label={t('pages.clients.wireguardPreSharedKey')}>
<Input
value={form.wgPreSharedKey}
onChange={(e) => update('wgPreSharedKey', e.target.value)}
/>
</Form.Item>
{isEdit && form.wgAllowedIPs && (
<Form.Item label={t('pages.clients.wireguardAllowedIPs')}>
<Input value={form.wgAllowedIPs} disabled />
</Form.Item>
)}
</>
)}
</>
),
},
@@ -278,12 +278,6 @@ export default function InboundFormModal({
form.setFieldValue(['settings', 'secretKey'], kp.privateKey);
};
const regenWgPeerKeypair = (peerName: number) => {
const kp = Wireguard.generateKeypair();
form.setFieldValue(['settings', 'peers', peerName, 'privateKey'], kp.privateKey);
form.setFieldValue(['settings', 'peers', peerName, 'publicKey'], kp.publicKey);
};
const matchesVlessAuth = (
block: { id?: string; label?: string } | undefined | null,
authId: string,
@@ -695,7 +689,7 @@ export default function InboundFormModal({
const protocolTab = (
<>
{protocol === Protocols.WIREGUARD && <WireguardFields wgPubKey={wgPubKey} regenInboundWg={regenInboundWg} regenWgPeerKeypair={regenWgPeerKeypair} />}
{protocol === Protocols.WIREGUARD && <WireguardFields wgPubKey={wgPubKey} regenInboundWg={regenInboundWg} />}
{protocol === Protocols.TUN && <TunFields />}
@@ -1,44 +1,14 @@
import { useTranslation } from 'react-i18next';
import { Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { MinusOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import { Wireguard } from '@/utils';
import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
interface WireguardFieldsProps {
wgPubKey: string;
regenInboundWg: () => void;
regenWgPeerKeypair: (name: number) => void;
}
function nextWgPeerAllowedIP(peers: Array<{ allowedIPs?: string[] }> | undefined): string {
const fallback = '10.0.0.2/32';
let maxInt = -1;
let prefix = 32;
for (const peer of peers ?? []) {
for (const ip of peer?.allowedIPs ?? []) {
const m = /^\s*(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,2}))?\s*$/.exec(String(ip));
if (!m) continue;
const octets = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
if (octets.some((o) => o > 255)) continue;
const asInt = octets[0] * 16777216 + octets[1] * 65536 + octets[2] * 256 + octets[3];
if (asInt > maxInt) {
maxInt = asInt;
prefix = m[5] !== undefined ? Math.min(Number(m[5]), 32) : 32;
}
}
}
if (maxInt < 0) return fallback;
const next = maxInt + 1;
const a = Math.floor(next / 16777216) % 256;
const b = Math.floor(next / 65536) % 256;
const c = Math.floor(next / 256) % 256;
const d = next % 256;
return `${a}.${b}.${c}.${d}/${prefix}`;
}
export default function WireguardFields({ wgPubKey, regenInboundWg, regenWgPeerKeypair }: WireguardFieldsProps) {
export default function WireguardFields({ wgPubKey, regenInboundWg }: WireguardFieldsProps) {
const { t } = useTranslation();
const form = Form.useFormInstance();
return (
<>
<Form.Item label={t('pages.xray.wireguard.secretKey')}>
@@ -74,96 +44,6 @@ export default function WireguardFields({ wgPubKey, regenInboundWg, regenWgPeerK
]}
/>
</Form.Item>
<Form.List name={['settings', 'peers']}>
{(fields, { add, remove }) => (
<>
<Form.Item label={t('pages.inbounds.form.peers')}>
<Button
size="small"
onClick={() => {
const kp = Wireguard.generateKeypair();
const peers = form.getFieldValue(['settings', 'peers']) as Array<{ allowedIPs?: string[] }> | undefined;
add({
privateKey: kp.privateKey,
publicKey: kp.publicKey,
allowedIPs: [nextWgPeerAllowedIP(peers)],
keepAlive: 0,
});
}}
>
<PlusOutlined /> {t('pages.inbounds.form.addPeer')}
</Button>
</Form.Item>
{fields.map((field, idx) => (
<div key={field.key} className="wg-peer">
<Divider titlePlacement="center">
<Space>
<span>{t('pages.inbounds.info.peerNumber', { n: idx + 1 })}</span>
<Form.Item noStyle shouldUpdate>
{() => {
const comment = form.getFieldValue(['settings', 'peers', field.name, 'comment']) as string | undefined;
return comment ? <span style={{ opacity: 0.65 }}> {comment}</span> : null;
}}
</Form.Item>
{fields.length > 1 && (
<Button
size="small"
danger
icon={<MinusOutlined />}
onClick={() => remove(field.name)}
/>
)}
</Space>
</Divider>
<Form.Item name={[field.name, 'comment']} label={t('comment')}>
<Input placeholder="e.g. Alice's laptop" />
</Form.Item>
<Form.Item label={t('pages.xray.wireguard.secretKey')}>
<Space.Compact block>
<Form.Item name={[field.name, 'privateKey']} noStyle>
<Input style={{ width: 'calc(100% - 32px)' }} />
</Form.Item>
<Button
icon={<ReloadOutlined />}
onClick={() => regenWgPeerKeypair(field.name)}
/>
</Space.Compact>
</Form.Item>
<Form.Item name={[field.name, 'publicKey']} label={t('pages.xray.wireguard.publicKey')}>
<Input />
</Form.Item>
<Form.Item name={[field.name, 'preSharedKey']} label="PSK">
<Input />
</Form.Item>
<Form.List name={[field.name, 'allowedIPs']}>
{(ipFields, { add: addIp, remove: removeIp }) => (
<Form.Item label={t('pages.xray.wireguard.allowedIPs')}>
<Button size="small" onClick={() => addIp('')}>
<PlusOutlined />
</Button>
{ipFields.map((ipField) => (
<Space.Compact key={ipField.key} block className="mt-4">
<Form.Item name={ipField.name} noStyle>
<Input />
</Form.Item>
{ipFields.length > 1 && (
<Button size="small" onClick={() => removeIp(ipField.name)}>
<MinusOutlined />
</Button>
)}
</Space.Compact>
))}
</Form.Item>
)}
</Form.List>
<Form.Item name={[field.name, 'keepAlive']} label={t('pages.inbounds.form.keepAlive')}>
<InputNumber min={0} />
</Form.Item>
</div>
))}
</>
)}
</Form.List>
</>
);
}
+5
View File
@@ -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();
@@ -33,10 +33,36 @@ export const WireguardInboundPeerSchema = z.object({
});
export type WireguardInboundPeer = z.infer<typeof WireguardInboundPeerSchema>;
// A WireGuard inbound client (multi-client model). Each client is one peer the
// server accepts: the panel stores its keypair so it can render a full .conf/QR,
// and allowedIPs is the client's unique tunnel address (allocated server-side
// when left blank). Keys are optional on the wire — the backend generates them
// when absent.
export const WireguardClientSchema = z.object({
privateKey: z.string().optional(),
publicKey: z.string().optional(),
preSharedKey: z.string().optional(),
allowedIPs: z.array(z.string()).default([]),
keepAlive: optionalClearedInt(z.number().int().min(0)),
email: z.string().min(1),
limitIp: z.number().int().min(0).default(0),
totalGB: z.number().int().min(0).default(0),
expiryTime: z.number().int().default(0),
enable: z.boolean().default(true),
tgId: z.union([z.number(), z.string()]).transform((v) => Number(v) || 0).default(0),
subId: z.string().default(''),
comment: z.string().default(''),
reset: z.number().int().min(0).default(0),
created_at: z.number().int().optional(),
updated_at: z.number().int().optional(),
});
export type WireguardClient = z.infer<typeof WireguardClientSchema>;
export const WireguardInboundSettingsSchema = z.object({
mtu: optionalClearedInt(z.number().int().min(1)),
secretKey: z.string().min(1),
peers: z.array(WireguardInboundPeerSchema).default([]),
clients: z.array(WireguardClientSchema).default([]),
noKernelTun: z.boolean().default(false),
domainStrategy: WireguardDomainStrategySchema.optional(),
});
@@ -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=",
}
`;
@@ -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": [
@@ -207,6 +207,7 @@ exports[`InboundSettingsSchema fixtures > parses wireguard-basic byte-stably 1`]
{
"protocol": "wireguard",
"settings": {
"clients": [],
"mtu": 1420,
"noKernelTun": false,
"peers": [
+2 -1
View File
@@ -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([]);
});
});
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest';
import { genWireguardConfigs, genWireguardLinks } from '@/lib/xray/inbound-link';
import { InboundSchema } from '@/schemas/api/inbound';
// Multi-client WireGuard renders one link/config per entry in settings.clients
// (the canonical store), not settings.peers. Each client carries its own
// privateKey + allowedIPs; the server public key is derived from secretKey.
function wgInbound() {
return InboundSchema.parse({
id: 90,
remark: 'wg-mc',
port: 51820,
protocol: 'wireguard',
settings: {
mtu: 1420,
secretKey: 'iJ2cBkrSGqRwIfYIDIxk7hr5RXfdR93MfJUL7yqkkH8=',
peers: [],
clients: [
{
email: 'alice',
privateKey: 'QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=',
publicKey: 'DGSYIcEKAUkA7HhzGSjxLZuV67BR3LeyU0BMLJzNVHQ=',
allowedIPs: ['10.0.0.2/32'],
keepAlive: 25,
},
{
email: 'bob',
privateKey: 'aGVsbG8td29ybGQtdGVzdC1wcml2YXRlLWtleS1ub3chIQ==',
publicKey: 'b3RoZXItcHVibGljLWtleS1mb3ItYm9iLXRlc3QtdmFsISE=',
allowedIPs: ['10.0.0.3/32'],
},
],
},
});
}
describe('wireguard multi-client link/config fan-out', () => {
it('emits one link per client from settings.clients', () => {
const out = genWireguardLinks({
inbound: wgInbound(),
remark: 'wg-mc',
fallbackHostname: 'wg.example.test',
});
const links = out.split('\r\n').filter(Boolean);
expect(links).toHaveLength(2);
expect(links[0]).toContain('wireguard://');
expect(links[0]).toContain('address=10.0.0.2%2F32');
expect(links[1]).toContain('address=10.0.0.3%2F32');
});
it('emits one .conf per client with its own address', () => {
const out = genWireguardConfigs({
inbound: wgInbound(),
remark: 'wg-mc',
fallbackHostname: 'wg.example.test',
});
const configs = out.split('\r\n[Interface]').length;
expect(out).toContain('Address = 10.0.0.2/32');
expect(out).toContain('Address = 10.0.0.3/32');
expect(configs).toBe(2);
});
it('falls back to settings.peers for legacy single-config inbounds', () => {
const legacy = InboundSchema.parse({
id: 91,
remark: 'wg-legacy',
port: 51820,
protocol: 'wireguard',
settings: {
secretKey: 'iJ2cBkrSGqRwIfYIDIxk7hr5RXfdR93MfJUL7yqkkH8=',
peers: [
{
privateKey: 'QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=',
publicKey: 'DGSYIcEKAUkA7HhzGSjxLZuV67BR3LeyU0BMLJzNVHQ=',
allowedIPs: ['10.0.0.9/32'],
},
],
},
});
const out = genWireguardLinks({ inbound: legacy, remark: 'wg-legacy', fallbackHostname: 'wg.example.test' });
const links = out.split('\r\n').filter(Boolean);
expect(links).toHaveLength(1);
expect(links[0]).toContain('address=10.0.0.9%2F32');
});
});
+148 -1
View File
@@ -192,6 +192,148 @@ func seedHostsFromExternalProxy() error {
})
}
// seedWireguardPeersToClients is a one-time, self-gated migration that converts
// legacy single-config WireGuard inbounds into the multi-client model: each
// settings.peers[] entry becomes a managed client in the clients table attached
// to the inbound, and the inbound settings are rewritten so peers becomes a
// clients[] array (GetXrayConfig re-projects clients back to peers for xray).
// Idempotent: gated on the history row and skipped per-inbound once it already
// has client links.
func seedWireguardPeersToClients() error {
var history []string
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &history).Error; err != nil {
return err
}
if slices.Contains(history, "WireguardPeersToClients") {
return nil
}
var inbounds []model.Inbound
if err := db.Where("protocol = ?", string(model.WireGuard)).Find(&inbounds).Error; err != nil {
return err
}
return db.Transaction(func(tx *gorm.DB) error {
usedEmails := map[string]struct{}{}
var existingEmails []string
if err := tx.Model(&model.ClientRecord{}).Pluck("email", &existingEmails).Error; err != nil {
return err
}
for _, e := range existingEmails {
usedEmails[e] = struct{}{}
}
for _, inbound := range inbounds {
if strings.TrimSpace(inbound.Settings) == "" {
continue
}
var settings map[string]any
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
log.Printf("WireguardPeersToClients: skip inbound %d (invalid settings json): %v", inbound.Id, err)
continue
}
peers, ok := settings["peers"].([]any)
if !ok || len(peers) == 0 {
continue
}
var linkCount int64
if err := tx.Model(&model.ClientInbound{}).Where("inbound_id = ?", inbound.Id).Count(&linkCount).Error; err != nil {
return err
}
if linkCount > 0 {
continue
}
clientObjs := make([]any, 0, len(peers))
for i, raw := range peers {
obj, ok := raw.(map[string]any)
if !ok {
continue
}
email := wireguardPeerEmail(inbound.Remark, obj, i, usedEmails)
usedEmails[email] = struct{}{}
obj["email"] = email
if sub, _ := obj["subId"].(string); strings.TrimSpace(sub) == "" {
obj["subId"] = random.NumLower(16)
}
if _, ok := obj["enable"]; !ok {
obj["enable"] = true
}
blob, err := json.Marshal(obj)
if err != nil {
continue
}
var c model.Client
if err := json.Unmarshal(blob, &c); err != nil {
log.Printf("WireguardPeersToClients: skip peer in inbound %d: %v", inbound.Id, err)
continue
}
c.Email = email
incoming := c.ToRecord()
var row model.ClientRecord
err = tx.Where("email = ?", email).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
if err := tx.Create(incoming).Error; err != nil {
return err
}
row = *incoming
} else if err != nil {
return err
} else {
model.MergeClientRecord(&row, incoming)
if err := tx.Save(&row).Error; err != nil {
return err
}
}
link := model.ClientInbound{ClientId: row.Id, InboundId: inbound.Id}
if err := tx.Where("client_id = ? AND inbound_id = ?", row.Id, inbound.Id).
FirstOrCreate(&link).Error; err != nil {
return err
}
clientObjs = append(clientObjs, obj)
}
delete(settings, "peers")
settings["clients"] = clientObjs
newSettings, err := json.Marshal(settings)
if err != nil {
return err
}
if err := tx.Model(&model.Inbound{}).Where("id = ?", inbound.Id).
Update("settings", string(newSettings)).Error; err != nil {
return err
}
}
return tx.Create(&model.HistoryOfSeeders{SeederName: "WireguardPeersToClients"}).Error
})
}
// wireguardPeerEmail derives a stable, unique client email for a migrated peer
// from the inbound remark plus the peer's comment (or its 1-based index).
func wireguardPeerEmail(remark string, peer map[string]any, index int, used map[string]struct{}) string {
base := strings.TrimSpace(remark)
if base == "" {
base = "wg"
}
suffix := strconv.Itoa(index + 1)
if c, ok := peer["comment"].(string); ok && strings.TrimSpace(c) != "" {
suffix = strings.TrimSpace(c)
}
email := strings.ReplaceAll(base+"-"+suffix, " ", "-")
candidate := email
for n := 2; ; n++ {
if _, taken := used[candidate]; !taken {
return candidate
}
candidate = email + "-" + strconv.Itoa(n)
}
}
// CreateHostsFromExternalProxy parses a legacy streamSettings.externalProxy array
// and inserts one Host row per entry on tx, returning the number of rows created.
// It is the shared core of both the one-time seedHostsFromExternalProxy startup
@@ -387,7 +529,7 @@ func runSeeders(isUsersEmpty bool) error {
}
if empty && isUsersEmpty {
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "ApiTokensHash", "LegacyProxySettingsCleanup"}
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "ApiTokensHash", "LegacyProxySettingsCleanup", "WireguardPeersToClients"}
for _, name := range seeders {
if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
return err
@@ -490,6 +632,11 @@ func runSeeders(isUsersEmpty bool) error {
if err := resetIpLimitsWithoutFail2ban(); err != nil {
return err
}
// Self-gated on the "WireguardPeersToClients" row.
if err := seedWireguardPeersToClients(); err != nil {
return err
}
return nil
}
+106 -37
View File
@@ -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)
@@ -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)
}
}
@@ -0,0 +1,190 @@
package database
import (
"encoding/json"
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func initWGMigrationDB(t *testing.T) {
t.Helper()
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB failed: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
}
func createWGInbound(t *testing.T, remark string, port int, peers []any) *model.Inbound {
t.Helper()
settings, err := json.Marshal(map[string]any{
"secretKey": "c2VjcmV0LWtleS1iYXNlNjQtMzJieXRlcy1wbGFjZWg=",
"mtu": 1420,
"peers": peers,
})
if err != nil {
t.Fatalf("marshal settings: %v", err)
}
in := &model.Inbound{
UserId: 1,
Remark: remark,
Port: port,
Protocol: model.WireGuard,
Settings: string(settings),
Tag: remark,
}
if err := db.Create(in).Error; err != nil {
t.Fatalf("create wg inbound: %v", err)
}
return in
}
func clearWGMigrationHistory(t *testing.T) {
t.Helper()
if err := db.Where("seeder_name = ?", "WireguardPeersToClients").Delete(&model.HistoryOfSeeders{}).Error; err != nil {
t.Fatalf("clear history: %v", err)
}
}
func reloadInboundSettings(t *testing.T, id int) map[string]any {
t.Helper()
var in model.Inbound
if err := db.First(&in, id).Error; err != nil {
t.Fatalf("reload inbound: %v", err)
}
var settings map[string]any
if err := json.Unmarshal([]byte(in.Settings), &settings); err != nil {
t.Fatalf("unmarshal settings: %v", err)
}
return settings
}
func wgPeer(comment, priv, pub, ip string, keepAlive int) any {
m := map[string]any{
"privateKey": priv,
"publicKey": pub,
"allowedIPs": []any{ip},
"keepAlive": keepAlive,
}
if comment != "" {
m["comment"] = comment
}
return m
}
func TestSeedWireguardPeersToClientsCreatesClients(t *testing.T) {
initWGMigrationDB(t)
in := createWGInbound(t, "wg-server", 51820, []any{
wgPeer("laptop", "priv-1", "pub-1", "10.0.0.2/32", 25),
})
clearWGMigrationHistory(t)
if err := seedWireguardPeersToClients(); err != nil {
t.Fatalf("seedWireguardPeersToClients: %v", err)
}
var rec model.ClientRecord
if err := db.Where("email = ?", "wg-server-laptop").First(&rec).Error; err != nil {
t.Fatalf("migrated client not found: %v", err)
}
if rec.PrivateKey != "priv-1" || rec.PublicKey != "pub-1" || rec.AllowedIPs != "10.0.0.2/32" {
t.Fatalf("wg columns not migrated: %+v", rec)
}
var linkCount int64
db.Model(&model.ClientInbound{}).Where("inbound_id = ? AND client_id = ?", in.Id, rec.Id).Count(&linkCount)
if linkCount != 1 {
t.Fatalf("expected 1 client_inbounds link, got %d", linkCount)
}
settings := reloadInboundSettings(t, in.Id)
if _, ok := settings["peers"]; ok {
t.Fatalf("peers key must be removed from stored settings")
}
clients, ok := settings["clients"].([]any)
if !ok || len(clients) != 1 {
t.Fatalf("settings.clients not written: %v", settings["clients"])
}
if settings["secretKey"] == nil || settings["mtu"] == nil {
t.Fatalf("server fields not preserved: %v", settings)
}
}
func TestSeedWireguardPeersToClientsIdempotent(t *testing.T) {
initWGMigrationDB(t)
in := createWGInbound(t, "wg-idem", 51823, []any{
wgPeer("", "priv-a", "pub-a", "10.0.0.2/32", 0),
})
clearWGMigrationHistory(t)
if err := seedWireguardPeersToClients(); err != nil {
t.Fatalf("first run: %v", err)
}
if err := seedWireguardPeersToClients(); err != nil {
t.Fatalf("second run (history gate): %v", err)
}
clearWGMigrationHistory(t)
if err := seedWireguardPeersToClients(); err != nil {
t.Fatalf("third run (linkCount gate): %v", err)
}
var clientCount int64
db.Model(&model.ClientInbound{}).Where("inbound_id = ?", in.Id).Count(&clientCount)
if clientCount != 1 {
t.Fatalf("expected exactly 1 link after repeated runs, got %d", clientCount)
}
}
func TestSeedWireguardPeersToClientsSkipsNonWireguard(t *testing.T) {
initWGMigrationDB(t)
vless := &model.Inbound{UserId: 1, Port: 41001, Protocol: model.VLESS, Tag: "vless-x", Settings: `{"clients":[]}`}
if err := db.Create(vless).Error; err != nil {
t.Fatalf("create vless: %v", err)
}
clearWGMigrationHistory(t)
if err := seedWireguardPeersToClients(); err != nil {
t.Fatalf("seed: %v", err)
}
var linkCount int64
db.Model(&model.ClientInbound{}).Where("inbound_id = ?", vless.Id).Count(&linkCount)
if linkCount != 0 {
t.Fatalf("vless inbound must be untouched, got %d links", linkCount)
}
}
func TestSeedWireguardPeersToClientsMultiplePeers(t *testing.T) {
initWGMigrationDB(t)
in := createWGInbound(t, "wg-multi", 51824, []any{
wgPeer("alpha", "p1", "pub1", "10.0.0.2/32", 0),
wgPeer("beta", "p2", "pub2", "10.0.0.3/32", 0),
})
clearWGMigrationHistory(t)
if err := seedWireguardPeersToClients(); err != nil {
t.Fatalf("seed: %v", err)
}
var links []model.ClientInbound
if err := db.Where("inbound_id = ?", in.Id).Find(&links).Error; err != nil {
t.Fatalf("load links: %v", err)
}
if len(links) != 2 {
t.Fatalf("expected 2 links, got %d", len(links))
}
settings := reloadInboundSettings(t, in.Id)
clients := settings["clients"].([]any)
ips := map[string]bool{}
emails := map[string]bool{}
for _, c := range clients {
m := c.(map[string]any)
emails[m["email"].(string)] = true
ip := m["allowedIPs"].([]any)[0].(string)
ips[ip] = true
}
if len(ips) != 2 || len(emails) != 2 {
t.Fatalf("expected distinct emails/ips, got emails=%v ips=%v", emails, ips)
}
}
+44 -1
View File
@@ -21,6 +21,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/util/random"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
@@ -367,7 +368,7 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error)
JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id
JOIN clients ON clients.id = client_inbounds.client_id
WHERE
inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria')
inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria','wireguard')
AND clients.sub_id = ? AND inbounds.enable = ?
)`, subId, true).Order("sub_sort_index ASC").Order("id ASC").Find(&inbounds).Error
if err != nil {
@@ -501,10 +502,52 @@ func (s *SubService) GetLink(inbound *model.Inbound, email string) string {
return s.genHysteriaLink(inbound, email)
case "mtproto":
return s.genMtprotoLink(inbound, email)
case "wireguard":
return s.genWireguardLink(inbound, email)
}
return ""
}
// genWireguardLink builds a per-client wireguard:// share link mirroring the
// frontend genWireguardLink: the client's private key is the userinfo, the
// server public key (derived from the inbound secretKey) and the client's
// tunnel address ride in the query. Returns "" when the client has no key.
func (s *SubService) genWireguardLink(inbound *model.Inbound, email string) string {
if inbound.Protocol != model.WireGuard {
return ""
}
settings := map[string]any{}
_ = json.Unmarshal([]byte(inbound.Settings), &settings)
secretKey, _ := settings["secretKey"].(string)
clients, _ := s.inboundService.GetClients(inbound)
var client *model.Client
for i := range clients {
if clients[i].Email == email {
client = &clients[i]
break
}
}
if client == nil || client.PrivateKey == "" {
return ""
}
link := fmt.Sprintf("wireguard://%s@%s", encodeUserinfo(client.PrivateKey), joinHostPort(s.resolveInboundAddress(inbound), inbound.Port))
params := make(map[string]string)
if secretKey != "" {
if pub, err := wgutil.PublicKeyFromPrivate(secretKey); err == nil {
params["publickey"] = pub
}
}
if len(client.AllowedIPs) > 0 && client.AllowedIPs[0] != "" {
params["address"] = client.AllowedIPs[0]
}
if mtu, ok := settings["mtu"].(float64); ok && mtu > 0 {
params["mtu"] = strconv.Itoa(int(mtu))
}
return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", ""))
}
// genMtprotoLink builds a Telegram proxy deep link for an mtproto inbound:
func (s *SubService) genMtprotoLink(inbound *model.Inbound, _ string) string {
if inbound.Protocol != model.MTProto {
+102
View File
@@ -0,0 +1,102 @@
package sub
import (
"net/url"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
func TestGenWireguardLinkFields(t *testing.T) {
serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("keypair: %v", err)
}
clientPriv, _, err := wgutil.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("client keypair: %v", err)
}
inbound := &model.Inbound{
Listen: "203.0.113.7",
Port: 51820,
Protocol: model.WireGuard,
Remark: "wg-sub",
Settings: `{"secretKey":"` + serverPriv + `","mtu":1420,"clients":[{"email":"user","privateKey":"` + clientPriv + `","allowedIPs":["10.0.0.2/32"],"keepAlive":25}]}`,
}
s := &SubService{}
link := s.genWireguardLink(inbound, "user")
u, err := url.Parse(link)
if err != nil {
t.Fatalf("link does not parse: %v\n got: %s", err, link)
}
if u.Scheme != "wireguard" {
t.Fatalf("scheme = %q, want wireguard", u.Scheme)
}
if u.Host != "203.0.113.7:51820" {
t.Fatalf("host = %q, want 203.0.113.7:51820", u.Host)
}
if u.User.Username() != clientPriv {
t.Fatalf("userinfo = %q, want client private key %q", u.User.Username(), clientPriv)
}
q := u.Query()
if q.Get("publickey") != serverPub {
t.Fatalf("publickey = %q, want server public key %q", q.Get("publickey"), serverPub)
}
if q.Get("address") != "10.0.0.2/32" {
t.Fatalf("address = %q, want 10.0.0.2/32", q.Get("address"))
}
if q.Get("mtu") != "1420" {
t.Fatalf("mtu = %q, want 1420", q.Get("mtu"))
}
}
func TestGenWireguardLinkWrongProtocol(t *testing.T) {
s := &SubService{}
vless := &model.Inbound{Protocol: model.VLESS, Settings: `{"clients":[{"email":"user"}]}`}
if got := s.genWireguardLink(vless, "user"); got != "" {
t.Fatalf("wrong protocol should yield empty link, got %q", got)
}
}
func TestGenWireguardLinkNoKey(t *testing.T) {
s := &SubService{}
inbound := &model.Inbound{
Protocol: model.WireGuard,
Port: 51820,
Settings: `{"secretKey":"x","clients":[{"email":"user"}]}`,
}
if got := s.genWireguardLink(inbound, "user"); got != "" {
t.Fatalf("client without private key should yield empty link, got %q", got)
}
}
func TestGetInboundsBySubIdIncludesWireguard(t *testing.T) {
initSubDB(t)
db := database.GetDB()
in := &model.Inbound{Port: 51820, Protocol: model.WireGuard, Enable: true, Tag: "wg-sub", Settings: `{"secretKey":"x","clients":[]}`}
if err := db.Create(in).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
rec := &model.ClientRecord{Email: "u@wg", SubID: "subwg", Enable: true}
if err := db.Create(rec).Error; err != nil {
t.Fatalf("create client: %v", err)
}
if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: in.Id}).Error; err != nil {
t.Fatalf("create link: %v", err)
}
s := &SubService{}
inbounds, err := s.getInboundsBySubId("subwg")
if err != nil {
t.Fatalf("getInboundsBySubId: %v", err)
}
if len(inbounds) != 1 || inbounds[0].Id != in.Id {
t.Fatalf("wireguard inbound not returned for subId: %+v", inbounds)
}
}
+75
View File
@@ -3,6 +3,9 @@ package wireguard
import (
"crypto/rand"
"encoding/base64"
"encoding/hex"
"errors"
"strings"
"golang.org/x/crypto/curve25519"
)
@@ -22,3 +25,75 @@ func GenerateWireguardKeypair() (privateKey string, publicKey string, err error)
return base64.StdEncoding.EncodeToString(priv[:]), base64.StdEncoding.EncodeToString(pub[:]), nil
}
// GenerateWireguardPSK generates a base64 encoded 32-byte pre-shared key for Wireguard.
func GenerateWireguardPSK() (string, error) {
var psk [32]byte
if _, err := rand.Read(psk[:]); err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(psk[:]), nil
}
// PublicKeyFromPrivate derives the base64 public key for a base64 (or hex) Wireguard private key.
func PublicKeyFromPrivate(privateKey string) (string, error) {
priv, err := decodeWireguardKey(privateKey)
if err != nil {
return "", err
}
var pub [32]byte
curve25519.ScalarBaseMult(&pub, &priv)
return base64.StdEncoding.EncodeToString(pub[:]), nil
}
// KeyToHex converts a base64 (or already-hex) 32-byte Wireguard key into the
// lowercase hex form xray-core's wireguard proxy expects: its ParseKey uses
// hex.DecodeString, and the device IPC layer wants hex for public_key and
// preshared_key. An empty input yields an empty result so optional keys pass
// through untouched.
func KeyToHex(key string) (string, error) {
if key == "" {
return "", nil
}
raw, err := decodeWireguardKey(key)
if err != nil {
return "", err
}
return hex.EncodeToString(raw[:]), nil
}
// decodeWireguardKey accepts a 64-char hex key or a base64 key (standard or
// URL-safe alphabet, with or without padding) and returns the raw 32 bytes.
func decodeWireguardKey(key string) ([32]byte, error) {
var out [32]byte
if key == "" {
return out, errors.New("wireguard: empty key")
}
if len(key) == 64 {
if raw, err := hex.DecodeString(key); err == nil {
if len(raw) != 32 {
return out, errors.New("wireguard: key must decode to 32 bytes")
}
copy(out[:], raw)
return out, nil
}
}
trimmed := strings.TrimRight(key, "=")
var raw []byte
var err error
if strings.ContainsAny(trimmed, "+/") {
raw, err = base64.RawStdEncoding.DecodeString(trimmed)
} else {
raw, err = base64.RawURLEncoding.DecodeString(trimmed)
}
if err != nil {
return out, err
}
if len(raw) != 32 {
return out, errors.New("wireguard: key must decode to 32 bytes")
}
copy(out[:], raw)
return out, nil
}
+123
View File
@@ -0,0 +1,123 @@
package wireguard
import (
"encoding/base64"
"encoding/hex"
"strings"
"testing"
)
func TestGenerateWireguardKeypairRoundTrip(t *testing.T) {
priv, pub, err := GenerateWireguardKeypair()
if err != nil {
t.Fatalf("GenerateWireguardKeypair: %v", err)
}
for name, key := range map[string]string{"private": priv, "public": pub} {
raw, err := base64.StdEncoding.DecodeString(key)
if err != nil {
t.Fatalf("%s key not base64: %v", name, err)
}
if len(raw) != 32 {
t.Fatalf("%s key decodes to %d bytes, want 32", name, len(raw))
}
}
derived, err := PublicKeyFromPrivate(priv)
if err != nil {
t.Fatalf("PublicKeyFromPrivate: %v", err)
}
if derived != pub {
t.Fatalf("PublicKeyFromPrivate(priv) = %q, want %q", derived, pub)
}
}
func TestPublicKeyFromPrivateKnownVector(t *testing.T) {
privHex := "77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a"
wantPubHex := "8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a"
privBytes, err := hex.DecodeString(privHex)
if err != nil {
t.Fatalf("decode priv vector: %v", err)
}
pubB64, err := PublicKeyFromPrivate(base64.StdEncoding.EncodeToString(privBytes))
if err != nil {
t.Fatalf("PublicKeyFromPrivate: %v", err)
}
gotPubHex, err := KeyToHex(pubB64)
if err != nil {
t.Fatalf("KeyToHex: %v", err)
}
if gotPubHex != wantPubHex {
t.Fatalf("derived public key hex = %q, want %q", gotPubHex, wantPubHex)
}
}
func TestKeyToHex(t *testing.T) {
low := make([]byte, 32)
for i := range low {
low[i] = byte(i)
}
high := make([]byte, 32)
for i := range high {
high[i] = 0xff
}
for _, raw := range [][]byte{low, high} {
wantHex := hex.EncodeToString(raw)
std := base64.StdEncoding.EncodeToString(raw)
url := base64.URLEncoding.EncodeToString(raw)
padless := strings.TrimRight(std, "=")
for label, in := range map[string]string{"std": std, "url": url, "padless": padless, "hex": wantHex} {
got, err := KeyToHex(in)
if err != nil {
t.Fatalf("KeyToHex(%s=%q): %v", label, in, err)
}
if got != wantHex {
t.Fatalf("KeyToHex(%s) = %q, want %q", label, got, wantHex)
}
if back, err := hex.DecodeString(got); err != nil || len(back) != 32 {
t.Fatalf("KeyToHex output not a 32-byte hex key: err=%v len=%d", err, len(back))
}
}
}
}
func TestKeyToHexEmpty(t *testing.T) {
got, err := KeyToHex("")
if err != nil {
t.Fatalf("KeyToHex(\"\"): %v", err)
}
if got != "" {
t.Fatalf("KeyToHex(\"\") = %q, want empty", got)
}
}
func TestKeyToHexRejectsBadInput(t *testing.T) {
cases := map[string]string{
"not base64": "this is not base64 @@@@",
"wrong length": base64.StdEncoding.EncodeToString(make([]byte, 16)),
}
for name, in := range cases {
if _, err := KeyToHex(in); err == nil {
t.Fatalf("KeyToHex(%s=%q) expected error, got nil", name, in)
}
}
}
func TestGenerateWireguardPSK(t *testing.T) {
a, err := GenerateWireguardPSK()
if err != nil {
t.Fatalf("GenerateWireguardPSK: %v", err)
}
b, err := GenerateWireguardPSK()
if err != nil {
t.Fatalf("GenerateWireguardPSK: %v", err)
}
if a == b {
t.Fatalf("two PSKs are identical: %q", a)
}
raw, err := base64.StdEncoding.DecodeString(a)
if err != nil || len(raw) != 32 {
t.Fatalf("PSK not a 32-byte base64 key: err=%v len=%d", err, len(raw))
}
}
+28 -12
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"strconv"
"strings"
"sync"
@@ -102,12 +103,16 @@ func (l *Local) AddClient(ctx context.Context, ib *model.Inbound, client model.C
return nil
}
user := map[string]any{
"email": client.Email,
"id": client.ID,
"security": client.Security,
"flow": client.Flow,
"auth": client.Auth,
"password": client.Password,
"email": client.Email,
"id": client.ID,
"security": client.Security,
"flow": client.Flow,
"auth": client.Auth,
"password": client.Password,
"publicKey": client.PublicKey,
"allowedIPs": client.AllowedIPs,
"preSharedKey": client.PreSharedKey,
"keepAlive": wgKeepAlive(client.KeepAlive),
}
return l.AddUser(ctx, ib, user)
}
@@ -135,16 +140,27 @@ func (l *Local) UpdateUser(ctx context.Context, ib *model.Inbound, oldEmail stri
return nil
}
user := map[string]any{
"email": payload.Email,
"id": payload.ID,
"security": payload.Security,
"flow": payload.Flow,
"auth": payload.Auth,
"password": payload.Password,
"email": payload.Email,
"id": payload.ID,
"security": payload.Security,
"flow": payload.Flow,
"auth": payload.Auth,
"password": payload.Password,
"publicKey": payload.PublicKey,
"allowedIPs": payload.AllowedIPs,
"preSharedKey": payload.PreSharedKey,
"keepAlive": wgKeepAlive(payload.KeepAlive),
}
return l.AddUser(ctx, ib, user)
}
func wgKeepAlive(seconds int) string {
if seconds <= 0 {
return ""
}
return strconv.Itoa(seconds)
}
func (l *Local) RestartXray(_ context.Context) error {
if l.deps.SetNeedRestart != nil {
l.deps.SetNeedRestart()
+73 -16
View File
@@ -295,6 +295,16 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
return false, err
}
if oldInbound.Protocol == model.WireGuard {
existing, gcErr := inboundSvc.GetClients(oldInbound)
if gcErr != nil {
return false, gcErr
}
if dErr := defaultWireguardClients(existing, clients, interfaceClients); dErr != nil {
return false, dErr
}
}
for _, client := range clients {
if strings.TrimSpace(client.Email) == "" {
return false, common.NewError("client email is required")
@@ -312,6 +322,10 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
if client.Auth == "" {
return false, common.NewError("empty client ID")
}
case "wireguard":
if client.PublicKey == "" {
return false, common.NewError("wireguard client requires a key")
}
default:
if client.ID == "" {
return false, common.NewError("empty client ID")
@@ -329,7 +343,7 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
applyShadowsocksClientMethod(interfaceClients, oldSettings)
}
oldClients := oldSettings["clients"].([]any)
oldClients, _ := oldSettings["clients"].([]any)
oldClients = compactOrphans(database.GetDB(), oldClients)
oldClients = append(oldClients, interfaceClients...)
@@ -395,13 +409,17 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
cipher = oldSettings["method"].(string)
}
err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
"email": client.Email,
"id": client.ID,
"auth": client.Auth,
"security": client.Security,
"flow": client.Flow,
"password": client.Password,
"cipher": cipher,
"email": client.Email,
"id": client.ID,
"auth": client.Auth,
"security": client.Security,
"flow": client.Flow,
"password": client.Password,
"cipher": cipher,
"publicKey": client.PublicKey,
"allowedIPs": client.AllowedIPs,
"preSharedKey": client.PreSharedKey,
"keepAlive": keepAliveStr(client.KeepAlive),
})
if err1 == nil {
logger.Debug("Client added on", rt.Name(), ":", client.Email)
@@ -472,6 +490,8 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
newClientId = clients[0].Email
case "hysteria":
newClientId = clients[0].Auth
case "wireguard":
newClientId = clients[0].Email
default:
newClientId = clients[0].ID
}
@@ -505,12 +525,34 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
}
}
// WireGuard keys are never rotated by an edit: when the incoming payload omits
// them (a metadata-only change), carry the stored credentials forward so the
// settings JSON and the running peer keep the client's identity.
if oldInbound.Protocol == model.WireGuard && clientIndex >= 0 && clientIndex < len(oldClients) {
old := oldClients[clientIndex]
if clients[0].PrivateKey == "" {
clients[0].PrivateKey = old.PrivateKey
}
if clients[0].PublicKey == "" {
clients[0].PublicKey = old.PublicKey
}
if len(clients[0].AllowedIPs) == 0 {
clients[0].AllowedIPs = old.AllowedIPs
}
if clients[0].PreSharedKey == "" {
clients[0].PreSharedKey = old.PreSharedKey
}
if clients[0].KeepAlive == 0 {
clients[0].KeepAlive = old.KeepAlive
}
}
var oldSettings map[string]any
err = json.Unmarshal([]byte(oldInbound.Settings), &oldSettings)
if err != nil {
return false, err
}
settingsClients := oldSettings["clients"].([]any)
settingsClients, _ := oldSettings["clients"].([]any)
var preservedCreated any
var preservedSubID string
if clientIndex >= 0 && clientIndex < len(settingsClients) {
@@ -536,6 +578,17 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
newMap["subId"] = random.NumLower(16)
}
}
if oldInbound.Protocol == model.WireGuard {
newMap["privateKey"] = clients[0].PrivateKey
newMap["publicKey"] = clients[0].PublicKey
newMap["allowedIPs"] = clients[0].AllowedIPs
if clients[0].PreSharedKey != "" {
newMap["preSharedKey"] = clients[0].PreSharedKey
}
if clients[0].KeepAlive > 0 {
newMap["keepAlive"] = clients[0].KeepAlive
}
}
interfaceClients[0] = newMap
}
}
@@ -681,13 +734,17 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
cipher = oldSettings["method"].(string)
}
err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
"email": clients[0].Email,
"id": clients[0].ID,
"security": clients[0].Security,
"flow": clients[0].Flow,
"auth": clients[0].Auth,
"password": clients[0].Password,
"cipher": cipher,
"email": clients[0].Email,
"id": clients[0].ID,
"security": clients[0].Security,
"flow": clients[0].Flow,
"auth": clients[0].Auth,
"password": clients[0].Password,
"cipher": cipher,
"publicKey": clients[0].PublicKey,
"allowedIPs": clients[0].AllowedIPs,
"preSharedKey": clients[0].PreSharedKey,
"keepAlive": keepAliveStr(clients[0].KeepAlive),
})
if err1 == nil {
logger.Debug("Client edited on", rt.Name(), ":", clients[0].Email)
+11
View File
@@ -82,6 +82,17 @@ func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.
if incoming.Reverse != "" {
row.Reverse = incoming.Reverse
}
if incoming.PrivateKey != "" {
row.PrivateKey = incoming.PrivateKey
}
if incoming.PublicKey != "" {
row.PublicKey = incoming.PublicKey
}
if incoming.AllowedIPs != "" {
row.AllowedIPs = incoming.AllowedIPs
}
row.PreSharedKey = incoming.PreSharedKey
row.KeepAlive = incoming.KeepAlive
row.SubID = incoming.SubID
row.LimitIP = incoming.LimitIP
row.TotalGB = incoming.TotalGB
+115
View File
@@ -0,0 +1,115 @@
package service
import (
"net/netip"
"strconv"
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
const defaultWireguardBase = "10.0.0.0/24"
func keepAliveStr(seconds int) string {
if seconds <= 0 {
return ""
}
return strconv.Itoa(seconds)
}
func wireguardHostAddr(s string) netip.Addr {
s = strings.TrimSpace(s)
if s == "" {
return netip.Addr{}
}
if p, err := netip.ParsePrefix(s); err == nil {
return p.Addr()
}
if a, err := netip.ParseAddr(s); err == nil {
return a
}
return netip.Addr{}
}
// allocateWireguardAddress returns the first free /32 host address in base that
// is not already present in used. The server holds the first host (.1), so
// allocation starts at the second host (.2).
func allocateWireguardAddress(used []string, base string) (string, error) {
if base == "" {
base = defaultWireguardBase
}
prefix, err := netip.ParsePrefix(base)
if err != nil {
return "", err
}
taken := make(map[netip.Addr]struct{}, len(used))
for _, u := range used {
if a := wireguardHostAddr(u); a.IsValid() {
taken[a] = struct{}{}
}
}
addr := prefix.Masked().Addr().Next().Next()
for prefix.Contains(addr) {
if _, ok := taken[addr]; !ok {
return addr.String() + "/32", nil
}
addr = addr.Next()
}
return "", common.NewError("wireguard: no free address available in", base)
}
// defaultWireguardClients fills in blank WireGuard credentials for newly added
// clients: a generated keypair when none was provided, a derived public key when
// only a private key was given, and a unique tunnel address allocated from the
// inbound's subnet. It mutates both the typed clients and the parallel raw client
// maps that get persisted into the inbound settings. Existing values are never
// overwritten, so editing a client never rotates its keys.
func defaultWireguardClients(existing, clients []model.Client, interfaceClients []any) error {
used := make([]string, 0)
for i := range existing {
used = append(used, existing[i].AllowedIPs...)
}
for i := range clients {
c := &clients[i]
if c.PrivateKey == "" && c.PublicKey == "" {
priv, pub, err := wgutil.GenerateWireguardKeypair()
if err != nil {
return err
}
c.PrivateKey = priv
c.PublicKey = pub
} else if c.PublicKey == "" && c.PrivateKey != "" {
pub, err := wgutil.PublicKeyFromPrivate(c.PrivateKey)
if err != nil {
return err
}
c.PublicKey = pub
}
if len(c.AllowedIPs) == 0 {
addr, err := allocateWireguardAddress(used, defaultWireguardBase)
if err != nil {
return err
}
c.AllowedIPs = []string{addr}
}
used = append(used, c.AllowedIPs...)
if i < len(interfaceClients) {
if m, ok := interfaceClients[i].(map[string]any); ok {
m["privateKey"] = c.PrivateKey
m["publicKey"] = c.PublicKey
m["allowedIPs"] = c.AllowedIPs
if c.PreSharedKey != "" {
m["preSharedKey"] = c.PreSharedKey
}
if c.KeepAlive > 0 {
m["keepAlive"] = c.KeepAlive
}
interfaceClients[i] = m
}
}
}
return nil
}
@@ -0,0 +1,144 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func wgServerSettings() string {
return `{"secretKey":"` + wgTestSecretKey() + `","mtu":1420,"clients":[]}`
}
func lookupClientRecord(t *testing.T, email string) model.ClientRecord {
t.Helper()
var rec model.ClientRecord
if err := database.GetDB().Where("email = ?", email).First(&rec).Error; err != nil {
t.Fatalf("lookup client %q: %v", email, err)
}
return rec
}
func TestWireGuardClientAddUpdateDeleteRoundTrip(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
ib := mkInbound(t, 51900, model.WireGuard, wgServerSettings())
add := &model.Inbound{Id: ib.Id, Protocol: model.WireGuard, Settings: clientsSettings(t, []model.Client{
{Email: "alice@wg", Enable: true},
})}
if _, err := svc.AddInboundClient(inboundSvc, add); err != nil {
t.Fatalf("AddInboundClient: %v", err)
}
list, err := svc.ListForInbound(nil, ib.Id)
if err != nil {
t.Fatalf("ListForInbound: %v", err)
}
if len(list) != 1 {
t.Fatalf("expected 1 attached client, got %d", len(list))
}
created := list[0]
if created.PrivateKey == "" || created.PublicKey == "" {
t.Fatalf("keys not generated/persisted: %+v", created)
}
if len(created.AllowedIPs) == 0 {
t.Fatalf("allowedIPs not allocated: %+v", created)
}
rec := lookupClientRecord(t, "alice@wg")
if rec.PrivateKey == "" || rec.AllowedIPs == "" {
t.Fatalf("client record missing wg columns: %+v", rec)
}
update := &model.Inbound{Id: ib.Id, Protocol: model.WireGuard, Settings: clientsSettings(t, []model.Client{
{Email: "alice@wg", Enable: true, Comment: "renamed laptop"},
})}
if _, err := svc.UpdateInboundClient(inboundSvc, update, "alice@wg"); err != nil {
t.Fatalf("UpdateInboundClient: %v", err)
}
afterUpdate := lookupClientRecord(t, "alice@wg")
if afterUpdate.PrivateKey != created.PrivateKey {
t.Fatalf("private key rotated on metadata edit: was %q now %q", created.PrivateKey, afterUpdate.PrivateKey)
}
if afterUpdate.PublicKey != created.PublicKey {
t.Fatalf("public key rotated on metadata edit: was %q now %q", created.PublicKey, afterUpdate.PublicKey)
}
if afterUpdate.Comment != "renamed laptop" {
t.Fatalf("comment not updated: %q", afterUpdate.Comment)
}
listAfter, err := svc.ListForInbound(nil, ib.Id)
if err != nil {
t.Fatalf("ListForInbound after update: %v", err)
}
if len(listAfter) != 1 || len(listAfter[0].AllowedIPs) == 0 {
t.Fatalf("settings lost wg fields after metadata edit: %+v", listAfter)
}
if _, err := svc.DelInboundClientByEmail(inboundSvc, ib.Id, "alice@wg", false); err != nil {
t.Fatalf("DelInboundClientByEmail: %v", err)
}
final, err := svc.ListForInbound(nil, ib.Id)
if err != nil {
t.Fatalf("ListForInbound after delete: %v", err)
}
if len(final) != 0 {
t.Fatalf("client not detached after delete: %+v", final)
}
}
func TestWireGuardClientAddToInboundWithoutClientsKey(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
ib := mkInbound(t, 51902, model.WireGuard, `{"secretKey":"`+wgTestSecretKey()+`","mtu":1420,"peers":[]}`)
add := &model.Inbound{Id: ib.Id, Protocol: model.WireGuard, Settings: clientsSettings(t, []model.Client{
{Email: "first@wg", Enable: true},
})}
if _, err := svc.AddInboundClient(inboundSvc, add); err != nil {
t.Fatalf("AddInboundClient onto clients-less wireguard inbound: %v", err)
}
list, err := svc.ListForInbound(nil, ib.Id)
if err != nil {
t.Fatalf("ListForInbound: %v", err)
}
if len(list) != 1 || list[0].PrivateKey == "" || len(list[0].AllowedIPs) == 0 {
t.Fatalf("client not added with generated keys/address: %+v", list)
}
}
func TestWireGuardClientAllocatesUniqueIPsAcrossTwoAdds(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
ib := mkInbound(t, 51901, model.WireGuard, wgServerSettings())
for _, email := range []string{"one@wg", "two@wg"} {
add := &model.Inbound{Id: ib.Id, Protocol: model.WireGuard, Settings: clientsSettings(t, []model.Client{
{Email: email, Enable: true},
})}
if _, err := svc.AddInboundClient(inboundSvc, add); err != nil {
t.Fatalf("AddInboundClient(%s): %v", email, err)
}
}
list, err := svc.ListForInbound(nil, ib.Id)
if err != nil {
t.Fatalf("ListForInbound: %v", err)
}
if len(list) != 2 {
t.Fatalf("expected 2 clients, got %d", len(list))
}
if list[0].AllowedIPs[0] == list[1].AllowedIPs[0] {
t.Fatalf("two adds collided on address %q", list[0].AllowedIPs[0])
}
}
@@ -0,0 +1,110 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
func TestAllocateWireguardAddress(t *testing.T) {
tests := []struct {
name string
used []string
base string
want string
err bool
}{
{name: "empty starts at .2", used: nil, base: "10.0.0.0/24", want: "10.0.0.2/32"},
{name: "skips used", used: []string{"10.0.0.2/32"}, base: "10.0.0.0/24", want: "10.0.0.3/32"},
{name: "fills gap", used: []string{"10.0.0.3/32", "10.0.0.4/32"}, base: "10.0.0.0/24", want: "10.0.0.2/32"},
{name: "ignores catch-all", used: []string{"0.0.0.0/0", "::/0"}, base: "10.0.0.0/24", want: "10.0.0.2/32"},
{name: "default base when empty", used: nil, base: "", want: "10.0.0.2/32"},
{name: "exhausted /30", used: []string{"10.9.0.2/32", "10.9.0.3/32"}, base: "10.9.0.0/30", err: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := allocateWireguardAddress(tt.used, tt.base)
if tt.err {
if err == nil {
t.Fatalf("expected error, got %q", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Fatalf("got %q, want %q", got, tt.want)
}
})
}
}
func TestDefaultWireguardClientsGeneratesKeypair(t *testing.T) {
clients := []model.Client{{Email: "a@wg"}}
ifaces := []any{map[string]any{"email": "a@wg"}}
if err := defaultWireguardClients(nil, clients, ifaces); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
c := clients[0]
if c.PrivateKey == "" || c.PublicKey == "" {
t.Fatalf("keypair not generated: priv=%q pub=%q", c.PrivateKey, c.PublicKey)
}
if len(c.AllowedIPs) != 1 || c.AllowedIPs[0] != "10.0.0.2/32" {
t.Fatalf("allowedIPs not allocated: %v", c.AllowedIPs)
}
m := ifaces[0].(map[string]any)
if m["privateKey"] != c.PrivateKey || m["publicKey"] != c.PublicKey {
t.Fatalf("interface map not updated: %v", m)
}
}
func TestDefaultWireguardClientsDerivesPublicKey(t *testing.T) {
priv, _, err := wgutil.GenerateWireguardKeypair()
if err != nil {
t.Fatal(err)
}
wantPub, err := wgutil.PublicKeyFromPrivate(priv)
if err != nil {
t.Fatal(err)
}
clients := []model.Client{{Email: "b@wg", PrivateKey: priv}}
ifaces := []any{map[string]any{"email": "b@wg"}}
if err := defaultWireguardClients(nil, clients, ifaces); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
if clients[0].PublicKey != wantPub {
t.Fatalf("derived public key = %q, want %q", clients[0].PublicKey, wantPub)
}
}
func TestDefaultWireguardClientsPreservesProvided(t *testing.T) {
clients := []model.Client{{
Email: "c@wg",
PrivateKey: "keep-priv",
PublicKey: "keep-pub",
AllowedIPs: []string{"10.0.0.50/32"},
}}
ifaces := []any{map[string]any{"email": "c@wg"}}
if err := defaultWireguardClients(nil, clients, ifaces); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
if clients[0].PrivateKey != "keep-priv" || clients[0].PublicKey != "keep-pub" {
t.Fatalf("provided keys were rotated: %+v", clients[0])
}
if clients[0].AllowedIPs[0] != "10.0.0.50/32" {
t.Fatalf("provided allowedIPs changed: %v", clients[0].AllowedIPs)
}
}
func TestDefaultWireguardClientsAllocatesDistinctIPs(t *testing.T) {
clients := []model.Client{{Email: "x@wg"}, {Email: "y@wg"}}
ifaces := []any{map[string]any{"email": "x@wg"}, map[string]any{"email": "y@wg"}}
if err := defaultWireguardClients(nil, clients, ifaces); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
if clients[0].AllowedIPs[0] == clients[1].AllowedIPs[0] {
t.Fatalf("two clients got the same address: %v", clients[0].AllowedIPs)
}
}
+31 -4
View File
@@ -157,6 +157,7 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
}
var finalClients []any
var wgPeers []any
for i := range dbClients {
c := dbClients[i]
if enable, exists := enableMap[c.Email]; exists && !enable {
@@ -204,14 +205,40 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
if c.Auth != "" {
entry["auth"] = c.Auth
}
case model.WireGuard:
peer := map[string]any{"email": c.Email, "level": 0}
if c.PublicKey != "" {
peer["publicKey"] = c.PublicKey
}
if len(c.AllowedIPs) > 0 {
peer["allowedIPs"] = c.AllowedIPs
}
if c.PreSharedKey != "" {
peer["preSharedKey"] = c.PreSharedKey
}
if c.KeepAlive > 0 {
peer["keepAlive"] = c.KeepAlive
}
wgPeers = append(wgPeers, peer)
continue
}
finalClients = append(finalClients, entry)
}
_, hadClients := settings["clients"]
mutated := hadClients || len(finalClients) > 0
if mutated {
settings["clients"] = finalClients
var mutated bool
if inbound.Protocol == model.WireGuard {
delete(settings, "clients")
if wgPeers == nil {
wgPeers = []any{}
}
settings["peers"] = wgPeers
mutated = true
} else {
_, hadClients := settings["clients"]
mutated = hadClients || len(finalClients) > 0
if mutated {
settings["clients"] = finalClients
}
}
if inboundCanHostFallbacks(inbound) {
@@ -0,0 +1,154 @@
package service
import (
"encoding/base64"
"encoding/json"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func wgTestSecretKey() string {
return base64.StdEncoding.EncodeToString(make([]byte, 32))
}
func wgInboundEmittedSettings(t *testing.T, tag string) map[string]any {
t.Helper()
svc := &XrayService{}
cfg, err := svc.GetXrayConfig()
if err != nil {
t.Fatalf("GetXrayConfig: %v", err)
}
for i := range cfg.InboundConfigs {
ic := cfg.InboundConfigs[i]
if ic.Tag != tag {
continue
}
var s map[string]any
if err := json.Unmarshal([]byte(ic.Settings), &s); err != nil {
t.Fatalf("unmarshal emitted settings: %v", err)
}
return s
}
t.Fatalf("inbound %q not found in generated config", tag)
return nil
}
func seedWGInbound(t *testing.T, tag string, port int, clients []model.Client) {
t.Helper()
setupSettingTestDB(t)
db := database.GetDB()
in := &model.Inbound{
Tag: tag,
Enable: true,
Port: port,
Protocol: model.WireGuard,
Settings: `{"secretKey":"` + wgTestSecretKey() + `","mtu":1420}`,
}
if err := db.Create(in).Error; err != nil {
t.Fatalf("create wg inbound: %v", err)
}
svc := ClientService{}
if err := svc.SyncInbound(nil, in.Id, clients); err != nil {
t.Fatalf("SyncInbound: %v", err)
}
}
func wgPeerList(t *testing.T, settings map[string]any) []map[string]any {
t.Helper()
if _, ok := settings["clients"]; ok {
t.Fatalf("wireguard inbound must not emit a clients[] key: %v", settings["clients"])
}
rawPeers, ok := settings["peers"].([]any)
if !ok {
t.Fatalf("settings.peers is not an array: %T", settings["peers"])
}
out := make([]map[string]any, 0, len(rawPeers))
for _, p := range rawPeers {
m, ok := p.(map[string]any)
if !ok {
t.Fatalf("peer is not an object: %T", p)
}
out = append(out, m)
}
return out
}
func TestGetXrayConfigWireGuardPeers(t *testing.T) {
clients := []model.Client{
{Email: "alice@wg.test", Enable: true, PublicKey: "pub-alice", AllowedIPs: []string{"10.0.0.2/32"}, KeepAlive: 25},
{Email: "bob@wg.test", Enable: true, PublicKey: "pub-bob", AllowedIPs: []string{"10.0.0.3/32"}},
}
seedWGInbound(t, "wg-multi", 51820, clients)
settings := wgInboundEmittedSettings(t, "wg-multi")
if settings["secretKey"] != wgTestSecretKey() {
t.Errorf("secretKey not preserved: %v", settings["secretKey"])
}
if settings["mtu"] != float64(1420) {
t.Errorf("mtu not preserved: %v", settings["mtu"])
}
peers := wgPeerList(t, settings)
if len(peers) != 2 {
t.Fatalf("expected 2 peers, got %d: %v", len(peers), peers)
}
ips := map[string]bool{}
for _, p := range peers {
if p["email"] == nil || p["email"] == "" {
t.Errorf("peer missing email: %v", p)
}
if p["publicKey"] == nil || p["publicKey"] == "" {
t.Errorf("peer missing publicKey: %v", p)
}
if p["level"] != float64(0) {
t.Errorf("peer level = %v, want 0 (needed for per-user stats)", p["level"])
}
allowed, ok := p["allowedIPs"].([]any)
if !ok || len(allowed) == 0 {
t.Fatalf("peer missing allowedIPs: %v", p)
}
ips[allowed[0].(string)] = true
}
if len(ips) != 2 {
t.Errorf("peers must have distinct allowedIPs, got %v", ips)
}
}
func TestGetXrayConfigWireGuardDisabledClientExcluded(t *testing.T) {
clients := []model.Client{
{Email: "on@wg.test", Enable: true, PublicKey: "pub-on", AllowedIPs: []string{"10.0.0.2/32"}},
{Email: "off@wg.test", Enable: true, PublicKey: "pub-off", AllowedIPs: []string{"10.0.0.3/32"}},
}
seedWGInbound(t, "wg-disabled", 51821, clients)
if err := database.GetDB().Model(&model.ClientRecord{}).
Where("email = ?", "off@wg.test").Update("enable", false).Error; err != nil {
t.Fatalf("disable client: %v", err)
}
peers := wgPeerList(t, wgInboundEmittedSettings(t, "wg-disabled"))
if len(peers) != 1 {
t.Fatalf("expected 1 enabled peer, got %d: %v", len(peers), peers)
}
if peers[0]["email"] != "on@wg.test" {
t.Errorf("wrong peer kept: %v", peers[0])
}
}
func TestGetXrayConfigWireGuardNoClientsEmitsEmptyPeers(t *testing.T) {
seedWGInbound(t, "wg-empty", 51822, nil)
settings := wgInboundEmittedSettings(t, "wg-empty")
if _, ok := settings["clients"]; ok {
t.Fatalf("clients key must be absent")
}
peers, ok := settings["peers"].([]any)
if !ok {
t.Fatalf("peers must be an (empty) array, got %T", settings["peers"])
}
if len(peers) != 0 {
t.Fatalf("expected empty peers, got %v", peers)
}
}
+4
View File
@@ -885,6 +885,10 @@
"uuid": "UUID",
"flow": "Flow",
"vmessSecurity": "أمان VMess",
"wireguardPrivateKey": "مفتاح وايرغارد الخاص",
"wireguardPublicKey": "مفتاح وايرغارد العام",
"wireguardPreSharedKey": "مفتاح وايرغارد المشترك مسبقًا",
"wireguardAllowedIPs": "عناوين IP المسموحة لوايرغارد",
"reverseTag": "وسم عكسي",
"reverseTagPlaceholder": "Reverse tag اختياري",
"telegramId": "معرّف مستخدم تلغرام",
+4
View File
@@ -888,6 +888,10 @@
"uuid": "UUID",
"flow": "Flow",
"vmessSecurity": "VMess Security",
"wireguardPrivateKey": "WireGuard Private Key",
"wireguardPublicKey": "WireGuard Public Key",
"wireguardPreSharedKey": "WireGuard Pre-Shared Key",
"wireguardAllowedIPs": "WireGuard Allowed IPs",
"reverseTag": "Reverse tag",
"reverseTagPlaceholder": "Optional reverse tag",
"telegramId": "Telegram user ID",
+4
View File
@@ -885,6 +885,10 @@
"uuid": "UUID",
"flow": "Flow",
"vmessSecurity": "Seguridad VMess",
"wireguardPrivateKey": "Clave privada de WireGuard",
"wireguardPublicKey": "Clave pública de WireGuard",
"wireguardPreSharedKey": "Clave precompartida de WireGuard",
"wireguardAllowedIPs": "IP permitidas de WireGuard",
"reverseTag": "Etiqueta inversa",
"reverseTagPlaceholder": "Reverse tag opcional",
"telegramId": "ID de usuario de Telegram",
+4
View File
@@ -885,6 +885,10 @@
"uuid": "UUID",
"flow": "Flow",
"vmessSecurity": "امنیت VMess",
"wireguardPrivateKey": "کلید خصوصی وایرگارد",
"wireguardPublicKey": "کلید عمومی وایرگارد",
"wireguardPreSharedKey": "کلید پیش‌اشتراکی وایرگارد",
"wireguardAllowedIPs": "آی‌پی‌های مجاز وایرگارد",
"reverseTag": "تگ معکوس",
"reverseTagPlaceholder": "Reverse tag اختیاری",
"telegramId": "شناسه کاربر تلگرام",
+4
View File
@@ -885,6 +885,10 @@
"uuid": "UUID",
"flow": "Flow",
"vmessSecurity": "Keamanan VMess",
"wireguardPrivateKey": "Kunci Privat WireGuard",
"wireguardPublicKey": "Kunci Publik WireGuard",
"wireguardPreSharedKey": "Kunci Pra-Berbagi WireGuard",
"wireguardAllowedIPs": "IP yang Diizinkan WireGuard",
"reverseTag": "Reverse tag",
"reverseTagPlaceholder": "Reverse tag opsional",
"telegramId": "ID pengguna Telegram",
+4
View File
@@ -885,6 +885,10 @@
"uuid": "UUID",
"flow": "Flow",
"vmessSecurity": "VMess セキュリティ",
"wireguardPrivateKey": "WireGuard 秘密鍵",
"wireguardPublicKey": "WireGuard 公開鍵",
"wireguardPreSharedKey": "WireGuard 事前共有鍵",
"wireguardAllowedIPs": "WireGuard 許可IP",
"reverseTag": "Reverse tag",
"reverseTagPlaceholder": "任意の Reverse tag",
"telegramId": "Telegram ユーザー ID",
+4
View File
@@ -885,6 +885,10 @@
"uuid": "UUID",
"flow": "Flow",
"vmessSecurity": "Segurança VMess",
"wireguardPrivateKey": "Chave privada do WireGuard",
"wireguardPublicKey": "Chave pública do WireGuard",
"wireguardPreSharedKey": "Chave pré-compartilhada do WireGuard",
"wireguardAllowedIPs": "IPs permitidos do WireGuard",
"reverseTag": "Tag reversa",
"reverseTagPlaceholder": "Reverse tag opcional",
"telegramId": "ID de usuário do Telegram",
+4
View File
@@ -885,6 +885,10 @@
"uuid": "UUID",
"flow": "Flow",
"vmessSecurity": "VMess Security",
"wireguardPrivateKey": "Приватный ключ WireGuard",
"wireguardPublicKey": "Публичный ключ WireGuard",
"wireguardPreSharedKey": "Общий ключ WireGuard",
"wireguardAllowedIPs": "Разрешённые IP WireGuard",
"reverseTag": "Обратный тег",
"reverseTagPlaceholder": "Необязательный Reverse tag",
"telegramId": "ID пользователя Telegram",
+4
View File
@@ -885,6 +885,10 @@
"uuid": "UUID",
"flow": "Flow",
"vmessSecurity": "VMess Güvenlik",
"wireguardPrivateKey": "WireGuard Özel Anahtarı",
"wireguardPublicKey": "WireGuard Genel Anahtarı",
"wireguardPreSharedKey": "WireGuard Ön Paylaşımlı Anahtar",
"wireguardAllowedIPs": "WireGuard İzin Verilen IP'ler",
"reverseTag": "Reverse Tag",
"reverseTagPlaceholder": "İsteğe Bağlı Reverse Tag",
"telegramId": "Telegram Kullanıcı ID'si",
+4
View File
@@ -885,6 +885,10 @@
"uuid": "UUID",
"flow": "Flow",
"vmessSecurity": "Безпека VMess",
"wireguardPrivateKey": "Приватний ключ WireGuard",
"wireguardPublicKey": "Публічний ключ WireGuard",
"wireguardPreSharedKey": "Спільний ключ WireGuard",
"wireguardAllowedIPs": "Дозволені IP WireGuard",
"reverseTag": "Зворотний тег",
"reverseTagPlaceholder": "Необов'язковий Reverse tag",
"telegramId": "ID користувача Telegram",
+4
View File
@@ -885,6 +885,10 @@
"uuid": "UUID",
"flow": "Flow",
"vmessSecurity": "Bảo mật VMess",
"wireguardPrivateKey": "Khóa riêng WireGuard",
"wireguardPublicKey": "Khóa công khai WireGuard",
"wireguardPreSharedKey": "Khóa chia sẻ trước WireGuard",
"wireguardAllowedIPs": "IP được phép WireGuard",
"reverseTag": "Reverse tag",
"reverseTagPlaceholder": "Reverse tag tùy chọn",
"telegramId": "ID người dùng Telegram",
+4
View File
@@ -885,6 +885,10 @@
"uuid": "UUID",
"flow": "Flow",
"vmessSecurity": "VMess 加密",
"wireguardPrivateKey": "WireGuard 私钥",
"wireguardPublicKey": "WireGuard 公钥",
"wireguardPreSharedKey": "WireGuard 预共享密钥",
"wireguardAllowedIPs": "WireGuard 允许的 IP",
"reverseTag": "反向标签",
"reverseTagPlaceholder": "可选 Reverse tag",
"telegramId": "Telegram 用户 ID",
+4
View File
@@ -885,6 +885,10 @@
"uuid": "UUID",
"flow": "Flow",
"vmessSecurity": "VMess 加密",
"wireguardPrivateKey": "WireGuard 私鑰",
"wireguardPublicKey": "WireGuard 公鑰",
"wireguardPreSharedKey": "WireGuard 預共用金鑰",
"wireguardAllowedIPs": "WireGuard 允許的 IP",
"reverseTag": "反向標籤",
"reverseTagPlaceholder": "選用 Reverse tag",
"telegramId": "Telegram 使用者 ID",
+102 -29
View File
@@ -18,6 +18,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
"github.com/xtls/xray-core/app/proxyman/command"
routerService "github.com/xtls/xray-core/app/router/command"
@@ -32,6 +33,7 @@ import (
"github.com/xtls/xray-core/proxy/trojan"
"github.com/xtls/xray-core/proxy/vless"
"github.com/xtls/xray-core/proxy/vmess"
wireguard "github.com/xtls/xray-core/proxy/wireguard"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
@@ -408,40 +410,62 @@ func ensureXrayAssetLocation() {
}
}
// AddUser adds a user to an inbound in the Xray core using the specified protocol and user data.
func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]any) error {
userEmail, err := getRequiredUserString(user, "email")
if err != nil {
return err
// collectStringSlice normalizes a JSON-decoded value into a slice of non-empty
// strings, accepting both []string (typed maps) and []any (json.Unmarshal output).
func collectStringSlice(value any) []string {
switch v := value.(type) {
case []string:
out := make([]string, 0, len(v))
for _, s := range v {
if s != "" {
out = append(out, s)
}
}
return out
case []any:
out := make([]string, 0, len(v))
for _, e := range v {
if s, ok := e.(string); ok && s != "" {
out = append(out, s)
}
}
return out
default:
return nil
}
}
var account *serial.TypedMessage
switch Protocol {
// buildUserAccount constructs the typed xray account for a user of the given
// protocol. It returns (nil, nil) for protocols that cannot be altered live so
// callers skip the AlterInbound call. WireGuard keys must be converted to the
// hex form xray's wireguard proxy expects (its ParseKey uses hex.DecodeString),
// unlike the file-config path which accepts base64 and converts internally.
func buildUserAccount(protocolName string, user map[string]any) (*serial.TypedMessage, error) {
switch protocolName {
case "vmess":
userID, err := getRequiredUserString(user, "id")
if err != nil {
return err
return nil, err
}
account = serial.ToTypedMessage(&vmess.Account{
return serial.ToTypedMessage(&vmess.Account{
Id: userID,
})
}), nil
case "vless":
userID, err := getRequiredUserString(user, "id")
if err != nil {
return err
return nil, err
}
userFlow, err := getOptionalUserString(user, "flow")
if err != nil {
return err
return nil, err
}
vlessAccount := &vless.Account{
Id: userID,
Flow: userFlow,
}
// Add testseed if provided
if testseedVal, ok := user["testseed"]; ok {
if testseedArr, ok := testseedVal.([]any); ok && len(testseedArr) >= 4 {
testseed := make([]uint32, len(testseedArr))
@@ -455,7 +479,6 @@ func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]an
vlessAccount.Testseed = testseedArr
}
}
// Add testpre if provided (for outbound, but can be in user for compatibility)
if testpreVal, ok := user["testpre"]; ok {
if testpre, ok := testpreVal.(float64); ok && testpre > 0 {
vlessAccount.Testpre = uint32(testpre)
@@ -463,25 +486,25 @@ func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]an
vlessAccount.Testpre = testpre
}
}
account = serial.ToTypedMessage(vlessAccount)
return serial.ToTypedMessage(vlessAccount), nil
case "trojan":
password, err := getRequiredUserString(user, "password")
if err != nil {
return err
return nil, err
}
account = serial.ToTypedMessage(&trojan.Account{
return serial.ToTypedMessage(&trojan.Account{
Password: password,
})
}), nil
case "shadowsocks":
cipher, err := getOptionalUserString(user, "cipher")
if err != nil {
return err
return nil, err
}
password, err := getRequiredUserString(user, "password")
if err != nil {
return err
return nil, err
}
var ssCipherType shadowsocks.CipherType
@@ -497,25 +520,75 @@ func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]an
}
if ssCipherType != shadowsocks.CipherType_NONE {
account = serial.ToTypedMessage(&shadowsocks.Account{
return serial.ToTypedMessage(&shadowsocks.Account{
Password: password,
CipherType: ssCipherType,
})
} else {
account = serial.ToTypedMessage(&shadowsocks_2022.Account{
Key: password,
})
}), nil
}
return serial.ToTypedMessage(&shadowsocks_2022.Account{
Key: password,
}), nil
case "hysteria":
auth, err := getRequiredUserString(user, "auth")
if err != nil {
return err
return nil, err
}
account = serial.ToTypedMessage(&hysteriaAccount.Account{
return serial.ToTypedMessage(&hysteriaAccount.Account{
Auth: auth,
})
}), nil
case "wireguard":
pubB64, err := getRequiredUserString(user, "publicKey")
if err != nil {
return nil, err
}
pubHex, err := wgutil.KeyToHex(pubB64)
if err != nil {
return nil, fmt.Errorf("wireguard publicKey: %w", err)
}
pskB64, err := getOptionalUserString(user, "preSharedKey")
if err != nil {
return nil, err
}
pskHex, err := wgutil.KeyToHex(pskB64)
if err != nil {
return nil, fmt.Errorf("wireguard preSharedKey: %w", err)
}
allowed := collectStringSlice(user["allowedIPs"])
if len(allowed) == 0 {
return nil, common.NewError("wireguard: allowedIPs required")
}
keepAlive, err := getOptionalUserString(user, "keepAlive")
if err != nil {
return nil, err
}
return serial.ToTypedMessage(&wireguard.PeerConfig{
PublicKey: pubHex,
PreSharedKey: pskHex,
AllowedIps: allowed,
KeepAlive: keepAlive,
}), nil
default:
return nil, nil
}
}
// AddUser adds a user to an inbound in the Xray core using the specified protocol and user data.
func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]any) error {
userEmail, err := getRequiredUserString(user, "email")
if err != nil {
return err
}
account, err := buildUserAccount(Protocol, user)
if err != nil {
return err
}
if account == nil {
return nil
}
+129
View File
@@ -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")
}
}