eSIM Platform Celery · GoIP-16 · SM-DP+ Phantom Swap Protection

eSIM-платформа: защита от критических ошибок при провизионировании SIM-карт

Автоматизированная замена eSIM-профилей через GoIP-шлюз и SM-DP+ сервер оператора. Phantom swap, race conditions и инфраструктура на грани — решено на уровне архитектуры.

5 checks
порог iccid_mismatch_count перед финальной записью
~100 ms
время ответа бота после вынесения в Celery
0 phantom
некорректных записей ICCID после внедрения
24 h
порог orphan-cleanup для незавершённых операций
Контекст
Платформа автоматической замены eSIM-профилей через физический GoIP-16 шлюз, MikroTik (Tailscale-туннель, 192.168.88.100) и интеграцию с SM-DP+ сервером оператора. Стек: FastAPI + Celery + aiogram + PostgreSQL.
GoIP-16 gateway MikroTik · Tailscale SM-DP+ server
Проблема
При нестабильном соединении шлюз мог записать неверный ICCID и вернуть статус успеха — phantom swap. Устройство уходило в нерабочее состояние, откат невозможен без физического доступа. Не воспроизводилось в тестовой среде.
Phantom ICCID write False success status No rollback possible
Архитектура платформы
Bot → Celery → GoIP → SM-DP+: асинхронное провизионирование
Вынос операции в Celery развязал бота от шлюза. Верификация ICCID выполняется независимым циклом.
User
Telegram
client
aiogram
cmd
FastAPI Bot
aiogram
~100ms resp.
task.delay()
enqueue
Celery
async worker
Redis broker
provision_task
Tailscale
GoIP-16
YX gateway
192.168.88.100
hardware
HTTP API
SM-DP+
operator server
ICCID swap
LPA protocol
Жизненный цикл операции · статусы задачи
pending processing verifying ×5 done cleaning failed
cleaning — промежуточный статус: предотвращает зацикливание orphan-cleanup на уже обрабатываемых задачах
Fix 1 — Phantom Swap Protection
Многоцикловая верификация ICCID с порогом 5
После каждой операции ICCID считывается повторно — финальный статус пишется только при совпадении 5 раз подряд
critical bug
Phantom ICCID
До — доверяем первому ответу
# provision.py — наивная логика async def swap_iccid(slot, new_iccid): resp = await goip.send_command( slot, f"AT+ICCIDSWAP={new_iccid}" ) if resp == "OK": # ❌ доверяем ответу шлюза await db.set_status(slot, "done") await db.write_iccid(slot, new_iccid) # ❌ phantom: шлюз вернул OK, # но записал старый ICCID
После — многоцикловая верификация
# Константа порога подтверждений MISMATCH_THRESHOLD = 5 async def verify_iccid(slot, expected): mismatch = 0 for _ in range(MISMATCH_THRESHOLD): actual = await goip.read_iccid(slot) if actual != expected: mismatch += 1 await asyncio.sleep(2) if mismatch >= MISMATCH_THRESHOLD: raise PhantomSwapError(actual) return "verified"
Fix 2 — Orphan Cleanup Loop
Статус "cleaning" предотвращает бесконечный цикл
Без промежуточного статуса orphan-worker перезапускал уже обрабатываемые задачи — race condition на уровне воркеров
race condition
Cleanup loop
До — orphan-worker зацикливается
# cleanup_worker.py — проблемная логика async def cleanup_orphans(): orphans = await db.get_stuck( older_than=timedelta(hours=24) ) for task in orphans: # ❌ processing попадает в очередь снова # пока воркер ещё работает await provision_task.delay(task.id) # ❌ два воркера → двойная запись
После — статус cleaning как барьер
# Атомарный переход в "cleaning" async def cleanup_orphans(): orphans = await db.get_stuck( status=["processing", "verifying"], older_than=timedelta(hours=24) ) for task in orphans: # ✓ атомарно: processing→cleaning updated = await db.cas_status( task.id, expected="processing", new="cleaning" # барьер ) if updated: # только один воркер await db.set_status(task.id, "failed")
Fix 3 — Async provisioning
Celery task: бот отвечает за ~100ms, шлюз работает фоново
До рефакторинга бот ждал ответа шлюза — до 30–60 секунд. После: task.delay() и моментальный ответ пользователю
performance
~100ms resp.
До — бот блокируется на шлюзе
# handler.py — синхронный вызов @router.message(F.text == "swap") async def handle_swap(msg): # ❌ ждём 30–60 сек ответа шлюза result = await goip.swap_iccid( slot=msg.slot, iccid=msg.new_iccid ) # ❌ Telegram timeout = 30 сек await msg.answer(result)
После — Celery task, ~100ms
# handler.py — async через Celery @router.message(F.text == "swap") async def handle_swap(msg): task_id = await db.create_task( slot=msg.slot, iccid=msg.new_iccid ) # ✓ мгновенный enqueue в Redis provision_task.delay(task_id) # ✓ бот отвечает сразу (~100ms) await msg.answer( f"⏳ Задача #{task_id} запущена" )
tasks.py — полный цикл provision_task
@celery_app.task(bind=True, max_retries=3) def provision_task(self, task_id): try: # 1. Выполняем операцию на шлюзе goip.swap_iccid(task.slot, task.iccid) # 2. Верифицируем ICCID × 5 циклов verify_iccid(task.slot, task.iccid) # 3. Статус только после верификации db.set_status(task_id, "done") bot.notify(task.user_id, "✓ Готово") except PhantomSwapError as e: db.set_status(task_id, "failed") bot.notify(task.user_id, f"❌ {e}")
Реализованные решения: ✓ Phantom swap guard ✓ cleaning status ✓ Celery async ✓ CAS атомарность
Fixed ✓
Результат
Полностью устранены случаи некорректной записи ICCID. Платформа обрабатывает операции асинхронно — бот отвечает за ~100ms независимо от времени работы шлюза. Подтверждена несовместимость прошивки GoIP с конкретным SM-DP+ сервером — задокументировано как ограничение на уровне вендора.
FastAPI
Celery · Redis broker
aiogram · Telegram bot
GoIP-16 · YX gateway
SM-DP+ · LPA protocol
MikroTik · Tailscale
PostgreSQL

Есть задача? Давайте разберём её

Расскажите что происходит — мы скажем можем ли помочь и как это выглядит по деньгам и срокам. Без обязательств.