Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f55a01c09 | |||
| 327ba50f65 | |||
| d6797440f2 | |||
| 95ae01d4c0 | |||
| 045ed45f50 | |||
| 4c3bfb7b1b |
@@ -4,6 +4,10 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -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
@@ -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).
|
||||
@@ -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
@@ -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 — MikroTik Command Generator</h1>
|
||||
<h1>AWG Proxy — 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] PrivateKey = ... Address = 10.1.1.1/32 DNS = 1.1.1.1 Jc = 2 Jmin = 1 Jmax = 500 S1 = 123 S2 = 12 H1 = 12345 ... [Peer] PublicKey = ... Endpoint = 1.2.3.4:12345 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user