openapi: 3.1.0
info:
  title: Latency Lab Personal API
  version: "1.0.0"
  summary: Personal API keys (Bearer ll_…) for scans and own stats
  description: |
    HTTP API консоли Latency Lab для personal API keys.

    Это **не** публичный бесплатный доступ: ключ выдаётся в профиле
    после входа через Telegram (https://latencylab.ru/account.html).

    **База:** `https://console.latencylab.ru`

    **Лимит аккаунта:** общий с ботом и консолью (сутки UTC, с 00:00 GMT).
    Multiscan считается как 1 запрос.

    **Scope ключа:** IP, домен, подсеть `/23`…`/32`, VPN-ключ, status/operators/job,
    своя статистика, **мои серверы** и **ссылки мониторинга** (подписка / кабинет / панель)
    как на https://ss.latencylab.ru.

    Сканы: для подсети wire-precheck; метод по умолчанию ICMP+TCP
    (`icmp/tcp`); домен — TCP.
  contact:
    name: Latency Lab
    url: https://latencylab.ru
servers:
  - url: https://console.latencylab.ru
    description: Production
tags:
  - name: Auth
  - name: Meta
  - name: Scan
  - name: VPN
  - name: Account
  - name: Servers
  - name: ServiceLinks
  - name: Jobs
security:
  - ApiKeyBearer: []

paths:
  /api/lab/health:
    get:
      tags: [Meta]
      summary: Health relay
      security: []
      operationId: getHealth
      responses:
        "200":
          description: Relay alive
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HealthResponse"
              example:
                ok: true
                service: latencylab-relay
                uptime_sec: 86400
                agents_online: [orel]
                pending_calls: 0
                default_node: orel
                agent_connected: true
                any_agent_connected: true
                status_cache_age_sec: 12
                status_cache_ready: true

  /api/lab/auth/me:
    get:
      tags: [Auth]
      summary: Текущий пользователь по API-ключу
      operationId: getAuthMe
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthMeResponse"
              example:
                ok: true
                result:
                  username: alice
                  role: user
                  is_admin: false
                  is_moderator: false
                  is_staff: false
                  can_access_db: false
                  can_access_stats: false
                  web_user_id: 123456789
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ForbiddenScope"

  /api/lab/status:
    get:
      tags: [Meta]
      summary: Статус агента и модемов
      operationId: getStatus
      parameters:
        - $ref: "#/components/parameters/NodeIdQuery"
      responses:
        "200":
          description: OK (часто из кэша)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ForbiddenScope"
        "503":
          description: Кэш статуса ещё не готов / агент офлайн
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorBody"
              example:
                ok: false
                error: "Статус ещё не готов - агент orel не подключён"

  /api/lab/operators:
    get:
      tags: [Meta]
      summary: Список операторов
      operationId: getOperators
      parameters:
        - $ref: "#/components/parameters/NodeIdQuery"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OperatorsResponse"
              example:
                ok: true
                result:
                  operators:
                    - id: mts
                      label: МТС
                      configured: true
                      online: true
                      signal_level: 3
                      health_rtt_ms: 42.5
                      wl: true
                  online: [mts, t2]
                  cached: true
                  updated_at: 1720000000.0
                  cache_age_sec: 15
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ForbiddenScope"
        "503":
          $ref: "#/components/responses/ServiceUnavailable"

  /api/lab/resolve:
    post:
      tags: [Scan]
      summary: Разобрать цель (kind + нормализованный target)
      operationId: postResolve
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ResolveRequest"
            examples:
              subnet:
                value: { text: "46.8.209.0/23" }
              ip:
                value: { text: "1.1.1.1" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ResolveResponse"
              example:
                ok: true
                result:
                  kind: subnet
                  target: "46.8.208.0/23"
                req_id: "550e8400-e29b-41d4-a716-446655440000"
        "400":
          description: Пустая цель или агент не смог разобрать
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorBody"
              examples:
                empty:
                  value: { ok: false, error: "Укажите IP, домен или подсеть" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ForbiddenScope"

  /api/lab/ping:
    post:
      tags: [Scan]
      summary: Пинг IP или домена (один оператор)
      description: |
        Подсеть через этот метод недоступна — используйте `/subnet-scan` или `/multiscan`.
        Метод пинга по умолчанию `icmp/tcp`; для домена принудительно `tcp`.
      operationId: postPing
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PingRequest"
            examples:
              ip:
                value: { operator: mts, target: "1.1.1.1" }
              domain:
                value: { operator: mts, target: "example.com" }
      responses:
        "200":
          description: Агент ответил (смотрите `result.ok`)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PingResponse"
              example:
                ok: true
                result:
                  operator: mts
                  kind: ip
                  ok: true
                  method: icmp+tcp
                  icmp_ok: true
                  tcp_ok: true
                  icmp_rtt_ms: 12.3
                  tcp_rtt_ms: 18.0
                  rtt_ms: 12.3
                  output: "icmp=…; tcp=…"
                  summary: "МТС: 1.1.1.1 — OK"
                  ping_method: icmp/tcp
                req_id: "550e8400-e29b-41d4-a716-446655440000"
        "400":
          description: Валидация / resolve / попытка пропинговать подсеть через ping
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorBody"
              examples:
                needOperator:
                  value: { ok: false, error: "Выберите оператора" }
                useSubnet:
                  value: { ok: false, error: "Для подсети используйте сканирование подсети" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ForbiddenScanScope"
        "502":
          $ref: "#/components/responses/AgentError"
        "503":
          $ref: "#/components/responses/MaintenanceOrAgent"

  /api/lab/multiscan:
    post:
      tags: [Scan]
      summary: Мультискан по online-операторам
      description: |
        Цель: IP, домен или подсеть `/23`…`/32` (для API-ключа).
        Шире `/23` → HTTP 403.
        `async: true` — сразу pending job, иначе синхронный ответ (может быть долгим).
      operationId: postMultiscan
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MultiscanRequest"
            examples:
              ip:
                value: { text: "8.8.8.8" }
              subnet:
                value: { text: "46.8.209.0/23" }
              asyncJob:
                value: { text: "8.8.8.8", async: true }
      responses:
        "200":
          description: Синхронный результат или старт async job
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/MultiscanResponse"
                  - $ref: "#/components/schemas/JobPendingStart"
              examples:
                sync:
                  value:
                    ok: true
                    result:
                      kind: subnet
                      target: "46.8.208.0/23"
                      operators: [tmobile, megafon, beeline, mts, t2]
                      wire_precheck: true
                      ping_method: icmp/tcp
                      results:
                        - operator: mts
                          kind: subnet
                          available: true
                          ok: true
                          alive_count: 173
                          icmp_alive: 165
                          tcp_alive: 61
                          wire_alive_count: 168
                          status_text: доступна
                          ping_method: icmp/tcp
                    req_id: "550e8400-e29b-41d4-a716-446655440000"
                pending:
                  value:
                    ok: true
                    req_id: "550e8400-e29b-41d4-a716-446655440000"
                    status: pending
        "400":
          description: Пустая цель / resolve fail
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorBody"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Scope API-ключа (в т.ч. подсеть шире /23)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorBody"
              example:
                ok: false
                error: "API-ключ: подсеть только /23…/32 (например 1.2.3.0/24)"
        "409":
          $ref: "#/components/responses/JobBusy"
        "502":
          $ref: "#/components/responses/AgentError"
        "503":
          $ref: "#/components/responses/MaintenanceOrAgent"

  /api/lab/subnet-scan:
    post:
      tags: [Scan]
      summary: Скан подсети одним оператором
      description: Для API-ключа только `/23`…`/32`. По умолчанию `ping_method=icmp/tcp`, wire-precheck на агенте.
      operationId: postSubnetScan
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SubnetScanRequest"
            example:
              operator: mts
              target: "46.8.208.0/24"
      responses:
        "200":
          description: Синхронный результат или pending job (`async`)
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/SubnetScanResponse"
                  - $ref: "#/components/schemas/JobPendingStart"
              example:
                ok: true
                result:
                  operator: mts
                  kind: subnet
                  target: "46.8.208.0/24"
                  display_range: "46.8.208.1-255"
                  scanned_hosts: 255
                  alive_count: 40
                  wire_alive_count: 50
                  icmp_alive: 38
                  tcp_alive: 35
                  available: true
                  ok: true
                  status_text: доступна
                  status_emoji: "🟢"
                  ping_method: icmp/tcp
                  reachable_ips: ["46.8.208.1", "46.8.208.10"]
                req_id: "550e8400-e29b-41d4-a716-446655440000"
        "400":
          description: Нет operator/target
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorBody"
              examples:
                needCidr:
                  value: { ok: false, error: "Укажите подсеть (например 46.8.0.0/24)" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Подсеть вне /23…/32 для API-ключа
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorBody"
              example:
                ok: false
                error: "API-ключ: подсеть только /23…/32 (например 1.2.3.0/24)"
        "409":
          $ref: "#/components/responses/JobBusy"
        "502":
          $ref: "#/components/responses/AgentError"
        "503":
          $ref: "#/components/responses/MaintenanceOrAgent"

  /api/lab/vpn-key:
    post:
      tags: [VPN]
      summary: Проверка VPN-ключа
      description: Всегда асинхронно — сразу `status=pending`, дальше поллите `/api/lab/job/{req_id}`.
      operationId: postVpnKey
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/VpnKeyRequest"
            example:
              uri: "vless://uuid@host:443?encryption=none&security=reality&type=tcp#node"
      responses:
        "200":
          description: Job поставлен в очередь
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/JobPendingStart"
              example:
                ok: true
                req_id: "550e8400-e29b-41d4-a716-446655440000"
                status: pending
        "400":
          description: Нет uri/outbound
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorBody"
              example:
                ok: false
                error: "Вставьте VPN-ключ (vless://…) или outbound"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Пустой ключ (API-key gate) или вне scope
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorBody"
        "409":
          $ref: "#/components/responses/JobBusy"
        "503":
          $ref: "#/components/responses/MaintenanceOrAgent"

  /api/lab/account/stats:
    get:
      tags: [Account]
      summary: Своя статистика сканов
      description: Только данные аккаунта ключа (`web_user_id`). Staff `/api/lab/stats` недоступен.
      operationId: getAccountStats
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AccountStatsResponse"
              example:
                ok: true
                result:
                  by_kind: { ip: 10, domain: 2, subnet: 5, vpn_key: 1 }
                  total: 18
                  day:
                    total: 7
                    by_kind: { ip: 4, domain: 1, subnet: 2, vpn_key: 0 }
                    window_sec: 86400
                  window: { used: 13, limit: 500, window_sec: 86400 }
                  user_id: 123456789
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ForbiddenScope"
        "503":
          description: Агент не отдал статистику
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorBody"
              example:
                ok: false
                error: "Статистика временно недоступна"

  /api/lab/job/active:
    get:
      tags: [Jobs]
      summary: Активная задача текущего пользователя
      operationId: getJobActive
      responses:
        "200":
          description: idle или pending
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/JobActiveIdle"
                  - $ref: "#/components/schemas/JobActivePending"
              examples:
                idle:
                  value: { ok: true, status: idle }
                pending:
                  value:
                    ok: true
                    status: pending
                    req_id: "550e8400-e29b-41d4-a716-446655440000"
                    scan_kind: "VPN-ключ"
                    scan_target: "vless://…"
                    progress: { done: 10, total: 255, alive: 3 }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ForbiddenScope"

  /api/lab/job/{req_id}:
    get:
      tags: [Jobs]
      summary: Статус / результат задачи
      operationId: getJob
      parameters:
        - name: req_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: pending | done | cancelled
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/JobPollPending"
                  - $ref: "#/components/schemas/JobPollDone"
                  - $ref: "#/components/schemas/JobPollCancelled"
              examples:
                pending:
                  value:
                    ok: true
                    status: pending
                    req_id: "550e8400-e29b-41d4-a716-446655440000"
                done:
                  value:
                    ok: true
                    status: done
                    req_id: "550e8400-e29b-41d4-a716-446655440000"
                    result: { operator: mts, ok: true, latency_ms: 85.2 }
                cancelled:
                  value:
                    ok: false
                    status: cancelled
                    req_id: "550e8400-e29b-41d4-a716-446655440000"
                    error: отменено
        "400":
          description: Пустой req_id
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorBody"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Чужая задача
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorBody"
              example:
                ok: false
                error: "Ты не достоин."
        "404":
          description: Задача не найдена
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorBody"
              example:
                ok: false
                error: "Задача не найдена или уже завершена"
        "502":
          description: Задача завершилась ошибкой агента
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/JobPollError"

  /api/lab/cancel:
    post:
      tags: [Jobs]
      summary: Отменить задачу
      operationId: postCancel
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CancelRequest"
            example:
              req_id: "550e8400-e29b-41d4-a716-446655440000"
      responses:
        "200":
          description: Запрос на отмену принят
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CancelResponse"
              example:
                ok: true
                result: { cancelled: true }
                req_id: "550e8400-e29b-41d4-a716-446655440000"
        "400":
          description: Нет req_id или агент вернул ошибку
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorBody"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Чужая задача / вне scope
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorBody"
        "404":
          description: Задача не найдена
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorBody"
              example:
                ok: false
                error: "Задача не найдена"

  /api/lab/servers:
    get:
      tags: [Servers]
      summary: Список моих серверов
      description: |
        Как вкладка «Мои серверы» на ss.latencylab.ru. В ответе также `service_links`.
        Лимит: **не больше 10 серверов** на аккаунт (общий с UI/ботом).
      operationId: getServers
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ServersListResponse"
              example:
                ok: true
                result:
                  servers:
                    - id: 1
                      kind: ip
                      target: "8.8.8.8"
                      ping_method: icmp
                      tcp_port: null
                      note: "VPS"
                      created_at: 1754122516
                      availability:
                        mts: { ok: true, rtt_ms: 42.1, checked_at: 1754122600 }
                  service_links:
                    subscription_url: "https://example.com/sub"
                    cabinet_url: "https://example.com/cabinet"
                    panel_url: null
                    items: []
                  last_scan_at: 1754122600
                  scan_cooldown_sec: 900
                  scan_available_in: 0
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ForbiddenScope"
        "502":
          $ref: "#/components/responses/AgentError"
    post:
      tags: [Servers]
      summary: Добавить сервер
      description: |
        Один сервер за запрос. При 11-м → 400 «Лимит: можно добавить не больше 10 серверов».
        Ссылки (подписка/кабинет/панель) сюда не передаются — только `/api/lab/service-links`.
      operationId: postServersAdd
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ServerAddRequest"
            example:
              target: "8.8.8.8"
              ping_method: icmp
              note: "VPS"
      responses:
        "200":
          description: Создан
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ServerMutateResponse"
        "400":
          description: Валидация / лимит 10 серверов
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorBody"
              example:
                ok: false
                error: "Лимит: можно добавить не больше 10 серверов"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ForbiddenScope"

  /api/lab/servers/update:
    post:
      tags: [Servers]
      summary: Изменить сервер
      operationId: postServersUpdate
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ServerUpdateRequest"
            example:
              server_id: 1
              note: "home"
              ping_method: tcp
              tcp_port: 443
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ServerMutateResponse"
        "400":
          description: Валидация
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorBody"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ForbiddenScope"

  /api/lab/servers/delete:
    post:
      tags: [Servers]
      summary: Удалить сервер
      operationId: postServersDelete
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [server_id]
              properties:
                server_id: { type: integer }
                id: { type: integer, description: alias }
            example: { server_id: 1 }
      responses:
        "200":
          description: Удалён
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  result:
                    type: object
                    properties:
                      deleted: { type: boolean }
                      server_id: { type: integer }
        "400":
          description: Нет/чужой server_id
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorBody"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ForbiddenScope"

  /api/lab/servers/reorder:
    post:
      tags: [Servers]
      summary: Порядок серверов
      operationId: postServersReorder
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                server_id: { type: integer }
                direction: { type: string, enum: [up, down] }
                order:
                  type: array
                  items: { type: integer }
                  description: полный порядок id
            examples:
              step: { value: { server_id: 1, direction: up } }
              full: { value: { order: [3, 1, 2] } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  result:
                    type: object
                    properties:
                      reordered: { type: boolean }
                      servers: { type: array, items: { type: object } }
        "400":
          description: Валидация
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorBody"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ForbiddenScope"

  /api/lab/servers/scan:
    post:
      tags: [Servers]
      summary: Полный перескан серверов
      description: Кулдаун ~15 минут. `mark_only` — только отметить время без пинга.
      operationId: postServersScan
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                mark_only: { type: boolean }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ServersListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ForbiddenScope"
        "429":
          description: Кулдаун
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorBody"
        "502":
          $ref: "#/components/responses/AgentError"
        "503":
          $ref: "#/components/responses/MaintenanceOrAgent"

  /api/lab/servers/scan-one:
    post:
      tags: [Servers]
      summary: Скан одного сервера
      operationId: postServersScanOne
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [server_id]
              properties:
                server_id: { type: integer }
            example: { server_id: 1 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  result: { type: object }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ForbiddenScope"
        "502":
          $ref: "#/components/responses/AgentError"
        "503":
          $ref: "#/components/responses/MaintenanceOrAgent"

  /api/lab/service-links:
    get:
      tags: [ServiceLinks]
      summary: Ссылки мониторинга
      description: |
        Подписка / кабинет / панель — как блок «Ссылки» на ss.latencylab.ru.
        На аккаунт ровно **по одной** ссылке каждого типа.
      operationId: getServiceLinks
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ServiceLinksResponse"
              example:
                ok: true
                result:
                  subscription_url: "https://example.com/sub"
                  cabinet_url: "https://example.com/cabinet"
                  panel_url: null
                  probe_interval_sec: 30
                  down_alert_sec: 180
                  items:
                    - kind: subscription
                      label: "Страница подписки"
                      url: "https://example.com/sub"
                      ok: true
                      checked_at: 1754122600
                      down_since: null
                      history: []
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ForbiddenScope"
        "502":
          $ref: "#/components/responses/AgentError"
    post:
      tags: [ServiceLinks]
      summary: Задать ссылки мониторинга
      description: |
        Поле можно опустить (не менять), передать URL (`http`/`https`, ≤400) или `null`/"" чтобы очистить.
        Только скаляры: **1** subscription_url, **1** cabinet_url, **1** panel_url.
        Массивы / `links[]` → 400.
      operationId: postServiceLinks
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ServiceLinksSetRequest"
            example:
              subscription_url: "https://example.com/sub"
              cabinet_url: "https://example.com/cabinet"
              panel_url: null
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ServiceLinksResponse"
        "400":
          description: Невалидный URL
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorBody"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ForbiddenScope"

  /api/lab/service-links/scan:
    post:
      tags: [ServiceLinks]
      summary: Проверить ссылки сейчас
      operationId: postServiceLinksScan
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ServiceLinksResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ForbiddenScope"
        "502":
          $ref: "#/components/responses/AgentError"
        "503":
          $ref: "#/components/responses/MaintenanceOrAgent"

components:
  securitySchemes:
    ApiKeyBearer:
      type: http
      scheme: bearer
      bearerFormat: ll_
      description: |
        Personal API key из профиля. Префикс `ll_`.
        Пример: `Authorization: Bearer ll_…`

  parameters:
    NodeIdQuery:
      name: node_id
      in: query
      required: false
      schema:
        type: string
        default: orel
      description: ID ноды агента (по умолчанию orel)

  responses:
    Unauthorized:
      description: Нет или невалидный API-ключ
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorBody"
          example:
            ok: false
            error: "Сначала представься или сгинь!"
    ForbiddenScope:
      description: Путь вне allowlist API-ключа
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorBody"
          example:
            ok: false
            error: "API-ключ: доступны сканы (IP/домен//23…/32), VPN-ключ, мои серверы, ссылки мониторинга (подписка/кабинет/панель), status/operators/job, account/stats"
    ForbiddenScanScope:
      description: Payload вне scope (kind/подсеть)
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorBody"
          examples:
            subnetPrefix:
              value:
                ok: false
                error: "API-ключ: подсеть только /23…/32 (например 1.2.3.0/24)"
            kind:
              value:
                ok: false
                error: "API-ключ: разрешены только IP, домен и подсеть /23…/32"
    JobBusy:
      description: Уже есть pending-задача на этой ноде
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/JobBusyBody"
          example:
            ok: false
            error: "Я уже работаю, не отвлекай Нвах!"
            req_id: "550e8400-e29b-41d4-a716-446655440000"
            status: pending
            scan_kind: мультискан
            scan_target: "8.8.8.8"
    AgentError:
      description: Агент вернул ok=false / внутренняя ошибка
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorBodyWithReqId"
          example:
            ok: false
            error: "внутренняя ошибка агента"
            req_id: "550e8400-e29b-41d4-a716-446655440000"
    MaintenanceOrAgent:
      description: Maintenance, нет операторов или агент офлайн
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorBody"
          examples:
            offline:
              value:
                ok: false
                error: "Агент 'orel' офлайн. Модемный сервер не подключён."
            noOps:
              value:
                ok: false
                error: "Нет онлайн-операторов"
    ServiceUnavailable:
      description: Сервис временно недоступен
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorBody"

  schemas:
    OperatorId:
      type: string
      enum: [tmobile, megafon, beeline, mts, t2]
      description: ID оператора модема

    TargetKind:
      type: string
      enum: [ip, domain, subnet]

    ErrorBody:
      type: object
      required: [ok, error]
      additionalProperties: false
      properties:
        ok:
          type: boolean
          const: false
        error:
          type: string

    ErrorBodyWithReqId:
      allOf:
        - $ref: "#/components/schemas/ErrorBody"
        - type: object
          properties:
            req_id:
              type: string
              format: uuid

    HealthResponse:
      type: object
      required: [ok, service]
      properties:
        ok: { type: boolean, const: true }
        service: { type: string }
        uptime_sec: { type: number }
        agents_online:
          type: array
          items: { type: string }
        pending_calls: { type: integer }
        default_node: { type: string }
        agent_connected: { type: boolean }
        any_agent_connected: { type: boolean }
        status_cache_age_sec:
          type: number
          nullable: true
        status_cache_ready: { type: boolean }

    UserPublic:
      type: object
      required: [web_user_id, role]
      properties:
        username: { type: string, nullable: true }
        role: { type: string, enum: [user, moderator, admin] }
        is_admin: { type: boolean }
        is_moderator: { type: boolean }
        is_staff: { type: boolean }
        can_access_db: { type: boolean }
        can_access_stats: { type: boolean }
        web_user_id: { type: integer }

    AuthMeResponse:
      type: object
      required: [ok, result]
      properties:
        ok: { type: boolean, const: true }
        result:
          $ref: "#/components/schemas/UserPublic"

    OperatorRow:
      type: object
      properties:
        id: { $ref: "#/components/schemas/OperatorId" }
        label: { type: string }
        configured: { type: boolean }
        online: { type: boolean }
        interface: { type: string }
        interface_up: { type: boolean }
        wl: { type: boolean }
        health_fail_count: { type: integer }
        queue_count: { type: integer }
        health_rtt_ms: { type: number, nullable: true }
        signal_level: { type: integer, nullable: true }
        signal_csq: { type: integer, nullable: true }
        signal_rsrp_dbm: { type: number, nullable: true }
        signal_source: { type: string, nullable: true }

    StatusResponse:
      type: object
      required: [ok, result]
      properties:
        ok: { type: boolean, const: true }
        result:
          type: object
          properties:
            operators:
              type: array
              items: { $ref: "#/components/schemas/OperatorRow" }
            online_operator_ids:
              type: array
              items: { $ref: "#/components/schemas/OperatorId" }
            queue_total: { type: integer }
            ok_pings_count: { type: integer }
            cached: { type: boolean }
            node_id: { type: string }
            updated_at: { type: number }
            cache_age_sec: { type: number }
            refresh_interval_sec: { type: number }
            viewer: { $ref: "#/components/schemas/UserPublic" }

    OperatorsResponse:
      type: object
      required: [ok, result]
      properties:
        ok: { type: boolean, const: true }
        result:
          type: object
          properties:
            operators:
              type: array
              items: { $ref: "#/components/schemas/OperatorRow" }
            online:
              type: array
              items: { $ref: "#/components/schemas/OperatorId" }
            cached: { type: boolean }
            updated_at: { type: number }
            cache_age_sec: { type: number }

    ResolveRequest:
      type: object
      required: [text]
      properties:
        text:
          type: string
          description: IP, домен или CIDR
          minLength: 1
        node_id:
          type: string
          default: orel

    ResolveResponse:
      type: object
      required: [ok, result]
      properties:
        ok: { type: boolean, const: true }
        result:
          type: object
          required: [kind, target]
          properties:
            kind: { $ref: "#/components/schemas/TargetKind" }
            target: { type: string }
        req_id:
          type: string
          format: uuid

    PingRequest:
      type: object
      required: [operator]
      properties:
        operator: { $ref: "#/components/schemas/OperatorId" }
        target:
          type: string
          description: IP или домен (алиас text)
        text:
          type: string
          description: Алиас target
        kind:
          type: string
          enum: [ip, domain]
        resolved_target: { type: string }
        ping_method:
          type: string
          description: Игнорируется/нормализуется в icmp/tcp (домен → tcp)
          default: icmp/tcp
        node_id:
          type: string
          default: orel

    PingResult:
      type: object
      properties:
        operator: { $ref: "#/components/schemas/OperatorId" }
        kind: { type: string, enum: [ip, domain] }
        ok: { type: boolean }
        method: { type: string, examples: [icmp+tcp, tcp, icmp] }
        icmp_ok: { type: boolean }
        tcp_ok: { type: boolean }
        icmp_rtt_ms: { type: number, nullable: true }
        tcp_rtt_ms: { type: number, nullable: true }
        rtt_ms: { type: number, nullable: true }
        output: { type: string }
        summary: { type: string }
        ping_method: { type: string }
        hint: { type: string }

    PingResponse:
      type: object
      required: [ok, result]
      properties:
        ok: { type: boolean, const: true }
        result: { $ref: "#/components/schemas/PingResult" }
        req_id: { type: string, format: uuid }

    MultiscanRequest:
      type: object
      properties:
        text: { type: string, description: Цель (предпочтительно) }
        target: { type: string, description: Алиас text }
        operators:
          type: array
          items: { $ref: "#/components/schemas/OperatorId" }
          description: Подмножество online; иначе все online
        ping_method:
          type: string
          default: icmp/tcp
        async:
          description: true/1/"true"/"yes"/"on" → pending job
          oneOf:
            - type: boolean
            - type: integer
            - type: string
        node_id:
          type: string
          default: orel
      anyOf:
        - required: [text]
        - required: [target]

    MultiscanIpResultRow:
      type: object
      properties:
        operator: { $ref: "#/components/schemas/OperatorId" }
        kind: { type: string }
        ok: { type: boolean }
        method: { type: string }
        icmp_ok: { type: boolean }
        tcp_ok: { type: boolean }
        icmp_rtt_ms: { type: number, nullable: true }
        tcp_rtt_ms: { type: number, nullable: true }
        rtt_ms: { type: number, nullable: true }
        output: { type: string }

    MultiscanSubnetResultRow:
      type: object
      properties:
        operator: { $ref: "#/components/schemas/OperatorId" }
        kind: { type: string, const: subnet }
        target: { type: string }
        display_range: { type: string }
        scanned_hosts: { type: integer }
        alive_count: { type: integer }
        wire_alive_count: { type: integer }
        icmp_alive: { type: integer }
        icmp_wire: { type: integer }
        tcp_alive: { type: integer }
        tcp_wire: { type: integer }
        available: { type: boolean }
        ok: { type: boolean }
        status_text: { type: string }
        status_emoji: { type: string }
        ping_method: { type: string }
        reachable_ips:
          type: array
          items: { type: string }

    MultiscanResult:
      type: object
      properties:
        kind: { $ref: "#/components/schemas/TargetKind" }
        target: { type: string }
        operators:
          type: array
          items: { $ref: "#/components/schemas/OperatorId" }
        wire_precheck: { type: boolean }
        wire_ok: { type: boolean, nullable: true }
        ping_method: { type: string }
        results:
          type: array
          items:
            oneOf:
              - $ref: "#/components/schemas/MultiscanIpResultRow"
              - $ref: "#/components/schemas/MultiscanSubnetResultRow"
        skipped:
          type: array
          items:
            type: object
            additionalProperties: true
        skipped_line: { type: string }

    MultiscanResponse:
      type: object
      required: [ok, result]
      properties:
        ok: { type: boolean, const: true }
        result: { $ref: "#/components/schemas/MultiscanResult" }
        req_id: { type: string, format: uuid }

    SubnetScanRequest:
      type: object
      required: [operator]
      properties:
        operator: { $ref: "#/components/schemas/OperatorId" }
        target: { type: string, description: CIDR или x.y.z (/24) }
        text: { type: string, description: Алиас target }
        async:
          oneOf:
            - type: boolean
            - type: integer
            - type: string
        node_id:
          type: string
          default: orel

    SubnetScanResult:
      type: object
      properties:
        operator: { $ref: "#/components/schemas/OperatorId" }
        kind: { type: string, const: subnet }
        target: { type: string }
        display_range: { type: string }
        scanned_hosts: { type: integer }
        alive_count: { type: integer }
        wire_alive_count: { type: integer }
        icmp_alive: { type: integer }
        icmp_wire: { type: integer }
        tcp_alive: { type: integer }
        tcp_wire: { type: integer }
        available: { type: boolean }
        ok: { type: boolean }
        status_text: { type: string }
        status_emoji: { type: string }
        ping_method: { type: string }
        reachable_ips:
          type: array
          items: { type: string }
        blocks:
          type: array
          items:
            type: object
            additionalProperties: true

    SubnetScanResponse:
      type: object
      required: [ok, result]
      properties:
        ok: { type: boolean, const: true }
        result: { $ref: "#/components/schemas/SubnetScanResult" }
        req_id: { type: string, format: uuid }

    VpnKeyRequest:
      type: object
      properties:
        uri: { type: string, description: vless:// / hysteria2:// / … }
        text: { type: string, description: Алиас uri }
        outbound:
          type: object
          description: Альтернатива uri (должен содержать protocol или type)
          additionalProperties: true
        label: { type: string }
        operator:
          oneOf:
            - $ref: "#/components/schemas/OperatorId"
            - type: string
              enum: [multiscan, all, ""]
          default: multiscan
        operators:
          type: array
          items: { $ref: "#/components/schemas/OperatorId" }
        node_id:
          type: string
          default: orel
      anyOf:
        - required: [uri]
        - required: [text]
        - required: [outbound]

    VpnKeyResultSingle:
      type: object
      properties:
        operator: { $ref: "#/components/schemas/OperatorId" }
        ok: { type: boolean }
        latency_ms: { type: number, nullable: true }
        detail: { type: string }
        scheme: { type: string }
        target: { type: string }
        label: { type: string }
        summary: { type: string }

    VpnKeyResultMultiscan:
      type: object
      properties:
        multiscan: { type: boolean, const: true }
        operators:
          type: array
          items: { $ref: "#/components/schemas/OperatorId" }
        results:
          type: array
          items: { $ref: "#/components/schemas/VpnKeyResultSingle" }
        scheme: { type: string }
        target: { type: string }
        label: { type: string }

    KindCounts:
      type: object
      properties:
        ip: { type: integer, minimum: 0 }
        domain: { type: integer, minimum: 0 }
        subnet: { type: integer, minimum: 0 }
        vpn_key: { type: integer, minimum: 0 }

    AccountStats:
      type: object
      required: [by_kind, total, day, window, user_id]
      properties:
        by_kind: { $ref: "#/components/schemas/KindCounts" }
        total: { type: integer, minimum: 0 }
        day:
          type: object
          properties:
            total: { type: integer, minimum: 0 }
            by_kind: { $ref: "#/components/schemas/KindCounts" }
            window_sec: { type: integer, example: 86400 }
        window:
          type: object
          required: [used, limit, window_sec]
          properties:
            used: { type: integer, minimum: 0, description: Запросы за текущие сутки UTC }
            limit: { type: integer, example: 100 }
            window_sec: { type: integer, example: 3600 }
        user_id: { type: integer }

    AccountStatsResponse:
      type: object
      required: [ok, result]
      properties:
        ok: { type: boolean, const: true }
        result: { $ref: "#/components/schemas/AccountStats" }

    MonitoredServer:
      type: object
      properties:
        id: { type: integer }
        kind: { type: string, enum: [ip, domain] }
        target: { type: string }
        ping_method: { type: string, enum: [icmp, tcp] }
        tcp_port: { type: [integer, "null"] }
        note: { type: [string, "null"] }
        created_at: { type: integer }
        availability:
          type: object
          additionalProperties:
            type: object
            properties:
              ok: { type: boolean }
              rtt_ms: { type: [number, "null"] }
              checked_at: { type: [integer, "null"] }

    ServersListResult:
      type: object
      properties:
        servers:
          type: array
          items: { $ref: "#/components/schemas/MonitoredServer" }
        service_links: { $ref: "#/components/schemas/ServiceLinks" }
        last_scan_at: { type: [integer, "null"] }
        scan_cooldown_sec: { type: integer }
        scan_available_in: { type: integer }

    ServersListResponse:
      type: object
      required: [ok, result]
      properties:
        ok: { type: boolean, const: true }
        result: { $ref: "#/components/schemas/ServersListResult" }

    ServerAddRequest:
      type: object
      required: [target]
      properties:
        target: { type: string, description: IP или домен }
        ping_method: { type: string, enum: [icmp, tcp], default: icmp }
        tcp_port: { type: integer, minimum: 1, maximum: 65535 }
        note: { type: [string, "null"], maxLength: 30 }

    ServerUpdateRequest:
      type: object
      required: [server_id]
      properties:
        server_id: { type: integer }
        target: { type: string }
        ping_method: { type: string, enum: [icmp, tcp] }
        tcp_port: { type: [integer, "null"] }
        note: { type: [string, "null"], maxLength: 30 }

    ServerMutateResponse:
      type: object
      required: [ok, result]
      properties:
        ok: { type: boolean, const: true }
        result:
          type: object
          properties:
            id: { type: integer }
            kind: { type: string }
            target: { type: string }
            ping_method: { type: string }
            tcp_port: { type: [integer, "null"] }
            note: { type: [string, "null"] }

    ServiceLinkItem:
      type: object
      properties:
        kind: { type: string, enum: [subscription, cabinet, panel] }
        label: { type: string }
        url: { type: string }
        ok: { type: [boolean, "null"] }
        checked_at: { type: [integer, "null"] }
        down_since: { type: [integer, "null"] }
        history:
          type: array
          items:
            type: object
            properties:
              ts: { type: integer }
              ok: { type: boolean }

    ServiceLinks:
      type: object
      properties:
        subscription_url: { type: [string, "null"] }
        cabinet_url: { type: [string, "null"] }
        panel_url: { type: [string, "null"] }
        probe_interval_sec: { type: integer }
        down_alert_sec: { type: integer }
        items:
          type: array
          items: { $ref: "#/components/schemas/ServiceLinkItem" }

    ServiceLinksResponse:
      type: object
      required: [ok, result]
      properties:
        ok: { type: boolean, const: true }
        result: { $ref: "#/components/schemas/ServiceLinks" }

    ServiceLinksSetRequest:
      type: object
      properties:
        subscription_url: { type: [string, "null"] }
        cabinet_url: { type: [string, "null"] }
        panel_url: { type: [string, "null"] }

    JobPendingStart:
      type: object
      required: [ok, req_id, status]
      properties:
        ok: { type: boolean, const: true }
        req_id: { type: string, format: uuid }
        status: { type: string, const: pending }

    JobBusyBody:
      type: object
      required: [ok, error, req_id, status]
      properties:
        ok: { type: boolean, const: false }
        error: { type: string }
        req_id: { type: string, format: uuid }
        status: { type: string, const: pending }
        scan_kind: { type: string }
        scan_target: { type: string }

    JobProgress:
      type: object
      additionalProperties: true
      properties:
        done: { type: integer }
        total: { type: integer }
        alive: { type: integer }

    JobActiveIdle:
      type: object
      required: [ok, status]
      properties:
        ok: { type: boolean, const: true }
        status: { type: string, const: idle }

    JobActivePending:
      type: object
      required: [ok, status, req_id]
      properties:
        ok: { type: boolean, const: true }
        status: { type: string, const: pending }
        req_id: { type: string, format: uuid }
        scan_kind: { type: string }
        scan_target: { type: string }
        progress: { $ref: "#/components/schemas/JobProgress" }

    JobPollPending:
      type: object
      required: [ok, status, req_id]
      properties:
        ok: { type: boolean, const: true }
        status: { type: string, const: pending }
        req_id: { type: string, format: uuid }
        progress: { $ref: "#/components/schemas/JobProgress" }

    JobPollDone:
      type: object
      required: [ok, status, req_id, result]
      properties:
        ok: { type: boolean, const: true }
        status: { type: string, const: done }
        req_id: { type: string, format: uuid }
        result:
          description: Полезная нагрузка агента (ping/multiscan/subnet/vpn)
          oneOf:
            - $ref: "#/components/schemas/PingResult"
            - $ref: "#/components/schemas/MultiscanResult"
            - $ref: "#/components/schemas/SubnetScanResult"
            - $ref: "#/components/schemas/VpnKeyResultSingle"
            - $ref: "#/components/schemas/VpnKeyResultMultiscan"
            - type: object
              additionalProperties: true
        progress: { $ref: "#/components/schemas/JobProgress" }

    JobPollCancelled:
      type: object
      required: [ok, status, req_id]
      properties:
        ok: { type: boolean, const: false }
        status: { type: string, const: cancelled }
        req_id: { type: string, format: uuid }
        error: { type: string }

    JobPollError:
      type: object
      required: [ok, status, req_id, error]
      properties:
        ok: { type: boolean, const: false }
        status: { type: string, const: error }
        req_id: { type: string, format: uuid }
        error: { type: string }

    CancelRequest:
      type: object
      required: [req_id]
      properties:
        req_id:
          type: string
          format: uuid
        node_id:
          type: string
          default: orel

    CancelResponse:
      type: object
      required: [ok, result]
      properties:
        ok: { type: boolean, const: true }
        result:
          type: object
          properties:
            cancelled: { type: boolean }
        req_id: { type: string, format: uuid }
