diff --git a/docs/content/advanced/config/amnezia.md b/docs/content/advanced/config/amnezia.md index 3e8d4d0a..46fbeb48 100644 --- a/docs/content/advanced/config/amnezia.md +++ b/docs/content/advanced/config/amnezia.md @@ -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 diff --git a/src/app/components/Form/NullSwitchField.vue b/src/app/components/Form/NullSwitchField.vue new file mode 100644 index 00000000..6291105e --- /dev/null +++ b/src/app/components/Form/NullSwitchField.vue @@ -0,0 +1,48 @@ + + + diff --git a/src/app/pages/admin/interface.vue b/src/app/pages/admin/interface.vue index 12d839b0..df3af779 100644 --- a/src/app/pages/admin/interface.vue +++ b/src/app/pages/admin/interface.vue @@ -129,6 +129,63 @@ :label="$t('awg.i5Label')" :description="$t('awg.i5Description')" /> + + +
{{ $t('awg.rangeNote') }}
+ + + + + + + + + {{ $t('admin.interface.firewall') }} diff --git a/src/app/pages/clients/[id].vue b/src/app/pages/clients/[id].vue index 6694c284..380cca16 100644 --- a/src/app/pages/clients/[id].vue +++ b/src/app/pages/clients/[id].vue @@ -144,6 +144,53 @@ :label="$t('awg.i5Label')" :description="$t('awg.i5Description')" /> + +
+ {{ $t('awg.rangeNote') }} +
+ + + + + + + +
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ffc60cff..44d82bf1 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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: ", "i5Label": "Special junk packet 5 (I5)", "i5Description": "Protocol mimic packet in hex format: ", + "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" } diff --git a/src/server/database/migrations/0008_thin_patch.sql b/src/server/database/migrations/0008_thin_patch.sql new file mode 100644 index 00000000..f16a0f04 --- /dev/null +++ b/src/server/database/migrations/0008_thin_patch.sql @@ -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; \ No newline at end of file diff --git a/src/server/database/migrations/meta/0008_snapshot.json b/src/server/database/migrations/meta/0008_snapshot.json new file mode 100644 index 00000000..4bd4a7f6 --- /dev/null +++ b/src/server/database/migrations/meta/0008_snapshot.json @@ -0,0 +1,1144 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "885a870f-9dc1-4d97-941e-d3d718b02d74", + "prevId": "6d5a5bde-368b-45cb-b73a-9f4324210d73", + "tables": { + "clients_table": { + "name": "clients_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "interface_id": { + "name": "interface_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ipv4_address": { + "name": "ipv4_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ipv6_address": { + "name": "ipv6_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pre_up": { + "name": "pre_up", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "post_up": { + "name": "post_up", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "pre_down": { + "name": "pre_down", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "post_down": { + "name": "post_down", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pre_shared_key": { + "name": "pre_shared_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowed_ips": { + "name": "allowed_ips", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_allowed_ips": { + "name": "server_allowed_ips", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "firewall_ips": { + "name": "firewall_ips", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "persistent_keepalive": { + "name": "persistent_keepalive", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mtu": { + "name": "mtu", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "j_c": { + "name": "j_c", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "j_min": { + "name": "j_min", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "j_max": { + "name": "j_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i1": { + "name": "i1", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i2": { + "name": "i2", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i3": { + "name": "i3", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i4": { + "name": "i4", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i5": { + "name": "i5", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_padding_addition": { + "name": "content_padding_addition", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rekey_after_time": { + "name": "rekey_after_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rekey_timeout": { + "name": "rekey_timeout", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reject_after_time": { + "name": "reject_after_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "keepalive_timeout": { + "name": "keepalive_timeout", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_handshake_attempts": { + "name": "max_handshake_attempts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disable_cookies": { + "name": "disable_cookies", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dns": { + "name": "dns", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_endpoint": { + "name": "server_endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "clients_table_ipv4_address_unique": { + "name": "clients_table_ipv4_address_unique", + "columns": [ + "ipv4_address" + ], + "isUnique": true + }, + "clients_table_ipv6_address_unique": { + "name": "clients_table_ipv6_address_unique", + "columns": [ + "ipv6_address" + ], + "isUnique": true + }, + "public_key_interface_unique": { + "name": "public_key_interface_unique", + "columns": [ + "public_key", + "interface_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "clients_table_user_id_users_table_id_fk": { + "name": "clients_table_user_id_users_table_id_fk", + "tableFrom": "clients_table", + "tableTo": "users_table", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "clients_table_interface_id_interfaces_table_name_fk": { + "name": "clients_table_interface_id_interfaces_table_name_fk", + "tableFrom": "clients_table", + "tableTo": "interfaces_table", + "columnsFrom": [ + "interface_id" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "general_table": { + "name": "general_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "setup_step": { + "name": "setup_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_password": { + "name": "session_password", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_timeout": { + "name": "session_timeout", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metrics_prometheus": { + "name": "metrics_prometheus", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metrics_json": { + "name": "metrics_json", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metrics_password": { + "name": "metrics_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "hooks_table": { + "name": "hooks_table", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "pre_up": { + "name": "pre_up", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "post_up": { + "name": "post_up", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pre_down": { + "name": "pre_down", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "post_down": { + "name": "post_down", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "hooks_table_id_interfaces_table_name_fk": { + "name": "hooks_table_id_interfaces_table_name_fk", + "tableFrom": "hooks_table", + "tableTo": "interfaces_table", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "interfaces_table": { + "name": "interfaces_table", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "device": { + "name": "device", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ipv4_cidr": { + "name": "ipv4_cidr", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ipv6_cidr": { + "name": "ipv6_cidr", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mtu": { + "name": "mtu", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "routing_table": { + "name": "routing_table", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'auto'" + }, + "j_c": { + "name": "j_c", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 7 + }, + "j_min": { + "name": "j_min", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "j_max": { + "name": "j_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1000 + }, + "s1": { + "name": "s1", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 128 + }, + "s2": { + "name": "s2", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 56 + }, + "s3": { + "name": "s3", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "s4": { + "name": "s4", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "h1": { + "name": "h1", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "h2": { + "name": "h2", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "h3": { + "name": "h3", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "h4": { + "name": "h4", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i1": { + "name": "i1", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i2": { + "name": "i2", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i3": { + "name": "i3", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i4": { + "name": "i4", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i5": { + "name": "i5", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "header_protection_key": { + "name": "header_protection_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_padding_addition": { + "name": "content_padding_addition", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rekey_after_time": { + "name": "rekey_after_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rekey_timeout": { + "name": "rekey_timeout", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reject_after_time": { + "name": "reject_after_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "keepalive_timeout": { + "name": "keepalive_timeout", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_handshake_attempts": { + "name": "max_handshake_attempts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "random_trailers": { + "name": "random_trailers", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disable_cookies": { + "name": "disable_cookies", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "firewall_enabled": { + "name": "firewall_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "interfaces_table_port_unique": { + "name": "interfaces_table_port_unique", + "columns": [ + "port" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "one_time_links_table": { + "name": "one_time_links_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "one_time_link": { + "name": "one_time_link", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "one_time_links_table_one_time_link_unique": { + "name": "one_time_links_table_one_time_link_unique", + "columns": [ + "one_time_link" + ], + "isUnique": true + } + }, + "foreignKeys": { + "one_time_links_table_id_clients_table_id_fk": { + "name": "one_time_links_table_id_clients_table_id_fk", + "tableFrom": "one_time_links_table", + "tableTo": "clients_table", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users_table": { + "name": "users_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "totp_key": { + "name": "totp_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "totp_verified": { + "name": "totp_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oauth_provider": { + "name": "oauth_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_id": { + "name": "oauth_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "users_table_username_unique": { + "name": "users_table_username_unique", + "columns": [ + "username" + ], + "isUnique": true + }, + "users_table_email_unique": { + "name": "users_table_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "oauth_provider_id_unique": { + "name": "oauth_provider_id_unique", + "columns": [ + "oauth_provider", + "oauth_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_configs_table": { + "name": "user_configs_table", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "default_mtu": { + "name": "default_mtu", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_persistent_keepalive": { + "name": "default_persistent_keepalive", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_dns": { + "name": "default_dns", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_allowed_ips": { + "name": "default_allowed_ips", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_j_c": { + "name": "default_j_c", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 7 + }, + "default_j_min": { + "name": "default_j_min", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "default_j_max": { + "name": "default_j_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1000 + }, + "default_i1": { + "name": "default_i1", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_i2": { + "name": "default_i2", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_i3": { + "name": "default_i3", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_i4": { + "name": "default_i4", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_i5": { + "name": "default_i5", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "user_configs_table_id_interfaces_table_name_fk": { + "name": "user_configs_table_id_interfaces_table_name_fk", + "tableFrom": "user_configs_table", + "tableTo": "interfaces_table", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/server/database/migrations/meta/_journal.json b/src/server/database/migrations/meta/_journal.json index 9bdc1db0..6d23e252 100644 --- a/src/server/database/migrations/meta/_journal.json +++ b/src/server/database/migrations/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1785630544322, "tag": "0007_blushing_electro", "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1788760134302, + "tag": "0008_thin_patch", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/server/database/repositories/client/schema.ts b/src/server/database/repositories/client/schema.ts index eb3a707e..ff82525b 100644 --- a/src/server/database/repositories/client/schema.ts +++ b/src/server/database/repositories/client/schema.ts @@ -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(), serverEndpoint: text('server_endpoint'), enabled: int({ mode: 'boolean' }).notNull(), diff --git a/src/server/database/repositories/client/types.ts b/src/server/database/repositories/client/types.ts index f7497e31..3570403f 100644 --- a/src/server/database/repositories/client/types.ts +++ b/src/server/database/repositories/client/types.ts @@ -7,6 +7,7 @@ import type { client } from './schema'; import { AddressSchema, AllowedIpsSchema, + AwgRangeSchema, DnsSchema, EnabledSchema, FirewallIpsSchema, @@ -109,6 +110,13 @@ export const ClientUpdateSchema = schemaForType()( 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(), diff --git a/src/server/database/repositories/interface/schema.ts b/src/server/database/repositories/interface/schema.ts index 0ce4e9b1..7ffa6141 100644 --- a/src/server/database/repositories/interface/schema.ts +++ b/src/server/database/repositories/interface/schema.ts @@ -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 diff --git a/src/server/database/repositories/interface/types.ts b/src/server/database/repositories/interface/types.ts index f63e9b08..e104fff4 100644 --- a/src/server/database/repositories/interface/types.ts +++ b/src/server/database/repositories/interface/types.ts @@ -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()( - 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 = { diff --git a/src/server/utils/awg.ts b/src/server/utils/awg.ts new file mode 100644 index 00000000..8335f0f8 --- /dev/null +++ b/src/server/utils/awg.ts @@ -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; + +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, + }; +} diff --git a/src/server/utils/types.ts b/src/server/utils/types.ts index 5c6bf55b..979c6b08 100644 --- a/src/server/utils/types.ts +++ b/src/server/utils/types.ts @@ -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 diff --git a/src/server/utils/wgHelper.ts b/src/server/utils/wgHelper.ts index b67bcb73..cb289a8e 100644 --- a/src/server/utils/wgHelper.ts +++ b/src/server/utils/wgHelper.ts @@ -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 diff --git a/src/test/unit/awg.spec.ts b/src/test/unit/awg.spec.ts new file mode 100644 index 00000000..b6ac3828 --- /dev/null +++ b/src/test/unit/awg.spec.ts @@ -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(); + }); +}); diff --git a/src/vitest.config.ts b/src/vitest.config.ts index 3a37baa0..894dcf0f 100644 --- a/src/vitest.config.ts +++ b/src/vitest.config.ts @@ -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: {