mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-02 08:27:14 +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": {
|
"Client": {
|
||||||
"description": "Client represents a client configuration for Xray inbounds with traffic limits and settings.",
|
"description": "Client represents a client configuration for Xray inbounds with traffic limits and settings.",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"allowedIPs": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"description": "Auth password (Hysteria)",
|
"description": "Auth password (Hysteria)",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -1120,6 +1126,9 @@
|
|||||||
"description": "Unique client identifier",
|
"description": "Unique client identifier",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"keepAlive": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"limitIp": {
|
"limitIp": {
|
||||||
"description": "IP limit for this client",
|
"description": "IP limit for this client",
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
@@ -1128,6 +1137,15 @@
|
|||||||
"description": "Client password",
|
"description": "Client password",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"preSharedKey": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"privateKey": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"publicKey": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"reset": {
|
"reset": {
|
||||||
"description": "Reset period in days",
|
"description": "Reset period in days",
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
@@ -1201,6 +1219,9 @@
|
|||||||
},
|
},
|
||||||
"ClientRecord": {
|
"ClientRecord": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"allowedIPs": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -1228,12 +1249,24 @@
|
|||||||
"id": {
|
"id": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"keepAlive": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"limitIp": {
|
"limitIp": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"preSharedKey": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"privateKey": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"publicKey": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"reset": {
|
"reset": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
@@ -1258,6 +1291,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
"allowedIPs",
|
||||||
"auth",
|
"auth",
|
||||||
"comment",
|
"comment",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
@@ -1267,8 +1301,12 @@
|
|||||||
"flow",
|
"flow",
|
||||||
"group",
|
"group",
|
||||||
"id",
|
"id",
|
||||||
|
"keepAlive",
|
||||||
"limitIp",
|
"limitIp",
|
||||||
"password",
|
"password",
|
||||||
|
"preSharedKey",
|
||||||
|
"privateKey",
|
||||||
|
"publicKey",
|
||||||
"reset",
|
"reset",
|
||||||
"reverse",
|
"reverse",
|
||||||
"security",
|
"security",
|
||||||
|
|||||||
@@ -212,6 +212,9 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"token": "new-token-string"
|
"token": "new-token-string"
|
||||||
},
|
},
|
||||||
"Client": {
|
"Client": {
|
||||||
|
"allowedIPs": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"auth": "",
|
"auth": "",
|
||||||
"comment": "",
|
"comment": "",
|
||||||
"created_at": 0,
|
"created_at": 0,
|
||||||
@@ -221,8 +224,12 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"flow": "",
|
"flow": "",
|
||||||
"group": "",
|
"group": "",
|
||||||
"id": "",
|
"id": "",
|
||||||
|
"keepAlive": 0,
|
||||||
"limitIp": 0,
|
"limitIp": 0,
|
||||||
"password": "",
|
"password": "",
|
||||||
|
"preSharedKey": "",
|
||||||
|
"privateKey": "",
|
||||||
|
"publicKey": "",
|
||||||
"reset": 0,
|
"reset": 0,
|
||||||
"reverse": null,
|
"reverse": null,
|
||||||
"security": "",
|
"security": "",
|
||||||
@@ -238,6 +245,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"inboundId": 0
|
"inboundId": 0
|
||||||
},
|
},
|
||||||
"ClientRecord": {
|
"ClientRecord": {
|
||||||
|
"allowedIPs": "",
|
||||||
"auth": "",
|
"auth": "",
|
||||||
"comment": "",
|
"comment": "",
|
||||||
"createdAt": 0,
|
"createdAt": 0,
|
||||||
@@ -247,8 +255,12 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"flow": "",
|
"flow": "",
|
||||||
"group": "",
|
"group": "",
|
||||||
"id": 0,
|
"id": 0,
|
||||||
|
"keepAlive": 0,
|
||||||
"limitIp": 0,
|
"limitIp": 0,
|
||||||
"password": "",
|
"password": "",
|
||||||
|
"preSharedKey": "",
|
||||||
|
"privateKey": "",
|
||||||
|
"publicKey": "",
|
||||||
"reset": 0,
|
"reset": 0,
|
||||||
"reverse": null,
|
"reverse": null,
|
||||||
"security": "",
|
"security": "",
|
||||||
|
|||||||
@@ -1058,6 +1058,12 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"Client": {
|
"Client": {
|
||||||
"description": "Client represents a client configuration for Xray inbounds with traffic limits and settings.",
|
"description": "Client represents a client configuration for Xray inbounds with traffic limits and settings.",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"allowedIPs": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"description": "Auth password (Hysteria)",
|
"description": "Auth password (Hysteria)",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -1094,6 +1100,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"description": "Unique client identifier",
|
"description": "Unique client identifier",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"keepAlive": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"limitIp": {
|
"limitIp": {
|
||||||
"description": "IP limit for this client",
|
"description": "IP limit for this client",
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
@@ -1102,6 +1111,15 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"description": "Client password",
|
"description": "Client password",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"preSharedKey": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"privateKey": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"publicKey": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"reset": {
|
"reset": {
|
||||||
"description": "Reset period in days",
|
"description": "Reset period in days",
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
@@ -1175,6 +1193,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
},
|
},
|
||||||
"ClientRecord": {
|
"ClientRecord": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"allowedIPs": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -1202,12 +1223,24 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"id": {
|
"id": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"keepAlive": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"limitIp": {
|
"limitIp": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"preSharedKey": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"privateKey": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"publicKey": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"reset": {
|
"reset": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
@@ -1232,6 +1265,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
"allowedIPs",
|
||||||
"auth",
|
"auth",
|
||||||
"comment",
|
"comment",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
@@ -1241,8 +1275,12 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"flow",
|
"flow",
|
||||||
"group",
|
"group",
|
||||||
"id",
|
"id",
|
||||||
|
"keepAlive",
|
||||||
"limitIp",
|
"limitIp",
|
||||||
"password",
|
"password",
|
||||||
|
"preSharedKey",
|
||||||
|
"privateKey",
|
||||||
|
"publicKey",
|
||||||
"reset",
|
"reset",
|
||||||
"reverse",
|
"reverse",
|
||||||
"security",
|
"security",
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ export interface ApiTokenView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Client {
|
export interface Client {
|
||||||
|
allowedIPs?: string[];
|
||||||
auth?: string;
|
auth?: string;
|
||||||
comment: string;
|
comment: string;
|
||||||
created_at?: number;
|
created_at?: number;
|
||||||
@@ -231,8 +232,12 @@ export interface Client {
|
|||||||
flow?: string;
|
flow?: string;
|
||||||
group?: string;
|
group?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
|
keepAlive?: number;
|
||||||
limitIp: number;
|
limitIp: number;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
preSharedKey?: string;
|
||||||
|
privateKey?: string;
|
||||||
|
publicKey?: string;
|
||||||
reset: number;
|
reset: number;
|
||||||
reverse?: ClientReverse | null;
|
reverse?: ClientReverse | null;
|
||||||
security: string;
|
security: string;
|
||||||
@@ -250,6 +255,7 @@ export interface ClientInbound {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ClientRecord {
|
export interface ClientRecord {
|
||||||
|
allowedIPs: string;
|
||||||
auth: string;
|
auth: string;
|
||||||
comment: string;
|
comment: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
@@ -259,8 +265,12 @@ export interface ClientRecord {
|
|||||||
flow: string;
|
flow: string;
|
||||||
group: string;
|
group: string;
|
||||||
id: number;
|
id: number;
|
||||||
|
keepAlive: number;
|
||||||
limitIp: number;
|
limitIp: number;
|
||||||
password: string;
|
password: string;
|
||||||
|
preSharedKey: string;
|
||||||
|
privateKey: string;
|
||||||
|
publicKey: string;
|
||||||
reset: number;
|
reset: number;
|
||||||
reverse: unknown;
|
reverse: unknown;
|
||||||
security: string;
|
security: string;
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ export const ApiTokenViewSchema = z.object({
|
|||||||
export type ApiTokenView = z.infer<typeof ApiTokenViewSchema>;
|
export type ApiTokenView = z.infer<typeof ApiTokenViewSchema>;
|
||||||
|
|
||||||
export const ClientSchema = z.object({
|
export const ClientSchema = z.object({
|
||||||
|
allowedIPs: z.array(z.string()).optional(),
|
||||||
auth: z.string().optional(),
|
auth: z.string().optional(),
|
||||||
comment: z.string(),
|
comment: z.string(),
|
||||||
created_at: z.number().int().optional(),
|
created_at: z.number().int().optional(),
|
||||||
@@ -247,8 +248,12 @@ export const ClientSchema = z.object({
|
|||||||
flow: z.string().optional(),
|
flow: z.string().optional(),
|
||||||
group: z.string().optional(),
|
group: z.string().optional(),
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
|
keepAlive: z.number().int().optional(),
|
||||||
limitIp: z.number().int(),
|
limitIp: z.number().int(),
|
||||||
password: z.string().optional(),
|
password: z.string().optional(),
|
||||||
|
preSharedKey: z.string().optional(),
|
||||||
|
privateKey: z.string().optional(),
|
||||||
|
publicKey: z.string().optional(),
|
||||||
reset: z.number().int(),
|
reset: z.number().int(),
|
||||||
reverse: z.lazy(() => ClientReverseSchema).nullable().optional(),
|
reverse: z.lazy(() => ClientReverseSchema).nullable().optional(),
|
||||||
security: z.string(),
|
security: z.string(),
|
||||||
@@ -268,6 +273,7 @@ export const ClientInboundSchema = z.object({
|
|||||||
export type ClientInbound = z.infer<typeof ClientInboundSchema>;
|
export type ClientInbound = z.infer<typeof ClientInboundSchema>;
|
||||||
|
|
||||||
export const ClientRecordSchema = z.object({
|
export const ClientRecordSchema = z.object({
|
||||||
|
allowedIPs: z.string(),
|
||||||
auth: z.string(),
|
auth: z.string(),
|
||||||
comment: z.string(),
|
comment: z.string(),
|
||||||
createdAt: z.number().int(),
|
createdAt: z.number().int(),
|
||||||
@@ -277,8 +283,12 @@ export const ClientRecordSchema = z.object({
|
|||||||
flow: z.string(),
|
flow: z.string(),
|
||||||
group: z.string(),
|
group: z.string(),
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
|
keepAlive: z.number().int(),
|
||||||
limitIp: z.number().int(),
|
limitIp: z.number().int(),
|
||||||
password: z.string(),
|
password: z.string(),
|
||||||
|
preSharedKey: z.string(),
|
||||||
|
privateKey: z.string(),
|
||||||
|
publicKey: z.string(),
|
||||||
reset: z.number().int(),
|
reset: z.number().int(),
|
||||||
reverse: z.unknown(),
|
reverse: z.unknown(),
|
||||||
security: z.string(),
|
security: z.string(),
|
||||||
|
|||||||
@@ -263,24 +263,20 @@ export interface WireguardInboundSeed {
|
|||||||
mtu?: number;
|
mtu?: number;
|
||||||
secretKey?: string;
|
secretKey?: string;
|
||||||
noKernelTun?: boolean;
|
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(
|
export function createDefaultWireguardInboundSettings(
|
||||||
seed: WireguardInboundSeed = {},
|
seed: WireguardInboundSeed = {},
|
||||||
): WireguardInboundSettings {
|
): WireguardInboundSettings {
|
||||||
const peerKp = seed.peerPrivateKey
|
|
||||||
? { privateKey: seed.peerPrivateKey, publicKey: Wireguard.generateKeypair(seed.peerPrivateKey).publicKey }
|
|
||||||
: Wireguard.generateKeypair();
|
|
||||||
return {
|
return {
|
||||||
mtu: seed.mtu ?? 1420,
|
mtu: seed.mtu ?? 1420,
|
||||||
secretKey: seed.secretKey ?? Wireguard.generateKeypair().privateKey,
|
secretKey: seed.secretKey ?? Wireguard.generateKeypair().privateKey,
|
||||||
peers: [{
|
peers: [],
|
||||||
privateKey: peerKp.privateKey,
|
clients: [],
|
||||||
publicKey: peerKp.publicKey,
|
|
||||||
allowedIPs: ['10.0.0.2/32'],
|
|
||||||
keepAlive: 0,
|
|
||||||
}],
|
|
||||||
noKernelTun: seed.noKernelTun ?? false,
|
noKernelTun: seed.noKernelTun ?? false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
TrojanClientSchema,
|
TrojanClientSchema,
|
||||||
VlessClientSchema,
|
VlessClientSchema,
|
||||||
VmessClientSchema,
|
VmessClientSchema,
|
||||||
|
WireguardClientSchema,
|
||||||
} from '@/schemas/protocols/inbound';
|
} from '@/schemas/protocols/inbound';
|
||||||
import type { StreamSettings } from '@/schemas/api/inbound';
|
import type { StreamSettings } from '@/schemas/api/inbound';
|
||||||
import type { Sniffing } from '@/schemas/primitives';
|
import type { Sniffing } from '@/schemas/primitives';
|
||||||
@@ -234,6 +235,7 @@ function clientSchemaForProtocol(protocol: string): z.ZodType | null {
|
|||||||
case 'trojan': return TrojanClientSchema;
|
case 'trojan': return TrojanClientSchema;
|
||||||
case 'shadowsocks': return ShadowsocksClientSchema;
|
case 'shadowsocks': return ShadowsocksClientSchema;
|
||||||
case 'hysteria': return HysteriaClientSchema;
|
case 'hysteria': return HysteriaClientSchema;
|
||||||
|
case 'wireguard': return WireguardClientSchema;
|
||||||
default: return null;
|
default: return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1126,14 +1126,30 @@ export interface GenWireguardFanoutInput {
|
|||||||
fallbackHostname: string;
|
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 {
|
export function genWireguardLinks(input: GenWireguardFanoutInput): string {
|
||||||
const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
|
const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
|
||||||
if (inbound.protocol !== 'wireguard') return '';
|
if (inbound.protocol !== 'wireguard') return '';
|
||||||
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
|
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
|
||||||
const sep = '-';
|
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({
|
.map((p, i) => genWireguardLink({
|
||||||
settings: inbound.settings as WireguardInboundSettings,
|
settings,
|
||||||
address: addr,
|
address: addr,
|
||||||
port: inbound.port,
|
port: inbound.port,
|
||||||
remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(p)}`,
|
remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(p)}`,
|
||||||
@@ -1147,9 +1163,12 @@ export function genWireguardConfigs(input: GenWireguardFanoutInput): string {
|
|||||||
if (inbound.protocol !== 'wireguard') return '';
|
if (inbound.protocol !== 'wireguard') return '';
|
||||||
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
|
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
|
||||||
const sep = '-';
|
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({
|
.map((p, i) => genWireguardConfig({
|
||||||
settings: inbound.settings as WireguardInboundSettings,
|
settings,
|
||||||
address: addr,
|
address: addr,
|
||||||
port: inbound.port,
|
port: inbound.port,
|
||||||
remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(p)}`,
|
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 FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||||
|
|
||||||
const MULTI_CLIENT_PROTOCOLS = new Set([
|
const MULTI_CLIENT_PROTOCOLS = new Set([
|
||||||
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria',
|
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'wireguard',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
interface ClientBulkAddModalProps {
|
interface ClientBulkAddModalProps {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
import { DeleteOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, RetweetOutlined } from '@ant-design/icons';
|
import { DeleteOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, RetweetOutlined } from '@ant-design/icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import type { 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 { formatInboundLabel } from '@/lib/inbounds/label';
|
||||||
import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
|
import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
|
||||||
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
|
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 VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305', 'none', 'zero'] as const;
|
||||||
|
|
||||||
const MULTI_CLIENT_PROTOCOLS = new Set([
|
const MULTI_CLIENT_PROTOCOLS = new Set([
|
||||||
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria',
|
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'wireguard',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const CLIENT_FORM_MODAL_Z_INDEX = 1000;
|
const CLIENT_FORM_MODAL_Z_INDEX = 1000;
|
||||||
@@ -113,6 +113,10 @@ interface FormState {
|
|||||||
enable: boolean;
|
enable: boolean;
|
||||||
inboundIds: number[];
|
inboundIds: number[];
|
||||||
externalLinks: ExternalLinkRow[];
|
externalLinks: ExternalLinkRow[];
|
||||||
|
wgPrivateKey: string;
|
||||||
|
wgPublicKey: string;
|
||||||
|
wgPreSharedKey: string;
|
||||||
|
wgAllowedIPs: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyForm(): FormState {
|
function emptyForm(): FormState {
|
||||||
@@ -137,6 +141,10 @@ function emptyForm(): FormState {
|
|||||||
enable: true,
|
enable: true,
|
||||||
inboundIds: [],
|
inboundIds: [],
|
||||||
externalLinks: [],
|
externalLinks: [],
|
||||||
|
wgPrivateKey: '',
|
||||||
|
wgPublicKey: '',
|
||||||
|
wgPreSharedKey: '',
|
||||||
|
wgAllowedIPs: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,6 +245,10 @@ export default function ClientFormModal({
|
|||||||
enable: !!client.enable,
|
enable: !!client.enable,
|
||||||
inboundIds: Array.isArray(attachedIds) ? [...attachedIds] : [],
|
inboundIds: Array.isArray(attachedIds) ? [...attachedIds] : [],
|
||||||
externalLinks: toExternalLinkRows(attachedExternalLinks),
|
externalLinks: toExternalLinkRows(attachedExternalLinks),
|
||||||
|
wgPrivateKey: client.privateKey || '',
|
||||||
|
wgPublicKey: client.publicKey || '',
|
||||||
|
wgPreSharedKey: client.preSharedKey || '',
|
||||||
|
wgAllowedIPs: client.allowedIPs || '',
|
||||||
};
|
};
|
||||||
if (et < 0) {
|
if (et < 0) {
|
||||||
next.delayedStart = true;
|
next.delayedStart = true;
|
||||||
@@ -250,6 +262,7 @@ export default function ClientFormModal({
|
|||||||
setForm(next);
|
setForm(next);
|
||||||
void loadIps();
|
void loadIps();
|
||||||
} else {
|
} else {
|
||||||
|
const wgKeypair = Wireguard.generateKeypair();
|
||||||
setForm({
|
setForm({
|
||||||
...emptyForm(),
|
...emptyForm(),
|
||||||
email: RandomUtil.randomLowerAndNum(10),
|
email: RandomUtil.randomLowerAndNum(10),
|
||||||
@@ -257,6 +270,8 @@ export default function ClientFormModal({
|
|||||||
subId: RandomUtil.randomLowerAndNum(16),
|
subId: RandomUtil.randomLowerAndNum(16),
|
||||||
password: RandomUtil.randomLowerAndNum(16),
|
password: RandomUtil.randomLowerAndNum(16),
|
||||||
auth: RandomUtil.randomLowerAndNum(16),
|
auth: RandomUtil.randomLowerAndNum(16),
|
||||||
|
wgPrivateKey: wgKeypair.privateKey,
|
||||||
|
wgPublicKey: wgKeypair.publicKey,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,6 +302,14 @@ export default function ClientFormModal({
|
|||||||
return ids;
|
return ids;
|
||||||
}, [inbounds]);
|
}, [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(() => {
|
const ss2022Method = useMemo(() => {
|
||||||
for (const id of form.inboundIds || []) {
|
for (const id of form.inboundIds || []) {
|
||||||
const ib = (inbounds || []).find((row) => row.id === id);
|
const ib = (inbounds || []).find((row) => row.id === id);
|
||||||
@@ -317,6 +340,16 @@ export default function ClientFormModal({
|
|||||||
[form.inboundIds, vmessIds],
|
[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(() => {
|
useEffect(() => {
|
||||||
if (!showFlow && form.flow) {
|
if (!showFlow && form.flow) {
|
||||||
|
|
||||||
@@ -453,6 +486,14 @@ export default function ClientFormModal({
|
|||||||
clientPayload.reverse = { tag: reverseTag };
|
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
|
const externalLinks: ExternalLinkInput[] = form.externalLinks
|
||||||
.map((r) => ({ kind: r.kind, value: r.value.trim(), remark: '' }))
|
.map((r) => ({ kind: r.kind, value: r.value.trim(), remark: '' }))
|
||||||
.filter((r) => r.value !== '');
|
.filter((r) => r.value !== '');
|
||||||
@@ -736,6 +777,38 @@ export default function ClientFormModal({
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</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);
|
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 = (
|
const matchesVlessAuth = (
|
||||||
block: { id?: string; label?: string } | undefined | null,
|
block: { id?: string; label?: string } | undefined | null,
|
||||||
authId: string,
|
authId: string,
|
||||||
@@ -695,7 +689,7 @@ export default function InboundFormModal({
|
|||||||
|
|
||||||
const protocolTab = (
|
const protocolTab = (
|
||||||
<>
|
<>
|
||||||
{protocol === Protocols.WIREGUARD && <WireguardFields wgPubKey={wgPubKey} regenInboundWg={regenInboundWg} regenWgPeerKeypair={regenWgPeerKeypair} />}
|
{protocol === Protocols.WIREGUARD && <WireguardFields wgPubKey={wgPubKey} regenInboundWg={regenInboundWg} />}
|
||||||
|
|
||||||
{protocol === Protocols.TUN && <TunFields />}
|
{protocol === Protocols.TUN && <TunFields />}
|
||||||
|
|
||||||
|
|||||||
@@ -1,44 +1,14 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
||||||
import { MinusOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
import { ReloadOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { Wireguard } from '@/utils';
|
|
||||||
|
|
||||||
interface WireguardFieldsProps {
|
interface WireguardFieldsProps {
|
||||||
wgPubKey: string;
|
wgPubKey: string;
|
||||||
regenInboundWg: () => void;
|
regenInboundWg: () => void;
|
||||||
regenWgPeerKeypair: (name: number) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextWgPeerAllowedIP(peers: Array<{ allowedIPs?: string[] }> | undefined): string {
|
export default function WireguardFields({ wgPubKey, regenInboundWg }: WireguardFieldsProps) {
|
||||||
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) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const form = Form.useFormInstance();
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Form.Item label={t('pages.xray.wireguard.secretKey')}>
|
<Form.Item label={t('pages.xray.wireguard.secretKey')}>
|
||||||
@@ -74,96 +44,6 @@ export default function WireguardFields({ wgPubKey, regenInboundWg, regenWgPeerK
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</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(),
|
inboundIds: nullableNumberArray.optional(),
|
||||||
traffic: ClientTrafficSchema.nullable().optional(),
|
traffic: ClientTrafficSchema.nullable().optional(),
|
||||||
reverse: z.object({ tag: z.string().optional() }).loose().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(),
|
createdAt: z.number().optional(),
|
||||||
updatedAt: z.number().optional(),
|
updatedAt: z.number().optional(),
|
||||||
}).loose();
|
}).loose();
|
||||||
|
|||||||
@@ -33,10 +33,36 @@ export const WireguardInboundPeerSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type WireguardInboundPeer = z.infer<typeof WireguardInboundPeerSchema>;
|
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({
|
export const WireguardInboundSettingsSchema = z.object({
|
||||||
mtu: optionalClearedInt(z.number().int().min(1)),
|
mtu: optionalClearedInt(z.number().int().min(1)),
|
||||||
secretKey: z.string().min(1),
|
secretKey: z.string().min(1),
|
||||||
peers: z.array(WireguardInboundPeerSchema).default([]),
|
peers: z.array(WireguardInboundPeerSchema).default([]),
|
||||||
|
clients: z.array(WireguardClientSchema).default([]),
|
||||||
noKernelTun: z.boolean().default(false),
|
noKernelTun: z.boolean().default(false),
|
||||||
domainStrategy: WireguardDomainStrategySchema.optional(),
|
domainStrategy: WireguardDomainStrategySchema.optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,18 +49,10 @@ exports[`createDefault*InboundSettings factories > vmess 1`] = `
|
|||||||
|
|
||||||
exports[`createDefault*InboundSettings factories > wireguard 1`] = `
|
exports[`createDefault*InboundSettings factories > wireguard 1`] = `
|
||||||
{
|
{
|
||||||
|
"clients": [],
|
||||||
"mtu": 1420,
|
"mtu": 1420,
|
||||||
"noKernelTun": false,
|
"noKernelTun": false,
|
||||||
"peers": [
|
"peers": [],
|
||||||
{
|
|
||||||
"allowedIPs": [
|
|
||||||
"10.0.0.2/32",
|
|
||||||
],
|
|
||||||
"keepAlive": 0,
|
|
||||||
"privateKey": "cGVlci1maXh0dXJlLXByaXZhdGUta2V5LWZvci10ZXN0cw==",
|
|
||||||
"publicKey": "RNa/H++60PStnhoiiU/vIuwFimZUBuIkLkbrmEoDz34=",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"secretKey": "QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=",
|
"secretKey": "QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=",
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -608,6 +608,7 @@ exports[`InboundSchema (full) fixtures > parses wireguard-server byte-stably 1`]
|
|||||||
"protocol": "wireguard",
|
"protocol": "wireguard",
|
||||||
"remark": "wg-server",
|
"remark": "wg-server",
|
||||||
"settings": {
|
"settings": {
|
||||||
|
"clients": [],
|
||||||
"mtu": 1420,
|
"mtu": 1420,
|
||||||
"noKernelTun": false,
|
"noKernelTun": false,
|
||||||
"peers": [
|
"peers": [
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ exports[`InboundSettingsSchema fixtures > parses wireguard-basic byte-stably 1`]
|
|||||||
{
|
{
|
||||||
"protocol": "wireguard",
|
"protocol": "wireguard",
|
||||||
"settings": {
|
"settings": {
|
||||||
|
"clients": [],
|
||||||
"mtu": 1420,
|
"mtu": 1420,
|
||||||
"noKernelTun": false,
|
"noKernelTun": false,
|
||||||
"peers": [
|
"peers": [
|
||||||
|
|||||||
@@ -142,10 +142,11 @@ describe('createDefault*InboundSettings factories', () => {
|
|||||||
it('wireguard', () => {
|
it('wireguard', () => {
|
||||||
const s = createDefaultWireguardInboundSettings({
|
const s = createDefaultWireguardInboundSettings({
|
||||||
secretKey: 'QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=',
|
secretKey: 'QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=',
|
||||||
peerPrivateKey: 'cGVlci1maXh0dXJlLXByaXZhdGUta2V5LWZvci10ZXN0cw==',
|
|
||||||
});
|
});
|
||||||
expect(s).toMatchSnapshot();
|
expect(s).toMatchSnapshot();
|
||||||
expect(WireguardInboundSettingsSchema.parse(s)).toEqual(s);
|
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
|
// CreateHostsFromExternalProxy parses a legacy streamSettings.externalProxy array
|
||||||
// and inserts one Host row per entry on tx, returning the number of rows created.
|
// 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
|
// It is the shared core of both the one-time seedHostsFromExternalProxy startup
|
||||||
@@ -387,7 +529,7 @@ func runSeeders(isUsersEmpty bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if empty && isUsersEmpty {
|
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 {
|
for _, name := range seeders {
|
||||||
if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
|
if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -490,6 +632,11 @@ func runSeeders(isUsersEmpty bool) error {
|
|||||||
if err := resetIpLimitsWithoutFail2ban(); err != nil {
|
if err := resetIpLimitsWithoutFail2ban(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Self-gated on the "WireguardPeersToClients" row.
|
||||||
|
if err := seedWireguardPeersToClients(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -592,46 +592,56 @@ type ClientReverse struct {
|
|||||||
|
|
||||||
// Client represents a client configuration for Xray inbounds with traffic limits and settings.
|
// Client represents a client configuration for Xray inbounds with traffic limits and settings.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
ID string `json:"id,omitempty"` // Unique client identifier
|
ID string `json:"id,omitempty"` // Unique client identifier
|
||||||
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
|
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
|
||||||
Password string `json:"password,omitempty"` // Client password
|
Password string `json:"password,omitempty"` // Client password
|
||||||
Flow string `json:"flow,omitempty"` // Flow control (XTLS)
|
Flow string `json:"flow,omitempty"` // Flow control (XTLS)
|
||||||
Reverse *ClientReverse `json:"reverse,omitempty"` // VLESS simple reverse proxy settings
|
Reverse *ClientReverse `json:"reverse,omitempty"` // VLESS simple reverse proxy settings
|
||||||
Auth string `json:"auth,omitempty"` // Auth password (Hysteria)
|
Auth string `json:"auth,omitempty"` // Auth password (Hysteria)
|
||||||
Email string `json:"email"` // Client email identifier
|
PrivateKey string `json:"privateKey,omitempty"`
|
||||||
LimitIP int `json:"limitIp"` // IP limit for this client
|
PublicKey string `json:"publicKey,omitempty"`
|
||||||
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
|
AllowedIPs []string `json:"allowedIPs,omitempty"`
|
||||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
PreSharedKey string `json:"preSharedKey,omitempty"`
|
||||||
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
|
KeepAlive int `json:"keepAlive,omitempty"`
|
||||||
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
|
Email string `json:"email"` // Client email identifier
|
||||||
SubID string `json:"subId" form:"subId"` // Subscription identifier
|
LimitIP int `json:"limitIp"` // IP limit for this client
|
||||||
Group string `json:"group,omitempty" form:"group"` // Logical grouping label
|
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
|
||||||
Comment string `json:"comment" form:"comment"` // Client comment
|
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
||||||
Reset int `json:"reset" form:"reset"` // Reset period in days
|
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
|
||||||
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
|
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
|
||||||
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
|
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 {
|
type ClientRecord struct {
|
||||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
Email string `json:"email" gorm:"uniqueIndex;not null"`
|
Email string `json:"email" gorm:"uniqueIndex;not null"`
|
||||||
SubID string `json:"subId" gorm:"index;column:sub_id"`
|
SubID string `json:"subId" gorm:"index;column:sub_id"`
|
||||||
UUID string `json:"uuid" gorm:"column:uuid"`
|
UUID string `json:"uuid" gorm:"column:uuid"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Auth string `json:"auth"`
|
Auth string `json:"auth"`
|
||||||
Flow string `json:"flow"`
|
Flow string `json:"flow"`
|
||||||
Security string `json:"security"`
|
Security string `json:"security"`
|
||||||
Reverse string `json:"reverse" gorm:"column:reverse"`
|
Reverse string `json:"reverse" gorm:"column:reverse"`
|
||||||
LimitIP int `json:"limitIp" gorm:"column:limit_ip"`
|
PrivateKey string `json:"privateKey" gorm:"column:wg_private_key"`
|
||||||
TotalGB int64 `json:"totalGB" gorm:"column:total_gb"`
|
PublicKey string `json:"publicKey" gorm:"column:wg_public_key"`
|
||||||
ExpiryTime int64 `json:"expiryTime" gorm:"column:expiry_time"`
|
AllowedIPs string `json:"allowedIPs" gorm:"column:wg_allowed_ips"`
|
||||||
Enable bool `json:"enable" gorm:"default:true"`
|
PreSharedKey string `json:"preSharedKey" gorm:"column:wg_pre_shared_key"`
|
||||||
TgID int64 `json:"tgId" gorm:"column:tg_id"`
|
KeepAlive int `json:"keepAlive" gorm:"column:wg_keep_alive;default:0"`
|
||||||
Group string `json:"group" gorm:"column:group_name;default:'';index:idx_client_record_group"`
|
LimitIP int `json:"limitIp" gorm:"column:limit_ip"`
|
||||||
Comment string `json:"comment"`
|
TotalGB int64 `json:"totalGB" gorm:"column:total_gb"`
|
||||||
Reset int `json:"reset" gorm:"default:0"`
|
ExpiryTime int64 `json:"expiryTime" gorm:"column:expiry_time"`
|
||||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
Enable bool `json:"enable" gorm:"default:true"`
|
||||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
|
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" }
|
func (ClientRecord) TableName() string { return "clients" }
|
||||||
@@ -799,6 +809,12 @@ func (c *Client) ToRecord() *ClientRecord {
|
|||||||
Reset: c.Reset,
|
Reset: c.Reset,
|
||||||
CreatedAt: c.CreatedAt,
|
CreatedAt: c.CreatedAt,
|
||||||
UpdatedAt: c.UpdatedAt,
|
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 c.Reverse != nil {
|
||||||
if b, err := json.Marshal(c.Reverse); err == nil {
|
if b, err := json.Marshal(c.Reverse); err == nil {
|
||||||
@@ -808,6 +824,23 @@ func (c *Client) ToRecord() *ClientRecord {
|
|||||||
return rec
|
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 {
|
func (r *ClientRecord) ToClient() *Client {
|
||||||
c := &Client{
|
c := &Client{
|
||||||
ID: r.UUID,
|
ID: r.UUID,
|
||||||
@@ -827,6 +860,12 @@ func (r *ClientRecord) ToClient() *Client {
|
|||||||
Reset: r.Reset,
|
Reset: r.Reset,
|
||||||
CreatedAt: r.CreatedAt,
|
CreatedAt: r.CreatedAt,
|
||||||
UpdatedAt: r.UpdatedAt,
|
UpdatedAt: r.UpdatedAt,
|
||||||
|
|
||||||
|
PrivateKey: r.PrivateKey,
|
||||||
|
PublicKey: r.PublicKey,
|
||||||
|
AllowedIPs: splitWireguardAllowedIPs(r.AllowedIPs),
|
||||||
|
PreSharedKey: r.PreSharedKey,
|
||||||
|
KeepAlive: r.KeepAlive,
|
||||||
}
|
}
|
||||||
if r.Reverse != "" {
|
if r.Reverse != "" {
|
||||||
var rev ClientReverse
|
var rev ClientReverse
|
||||||
@@ -960,6 +999,36 @@ func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientM
|
|||||||
existing.Reverse = incoming.Reverse
|
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 existing.Comment != incoming.Comment && incoming.Comment != "" {
|
||||||
if incomingNewer || existing.Comment == "" {
|
if incomingNewer || existing.Comment == "" {
|
||||||
keep("comment", existing.Comment, incoming.Comment, incoming.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/logger"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/random"
|
"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/web/service"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
"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 client_inbounds ON client_inbounds.inbound_id = inbounds.id
|
||||||
JOIN clients ON clients.id = client_inbounds.client_id
|
JOIN clients ON clients.id = client_inbounds.client_id
|
||||||
WHERE
|
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 = ?
|
AND clients.sub_id = ? AND inbounds.enable = ?
|
||||||
)`, subId, true).Order("sub_sort_index ASC").Order("id ASC").Find(&inbounds).Error
|
)`, subId, true).Order("sub_sort_index ASC").Order("id ASC").Find(&inbounds).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -501,10 +502,52 @@ func (s *SubService) GetLink(inbound *model.Inbound, email string) string {
|
|||||||
return s.genHysteriaLink(inbound, email)
|
return s.genHysteriaLink(inbound, email)
|
||||||
case "mtproto":
|
case "mtproto":
|
||||||
return s.genMtprotoLink(inbound, email)
|
return s.genMtprotoLink(inbound, email)
|
||||||
|
case "wireguard":
|
||||||
|
return s.genWireguardLink(inbound, email)
|
||||||
}
|
}
|
||||||
return ""
|
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:
|
// genMtprotoLink builds a Telegram proxy deep link for an mtproto inbound:
|
||||||
func (s *SubService) genMtprotoLink(inbound *model.Inbound, _ string) string {
|
func (s *SubService) genMtprotoLink(inbound *model.Inbound, _ string) string {
|
||||||
if inbound.Protocol != model.MTProto {
|
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 (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"golang.org/x/crypto/curve25519"
|
"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
|
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"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
@@ -102,12 +103,16 @@ func (l *Local) AddClient(ctx context.Context, ib *model.Inbound, client model.C
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
user := map[string]any{
|
user := map[string]any{
|
||||||
"email": client.Email,
|
"email": client.Email,
|
||||||
"id": client.ID,
|
"id": client.ID,
|
||||||
"security": client.Security,
|
"security": client.Security,
|
||||||
"flow": client.Flow,
|
"flow": client.Flow,
|
||||||
"auth": client.Auth,
|
"auth": client.Auth,
|
||||||
"password": client.Password,
|
"password": client.Password,
|
||||||
|
"publicKey": client.PublicKey,
|
||||||
|
"allowedIPs": client.AllowedIPs,
|
||||||
|
"preSharedKey": client.PreSharedKey,
|
||||||
|
"keepAlive": wgKeepAlive(client.KeepAlive),
|
||||||
}
|
}
|
||||||
return l.AddUser(ctx, ib, user)
|
return l.AddUser(ctx, ib, user)
|
||||||
}
|
}
|
||||||
@@ -135,16 +140,27 @@ func (l *Local) UpdateUser(ctx context.Context, ib *model.Inbound, oldEmail stri
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
user := map[string]any{
|
user := map[string]any{
|
||||||
"email": payload.Email,
|
"email": payload.Email,
|
||||||
"id": payload.ID,
|
"id": payload.ID,
|
||||||
"security": payload.Security,
|
"security": payload.Security,
|
||||||
"flow": payload.Flow,
|
"flow": payload.Flow,
|
||||||
"auth": payload.Auth,
|
"auth": payload.Auth,
|
||||||
"password": payload.Password,
|
"password": payload.Password,
|
||||||
|
"publicKey": payload.PublicKey,
|
||||||
|
"allowedIPs": payload.AllowedIPs,
|
||||||
|
"preSharedKey": payload.PreSharedKey,
|
||||||
|
"keepAlive": wgKeepAlive(payload.KeepAlive),
|
||||||
}
|
}
|
||||||
return l.AddUser(ctx, ib, user)
|
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 {
|
func (l *Local) RestartXray(_ context.Context) error {
|
||||||
if l.deps.SetNeedRestart != nil {
|
if l.deps.SetNeedRestart != nil {
|
||||||
l.deps.SetNeedRestart()
|
l.deps.SetNeedRestart()
|
||||||
|
|||||||
@@ -295,6 +295,16 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
|
|||||||
return false, err
|
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 {
|
for _, client := range clients {
|
||||||
if strings.TrimSpace(client.Email) == "" {
|
if strings.TrimSpace(client.Email) == "" {
|
||||||
return false, common.NewError("client email is required")
|
return false, common.NewError("client email is required")
|
||||||
@@ -312,6 +322,10 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
|
|||||||
if client.Auth == "" {
|
if client.Auth == "" {
|
||||||
return false, common.NewError("empty client ID")
|
return false, common.NewError("empty client ID")
|
||||||
}
|
}
|
||||||
|
case "wireguard":
|
||||||
|
if client.PublicKey == "" {
|
||||||
|
return false, common.NewError("wireguard client requires a key")
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
if client.ID == "" {
|
if client.ID == "" {
|
||||||
return false, common.NewError("empty client ID")
|
return false, common.NewError("empty client ID")
|
||||||
@@ -329,7 +343,7 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
|
|||||||
applyShadowsocksClientMethod(interfaceClients, oldSettings)
|
applyShadowsocksClientMethod(interfaceClients, oldSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
oldClients := oldSettings["clients"].([]any)
|
oldClients, _ := oldSettings["clients"].([]any)
|
||||||
oldClients = compactOrphans(database.GetDB(), oldClients)
|
oldClients = compactOrphans(database.GetDB(), oldClients)
|
||||||
oldClients = append(oldClients, interfaceClients...)
|
oldClients = append(oldClients, interfaceClients...)
|
||||||
|
|
||||||
@@ -395,13 +409,17 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
|
|||||||
cipher = oldSettings["method"].(string)
|
cipher = oldSettings["method"].(string)
|
||||||
}
|
}
|
||||||
err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
|
err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
|
||||||
"email": client.Email,
|
"email": client.Email,
|
||||||
"id": client.ID,
|
"id": client.ID,
|
||||||
"auth": client.Auth,
|
"auth": client.Auth,
|
||||||
"security": client.Security,
|
"security": client.Security,
|
||||||
"flow": client.Flow,
|
"flow": client.Flow,
|
||||||
"password": client.Password,
|
"password": client.Password,
|
||||||
"cipher": cipher,
|
"cipher": cipher,
|
||||||
|
"publicKey": client.PublicKey,
|
||||||
|
"allowedIPs": client.AllowedIPs,
|
||||||
|
"preSharedKey": client.PreSharedKey,
|
||||||
|
"keepAlive": keepAliveStr(client.KeepAlive),
|
||||||
})
|
})
|
||||||
if err1 == nil {
|
if err1 == nil {
|
||||||
logger.Debug("Client added on", rt.Name(), ":", client.Email)
|
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
|
newClientId = clients[0].Email
|
||||||
case "hysteria":
|
case "hysteria":
|
||||||
newClientId = clients[0].Auth
|
newClientId = clients[0].Auth
|
||||||
|
case "wireguard":
|
||||||
|
newClientId = clients[0].Email
|
||||||
default:
|
default:
|
||||||
newClientId = clients[0].ID
|
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
|
var oldSettings map[string]any
|
||||||
err = json.Unmarshal([]byte(oldInbound.Settings), &oldSettings)
|
err = json.Unmarshal([]byte(oldInbound.Settings), &oldSettings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
settingsClients := oldSettings["clients"].([]any)
|
settingsClients, _ := oldSettings["clients"].([]any)
|
||||||
var preservedCreated any
|
var preservedCreated any
|
||||||
var preservedSubID string
|
var preservedSubID string
|
||||||
if clientIndex >= 0 && clientIndex < len(settingsClients) {
|
if clientIndex >= 0 && clientIndex < len(settingsClients) {
|
||||||
@@ -536,6 +578,17 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
|
|||||||
newMap["subId"] = random.NumLower(16)
|
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
|
interfaceClients[0] = newMap
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -681,13 +734,17 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
|
|||||||
cipher = oldSettings["method"].(string)
|
cipher = oldSettings["method"].(string)
|
||||||
}
|
}
|
||||||
err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
|
err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
|
||||||
"email": clients[0].Email,
|
"email": clients[0].Email,
|
||||||
"id": clients[0].ID,
|
"id": clients[0].ID,
|
||||||
"security": clients[0].Security,
|
"security": clients[0].Security,
|
||||||
"flow": clients[0].Flow,
|
"flow": clients[0].Flow,
|
||||||
"auth": clients[0].Auth,
|
"auth": clients[0].Auth,
|
||||||
"password": clients[0].Password,
|
"password": clients[0].Password,
|
||||||
"cipher": cipher,
|
"cipher": cipher,
|
||||||
|
"publicKey": clients[0].PublicKey,
|
||||||
|
"allowedIPs": clients[0].AllowedIPs,
|
||||||
|
"preSharedKey": clients[0].PreSharedKey,
|
||||||
|
"keepAlive": keepAliveStr(clients[0].KeepAlive),
|
||||||
})
|
})
|
||||||
if err1 == nil {
|
if err1 == nil {
|
||||||
logger.Debug("Client edited on", rt.Name(), ":", clients[0].Email)
|
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 != "" {
|
if incoming.Reverse != "" {
|
||||||
row.Reverse = 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.SubID = incoming.SubID
|
||||||
row.LimitIP = incoming.LimitIP
|
row.LimitIP = incoming.LimitIP
|
||||||
row.TotalGB = incoming.TotalGB
|
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 finalClients []any
|
||||||
|
var wgPeers []any
|
||||||
for i := range dbClients {
|
for i := range dbClients {
|
||||||
c := dbClients[i]
|
c := dbClients[i]
|
||||||
if enable, exists := enableMap[c.Email]; exists && !enable {
|
if enable, exists := enableMap[c.Email]; exists && !enable {
|
||||||
@@ -204,14 +205,40 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
|||||||
if c.Auth != "" {
|
if c.Auth != "" {
|
||||||
entry["auth"] = 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)
|
finalClients = append(finalClients, entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, hadClients := settings["clients"]
|
var mutated bool
|
||||||
mutated := hadClients || len(finalClients) > 0
|
if inbound.Protocol == model.WireGuard {
|
||||||
if mutated {
|
delete(settings, "clients")
|
||||||
settings["clients"] = finalClients
|
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) {
|
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",
|
"uuid": "UUID",
|
||||||
"flow": "Flow",
|
"flow": "Flow",
|
||||||
"vmessSecurity": "أمان VMess",
|
"vmessSecurity": "أمان VMess",
|
||||||
|
"wireguardPrivateKey": "مفتاح وايرغارد الخاص",
|
||||||
|
"wireguardPublicKey": "مفتاح وايرغارد العام",
|
||||||
|
"wireguardPreSharedKey": "مفتاح وايرغارد المشترك مسبقًا",
|
||||||
|
"wireguardAllowedIPs": "عناوين IP المسموحة لوايرغارد",
|
||||||
"reverseTag": "وسم عكسي",
|
"reverseTag": "وسم عكسي",
|
||||||
"reverseTagPlaceholder": "Reverse tag اختياري",
|
"reverseTagPlaceholder": "Reverse tag اختياري",
|
||||||
"telegramId": "معرّف مستخدم تلغرام",
|
"telegramId": "معرّف مستخدم تلغرام",
|
||||||
|
|||||||
@@ -888,6 +888,10 @@
|
|||||||
"uuid": "UUID",
|
"uuid": "UUID",
|
||||||
"flow": "Flow",
|
"flow": "Flow",
|
||||||
"vmessSecurity": "VMess Security",
|
"vmessSecurity": "VMess Security",
|
||||||
|
"wireguardPrivateKey": "WireGuard Private Key",
|
||||||
|
"wireguardPublicKey": "WireGuard Public Key",
|
||||||
|
"wireguardPreSharedKey": "WireGuard Pre-Shared Key",
|
||||||
|
"wireguardAllowedIPs": "WireGuard Allowed IPs",
|
||||||
"reverseTag": "Reverse tag",
|
"reverseTag": "Reverse tag",
|
||||||
"reverseTagPlaceholder": "Optional reverse tag",
|
"reverseTagPlaceholder": "Optional reverse tag",
|
||||||
"telegramId": "Telegram user ID",
|
"telegramId": "Telegram user ID",
|
||||||
|
|||||||
@@ -885,6 +885,10 @@
|
|||||||
"uuid": "UUID",
|
"uuid": "UUID",
|
||||||
"flow": "Flow",
|
"flow": "Flow",
|
||||||
"vmessSecurity": "Seguridad VMess",
|
"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",
|
"reverseTag": "Etiqueta inversa",
|
||||||
"reverseTagPlaceholder": "Reverse tag opcional",
|
"reverseTagPlaceholder": "Reverse tag opcional",
|
||||||
"telegramId": "ID de usuario de Telegram",
|
"telegramId": "ID de usuario de Telegram",
|
||||||
|
|||||||
@@ -885,6 +885,10 @@
|
|||||||
"uuid": "UUID",
|
"uuid": "UUID",
|
||||||
"flow": "Flow",
|
"flow": "Flow",
|
||||||
"vmessSecurity": "امنیت VMess",
|
"vmessSecurity": "امنیت VMess",
|
||||||
|
"wireguardPrivateKey": "کلید خصوصی وایرگارد",
|
||||||
|
"wireguardPublicKey": "کلید عمومی وایرگارد",
|
||||||
|
"wireguardPreSharedKey": "کلید پیشاشتراکی وایرگارد",
|
||||||
|
"wireguardAllowedIPs": "آیپیهای مجاز وایرگارد",
|
||||||
"reverseTag": "تگ معکوس",
|
"reverseTag": "تگ معکوس",
|
||||||
"reverseTagPlaceholder": "Reverse tag اختیاری",
|
"reverseTagPlaceholder": "Reverse tag اختیاری",
|
||||||
"telegramId": "شناسه کاربر تلگرام",
|
"telegramId": "شناسه کاربر تلگرام",
|
||||||
|
|||||||
@@ -885,6 +885,10 @@
|
|||||||
"uuid": "UUID",
|
"uuid": "UUID",
|
||||||
"flow": "Flow",
|
"flow": "Flow",
|
||||||
"vmessSecurity": "Keamanan VMess",
|
"vmessSecurity": "Keamanan VMess",
|
||||||
|
"wireguardPrivateKey": "Kunci Privat WireGuard",
|
||||||
|
"wireguardPublicKey": "Kunci Publik WireGuard",
|
||||||
|
"wireguardPreSharedKey": "Kunci Pra-Berbagi WireGuard",
|
||||||
|
"wireguardAllowedIPs": "IP yang Diizinkan WireGuard",
|
||||||
"reverseTag": "Reverse tag",
|
"reverseTag": "Reverse tag",
|
||||||
"reverseTagPlaceholder": "Reverse tag opsional",
|
"reverseTagPlaceholder": "Reverse tag opsional",
|
||||||
"telegramId": "ID pengguna Telegram",
|
"telegramId": "ID pengguna Telegram",
|
||||||
|
|||||||
@@ -885,6 +885,10 @@
|
|||||||
"uuid": "UUID",
|
"uuid": "UUID",
|
||||||
"flow": "Flow",
|
"flow": "Flow",
|
||||||
"vmessSecurity": "VMess セキュリティ",
|
"vmessSecurity": "VMess セキュリティ",
|
||||||
|
"wireguardPrivateKey": "WireGuard 秘密鍵",
|
||||||
|
"wireguardPublicKey": "WireGuard 公開鍵",
|
||||||
|
"wireguardPreSharedKey": "WireGuard 事前共有鍵",
|
||||||
|
"wireguardAllowedIPs": "WireGuard 許可IP",
|
||||||
"reverseTag": "Reverse tag",
|
"reverseTag": "Reverse tag",
|
||||||
"reverseTagPlaceholder": "任意の Reverse tag",
|
"reverseTagPlaceholder": "任意の Reverse tag",
|
||||||
"telegramId": "Telegram ユーザー ID",
|
"telegramId": "Telegram ユーザー ID",
|
||||||
|
|||||||
@@ -885,6 +885,10 @@
|
|||||||
"uuid": "UUID",
|
"uuid": "UUID",
|
||||||
"flow": "Flow",
|
"flow": "Flow",
|
||||||
"vmessSecurity": "Segurança VMess",
|
"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",
|
"reverseTag": "Tag reversa",
|
||||||
"reverseTagPlaceholder": "Reverse tag opcional",
|
"reverseTagPlaceholder": "Reverse tag opcional",
|
||||||
"telegramId": "ID de usuário do Telegram",
|
"telegramId": "ID de usuário do Telegram",
|
||||||
|
|||||||
@@ -885,6 +885,10 @@
|
|||||||
"uuid": "UUID",
|
"uuid": "UUID",
|
||||||
"flow": "Flow",
|
"flow": "Flow",
|
||||||
"vmessSecurity": "VMess Security",
|
"vmessSecurity": "VMess Security",
|
||||||
|
"wireguardPrivateKey": "Приватный ключ WireGuard",
|
||||||
|
"wireguardPublicKey": "Публичный ключ WireGuard",
|
||||||
|
"wireguardPreSharedKey": "Общий ключ WireGuard",
|
||||||
|
"wireguardAllowedIPs": "Разрешённые IP WireGuard",
|
||||||
"reverseTag": "Обратный тег",
|
"reverseTag": "Обратный тег",
|
||||||
"reverseTagPlaceholder": "Необязательный Reverse tag",
|
"reverseTagPlaceholder": "Необязательный Reverse tag",
|
||||||
"telegramId": "ID пользователя Telegram",
|
"telegramId": "ID пользователя Telegram",
|
||||||
|
|||||||
@@ -885,6 +885,10 @@
|
|||||||
"uuid": "UUID",
|
"uuid": "UUID",
|
||||||
"flow": "Flow",
|
"flow": "Flow",
|
||||||
"vmessSecurity": "VMess Güvenlik",
|
"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",
|
"reverseTag": "Reverse Tag",
|
||||||
"reverseTagPlaceholder": "İsteğe Bağlı Reverse Tag",
|
"reverseTagPlaceholder": "İsteğe Bağlı Reverse Tag",
|
||||||
"telegramId": "Telegram Kullanıcı ID'si",
|
"telegramId": "Telegram Kullanıcı ID'si",
|
||||||
|
|||||||
@@ -885,6 +885,10 @@
|
|||||||
"uuid": "UUID",
|
"uuid": "UUID",
|
||||||
"flow": "Flow",
|
"flow": "Flow",
|
||||||
"vmessSecurity": "Безпека VMess",
|
"vmessSecurity": "Безпека VMess",
|
||||||
|
"wireguardPrivateKey": "Приватний ключ WireGuard",
|
||||||
|
"wireguardPublicKey": "Публічний ключ WireGuard",
|
||||||
|
"wireguardPreSharedKey": "Спільний ключ WireGuard",
|
||||||
|
"wireguardAllowedIPs": "Дозволені IP WireGuard",
|
||||||
"reverseTag": "Зворотний тег",
|
"reverseTag": "Зворотний тег",
|
||||||
"reverseTagPlaceholder": "Необов'язковий Reverse tag",
|
"reverseTagPlaceholder": "Необов'язковий Reverse tag",
|
||||||
"telegramId": "ID користувача Telegram",
|
"telegramId": "ID користувача Telegram",
|
||||||
|
|||||||
@@ -885,6 +885,10 @@
|
|||||||
"uuid": "UUID",
|
"uuid": "UUID",
|
||||||
"flow": "Flow",
|
"flow": "Flow",
|
||||||
"vmessSecurity": "Bảo mật VMess",
|
"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",
|
"reverseTag": "Reverse tag",
|
||||||
"reverseTagPlaceholder": "Reverse tag tùy chọn",
|
"reverseTagPlaceholder": "Reverse tag tùy chọn",
|
||||||
"telegramId": "ID người dùng Telegram",
|
"telegramId": "ID người dùng Telegram",
|
||||||
|
|||||||
@@ -885,6 +885,10 @@
|
|||||||
"uuid": "UUID",
|
"uuid": "UUID",
|
||||||
"flow": "Flow",
|
"flow": "Flow",
|
||||||
"vmessSecurity": "VMess 加密",
|
"vmessSecurity": "VMess 加密",
|
||||||
|
"wireguardPrivateKey": "WireGuard 私钥",
|
||||||
|
"wireguardPublicKey": "WireGuard 公钥",
|
||||||
|
"wireguardPreSharedKey": "WireGuard 预共享密钥",
|
||||||
|
"wireguardAllowedIPs": "WireGuard 允许的 IP",
|
||||||
"reverseTag": "反向标签",
|
"reverseTag": "反向标签",
|
||||||
"reverseTagPlaceholder": "可选 Reverse tag",
|
"reverseTagPlaceholder": "可选 Reverse tag",
|
||||||
"telegramId": "Telegram 用户 ID",
|
"telegramId": "Telegram 用户 ID",
|
||||||
|
|||||||
@@ -885,6 +885,10 @@
|
|||||||
"uuid": "UUID",
|
"uuid": "UUID",
|
||||||
"flow": "Flow",
|
"flow": "Flow",
|
||||||
"vmessSecurity": "VMess 加密",
|
"vmessSecurity": "VMess 加密",
|
||||||
|
"wireguardPrivateKey": "WireGuard 私鑰",
|
||||||
|
"wireguardPublicKey": "WireGuard 公鑰",
|
||||||
|
"wireguardPreSharedKey": "WireGuard 預共用金鑰",
|
||||||
|
"wireguardAllowedIPs": "WireGuard 允許的 IP",
|
||||||
"reverseTag": "反向標籤",
|
"reverseTag": "反向標籤",
|
||||||
"reverseTagPlaceholder": "選用 Reverse tag",
|
"reverseTagPlaceholder": "選用 Reverse tag",
|
||||||
"telegramId": "Telegram 使用者 ID",
|
"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/config"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
"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/common"
|
||||||
|
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
|
||||||
|
|
||||||
"github.com/xtls/xray-core/app/proxyman/command"
|
"github.com/xtls/xray-core/app/proxyman/command"
|
||||||
routerService "github.com/xtls/xray-core/app/router/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/trojan"
|
||||||
"github.com/xtls/xray-core/proxy/vless"
|
"github.com/xtls/xray-core/proxy/vless"
|
||||||
"github.com/xtls/xray-core/proxy/vmess"
|
"github.com/xtls/xray-core/proxy/vmess"
|
||||||
|
wireguard "github.com/xtls/xray-core/proxy/wireguard"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"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.
|
// collectStringSlice normalizes a JSON-decoded value into a slice of non-empty
|
||||||
func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]any) error {
|
// strings, accepting both []string (typed maps) and []any (json.Unmarshal output).
|
||||||
userEmail, err := getRequiredUserString(user, "email")
|
func collectStringSlice(value any) []string {
|
||||||
if err != nil {
|
switch v := value.(type) {
|
||||||
return err
|
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
|
// buildUserAccount constructs the typed xray account for a user of the given
|
||||||
switch Protocol {
|
// 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":
|
case "vmess":
|
||||||
userID, err := getRequiredUserString(user, "id")
|
userID, err := getRequiredUserString(user, "id")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
account = serial.ToTypedMessage(&vmess.Account{
|
return serial.ToTypedMessage(&vmess.Account{
|
||||||
Id: userID,
|
Id: userID,
|
||||||
})
|
}), nil
|
||||||
case "vless":
|
case "vless":
|
||||||
userID, err := getRequiredUserString(user, "id")
|
userID, err := getRequiredUserString(user, "id")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
userFlow, err := getOptionalUserString(user, "flow")
|
userFlow, err := getOptionalUserString(user, "flow")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
vlessAccount := &vless.Account{
|
vlessAccount := &vless.Account{
|
||||||
Id: userID,
|
Id: userID,
|
||||||
Flow: userFlow,
|
Flow: userFlow,
|
||||||
}
|
}
|
||||||
// Add testseed if provided
|
|
||||||
if testseedVal, ok := user["testseed"]; ok {
|
if testseedVal, ok := user["testseed"]; ok {
|
||||||
if testseedArr, ok := testseedVal.([]any); ok && len(testseedArr) >= 4 {
|
if testseedArr, ok := testseedVal.([]any); ok && len(testseedArr) >= 4 {
|
||||||
testseed := make([]uint32, len(testseedArr))
|
testseed := make([]uint32, len(testseedArr))
|
||||||
@@ -455,7 +479,6 @@ func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]an
|
|||||||
vlessAccount.Testseed = testseedArr
|
vlessAccount.Testseed = testseedArr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Add testpre if provided (for outbound, but can be in user for compatibility)
|
|
||||||
if testpreVal, ok := user["testpre"]; ok {
|
if testpreVal, ok := user["testpre"]; ok {
|
||||||
if testpre, ok := testpreVal.(float64); ok && testpre > 0 {
|
if testpre, ok := testpreVal.(float64); ok && testpre > 0 {
|
||||||
vlessAccount.Testpre = uint32(testpre)
|
vlessAccount.Testpre = uint32(testpre)
|
||||||
@@ -463,25 +486,25 @@ func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]an
|
|||||||
vlessAccount.Testpre = testpre
|
vlessAccount.Testpre = testpre
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
account = serial.ToTypedMessage(vlessAccount)
|
return serial.ToTypedMessage(vlessAccount), nil
|
||||||
case "trojan":
|
case "trojan":
|
||||||
password, err := getRequiredUserString(user, "password")
|
password, err := getRequiredUserString(user, "password")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
account = serial.ToTypedMessage(&trojan.Account{
|
return serial.ToTypedMessage(&trojan.Account{
|
||||||
Password: password,
|
Password: password,
|
||||||
})
|
}), nil
|
||||||
case "shadowsocks":
|
case "shadowsocks":
|
||||||
cipher, err := getOptionalUserString(user, "cipher")
|
cipher, err := getOptionalUserString(user, "cipher")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
password, err := getRequiredUserString(user, "password")
|
password, err := getRequiredUserString(user, "password")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var ssCipherType shadowsocks.CipherType
|
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 {
|
if ssCipherType != shadowsocks.CipherType_NONE {
|
||||||
account = serial.ToTypedMessage(&shadowsocks.Account{
|
return serial.ToTypedMessage(&shadowsocks.Account{
|
||||||
Password: password,
|
Password: password,
|
||||||
CipherType: ssCipherType,
|
CipherType: ssCipherType,
|
||||||
})
|
}), nil
|
||||||
} else {
|
|
||||||
account = serial.ToTypedMessage(&shadowsocks_2022.Account{
|
|
||||||
Key: password,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
return serial.ToTypedMessage(&shadowsocks_2022.Account{
|
||||||
|
Key: password,
|
||||||
|
}), nil
|
||||||
case "hysteria":
|
case "hysteria":
|
||||||
auth, err := getRequiredUserString(user, "auth")
|
auth, err := getRequiredUserString(user, "auth")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
account = serial.ToTypedMessage(&hysteriaAccount.Account{
|
return serial.ToTypedMessage(&hysteriaAccount.Account{
|
||||||
Auth: auth,
|
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:
|
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
|
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