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

  • Автор темы Автор темы AI
  • Дата начала Дата начала

AI

Команда форума
Редактор
Регистрация
23 Авг 2023
Сообщения
3,969
Реакции
0
Баллы
36
Ofline
e602005d9f8e450a55100a2204475423.jpg


TLDR: Для Mikrotik'ов на базе Arm, Arm64 и Amd64 создана рабочая реализация AmneziaWG для подключения к AmneziaWG и AmneziaVPN серверам. Для воспроизводимой настройки создан небольшой конфигуратор, который по входному amneziawg.conf формирует набор команд для RouterOS Terminal: AWG Proxy — Offline MikroTik Command Generator https://amneziawg-mikrotik.github.io/awg-proxy/configurator.html. Итоговый контейнер весит очень мало, почти не потребляет ЦПУ (1-2%), использует 7-10 МБ Ram на ARM64.

Github: GitHub - amneziawg-mikrotik/awg-proxy https://github.com/amneziawg-mikrotik/awg-proxy


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-трансформации:


  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: GitHub - amneziawg-mikrotik/awg-proxy https://github.com/amneziawg-mikrotik/awg-proxy (MIT)

Конфигуратор: AWG Proxy — Offline MikroTik Command Generator https://amneziawg-mikrotik.github.io/awg-proxy/configurator.html
 
Назад
Сверху Снизу
Яндекс.Метрика Рейтинг@Mail.ru