feat: update AmneziaWG to 3.1 (#2804)
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Edge / Build Docker (push) Has been cancelled
Edge / Build Docker-1 (push) Has been cancelled
Lint / Check Docs (push) Has been cancelled
Edge / Merge & Deploy Docker (push) Has been cancelled
Edge / Build & Deploy Docs (push) Has been cancelled
Lint / Lint (push) Has been cancelled
Lint / Lint-1 (push) Has been cancelled
Lint / Lint-2 (push) Has been cancelled

* feat: update AmneziaWG to 3.1

* fix: correct AmneziaWG 3.1 parameter handling

Verified against the config parser of amneziawg-tools v3.1.20260812.

- validate HeaderProtectionKey as a 32 byte base64 key instead of an
  arbitrary string. Control characters were allowed, so a newline could
  inject additional lines into the generated configuration
- parse the range parameters as uint16 ranges (0-65535) instead of
  reusing the H1-H4 bounds (5 - 2^31-1), which rejected valid values
  such as MaxHandshakeAttempts = 1 and truncated large ones
- write RandomTrailers and DisableCookies as on/off. parse_bool only
  accepts on, off or a number, so true/false aborted awg setconf
- take HeaderProtectionKey and RandomTrailers from the interface, both
  have to be identical on either end of the tunnel, like S1-S4 and H1-H4
- require S1-S4 to be at least 12 while header protection is enabled,
  its cipher uses the first 12 bytes of every junk prefix as its nonce
- render the null state of a nullable switch explicitly and add the
  missing translations, the reset button rendered its own i18n key
- extract the config line building into server/utils/awg.ts and cover
  it with unit tests
- document the new parameters and their compatibility

---------

Co-authored-by: cany748 <30237734+cany748@users.noreply.github.com>
This commit is contained in:
Alexander Chepurnoy 2026-09-09 16:05:15 +07:00 • committed by GitHub
parent b3fffafac9
commit 2cf1ab78e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1920 additions and 112 deletions

View file

@ -39,14 +39,33 @@ If a parameter is not set, it will not be added to the configuration. If all Amn
### Parameter Compatibility Table
| Parameter | Can differ between server and client | Configurable on server | Configurable on client |
| --------- | ------------------------------------ | ---------------------- | ------------------------ |
| Jc | :white_check_mark: Yes | :white_check_mark: | :white_check_mark: |
| Jmin | :white_check_mark: Yes | :white_check_mark: | :white_check_mark: |
| Jmax | :white_check_mark: Yes | :white_check_mark: | :white_check_mark: |
| S1-S4 | :x: No, must match | :white_check_mark: | :x: (copied from server) |
| H1-H4 | :x: No, must match | :white_check_mark: | :x: (copied from server) |
| I1-I5 | :white_check_mark: Yes | :white_check_mark: | :white_check_mark: |
| Parameter | Can differ between server and client | Configurable on server | Configurable on client |
| ---------------------- | ------------------------------------ | ---------------------- | ------------------------ |
| Jc | :white_check_mark: Yes | :white_check_mark: | :white_check_mark: |
| Jmin | :white_check_mark: Yes | :white_check_mark: | :white_check_mark: |
| Jmax | :white_check_mark: Yes | :white_check_mark: | :white_check_mark: |
| S1-S4 | :x: No, must match | :white_check_mark: | :x: (copied from server) |
| H1-H4 | :x: No, must match | :white_check_mark: | :x: (copied from server) |
| I1-I5 | :white_check_mark: Yes | :white_check_mark: | :white_check_mark: |
| HeaderProtectionKey | :x: No, must match | :white_check_mark: | :x: (copied from server) |
| RandomTrailers | :x: No, must match | :white_check_mark: | :x: (copied from server) |
| ContentPaddingAddition | :white_check_mark: Yes | :white_check_mark: | :white_check_mark: |
| RekeyAfterTime | :white_check_mark: Yes | :white_check_mark: | :white_check_mark: |
| RekeyTimeout | :white_check_mark: Yes | :white_check_mark: | :white_check_mark: |
| RejectAfterTime | :white_check_mark: Yes | :white_check_mark: | :white_check_mark: |
| KeepaliveTimeout | :white_check_mark: Yes | :white_check_mark: | :white_check_mark: |
| MaxHandshakeAttempts | :white_check_mark: Yes | :white_check_mark: | :white_check_mark: |
| DisableCookies | :white_check_mark: Yes | :white_check_mark: | :white_check_mark: |
### AmneziaWG 3.0 and 3.1 Parameters
These parameters require AmneziaWG 3.0 or newer on both ends of the tunnel. Older clients reject configurations that contain them, so leave them unset if you still support AmneziaWG 2.x clients.
`HeaderProtectionKey` is a 32 byte key in base64, in the same format as a WireGuard key. It encrypts the packet headers, so it has to be identical on both sides and is copied into every client configuration. Header protection uses the first 12 bytes of each junk prefix as its nonce, which means **S1, S2, S3 and S4 all have to be set to at least 12** while a key is configured.
`ContentPaddingAddition`, `RekeyAfterTime`, `RekeyTimeout`, `RejectAfterTime`, `KeepaliveTimeout` and `MaxHandshakeAttempts` accept either a single number or an inclusive range written as `lower-upper` (for example `30-90`, both bounds between 0 and 65535). When a range is given, AmneziaWG picks a random value from it, which removes the fixed timings a DPI system could otherwise fingerprint.
`RandomTrailers` appends a random amount of bytes to handshake packets and has to be enabled on both sides. `DisableCookies` stops this side from answering handshakes with cookie messages when it is under load and only affects the peer it is set on.
## Client Applications

View file

@ -0,0 +1,48 @@
<template>
<div class="flex items-center">
<FormLabel :for="id">
{{ label }}
</FormLabel>
<BaseTooltip v-if="description" :text="description">
<IconsInfo class="size-4" />
</BaseTooltip>
</div>
<div class="my-auto flex items-center gap-3">
<template v-if="data === null">
<span class="text-gray-500 dark:text-neutral-300">
{{ $t('form.nullNotSet') }}
</span>
<BaseSecondaryButton
type="button"
class="rounded-lg"
@click="data = false"
>
{{ $t('form.set') }}
</BaseSecondaryButton>
</template>
<template v-else>
<BaseSwitch :id="id" v-model="enabled" />
<BaseSecondaryButton
type="button"
class="rounded-lg"
@click="data = null"
>
{{ $t('form.unset') }}
</BaseSecondaryButton>
</template>
</div>
</template>
<script lang="ts" setup>
defineProps<{ id: string; label: string; description?: string }>();
const data = defineModel<boolean | null>();
/** `null` is rendered as its own state, so the switch is only ever a boolean */
const enabled = computed({
get: () => data.value === true,
set: (value: boolean) => {
data.value = value;
},
});
</script>

View file

@ -129,6 +129,63 @@
:label="$t('awg.i5Label')"
:description="$t('awg.i5Description')"
/>
<FormNullTextField
id="headerProtectionKey"
v-model="data.headerProtectionKey"
:label="$t('awg.headerProtectionKeyLabel')"
:description="$t('awg.headerProtectionKeyDescription')"
/>
<div class="col-span-full text-sm">{{ $t('awg.rangeNote') }}</div>
<FormNullTextField
id="contentPaddingAddition"
v-model="data.contentPaddingAddition"
:label="$t('awg.contentPaddingAdditionLabel')"
:description="$t('awg.contentPaddingAdditionDescription')"
/>
<FormNullTextField
id="rekeyAfterTime"
v-model="data.rekeyAfterTime"
:label="$t('awg.rekeyAfterTimeLabel')"
:description="$t('awg.rekeyAfterTimeDescription')"
/>
<FormNullTextField
id="rekeyTimeout"
v-model="data.rekeyTimeout"
:label="$t('awg.rekeyTimeoutLabel')"
:description="$t('awg.rekeyTimeoutDescription')"
/>
<FormNullTextField
id="rejectAfterTime"
v-model="data.rejectAfterTime"
:label="$t('awg.rejectAfterTimeLabel')"
:description="$t('awg.rejectAfterTimeDescription')"
/>
<FormNullTextField
id="keepaliveTimeout"
v-model="data.keepaliveTimeout"
:label="$t('awg.keepaliveTimeoutLabel')"
:description="$t('awg.keepaliveTimeoutDescription')"
/>
<FormNullTextField
id="maxHandshakeAttempts"
v-model="data.maxHandshakeAttempts"
:label="$t('awg.maxHandshakeAttemptsLabel')"
:description="$t('awg.maxHandshakeAttemptsDescription')"
/>
<FormNullSwitchField
id="randomTrailers"
v-model="data.randomTrailers"
:label="$t('awg.randomTrailersLabel')"
:description="$t('awg.randomTrailersDescription')"
/>
<FormNullSwitchField
id="disableCookies"
v-model="data.disableCookies"
:label="$t('awg.disableCookiesLabel')"
:description="$t('awg.disableCookiesDescription')"
/>
</FormGroup>
<FormGroup>
<FormHeading>{{ $t('admin.interface.firewall') }}</FormHeading>

View file

@ -144,6 +144,53 @@
:label="$t('awg.i5Label')"
:description="$t('awg.i5Description')"
/>
<div class="col-span-full text-sm">
{{ $t('awg.rangeNote') }}
</div>
<FormNullTextField
id="contentPaddingAddition"
v-model="data.contentPaddingAddition"
:label="$t('awg.contentPaddingAdditionLabel')"
:description="$t('awg.contentPaddingAdditionDescription')"
/>
<FormNullTextField
id="rekeyAfterTime"
v-model="data.rekeyAfterTime"
:label="$t('awg.rekeyAfterTimeLabel')"
:description="$t('awg.rekeyAfterTimeDescription')"
/>
<FormNullTextField
id="rekeyTimeout"
v-model="data.rekeyTimeout"
:label="$t('awg.rekeyTimeoutLabel')"
:description="$t('awg.rekeyTimeoutDescription')"
/>
<FormNullTextField
id="rejectAfterTime"
v-model="data.rejectAfterTime"
:label="$t('awg.rejectAfterTimeLabel')"
:description="$t('awg.rejectAfterTimeDescription')"
/>
<FormNullTextField
id="keepaliveTimeout"
v-model="data.keepaliveTimeout"
:label="$t('awg.keepaliveTimeoutLabel')"
:description="$t('awg.keepaliveTimeoutDescription')"
/>
<FormNullTextField
id="maxHandshakeAttempts"
v-model="data.maxHandshakeAttempts"
:label="$t('awg.maxHandshakeAttemptsLabel')"
:description="$t('awg.maxHandshakeAttemptsDescription')"
/>
<FormNullSwitchField
id="disableCookies"
v-model="data.disableCookies"
:label="$t('awg.disableCookiesLabel')"
:description="$t('awg.disableCookiesDescription')"
/>
</FormGroup>
<FormGroup>
<FormHeading :description="$t('client.hooksDescription')">

View file

@ -153,6 +153,9 @@
"sectionAdvanced": "Advanced",
"noItems": "No items",
"nullNoItems": "No items. Using global config",
"nullNotSet": "Not set",
"set": "Set",
"unset": "Unset",
"add": "Add"
},
"admin": {
@ -244,10 +247,12 @@
"cidr": "CIDR",
"device": "Device",
"cidrValid": "CIDR must be valid",
"routingTable": "Routing table must be auto, off, or a table number"
"routingTable": "Routing table must be auto, off, or a table number",
"headerProtectionJunkSize": "Junk sizes S1-S4 must be at least 12 when a Header Protection Key is set"
},
"otl": "One Time link",
"stringMalformed": "String is malformed",
"keyMalformed": "Key must be a 32 byte base64 encoded key",
"body": "Body must be a valid object",
"hook": "Hook",
"enabled": "Enabled",
@ -304,6 +309,25 @@
"i4Description": "Protocol mimic packet in hex format: <b 0x...>",
"i5Label": "Special junk packet 5 (I5)",
"i5Description": "Protocol mimic packet in hex format: <b 0x...>",
"rangeNote": "The following parameters accept a single number or an inclusive range (0-65535), for example 30 or 30-90",
"headerProtectionKeyLabel": "Header protection key",
"headerProtectionKeyDescription": "32 byte base64 key used to encrypt packet headers. Must match on both sides, so it is copied to every client. Requires S1-S4 to be at least 12",
"contentPaddingAdditionLabel": "Content padding addition",
"contentPaddingAdditionDescription": "Random amount of bytes appended to transport packets before encryption",
"rekeyAfterTimeLabel": "Rekey after time",
"rekeyAfterTimeDescription": "Seconds before a session is renegotiated (WireGuard default: 120)",
"rekeyTimeoutLabel": "Rekey timeout",
"rekeyTimeoutDescription": "Seconds between handshake retries (WireGuard default: 5)",
"rejectAfterTimeLabel": "Reject after time",
"rejectAfterTimeDescription": "Session lifetime in seconds (WireGuard default: 180)",
"keepaliveTimeoutLabel": "Keepalive timeout",
"keepaliveTimeoutDescription": "Idle seconds before a keepalive is sent (WireGuard default: 10)",
"maxHandshakeAttemptsLabel": "Max handshake attempts",
"maxHandshakeAttemptsDescription": "Handshake attempts before giving up (WireGuard default: 18)",
"randomTrailersLabel": "Random trailers",
"randomTrailersDescription": "Append a random amount of bytes to handshake packets. Must match on both sides, so it is copied to every client",
"disableCookiesLabel": "Disable cookies",
"disableCookiesDescription": "Do not answer handshakes with cookie messages when under load. Only affects this side of the tunnel",
"mtuNote": "Values depend on the MTU",
"obfuscationParameters": "AmneziaWG Obfuscation Parameters"
}

View file

@ -0,0 +1,16 @@
ALTER TABLE `clients_table` ADD `content_padding_addition` text;--> statement-breakpoint
ALTER TABLE `clients_table` ADD `rekey_after_time` text;--> statement-breakpoint
ALTER TABLE `clients_table` ADD `rekey_timeout` text;--> statement-breakpoint
ALTER TABLE `clients_table` ADD `reject_after_time` text;--> statement-breakpoint
ALTER TABLE `clients_table` ADD `keepalive_timeout` text;--> statement-breakpoint
ALTER TABLE `clients_table` ADD `max_handshake_attempts` text;--> statement-breakpoint
ALTER TABLE `clients_table` ADD `disable_cookies` integer;--> statement-breakpoint
ALTER TABLE `interfaces_table` ADD `header_protection_key` text;--> statement-breakpoint
ALTER TABLE `interfaces_table` ADD `content_padding_addition` text;--> statement-breakpoint
ALTER TABLE `interfaces_table` ADD `rekey_after_time` text;--> statement-breakpoint
ALTER TABLE `interfaces_table` ADD `rekey_timeout` text;--> statement-breakpoint
ALTER TABLE `interfaces_table` ADD `reject_after_time` text;--> statement-breakpoint
ALTER TABLE `interfaces_table` ADD `keepalive_timeout` text;--> statement-breakpoint
ALTER TABLE `interfaces_table` ADD `max_handshake_attempts` text;--> statement-breakpoint
ALTER TABLE `interfaces_table` ADD `random_trailers` integer;--> statement-breakpoint
ALTER TABLE `interfaces_table` ADD `disable_cookies` integer;

File diff suppressed because it is too large Load diff

View file

@ -57,6 +57,13 @@
"when": 1785630544322,
"tag": "0007_blushing_electro",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1788760134302,
"tag": "0008_thin_patch",
"breakpoints": true
}
]
}

View file

@ -52,6 +52,15 @@ export const client = sqliteTable(
i3: text(),
i4: text(),
i5: text(),
// HeaderProtectionKey and RandomTrailers have to match the interface and
// are therefore only configurable on the server
contentPaddingAddition: text('content_padding_addition'),
rekeyAfterTime: text('rekey_after_time'),
rekeyTimeout: text('rekey_timeout'),
rejectAfterTime: text('reject_after_time'),
keepaliveTimeout: text('keepalive_timeout'),
maxHandshakeAttempts: text('max_handshake_attempts'),
disableCookies: int('disable_cookies', { mode: 'boolean' }),
dns: text({ mode: 'json' }).$type<string[]>(),
serverEndpoint: text('server_endpoint'),
enabled: int({ mode: 'boolean' }).notNull(),

View file

@ -7,6 +7,7 @@ import type { client } from './schema';
import {
AddressSchema,
AllowedIpsSchema,
AwgRangeSchema,
DnsSchema,
EnabledSchema,
FirewallIpsSchema,
@ -109,6 +110,13 @@ export const ClientUpdateSchema = schemaForType<UpdateClientType>()(
i3: ISchema,
i4: ISchema,
i5: ISchema,
contentPaddingAddition: AwgRangeSchema,
rekeyAfterTime: AwgRangeSchema,
rekeyTimeout: AwgRangeSchema,
rejectAfterTime: AwgRangeSchema,
keepaliveTimeout: AwgRangeSchema,
maxHandshakeAttempts: AwgRangeSchema,
disableCookies: z.boolean().nullable(),
persistentKeepalive: PersistentKeepaliveSchema,
serverEndpoint: AddressSchema.nullable(),
dns: DnsSchema.nullable(),

View file

@ -31,6 +31,15 @@ export const wgInterface = sqliteTable('interfaces_table', {
i3: text(),
i4: text(),
i5: text(),
headerProtectionKey: text('header_protection_key'),
contentPaddingAddition: text('content_padding_addition'),
rekeyAfterTime: text('rekey_after_time'),
rekeyTimeout: text('rekey_timeout'),
rejectAfterTime: text('reject_after_time'),
keepaliveTimeout: text('keepalive_timeout'),
maxHandshakeAttempts: text('max_handshake_attempts'),
randomTrailers: int('random_trailers', { mode: 'boolean' }),
disableCookies: int('disable_cookies', { mode: 'boolean' }),
// does nothing yet
enabled: int({ mode: 'boolean' }).notNull(),
// Enable per-client firewall filtering via iptables

View file

@ -5,12 +5,14 @@ import isCidr from 'is-cidr';
import type { wgInterface } from './schema';
import {
AwgRangeSchema,
EnabledSchema,
HSchema,
ISchema,
JcSchema,
JmaxSchema,
JminSchema,
KeySchema,
MtuSchema,
PortSchema,
RoutingTableSchema,
@ -43,33 +45,64 @@ const cidr = z
.refine((value) => isCidr(value), { message: t('zod.interface.cidrValid') })
.pipe(safeStringRefine);
/**
* Header protection uses the first 12 bytes of every junk prefix as the nonce
* for its cipher, so all four junk sizes have to be at least that large.
*/
const HEADER_PROTECTION_MIN_JUNK_SIZE = 12;
export const InterfaceUpdateSchema = schemaForType<InterfaceUpdateType>()(
z.object({
ipv4Cidr: cidr,
ipv6Cidr: cidr,
mtu: MtuSchema,
routingTable: RoutingTableSchema,
jC: JcSchema,
jMin: JminSchema,
jMax: JmaxSchema,
s1: SSchema,
s2: SSchema,
s3: SSchema,
s4: SSchema,
h1: HSchema,
h2: HSchema,
h3: HSchema,
h4: HSchema,
i1: ISchema,
i2: ISchema,
i3: ISchema,
i4: ISchema,
i5: ISchema,
port: PortSchema,
device: device,
enabled: EnabledSchema,
firewallEnabled: EnabledSchema,
})
z
.object({
ipv4Cidr: cidr,
ipv6Cidr: cidr,
mtu: MtuSchema,
routingTable: RoutingTableSchema,
jC: JcSchema,
jMin: JminSchema,
jMax: JmaxSchema,
s1: SSchema,
s2: SSchema,
s3: SSchema,
s4: SSchema,
h1: HSchema,
h2: HSchema,
h3: HSchema,
h4: HSchema,
i1: ISchema,
i2: ISchema,
i3: ISchema,
i4: ISchema,
i5: ISchema,
headerProtectionKey: KeySchema,
contentPaddingAddition: AwgRangeSchema,
rekeyAfterTime: AwgRangeSchema,
rekeyTimeout: AwgRangeSchema,
rejectAfterTime: AwgRangeSchema,
keepaliveTimeout: AwgRangeSchema,
maxHandshakeAttempts: AwgRangeSchema,
randomTrailers: z.boolean().nullable(),
disableCookies: z.boolean().nullable(),
port: PortSchema,
device: device,
enabled: EnabledSchema,
firewallEnabled: EnabledSchema,
})
.superRefine((data, ctx) => {
if (data.headerProtectionKey === null) return;
for (const junk of ['s1', 's2', 's3', 's4'] as const) {
const size = data[junk];
if (size === null || size < HEADER_PROTECTION_MIN_JUNK_SIZE) {
ctx.addIssue({
code: 'custom',
path: [junk],
message: t('zod.interface.headerProtectionJunkSize'),
});
}
}
})
);
export type InterfaceCidrUpdateType = {

154
src/server/utils/awg.ts Normal file
View file

@ -0,0 +1,154 @@
import type { ClientType } from '#db/repositories/client/types';
import type { InterfaceType } from '#db/repositories/interface/types';
/**
* A parameter written as `Key = value` into the `[Interface]` section of a
* configuration file. `null` means "not set" and is left out entirely.
*/
type AwgParameters = Record<string, string | number | boolean | null>;
type AwgInterface = Pick<
InterfaceType,
| 'jC'
| 'jMin'
| 'jMax'
| 's1'
| 's2'
| 's3'
| 's4'
| 'h1'
| 'h2'
| 'h3'
| 'h4'
| 'i1'
| 'i2'
| 'i3'
| 'i4'
| 'i5'
| 'headerProtectionKey'
| 'contentPaddingAddition'
| 'rekeyAfterTime'
| 'rekeyTimeout'
| 'rejectAfterTime'
| 'keepaliveTimeout'
| 'maxHandshakeAttempts'
| 'randomTrailers'
| 'disableCookies'
>;
type AwgClient = Pick<
ClientType,
| 'jC'
| 'jMin'
| 'jMax'
| 'i1'
| 'i2'
| 'i3'
| 'i4'
| 'i5'
| 'contentPaddingAddition'
| 'rekeyAfterTime'
| 'rekeyTimeout'
| 'rejectAfterTime'
| 'keepaliveTimeout'
| 'maxHandshakeAttempts'
| 'disableCookies'
>;
/**
* amneziawg-tools parses booleans with `parse_bool`, which only accepts
* `on`, `off` or a plain number - not `true` / `false`.
*/
function formatBoolean(value: boolean) {
return value ? 'on' : 'off';
}
export function buildAwgLines(parameters: AwgParameters) {
const lines: string[] = [];
for (const [key, value] of Object.entries(parameters)) {
if (typeof value === 'boolean') {
lines.push(`${key} = ${formatBoolean(value)}`);
continue;
}
if (!value) continue;
lines.push(`${key} = ${value}`);
}
return lines;
}
/** Obfuscation parameters for the server's own interface */
export function interfaceAwgParameters(
wgInterface: AwgInterface
): AwgParameters {
return {
Jc: wgInterface.jC,
Jmin: wgInterface.jMin,
Jmax: wgInterface.jMax,
S1: wgInterface.s1,
S2: wgInterface.s2,
S3: wgInterface.s3,
S4: wgInterface.s4,
H1: wgInterface.h1,
H2: wgInterface.h2,
H3: wgInterface.h3,
H4: wgInterface.h4,
I1: wgInterface.i1,
I2: wgInterface.i2,
I3: wgInterface.i3,
I4: wgInterface.i4,
I5: wgInterface.i5,
HeaderProtectionKey: wgInterface.headerProtectionKey,
ContentPaddingAddition: wgInterface.contentPaddingAddition,
RekeyAfterTime: wgInterface.rekeyAfterTime,
RekeyTimeout: wgInterface.rekeyTimeout,
RejectAfterTime: wgInterface.rejectAfterTime,
KeepaliveTimeout: wgInterface.keepaliveTimeout,
MaxHandshakeAttempts: wgInterface.maxHandshakeAttempts,
RandomTrailers: wgInterface.randomTrailers,
DisableCookies: wgInterface.disableCookies,
};
}
/**
* Obfuscation parameters for a client's interface.
*
* S1-S4, H1-H4, HeaderProtectionKey and RandomTrailers have to be identical on
* both ends of the tunnel and are therefore copied from the server, everything
* else may differ per client.
*/
export function clientAwgParameters(
wgInterface: AwgInterface,
client: AwgClient
): AwgParameters {
return {
Jc: client.jC,
Jmin: client.jMin,
Jmax: client.jMax,
S1: wgInterface.s1,
S2: wgInterface.s2,
S3: wgInterface.s3,
S4: wgInterface.s4,
H1: wgInterface.h1,
H2: wgInterface.h2,
H3: wgInterface.h3,
H4: wgInterface.h4,
I1: client.i1,
I2: client.i2,
I3: client.i3,
I4: client.i4,
I5: client.i5,
HeaderProtectionKey: wgInterface.headerProtectionKey,
ContentPaddingAddition: client.contentPaddingAddition,
RekeyAfterTime: client.rekeyAfterTime,
RekeyTimeout: client.rekeyTimeout,
RejectAfterTime: client.rejectAfterTime,
KeepaliveTimeout: client.keepaliveTimeout,
MaxHandshakeAttempts: client.maxHandshakeAttempts,
RandomTrailers: wgInterface.randomTrailers,
DisableCookies: client.disableCookies,
};
}

View file

@ -46,38 +46,87 @@ export const JmaxSchema = z.number().max(1280).nullable();
export const SSchema = z.number().max(1132).nullable();
const RANGE_REGEX = /^(\d+)(?:-(\d+))?$/;
function parseRange(value: string) {
const match = RANGE_REGEX.exec(value);
if (!match) return null;
const lower = Number(match[1]);
const upper = match[2] === undefined ? lower : Number(match[2]);
if (!Number.isSafeInteger(lower) || !Number.isSafeInteger(upper)) return null;
return { lower, upper };
}
/**
* AmneziaWG accepts either a single number or an inclusive `lower-upper` range
* for several parameters (see `u32_range_from_string` / `u16_range_from_string`
* in amneziawg-tools). Values outside of `[min, max]` are silently truncated by
* the parser, so they are rejected here instead.
*/
function rangeSchema(min: number, max: number) {
return z
.string()
.transform((v) => v.replace(/\s+/g, ''))
.refine(
(v) => {
const range = parseRange(v);
if (!range) return false;
return (
range.lower >= min && range.upper <= max && range.lower <= range.upper
);
},
{
message: t('zod.generic.validNumberRange'),
}
)
.transform((v) => {
const range = parseRange(v);
// cannot happen, the refine above already rejected unparsable values
if (!range) return v;
return range.lower === range.upper
? `${range.lower}`
: `${range.lower}-${range.upper}`;
})
.nullable();
}
const H_MIN = 5;
const H_MAX = 2 ** 31 - 1;
export const HSchema = z
/** H1-H4 packet header types, parsed as a uint32 range */
export const HSchema = rangeSchema(H_MIN, H_MAX);
/**
* AmneziaWG 3.0+ parameters that are parsed as a uint16 range:
* ContentPaddingAddition, RekeyAfterTime, RekeyTimeout, RejectAfterTime,
* KeepaliveTimeout and MaxHandshakeAttempts.
*/
export const AwgRangeSchema = rangeSchema(0, 65535);
/** Length of a WireGuard key in bytes */
const KEY_LENGTH = 32;
/**
* A 32 byte key in canonical base64, the same format WireGuard uses for its
* own keys. Used by the AmneziaWG 3.0+ `HeaderProtectionKey` parameter.
*/
export const KeySchema = z
.string()
.transform((v) => v.replace(/\s+/g, ''))
.pipe(safeStringRefine)
.pipe(controlStringRefine)
.refine(
(v) => {
if (!v) return false;
const key = Buffer.from(v, 'base64');
if (!/^\d+(-\d+)?$/.test(v)) return false;
if (!v.includes('-')) {
const num = Number(v);
return num >= H_MIN && num <= H_MAX;
}
const [min, max] = v.split('-').map(Number);
return min && max && min >= H_MIN && max <= H_MAX && min <= max;
return false;
return key.length === KEY_LENGTH && key.toString('base64') === v;
},
{
message: t('zod.generic.validNumberRange'),
}
{ message: t('zod.keyMalformed') }
)
.transform((v) => {
if (!v.includes('-')) return `${Number(v)}`;
const [min, max] = v.split('-').map(Number);
return min === max ? `${min}` : `${min}-${max}`;
})
.nullable();
export const ISchema = z

View file

@ -1,6 +1,11 @@
import { parseCidr } from 'cidr-tools';
import { stringifyIp } from 'ip-bigint';
import {
buildAwgLines,
clientAwgParameters,
interfaceAwgParameters,
} from '#server/utils/awg';
import { removeNewlines, iptablesTemplate } from '#server/utils/template';
import { exec } from '#server/utils/cmd';
import { WG_ENV } from '#server/utils/config';
@ -58,34 +63,10 @@ AllowedIPs = ${allowedIps.join(', ')}${extraLines.length ? `\n${extraLines.join(
`${ipv4Addr}/${cidr4.prefix}` +
(enableIpv6 ? `, ${ipv6Addr}/${cidr6.prefix}` : '');
let awgLines: string[] = [];
if (wgExecutable === 'awg') {
const parameters = {
Jc: wgInterface.jC,
Jmin: wgInterface.jMin,
Jmax: wgInterface.jMax,
S1: wgInterface.s1,
S2: wgInterface.s2,
S3: wgInterface.s3,
S4: wgInterface.s4,
H1: wgInterface.h1,
H2: wgInterface.h2,
H3: wgInterface.h3,
H4: wgInterface.h4,
I1: wgInterface.i1,
I2: wgInterface.i2,
I3: wgInterface.i3,
I4: wgInterface.i4,
I5: wgInterface.i5,
} as const;
awgLines = Object.entries(parameters)
.filter(([_, value]) => !!value)
.map(([key, value]) => `${key} = ${value}`);
}
const extraLines = [...awgLines].filter((v) => v !== null);
const extraLines =
wgExecutable === 'awg'
? buildAwgLines(interfaceAwgParameters(wgInterface))
: [];
return `# Note: Do not edit this file directly.
# Your changes will be overwritten!
@ -127,32 +108,10 @@ PostDown = ${iptablesTemplate(hooks.postDown, wgInterface)}`;
const dnsLine =
dnsServers.length > 0 ? `DNS = ${dnsServers.join(', ')}` : null;
let awgLines: string[] = [];
if (wgExecutable === 'awg') {
const parameters = {
Jc: client.jC,
Jmin: client.jMin,
Jmax: client.jMax,
S1: wgInterface.s1,
S2: wgInterface.s2,
S3: wgInterface.s3,
S4: wgInterface.s4,
H1: wgInterface.h1,
H2: wgInterface.h2,
H3: wgInterface.h3,
H4: wgInterface.h4,
I1: client.i1,
I2: client.i2,
I3: client.i3,
I4: client.i4,
I5: client.i5,
} as const;
awgLines = Object.entries(parameters)
.filter(([_, value]) => !!value)
.map(([key, value]) => `${key} = ${value}`);
}
const awgLines =
wgExecutable === 'awg'
? buildAwgLines(clientAwgParameters(wgInterface, client))
: [];
const extraLines = [dnsLine, ...hookLines, ...awgLines].filter(
(v) => v !== null

222
src/test/unit/awg.spec.ts Normal file
View file

@ -0,0 +1,222 @@
import { describe, expect, test } from 'vitest';
import {
buildAwgLines,
clientAwgParameters,
interfaceAwgParameters,
} from '#server/utils/awg';
import { InterfaceUpdateSchema } from '#db/repositories/interface/types';
import { AwgRangeSchema, HSchema, KeySchema } from '#server/utils/types';
/** Any valid 32 byte base64 key, generated with `wg genkey` */
const KEY = 'JPJ2DWzJbwrRkY6cPbZaJasL6bHQOM6J2Ollx4D/OHU=';
const wgInterface = {
jC: 7,
jMin: 10,
jMax: 1000,
s1: 128,
s2: 56,
s3: null,
s4: null,
h1: '5',
h2: null,
h3: null,
h4: null,
i1: null,
i2: null,
i3: null,
i4: null,
i5: null,
headerProtectionKey: null,
contentPaddingAddition: null,
rekeyAfterTime: null,
rekeyTimeout: null,
rejectAfterTime: null,
keepaliveTimeout: null,
maxHandshakeAttempts: null,
randomTrailers: null,
disableCookies: null,
};
const client = {
jC: null,
jMin: null,
jMax: null,
i1: null,
i2: null,
i3: null,
i4: null,
i5: null,
contentPaddingAddition: null,
rekeyAfterTime: null,
rekeyTimeout: null,
rejectAfterTime: null,
keepaliveTimeout: null,
maxHandshakeAttempts: null,
disableCookies: null,
};
describe('AmneziaWG config lines', () => {
test('leaves out parameters that are not set', () => {
expect(buildAwgLines({ Jc: null, Jmin: null })).toEqual([]);
});
test('writes booleans as on/off, not true/false', () => {
expect(
buildAwgLines({ RandomTrailers: true, DisableCookies: false })
).toEqual(['RandomTrailers = on', 'DisableCookies = off']);
});
test('keeps ranges verbatim', () => {
expect(buildAwgLines({ RekeyAfterTime: '90-150' })).toEqual([
'RekeyAfterTime = 90-150',
]);
});
test('builds the interface parameters', () => {
expect(
buildAwgLines(
interfaceAwgParameters({
...wgInterface,
headerProtectionKey: KEY,
maxHandshakeAttempts: '5',
randomTrailers: true,
disableCookies: false,
})
)
).toEqual([
'Jc = 7',
'Jmin = 10',
'Jmax = 1000',
'S1 = 128',
'S2 = 56',
'H1 = 5',
`HeaderProtectionKey = ${KEY}`,
'MaxHandshakeAttempts = 5',
'RandomTrailers = on',
'DisableCookies = off',
]);
});
test('copies parameters that must match from the interface to the client', () => {
const lines = buildAwgLines(
clientAwgParameters(
{ ...wgInterface, headerProtectionKey: KEY, randomTrailers: true },
{ ...client, rekeyTimeout: '4-8', disableCookies: true }
)
);
expect(lines).toContain(`HeaderProtectionKey = ${KEY}`);
expect(lines).toContain('RandomTrailers = on');
expect(lines).toContain('S1 = 128');
expect(lines).toContain('H1 = 5');
expect(lines).toContain('RekeyTimeout = 4-8');
expect(lines).toContain('DisableCookies = on');
});
test('does not inherit per-client parameters from the interface', () => {
const lines = buildAwgLines(
clientAwgParameters(
{ ...wgInterface, jC: 7, maxHandshakeAttempts: '9' },
client
)
);
expect(lines).not.toContain('Jc = 7');
expect(lines).not.toContain('MaxHandshakeAttempts = 9');
});
});
describe('AwgRangeSchema', () => {
test('accepts a single number and an inclusive range', () => {
expect(AwgRangeSchema.parse('30')).toBe('30');
expect(AwgRangeSchema.parse('30-90')).toBe('30-90');
expect(AwgRangeSchema.parse(' 30 - 90 ')).toBe('30-90');
expect(AwgRangeSchema.parse('0')).toBe('0');
expect(AwgRangeSchema.parse(null)).toBeNull();
});
test('collapses a range with identical bounds', () => {
expect(AwgRangeSchema.parse('30-30')).toBe('30');
});
test('rejects values the awg parser would truncate or refuse', () => {
expect(() => AwgRangeSchema.parse('65536')).toThrow();
expect(() => AwgRangeSchema.parse('90-30')).toThrow();
expect(() => AwgRangeSchema.parse('-5')).toThrow();
expect(() => AwgRangeSchema.parse('1-2-3')).toThrow();
expect(() => AwgRangeSchema.parse('abc')).toThrow();
expect(() => AwgRangeSchema.parse('')).toThrow();
});
test('does not change the H1-H4 bounds', () => {
expect(HSchema.parse('5')).toBe('5');
expect(() => HSchema.parse('4')).toThrow();
expect(HSchema.parse('5-2147483647')).toBe('5-2147483647');
expect(() => HSchema.parse('5-2147483648')).toThrow();
});
});
describe('KeySchema', () => {
test('accepts a 32 byte base64 key', () => {
expect(KeySchema.parse(KEY)).toBe(KEY);
expect(KeySchema.parse(null)).toBeNull();
});
test('rejects keys of the wrong length or encoding', () => {
expect(() => KeySchema.parse('too-short')).toThrow();
expect(() => KeySchema.parse(KEY.slice(0, -1))).toThrow();
expect(() => KeySchema.parse(`${KEY}${KEY}`)).toThrow();
// non canonical: the trailing bits of the last character are not zero
expect(() => KeySchema.parse(`${KEY.slice(0, 42)}B=`)).toThrow();
});
test('rejects newlines that would inject extra config lines', () => {
expect(() =>
KeySchema.parse(`${KEY}\nPostUp = touch /tmp/pwned`)
).toThrow();
});
});
describe('InterfaceUpdateSchema', () => {
const base = {
ipv4Cidr: '10.8.0.0/24',
ipv6Cidr: 'fdcc:ad94:bacf:61a3::/64',
mtu: 1420,
routingTable: 'auto',
...wgInterface,
port: 51820,
device: 'eth0',
enabled: true,
firewallEnabled: false,
};
test('accepts an interface without header protection', () => {
expect(() => InterfaceUpdateSchema.parse(base)).not.toThrow();
});
test('requires S1-S4 to be at least 12 when a key is set', () => {
const result = InterfaceUpdateSchema.safeParse({
...base,
headerProtectionKey: KEY,
});
expect(result.success).toBe(false);
expect(result.error?.issues.map((issue) => issue.path[0])).toEqual([
's3',
's4',
]);
});
test('accepts header protection with large enough junk sizes', () => {
expect(() =>
InterfaceUpdateSchema.parse({
...base,
headerProtectionKey: KEY,
s3: 12,
s4: 40,
})
).not.toThrow();
});
});

View file

@ -9,6 +9,9 @@ export default defineConfig({
resolve: {
alias: {
'#server': fileURLToPath(new URL('./server', import.meta.url)),
'#db': fileURLToPath(
new URL('./server/database/', import.meta.url)
),
},
},
test: {