mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-28 00:24:19 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -885,6 +885,10 @@
|
||||
"uuid": "UUID",
|
||||
"flow": "Flow",
|
||||
"vmessSecurity": "أمان VMess",
|
||||
"wireguardPrivateKey": "مفتاح وايرغارد الخاص",
|
||||
"wireguardPublicKey": "مفتاح وايرغارد العام",
|
||||
"wireguardPreSharedKey": "مفتاح وايرغارد المشترك مسبقًا",
|
||||
"wireguardAllowedIPs": "عناوين IP المسموحة لوايرغارد",
|
||||
"reverseTag": "وسم عكسي",
|
||||
"reverseTagPlaceholder": "Reverse tag اختياري",
|
||||
"telegramId": "معرّف مستخدم تلغرام",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -885,6 +885,10 @@
|
||||
"uuid": "UUID",
|
||||
"flow": "Flow",
|
||||
"vmessSecurity": "امنیت VMess",
|
||||
"wireguardPrivateKey": "کلید خصوصی وایرگارد",
|
||||
"wireguardPublicKey": "کلید عمومی وایرگارد",
|
||||
"wireguardPreSharedKey": "کلید پیشاشتراکی وایرگارد",
|
||||
"wireguardAllowedIPs": "آیپیهای مجاز وایرگارد",
|
||||
"reverseTag": "تگ معکوس",
|
||||
"reverseTagPlaceholder": "Reverse tag اختیاری",
|
||||
"telegramId": "شناسه کاربر تلگرام",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user