golang

Наконец-то: AmneziaWG в Mikrotik

  • среда, 25 февраля 2026 г. в 00:00:12
https://habr.com/ru/articles/1002824/

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: Добавлена поддержка протокола AmneziaWG v2.

Disclaimer.  Материал носит образовательный и ознакомительный характер и посвящён вопросам совместимости реализаций WireGuard/AmneziaWG и разбору сетевой упаковки пакетов/handshake. Примеры приводятся для сценариев защищённого удалённого доступа к собственным системам и инфраструктуре (администрирование, корпоративные сети, тестовые стенды). Не используйте информацию из статьи для нарушения законодательства РФ и иных применимых норм, а также правил площадок и провайдеров. Автор не несёт ответственности за последствия использования описанных подходов.

Одним очередным томным вечером приходя домой вдруг снова обнаруживаешь, что соединение с сервером нестабильно и периодически обрывается. Вполне безобидный сервер, для доступа к рабочим инструментам вдруг стал недоступен. В очередной раз решил поискать в интернете, может кто-то уже реализовал нормальный клиент AmneziaWG в Mikrotik:

На форуме mikrotik так ничего и не появилось, а из самых доступных реализаций находится только этот контейнер на 36 МБ (от @wiktorbgu, он много лет поддерживал его, поставьте + в карму за поддержку) с полной реализацией AmneziaWG-клиента, но на моем стареньком Mikrotik доступно всего 7 МБ :'( , поэтому этот вариант отпадает. (UPD: по пользовательским замерам, у этого решения нагрузка на CPU примерно в 1,5 раза ниже, чем у контейнера на 36 МБ по ссылке выше).

Выхода нет. Почему бы не написать реализацию самому с нуля?

Варианты реализации

Первым делом полез искать вариант реализации своего 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-трансформации:

  1. Прочитать тип пакета из первых 4 байт (uint32 LE)

  2. Определить вид пакета по паре (тип, размер)

  3. Заменить тип на H1/H2/H3/H4

  4. Для handshake init: пересчитать MAC1 (подробнее в секции 4)

  5. Для handshake init/response: добавить S1/S2 случайных байт перед пакетом

  6. Для 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