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 000000000..3807392d3 Binary files /dev/null and b/docs/public/images/amneziawg-keepalive-range.png differ diff --git a/docs/public/openapi.json b/docs/public/openapi.json index e9dc2f9fc..0ea084f34 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -1759,9 +1759,15 @@ "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", @@ -1981,7 +1987,15 @@ "type": "integer" }, "keepAlive": { - "type": "integer" + "description": "Numeric values or AmneziaWG ranges.", + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] }, "limitHwid": { "type": "integer" diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index e9dc2f9fc..0ea084f34 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -1759,9 +1759,15 @@ "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", @@ -1981,7 +1987,15 @@ "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/examples.ts b/frontend/src/generated/examples.ts index ded5d7826..85faa648b 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -424,7 +424,7 @@ export const EXAMPLES: Record = { "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) + } +}