Взаимодействие netgap-gateway и netgap-executor
Эта статья дополняет Конфигурирование внутреннего транспорта и разбирает механику взаимодействия компонентов до уровня отдельных вызовов и шагов TLS-рукопожатия: как запрос попадает из netgap-gateway в netgap-executor, как ответ возвращается обратно, кто кого вызывает, кто какие сертификаты предъявляет, кто и что проверяет, и какие остаточные риски есть у этой схемы.
Роли и направление соединений
Ключевое архитектурное свойство: все внутренние соединения инициирует netgap-executor. Шлюз никогда не открывает соединений в сторону исполнителя и вообще не знает его сетевого адреса — в конфигурации шлюза нет ни одного параметра с адресом исполнителя. Запросы доставляются по pull-модели: исполнитель сам опрашивает шлюз и забирает работу.
| Компонент | Роль во внутреннем транспорте | Публикуемые/вызываемые точки |
|---|---|---|
netgap-gateway | TLS-сервер (секция 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-executor | TLS-клиент (секция 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переносит W3Ctraceparent/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_pem | CRL, применяемый к каждому клиентскому рукопожатию | 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 нет ни одного валидного URIspiffe://…(или есть дубликаты);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 соответствующего моста: валидный сертификат того же УЦ, но с другой идентичностью или чужой ролью, отклоняется |
| Сертификат чужой инсталляции Netgap | truststore ограничен корнями из конфигурации; системное хранилище ОС не используется |
| Использование украденного, но уже отозванного сертификата | 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).
Ссылки
- Конфигурирование внутреннего транспорта — секция
internal_transport, требования к сертификатам, генерация dev-PKI. - ADR 0009: Доверенная среда передачи данных между компонентами — решение и его правила (нумерация пунктов, на которую ссылается эта статья).
- ADR 0010: Доставка сертификатов в компоненты через конфигурацию — почему сертификаты передаются значением через
stdin. - mTLS в rustls — как устроены верификаторы rustls, поверх которых реализована авторизация по SPIFFE ID.
- Идентичность клиента — почему SPIFFE ID, а не CN/DNS.
- Наблюдаемость — поток аудита, трассировка и метрики, упомянутые в статье.