Files
ros_control/README.md
T
ayurishchevandClaude Opus 5.5 ae826fc562 Состояния колонки «Upgrade ROS» и осознанный откат ROS до версии канала
После смены канала на long-term колонка показывала «актуально», хотя
версия канала (7.23.7) старше установленной (7.24.4) — docs/changes/023:
- состояния колонки: «↑ X», «актуально», «канал: X» (версия канала старше
  установленной), «проверка не удалась» (ошибка check-for-updates больше
  не маскируется под «актуально»), «—»;
- откат ROS до версии канала как отдельная задача ros_downgrade: проверка
  версии на устройстве → обязательный бэкап (сбой прерывает) → install;
- UI: «Откатить ROS…» в меню устройства и групповой пункт в «Обновление»,
  окно с вводом целевой версии, список не затрагиваемых устройств;
- API: POST /devices/{id}/update/downgrade и /batch/ros_downgrade с
  обязательным target_version. Штатное обновление откат не выполняет.

Тесты: 33 из 33. Стенд: 422 без версии, задачи отката на недоступном
устройстве завершаются на проверке без бэкапа. Реальный откат и ручная
проверка UI пользователем на момент коммита не выполнены.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:28:01 +03:00

175 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ros_control
Централизованное управление парком MikroTik RouterOS: WEB UI и JSON API.
Дизайн и требования — `ros_control.svg`; макет интерфейса — утверждён и перенесён в приложение (см. `docs/changes/006-ui-redesign`).
```
Admin Dashboard ⇄ Control Server ⇄ RouterOS REST (на каждом устройстве)
⇅ ⇅
Metadata DB S3 (Yandex Object Storage)
```
## Возможности
**Устройства**
- Список устройств: добавление, изменение, удаление; пароли хранятся зашифрованно (Fernet). Имя устройства в таблице кликабельно — открывает окно изменения; **само имя задаётся только при создании и не меняется** (оно входит в ключи бэкапов).
- К устройству можно добавить **примечание** (до 500 символов): видно подсказкой при наведении на имя.
- Статус: online/offline, модель, канал обновлений, версии ROS и FW, uptime, время запроса бэкапа. Колонки **Upgrade ROS** и **Upgrade FW** показывают версию, до которой можно обновиться (устройство проверяет обновления на серверах MikroTik; без интернета на устройстве — «—»).
- Колонка **Upgrade ROS** — пять состояний: обновление доступно («↑ версия»), актуально, **«канал: версия»** — версия канала старше установленной (например, после переключения на `long-term`: это не обновление, а осознанный откат), «проверка не удалась» — последняя проверка обновлений завершилась ошибкой, «—» — проверка ещё не выполнялась.
- **Мониторинг доступности**: сервер опрашивает устройства каждые 30 с (`POLL_INTERVAL`), недоступность определяется за ~4 с на устройство (`ROS_CONNECT_TIMEOUT`); страница обновляет статусы сама, без перезагрузки.
**Группы и фильтры**
- Устройство относится к одной группе: вкладки групп со счётчиками, страница «Группы» (создание, переименование, удаление), перенос отмеченных устройств в группу. При добавлении устройства группу можно создать «на месте» (пункт «+ Новая группа…»).
- Фильтры устройств: поиск (имя, адрес, модель), статус, наличие обновлений, канал. Фильтры бэкапов: группа, устройство, тип файла, период, имя файла. Фильтры попадают в URL.
**Операции**
- Действия по устройству — в меню «⋯» строки; действия над выбранными устройствами (бэкап, статус, обновление ROS/FW, канал, перенос в группу) появляются в полосе инструментов при выборе строк; через API их можно запускать и над всей группой.
- **Бэкап**: `.backup` (без шифрования) и `.rsc` (с `show-sensitive`, т.е. с паролями и ключами — из него можно восстановить все сущности, использующие пароль). Сервер создаёт файлы через REST, скачивает во временную папку, загружает в S3 и удаляет файлы с устройства.
- Список бэкапов в бакете, скачивание (временная ссылка), удаление одного файла или **группы выбранных** (чекбоксы, кнопка «Удалить» с подтверждением).
- Канал обновлений; обновление ROS (устройство перезагружается после скачивания); обновление FW (перезагрузка сразу после появления в журнале устройства записи «Firmware upgraded successfully…»).
- **Откат ROS до версии канала** (состояние «канал: X»): окно с вводом целевой версии (кнопка активна только после ввода, сервер сверяет версию с кэшем статуса) — для одного устройства (пункт «Откатить ROS…» в меню «⋯», виден только в состоянии отката) и для группы (пункт «Откатить ROS до версии канала…» в меню «Обновление» с выбранными устройствами; устройства, которые откатывать нельзя, показаны отдельным списком «Не будут затронуты»). Перед откатом — обязательный бэкап: не удался — откат не запускается. Реальный откат на устройстве запускает пользователь в UI.
- Долгие и групповые операции выполняются задачами (карточка «Задачи», состояние — в UI и через API).
## Идентификаторы и журнал событий
У каждой сущности глобально уникальный ID вида `<префикс>_<uuid7>`: `dev_…` устройство, `grp_…` группа, `job_…` задача, `bkp_…` резервная копия, `evt_…` запись журнала. Префикс исключает пересечение типов, UUIDv7 не повторяется и сортируется по времени создания; ID не переиспользуются после удаления. Числовых ID нет: `GET /api/v1/devices/dev_…`, ID чужого типа или неверного формата — 404.
Резервная копия — пара файлов (`.backup` + `.rsc`) с одним `bkp_` ID; файлы в бакете имеют метаданные `backup-id`/`device-id` (Yandex возвращает их как `Backup-Id`/`Device-Id`), в списке файлов API есть `backup_id`; копия, у которой пропали файлы, помечается удалённой, но остаётся в истории.
**Журнал событий** пишется в БД: создание, изменение и удаление устройств и групп, перенос устройств, жизненный цикл задач и бэкапов, смена online/offline, вход в UI, ротация и очистка журнала. Каждая запись содержит `evt_` ID, ID сущности, актора (`ui:<пользователь>`, `api`, `poller`, `system`) и данные.
В UI — страница «Журнал» (фильтры по типу, актору, устройству, периоду и поиску по сообщению/ID; подгрузка «Показать ещё 100»; клик по строке открывает запись с полными ID и данными).
- **Ротация**: «Журнал → Настройки» — срок хранения (дней) и максимум записей, `0` — без ограничения. Настройки хранятся в БД (значения по умолчанию — `EVENTS_RETENTION_DAYS=90`, `EVENTS_MAX_ROWS=100000` в `.env`); ротация выполняется при старте, раз в час и сразу после сохранения настроек и оставляет запись `journal.rotated`.
- **Очистка**: «Журнал → Очистить журнал» открывает окно с вводом пароля пользователя. Неверный пароль журнал не трогает (`journal.clear_denied`), 5 неверных попыток за 10 минут блокируют очистку на 10 минут. После успешной очистки остаётся одна запись `journal.cleared` (кто и сколько удалил). Очистки через API нет — только через это окно; API даёт чтение журнала и настроек.
## Интерфейс
Экран строится сверху вниз: приложение (навигация) → страница (заголовок, счётчики, главное действие) → вкладки групп → полоса инструментов таблицы → данные; «Задачи» — отдельная карточка. Пока ничего не выбрано, полоса показывает фильтры; при выборе строк — действия над выбранными.
Добавление и изменение устройств, создание и переименование групп, откат ROS — в окнах поверх страницы (запасные страницы `/devices/new`, `/devices/{id}/edit` работают без JavaScript). Выпадающие меню не обрезаются таблицей и раскрываются вверх, если снизу нет места. Выпадающие списки (фильтры устройств/бэкапов/журнала, поле «Группа») оформлены как меню действий «⋯»: список строит JS поверх обычного `<select>`, который остаётся в разметке скрытым — без JavaScript работает стандартный выбор браузера. Светлая и тёмная темы переключаются по настройке системы. Шрифты IBM Plex лежат в `app/ui/static/fonts` (лицензия OFL), внешние ресурсы не загружаются.
## Требования к устройствам
- Поддерживаются и RouterBOARD, и виртуальные **CHR** (у них нет `/system/routerboard`: версия FW не показывается, обновление FW пропускается).
- RouterOS **7.1+**, включённый сервис `www-ssl` (REST API, порт 443; **не** `api-ssl` 8729 и не `api` 8728). Если `www-ssl` недоступен, для доверенной сети можно указать сервис `www` (порт 80) и снять флаг «HTTPS» у устройства — логин и пароль пойдут открытым текстом.
- Пользователь с политиками `read`, `write`, `policy`, `test`, `ftp`, `reboot`, `sensitive` (проще всего — группа `full`). Встроенная группа `write` с запретом `ftp`/`policy` не подходит: бэкап падает с `not enough permissions`. После смены прав пересоздайте подключение к устройству.
- Доступ устройств к S3 не нужен: файлы забирает и загружает Control Server (ему нужен доступ к `storage.yandexcloud.net:443`).
## Безопасность
- **Секреты обязательны:** `API_TOKEN`, `SESSION_SECRET` (≥ 32 символов) и `ADMIN_PASSWORD` (≥ 12 символов) не могут быть пустыми или равными `change-me`; `SECRET_KEY` должен быть валидным ключом Fernet. С небезопасными значениями приложение не стартует (`RuntimeError` при старте, без секретов в тексте ошибки и в логе) — сгенерировать: `python -c "import secrets; print(secrets.token_urlsafe(32))"`. Порт 8000 не стоит открывать в недоверенную сеть.
- **Вход в UI ограничен по IP клиента**: 5 неверных попыток за 10 минут блокируют IP на 10 минут (в блокировке пароль не проверяется); единственного администратора нельзя заблокировать чужими попытками, т.к. блокировка не привязана к имени пользователя. Ограничение достоверно только пока перед приложением нет reverse-proxy — иначе все запросы приходят с одного IP прокси, и понадобится доверенный `X-Forwarded-For`.
- **Cookie сессии UI**: `SESSION_COOKIE_SECURE=false` по умолчанию (UI по HTTP продолжает работать); включите `true`, когда приложение работает за TLS.
- Пароли устройств шифруются ключом `SECRET_KEY` (Fernet); потеря ключа = потеря доступа к сохранённым паролям.
- **Секреты в бэкапах:** `.rsc` создаётся с `show-sensitive`, а `.backup` — без шифрования, поэтому файлы содержат пароли и ключи открытым текстом. Ограничьте доступ к бакету и ссылкам скачивания.
## Ограничения
- **Один процесс на БД**: сервер держит файловую блокировку `<файл БД>.lock` рядом с БД (снимается при остановке); второй процесс на той же БД (`--workers 2+`, вторая копия контейнера на том же томе) не стартует — понятная ошибка вместо молчаливой порчи данных. В памяти процесса (не переживает перезапуск и не разделяется между процессами) — семафор фоновых задач, блокировка синхронизации бэкапов, счётчики неудачных попыток входа в UI и очистки журнала, кэш списка бакета.
- **Кэш списка бакета**: страница «Бэкапы» и `GET /api/v1/backups` не перечитывают бакет на каждый просмотр — список живёт `BACKUPS_CACHE_TTL` секунд (по умолчанию 60; `0` — кэш выключен). Изменения бакета, сделанные не через это приложение, видны не позже TTL или сразу — кнопкой «Обновить список» (`refresh=1`). Собственные изменения (бэкап, удаление) сбрасывают кэш сами.
## Запуск
```bash
cp .env.example .env # заполнить SECRET_KEY, SESSION_SECRET, ADMIN_PASSWORD, API_TOKEN, S3_*
docker compose up -d --build # UI: http://localhost:8000, OpenAPI: /docs
```
БД (SQLite) хранится в Docker-томе `ros_data` (`docker volume inspect ros_control_ros_data`), переживает пересборку контейнера; схема обновляется автоматически при старте (перед миграцией ID создаётся копия `ros_control.db.bak-<метка>` рядом с БД). Остановка: `docker compose down` (том сохраняется; `down -v` удалит БД).
Ключ Fernet: `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`.
### Настройки (`.env`)
| Переменная | По умолчанию | Назначение |
|---|---|---|
| `SECRET_KEY` | — | ключ Fernet для паролей устройств (обязателен) |
| `SESSION_SECRET` | — | подпись cookie-сессии UI (обязателен, ≥ 32 символов) |
| `SESSION_COOKIE_SECURE` | `false` | Secure-флаг cookie сессии; включить за TLS |
| `ADMIN_USER` / `ADMIN_PASSWORD` | `admin` / — | вход в UI (`ADMIN_PASSWORD` обязателен, ≥ 12 символов) |
| `API_TOKEN` | — | Bearer-токен API (обязателен, ≥ 32 символов) |
| `DATABASE_URL` | `sqlite:///./data/ros_control.db` | база метаданных (в compose — том) |
| `S3_ENDPOINT`, `S3_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_PREFIX` | Yandex Object Storage, `backups` | бакет для резервных копий |
| `BACKUPS_CACHE_TTL` | `60` | кэш списка бакета, с; `0` — выключить (см. «Ограничения») |
| `MAX_CONCURRENCY` | `10` | одновременные обращения к устройствам |
| `ROS_CONNECT_TIMEOUT` | `4` | секунд на установление соединения (скорость обнаружения недоступности) |
| `ROS_TIMEOUT` | `30` | секунд на ответ устройства (долгие операции) |
| `POLL_INTERVAL` | `30` | период фонового опроса, с; `0` — выключить |
| `UPDATE_CHECK_INTERVAL` | `1800` | как часто опрос проверяет обновления ROS, с |
| `EVENTS_RETENTION_DAYS` / `EVENTS_MAX_ROWS` | `90` / `100000` | ротация журнала по умолчанию (действующие значения меняются в UI); `0` — без ограничения |
### Разработка
```bash
python3 -m venv venv && ./venv/bin/pip install -r requirements.txt
set -a; . ./.env; set +a
./venv/bin/uvicorn app.main:app --reload
./venv/bin/python -m pytest # 33 теста, фоновый опрос в тестах выключен
```
## API v1
Заголовок `Authorization: Bearer <API_TOKEN>`. Интерактивная документация — `/docs`. Пример:
```bash
curl -s -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/api/v1/devices | python3 -m json.tool
```
Все `id` — строки (`dev_…`, `grp_…`, `job_…`); `group_id` — `grp_…`. Устройство: `name`, `host`, `port`, `username`, `password` (только при записи), `verify_tls`, `use_tls` (`false` — HTTP), `group_id` (`null` — без группы), `note`.
| Метод | Путь | Назначение |
|---|---|---|
| GET/POST | `/api/v1/devices` | список (фильтры `group`, `q`, `status`, `updates`, `channel`) / добавить |
| GET/PATCH/DELETE | `/api/v1/devices/{id}` | получить / изменить (имя менять нельзя — 400) / удалить |
| POST | `/api/v1/devices/refresh`, `/devices/{id}/refresh` | обновить статус |
| POST | `/api/v1/devices/{id}/backups` | бэкап (задача) |
| PUT | `/api/v1/devices/{id}/update/channel` | `{"channel": "stable\|long-term\|testing\|development"}` |
| POST | `/api/v1/devices/{id}/update/install` | обновление ROS (задача) |
| POST | `/api/v1/devices/{id}/update/downgrade` | `{"target_version": "7.23.7"}` (обязательно) — откат ROS до версии канала: бэкап, затем откат (задача `ros_downgrade`); версия сверяется на устройстве, не совпала — задача завершается ошибкой |
| POST | `/api/v1/devices/{id}/firmware/upgrade` | обновление FW (задача) |
| GET/POST | `/api/v1/groups` | группы (с числом устройств) / создать |
| PATCH/DELETE | `/api/v1/groups/{id}` | переименовать / удалить (устройства остаются без группы) |
| POST | `/api/v1/batch/{backup\|ros_update\|fw_update}` | `{"device_ids": [...]}` и/или `{"group_id": N}` — групповая операция (задача) |
| PUT | `/api/v1/batch/channel` | `{"device_ids": [...] или "group_id": N, "channel": "..."}` — групповая смена канала (задача `set_channel`) → 202 `{"job_ids": [...]}` |
| POST | `/api/v1/batch/ros_downgrade` | `{"device_ids": [...] или "group_id": N, "target_version": "7.23.7"}` — групповой откат ROS (задача `ros_downgrade`); отдельный эндпоинт — `ros_downgrade` в `Literal` `/batch/{action}` не входит, версия там обязательна |
| GET/DELETE | `/api/v1/backups`, `/backups/download?key=` | бэкапы в бакете (фильтры `device_id`, `group`, `kind`, `date_from`, `date_to`, `q`; `refresh=1` — минуя кэш) |
| POST | `/api/v1/backups/delete` | групповое удаление файлов: `{"keys": [...]}` → `{"deleted": N, "failed": M}` |
| GET | `/api/v1/jobs`, `/jobs/{id}` | состояние задач |
| GET | `/api/v1/events`, `/events/{id}` | журнал событий (фильтры `entity_id`, `type`, `device_id`, `job_id`, `actor`, `date_from`, `date_to`, `q`; постранично `before=<evt_…>`, `limit`) |
| GET/PUT | `/api/v1/events/settings` | настройки ротации журнала `{"retention_days", "max_rows"}` и сводка (очистки журнала через API нет) |
Внешние справочники: [RouterOS REST API](https://help.mikrotik.com/docs/spaces/ROS/pages/47579162/REST+API#RESTAPI-HTTPMethods),
[Yandex Object Storage S3 API](https://yandex.cloud/ru/docs/storage/s3/api-ref/).
## Структура
- `app/ros` — клиент RouterOS REST и операции (статус, бэкап, скачивание файлов, обновления)
- `app/s3.py` — бакет; `app/security.py` — шифрование и авторизация; `app/models.py`, `app/db.py` — БД и миграции
- `app/services` — устройства и фильтры, группы, бэкапы, операции, задачи, фоновый опрос (`poller.py`)
- `app/api` — JSON API; `app/ui` — WEB UI (Jinja2 + HTMX, `static/` со стилями, скриптом и шрифтами)
- `tests` — минимальный набор; `docs/changes` — планы и итоги каждого изменения
- `docs/reviews` — ревью кодовой базы (замечания и приоритеты исправлений)
## История изменений
Планы и итоги — в `docs/changes/`:
- `001-initial-implementation` — первая версия.
- `002-http-support-and-fixes` — HTTP-подключение, исправления по итогам теста на реальном устройстве.
- `003-download-via-rest-upload-from-server` — бэкап: скачивание файлов через REST, загрузка в S3 сервером.
- `004-upgrade-columns-and-actions-menu` — колонки Upgrade ROS/FW, меню действий «⋯».
- `005-groups-and-filters` — группы устройств, фильтры устройств и бэкапов.
- `006-ui-redesign` — новый интерфейс по утверждённому макету; исправлены перезагрузка после обновления FW и привязка группы при добавлении устройства.
- `007-fast-offline-detection` — фоновый опрос устройств (30 с), быстрый таймаут соединения, автообновление страницы.
- `008-device-note` — примечание к устройству.
- `009-dialog-label-alignment` — выравнивание полей в окне устройства.
- `010-export-show-sensitive` — `.rsc` создаётся с `show-sensitive` (секреты в файле).
- `011-dropdown-clipping` — выпадающие меню не обрезаются в узком окне.
- `012-clickable-device-name` — клик по имени устройства открывает окно изменения.
- `013-bulk-delete-backups` — выбор файлов чекбоксами и групповое удаление бэкапов.
- `014-chr-support` — поддержка CHR (нет `/system/routerboard`), строгое сравнение версий ROS, причина недоступности в таблице.
- `015-immutable-device-name` — имя устройства задаётся только при создании.
- `016-unique-ids-and-event-log` — глобально уникальные ID (префикс + UUIDv7) для всех сущностей, журнал событий в БД, миграция числовых ID.
- `017-events-ui-rotation` — страница «Журнал» в UI, настройки ротации, очистка журнала с подтверждением паролем.
- `018-correctness-consistency` — одиночное удаление бэкапа в UI через общий сервис `delete_many`, единая система миграций (колонки старой схемы — в `migrations.run` до миграции ID), групповая смена канала фоновыми задачами (`set_channel`).
- `019-performance-scaling` — кэш списка бакета (`BACKUPS_CACHE_TTL`, «Обновить список»); обработчики без обращений к event loop — обычные функции (пул потоков FastAPI), запись статуса и тяжёлые операции с БД в фоне — через `asyncio.to_thread`; SQLite — WAL и `busy_timeout`; файловая блокировка БД — один процесс на БД.
- `020-custom-select-menus` — выпадающие списки (фильтры, «Группа») в стиле меню действий «⋯»: прогрессивное улучшение в JS, нативный `select` остаётся в разметке и работает без JavaScript.
- `021-security-hardening` — отказ старта при небезопасных секретах (`API_TOKEN`/`SESSION_SECRET`/`ADMIN_PASSWORD`/`SECRET_KEY`), блокировка входа в UI по IP клиента, `SESSION_COOKIE_SECURE`, безопасный `next` в редиректах (`/ui/move`, `/backups/delete-many`).
- `022-async-db-remainder` — остаток п. 9 ревью: оставшиеся синхронные обращения к БД в async-обработчиках (API, UI, `ops`, `backups.search`) — через `asyncio.to_thread`; `jobs.start_jobs` разделён на синхронную `_create_jobs` (в потоке) и `async start_jobs` (создаёт задачи и планирует их в event loop); регрессионный тест-линтер (AST-обход `app/`) не даёт синхронным обращениям к БД вернуться в async-код.
- `023-ros-downgrade` — пять состояний колонки «Upgrade ROS» (`update`/`current`/`downgrade`/`check_error`/`unknown`; строгое сравнение версий больше не путает откат канала с «актуально»); осознанный откат ROS до версии канала с обязательным бэкапом — для одного устройства и группы, в UI (окно с подтверждением версии) и API (`/update/downgrade`, `/batch/ros_downgrade`).