6 Commits

Author SHA1 Message Date
Tims 0f55a01c09 Builder fix 2026-02-24 16:14:56 +03:00
Tims 327ba50f65 - AmneziaWG v1.5 support
- docs/configurator.html : custom disk added
2026-02-24 15:59:23 +03:00
Tims d6797440f2 Readme update 2026-02-24 15:15:49 +03:00
amneziawg-mikrotik 95ae01d4c0 Merge pull request #3 from amneziawg-mikrotik/feature/awg-v2
AmneziaWG v2 support (v1.5 not supported)
2026-02-24 15:12:45 +03:00
Tims 045ed45f50 AmneziaWG v2 support (v1.5 not supported) 2026-02-24 15:12:06 +03:00
Tims 4c3bfb7b1b configurator reupload 2026-02-24 00:51:07 +03:00
12 changed files with 1551 additions and 566 deletions
+4
View File
@@ -4,6 +4,10 @@ on:
push:
branches: [main]
concurrency:
group: release
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
+143 -172
View File
@@ -1,120 +1,88 @@
# awg-proxy -- AmneziaWG UDP Proxy for MikroTik
# awg-proxy -- AmneziaWG для MikroTik
🇷🇺 [Русская версия](docs/README.ru.md)
[English version](README_en.md)
Lightweight Docker container that transforms standard WireGuard traffic into AmneziaWG-compatible format, allowing MikroTik routers to connect to AmneziaWG servers with traffic obfuscation support.
Легковесный Docker-контейнер, который позволяет MikroTik подключаться к серверам AmneziaWG. Весь трафик шифруется нативным WireGuard-клиентом роутера, а контейнер только преобразует формат пакетов.
## Table of Contents
- [Requirements](#requirements)
- [Quick Start](#quick-start)
- [Installation](#installation)
- [Verification](#verification)
- [Configuration Reference](#configuration-reference)
- [Getting AWG Parameters](#getting-awg-parameters)
- [Uninstallation](#uninstallation)
- [Building from Source](#building-from-source)
- [Troubleshooting](#troubleshooting)
## How It Works
## Как это работает
```
MikroTik WG client ──UDP── [awg-proxy container] ──UDP── AmneziaWG server
(native crypto) (packet transformation) (sees valid AWG)
MikroTik WG-клиент ──UDP──> [awg-proxy] ──UDP──> сервер AmneziaWG
(шифрование) (преобразование) (обфускация)
```
MikroTik handles all WireGuard cryptography natively using its built-in WG client. The proxy sits between the router and the AmneziaWG server, performing only packet framing transformations:
Прокси заменяет заголовки пакетов, добавляет паддинг и мусорные пакеты так, чтобы сервер AmneziaWG принял трафик. Ключи и данные не затрагиваются.
- **Outbound (WG to AWG):** replaces standard WireGuard message type headers with AmneziaWG values (H1--H4), prepends random padding to handshake packets (S1/S2 bytes), sends junk packets before handshake initiation (Jc packets of Jmin--Jmax bytes), and recomputes MAC1 using the server's public key so the AWG server accepts the packet.
- **Inbound (AWG to WG):** reverses type replacement, strips padding from handshake packets, recomputes MAC1 using the client's public key so MikroTik accepts the response, and silently drops junk packets.
Совместим с AWG v1 и v2 -- версия определяется автоматически по переменным окружения.
No tunnel data or session keys are modified. The proxy is completely transparent to the WireGuard protocol layer.
## Быстрый старт (конфигуратор)
## Quick Start
1. Экспортируйте `.conf`-файл из AmneziaVPN (см. [Получение параметров AWG](#получение-параметров-awg))
2. Откройте [конфигуратор](https://amneziawg-mikrotik.github.io/awg-proxy/configurator.html)
3. Вставьте содержимое `.conf`-файла
4. Скопируйте сгенерированные команды и выполните их в терминале MikroTik
1. Export your AmneziaWG `.conf` file (see [Getting AWG Parameters](#getting-awg-parameters))
2. Open the **[Offline Configurator](https://amneziawg-mikrotik.github.io/awg-proxy/configurator.html)**
3. Paste the `.conf` contents and copy the generated commands
4. Execute the commands on your MikroTik router via terminal
Готово. Конфигуратор работает оффлайн, данные не отправляются на сервер.
## Requirements
## Требования
- **AmneziaWG server** -- a running server with known obfuscation parameters
- **Configuration file** (`.conf`) -- exported from AmneziaVPN (see [Getting AWG Parameters](#getting-awg-parameters))
- **MikroTik RouterOS 7.4+** with the **container** package installed
- **Supported architectures**: ARM64, ARM (v7), or x86\_64
([check your device](https://help.mikrotik.com/docs/spaces/ROS/pages/47579139/Container))
- Device mode enabled: `/system/device-mode/update container=yes`
- At least 5 MB free disk space, 16+ MB free RAM recommended
- Сервер AmneziaWG с известными параметрами обфускации
- Файл конфигурации `.conf`, экспортированный из AmneziaVPN
- MikroTik RouterOS 7.4+ с пакетом **container**
- Архитектура: ARM64, ARM (v7) или x86_64 ([проверить устройство](https://help.mikrotik.com/docs/spaces/ROS/pages/47579139/Container))
- Минимум 5 МБ на диске, рекомендуется 16+ МБ RAM
## Installation
## Ручная установка
### Step 1: Enable container package and reboot
### 1. Включение контейнеров
Install the container package from `/system/package`, then enable container mode and reboot:
Установите пакет container с [mikrotik.com](https://mikrotik.com/download), загрузите на роутер и перезагрузитесь. Затем:
```routeros
/system/device-mode/update container=yes
```
The router will reboot. After it comes back up, proceed to the next steps.
Роутер попросит подтверждение (кнопка или перезагрузка, зависит от модели).
### Choose your setup method
### 2. Загрузка образа
**Option A: [Offline Configurator](https://amneziawg-mikrotik.github.io/awg-proxy/configurator.html) (recommended)**
Скачайте `awg-proxy-{arch}.tar.gz` со страницы [Releases](https://github.com/amneziawg-mikrotik/awg-proxy/releases/latest) и загрузите на роутер через Winbox или SCP.
Paste your AmneziaWG `.conf` file and get ready-to-use MikroTik commands. Copy the output and execute on the router, then skip to [Verification](#verification).
**Option B: Manual setup**
Follow Steps 2--7 below to configure everything manually.
### Step 2: Upload image to router
Download `awg-proxy-{arch}.tar.gz` from [GitHub Releases](https://github.com/amneziawg-mikrotik/awg-proxy/releases/latest) (choose arm64, arm, or amd64 to match your router) and upload it to the router via Winbox or SCP.
Alternatively, download directly from RouterOS (replace the URL with the actual release version):
Или скачайте прямо на роутер (замените URL на актуальный):
```routeros
# /tool/fetch url="https://github.com/amneziawg-mikrotik/awg-proxy/releases/download/vX.X.X/awg-proxy-arm64.tar.gz" dst-path=awg-proxy-arm64.tar.gz
/tool/fetch url="https://github.com/amneziawg-mikrotik/awg-proxy/releases/download/vX.X.X/awg-proxy-arm64.tar.gz" dst-path=awg-proxy-arm64.tar.gz
```
### Step 3: Create network (veth, IP, NAT)
### 3. Настройка сети
```routeros
# Create virtual Ethernet interface for the container
/interface/veth/add name=veth-awg-proxy address=172.18.0.2/30 gateway=172.18.0.1
# Assign IP address to the host side of the veth pair
/ip/address/add address=172.18.0.1/30 interface=veth-awg-proxy
# NAT rule so the container can reach the internet
/ip/firewall/nat/add chain=srcnat action=masquerade src-address=172.18.0.0/30
```
### Step 4: Create WireGuard interface and peer
### 4. WireGuard
```routeros
# Create the WireGuard interface
/interface/wireguard/add name=wg-awg-proxy private-key="YOUR_PRIVATE_KEY" listen-port=12429
# Add the peer, pointing endpoint at the proxy container
/interface/wireguard/peers/add interface=wg-awg-proxy public-key="SERVER_PUBLIC_KEY" preshared-key="YOUR_PRESHARED_KEY" endpoint-address=172.18.0.2 endpoint-port=51820 allowed-address=0.0.0.0/0 persistent-keepalive=25
# Assign the tunnel IP address
/interface/wireguard/peers/add interface=wg-awg-proxy public-key="SERVER_PUBLIC_KEY" \
preshared-key="YOUR_PRESHARED_KEY" endpoint-address=172.18.0.2 endpoint-port=51820 \
allowed-address=0.0.0.0/0 persistent-keepalive=25
/ip/address/add address=YOUR_TUNNEL_IP interface=wg-awg-proxy
```
Replace `YOUR_PRIVATE_KEY` with your WireGuard private key (from `[Interface]` PrivateKey), `SERVER_PUBLIC_KEY` with the AWG server public key (from `[Peer]` PublicKey), `YOUR_PRESHARED_KEY` with the preshared key (if any), and `YOUR_TUNNEL_IP` with the tunnel IP (from `[Interface]` Address, e.g. `10.8.0.2/32`). Add routing rules as needed for your setup.
Замените:
- `YOUR_PRIVATE_KEY` -- PrivateKey из `[Interface]`
- `SERVER_PUBLIC_KEY` -- PublicKey из `[Peer]`
- `YOUR_PRESHARED_KEY` -- PresharedKey из `[Peer]` (если есть)
- `YOUR_TUNNEL_IP` -- Address из `[Interface]` (например, `10.8.0.2/32`)
### Step 5: Set environment variables
`AWG_CLIENT_PUB` is automatically read from the WireGuard interface created in the previous step -- no need to compute it manually.
### 5. Переменные окружения
```routeros
# Container environment variables (AWG obfuscation parameters)
/container/envs/add list=awg-proxy-env key=AWG_LISTEN value=":51820"
/container/envs/add list=awg-proxy-env key=AWG_REMOTE value="YOUR_SERVER:PORT"
/container/envs/add list=awg-proxy-env key=AWG_REMOTE value="SERVER_IP:PORT"
/container/envs/add list=awg-proxy-env key=AWG_JC value="5"
/container/envs/add list=awg-proxy-env key=AWG_JMIN value="30"
/container/envs/add list=awg-proxy-env key=AWG_JMAX value="500"
@@ -128,139 +96,142 @@ Replace `YOUR_PRIVATE_KEY` with your WireGuard private key (from `[Interface]` P
/container/envs/add list=awg-proxy-env key=AWG_CLIENT_PUB value=[/interface/wireguard/get [find name=wg-awg-proxy] public-key]
```
Replace `YOUR_SERVER:PORT` with your AmneziaWG server address and port. Replace all H1--H4, S1, S2, Jc, Jmin, Jmax values with the actual parameters from your AmneziaWG configuration. `AWG_SERVER_PUB` is the AWG server public key (from `[Peer]` PublicKey in your `.conf` file).
Замените все значения на параметры из вашего `.conf`-файла. `AWG_CLIENT_PUB` берется автоматически из WireGuard-интерфейса.
### Step 6: Create container
```routeros
/container/add file=awg-proxy-arm64.tar.gz interface=veth-awg-proxy envlist=awg-proxy-env hostname=awg-proxy root-dir=disk1/awg-proxy logging=yes shm-size=4M start-on-boot=yes
```
### Step 7: Start container
### 6. Создание и запуск контейнера
```routeros
/container/add file=awg-proxy-arm64.tar.gz interface=veth-awg-proxy envlist=awg-proxy-env \
hostname=awg-proxy root-dir=disk1/awg-proxy logging=yes shm-size=4M start-on-boot=yes
/container/start [find where tag~"awg-proxy"]
```
## Verification
After starting the container, confirm that everything is running correctly:
Проверьте работу:
```routeros
/container/print
/interface/wireguard/print
/interface/wireguard/peers/print
/ping 172.18.0.2
```
The container status should show `running`. The WireGuard peer should show a recent handshake time once traffic flows. The ping to `172.18.0.2` confirms the veth link to the container is up.
Контейнер должен быть в статусе `running`, а у пира должно появиться значение `last-handshake`.
## Configuration Reference
## Получение параметров AWG
All configuration is done through environment variables passed to the container.
1. Откройте приложение **AmneziaVPN**
2. Выберите нужное подключение
3. Нажмите **Поделиться** (Share)
4. Выберите: **Протокол**: AmneziaWG, **Формат**: AmneziaWG Format
5. Сохраните `.conf`-файл
| Variable | Required | Default | Description |
|---|---|---|---|
| `AWG_LISTEN` | Yes | -- | Listen address, e.g. `:51820` |
| `AWG_REMOTE` | Yes | -- | AmneziaWG server address (`host:port`) |
| `AWG_JC` | Yes | -- | Junk packet count sent before handshake initiation |
| `AWG_JMIN` | Yes | -- | Minimum junk packet size in bytes |
| `AWG_JMAX` | Yes | -- | Maximum junk packet size in bytes |
| `AWG_S1` | Yes | -- | Random padding prepended to handshake init (bytes) |
| `AWG_S2` | Yes | -- | Random padding prepended to handshake response (bytes) |
| `AWG_H1` | Yes | -- | Replacement message type for handshake init |
| `AWG_H2` | Yes | -- | Replacement message type for handshake response |
| `AWG_H3` | Yes | -- | Replacement message type for cookie reply |
| `AWG_H4` | Yes | -- | Replacement message type for transport data |
| `AWG_SERVER_PUB` | Yes | -- | AWG server public key (base64), used for MAC1 recomputation on outbound handshake packets |
| `AWG_CLIENT_PUB` | Yes | -- | WG client public key (base64), auto-derived from WG interface (see Step 5) |
| `AWG_TIMEOUT` | No | `180` | Inactivity timeout in seconds before reconnecting |
| `AWG_LOG_LEVEL` | No | `info` | Log verbosity: `none`, `error`, or `info` |
Параметры обфускации (`Jc`, `Jmin`, `Jmax`, `S1`, `S2`, `H1`--`H4`) находятся в секции `[Interface]`, а `Endpoint` и `PublicKey` -- в секции `[Peer]`.
## Getting AWG Parameters
## Дополнительные настройки
The Jc, Jmin, Jmax, S1, S2, H1--H4 values must match your AmneziaWG server configuration exactly. To obtain them:
### Маршрутизация трафика через туннель
### Export from AmneziaVPN
Конкретный хост:
1. Open the **AmneziaVPN** application
2. Select the desired connection
3. Tap **Share**
4. Choose: **Protocol**: AmneziaWG, **Format**: AmneziaWG Format
5. Save the resulting `.conf` file
```routeros
/ip/route/add dst-address=8.8.8.8/32 gateway=wg-awg-proxy
```
### Reading the parameters
Подсеть:
1. Open the exported `.conf` file in a text editor.
2. The obfuscation parameters are in the `[Interface]` section:
```ini
[Interface]
Jc = 5
Jmin = 30
Jmax = 500
S1 = 20
S2 = 20
H1 = 1234567890
H2 = 1234567891
H3 = 1234567892
H4 = 1234567893
```
3. The `Endpoint` value from the `[Peer]` section becomes `AWG_REMOTE`.
4. The `PublicKey` value from the `[Peer]` section becomes `AWG_SERVER_PUB`.
5. `AWG_CLIENT_PUB` is derived automatically from the WireGuard interface (see Step 5).
```routeros
/ip/route/add dst-address=10.0.0.0/8 gateway=wg-awg-proxy
```
Alternatively, use the [offline configurator](https://amneziawg-mikrotik.github.io/awg-proxy/configurator.html) to paste your `.conf` file and generate all MikroTik commands automatically.
Просмотр маршрутов:
## Uninstallation
```routeros
/ip/route/print where gateway=wg-awg-proxy
```
The uninstall script is created automatically during installation via the configurator.
To remove awg-proxy, run:
Удаление маршрута:
```routeros
/ip/route/remove [find where dst-address="8.8.8.8/32" gateway="wg-awg-proxy"]
```
### DNS через туннель
Чтобы DNS-запросы шли через туннель, укажите DNS-сервер и добавьте маршрут к нему:
```routeros
/ip/dns/set servers=8.8.8.8,8.8.4.4
/ip/route/add dst-address=8.8.8.8/32 gateway=wg-awg-proxy
/ip/route/add dst-address=8.8.4.4/32 gateway=wg-awg-proxy
```
### Маршрутизация по address-list (продвинутое)
Для выборочной маршрутизации трафика через туннель используйте routing table и mangle rules.
Создание routing table:
```routeros
/routing/table/add disabled=no fib name=r_to_vpn
```
Маршрут по умолчанию через туннель для этой таблицы:
```routeros
/ip/route/add dst-address=0.0.0.0/0 gateway=wg-awg-proxy routing-table=r_to_vpn
```
Address-list с адресами, которые нужно направить через туннель:
```routeros
/ip/firewall/address-list/add address=8.8.8.8 list=to_vpn
/ip/firewall/address-list/add address=1.1.1.1 list=to_vpn
```
Mangle rules для маркировки трафика:
```routeros
# Пропускаем локальный трафик
/ip/firewall/mangle/add chain=prerouting action=accept dst-address=10.0.0.0/8
/ip/firewall/mangle/add chain=prerouting action=accept dst-address=172.16.0.0/12
/ip/firewall/mangle/add chain=prerouting action=accept dst-address=192.168.0.0/16
# Маркируем соединения к адресам из списка
/ip/firewall/mangle/add chain=prerouting action=mark-connection \
dst-address-list=to_vpn connection-mark=no-mark \
new-connection-mark=to-vpn-conn passthrough=yes
# Маркируем маршрутизацию для отмеченных соединений
/ip/firewall/mangle/add chain=prerouting action=mark-routing \
connection-mark=to-vpn-conn new-routing-mark=r_to_vpn passthrough=yes
```
NAT для маркированного трафика:
```routeros
/ip/firewall/nat/add chain=srcnat action=masquerade routing-mark=r_to_vpn
```
Теперь весь трафик к адресам из списка `to_vpn` будет идти через туннель. Добавляйте адреса в список по мере необходимости.
## Удаление
Если установка была через конфигуратор:
```routeros
/system/script/run awg-proxy-uninstall
```
The script removes the container, WireGuard interface, NAT rules, routes,
environment variables, restores previous DNS settings, and deletes itself.
Скрипт удалит контейнер, WireGuard-интерфейс, правила NAT, маршруты, переменные окружения, восстановит DNS и удалит себя.
## Building from Source
## Устранение неполадок
Requires Go 1.25+ and Docker with buildx support.
**Контейнер не запускается** -- проверьте установку пакета container (`/system/package/print`), режим устройства (`/system/device-mode/print`) и свободное место (`/system/resource/print`).
```bash
make build # Build local binary
make test # Run tests with race detector
make docker-arm64 # Build Docker image for ARM64 (MikroTik ARM64 devices)
make docker-arm # Build Docker image for ARM v7
make docker-amd64 # Build Docker image for x86_64
make docker-all # Build for all architectures
```
**Нет рукопожатия** -- убедитесь, что все параметры AWG (Jc, Jmin, Jmax, S1, S2, H1--H4) точно совпадают с сервером. Проверьте `AWG_REMOTE`, `AWG_SERVER_PUB` и `AWG_CLIENT_PUB`.
The Docker build produces a minimal scratch-based image containing a single statically linked binary.
**Нет трафика после рукопожатия** -- проверьте правило NAT (`/ip/firewall/nat/print`), маршрутизацию и `endpoint-address` пира (должен быть `172.18.0.2`).
## Troubleshooting
**Контейнер перезапускается** -- установите `AWG_LOG_LEVEL=info` и проверьте логи. Частая причина -- отсутствующие переменные окружения.
**Container does not start**
- Verify that the container package is installed: `/system/package/print`
- Confirm device mode is enabled: `/system/device-mode/print`
- Check available disk space: `/system/resource/print`
## Лицензия
**Handshake timeout (no connection established)**
- Ensure all AWG parameters (Jc, Jmin, Jmax, S1, S2, H1--H4) match the server configuration exactly. Even a single mismatched value will prevent the handshake.
- Verify that `AWG_REMOTE` points to the correct server address and port.
- Verify that `AWG_SERVER_PUB` and `AWG_CLIENT_PUB` are set correctly. Incorrect public keys cause MAC1 verification failures and silently dropped packets.
- Check that the container can reach the server: the NAT masquerade rule must be in place.
**No traffic after successful handshake**
- Confirm the NAT rule exists: `/ip/firewall/nat/print`
- Check routing on the MikroTik -- traffic to the WireGuard peer must be routed through the proxy.
- Verify the WireGuard peer `endpoint-address` is set to the container IP (`172.18.0.2`).
**Container crash loop**
- Inspect container status: `/container/print`
- Set `AWG_LOG_LEVEL` to `info` to see detailed proxy logs.
- Common cause: missing or invalid environment variables. All required variables must be set.
## License
MIT -- see [LICENSE](LICENSE) for details.
MIT -- см. [LICENSE](LICENSE).
+237
View File
@@ -0,0 +1,237 @@
# awg-proxy -- AmneziaWG for MikroTik
[Русская версия](README.md)
Lightweight Docker container that allows MikroTik routers to connect to AmneziaWG servers. All traffic is encrypted by the router's native WireGuard client; the container only transforms the packet format.
## How It Works
```
MikroTik WG client ──UDP──> [awg-proxy] ──UDP──> AmneziaWG server
(encryption) (transformation) (obfuscation)
```
The proxy replaces packet headers, adds padding and junk packets so the AmneziaWG server accepts the traffic. Keys and data are not modified.
Compatible with AWG v1 and v2 -- the version is detected automatically based on the environment variables.
## Quick Start (Configurator)
1. Export a `.conf` file from AmneziaVPN (see [Getting AWG Parameters](#getting-awg-parameters))
2. Open the [configurator](https://amneziawg-mikrotik.github.io/awg-proxy/configurator.html)
3. Paste the `.conf` file contents
4. Copy the generated commands and run them in MikroTik terminal
Done. The configurator works offline; no data is sent to any server.
## Requirements
- An AmneziaWG server with known obfuscation parameters
- Configuration file `.conf` exported from AmneziaVPN
- MikroTik RouterOS 7.4+ with the **container** package
- Architecture: ARM64, ARM (v7), or x86_64 ([check your device](https://help.mikrotik.com/docs/spaces/ROS/pages/47579139/Container))
- At least 5 MB disk space, 16+ MB RAM recommended
## Manual Installation
### 1. Enable Containers
Install the container package from [mikrotik.com](https://mikrotik.com/download), upload it to the router, and reboot. Then:
```routeros
/system/device-mode/update container=yes
```
The router will ask for confirmation (button press or reboot, depending on the model).
### 2. Upload Image
Download `awg-proxy-{arch}.tar.gz` from [Releases](https://github.com/amneziawg-mikrotik/awg-proxy/releases/latest) and upload it to the router via Winbox or SCP.
Or download directly on the router (replace URL with the actual one):
```routeros
/tool/fetch url="https://github.com/amneziawg-mikrotik/awg-proxy/releases/download/vX.X.X/awg-proxy-arm64.tar.gz" dst-path=awg-proxy-arm64.tar.gz
```
### 3. Network Setup
```routeros
/interface/veth/add name=veth-awg-proxy address=172.18.0.2/30 gateway=172.18.0.1
/ip/address/add address=172.18.0.1/30 interface=veth-awg-proxy
/ip/firewall/nat/add chain=srcnat action=masquerade src-address=172.18.0.0/30
```
### 4. WireGuard
```routeros
/interface/wireguard/add name=wg-awg-proxy private-key="YOUR_PRIVATE_KEY" listen-port=12429
/interface/wireguard/peers/add interface=wg-awg-proxy public-key="SERVER_PUBLIC_KEY" \
preshared-key="YOUR_PRESHARED_KEY" endpoint-address=172.18.0.2 endpoint-port=51820 \
allowed-address=0.0.0.0/0 persistent-keepalive=25
/ip/address/add address=YOUR_TUNNEL_IP interface=wg-awg-proxy
```
Replace:
- `YOUR_PRIVATE_KEY` -- PrivateKey from `[Interface]`
- `SERVER_PUBLIC_KEY` -- PublicKey from `[Peer]`
- `YOUR_PRESHARED_KEY` -- PresharedKey from `[Peer]` (if any)
- `YOUR_TUNNEL_IP` -- Address from `[Interface]` (e.g., `10.8.0.2/32`)
### 5. Environment Variables
```routeros
/container/envs/add list=awg-proxy-env key=AWG_LISTEN value=":51820"
/container/envs/add list=awg-proxy-env key=AWG_REMOTE value="SERVER_IP:PORT"
/container/envs/add list=awg-proxy-env key=AWG_JC value="5"
/container/envs/add list=awg-proxy-env key=AWG_JMIN value="30"
/container/envs/add list=awg-proxy-env key=AWG_JMAX value="500"
/container/envs/add list=awg-proxy-env key=AWG_S1 value="20"
/container/envs/add list=awg-proxy-env key=AWG_S2 value="20"
/container/envs/add list=awg-proxy-env key=AWG_H1 value="1234567890"
/container/envs/add list=awg-proxy-env key=AWG_H2 value="1234567891"
/container/envs/add list=awg-proxy-env key=AWG_H3 value="1234567892"
/container/envs/add list=awg-proxy-env key=AWG_H4 value="1234567893"
/container/envs/add list=awg-proxy-env key=AWG_SERVER_PUB value="SERVER_PUBLIC_KEY"
/container/envs/add list=awg-proxy-env key=AWG_CLIENT_PUB value=[/interface/wireguard/get [find name=wg-awg-proxy] public-key]
```
Replace all values with parameters from your `.conf` file. `AWG_CLIENT_PUB` is read automatically from the WireGuard interface.
### 6. Create and Start Container
```routeros
/container/add file=awg-proxy-arm64.tar.gz interface=veth-awg-proxy envlist=awg-proxy-env \
hostname=awg-proxy root-dir=disk1/awg-proxy logging=yes shm-size=4M start-on-boot=yes
/container/start [find where tag~"awg-proxy"]
```
Verify it works:
```routeros
/container/print
/interface/wireguard/peers/print
```
The container should show `running` status, and the peer should have a `last-handshake` value.
## Getting AWG Parameters
1. Open the **AmneziaVPN** application
2. Select the desired connection
3. Tap **Share**
4. Choose: **Protocol**: AmneziaWG, **Format**: AmneziaWG Format
5. Save the `.conf` file
The obfuscation parameters (`Jc`, `Jmin`, `Jmax`, `S1`, `S2`, `H1`--`H4`) are in the `[Interface]` section, while `Endpoint` and `PublicKey` are in the `[Peer]` section.
## Additional Settings
### Routing Traffic Through the Tunnel
Specific host:
```routeros
/ip/route/add dst-address=8.8.8.8/32 gateway=wg-awg-proxy
```
Subnet:
```routeros
/ip/route/add dst-address=10.0.0.0/8 gateway=wg-awg-proxy
```
View routes:
```routeros
/ip/route/print where gateway=wg-awg-proxy
```
Remove a route:
```routeros
/ip/route/remove [find where dst-address="8.8.8.8/32" gateway="wg-awg-proxy"]
```
### DNS Through the Tunnel
To route DNS queries through the tunnel, set DNS servers and add routes to them:
```routeros
/ip/dns/set servers=8.8.8.8,8.8.4.4
/ip/route/add dst-address=8.8.8.8/32 gateway=wg-awg-proxy
/ip/route/add dst-address=8.8.4.4/32 gateway=wg-awg-proxy
```
### Address-List Based Routing (Advanced)
For selective traffic routing through the tunnel, use routing tables and mangle rules.
Create a routing table:
```routeros
/routing/table/add disabled=no fib name=r_to_vpn
```
Default route through the tunnel for this table:
```routeros
/ip/route/add dst-address=0.0.0.0/0 gateway=wg-awg-proxy routing-table=r_to_vpn
```
Address list with destinations to route through the tunnel:
```routeros
/ip/firewall/address-list/add address=8.8.8.8 list=to_vpn
/ip/firewall/address-list/add address=1.1.1.1 list=to_vpn
```
Mangle rules for traffic marking:
```routeros
# Skip local traffic
/ip/firewall/mangle/add chain=prerouting action=accept dst-address=10.0.0.0/8
/ip/firewall/mangle/add chain=prerouting action=accept dst-address=172.16.0.0/12
/ip/firewall/mangle/add chain=prerouting action=accept dst-address=192.168.0.0/16
# Mark connections to addresses in the list
/ip/firewall/mangle/add chain=prerouting action=mark-connection \
dst-address-list=to_vpn connection-mark=no-mark \
new-connection-mark=to-vpn-conn passthrough=yes
# Mark routing for tagged connections
/ip/firewall/mangle/add chain=prerouting action=mark-routing \
connection-mark=to-vpn-conn new-routing-mark=r_to_vpn passthrough=yes
```
NAT for marked traffic:
```routeros
/ip/firewall/nat/add chain=srcnat action=masquerade routing-mark=r_to_vpn
```
Now all traffic to addresses in the `to_vpn` list will go through the tunnel. Add addresses to the list as needed.
## Uninstallation
If installed via the configurator:
```routeros
/system/script/run awg-proxy-uninstall
```
The script removes the container, WireGuard interface, NAT rules, routes, environment variables, restores DNS settings, and deletes itself.
## Troubleshooting
**Container does not start** -- check the container package is installed (`/system/package/print`), device mode is enabled (`/system/device-mode/print`), and there is enough disk space (`/system/resource/print`).
**No handshake** -- make sure all AWG parameters (Jc, Jmin, Jmax, S1, S2, H1--H4) exactly match the server. Verify `AWG_REMOTE`, `AWG_SERVER_PUB`, and `AWG_CLIENT_PUB`.
**No traffic after handshake** -- check the NAT rule (`/ip/firewall/nat/print`), routing, and the peer's `endpoint-address` (should be `172.18.0.2`).
**Container keeps restarting** -- set `AWG_LOG_LEVEL=info` and check the logs. Common cause: missing environment variables.
## License
MIT -- see [LICENSE](LICENSE).
-268
View File
@@ -1,268 +0,0 @@
# awg-proxy -- UDP-прокси AmneziaWG для MikroTik
Легковесный Docker-контейнер, преобразующий стандартный трафик WireGuard в формат, совместимый с AmneziaWG, что позволяет маршрутизаторам MikroTik подключаться к серверам AmneziaWG с поддержкой обфускации трафика.
## Содержание
- [Требования](#требования)
- [Быстрый старт](#быстрый-старт)
- [Установка](#установка)
- [Проверка](#проверка)
- [Справочник по конфигурации](#справочник-по-конфигурации)
- [Получение параметров AWG](#получение-параметров-awg)
- [Удаление](#удаление)
- [Сборка из исходников](#сборка-из-исходников)
- [Устранение неполадок](#устранение-неполадок)
## Принцип работы
```
WG-клиент MikroTik ──UDP──► [контейнер awg-proxy] ──UDP──► Сервер AmneziaWG
(нативная криптография) (преобразование пакетов) (видит валидный AWG)
```
MikroTik выполняет всю криптографию WireGuard нативно, используя встроенный WG-клиент. Прокси располагается между маршрутизатором и сервером AmneziaWG, выполняя только преобразование структуры пакетов:
- **Исходящие (WG в AWG):** заменяет стандартные заголовки типов сообщений WireGuard на значения AmneziaWG (H1--H4), добавляет случайный паддинг в начало пакетов рукопожатия (S1/S2 байт), отправляет мусорные пакеты перед инициацией рукопожатия (Jc пакетов размером от Jmin до Jmax байт), пересчитывает MAC1 с публичным ключом сервера AWG.
- **Входящие (AWG в WG):** выполняет обратную замену типов, удаляет паддинг из пакетов рукопожатия, молча отбрасывает мусорные пакеты, пересчитывает MAC1 с публичным ключом WG-клиента.
Криптографические ключи и данные туннеля не изменяются. Прокси полностью прозрачен для протокольного уровня WireGuard.
## Быстрый старт
1. Экспортируйте `.conf`-файл AmneziaWG (см. [Получение параметров AWG](#получение-параметров-awg))
2. Откройте **[оффлайн-конфигуратор](https://amneziawg-mikrotik.github.io/awg-proxy/configurator.html)**
3. Вставьте содержимое `.conf`-файла и скопируйте сгенерированные команды
4. Выполните команды на вашем маршрутизаторе MikroTik через терминал
## Требования
- **Сервер AmneziaWG** -- работающий сервер с известными параметрами обфускации
- **Файл конфигурации** (`.conf`) -- экспортированный из AmneziaVPN (см. [Получение параметров AWG](#получение-параметров-awg))
- **MikroTik RouterOS 7.4+** с установленным пакетом **container**
- **Поддерживаемые архитектуры**: ARM64, ARM (v7) или x86\_64
([проверьте своё устройство](https://help.mikrotik.com/docs/spaces/ROS/pages/47579139/Container))
- Включённый режим устройства: `/system/device-mode/update container=yes`
- Минимум 5 МБ свободного места на диске, рекомендуется 16+ МБ свободной RAM
## Установка
### Шаг 1: Включение пакета container
Если пакет container ещё не установлен, скачайте его с [mikrotik.com](https://mikrotik.com/download) для вашей версии RouterOS и архитектуры, загрузите на роутер и перезагрузитесь. Затем включите режим устройства:
```routeros
/system/device-mode/update container=yes
```
После этой команды роутер попросит физически нажать кнопку подтверждения (или перезагрузится автоматически, в зависимости от модели).
### Выберите способ настройки
**Вариант А: [Оффлайн-конфигуратор](https://amneziawg-mikrotik.github.io/awg-proxy/configurator.html) (рекомендуется)**
Вставьте содержимое вашего `.conf`-файла AmneziaWG и получите готовые команды MikroTik. Скопируйте и выполните их на роутере, затем перейдите к разделу [Проверка](#проверка).
**Вариант Б: Ручная настройка**
Следуйте шагам 2--7 ниже для пошаговой настройки.
### Шаг 2: Загрузка образа
Скачайте `awg-proxy-{arch}.tar.gz` из раздела [GitHub Releases](https://github.com/amneziawg-mikrotik/awg-proxy/releases/latest) (выберите arm64, arm или amd64 в соответствии с архитектурой вашего маршрутизатора) и загрузите файл на маршрутизатор MikroTik.
Альтернативно, можно скачать образ прямо на роутер командой RouterOS (замените URL на актуальный из раздела releases):
```routeros
# /tool/fetch url="https://github.com/amneziawg-mikrotik/awg-proxy/releases/download/vX.X.X/awg-proxy-arm64.tar.gz" dst-path=awg-proxy-arm64.tar.gz
```
### Шаг 3: Настройка сети
Создайте виртуальный Ethernet-интерфейс для контейнера и настройте NAT:
```routeros
# Создание виртуального Ethernet-интерфейса для контейнера
/interface/veth/add name=veth-awg-proxy address=172.18.0.2/30 gateway=172.18.0.1
# Назначение IP-адреса на хостовую сторону veth-пары
/ip/address/add address=172.18.0.1/30 interface=veth-awg-proxy
# Правило NAT для доступа контейнера в интернет
/ip/firewall/nat/add chain=srcnat action=masquerade src-address=172.18.0.0/30
```
### Шаг 4: Настройка WireGuard
Создайте WireGuard-интерфейс, добавьте пира, указывающего на контейнер, и назначьте туннельный IP-адрес:
```routeros
# Создание WireGuard-интерфейса
/interface/wireguard/add name=wg-awg-proxy private-key="YOUR_PRIVATE_KEY" listen-port=12429
# Добавление пира (endpoint указывает на контейнер awg-proxy)
/interface/wireguard/peers/add interface=wg-awg-proxy public-key="SERVER_PUBLIC_KEY" preshared-key="YOUR_PRESHARED_KEY" endpoint-address=172.18.0.2 endpoint-port=51820 allowed-address=0.0.0.0/0 persistent-keepalive=25
# Назначение туннельного IP-адреса интерфейсу
/ip/address/add address=YOUR_TUNNEL_IP interface=wg-awg-proxy
```
Замените `YOUR_PRIVATE_KEY` на приватный ключ из `[Interface]` PrivateKey, `SERVER_PUBLIC_KEY` на публичный ключ сервера из `[Peer]` PublicKey, `YOUR_PRESHARED_KEY` на preshared-ключ (если есть), `YOUR_TUNNEL_IP` на туннельный IP из `[Interface]` Address (например, `10.8.0.2/32`). Маршруты настраиваются отдельно по необходимости.
### Шаг 5: Переменные окружения
`AWG_CLIENT_PUB` автоматически считывается из WireGuard-интерфейса, созданного на предыдущем шаге -- вычислять его вручную не нужно.
```routeros
# Переменные окружения контейнера (параметры обфускации AWG)
/container/envs/add list=awg-proxy-env key=AWG_LISTEN value=":51820"
/container/envs/add list=awg-proxy-env key=AWG_REMOTE value="YOUR_SERVER:PORT"
/container/envs/add list=awg-proxy-env key=AWG_JC value="5"
/container/envs/add list=awg-proxy-env key=AWG_JMIN value="30"
/container/envs/add list=awg-proxy-env key=AWG_JMAX value="500"
/container/envs/add list=awg-proxy-env key=AWG_S1 value="20"
/container/envs/add list=awg-proxy-env key=AWG_S2 value="20"
/container/envs/add list=awg-proxy-env key=AWG_H1 value="1234567890"
/container/envs/add list=awg-proxy-env key=AWG_H2 value="1234567891"
/container/envs/add list=awg-proxy-env key=AWG_H3 value="1234567892"
/container/envs/add list=awg-proxy-env key=AWG_H4 value="1234567893"
/container/envs/add list=awg-proxy-env key=AWG_SERVER_PUB value="SERVER_PUBLIC_KEY"
/container/envs/add list=awg-proxy-env key=AWG_CLIENT_PUB value=[/interface/wireguard/get [find name=wg-awg-proxy] public-key]
```
Замените `YOUR_SERVER:PORT` на адрес и порт вашего сервера AmneziaWG. Замените все значения H1--H4, S1, S2, Jc, Jmin, Jmax на реальные параметры из вашей конфигурации AmneziaWG. `AWG_SERVER_PUB` -- публичный ключ сервера (из `[Peer]` PublicKey в вашем .conf-файле).
### Шаг 6: Создание контейнера
```routeros
/container/add file=awg-proxy-arm64.tar.gz interface=veth-awg-proxy envlist=awg-proxy-env hostname=awg-proxy root-dir=disk1/awg-proxy logging=yes shm-size=4M start-on-boot=yes
```
### Шаг 7: Запуск
```routeros
/container/start [find where tag~"awg-proxy"]
```
## Проверка
После запуска убедитесь, что контейнер работает и туннель поднялся:
```routeros
/container/print
/interface/wireguard/print
/interface/wireguard/peers/print
/ping 172.18.0.2
```
Контейнер должен быть в состоянии `running`. У пира WireGuard поле `last-handshake` должно обновиться в течение нескольких секунд после запуска.
## Справочник по конфигурации
Вся настройка выполняется через переменные окружения, передаваемые контейнеру.
| Переменная | Обязательна | По умолчанию | Описание |
|---|---|---|---|
| `AWG_LISTEN` | Да | -- | Адрес прослушивания, например `:51820` |
| `AWG_REMOTE` | Да | -- | Адрес сервера AmneziaWG (`хост:порт`) |
| `AWG_JC` | Да | -- | Количество мусорных пакетов перед инициацией рукопожатия |
| `AWG_JMIN` | Да | -- | Минимальный размер мусорного пакета в байтах |
| `AWG_JMAX` | Да | -- | Максимальный размер мусорного пакета в байтах |
| `AWG_S1` | Да | -- | Случайный паддинг перед пакетом инициации рукопожатия (байт) |
| `AWG_S2` | Да | -- | Случайный паддинг перед пакетом ответа рукопожатия (байт) |
| `AWG_H1` | Да | -- | Подменный тип сообщения для инициации рукопожатия |
| `AWG_H2` | Да | -- | Подменный тип сообщения для ответа рукопожатия |
| `AWG_H3` | Да | -- | Подменный тип сообщения для cookie reply |
| `AWG_H4` | Да | -- | Подменный тип сообщения для транспортных данных |
| `AWG_SERVER_PUB` | Да | -- | Публичный ключ сервера AWG (base64), используется для пересчёта MAC1 в исходящих пакетах рукопожатия |
| `AWG_CLIENT_PUB` | Да | -- | Публичный ключ WG-клиента (base64), берётся автоматически из WG-интерфейса (см. шаг 5) |
| `AWG_TIMEOUT` | Нет | `180` | Таймаут неактивности в секундах до переподключения |
| `AWG_LOG_LEVEL` | Нет | `info` | Уровень логирования: `none`, `error` или `info` |
## Получение параметров AWG
Значения Jc, Jmin, Jmax, S1, S2, H1--H4 должны точно совпадать с конфигурацией вашего сервера AmneziaWG. Чтобы их получить:
### Экспорт из AmneziaVPN
1. Откройте приложение **AmneziaVPN**
2. Выберите нужное подключение
3. Нажмите **Поделиться** (Share)
4. Выберите: **Протокол**: AmneziaWG, **Формат**: AmneziaWG Format
5. Сохраните полученный `.conf`-файл
### Чтение параметров
1. Откройте экспортированный `.conf`-файл в текстовом редакторе.
2. Параметры обфускации находятся в секции `[Interface]`:
```ini
[Interface]
Jc = 5
Jmin = 30
Jmax = 500
S1 = 20
S2 = 20
H1 = 1234567890
H2 = 1234567891
H3 = 1234567892
H4 = 1234567893
```
3. Значение `Endpoint` из секции `[Peer]` используется как `AWG_REMOTE`.
4. Значение `PublicKey` из секции `[Peer]` используется как `AWG_SERVER_PUB`.
5. `AWG_CLIENT_PUB` берётся автоматически из WireGuard-интерфейса (см. шаг 5).
Также можно воспользоваться [оффлайн-конфигуратором](https://amneziawg-mikrotik.github.io/awg-proxy/configurator.html) -- вставьте содержимое .conf-файла и получите готовые команды MikroTik.
## Удаление
Скрипт удаления создаётся автоматически при установке через конфигуратор.
Для удаления awg-proxy выполните:
```routeros
/system/script/run awg-proxy-uninstall
```
Скрипт удаляет контейнер, WireGuard-интерфейс, правила NAT, маршруты,
переменные окружения, восстанавливает предыдущие настройки DNS и удаляет себя.
## Сборка из исходников
Требуется Go 1.25+ и Docker с поддержкой buildx.
```bash
make build # Сборка локального бинарника
make test # Запуск тестов с детектором гонок
make docker-arm64 # Docker-образ для ARM64 (устройства MikroTik ARM64)
make docker-arm # Docker-образ для ARM v7
make docker-amd64 # Docker-образ для x86_64
make docker-all # Сборка для всех архитектур
```
Docker-сборка создаёт минимальный образ на основе scratch, содержащий единственный статически скомпонованный бинарный файл.
## Устранение неполадок
**Контейнер не запускается**
- Убедитесь, что пакет container установлен: `/system/package/print`
- Проверьте, что режим устройства включён: `/system/device-mode/print`
- Проверьте свободное место на диске: `/system/resource/print`
**Таймаут рукопожатия (соединение не устанавливается)**
- Убедитесь, что все параметры AWG (Jc, Jmin, Jmax, S1, S2, H1--H4) точно совпадают с конфигурацией сервера. Даже одно несовпадающее значение приведёт к невозможности установить рукопожатие.
- Проверьте, что `AWG_REMOTE` указывает на правильный адрес и порт сервера.
- Убедитесь, что `AWG_SERVER_PUB` и `AWG_CLIENT_PUB` заданы корректно -- неверные ключи приведут к невалидному MAC1 и отбросу пакетов сервером или клиентом.
- Убедитесь, что контейнер может достичь сервера -- правило NAT masquerade должно быть настроено.
**Нет трафика после успешного рукопожатия**
- Проверьте наличие правила NAT: `/ip/firewall/nat/print`
- Проверьте маршрутизацию на MikroTik -- трафик к пиру WireGuard должен идти через прокси.
- Убедитесь, что `endpoint-address` пира WireGuard установлен на IP контейнера (`172.18.0.2`).
**Контейнер перезапускается в цикле**
- Проверьте статус контейнера: `/container/print`
- Установите `AWG_LOG_LEVEL` в `info` для просмотра подробных логов прокси.
- Частая причина: отсутствующие или некорректные переменные окружения. Все обязательные переменные должны быть заданы.
## Лицензия
MIT -- подробности в файле [LICENSE](../LICENSE).
+173 -20
View File
@@ -107,6 +107,29 @@
color: #9ca3af;
}
select, input[type="text"] {
font-family: var(--mono);
font-size: 0.85rem;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fafafa;
color: var(--text);
transition: border-color 0.15s;
}
select:focus, input[type="text"]:focus {
outline: none;
border-color: var(--border-focus);
background: #fff;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.12);
}
#storage-custom {
margin-left: 8px;
width: 160px;
}
.btn {
display: inline-flex;
align-items: center;
@@ -275,7 +298,7 @@
<div class="container">
<header>
<h1>AWG Proxy &mdash; MikroTik Command Generator</h1>
<h1>AWG Proxy &mdash; MikroTik Offline Command Generator</h1>
<p>Paste your AmneziaWG .conf file contents to generate ready-to-use MikroTik RouterOS commands</p>
</header>
@@ -284,6 +307,19 @@
<label for="conf-input">.conf file contents</label>
<textarea id="conf-input"
placeholder="[Interface]&#10;PrivateKey = ...&#10;Address = 10.1.1.1/32&#10;DNS = 1.1.1.1&#10;Jc = 2&#10;Jmin = 1&#10;Jmax = 500&#10;S1 = 123&#10;S2 = 12&#10;H1 = 12345&#10;...&#10;&#10;[Peer]&#10;PublicKey = ...&#10;Endpoint = 1.2.3.4:12345&#10;AllowedIPs = 0.0.0.0/0"></textarea>
<div style="margin-top:14px">
<label for="storage-select">Container storage</label>
<select id="storage-select">
<option value="disk1">disk1 — internal storage (default)</option>
<option value="usb1">usb1 — USB flash drive</option>
<option value="usb2">usb2 — USB flash drive (2nd)</option>
<option value="sd1">sd1 — SD / microSD card</option>
<option value="nvme1">nvme1 — NVMe SSD</option>
<option value="sata1">sata1 — SATA drive</option>
<option value="__custom">Custom...</option>
</select>
<input type="text" id="storage-custom" placeholder="e.g. usb1-part1" style="display:none">
</div>
<div class="actions">
<button class="btn btn-primary" onclick="generate()">Generate commands</button>
<button class="btn btn-secondary" onclick="clearAll()">Clear</button>
@@ -351,7 +387,7 @@
// Field definitions
// ============================================================
var IFACE_REQUIRED = ['PrivateKey', 'Address', 'Jc', 'Jmin', 'Jmax', 'S1', 'S2', 'H1', 'H2', 'H3', 'H4'];
var IFACE_OPTIONAL = ['DNS'];
var IFACE_OPTIONAL = ['DNS', 'S3', 'S4', 'I1', 'I2', 'I3', 'I4', 'I5'];
var PEER_REQUIRED = ['PublicKey', 'Endpoint', 'AllowedIPs'];
var PEER_OPTIONAL = ['PresharedKey', 'PersistentKeepalive'];
@@ -359,7 +395,14 @@
// Render parsed fields as tags
// ============================================================
function renderFields(parsed) {
var html = '<div style="margin-bottom:8px;font-size:0.8rem;color:var(--text-muted)">[Interface] section</div>';
var proto = detectProtocol(parsed.interface);
var badgeColors = {
'v1': 'background:#e5e7eb;color:#374151',
'v1.5': 'background:#fef3c7;color:#92400e',
'v2': 'background:#dbeafe;color:#1e40af'
};
var protoBadge = '<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.75rem;font-weight:600;' + badgeColors[proto] + ';margin-left:8px">AWG ' + proto + '</span>';
var html = '<div style="margin-bottom:8px;font-size:0.8rem;color:var(--text-muted)">[Interface] section' + protoBadge + '</div>';
html += '<div class="fields-grid">';
IFACE_REQUIRED.concat(IFACE_OPTIONAL).forEach(function (k) {
var v = parsed.interface[k];
@@ -405,6 +448,13 @@
return n >= 0 && n <= 4294967295;
}
function isHValue(s) {
if (isUint32(s)) return true;
var parts = s.split('-');
return parts.length === 2 && isUint32(parts[0]) && isUint32(parts[1])
&& Number(parts[0]) <= Number(parts[1]);
}
function isValidIPCIDR(s) {
var m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\/(\d{1,2})$/.exec(s);
if (!m) return false;
@@ -461,6 +511,51 @@
return /^[1-9]\d*$/.test(s);
}
function isValidCPSTemplate(s) {
if (!s || !s.trim()) return false;
var i = 0;
var count = 0;
while (i < s.length) {
if (s[i] === ' ' || s[i] === '\t' || s[i] === '\n' || s[i] === '\r') { i++; continue; }
if (s[i] !== '<') return false;
var end = s.indexOf('>', i + 1);
if (end < 0) return false;
var tag = s.slice(i + 1, end).trim();
if (!tag) return false;
var kind = tag[0];
if (kind === 'b') {
var hex = tag.slice(1).trim();
if (hex.length < 3 || hex.slice(0, 2).toLowerCase() !== '0x') return false;
if (!/^[0-9a-fA-F]*$/.test(hex.slice(2)) || hex.length < 4 || (hex.length - 2) % 2 !== 0) return false;
} else if (kind === 'r') {
if (tag.length > 1 && (tag[1] === 'c' || tag[1] === 'd')) {
var n = tag.slice(2).trim();
if (!/^[1-9]\d*$/.test(n)) return false;
} else {
var n = tag.slice(1).trim();
if (!/^[1-9]\d*$/.test(n)) return false;
}
} else if (kind === 't' || kind === 'c') {
if (tag.trim().length !== 1) return false;
} else {
return false;
}
count++;
i = end + 1;
}
return count > 0;
}
function detectProtocol(iface) {
var hasHRange = (iface.H1 && iface.H1.indexOf('-') >= 0) ||
(iface.H2 && iface.H2.indexOf('-') >= 0) ||
(iface.H3 && iface.H3.indexOf('-') >= 0) ||
(iface.H4 && iface.H4.indexOf('-') >= 0);
if (iface.S3 || iface.S4 || hasHRange) return 'v2';
if (iface.I1 || iface.I2 || iface.I3 || iface.I4 || iface.I5) return 'v1.5';
return 'v1';
}
// ============================================================
// Validate
// ============================================================
@@ -499,15 +594,19 @@
errors.push('[Interface] S1 -- must be a non-negative integer');
if (!isNonNegativeInt(iface.S2))
errors.push('[Interface] S2 -- must be a non-negative integer');
if (iface.S3 && !isNonNegativeInt(iface.S3))
errors.push('[Interface] S3 -- must be a non-negative integer');
if (iface.S4 && !isNonNegativeInt(iface.S4))
errors.push('[Interface] S4 -- must be a non-negative integer');
if (!isUint32(iface.H1))
errors.push('[Interface] H1 -- must be an integer 0..4294967295');
if (!isUint32(iface.H2))
errors.push('[Interface] H2 -- must be an integer 0..4294967295');
if (!isUint32(iface.H3))
errors.push('[Interface] H3 -- must be an integer 0..4294967295');
if (!isUint32(iface.H4))
errors.push('[Interface] H4 -- must be an integer 0..4294967295');
if (!isHValue(iface.H1))
errors.push('[Interface] H1 -- must be uint32 or range MIN-MAX');
if (!isHValue(iface.H2))
errors.push('[Interface] H2 -- must be uint32 or range MIN-MAX');
if (!isHValue(iface.H3))
errors.push('[Interface] H3 -- must be uint32 or range MIN-MAX');
if (!isHValue(iface.H4))
errors.push('[Interface] H4 -- must be uint32 or range MIN-MAX');
if (!isValidBase64Key(peer.PublicKey))
errors.push('[Peer] PublicKey -- invalid base64 key (expected 44 chars, 32 bytes)');
@@ -520,6 +619,11 @@
if (peer.PersistentKeepalive && !isPositiveInt(peer.PersistentKeepalive))
errors.push('[Peer] PersistentKeepalive -- must be a positive integer');
['I1','I2','I3','I4','I5'].forEach(function(k) {
if (iface[k] && !isValidCPSTemplate(iface[k]))
errors.push('[Interface] ' + k + ' -- invalid CPS template (valid tags: <b 0xHEX>, <r N>, <rc N>, <rd N>, <t>, <c>)');
});
return errors;
}
@@ -534,7 +638,7 @@
// ============================================================
// Generate uninstall script as /system/script/add command
// ============================================================
function buildUninstallScriptSource(hasDNS) {
function buildUninstallScriptSource(hasDNS, storageDisk) {
var src = [];
src.push('/system/script/add name=awg-proxy-uninstall comment=awg-proxy source={');
src.push(' :put "Uninstallimg AmneziaWG..."');
@@ -565,7 +669,7 @@
src.push(' /ip/address/remove [find where interface="wg-awg-proxy"]');
src.push(' /interface/wireguard/remove [find where name="wg-awg-proxy"]');
src.push(' :do { /file/remove [find where name~"awg-proxy.+tar"] } on-error={}');
src.push(' :do { /file/remove [find where name="disk1/awg-proxy"] } on-error={}');
src.push(' :do { /file/remove [find where name="' + storageDisk + '/awg-proxy"] } on-error={}');
src.push(' :put "Uninstall AmneziaWG Proxy complete!"');
src.push(' :log info "Uninstall AmneziaWG Proxy complete!"');
src.push(' /system/script/remove [find where name=awg-proxy-uninstall]');
@@ -576,7 +680,7 @@
// ============================================================
// Generate MikroTik commands
// ============================================================
function buildCommands(parsed) {
function buildCommands(parsed, storageDisk) {
var i = parsed.interface;
var p = parsed.peer;
var dnsServers = parseDNSServers(i.DNS);
@@ -589,9 +693,10 @@
' allowed-address=' + p.AllowedIPs.replace(/\s*,\s*/g, ',');
if (p.PersistentKeepalive) peerAdd += ' persistent-keepalive=' + p.PersistentKeepalive;
var detectedProto = detectProtocol(i);
var lines = [
'# ============================================================',
'# AWG Proxy -- MikroTik RouterOS',
'# AWG Proxy -- MikroTik RouterOS (protocol: ' + detectedProto + ')',
'# ' + new Date().toISOString().slice(0, 19).replace('T', ' '),
'# ============================================================',
'',
@@ -604,7 +709,7 @@
'# 1. Uninstall script',
];
lines.push(buildUninstallScriptSource(hasDNS));
lines.push(buildUninstallScriptSource(hasDNS, storageDisk));
lines = lines.concat([
'',
@@ -614,7 +719,7 @@
'/ip/firewall/nat/add chain=srcnat action=masquerade src-address=172.18.0.0/30',
'',
'# 3. WireGuard interface (MikroTik derives the public key automatically)',
'/interface/wireguard/add name=wg-awg-proxy private-key="' + i.PrivateKey + '" listen-port=12429',
'/interface/wireguard/add name=wg-awg-proxy private-key="' + i.PrivateKey + '" listen-port=12429 disabled=yes',
peerAdd,
'/ip/address/add address=' + i.Address + ' interface=wg-awg-proxy',
'/ip/firewall/nat/add chain=srcnat action=masquerade out-interface=wg-awg-proxy',
@@ -635,6 +740,12 @@
'/container/envs/add list=awg-proxy-env key=AWG_CLIENT_PUB value=[/interface/wireguard/get [find name=wg-awg-proxy] public-key]',
]);
if (i.S3) lines.push('/container/envs/add list=awg-proxy-env key=AWG_S3 value="' + i.S3 + '"');
if (i.S4) lines.push('/container/envs/add list=awg-proxy-env key=AWG_S4 value="' + i.S4 + '"');
['I1','I2','I3','I4','I5'].forEach(function(k) {
if (i[k]) lines.push('/container/envs/add list=awg-proxy-env key=AWG_' + k + ' value="' + i[k] + '"');
});
if (hasDNS) {
var dnsStr = dnsServers.join(',');
lines.push('');
@@ -670,7 +781,7 @@
' } else={',
' :put "File $file already exists, skipping download"',
' }',
' /container/add file=$file interface=veth-awg-proxy envlist=awg-proxy-env hostname=awg-proxy root-dir=disk1/awg-proxy logging=yes shm-size=4M start-on-boot=yes comment=awg-proxy',
' /container/add file=$file interface=veth-awg-proxy envlist=awg-proxy-env hostname=awg-proxy root-dir=' + storageDisk + '/awg-proxy logging=yes shm-size=4M start-on-boot=yes comment=awg-proxy',
' /file/remove $file',
' :local freeMem [/system/resource/get free-memory]',
' :if ($freeMem < 16777216) do={',
@@ -678,13 +789,16 @@
' }',
' /container/start [find where interface=veth-awg-proxy]',
' :do { /file/remove [find where name="console-dump.txt"] } on-error={}',
' :put "Waiting for container to start..."',
' :delay 5s',
' /interface/wireguard/enable wg-awg-proxy',
' :put "WireGuard interface enabled"',
' :put "Installation complete!"',
]);
if (hasDNS) {
var firstDNS = dnsServers[0];
lines = lines.concat([
' :delay 2s',
' :put "Ping test: ' + firstDNS + ' via AmneziaWG tunnel. repeat 10 times"',
' /ping ' + firstDNS + ' count=10 interval=1',
]);
@@ -693,6 +807,19 @@
lines = lines.concat([
' :put ""',
' :put "======= NEXT STEPS ======="',
]);
if (detectedProto !== 'v2') {
lines = lines.concat([
' :put ""',
' :put " NOTE: Your config uses AWG ' + detectedProto + '. For better obfuscation,"',
' :put " update AmneziaVPN to the latest version and regenerate the config."',
' :put " Latest version supports AWG v2 with H-ranges and S3/S4 padding."',
]);
}
lines = lines.concat([
' :put ""',
' :put "Route specific traffic through the AmneziaWG tunnel:"',
' :put ""',
' :put " Route a single host:"',
@@ -736,6 +863,29 @@
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ============================================================
// Storage selector
// ============================================================
document.getElementById('storage-select').addEventListener('change', function() {
var custom = document.getElementById('storage-custom');
if (this.value === '__custom') {
custom.style.display = 'inline-block';
custom.focus();
} else {
custom.style.display = 'none';
custom.value = '';
}
});
function getStorageDisk() {
var sel = document.getElementById('storage-select').value;
if (sel === '__custom') {
var v = document.getElementById('storage-custom').value.trim();
return v || 'disk1';
}
return sel;
}
// ============================================================
// Main
// ============================================================
@@ -769,7 +919,7 @@
return;
}
var commands = buildCommands(parsed);
var commands = buildCommands(parsed, getStorageDisk());
document.getElementById('output').dataset.plain = commands;
document.getElementById('output').innerHTML = highlight(commands);
document.getElementById('output-section').style.display = 'block';
@@ -798,6 +948,9 @@
document.getElementById('parsed-section').style.display = 'none';
document.getElementById('output-section').style.display = 'none';
document.getElementById('uninstall-section').style.display = 'none';
document.getElementById('storage-select').value = 'disk1';
document.getElementById('storage-custom').style.display = 'none';
document.getElementById('storage-custom').value = '';
document.getElementById('conf-input').focus();
}
+246
View File
@@ -0,0 +1,246 @@
package awg
import (
"encoding/binary"
"errors"
"math/rand/v2"
"strconv"
"time"
)
const alphanumChars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
func randAlphanumFill(b []byte) {
for i := range b {
b[i] = alphanumChars[rand.IntN(len(alphanumChars))]
}
}
func randDigitFill(b []byte) {
for i := range b {
b[i] = '0' + byte(rand.IntN(10))
}
}
// CPS template segment kinds.
const (
cpsStatic byte = 'b' // static hex bytes
cpsRandom byte = 'r' // random bytes
cpsTimestamp byte = 't' // 4-byte LE unix timestamp
cpsCounter byte = 'c' // 4-byte LE packet counter
cpsRandomChars byte = 'C' // random alphanumeric ASCII chars (rc tag)
cpsRandomDigits byte = 'D' // random decimal digits (rd tag)
)
type cpsSegment struct {
kind byte
data []byte // static bytes for 'b'
size int // byte count for 'r'
}
// CPSTemplate represents a parsed CPS template (I1-I5).
type CPSTemplate struct {
segments []cpsSegment
}
// ParseCPSTemplate parses a CPS template string.
// Format tags: <b 0xHEX>, <r SIZE>, <rc SIZE>, <rd SIZE>, <t>, <c>
func ParseCPSTemplate(s string) (*CPSTemplate, error) {
var segs []cpsSegment
i := 0
for i < len(s) {
// Skip whitespace between tags.
if s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r' {
i++
continue
}
if s[i] != '<' {
return nil, errors.New("expected '<' at position " + strconv.Itoa(i))
}
// Find closing '>'.
end := -1
for j := i + 1; j < len(s); j++ {
if s[j] == '>' {
end = j
break
}
}
if end < 0 {
return nil, errors.New("unclosed '<' at position " + strconv.Itoa(i))
}
inner := s[i+1 : end]
seg, err := parseCPSTag(inner)
if err != nil {
return nil, err
}
segs = append(segs, seg)
i = end + 1
}
if len(segs) == 0 {
return nil, errors.New("empty CPS template")
}
return &CPSTemplate{segments: segs}, nil
}
func parseCPSTag(tag string) (cpsSegment, error) {
if len(tag) == 0 {
return cpsSegment{}, errors.New("empty tag")
}
kind := tag[0]
switch kind {
case 'b':
// <b 0xHEXDATA>
rest := trimLeft(tag[1:])
if len(rest) < 3 || rest[0] != '0' || (rest[1] != 'x' && rest[1] != 'X') {
return cpsSegment{}, errors.New("expected '0x' prefix in <b> tag")
}
hex := rest[2:]
data, err := decodeHex(hex)
if err != nil {
return cpsSegment{}, err
}
return cpsSegment{kind: cpsStatic, data: data}, nil
case 'r':
// <r SIZE>, <rc SIZE>, <rd SIZE>
if len(tag) > 1 && tag[1] == 'c' {
rest := trimLeft(tag[2:])
size, err := strconv.Atoi(rest)
if err != nil {
return cpsSegment{}, errors.New("invalid size in <rc> tag: " + err.Error())
}
if size <= 0 {
return cpsSegment{}, errors.New("<rc> size must be positive")
}
return cpsSegment{kind: cpsRandomChars, size: size}, nil
}
if len(tag) > 1 && tag[1] == 'd' {
rest := trimLeft(tag[2:])
size, err := strconv.Atoi(rest)
if err != nil {
return cpsSegment{}, errors.New("invalid size in <rd> tag: " + err.Error())
}
if size <= 0 {
return cpsSegment{}, errors.New("<rd> size must be positive")
}
return cpsSegment{kind: cpsRandomDigits, size: size}, nil
}
rest := trimLeft(tag[1:])
size, err := strconv.Atoi(rest)
if err != nil {
return cpsSegment{}, errors.New("invalid size in <r> tag: " + err.Error())
}
if size <= 0 {
return cpsSegment{}, errors.New("<r> size must be positive")
}
return cpsSegment{kind: cpsRandom, size: size}, nil
case 't':
return cpsSegment{kind: cpsTimestamp}, nil
case 'c':
return cpsSegment{kind: cpsCounter}, nil
default:
return cpsSegment{}, errors.New("unknown tag kind: " + string(kind))
}
}
// trimLeft removes leading spaces/tabs.
func trimLeft(s string) string {
i := 0
for i < len(s) && (s[i] == ' ' || s[i] == '\t') {
i++
}
return s[i:]
}
// Generate builds a CPS packet from the template.
func (t *CPSTemplate) Generate(counter uint32) []byte {
// Calculate total size.
total := 0
for _, seg := range t.segments {
switch seg.kind {
case cpsStatic:
total += len(seg.data)
case cpsRandom, cpsRandomChars, cpsRandomDigits:
total += seg.size
case cpsTimestamp:
total += 4
case cpsCounter:
total += 4
}
}
buf := make([]byte, total)
off := 0
for _, seg := range t.segments {
switch seg.kind {
case cpsStatic:
copy(buf[off:], seg.data)
off += len(seg.data)
case cpsRandom:
randFill(buf[off : off+seg.size])
off += seg.size
case cpsRandomChars:
randAlphanumFill(buf[off : off+seg.size])
off += seg.size
case cpsRandomDigits:
randDigitFill(buf[off : off+seg.size])
off += seg.size
case cpsTimestamp:
binary.LittleEndian.PutUint32(buf[off:], uint32(time.Now().Unix()))
off += 4
case cpsCounter:
binary.LittleEndian.PutUint32(buf[off:], counter)
off += 4
}
}
return buf
}
// GenerateCPSPackets generates all configured CPS packets (I1->I5 order).
// counter is incremented for each packet sent.
func GenerateCPSPackets(templates [5]*CPSTemplate, counter *uint32) [][]byte {
var packets [][]byte
for _, tmpl := range templates {
if tmpl == nil {
continue
}
pkt := tmpl.Generate(*counter)
*counter++
packets = append(packets, pkt)
}
return packets
}
// decodeHex decodes a hex string to bytes. Hand-written, no encoding/hex dependency.
func decodeHex(s string) ([]byte, error) {
if len(s)%2 != 0 {
return nil, errors.New("odd-length hex string")
}
out := make([]byte, len(s)/2)
for i := 0; i < len(s); i += 2 {
hi := hexVal(s[i])
lo := hexVal(s[i+1])
if hi < 0 || lo < 0 {
return nil, errors.New("invalid hex char at position " + strconv.Itoa(i))
}
out[i/2] = byte(hi<<4 | lo)
}
return out, nil
}
// hexVal returns the value of a hex character, or -1 if invalid.
func hexVal(c byte) int {
switch {
case c >= '0' && c <= '9':
return int(c - '0')
case c >= 'a' && c <= 'f':
return int(c-'a') + 10
case c >= 'A' && c <= 'F':
return int(c-'A') + 10
default:
return -1
}
}
+321
View File
@@ -0,0 +1,321 @@
package awg
import (
"encoding/binary"
"testing"
)
func TestParseCPSStaticBytes(t *testing.T) {
tmpl, err := ParseCPSTemplate("<b 0x0844>")
if err != nil {
t.Fatal(err)
}
if len(tmpl.segments) != 1 {
t.Fatalf("expected 1 segment, got %d", len(tmpl.segments))
}
seg := tmpl.segments[0]
if seg.kind != cpsStatic {
t.Fatalf("expected kind 'b', got %c", seg.kind)
}
if len(seg.data) != 2 || seg.data[0] != 0x08 || seg.data[1] != 0x44 {
t.Fatalf("expected [0x08 0x44], got %v", seg.data)
}
}
func TestParseCPSRandom(t *testing.T) {
tmpl, err := ParseCPSTemplate("<r 16>")
if err != nil {
t.Fatal(err)
}
if len(tmpl.segments) != 1 {
t.Fatalf("expected 1 segment, got %d", len(tmpl.segments))
}
seg := tmpl.segments[0]
if seg.kind != cpsRandom {
t.Fatalf("expected kind 'r', got %c", seg.kind)
}
if seg.size != 16 {
t.Fatalf("expected size 16, got %d", seg.size)
}
}
func TestParseCPSTimestamp(t *testing.T) {
tmpl, err := ParseCPSTemplate("<t>")
if err != nil {
t.Fatal(err)
}
if len(tmpl.segments) != 1 {
t.Fatalf("expected 1 segment, got %d", len(tmpl.segments))
}
if tmpl.segments[0].kind != cpsTimestamp {
t.Fatalf("expected kind 't', got %c", tmpl.segments[0].kind)
}
}
func TestParseCPSCounter(t *testing.T) {
tmpl, err := ParseCPSTemplate("<c>")
if err != nil {
t.Fatal(err)
}
if len(tmpl.segments) != 1 {
t.Fatalf("expected 1 segment, got %d", len(tmpl.segments))
}
if tmpl.segments[0].kind != cpsCounter {
t.Fatalf("expected kind 'c', got %c", tmpl.segments[0].kind)
}
}
func TestParseCPSRandomChars(t *testing.T) {
tmpl, err := ParseCPSTemplate("<rc 12>")
if err != nil {
t.Fatal(err)
}
if len(tmpl.segments) != 1 {
t.Fatalf("expected 1 segment, got %d", len(tmpl.segments))
}
seg := tmpl.segments[0]
if seg.kind != cpsRandomChars {
t.Fatalf("expected kind cpsRandomChars, got %c", seg.kind)
}
if seg.size != 12 {
t.Fatalf("expected size 12, got %d", seg.size)
}
}
func TestParseCPSRandomDigits(t *testing.T) {
tmpl, err := ParseCPSTemplate("<rd 8>")
if err != nil {
t.Fatal(err)
}
if len(tmpl.segments) != 1 {
t.Fatalf("expected 1 segment, got %d", len(tmpl.segments))
}
seg := tmpl.segments[0]
if seg.kind != cpsRandomDigits {
t.Fatalf("expected kind cpsRandomDigits, got %c", seg.kind)
}
if seg.size != 8 {
t.Fatalf("expected size 8, got %d", seg.size)
}
}
func TestGenerateRandomChars(t *testing.T) {
tmpl, err := ParseCPSTemplate("<rc 20>")
if err != nil {
t.Fatal(err)
}
pkt := tmpl.Generate(0)
if len(pkt) != 20 {
t.Fatalf("expected 20 bytes, got %d", len(pkt))
}
for i, b := range pkt {
if !isAlphanumeric(b) {
t.Fatalf("byte %d: 0x%x is not alphanumeric", i, b)
}
}
}
func TestGenerateRandomDigits(t *testing.T) {
tmpl, err := ParseCPSTemplate("<rd 10>")
if err != nil {
t.Fatal(err)
}
pkt := tmpl.Generate(0)
if len(pkt) != 10 {
t.Fatalf("expected 10 bytes, got %d", len(pkt))
}
for i, b := range pkt {
if b < '0' || b > '9' {
t.Fatalf("byte %d: 0x%x is not a digit", i, b)
}
}
}
func isAlphanumeric(b byte) bool {
return (b >= '0' && b <= '9') || (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z')
}
func TestParseCPSMixedWithRcRd(t *testing.T) {
tmpl, err := ParseCPSTemplate("<b 0xDEAD> <rc 8> <t> <rd 4>")
if err != nil {
t.Fatal(err)
}
if len(tmpl.segments) != 4 {
t.Fatalf("expected 4 segments, got %d", len(tmpl.segments))
}
kinds := []byte{cpsStatic, cpsRandomChars, cpsTimestamp, cpsRandomDigits}
for i, seg := range tmpl.segments {
if seg.kind != kinds[i] {
t.Fatalf("segment %d: expected kind %c, got %c", i, kinds[i], seg.kind)
}
}
pkt := tmpl.Generate(0)
// 2 (static) + 8 (rc) + 4 (timestamp) + 4 (rd) = 18
if len(pkt) != 18 {
t.Fatalf("expected 18 bytes, got %d", len(pkt))
}
if pkt[0] != 0xDE || pkt[1] != 0xAD {
t.Fatalf("static bytes mismatch")
}
for i := 2; i < 10; i++ {
if !isAlphanumeric(pkt[i]) {
t.Fatalf("rc byte %d: 0x%x is not alphanumeric", i, pkt[i])
}
}
for i := 14; i < 18; i++ {
if pkt[i] < '0' || pkt[i] > '9' {
t.Fatalf("rd byte %d: 0x%x is not a digit", i, pkt[i])
}
}
}
func TestParseCPSRcRdInvalid(t *testing.T) {
cases := []string{
"<rc>", // no size
"<rc abc>", // non-numeric
"<rc 0>", // zero
"<rc -1>", // negative
"<rd>", // no size
"<rd abc>", // non-numeric
"<rd 0>", // zero
"<rd -1>", // negative
}
for _, tc := range cases {
_, err := ParseCPSTemplate(tc)
if err == nil {
t.Fatalf("expected error for %q, got nil", tc)
}
}
}
func TestParseCPSMultiSegment(t *testing.T) {
tmpl, err := ParseCPSTemplate("<b 0xAABB> <r 8> <t> <c>")
if err != nil {
t.Fatal(err)
}
if len(tmpl.segments) != 4 {
t.Fatalf("expected 4 segments, got %d", len(tmpl.segments))
}
kinds := []byte{cpsStatic, cpsRandom, cpsTimestamp, cpsCounter}
for i, seg := range tmpl.segments {
if seg.kind != kinds[i] {
t.Fatalf("segment %d: expected kind %c, got %c", i, kinds[i], seg.kind)
}
}
}
func TestParseCPSEmpty(t *testing.T) {
_, err := ParseCPSTemplate("")
if err == nil {
t.Fatal("expected error for empty template")
}
}
func TestParseCPSInvalid(t *testing.T) {
cases := []string{
"no tags here",
"<b>", // no hex data
"<b 0x>", // empty hex
"<b 0xGG>", // invalid hex
"<b 0x1>", // odd-length hex
"<r>", // no size
"<r abc>", // non-numeric size
"<r -5>", // negative size
"<r 0>", // zero size
"<x>", // unknown kind
"<b 0xFF", // unclosed tag
}
for _, tc := range cases {
_, err := ParseCPSTemplate(tc)
if err == nil {
t.Fatalf("expected error for %q, got nil", tc)
}
}
}
func TestGenerateCPS(t *testing.T) {
tmpl, err := ParseCPSTemplate("<b 0xDEAD> <r 4> <c>")
if err != nil {
t.Fatal(err)
}
pkt := tmpl.Generate(42)
// Expected: 2 bytes static + 4 bytes random + 4 bytes counter = 10
if len(pkt) != 10 {
t.Fatalf("expected 10 bytes, got %d", len(pkt))
}
if pkt[0] != 0xDE || pkt[1] != 0xAD {
t.Fatalf("static bytes mismatch: %x %x", pkt[0], pkt[1])
}
counter := binary.LittleEndian.Uint32(pkt[6:10])
if counter != 42 {
t.Fatalf("expected counter 42, got %d", counter)
}
}
func TestGenerateCPSPackets(t *testing.T) {
t1, _ := ParseCPSTemplate("<b 0xFF>")
t3, _ := ParseCPSTemplate("<c>")
var templates [5]*CPSTemplate
templates[0] = t1
templates[2] = t3
var counter uint32
packets := GenerateCPSPackets(templates, &counter)
if len(packets) != 2 {
t.Fatalf("expected 2 packets (I1 + I3), got %d", len(packets))
}
if counter != 2 {
t.Fatalf("expected counter 2, got %d", counter)
}
// First packet: I1 -> counter was 0
if len(packets[0]) != 1 || packets[0][0] != 0xFF {
t.Fatalf("I1 packet mismatch")
}
// Second packet: I3 -> counter was 1
c := binary.LittleEndian.Uint32(packets[1])
if c != 1 {
t.Fatalf("I3 counter: expected 1, got %d", c)
}
}
func TestDecodeHex(t *testing.T) {
cases := []struct {
input string
want []byte
}{
{"", nil},
{"00", []byte{0}},
{"FF", []byte{0xFF}},
{"ff", []byte{0xFF}},
{"0123456789abcdef", []byte{0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF}},
}
for _, tc := range cases {
got, err := decodeHex(tc.input)
if err != nil {
t.Fatalf("decodeHex(%q): %v", tc.input, err)
}
if len(got) != len(tc.want) {
t.Fatalf("decodeHex(%q): len %d, want %d", tc.input, len(got), len(tc.want))
}
for i := range got {
if got[i] != tc.want[i] {
t.Fatalf("decodeHex(%q): byte %d: %x, want %x", tc.input, i, got[i], tc.want[i])
}
}
}
}
func TestDecodeHexErrors(t *testing.T) {
cases := []string{
"1", // odd length
"GG", // invalid chars
"0G", // second char invalid
}
for _, tc := range cases {
_, err := decodeHex(tc)
if err == nil {
t.Fatalf("decodeHex(%q): expected error", tc)
}
}
}
+11
View File
@@ -20,6 +20,7 @@ type Proxy struct {
clientAddr atomic.Pointer[net.UDPAddr]
pool sync.Pool
lastRecv atomic.Int64 // unix timestamp of last packet from server
cpsCounter uint32 // counter for CPS <c> tags
}
// NewProxy creates a new Proxy instance.
@@ -118,6 +119,16 @@ func (p *Proxy) clientToServer(listenConn *net.UDPConn, remoteConn *net.UDPConn,
LogDebug(p.cfg, "c->s: recv ", strconv.Itoa(n), "B, send ", strconv.Itoa(len(out)), "B, junk=", strconv.FormatBool(sendJunk))
if sendJunk {
// CPS packets (I1->I2->I3->I4->I5).
cpsPackets := GenerateCPSPackets(p.cfg.CPS, &p.cpsCounter)
for ci, pkt := range cpsPackets {
if _, err := currentRemote.Write(pkt); err != nil {
LogDebug(p.cfg, "c->s: cps ", strconv.Itoa(ci), " write err: ", err.Error())
break
}
LogDebug(p.cfg, "c->s: cps ", strconv.Itoa(ci+1), "/", strconv.Itoa(len(cpsPackets)), " ", strconv.Itoa(len(pkt)), "B sent")
}
// Junk packets.
junkPackets := GenerateJunkPackets(p.cfg)
for i, junk := range junkPackets {
if _, err := currentRemote.Write(junk); err != nil {
+10 -10
View File
@@ -18,10 +18,10 @@ func ams42Config() *Config {
Jmax: 50,
S1: 46,
S2: 122,
H1: 1033089720,
H2: 1336452505,
H3: 1858775673,
H4: 332219739,
H1: HRange{Min: 1033089720, Max: 1033089720},
H2: HRange{Min: 1336452505, Max: 1336452505},
H3: HRange{Min: 1858775673, Max: 1858775673},
H4: HRange{Min: 332219739, Max: 332219739},
Timeout: 180,
LogLevel: LevelInfo,
}
@@ -177,8 +177,8 @@ func TestProxyForwardsHandshakeInit(t *testing.T) {
// Check H1 type at offset S1.
gotType := binary.LittleEndian.Uint32(hsInit[cfg.S1 : cfg.S1+4])
if gotType != cfg.H1 {
t.Fatalf("handshake init type: expected H1=%d, got %d", cfg.H1, gotType)
if !cfg.H1.Contains(gotType) {
t.Fatalf("handshake init type: expected H1=%d, got %d", cfg.H1.Min, gotType)
}
t.Logf("H1 type at offset S1=%d: %d (correct)", cfg.S1, gotType)
@@ -234,7 +234,7 @@ func TestProxyForwardsHandshakeResponse(t *testing.T) {
// Step 2: From mock server, send back a transformed handshake response.
// Build AWG handshake response: S2 padding + H2 type + 92 bytes total inner.
innerResponse := make([]byte, WgHandshakeResponseSize)
binary.LittleEndian.PutUint32(innerResponse[:4], cfg.H2)
binary.LittleEndian.PutUint32(innerResponse[:4], cfg.H2.Min)
for i := 4; i < WgHandshakeResponseSize; i++ {
innerResponse[i] = byte(i + 100) // distinct payload
}
@@ -367,8 +367,8 @@ func TestProxyForwardsTransportData(t *testing.T) {
}
gotType := binary.LittleEndian.Uint32(pkt[:4])
if gotType != cfg.H4 {
t.Fatalf("transport packet: expected H4=%d, got %d", cfg.H4, gotType)
if !cfg.H4.Contains(gotType) {
t.Fatalf("transport packet: expected H4=%d, got %d", cfg.H4.Min, gotType)
}
t.Logf("transport packet type: H4=%d (correct)", gotType)
@@ -420,7 +420,7 @@ func TestProxyRealAWGServer(t *testing.T) {
t.Logf("proxy will listen on %s", proxyAddr.String())
t.Logf("remote AWG server: %s", remoteAddr.String())
t.Logf("config: Jc=%d Jmin=%d Jmax=%d S1=%d S2=%d H1=%d H2=%d H3=%d H4=%d",
cfg.Jc, cfg.Jmin, cfg.Jmax, cfg.S1, cfg.S2, cfg.H1, cfg.H2, cfg.H3, cfg.H4)
cfg.Jc, cfg.Jmin, cfg.Jmax, cfg.S1, cfg.S2, cfg.H1.Min, cfg.H2.Min, cfg.H3.Min, cfg.H4.Min)
proxy := NewProxy(cfg, proxyAddr, remoteAddr)
stop := make(chan struct{})
+66 -73
View File
@@ -37,6 +37,24 @@ const (
WgTransportMinSize = 32
)
// HRange represents a uint32 range [Min, Max] for v2 H-parameters.
type HRange struct {
Min, Max uint32
}
// Pick returns a random value in [Min, Max]. Returns Min if Min == Max.
func (r HRange) Pick() uint32 {
if r.Min == r.Max {
return r.Min
}
return r.Min + uint32(rand.IntN(int(r.Max-r.Min+1)))
}
// Contains returns true if v is in [Min, Max].
func (r HRange) Contains(v uint32) bool {
return v >= r.Min && v <= r.Max
}
// Config holds AmneziaWG obfuscation parameters.
type Config struct {
Jc int // junk packet count before handshake init
@@ -44,10 +62,14 @@ type Config struct {
Jmax int // max junk packet size
S1 int // padding bytes prepended to handshake init
S2 int // padding bytes prepended to handshake response
H1 uint32 // replacement type for handshake init
H2 uint32 // replacement type for handshake response
H3 uint32 // replacement type for cookie reply
H4 uint32 // replacement type for transport data
S3 int // padding bytes prepended to cookie reply (v2, default 0)
S4 int // padding bytes prepended to transport data (v2, default 0)
H1 HRange // replacement type for handshake init
H2 HRange // replacement type for handshake response
H3 HRange // replacement type for cookie reply
H4 HRange // replacement type for transport data
CPS [5]*CPSTemplate // I1-I5 CPS templates (v2, nil = not configured)
ServerPub [32]byte // AWG server public key (for outbound MAC1 recomputation)
ClientPub [32]byte // WG client public key (for inbound MAC1 recomputation)
@@ -85,7 +107,7 @@ func TransformOutbound(buf []byte, n int, cfg *Config) (out []byte, sendJunk boo
switch {
case msgType == wgHandshakeInit && n == WgHandshakeInitSize:
// Replace type and recompute MAC1.
binary.LittleEndian.PutUint32(buf[:4], cfg.H1)
binary.LittleEndian.PutUint32(buf[:4], cfg.H1.Pick())
if cfg.ServerPub != ([32]byte{}) {
recomputeMAC1(buf[:n], cfg.mac1keyServer)
}
@@ -100,7 +122,7 @@ func TransformOutbound(buf []byte, n int, cfg *Config) (out []byte, sendJunk boo
case msgType == wgHandshakeResponse && n == WgHandshakeResponseSize:
// Replace type and prepend S2 padding bytes.
binary.LittleEndian.PutUint32(buf[:4], cfg.H2)
binary.LittleEndian.PutUint32(buf[:4], cfg.H2.Pick())
if cfg.S2 > 0 {
out = make([]byte, cfg.S2+n)
randFill(out[:cfg.S2])
@@ -111,14 +133,26 @@ func TransformOutbound(buf []byte, n int, cfg *Config) (out []byte, sendJunk boo
return out, false
case msgType == wgCookieReply && n == WgCookieReplySize:
// Replace type, no padding.
binary.LittleEndian.PutUint32(buf[:4], cfg.H3)
return buf[:n], false
binary.LittleEndian.PutUint32(buf[:4], cfg.H3.Pick())
if cfg.S3 > 0 {
out = make([]byte, cfg.S3+n)
randFill(out[:cfg.S3])
copy(out[cfg.S3:], buf[:n])
} else {
out = buf[:n]
}
return out, false
case msgType == wgTransportData && n >= WgTransportMinSize:
// Hot path: replace type in-place, no allocation.
binary.LittleEndian.PutUint32(buf[:4], cfg.H4)
return buf[:n], false
binary.LittleEndian.PutUint32(buf[:4], cfg.H4.Pick())
if cfg.S4 > 0 {
out = make([]byte, cfg.S4+n)
randFill(out[:cfg.S4])
copy(out[cfg.S4:], buf[:n])
} else {
out = buf[:n]
}
return out, false
default:
// Unknown packet, pass through unchanged.
@@ -128,80 +162,39 @@ func TransformOutbound(buf []byte, n int, cfg *Config) (out []byte, sendJunk boo
// TransformInbound transforms an inbound AmneziaWG packet back to standard WireGuard format.
// Returns the transformed packet and whether it is valid (junk packets return valid=false).
// Uses scanning to find the header at offsets [0, maxScan] to handle S1-S4 padding.
func TransformInbound(buf []byte, n int, cfg *Config) (out []byte, valid bool) {
if n < 4 {
return nil, false
}
// Check for handshake init with S1 padding: total size = S1 + 148.
if cfg.S1 > 0 && n == cfg.S1+WgHandshakeInitSize {
offset := cfg.S1
if n < offset+4 {
return nil, false
}
msgType := binary.LittleEndian.Uint32(buf[offset : offset+4])
if msgType == cfg.H1 {
binary.LittleEndian.PutUint32(buf[offset:offset+4], wgHandshakeInit)
return buf[offset:n], true
}
}
maxScan := max(cfg.S1, cfg.S2, cfg.S3, cfg.S4)
// Check for handshake init without padding (S1=0).
if cfg.S1 == 0 && n == WgHandshakeInitSize {
msgType := binary.LittleEndian.Uint32(buf[:4])
if msgType == cfg.H1 {
binary.LittleEndian.PutUint32(buf[:4], wgHandshakeInit)
return buf[:n], true
}
}
for off := 0; off <= maxScan && off+4 <= n; off++ {
h := binary.LittleEndian.Uint32(buf[off : off+4])
rem := n - off
// Check for handshake response with S2 padding: total size = S2 + 92.
if cfg.S2 > 0 && n == cfg.S2+WgHandshakeResponseSize {
offset := cfg.S2
if n < offset+4 {
return nil, false
if cfg.H1.Contains(h) && rem == WgHandshakeInitSize {
binary.LittleEndian.PutUint32(buf[off:off+4], wgHandshakeInit)
return buf[off:n], true
}
msgType := binary.LittleEndian.Uint32(buf[offset : offset+4])
if msgType == cfg.H2 {
binary.LittleEndian.PutUint32(buf[offset:offset+4], wgHandshakeResponse)
if cfg.H2.Contains(h) && rem == WgHandshakeResponseSize {
binary.LittleEndian.PutUint32(buf[off:off+4], wgHandshakeResponse)
if cfg.ClientPub != ([32]byte{}) {
recomputeMAC1Response(buf[offset:offset+WgHandshakeResponseSize], cfg.mac1keyClient)
recomputeMAC1Response(buf[off:off+WgHandshakeResponseSize], cfg.mac1keyClient)
}
return buf[offset:n], true
return buf[off:n], true
}
if cfg.H3.Contains(h) && rem == WgCookieReplySize {
binary.LittleEndian.PutUint32(buf[off:off+4], wgCookieReply)
return buf[off:n], true
}
if cfg.H4.Contains(h) && rem >= WgTransportMinSize {
binary.LittleEndian.PutUint32(buf[off:off+4], wgTransportData)
return buf[off:n], true
}
}
// Check for handshake response without padding (S2=0).
if cfg.S2 == 0 && n == WgHandshakeResponseSize {
msgType := binary.LittleEndian.Uint32(buf[:4])
if msgType == cfg.H2 {
binary.LittleEndian.PutUint32(buf[:4], wgHandshakeResponse)
if cfg.ClientPub != ([32]byte{}) {
recomputeMAC1Response(buf[:WgHandshakeResponseSize], cfg.mac1keyClient)
}
return buf[:n], true
}
}
// Cookie reply: no padding, fixed size.
if n == WgCookieReplySize {
msgType := binary.LittleEndian.Uint32(buf[:4])
if msgType == cfg.H3 {
binary.LittleEndian.PutUint32(buf[:4], wgCookieReply)
return buf[:n], true
}
}
// Transport data: no padding, variable size >= 32.
if n >= WgTransportMinSize {
msgType := binary.LittleEndian.Uint32(buf[:4])
if msgType == cfg.H4 {
binary.LittleEndian.PutUint32(buf[:4], wgTransportData)
return buf[:n], true
}
}
// Unknown or junk packet.
return nil, false
}
+287 -16
View File
@@ -12,10 +12,10 @@ func testConfig() *Config {
Jmax: 500,
S1: 20,
S2: 20,
H1: 1234567890,
H2: 1234567891,
H3: 1234567892,
H4: 1234567893,
H1: HRange{Min: 1234567890, Max: 1234567890},
H2: HRange{Min: 1234567891, Max: 1234567891},
H3: HRange{Min: 1234567892, Max: 1234567892},
H4: HRange{Min: 1234567893, Max: 1234567893},
}
}
@@ -45,8 +45,8 @@ func TestTransformOutboundHandshakeInit(t *testing.T) {
}
// Check type at S1 offset.
gotType := binary.LittleEndian.Uint32(out[cfg.S1 : cfg.S1+4])
if gotType != cfg.H1 {
t.Fatalf("expected type %d, got %d", cfg.H1, gotType)
if !cfg.H1.Contains(gotType) {
t.Fatalf("expected type in H1 range [%d,%d], got %d", cfg.H1.Min, cfg.H1.Max, gotType)
}
// Check payload preserved after type.
for i := 4; i < WgHandshakeInitSize; i++ {
@@ -71,8 +71,8 @@ func TestTransformOutboundHandshakeResponse(t *testing.T) {
t.Fatalf("expected len %d, got %d", cfg.S2+WgHandshakeResponseSize, len(out))
}
gotType := binary.LittleEndian.Uint32(out[cfg.S2 : cfg.S2+4])
if gotType != cfg.H2 {
t.Fatalf("expected type %d, got %d", cfg.H2, gotType)
if !cfg.H2.Contains(gotType) {
t.Fatalf("expected type in H2 range [%d,%d], got %d", cfg.H2.Min, cfg.H2.Max, gotType)
}
}
@@ -89,8 +89,8 @@ func TestTransformOutboundCookieReply(t *testing.T) {
t.Fatalf("expected len %d, got %d", WgCookieReplySize, len(out))
}
gotType := binary.LittleEndian.Uint32(out[:4])
if gotType != cfg.H3 {
t.Fatalf("expected type %d, got %d", cfg.H3, gotType)
if !cfg.H3.Contains(gotType) {
t.Fatalf("expected type in H3 range [%d,%d], got %d", cfg.H3.Min, cfg.H3.Max, gotType)
}
}
@@ -107,15 +107,15 @@ func TestTransformOutboundTransportData(t *testing.T) {
t.Fatalf("expected len 100, got %d", len(out))
}
gotType := binary.LittleEndian.Uint32(out[:4])
if gotType != cfg.H4 {
t.Fatalf("expected type %d, got %d", cfg.H4, gotType)
if !cfg.H4.Contains(gotType) {
t.Fatalf("expected type in H4 range [%d,%d], got %d", cfg.H4.Min, cfg.H4.Max, gotType)
}
}
func TestTransformInboundHandshakeInit(t *testing.T) {
cfg := testConfig()
// Build an AWG handshake init: S1 random bytes + H1 type + payload.
inner := makePacket(cfg.H1, WgHandshakeInitSize)
inner := makePacket(cfg.H1.Min, WgHandshakeInitSize)
buf := make([]byte, cfg.S1+WgHandshakeInitSize)
copy(buf[cfg.S1:], inner)
@@ -134,7 +134,7 @@ func TestTransformInboundHandshakeInit(t *testing.T) {
func TestTransformInboundHandshakeResponse(t *testing.T) {
cfg := testConfig()
inner := makePacket(cfg.H2, WgHandshakeResponseSize)
inner := makePacket(cfg.H2.Min, WgHandshakeResponseSize)
buf := make([]byte, cfg.S2+WgHandshakeResponseSize)
copy(buf[cfg.S2:], inner)
@@ -153,7 +153,7 @@ func TestTransformInboundHandshakeResponse(t *testing.T) {
func TestTransformInboundCookieReply(t *testing.T) {
cfg := testConfig()
buf := makePacket(cfg.H3, WgCookieReplySize)
buf := makePacket(cfg.H3.Min, WgCookieReplySize)
out, valid := TransformInbound(buf, len(buf), cfg)
if !valid {
@@ -167,7 +167,7 @@ func TestTransformInboundCookieReply(t *testing.T) {
func TestTransformInboundTransportData(t *testing.T) {
cfg := testConfig()
buf := makePacket(cfg.H4, 100)
buf := makePacket(cfg.H4.Min, 100)
out, valid := TransformInbound(buf, len(buf), cfg)
if !valid {
@@ -368,3 +368,274 @@ func TestOutboundTooShort(t *testing.T) {
t.Fatalf("expected passthrough for too-short packet")
}
}
// ============================================================
// v2-specific tests
// ============================================================
func TestHRangePick(t *testing.T) {
// Point range: always returns Min.
r := HRange{Min: 42, Max: 42}
for i := 0; i < 100; i++ {
if v := r.Pick(); v != 42 {
t.Fatalf("point range Pick: expected 42, got %d", v)
}
}
// Range: should return values in [100, 200].
r2 := HRange{Min: 100, Max: 200}
for i := 0; i < 100; i++ {
v := r2.Pick()
if v < 100 || v > 200 {
t.Fatalf("range Pick: %d not in [100, 200]", v)
}
}
}
func TestHRangeContains(t *testing.T) {
r := HRange{Min: 10, Max: 20}
if !r.Contains(10) {
t.Fatal("expected Contains(10)=true")
}
if !r.Contains(15) {
t.Fatal("expected Contains(15)=true")
}
if !r.Contains(20) {
t.Fatal("expected Contains(20)=true")
}
if r.Contains(9) {
t.Fatal("expected Contains(9)=false")
}
if r.Contains(21) {
t.Fatal("expected Contains(21)=false")
}
}
func TestOutboundCookieWithS3(t *testing.T) {
cfg := testConfig()
cfg.S3 = 49
payload := makePacket(wgCookieReply, WgCookieReplySize)
out, sendJunk := TransformOutbound(payload, WgCookieReplySize, cfg)
if sendJunk {
t.Fatal("expected sendJunk=false for cookie reply")
}
if len(out) != cfg.S3+WgCookieReplySize {
t.Fatalf("expected len %d, got %d", cfg.S3+WgCookieReplySize, len(out))
}
gotType := binary.LittleEndian.Uint32(out[cfg.S3 : cfg.S3+4])
if !cfg.H3.Contains(gotType) {
t.Fatalf("expected H3 type, got %d", gotType)
}
}
func TestOutboundTransportWithS4(t *testing.T) {
cfg := testConfig()
cfg.S4 = 17
payload := makePacket(wgTransportData, 100)
out, sendJunk := TransformOutbound(payload, 100, cfg)
if sendJunk {
t.Fatal("expected sendJunk=false for transport data")
}
if len(out) != cfg.S4+100 {
t.Fatalf("expected len %d, got %d", cfg.S4+100, len(out))
}
gotType := binary.LittleEndian.Uint32(out[cfg.S4 : cfg.S4+4])
if !cfg.H4.Contains(gotType) {
t.Fatalf("expected H4 type, got %d", gotType)
}
}
func TestInboundScanningWithS3(t *testing.T) {
cfg := testConfig()
cfg.S3 = 49
// Build AWG cookie reply with S3 padding.
inner := makePacket(cfg.H3.Min, WgCookieReplySize)
buf := make([]byte, cfg.S3+WgCookieReplySize)
randFill(buf[:cfg.S3])
copy(buf[cfg.S3:], inner)
out, valid := TransformInbound(buf, len(buf), cfg)
if !valid {
t.Fatal("expected valid=true for cookie with S3 padding")
}
if len(out) != WgCookieReplySize {
t.Fatalf("expected len %d, got %d", WgCookieReplySize, len(out))
}
gotType := binary.LittleEndian.Uint32(out[:4])
if gotType != wgCookieReply {
t.Fatalf("expected type %d, got %d", wgCookieReply, gotType)
}
}
func TestInboundScanningWithS4(t *testing.T) {
cfg := testConfig()
cfg.S4 = 17
// Build AWG transport with S4 padding.
inner := makePacket(cfg.H4.Min, 100)
buf := make([]byte, cfg.S4+100)
randFill(buf[:cfg.S4])
copy(buf[cfg.S4:], inner)
out, valid := TransformInbound(buf, len(buf), cfg)
if !valid {
t.Fatal("expected valid=true for transport with S4 padding")
}
if len(out) != 100 {
t.Fatalf("expected len 100, got %d", len(out))
}
gotType := binary.LittleEndian.Uint32(out[:4])
if gotType != wgTransportData {
t.Fatalf("expected type %d, got %d", wgTransportData, gotType)
}
}
func TestInboundHRange(t *testing.T) {
cfg := testConfig()
cfg.H4 = HRange{Min: 1000, Max: 2000}
// Use a value inside the range.
buf := makePacket(1500, 100)
out, valid := TransformInbound(buf, len(buf), cfg)
if !valid {
t.Fatal("expected valid=true for value in H4 range")
}
gotType := binary.LittleEndian.Uint32(out[:4])
if gotType != wgTransportData {
t.Fatalf("expected type %d, got %d", wgTransportData, gotType)
}
}
func TestInboundHRangeReject(t *testing.T) {
cfg := testConfig()
cfg.H4 = HRange{Min: 1000, Max: 2000}
// Use a value outside the range.
buf := makePacket(999, 100)
_, valid := TransformInbound(buf, len(buf), cfg)
if valid {
t.Fatal("expected valid=false for value outside H4 range")
}
}
func TestRoundtripV2(t *testing.T) {
cfg := testConfig()
cfg.S3 = 49
cfg.S4 = 17
cfg.H1 = HRange{Min: 100000, Max: 200000}
cfg.H2 = HRange{Min: 300000, Max: 400000}
cfg.H3 = HRange{Min: 500000, Max: 600000}
cfg.H4 = HRange{Min: 700000, Max: 800000}
// Roundtrip handshake init.
initPkt := makePacket(wgHandshakeInit, WgHandshakeInitSize)
savedInit := make([]byte, WgHandshakeInitSize)
copy(savedInit, initPkt)
out, _ := TransformOutbound(initPkt, WgHandshakeInitSize, cfg)
result, valid := TransformInbound(out, len(out), cfg)
if !valid {
t.Fatal("v2 roundtrip init: invalid")
}
if binary.LittleEndian.Uint32(result[:4]) != wgHandshakeInit {
t.Fatal("v2 roundtrip init: type not restored")
}
// Roundtrip cookie reply with S3.
cookiePkt := makePacket(wgCookieReply, WgCookieReplySize)
out, _ = TransformOutbound(cookiePkt, WgCookieReplySize, cfg)
if len(out) != cfg.S3+WgCookieReplySize {
t.Fatalf("v2 cookie outbound: expected %d, got %d", cfg.S3+WgCookieReplySize, len(out))
}
result, valid = TransformInbound(out, len(out), cfg)
if !valid {
t.Fatal("v2 roundtrip cookie: invalid")
}
if binary.LittleEndian.Uint32(result[:4]) != wgCookieReply {
t.Fatal("v2 roundtrip cookie: type not restored")
}
// Roundtrip transport with S4.
transportPkt := makePacket(wgTransportData, 200)
savedTransport := make([]byte, 200)
copy(savedTransport, transportPkt)
out, _ = TransformOutbound(transportPkt, 200, cfg)
if len(out) != cfg.S4+200 {
t.Fatalf("v2 transport outbound: expected %d, got %d", cfg.S4+200, len(out))
}
result, valid = TransformInbound(out, len(out), cfg)
if !valid {
t.Fatal("v2 roundtrip transport: invalid")
}
if binary.LittleEndian.Uint32(result[:4]) != wgTransportData {
t.Fatal("v2 roundtrip transport: type not restored")
}
for i := 4; i < 200; i++ {
if result[i] != savedTransport[i] {
t.Fatalf("v2 roundtrip transport: byte %d mismatch", i)
}
}
}
func TestV1Backward(t *testing.T) {
// Verify v1 config (point ranges, S3=S4=0) still works identically.
cfg := &Config{
Jc: 2,
Jmin: 10,
Jmax: 50,
S1: 46,
S2: 122,
S3: 0,
S4: 0,
H1: HRange{Min: 1033089720, Max: 1033089720},
H2: HRange{Min: 1336452505, Max: 1336452505},
H3: HRange{Min: 1858775673, Max: 1858775673},
H4: HRange{Min: 332219739, Max: 332219739},
}
// Handshake init roundtrip.
initPkt := makePacket(wgHandshakeInit, WgHandshakeInitSize)
out, sendJunk := TransformOutbound(initPkt, WgHandshakeInitSize, cfg)
if !sendJunk {
t.Fatal("v1 backward: expected sendJunk=true")
}
if len(out) != cfg.S1+WgHandshakeInitSize {
t.Fatalf("v1 backward: expected len %d, got %d", cfg.S1+WgHandshakeInitSize, len(out))
}
result, valid := TransformInbound(out, len(out), cfg)
if !valid {
t.Fatal("v1 backward: inbound invalid")
}
if binary.LittleEndian.Uint32(result[:4]) != wgHandshakeInit {
t.Fatal("v1 backward: type not restored")
}
// Transport data: no padding with S4=0.
transportPkt := makePacket(wgTransportData, 100)
out, _ = TransformOutbound(transportPkt, 100, cfg)
if len(out) != 100 {
t.Fatalf("v1 backward: transport expected 100, got %d", len(out))
}
result, valid = TransformInbound(out, len(out), cfg)
if !valid {
t.Fatal("v1 backward: transport inbound invalid")
}
if binary.LittleEndian.Uint32(result[:4]) != wgTransportData {
t.Fatal("v1 backward: transport type not restored")
}
// Cookie: no padding with S3=0.
cookiePkt := makePacket(wgCookieReply, WgCookieReplySize)
out, _ = TransformOutbound(cookiePkt, WgCookieReplySize, cfg)
if len(out) != WgCookieReplySize {
t.Fatalf("v1 backward: cookie expected %d, got %d", WgCookieReplySize, len(out))
}
result, valid = TransformInbound(out, len(out), cfg)
if !valid {
t.Fatal("v1 backward: cookie inbound invalid")
}
if binary.LittleEndian.Uint32(result[:4]) != wgCookieReply {
t.Fatal("v1 backward: cookie type not restored")
}
}
+53 -7
View File
@@ -7,6 +7,7 @@ import (
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"github.com/amneziawg-mikrotik/awg-proxy/internal/awg"
@@ -15,11 +16,25 @@ import (
func main() {
cfg, listenAddr, remoteAddr, err := parseEnv()
if err != nil {
io.WriteString(os.Stderr, "FATAL: "+err.Error()+"\n")
_, _ = io.WriteString(os.Stderr, "FATAL: "+err.Error()+"\n")
os.Exit(1)
}
awg.LogInfo(cfg, "starting awg-proxy")
// Определяем версию протокола AmneziaWG по параметрам конфига.
// v2: если S3 или S4 ненулевые, или хотя бы один H задан диапазоном (Min != Max).
// v1.5: если нет признаков v2, но задан хотя бы один CPS-шаблон (I1-I5).
// v1: всё остальное — фиксированные H, без CPS, без S3/S4.
mode := "v1"
if cfg.S3 > 0 || cfg.S4 > 0 ||
cfg.H1.Min != cfg.H1.Max || cfg.H2.Min != cfg.H2.Max ||
cfg.H3.Min != cfg.H3.Max || cfg.H4.Min != cfg.H4.Max {
mode = "v2"
} else if cfg.CPS[0] != nil || cfg.CPS[1] != nil || cfg.CPS[2] != nil ||
cfg.CPS[3] != nil || cfg.CPS[4] != nil {
mode = "v1.5"
}
awg.LogInfo(cfg, "starting awg-proxy mode=", mode)
awg.LogInfo(cfg, "listen=", listenAddr.String(), " remote=", remoteAddr.String())
proxy := awg.NewProxy(cfg, listenAddr, remoteAddr)
@@ -35,7 +50,7 @@ func main() {
}()
if err := proxy.Run(stop); err != nil {
io.WriteString(os.Stderr, "FATAL: "+err.Error()+"\n")
_, _ = io.WriteString(os.Stderr, "FATAL: "+err.Error()+"\n")
os.Exit(1)
}
}
@@ -87,10 +102,10 @@ func parseEnv() (*awg.Config, *net.UDPAddr, *net.UDPAddr, error) {
cfg.Jmax = collectInt("AWG_JMAX", jmaxStr, &errs)
cfg.S1 = collectInt("AWG_S1", s1Str, &errs)
cfg.S2 = collectInt("AWG_S2", s2Str, &errs)
cfg.H1 = collectUint32("AWG_H1", h1Str, &errs)
cfg.H2 = collectUint32("AWG_H2", h2Str, &errs)
cfg.H3 = collectUint32("AWG_H3", h3Str, &errs)
cfg.H4 = collectUint32("AWG_H4", h4Str, &errs)
cfg.H1 = collectHRange("AWG_H1", h1Str, &errs)
cfg.H2 = collectHRange("AWG_H2", h2Str, &errs)
cfg.H3 = collectHRange("AWG_H3", h3Str, &errs)
cfg.H4 = collectHRange("AWG_H4", h4Str, &errs)
if b, err := base64.StdEncoding.DecodeString(serverPubB64); err != nil {
errs = append(errs, "AWG_SERVER_PUB: invalid base64: "+err.Error())
@@ -108,6 +123,24 @@ func parseEnv() (*awg.Config, *net.UDPAddr, *net.UDPAddr, error) {
copy(cfg.ClientPub[:], b)
}
// Optional v2 parameters.
if v := os.Getenv("AWG_S3"); v != "" {
cfg.S3 = collectInt("AWG_S3", v, &errs)
}
if v := os.Getenv("AWG_S4"); v != "" {
cfg.S4 = collectInt("AWG_S4", v, &errs)
}
for idx, name := range [5]string{"AWG_I1", "AWG_I2", "AWG_I3", "AWG_I4", "AWG_I5"} {
if v := os.Getenv(name); v != "" {
tmpl, err := awg.ParseCPSTemplate(v)
if err != nil {
errs = append(errs, name+": "+err.Error())
} else {
cfg.CPS[idx] = tmpl
}
}
}
if len(errs) > 0 {
return nil, nil, nil, &envError{msg: buildErrorMsg(errs)}
}
@@ -169,6 +202,19 @@ func collectUint32(name, s string, errs *[]string) uint32 {
return uint32(n)
}
func collectHRange(name, s string, errs *[]string) awg.HRange {
before, after, found := strings.Cut(s, "-")
lo := collectUint32(name, before, errs)
if !found {
return awg.HRange{Min: lo, Max: lo}
}
hi := collectUint32(name+" max", after, errs)
if lo > hi {
*errs = append(*errs, name+": min > max")
}
return awg.HRange{Min: lo, Max: hi}
}
func buildErrorMsg(errs []string) string {
msg := "configuration errors:\n"
for _, e := range errs {