feat(mcp): подготовить сервер и доступ к приёмке
This commit is contained in:
commit
bf3d50e1ff
3835 files changed
+537122
No files matched your search
@@ -0,0 +1,9 @@
|
||||
# Постоянное хранение локальных резервных копий
|
||||
|
||||
Backend сохраняет `.rsc` в `/app/storage/backups`. В Compose этот путь подключён к `data/backups` на хосте (в варианте CDN — `data/mm/backups`, в основном варианте — `/opt/mmapp/data/backups`). Том PostgreSQL не хранит содержимое `.rsc`: запись каталога и файл должны сохраняться отдельно.
|
||||
|
||||
Перед первым обновлением существующего развёртывания с новым томом сохраните файлы из `/app/storage/backups` работающего старого контейнера в соответствующий каталог хоста. Сделайте это **до пересоздания контейнера**. Проверьте размеры и SHA-256, права доступа backend и отсутствие конфликтующих имён; не перезаписывайте отличающиеся файлы. Если файлы уже отсутствуют, записи PostgreSQL их не восстановят. После обновления проверьте скачивание через приложение.
|
||||
|
||||
Не меняйте имя Compose project и тома PostgreSQL при переносе файлов. Удаление контейнера допустимо после подтверждения сохранности; `down -v` для обновления не требуется.
|
||||
|
||||
На тестовом Debian проверены две новые нативные копии CHR: скачивание до и после `--force-recreate`, совпадение SHA-256, удаление только собственных копий. Доказательства и границы — `docs/ux-ui-progress.md`, блок12.5.235.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Production275 — SYN и задержки API arn-gw01 (2026-10-16)
|
||||
|
||||
Поручение: исследовать предупреждения SYN flooding на80/443 и периодически
|
||||
медленный HTTPS REST на `arn-gw01.rtnt.top`. Выполнены только чтение и короткие
|
||||
диагностические подключения. Конфигурация production/RouterOS не менялась.
|
||||
|
||||
## Результаты
|
||||
|
||||
Устройство ID1, DNS IPv4 `62.182.194.146`, REST HTTPS443 с проверкой сертификата.
|
||||
RouterOS7.24.5 stable,1 CPU. В разовом resource: CPU6%, свободно933036032 байт
|
||||
из1207959552, uptime5m41s. Перезапуск выполнен до диагностики, не агентом.
|
||||
|
||||
1. Первый диагностический GET system/resource завершился тайм-аутом10с.
|
||||
Следующие чтения: system/clock2307мс, ip/settings638мс, ip/service145мс,
|
||||
log160мс; отдельный полный resource1170мс. Неверная авторизация не наблюдалась.
|
||||
2. В первоначальном журнале41 событие: первая отметка02:43:13, оба SYN warning
|
||||
на80/443 —02:43:20. Совпадение с uptime указывает на события первых секунд
|
||||
запуска. Позднее часы используют Europe/Stockholm/+02:00, в журнале есть
|
||||
скачок02:43→04:44; ранние отметки нельзя механически переводить как уже
|
||||
синхронизированное местное время. Непрерывную SYN-атаку из этих двух записей
|
||||
вывести нельзя. Последующее кольцо1000 записей уже не содержало предупреждений.
|
||||
3. Четыре последовательных GET system/identity с новым TCP/TLS, один за раз:
|
||||
|
||||
| Замер | DNS | TCP установлен | TLS завершён | Заголовки ответа | Всего |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 1 | 6 | 1042 | 1141 | 1161 | 1162 |
|
||||
| 2 | 2 | 3071 | 3189 | 3208 | 3208 |
|
||||
| 3 | 3 | 3088 | 3233 | 3254 | 3254 |
|
||||
| 4 | 2 | 5136 | 5254 | 5273 | 5274 |
|
||||
|
||||
Значения — накопленные миллисекунды от начала запроса. Все HTTP200;
|
||||
после TLS до ответа19–21мс. Основная задержка находится до TCP connect.
|
||||
4. Захват только SYN/SYN-ACK заголовков на eth0 production-хоста,20с,
|
||||
фильтр строго target IPv4/TCP443. Полезная нагрузка не сохранялась.
|
||||
Два наших контрольных GET прошли155/149мс; SYN-ACK через18–21мс.
|
||||
Затем в обычном опросе приложения зафиксированы повторения исходящих SYN:
|
||||
один исходный порт — первое SYN1792119069.651615, SYN-ACK1792119073.773299
|
||||
(4.122с, несколько повторов); другой —1792119069.713556→1792119071.757179
|
||||
(2.044с). При полученном ответе на SYN RTT около18мс. Захват16 пакетов,
|
||||
dropped by kernel0, завершён автоматически через timeout20s.
|
||||
5. В02:52:33 UTC (09:52:33 Новосибирск) среди239 TCP conntrack записей4 к
|
||||
локальным80/443: time-wait1/syn-sent2/syn-recv1. Две от приложения,
|
||||
две с других внешних адресов — по одной80 и443. Массовый текущий SYN flood
|
||||
этим одиночным снимком не подтверждён. В этой версии адрес и порт — отдельные
|
||||
поля; для итогового подсчёта использованы dst-address **и** dst-port.
|
||||
6. tcp-syncookies=false. www80/www-ssl443/reverse-proxy443 включены,
|
||||
max-sessions20; FTP/Telnet/SSH уже выключены. В input есть блокировка EVOFW_DENY,
|
||||
drop UDP53 и accept WireGuard; отдельных ограничений80/443 доверенными
|
||||
источниками и завершающего drop input в прочитанных правилах нет. RAW пустой.
|
||||
Правила не менялись; обратный прокси потенциально использует те же порты,
|
||||
поэтому закрывать их без проверки потребителей нельзя.
|
||||
7. Scheduler20минут на02:49 UTC: traffic4 error/36 ok, gre_bgp4 error/36 ok.
|
||||
Выборка последних ошибок называет arn и тайм-аут10с; последние в02:43:49–50 UTC,
|
||||
рядом с запуском устройства. Успешные задания иногда занимают9–11с суммарно.
|
||||
Это не доказательство медленного SQL или обработки REST на RouterOS.
|
||||
|
||||
## Вывод
|
||||
|
||||
Воспроизведены задержки TCP-handshake и повторные SYN **до** TLS/REST.
|
||||
Разовые низкие CPU и conntrack не показывают постоянного истощения ресурсов.
|
||||
Причину нужно различать между сетью/фильтрацией по пути и обработкой SYN на CHR;
|
||||
захват только на отправителе не определяет точное место потери. Историческое
|
||||
предупреждение при запуске само по себе не доказывает текущую DDoS-атаку.
|
||||
|
||||
Приложение сейчас создаёт новые соединения (keepAlive=false, maxSockets2 на origin).
|
||||
Это увеличивает количество TCP/TLS-handshake, но не доказывает, что приложение
|
||||
создаёт SYN flood. Менять поведение пула вслепую нельзя: прежние проверки273 касались
|
||||
также отказов входа. Повышение API timeout лишь скроет часть проявлений.
|
||||
|
||||
## Подготовленный следующий шаг
|
||||
|
||||
После отдельного разрешения владельца проверить ID1/host и исходный
|
||||
tcp-syncookies=false, изменить **только** `/ip/settings tcp-syncookies=true`
|
||||
через REST PATCH. Без перезагрузки, изменения паролей, служб, правил firewall
|
||||
и маршрутов. Повторно прочитать значение, повторить небольшой последовательный
|
||||
замер TCP/TLS/HTTP и сверить штатные опросы. Откат — вернуть исходное false после
|
||||
решения владельца; не объявлять положительный краткий замер доказательством
|
||||
устранения причины. Это пока предложение, изменение не применялось.
|
||||
|
||||
[IP Settings MikroTik](https://help.mikrotik.com/docs/spaces/ROS/pages/103841817/IP%2BSettings)
|
||||
описывает SYN cookies при переполнении SYN backlog и ограничения TCP extensions.
|
||||
Это защита от переполнения очереди, она не устраняет потери в сети провайдера.
|
||||
Точный scope изменения и проверка должны быть согласованы до записи в production.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Production276 — включение SYN cookies на arn-gw01 (2026-10-16)
|
||||
|
||||
Владелец явно разрешил: «Включить tcp-syncookies и проверить».
|
||||
Исходная диагностика: [Production275](PRODUCTION-ARN-275.md).
|
||||
|
||||
## Применение
|
||||
|
||||
Перед записью проверены server ID1/host `arn-gw01.rtnt.top` и исходное
|
||||
`tcp-syncookies=false`. Первая попытка PATCH `/ip/settings` была отклонена400
|
||||
`missing or invalid resource identifier`; значение после отказа осталосьfalse.
|
||||
Этот singleton-раздел изменяется командой POST `/ip/settings/set`.
|
||||
|
||||
Выполнена ровно одна успешная команда с единственным параметром
|
||||
`{"tcp-syncookies":"true"}`. Подтверждение в02:55:01.749 UTC,
|
||||
повторное чтение02:55:01.893 UTC —true и корректная identity.
|
||||
Местное время: **2026-10-16 09:55:01 по Новосибирску**.
|
||||
|
||||
Пароли, пользователи, firewall, порты, маршруты, приложение, БД и контейнеры
|
||||
не менялись; перезапуск не выполнялся. Уточнение протокола PATCH→POST не расширяет
|
||||
согласованный scope и не меняет дополнительные параметры.
|
||||
|
||||
## Метод проверки
|
||||
|
||||
С тем же production-хостом, адресом устройства, учётной записью и проверкой TLS
|
||||
повторяются GET system/identity с новым TCP/TLS, последовательно, без повторов
|
||||
при ошибке. Один запрос каждые4 секунды,8 измерений. Отдельно захват45 секунд
|
||||
только SYN/SYN-ACK на eth0, фильтр строго target IPv4/TCP443, без сохранения payload.
|
||||
Проверяются результат чтения настройки, uptime и штатные задания scheduler.
|
||||
|
||||
## Контрольные запросы после изменения
|
||||
|
||||
С02:55:24 по02:55:53 UTC выполнены8 измерений. Все HTTP200, без повторов.
|
||||
|
||||
| Замер | DNS | TCP установлен | TLS завершён | Заголовки ответа | Всего |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 1 | 6 | 25 | 138 | 158 | 159 |
|
||||
| 2 | 9 | 29 | 137 | 158 | 158 |
|
||||
| 3 | 3 | 22 | 125 | 144 | 145 |
|
||||
| 4 | 3 | 22 | 123 | 143 | 143 |
|
||||
| 5 | 3 | 25 | 130 | 152 | 152 |
|
||||
| 6 | 2 | 22 | 126 | 168 | 169 |
|
||||
| 7 | 2 | 21 | 126 | 145 | 145 |
|
||||
| 8 | 2 | 45 | 313 | 334 | 334 |
|
||||
|
||||
Все значения — накопленные миллисекунды от начала запроса. Диапазон полного
|
||||
запроса143–334мс против1162–5274мс в четырёх исходных измерениях; TCP21–45мс
|
||||
против1042–5136мс. Настройка повторноtrue, uptime12m42s/CPU14% в замере02:55:54.
|
||||
|
||||
В повторном45-секундном захвате42 SYN/SYN-ACK пакета (44 received by filter,
|
||||
0 dropped by kernel), повторов SYN с тем же исходным портом/sequence в полученном
|
||||
выводе не обнаружено. Запись payload не выполнялась. Захват завершился по timeout,
|
||||
фонового процесса захвата после него нет. Это короткая выборка, а не гарантия
|
||||
отсутствия сетевых потерь при любой нагрузке.
|
||||
|
||||
На02:57:30 UTC /09:57:30 Новосибирск, через2мин29с после применения:
|
||||
|
||||
| Штатное задание | Успешно | Ошибки | Максимальная длительность |
|
||||
| --- | --- | --- | --- |
|
||||
| traffic | 5 | 0 | 1091мс |
|
||||
| gre_bgp | 5 | 0 | 426мс |
|
||||
| uptime_resources | 10 | 0 | 854мс |
|
||||
| internet_path | 1 | 0 | 284мс |
|
||||
|
||||
Считались запуски с started_at после02:55:01.893 UTC. Финальный readbacktrue,
|
||||
CPU6%, uptime14m19s, RouterOS7.24.5 stable — перезапуска не было. Доступное кольцо
|
||||
логов04:55:53–04:57:16 по часам устройства не содержит новых SYN warning;
|
||||
оно не покрывает весь интервал после изменения. Старые события не удалялись.
|
||||
|
||||
В коротком сравнении после включения SYN cookies многосекундная задержка TCP
|
||||
не воспроизвелась, результаты существенно лучше. Это согласуется с гипотезой
|
||||
проблемы обработки входящих SYN на CHR, но не устанавливает источник всех прежних
|
||||
потерь и не доказывает результат под другой нагрузкой. Дополнительные изменения
|
||||
не применялись. Наблюдение и захват завершены; новых фоновых циклов нет.
|
||||
|
||||
## Откат
|
||||
|
||||
При необходимости по решению владельца повторно проверить устройство и текущее
|
||||
значение, выполнить POST `/ip/settings/set` с `tcp-syncookies=false`, затем чтение
|
||||
`/ip/settings?.proplist=tcp-syncookies`. Автоматического отката после успешной
|
||||
проверки не предусмотрено: включение разрешено явно. Другие настройки не трогать.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Production frontend v2.0.1 — 2026-10-15
|
||||
|
||||
Владелец отдельно разрешил обновить только frontend и проверить карту. До обновления публичный release-manifest и OCI labels работающего контейнера подтверждали2.0.0/72bf648c. Релиз2.0.1/042407f0 опубликован CI2467 успешно; публикация образа не обновила работающий frontend автоматически. Причина отсутствия узлов в старой версии — отклонение PostgreSQL polledAt (блок265).
|
||||
|
||||
На production прочитаны настройки Compose только для сервиса frontend: проект cdn-mm, файл /opt/cdn-mm/docker-compose.yml, образ git.shx.one/denozord/mikrotikmanager-frontend:latest. Учётные данные/полная конфигурация не выводились.
|
||||
|
||||
Проверенный образ:
|
||||
|
||||
```text
|
||||
git.shx.one/denozord/mikrotikmanager-frontend@sha256:73ac48979084c0c93b77e75308a9e2f002481586704d0c03fd9a7b5e39511647
|
||||
version=2.0.1
|
||||
revision=042407f03ed935d26576cbd31dbaaca0e24d8a84
|
||||
```
|
||||
|
||||
После pull по digest и проверки OCI revision выполнен локальный tag этого образа в frontend:latest, затем:
|
||||
|
||||
```bash
|
||||
docker compose -p cdn-mm -f /opt/cdn-mm/docker-compose.yml up -d --no-deps --pull never frontend
|
||||
```
|
||||
|
||||
Frontend started2026-10-15T16:22:46.626507761Z (23:22:46 Новосибирск), healthy. Публичный manifest200/version2.0.1. Backend/PostgreSQL сохранили container ID и StartedAt, не перезапускались; Compose-файл, БД, права и маршрутизаторы не менялись. Пересоздание frontend заняло около1,3с по Compose, это не измерение полной пользовательской загрузки.
|
||||
|
||||
В браузере после reload подтверждены4 узла (3JH/1EN),4online,25 загруженных GRE; нарисованы3/25 связи. Ошибка первоначальной валидации исчезла, выбор устройства и экспорт доступны. Осталось ограничение read-only: POST network/resolve-hosts запрещён mm:network:write; связи по неразрешённым FQDN не подтверждены. Права не расширялись, все25 туннелей не объявляются подтверждёнными линиями. API до обновления: servers171мс, GRE498мс, speed-probes56мс; исправленная схема принимает4/25/0. Это разовый замер, не гарантия всех задержек.
|
||||
|
||||
Старый образ оставлен локально для отката; автоматический prune не запускался. При отдельно согласованном откате:
|
||||
|
||||
```bash
|
||||
docker tag sha256:59afb59c6b7322f339cc1b5cca9a0c9ae725fd04a45a135bf023ed81c6e528a2 git.shx.one/denozord/mikrotikmanager-frontend:latest
|
||||
docker compose -p cdn-mm -f /opt/cdn-mm/docker-compose.yml up -d --no-deps --pull never frontend
|
||||
```
|
||||
|
||||
Откат возвращает и дефект карты. Исправление Filters0f0d3aa0 в образ2.0.1 не входит и остаётся локальным. Скриншот с production-данными сохранён приватно в игнорируемом tmp/prod-map269.jpg, в Git не включён.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Обновление production до v2.0.2 (2026-10-16)
|
||||
|
||||
Владелец разрешил публикацию готовых Filters268, карты270 и recursive271 в main и обновление backend/frontend после успешного CI. Четыре коммита0f0d3aa0..e6a2e400 отправлены без перезаписи истории. Все7 jobs [CI2468](https://git.shx.one/denozord/MikrotikManager/actions/runs/2468) завершились success, опубликован v2.0.2. Версия/тег вычислены CI, package.json вручную не менялся.
|
||||
|
||||
Оба образа скачаны по digest, OCI version2.0.2/revision e6a2e4009d8af9e0e2f98c645791981c2b6d6ca8 проверены до перезапуска:
|
||||
|
||||
```text
|
||||
backend: git.shx.one/denozord/mikrotikmanager-backend@sha256:1a02d517b16d9d475b478e7fae1a2749bad88a156f0e49183e8cb57e2d1a7f10
|
||||
frontend: git.shx.one/denozord/mikrotikmanager-frontend@sha256:fbd0c3743613a60d18dc6a13120cf1d755f89bc574c490e363d235301a267765
|
||||
```
|
||||
|
||||
Проверенные digest локально помечены соответствующими frontend:latest/backend:latest. Compose-файл не редактировался. Выполнено последовательно:
|
||||
|
||||
```bash
|
||||
docker compose -p cdn-mm -f /opt/cdn-mm/docker-compose.yml up -d --no-deps --pull never backend
|
||||
# Проверка backend healthy; затем:
|
||||
docker compose -p cdn-mm -f /opt/cdn-mm/docker-compose.yml up -d --no-deps --pull never frontend
|
||||
```
|
||||
|
||||
По Новосибирску backend запущен16.10 в00:41:33, frontend в00:42:11. Оба healthy. PostgreSQL сохранил container ID и StartedAt; конфигурация RouterOS и права агентом не менялись. SHA256 Compose до/после одинаков: e675c00d1cbdf6558f9cad601c2674007d899d1b47c92acd0d866ff51bb61bd31. Краткое время Compose recreate не используется как измерение пользовательского простоя.
|
||||
|
||||
До обновления ошибка JH воспроизведена в браузере. После: HTTP200/live=true/stale=false для3 JH,4/3/5 endpoints; соответствующие UI-срезы загружены без прежней ошибки. Группировка нескольких endpoints одного dst/table в одну строку сохранена. Filters выбранного московского JH:10 правил,0 ложных missing-route warnings. Карта:4 узла/новая легенда/25GRE в данных; после клика computed outline=none, keyboard ring opacity=0. Read-only DNS по-прежнему запрещён mm:network:write, нарисованы3/25 GRE; все25 связей подтверждёнными не объявляются.
|
||||
|
||||
[Структурированный протокол](../docs/ux-ui/acceptance/prod272-release.json). Скриншоты production в игнорируемом tmp, в Git не включены. Изменение маршрутов, формы записи и удаление в production не выполнялись.
|
||||
|
||||
Старые образы оставлены для отката, prune не запускался. Для восстановления прежних сервисов при необходимости:
|
||||
|
||||
```bash
|
||||
docker tag sha256:6f344eecb5dd0ec820c23b8d71f89a16ebccff3ac898d954eadd159f2611b6a4 git.shx.one/denozord/mikrotikmanager-backend:latest
|
||||
docker tag sha256:73ac48979084c0c93b77e75308a9e2f002481586704d0c03fd9a7b5e39511647 git.shx.one/denozord/mikrotikmanager-frontend:latest
|
||||
docker compose -p cdn-mm -f /opt/cdn-mm/docker-compose.yml up -d --no-deps --pull never backend
|
||||
# Дождаться healthy, затем:
|
||||
docker compose -p cdn-mm -f /opt/cdn-mm/docker-compose.yml up -d --no-deps --pull never frontend
|
||||
```
|
||||
|
||||
Откат вернёт исправленные дефекты. Он не понадобился и не выполнялся. Настоящий протокол сохранён отдельным локальным коммитом без повторного push, чтобы документирование приёмки само не запускало следующий релиз.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Индекс истории планировщика — 2026-10-15
|
||||
|
||||
Диагностика production: scheduler_runs около1.06M строк/853МБ, retention DELETE после каждого задания, индекса finished_at нет. Наблюдалось ожидание IO/AioIoCompletion; EXPLAIN эквивалентного SELECT показал полный скан. Это подтверждает лишнюю нагрузку БД, но не доказывает причину каждого тайм-аута RouterOS.
|
||||
|
||||
Миграция0013 создаёт индекс для новых установок и последующего старта backend. Для работающей установки ниже приведён отдельный online-вариант. **На production выполнен 2026-10-15, запуск в14:55:45 UTC, после разрешения владельца только на индекс и проверку нагрузки.** Создание индекса изменяет БД и временно добавляет нагрузку на CPU/диск.
|
||||
|
||||
Подключение psql к текущему mmapp-postgres, без вывода пароля:
|
||||
|
||||
```bash
|
||||
docker exec -it mmapp-postgres sh -c 'exec psql -X -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"'
|
||||
```
|
||||
|
||||
Сначала проверить, что имя не занято другим или невалидным индексом:
|
||||
|
||||
```sql
|
||||
SELECT indexrelid::regclass, indisvalid, indisready, pg_get_indexdef(indexrelid)
|
||||
FROM pg_index WHERE indexrelid = to_regclass('public.idx_scheduler_runs_finished_at');
|
||||
```
|
||||
|
||||
Если индекс отсутствует, выполнить **вне BEGIN/транзакции**:
|
||||
|
||||
```sql
|
||||
SET lock_timeout = '5s';
|
||||
SET statement_timeout = '10min';
|
||||
CREATE INDEX CONCURRENTLY idx_scheduler_runs_finished_at
|
||||
ON public.scheduler_runs (finished_at);
|
||||
RESET statement_timeout;
|
||||
RESET lock_timeout;
|
||||
```
|
||||
|
||||
При прерывании concurrent build не повторять вслепую с IF NOT EXISTS: сначала проверить indisvalid/indisready. Если остался невалидный индекс, отдельно согласовать его удаление и повторное построение.
|
||||
|
||||
После успеха повторить проверку определения и валидности индекса, снять EXPLAIN условия retention и сравнить CPU/IO в нескольких окнах. При очень большой доле устаревших строк PostgreSQL может обоснованно выбрать Seq Scan; не отключать его принудительно. Срок хранения30 дней и код удаления не меняются. Индекс не удаляет записи и не требует перезапуска контейнеров.
|
||||
|
||||
Удаление индекса, перезапуски и изменение ресурсов не входят в эту процедуру. Исправление карты — отдельный frontend-патч и само этим SQL не разворачивается.
|
||||
|
||||
## Результат применения (2026-10-15)
|
||||
|
||||
CREATE INDEX CONCURRENTLY завершён успешно; indisvalid/indisready=true. Повторная проверка в15:01:30 UTC:23МБ,697 использований индекса. EXPLAIN эквивалентного SELECT retention: Bitmap Heap Scan → Bitmap Index Scan idx_scheduler_runs_finished_at вместо Parallel Seq Scan. EXPLAIN ANALYZE DELETE не запускался.
|
||||
|
||||
| Измерение | До индекса | После,14:56 UTC | После,15:00 UTC |
|
||||
| --- | --- | --- | --- |
|
||||
| CPU контейнера PostgreSQL, docker stats | 66,51% | 0,16% | 0,13% |
|
||||
| CPU idle сервера, секундные vmstat | 0% | 74–94% | 94–97% |
|
||||
| CPU iowait сервера, секундные vmstat | 29–34% | 0–5% | 0–1% |
|
||||
| IO pressure some avg10 | 63,67% | 1,70% | 0,48% |
|
||||
|
||||
Первая строка vmstat (среднее с загрузки ОС) исключена из сравнения. Замеры показывают устойчивое улучшение в пределах нескольких минут обычной работы, а не нагрузочную приёмку или гарантию отсутствия всех тайм-аутов. PostgreSQL/контейнеры не перезапускались; ручное удаление данных, изменение retention и параметров приложения не выполнялись. Действующий штатный retention продолжал работать.
|
||||
|
||||
Автоматическая проверка фактического SQL миграции выполнена на отдельном тестовом Debian в контейнере mmapp-test-backend:20тыс. временных строк, повторный запуск, валидность индекса, смена плана и сохранение результата выборки. Тест не запускает bootstrap и не изменяет public-таблицы. Backend build и scoped ESLint прошли. [Структурированный протокол](../docs/ux-ui/acceptance/prod266-retention.json).
|
||||
@@ -0,0 +1,72 @@
|
||||
# Production273 — отказы входа RouterOS (2026-10-16)
|
||||
|
||||
Диагностика production выполнена только чтением. Пароли, пользователи, службы,
|
||||
firewall, задания RouterOS и контейнеры не изменялись. Секреты не включены.
|
||||
|
||||
## Наблюдения
|
||||
|
||||
- `nov-gw01.rtnt.top`, server ID3: RouterOS **7.24.4 stable**.
|
||||
- Рядом зафиксированы `500 / too many concurrent logins (6)` и `401 Unauthorized`
|
||||
на `/interface`, `/interface/gre`, `/routing/bgp/session`. Последние отказы серии:
|
||||
**2026-10-16 01:11:40–44 по Новосибирску** (2026-10-15 18:11:40–44 UTC).
|
||||
- В последних15 минутах на01:27: traffic30/30 ok, gre_bgp30/30 ok,
|
||||
internet_path3/3 ok, ошибок этих заданий нет.
|
||||
- Тот же сохранённый пароль успешно использован для system/resource, user/active,
|
||||
ip/service, interface/gre (3 записи), routing/bgp/session (3 записи).
|
||||
Пользователь приложения: full, disabled=false, address пустой, api/rest-api
|
||||
разрешены. Это подтверждает текущий доступ, но не объясняет каждый старый401.
|
||||
- FTP/Telnet включены. В кольце журнала01:26:00–01:26:47:10 отказов входа
|
||||
с посторонних адресов —9 FTP и1 Telnet; отказов с адреса приложения в этой
|
||||
выборке нет. Следующий снимок показывает **7 внешних Telnet-соединений**.
|
||||
Подбор учётных данных наблюдается; успешный посторонний вход не установлен.
|
||||
- У www/www-ssl/api/api-ssl max-sessions=20. Число6 в ошибке нельзя автоматически
|
||||
отождествлять с этим параметром или количеством TCP-соединений приложения.
|
||||
- user/active содержит много старых rest-api записей с адреса приложения;
|
||||
api запись в момент чтения одна. По одному списку нельзя доказывать утечку
|
||||
живых TCP-соединений или принудительно удалять записи.
|
||||
- RouterOS scheduler `evofw-sync`: enabled, interval1m, run-count625.
|
||||
В доступном кольце01:26–01:30:866 изменений от scheduler, из них860 добавлений
|
||||
address-list и6 изменений filter rule. Два script/job в снимке, включая начатый
|
||||
01:22:08. Скрипты не выгружались и не менялись; связь с отказами не доказана.
|
||||
- Общий HTTP/HTTPS Agent backend ограничивает сокеты до2 **на origin**.
|
||||
Одно задание планировщика защищено от повторного запуска; разные задания
|
||||
работают одновременно. Шесть одновременных REST-запросов из основного процесса
|
||||
приложения не установлены.
|
||||
|
||||
## Вывод и границы
|
||||
|
||||
Неверный пароль как причина текущего доступа не подтвердился. RouterOS явно
|
||||
сообщил о насыщении механизма входа;401 возникали рядом, затем без изменения
|
||||
пароля прекратились. Связь с внешним подбором, продлением REST-сессии или локальным
|
||||
scheduler необходимо различить дальнейшим наблюдением.15 минут успеха не доказывают
|
||||
устранение причины.
|
||||
|
||||
[Официальная документация REST](https://manual.mikrotik.com/docs/developer-guides/rest-api/)
|
||||
описывает внутренний API login, повторное использование логина10 минут и политики
|
||||
api/rest-api. Страница относится к7.26; поведение7.24.4 не считать идентичным
|
||||
без отдельного подтверждения.
|
||||
|
||||
## Локальная правка
|
||||
|
||||
- 500 с точным JSON detail `too many concurrent logins (...)` получает подсказку
|
||||
«RouterOS занят: превышен лимит одновременных входов».
|
||||
- 401 больше не обозначается безусловно как неверный пароль: предлагается проверить
|
||||
учётные данные, права API/REST и ограничения входа.
|
||||
- Исходные HTTP status/path/body сохранены; повторов чтения/записи не добавлено.
|
||||
Другие500 не классифицируются как лимит входов.
|
||||
- 12 HTTP-проверок и профиль ros-file, backend production build, scoped ESLint,
|
||||
diff check прошли. Локально, без push/deploy.
|
||||
|
||||
## Следующий шаг, требующий разрешения
|
||||
|
||||
Временно отключить **только** ftp (`*1`,21) и telnet (`*0`,23) на ID3.
|
||||
Перед записью повторно проверить имя/ID и исходный disabled каждой службы.
|
||||
Это отключит FTP/Telnet подключения; Winbox, HTTPS, API и маршрутизация сохраняются.
|
||||
Для этой диагностики production доступ предоставлен на чтение: запись требует
|
||||
отдельного разрешения владельца. Если эти службы нужны, согласовать доверенные адреса.
|
||||
|
||||
После разрешения: сохранить исходные disabled, отключить две службы, проверить
|
||||
применение и REST-доступ; наблюдать обычный опрос не менее двух периодов продления
|
||||
REST-логина, сравнить ошибки и login failures. Не повышать max-sessions, не менять
|
||||
пароль, не удалять user/active, не перезапускать маршрутизатор. Результат записать
|
||||
отдельно: отключение ещё не выполнялось и причинная связь не доказана.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Production274 — отключение FTP/Telnet и наблюдение (2026-10-16)
|
||||
|
||||
Владелец явно разрешил отключить FTP и Telnet на `nov-gw01.rtnt.top`.
|
||||
Предыдущая диагностика: [Production273](PRODUCTION-ROUTEROS-273.md).
|
||||
|
||||
## Изменение
|
||||
|
||||
Перед записью проверены server ID3/host, единственность статических служб,
|
||||
их ID, порты21/23 и исходный disabled=false. Поочерёдно применено только
|
||||
`disabled=true`:
|
||||
|
||||
| Служба | ID | До | После | Время UTC 2026-10-15 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| ftp | *1 | false | true | 18:33:45.013 |
|
||||
| telnet | *0 | false | true | 18:33:45.322 |
|
||||
|
||||
Повторное чтение18:33:45.582 UTC подтвердило обе службы выключенными.
|
||||
Это **2026-10-16 01:33:45 по Новосибирску**.
|
||||
www80/www-ssl443/winbox8291/api8728/api-ssl8729 остались disabled=false,
|
||||
как до операции. REST identity подтвердил имя устройства; resource —7.24.4 stable,
|
||||
uptime10h28m24s, без перезапуска.
|
||||
|
||||
Пароль, пользователи, firewall, маршруты, локальные задания RouterOS, контейнеры
|
||||
и приложение не изменялись. Локальная правка подсказок273 не развёртывалась.
|
||||
|
||||
## Метод наблюдения
|
||||
|
||||
Штатные задания продолжают работать с прежними интервалами. Диагностический
|
||||
процесс раз в минуту только читает scheduler_runs и RouterOS log; дополнительных
|
||||
запусков заданий/нагрузочных тестов нет. Подсчитываются ok/error после18:33:45.582 UTC
|
||||
для traffic, gre_bgp, internet_path, uptime_resources. Журнал логинов проверяется
|
||||
с продвижением по ID и контролем перекрытия кольцевого буфера, без вывода секретов.
|
||||
|
||||
## Итог наблюдения
|
||||
|
||||
Окончание:2026-10-15 18:54:44 UTC /2026-10-16 01:54:44 по Новосибирску,
|
||||
почти21 минута после применения. После изменения в scheduler_runs:
|
||||
|
||||
| Задание | Успешно | Ошибки | Максимальная длительность |
|
||||
| --- | --- | --- | --- |
|
||||
| traffic | 42 | 0 | 2278мс |
|
||||
| gre_bgp | 42 | 0 | 910мс |
|
||||
| internet_path | 4 | 0 | 809мс |
|
||||
| uptime_resources | 84 | 0 | 1937мс |
|
||||
|
||||
Итоговое чтение: статические ftp/telnet disabled=true; www/www-ssl/winbox/api/api-ssl
|
||||
остались включены. Identity/REST работает, RouterOS7.24.4 stable,
|
||||
uptime10h49m22s — перезапуска не было. В списке остаются6 динамических строк Telnet;
|
||||
отключение службы не доказывает завершение всех прежних соединений. Другие службы,
|
||||
соединения и firewall принудительно не менялись.
|
||||
|
||||
В18:55:47 UTC проверено новое TCP-подключение с production backend к имени
|
||||
устройства:21 и23 вернули ECONNREFUSED. Новые подключения к этим портам отклоняются.
|
||||
|
||||
В наблюдавшихся новых логах нет отказов FTP/Telnet, но есть2 отказа Winbox.
|
||||
Их источники не были сохранены: не объявлять эти2 события доказанным внешним
|
||||
подбором. Кольцо RouterOS переполнялось scheduler-изменениями; обнаружено4 пропуска
|
||||
перекрытия, поэтому отсутствие FTP/Telnet failures не доказано для всего интервала.
|
||||
Статистика scheduler_runs этим ограничением не затронута.
|
||||
|
||||
Изменение служб само по себе не доказывает причину предыдущих401/500: они
|
||||
прекратились ещё до операции. Зафиксировано отсутствие повторения после отключения,
|
||||
а не гарантированное устранение первопричины. Автоматический сбор наблюдений завершён.
|
||||
|
||||
## Восстановление исходного состояния
|
||||
|
||||
Владелец отдельно подтвердил: **«Оставить FTP и Telnet выключенными»**.
|
||||
После наблюдения службы не включать обратно. При необходимости отдельного отката:
|
||||
повторно проверить host, имена/ID, вернуть disabled=false только ftp/telnet и
|
||||
сверить чтением. Других изменений конфигурации этим блоком не выполнять.
|
||||
|
||||
## Дополнительный вопрос о PostgreSQL
|
||||
|
||||
По вопросу владельца выполнена только читающая проверка production PostgreSQL:
|
||||
pg_is_in_recovery=false, wal_level=replica, pg_stat_replication count=0,
|
||||
pg_replication_slots count=0, pg_subscription count=0, synchronous_standby_names
|
||||
пустой. В конфигурации проекта один PostgreSQL и один DATABASE_URL backend.
|
||||
Активная физическая/логическая репликация не обнаружена; параметры БД не менялись.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Реальная приёмка ACME — 2026-10-14
|
||||
|
||||
Принят блок 12.5.250: `mmt250-c22a9715.pngs.top`, CHR `104.165.244.7`, production Let's Encrypt DNS-01 / Cloudflare. Пользователь разрешил только собственные поддомены `mmt*` и подтвердил назначение сертификата сервисам с последующей очисткой. Звёздочка обозначает разрешённый префикс; в заявке использовалось точное обычное имя.
|
||||
|
||||
Временные credentials только в ignored private fixture; ключи/почта/account ID в Git не публикуются. Перед запуском проверить token/zone/account и отсутствие записей на точном FQDN и `_acme-challenge.FQDN`. Снять приватный native export, сертификаты/файлы и исходные `www-ssl`/`api-ssl`; сохранить исходную строку `acme_settings`, включая ключ ACME-аккаунта и дату. После окончания восстановить её полностью.
|
||||
|
||||
Текущий issuer создаёт TXT, выполняет ACME, синхронизирует A-запись с IPv4 сервера, импортирует leaf/key/chain и назначает сертификат **обоим** `www-ssl`/`api-ssl`, включая их. Переключатели trust-store не отключают назначение. Поэтому исходные параметры сервисов обязательны для отката. HTTP REST80 используется для управления во время проверки.
|
||||
|
||||
Ручной helper: `backend/scripts/acceptance/acme-live-issue.mjs`, private fixture через `MMAPP_ACME_FIXTURE` (по умолчанию ignored `tmp/acme250-private.json`). Требуется отдельно разрешённый тест, baseline, Portal fixture и SSH-туннель. Helper не заменяет native/DNS очистку; при уже сохранённом jobId новый выпуск блокируется. Неподтверждённый запрос сначала проверять по тому же ID, не отправлять новую задачу. Не запускать этот helper повторно без свежей подготовки: временные credentials после блока удалены.
|
||||
|
||||
Проверено: реальный Portal/UI, один POST issue, terminal done; trusted TLS/SNI/SAN, DNS A → `104.165.244.7`, REST HTTPS200 с обычной проверкой сертификата; RouterOS private key и назначение обоим сервисам. После теста сервисы восстановлены, собственные leaf/chain/files/job удалены, первоначальные коллекции/экспорт/settings совпали. Приложение удалило TXT; собственная A-запись удалена по проверенным имени/типу/content/ID. Даты PostgreSQL сравниваются после JSON-нормализации; неверное сравнение Date со строкой не должно повторять очистку.
|
||||
|
||||
Выданный публичный сертификат CA не объявляется отозванным: экземпляры и ключевые файлы на CHR удалены, DNS очищен; запись о выпуске сохраняется у CA. Это не тест автоматического renewal, CA revocation или нескольких backend.
|
||||
|
||||
Протоколы: [UI](../docs/ux-ui/acceptance/acme250-live-ui.json), [TLS](../docs/ux-ui/acceptance/acme250-tls.json), [DNS](../docs/ux-ui/acceptance/acme250-cloudflare.json), [native/cleanup](../docs/ux-ui/acceptance/acme250-native.json).
|
||||
|
||||
## Подготовка повторной выдачи / renewal — блок253 (2026-10-14)
|
||||
|
||||
Подготовлен конкретный домен `mmt253-8e62a941.pngs.top`, certName `mmt253-8e62a941`, CHR `104.165.244.7` (server2). Это обычное имя без wildcard. Этот раздел описывает следующий тест; выдача и продление253 ещё не запускались. Первичная выдача250 не доказывает замену сертификата при повторном импорте. HTTP ACK и terminal job сами по себе также не доказывают, какой leaf обслуживает TLS.
|
||||
|
||||
1. Перед изменениями снять свежий приватный baseline: native export, certificates/files/services; SQL ACME и renewal settings, список managed targets/jobs; scheduler state. Подтвердить отсутствие любых других managed targets/active jobs и DNS записей на обоих точных именах. Если они появились — остановиться до мутации. Не использовать удалённый baseline250.
|
||||
2. Временно остановить автоматический certificates_renew через его настройки и дождаться отсутствия текущего запуска. Сохранить исходные настройки целиком. Не запускать несколько экземпляров collector. Настроить тестовый Cloudflare token и выполнить первый UI выпуск с новым requestId. Публично сохранить только terminal job/protocol и native metadata без секретов.
|
||||
3. По аутентифицированному RouterOS чтению выбрать ровно один собственный leaf с exact name/CN, private-key=true и отпечатком; снять served TLS с SNI/обычной проверкой доверия. Если native fingerprint не SHA-256, сначала экспортировать только публичный сертификат и вычислить SHA-256 через X509Certificate; не интерпретировать другой алгоритм как SHA-256. Изолированная сводка для helper содержит только fqdn/certName/nativePrivateKey/nativeFingerprint256.
|
||||
4. Read-only helper `node backend/scripts/acceptance/acme-renew-tls.mjs sample native-summary.json before.json` подключается только к104.165.244.7:443 с точным SNI, rejectUnauthorized=true и15s timeout. Требует совпадения native и TLS SHA-256, private key и hostname. Существующий output не перезаписывается. Этот helper ничего не выпускает и не меняет RouterOS/DNS.
|
||||
5. Для реального collector renewal подтвердить канонический срок/статус leaf по state. Не менять даты на CHR и не обходить unknown guard252. Если RouterOS дата не распознаётся, записать препятствие и не запускать повторную выдачу. Для известного срока при выключенном renewal подготовить порог90 дней и интервал не менее3600s, подтвердить единственный собственный target. Затем один PUT enabled=true запускает certificates_renew через refreshScheduler немедленно: отдельный run-now НЕ отправлять. После terminal сразу выключить renewal до следующего интервала. Сохранить ID созданной scheduler-задачи; при неизвестном ACK проверять тот же ID, новый run не отправлять. Проверить source=scheduler, matching context, terminal done и snapshot renewed1/errors0.
|
||||
6. Снять after.json тем же helper и сравнить `node backend/scripts/acceptance/acme-renew-tls.mjs compare samples.json comparison.json`, где samples.json содержит before/after. Новый SHA-256 должен отличаться от первого, native должен совпасть с served TLS. Не требовать увеличения expiry на целый день: обе выдачи могут быть в один день. Проверить оба назначения www-ssl/api-ssl и HTTPS REST с обычным доверием. Если job done, а TLS leaf прежний, это ошибка приёмки; не удалять первый leaf ради получения ложного успеха.
|
||||
7. Независимо от результата остановить own renewal, вернуть исходные SSL сервисы, удалить только собственные exact cert IDs/fingerprints/PEM файлы/job IDs; вернуть ACME/renew settings целиком. Удалять только DNS записи с проверенными exact name/type/content/ID, не чужие записи. Подтвердить исходные коллекции, normalized export, settings и0 own DNS; удалить private baseline/token. Контрольный194.226.121.150 не менять, пароли не менять, перезагрузки не нужны.
|
||||
|
||||
Для этих двух выдач и повторного назначения SSL необходимо согласие на конкретный сценарий. Разрешение250 относилось к уже завершённому тесту. До согласия выполняются только подготовка/чтение. Полная регрессия и Docker build для изменения лишь ручного acceptance helper не нужны; comparator проверяется отдельно. CA revocation и multi-instance здесь не принимаются.
|
||||
|
||||
Нормативная справка для native полей/import: [MikroTik Certificates](https://help.mikrotik.com/docs/spaces/ROS/pages/2555969/Certificates). Документация не гарантирует успех повторного импорта в текущем приложении; именно это предстоит измерить.
|
||||
|
||||
## Ротация поколений — подготовка258 (2026-10-15)
|
||||
|
||||
Новый код проверяет неоднозначное исходное имя до CA order. Leaf/key импортируются под уникальным `<base>_new_<12hex>`. После проверки отпечатка/ключа оба SSL-сервиса переключаются на это поколение. Только после подтверждения назначений прежний объект переименовывается в `<base>_old_<12hex>`, новый получает исходное логическое имя, назначения и отпечаток проверяются повторно. Старый ключ не удаляется. API/DB requestId и certName остаются прежними; raw native IDs проверяются перед PATCH.
|
||||
|
||||
Подтверждается сохранение старого native ID/fingerprint, освобождение логического имени и неизменность нового поколения. Неизвестный ACK или внешнее изменение не дают success; автоматического повторного PATCH/слепого rollback нет. При поздней ошибке нужно сверить фактические назначения и оба поколения. Это ещё не обещание отсутствия краткого TLS reload и не приёмка нескольких одновременно работающих issuers.
|
||||
|
||||
Следующий реальный тест259: активировать подготовленный образ258, снять свежий baseline и выполнить согласованный first issue + scheduler renewal. Проверить ровно один leaf с логическим именем, один архив старого leaf с прежними ID/SHA-256/private-key и отсутствие незавершённых `_new_` объектов. Trusted TLS должен совпасть с новым leaf; canonical dates — с X509. Очистка должна учитывать собственные `_old_`/`_new_` имена с12hex, exact CN и записанные ID/fingerprints. Старый cleanup helper256, допускавший лишь exact logical/root имена, без адаптации не запускать. Удалять leaf поколения до собственных CA, затем проверить отсутствие и исходный export/files/services/settings. Чужие объекты не удалять.
|
||||
|
||||
Образ258 подготовлен и проверен локальными service сценариями; до этого реального теста активный backend остаётся257. Повторное согласование того же уже разрешённого сценария не требуется. Продление не объявляется принятым по fixtures.
|
||||
|
||||
## Реальная ротация принята — блок259 (2026-10-15)
|
||||
|
||||
First issue через настоящий Portal/UI и одно enable-triggered scheduler renewal того же exact домена завершены. Новый native ID/SHA-256, единственный logical name, один old archive с прежними ID/fingerprint/private-key и отсутствие unfinished new подтверждены. Оба trusted TLS samples совпали с соответствующими native leaf, отпечаток изменился; HTTPS REST200 после продления. Native/SSL/files/export/private SQL/DNS восстановлены, оба CHR доступны, private baseline/fixtures удалены. Активен backend258/frontend251. CA revocation не выполнялся. Протоколы находятся в docs/ux-ui/acceptance/acme259-*.json; следующий запуск требует свежего baseline, удалённый259 повторно не использовать.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Контейнерная приёмка на тестовом CHR — 2026-10-14
|
||||
|
||||
**Актуальный источник по указанию пользователя:** [mikrotik.ltd](https://mikrotik.ltd/), который ссылается на [MikroTikPatch release7.24.5](https://github.com/elseif/MikroTikPatch/releases/tag/7.24.5). Пользователь отклонил применение официального пакета244 из-за источника. Он не будет установлен. Из запрошенного архива извлечён только `container-7.24.5.npk`, 1,163,409bytes, SHA256`70f1c5281a3d37b347e9a83c81d9daaac511357fe26cfbac11321e2dc1407259`; архив29,675,363bytes совпал с digest опубликованного GitHub asset. [Новый протокол](../docs/ux-ui/acceptance/containers246-requested-source.json). Main/option/python/keygen/patch scripts не выбраны и не выполнялись. Установка и перезапуски пока не согласованы; новый комплект подготовлен, не применён.
|
||||
|
||||
Целевой CHR: **104.165.244.7**, контрольный **194.226.121.150** остаётся доступным. На обоих RouterOS7.24.5/x86_64; container feature уже разрешён, `/container` возвращает400. Первоначальная [подготовка244](../docs/ux-ui/acceptance/containers244-preparation.json) сохраняется как история, её официальный пакет применять нельзя. Подпись/совместимость нового container-пакета проверяет текущая RouterOS при установке; ключи проверки заменять нельзя. Бинарные архивы только в ignored tmp, в Git не публикуются.
|
||||
|
||||
До применения нужно отдельное согласие на временную установку пакета и перезапуск выбранного CHR. Это меняет набор пакетов, но сохраняет текущую версию7.24.5. Нельзя заменять версию, сбрасывать устройство или менять пароли/device-mode.
|
||||
|
||||
Последовательность после согласия:
|
||||
|
||||
1. Снять приватный native export и контрольные GET состояния исходных интерфейсов, bridge/ports, IP/routes/firewall, файлов и пакетов. Сверить отсутствие собственных имён. Экспорт разрешён для тестовых CHR; полное восстановление конфигурации не выполнять.
|
||||
2. Передать ровно проверенный NPK по SSH с зафиксированным host key. Обычный JSON `contents` не использовать для бинарного пакета. Проверить имя/размер загруженного файла.
|
||||
3. Однократно запросить reboot. Разрыв транспорта считать неподтверждённым ответом, не причиной повторять reboot. Дождаться read-only доступности; подтвердить7.24.5 и installed container package, `/container`200, контрольный CHR доступен.
|
||||
4. Подготовить только собственный минимальный контейнер, veth/bridge и каталог. Образ закрепить по digest; не использовать чужие registry credentials. Порты/адреса/маршруты/правила добавлять только при необходимости и с отдельными собственными именами/comment. Не менять существующие объекты или публиковать новый внешний порт ради проверки UI.
|
||||
5. Через реальный Portal/API/UI проверить start → running → stop → stopped → restart → running → remove, отрицательные права и повторное действие. Сверять native state, не только toast/HTTP ack. Отдельно фиксировать неизвестные промежуточные статусы.
|
||||
6. Удалить только собственный контейнер, veth/bridge/IP/каталоги/image/import файлы и сравнить исходные конфигурации. Если согласовано полное возвращение пакетов — удалить только новый container package и выполнить второй контролируемый reboot; подтвердить исходный набор/версию. Сохранение пакета требует явного решения пользователя.
|
||||
|
||||
Текущий статус (248, 2026-10-14): **после отдельного повторного согласия удаление через UI исправлено и принято**. Работающий контейнер блокируется UI и API409; после UI stop и native stopped выполнено UI remove200, отсутствие подтверждено повторным чтением. Повторный запрос даёт409. Start/stop/restart из247 и remove248 приняты в записанных границах. Backend учитывает native running/stopped flags и envlists. 29 E2E, backend-профиль, build и ESLint прошли. Собственные объекты и пакет удалены второй разрешённой перезагрузкой; исходные установленные пакеты и нормализованный экспорт совпали. [Native-протокол](../docs/ux-ui/acceptance/containers248-native.json), [UI-протокол](../docs/ux-ui/acceptance/containers248-live-ui.json). Ранние формулировки подготовки выше отражают состояние до полученного согласия; пакет уже удалён. Полная история и границы — [журнал](../docs/ux-ui-progress.md).
|
||||
@@ -0,0 +1,312 @@
|
||||
# CDN Manager + MikrotikManager + one Traefik (production).
|
||||
#
|
||||
# Hosts:
|
||||
# https://cdn.shnt.top → cdnmanager:8080
|
||||
# https://mm.shnt.top → mmapp-frontend:3000 → backend:8000 (internal rewrite)
|
||||
#
|
||||
# On server:
|
||||
# mkdir -p /opt/cdn-mm/{data/cdn,data/mm,state,updater}
|
||||
# cp deploy/docker-compose.cdn-mm.yml /opt/cdn-mm/docker-compose.yml
|
||||
# cp deploy/env.cdn-mm.example /opt/cdn-mm/.env # fill secrets
|
||||
# # targets.json:
|
||||
# # cp deploy/updater/targets.json.example /opt/cdn-mm/updater/targets.json
|
||||
# # (в CDNManager-репо скачайте тот же файл из MikrotikManager)
|
||||
# docker login git.shx.one
|
||||
# cd /opt/cdn-mm && docker compose pull && docker compose up -d
|
||||
#
|
||||
# DNS (Cloudflare DNS only, grey cloud):
|
||||
# A/AAAA cdn.shnt.top → VPS
|
||||
# A/AAAA mm.shnt.top → VPS
|
||||
#
|
||||
# Do not run a second Traefik (standalone CDNManager or MikrotikManager compose)
|
||||
# on the same host ports while this stack is up.
|
||||
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:${TRAEFIK_IMAGE_TAG:-v3.7}
|
||||
container_name: cdn-mm-traefik
|
||||
restart: unless-stopped
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
ports:
|
||||
- "${TRAEFIK_HTTP_PORT:-80}:80"
|
||||
- "${TRAEFIK_HTTPS_PORT:-443}:443"
|
||||
environment:
|
||||
CF_DNS_API_TOKEN: ${CF_DNS_API_TOKEN:?set CF_DNS_API_TOKEN in .env}
|
||||
# Optional if DNS token lacks Zone:Read:
|
||||
# CF_ZONE_API_TOKEN: ${CF_ZONE_API_TOKEN:-}
|
||||
command:
|
||||
- --log.level=${TRAEFIK_LOG_LEVEL:-INFO}
|
||||
- --api.dashboard=false
|
||||
- --providers.docker=true
|
||||
- --providers.docker.exposedbydefault=false
|
||||
- --providers.docker.network=edge
|
||||
- --entrypoints.web.address=:80
|
||||
- --entrypoints.websecure.address=:443
|
||||
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
||||
- --entrypoints.web.http.redirections.entrypoint.scheme=https
|
||||
- --certificatesresolvers.letsencrypt.acme.email=${LETSENCRYPT_EMAIL:?set LETSENCRYPT_EMAIL in .env}
|
||||
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
|
||||
- --certificatesresolvers.letsencrypt.acme.dnschallenge=true
|
||||
- --certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare
|
||||
- --certificatesresolvers.letsencrypt.acme.dnschallenge.delaybeforecheck=15
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- traefik_letsencrypt:/letsencrypt
|
||||
networks:
|
||||
- edge
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# --- CDN Manager -----------------------------------------------------------
|
||||
cdnmanager:
|
||||
# cdnmanager и cdn-manager — один образ (алиас для drop-in).
|
||||
image: git.shx.one/denozord/cdnmanager:${CDN_IMAGE_TAG:-latest}
|
||||
pull_policy: always
|
||||
container_name: cdnmanager
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- traefik
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DATABASE_URL: sqlite:/data/app.db
|
||||
STATIC_DIR: /app/static
|
||||
SERVER_PORT: "8080"
|
||||
NODE_ENV: production
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN:?set CLOUDFLARE_API_TOKEN in .env}
|
||||
JWT_SECRET: ${JWT_SECRET:-}
|
||||
AUTH_REQUIRED: ${AUTH_REQUIRED:-true}
|
||||
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET in .env}
|
||||
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
|
||||
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top}
|
||||
AUTH_AUDIT_INGEST_SECRET: ${AUTH_AUDIT_INGEST_SECRET:-}
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
ADMIN_PASSWORD_HASH: ${ADMIN_PASSWORD_HASH:-}
|
||||
volumes:
|
||||
- ./data/cdn:/data
|
||||
networks:
|
||||
- edge
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=edge
|
||||
- traefik.http.routers.cdnmanager.rule=Host(`${CDN_DOMAIN:-cdn.shnt.top}`)
|
||||
- traefik.http.routers.cdnmanager.entrypoints=websecure
|
||||
- traefik.http.routers.cdnmanager.tls=true
|
||||
- traefik.http.routers.cdnmanager.tls.certresolver=letsencrypt
|
||||
- traefik.http.services.cdnmanager.loadbalancer.server.port=8080
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
postgres:
|
||||
image: ${POSTGRES_IMAGE_TAG:-postgres:18-alpine}
|
||||
container_name: mmapp-postgres
|
||||
restart: unless-stopped
|
||||
shm_size: "256mb"
|
||||
environment:
|
||||
POSTGRES_USER: mmapp
|
||||
POSTGRES_DB: mmapp
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C.UTF-8 --data-checksums"
|
||||
TZ: UTC
|
||||
PGTZ: UTC
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
- timezone=UTC
|
||||
- -c
|
||||
- listen_addresses=*
|
||||
- -c
|
||||
- max_connections=100
|
||||
- -c
|
||||
- shared_buffers=256MB
|
||||
- -c
|
||||
- work_mem=16MB
|
||||
- -c
|
||||
- maintenance_work_mem=128MB
|
||||
- -c
|
||||
- wal_compression=lz4
|
||||
- -c
|
||||
- default_toast_compression=lz4
|
||||
- -c
|
||||
- io_method=worker
|
||||
- -c
|
||||
- io_workers=3
|
||||
- -c
|
||||
- effective_io_concurrency=200
|
||||
- -c
|
||||
- max_wal_size=2GB
|
||||
- -c
|
||||
- checkpoint_timeout=15min
|
||||
- -c
|
||||
- checkpoint_completion_target=0.9
|
||||
volumes:
|
||||
# PostgreSQL 18+: VOLUME is /var/lib/postgresql (PGDATA = .../18/docker)
|
||||
- mmapp-pgdata:/var/lib/postgresql
|
||||
networks:
|
||||
- mmapp
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U mmapp -d mmapp"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# --- MikrotikManager -------------------------------------------------------
|
||||
backend:
|
||||
image: git.shx.one/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG:-latest}
|
||||
pull_policy: always
|
||||
container_name: mmapp-backend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
traefik:
|
||||
condition: service_started
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: "8000"
|
||||
DATABASE_URL: postgres://mmapp:${POSTGRES_PASSWORD}@postgres:5432/mmapp
|
||||
DATABASE_PATH: /app/data/mikrotik.db
|
||||
CORS_ORIGIN: ${CORS_ORIGIN:-https://mm.shnt.top}
|
||||
AUTH_REQUIRED: ${AUTH_REQUIRED:-true}
|
||||
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET in .env}
|
||||
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
|
||||
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top}
|
||||
# IPFIX: внутри контейнера слушать все iface; на хосте bind только WG-IP после wg-quick@wg-flow
|
||||
FLOW_LISTEN_HOST: "0.0.0.0"
|
||||
# Сначала wg-quick@wg-flow (адрес 10.255.254.1), затем recreate backend.
|
||||
ports:
|
||||
- "10.255.254.1:4739:4739/udp"
|
||||
volumes:
|
||||
- ./data/mm:/app/data
|
||||
# Native .rsc files must survive backend recreation.
|
||||
- ./data/mm/backups:/app/storage/backups
|
||||
networks:
|
||||
mmapp:
|
||||
aliases:
|
||||
- backend
|
||||
labels:
|
||||
mmapp.updater.managed: "true"
|
||||
mmapp.updater.target: backend
|
||||
mmapp.updater.image: git.shx.one/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG:-latest}
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:8000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 300s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
frontend:
|
||||
image: git.shx.one/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG:-latest}
|
||||
pull_policy: always
|
||||
container_name: mmapp-frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
environment:
|
||||
BACKEND_INTERNAL_URL: http://backend:8000
|
||||
networks:
|
||||
mmapp:
|
||||
aliases:
|
||||
- frontend
|
||||
edge: {}
|
||||
labels:
|
||||
- mmapp.updater.managed=true
|
||||
- mmapp.updater.target=frontend
|
||||
- mmapp.updater.image=git.shx.one/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG:-latest}
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=edge
|
||||
- traefik.http.routers.mmapp.rule=Host(`${MM_DOMAIN:-mm.shnt.top}`)
|
||||
- traefik.http.routers.mmapp.entrypoints=websecure
|
||||
- traefik.http.routers.mmapp.tls=true
|
||||
- traefik.http.routers.mmapp.tls.certresolver=letsencrypt
|
||||
- traefik.http.services.mmapp.loadbalancer.server.port=3000
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:3000/dashboard').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 25s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
updater:
|
||||
image: git.shx.one/denozord/mikrotikmanager-updater:${MM_UPDATER_IMAGE_TAG:-latest}
|
||||
pull_policy: always
|
||||
container_name: mmapp-updater
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- frontend
|
||||
environment:
|
||||
REGISTRY: git.shx.one
|
||||
REGISTRY_USERNAME: ${REGISTRY_USERNAME:-}
|
||||
REGISTRY_PASSWORD: ${REGISTRY_PASSWORD:-}
|
||||
POLL_INTERVAL_SECONDS: ${POLL_INTERVAL_SECONDS:-300}
|
||||
HEALTH_TIMEOUT_SECONDS: ${HEALTH_TIMEOUT_SECONDS:-600}
|
||||
STOP_TIMEOUT_SECONDS: ${STOP_TIMEOUT_SECONDS:-30}
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./state:/state
|
||||
- ./updater/targets.json:/etc/updater/targets.json:ro
|
||||
networks:
|
||||
- mmapp
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
traefik_letsencrypt:
|
||||
name: cdn_mm_traefik_letsencrypt
|
||||
mmapp-pgdata:
|
||||
|
||||
networks:
|
||||
edge:
|
||||
name: edge
|
||||
mmapp:
|
||||
name: mmapp
|
||||
@@ -0,0 +1,27 @@
|
||||
# Optional addition to docker-compose.cdn-mm.yml. Run the exported installer first.
|
||||
# Keep this file in every subsequent docker compose -f invocation.
|
||||
services:
|
||||
netflow-agent:
|
||||
image: git.shx.one/denozord/mikrotikmanager-netflow-agent:${MM_NETFLOW_AGENT_IMAGE_TAG:-latest}
|
||||
container_name: mmapp-netflow-agent
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
cap_drop: [ALL]
|
||||
cap_add: [NET_ADMIN]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
read_only: true
|
||||
tmpfs: ["/tmp:rw,noexec,nosuid,size=8m"]
|
||||
volumes:
|
||||
- ./data/netflow-wireguard:/etc/wireguard
|
||||
- ./data/netflow-agent-socket:/run/mm-netflow-host
|
||||
logging:
|
||||
driver: json-file
|
||||
options: {max-size: "5m", max-file: "2"}
|
||||
backend:
|
||||
environment:
|
||||
FLOW_AGENT_SOCKET: /run/mm-netflow-host/agent.sock
|
||||
volumes:
|
||||
- ./data/netflow-agent-socket:/run/mm-netflow-host:ro
|
||||
depends_on:
|
||||
netflow-agent:
|
||||
condition: service_healthy
|
||||
@@ -0,0 +1,65 @@
|
||||
# Local PostgreSQL 18 for MikrotikManager (dev / ETL dry-run).
|
||||
# copy env.postgres.example → deploy/.env.postgres
|
||||
# docker compose -f deploy/docker-compose.postgres.yml --env-file deploy/.env.postgres up -d
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: ${POSTGRES_IMAGE_TAG:-postgres:18-alpine}
|
||||
container_name: mmapp-postgres
|
||||
restart: unless-stopped
|
||||
shm_size: "256mb"
|
||||
environment:
|
||||
POSTGRES_USER: mmapp
|
||||
POSTGRES_DB: mmapp
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C.UTF-8 --data-checksums"
|
||||
TZ: UTC
|
||||
PGTZ: UTC
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
- timezone=UTC
|
||||
- -c
|
||||
- listen_addresses=*
|
||||
- -c
|
||||
- max_connections=100
|
||||
- -c
|
||||
- shared_buffers=256MB
|
||||
- -c
|
||||
- work_mem=16MB
|
||||
- -c
|
||||
- maintenance_work_mem=128MB
|
||||
- -c
|
||||
- wal_compression=lz4
|
||||
- -c
|
||||
- default_toast_compression=lz4
|
||||
- -c
|
||||
- io_method=worker
|
||||
- -c
|
||||
- io_workers=3
|
||||
- -c
|
||||
- effective_io_concurrency=200
|
||||
- -c
|
||||
- max_wal_size=2GB
|
||||
- -c
|
||||
- checkpoint_timeout=15min
|
||||
- -c
|
||||
- checkpoint_completion_target=0.9
|
||||
volumes:
|
||||
# PostgreSQL 18+: VOLUME is /var/lib/postgresql (PGDATA = .../18/docker)
|
||||
- mmapp-pgdata:/var/lib/postgresql
|
||||
ports:
|
||||
- "127.0.0.1:${POSTGRES_PORT:-5432}:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U mmapp -d mmapp"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
mmapp-pgdata:
|
||||
@@ -0,0 +1,121 @@
|
||||
name: mmapp-test
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: ${POSTGRES_IMAGE_TAG:-postgres:18-alpine}
|
||||
container_name: mmapp-test-postgres
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
shm_size: "256mb"
|
||||
environment:
|
||||
POSTGRES_USER: mmapp
|
||||
POSTGRES_DB: mmapp
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C.UTF-8 --data-checksums"
|
||||
TZ: UTC
|
||||
PGTZ: UTC
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
- timezone=UTC
|
||||
- -c
|
||||
- listen_addresses=*
|
||||
- -c
|
||||
- max_connections=100
|
||||
- -c
|
||||
- shared_buffers=256MB
|
||||
- -c
|
||||
- work_mem=16MB
|
||||
- -c
|
||||
- maintenance_work_mem=128MB
|
||||
- -c
|
||||
- wal_compression=lz4
|
||||
- -c
|
||||
- default_toast_compression=lz4
|
||||
- -c
|
||||
- io_method=worker
|
||||
- -c
|
||||
- io_workers=3
|
||||
- -c
|
||||
- effective_io_concurrency=200
|
||||
- -c
|
||||
- max_wal_size=2GB
|
||||
- -c
|
||||
- checkpoint_timeout=15min
|
||||
- -c
|
||||
- checkpoint_completion_target=0.9
|
||||
volumes:
|
||||
# PostgreSQL 18+: VOLUME is /var/lib/postgresql (PGDATA = .../18/docker)
|
||||
- mmapp-pgdata:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U mmapp -d mmapp"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
backend:
|
||||
image: mmapp-test/backend:${BACKEND_IMAGE_TAG:-${MMAPP_IMAGE_TAG:?set MMAPP_IMAGE_TAG}}
|
||||
container_name: mmapp-test-backend
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "127.0.0.1:8000:8000"
|
||||
# IPFIX: только на WG-адресе хоста после `wg-quick up wg-flow`, не 0.0.0.0
|
||||
# - "10.255.254.1:4739:4739/udp"
|
||||
environment:
|
||||
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:3000}
|
||||
DATABASE_URL: postgres://mmapp:${POSTGRES_PASSWORD}@postgres:5432/mmapp
|
||||
DATABASE_PATH: /app/data/mikrotik.db
|
||||
AUTH_REQUIRED: ${AUTH_REQUIRED:-false}
|
||||
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET}
|
||||
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.test.local}
|
||||
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-http://localhost:5175}
|
||||
# IPFIX: внутри контейнера слушаем все интерфейсы; на хосте UDP 4739 публикуется только на WG-IP
|
||||
FLOW_LISTEN_HOST: "0.0.0.0"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
# Native .rsc files must survive backend recreation.
|
||||
- ./data/backups:/app/storage/backups
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:8000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 300s
|
||||
|
||||
frontend:
|
||||
image: mmapp-test/frontend:${MMAPP_IMAGE_TAG:?set MMAPP_IMAGE_TAG}
|
||||
container_name: mmapp-test-frontend
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
environment:
|
||||
BACKEND_INTERNAL_URL: http://backend:8000
|
||||
|
||||
|
||||
volumes:
|
||||
mmapp-pgdata:
|
||||
@@ -0,0 +1,205 @@
|
||||
# MikrotikManager behind an existing CDNManager Traefik (no second Traefik).
|
||||
#
|
||||
# Prerequisite: CDNManager stack is up — network `cdnmanager` and container
|
||||
# `cdnmanager-traefik` already publish :80/:443 and watch Docker labels.
|
||||
#
|
||||
# On server:
|
||||
# mkdir -p /opt/mmapp/data /opt/mmapp/state /opt/mmapp/updater
|
||||
# cp deploy/docker-compose.traefik-cdn.yml /opt/mmapp/docker-compose.yml
|
||||
# cp deploy/env.traefik.example /opt/mmapp/.env
|
||||
# # set MM_DOMAIN / CORS_ORIGIN; CF_* / LETSENCRYPT_* not required here
|
||||
# cp deploy/updater/targets.json.example /opt/mmapp/updater/targets.json
|
||||
# docker login git.shx.one
|
||||
# cd /opt/mmapp && docker compose pull && docker compose up -d
|
||||
#
|
||||
# Equivalent plain CLI: deploy/run-beside-cdn-traefik.sh
|
||||
#
|
||||
# Traffic:
|
||||
# Internet → CDNManager Traefik (:80/:443) → mmapp-frontend:3000 (network cdnmanager)
|
||||
# └─ rewrite /api,/health → backend:8000 (network mmapp)
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: ${POSTGRES_IMAGE_TAG:-postgres:18-alpine}
|
||||
container_name: mmapp-postgres
|
||||
restart: unless-stopped
|
||||
shm_size: "256mb"
|
||||
environment:
|
||||
POSTGRES_USER: mmapp
|
||||
POSTGRES_DB: mmapp
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C.UTF-8 --data-checksums"
|
||||
TZ: UTC
|
||||
PGTZ: UTC
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
- timezone=UTC
|
||||
- -c
|
||||
- listen_addresses=*
|
||||
- -c
|
||||
- max_connections=100
|
||||
- -c
|
||||
- shared_buffers=256MB
|
||||
- -c
|
||||
- work_mem=16MB
|
||||
- -c
|
||||
- maintenance_work_mem=128MB
|
||||
- -c
|
||||
- wal_compression=lz4
|
||||
- -c
|
||||
- default_toast_compression=lz4
|
||||
- -c
|
||||
- io_method=worker
|
||||
- -c
|
||||
- io_workers=3
|
||||
- -c
|
||||
- effective_io_concurrency=200
|
||||
- -c
|
||||
- max_wal_size=2GB
|
||||
- -c
|
||||
- checkpoint_timeout=15min
|
||||
- -c
|
||||
- checkpoint_completion_target=0.9
|
||||
volumes:
|
||||
# PostgreSQL 18+: VOLUME is /var/lib/postgresql (PGDATA = .../18/docker)
|
||||
- mmapp-pgdata:/var/lib/postgresql
|
||||
networks:
|
||||
- mmapp
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U mmapp -d mmapp"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
backend:
|
||||
image: git.shx.one/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG:-latest}
|
||||
pull_policy: always
|
||||
container_name: mmapp-backend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: "8000"
|
||||
DATABASE_URL: postgres://mmapp:${POSTGRES_PASSWORD}@postgres:5432/mmapp
|
||||
DATABASE_PATH: /app/data/mikrotik.db
|
||||
CORS_ORIGIN: ${CORS_ORIGIN:-https://mm.shnt.top}
|
||||
AUTH_REQUIRED: ${AUTH_REQUIRED:-true}
|
||||
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET in .env}
|
||||
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
|
||||
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top}
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
# Native .rsc files must survive backend recreation.
|
||||
- ./data/backups:/app/storage/backups
|
||||
networks:
|
||||
mmapp:
|
||||
aliases:
|
||||
- backend
|
||||
labels:
|
||||
mmapp.updater.managed: "true"
|
||||
mmapp.updater.target: backend
|
||||
mmapp.updater.image: git.shx.one/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG:-latest}
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:8000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 300s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
frontend:
|
||||
image: git.shx.one/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG:-latest}
|
||||
pull_policy: always
|
||||
container_name: mmapp-frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
environment:
|
||||
BACKEND_INTERNAL_URL: http://backend:8000
|
||||
networks:
|
||||
mmapp:
|
||||
aliases:
|
||||
- frontend
|
||||
cdnmanager: {}
|
||||
labels:
|
||||
- mmapp.updater.managed=true
|
||||
- mmapp.updater.target=frontend
|
||||
- mmapp.updater.image=git.shx.one/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG:-latest}
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=cdnmanager
|
||||
- traefik.http.routers.mmapp.rule=Host(`${MM_DOMAIN:-mm.shnt.top}`)
|
||||
- traefik.http.routers.mmapp.entrypoints=websecure
|
||||
- traefik.http.routers.mmapp.tls=true
|
||||
- traefik.http.routers.mmapp.tls.certresolver=letsencrypt
|
||||
- traefik.http.services.mmapp.loadbalancer.server.port=3000
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:3000/dashboard').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 25s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
updater:
|
||||
image: git.shx.one/denozord/mikrotikmanager-updater:${MM_UPDATER_IMAGE_TAG:-latest}
|
||||
pull_policy: always
|
||||
container_name: mmapp-updater
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- frontend
|
||||
environment:
|
||||
REGISTRY: git.shx.one
|
||||
REGISTRY_USERNAME: ${REGISTRY_USERNAME:-}
|
||||
REGISTRY_PASSWORD: ${REGISTRY_PASSWORD:-}
|
||||
POLL_INTERVAL_SECONDS: ${POLL_INTERVAL_SECONDS:-300}
|
||||
HEALTH_TIMEOUT_SECONDS: ${HEALTH_TIMEOUT_SECONDS:-600}
|
||||
STOP_TIMEOUT_SECONDS: ${STOP_TIMEOUT_SECONDS:-30}
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./state:/state
|
||||
- ./updater/targets.json:/etc/updater/targets.json:ro
|
||||
networks:
|
||||
- mmapp
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
mmapp:
|
||||
name: mmapp
|
||||
cdnmanager:
|
||||
external: true
|
||||
name: cdnmanager
|
||||
|
||||
volumes:
|
||||
mmapp-pgdata:
|
||||
@@ -0,0 +1,238 @@
|
||||
# MikrotikManager + Traefik in one Compose stack (production).
|
||||
# Docs: README.md (раздел «Прод-развёртывание Docker»)
|
||||
#
|
||||
# On server:
|
||||
# mkdir -p /opt/mmapp/data /opt/mmapp/state
|
||||
# cp deploy/docker-compose.traefik.yml /opt/mmapp/docker-compose.yml
|
||||
# cp deploy/env.traefik.example /opt/mmapp/.env # fill secrets
|
||||
# cp deploy/updater/targets.json.example /opt/mmapp/updater/targets.json
|
||||
# docker login git.shx.one
|
||||
# cd /opt/mmapp && docker compose pull && docker compose up -d
|
||||
#
|
||||
# Traffic:
|
||||
# Internet → :80/:443 (Traefik) → frontend:3000
|
||||
# └─ rewrite /api,/health → backend:8000 (internal)
|
||||
# Backend is not published on the host — only Traefik exposes 80/443.
|
||||
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:${TRAEFIK_IMAGE_TAG:-v3.7}
|
||||
container_name: mmapp-traefik
|
||||
restart: unless-stopped
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
ports:
|
||||
- "${TRAEFIK_HTTP_PORT:-80}:80"
|
||||
- "${TRAEFIK_HTTPS_PORT:-443}:443"
|
||||
environment:
|
||||
CF_DNS_API_TOKEN: ${CF_DNS_API_TOKEN:?set CF_DNS_API_TOKEN in .env}
|
||||
# Optional if DNS token lacks Zone:Read:
|
||||
# CF_ZONE_API_TOKEN: ${CF_ZONE_API_TOKEN:-}
|
||||
command:
|
||||
- --log.level=${TRAEFIK_LOG_LEVEL:-INFO}
|
||||
- --api.dashboard=false
|
||||
- --providers.docker=true
|
||||
- --providers.docker.exposedbydefault=false
|
||||
- --providers.docker.network=mmapp
|
||||
- --entrypoints.web.address=:80
|
||||
- --entrypoints.websecure.address=:443
|
||||
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
||||
- --entrypoints.web.http.redirections.entrypoint.scheme=https
|
||||
- --certificatesresolvers.letsencrypt.acme.email=${LETSENCRYPT_EMAIL:?set LETSENCRYPT_EMAIL in .env}
|
||||
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
|
||||
- --certificatesresolvers.letsencrypt.acme.dnschallenge=true
|
||||
- --certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare
|
||||
- --certificatesresolvers.letsencrypt.acme.dnschallenge.delaybeforecheck=15
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- traefik_letsencrypt:/letsencrypt
|
||||
networks:
|
||||
- mmapp
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
postgres:
|
||||
image: ${POSTGRES_IMAGE_TAG:-postgres:18-alpine}
|
||||
container_name: mmapp-postgres
|
||||
restart: unless-stopped
|
||||
shm_size: "256mb"
|
||||
environment:
|
||||
POSTGRES_USER: mmapp
|
||||
POSTGRES_DB: mmapp
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C.UTF-8 --data-checksums"
|
||||
TZ: UTC
|
||||
PGTZ: UTC
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
- timezone=UTC
|
||||
- -c
|
||||
- listen_addresses=*
|
||||
- -c
|
||||
- max_connections=100
|
||||
- -c
|
||||
- shared_buffers=256MB
|
||||
- -c
|
||||
- work_mem=16MB
|
||||
- -c
|
||||
- maintenance_work_mem=128MB
|
||||
- -c
|
||||
- wal_compression=lz4
|
||||
- -c
|
||||
- default_toast_compression=lz4
|
||||
- -c
|
||||
- io_method=worker
|
||||
- -c
|
||||
- io_workers=3
|
||||
- -c
|
||||
- effective_io_concurrency=200
|
||||
- -c
|
||||
- max_wal_size=2GB
|
||||
- -c
|
||||
- checkpoint_timeout=15min
|
||||
- -c
|
||||
- checkpoint_completion_target=0.9
|
||||
volumes:
|
||||
# PostgreSQL 18+: VOLUME is /var/lib/postgresql (PGDATA = .../18/docker)
|
||||
- mmapp-pgdata:/var/lib/postgresql
|
||||
networks:
|
||||
- mmapp
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U mmapp -d mmapp"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
backend:
|
||||
image: git.shx.one/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG:-latest}
|
||||
pull_policy: always
|
||||
container_name: mmapp-backend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
traefik:
|
||||
condition: service_started
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
# No host ports — frontend reaches backend on the Compose network.
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: "8000"
|
||||
DATABASE_URL: postgres://mmapp:${POSTGRES_PASSWORD}@postgres:5432/mmapp
|
||||
DATABASE_PATH: /app/data/mikrotik.db
|
||||
CORS_ORIGIN: ${CORS_ORIGIN:-https://mm.shnt.top}
|
||||
AUTH_REQUIRED: ${AUTH_REQUIRED:-true}
|
||||
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET in .env}
|
||||
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
|
||||
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top}
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
# Native .rsc files must survive backend recreation.
|
||||
- ./data/backups:/app/storage/backups
|
||||
networks:
|
||||
- mmapp
|
||||
labels:
|
||||
mmapp.updater.managed: "true"
|
||||
mmapp.updater.target: backend
|
||||
mmapp.updater.image: git.shx.one/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG:-latest}
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:8000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 300s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
frontend:
|
||||
image: git.shx.one/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG:-latest}
|
||||
pull_policy: always
|
||||
container_name: mmapp-frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
# No host ports — only Traefik publishes 80/443.
|
||||
environment:
|
||||
BACKEND_INTERNAL_URL: http://backend:8000
|
||||
networks:
|
||||
- mmapp
|
||||
labels:
|
||||
- mmapp.updater.managed=true
|
||||
- mmapp.updater.target=frontend
|
||||
- mmapp.updater.image=git.shx.one/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG:-latest}
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=mmapp
|
||||
- traefik.http.routers.mmapp.rule=Host(`${MM_DOMAIN:-mm.shnt.top}`)
|
||||
- traefik.http.routers.mmapp.entrypoints=websecure
|
||||
- traefik.http.routers.mmapp.tls=true
|
||||
- traefik.http.routers.mmapp.tls.certresolver=letsencrypt
|
||||
- traefik.http.services.mmapp.loadbalancer.server.port=3000
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:3000/dashboard').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 25s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
updater:
|
||||
image: git.shx.one/denozord/mikrotikmanager-updater:${MM_UPDATER_IMAGE_TAG:-latest}
|
||||
pull_policy: always
|
||||
container_name: mmapp-updater
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- frontend
|
||||
environment:
|
||||
REGISTRY: git.shx.one
|
||||
REGISTRY_USERNAME: ${REGISTRY_USERNAME:-}
|
||||
REGISTRY_PASSWORD: ${REGISTRY_PASSWORD:-}
|
||||
POLL_INTERVAL_SECONDS: ${POLL_INTERVAL_SECONDS:-300}
|
||||
HEALTH_TIMEOUT_SECONDS: ${HEALTH_TIMEOUT_SECONDS:-600}
|
||||
STOP_TIMEOUT_SECONDS: ${STOP_TIMEOUT_SECONDS:-30}
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./state:/state
|
||||
- ./updater/targets.json:/etc/updater/targets.json:ro
|
||||
networks:
|
||||
- mmapp
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
traefik_letsencrypt:
|
||||
name: mmapp_traefik_letsencrypt
|
||||
mmapp-pgdata:
|
||||
|
||||
networks:
|
||||
mmapp:
|
||||
name: mmapp
|
||||
@@ -0,0 +1,123 @@
|
||||
services:
|
||||
postgres:
|
||||
image: ${POSTGRES_IMAGE_TAG:-postgres:18-alpine}
|
||||
container_name: mmapp-postgres
|
||||
restart: unless-stopped
|
||||
shm_size: "256mb"
|
||||
environment:
|
||||
POSTGRES_USER: mmapp
|
||||
POSTGRES_DB: mmapp
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C.UTF-8 --data-checksums"
|
||||
TZ: UTC
|
||||
PGTZ: UTC
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
- timezone=UTC
|
||||
- -c
|
||||
- listen_addresses=*
|
||||
- -c
|
||||
- max_connections=100
|
||||
- -c
|
||||
- shared_buffers=256MB
|
||||
- -c
|
||||
- work_mem=16MB
|
||||
- -c
|
||||
- maintenance_work_mem=128MB
|
||||
- -c
|
||||
- wal_compression=lz4
|
||||
- -c
|
||||
- default_toast_compression=lz4
|
||||
- -c
|
||||
- io_method=worker
|
||||
- -c
|
||||
- io_workers=3
|
||||
- -c
|
||||
- effective_io_concurrency=200
|
||||
- -c
|
||||
- max_wal_size=2GB
|
||||
- -c
|
||||
- checkpoint_timeout=15min
|
||||
- -c
|
||||
- checkpoint_completion_target=0.9
|
||||
volumes:
|
||||
# PostgreSQL 18+: VOLUME is /var/lib/postgresql (PGDATA = .../18/docker)
|
||||
- mmapp-pgdata:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U mmapp -d mmapp"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
backend:
|
||||
image: git.shx.one/denozord/mikrotikmanager-backend:latest
|
||||
container_name: mmapp-backend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8000:8000"
|
||||
# IPFIX: только на WG-адресе хоста после `wg-quick up wg-flow`, не 0.0.0.0
|
||||
# - "10.255.254.1:4739:4739/udp"
|
||||
environment:
|
||||
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:3000}
|
||||
DATABASE_URL: postgres://mmapp:${POSTGRES_PASSWORD}@postgres:5432/mmapp
|
||||
DATABASE_PATH: /app/data/mikrotik.db
|
||||
# IPFIX: внутри контейнера слушаем все интерфейсы; на хосте UDP 4739 публикуется только на WG-IP
|
||||
FLOW_LISTEN_HOST: "0.0.0.0"
|
||||
volumes:
|
||||
- /opt/mmapp/data:/app/data
|
||||
# Native .rsc files must survive backend recreation.
|
||||
- /opt/mmapp/data/backups:/app/storage/backups
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:8000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 300s
|
||||
labels:
|
||||
mmapp.updater.managed: "true"
|
||||
mmapp.updater.target: backend
|
||||
mmapp.updater.image: git.shx.one/denozord/mikrotikmanager-backend:latest
|
||||
|
||||
frontend:
|
||||
image: git.shx.one/denozord/mikrotikmanager-frontend:latest
|
||||
container_name: mmapp-frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
BACKEND_INTERNAL_URL: http://backend:8000
|
||||
labels:
|
||||
mmapp.updater.managed: "true"
|
||||
mmapp.updater.target: frontend
|
||||
mmapp.updater.image: git.shx.one/denozord/mikrotikmanager-frontend:latest
|
||||
|
||||
updater:
|
||||
image: git.shx.one/denozord/mikrotikmanager-updater:latest
|
||||
container_name: mmapp-updater
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
REGISTRY: git.shx.one
|
||||
REGISTRY_USERNAME: ${REGISTRY_USERNAME:-}
|
||||
REGISTRY_PASSWORD: ${REGISTRY_PASSWORD:-}
|
||||
POLL_INTERVAL_SECONDS: ${POLL_INTERVAL_SECONDS:-300}
|
||||
HEALTH_TIMEOUT_SECONDS: ${HEALTH_TIMEOUT_SECONDS:-600}
|
||||
STOP_TIMEOUT_SECONDS: ${STOP_TIMEOUT_SECONDS:-30}
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /opt/mmapp/state:/state
|
||||
- /opt/mmapp/updater/targets.json:/etc/updater/targets.json:ro
|
||||
|
||||
volumes:
|
||||
mmapp-pgdata:
|
||||
@@ -0,0 +1,55 @@
|
||||
# Production .env for deploy/docker-compose.cdn-mm.yml
|
||||
# (CDN Manager + MikrotikManager + one Traefik).
|
||||
# Copy to /opt/cdn-mm/.env and fill secrets. Do not commit.
|
||||
|
||||
# --- Traefik / Let's Encrypt (Cloudflare DNS-01) ---
|
||||
# Token for ACME only (Zone DNS Edit). Separate from CLOUDFLARE_API_TOKEN below.
|
||||
CF_DNS_API_TOKEN=
|
||||
LETSENCRYPT_EMAIL=admin@shnt.top
|
||||
# TRAEFIK_IMAGE_TAG=v3.7
|
||||
# TRAEFIK_HTTP_PORT=80
|
||||
# TRAEFIK_HTTPS_PORT=443
|
||||
# TRAEFIK_LOG_LEVEL=INFO
|
||||
|
||||
# --- Public hosts ---
|
||||
CDN_DOMAIN=cdn.shnt.top
|
||||
MM_DOMAIN=mm.shnt.top
|
||||
# Must match MM UI origin (https:// + MM_DOMAIN).
|
||||
CORS_ORIGIN=https://mm.shnt.top
|
||||
|
||||
# --- Images ---
|
||||
CDN_IMAGE_TAG=latest
|
||||
# drop-in alias (same manifest): git.shx.one/denozord/cdn-manager
|
||||
MM_BACKEND_IMAGE_TAG=latest
|
||||
MM_FRONTEND_IMAGE_TAG=latest
|
||||
MM_UPDATER_IMAGE_TAG=latest
|
||||
|
||||
# --- CDN Manager ---
|
||||
CLOUDFLARE_API_TOKEN=
|
||||
LOG_LEVEL=info
|
||||
NODE_ENV=production
|
||||
|
||||
# --- MikrotikManager PostgreSQL ---
|
||||
POSTGRES_PASSWORD=
|
||||
|
||||
# Portal SSO — used by CDN Manager and MikrotikManager backend
|
||||
AUTH_REQUIRED=true
|
||||
# Same HS256 secret as auth-portal JWT_SECRET (required)
|
||||
AUTH_JWT_SECRET=
|
||||
# Optional alias — CDN Manager also reads JWT_SECRET
|
||||
JWT_SECRET=
|
||||
AUTH_ISSUER=https://auth.shnt.top
|
||||
AUTH_PORTAL_URL=https://auth.shnt.top
|
||||
# Shared with auth-portal AUDIT_INGEST_SECRET (optional, CDN Manager)
|
||||
AUTH_AUDIT_INGEST_SECRET=
|
||||
|
||||
# Legacy local admin (CDN) — only when AUTH_REQUIRED=false
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD_HASH=
|
||||
|
||||
# --- MikrotikManager updater (optional; private registry pull) ---
|
||||
REGISTRY_USERNAME=
|
||||
REGISTRY_PASSWORD=
|
||||
# POLL_INTERVAL_SECONDS=300
|
||||
# HEALTH_TIMEOUT_SECONDS=120
|
||||
# STOP_TIMEOUT_SECONDS=30
|
||||
@@ -0,0 +1,8 @@
|
||||
# Copy to deploy/.env.postgres (gitignored) or merge into prod .env
|
||||
POSTGRES_PASSWORD=change-me
|
||||
POSTGRES_PORT=5432
|
||||
# POSTGRES_IMAGE_TAG=postgres:18-alpine
|
||||
|
||||
# Backend
|
||||
DATABASE_URL=postgres://mmapp:change-me@127.0.0.1:5432/mmapp
|
||||
DATABASE_PATH=./mikrotik.db
|
||||
@@ -0,0 +1,4 @@
|
||||
# PostgreSQL 18 for MikrotikManager backend.
|
||||
|
||||
POSTGRES_PASSWORD=
|
||||
HEALTH_TIMEOUT_SECONDS=600
|
||||
@@ -0,0 +1,11 @@
|
||||
# Save as /opt/mmapp-test/.env on the test server (mode 0600).
|
||||
# Generate unique hex secrets on that server; never reuse production credentials.
|
||||
POSTGRES_PASSWORD=
|
||||
AUTH_JWT_SECRET=
|
||||
MMAPP_IMAGE_TAG=ux-673e5ba
|
||||
# Optional backend-only image tag; blank uses MMAPP_IMAGE_TAG.
|
||||
BACKEND_IMAGE_TAG=
|
||||
AUTH_REQUIRED=false
|
||||
AUTH_ISSUER=https://auth.test.local
|
||||
AUTH_PORTAL_URL=http://localhost:5175
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
@@ -0,0 +1,40 @@
|
||||
# Production .env for MikrotikManager Traefik deploys. Do not commit.
|
||||
# Use with:
|
||||
# deploy/docker-compose.traefik.yml — own Traefik (standalone)
|
||||
# deploy/docker-compose.traefik-cdn.yml — reuse CDNManager Traefik (network cdnmanager)
|
||||
# deploy/run-beside-cdn-traefik.sh — plain docker CLI beside CDNManager
|
||||
|
||||
# --- Public host ---
|
||||
MM_DOMAIN=mm.shnt.top
|
||||
# Must match the public HTTPS origin of the UI (same as MM_DOMAIN with https://).
|
||||
CORS_ORIGIN=https://mm.shnt.top
|
||||
|
||||
# --- Portal SSO (MM backend) ---
|
||||
AUTH_REQUIRED=true
|
||||
AUTH_JWT_SECRET=
|
||||
AUTH_ISSUER=https://auth.shnt.top
|
||||
AUTH_PORTAL_URL=https://auth.shnt.top
|
||||
|
||||
# --- Traefik / Let's Encrypt (only for docker-compose.traefik.yml standalone) ---
|
||||
# Not required when attaching to CDNManager Traefik (traefik-cdn / run-beside script).
|
||||
CF_DNS_API_TOKEN=
|
||||
LETSENCRYPT_EMAIL=admin@shnt.top
|
||||
# TRAEFIK_IMAGE_TAG=v3.7
|
||||
# TRAEFIK_HTTP_PORT=80
|
||||
# TRAEFIK_HTTPS_PORT=443
|
||||
# TRAEFIK_LOG_LEVEL=INFO
|
||||
|
||||
# --- PostgreSQL ---
|
||||
POSTGRES_PASSWORD=
|
||||
|
||||
# --- Images ---
|
||||
MM_BACKEND_IMAGE_TAG=latest
|
||||
MM_FRONTEND_IMAGE_TAG=latest
|
||||
MM_UPDATER_IMAGE_TAG=latest
|
||||
|
||||
# --- Updater (optional; needed for private registry pull) ---
|
||||
REGISTRY_USERNAME=
|
||||
REGISTRY_PASSWORD=
|
||||
# POLL_INTERVAL_SECONDS=300
|
||||
# HEALTH_TIMEOUT_SECONDS=120
|
||||
# STOP_TIMEOUT_SECONDS=30
|
||||
@@ -0,0 +1,11 @@
|
||||
FROM alpine:3.22 AS base
|
||||
RUN apk add --no-cache python3 wireguard-tools iproute2
|
||||
COPY agent.py /app/agent.py
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||
HEALTHCHECK --interval=15s --timeout=5s --retries=3 CMD ["python3", "/app/agent.py", "--healthcheck"]
|
||||
ENTRYPOINT ["python3", "/app/agent.py"]
|
||||
|
||||
FROM base AS test
|
||||
COPY test_agent.py /app/test_agent.py
|
||||
|
||||
FROM base AS runtime
|
||||
@@ -0,0 +1,367 @@
|
||||
"""Local, single-interface WireGuard controller. Never executes wg-quick/hooks."""
|
||||
import base64
|
||||
import binascii
|
||||
import http.client
|
||||
import http.server
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import socket
|
||||
import socketserver
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
|
||||
IFACE = "wg-flow"
|
||||
CONFIG = Path(os.environ.get("FLOW_AGENT_CONFIG_DIR", "/etc/wireguard"))
|
||||
SOCKET = os.environ.get("FLOW_AGENT_SOCKET", "/run/mm-netflow-host/agent.sock")
|
||||
LOCK = threading.RLock()
|
||||
MAX_BODY = 262144
|
||||
|
||||
|
||||
class AgentError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def run(*args, input=None):
|
||||
try:
|
||||
result = subprocess.run(args, input=input, text=True, capture_output=True, timeout=10)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
raise AgentError("Команда WireGuard/IP недоступна или превысила время ожидания") from None
|
||||
if result.returncode:
|
||||
# stderr/stdout can contain keys and must never reach an HTTP response/log.
|
||||
raise AgentError("Операция WireGuard/IP не выполнена")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def key(value):
|
||||
try:
|
||||
if not isinstance(value, str) or len(value) != 44 or len(base64.b64decode(value, validate=True)) != 32:
|
||||
raise ValueError()
|
||||
except (ValueError, binascii.Error):
|
||||
raise AgentError("Некорректный ключ WireGuard") from None
|
||||
return value
|
||||
|
||||
|
||||
def port(value):
|
||||
if type(value) is not int or not 1 <= value <= 65535:
|
||||
raise AgentError("Некорректный UDP-порт")
|
||||
return value
|
||||
|
||||
|
||||
def endpoint(value):
|
||||
if not isinstance(value, str) or not re.fullmatch(r"(?:\[[0-9a-fA-F:]+\]|[a-zA-Z0-9._-]+):[0-9]{1,5}", value):
|
||||
raise AgentError("Некорректный endpoint")
|
||||
port(int(value.rsplit(":", 1)[1]))
|
||||
return value
|
||||
|
||||
|
||||
def parse_conf(text):
|
||||
interface, peers, section = {}, [], None
|
||||
allowed = {"interface": {"PrivateKey", "Address", "ListenPort", "MTU"},
|
||||
"peer": {"PublicKey", "PresharedKey", "AllowedIPs", "Endpoint", "PersistentKeepalive"}}
|
||||
for raw in text.splitlines():
|
||||
line = raw.split("#", 1)[0].strip()
|
||||
if not line:
|
||||
continue
|
||||
if line == "[Interface]" and section is None:
|
||||
section, current = "interface", interface
|
||||
elif line == "[Peer]" and section is not None:
|
||||
section, current = "peer", {}
|
||||
peers.append(current)
|
||||
else:
|
||||
name, sep, value = line.partition("=")
|
||||
name, value = name.strip(), value.strip()
|
||||
if not sep or section is None or name not in allowed[section] or name in current or not value:
|
||||
raise AgentError("Конфигурация wg-flow содержит неподдерживаемые или повторные параметры; требуется ручная проверка")
|
||||
current[name] = value
|
||||
if not interface or not {"PrivateKey", "Address", "ListenPort"} <= interface.keys():
|
||||
raise AgentError("Неполная конфигурация wg-flow")
|
||||
key(interface["PrivateKey"])
|
||||
try:
|
||||
ipaddress.IPv4Interface(interface["Address"])
|
||||
port(int(interface["ListenPort"]))
|
||||
if "MTU" in interface and not 576 <= int(interface["MTU"]) <= 9000:
|
||||
raise ValueError()
|
||||
for peer in peers:
|
||||
key(peer.get("PublicKey"))
|
||||
if "PresharedKey" in peer:
|
||||
key(peer["PresharedKey"])
|
||||
if "Endpoint" in peer:
|
||||
endpoint(peer["Endpoint"])
|
||||
if peer.get("AllowedIPs"):
|
||||
networks(peer["AllowedIPs"].split(","))
|
||||
if "PersistentKeepalive" in peer and not 0 <= int(peer["PersistentKeepalive"]) <= 65535:
|
||||
raise ValueError()
|
||||
except (ValueError, TypeError):
|
||||
raise AgentError("Некорректные параметры wg-flow") from None
|
||||
if len({p["PublicKey"] for p in peers}) != len(peers):
|
||||
raise AgentError("Повторный публичный ключ пира")
|
||||
return interface, peers
|
||||
|
||||
|
||||
def networks(values):
|
||||
if not isinstance(values, list) or not values:
|
||||
raise AgentError("AllowedIPs должны содержать адреса")
|
||||
try:
|
||||
return [ipaddress.ip_network(v.strip(), strict=False) for v in values]
|
||||
except (ValueError, AttributeError):
|
||||
raise AgentError("Некорректные AllowedIPs") from None
|
||||
|
||||
|
||||
def validate(raw):
|
||||
fields = {"collectorIp", "prefix", "flowListenPort", "wgListenPort", "hostPublicKey", "hostPrivateKey", "peers"}
|
||||
if not isinstance(raw, dict) or set(raw) != fields:
|
||||
raise AgentError("Некорректный снимок NetFlow")
|
||||
try:
|
||||
collector = ipaddress.IPv4Address(raw["collectorIp"])
|
||||
prefix = ipaddress.IPv4Network(raw["prefix"], strict=False)
|
||||
if prefix.prefixlen != 24 or collector not in prefix or collector in (prefix.network_address, prefix.broadcast_address):
|
||||
raise ValueError()
|
||||
except (ValueError, TypeError):
|
||||
raise AgentError("Требуется IPv4 overlay /24 и адрес коллектора внутри него") from None
|
||||
port(raw["flowListenPort"])
|
||||
port(raw["wgListenPort"])
|
||||
key(raw["hostPublicKey"])
|
||||
key(raw["hostPrivateKey"])
|
||||
if run("wg", "pubkey", input=raw["hostPrivateKey"] + "\n") != raw["hostPublicKey"]:
|
||||
raise AgentError("Публичный и приватный ключи MM не соответствуют друг другу")
|
||||
binding = json.loads((CONFIG / "binding.json").read_text())
|
||||
for name in ("collectorIp", "prefix", "flowListenPort", "wgListenPort"):
|
||||
if binding.get(name) != raw[name]:
|
||||
raise AgentError("Адрес, префикс или порт отличаются от установки агента; повторите установщик")
|
||||
if not isinstance(raw["peers"], list) or len(raw["peers"]) > 253:
|
||||
raise AgentError("Некорректный список пиров")
|
||||
peers, ids, keys = [], set(), set()
|
||||
for peer in raw["peers"]:
|
||||
if not isinstance(peer, dict) or not {"serverId", "publicKey", "allowedIps"} <= peer.keys() or set(peer) - {"serverId", "publicKey", "allowedIps", "endpoint"}:
|
||||
raise AgentError("Некорректный пир")
|
||||
peer_key = key(peer["publicKey"])
|
||||
if type(peer["serverId"]) is not int or peer["serverId"] <= 0 or peer["serverId"] in ids or peer_key in keys or peer_key == raw["hostPublicKey"]:
|
||||
raise AgentError("Повторный или некорректный пир")
|
||||
ids.add(peer["serverId"])
|
||||
keys.add(peer_key)
|
||||
nets = networks(peer["allowedIps"])
|
||||
if len(nets) != 1 or nets[0].version != 4 or nets[0].prefixlen != 32 or nets[0].network_address not in prefix or nets[0].network_address in (collector, prefix.network_address, prefix.broadcast_address):
|
||||
raise AgentError("Пиру разрешён только собственный IPv4 /32 внутри overlay")
|
||||
data = {"PublicKey": peer_key, "AllowedIPs": str(nets[0])}
|
||||
if peer.get("endpoint"):
|
||||
data.update(Endpoint=endpoint(peer["endpoint"]), PersistentKeepalive="25")
|
||||
peers.append(data)
|
||||
return peers
|
||||
|
||||
|
||||
def merge_peers(existing, desired):
|
||||
merged = {peer["PublicKey"]: dict(peer) for peer in existing}
|
||||
for peer in desired:
|
||||
public = peer["PublicKey"]
|
||||
# Replacing a PSK would silently break a previously working tunnel.
|
||||
if merged.get(public, {}).get("PresharedKey", "A" * 43 + "=") != "A" * 43 + "=":
|
||||
raise AgentError("Управляемый пир использует preshared key; требуется ручная проверка")
|
||||
merged[public] = dict(peer)
|
||||
rows = list(merged.values())
|
||||
wanted = {p["PublicKey"] for p in desired}
|
||||
for i, left in enumerate(rows):
|
||||
for right in rows[i + 1:]:
|
||||
if left["PublicKey"] not in wanted and right["PublicKey"] not in wanted:
|
||||
continue
|
||||
if not left.get("AllowedIPs") or not right.get("AllowedIPs"):
|
||||
continue
|
||||
if any(a.version == b.version and a.overlaps(b) for a in networks(left["AllowedIPs"].split(",")) for b in networks(right["AllowedIPs"].split(","))):
|
||||
raise AgentError("AllowedIPs управляемого пира пересекаются с другим пиром")
|
||||
return rows
|
||||
|
||||
|
||||
def render(interface, peers, native=False):
|
||||
rows = ["[Interface]"]
|
||||
rows += [f"{k} = {v}" for k, v in interface.items() if not native or k not in ("Address", "MTU")]
|
||||
for peer in peers:
|
||||
rows += ["", "[Peer]"] + [f"{k} = {v}" for k, v in peer.items() if k != "AllowedIPs" or v]
|
||||
return "\n".join(rows) + "\n"
|
||||
|
||||
|
||||
def atomic_write(path, text):
|
||||
fd, name = tempfile.mkstemp(prefix=".flow-", dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w") as stream:
|
||||
stream.write(text)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(name, path)
|
||||
if os.name == "posix":
|
||||
directory = os.open(path.parent, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(directory)
|
||||
finally:
|
||||
os.close(directory)
|
||||
finally:
|
||||
if os.path.exists(name):
|
||||
os.unlink(name)
|
||||
|
||||
|
||||
def sync(text):
|
||||
# wg requires a file. mkstemp creates it with mode 0600.
|
||||
fd, name = tempfile.mkstemp(prefix="flow-sync-")
|
||||
try:
|
||||
with os.fdopen(fd, "w") as stream:
|
||||
stream.write(text)
|
||||
run("wg", "syncconf", IFACE, name)
|
||||
finally:
|
||||
os.unlink(name)
|
||||
|
||||
|
||||
def live():
|
||||
dump = run("wg", "show", IFACE, "dump").splitlines()
|
||||
if not dump or len(dump[0].split("\t")) != 4:
|
||||
raise AgentError("Интерфейс wg-flow недоступен")
|
||||
interface = dump[0].split("\t")
|
||||
peers, raw_peers = [], []
|
||||
for line in dump[1:]:
|
||||
cols = line.split("\t")
|
||||
if len(cols) != 8:
|
||||
raise AgentError("Некорректный ответ WireGuard")
|
||||
public, psk, ep, ips, handshake, rx, tx, keepalive = cols
|
||||
peers.append({"publicKey": public, "allowedIps": ips.split(",") if ips != "(none)" else [],
|
||||
"lastHandshakeAt": int(handshake) or None, "receivedBytes": int(rx), "sentBytes": int(tx)})
|
||||
item = {"PublicKey": public, "AllowedIPs": ips if ips != "(none)" else ""}
|
||||
if psk != "(none)":
|
||||
item["PresharedKey"] = psk
|
||||
if ep != "(none)":
|
||||
item["Endpoint"] = ep
|
||||
item["PersistentKeepalive"] = keepalive
|
||||
raw_peers.append(item)
|
||||
addresses = json.loads(run("ip", "-j", "-4", "address", "show", "dev", IFACE))
|
||||
cidrs = [f"{a['local']}/{a['prefixlen']}" for row in addresses for a in row.get("addr_info", []) if a.get("family") == "inet"]
|
||||
return {"interfaceUp": any("UP" in row.get("flags", []) for row in addresses), "publicKey": interface[1], "listenPort": int(interface[2]), "addresses": cidrs, "peers": peers}, raw_peers
|
||||
|
||||
|
||||
def check(raw, apply=False):
|
||||
desired = validate(raw)
|
||||
path = CONFIG / "wg-flow.conf"
|
||||
previous = path.read_text()
|
||||
interface, stored = parse_conf(previous)
|
||||
expected_address = f"{raw['collectorIp']}/{raw['prefix'].split('/')[1]}"
|
||||
if run("wg", "pubkey", input=interface["PrivateKey"] + "\n") != raw["hostPublicKey"] or interface["Address"] != expected_address or int(interface["ListenPort"]) != raw["wgListenPort"]:
|
||||
raise AgentError("Ключ, адрес или порт существующего wg-flow отличаются от MM; требуется ручная проверка")
|
||||
status, runtime = live()
|
||||
if not status["interfaceUp"]:
|
||||
raise AgentError("Интерфейс wg-flow выключен; проверьте wg-quick на хосте")
|
||||
if status["publicKey"] != raw["hostPublicKey"] or expected_address not in status["addresses"] or status["listenPort"] != raw["wgListenPort"]:
|
||||
raise AgentError("Работающий wg-flow отличается от конфигурации; требуется ручная проверка")
|
||||
# Preserve both persistent and runtime-only third-party peers. Detect conflicts before writing.
|
||||
combined = {p["PublicKey"]: p for p in runtime}
|
||||
combined.update({p["PublicKey"]: p for p in stored})
|
||||
merged = merge_peers(list(combined.values()), desired)
|
||||
runtime_merged = merge_peers(runtime, desired)
|
||||
# Do not reset a third-party peer's runtime settings to an older persistent file.
|
||||
# Preserve roaming endpoints of managed peers unless the persisted endpoint was edited.
|
||||
stored_by_key = {p["PublicKey"]: p for p in stored}
|
||||
runtime_by_key = {p["PublicKey"]: p for p in runtime}
|
||||
desired_by_key = {p["PublicKey"]: p for p in desired}
|
||||
for peer in runtime_merged:
|
||||
public = peer["PublicKey"]
|
||||
if public in desired_by_key and stored_by_key.get(public, {}).get("Endpoint") == desired_by_key[public].get("Endpoint"):
|
||||
if runtime_by_key.get(public, {}).get("Endpoint"):
|
||||
peer["Endpoint"] = runtime_by_key[public]["Endpoint"]
|
||||
def matches(actual, persistent=False):
|
||||
by_key = {p["PublicKey"]: p for p in actual}
|
||||
return all(p["PublicKey"] in by_key and all(by_key[p["PublicKey"]].get(k) == v for k, v in p.items() if persistent or k != "Endpoint") for p in desired)
|
||||
matched = matches(stored, persistent=True) and matches(runtime)
|
||||
if apply:
|
||||
rollback = run("wg", "showconf", IFACE)
|
||||
try:
|
||||
atomic_write(path, render(interface, merged))
|
||||
# Keep the running interface header verbatim (including a possible fwmark).
|
||||
header = rollback.split("[Peer]", 1)[0].rstrip()
|
||||
peer_config = render({}, runtime_merged, native=True).removeprefix("[Interface]\n")
|
||||
sync(header + "\n" + peer_config)
|
||||
status, after = live()
|
||||
if not matches(after):
|
||||
raise AgentError("Применение пиров не подтверждено")
|
||||
except Exception:
|
||||
failures = []
|
||||
for action in (lambda: atomic_write(path, previous), lambda: sync(rollback)):
|
||||
try:
|
||||
action()
|
||||
except Exception:
|
||||
failures.append(True)
|
||||
raise AgentError("Применение не удалось; " + ("восстановление требует ручной проверки" if failures else "предыдущая конфигурация восстановлена")) from None
|
||||
matched = True
|
||||
return {**status, "configMatches": matched}
|
||||
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, *_):
|
||||
pass
|
||||
|
||||
def handle_request(self):
|
||||
try:
|
||||
with LOCK:
|
||||
if self.command == "GET" and self.path == "/status":
|
||||
result, _ = live()
|
||||
result["configMatches"] = None
|
||||
elif self.command == "POST" and self.path in ("/check", "/apply"):
|
||||
size = int(self.headers.get("Content-Length", "0"))
|
||||
if not 0 < size <= MAX_BODY or self.headers.get("Transfer-Encoding"):
|
||||
raise AgentError("Некорректный размер запроса")
|
||||
raw = json.loads(self.rfile.read(size))
|
||||
result = check(raw, apply=self.path == "/apply")
|
||||
else:
|
||||
self.respond(404, {"error": "Операция не поддерживается"})
|
||||
return
|
||||
self.respond(200, result)
|
||||
except AgentError as error:
|
||||
self.respond(409, {"error": str(error)})
|
||||
except Exception:
|
||||
self.respond(503, {"error": "Агент NetFlow не готов; проверьте конфигурацию и интерфейс хоста"})
|
||||
|
||||
def setup(self):
|
||||
super().setup()
|
||||
self.connection.settimeout(15)
|
||||
|
||||
def respond(self, code, body):
|
||||
data = json.dumps(body, ensure_ascii=False).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
do_GET = handle_request
|
||||
do_POST = handle_request
|
||||
|
||||
|
||||
class Server(socketserver.ThreadingMixIn, getattr(socketserver, "UnixStreamServer", socketserver.TCPServer)):
|
||||
daemon_threads = True
|
||||
|
||||
|
||||
class UnixConnection(http.client.HTTPConnection):
|
||||
def connect(self):
|
||||
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
self.sock.settimeout(3)
|
||||
self.sock.connect(SOCKET)
|
||||
|
||||
|
||||
def main():
|
||||
if not hasattr(socketserver, "UnixStreamServer"):
|
||||
raise SystemExit("NetFlow agent requires Linux Unix sockets")
|
||||
if "--healthcheck" in sys.argv:
|
||||
client = UnixConnection("localhost")
|
||||
client.request("GET", "/status")
|
||||
response = client.getresponse()
|
||||
sys.exit(0 if response.status == 200 and json.loads(response.read()).get("interfaceUp") else 1)
|
||||
os.umask(0o077)
|
||||
Path(SOCKET).parent.mkdir(parents=True, exist_ok=True)
|
||||
if os.path.lexists(SOCKET):
|
||||
Path(SOCKET).unlink()
|
||||
with Server(SOCKET, Handler) as server:
|
||||
os.chmod(SOCKET, 0o660)
|
||||
print("NetFlow agent: Unix API ready", flush=True)
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage: sudo bash install.sh /absolute/path/wg-flow.conf /opt/cdn-mm 4739
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
[[ ${EUID:-$(id -u)} -eq 0 ]] || { echo "Запустите от root" >&2; exit 1; }
|
||||
SOURCE=$(realpath "${1:?Укажите скачанный wg-flow.conf}")
|
||||
COMPOSE_DIR=$(realpath "${2:-/opt/cdn-mm}")
|
||||
FLOW_PORT=${3:-4739}
|
||||
[[ "$FLOW_PORT" =~ ^[0-9]+$ ]] && (( FLOW_PORT >= 1 && FLOW_PORT <= 65535 )) || exit 1
|
||||
command -v docker >/dev/null
|
||||
docker compose version >/dev/null
|
||||
systemctl show docker.service --property=LoadState --value | grep -qx loaded
|
||||
if ! command -v wg >/dev/null || ! command -v python3 >/dev/null || ! command -v modprobe >/dev/null; then
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y wireguard-tools python3 iproute2 kmod
|
||||
fi
|
||||
modprobe wireguard
|
||||
[[ -f "$COMPOSE_DIR/docker-compose.yml" ]] || { echo "Не найден docker-compose.yml" >&2; exit 1; }
|
||||
AGENT_IMAGE=${MM_NETFLOW_AGENT_IMAGE:-git.shx.one/denozord/mikrotikmanager-netflow-agent:latest}
|
||||
[[ "$AGENT_IMAGE" =~ ^[A-Za-z0-9][A-Za-z0-9._:/@-]+$ ]] || { echo "Некорректное имя образа" >&2; exit 1; }
|
||||
# The image must be available before changing the host configuration.
|
||||
docker pull "$AGENT_IMAGE"
|
||||
TASK_TEMP=$(mktemp -d)
|
||||
trap 'rm -rf -- "$TASK_TEMP"' EXIT
|
||||
|
||||
python3 - "$SOURCE" "$COMPOSE_DIR" "$FLOW_PORT" "$TASK_TEMP" <<'PY'
|
||||
import base64, ipaddress, json, os, pathlib, re, subprocess, sys
|
||||
source, directory, port, temp = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]), int(sys.argv[3]), pathlib.Path(sys.argv[4])
|
||||
def read(text):
|
||||
section, fields, peers = None, {}, []
|
||||
allowed = {'Interface': {'PrivateKey','Address','ListenPort','MTU'}, 'Peer': {'PublicKey','AllowedIPs','Endpoint','PersistentKeepalive','PresharedKey'}}
|
||||
for raw in text.splitlines():
|
||||
line = raw.split('#',1)[0].strip()
|
||||
if not line: continue
|
||||
if line == '[Interface]' and section is None: section, current = 'Interface', fields
|
||||
elif line == '[Peer]' and section is not None:
|
||||
section, current = 'Peer', {}; peers.append(current)
|
||||
else:
|
||||
key, sep, value = line.partition('='); key, value = key.strip(), value.strip()
|
||||
if not sep or section not in allowed or key not in allowed[section] or key in current or not value:
|
||||
raise SystemExit('Конфигурация содержит неподдерживаемые параметры; требуется ручная проверка')
|
||||
current[key] = value
|
||||
try:
|
||||
addr = ipaddress.IPv4Interface(fields['Address'])
|
||||
if addr.network.prefixlen != 24 or addr.ip in (addr.network.network_address, addr.network.broadcast_address): raise ValueError()
|
||||
if len(base64.b64decode(fields['PrivateKey'], validate=True)) != 32 or not 1 <= int(fields['ListenPort']) <= 65535: raise ValueError()
|
||||
if 'MTU' in fields and not 576 <= int(fields['MTU']) <= 9000: raise ValueError()
|
||||
for peer in peers:
|
||||
if len(base64.b64decode(peer['PublicKey'], validate=True)) != 32: raise ValueError()
|
||||
for network in peer['AllowedIPs'].split(','): ipaddress.ip_network(network.strip(), strict=False)
|
||||
if 'Endpoint' in peer and not re.fullmatch(r'(?:\[[0-9a-fA-F:]+\]|[a-zA-Z0-9._-]+):[0-9]{1,5}', peer['Endpoint']): raise ValueError()
|
||||
if 'PersistentKeepalive' in peer and not 0 <= int(peer['PersistentKeepalive']) <= 65535: raise ValueError()
|
||||
except (ValueError, KeyError): raise SystemExit('Некорректная конфигурация WireGuard; ключи не изменены')
|
||||
return fields, addr
|
||||
expected, addr = read(source.read_text())
|
||||
active = pathlib.Path('/etc/wireguard/wg-flow.conf')
|
||||
candidate = active.read_text() if active.exists() else source.read_text()
|
||||
actual, actual_addr = read(candidate)
|
||||
for key in ('PrivateKey','Address','ListenPort'):
|
||||
if actual[key] != expected[key]: raise SystemExit('Ключ, адрес или порт существующего туннеля отличаются от MM; установка остановлена')
|
||||
public = subprocess.run(['wg','pubkey'], input=actual['PrivateKey']+'\n',text=True,capture_output=True,check=True).stdout.strip()
|
||||
if subprocess.run(['ip','link','show','dev','wg-flow'],capture_output=True).returncode == 0:
|
||||
dump = subprocess.run(['wg','show','wg-flow','dump'],capture_output=True,text=True,check=True).stdout.splitlines()
|
||||
head = dump[0].split('\t')
|
||||
addresses = json.loads(subprocess.run(['ip','-j','-4','address','show','dev','wg-flow'],capture_output=True,text=True,check=True).stdout)
|
||||
if head[1] != public or int(head[2]) != int(actual['ListenPort']) or not any(a.get('local') == str(addr.ip) and a.get('prefixlen') == addr.network.prefixlen for row in addresses for a in row.get('addr_info',[])):
|
||||
raise SystemExit('Работающий wg-flow отличается от MM; установка остановлена')
|
||||
binding = dict(collectorIp=str(addr.ip), prefix=str(addr.network), flowListenPort=port, wgListenPort=int(actual['ListenPort']))
|
||||
(temp/'binding.json').write_text(json.dumps(binding))
|
||||
(temp/'wg-flow.conf').write_text(candidate)
|
||||
(temp/'collector').write_text(str(addr.ip))
|
||||
PY
|
||||
|
||||
COLLECTOR_IP=$(cat "$TASK_TEMP/collector")
|
||||
# Keep a dedicated override; never replace docker-compose.override.yml.
|
||||
cat > "$TASK_TEMP/compose.yml" <<EOF
|
||||
services:
|
||||
netflow-agent:
|
||||
image: "$AGENT_IMAGE"
|
||||
container_name: mmapp-netflow-agent
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
cap_drop: [ALL]
|
||||
cap_add: [NET_ADMIN]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
read_only: true
|
||||
tmpfs: ["/tmp:rw,noexec,nosuid,size=8m"]
|
||||
volumes:
|
||||
- ./data/netflow-wireguard:/etc/wireguard
|
||||
- ./data/netflow-agent-socket:/run/mm-netflow-host
|
||||
logging:
|
||||
driver: json-file
|
||||
options: {max-size: "5m", max-file: "2"}
|
||||
backend:
|
||||
environment:
|
||||
FLOW_AGENT_SOCKET: /run/mm-netflow-host/agent.sock
|
||||
FLOW_LISTEN_HOST: "0.0.0.0"
|
||||
FLOW_TEMPLATE_LIFETIME_MS: \${FLOW_TEMPLATE_LIFETIME_MS:-5400000}
|
||||
volumes:
|
||||
- ./data/netflow-agent-socket:/run/mm-netflow-host:ro
|
||||
ports:
|
||||
- "$COLLECTOR_IP:$FLOW_PORT:$FLOW_PORT/udp"
|
||||
depends_on:
|
||||
netflow-agent:
|
||||
condition: service_healthy
|
||||
EOF
|
||||
COMPOSE_ARGS=(-f "$COMPOSE_DIR/docker-compose.yml")
|
||||
if [[ -f "$COMPOSE_DIR/docker-compose.override.yml" ]]; then COMPOSE_ARGS+=(-f "$COMPOSE_DIR/docker-compose.override.yml"); fi
|
||||
# Render first, and reject an existing public/incorrect IPFIX binding before touching WireGuard.
|
||||
docker compose --project-directory "$COMPOSE_DIR" "${COMPOSE_ARGS[@]}" -f "$TASK_TEMP/compose.yml" config --format json > "$TASK_TEMP/merged.json"
|
||||
python3 - "$TASK_TEMP/merged.json" "$COLLECTOR_IP" "$FLOW_PORT" <<'PY'
|
||||
import json,sys
|
||||
config=json.load(open(sys.argv[1])); ip=sys.argv[2]; port=int(sys.argv[3])
|
||||
for bind in config['services']['backend'].get('ports',[]):
|
||||
if bind.get('protocol','tcp') == 'udp' and (bind['target'] in (4739,port) or str(bind.get('published')) == str(port)):
|
||||
if bind.get('host_ip') != ip or bind['target'] != port or str(bind.get('published')) != str(port):
|
||||
raise SystemExit('В Compose уже есть другой IPFIX bind. Согласуйте его с адресом и портом MM и повторите установку.')
|
||||
PY
|
||||
if docker container inspect mmapp-netflow-agent >/dev/null 2>&1; then docker stop mmapp-netflow-agent; fi
|
||||
install -d -m 700 /etc/wireguard "$COMPOSE_DIR/data/netflow-wireguard" "$COMPOSE_DIR/data/netflow-agent-socket"
|
||||
if [[ -e /etc/wireguard/wg-flow.conf ]]; then
|
||||
cp -L /etc/wireguard/wg-flow.conf "$COMPOSE_DIR/data/netflow-wireguard/wg-flow.conf.backup.$(date +%s)"
|
||||
fi
|
||||
install -m 600 "$TASK_TEMP/wg-flow.conf" "$COMPOSE_DIR/data/netflow-wireguard/wg-flow.conf"
|
||||
install -m 600 "$TASK_TEMP/binding.json" "$COMPOSE_DIR/data/netflow-wireguard/binding.json"
|
||||
ln -sfn "$COMPOSE_DIR/data/netflow-wireguard/wg-flow.conf" /etc/wireguard/wg-flow.conf
|
||||
install -m 600 "$TASK_TEMP/compose.yml" "$COMPOSE_DIR/docker-compose.netflow-agent.yml"
|
||||
install -d /etc/systemd/system/docker.service.d
|
||||
cat > /etc/systemd/system/docker.service.d/mm-netflow.conf <<'EOF'
|
||||
[Unit]
|
||||
Wants=wg-quick@wg-flow.service
|
||||
After=wg-quick@wg-flow.service
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
systemctl enable wg-quick@wg-flow.service
|
||||
if ! ip link show dev wg-flow >/dev/null 2>&1; then
|
||||
systemctl start wg-quick@wg-flow.service
|
||||
else
|
||||
ip link set dev wg-flow up
|
||||
fi
|
||||
cd "$COMPOSE_DIR"
|
||||
docker compose "${COMPOSE_ARGS[@]}" -f docker-compose.netflow-agent.yml up -d netflow-agent
|
||||
AGENT_READY=false
|
||||
for attempt in $(seq 1 60); do
|
||||
if [[ "$(docker inspect --format '{{.State.Health.Status}}' mmapp-netflow-agent)" == healthy ]]; then
|
||||
AGENT_READY=true
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
[[ "$AGENT_READY" == true ]] || { echo "Агент не готов. Проверьте docker logs mmapp-netflow-agent; backend не пересоздан." >&2; exit 1; }
|
||||
docker compose "${COMPOSE_ARGS[@]}" -f docker-compose.netflow-agent.yml up -d --no-deps --force-recreate backend
|
||||
echo "Агент установлен. Используйте те же -f файлы при дальнейших операциях Compose:"
|
||||
printf 'docker compose '; printf '%q ' "${COMPOSE_ARGS[@]}" -f "$COMPOSE_DIR/docker-compose.netflow-agent.yml"; echo 'up -d'
|
||||
echo "В MM обновите статус хоста и нажмите «Применить настройки хоста»."
|
||||
@@ -0,0 +1,201 @@
|
||||
import base64
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
import shutil
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import agent
|
||||
|
||||
|
||||
def key(n):
|
||||
return base64.b64encode(bytes([n]) * 32).decode()
|
||||
|
||||
|
||||
class ControllerTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# Windows Python 3.13's TemporaryDirectory mode 0700 excludes the restricted sandbox token.
|
||||
base = Path.cwd() / "tmp" if os.name == "nt" else Path(tempfile.gettempdir())
|
||||
base.mkdir(exist_ok=True)
|
||||
self.root = base / ("flow-agent-test-" + uuid.uuid4().hex)
|
||||
self.root.mkdir()
|
||||
def cleanup():
|
||||
if self.root.resolve().parent != base.resolve():
|
||||
raise RuntimeError("Unexpected test directory")
|
||||
shutil.rmtree(self.root)
|
||||
self.addCleanup(cleanup)
|
||||
self.addCleanup(patch.stopall)
|
||||
patch.object(agent, "CONFIG", self.root).start()
|
||||
self.interface = dict(PrivateKey=key(1), Address="10.255.254.1/24", ListenPort="51821", MTU="1420")
|
||||
self.third = dict(PublicKey=key(3), AllowedIPs="192.0.2.5/32", PresharedKey=key(9), PersistentKeepalive="0")
|
||||
self.desired = dict(collectorIp="10.255.254.1", prefix="10.255.254.0/24", flowListenPort=4739,
|
||||
wgListenPort=51821, hostPublicKey=key(2), hostPrivateKey=key(1),
|
||||
peers=[dict(serverId=1, publicKey=key(4), allowedIps=["10.255.254.2/32"], endpoint="router.test:13232")])
|
||||
(self.root / "binding.json").write_text(json.dumps({k: self.desired[k] for k in ("collectorIp", "prefix", "flowListenPort", "wgListenPort")}))
|
||||
self.previous = agent.render(self.interface, [self.third])
|
||||
(self.root / "wg-flow.conf").write_text(self.previous)
|
||||
self.runtime = [copy.deepcopy(self.third)]
|
||||
self.calls = []
|
||||
def run(*args, input=None):
|
||||
self.calls.append(args)
|
||||
if args == ("wg", "pubkey"):
|
||||
return key(2) if input == key(1) + "\n" else key(8)
|
||||
if args == ("wg", "showconf", "wg-flow"):
|
||||
return agent.render(self.interface, self.runtime, native=True)
|
||||
raise AssertionError(args)
|
||||
patch.object(agent, "run", run).start()
|
||||
patch.object(agent, "live", self.live).start()
|
||||
patch.object(agent, "sync", self.sync).start()
|
||||
self.synced = []
|
||||
|
||||
def live(self):
|
||||
return dict(interfaceUp=True, publicKey=key(2), listenPort=51821, addresses=["10.255.254.1/24"], peers=[]), copy.deepcopy(self.runtime)
|
||||
|
||||
def sync(self, text):
|
||||
self.synced.append(text)
|
||||
# Native configs don't include wg-quick's Address; simulate applying their peers.
|
||||
_, self.runtime = agent.parse_conf(text.replace("ListenPort = 51821", "ListenPort = 51821\nAddress = 10.255.254.1/24"))
|
||||
|
||||
def test_apply_preserves_third_party_psk_and_is_idempotent(self):
|
||||
self.assertFalse(agent.check(self.desired)["configMatches"])
|
||||
self.assertTrue(agent.check(self.desired, apply=True)["configMatches"])
|
||||
text = (self.root / "wg-flow.conf").read_text()
|
||||
self.assertIn(key(3), text)
|
||||
self.assertIn(key(9), text)
|
||||
self.assertIn(key(4), text)
|
||||
self.assertNotIn("Address", self.synced[0])
|
||||
self.assertTrue(agent.check(self.desired)["configMatches"])
|
||||
agent.check(self.desired, apply=True)
|
||||
self.assertEqual((self.root / "wg-flow.conf").read_text(), text)
|
||||
if os.name != "nt":
|
||||
self.assertEqual((self.root / "wg-flow.conf").stat().st_mode & 0o777, 0o600)
|
||||
|
||||
def test_runtime_only_peer_preserved(self):
|
||||
self.runtime.append(dict(PublicKey=key(6), AllowedIPs="198.51.100.7/32", PersistentKeepalive="0"))
|
||||
agent.check(self.desired, apply=True)
|
||||
self.assertIn(key(6), (self.root / "wg-flow.conf").read_text())
|
||||
|
||||
def test_runtime_peer_without_allowed_ips_preserved(self):
|
||||
self.runtime.append(dict(PublicKey=key(6), AllowedIPs="", PersistentKeepalive="0"))
|
||||
agent.check(self.desired, apply=True)
|
||||
self.assertTrue(agent.check(self.desired)["configMatches"])
|
||||
|
||||
def test_third_party_runtime_settings_are_not_reset(self):
|
||||
# Runtime may contain a roaming endpoint and intentionally unsaved settings.
|
||||
self.runtime[0].update(Endpoint="198.51.100.2:50000", PersistentKeepalive="10")
|
||||
agent.check(self.desired, apply=True)
|
||||
third = next(p for p in self.runtime if p["PublicKey"] == key(3))
|
||||
self.assertEqual(third["Endpoint"], "198.51.100.2:50000")
|
||||
self.assertEqual(third["PersistentKeepalive"], "10")
|
||||
_, stored = agent.parse_conf((self.root / "wg-flow.conf").read_text())
|
||||
self.assertEqual(next(p for p in stored if p["PublicKey"] == key(3))["PersistentKeepalive"], "0")
|
||||
|
||||
def test_managed_roaming_endpoint_preserved_without_changing_desired_dns(self):
|
||||
agent.check(self.desired, apply=True)
|
||||
next(p for p in self.runtime if p["PublicKey"] == key(4))["Endpoint"] = "198.51.100.2:50000"
|
||||
agent.check(self.desired, apply=True)
|
||||
_, stored = agent.parse_conf((self.root / "wg-flow.conf").read_text())
|
||||
self.assertEqual(next(p for p in stored if p["PublicKey"] == key(4))["Endpoint"], "router.test:13232")
|
||||
self.assertEqual(next(p for p in self.runtime if p["PublicKey"] == key(4))["Endpoint"], "198.51.100.2:50000")
|
||||
self.assertTrue(agent.check(self.desired)["configMatches"])
|
||||
|
||||
def test_down_interface_cannot_report_applied(self):
|
||||
status, peers = self.live()
|
||||
with patch.object(agent, "live", return_value=({**status, "interfaceUp": False}, peers)):
|
||||
with self.assertRaisesRegex(agent.AgentError, "выключен"):
|
||||
agent.check(self.desired, apply=True)
|
||||
self.assertEqual(self.synced, [])
|
||||
|
||||
def test_overlap_rejected_before_any_write(self):
|
||||
self.runtime.append(dict(PublicKey=key(6), AllowedIPs="10.255.254.0/24"))
|
||||
with self.assertRaisesRegex(agent.AgentError, "пересекаются"):
|
||||
agent.check(self.desired, apply=True)
|
||||
self.assertEqual((self.root / "wg-flow.conf").read_text(), self.previous)
|
||||
self.assertEqual(self.synced, [])
|
||||
|
||||
def test_different_interface_identity_rejected(self):
|
||||
self.desired["hostPrivateKey"], self.desired["hostPublicKey"] = key(7), key(8)
|
||||
with self.assertRaisesRegex(agent.AgentError, "существующего"):
|
||||
agent.check(self.desired, apply=True)
|
||||
self.assertEqual(self.synced, [])
|
||||
|
||||
def test_failed_apply_restores_file_and_runtime(self):
|
||||
rollback = agent.render(self.interface, self.runtime, native=True)
|
||||
with patch.object(agent, "sync", side_effect=[agent.AgentError("secret error"), None]) as sync:
|
||||
with self.assertRaisesRegex(agent.AgentError, "предыдущая конфигурация восстановлена"):
|
||||
agent.check(self.desired, apply=True)
|
||||
self.assertEqual(sync.call_args_list[-1].args, (rollback,))
|
||||
self.assertEqual((self.root / "wg-flow.conf").read_text(), self.previous)
|
||||
|
||||
def test_failed_rollback_is_explicit(self):
|
||||
with patch.object(agent, "sync", side_effect=agent.AgentError("secret error")):
|
||||
with self.assertRaisesRegex(agent.AgentError, "восстановление требует ручной проверки"):
|
||||
agent.check(self.desired, apply=True)
|
||||
|
||||
def test_invalid_or_shell_input_rejected(self):
|
||||
for change in [dict(flowListenPort=4740), dict(prefix="10.255.254.0/28"), dict(hostPrivateKey="$(id)"), dict(command="id"), dict(peers=[dict(serverId=1, publicKey=key(4), allowedIps=["0.0.0.0/0"])]), dict(peers=[dict(serverId=1, publicKey=key(4), allowedIps=["10.255.254.2/32"], endpoint="router.test:13232\nPostUp = id")])]:
|
||||
with self.subTest(change=list(change)):
|
||||
with self.assertRaises(agent.AgentError):
|
||||
agent.check({**self.desired, **change}, apply=True)
|
||||
self.assertEqual(self.synced, [])
|
||||
|
||||
def test_hooks_and_duplicate_fields_rejected(self):
|
||||
for suffix in ("\nPostUp = id", "\nListenPort = 3", "\n[Interface]\nPrivateKey = secret"):
|
||||
with self.assertRaises(agent.AgentError):
|
||||
agent.parse_conf(agent.render(self.interface, []) + suffix)
|
||||
|
||||
def test_endpoint_change_requires_apply(self):
|
||||
agent.check(self.desired, apply=True)
|
||||
self.desired["peers"][0]["endpoint"] = "new-router.test:13232"
|
||||
self.assertFalse(agent.check(self.desired)["configMatches"])
|
||||
|
||||
def test_command_errors_do_not_expose_keys(self):
|
||||
with patch.object(agent.subprocess, "run", return_value=type("Result", (), {"returncode": 1, "stdout": key(1), "stderr": key(9)})()):
|
||||
# Use the original function, not the fixture runner.
|
||||
with self.assertRaisesRegex(agent.AgentError, "не выполнена") as error:
|
||||
ORIGINAL_RUN("wg", "show")
|
||||
self.assertNotIn(key(1), str(error.exception))
|
||||
self.assertNotIn(key(9), str(error.exception))
|
||||
|
||||
|
||||
ORIGINAL_RUN = agent.run
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get("FLOW_AGENT_KERNEL_TEST") == "1", "Requires Linux kernel WireGuard and NET_ADMIN in an isolated container")
|
||||
class KernelTest(unittest.TestCase):
|
||||
def test_real_interface_apply_and_preservation(self):
|
||||
with tempfile.TemporaryDirectory() as folder, patch.object(agent, "CONFIG", Path(folder)):
|
||||
private = agent.run("wg", "genkey")
|
||||
public = agent.run("wg", "pubkey", input=private + "\n")
|
||||
other = agent.run("wg", "pubkey", input=agent.run("wg", "genkey") + "\n")
|
||||
desired_key = agent.run("wg", "pubkey", input=agent.run("wg", "genkey") + "\n")
|
||||
interface = dict(PrivateKey=private, Address="10.255.254.1/24", ListenPort="51821")
|
||||
third = dict(PublicKey=other, AllowedIPs="192.0.2.3/32")
|
||||
path = Path(folder)
|
||||
(path / "wg-flow.conf").write_text(agent.render(interface, [third]))
|
||||
snapshot = dict(collectorIp="10.255.254.1", prefix="10.255.254.0/24", flowListenPort=4739, wgListenPort=51821,
|
||||
hostPublicKey=public, hostPrivateKey=private, peers=[dict(serverId=1, publicKey=desired_key, allowedIps=["10.255.254.2/32"], endpoint="127.0.0.1:13232")])
|
||||
(path / "binding.json").write_text(json.dumps({k: snapshot[k] for k in ("collectorIp", "prefix", "flowListenPort", "wgListenPort")}))
|
||||
agent.run("ip", "link", "add", "dev", agent.IFACE, "type", "wireguard")
|
||||
try:
|
||||
agent.run("ip", "address", "add", "10.255.254.1/24", "dev", agent.IFACE)
|
||||
agent.sync(agent.render(interface, [third], native=True))
|
||||
agent.run("ip", "link", "set", "dev", agent.IFACE, "up")
|
||||
before = json.loads(agent.run("ip", "-j", "link", "show", "dev", agent.IFACE))[0]["ifindex"]
|
||||
self.assertTrue(agent.check(snapshot, apply=True)["configMatches"])
|
||||
self.assertTrue(agent.check(snapshot)["configMatches"])
|
||||
status, _ = agent.live()
|
||||
self.assertEqual({p["publicKey"] for p in status["peers"]}, {other, desired_key})
|
||||
self.assertNotIn(private, json.dumps(status))
|
||||
after = json.loads(agent.run("ip", "-j", "link", "show", "dev", agent.IFACE))[0]["ifindex"]
|
||||
self.assertEqual(before, after)
|
||||
finally:
|
||||
agent.run("ip", "link", "delete", "dev", agent.IFACE)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,23 @@
|
||||
# MikrotikManager — PostgreSQL 18 runtime (overrides via `postgres -c`).
|
||||
# Не подменять целиком config_file образа: эти GUC передаются как -c в compose.
|
||||
|
||||
timezone = UTC
|
||||
listen_addresses = '*'
|
||||
max_connections = 100
|
||||
shared_buffers = 256MB
|
||||
work_mem = 16MB
|
||||
maintenance_work_mem = 128MB
|
||||
|
||||
# PG18 AIO: worker (io_uring в Docker/Alpine часто недоступен)
|
||||
io_method = worker
|
||||
io_workers = 3
|
||||
effective_io_concurrency = 200
|
||||
|
||||
# TOAST: lz4 (не zstd — для колонок только pglz/lz4)
|
||||
default_toast_compression = lz4
|
||||
wal_compression = lz4
|
||||
|
||||
# Реже checkpoint / меньше full-page writes (NetFlow upsert)
|
||||
max_wal_size = 2GB
|
||||
checkpoint_timeout = 15min
|
||||
checkpoint_completion_target = 0.9
|
||||
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run MikrotikManager beside an already-running CDNManager Traefik stack.
|
||||
# Does NOT start a second Traefik — attaches frontend to network `cdnmanager`.
|
||||
#
|
||||
# Usage (on the VPS):
|
||||
# curl -fsSL -o /tmp/run-beside-cdn-traefik.sh \
|
||||
# https://git.shx.one/denozord/MikrotikManager/raw/branch/main/deploy/run-beside-cdn-traefik.sh
|
||||
# chmod +x /tmp/run-beside-cdn-traefik.sh
|
||||
# sudo MM_DOMAIN=mm.shnt.top /tmp/run-beside-cdn-traefik.sh
|
||||
#
|
||||
# Or copy this file to the server and run it.
|
||||
#
|
||||
# Env overrides:
|
||||
# MM_DOMAIN=mm.shnt.top
|
||||
# CORS_ORIGIN=https://mm.shnt.top
|
||||
# MM_ROOT=/opt/mmapp
|
||||
# MM_BACKEND_IMAGE_TAG=latest
|
||||
# MM_FRONTEND_IMAGE_TAG=latest
|
||||
# MM_UPDATER_IMAGE_TAG=latest
|
||||
# REGISTRY_USERNAME=… REGISTRY_PASSWORD=… # optional private pull
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MM_DOMAIN="${MM_DOMAIN:-mm.shnt.top}"
|
||||
CORS_ORIGIN="${CORS_ORIGIN:-https://${MM_DOMAIN}}"
|
||||
MM_ROOT="${MM_ROOT:-/opt/mmapp}"
|
||||
MM_BACKEND_IMAGE_TAG="${MM_BACKEND_IMAGE_TAG:-latest}"
|
||||
MM_FRONTEND_IMAGE_TAG="${MM_FRONTEND_IMAGE_TAG:-latest}"
|
||||
MM_UPDATER_IMAGE_TAG="${MM_UPDATER_IMAGE_TAG:-latest}"
|
||||
REGISTRY="${REGISTRY:-git.shx.one}"
|
||||
|
||||
BACKEND_IMAGE="${REGISTRY}/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG}"
|
||||
FRONTEND_IMAGE="${REGISTRY}/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG}"
|
||||
UPDATER_IMAGE="${REGISTRY}/denozord/mikrotikmanager-updater:${MM_UPDATER_IMAGE_TAG}"
|
||||
|
||||
echo "==> Check CDNManager Traefik network"
|
||||
if ! docker network inspect cdnmanager >/dev/null 2>&1; then
|
||||
echo "ERROR: Docker network 'cdnmanager' not found." >&2
|
||||
echo "Start CDNManager Traefik stack first (deploy/docker-compose.traefik.yml)." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! docker inspect cdnmanager-traefik >/dev/null 2>&1; then
|
||||
echo "WARN: container 'cdnmanager-traefik' not found — labels may not be routed." >&2
|
||||
fi
|
||||
|
||||
echo "==> Prepare dirs under ${MM_ROOT}"
|
||||
mkdir -p "${MM_ROOT}/data" "${MM_ROOT}/state" "${MM_ROOT}/updater"
|
||||
|
||||
TARGETS="${MM_ROOT}/updater/targets.json"
|
||||
if [[ ! -f "${TARGETS}" ]]; then
|
||||
cat >"${TARGETS}" <<'EOF'
|
||||
{
|
||||
"targets": [
|
||||
{
|
||||
"id": "backend",
|
||||
"container_name": "mmapp-backend",
|
||||
"image": "git.shx.one/denozord/mikrotikmanager-backend:latest",
|
||||
"health": {
|
||||
"type": "http",
|
||||
"url": "http://backend:8000/health",
|
||||
"expect_status": 200
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "frontend",
|
||||
"container_name": "mmapp-frontend",
|
||||
"image": "git.shx.one/denozord/mikrotikmanager-frontend:latest",
|
||||
"health": {
|
||||
"type": "http",
|
||||
"url": "http://frontend:3000/dashboard",
|
||||
"expect_status": 200
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
echo " wrote ${TARGETS}"
|
||||
fi
|
||||
|
||||
echo "==> Ensure internal network mmapp"
|
||||
docker network inspect mmapp >/dev/null 2>&1 || docker network create mmapp >/dev/null
|
||||
|
||||
: "${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}"
|
||||
|
||||
echo "==> Ensure mmapp-postgres"
|
||||
if ! docker inspect mmapp-postgres >/dev/null 2>&1; then
|
||||
docker volume inspect mmapp-pgdata >/dev/null 2>&1 || docker volume create mmapp-pgdata >/dev/null
|
||||
docker run -d \
|
||||
--name mmapp-postgres \
|
||||
--restart unless-stopped \
|
||||
--network mmapp \
|
||||
--network-alias postgres \
|
||||
--shm-size 256mb \
|
||||
-e POSTGRES_USER=mmapp \
|
||||
-e POSTGRES_DB=mmapp \
|
||||
-e POSTGRES_PASSWORD="${POSTGRES_PASSWORD}" \
|
||||
-e POSTGRES_INITDB_ARGS="--encoding=UTF8 --locale=C.UTF-8 --data-checksums" \
|
||||
-e TZ=UTC \
|
||||
-e PGTZ=UTC \
|
||||
-v mmapp-pgdata:/var/lib/postgresql \
|
||||
--health-cmd="pg_isready -U mmapp -d mmapp" \
|
||||
--health-interval=5s \
|
||||
--health-timeout=5s \
|
||||
--health-retries=10 \
|
||||
postgres:18-alpine \
|
||||
postgres -c timezone=UTC -c listen_addresses=* -c max_connections=100 \
|
||||
-c shared_buffers=256MB -c work_mem=16MB -c maintenance_work_mem=128MB \
|
||||
-c wal_compression=lz4 -c default_toast_compression=lz4 \
|
||||
-c io_method=worker -c io_workers=3 -c effective_io_concurrency=200
|
||||
fi
|
||||
until docker exec mmapp-postgres pg_isready -U mmapp -d mmapp >/dev/null 2>&1; do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [[ -n "${REGISTRY_USERNAME:-}" && -n "${REGISTRY_PASSWORD:-}" ]]; then
|
||||
echo "==> docker login ${REGISTRY}"
|
||||
echo "${REGISTRY_PASSWORD}" | docker login "${REGISTRY}" -u "${REGISTRY_USERNAME}" --password-stdin
|
||||
fi
|
||||
|
||||
echo "==> Pull images"
|
||||
docker pull "${BACKEND_IMAGE}"
|
||||
docker pull "${FRONTEND_IMAGE}"
|
||||
docker pull "${UPDATER_IMAGE}"
|
||||
|
||||
stop_rm() {
|
||||
local name="$1"
|
||||
if docker inspect "${name}" >/dev/null 2>&1; then
|
||||
docker stop "${name}" >/dev/null || true
|
||||
docker rm "${name}" >/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
echo "==> Recreate mmapp-backend"
|
||||
stop_rm mmapp-backend
|
||||
docker run -d \
|
||||
--name mmapp-backend \
|
||||
--restart unless-stopped \
|
||||
--network mmapp \
|
||||
--network-alias backend \
|
||||
-e NODE_ENV=production \
|
||||
-e PORT=8000 \
|
||||
-e DATABASE_URL="postgres://mmapp:${POSTGRES_PASSWORD}@mmapp-postgres:5432/mmapp" \
|
||||
-e DATABASE_PATH=/app/data/mikrotik.db \
|
||||
-e "CORS_ORIGIN=${CORS_ORIGIN}" \
|
||||
-e "AUTH_REQUIRED=${AUTH_REQUIRED:-true}" \
|
||||
-e "AUTH_JWT_SECRET=${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET}" \
|
||||
-e "AUTH_ISSUER=${AUTH_ISSUER:-https://auth.shnt.top}" \
|
||||
-e "AUTH_PORTAL_URL=${AUTH_PORTAL_URL:-https://auth.shnt.top}" \
|
||||
-v "${MM_ROOT}/data:/app/data" \
|
||||
--label mmapp.updater.managed=true \
|
||||
--label mmapp.updater.target=backend \
|
||||
--label "mmapp.updater.image=${BACKEND_IMAGE}" \
|
||||
--health-cmd="node -e \"fetch('http://127.0.0.1:8000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"" \
|
||||
--health-interval=30s \
|
||||
--health-timeout=5s \
|
||||
--health-retries=3 \
|
||||
--health-start-period=300s \
|
||||
--log-driver json-file \
|
||||
--log-opt max-size=10m \
|
||||
--log-opt max-file=3 \
|
||||
"${BACKEND_IMAGE}"
|
||||
|
||||
echo "==> Recreate mmapp-frontend (mmapp + cdnmanager)"
|
||||
stop_rm mmapp-frontend
|
||||
docker run -d \
|
||||
--name mmapp-frontend \
|
||||
--restart unless-stopped \
|
||||
--network mmapp \
|
||||
--network-alias frontend \
|
||||
-e BACKEND_INTERNAL_URL=http://backend:8000 \
|
||||
--label mmapp.updater.managed=true \
|
||||
--label mmapp.updater.target=frontend \
|
||||
--label "mmapp.updater.image=${FRONTEND_IMAGE}" \
|
||||
--label traefik.enable=true \
|
||||
--label traefik.docker.network=cdnmanager \
|
||||
--label "traefik.http.routers.mmapp.rule=Host(\`${MM_DOMAIN}\`)" \
|
||||
--label traefik.http.routers.mmapp.entrypoints=websecure \
|
||||
--label traefik.http.routers.mmapp.tls=true \
|
||||
--label traefik.http.routers.mmapp.tls.certresolver=letsencrypt \
|
||||
--label traefik.http.services.mmapp.loadbalancer.server.port=3000 \
|
||||
--health-cmd="node -e \"fetch('http://127.0.0.1:3000/dashboard').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"" \
|
||||
--health-interval=30s \
|
||||
--health-timeout=5s \
|
||||
--health-retries=3 \
|
||||
--health-start-period=25s \
|
||||
--log-driver json-file \
|
||||
--log-opt max-size=10m \
|
||||
--log-opt max-file=3 \
|
||||
"${FRONTEND_IMAGE}"
|
||||
|
||||
docker network connect cdnmanager mmapp-frontend
|
||||
|
||||
echo "==> Recreate mmapp-updater"
|
||||
stop_rm mmapp-updater
|
||||
docker run -d \
|
||||
--name mmapp-updater \
|
||||
--restart unless-stopped \
|
||||
--network mmapp \
|
||||
-e "REGISTRY=${REGISTRY}" \
|
||||
-e "REGISTRY_USERNAME=${REGISTRY_USERNAME:-}" \
|
||||
-e "REGISTRY_PASSWORD=${REGISTRY_PASSWORD:-}" \
|
||||
-e "POLL_INTERVAL_SECONDS=${POLL_INTERVAL_SECONDS:-300}" \
|
||||
-e "HEALTH_TIMEOUT_SECONDS=${HEALTH_TIMEOUT_SECONDS:-120}" \
|
||||
-e "STOP_TIMEOUT_SECONDS=${STOP_TIMEOUT_SECONDS:-30}" \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v "${MM_ROOT}/state:/state" \
|
||||
-v "${TARGETS}:/etc/updater/targets.json:ro" \
|
||||
--log-driver json-file \
|
||||
--log-opt max-size=10m \
|
||||
--log-opt max-file=3 \
|
||||
"${UPDATER_IMAGE}"
|
||||
|
||||
echo
|
||||
echo "OK. UI: https://${MM_DOMAIN}"
|
||||
echo "DNS: A/AAAA for ${MM_DOMAIN} → this VPS, Cloudflare proxy OFF (DNS only)."
|
||||
echo "Check: curl -fsS https://${MM_DOMAIN}/health"
|
||||
echo " docker ps --filter name=mmapp-"
|
||||
echo " docker network inspect cdnmanager --format '{{range .Containers}}{{.Name}} {{end}}'"
|
||||
@@ -0,0 +1,23 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM docker:27-cli
|
||||
|
||||
RUN apk add --no-cache bash curl jq coreutils
|
||||
|
||||
WORKDIR /updater
|
||||
|
||||
COPY entrypoint.sh /updater/entrypoint.sh
|
||||
COPY targets.json.example /etc/updater/targets.json
|
||||
|
||||
RUN chmod +x /updater/entrypoint.sh
|
||||
|
||||
ENV TARGETS_FILE=/etc/updater/targets.json
|
||||
ENV STATE_FILE=/state/updater-state.json
|
||||
ENV POLL_INTERVAL_SECONDS=300
|
||||
ENV HEALTH_TIMEOUT_SECONDS=600
|
||||
ENV STOP_TIMEOUT_SECONDS=30
|
||||
ENV REGISTRY=git.shx.one
|
||||
|
||||
VOLUME ["/state"]
|
||||
|
||||
ENTRYPOINT ["/updater/entrypoint.sh"]
|
||||
@@ -0,0 +1,524 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
TARGETS_FILE="${TARGETS_FILE:-/etc/updater/targets.json}"
|
||||
STATE_FILE="${STATE_FILE:-/state/updater-state.json}"
|
||||
POLL_INTERVAL_SECONDS="${POLL_INTERVAL_SECONDS:-300}"
|
||||
HEALTH_TIMEOUT_SECONDS="${HEALTH_TIMEOUT_SECONDS:-600}"
|
||||
STOP_TIMEOUT_SECONDS="${STOP_TIMEOUT_SECONDS:-30}"
|
||||
REGISTRY="${REGISTRY:-git.shx.one}"
|
||||
MANAGED_LABEL="mmapp.updater.managed"
|
||||
TARGET_LABEL="mmapp.updater.target"
|
||||
IMAGE_LABEL="mmapp.updater.image"
|
||||
|
||||
log() {
|
||||
local level="$1"
|
||||
shift
|
||||
printf '%s level=%s %s\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" "$level" "$*"
|
||||
}
|
||||
|
||||
require_command() {
|
||||
command -v "$1" >/dev/null 2>&1 || {
|
||||
log error "missing_command=$1"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
registry_login() {
|
||||
if [[ -n "${REGISTRY_USERNAME:-}" && -n "${REGISTRY_PASSWORD:-}" ]]; then
|
||||
log info "action=registry_login registry=${REGISTRY}"
|
||||
printf '%s' "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USERNAME" --password-stdin >/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
validate_targets_file() {
|
||||
if [[ ! -f "$TARGETS_FILE" ]]; then
|
||||
log error "targets_file_missing path=${TARGETS_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
jq -e '.targets | type == "array" and length > 0' "$TARGETS_FILE" >/dev/null
|
||||
}
|
||||
|
||||
state_init() {
|
||||
mkdir -p "$(dirname "$STATE_FILE")"
|
||||
if [[ ! -f "$STATE_FILE" ]]; then
|
||||
printf '{}\n' >"$STATE_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
state_get() {
|
||||
local target_id="$1"
|
||||
local field="$2"
|
||||
jq -r --arg id "$target_id" --arg field "$field" '.[$id][$field] // empty' "$STATE_FILE"
|
||||
}
|
||||
|
||||
state_set() {
|
||||
local target_id="$1"
|
||||
local field="$2"
|
||||
local value="$3"
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
jq --arg id "$target_id" --arg field "$field" --arg value "$value" \
|
||||
'.[$id] = (.[$id] // {}) | .[$id][$field] = $value' "$STATE_FILE" >"$tmp"
|
||||
mv "$tmp" "$STATE_FILE"
|
||||
}
|
||||
|
||||
state_clear_error() {
|
||||
local target_id="$1"
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
jq --arg id "$target_id" '.[$id].last_error = null' "$STATE_FILE" >"$tmp"
|
||||
mv "$tmp" "$STATE_FILE"
|
||||
}
|
||||
|
||||
state_set_error() {
|
||||
local target_id="$1"
|
||||
local message="$2"
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
jq --arg id "$target_id" --arg message "$message" '.[$id].last_error = $message' "$STATE_FILE" >"$tmp"
|
||||
mv "$tmp" "$STATE_FILE"
|
||||
}
|
||||
|
||||
cleanup_legacy_lock_dirs() {
|
||||
local locks_dir="/state/locks"
|
||||
[[ -d "$locks_dir" ]] || return 0
|
||||
find "$locks_dir" -mindepth 1 -maxdepth 1 -type d -exec rm -rf {} + 2>/dev/null || true
|
||||
}
|
||||
|
||||
with_target_lock() {
|
||||
local target_id="$1"
|
||||
shift
|
||||
local lock_file="/state/locks/${target_id}.lock"
|
||||
mkdir -p "$(dirname "$lock_file")"
|
||||
|
||||
if [[ -f "$lock_file" ]]; then
|
||||
local lock_pid=""
|
||||
lock_pid="$(tr -d '[:space:]' <"$lock_file" 2>/dev/null || true)"
|
||||
if [[ -n "$lock_pid" ]] && kill -0 "$lock_pid" 2>/dev/null; then
|
||||
log warn "target=${target_id} action=skip reason=lock_busy pid=${lock_pid}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log warn "target=${target_id} action=stale_lock_removed previous_pid=${lock_pid:-unknown}"
|
||||
rm -f "$lock_file"
|
||||
fi
|
||||
|
||||
printf '%s\n' "$$" >"$lock_file"
|
||||
trap 'rm -f "'"$lock_file"'"' RETURN
|
||||
"$@"
|
||||
}
|
||||
|
||||
remote_digest() {
|
||||
local image="$1"
|
||||
docker buildx imagetools inspect "$image" --format '{{json .Manifest}}' 2>/dev/null | jq -er '.digest // empty'
|
||||
}
|
||||
|
||||
running_image_digest() {
|
||||
local container_name="$1"
|
||||
local image_id
|
||||
image_id="$(docker inspect --format '{{.Image}}' "$container_name" 2>/dev/null || true)"
|
||||
if [[ -z "$image_id" ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
local repo_digest
|
||||
repo_digest="$(docker image inspect "$image_id" --format '{{index .RepoDigests 0}}' 2>/dev/null || true)"
|
||||
if [[ -n "$repo_digest" && "$repo_digest" == *@* ]]; then
|
||||
printf '%s' "${repo_digest#*@}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
docker image inspect "$image_id" --format '{{.Id}}'
|
||||
}
|
||||
|
||||
validate_managed_container() {
|
||||
local target_id="$1"
|
||||
local container_name="$2"
|
||||
local expected_image="$3"
|
||||
|
||||
if ! docker inspect "$container_name" >/dev/null 2>&1; then
|
||||
log warn "target=${target_id} action=skip reason=container_missing name=${container_name}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local managed target_label image_label
|
||||
managed="$(docker inspect --format "{{ index .Config.Labels \"${MANAGED_LABEL}\" }}" "$container_name")"
|
||||
target_label="$(docker inspect --format "{{ index .Config.Labels \"${TARGET_LABEL}\" }}" "$container_name")"
|
||||
image_label="$(docker inspect --format "{{ index .Config.Labels \"${IMAGE_LABEL}\" }}" "$container_name")"
|
||||
|
||||
if [[ "$managed" != "true" ]]; then
|
||||
log warn "target=${target_id} action=skip reason=not_managed name=${container_name}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -n "$target_label" && "$target_label" != "$target_id" ]]; then
|
||||
log warn "target=${target_id} action=skip reason=target_label_mismatch label=${target_label}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -n "$image_label" && "$image_label" != "$expected_image" ]]; then
|
||||
log warn "target=${target_id} action=skip reason=image_label_mismatch label=${image_label}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
snapshot_container() {
|
||||
local container_name="$1"
|
||||
local snapshot_file="$2"
|
||||
docker inspect "$container_name" | jq '.[0]' >"$snapshot_file"
|
||||
}
|
||||
|
||||
image_with_digest() {
|
||||
local image="$1"
|
||||
local digest="$2"
|
||||
local base="${image%@*}"
|
||||
|
||||
if docker image inspect "$digest" >/dev/null 2>&1; then
|
||||
printf '%s' "$digest"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local repo="$base"
|
||||
if [[ "$base" == *:* ]]; then
|
||||
repo="${base%:*}"
|
||||
fi
|
||||
|
||||
local ref="${repo}@${digest}"
|
||||
if docker image inspect "$ref" >/dev/null 2>&1; then
|
||||
printf '%s' "$ref"
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf '%s' "$ref"
|
||||
}
|
||||
|
||||
resolve_image_ref() {
|
||||
local image="$1"
|
||||
local digest="$2"
|
||||
local ref
|
||||
ref="$(image_with_digest "$image" "$digest")"
|
||||
|
||||
if docker image inspect "$ref" >/dev/null 2>&1; then
|
||||
printf '%s' "$ref"
|
||||
return 0
|
||||
fi
|
||||
|
||||
docker pull "$ref" >/dev/null
|
||||
printf '%s' "$ref"
|
||||
}
|
||||
|
||||
build_run_args_from_snapshot() {
|
||||
local snapshot_file="$1"
|
||||
local args=()
|
||||
|
||||
while IFS= read -r env_line; do
|
||||
args+=(--env "$env_line")
|
||||
done < <(jq -r '.Config.Env[]?' "$snapshot_file")
|
||||
|
||||
while IFS= read -r mount_line; do
|
||||
args+=(--mount "$mount_line")
|
||||
done < <(jq -r '
|
||||
.Mounts[]?
|
||||
| if .Type == "bind" then
|
||||
"type=bind,source=\(.Source),target=\(.Destination)\(if .RW == false then ",readonly" else "" end)"
|
||||
elif .Type == "volume" then
|
||||
"type=volume,source=\(.Name),target=\(.Destination)"
|
||||
else empty end
|
||||
' "$snapshot_file")
|
||||
|
||||
while IFS= read -r publish_line; do
|
||||
args+=(-p "$publish_line")
|
||||
done < <(jq -r '
|
||||
.HostConfig.PortBindings // {}
|
||||
| to_entries[]
|
||||
| .key as $containerPort
|
||||
| .value[0] as $binding
|
||||
| if ($binding.HostIp // "") != "" and $binding.HostIp != "0.0.0.0" then
|
||||
"\($binding.HostIp):\($binding.HostPort):\($containerPort | sub("/tcp$"; "") | sub("/udp$"; ""))"
|
||||
else
|
||||
"\($binding.HostPort):\($containerPort | sub("/tcp$"; "") | sub("/udp$"; ""))"
|
||||
end
|
||||
' "$snapshot_file")
|
||||
|
||||
local restart_policy
|
||||
restart_policy="$(jq -r '.HostConfig.RestartPolicy.Name // empty' "$snapshot_file")"
|
||||
if [[ -n "$restart_policy" && "$restart_policy" != "no" ]]; then
|
||||
args+=(--restart "$restart_policy")
|
||||
fi
|
||||
|
||||
local user
|
||||
user="$(jq -r '.Config.User // empty' "$snapshot_file")"
|
||||
if [[ -n "$user" ]]; then
|
||||
args+=(--user "$user")
|
||||
fi
|
||||
|
||||
while IFS= read -r label_line; do
|
||||
args+=(--label "$label_line")
|
||||
done < <(jq -r '
|
||||
.Config.Labels // {}
|
||||
| to_entries[]
|
||||
| "\(.key)=\(.value)"
|
||||
' "$snapshot_file")
|
||||
|
||||
local network_mode
|
||||
local network_name=""
|
||||
network_mode="$(jq -r '.HostConfig.NetworkMode // empty' "$snapshot_file")"
|
||||
if [[ "$network_mode" == "host" ]]; then
|
||||
args+=(--network host)
|
||||
elif [[ -n "$network_mode" && "$network_mode" != "default" && "$network_mode" != "bridge" ]]; then
|
||||
network_name="$network_mode"
|
||||
args+=(--network "$network_name")
|
||||
else
|
||||
network_name="$(jq -r '.NetworkSettings.Networks // {} | keys[0] // empty' "$snapshot_file")"
|
||||
if [[ -n "$network_name" && "$network_name" != "bridge" ]]; then
|
||||
args+=(--network "$network_name")
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "$network_name" ]]; then
|
||||
while IFS= read -r alias; do
|
||||
[[ -n "$alias" ]] || continue
|
||||
args+=(--network-alias "$alias")
|
||||
done < <(jq -r --arg net "$network_name" '
|
||||
.NetworkSettings.Networks[$net].Aliases // []
|
||||
| .[]
|
||||
' "$snapshot_file")
|
||||
fi
|
||||
|
||||
if [[ "$(jq -r '.HostConfig.Privileged // false' "$snapshot_file")" == "true" ]]; then
|
||||
args+=(--privileged)
|
||||
fi
|
||||
|
||||
if [[ "$(jq -r '.HostConfig.ReadonlyRootfs // false' "$snapshot_file")" == "true" ]]; then
|
||||
args+=(--read-only)
|
||||
fi
|
||||
|
||||
printf '%s\0' "${args[@]}"
|
||||
}
|
||||
|
||||
run_container_from_snapshot() {
|
||||
local container_name="$1"
|
||||
local snapshot_file="$2"
|
||||
local image="$3"
|
||||
|
||||
local -a run_args=()
|
||||
mapfile -d '' -t run_args < <(build_run_args_from_snapshot "$snapshot_file")
|
||||
|
||||
docker run -d --name "$container_name" "${run_args[@]}" "$image"
|
||||
}
|
||||
|
||||
wait_for_health() {
|
||||
local target_id="$1"
|
||||
local health_type="$2"
|
||||
local health_url="$3"
|
||||
local expect_status="$4"
|
||||
local deadline=$((SECONDS + HEALTH_TIMEOUT_SECONDS))
|
||||
local last_status=""
|
||||
|
||||
while (( SECONDS < deadline )); do
|
||||
if [[ "$health_type" == "http" ]]; then
|
||||
local status
|
||||
status="$(curl --silent --location --output /dev/null --write-out '%{http_code}' --max-time 5 "$health_url" || true)"
|
||||
last_status="$status"
|
||||
if [[ "$status" == "$expect_status" ]]; then
|
||||
log info "target=${target_id} action=health_ok status=${status}"
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
log error "target=${target_id} action=health_fail reason=unsupported_type type=${health_type}"
|
||||
return 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
log error "target=${target_id} action=health_fail reason=timeout seconds=${HEALTH_TIMEOUT_SECONDS} url=${health_url} last_status=${last_status:-none}"
|
||||
return 1
|
||||
}
|
||||
|
||||
ensure_baseline_state() {
|
||||
local target_id="$1"
|
||||
local container_name="$2"
|
||||
local image="$3"
|
||||
|
||||
local applied
|
||||
applied="$(state_get "$target_id" "last_applied_digest")"
|
||||
if [[ -n "$applied" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local digest=""
|
||||
if digest="$(running_image_digest "$container_name" 2>/dev/null)"; then
|
||||
:
|
||||
elif digest="$(remote_digest "$image" 2>/dev/null)"; then
|
||||
:
|
||||
fi
|
||||
|
||||
if [[ -n "$digest" ]]; then
|
||||
state_set "$target_id" "image" "$image"
|
||||
state_set "$target_id" "last_applied_digest" "$digest"
|
||||
log info "target=${target_id} action=baseline_state digest=${digest}"
|
||||
fi
|
||||
}
|
||||
|
||||
restore_container() {
|
||||
local target_id="$1"
|
||||
local container_name="$2"
|
||||
local snapshot_file="$3"
|
||||
local image="$4"
|
||||
local previous_digest="$5"
|
||||
|
||||
docker rm -f "$container_name" >/dev/null 2>&1 || true
|
||||
if [[ -n "$previous_digest" ]]; then
|
||||
local rollback_image
|
||||
if ! rollback_image="$(resolve_image_ref "$image" "$previous_digest")"; then
|
||||
log error "target=${target_id} action=rollback_failed reason=image_unavailable digest=${previous_digest}"
|
||||
return 1
|
||||
fi
|
||||
run_container_from_snapshot "$container_name" "$snapshot_file" "$rollback_image" || {
|
||||
log error "target=${target_id} action=rollback_failed"
|
||||
return 1
|
||||
}
|
||||
log info "target=${target_id} action=rollback_success digest=${previous_digest}"
|
||||
else
|
||||
log error "target=${target_id} action=rollback_skipped reason=no_previous_digest"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
update_target() {
|
||||
local target_id="$1"
|
||||
local container_name="$2"
|
||||
local image="$3"
|
||||
local health_type="$4"
|
||||
local health_url="$5"
|
||||
local expect_status="$6"
|
||||
|
||||
validate_managed_container "$target_id" "$container_name" "$image" || return 0
|
||||
ensure_baseline_state "$target_id" "$container_name" "$image"
|
||||
|
||||
local remote
|
||||
if ! remote="$(remote_digest "$image")"; then
|
||||
state_set_error "$target_id" "remote_digest_unavailable"
|
||||
log error "target=${target_id} action=remote_digest_failed image=${image}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local applied
|
||||
applied="$(state_get "$target_id" "last_applied_digest")"
|
||||
if [[ "$remote" == "$applied" ]]; then
|
||||
log info "target=${target_id} action=noop digest=${remote}"
|
||||
state_clear_error "$target_id"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log info "target=${target_id} action=update_start remote_digest=${remote} applied_digest=${applied:-none}"
|
||||
|
||||
if ! docker pull "$image"; then
|
||||
state_set_error "$target_id" "pull_failed"
|
||||
log error "target=${target_id} action=pull_failed image=${image}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local snapshot_file
|
||||
snapshot_file="$(mktemp)"
|
||||
snapshot_container "$container_name" "$snapshot_file"
|
||||
|
||||
local previous_digest
|
||||
previous_digest="$(running_image_digest "$container_name" || true)"
|
||||
|
||||
docker stop -t "$STOP_TIMEOUT_SECONDS" "$container_name" >/dev/null
|
||||
docker rm "$container_name" >/dev/null
|
||||
|
||||
if ! run_container_from_snapshot "$container_name" "$snapshot_file" "$image"; then
|
||||
state_set_error "$target_id" "recreate_failed"
|
||||
log error "target=${target_id} action=recreate_failed"
|
||||
restore_container "$target_id" "$container_name" "$snapshot_file" "$image" "$previous_digest" || true
|
||||
rm -f "$snapshot_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if wait_for_health "$target_id" "$health_type" "$health_url" "$expect_status"; then
|
||||
state_set "$target_id" "image" "$image"
|
||||
state_set "$target_id" "last_applied_digest" "$remote"
|
||||
if [[ -n "$previous_digest" ]]; then
|
||||
state_set "$target_id" "previous_digest" "$previous_digest"
|
||||
fi
|
||||
state_set "$target_id" "last_success_at" "$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
state_clear_error "$target_id"
|
||||
log info "target=${target_id} action=update_success digest=${remote}"
|
||||
rm -f "$snapshot_file"
|
||||
return 0
|
||||
fi
|
||||
|
||||
state_set_error "$target_id" "health_failed"
|
||||
log error "target=${target_id} action=rollback_start"
|
||||
restore_container "$target_id" "$container_name" "$snapshot_file" "$image" "$previous_digest" || true
|
||||
rm -f "$snapshot_file"
|
||||
return 1
|
||||
}
|
||||
|
||||
process_targets() {
|
||||
local had_error=0
|
||||
local target_count
|
||||
target_count="$(jq '.targets | length' "$TARGETS_FILE")"
|
||||
|
||||
for (( index=0; index<target_count; index++ )); do
|
||||
local target_id container_name image health_type health_url expect_status
|
||||
target_id="$(jq -r ".targets[$index].id" "$TARGETS_FILE")"
|
||||
container_name="$(jq -r ".targets[$index].container_name" "$TARGETS_FILE")"
|
||||
image="$(jq -r ".targets[$index].image" "$TARGETS_FILE")"
|
||||
health_type="$(jq -r ".targets[$index].health.type" "$TARGETS_FILE")"
|
||||
health_url="$(jq -r ".targets[$index].health.url" "$TARGETS_FILE")"
|
||||
expect_status="$(jq -r ".targets[$index].health.expect_status" "$TARGETS_FILE")"
|
||||
|
||||
with_target_lock "$target_id" update_target \
|
||||
"$target_id" "$container_name" "$image" "$health_type" "$health_url" "$expect_status" \
|
||||
|| had_error=1
|
||||
done
|
||||
|
||||
return "$had_error"
|
||||
}
|
||||
|
||||
sleep_with_jitter() {
|
||||
local base="$1"
|
||||
local jitter_max=$((base / 10))
|
||||
local jitter=0
|
||||
if (( jitter_max > 0 )); then
|
||||
jitter=$((RANDOM % (jitter_max + 1)))
|
||||
fi
|
||||
sleep $((base + jitter))
|
||||
}
|
||||
|
||||
main() {
|
||||
require_command docker
|
||||
require_command jq
|
||||
require_command curl
|
||||
|
||||
validate_targets_file
|
||||
state_init
|
||||
cleanup_legacy_lock_dirs
|
||||
registry_login
|
||||
|
||||
log info "action=startup targets_file=${TARGETS_FILE} state_file=${STATE_FILE} poll_interval=${POLL_INTERVAL_SECONDS}"
|
||||
|
||||
local backoff=0
|
||||
while true; do
|
||||
if process_targets; then
|
||||
backoff=0
|
||||
else
|
||||
backoff=$((POLL_INTERVAL_SECONDS * 2))
|
||||
log warn "action=cycle_failed backoff_seconds=${backoff}"
|
||||
fi
|
||||
|
||||
local sleep_for="$POLL_INTERVAL_SECONDS"
|
||||
if (( backoff > 0 )); then
|
||||
sleep_for="$backoff"
|
||||
fi
|
||||
sleep_with_jitter "$sleep_for"
|
||||
done
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"targets": [
|
||||
{
|
||||
"id": "backend",
|
||||
"container_name": "mmapp-backend",
|
||||
"image": "git.shx.one/denozord/mikrotikmanager-backend:latest",
|
||||
"health": {
|
||||
"type": "http",
|
||||
"url": "http://backend:8000/health",
|
||||
"expect_status": 200
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "frontend",
|
||||
"container_name": "mmapp-frontend",
|
||||
"image": "git.shx.one/denozord/mikrotikmanager-frontend:latest",
|
||||
"health": {
|
||||
"type": "http",
|
||||
"url": "http://frontend:3000/dashboard",
|
||||
"expect_status": 200
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cat <<'EOF'
|
||||
Staging checklist for mmapp-updater:
|
||||
|
||||
1. Independent updates
|
||||
- Push a new backend :latest image only.
|
||||
- Wait for POLL_INTERVAL_SECONDS.
|
||||
- Confirm mmapp-backend was recreated and mmapp-frontend stayed on the previous digest.
|
||||
|
||||
2. Failed pull
|
||||
- Point one target image to a non-existent repository tag.
|
||||
- Confirm the running container stays up and updater-state records last_error=pull_failed.
|
||||
|
||||
3. Health fail rollback
|
||||
- Deploy a backend image that exits immediately or fails /health.
|
||||
- Confirm updater rolls back to previous_digest and keeps mmapp-backend running.
|
||||
|
||||
4. Registry outage
|
||||
- Block access to git.shx.one temporarily.
|
||||
- Confirm updater logs remote_digest_failed and does not stop managed containers.
|
||||
|
||||
Run ./validate.sh before executing this checklist on a staging host.
|
||||
EOF
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
bash -n "${ROOT}/entrypoint.sh"
|
||||
|
||||
jq -e '.targets | type == "array" and length > 0' "${ROOT}/targets.json.example" >/dev/null
|
||||
|
||||
for target in backend frontend; do
|
||||
jq -e --arg target "$target" '.targets[] | select(.id == $target) | .image and .container_name and .health.url' \
|
||||
"${ROOT}/targets.json.example" >/dev/null
|
||||
done
|
||||
|
||||
printf 'updater validation passed\n'
|
||||
Reference in new issue
Block a user