diff --git a/doc/etcp_router_arch.md b/doc/etcp_router_arch.md index ddc1cc8f..02ef2dbb 100644 --- a/doc/etcp_router_arch.md +++ b/doc/etcp_router_arch.md @@ -1,329 +1,149 @@ -> Исторический документ. Актуальная реализация и wire-формат описаны в [ETCP Router](../src/routing_layer/etcp_router_doc.md). +# Архитектура ETCP Router -# ETCP Router Architecture +Маршрутизатор связывает сервис с удалённым узлом через группу, не привязывая логическую +сессию к одному физическому ETCP. Ключ канала — `(group_id, remote_node_id, svc_id)`. +Wire-формат и сервисный API подробно описаны в [etcp_router_doc.md](../src/routing_layer/etcp_router_doc.md), +объявления — в [etcp_router.h](../src/routing_layer/etcp_router.h). -## Структуры данных +## Отправка и приём -``` -ETCP_ROUTER_CONN (один на пару remote_node_id + svc_id) -┌─────────────────────────────────────────────────────┐ -│ ll_entry (хеш-индекс: remote_node_id[8] + svc_id[1]) │ -│ remote_node_id: u64 │ -│ svc_id: u8 │ -│ last_dgram_ts: u16 (timestamp последней датаграммы) │ -│ │ -│ tx_seq: u32 следующий seq для отправки │ -│ rx_seq: u32 ожидаемый seq для сборки │ -│ tx_acked: u32 сколько наших пакетов подтвердил remote │ -│ last_sent_ack_seq: u32 последний отправленный ACK │ -│ │ -│ recv_q: ll_queue* очередь reorder (хеш по seq[4]) │ -│ send_q: ll_queue* очередь при переполнении inflight │ -│ │ -│ ack_timer: void* периодический 100ms │ -│ idle_ack_timer: void* idle таймаут 500ms │ -│ send_waiter: queue_waiter_handle waiter на send_input_q │ -│ watchdog_timer: void* проверка инварианта 500ms │ -│ send_blocked: u8 inflight полон │ -└─────────────────────────────────────────────────────┘ - -SVC_ROUTE_HDR (22 байта) -┌──────┬────────────────┬────────────────┬───────┬────────┬──────────┐ -│ cmd │ dst_node_id │ src_node_id │ seq │ svc_id │ payload │ -│ u8 │ u64 │ u64 │ u32 │ u8 │ ... │ -└──────┴────────────────┴────────────────┴───────┴────────┴──────────┘ - - seq в data-пакетах = tx_seq (счетчик отправленных) - seq в ACK-пакетах = rx_seq (ожидаемый следующий) - CLOSE-флаг = 0x80000000 в seq, RST-флаг = 0x40000000 +```mermaid +flowchart TD + Producer["Сервис: payload + mode"] --> Send["send_q: plaintext, seq, mode"] + Send --> Session{"peer_reset_id подтверждён?"} + Session -- нет --> Hello["HELLO / challenge / confirm"] + Hello --> Session + Session -- да --> Route["Текущий маршрут: conn_mgr → BGP, либо прямой глобальный ETCP"] + Route --> Wait["Waiter физической send_input_q"] + Wait --> Encode["route_crypto_encode: encrypt → sign"] + Encode --> Inflight["inflight_q: неизменяемое тело без visited-list"] + Inflight --> Path["Добавить source ID"] + Path --> ETCP["etcp_send / normalizer"] + Inflight -- RTO 300 ms --> Wait + ETCP --> Network["Сеть / транзитные узлы"] + Network --> Check["Проверить visited-list"] + Check --> Destination{"dst=self?"} + Destination -- нет --> Transit["Добавить self один раз, BGP next hop / transit FIFO"] + Transit --> Network + Destination -- да --> Decode["Crypto policy + verify/decrypt + проверка эпох"] + Decode --> Incoming["incoming_q: plaintext"] + Incoming --> Reorder["recv_q: reorder по seq"] + Reorder --> Ready{"receive_ready разрешает?"} + Ready -- нет --> Busy["Сохранить пакет, ACK RECEIVE_BUSY"] + Busy --> Resume["resume_receive: отложенное продолжение"] + Resume --> Reorder + Ready -- да --> Callback["Callback сервиса получает entry"] + Callback --> Ack["rx_seq++ → ACK текущей границы"] + Ack --> Release["Отправитель снимает подтверждённый inflight"] ``` -## Send Path (отправка данных) +Каждая кодограмма имеет самостоятельный seq; это не байтовый поток. +send_q ограничена 64 пакетами при обычной отправке, `force` обходит этот порог. +Inflight по умолчанию — 256, при minRTT probe — 4. Получатель ограничивает reorder +окном max_inflight и суммарным размером очередей max_inflight+1 записей. -``` - etcp_route_send(inst, dst, entry) - etcp_router_conn_send(rconn, data, len) - │ - ▼ - ┌─────────────────────┐ - │ rconn = find/get │ поиск по (dst, svc_id) - │ авто-создание если │ или etcp_router_conn_get() - │ ещё нет │ - └────────┬────────────┘ - │ - ▼ - ┌──────────────────────────────┐ - │ router_enqueue_send(rconn) │ всегда через send_q (FIFO) - │ if send_q >= 64 && !force: │ → вернуть -1 (backpressure) - │ return -1 │ - └──────┬───────────────────────┘ - │ копия в send_q + router_send_kick() - ▼ - ┌─────────────────────────────────────────┐ - │ router_send_kick(rconn) │ - │ closed/no_route → return │ - │ send_q пуст → return │ - │ inflight >= limit → send_blocked=1, ret │ - │ waiter уже ждёт → return │ - │ нет conn → set_no_route │ - │ queue_waiter_wait(send_input_q, │ - │ &send_waiter, drain_cb) │ - └──────┬───────────────────────────────────┘ - │ send_input_q опустел (round-robin: FIFO waiter-ов) - ▼ - ┌──────────────────────────────┐ - │ router_send_drain_cb(rconn) │ шлём ОДИН пакет - │ e = send_q.pop() │ - │ router_send_one_flags(e) │ - │ → tx_seq++ │ - │ → etcp_send(conn, entry) │ - │ если send_q ещё непуст: │ - │ inflight полон → blocked=1│ - │ иначе → queue_waiter_wait │ (в хвост → round-robin) - │ если пуст → send_blocked=0 │ - └──────────────────────────────┘ +Все события выполняются в uasync-потоке. Штатный normalizer откладывает physical waiters, +а callbacks производителей send_q могут выполняться синхронно при освобождении места. +Поэтому код отправки повторно проверяет состояние/эпохи после callback. - Повторный запуск drain: - • приход ACK → router_handle_ack → kick - • маршрут появился → router_no_route_retry_cb → kick - • рост inflight-лимита → router_update_inflight_limit → kick - • страховка → router_send_watchdog_cb (500ms) → DEBUG_ERROR + kick +## Эпохи логического канала + +Полный сетевой пакет: + +`[57-byte SVC_ROUTE_HDR][payload][signature?][visited_ids:8*N][count:1]` + +Числа в заголовке — native endian. flags — отдельный байт; биты seq не заняты флагами. +Заголовок включает group/src/dst/service, seq/timestamp и поля reset_id/peer_reset_id/challenge. +visited-list содержит 1..64 ID, находится вне подписи/шифрования и проверяется на каждом переходе. + +Первичное согласование двух сторон имеет вид: + +```mermaid +sequenceDiagram + participant A as Узел A, local=a + participant B as Узел B, local=b + A->>B: HELLO(reset=a, peer=0) + B->>A: CHALLENGE(reset=b, peer=a, cB) + A->>B: CONFIRM(reset=a, peer=b, cB) + A->>B: CHALLENGE(reset=a, peer=b, cA) + B->>A: CONFIRM(reset=b, peer=a, cA) + Note over A,B: Каждый узел принимает peer ID только после ответа на свой pending challenge ``` -## Receive Path (приём данных) +Получение HELLO/CHALLENGE с неизвестной эпохой само по себе не сбрасывает работающий канал. +Переставленные/старые CONFIRM не совпадают с pending ID/challenge и игнорируются. +Новые challenge ограничены интервалом 300 ms; первичный HELLO повторяется без отдельного timeout. -``` - etcp_router_recv_cb(conn, entry) - │ - ▼ - ┌──────────────────────┐ - │ dst == node_id? │ - └──┬───────────────┬───┘ - │ YES │ NO (транзит) - ▼ ▼ - ┌────────────────────┐ ┌──────────────────────────┐ - │ Мы — целевая нода │ │ Транзит: найти next hop │ - └────────┬───────────┘ │ route_bgp_find_conn_for_ │ - │ │ node(bgp, hdr.dst) │ - ▼ │ etcp_send(next, entry) │ - ┌────────────────────┐ └──────────────────────────┘ - │ pl_len == 0? │ - └──┬───────────┬─────┘ - │ YES │ NO (данные) - ▼ ▼ - ┌─────────┐ ┌─────────────────────────┐ - │ CLOSE/ │ │ rconn = find/get │ - │ RST? │ │ (авто-создание если нет) │ - └──┬──┬───┘ └───────────┬─────────────┘ - │ │ │ - │ │ NO (ACK) ▼ - │ ▼ ┌───────────────────────────────────────┐ - │ ┌──────────┐│ Проверка границ seq: │ - │ │ tx_acked ││ d = seq - rx_seq │ - │ │ = hdr.seq││ |d| < ROUTER_MAX_INFLIGHT? │ - │ │ (только ││ │ - │ │ вперёд) ││ Дубликат? │ - │ │ kick ││ rx_seq - seq > 0 ИЛИ │ - │ └──────────┘│ seq уже в recv_q? │ - │ └──────┬─────────────┬──────────────────┘ - │ │ OK │ out-of-bounds / dup - │ ▼ ▼ - │ ┌──────────────┐ ┌─────────┐ - │ │ recv_q.put() │ │ DROP │ - │ │ хеш по seq │ └─────────┘ - │ └──────┬───────┘ - │ │ - │ ▼ - │ ┌──────────────────────┐ - │ │ seq == rx_seq? │ - │ └──┬───────────────┬───┘ - │ │ YES │ NO - │ ▼ │ - │ ┌──────────────┐ │ - │ │ try_assembly │ │ - │ │ while next в │ │ - │ │ recv_q: │ │ - │ │ deliver() │ │ - │ │ rx_seq++ │ │ - │ └──────┬───────┘ │ - │ │ │ - │ ▼ │ - │ ┌──────────────────────┴──┐ - │ │ router_deliver() │ - │ │ вызывает callback │ - │ │ сервиса (svc_id) │ - │ │ entry: [svc_id|payload] │ - │ └──────────────┬──────────┘ - │ │ - ▼ ▼ - ┌────────────────────────────────────┐ - │ router_schedule_ack(rconn) │ - │ запускает ack_timer (100ms) если │ - │ ещё не запущен │ - └────────────────────────────────────┘ -``` +| Событие | Что сохраняется | Что меняется | +| --- | --- | --- | +| Смена ETCP/next hop | Логические эпохи, seq, очереди | Физический waiter/маршрут | +| Первое подтверждение peer | Накопленная send_q | Заполняется peer_reset_id, начинается отправка | +| Подтверждённый рестарт peer | Собственный reset_id | Очереди, seq, RTT/метрики; уведомление сервиса | +| Явный conn_restart | Ключ group/peer/service и адрес rconn | Новый reset_id, пустое состояние; затем callback и HELLO | +| conn_close | Только данные, переданные во владение callback | Запись снимается с реестра, ресурсы освобождаются | -``` - CLOSE / RST ветка: - ┌────────────────────────────────────────┐ - │ pl_len == 0 && seq & CLOSE/RST │ - │ → router_close_and_notify(rconn) │ - │ ├─ router_send_close_to_service() │ - │ │ (callback с NULL conn) │ - │ ├─ отправить CLOSE удалённой стороне│ - │ ├─ отмена всех таймеров │ - │ ├─ очистка recv_q + send_q │ - │ └─ удаление из inst->router_conns │ - └────────────────────────────────────────┘ -``` +Сервис возобновляет прикладную синхронизацию после сброса; старые гарантии доставки через reset не продолжаются. +Готовность router означает наличие подтверждённого peer ID. Это отдельное состояние +от физического UP и READY BGP-группы. -## ACK Mechanism +## Подтверждение и пауза потребителя -``` - ┌─────────────────────────────────────────┐ - │ router_schedule_ack(rconn) │ - │ (вызывается при получении данных) │ - └────────────────┬────────────────────────┘ - │ - ┌────────────────▼────────────────────────┐ - │ если idle_ack_timer активен — отменить │ - │ если ack_timer не активен — запустить │ - │ ROUTER_ACK_INTERVAL_TB = 1000 (100ms) │ - └────────────────┬────────────────────────┘ - │ - ▼ (через 100ms) - ┌─────────────────────────────────────────┐ - │ router_ack_timer_cb() │ - │ │ - │ if rx_seq != last_sent_ack_seq: │ - │ router_send_ack(rconn) │ - │ last_sent_ack_seq = rx_seq │ - │ перезапустить ack_timer │ - │ else: │ - │ запустить idle_ack_timer (500ms) │ - └────────────────┬────────────────────────┘ - │ - ▼ (через 500ms) - ┌─────────────────────────────────────────┐ - │ router_idle_ack_timer_cb() │ - │ │ - │ if rx_seq != last_sent_ack_seq: │ - │ router_send_ack(rconn) (досылка) │ - │ last_sent_ack_seq = rx_seq │ - └─────────────────────────────────────────┘ +DATA проходят crypto policy и verify/decrypt до изменения reorder/rx_seq. +Сервис получает `[svc:1][src:8][dst:8][rx_flags:1][group:8][payload]`, всего 26 байт заголовка. +Его callback владеет entry, ACK продвигается после успешной передачи callback. - router_send_ack(rconn): - hdr.seq = rx_seq - hdr.svc_id = svc_id - pl_len = 0 → это ACK-пакет - etcp_send(conn, entry) -``` +ACK подтверждает только диапазон до tx_sent, в модульном порядке uint32. +При продвижении снимаются существующие inflight-записи и один раз учитываются исходные payload bytes. -``` - Обработка входящего ACK: - ┌────────────────────────────────────────┐ - │ pl_len == 0 && нет CLOSE/RST флагов │ - │ │ - │ if seq >= tx_acked: // только вперёд │ - │ tx_acked = seq // обновить │ - │ else: │ - │ DEBUG_WARN "stale ACK" │ - │ │ - │ if send_blocked: │ - │ send_blocked = 0 │ - │ router_send_kick(rconn) │ - └────────────────────────────────────────┘ -``` +RECEIVE_BUSY не подтверждает отложенные DATA, но поддерживает живость ожидающего диска/буфера сервиса. +Даже при прежнем seq допустимый BUSY обновляет отсчёт RTO и останавливает новую отправку/проход ретрансмитов. +Обычный повторный ACK без продвижения этого не делает. Снятие BUSY возобновляет send_q. -## Inflight Control +receive_ready — предикат без I/O и изменения/закрытия роутера. При 0 пакет сохраняется в recv_q; +resume_receive планирует продолжение. Управляющий CLOSE не проходит через этот предикат. -``` - ROUTER_MAX_INFLIGHT = 256 +| Механизм | Интервал/условие | +| --- | --- | +| ACK | Не чаще 10 ms; текущая граница повторяется при дубликате DATA после потери ACK | +| RECEIVE_BUSY | Около 100 ms, пока сервис остановлен | +| Idle ACK | 500 ms, только если rx_seq ещё не был отправлен в ACK | +| Handshake/RTO | 300 ms | +| Нет маршрута после handshake | Повторный поиск через 20 ms | +| Транзит при backpressure | Waiter + повтор выбора маршрута через 20 ms | +| Watchdog send_q | 500 ms, пока очередь непуста | +| Нет прогресса/BUSY | Закрытие после 17 циклов, для сервиса CALL — после 67 | - Отправка возможна когда: tx_seq - tx_acked < 256 - Отправка заблокирована: tx_seq - tx_acked >= 256 +Транзитные очереди принадлежат физическому ETCP, сгруппированы по group/src/dst и не имеют своих seq. +При смене маршрута пакет переносится на актуальный next hop без ожидания освобождения старой очереди. +Отсутствие пути приводит к потере транзитной копии; её восстанавливает end-to-end inflight источника. - При блокировке: - данные → send_q (FIFO, без дропов) - send_blocked = 1 +## Владение и локальная доставка - Разблокировка: - приход ACK от remote → tx_acked обновляется - → router_send_kick() → waiter → drain_cb разгребает send_q - или маршрут появился / inflight-лимит вырос → kick - страховка: router_send_watchdog_cb (500ms) → force kick +| Объект | Владение | +| --- | --- | +| Входной entry etcp_route_send | Всегда поглощается, включая ошибку | +| Буфер etcp_router_conn_send | Заимствован на время копирования | +| ETCP_ROUTER_CONN из conn_get | Заимствован до close/destroy | +| Сервисный entry | Передаётся callback; освободить dgram и entry | +| Физический conn в callback | Заимствован, может обозначать транзитного пира | +| Синтетический conn | Только на время callback, instance/peer=self; без сетевого send/ref_take | +| Send-ready handle/arg | У производителя до callback/отмены; перед освобождением отменить | - send_q полностью разобран → send_blocked = 0 -``` +Self-доставка через etcp_route_send синхронна и не создаёт канал. Она обходит сетевые crypto policy +и receive_ready, выдаёт rx_flags=0; режим проверяется на допустимые биты. +Payload должен быть непустым и помещаться вместе с 26-байтным заголовком в uint16 длину. +Нет обработчика, переполнение размера или отказ памяти возвращают -1. +Отдельный синтетический conn каждого вызова сохраняет контекст при вложенных доставках разных ядер. -## Полная схема потоков +Закрываемый канал удаляется из реестра до callback. Callback может закрывать группу/другие каналы +или открыть замену с тем же ключом; старая запись не возвращается повторным поиском. +Память канала и send_q освобождается отложенно за пределами текущего callback. +До массовой остановки сервисы отменяют producers/waiters; уничтожение ядра из callback останавливаемого сервиса запрещено. -``` - ┌──────────┐ - │ Сервис │ (routing, proxy, NAT, ...) - │ (svc_id)│ - └────┬─────┘ - │ - etcp_route_send() │ callback(svc_id, entry) - ▼ ▲ - ┌─────────┐ send ┌─────────────────────────────────┐ recv ┌─────────┐ - │ TUN │─────────▶│ ETCP ROUTER │─────────▶│ TUN │ - │ (выход) │ │ │ │ (вход) │ - └─────────┘ │ ┌──────────┐ ┌──────────┐ │ └─────────┘ - │ │ send_q │ │ recv_q │ │ - │ │ (FIFO) │ │ (хеш seq)│ │ - │ └────┬─────┘ └────┬─────┘ │ - │ │ │ │ - │ ▼ ▼ │ - │ ┌─────────────────────────┐ │ - │ │ Inflight / Assembly │ │ - │ │ tx_seq - tx_acked │ │ - │ │ rx_seq → deliver │ │ - │ └───────────┬─────────────┘ │ - │ │ │ - └──────────────┼──────────────────┘ - │ - ┌──────▼──────┐ - │ BGP │ - │ (нахождение│ - │ next hop) │ - └──────┬──────┘ - │ - ┌──────▼──────┐ - │ ETCP conn │──▶ сеть - └─────────────┘ +close_group удаляет логические каналы и pending transit указанной группы. +destroy снимает wire callback, закрывает все логические каналы и очищает транзитные очереди, +таймеры и waiters на всех живых ETCP. Уничтожать сам ETCP для очистки роутера не требуется. - ──▶ send (исходящие данные) - ──▶ recv (входящие данные, callback сервису) - ──▶ транзит (через BGP к следующему hop) -``` - -## Таймеры (один rconn) - -``` - ┌─────────────────────────────────────────────────────┐ - │ │ - │ ack_timer ──────── 100ms ─────▶ router_ack_timer_cb│ - │ (периодический) │ - │ │ - │ idle_ack_timer ─── 500ms ─────▶ router_idle_ack_cb │ - │ (досылка последнего ACK) │ - │ │ - │ watchdog_timer ── 500ms ─────▶ router_send_watchdog│ - │ (страховка инварианта drain, force kick) │ - │ │ - └─────────────────────────────────────────────────────┘ -``` - -## Флаги seq - -``` - ┌──────────────────────────────────────────────────┐ - │ CLOSE: 0x80000000 нормальное закрытие conn │ - │ RST: 0x40000000 conn не найден (reset) │ - │ │ - │ CLOSE/RST-пакеты: seq=0 | флаг, pl_len=0 │ - │ Data-пакеты: seq=tx_seq, pl_len>0 │ - │ ACK-пакеты: seq=rx_seq, pl_len=0 │ - └──────────────────────────────────────────────────┘ -``` +Метрики delivery считают первые подтверждения исходного payload, без заголовков/crypto/ретрансмитов. +RTT — 0.1 ms, snapshot API выдаёт микросекунды; jitter — unsigned 16.16 EWMA, +монитор публикует удвоенный EWMA. Очереди, метрики и параметры minRTT сбрасываются вместе с логической сессией. diff --git a/src/routing_layer/etcp_router_doc.md b/src/routing_layer/etcp_router_doc.md index 385f114a..a6adac11 100644 --- a/src/routing_layer/etcp_router_doc.md +++ b/src/routing_layer/etcp_router_doc.md @@ -1,121 +1,192 @@ -# ETCP Router: протокол и проверка транспорта +# ETCP Router: протокол и сервисный API -Логическое соединение определяется `(group_id, remote_node_id, svc_id)`. Роутер обеспечивает порядок, дедупликацию и повторную отправку между конечными узлами; физический ETCP и транзитные узлы могут меняться. Формат изменён без обратной совместимости. +Логический канал определяется тройкой `(group_id, remote_node_id, svc_id)`. +Роутер обеспечивает порядок, дедупликацию и повторную отправку между конечными узлами; +физический ETCP и транзитные узлы могут меняться. Работа и callbacks выполняются в uasync-потоке ядра. + +Объявления и точные константы — в `etcp_router.h`, схема потоков — в +[архитектурном описании](../../doc/etcp_router_arch.md). Формат не имеет согласования версий; +узлы используют один формат сборки, обратная совместимость не поддерживается. ## Wire-формат -Packed `SVC_ROUTE_HDR`, 57 байт: +Packed `SVC_ROUTE_HDR`, 57 байт, числовые поля в native endian: `cmd:1 | group:8 | dst:8 | src:8 | seq:4 | svc:1 | flags:1 | timestamp:2 | reset_id:8 | peer_reset_id:8 | challenge:8` -Числовое представление полей сохраняет существующий формат проекта (native endian). Отдельной версии/согласования формата нет; узлы должны обновляться совместно. - Полный пакет: `[SVC_ROUTE_HDR][payload][signature?][visited_ids:8*count][count:1]`. -Источник начинает список своим ID; транзит проверяет отсутствие собственного ID и дописывает его. -Правило действует для DATA, ACK и всех управляющих пакетов. Повторное посещение отбрасывается; -также проверяются размер, уникальность ID, первый ID=src и последний ID=ETCP peer. -Предел — 64 ID; на конечном узле полный список допустим, для следующего перехода места уже нет. + +Источник начинает список собственным ID. Каждый транзитный узел проверяет список и добавляет +свой ID один раз на приёме, перед ожиданием очереди. При повторной отправке источника список создаётся заново. +Проверяются границы, 1..64 ID, отсутствие нулей/дубликатов/собственного ID, первый ID=src, +последний ID=ETCP peer. Полный список из 64 ID допустим на конечном узле; следующий транзит уже невозможен. + Список находится вне end-to-end подписи/шифрования и снимается перед crypto decode. -Это защита от временных петель при сходимости BGP, а не криптографическое подтверждение пути. +Это защита от временных петель BGP, а не доказательство пути от недоверенного пира. +Транзит не декодирует payload. При занятой физической очереди он сохраняет пакет в FIFO +для `(group, src, dst)`; waiter и таймер 20 ms повторно выбирают путь. +При исчезновении маршрута пакет отбрасывается, повтор доставки выполняет источник. -Маршруты строит BGP по правилам `topo_group.h`. Транзит использует текущий next hop. -При backpressure пакет ждёт в очереди; waiter и таймер 20 ms повторно проверяют маршрут. -Смена пути не требует освобождения старой физической очереди. ID добавляется один раз на приёме, -а не при каждой попытке выгрузки. При исчезновении маршрута транзит отбрасывается; доставку повторяет источник. +Отправитель сначала выбирает транспорт через conn_mgr, затем BGP группы; при отсутствии группы +допускается прямой глобальный транспорт. Транзит требует маршрута зарегистрированной группы: +одно прямое соединение не разрешает ретрансляцию для неизвестного group_id. +Право обработки сервисных данных проверяет сам сервис. -`reset_id` — случайный ненулевой ID локального экземпляра соединения; `peer_reset_id` — ID получателя. DATA, ACK и CLOSE принимаются только при совпадении обоих ID с текущей парой. Смена физического транспорта пару не меняет. +## Согласование эпох -| Пакет | Флаги | Payload | Поля | -|---|---|---|---| -| HELLO | START (0x80) | нет | собственный ID, peer=0, challenge=0 | -| CHALLENGE | RST (0x40) | нет | оба ID, случайный ненулевой challenge | -| CONFIRM | START|RST | нет | оба ID, эхо challenge | -| DATA | 0 либо SIGNED/ENCRYPTED | непустой | seq, оба ID, challenge=0 | -| ACK | 0 | нет | seq=следующий ожидаемый, оба ID, challenge=0 | -| CLOSE | CLOSE (0x02) | нет | оба ID, challenge=0 | +`reset_id` — случайный ненулевой ID локального экземпляра канала, +`peer_reset_id` — подтверждённый ID получателя. DATA/ACK/CLOSE допускаются только для текущей пары. +Смена физического транспорта пару не меняет. -Незнакомый ID сам по себе не сбрасывает рабочую сессию. Получатель выдаёт свежий challenge и меняет peer ID только после соответствующего CONFIRM. CHALLENGE не меняет рабочую сессию: отправляется CONFIRM и, если нужно, встречный challenge. Потерянные сообщения handshake восстанавливаются повторением HELLO через 300 ms; выдача новых challenge ограничена тем же интервалом. +| Исходящий пакет | flags | Payload | Значение | +| --- | --- | --- | --- | +| HELLO | START, 0x80 | Нет | Свой ID, peer=0, challenge=0 | +| CHALLENGE | RST, 0x40 | Нет | Оба ID и случайный ненулевой challenge | +| CONFIRM | START\|RST | Нет | Оба ID и эхо challenge | +| DATA | 0, SIGNED 0x08 и/или ENCRYPTED 0x04 | Непустой | seq, оба ID, challenge=0 | +| ACK | 0 или RECEIVE_BUSY 0x01 | Нет | seq=rx_seq, оба ID, challenge=0 | +| CLOSE | CLOSE, 0x02 | Нет | Оба ID, challenge=0 | -Первичное согласование сохраняет накопленные исходящие данные. Подтверждённый рестарт пира сбрасывает seq/очереди и уведомляет сервис; собственный ID сохраняется. Явный `etcp_router_conn_restart` создаёт новый собственный ID. Сброс выполняется до уведомления, поэтому данные, отправленные сервисом из callback, сохраняются. Сервис должен заново выполнить прикладную синхронизацию после уведомления; гарантии доставки не продолжаются через явный сброс сессии. +Незнакомый ID не сбрасывает рабочую сессию: получатель сначала выдаёт challenge. +CONFIRM должен совпасть с локальным ID, pending peer ID и challenge. +CHALLENGE вызывает ответ CONFIRM и при необходимости встречную проверку, но сам по себе не меняет сессию. +Выдача новых challenge ограничена интервалом 300 ms; пока первичный peer не подтверждён, +HELLO повторяется каждые 300 ms без отдельного таймаута. При этом исходные данные остаются в send_q. +На этом этапе отсутствие маршрута обрабатывается тем же HELLO-таймером. -Challenge защищает от случайных старых/переставленных управляющих пакетов, но не является end-to-end аутентификацией против злонамеренного транзитного узла. Управление опирается на защиту физических ETCP-соединений. Подпись/шифрование DATA включаются вызывающим сервисом через `ROUTE_CRYPTO_SIGN` / `ROUTE_CRYPTO_ENCRYPT`. +Первичное согласование сохраняет накопленные исходящие данные. +Подтверждённый рестарт пира очищает seq/очереди/метрики и уведомляет сервис, сохраняя собственный ID. +Явный `etcp_router_conn_restart` выбирает новый собственный ID. Отсутствующий канал не создаётся. +Сброс выполняется до уведомления, поэтому отправка из callback относится уже к новой сессии. +Гарантии прежнего канала не продолжаются через его сброс; прикладную синхронизацию возобновляет сервис. -## Очереди и подтверждения +Challenge защищает от старых/переставленных пакетов, но не аутентифицирует end-to-end управление +против злонамеренного транзитного узла. Управление опирается на защиту физических ETCP-соединений. +DATA кодируются через route_crypto: сначала шифрование, затем подпись заголовка и payload. -- `send_q`: plaintext + seq + crypto mode, до 64 пакетов при обычной отправке. Кодирование происходит при отправке после handshake, с актуальной парой ID. -- `tx_seq`: следующий выделяемый seq; `tx_sent`: граница фактически отправленного; `tx_acked`: граница подтверждённого. -- `inflight_q`: неизменяемое тело с подписью, без списка посещённых узлов, обычно до 256 пакетов. Каждый ретрансмит начинает новый список; ошибка не теряет pending-пакет. -- ACK принимается только в модульном интервале `(tx_acked, tx_sent]`. Дубликаты, старые и выходящие за границу ACK не продлевают ожидание прогресса. Удаляются существующие inflight-записи, а не перебираются все номера до произвольного ACK. -- Новые DATA и ретрансмиты идут по одному через waiter физической `send_input_q`. При смене пути отменяется waiter на очереди, где он действительно был зарегистрирован, включая уже запланированный callback. -- Каждые 300 ms без прогресса ACK планируется проход повторной отправки. Логический канал закрывается после 67 циклов без прогресса для `ETCP_RT_ID_CALL` (около 20 s), для остальных сервисов — после 17 циклов (около 5 s). Отсутствующий маршрут проверяется каждые 20 ms; очередь сохраняется. Пока первичный handshake не завершён, HELLO повторяется без отдельного таймаута. -- Watchdog 500 ms восстанавливает отправку и переносит waiter при смене заблокированного пути. -- Сброс физического ETCP сохраняет router inflight и seq. Восстановление самого ETCP-handshake остаётся обязанностью транспортного слоя. -- Crypto decode/проверка подписи выполняются до помещения в reorder и изменения rx_seq. Повреждённый пакет не подтверждается как доставленный. -- DATA выдаются сервису строго по seq; входящие дубликаты повторно проверяются после извлечения из очереди. -- Wire-пакет ограничен `PKTNORM_MAX_DGRAM_SIZE` (16384), включая заголовок, crypto overhead и резерв полного списка переходов. Пустой payload запрещён: он используется для управления. +## Очереди и seq -## Сервисный API +| Состояние | Содержимое/смысл | +| --- | --- | +| send_q | Plaintext FIFO; в entry.data — выделенный seq и crypto mode | +| tx_seq | Следующий выделяемый seq | +| tx_sent | Граница действительно отправленных пакетов | +| tx_acked | Граница подтверждённых пакетов, модульный uint32 | +| inflight_q | Неизменяемое подписанное/зашифрованное тело без visited-list; исходная длина payload учитывается отдельно | +| incoming_q | Проверенный plaintext между сетью и reorder | +| recv_q | Reorder по seq; данные, ещё не выданные сервису | +| rx_seq | Следующий ожидаемый seq, увеличивается после успешного callback | -`etcp_router_conn_send(rconn, data, len, mode)` копирует payload; 0 означает принятие в очередь, а не end-to-end доставку. При backpressure производитель ждёт через `etcp_router_on_send_ready`; отменяет ожидание через `etcp_router_cancel_send_ready`. Handle должен жить до callback/отмены. Режим `force` низкоуровневого `etcp_route_send` обходит порог очереди и не предназначен для непрерывного потока. +Начальные seq равны 0. Сравнения выполняются по модульной разности uint32. +Обычная отправка ограничена 64 пакетами send_q; `force` обходит этот порог, но не проверку размера/режима. +Кодирование выполняется при реальной отправке после handshake, с текущими эпохами. +После успешного etcp_send запись снимается с send_q; при отказе исходный пакет остаётся в очереди. -`etcp_router_bind` регистрирует callback, владеющий переданным ll_entry. Формат доставки — 26 байт заголовка: +Обычный inflight — 256 пакетов, минимум настраиваемого max_inflight — 4. +Во время minRTT probe действует лимит 4; сброс сессии возвращает default=256. +Новые DATA и ретрансмиты проходят по одному через waiter физической send_input_q. +При смене пути отменяется ожидание на очереди, где оно действительно зарегистрировано. +Штатный normalizer откладывает физические waiters, чтобы избежать рекурсивного drain. + +Wire-пакет ограничен `PKTNORM_MAX_DGRAM_SIZE` (16384), включая 57-байтный заголовок, +crypto overhead и резерв полного 513-байтного visited-list. Пустой payload зарезервирован для управления. +Входной размер проверяется до выделения/копирования payload, включая SIZE_MAX и неверные биты mode. + +## ACK, backpressure и повторы + +ACK принимается в модульном диапазоне `[tx_acked, tx_sent]`. +Только продвижение seq освобождает существующие inflight-записи и учитывает первую доставку payload. +Старые/выходящие за диапазон ACK не продлевают ожидание прогресса. + +Исключение — допустимый RECEIVE_BUSY: даже при прежнем seq он подтверждает живость принимающего сервиса, +обновляет отсчёт RTO, обнуляет no_ack_count, останавливает текущий повторный проход и новую отправку. +Снятие BUSY возобновляет send_q, в том числе при прежнем seq. +Busy ACK не подтверждает отложенные пакеты. c_stale_ack включает и допустимые ACK без продвижения. + +receive_ready получает заимствованный проверенный plaintext перед callback и ACK. +Это предикат без I/O и изменения/закрытия состояния роутера. +При 0 пакет остаётся в recv_q, порядок сохраняется; принимающий узел публикует BUSY примерно каждые 100 ms. +CLOSE обрабатывается отдельно и предикатом не блокируется. +`etcp_router_resume_receive` планирует продолжение, не создаёт канал и не сохраняет аргументы сервиса. +Снятие предиката через set_receive_ready(NULL) само по себе не планирует продолжение уже остановленных каналов. + +ACK ограничен снизу интервалом 10 ms. При повторном DATA после потери ACK текущая граница отсылается снова. +Idle-таймер 500 ms отправляет ACK только если rx_seq ещё отличается от last_sent_ack_seq; +безусловного повтора каждого последнего ACK нет. + +Каждые 300 ms без продвижения ACK или допустимого BUSY планируется повторный проход inflight. +Канал закрывается после 17 циклов (около 5 s), для ETCP_RT_ID_CALL — 67 (около 20 s). +Отсутствующий маршрут после согласования проверяется каждые 20 ms, очередь сохраняется. +Watchdog 500 ms повторяет kick при непустой send_q и переносит waiter на актуальный путь. +Сброс физического ETCP сохраняет router seq/inflight; восстановление физического handshake выполняет транспорт. + +До изменения seq/reorder выполняются требования crypto policy и decode/verify. +Повреждённая подпись/криптограмма не подтверждается как доставленная. +Приём проверяет окно относительно max_inflight, а суммарный буфер incoming_q+recv_q ограничен max_inflight+1. + +## Сервисный API и локальная доставка + +`etcp_router_bind` регистрирует callback, получающий владение ll_entry: `svc:1 | src:8 | dst:8 | rx_flags:1 | group:8 | payload...` -Читать невыравненные числовые поля нужно через `memcpy`. Заголовок без payload означает уведомление о закрытии/сбросе. После обработки освобождаются `queue_dgram_free(entry)` и `queue_entry_free(entry)`. +Заголовок доставки — 26 байт. Числовые поля не выровнены, читать через memcpy. +rx_flags содержит crypto-флаги исходного сетевого пакета. Пустой payload — уведомление close/reset. +После обработки сервис освобождает queue_dgram_free(entry) и queue_entry_free(entry). -Callback может закрыть соединение. Освобождение send_q отложено за пределы callback производителя; отправка проверяет, не закрылся/перезапустился ли rconn. При уничтожении реестр удаляется ровно один раз для каждого соединения. +conn заимствован на время callback и может обозначать промежуточного ETCP-пира. +Для локальной доставки, закрытия и отложенного приёма он синтетический: instance и peer=self. +Этот объект нельзя сохранять или использовать для сетевой отправки/ref_take; +реальные end-to-end src/dst/group берутся из сервисного заголовка. +Каждый синтетический callback имеет собственный контекст, включая вложенные вызовы разных ядер. + +Замена ненулевого callback сохраняет readiness/policy. bind(NULL) и unbind снимают оба ограничения; +unbind возвращает -1 при отсутствии обработчика, bind(NULL) в этом случае даёт 0. +Снятие обработчика не закрывает существующие каналы. + +`etcp_router_conn_send` копирует непустой payload в сетевую send_q. +Возврат 0 означает принятие, не end-to-end доставку. +Производитель ждёт порог через on_send_ready и отменяет handle через cancel_send_ready. +Callback ожидания может выполниться синхронно; handle и arg должны жить до завершения/отмены. +Отсутствующий канал не создаётся ни проверкой места, ни регистрацией ожидания. + +`etcp_route_send` принимает entry с svc_id перед payload и освобождает его во всех исходах. +Для удалённого узла создаёт канал и ставит копию в очередь. +Для dst=self выполняет callback синхронно без канала, seq/inflight, receive_ready или crypto. +Доверенная self-ветка обходит сетевую crypto policy и передаёт rx_flags=0; допустимые биты mode проверяются. +Её payload — 1..65509 байт, чтобы 26-байтный заголовок и данные помещались в uint16 длину. +Нет обработчика, неверный размер/режим или отказ памяти дают -1 вместо ложного успеха. + +## Закрытие и метрики + +Индивидуальное закрытие сначала снимает канал с реестра, затем уведомляет сервис. +Callback может закрыть свою группу/другие каналы или создать замену с тем же ключом. +Ресурсы/таймеры очищаются, память канала освобождается отложенно; старый rconn после close использовать нельзя. +Сервисные producers/waiters должны быть отменены до массового close_group/destroy. + +close_group удаляет логические каналы и ожидающий транзит указанной группы. +destroy снимает wire-обработчик, закрывает все логические каналы и очищает все транзитные очереди, +их waiters и route timers даже при остающемся живым ETCP. Повторный destroy допустим. + +tx_stats не создаёт канал: 0 — снимок, 1 — отсутствует/закрыт с нулевым out, -1 — аргументы/время. +queued/inflight/acked bytes измеряют исходный payload. Повторы не увеличивают acked-итог. +ready означает подтверждённый peer_reset_id, не READY группы и не гарантированный физический UP. +RTT хранится в 0.1 ms, в snapshot переводится в микросекунды. +Jitter — беззнаковый 16.16 EWMA |ΔRTT|; монитор публикует удвоенный EWMA без сужения до uint16. +История delivery и параметры RTT/probe сбрасываются при смене логической сессии. ## Проверки и диагностика -`test_bgp_paths` использует настоящий BGP и роутер в управляемой сети четырёх узлов: -все 64 графа, разбиение/слияние, изменения при непустых очередях, REINIT, выбор кратчайшего пути -с устойчивым разрешением равенства. Дополнительно проверяет подписанный зашифрованный транзит, -петли DATA/ACK/control, повреждённые списки переходов, смену заблокированного next hop и очистку таймеров. -Счётчики `UTUN_INSTANCE.router_loop_drops` и `router_path_errors` сопровождаются WARN с идентичностью пакета. +- test_etcp_router_unit: 43 проверки seq/reorder, crypto policy/verify/decrypt, RECEIVE_BUSY, метрик, + nested close/reopen, вложенной self-доставки, предельной длины, unsigned jitter и очистки pending transit. + Fixture освобождает собственные registry-ссылки и проверяет отсутствие оставшихся u_malloc-объектов. +- test_etcp_router_faults: 15 сценариев потерь, перестановок, дубликатов, duplex/multiplexing, handshake, + старых эпох/ACK, wraparound, смены маршрута, закрытия из callbacks, timeout и десятисекундного разрыва звонка. +- test_bgp_paths: реальные BGP/роутеры в управляемой сети четырёх узлов, все 64 графа, + signed+encrypted transit, петли DATA/ACK/control, повреждённые пути, смена занятого next hop и cleanup. +- test_etcp_router, test_etcp_router_tcp, test_etcp_router_reconnect: UDP/STCP, backpressure, + reset транспорта и транзит с перезапуском промежуточного узла. -`tests/test_etcp_router_faults` перехватывает SVC_ROUTE между настоящими роутерами, очередями и UASYNC-таймерами. Физическая очередь тестовая: нижележащий ETCP не скрывает потери от роутера. Проверяются: - -1. Потери DATA/ACK, перестановка, дубликаты, четыре потока (два направления × два сервиса), backpressure и ограниченность физической очереди. -2. Потеря HELLO/CHALLENGE/CONFIRM в обоих направлениях. -3. Старые, повторные, слишком большие ACK и ACK ещё не отправленного пакета. -4. Сохранение inflight при сбросе транспорта. -5. Старые DATA/ACK/CLOSE/HELLO/CONFIRM после смены сессии. -6. Переполнение uint32 seq. -7. Закрытие после смены пути при зарегистрированном и отложенном waiter. -8. Закрытие из callback приёма. -9. Смена маршрута при занятой старой очереди без новой отправки. -10. Исчезновение и восстановление маршрута. -11. Таймаут при непрерывных повторных ACK без прогресса. -12. Одновременный рестарт обоих концов. -13. Граничные размеры payload, crypto overhead, SIZE_MAX. -14. Закрытие из callback производителя при освобождении send_q. - -Успех передачи требует совпадения содержимого, порядка и количества, финальных ACK, пустых send/inflight. Завершение проверяет освобождение таймеров. Unit-набор включает 34 проверки, в том числе криптографию и повторную доставку после неверной подписи. - -Сетевой `test_etcp_router` использует UDP/dummynet, backpressure и сброс с восстановлением ETCP-handshake. `test_etcp_router_reconnect` проверяет a–b–c и перезапуск b, по 500 пакетов до/после, с ожиданием последних ACK в каждой фазе и фиксированным seed. - -Переменная `ROUTER_TEST_LOG=/tmp/router.log` включает подробный файл. Рабочая категория — `etcp_route`; DEBUG показывает handshake, пару эпох, ретрансмиты и смену состояния; поштучные ACK/DATA оставлены на TRACE. Ошибочные ветки не скрыты. В fault-тесте WARN/ERROR для намеренно неверных ACK/размеров и искусственного таймаута ожидаемы. - -Предыдущий этап проверки роутера: 14 fault-сценариев и 34 unit-проверки прошли; fault-набор также прошёл ASan/UBSan/LeakSanitizer (инструментированы router, route_crypto и ll_queue, остальная библиотека обычная). UDP-тест: 440/440 доставлено и подтверждено. Транзитный: 1000/1000 доставлено и подтверждено. Это не доказательство отсутствия всех ошибок стека. - -## Lifecycle транспорта и восстановление - -ETCP имеет единую точку финального освобождения после DELETE, завершения callback и освобождения последней ссылки. Запрос закрытия идемпотентен и сразу запрещает новые send/ref_take. - -NCD-entry удерживает одну ссылку ETCP. При DELETE запись сначала исключается из реестра, затем владельцы получают CLOSED; старые handles безопасно закрываются после создания нового соединения с тем же node_id. Закрытые callbacks/handles удаляются после внешней рассылки. Отложенный начальный UP отменяется при DOWN/close. При удалении сокета обход использует снимок ссылок ETCP, поскольку DOWN может удалить NCD-entry. - -Recovery удерживает TOPO_NODE для каждого уникального кандидата и освобождает ссылку при любом завершении. При успехе BGP сначала получает собственный NCD-handle. `test_topo_recovery` удаляет B и проверяет самостоятельное подключение A↔C и доставку 1000 сообщений без тестового reconnect. - -`etcp_session.c` согласует эпохи физического транспорта через HELLO/CHALLENGE/CONFIRM поверх аутентифицированного UDP/STCP. Каждый DATA/ACK содержит пару эпох; старые кадры отбрасываются до обработки. Сброс потока не генерирует ложный DOWN. REINIT повторно запускает обмен таблицами BGP. Формат и таймауты описаны в `doc/etcp_protocol.txt`. - -Дополнительные проверки: -- `test_etcp_lifecycle`: DELETE/refcount, повторное закрытие, закрытие из callback, FIN_WAIT, отмена начального UP, удаление сокета, владение recovery. -- `test_etcp_session`: реальные crypto/ingress/таймеры; reset любой стороны, одновременный reset, потери всех видов control, повторный reset, отказ одного из двух линков, timeout, старые DATA/ACK/control, неверные размеры. -- `test_etcp_router_tcp`: тот же контроль содержимого, порядка, дубликатов, backpressure и финальных ACK по STCP; обе стороны сбрасываются одновременно. - -Диагностика по запросу: `SESSION_TEST_LOG=/tmp/session.log`, `LIFECYCLE_TEST_LOG=/tmp/lifecycle.log`, `ROUTER_TEST_LOG=/tmp/router.log`, `BGP_TEST_LOG=/tmp/bgp.log`. `ROUTER_TEST_RESET=client|server|both` выбирает сторону reset; `ROUTER_TEST_TCP=1` включает STCP. Ожидаемые ошибки отрицательных сценариев не скрываются. - -Дополнительно исправлен монитор conn_mgr: наличие маршрута через посредника не означает прямого соединения. Фоновый PING выбирает транспорт по node_id конечного peer и живой линк именно этого транспорта. Проверка BGP-треугольника выдерживает полный период фоновых проб и требует отсутствия ошибок входящих пакетов до внесения потерь. На старом monitor эта проверка воспроизводимо падает; с исправлением проходит. - -Итоговая проверка текущих исправлений: 16 регрессионных executable-тестов прошли (router unit/faults/lifecycle/session, UDP, TCP, reconnect, recovery, STCP traffic, NCD, три conn_mgr, normalizer/ETCP, BGP triangle). Семь наборов дополнительно прошли ASan/UBSan/LeakSanitizer: lifecycle, session, router faults, UDP router, TCP router, autonomous recovery, BGP triangle. Инструментированы ETCP core/API/connections/session, NCD, router, recovery, topo_group/connect, conn_mgr core/monitor и ll_queue; остальные библиотечные объекты обычные. Отрицательные тесты намеренно вызывают ошибки; после принудительного удаления транспорта возможен отказ приёма запоздалой UDP-датаграммы без существующего линка. Финальный BGP-прогон, включая фоновые пробы и cleanup таймеров, завершился без диагностических ошибок. +ROUTER_TEST_LOG=/tmp/router.log включает подробный файл в fault/network-наборах. +Категория etcp_route: DEBUG — эпохи/повторы/состояния, TRACE — отдельные DATA/ACK. +Ожидаемые WARN/ERROR отрицательных сценариев не скрываются.