docs(proxy): consolidate current protocol and audit coverage

This commit is contained in:
evgeny committed 2026-10-24 12:38:27 +02:00
1 parent f003aaa92d
commit 5e80a9cc6c
2 files changed
+175 -519

No files matched your search

+162 -46
View File
@@ -1,61 +1,177 @@
# Proxy: потоки TCP и датаграммы
# Proxy: TCP, UDP и ICMP
Клиентские входы — TUN (lwIP), SOCKS5 CONNECT и HTTP proxy/CONNECT. Exit открывает
обычный TCP-сокет к назначению. TCP-поток на exit определяется парой `(peer_node_id,
stream_id)`. Клиент принимает ответы и уведомления restart только от настроенного
exit в UTUN-группе. UDP/ICMP-контексты принадлежат конкретному `UTUN_INSTANCE`.
Клиент принимает IPv4 из отдельного TUN через lwIP либо TCP от локальных SOCKS5/HTTP
listeners. Exit открывает обычный IPv4 TCP/UDP или raw ICMP socket. Вся передача proxy
идёт через `etcp_route_send` в `TOPO_GROUP_UTUN`, mode=0. Протокол не имеет слоя
совместимости с прежними форматами.
## TCP
## Сообщения для router и приёмные метаданные
Общие определения находятся в `src/proxy/proxy_protocol.h`. Передаваемый заголовок:
`svc_id:1, command:1, stream_id:4`; поля маршрута добавляет ETCP-router.
Это форматы **сервисного API**, а не полные ETCP wire packets. На отправке первый байт —
svc_id, остальное — payload router. Router callback получает такой заголовок:
- CONNECT (1): IPv4:4 и порт:2 в сетевом порядке.
- CONNECTED (2): exit подтвердил успешный TCP connect. Только после этого клиент
сообщает SOCKS success / HTTP 200 и начинает передавать накопленные данные.
- DATA (3): от 1 до 4096 байт.
- CLOSE (4), ERROR (5): прекращение потока.
- FIN (6): конец одного направления; все предшествующие DATA должны быть переданы.
- WINDOW (7): uint32_t, число дополнительно разрешённых байт.
| Смещение | Размер | Поле |
|---|---|---|
| 0 | 1 | svc_id |
| 1 | 8 | src_node_id |
| 9 | 8 | dst_node_id |
| 17 | 1 | rx_flags |
| 18 | 8 | group_id |
| 26 | N | service payload |
У каждого направления начальное окно 65536 байт. DATA уменьшает окно отправителя
и получателя. Получатель возвращает кредит после записи в локальный TCP-сокет
либо принятия данных ограниченным send-buffer lwIP. Очередь ETCP сама по себе
не является подтверждением потребления данных конечным TCP.
Смещения определены `ROUTER_SVC_*_OFF` в `src/routing_layer/etcp_router.h`.
Заголовок callback имеет размер 26 байт. Сервис освобождает полученные entry/dgram
при любом исходе. На отправке router принимает владение entry/dgram даже при ошибке;
0 означает принятие сообщения router, а не доставку конечному TCP/UDP.
FIN ждёт освобождения исходящей очереди, включая данные, остановленные окном
или backpressure роутера. Закрытие одного направления не прекращает другое.
Окончательное освобождение происходит после обоих FIN и передачи оставшихся данных.
Непереданные управляющие сообщения повторяются таймером. Ошибки и переходы
состояния диагностируются категорией `proxy`; состояние сокетов — `socket`.
TCP-клиент проверяет src_node_id==via_node_id и group_id==TOPO_GROUP_UTUN, включая
пустые уведомления. UDP/ICMP допускают REQUEST при включённом exit, REPLY — только при
наличии клиента и совпадении src_node_id с его via_node_id; отдельной проверки group
в их callback нет. IP, TCP/UDP-порты и ICMP id/seq в proxy API передаются в network
byte order. TCP stream_id и WINDOW credit копируются как host-order uint32_t:
формат не нормализован между архитектурами с разным byte order.
Парсеры сохраняют остаток после greeting/request/HTTP headers. HTTP CONNECT ждёт
полного блока заголовков. Во время DNS/connect чтение ограничено очередью tcp_io;
накопленные HTTP headers/body передаются частями размером DATA.
## TCP и управление окном
Определения — `src/proxy/proxy_protocol.h`.
На отправке: `svc:1, cmd:1, stream_id:4, data` (заголовок 6 байт).
На приёме: router header(26), cmd(1), stream_id(4), data (заголовок 31 байт).
Клиент отправляет в `ETCP_RT_ID_TCP_PROXY_SERVER=0x04`, exit отвечает в
`ETCP_RT_ID_TCP_PROXY_CLIENT=0x08`. На exit поток определяется парой peer_node_id/stream_id;
повторный CONNECT этой пары отклоняется с ERROR.
| Команда | Код | Содержимое после stream_id |
|---|---|---|
| CONNECT | 1 | IPv4:4, порт:2 |
| CONNECTED | 2 | Пусто; exit завершил реальный TCP connect |
| DATA | 3 | 1..4096 байт |
| CLOSE | 4 | Пусто; прекращение потока с дренажом принятой записи на exit |
| ERROR | 5 | Пусто; отмена, на exit немедленно уничтожает TCP и очереди |
| FIN | 6 | Пусто; конец одного направления после всех его DATA |
| WINDOW | 7 | uint32_t дополнительного кредита, ровно 4 байта |
Каждое направление начинает с окна 65536 байт. proxy_flow init оставляет ready=0.
DATA ждёт CONNECTED; SOCKS success и HTTP CONNECT 200 также отправляются только после него.
Локальный TUN TCP handshake выполняет lwIP и сам по себе connect назначения не подтверждает.
При отправке DATA списывается tx_credit, при приёме — rx_credit.
WINDOW допускает только ненулевой кредит без увеличения tx_credit сверх 65536.
Кредит возвращается после потребления локальным TCP: на OS-сокетах после опустошения
write_queue **и write_buf**, в lwIP после принятия байтов ограниченным send-buffer
через tcp_write. Это не подтверждение чтения удалённым приложением.
Для HTTP учитывается число исходных upstream байтов независимо от преобразования
headers/chunks/trailers; кредит возвращается после доставки соответствующего результата в OS TCP.
Backpressure router и окно потока независимы. При ошибке DATA остаётся в очереди/буфере
владельца, регистрируется send-ready waiter и safety-net на 500 мс.
У TCP-клиента с lwIP timer повторяет DATA с force=1, у exit и SOCKS/HTTP — обычный force=0.
Force обходит лимит send_q router, но никогда не окно proxy.
При отсутствии CONNECTED/кредита (-2) отправка ждёт ready/WINDOW, а не только router waiter.
## Half-close и удаление потока
Владелец выставляет fin_pending только после отправки всех исходящих DATA.
proxy_flow не видит очередей владельца и сам это условие не проверяет.
FIN не должен ждать записи противоположного направления: обе половины независимы.
Приём FIN ставит FIN-маркер после ранее принятых DATA в OS write_queue; в lwIP FIN
откладывается до дренажа to_lwip и успешного tcp_shutdown.
Окончательное закрытие сырого TCP наступает после обоих FIN и необходимых локальных
очередей/shutdown. Дополнительный обмен CLOSE при штатном half-close не требуется.
На exit CLOSE паузит чтение, отбрасывает непрочитанный read_queue и закрывает сокет
после принятой write_queue; ERROR уничтожает поток сразу. На клиенте CLOSE/ERROR
прекращают поток; HTTP вне туннеля дополнительно проверяет завершённость ответа.
Общий retry повторяет pending CONNECTED → WINDOW → FIN через 5000 tb (500 мс),
используя force=1. CONNECT/CLOSE/ERROR в эту очередь не входят.
Клиентский close_pending может повторяться в poll/free пока поток жив; сервер после
ошибки TCP удаляет поток и делает попытку ERROR. Гарантированного повторения CLOSE/ERROR
после освобождения владельца нет.
Router close/restart уведомляет сервис entry длиной 26 байт без payload.
Proxy называет это CLOSE_ALL: exit удаляет потоки указанного peer, клиент — все свои
TUN/SOCKS/HTTP потоки к настроенному exit. Это не самостоятельная подкоманда или
9-байтовый пакет на проводе.
## SOCKS5 и HTTP
SOCKS реализует CONNECT к IPv4 или DNS-имени, метод аутентификации 0 (no auth).
IPv6 отклоняется с REP=8; BIND и UDP ASSOCIATE не реализованы.
DNS выполняется асинхронно на ingress, выбирается первая A-запись.
Совмещённые и разделённые greeting/request/data сохраняют непрочитанный хвост.
Ошибка DNS/connect до успеха даёт SOCKS REP=4 либо HTTP 502.
HTTP/1.0–1.1 forward proxy требует absolute http:// URL; upstream получает origin-form,
Host из URL и Via. Обычный запрос отправляется как HTTP/1.1 с Connection: close,
кроме Upgrade. Frontend HTTP/1.1 может сохранять keep-alive: каждый запрос получает
новый stream_id/upstream, pipelining обслуживается последовательно, включая разные цели.
Новый stream начинается после доставки полного ответа и отправки CLOSE/ERROR старого.
Codec проверяет headers до пересылки, поддерживает Content-Length, chunked
с extensions/trailers, промежуточные ответы, HEAD/204/304 и тело до EOF.
Hop-by-hop и proxy authentication поля удаляются, framing/Connection строятся заново.
HTTP/1.0 получает декодированное chunked тело и закрытие. CONNECT ждёт полного блока
headers и реального CONNECTED; согласованный 101 переводит соединение в сырой туннель.
Ранний final response прекращает upload и требует закрытия клиента.
Ошибка до final headers может дать собственный HTTP error; после них закрывает оборванный ответ.
Лимиты: headers/trailers 32 KiB, start line и chunk/trailer line 8 KiB, 512 header fields.
CL+TE, разные Content-Length, obs-fold, управляющие байты и неверные порты отклоняются.
Поддержан только transfer coding chunked; HTTP IPv6-назначения отклоняются с 502.
Headers имеют deadline 30 с, DNS/connect — 15 с, передача/idle/дренаж закрытия — 60 с.
Deadline headers не продлевается каждым байтом. Установленные CONNECT/Upgrade-туннели
используют TCP lifecycle без HTTP deadline. Детали codec — в `src/proxy/http_proxy.h`.
## UDP и ICMP
REQUEST и REPLY различаются явно. Ответ допускается только от настроенного exit.
UDP-ответ восстанавливается с нулевой UDP checksum (допустимо для IPv4), IPv4 checksum
пересчитывается. ICMP checksum учитывает нечётный последний байт без чтения за буфером.
Контексты принадлежат конкретному UTUN_INSTANCE. REQUEST=1, REPLY=2.
На отправке UDP: `svc:1, cmd:1, src_ip:4, src_port:2, dst_ip:4, dst_port:2, payload`.
ICMP: `svc:1, cmd:1, dst_ip:4, orig_src_ip:4, echo_id:2, echo_seq:2, payload`.
Заголовки отправки — 14 байт, приёма — 39. Отдельного sender_node_id в payload нет.
Exit заменяет ICMP id/sequence уникальной среди ожидающих запросов парой и сопоставляет
ответ также с IP назначения. Исходные id/sequence восстанавливаются для клиента.
Это исключает смешивание ping-запросов разных клиентов с одинаковыми исходными ID.
UDP exit создаёт сокет на tuple client_node_id/src_ip/src_port/dst_ip/dst_port,
проверяет источник ответа, меняет src/dst местами в REPLY. Удаляет неактивные flows
при очередной периодической проверке с порогом >60 с. Клиент восстанавливает IPv4 checksum,
UDP checksum=0 (допустимо для IPv4).
## Проверки и ограничения
ICMP exit использует один raw socket; заменяет id/seq token и сопоставляет
wire_id/wire_seq/IP цели с pending. Исходные id/seq восстанавливаются для клиента.
Порог ожидания >5 с проверяется периодически. Raw reply проходит проверку ICMP checksum.
Нечётный хвост checksum обрабатывается без чтения за буфером.
test_loopback даёт виртуальный ответ только при отсутствии raw socket.
Успешный ICMP init не гарантирует raw socket: его ошибка оставляет контекст активным.
`test_proxy_packets` проверяет IPv4/UDP/ICMP и payload длиной 0..1501.
`test_tcp_io_flush` проверяет уведомление после последнего синхронного send.
`test_proxy_regressions` использует реальные локальные сокеты и управляемую доставку
ETCP: совместные/раздельные handshake, ранние данные, отложенный CONNECTED, HTTP 502,
большой POST, одинаковые stream ID разных пиров, FIN при закрытом окне, RST и IPv4 options.
Send API принимает payload 0..65507, TUN delivery — 0..65506 из-за однобайтового
префикса в uint16_t длине entry. UDP send требует локальный UDP-контекст, ICMP send
его не требует. Доставка ответа требует клиентский TUN; своего окна/повтора датаграмм нет.
На Linux полный `./check.sh`: 109 passed, 0 failed, 1 skipped; дополнительно прошли
настоящий TUN, full-duplex burst и нагрузка 8 соединений / 128 МиБ.
Windows/FreeBSD в этом прогоне не проверялись.
## Жизненный цикл, диагностика и проверки
SOCKS IPv6 явно отклоняется кодом address type not supported. IPv4-фрагменты
на входе proxy TUN отклоняются с диагностикой; сборка фрагментов не реализована.
Формат TCP дополнен CONNECTED/WINDOW, UDP-команды разделены: клиент и exit необходимо
обновлять вместе; совместимость со старым proxy-протоколом не предусмотрена.
Все операции и callbacks — в uasync владельца. Proxy init запрашивает binding
после router init; client/server имеют разные service IDs. Сервер создаёт UDP/ICMP,
клиент делает это при наличии TUN. Сначала уничтожается клиент, затем сервер:
клиент сохраняет общие UDP/ICMP пока сервер включён, server destroy уничтожает их.
После удаления exit-потоков ua должен обработать отложенные free.
Ошибки listener/bind/подмодулей не всегда пробрасываются init/create — точные контракты в .h.
Категория proxy: info — подключения/закрытия/транзакции, debug — окна/паузы/повторы,
trace — трафик и секундная диагностика exit TCP-очередей/пулов/OS buffers.
Для поиска проблемы также полезны socket и etcp_route.
Регрессионный тест включает proxy=debug через UTUN_TEST_DEBUG=1.
| Тест | Что проверяет |
|---|---|
| test_http_proxy | HTTP codec, разбиения headers/chunks, framing, лимиты, source credit, Upgrade |
| test_proxy_packets | IPv4/UDP/ICMP, checksum и payload длиной 0..1501 |
| test_proxy_regressions | Handshake/CONNECTED, HTTP pipeline/EOF/deadlines, окна/FIN, peer+sid, reentrancy, TUN options/fragment validation, per-instance UDP/ICMP |
| test_tcp_proxy_client | Create/destroy с тестовым TUN, без утечек |
| test_tcp_proxy_server | TCP echo через tcp_conn; сам по себе не проверяет протокол exit |
| test_socks_http_proxy | Реальные SOCKS IPv4/domain, HTTP и HTTPS CONNECT, byte-exact загрузки |
| test_udp_proxy / test_icmp_proxy | Два instance через ETCP; ICMP использует raw socket либо тестовый fallback |
| test_tcp_io_flush | Callback после последнего синхронного send |
Аудит 2026-10-24: перечисленные девять тестовых программ пересобраны и прошли на Linux.
Полный check.sh, реальные TUN/load/burst и Windows/FreeBSD в этом аудите не запускались.
Создание proxy из конфигурации в utun_instance.c исключено под _WIN32.
IPv4-фрагменты на входе proxy TUN отклоняются; сборки фрагментов нет.
Описания модулей: `src/proxy/*_doc.md`; все семь заголовков `src/proxy/*.h` сверены с реализацией.
+13 -473
View File
@@ -1,478 +1,18 @@
## TCP Proxy Protocol — архитектура (v2, без CONNECTED)
# Протокол proxy
### 1. Протокольный формат (общий для client и server)
Актуальное описание TCP/UDP/ICMP, router-метаданных, окон, half-close и проверок:
[doc/proxy_protocol.md](../../doc/proxy_protocol.md).
```
┌──────┬──────┬───────────────────────┬──────────────────────┐
│svc_id│subcmd│ stream_id (4) │ data ... │
│ 1B │ 1B │ │ │
└──────┴──────┴───────────────────────┴──────────────────────┘
HDR = 6 байт
TCP ожидает реальный CONNECTED и использует WINDOW; FIN завершает одно направление.
Прежние схемы без CONNECTED, с общим TCP service ID и 9-байтовым CLOSE_ALL не соответствуют
реализации. Общие определения и контракт proxy_flow находятся в [proxy_protocol.h](proxy_protocol.h).
subcmd:
0x01 CONNECT client→server : dest_ip(4)+dest_port(2)
0x03 DATA ↔ bidirectional: payload
0x04 CLOSE ↔ bidirectional: (нет данных)
0x05 ERROR ↔ bidirectional: (нет данных)
```
Описание отдельных модулей:
---
- [TCP client](tcp_proxy_client_doc.md) — TUN/lwIP, маршрутизация к exit, приём ответов.
- [TCP server](tcp_proxy_server_doc.md) — OS connect, окна, очереди, FIN/CLOSE/ERROR.
- [SOCKS и HTTP](socks_proxy_doc.md) — listeners, DNS, HTTP framing/keep-alive/туннели.
- [UDP](udp_proxy_doc.md) — per-instance flows и восстановление ответа в TUN.
- [ICMP](icmp_proxy_doc.md) — raw socket, сопоставление echo, тестовый fallback.
### 2. Диаграмма состояний `tcp_proxy_client_conn`
```
┌──────────────┐
│ ALLOC + │ accept_cb: u_calloc, stream_id++
│ CONNECT │ send CONNECT → exit
│ отправлен │
└──────┬───────┘
│
┌───────────┴───────────┐
│ send успешен? │
└───────────┬───────────┘
Y │ N
┌────────┘ └──────┐
▼ ▼
┌────────────┐ ┌──────────────┐
│ ACTIVE │ │ FREE │
│ traffic │ └──────────────┘
└────┬──┬────┘
│ │
│ └─────────────────────────────────┐
│ │
▼ lwIP FIN (p=NULL) ▼ ERROR от lwIP/exit
┌──────────────┐ ┌──────────────┐
│ TUN_CLOSED │ │ ERROR │
│ tun_closed=1 │ │ error=1 │
│ ↓ send_close │ │ send ERROR→ │
│ после drain │ └──────┬───────┘
│ tx_buf+lwIP │ │
└──────┬───────┘ poll_cb: tcp_abort+free
│
│ recv CLOSE от exit
▼
┌───────────────────┐
│ TUN+REM CLOSED │
│ tun=1 rem=1 │
│ ждём flush буферов│
└────────┬──────────┘
│ poll_cb: pending=0, unsent=NULL, unacked=NULL
│ (или sndbuf==0)
▼
┌──────────────┐
│ CLEANUP │
│ free(pc) │
└──────────────┘
ДОП. СОСТОЯНИЯ:
┌─────────────────┐ send_data в ETCP вернул -1 (send_q полон)
│ TX_BACKPRESSURE│ не tcp_recved() → окно lwIP=0 → recv_cb остановлен
│ tx_buf != NULL │ etcp_router_on_send_ready() → ждём освобождения
│ tx_waiter pend │ waiter_cb: retry send_data → tcp_recved → окно открыто
└─────────────────┘
┌─────────────────┐ send_close/send_error вернул -1
│ CLOSE_PENDING │ → retry в poll_cb
│ close_pending=1│
└─────────────────┘
```
---
### 3. Диаграмма состояний `tcp_proxy_server_conn`
```
┌───────────────────┐
│ CREATING │ recv CONNECT
│ socket() создан │ → неблокирующий connect()
│ sock = неблок. │
└────────┬──────────┘
│
┌────────────┴────────────┐
│ connect() результат? │
└────┬──────────┬──────────┘
EINPROGRESS│ │ошибка сразу
▼ ▼
┌──────────┐ ┌──────────────┐
│CONNECTING│ │ ERROR │ send ERROR→client
│ждём write│ │ FREE │
└────┬─────┘ └──────────────┘
│ write_cb: SO_ERROR==0
▼
┌─────────────┐
│ CONNECTED │ connected=1
│ flush pend │ → flush pending_buf в сокет
└──────┬──────┘
│
┌──────────────┼──────────────┐
│ │ │
client→DATA │ │ ERROR (sock/pipe)
(sock_send) │ ▼
или pending_buf │ ┌──────────────┐
│ │ │ ERROR │ send ERROR→client
│ │ │ FREE │
│ sock EOF (n=0) │ │
│ │ └──────────────┘
│ ▼
│ ┌──────────────────┐
│ │ SOCK_CLOSED │ sock_closed=1
│ │ out_buf/pause? │ если очереди пусты → send_close
│ │ ждём drain │ иначе ждём retry_cb/waiter_cb
│ └────────┬─────────┘
│ │ очереди опустошены → send_close
│ │ если cli_closed → FREE
│ │
client→CLOSE │
│ │
▼ │
┌──────────────┐ │
│ CLI_CLOSED │ │ cli_closed=1
│ out_buf pend? │◄────────┘ если out_buf пуст → shutdown(SHUT_WR)
│ ждём flush │ иначе ждём sock_retry_cb → shutdown
└──────┬───────┘
│
└──────────────┐
▼
┌──────────────────┐
│ cli && sock │ оба закрыты + очереди пусты
│ очереди пусты │
└────────┬─────────┘
▼
┌──────────────┐
│ FREE │ conn_free(rc)
└──────────────┘
ДОП. СОСТОЯНИЯ:
┌─────────────────┐ send_msg в ETCP вернул -1
│ PAUSED │ буферизуем в pause_buf, убираем read_id (пауза чтения)
│ pause_buf>0 │ etcp_router_on_send_ready() → ждём освобождения
│ pause_waiter │ waiter_cb: retry send_msg → resume read_id
└─────────────────┘
┌─────────────────┐ CLOSE/ERROR не доставлен через ETCP
│ CLOSE_PENDING │ close_timer с backoff 50..5000 tb → ретрай
│ close_pending=1│
└─────────────────┘
```
---
### 4. DATA FLOW
```
══════════════════════════════════════════════════════════════════════
APP → lwIP → recv_cb → ETCP → handle → socket → DEST
APP ← lwIP ← tcp_write ← ETCP ← read_cb ← socket ← DEST
══════════════════════════════════════════════════════════════════════
CLIENT (узел A) SERVER (узел B)
APP (через TUN) DEST (реальный)
│ │
▼ TCP SYN/ACK (lwIP) ▼ TCP SYN/ACK (OS)
┌──────────┐ ┌──────────┐
│ lwIP │ │ socket │
│ TCP │ │ (неблок) │
└────┬─────┘ └────┬─────┘
│ recv_cb │ read_cb
│ │
┌────▼────────────┐ ┌────▼───────────────┐
│ tcp_proxy │ ETCP │ tcp_proxy │
│ _client │◄═══════════════════════►│ _server │
│ │ │ │
│ ┌────────────┐ │ │ ┌──────────────┐ │
│ │ recv_cb │ │──CONNECT+DATA──► │ │handle_connect│ │
│ │(lwIP→ETCP) │ │ │ │handle_data │ │
│ │ │ │ backpressure: │ └──────┬───────┘ │
│ │ send fail: │ │ waiter на send_q │ │ sock_send │
│ │ tx_buf+ │ │ │ ┌──────▼───────┐ │
│ │ waiter │ │ │ │ out_buf │ │
│ │ окно lwIP=0│ │ │ │ pending_buf │ │
│ └────────────┘ │ │ └─────────────┘ │
│ │ │ │ send() │
│ ┌────────────┐ │ │ ┌──────▼───────┐ │
│ │handle_data │ │◄────DATA──────── │ │ read_cb │ │
│ │handle_close│ │ │ │(sock→ETCP) │ │
│ └─────┬──────┘ │ │ │ │ │
│ │ │ │ │ send fail: │ │
│ ┌─────▼──────┐ │ │ │ pause_buf+ │ │
│ │ to_lwip │ │ │ │ waiter+ │ │
│ │ (очередь) │ │ │ │ пауза read_id│ │
│ └─────┬──────┘ │ │ └─────────────┘ │
│ │tcp_write│ │ │
│ ▼ │ └───────────────────┘
│ lwIP→TUN→APP │
└─────────────────┘
```
---
### 5. BACKPRESSURE (lwIP→ETCP и socket→ETCP)
```
══════════════════════════════════════════════════════════════════════
Используется встроенный механизм ll_queue threshold waiter (на router send_q):
etcp_router_on_send_ready(inst, group_id, node_id, svc_id, &waiter, cb, arg)
→ внутри: queue_waiter_wait(rconn->send_q, &waiter)
etcp_router_cancel_send_ready(inst, group_id, node_id, svc_id, &waiter)
CLIENT (lwIP → ETCP):
recv_cb(buf, len):
ret = send_data(buf, len)
if ret == 0: tcp_recved(len)
if ret < 0:
tx_buf = copy(buf), tx_len = len
НЕ tcp_recved() → окно lwIP = 0, recv_cb больше не вызывается
etcp_router_on_send_ready(inst, group_id, via_node_id, ETCP_RT_ID_TCP_PROXY_SERVER, &tx_waiter, tx_waiter_cb, pc)
tx_waiter callback:
ret = send_data(tx_buf, tx_len)
if ret == 0:
tcp_recved(tx_len) → окно открыто, lwIP возобновляет recv_cb
free(tx_buf)
if tun_closed → send_close()
// если ret < 0 → waiter остаётся, router вызовет снова
SERVER (socket → ETCP):
read_cb(buf, n):
ret = send_msg(DATA, buf, n)
if ret == 0: ok
if ret < 0:
pause_buf = copy(buf), pause_len = n
uasync_remove_socket_t(sock) → пауза чтения с сокета
etcp_router_on_send_ready(inst, group_id, peer_node_id, ETCP_RT_ID_TCP_PROXY_CLIENT, &pause_waiter, pause_waiter_cb, rc)
pause_waiter callback:
ret = send_msg(DATA, pause_buf, pause_len)
if ret == 0:
free(pause_buf)
uasync_add_socket_t(sock) → возобновляем чтение
if sock_closed → send_close()
// если ret < 0 → waiter остаётся
```
---
### 6. Последовательность: HANDSHAKE (упрощённый, без CONNECTED)
```
CLIENT (lwIP) CLIENT PROXY ETCP SERVER PROXY SOCKET
─────────────────────────────────────────────────────────────────────────────────────────
tcp_proxy_client_accept_cb() CONNECT
├─ u_calloc(conn) ────────────────────────────→ tcp_proxy_server_handle_connect()
├─ stream_id++ ├─ socket(AF_INET,SOCK_STREAM)
├─ send_connect() ├─ socket_set_nonblocking()
│ [dest_ip(4)+dest_port(2)] ├─ uasync_add_socket_t(read/write/error)
├─ conn→conns list ├─ connect(неблок.) → EINPROGRESS
│ └─ rc→conns list
▼
ACTIVE — данные идут СРАЗУ (без ожидания CONNECTED)
│
│ DATA ...connect завершён...
├──────────────────────────────────────────────────→ tcp_proxy_server_sock_write_cb()
│ ├─ connected=1
│ DATA └─ flush pending_buf в сокет
├──────────────────────────────────────────────────→
│
│ DATA ←─ tcp_proxy_server_sock_read_cb()
│◄────────────────────────────────────────────── (socket recv → ETCP)
│
└──→ трафик идёт в обе стороны
При ошибке connect:
...connect fail... → tcp_proxy_server_send_error() → client получает ERROR, error=1, cleanup
```
---
### 7. Последовательность: CLOSE (drain очередей → close в конце)
```
CLIENT (lwIP) CLIENT PROXY ETCP SERVER PROXY SOCKET
─────────────────────────────────────────────────────────────────────────────────────────
══ Вариант А: lwIP закрывает первым ══
tcp_proxy_client_recv_cb(p=NULL)
├─ tun_closed=1
│ если tx_buf пуст → send_close CLOSE
│ если tx_buf pend → waiter досылает ──────────→ tcp_proxy_server_handle_close()
│ потом send_close ├─ cli_closed=1
└─ │ если out_buf пуст → shutdown(SHUT_WR)
│ иначе ждём sock_retry_cb
│
...out_buf дослан...
sock_retry_cb:
shutdown(SHUT_WR)
│
...сокет дочитывает остаток...
│
tcp_proxy_server_sock_read_cb(n=0)
├─ sock_closed=1
│ если pause_buf пуст → send_close
│ иначе ждём waiter_cb
│
CLOSE │
tcp_proxy_client_handle_close() ◄────────────────────────┘
└─ rem_closed=1
│
poll_cb: tun=1, rem=1, to_lwip пуст, unsent=NULL, unacked=NULL
├─ tcp_close(pcb)
└─ conn_free()
══ Вариант Б: серверный сокет закрывается первым ══
tcp_proxy_server_sock_read_cb(n=0)
├─ sock_closed=1
│ если out_buf+pause_buf пусты → send_close
│ иначе ждём retry/waiter
CLOSE │
tcp_proxy_client_handle_close() ◄────────────────────────┘
└─ rem_closed=1
│
▼
...приложение закрывает TCP...
│
tcp_proxy_client_recv_cb(p=NULL)
├─ tun_closed=1
│ если tx_buf пуст → send_close
└─
poll_cb: оба закрыты, буферы пусты → cleanup
══ Вариант В: Ошибка / аномалия ══
ERROR
─────────────────────── (или ←) ──────────
error=1
│
▼
poll_cb: tcp_abort(pcb) → немедленная очистка
или: rc→error=1 → conn_free
══ Вариант Г: conn не найден / closed / error ══
Любой пришедший пакет (DATA/CLOSE/ERROR) для conn в закрытом состоянии:
→ tcp_proxy_*_send_msg(ERROR, stream_id)
→ удалённая сторона получает ERROR и немедленно закрывается
```
---
### 8. Диспетчеризация в `tcp_proxy_client_etcp_recv_cb()`
```
┌────────────────────────────────────────────────────────────────────────┐
│ tcp_proxy_client_etcp_recv_cb() │
│ (единая точка входа для обоих направлений) │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ subcmd == CONNECT? │
│ └── tcp_proxy_server_handle_connect() ←── всегда сервер │
│ │
│ stream_id в server.conns? (если server.enabled) │
│ ├── DATA → tcp_proxy_server_handle_data() │
│ ├── CLOSE → tcp_proxy_server_handle_close() │
│ └── ERROR → rc->error=1; conn_free() │
│ │
│ stream_id в client.conns? (если client proxy != NULL) │
│ ├── DATA → tcp_proxy_client_handle_data() │
│ ├── CLOSE → tcp_proxy_client_handle_close() │
│ └── ERROR → tcp_proxy_client_handle_error() │
│ │
│ Приоритет: │
│ 1. CONNECT всегда интерпретируется сервером │
│ 2. Если server.enabled И stream_id найден в server.conns → server │
│ 3. Иначе если client proxy != NULL → client │
│ 4. Иначе drop │
└────────────────────────────────────────────────────────────────────────┘
```
---
### 9. INIT
```
CLIENT SERVER
─────────────────────────────────────────────────────────
utun_instance.c
│
├─ tcp_proxy_client_create() tcp_proxy_server_init()
│ ├─ tun_init_nat() (TUN iface) ├─ memset(ctx,0)
│ ├─ lwip_tcp_init() (TCP стек) ├─ ctx->enabled = config ...
│ ├─ etcp_router_bind( ├─ etcp_router_bind(
│ │ ETCP_ID_TCP_PROXY, │ ETCP_ID_TCP_PROXY,
│ │ tcp_proxy_client_etcp_recv_cb) │ tcp_proxy_client_etcp_recv_cb)
│ ├─ udp_proxy_init() ├─ udp_proxy_init()
│ └─ icmp_proxy_init() └─ icmp_proxy_init() (если client не активен)
│
└─ ОБА слушают ETCP_ID_TCP_PROXY через один обработчик
```
---
### 10. Поля структур
```
tcp_proxy_client_conn:
stream_id — уникальный ID потока
tun_closed — lwIP/FIN (сторона TUN)
rem_closed — CLOSE от exit
error — немедленная очистка
close_sent — CLOSE/ERROR отправлен
close_pending — отправка не удалась, ретрай
to_lwip — очередь DATA от exit → lwIP (feed_from_transport)
pcb — lwIP tcp_pcb
tx_buf — буфер при backpressure lwIP→ETCP (send_data fail)
tx_len — длина tx_buf
tx_waiter — waiter на router send_q (etcp_router_on_send_ready)
tcp_proxy_server_conn:
stream_id — уникальный ID потока
cli_closed — клиент прислал CLOSE
sock_closed — сокет получил EOF
error — ошибка
connected — сокет подключился (connect завершён)
close_pending — CLOSE/ERROR не доставлен, ретрай
pending_buf — буфер данных от клиента до завершения connect
out_buf — буфер отправки в сокет (неблокирующий send, EAGAIN retry)
out_off/out_len — offset/размер out_buf
out_timer — таймер повтора send в сокет
out_backoff — backoff для retry send в сокет
pause_buf — буфер при backpressure socket→ETCP (send_msg fail)
pause_len — длина pause_buf
pause_waiter — waiter на router send_q (etcp_router_on_send_ready)
close_timer — таймер повтора CLOSE/ERROR
close_backoff — backoff для retry CLOSE/ERROR
sock — OS сокет к адресату
read_id — handle uasync для событий сокета
```
---
### 11. etcp_router waiter API
```
// etcp_router.h
void etcp_router_on_send_ready(struct UTUN_INSTANCE* inst, uint64_t group_id,
uint64_t node_id, uint8_t svc_id, struct queue_waiter_handle* h,
queue_threshold_callback_fn cb, void* arg);
void etcp_router_cancel_send_ready(struct UTUN_INSTANCE* inst, uint64_t group_id,
uint64_t node_id, uint8_t svc_id, struct queue_waiter_handle* h);
// Внутри:
// rconn = router_conn_find(inst, group_id, node_id, svc_id)
// queue_waiter_wait(rconn->send_q, h, cb, arg)
// queue_waiter_cancel(rconn->send_q, h)
//
// send_q — очередь router conn при полном inflight
// (threshold = ROUTER_MAX_SEND_Q_PACKETS - 1 в router_conn_reset)
```
Контракты функций, буферов и полей HTTP codec — в [http_proxy.h](http_proxy.h).