# VPNaaS-миграция: Neutron → Sprut (`ipsec_migrator`) Инструмент для миграции VPNaaS (IPsec site-to-site) с Neutron-роутера OpenStack на «продвинутый» роутер (DC Router) во внешней SDN **Sprut** (`infra.mail.ru:9696`). Инструмент роутероцентричен: каждый Neutron-роутер из входного CSV сопоставлен с advanced-роутером в Sprut. Переносятся все IPsec-туннели, IKE/IPsec policy и endpoint groups, связанные с этим Neutron-роутером; объекты других роутеров того же OpenStack-проекта не затрагиваются. В репозитории две реализации с идентичным CLI, логикой и форматом `audit.json`: - **`ipsec_migrator_v2.sh`** — исходный bash-скрипт. Требует бинари `openstack`/`jq`/`curl`. - **`ipsec_migrator/`** — Python-порт, актуальная реализация. Не использует бинарь `openstack` (ходит в Keystone/Neutron напрямую по REST) и не требует `jq`/`curl`. См. раздел [Python-порт](#python-порт-ipsec_migrator) ниже. ## Статус проекта Python-порт — рекомендуемая реализация для новых миграций. История: 1. Faithful-порт bash-скрипта на Python через subprocess-обёртку над `openstack` CLI (см. `port_summary.md`). 2. Переход с `openstack` CLI на прямые REST-вызовы к Keystone/Neutron (см. `session_2026-07-21_summary.md`). 3. Добавлено автосоздание DC Router в Sprut, когда `advanced_router_id` в CSV пуст (см. [Авто-создание целевого роутера](#авто-создание-целевого-advanced-роутера) ниже). Пункт 3 прошёл боевую проверку 2026-07-21: полный цикл (`--audit-only` → ревью → `--from-audit`) выполнен против настоящего OpenStack-тенанта и Sprut — DC Router + публичный интерфейс, IKE/IPsec policy, endpoint groups, VPN service и IPsec site connection созданы успешно для одного роутера. В ходе этого прогона найдены и исправлены три бага, не проявлявшиеся в офлайн-тестах (подробности и точные фиксы — в `session_2026-07-21_summary.md` и в разделе про авто-создание ниже): - Задвоенный `/v2.0` в пути запроса списка сетей Sprut (`.../v2.0/v2.0/networks`) — 404 при любом не-`--audit-only` прогоне. - Публичная сеть в реальном тенанте называется `internet` (нижний регистр), а сравнение было регистрозависимым и искало `Internet`. - Sprut отклоняет `subnet_id`/`ip_address` при подключении интерфейса к внешней сети (`400 Bad dc_interface request`) — `subnet_id` для такого интерфейса отправлять не нужно, Sprut назначает его сам. ## Требования (bash-скрипт `ipsec_migrator_v2.sh`) - `bash` ≥ 4.3 (используются ассоциативные массивы и namerefs) - `openstack` CLI, аутентифицированный в нужном проекте (переменные окружения `OS_*` / `clouds.yaml`) - `jq` - `curl` - Сетевой доступ к `https://infra.mail.ru:9696` Скрипт проверяет версию bash и наличие всех трёх бинарей при старте и завершается с понятной ошибкой, если что-то не так. Требования для Python-порта — в его собственном разделе ниже; он ничего из списка выше не требует. ## Формат входного CSV ``` neutron_router_id,advanced_router_id neutron_router_id2,advanced_router_id2 ... ``` Пустые строки пропускаются; строки без второй колонки — с предупреждением, но не останавливая скрипт. Python-порт дополнительно поддерживает пустой `advanced_router_id` (авто-создание DC Router) и необязательные 3-ю/4-ю колонки — см. [Авто-создание целевого роутера](#авто-создание-целевого-advanced-роутера). ## Режимы запуска ### 1. Полный прогон (аудит + настройка Sprut) ```bash ./ipsec_migrator_v2.sh [output.json] [--dry-run] python3 -m ipsec_migrator [output.json] [--dry-run] ``` Выполняет всё последовательно: аудит конфигурации Neutron (STAGE 1) → сбор существующих объектов в Sprut (STAGE 2) → создание недостающих объектов и site connection'ов (STAGE 3–4). `output.json` необязателен, по умолчанию — `vpnaas_audit_.json` в текущей директории. ### 2. Только аудит (без обращений к Sprut на запись) ```bash ./ipsec_migrator_v2.sh --audit-only [output.json] python3 -m ipsec_migrator --audit-only [output.json] ``` Выполняет только STAGE 1: проверяет, что Neutron- и advanced-роутеры существуют, собирает полную конфигурацию VPNaaS из Neutron и пишет её в JSON-файл. STAGE 2–4 (создание объектов в Sprut) не запускаются — скрипт завершается сразу после записи аудита. ### 3. Dry-run по ранее собранному аудиту ```bash ./ipsec_migrator_v2.sh --from-audit --dry-run python3 -m ipsec_migrator --from-audit --dry-run ``` Пропускает повторный опрос OpenStack: все данные (IKE/IPsec policy, endpoint groups, VPN service, site connections) берутся из ранее сохранённого `audit.json`. Заново получает Keystone-токен и перепроверяет, что advanced-роутеры из аудита всё ещё существуют в Sprut (аудит мог быть сделан заранее). Дальше выполняются STAGE 2–4 в режиме симуляции: `GET`-запросы к Sprut выполняются по-настоящему (для сверки, что уже существует), но ни один объект не создаётся — вместо `POST` в лог пишется, что было бы отправлено (с маскированным PSK). ### 4. Реальная настройка продвинутого роутера по аудиту ```bash ./ipsec_migrator_v2.sh --from-audit python3 -m ipsec_migrator --from-audit ``` То же самое, что режим 3, но без `--dry-run` — объекты реально создаются в Sprut (STAGE 2–4 выполняются по-боевому). `--audit-only` и `--from-audit` взаимоисключающие — совместное указание завершает скрипт с ошибкой. **Рекомендуемый порядок для боевого прогона:** `--audit-only` → ревью получившегося `audit.json` → `--from-audit` (без `--dry-run`). Так реальное создание объектов в Sprut — отдельный, осознанный шаг после проверки того, что именно будет создано. ## Что делает каждый STAGE | Stage | Действие | |---|---| | **STAGE 1** (STEP 1–10) | Читает CSV, проверяет существование роутеров в OpenStack и Sprut (STEP 3; для Python-порта здесь же — авто-создание DC Router, если `advanced_router_id` пуст), собирает все IKE/IPsec policy, endpoint groups, VPN services и IPsec site connections по каждому роутеру из CSV, пишет самодостаточный JSON-аудит (атомарная запись, права `600`). | | **STAGE 2** | Забирает текущий список объектов из Sprut API (`/vpn/ikepolicies`, `/vpn/ipsecpolicies`, `/vpn/endpoint-groups`, `/vpn/vpnservices`, `/vpn/ipsec-site-connections`) — основа для идемпотентной сверки. | | **STAGE 3** | Сравнивает Neutron-объекты с уже существующими в Sprut (по имени / router_id / набору endpoints) и создаёт недостающие: IKE policy → IPsec policy → Endpoint Groups → VPN service. | | **STAGE 4** | Создаёт IPsec site connections в Sprut, транслируя все связанные ID через карты соответствия из STAGE 3. Идемпотентно по полю `name`. | ## Формат audit.json ```json { "audit_metadata": { "generated_at": "...", "input_file": "routers.csv", "stage": "STAGE1_AUDIT" }, "subnets": { "": "" }, "routers": [ { "neutron_router_id": "...", "advanced_router_id": "...", "pending_dc_router": { "name": "...", "description": "...", "availability_zone": "...", "flavor": "..." }, "vpn_service": { "...": "полный объект vpnservice из Neutron API, либо null" }, "ipsec_site_connections": [ { "id": "...", "raw": { "...": "полный объект ipsec_site_connection из Neutron API" }, "ike_policy": { "...": "полный объект ikepolicy" }, "ipsec_policy": { "...": "полный объект ipsecpolicy" }, "local_endpoint_group": { "id": "...", "raw": {}, "resolved_endpoints": ["10.0.0.0/24"], "already_migrated": false }, "peer_endpoint_group": { "...": "та же структура" } } ] } ] } ``` `advanced_router_id` равен `null`, а `pending_dc_router` заполнен, только когда CSV-строка не задавала advanced-роутер и он ещё не создан (Python-порт, `--audit-only`) — см. [Авто-создание целевого роутера](#авто-создание-целевого-advanced-роутера). Это тот же файл, что пишет режим `--audit-only` / полный прогон, и его же читает `--from-audit`. `--from-audit` проверяет `audit_metadata.stage == "STAGE1_AUDIT"` перед использованием. ## Безопасность - **Файл аудита содержит PSK в открытом виде** (`ipsec_site_connections[].raw.psk` в Python-порте / `..."Pre-shared Key"` в bash-выводе). Пишется атомарно с правами `600` (только владелец). Храните и передавайте его как секрет. - В stdout-логах PSK всегда маскируется (`redact_psk`), в т.ч. в теле запросов к Sprut и в ответах, содержащих `psk`. - В `--dry-run` не-`GET` запросы к Sprut не отправляются — в лог пишется только предполагаемый метод/URL/тело (с маскированным PSK). `GET`-запросы (в т.ч. проверка существования advanced-роутера и поиск публичной сети для авто-создания DC Router) выполняются по-настоящему даже в `--dry-run`. - HTTP-код ответа Sprut/Neutron всегда проверяется; не-2xx останавливает скрипт с ошибкой вместо тихого продолжения. ## Известные ограничения - Токен Keystone не обновляется в течение STAGE 2–4 — для очень долгих миграций возможен `401` в середине прогона. - Сравнение endpoint group'ов чувствительно к порядку адресов в массиве. - Совпадающие имена IKE/IPsec policy у разных объектов в Neutron могут сломать сопоставление в Sprut. То же верно и для DC Router: авто-создание идемпотентно по имени Neutron-роутера — два разных роутера с одинаковым именем будут ошибочно сведены к одному DC Router. - Между проверкой существования объекта (`GET`) и его созданием (`POST`) есть окно гонки — не запускайте миграцию параллельно против одного проекта. ## Python-порт (`ipsec_migrator/`) Порт `ipsec_migrator_v2.sh` на Python, запуск `python3 -m ipsec_migrator `, тот же CLI и формат `audit.json`, что описаны выше. Ключевое отличие от bash-версии — способ обращения к OpenStack и Sprut: порт **не использует бинарь `openstack`** (и не требует `jq`/`curl`), а ходит в Keystone/Neutron/Sprut напрямую по REST через `requests`. Требования: - `python3`, `requests` — больше ничего не нужно. - Аутентификация — только переменные окружения `OS_*` (password auth, `clouds.yaml` не поддерживается): `OS_AUTH_URL`, `OS_USERNAME`, `OS_PASSWORD`, `OS_USER_DOMAIN_NAME` (или `OS_USER_DOMAIN_ID`), `OS_PROJECT_ID` (либо `OS_PROJECT_NAME` + `OS_PROJECT_DOMAIN_NAME`/`_ID`); опционально `OS_REGION_NAME`, `OS_INTERFACE` (по умолчанию `public`) — см. `test_openrc.sh` для примера. Эндпоинт Neutron резолвится из каталога сервисов Keystone, а не хардкодится (с нормализацией хвостового `/v2.0`/`/v2` — разные облака отдают его в каталоге по-разному). - Списки Neutron-объектов (routers, subnets, ike/ipsec policies, endpoint groups, vpn services, ipsec site connections) собираются с пагинацией — порт идёт по `_links` (`rel: next`), пока не соберёт все страницы, а не только первую. **Несовместимость `--from-audit`:** внутри `audit.json` вложенные объекты (`raw`, `ike_policy`, `ipsec_policy`, endpoint group'ы) хранят имена полей как их отдаёт Neutron API (`id`, `name`, `admin_state_up`, ...), а не человекочитаемые заголовки колонок `openstack ... -f json` (`ID`, `Name`, `State`, ...). Audit-файл, сгенерированный более ранней версией порта (той, что ходила в OpenStack через `openstack` CLI), нельзя скормить в `--from-audit` этой версии — нужно перегенерировать аудит заново. ### Авто-создание целевого (advanced) роутера В `input.csv` для порта вторая колонка (`advanced_router_id`) может быть пустой: ``` neutron_router_id1,advanced_router_id1 neutron_router_id2,,MS1,standard neutron_router_id3 ``` Если `advanced_router_id` пуст, порт сам создаёт DC Router в Sprut (`POST /direct_connect/dc_routers`, `enable_snat: true`, `name`/`description` берутся из самого Neutron-роутера) и подключает к нему публичный интерфейс на внешнюю сеть Sprut (`POST /direct_connect/dc_interfaces`; сеть ищется по имени `internet`/`Internet` без учёта регистра — реальное имя в проверенном тенанте оказалось строчным `internet`). `subnet_id` для этого интерфейса не указывается: Sprut отклоняет `subnet_id`/`ip_address` для интерфейса на внешнюю сеть (`400 Bad dc_interface request: Specifying subnet_id or ip_address for external network is restricted`) и назначает адрес сам. Обе операции (DC Router, DC Interface) идемпотентны — по имени и по паре `(dc_router_id, network_id)` соответственно — повторный прогон не создаёт дублей. Точные пути и схемы запросов сверены с `neutron-sprut-api.json` в корне репозитория. - `availability_zone` и `flavor` (обязательные поля Sprut, `flavor` — одно из `basic`/`standard`/`advanced`) берутся из 3-й/4-й колонки CSV; если их там нет — порт спросит интерактивно (`input()`). Для неинтерактивных запусков (cron/CI) указывайте их в CSV заранее — иначе скрипт завершится ошибкой вместо зависания на `stdin`. - В режиме `--audit-only` DC Router **не создаётся** (режим по-прежнему ничего не пишет в Sprut) — вместо этого в `audit.json` записывается `"advanced_router_id": null` и блок `"pending_dc_router"` с именем, описанием, AZ и flavor. Реальное создание происходит при последующем `--from-audit` (без `--dry-run`) или в полном прогоне. - Если в Sprut найдено несколько сетей с именем `internet`/`Internet` без учёта регистра, используется первая с предупреждением в stderr — сознательный упрощённый выбор (без дополнительной проверки `router:external`), см. «Известные ограничения» выше про аналогичный риск коллизии имён. - Реальное (не `--dry-run`) создание роутера и интерфейса — необратимое изменение инфраструктуры Sprut, выполняется как часть обычного потока STEP 3 (внутри STAGE 1). Статус: боевой цикл (`--audit-only` → `--from-audit`) успешно прогнан против реального Sprut 2026-07-21 — детали в `session_2026-07-21_summary.md`. ## Файлы в репозитории | Файл/директория | Назначение | |---|---| | `ipsec_migrator_v2.sh` | Исходный bash-скрипт миграции | | `ipsec_migrator/` | Python-порт (пакет), запуск `python3 -m ipsec_migrator` | | `input.csv` | Пример/рабочий входной CSV для текущей миграции | | `test_openrc.sh` | Пример переменных окружения `OS_*` для аутентификации в OpenStack | | `neutron-sprut-api.json` | OpenAPI-спека Sprut API — источник истины для путей/схем запросов (онлайн-документация `cloud.vk.com` недоступна из окружения разработки) | | `port_summary.md` | Как и с какими оговорками бэш-скрипт был портирован на Python (2026-07-20) | | `session_2026-07-21_summary.md` | Переход Python-порта на прямые Keystone/Neutron REST-вызовы и добавление авто-создания DC Router, включая боевую проверку и найденные баги (2026-07-21) | | `vpnaas_audit_*.json` | Результаты прогонов (`--audit-only`/полный прогон) — содержат PSK, права `600` |