
TLDR: Для Mikrotik'ов на базе Arm, Arm64 и Amd64 создана рабочая реализация AmneziaWG для подключения к AmneziaWG и AmneziaVPN серверам. Для воспроизводимой настройки создан небольшой конфигуратор, который по входному amneziawg.conf формирует набор команд для RouterOS Terminal: https://amneziawg-mikrotik.github.io/awg-proxy/configurator.html. Итоговый контейнер весит очень мало, почти не потребляет ЦПУ (1-2%), использует 7-10 МБ Ram на ARM64.
Github: https://github.com/amneziawg-mikrotik/awg-proxy
upd: Сейчас реализован только протокол amnezia v1. По запросам трудящихся добавлю v2 или хотя бы v1.5, следите за апдейтами.
Disclaimer. Материал носит образовательный и ознакомительный характер и посвящён вопросам совместимости реализаций WireGuard/AmneziaWG и разбору сетевой упаковки пакетов/handshake. Примеры приводятся для сценариев защищённого удалённого доступа к собственным системам и инфраструктуре (администрирование, корпоративные сети, тестовые стенды). Не используйте информацию из статьи для нарушения законодательства РФ и иных применимых норм, а также правил площадок и провайдеров. Автор не несёт ответственности за последствия использования описанных подходов.
Одним очередным томным вечером приходя домой вдруг снова обнаруживаешь, что соединение с сервером нестабильно и периодически обрывается. Вполне безобидный сервер, для доступа к рабочим инструментам вдруг стал недоступен. В очередной раз решил поискать в интернете, может кто-то уже реализовал нормальный клиент AmneziaWG в Mikrotik:
На форуме mikrotik так ничего и не появилось, а из самых доступных реализаций находится только этот контейнер на 36 МБ (!) с полной реализацией AmneziaWG-клиента, но на моем стареньком Mikrotik доступно всего 7 МБ :'( , поэтому этот вариант отпадает. Про инструкции по настройке я вообще молчу.
Выхода нет. Почему бы не написать реализацию самому с нуля?
Варианты реализации
Первым делом полез искать вариант реализации своего Package, который можно было бы установить максимально нативно, чтобы он работал на уровне ядра Mikrotik. К сожалению, этот вариант отпадает первым - Routerboard закрытая проприетарная система, а пакеты требуют цифровую подпись.
Второй вариант - создать свой минималистичный container по реализации AmneziaWG клиента. Туда нужно прокинуть все настройки подключения, научить его не только подключаться и работать с криптографией, но и корректно маршрутизировать сквозь себя трафик. При реализации очень легко ошибиться с криптографией, а сам бинарник, по моим подсчетам, будет в районе ~15 МБ. Не говоря уже о дублировании почти всего, что в mikrotik уже есть.
Третий вариант пришел неожиданно. У нас есть Mikrotik, в котором из коробки уже есть прекрасная реализация Wireguard. А AmneziaWG это тот же Wireguard, с небольшими отличиями. Вся разница - во фрейминге пакетов: перед тем как безопасное соединение установится, две точки должны обменяться рандомными мусорными пакетами. А дальше идет обычное соединение Wireguard!
То есть всё, что нам нужно - это реализовать первую фазу установления соединения: "поздороваться" с сервером на языке AmneziaWG, а дальше просто передать всё управление интерфейсу Wireguard'а! Всего лишь обменяемся мусорными пакетами (так думал я поначалу и глубоко ошибался), а всю дальнейшую маршрутизацию, криптографию и пиринг передадим в руки нативного Wireguard в mikrotik. Такая реализация простой udp-прокси по моим подсчетам могла уложиться в виде go-бинарника размером 3-4 МБ, что соответствует моим ожиданиям.
В итоге мы по-максимуму переиспользуем уже готовый и реализованный Wireguard в самом Mikrotik, а допишем и реализуем только то, что ему не хватает - Handshake с AmneziaWG!
Mikrotik Wireguard -> UDP -> [awg-proxy container] -> UDP -> AmneziaWG Server
Приступаем к реализации
Был выбран язык Go, который будет компилироваться из scratch-контейнера в самый минималистичный вариант. Go довольно быстрый, позволяет легко собрать бинарник, не требующий скрытых зависимостей, да и вообще я его сейчас практикую, почему бы и нет?
Вполне возможно, реализация на С была бы ещё меньшего размера, но у меня в нём мало практики, а я не собирался стрелять себе в ноги, я лишь хочу решить свою маленькую проблему. Так что выбор был остановлен на Go.
Протокол WireGuard изнутри
Чтобы понять, что именно трансформирует прокси, нужно заглянуть в формат пакетов WireGuard. Не в криптографию (она нас не касается), а именно в структуру датаграмм:
WireGuard использует ровно четыре типа UDP-сообщений. Тип кодируется как uint32 в little-endian порядке в первых 4 байтах каждого пакета:
Тип | Значение | Размер | Назначение |
|---|---|---|---|
1 | Handshake Init | 148 байт (фикс.) | Инициация handshake, первое сообщение Noise IK |
2 | Handshake Response | 92 байта (фикс.) | Ответ на handshake, второе сообщение Noise IK |
3 | Cookie Reply | 64 байта (фикс.) | Cookie для защиты от DoS (rate limiting) |
4 | Transport Data | 32+ байт (перем.) | Зашифрованные данные пользователя |
Три handshake-пакета имеют фиксированный размер. Transport data - переменный (зависит от размера payload). Это важно: прокси идентифицирует вид пакета по комбинации (тип в первых 4 байтах, общий размер датаграммы). Это надёжнее, чем полагаться только на тип - например, случайные данные с type=1 но размером 200 байт явно не handshake init.
В коде это выглядит так:
// Standard WireGuard message types (little-endian uint32 in first 4 bytes). const ( wgHandshakeInit uint32 = 1 wgHandshakeResponse uint32 = 2 wgCookieReply uint32 = 3 wgTransportData uint32 = 4 ) // Standard WireGuard packet sizes. const ( WgHandshakeInitSize = 148 WgHandshakeResponseSize = 92 WgCookieReplySize = 64 WgTransportMinSize = 32 )
Побайтовая раскладка Handshake Init
Самый важный для нас пакет - Handshake Init. Именно на нём сломается всё, что может сломаться. Вот его структура:
Handshake Init (148 байт): +--------+--------+--------+----------+---------+---------+------+ | type | sender | epheme | static | timesta | mac1 | mac2 | | uint32 | uint32 | ral | (encryp) | mp(enc) | 16 B | 16 B | | 4 B | 4 B | 32 B | 48 B | 28 B | | | +--------+--------+--------+----------+---------+---------+------+ 0 4 8 40 88 116 132 148 |<------------- MAC1 покрывает [0:116] ------------->|
Поля: type (4 байта) - тип сообщения; sender (4 байта) - индекс отправителя; ephemeral (32 байта) - эфемерный публичный ключ Curve25519; static (48 байт) - зашифрованный статический публичный ключ + Poly1305 тег; timestamp (28 байт) - зашифрованный TAI64N-таймстамп + тег; mac1 (16 байт) - BLAKE2s-128 MAC; mac2 (16 байт) - опциональный cookie MAC (нули, если cookie не требуется).
Обратите внимание на MAC1: 16 байт по смещению [116:132]. MAC1 вычисляется как BLAKE2s-128 (с ключом) от первых 116 байт пакета. Ключ для MAC1 - это BLAKE2s-256("mac1----" || server_public_key). Поле type входит в расчёт MAC1.
Запомните этот факт. Мы к нему вернёмся. И когда вернёмся - станет очень больно.
Что делает AmneziaWG
TLDR: Амнезия генерирует мусорные пакеты в том порядке и размере, в котором это ждет сервер.
AmneziaWG модифицирует данные тремя способами:
1. Замена типов (H1-H4). Стандартные значения 1, 2, 3, 4 заменяются на произвольные uint32. Например, вместо type=1 для Handshake Init может использоваться type=1013049720. Это рандомизированные значения, уникальные для каждой конфигурации - клиент и сервер договариваются о них заранее.
2. Паддинг (S1/S2). Перед handshake init вставляются S1 случайных байт. Перед handshake response - S2 байт. Пакет из 148 байт превращается в (S1 + 148) байт. Это меняет характерный размер пакета.
3. Junk-пакеты (Jc/Jmin/Jmax). Перед отправкой Handshake Init клиент отправляет Jc пакетов случайного размера (от Jmin до Jmax байт) со случайным содержимым. Сервер их получает и отбрасывает. Это маскирует характерный паттерн "один пакет 148 байт - начало сессии".
Криптография при этом не меняется. Noise IK handshake, Curve25519, ChaCha20-Poly1305 - всё идентично стандартному WireGuard. Вся нагрузка (генерация ключей, шифрование, расшифровка) остаётся на MikroTik. Прокси трогает только внешнюю обёртку.
Трансформация пакетов
Теперь к коду. Трансформация - сердце прокси. Два направления: outbound (WG -> AWG) и inbound (AWG -> WG).
Outbound: от WireGuard к AmneziaWG
Алгоритм outbound-трансформации:
Прочитать тип пакета из первых 4 байт (uint32 LE)
Определить вид пакета по паре (тип, размер)
Заменить тип на H1/H2/H3/H4
Для handshake init: пересчитать MAC1 (подробнее в секции 4)
Для handshake init/response: добавить S1/S2 случайных байт перед пакетом
Для handshake init: вернуть флаг "перед отправкой послать junk-пакеты"
TransformOutbound
func TransformOutbound(buf []byte, n int, cfg *Config) (out []byte, sendJunk bool) { if n < 4 { return buf[:n], false } msgType := binary.LittleEndian.Uint32(buf[:4]) switch { case msgType == wgHandshakeInit && n == WgHandshakeInitSize: // Replace type and recompute MAC1. binary.LittleEndian.PutUint32(buf[:4], cfg.H1) if cfg.ServerPub != ([32]byte{}) { recomputeMAC1(buf[:n], cfg.mac1keyServer) } if cfg.S1 > 0 { out = make([]byte, cfg.S1+n) randFill(out[:cfg.S1]) copy(out[cfg.S1:], buf[:n]) } else { out = buf[:n] } return out, cfg.Jc > 0 case msgType == wgHandshakeResponse && n == WgHandshakeResponseSize: binary.LittleEndian.PutUint32(buf[:4], cfg.H2) if cfg.S2 > 0 { out = make([]byte, cfg.S2+n) randFill(out[:cfg.S2]) copy(out[cfg.S2:], buf[:n]) } else { out = buf[:n] } return out, false case msgType == wgCookieReply && n == WgCookieReplySize: binary.LittleEndian.PutUint32(buf[:4], cfg.H3) return buf[:n], 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 default: return buf[:n], false } }
Hot path: transport data
После завершения handshake 99%+ трафика - это transport data. Для этих пакетов трансформация максимально дешёвая:
case msgType == wgTransportData && n >= WgTransportMinSize: // Hot path: replace type in-place, no allocation. binary.LittleEndian.PutUint32(buf[:4], cfg.H4) return buf[:n], false
Одна запись 4 байт прямо в исходный буфер. Zero allocation - новый слайс не со��даётся, возвращается подслайс входного буфера. Никакого паддинга, никаких junk-пакетов, никакого пересчёта MAC1. Этот путь - самый частый и самый быстрый. Для гигабита трафика это тысячи пакетов в секунду, и каждый обрабатывается одной записью PutUint32.
Inbound: от AmneziaWG к WireGuard
Обратная трансформация сложнее, потому что нужно учитывать паддинг и определить тип по заменённому значению.
Входящий пакет может быть:
Handshake init с S1 байтами паддинга: размер = S1 + 148, тип H1 по смещению S1
Handshake response с S2 байтами паддинга: размер = S2 + 92, тип H2 по смещению S2
Cookie reply без паддинга: размер = 64, тип H3
Transport data без паддинга: размер >= 32, тип H4
Junk-пакет: не подходит ни под одно правило - отбрасывается
Для каждого варианта: проверяем общий размер датаграммы, читаем тип с учётом смещения паддинга, если тип совпадает с Hx - заменяем обратно на стандартный WireGuard-тип и отрезаем паддинг. Для handshake response - дополнительно пересчитываем MAC1, потому что MikroTik тоже проверяет MAC1 входящих пакетов.
Если пакет не подошёл ни под одно правило - возвращаем valid=false, и прокси его просто отбрасывает. Это нормальное поведение для junk-пакетов.
randFill: случайные байты быстро
Для заполнения паддинга и junk-пакетов случайными данными нужна быстрая функция. crypto/rand можно использовать, но для мусорных данных я взял быстрый PRNG, чтобы не упираться в syscalls/производительность. math/rand/v2 - наш выбор: быстрый PRNG, достаточный для мусорных данных (криптографическая стойкость здесь не нужна).
Но побайтовая генерация медленная: rand.IntN(256) на каждый байт - это лишние вызовы. Решение - генерировать по 8 байт за раз через rand.Uint64():
func randFill(b []byte) { for i := 0; i+8 <= len(b); i += 8 { binary.LittleEndian.PutUint64(b[i:i+8], rand.Uint64()) } // Handle remaining bytes. tail := len(b) & 7 if tail > 0 { v := rand.Uint64() off := len(b) - tail for j := 0; j < tail; j++ { b[off+j] = byte(v >> (j * 8)) } } }
Один вызов rand.Uint64() даёт 8 байт псевдослучайных данных. Основной цикл заполняет буфер блоками по 8 байт. Остаток (0-7 байт) обрабатывается побитовым сдвигом одного uint64. Для заполнения буфера в 500 байт (типичный junk-пакет) это 63 вызова rand.Uint64() вместо 500 вызовов rand.IntN(256).
Побитовая маска & 7 вместо % 8 - микрооптимизация, но для паддинга и junk'а эта функция вызывается часто. math/rand/v2 использует ChaCha8 в качестве PRNG - это быстро и даёт хорошее распределение, достаточное для заполнения мусорных данных. Для криптографических целей (генерация ключей, nonce) math/rand непригоден - но мы им и не пользуемся для этого.
Ловушка с MAC1: 3 дня в тишине
Баг, над которым просидел 3 дня.
Вроде работает но не работает
Я написал прокси, проверил код: пакет превращается из WG в AWG и обратно без потерь: тип восстанавливается, payload побайтово совпадает с оригиналом. Junk-пакеты генерируются правильного размера. Паддинг добавляется и снимается.
Запускаю прокси локально, указываю на реальный AWG-сервер. Конфигурация - 13 env-переменных, трижды проверенных по .conf-файлу. WireGuard-клиент отправляет handshake init через прокси. В логах прокси вижу: c->s: recv 148B, send 194B, junk=true. Пакет принят, трансформирован (148 + S1 = 194 байта), junk-пакеты отправлены. Всё по плану.
Жду handshake response.
Тишина.
Ни ответа, ни ошибки. WireGuard-клиент ждёт 5 секунд и ретранслирует handshake init. Прокси послушно трансформирует каждую ретрансляцию. Junk-пакеты летят. Трансформированные init'ы летят. Сервер - молчит. 5 секунд, 1, 3, минуты. WireGuard сдаётся: handshake did not complete. Ничего. Wireguard тихо молча отваливается по таймауту (и это внесло больше всего смуты).
Может, проблема в параметрах?
Первая мысль - я неправильно прочитал конфиг. Открываю .conf-файл, перепроверяю все параметры: H1-H4, S1, S2, Jc, Jmin, Jmax. Всё совпадает. Перепроверяю ещё раз, побуквенно. Совпадает.
Запускаю tcpdump на стороне сервера (благо, есть root-доступ). Пакеты приходят. Правильного размера: S1 + 148 = 194 байта. Перед ними - 4 junk-пакета в правильном диапазоне размеров (10-50 байт). Читаю hex-дамп, нахожу тип по смещению S1 - H1 на месте.
Три дня я перепроверял, методично исключая гипотезы:
Значения H1-H4 - три раза сверил с конфигом, конвертировал вручную в hex и сравнил с дампом
Размеры пакетов - ровно как ожидается, посчитал побайтово в Wireshark
Паддинг - случайные байты на месте, правильной длины, перед payload
Junk-пакеты - отправляются, правильного количества и размера
Endianness параметров - перепроверил, что H1 записывается как uint32 LE, а не BE
Сетевая связность - пинг до сервера проходит, UDP-порт открыт
Firewall - правила не блокируют, tcpdump на сервере видит пакеты
Реализовал даже упрощенную проксю для обычного AmneziaWG client, вдруг я реализовал udp-proxy криво. Но нет, родная amneziawg завелась успешно, значит проблема была в реализации.
Добавлял всё более детальное логирование. Выводил каждый байт входящего и исходящего пакета в hex. Сравнивал с тем, что показывает tcpdump. Всё совпадало (почти). Байт в байт. Пакет выходил из прокси точно таким, каким я его ожидал. Структура правильная. Но сервер его игнорировал.
Я начал подозревать баг в самом AWG-сервере. Пробовал подключиться обычным AmneziaWG-клиентом - работает. Значит, сервер исправен. Проблема в моём прокси. Но где?
Изучаем исходники
От безысходности полез копать исходники WireGuard - конкретно noise-protocol.c и cookie.c. И нашёл это:
При получении Handshake Init: 1. Проверить размер пакета <- OK 2. Прочитать тип <- OK 3. Проверить MAC1 <- !!! 4. Если MAC1 невалиден - DROP <- молча, без логирования 5. Проверить MAC2 (если нужен) 6. Расшифровать static key 7. ...остальная обработка...
MAC1 проверяется ДО любой криптографической обработки пакета. Оказывается, это DoS-защита: проверка MAC1 дешёвая (один BLAKE2s-128), а расшифровка - дорогая. Если MAC1 невалиден, пакет отбрасывается немедленно. Без ответа. Без логирования. Молча. Это by design - нет смысла тратить ресурсы и раскрывать информацию о себе для пакетов с невалидным MAC.
MAC1 в Handshake Init - это:
mac1key = BLAKE2s-256("mac1" || server_public_key) MAC1 = BLAKE2s-128(mac1key, packet[0:116]) ^^^^^^^^^^^^^^ включая type в bytes [0:4] !
Что делает прокси? Заменяет type с 1 на H1. Четыре байта. После замены, хэш MAC1, который MikroTik вычислил по type=1, становится невалидным для сервера, который теперь видит type=H1 в тех же 4 байтах.
MikroTik: MAC1 = BLAKE2s-128(key, [01,00,00,00 | rest...]) - вычислил Прокси: type = 01,00,00,00 -> 38,89,89,3D (H1) - заменил Сервер: MAC1' = BLAKE2s-128(key, [38,89,89,3D | rest...]) - ожидает MAC1 != MAC1' - DROP
Вот и проблема. 3 дня!
И это работает в обе стороны. Когда сервер отправляет Handshake Response с type=H2, прокси заменяет на type=2 и MikroTik WG-стек отбрасывает ответ по той же причине: MAC1 в ответе был вычислен по type=H2, а MikroTik ожидает MAC1 по type=2. Даже если бы сервер каким-то чудом ответил на пакет с невалидным MAC1 (что невозможно, но допустим) - MikroTik бы не принял его ответ. Двусторонний deadlock. Нужно переделывать расчёт MAC1.
В общем, пришлось реализовывать подсчет и пересчет MAC1 самостоятельно. А хотел обойтись "простой udp-прокси". С привлечением LLM'ки написал вполне сносный модуль, проверил, и.... запустилось! Пошли байтики в Tx и Rx, родненькие! Фух, на компе работает, осталось запустить это в Mikrotik'е. В подсчете MAC1 активно участвует публичный ключ туннеля, поэтому нужно прокинуть +1 env в контейнер.
В итоге, даже с учетом добавления небольшой криптографии с пересчетом MAC1, бинарники получились всего 806-866 кб! Это 0.85 МБ. Гораздо меньше ожидаемого! Отлично!
Развёртывание на MikroTik
Контейнеры MikroTik: ограничения
MikroTik RouterOS 7.4+ поддерживает Docker-контейнеры. Но это не полноценный Docker - скорее, минимальная реализация OCI runtime с существенными ограничениями:
RAM: контейнеры разделяют память роутера (обычно 256-512 МБ на всё). Каждый мбайт, съеденный контейнером - это МБ, отнятый у RouterOS.
Диск: NAND или eMMC, типично 128-256 МБ, из них свободного и того меньше. Образ контейнера хранится на flash.
Нет привычных docker-команд: ни тебе docker exec, ни docker pull, логи не посмотреть, установка вручную проприетарными командами вроде /container/add
Через winbox тоже не покликаешь - лично у меня типичный баг добавления контейнера в UI: это сделать по сути невозможно, т.к. бесконечно ругается на поле Shm size.
После нескольких часов тестов и дебагов - комплекс всё же завелся. Настройки задаю через переменные окружения, создаю дополнительный виртуальный интерфейс для контейнера, настраиваю маршрутизацию.
В процессе настройки я накопил себе целую кучу скриптов. Со временем я понял, что уже сам путаюсь что откуда брать, и представил как могут мучаться все остальные (или я сам через год, когда попытаюсь поднять новое соединение - уже всё забуду и буду не понимать, кто такое написал). Так что решил что надо это дело оформить в виде конфигуратора: вставляем туда конфиг подключения AmneziaWG.conf, он его парсит и выдает:
Команды на полную установку с проверками совместимости
Создает скрипт удаления все этого - вдруг у вас что-то пойдёт не так и захотите откатиться
Конфигуратор помогает только установить базовое соединение. Он не настраивает вам маршрутизацию - это каждый делает сам. Если что - обращайтесь к LLM'кам
Не забывайте создавать бэкапы перед выполнением команд!
В процессе эксплуатации выявил, что ЦПУ в основном потребляет Wireguard. Контейнер awg-proxy потребляет ЦПУ незначительно (1-2%). Бинарник хоть и весит 0.9 МБ, но потребление Ram колеблется в районе 7-10 МБ. Глобально, у меня 128 МБ, я могу себе это позволить)
Итоги
Ограничения рождают креативность. Когда совсем уже припекло, родилось хорошее решение переиспользовать. По итогу был реализован минималистичный AmneziaWG-container, неплохо (уверен, можно сделать ещё лучше) решающий мою задачу, делюсь с обществом (для ознакомления, см. disclaimer).
Github: https://github.com/amneziawg-mikrotik/awg-proxy (MIT)
Конфигуратор: https://amneziawg-mikrotik.github.io/awg-proxy/configurator.html
