Перейти к основному содержимому

Взаимодействие netgap-gateway и netgap-executor

Эта статья дополняет Конфигурирование внутреннего транспорта и разбирает механику взаимодействия компонентов до уровня отдельных вызовов и шагов TLS-рукопожатия: как запрос попадает из netgap-gateway в netgap-executor, как ответ возвращается обратно, кто кого вызывает, кто какие сертификаты предъявляет, кто и что проверяет, и какие остаточные риски есть у этой схемы.

Роли и направление соединений

Ключевое архитектурное свойство: все внутренние соединения инициирует netgap-executor. Шлюз никогда не открывает соединений в сторону исполнителя и вообще не знает его сетевого адреса — в конфигурации шлюза нет ни одного параметра с адресом исполнителя. Запросы доставляются по pull-модели: исполнитель сам опрашивает шлюз и забирает работу.

КомпонентРоль во внутреннем транспортеПубликуемые/вызываемые точки
netgap-gatewayTLS-сервер (секция internal_transport.server с подсекциями request_bridge и response_bridge)публикует мост запросов (по умолчанию 0.0.0.0:50051, опция request_bridge_listen_address) и мост ответов (по умолчанию 0.0.0.0:50052, опция response_bridge_listen_address); каждый мост авторизует клиентов по своему allowlist
netgap-executorTLS-клиент (секция internal_transport.client с подсекциями request_bridge и response_bridge)вызывает обе точки шлюза (опции request_bridge_address и response_bridge_address, в примере поставки — 127.0.0.1:50051 и 127.0.0.1:50052); для каждой точки ожидает свой ролевой SPIFFE ID сервера

SPIFFE ID в Netgap — атрибут роли (операции), а не компонента целиком: spiffe://<trust-domain>/<component>/<instance>/<role>. Исполнитель выступает в двух клиентских ролях — http1-request-reader (забирает запросы) и http1-response-writer (доставляет ответы), шлюз — в двух серверных: http1-request-bridge и http1-response-bridge. Один сертификат может нести несколько ролевых ID в SAN (так делает dev-PKI), но авторизуется каждое соединение отдельно — по роли того моста, к которому оно установлено.

Оба моста — gRPC-серверы (tonic) поверх mTLS (TLS 1.3, ALPN h2). Внешний HTTP-порт шлюза (listen_address/listen_port, в примере поставки 0.0.0.0:9090) и целевой сервис исполнителя (target_service_address, в примере 127.0.0.1:8080) во внутренний транспорт не входят — это внешние границы системы.

Такое направление соединений выбрано намеренно: в топологии logical air gap исполнитель стоит в доверенном сегменте, шлюз — на границе с недоверенным. Соединения открываются только «изнутри наружу» (из доверенного сегмента к шлюзу), и на сетевом уровне достаточно разрешить исходящие соединения исполнителя к двум портам шлюза, не открывая ни одного входящего пути в доверенный сегмент.

Контракт мостов (gRPC)

Оба контракта минимальны и определены в proto-файлах шлюза (libs/netgap_gateway/proto/). Мост запросов — выдача очередного запроса по требованию:

service RequestBridgeApi {
rpc GetNextRequest(GetNextRequestRequest) returns (GetNextRequestResponse);
}

message GetNextRequestRequest {}

message GetNextRequestResponse {
GatewayRequest request = 1; // отсутствует, если очередь пуста
}

message GatewayRequest {
string correlation_id = 1; // связывает запрос с ожидающим соединением
repeated Header headers = 2;
bytes body = 3;
string path = 4;
string method = 5;
repeated Header trace_context = 6; // W3C traceparent/tracestate для сквозной трассировки
}

Мост ответов — возврат ответа целевого сервиса:

service ResponseBridgeApi {
rpc SendResponse(SendResponseRequest) returns (SendResponseResponse);
}

message SendResponseRequest {
string correlation_id = 1; // тот же идентификатор, что был в GatewayRequest
repeated Header headers = 2;
bytes body = 3;
uint32 status_code = 4;
repeated Header trace_context = 5;
double target_processing_time = 6; // время вызова целевого сервиса, для метрик шлюза
}

message SendResponseResponse {
bool sent = 1; // false: соединение не найдено или уже истекло по таймауту
}

correlation_id генерируется шлюзом (монотонный счётчик вида request-1, request-2, …) и является единственной связкой между запросом и ответом: мосты работают на разных TCP-соединениях, и никакой «сессии запроса» между ними нет.

Жизненный цикл запроса

Поведенческие детали, важные для эксплуатации:

  • Очередь и пул — в памяти шлюза. RequestQueue — FIFO-очередь принятых запросов; ConnectionPool — словарь correlation_id → TCP-соединение клиента. Перезапуск шлюза теряет и очередь, и открытые соединения.
  • Таймаут ожидания ответа — 30 секунд (константа CONNECTION_TIMEOUT в шлюзе). Фоновый поток каждую секунду удаляет истёкшие соединения из пула. Если ответ пришёл позже, мост ответов возвращает sent: false, исполнитель фиксирует это как ошибку (response did not delivered), клиент к этому моменту уже не ждёт ответа.
  • Опрос пустой очереди повторяется каждые poll_interval_ms (по умолчанию 50 мс) — это верхняя добавка к латентности «холодного» запроса.
  • Фильтр HTTP-запросов проверяется дважды. Если включена секция http_filter (Фильтр HTTP-запросов), шлюз проверяет метод и путь до постановки запроса в очередь, а исполнитель — до вызова целевого сервиса. Заблокированный запрос не занимает место в очереди и не доходит до целевого сервиса: клиент получает 403 или 405, блокировка уходит в поток аудита и в метрики блокировок.
  • Ошибка вызова целевого сервиса не роняет цикл: исполнитель возвращает шлюзу ответ со статусом 404 с пустым телом, событие уходит в поток аудита.
  • Ошибка связи с мостом (обрыв, перезапуск шлюза): исполнитель ждёт retry_delay_ms (по умолчанию 2000 мс) и заново устанавливает mTLS-канал; компонент здоровья gateway_connection на точке /health переводится в NotReady до восстановления.
  • Сквозная трассировка: поле trace_context переносит W3C traceparent/tracestate через оба моста, поэтому в Jaeger запрос виден одной трассой «шлюз → исполнитель → целевой сервис» (см. Трассировка).

mTLS: кто, что и когда предъявляет и проверяет

Откуда берутся сертификаты

Никто ничего не «запрашивает» во время работы: ни CSR, ни обращений к УЦ, ни OCSP-запросов, ни чтения файлов с диска в момент рукопожатия не происходит. Все сертификаты и ключи доставлются значениями в YAML-документе через stdin при старте процесса (ADR 0010) и загружен в память один раз:

Параметр конфигурацииУ шлюзаУ исполнителя
internal_transport.identity.certificate_chain_pemсерверный сертификат, предъявляется каждому подключающемуся исполнителюклиентский сертификат, предъявляется шлюзу по его запросу
internal_transport.identity.private_key_pemключ серверного сертификата (в памяти защищён от журналирования и затирается при освобождении)ключ клиентского сертификата
internal_transport.trust.trusted_roots_pemкорни, по которым проверяется цепочка клиентакорни, по которым проверяется цепочка сервера
internal_transport.trust.crl_pemCRL, применяемый к каждому клиентскому рукопожатиюCRL, применяемый к каждому серверному рукопожатию
internal_transport.server.request_bridge.allowed_client_spiffe_idsролевые SPIFFE ID, которым разрешено читать очередь запросов (…/http1-request-reader)
internal_transport.server.response_bridge.allowed_client_spiffe_idsролевые SPIFFE ID, которым разрешено доставлять ответы (…/http1-response-writer)
internal_transport.client.request_bridge.expected_server_spiffe_idролевой ID, который должен предъявить мост запросов (…/http1-request-bridge)
internal_transport.client.response_bridge.expected_server_spiffe_idролевой ID, который должен предъявить мост ответов (…/http1-response-bridge)

TLS-конфигурации собираются один раз при старте (fail-fast: некорректный сертификат или пустой allowlist — отказ запуска до открытия сетевых точек), и у каждого моста она своя: шлюз строит две серверные конфигурации rustls (отдельно для 50051 и 50052, с разными списками разрешённых клиентов), исполнитель — две клиентские (с разными ожидаемыми ролевыми ID сервера). Ключ и сертификат при этом общие — различаются только правила авторизации.

Рукопожатие шаг за шагом

Исполнитель — инициатор TCP-соединения и TLS-клиент; шлюз — TLS-сервер. Рукопожатие TLS 1.3 выполняется библиотекой rustls, других реализаций TLS во внутреннем транспорте нет:

Пояснения к шагам:

  • SNI не участвует в доверии. Клиент всегда отправляет техническое имя netgap.internal (константа INTERNAL_SNI в общем транспортном слое), а несоответствие DNS-имени в сертификате сервера намеренно не считается ошибкой: идентичность сервера определяется исключительно SPIFFE ID из SAN URI. Это позволяет обращаться к шлюзу по любому IP или имени, не перевыпуская сертификаты.
  • Проверка симметрична, но несимметрично сконфигурирована. Сервер сверяет множество предъявленных клиентом ID со списком своего моста (allowed_client_spiffe_ids, deny by default — пустой список отклоняется ещё на старте процесса): достаточно одного совпадения. Клиент требует, чтобы среди ID сервера было единственное ожидаемое значение (expected_server_spiffe_id) для этого же моста. Сертификат «того же УЦ, но другого компонента» отклоняется обеими сторонами.
  • Авторизация раздельна по мостам. Один и тот же сертификат может быть принят одним мостом и отклонён другим: клиент только с ролью http1-request-reader пройдёт на :50051, но не на :50052, и наоборот — с http1-response-writer. Сертификат с обеими ролями в SAN (вариант dev-PKI и поставки по умолчанию) проходит оба моста; разнести роли по разным сертификатам можно без изменения схемы конфигурации.
  • CRL применяется при каждом рукопожатии обеими сторонами. Соединения между мостами долгоживущие (gRPC-канал переиспользуется для многих вызовов), поэтому фактическая проверка отзыва происходит при установке и каждой переустановке канала — например, после retry_delay_ms при обрыве.
  • Отказ — до прикладных данных. Любая неудачная проверка завершает рукопожатие ошибкой InvalidCertificate; соединение не доходит до gRPC-сервера. Отклонённые рукопожатия журналируются в поток аудита (target_id: Audit, см. События) с причиной и предъявленной идентичностью, например:
    • internal transport: client certificate rejected during the handshake — невалидная цепочка, срок, EKU или отзыв;
    • internal transport: client identity rejected, no valid SPIFFE ID — в SAN нет ни одного валидного URI spiffe://… (или есть дубликаты);
    • internal transport: none of the client SPIFFE IDs is in the allowed list, connection rejected — идентичности валидны, но ни одна из них не авторизована на этом мосту (например, http1-request-reader постучался в мост ответов);
    • internal transport: server SPIFFE IDs do not include the expected identity, connection rejected — исполнитель отказался от подставного сервера или от моста с чужой ролью;
    • internal transport handshake rejected — итоговое событие сервера с адресом источника.

Идентичность после рукопожатия

Прикладной код мостов не выполняет собственных проверок TLS (ADR 0009, п. 10): транспортный слой передаёт ему уже проверенную идентичность (InternalConnectInfo с адресом и SPIFFE ID контрагента), и она используется только для журналирования — например, мост ответов пишет peer_spiffe_id в каждое аудит-событие приёма ответа. Повторной авторизации на уровне вызова gRPC нет: право вызвать мост эквивалентно праву пройти рукопожатие.

Проверка «конкретного отправителя и получателя по id» в итоге устроена так:

  • шлюз ↔ конкретная операция исполнителя: забрать запрос может только клиент с ID из server.request_bridge.allowed_client_spiffe_ids (например, spiffe://netgap.prod/netgap-executor/dc1/http1-request-reader), доставить ответ — только клиент с ID из server.response_bridge.allowed_client_spiffe_ids (…/http1-response-writer);
  • исполнитель ↔ конкретный мост шлюза: исполнитель разговаривает только с сервером, среди SAN URI которого есть ожидаемый ролевой ID этого моста (…/netgap-gateway/dc1/http1-request-bridge или …/http1-response-bridge); DNS-имя, IP и SNI не влияют на это решение;
  • изоляция инсталляций: SPIFFE ID имеет вид spiffe://<trust-domain>/<component>/<instance>/<role>, а доверие ограничено корнями из trusted_roots_pem — сертификат другой инсталляции (другой trust-домен, другой корень) не проходит уже проверку цепочки.

Что схема гарантирует и какие остаточные риски есть

Закрываемые угрозы

УгрозаЧем закрыта
Чтение/модификация трафика в канале (MITM)TLS 1.3 c AEAD-шифрами; downgrade до TLS 1.2 и plaintext-режим отсутствуют в коде
Подключение постороннего клиента к мостамобязательный клиентский сертификат + цепочка + CRL + явный allowlist SPIFFE ID у каждого моста (deny by default)
Выход авторизованного клиента за границы своей операции (чтение очереди ролью для ответов)раздельные allowlist мостов: сертификат без роли http1-request-reader не пройдёт рукопожатие на :50051, без http1-response-writer — на :50052
Перенаправление исполнителя на подставной «шлюз» (DNS/ARP-спуфинг, подмена адреса в инфраструктуре)проверка expected_server_spiffe_id соответствующего моста: валидный сертификат того же УЦ, но с другой идентичностью или чужой ролью, отклоняется
Сертификат чужой инсталляции Netgaptruststore ограничен корнями из конфигурации; системное хранилище ОС не используется
Использование украденного, но уже отозванного сертификатаCRL проверяется при каждом рукопожатии обеими сторонами
Подмена сертификата через окружение/файлы на хостесертификаты и ключи принимаются только значением через stdin; процесс не читает ключи с диска и из переменных окружения

Остаточные риски и практические следствия

Ни одна из перечисленных ниже позиций не является «дырой» в протоколе — это границы модели, которые нужно учитывать при развёртывании:

  • Компрометация приватного ключа компонента. Владелец ключа исполнителя полноценно неотличим от исполнителя. Окно атаки — до попадания сертификата в CRL и перезапуска обоих компонентов с обновлённым crl_pem (горячей перечитки CRL нет — ротация любого материала выполняется перезапуском). Штатная компенсация — короткоживущие leaf-сертификаты (ADR 0009 рекомендует часы–дни) и автоматизация выпуска (см. Автоматизация выпуска).
  • Гранулярность авторизации — мост, а не отдельный вызов. Разделение «читать очередь» / «доставлять ответы» реализовано на уровне моста и проверяется на рукопожатии; внутри одного моста дополнительной авторизации по конкретному gRPC-вызову или по correlation_id нет.
  • Разделение ролей стоит ровно столько, сколько выданные сертификаты. Если один сертификат несёт и http1-request-reader, и http1-response-writer (так делает dev-PKI и конфигурация по умолчанию), то компрометация его ключа даёт доступ сразу к обеим операциям. Чтобы разделение было реальным, выпускайте отдельные сертификаты на роль — схема конфигурации этого не требует менять.
  • correlation_id не привязан к получателю запроса. Идентификаторы предсказуемы (монотонный счётчик), а мост ответов доставляет ответ любому припаркованному соединению с совпадающим correlation_id. Внутри множества авторизованных клиентов один исполнитель технически может ответить на запрос, выданный другому. Это осознанный компромисс: границей доверия является allowlist моста ответов, все его участники считаются одинаково доверенными.
  • Порты мостов доступны для TCP-подключений до аутентификации. Неавторизованный клиент не получит ни байта прикладных данных, но сам TCP-accept и криптографическая работа рукопожатия происходят до отклонения — это поверхность для DoS. Порты 50051/50052 не должны публиковаться в недоверенную сеть шире необходимого; ограничивайте доступ сетевой фильтрацией до единственного адреса исполнителя.
  • Шлюз буферизует запросы в памяти. Очередь запросов не ограничена по размеру, тело запроса читается целиком до постановки в очередь. Лавина крупных запросов на внешний порт увеличивает потребление памяти шлюза; мониторьте netgap_gateway_queue_size и netgap_gateway_connection_pool_size (см. Справочник метрик).
  • Ответ позже 30 секунд теряется для клиента. CONNECTION_TIMEOUT фиксирован; медленный целевой сервис приводит к sent: false и разрыву клиентского соединения без ответа. Таймаут HTTP-вызова целевого сервиса у исполнителя — тоже 30 секунд.
  • Секрет живёт в конфигурационном документе. private_key_pem проходит через пайплайн доставки конфигурации (оркестратор, secret-менеджер, скрипт запуска). Транспорт защищает канал между компонентами, но защита самого документа — ответственность окружения: доставляйте секцию internal_transport из хранилища секретов и не сохраняйте собранный документ на диск.
  • Dev-PKI — только для стендов. Сертификат netgap-certgen живёт 30 дней и не имеет инфраструктуры отзыва; в продакшене используйте PKI организации (см. Private PKI).

Ссылки