From 35e09191de51e61d7f724dbdbe3a2dfbb3b7371e Mon Sep 17 00:00:00 2001 From: YoungReckless4 <132759713+YoungReckless4@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:36:49 +0300 Subject: [PATCH] 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. --- .github/workflows/ci.yml | 6 +- docs/content/docs/en/config/amneziawg.mdx | 8 + .../images/amneziawg-keepalive-range.png | Bin 0 -> 66087 bytes docs/public/openapi.json | 22 +- frontend/public/openapi.json | 22 +- frontend/src/generated/examples.ts | 2 +- frontend/src/generated/schemas.ts | 22 +- frontend/src/generated/types.ts | 4 +- frontend/src/generated/zod.ts | 4 +- frontend/src/lib/xray/inbound-link.ts | 6 +- .../src/pages/clients/ClientFormModal.tsx | 64 ++- frontend/src/pages/clients/amneziawgConfig.ts | 9 +- frontend/src/pages/clients/wireguardConfig.ts | 4 +- frontend/src/schemas/client.ts | 3 +- frontend/src/schemas/primitives/keepalive.ts | 40 ++ .../schemas/protocols/inbound/amneziawg.ts | 3 +- .../src/test/amneziawg-keepalive-ui.test.tsx | 256 ++++++++++++ .../client_keepalive_migration_test.go | 194 +++++++++ .../client_keepalive_postgres_test.go | 183 +++++++++ internal/database/db.go | 11 + .../database/model/keepalive_range_test.go | 97 +++++ internal/database/model/keepalive_value.go | 116 ++++++ internal/database/model/model.go | 156 +++++--- .../database/model/model_wireguard_test.go | 2 +- internal/sub/clash_service.go | 4 +- internal/sub/clash_service_test.go | 25 ++ internal/sub/keepalive.go | 18 + internal/sub/service.go | 7 +- internal/sub/service_amneziawg_test.go | 26 +- internal/sub/service_wireguard_test.go | 6 +- internal/web/service/client_crud.go | 77 ++-- internal/web/service/client_inbound_apply.go | 34 +- internal/web/service/client_keepalive.go | 65 +++ .../service/client_keepalive_range_test.go | 374 ++++++++++++++++++ internal/web/service/client_link.go | 4 +- .../service/client_update_keepalive_test.go | 8 +- internal/web/service/inbound.go | 17 +- .../web/service/xray_wireguard_config_test.go | 10 +- internal/web/translation/ar-EG.json | 2 + internal/web/translation/en-US.json | 2 + internal/web/translation/es-ES.json | 2 + internal/web/translation/fa-IR.json | 2 + internal/web/translation/id-ID.json | 2 + internal/web/translation/ja-JP.json | 2 + internal/web/translation/pt-BR.json | 2 + internal/web/translation/ru-RU.json | 2 + internal/web/translation/tr-TR.json | 2 + internal/web/translation/uk-UA.json | 2 + internal/web/translation/vi-VN.json | 2 + internal/web/translation/zh-CN.json | 2 + internal/web/translation/zh-TW.json | 2 + tools/openapigen/emit_examples.go | 7 + tools/openapigen/emit_jsonschema.go | 6 + tools/openapigen/emit_types.go | 7 + tools/openapigen/emit_zod.go | 6 + tools/openapigen/main.go | 4 + tools/openapigen/schema.go | 14 +- tools/openapigen/walker.go | 13 +- tools/openapigen/walker_test.go | 25 ++ 59 files changed, 1871 insertions(+), 146 deletions(-) create mode 100644 docs/public/images/amneziawg-keepalive-range.png create mode 100644 frontend/src/schemas/primitives/keepalive.ts create mode 100644 frontend/src/test/amneziawg-keepalive-ui.test.tsx create mode 100644 internal/database/client_keepalive_migration_test.go create mode 100644 internal/database/client_keepalive_postgres_test.go create mode 100644 internal/database/model/keepalive_range_test.go create mode 100644 internal/database/model/keepalive_value.go create mode 100644 internal/sub/keepalive.go create mode 100644 internal/web/service/client_keepalive.go create mode 100644 internal/web/service/client_keepalive_range_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1c312129..1c7148544 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,12 +83,12 @@ jobs: - name: PostgreSQL schema and migration tests run: | 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 # otherwise leave this step green while testing nothing. passed=$(grep -c -- '^--- PASS' /tmp/postgres-schema.log || true) - if [ "$passed" -lt 4 ]; then - echo "expected at least 4 passing PostgreSQL schema tests, got $passed" >&2 + if [ "$passed" -lt 5 ]; then + echo "expected at least 5 passing PostgreSQL schema tests, got $passed" >&2 exit 1 fi diff --git a/docs/content/docs/en/config/amneziawg.mdx b/docs/content/docs/en/config/amneziawg.mdx index 51a27f747..ab4e79cfb 100644 --- a/docs/content/docs/en/config/amneziawg.mdx +++ b/docs/content/docs/en/config/amneziawg.mdx @@ -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 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. + +![AmneziaWG client editor with an ordered keepalive range](/images/amneziawg-keepalive-range.png) + ## What the configuration looks like A client's downloadable `.conf` (also what the `vpn://` share link encodes, diff --git a/docs/public/images/amneziawg-keepalive-range.png b/docs/public/images/amneziawg-keepalive-range.png new file mode 100644 index 0000000000000000000000000000000000000000..3807392d3b142cf53ba22db96cf713b66209fdf1 GIT binary patch literal 66087 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 Ir>} = { "forwardedPorts": "", "group": "", "id": "", - "keepAlive": null, + "keepAlive": 0, "limitIp": 0, "password": "", "preSharedKey": "", diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 5902ec19b..0d684072c 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -1733,9 +1733,15 @@ export const SCHEMAS: Record = { "type": "string" }, "keepAlive": { - "description": "Seconds between PersistentKeepalive packets; 0 sends none, omit to keep the stored value", - "nullable": true, - "type": "integer" + "description": "Numeric values or AmneziaWG ranges; 0 disables, omission preserves.", + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] }, "limitIp": { "description": "IP limit for this client", @@ -1955,7 +1961,15 @@ export const SCHEMAS: Record = { "type": "integer" }, "keepAlive": { - "type": "integer" + "description": "Numeric values or AmneziaWG ranges.", + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] }, "limitHwid": { "type": "integer" diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 0954c5ea5..e23ad9c37 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -420,7 +420,7 @@ export interface Client { forwardedPorts?: string; group?: string; id?: string; - keepAlive?: number | null; + keepAlive?: number | string; limitIp: number; password?: string; preSharedKey?: string; @@ -471,7 +471,7 @@ export interface ClientRecord { forwardedPorts: string; group: string; id: number; - keepAlive: number; + keepAlive: number | string; limitHwid: number; limitIp: number; password: string; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index b8fc7bafd..a9c4296ec 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -441,7 +441,7 @@ export const ClientSchema = z.object({ forwardedPorts: z.string().optional(), group: 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(), password: z.string().optional(), preSharedKey: z.string().optional(), @@ -495,7 +495,7 @@ export const ClientRecordSchema = z.object({ forwardedPorts: z.string(), group: z.string(), id: z.number().int(), - keepAlive: z.number().int(), + keepAlive: z.union([z.number().int(), z.string()]), limitHwid: z.number().int(), limitIp: z.number().int(), password: z.string(), diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index fe8d9d5fd..58215b1f2 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -1,5 +1,6 @@ import { Base64, Wireguard } from '@/utils'; import { effectiveMtu } from '@/lib/xray/amneziawg-obfuscation'; +import { AmneziaWGKeepAliveRangeSchema } from '@/schemas/primitives/keepalive'; import type { Inbound } from '@/schemas/api/inbound'; 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('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}`); } return joinConfLines(lines); diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index 97b1539de..643471e8f 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/frontend/src/pages/clients/ClientFormModal.tsx @@ -38,9 +38,14 @@ import { resolveExternalLinkExpiry } from '@/lib/clients/external-link'; import { useDatepicker } from '@/hooks/useDatepicker'; import { useClientHwids } from '@/hooks/useClientHwids'; 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 { TLS_FLOW_CONTROL, TRAFFIC_RESETS } from '@/schemas/primitives'; +import { + OptionalAmneziaWGKeepAliveInputSchema, + OptionalNumericKeepAliveInputSchema, + keepAliveInputValue, +} from '@/schemas/primitives/keepalive'; import type { ClientRecord, InboundOption, @@ -137,7 +142,7 @@ type Values = ClientFormValues & { wgAllowedIPs: string; awgAllowedIPs: string; awgForwardedPorts: string; - wgKeepAlive: number; + wgKeepAlive: number | string | undefined; secret: string; adTag: string; }; @@ -389,7 +394,7 @@ export default function ClientFormModal({ wgAllowedIPs: wgTunnelIPs ?? client.allowedIPs ?? '', awgAllowedIPs: awgTunnelIPs ?? client.allowedIPs ?? '', awgForwardedPorts: client.forwardedPorts || '', - wgKeepAlive: client.keepAlive ?? 0, + wgKeepAlive: client.keepAlive, secret: client.secret || '', adTag: client.adTag || '', }; @@ -641,6 +646,25 @@ export default function ClientFormModal({ async function onSubmit() { 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 validated = schema.safeParse({ email: values.email, @@ -712,7 +736,9 @@ export default function ClientFormModal({ // so both protocols share this one field set — see wgPrivateKey etc. // below and the AmneziaWG-labeled variants of the same inputs. clientPayload.privateKey = values.wgPrivateKey; - clientPayload.keepAlive = values.wgKeepAlive; + if (!unchangedLegacyKeepAlive && values.wgKeepAlive !== undefined) { + clientPayload.keepAlive = values.wgKeepAlive; + } clientPayload.publicKey = values.wgPublicKey; if (values.wgPreSharedKey) { clientPayload.preSharedKey = values.wgPreSharedKey; @@ -1339,10 +1365,34 @@ export default function ClientFormModal({ Number(v) || 0 }} + extra={t( + 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 + } > - + {showAmneziawg && !showWireguard ? ( + + ) : ( + + )} {showAmneziawg && ( 0) + if (typeof client.keepAlive === 'string') { + if (!AmneziaWGKeepAliveRangeSchema.safeParse(client.keepAlive).success) return ''; lines.push(`PersistentKeepalive = ${client.keepAlive}`); + } else if (client.keepAlive && client.keepAlive > 0) { + lines.push(`PersistentKeepalive = ${client.keepAlive}`); + } return joinConfLines(lines); } diff --git a/frontend/src/pages/clients/wireguardConfig.ts b/frontend/src/pages/clients/wireguardConfig.ts index b63d3e05c..a58297d3e 100644 --- a/frontend/src/pages/clients/wireguardConfig.ts +++ b/frontend/src/pages/clients/wireguardConfig.ts @@ -11,7 +11,7 @@ export function isWireguardClient(client: ClientRecord | null | undefined): bool client.publicKey || client.allowedIPs || client.preSharedKey || - client.keepAlive + typeof client.keepAlive === 'number' ); } @@ -53,7 +53,7 @@ export function buildWireguardClientConfig( lines.push('[Peer]', `PublicKey = ${inbound?.wgPublicKey || ''}`); if (client.preSharedKey) lines.push(`PresharedKey = ${client.preSharedKey}`); 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}`); return joinConfLines(lines); } diff --git a/frontend/src/schemas/client.ts b/frontend/src/schemas/client.ts index 8dfe1206b..f0447e953 100644 --- a/frontend/src/schemas/client.ts +++ b/frontend/src/schemas/client.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { OptionalClientKeepAliveSchema } from '@/schemas/primitives/keepalive'; const nullableStringArray = z .array(z.string()) @@ -52,7 +53,7 @@ export const ClientRecordSchema = z publicKey: z.string().optional(), allowedIPs: z.string().optional(), preSharedKey: z.string().optional(), - keepAlive: z.number().optional(), + keepAlive: OptionalClientKeepAliveSchema, forwardedPorts: z.string().optional(), secret: z.string().optional(), adTag: z.string().optional(), diff --git a/frontend/src/schemas/primitives/keepalive.ts b/frontend/src/schemas/primitives/keepalive.ts new file mode 100644 index 000000000..546a0b1ae --- /dev/null +++ b/frontend/src/schemas/primitives/keepalive.ts @@ -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; +} diff --git a/frontend/src/schemas/protocols/inbound/amneziawg.ts b/frontend/src/schemas/protocols/inbound/amneziawg.ts index 5c00ce0ef..e781c73a2 100644 --- a/frontend/src/schemas/protocols/inbound/amneziawg.ts +++ b/frontend/src/schemas/protocols/inbound/amneziawg.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { OptionalClientKeepAliveSchema } from '@/schemas/primitives/keepalive'; // 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 @@ -23,7 +24,7 @@ export const AmneziawgClientSchema = z.object({ publicKey: z.string().optional(), preSharedKey: z.string().optional(), allowedIPs: z.array(z.string()).default([]), - keepAlive: optionalClearedInt(z.number().int().min(0)), + keepAlive: OptionalClientKeepAliveSchema, forwardedPorts: z.string().default(''), email: z.string().min(1), limitIp: z.number().int().min(0).default(0), diff --git a/frontend/src/test/amneziawg-keepalive-ui.test.tsx b/frontend/src/test/amneziawg-keepalive-ui.test.tsx new file mode 100644 index 000000000..e52946b74 --- /dev/null +++ b/frontend/src/test/amneziawg-keepalive-ui.test.tsx @@ -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( + + {}} + /> + , + ); + 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('.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( + + {}} + /> + , + ); + 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).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( + + {}} + /> + , + ); + 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( + + {}} + /> + , + ); + 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( + + {}} + /> + , + ); + 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); + }); +}); diff --git a/internal/database/client_keepalive_migration_test.go b/internal/database/client_keepalive_migration_test.go new file mode 100644 index 000000000..54ef07b47 --- /dev/null +++ b/internal/database/client_keepalive_migration_test.go @@ -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) + } +} diff --git a/internal/database/client_keepalive_postgres_test.go b/internal/database/client_keepalive_postgres_test.go new file mode 100644 index 000000000..971bcab1b --- /dev/null +++ b/internal/database/client_keepalive_postgres_test.go @@ -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) + } +} diff --git a/internal/database/db.go b/internal/database/db.go index 01704fa9f..705469c70 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -112,7 +112,18 @@ func migrateInboundExcludeFromSubColumn() error { 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 { + if err := migrateClientKeepAliveRangeColumn(); err != nil { + return err + } if err := migrateClientTrafficLastSubFetchColumn(); err != nil { return err } diff --git a/internal/database/model/keepalive_range_test.go b/internal/database/model/keepalive_range_test.go new file mode 100644 index 000000000..ad87859a7 --- /dev/null +++ b/internal/database/model/keepalive_range_test.go @@ -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) + } +} diff --git a/internal/database/model/keepalive_value.go b/internal/database/model/keepalive_value.go new file mode 100644 index 000000000..0aac4e999 --- /dev/null +++ b/internal/database/model/keepalive_value.go @@ -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 +} diff --git a/internal/database/model/model.go b/internal/database/model/model.go index 4a3a3edbe..355ba4234 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -7,8 +7,11 @@ import ( "encoding/hex" "encoding/json" "fmt" + "strconv" "strings" + "gorm.io/gorm" + "github.com/mhsanaei/3x-ui/v3/internal/util/json_util" "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. AllowedIPsByInbound map[int][]string `json:"allowedIPsByInbound,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" Secret string `json:"secret,omitempty" example:"ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d"` AdTag string `json:"adTag,omitempty" example:"0123456789abcdef0123456789abcdef"` @@ -969,39 +972,41 @@ type Client struct { } type ClientRecord struct { - Id int `json:"id" gorm:"primaryKey;autoIncrement"` - Email string `json:"email" gorm:"uniqueIndex;not null"` - SubID string `json:"subId" gorm:"index;column:sub_id"` - UUID string `json:"uuid" gorm:"column:uuid"` - Password string `json:"password"` - Auth string `json:"auth"` - Flow string `json:"flow"` - Security string `json:"security"` - Reverse string `json:"reverse" gorm:"column:reverse"` - PrivateKey string `json:"privateKey" gorm:"column:wg_private_key"` - PublicKey string `json:"publicKey" gorm:"column:wg_public_key"` - AllowedIPs string `json:"allowedIPs" gorm:"column:wg_allowed_ips"` - PreSharedKey string `json:"preSharedKey" gorm:"column:wg_pre_shared_key"` - KeepAlive int `json:"keepAlive" gorm:"column:wg_keep_alive;default:0"` - ForwardedPorts string `json:"forwardedPorts" gorm:"column:wg_forwarded_ports"` - Secret string `json:"secret" gorm:"column:secret"` - AdTag string `json:"adTag" gorm:"column:ad_tag;default:''"` - LimitIP int `json:"limitIp" gorm:"column:limit_ip"` - LimitHwid int `json:"limitHwid" gorm:"column:limit_hwid;default:0"` - TotalGB int64 `json:"totalGB" gorm:"column:total_gb"` - ExpiryTime int64 `json:"expiryTime" gorm:"column:expiry_time"` - Enable bool `json:"enable" gorm:"default:true"` - TgID int64 `json:"tgId" gorm:"column:tg_id;index:idx_clients_tg_id"` - Group string `json:"group" gorm:"column:group_name;default:'';index:idx_client_record_group"` - Comment string `json:"comment"` - Reset int `json:"reset" gorm:"default:0"` - ResetDay int `json:"resetDay" gorm:"column:reset_day;default:0"` - ResetWeekday int `json:"resetWeekday" gorm:"column:reset_weekday;default:0"` - ResetMax int `json:"resetMax" gorm:"column:reset_max;default:0"` - TrafficReset string `json:"trafficReset" gorm:"column:traffic_reset;default:never;index:idx_clients_traffic_reset"` - 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"` + Id int `json:"id" gorm:"primaryKey;autoIncrement"` + Email string `json:"email" gorm:"uniqueIndex;not null"` + SubID string `json:"subId" gorm:"index;column:sub_id"` + UUID string `json:"uuid" gorm:"column:uuid"` + Password string `json:"password"` + Auth string `json:"auth"` + Flow string `json:"flow"` + Security string `json:"security"` + Reverse string `json:"reverse" gorm:"column:reverse"` + PrivateKey string `json:"privateKey" gorm:"column:wg_private_key"` + PublicKey string `json:"publicKey" gorm:"column:wg_public_key"` + AllowedIPs string `json:"allowedIPs" gorm:"column:wg_allowed_ips"` + PreSharedKey string `json:"preSharedKey" gorm:"column:wg_pre_shared_key"` + KeepAlive KeepAliveValue `json:"keepAlive" gorm:"-"` // Numeric values or AmneziaWG ranges. + KeepAliveLegacy int `json:"-" gorm:"column:wg_keep_alive;default:0"` + KeepAliveRange KeepAliveValue `json:"-" gorm:"column:wg_keep_alive_range;type:text;default:''"` + ForwardedPorts string `json:"forwardedPorts" gorm:"column:wg_forwarded_ports"` + Secret string `json:"secret" gorm:"column:secret"` + AdTag string `json:"adTag" gorm:"column:ad_tag;default:''"` + LimitIP int `json:"limitIp" gorm:"column:limit_ip"` + LimitHwid int `json:"limitHwid" gorm:"column:limit_hwid;default:0"` + TotalGB int64 `json:"totalGB" gorm:"column:total_gb"` + ExpiryTime int64 `json:"expiryTime" gorm:"column:expiry_time"` + Enable bool `json:"enable" gorm:"default:true"` + TgID int64 `json:"tgId" gorm:"column:tg_id;index:idx_clients_tg_id"` + Group string `json:"group" gorm:"column:group_name;default:'';index:idx_client_record_group"` + Comment string `json:"comment"` + Reset int `json:"reset" gorm:"default:0"` + ResetDay int `json:"resetDay" gorm:"column:reset_day;default:0"` + ResetWeekday int `json:"resetWeekday" gorm:"column:reset_weekday;default:0"` + ResetMax int `json:"resetMax" gorm:"column:reset_max;default:0"` + TrafficReset string `json:"trafficReset" gorm:"column:traffic_reset;default:never;index:idx_clients_traffic_reset"` + 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 // deleting; orphans from any other cause stay at zero and are never reaped. SyncOrphanedAt int64 `json:"-" gorm:"column:sync_orphaned_at;default:0"` @@ -1009,6 +1014,47 @@ type ClientRecord struct { 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 { Id int `json:"id" gorm:"primaryKey;autoIncrement"` Name string `json:"name" gorm:"uniqueIndex;not null"` @@ -1171,25 +1217,39 @@ type Host struct { 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 { if c.KeepAlive == nil { return 0 } - return *c.KeepAlive + return c.KeepAlive.Seconds() } -// KeepAlivePtr wraps an explicit PersistentKeepalive, 0 included -- distinct -// from a nil KeepAlive, which means the field was never sent. -func KeepAlivePtr(v int) *int { return &v } +func (c Client) KeepAliveText() string { + if c.KeepAlive == nil { + return "" + } + return c.KeepAlive.String() +} -// nonZeroKeepAlive keeps a stored 0 nil: wg_keep_alive cannot tell "off" from -// "never set", and an explicit 0 would emit keepAlive on every protocol. -func nonZeroKeepAlive(seconds int) *int { - if seconds <= 0 { +func KeepAlivePtr(v int) *KeepAliveValue { + value := KeepAliveValue(fmt.Sprintf("%d", v)) + return &value +} + +func keepAlivePtr(value KeepAliveValue) *KeepAliveValue { + if value == "" { 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 { @@ -1221,7 +1281,7 @@ func (c *Client) ToRecord() *ClientRecord { PublicKey: c.PublicKey, AllowedIPs: strings.Join(c.AllowedIPs, ","), PreSharedKey: c.PreSharedKey, - KeepAlive: c.KeepAliveSeconds(), + KeepAlive: keepAliveValue(c.KeepAlive), ForwardedPorts: c.ForwardedPorts, Secret: c.Secret, AdTag: c.AdTag, @@ -1280,7 +1340,7 @@ func (r *ClientRecord) ToClient() *Client { PublicKey: r.PublicKey, AllowedIPs: splitWireguardAllowedIPs(r.AllowedIPs), PreSharedKey: r.PreSharedKey, - KeepAlive: nonZeroKeepAlive(r.KeepAlive), + KeepAlive: keepAlivePtr(r.KeepAlive), ForwardedPorts: r.ForwardedPorts, Secret: r.Secret, AdTag: r.AdTag, @@ -1524,8 +1584,8 @@ func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientM existing.AllowedIPs = incoming.AllowedIPs } } - if existing.KeepAlive != incoming.KeepAlive && incoming.KeepAlive != 0 { - if incomingNewer || existing.KeepAlive == 0 { + if incoming.KeepAlive != "" && existing.KeepAlive != incoming.KeepAlive { + if incomingNewer || existing.KeepAlive == "" { keep("keepAlive", existing.KeepAlive, incoming.KeepAlive, incoming.KeepAlive) existing.KeepAlive = incoming.KeepAlive } diff --git a/internal/database/model/model_wireguard_test.go b/internal/database/model/model_wireguard_test.go index fb7e4d1e3..f2d7b8898 100644 --- a/internal/database/model/model_wireguard_test.go +++ b/internal/database/model/model_wireguard_test.go @@ -48,7 +48,7 @@ func TestClientToClientOmitsUnsetKeepAlive(t *testing.T) { rec := &ClientRecord{Email: "vless@example.test", UUID: "11111111-2222-3333-4444-555555555555", Enable: true} 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()}}) if err != nil { diff --git a/internal/sub/clash_service.go b/internal/sub/clash_service.go index db0e9c0fd..8af51c57d 100644 --- a/internal/sub/clash_service.go +++ b/internal/sub/clash_service.go @@ -652,7 +652,7 @@ func (s *SubClashService) buildWireguardProxy(subReq *SubService, inbound *model if client.PreSharedKey != "" { proxy["pre-shared-key"] = client.PreSharedKey } - if ka := client.KeepAliveSeconds(); ka > 0 { + if ka, ok := keepAliveForScalarExport(client); ok { proxy["persistent-keepalive"] = ka } for _, addr := range client.AllowedIPs { @@ -794,7 +794,7 @@ func (s *SubClashService) buildAmneziaWGProxy(subReq *SubService, inbound *model if client.PreSharedKey != "" { proxy["pre-shared-key"] = client.PreSharedKey } - if ka := client.KeepAliveSeconds(); ka > 0 { + if ka, ok := keepAliveForScalarExport(client); ok { proxy["persistent-keepalive"] = ka } diff --git a/internal/sub/clash_service_test.go b/internal/sub/clash_service_test.go index 092058f98..36b364352 100644 --- a/internal/sub/clash_service_test.go +++ b/internal/sub/clash_service_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "reflect" + "strings" "testing" "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"]) + } +} diff --git a/internal/sub/keepalive.go b/internal/sub/keepalive.go new file mode 100644 index 000000000..f3416b62b --- /dev/null +++ b/internal/sub/keepalive.go @@ -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 +} diff --git a/internal/sub/service.go b/internal/sub/service.go index ba6a00219..49a3bbefd 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -1057,8 +1057,11 @@ func amneziaWGConfigText(server *amneziawg.ServerSettings, client *model.Client, lines = append(lines, "PresharedKey = "+client.PreSharedKey) } lines = append(lines, "AllowedIPs = 0.0.0.0/0, ::/0", fmt.Sprintf("Endpoint = %s:%d", host, port)) - if ka := client.KeepAliveSeconds(); ka > 0 { - lines = append(lines, fmt.Sprintf("PersistentKeepalive = %d", ka)) + if client.KeepAlive != nil { + 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 diff --git a/internal/sub/service_amneziawg_test.go b/internal/sub/service_amneziawg_test.go index 3bf489e31..40d13c7d5 100644 --- a/internal/sub/service_amneziawg_test.go +++ b/internal/sub/service_amneziawg_test.go @@ -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) { client := &model.Client{PrivateKey: "clientPriv", AllowedIPs: []string{"10.8.1.2/32"}} conf := amneziaWGConfigText(server, client, "203.0.113.7", 51820, "remark") @@ -364,7 +382,7 @@ func TestGetSubs_PreservesPerInboundAmneziaWGIdentity(t *testing.T) { rec := &model.ClientRecord{ Email: email, SubID: subID, Enable: true, PrivateKey: mergedPriv, AllowedIPs: mergedAddr, - PreSharedKey: "sharedpsk", KeepAlive: 25, + PreSharedKey: "sharedpsk", KeepAlive: model.KeepAliveValue("25"), } if err := db.Create(rec).Error; err != nil { t.Fatalf("create client: %v", err) @@ -452,7 +470,7 @@ func TestGetSubs_AmneziaWGUnavailableSettingsEmitNoSharedConfig(t *testing.T) { rec := &model.ClientRecord{ Email: email, SubID: subID, Enable: true, PrivateKey: mergedPriv, AllowedIPs: mergedAddr, - PreSharedKey: "sharedpsk", KeepAlive: 25, + PreSharedKey: "sharedpsk", KeepAlive: model.KeepAliveValue("25"), } if err := db.Create(rec).Error; err != nil { t.Fatalf("create client: %v", err) @@ -515,7 +533,7 @@ func TestGetSubs_AmneziaWGEmptyOptionalTunnelFieldsDoNotInheritShared(t *testing rec := &model.ClientRecord{ Email: email, SubID: subID, Enable: true, PrivateKey: mergedPriv, AllowedIPs: mergedAddr, - PreSharedKey: "sharedpsk", KeepAlive: 25, + PreSharedKey: "sharedpsk", KeepAlive: model.KeepAliveValue("25"), } if err := db.Create(rec).Error; err != nil { t.Fatalf("create client: %v", err) @@ -580,7 +598,7 @@ func TestMatchingClients_TunnelMetadataStaysNormalized(t *testing.T) { Email: email, SubID: subID, UUID: freshID, Enable: true, TotalGB: 5, ExpiryTime: expiry, Comment: "vip", LimitIP: 3, 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{ Email: "other-sub@awg", SubID: "other-sub", UUID: "22222222-2222-4333-8444-555555555555", Enable: true, diff --git a/internal/sub/service_wireguard_test.go b/internal/sub/service_wireguard_test.go index 2fb350c3d..d58e8ec68 100644 --- a/internal/sub/service_wireguard_test.go +++ b/internal/sub/service_wireguard_test.go @@ -206,7 +206,7 @@ func TestGetSubs_PreservesPerInboundWireGuardIdentity(t *testing.T) { rec := &model.ClientRecord{ Email: email, SubID: subID, Enable: true, PrivateKey: mergedPriv, AllowedIPs: mergedAddr, - PreSharedKey: "sharedpsk", KeepAlive: 25, + PreSharedKey: "sharedpsk", KeepAlive: model.KeepAliveValue("25"), } if err := db.Create(rec).Error; err != nil { t.Fatalf("create client: %v", err) @@ -298,7 +298,7 @@ func TestGetSubs_WireGuardUnavailableSettingsEmitNoSharedConfig(t *testing.T) { rec := &model.ClientRecord{ Email: email, SubID: subID, Enable: true, PrivateKey: mergedPriv, AllowedIPs: mergedAddr, - PreSharedKey: "sharedpsk", KeepAlive: 25, + PreSharedKey: "sharedpsk", KeepAlive: model.KeepAliveValue("25"), } if err := db.Create(rec).Error; err != nil { t.Fatalf("create client: %v", err) @@ -358,7 +358,7 @@ func TestGetSubs_WireGuardEmptyOptionalTunnelFieldsDoNotInheritShared(t *testing rec := &model.ClientRecord{ Email: email, SubID: subID, Enable: true, 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 { t.Fatalf("create client: %v", err) diff --git a/internal/web/service/client_crud.go b/internal/web/service/client_crud.go index 2cee1734e..95126b316 100644 --- a/internal/web/service/client_crud.go +++ b/internal/web/service/client_crud.go @@ -432,6 +432,13 @@ func markInboundNodesDirty(inboundIds []int) 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 { case model.VMESS, model.VLESS: 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 { 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) if updated.SubID == "" { updated.SubID = existing.SubID @@ -765,7 +789,7 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model if existing.Email == "" { continue } - if err := s.fillProtocolDefaults(&updated, inbound); err != nil { + if err := s.fillProtocolDefaultsAgainstExisting(&updated, inbound, existingClientsByInbound[ibId], existing.Email); err != nil { return false, err } clientForInbound := updated @@ -822,32 +846,37 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model if len(inboundIds) == 0 { merged := *existing applyClientRecordMerge(&merged, updated.ToRecord()) + keepAliveLegacy, keepAliveRange, keepAliveErr := model.KeepAliveStorage(merged.KeepAlive) + if keepAliveErr != nil { + return needRestart, keepAliveErr + } if err := database.GetDB().Model(&model.ClientRecord{}). Where("id = ?", id). Updates(map[string]any{ - "sub_id": merged.SubID, - "uuid": merged.UUID, - "password": merged.Password, - "auth": merged.Auth, - "secret": merged.Secret, - "flow": merged.Flow, - "security": merged.Security, - "wg_private_key": merged.PrivateKey, - "wg_public_key": merged.PublicKey, - "wg_allowed_ips": merged.AllowedIPs, - "wg_pre_shared_key": merged.PreSharedKey, - "wg_keep_alive": merged.KeepAlive, - "limit_ip": merged.LimitIP, - "total_gb": merged.TotalGB, - "expiry_time": merged.ExpiryTime, - "tg_id": merged.TgID, - "comment": merged.Comment, - "reset": merged.Reset, - "reset_day": merged.ResetDay, - "reset_weekday": merged.ResetWeekday, - "reset_max": merged.ResetMax, - "traffic_reset": merged.TrafficReset, - "traffic_reset_day": merged.TrafficResetDay, + "sub_id": merged.SubID, + "uuid": merged.UUID, + "password": merged.Password, + "auth": merged.Auth, + "secret": merged.Secret, + "flow": merged.Flow, + "security": merged.Security, + "wg_private_key": merged.PrivateKey, + "wg_public_key": merged.PublicKey, + "wg_allowed_ips": merged.AllowedIPs, + "wg_pre_shared_key": merged.PreSharedKey, + "wg_keep_alive": keepAliveLegacy, + "wg_keep_alive_range": keepAliveRange, + "limit_ip": merged.LimitIP, + "total_gb": merged.TotalGB, + "expiry_time": merged.ExpiryTime, + "tg_id": merged.TgID, + "comment": merged.Comment, + "reset": merged.Reset, + "reset_day": merged.ResetDay, + "reset_weekday": merged.ResetWeekday, + "reset_max": merged.ResetMax, + "traffic_reset": merged.TrafficReset, + "traffic_reset_day": merged.TrafficResetDay, }).Error; err != nil { return needRestart, err } diff --git a/internal/web/service/client_inbound_apply.go b/internal/web/service/client_inbound_apply.go index b0fd16e7c..c342d8383 100644 --- a/internal/web/service/client_inbound_apply.go +++ b/internal/web/service/client_inbound_apply.go @@ -334,6 +334,17 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model if err := validateClientsRenewal(clients); err != nil { 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 err = json.Unmarshal([]byte(data.Settings), &settings) @@ -372,11 +383,6 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model 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: // checkEmailsExistForClients exempts a matching subId so one identity can // 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 { return false, err } - + inbound, err := inboundSvc.GetInbound(data.Id) + if err != nil { + return false, err + } var settings map[string]any err = json.Unmarshal([]byte(data.Settings), &settings) if err != nil { @@ -691,6 +700,15 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo if err != nil { 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 := "" switch oldInbound.Protocol { @@ -844,8 +862,8 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo if clients[0].PreSharedKey != "" { newMap["preSharedKey"] = clients[0].PreSharedKey } - if ka := clients[0].KeepAliveSeconds(); ka > 0 { - newMap["keepAlive"] = ka + if clients[0].KeepAlive != nil { + newMap["keepAlive"] = clients[0].KeepAlive } if oldInbound.Protocol == model.AmneziaWG && clients[0].ForwardedPorts != "" { newMap["forwardedPorts"] = clients[0].ForwardedPorts diff --git a/internal/web/service/client_keepalive.go b/internal/web/service/client_keepalive.go new file mode 100644 index 000000000..85bfc69f0 --- /dev/null +++ b/internal/web/service/client_keepalive.go @@ -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 +} diff --git a/internal/web/service/client_keepalive_range_test.go b/internal/web/service/client_keepalive_range_test.go new file mode 100644 index 000000000..47ac5d27e --- /dev/null +++ b/internal/web/service/client_keepalive_range_test.go @@ -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) + } +} diff --git a/internal/web/service/client_link.go b/internal/web/service/client_link.go index 61058b161..06b78c16a 100644 --- a/internal/web/service/client_link.go +++ b/internal/web/service/client_link.go @@ -55,7 +55,9 @@ func applyClientRecordMerge(row *model.ClientRecord, incoming *model.ClientRecor row.AllowedIPs = incoming.AllowedIPs } row.PreSharedKey = incoming.PreSharedKey - row.KeepAlive = incoming.KeepAlive + if incoming.KeepAlive != "" { + row.KeepAlive = incoming.KeepAlive + } row.SubID = incoming.SubID row.LimitIP = incoming.LimitIP row.TotalGB = incoming.TotalGB diff --git a/internal/web/service/client_update_keepalive_test.go b/internal/web/service/client_update_keepalive_test.go index f0b19ccde..dd618e848 100644 --- a/internal/web/service/client_update_keepalive_test.go +++ b/internal/web/service/client_update_keepalive_test.go @@ -70,8 +70,8 @@ func TestUpdateCanClearKeepAliveOnAnExistingClient(t *testing.T) { if got := inboundKeepAlive(t, inboundSvc, ib.Id, "ka@x"); got != 0 { t.Fatalf("inbound keepAlive after an explicit 0 = %d, want 0", got) } - if got := lookupClientRecord(t, "ka@x").KeepAlive; got != 0 { - t.Fatalf("stored wg_keep_alive after an explicit 0 = %d, want 0", got) + if got := lookupClientRecord(t, "ka@x").KeepAlive.String(); got != "0" { + 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 { t.Fatalf("inbound keepAlive after an edit that omitted it = %d, want 25", got) } - if got := lookupClientRecord(t, "ka@x").KeepAlive; got != 25 { - t.Fatalf("stored wg_keep_alive after an edit that omitted it = %d, want 25", got) + if got := lookupClientRecord(t, "ka@x").KeepAlive.String(); got != "25" { + t.Fatalf("stored wg_keep_alive after an edit that omitted it = %s, want 25", got) } } diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 0c303a50c..f82a260a0 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -1338,6 +1338,9 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo if err := validateClientsRenewal(clients); err != nil { return inbound, false, err } + if err := validateClientsKeepAlive(inbound.Protocol, clients); err != nil { + return inbound, false, err + } for _, traffic := range inbound.ClientStats { if err := validateClientRenewal(model.Client{Reset: traffic.Reset, ResetDay: traffic.ResetDay, ResetWeekday: traffic.ResetWeekday}); err != nil { return inbound, false, err @@ -1912,7 +1915,7 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) { 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) if err != nil { return err @@ -1920,6 +1923,16 @@ func (s *InboundService) validateUpdatedInboundClients(inbound *model.Inbound) e if err := validateClientsRenewal(clients); err != nil { 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 { switch inbound.Protocol { case model.Hysteria: @@ -2038,7 +2051,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, inbound.Enable = stored.Enable } // 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 } conflict, cErr := checkPortConflictTx(tx, inbound, inbound.Id) diff --git a/internal/web/service/xray_wireguard_config_test.go b/internal/web/service/xray_wireguard_config_test.go index 61aed076c..eef8d3093 100644 --- a/internal/web/service/xray_wireguard_config_test.go +++ b/internal/web/service/xray_wireguard_config_test.go @@ -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() setupSettingTestDB(t) db := database.GetDB() @@ -202,8 +202,8 @@ func TestGetXrayConfigWireGuardUsesInboundLocalTunnelFields(t *testing.T) { if err := database.GetDB().Where("email = ?", email).First(&shared).Error; err != nil { 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 { - 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) + 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.Seconds()) } 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 { t.Fatalf("read shared client: %v", err) } - if shared.PublicKey != "pub-awg" || shared.KeepAlive != 25 { - t.Fatalf("test setup did not persist AmneziaWG identity: publicKey=%q keepAlive=%d", shared.PublicKey, shared.KeepAlive) + 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.Seconds()) } peers := wgPeerList(t, wgInboundEmittedSettings(t, "wg-dual")) diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 9892f1593..16bbccfc8 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -983,6 +983,8 @@ "amneziaWgForwardedPorts": "المنافذ المُعاد توجيهها", "amneziaWgForwardedPortsHint": "المنافذ/النطاقات المُعاد توجيهها (DNAT) لهذا العميل، مثل 80, 443, 8000-8100. اتركها فارغة إن لم تكن مطلوبة.", "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.", "amneziaWgConfig": "إعدادات AmneziaWG", "tuicConfig": "إعدادات TUIC (Clash)", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 8d2aa4c4d..c7d36315f 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -983,6 +983,8 @@ "amneziaWgForwardedPorts": "Forwarded Ports", "amneziaWgForwardedPortsHint": "Ports/ranges DNAT'd to this client, e.g. 80, 443, 8000-8100. Leave empty for none.", "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.", "amneziaWgConfig": "AmneziaWG config", "tuicConfig": "TUIC config (Clash)", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 2f6daab46..ebad353cb 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -983,6 +983,8 @@ "amneziaWgForwardedPorts": "Puertos reenviados", "amneziaWgForwardedPortsHint": "Puertos/rangos redirigidos (DNAT) a este cliente, p. ej. 80, 443, 8000-8100. Déjalo vacío si no aplica.", "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.", "amneziaWgConfig": "Configuración de AmneziaWG", "tuicConfig": "Configuración de TUIC (Clash)", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 9fc7e456c..bac8bc70a 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -983,6 +983,8 @@ "amneziaWgForwardedPorts": "پورت‌های هدایت‌شده", "amneziaWgForwardedPortsHint": "پورت‌ها/محدوده‌های DNAT شده به این کلاینت، مثلاً 80, 443, 8000-8100. برای غیرفعال بودن خالی بگذارید.", "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.", "amneziaWgConfig": "پیکربندی AmneziaWG", "tuicConfig": "پیکربندی TUIC (Clash)", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index d15077f36..1051744c3 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -983,6 +983,8 @@ "amneziaWgForwardedPorts": "Port yang Diteruskan", "amneziaWgForwardedPortsHint": "Port/rentang yang di-DNAT ke klien ini, mis. 80, 443, 8000-8100. Biarkan kosong jika tidak ada.", "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.", "amneziaWgConfig": "Konfigurasi AmneziaWG", "tuicConfig": "Konfigurasi TUIC (Clash)", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 4dd2d7d89..ceb53e79d 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -983,6 +983,8 @@ "amneziaWgForwardedPorts": "転送ポート", "amneziaWgForwardedPortsHint": "このクライアントに転送するポート/範囲。例: 80, 443, 8000-8100。空欄で転送なし。", "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.", "amneziaWgConfig": "AmneziaWG 設定", "tuicConfig": "TUIC 設定 (Clash)", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index b27449b32..833c8c5d0 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -983,6 +983,8 @@ "amneziaWgForwardedPorts": "Portas encaminhadas", "amneziaWgForwardedPortsHint": "Portas/intervalos redirecionados (DNAT) para este cliente, ex. 80, 443, 8000-8100. Deixe vazio se não aplicável.", "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.", "amneziaWgConfig": "Configuração do AmneziaWG", "tuicConfig": "Configuração do TUIC (Clash)", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index ede11218f..3b7c45088 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -983,6 +983,8 @@ "amneziaWgForwardedPorts": "Проброс портов", "amneziaWgForwardedPortsHint": "Порты/диапазоны, DNAT'ящиеся на этого клиента, например 80, 443, 8000-8100. Оставьте пустым, если не нужно.", "tunnelKeepAlive": "Keepalive (секунды)", + "amneziaWgKeepAliveHint": "Укажите секунды числом или диапазоном (например, 15-25). Значение должно быть от 0 до 65535; 0 отключает keepalive.", + "amneziaWgKeepAliveInvalid": "Введите целое число или диапазон от 0 до 65535.", "tunnelKeepAliveHint": "Как часто клиент шлёт keepalive-пакет. Обычное значение 25: оно держит NAT-маппинг открытым и возвращает простаивающий пир сам после любого разрыва, включая рестарт панели. 0 отключает — тогда молчащий клиент остаётся отключённым до первого своего пакета.", "amneziaWgConfig": "Конфиг AmneziaWG", "tuicConfig": "Конфиг TUIC (Clash)", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 8f2faa633..49d2d203f 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -983,6 +983,8 @@ "amneziaWgForwardedPorts": "Yönlendirilen Portlar", "amneziaWgForwardedPortsHint": "Bu istemciye DNAT ile yönlendirilen port/aralıklar, örn. 80, 443, 8000-8100. Yoksa boş bırakın.", "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.", "amneziaWgConfig": "AmneziaWG Yapılandırması", "tuicConfig": "TUIC Yapılandırması (Clash)", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index fde1deb65..b2e205b8e 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -983,6 +983,8 @@ "amneziaWgForwardedPorts": "Перенаправлені порти", "amneziaWgForwardedPortsHint": "Порти/діапазони, що перенаправляються (DNAT) на цього клієнта, напр. 80, 443, 8000-8100. Залиште порожнім, якщо не потрібно.", "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.", "amneziaWgConfig": "Конфігурація AmneziaWG", "tuicConfig": "Конфігурація TUIC (Clash)", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 210fc6556..12e227fc8 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -983,6 +983,8 @@ "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.", "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.", "amneziaWgConfig": "Cấu hình AmneziaWG", "tuicConfig": "Cấu hình TUIC (Clash)", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 3b798295c..7db446c69 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -983,6 +983,8 @@ "amneziaWgForwardedPorts": "转发端口", "amneziaWgForwardedPortsHint": "转发到此客户端的端口/范围,例如 80, 443, 8000-8100。留空则不转发。", "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.", "amneziaWgConfig": "AmneziaWG 配置", "tuicConfig": "TUIC 配置 (Clash)", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 8f5d9adb2..91aa12c79 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -983,6 +983,8 @@ "amneziaWgForwardedPorts": "轉發連接埠", "amneziaWgForwardedPortsHint": "轉發到此客戶端的連接埠/範圍,例如 80, 443, 8000-8100。留空則不轉發。", "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.", "amneziaWgConfig": "AmneziaWG 設定", "tuicConfig": "TUIC 設定 (Clash)", diff --git a/tools/openapigen/emit_examples.go b/tools/openapigen/emit_examples.go index a0ecbcdff..33c4292fa 100644 --- a/tools/openapigen/emit_examples.go +++ b/tools/openapigen/emit_examples.go @@ -86,6 +86,10 @@ func (g *exampleGen) forType(t TypeRef, visited map[string]bool) any { return []any{g.forType(*t.Element, visited)} case KindMap: return map[string]any{} + case KindUnion: + if len(t.Variants) > 0 { + return g.forType(t.Variants[0], visited) + } case KindRef: if t.Name == "nullable" { return nil @@ -105,6 +109,9 @@ func (g *exampleGen) forType(t TypeRef, visited map[string]bool) any { } 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 { return *t.Inner } diff --git a/tools/openapigen/emit_jsonschema.go b/tools/openapigen/emit_jsonschema.go index 9083add7f..549376bde 100644 --- a/tools/openapigen/emit_jsonschema.go +++ b/tools/openapigen/emit_jsonschema.go @@ -110,6 +110,12 @@ func (g *schemaGen) typeSchema(t TypeRef) map[string]any { return map[string]any{"type": "array", "items": g.typeSchema(*t.Element)} case KindMap: 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: return map[string]any{} case KindRef: diff --git a/tools/openapigen/emit_types.go b/tools/openapigen/emit_types.go index 338231a06..8f2cc49e4 100644 --- a/tools/openapigen/emit_types.go +++ b/tools/openapigen/emit_types.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "sort" + "strings" ) func emitTypes(w io.Writer, schemas []Schema, aliases []Alias) error { @@ -60,6 +61,12 @@ func tsTypeExpr(t TypeRef) string { return tsTypeExpr(*t.Element) + "[]" case KindMap: 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: if t.Name == "nullable" { return tsTypeExpr(*t.Inner) + " | null" diff --git a/tools/openapigen/emit_zod.go b/tools/openapigen/emit_zod.go index a5ece3916..48f4da100 100644 --- a/tools/openapigen/emit_zod.go +++ b/tools/openapigen/emit_zod.go @@ -70,6 +70,12 @@ func zodTypeExpr(t TypeRef) string { return "z.array(" + zodTypeExpr(*t.Element) + ")" case KindMap: 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: if t.Name == "nullable" { return zodTypeExpr(*t.Inner) + ".nullable()" diff --git a/tools/openapigen/main.go b/tools/openapigen/main.go index 04abedb49..aa3cc3a99 100644 --- a/tools/openapigen/main.go +++ b/tools/openapigen/main.go @@ -48,7 +48,11 @@ func run(root, outDir string) error { {Field: "StreamSettings", Kind: KindAny}, {Field: "Sniffing", Kind: KindAny}, }, + "Client": { + {Field: "KeepAlive", Type: TypeRef{Kind: KindUnion, Variants: []TypeRef{{Kind: KindInt}, {Kind: KindString}}}}, + }, "ClientRecord": { + {Field: "KeepAlive", Type: TypeRef{Kind: KindUnion, Variants: []TypeRef{{Kind: KindInt}, {Kind: KindString}}}}, {Field: "Reverse", Kind: KindAny}, }, "InboundClientIps": { diff --git a/tools/openapigen/schema.go b/tools/openapigen/schema.go index d8e2638ac..d0502e22c 100644 --- a/tools/openapigen/schema.go +++ b/tools/openapigen/schema.go @@ -31,12 +31,13 @@ type Field struct { } type TypeRef struct { - Kind TypeKind - Name string - Element *TypeRef - Key *TypeRef - Value *TypeRef - Inner *TypeRef + Kind TypeKind + Name string + Element *TypeRef + Key *TypeRef + Value *TypeRef + Inner *TypeRef + Variants []TypeRef } type TypeKind string @@ -48,6 +49,7 @@ const ( KindBool TypeKind = "boolean" KindArray TypeKind = "array" KindMap TypeKind = "map" + KindUnion TypeKind = "union" KindObject TypeKind = "object" KindRef TypeKind = "ref" KindUnknown TypeKind = "unknown" diff --git a/tools/openapigen/walker.go b/tools/openapigen/walker.go index 05a9638f2..ac354f09c 100644 --- a/tools/openapigen/walker.go +++ b/tools/openapigen/walker.go @@ -13,6 +13,7 @@ import ( type walkOverride struct { Field string Kind TypeKind + Type TypeRef } type packageRequest struct { @@ -122,7 +123,11 @@ func buildFields(fld *ast.Field, overrides []walkOverride) []Field { t := exprToType(fld.Type) for _, o := range overrides { 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 } } @@ -145,7 +150,11 @@ func buildFields(fld *ast.Field, overrides []walkOverride) []Field { t := exprToType(fld.Type) for _, o := range overrides { 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 } } diff --git a/tools/openapigen/walker_test.go b/tools/openapigen/walker_test.go index e0bdff648..185ffae8d 100644 --- a/tools/openapigen/walker_test.go +++ b/tools/openapigen/walker_test.go @@ -43,3 +43,28 @@ type HappLinkResult struct { 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) + } +}