feat(amneziawg): support persistent keepalive ranges (#6881)

AmneziaWG clients accept a randomised PersistentKeepalive range such as 15-25,
which makes keepalive timing harder to fingerprint; the panel only stored a
single number. A client's keepalive may now be a range, validated the way
AmneziaWG parses it. The downloadable .conf and vpn:// profile carry the range,
Clash/Mihomo output gets its lower bound, and plain WireGuard stays scalar-only.
The range lives in an additive wg_keep_alive_range column, so the integer
column and an older panel binary keep working after a rollback.
This commit is contained in:
YoungReckless4
2026-10-24 07:36:49 +03:00
committed by GitHub
parent 207c705300
commit 35e09191de
59 changed files with 1871 additions and 146 deletions
+57 -7
View File
@@ -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({
<FormField
name="wgKeepAlive"
label={t('pages.clients.tunnelKeepAlive')}
extra={t('pages.clients.tunnelKeepAliveHint')}
transform={{ output: (v) => 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
}
>
<InputNumber min={0} max={65535} style={{ width: '100%' }} />
{showAmneziawg && !showWireguard ? (
<Input placeholder="25" />
) : (
<InputNumber min={0} max={65535} style={{ width: '100%' }} />
)}
</FormField>
{showAmneziawg && (
<FormField
@@ -3,6 +3,7 @@ import { formatInboundLabel } from '@/lib/inbounds/label';
import { preferPublicHost, resolveShareHost } from '@/lib/xray/inbound-link';
import { effectiveMtu } from '@/lib/xray/amneziawg-obfuscation';
import { joinConfLines } from '@/lib/xray/conf-lines';
import { AmneziaWGKeepAliveRangeSchema } from '@/schemas/primitives/keepalive';
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
// AmneziaWG clients are wire-identical to WireGuard clients (same
@@ -17,7 +18,7 @@ export function isAmneziaWGClient(client: ClientRecord | null | undefined): bool
client.publicKey ||
client.allowedIPs ||
client.preSharedKey ||
client.keepAlive
client.keepAlive !== undefined
);
}
@@ -102,7 +103,11 @@ export function buildAmneziaWGClientConfig(
lines.push('[Peer]', `PublicKey = ${server?.publicKey || ''}`);
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 === '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);
}
@@ -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);
}