mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-24 23:05:01 +03:00
feat(amneziawg): support persistent keepalive ranges (#6881)
AmneziaWG clients accept a randomised PersistentKeepalive range such as 15-25, which makes keepalive timing harder to fingerprint; the panel only stored a single number. A client's keepalive may now be a range, validated the way AmneziaWG parses it. The downloadable .conf and vpn:// profile carry the range, Clash/Mihomo output gets its lower bound, and plain WireGuard stays scalar-only. The range lives in an additive wg_keep_alive_range column, so the integer column and an older panel binary keep working after a rollback.
This commit is contained in:
@@ -83,12 +83,12 @@ jobs:
|
|||||||
- name: PostgreSQL schema and migration tests
|
- name: PostgreSQL schema and migration tests
|
||||||
run: |
|
run: |
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
go test ./internal/database -run '^(TestHostAutoMigrateCreatesColumns_Postgres|TestMigrate_Postgres|TestClientWeeklyRenewMigration_Postgres|TestMigrate_LegacyBooleanVerifyPeerCertByNameIsBlanked_Postgres)$' -count=1 -v | tee /tmp/postgres-schema.log
|
go test ./internal/database -run '^(TestHostAutoMigrateCreatesColumns_Postgres|TestMigrate_Postgres|TestClientWeeklyRenewMigration_Postgres|TestMigrate_LegacyBooleanVerifyPeerCertByNameIsBlanked_Postgres|TestClientKeepAlivePostgresMigrationPreservesLegacyScalars)$' -count=1 -v | tee /tmp/postgres-schema.log
|
||||||
# All must pass. Counting, not SKIP-matching: renaming a test would
|
# All must pass. Counting, not SKIP-matching: renaming a test would
|
||||||
# otherwise leave this step green while testing nothing.
|
# otherwise leave this step green while testing nothing.
|
||||||
passed=$(grep -c -- '^--- PASS' /tmp/postgres-schema.log || true)
|
passed=$(grep -c -- '^--- PASS' /tmp/postgres-schema.log || true)
|
||||||
if [ "$passed" -lt 4 ]; then
|
if [ "$passed" -lt 5 ]; then
|
||||||
echo "expected at least 4 passing PostgreSQL schema tests, got $passed" >&2
|
echo "expected at least 5 passing PostgreSQL schema tests, got $passed" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,14 @@ inbound. There is no separate toggle for this: unlike a kernel tunnel,
|
|||||||
there's no other way for a peer's traffic to reach the internet once it's
|
there's no other way for a peer's traffic to reach the internet once it's
|
||||||
decapsulated.
|
decapsulated.
|
||||||
|
|
||||||
|
## Keepalive ranges
|
||||||
|
|
||||||
|
AmneziaWG clients accept `keepAlive` as a number or an ordered range from 0 to 65535, such as `15-25`. Omitted values keep the existing setting; explicit `0` disables keepalive. Native `.conf` files preserve ranges, while Clash/Mihomo scalar-only output uses the lower bound (`0-25` therefore disables keepalive). Plain WireGuard clients support only a scalar value. Upgrade sub-nodes before sending them range values because older nodes parse keepalive as an integer.
|
||||||
|
|
||||||
|
Parser support is present in the native tools and the panel embedded engine: [the native `awg-tools` v3.1.20260812 config parser](https://github.com/amnezia-vpn/amneziawg-tools/blob/v3.1.20260812/src/config.c#L596-L599) sends `PersistentKeepalive` to its [ordered-range parser](https://github.com/amnezia-vpn/amneziawg-tools/blob/v3.1.20260812/src/type.c#L40-L59), and this panel pins [amneziawg-go v3.1.20260828](https://github.com/amnezia-vpn/amneziawg-go/tree/v3.1.20260828) in `go.mod`; its [UAPI parser](https://github.com/amnezia-vpn/amneziawg-go/blob/v3.1.20260828/device/uapi.go#L663-L674) reads the interval as a `UintRange`. This confirms parser support for these code paths, not interoperability with every third-party client. The panel enforces the documented 0-65535 limit on new values; the native parser's syntax support is not a substitute for that validation.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## What the configuration looks like
|
## What the configuration looks like
|
||||||
|
|
||||||
A client's downloadable `.conf` (also what the `vpn://` share link encodes,
|
A client's downloadable `.conf` (also what the `vpn://` share link encodes,
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
@@ -1759,9 +1759,15 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"keepAlive": {
|
"keepAlive": {
|
||||||
"description": "Seconds between PersistentKeepalive packets; 0 sends none, omit to keep the stored value",
|
"description": "Numeric values or AmneziaWG ranges; 0 disables, omission preserves.",
|
||||||
"nullable": true,
|
"oneOf": [
|
||||||
"type": "integer"
|
{
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"limitIp": {
|
"limitIp": {
|
||||||
"description": "IP limit for this client",
|
"description": "IP limit for this client",
|
||||||
@@ -1981,7 +1987,15 @@
|
|||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"keepAlive": {
|
"keepAlive": {
|
||||||
"type": "integer"
|
"description": "Numeric values or AmneziaWG ranges.",
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"limitHwid": {
|
"limitHwid": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
|
|||||||
@@ -1759,9 +1759,15 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"keepAlive": {
|
"keepAlive": {
|
||||||
"description": "Seconds between PersistentKeepalive packets; 0 sends none, omit to keep the stored value",
|
"description": "Numeric values or AmneziaWG ranges; 0 disables, omission preserves.",
|
||||||
"nullable": true,
|
"oneOf": [
|
||||||
"type": "integer"
|
{
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"limitIp": {
|
"limitIp": {
|
||||||
"description": "IP limit for this client",
|
"description": "IP limit for this client",
|
||||||
@@ -1981,7 +1987,15 @@
|
|||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"keepAlive": {
|
"keepAlive": {
|
||||||
"type": "integer"
|
"description": "Numeric values or AmneziaWG ranges.",
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"limitHwid": {
|
"limitHwid": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
|
|||||||
@@ -424,7 +424,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"forwardedPorts": "",
|
"forwardedPorts": "",
|
||||||
"group": "",
|
"group": "",
|
||||||
"id": "",
|
"id": "",
|
||||||
"keepAlive": null,
|
"keepAlive": 0,
|
||||||
"limitIp": 0,
|
"limitIp": 0,
|
||||||
"password": "",
|
"password": "",
|
||||||
"preSharedKey": "",
|
"preSharedKey": "",
|
||||||
|
|||||||
@@ -1733,9 +1733,15 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"keepAlive": {
|
"keepAlive": {
|
||||||
"description": "Seconds between PersistentKeepalive packets; 0 sends none, omit to keep the stored value",
|
"description": "Numeric values or AmneziaWG ranges; 0 disables, omission preserves.",
|
||||||
"nullable": true,
|
"oneOf": [
|
||||||
"type": "integer"
|
{
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"limitIp": {
|
"limitIp": {
|
||||||
"description": "IP limit for this client",
|
"description": "IP limit for this client",
|
||||||
@@ -1955,7 +1961,15 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"keepAlive": {
|
"keepAlive": {
|
||||||
"type": "integer"
|
"description": "Numeric values or AmneziaWG ranges.",
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"limitHwid": {
|
"limitHwid": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
|
|||||||
@@ -420,7 +420,7 @@ export interface Client {
|
|||||||
forwardedPorts?: string;
|
forwardedPorts?: string;
|
||||||
group?: string;
|
group?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
keepAlive?: number | null;
|
keepAlive?: number | string;
|
||||||
limitIp: number;
|
limitIp: number;
|
||||||
password?: string;
|
password?: string;
|
||||||
preSharedKey?: string;
|
preSharedKey?: string;
|
||||||
@@ -471,7 +471,7 @@ export interface ClientRecord {
|
|||||||
forwardedPorts: string;
|
forwardedPorts: string;
|
||||||
group: string;
|
group: string;
|
||||||
id: number;
|
id: number;
|
||||||
keepAlive: number;
|
keepAlive: number | string;
|
||||||
limitHwid: number;
|
limitHwid: number;
|
||||||
limitIp: number;
|
limitIp: number;
|
||||||
password: string;
|
password: string;
|
||||||
|
|||||||
@@ -441,7 +441,7 @@ export const ClientSchema = z.object({
|
|||||||
forwardedPorts: z.string().optional(),
|
forwardedPorts: z.string().optional(),
|
||||||
group: z.string().optional(),
|
group: z.string().optional(),
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
keepAlive: z.number().int().nullable().optional(),
|
keepAlive: z.union([z.number().int(), z.string()]).optional(),
|
||||||
limitIp: z.number().int(),
|
limitIp: z.number().int(),
|
||||||
password: z.string().optional(),
|
password: z.string().optional(),
|
||||||
preSharedKey: z.string().optional(),
|
preSharedKey: z.string().optional(),
|
||||||
@@ -495,7 +495,7 @@ export const ClientRecordSchema = z.object({
|
|||||||
forwardedPorts: z.string(),
|
forwardedPorts: z.string(),
|
||||||
group: z.string(),
|
group: z.string(),
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
keepAlive: z.number().int(),
|
keepAlive: z.union([z.number().int(), z.string()]),
|
||||||
limitHwid: z.number().int(),
|
limitHwid: z.number().int(),
|
||||||
limitIp: z.number().int(),
|
limitIp: z.number().int(),
|
||||||
password: z.string(),
|
password: z.string(),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Base64, Wireguard } from '@/utils';
|
import { Base64, Wireguard } from '@/utils';
|
||||||
import { effectiveMtu } from '@/lib/xray/amneziawg-obfuscation';
|
import { effectiveMtu } from '@/lib/xray/amneziawg-obfuscation';
|
||||||
|
import { AmneziaWGKeepAliveRangeSchema } from '@/schemas/primitives/keepalive';
|
||||||
|
|
||||||
import type { Inbound } from '@/schemas/api/inbound';
|
import type { Inbound } from '@/schemas/api/inbound';
|
||||||
import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg';
|
import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg';
|
||||||
@@ -1127,7 +1128,10 @@ export function genAmneziaWGConfig(input: GenAmneziaWGLinkInput): string {
|
|||||||
lines.push(`PresharedKey = ${client.preSharedKey}`);
|
lines.push(`PresharedKey = ${client.preSharedKey}`);
|
||||||
}
|
}
|
||||||
lines.push('AllowedIPs = 0.0.0.0/0, ::/0', `Endpoint = ${address}:${port}`);
|
lines.push('AllowedIPs = 0.0.0.0/0, ::/0', `Endpoint = ${address}:${port}`);
|
||||||
if (typeof client.keepAlive === 'number' && client.keepAlive > 0) {
|
if (typeof client.keepAlive === 'string') {
|
||||||
|
if (!AmneziaWGKeepAliveRangeSchema.safeParse(client.keepAlive).success) return '';
|
||||||
|
lines.push(`PersistentKeepalive = ${client.keepAlive}`);
|
||||||
|
} else if (typeof client.keepAlive === 'number' && client.keepAlive > 0) {
|
||||||
lines.push(`PersistentKeepalive = ${client.keepAlive}`);
|
lines.push(`PersistentKeepalive = ${client.keepAlive}`);
|
||||||
}
|
}
|
||||||
return joinConfLines(lines);
|
return joinConfLines(lines);
|
||||||
|
|||||||
@@ -38,9 +38,14 @@ import { resolveExternalLinkExpiry } from '@/lib/clients/external-link';
|
|||||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||||
import { useClientHwids } from '@/hooks/useClientHwids';
|
import { useClientHwids } from '@/hooks/useClientHwids';
|
||||||
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
|
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
|
||||||
import { FormField } from '@/components/form/rhf';
|
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||||
import ClientHwidListModal from '@/components/clients/ClientHwidList';
|
import ClientHwidListModal from '@/components/clients/ClientHwidList';
|
||||||
import { TLS_FLOW_CONTROL, TRAFFIC_RESETS } from '@/schemas/primitives';
|
import { TLS_FLOW_CONTROL, TRAFFIC_RESETS } from '@/schemas/primitives';
|
||||||
|
import {
|
||||||
|
OptionalAmneziaWGKeepAliveInputSchema,
|
||||||
|
OptionalNumericKeepAliveInputSchema,
|
||||||
|
keepAliveInputValue,
|
||||||
|
} from '@/schemas/primitives/keepalive';
|
||||||
import type {
|
import type {
|
||||||
ClientRecord,
|
ClientRecord,
|
||||||
InboundOption,
|
InboundOption,
|
||||||
@@ -137,7 +142,7 @@ type Values = ClientFormValues & {
|
|||||||
wgAllowedIPs: string;
|
wgAllowedIPs: string;
|
||||||
awgAllowedIPs: string;
|
awgAllowedIPs: string;
|
||||||
awgForwardedPorts: string;
|
awgForwardedPorts: string;
|
||||||
wgKeepAlive: number;
|
wgKeepAlive: number | string | undefined;
|
||||||
secret: string;
|
secret: string;
|
||||||
adTag: string;
|
adTag: string;
|
||||||
};
|
};
|
||||||
@@ -389,7 +394,7 @@ export default function ClientFormModal({
|
|||||||
wgAllowedIPs: wgTunnelIPs ?? client.allowedIPs ?? '',
|
wgAllowedIPs: wgTunnelIPs ?? client.allowedIPs ?? '',
|
||||||
awgAllowedIPs: awgTunnelIPs ?? client.allowedIPs ?? '',
|
awgAllowedIPs: awgTunnelIPs ?? client.allowedIPs ?? '',
|
||||||
awgForwardedPorts: client.forwardedPorts || '',
|
awgForwardedPorts: client.forwardedPorts || '',
|
||||||
wgKeepAlive: client.keepAlive ?? 0,
|
wgKeepAlive: client.keepAlive,
|
||||||
secret: client.secret || '',
|
secret: client.secret || '',
|
||||||
adTag: client.adTag || '',
|
adTag: client.adTag || '',
|
||||||
};
|
};
|
||||||
@@ -641,6 +646,25 @@ export default function ClientFormModal({
|
|||||||
|
|
||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
const values = methods.getValues();
|
const values = methods.getValues();
|
||||||
|
const unchangedLegacyKeepAlive =
|
||||||
|
isEdit &&
|
||||||
|
typeof client?.keepAlive === 'number' &&
|
||||||
|
client.keepAlive > 65535 &&
|
||||||
|
values.wgKeepAlive === client.keepAlive;
|
||||||
|
if (showAmneziawg && !unchangedLegacyKeepAlive) {
|
||||||
|
const keepAliveSchema = showWireguard
|
||||||
|
? OptionalNumericKeepAliveInputSchema
|
||||||
|
: OptionalAmneziaWGKeepAliveInputSchema;
|
||||||
|
const keepAliveResult = keepAliveSchema.safeParse(values.wgKeepAlive);
|
||||||
|
if (!keepAliveResult.success) {
|
||||||
|
methods.setError('wgKeepAlive', {
|
||||||
|
type: 'validate',
|
||||||
|
message: keepAliveResult.error.issues[0]?.message ?? 'validation.invalid',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
methods.clearErrors('wgKeepAlive');
|
||||||
|
}
|
||||||
const schema = isEdit ? ClientFormSchema : ClientCreateFormSchema;
|
const schema = isEdit ? ClientFormSchema : ClientCreateFormSchema;
|
||||||
const validated = schema.safeParse({
|
const validated = schema.safeParse({
|
||||||
email: values.email,
|
email: values.email,
|
||||||
@@ -712,7 +736,9 @@ export default function ClientFormModal({
|
|||||||
// so both protocols share this one field set — see wgPrivateKey etc.
|
// so both protocols share this one field set — see wgPrivateKey etc.
|
||||||
// below and the AmneziaWG-labeled variants of the same inputs.
|
// below and the AmneziaWG-labeled variants of the same inputs.
|
||||||
clientPayload.privateKey = values.wgPrivateKey;
|
clientPayload.privateKey = values.wgPrivateKey;
|
||||||
clientPayload.keepAlive = values.wgKeepAlive;
|
if (!unchangedLegacyKeepAlive && values.wgKeepAlive !== undefined) {
|
||||||
|
clientPayload.keepAlive = values.wgKeepAlive;
|
||||||
|
}
|
||||||
clientPayload.publicKey = values.wgPublicKey;
|
clientPayload.publicKey = values.wgPublicKey;
|
||||||
if (values.wgPreSharedKey) {
|
if (values.wgPreSharedKey) {
|
||||||
clientPayload.preSharedKey = values.wgPreSharedKey;
|
clientPayload.preSharedKey = values.wgPreSharedKey;
|
||||||
@@ -1339,10 +1365,34 @@ export default function ClientFormModal({
|
|||||||
<FormField
|
<FormField
|
||||||
name="wgKeepAlive"
|
name="wgKeepAlive"
|
||||||
label={t('pages.clients.tunnelKeepAlive')}
|
label={t('pages.clients.tunnelKeepAlive')}
|
||||||
extra={t('pages.clients.tunnelKeepAliveHint')}
|
extra={t(
|
||||||
transform={{ output: (v) => Number(v) || 0 }}
|
showAmneziawg && !showWireguard
|
||||||
|
? 'pages.clients.amneziaWgKeepAliveHint'
|
||||||
|
: 'pages.clients.tunnelKeepAliveHint',
|
||||||
|
)}
|
||||||
|
transform={{
|
||||||
|
output:
|
||||||
|
showAmneziawg && !showWireguard
|
||||||
|
? keepAliveInputValue
|
||||||
|
: (value) => Number(value) || 0,
|
||||||
|
}}
|
||||||
|
rules={
|
||||||
|
showAmneziawg
|
||||||
|
? {
|
||||||
|
validate: rhfZodValidate(
|
||||||
|
showWireguard
|
||||||
|
? OptionalNumericKeepAliveInputSchema
|
||||||
|
: OptionalAmneziaWGKeepAliveInputSchema,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<InputNumber min={0} max={65535} style={{ width: '100%' }} />
|
{showAmneziawg && !showWireguard ? (
|
||||||
|
<Input placeholder="25" />
|
||||||
|
) : (
|
||||||
|
<InputNumber min={0} max={65535} style={{ width: '100%' }} />
|
||||||
|
)}
|
||||||
</FormField>
|
</FormField>
|
||||||
{showAmneziawg && (
|
{showAmneziawg && (
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { formatInboundLabel } from '@/lib/inbounds/label';
|
|||||||
import { preferPublicHost, resolveShareHost } from '@/lib/xray/inbound-link';
|
import { preferPublicHost, resolveShareHost } from '@/lib/xray/inbound-link';
|
||||||
import { effectiveMtu } from '@/lib/xray/amneziawg-obfuscation';
|
import { effectiveMtu } from '@/lib/xray/amneziawg-obfuscation';
|
||||||
import { joinConfLines } from '@/lib/xray/conf-lines';
|
import { joinConfLines } from '@/lib/xray/conf-lines';
|
||||||
|
import { AmneziaWGKeepAliveRangeSchema } from '@/schemas/primitives/keepalive';
|
||||||
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
||||||
|
|
||||||
// AmneziaWG clients are wire-identical to WireGuard clients (same
|
// AmneziaWG clients are wire-identical to WireGuard clients (same
|
||||||
@@ -17,7 +18,7 @@ export function isAmneziaWGClient(client: ClientRecord | null | undefined): bool
|
|||||||
client.publicKey ||
|
client.publicKey ||
|
||||||
client.allowedIPs ||
|
client.allowedIPs ||
|
||||||
client.preSharedKey ||
|
client.preSharedKey ||
|
||||||
client.keepAlive
|
client.keepAlive !== undefined
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +103,11 @@ export function buildAmneziaWGClientConfig(
|
|||||||
lines.push('[Peer]', `PublicKey = ${server?.publicKey || ''}`);
|
lines.push('[Peer]', `PublicKey = ${server?.publicKey || ''}`);
|
||||||
if (client.preSharedKey) lines.push(`PresharedKey = ${client.preSharedKey}`);
|
if (client.preSharedKey) lines.push(`PresharedKey = ${client.preSharedKey}`);
|
||||||
lines.push('AllowedIPs = 0.0.0.0/0, ::/0', `Endpoint = ${endpoint}`);
|
lines.push('AllowedIPs = 0.0.0.0/0, ::/0', `Endpoint = ${endpoint}`);
|
||||||
if (client.keepAlive && client.keepAlive > 0)
|
if (typeof client.keepAlive === 'string') {
|
||||||
|
if (!AmneziaWGKeepAliveRangeSchema.safeParse(client.keepAlive).success) return '';
|
||||||
lines.push(`PersistentKeepalive = ${client.keepAlive}`);
|
lines.push(`PersistentKeepalive = ${client.keepAlive}`);
|
||||||
|
} else if (client.keepAlive && client.keepAlive > 0) {
|
||||||
|
lines.push(`PersistentKeepalive = ${client.keepAlive}`);
|
||||||
|
}
|
||||||
return joinConfLines(lines);
|
return joinConfLines(lines);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export function isWireguardClient(client: ClientRecord | null | undefined): bool
|
|||||||
client.publicKey ||
|
client.publicKey ||
|
||||||
client.allowedIPs ||
|
client.allowedIPs ||
|
||||||
client.preSharedKey ||
|
client.preSharedKey ||
|
||||||
client.keepAlive
|
typeof client.keepAlive === 'number'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ export function buildWireguardClientConfig(
|
|||||||
lines.push('[Peer]', `PublicKey = ${inbound?.wgPublicKey || ''}`);
|
lines.push('[Peer]', `PublicKey = ${inbound?.wgPublicKey || ''}`);
|
||||||
if (client.preSharedKey) lines.push(`PresharedKey = ${client.preSharedKey}`);
|
if (client.preSharedKey) lines.push(`PresharedKey = ${client.preSharedKey}`);
|
||||||
lines.push('AllowedIPs = 0.0.0.0/0, ::/0', `Endpoint = ${endpoint}`);
|
lines.push('AllowedIPs = 0.0.0.0/0, ::/0', `Endpoint = ${endpoint}`);
|
||||||
if (client.keepAlive && client.keepAlive > 0)
|
if (typeof client.keepAlive === 'number' && client.keepAlive > 0)
|
||||||
lines.push(`PersistentKeepalive = ${client.keepAlive}`);
|
lines.push(`PersistentKeepalive = ${client.keepAlive}`);
|
||||||
return joinConfLines(lines);
|
return joinConfLines(lines);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { OptionalClientKeepAliveSchema } from '@/schemas/primitives/keepalive';
|
||||||
|
|
||||||
const nullableStringArray = z
|
const nullableStringArray = z
|
||||||
.array(z.string())
|
.array(z.string())
|
||||||
@@ -52,7 +53,7 @@ export const ClientRecordSchema = z
|
|||||||
publicKey: z.string().optional(),
|
publicKey: z.string().optional(),
|
||||||
allowedIPs: z.string().optional(),
|
allowedIPs: z.string().optional(),
|
||||||
preSharedKey: z.string().optional(),
|
preSharedKey: z.string().optional(),
|
||||||
keepAlive: z.number().optional(),
|
keepAlive: OptionalClientKeepAliveSchema,
|
||||||
forwardedPorts: z.string().optional(),
|
forwardedPorts: z.string().optional(),
|
||||||
secret: z.string().optional(),
|
secret: z.string().optional(),
|
||||||
adTag: z.string().optional(),
|
adTag: z.string().optional(),
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const invalidKeepAlive = 'pages.clients.amneziaWgKeepAliveInvalid';
|
||||||
|
const MAX_KEEPALIVE_SECONDS = 65535;
|
||||||
|
|
||||||
|
export const AmneziaWGKeepAliveRangeSchema = z.string().refine((value) => {
|
||||||
|
if (!/^(0|[1-9]\d*)-(0|[1-9]\d*)$/.test(value)) return false;
|
||||||
|
const [minimum, maximum] = value.split('-').map(Number);
|
||||||
|
return minimum >= 0 && minimum <= maximum && maximum <= MAX_KEEPALIVE_SECONDS;
|
||||||
|
}, invalidKeepAlive);
|
||||||
|
|
||||||
|
// Numeric values stay permissive here so older stored values remain readable.
|
||||||
|
export const ClientKeepAliveSchema = z.union([z.number(), AmneziaWGKeepAliveRangeSchema]);
|
||||||
|
export const OptionalClientKeepAliveSchema = z.preprocess(
|
||||||
|
(value) => (value == null ? undefined : value),
|
||||||
|
ClientKeepAliveSchema.optional(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const boundedSeconds = z
|
||||||
|
.number()
|
||||||
|
.refine(
|
||||||
|
(value) => Number.isInteger(value) && value >= 0 && value <= MAX_KEEPALIVE_SECONDS,
|
||||||
|
invalidKeepAlive,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const OptionalNumericKeepAliveInputSchema = z.preprocess(
|
||||||
|
(value) => (value == null || value === '' ? undefined : value),
|
||||||
|
boundedSeconds.optional(),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const OptionalAmneziaWGKeepAliveInputSchema = z.preprocess(
|
||||||
|
(value) => (value == null || value === '' ? undefined : value),
|
||||||
|
z.union([boundedSeconds, AmneziaWGKeepAliveRangeSchema]).optional(),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function keepAliveInputValue(value: unknown): number | string | undefined {
|
||||||
|
if (value == null || value === '') return undefined;
|
||||||
|
const text = String(value);
|
||||||
|
return /^\d+$/.test(text) ? Number(text) : text;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { OptionalClientKeepAliveSchema } from '@/schemas/primitives/keepalive';
|
||||||
|
|
||||||
// AntD InputNumber emits null (not undefined) when the user clears it, and
|
// AntD InputNumber emits null (not undefined) when the user clears it, and
|
||||||
// the form store hands that null straight to safeParse on submit — a bare
|
// the form store hands that null straight to safeParse on submit — a bare
|
||||||
@@ -23,7 +24,7 @@ export const AmneziawgClientSchema = z.object({
|
|||||||
publicKey: z.string().optional(),
|
publicKey: z.string().optional(),
|
||||||
preSharedKey: z.string().optional(),
|
preSharedKey: z.string().optional(),
|
||||||
allowedIPs: z.array(z.string()).default([]),
|
allowedIPs: z.array(z.string()).default([]),
|
||||||
keepAlive: optionalClearedInt(z.number().int().min(0)),
|
keepAlive: OptionalClientKeepAliveSchema,
|
||||||
forwardedPorts: z.string().default(''),
|
forwardedPorts: z.string().default(''),
|
||||||
email: z.string().min(1),
|
email: z.string().min(1),
|
||||||
limitIp: z.number().int().min(0).default(0),
|
limitIp: z.number().int().min(0).default(0),
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { buildAmneziaWGClientConfig, isAmneziaWGClient } from '@/pages/clients/amneziawgConfig';
|
||||||
|
import { genAmneziaWGConfig } from '@/lib/xray/inbound-link';
|
||||||
|
import { buildWireguardClientConfig, isWireguardClient } from '@/pages/clients/wireguardConfig';
|
||||||
|
import ClientFormModal from '@/pages/clients/ClientFormModal';
|
||||||
|
import { ClientRecordSchema } from '@/schemas/client';
|
||||||
|
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
||||||
|
import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg';
|
||||||
|
import {
|
||||||
|
OptionalAmneziaWGKeepAliveInputSchema,
|
||||||
|
OptionalNumericKeepAliveInputSchema,
|
||||||
|
keepAliveInputValue,
|
||||||
|
} from '@/schemas/primitives/keepalive';
|
||||||
|
import { renderWithProviders } from './test-utils';
|
||||||
|
|
||||||
|
const awgInbound = {
|
||||||
|
id: 17,
|
||||||
|
tag: 'awg-17',
|
||||||
|
protocol: 'amneziawg',
|
||||||
|
port: 51820,
|
||||||
|
awgServer: { publicKey: 'serverPubKey==', jc: 4, jmin: 40, jmax: 100, s1: 30, s2: 90 },
|
||||||
|
} as unknown as InboundOption;
|
||||||
|
|
||||||
|
const baseClient = {
|
||||||
|
email: 'peer-1',
|
||||||
|
privateKey: 'clientPrivKey==',
|
||||||
|
allowedIPs: '10.8.1.2/32',
|
||||||
|
keepAlive: 25,
|
||||||
|
} as unknown as ClientRecord;
|
||||||
|
|
||||||
|
describe('AWG keepalive range UI contract', () => {
|
||||||
|
it('accepts range-valued API records while preserving legacy numeric values', () => {
|
||||||
|
expect(ClientRecordSchema.parse({ email: 'peer-1', keepAlive: '15-25' }).keepAlive).toBe(
|
||||||
|
'15-25',
|
||||||
|
);
|
||||||
|
expect(ClientRecordSchema.parse({ email: 'legacy', keepAlive: 70000 }).keepAlive).toBe(70000);
|
||||||
|
expect(ClientRecordSchema.safeParse({ email: 'bad', keepAlive: '15-65536' }).success).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(ClientRecordSchema.safeParse({ email: 'bad', keepAlive: '015-25' }).success).toBe(false);
|
||||||
|
expect(ClientRecordSchema.safeParse({ email: 'bad', keepAlive: '25-15' }).success).toBe(false);
|
||||||
|
expect(ClientRecordSchema.parse({ email: 'peer-1', keepAlive: '0-65535' }).keepAlive).toBe(
|
||||||
|
'0-65535',
|
||||||
|
);
|
||||||
|
expect(OptionalNumericKeepAliveInputSchema.safeParse('15-25').success).toBe(false);
|
||||||
|
expect(OptionalAmneziaWGKeepAliveInputSchema.parse(keepAliveInputValue('0'))).toBe(0);
|
||||||
|
expect(OptionalAmneziaWGKeepAliveInputSchema.parse(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes the exact range to a native AmneziaWG profile', () => {
|
||||||
|
const conf = buildAmneziaWGClientConfig(
|
||||||
|
{ ...baseClient, keepAlive: '15-25' } as unknown as ClientRecord,
|
||||||
|
awgInbound,
|
||||||
|
'awg.example.test',
|
||||||
|
);
|
||||||
|
expect(conf).toContain('PersistentKeepalive = 15-25');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes the exact range to an AmneziaWG share profile', () => {
|
||||||
|
const settings = {
|
||||||
|
server: { publicKey: 'serverPubKey==', jc: 4, jmin: 40, jmax: 100, s1: 30, s2: 90 },
|
||||||
|
clients: [
|
||||||
|
{
|
||||||
|
email: 'peer-1',
|
||||||
|
privateKey: 'clientPrivKey==',
|
||||||
|
allowedIPs: ['10.8.1.2/32'],
|
||||||
|
keepAlive: '15-25',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as AmneziawgInboundSettings;
|
||||||
|
const conf = genAmneziaWGConfig({
|
||||||
|
settings,
|
||||||
|
address: 'awg.example.test',
|
||||||
|
port: 51820,
|
||||||
|
remark: 'peer-1',
|
||||||
|
peerIndex: 0,
|
||||||
|
});
|
||||||
|
expect(conf).toContain('PersistentKeepalive = 15-25');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never exports a range into a plain WireGuard profile', () => {
|
||||||
|
const conf = buildWireguardClientConfig(
|
||||||
|
{ ...baseClient, keepAlive: '15-25' } as unknown as ClientRecord,
|
||||||
|
{ id: 18, tag: 'wg-18', protocol: 'wireguard', port: 51821 } as unknown as InboundOption,
|
||||||
|
'wg.example.test',
|
||||||
|
);
|
||||||
|
expect(conf).toContain('PrivateKey = clientPrivKey==');
|
||||||
|
expect(conf).toContain('Endpoint = wg.example.test:51821');
|
||||||
|
expect(conf).not.toContain('PersistentKeepalive');
|
||||||
|
const rangeOnlyClient = { email: 'peer-1', keepAlive: '15-25' } as unknown as ClientRecord;
|
||||||
|
expect(isWireguardClient(rangeOnlyClient)).toBe(false);
|
||||||
|
expect(isAmneziaWGClient(rangeOnlyClient)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the shared WireGuard and AWG form numeric-only', async () => {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
renderWithProviders(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ClientFormModal
|
||||||
|
open
|
||||||
|
mode="edit"
|
||||||
|
client={baseClient}
|
||||||
|
inbounds={[
|
||||||
|
awgInbound,
|
||||||
|
{
|
||||||
|
id: 18,
|
||||||
|
tag: 'wg-18',
|
||||||
|
protocol: 'wireguard',
|
||||||
|
port: 51821,
|
||||||
|
} as unknown as InboundOption,
|
||||||
|
]}
|
||||||
|
attachedIds={[17, 18]}
|
||||||
|
save={vi.fn().mockResolvedValue({ success: true })}
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
const credentialsTab = Array.from(document.querySelectorAll('.ant-tabs-tab')).find(
|
||||||
|
(tab) => tab.textContent?.trim() === 'Credentials',
|
||||||
|
);
|
||||||
|
if (!credentialsTab) throw new Error('Credentials tab not found');
|
||||||
|
fireEvent.click(credentialsTab);
|
||||||
|
|
||||||
|
const keepAliveLabel = Array.from(
|
||||||
|
document.querySelectorAll<HTMLLabelElement>('.ant-form-item-label label'),
|
||||||
|
).find((label) => label.textContent?.trim() === 'Keepalive (seconds)');
|
||||||
|
if (!keepAliveLabel?.htmlFor) throw new Error('Keepalive field label not found');
|
||||||
|
const keepAlive = await screen.findByRole('spinbutton', { name: 'Keepalive (seconds)' });
|
||||||
|
expect(document.getElementById(keepAliveLabel.htmlFor)).toBe(keepAlive);
|
||||||
|
expect(screen.queryByPlaceholderText('25')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits an AWG range from the client form', async () => {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
const save = vi.fn().mockResolvedValue({ success: true });
|
||||||
|
renderWithProviders(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ClientFormModal
|
||||||
|
open
|
||||||
|
mode="edit"
|
||||||
|
client={baseClient}
|
||||||
|
inbounds={[awgInbound]}
|
||||||
|
attachedIds={[17]}
|
||||||
|
save={save}
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
const credentialsTab = Array.from(document.querySelectorAll('.ant-tabs-tab')).find(
|
||||||
|
(tab) => tab.textContent?.trim() === 'Credentials',
|
||||||
|
);
|
||||||
|
if (!credentialsTab) throw new Error('Credentials tab not found');
|
||||||
|
fireEvent.click(credentialsTab);
|
||||||
|
|
||||||
|
const keepAlive = await screen.findByPlaceholderText('25');
|
||||||
|
fireEvent.change(keepAlive, { target: { value: '15-25' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(save).toHaveBeenCalled());
|
||||||
|
expect((save.mock.calls[0][0] as Record<string, unknown>).keepAlive).toBe('15-25');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves an unchanged legacy numeric value outside the new range', async () => {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
const save = vi.fn().mockResolvedValue({ success: true });
|
||||||
|
renderWithProviders(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ClientFormModal
|
||||||
|
open
|
||||||
|
mode="edit"
|
||||||
|
client={{ ...baseClient, keepAlive: 70000 } as unknown as ClientRecord}
|
||||||
|
inbounds={[awgInbound]}
|
||||||
|
attachedIds={[17]}
|
||||||
|
save={save}
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
const credentialsTab = Array.from(document.querySelectorAll('.ant-tabs-tab')).find(
|
||||||
|
(tab) => tab.textContent?.trim() === 'Credentials',
|
||||||
|
);
|
||||||
|
if (!credentialsTab) throw new Error('Credentials tab not found');
|
||||||
|
fireEvent.click(credentialsTab);
|
||||||
|
await screen.findByPlaceholderText('25');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(save).toHaveBeenCalled());
|
||||||
|
expect(Object.hasOwn(save.mock.calls[0][0] as object, 'keepAlive')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects out-of-bounds ranges before saving', async () => {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
const save = vi.fn().mockResolvedValue({ success: true });
|
||||||
|
renderWithProviders(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ClientFormModal
|
||||||
|
open
|
||||||
|
mode="edit"
|
||||||
|
client={baseClient}
|
||||||
|
inbounds={[awgInbound]}
|
||||||
|
attachedIds={[17]}
|
||||||
|
save={save}
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
const credentialsTab = Array.from(document.querySelectorAll('.ant-tabs-tab')).find(
|
||||||
|
(tab) => tab.textContent?.trim() === 'Credentials',
|
||||||
|
);
|
||||||
|
if (!credentialsTab) throw new Error('Credentials tab not found');
|
||||||
|
fireEvent.click(credentialsTab);
|
||||||
|
|
||||||
|
const keepAlive = await screen.findByPlaceholderText('25');
|
||||||
|
fireEvent.change(keepAlive, { target: { value: '15-65536' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(document.querySelector('.ant-form-item-explain-error')?.textContent).toContain(
|
||||||
|
'Enter a whole number or range from 0 to 65535.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits a cleared AWG value so editing another field preserves the stored value', async () => {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
const save = vi.fn().mockResolvedValue({ success: true });
|
||||||
|
renderWithProviders(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ClientFormModal
|
||||||
|
open
|
||||||
|
mode="edit"
|
||||||
|
client={baseClient}
|
||||||
|
inbounds={[awgInbound]}
|
||||||
|
attachedIds={[17]}
|
||||||
|
save={save}
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
const credentialsTab = Array.from(document.querySelectorAll('.ant-tabs-tab')).find(
|
||||||
|
(tab) => tab.textContent?.trim() === 'Credentials',
|
||||||
|
);
|
||||||
|
if (!credentialsTab) throw new Error('Credentials tab not found');
|
||||||
|
fireEvent.click(credentialsTab);
|
||||||
|
const keepAlive = await screen.findByPlaceholderText('25');
|
||||||
|
fireEvent.change(keepAlive, { target: { value: '' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(save).toHaveBeenCalled());
|
||||||
|
expect(Object.hasOwn(save.mock.calls[0][0] as object, 'keepAlive')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||||
|
)
|
||||||
|
|
||||||
|
type legacyKeepAliveClient struct {
|
||||||
|
Id int `gorm:"column:id;primaryKey"`
|
||||||
|
Email string `gorm:"column:email"`
|
||||||
|
KeepAlive int `gorm:"column:wg_keep_alive"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (legacyKeepAliveClient) TableName() string { return "clients" }
|
||||||
|
|
||||||
|
func TestClientKeepAliveSQLiteMigrationPreservesLegacyScalarsAndAddsRanges(t *testing.T) {
|
||||||
|
dbDir := t.TempDir()
|
||||||
|
dbPath := filepath.Join(dbDir, "x-ui.db")
|
||||||
|
legacy, err := sql.Open("sqlite3", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, statement := range []string{
|
||||||
|
"CREATE TABLE clients (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, wg_keep_alive INTEGER DEFAULT 0)",
|
||||||
|
"INSERT INTO clients (email, wg_keep_alive) VALUES ('legacy-25', 25), ('legacy-large', 70000), ('legacy-off', 0), ('legacy-negative', -5)",
|
||||||
|
"CREATE TABLE inbounds (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, port INTEGER NOT NULL, protocol TEXT, settings TEXT)",
|
||||||
|
`INSERT INTO inbounds (id, user_id, port, protocol, settings) VALUES (41, 1, 51888, 'amneziawg', '{"clients":[{"email":"legacy-negative-inbound","keepAlive":-5}]}')`,
|
||||||
|
"CREATE TABLE client_inbounds (client_id INTEGER NOT NULL, inbound_id INTEGER NOT NULL, flow_override TEXT, created_at INTEGER, PRIMARY KEY(client_id, inbound_id))",
|
||||||
|
"INSERT INTO client_inbounds (client_id, inbound_id, flow_override, created_at) VALUES (1, 41, '', 1234)",
|
||||||
|
"CREATE TABLE client_traffics (id INTEGER PRIMARY KEY AUTOINCREMENT, inbound_id INTEGER, email TEXT, up INTEGER, down INTEGER)",
|
||||||
|
"INSERT INTO client_traffics (inbound_id, email, up, down) VALUES (41, 'legacy-25', 1200, 3400)",
|
||||||
|
} {
|
||||||
|
if _, err := legacy.Exec(statement); err != nil {
|
||||||
|
t.Fatalf("%s: %v", statement, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := legacy.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||||
|
t.Setenv("XUI_DB_TYPE", "sqlite")
|
||||||
|
if err := InitDB(dbPath); err != nil {
|
||||||
|
t.Fatalf("InitDB legacy schema: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = CloseDB() })
|
||||||
|
|
||||||
|
var dataType string
|
||||||
|
if err := GetDB().Raw("SELECT type FROM pragma_table_info('clients') WHERE name = 'wg_keep_alive'").Scan(&dataType).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(dataType, "INTEGER") {
|
||||||
|
t.Fatalf("wg_keep_alive type = %q, want INTEGER", dataType)
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
email string
|
||||||
|
want model.KeepAliveValue
|
||||||
|
}{
|
||||||
|
{email: "legacy-25", want: "25"},
|
||||||
|
{email: "legacy-large", want: "70000"},
|
||||||
|
{email: "legacy-off", want: ""},
|
||||||
|
{email: "legacy-negative", want: "-5"},
|
||||||
|
} {
|
||||||
|
var record model.ClientRecord
|
||||||
|
if err := GetDB().Where("email = ?", tc.email).First(&record).Error; err != nil {
|
||||||
|
t.Fatalf("read %s: %v", tc.email, err)
|
||||||
|
}
|
||||||
|
if tc.email == "legacy-25" && record.Id != 1 {
|
||||||
|
t.Fatalf("legacy client ID = %d, want preserved ID 1", record.Id)
|
||||||
|
}
|
||||||
|
if record.KeepAlive != tc.want {
|
||||||
|
t.Fatalf("%s keepAlive = %q, want %q", tc.email, record.KeepAlive, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
email string
|
||||||
|
want int
|
||||||
|
}{{email: "legacy-25", want: 25}, {email: "legacy-large", want: 70000}, {email: "legacy-off", want: 0}} {
|
||||||
|
var legacyClient legacyKeepAliveClient
|
||||||
|
if err := GetDB().Where("email = ?", tc.email).First(&legacyClient).Error; err != nil {
|
||||||
|
t.Fatalf("legacy int model cannot read %s after migration: %v", tc.email, err)
|
||||||
|
}
|
||||||
|
if legacyClient.KeepAlive != tc.want {
|
||||||
|
t.Fatalf("legacy int model read %s keepAlive = %d, want %d", tc.email, legacyClient.KeepAlive, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := GetDB().Create(&model.ClientRecord{Email: "new-unset"}).Error; err != nil {
|
||||||
|
t.Fatalf("create new unset client: %v", err)
|
||||||
|
}
|
||||||
|
var legacyUnset legacyKeepAliveClient
|
||||||
|
if err := GetDB().Where("email = ?", "new-unset").First(&legacyUnset).Error; err != nil || legacyUnset.KeepAlive != 0 {
|
||||||
|
t.Fatalf("legacy int model read of new unset keepAlive = %d, %v; want zero", legacyUnset.KeepAlive, err)
|
||||||
|
}
|
||||||
|
if err := GetDB().Create(&legacyKeepAliveClient{Email: "old-panel-write", KeepAlive: 45}).Error; err != nil {
|
||||||
|
t.Fatalf("legacy int model cannot write after migration: %v", err)
|
||||||
|
}
|
||||||
|
var currentWrite model.ClientRecord
|
||||||
|
if err := GetDB().Where("email = ?", "old-panel-write").First(¤tWrite).Error; err != nil || currentWrite.KeepAlive != "45" {
|
||||||
|
t.Fatalf("new model read of legacy write = %q, %v; want 45", currentWrite.KeepAlive, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
zero := &model.ClientRecord{Email: "explicit-zero", KeepAlive: "0"}
|
||||||
|
if err := GetDB().Create(zero).Error; err != nil {
|
||||||
|
t.Fatalf("save explicit zero: %v", err)
|
||||||
|
}
|
||||||
|
var storageType string
|
||||||
|
if err := GetDB().Raw("SELECT typeof(wg_keep_alive) FROM clients WHERE email = ?", zero.Email).Scan(&storageType).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if storageType != "integer" {
|
||||||
|
t.Fatalf("explicit zero SQLite storage class = %q, want integer", storageType)
|
||||||
|
}
|
||||||
|
var rangeValue string
|
||||||
|
if err := GetDB().Raw("SELECT wg_keep_alive_range FROM clients WHERE email = ?", zero.Email).Scan(&rangeValue).Error; err != nil || rangeValue != "0" {
|
||||||
|
t.Fatalf("explicit zero range marker = %q, %v; want 0", rangeValue, err)
|
||||||
|
}
|
||||||
|
if err := GetDB().Model(&legacyKeepAliveClient{}).Where("email = ?", zero.Email).Update("wg_keep_alive", 45).Error; err != nil {
|
||||||
|
t.Fatalf("old int model cannot update a new row: %v", err)
|
||||||
|
}
|
||||||
|
var updatedOldRow model.ClientRecord
|
||||||
|
if err := GetDB().Where("email = ?", zero.Email).First(&updatedOldRow).Error; err != nil || updatedOldRow.KeepAlive != "45" {
|
||||||
|
t.Fatalf("new model read after old update = %q, %v; want 45", updatedOldRow.KeepAlive, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
inbound := &model.Inbound{
|
||||||
|
UserId: 1, Port: 51888, Protocol: model.AmneziaWG, Tag: "legacy-awg-range",
|
||||||
|
Settings: "{\"server\":{\"privateKey\":\"server\",\"publicKey\":\"pub\"},\"clients\":[{\"email\":\"legacy-range\",\"keepAlive\":\"15-25\"}]}",
|
||||||
|
}
|
||||||
|
if err := GetDB().Create(inbound).Error; err != nil {
|
||||||
|
t.Fatalf("save ranged inbound: %v", err)
|
||||||
|
}
|
||||||
|
if err := GetDB().Create(&model.ClientRecord{Email: "range-record", KeepAlive: "15-25"}).Error; err != nil {
|
||||||
|
t.Fatalf("save ranged client record: %v", err)
|
||||||
|
}
|
||||||
|
var rangeRecord model.ClientRecord
|
||||||
|
if err := GetDB().Where("email = ?", "range-record").First(&rangeRecord).Error; err != nil || rangeRecord.KeepAlive != "15-25" {
|
||||||
|
t.Fatalf("read ranged client record = %q, %v; want 15-25", rangeRecord.KeepAlive, err)
|
||||||
|
}
|
||||||
|
if err := CloseDB(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := InitDB(dbPath); err != nil {
|
||||||
|
t.Fatalf("reopen migrated database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var link model.ClientInbound
|
||||||
|
if err := GetDB().Where("client_id = ? AND inbound_id = ?", 1, 41).First(&link).Error; err != nil {
|
||||||
|
t.Fatalf("client-inbound link was not preserved: %v", err)
|
||||||
|
}
|
||||||
|
if link.ClientId != 1 || link.InboundId != 41 || link.FlowOverride != "" || link.CreatedAt != 1234 {
|
||||||
|
t.Fatalf("client-inbound link changed during migration: %#v", link)
|
||||||
|
}
|
||||||
|
var traffic xray.ClientTraffic
|
||||||
|
if err := GetDB().Where("inbound_id = ? AND email = ?", 41, "legacy-25").First(&traffic).Error; err != nil {
|
||||||
|
t.Fatalf("client traffic was not preserved: %v", err)
|
||||||
|
}
|
||||||
|
if traffic.Id != 1 || traffic.Up != 1200 || traffic.Down != 3400 {
|
||||||
|
t.Fatalf("client traffic changed during migration: %#v", traffic)
|
||||||
|
}
|
||||||
|
var got model.ClientRecord
|
||||||
|
if err := GetDB().Where("email = ?", "explicit-zero").First(&got).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.KeepAlive != "45" {
|
||||||
|
t.Fatalf("reopened explicit zero after old update = %q, want 45", got.KeepAlive)
|
||||||
|
}
|
||||||
|
var reopened model.Inbound
|
||||||
|
if err := GetDB().First(&reopened, inbound.Id).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Clients []model.Client `json:"clients"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(reopened.Settings), &payload); err != nil {
|
||||||
|
t.Fatalf("read range from inbound settings after reopen: %v", err)
|
||||||
|
}
|
||||||
|
if len(payload.Clients) != 1 || payload.Clients[0].KeepAliveText() != "15-25" {
|
||||||
|
t.Fatalf("reopened inbound keepAlive = %#v, want 15-25", payload.Clients)
|
||||||
|
}
|
||||||
|
var legacyInbound model.Inbound
|
||||||
|
if err := GetDB().First(&legacyInbound, 41).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(legacyInbound.Settings), &payload); err != nil || len(payload.Clients) != 1 || payload.Clients[0].KeepAliveText() != "-5" {
|
||||||
|
t.Fatalf("legacy negative inbound keepAlive = %#v, %v; want -5", payload.Clients, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClientKeepAlivePostgresMigrationPreservesLegacyScalars(t *testing.T) {
|
||||||
|
dsn := strings.TrimSpace(os.Getenv("XUI_TEST_PG_DSN"))
|
||||||
|
if dsn == "" {
|
||||||
|
dsn = strings.TrimSpace(os.Getenv("XUI_DB_DSN"))
|
||||||
|
}
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("set XUI_TEST_PG_DSN or XUI_DB_DSN to run the PostgreSQL keepAlive migration test")
|
||||||
|
}
|
||||||
|
admin, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open PostgreSQL admin connection: %v", err)
|
||||||
|
}
|
||||||
|
adminHandle, err := admin.DB()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = adminHandle.Close() })
|
||||||
|
|
||||||
|
schema := fmt.Sprintf("keepalive_%d", time.Now().UnixNano())
|
||||||
|
if err := admin.Exec("CREATE SCHEMA " + schema).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = CloseDB()
|
||||||
|
if err := admin.Exec("DROP SCHEMA " + schema + " CASCADE").Error; err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
scoped := dsn + " search_path=" + schema
|
||||||
|
if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") {
|
||||||
|
parsed, err := url.Parse(dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
query := parsed.Query()
|
||||||
|
query.Set("search_path", schema)
|
||||||
|
parsed.RawQuery = query.Encode()
|
||||||
|
scoped = parsed.String()
|
||||||
|
}
|
||||||
|
t.Setenv("XUI_DB_TYPE", "postgres")
|
||||||
|
t.Setenv("XUI_DB_DSN", scoped)
|
||||||
|
|
||||||
|
legacy, err := gorm.Open(postgres.Open(scoped), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open legacy schema: %v", err)
|
||||||
|
}
|
||||||
|
if err := legacy.Exec("CREATE TABLE clients (id BIGSERIAL PRIMARY KEY, email TEXT NOT NULL, wg_keep_alive BIGINT DEFAULT 0)").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := legacy.Exec("INSERT INTO clients (email, wg_keep_alive) VALUES ('legacy-25', 25), ('legacy-large', 70000), ('legacy-off', 0)").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
legacyHandle, err := legacy.DB()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := legacyHandle.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := InitDB(""); err != nil {
|
||||||
|
t.Fatalf("InitDB legacy schema: %v", err)
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
email string
|
||||||
|
want model.KeepAliveValue
|
||||||
|
}{
|
||||||
|
{email: "legacy-25", want: "25"},
|
||||||
|
{email: "legacy-large", want: "70000"},
|
||||||
|
{email: "legacy-off", want: ""},
|
||||||
|
} {
|
||||||
|
var record model.ClientRecord
|
||||||
|
if err := GetDB().Where("email = ?", tc.email).First(&record).Error; err != nil {
|
||||||
|
t.Fatalf("read %s: %v", tc.email, err)
|
||||||
|
}
|
||||||
|
if record.KeepAlive != tc.want {
|
||||||
|
t.Fatalf("%s keepAlive = %q, want %q", tc.email, record.KeepAlive, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
email string
|
||||||
|
want int
|
||||||
|
}{{"legacy-25", 25}, {"legacy-large", 70000}, {"legacy-off", 0}} {
|
||||||
|
var old legacyKeepAliveClient
|
||||||
|
if err := GetDB().Where("email = ?", tc.email).First(&old).Error; err != nil || old.KeepAlive != tc.want {
|
||||||
|
t.Fatalf("old int model read %s = %d, %v; want %d", tc.email, old.KeepAlive, err, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := GetDB().Create(&model.ClientRecord{Email: "new-unset"}).Error; err != nil {
|
||||||
|
t.Fatalf("create new unset client: %v", err)
|
||||||
|
}
|
||||||
|
var oldUnset legacyKeepAliveClient
|
||||||
|
if err := GetDB().Where("email = ?", "new-unset").First(&oldUnset).Error; err != nil || oldUnset.KeepAlive != 0 {
|
||||||
|
t.Fatalf("old int model read of new unset = %d, %v; want 0", oldUnset.KeepAlive, err)
|
||||||
|
}
|
||||||
|
if err := GetDB().Create(&legacyKeepAliveClient{Email: "old-panel-write", KeepAlive: 45}).Error; err != nil {
|
||||||
|
t.Fatalf("old int model cannot write after migration: %v", err)
|
||||||
|
}
|
||||||
|
var current model.ClientRecord
|
||||||
|
if err := GetDB().Where("email = ?", "old-panel-write").First(¤t).Error; err != nil || current.KeepAlive != "45" {
|
||||||
|
t.Fatalf("current model read of old write = %q, %v; want 45", current.KeepAlive, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
zero := &model.ClientRecord{Email: "explicit-zero", KeepAlive: "0"}
|
||||||
|
if err := GetDB().Create(zero).Error; err != nil {
|
||||||
|
t.Fatalf("save explicit zero: %v", err)
|
||||||
|
}
|
||||||
|
if err := GetDB().Model(&legacyKeepAliveClient{}).Where("email = ?", zero.Email).Update("wg_keep_alive", 45).Error; err != nil {
|
||||||
|
t.Fatalf("old int model cannot update a new row: %v", err)
|
||||||
|
}
|
||||||
|
var updatedOldRow model.ClientRecord
|
||||||
|
if err := GetDB().Where("email = ?", zero.Email).First(&updatedOldRow).Error; err != nil || updatedOldRow.KeepAlive != "45" {
|
||||||
|
t.Fatalf("new model read after old update = %q, %v; want 45", updatedOldRow.KeepAlive, err)
|
||||||
|
}
|
||||||
|
if err := GetDB().Create(&model.ClientRecord{Email: "range-record", KeepAlive: "15-25"}).Error; err != nil {
|
||||||
|
t.Fatalf("save ranged client record: %v", err)
|
||||||
|
}
|
||||||
|
var rangeRecord model.ClientRecord
|
||||||
|
if err := GetDB().Where("email = ?", "range-record").First(&rangeRecord).Error; err != nil || rangeRecord.KeepAlive != "15-25" {
|
||||||
|
t.Fatalf("read ranged client record = %q, %v; want 15-25", rangeRecord.KeepAlive, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
inbound := &model.Inbound{
|
||||||
|
UserId: 1, Port: 51889, Protocol: model.AmneziaWG, Tag: "legacy-awg-range-pg",
|
||||||
|
Settings: "{\"server\":{\"privateKey\":\"server\",\"publicKey\":\"pub\"},\"clients\":[{\"email\":\"legacy-range\",\"keepAlive\":\"15-25\"}]}",
|
||||||
|
}
|
||||||
|
if err := GetDB().Create(inbound).Error; err != nil {
|
||||||
|
t.Fatalf("save ranged inbound: %v", err)
|
||||||
|
}
|
||||||
|
if err := CloseDB(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := InitDB(""); err != nil {
|
||||||
|
t.Fatalf("reopen migrated PostgreSQL database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dataType string
|
||||||
|
if err := GetDB().Raw("SELECT data_type FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'clients' AND column_name = 'wg_keep_alive'").Scan(&dataType).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(dataType, "bigint") {
|
||||||
|
t.Fatalf("wg_keep_alive data type = %q, want bigint", dataType)
|
||||||
|
}
|
||||||
|
var got model.ClientRecord
|
||||||
|
if err := GetDB().Where("email = ?", "explicit-zero").First(&got).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.KeepAlive != "45" {
|
||||||
|
t.Fatalf("reopened explicit zero after old update = %q, want 45", got.KeepAlive)
|
||||||
|
}
|
||||||
|
var reopened model.Inbound
|
||||||
|
if err := GetDB().First(&reopened, inbound.Id).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Clients []model.Client `json:"clients"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(reopened.Settings), &payload); err != nil {
|
||||||
|
t.Fatalf("read range from inbound settings after reopen: %v", err)
|
||||||
|
}
|
||||||
|
if len(payload.Clients) != 1 || payload.Clients[0].KeepAliveText() != "15-25" {
|
||||||
|
t.Fatalf("reopened inbound keepAlive = %#v, want 15-25", payload.Clients)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -112,7 +112,18 @@ func migrateInboundExcludeFromSubColumn() error {
|
|||||||
return migrator.AddColumn(&model.Inbound{}, "ExcludeFromSub")
|
return migrator.AddColumn(&model.Inbound{}, "ExcludeFromSub")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func migrateClientKeepAliveRangeColumn() error {
|
||||||
|
migrator := db.Migrator()
|
||||||
|
if !migrator.HasTable(&model.ClientRecord{}) || migrator.HasColumn(&model.ClientRecord{}, "wg_keep_alive_range") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return migrator.AddColumn(&model.ClientRecord{}, "KeepAliveRange")
|
||||||
|
}
|
||||||
|
|
||||||
func initModels() error {
|
func initModels() error {
|
||||||
|
if err := migrateClientKeepAliveRangeColumn(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := migrateClientTrafficLastSubFetchColumn(); err != nil {
|
if err := migrateClientTrafficLastSubFetchColumn(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClientKeepAliveJSONPreservesRangeAndScalarCompatibility(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want any
|
||||||
|
}{
|
||||||
|
{name: "range", input: `{"keepAlive":"15-25"}`, want: "15-25"},
|
||||||
|
{name: "scalar", input: `{"keepAlive":25}`, want: float64(25)},
|
||||||
|
{name: "explicit zero", input: `{"keepAlive":0}`, want: float64(0)},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var client Client
|
||||||
|
if err := json.Unmarshal([]byte(tt.input), &client); err != nil {
|
||||||
|
t.Fatalf("decode keepAlive: %v", err)
|
||||||
|
}
|
||||||
|
blob, err := json.Marshal(client)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode client: %v", err)
|
||||||
|
}
|
||||||
|
var got map[string]any
|
||||||
|
if err := json.Unmarshal(blob, &got); err != nil {
|
||||||
|
t.Fatalf("decode client output: %v", err)
|
||||||
|
}
|
||||||
|
if got["keepAlive"] != tt.want {
|
||||||
|
t.Fatalf("keepAlive JSON = %#v, want %#v", got["keepAlive"], tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var omitted Client
|
||||||
|
blob, err := json.Marshal(omitted)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode omitted client: %v", err)
|
||||||
|
}
|
||||||
|
var got map[string]any
|
||||||
|
if err := json.Unmarshal(blob, &got); err != nil {
|
||||||
|
t.Fatalf("decode omitted output: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := got["keepAlive"]; ok {
|
||||||
|
t.Fatalf("omitted keepAlive serialized as %#v", got["keepAlive"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeepAliveValueValidatesNewInputAndPreservesLegacyScalar(t *testing.T) {
|
||||||
|
for _, value := range []KeepAliveValue{"0", "25", "65535"} {
|
||||||
|
if err := value.Validate(false); err != nil {
|
||||||
|
t.Errorf("scalar %q rejected: %v", value, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := KeepAliveValue("15-25").Validate(true); err != nil {
|
||||||
|
t.Fatalf("AmneziaWG range rejected: %v", err)
|
||||||
|
}
|
||||||
|
if err := KeepAliveValue("15-25").Validate(false); err == nil || err.Error() != "keepAlive ranges are supported only for AmneziaWG clients" {
|
||||||
|
t.Fatalf("WireGuard range error = %v", err)
|
||||||
|
}
|
||||||
|
for _, value := range []KeepAliveValue{"25-15", "0-65536", "65536", "01-2"} {
|
||||||
|
if err := value.Validate(true); err == nil {
|
||||||
|
t.Errorf("invalid new value %q accepted", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var legacy KeepAliveValue
|
||||||
|
if err := json.Unmarshal([]byte("70000"), &legacy); err != nil {
|
||||||
|
t.Fatalf("read legacy numeric value: %v", err)
|
||||||
|
}
|
||||||
|
blob, err := json.Marshal(legacy)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal legacy numeric value: %v", err)
|
||||||
|
}
|
||||||
|
if string(blob) != "70000" {
|
||||||
|
t.Fatalf("legacy numeric value = %s, want 70000", blob)
|
||||||
|
}
|
||||||
|
if got := legacy.Seconds(); got != 70000 {
|
||||||
|
t.Fatalf("legacy scalar accessor = %d, want 70000", got)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte("-5"), &legacy); err != nil {
|
||||||
|
t.Fatalf("read legacy negative numeric value: %v", err)
|
||||||
|
}
|
||||||
|
blob, err = json.Marshal(legacy)
|
||||||
|
if err != nil || string(blob) != "-5" {
|
||||||
|
t.Fatalf("legacy negative numeric value = %s, %v; want -5", blob, err)
|
||||||
|
}
|
||||||
|
if err := legacy.Validate(true); err == nil {
|
||||||
|
t.Fatal("new-input validator accepted a legacy value above the panel bound")
|
||||||
|
}
|
||||||
|
if got := legacy.Seconds(); got != -5 {
|
||||||
|
t.Fatalf("legacy negative scalar accessor = %d, want -5", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxKeepAliveValue = 65535
|
||||||
|
|
||||||
|
type KeepAliveValue string
|
||||||
|
|
||||||
|
func (v KeepAliveValue) String() string { return string(v) }
|
||||||
|
|
||||||
|
func (v KeepAliveValue) Seconds() int {
|
||||||
|
if n, err := strconv.Atoi(string(v)); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v KeepAliveValue) MinSeconds() int {
|
||||||
|
parts := strings.SplitN(string(v), "-", 2)
|
||||||
|
n, _ := strconv.Atoi(parts[0])
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v KeepAliveValue) Validate(allowRange bool) error {
|
||||||
|
text := string(v)
|
||||||
|
parts := strings.Split(text, "-")
|
||||||
|
if len(parts) == 1 {
|
||||||
|
n, err := strconv.Atoi(text)
|
||||||
|
if err != nil || strconv.Itoa(n) != text || n < 0 || n > maxKeepAliveValue {
|
||||||
|
return fmt.Errorf("keepAlive must be an integer from 0 to %d", maxKeepAliveValue)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(parts) != 2 || !allowRange {
|
||||||
|
return errors.New("keepAlive ranges are supported only for AmneziaWG clients")
|
||||||
|
}
|
||||||
|
min, errMin := strconv.Atoi(parts[0])
|
||||||
|
max, errMax := strconv.Atoi(parts[1])
|
||||||
|
if errMin != nil || errMax != nil || strconv.Itoa(min) != parts[0] || strconv.Itoa(max) != parts[1] || min < 0 || max > maxKeepAliveValue || min > max {
|
||||||
|
return fmt.Errorf("keepAlive range must be ordered integers from 0 to %d", maxKeepAliveValue)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v KeepAliveValue) Value() (driver.Value, error) {
|
||||||
|
return string(v), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *KeepAliveValue) Scan(src any) error {
|
||||||
|
switch value := src.(type) {
|
||||||
|
case nil:
|
||||||
|
*v = ""
|
||||||
|
case string:
|
||||||
|
*v = KeepAliveValue(value)
|
||||||
|
case []byte:
|
||||||
|
*v = KeepAliveValue(string(value))
|
||||||
|
case int64:
|
||||||
|
if value == 0 {
|
||||||
|
*v = ""
|
||||||
|
} else {
|
||||||
|
*v = KeepAliveValue(strconv.FormatInt(value, 10))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("cannot scan %T into KeepAliveValue", src)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v KeepAliveValue) MarshalJSON() ([]byte, error) {
|
||||||
|
if v == "" {
|
||||||
|
return []byte("0"), nil
|
||||||
|
}
|
||||||
|
if _, err := strconv.ParseInt(string(v), 10, 64); err == nil {
|
||||||
|
return []byte(string(v)), nil
|
||||||
|
}
|
||||||
|
if err := v.Validate(true); err != nil || !strings.Contains(string(v), "-") {
|
||||||
|
return nil, fmt.Errorf("invalid stored keepAlive %q", v)
|
||||||
|
}
|
||||||
|
return json.Marshal(string(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *KeepAliveValue) UnmarshalJSON(data []byte) error {
|
||||||
|
if string(data) == "null" {
|
||||||
|
*v = ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(data) > 0 && data[0] == '"' {
|
||||||
|
var text string
|
||||||
|
if err := json.Unmarshal(data, &text); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
candidate := KeepAliveValue(text)
|
||||||
|
if err := candidate.Validate(true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*v = candidate
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var number json.Number
|
||||||
|
if err := json.Unmarshal(data, &number); err != nil {
|
||||||
|
return errors.New("keepAlive must be a number or an AmneziaWG range string")
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(string(number), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("keepAlive must be an integer")
|
||||||
|
}
|
||||||
|
*v = KeepAliveValue(strconv.FormatInt(n, 10))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -7,8 +7,11 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||||
)
|
)
|
||||||
@@ -944,7 +947,7 @@ type Client struct {
|
|||||||
// before -- fully backward compatible for callers that never set this.
|
// before -- fully backward compatible for callers that never set this.
|
||||||
AllowedIPsByInbound map[int][]string `json:"allowedIPsByInbound,omitempty"`
|
AllowedIPsByInbound map[int][]string `json:"allowedIPsByInbound,omitempty"`
|
||||||
PreSharedKey string `json:"preSharedKey,omitempty"`
|
PreSharedKey string `json:"preSharedKey,omitempty"`
|
||||||
KeepAlive *int `json:"keepAlive,omitempty"` // Seconds between PersistentKeepalive packets; 0 sends none, omit to keep the stored value
|
KeepAlive *KeepAliveValue `json:"keepAlive,omitempty"` // Numeric values or AmneziaWG ranges; 0 disables, omission preserves.
|
||||||
ForwardedPorts string `json:"forwardedPorts,omitempty"` // AmneziaWG per-client port-forwarding spec, e.g. "80,443,8000-8100"
|
ForwardedPorts string `json:"forwardedPorts,omitempty"` // AmneziaWG per-client port-forwarding spec, e.g. "80,443,8000-8100"
|
||||||
Secret string `json:"secret,omitempty" example:"ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d"`
|
Secret string `json:"secret,omitempty" example:"ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d"`
|
||||||
AdTag string `json:"adTag,omitempty" example:"0123456789abcdef0123456789abcdef"`
|
AdTag string `json:"adTag,omitempty" example:"0123456789abcdef0123456789abcdef"`
|
||||||
@@ -969,39 +972,41 @@ type Client struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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"`
|
||||||
PrivateKey string `json:"privateKey" gorm:"column:wg_private_key"`
|
PrivateKey string `json:"privateKey" gorm:"column:wg_private_key"`
|
||||||
PublicKey string `json:"publicKey" gorm:"column:wg_public_key"`
|
PublicKey string `json:"publicKey" gorm:"column:wg_public_key"`
|
||||||
AllowedIPs string `json:"allowedIPs" gorm:"column:wg_allowed_ips"`
|
AllowedIPs string `json:"allowedIPs" gorm:"column:wg_allowed_ips"`
|
||||||
PreSharedKey string `json:"preSharedKey" gorm:"column:wg_pre_shared_key"`
|
PreSharedKey string `json:"preSharedKey" gorm:"column:wg_pre_shared_key"`
|
||||||
KeepAlive int `json:"keepAlive" gorm:"column:wg_keep_alive;default:0"`
|
KeepAlive KeepAliveValue `json:"keepAlive" gorm:"-"` // Numeric values or AmneziaWG ranges.
|
||||||
ForwardedPorts string `json:"forwardedPorts" gorm:"column:wg_forwarded_ports"`
|
KeepAliveLegacy int `json:"-" gorm:"column:wg_keep_alive;default:0"`
|
||||||
Secret string `json:"secret" gorm:"column:secret"`
|
KeepAliveRange KeepAliveValue `json:"-" gorm:"column:wg_keep_alive_range;type:text;default:''"`
|
||||||
AdTag string `json:"adTag" gorm:"column:ad_tag;default:''"`
|
ForwardedPorts string `json:"forwardedPorts" gorm:"column:wg_forwarded_ports"`
|
||||||
LimitIP int `json:"limitIp" gorm:"column:limit_ip"`
|
Secret string `json:"secret" gorm:"column:secret"`
|
||||||
LimitHwid int `json:"limitHwid" gorm:"column:limit_hwid;default:0"`
|
AdTag string `json:"adTag" gorm:"column:ad_tag;default:''"`
|
||||||
TotalGB int64 `json:"totalGB" gorm:"column:total_gb"`
|
LimitIP int `json:"limitIp" gorm:"column:limit_ip"`
|
||||||
ExpiryTime int64 `json:"expiryTime" gorm:"column:expiry_time"`
|
LimitHwid int `json:"limitHwid" gorm:"column:limit_hwid;default:0"`
|
||||||
Enable bool `json:"enable" gorm:"default:true"`
|
TotalGB int64 `json:"totalGB" gorm:"column:total_gb"`
|
||||||
TgID int64 `json:"tgId" gorm:"column:tg_id;index:idx_clients_tg_id"`
|
ExpiryTime int64 `json:"expiryTime" gorm:"column:expiry_time"`
|
||||||
Group string `json:"group" gorm:"column:group_name;default:'';index:idx_client_record_group"`
|
Enable bool `json:"enable" gorm:"default:true"`
|
||||||
Comment string `json:"comment"`
|
TgID int64 `json:"tgId" gorm:"column:tg_id;index:idx_clients_tg_id"`
|
||||||
Reset int `json:"reset" gorm:"default:0"`
|
Group string `json:"group" gorm:"column:group_name;default:'';index:idx_client_record_group"`
|
||||||
ResetDay int `json:"resetDay" gorm:"column:reset_day;default:0"`
|
Comment string `json:"comment"`
|
||||||
ResetWeekday int `json:"resetWeekday" gorm:"column:reset_weekday;default:0"`
|
Reset int `json:"reset" gorm:"default:0"`
|
||||||
ResetMax int `json:"resetMax" gorm:"column:reset_max;default:0"`
|
ResetDay int `json:"resetDay" gorm:"column:reset_day;default:0"`
|
||||||
TrafficReset string `json:"trafficReset" gorm:"column:traffic_reset;default:never;index:idx_clients_traffic_reset"`
|
ResetWeekday int `json:"resetWeekday" gorm:"column:reset_weekday;default:0"`
|
||||||
TrafficResetDay int `json:"trafficResetDay" gorm:"column:traffic_reset_day;default:1"`
|
ResetMax int `json:"resetMax" gorm:"column:reset_max;default:0"`
|
||||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
TrafficReset string `json:"trafficReset" gorm:"column:traffic_reset;default:never;index:idx_clients_traffic_reset"`
|
||||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
|
TrafficResetDay int `json:"trafficResetDay" gorm:"column:traffic_reset_day;default:1"`
|
||||||
|
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
||||||
|
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
|
||||||
// Owned solely by the node-snapshot sweep, which soft-orphans instead of
|
// Owned solely by the node-snapshot sweep, which soft-orphans instead of
|
||||||
// deleting; orphans from any other cause stay at zero and are never reaped.
|
// deleting; orphans from any other cause stay at zero and are never reaped.
|
||||||
SyncOrphanedAt int64 `json:"-" gorm:"column:sync_orphaned_at;default:0"`
|
SyncOrphanedAt int64 `json:"-" gorm:"column:sync_orphaned_at;default:0"`
|
||||||
@@ -1009,6 +1014,47 @@ type ClientRecord struct {
|
|||||||
|
|
||||||
func (ClientRecord) TableName() string { return "clients" }
|
func (ClientRecord) TableName() string { return "clients" }
|
||||||
|
|
||||||
|
func (r *ClientRecord) BeforeSave(*gorm.DB) error {
|
||||||
|
legacy, ranged, err := splitKeepAliveStorage(r.KeepAlive)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.KeepAliveLegacy = legacy
|
||||||
|
r.KeepAliveRange = ranged
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ClientRecord) AfterFind(*gorm.DB) error {
|
||||||
|
if r.KeepAliveLegacy != 0 {
|
||||||
|
r.KeepAlive = KeepAliveValue(strconv.Itoa(r.KeepAliveLegacy))
|
||||||
|
} else if r.KeepAliveRange != "" {
|
||||||
|
r.KeepAlive = r.KeepAliveRange
|
||||||
|
} else {
|
||||||
|
r.KeepAlive = ""
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitKeepAliveStorage(value KeepAliveValue) (int, KeepAliveValue, error) {
|
||||||
|
if value == "" {
|
||||||
|
return 0, "", nil
|
||||||
|
}
|
||||||
|
if n, err := strconv.Atoi(string(value)); err == nil && strconv.Itoa(n) == string(value) {
|
||||||
|
if n == 0 {
|
||||||
|
return 0, "0", nil
|
||||||
|
}
|
||||||
|
return n, "", nil
|
||||||
|
}
|
||||||
|
if err := value.Validate(true); err != nil || !strings.Contains(string(value), "-") {
|
||||||
|
return 0, "", fmt.Errorf("invalid stored keepAlive %q", value)
|
||||||
|
}
|
||||||
|
return 0, value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func KeepAliveStorage(value KeepAliveValue) (int, KeepAliveValue, error) {
|
||||||
|
return splitKeepAliveStorage(value)
|
||||||
|
}
|
||||||
|
|
||||||
type ClientGroup struct {
|
type ClientGroup struct {
|
||||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
Name string `json:"name" gorm:"uniqueIndex;not null"`
|
Name string `json:"name" gorm:"uniqueIndex;not null"`
|
||||||
@@ -1171,25 +1217,39 @@ type Host struct {
|
|||||||
|
|
||||||
func (Host) TableName() string { return "hosts" }
|
func (Host) TableName() string { return "hosts" }
|
||||||
|
|
||||||
// KeepAliveSeconds is the client's PersistentKeepalive, 0 when unset.
|
// KeepAliveSeconds returns a scalar value; ranges are emitted by native AWG config.
|
||||||
func (c Client) KeepAliveSeconds() int {
|
func (c Client) KeepAliveSeconds() int {
|
||||||
if c.KeepAlive == nil {
|
if c.KeepAlive == nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
return *c.KeepAlive
|
return c.KeepAlive.Seconds()
|
||||||
}
|
}
|
||||||
|
|
||||||
// KeepAlivePtr wraps an explicit PersistentKeepalive, 0 included -- distinct
|
func (c Client) KeepAliveText() string {
|
||||||
// from a nil KeepAlive, which means the field was never sent.
|
if c.KeepAlive == nil {
|
||||||
func KeepAlivePtr(v int) *int { return &v }
|
return ""
|
||||||
|
}
|
||||||
|
return c.KeepAlive.String()
|
||||||
|
}
|
||||||
|
|
||||||
// nonZeroKeepAlive keeps a stored 0 nil: wg_keep_alive cannot tell "off" from
|
func KeepAlivePtr(v int) *KeepAliveValue {
|
||||||
// "never set", and an explicit 0 would emit keepAlive on every protocol.
|
value := KeepAliveValue(fmt.Sprintf("%d", v))
|
||||||
func nonZeroKeepAlive(seconds int) *int {
|
return &value
|
||||||
if seconds <= 0 {
|
}
|
||||||
|
|
||||||
|
func keepAlivePtr(value KeepAliveValue) *KeepAliveValue {
|
||||||
|
if value == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return KeepAlivePtr(seconds)
|
v := value
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
func keepAliveValue(value *KeepAliveValue) KeepAliveValue {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *value
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) ToRecord() *ClientRecord {
|
func (c *Client) ToRecord() *ClientRecord {
|
||||||
@@ -1221,7 +1281,7 @@ func (c *Client) ToRecord() *ClientRecord {
|
|||||||
PublicKey: c.PublicKey,
|
PublicKey: c.PublicKey,
|
||||||
AllowedIPs: strings.Join(c.AllowedIPs, ","),
|
AllowedIPs: strings.Join(c.AllowedIPs, ","),
|
||||||
PreSharedKey: c.PreSharedKey,
|
PreSharedKey: c.PreSharedKey,
|
||||||
KeepAlive: c.KeepAliveSeconds(),
|
KeepAlive: keepAliveValue(c.KeepAlive),
|
||||||
ForwardedPorts: c.ForwardedPorts,
|
ForwardedPorts: c.ForwardedPorts,
|
||||||
Secret: c.Secret,
|
Secret: c.Secret,
|
||||||
AdTag: c.AdTag,
|
AdTag: c.AdTag,
|
||||||
@@ -1280,7 +1340,7 @@ func (r *ClientRecord) ToClient() *Client {
|
|||||||
PublicKey: r.PublicKey,
|
PublicKey: r.PublicKey,
|
||||||
AllowedIPs: splitWireguardAllowedIPs(r.AllowedIPs),
|
AllowedIPs: splitWireguardAllowedIPs(r.AllowedIPs),
|
||||||
PreSharedKey: r.PreSharedKey,
|
PreSharedKey: r.PreSharedKey,
|
||||||
KeepAlive: nonZeroKeepAlive(r.KeepAlive),
|
KeepAlive: keepAlivePtr(r.KeepAlive),
|
||||||
ForwardedPorts: r.ForwardedPorts,
|
ForwardedPorts: r.ForwardedPorts,
|
||||||
Secret: r.Secret,
|
Secret: r.Secret,
|
||||||
AdTag: r.AdTag,
|
AdTag: r.AdTag,
|
||||||
@@ -1524,8 +1584,8 @@ func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientM
|
|||||||
existing.AllowedIPs = incoming.AllowedIPs
|
existing.AllowedIPs = incoming.AllowedIPs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if existing.KeepAlive != incoming.KeepAlive && incoming.KeepAlive != 0 {
|
if incoming.KeepAlive != "" && existing.KeepAlive != incoming.KeepAlive {
|
||||||
if incomingNewer || existing.KeepAlive == 0 {
|
if incomingNewer || existing.KeepAlive == "" {
|
||||||
keep("keepAlive", existing.KeepAlive, incoming.KeepAlive, incoming.KeepAlive)
|
keep("keepAlive", existing.KeepAlive, incoming.KeepAlive, incoming.KeepAlive)
|
||||||
existing.KeepAlive = incoming.KeepAlive
|
existing.KeepAlive = incoming.KeepAlive
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ func TestClientToClientOmitsUnsetKeepAlive(t *testing.T) {
|
|||||||
rec := &ClientRecord{Email: "vless@example.test", UUID: "11111111-2222-3333-4444-555555555555", Enable: true}
|
rec := &ClientRecord{Email: "vless@example.test", UUID: "11111111-2222-3333-4444-555555555555", Enable: true}
|
||||||
|
|
||||||
if got := rec.ToClient().KeepAlive; got != nil {
|
if got := rec.ToClient().KeepAlive; got != nil {
|
||||||
t.Fatalf("KeepAlive for a record that never set one = %d, want nil", *got)
|
t.Fatalf("KeepAlive for a record that never set one = %s, want nil", *got)
|
||||||
}
|
}
|
||||||
blob, err := json.Marshal(map[string][]Client{"clients": {*rec.ToClient()}})
|
blob, err := json.Marshal(map[string][]Client{"clients": {*rec.ToClient()}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -652,7 +652,7 @@ func (s *SubClashService) buildWireguardProxy(subReq *SubService, inbound *model
|
|||||||
if client.PreSharedKey != "" {
|
if client.PreSharedKey != "" {
|
||||||
proxy["pre-shared-key"] = client.PreSharedKey
|
proxy["pre-shared-key"] = client.PreSharedKey
|
||||||
}
|
}
|
||||||
if ka := client.KeepAliveSeconds(); ka > 0 {
|
if ka, ok := keepAliveForScalarExport(client); ok {
|
||||||
proxy["persistent-keepalive"] = ka
|
proxy["persistent-keepalive"] = ka
|
||||||
}
|
}
|
||||||
for _, addr := range client.AllowedIPs {
|
for _, addr := range client.AllowedIPs {
|
||||||
@@ -794,7 +794,7 @@ func (s *SubClashService) buildAmneziaWGProxy(subReq *SubService, inbound *model
|
|||||||
if client.PreSharedKey != "" {
|
if client.PreSharedKey != "" {
|
||||||
proxy["pre-shared-key"] = client.PreSharedKey
|
proxy["pre-shared-key"] = client.PreSharedKey
|
||||||
}
|
}
|
||||||
if ka := client.KeepAliveSeconds(); ka > 0 {
|
if ka, ok := keepAliveForScalarExport(client); ok {
|
||||||
proxy["persistent-keepalive"] = ka
|
proxy["persistent-keepalive"] = ka
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||||
@@ -1537,3 +1538,27 @@ func TestMihomoCertFingerprintUsesFirstValidPin(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildAmneziaWGProxyForClashProjectsKeepAliveRangeToLowerBound(t *testing.T) {
|
||||||
|
keepAlive := model.KeepAliveValue("15-25")
|
||||||
|
client := model.Client{Email: "range@awg", PrivateKey: "client-key", KeepAlive: &keepAlive}
|
||||||
|
inbound := &model.Inbound{Protocol: model.AmneziaWG, Listen: "198.51.100.1", Port: 443, Settings: `{"server":{"privateKey":"server","publicKey":"server-pub","subnetIp":"10.8.1.0","subnetCidr":24},"clients":[]}`}
|
||||||
|
svc := NewSubClashService(false, "", false, &SubService{})
|
||||||
|
proxy := svc.buildProxy(svc.SubService, inbound, client, nil, nil)
|
||||||
|
if proxy == nil || proxy["persistent-keepalive"] != 15 {
|
||||||
|
t.Fatalf("Clash persistent-keepalive = %v, want range lower bound 15", proxy["persistent-keepalive"])
|
||||||
|
}
|
||||||
|
blob, err := marshalClashYAML(proxy)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal Clash proxy: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(blob), "persistent-keepalive: 15") {
|
||||||
|
t.Fatalf("Clash YAML did not preserve lower-bound projection:\n%s", blob)
|
||||||
|
}
|
||||||
|
legacyNegative := model.KeepAliveValue("-1")
|
||||||
|
client.KeepAlive = &legacyNegative
|
||||||
|
proxy = svc.buildProxy(svc.SubService, inbound, client, nil, nil)
|
||||||
|
if _, ok := proxy["persistent-keepalive"]; ok {
|
||||||
|
t.Fatalf("Clash export emitted invalid legacy keepalive: %v", proxy["persistent-keepalive"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package sub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func keepAliveForScalarExport(client model.Client) (int, bool) {
|
||||||
|
if client.KeepAlive == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if strings.Contains(client.KeepAliveText(), "-") && client.KeepAlive.Validate(true) == nil {
|
||||||
|
return client.KeepAlive.MinSeconds(), true
|
||||||
|
}
|
||||||
|
value := client.KeepAliveSeconds()
|
||||||
|
return value, value > 0
|
||||||
|
}
|
||||||
@@ -1057,8 +1057,11 @@ func amneziaWGConfigText(server *amneziawg.ServerSettings, client *model.Client,
|
|||||||
lines = append(lines, "PresharedKey = "+client.PreSharedKey)
|
lines = append(lines, "PresharedKey = "+client.PreSharedKey)
|
||||||
}
|
}
|
||||||
lines = append(lines, "AllowedIPs = 0.0.0.0/0, ::/0", fmt.Sprintf("Endpoint = %s:%d", host, port))
|
lines = append(lines, "AllowedIPs = 0.0.0.0/0, ::/0", fmt.Sprintf("Endpoint = %s:%d", host, port))
|
||||||
if ka := client.KeepAliveSeconds(); ka > 0 {
|
if client.KeepAlive != nil {
|
||||||
lines = append(lines, fmt.Sprintf("PersistentKeepalive = %d", ka))
|
keepAlive := client.KeepAliveText()
|
||||||
|
if (strings.Contains(keepAlive, "-") && client.KeepAlive.Validate(true) == nil) || client.KeepAlive.Seconds() > 0 {
|
||||||
|
lines = append(lines, "PersistentKeepalive = "+keepAlive)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A control character would split its line, and the split can re-open
|
// A control character would split its line, and the split can re-open
|
||||||
|
|||||||
@@ -228,6 +228,24 @@ func TestAmneziaWGConfigTextPeerFieldOrder(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("range keepalive is preserved in native config", func(t *testing.T) {
|
||||||
|
keepAlive := model.KeepAliveValue("15-25")
|
||||||
|
client := &model.Client{PrivateKey: "clientPriv", AllowedIPs: []string{"10.8.1.2/32"}, KeepAlive: &keepAlive}
|
||||||
|
conf := amneziaWGConfigText(server, client, "203.0.113.7", 51820, "remark")
|
||||||
|
if !strings.Contains(conf, "PersistentKeepalive = 15-25") {
|
||||||
|
t.Fatalf("native config lost keepalive range:\n%s", conf)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("legacy negative keepalive is omitted", func(t *testing.T) {
|
||||||
|
keepAlive := model.KeepAliveValue("-1")
|
||||||
|
client := &model.Client{PrivateKey: "clientPriv", AllowedIPs: []string{"10.8.1.2/32"}, KeepAlive: &keepAlive}
|
||||||
|
conf := amneziaWGConfigText(server, client, "203.0.113.7", 51820, "remark")
|
||||||
|
if strings.Contains(conf, "PersistentKeepalive") {
|
||||||
|
t.Fatalf("native config emitted invalid legacy keepalive:\n%s", conf)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("no preshared key or keepalive", func(t *testing.T) {
|
t.Run("no preshared key or keepalive", func(t *testing.T) {
|
||||||
client := &model.Client{PrivateKey: "clientPriv", AllowedIPs: []string{"10.8.1.2/32"}}
|
client := &model.Client{PrivateKey: "clientPriv", AllowedIPs: []string{"10.8.1.2/32"}}
|
||||||
conf := amneziaWGConfigText(server, client, "203.0.113.7", 51820, "remark")
|
conf := amneziaWGConfigText(server, client, "203.0.113.7", 51820, "remark")
|
||||||
@@ -364,7 +382,7 @@ func TestGetSubs_PreservesPerInboundAmneziaWGIdentity(t *testing.T) {
|
|||||||
rec := &model.ClientRecord{
|
rec := &model.ClientRecord{
|
||||||
Email: email, SubID: subID, Enable: true,
|
Email: email, SubID: subID, Enable: true,
|
||||||
PrivateKey: mergedPriv, AllowedIPs: mergedAddr,
|
PrivateKey: mergedPriv, AllowedIPs: mergedAddr,
|
||||||
PreSharedKey: "sharedpsk", KeepAlive: 25,
|
PreSharedKey: "sharedpsk", KeepAlive: model.KeepAliveValue("25"),
|
||||||
}
|
}
|
||||||
if err := db.Create(rec).Error; err != nil {
|
if err := db.Create(rec).Error; err != nil {
|
||||||
t.Fatalf("create client: %v", err)
|
t.Fatalf("create client: %v", err)
|
||||||
@@ -452,7 +470,7 @@ func TestGetSubs_AmneziaWGUnavailableSettingsEmitNoSharedConfig(t *testing.T) {
|
|||||||
rec := &model.ClientRecord{
|
rec := &model.ClientRecord{
|
||||||
Email: email, SubID: subID, Enable: true,
|
Email: email, SubID: subID, Enable: true,
|
||||||
PrivateKey: mergedPriv, AllowedIPs: mergedAddr,
|
PrivateKey: mergedPriv, AllowedIPs: mergedAddr,
|
||||||
PreSharedKey: "sharedpsk", KeepAlive: 25,
|
PreSharedKey: "sharedpsk", KeepAlive: model.KeepAliveValue("25"),
|
||||||
}
|
}
|
||||||
if err := db.Create(rec).Error; err != nil {
|
if err := db.Create(rec).Error; err != nil {
|
||||||
t.Fatalf("create client: %v", err)
|
t.Fatalf("create client: %v", err)
|
||||||
@@ -515,7 +533,7 @@ func TestGetSubs_AmneziaWGEmptyOptionalTunnelFieldsDoNotInheritShared(t *testing
|
|||||||
rec := &model.ClientRecord{
|
rec := &model.ClientRecord{
|
||||||
Email: email, SubID: subID, Enable: true,
|
Email: email, SubID: subID, Enable: true,
|
||||||
PrivateKey: mergedPriv, AllowedIPs: mergedAddr,
|
PrivateKey: mergedPriv, AllowedIPs: mergedAddr,
|
||||||
PreSharedKey: "sharedpsk", KeepAlive: 25,
|
PreSharedKey: "sharedpsk", KeepAlive: model.KeepAliveValue("25"),
|
||||||
}
|
}
|
||||||
if err := db.Create(rec).Error; err != nil {
|
if err := db.Create(rec).Error; err != nil {
|
||||||
t.Fatalf("create client: %v", err)
|
t.Fatalf("create client: %v", err)
|
||||||
@@ -580,7 +598,7 @@ func TestMatchingClients_TunnelMetadataStaysNormalized(t *testing.T) {
|
|||||||
Email: email, SubID: subID, UUID: freshID, Enable: true,
|
Email: email, SubID: subID, UUID: freshID, Enable: true,
|
||||||
TotalGB: 5, ExpiryTime: expiry, Comment: "vip", LimitIP: 3,
|
TotalGB: 5, ExpiryTime: expiry, Comment: "vip", LimitIP: 3,
|
||||||
PrivateKey: sharedKey, PublicKey: "shared-pub", AllowedIPs: "10.9.9.9/32",
|
PrivateKey: sharedKey, PublicKey: "shared-pub", AllowedIPs: "10.9.9.9/32",
|
||||||
PreSharedKey: "shared-psk", KeepAlive: 99,
|
PreSharedKey: "shared-psk", KeepAlive: model.KeepAliveValue("99"),
|
||||||
}
|
}
|
||||||
other := &model.ClientRecord{
|
other := &model.ClientRecord{
|
||||||
Email: "other-sub@awg", SubID: "other-sub", UUID: "22222222-2222-4333-8444-555555555555", Enable: true,
|
Email: "other-sub@awg", SubID: "other-sub", UUID: "22222222-2222-4333-8444-555555555555", Enable: true,
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ func TestGetSubs_PreservesPerInboundWireGuardIdentity(t *testing.T) {
|
|||||||
rec := &model.ClientRecord{
|
rec := &model.ClientRecord{
|
||||||
Email: email, SubID: subID, Enable: true,
|
Email: email, SubID: subID, Enable: true,
|
||||||
PrivateKey: mergedPriv, AllowedIPs: mergedAddr,
|
PrivateKey: mergedPriv, AllowedIPs: mergedAddr,
|
||||||
PreSharedKey: "sharedpsk", KeepAlive: 25,
|
PreSharedKey: "sharedpsk", KeepAlive: model.KeepAliveValue("25"),
|
||||||
}
|
}
|
||||||
if err := db.Create(rec).Error; err != nil {
|
if err := db.Create(rec).Error; err != nil {
|
||||||
t.Fatalf("create client: %v", err)
|
t.Fatalf("create client: %v", err)
|
||||||
@@ -298,7 +298,7 @@ func TestGetSubs_WireGuardUnavailableSettingsEmitNoSharedConfig(t *testing.T) {
|
|||||||
rec := &model.ClientRecord{
|
rec := &model.ClientRecord{
|
||||||
Email: email, SubID: subID, Enable: true,
|
Email: email, SubID: subID, Enable: true,
|
||||||
PrivateKey: mergedPriv, AllowedIPs: mergedAddr,
|
PrivateKey: mergedPriv, AllowedIPs: mergedAddr,
|
||||||
PreSharedKey: "sharedpsk", KeepAlive: 25,
|
PreSharedKey: "sharedpsk", KeepAlive: model.KeepAliveValue("25"),
|
||||||
}
|
}
|
||||||
if err := db.Create(rec).Error; err != nil {
|
if err := db.Create(rec).Error; err != nil {
|
||||||
t.Fatalf("create client: %v", err)
|
t.Fatalf("create client: %v", err)
|
||||||
@@ -358,7 +358,7 @@ func TestGetSubs_WireGuardEmptyOptionalTunnelFieldsDoNotInheritShared(t *testing
|
|||||||
rec := &model.ClientRecord{
|
rec := &model.ClientRecord{
|
||||||
Email: email, SubID: subID, Enable: true,
|
Email: email, SubID: subID, Enable: true,
|
||||||
PrivateKey: mergedPriv, AllowedIPs: "10.9.9.9/32,fd00:9::9/128",
|
PrivateKey: mergedPriv, AllowedIPs: "10.9.9.9/32,fd00:9::9/128",
|
||||||
PreSharedKey: "sharedpsk", KeepAlive: 25,
|
PreSharedKey: "sharedpsk", KeepAlive: model.KeepAliveValue("25"),
|
||||||
}
|
}
|
||||||
if err := db.Create(rec).Error; err != nil {
|
if err := db.Create(rec).Error; err != nil {
|
||||||
t.Fatalf("create client: %v", err)
|
t.Fatalf("create client: %v", err)
|
||||||
|
|||||||
@@ -432,6 +432,13 @@ func markInboundNodesDirty(inboundIds []int) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *ClientService) fillProtocolDefaults(c *model.Client, ib *model.Inbound) error {
|
func (s *ClientService) fillProtocolDefaults(c *model.Client, ib *model.Inbound) error {
|
||||||
|
return s.fillProtocolDefaultsAgainstExisting(c, ib, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ClientService) fillProtocolDefaultsAgainstExisting(c *model.Client, ib *model.Inbound, existing []model.Client, identityEmail ...string) error {
|
||||||
|
if err := validateClientKeepAliveAgainstExisting(ib.Protocol, *c, existing, identityEmail...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
switch ib.Protocol {
|
switch ib.Protocol {
|
||||||
case model.VMESS, model.VLESS:
|
case model.VMESS, model.VLESS:
|
||||||
if c.ID == "" {
|
if c.ID == "" {
|
||||||
@@ -688,6 +695,23 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
|
|||||||
if err := validateClientTrafficReset(updated.TrafficReset, updated.TrafficResetDay); err != nil {
|
if err := validateClientTrafficReset(updated.TrafficReset, updated.TrafficResetDay); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
existingClientsByInbound := make(map[int][]model.Client, len(attachedIds))
|
||||||
|
if updated.KeepAlive != nil {
|
||||||
|
for _, inboundID := range attachedIds {
|
||||||
|
inbound, getErr := inboundSvc.GetInbound(inboundID)
|
||||||
|
if getErr != nil {
|
||||||
|
return false, fmt.Errorf("inbound %d: %w", inboundID, getErr)
|
||||||
|
}
|
||||||
|
clients, getErr := inboundSvc.GetClients(inbound)
|
||||||
|
if getErr != nil {
|
||||||
|
return false, fmt.Errorf("inbound %d: %w", inboundID, getErr)
|
||||||
|
}
|
||||||
|
existingClientsByInbound[inboundID] = clients
|
||||||
|
if err := validateClientKeepAliveAgainstExisting(inbound.Protocol, updated, clients, existing.Email); err != nil {
|
||||||
|
return false, fmt.Errorf("inbound %d: %w", inboundID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
normalizeClientTrafficReset(&updated)
|
normalizeClientTrafficReset(&updated)
|
||||||
if updated.SubID == "" {
|
if updated.SubID == "" {
|
||||||
updated.SubID = existing.SubID
|
updated.SubID = existing.SubID
|
||||||
@@ -765,7 +789,7 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
|
|||||||
if existing.Email == "" {
|
if existing.Email == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := s.fillProtocolDefaults(&updated, inbound); err != nil {
|
if err := s.fillProtocolDefaultsAgainstExisting(&updated, inbound, existingClientsByInbound[ibId], existing.Email); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
clientForInbound := updated
|
clientForInbound := updated
|
||||||
@@ -822,32 +846,37 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
|
|||||||
if len(inboundIds) == 0 {
|
if len(inboundIds) == 0 {
|
||||||
merged := *existing
|
merged := *existing
|
||||||
applyClientRecordMerge(&merged, updated.ToRecord())
|
applyClientRecordMerge(&merged, updated.ToRecord())
|
||||||
|
keepAliveLegacy, keepAliveRange, keepAliveErr := model.KeepAliveStorage(merged.KeepAlive)
|
||||||
|
if keepAliveErr != nil {
|
||||||
|
return needRestart, keepAliveErr
|
||||||
|
}
|
||||||
if err := database.GetDB().Model(&model.ClientRecord{}).
|
if err := database.GetDB().Model(&model.ClientRecord{}).
|
||||||
Where("id = ?", id).
|
Where("id = ?", id).
|
||||||
Updates(map[string]any{
|
Updates(map[string]any{
|
||||||
"sub_id": merged.SubID,
|
"sub_id": merged.SubID,
|
||||||
"uuid": merged.UUID,
|
"uuid": merged.UUID,
|
||||||
"password": merged.Password,
|
"password": merged.Password,
|
||||||
"auth": merged.Auth,
|
"auth": merged.Auth,
|
||||||
"secret": merged.Secret,
|
"secret": merged.Secret,
|
||||||
"flow": merged.Flow,
|
"flow": merged.Flow,
|
||||||
"security": merged.Security,
|
"security": merged.Security,
|
||||||
"wg_private_key": merged.PrivateKey,
|
"wg_private_key": merged.PrivateKey,
|
||||||
"wg_public_key": merged.PublicKey,
|
"wg_public_key": merged.PublicKey,
|
||||||
"wg_allowed_ips": merged.AllowedIPs,
|
"wg_allowed_ips": merged.AllowedIPs,
|
||||||
"wg_pre_shared_key": merged.PreSharedKey,
|
"wg_pre_shared_key": merged.PreSharedKey,
|
||||||
"wg_keep_alive": merged.KeepAlive,
|
"wg_keep_alive": keepAliveLegacy,
|
||||||
"limit_ip": merged.LimitIP,
|
"wg_keep_alive_range": keepAliveRange,
|
||||||
"total_gb": merged.TotalGB,
|
"limit_ip": merged.LimitIP,
|
||||||
"expiry_time": merged.ExpiryTime,
|
"total_gb": merged.TotalGB,
|
||||||
"tg_id": merged.TgID,
|
"expiry_time": merged.ExpiryTime,
|
||||||
"comment": merged.Comment,
|
"tg_id": merged.TgID,
|
||||||
"reset": merged.Reset,
|
"comment": merged.Comment,
|
||||||
"reset_day": merged.ResetDay,
|
"reset": merged.Reset,
|
||||||
"reset_weekday": merged.ResetWeekday,
|
"reset_day": merged.ResetDay,
|
||||||
"reset_max": merged.ResetMax,
|
"reset_weekday": merged.ResetWeekday,
|
||||||
"traffic_reset": merged.TrafficReset,
|
"reset_max": merged.ResetMax,
|
||||||
"traffic_reset_day": merged.TrafficResetDay,
|
"traffic_reset": merged.TrafficReset,
|
||||||
|
"traffic_reset_day": merged.TrafficResetDay,
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
return needRestart, err
|
return needRestart, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -334,6 +334,17 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
|
|||||||
if err := validateClientsRenewal(clients); err != nil {
|
if err := validateClientsRenewal(clients); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
inbound, err := inboundSvc.GetInbound(data.Id)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
existingClients, err := inboundSvc.GetClients(inbound)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if err := validateClientsKeepAliveAgainstExisting(inbound.Protocol, clients, existingClients); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
var settings map[string]any
|
var settings map[string]any
|
||||||
err = json.Unmarshal([]byte(data.Settings), &settings)
|
err = json.Unmarshal([]byte(data.Settings), &settings)
|
||||||
@@ -372,11 +383,6 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
|
|||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
existingClients, err := inboundSvc.GetClients(oldInbound)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// A client already on this inbound is skipped instead of appended again:
|
// A client already on this inbound is skipped instead of appended again:
|
||||||
// checkEmailsExistForClients exempts a matching subId so one identity can
|
// checkEmailsExistForClients exempts a matching subId so one identity can
|
||||||
// live on several inbounds, which let retried or raced adds duplicate the
|
// live on several inbounds, which let retried or raced adds duplicate the
|
||||||
@@ -670,7 +676,10 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
|
|||||||
if err := validateClientsRenewal(clients); err != nil {
|
if err := validateClientsRenewal(clients); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
inbound, err := inboundSvc.GetInbound(data.Id)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
var settings map[string]any
|
var settings map[string]any
|
||||||
err = json.Unmarshal([]byte(data.Settings), &settings)
|
err = json.Unmarshal([]byte(data.Settings), &settings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -691,6 +700,15 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
for i, client := range clients {
|
||||||
|
identityEmail := client.Email
|
||||||
|
if i == 0 {
|
||||||
|
identityEmail = oldEmail
|
||||||
|
}
|
||||||
|
if err := validateClientKeepAliveAgainstExisting(inbound.Protocol, client, oldClients, identityEmail); err != nil {
|
||||||
|
return false, fmt.Errorf("client %q: %w", client.Email, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
newClientId := ""
|
newClientId := ""
|
||||||
switch oldInbound.Protocol {
|
switch oldInbound.Protocol {
|
||||||
@@ -844,8 +862,8 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
|
|||||||
if clients[0].PreSharedKey != "" {
|
if clients[0].PreSharedKey != "" {
|
||||||
newMap["preSharedKey"] = clients[0].PreSharedKey
|
newMap["preSharedKey"] = clients[0].PreSharedKey
|
||||||
}
|
}
|
||||||
if ka := clients[0].KeepAliveSeconds(); ka > 0 {
|
if clients[0].KeepAlive != nil {
|
||||||
newMap["keepAlive"] = ka
|
newMap["keepAlive"] = clients[0].KeepAlive
|
||||||
}
|
}
|
||||||
if oldInbound.Protocol == model.AmneziaWG && clients[0].ForwardedPorts != "" {
|
if oldInbound.Protocol == model.AmneziaWG && clients[0].ForwardedPorts != "" {
|
||||||
newMap["forwardedPorts"] = clients[0].ForwardedPorts
|
newMap["forwardedPorts"] = clients[0].ForwardedPorts
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func validateClientKeepAlive(protocol model.Protocol, client model.Client) error {
|
||||||
|
if client.KeepAlive == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch protocol {
|
||||||
|
case model.AmneziaWG:
|
||||||
|
return client.KeepAlive.Validate(true)
|
||||||
|
case model.WireGuard:
|
||||||
|
return client.KeepAlive.Validate(false)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateClientsKeepAlive(protocol model.Protocol, clients []model.Client) error {
|
||||||
|
for _, client := range clients {
|
||||||
|
if err := validateClientKeepAlive(protocol, client); err != nil {
|
||||||
|
return fmt.Errorf("client %q: %w", client.Email, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateClientKeepAliveAgainstExisting(protocol model.Protocol, client model.Client, existing []model.Client, identityEmail ...string) error {
|
||||||
|
if err := validateClientKeepAlive(protocol, client); err == nil || client.KeepAlive == nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if protocol != model.AmneziaWG && protocol != model.WireGuard {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
value := client.KeepAlive.String()
|
||||||
|
identity := client.Email
|
||||||
|
if len(identityEmail) > 0 && identityEmail[0] != "" {
|
||||||
|
identity = identityEmail[0]
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||||
|
if err != nil || strconv.FormatInt(parsed, 10) != value {
|
||||||
|
return validateClientKeepAlive(protocol, client)
|
||||||
|
}
|
||||||
|
for _, old := range existing {
|
||||||
|
if strings.EqualFold(old.Email, identity) && old.KeepAlive != nil && old.KeepAlive.String() == value {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return validateClientKeepAlive(protocol, client)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateClientsKeepAliveAgainstExisting(protocol model.Protocol, clients, existing []model.Client) error {
|
||||||
|
for _, client := range clients {
|
||||||
|
if err := validateClientKeepAliveAgainstExisting(protocol, client, existing); err != nil {
|
||||||
|
return fmt.Errorf("client %q: %w", client.Email, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"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 newKeepAliveRangeClient(t *testing.T, email, value string) model.Client {
|
||||||
|
t.Helper()
|
||||||
|
privateKey, publicKey, err := wgutil.GenerateWireguardKeypair()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate peer keypair: %v", err)
|
||||||
|
}
|
||||||
|
keepAlive := model.KeepAliveValue(value)
|
||||||
|
return model.Client{
|
||||||
|
Email: email, Enable: true, PrivateKey: privateKey, PublicKey: publicKey,
|
||||||
|
KeepAlive: &keepAlive,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func keepAliveTestAWGInbound(t *testing.T, port int) *model.Inbound {
|
||||||
|
t.Helper()
|
||||||
|
return mkInbound(t, port, model.AmneziaWG, `{"server":{"privateKey":"server","publicKey":"server-pub","subnetIp":"10.8.1.0","subnetCidr":24},"clients":[]}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientCreateAllowsAmneziaWGKeepAliveRangeAndPersistsNativeValue(t *testing.T) {
|
||||||
|
setupBulkDB(t)
|
||||||
|
inboundSvc := &InboundService{}
|
||||||
|
clientSvc := &ClientService{}
|
||||||
|
inbound := keepAliveTestAWGInbound(t, 51871)
|
||||||
|
client := newKeepAliveRangeClient(t, "range@awg", "15-25")
|
||||||
|
|
||||||
|
if _, err := clientSvc.CreateOne(inboundSvc, inbound.Id, client); err != nil {
|
||||||
|
t.Fatalf("create AmneziaWG client with keepAlive range: %v", err)
|
||||||
|
}
|
||||||
|
var record model.ClientRecord
|
||||||
|
if err := database.GetDB().Where("email = ?", client.Email).First(&record).Error; err != nil {
|
||||||
|
t.Fatalf("load saved client: %v", err)
|
||||||
|
}
|
||||||
|
if record.KeepAlive != "15-25" {
|
||||||
|
t.Fatalf("saved keepAlive = %q, want 15-25", record.KeepAlive)
|
||||||
|
}
|
||||||
|
savedInbound, err := inboundSvc.GetInbound(inbound.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
clients, err := inboundSvc.GetClients(savedInbound)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load client settings: %v", err)
|
||||||
|
}
|
||||||
|
if len(clients) != 1 || clients[0].KeepAliveText() != "15-25" {
|
||||||
|
t.Fatalf("inbound keepAlive = %#v, want 15-25", clients)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientCreateRejectsKeepAliveRangeForWireGuardBeforeAnyWrite(t *testing.T) {
|
||||||
|
setupBulkDB(t)
|
||||||
|
inboundSvc := &InboundService{}
|
||||||
|
clientSvc := &ClientService{}
|
||||||
|
wireguard := mkInbound(t, 51872, model.WireGuard, `{"secretKey":"server","clients":[]}`)
|
||||||
|
awg := keepAliveTestAWGInbound(t, 51873)
|
||||||
|
client := newKeepAliveRangeClient(t, "range@mixed", "15-25")
|
||||||
|
|
||||||
|
_, err := clientSvc.Create(inboundSvc, &ClientCreatePayload{
|
||||||
|
Client: client, InboundIds: []int{awg.Id, wireguard.Id},
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "keepAlive ranges are supported only for AmneziaWG clients") {
|
||||||
|
t.Fatalf("mixed-protocol create error = %v, want WireGuard range rejection", err)
|
||||||
|
}
|
||||||
|
var records int64
|
||||||
|
if err := database.GetDB().Model(&model.ClientRecord{}).Where("email = ?", client.Email).Count(&records).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if records != 0 {
|
||||||
|
t.Fatalf("client records written before protocol validation: %d", records)
|
||||||
|
}
|
||||||
|
for _, inbound := range []*model.Inbound{awg, wireguard} {
|
||||||
|
saved, err := inboundSvc.GetInbound(inbound.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var settings struct {
|
||||||
|
Clients []model.Client `json:"clients"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(saved.Settings), &settings); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(settings.Clients) != 0 {
|
||||||
|
t.Fatalf("client partially written to %s inbound: %#v", inbound.Protocol, settings.Clients)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientUpdateRejectsRangeWhenSharedWithWireGuard(t *testing.T) {
|
||||||
|
setupBulkDB(t)
|
||||||
|
inboundSvc := &InboundService{}
|
||||||
|
clientSvc := &ClientService{}
|
||||||
|
awg := keepAliveTestAWGInbound(t, 51874)
|
||||||
|
wireguard := mkInbound(t, 51875, model.WireGuard, `{"secretKey":"server","clients":[]}`)
|
||||||
|
seed := newKeepAliveRangeClient(t, "shared@tunnels", "25")
|
||||||
|
if _, err := clientSvc.Create(inboundSvc, &ClientCreatePayload{
|
||||||
|
Client: seed, InboundIds: []int{awg.Id, wireguard.Id},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create scalar client on both protocols: %v", err)
|
||||||
|
}
|
||||||
|
record, err := clientSvc.GetRecordByEmail(nil, seed.Email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
update := model.Client{Email: seed.Email, Enable: true, KeepAlive: seed.KeepAlive}
|
||||||
|
*update.KeepAlive = "15-25"
|
||||||
|
if _, err := clientSvc.Update(inboundSvc, record.Id, update, 0); err == nil || !strings.Contains(err.Error(), "keepAlive ranges are supported only for AmneziaWG clients") {
|
||||||
|
t.Fatalf("shared client update error = %v, want WireGuard range rejection", err)
|
||||||
|
}
|
||||||
|
var unchanged model.ClientRecord
|
||||||
|
if err := database.GetDB().Where("id = ?", record.Id).First(&unchanged).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if unchanged.KeepAlive != "25" {
|
||||||
|
t.Fatalf("shared record keepAlive after rejected update = %q, want 25", unchanged.KeepAlive)
|
||||||
|
}
|
||||||
|
for _, inbound := range []*model.Inbound{awg, wireguard} {
|
||||||
|
saved, err := inboundSvc.GetInbound(inbound.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
clients, err := inboundSvc.GetClients(saved)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(clients) != 1 || clients[0].KeepAliveText() != "25" {
|
||||||
|
t.Fatalf("%s inbound keepAlive after rejected update = %#v, want 25", inbound.Protocol, clients)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientCreateRejectsKeepAliveOutsideNativeRangeBeforeWrite(t *testing.T) {
|
||||||
|
setupBulkDB(t)
|
||||||
|
inboundSvc := &InboundService{}
|
||||||
|
clientSvc := &ClientService{}
|
||||||
|
inbound := keepAliveTestAWGInbound(t, 51876)
|
||||||
|
client := newKeepAliveRangeClient(t, "too-large@awg", "65536")
|
||||||
|
if _, err := clientSvc.CreateOne(inboundSvc, inbound.Id, client); err == nil || !strings.Contains(err.Error(), "keepAlive must be an integer from 0 to 65535") {
|
||||||
|
t.Fatalf("out-of-range create error = %v, want keepAlive bound error", err)
|
||||||
|
}
|
||||||
|
var records int64
|
||||||
|
if err := database.GetDB().Model(&model.ClientRecord{}).Where("email = ?", client.Email).Count(&records).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if records != 0 {
|
||||||
|
t.Fatalf("out-of-range client was written: %d records", records)
|
||||||
|
}
|
||||||
|
saved, err := inboundSvc.GetInbound(inbound.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
clients, err := inboundSvc.GetClients(saved)
|
||||||
|
if err != nil || len(clients) != 0 {
|
||||||
|
t.Fatalf("out-of-range client settings = %#v, %v; want no clients", clients, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientCreateAllowsRangeForSharedAmneziaWGAndVLESSClient(t *testing.T) {
|
||||||
|
setupBulkDB(t)
|
||||||
|
inboundSvc := &InboundService{}
|
||||||
|
clientSvc := &ClientService{}
|
||||||
|
awg := keepAliveTestAWGInbound(t, 51877)
|
||||||
|
vless := mkInbound(t, 51878, model.VLESS, `{"clients":[]}`)
|
||||||
|
client := newKeepAliveRangeClient(t, "range@awg-vless", "15-25")
|
||||||
|
if _, err := clientSvc.Create(inboundSvc, &ClientCreatePayload{Client: client, InboundIds: []int{awg.Id, vless.Id}}); err != nil {
|
||||||
|
t.Fatalf("create client shared by AmneziaWG and VLESS: %v", err)
|
||||||
|
}
|
||||||
|
var record model.ClientRecord
|
||||||
|
if err := database.GetDB().Where("email = ?", client.Email).First(&record).Error; err != nil {
|
||||||
|
t.Fatalf("read shared client: %v", err)
|
||||||
|
}
|
||||||
|
if record.KeepAlive != "15-25" {
|
||||||
|
t.Fatalf("shared client keepAlive = %q, want 15-25", record.KeepAlive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientUpdateAllowsRangeForSharedAmneziaWGAndVLESSClient(t *testing.T) {
|
||||||
|
setupBulkDB(t)
|
||||||
|
inboundSvc := &InboundService{}
|
||||||
|
clientSvc := &ClientService{}
|
||||||
|
awg := keepAliveTestAWGInbound(t, 51879)
|
||||||
|
vless := mkInbound(t, 51880, model.VLESS, `{"clients":[]}`)
|
||||||
|
client := newKeepAliveRangeClient(t, "update@awg-vless", "25")
|
||||||
|
if _, err := clientSvc.Create(inboundSvc, &ClientCreatePayload{Client: client, InboundIds: []int{awg.Id, vless.Id}}); err != nil {
|
||||||
|
t.Fatalf("create scalar client shared by AmneziaWG and VLESS: %v", err)
|
||||||
|
}
|
||||||
|
record, err := clientSvc.GetRecordByEmail(nil, client.Email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
updated := model.KeepAliveValue("15-25")
|
||||||
|
client.KeepAlive = &updated
|
||||||
|
if _, err := clientSvc.Update(inboundSvc, record.Id, client, 0); err != nil {
|
||||||
|
t.Fatalf("update shared client to an AmneziaWG range: %v", err)
|
||||||
|
}
|
||||||
|
var saved model.ClientRecord
|
||||||
|
if err := database.GetDB().Where("id = ?", record.Id).First(&saved).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if saved.KeepAlive != "15-25" {
|
||||||
|
t.Fatalf("updated shared client keepAlive = %q, want 15-25", saved.KeepAlive)
|
||||||
|
}
|
||||||
|
client.KeepAlive = model.KeepAlivePtr(25)
|
||||||
|
if _, err := clientSvc.Update(inboundSvc, record.Id, client, 0); err != nil {
|
||||||
|
t.Fatalf("change shared client range to scalar: %v", err)
|
||||||
|
}
|
||||||
|
client.KeepAlive = model.KeepAlivePtr(0)
|
||||||
|
if _, err := clientSvc.Update(inboundSvc, record.Id, client, 0); err != nil {
|
||||||
|
t.Fatalf("set explicit zero after range: %v", err)
|
||||||
|
}
|
||||||
|
client.KeepAlive = nil
|
||||||
|
if _, err := clientSvc.Update(inboundSvc, record.Id, client, 0); err != nil {
|
||||||
|
t.Fatalf("update with omitted keepAlive after explicit zero: %v", err)
|
||||||
|
}
|
||||||
|
if err := database.GetDB().Where("id = ?", record.Id).First(&saved).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if saved.KeepAlive != "0" {
|
||||||
|
t.Fatalf("omitted update changed explicit zero to %q", saved.KeepAlive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetClientEnableByEmailPreservesLegacyAmneziaWGKeepAlive(t *testing.T) {
|
||||||
|
setupBulkDB(t)
|
||||||
|
inboundSvc := &InboundService{}
|
||||||
|
clientSvc := &ClientService{}
|
||||||
|
legacy := newKeepAliveRangeClient(t, "legacy-enable@awg", "25")
|
||||||
|
inbound := keepAliveTestAWGInbound(t, 51881)
|
||||||
|
if _, err := clientSvc.CreateOne(inboundSvc, inbound.Id, legacy); err != nil {
|
||||||
|
t.Fatalf("create client: %v", err)
|
||||||
|
}
|
||||||
|
legacy.KeepAlive = model.KeepAlivePtr(70000)
|
||||||
|
if err := saveKeepAliveTestClientSettings(t, inboundSvc, inbound.Id, legacy); err != nil {
|
||||||
|
t.Fatalf("seed legacy client settings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
changed, _, err := clientSvc.SetClientEnableByEmail(inboundSvc, legacy.Email, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("disable existing client with legacy keepAlive: %v", err)
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
t.Fatal("SetClientEnableByEmail reported no change")
|
||||||
|
}
|
||||||
|
saved, err := inboundSvc.GetInbound(inbound.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
clients, err := inboundSvc.GetClients(saved)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(clients) != 1 || clients[0].Enable || clients[0].KeepAliveText() != "70000" {
|
||||||
|
t.Fatalf("saved legacy client = %#v, want disabled with keepAlive 70000", clients)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateInboundPreservesUnchangedLegacyAmneziaWGKeepAlive(t *testing.T) {
|
||||||
|
setupBulkDB(t)
|
||||||
|
inboundSvc := &InboundService{}
|
||||||
|
legacy := newKeepAliveRangeClient(t, "legacy-remark@awg", "-5")
|
||||||
|
legacy.AllowedIPs = []string{"10.8.1.3/32"}
|
||||||
|
inbound := mkInbound(t, 51882, model.AmneziaWG, clientsSettings(t, []model.Client{legacy}))
|
||||||
|
loaded, err := inboundSvc.GetInbound(inbound.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
loaded.Remark = "updated remark"
|
||||||
|
if _, _, err := inboundSvc.UpdateInbound(loaded); err != nil {
|
||||||
|
t.Fatalf("save unrelated inbound remark with legacy keepAlive: %v", err)
|
||||||
|
}
|
||||||
|
saved, err := inboundSvc.GetInbound(inbound.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
clients, err := inboundSvc.GetClients(saved)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if saved.Remark != "updated remark" {
|
||||||
|
t.Fatalf("saved inbound remark = %q, want updated remark", saved.Remark)
|
||||||
|
}
|
||||||
|
if len(clients) != 1 || clients[0].KeepAliveText() != "-5" {
|
||||||
|
t.Fatalf("saved client keepAlive = %#v, want -5", clients)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveKeepAliveTestClientSettings(t *testing.T, inboundSvc *InboundService, inboundID int, client model.Client) error {
|
||||||
|
t.Helper()
|
||||||
|
inbound, err := inboundSvc.GetInbound(inboundID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
clients, err := inboundSvc.GetClients(inbound)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range clients {
|
||||||
|
if clients[i].Email == client.Email {
|
||||||
|
clients[i].KeepAlive = client.KeepAlive
|
||||||
|
clients[i].AllowedIPs = client.AllowedIPs
|
||||||
|
clients[i].PrivateKey = client.PrivateKey
|
||||||
|
clients[i].PublicKey = client.PublicKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return database.GetDB().Model(&model.Inbound{}).Where("id = ?", inboundID).Update("settings", clientsSettings(t, clients)).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientUpdateGrandfathersOnlyUnchangedLegacyKeepAlive(t *testing.T) {
|
||||||
|
setupBulkDB(t)
|
||||||
|
inboundSvc := &InboundService{}
|
||||||
|
clientSvc := &ClientService{}
|
||||||
|
client := newKeepAliveRangeClient(t, "legacy-update@awg", "25")
|
||||||
|
inbound := keepAliveTestAWGInbound(t, 51883)
|
||||||
|
if _, err := clientSvc.CreateOne(inboundSvc, inbound.Id, client); err != nil {
|
||||||
|
t.Fatalf("create client: %v", err)
|
||||||
|
}
|
||||||
|
client.KeepAlive = model.KeepAlivePtr(70000)
|
||||||
|
if err := saveKeepAliveTestClientSettings(t, inboundSvc, inbound.Id, client); err != nil {
|
||||||
|
t.Fatalf("seed legacy client settings: %v", err)
|
||||||
|
}
|
||||||
|
fresh, err := inboundSvc.GetInbound(inbound.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
clients, err := inboundSvc.GetClients(fresh)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := clientSvc.SyncInbound(nil, inbound.Id, clients); err != nil {
|
||||||
|
t.Fatalf("sync legacy record: %v", err)
|
||||||
|
}
|
||||||
|
record, err := clientSvc.GetRecordByEmail(nil, client.Email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
unchanged := model.Client{Email: client.Email, Enable: true, KeepAlive: model.KeepAlivePtr(70000), Comment: "updated comment"}
|
||||||
|
if _, err := clientSvc.Update(inboundSvc, record.Id, unchanged, 0); err != nil {
|
||||||
|
t.Fatalf("update unrelated field with unchanged legacy keepAlive: %v", err)
|
||||||
|
}
|
||||||
|
renamed := unchanged
|
||||||
|
renamed.Email = "legacy-renamed@awg"
|
||||||
|
if _, err := clientSvc.Update(inboundSvc, record.Id, renamed, 0); err != nil {
|
||||||
|
t.Fatalf("rename client with unchanged legacy keepAlive: %v", err)
|
||||||
|
}
|
||||||
|
record, err = clientSvc.GetByID(record.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if record.Email != "legacy-renamed@awg" || record.Comment != "updated comment" || record.KeepAlive != "70000" {
|
||||||
|
t.Fatalf("saved renamed record = %#v, want renamed/comment updated with keepAlive 70000", record)
|
||||||
|
}
|
||||||
|
changed := renamed
|
||||||
|
changed.KeepAlive = model.KeepAlivePtr(70001)
|
||||||
|
if _, err := clientSvc.Update(inboundSvc, record.Id, changed, 0); err == nil || !strings.Contains(err.Error(), "keepAlive must be an integer from 0 to 65535") {
|
||||||
|
t.Fatalf("changed invalid legacy value error = %v, want keepAlive bound error", err)
|
||||||
|
}
|
||||||
|
stored, err := clientSvc.GetByID(record.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stored.KeepAlive != "70000" {
|
||||||
|
t.Fatalf("record after rejected changed legacy value = %q, want 70000", stored.KeepAlive)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,7 +55,9 @@ func applyClientRecordMerge(row *model.ClientRecord, incoming *model.ClientRecor
|
|||||||
row.AllowedIPs = incoming.AllowedIPs
|
row.AllowedIPs = incoming.AllowedIPs
|
||||||
}
|
}
|
||||||
row.PreSharedKey = incoming.PreSharedKey
|
row.PreSharedKey = incoming.PreSharedKey
|
||||||
row.KeepAlive = incoming.KeepAlive
|
if incoming.KeepAlive != "" {
|
||||||
|
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
|
||||||
|
|||||||
@@ -70,8 +70,8 @@ func TestUpdateCanClearKeepAliveOnAnExistingClient(t *testing.T) {
|
|||||||
if got := inboundKeepAlive(t, inboundSvc, ib.Id, "ka@x"); got != 0 {
|
if got := inboundKeepAlive(t, inboundSvc, ib.Id, "ka@x"); got != 0 {
|
||||||
t.Fatalf("inbound keepAlive after an explicit 0 = %d, want 0", got)
|
t.Fatalf("inbound keepAlive after an explicit 0 = %d, want 0", got)
|
||||||
}
|
}
|
||||||
if got := lookupClientRecord(t, "ka@x").KeepAlive; got != 0 {
|
if got := lookupClientRecord(t, "ka@x").KeepAlive.String(); got != "0" {
|
||||||
t.Fatalf("stored wg_keep_alive after an explicit 0 = %d, want 0", got)
|
t.Fatalf("stored wg_keep_alive after an explicit 0 = %s, want 0", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ func TestUpdateWithoutKeepAlivePreservesTheStoredValue(t *testing.T) {
|
|||||||
if got := inboundKeepAlive(t, inboundSvc, ib.Id, "ka@x"); got != 25 {
|
if got := inboundKeepAlive(t, inboundSvc, ib.Id, "ka@x"); got != 25 {
|
||||||
t.Fatalf("inbound keepAlive after an edit that omitted it = %d, want 25", got)
|
t.Fatalf("inbound keepAlive after an edit that omitted it = %d, want 25", got)
|
||||||
}
|
}
|
||||||
if got := lookupClientRecord(t, "ka@x").KeepAlive; got != 25 {
|
if got := lookupClientRecord(t, "ka@x").KeepAlive.String(); got != "25" {
|
||||||
t.Fatalf("stored wg_keep_alive after an edit that omitted it = %d, want 25", got)
|
t.Fatalf("stored wg_keep_alive after an edit that omitted it = %s, want 25", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1338,6 +1338,9 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
|||||||
if err := validateClientsRenewal(clients); err != nil {
|
if err := validateClientsRenewal(clients); err != nil {
|
||||||
return inbound, false, err
|
return inbound, false, err
|
||||||
}
|
}
|
||||||
|
if err := validateClientsKeepAlive(inbound.Protocol, clients); err != nil {
|
||||||
|
return inbound, false, err
|
||||||
|
}
|
||||||
for _, traffic := range inbound.ClientStats {
|
for _, traffic := range inbound.ClientStats {
|
||||||
if err := validateClientRenewal(model.Client{Reset: traffic.Reset, ResetDay: traffic.ResetDay, ResetWeekday: traffic.ResetWeekday}); err != nil {
|
if err := validateClientRenewal(model.Client{Reset: traffic.Reset, ResetDay: traffic.ResetDay, ResetWeekday: traffic.ResetWeekday}); err != nil {
|
||||||
return inbound, false, err
|
return inbound, false, err
|
||||||
@@ -1912,7 +1915,7 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
|
|||||||
return needRestart, nil
|
return needRestart, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *InboundService) validateUpdatedInboundClients(inbound *model.Inbound) error {
|
func (s *InboundService) validateUpdatedInboundClients(inbound, existing *model.Inbound) error {
|
||||||
clients, err := s.GetClients(inbound)
|
clients, err := s.GetClients(inbound)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1920,6 +1923,16 @@ func (s *InboundService) validateUpdatedInboundClients(inbound *model.Inbound) e
|
|||||||
if err := validateClientsRenewal(clients); err != nil {
|
if err := validateClientsRenewal(clients); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
var existingClients []model.Client
|
||||||
|
if inbound.Protocol == existing.Protocol {
|
||||||
|
existingClients, err = s.GetClients(existing)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := validateClientsKeepAliveAgainstExisting(inbound.Protocol, clients, existingClients); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
for _, client := range clients {
|
for _, client := range clients {
|
||||||
switch inbound.Protocol {
|
switch inbound.Protocol {
|
||||||
case model.Hysteria:
|
case model.Hysteria:
|
||||||
@@ -2038,7 +2051,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
|||||||
inbound.Enable = stored.Enable
|
inbound.Enable = stored.Enable
|
||||||
}
|
}
|
||||||
// On the clients actually saved: a protocol switch keeps the stored ones.
|
// On the clients actually saved: a protocol switch keeps the stored ones.
|
||||||
if err := s.validateUpdatedInboundClients(inbound); err != nil {
|
if err := s.validateUpdatedInboundClients(inbound, stored); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
conflict, cErr := checkPortConflictTx(tx, inbound, inbound.Id)
|
conflict, cErr := checkPortConflictTx(tx, inbound, inbound.Id)
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ func seedWGInbound(t *testing.T, tag string, port int, clients []model.Client) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func seedDualTunnelClient(t *testing.T, enabled bool, wgKeepAlive *int) string {
|
func seedDualTunnelClient(t *testing.T, enabled bool, wgKeepAlive *model.KeepAliveValue) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
setupSettingTestDB(t)
|
setupSettingTestDB(t)
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
@@ -202,8 +202,8 @@ func TestGetXrayConfigWireGuardUsesInboundLocalTunnelFields(t *testing.T) {
|
|||||||
if err := database.GetDB().Where("email = ?", email).First(&shared).Error; err != nil {
|
if err := database.GetDB().Where("email = ?", email).First(&shared).Error; err != nil {
|
||||||
t.Fatalf("read shared client: %v", err)
|
t.Fatalf("read shared client: %v", err)
|
||||||
}
|
}
|
||||||
if shared.AllowedIPs != "10.8.1.5/32" || shared.PreSharedKey != "awg-psk" || shared.PublicKey != "pub-awg" || shared.KeepAlive != 25 {
|
if shared.AllowedIPs != "10.8.1.5/32" || shared.PreSharedKey != "awg-psk" || shared.PublicKey != "pub-awg" || shared.KeepAlive.Seconds() != 25 {
|
||||||
t.Fatalf("test setup did not persist AmneziaWG identity: allowedIPs=%q preSharedKey=%q publicKey=%q keepAlive=%d", shared.AllowedIPs, shared.PreSharedKey, shared.PublicKey, shared.KeepAlive)
|
t.Fatalf("test setup did not persist AmneziaWG identity: allowedIPs=%q preSharedKey=%q publicKey=%q keepAlive=%d", shared.AllowedIPs, shared.PreSharedKey, shared.PublicKey, shared.KeepAlive.Seconds())
|
||||||
}
|
}
|
||||||
|
|
||||||
peers := wgPeerList(t, wgInboundEmittedSettings(t, "wg-dual"))
|
peers := wgPeerList(t, wgInboundEmittedSettings(t, "wg-dual"))
|
||||||
@@ -232,8 +232,8 @@ func TestGetXrayConfigWireGuardOmitsKeepAliveAbsentFromSettings(t *testing.T) {
|
|||||||
if err := database.GetDB().Where("email = ?", email).First(&shared).Error; err != nil {
|
if err := database.GetDB().Where("email = ?", email).First(&shared).Error; err != nil {
|
||||||
t.Fatalf("read shared client: %v", err)
|
t.Fatalf("read shared client: %v", err)
|
||||||
}
|
}
|
||||||
if shared.PublicKey != "pub-awg" || shared.KeepAlive != 25 {
|
if shared.PublicKey != "pub-awg" || shared.KeepAlive.Seconds() != 25 {
|
||||||
t.Fatalf("test setup did not persist AmneziaWG identity: publicKey=%q keepAlive=%d", shared.PublicKey, shared.KeepAlive)
|
t.Fatalf("test setup did not persist AmneziaWG identity: publicKey=%q keepAlive=%d", shared.PublicKey, shared.KeepAlive.Seconds())
|
||||||
}
|
}
|
||||||
|
|
||||||
peers := wgPeerList(t, wgInboundEmittedSettings(t, "wg-dual"))
|
peers := wgPeerList(t, wgInboundEmittedSettings(t, "wg-dual"))
|
||||||
|
|||||||
@@ -983,6 +983,8 @@
|
|||||||
"amneziaWgForwardedPorts": "المنافذ المُعاد توجيهها",
|
"amneziaWgForwardedPorts": "المنافذ المُعاد توجيهها",
|
||||||
"amneziaWgForwardedPortsHint": "المنافذ/النطاقات المُعاد توجيهها (DNAT) لهذا العميل، مثل 80, 443, 8000-8100. اتركها فارغة إن لم تكن مطلوبة.",
|
"amneziaWgForwardedPortsHint": "المنافذ/النطاقات المُعاد توجيهها (DNAT) لهذا العميل، مثل 80, 443, 8000-8100. اتركها فارغة إن لم تكن مطلوبة.",
|
||||||
"tunnelKeepAlive": "Keepalive (seconds)",
|
"tunnelKeepAlive": "Keepalive (seconds)",
|
||||||
|
"amneziaWgKeepAliveHint": "أدخل الثواني كرقم أو كنطاق (مثل 15-25). يجب أن تكون القيم بين 0 و65535؛ القيمة 0 تعطل الإبقاء على الاتصال.",
|
||||||
|
"amneziaWgKeepAliveInvalid": "أدخل رقمًا صحيحًا أو نطاقًا من 0 إلى 65535.",
|
||||||
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
|
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
|
||||||
"amneziaWgConfig": "إعدادات AmneziaWG",
|
"amneziaWgConfig": "إعدادات AmneziaWG",
|
||||||
"tuicConfig": "إعدادات TUIC (Clash)",
|
"tuicConfig": "إعدادات TUIC (Clash)",
|
||||||
|
|||||||
@@ -983,6 +983,8 @@
|
|||||||
"amneziaWgForwardedPorts": "Forwarded Ports",
|
"amneziaWgForwardedPorts": "Forwarded Ports",
|
||||||
"amneziaWgForwardedPortsHint": "Ports/ranges DNAT'd to this client, e.g. 80, 443, 8000-8100. Leave empty for none.",
|
"amneziaWgForwardedPortsHint": "Ports/ranges DNAT'd to this client, e.g. 80, 443, 8000-8100. Leave empty for none.",
|
||||||
"tunnelKeepAlive": "Keepalive (seconds)",
|
"tunnelKeepAlive": "Keepalive (seconds)",
|
||||||
|
"amneziaWgKeepAliveHint": "Enter seconds as a number or range (for example, 15-25). Values must be from 0 to 65535; 0 disables keepalive.",
|
||||||
|
"amneziaWgKeepAliveInvalid": "Enter a whole number or range from 0 to 65535.",
|
||||||
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
|
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
|
||||||
"amneziaWgConfig": "AmneziaWG config",
|
"amneziaWgConfig": "AmneziaWG config",
|
||||||
"tuicConfig": "TUIC config (Clash)",
|
"tuicConfig": "TUIC config (Clash)",
|
||||||
|
|||||||
@@ -983,6 +983,8 @@
|
|||||||
"amneziaWgForwardedPorts": "Puertos reenviados",
|
"amneziaWgForwardedPorts": "Puertos reenviados",
|
||||||
"amneziaWgForwardedPortsHint": "Puertos/rangos redirigidos (DNAT) a este cliente, p. ej. 80, 443, 8000-8100. Déjalo vacío si no aplica.",
|
"amneziaWgForwardedPortsHint": "Puertos/rangos redirigidos (DNAT) a este cliente, p. ej. 80, 443, 8000-8100. Déjalo vacío si no aplica.",
|
||||||
"tunnelKeepAlive": "Keepalive (seconds)",
|
"tunnelKeepAlive": "Keepalive (seconds)",
|
||||||
|
"amneziaWgKeepAliveHint": "Indica los segundos como un número o un intervalo (por ejemplo, 15-25). Los valores deben estar entre 0 y 65535; 0 desactiva el keepalive.",
|
||||||
|
"amneziaWgKeepAliveInvalid": "Introduce un número entero o un intervalo entre 0 y 65535.",
|
||||||
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
|
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
|
||||||
"amneziaWgConfig": "Configuración de AmneziaWG",
|
"amneziaWgConfig": "Configuración de AmneziaWG",
|
||||||
"tuicConfig": "Configuración de TUIC (Clash)",
|
"tuicConfig": "Configuración de TUIC (Clash)",
|
||||||
|
|||||||
@@ -983,6 +983,8 @@
|
|||||||
"amneziaWgForwardedPorts": "پورتهای هدایتشده",
|
"amneziaWgForwardedPorts": "پورتهای هدایتشده",
|
||||||
"amneziaWgForwardedPortsHint": "پورتها/محدودههای DNAT شده به این کلاینت، مثلاً 80, 443, 8000-8100. برای غیرفعال بودن خالی بگذارید.",
|
"amneziaWgForwardedPortsHint": "پورتها/محدودههای DNAT شده به این کلاینت، مثلاً 80, 443, 8000-8100. برای غیرفعال بودن خالی بگذارید.",
|
||||||
"tunnelKeepAlive": "Keepalive (seconds)",
|
"tunnelKeepAlive": "Keepalive (seconds)",
|
||||||
|
"amneziaWgKeepAliveHint": "ثانیه را بهصورت عدد یا بازه (مثلاً 15-25) وارد کنید. مقدار باید بین 0 تا 65535 باشد؛ 0 قابلیت keepalive را غیرفعال میکند.",
|
||||||
|
"amneziaWgKeepAliveInvalid": "یک عدد صحیح یا بازهای از 0 تا 65535 وارد کنید.",
|
||||||
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
|
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
|
||||||
"amneziaWgConfig": "پیکربندی AmneziaWG",
|
"amneziaWgConfig": "پیکربندی AmneziaWG",
|
||||||
"tuicConfig": "پیکربندی TUIC (Clash)",
|
"tuicConfig": "پیکربندی TUIC (Clash)",
|
||||||
|
|||||||
@@ -983,6 +983,8 @@
|
|||||||
"amneziaWgForwardedPorts": "Port yang Diteruskan",
|
"amneziaWgForwardedPorts": "Port yang Diteruskan",
|
||||||
"amneziaWgForwardedPortsHint": "Port/rentang yang di-DNAT ke klien ini, mis. 80, 443, 8000-8100. Biarkan kosong jika tidak ada.",
|
"amneziaWgForwardedPortsHint": "Port/rentang yang di-DNAT ke klien ini, mis. 80, 443, 8000-8100. Biarkan kosong jika tidak ada.",
|
||||||
"tunnelKeepAlive": "Keepalive (seconds)",
|
"tunnelKeepAlive": "Keepalive (seconds)",
|
||||||
|
"amneziaWgKeepAliveHint": "Masukkan detik sebagai angka atau rentang (misalnya, 15-25). Nilai harus antara 0 dan 65535; 0 menonaktifkan keepalive.",
|
||||||
|
"amneziaWgKeepAliveInvalid": "Masukkan bilangan bulat atau rentang dari 0 hingga 65535.",
|
||||||
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
|
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
|
||||||
"amneziaWgConfig": "Konfigurasi AmneziaWG",
|
"amneziaWgConfig": "Konfigurasi AmneziaWG",
|
||||||
"tuicConfig": "Konfigurasi TUIC (Clash)",
|
"tuicConfig": "Konfigurasi TUIC (Clash)",
|
||||||
|
|||||||
@@ -983,6 +983,8 @@
|
|||||||
"amneziaWgForwardedPorts": "転送ポート",
|
"amneziaWgForwardedPorts": "転送ポート",
|
||||||
"amneziaWgForwardedPortsHint": "このクライアントに転送するポート/範囲。例: 80, 443, 8000-8100。空欄で転送なし。",
|
"amneziaWgForwardedPortsHint": "このクライアントに転送するポート/範囲。例: 80, 443, 8000-8100。空欄で転送なし。",
|
||||||
"tunnelKeepAlive": "Keepalive (seconds)",
|
"tunnelKeepAlive": "Keepalive (seconds)",
|
||||||
|
"amneziaWgKeepAliveHint": "秒数を数値または範囲(例: 15-25)で入力します。値は0〜65535です。0にするとkeepaliveを無効にします。",
|
||||||
|
"amneziaWgKeepAliveInvalid": "0〜65535の整数または範囲を入力してください。",
|
||||||
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
|
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
|
||||||
"amneziaWgConfig": "AmneziaWG 設定",
|
"amneziaWgConfig": "AmneziaWG 設定",
|
||||||
"tuicConfig": "TUIC 設定 (Clash)",
|
"tuicConfig": "TUIC 設定 (Clash)",
|
||||||
|
|||||||
@@ -983,6 +983,8 @@
|
|||||||
"amneziaWgForwardedPorts": "Portas encaminhadas",
|
"amneziaWgForwardedPorts": "Portas encaminhadas",
|
||||||
"amneziaWgForwardedPortsHint": "Portas/intervalos redirecionados (DNAT) para este cliente, ex. 80, 443, 8000-8100. Deixe vazio se não aplicável.",
|
"amneziaWgForwardedPortsHint": "Portas/intervalos redirecionados (DNAT) para este cliente, ex. 80, 443, 8000-8100. Deixe vazio se não aplicável.",
|
||||||
"tunnelKeepAlive": "Keepalive (seconds)",
|
"tunnelKeepAlive": "Keepalive (seconds)",
|
||||||
|
"amneziaWgKeepAliveHint": "Informe os segundos como número ou intervalo (por exemplo, 15-25). Os valores devem estar entre 0 e 65535; 0 desativa o keepalive.",
|
||||||
|
"amneziaWgKeepAliveInvalid": "Informe um número inteiro ou intervalo de 0 a 65535.",
|
||||||
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
|
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
|
||||||
"amneziaWgConfig": "Configuração do AmneziaWG",
|
"amneziaWgConfig": "Configuração do AmneziaWG",
|
||||||
"tuicConfig": "Configuração do TUIC (Clash)",
|
"tuicConfig": "Configuração do TUIC (Clash)",
|
||||||
|
|||||||
@@ -983,6 +983,8 @@
|
|||||||
"amneziaWgForwardedPorts": "Проброс портов",
|
"amneziaWgForwardedPorts": "Проброс портов",
|
||||||
"amneziaWgForwardedPortsHint": "Порты/диапазоны, DNAT'ящиеся на этого клиента, например 80, 443, 8000-8100. Оставьте пустым, если не нужно.",
|
"amneziaWgForwardedPortsHint": "Порты/диапазоны, DNAT'ящиеся на этого клиента, например 80, 443, 8000-8100. Оставьте пустым, если не нужно.",
|
||||||
"tunnelKeepAlive": "Keepalive (секунды)",
|
"tunnelKeepAlive": "Keepalive (секунды)",
|
||||||
|
"amneziaWgKeepAliveHint": "Укажите секунды числом или диапазоном (например, 15-25). Значение должно быть от 0 до 65535; 0 отключает keepalive.",
|
||||||
|
"amneziaWgKeepAliveInvalid": "Введите целое число или диапазон от 0 до 65535.",
|
||||||
"tunnelKeepAliveHint": "Как часто клиент шлёт keepalive-пакет. Обычное значение 25: оно держит NAT-маппинг открытым и возвращает простаивающий пир сам после любого разрыва, включая рестарт панели. 0 отключает — тогда молчащий клиент остаётся отключённым до первого своего пакета.",
|
"tunnelKeepAliveHint": "Как часто клиент шлёт keepalive-пакет. Обычное значение 25: оно держит NAT-маппинг открытым и возвращает простаивающий пир сам после любого разрыва, включая рестарт панели. 0 отключает — тогда молчащий клиент остаётся отключённым до первого своего пакета.",
|
||||||
"amneziaWgConfig": "Конфиг AmneziaWG",
|
"amneziaWgConfig": "Конфиг AmneziaWG",
|
||||||
"tuicConfig": "Конфиг TUIC (Clash)",
|
"tuicConfig": "Конфиг TUIC (Clash)",
|
||||||
|
|||||||
@@ -983,6 +983,8 @@
|
|||||||
"amneziaWgForwardedPorts": "Yönlendirilen Portlar",
|
"amneziaWgForwardedPorts": "Yönlendirilen Portlar",
|
||||||
"amneziaWgForwardedPortsHint": "Bu istemciye DNAT ile yönlendirilen port/aralıklar, örn. 80, 443, 8000-8100. Yoksa boş bırakın.",
|
"amneziaWgForwardedPortsHint": "Bu istemciye DNAT ile yönlendirilen port/aralıklar, örn. 80, 443, 8000-8100. Yoksa boş bırakın.",
|
||||||
"tunnelKeepAlive": "Keepalive (seconds)",
|
"tunnelKeepAlive": "Keepalive (seconds)",
|
||||||
|
"amneziaWgKeepAliveHint": "Saniyeyi sayı veya aralık olarak girin (ör. 15-25). Değerler 0 ile 65535 arasında olmalıdır; 0 keepalive özelliğini kapatır.",
|
||||||
|
"amneziaWgKeepAliveInvalid": "0 ile 65535 arasında bir tam sayı veya aralık girin.",
|
||||||
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
|
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
|
||||||
"amneziaWgConfig": "AmneziaWG Yapılandırması",
|
"amneziaWgConfig": "AmneziaWG Yapılandırması",
|
||||||
"tuicConfig": "TUIC Yapılandırması (Clash)",
|
"tuicConfig": "TUIC Yapılandırması (Clash)",
|
||||||
|
|||||||
@@ -983,6 +983,8 @@
|
|||||||
"amneziaWgForwardedPorts": "Перенаправлені порти",
|
"amneziaWgForwardedPorts": "Перенаправлені порти",
|
||||||
"amneziaWgForwardedPortsHint": "Порти/діапазони, що перенаправляються (DNAT) на цього клієнта, напр. 80, 443, 8000-8100. Залиште порожнім, якщо не потрібно.",
|
"amneziaWgForwardedPortsHint": "Порти/діапазони, що перенаправляються (DNAT) на цього клієнта, напр. 80, 443, 8000-8100. Залиште порожнім, якщо не потрібно.",
|
||||||
"tunnelKeepAlive": "Keepalive (seconds)",
|
"tunnelKeepAlive": "Keepalive (seconds)",
|
||||||
|
"amneziaWgKeepAliveHint": "Укажіть секунди числом або діапазоном (наприклад, 15-25). Значення має бути від 0 до 65535; 0 вимикає keepalive.",
|
||||||
|
"amneziaWgKeepAliveInvalid": "Введіть ціле число або діапазон від 0 до 65535.",
|
||||||
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
|
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
|
||||||
"amneziaWgConfig": "Конфігурація AmneziaWG",
|
"amneziaWgConfig": "Конфігурація AmneziaWG",
|
||||||
"tuicConfig": "Конфігурація TUIC (Clash)",
|
"tuicConfig": "Конфігурація TUIC (Clash)",
|
||||||
|
|||||||
@@ -983,6 +983,8 @@
|
|||||||
"amneziaWgForwardedPorts": "Cổng chuyển tiếp",
|
"amneziaWgForwardedPorts": "Cổng chuyển tiếp",
|
||||||
"amneziaWgForwardedPortsHint": "Cổng/dải cổng được chuyển tiếp (DNAT) đến client này, vd. 80, 443, 8000-8100. Để trống nếu không cần.",
|
"amneziaWgForwardedPortsHint": "Cổng/dải cổng được chuyển tiếp (DNAT) đến client này, vd. 80, 443, 8000-8100. Để trống nếu không cần.",
|
||||||
"tunnelKeepAlive": "Keepalive (seconds)",
|
"tunnelKeepAlive": "Keepalive (seconds)",
|
||||||
|
"amneziaWgKeepAliveHint": "Nhập số giây dưới dạng số hoặc khoảng (ví dụ: 15-25). Giá trị phải từ 0 đến 65535; 0 sẽ tắt keepalive.",
|
||||||
|
"amneziaWgKeepAliveInvalid": "Nhập số nguyên hoặc khoảng từ 0 đến 65535.",
|
||||||
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
|
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
|
||||||
"amneziaWgConfig": "Cấu hình AmneziaWG",
|
"amneziaWgConfig": "Cấu hình AmneziaWG",
|
||||||
"tuicConfig": "Cấu hình TUIC (Clash)",
|
"tuicConfig": "Cấu hình TUIC (Clash)",
|
||||||
|
|||||||
@@ -983,6 +983,8 @@
|
|||||||
"amneziaWgForwardedPorts": "转发端口",
|
"amneziaWgForwardedPorts": "转发端口",
|
||||||
"amneziaWgForwardedPortsHint": "转发到此客户端的端口/范围,例如 80, 443, 8000-8100。留空则不转发。",
|
"amneziaWgForwardedPortsHint": "转发到此客户端的端口/范围,例如 80, 443, 8000-8100。留空则不转发。",
|
||||||
"tunnelKeepAlive": "Keepalive (seconds)",
|
"tunnelKeepAlive": "Keepalive (seconds)",
|
||||||
|
"amneziaWgKeepAliveHint": "秒数可输入数字或范围(例如 15-25)。数值须在 0 到 65535 之间;0 表示禁用 keepalive。",
|
||||||
|
"amneziaWgKeepAliveInvalid": "请输入 0 到 65535 之间的整数或范围。",
|
||||||
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
|
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
|
||||||
"amneziaWgConfig": "AmneziaWG 配置",
|
"amneziaWgConfig": "AmneziaWG 配置",
|
||||||
"tuicConfig": "TUIC 配置 (Clash)",
|
"tuicConfig": "TUIC 配置 (Clash)",
|
||||||
|
|||||||
@@ -983,6 +983,8 @@
|
|||||||
"amneziaWgForwardedPorts": "轉發連接埠",
|
"amneziaWgForwardedPorts": "轉發連接埠",
|
||||||
"amneziaWgForwardedPortsHint": "轉發到此客戶端的連接埠/範圍,例如 80, 443, 8000-8100。留空則不轉發。",
|
"amneziaWgForwardedPortsHint": "轉發到此客戶端的連接埠/範圍,例如 80, 443, 8000-8100。留空則不轉發。",
|
||||||
"tunnelKeepAlive": "Keepalive (seconds)",
|
"tunnelKeepAlive": "Keepalive (seconds)",
|
||||||
|
"amneziaWgKeepAliveHint": "秒數可輸入數字或範圍(例如 15-25)。數值須介於 0 到 65535;0 表示停用 keepalive。",
|
||||||
|
"amneziaWgKeepAliveInvalid": "請輸入 0 到 65535 之間的整數或範圍。",
|
||||||
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
|
"tunnelKeepAliveHint": "How often the client sends a keepalive packet. 25 is the usual value: it holds the NAT mapping open and brings an idle peer back on its own after any drop, including a panel restart. 0 disables it — an idle client then stays disconnected until it sends traffic.",
|
||||||
"amneziaWgConfig": "AmneziaWG 設定",
|
"amneziaWgConfig": "AmneziaWG 設定",
|
||||||
"tuicConfig": "TUIC 設定 (Clash)",
|
"tuicConfig": "TUIC 設定 (Clash)",
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ func (g *exampleGen) forType(t TypeRef, visited map[string]bool) any {
|
|||||||
return []any{g.forType(*t.Element, visited)}
|
return []any{g.forType(*t.Element, visited)}
|
||||||
case KindMap:
|
case KindMap:
|
||||||
return map[string]any{}
|
return map[string]any{}
|
||||||
|
case KindUnion:
|
||||||
|
if len(t.Variants) > 0 {
|
||||||
|
return g.forType(t.Variants[0], visited)
|
||||||
|
}
|
||||||
case KindRef:
|
case KindRef:
|
||||||
if t.Name == "nullable" {
|
if t.Name == "nullable" {
|
||||||
return nil
|
return nil
|
||||||
@@ -105,6 +109,9 @@ func (g *exampleGen) forType(t TypeRef, visited map[string]bool) any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func baseKind(t TypeRef) TypeRef {
|
func baseKind(t TypeRef) TypeRef {
|
||||||
|
if t.Kind == KindUnion && len(t.Variants) > 0 {
|
||||||
|
return t.Variants[0]
|
||||||
|
}
|
||||||
if t.Kind == KindRef && t.Name == "nullable" && t.Inner != nil {
|
if t.Kind == KindRef && t.Name == "nullable" && t.Inner != nil {
|
||||||
return *t.Inner
|
return *t.Inner
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,12 @@ func (g *schemaGen) typeSchema(t TypeRef) map[string]any {
|
|||||||
return map[string]any{"type": "array", "items": g.typeSchema(*t.Element)}
|
return map[string]any{"type": "array", "items": g.typeSchema(*t.Element)}
|
||||||
case KindMap:
|
case KindMap:
|
||||||
return map[string]any{"type": "object", "additionalProperties": g.typeSchema(*t.Value)}
|
return map[string]any{"type": "object", "additionalProperties": g.typeSchema(*t.Value)}
|
||||||
|
case KindUnion:
|
||||||
|
variants := make([]any, 0, len(t.Variants))
|
||||||
|
for _, variant := range t.Variants {
|
||||||
|
variants = append(variants, g.typeSchema(variant))
|
||||||
|
}
|
||||||
|
return map[string]any{"oneOf": variants}
|
||||||
case KindAny, KindUnknown, KindRaw:
|
case KindAny, KindUnknown, KindRaw:
|
||||||
return map[string]any{}
|
return map[string]any{}
|
||||||
case KindRef:
|
case KindRef:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func emitTypes(w io.Writer, schemas []Schema, aliases []Alias) error {
|
func emitTypes(w io.Writer, schemas []Schema, aliases []Alias) error {
|
||||||
@@ -60,6 +61,12 @@ func tsTypeExpr(t TypeRef) string {
|
|||||||
return tsTypeExpr(*t.Element) + "[]"
|
return tsTypeExpr(*t.Element) + "[]"
|
||||||
case KindMap:
|
case KindMap:
|
||||||
return "Record<" + tsTypeExpr(*t.Key) + ", " + tsTypeExpr(*t.Value) + ">"
|
return "Record<" + tsTypeExpr(*t.Key) + ", " + tsTypeExpr(*t.Value) + ">"
|
||||||
|
case KindUnion:
|
||||||
|
parts := make([]string, 0, len(t.Variants))
|
||||||
|
for _, variant := range t.Variants {
|
||||||
|
parts = append(parts, tsTypeExpr(variant))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " | ")
|
||||||
case KindRef:
|
case KindRef:
|
||||||
if t.Name == "nullable" {
|
if t.Name == "nullable" {
|
||||||
return tsTypeExpr(*t.Inner) + " | null"
|
return tsTypeExpr(*t.Inner) + " | null"
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ func zodTypeExpr(t TypeRef) string {
|
|||||||
return "z.array(" + zodTypeExpr(*t.Element) + ")"
|
return "z.array(" + zodTypeExpr(*t.Element) + ")"
|
||||||
case KindMap:
|
case KindMap:
|
||||||
return "z.record(" + zodTypeExpr(*t.Key) + ", " + zodTypeExpr(*t.Value) + ")"
|
return "z.record(" + zodTypeExpr(*t.Key) + ", " + zodTypeExpr(*t.Value) + ")"
|
||||||
|
case KindUnion:
|
||||||
|
parts := make([]string, 0, len(t.Variants))
|
||||||
|
for _, variant := range t.Variants {
|
||||||
|
parts = append(parts, zodTypeExpr(variant))
|
||||||
|
}
|
||||||
|
return "z.union([" + strings.Join(parts, ", ") + "])"
|
||||||
case KindRef:
|
case KindRef:
|
||||||
if t.Name == "nullable" {
|
if t.Name == "nullable" {
|
||||||
return zodTypeExpr(*t.Inner) + ".nullable()"
|
return zodTypeExpr(*t.Inner) + ".nullable()"
|
||||||
|
|||||||
@@ -48,7 +48,11 @@ func run(root, outDir string) error {
|
|||||||
{Field: "StreamSettings", Kind: KindAny},
|
{Field: "StreamSettings", Kind: KindAny},
|
||||||
{Field: "Sniffing", Kind: KindAny},
|
{Field: "Sniffing", Kind: KindAny},
|
||||||
},
|
},
|
||||||
|
"Client": {
|
||||||
|
{Field: "KeepAlive", Type: TypeRef{Kind: KindUnion, Variants: []TypeRef{{Kind: KindInt}, {Kind: KindString}}}},
|
||||||
|
},
|
||||||
"ClientRecord": {
|
"ClientRecord": {
|
||||||
|
{Field: "KeepAlive", Type: TypeRef{Kind: KindUnion, Variants: []TypeRef{{Kind: KindInt}, {Kind: KindString}}}},
|
||||||
{Field: "Reverse", Kind: KindAny},
|
{Field: "Reverse", Kind: KindAny},
|
||||||
},
|
},
|
||||||
"InboundClientIps": {
|
"InboundClientIps": {
|
||||||
|
|||||||
@@ -31,12 +31,13 @@ type Field struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TypeRef struct {
|
type TypeRef struct {
|
||||||
Kind TypeKind
|
Kind TypeKind
|
||||||
Name string
|
Name string
|
||||||
Element *TypeRef
|
Element *TypeRef
|
||||||
Key *TypeRef
|
Key *TypeRef
|
||||||
Value *TypeRef
|
Value *TypeRef
|
||||||
Inner *TypeRef
|
Inner *TypeRef
|
||||||
|
Variants []TypeRef
|
||||||
}
|
}
|
||||||
|
|
||||||
type TypeKind string
|
type TypeKind string
|
||||||
@@ -48,6 +49,7 @@ const (
|
|||||||
KindBool TypeKind = "boolean"
|
KindBool TypeKind = "boolean"
|
||||||
KindArray TypeKind = "array"
|
KindArray TypeKind = "array"
|
||||||
KindMap TypeKind = "map"
|
KindMap TypeKind = "map"
|
||||||
|
KindUnion TypeKind = "union"
|
||||||
KindObject TypeKind = "object"
|
KindObject TypeKind = "object"
|
||||||
KindRef TypeKind = "ref"
|
KindRef TypeKind = "ref"
|
||||||
KindUnknown TypeKind = "unknown"
|
KindUnknown TypeKind = "unknown"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
type walkOverride struct {
|
type walkOverride struct {
|
||||||
Field string
|
Field string
|
||||||
Kind TypeKind
|
Kind TypeKind
|
||||||
|
Type TypeRef
|
||||||
}
|
}
|
||||||
|
|
||||||
type packageRequest struct {
|
type packageRequest struct {
|
||||||
@@ -122,7 +123,11 @@ func buildFields(fld *ast.Field, overrides []walkOverride) []Field {
|
|||||||
t := exprToType(fld.Type)
|
t := exprToType(fld.Type)
|
||||||
for _, o := range overrides {
|
for _, o := range overrides {
|
||||||
if o.Field == n.Name || o.Field == jsonName {
|
if o.Field == n.Name || o.Field == jsonName {
|
||||||
t = TypeRef{Kind: o.Kind}
|
if o.Type.Kind != "" {
|
||||||
|
t = o.Type
|
||||||
|
} else {
|
||||||
|
t = TypeRef{Kind: o.Kind}
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,7 +150,11 @@ func buildFields(fld *ast.Field, overrides []walkOverride) []Field {
|
|||||||
t := exprToType(fld.Type)
|
t := exprToType(fld.Type)
|
||||||
for _, o := range overrides {
|
for _, o := range overrides {
|
||||||
if o.Field == exprIdentName(fld.Type) || o.Field == jsonName {
|
if o.Field == exprIdentName(fld.Type) || o.Field == jsonName {
|
||||||
t = TypeRef{Kind: o.Kind}
|
if o.Type.Kind != "" {
|
||||||
|
t = o.Type
|
||||||
|
} else {
|
||||||
|
t = TypeRef{Kind: o.Kind}
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,3 +43,28 @@ type HappLinkResult struct {
|
|||||||
t.Fatalf("generated types expose DI interface:\n%s", apiSurface)
|
t.Fatalf("generated types expose DI interface:\n%s", apiSurface)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestKeepAliveOverrideEmitsNumberOrString(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
source := "package model\ntype Client struct { KeepAlive *int `json:\"keepAlive,omitempty\"` }\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "client.go"), []byte(source), 0o644); err != nil {
|
||||||
|
t.Fatalf("write fixture: %v", err)
|
||||||
|
}
|
||||||
|
union := TypeRef{Kind: KindUnion, Variants: []TypeRef{{Kind: KindInt}, {Kind: KindString}}}
|
||||||
|
schemas, _, err := walkPackages([]packageRequest{{Path: dir, StructAllow: setOf("Client"), Overrides: map[string][]walkOverride{"Client": {{Field: "KeepAlive", Type: union}}}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("walk fixture: %v", err)
|
||||||
|
}
|
||||||
|
var types bytes.Buffer
|
||||||
|
if err := emitTypes(&types, schemas, nil); err != nil || !strings.Contains(types.String(), "keepAlive?: number | string;") {
|
||||||
|
t.Fatalf("generated TypeScript keepAlive = %s, err = %v", types.String(), err)
|
||||||
|
}
|
||||||
|
var zod bytes.Buffer
|
||||||
|
if err := emitZod(&zod, schemas, nil); err != nil || !strings.Contains(zod.String(), "z.union([z.number().int(), z.string()]).optional()") {
|
||||||
|
t.Fatalf("generated Zod keepAlive = %s, err = %v", zod.String(), err)
|
||||||
|
}
|
||||||
|
var schema bytes.Buffer
|
||||||
|
if err := emitJSONSchema(&schema, schemas, nil); err != nil || !strings.Contains(schema.String(), `"oneOf"`) {
|
||||||
|
t.Fatalf("generated JSON schema keepAlive = %s, err = %v", schema.String(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user